Repository: AI4S2S/lilio

Issue:
Add (more) attributes to xarray `resample` output
As xarray uses the structure of netCDF files, there is quite a lot of space to add extra information, in the form of "attributes".

For provenance and traceability, we can make use of this in Lilio. This can consist of, but is not limited to:

- Adding attributes to all coordinates to explain what they are (e.g. anchor_year). [left_bound and right_bound currently do have a description in the attrs](https://github.com/AI4S2S/lilio/blob/d0923997b4fd8cdb7ea41712006f6055ae7deb0d/lilio/utils.py#L167-L174). 
- Adding the calendar itself to the xr.Dataset/xr.DataArray main attrs.


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=49)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&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=49)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&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=49)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&metric=new_duplicated_lines_density&view=list)


BSchilperoort: I saw that pandas also [supports adding attributes to `DataFrame`s](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.attrs.html), however this features is marked as "experimental" and can change without warning, so it might still be too early to support this.
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=49)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AI4S2S_lilio&pullRequest=49&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=49&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=49&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&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=49&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=AI4S2S_lilio&pullRequest=49&metric=new_duplicated_lines_density&view=list)


BSchilperoort: Thanks for the review and suggestion!

Patch:
diff --git a/lilio/__init__.py b/lilio/__init__.py
index f00a7de..ef989aa 100644
--- a/lilio/__init__.py
+++ b/lilio/__init__.py
@@ -62,8 +62,8 @@ Example:
 """
 
 import logging
-from . import calendar_shifter
-from . import traintest
+from lilio import calendar_shifter
+from lilio import traintest
 from .calendar import Calendar
 from .calendar import Interval
 from .calendar_shorthands import daily_calendar
diff --git a/lilio/_attrs.py b/lilio/_attrs.py
new file mode 100644
index 0000000..155ae00
--- /dev/null
+++ b/lilio/_attrs.py
@@ -0,0 +1,52 @@
+from datetime import datetime
+from datetime import timezone
+from typing import Union
+import xarray as xr
+import lilio
+from lilio.calendar import Calendar
+
+
+def add_attrs(data: Union[xr.DataArray, xr.Dataset], calendar: Calendar) -> None:
+    """Update resampled xarray data with the Calendar's attributes and provenance."""
+    history = (
+        f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M:%S %Z} - "
+        "Resampled with a Lilio calendar. "
+        "See: https://github.com/AI4S2S/lilio\n"
+    )
+    if "history" in data.attrs.keys():
+        history += data.attrs["history"]
+
+    data.attrs = {
+        **data.attrs,  # Keep original attrs. Conflicts will be overwritten attrs below:
+        "lilio_version": lilio.__version__,
+        "lilio_calendar_anchor_date": calendar.anchor,
+        "lilio_calendar_code": str(calendar),
+        "history": history,
+    }
+
+    data["anchor_year"].attrs = {
+        "name": "anchor year",
+        "units": "year",
+        "description": (
+            "The anchor date is the start of the period you want to forecast, and is "
+            "an abstract date and does not include a year. "
+            "Anchor years are used to create a full date with the anchor date "
+            f"(here: {calendar.anchor})."
+        ),
+    }
+    data["i_interval"].attrs = {
+        "name": "interval index",
+        "units": "-",
+        "description": (
+            "The index of each Lilio Calendar interval. Positive values denote "
+            "intervals after the anchor date (targets), while negative values "
+            "represent intervals before the anchor date (precursors)."
+        ),
+    }
+    data["is_target"].attrs = {
+        "name": "Target flag",
+        "description": (
+            "Denotes if an interval was marked as a target interval in the"
+            " Lilio Calendar."
+        ),
+    }
diff --git a/lilio/calendar.py b/lilio/calendar.py
index 95279cc..0b32e06 100644
--- a/lilio/calendar.py
+++ b/lilio/calendar.py
@@ -10,8 +10,8 @@ from typing import Union
 import pandas as pd
 import xarray as xr
 from pandas.tseries.offsets import DateOffset
-from . import _plot
-from . import utils
+from lilio import _plot
+from lilio import utils
 
 
 _MappingYears = Tuple[Literal["years"], int, int]
diff --git a/lilio/calendar_shifter.py b/lilio/calendar_shifter.py
index e7fce9c..ce30769 100644
--- a/lilio/calendar_shifter.py
+++ b/lilio/calendar_shifter.py
@@ -4,8 +4,8 @@ from typing import Dict
 from typing import List
 from typing import Union
 import xarray as xr
-from . import calendar
-from . import utils
+from lilio import calendar
+from lilio import utils
 from .resampling import resample
 
 
diff --git a/lilio/resampling.py b/lilio/resampling.py
index f3a449c..2d29ce0 100644
--- a/lilio/resampling.py
+++ b/lilio/resampling.py
@@ -7,8 +7,9 @@ from typing import overload
 import numpy as np
 import pandas as pd
 import xarray as xr
+from lilio import utils
+from lilio._attrs import add_attrs
 from lilio.calendar import Calendar
-from . import utils
 
 
 # List of numpy statistical methods, with a single input argument and a single output.
@@ -356,5 +357,11 @@ def resample(
     resampled_data = _mark_target_period(resampled_data)
 
     if isinstance(input_data, xr.DataArray):
-        return resampled_data[da_name]
+        resampled_dataarray = resampled_data[da_name]
+        resampled_dataarray.attrs = input_data.attrs
+        add_attrs(resampled_dataarray, calendar)
+        return resampled_dataarray
+    if isinstance(input_data, xr.Dataset):
+        resampled_data.attrs = input_data.attrs
+        add_attrs(resampled_data, calendar)
     return resampled_data
diff --git a/pyproject.toml b/pyproject.toml
index 52b93b5..7ae6b05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -128,7 +128,7 @@ select = [
   "F",  # pyflakes
   "B",  # flake8-bugbear
   "D",  # pydocstyle
-  "C",  # mccabe complexity
+  "C90",  # mccabe complexity
 #  "I",  # isort (autosort not working correctly, disabled for now).
   "N",  # PEP8-naming
   "UP",  # pyupgrade (upgrade syntax to current syntax)
@@ -140,12 +140,13 @@ extend-select = [
   "D401",  # First line should be in imperative mood
   "D400",  # First line should end in a period.
   "D404",  # First word of the docstring should not be 'This'
+  "TID252",  # No relative imports (not pep8 compliant)
 ]
 ignore = [
   "PLR2004",  # magic value used in comparsion (i.e. `if ndays == 28: month_is_feb`).
 ]
 # Allow autofix for all enabled rules (when `--fix`) is provided.
-fixable = ["A", "B", "C", "D", "E", "F"]
+fixable = ["A", "B", "C90", "D", "E", "F", "TID252"]
 unfixable = []
 line-length = 88
 exclude = ["docs", "build"]


Test patch:
diff --git a/tests/test_resample.py b/tests/test_resample.py
index 59fed6b..b50380f 100644
--- a/tests/test_resample.py
+++ b/tests/test_resample.py
@@ -209,6 +209,45 @@ class TestResample:
         with pytest.warns(UserWarning):
             resample(cal, dataset)
 
+    def test_dataset_attrs(self, dummy_calendar, dummy_dataset):
+        dataset, _ = dummy_dataset
+        dataset.attrs = {"history": "test_history", "other_attrs": "abc"}
+        cal = dummy_calendar.map_years(2020, 2025)
+        resampled = resample(cal, dataset)
+
+        expected_attrs = [
+            "lilio_version",
+            "lilio_calendar_anchor_date",
+            "lilio_calendar_code",
+        ]
+
+        assert "test_history" in resampled.attrs["history"]
+        assert "other_attrs" in resampled.attrs.keys()
+        for att in expected_attrs:
+            assert att in resampled.attrs.keys()
+
+    def test_dataarray_attrs(self, dummy_calendar, dummy_dataarray):
+        """This is a copy of the previous test, but with dataarray input.
+
+        Sadly, fixtures aren't compatible with parameterize. Refactoring the fixtures
+        could solve this.
+        """
+        dataarray, _ = dummy_dataarray
+        dataarray.attrs = {"history": "test_history", "other_attrs": "abc"}
+        cal = dummy_calendar.map_years(2020, 2025)
+        resampled = resample(cal, dataarray)
+
+        expected_attrs = [
+            "lilio_version",
+            "lilio_calendar_anchor_date",
+            "lilio_calendar_code",
+        ]
+
+        assert "test_history" in resampled.attrs["history"]
+        assert "other_attrs" in resampled.attrs.keys()
+        for att in expected_attrs:
+            assert att in resampled.attrs.keys()
+
 
 TOO_LOW_FREQ_ERR = r".*lower time resolution than the calendar.*"
 TOO_LOW_FREQ_WARN = r".*input data frequency is very close to the Calendar's freq.*"