Add Flemish bathymetry partition workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 16:15:29 +02:00
parent 8ef42a23e5
commit 132c4feed8
34 changed files with 1862 additions and 27 deletions
+26
View File
@@ -1728,3 +1728,29 @@ The Dataset exposes profile count and nullable structured depth/width metrics.
It does not claim a continuous bed model, current depth or volume. Use
`scripts/provision_mol_bathymetry_profiles.py` for the canonical Mol operator
flow.
Provision the complete current Flemish land scope and then run the resumable
VHA municipality coordinator:
```bash
docker exec geointel python /app/scripts/provision_flanders_geographic_scope.py
docker exec geointel python /app/scripts/provision_flanders_bathymetry_profiles.py
```
The first command discovers all current VRBG RefGem municipalities, validates
a 270..300 safety range and persists their exact Areas. The second writes its
manifest after every partition. Repeating it reuses completed source
identities; `--force` deliberately refreshes them. A partial `--members` or
`--max-partitions` run never marks regional coverage complete.
Inspect the MDK North Sea WCS without downloading coverage:
```bash
docker exec geointel python /app/scripts/probe_mdk_bathymetry.py
```
Exit code `0` means verified capabilities; `2` means a truthful blocked
readiness state such as TLS or endpoint failure. Runtime controls are
`MDK_BATHYMETRY_PROBE_ENABLED`, `MDK_BATHYMETRY_WCS_URL`,
`MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS` and
`MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB`. TLS verification cannot be disabled.
+18
View File
@@ -27,6 +27,7 @@ from app.schemas import (
FloodHazardAcquireRequest,
FloodHazardPartitionSelectionRequest,
FloodHazardSelectionRequest,
BathymetryPartitionFinalizeRequest,
BathymetryProfileAcquireRequest,
ThematicRasterAcquireRequest,
ThematicRasterSelectionRequest,
@@ -54,6 +55,7 @@ from app.services.terrain_analysis_service import TerrainAnalysisService
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.utils.response import envelope
@@ -223,6 +225,13 @@ def list_bathymetry_sources(project_id: UUID, db: Session = Depends(get_db)):
return envelope({"items": items, "total": len(items)})
@router.get("/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness", response_model=dict)
def probe_mdk_bathymetry_readiness(project_id: UUID, db: Session = Depends(get_db)):
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
return envelope(MdkBathymetryProbeService.probe())
@router.post("/datasets/bathymetry/profiles/acquire", response_model=dict)
def acquire_bounded_bathymetry_profiles(
project_id: UUID,
@@ -239,6 +248,15 @@ def acquire_bounded_bathymetry_profiles(
return envelope(job)
@router.post("/datasets/bathymetry/profiles/partitions/finalize", response_model=dict)
def finalize_bathymetry_profile_partitions(
project_id: UUID,
payload: BathymetryPartitionFinalizeRequest,
db: Session = Depends(get_db),
):
return envelope(BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload))
@router.post("/datasets/thematic-raster/acquire", response_model=dict)
def acquire_bounded_thematic_raster(
project_id: UUID,
+17
View File
@@ -123,6 +123,23 @@ class Settings(BaseSettings):
le=256,
validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB",
)
mdk_bathymetry_probe_enabled: bool = Field(default=True, validation_alias="MDK_BATHYMETRY_PROBE_ENABLED")
mdk_bathymetry_wcs_url: str = Field(
default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
validation_alias="MDK_BATHYMETRY_WCS_URL",
)
mdk_bathymetry_probe_timeout_seconds: int = Field(
default=20,
ge=1,
le=120,
validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS",
)
mdk_bathymetry_probe_max_response_mb: int = Field(
default=4,
ge=1,
le=16,
validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB",
)
thematic_raster_enabled: bool = Field(default=True, validation_alias="THEMATIC_RASTER_ENABLED")
thematic_raster_wcs_url: str = Field(
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
+6
View File
@@ -66,8 +66,11 @@ from .flood_hazard import (
FloodHazardSelectionSummary,
)
from .bathymetry import (
BathymetryPartitionFinalizeRequest,
BathymetryPartitionFinalizationResult,
BathymetryProfileAcquireRequest,
BathymetryProfileAcquisitionResult,
BathymetrySourceProbeRead,
BathymetrySourceRead,
)
from .thematic_raster import (
@@ -209,6 +212,9 @@ __all__ = [
"FloodHazardSelectionSummary",
"BathymetryProfileAcquireRequest",
"BathymetryProfileAcquisitionResult",
"BathymetryPartitionFinalizeRequest",
"BathymetryPartitionFinalizationResult",
"BathymetrySourceProbeRead",
"BathymetrySourceRead",
"ThematicRasterAcquireRequest",
"ThematicRasterAcquisitionResult",
+61 -2
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from .operations import VectorSelectionBBox
@@ -25,7 +26,7 @@ class BathymetrySourceRead(BaseModel):
vertical_reference: str
horizontal_crs: str
native_resolution: str | None = None
integration_status: Literal["operational", "available_not_integrated", "catalog_only"]
integration_status: Literal["operational", "probe_only", "available_not_integrated", "catalog_only"]
acquisition_supported: bool
configured: bool
service_url: str | None = None
@@ -50,3 +51,61 @@ class BathymetryProfileAcquisitionResult(BaseModel):
measurement_date_max: str | None = None
attribution: str
limitation_message: str
class BathymetryPartitionFinalizeRequest(BaseModel):
partition_scope_key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9][a-z0-9_-]*$")
expected_area_ids: list[UUID] = Field(min_length=1, max_length=500)
dataset_ids: list[UUID] = Field(default_factory=list, max_length=500)
no_profile_area_ids: list[UUID] = Field(default_factory=list, max_length=500)
manifest_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
observed_at: datetime
@field_validator("expected_area_ids", "dataset_ids", "no_profile_area_ids")
@classmethod
def require_unique_ids(cls, value: list[UUID]) -> list[UUID]:
if len(value) != len(set(value)):
raise ValueError("Partition identifiers must be unique")
return value
class BathymetryPartitionFinalizationResult(BaseModel):
partition_scope_key: str
regional_partitions_complete: bool
partition_count: int = Field(ge=1)
data_partition_count: int = Field(ge=0)
no_profile_partition_count: int = Field(ge=0)
profile_count: int = Field(ge=0)
document_count: int = Field(ge=0)
structured_depth_count: int = Field(ge=0)
measurement_date_min: str | None = None
measurement_date_max: str | None = None
dataset_ids: list[UUID]
manifest_sha256: str
observed_at: datetime
limitation_message: str
class BathymetrySourceProbeRead(BaseModel):
source_key: str
status: Literal[
"disabled",
"invalid_configuration",
"tls_error",
"endpoint_unavailable",
"invalid_capabilities",
"reachable",
]
configured_url: str
capabilities_url: str | None = None
tls_verified: bool
capabilities_reachable: bool
acquisition_supported: bool = False
wcs_version: str | None = None
coverage_identifiers: list[str] = Field(default_factory=list)
advertised_formats: list[str] = Field(default_factory=list)
advertised_crs: list[str] = Field(default_factory=list)
response_sha256: str | None = None
checked_at: datetime
message: str
limitation_message: str
@@ -15,8 +15,10 @@ from shapely.geometry import Point, box, mapping
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.models import Area, Dataset, Project
from app.models import Area, Dataset, DatasetVersion, Project
from app.schemas.bathymetry import (
BathymetryPartitionFinalizeRequest,
BathymetryPartitionFinalizationResult,
BathymetryProfileAcquireRequest,
BathymetryProfileAcquisitionResult,
BathymetrySourceRead,
@@ -70,16 +72,16 @@ class BathymetryProfileAcquisitionService:
"vertical_reference": "LAT",
"horizontal_crs": "bronafhankelijk; expliciet per WCS-respons",
"native_resolution": "20 x 20 m",
"integration_status": "available_not_integrated",
"integration_status": "probe_only",
"acquisition_supported": False,
"configured": False,
"service_url": "https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/WCS_Public",
"service_url": "https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
"catalog_url": "https://www.vlaanderen.be/datavindplaats/catalogus/dieptemodel-van-de-zeebodem-belgisch-continentaal-plat-noordzee",
"attribution": "Agentschap Maritieme Dienstverlening en Kust",
"license_note": "Zie de officiële datasetmetadata en gebruiksvoorwaarden.",
"limitation_message": (
"Niet operationeel in GeoIntel totdat WCS, maritieme begrenzing, tegels, LAT-semantiek en "
"servercertificaten in een live integratietest zijn gevalideerd."
"Alleen een read-only GetCapabilities-probe is beschikbaar. Rasteracquisitie blijft uit totdat "
"WCS, maritieme begrenzing, tegels, LAT-semantiek en servercertificaten live zijn gevalideerd."
),
},
{
@@ -404,6 +406,8 @@ class BathymetryProfileAcquisitionService:
raw_features: list[dict[str, Any]],
scope_geometry,
watercourse_names: dict[int, dict[str, str | None]],
*,
partition_properties: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
normalized: list[dict[str, Any]] = []
dates: list[str] = []
@@ -465,6 +469,7 @@ class BathymetryProfileAcquisitionService:
"provider": BathymetryProfileAcquisitionService.PROVIDER,
"measurement_semantics": "historical_cross_section_profile_point",
"vertical_reference": "document-specific",
**(partition_properties or {}),
},
"geometry": mapping(point),
}
@@ -487,6 +492,17 @@ class BathymetryProfileAcquisitionService:
},
)
@staticmethod
def _municipality_name(area: Area | None) -> str | None:
if area is None:
return None
normalized = str(area.name or "").strip()
prefix = "Gemeente "
if not normalized.casefold().startswith(prefix.casefold()):
return None
municipality = normalized[len(prefix) :].split(" - ", 1)[0].strip()
return municipality or None
@staticmethod
def _cached_dataset(db, project_id: UUID, filename: str) -> Dataset | None:
return (
@@ -541,6 +557,8 @@ class BathymetryProfileAcquisitionService:
scope_geometry = BathymetryProfileAcquisitionService._scope_geometry(
db, project_id, payload.area_id, bbox_values
)
area = db.get(Area, payload.area_id) if payload.area_id else None
municipality = BathymetryProfileAcquisitionService._municipality_name(area)
exact_bbox = tuple(float(value) for value in scope_geometry.bounds)
request_identity = {
"provider": BathymetryProfileAcquisitionService.PROVIDER,
@@ -568,7 +586,16 @@ class BathymetryProfileAcquisitionService:
vhag_codes, resolved_settings, opener
)
feature_collection, summary = BathymetryProfileAcquisitionService._normalize_features(
raw_features, scope_geometry, names
raw_features,
scope_geometry,
names,
partition_properties={
"partition_area_id": str(area.id),
"partition_area_name": area.name,
**({"municipality": municipality} if municipality else {}),
}
if area
else None,
)
if summary["profile_count"] == 0:
raise AppError(
@@ -585,6 +612,11 @@ class BathymetryProfileAcquisitionService:
"theme": "bathymetry",
"layer_name": "bathymetry_profiles",
"coverage_scope": "municipality" if payload.area_id else "bounded_selection",
"partition_area_id": str(area.id) if area else None,
"partition_area_name": area.name if area else None,
"municipality": municipality,
"partitioned_source_audit": False,
"regional_partitions_complete": False,
"bbox_epsg4326": list(exact_bbox),
**summary,
"selection_aggregation": {
@@ -669,3 +701,160 @@ class BathymetryProfileAcquisitionService:
)
persisted = db.get(Dataset, dataset.id)
return BathymetryProfileAcquisitionService._result(persisted, reused=False)
@staticmethod
def finalize_partitions(
db,
project_id: UUID,
payload: BathymetryPartitionFinalizeRequest,
) -> dict[str, Any]:
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
expected_area_ids = set(payload.expected_area_ids)
dataset_ids = set(payload.dataset_ids)
no_profile_area_ids = set(payload.no_profile_area_ids)
areas: dict[UUID, Area] = {}
for area_id in expected_area_ids:
area = db.get(Area, area_id)
if area is None or area.project_id != project_id:
raise AppError(
code="BATHYMETRY_PARTITION_AREA_INVALID",
message="Every expected partition Area must belong to the project",
details={"area_id": str(area_id)},
status_code=400,
)
if BathymetryProfileAcquisitionService._municipality_name(area) is None:
raise AppError(
code="BATHYMETRY_PARTITION_AREA_INVALID",
message="Bathymetry partitions must use persisted municipality Areas",
details={"area_id": str(area_id), "area_name": area.name},
status_code=400,
)
areas[area_id] = area
if not no_profile_area_ids.issubset(expected_area_ids):
raise AppError(
code="BATHYMETRY_PARTITION_MANIFEST_INVALID",
message="No-profile partitions must be part of the expected Area set",
status_code=400,
)
datasets: list[Dataset] = []
data_area_ids: set[UUID] = set()
for dataset_id in payload.dataset_ids:
dataset = db.get(Dataset, dataset_id)
if (
dataset is None
or dataset.project_id != project_id
or dataset.source_name != BathymetryProfileAcquisitionService.PROVIDER
or dataset.status != "ready"
or dataset.area_id not in expected_area_ids
):
raise AppError(
code="BATHYMETRY_PARTITION_DATASET_INVALID",
message="Every partition Dataset must be a ready VHA profile Dataset scoped to an expected Area",
details={"dataset_id": str(dataset_id)},
status_code=400,
)
if dataset.area_id in data_area_ids:
raise AppError(
code="BATHYMETRY_PARTITION_DATASET_DUPLICATE",
message="A complete manifest may reference only one profile Dataset per Area",
details={"area_id": str(dataset.area_id)},
status_code=400,
)
data_area_ids.add(dataset.area_id)
datasets.append(dataset)
accounted_area_ids = data_area_ids.union(no_profile_area_ids)
if accounted_area_ids != expected_area_ids:
raise AppError(
code="BATHYMETRY_PARTITION_MANIFEST_INCOMPLETE",
message="Every expected Area must have one ready Dataset or an explicit no-profile result",
details={
"missing_area_ids": sorted(str(value) for value in expected_area_ids - accounted_area_ids),
"unexpected_area_ids": sorted(str(value) for value in accounted_area_ids - expected_area_ids),
},
status_code=400,
)
profile_count = 0
document_count = 0
structured_depth_count = 0
dates_min: list[str] = []
dates_max: list[str] = []
shared_metadata = {
"partition_scope_key": payload.partition_scope_key,
"partition_count": len(expected_area_ids),
"data_partition_count": len(datasets),
"no_profile_partition_count": len(no_profile_area_ids),
"partition_manifest_sha256": payload.manifest_sha256,
"partition_manifest_observed_at": payload.observed_at.isoformat(),
"partitioned_source_audit": True,
"regional_partitions_complete": True,
}
shared_provenance = {
"partition_manifest_sha256": payload.manifest_sha256,
"partition_manifest_observed_at": payload.observed_at.isoformat(),
"partition_scope_key": payload.partition_scope_key,
"regional_partitions_complete": True,
"no_profile_area_ids": sorted(str(value) for value in no_profile_area_ids),
}
for dataset in datasets:
source_metadata = dict(dataset.source_metadata or {})
provenance_metadata = dict(dataset.provenance_metadata or {})
profile_count += int(source_metadata.get("profile_count") or 0)
document_count += int(source_metadata.get("document_count") or 0)
structured_depth_count += int(source_metadata.get("structured_depth_count") or 0)
if source_metadata.get("measurement_date_min"):
dates_min.append(str(source_metadata["measurement_date_min"]))
if source_metadata.get("measurement_date_max"):
dates_max.append(str(source_metadata["measurement_date_max"]))
area = areas[dataset.area_id]
source_metadata.update(
{
**shared_metadata,
"coverage_scope": payload.partition_scope_key,
"partition_area_id": str(area.id),
"partition_area_name": area.name,
"municipality": BathymetryProfileAcquisitionService._municipality_name(area),
}
)
provenance_metadata.update(shared_provenance)
dataset.source_metadata = source_metadata
dataset.provenance_metadata = provenance_metadata
if dataset_ids:
versions = (
db.query(DatasetVersion)
.filter(DatasetVersion.dataset_id.in_(dataset_ids))
.all()
)
for version in versions:
version.source_metadata = dict(
next(dataset.source_metadata for dataset in datasets if dataset.id == version.dataset_id)
)
version.provenance_metadata = dict(
next(dataset.provenance_metadata for dataset in datasets if dataset.id == version.dataset_id)
)
db.commit()
return BathymetryPartitionFinalizationResult(
partition_scope_key=payload.partition_scope_key,
regional_partitions_complete=True,
partition_count=len(expected_area_ids),
data_partition_count=len(datasets),
no_profile_partition_count=len(no_profile_area_ids),
profile_count=profile_count,
document_count=document_count,
structured_depth_count=structured_depth_count,
measurement_date_min=min(dates_min) if dates_min else None,
measurement_date_max=max(dates_max) if dates_max else None,
dataset_ids=payload.dataset_ids,
manifest_sha256=payload.manifest_sha256,
observed_at=payload.observed_at,
limitation_message=BathymetryProfileAcquisitionService.LIMITATION,
).model_dump(mode="json")
@@ -0,0 +1,253 @@
from __future__ import annotations
from datetime import UTC, datetime
import hashlib
import ssl
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.request import Request, urlopen
from xml.etree import ElementTree
from app.core.config import Settings, get_settings
from app.schemas.bathymetry import BathymetrySourceProbeRead
class MdkBathymetryProbeService:
SOURCE_KEY = "mdk_bcp_bathymetry"
LIMITATION = (
"Deze probe leest alleen WCS GetCapabilities met strikte TLS-controle. "
"GeoIntel downloadt of activeert geen Noordzee-raster totdat endpoint, coverage-id, CRS, LAT, "
"nodata, resolutie, begrenzing en responslimieten live zijn gevalideerd."
)
@staticmethod
def _capabilities_url(configured_url: str) -> str:
parsed = urlsplit(configured_url.strip())
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise ValueError("The MDK WCS probe requires an absolute HTTPS URL")
parameters = dict(parse_qsl(parsed.query, keep_blank_values=True))
parameters.update({"service": "WCS", "request": "GetCapabilities", "version": "1.0.0"})
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(parameters), ""))
@staticmethod
def _read_capabilities(
capabilities_url: str,
settings: Settings,
opener: Callable[..., Any] | None,
) -> tuple[bytes, str]:
request = Request(
capabilities_url,
headers={
"Accept": "application/xml,text/xml;q=0.9,*/*;q=0.1",
"User-Agent": "GeoIntel/1.0 MDK-bathymetry-readiness-probe",
},
)
with (opener or urlopen)(request, timeout=settings.mdk_bathymetry_probe_timeout_seconds) as response:
limit = settings.mdk_bathymetry_probe_max_response_mb * 1024 * 1024
content = response.read(limit + 1)
if len(content) > limit:
raise ValueError("MDK WCS capabilities response exceeded the configured size limit")
content_type = str(response.headers.get("Content-Type") or "") if hasattr(response, "headers") else ""
return content, content_type
@staticmethod
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1].casefold()
@staticmethod
def _parse_capabilities(content: bytes) -> dict[str, Any]:
root = ElementTree.fromstring(content)
root_name = MdkBathymetryProbeService._local_name(root.tag)
if "capabilities" not in root_name:
raise ValueError("MDK endpoint did not return a WCS capabilities document")
coverage_identifiers: set[str] = set()
advertised_formats: set[str] = set()
advertised_crs: set[str] = set()
for element in root.iter():
local_name = MdkBathymetryProbeService._local_name(element.tag)
text = (element.text or "").strip()
if local_name in {"coverageofferingbrief", "coverageoffering"}:
for child in element:
if MdkBathymetryProbeService._local_name(child.tag) in {"name", "identifier"}:
identifier = (child.text or "").strip()
if identifier:
coverage_identifiers.add(identifier)
break
if local_name in {"format", "formats"} and text:
advertised_formats.add(text)
if local_name in {"requestresponsecrss", "requestcrss", "responsecrss", "nativecrss", "crs"} and text:
advertised_crs.add(text)
for attribute_value in element.attrib.values():
normalized = str(attribute_value).strip()
if "EPSG" in normalized.upper() or "CRS:" in normalized.upper():
advertised_crs.add(normalized)
return {
"wcs_version": str(root.attrib.get("version") or "") or None,
"coverage_identifiers": sorted(coverage_identifiers),
"advertised_formats": sorted(advertised_formats),
"advertised_crs": sorted(advertised_crs),
}
@staticmethod
def _result(
*,
settings: Settings,
status: str,
checked_at: datetime,
message: str,
capabilities_url: str | None = None,
tls_verified: bool = False,
capabilities_reachable: bool = False,
response_sha256: str | None = None,
parsed: dict[str, Any] | None = None,
) -> dict[str, Any]:
parsed = parsed or {}
return BathymetrySourceProbeRead(
source_key=MdkBathymetryProbeService.SOURCE_KEY,
status=status,
configured_url=settings.mdk_bathymetry_wcs_url,
capabilities_url=capabilities_url,
tls_verified=tls_verified,
capabilities_reachable=capabilities_reachable,
acquisition_supported=False,
wcs_version=parsed.get("wcs_version"),
coverage_identifiers=parsed.get("coverage_identifiers") or [],
advertised_formats=parsed.get("advertised_formats") or [],
advertised_crs=parsed.get("advertised_crs") or [],
response_sha256=response_sha256,
checked_at=checked_at,
message=message,
limitation_message=MdkBathymetryProbeService.LIMITATION,
).model_dump(mode="json")
@staticmethod
def probe(
*,
settings: Settings | None = None,
opener: Callable[..., Any] | None = None,
checked_at: datetime | None = None,
) -> dict[str, Any]:
resolved_settings = settings or get_settings()
now = checked_at or datetime.now(UTC)
if not resolved_settings.mdk_bathymetry_probe_enabled:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="disabled",
checked_at=now,
message="MDK bathymetry readiness probing is disabled.",
)
try:
capabilities_url = MdkBathymetryProbeService._capabilities_url(
resolved_settings.mdk_bathymetry_wcs_url
)
except ValueError as exc:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="invalid_configuration",
checked_at=now,
message=str(exc),
)
try:
content, content_type = MdkBathymetryProbeService._read_capabilities(
capabilities_url, resolved_settings, opener
)
except HTTPError as exc:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="endpoint_unavailable",
checked_at=now,
capabilities_url=capabilities_url,
tls_verified=True,
message=f"MDK WCS GetCapabilities returned HTTP {exc.code}.",
)
except ssl.SSLCertVerificationError:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="tls_error",
checked_at=now,
capabilities_url=capabilities_url,
message="MDK WCS TLS certificate validation failed; insecure fallback is prohibited.",
)
except URLError as exc:
reason = exc.reason
is_tls_error = isinstance(reason, (ssl.SSLError, ssl.CertificateError)) or "certificate" in str(
reason
).casefold()
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="tls_error" if is_tls_error else "endpoint_unavailable",
checked_at=now,
capabilities_url=capabilities_url,
message=(
"MDK WCS TLS certificate validation failed; insecure fallback is prohibited."
if is_tls_error
else "MDK WCS GetCapabilities could not be reached."
),
)
except (TimeoutError, OSError) as exc:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="endpoint_unavailable",
checked_at=now,
capabilities_url=capabilities_url,
message=f"MDK WCS GetCapabilities could not be reached ({type(exc).__name__}).",
)
except ValueError as exc:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="invalid_capabilities",
checked_at=now,
capabilities_url=capabilities_url,
tls_verified=True,
capabilities_reachable=True,
message=str(exc),
)
response_sha256 = hashlib.sha256(content).hexdigest()
try:
parsed = MdkBathymetryProbeService._parse_capabilities(content)
except (ElementTree.ParseError, ValueError) as exc:
detail = " ".join(str(exc).split())
if content_type:
detail = f"{detail} Content-Type: {content_type}."
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="invalid_capabilities",
checked_at=now,
capabilities_url=capabilities_url,
tls_verified=True,
capabilities_reachable=True,
response_sha256=response_sha256,
message=detail,
)
if not parsed["coverage_identifiers"]:
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="invalid_capabilities",
checked_at=now,
capabilities_url=capabilities_url,
tls_verified=True,
capabilities_reachable=True,
response_sha256=response_sha256,
parsed=parsed,
message="MDK WCS capabilities are reachable but advertise no coverage identifier.",
)
return MdkBathymetryProbeService._result(
settings=resolved_settings,
status="reachable",
checked_at=now,
capabilities_url=capabilities_url,
tls_verified=True,
capabilities_reachable=True,
response_sha256=response_sha256,
parsed=parsed,
message=(
"MDK WCS capabilities are reachable with verified TLS. "
"Raster acquisition remains disabled pending bounded coverage validation."
),
)
@@ -282,8 +282,11 @@ def test_bathymetry_acquisition_persists_reference_dataset_through_dataset_servi
assert captured["reference_layer_name"] == "bathymetry_profiles"
assert captured["source_metadata"]["theme"] == "bathymetry"
assert captured["source_metadata"]["volume_supported"] is False
assert captured["provenance_metadata"]["water_volume_available"] is False
assert captured["source_metadata"]["municipality"] == "Mol"
assert captured["source_metadata"]["regional_partitions_complete"] is False
payload = json.loads(captured["content"])
assert payload["features"][0]["properties"]["municipality"] == "Mol"
assert captured["provenance_metadata"]["water_volume_available"] is False
assert len(payload["features"]) == 2
@@ -0,0 +1,295 @@
from __future__ import annotations
from datetime import UTC, datetime
import json
from pathlib import Path
import ssl
import sys
from types import SimpleNamespace
from urllib.error import URLError
from uuid import uuid4
from fastapi.testclient import TestClient
import pytest
from app.core.config import Settings
from app.core.errors import AppError
from app.db.session import get_db
from app.main import app
from app.models import Area, Dataset, DatasetVersion, Project
from app.schemas.bathymetry import BathymetryPartitionFinalizeRequest
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import provision_flanders_geographic_scope as flanders_scope # noqa: E402
class BinaryResponse:
def __init__(self, content: bytes, *, content_type: str = "application/xml"):
self.content = content
self.headers = {"Content-Type": content_type}
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, size=-1):
return self.content if size < 0 else self.content[:size]
class JsonResponse:
def __init__(self, payload, *, url="https://geo.api.vlaanderen.be/VRBG/items"):
self.payload = payload
self.url = url
def raise_for_status(self):
return None
def json(self):
return self.payload
class SourceSession:
def __init__(self, payload):
self.payload = payload
def get(self, *_args, **_kwargs):
return JsonResponse(self.payload)
class VersionQuery:
def __init__(self, versions):
self.versions = versions
def filter(self, *_args):
return self
def all(self):
return self.versions
class FinalizeSession:
def __init__(self, rows, versions=None):
self.rows = rows
self.versions = versions or []
self.commit_count = 0
def get(self, model, row_id):
return self.rows.get((model, row_id))
def query(self, model):
assert model is DatasetVersion
return VersionQuery(self.versions)
def commit(self):
self.commit_count += 1
def capabilities_xml() -> bytes:
return b"""<?xml version="1.0"?>
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
<ContentMetadata>
<CoverageOfferingBrief>
<name>EL.GridCoverage</name>
<label>Belgian Continental Shelf bathymetry</label>
<lonLatEnvelope srsName="urn:ogc:def:crs:OGC:1.3:CRS84" />
</CoverageOfferingBrief>
</ContentMetadata>
<Capability><Request><GetCoverage><resultFormat><formats>GeoTIFF</formats></resultFormat></GetCoverage></Request></Capability>
</WCS_Capabilities>"""
def test_mdk_probe_parses_capabilities_without_enabling_acquisition() -> None:
seen = {}
def opener(request, timeout):
seen["url"] = request.full_url
seen["timeout"] = timeout
return BinaryResponse(capabilities_xml())
result = MdkBathymetryProbeService.probe(
settings=Settings(_env_file=None),
opener=opener,
checked_at=datetime(2026, 7, 17, tzinfo=UTC),
)
assert result["status"] == "reachable"
assert result["tls_verified"] is True
assert result["capabilities_reachable"] is True
assert result["acquisition_supported"] is False
assert result["coverage_identifiers"] == ["EL.GridCoverage"]
assert result["advertised_formats"] == ["GeoTIFF"]
assert result["response_sha256"]
assert "request=GetCapabilities" in seen["url"]
assert seen["timeout"] == 20
def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None:
calls = 0
def opener(_request, timeout):
nonlocal calls
assert timeout == 20
calls += 1
raise URLError(ssl.SSLCertVerificationError("hostname mismatch"))
result = MdkBathymetryProbeService.probe(
settings=Settings(_env_file=None),
opener=opener,
)
assert calls == 1
assert result["status"] == "tls_error"
assert result["tls_verified"] is False
assert result["capabilities_reachable"] is False
assert "insecure fallback is prohibited" in result["message"]
def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
project_id = uuid4()
db = SimpleNamespace(get=lambda model, row_id: Project(id=project_id, name="Mol") if model is Project else None)
monkeypatch.setattr(
MdkBathymetryProbeService,
"probe",
lambda: {
"source_key": "mdk_bcp_bathymetry",
"status": "tls_error",
"acquisition_supported": False,
},
)
app.dependency_overrides[get_db] = lambda: db
try:
response = TestClient(app).get(
f"/api/v1/projects/{project_id}/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness"
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert set(response.json()) == {"data"}
assert response.json()["data"]["status"] == "tls_error"
assert response.json()["data"]["acquisition_supported"] is False
def test_partition_finalization_requires_complete_area_accounting_and_updates_versions() -> None:
project_id = uuid4()
area_ids = [uuid4(), uuid4()]
dataset_id = uuid4()
project = Project(id=project_id, name="Flanders")
areas = [
Area(id=area_ids[0], project_id=project_id, name="Gemeente Mol - officiële grens"),
Area(id=area_ids[1], project_id=project_id, name="Gemeente Geel - officiële grens"),
]
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_ids[0],
name="vha.geojson",
dataset_type="vector",
source="VHA",
source_name=BathymetryProfileAcquisitionService.PROVIDER,
source_metadata={
"profile_count": 3,
"document_count": 2,
"structured_depth_count": 1,
"measurement_date_min": "1990-01-01",
"measurement_date_max": "2020-01-01",
},
provenance_metadata={},
status="ready",
)
version = DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1)
rows = {
(Project, project_id): project,
(Area, area_ids[0]): areas[0],
(Area, area_ids[1]): areas[1],
(Dataset, dataset_id): dataset,
}
db = FinalizeSession(rows, [version])
payload = BathymetryPartitionFinalizeRequest(
partition_scope_key="flanders",
expected_area_ids=area_ids,
dataset_ids=[dataset_id],
no_profile_area_ids=[area_ids[1]],
manifest_sha256="a" * 64,
observed_at=datetime(2026, 7, 17, tzinfo=UTC),
)
result = BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload)
assert result["regional_partitions_complete"] is True
assert result["partition_count"] == 2
assert result["data_partition_count"] == 1
assert result["no_profile_partition_count"] == 1
assert result["profile_count"] == 3
assert dataset.source_metadata["coverage_scope"] == "flanders"
assert dataset.source_metadata["municipality"] == "Mol"
assert dataset.source_metadata["partitioned_source_audit"] is True
assert version.source_metadata == dataset.source_metadata
assert version.provenance_metadata == dataset.provenance_metadata
assert db.commit_count == 1
incomplete = payload.model_copy(update={"no_profile_area_ids": []})
with pytest.raises(AppError) as exc_info:
BathymetryProfileAcquisitionService.finalize_partitions(
FinalizeSession(rows, [version]),
project_id,
incomplete,
)
assert exc_info.value.code == "BATHYMETRY_PARTITION_MANIFEST_INCOMPLETE"
def test_flanders_scope_discovery_uses_complete_unique_vrbg_inventory() -> None:
features = [
{
"type": "Feature",
"id": f"Refgem.{index:05d}",
"properties": {"NISCODE": f"{index:05d}", "NAAM": f"Gemeente {index:03d}"},
"geometry": {
"type": "Polygon",
"coordinates": [[[4.0, 50.5], [4.1, 50.5], [4.1, 50.6], [4.0, 50.5]]],
},
}
for index in range(1, 286)
]
scope, selected, source_url = flanders_scope.discover_flanders_scope(
SourceSession({"features": list(reversed(features))}),
timeout=30,
min_municipalities=270,
max_municipalities=300,
)
assert scope.key == "flanders"
assert scope.project_name == "Flanders Regional Workbench"
assert len(scope.members) == 285
assert len(set(scope.nis_codes)) == 285
assert [item["properties"]["NISCODE"] for item in selected] == sorted(scope.nis_codes)
assert source_url.startswith("https://geo.api.vlaanderen.be/")
def test_expansion_scripts_are_packaged_and_readiness_checked() -> 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")
for script in (
"provision_flanders_geographic_scope.py",
"provision_flanders_bathymetry_profiles.py",
"probe_mdk_bathymetry.py",
):
assert f"COPY scripts/{script}" in dockerfile
assert f"py_compile scripts/{script}" in readiness
sources = {
item["key"]: item
for item in BathymetryProfileAcquisitionService.list_sources()
}
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]