feat: add governed hydrology and historical imagery
This commit is contained in:
@@ -86,7 +86,13 @@ class FakeImageResponse:
|
||||
return self.content[:limit]
|
||||
|
||||
|
||||
def _selection_payload(*, side_m: float = 512.0, force_refresh: bool = True, area_id=None) -> OrthophotoAcquireRequest:
|
||||
def _selection_payload(
|
||||
*,
|
||||
side_m: float = 512.0,
|
||||
force_refresh: bool = True,
|
||||
area_id=None,
|
||||
product_key: str = "most_recent",
|
||||
) -> OrthophotoAcquireRequest:
|
||||
west, south = 199_000.0, 210_000.0
|
||||
transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
|
||||
min_lon, min_lat = transformer.transform(west, south)
|
||||
@@ -100,6 +106,7 @@ def _selection_payload(*, side_m: float = 512.0, force_refresh: bool = True, are
|
||||
"crs": "EPSG:4326",
|
||||
},
|
||||
area_id=area_id,
|
||||
product_key=product_key,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
|
||||
@@ -136,6 +143,25 @@ def test_orthophoto_request_is_bounded_and_uses_official_wms_contract() -> None:
|
||||
assert len(prepared["request_hash"]) == 64
|
||||
|
||||
|
||||
def test_orthophoto_product_registry_exposes_only_governed_official_layers() -> None:
|
||||
settings = Settings(_env_file=None)
|
||||
products = OrthophotoAcquisitionService.list_products(settings)
|
||||
keys = [item["key"] for item in products]
|
||||
|
||||
assert keys[0] == "most_recent"
|
||||
assert {"2025", "2012", "2008_2011", "2000_2003", "1979_1990", "1971"}.issubset(keys)
|
||||
assert next(item for item in products if item["key"] == "most_recent")["supports_detection"] is True
|
||||
assert all(item["supports_detection"] is False for item in products if item["key"] != "most_recent")
|
||||
|
||||
prepared = OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="1971"), settings)
|
||||
assert prepared["params"]["LAYERS"] == "OKZPAN71VL"
|
||||
assert prepared["wms_url"] == "https://geo.api.vlaanderen.be/OKZ/wms"
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
OrthophotoAcquisitionService._prepared_request(_selection_payload(product_key="arbitrary-layer"), settings)
|
||||
assert exc_info.value.code == "ORTHOPHOTO_PRODUCT_NOT_SUPPORTED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_m", "expected_code"),
|
||||
[(64.0, "ORTHOPHOTO_SELECTION_TOO_SMALL"), (1_200.0, "ORTHOPHOTO_SELECTION_TOO_LARGE")],
|
||||
@@ -240,7 +266,7 @@ def test_orthophoto_acquisition_reuses_fresh_exact_request_without_provider_call
|
||||
cached = Dataset(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
name=f"orthofoto_selectie_{prepared['request_hash'][:12]}.tif",
|
||||
name=f"orthofoto_most_recent_{prepared['request_hash'][:12]}.tif",
|
||||
dataset_type="raster",
|
||||
source="Digitaal Vlaanderen",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
@@ -277,6 +303,54 @@ def test_orthophoto_provider_rejects_non_image_response() -> None:
|
||||
assert exc_info.value.code == "ORTHOPHOTO_PROVIDER_INVALID_RESPONSE"
|
||||
|
||||
|
||||
def test_historical_orthophoto_persists_temporal_product_provenance(tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
payload = _selection_payload(product_key="2020")
|
||||
db = FakeSession({(Project, project_id): Project(id=project_id, name="Mol")})
|
||||
settings = Settings(_env_file=None, storage_root=str(tmp_path), orthophoto_resolution_m=1.0)
|
||||
prepared = OrthophotoAcquisitionService._prepared_request(payload, settings)
|
||||
response = FakeImageResponse(_source_tiff(prepared["width"], prepared["height"]))
|
||||
|
||||
result = OrthophotoAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
payload,
|
||||
settings=settings,
|
||||
opener=lambda *_args, **_kwargs: response,
|
||||
)
|
||||
|
||||
dataset = next(row for row in db.added if isinstance(row, Dataset))
|
||||
assert result["product_key"] == "2020"
|
||||
assert result["supports_detection"] is False
|
||||
assert dataset.observed_at.year == 2020
|
||||
assert dataset.temporal_granularity == "year"
|
||||
assert dataset.source_metadata["layer"] == "OMWRGB20VL"
|
||||
assert dataset.source_metadata["product_key"] == "2020"
|
||||
assert dataset.provenance_metadata["spatial_hash"] == prepared["spatial_hash"]
|
||||
|
||||
|
||||
def test_persisted_orthophoto_renders_browser_png(tmp_path) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
path = tmp_path / "ortho.tif"
|
||||
path.write_bytes(_source_tiff(32, 24))
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="ortho.tif",
|
||||
dataset_type="raster",
|
||||
source="Digitaal Vlaanderen",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
status="ready",
|
||||
storage_path=str(path),
|
||||
)
|
||||
db = FakeSession({(Dataset, dataset_id): dataset})
|
||||
|
||||
png = OrthophotoAcquisitionService.render_png(db, project_id, dataset_id)
|
||||
|
||||
assert png.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
def test_orthophoto_endpoint_returns_canonical_job_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
output_dataset_id = uuid4()
|
||||
@@ -307,6 +381,22 @@ def test_orthophoto_endpoint_returns_canonical_job_envelope(monkeypatch) -> None
|
||||
assert any(isinstance(row, Job) for row in db.added)
|
||||
|
||||
|
||||
def test_orthophoto_product_endpoint_returns_canonical_envelope() -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession({(Project, project_id): Project(id=project_id, name="Mol")})
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
response = TestClient(app).get(f"/api/v1/projects/{project_id}/datasets/orthophoto/products")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert set(body) == {"data"}
|
||||
assert body["data"]["total"] == len(body["data"]["items"])
|
||||
assert body["data"]["items"][0]["key"] == "most_recent"
|
||||
|
||||
|
||||
def test_frontend_connects_map_selection_to_existing_detection_and_qa_flows() -> None:
|
||||
app_source = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||
hook_source = (ROOT / "frontend" / "src" / "hooks" / "useMapOrthophotoAnalysis.ts").read_text(encoding="utf-8")
|
||||
|
||||
@@ -114,6 +114,36 @@ def test_population_keeps_configured_metric_and_adds_sector_count() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_station_measurement_uses_numeric_mean_without_area_extrapolation() -> None:
|
||||
dataset = themed_dataset("water", method="mean")
|
||||
dataset.source_name = "waterinfo"
|
||||
dataset.source_metadata.update(
|
||||
{
|
||||
"semantic_metrics": False,
|
||||
"selection_aggregation": {
|
||||
"metric_key": "water_level",
|
||||
"method": "mean",
|
||||
"property": "annual_mean_water_level_m",
|
||||
"label": "Jaargemiddelde waterstand",
|
||||
"unit": "m",
|
||||
"warning": "Puntmeting; geen gebiedsdekkend watervolume.",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
SequenceScalarSession([30.455]),
|
||||
dataset=dataset,
|
||||
bbox=BBOX,
|
||||
total_feature_count=1,
|
||||
)
|
||||
|
||||
assert result["metric_value"] == 30.455
|
||||
assert result["aggregation_method"] == "mean"
|
||||
assert result["metric_unit"] == "m"
|
||||
assert result["warning"] == "Puntmeting; geen gebiedsdekkend watervolume."
|
||||
|
||||
|
||||
def test_future_regional_imports_persist_semantic_aggregation_configuration() -> None:
|
||||
buildings = (ROOT / "scripts/provision_regional_grb_buildings.py").read_text(encoding="utf-8")
|
||||
context = (ROOT / "scripts/provision_regional_grb_context.py").read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from shapely.geometry import box
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_operator():
|
||||
script_path = ROOT / "scripts" / "provision_waterinfo_station_history.py"
|
||||
spec = importlib.util.spec_from_file_location("waterinfo_history_operator", script_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class JsonResponse:
|
||||
ok = True
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class JsonSession:
|
||||
def __init__(self, payloads):
|
||||
self.payloads = iter(payloads)
|
||||
self.calls = []
|
||||
|
||||
def get(self, url, *, params, timeout):
|
||||
self.calls.append((url, params, timeout))
|
||||
return JsonResponse(next(self.payloads))
|
||||
|
||||
|
||||
def test_waterinfo_station_discovery_filters_exact_area_and_uses_annual_group() -> None:
|
||||
module = load_operator()
|
||||
payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [5.1, 51.2]},
|
||||
"properties": {"ts_id": 5319042, "station_no": "L10_089", "station_name": "Mol/ScheppelijkeNete"},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [6.0, 52.0]},
|
||||
"properties": {"ts_id": 999, "station_no": "outside", "station_name": "Outside"},
|
||||
},
|
||||
],
|
||||
}
|
||||
session = JsonSession([payload])
|
||||
|
||||
raw, stations = module.discover_station_series(
|
||||
session,
|
||||
module.PARAMETERS["water_level"],
|
||||
box(5.0, 51.0, 5.3, 51.4),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert raw == payload
|
||||
assert [item["ts_id"] for item in stations] == ["5319042"]
|
||||
assert session.calls[0][1]["timeseriesgroup_id"] == "192784"
|
||||
assert session.calls[0][1]["request"] == "getTimeseriesValueLayer"
|
||||
|
||||
|
||||
def test_waterinfo_annual_values_reject_invalid_sentinel_and_keep_real_zero() -> None:
|
||||
module = load_operator()
|
||||
payload = [
|
||||
{
|
||||
"ts_id": 5319042,
|
||||
"data": [
|
||||
["2013-01-01T00:00:00.000+01:00", 30.46],
|
||||
["2014-01-01T00:00:00.000+01:00", -9999],
|
||||
["2015-01-01T00:00:00.000+01:00", 0.0],
|
||||
["2026-01-01T00:00:00.000+01:00", 99.0],
|
||||
],
|
||||
}
|
||||
]
|
||||
session = JsonSession([payload])
|
||||
|
||||
raw, values = module.fetch_annual_values(session, "5319042", from_year=2013, to_year=2025, timeout=30)
|
||||
|
||||
assert raw == payload
|
||||
assert values == {2013: 30.46, 2015: 0.0}
|
||||
assert session.calls[0][1]["request"] == "getTimeseriesValues"
|
||||
|
||||
|
||||
def test_waterinfo_snapshot_and_series_keep_station_identity_and_honest_metric() -> None:
|
||||
module = load_operator()
|
||||
parameter = module.PARAMETERS["water_level"]
|
||||
station = {
|
||||
"ts_id": "5319042",
|
||||
"geometry": {"type": "Point", "coordinates": [5.1, 51.2]},
|
||||
"properties": {
|
||||
"station_id": "123",
|
||||
"station_no": "L10_089",
|
||||
"station_name": "Mol/ScheppelijkeNete",
|
||||
"ts_unitsymbol": "m",
|
||||
},
|
||||
}
|
||||
|
||||
snapshot = module.build_snapshot(parameter, station, 2025, 30.455)
|
||||
|
||||
feature = snapshot["features"][0]
|
||||
assert module.series_key(parameter, station) == "waterinfo:water_level:annual:l10-089"
|
||||
assert feature["geometry"]["type"] == "Point"
|
||||
assert feature["properties"]["annual_mean_water_level_m"] == 30.455
|
||||
assert feature["properties"]["timeseries_id"] == "5319042"
|
||||
assert "volume" in parameter.limitation
|
||||
|
||||
|
||||
def test_waterinfo_operator_is_packaged_and_readiness_checked() -> None:
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
vector_service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "py_compile scripts/provision_waterinfo_station_history.py" in readiness
|
||||
assert "COPY scripts/provision_waterinfo_station_history.py" in dockerfile
|
||||
assert '"provision_waterinfo_station_history.py"' in vector_service
|
||||
assert '"sum", "mean", "area_weighted_sum"' in vector_service
|
||||
Reference in New Issue
Block a user