Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion stac_fastapi/core/stac_fastapi/core/datetime_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ 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=round(dt_utc.microsecond / 1000) * 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 @@ -8,9 +8,10 @@
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 +37,16 @@ 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=round(dt_utc.microsecond / 1000) * 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 +55,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