Skip to content

Commit e288323

Browse files
committed
Handle filter_temporal end-exclusiveness in DateTimeInterval #955
1 parent e270cab commit e288323

5 files changed

Lines changed: 88 additions & 27 deletions

File tree

‎openeo/metadata/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | Non
121121
"""
122122
Create new TemporalDimension with intersection current and given temporal extents.
123123
"""
124-
if (interval1 := DateTimeInterval.try_from(self.extent)) and (interval2 := DateTimeInterval.try_from(extent)):
124+
if (interval1 := DateTimeInterval.try_from_tuple(self.extent)) and (
125+
interval2 := DateTimeInterval.try_from_tuple(extent, end_inclusive=False)
126+
):
125127
if intersection := interval1.intersection(interval2):
126128
new_extent = intersection.as_rfc3339()
127129
else:

‎openeo/utils/datetime.py‎

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import dataclasses
44
import datetime
5+
import sys
56
from typing import Any, Tuple, Union
67

78
import dateutil.parser
@@ -46,34 +47,38 @@ class DateTimeInterval:
4647
Datetime interval, defined by a start and end datetime instant (UTC)
4748
each of which can be ``None`` to indicate being open-ended in that direction (no limit).
4849
49-
Note on including or excluding the end instant:
50-
this is not a property of the extent itself, but is an aspect of what the extent describes or how it is used:
51-
- when the extent describes the range of a certain data set, the end instant is inclusive
52-
- when the extent is used as search window for a query, the end instant is typically handled as exclusive
50+
Note on including or excluding the end instant (attribute ``end_inclusive``):
51+
this aspect is usually linked to what the extent describes or how it is used:
52+
- when the extent describes the known time range of a certain data set (e.g. a STAC collection),
53+
the end instant is inclusive
54+
- when the extent is used as search window for a query/filter operation,
55+
some standards (e.g. openEO ``filter_temporal``) prescribe the end instant as exclusive,
56+
as this simplifies some aspects of the query logic and specification.
57+
The ``end_inclusive`` attribute can be toggled to indicate the intended semantics where this matters.
5358
"""
5459

5560
start: Union[datetime.datetime, None]
5661
end: Union[datetime.datetime, None]
62+
end_inclusive: bool = dataclasses.field(default=True, **({"kw_only": True} if sys.version_info >= (3, 10) else {}))
5763

58-
def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None):
64+
def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None, *, end_inclusive: bool = True):
5965
start = to_datetime_utc_unless_none(start)
6066
end = to_datetime_utc_unless_none(end)
6167
if start and end and end < start:
6268
raise ValueError(f"End instant {end!r} is before start instant {start!r}")
6369

6470
object.__setattr__(self, "start", start)
6571
object.__setattr__(self, "end", end)
72+
object.__setattr__(self, "end_inclusive", end_inclusive)
6673

6774
@classmethod
68-
def try_from(cls, x: Any) -> DateTimeInterval | None:
69-
"""Try to build a TemporalExtent from the given object, or return None if not possible."""
70-
try:
71-
if isinstance(x, (list, tuple)) and len(x) == 2:
72-
return DateTimeInterval(*x)
73-
elif isinstance(x, dict):
74-
return DateTimeInterval(**x)
75-
except (ValueError, TypeError):
76-
pass
75+
def try_from_tuple(cls, x: Any, **kwargs: Any) -> DateTimeInterval | None:
76+
"""Try to build a TemporalExtent from the given tuple/list, or return None if not possible."""
77+
if isinstance(x, (list, tuple)) and len(x) == 2:
78+
try:
79+
return DateTimeInterval(*x, **kwargs)
80+
except (ValueError, TypeError):
81+
pass
7782
return None
7883

7984
def as_tuple(self) -> Tuple[datetime.datetime | None, datetime.datetime | None]:
@@ -110,14 +115,14 @@ def intersection(self, other: DateTimeInterval) -> DateTimeInterval | None:
110115
start = max(self.start, other.start)
111116

112117
if self.end is None:
113-
end = other.end
118+
end, end_inclusive = other.end, other.end_inclusive
114119
elif other.end is None:
115-
end = self.end
120+
end, end_inclusive = self.end, self.end_inclusive
116121
else:
117-
end = min(self.end, other.end)
122+
end, end_inclusive = min((self.end, self.end_inclusive), (other.end, other.end_inclusive))
118123

119124
# Double check naive calculation on emptiness
120-
if start is not None and end is not None and end < start:
125+
if start is not None and end is not None and (end < start or (end == start and not end_inclusive)):
121126
return None
122127

123-
return DateTimeInterval(start, end)
128+
return DateTimeInterval(start, end, end_inclusive=end_inclusive)

‎tests/metadata/test_metadata.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ def test_filter_temporal_empty_intersection(self):
6161
assert tdim1.extent == ("2021-01-01", None)
6262
assert isinstance(tdim2.extent, _EmptyExtent)
6363

64+
def test_filter_temporal_end_inclusive(self):
65+
"""`filter_temporal` is end-exclusive"""
66+
tdim1 = TemporalDimension(name="t", extent=("2026-03-01", "2026-04-01"))
67+
# `filter_temporal` with extent just before: no match
68+
tdim2 = tdim1.filter_temporal(extent=("2026-02-01", "2026-03-01"))
69+
assert tdim2.extent == _EmptyExtent()
70+
# `filter_temporal` with extent just after: single instant match
71+
tdim3 = tdim1.filter_temporal(extent=("2026-04-01", "2026-05-01"))
72+
assert tdim3.extent == ("2026-04-01T00:00:00Z", "2026-04-01T00:00:00Z")
73+
6474
def test_filter_temporal_none(self):
6575
tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None))
6676
tdim2 = tdim1.filter_temporal(extent=None)

‎tests/rest/datacube/test_datacube.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,6 @@ def test_filter_temporal_generic(self, s2cube, args, kwargs, extent):
473473
(["2016", "2017"], ["2016-01-01", "2017-01-01"]),
474474
(["2016-03", "2016-04"], ["2016-03-01", "2016-04-01"]),
475475
(["2016-03", "2018-12"], ["2016-03-01", "2018-12-01"]),
476-
(["2016", "2016-01"], ["2016-01-01", "2016-01-01"]),
477476
(["2016", "2016-04"], ["2016-01-01", "2016-04-01"]),
478477
([None, "2016-04"], [None, "2016-04-01"]),
479478
],

‎tests/utils/test_datetime.py‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,43 @@ def test_init_basic_datetime_timezone(self):
112112
assert interval.start == datetime.datetime(2026, 9, 10, 9, 11, 11, tzinfo=datetime.timezone.utc)
113113
assert interval.end == datetime.datetime(2026, 11, 12, 23, 52, 22, tzinfo=datetime.timezone.utc)
114114

115+
@pytest.mark.parametrize(
116+
["interval", "expected"],
117+
[
118+
(DateTimeInterval("2026-09-10", "2026-09-20"), True),
119+
(DateTimeInterval("2026-09-10T00:00:00", "2026-09-20T00:00:00"), True),
120+
(DateTimeInterval("2026-09-10T01:00:00+01:00", "2026-09-20T00:00:00"), True),
121+
(DateTimeInterval("2026-09-10T01:00:00", "2026-09-20T00:00:00"), False),
122+
],
123+
)
124+
def test_equality(self, interval, expected):
125+
ref = DateTimeInterval("2026-09-10", "2026-09-20")
126+
assert (ref == interval) == expected
127+
assert (interval == ref) == expected
128+
129+
def test_equality_end_inclusive(self):
130+
d1 = "2026-09-10"
131+
d2 = "2026-09-20"
132+
assert DateTimeInterval(d1, d2) == DateTimeInterval(d1, d2, end_inclusive=True)
133+
assert DateTimeInterval(d1, d2) != DateTimeInterval(d1, d2, end_inclusive=False)
134+
assert DateTimeInterval(d1, d2, end_inclusive=False) == DateTimeInterval(d1, d2, end_inclusive=False)
135+
115136
@pytest.mark.parametrize(
116137
["given", "expected"],
117138
[
118-
(["2026-09-10", "2026-11-12"], DateTimeInterval("2026-09-10", "2026-11-12")),
119139
(("2026-09-10", "2026-11-12"), DateTimeInterval("2026-09-10", "2026-11-12")),
120-
({"start": "2026-09-10", "end": "2026-11-12"}, DateTimeInterval("2026-09-10", "2026-11-12")),
140+
(("2026-09-10", None), DateTimeInterval("2026-09-10", None)),
141+
(["2026-09-10", "2026-11-12"], DateTimeInterval("2026-09-10", "2026-11-12")),
142+
(None, None),
121143
("foobar", None),
122144
(["2026-09-10"], None),
123145
(["2026-09-10", "2026-11-12", "2026-12-01"], None),
124-
({"color": "green"}, None),
125-
({"start": "2026-09-10"}, None),
146+
([{"2026": "09-10"}, {"2026": "11-12"}], None),
147+
({"2026": "09-10"}, None),
126148
],
127149
)
128150
def test_try_from(self, given, expected):
129-
assert DateTimeInterval.try_from(given) == expected
130-
151+
assert DateTimeInterval.try_from_tuple(given) == expected
131152
def test_as_tuple(self):
132153
interval = DateTimeInterval("2026-09-10", "2026-11-12T11:22:33Z")
133154
assert interval.as_tuple() == (
@@ -174,3 +195,27 @@ def test_intersection(self, interval1, interval2, expected):
174195
interval2 = DateTimeInterval(*interval2)
175196
assert interval1.intersection(interval2) == expected
176197
assert interval2.intersection(interval1) == expected
198+
199+
@pytest.mark.parametrize(
200+
["interval1", "interval2", "expected"],
201+
[
202+
(
203+
DateTimeInterval("2026-08-20", "2026-09-01"),
204+
DateTimeInterval("2026-09-01", "2026-09-30"),
205+
DateTimeInterval("2026-09-01", "2026-09-01"),
206+
),
207+
(
208+
DateTimeInterval("2026-08-20", "2026-09-01", end_inclusive=False),
209+
DateTimeInterval("2026-09-01", "2026-09-30"),
210+
None,
211+
),
212+
(
213+
DateTimeInterval("2026-08-20", "2026-09-01", end_inclusive=False),
214+
DateTimeInterval("2026-08-25", "2026-09-30"),
215+
DateTimeInterval("2026-08-25", "2026-09-01", end_inclusive=False),
216+
),
217+
],
218+
)
219+
def test_intersection_end_inclusive(self, interval1, interval2, expected):
220+
assert interval1.intersection(interval2) == expected
221+
assert interval2.intersection(interval1) == expected

0 commit comments

Comments
 (0)