Skip to content

Commit 18afbc5

Browse files
committed
1 parent 7a423da commit 18afbc5

File tree

3 files changed

+94
-35
lines changed

3 files changed

+94
-35
lines changed

openeogeotrellis/load_stac.py

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from openeogeotrellis.constants import EVAL_ENV_KEY
5050
from openeogeotrellis.geopysparkcubemetadata import GeopysparkCubeMetadata
5151
from openeogeotrellis.geopysparkdatacube import GeopysparkDataCube
52+
from openeogeotrellis.util.datetime import to_datetime_utc_unless_none
5253
from openeogeotrellis.utils import normalize_temporal_extent, get_jvm, to_projected_polygons, map_optional, unzip
5354
from openeogeotrellis.integrations.stac import ResilientStacIO
5455

@@ -93,7 +94,6 @@ def load_stac(
9394
to_date = (dt.datetime.combine(until_date, dt.time.max, until_date.tzinfo) if from_date == until_date
9495
else until_date - dt.timedelta(milliseconds=1))
9596

96-
# TODO: move date preparation to __init__ of _SpatioTemporalExtent
9797
spatiotemporal_extent = _SpatioTemporalExtent(bbox=requested_bbox, from_date=from_date, to_date=to_date)
9898

9999
def get_pixel_value_offset(itm: pystac.Item, asst: pystac.Asset) -> float:
@@ -712,44 +712,26 @@ def intersects_temporally(interval) -> bool:
712712

713713
class _TemporalExtent:
714714
"""
715-
Helper to represent a temporal extent with a from_date and to_date
715+
Helper to represent a load_collection/load_stac-style temporal extent
716+
with a from_date (inclusive) and to_date (exclusive)
716717
and calculate intersection with STAC entities
717-
(nominal datetime or start_datetime+end_datetime).
718-
"""
718+
based on nominal datetime or start_datetime+end_datetime
719719
720+
refs:
721+
- https://github.com/radiantearth/stac-spec/blob/master/item-spec/item-spec.md#datetime
722+
- https://github.com/radiantearth/stac-spec/blob/master/commons/common-metadata.md#date-and-time-range
723+
"""
720724
# TODO: move this to a more generic location for better reuse
725+
721726
__slots__ = ("from_date", "to_date")
722727

723728
def __init__(
724729
self,
725730
from_date: Union[str, datetime.datetime, datetime.date, None],
726731
to_date: Union[str, datetime.datetime, datetime.date, None],
727732
):
728-
self.from_date: Union[datetime.datetime, None] = self._to_datetime_utc_unless_none(from_date)
729-
self.to_date: Union[datetime.datetime, None] = self._to_datetime_utc_unless_none(to_date)
730-
731-
@staticmethod
732-
def _to_datetime_utc(d: Union[str, datetime.datetime, datetime.date]) -> datetime.datetime:
733-
"""Parse/convert to datetime in UTC."""
734-
if isinstance(d, str):
735-
d = dateutil.parser.parse(d)
736-
elif isinstance(d, datetime.datetime):
737-
pass
738-
elif isinstance(d, datetime.date):
739-
d = datetime.datetime.combine(d, datetime.time.min)
740-
else:
741-
raise ValueError("Expected str/datetime, but got {type(d)}")
742-
if d.tzinfo is None:
743-
d = d.replace(tzinfo=dt.timezone.utc)
744-
else:
745-
d = d.astimezone(dt.timezone.utc)
746-
return d
747-
748-
@classmethod
749-
def _to_datetime_utc_unless_none(
750-
cls, d: Union[str, datetime.datetime, datetime.date, None]
751-
) -> Union[datetime.datetime, None]:
752-
return None if d is None else cls._to_datetime_utc(d)
733+
self.from_date: Union[datetime.datetime, None] = to_datetime_utc_unless_none(from_date)
734+
self.to_date: Union[datetime.datetime, None] = to_datetime_utc_unless_none(to_date)
753735

754736
def intersects(
755737
self,
@@ -764,9 +746,9 @@ def intersects(
764746
:param start_datetime: start of the interval (e.g. "start_datetime" property of a STAC Item)
765747
:param end_datetime: end of the interval (e.g. "end_datetime" property of a STAC Item)
766748
"""
767-
start_datetime = self._to_datetime_utc_unless_none(start_datetime)
768-
end_datetime = self._to_datetime_utc_unless_none(end_datetime)
769-
nominal = self._to_datetime_utc_unless_none(nominal)
749+
start_datetime = to_datetime_utc_unless_none(start_datetime)
750+
end_datetime = to_datetime_utc_unless_none(end_datetime)
751+
nominal = to_datetime_utc_unless_none(nominal)
770752

771753
# If available, start+end are preferred (cleanly defined interval)
772754
# fall back on nominal otherwise
@@ -787,7 +769,6 @@ class _SpatialExtent:
787769
Helper to represent a spatial extent with a bounding box
788770
and calculate intersection with STAC entities (e.g. bbox of a STAC Item).
789771
"""
790-
791772
# TODO: move this to a more generic location for better reuse
792773

793774
__slots__ = ("bbox", "_bbox_lonlat_shape")
@@ -810,8 +791,8 @@ def __init__(
810791
self,
811792
*,
812793
bbox: Union[BoundingBox, None],
813-
from_date: Union[str, datetime.datetime, None],
814-
to_date: Union[str, datetime.datetime, None],
794+
from_date: Union[str, datetime.datetime, datetime.date, None],
795+
to_date: Union[str, datetime.datetime, datetime.date, None],
815796
):
816797
self._spatial_extent = _SpatialExtent(bbox=bbox)
817798
self._temporal_extent = _TemporalExtent(from_date=from_date, to_date=to_date)

openeogeotrellis/util/datetime.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import Union
2+
import datetime
3+
4+
import dateutil.parser
5+
6+
7+
def to_datetime_utc(d: Union[str, datetime.datetime, datetime.date]) -> datetime.datetime:
8+
"""Parse/convert to datetime in UTC."""
9+
if isinstance(d, str):
10+
d = dateutil.parser.parse(d)
11+
elif isinstance(d, datetime.datetime):
12+
pass
13+
elif isinstance(d, datetime.date):
14+
d = datetime.datetime.combine(d, datetime.time.min)
15+
else:
16+
raise ValueError(f"Expected str/datetime, but got {type(d)}")
17+
if d.tzinfo is None:
18+
d = d.replace(tzinfo=datetime.timezone.utc)
19+
else:
20+
d = d.astimezone(datetime.timezone.utc)
21+
return d
22+
23+
24+
def to_datetime_utc_unless_none(
25+
d: Union[str, datetime.datetime, datetime.date, None]
26+
) -> Union[datetime.datetime, None]:
27+
"""Parse/convert to datetime in UTC, but preserve None."""
28+
return None if d is None else to_datetime_utc(d)

tests/util/test_datetime.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import datetime
2+
import pytest
3+
4+
from openeogeotrellis.util.datetime import to_datetime_utc, to_datetime_utc_unless_none
5+
6+
7+
@pytest.mark.parametrize(
8+
["obj", "expected"],
9+
[
10+
("2025-07-24", (2025, 7, 24, 0, 0, 0)),
11+
("2025-07-24T12:34:56", (2025, 7, 24, 12, 34, 56)),
12+
("2025-07-24T12:34:56.123", (2025, 7, 24, 12, 34, 56, 123000)),
13+
("2025-07-24T12:34:56Z", (2025, 7, 24, 12, 34, 56)),
14+
("2025-07-24T12:34:56+03", (2025, 7, 24, 9, 34, 56)),
15+
("2025-07-24T12:34:56+03:30", (2025, 7, 24, 9, 4, 56)),
16+
("2025-07-24T12:34:56-03", (2025, 7, 24, 15, 34, 56)),
17+
("2025-07-24T12:34:56-03:30", (2025, 7, 24, 16, 4, 56)),
18+
(datetime.date(2025, 7, 24), (2025, 7, 24, 0, 0, 0)),
19+
(datetime.datetime(2025, 7, 24, 12, 34, 56), (2025, 7, 24, 12, 34, 56)),
20+
(
21+
datetime.datetime(2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone(offset=datetime.timedelta(hours=+2))),
22+
(2025, 7, 24, 10, 34, 56),
23+
),
24+
(
25+
datetime.datetime(
26+
2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone(offset=datetime.timedelta(hours=-4, minutes=-30))
27+
),
28+
(2025, 7, 24, 17, 4, 56),
29+
),
30+
],
31+
)
32+
def test_to_datetime_utc(obj, expected):
33+
actual = to_datetime_utc(obj)
34+
assert actual == datetime.datetime(*expected, tzinfo=datetime.timezone.utc)
35+
assert actual.tzinfo == datetime.timezone.utc
36+
37+
38+
@pytest.mark.parametrize(
39+
["obj", "expected"],
40+
[
41+
(None, None),
42+
("2025-07-24", datetime.datetime(2025, 7, 24, 0, 0, 0, tzinfo=datetime.timezone.utc)),
43+
(
44+
datetime.datetime(2025, 7, 24, 12, 34, 56),
45+
datetime.datetime(2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone.utc),
46+
),
47+
],
48+
)
49+
def test_to_datetime_utc_unless_none(obj, expected):
50+
assert to_datetime_utc_unless_none(obj) == expected

0 commit comments

Comments
 (0)