Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Fixed

- Ensure datetime filter rounds microseconds using standard rounding instead of truncation. [#492](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/492)

### Removed

### Updated
Expand Down
6 changes: 5 additions & 1 deletion stac_fastapi/core/stac_fastapi/core/datetime_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Utility functions to handle datetime parsing."""

import math
from datetime import datetime, timezone

from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime
Expand All @@ -23,7 +24,10 @@ def normalize(dt):
return ".."
dt_obj = rfc3339_str_to_datetime(dt)
dt_utc = dt_obj.astimezone(timezone.utc)
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
rounded_dt = dt_utc.replace(
microsecond=math.floor(dt_utc.microsecond / 1000 + 0.5) * 1000
)
return rounded_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")

if not isinstance(date_str, str):
return "../.."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
"""

import logging
import math
import re
from datetime import date
from datetime import datetime as datetime_type
from datetime import timezone
from typing import Dict, Optional, Union

from stac_fastapi.types.rfc3339 import DateTimeType
from stac_fastapi.types.rfc3339 import DateTimeType, rfc3339_str_to_datetime

logger = logging.getLogger(__name__)

Expand All @@ -36,6 +38,18 @@ def return_date(
dict: A dictionary representing the date interval for use in filtering search results,
always containing 'gte' and 'lte' keys.
"""

def normalize_datetime(dt_str):
"""Normalize datetime string and preserve millisecond precision."""
if not dt_str or dt_str == "..":
return dt_str
dt_obj = rfc3339_str_to_datetime(dt_str)
dt_utc = dt_obj.astimezone(timezone.utc)
rounded_dt = dt_utc.replace(
microsecond=math.floor(dt_utc.microsecond / 1000 + 0.5) * 1000
)
return rounded_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")

result: Dict[str, Optional[str]] = {"gte": None, "lte": None}

if interval is None:
Expand All @@ -44,29 +58,53 @@ def return_date(
if isinstance(interval, str):
if "/" in interval:
parts = interval.split("/")
result["gte"] = (
gte_value = (
parts[0] if parts[0] != ".." else datetime_type.min.isoformat() + "Z"
)
result["lte"] = (
lte_value = (
parts[1]
if len(parts) > 1 and parts[1] != ".."
else datetime_type.max.isoformat() + "Z"
)

result["gte"] = (
normalize_datetime(gte_value)
if gte_value != datetime_type.min.isoformat() + "Z"
else gte_value
)
result["lte"] = (
normalize_datetime(lte_value)
if lte_value != datetime_type.max.isoformat() + "Z"
else lte_value
)
else:
converted_time = interval if interval != ".." else None
converted_time = normalize_datetime(interval) if interval != ".." else ".."
result["gte"] = result["lte"] = converted_time
return result

if isinstance(interval, datetime_type):
datetime_iso = interval.isoformat()
result["gte"] = result["lte"] = datetime_iso
datetime_str = interval.isoformat()
normalized_datetime = normalize_datetime(datetime_str)
result["gte"] = result["lte"] = normalized_datetime
elif isinstance(interval, tuple):
start, end = interval
# Ensure datetimes are converted to UTC and formatted with 'Z'
if start:
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
dt_utc = start.astimezone(timezone.utc)
rounded_dt = dt_utc.replace(
microsecond=round(dt_utc.microsecond / 1000) * 1000
)
result["gte"] = rounded_dt.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
)
if end:
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
dt_utc = end.astimezone(timezone.utc)
rounded_dt = dt_utc.replace(
microsecond=round(dt_utc.microsecond / 1000) * 1000
)
result["lte"] = rounded_dt.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
)

return result

Expand Down
67 changes: 67 additions & 0 deletions stac_fastapi/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1657,3 +1657,70 @@ async def test_use_datetime_false(app_client, load_test_data, txn_client, monkey

assert "test-item-datetime-only" not in found_ids
assert "test-item-start-end-only" in found_ids


@pytest.mark.asyncio
async def test_format_datetime_range_microsecond_rounding(
app_client, txn_client, load_test_data
):
"""Test that microseconds are rounded in format_datetime_range"""

test_collection = load_test_data("test_collection.json")
test_collection_id = "test-collection-microseconds"
test_collection["id"] = test_collection_id
await create_collection(txn_client, test_collection)

item = load_test_data("test_item.json")
item["id"] = "test-item-1"
item["collection"] = test_collection_id
item["properties"]["datetime"] = "2020-01-01T12:00:00.123Z"
await create_item(txn_client, item)

test_cases = [
("2020-01-01T12:00:00.123678Z", False),
("2020-01-01T12:00:00.123499Z", True),
("2020-01-01T12:00:00.123500Z", False),
]

for datetime_input, should_match in test_cases:
# Test GET /collections/{id}/items
resp = await app_client.get(
f"/collections/{test_collection_id}/items",
params={"datetime": datetime_input},
)
assert resp.status_code == 200
resp_json = resp.json()

if should_match:
assert len(resp_json["features"]) == 1
assert resp_json["features"][0]["id"] == "test-item-1"
else:
assert len(resp_json["features"]) == 0

# Test GET /search
resp = await app_client.get(
"/search",
params={"collections": test_collection_id, "datetime": datetime_input},
)
assert resp.status_code == 200
resp_json = resp.json()

if should_match:
assert len(resp_json["features"]) == 1
assert resp_json["features"][0]["id"] == "test-item-1"
else:
assert len(resp_json["features"]) == 0

# Test POST /search
resp = await app_client.post(
"/search",
json={"collections": [test_collection_id], "datetime": datetime_input},
)
assert resp.status_code == 200
resp_json = resp.json()

if should_match:
assert len(resp_json["features"]) == 1
assert resp_json["features"][0]["id"] == "test-item-1"
else:
assert len(resp_json["features"]) == 0