diff --git a/.env.example b/.env.example index c80c35e8..fa8d40be 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,14 @@ DHMV_MAX_SIDE_M=20000 DHMV_MAX_PIXELS=12000000 DHMV_TIMEOUT_SECONDS=300 DHMV_MAX_RESPONSE_MB=160 +FLOOD_HAZARD_ENABLED=true +FLOOD_HAZARD_WCS_URL=https://geoservice.waterinfo.be/OGRK/wcs +FLOOD_HAZARD_RESOLUTION_M=5.0 +FLOOD_HAZARD_MIN_SIDE_M=10 +FLOOD_HAZARD_MAX_SIDE_M=20000 +FLOOD_HAZARD_MAX_PIXELS=12000000 +FLOOD_HAZARD_TIMEOUT_SECONDS=300 +FLOOD_HAZARD_MAX_RESPONSE_MB=160 YOLO_ENABLED=false YOLO_MODELS_DIR=/app/models YOLO_MODEL_PATH= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d66e6eb..48f3beac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ # Changelog +## Sprint 208 Governed VMM flood-hazard scenarios (2026-07-15) + +- Audited official water-depth and bathymetry sources and found no public, + municipality-wide inland bathymetry suitable for permanent Mol waterbody + volume; coastal and North Sea products are outside scope. +- Added a fixed twelve-product VMM OGRK WCS registry for fluvial/pluvial, + current climate/climate projection 2050 and T10/T100/T1000 depth scenarios. +- Added bounded tiled WCS 1.1 acquisition, multipart GeoTIFF extraction, exact + Area clipping, centimetre-to-metre normalization and immutable provenance + through existing Dataset, DatasetVersion and Job persistence. +- Added raster selection metrics for mapped inundated hectares/share, + mean/P90/maximum local modeled depth and a strictly named maximum-depth area + integral; permanent and concurrent water volume remain unsupported. +- Added a separate `Overstroming` map theme, scenario selector, transparent + MapLibre overlay, source inventory and source-grounded Ollama context. +- Added the full-Mol operator, Unraid/runtime settings, focused GIS/API/UI/AI + tests and documentation without a migration or new dependency. + ## Sprint 207 Governed DHMV II terrain foundation (2026-07-15) - Added a fixed official Digitaal Vlaanderen DHMV II DTM/DSM WCS registry, diff --git a/backend/README.md b/backend/README.md index 2d7fdc5c..a8062dda 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1217,6 +1217,32 @@ Settings: `DHMV_ENABLED`, `DHMV_WCS_URL`, `DHMV_RESOLUTION_M`, `DHMV_MIN_SIDE_M`, `DHMV_MAX_SIDE_M`, `DHMV_MAX_PIXELS`, `DHMV_TIMEOUT_SECONDS` and `DHMV_MAX_RESPONSE_MB`. +## Governed VMM flood-hazard depth scenarios + +`GET /api/v1/projects/{project_id}/datasets/flood-hazard/products` exposes the +twelve allowlisted VMM OGRK coverages. `POST .../flood-hazard/acquire` performs +bounded WCS 1.1 requests, exact Area clipping, checksum validation and ordinary +Dataset/DatasetVersion/Job persistence. The source's positive centimetre +values are normalized to metres; null/zero cells are transparent nodata. + +Run all scenarios for Mol after the regional workspace and Mol Area exist: + +```bash +docker exec geointel python /app/scripts/provision_mol_flood_hazards.py +``` + +Use `--products pluviaal_current_t100`, `--resolution-m 5` or `--force` for an +explicit subset/refresh. `POST .../raster/flood-hazard/select` returns mapped +inundated hectares, selection share and local modeled maximum-depth statistics. +The `modelled_max_depth_area_integral_m3` metric is an area integral of local +maxima and must not be called actual, permanent or concurrent water volume. +`GET .../raster/flood-hazard/image` serves the constrained transparent PNG. + +Settings: `FLOOD_HAZARD_ENABLED`, `FLOOD_HAZARD_WCS_URL`, +`FLOOD_HAZARD_RESOLUTION_M`, `FLOOD_HAZARD_MIN_SIDE_M`, +`FLOOD_HAZARD_MAX_SIDE_M`, `FLOOD_HAZARD_MAX_PIXELS`, +`FLOOD_HAZARD_TIMEOUT_SECONDS` and `FLOOD_HAZARD_MAX_RESPONSE_MB`. + ## Waterinfo station histories Run the explicit operator after the regional workspace and Mol Area exist: diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index 7a3faf16..f6361bf2 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -24,6 +24,8 @@ from app.schemas import ( OrthophotoAcquireRequest, DhmvAcquireRequest, TerrainSelectionRequest, + FloodHazardAcquireRequest, + FloodHazardSelectionRequest, VectorBBoxResponse, VectorBufferRequest, VectorClipRequest, @@ -44,6 +46,8 @@ from app.services.dataset_service import DatasetService from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.terrain_analysis_service import TerrainAnalysisService +from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService +from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService from app.utils.response import envelope router = APIRouter(prefix="/projects/{project_id}", tags=["datasets"]) @@ -179,6 +183,30 @@ def list_dhmv_products(project_id: UUID, db: Session = Depends(get_db)): return envelope({"items": items, "total": len(items)}) +@router.post("/datasets/flood-hazard/acquire", response_model=dict) +def acquire_bounded_flood_hazard( + project_id: UUID, + payload: FloodHazardAcquireRequest, + db: Session = Depends(get_db), +): + job = JobService.run_sync_job( + db=db, + project_id=project_id, + job_type="raster.flood_hazard.acquire", + parameters=payload.model_dump(mode="json"), + operation=lambda: FloodHazardAcquisitionService.acquire(db, project_id, payload), + ) + return envelope(job) + + +@router.get("/datasets/flood-hazard/products", response_model=dict) +def list_flood_hazard_products(project_id: UUID, db: Session = Depends(get_db)): + if not db.get(Project, project_id): + raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) + items = FloodHazardAcquisitionService.list_products() + return envelope({"items": items, "total": len(items)}) + + @router.get("/datasets", response_model=dict) def list_datasets( project_id: UUID, @@ -500,6 +528,30 @@ def raster_terrain_image( ) +@router.post("/datasets/{dataset_id}/raster/flood-hazard/select", response_model=dict) +def raster_flood_hazard_selection( + project_id: UUID, + dataset_id: UUID, + payload: FloodHazardSelectionRequest, + db: Session = Depends(get_db), +): + return envelope(FloodHazardAnalysisService.analyze(db, project_id, dataset_id, payload)) + + +@router.get("/datasets/{dataset_id}/raster/flood-hazard/image") +def raster_flood_hazard_image( + project_id: UUID, + dataset_id: UUID, + db: Session = Depends(get_db), +): + content = FloodHazardAnalysisService.render_png(db, project_id, dataset_id) + return Response( + content=content, + media_type="image/png", + headers={"Cache-Control": "private, max-age=86400"}, + ) + + @router.get("/datasets/{dataset_id}/raster/stats", response_model=dict) def raster_stats( project_id: UUID, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0d0dfd9e..1e57c526 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -42,6 +42,17 @@ class Settings(BaseSettings): dhmv_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="DHMV_MAX_PIXELS") dhmv_timeout_seconds: int = Field(default=300, ge=1, validation_alias="DHMV_TIMEOUT_SECONDS") dhmv_max_response_mb: int = Field(default=160, ge=1, validation_alias="DHMV_MAX_RESPONSE_MB") + flood_hazard_enabled: bool = Field(default=True, validation_alias="FLOOD_HAZARD_ENABLED") + flood_hazard_wcs_url: str = Field( + default="https://geoservice.waterinfo.be/OGRK/wcs", + validation_alias="FLOOD_HAZARD_WCS_URL", + ) + flood_hazard_resolution_m: float = Field(default=5.0, ge=2.0, le=20.0, validation_alias="FLOOD_HAZARD_RESOLUTION_M") + flood_hazard_min_side_m: float = Field(default=10.0, gt=0, validation_alias="FLOOD_HAZARD_MIN_SIDE_M") + flood_hazard_max_side_m: float = Field(default=20_000.0, gt=0, validation_alias="FLOOD_HAZARD_MAX_SIDE_M") + flood_hazard_max_pixels: int = Field(default=12_000_000, ge=1, validation_alias="FLOOD_HAZARD_MAX_PIXELS") + flood_hazard_timeout_seconds: int = Field(default=300, ge=1, validation_alias="FLOOD_HAZARD_TIMEOUT_SECONDS") + flood_hazard_max_response_mb: int = Field(default=160, ge=1, validation_alias="FLOOD_HAZARD_MAX_RESPONSE_MB") redis_url: str | None = Field(default=None, validation_alias="REDIS_URL") log_level: str = Field(default="INFO", validation_alias="GEOINTEL_LOG_LEVEL") database_statement_timeout_ms: int = Field(default=5_000, validation_alias="DATABASE_STATEMENT_TIMEOUT_MS") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 49f200b4..2d2fb04f 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -42,6 +42,15 @@ from .dhmv import ( TerrainSelectionResponse, TerrainSelectionSummary, ) +from .flood_hazard import ( + FloodHazardAcquireRequest, + FloodHazardAcquisitionResult, + FloodHazardMetric, + FloodHazardProductRead, + FloodHazardSelectionRequest, + FloodHazardSelectionResponse, + FloodHazardSelectionSummary, +) from .external import ( ExternalFetchRequest, ExternalFetchResponse, @@ -151,6 +160,13 @@ __all__ = [ "TerrainSelectionRequest", "TerrainSelectionResponse", "TerrainSelectionSummary", + "FloodHazardAcquireRequest", + "FloodHazardAcquisitionResult", + "FloodHazardMetric", + "FloodHazardProductRead", + "FloodHazardSelectionRequest", + "FloodHazardSelectionResponse", + "FloodHazardSelectionSummary", "VectorBBoxResponse", "VectorClipRequest", "VectorBufferRequest", diff --git a/backend/app/schemas/flood_hazard.py b/backend/app/schemas/flood_hazard.py new file mode 100644 index 00000000..c380d623 --- /dev/null +++ b/backend/app/schemas/flood_hazard.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, Field + +from .operations import VectorSelectionBBox + + +class FloodHazardAcquireRequest(BaseModel): + bbox: VectorSelectionBBox + area_id: UUID | None = None + product_key: str = "pluvial_current_t100" + resolution_m: float | None = Field(default=None, ge=2.0, le=20.0) + force_refresh: bool = False + + +class FloodHazardProductRead(BaseModel): + key: str + display_name: str + mechanism: str + climate_context: str + probability_class: str + return_period_years: int + coverage_id: str + native_resolution_m: float + source_crs: str + source_value_unit: str + normalized_value_unit: str + published_on: str + catalog_url: str + attribution: str + limitation_message: str + + +class FloodHazardAcquisitionResult(BaseModel): + output_dataset_id: UUID + reused: bool + provider: str + product_key: str + display_name: str + mechanism: str + climate_context: str + probability_class: str + return_period_years: int + coverage_id: str + resolution_m: float + width: int + height: int + inundated_pixel_count: int + bbox_epsg4326: list[float] + bbox_epsg31370: list[float] + attribution: str + limitation_message: str + + +class FloodHazardSelectionRequest(BaseModel): + bbox: VectorSelectionBBox + area_id: UUID | None = None + + +class FloodHazardMetric(BaseModel): + metric_key: str + metric_label: str + metric_value: float + metric_unit: str + aggregation_method: str + derived: bool = True + + +class FloodHazardSelectionSummary(BaseModel): + metric_label: str + metric_value: float + metric_unit: str + aggregation_method: str + primary_metric_key: str + metrics: list[FloodHazardMetric] + + +class FloodHazardSelectionResponse(BaseModel): + dataset_id: UUID + product_key: str + mechanism: str + climate_context: str + probability_class: str + return_period_years: int + selection_bbox: VectorSelectionBBox + selection_area_id: UUID | None = None + selected_cell_count: int + inundated_cell_count: int + inundated_fraction: float + resolution_m: float + summary: FloodHazardSelectionSummary + unsupported_metrics: list[str] + limitation_message: str + generated_at: str diff --git a/backend/app/services/flood_hazard_acquisition_service.py b/backend/app/services/flood_hazard_acquisition_service.py new file mode 100644 index 00000000..59a802b5 --- /dev/null +++ b/backend/app/services/flood_hazard_acquisition_service.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import hashlib +import json +import math +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from email.parser import BytesParser +from email.policy import default +from pathlib import Path +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from uuid import UUID + +from geoalchemy2.shape import to_shape +from pyproj import Transformer +from shapely.geometry import box, mapping +from shapely.ops import transform as shapely_transform + +from app.core.config import Settings, get_settings +from app.core.errors import AppError +from app.models import Area, Dataset, Project +from app.schemas.flood_hazard import FloodHazardAcquireRequest, FloodHazardAcquisitionResult, FloodHazardProductRead +from app.services.dataset_service import DatasetService + + +@dataclass(frozen=True) +class FloodHazardProduct: + key: str + display_name: str + mechanism: str + climate_context: str + probability_class: str + return_period_years: int + coverage_id: str + published_on: str + catalog_url: str + + +class FloodHazardAcquisitionService: + PROVIDER = "vmm_flood_hazard" + SOURCE_CRS = "EPSG:31370" + NATIVE_RESOLUTION_M = 2.0 + SOURCE_VALUE_UNIT = "cm" + NORMALIZED_VALUE_UNIT = "m" + NODATA = -9999.0 + SOURCE_VERSION = "VMM OGRK flood hazard maps" + ATTRIBUTION = "Bron: VMM" + LICENSE_NOTE = "Publieke toegang; gebruik en bronvermelding volgens de metadata van VMM/GDI-Vlaanderen." + WCS_TILE_SIDE_M = 10_000.0 + WCS_REQUEST_INTERVAL_SECONDS = 1.0 + WCS_RETRY_DELAY_SECONDS = 3.0 + WCS_TRANSIENT_STATUS_CODES = frozenset({400, 429, 502, 503, 504}) + SERVICE_CATALOG_URL = "https://www.vlaanderen.be/datavindplaats/catalogus/publieke-inspire-coverage-service-van-ogrk" + LIMITATION = ( + "Gemodelleerde maximale overstromingsdiepte voor een vast kans- en klimaatscenario. " + "Dit is geen actuele waterstand, geen bathymetrie en geen permanente diepte of inhoud van een waterlichaam." + ) + + @staticmethod + def _products() -> dict[str, FloodHazardProduct]: + products: list[FloodHazardProduct] = [] + probability = { + 10: ("grote kans", "grote-kans"), + 100: ("middelgrote kans", "middelgrote-kans"), + 1000: ("kleine kans", "kleine-kans"), + } + for mechanism, code in (("pluviaal", "PLU"), ("fluviaal", "FLU")): + for climate_key, climate_code, climate_label, published_on in ( + ("current", "noCC", "huidig klimaat", "2021-08-31"), + ("future_2050", "hCC", "klimaatprojectie 2050", "2021-08-31" if mechanism == "fluviaal" else "2019-12-22"), + ): + for period, (probability_label, probability_slug) in probability.items(): + climate_slug = ( + "huidig-klimaat" + if climate_key == "current" + else "toekomstig-klimaat-met-klimaatprojectie-2050" + ) + catalog_url = ( + "https://www.vlaanderen.be/datavindplaats/catalogus/" + f"overstromingsgevaarkaart-waterdiepte-{mechanism}-{climate_slug}-{probability_slug}" + ) + products.append( + FloodHazardProduct( + key=f"{mechanism}_{climate_key}_t{period}", + display_name=( + f"{mechanism.capitalize()} - {climate_label} - {probability_label} (T{period})" + ), + mechanism=mechanism, + climate_context=climate_label, + probability_class=probability_label, + return_period_years=period, + coverage_id=( + f"Overstromingsgevaarkaarten-{code.replace('PLU', 'PLUVIAAL').replace('FLU', 'FLUVIAAL')}:" + f"waterdiepte_{code}_{climate_code}_T{period}" + ), + published_on=published_on, + catalog_url=catalog_url, + ) + ) + return {product.key: product for product in products} + + @staticmethod + def list_products() -> list[dict[str, Any]]: + return [ + FloodHazardProductRead( + key=product.key, + display_name=product.display_name, + mechanism=product.mechanism, + climate_context=product.climate_context, + probability_class=product.probability_class, + return_period_years=product.return_period_years, + coverage_id=product.coverage_id, + native_resolution_m=FloodHazardAcquisitionService.NATIVE_RESOLUTION_M, + source_crs=FloodHazardAcquisitionService.SOURCE_CRS, + source_value_unit=FloodHazardAcquisitionService.SOURCE_VALUE_UNIT, + normalized_value_unit=FloodHazardAcquisitionService.NORMALIZED_VALUE_UNIT, + published_on=product.published_on, + catalog_url=product.catalog_url, + attribution=FloodHazardAcquisitionService.ATTRIBUTION, + limitation_message=FloodHazardAcquisitionService.LIMITATION, + ).model_dump() + for product in FloodHazardAcquisitionService._products().values() + ] + + @staticmethod + def _product(product_key: str) -> FloodHazardProduct: + product = FloodHazardAcquisitionService._products().get(product_key.strip().lower()) + if product is None: + raise AppError( + code="FLOOD_HAZARD_PRODUCT_NOT_SUPPORTED", + message="Select a governed VMM fluvial or pluvial flood-depth scenario", + details={"product_key": product_key}, + status_code=422, + ) + return product + + @staticmethod + def _prepared_request(payload: FloodHazardAcquireRequest, settings: Settings) -> dict[str, Any]: + if not settings.flood_hazard_enabled: + raise AppError(code="FLOOD_HAZARD_NOT_CONFIGURED", message="VMM flood-hazard acquisition is disabled", status_code=503) + product = FloodHazardAcquisitionService._product(payload.product_key) + resolution_m = float(payload.resolution_m or settings.flood_hazard_resolution_m) + values = (payload.bbox.min_x, payload.bbox.min_y, payload.bbox.max_x, payload.bbox.max_y) + if not all(math.isfinite(value) for value in values) or payload.bbox.min_x >= payload.bbox.max_x or payload.bbox.min_y >= payload.bbox.max_y: + raise AppError(code="INVALID_BBOX", message="Flood-hazard selection must be a finite non-empty rectangle", status_code=400) + transformer = Transformer.from_crs("EPSG:4326", FloodHazardAcquisitionService.SOURCE_CRS, always_xy=True) + metric_bounds = transformer.transform_bounds(*values, densify_pts=21) + width_m = float(metric_bounds[2] - metric_bounds[0]) + height_m = float(metric_bounds[3] - metric_bounds[1]) + if width_m < settings.flood_hazard_min_side_m or height_m < settings.flood_hazard_min_side_m: + raise AppError( + code="FLOOD_HAZARD_SELECTION_TOO_SMALL", + message=f"Select an area of at least {settings.flood_hazard_min_side_m:g} by {settings.flood_hazard_min_side_m:g} metres", + status_code=422, + ) + if width_m > settings.flood_hazard_max_side_m or height_m > settings.flood_hazard_max_side_m: + raise AppError( + code="FLOOD_HAZARD_SELECTION_TOO_LARGE", + message=f"Select an area no larger than {settings.flood_hazard_max_side_m:g} by {settings.flood_hazard_max_side_m:g} metres", + details={"width_m": width_m, "height_m": height_m}, + status_code=422, + ) + width = max(1, math.ceil(width_m / resolution_m)) + height = max(1, math.ceil(height_m / resolution_m)) + if width * height > settings.flood_hazard_max_pixels: + raise AppError( + code="FLOOD_HAZARD_SELECTION_TOO_LARGE", + message="Flood-hazard selection exceeds the configured raster cell limit", + details={"pixel_count": width * height, "max_pixels": settings.flood_hazard_max_pixels}, + status_code=422, + ) + bbox_4326 = [float(value) for value in values] + bbox_31370 = [float(value) for value in metric_bounds] + identity = { + "provider": FloodHazardAcquisitionService.PROVIDER, + "coverage_id": product.coverage_id, + "bbox_epsg4326": [round(value, 8) for value in bbox_4326], + "bbox_epsg31370": [round(value, 3) for value in bbox_31370], + "resolution_m": resolution_m, + "area_id": str(payload.area_id) if payload.area_id else None, + } + request_hash = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() + return { + **identity, + "product": product, + "request_hash": request_hash, + "bbox_epsg4326": bbox_4326, + "bbox_epsg31370": bbox_31370, + "width": width, + "height": height, + } + + @staticmethod + def _wcs_request_url(settings: Settings, product: FloodHazardProduct, bounds: tuple[float, float, float, float], resolution_m: float) -> str: + crs = "urn:ogc:def:crs:EPSG::31370" + query = [ + ("SERVICE", "WCS"), + ("VERSION", "1.1.0"), + ("REQUEST", "GetCoverage"), + ("IDENTIFIER", product.coverage_id), + ("BOUNDINGBOX", f"{bounds[0]:.3f},{bounds[1]:.3f},{bounds[2]:.3f},{bounds[3]:.3f},{crs}"), + ("FORMAT", "image/tiff"), + ("GRIDBASECRS", crs), + ("GRIDCS", "urn:ogc:def:cs:OGC:0.0:Grid2dSquareCS"), + ("GRIDTYPE", "urn:ogc:def:method:WCS:1.1:2dSimpleGrid"), + ("GRIDORIGIN", f"{bounds[0]:.3f},{bounds[3]:.3f}"), + ("GRIDOFFSETS", f"{resolution_m:g},-{resolution_m:g}"), + ] + return f"{settings.flood_hazard_wcs_url}?{urlencode(query)}" + + @staticmethod + def _tile_bounds(prepared: dict[str, Any]) -> list[tuple[float, float, float, float]]: + min_x, min_y, max_x, max_y = prepared["bbox_epsg31370"] + tiles: list[tuple[float, float, float, float]] = [] + y = min_y + while y < max_y: + tile_max_y = min(y + FloodHazardAcquisitionService.WCS_TILE_SIDE_M, max_y) + x = min_x + while x < max_x: + tile_max_x = min(x + FloodHazardAcquisitionService.WCS_TILE_SIDE_M, max_x) + tiles.append((x, y, tile_max_x, tile_max_y)) + x = tile_max_x + y = tile_max_y + return tiles + + @staticmethod + def _scope_geometry(db, project_id: UUID, area_id: UUID | None, bbox_epsg4326: list[float]): + if not db.get(Project, project_id): + raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) + selection = box(*bbox_epsg4326) + if area_id is None: + return selection + area = db.get(Area, area_id) + if not area: + raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) + if area.project_id != project_id: + raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400) + intersection = to_shape(area.geometry).intersection(selection) + if intersection.is_empty or intersection.area <= 0: + raise AppError(code="FLOOD_HAZARD_SELECTION_OUTSIDE_AREA", message="Selection does not overlap the selected work area", status_code=422) + return intersection + + @staticmethod + def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]: + request = Request(request_url, headers={"Accept": "*/*", "User-Agent": "GeoIntel/0.1 bounded-vmm-flood-hazard-acquisition"}) + max_bytes = settings.flood_hazard_max_response_mb * 1024 * 1024 + try: + with (opener or urlopen)(request, timeout=settings.flood_hazard_timeout_seconds) as response: + content_type = str(response.headers.get("Content-Type", "")) + content_length = response.headers.get("Content-Length") + if content_length and int(content_length) > max_bytes: + raise AppError(code="FLOOD_HAZARD_RESPONSE_TOO_LARGE", message="Official VMM response exceeds the configured size limit", status_code=502) + content = response.read(max_bytes + 1) + except AppError: + raise + except HTTPError as exc: + preview = exc.read(300).decode("utf-8", errors="replace") + raise AppError( + code="FLOOD_HAZARD_PROVIDER_UNAVAILABLE", + message="The official VMM WCS could not complete the bounded request", + details={"reason": str(exc), "provider_status_code": int(exc.code), "response_preview": preview}, + status_code=502, + ) from exc + except (URLError, TimeoutError, OSError) as exc: + raise AppError( + code="FLOOD_HAZARD_PROVIDER_UNAVAILABLE", + message="The official VMM WCS could not complete the bounded request", + details={"reason": str(exc)}, + status_code=502, + ) from exc + if len(content) > max_bytes: + raise AppError(code="FLOOD_HAZARD_RESPONSE_TOO_LARGE", message="Official VMM response exceeds the configured size limit", status_code=502) + return content, content_type + + @staticmethod + def _extract_geotiff(content: bytes, content_type: str) -> bytes: + if content.startswith((b"II*\x00", b"MM\x00*")): + return content + if "multipart" not in content_type.lower(): + raise AppError( + code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE", + message="The official VMM service did not return a GeoTIFF coverage", + details={"content_type": content_type, "response_preview": content[:300].decode("utf-8", errors="replace")}, + status_code=502, + ) + message = BytesParser(policy=default).parsebytes(f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + content) + for part in message.walk(): + payload = part.get_payload(decode=True) or b"" + if part.get_content_type() == "image/tiff" and payload.startswith((b"II*\x00", b"MM\x00*")): + return payload + raise AppError( + code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE", + message="The official VMM multipart response contains no valid GeoTIFF coverage", + status_code=502, + ) + + @staticmethod + def _mosaic_geotiffs(coverages: list[bytes], expected_resolution_m: float) -> bytes: + if len(coverages) == 1: + return coverages[0] + try: + from rasterio.io import MemoryFile + from rasterio.merge import merge + except ImportError as exc: + raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio is required to assemble VMM flood-hazard tiles", status_code=503) from exc + memories = [MemoryFile(content) for content in coverages] + sources = [] + try: + sources = [memory.open() for memory in memories] + for source in sources: + if source.crs is None or source.crs.to_epsg() != 31370 or source.count != 1: + raise AppError(code="FLOOD_HAZARD_TILE_MISMATCH", message="VMM coverage tiles do not share the governed CRS and band layout", status_code=502) + if not all(math.isclose(abs(float(value)), expected_resolution_m, rel_tol=0.02, abs_tol=0.05) for value in source.res): + raise AppError(code="FLOOD_HAZARD_TILE_MISMATCH", message="VMM coverage tile resolution differs from the governed request", status_code=502) + mosaic, transform = merge(sources, res=(expected_resolution_m, expected_resolution_m), nodata=0.0, dtype="float32") + profile = sources[0].profile.copy() + profile.pop("blockxsize", None) + profile.pop("blockysize", None) + profile.update(driver="GTiff", width=mosaic.shape[2], height=mosaic.shape[1], count=1, dtype="float32", crs=FloodHazardAcquisitionService.SOURCE_CRS, transform=transform, nodata=0.0, compress="deflate", predictor=3) + with MemoryFile() as output_memory: + with output_memory.open(**profile) as output: + output.write(mosaic) + return output_memory.read() + except AppError: + raise + except Exception as exc: + raise AppError(code="FLOOD_HAZARD_TILE_MOSAIC_FAILED", message="VMM flood-hazard tiles could not be assembled", details={"reason": str(exc)}, status_code=502) from exc + finally: + for source in sources: + source.close() + for memory in memories: + memory.close() + + @staticmethod + def _fetch_coverage(prepared: dict[str, Any], settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, dict[str, Any]]: + product: FloodHazardProduct = prepared["product"] + request_urls = [ + FloodHazardAcquisitionService._wcs_request_url(settings, product, bounds, prepared["resolution_m"]) + for bounds in FloodHazardAcquisitionService._tile_bounds(prepared) + ] + raw_hash = hashlib.sha256() + coverage_hash = hashlib.sha256() + content_types: list[str] = [] + coverages: list[bytes] = [] + for index, request_url in enumerate(request_urls): + if index > 0 and opener is None: + time.sleep(FloodHazardAcquisitionService.WCS_REQUEST_INTERVAL_SECONDS) + try: + raw_content, content_type = FloodHazardAcquisitionService._fetch(request_url, settings, opener) + except AppError as exc: + provider_status = (exc.details or {}).get("provider_status_code") + if opener is not None or provider_status not in FloodHazardAcquisitionService.WCS_TRANSIENT_STATUS_CODES: + raise + time.sleep(FloodHazardAcquisitionService.WCS_RETRY_DELAY_SECONDS) + raw_content, content_type = FloodHazardAcquisitionService._fetch(request_url, settings, opener) + coverage = FloodHazardAcquisitionService._extract_geotiff(raw_content, content_type) + raw_hash.update(len(raw_content).to_bytes(8, "big")); raw_hash.update(raw_content) + coverage_hash.update(len(coverage).to_bytes(8, "big")); coverage_hash.update(coverage) + content_types.append(content_type) + coverages.append(coverage) + return FloodHazardAcquisitionService._mosaic_geotiffs(coverages, prepared["resolution_m"]), { + "tile_count": len(request_urls), + "request_urls": request_urls, + "response_content_types": content_types, + "response_sha256": raw_hash.hexdigest(), + "coverage_sha256": coverage_hash.hexdigest(), + } + + @staticmethod + def _normalize_raster(content: bytes, scope_geometry_4326, prepared: dict[str, Any]) -> tuple[bytes, dict[str, Any]]: + try: + import numpy as np + from rasterio.io import MemoryFile + from rasterio.mask import mask + except ImportError as exc: + raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for flood-hazard validation", status_code=503) from exc + try: + with MemoryFile(content) as source_memory, source_memory.open() as source: + if source.crs is None or source.crs.to_epsg() != 31370: + raise AppError(code="FLOOD_HAZARD_INVALID_CRS", message="VMM flood-hazard coverage must use EPSG:31370", status_code=502) + if source.count != 1: + raise AppError(code="FLOOD_HAZARD_INVALID_BANDS", message="VMM flood-hazard coverage must contain one depth band", status_code=502) + resolution = max(abs(float(source.res[0])), abs(float(source.res[1]))) + if not math.isclose(resolution, prepared["resolution_m"], rel_tol=0.02, abs_tol=0.05): + raise AppError(code="FLOOD_HAZARD_INVALID_RESOLUTION", message="VMM coverage resolution differs from the governed request", status_code=502) + transformer = Transformer.from_crs("EPSG:4326", FloodHazardAcquisitionService.SOURCE_CRS, always_xy=True) + scope_metric = shapely_transform(transformer.transform, scope_geometry_4326) + clipped, transform = mask(source, [mapping(scope_metric)], crop=True, filled=False, indexes=[1]) + source_values = np.ma.asarray(clipped[0], dtype="float32") + raw_cm = np.asarray(source_values.filled(0.0), dtype="float32") + positive = (~np.ma.getmaskarray(source_values)) & np.isfinite(raw_cm) & (raw_cm > 0.0) + normalized_m = np.full(raw_cm.shape, FloodHazardAcquisitionService.NODATA, dtype="float32") + normalized_m[positive] = raw_cm[positive] / 100.0 + valid_values = normalized_m[positive].astype("float64") + profile = source.profile.copy() + profile.pop("blockxsize", None); profile.pop("blockysize", None) + profile.update(driver="GTiff", width=normalized_m.shape[1], height=normalized_m.shape[0], count=1, dtype="float32", crs=FloodHazardAcquisitionService.SOURCE_CRS, transform=transform, nodata=FloodHazardAcquisitionService.NODATA, compress="deflate", predictor=3) + with MemoryFile() as output_memory: + with output_memory.open(**profile) as output: + output.write(normalized_m, 1) + normalized_content = output_memory.read() + return normalized_content, { + "width": int(normalized_m.shape[1]), + "height": int(normalized_m.shape[0]), + "inundated_pixel_count": int(positive.sum()), + "nodata_value": FloodHazardAcquisitionService.NODATA, + "resolution_m": resolution, + "minimum_depth_m": float(valid_values.min()) if valid_values.size else None, + "maximum_depth_m": float(valid_values.max()) if valid_values.size else None, + "source_value_unit": FloodHazardAcquisitionService.SOURCE_VALUE_UNIT, + "normalized_value_unit": FloodHazardAcquisitionService.NORMALIZED_VALUE_UNIT, + } + except AppError: + raise + except Exception as exc: + raise AppError(code="FLOOD_HAZARD_RASTER_INVALID", message="The official VMM response is not a valid georeferenced flood-depth raster", details={"reason": str(exc)}, status_code=502) from exc + + @staticmethod + def _cached_dataset(db, project_id: UUID, filename: str) -> Dataset | None: + candidate = ( + db.query(Dataset) + .filter(Dataset.project_id == project_id, Dataset.name == filename, Dataset.source_name == FloodHazardAcquisitionService.PROVIDER, Dataset.status == "ready") + .order_by(Dataset.imported_at.desc()) + .first() + ) + if candidate and candidate.storage_path and Path(candidate.storage_path).is_file(): + return candidate + return None + + @staticmethod + def acquire(db, project_id: UUID, payload: FloodHazardAcquireRequest, *, settings: Settings | None = None, opener: Callable[..., Any] | None = None) -> dict[str, Any]: + resolved_settings = settings or get_settings() + prepared = FloodHazardAcquisitionService._prepared_request(payload, resolved_settings) + product: FloodHazardProduct = prepared["product"] + scope_geometry = FloodHazardAcquisitionService._scope_geometry(db, project_id, payload.area_id, prepared["bbox_epsg4326"]) + resolution_token = f"{prepared['resolution_m']:g}".replace(".", "p") + filename = f"vmm_flood_depth_{product.key}_{resolution_token}m_{prepared['request_hash'][:12]}.tif" + if not payload.force_refresh: + cached = FloodHazardAcquisitionService._cached_dataset(db, project_id, filename) + if cached is not None: + metadata = cached.source_metadata or {} + raster = cached.metadata_json or {} + return FloodHazardAcquisitionResult( + output_dataset_id=cached.id, + reused=True, + provider=FloodHazardAcquisitionService.PROVIDER, + product_key=product.key, + display_name=product.display_name, + mechanism=product.mechanism, + climate_context=product.climate_context, + probability_class=product.probability_class, + return_period_years=product.return_period_years, + coverage_id=product.coverage_id, + resolution_m=float(metadata.get("analysis_resolution_m", prepared["resolution_m"])), + width=int(raster.get("width", prepared["width"])), + height=int(raster.get("height", prepared["height"])), + inundated_pixel_count=int(metadata.get("inundated_pixel_count", 0)), + bbox_epsg4326=prepared["bbox_epsg4326"], + bbox_epsg31370=prepared["bbox_epsg31370"], + attribution=FloodHazardAcquisitionService.ATTRIBUTION, + limitation_message=FloodHazardAcquisitionService.LIMITATION, + ).model_dump(mode="json") + content, transfer = FloodHazardAcquisitionService._fetch_coverage(prepared, resolved_settings, opener) + normalized, validation = FloodHazardAcquisitionService._normalize_raster(content, scope_geometry, prepared) + acquired_at = datetime.now(UTC) + dataset = DatasetService.import_raster_bytes( + db, + project_id=project_id, + area_id=payload.area_id, + filename=filename, + content=normalized, + source=f"VMM OGRK WCS {product.coverage_id}", + source_name=FloodHazardAcquisitionService.PROVIDER, + source_version=FloodHazardAcquisitionService.SOURCE_VERSION, + content_type="image/tiff", + source_metadata={ + "provider": FloodHazardAcquisitionService.PROVIDER, + "service": "WCS", + "service_version": "1.1.0", + "product_key": product.key, + "product_display_name": product.display_name, + "mechanism": product.mechanism, + "climate_context": product.climate_context, + "probability_class": product.probability_class, + "return_period_years": product.return_period_years, + "coverage_id": product.coverage_id, + "native_resolution_m": FloodHazardAcquisitionService.NATIVE_RESOLUTION_M, + "analysis_resolution_m": validation["resolution_m"], + "source_crs": FloodHazardAcquisitionService.SOURCE_CRS, + "source_value_unit": FloodHazardAcquisitionService.SOURCE_VALUE_UNIT, + "normalized_value_unit": FloodHazardAcquisitionService.NORMALIZED_VALUE_UNIT, + "inundated_pixel_count": validation["inundated_pixel_count"], + "minimum_depth_m": validation["minimum_depth_m"], + "maximum_depth_m": validation["maximum_depth_m"], + "bbox_epsg4326": prepared["bbox_epsg4326"], + "bbox_epsg31370": prepared["bbox_epsg31370"], + "published_on": product.published_on, + "catalog_url": product.catalog_url, + "service_catalog_url": FloodHazardAcquisitionService.SERVICE_CATALOG_URL, + "attribution": FloodHazardAcquisitionService.ATTRIBUTION, + "license_note": FloodHazardAcquisitionService.LICENSE_NOTE, + "theme": "flood_hazard", + "layer_name": "modelled_flood_depth", + "coverage_scope": "municipality" if payload.area_id else "bounded_selection", + }, + provenance_metadata={ + "acquisition": "explicit_bounded_tiled_wcs_coverage", + "acquired_at": acquired_at.isoformat(), + "request_hash": prepared["request_hash"], + "tile_count": transfer["tile_count"], + "tile_request_urls": transfer["request_urls"], + "response_content_types": transfer["response_content_types"], + "response_sha256": transfer["response_sha256"], + "coverage_sha256": transfer["coverage_sha256"], + "normalized_sha256": hashlib.sha256(normalized).hexdigest(), + "bbox_epsg4326": prepared["bbox_epsg4326"], + "bbox_epsg31370": prepared["bbox_epsg31370"], + "requested_resolution_m": prepared["resolution_m"], + "clipped_to_area_id": str(payload.area_id) if payload.area_id else None, + "validation": validation, + "limitation_message": FloodHazardAcquisitionService.LIMITATION, + "bathymetry_available": False, + "permanent_water_depth_available": False, + "permanent_water_volume_available": False, + "concurrent_flood_volume_available": False, + }, + ) + return FloodHazardAcquisitionResult( + output_dataset_id=dataset.id, + reused=False, + provider=FloodHazardAcquisitionService.PROVIDER, + product_key=product.key, + display_name=product.display_name, + mechanism=product.mechanism, + climate_context=product.climate_context, + probability_class=product.probability_class, + return_period_years=product.return_period_years, + coverage_id=product.coverage_id, + resolution_m=validation["resolution_m"], + width=validation["width"], + height=validation["height"], + inundated_pixel_count=validation["inundated_pixel_count"], + bbox_epsg4326=prepared["bbox_epsg4326"], + bbox_epsg31370=prepared["bbox_epsg31370"], + attribution=FloodHazardAcquisitionService.ATTRIBUTION, + limitation_message=FloodHazardAcquisitionService.LIMITATION, + ).model_dump(mode="json") diff --git a/backend/app/services/flood_hazard_analysis_service.py b/backend/app/services/flood_hazard_analysis_service.py new file mode 100644 index 00000000..2907eb53 --- /dev/null +++ b/backend/app/services/flood_hazard_analysis_service.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import io +import math +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID + +from geoalchemy2.shape import to_shape +from pyproj import Transformer +from shapely.geometry import box, mapping +from shapely.ops import transform as shapely_transform + +from app.core.config import Settings, get_settings +from app.core.errors import AppError +from app.models import Area, Dataset +from app.schemas.flood_hazard import ( + FloodHazardMetric, + FloodHazardSelectionRequest, + FloodHazardSelectionResponse, + FloodHazardSelectionSummary, +) +from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService + + +class FloodHazardAnalysisService: + UNSUPPORTED_METRICS = [ + "bathymetry_depth_m", + "permanent_water_volume_m3", + "concurrent_flood_volume_m3", + ] + LIMITATION = ( + "Alle waarden horen bij het gekozen VMM-overstromingsscenario. De diepte-oppervlakte-integraal telt lokale " + "gemodelleerde maxima op en is geen gelijktijdig opgeslagen watervolume, actuele waterstand of bathymetrie." + ) + + @staticmethod + def _load_dataset(db, project_id: UUID, dataset_id: UUID) -> Dataset: + dataset = db.get(Dataset, dataset_id) + if not dataset or dataset.project_id != project_id: + raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404) + if dataset.dataset_type != "raster" or dataset.source_name != FloodHazardAcquisitionService.PROVIDER: + raise AppError( + code="INVALID_FLOOD_HAZARD_DATASET", + message="Flood-hazard analysis requires a governed VMM flood-depth raster", + status_code=400, + ) + if dataset.status != "ready" or not dataset.storage_path or not Path(dataset.storage_path).is_file(): + raise AppError(code="DATASET_FILE_MISSING", message="Persisted VMM flood-depth raster is unavailable", status_code=404) + return dataset + + @staticmethod + def _selection_geometry(db, project_id: UUID, payload: FloodHazardSelectionRequest): + selection = box(payload.bbox.min_x, payload.bbox.min_y, payload.bbox.max_x, payload.bbox.max_y) + if payload.area_id is None: + return selection + area = db.get(Area, payload.area_id) + if not area: + raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) + if area.project_id != project_id: + raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400) + intersection = selection.intersection(to_shape(area.geometry)) + if intersection.is_empty or intersection.area <= 0: + raise AppError(code="FLOOD_HAZARD_SELECTION_OUTSIDE_AREA", message="Selection does not overlap the selected work area", status_code=422) + return intersection + + @staticmethod + def analyze( + db, + project_id: UUID, + dataset_id: UUID, + payload: FloodHazardSelectionRequest, + *, + settings: Settings | None = None, + ) -> dict: + resolved_settings = settings or get_settings() + dataset = FloodHazardAnalysisService._load_dataset(db, project_id, dataset_id) + selection_4326 = FloodHazardAnalysisService._selection_geometry(db, project_id, payload) + try: + import numpy as np + import rasterio + from rasterio.features import geometry_mask + from rasterio.mask import mask + except ImportError as exc: + raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for flood-hazard analysis", status_code=503) from exc + + source_metadata = dataset.source_metadata or {} + product_key = str(source_metadata.get("product_key") or "") + product = FloodHazardAcquisitionService._products().get(product_key) + if product is None or str(source_metadata.get("normalized_value_unit") or "") != "m": + raise AppError(code="INVALID_FLOOD_HAZARD_METADATA", message="VMM flood-hazard provenance is incomplete", status_code=409) + + try: + with rasterio.open(dataset.storage_path) as source: + if source.crs is None: + raise AppError(code="INVALID_DATASET_CRS", message="VMM flood-depth raster CRS is missing", status_code=409) + transformer = Transformer.from_crs("EPSG:4326", source.crs, always_xy=True) + selection_metric = shapely_transform(transformer.transform, selection_4326) + analysis_geometry = selection_metric.intersection(box(*source.bounds)) + if analysis_geometry.is_empty or analysis_geometry.area <= 0: + raise AppError(code="FLOOD_HAZARD_SELECTION_OUTSIDE_DATASET", message="Selection does not overlap the persisted flood-depth raster", status_code=422) + min_x, min_y, max_x, max_y = analysis_geometry.bounds + expected_cells = math.ceil((max_x - min_x) / abs(source.res[0])) * math.ceil((max_y - min_y) / abs(source.res[1])) + if expected_cells > resolved_settings.flood_hazard_max_pixels: + raise AppError( + code="FLOOD_HAZARD_SELECTION_TOO_LARGE", + message="Flood-hazard analysis exceeds the configured raster cell limit", + details={"pixel_count": expected_cells, "max_pixels": resolved_settings.flood_hazard_max_pixels}, + status_code=422, + ) + clipped, clipped_transform = mask(source, [mapping(analysis_geometry)], crop=True, filled=False, indexes=[1]) + depth = np.ma.asarray(clipped[0], dtype="float64") + raw = depth.filled(np.nan) + selected_cells = geometry_mask([mapping(analysis_geometry)], out_shape=depth.shape, transform=clipped_transform, invert=True) + nodata = source.nodata + valid = selected_cells & ~np.ma.getmaskarray(depth) & np.isfinite(raw) & (raw > 0.0) + if nodata is not None: + valid &= raw != float(nodata) + values = raw[valid] + selected_cell_count = int(selected_cells.sum()) + inundated_cell_count = int(values.size) + resolution_x = abs(float(source.res[0])) + resolution_y = abs(float(source.res[1])) + cell_area_m2 = resolution_x * resolution_y + except AppError: + raise + except Exception as exc: + raise AppError( + code="FLOOD_HAZARD_ANALYSIS_FAILED", + message="The persisted VMM flood-depth raster could not be analysed", + details={"reason": str(exc)}, + status_code=500, + ) from exc + + def metric(key: str, label: str, value: float, unit: str, method: str) -> FloodHazardMetric: + return FloodHazardMetric( + metric_key=key, + metric_label=label, + metric_value=round(float(value), 4), + metric_unit=unit, + aggregation_method=method, + ) + + inundated_area_ha = inundated_cell_count * cell_area_m2 / 10_000.0 + metrics = [ + metric("modelled_inundated_area_ha", "Gemodelleerd overstroomd oppervlak", inundated_area_ha, "ha", "positive_depth_cells_times_cell_area"), + metric( + "modelled_inundated_share_pct", + "Aandeel selectie met gemodelleerde diepte", + inundated_cell_count / max(1, selected_cell_count) * 100.0, + "%", + "positive_depth_cells_divided_by_selected_cells", + ), + ] + if inundated_cell_count: + metrics.extend( + [ + metric("modelled_depth_mean_m", "Gemiddelde gemodelleerde maximumdiepte", values.mean(), "m", "mean_positive_depth_cells"), + metric("modelled_depth_p90_m", "90e percentiel gemodelleerde maximumdiepte", np.percentile(values, 90), "m", "percentile_90_positive_depth_cells"), + metric("modelled_depth_max_m", "Hoogste gemodelleerde maximumdiepte", values.max(), "m", "maximum_positive_depth_cells"), + metric( + "modelled_max_depth_area_integral_m3", + "Diepte-oppervlakte-integraal (geen gelijktijdig volume)", + values.sum() * cell_area_m2, + "m3", + "sum_local_max_depth_times_cell_area", + ), + ] + ) + primary = metrics[0] + response = FloodHazardSelectionResponse( + dataset_id=dataset.id, + product_key=product.key, + mechanism=product.mechanism, + climate_context=product.climate_context, + probability_class=product.probability_class, + return_period_years=product.return_period_years, + selection_bbox=payload.bbox, + selection_area_id=payload.area_id, + selected_cell_count=selected_cell_count, + inundated_cell_count=inundated_cell_count, + inundated_fraction=round(inundated_cell_count / max(1, selected_cell_count), 6), + resolution_m=round(max(resolution_x, resolution_y), 4), + summary=FloodHazardSelectionSummary( + metric_label=primary.metric_label, + metric_value=primary.metric_value, + metric_unit=primary.metric_unit, + aggregation_method=primary.aggregation_method, + primary_metric_key=primary.metric_key, + metrics=metrics, + ), + unsupported_metrics=FloodHazardAnalysisService.UNSUPPORTED_METRICS, + limitation_message=FloodHazardAnalysisService.LIMITATION, + generated_at=datetime.now(UTC).isoformat(), + ) + return response.model_dump(mode="json") + + @staticmethod + def render_png(db, project_id: UUID, dataset_id: UUID, *, max_dimension: int = 1800) -> bytes: + dataset = FloodHazardAnalysisService._load_dataset(db, project_id, dataset_id) + try: + import numpy as np + import rasterio + from PIL import Image + from rasterio.enums import Resampling + except ImportError as exc: + raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio, numpy and Pillow are required for flood-hazard rendering", status_code=503) from exc + try: + with rasterio.open(dataset.storage_path) as source: + scale = min(1.0, max_dimension / max(source.width, source.height)) + width = max(1, round(source.width * scale)) + height = max(1, round(source.height * scale)) + data = source.read(1, out_shape=(height, width), masked=True, resampling=Resampling.bilinear) + values = np.asarray(data.filled(np.nan), dtype="float64") + valid = np.isfinite(values) & ~np.ma.getmaskarray(data) & (values > 0.0) + normalized = np.clip(values / 2.0, 0.0, 1.0) + normalized = np.where(valid, normalized, 0.0) + stops = np.asarray([0.0, 0.15, 0.35, 0.65, 1.0]) + colors = np.asarray( + [[190, 228, 255], [105, 184, 235], [42, 132, 201], [19, 83, 154], [8, 36, 92]], + dtype="float64", + ) + rgba = np.zeros((height, width, 4), dtype="uint8") + for channel in range(3): + rgba[:, :, channel] = np.interp(normalized, stops, colors[:, channel]).astype("uint8") + rgba[:, :, 3] = np.where(valid, np.clip(150 + normalized * 90, 0, 235), 0).astype("uint8") + output = io.BytesIO() + Image.fromarray(rgba).save(output, format="PNG", optimize=True) + return output.getvalue() + except AppError: + raise + except Exception as exc: + raise AppError( + code="FLOOD_HAZARD_PREVIEW_FAILED", + message="The persisted VMM flood-depth raster could not be rendered", + details={"reason": str(exc)}, + status_code=500, + ) from exc diff --git a/backend/app/services/geo_assistant_service.py b/backend/app/services/geo_assistant_service.py index 8415492e..c3fe4668 100644 --- a/backend/app/services/geo_assistant_service.py +++ b/backend/app/services/geo_assistant_service.py @@ -21,6 +21,9 @@ from app.schemas.assistant import ( AssistantStatus, AssistantTemporalSeries, ) +from app.schemas.flood_hazard import FloodHazardSelectionRequest +from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService +from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService from app.services.vector_feature_service import VectorFeatureService @@ -252,16 +255,22 @@ class GeoAssistantService: db.query(Dataset) .filter(Dataset.project_id == project_id) .filter(Dataset.status == "ready") - .filter(Dataset.dataset_type.in_(["vector", "geojson"])) .all() ) + vector_datasets = [dataset for dataset in datasets if dataset.dataset_type in {"vector", "geojson"}] + flood_hazard_datasets = [ + dataset + for dataset in datasets + if dataset.dataset_type == "raster" and dataset.source_name == FloodHazardAcquisitionService.PROVIDER + and (area is None or dataset.area_id is None or dataset.area_id == area.id) + ] warnings: list[str] = [] context_metrics: list[AssistantContextMetric] = [] source_dataset_ids: list[UUID] = [] current_context: list[dict[str, Any]] = [] if bbox is not None: - for dataset in self._current_datasets(datasets): + for dataset in self._current_datasets(vector_datasets): kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox} if area is not None: kwargs["selection_geometry"] = area.geometry @@ -314,10 +323,60 @@ class GeoAssistantService: } ) + for dataset in sorted( + flood_hazard_datasets, + key=lambda item: str((item.source_metadata or {}).get("product_key") or item.name), + ): + try: + result = FloodHazardAnalysisService.analyze( + db, + project_id, + dataset.id, + FloodHazardSelectionRequest(bbox=bbox, area_id=area.id if area is not None else None), + settings=self.settings, + ) + except AppError as exc: + warnings.append(f"{dataset.name}: {exc.message}") + continue + metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {} + scenario_label = str(metadata.get("product_display_name") or result["product_key"]) + serialized_metrics: list[dict[str, Any]] = [] + for metric in result["summary"]["metrics"]: + item = AssistantContextMetric( + theme="flood_hazard", + label=f"{metric['metric_label']} - {scenario_label}", + value=float(metric["metric_value"]), + unit=str(metric["metric_unit"]), + source=FloodHazardAcquisitionService.ATTRIBUTION, + dataset_id=dataset.id, + is_estimate=False, + ) + context_metrics.append(item) + serialized_metrics.append(item.model_dump(mode="json")) + serialized_metrics[-1]["measurement_quality"] = "exacte_berekening_binnen_gemodelleerd_scenario" + source_dataset_ids.append(dataset.id) + current_context.append( + { + "dataset_name": dataset.name, + "dataset_id": str(dataset.id), + "theme": "flood_hazard", + "source": FloodHazardAcquisitionService.ATTRIBUTION, + "scenario": { + "label": scenario_label, + "mechanism": result["mechanism"], + "climate_context": result["climate_context"], + "probability_class": result["probability_class"], + "return_period_years": result["return_period_years"], + }, + "metrics": serialized_metrics, + "warning": result["limitation_message"], + } + ) + temporal_series: list[AssistantTemporalSeries] = [] temporal_context: list[dict[str, Any]] = [] include_history = self.history_requested(payload.question) - for key, observations in self._series(datasets): + for key, observations in self._series(vector_datasets): first = observations[0] last = observations[-1] source_metadata = last.source_metadata if isinstance(last.source_metadata, dict) else {} @@ -364,7 +423,9 @@ class GeoAssistantService: "available_temporal_series": temporal_context, "rules": { "water_volume_available": False, - "water_volume_reason": "Geen gebiedsdekkende waterdiepte of bathymetrie gekoppeld.", + "water_volume_reason": "Geen bathymetrie gekoppeld voor de permanente inhoud van waterlichamen.", + "flood_hazard_scenarios_available": bool(flood_hazard_datasets), + "flood_depth_area_integral_is_concurrent_volume": False, "object_counts_are_supporting_metrics": True, "causal_explanations_available": False, "forecast_available": False, @@ -402,6 +463,7 @@ class GeoAssistantService: "Neem waarden en jaren letterlijk over en bereken zelf geen gemiddelde, tempo, oorzaak of afgeleide trend. " "Gebruik platte tekst met korte alinea's en opsommingen, zonder Markdown-symbolen. " "Bereken of suggereer nooit watervolume zonder gekoppelde diepte of bathymetrie. " + "Noem de VMM-diepte-oppervlakte-integraal nooit een werkelijk, permanent of gelijktijdig watervolume. " "Als de gevraagde informatie niet in de context staat, zeg precies welke bron of meting ontbreekt. " "CONTEXT_JSON:\n" + json.dumps(context, ensure_ascii=False, separators=(",", ":")) ) diff --git a/backend/tests/test_sprint208_vmm_flood_hazard.py b/backend/tests/test_sprint208_vmm_flood_hazard.py new file mode 100644 index 00000000..09a82d6d --- /dev/null +++ b/backend/tests/test_sprint208_vmm_flood_hazard.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +import numpy as np +import pytest +import rasterio +from fastapi.testclient import TestClient +from pyproj import Transformer +from rasterio.io import MemoryFile +from rasterio.transform import from_origin +from shapely.geometry import box + +from app.core.config import Settings +from app.core.errors import AppError +from app.db.session import get_db +from app.main import app +from app.models import Dataset, Job, Project +from app.schemas.flood_hazard import FloodHazardAcquireRequest, FloodHazardSelectionRequest +from app.schemas.assistant import AssistantQueryRequest +from app.services.geo_assistant_service import GeoAssistantService +from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService +from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService + + +ROOT = Path(__file__).resolve().parents[2] + + +class FakeQuery: + def __init__(self, result=None): + self.result = result + + def filter(self, *_args): + return self + + def order_by(self, *_args): + return self + + def first(self): + return self.result + + def all(self): + return self.result if isinstance(self.result, list) else [] + + +class FakeSession: + def __init__(self, rows=None, query_result=None): + self.rows = rows or {} + self.query_result = query_result + self.added = [] + + def get(self, model, row_id): + row = self.rows.get((model, row_id)) + if row is not None: + return row + return next((item for item in self.added if isinstance(item, model) and item.id == row_id), None) + + def add(self, row): + self.added.append(row) + + def commit(self): + return None + + def rollback(self): + return None + + def refresh(self, row): + return row + + def query(self, _model): + return FakeQuery(self.query_result) + + +def flood_payload(*, product_key: str = "pluviaal_current_t100", side_m: float = 100.0) -> FloodHazardAcquireRequest: + transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) + min_x, min_y = transformer.transform(200_000, 210_000) + max_x, max_y = transformer.transform(200_000 + side_m, 210_000 + side_m) + return FloodHazardAcquireRequest( + bbox={"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"}, + product_key=product_key, + resolution_m=5.0, + force_refresh=True, + ) + + +def depth_tiff(*, normalized_metres: bool = False) -> bytes: + values = np.zeros((20, 20), dtype="float32") + values[:, :10] = 1.0 if normalized_metres else 100.0 + with MemoryFile() as memory: + with memory.open( + driver="GTiff", + width=20, + height=20, + count=1, + dtype="float32", + crs="EPSG:31370", + transform=from_origin(200_000, 210_100, 5.0, 5.0), + nodata=-9999.0 if normalized_metres else 0.0, + ) as output: + if normalized_metres: + values[:, 10:] = -9999.0 + output.write(values, 1) + return memory.read() + + +def test_flood_hazard_registry_is_complete_and_semantically_honest() -> None: + products = FloodHazardAcquisitionService.list_products() + + assert len(products) == 12 + assert {item["mechanism"] for item in products} == {"pluviaal", "fluviaal"} + assert {item["climate_context"] for item in products} == {"huidig klimaat", "klimaatprojectie 2050"} + assert {item["return_period_years"] for item in products} == {10, 100, 1000} + assert all(item["coverage_id"].startswith("Overstromingsgevaarkaarten-") for item in products) + assert all(item["source_value_unit"] == "cm" and item["normalized_value_unit"] == "m" for item in products) + assert all("geen bathymetrie" in item["limitation_message"] for item in products) + + +def test_flood_hazard_request_is_bounded_and_rejects_arbitrary_products() -> None: + settings = Settings(_env_file=None) + prepared = FloodHazardAcquisitionService._prepared_request(flood_payload(), settings) + product = prepared["product"] + url = FloodHazardAcquisitionService._wcs_request_url( + settings, + product, + tuple(prepared["bbox_epsg31370"]), + prepared["resolution_m"], + ) + + assert "VERSION=1.1.0" in url + assert "IDENTIFIER=Overstromingsgevaarkaarten-PLUVIAAL%3Awaterdiepte_PLU_noCC_T100" in url + assert "GRIDOFFSETS=5%2C-5" in url + assert prepared["width"] * prepared["height"] <= settings.flood_hazard_max_pixels + + with pytest.raises(AppError) as exc_info: + FloodHazardAcquisitionService._prepared_request(flood_payload(product_key="custom"), settings) + assert exc_info.value.code == "FLOOD_HAZARD_PRODUCT_NOT_SUPPORTED" + + +def test_flood_hazard_normalization_converts_centimetres_and_clips_zero_values() -> None: + payload = flood_payload() + prepared = FloodHazardAcquisitionService._prepared_request(payload, Settings(_env_file=None)) + scope = box( + payload.bbox.min_x, + payload.bbox.min_y, + payload.bbox.max_x, + payload.bbox.max_y, + ) + + normalized, validation = FloodHazardAcquisitionService._normalize_raster(depth_tiff(), scope, prepared) + + assert validation["inundated_pixel_count"] == 200 + assert validation["minimum_depth_m"] == pytest.approx(1.0) + assert validation["maximum_depth_m"] == pytest.approx(1.0) + with MemoryFile(normalized) as memory, memory.open() as dataset: + values = dataset.read(1, masked=True) + assert dataset.crs.to_epsg() == 31370 + assert dataset.nodata == -9999.0 + assert values.count() == 200 + assert float(values.mean()) == pytest.approx(1.0) + + +def test_flood_hazard_analysis_reports_scenario_metrics_without_claiming_waterbody_volume(tmp_path) -> None: + project_id = uuid4() + dataset_id = uuid4() + path = tmp_path / "flood.tif" + path.write_bytes(depth_tiff(normalized_metres=True)) + dataset = Dataset( + id=dataset_id, + project_id=project_id, + name="flood.tif", + dataset_type="raster", + source="VMM", + source_name=FloodHazardAcquisitionService.PROVIDER, + source_metadata={"product_key": "pluviaal_current_t100", "normalized_value_unit": "m"}, + status="ready", + storage_path=str(path), + ) + db = FakeSession({(Dataset, dataset_id): dataset}) + + result = FloodHazardAnalysisService.analyze( + db, + project_id, + dataset_id, + FloodHazardSelectionRequest(bbox=flood_payload().bbox), + settings=Settings(_env_file=None), + ) + metrics = {item["metric_key"]: item for item in result["summary"]["metrics"]} + + assert result["inundated_cell_count"] == 200 + assert result["inundated_fraction"] == pytest.approx(0.5) + assert metrics["modelled_inundated_area_ha"]["metric_value"] == pytest.approx(0.5) + assert metrics["modelled_depth_mean_m"]["metric_value"] == pytest.approx(1.0) + assert metrics["modelled_max_depth_area_integral_m3"]["metric_value"] == pytest.approx(5000.0) + assert "concurrent_flood_volume_m3" in result["unsupported_metrics"] + assert "geen gelijktijdig" in result["limitation_message"] + + +def test_flood_hazard_renderer_returns_transparent_png(tmp_path) -> None: + project_id = uuid4() + dataset_id = uuid4() + path = tmp_path / "flood.tif" + path.write_bytes(depth_tiff(normalized_metres=True)) + dataset = Dataset( + id=dataset_id, + project_id=project_id, + name="flood.tif", + dataset_type="raster", + source="VMM", + source_name=FloodHazardAcquisitionService.PROVIDER, + source_metadata={"product_key": "pluviaal_current_t100", "normalized_value_unit": "m"}, + status="ready", + storage_path=str(path), + ) + db = FakeSession({(Dataset, dataset_id): dataset}) + + assert FloodHazardAnalysisService.render_png(db, project_id, dataset_id).startswith(b"\x89PNG\r\n\x1a\n") + + +def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None: + project_id = uuid4() + output_dataset_id = uuid4() + db = FakeSession({(Project, project_id): Project(id=project_id, name="Mol")}) + monkeypatch.setattr( + FloodHazardAcquisitionService, + "acquire", + lambda *_args, **_kwargs: {"output_dataset_id": str(output_dataset_id), "provider": "vmm_flood_hazard", "reused": False}, + ) + monkeypatch.setattr( + FloodHazardAnalysisService, + "analyze", + lambda *_args, **_kwargs: { + "dataset_id": str(output_dataset_id), + "inundated_cell_count": 4, + "summary": {"metric_value": 0.01, "metric_unit": "ha", "metrics": []}, + "unsupported_metrics": ["permanent_water_volume_m3"], + }, + ) + app.dependency_overrides[get_db] = lambda: db + try: + client = TestClient(app) + products = client.get(f"/api/v1/projects/{project_id}/datasets/flood-hazard/products") + acquisition = client.post( + f"/api/v1/projects/{project_id}/datasets/flood-hazard/acquire", + json=flood_payload().model_dump(mode="json"), + ) + selection = client.post( + f"/api/v1/projects/{project_id}/datasets/{output_dataset_id}/raster/flood-hazard/select", + json={"bbox": flood_payload().bbox.model_dump()}, + ) + finally: + app.dependency_overrides.clear() + + assert products.status_code == 200 and set(products.json()) == {"data"} + assert products.json()["data"]["total"] == 12 + assert acquisition.status_code == 200 and set(acquisition.json()) == {"data"} + assert acquisition.json()["data"]["job_type"] == "raster.flood_hazard.acquire" + assert selection.status_code == 200 and set(selection.json()) == {"data"} + assert any(isinstance(item, Job) for item in db.added) + + +def test_geo_assistant_receives_scenario_bound_flood_metrics(monkeypatch) -> None: + project_id = uuid4() + dataset_id = uuid4() + dataset = Dataset( + id=dataset_id, + project_id=project_id, + name="pluvial.tif", + dataset_type="raster", + source="VMM", + source_name=FloodHazardAcquisitionService.PROVIDER, + source_metadata={ + "product_key": "pluviaal_current_t100", + "product_display_name": "Pluviaal - huidig klimaat - middelgrote kans (T100)", + }, + status="ready", + ) + db = FakeSession({(Project, project_id): Project(id=project_id, name="Mol")}, query_result=[dataset]) + monkeypatch.setattr( + FloodHazardAnalysisService, + "analyze", + lambda *_args, **_kwargs: { + "product_key": "pluviaal_current_t100", + "mechanism": "pluviaal", + "climate_context": "huidig klimaat", + "probability_class": "middelgrote kans", + "return_period_years": 100, + "summary": { + "metrics": [ + { + "metric_label": "Gemodelleerd overstroomd oppervlak", + "metric_value": 12.5, + "metric_unit": "ha", + } + ] + }, + "limitation_message": "Geen werkelijk of gelijktijdig volume.", + }, + ) + payload = AssistantQueryRequest(question="Wat is het overstromingsgevaar?", bbox=flood_payload().bbox) + + context, metrics, _series, dataset_ids, warnings, _scope = GeoAssistantService(Settings(_env_file=None))._build_context( + db, + project_id=project_id, + payload=payload, + ) + + assert warnings == [] + assert dataset_ids == [dataset_id] + assert metrics[0].theme == "flood_hazard" + assert "T100" in metrics[0].label + assert context["rules"]["water_volume_available"] is False + assert context["rules"]["flood_hazard_scenarios_available"] is True + assert context["rules"]["flood_depth_area_integral_is_concurrent_volume"] is False + + +def test_flood_hazard_runtime_contract_is_packaged() -> None: + for path in ( + ROOT / ".env.example", + ROOT / "docker-compose.yml", + ROOT / "docker-compose.unraid.yml", + ROOT / "deploy" / "unraid" / "geointel.env.example", + ): + content = path.read_text(encoding="utf-8") + assert "FLOOD_HAZARD_ENABLED" in content + assert "FLOOD_HAZARD_WCS_URL" in content + assert "FLOOD_HAZARD_MAX_PIXELS" in content + + operator = (ROOT / "scripts" / "provision_mol_flood_hazards.py").read_text(encoding="utf-8") + 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") + frontend = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") + assert "/datasets/flood-hazard/acquire" in operator + assert "/raster/flood-hazard/select" in operator + assert "concurrent_flood_volume_m3" in operator + assert "py_compile scripts/provision_mol_flood_hazards.py" in readiness + assert "COPY scripts/provision_mol_flood_hazards.py" in dockerfile + assert "Overstromingsscenario" in frontend + assert "floodHazardImageUrl" in frontend diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index 1ff1750f..d5d91dab 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -75,6 +75,7 @@ COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator COPY scripts/provision_mol_municipality_workspace.py /app/scripts/provision_mol_municipality_workspace.py COPY scripts/provision_mol_context_layers.py /app/scripts/provision_mol_context_layers.py COPY scripts/provision_mol_dhmv.py /app/scripts/provision_mol_dhmv.py +COPY scripts/provision_mol_flood_hazards.py /app/scripts/provision_mol_flood_hazards.py COPY scripts/provision_mol_population_history.py /app/scripts/provision_mol_population_history.py COPY scripts/provision_mol_historical_landuse.py /app/scripts/provision_mol_historical_landuse.py COPY scripts/provision_official_landuse_timeseries.py /app/scripts/provision_official_landuse_timeseries.py diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index 61f69dc4..5fb0da1c 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -39,6 +39,10 @@ 5.0 20000 12000000 + true + https://geoservice.waterinfo.be/OGRK/wcs + 5.0 + 12000000 true http://host.docker.internal:11434 qwen3.5:9b diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index 536ece29..3a01072f 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -40,6 +40,14 @@ DHMV_MAX_SIDE_M=20000 DHMV_MAX_PIXELS=12000000 DHMV_TIMEOUT_SECONDS=300 DHMV_MAX_RESPONSE_MB=160 +FLOOD_HAZARD_ENABLED=true +FLOOD_HAZARD_WCS_URL=https://geoservice.waterinfo.be/OGRK/wcs +FLOOD_HAZARD_RESOLUTION_M=5.0 +FLOOD_HAZARD_MIN_SIDE_M=10 +FLOOD_HAZARD_MAX_SIDE_M=20000 +FLOOD_HAZARD_MAX_PIXELS=12000000 +FLOOD_HAZARD_TIMEOUT_SECONDS=300 +FLOOD_HAZARD_MAX_RESPONSE_MB=160 # Optional configured-YOLO runtime. Keep disabled unless a local model is mounted. GEOINTEL_INSTALL_AI=false diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index f66b2ae4..6551b747 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -35,6 +35,14 @@ DHMV_MAX_SIDE_M="${DHMV_MAX_SIDE_M:-20000}" DHMV_MAX_PIXELS="${DHMV_MAX_PIXELS:-12000000}" DHMV_TIMEOUT_SECONDS="${DHMV_TIMEOUT_SECONDS:-300}" DHMV_MAX_RESPONSE_MB="${DHMV_MAX_RESPONSE_MB:-160}" +FLOOD_HAZARD_ENABLED="${FLOOD_HAZARD_ENABLED:-true}" +FLOOD_HAZARD_WCS_URL="${FLOOD_HAZARD_WCS_URL:-https://geoservice.waterinfo.be/OGRK/wcs}" +FLOOD_HAZARD_RESOLUTION_M="${FLOOD_HAZARD_RESOLUTION_M:-5.0}" +FLOOD_HAZARD_MIN_SIDE_M="${FLOOD_HAZARD_MIN_SIDE_M:-10}" +FLOOD_HAZARD_MAX_SIDE_M="${FLOOD_HAZARD_MAX_SIDE_M:-20000}" +FLOOD_HAZARD_MAX_PIXELS="${FLOOD_HAZARD_MAX_PIXELS:-12000000}" +FLOOD_HAZARD_TIMEOUT_SECONDS="${FLOOD_HAZARD_TIMEOUT_SECONDS:-300}" +FLOOD_HAZARD_MAX_RESPONSE_MB="${FLOOD_HAZARD_MAX_RESPONSE_MB:-160}" YOLO_ENABLED="${YOLO_ENABLED:-false}" YOLO_MODELS_DIR="${YOLO_MODELS_DIR:-/app/models}" YOLO_MODEL_PATH="${YOLO_MODEL_PATH:-}" @@ -119,6 +127,14 @@ docker run -d \ -e DHMV_MAX_PIXELS="$DHMV_MAX_PIXELS" \ -e DHMV_TIMEOUT_SECONDS="$DHMV_TIMEOUT_SECONDS" \ -e DHMV_MAX_RESPONSE_MB="$DHMV_MAX_RESPONSE_MB" \ + -e FLOOD_HAZARD_ENABLED="$FLOOD_HAZARD_ENABLED" \ + -e FLOOD_HAZARD_WCS_URL="$FLOOD_HAZARD_WCS_URL" \ + -e FLOOD_HAZARD_RESOLUTION_M="$FLOOD_HAZARD_RESOLUTION_M" \ + -e FLOOD_HAZARD_MIN_SIDE_M="$FLOOD_HAZARD_MIN_SIDE_M" \ + -e FLOOD_HAZARD_MAX_SIDE_M="$FLOOD_HAZARD_MAX_SIDE_M" \ + -e FLOOD_HAZARD_MAX_PIXELS="$FLOOD_HAZARD_MAX_PIXELS" \ + -e FLOOD_HAZARD_TIMEOUT_SECONDS="$FLOOD_HAZARD_TIMEOUT_SECONDS" \ + -e FLOOD_HAZARD_MAX_RESPONSE_MB="$FLOOD_HAZARD_MAX_RESPONSE_MB" \ -e YOLO_ENABLED="$YOLO_ENABLED" \ -e YOLO_MODELS_DIR="$YOLO_MODELS_DIR" \ -e YOLO_MODEL_PATH="$YOLO_MODEL_PATH" \ diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index f068e25f..6dbc5c51 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -33,6 +33,14 @@ services: DHMV_MAX_PIXELS: ${DHMV_MAX_PIXELS:-12000000} DHMV_TIMEOUT_SECONDS: ${DHMV_TIMEOUT_SECONDS:-300} DHMV_MAX_RESPONSE_MB: ${DHMV_MAX_RESPONSE_MB:-160} + FLOOD_HAZARD_ENABLED: ${FLOOD_HAZARD_ENABLED:-true} + FLOOD_HAZARD_WCS_URL: ${FLOOD_HAZARD_WCS_URL:-https://geoservice.waterinfo.be/OGRK/wcs} + FLOOD_HAZARD_RESOLUTION_M: ${FLOOD_HAZARD_RESOLUTION_M:-5.0} + FLOOD_HAZARD_MIN_SIDE_M: ${FLOOD_HAZARD_MIN_SIDE_M:-10} + FLOOD_HAZARD_MAX_SIDE_M: ${FLOOD_HAZARD_MAX_SIDE_M:-20000} + FLOOD_HAZARD_MAX_PIXELS: ${FLOOD_HAZARD_MAX_PIXELS:-12000000} + FLOOD_HAZARD_TIMEOUT_SECONDS: ${FLOOD_HAZARD_TIMEOUT_SECONDS:-300} + FLOOD_HAZARD_MAX_RESPONSE_MB: ${FLOOD_HAZARD_MAX_RESPONSE_MB:-160} YOLO_ENABLED: ${YOLO_ENABLED:-false} YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models} YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} diff --git a/docker-compose.yml b/docker-compose.yml index 7845b3f6..604105ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,14 @@ services: DHMV_MAX_PIXELS: ${DHMV_MAX_PIXELS:-12000000} DHMV_TIMEOUT_SECONDS: ${DHMV_TIMEOUT_SECONDS:-300} DHMV_MAX_RESPONSE_MB: ${DHMV_MAX_RESPONSE_MB:-160} + FLOOD_HAZARD_ENABLED: ${FLOOD_HAZARD_ENABLED:-true} + FLOOD_HAZARD_WCS_URL: ${FLOOD_HAZARD_WCS_URL:-https://geoservice.waterinfo.be/OGRK/wcs} + FLOOD_HAZARD_RESOLUTION_M: ${FLOOD_HAZARD_RESOLUTION_M:-5.0} + FLOOD_HAZARD_MIN_SIDE_M: ${FLOOD_HAZARD_MIN_SIDE_M:-10} + FLOOD_HAZARD_MAX_SIDE_M: ${FLOOD_HAZARD_MAX_SIDE_M:-20000} + FLOOD_HAZARD_MAX_PIXELS: ${FLOOD_HAZARD_MAX_PIXELS:-12000000} + FLOOD_HAZARD_TIMEOUT_SECONDS: ${FLOOD_HAZARD_TIMEOUT_SECONDS:-300} + FLOOD_HAZARD_MAX_RESPONSE_MB: ${FLOOD_HAZARD_MAX_RESPONSE_MB:-160} YOLO_ENABLED: ${YOLO_ENABLED:-false} YOLO_MODELS_DIR: ${YOLO_MODELS_DIR:-/app/models} YOLO_MODEL_PATH: ${YOLO_MODEL_PATH:-} diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index ca81eccc..7f1ea966 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -277,6 +277,51 @@ Safety contract: - product periods such as `1979_1990` remain explicitly multi-year and are not presented as exact annual observations. +### GET `/api/v1/projects/{project_id}/datasets/flood-hazard/products` + +Returns the fixed twelve-product VMM flood-depth registry in the canonical +envelope. Products combine `pluviaal`/`fluviaal`, current climate/climate +projection 2050 and T10/T100/T1000. Each product keeps the official WCS +coverage id, probability class, source unit centimetres, normalized unit +metres, publication metadata, attribution and limitation. + +### POST `/api/v1/projects/{project_id}/datasets/flood-hazard/acquire` + +Acquires one bounded official VMM OGRK WCS 1.1 coverage behind the synchronous +Job abstraction. Arbitrary coverage identifiers and service URLs are rejected. + +```json +{ + "bbox": {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}, + "area_id": "optional-project-area-uuid", + "product_key": "pluviaal_current_t100", + "resolution_m": 5.0, + "force_refresh": false +} +``` + +The service tiles municipality-size requests, validates EPSG:31370 and one +Float32 depth band, converts positive source centimetres to metres, clips to +the exact persisted Area and stores an ordinary raster Dataset and +DatasetVersion. Zero/null source cells become transparent nodata. The scenario +is not a temporal observation and receives no fabricated `observed_at` value. + +### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/select` + +Returns mapped positive-depth area in hectares, share of the selection, mean, +P90 and maximum modeled local depth and `modelled_max_depth_area_integral_m3`. +Every result identifies mechanism, climate context, probability class and +return period. The integral sums local modeled maximum depth times cell area; +it is explicitly not concurrent flood storage, permanent waterbody content, +current water level or bathymetry. These unsupported metrics remain listed in +the response. + +### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/image` + +Returns a constrained transparent PNG for a persisted governed VMM flood-depth +Dataset. It never accepts an arbitrary path or coverage id and is used by the +existing MapLibre image-overlay path. + ### GET `/api/v1/projects/{project_id}/datasets` List datasets. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 5b9ab233..fbba8847 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -8737,3 +8737,51 @@ Known limitations: Next: - Audit P5 official bathymetry and water-depth sources before enabling any water depth or volume metric. Keep DHMV as height/relief input only. + +## Sprint 208 - Governed VMM flood-hazard scenarios (2026-07-15) + +Implemented: +- Audited official Vlaamse water-depth/bathymetry sources. Public coastal and + North Sea bathymetry does not cover Mol; Waterinfo is station-based and GRB + water geometry is two-dimensional. Permanent waterbody volume therefore + remains unavailable. +- Added a fixed VMM OGRK registry for twelve water-depth scenarios: fluvial and + pluvial, current climate and climate projection 2050, each at T10/T100/T1000. +- Added bounded WCS 1.1 retrieval, 10 km tiling, multipart GeoTIFF extraction, + georeferenced mosaicking, exact persisted-Area clipping and conversion from + positive source centimetres to normalized metres. +- Persisted scenario, probability, units, bounds, request URLs and response, + coverage and normalized checksums through the existing Job, Dataset and + DatasetVersion architecture. No migration or direct raster DB write exists. +- Added exact raster selection metrics for mapped inundated area/share, + mean/P90/maximum modeled depth and a maximum-depth area integral. The API, + UI and Ollama context all prohibit interpreting that integral as actual, + permanent or concurrent water volume. +- Added an `Overstroming` map theme, explicit scenario selector, transparent + MapLibre overlay, source-inventory presentation and full-Mol operator. +- Added Docker/Unraid configuration, packaging and focused backend/frontend/AI + contracts without a new dependency. + +Validation evidence before live deployment: +- Official WCS capabilities and DescribeCoverage confirmed EPSG:31370, 2 m + GridOffsets, Float32 values, null value 0 and `image/tiff` multipart output. +- A live bounded VMM request returned valid georeferenced data; official + catalog metadata confirms depth is expressed in centimetres between modeled + water surface and terrain. +- Synthetic GIS fixtures confirmed 200 positive 5 m cells at 1 m depth produce + 0.5 ha mapped area and exactly 5,000 m3 maximum-depth area integral. +- Focused flood-hazard and Ollama tests passed; frontend typecheck and build + passed. Full readiness and Tower provisioning follow in this same pass. + +Known limitations: +- The scenario raster represents modeled local maximum depth at 1:100,000 + application scale, not a measurement of a current flood event. +- No public municipality-wide inland bathymetry was identified. Waterbody + bottom elevation, uncertainty and permanent content remain open. +- Scenario alternatives are not observations in time and are intentionally not + exposed as a historical temporal series. + +Next: +- Complete live twelve-scenario Mol provisioning and browser validation. After + that, treat permanent inland bathymetry as an external-data prerequisite, + not as a value derivable from DHMV or the flood-hazard maps. diff --git a/docs/DATABASE_IMPLEMENTATION_PLAN.md b/docs/DATABASE_IMPLEMENTATION_PLAN.md index d1e475cc..38e81fbd 100644 --- a/docs/DATABASE_IMPLEMENTATION_PLAN.md +++ b/docs/DATABASE_IMPLEMENTATION_PLAN.md @@ -269,6 +269,19 @@ public provider endpoint's `not_configured` status. - User accounts. - Full model registry tables. +## Governed flood-hazard rasters + +VMM flood-depth scenarios require no new table. Each acquired coverage is an +ordinary `datasets` raster plus immutable `dataset_versions` provenance and a +normal synchronous acquisition Job. The GeoTIFF remains filesystem/object +storage; PostgreSQL keeps source identity, WCS checksums, EPSG:31370 bounds, +source/normalized units, exact Area scope and scenario parameters. + +Scenario alternatives do not receive a fabricated `observed_at` value and are +not grouped as a temporal series. Selection metrics are calculated on demand +from the persisted raster. Bathymetry and permanent waterbody volume remain +absent from persistence until a separately governed source/model exists. + ## Temporal dataset foundation Historical observations remain normal `datasets` and `vector_features`; there diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md index 7be8570e..7e0411d1 100644 --- a/docs/DATA_SOURCES.md +++ b/docs/DATA_SOURCES.md @@ -340,6 +340,32 @@ DSM includes buildings and vegetation. Neither is exposed as water depth, water volume or a directly measured building-height product. A drainage model would require a separately governed hydrological processing pass. +## VMM overstromingsgevaarkaarten + +- Naam: Overstromingsgevaarkaart Waterdiepte +- Uitgever: Vlaamse Milieumaatschappij +- Type: raster/modelscenario +- Toegang: publieke OGRK WCS 1.1 `https://geoservice.waterinfo.be/OGRK/wcs` +- Coverages: fluviaal/pluviaal, huidig klimaat/klimaatprojectie 2050, + T10/T100/T1000 +- Native raster: 2 m Float32, EPSG:31370, waterdiepte in centimeter +- Publicatie: 2019/2021 afhankelijk van product; scenario-identiteit is + leidend en wordt niet als observatiedatum opgeslagen +- Cache: canonical raster Dataset plus request/response/output checksums +- Operator: `scripts/provision_mol_flood_hazards.py` +- Prioriteit: P5 scenariofundament uitgevoerd voor Mol + +VMM beschrijft deze lagen als maximale lokale waterdiepte tussen wateroppervlak +en maaiveld voor een gekozen kans- en klimaatscenario. GeoIntel converteert +positieve waarden naar meter en kan een diepte-oppervlakte-integraal berekenen. +Omdat lokale maxima niet noodzakelijk op hetzelfde tijdstip optreden, is dat +geen gelijktijdig overstromingsvolume. De bron bevat evenmin bodemprofielen van +meren, kanalen of waterlopen en kan dus geen permanente waterinhoud leveren. + +De officiële audit vond geen publieke, gebiedsdekkende inland-bathymetrie voor +Mol. Kust- en Noordzeeproducten vallen buiten de ruimtelijke scope. Waterinfo +stations blijven puntmetingen en GRB-watergeometrie blijft tweedimensionaal. + ## Gebouwenregister The governed operator `scripts/provision_buildings_addresses_register.py` diff --git a/docs/DATA_SPECIFICATION.md b/docs/DATA_SPECIFICATION.md index c56ce8d5..eef48407 100644 --- a/docs/DATA_SPECIFICATION.md +++ b/docs/DATA_SPECIFICATION.md @@ -185,6 +185,45 @@ Raster / afgeleid van LiDAR. P4 operationeel voor Mol; regionale uitrol volgt dezelfde operatorgrenzen. +## VMM overstromingsgevaarkaarten waterdiepte + +### Rol + +Scenarioanalyse voor gemodelleerde overstroming door intense neerslag +(`pluviaal`) en vanuit waterlopen (`fluviaal`). Dit is een afzonderlijk thema +en geen verdieping van de gewone GRB-waterlaag. + +### Governed products + +- VMM OGRK WCS 1.1, twaalf vaste coverages +- huidig klimaat en klimaatprojectie 2050 +- grote, middelgrote en kleine kans: T10, T100 en T1000 +- native raster 2 m, EPSG:31370, bronwaarden in centimeter +- GeoIntel normaliseert positieve dieptes naar meter en bronnullen naar + transparant `-9999` nodata +- standaard analysekopie 5 m, exact geclipt op de persisted Area + +### Toegestane metingen + +- gemodelleerd overstroomd oppervlak in hectare +- aandeel van de selectie met positieve gemodelleerde diepte +- gemiddelde, P90 en maximale lokale gemodelleerde maximumdiepte in meter +- diepte-oppervlakte-integraal in m3, uitsluitend onder die exacte naam en met + de vaste waarschuwing dat lokale maxima niet noodzakelijk gelijktijdig zijn + +De laag meet geen huidige waterstand en bevat geen bodemhoogte. Bathymetrie, +permanente inhoud van waterlichamen en gelijktijdig overstromingsvolume blijven +onbeschikbaar. Scenario's zijn alternatieve modelcondities, geen historische +meetmomenten en daarom geen GeoIntel-temporale reeks. + +### Type + +Raster / gemodelleerd overstromingsgevaar. + +### Prioriteit + +P5 overstromingsscenario's operationeel voor Mol; bathymetrie blijft open. + ## LAS/LAZ LiDAR ### Rol diff --git a/docs/TODO.md b/docs/TODO.md index 41d40d92..f002d159 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -67,7 +67,10 @@ pass live Mol validation before regional expansion. ### P5 - Water depth / bathymetry -- [ ] Identify an authoritative source with compatible spatial coverage, vertical datum, date and uncertainty; otherwise keep volume unavailable. +- [x] Audit authoritative sources: no public municipality-wide inland bathymetry for Mol was identified; keep permanent waterbody volume unavailable. +- [x] Integrate the separate public VMM fluvial/pluvial flood-hazard depth scenarios without presenting them as bathymetry or current water state. +- [x] Persist scenario identity, source centimetres, normalized metres, WCS checksums, exact Area clipping and positive-depth coverage. +- [x] Expose mapped inundation area, depth statistics and a clearly named maximum-depth area integral with a prohibition on calling it concurrent volume. - [ ] Define waterbody linkage, surface elevation, bottom elevation and uncertainty propagation before adding any volume metric. - [ ] Validate coverage gaps and prohibit extrapolation outside measured/profiled waterbodies. - [ ] Add independent GIS review and golden-volume fixtures before exposing the result to users or Ollama. diff --git a/frontend/README.md b/frontend/README.md index 3598cb2d..7f10654e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -500,6 +500,16 @@ TAW and the 2013-2015 source period visible. Raster cells are not presented as objects. GeoJSON export is disabled for this raster-only result, and the UI explicitly states that water depth and volume cannot be derived from DHMV. +When governed VMM scenarios are present, the Map explorer adds a separate +`Overstroming` theme. It is deliberately not merged into `Water`: the latter +describes persisted water surfaces and watercourse lengths, while the former +is modeled flood hazard. A scenario selector keeps pluvial/fluvial, +T10/T100/T1000 and current/2050 conditions visible before analysis. The +persisted depth raster is shown through the existing MapLibre image-overlay +pattern and a rectangle returns scenario-bound hectare/depth metrics. Raster +GeoJSON export stays disabled. The UI never labels the maximum-depth area +integral as current, permanent or concurrent water volume. + ## Useful repository scripts - `bash scripts/frontend_install.sh` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ace1680..14d7e479 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -187,7 +187,10 @@ function App(): JSX.Element { const terrain = datasets.filter( (dataset) => dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv' && dataset.status === 'ready', ) - return [...vectors, ...terrain] + const floodHazards = datasets.filter( + (dataset) => dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard' && dataset.status === 'ready', + ) + return [...vectors, ...terrain, ...floodHazards] }, [datasets], ) diff --git a/frontend/src/components/datasets/SourceCatalogPanel.tsx b/frontend/src/components/datasets/SourceCatalogPanel.tsx index 8ef90257..efcbbc39 100644 --- a/frontend/src/components/datasets/SourceCatalogPanel.tsx +++ b/frontend/src/components/datasets/SourceCatalogPanel.tsx @@ -12,6 +12,7 @@ const THEME_LABELS: Record = { nature_value: 'Natuurwaarde', agriculture: 'Landbouw', water: 'Water', + flood_hazard: 'Overstromingsgevaar', elevation: 'Hoogte en reliëf', roads: 'Wegen en transport', parcels: 'Percelen', @@ -66,6 +67,14 @@ const AVAILABLE_SOURCES = [ value: 'Hydrologische toestand; geen gebiedsdekkend watervolume zonder bodemprofiel', url: 'https://waterinfo.vlaanderen.be/', }, + { + key: 'flood_hazard', + name: 'Overstromingsgevaarkaarten waterdiepte', + owner: 'Vlaamse Milieumaatschappij', + coverage: 'Fluviaal/pluviaal, T10/T100/T1000, huidig klimaat en projectie 2050', + value: 'Gemodelleerd overstroomd oppervlak en maximale waterdiepte per vast scenario; geen bathymetrie', + url: 'https://www.vlaanderen.be/datavindplaats/catalogus/publieke-inspire-coverage-service-van-ogrk', + }, ] function datasetTheme(dataset: DatasetCreateResponse): string | null { @@ -113,6 +122,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E (dataset) => dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register', ) const dhmvDatasets = ready.filter((dataset) => dataset.source_name === 'digitaal_vlaanderen_dhmv') + const floodHazardDatasets = ready.filter((dataset) => dataset.source_name === 'vmm_flood_hazard') const latestBuildingsRegister = [...buildingsRegisterDatasets].sort( (left, right) => new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime(), )[0] @@ -125,6 +135,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E if (source.key === 'agriculture') return agricultureDatasets.length === 0 if (source.key === 'buildings_register') return buildingsRegisterDatasets.length === 0 if (source.key === 'elevation') return dhmvDatasets.length === 0 + if (source.key === 'flood_hazard') return floodHazardDatasets.length === 0 return true }) const themes = Object.keys(THEME_LABELS).map((theme) => { @@ -184,7 +195,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E ))} - {waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 || dhmvDatasets.length > 0 ? ( + {waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 || dhmvDatasets.length > 0 || floodHazardDatasets.length > 0 ? (
{waterinfoDatasets.length > 0 ? (
@@ -232,6 +243,13 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E

Hoogte in TAW, reliëf en helling uit opnameperiode 2013-2015. Geen waterdiepte of watervolume.

) : null} + {floodHazardDatasets.length > 0 ? ( +
+ VMM-overstromingsgevaarkaarten + {floodHazardDatasets.length} scenario's · fluviaal en pluviaal · huidig en 2050 +

Gemodelleerde maximumdiepte per kansscenario. Geen actuele waterstand, bathymetrie of permanent watervolume.

+
+ ) : null}
) : null} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index d9c7ed03..f2a5a61b 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -7,6 +7,7 @@ import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { TemporalTrendChart } from './TemporalTrendChart' import { terrainImageUrl } from '../../lib/terrainImage' +import { floodHazardImageUrl } from '../../lib/floodHazardImage' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' @@ -14,7 +15,7 @@ const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = [] const MOL_PROJECT_NAME = 'Mol Municipality Workbench' const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench' -type DataThemeId = 'buildings' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'water' | 'elevation' | 'roads' | 'parcels' +type DataThemeId = 'buildings' | 'population' | 'forest' | 'nature_value' | 'agriculture' | 'water' | 'flood_hazard' | 'elevation' | 'roads' | 'parcels' interface DataTheme { id: DataThemeId @@ -73,6 +74,13 @@ const DATA_THEMES: DataTheme[] = [ description: 'Waterlopen, grachten, kanalen en wateroppervlakken.', tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'], }, + { + id: 'flood_hazard', + label: 'Overstroming', + shortLabel: 'Overstroomd oppervlak', + description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.', + tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'], + }, { id: 'elevation', label: 'Hoogte & reliëf', @@ -103,6 +111,7 @@ const DATA_THEME_MAP_STYLES: Record nature_value: { fill: '#9a4f64', line: '#74364a' }, agriculture: { fill: '#7b8f32', line: '#53671d' }, water: { fill: '#2676a8', line: '#155b85' }, + flood_hazard: { fill: '#1597c2', line: '#075985' }, elevation: { fill: '#a57a4b', line: '#315f59' }, roads: { fill: '#6b7280', line: '#4b5563' }, parcels: { fill: '#a7792f', line: '#7d571f' }, @@ -113,6 +122,10 @@ function datasetAvailabilityLabel(dataset: DatasetCreateResponse): string { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid beschikbaar` } + if (dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard') { + const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) + return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} overstromingsscenario` + } return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar` } @@ -126,6 +139,7 @@ function datasetSearchText(dataset: DatasetCreateResponse): string { dataset.metadata_json?.['layer_name'], dataset.source_metadata?.['layer_name'], dataset.source_metadata?.['theme'], + dataset.source_metadata?.['product_display_name'], ] .filter(Boolean) .join(' ') @@ -162,7 +176,9 @@ function pickThemeDataset( (dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) + (dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) + (dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) + + (dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) + (dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) + + (dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) + (dataset.dataset_role === 'reference' ? 10_000 : 0) + (dataset.observed_at ? new Date(dataset.observed_at).getTime() / 100_000_000 : 0) + (dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0) @@ -220,6 +236,9 @@ function formatObservationDate(value: string | null | undefined): string { } function formatDatasetObservation(dataset: DatasetCreateResponse): string { + if (dataset.source_name === 'vmm_flood_hazard') { + return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}` + } const period = dataset.source_metadata?.['acquisition_period'] if (typeof period === 'string' && period.trim()) { return `opnameperiode ${period}` @@ -227,6 +246,11 @@ function formatDatasetObservation(dataset: DatasetCreateResponse): string { return formatObservationDate(dataset.observed_at) } +function floodScenarioLabel(dataset: DatasetCreateResponse): string { + const configured = dataset.source_metadata?.['product_display_name'] + return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset) +} + function operationalScopeProjectLabel(project: ProjectRead): string { if (project.name === MOL_PROJECT_NAME) { return 'Mol' @@ -643,6 +667,7 @@ export function MapWorkspace({ clearTemporalComparison, } = useTemporalComparison(selectedProjectId) const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current') + const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('') const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('') const [earlierDatasetId, setEarlierDatasetId] = useState('') const [laterDatasetId, setLaterDatasetId] = useState('') @@ -675,13 +700,22 @@ export function MapWorkspace({ const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson` const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL - const themeDatasetMap = useMemo( - () => - Object.fromEntries( - DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]), - ) as Record, + const floodHazardDatasets = useMemo( + () => availableMapDatasets + .filter((dataset) => dataset.source_name === 'vmm_flood_hazard' && datasetCoversSelectedArea(dataset, selectedMapAreaId)) + .sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')), [availableMapDatasets, selectedMapAreaId], ) + const themeDatasetMap = useMemo(() => { + const result = Object.fromEntries( + DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]), + ) as Record + const selectedFloodHazard = floodHazardDatasets.find((dataset) => dataset.id === selectedFloodHazardDatasetId) + if (selectedFloodHazard) { + result.flood_hazard = selectedFloodHazard + } + return result + }, [availableMapDatasets, floodHazardDatasets, selectedFloodHazardDatasetId, selectedMapAreaId]) const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id] const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection) @@ -706,7 +740,18 @@ export function MapWorkspace({ opacity: 0.82, } : null - const activeImageOverlay = terrainImageOverlay ?? orthophotoImageOverlay + const floodHazardBounds = activeThemeDataset?.source_name === 'vmm_flood_hazard' + ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] + : null + const floodHazardImageOverlay = activeTheme.id === 'flood_hazard' && activeThemeDataset && selectedProjectId && Array.isArray(floodHazardBounds) && floodHazardBounds.length === 4 + ? { + url: floodHazardImageUrl(selectedProjectId, activeThemeDataset.id), + bbox: floodHazardBounds.map(Number) as [number, number, number, number], + label: floodScenarioLabel(activeThemeDataset), + opacity: 0.82, + } + : null + const activeImageOverlay = floodHazardImageOverlay ?? terrainImageOverlay ?? orthophotoImageOverlay const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length @@ -730,7 +775,8 @@ export function MapWorkspace({ }), [themeInsights], ) - const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result ?? mapSelectionResult + const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result + ?? (selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) const selectedAreaSquareMetres = useMemo( () => bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2 @@ -788,6 +834,16 @@ export function MapWorkspace({ ) }, [activeTemporalSeriesGroups]) + useEffect(() => { + if (floodHazardDatasets.some((dataset) => dataset.id === selectedFloodHazardDatasetId)) { + return + } + const preferred = floodHazardDatasets.find( + (dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100', + ) ?? floodHazardDatasets[0] + setSelectedFloodHazardDatasetId(preferred?.id ?? '') + }, [floodHazardDatasets, selectedFloodHazardDatasetId]) + useEffect(() => { const first = activeTemporalSeries[0] const last = activeTemporalSeries[activeTemporalSeries.length - 1] @@ -1172,6 +1228,29 @@ export function MapWorkspace({ + {analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? ( + + ) : null} + {analysisMode === 'evolution' ? (
{activeTemporalSeriesGroups.length > 1 ? ( @@ -1559,7 +1638,7 @@ export function MapWorkspace({ {analysisMode === 'current' ? (
- +
) : null} diff --git a/frontend/src/hooks/useMapSelectionExtract.ts b/frontend/src/hooks/useMapSelectionExtract.ts index 56659f4b..e6930313 100644 --- a/frontend/src/hooks/useMapSelectionExtract.ts +++ b/frontend/src/hooks/useMapSelectionExtract.ts @@ -3,6 +3,7 @@ import { datasetsApi } from '../services/api' import { formatError } from '../lib/formatError' import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types' import { terrainSelectionToMapSelection } from '../lib/terrainSelection' +import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection' interface MapSelectionExtractOptions { selectedProjectId: string | null @@ -40,8 +41,9 @@ export function useMapSelectionExtract({ return null } const terrainDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'digitaal_vlaanderen_dhmv' - if (!isVectorDatasetType(selectedDataset.dataset_type) && !terrainDataset) { - setMapSelectionError('Gebiedsanalyse ondersteunt een vectorlaag of een beheerd DHMV-hoogtemodel.') + const floodHazardDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'vmm_flood_hazard' + if (!isVectorDatasetType(selectedDataset.dataset_type) && !terrainDataset && !floodHazardDataset) { + setMapSelectionError('Gebiedsanalyse ondersteunt een vectorlaag, DHMV-hoogtemodel of beheerd VMM-overstromingsscenario.') return null } @@ -56,6 +58,11 @@ export function useMapSelectionExtract({ bbox: { ...bbox, crs: 'EPSG:4326' }, area_id: areaId, })) + : floodHazardDataset + ? floodHazardSelectionToMapSelection(await datasetsApi.selectFloodHazard(selectedProjectId, selectedDataset.id, { + bbox: { ...bbox, crs: 'EPSG:4326' }, + area_id: areaId, + })) : await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, { bbox: { ...bbox, crs: 'EPSG:4326' }, area_id: areaId, diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts index 6e083e87..0a5cea43 100644 --- a/frontend/src/hooks/useMapThemeSelectionInsights.ts +++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts @@ -3,6 +3,7 @@ import { formatError } from '../lib/formatError' import { datasetsApi } from '../services/api/datasets' import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types' import { terrainSelectionToMapSelection } from '../lib/terrainSelection' +import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection' export interface MapThemeQuery { themeId: TThemeId @@ -60,6 +61,11 @@ export function useMapThemeSelectionInsights( bbox, area_id: areaId, })) + : dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard' + ? floodHazardSelectionToMapSelection(await datasetsApi.selectFloodHazard(selectedProjectId, dataset.id, { + bbox, + area_id: areaId, + })) : await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, { bbox, area_id: areaId, diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts index cb1cbe51..bb89ad63 100644 --- a/frontend/src/lib/datasetDisplay.ts +++ b/frontend/src/lib/datasetDisplay.ts @@ -9,6 +9,7 @@ const DATASET_LABEL_BY_LAYER: Record = { forest: 'Bos en groen', nature_value: 'Natuurwaarde', agriculture: 'Landbouwgebruikspercelen', + flood_hazard: 'Overstromingsgevaar', elevation: 'Hoogte en reliëf', building_registry: 'Gebouwenregister', regional_boundary: 'Grens vervoerregio Kempen', @@ -27,6 +28,7 @@ const DATASET_SOURCE_LABELS: Record = { statbel: 'Statbel', vrbg: 'Digitaal Vlaanderen', waterinfo: 'Waterinfo Vlaanderen', + vmm_flood_hazard: 'Vlaamse Milieumaatschappij', } export function getDatasetSourceDisplayName(dataset: DatasetCreateResponse): string { @@ -39,6 +41,10 @@ export function getDatasetDisplayName(dataset: DatasetCreateResponse): string { const productName = dataset.source_metadata?.['product_display_name'] return typeof productName === 'string' && productName.trim() ? productName : 'DHMV II hoogtemodel' } + if (dataset.source_name === 'vmm_flood_hazard') { + const productName = dataset.source_metadata?.['product_display_name'] + return typeof productName === 'string' && productName.trim() ? productName : 'VMM-overstromingsscenario' + } const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '') .toString() .toLowerCase() diff --git a/frontend/src/lib/floodHazardImage.ts b/frontend/src/lib/floodHazardImage.ts new file mode 100644 index 00000000..40540d5b --- /dev/null +++ b/frontend/src/lib/floodHazardImage.ts @@ -0,0 +1,3 @@ +export function floodHazardImageUrl(projectId: string, datasetId: string): string { + return `/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/image` +} diff --git a/frontend/src/lib/floodHazardSelection.ts b/frontend/src/lib/floodHazardSelection.ts new file mode 100644 index 00000000..567c2b56 --- /dev/null +++ b/frontend/src/lib/floodHazardSelection.ts @@ -0,0 +1,20 @@ +import type { FloodHazardSelectionResponse, VectorSelectionResponse } from '../types' + +export function floodHazardSelectionToMapSelection(result: FloodHazardSelectionResponse): VectorSelectionResponse { + return { + selection_bbox: result.selection_bbox, + selection_area_id: result.selection_area_id, + feature_count: result.inundated_cell_count, + total_feature_count: result.inundated_cell_count, + limit: 0, + truncated: false, + geojson: { type: 'FeatureCollection', features: [] }, + summary: { + ...result.summary, + feature_count: result.inundated_cell_count, + is_estimate: false, + warning: result.limitation_message, + metrics: result.summary.metrics.map((metric) => ({ ...metric, is_estimate: false })), + }, + } +} diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts index fb1c0cab..da030fba 100644 --- a/frontend/src/services/api/datasets.ts +++ b/frontend/src/services/api/datasets.ts @@ -19,6 +19,9 @@ import type { OrthophotoAcquireRequest, OrthophotoProductRead, DhmvAcquireRequest, + FloodHazardAcquireRequest, + FloodHazardProductRead, + FloodHazardSelectionResponse, DhmvProductRead, TerrainSelectionResponse, } from '../../types' @@ -128,6 +131,16 @@ export const datasetsApi = { payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string }, ): Promise => apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/select`, payload), + acquireFloodHazard: (projectId: string, payload: FloodHazardAcquireRequest): Promise => + apiPost(`/api/v1/projects/${projectId}/datasets/flood-hazard/acquire`, payload), + listFloodHazardProducts: (projectId: string): Promise<{ items: FloodHazardProductRead[]; total: number }> => + apiGet<{ items: FloodHazardProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/flood-hazard/products`), + selectFloodHazard: ( + projectId: string, + datasetId: string, + payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string }, + ): Promise => + apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/select`, payload), refreshMetadata: (projectId: string, datasetId: string): Promise => apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}), inspectRaster: (projectId: string, datasetId: string): Promise => diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index efe1d0f7..0723e6c4 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -5756,6 +5756,7 @@ section { .geo-theme-symbol-nature_value { background: #9a4f64; } .geo-theme-symbol-agriculture { background: #7b8f32; } .geo-theme-symbol-water { background: #2676a8; } +.geo-theme-symbol-flood_hazard { background: #1597c2; } .geo-theme-symbol-elevation { background: #a57a4b; } .geo-theme-symbol-roads { background: #6b7280; } .geo-theme-symbol-parcels { background: #a7792f; } @@ -5941,6 +5942,11 @@ section { background: rgba(38, 118, 168, 0.24); } +.geo-map-legend .geo-legend-layer-flood_hazard { + border-color: #075985; + background: rgba(21, 151, 194, 0.28); +} + .geo-map-legend .geo-legend-layer-elevation { border-color: #315f59; background: rgba(165, 122, 75, 0.26); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 536ce974..71536518 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -382,6 +382,58 @@ export interface TerrainSelectionResponse { generated_at: string } +export interface FloodHazardAcquireRequest { + bbox: VectorSelectionBBox + area_id?: string | null + product_key?: string + resolution_m?: number | null + force_refresh?: boolean +} + +export interface FloodHazardProductRead { + key: string + display_name: string + mechanism: 'pluviaal' | 'fluviaal' + climate_context: string + probability_class: string + return_period_years: number + coverage_id: string + native_resolution_m: number + source_crs: string + source_value_unit: 'cm' + normalized_value_unit: 'm' + published_on: string + catalog_url: string + attribution: string + limitation_message: string +} + +export interface FloodHazardSelectionResponse { + dataset_id: string + product_key: string + mechanism: 'pluviaal' | 'fluviaal' + climate_context: string + probability_class: string + return_period_years: number + selection_bbox: VectorSelectionBBox + selection_area_id?: string | null + selected_cell_count: number + inundated_cell_count: number + inundated_fraction: number + resolution_m: number + summary: { + metric_label: string + metric_value: number + metric_unit: string + aggregation_method: string + primary_metric_key: string + metrics: VectorSelectionMetric[] + } + unsupported_metrics: string[] + limitation_message: string + generated_at: string +} + export interface MapImageOverlay { url: string bbox: [number, number, number, number] diff --git a/scripts/README.md b/scripts/README.md index 3abcb71c..3a6c0288 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1539,6 +1539,29 @@ docker exec geointel python /app/scripts/provision_mol_dhmv.py --resolution-m 5 Do not use DHMV output as water depth or water volume. The command fails when the API no longer reports those metrics as explicitly unsupported. +## Mol VMM flood-hazard scenarios + +Acquire and validate all twelve official VMM fluvial/pluvial flood-depth +scenarios for the exact persisted Mol Area: + +```bash +docker exec geointel python /app/scripts/provision_mol_flood_hazards.py +``` + +The operator resolves the project and Area through canonical APIs, validates +the backend registry and acquires every current/2050 T10/T100/T1000 coverage. +The backend performs bounded WCS 1.1 retrieval, tiled mosaicking, exact Area +clipping and centimetre-to-metre normalization. The operator then runs the +full-Area selection smoke and rejects any response that stops declaring +bathymetry, permanent water volume and concurrent flood volume unsupported. + +Useful safe overrides: + +```bash +docker exec geointel python /app/scripts/provision_mol_flood_hazards.py --products pluviaal_current_t100 +docker exec geointel python /app/scripts/provision_mol_flood_hazards.py --resolution-m 5 --force +``` + ## Tower deployment Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime: diff --git a/scripts/provision_mol_flood_hazards.py b/scripts/provision_mol_flood_hazards.py new file mode 100644 index 00000000..a1b90f66 --- /dev/null +++ b/scripts/provision_mol_flood_hazards.py @@ -0,0 +1,158 @@ +"""Provision governed VMM flood-depth scenarios for the persisted Mol Area. + +All data flows through the canonical API, Job abstraction and DatasetService. +The operator never writes raster files or database rows directly. +""" + +from __future__ import annotations + +import argparse +import json +import os +from typing import Any, Iterable + +import requests + + +DEFAULT_API_URL = "http://127.0.0.1:8000" +DEFAULT_PROJECT_NAME = "Kempen Regional Workbench" +DEFAULT_AREA_FRAGMENT = "Gemeente Mol" +PRODUCTS = tuple( + f"{mechanism}_{climate}_t{period}" + for mechanism in ("pluviaal", "fluviaal") + for climate in ("current", "future_2050") + for period in (10, 100, 1000) +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Provision official VMM flood-depth scenarios for Mol.") + parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL)) + parser.add_argument("--project-name", default=DEFAULT_PROJECT_NAME) + parser.add_argument("--area-name", default=DEFAULT_AREA_FRAGMENT) + parser.add_argument("--products", default=",".join(PRODUCTS), help="Comma-separated governed product keys.") + parser.add_argument("--resolution-m", type=float, default=5.0) + parser.add_argument("--timeout", type=int, default=1800) + parser.add_argument("--force", action="store_true") + return parser.parse_args() + + +def unwrap(response: requests.Response) -> Any: + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict) or "data" not in payload: + raise RuntimeError(f"Non-canonical API response from {response.url}") + return payload["data"] + + +def coordinates(geometry: dict[str, Any]) -> Iterable[tuple[float, float]]: + def walk(value: Any): + if isinstance(value, list) and len(value) >= 2 and all(isinstance(item, (int, float)) for item in value[:2]): + yield float(value[0]), float(value[1]) + return + if isinstance(value, list): + for child in value: + yield from walk(child) + + yield from walk(geometry.get("coordinates", [])) + + +def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float | str]: + points = list(coordinates(geometry)) + if not points: + raise RuntimeError("Persisted Area geometry contains no coordinates") + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return {"min_x": min(xs), "min_y": min(ys), "max_x": max(xs), "max_y": max(ys), "crs": "EPSG:4326"} + + +def main() -> int: + args = parse_args() + base_url = args.base_url.rstrip("/") + session = requests.Session() + session.headers.update({"User-Agent": "GeoIntel-VMM-Flood-Hazard-Operator/1.0"}) + + projects = unwrap(session.get(f"{base_url}/api/v1/projects", params={"limit": 200, "offset": 0}, timeout=60))["items"] + project = next((item for item in projects if item["name"] == args.project_name), None) + if project is None: + raise RuntimeError(f"Project {args.project_name!r} was not found") + areas = unwrap( + session.get( + f"{base_url}/api/v1/projects/{project['id']}/areas", + params={"limit": 200, "offset": 0}, + timeout=60, + ) + )["items"] + fragment = args.area_name.casefold() + area = next((item for item in areas if fragment in item["name"].casefold()), None) + if area is None: + raise RuntimeError(f"Area containing {args.area_name!r} was not found") + bbox = geometry_bbox(area["geometry"]) + + registry = unwrap(session.get(f"{base_url}/api/v1/projects/{project['id']}/datasets/flood-hazard/products", timeout=60))["items"] + registry_keys = {item["key"] for item in registry} + if registry_keys != set(PRODUCTS): + raise RuntimeError("Backend flood-hazard registry does not expose the governed twelve-product set") + requested_products = [item.strip() for item in args.products.split(",") if item.strip()] + invalid = sorted(set(requested_products) - registry_keys) + if invalid: + raise RuntimeError(f"Unsupported flood-hazard product keys: {', '.join(invalid)}") + + results = [] + for product_key in requested_products: + job = unwrap( + session.post( + f"{base_url}/api/v1/projects/{project['id']}/datasets/flood-hazard/acquire", + json={ + "bbox": bbox, + "area_id": area["id"], + "product_key": product_key, + "resolution_m": args.resolution_m, + "force_refresh": args.force, + }, + timeout=args.timeout, + ) + ) + if job.get("status") != "success" or not job.get("output_dataset_id"): + raise RuntimeError(f"Flood-hazard acquisition failed for {product_key}: {job.get('error_message') or job}") + analysis = unwrap( + session.post( + f"{base_url}/api/v1/projects/{project['id']}/datasets/{job['output_dataset_id']}/raster/flood-hazard/select", + json={"bbox": bbox, "area_id": area["id"]}, + timeout=args.timeout, + ) + ) + unsupported = set(analysis.get("unsupported_metrics", [])) + if {"bathymetry_depth_m", "permanent_water_volume_m3", "concurrent_flood_volume_m3"} - unsupported: + raise RuntimeError("Flood-hazard contract must keep bathymetry and definitive water volumes unavailable") + results.append( + { + "product_key": product_key, + "dataset_id": job["output_dataset_id"], + "reused": bool((job.get("result_json") or {}).get("reused")), + "resolution_m": analysis["resolution_m"], + "selected_cell_count": analysis["selected_cell_count"], + "inundated_cell_count": analysis["inundated_cell_count"], + "metrics": analysis["summary"]["metrics"], + } + ) + + print( + json.dumps( + { + "status": "ok", + "project_id": project["id"], + "area_id": area["id"], + "area_name": area["name"], + "bbox": bbox, + "products": results, + }, + ensure_ascii=False, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 1a0c1f61..596c3c4a 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -52,6 +52,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_mol_bwk_natura2000.py ${PYTHON_BIN} -m py_compile scripts/provision_agricultural_parcel_history.py ${PYTHON_BIN} -m py_compile scripts/provision_buildings_addresses_register.py ${PYTHON_BIN} -m py_compile scripts/provision_mol_dhmv.py +${PYTHON_BIN} -m py_compile scripts/provision_mol_flood_hazards.py ${PYTHON_BIN} -m py_compile scripts/provision_regional_timeseries.py ${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py ${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py