Add governed VMM flood hazard scenarios
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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=(",", ":"))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<Config Name="DHMV Analysis Resolution (m)" Target="DHMV_RESOLUTION_M" Default="5.0" Mode="" Description="Stored analysis grid resolution. Native source resolution remains recorded as 1 metre." Type="Variable" Display="advanced" Required="true" Mask="false">5.0</Config>
|
||||
<Config Name="DHMV Maximum Side (m)" Target="DHMV_MAX_SIDE_M" Default="20000" Mode="" Description="Maximum bounded terrain request side length." Type="Variable" Display="advanced" Required="true" Mask="false">20000</Config>
|
||||
<Config Name="DHMV Maximum Cells" Target="DHMV_MAX_PIXELS" Default="12000000" Mode="" Description="Maximum raster cells per acquisition or selection analysis." Type="Variable" Display="advanced" Required="true" Mask="false">12000000</Config>
|
||||
<Config Name="Official VMM Flood Hazard Acquisition" Target="FLOOD_HAZARD_ENABLED" Default="true" Mode="" Description="Allow bounded official VMM flood-depth scenario acquisition." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
||||
<Config Name="VMM Flood Hazard WCS URL" Target="FLOOD_HAZARD_WCS_URL" Default="https://geoservice.waterinfo.be/OGRK/wcs" Mode="" Description="Official VMM OGRK WCS endpoint for governed flood-depth scenarios." Type="Variable" Display="advanced" Required="true" Mask="false">https://geoservice.waterinfo.be/OGRK/wcs</Config>
|
||||
<Config Name="Flood Hazard Analysis Resolution (m)" Target="FLOOD_HAZARD_RESOLUTION_M" Default="5.0" Mode="" Description="Stored analysis grid resolution; official source values are converted from centimetres to metres." Type="Variable" Display="advanced" Required="true" Mask="false">5.0</Config>
|
||||
<Config Name="Flood Hazard Maximum Cells" Target="FLOOD_HAZARD_MAX_PIXELS" Default="12000000" Mode="" Description="Maximum raster cells per flood-hazard acquisition or selection analysis." Type="Variable" Display="advanced" Required="true" Mask="false">12000000</Config>
|
||||
<Config Name="Local Ollama Assistant" Target="OLLAMA_ENABLED" Default="true" Mode="" Description="Enable the source-grounded GeoIntel assistant backed by Ollama on the Unraid host." Type="Variable" Display="always" Required="true" Mask="false">true</Config>
|
||||
<Config Name="Ollama Base URL" Target="OLLAMA_BASE_URL" Default="http://host.docker.internal:11434" Mode="" Description="Ollama API reachable from the container. The deployment maps host.docker.internal to the Unraid host gateway." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:11434</Config>
|
||||
<Config Name="Default Ollama Model" Target="OLLAMA_DEFAULT_MODEL" Default="qwen3.5:9b" Mode="" Description="Preferred locally installed Ollama model. Users can select another installed model in GeoIntel." Type="Variable" Display="always" Required="true" Mask="false">qwen3.5:9b</Config>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ const THEME_LABELS: Record<string, string> = {
|
||||
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
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<div className="source-catalog-loaded" aria-label="Aanvullende ingeladen bronnen">
|
||||
{waterinfoDatasets.length > 0 ? (
|
||||
<article>
|
||||
@@ -232,6 +243,13 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
|
||||
<p>Hoogte in TAW, reliëf en helling uit opnameperiode 2013-2015. Geen waterdiepte of watervolume.</p>
|
||||
</article>
|
||||
) : null}
|
||||
{floodHazardDatasets.length > 0 ? (
|
||||
<article>
|
||||
<strong>VMM-overstromingsgevaarkaarten</strong>
|
||||
<span>{floodHazardDatasets.length} scenario's · fluviaal en pluviaal · huidig en 2050</span>
|
||||
<p>Gemodelleerde maximumdiepte per kansscenario. Geen actuele waterstand, bathymetrie of permanent watervolume.</p>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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<DataThemeId, { fill: string; line: string }>
|
||||
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<DataThemeId, DatasetCreateResponse | null>,
|
||||
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<DataThemeId, DatasetCreateResponse | null>
|
||||
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({
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? (
|
||||
<label className="geo-scope-select">
|
||||
Overstromingsscenario
|
||||
<select
|
||||
aria-label="Overstromingsscenario"
|
||||
value={activeThemeDataset?.id ?? ''}
|
||||
onChange={(event) => {
|
||||
const dataset = floodHazardDatasets.find((item) => item.id === event.target.value)
|
||||
setSelectedFloodHazardDatasetId(event.target.value)
|
||||
clearThemeInsights()
|
||||
if (dataset) {
|
||||
onOpenDatasetInMap(dataset)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{floodHazardDatasets.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>{floodScenarioLabel(dataset)}</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Elke meting blijft gekoppeld aan deze kans en klimaatprojectie.</small>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{analysisMode === 'evolution' ? (
|
||||
<div className="geo-time-controls" aria-label="Meetmomenten vergelijken">
|
||||
{activeTemporalSeriesGroups.length > 1 ? (
|
||||
@@ -1559,7 +1638,7 @@ export function MapWorkspace({
|
||||
|
||||
{analysisMode === 'current' ? (
|
||||
<div className="geo-result-actions">
|
||||
<button className="secondary-action" disabled={!activeSelectionResult || activeTheme.id === 'elevation'} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button>
|
||||
<button className="secondary-action" disabled={!activeSelectionResult || activeTheme.id === 'elevation' || activeTheme.id === 'flood_hazard'} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button>
|
||||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeSelection}>Kopieer gegevens</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TThemeId extends string> {
|
||||
themeId: TThemeId
|
||||
@@ -60,6 +61,11 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
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,
|
||||
|
||||
@@ -9,6 +9,7 @@ const DATASET_LABEL_BY_LAYER: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function floodHazardImageUrl(projectId: string, datasetId: string): string {
|
||||
return `/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/image`
|
||||
}
|
||||
@@ -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 })),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<TerrainSelectionResponse> =>
|
||||
apiPost<TerrainSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/select`, payload),
|
||||
acquireFloodHazard: (projectId: string, payload: FloodHazardAcquireRequest): Promise<JobRead> =>
|
||||
apiPost<JobRead>(`/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<FloodHazardSelectionResponse> =>
|
||||
apiPost<FloodHazardSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/select`, payload),
|
||||
refreshMetadata: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
|
||||
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
|
||||
inspectRaster: (projectId: string, datasetId: string): Promise<RasterInspectResponse> =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user