feat: add temporal Mol explorer
This commit is contained in:
@@ -7,7 +7,7 @@ import importlib
|
||||
|
||||
from geoalchemy2.shape import from_shape
|
||||
from app.core.errors import AppError
|
||||
from app.models import Area, Dataset
|
||||
from app.models import Area, Dataset, DatasetVersion
|
||||
from app.services.raster_operations_service import RasterOperationsService
|
||||
from app.api.routes.datasets import _run_job_sync
|
||||
from shapely.geometry import box
|
||||
@@ -356,8 +356,12 @@ def test_raster_reproject_returns_persisted_derived_dataset(monkeypatch, tmp_pat
|
||||
)
|
||||
|
||||
assert result_id == output_id
|
||||
assert len(db.added) == 1
|
||||
assert len(db.added) == 2
|
||||
derived = db.added[0]
|
||||
version = db.added[1]
|
||||
assert isinstance(version, DatasetVersion)
|
||||
assert version.dataset_id == output_id
|
||||
assert version.version == 1
|
||||
assert derived.id == output_id
|
||||
assert derived.metadata_json is not None
|
||||
assert derived.metadata_json["operation"] == "raster.reproject"
|
||||
@@ -783,9 +787,13 @@ def test_raster_clip_persists_derived_dataset(monkeypatch, tmp_path) -> None:
|
||||
result_id = RasterOperationsService.clip(db, dataset_id, area_id, "clip-result.tif")
|
||||
|
||||
assert result_id == output_id
|
||||
assert len(db.added) == 1
|
||||
assert len(db.added) == 2
|
||||
derived = db.added[0]
|
||||
version = db.added[1]
|
||||
assert isinstance(derived, Dataset)
|
||||
assert isinstance(version, DatasetVersion)
|
||||
assert version.dataset_id == output_id
|
||||
assert version.version == 1
|
||||
assert derived.id == output_id
|
||||
assert derived.source == "operation:raster.clip"
|
||||
assert derived.dataset_type == "raster"
|
||||
@@ -1295,8 +1303,12 @@ def test_raster_index_records_provenance_and_dtype(tmp_path, monkeypatch) -> Non
|
||||
|
||||
result_dataset_id = RasterOperationsService.ndvi(db, dataset_id, nir_band=4, red_band=3, output_name="ndvi-test")
|
||||
assert result_dataset_id == output_dataset_id
|
||||
assert len(db.added) == 1
|
||||
assert len(db.added) == 2
|
||||
derived = db.added[0]
|
||||
version = db.added[1]
|
||||
assert isinstance(version, DatasetVersion)
|
||||
assert version.dataset_id == output_dataset_id
|
||||
assert version.version == 1
|
||||
assert derived.id == output_dataset_id
|
||||
assert derived.metadata_json is not None
|
||||
assert derived.metadata_json["operation"] == "raster.ndvi"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import Dataset, DatasetVersion
|
||||
from app.schemas.dataset import DatasetTemporalUpdate
|
||||
from app.schemas.temporal import TemporalComparisonRequest, TemporalObjectChanges
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
class ScalarQuery:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
def filter(self, *args): # noqa: ANN002, ARG002
|
||||
return self
|
||||
|
||||
def scalar(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class ScalarSession:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
def query(self, *args): # noqa: ANN002, ARG002
|
||||
return ScalarQuery(self.value)
|
||||
|
||||
|
||||
class VersionQuery:
|
||||
def __init__(self, latest: DatasetVersion | None):
|
||||
self.latest = latest
|
||||
|
||||
def filter(self, *args): # noqa: ANN002, ARG002
|
||||
return self
|
||||
|
||||
def order_by(self, *args): # noqa: ANN002, ARG002
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.latest
|
||||
|
||||
|
||||
class TemporalUpdateSession:
|
||||
def __init__(self, dataset: Dataset, latest: DatasetVersion | None):
|
||||
self.dataset = dataset
|
||||
self.latest = latest
|
||||
self.added: list[object] = []
|
||||
|
||||
def get(self, model, item_id): # noqa: ANN001
|
||||
return self.dataset if model is Dataset and item_id == self.dataset.id else None
|
||||
|
||||
def query(self, model): # noqa: ANN001
|
||||
assert model is DatasetVersion
|
||||
return VersionQuery(self.latest)
|
||||
|
||||
def add(self, item): # noqa: ANN001
|
||||
self.added.append(item)
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def refresh(self, _item):
|
||||
return None
|
||||
|
||||
|
||||
def temporal_dataset(*, project_id, observed_year: int, metric_method: str = "feature_count") -> Dataset:
|
||||
return Dataset(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
name=f"snapshot-{observed_year}.geojson",
|
||||
dataset_type="vector",
|
||||
source="official",
|
||||
dataset_role="reference",
|
||||
temporal_series_key="official:test:mol",
|
||||
observed_at=datetime(observed_year, 1, 1, tzinfo=timezone.utc),
|
||||
source_version=str(observed_year),
|
||||
source_metadata={
|
||||
"selection_aggregation": {
|
||||
"method": metric_method,
|
||||
"label": "Objecten",
|
||||
"unit": "objecten",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_temporal_migration_and_models_align() -> None:
|
||||
migration = (ROOT / "backend/alembic/versions/202607140001_temporal_dataset_foundation.py").read_text(encoding="utf-8")
|
||||
for field in (
|
||||
"temporal_series_key",
|
||||
"observed_at",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
"temporal_granularity",
|
||||
"source_version",
|
||||
):
|
||||
assert field in migration
|
||||
assert hasattr(Dataset, field)
|
||||
assert "ix_vector_features_dataset_source_feature" in migration
|
||||
assert 'down_revision = "202606120900"' in migration
|
||||
|
||||
|
||||
def test_temporal_metadata_requires_an_explicit_series_and_observation_date() -> None:
|
||||
with pytest.raises(AppError, match="observed_at is required"):
|
||||
DatasetService._validate_temporal_metadata(
|
||||
temporal_series_key="official:test:mol",
|
||||
observed_at=None,
|
||||
valid_from=None,
|
||||
valid_to=None,
|
||||
temporal_granularity="year",
|
||||
source_version="2024",
|
||||
)
|
||||
with pytest.raises(AppError, match="valid_to must be"):
|
||||
DatasetService._validate_temporal_metadata(
|
||||
temporal_series_key="official:test:mol",
|
||||
observed_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
valid_from=datetime(2024, 12, 31, tzinfo=timezone.utc),
|
||||
valid_to=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
temporal_granularity="year",
|
||||
source_version="2024",
|
||||
)
|
||||
|
||||
|
||||
def test_temporal_metadata_update_appends_provenance_version_and_is_idempotent() -> None:
|
||||
project_id = uuid4()
|
||||
dataset = temporal_dataset(project_id=project_id, observed_year=2024)
|
||||
dataset.status = "ready"
|
||||
dataset.metadata_json = {}
|
||||
latest = DatasetVersion(
|
||||
dataset_id=dataset.id,
|
||||
version=3,
|
||||
observed_at=dataset.observed_at,
|
||||
source_version="2024",
|
||||
)
|
||||
session = TemporalUpdateSession(dataset, latest)
|
||||
payload = DatasetTemporalUpdate(
|
||||
temporal_series_key="official:test:mol",
|
||||
observed_at=datetime(2025, 1, 1, tzinfo=timezone.utc),
|
||||
temporal_granularity="year",
|
||||
source_version="2025",
|
||||
)
|
||||
|
||||
updated = DatasetService.update_temporal_metadata(session, dataset.id, payload)
|
||||
|
||||
assert updated.observed_at == payload.observed_at
|
||||
assert latest.version == 3
|
||||
assert latest.observed_at == datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
assert len(session.added) == 2
|
||||
appended = session.added[1]
|
||||
assert isinstance(appended, DatasetVersion)
|
||||
assert appended.version == 4
|
||||
assert appended.observed_at == payload.observed_at
|
||||
|
||||
session.added.clear()
|
||||
DatasetService.update_temporal_metadata(session, dataset.id, payload)
|
||||
assert session.added == []
|
||||
|
||||
|
||||
def test_selection_area_aggregation_returns_hectares_without_loading_all_features() -> None:
|
||||
project_id = uuid4()
|
||||
dataset = temporal_dataset(project_id=project_id, observed_year=1969, metric_method="intersection_area")
|
||||
dataset.source_metadata["selection_aggregation"].update({"label": "Oppervlakte", "unit": "ha"})
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
ScalarSession(125_000.0),
|
||||
dataset=dataset,
|
||||
bbox={"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"},
|
||||
total_feature_count=40,
|
||||
)
|
||||
assert result["metric_value"] == 12.5
|
||||
assert result["metric_unit"] == "ha"
|
||||
assert result["feature_count"] == 40
|
||||
|
||||
|
||||
def test_temporal_compare_returns_delta_and_canonical_change_payload(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
earlier = temporal_dataset(project_id=project_id, observed_year=2021)
|
||||
later = temporal_dataset(project_id=project_id, observed_year=2024)
|
||||
|
||||
def get_dataset(_db, _project_id, dataset_id, _label):
|
||||
return earlier if dataset_id == earlier.id else later
|
||||
|
||||
def summarize(_db, *, dataset, bbox): # noqa: ARG001
|
||||
value = 100.0 if dataset.id == earlier.id else 115.0
|
||||
return {
|
||||
"metric_label": "Inwoners",
|
||||
"metric_value": value,
|
||||
"metric_unit": "inwoners",
|
||||
"aggregation_method": "area_weighted_sum",
|
||||
"feature_count": 10,
|
||||
"is_estimate": True,
|
||||
"warning": "Areal weighting",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(TemporalAnalysisService, "_get_temporal_dataset", staticmethod(get_dataset))
|
||||
monkeypatch.setattr(VectorFeatureService, "summarize_features_by_bbox", staticmethod(summarize))
|
||||
monkeypatch.setattr(
|
||||
TemporalAnalysisService,
|
||||
"_compare_identity_features",
|
||||
staticmethod(
|
||||
lambda *args, **kwargs: (
|
||||
TemporalObjectChanges(available=True, added_count=1, removed_count=0, modified_count=2, unchanged_count=7),
|
||||
{"type": "FeatureCollection", "features": []},
|
||||
[],
|
||||
)
|
||||
),
|
||||
)
|
||||
result = TemporalAnalysisService.compare(
|
||||
SimpleNamespace(),
|
||||
project_id=project_id,
|
||||
payload=TemporalComparisonRequest(
|
||||
earlier_dataset_id=earlier.id,
|
||||
later_dataset_id=later.id,
|
||||
bbox={"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3},
|
||||
),
|
||||
)
|
||||
assert result.metric.absolute_change == 15.0
|
||||
assert result.metric.percent_change == 15.0
|
||||
assert result.metric.is_estimate is True
|
||||
assert result.object_changes.modified_count == 2
|
||||
assert result.geojson["type"] == "FeatureCollection"
|
||||
|
||||
|
||||
def test_temporal_frontend_and_official_operator_contracts_exist() -> None:
|
||||
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
temporal_api = (ROOT / "frontend/src/services/api/temporal.ts").read_text(encoding="utf-8")
|
||||
population = (ROOT / "scripts/provision_mol_population_history.py").read_text(encoding="utf-8")
|
||||
landuse = (ROOT / "scripts/provision_mol_historical_landuse.py").read_text(encoding="utf-8")
|
||||
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
|
||||
assert "Laatste toestand" in workspace
|
||||
assert "Evolutie" in workspace
|
||||
assert "Vergelijk periode" in workspace
|
||||
assert "/temporal/compare" in temporal_api
|
||||
assert "Statbel" in population and "area_weighted_sum" in population
|
||||
assert "HistLandgebruik" in landuse and "intersection_area" in landuse
|
||||
assert "provision_mol_population_history.py" in dockerfile
|
||||
assert "provision_mol_historical_landuse.py" in dockerfile
|
||||
assert "fake" not in population.lower()
|
||||
Reference in New Issue
Block a user