Skip to content

Commit 811d9fa

Browse files
committed
Handle filter_temporal end-exclusiveness in DateTimeInterval #955
1 parent fedc096 commit 811d9fa

6 files changed

Lines changed: 91 additions & 35 deletions

File tree

‎openeo/metadata/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from openeo.internal.jupyter import render_component
2424
from openeo.metadata._extents import _EmptyExtent, _NonConcreteExtent
2525
from openeo.util import Rfc3339, deep_get
26+
from openeo.utils.datastructure import is_2_tuple_like
2627
from openeo.utils.datetime import DateTimeInterval
2728
from openeo.utils.normalize import normalize_resample_resolution, unique
2829

@@ -121,7 +122,9 @@ def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | Non
121122
"""
122123
Create new TemporalDimension with intersection current and given temporal extents.
123124
"""
124-
if (interval1 := DateTimeInterval.try_from(self.extent)) and (interval2 := DateTimeInterval.try_from(extent)):
125+
if is_2_tuple_like(self.extent) and is_2_tuple_like(extent):
126+
interval1 = DateTimeInterval(*self.extent)
127+
interval2 = DateTimeInterval(*extent, end_inclusive=False)
125128
if intersection := interval1.intersection(interval2):
126129
new_extent = intersection.as_rfc3339()
127130
else:

‎openeo/utils/datastructure.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
from typing import Container
77

88

9+
def is_2_tuple_like(x) -> bool:
10+
"""
11+
Is given value like a 2-tuple: a sequence of length 2,
12+
e.g. usable as `*args` in a function call?
13+
"""
14+
return isinstance(x, (list, tuple)) and len(x) == 2
15+
16+
917
def make_new_key(container: Container[str], key: str, *, start: int = 1):
1018
"""
1119
Construct a new key that doesn't exist yet in the given container (dict, set, ...),

‎openeo/utils/datetime.py‎

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,35 +46,29 @@ class DateTimeInterval:
4646
Datetime interval, defined by a start and end datetime instant (UTC)
4747
each of which can be ``None`` to indicate being open-ended in that direction (no limit).
4848
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
49+
Note on including or excluding the end instant (attribute ``end_inclusive``):
50+
this aspect is usually linked to what the extent describes or how it is used:
51+
- when the extent describes the known time range of a certain data set (e.g. a STAC collection),
52+
the end instant is inclusive
53+
- when the extent is used as search window for a query/filter operation,
54+
some standards (e.g. openEO ``filter_temporal``) prescribe the end instant as exclusive,
55+
as this simplifies some aspects of the query logic and specification.
56+
The ``end_inclusive`` attribute can be toggled to indicate the intended semantics where this matters.
5357
"""
5458

5559
start: Union[datetime.datetime, None]
5660
end: Union[datetime.datetime, None]
61+
end_inclusive: bool = dataclasses.field(default=True, kw_only=True)
5762

58-
def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None):
63+
def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None, *, end_inclusive: bool = True):
5964
start = to_datetime_utc_unless_none(start)
6065
end = to_datetime_utc_unless_none(end)
6166
if start and end and end < start:
6267
raise ValueError(f"End instant {end!r} is before start instant {start!r}")
6368

6469
object.__setattr__(self, "start", start)
6570
object.__setattr__(self, "end", end)
66-
67-
@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
77-
return None
71+
object.__setattr__(self, "end_inclusive", end_inclusive)
7872

7973
def as_tuple(self) -> Tuple[datetime.datetime | None, datetime.datetime | None]:
8074
return self.start, self.end
@@ -110,14 +104,14 @@ def intersection(self, other: DateTimeInterval) -> DateTimeInterval | None:
110104
start = max(self.start, other.start)
111105

112106
if self.end is None:
113-
end = other.end
107+
end, end_inclusive = other.end, other.end_inclusive
114108
elif other.end is None:
115-
end = self.end
109+
end, end_inclusive = self.end, self.end_inclusive
116110
else:
117-
end = min(self.end, other.end)
111+
end, end_inclusive = min((self.end, self.end_inclusive), (other.end, other.end_inclusive))
118112

119113
# Double check naive calculation on emptiness
120-
if start is not None and end is not None and end < start:
114+
if start is not None and end is not None and (end < start or (end == start and not end_inclusive)):
121115
return None
122116

123-
return DateTimeInterval(start, end)
117+
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/utils/test_datastructure.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
from openeo.utils.datastructure import make_new_key
1+
from openeo.utils.datastructure import is_2_tuple_like, make_new_key
2+
3+
4+
def test_is_2_tuple_like():
5+
assert is_2_tuple_like((1, 2))
6+
assert is_2_tuple_like([1, 2])
7+
assert not is_2_tuple_like((1,))
8+
assert not is_2_tuple_like([1])
9+
assert not is_2_tuple_like((1, 2, 3))
10+
assert not is_2_tuple_like([1, 2, 3])
11+
assert not is_2_tuple_like("12")
12+
assert not is_2_tuple_like(12)
13+
assert not is_2_tuple_like(None)
214

315

416
def test_make_new_key_on_dict():

‎tests/utils/test_datetime.py‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,25 @@ def test_init_basic_datetime_timezone(self):
113113
assert interval.end == datetime.datetime(2026, 11, 12, 23, 52, 22, tzinfo=datetime.timezone.utc)
114114

115115
@pytest.mark.parametrize(
116-
["given", "expected"],
116+
["interval", "expected"],
117117
[
118-
(["2026-09-10", "2026-11-12"], DateTimeInterval("2026-09-10", "2026-11-12")),
119-
(("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")),
121-
("foobar", None),
122-
(["2026-09-10"], None),
123-
(["2026-09-10", "2026-11-12", "2026-12-01"], None),
124-
({"color": "green"}, None),
125-
({"start": "2026-09-10"}, None),
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),
126122
],
127123
)
128-
def test_try_from(self, given, expected):
129-
assert DateTimeInterval.try_from(given) == expected
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)
130135

131136
def test_as_tuple(self):
132137
interval = DateTimeInterval("2026-09-10", "2026-11-12T11:22:33Z")
@@ -174,3 +179,27 @@ def test_intersection(self, interval1, interval2, expected):
174179
interval2 = DateTimeInterval(*interval2)
175180
assert interval1.intersection(interval2) == expected
176181
assert interval2.intersection(interval1) == expected
182+
183+
@pytest.mark.parametrize(
184+
["interval1", "interval2", "expected"],
185+
[
186+
(
187+
DateTimeInterval("2026-08-20", "2026-09-01"),
188+
DateTimeInterval("2026-09-01", "2026-09-30"),
189+
DateTimeInterval("2026-09-01", "2026-09-01"),
190+
),
191+
(
192+
DateTimeInterval("2026-08-20", "2026-09-01", end_inclusive=False),
193+
DateTimeInterval("2026-09-01", "2026-09-30"),
194+
None,
195+
),
196+
(
197+
DateTimeInterval("2026-08-20", "2026-09-01", end_inclusive=False),
198+
DateTimeInterval("2026-08-25", "2026-09-30"),
199+
DateTimeInterval("2026-08-25", "2026-09-01", end_inclusive=False),
200+
),
201+
],
202+
)
203+
def test_intersection_end_inclusive(self, interval1, interval2, expected):
204+
assert interval1.intersection(interval2) == expected
205+
assert interval2.intersection(interval1) == expected

0 commit comments

Comments
 (0)