Files
geointel/backend/app/services/mdk_bathymetry_acquisition_service.py
T
Codex 0aff8e3b8c
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
feat(scope): make Belgium and North Sea operational default
2026-07-22 02:11:48 +02:00

335 lines
15 KiB
Python

from __future__ import annotations
import hashlib
from datetime import UTC, datetime
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 uuid import UUID
from app.core.config import Settings, get_settings
from app.core.errors import AppError
from app.models import Dataset
from app.schemas.bathymetry import MdkBathymetryAcquireRequest, MdkBathymetryAcquisitionResult
from app.services.dataset_service import DatasetService
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
class MdkBathymetryAcquisitionService:
"""Bounded, fail-closed GetCoverage acquisition for the MDK Belgian North Sea depth model.
Acquisition only runs when:
- the operator explicitly enabled acquisition and configured a coverage id,
- the live strict-TLS readiness probe reports ``reachable``,
- the configured coverage id is advertised by the live capabilities document,
- the requested EPSG:4326 bbox stays within the configured size bound.
No depth values are ever synthesized, no insecure TLS fallback exists and the
LAT vertical reference is persisted with every artifact so it can never be
silently compared with TAW or mDNG data.
"""
PROVIDER = "mdk_bcp_bathymetry"
VERTICAL_REFERENCE = "LAT"
NATIVE_RESOLUTION_M = 20.0
MAX_PIXELS_PER_SIDE = 4096
LIMITATION = (
"Dieptewaarden zijn LAT-gerefereerd en gelden voor de bemonsterde survey-periode van het officiële "
"MDK-model. LAT mag nooit zonder gedocumenteerde datumtransformatie met TAW- of mDNG-gegevens worden "
"vergeleken; watervolume blijft zonder compatibel wateroppervlak niet ondersteund."
)
ATTRIBUTION = "Agentschap Maritieme Dienstverlening en Kust (MDK)"
LICENSE_NOTE = "Consult the official MDK product license before redistribution."
@staticmethod
def acquire(
db,
project_id: UUID,
payload: MdkBathymetryAcquireRequest,
*,
settings: Settings | None = None,
opener: Callable[..., Any] | None = None,
) -> dict[str, Any]:
resolved_settings = settings or get_settings()
if not resolved_settings.mdk_bathymetry_acquisition_enabled:
raise AppError(
code="MDK_BATHYMETRY_ACQUISITION_DISABLED",
message=(
"MDK bathymetry acquisition is disabled. Enable it explicitly with "
"MDK_BATHYMETRY_ACQUISITION_ENABLED=true after the readiness probe reports reachable."
),
status_code=409,
)
coverage_id = (resolved_settings.mdk_bathymetry_coverage_id or "").strip()
if not coverage_id:
raise AppError(
code="MDK_BATHYMETRY_COVERAGE_NOT_CONFIGURED",
message="MDK_BATHYMETRY_COVERAGE_ID is not configured; GeoIntel will not guess coverage identifiers.",
status_code=409,
)
bbox = MdkBathymetryAcquisitionService._validated_bbox(payload, resolved_settings)
probe = MdkBathymetryProbeService.probe(settings=resolved_settings, opener=opener)
if probe.get("status") != "reachable":
raise AppError(
code="MDK_BATHYMETRY_ENDPOINT_NOT_READY",
message="The live MDK readiness probe does not report a reachable, TLS-verified WCS endpoint.",
details={"probe_status": probe.get("status"), "probe_message": probe.get("message")},
status_code=502,
)
if coverage_id not in (probe.get("coverage_identifiers") or []):
raise AppError(
code="MDK_BATHYMETRY_COVERAGE_NOT_ADVERTISED",
message="The configured coverage id is not advertised by the live MDK capabilities document.",
details={
"configured_coverage_id": coverage_id,
"advertised_coverage_identifiers": probe.get("coverage_identifiers") or [],
},
status_code=502,
)
request_url = MdkBathymetryAcquisitionService._get_coverage_url(resolved_settings, coverage_id, bbox)
request_hash = hashlib.sha256(request_url.encode("utf-8")).hexdigest()
filename = f"mdk_bathymetry_{request_hash[:12]}.tif"
if not payload.force_refresh:
cached = MdkBathymetryAcquisitionService._cached_dataset(db, project_id, filename)
if cached is not None:
return MdkBathymetryAcquisitionResult(
output_dataset_id=cached.id,
reused=True,
provider=MdkBathymetryAcquisitionService.PROVIDER,
coverage_id=coverage_id,
bbox_epsg4326=bbox,
vertical_reference=MdkBathymetryAcquisitionService.VERTICAL_REFERENCE,
resolution_m=MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M,
attribution=MdkBathymetryAcquisitionService.ATTRIBUTION,
limitation_message=MdkBathymetryAcquisitionService.LIMITATION,
).model_dump(mode="json")
content, content_type = MdkBathymetryAcquisitionService._fetch(request_url, resolved_settings, opener)
validation = MdkBathymetryAcquisitionService._validate_geotiff(content)
acquired_at = datetime.now(UTC)
dataset = DatasetService.import_raster_bytes(
db,
project_id=project_id,
area_id=payload.area_id,
filename=filename,
content=content,
source=f"MDK Belgian Continental Shelf WCS {coverage_id}",
source_name=MdkBathymetryAcquisitionService.PROVIDER,
source_metadata={
"provider": MdkBathymetryAcquisitionService.PROVIDER,
"service": "WCS",
"service_version": "1.0.0",
"coverage_id": coverage_id,
"vertical_reference": MdkBathymetryAcquisitionService.VERTICAL_REFERENCE,
"native_resolution_m": MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M,
"bbox_epsg4326": bbox,
"attribution": MdkBathymetryAcquisitionService.ATTRIBUTION,
"license_note": MdkBathymetryAcquisitionService.LICENSE_NOTE,
"raster_validation": validation,
},
provenance_metadata={
"acquisition": "explicit_bounded_wcs_get_coverage",
"acquired_at": acquired_at.isoformat(),
"request_url": request_url,
"request_hash": request_hash,
"response_content_type": content_type,
"coverage_sha256": hashlib.sha256(content).hexdigest(),
"probe_status": probe.get("status"),
"probe_response_sha256": probe.get("response_sha256"),
"probe_checked_at": probe.get("checked_at"),
"limitation_message": MdkBathymetryAcquisitionService.LIMITATION,
},
)
return MdkBathymetryAcquisitionResult(
output_dataset_id=dataset.id,
reused=False,
provider=MdkBathymetryAcquisitionService.PROVIDER,
coverage_id=coverage_id,
bbox_epsg4326=bbox,
vertical_reference=MdkBathymetryAcquisitionService.VERTICAL_REFERENCE,
resolution_m=MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M,
attribution=MdkBathymetryAcquisitionService.ATTRIBUTION,
limitation_message=MdkBathymetryAcquisitionService.LIMITATION,
).model_dump(mode="json")
@staticmethod
def _validated_bbox(payload: MdkBathymetryAcquireRequest, settings: Settings) -> list[float]:
bbox = payload.bbox
min_x, min_y, max_x, max_y = (
float(bbox.min_x),
float(bbox.min_y),
float(bbox.max_x),
float(bbox.max_y),
)
if max_x <= min_x or max_y <= min_y:
raise AppError(
code="MDK_BATHYMETRY_INVALID_BBOX",
message="The requested bbox must have positive width and height in EPSG:4326.",
status_code=422,
)
area_deg2 = (max_x - min_x) * (max_y - min_y)
if area_deg2 > float(settings.mdk_bathymetry_max_bbox_deg2):
raise AppError(
code="MDK_BATHYMETRY_BBOX_TOO_LARGE",
message="The requested bbox exceeds the configured bounded acquisition size.",
details={
"bbox_area_deg2": area_deg2,
"max_bbox_deg2": float(settings.mdk_bathymetry_max_bbox_deg2),
},
status_code=422,
)
return [min_x, min_y, max_x, max_y]
@staticmethod
def _get_coverage_url(settings: Settings, coverage_id: str, bbox: list[float]) -> str:
parsed = urlsplit(settings.mdk_bathymetry_wcs_url.strip())
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise AppError(
code="MDK_BATHYMETRY_INVALID_CONFIGURATION",
message="MDK bathymetry acquisition requires an absolute HTTPS WCS URL.",
status_code=409,
)
width, height = MdkBathymetryAcquisitionService._pixel_dimensions(bbox)
parameters = dict(parse_qsl(parsed.query, keep_blank_values=True))
parameters.update(
{
"service": "WCS",
"request": "GetCoverage",
"version": "1.0.0",
"coverage": coverage_id,
"crs": settings.mdk_bathymetry_request_crs,
"bbox": ",".join(f"{value:.8f}" for value in bbox),
"width": str(width),
"height": str(height),
"format": "GeoTIFF",
}
)
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(parameters), ""))
@staticmethod
def _pixel_dimensions(bbox: list[float]) -> tuple[int, int]:
min_x, min_y, max_x, max_y = bbox
# Approximate meters per degree near the Belgian North Sea (~51.5N).
meters_per_deg_lat = 111_320.0
meters_per_deg_lon = 69_400.0
width = int((max_x - min_x) * meters_per_deg_lon / MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M)
height = int((max_y - min_y) * meters_per_deg_lat / MdkBathymetryAcquisitionService.NATIVE_RESOLUTION_M)
width = max(1, min(width, MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE))
height = max(1, min(height, MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE))
return width, height
@staticmethod
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
request = Request(
request_url,
headers={
"Accept": "image/tiff,*/*;q=0.1",
"User-Agent": "GeoIntel/1.0 MDK-bathymetry-bounded-acquisition",
},
)
max_bytes = settings.mdk_bathymetry_acquisition_max_response_mb * 1024 * 1024
try:
with (opener or urlopen)(request, timeout=settings.mdk_bathymetry_acquisition_timeout_seconds) as response:
content_type = str(response.headers.get("Content-Type", "")) if hasattr(response, "headers") else ""
content = response.read(max_bytes + 1)
except HTTPError as exc:
preview = exc.read(300).decode("utf-8", errors="replace")
raise AppError(
code="MDK_BATHYMETRY_PROVIDER_UNAVAILABLE",
message="The MDK WCS could not complete the bounded GetCoverage request.",
details={"provider_status_code": int(exc.code), "response_preview": preview},
status_code=502,
) from exc
except (URLError, TimeoutError, OSError) as exc:
raise AppError(
code="MDK_BATHYMETRY_PROVIDER_UNAVAILABLE",
message="The MDK WCS could not be reached for the bounded GetCoverage request.",
details={"reason": str(exc)},
status_code=502,
) from exc
if len(content) > max_bytes:
raise AppError(
code="MDK_BATHYMETRY_RESPONSE_TOO_LARGE",
message="The MDK coverage response exceeds the configured size limit.",
status_code=502,
)
if not content.startswith((b"II*\x00", b"MM\x00*")):
preview = content[:300].decode("utf-8", errors="replace")
raise AppError(
code="MDK_BATHYMETRY_INVALID_RESPONSE",
message="The MDK WCS did not return a GeoTIFF coverage.",
details={"content_type": content_type, "response_preview": preview},
status_code=502,
)
return content, content_type
@staticmethod
def _validate_geotiff(content: bytes) -> dict[str, Any]:
try:
import numpy as np
from rasterio.io import MemoryFile
except ImportError as exc:
raise AppError(
code="RASTER_PROCESSING_UNAVAILABLE",
message="Rasterio is required to validate the MDK bathymetry coverage before persistence.",
status_code=503,
) from exc
try:
with MemoryFile(content) as memory, memory.open() as source:
if source.count < 1:
raise AppError(
code="MDK_BATHYMETRY_INVALID_RESPONSE",
message="The MDK coverage contains no raster bands.",
status_code=502,
)
band = source.read(1, masked=True)
valid = band.compressed()
if valid.size == 0:
raise AppError(
code="MDK_BATHYMETRY_NO_VALID_DATA",
message="The MDK coverage contains no valid depth cells in this selection.",
status_code=422,
)
return {
"crs": str(source.crs) if source.crs else None,
"width": int(source.width),
"height": int(source.height),
"nodata": None if source.nodata is None else float(source.nodata),
"valid_cell_count": int(valid.size),
"minimum_value": float(np.min(valid)),
"maximum_value": float(np.max(valid)),
}
except AppError:
raise
except Exception as exc: # rasterio raises many distinct errors for corrupt input
raise AppError(
code="MDK_BATHYMETRY_INVALID_RESPONSE",
message="The MDK coverage could not be opened as a valid GeoTIFF.",
details={"reason": str(exc)},
status_code=502,
) from exc
@staticmethod
def _cached_dataset(db, project_id: UUID, filename: str) -> Dataset | None:
from pathlib import Path
candidate = (
db.query(Dataset)
.filter(
Dataset.project_id == project_id,
Dataset.name == filename,
Dataset.source_name == MdkBathymetryAcquisitionService.PROVIDER,
Dataset.status == "ready",
)
.order_by(Dataset.imported_at.desc())
.first()
)
return candidate if candidate and candidate.storage_path and Path(candidate.storage_path).is_file() else None