diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f37e4..38cd742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.1.7 - 2025-01-02 + +* Export version as `earthkit.time.__version__` +* Add options for relative year range in climatology tools + ## 0.1.6 - 2024-11-04 * Update project metadata diff --git a/docs/cli/climdates.rst b/docs/cli/climdates.rst index 2166da0..0021d89 100644 --- a/docs/cli/climdates.rst +++ b/docs/cli/climdates.rst @@ -21,7 +21,7 @@ here, as follows:: Compute climatological date ranges, one day per year in a given range:: - earthkit-climdates range [--sep ] (--from-date | --from-year ) (--to-date | --to-year ) + earthkit-climdates range [--sep ] (--from-date | --from-year | --from-rel-year ) (--to-date | --to-year | --to-rel-year ) The list is printed using the given separator, as documented in :ref:`cli_sep`. @@ -33,6 +33,11 @@ The list is printed using the given separator, as documented in :ref:`cli_sep`. Return dates starting from this year +.. option:: --from-rel-year + + Return dates starting from this number after the year in :option:`date` (e.g. + ``--from-rel-year -5`` will start 5 years earlier) + .. option:: --to-date Return dates up to this one @@ -41,6 +46,11 @@ The list is printed using the given separator, as documented in :ref:`cli_sep`. Return dates up to this year +.. option:: --to-rel-year + + Return dates up to this number after the year in :option:`date` (e.g. + ``--to-rel-year -1`` will end in the year before) + .. option:: date The date to use as a reference (YYYYMMDD) @@ -54,7 +64,7 @@ recurring source (e.g. twice a week). Usage:: - earthkit-climdates mclim [--sep ] (--from-date | --from-year ) (--to-date | --to-year ) --before --after + earthkit-climdates mclim [--sep ] (--from-date | --from-year | --from-rel-year ) (--to-date | --to-year | --to-rel-year ) --before --after The sequence is described as documented in :ref:`cli_seq`. The list is printed using the given separator, as documented in :ref:`cli_sep`. @@ -67,6 +77,11 @@ using the given separator, as documented in :ref:`cli_sep`. Return dates starting from this year +.. option:: --from-rel-year + + Return dates starting from this number after the year of the current date + (e.g. ``--from-rel-year -5`` will start 5 years earlier) + .. option:: --to-date Return dates up to this one @@ -83,6 +98,11 @@ using the given separator, as documented in :ref:`cli_sep`. Pick up all inputs up to *num* days after the chosen date +.. option:: --to-rel-year + + Return dates up to this number after the year of the current date (e.g. + ``--to-rel-year -1`` will end in the year before) + .. option:: date The date to use as a reference (YYYYMMDD) diff --git a/docs/guide/api.rst b/docs/guide/api.rst index d1469c9..812d7d1 100644 --- a/docs/guide/api.rst +++ b/docs/guide/api.rst @@ -143,6 +143,9 @@ To get one date per year on the same day as a given reference, use 20001023, 20011023, 20021023, 20031023, 20041023, 20051023 >>> print_dates(date_range(date(2005, 6, 2), date(2002, 6, 8), date(2004, 7, 1))) 20030602, 20040602 + >>> from earthkit.time import RelativeYear + >>> print_dates(date_range(date(2010, 8, 5), RelativeYear(-3), RelativeYear(-1))) + 20070805, 20080805, 20090805 To combine yearly dates with multiple reference dates taken from a sequence, use :meth:`~earthkit.time.climatology.model_climate_dates`: @@ -153,3 +156,5 @@ To combine yearly dates with multiple reference dates taken from a sequence, use >>> seq = Sequence.from_resource("ecmwf-mon-thu") >>> print_dates(model_climate_dates(date(2023, 8, 6), 2018, 2020, 7, 7, seq)) 20180731, 20180803, 20180807, 20180810, 20190731, 20190803, 20190807, 20190810, 20200731, 20200803, 20200807, 20200810 + >>> print_dates(model_climate_dates(date(2023, 1, 1), RelativeYear(-7), RelativeYear(-4), 5, 5, seq)) + 20151229, 20160102, 20160105, 20161229, 20170102, 20170105, 20171229, 20180102, 20180105, 20181229, 20190102, 20190105 diff --git a/docs/guide/cli.rst b/docs/guide/cli.rst index 47e2cd4..de413dd 100644 --- a/docs/guide/cli.rst +++ b/docs/guide/cli.rst @@ -150,6 +150,8 @@ To get one date per year on the same day as a given reference, use 20001023, 20011023, 20021023, 20031023, 20041023, 20051023 $ earthkit-climdates range --sep ", " --from-date 20020608 --to-date 20040701 20050602 20030602, 20040602 + $ earthkit-climdates range --sep ", " --from-rel-year -3 --to-rel-year -1 20100805 + 20070805, 20080805, 20090805 To combine yearly dates with multiple reference dates taken from a sequence, use ``earthkit-climdates mclim``: @@ -158,3 +160,5 @@ To combine yearly dates with multiple reference dates taken from a sequence, use $ earthkit-climdates mclim --sep ", " --from-year 2018 --to-year 2020 --before 7 --after 7 --preset ecmwf-mon-thu 20230806 20180731, 20180803, 20180807, 20180810, 20190731, 20190803, 20190807, 20190810, 20200731, 20200803, 20200807, 20200810 + $ earthkit-climdates mclim --sep ", " --from-rel-year -7 --to-rel-year -4 --before 5 --after 5 --preset ecmwf-mon-thu 20230101 + 20151229, 20160102, 20160105, 20161229, 20170102, 20170105, 20171229, 20180102, 20180105, 20181229, 20190102, 20190105 diff --git a/pyproject.toml b/pyproject.toml index 6daf64f..e8dab13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ requires = ["setuptools>=65", "wheel"] build-backend = "setuptools.build_meta" [project] +dynamic = ["version"] name = "earthkit-time" -version = "0.1.6" requires-python = ">= 3.8" description = "Date and time manipulation routines for the use of weather data" license = {file = "LICENSE"} @@ -58,6 +58,9 @@ testpaths = [ ] consider_namespace_packages = true +[tool.setuptools.dynamic] +version = {attr = "earthkit.time.__version__"} + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/earthkit/time/__init__.py b/src/earthkit/time/__init__.py index 891ccd2..08d4c1a 100644 --- a/src/earthkit/time/__init__.py +++ b/src/earthkit/time/__init__.py @@ -1,4 +1,4 @@ -from .climatology import date_range, model_climate_dates +from .climatology import RelativeYear, date_range, model_climate_dates from .sequence import ( DailySequence, MonthlySequence, @@ -7,7 +7,11 @@ YearlySequence, ) +__version__ = "0.1.7" + __all__ = [ + "__version__", + "RelativeYear", "date_range", "model_climate_dates", "DailySequence", diff --git a/src/earthkit/time/cli/cliargs.py b/src/earthkit/time/cli/cliargs.py index 921f741..0c1a8c5 100644 --- a/src/earthkit/time/cli/cliargs.py +++ b/src/earthkit/time/cli/cliargs.py @@ -3,6 +3,7 @@ from typing import List, Tuple from ..calendar import Weekday, parse_date, parse_mmdd, to_weekday +from ..climatology import RelativeYear from ..sequence import ( DailySequence, MonthlySequence, @@ -159,3 +160,7 @@ def add_sep_arg(parser: argparse.ArgumentParser): default="\n", help="output separator, see SEPARATORS for special values", ) + + +def relative_year(arg: str): + return RelativeYear(int(arg)) diff --git a/src/earthkit/time/cli/climatology.py b/src/earthkit/time/cli/climatology.py index bd11980..ab07d2d 100644 --- a/src/earthkit/time/cli/climatology.py +++ b/src/earthkit/time/cli/climatology.py @@ -11,6 +11,7 @@ add_sep_arg, add_sequence_args, create_sequence, + relative_year, ) from .cliout import format_date_list @@ -53,12 +54,24 @@ def get_parser() -> argparse.ArgumentParser: range_start_group.add_argument( "--from-year", type=int, dest="start", help="starting year" ) + range_start_group.add_argument( + "--from-rel-year", + type=relative_year, + dest="start", + help="starting year, relative to `date`", + ) range_end_group = range_action.add_mutually_exclusive_group(required=True) range_end_group.add_argument( "--to-date", type=parse_date, dest="end", help="ending date" ) range_end_group.add_argument("--to-year", type=int, dest="end", help="ending year") + range_end_group.add_argument( + "--to-rel-year", + type=relative_year, + dest="end", + help="ending year, relative to `date`", + ) add_sep_arg(range_action) @@ -86,12 +99,24 @@ def get_parser() -> argparse.ArgumentParser: mclim_start_group.add_argument( "--from-year", type=int, dest="start", help="starting year" ) + mclim_start_group.add_argument( + "--from-rel-year", + type=relative_year, + dest="start", + help="starting year, relative to `date`", + ) mclim_end_group = mclim_action.add_mutually_exclusive_group(required=True) mclim_end_group.add_argument( "--to-date", type=parse_date, dest="end", help="ending date" ) mclim_end_group.add_argument("--to-year", type=int, dest="end", help="ending year") + mclim_end_group.add_argument( + "--to-rel-year", + type=relative_year, + dest="end", + help="ending year, relative to `date`", + ) mclim_action.add_argument( "--before", diff --git a/src/earthkit/time/climatology.py b/src/earthkit/time/climatology.py index 580d84f..b26e38c 100644 --- a/src/earthkit/time/climatology.py +++ b/src/earthkit/time/climatology.py @@ -1,5 +1,6 @@ """Date utilities to build a climatology""" +from dataclasses import dataclass from datetime import date, timedelta from typing import Iterator, Union @@ -7,10 +8,22 @@ from .utilities import merge_sorted +@dataclass +class RelativeYear: + """Wrapper for a year intended to be relative to a reference""" + + value: int + + def relative_to(self, reference: Union[date, int]) -> int: + if isinstance(reference, date): + reference = reference.year + return reference + self.value + + def date_range( reference: date, - start: Union[date, int], - end: Union[date, int], + start: Union[date, int, RelativeYear], + end: Union[date, int, RelativeYear], recurrence: str = "yearly", include_endpoint: bool = True, ) -> Iterator[date]: @@ -24,10 +37,10 @@ def date_range( reference: :class:`datetime.date` Reference date setting the fixed part in the sequence (e.g., month and day for a yearly recurrence) - start: :class:`datetime.date` or int + start: :class:`datetime.date`, int, or :class:`RelativeYear` Start of the period. Either a full date or a meaningful identifier (e.g. year for a yearly recurrence) - end: :class:`datetime.date` or int + end: :class:`datetime.date`, int, or :class:`RelativeYear` End of the period. Included in the sequence unless ``include_endpoint`` is ``False`` recurrence: "yearly" @@ -48,6 +61,8 @@ def date_range( [datetime.date(1999, 4, 12), datetime.date(2000, 4, 12), datetime.date(2001, 4, 12)] >>> list(date_range(date(2014, 8, 23), date(2010, 8, 16), date(2012, 8, 1))) [datetime.date(2010, 8, 23), datetime.date(2011, 8, 23)] + >>> list(date_range(date(2014, 8, 23), RelativeYear(-3), RelativeYear(-1))) + [datetime.date(2011, 8, 23), datetime.date(2012, 8, 23), datetime.date(2013, 8, 23)] """ _known_recurrences = ["yearly"] @@ -59,9 +74,13 @@ def date_range( if reference.month == 2 and reference.day == 29: reference = reference.replace(day=28) + if isinstance(start, RelativeYear): + start = start.relative_to(reference) if not isinstance(start, date): start = reference.replace(year=start) + if isinstance(end, RelativeYear): + end = end.relative_to(reference) if not isinstance(end, date): end = reference.replace(year=end) @@ -71,8 +90,8 @@ def date_range( def model_climate_dates( reference: date, - start: Union[date, int], - end: Union[date, int], + start: Union[date, int, RelativeYear], + end: Union[date, int, RelativeYear], before: Union[timedelta, int], after: Union[timedelta, int], sequence: Sequence, @@ -88,9 +107,9 @@ def model_climate_dates( ---------- reference: :class:`datetime.date` Reference date for the climate - start: :class:`datetime.date` or int + start: :class:`datetime.date`, int, or :class:`RelativeYear` Start of the climatological period. Either a full date or a year - end: :class:`datetime.date` or int + end: :class:`datetime.date`, int, or :class:`RelativeYear` End of the climatological period. Either a full date or a year before: :class:`datetime.timedelta` or int Cut-off before the reference date. Either a timedelta or a number of @@ -108,7 +127,7 @@ def model_climate_dates( Examples -------- >>> from earthkit.time.calendar import MONDAY, THURSDAY - >>> from earthkit.time import MonthlySequence, WeeklySequence + >>> from earthkit.time import MonthlySequence, Sequence, WeeklySequence >>> sequence = WeeklySequence([MONDAY, THURSDAY]) >>> [f"{d:%Y%m%d}" for d in model_climate_dates(date(2024, 2, 12), 2020, 2023, 7, 7, sequence)] ... # doctest: +NORMALIZE_WHITESPACE @@ -123,6 +142,11 @@ def model_climate_dates( '20210205', '20210207', '20210209', '20210211', '20210213', '20210215', '20210217', '20210219', '20220205', '20220207', '20220209', '20220211', '20220213', '20220215', '20220217', '20220219', '20230205', '20230207', '20230209', '20230211', '20230213', '20230215', '20230217', '20230219'] + >>> sequence = Sequence.from_resource("ecmwf-2days") + >>> [f"{d:%Y%m%d}" for d in model_climate_dates( + ... date(2024, 12, 31), RelativeYear(-3), RelativeYear(-1), 2, 2, sequence + ... )] + ['20211229', '20211231', '20220101', '20221229', '20221231', '20230101', '20231229', '20231231', '20240101'] """ if not isinstance(before, timedelta): before = timedelta(days=before) diff --git a/tests/cli/test_climatology_cli.py b/tests/cli/test_climatology_cli.py index b957f6c..00e4c44 100644 --- a/tests/cli/test_climatology_cli.py +++ b/tests/cli/test_climatology_cli.py @@ -6,6 +6,7 @@ from earthkit.time.calendar import Weekday from earthkit.time.cli.climatology import date_range_action, model_climate_action +from earthkit.time.climatology import RelativeYear @pytest.mark.parametrize( @@ -32,19 +33,29 @@ "\t", "20150607\t20160607\t20170607", ), + ( + date(2025, 1, 1), + RelativeYear(-5), + RelativeYear(-2), + " ", + "20200101 20210101 20220101 20230101", + ), ], ) def test_date_range_action( ref: date, - start: Union[date, int], - end: Union[date, int], + start: Union[date, int, RelativeYear], + end: Union[date, int, RelativeYear], sep: Optional[str], expected: str, capsys: pytest.CaptureFixture[str], ): parser = argparse.ArgumentParser() args = argparse.Namespace( - date=ref, start=start, end=end, sep=("\n" if sep is None else sep) + date=ref, + start=start, + end=end, + sep=("\n" if sep is None else sep), ) date_range_action(parser, args) captured = capsys.readouterr() @@ -125,6 +136,23 @@ def test_date_range_action( ), id="weekly-sep", ), + pytest.param( + { + "date": date(2019, 12, 31), + "start": RelativeYear(-10), + "end": RelativeYear(-6), + "before": 7, + "after": 7, + "preset": "ecmwf-mon-thu", + "sep": "/", + }, + "/".join( + f"{y + (1 if m == 1 else 0)}{m:02d}{d:02d}" + for y in range(2009, 2014) + for m, d in [(12, 26), (12, 30), (1, 2), (1, 6)] + ), + id="preset-rel", + ), ], ) def test_model_climate_action( @@ -135,6 +163,7 @@ def test_model_climate_action( args.setdefault("weekly", None) args.setdefault("monthly", None) args.setdefault("yearly", None) + args.setdefault("preset", None) args.setdefault("exclude", []) args.setdefault("sep", "\n") args = argparse.Namespace(**args) diff --git a/tests/test_climatology.py b/tests/test_climatology.py index 8d4a6a7..f2a6175 100644 --- a/tests/test_climatology.py +++ b/tests/test_climatology.py @@ -4,6 +4,7 @@ from earthkit.time import date_range, model_climate_dates from earthkit.time.calendar import MONDAY, THURSDAY +from earthkit.time.climatology import RelativeYear from earthkit.time.sequence import MonthlySequence, WeeklySequence @@ -135,3 +136,18 @@ def test_model_climate_dates(): for y in range(2020, 2024) for m, d in [(2, 21), (2, 25), (3, 1), (3, 5), (3, 9)] ] + + assert list( + model_climate_dates( + date(2024, 1, 1), + RelativeYear(-5), + RelativeYear(-2), + 3, + 3, + MonthlySequence(range(1, 32, 2), excludes=[(2, 29)]), + ) + ) == [ + date(y - (1 if m == 12 else 0), m, d) + for y in range(2019, 2023) + for m, d in [(12, 29), (12, 31), (1, 1), (1, 3)] + ]