>()
for (const feature of activeSelectionResult?.geojson.features ?? []) {
@@ -1094,7 +1136,7 @@ export function MapWorkspace({
? 'Alleen huidige toestand'
: 'Bron nog niet ingeladen'
: dataset
- ? `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar`
+ ? datasetAvailabilityLabel(dataset)
: 'Bron nog niet ingeladen'}
@@ -1125,7 +1167,7 @@ export function MapWorkspace({
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}`
: 'Voor dit thema is nog geen tweede officieel meetmoment beschikbaar.'
: activeThemeDataset
- ? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}`
+ ? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}`
: activeTheme.description}
@@ -1225,7 +1267,7 @@ export function MapWorkspace({
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={analysisMode === 'current' ? mapSelectionResult?.geojson ?? null : null}
- imageOverlay={orthophotoImageOverlay}
+ imageOverlay={activeImageOverlay}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
@@ -1241,7 +1283,7 @@ export function MapWorkspace({
/>
Werkgebied
- {orthophotoImageOverlay ? {orthophotoImageOverlay.label} : null}
+ {activeImageOverlay ? {activeImageOverlay.label} : null}
{analysisOverlayActive ? (
<>
AI-kandidaten
@@ -1444,7 +1486,7 @@ export function MapWorkspace({
{activeSelectionResult ? resultMetricLabel(activeSelectionResult) : 'Geen resultaat'}
- {activeMetricUnit === 'ha' ? 'Aandeel selectie' : 'Dichtheid'}
+ {activeMetricUnit === 'ha' ? 'Aandeel selectie' : activeMetricUnit === 'm TAW' ? 'Reliëf' : 'Dichtheid'}
{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}
@@ -1517,7 +1559,7 @@ export function MapWorkspace({
{analysisMode === 'current' ? (
-
+
) : null}
diff --git a/frontend/src/hooks/useMapSelectionExtract.ts b/frontend/src/hooks/useMapSelectionExtract.ts
index 23cf6110..56659f4b 100644
--- a/frontend/src/hooks/useMapSelectionExtract.ts
+++ b/frontend/src/hooks/useMapSelectionExtract.ts
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { datasetsApi } from '../services/api'
import { formatError } from '../lib/formatError'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
+import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
interface MapSelectionExtractOptions {
selectedProjectId: string | null
@@ -38,8 +39,9 @@ export function useMapSelectionExtract({
setMapSelectionError('Open a vector dataset before extracting a map area.')
return null
}
- if (!isVectorDatasetType(selectedDataset.dataset_type)) {
- setMapSelectionError('Area extraction requires an active vector dataset.')
+ 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.')
return null
}
@@ -49,11 +51,16 @@ export function useMapSelectionExtract({
setMapSelectionError(null)
setMapSelectionBbox(bbox)
try {
- const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
- bbox: { ...bbox, crs: 'EPSG:4326' },
- area_id: areaId,
- limit: 1000,
- })
+ const response = terrainDataset
+ ? terrainSelectionToMapSelection(await datasetsApi.selectTerrain(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,
+ limit: 1000,
+ })
if (requestSequence.current !== sequence) {
return null
}
diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts
index 09b6460f..6e083e87 100644
--- a/frontend/src/hooks/useMapThemeSelectionInsights.ts
+++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { formatError } from '../lib/formatError'
import { datasetsApi } from '../services/api/datasets'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
+import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
export interface MapThemeQuery {
themeId: TThemeId
@@ -54,11 +55,16 @@ export function useMapThemeSelectionInsights(
queries.map(async ({ themeId, dataset }) => ({
themeId,
dataset,
- result: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
- bbox,
- area_id: areaId,
- limit: 1000,
- }),
+ result: dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv'
+ ? terrainSelectionToMapSelection(await datasetsApi.selectTerrain(selectedProjectId, dataset.id, {
+ bbox,
+ area_id: areaId,
+ }))
+ : await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
+ bbox,
+ area_id: areaId,
+ limit: 1000,
+ }),
})),
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts
index 91d3ab27..cb1cbe51 100644
--- a/frontend/src/lib/datasetDisplay.ts
+++ b/frontend/src/lib/datasetDisplay.ts
@@ -9,6 +9,7 @@ const DATASET_LABEL_BY_LAYER: Record = {
forest: 'Bos en groen',
nature_value: 'Natuurwaarde',
agriculture: 'Landbouwgebruikspercelen',
+ elevation: 'Hoogte en reliëf',
building_registry: 'Gebouwenregister',
regional_boundary: 'Grens vervoerregio Kempen',
municipality_boundaries: 'Gemeentegrenzen Kempen',
@@ -19,6 +20,7 @@ const DATASET_SOURCE_LABELS: Record = {
agentschap_landbouw_zeevisserij_agricultural_parcels: 'Agentschap Landbouw en Zeevisserij',
digitaal_vlaanderen_orthophoto: 'Digitaal Vlaanderen',
digitaal_vlaanderen_buildings_addresses_register: 'Digitaal Vlaanderen',
+ digitaal_vlaanderen_dhmv: 'Digitaal Vlaanderen',
grb: 'GRB',
historical_landuse: 'Digitaal Vlaanderen',
inbo_bwk_natura2000: 'INBO',
@@ -33,6 +35,10 @@ export function getDatasetSourceDisplayName(dataset: DatasetCreateResponse): str
}
export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
+ if (dataset.source_name === 'digitaal_vlaanderen_dhmv') {
+ const productName = dataset.source_metadata?.['product_display_name']
+ return typeof productName === 'string' && productName.trim() ? productName : 'DHMV II hoogtemodel'
+ }
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '')
.toString()
.toLowerCase()
diff --git a/frontend/src/lib/terrainImage.ts b/frontend/src/lib/terrainImage.ts
new file mode 100644
index 00000000..14b7cec0
--- /dev/null
+++ b/frontend/src/lib/terrainImage.ts
@@ -0,0 +1,3 @@
+export function terrainImageUrl(projectId: string, datasetId: string): string {
+ return `/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/image`
+}
diff --git a/frontend/src/lib/terrainSelection.ts b/frontend/src/lib/terrainSelection.ts
new file mode 100644
index 00000000..7a4188e8
--- /dev/null
+++ b/frontend/src/lib/terrainSelection.ts
@@ -0,0 +1,23 @@
+import type { TerrainSelectionResponse, VectorSelectionResponse } from '../types'
+
+export function terrainSelectionToMapSelection(result: TerrainSelectionResponse): VectorSelectionResponse {
+ return {
+ selection_bbox: result.selection_bbox,
+ selection_area_id: result.selection_area_id,
+ feature_count: 0,
+ total_feature_count: 0,
+ limit: 0,
+ truncated: false,
+ geojson: { type: 'FeatureCollection', features: [] },
+ summary: {
+ ...result.summary,
+ feature_count: result.sample_count,
+ is_estimate: false,
+ warning: result.limitation_message,
+ metrics: result.summary.metrics.map((metric) => ({
+ ...metric,
+ is_estimate: false,
+ })),
+ },
+ }
+}
diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts
index 8a8bf13e..fb1c0cab 100644
--- a/frontend/src/services/api/datasets.ts
+++ b/frontend/src/services/api/datasets.ts
@@ -18,6 +18,9 @@ import type {
RasterNdbiRequest,
OrthophotoAcquireRequest,
OrthophotoProductRead,
+ DhmvAcquireRequest,
+ DhmvProductRead,
+ TerrainSelectionResponse,
} from '../../types'
const DATASET_PAGE_SIZE = 200
@@ -115,6 +118,16 @@ export const datasetsApi = {
apiGet<{ items: OrthophotoProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/orthophoto/products`),
orthophotoImageUrl: (projectId: string, datasetId: string): string =>
`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/image`,
+ acquireDhmv: (projectId: string, payload: DhmvAcquireRequest): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/dhmv/acquire`, payload),
+ listDhmvProducts: (projectId: string): Promise<{ items: DhmvProductRead[]; total: number }> =>
+ apiGet<{ items: DhmvProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/dhmv/products`),
+ selectTerrain: (
+ projectId: string,
+ datasetId: string,
+ payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string },
+ ): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/terrain/select`, payload),
refreshMetadata: (projectId: string, datasetId: string): Promise =>
apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
inspectRaster: (projectId: string, datasetId: string): Promise =>
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index aaecc6da..efe1d0f7 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -5756,6 +5756,7 @@ section {
.geo-theme-symbol-nature_value { background: #9a4f64; }
.geo-theme-symbol-agriculture { background: #7b8f32; }
.geo-theme-symbol-water { background: #2676a8; }
+.geo-theme-symbol-elevation { background: #a57a4b; }
.geo-theme-symbol-roads { background: #6b7280; }
.geo-theme-symbol-parcels { background: #a7792f; }
@@ -5940,6 +5941,11 @@ section {
background: rgba(38, 118, 168, 0.24);
}
+.geo-map-legend .geo-legend-layer-elevation {
+ border-color: #315f59;
+ background: rgba(165, 122, 75, 0.26);
+}
+
.geo-map-legend .geo-legend-layer-roads {
border-color: #4b5563;
background: rgba(107, 114, 128, 0.24);
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index a4ee877c..536ce974 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -336,6 +336,52 @@ export interface OrthophotoAcquisitionResult {
limitation_message: string
}
+export interface DhmvAcquireRequest {
+ bbox: VectorSelectionBBox
+ area_id?: string | null
+ product_key?: 'dtm_1m' | 'dsm_1m'
+ resolution_m?: number | null
+ force_refresh?: boolean
+}
+
+export interface DhmvProductRead {
+ key: 'dtm_1m' | 'dsm_1m'
+ display_name: string
+ surface_model: 'terrain' | 'surface'
+ coverage_id: string
+ native_resolution_m: number
+ source_crs: string
+ vertical_reference: string
+ acquisition_period: string
+ catalog_url: string
+ attribution: string
+ limitation_message: string
+}
+
+export interface TerrainSelectionResponse {
+ dataset_id: string
+ product_key: string
+ surface_model: 'terrain' | 'surface'
+ selection_bbox: VectorSelectionBBox
+ selection_area_id?: string | null
+ sample_count: number
+ slope_sample_count: number
+ coverage_ratio: number
+ resolution_m: number
+ vertical_reference: string
+ summary: {
+ metric_label: string
+ metric_value: number
+ metric_unit: string
+ aggregation_method: string
+ primary_metric_key: string
+ metrics: VectorSelectionMetric[]
+ }
+ unsupported_metrics: string[]
+ limitation_message: string
+ generated_at: string
+}
+
export interface MapImageOverlay {
url: string
bbox: [number, number, number, number]
diff --git a/scripts/README.md b/scripts/README.md
index 556fbe72..3bc4ac63 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1513,6 +1513,31 @@ Only aggregate unit/address counts enter the queryable building layer. Review
manifest before accepting a broader import. Raw address response pages are
operator evidence and must not be published.
+## Mol DHMV terrain rasters
+
+Acquire and validate the official DHMV II DTM and DSM for the exact persisted
+Mol Area:
+
+```bash
+docker exec geointel python /app/scripts/provision_mol_dhmv.py
+```
+
+The operator resolves project and Area through the API, derives the bounded
+EPSG:4326 request rectangle and calls the canonical DHMV endpoints. The backend
+requests the fixed official WCS coverages, extracts multipart GeoTIFF, clips to
+the exact Area, validates EPSG:31370/resolution/nodata/valid cells and stores
+through DatasetService. It then runs a full-Area terrain metric smoke.
+
+Useful safe overrides:
+
+```bash
+docker exec geointel python /app/scripts/provision_mol_dhmv.py --products dtm_1m
+docker exec geointel python /app/scripts/provision_mol_dhmv.py --resolution-m 5 --force
+```
+
+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.
+
## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
diff --git a/scripts/provision_mol_dhmv.py b/scripts/provision_mol_dhmv.py
new file mode 100644
index 00000000..672554d5
--- /dev/null
+++ b/scripts/provision_mol_dhmv.py
@@ -0,0 +1,157 @@
+"""Provision governed DHMV II terrain/surface rasters for the persisted Mol Area.
+
+The operator calls the canonical GeoIntel DHMV acquisition API. The backend
+performs the bounded official WCS request, exact Area clipping, validation,
+checksum storage and Dataset/Job persistence. No raster rows are written
+directly by this script.
+"""
+
+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 = ("dtm_1m", "dsm_1m")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Provision official DHMV II DTM/DSM rasters 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-DHMV-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"])
+
+ requested_products = [item.strip() for item in args.products.split(",") if item.strip()]
+ invalid = sorted(set(requested_products) - set(PRODUCTS))
+ if invalid:
+ raise RuntimeError(f"Unsupported DHMV product keys: {', '.join(invalid)}")
+
+ results = []
+ for product_key in requested_products:
+ job = unwrap(
+ session.post(
+ f"{base_url}/api/v1/projects/{project['id']}/datasets/dhmv/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"DHMV 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/terrain/select",
+ json={"bbox": bbox, "area_id": area["id"]},
+ timeout=args.timeout,
+ )
+ )
+ if sorted(analysis.get("unsupported_metrics", [])) != ["water_depth_m", "water_volume_m3"]:
+ raise RuntimeError("DHMV terrain contract must explicitly keep water depth and volume 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"],
+ "sample_count": analysis["sample_count"],
+ "coverage_ratio": analysis["coverage_ratio"],
+ "metrics": analysis["summary"]["metrics"],
+ }
+ )
+
+ print(
+ json.dumps(
+ {
+ "status": "ok",
+ "project_id": project["id"],
+ "area_id": area["id"],
+ "area_name": area["name"],
+ "bbox": bbox,
+ "products": results,
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh
index 79f0fd5c..1a0c1f61 100755
--- a/scripts/run_readiness_check.sh
+++ b/scripts/run_readiness_check.sh
@@ -51,6 +51,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_waterinfo_station_history.py
${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_regional_timeseries.py
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py