Repository: AI4S2S/lilio

Issue:
Unclear error message when an empty calendar is passed to `resample` function.
When executing the following code:
```py
import lilio
from lilio import Calendar
import xarray as xr

ds = xr.load_dataset(path_to_data) # just some sample data
cal = Calendar("12-25") 
# note that no intervals are added to the calendar
# cal.add_intervals("target", length="7d")
# cal.add_intervals("precursor", length="7d")
cal.map_to_data(ds)
data_resampled = lilio.resample(cal, ds)
```

This will trigger the following `ValueError`

```py
File [~/AI4S2S/lilio/lilio/resampling.py:342](https://vscode-remote+wsl-002bubuntu-002d20-002e04.vscode-resource.vscode-cdn.net/home/yangliu/AI4S2S/lilio/docs/notebooks/~/AI4S2S/lilio/lilio/resampling.py:342), in resample(calendar, input_data, how)
    340     _check_valid_resampling_methods(how)
    341 utils.check_timeseries(input_data)
--> 342 utils.check_input_frequency(calendar, input_data)
    343 utils.check_reserved_names(input_data)
    345 if isinstance(input_data, (pd.Series, pd.DataFrame)):

File [~/AI4S2S/lilio/lilio/utils.py:158](https://vscode-remote+wsl-002bubuntu-002d20-002e04.vscode-resource.vscode-cdn.net/home/yangliu/AI4S2S/lilio/docs/notebooks/~/AI4S2S/lilio/lilio/utils.py:158), in check_input_frequency(calendar, data)
    151 """Compare the frequency of (input) data to the frequency of the calendar.
    152 
    153 Note: Pandas and xarray have the builtin function `infer_freq`, but this function is
    154 not robust enough for our purpose, so we have to manually infer the frequency if the
    155 builtin one fails.
    156 """
    157 data_freq = infer_input_data_freq(data)
--> 158 calendar_freq = get_smallest_calendar_freq(calendar)
    160 if calendar_freq < data_freq:
    161     raise ValueError(
    162         "The data is of a lower time resolution than the calendar. "
    163         "This would lead to incorrect data and/or NaN values in the resampled data."
   (...)
    167         f"{str(calendar_freq)}"
    168     )

File [~/AI4S2S/lilio/lilio/utils.py:145](https://vscode-remote+wsl-002bubuntu-002d20-002e04.vscode-resource.vscode-cdn.net/home/yangliu/AI4S2S/lilio/docs/notebooks/~/AI4S2S/lilio/lilio/utils.py:145), in get_smallest_calendar_freq(calendar)
    143 lengthstr = [replace_month_length(ln) if ln[-1] == "M" else ln for ln in lengthstr]
    144 lengths = [pd.Timedelta(ln) for ln in lengthstr]
--> 145 return min(lengths)

ValueError: min() arg is an empty sequence
```

The error message is not informative. It should remind the user to add intervals to the calendar. This needs to be fixed in the check for `resample` function, in `utils` module.

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=AI4S2S_lilio&pullRequest=58)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=AI4S2S_lilio&pullRequest=58)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=58&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=58&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/lilio/calendar.py b/lilio/calendar.py
index c288ebb..93ee1db 100644
--- a/lilio/calendar.py
+++ b/lilio/calendar.py
@@ -206,10 +206,15 @@ class Calendar:
         self._set_mapping(mapping)
 
     @property
-    def n_targets(self):
+    def n_targets(self) -> int:
         """Return the number of targets."""
         return len(self.targets)
 
+    @property
+    def n_precursors(self) -> int:
+        """Return the number of precursors."""
+        return len(self.precursors)
+
     @property
     def anchor(self):
         """Return the anchor."""
diff --git a/lilio/resampling.py b/lilio/resampling.py
index b426dbd..aa729d5 100644
--- a/lilio/resampling.py
+++ b/lilio/resampling.py
@@ -335,6 +335,8 @@ def resample(
     """
     if calendar.mapping is None:
         raise ValueError("Generate a calendar map before calling resample")
+    if calendar.n_targets + calendar.n_precursors == 0:
+        raise ValueError("The calendar has no intervals: resampling is not possible.")
 
     if isinstance(how, str):
         _check_valid_resampling_methods(how)


Test patch:
diff --git a/tests/test_resample.py b/tests/test_resample.py
index c39bd68..b68441f 100644
--- a/tests/test_resample.py
+++ b/tests/test_resample.py
@@ -326,6 +326,24 @@ class TestResampleChecks:
         with pytest.raises(ValueError, match=r".*reserved names..*"):
             resample(cal, dummy_dataframe.rename(columns={"data1": "anchor_year"}))
 
+    def test_empty_calendar(self, dummy_dataframe):
+        cal = Calendar(anchor="Jan")
+        cal.map_to_data(dummy_dataframe)
+        with pytest.raises(ValueError, match=r".*calendar has no intervals.*"):
+            resample(cal, dummy_dataframe)
+
+    def test_single_target(self, dummy_dataframe):
+        cal = Calendar(anchor="Jan")
+        cal.add_intervals("target", length="7d")
+        cal.map_to_data(dummy_dataframe)
+        resample(cal, dummy_dataframe)
+
+    def test_single_precursor(self, dummy_dataframe):
+        cal = Calendar(anchor="Jan")
+        cal.add_intervals("precursor", length="7d")
+        cal.map_to_data(dummy_dataframe)
+        resample(cal, dummy_dataframe)
+
 
 class TestResampleMethods:
     """Test alternative resampling methods.