diff --git a/CHANGES.md b/CHANGES.md index 80ecf564f..e840697be 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,10 +2,15 @@ ## __NEXT__ +### Features + +* filter, frequencies, refine: Added support in metadata for precise date ranges in `YYYY-MM-DD/YYYY-MM-DD` format. [#1304][] (@victorlin) + ### Bug fixes * export v2: Improved the error message that is displayed when a deprecated coloring key is used. [#1882][] (@corneliusroemer) +[#1304]: https://github.com/nextstrain/augur/issues/1304 [#1882]: https://github.com/nextstrain/augur/issues/1882 ## 32.1.0 (18 November 2025) diff --git a/augur/dates/__init__.py b/augur/dates/__init__.py index f7386b668..5a64049fa 100644 --- a/augur/dates/__init__.py +++ b/augur/dates/__init__.py @@ -161,6 +161,12 @@ def is_date_ambiguous(date, ambiguous_by): Note that this can support any date format, not just YYYY-MM-DD. """ +RE_DATE_RANGE = re.compile(r'^\d{4}-\d{2}-\d{2}/\d{4}-\d{2}-\d{2}$') +""" +Matches a date range in YYYY-MM-DD/YYYY-MM-DD format. +Note that this is a subset of the ISO 8601 time interval format. +""" + @cache def get_numerical_date_from_value(value, fmt, min_max_year=None) -> Union[float, Tuple[float, float], None]: value = str(value) @@ -200,6 +206,17 @@ def get_numerical_date_from_value(value, fmt, min_max_year=None) -> Union[float, # closest in-bound value. raise InvalidDate(value, str(error)) from error + if RE_DATE_RANGE.match(value): + start, end = value.split("/") + + start = datetime.datetime.strptime(start, "%Y-%m-%d") + end = datetime.datetime.strptime(end , "%Y-%m-%d") + + if start > end: + raise InvalidDate(value, f"Start {start!r} is later than end {end!r}") + + return (date_to_numeric(start), date_to_numeric(end)) + # 4. Return none (silent error) if the date does not match any of the checked formats. return None diff --git a/docs/faq/metadata.rst b/docs/faq/metadata.rst index 23de020a5..692002002 100644 --- a/docs/faq/metadata.rst +++ b/docs/faq/metadata.rst @@ -64,6 +64,9 @@ Ambiguity over a range of dates is supported in the following formats: (e.g.. ``2018``, ``2018-03``) 2. Augur-style reduced precision format, i.e. ISO 8601 format with unknown parts explicitly masked by ``XX`` (e.g. ``2018-XX-XX``, ``2018-03-XX``) +3. `/` range format, where `` and `` are exact dates in `YYYY-MM-DD` format. + This is a subset of `ISO 8601 interval format __`. + (e.g. ``2017-12-01/2018-03-25``) **Geography** diff --git a/tests/dates/test_dates.py b/tests/dates/test_dates.py index ecf7cba93..15f6900b5 100644 --- a/tests/dates/test_dates.py +++ b/tests/dates/test_dates.py @@ -134,6 +134,15 @@ def test_get_numerical_date_from_value_current_day_limit(self): == pytest.approx(2000.138, abs=1e-3) ) + def test_get_numerical_date_from_value_range(self): + assert dates.get_numerical_date_from_value("2019-01-02/2019-03-04", fmt="unused") == ( + pytest.approx(dates.numeric_date(datetime.date(year=2019, month=1, day=2)), abs=1e-3), + pytest.approx(dates.numeric_date(datetime.date(year=2019, month=3, day=4)), abs=1e-3), + ) + + # Using a numeric date as a bound is not valid. + assert dates.get_numerical_date_from_value("2019.0/2019-06-01", fmt="unused") == None + def test_is_date_ambiguous(self): """is_date_ambiguous should return true for ambiguous dates and false for valid dates.""" # Test complete date strings with ambiguous values.