Files
geointel/backend/app/services/temporal_compatibility_service.py
T
Codex 9146981802
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
Complete temporal detection safety gate
2026-07-18 00:53:00 +02:00

154 lines
6.1 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from app.core.errors import AppError
from app.models import Dataset
@dataclass(frozen=True)
class TemporalInterval:
start: datetime | None
end: datetime | None
granularity: str | None
@property
def bounded(self) -> bool:
return self.start is not None and self.end is not None
def as_dict(self) -> dict[str, Any]:
return {
"start": self.start.isoformat() if self.start else None,
"end": self.end.isoformat() if self.end else None,
"granularity": self.granularity,
}
class TemporalCompatibilityService:
@staticmethod
def ensure_detection_source_supported(dataset: Dataset) -> None:
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
if metadata.get("supports_detection") is False:
raise AppError(
code="DETECTION_SOURCE_TEMPORALLY_UNSUPPORTED",
message="The selected raster edition is not approved for the configured detection model",
details={
"dataset_id": str(dataset.id),
"source_name": dataset.source_name,
"product_key": metadata.get("product_key"),
"observed_at": TemporalCompatibilityService._iso(dataset.observed_at),
"valid_from": TemporalCompatibilityService._iso(dataset.valid_from),
"valid_to": TemporalCompatibilityService._iso(dataset.valid_to),
},
status_code=422,
)
@staticmethod
def assess_detection_qa(candidate: Dataset, reference: Dataset) -> dict[str, Any]:
candidate_interval = TemporalCompatibilityService._interval(candidate)
reference_interval = TemporalCompatibilityService._interval(reference)
candidate_historical = TemporalCompatibilityService._is_historical_detection_source(candidate)
if candidate_historical:
if not reference_interval.bounded:
TemporalCompatibilityService._raise_mismatch(
candidate,
reference,
candidate_interval,
reference_interval,
"Historical imagery requires a reference dataset with an explicit compatible validity period.",
)
if not TemporalCompatibilityService._overlaps(candidate_interval, reference_interval):
TemporalCompatibilityService._raise_mismatch(
candidate,
reference,
candidate_interval,
reference_interval,
"The historical imagery and reference dataset validity periods do not overlap.",
)
if (
candidate_interval.bounded
and reference_interval.bounded
and not TemporalCompatibilityService._overlaps(candidate_interval, reference_interval)
):
TemporalCompatibilityService._raise_mismatch(
candidate,
reference,
candidate_interval,
reference_interval,
"The candidate and reference dataset validity periods do not overlap.",
)
return {
"status": "compatible",
"policy": "explicit_interval_overlap_for_historical_sources",
"candidate_dataset_id": str(candidate.id),
"reference_dataset_id": str(reference.id),
"candidate_historical": candidate_historical,
"candidate_interval": candidate_interval.as_dict(),
"reference_interval": reference_interval.as_dict(),
}
@staticmethod
def _is_historical_detection_source(dataset: Dataset) -> bool:
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
if metadata.get("supports_detection") is False:
return True
product_key = str(metadata.get("product_key") or "").strip().lower()
return dataset.source_name == "digitaal_vlaanderen_orthophoto" and product_key not in {"", "most_recent"}
@staticmethod
def _interval(dataset: Dataset) -> TemporalInterval:
start = TemporalCompatibilityService._utc(dataset.valid_from or dataset.observed_at)
end = TemporalCompatibilityService._utc(dataset.valid_to)
granularity = dataset.temporal_granularity
if start is not None and end is None and granularity == "year":
end = datetime(start.year, 12, 31, 23, 59, 59, tzinfo=UTC)
elif start is not None and end is None and granularity == "day":
end = start.replace(hour=23, minute=59, second=59, microsecond=999999)
return TemporalInterval(start=start, end=end, granularity=granularity)
@staticmethod
def _overlaps(left: TemporalInterval, right: TemporalInterval) -> bool:
if not left.bounded or not right.bounded:
return True
return left.start <= right.end and right.start <= left.end
@staticmethod
def _raise_mismatch(
candidate: Dataset,
reference: Dataset,
candidate_interval: TemporalInterval,
reference_interval: TemporalInterval,
message: str,
) -> None:
raise AppError(
code="DETECTION_QA_TEMPORAL_MISMATCH",
message=message,
details={
"candidate_dataset_id": str(candidate.id),
"reference_dataset_id": str(reference.id),
"candidate_interval": candidate_interval.as_dict(),
"reference_interval": reference_interval.as_dict(),
},
status_code=422,
)
@staticmethod
def _utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
@staticmethod
def _iso(value: datetime | None) -> str | None:
normalized = TemporalCompatibilityService._utc(value)
return normalized.isoformat() if normalized else None