154 lines
6.1 KiB
Python
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
|