feat(scope): make Belgium and North Sea operational default
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

This commit is contained in:
Codex
2026-07-22 02:11:48 +02:00
parent 46884cbbc9
commit 0aff8e3b8c
61 changed files with 2499 additions and 194 deletions
+21
View File
@@ -50,6 +50,7 @@ from app.schemas import (
BathymetryProfileAcquireRequest,
BathymetryRasterSelectionRequest,
BathymetryRasterSelectionResponse,
MdkBathymetryAcquireRequest,
ThematicRasterAcquireRequest,
ThematicRasterProductRead,
ThematicRasterSelectionResponse,
@@ -92,6 +93,7 @@ from app.services.flood_hazard_acquisition_service import FloodHazardAcquisition
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
from app.services.bathymetry_raster_analysis_service import BathymetryRasterAnalysisService
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
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
@@ -342,6 +344,25 @@ def probe_mdk_bathymetry_readiness(project_id: UUID, db: Session = Depends(get_d
return envelope(MdkBathymetryProbeService.probe())
@router.post(
"/datasets/bathymetry/mdk/acquire",
response_model=Envelope[JobRead],
)
def acquire_bounded_mdk_bathymetry(
project_id: UUID,
payload: MdkBathymetryAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="raster.mdk_bathymetry.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: MdkBathymetryAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.post(
"/datasets/bathymetry/profiles/acquire",
response_model=Envelope[JobRead],
+6 -1
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas import Envelope
from app.schemas.export import (
@@ -47,7 +48,11 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
)
if payload.dataset_id is not None:
return envelope(ExportService.export_dataset_geojson(db, payload.dataset_id, payload.name).model_dump(mode="json"))
return envelope({})
raise AppError(
code="INVALID_EXPORT_REQUEST",
message="GeoJSON export request does not match any supported export target",
status_code=422,
)
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
+6 -1
View File
@@ -147,6 +147,11 @@ def capabilities() -> SystemCapabilitiesEnvelope:
)
yolo_configured = bool(configured_yolo and configured_yolo.configured)
yolo_status = configured_yolo.status if configured_yolo else "not_configured"
configured_sam = ModelRegistryService.get_model_capability(
settings.sam_model_id,
settings=settings,
task_type="segmentation",
)
postgis_ready = _database_checks()["postgis"].startswith("ok:")
return SystemCapabilitiesEnvelope(
data=SystemCapabilities(
@@ -155,7 +160,7 @@ def capabilities() -> SystemCapabilitiesEnvelope:
geopandas=_dependency_enabled("geopandas"),
yolo=yolo_configured,
yolo_status=yolo_status,
sam=False,
sam=bool(configured_sam and configured_sam.configured),
grb="bounded",
sentinel="planned",
version=settings.app_version,
+44
View File
@@ -247,6 +247,27 @@ class Settings(BaseSettings):
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
validation_alias="THEMATIC_RASTER_WCS_URL",
)
mdk_bathymetry_acquisition_enabled: bool = Field(
default=False,
validation_alias="MDK_BATHYMETRY_ACQUISITION_ENABLED",
)
mdk_bathymetry_coverage_id: str | None = Field(default=None, validation_alias="MDK_BATHYMETRY_COVERAGE_ID")
mdk_bathymetry_request_crs: str = Field(default="EPSG:4326", validation_alias="MDK_BATHYMETRY_REQUEST_CRS")
mdk_bathymetry_max_bbox_deg2: float = Field(
default=0.25,
gt=0,
validation_alias="MDK_BATHYMETRY_MAX_BBOX_DEG2",
)
mdk_bathymetry_acquisition_timeout_seconds: int = Field(
default=120,
ge=1,
validation_alias="MDK_BATHYMETRY_ACQUISITION_TIMEOUT_SECONDS",
)
mdk_bathymetry_acquisition_max_response_mb: int = Field(
default=160,
ge=1,
validation_alias="MDK_BATHYMETRY_ACQUISITION_MAX_RESPONSE_MB",
)
thematic_raster_min_side_m: float = Field(default=100.0, gt=0, validation_alias="THEMATIC_RASTER_MIN_SIDE_M")
thematic_raster_max_side_m: float = Field(default=60_000.0, gt=0, validation_alias="THEMATIC_RASTER_MAX_SIDE_M")
thematic_raster_max_pixels: int = Field(default=30_000_000, ge=1, validation_alias="THEMATIC_RASTER_MAX_PIXELS")
@@ -272,6 +293,29 @@ class Settings(BaseSettings):
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
yolo_seg_enabled: bool = Field(default=False, validation_alias="YOLO_SEG_ENABLED")
yolo_seg_model_path: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_PATH")
yolo_seg_model_id: str = Field(default="yolo-seg-configured", validation_alias="YOLO_SEG_MODEL_ID")
yolo_seg_model_display_name: str = Field(
default="Configured YOLO segmentation",
validation_alias="YOLO_SEG_MODEL_DISPLAY_NAME",
)
yolo_seg_model_version: str | None = Field(default=None, validation_alias="YOLO_SEG_MODEL_VERSION")
sam_enabled: bool = Field(default=False, validation_alias="SAM_ENABLED")
sam_model_path: str | None = Field(default=None, validation_alias="SAM_MODEL_PATH")
sam_model_id: str = Field(default="sam-configured", validation_alias="SAM_MODEL_ID")
sam_model_display_name: str = Field(
default="Configured SAM segmentation",
validation_alias="SAM_MODEL_DISPLAY_NAME",
)
sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION")
segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE")
segmentation_duplicate_iou_threshold: float = Field(
default=0.5,
ge=0.0,
le=1.0,
validation_alias="SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
)
ollama_enabled: bool = Field(default=False, validation_alias="OLLAMA_ENABLED")
ollama_base_url: str = Field(default="http://127.0.0.1:11434", validation_alias="OLLAMA_BASE_URL")
ollama_default_model: str = Field(default="qwen3.5:9b", validation_alias="OLLAMA_DEFAULT_MODEL")
+1 -1
View File
@@ -18,7 +18,7 @@ class Project(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
region: Mapped[str] = mapped_column(String(120), default="Kempen")
region: Mapped[str] = mapped_column(String(120), default="Belgium and Belgian North Sea")
status: Mapped[str] = mapped_column(String(32), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+4
View File
@@ -99,6 +99,8 @@ from .bathymetry import (
BathymetryRasterSelectionSummary,
BathymetrySourceProbeRead,
BathymetrySourceRead,
MdkBathymetryAcquireRequest,
MdkBathymetryAcquisitionResult,
)
from .thematic_raster import (
ThematicRasterAcquireRequest,
@@ -265,6 +267,8 @@ __all__ = [
"BathymetryPartitionFinalizationResult",
"BathymetrySourceProbeRead",
"BathymetrySourceRead",
"MdkBathymetryAcquireRequest",
"MdkBathymetryAcquisitionResult",
"ThematicRasterAcquireRequest",
"ThematicRasterAcquisitionResult",
"ThematicRasterMetric",
+18
View File
@@ -111,6 +111,24 @@ class BathymetrySourceProbeRead(BaseModel):
limitation_message: str
class MdkBathymetryAcquireRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
force_refresh: bool = False
class MdkBathymetryAcquisitionResult(BaseModel):
output_dataset_id: UUID
reused: bool
provider: str
coverage_id: str
bbox_epsg4326: list[float]
vertical_reference: str
resolution_m: float = Field(gt=0)
attribution: str
limitation_message: str
class BathymetryRasterSelectionRequest(BaseModel):
bbox: VectorSelectionBBox
area_id: UUID | None = None
+1 -1
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
class ProjectCreate(BaseModel):
name: str
description: str | None = None
region: str | None = "Kempen"
region: str | None = "Belgium and Belgian North Sea"
class ProjectUpdate(BaseModel):
@@ -134,8 +134,37 @@ class BathymetryProfileAcquisitionService:
)
@staticmethod
def list_sources() -> list[dict[str, Any]]:
return [BathymetrySourceRead(**item).model_dump() for item in BathymetryProfileAcquisitionService._SOURCES]
def list_sources(settings=None) -> list[dict[str, Any]]:
from app.core.config import get_settings
resolved_settings = settings or get_settings()
items: list[dict[str, Any]] = []
for source in BathymetryProfileAcquisitionService._SOURCES:
item = dict(source)
if item["key"] == "mdk_bcp_bathymetry":
mdk_configured = bool(
resolved_settings.mdk_bathymetry_acquisition_enabled
and (resolved_settings.mdk_bathymetry_coverage_id or "").strip()
)
item["acquisition_supported"] = True
item["configured"] = mdk_configured
if mdk_configured:
item["integration_status"] = "operational"
item["limitation_message"] = (
"Begrensde WCS-acquisitie is expliciet ingeschakeld en draait alleen wanneer de "
"live readiness-probe bereikbaar is en het geconfigureerde coverage-id door de "
"capabilities wordt geadverteerd. Dieptes blijven LAT-gerefereerd; watervolume "
"blijft zonder compatibel wateroppervlak niet ondersteund."
)
else:
item["limitation_message"] = (
"Begrensde WCS-acquisitie bestaat maar staat uit. Zet "
"MDK_BATHYMETRY_ACQUISITION_ENABLED=true en configureer MDK_BATHYMETRY_COVERAGE_ID "
"pas nadat de readiness-probe live 'reachable' rapporteert. Er wordt nooit "
"onbeveiligd of ongevalideerd gedownload."
)
items.append(item)
return [BathymetrySourceRead(**item).model_dump() for item in items]
@staticmethod
def _validate_bbox(payload: BathymetryProfileAcquireRequest) -> tuple[float, float, float, float]:
@@ -272,11 +272,11 @@ SOURCE_DEFINITIONS = (
attribution="Brussels UrbIS",
license_note="Consult the license of the selected UrbIS dataset.",
limitation_message=(
"Bounded UrbIS buildings and cadastral parcels are operational; "
"other Brussels themes remain unavailable until separately governed."
"Bounded UrbIS buildings, cadastral parcels, street axes and Land Cover blocks are operational. "
"Permanent water uses the official WB block class; no separate hydrography network is inferred."
),
materialized_source_names=("urbis",),
operational_themes=("buildings", "parcels"),
operational_themes=("buildings", "parcels", "roads", "surface_water", "land_cover_use"),
),
_contract(
source_name="rbins_marine_reporting_units",
@@ -341,7 +341,10 @@ SOURCE_DEFINITIONS = (
source_url="https://www.vlaanderen.be/datavindplaats",
attribution="Agentschap Maritieme Dienstverlening en Kust (MDK)",
license_note="Consult the official product license before acquisition.",
limitation_message="Strict-TLS acquisition and vertical datum evidence are not yet sufficient; no depths are synthesized.",
limitation_message=(
"Bounded strict-TLS WCS acquisition is implemented but stays disabled until the operator enables it "
"with a live-validated coverage id; no depths are synthesized."
),
),
)
@@ -377,6 +380,9 @@ REGIONAL_THEME_DATASETS: dict[str, dict[str, dict[str, tuple[str, ...]]]] = {
"urbis": {
"buildings": {"urbis": ("buildings",)},
"parcels": {"urbis": ("parcels",)},
"roads": {"urbis": ("roads",)},
"surface_water": {"urbis": ("water",)},
"land_cover_use": {"urbis": ("space_occupation", "forest")},
},
}
@@ -39,6 +39,75 @@ def pixel_bbox_to_epsg4326_polygon(bbox: list[float], tile: dict[str, Any], crs:
return polygon
def pixel_points_to_epsg4326_polygon(points: list[list[float]], tile: dict[str, Any], crs: str | None = None) -> Polygon:
if not isinstance(points, list) or len(points) < 3:
raise AppError(
code="SEGMENTATION_INVALID_MASK",
message="Segmentation mask polygon must contain at least three pixel points",
status_code=422,
)
try:
pixel_points = [(float(point[0]), float(point[1])) for point in points]
except (TypeError, ValueError, IndexError) as exc:
raise AppError(
code="SEGMENTATION_INVALID_MASK",
message="Segmentation mask polygon points must be numeric [x, y] pairs",
status_code=422,
) from exc
transform = tile.get("transform")
if isinstance(transform, list) and len(transform) >= 6:
coordinates = [_apply_gdal_transform(transform, x, y) for x, y in pixel_points]
else:
coordinates = [_project_pixel_with_bounds(tile, x, y) for x, y in pixel_points]
source_crs = crs or tile.get("crs") or tile.get("source_crs") or "EPSG:4326"
if str(source_crs).upper() not in {"EPSG:4326", "4326"}:
transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True)
coordinates = [transformer.transform(x, y) for x, y in coordinates]
if coordinates[0] != coordinates[-1]:
coordinates.append(coordinates[0])
polygon = Polygon(coordinates)
if not polygon.is_valid:
from shapely.validation import make_valid
repaired = make_valid(polygon)
polygon = _largest_polygon(repaired)
if polygon is None or polygon.is_empty or not polygon.is_valid or polygon.area <= 0:
raise AppError(
code="SEGMENTATION_INVALID_GEOMETRY",
message="Georeferenced segmentation geometry is invalid",
status_code=422,
)
return polygon
def _largest_polygon(geometry: Any) -> Polygon | None:
if isinstance(geometry, Polygon):
return geometry
candidates = [geom for geom in getattr(geometry, "geoms", []) if isinstance(geom, Polygon) and geom.area > 0]
if not candidates:
return None
return max(candidates, key=lambda geom: geom.area)
def _project_pixel_with_bounds(tile: dict[str, Any], px: float, py: float) -> tuple[float, float]:
bounds = tile.get("bounds")
pixel_window = tile.get("pixel_window")
if not (isinstance(bounds, list) and len(bounds) == 4 and isinstance(pixel_window, list) and len(pixel_window) == 4):
raise AppError(
code="DETECTION_TILE_MANIFEST_INVALID",
message="Tile manifest entries require transform or bounds plus pixel_window for georeferencing",
status_code=422,
)
left, bottom, right, top = [float(value) for value in bounds]
_, _, width, height = [float(value) for value in pixel_window]
if width <= 0 or height <= 0:
raise AppError(code="DETECTION_TILE_MANIFEST_INVALID", message="Tile pixel_window must have positive size", status_code=422)
return (left + (px / width) * (right - left), top - (py / height) * (top - bottom))
def _apply_gdal_transform(transform: list[float], x: float, y: float) -> tuple[float, float]:
c, a, b, f, d, e = [float(value) for value in transform[:6]]
return (a * x + b * y + c, d * x + e * y + f)
+41 -12
View File
@@ -130,18 +130,23 @@ class DetectionService:
)
if model.model_id == "manual-fixture-detector":
detections = DetectionService._persist_fixture_detections(
db=db,
project_id=project_id,
dataset_id=dataset_id,
analysis_run=analysis_run,
job=job,
model_name=model.model_id,
model_version=model.version,
raw_detections=parameters.get("fixture_detections"),
confidence_threshold=confidence_threshold,
class_filter=class_filter or [],
)
try:
detections = DetectionService._persist_fixture_detections(
db=db,
project_id=project_id,
dataset_id=dataset_id,
analysis_run=analysis_run,
job=job,
model_name=model.model_id,
model_version=model.version,
raw_detections=parameters.get("fixture_detections"),
confidence_threshold=confidence_threshold,
class_filter=class_filter or [],
)
except Exception as exc:
# A rejected fixture payload must never leave the run stuck in "running".
DetectionService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="DETECTION_INTERNAL_ERROR")
raise
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections))
return DetectionRunResponse(
analysis_run_id=analysis_run.id,
@@ -183,6 +188,10 @@ class DetectionService:
error_code=exc.code,
message=exc.message,
)
except Exception as exc:
# An unexpected inference error must never leave the run stuck in "running".
DetectionService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="DETECTION_INTERNAL_ERROR")
raise
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections), extra_result=postprocess_summary)
return DetectionRunResponse(
analysis_run_id=analysis_run.id,
@@ -195,8 +204,28 @@ class DetectionService:
message="YOLO detections persisted.",
)
DetectionService._mark_failed(
db,
analysis_run,
job,
code="DETECTION_MODEL_UNAVAILABLE",
message="Detection model is unavailable",
)
raise AppError(code="DETECTION_MODEL_UNAVAILABLE", message="Detection model is unavailable", status_code=503)
@staticmethod
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
try:
db.rollback()
except Exception:
pass
code = getattr(exc, "code", None) or fallback_code
message = getattr(exc, "message", None) or "Unexpected internal error during analysis run"
try:
DetectionService._mark_failed(db, analysis_run, job, code=str(code), message=str(message))
except Exception:
pass
@staticmethod
def get_run(db, analysis_run_id: uuid.UUID) -> DetectionRunRead:
run = db.get(AnalysisRun, analysis_run_id)
+16
View File
@@ -76,6 +76,22 @@ class JobService:
result_json["output_dataset_id"] = str(result_json["output_dataset_id"])
payload["result_json"] = result_json
raise
except Exception:
# An unexpected error must never leave the job stuck in "running".
try:
db.rollback()
except Exception:
pass
try:
JobService.mark_failed(
db,
created.id,
error_message="Unexpected internal error during synchronous job execution",
details={"code": "JOB_INTERNAL_ERROR"},
)
except Exception:
pass
raise
@staticmethod
def _coerce_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
@@ -0,0 +1,334 @@
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
@@ -24,11 +24,18 @@ class ModelAssetCatalogService:
if not model_directory.exists() or not model_directory.is_dir():
return ModelAssetListResponse(items=[], total=0, model_directory=str(model_directory))
items = [
ModelAssetCatalogService._asset_from_file(path, active_model_path=active_model_path)
candidate_paths = [
path
for path in sorted(model_directory.iterdir(), key=lambda item: item.name.lower())
if path.is_file() and path.suffix.lower() in ModelAssetCatalogService.SUPPORTED_SUFFIXES
]
if active_model_path is not None:
candidate_paths = [path for path in candidate_paths if path.resolve() == active_model_path]
items = [
ModelAssetCatalogService._asset_from_file(path, active_model_path=active_model_path)
for path in candidate_paths
]
return ModelAssetListResponse(items=items, total=len(items), model_directory=str(model_directory))
@staticmethod
@@ -72,8 +79,12 @@ class ModelAssetCatalogService:
size_bytes=path.stat().st_size,
sha256=ModelAssetCatalogService._sha256(path),
active=active_model_path == resolved_path,
status="available",
limitation_message="Local runtime model asset. GeoIntel will not download or mutate model weights.",
status="approved" if active_model_path == resolved_path else "available",
limitation_message=(
"Approved local runtime model asset. GeoIntel will not download or mutate model weights."
if active_model_path == resolved_path
else "Local development model asset. Configure it explicitly before production use."
),
will_download_models=False,
)
+102 -26
View File
@@ -5,6 +5,7 @@ from typing import Type
from app.core.config import Settings, get_settings
from app.schemas.detection import DetectionModelCapability
from app.services.segmentation_adapter import SamSegmentationAdapter, YoloSegmentationAdapter
from app.services.yolo_adapter import YoloDetectionAdapter
@@ -14,10 +15,16 @@ class ModelRegistryService:
settings: Settings | None = None,
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
task_type: str = "object_detection",
yolo_seg_adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
sam_adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
) -> list[DetectionModelCapability]:
resolved_settings = settings or get_settings()
if task_type == "segmentation":
return ModelRegistryService.list_segmentation_model_capabilities()
return ModelRegistryService.list_segmentation_model_capabilities(
settings=resolved_settings,
yolo_seg_adapter_class=yolo_seg_adapter_class,
sam_adapter_class=sam_adapter_class,
)
if task_type != "object_detection":
return []
return [
@@ -52,15 +59,28 @@ class ModelRegistryService:
settings: Settings | None = None,
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
task_type: str = "object_detection",
yolo_seg_adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
sam_adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
) -> DetectionModelCapability | None:
normalized = model_id.strip()
for model in ModelRegistryService.list_model_capabilities(settings=settings, yolo_adapter_class=yolo_adapter_class, task_type=task_type):
for model in ModelRegistryService.list_model_capabilities(
settings=settings,
yolo_adapter_class=yolo_adapter_class,
task_type=task_type,
yolo_seg_adapter_class=yolo_seg_adapter_class,
sam_adapter_class=sam_adapter_class,
):
if model.model_id == normalized:
return model
return None
@staticmethod
def list_segmentation_model_capabilities() -> list[DetectionModelCapability]:
def list_segmentation_model_capabilities(
settings: Settings | None = None,
yolo_seg_adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
sam_adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
) -> list[DetectionModelCapability]:
resolved_settings = settings or get_settings()
return [
DetectionModelCapability(
model_id="segmentation-placeholder",
@@ -70,7 +90,7 @@ class ModelRegistryService:
supported_classes=["building", "vegetation", "water", "landuse"],
configured=False,
status="not_configured",
limitation_message="Segmentation inference is not configured in Sprint 9; no SAM/YOLO-seg model is downloaded or executed.",
limitation_message="Segmentation inference is not configured for this placeholder; no model is downloaded or executed.",
version=None,
),
DetectionModelCapability(
@@ -84,30 +104,86 @@ class ModelRegistryService:
limitation_message="Fixture segmenter is for explicit tests/demo fixtures only and is not production inference.",
version="fixture-v1",
),
DetectionModelCapability(
model_id="yolo-seg-configured",
display_name="Configured YOLO segmentation",
framework="ultralytics/pytorch",
task_type="segmentation",
supported_classes=["building", "vegetation", "water", "landuse"],
configured=False,
status="not_configured",
limitation_message="YOLO-seg is not configured in Sprint 9. GeoIntel will not download segmentation model weights automatically.",
version=None,
),
DetectionModelCapability(
model_id="sam-configured",
display_name="Configured SAM segmentation",
framework="sam",
task_type="segmentation",
supported_classes=["building", "vegetation", "water", "landuse"],
configured=False,
status="not_configured",
limitation_message="SAM is not configured in Sprint 9 and is not installed as a backend dependency.",
version=None,
),
ModelRegistryService._configured_yolo_seg_capability(resolved_settings, yolo_seg_adapter_class),
ModelRegistryService._configured_sam_capability(resolved_settings, sam_adapter_class),
]
@staticmethod
def _configured_yolo_seg_capability(
settings: Settings,
adapter_class: Type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
) -> DetectionModelCapability:
configured = False
status = "not_configured"
limitation = (
"YOLO segmentation is disabled. Set YOLO_SEG_ENABLED=true and YOLO_SEG_MODEL_PATH to a local "
"segmentation model file to enable inference. GeoIntel never downloads model weights automatically."
)
model_path = Path(settings.yolo_seg_model_path).expanduser() if settings.yolo_seg_model_path else None
if settings.yolo_seg_enabled:
if not adapter_class.dependencies_available():
status = "dependency_unavailable"
limitation = "Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai]."
elif model_path is None:
limitation = "YOLO_SEG_MODEL_PATH is not set. GeoIntel will not download segmentation model weights automatically."
elif not model_path.exists() or not model_path.is_file():
limitation = "YOLO_SEG_MODEL_PATH does not point to an existing local model file. GeoIntel will not download segmentation model weights automatically."
else:
configured = True
status = "configured"
limitation = "Configured for local YOLO segmentation inference over an existing raster tile manifest."
return DetectionModelCapability(
model_id=settings.yolo_seg_model_id,
display_name=settings.yolo_seg_model_display_name,
framework="ultralytics/pytorch",
task_type="segmentation",
supported_classes=["building", "vegetation", "water", "landuse"],
configured=configured,
status=status,
limitation_message=limitation,
version=settings.yolo_seg_model_version,
)
@staticmethod
def _configured_sam_capability(
settings: Settings,
adapter_class: Type[SamSegmentationAdapter] = SamSegmentationAdapter,
) -> DetectionModelCapability:
configured = False
status = "not_configured"
limitation = (
"SAM is disabled. Set SAM_ENABLED=true and SAM_MODEL_PATH to a local SAM-compatible model file to "
"enable class-agnostic segmentation. GeoIntel never downloads model weights automatically."
)
model_path = Path(settings.sam_model_path).expanduser() if settings.sam_model_path else None
if settings.sam_enabled:
if not adapter_class.dependencies_available():
status = "dependency_unavailable"
limitation = "Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai]."
elif model_path is None:
limitation = "SAM_MODEL_PATH is not set. GeoIntel will not download segmentation model weights automatically."
elif not model_path.exists() or not model_path.is_file():
limitation = "SAM_MODEL_PATH does not point to an existing local model file. GeoIntel will not download segmentation model weights automatically."
else:
configured = True
status = "configured"
limitation = "Configured for local class-agnostic SAM segmentation over an existing raster tile manifest."
return DetectionModelCapability(
model_id=settings.sam_model_id,
display_name=settings.sam_model_display_name,
framework="ultralytics/sam",
task_type="segmentation",
supported_classes=["segment"],
configured=configured,
status=status,
limitation_message=limitation,
version=settings.sam_model_version,
)
@staticmethod
def _configured_yolo_capability(
settings: Settings,
@@ -71,6 +71,7 @@ class OfficialVectorProduct:
response_crs: str = "EPSG:4326"
identity_field: str | None = None
requires_coverage_area: bool = False
property_filter: dict[str, tuple[str, ...]] | None = None
class OfficialVectorAcquisitionService:
@@ -509,6 +510,57 @@ class OfficialVectorAcquisitionService:
identity_field="INSPIRE_ID",
requires_coverage_area=True,
),
OfficialVectorProduct(
key="urbis_street_axes",
display_name="UrbIS street axes",
theme="roads",
provider="Paradigm Brussels",
source_name="urbis",
reference_layer_name="roads",
service_type="WFS 2.0",
collection="urbisvector:StreetAxes",
source_crs="EPSG:31370",
source_version="2026-06-06",
observation_label="UrbIS revision 6 June 2026",
authority_level="authoritative",
catalog_url=(
"https://datastore.brussels/web/data/dataset/"
"2cf42541-1813-11ef-8a81-00090ffe0001"
),
attribution="Paradigm Brussels - UrbIS",
license_note="UrbIS topographic layers are published under CC0.",
limitation_message=(
"UrbIS street axes describe topographic road geometry for the Brussels-Capital "
"Region and are not a routing network or a traffic measurement."
),
source="UrbIS WFS",
observed_at=datetime(2026, 6, 6, tzinfo=UTC),
valid_from=None,
valid_to=None,
primary_metric={
"metric_key": "road_length",
"method": "intersection_length",
"label": "Wegaslengte",
"unit": "km",
"geometry_dimension": 1,
"is_estimate": False,
},
selection_metrics=(
{
"metric_key": "road_segment_count",
"method": "feature_count",
"label": "Wegsegmenten",
"unit": "objecten",
"geometry_dimension": 1,
},
),
geometry_types=("LineString", "MultiLineString"),
coverage_zones=("brussels",),
endpoint_kind="urbis_wfs",
response_crs="EPSG:31370",
identity_field="INSPIRE_ID",
requires_coverage_area=True,
),
OfficialVectorProduct(
key="urbis_cadastral_parcels",
display_name="UrbIS cadastral parcels",
@@ -561,6 +613,143 @@ class OfficialVectorAcquisitionService:
identity_field="INSPIRE_ID",
requires_coverage_area=True,
),
OfficialVectorProduct(
key="urbis_land_cover_blocks",
display_name="UrbIS land cover blocks",
theme="space_occupation",
provider="Paradigm Brussels",
source_name="urbis",
reference_layer_name="space_occupation",
service_type="WFS 2.0",
collection="urbisvector:Blocks",
source_crs="EPSG:31370",
source_version="UrbIS Land Cover 1.0; live WFS checked 2026-07-22",
observation_label="Current UrbIS land-cover WFS",
authority_level="authoritative",
catalog_url="https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_NL20240401.pdf",
attribution="Paradigm Brussels - UrbIS Land Cover",
license_note="UrbIS Land Cover is available through the official download and WFS service; retain source attribution.",
limitation_message=(
"UrbIS blocks describe physical and biological land cover. They are not zoning, ownership or legal land use. "
"The WFS does not expose a separate observation date per feature."
),
source="UrbIS WFS",
observed_at=None,
valid_from=None,
valid_to=None,
primary_metric={
"metric_key": "land_cover_area",
"method": "intersection_area",
"label": "Landbedekking",
"unit": "ha",
"geometry_dimension": 2,
"is_estimate": False,
},
selection_metrics=(
{
"metric_key": "land_cover_block_count",
"method": "feature_count",
"label": "Landbedekkingsblokken",
"unit": "objecten",
"geometry_dimension": 2,
},
),
coverage_zones=("brussels",),
endpoint_kind="urbis_wfs",
response_crs="EPSG:31370",
identity_field="INSPIRE_ID",
requires_coverage_area=True,
),
OfficialVectorProduct(
key="urbis_forest_parks",
display_name="UrbIS forests and parks",
theme="forest",
provider="Paradigm Brussels",
source_name="urbis",
reference_layer_name="forest",
service_type="WFS 2.0",
collection="urbisvector:Blocks",
source_crs="EPSG:31370",
source_version="UrbIS Land Cover 1.0; live WFS checked 2026-07-22",
observation_label="Current UrbIS land-cover WFS",
authority_level="authoritative",
catalog_url="https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_NL20240401.pdf",
attribution="Paradigm Brussels - UrbIS Land Cover",
license_note="UrbIS Land Cover is available through the official download and WFS service; retain source attribution.",
limitation_message="Includes only UrbIS block types FO (forest/woodland) and GB (parks); street trees and smaller green elements are not inferred.",
source="UrbIS WFS",
observed_at=None,
valid_from=None,
valid_to=None,
primary_metric={
"metric_key": "forest_park_area",
"method": "intersection_area",
"label": "Bos- en parkoppervlakte",
"unit": "ha",
"geometry_dimension": 2,
"is_estimate": False,
},
selection_metrics=(
{
"metric_key": "forest_park_count",
"method": "feature_count",
"label": "Bos- en parkzones",
"unit": "objecten",
"geometry_dimension": 2,
},
),
coverage_zones=("brussels",),
endpoint_kind="urbis_wfs",
response_crs="EPSG:31370",
identity_field="INSPIRE_ID",
requires_coverage_area=True,
property_filter={"TYPE": ("FO", "GB")},
),
OfficialVectorProduct(
key="urbis_water_surfaces",
display_name="UrbIS permanent water surfaces",
theme="water",
provider="Paradigm Brussels",
source_name="urbis",
reference_layer_name="water",
service_type="WFS 2.0",
collection="urbisvector:Blocks",
source_crs="EPSG:31370",
source_version="UrbIS Land Cover 1.0; live WFS checked 2026-07-22",
observation_label="Current UrbIS land-cover WFS",
authority_level="authoritative",
catalog_url="https://urbisdownload.datastore.brussels/UrbIS/TechSpec/LandCover_TechSpec_NL20240401.pdf",
attribution="Paradigm Brussels - UrbIS Land Cover",
license_note="UrbIS Land Cover is available through the official download and WFS service; retain source attribution.",
limitation_message="Includes only UrbIS block type WB: canals, lakes and watercourses with predominantly permanent water.",
source="UrbIS WFS",
observed_at=None,
valid_from=None,
valid_to=None,
primary_metric={
"metric_key": "water_surface_area",
"method": "intersection_area",
"label": "Permanent wateroppervlak",
"unit": "ha",
"geometry_dimension": 2,
"is_estimate": False,
},
selection_metrics=(
{
"metric_key": "water_surface_count",
"method": "feature_count",
"label": "Waterzones",
"unit": "objecten",
"geometry_dimension": 2,
},
),
coverage_zones=("brussels",),
endpoint_kind="urbis_wfs",
response_crs="EPSG:31370",
identity_field="INSPIRE_ID",
requires_coverage_area=True,
property_filter={"TYPE": ("WB",)},
),
)
return {product.key: product for product in products}
@@ -1081,6 +1270,12 @@ class OfficialVectorAcquisitionService:
scope_metric: Any,
coverage_scope: str,
) -> dict[str, Any] | None:
raw = dict(feature.get("properties") or {})
if product.property_filter and any(
str(raw.get(property_name) or "") not in allowed_values
for property_name, allowed_values in product.property_filter.items()
):
return None
dimension = 2 if any("Polygon" in item for item in product.geometry_types) else 1
try:
source_geometry = shape(feature.get("geometry"))
@@ -1122,7 +1317,6 @@ class OfficialVectorAcquisitionService:
)
if clipped_wgs84 is None:
return None
raw = dict(feature.get("properties") or {})
identity = (
raw.get(product.identity_field or "")
or feature.get("id")
+5 -1
View File
@@ -31,7 +31,11 @@ class ProjectService:
@staticmethod
def create_project(db: Session, payload: ProjectCreate) -> ProjectRead:
project = Project(name=payload.name.strip(), description=(payload.description or "").strip() or None, region=payload.region or "Kempen")
project = Project(
name=payload.name.strip(),
description=(payload.description or "").strip() or None,
region=payload.region or "Belgium and Belgian North Sea",
)
db.add(project)
db.commit()
db.refresh(project)
@@ -1,8 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from app.core.config import Settings
from app.core.errors import AppError
from app.services.yolo_adapter import _prediction_source, _to_list
@dataclass(frozen=True)
class SegmentationAdapterResult:
@@ -23,6 +28,159 @@ class SegmentationAdapter(Protocol):
"""Future segmentation adapters must local-import model dependencies inside execution paths."""
class _UltralyticsSegmentationAdapterBase:
"""Shared local-inference plumbing for ultralytics-backed segmentation models.
Model weights are never downloaded automatically; a missing local file or
missing dependency fails closed with an explicit error.
"""
def __init__(self, settings: Settings) -> None:
self.settings = settings
@staticmethod
def dependencies_available() -> bool:
try:
import torch # noqa: F401
import ultralytics # noqa: F401
except Exception:
return False
return True
def _require_model_file(self, model_path: Path) -> None:
if not model_path.exists() or not model_path.is_file():
raise AppError(
code="SEGMENTATION_MODEL_UNAVAILABLE",
message="Configured segmentation model file does not exist",
details={"model_path": str(model_path)},
status_code=503,
)
if not self.dependencies_available():
raise AppError(
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
status_code=503,
)
def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]:
if not tile_path.exists() or not tile_path.is_file():
raise AppError(
code="SEGMENTATION_TILE_NOT_FOUND",
message="Tile referenced by manifest does not exist",
details={"tile_path": str(tile_path)},
status_code=422,
)
try:
with _prediction_source(tile_path) as prediction_source:
return model.predict(
source=prediction_source,
conf=float(confidence_threshold),
imgsz=int(self.settings.yolo_image_size),
device=self.settings.yolo_device,
verbose=False,
)
except AppError:
raise
except Exception as exc:
raise AppError(
code="SEGMENTATION_INFERENCE_FAILED",
message="Configured segmentation inference failed for a raster tile",
details={"tile_path": str(tile_path), "error": str(exc)},
status_code=503,
) from exc
def _extract_masks(self, results: list[Any], default_class_name: str | None = None) -> list[dict[str, Any]]:
segmentations: list[dict[str, Any]] = []
max_masks = int(self.settings.segmentation_max_masks_per_tile)
for result in results:
names = getattr(result, "names", {}) or {}
masks = getattr(result, "masks", None)
if masks is None:
continue
polygons = getattr(masks, "xy", None) or []
boxes = getattr(result, "boxes", None)
confidence_values = _to_list(getattr(boxes, "conf", [])) if boxes is not None else []
class_values = _to_list(getattr(boxes, "cls", [])) if boxes is not None else []
bbox_values = _to_list(getattr(boxes, "xyxy", [])) if boxes is not None else []
for index, polygon in enumerate(polygons):
if len(segmentations) >= max_masks:
return segmentations
points = _to_list(polygon)
if not isinstance(points, list) or len(points) < 3:
continue
class_id = int(class_values[index]) if index < len(class_values) else -1
if default_class_name is not None:
class_name = default_class_name
else:
class_name = str(names.get(class_id, class_id))
confidence = float(confidence_values[index]) if index < len(confidence_values) else None
bbox = [float(value) for value in bbox_values[index]] if index < len(bbox_values) else None
segmentations.append(
{
"class_name": class_name,
"confidence": confidence,
"points": [[float(point[0]), float(point[1])] for point in points],
"bbox": bbox,
"properties": {"class_id": class_id},
}
)
return segmentations
class YoloSegmentationAdapter(_UltralyticsSegmentationAdapterBase):
def load_model(self, model_path: Path):
self._require_model_file(model_path)
try:
from ultralytics import YOLO
except ImportError as exc:
raise AppError(
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
message="YOLO segmentation dependencies are not importable. Install backend optional extras with geointel-backend[ai].",
status_code=503,
) from exc
try:
return YOLO(str(model_path))
except Exception as exc:
raise AppError(
code="SEGMENTATION_MODEL_LOAD_FAILED",
message="Configured YOLO segmentation model could not be loaded",
details={"model_path": str(model_path)},
status_code=503,
) from exc
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
results = self._predict(model, tile_path, confidence_threshold)
return self._extract_masks(results)
class SamSegmentationAdapter(_UltralyticsSegmentationAdapterBase):
"""Class-agnostic SAM segmentation through the ultralytics SAM interface."""
def load_model(self, model_path: Path):
self._require_model_file(model_path)
try:
from ultralytics import SAM
except ImportError as exc:
raise AppError(
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
message="SAM segmentation requires the ultralytics SAM interface. Install backend optional extras with geointel-backend[ai].",
status_code=503,
) from exc
try:
return SAM(str(model_path))
except Exception as exc:
raise AppError(
code="SEGMENTATION_MODEL_LOAD_FAILED",
message="Configured SAM model could not be loaded",
details={"model_path": str(model_path)},
status_code=503,
) from exc
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
results = self._predict(model, tile_path, confidence_threshold)
return self._extract_masks(results, default_class_name="segment")
class FixtureSegmentationAdapter:
def segment(self, raw_segmentations: Any) -> list[SegmentationAdapterResult]:
if not isinstance(raw_segmentations, list):
+239 -16
View File
@@ -19,10 +19,16 @@ from app.schemas.segmentation import (
SegmentationRunRead,
SegmentationRunResponse,
)
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
from app.services.detection_service import DetectionService
from app.services.model_registry_service import ModelRegistryService
from app.services.qa_service import QaService
from app.services.quality_service import QualityService
from app.services.segmentation_adapter import FixtureSegmentationAdapter
from app.services.segmentation_adapter import (
FixtureSegmentationAdapter,
SamSegmentationAdapter,
YoloSegmentationAdapter,
)
class SegmentationService:
@@ -41,6 +47,8 @@ class SegmentationService:
tile_manifest_path: str | None = None,
parameters_json: dict[str, Any] | None = None,
settings: Settings | None = None,
yolo_seg_adapter_class: type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
sam_adapter_class: type[SamSegmentationAdapter] = SamSegmentationAdapter,
) -> SegmentationRunResponse:
parameters = dict(parameters_json or {})
resolved_settings = settings or get_settings()
@@ -58,7 +66,13 @@ class SegmentationService:
status_code=400,
)
model = ModelRegistryService.get_model_capability(model_id, task_type="segmentation")
model = ModelRegistryService.get_model_capability(
model_id,
settings=resolved_settings,
task_type="segmentation",
yolo_seg_adapter_class=yolo_seg_adapter_class,
sam_adapter_class=sam_adapter_class,
)
if model is None:
raise AppError(code="SEGMENTATION_MODEL_NOT_FOUND", message="Segmentation model not found", status_code=404)
if model.model_id == "fixture-segmenter" and parameters.get("fixture_mode") is not True:
@@ -67,6 +81,13 @@ class SegmentationService:
message="Fixture segmenter requires explicit fixture_mode=true",
status_code=400,
)
configured_model_ids = {resolved_settings.yolo_seg_model_id, resolved_settings.sam_model_id}
if model.model_id in configured_model_ids and model.configured and not tile_manifest_path:
raise AppError(
code="SEGMENTATION_TILE_MANIFEST_REQUIRED",
message="Configured segmentation inference requires an existing raster tile manifest path",
status_code=400,
)
run_parameters = {
"model_id": model.model_id,
@@ -100,19 +121,24 @@ class SegmentationService:
)
if model.model_id == "fixture-segmenter":
segmentations = SegmentationService._persist_fixture_segmentations(
db=db,
project_id=project_id,
dataset_id=dataset_id,
analysis_run=analysis_run,
job=job,
model_name=model.model_id,
model_version=model.version,
raw_segmentations=parameters.get("fixture_segmentations"),
confidence_threshold=confidence_threshold,
class_filter=class_filter or [],
settings=resolved_settings,
)
try:
segmentations = SegmentationService._persist_fixture_segmentations(
db=db,
project_id=project_id,
dataset_id=dataset_id,
analysis_run=analysis_run,
job=job,
model_name=model.model_id,
model_version=model.version,
raw_segmentations=parameters.get("fixture_segmentations"),
confidence_threshold=confidence_threshold,
class_filter=class_filter or [],
settings=resolved_settings,
)
except Exception as exc:
# A rejected fixture payload must never leave the run stuck in "running".
SegmentationService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="SEGMENTATION_INTERNAL_ERROR")
raise
SegmentationService._mark_success(db, analysis_run, job, segmentation_count=len(segmentations))
return SegmentationRunResponse(
analysis_run_id=analysis_run.id,
@@ -125,8 +151,80 @@ class SegmentationService:
message="Fixture segmentations persisted.",
)
if model.model_id in configured_model_ids:
try:
segmentations, postprocess_summary = SegmentationService._run_configured_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
analysis_run=analysis_run,
job=job,
model_name=model.model_id,
model_version=model.version,
tile_manifest_path=tile_manifest_path,
confidence_threshold=confidence_threshold,
class_filter=class_filter or [],
settings=resolved_settings,
yolo_seg_adapter_class=yolo_seg_adapter_class,
sam_adapter_class=sam_adapter_class,
)
except AppError as exc:
SegmentationService._mark_failed(db, analysis_run, job, code=exc.code, message=exc.message)
return SegmentationRunResponse(
analysis_run_id=analysis_run.id,
job_id=job.id,
project_id=project_id,
dataset_id=dataset_id,
model_id=model.model_id,
status="failed",
segmentation_count=0,
error_code=exc.code,
message=exc.message,
)
except Exception as exc:
# An unexpected inference error must never leave the run stuck in "running".
SegmentationService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="SEGMENTATION_INTERNAL_ERROR")
raise
SegmentationService._mark_success(
db,
analysis_run,
job,
segmentation_count=len(segmentations),
extra_result=postprocess_summary,
)
return SegmentationRunResponse(
analysis_run_id=analysis_run.id,
job_id=job.id,
project_id=project_id,
dataset_id=dataset_id,
model_id=model.model_id,
status="success",
segmentation_count=len(segmentations),
message="Configured segmentation inference persisted georeferenced masks.",
)
SegmentationService._mark_failed(
db,
analysis_run,
job,
code="SEGMENTATION_MODEL_UNAVAILABLE",
message="Segmentation model is unavailable",
)
raise AppError(code="SEGMENTATION_MODEL_UNAVAILABLE", message="Segmentation model is unavailable", status_code=503)
@staticmethod
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
try:
db.rollback()
except Exception:
pass
code = getattr(exc, "code", None) or fallback_code
message = getattr(exc, "message", None) or "Unexpected internal error during analysis run"
try:
SegmentationService._mark_failed(db, analysis_run, job, code=str(code), message=str(message))
except Exception:
pass
@staticmethod
def get_run(db, analysis_run_id: uuid.UUID) -> SegmentationRunRead:
run = db.get(AnalysisRun, analysis_run_id)
@@ -378,8 +476,10 @@ class SegmentationService:
db.refresh(job)
@staticmethod
def _mark_success(db, analysis_run: AnalysisRun, job: Job, segmentation_count: int) -> None:
def _mark_success(db, analysis_run: AnalysisRun, job: Job, segmentation_count: int, extra_result: dict[str, Any] | None = None) -> None:
result = {"segmentation_count": segmentation_count}
if extra_result:
result.update(extra_result)
analysis_run.status = "success"
analysis_run.finished_at = SegmentationService._now()
analysis_run.result_json = result
@@ -392,6 +492,129 @@ class SegmentationService:
db.refresh(analysis_run)
db.refresh(job)
@staticmethod
def _run_configured_segmentation(
db,
project_id: uuid.UUID,
dataset_id: uuid.UUID,
analysis_run: AnalysisRun,
job: Job,
model_name: str,
model_version: str | None,
tile_manifest_path: str | None,
confidence_threshold: float,
class_filter: list[str],
settings: Settings,
yolo_seg_adapter_class: type[YoloSegmentationAdapter],
sam_adapter_class: type[SamSegmentationAdapter],
) -> tuple[list[Segmentation], dict[str, Any]]:
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
if model_name == settings.sam_model_id:
adapter = sam_adapter_class(settings)
model_path = Path(settings.sam_model_path or "").expanduser()
else:
adapter = yolo_seg_adapter_class(settings)
model_path = Path(settings.yolo_seg_model_path or "").expanduser()
model = adapter.load_model(model_path)
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326"
candidates: list[dict[str, Any]] = []
for tile in manifest["tiles"]:
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser())
for raw in adapter.predict_tile(model, tile_path, confidence_threshold):
model_class_name = str(raw.get("class_name") or "").strip()
class_name = DetectionService._canonical_class_name(model_class_name)
confidence = raw.get("confidence")
confidence = float(confidence) if confidence is not None else None
if allowed_classes and class_name not in allowed_classes:
continue
if confidence is not None and confidence < confidence_threshold:
continue
points = raw.get("points")
if not isinstance(points, list) or len(points) < 3:
continue
geometry = pixel_points_to_epsg4326_polygon(points=points, tile=tile, crs=tile.get("crs") or manifest_crs)
properties = dict(raw.get("properties") or {})
if model_class_name and model_class_name != class_name:
properties.setdefault("model_class_name", model_class_name)
candidates.append(
{
"class_name": class_name,
"confidence": confidence if confidence is not None else 0.0,
"reported_confidence": confidence,
"geometry": geometry,
"bbox": raw.get("bbox"),
"source_tile_path": str(tile_path),
"tile_index": tile.get("index"),
"properties": {**properties, "tile_index": tile.get("index")},
}
)
filtered_candidates = DetectionService._suppress_duplicate_candidates(
candidates,
iou_threshold=float(settings.segmentation_duplicate_iou_threshold),
)
persisted: list[Segmentation] = []
for candidate in filtered_candidates:
geometry = candidate["geometry"]
if isinstance(geometry, Polygon):
geometry = MultiPolygon([geometry])
bbox = candidate.get("bbox")
bbox_json = None
if isinstance(bbox, list) and len(bbox) == 4:
bbox_json = {
"x_min": float(bbox[0]),
"y_min": float(bbox[1]),
"x_max": float(bbox[2]),
"y_max": float(bbox[3]),
}
segmentation = Segmentation(
id=uuid.uuid4(),
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run.id,
job_id=job.id,
model_name=model_name,
model_version=model_version,
class_name=candidate["class_name"],
confidence=candidate["reported_confidence"],
geometry=from_shape(geometry, srid=4326),
bbox_json=bbox_json,
area_m2=SegmentationService._geodesic_area_m2(geometry),
mask_path=None,
source_tile_path=candidate["source_tile_path"],
tile_index=candidate["tile_index"] if isinstance(candidate["tile_index"], int) else None,
properties_json=candidate["properties"],
provenance_json={
"inference": "local",
"model_id": model_name,
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
"tile_index": candidate["tile_index"],
"device": settings.yolo_device,
},
)
db.add(segmentation)
persisted.append(segmentation)
db.commit()
for segmentation in persisted:
db.refresh(segmentation)
return persisted, {
"raw_segmentation_count": len(candidates),
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
}
@staticmethod
def _geodesic_area_m2(geometry: MultiPolygon | Polygon) -> float | None:
try:
from pyproj import Geod
area, _ = Geod(ellps="WGS84").geometry_area_perimeter(geometry)
return abs(float(area))
except Exception:
return None
@staticmethod
def _persist_fixture_segmentations(
db,
@@ -277,6 +277,48 @@ def test_regional_official_vector_sources_are_configurable_in_every_runtime() ->
assert f'Target="{key}"' in template
def test_segmentation_and_mdk_acquisition_are_configurable_in_every_runtime() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
unraid_compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
unraid_env = (ROOT / "deploy" / "unraid" / "geointel.env.example").read_text(encoding="utf-8")
template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8")
for key in (
"YOLO_SEG_ENABLED",
"YOLO_SEG_MODEL_PATH",
"SAM_ENABLED",
"SAM_MODEL_PATH",
"SEGMENTATION_MAX_MASKS_PER_TILE",
"SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
"MDK_BATHYMETRY_ACQUISITION_ENABLED",
"MDK_BATHYMETRY_COVERAGE_ID",
"MDK_BATHYMETRY_MAX_BBOX_DEG2",
):
assert key in compose, key
assert key in unraid_compose, key
assert f'{key}="${{{key}:-' in run_script, key
assert f'-e {key}="${key}"' in run_script, key
assert f"{key}=" in env_example, key
assert f"{key}=" in unraid_env, key
assert f'Target="{key}"' in template, key
def test_compose_reconciles_interrupted_runs_after_restart_like_unraid_runtime() -> None:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
start_script = (ROOT / "deploy" / "unraid" / "all-in-one-start.sh").read_text(encoding="utf-8")
assert (
"GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP: "
"${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}"
) in compose
assert (
'GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP='
'"${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}"'
) in start_script
def test_docker_build_contexts_exclude_vendor_build_and_cache_outputs() -> None:
required_patterns = {
"node_modules",
@@ -0,0 +1,153 @@
from __future__ import annotations
import io
from pathlib import Path
from uuid import uuid4
import pytest
from app.core.config import Settings
from app.schemas.bathymetry import MdkBathymetryAcquireRequest
from app.schemas.operations import VectorSelectionBBox
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
CAPABILITIES_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
<ContentMetadata>
<CoverageOfferingBrief>
<name>depth_model_20m_lat</name>
<label>Belgian Continental Shelf depth model</label>
</CoverageOfferingBrief>
</ContentMetadata>
</WCS_Capabilities>
"""
class FakeResponse:
def __init__(self, content: bytes, content_type: str = "application/xml") -> None:
self._stream = io.BytesIO(content)
self.headers = {"Content-Type": content_type}
def read(self, limit: int = -1) -> bytes:
return self._stream.read(limit)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def _payload(**overrides) -> MdkBathymetryAcquireRequest:
values = {
"bbox": VectorSelectionBBox(min_x=2.5, min_y=51.3, max_x=2.6, max_y=51.4),
"force_refresh": True,
}
values.update(overrides)
return MdkBathymetryAcquireRequest(**values)
def _settings(**overrides) -> Settings:
values = {
"mdk_bathymetry_acquisition_enabled": True,
"mdk_bathymetry_coverage_id": "depth_model_20m_lat",
}
values.update(overrides)
return Settings(**values)
def test_acquisition_fails_closed_when_disabled() -> None:
settings = _settings(mdk_bathymetry_acquisition_enabled=False)
with pytest.raises(Exception) as exc_info:
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_ACQUISITION_DISABLED"
def test_acquisition_fails_closed_without_coverage_id() -> None:
settings = _settings(mdk_bathymetry_coverage_id=None)
with pytest.raises(Exception) as exc_info:
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_COVERAGE_NOT_CONFIGURED"
def test_acquisition_rejects_oversized_bbox() -> None:
settings = _settings(mdk_bathymetry_max_bbox_deg2=0.001)
with pytest.raises(Exception) as exc_info:
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_BBOX_TOO_LARGE"
def test_acquisition_requires_reachable_probe() -> None:
settings = _settings()
def failing_opener(request, timeout=None):
raise OSError("connection refused")
with pytest.raises(Exception) as exc_info:
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=failing_opener)
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_ENDPOINT_NOT_READY"
def test_acquisition_requires_advertised_coverage_id() -> None:
settings = _settings(mdk_bathymetry_coverage_id="not_advertised_coverage")
def opener(request, timeout=None):
return FakeResponse(CAPABILITIES_XML)
with pytest.raises(Exception) as exc_info:
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=opener)
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_COVERAGE_NOT_ADVERTISED"
def test_acquisition_rejects_non_geotiff_coverage_response() -> None:
settings = _settings()
responses = []
def opener(request, timeout=None):
url = request.full_url if hasattr(request, "full_url") else str(request)
responses.append(url)
if "GetCapabilities" in url:
return FakeResponse(CAPABILITIES_XML)
return FakeResponse(b"<ServiceExceptionReport>boom</ServiceExceptionReport>", "application/xml")
with pytest.raises(Exception) as exc_info:
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=opener)
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_INVALID_RESPONSE"
assert any("GetCoverage" in url for url in responses)
coverage_urls = [url for url in responses if "GetCoverage" in url]
assert "coverage=depth_model_20m_lat" in coverage_urls[0]
assert "format=GeoTIFF" in coverage_urls[0]
def test_get_coverage_url_is_bounded_and_pinned() -> None:
settings = _settings()
bbox = [2.5, 51.3, 2.6, 51.4]
url = MdkBathymetryAcquisitionService._get_coverage_url(settings, "depth_model_20m_lat", bbox)
assert url.startswith("https://")
assert "request=GetCoverage" in url
assert "version=1.0.0" in url
assert "crs=EPSG%3A4326" in url or "crs=EPSG:4326" in url
width, height = MdkBathymetryAcquisitionService._pixel_dimensions(bbox)
assert 1 <= width <= MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE
assert 1 <= height <= MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE
def test_source_module_never_disables_tls_verification() -> None:
source = (
Path(__file__).resolve().parents[1] / "app" / "services" / "mdk_bathymetry_acquisition_service.py"
).read_text(encoding="utf-8")
assert "_create_unverified_context" not in source
assert "CERT_NONE" not in source
assert "check_hostname = False" not in source
+20 -1
View File
@@ -119,7 +119,7 @@ def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -
assert asset.size_bytes == len(b"local model")
assert len(asset.sha256) == 64
assert asset.active is True
assert asset.status == "available"
assert asset.status == "approved"
assert asset.will_download_models is False
@@ -134,6 +134,25 @@ def test_model_asset_catalog_resolves_known_asset(tmp_path: Path) -> None:
assert asset.model_path == str(model_file)
def test_model_asset_catalog_only_exposes_explicit_active_asset_in_runtime(tmp_path: Path) -> None:
active_file = tmp_path / "approved-building-detector.pt"
active_file.write_bytes(b"approved")
(tmp_path / "training-smoke.pt").write_bytes(b"experiment")
(tmp_path / "partial-checkpoint.pt").write_bytes(b"partial")
settings = Settings(
yolo_models_dir=str(tmp_path),
yolo_model_path=str(active_file),
yolo_enabled=True,
)
response = ModelAssetCatalogService.list_assets(settings=settings)
assert response.total == 1
assert response.items[0].filename == active_file.name
assert response.items[0].active is True
assert response.items[0].status == "approved"
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True)
@@ -119,6 +119,72 @@ def test_regional_product_registry_is_explicit_and_source_specific() -> None:
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"]
# urbis_street_axes is live-validated against the UrbIS WFS capabilities:
# urbisvector:StreetAxes exposes INSPIRE_ID and LineString geometry. The
# same capabilities document advertises no hydrography feature type, so
# Brussels surface water intentionally stays not_configured.
assert products["urbis_street_axes"]["coverage_zones"] == ["brussels"]
assert products["urbis_street_axes"]["collection"] == "urbisvector:StreetAxes"
assert products["urbis_street_axes"]["geometry_types"] == [
"LineString",
"MultiLineString",
]
assert products["urbis_street_axes"]["theme"] == "roads"
assert products["urbis_land_cover_blocks"]["collection"] == "urbisvector:Blocks"
assert products["urbis_land_cover_blocks"]["theme"] == "space_occupation"
assert products["urbis_forest_parks"]["theme"] == "forest"
assert products["urbis_water_surfaces"]["theme"] == "water"
def test_urbis_land_cover_products_filter_only_documented_block_classes() -> None:
scope_wgs84 = Polygon(
[(4.35, 50.84), (4.36, 50.84), (4.36, 50.85), (4.35, 50.85), (4.35, 50.84)]
)
scope_metric = Polygon([_TO_LAMBERT72.transform(x, y) for x, y in scope_wgs84.exterior.coords])
min_x, min_y, max_x, max_y = scope_metric.bounds
def block(block_type: str):
return {
"type": "Feature",
"id": f"Blocks.{block_type}",
"geometry": {
"type": "Polygon",
"coordinates": [[
[min_x + 10, min_y + 10],
[min_x + 100, min_y + 10],
[min_x + 100, min_y + 100],
[min_x + 10, min_y + 100],
[min_x + 10, min_y + 10],
]],
},
"properties": {
"INSPIRE_ID": f"https://databrussels.be/id/block/{block_type}",
"TYPE": block_type,
},
}
forest_product = OfficialVectorAcquisitionService._product("urbis_forest_parks")
water_product = OfficialVectorAcquisitionService._product("urbis_water_surfaces")
land_cover_product = OfficialVectorAcquisitionService._product("urbis_land_cover_blocks")
assert OfficialVectorAcquisitionService._normalize_regional_feature(
forest_product, block("FO"), scope_metric, "brussels"
) is not None
assert OfficialVectorAcquisitionService._normalize_regional_feature(
forest_product, block("CB"), scope_metric, "brussels"
) is None
assert OfficialVectorAcquisitionService._normalize_regional_feature(
water_product, block("WB"), scope_metric, "brussels"
) is not None
assert OfficialVectorAcquisitionService._normalize_regional_feature(
water_product, block("GB"), scope_metric, "brussels"
) is None
normalized = OfficialVectorAcquisitionService._normalize_regional_feature(
land_cover_product, block("CB"), scope_metric, "brussels"
)
assert normalized is not None
assert normalized["properties"]["TYPE"] == "CB"
assert normalized["properties"]["clipped_area_ha"] > 0
def test_spw_arcgis_paging_is_bounded_stable_and_clipped() -> None:
+2 -2
View File
@@ -404,8 +404,8 @@ def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbo
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 "return nationalProject.id" in workspace_hook
assert "NATIONAL_WORKSPACE_REGION" in workspace_hook
assert "externalApi.resolveCoverage" in coverage_hook
assert "coverage.outside_supported_scope" in map_workspace
assert "coverageStatusLabel" in map_workspace
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from app.core.config import Settings
from app.core.errors import AppError
from app.models import AnalysisRun, Dataset, Job, Project
from app.services.detection_service import DetectionService
from app.services.job_service import JobService
from app.services.segmentation_service import SegmentationService
class FakeSession:
"""Minimal session double without rollback support, mirroring existing test doubles."""
def __init__(self, objects=None) -> None:
self.objects = objects or {}
self.added = []
self.commits = 0
def get(self, model, item_id):
return self.objects.get((model, item_id))
def add(self, item) -> None:
self.added.append(item)
if getattr(item, "id", None) is not None:
self.objects[(item.__class__, item.id)] = item
def commit(self) -> None:
self.commits += 1
def refresh(self, item) -> None:
pass
def _project_and_dataset():
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Mol")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="ortho.tif",
dataset_type="raster",
source="user_upload",
storage_path="storage/uploads/ortho.tif",
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id
def _statuses(db: FakeSession) -> tuple[list[str], list[str]]:
runs = [item.status for item in db.added if isinstance(item, AnalysisRun)]
jobs = [item.status for item in db.added if isinstance(item, Job)]
return runs, jobs
def test_invalid_fixture_detections_mark_run_and_job_failed() -> None:
db, project_id, dataset_id = _project_and_dataset()
with pytest.raises(AppError) as exc_info:
DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="manual-fixture-detector",
confidence_threshold=0.5,
parameters_json={"fixture_mode": True, "fixture_detections": "not-a-list"},
settings=Settings(_env_file=None),
)
assert exc_info.value.code == "INVALID_FIXTURE_DETECTIONS"
run_statuses, job_statuses = _statuses(db)
assert run_statuses and all(status == "failed" for status in run_statuses)
assert job_statuses and all(status == "failed" for status in job_statuses)
def test_invalid_fixture_segmentations_mark_run_and_job_failed() -> None:
db, project_id, dataset_id = _project_and_dataset()
with pytest.raises(AppError) as exc_info:
SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="fixture-segmenter",
confidence_threshold=0.5,
parameters_json={"fixture_mode": True, "fixture_segmentations": "not-a-list"},
settings=Settings(_env_file=None),
)
assert exc_info.value.code == "INVALID_FIXTURE_SEGMENTATIONS"
run_statuses, job_statuses = _statuses(db)
assert run_statuses and all(status == "failed" for status in run_statuses)
assert job_statuses and all(status == "failed" for status in job_statuses)
def test_unexpected_error_in_sync_job_marks_job_failed() -> None:
project_id = uuid4()
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")})
def exploding_operation():
raise RuntimeError("unexpected internal failure")
with pytest.raises(RuntimeError):
JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="test.unexpected",
parameters={},
operation=exploding_operation,
)
jobs = [item for item in db.added if isinstance(item, Job)]
assert jobs
final_job = jobs[-1]
assert final_job.status == "failed"
assert "Unexpected internal error" in (final_job.error_message or "")
def test_app_error_in_sync_job_still_marks_job_failed() -> None:
project_id = uuid4()
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")})
def failing_operation():
raise AppError(code="SOME_DOMAIN_ERROR", message="Bounded failure", status_code=422)
with pytest.raises(AppError):
JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="test.bounded",
parameters={},
operation=failing_operation,
)
jobs = [item for item in db.added if isinstance(item, Job)]
assert jobs
assert jobs[-1].status == "failed"
assert jobs[-1].error_message == "Bounded failure"
@@ -0,0 +1,333 @@
from __future__ import annotations
import json
from pathlib import Path
from uuid import uuid4
import pytest
from geoalchemy2.shape import to_shape
from app.core.config import Settings
from app.models import Dataset, Project, Segmentation
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
from app.services.model_registry_service import ModelRegistryService
from app.services.segmentation_service import SegmentationService
ROOT = Path(__file__).resolve().parents[2]
class FakeSession:
def __init__(self, objects=None) -> None:
self.objects = objects or {}
self.added = []
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def add(self, item) -> None:
self.added.append(item)
if getattr(item, "id", None) is not None:
self.objects[(item.__class__, item.id)] = item
def commit(self) -> None:
self.commits += 1
def refresh(self, item) -> None:
self.refreshes.append(item)
class AvailableSegAdapter:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@staticmethod
def dependencies_available() -> bool:
return True
def load_model(self, model_path: Path):
return object()
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
return [
{
"class_name": "building",
"confidence": 0.91,
"points": [[10.0, 20.0], [30.0, 20.0], [30.0, 40.0], [10.0, 40.0]],
"bbox": [10.0, 20.0, 30.0, 40.0],
"properties": {"class_id": 0},
}
]
class ClassAgnosticSamAdapter(AvailableSegAdapter):
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
return [
{
"class_name": "segment",
"confidence": None,
"points": [[5.0, 5.0], [25.0, 5.0], [25.0, 25.0], [5.0, 25.0]],
"bbox": [5.0, 5.0, 25.0, 25.0],
"properties": {"class_id": -1},
}
]
class MissingDependencySegAdapter(AvailableSegAdapter):
@staticmethod
def dependencies_available() -> bool:
return False
def _project_and_dataset(dataset_type: str = "raster"):
project_id = uuid4()
dataset_id = uuid4()
project = Project(id=project_id, name="Mol")
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="ortho.tif",
dataset_type=dataset_type,
source="user_upload",
storage_path="storage/uploads/ortho.tif",
)
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
return db, project_id, dataset_id
def _settings(tmp_path: Path, **overrides) -> Settings:
values = {
"yolo_seg_enabled": True,
"yolo_seg_model_path": str(tmp_path / "seg.pt"),
"sam_enabled": True,
"sam_model_path": str(tmp_path / "sam.pt"),
"yolo_max_tiles": 4,
}
values.update(overrides)
return Settings(**values)
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
tiles = []
for index in range(tile_count):
tile_path = tmp_path / f"tile_{index:04d}.tif"
tile_path.write_bytes(b"fixture")
tiles.append(
{
"path": str(tile_path),
"pixel_window": [0, 0, 100, 100],
"bounds": [4.0, 51.0, 5.0, 52.0],
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"index": index,
}
)
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(
json.dumps(
{
"tile_set_id": "tiles-fixture",
"source_dataset_id": str(uuid4()),
"source_raster_id": str(uuid4()),
"tile_size": 100,
"overlap": 0,
"count": tile_count,
"tiles": tiles,
}
),
encoding="utf-8",
)
return manifest_path
def test_segmentation_models_report_not_configured_when_disabled(tmp_path: Path) -> None:
settings = _settings(tmp_path, yolo_seg_enabled=False, sam_enabled=False)
models = {
model.model_id: model
for model in ModelRegistryService.list_segmentation_model_capabilities(settings=settings)
}
assert models["yolo-seg-configured"].configured is False
assert models["yolo-seg-configured"].status == "not_configured"
assert models["sam-configured"].configured is False
assert models["sam-configured"].status == "not_configured"
def test_segmentation_models_report_dependency_unavailable(tmp_path: Path) -> None:
(tmp_path / "seg.pt").write_bytes(b"weights")
(tmp_path / "sam.pt").write_bytes(b"weights")
settings = _settings(tmp_path)
models = {
model.model_id: model
for model in ModelRegistryService.list_segmentation_model_capabilities(
settings=settings,
yolo_seg_adapter_class=MissingDependencySegAdapter,
sam_adapter_class=MissingDependencySegAdapter,
)
}
assert models["yolo-seg-configured"].status == "dependency_unavailable"
assert models["sam-configured"].status == "dependency_unavailable"
def test_segmentation_models_report_configured_with_local_weights(tmp_path: Path) -> None:
(tmp_path / "seg.pt").write_bytes(b"weights")
(tmp_path / "sam.pt").write_bytes(b"weights")
settings = _settings(tmp_path)
models = {
model.model_id: model
for model in ModelRegistryService.list_segmentation_model_capabilities(
settings=settings,
yolo_seg_adapter_class=AvailableSegAdapter,
sam_adapter_class=ClassAgnosticSamAdapter,
)
}
assert models["yolo-seg-configured"].configured is True
assert models["yolo-seg-configured"].status == "configured"
assert models["sam-configured"].configured is True
assert models["sam-configured"].status == "configured"
def test_segmentation_dependency_check_uses_real_imports_not_find_spec() -> None:
source = (ROOT / "backend" / "app" / "services" / "segmentation_adapter.py").read_text(encoding="utf-8")
assert 'find_spec("ultralytics")' not in source
assert "import ultralytics" in source
assert "import torch" in source
def test_configured_segmentation_requires_tile_manifest(tmp_path: Path) -> None:
(tmp_path / "seg.pt").write_bytes(b"weights")
db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path)
with pytest.raises(Exception) as exc_info:
SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-seg-configured",
confidence_threshold=0.5,
settings=settings,
yolo_seg_adapter_class=AvailableSegAdapter,
sam_adapter_class=ClassAgnosticSamAdapter,
)
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_TILE_MANIFEST_REQUIRED"
def test_configured_yolo_seg_run_persists_georeferenced_masks(tmp_path: Path) -> None:
(tmp_path / "seg.pt").write_bytes(b"weights")
db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path)
manifest_path = _manifest(tmp_path)
response = SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-seg-configured",
confidence_threshold=0.5,
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_seg_adapter_class=AvailableSegAdapter,
sam_adapter_class=ClassAgnosticSamAdapter,
)
assert response.status == "success"
assert response.segmentation_count == 1
persisted = [item for item in db.added if isinstance(item, Segmentation)]
assert len(persisted) == 1
segmentation = persisted[0]
assert segmentation.class_name == "building"
assert segmentation.confidence == pytest.approx(0.91)
geometry = to_shape(segmentation.geometry)
assert geometry.geom_type == "MultiPolygon"
min_x, min_y, max_x, max_y = geometry.bounds
assert 4.0 <= min_x <= 5.0
assert 51.0 <= min_y <= 52.0
assert max_x <= 5.0
assert max_y <= 52.0
assert segmentation.area_m2 is not None and segmentation.area_m2 > 0
assert segmentation.provenance_json["inference"] == "local"
assert segmentation.provenance_json["model_id"] == "yolo-seg-configured"
def test_configured_sam_run_is_class_agnostic(tmp_path: Path) -> None:
(tmp_path / "sam.pt").write_bytes(b"weights")
db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path)
manifest_path = _manifest(tmp_path)
response = SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="sam-configured",
confidence_threshold=0.5,
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_seg_adapter_class=AvailableSegAdapter,
sam_adapter_class=ClassAgnosticSamAdapter,
)
assert response.status == "success"
assert response.segmentation_count == 1
persisted = [item for item in db.added if isinstance(item, Segmentation)]
assert persisted[0].class_name == "segment"
assert persisted[0].confidence is None
def test_unconfigured_segmentation_run_fails_closed(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
settings = _settings(tmp_path, yolo_seg_enabled=False)
manifest_path = _manifest(tmp_path)
response = SegmentationService.run_segmentation(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-seg-configured",
confidence_threshold=0.5,
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_seg_adapter_class=AvailableSegAdapter,
sam_adapter_class=ClassAgnosticSamAdapter,
)
assert response.status == "failed"
assert response.error_code == "SEGMENTATION_MODEL_UNAVAILABLE"
assert not [item for item in db.added if isinstance(item, Segmentation)]
def test_pixel_points_to_epsg4326_polygon_uses_tile_transform() -> None:
tile = {
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
"bounds": [4.0, 51.0, 5.0, 52.0],
"pixel_window": [0, 0, 100, 100],
}
polygon = pixel_points_to_epsg4326_polygon(
points=[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]],
tile=tile,
crs="EPSG:4326",
)
min_x, min_y, max_x, max_y = polygon.bounds
assert min_x == pytest.approx(4.0)
assert max_x == pytest.approx(5.0)
assert min_y == pytest.approx(51.0)
assert max_y == pytest.approx(52.0)
def test_pixel_points_to_epsg4326_polygon_rejects_degenerate_input() -> None:
tile = {"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01]}
with pytest.raises(Exception) as exc_info:
pixel_points_to_epsg4326_polygon(points=[[0.0, 0.0], [1.0, 1.0]], tile=tile)
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_INVALID_MASK"
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_frontend_declares_mol_as_primary_operating_focus() -> None:
def test_frontend_declares_national_scope_as_primary_operating_focus() -> None:
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(
encoding="utf-8"
)
@@ -23,19 +23,17 @@ def test_frontend_declares_mol_as_primary_operating_focus() -> None:
/ "WorkbenchNavigation.tsx"
).read_text(encoding="utf-8")
assert "PRIMARY_FOCUS_LABEL = 'Mol'" in focus
assert "PRIMARY_FOCUS_REGION = 'Mol, Kempen'" in focus
assert "[5.1167, 51.1919]" in focus
assert "isPrimaryFocusProjectData" in focus
assert "isPrimaryFocusProjectData(project, data.datasets)" in project_hook
assert "NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'" in focus
assert "NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'" in focus
assert "NATIONAL_MAP_CENTER" in focus
assert "return nationalProject.id" in project_hook
assert "hasMappedAnalysisContext(data)" in project_hook
assert "dataset.dataset_type === 'raster'" in project_hook
assert "dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson'" in project_hook
assert "const primaryContext = inspectedCandidates.find" in project_hook
assert "PRIMARY_FOCUS_AREA_NAME" in project_hook
assert "PRIMARY_FOCUS_AREA_GEOJSON" in project_hook
assert "4.35,51.28" not in project_hook
assert "center: PRIMARY_FOCUS_CENTER" in map_source
assert "PRIMARY_FOCUS_AREA_NAME" not in project_hook
assert "PRIMARY_FOCUS_AREA_GEOJSON" not in project_hook
assert "center: NATIONAL_MAP_CENTER" in map_source
assert "zoom: NATIONAL_MAP_ZOOM" in map_source
assert "GeoIntel" in navigation
assert "Atlas Workbench" in navigation
@@ -142,7 +142,7 @@ def test_large_vector_persistence_flushes_once_without_per_feature_refresh() ->
assert db.refreshes == 0
def test_municipality_workspace_is_wired_into_runtime_and_frontend_priority() -> None:
def test_municipality_workspace_remains_a_regression_fixture_without_frontend_priority() -> 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")
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
@@ -154,7 +154,8 @@ def test_municipality_workspace_is_wired_into_runtime_and_frontend_priority() ->
assert "py_compile scripts/provision_mol_municipality_workspace.py" in readiness
assert "COPY scripts/provision_mol_municipality_workspace.py" in dockerfile
assert "PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'" in focus
assert "items.find(isPrimaryFocusMunicipalityProject)" in project_hook
assert "items.find(isPrimaryFocusMunicipalityProject)" not in project_hook
assert "return nationalProject.id" in project_hook
assert "datasets.find(isPrimaryFocusMunicipalityBoundaryDataset)" in dataset_hook
assert "featureCollectionBounds(featureCollection)" in map_source
assert "useMemo(() => getFeatureCollectionBBox(mapFeatureCollection)" in map_workspace
@@ -8,13 +8,15 @@ def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_regional_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
map_workspace = read("frontend/src/components/map/MapWorkspace.tsx")
national_check = project_hook.index("const nationalProject")
regional_check = project_hook.index("const regionalProject")
municipality_check = project_hook.index("const municipalityProject")
assert regional_check < municipality_check
assert national_check < regional_check
assert "return nationalProject.id" in project_hook
assert "const municipalityProject" not in project_hook
assert 'aria-label="Regio"' not in map_workspace
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
assert "Snel naar een gemeente (optioneel)" in map_workspace
@@ -56,7 +58,8 @@ def test_configured_yolo_and_active_asset_are_selected_without_hiding_limitation
assert "asset.active" in hook
assert "getYoloPreflight" in hook
assert 'aria-label="Status gebouwdetectie"' in lab
assert "resultaten blijven controleplichtig" in lab
assert "Nog niet nationaal gevalideerd" in lab
assert "vereisen lokale referentiedata en QA" in lab
assert "Modelkalibratie voor beheerders" in lab
@@ -249,7 +249,7 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
assert "statbel: 'Statbel'" in display
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
assert "resultDataset ? getDatasetSourceDisplayName(resultDataset)" in workspace
assert "getDatasetSourceDisplayName(resultDataset)" in workspace
assert "Snel naar een gemeente (optioneel)" in workspace
assert "latestDatasetBySeries" in catalog
assert "Historische meetmomenten" in catalog
@@ -174,7 +174,10 @@ def test_bathymetry_source_registry_is_honest_and_nationally_extensible() -> Non
assert by_key["vha_inland_profiles"]["integration_status"] == "operational"
assert by_key["vha_inland_profiles"]["acquisition_supported"] is True
assert by_key["mdk_bcp_bathymetry"]["vertical_reference"] == "LAT"
assert by_key["mdk_bcp_bathymetry"]["acquisition_supported"] is False
# Bounded MDK acquisition now exists but stays fail-closed until the
# operator enables it explicitly with a live-validated coverage id.
assert by_key["mdk_bcp_bathymetry"]["acquisition_supported"] is True
assert by_key["mdk_bcp_bathymetry"]["configured"] is False
assert by_key["spw_walloon_waterway_bathymetry"]["vertical_reference"] == "mDNG"
assert by_key["spw_walloon_waterway_bathymetry"]["license_note"].startswith("CC BY 4.0")
@@ -411,7 +411,9 @@ def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
for item in BathymetryProfileAcquisitionService.list_sources()
}
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False
# Bounded acquisition is implemented but remains disabled by default.
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is True
assert sources["mdk_bcp_bathymetry"]["configured"] is False
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
@@ -134,6 +134,10 @@ def test_product_registries_expose_honest_forest_agriculture_nature_and_soil() -
"spw_picc_water_surfaces",
"urbis_buildings",
"urbis_cadastral_parcels",
"urbis_street_axes",
"urbis_land_cover_blocks",
"urbis_forest_parks",
"urbis_water_surfaces",
} == set(vector)
assert vector["bwk_natura2000_2025"]["authority_level"] == "authoritative"
assert vector["dov_soil_types"]["authority_level"] == "authoritative_historical_baseline"
@@ -401,7 +405,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"] == 8
assert products_response.json()["data"]["total"] == 12
assert acquire_response.status_code == 200
assert set(acquire_response.json()) == {"data"}
assert acquire_response.json()["data"]["job_type"] == "vector.official.acquire"