>()
for (const feature of activeSelectionResult?.geojson.features ?? []) {
@@ -1185,7 +1269,7 @@ export function MapWorkspace({
const dataset = themeDatasetMap[theme.id]
const temporalGroups = themeTemporalSeriesMap[theme.id]
const temporalGroup = temporalGroups[0]
- const evolutionAvailable = temporalGroups.length > 0
+ const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
const available = Boolean(dataset) && (analysisMode === 'current' || evolutionAvailable)
const active = activeThemeId === theme.id
const firstObservation = temporalGroup?.items[0]?.observed_at
@@ -1382,7 +1466,12 @@ export function MapWorkspace({
/>
Werkgebied
- {activeImageOverlay ? {activeImageOverlay.label} : null}
+ {thematicRasterImageOverlay ? (
+
+
+ {thematicLegendMin} → {thematicLegendMax}
+
+ ) : activeImageOverlay ? {activeImageOverlay.label} : null}
{analysisOverlayActive ? (
<>
AI-kandidaten
@@ -1585,7 +1674,7 @@ export function MapWorkspace({
{activeSelectionResult ? resultMetricLabel(activeSelectionResult) : 'Geen resultaat'}
- {activeMetricUnit === 'ha' ? 'Aandeel selectie' : activeMetricUnit === 'm TAW' ? 'Reliëf' : 'Dichtheid'}
+ {activeSecondaryLabel}
{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}
@@ -1658,7 +1747,7 @@ export function MapWorkspace({
{analysisMode === 'current' ? (
-
+
) : null}
diff --git a/frontend/src/hooks/useMapSelectionExtract.ts b/frontend/src/hooks/useMapSelectionExtract.ts
index e6930313..1afbb3f4 100644
--- a/frontend/src/hooks/useMapSelectionExtract.ts
+++ b/frontend/src/hooks/useMapSelectionExtract.ts
@@ -4,6 +4,7 @@ import { formatError } from '../lib/formatError'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
+import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
interface MapSelectionExtractOptions {
selectedProjectId: string | null
@@ -42,8 +43,9 @@ export function useMapSelectionExtract({
}
const terrainDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'digitaal_vlaanderen_dhmv'
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.')
+ const thematicRasterDataset = selectedDataset.dataset_type === 'raster' && selectedDataset.source_name === 'department_omgeving_thematic_raster'
+ if (!isVectorDatasetType(selectedDataset.dataset_type) && !terrainDataset && !floodHazardDataset && !thematicRasterDataset) {
+ setMapSelectionError('Gebiedsanalyse ondersteunt een vectorlaag of een beheerd thematisch raster.')
return null
}
@@ -63,6 +65,11 @@ export function useMapSelectionExtract({
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
}))
+ : thematicRasterDataset
+ ? thematicRasterSelectionToMapSelection(await datasetsApi.selectThematicRaster(selectedProjectId, selectedDataset.id, {
+ bbox: { ...bbox, crs: 'EPSG:4326' },
+ area_id: areaId,
+ }))
: await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts
index 0a5cea43..150eb817 100644
--- a/frontend/src/hooks/useMapThemeSelectionInsights.ts
+++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts
@@ -4,6 +4,7 @@ import { datasetsApi } from '../services/api/datasets'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
+import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
export interface MapThemeQuery {
themeId: TThemeId
@@ -66,6 +67,11 @@ export function useMapThemeSelectionInsights(
bbox,
area_id: areaId,
}))
+ : dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster'
+ ? thematicRasterSelectionToMapSelection(await datasetsApi.selectThematicRaster(selectedProjectId, dataset.id, {
+ bbox,
+ area_id: areaId,
+ }))
: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts
index bb89ad63..04a610cc 100644
--- a/frontend/src/lib/datasetDisplay.ts
+++ b/frontend/src/lib/datasetDisplay.ts
@@ -9,8 +9,13 @@ const DATASET_LABEL_BY_LAYER: Record = {
forest: 'Bos en groen',
nature_value: 'Natuurwaarde',
agriculture: 'Landbouwgebruikspercelen',
+ soil: 'Digitale bodemkaart',
flood_hazard: 'Overstromingsgevaar',
elevation: 'Hoogte en reliëf',
+ space_occupation: 'Ruimtebeslag',
+ open_space: 'Open ruimte',
+ accessibility: 'Knooppuntwaarde',
+ services: 'Voorzieningenniveau',
building_registry: 'Gebouwenregister',
regional_boundary: 'Grens vervoerregio Kempen',
municipality_boundaries: 'Gemeentegrenzen Kempen',
@@ -29,6 +34,8 @@ const DATASET_SOURCE_LABELS: Record = {
vrbg: 'Digitaal Vlaanderen',
waterinfo: 'Waterinfo Vlaanderen',
vmm_flood_hazard: 'Vlaamse Milieumaatschappij',
+ department_omgeving_thematic_raster: 'Departement Omgeving',
+ dov_soil_map: 'Databank Ondergrond Vlaanderen',
}
export function getDatasetSourceDisplayName(dataset: DatasetCreateResponse): string {
@@ -45,6 +52,10 @@ export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
const productName = dataset.source_metadata?.['product_display_name']
return typeof productName === 'string' && productName.trim() ? productName : 'VMM-overstromingsscenario'
}
+ if (dataset.source_name === 'department_omgeving_thematic_raster') {
+ const productName = dataset.source_metadata?.['product_display_name']
+ return typeof productName === 'string' && productName.trim() ? productName : 'Officieel Vlaams themaraster'
+ }
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '')
.toString()
.toLowerCase()
diff --git a/frontend/src/lib/sourcePortfolio.ts b/frontend/src/lib/sourcePortfolio.ts
index db41ef45..3569442c 100644
--- a/frontend/src/lib/sourcePortfolio.ts
+++ b/frontend/src/lib/sourcePortfolio.ts
@@ -35,6 +35,9 @@ const sourceNameIs = (dataset: DatasetCreateResponse, sourceName: string): boole
const referenceLayerIs = (dataset: DatasetCreateResponse, layerName: string): boolean =>
String(dataset.reference_layer_name ?? dataset.source_metadata?.['theme'] ?? '').toLowerCase() === layerName
+const thematicProductIs = (dataset: DatasetCreateResponse, productKey: string): boolean =>
+ sourceNameIs(dataset, 'department_omgeving_thematic_raster') && dataset.source_metadata?.['product_key'] === productKey
+
export const SOURCE_DOMAINS: SourceDomainDefinition[] = [
{
key: 'space',
@@ -127,7 +130,7 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
metricExamples: 'hectare ruimtebeslag, aandeel, vergelijking met open ruimte',
priority: 'next',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/ruimtebeslag-vlaanderen-toestand-2025',
- matches: (dataset) => sourceNameIs(dataset, 'department_omgeving_space_occupation'),
+ matches: (dataset) => thematicProductIs(dataset, 'space_occupation_2025'),
},
{
key: 'settlement_typology',
@@ -137,7 +140,7 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
coverage: 'Toestand 2022',
value: 'Begrijpelijke morfologie voor kern, lint en verspreide bebouwing.',
metricExamples: 'oppervlakte en aandeel per morfologisch type',
- priority: 'planned',
+ priority: 'next',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/kernen-linten-verspreide-bebouwing-in-vlaanderen-kernen-toestand-2022',
matches: (dataset) => sourceNameIs(dataset, 'department_omgeving_settlement_typology'),
},
@@ -187,7 +190,7 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
metricExamples: 'hectare open ruimte, aandeel en fragmentatie',
priority: 'planned',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/open-ruimte-vlaanderen-toestand-2022',
- matches: (dataset) => sourceNameIs(dataset, 'department_omgeving_open_space'),
+ matches: (dataset) => thematicProductIs(dataset, 'open_space_2022'),
},
{
key: 'dhmv',
@@ -244,10 +247,10 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
owner: 'Departement Omgeving',
coverage: '1 ha raster, toestand 2022',
value: 'Modelmatige bereikbaarheid via collectief vervoer.',
- metricExamples: 'gemiddelde score en aandeel per bereikbaarheidsklasse',
+ metricExamples: 'gemiddelde, mediaan en percentielen van de bronindex',
priority: 'next',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/knooppuntwaarde-per-ha-toestand-2022',
- matches: (dataset) => sourceNameIs(dataset, 'department_omgeving_node_value'),
+ matches: (dataset) => thematicProductIs(dataset, 'node_value_2022'),
},
{
key: 'hoppin',
@@ -295,7 +298,7 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
metricExamples: 'geschat aantal inwoners en inwoners per hectare',
priority: 'next',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/inwonersdichtheid-per-ha-vlaanderen-toestand-2019',
- matches: (dataset) => sourceNameIs(dataset, 'department_omgeving_population_density'),
+ matches: (dataset) => thematicProductIs(dataset, 'population_density_2019'),
},
{
key: 'service_level',
@@ -304,10 +307,10 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
owner: 'Departement Omgeving',
coverage: '1 ha raster, toestand 2022',
value: 'Nabijheid van dagelijkse voorzieningen in één brongetrouwe score.',
- metricExamples: 'gemiddelde score en aandeel per voorzieningsklasse',
+ metricExamples: 'gemiddelde, mediaan en percentielen van de 0-1-score',
priority: 'next',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/totaal-voorzieningenniveau-toestand-2022',
- matches: (dataset) => sourceNameIs(dataset, 'department_omgeving_service_level'),
+ matches: (dataset) => thematicProductIs(dataset, 'service_level_2022'),
},
{
key: 'municipality_indicators',
diff --git a/frontend/src/lib/thematicRaster.ts b/frontend/src/lib/thematicRaster.ts
new file mode 100644
index 00000000..fe1af233
--- /dev/null
+++ b/frontend/src/lib/thematicRaster.ts
@@ -0,0 +1,27 @@
+import type { ThematicRasterSelectionResponse, VectorSelectionResponse } from '../types'
+
+export function thematicRasterImageUrl(projectId: string, datasetId: string): string {
+ return `/api/v1/projects/${projectId}/datasets/${datasetId}/raster/thematic/image`
+}
+
+export function thematicRasterSelectionToMapSelection(result: ThematicRasterSelectionResponse): VectorSelectionResponse {
+ return {
+ selection_bbox: result.selection_bbox,
+ selection_area_id: result.selection_area_id,
+ feature_count: result.valid_cell_count,
+ total_feature_count: result.valid_cell_count,
+ limit: 0,
+ truncated: false,
+ geojson: { type: 'FeatureCollection', features: [] },
+ summary: {
+ ...result.summary,
+ feature_count: result.valid_cell_count,
+ is_estimate: true,
+ warning: result.limitation_message,
+ metrics: result.summary.metrics.map((metric) => ({
+ ...metric,
+ is_estimate: metric.is_estimate ?? true,
+ })),
+ },
+ }
+}
diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts
index da030fba..96c54c04 100644
--- a/frontend/src/services/api/datasets.ts
+++ b/frontend/src/services/api/datasets.ts
@@ -24,6 +24,9 @@ import type {
FloodHazardSelectionResponse,
DhmvProductRead,
TerrainSelectionResponse,
+ ThematicRasterAcquireRequest,
+ ThematicRasterProductRead,
+ ThematicRasterSelectionResponse,
} from '../../types'
const DATASET_PAGE_SIZE = 200
@@ -141,6 +144,16 @@ export const datasetsApi = {
payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string },
): Promise =>
apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/flood-hazard/select`, payload),
+ acquireThematicRaster: (projectId: string, payload: ThematicRasterAcquireRequest): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/thematic-raster/acquire`, payload),
+ listThematicRasterProducts: (projectId: string): Promise<{ items: ThematicRasterProductRead[]; total: number }> =>
+ apiGet<{ items: ThematicRasterProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/thematic-raster/products`),
+ selectThematicRaster: (
+ projectId: string,
+ datasetId: string,
+ payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string },
+ ): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/thematic/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 91fffe11..6526984a 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -5751,13 +5751,18 @@ section {
}
.geo-theme-symbol-buildings { background: #d45f3d; }
+.geo-theme-symbol-space_occupation { background: #be3e33; }
+.geo-theme-symbol-open_space { background: #267a46; }
.geo-theme-symbol-population { background: #7559a6; }
.geo-theme-symbol-forest { background: #347950; }
.geo-theme-symbol-nature_value { background: #9a4f64; }
.geo-theme-symbol-agriculture { background: #7b8f32; }
+.geo-theme-symbol-soil { background: #9a7040; }
.geo-theme-symbol-water { background: #2676a8; }
.geo-theme-symbol-flood_hazard { background: #1597c2; }
.geo-theme-symbol-elevation { background: #a57a4b; }
+.geo-theme-symbol-accessibility { background: #0f766e; }
+.geo-theme-symbol-services { background: #b66d16; }
.geo-theme-symbol-roads { background: #6b7280; }
.geo-theme-symbol-parcels { background: #a7792f; }
@@ -5932,6 +5937,16 @@ section {
background: rgba(117, 89, 166, 0.24);
}
+.geo-map-legend .geo-legend-layer-space_occupation {
+ border-color: #8f2c24;
+ background: rgba(190, 62, 51, 0.28);
+}
+
+.geo-map-legend .geo-legend-layer-open_space {
+ border-color: #175c32;
+ background: rgba(38, 122, 70, 0.26);
+}
+
.geo-map-legend .geo-legend-layer-forest {
border-color: #225f3b;
background: rgba(52, 121, 80, 0.24);
@@ -5942,6 +5957,11 @@ section {
background: rgba(38, 118, 168, 0.24);
}
+.geo-map-legend .geo-legend-layer-soil {
+ border-color: #6f4c27;
+ background: rgba(154, 112, 64, 0.24);
+}
+
.geo-map-legend .geo-legend-layer-flood_hazard {
border-color: #075985;
background: rgba(21, 151, 194, 0.28);
@@ -5952,6 +5972,16 @@ section {
background: rgba(165, 122, 75, 0.26);
}
+.geo-map-legend .geo-legend-layer-accessibility {
+ border-color: #115e59;
+ background: rgba(15, 118, 110, 0.26);
+}
+
+.geo-map-legend .geo-legend-layer-services {
+ border-color: #854d0e;
+ background: rgba(182, 109, 22, 0.26);
+}
+
.geo-map-legend .geo-legend-layer-roads {
border-color: #4b5563;
background: rgba(107, 114, 128, 0.24);
@@ -5972,6 +6002,32 @@ section {
background: linear-gradient(135deg, #7a9b68 0 33%, #d1b37a 33% 66%, #8eb5cb 66%);
}
+.geo-map-legend .geo-legend-thematic {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.42rem;
+}
+
+.geo-map-legend .geo-legend-thematic small {
+ color: #42504c;
+ font-size: 0.66rem;
+ font-weight: 700;
+}
+
+.geo-map-legend .geo-legend-ramp {
+ display: block;
+ width: 3.4rem;
+ height: 0.62rem;
+ border: 1px solid rgba(23, 39, 34, 0.25);
+ border-radius: 2px;
+}
+
+.geo-legend-ramp-space_occupation { background: linear-gradient(90deg, #fbe7d3, #be3e33); }
+.geo-legend-ramp-open_space { background: linear-gradient(90deg, #ddeedb, #267a46); }
+.geo-legend-ramp-population { background: linear-gradient(90deg, #eee7f6, #673a97); }
+.geo-legend-ramp-accessibility { background: linear-gradient(90deg, #e9f1f4, #0f766e); }
+.geo-legend-ramp-services { background: linear-gradient(90deg, #fff4bf, #b66d16); }
+
.geo-map-legend .geo-legend-added {
border-color: #15803d;
background: rgba(22, 163, 74, 0.2);
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 71536518..db6d3499 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -434,6 +434,57 @@ export interface FloodHazardSelectionResponse {
generated_at: string
}
+export interface ThematicRasterAcquireRequest {
+ bbox: VectorSelectionBBox
+ area_id?: string | null
+ product_key: string
+ force_refresh?: boolean
+}
+
+export interface ThematicRasterProductRead {
+ key: string
+ display_name: string
+ theme: 'space_occupation' | 'open_space' | 'population' | 'accessibility' | 'services'
+ metric_kind: 'binary_area' | 'population_density' | 'index_score' | 'normalized_score'
+ coverage_id: string
+ native_resolution_m: number
+ source_crs: 'EPSG:31370'
+ source_value_unit: string
+ observation_year: number
+ source_version: string
+ catalog_url: string
+ attribution: string
+ license_note: string
+ legend_min_label: string
+ legend_max_label: string
+ limitation_message: string
+}
+
+export interface ThematicRasterSelectionResponse {
+ dataset_id: string
+ product_key: string
+ theme: ThematicRasterProductRead['theme']
+ metric_kind: ThematicRasterProductRead['metric_kind']
+ selection_bbox: VectorSelectionBBox
+ selection_area_id?: string | null
+ selected_cell_count: number
+ valid_cell_count: number
+ coverage_ratio: number
+ resolution_m: number
+ observation_year: number
+ summary: {
+ metric_label: string
+ metric_value: number
+ metric_unit: string
+ aggregation_method: string
+ primary_metric_key: string
+ metrics: Array
+ }
+ 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 b907c718..50534b6b 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -1613,6 +1613,33 @@ take a long time because every VMM WCS tile is bounded, rate-limited and
validated. This is expected operator work; the app never fetches these rasters
on page load or map click.
+## Cross-domain Mol profile
+
+Load the five official policy rasters for the exact Mol municipality Area and
+immediately verify each persisted selection result:
+
+```bash
+docker exec geointel python /app/scripts/provision_thematic_rasters.py
+```
+
+Plan the later complete Kempen rollout without source fetches or writes:
+
+```bash
+docker exec geointel python /app/scripts/provision_thematic_rasters.py \
+ --project-name "Kempen Regional Workbench" --all-municipalities --dry-run
+```
+
+Load the official DOV soil map for Mol:
+
+```bash
+docker exec geointel python /app/scripts/provision_mol_soil_map.py
+```
+
+`--fetch-only` builds the soil artifact and manifest without API import;
+`--force` is the only way to bypass an existing ready soil Dataset. Both
+operators use canonical APIs and persistent operator-evidence storage. They do
+not run on application startup.
+
## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
diff --git a/scripts/provision_mol_soil_map.py b/scripts/provision_mol_soil_map.py
new file mode 100644
index 00000000..a738920d
--- /dev/null
+++ b/scripts/provision_mol_soil_map.py
@@ -0,0 +1,661 @@
+"""Provision the official DOV digital soil map for the municipality of Mol.
+
+The operator follows every bounded WFS page, retains checksummed source
+responses, clips soil polygons to the persisted Mol Area in EPSG:31370 and
+imports the result through GeoIntel's canonical dataset upload route. It does
+not write directly to vector_features and it does not treat the historical
+1949-1971 field survey as a current drainage observation.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+from collections import defaultdict
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterable
+
+import requests
+from pyproj import Transformer
+from requests.adapters import HTTPAdapter
+from shapely.geometry import MultiPolygon, Polygon, mapping, shape
+from shapely.ops import transform as transform_geometry
+from shapely.ops import unary_union
+from shapely.validation import make_valid
+from urllib3.util.retry import Retry
+
+
+WFS_URL = "https://www.dov.vlaanderen.be/geoserver/wfs"
+CATALOG_URL = (
+ "https://www.vlaanderen.be/datavindplaats/catalogus/"
+ "digitale-bodemkaart-van-het-vlaams-gewest-bodemtypes"
+)
+TYPE_NAME = "bodemkaart:bodemtypes"
+SOURCE_NAME = "dov_soil_map"
+SOURCE_VERSION = "Digitale uitgave juni 2017"
+SURVEY_PERIOD = "1949-1971"
+OBSERVED_AT = "1971-12-31T23:59:59Z"
+VALID_FROM = "1949-01-01T00:00:00Z"
+VALID_TO = OBSERVED_AT
+ATTRIBUTION = "Databank Ondergrond Vlaanderen - Digitale bodemkaart: bodemtypes"
+DEFAULT_API_URL = "http://127.0.0.1:8000"
+DEFAULT_OUTPUT_DIR = "/app/storage/operator-evidence/dov-soil-map/mol"
+DEFAULT_PROJECT_NAME = "Mol Municipality Workbench"
+DEFAULT_AREA_FRAGMENT = "Gemeente Mol"
+DATASET_FILENAME = "dov_soil_map_mol.geojson"
+MANIFEST_FILENAME = "dov_soil_map_mol.manifest.json"
+SCHEMA_VERSION = 1
+
+TO_LAMBERT72 = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
+TO_WGS84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Provision the official DOV soil map 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-fragment", default=DEFAULT_AREA_FRAGMENT)
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=Path(os.environ.get("GEOINTEL_SOIL_MAP_OUTPUT_DIR", DEFAULT_OUTPUT_DIR)),
+ )
+ parser.add_argument("--page-limit", type=int, default=500)
+ parser.add_argument("--max-features", type=int, default=20_000)
+ parser.add_argument("--request-timeout", type=int, default=180)
+ parser.add_argument("--import-timeout", type=int, default=1800)
+ parser.add_argument("--force", action="store_true")
+ parser.add_argument("--fetch-only", action="store_true")
+ return parser.parse_args()
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def sha256_bytes(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def write_bytes_atomic(path: Path, value: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_bytes(value)
+ temporary.replace(path)
+
+
+def write_json_atomic(path: Path, value: Any, *, pretty: bool = False) -> None:
+ encoded = json.dumps(
+ value,
+ ensure_ascii=False,
+ indent=2 if pretty else None,
+ separators=None if pretty else (",", ":"),
+ ).encode("utf-8")
+ write_bytes_atomic(path, encoded)
+
+
+def source_session() -> requests.Session:
+ retry = Retry(
+ total=5,
+ connect=5,
+ read=5,
+ status=5,
+ backoff_factor=1.0,
+ status_forcelist=(429, 500, 502, 503, 504),
+ allowed_methods=frozenset({"GET"}),
+ raise_on_status=True,
+ )
+ session = requests.Session()
+ session.headers.update({"User-Agent": "GeoIntel-DOV-Soil-Mol-Operator/1.0"})
+ adapter = HTTPAdapter(max_retries=retry)
+ session.mount("https://", adapter)
+ session.mount("http://", adapter)
+ return session
+
+
+def response_data(response: requests.Response) -> Any:
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ raise RuntimeError(f"GeoIntel API returned non-JSON ({response.status_code}): {response.text[:300]}") from exc
+ if not response.ok:
+ raise RuntimeError(
+ f"GeoIntel API failed ({response.status_code}): {json.dumps(payload, ensure_ascii=False)[:800]}"
+ )
+ if not isinstance(payload, dict) or "data" not in payload:
+ raise RuntimeError("GeoIntel API response does not use the canonical data envelope")
+ return payload["data"]
+
+
+def paginated_items(session: requests.Session, url: str, *, timeout: int) -> list[dict[str, Any]]:
+ items: list[dict[str, Any]] = []
+ offset = 0
+ total: int | None = None
+ while total is None or offset < total:
+ page = response_data(session.get(url, params={"limit": 200, "offset": offset}, timeout=timeout))
+ page_items = list(page.get("items") or [])
+ page_total = int(page.get("total") or 0)
+ if total is None:
+ total = page_total
+ elif page_total != total:
+ raise RuntimeError("GeoIntel pagination total changed while locating the Mol workspace")
+ items.extend(page_items)
+ if not page_items:
+ break
+ offset += len(page_items)
+ if total is not None and len(items) != total:
+ raise RuntimeError(f"GeoIntel pagination returned {len(items)} of {total} items")
+ return items
+
+
+def polygonal_geometry(geometry):
+ if geometry is None or geometry.is_empty:
+ return None
+ if not geometry.is_valid:
+ geometry = make_valid(geometry)
+ polygons: list[Polygon] = []
+
+ def collect(candidate) -> None:
+ if candidate is None or candidate.is_empty:
+ return
+ if isinstance(candidate, Polygon):
+ polygons.append(candidate)
+ elif isinstance(candidate, MultiPolygon):
+ polygons.extend(part for part in candidate.geoms if not part.is_empty)
+ elif hasattr(candidate, "geoms"):
+ for part in candidate.geoms:
+ collect(part)
+
+ collect(geometry)
+ if not polygons:
+ return None
+ result = unary_union(polygons)
+ if not result.is_valid:
+ result = make_valid(result)
+ return result if not result.is_empty and result.is_valid else None
+
+
+def locate_workspace(
+ session: requests.Session,
+ base_url: str,
+ project_name: str,
+ area_fragment: str,
+ timeout: int,
+) -> tuple[str, str, Any, list[dict[str, Any]]]:
+ projects = paginated_items(session, f"{base_url}/api/v1/projects", timeout=timeout)
+ project = next((item for item in projects if item.get("name") == project_name), None)
+ if not project:
+ raise RuntimeError(f"Project {project_name!r} is missing")
+ project_id = str(project["id"])
+ areas = paginated_items(session, f"{base_url}/api/v1/projects/{project_id}/areas", timeout=timeout)
+ area = next(
+ (item for item in areas if area_fragment.casefold() in str(item.get("name") or "").casefold()),
+ None,
+ )
+ if not area or not area.get("geometry"):
+ raise RuntimeError(f"Persisted Mol Area containing {area_fragment!r} is missing")
+ boundary_wgs84 = polygonal_geometry(shape(area["geometry"]))
+ if boundary_wgs84 is None:
+ raise RuntimeError("Persisted Mol Area is not valid polygonal geometry")
+ datasets = paginated_items(session, f"{base_url}/api/v1/projects/{project_id}/datasets", timeout=timeout)
+ return project_id, str(area["id"]), boundary_wgs84, datasets
+
+
+def iter_wfs_pages(
+ session: requests.Session,
+ bbox_lambert72: tuple[float, float, float, float],
+ *,
+ page_limit: int,
+ timeout: int,
+) -> Iterable[tuple[dict[str, Any], str, bytes]]:
+ start_index = 0
+ expected_total: int | None = None
+ while True:
+ params = {
+ "service": "WFS",
+ "version": "2.0.0",
+ "request": "GetFeature",
+ "typeNames": TYPE_NAME,
+ "srsName": "EPSG:4326",
+ "bbox": ",".join(f"{value:.3f}" for value in bbox_lambert72) + ",EPSG:31370",
+ "count": str(page_limit),
+ "startIndex": str(start_index),
+ "sortBy": "gid",
+ "outputFormat": "application/json",
+ }
+ response = session.get(WFS_URL, params=params, timeout=timeout)
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection":
+ raise RuntimeError("DOV WFS returned an invalid FeatureCollection")
+ features = list(payload.get("features") or [])
+ matched = int(payload.get("numberMatched") or payload.get("totalFeatures") or 0)
+ if expected_total is None:
+ expected_total = matched
+ elif matched != expected_total:
+ raise RuntimeError("DOV WFS numberMatched changed during pagination")
+ yield payload, response.url, response.content
+ returned = int(payload.get("numberReturned") or len(features))
+ if returned != len(features):
+ raise RuntimeError("DOV WFS numberReturned does not match its feature payload")
+ start_index += returned
+ if returned == 0 or start_index >= expected_total:
+ if start_index != expected_total:
+ raise RuntimeError(f"DOV WFS returned {start_index} of {expected_total} matched features")
+ break
+
+
+def normalize_feature(feature: dict[str, Any], boundary_lambert72) -> tuple[dict[str, Any] | None, bool]:
+ geometry_payload = feature.get("geometry")
+ if not geometry_payload:
+ return None, False
+ source_wgs84 = polygonal_geometry(shape(geometry_payload))
+ if source_wgs84 is None:
+ return None, False
+ source_lambert72 = polygonal_geometry(transform_geometry(TO_LAMBERT72.transform, source_wgs84))
+ if source_lambert72 is None or not source_lambert72.intersects(boundary_lambert72):
+ return None, False
+ was_clipped = not source_lambert72.within(boundary_lambert72)
+ clipped_lambert72 = polygonal_geometry(source_lambert72.intersection(boundary_lambert72))
+ if clipped_lambert72 is None or clipped_lambert72.area <= 0:
+ return None, was_clipped
+ clipped_wgs84 = polygonal_geometry(transform_geometry(TO_WGS84.transform, clipped_lambert72))
+ if clipped_wgs84 is None:
+ return None, was_clipped
+
+ raw = dict(feature.get("properties") or {})
+ gid = raw.get("gid")
+ map_polygon_id = raw.get("id_kaartvlak")
+ source_id = str(feature.get("id") or f"{TYPE_NAME}:{gid or map_polygon_id}")
+ properties = {
+ "source_name": SOURCE_NAME,
+ "source_collection": TYPE_NAME,
+ "source_feature_id": source_id,
+ "source_gid": gid,
+ "source_map_polygon_id": map_polygon_id,
+ "reference_layer_name": "soil",
+ "theme": "soil",
+ "authority_level": "authoritative_historical_baseline",
+ "coverage_scope": "municipality",
+ "municipality": "Mol",
+ "nis_code": "13025",
+ "source_version": SOURCE_VERSION,
+ "survey_period": SURVEY_PERIOD,
+ "soil_type_code": raw.get("Bodemtype"),
+ "unified_soil_type_code": raw.get("Unibodemtype"),
+ "soil_series_code": raw.get("Bodemserie"),
+ "soil_series_description": raw.get("Beknopte_omschrijving_bodemserie"),
+ "soil_generalized_legend": raw.get("Gegeneraliseerde_legende"),
+ "soil_texture_class_code": raw.get("Textuurklasse_code"),
+ "soil_texture_class": raw.get("Textuurklasse"),
+ "soil_drainage_class_code": raw.get("Drainageklasse_code"),
+ "soil_drainage_class": raw.get("Drainageklasse"),
+ "soil_profile_group_code": raw.get("Profielontwikkelingsgroep_code"),
+ "soil_profile_group": raw.get("Profielontwikkelingsgroep"),
+ "soil_substrate_code": raw.get("Substraat_code"),
+ "soil_substrate": raw.get("Substraat_Vlaanderen") or raw.get("Substraat_legende"),
+ "soil_region": raw.get("Streek"),
+ "classification_type": raw.get("Type_classificatie"),
+ "soil_map_title": raw.get("Eenduidige_legende_titel"),
+ "clipped_area_ha": round(float(clipped_lambert72.area) / 10_000.0, 8),
+ "attribution": ATTRIBUTION,
+ "historical_drainage_limitation": (
+ "Drainage class derives from field data collected between 1949 and 1971 and may differ today."
+ ),
+ }
+ return {
+ "type": "Feature",
+ "id": source_id,
+ "geometry": mapping(clipped_wgs84),
+ "properties": properties,
+ }, was_clipped
+
+
+def prepare_artifact(
+ session: requests.Session,
+ boundary_wgs84,
+ output_dir: Path,
+ *,
+ page_limit: int,
+ max_features: int,
+ timeout: int,
+) -> tuple[Path, Path, dict[str, Any]]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ raw_dir = output_dir / "raw"
+ raw_dir.mkdir(parents=True, exist_ok=True)
+ boundary_lambert72 = polygonal_geometry(transform_geometry(TO_LAMBERT72.transform, boundary_wgs84))
+ if boundary_lambert72 is None:
+ raise RuntimeError("Mol boundary could not be transformed to EPSG:31370")
+
+ retained: list[dict[str, Any]] = []
+ raw_pages: list[dict[str, Any]] = []
+ source_urls: list[str] = []
+ seen_ids: set[str] = set()
+ raw_feature_count = 0
+ duplicate_count = 0
+ rejected_count = 0
+ clipped_count = 0
+ area_by_legend: dict[str, float] = defaultdict(float)
+ area_by_texture: dict[str, float] = defaultdict(float)
+ area_by_drainage: dict[str, float] = defaultdict(float)
+
+ for page_number, (payload, source_url, raw_bytes) in enumerate(
+ iter_wfs_pages(
+ session,
+ boundary_lambert72.bounds,
+ page_limit=page_limit,
+ timeout=timeout,
+ ),
+ start=1,
+ ):
+ page_path = raw_dir / f"dov_soil_map_page_{page_number:05d}.json"
+ write_bytes_atomic(page_path, raw_bytes)
+ features = list(payload.get("features") or [])
+ raw_feature_count += len(features)
+ if raw_feature_count > max_features:
+ raise RuntimeError(
+ f"DOV WFS exceeded the {max_features} feature safety limit; refusing a truncated import"
+ )
+ raw_pages.append(
+ {
+ "path": str(page_path.relative_to(output_dir)),
+ "sha256": sha256_bytes(raw_bytes),
+ "size_bytes": len(raw_bytes),
+ "feature_count": len(features),
+ "source_url": source_url,
+ }
+ )
+ source_urls.append(source_url)
+ for feature in features:
+ raw = dict(feature.get("properties") or {})
+ source_id = str(feature.get("id") or f"{TYPE_NAME}:{raw.get('gid')}")
+ if source_id in seen_ids:
+ duplicate_count += 1
+ continue
+ seen_ids.add(source_id)
+ normalized, was_clipped = normalize_feature(feature, boundary_lambert72)
+ if normalized is None:
+ rejected_count += 1
+ continue
+ if was_clipped:
+ clipped_count += 1
+ retained.append(normalized)
+ properties = normalized["properties"]
+ area = float(properties["clipped_area_ha"])
+ area_by_legend[str(properties.get("soil_generalized_legend") or "Onbekend")] += area
+ area_by_texture[str(properties.get("soil_texture_class") or "Onbekend")] += area
+ area_by_drainage[str(properties.get("soil_drainage_class") or "Onbekend")] += area
+
+ if not retained:
+ raise RuntimeError("DOV WFS returned no valid soil polygons inside the persisted Mol Area")
+ generated_at = utc_now()
+ artifact = {
+ "type": "FeatureCollection",
+ "name": "Digitale bodemkaart - Gemeente Mol",
+ "features": retained,
+ "source": ATTRIBUTION,
+ "source_version": SOURCE_VERSION,
+ "survey_period": SURVEY_PERIOD,
+ "catalog_url": CATALOG_URL,
+ "generated_at": generated_at,
+ }
+ artifact_path = output_dir / DATASET_FILENAME
+ write_json_atomic(artifact_path, artifact)
+ manifest = {
+ "schema_version": SCHEMA_VERSION,
+ "source_version": SOURCE_VERSION,
+ "survey_period": SURVEY_PERIOD,
+ "source_type_name": TYPE_NAME,
+ "wfs_url": WFS_URL,
+ "catalog_url": CATALOG_URL,
+ "attribution": ATTRIBUTION,
+ "generated_at": generated_at,
+ "crs_source_service": "EPSG:31370",
+ "crs_response_and_persisted": "EPSG:4326",
+ "crs_clip_and_area_measurement": "EPSG:31370",
+ "boundary_sha256": sha256_bytes(json.dumps(mapping(boundary_wgs84), sort_keys=True).encode("utf-8")),
+ "boundary_bbox_wgs84": list(boundary_wgs84.bounds),
+ "boundary_bbox_epsg31370": list(boundary_lambert72.bounds),
+ "page_limit": page_limit,
+ "page_count": len(raw_pages),
+ "raw_source_feature_count": raw_feature_count,
+ "feature_count": len(retained),
+ "duplicate_count": duplicate_count,
+ "rejected_or_outside_count": rejected_count,
+ "clipped_feature_count": clipped_count,
+ "reference_truncated": False,
+ "raw_pages": raw_pages,
+ "source_urls": source_urls,
+ "area_by_generalized_legend_ha": {key: round(value, 6) for key, value in sorted(area_by_legend.items())},
+ "area_by_texture_ha": {key: round(value, 6) for key, value in sorted(area_by_texture.items())},
+ "area_by_drainage_ha": {key: round(value, 6) for key, value in sorted(area_by_drainage.items())},
+ "artifact_path": str(artifact_path),
+ "artifact_sha256": sha256_file(artifact_path),
+ "artifact_size_bytes": artifact_path.stat().st_size,
+ "limitations": [
+ "The map is based on field data collected between 1949 and 1971.",
+ "Current drainage, land use and local soil disturbance may differ from the mapped class.",
+ "The 1:20,000 source is contextual evidence and not a parcel-scale soil investigation.",
+ ],
+ }
+ manifest_path = output_dir / MANIFEST_FILENAME
+ write_json_atomic(manifest_path, manifest, pretty=True)
+ return artifact_path, manifest_path, manifest
+
+
+def selection_metrics() -> list[dict[str, Any]]:
+ return [
+ {
+ "metric_key": "soil_dry_sand_area",
+ "method": "intersection_area",
+ "label": "Gekarteerd als droog zand",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Droog zand", "Zeer droog zand"],
+ },
+ {
+ "metric_key": "soil_moist_sand_area",
+ "method": "intersection_area",
+ "label": "Gekarteerd als vochtig zand",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Vochtig zand"],
+ },
+ {
+ "metric_key": "soil_wet_sand_area",
+ "method": "intersection_area",
+ "label": "Gekarteerd als nat zand",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Nat zand", "Zeer nat zand"],
+ },
+ {
+ "metric_key": "soil_anthropogenic_area",
+ "method": "intersection_area",
+ "label": "Antropogene bodemklasse",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Antropogeen"],
+ },
+ ]
+
+
+def upload_artifact(
+ session: requests.Session,
+ *,
+ base_url: str,
+ project_id: str,
+ area_id: str,
+ artifact_path: Path,
+ manifest_path: Path,
+ manifest: dict[str, Any],
+ timeout: int,
+) -> dict[str, Any]:
+ limitation = (
+ "Historische bodemkartering op schaal 1:20.000 op basis van veldwerk 1949-1971; "
+ "de huidige drainage en lokale bodemtoestand kunnen afwijken."
+ )
+ source_metadata = {
+ "provider": SOURCE_NAME,
+ "theme": "soil",
+ "layer_type": "soil",
+ "source_collection": TYPE_NAME,
+ "source_crs": "EPSG:31370",
+ "persisted_crs": "EPSG:4326",
+ "authority_level": "authoritative_historical_baseline",
+ "coverage_scope": "municipality",
+ "municipality": "Mol",
+ "nis_code": "13025",
+ "feature_count": manifest["feature_count"],
+ "geometry_clipped_to_area": True,
+ "semantic_metrics": False,
+ "survey_period": SURVEY_PERIOD,
+ "source_scale": "1:20,000",
+ "attribution": ATTRIBUTION,
+ "catalog_url": CATALOG_URL,
+ "license_note": "DOV standard attribution and public GDI reuse conditions apply.",
+ "limitation_message": limitation,
+ "selection_aggregation": {
+ "metric_key": "soil_mapped_area",
+ "method": "intersection_area",
+ "label": "Bodemkaartoppervlakte",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "warning": limitation,
+ },
+ "selection_metrics": selection_metrics(),
+ }
+ provenance_metadata = {
+ "operator_tool": "provision_mol_soil_map.py",
+ "operator_explicit_fetch": True,
+ "geometry_clipped_to_area": True,
+ "source_type_name": TYPE_NAME,
+ "wfs_url": WFS_URL,
+ "catalog_url": CATALOG_URL,
+ "manifest_path": str(manifest_path),
+ "artifact_sha256": manifest["artifact_sha256"],
+ "raw_page_checksums": {page["path"]: page["sha256"] for page in manifest["raw_pages"]},
+ "source_urls": manifest["source_urls"],
+ "reference_truncated": False,
+ "generated_at": manifest["generated_at"],
+ "limitations": manifest["limitations"],
+ }
+ with artifact_path.open("rb") as handle:
+ response = session.post(
+ f"{base_url}/api/v1/projects/{project_id}/datasets/upload",
+ data={
+ "dataset_type": "vector",
+ "source": "operator_official_import",
+ "dataset_role": "reference",
+ "source_name": SOURCE_NAME,
+ "reference_layer_name": "soil",
+ "source_metadata_json": json.dumps(source_metadata, ensure_ascii=False),
+ "provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False),
+ "area_id": area_id,
+ "temporal_series_key": "dov:digital-soil-map:mol",
+ "observed_at": OBSERVED_AT,
+ "valid_from": VALID_FROM,
+ "valid_to": VALID_TO,
+ "temporal_granularity": "period",
+ "source_version": SOURCE_VERSION,
+ },
+ files={"file": (artifact_path.name, handle, "application/geo+json")},
+ timeout=timeout,
+ )
+ return response_data(response)
+
+
+def main() -> int:
+ args = parse_args()
+ if args.page_limit < 1 or args.page_limit > 2000 or args.max_features < args.page_limit:
+ print(json.dumps({"status": "error", "message": "Invalid page or feature safety limits"}), file=sys.stderr)
+ return 2
+ base_url = args.base_url.rstrip("/")
+ api_session = requests.Session()
+ try:
+ project_id, area_id, boundary, datasets = locate_workspace(
+ api_session,
+ base_url,
+ args.project_name,
+ args.area_fragment,
+ args.request_timeout,
+ )
+ existing = next(
+ (
+ item
+ for item in datasets
+ if item.get("source_name") == SOURCE_NAME
+ and str(item.get("area_id") or "") == area_id
+ and item.get("status") == "ready"
+ ),
+ None,
+ )
+ if existing and not args.force:
+ result = {
+ "status": "reused",
+ "project_id": project_id,
+ "area_id": area_id,
+ "dataset_id": existing["id"],
+ "feature_count": existing.get("feature_count"),
+ }
+ else:
+ artifact_path, manifest_path, manifest = prepare_artifact(
+ source_session(),
+ boundary,
+ args.output_dir,
+ page_limit=args.page_limit,
+ max_features=args.max_features,
+ timeout=args.request_timeout,
+ )
+ if args.fetch_only:
+ result = {
+ "status": "prepared",
+ "project_id": project_id,
+ "area_id": area_id,
+ "artifact_path": str(artifact_path),
+ "feature_count": manifest["feature_count"],
+ }
+ else:
+ dataset = upload_artifact(
+ api_session,
+ base_url=base_url,
+ project_id=project_id,
+ area_id=area_id,
+ artifact_path=artifact_path,
+ manifest_path=manifest_path,
+ manifest=manifest,
+ timeout=args.import_timeout,
+ )
+ result = {
+ "status": "imported",
+ "project_id": project_id,
+ "area_id": area_id,
+ "dataset_id": dataset["id"],
+ "feature_count": dataset.get("feature_count") or manifest["feature_count"],
+ "artifact_path": str(artifact_path),
+ }
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+ return 0
+ except (OSError, RuntimeError, requests.RequestException, ValueError) as exc:
+ print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/provision_thematic_rasters.py b/scripts/provision_thematic_rasters.py
new file mode 100644
index 00000000..0781c3a5
--- /dev/null
+++ b/scripts/provision_thematic_rasters.py
@@ -0,0 +1,182 @@
+"""Provision governed Flemish thematic rasters through the GeoIntel API.
+
+The safe default loads all five products for the official Mol municipality
+Area. Use --all-municipalities with an explicitly named regional project to
+load every persisted municipality Area. The operator never writes to PostGIS
+or storage directly and never accepts an arbitrary external service URL.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from typing import Any
+
+import requests
+
+
+DEFAULT_API_URL = "http://127.0.0.1:8000"
+DEFAULT_PROJECT_NAME = "Mol Municipality Workbench"
+DEFAULT_AREA_FRAGMENT = "Gemeente Mol"
+DEFAULT_PRODUCTS = (
+ "space_occupation_2025",
+ "open_space_2022",
+ "population_density_2019",
+ "node_value_2022",
+ "service_level_2022",
+)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Provision governed Flemish thematic raster products.")
+ 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", default=DEFAULT_AREA_FRAGMENT, help="Case-insensitive Area name fragment.")
+ parser.add_argument("--products", default=",".join(DEFAULT_PRODUCTS))
+ parser.add_argument("--all-municipalities", action="store_true", help="Process every Area whose name starts with 'Gemeente '.")
+ parser.add_argument("--force-refresh", action="store_true")
+ parser.add_argument("--timeout", type=int, default=900)
+ parser.add_argument("--dry-run", action="store_true")
+ return parser.parse_args()
+
+
+def unwrap(response: requests.Response) -> Any:
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ raise RuntimeError(f"GeoIntel returned non-JSON HTTP {response.status_code}: {response.text[:300]}") from exc
+ if not response.ok:
+ error = payload.get("error") if isinstance(payload, dict) else None
+ message = error.get("message") if isinstance(error, dict) else response.text[:300]
+ raise RuntimeError(f"GeoIntel HTTP {response.status_code}: {message}")
+ return payload.get("data") if isinstance(payload, dict) and "data" in payload else payload
+
+
+def paged_items(session: requests.Session, url: str, timeout: int) -> list[dict[str, Any]]:
+ items: list[dict[str, Any]] = []
+ offset = 0
+ while True:
+ separator = "&" if "?" in url else "?"
+ page = unwrap(session.get(f"{url}{separator}limit=200&offset={offset}", timeout=timeout))
+ rows = list(page.get("items") or [])
+ items.extend(rows)
+ total = int(page.get("total") or 0)
+ if not rows or len(items) >= total:
+ return items
+ offset += len(rows)
+
+
+def geometry_bbox(geometry: dict[str, Any]) -> dict[str, Any]:
+ points: list[tuple[float, float]] = []
+
+ def visit(value: Any) -> None:
+ if isinstance(value, list) and len(value) >= 2 and all(isinstance(item, (int, float)) for item in value[:2]):
+ points.append((float(value[0]), float(value[1])))
+ return
+ if isinstance(value, list):
+ for item in value:
+ visit(item)
+
+ visit(geometry.get("coordinates"))
+ if not points:
+ raise RuntimeError("Persisted Area geometry contains no coordinates")
+ return {
+ "min_x": min(point[0] for point in points),
+ "min_y": min(point[1] for point in points),
+ "max_x": max(point[0] for point in points),
+ "max_y": max(point[1] for point in points),
+ "crs": "EPSG:4326",
+ }
+
+
+def find_project(projects: list[dict[str, Any]], name: str) -> dict[str, Any]:
+ matches = [project for project in projects if str(project.get("name", "")).casefold() == name.casefold()]
+ if len(matches) != 1:
+ raise RuntimeError(f"Expected exactly one project named {name!r}, found {len(matches)}")
+ return matches[0]
+
+
+def select_areas(areas: list[dict[str, Any]], fragment: str, all_municipalities: bool) -> list[dict[str, Any]]:
+ if all_municipalities:
+ selected = [area for area in areas if str(area.get("name", "")).casefold().startswith("gemeente ")]
+ else:
+ selected = [area for area in areas if fragment.casefold() in str(area.get("name", "")).casefold()]
+ if not selected:
+ raise RuntimeError("No persisted Area matches the requested scope")
+ selected.sort(key=lambda item: str(item.get("name", "")).casefold())
+ return selected
+
+
+def main() -> int:
+ args = parse_args()
+ base_url = args.base_url.rstrip("/")
+ requested_products = [value.strip() for value in args.products.split(",") if value.strip()]
+ if not requested_products:
+ raise RuntimeError("Select at least one thematic raster product")
+ session = requests.Session()
+ session.headers.update({"User-Agent": "GeoIntel-Thematic-Raster-Operator/1.0"})
+
+ projects = paged_items(session, f"{base_url}/api/v1/projects", args.timeout)
+ project = find_project(projects, args.project_name)
+ project_id = str(project["id"])
+ areas = paged_items(session, f"{base_url}/api/v1/projects/{project_id}/areas", args.timeout)
+ selected_areas = select_areas(areas, args.area, args.all_municipalities)
+ registry = unwrap(session.get(f"{base_url}/api/v1/projects/{project_id}/datasets/thematic-raster/products", timeout=args.timeout))
+ products = {str(item["key"]): item for item in registry.get("items") or []}
+ unknown = sorted(set(requested_products) - set(products))
+ if unknown:
+ raise RuntimeError(f"Products are not present in the canonical registry: {', '.join(unknown)}")
+
+ print(json.dumps({
+ "status": "planned" if args.dry_run else "running",
+ "project_id": project_id,
+ "project_name": project["name"],
+ "area_count": len(selected_areas),
+ "products": requested_products,
+ }, ensure_ascii=False))
+ if args.dry_run:
+ for area in selected_areas:
+ print(json.dumps({"area_id": area["id"], "area_name": area["name"], "bbox": geometry_bbox(area["geometry"])}, ensure_ascii=False))
+ return 0
+
+ results: list[dict[str, Any]] = []
+ for area in selected_areas:
+ bbox = geometry_bbox(area["geometry"])
+ for product_key in requested_products:
+ acquisition = unwrap(session.post(
+ f"{base_url}/api/v1/projects/{project_id}/datasets/thematic-raster/acquire",
+ json={
+ "bbox": bbox,
+ "area_id": area["id"],
+ "product_key": product_key,
+ "force_refresh": args.force_refresh,
+ },
+ timeout=args.timeout,
+ ))
+ if acquisition.get("status") != "success" or not acquisition.get("output_dataset_id"):
+ raise RuntimeError(f"Acquisition failed for {area['name']} / {product_key}: {acquisition}")
+ dataset_id = str(acquisition["output_dataset_id"])
+ analysis = unwrap(session.post(
+ f"{base_url}/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/thematic/select",
+ json={"bbox": bbox, "area_id": area["id"]},
+ timeout=args.timeout,
+ ))
+ result = {
+ "area_id": area["id"],
+ "area_name": area["name"],
+ "product_key": product_key,
+ "dataset_id": dataset_id,
+ "reused": bool((acquisition.get("result_json") or {}).get("reused")),
+ "metric": analysis.get("summary"),
+ "coverage_ratio": analysis.get("coverage_ratio"),
+ }
+ results.append(result)
+ print(json.dumps(result, ensure_ascii=False))
+
+ print(json.dumps({"status": "complete", "dataset_count": len(results), "project_id": project_id}, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh
index 6a9a2574..77e2e15c 100755
--- a/scripts/run_readiness_check.sh
+++ b/scripts/run_readiness_check.sh
@@ -56,6 +56,8 @@ ${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_flood_hazards.py
+${PYTHON_BIN} -m py_compile scripts/provision_thematic_rasters.py
+${PYTHON_BIN} -m py_compile scripts/provision_mol_soil_map.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