diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd173e1..74cfe4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ # Changelog +## Sprint 218 Regional terrain and flood completion (2026-07-16) + +- Provisioned and audited the complete governed Kempen matrices: 56 DHMV + DTM/DSM rasters and 336 VMM flood-scenario rasters across all 28 approved + municipalities, each with one DatasetVersion and a non-empty checksum-bound + GeoTIFF. +- Bounded official WCS edge-grid rounding to at most 5%/0.25 m for DHMV and + VMM tile assembly, resampled accepted edge tiles to the exact 5 m analysis + grid and retained every source resolution and harmonized tile index in + provenance. Larger mismatches still fail closed. +- Added exact regional raster-selection endpoints. They open only intersecting + persisted municipality partitions, mosaic the selected windows in memory + and calculate global cell statistics under the existing 12-million-cell + guard; no monolithic or hidden authoritative raster is created. +- Updated the Map workspace to expose DHMV and VMM on the complete Kempen Area, + render all 28 matching MapLibre partitions, deduplicate VMM into twelve + scenario choices and analyse a drawn cross-boundary rectangle without first + selecting a municipality. +- Preserved the distinction between DHMV height, modeled VMM scenario depth, + permanent water, bathymetry and concurrent flood volume. + ## Sprint 217 Regional DOV soil coverage (2026-07-16) - Added a governed regional DOV soil operator for all 28 approved Kempen diff --git a/backend/README.md b/backend/README.md index 7a0e1961..7d32328c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1246,6 +1246,12 @@ and a complete failure summary. Persistence remains inside the canonical DHMV acquisition service and Dataset/DatasetVersion/Job flow; the operator does not fetch WCS bytes or write raster metadata directly. +The complete live matrix contains 56 ready Datasets and 56 DatasetVersions +across 28 Areas. On the complete Kempen Area the Map workspace presents those +partitions as one logical DTM/DSM layer. `POST .../datasets/raster/terrain/select` +opens only partitions intersecting the drawn rectangle and computes exact +global cell statistics. It does not create a hidden regional mosaic. + 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`. @@ -1295,6 +1301,12 @@ The full Kempen scope with all products means 28 municipalities times 12 scenario rasters. This is intentionally explicit operator work, not startup work and not a browser-side provider fetch. +The complete live matrix contains 336 ready Datasets and 336 DatasetVersions. +The regional Map workspace deduplicates them into twelve scenario choices, +renders all municipality image partitions for the selected scenario and uses +`POST .../datasets/raster/flood-hazard/select` for exact bounded cross-boundary +analysis. The same 12-million-cell guard prevents unsafe full-region reads. + 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. diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index 830c53f9..620a976f 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -4,7 +4,6 @@ import json from datetime import datetime from typing import Any from uuid import UUID -from uuid import UUID as _UUID from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response from fastapi import UploadFile @@ -23,8 +22,10 @@ from app.schemas import ( RasterNdbiRequest, OrthophotoAcquireRequest, DhmvAcquireRequest, + TerrainPartitionSelectionRequest, TerrainSelectionRequest, FloodHazardAcquireRequest, + FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest, ThematicRasterAcquireRequest, ThematicRasterSelectionRequest, @@ -32,14 +33,12 @@ from app.schemas import ( VectorBufferRequest, VectorClipRequest, VectorIntersectRequest, - VectorSelectionBBox, + VectorSelectionBBox, # noqa: F401 - retained as a route-module compatibility export VectorSelectionDeriveRequest, VectorSelectionRequest, VectorSelectionResponse, ) -from app.schemas.job import JobCreate from app.schemas.dataset import DatasetCreateResponse, DatasetTemporalUpdate -from app.schemas.operations import VectorOperationResult from app.services.job_service import JobService from app.services.raster_operations_service import RasterOperationsService from app.services.vector_operations_service import VectorOperationsService @@ -550,6 +549,15 @@ def raster_terrain_selection( return envelope(TerrainAnalysisService.analyze(db, project_id, dataset_id, payload)) +@router.post("/datasets/raster/terrain/select", response_model=dict) +def partitioned_raster_terrain_selection( + project_id: UUID, + payload: TerrainPartitionSelectionRequest, + db: Session = Depends(get_db), +): + return envelope(TerrainAnalysisService.analyze_partitions(db, project_id, payload)) + + @router.get("/datasets/{dataset_id}/raster/terrain/image") def raster_terrain_image( project_id: UUID, @@ -574,6 +582,15 @@ def raster_flood_hazard_selection( return envelope(FloodHazardAnalysisService.analyze(db, project_id, dataset_id, payload)) +@router.post("/datasets/raster/flood-hazard/select", response_model=dict) +def partitioned_raster_flood_hazard_selection( + project_id: UUID, + payload: FloodHazardPartitionSelectionRequest, + db: Session = Depends(get_db), +): + return envelope(FloodHazardAnalysisService.analyze_partitions(db, project_id, payload)) + + @router.get("/datasets/{dataset_id}/raster/flood-hazard/image") def raster_flood_hazard_image( project_id: UUID, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 043faafe..ef470b55 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -38,6 +38,7 @@ from .dhmv import ( DhmvAcquisitionResult, DhmvProductRead, TerrainMetric, + TerrainPartitionSelectionRequest, TerrainSelectionRequest, TerrainSelectionResponse, TerrainSelectionSummary, @@ -46,6 +47,7 @@ from .flood_hazard import ( FloodHazardAcquireRequest, FloodHazardAcquisitionResult, FloodHazardMetric, + FloodHazardPartitionSelectionRequest, FloodHazardProductRead, FloodHazardSelectionRequest, FloodHazardSelectionResponse, @@ -166,12 +168,14 @@ __all__ = [ "DhmvAcquisitionResult", "DhmvProductRead", "TerrainMetric", + "TerrainPartitionSelectionRequest", "TerrainSelectionRequest", "TerrainSelectionResponse", "TerrainSelectionSummary", "FloodHazardAcquireRequest", "FloodHazardAcquisitionResult", "FloodHazardMetric", + "FloodHazardPartitionSelectionRequest", "FloodHazardProductRead", "FloodHazardSelectionRequest", "FloodHazardSelectionResponse", diff --git a/backend/app/schemas/dhmv.py b/backend/app/schemas/dhmv.py index fb96f35b..d8a6690b 100644 --- a/backend/app/schemas/dhmv.py +++ b/backend/app/schemas/dhmv.py @@ -56,6 +56,10 @@ class TerrainSelectionRequest(BaseModel): area_id: UUID | None = None +class TerrainPartitionSelectionRequest(TerrainSelectionRequest): + product_key: str = "dtm_1m" + + class TerrainMetric(BaseModel): metric_key: str metric_label: str @@ -76,6 +80,8 @@ class TerrainSelectionSummary(BaseModel): class TerrainSelectionResponse(BaseModel): dataset_id: UUID + dataset_ids: list[UUID] = Field(default_factory=list) + partition_count: int = Field(default=1, ge=1) product_key: str surface_model: str selection_bbox: VectorSelectionBBox diff --git a/backend/app/schemas/flood_hazard.py b/backend/app/schemas/flood_hazard.py index c380d623..ce02ff0f 100644 --- a/backend/app/schemas/flood_hazard.py +++ b/backend/app/schemas/flood_hazard.py @@ -59,6 +59,10 @@ class FloodHazardSelectionRequest(BaseModel): area_id: UUID | None = None +class FloodHazardPartitionSelectionRequest(FloodHazardSelectionRequest): + product_key: str = "pluviaal_current_t100" + + class FloodHazardMetric(BaseModel): metric_key: str metric_label: str @@ -79,6 +83,8 @@ class FloodHazardSelectionSummary(BaseModel): class FloodHazardSelectionResponse(BaseModel): dataset_id: UUID + dataset_ids: list[UUID] = Field(default_factory=list) + partition_count: int = Field(default=1, ge=1) product_key: str mechanism: str climate_context: str diff --git a/backend/app/services/flood_hazard_analysis_service.py b/backend/app/services/flood_hazard_analysis_service.py index 2907eb53..52ca1f4b 100644 --- a/backend/app/services/flood_hazard_analysis_service.py +++ b/backend/app/services/flood_hazard_analysis_service.py @@ -16,11 +16,13 @@ from app.core.errors import AppError from app.models import Area, Dataset from app.schemas.flood_hazard import ( FloodHazardMetric, + FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest, FloodHazardSelectionResponse, FloodHazardSelectionSummary, ) from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService +from app.services.raster_partition_analysis_service import RasterPartitionAnalysisService class FloodHazardAnalysisService: @@ -170,6 +172,8 @@ class FloodHazardAnalysisService: primary = metrics[0] response = FloodHazardSelectionResponse( dataset_id=dataset.id, + dataset_ids=[dataset.id], + partition_count=1, product_key=product.key, mechanism=product.mechanism, climate_context=product.climate_context, @@ -195,6 +199,129 @@ class FloodHazardAnalysisService: ) return response.model_dump(mode="json") + @staticmethod + def analyze_partitions( + db, + project_id: UUID, + payload: FloodHazardPartitionSelectionRequest, + *, + settings: Settings | None = None, + ) -> dict: + resolved_settings = settings or get_settings() + product = FloodHazardAcquisitionService._products().get(payload.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": payload.product_key}, + status_code=422, + ) + selection_4326 = FloodHazardAnalysisService._selection_geometry(db, project_id, payload) + partition = RasterPartitionAnalysisService.select( + db, + project_id, + source_name=FloodHazardAcquisitionService.PROVIDER, + product_key=product.key, + selection_geometry_4326=selection_4326, + nodata=FloodHazardAcquisitionService.NODATA, + max_pixels=resolved_settings.flood_hazard_max_pixels, + ) + try: + import numpy as np + except ImportError as exc: + raise AppError( + code="RASTER_PROCESSING_UNAVAILABLE", + message="Numpy is required for partitioned flood-hazard analysis", + status_code=503, + ) from exc + + raw = partition.values + valid = ( + partition.selected_cells + & np.isfinite(raw) + & (raw != FloodHazardAcquisitionService.NODATA) + & (raw > 0.0) + ) + values = raw[valid] + selected_cell_count = int(partition.selected_cells.sum()) + inundated_cell_count = int(values.size) + cell_area_m2 = partition.resolution_x * partition.resolution_y + + 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] + first_dataset = partition.datasets[0] + response = FloodHazardSelectionResponse( + dataset_id=first_dataset.id, + dataset_ids=[dataset.id for dataset in partition.datasets], + partition_count=len(partition.datasets), + 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(partition.resolution_x, partition.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=( + f"{FloodHazardAnalysisService.LIMITATION} De selectie werd exact berekend over " + f"{len(partition.datasets)} persistente gemeentelijke rasterpartities." + ), + 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) diff --git a/backend/app/services/raster_partition_analysis_service.py b/backend/app/services/raster_partition_analysis_service.py new file mode 100644 index 00000000..cf048aed --- /dev/null +++ b/backend/app/services/raster_partition_analysis_service.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import math +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import UUID + +from pyproj import Transformer +from shapely.geometry import mapping +from shapely.ops import transform as shapely_transform + +from app.core.errors import AppError +from app.models import Dataset + + +@dataclass(frozen=True) +class RasterPartitionSelection: + datasets: list[Dataset] + values: Any + selected_cells: Any + resolution_x: float + resolution_y: float + + +class RasterPartitionAnalysisService: + MAX_PARTITIONS = 64 + + @staticmethod + def _bbox_intersects(dataset: Dataset, bbox: tuple[float, float, float, float]) -> bool: + source_bbox = (dataset.source_metadata or {}).get("bbox_epsg4326") + if not isinstance(source_bbox, list) or len(source_bbox) != 4: + return True + try: + min_x, min_y, max_x, max_y = (float(value) for value in source_bbox) + except (TypeError, ValueError): + return True + return not ( + max_x <= bbox[0] + or min_x >= bbox[2] + or max_y <= bbox[1] + or min_y >= bbox[3] + ) + + @staticmethod + def _candidate_datasets( + db, + project_id: UUID, + *, + source_name: str, + product_key: str, + bbox: tuple[float, float, float, float], + ) -> list[Dataset]: + rows = ( + db.query(Dataset) + .filter( + Dataset.project_id == project_id, + Dataset.source_name == source_name, + Dataset.dataset_type == "raster", + Dataset.status == "ready", + ) + .all() + ) + candidates = [ + dataset + for dataset in rows + if str((dataset.source_metadata or {}).get("product_key") or "") == product_key + and dataset.storage_path + and Path(dataset.storage_path).is_file() + and RasterPartitionAnalysisService._bbox_intersects(dataset, bbox) + ] + candidates.sort(key=lambda dataset: (str(dataset.area_id or ""), str(dataset.id))) + if not candidates: + raise AppError( + code="RASTER_PARTITIONS_NOT_FOUND", + message="No persisted raster partitions cover this selection", + details={"source_name": source_name, "product_key": product_key}, + status_code=404, + ) + if len(candidates) > RasterPartitionAnalysisService.MAX_PARTITIONS: + raise AppError( + code="RASTER_PARTITION_LIMIT_EXCEEDED", + message="The selection intersects too many raster partitions", + details={ + "partition_count": len(candidates), + "max_partitions": RasterPartitionAnalysisService.MAX_PARTITIONS, + }, + status_code=422, + ) + return candidates + + @staticmethod + def select( + db, + project_id: UUID, + *, + source_name: str, + product_key: str, + selection_geometry_4326, + nodata: float, + max_pixels: int, + ) -> RasterPartitionSelection: + try: + import numpy as np + import rasterio + from rasterio.features import geometry_mask + from rasterio.merge import merge + except ImportError as exc: + raise AppError( + code="RASTER_PROCESSING_UNAVAILABLE", + message="Rasterio and numpy are required for partitioned raster analysis", + status_code=503, + ) from exc + + bbox = tuple(float(value) for value in selection_geometry_4326.bounds) + datasets = RasterPartitionAnalysisService._candidate_datasets( + db, + project_id, + source_name=source_name, + product_key=product_key, + bbox=bbox, + ) + transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) + selection_metric = shapely_transform(transformer.transform, selection_geometry_4326) + min_x, min_y, max_x, max_y = selection_metric.bounds + + try: + with ExitStack() as stack: + sources = [stack.enter_context(rasterio.open(dataset.storage_path)) for dataset in datasets] + invalid_sources = [ + index + for index, source in enumerate(sources) + if source.crs is None or source.crs.to_epsg() != 31370 or source.count != 1 + ] + if invalid_sources: + raise AppError( + code="RASTER_PARTITION_MISMATCH", + message="Raster partitions do not share the governed CRS and band layout", + details={"invalid_partition_indexes": invalid_sources}, + status_code=409, + ) + target_resolution = max(abs(float(sources[0].res[0])), abs(float(sources[0].res[1]))) + invalid_resolutions = [ + { + "partition_index": index, + "resolution": [abs(float(source.res[0])), abs(float(source.res[1]))], + } + for index, source in enumerate(sources) + if not all( + math.isclose(abs(float(value)), target_resolution, rel_tol=0.001, abs_tol=0.01) + for value in source.res + ) + ] + if invalid_resolutions: + raise AppError( + code="RASTER_PARTITION_MISMATCH", + message="Raster partitions do not share one analysis resolution", + details={"invalid_resolutions": invalid_resolutions}, + status_code=409, + ) + width = max(1, math.ceil((max_x - min_x) / target_resolution)) + height = max(1, math.ceil((max_y - min_y) / target_resolution)) + if width * height > max_pixels: + raise AppError( + code="RASTER_PARTITION_SELECTION_TOO_LARGE", + message="Select a smaller rectangle for regional raster analysis", + details={"pixel_count": width * height, "max_pixels": max_pixels}, + status_code=422, + ) + mosaic, transform = merge( + sources, + bounds=(min_x, min_y, max_x, max_y), + res=(target_resolution, target_resolution), + nodata=nodata, + dtype="float32", + ) + values = np.asarray(mosaic[0], dtype="float64") + selected_cells = geometry_mask( + [mapping(selection_metric)], + out_shape=values.shape, + transform=transform, + invert=True, + ) + return RasterPartitionSelection( + datasets=datasets, + values=values, + selected_cells=selected_cells, + resolution_x=target_resolution, + resolution_y=target_resolution, + ) + except AppError: + raise + except Exception as exc: + raise AppError( + code="RASTER_PARTITION_ANALYSIS_FAILED", + message="Persisted raster partitions could not be assembled for this selection", + details={"reason": str(exc)}, + status_code=500, + ) from exc diff --git a/backend/app/services/terrain_analysis_service.py b/backend/app/services/terrain_analysis_service.py index a756d585..21aecea2 100644 --- a/backend/app/services/terrain_analysis_service.py +++ b/backend/app/services/terrain_analysis_service.py @@ -14,8 +14,15 @@ 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.dhmv import TerrainMetric, TerrainSelectionRequest, TerrainSelectionResponse, TerrainSelectionSummary +from app.schemas.dhmv import ( + TerrainMetric, + TerrainPartitionSelectionRequest, + TerrainSelectionRequest, + TerrainSelectionResponse, + TerrainSelectionSummary, +) from app.services.dhmv_acquisition_service import DhmvAcquisitionService +from app.services.raster_partition_analysis_service import RasterPartitionAnalysisService class TerrainAnalysisService: @@ -177,6 +184,8 @@ class TerrainAnalysisService: selected_cell_count = int(selected_cells.sum()) response = TerrainSelectionResponse( dataset_id=dataset.id, + dataset_ids=[dataset.id], + partition_count=1, product_key=product_key, surface_model=surface_model, selection_bbox=payload.bbox, @@ -200,6 +209,139 @@ class TerrainAnalysisService: ) return response.model_dump(mode="json") + @staticmethod + def analyze_partitions( + db, + project_id: UUID, + payload: TerrainPartitionSelectionRequest, + *, + settings: Settings | None = None, + ) -> dict: + resolved_settings = settings or get_settings() + product = DhmvAcquisitionService._products().get(payload.product_key.strip().lower()) + if product is None: + raise AppError( + code="DHMV_PRODUCT_NOT_SUPPORTED", + message="Select a governed DHMV terrain or surface product", + details={"product_key": payload.product_key}, + status_code=422, + ) + selection_4326 = TerrainAnalysisService._selection_geometry(db, project_id, payload) + partition = RasterPartitionAnalysisService.select( + db, + project_id, + source_name=DhmvAcquisitionService.PROVIDER, + product_key=product.key, + selection_geometry_4326=selection_4326, + nodata=DhmvAcquisitionService.NODATA, + max_pixels=resolved_settings.dhmv_max_pixels, + ) + surface_models = { + str((dataset.source_metadata or {}).get("surface_model") or "") + for dataset in partition.datasets + } + if surface_models != {product.surface_model}: + raise AppError( + code="INVALID_TERRAIN_METADATA", + message="DHMV partition provenance is incomplete", + details={"surface_models": sorted(surface_models)}, + status_code=409, + ) + + try: + import numpy as np + except ImportError as exc: + raise AppError( + code="RASTER_PROCESSING_UNAVAILABLE", + message="Numpy is required for partitioned terrain analysis", + status_code=503, + ) from exc + + raw = partition.values + invalid = ~np.isfinite(raw) | (raw == DhmvAcquisitionService.NODATA) + valid_mask = partition.selected_cells & ~invalid + values = raw[valid_mask] + if values.size == 0: + raise AppError( + code="TERRAIN_NO_VALID_DATA", + message="No valid DHMV height cells occur in this selection", + status_code=422, + ) + slope_values = np.asarray([], dtype="float64") + if raw.shape[0] >= 2 and raw.shape[1] >= 2: + surface = np.where(valid_mask, raw, np.nan) + gradient_y, gradient_x = np.gradient( + surface, + partition.resolution_y, + partition.resolution_x, + ) + slope = np.degrees(np.arctan(np.hypot(gradient_x, gradient_y))) + slope_values = slope[np.isfinite(slope) & valid_mask] + + def metric(key: str, label: str, value: float, unit: str, method: str) -> TerrainMetric: + return TerrainMetric( + metric_key=key, + metric_label=label, + metric_value=round(float(value), 4), + metric_unit=unit, + aggregation_method=method, + ) + + prefix = "terrain" if product.surface_model == "terrain" else "surface" + elevation_label = ( + "Gemiddelde maaiveldhoogte" + if product.surface_model == "terrain" + else "Gemiddelde oppervlaktehoogte" + ) + metrics = [ + metric(f"{prefix}_elevation_mean_m", elevation_label, values.mean(), "m TAW", "mean_valid_cells"), + metric(f"{prefix}_elevation_min_m", "Laagste hoogte", values.min(), "m TAW", "minimum_valid_cells"), + metric(f"{prefix}_elevation_max_m", "Hoogste hoogte", values.max(), "m TAW", "maximum_valid_cells"), + metric(f"{prefix}_elevation_p10_m", "10e percentiel hoogte", np.percentile(values, 10), "m TAW", "percentile_10_valid_cells"), + metric(f"{prefix}_elevation_p90_m", "90e percentiel hoogte", np.percentile(values, 90), "m TAW", "percentile_90_valid_cells"), + metric("relief_m", "Reliëfverschil", values.max() - values.min(), "m", "maximum_minus_minimum"), + ] + if slope_values.size: + metrics.extend( + [ + metric("slope_mean_deg", "Gemiddelde helling", slope_values.mean(), "°", "mean_finite_gradient"), + metric("slope_p90_deg", "90e percentiel helling", np.percentile(slope_values, 90), "°", "percentile_90_finite_gradient"), + metric("slope_max_deg", "Steilste helling", slope_values.max(), "°", "maximum_finite_gradient"), + ] + ) + primary = metrics[0] + selected_cell_count = int(partition.selected_cells.sum()) + first_dataset = partition.datasets[0] + response = TerrainSelectionResponse( + dataset_id=first_dataset.id, + dataset_ids=[dataset.id for dataset in partition.datasets], + partition_count=len(partition.datasets), + product_key=product.key, + surface_model=product.surface_model, + selection_bbox=payload.bbox, + selection_area_id=payload.area_id, + sample_count=int(values.size), + slope_sample_count=int(slope_values.size), + coverage_ratio=round(float(values.size / max(1, selected_cell_count)), 6), + resolution_m=round(max(partition.resolution_x, partition.resolution_y), 4), + vertical_reference=DhmvAcquisitionService.VERTICAL_REFERENCE, + summary=TerrainSelectionSummary( + 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=TerrainAnalysisService.UNSUPPORTED_METRICS, + limitation_message=( + f"{TerrainAnalysisService.LIMITATION} De selectie werd exact berekend over " + f"{len(partition.datasets)} persistente gemeentelijke rasterpartities." + ), + 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 = TerrainAnalysisService._load_dataset(db, project_id, dataset_id) diff --git a/backend/tests/test_sprint205_dhmv_terrain.py b/backend/tests/test_sprint205_dhmv_terrain.py index 94434b9c..a25d6002 100644 --- a/backend/tests/test_sprint205_dhmv_terrain.py +++ b/backend/tests/test_sprint205_dhmv_terrain.py @@ -18,7 +18,7 @@ from app.core.errors import AppError from app.db.session import get_db from app.main import app from app.models import Area, Dataset, DatasetVersion, Job, Project -from app.schemas.dhmv import DhmvAcquireRequest, TerrainSelectionRequest +from app.schemas.dhmv import DhmvAcquireRequest, TerrainPartitionSelectionRequest, TerrainSelectionRequest from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.terrain_analysis_service import TerrainAnalysisService @@ -39,6 +39,9 @@ class FakeQuery: 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): @@ -115,6 +118,23 @@ def elevation_tiff(*, left: float, top: float, width: int, height: int, resoluti return memory.read() +def constant_elevation_tiff(*, left: float, top: float, value: float) -> bytes: + values = np.full((20, 20), value, dtype="float32") + with MemoryFile() as memory: + with memory.open( + driver="GTiff", + width=20, + height=20, + count=1, + dtype="float32", + crs="EPSG:31370", + transform=from_origin(left, top, 5.0, 5.0), + nodata=-9999.0, + ) as output: + output.write(values, 1) + return memory.read() + + def edge_elevation_tiff(*, left: float, top: float, x_resolution: float, y_resolution: float = 5.0) -> bytes: rows, columns = np.indices((20, 20)) values = (20.0 + columns * 0.5 + rows).astype("float32") @@ -359,6 +379,61 @@ def test_terrain_analysis_returns_governed_elevation_relief_and_slope(tmp_path) assert "Waterdiepte" in result["limitation_message"] +def test_partitioned_terrain_analysis_is_exact_across_municipality_boundaries(tmp_path) -> None: + project_id = uuid4() + transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) + min_x, min_y = transformer.transform(200_000, 210_000) + middle_x, _ = transformer.transform(200_100, 210_000) + max_x, max_y = transformer.transform(200_200, 210_100) + paths = [tmp_path / "left-terrain.tif", tmp_path / "right-terrain.tif"] + paths[0].write_bytes(constant_elevation_tiff(left=200_000, top=210_100, value=10.0)) + paths[1].write_bytes(constant_elevation_tiff(left=200_100, top=210_100, value=20.0)) + datasets = [ + Dataset( + id=uuid4(), + project_id=project_id, + area_id=uuid4(), + name=path.name, + dataset_type="raster", + source="official WCS", + source_name="digitaal_vlaanderen_dhmv", + source_metadata={ + "product_key": "dtm_1m", + "surface_model": "terrain", + "bbox_epsg4326": [left, min_y, right, max_y], + }, + status="ready", + storage_path=str(path), + ) + for path, left, right in ( + (paths[0], min_x, middle_x), + (paths[1], middle_x, max_x), + ) + ] + db = FakeSession(query_result=datasets) + payload = TerrainPartitionSelectionRequest( + bbox={"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"}, + product_key="dtm_1m", + ) + + result = TerrainAnalysisService.analyze_partitions( + db, + project_id, + payload, + settings=Settings(_env_file=None), + ) + metrics = {item["metric_key"]: item["metric_value"] for item in result["summary"]["metrics"]} + + assert result["partition_count"] == 2 + assert set(result["dataset_ids"]) == {str(dataset.id) for dataset in datasets} + assert result["sample_count"] >= 790 + assert metrics["terrain_elevation_mean_m"] == pytest.approx(15.0, abs=0.1) + assert metrics["terrain_elevation_min_m"] == 10.0 + assert metrics["terrain_elevation_max_m"] == 20.0 + assert metrics["terrain_elevation_p90_m"] == 20.0 + assert "2 persistente gemeentelijke rasterpartities" in result["limitation_message"] + + def test_terrain_analysis_rejects_non_dhmv_raster(tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() @@ -425,6 +500,16 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None: "unsupported_metrics": ["water_depth_m", "water_volume_m3"], }, ) + monkeypatch.setattr( + TerrainAnalysisService, + "analyze_partitions", + lambda *_args, **_kwargs: { + "dataset_id": str(output_dataset_id), + "dataset_ids": [str(output_dataset_id)], + "partition_count": 1, + "sample_count": 100, + }, + ) app.dependency_overrides[get_db] = lambda: db try: products = TestClient(app).get(f"/api/v1/projects/{project_id}/datasets/dhmv/products") @@ -436,6 +521,10 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None: f"/api/v1/projects/{project_id}/datasets/{output_dataset_id}/raster/terrain/select", json={"bbox": lambert_bbox_payload().bbox.model_dump()}, ) + regional_terrain = TestClient(app).post( + f"/api/v1/projects/{project_id}/datasets/raster/terrain/select", + json={"bbox": lambert_bbox_payload().bbox.model_dump(), "product_key": "dtm_1m"}, + ) finally: app.dependency_overrides.clear() @@ -450,6 +539,9 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None: assert set(terrain.json()) == {"data"} assert terrain.json()["data"]["sample_count"] == 100 assert terrain.json()["data"]["unsupported_metrics"] == ["water_depth_m", "water_volume_m3"] + assert regional_terrain.status_code == 200 + assert set(regional_terrain.json()) == {"data"} + assert regional_terrain.json()["data"]["partition_count"] == 1 assert any(isinstance(item, Job) for item in db.added) diff --git a/backend/tests/test_sprint208_vmm_flood_hazard.py b/backend/tests/test_sprint208_vmm_flood_hazard.py index e9025d37..336bfb14 100644 --- a/backend/tests/test_sprint208_vmm_flood_hazard.py +++ b/backend/tests/test_sprint208_vmm_flood_hazard.py @@ -16,7 +16,11 @@ 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.flood_hazard import ( + FloodHazardAcquireRequest, + FloodHazardPartitionSelectionRequest, + FloodHazardSelectionRequest, +) from app.schemas.assistant import AssistantQueryRequest from app.services.geo_assistant_service import GeoAssistantService from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService @@ -120,6 +124,23 @@ def edge_depth_tiff(*, left: float, top: float, x_resolution: float, y_resolutio return memory.read() +def normalized_depth_tiff(*, left: float, top: float, value: float) -> bytes: + values = np.full((20, 20), value, dtype="float32") + with MemoryFile() as memory: + with memory.open( + driver="GTiff", + width=20, + height=20, + count=1, + dtype="float32", + crs="EPSG:31370", + transform=from_origin(left, top, 5.0, 5.0), + nodata=-9999.0, + ) as output: + output.write(values, 1) + return memory.read() + + def test_flood_hazard_registry_is_complete_and_semantically_honest() -> None: products = FloodHazardAcquisitionService.list_products() @@ -274,6 +295,61 @@ def test_flood_hazard_analysis_reports_scenario_metrics_without_claiming_waterbo assert "geen gelijktijdig" in result["limitation_message"] +def test_partitioned_flood_analysis_is_exact_across_municipality_boundaries(tmp_path) -> None: + project_id = uuid4() + transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) + min_x, min_y = transformer.transform(200_000, 210_000) + middle_x, _ = transformer.transform(200_100, 210_000) + max_x, max_y = transformer.transform(200_200, 210_100) + paths = [tmp_path / "left-flood.tif", tmp_path / "right-flood.tif"] + paths[0].write_bytes(normalized_depth_tiff(left=200_000, top=210_100, value=1.0)) + paths[1].write_bytes(normalized_depth_tiff(left=200_100, top=210_100, value=2.0)) + datasets = [ + Dataset( + id=uuid4(), + project_id=project_id, + area_id=uuid4(), + name=path.name, + dataset_type="raster", + source="VMM", + source_name=FloodHazardAcquisitionService.PROVIDER, + source_metadata={ + "product_key": "pluviaal_current_t100", + "normalized_value_unit": "m", + "bbox_epsg4326": [left, min_y, right, max_y], + }, + status="ready", + storage_path=str(path), + ) + for path, left, right in ( + (paths[0], min_x, middle_x), + (paths[1], middle_x, max_x), + ) + ] + db = FakeSession(query_result=datasets) + payload = FloodHazardPartitionSelectionRequest( + bbox={"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"}, + product_key="pluviaal_current_t100", + ) + + result = FloodHazardAnalysisService.analyze_partitions( + db, + project_id, + payload, + settings=Settings(_env_file=None), + ) + metrics = {item["metric_key"]: item["metric_value"] for item in result["summary"]["metrics"]} + + assert result["partition_count"] == 2 + assert set(result["dataset_ids"]) == {str(dataset.id) for dataset in datasets} + assert result["inundated_cell_count"] >= 790 + assert result["inundated_fraction"] == pytest.approx(1.0) + assert metrics["modelled_depth_mean_m"] == pytest.approx(1.5, abs=0.01) + assert metrics["modelled_depth_p90_m"] == 2.0 + assert metrics["modelled_inundated_area_ha"] == pytest.approx(2.0, abs=0.03) + assert "2 persistente gemeentelijke rasterpartities" in result["limitation_message"] + + def test_flood_hazard_renderer_returns_transparent_png(tmp_path) -> None: project_id = uuid4() dataset_id = uuid4() @@ -314,6 +390,16 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None: "unsupported_metrics": ["permanent_water_volume_m3"], }, ) + monkeypatch.setattr( + FloodHazardAnalysisService, + "analyze_partitions", + lambda *_args, **_kwargs: { + "dataset_id": str(output_dataset_id), + "dataset_ids": [str(output_dataset_id)], + "partition_count": 1, + "inundated_cell_count": 4, + }, + ) app.dependency_overrides[get_db] = lambda: db try: client = TestClient(app) @@ -326,6 +412,10 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None: f"/api/v1/projects/{project_id}/datasets/{output_dataset_id}/raster/flood-hazard/select", json={"bbox": flood_payload().bbox.model_dump()}, ) + regional_selection = client.post( + f"/api/v1/projects/{project_id}/datasets/raster/flood-hazard/select", + json={"bbox": flood_payload().bbox.model_dump(), "product_key": "pluviaal_current_t100"}, + ) finally: app.dependency_overrides.clear() @@ -334,6 +424,8 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None: 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 regional_selection.status_code == 200 and set(regional_selection.json()) == {"data"} + assert regional_selection.json()["data"]["partition_count"] == 1 assert any(isinstance(item, Job) for item in db.added) diff --git a/backend/tests/test_sprint219_regional_raster_explorer.py b/backend/tests/test_sprint219_regional_raster_explorer.py new file mode 100644 index 00000000..e7a69afa --- /dev/null +++ b/backend/tests/test_sprint219_regional_raster_explorer.py @@ -0,0 +1,52 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_partitioned_raster_routes_are_canonical_and_documented() -> None: + routes = (ROOT / "backend/app/api/routes/datasets.py").read_text(encoding="utf-8") + contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8") + + for path in ( + "/datasets/raster/terrain/select", + "/datasets/raster/flood-hazard/select", + ): + assert f'@router.post("{path}", response_model=dict)' in routes + assert path in contracts + assert "envelope(TerrainAnalysisService.analyze_partitions" in routes + assert "envelope(FloodHazardAnalysisService.analyze_partitions" in routes + + +def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None: + workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") + hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8") + api = (ROOT / "frontend/src/services/api/datasets.ts").read_text(encoding="utf-8") + + assert "regionalScopeSelected" in workspace + assert "rasterPartitionsForDataset" in workspace + assert "imageOverlays={activeImageOverlays}" in workspace + assert "de juiste gemeentelijke rasters worden automatisch gecombineerd" in workspace + assert "selectTerrainPartitions" in hook + assert "selectFloodHazardPartitions" in hook + assert "/datasets/raster/terrain/select" in api + assert "/datasets/raster/flood-hazard/select" in api + + +def test_maplibre_supports_multiple_persisted_raster_overlays() -> None: + map_source = (ROOT / "frontend/src/components/GeoMap.tsx").read_text(encoding="utf-8") + + assert "imageOverlays?: MapImageOverlay[]" in map_source + assert "imageOverlayIdsRef" in map_source + assert "imageOverlays.forEach" in map_source + assert "bounded-raster-" in map_source + + +def test_regional_analysis_does_not_create_an_authoritative_mosaic() -> None: + service = (ROOT / "backend/app/services/raster_partition_analysis_service.py").read_text(encoding="utf-8") + storage = (ROOT / "docs/STORAGE_ARCHITECTURE.md").read_text(encoding="utf-8") + + assert "from rasterio.merge import merge" in service + assert "DatasetService" not in service + assert "12-million-cell limit" in storage + assert "does not create another authoritative raster" in storage diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 9f4f6856..66ab48a5 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -257,6 +257,17 @@ in degrees. Area geometry is an exact mask, not only a bounding box. The response always lists `water_depth_m` and `water_volume_m3` under `unsupported_metrics`. Drainage is not calculated by this endpoint. +### POST `/api/v1/projects/{project_id}/datasets/raster/terrain/select` + +Runs the same exact terrain calculation over every persisted municipal DHMV +partition intersecting one bounded EPSG:4326 rectangle. The request adds the +governed `product_key` (`dtm_1m` or `dsm_1m`) to the ordinary selection bbox +and optional Area id. The backend mosaics only the intersecting windows in +EPSG:31370, enforces the existing 12-million-cell limit and calculates global +cell statistics. The canonical response includes `dataset_ids` and +`partition_count`; percentiles are calculated from the combined cells and are +not averages of municipal summaries. + ### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/terrain/image` Returns a browser-safe PNG colour relief for the persisted governed DHMV @@ -316,6 +327,16 @@ it is explicitly not concurrent flood storage, permanent waterbody content, current water level or bathymetry. These unsupported metrics remain listed in the response. +### POST `/api/v1/projects/{project_id}/datasets/raster/flood-hazard/select` + +Runs exact bounded analysis over the persisted municipal VMM partitions for +one governed `product_key`. Only partitions intersecting the selection are +opened, the normalized metre grids are combined at their common 5 m analysis +resolution and the global area/depth metrics are calculated from the combined +cells. The canonical response includes every contributing Dataset id in +`dataset_ids` plus `partition_count`. The existing flood-volume and bathymetry +prohibitions are unchanged. + ### 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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index d7ea7aba..4edfaef3 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,52 @@ +## Sprint 218 Regional terrain and flood completion (2026-07-16) + +Changed: +- Added the resumable 28-municipality DHMV DTM/DSM operator and executed all + 56 governed acquisitions through the canonical API. +- Executed all 336 governed VMM mechanism/climate/return-period combinations + across the same 28 persisted municipality Areas. +- Diagnosed Retie's official WCS integer-grid edge rounding in both providers. + Accepted only bounded 5%/0.25 m edge drift, harmonized accepted tiles to the + exact requested grid and retained source resolutions, tile indexes and + method in provenance. A 4.5 m regression fixture remains rejected. +- Added exact partitioned terrain and flood selection routes for bounded + rectangles that cross municipality boundaries. They use only persisted + GeoTIFFs, calculate global statistics from combined cells and retain the + contributing Dataset ids in the response. +- Made the complete Kempen Area expose DHMV/VMM as logical regional MapLibre + layers while retaining municipality-linked storage and twelve distinct VMM + scenario identities. + +Live evidence: +- VMM: 336 ready Datasets, 28 Areas, 12 products, 336 DatasetVersions, + 165,277,992 bytes, zero duplicate Area/product pairs and zero missing or + size-mismatched files. +- DHMV: 56 ready Datasets, 28 Areas, two products, 56 DatasetVersions, + 282,645,991 bytes, zero duplicate Area/product pairs and zero missing or + size-mismatched files. +- Every VMM Dataset has null `observed_at` and explicit false flags for + bathymetry, permanent depth/volume and concurrent volume. A repeated Retie + run reused all twelve immutable Dataset ids. +- Tower commit `153cff0` passed container health, PostGIS 3.6, Alembic head + `202607160001` and frontend/API/icon proxy checks before the regional UI + follow-up. + +Validation evidence: +- Focused DHMV/VMM and regional explorer backend suites passed, including + exact adjacent-partition percentile and area calculations. +- The pre-UI-fix release gate passed 746 tests, backend compilation, API + contract checks, one Alembic head, frontend typecheck and production build. +- The final local release gate passed all 752 backend tests, backend + compilation, 107 documented API route checks, Alembic head `202607160001`, + frontend typecheck and the production build. Live deployment and browser + evidence are recorded after the Tower rollout. + +Next: +- Use the now-complete regional current-state layers as the baseline for a + governed refresh/change scheduler. Keep source-specific publication dates + and scenario semantics; do not turn VMM scenarios into a historical water + level series. + ## Sprint 217 Regional DOV soil coverage (2026-07-16) Changed: diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md index c9d93981..f1cbbc76 100644 --- a/docs/DATA_SOURCES.md +++ b/docs/DATA_SOURCES.md @@ -387,7 +387,7 @@ quality metrics. - Cache: canonical raster Dataset plus WCS request/response/output checksums - Operators: `scripts/provision_mol_dhmv.py`, `scripts/provision_regional_dhmv.py` -- Prioriteit: P4 uitgevoerd voor Mol en operationeel regionaal uitbreidbaar +- Prioriteit: P4 uitgevoerd voor alle 28 Kempen-gemeenten The operator requests a bounded 5 m analysis copy by default so a complete municipality remains operationally manageable while retaining the official @@ -401,7 +401,10 @@ De regionale operator gebruikt exact de 28 persistente gemeente-Areas van de goedgekeurde Kempen-scope en plant twee outputs per gemeente. Die 56 gemeentepartities vermijden een onnodig monolithisch hoogtebestand, blijven binnen WCS/pixelgrenzen en sluiten aan op de gebiedsgebonden datasetselectie in -de kaart. Herhaalruns gebruiken de bestaande checksummed requestcache. +de kaart. De live matrix bevat 56 geverifieerde Datasets en DatasetVersions. +De regionale kaart combineert alleen de partities die een getekende selectie +raken; globale statistieken worden uit de samengevoegde cellen berekend. +Herhaalruns gebruiken de bestaande checksummed requestcache. ## VMM overstromingsgevaarkaarten @@ -415,8 +418,9 @@ de kaart. Herhaalruns gebruiken de bestaande checksummed requestcache. - 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 +- Operators: `scripts/provision_mol_flood_hazards.py`, + `scripts/provision_regional_flood_hazards.py` +- Prioriteit: P5 scenariofundament uitgevoerd voor alle 28 Kempen-gemeenten VMM beschrijft deze lagen als maximale lokale waterdiepte tussen wateroppervlak en maaiveld voor een gekozen kans- en klimaatscenario. GeoIntel converteert @@ -441,15 +445,17 @@ scenario uitsluitend de bestaande canonical API aan: - `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/select` Deze gemeentepartities zijn bewust. Een volledig regionaal raster in een -aanvraag zou de publieke WCS- en pixelgrenzen onnodig belasten. In de UI wordt -een VMM-dataset alleen als overstromingslaag getoond voor het actieve -werkgebied waaraan die dataset gekoppeld is. Zo blijft Mol bij Mol, Geel bij -Geel, enzovoort. +aanvraag zou de publieke WCS- en pixelgrenzen onnodig belasten. In de UI blijft +een gemeente gekoppeld aan haar eigen bestand. Op het volledige Kempen-gebied +worden de 28 partities als één logische scenario-laag getoond. Een getekende +rechthoek opent alleen de rakende partities en berekent globale statistieken +uit de werkelijk samengevoegde cellen. De regionale operator ondersteunt `--dry-run`, `--members` en `--products`. -Een volledige scope met alle twaalf scenario's plant 336 gecontroleerde -acquisities. Herhaalruns gebruiken bestaande checksummed Datasets via de -backend-cache zolang de requestidentiteit niet verandert. +De uitgevoerde volledige scope bevat 336 gecontroleerde Datasets en 336 +DatasetVersions zonder ontbrekende gemeente/scenario-combinaties. Herhaalruns +gebruiken bestaande checksummed Datasets via de backend-cache zolang de +requestidentiteit niet verandert. Ook regionaal blijft de semantiek onveranderd: VMM-waterdiepte is een gemodelleerde maximale lokale diepte per kans- en klimaatscenario. GeoIntel kan diff --git a/docs/STORAGE_ARCHITECTURE.md b/docs/STORAGE_ARCHITECTURE.md index 257e3ec6..94491350 100644 --- a/docs/STORAGE_ARCHITECTURE.md +++ b/docs/STORAGE_ARCHITECTURE.md @@ -134,6 +134,14 @@ URLs, response/coverage/normalized checksums, EPSG:31370 bounds, scenario metadata and explicit unsupported-volume flags. Repeat runs reuse matching ready Datasets through the acquisition service cache. +The regional Map workspace does not create another authoritative raster or +copy pixels into PostgreSQL. Its partition-selection endpoints read only the +municipal GeoTIFF windows intersecting a bounded selection, mosaic those +windows in memory at the governed analysis resolution and return metrics plus +the complete contributing `dataset_ids`. The 12-million-cell limit applies to +the combined window. Persisted files, checksums and DatasetVersions remain the +only authoritative artifacts. + BWK/Natura 2000 evidence lives under `storage/operator-evidence/bwk-natura2000-2025/mol/`. The `raw/` directory contains immutable WFS pages; the adjacent manifest records their URLs, diff --git a/docs/TODO.md b/docs/TODO.md index 39926dfa..a393bae9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -36,7 +36,8 @@ - [x] Persist one bounded whole-region snapshot per thematic raster so drawn selections can cross municipality boundaries without changing source semantics. - [x] Generalize the DOV soil-map operator to all 28 approved Kempen municipality partitions with one regional snapshot manifest. - [x] Add a resumable regional DHMV DTM/DSM operator for all 28 approved Kempen municipality Areas. -- [ ] Execute and audit the complete 336-product VMM and 56-product DHMV regional runtime matrices. +- [x] Execute and audit the complete 336-product VMM and 56-product DHMV regional runtime matrices. +- [x] Present municipal DHMV/VMM partitions as logical regional layers and analyse cross-boundary rectangles without a municipality prerequisite. ## Governed source expansion backlog diff --git a/frontend/README.md b/frontend/README.md index 512a06cc..156e88a3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -519,12 +519,14 @@ GeoJSON export stays disabled. The UI never labels the maximum-depth area integral as current, permanent or concurrent water volume. Regional VMM provisioning creates one scenario raster per municipality Area. -The explorer therefore shows only the flood scenarios whose `area_id` matches -the active work area. This avoids presenting a Mol scenario while the map is -focused on another municipality. The region-wide Area remains the navigation -context; municipality Areas are the analysis scope for flood rasters because -the public WCS and raster cell limits make one monolithic Kempen raster -operationally unsafe. +For a municipality the explorer still uses only that exact Area-linked file. +For the complete Kempen Area it presents the 28 VMM and DHMV partitions as one +logical map layer, deduplicates VMM into twelve scenario choices and renders +every matching MapLibre image partition. A drawn rectangle is sent to the +partition endpoint, which opens only intersecting files and calculates exact +combined cell statistics. A monolithic full-region 5 m calculation remains +disabled because it exceeds the governed raster-cell limit; users draw a +bounded rectangle without first choosing a municipality. ## Useful repository scripts diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index e6a5384f..14299716 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -13,7 +13,7 @@ interface GeoMapProps { selectedFeature?: GeoJSON.Feature | null selectionData?: GeoJSON.FeatureCollection | null qaEvidenceData?: GeoJSON.FeatureCollection | null - imageOverlay?: MapImageOverlay | null + imageOverlays?: MapImageOverlay[] selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null bboxSelectionMode?: boolean visible?: boolean @@ -154,7 +154,7 @@ function GeoMap({ selectedFeature = null, selectionData = null, qaEvidenceData = null, - imageOverlay = null, + imageOverlays = [], selectionBbox = null, bboxSelectionMode = false, visible = true, @@ -180,6 +180,7 @@ function GeoMap({ const dataRef = useRef(data) const fitDataOnChangeRef = useRef(fitDataOnChange) const lastFittedAreaRef = useRef(null) + const imageOverlayIdsRef = useRef([]) const [mapStyleReady, setMapStyleReady] = useState(false) areaDataRef.current = areaData @@ -353,37 +354,38 @@ function GeoMap({ if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } - if (map.getLayer('bounded-orthophoto')) { - map.removeLayer('bounded-orthophoto') + for (const overlayId of [...imageOverlayIdsRef.current].reverse()) { + if (map.getLayer(overlayId)) { + map.removeLayer(overlayId) + } + if (map.getSource(overlayId)) { + map.removeSource(overlayId) + } } - if (map.getSource('bounded-orthophoto')) { - map.removeSource('bounded-orthophoto') - } - if (!imageOverlay) { - return - } - const [minX, minY, maxX, maxY] = imageOverlay.bbox - map.addSource('bounded-orthophoto', { - type: 'image', - url: imageOverlay.url, - coordinates: [ - [minX, maxY], - [maxX, maxY], - [maxX, minY], - [minX, minY], - ], - }) + imageOverlayIdsRef.current = [] const beforeLayer = ['area-fill', 'dataset-fill', 'selection-bbox-fill'].find((layerId) => map.getLayer(layerId)) - map.addLayer( - { - id: 'bounded-orthophoto', + imageOverlays.forEach((imageOverlay, index) => { + const overlayId = `bounded-raster-${index}` + const [minX, minY, maxX, maxY] = imageOverlay.bbox + map.addSource(overlayId, { + type: 'image', + url: imageOverlay.url, + coordinates: [ + [minX, maxY], + [maxX, maxY], + [maxX, minY], + [minX, minY], + ], + }) + map.addLayer({ + id: overlayId, type: 'raster', - source: 'bounded-orthophoto', + source: overlayId, paint: { 'raster-opacity': imageOverlay.opacity ?? 0.88 }, - }, - beforeLayer, - ) - }, [imageOverlay, mapStyleReady]) + }, beforeLayer) + imageOverlayIdsRef.current.push(overlayId) + }) + }, [imageOverlays, mapStyleReady]) useEffect(() => { const map = mapRef.current diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 48153947..28a1fc36 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -158,14 +158,15 @@ const DATA_THEME_MAP_STYLES: Record parcels: { fill: '#a7792f', line: '#7d571f' }, } -function datasetAvailabilityLabel(dataset: DatasetCreateResponse): string { +function datasetAvailabilityLabel(dataset: DatasetCreateResponse, partitionCount = 1): string { + const regionalSuffix = partitionCount > 1 ? ` · ${partitionCount} gemeenten` : '' if (dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv') { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) - return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid beschikbaar` + return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid${regionalSuffix}` } 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 `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} overstromingsscenario${regionalSuffix}` } if (dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster') { const resolution = Number(dataset.source_metadata?.['analysis_resolution_m']) @@ -213,21 +214,69 @@ function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): return theme.tokens.some((token) => searchText.includes(token)) } -function datasetCoversSelectedArea(dataset: DatasetCreateResponse, selectedAreaId: string | null): boolean { +function isMunicipalityAreaName(name: string | null | undefined): boolean { + return /^Gemeente\s/i.test(name ?? '') +} + +function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean { + return Boolean( + dataset?.dataset_type === 'raster' + && ['digitaal_vlaanderen_dhmv', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''), + ) +} + +function datasetProductKey(dataset: DatasetCreateResponse): string { + return String(dataset.source_metadata?.['product_key'] ?? '') +} + +function datasetCoversSelectedArea( + dataset: DatasetCreateResponse, + selectedAreaId: string | null, + regionalScope = false, +): boolean { const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '') if (coverageScope !== 'municipality' || !dataset.area_id) { return true } + if (regionalScope) { + return true + } return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId } +function rasterPartitionsForDataset( + datasets: DatasetCreateResponse[], + representative: DatasetCreateResponse | null, + selectedAreaId: string | null, + regionalScope: boolean, +): DatasetCreateResponse[] { + if (!representative) { + return [] + } + if (!regionalScope || !isPartitionedRaster(representative)) { + return [representative] + } + const productKey = datasetProductKey(representative) + return datasets + .filter( + (dataset) => + dataset.source_name === representative.source_name + && datasetProductKey(dataset) === productKey + && datasetCoversSelectedArea(dataset, selectedAreaId, true), + ) + .sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? ''))) +} + function pickThemeDataset( datasets: DatasetCreateResponse[], theme: DataTheme, selectedAreaId: string | null, + regionalScope = false, ): DatasetCreateResponse | null { const candidates = datasets.filter( - (dataset) => datasetMatchesTheme(dataset, theme) && datasetCoversSelectedArea(dataset, selectedAreaId), + (dataset) => + datasetMatchesTheme(dataset, theme) + && datasetCoversSelectedArea(dataset, selectedAreaId, regionalScope), ) candidates.sort((left, right) => { const score = (dataset: DatasetCreateResponse) => @@ -745,6 +794,7 @@ export function MapWorkspace({ const [fullWorkflowError, setFullWorkflowError] = useState(null) const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new') const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) + const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) const featureProperties = selectedMapFeature?.properties ?? null const featureSummaryEntries = featureProperties ? Object.entries(featureProperties) @@ -767,70 +817,138 @@ export function MapWorkspace({ const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL 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 scoped = availableMapDatasets + .filter( + (dataset) => + dataset.source_name === 'vmm_flood_hazard' + && datasetCoversSelectedArea(dataset, selectedMapAreaId, regionalScopeSelected), + ) + .sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')) + if (!regionalScopeSelected) { + return scoped + } + const products = new Map() + for (const dataset of scoped) { + const key = datasetProductKey(dataset) + if (key && !products.has(key)) { + products.set(key, dataset) + } + } + return Array.from(products.values()) + }, + [availableMapDatasets, regionalScopeSelected, selectedMapAreaId], ) const themeDatasetMap = useMemo(() => { const result = Object.fromEntries( - DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]), + DATA_THEMES.map((theme) => [ + theme.id, + pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId, regionalScopeSelected), + ]), ) as Record const selectedFloodHazard = floodHazardDatasets.find((dataset) => dataset.id === selectedFloodHazardDatasetId) if (selectedFloodHazard) { result.flood_hazard = selectedFloodHazard } return result - }, [availableMapDatasets, floodHazardDatasets, selectedFloodHazardDatasetId, selectedMapAreaId]) + }, [availableMapDatasets, floodHazardDatasets, regionalScopeSelected, selectedFloodHazardDatasetId, selectedMapAreaId]) + const themePartitionMap = useMemo( + () => + Object.fromEntries( + DATA_THEMES.map((theme) => [ + theme.id, + rasterPartitionsForDataset( + availableMapDatasets, + themeDatasetMap[theme.id], + selectedMapAreaId, + regionalScopeSelected, + ), + ]), + ) as Record, + [availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap], + ) 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) const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null - const orthophotoImageOverlay = orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4 - ? { - url: orthophotoImageUrl, - bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number], - label: orthophotoResult.display_name, - opacity: 0.9, - } - : null + const orthophotoImageOverlay = useMemo( + () => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4 + ? { + url: orthophotoImageUrl, + bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number], + label: orthophotoResult.display_name, + opacity: 0.9, + } + : null, + [orthophotoImageUrl, orthophotoResult], + ) const activeThemeDataset = themeDatasetMap[activeTheme.id] - const terrainBounds = activeThemeDataset?.source_name === 'digitaal_vlaanderen_dhmv' - ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] - : null - const terrainImageOverlay = activeTheme.id === 'elevation' && activeThemeDataset && selectedProjectId && Array.isArray(terrainBounds) && terrainBounds.length === 4 - ? { - url: terrainImageUrl(selectedProjectId, activeThemeDataset.id), - bbox: terrainBounds.map(Number) as [number, number, number, number], - label: getDatasetDisplayName(activeThemeDataset), - opacity: 0.82, - } - : null - 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 activeThemePartitions = themePartitionMap[activeTheme.id] + const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset) + const terrainImageOverlays = useMemo( + () => + activeTheme.id === 'elevation' && selectedProjectId + ? activeThemePartitions.flatMap((dataset) => { + const bounds = dataset.source_metadata?.['bbox_epsg4326'] + return dataset.source_name === 'digitaal_vlaanderen_dhmv' + && Array.isArray(bounds) + && bounds.length === 4 + ? [{ + url: terrainImageUrl(selectedProjectId, dataset.id), + bbox: bounds.map(Number) as [number, number, number, number], + label: getDatasetDisplayName(dataset), + opacity: 0.82, + }] + : [] + }) + : [], + [activeTheme.id, activeThemePartitions, selectedProjectId], + ) + const floodHazardImageOverlays = useMemo( + () => + activeTheme.id === 'flood_hazard' && selectedProjectId + ? activeThemePartitions.flatMap((dataset) => { + const bounds = dataset.source_metadata?.['bbox_epsg4326'] + return dataset.source_name === 'vmm_flood_hazard' + && Array.isArray(bounds) + && bounds.length === 4 + ? [{ + url: floodHazardImageUrl(selectedProjectId, dataset.id), + bbox: bounds.map(Number) as [number, number, number, number], + label: floodScenarioLabel(dataset), + opacity: 0.82, + }] + : [] + }) + : [], + [activeTheme.id, activeThemePartitions, selectedProjectId], + ) const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] : null - const thematicRasterImageOverlay = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4 - ? { - url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id), - bbox: thematicRasterBounds.map(Number) as [number, number, number, number], - label: getDatasetDisplayName(activeThemeDataset), - opacity: 0.78, - } - : null + const thematicRasterImageOverlays = useMemo( + () => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4 + ? [{ + url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id), + bbox: thematicRasterBounds.map(Number) as [number, number, number, number], + label: getDatasetDisplayName(activeThemeDataset), + opacity: 0.78, + }] + : [], + [activeThemeDataset, selectedProjectId, thematicRasterBounds], + ) const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde') const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde') - const activeImageOverlay = thematicRasterImageOverlay ?? floodHazardImageOverlay ?? terrainImageOverlay ?? orthophotoImageOverlay + const activeImageOverlays = useMemo( + () => thematicRasterImageOverlays.length > 0 + ? thematicRasterImageOverlays + : floodHazardImageOverlays.length > 0 + ? floodHazardImageOverlays + : terrainImageOverlays.length > 0 + ? terrainImageOverlays + : orthophotoImageOverlay ? [orthophotoImageOverlay] : [], + [floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays], + ) 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 @@ -865,7 +983,7 @@ export function MapWorkspace({ [themeInsights], ) const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result - ?? (selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) + ?? (!regionalRasterThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) const selectedAreaSquareMetres = useMemo( () => bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2 @@ -1106,14 +1224,23 @@ export function MapWorkspace({ const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const availableThemes = DATA_THEMES.flatMap((theme) => { const dataset = themeDatasetMap[theme.id] - return dataset ? [{ themeId: theme.id, dataset }] : [] + return dataset + ? [{ + themeId: theme.id, + dataset, + partitioned: regionalScopeSelected && isPartitionedRaster(dataset), + }] + : [] }) await loadThemeInsights(bbox, availableThemes, areaId) } const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { setSelectionBbox(bbox) - const tasks: Array> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)] + const tasks: Array> = [loadAllThemeResults(bbox, areaId)] + if (!regionalRasterThemeActive) { + tasks.push(onRunMapSelectionExtract(bbox, areaId)) + } if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId)) } @@ -1267,6 +1394,7 @@ export function MapWorkspace({
{DATA_THEMES.map((theme) => { const dataset = themeDatasetMap[theme.id] + const partitionCount = themePartitionMap[theme.id].length const temporalGroups = themeTemporalSeriesMap[theme.id] const temporalGroup = temporalGroups[0] const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2) @@ -1296,7 +1424,7 @@ export function MapWorkspace({ ? 'Alleen huidige toestand' : 'Bron nog niet ingeladen' : dataset - ? datasetAvailabilityLabel(dataset) + ? datasetAvailabilityLabel(dataset, partitionCount) : 'Bron nog niet ingeladen'} @@ -1416,7 +1544,13 @@ export function MapWorkspace({ 2

Selecteer een gebied

-

{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer het volledige werkgebied.'}

+

+ {bboxSelectionMode + ? 'Sleep nu een rechthoek op de kaart.' + : regionalRasterThemeActive + ? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.' + : 'Sleep een rechthoek of analyseer het volledige werkgebied.'} +

@@ -1430,11 +1564,12 @@ export function MapWorkspace({