Add Belgium and North Sea coverage foundation
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 01:45:00 +02:00
parent a8fd6e5e9c
commit 1e527bf810
29 changed files with 2524 additions and 10 deletions
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
from types import SimpleNamespace
from pathlib import Path
from uuid import uuid4
from fastapi.testclient import TestClient
from shapely.geometry import box
from app.core.errors import AppError
from app.main import app
from app.models import Area, Dataset, Project
from app.schemas.coverage import CoverageBBox
from app.services.coverage_registry_service import CoverageRegistryService, THEMES, ZONES
class FakeQuery:
def __init__(self, rows):
self.rows = rows
def filter(self, *_args, **_kwargs):
return self
def all(self):
return list(self.rows)
class FakeSession:
def __init__(self, *, project, areas, datasets):
self.project = project
self.areas = areas
self.datasets = datasets
def get(self, model, object_id):
if model is Project and str(self.project.id) == str(object_id):
return self.project
return None
def query(self, model):
if model is Area:
return FakeQuery(self.areas)
if model is Dataset:
return FakeQuery(self.datasets)
raise AssertionError(f"Unexpected query model: {model}")
def scope_area(name: str, geometry):
return SimpleNamespace(name=name, geometry=geometry)
def test_coverage_catalog_uses_normalized_contracts_and_does_not_change_provider_registry() -> None:
catalog = CoverageRegistryService.catalog()
assert set(catalog.themes) == set(THEMES)
assert set(catalog.zones) == set(ZONES)
assert catalog.statuses == ["unsupported", "not_configured", "partial", "operational"]
assert {source.source_name for source in catalog.sources} >= {
"ngi_adminvector",
"statbel",
"digitaal_vlaanderen",
"spw_geoportail",
"urbis",
"rbins_marine_reporting_units",
"rbins_msp_2026",
"mdk_bathymetry",
}
assert next(source for source in catalog.sources if source.source_name == "ngi_adminvector").license_note == "CC BY 4.0"
assert next(source for source in catalog.sources if source.source_name == "mdk_bathymetry").integration_status == "not_configured"
response = TestClient(app).get("/api/v1/external/coverage/catalog")
assert response.status_code == 200
assert response.json()["data"]["themes"] == list(THEMES)
def test_coverage_resolver_only_reports_operational_for_materialized_ready_dataset() -> None:
project_id = uuid4()
project = SimpleNamespace(id=project_id)
areas = [
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
]
bbox = CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0)
without_materialized = CoverageRegistryService.resolve(
FakeSession(project=project, areas=areas, datasets=[]),
project_id,
bbox,
["admin"],
)
assert without_materialized.intersected_zones == ["flanders"]
assert without_materialized.items[0].status == "partial"
assert without_materialized.items[0].materialized_dataset_ids == []
dataset_id = uuid4()
materialized = SimpleNamespace(
id=dataset_id,
status="ready",
source_name="ngi_adminvector",
reference_layer_name="belgium_regions",
source_metadata={"coverage_zones": ["belgium", "flanders", "wallonia", "brussels"]},
)
with_materialized = CoverageRegistryService.resolve(
FakeSession(project=project, areas=areas, datasets=[materialized]),
project_id,
bbox,
["admin"],
)
admin_item = next(item for item in with_materialized.items if item.zone == "flanders")
assert admin_item.status == "operational"
assert admin_item.materialized_dataset_ids == [dataset_id]
def test_mixed_land_and_north_sea_selection_remains_split() -> None:
project_id = uuid4()
project = SimpleNamespace(id=project_id)
areas = [
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
scope_area("Belgian part of the North Sea", box(2.2, 51.1, 3.4, 51.9)),
scope_area("Belgian territorial sea (0-12 nautical miles)", box(2.7, 51.1, 3.4, 51.5)),
scope_area("Belgian exclusive economic zone beyond territorial sea", box(2.2, 51.5, 3.1, 51.9)),
scope_area("Belgian continental shelf beyond territorial sea", box(2.2, 51.5, 3.1, 51.9)),
]
result = CoverageRegistryService.resolve(
FakeSession(project=project, areas=areas, datasets=[]),
project_id,
CoverageBBox(minx=2.65, miny=51.05, maxx=2.85, maxy=51.2),
["admin", "bathymetry"],
)
assert result.intersected_zones == ["flanders", "territorial_sea"]
assert len(result.items) == 4
assert any("crosses coverage zones" in warning for warning in result.warnings)
bathymetry = next(item for item in result.items if item.zone == "territorial_sea" and item.theme == "bathymetry")
assert bathymetry.status == "not_configured"
assert bathymetry.materialized_dataset_ids == []
def test_flemish_materialization_is_theme_specific() -> None:
project_id = uuid4()
project = SimpleNamespace(id=project_id)
orthophoto_id = uuid4()
orthophoto = SimpleNamespace(
id=orthophoto_id,
status="ready",
source_name="digitaal_vlaanderen_orthophoto",
reference_layer_name="orthophoto",
source_metadata={"coverage_zones": ["flanders"]},
)
result = CoverageRegistryService.resolve(
FakeSession(
project=project,
areas=[scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5))],
datasets=[orthophoto],
),
project_id,
CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0),
["orthophoto", "roads"],
)
assert next(item for item in result.items if item.theme == "orthophoto").status == "operational"
assert next(item for item in result.items if item.theme == "roads").status == "partial"
def test_outside_scope_and_unknown_theme_are_explicit() -> None:
project_id = uuid4()
project = SimpleNamespace(id=project_id)
db = FakeSession(
project=project,
areas=[scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5))],
datasets=[],
)
result = CoverageRegistryService.resolve(
db,
project_id,
CoverageBBox(minx=7.0, miny=52.0, maxx=7.1, maxy=52.1),
["admin"],
)
assert result.intersected_zones == []
assert result.outside_supported_scope is True
assert result.items == []
try:
CoverageRegistryService.resolve(
db,
project_id,
CoverageBBox(minx=4.0, miny=50.0, maxx=4.1, maxy=50.1),
["invented_metric"],
)
except AppError as exc:
assert exc.code == "COVERAGE_THEME_UNSUPPORTED"
assert exc.status_code == 422
assert exc.details["unsupported_themes"] == ["invented_metric"]
else:
raise AssertionError("Unknown coverage theme was accepted")
def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbox() -> None:
root = Path(__file__).parents[2]
focus = (root / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
workspace_hook = (root / "frontend" / "src" / "hooks" / "useProjectWorkspace.ts").read_text(encoding="utf-8")
coverage_hook = (root / "frontend" / "src" / "hooks" / "useCoverageResolver.ts").read_text(encoding="utf-8")
map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
assert "Belgium and North Sea Workbench" in focus
assert "nationalProject" in workspace_hook
assert "data.areas.length > 0" in workspace_hook
assert "dataset.status === 'ready'" in workspace_hook
assert "externalApi.resolveCoverage" in coverage_hook
assert "coverage.outside_supported_scope" in map_workspace
assert "coverageStatusLabel" in map_workspace
@@ -0,0 +1,161 @@
from __future__ import annotations
import json
import sys
import zipfile
from pathlib import Path
import pytest
from shapely.geometry import box, mapping, shape
ROOT = Path(__file__).parents[2]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import provision_belgium_north_sea_scope as operator # noqa: E402
def marine_feature(identifier: str, geometry):
return {
"type": "Feature",
"id": identifier,
"geometry": mapping(geometry),
"properties": {"MarineReportingUnitId": identifier},
}
def test_marine_legal_scopes_are_derived_from_official_reporting_units() -> None:
reporting_units = {
"type": "FeatureCollection",
"features": [
marine_feature("ANS-BE-MS-1", box(0, 0, 10, 10)),
marine_feature("ANS-BE-AA-CW", box(0, 0, 2, 2)),
marine_feature("ANS-BE-AA-TEW", box(0, 2, 2, 4)),
marine_feature("ANS-BE-AA-OFFSHORE", box(2, 0, 10, 10)),
],
}
payload = operator.derive_marine_scope_payload(reporting_units)
by_zone = {
feature["properties"]["coverage_zone"]: feature
for feature in payload["features"]
}
assert set(by_zone) == {
"belgian_north_sea",
"territorial_sea",
"exclusive_economic_zone",
"continental_shelf",
}
assert shape(by_zone["territorial_sea"]["geometry"]).area == pytest.approx(8.0)
assert shape(by_zone["exclusive_economic_zone"]["geometry"]).equals(
shape(by_zone["continental_shelf"]["geometry"])
)
assert (
by_zone["exclusive_economic_zone"]["properties"]["legal_domain"]
!= by_zone["continental_shelf"]["properties"]["legal_domain"]
)
assert by_zone["territorial_sea"]["properties"]["derived_from_reporting_unit_ids"] == [
"ANS-BE-AA-CW",
"ANS-BE-AA-TEW",
]
def test_marine_scope_derivation_fails_when_a_required_unit_is_missing() -> None:
with pytest.raises(RuntimeError, match="ANS-BE-AA-CW"):
operator.derive_marine_scope_payload(
{
"type": "FeatureCollection",
"features": [
marine_feature("ANS-BE-MS-1", box(0, 0, 10, 10)),
marine_feature("ANS-BE-AA-TEW", box(0, 2, 2, 4)),
marine_feature("ANS-BE-AA-OFFSHORE", box(2, 0, 10, 10)),
],
}
)
def test_archive_extraction_accepts_one_safe_geopackage_and_rejects_traversal(tmp_path: Path) -> None:
archive = tmp_path / "adminvector.zip"
with zipfile.ZipFile(archive, "w") as handle:
handle.writestr("release/adminvector.gpkg", b"sqlite-bytes")
result = operator.extract_single_geopackage(archive, tmp_path / "output")
assert result.read_bytes() == b"sqlite-bytes"
unsafe = tmp_path / "unsafe.zip"
with zipfile.ZipFile(unsafe, "w") as handle:
handle.writestr("../adminvector.gpkg", b"unsafe")
with pytest.raises(RuntimeError, match="unsafe"):
operator.extract_single_geopackage(unsafe, tmp_path / "unsafe-output")
class FakeResponse:
def __init__(self, payload):
self.payload = payload
self.content = json.dumps(payload).encode("utf-8")
def raise_for_status(self):
return None
def json(self):
return self.payload
class FakeSession:
def __init__(self, pages):
self.pages = list(pages)
self.calls = []
def get(self, url, params, timeout):
self.calls.append({"url": url, "params": params, "timeout": timeout})
return FakeResponse(self.pages.pop(0))
def test_wfs_fetch_is_allowlisted_paginated_and_complete() -> None:
pages = [
{
"type": "FeatureCollection",
"numberMatched": 2,
"features": [{"type": "Feature", "id": "unit.1", "geometry": None, "properties": {}}],
},
{
"type": "FeatureCollection",
"numberMatched": 2,
"features": [{"type": "Feature", "id": "unit.2", "geometry": None, "properties": {}}],
},
]
session = FakeSession(pages)
payload = operator.fetch_wfs_layer(
session,
service_url=operator.RBINS_MRU_WFS_URL,
layer_name=operator.RBINS_MRU_LAYER,
timeout=30,
page_size=1,
)
assert [feature["id"] for feature in payload["features"]] == ["unit.1", "unit.2"]
assert [call["params"]["startIndex"] for call in session.calls] == [0, 1]
assert all(call["params"]["srsName"] == "EPSG:4326" for call in session.calls)
with pytest.raises(RuntimeError, match="allowlist"):
operator.fetch_wfs_layer(
FakeSession([]),
service_url=operator.RBINS_MSP_WFS_URL,
layer_name="untrusted:layer",
timeout=30,
)
def test_operator_is_packaged_and_guarded_by_readiness() -> None:
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
source = (ROOT / "scripts" / "provision_belgium_north_sea_scope.py").read_text(encoding="utf-8")
assert "COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/" in dockerfile
assert "py_compile scripts/provision_belgium_north_sea_scope.py" in readiness
assert "/datasets/upload" in source
assert "from app.models" not in source
assert "INSERT INTO vector_features" not in source
@@ -113,7 +113,10 @@ def test_tower_deploy_uses_single_container_unraid_compose() -> None:
for script in (powershell, bash):
assert "docker compose -f docker-compose.unraid.yml config" in script
assert "--build-arg GEOINTEL_INSTALL_AI=" in script
assert "-f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest ." in script
assert '--build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA"' in script
assert '--build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME"' in script
assert "-f deploy/unraid/Dockerfile.all-in-one" in script
assert "-t geointel-all-in-one:latest" in script
assert "docker compose -f docker-compose.unraid.yml build geointel" not in script
assert "bash deploy/unraid/run-dockerman-container.sh" in script
assert "LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh" in script