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