feat: complete governed Walloon coverage sources
This commit is contained in:
@@ -95,6 +95,7 @@ def test_all_in_one_dockerfile_copies_operator_scripts_for_runtime_use() -> None
|
||||
"activate_promoted_yolo_candidate.py",
|
||||
"manage_grb_refresh.py",
|
||||
"orthophoto_release_preflight.py",
|
||||
"provision_walous_sources.py",
|
||||
}
|
||||
for script_name in required_runtime_scripts:
|
||||
assert f"COPY scripts/{script_name} /app/scripts/{script_name}" in dockerfile
|
||||
@@ -149,6 +150,24 @@ def test_env_example_uses_runtime_env_names_read_by_backend_and_frontend() -> No
|
||||
assert "VITE_API_PROXY_TARGET=http://localhost:8000" in env_example
|
||||
|
||||
|
||||
def test_walloon_runtime_settings_are_editable_in_compose_and_unraid() -> None:
|
||||
files = [
|
||||
(ROOT / "docker-compose.yml").read_text(encoding="utf-8"),
|
||||
(ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8"),
|
||||
(ROOT / "deploy" / "unraid" / "geointel.env.example").read_text(encoding="utf-8"),
|
||||
(ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8"),
|
||||
(ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8"),
|
||||
]
|
||||
for content in files:
|
||||
assert "SPW_FLOOD_HAZARD_ENABLED" in content
|
||||
assert "SPW_FLOOD_HAZARD_MAPSERVER_URL" in content
|
||||
assert "WALOUS_ENABLED" in content
|
||||
assert "WALOUS_SOURCE_DIR" in content
|
||||
assert "WALOUS_ANALYSIS_RESOLUTION_M" in content
|
||||
assert "WALOUS_MAX_SIDE_M" in content
|
||||
assert "WALOUS_MAX_PIXELS" in content
|
||||
|
||||
|
||||
def test_frontend_uses_same_origin_api_proxy_by_default() -> None:
|
||||
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
|
||||
nginx_config = (ROOT / "frontend" / "nginx.conf").read_text(encoding="utf-8")
|
||||
|
||||
@@ -116,6 +116,9 @@ def test_regional_product_registry_is_explicit_and_source_specific() -> None:
|
||||
]
|
||||
assert products["spw_picc_waterways"]["collection"] == "28"
|
||||
assert products["spw_picc_water_surfaces"]["collection"] == "30"
|
||||
assert products["spw_flood_hazard_2021"]["collection"] == "2"
|
||||
assert products["spw_flood_hazard_2021"]["theme"] == "flood_hazard"
|
||||
assert products["spw_flood_hazard_2021"]["coverage_zones"] == ["wallonia"]
|
||||
assert products["urbis_buildings"]["coverage_zones"] == ["brussels"]
|
||||
assert products["urbis_buildings"]["license_note"] == "Buildings are published under CC0."
|
||||
assert "FPS Finance" in products["urbis_cadastral_parcels"]["license_note"]
|
||||
@@ -252,6 +255,82 @@ def test_spw_arcgis_paging_is_bounded_stable_and_clipped() -> None:
|
||||
assert all(item["properties"]["clipped_area_ha"] > 0 for item in features)
|
||||
|
||||
|
||||
def test_spw_flood_hazard_uses_separate_governed_endpoint_and_persists_classification() -> None:
|
||||
product = OfficialVectorAcquisitionService._product("spw_flood_hazard_2021")
|
||||
scope = Polygon(
|
||||
[(4.55, 50.58), (4.56, 50.58), (4.56, 50.59), (4.55, 50.59), (4.55, 50.58)]
|
||||
)
|
||||
scope_metric = Polygon(
|
||||
[_TO_LAMBERT72.transform(x, y) for x, y in scope.exterior.coords]
|
||||
)
|
||||
|
||||
def opener(raw_request, timeout):
|
||||
assert timeout == 180
|
||||
parsed = urlparse(raw_request.full_url)
|
||||
assert parsed.path.endswith("/EAU/ALEA_INOND/MapServer/2/query")
|
||||
query = parse_qs(parsed.query)
|
||||
assert query["outSR"] == ["4326"]
|
||||
return JsonResponse(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": 7,
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[
|
||||
[4.551, 50.581],
|
||||
[4.559, 50.581],
|
||||
[4.559, 50.589],
|
||||
[4.551, 50.589],
|
||||
[4.551, 50.581],
|
||||
]],
|
||||
},
|
||||
"properties": {
|
||||
"OBJECTID": 7,
|
||||
"LOCALID": "ALEA-7",
|
||||
"TYPEALEA": "Debordement",
|
||||
"CLASSEMENT": 130,
|
||||
"MILLESIME": 2021,
|
||||
},
|
||||
}
|
||||
],
|
||||
"exceededTransferLimit": False,
|
||||
}
|
||||
)
|
||||
|
||||
features, transfer = OfficialVectorAcquisitionService._fetch_features(
|
||||
product,
|
||||
scope,
|
||||
scope_metric,
|
||||
"wallonia",
|
||||
Settings(_env_file=None),
|
||||
opener,
|
||||
)
|
||||
|
||||
assert transfer["feature_count"] == 1
|
||||
assert features[0]["id"] == "2:ALEA-7"
|
||||
assert features[0]["properties"]["CLASSEMENT"] == 130
|
||||
assert features[0]["properties"]["source_name"] == "spw_flood_hazard"
|
||||
assert features[0]["properties"]["clipped_area_ha"] > 0
|
||||
|
||||
|
||||
def test_spw_flood_hazard_can_be_disabled_independently() -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")})
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
OfficialVectorAcquisitionService.acquire(
|
||||
db,
|
||||
project_id,
|
||||
request("spw_flood_hazard_2021", (4.55, 50.58, 4.56, 50.59)),
|
||||
settings=Settings(_env_file=None, SPW_FLOOD_HAZARD_ENABLED=False),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "SPW_FLOOD_HAZARD_NOT_CONFIGURED"
|
||||
|
||||
|
||||
def test_regional_products_require_the_persisted_authoritative_coverage_area() -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession({(Project, project_id): Project(id=project_id, name="Belgium")})
|
||||
|
||||
@@ -130,8 +130,9 @@ def test_product_registries_expose_honest_forest_agriculture_nature_and_soil() -
|
||||
"dov_soil_types",
|
||||
"spw_picc_buildings",
|
||||
"spw_picc_roads",
|
||||
"spw_picc_waterways",
|
||||
"spw_picc_water_surfaces",
|
||||
"spw_picc_waterways",
|
||||
"spw_picc_water_surfaces",
|
||||
"spw_flood_hazard_2021",
|
||||
"urbis_buildings",
|
||||
"urbis_cadastral_parcels",
|
||||
"urbis_street_axes",
|
||||
@@ -405,7 +406,7 @@ def test_official_vector_routes_and_frontend_use_canonical_backend_path(monkeypa
|
||||
|
||||
assert products_response.status_code == 200
|
||||
assert set(products_response.json()) == {"data"}
|
||||
assert products_response.json()["data"]["total"] == 12
|
||||
assert products_response.json()["data"]["total"] == 13
|
||||
assert acquire_response.status_code == 200
|
||||
assert set(acquire_response.json()) == {"data"}
|
||||
assert acquire_response.json()["data"]["job_type"] == "vector.official.acquire"
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
from pyproj import Transformer
|
||||
import rasterio
|
||||
from rasterio.transform import from_origin
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models import Dataset, Job, Project
|
||||
from app.schemas.thematic_raster import ThematicRasterAcquireRequest, ThematicRasterSelectionRequest
|
||||
from app.schemas.temporal import TemporalComparisonRequest
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||
from app.services.walous_land_cover_service import WalousLandCoverService
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *_args):
|
||||
return self
|
||||
|
||||
def order_by(self, *_args):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, project, dataset=None):
|
||||
self.project = project
|
||||
self.dataset = dataset
|
||||
self.added = []
|
||||
|
||||
def get(self, model, row_id):
|
||||
if model is Project and row_id == self.project.id:
|
||||
return self.project
|
||||
if model is Dataset and self.dataset is not None and row_id == self.dataset.id:
|
||||
return self.dataset
|
||||
match = next((item for item in self.added if isinstance(item, model) and item.id == row_id), None)
|
||||
if match is not None:
|
||||
return match
|
||||
return None
|
||||
|
||||
def query(self, _model):
|
||||
return FakeQuery()
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def rollback(self):
|
||||
return None
|
||||
|
||||
def refresh(self, row):
|
||||
return row
|
||||
|
||||
|
||||
def make_source(path: Path) -> tuple[list[float], np.ndarray]:
|
||||
to_3812 = Transformer.from_crs("EPSG:4326", "EPSG:3812", always_xy=True)
|
||||
to_4326 = Transformer.from_crs("EPSG:3812", "EPSG:4326", always_xy=True)
|
||||
x, y = to_3812.transform(4.85, 50.45)
|
||||
transform = from_origin(x, y + 100, 1, 1)
|
||||
values = np.ones((100, 100), dtype="uint8")
|
||||
values[:, 20:40] = 4
|
||||
values[:, 40:50] = 8
|
||||
values[:, 50:70] = 9
|
||||
values[:, 70:] = 2
|
||||
with rasterio.open(
|
||||
path,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
width=100,
|
||||
height=100,
|
||||
count=1,
|
||||
dtype="uint8",
|
||||
crs="EPSG:3812",
|
||||
transform=transform,
|
||||
nodata=255,
|
||||
) as target:
|
||||
target.write(values, 1)
|
||||
min_lon, min_lat = to_4326.transform(x, y)
|
||||
max_lon, max_lat = to_4326.transform(x + 100, y + 100)
|
||||
return [min_lon, min_lat, max_lon, max_lat], values
|
||||
|
||||
|
||||
def settings(source_dir: Path) -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
WALOUS_SOURCE_DIR=str(source_dir),
|
||||
WALOUS_ANALYSIS_RESOLUTION_M=10,
|
||||
WALOUS_MAX_SIDE_M=60_000,
|
||||
WALOUS_MAX_PIXELS=1_000_000,
|
||||
)
|
||||
|
||||
|
||||
def test_walous_registry_reports_real_provisioning_state(tmp_path: Path) -> None:
|
||||
before = {item["key"]: item for item in WalousLandCoverService.list_products(settings=settings(tmp_path))}
|
||||
assert before["walous_land_cover_2023"]["status"] == "source_not_provisioned"
|
||||
make_source(tmp_path / "walous_land_cover_2023_3812.tif")
|
||||
after = {item["key"]: item for item in WalousLandCoverService.list_products(settings=settings(tmp_path))}
|
||||
assert after["walous_land_cover_2023"]["configured"] is True
|
||||
assert after["walous_land_cover_2023"]["source_crs"] == "EPSG:3812"
|
||||
assert after["walous_land_cover_2023"]["native_resolution_m"] == 1.0
|
||||
assert after["walous_land_cover_2023"]["analysis_resolution_m"] == 10.0
|
||||
assert after["walous_land_cover_2023"]["coverage_zones"] == ["wallonia"]
|
||||
|
||||
|
||||
def test_walous_acquisition_reads_real_classes_and_persists_provenance(tmp_path: Path, monkeypatch) -> None:
|
||||
bbox, _values = make_source(tmp_path / "walous_land_cover_2023_3812.tif")
|
||||
project = Project(id=uuid4(), name="Belgium")
|
||||
db = FakeSession(project)
|
||||
captured = {}
|
||||
output_id = uuid4()
|
||||
|
||||
def persist(_db, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id=output_id)
|
||||
|
||||
monkeypatch.setattr(DatasetService, "import_raster_bytes", persist)
|
||||
result = WalousLandCoverService.acquire(
|
||||
db,
|
||||
project.id,
|
||||
ThematicRasterAcquireRequest(
|
||||
bbox={"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3], "crs": "EPSG:4326"},
|
||||
product_key="walous_land_cover_2023",
|
||||
force_refresh=True,
|
||||
),
|
||||
settings=settings(tmp_path),
|
||||
)
|
||||
|
||||
assert result["output_dataset_id"] == str(output_id)
|
||||
assert result["resolution_m"] == 10
|
||||
assert captured["source_name"] == "spw_walous_land_cover"
|
||||
assert captured["source_metadata"]["classes_present"] == [1, 2, 4, 8, 9]
|
||||
assert captured["provenance_metadata"]["resampling"] == "nearest"
|
||||
assert captured["temporal_series_key"].startswith("spw:walous:land-cover:")
|
||||
assert captured["observed_at"].year == 2023
|
||||
|
||||
|
||||
def test_walous_analysis_returns_semantic_area_metrics(tmp_path: Path, monkeypatch) -> None:
|
||||
bbox, _values = make_source(tmp_path / "walous_land_cover_2023_3812.tif")
|
||||
project = Project(id=uuid4(), name="Belgium")
|
||||
output_id = uuid4()
|
||||
captured = {}
|
||||
|
||||
def persist(_db, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id=output_id)
|
||||
|
||||
monkeypatch.setattr(DatasetService, "import_raster_bytes", persist)
|
||||
db = FakeSession(project)
|
||||
payload = ThematicRasterAcquireRequest(
|
||||
bbox={"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3], "crs": "EPSG:4326"},
|
||||
product_key="walous_land_cover_2023",
|
||||
force_refresh=True,
|
||||
)
|
||||
WalousLandCoverService.acquire(db, project.id, payload, settings=settings(tmp_path))
|
||||
persisted_path = tmp_path / "derived.tif"
|
||||
persisted_path.write_bytes(captured["content"])
|
||||
dataset = Dataset(
|
||||
id=output_id,
|
||||
project_id=project.id,
|
||||
name="derived.tif",
|
||||
dataset_type="raster",
|
||||
source="SPW WALOUS",
|
||||
source_name="spw_walous_land_cover",
|
||||
source_metadata=captured["source_metadata"],
|
||||
provenance_metadata=captured["provenance_metadata"],
|
||||
storage_path=str(persisted_path),
|
||||
status="ready",
|
||||
)
|
||||
db.dataset = dataset
|
||||
result = WalousLandCoverService.analyze(
|
||||
db,
|
||||
project.id,
|
||||
output_id,
|
||||
ThematicRasterSelectionRequest(bbox=payload.bbox),
|
||||
)
|
||||
metrics = {item["metric_key"]: item["metric_value"] for item in result["summary"]["metrics"]}
|
||||
|
||||
assert result["metric_kind"] == "categorical_area"
|
||||
assert metrics["land_cover_observed_area_ha"] > 0
|
||||
assert metrics["forest_cover_area_ha"] > 0
|
||||
assert metrics["surface_water_area_ha"] > 0
|
||||
assert metrics["artificial_cover_area_ha"] > 0
|
||||
assert "water_volume" in result["unsupported_metrics"]
|
||||
|
||||
|
||||
def test_walous_render_png_uses_governed_class_colours(tmp_path: Path) -> None:
|
||||
bbox, _values = make_source(tmp_path / "walous_land_cover_2023_3812.tif")
|
||||
project = Project(id=uuid4(), name="Belgium")
|
||||
dataset = Dataset(
|
||||
id=uuid4(),
|
||||
project_id=project.id,
|
||||
name="walous.tif",
|
||||
dataset_type="raster",
|
||||
source="SPW WALOUS",
|
||||
source_name="spw_walous_land_cover",
|
||||
source_metadata={"product_key": "walous_land_cover_2023", "bbox_epsg4326": bbox},
|
||||
storage_path=str(tmp_path / "walous_land_cover_2023_3812.tif"),
|
||||
status="ready",
|
||||
)
|
||||
db = FakeSession(project, dataset)
|
||||
|
||||
rendered = WalousLandCoverService.render_png(db, project.id, dataset.id)
|
||||
|
||||
assert rendered.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
def test_walous_temporal_comparison_reuses_persisted_raster_metrics(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
earlier = Dataset(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
name="walous-2020.tif",
|
||||
dataset_type="raster",
|
||||
source="SPW WALOUS",
|
||||
source_name="spw_walous_land_cover",
|
||||
temporal_series_key="spw:walous:land-cover:selection",
|
||||
observed_at=datetime(2020, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
|
||||
source_version="WAL_OCS_IA__2020",
|
||||
)
|
||||
later = Dataset(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
name="walous-2023.tif",
|
||||
dataset_type="raster",
|
||||
source="SPW WALOUS",
|
||||
source_name="spw_walous_land_cover",
|
||||
temporal_series_key=earlier.temporal_series_key,
|
||||
observed_at=datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
|
||||
source_version="WAL_OCS_IA__2023",
|
||||
)
|
||||
rows = {earlier.id: earlier, later.id: later}
|
||||
|
||||
class TemporalSession:
|
||||
def get(self, model, row_id):
|
||||
return rows.get(row_id) if model is Dataset else None
|
||||
|
||||
def analyze(_db, _project_id, dataset_id, _payload):
|
||||
value = 4.0 if dataset_id == earlier.id else 5.5
|
||||
return {
|
||||
"summary": {
|
||||
"metric_label": "Gekarteerde landbedekking",
|
||||
"metric_value": value,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "nearest_resampled_cells_times_cell_area",
|
||||
"primary_metric_key": "land_cover_observed_area_ha",
|
||||
"metrics": [{
|
||||
"metric_key": "land_cover_observed_area_ha",
|
||||
"metric_label": "Gekarteerde landbedekking",
|
||||
"metric_value": value,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "nearest_resampled_cells_times_cell_area",
|
||||
"is_estimate": True,
|
||||
}],
|
||||
},
|
||||
"limitation_message": "Cell-based estimate.",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(WalousLandCoverService, "analyze", analyze)
|
||||
payload = TemporalComparisonRequest(
|
||||
earlier_dataset_id=earlier.id,
|
||||
later_dataset_id=later.id,
|
||||
bbox={"min_x": 4.8, "min_y": 50.4, "max_x": 4.9, "max_y": 50.5, "crs": "EPSG:4326"},
|
||||
)
|
||||
|
||||
result = TemporalAnalysisService.compare(TemporalSession(), project_id=project_id, payload=payload)
|
||||
|
||||
assert result.metric.earlier_value == 4.0
|
||||
assert result.metric.later_value == 5.5
|
||||
assert result.metric.absolute_change == 1.5
|
||||
assert result.object_changes.available is False
|
||||
|
||||
|
||||
def test_walous_api_routes_use_canonical_envelopes(monkeypatch) -> None:
|
||||
project = Project(id=uuid4(), name="Belgium")
|
||||
dataset_id = uuid4()
|
||||
db = FakeSession(project)
|
||||
monkeypatch.setattr(
|
||||
WalousLandCoverService,
|
||||
"acquire",
|
||||
lambda *_args, **_kwargs: {"output_dataset_id": str(dataset_id), "provider": WalousLandCoverService.PROVIDER},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
WalousLandCoverService,
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(dataset_id),
|
||||
"product_key": "walous_land_cover_2023",
|
||||
"theme": "land_cover_use",
|
||||
"metric_kind": "categorical_area",
|
||||
"selection_bbox": {"min_x": 4.8, "min_y": 50.4, "max_x": 4.9, "max_y": 50.5, "crs": "EPSG:4326"},
|
||||
"selected_cell_count": 100,
|
||||
"valid_cell_count": 100,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 10.0,
|
||||
"observation_year": 2023,
|
||||
"summary": {
|
||||
"metric_label": "Gekarteerde landbedekking",
|
||||
"metric_value": 1.0,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "nearest_resampled_cells_times_cell_area",
|
||||
"primary_metric_key": "land_cover_observed_area_ha",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["water_volume"],
|
||||
"limitation_message": "Cell-based estimate.",
|
||||
"generated_at": "2026-07-22T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
client = TestClient(app)
|
||||
products = client.get(f"/api/v1/projects/{project.id}/datasets/walous/products")
|
||||
acquisition = client.post(
|
||||
f"/api/v1/projects/{project.id}/datasets/walous/acquire",
|
||||
json={
|
||||
"bbox": {"min_x": 4.8, "min_y": 50.4, "max_x": 4.9, "max_y": 50.5, "crs": "EPSG:4326"},
|
||||
"product_key": "walous_land_cover_2023",
|
||||
},
|
||||
)
|
||||
selection = client.post(
|
||||
f"/api/v1/projects/{project.id}/datasets/{dataset_id}/raster/walous/select",
|
||||
json={"bbox": {"min_x": 4.8, "min_y": 50.4, "max_x": 4.9, "max_y": 50.5, "crs": "EPSG:4326"}},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert products.status_code == 200 and set(products.json()) == {"data"}
|
||||
assert products.json()["data"]["total"] == 2
|
||||
assert acquisition.status_code == 200 and set(acquisition.json()) == {"data"}
|
||||
assert acquisition.json()["data"]["job_type"] == "raster.walous.acquire"
|
||||
assert selection.status_code == 200 and selection.json()["data"]["theme"] == "land_cover_use"
|
||||
assert any(isinstance(item, Job) for item in db.added)
|
||||
Reference in New Issue
Block a user