= {
administrative: { fill: '#5f6f7f', line: '#344554' },
buildings: { fill: '#d45f3d', line: '#9f3e24' },
+ land_cover: { fill: '#4f7b4f', line: '#315c39' },
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
open_space: { fill: '#267a46', line: '#175c32' },
population: { fill: '#7559a6', line: '#5b3f88' },
@@ -358,6 +368,12 @@ function datasetAvailabilityLabel(
const resolutionLabel = Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'
return `${resolutionLabel} officiële bron${Number.isFinite(year) ? ` · ${year}` : ''}`
}
+ if (dataset.dataset_type === 'raster' && dataset.source_name === 'spw_walous_land_cover') {
+ const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
+ const year = Number(dataset.source_metadata?.['observation_year'])
+ const resolutionLabel = Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'
+ return `${resolutionLabel} WALOUS-landbedekking${Number.isFinite(year) ? ` · ${year}` : ''}`
+ }
if (dataset.source_name === 'ngi_adminvector') {
return `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} officiële bestuursgebieden`
}
@@ -406,6 +422,9 @@ function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme):
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dataset.source_metadata?.['theme'] === theme.id
}
+ if (dataset.source_name === 'spw_walous_land_cover') {
+ return theme.id === 'land_cover'
+ }
if (dataset.source_name === 'dov_soil_map') {
return theme.id === 'soil'
}
@@ -522,6 +541,7 @@ function pickThemeDataset(
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
+ (dataset.source_name === 'spw_walous_land_cover' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
@@ -636,6 +656,10 @@ function formatDatasetObservation(dataset: DatasetCreateResponse): string {
return `referentiejaar ${observationYear}`
}
}
+ if (dataset.source_name === 'spw_walous_land_cover') {
+ const observationYear = Number(dataset.source_metadata?.['observation_year'])
+ return Number.isFinite(observationYear) ? `WALOUS referentiejaar ${observationYear}` : 'WALOUS landbedekking'
+ }
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
@@ -1085,6 +1109,29 @@ export function MapWorkspace({
})
}
}
+ const latestWalous = officialMapProducts.walous
+ .filter((product) => product.configured && productCoversZones(product.coverage_zones, effectiveZones))
+ .sort((left, right) => right.observation_year - left.observation_year)[0]
+ if (latestWalous) {
+ result.push({
+ kind: 'walous',
+ productKey: latestWalous.key,
+ historyProductKeys: officialMapProducts.walous
+ .filter(
+ (product) =>
+ product.configured
+ && product.key !== latestWalous.key
+ && productCoversZones(product.coverage_zones, effectiveZones),
+ )
+ .map((product) => product.key),
+ displayName: latestWalous.display_name,
+ theme: 'land_cover',
+ availabilityLabel: `${latestWalous.analysis_resolution_m ?? latestWalous.native_resolution_m} m analyse · ${latestWalous.observation_year} · automatisch bij selectie`,
+ attribution: latestWalous.attribution,
+ limitationMessage: latestWalous.limitation_message,
+ coverageZones: latestWalous.coverage_zones,
+ })
+ }
for (const product of officialMapProducts.officialVector.filter((item) =>
productCoversZones(item.coverage_zones, effectiveZones),
)) {
@@ -1319,6 +1366,20 @@ export function MapWorkspace({
: [],
[activeThemeDataset, selectedProjectId, thematicRasterBounds],
)
+ const walousRasterBounds = activeThemeDataset?.source_name === 'spw_walous_land_cover'
+ ? activeThemeDataset.source_metadata?.['bbox_epsg4326']
+ : null
+ const walousRasterImageOverlays = useMemo(
+ () => activeThemeDataset?.source_name === 'spw_walous_land_cover' && selectedProjectId && Array.isArray(walousRasterBounds) && walousRasterBounds.length === 4
+ ? [{
+ url: walousRasterImageUrl(selectedProjectId, activeThemeDataset.id),
+ bbox: walousRasterBounds.map(Number) as [number, number, number, number],
+ label: getDatasetDisplayName(activeThemeDataset),
+ opacity: 0.82,
+ }]
+ : [],
+ [activeThemeDataset, selectedProjectId, walousRasterBounds],
+ )
const bathymetryRasterBounds = activeThemeDataset?.source_name === 'spw_bathymetry'
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
: null
@@ -1338,6 +1399,8 @@ export function MapWorkspace({
const activeImageOverlays = useMemo(
() => bathymetryRasterImageOverlays.length > 0
? bathymetryRasterImageOverlays
+ : walousRasterImageOverlays.length > 0
+ ? walousRasterImageOverlays
: thematicRasterImageOverlays.length > 0
? thematicRasterImageOverlays
: floodHazardImageOverlays.length > 0
@@ -1345,7 +1408,7 @@ export function MapWorkspace({
: terrainImageOverlays.length > 0
? terrainImageOverlays
: orthophotoImageOverlay ? [orthophotoImageOverlay] : [],
- [bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays],
+ [bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays, walousRasterImageOverlays],
)
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
const themeTemporalSeriesMap = useMemo(
@@ -1908,6 +1971,7 @@ export function MapWorkspace({
kind: onDemandProduct.kind,
productKey: onDemandProduct.productKey,
displayName: onDemandProduct.displayName,
+ historyProductKeys: onDemandProduct.historyProductKeys,
},
acquisitionBboxes: onDemandProduct.acquisitionBboxes,
featureLimit: resultFeatureLimit,
@@ -2471,7 +2535,7 @@ export function MapWorkspace({
/>
Werkgebied
- {thematicRasterImageOverlays.length > 0 ? (
+ {thematicRasterImageOverlays.length > 0 || walousRasterImageOverlays.length > 0 ? (
{thematicLegendMin} → {thematicLegendMax}
diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts
index 40ea3066..fa86960f 100644
--- a/frontend/src/hooks/useMapThemeSelectionInsights.ts
+++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts
@@ -9,6 +9,7 @@ import { bathymetryRasterSelectionToMapSelection } from '../lib/bathymetryRaster
export type MapThemeAcquisitionKind =
| 'thematic_raster'
+ | 'walous'
| 'dhmv'
| 'flood_hazard'
| 'grb'
@@ -19,6 +20,7 @@ export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind
productKey: string
displayName: string
+ historyProductKeys?: string[]
}
export interface MapThemeQuery {
@@ -113,7 +115,7 @@ export function useMapThemeSelectionInsights(
const resultLimit = featureLimit ?? 1000
if (acquisition) {
const requestedBboxes = acquisitionBboxes?.length ? acquisitionBboxes : [bbox]
- const acquisitionResults = await settleWithConcurrency(requestedBboxes, 1, async (acquisitionBbox) => {
+ const acquireProduct = async (acquisitionBbox: VectorSelectionBBox, productKey: string) => {
const commonPayload = {
bbox: acquisitionBbox,
area_id: areaId,
@@ -124,6 +126,11 @@ export function useMapThemeSelectionInsights(
...commonPayload,
product_key: acquisition.productKey,
})
+ : acquisition.kind === 'walous'
+ ? await datasetsApi.acquireWalous(selectedProjectId, {
+ ...commonPayload,
+ product_key: productKey,
+ })
: acquisition.kind === 'dhmv'
? await datasetsApi.acquireDhmv(selectedProjectId, {
...commonPayload,
@@ -152,13 +159,38 @@ export function useMapThemeSelectionInsights(
)
}
return datasetsApi.get(selectedProjectId, acquisitionJob.output_dataset_id)
- })
+ }
+ const acquisitionResults = await settleWithConcurrency(
+ requestedBboxes,
+ 1,
+ (acquisitionBbox) => acquireProduct(acquisitionBbox, acquisition.productKey),
+ )
const failedAcquisition = acquisitionResults.find((item) => item.status === 'rejected')
if (failedAcquisition?.status === 'rejected') {
throw failedAcquisition.reason
}
acquiredDatasets = acquisitionResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : [])
dataset = acquiredDatasets[0]
+ const historyProductKeys = acquisition.kind === 'walous'
+ ? [...new Set(acquisition.historyProductKeys ?? [])].filter((key) => key !== acquisition.productKey)
+ : []
+ if (historyProductKeys.length > 0) {
+ const historyRequests = historyProductKeys.flatMap((productKey) =>
+ requestedBboxes.map((acquisitionBbox) => ({ acquisitionBbox, productKey })),
+ )
+ const historyResults = await settleWithConcurrency(
+ historyRequests,
+ 1,
+ ({ acquisitionBbox, productKey }) => acquireProduct(acquisitionBbox, productKey),
+ )
+ const failedHistory = historyResults.find((item) => item.status === 'rejected')
+ if (failedHistory?.status === 'rejected') {
+ throw failedHistory.reason
+ }
+ acquiredDatasets.push(
+ ...historyResults.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []),
+ )
+ }
}
if (!dataset) {
throw new Error(`Geen persistente databron beschikbaar voor thema ${themeId}.`)
@@ -197,8 +229,13 @@ export function useMapThemeSelectionInsights(
: dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster'
? thematicRasterSelectionToMapSelection(await datasetsApi.selectThematicRaster(selectedProjectId, dataset.id, {
bbox,
- area_id: areaId,
- }))
+ area_id: areaId,
+ }))
+ : dataset.dataset_type === 'raster' && dataset.source_name === 'spw_walous_land_cover'
+ ? thematicRasterSelectionToMapSelection(await datasetsApi.selectWalous(selectedProjectId, dataset.id, {
+ bbox,
+ area_id: areaId,
+ }))
: dataset.dataset_type === 'raster' && dataset.source_name === 'spw_bathymetry'
? bathymetryRasterSelectionToMapSelection(await datasetsApi.selectBathymetryRaster(selectedProjectId, dataset.id, {
bbox,
diff --git a/frontend/src/hooks/useOfficialMapProducts.ts b/frontend/src/hooks/useOfficialMapProducts.ts
index b22e5015..2f987f47 100644
--- a/frontend/src/hooks/useOfficialMapProducts.ts
+++ b/frontend/src/hooks/useOfficialMapProducts.ts
@@ -14,6 +14,7 @@ import type {
export interface OfficialMapProducts {
thematic: ThematicRasterProductRead[]
+ walous: ThematicRasterProductRead[]
dhmv: DhmvProductRead[]
floodHazard: FloodHazardProductRead[]
grb: GrbProductRead[]
@@ -23,6 +24,7 @@ export interface OfficialMapProducts {
const EMPTY_PRODUCTS: OfficialMapProducts = {
thematic: [],
+ walous: [],
dhmv: [],
floodHazard: [],
grb: [],
@@ -50,16 +52,18 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
setError(null)
void Promise.all([
datasetsApi.listThematicRasterProducts(selectedProjectId),
+ datasetsApi.listWalousProducts(selectedProjectId),
datasetsApi.listDhmvProducts(selectedProjectId),
datasetsApi.listFloodHazardProducts(selectedProjectId),
datasetsApi.listGrbProducts(selectedProjectId),
datasetsApi.listOfficialVectorProducts(selectedProjectId),
datasetsApi.listBathymetrySources(selectedProjectId),
])
- .then(([thematic, dhmv, floodHazard, grb, officialVector, bathymetry]) => {
+ .then(([thematic, walous, dhmv, floodHazard, grb, officialVector, bathymetry]) => {
if (!cancelled) {
setProducts({
thematic: thematic.items,
+ walous: walous.items,
dhmv: dhmv.items,
floodHazard: floodHazard.items,
grb: grb.items,
diff --git a/frontend/src/lib/datasetCapabilities.ts b/frontend/src/lib/datasetCapabilities.ts
index 97097248..8b4aed88 100644
--- a/frontend/src/lib/datasetCapabilities.ts
+++ b/frontend/src/lib/datasetCapabilities.ts
@@ -5,6 +5,7 @@ const NON_IMAGERY_RASTER_SOURCES = new Set([
'digitaal_vlaanderen_dhmv',
'vmm_flood_hazard',
'spw_bathymetry',
+ 'spw_walous_land_cover',
])
export function isDetectionImageryDataset(dataset: DatasetCreateResponse): boolean {
diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts
index c80c3e23..82a02332 100644
--- a/frontend/src/lib/datasetDisplay.ts
+++ b/frontend/src/lib/datasetDisplay.ts
@@ -17,6 +17,7 @@ const DATASET_LABEL_BY_LAYER: Record = {
open_space: 'Open ruimte',
accessibility: 'Knooppuntwaarde',
services: 'Voorzieningenniveau',
+ land_cover: 'Landbedekking',
building_registry: 'Gebouwenregister',
regional_boundary: 'Grens vervoerregio Kempen',
municipality_boundaries: 'Gemeentegrenzen Kempen',
@@ -37,6 +38,8 @@ const DATASET_SOURCE_LABELS: Record = {
vmm_flood_hazard: 'Vlaamse Milieumaatschappij',
vmm_vha_bathymetry_profiles: 'VMM / Vlaamse Hydrografische Atlas',
spw_bathymetry: 'Service public de Wallonie',
+ spw_walous_land_cover: 'Service public de Wallonie',
+ spw_flood_hazard: 'Service public de Wallonie',
department_omgeving_thematic_raster: 'Departement Omgeving',
dov_soil_map: 'Databank Ondergrond Vlaanderen',
}
@@ -65,6 +68,10 @@ export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
const productName = dataset.source_metadata?.['product_display_name']
return typeof productName === 'string' && productName.trim() ? productName : 'Officieel Vlaams themaraster'
}
+ if (dataset.source_name === 'spw_walous_land_cover') {
+ const productName = dataset.source_metadata?.['product_display_name']
+ return typeof productName === 'string' && productName.trim() ? productName : 'WALOUS landbedekking'
+ }
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '')
.toString()
.toLowerCase()
diff --git a/frontend/src/lib/thematicRaster.ts b/frontend/src/lib/thematicRaster.ts
index fe1af233..2e83b325 100644
--- a/frontend/src/lib/thematicRaster.ts
+++ b/frontend/src/lib/thematicRaster.ts
@@ -4,6 +4,10 @@ export function thematicRasterImageUrl(projectId: string, datasetId: string): st
return `/api/v1/projects/${projectId}/datasets/${datasetId}/raster/thematic/image`
}
+export function walousRasterImageUrl(projectId: string, datasetId: string): string {
+ return `/api/v1/projects/${projectId}/datasets/${datasetId}/raster/walous/image`
+}
+
export function thematicRasterSelectionToMapSelection(result: ThematicRasterSelectionResponse): VectorSelectionResponse {
return {
selection_bbox: result.selection_bbox,
diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts
index c6d42023..e3bc3c3d 100644
--- a/frontend/src/services/api/datasets.ts
+++ b/frontend/src/services/api/datasets.ts
@@ -220,12 +220,22 @@ export const datasetsApi = {
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`),
+ acquireWalous: (projectId: string, payload: ThematicRasterAcquireRequest): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/walous/acquire`, payload),
+ listWalousProducts: (projectId: string): Promise<{ items: ThematicRasterProductRead[]; total: number }> =>
+ apiGet<{ items: ThematicRasterProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/walous/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),
+ selectWalous: (
+ projectId: string,
+ datasetId: string,
+ payload: { bbox: VectorSelectionRequest['bbox']; area_id?: string },
+ ): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/walous/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/types.ts b/frontend/src/types.ts
index 08e530a0..98d4b229 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -576,11 +576,12 @@ export interface ThematicRasterAcquireRequest {
export interface ThematicRasterProductRead {
key: string
display_name: string
- theme: 'space_occupation' | 'open_space' | 'forest' | 'agriculture' | 'population' | 'accessibility' | 'services'
- metric_kind: 'binary_area' | 'population_density' | 'index_score' | 'normalized_score'
+ theme: 'space_occupation' | 'open_space' | 'forest' | 'agriculture' | 'population' | 'accessibility' | 'services' | 'land_cover'
+ metric_kind: 'binary_area' | 'population_density' | 'index_score' | 'normalized_score' | 'categorical_area'
coverage_id: string
native_resolution_m: number
- source_crs: 'EPSG:31370'
+ analysis_resolution_m?: number | null
+ source_crs: 'EPSG:31370' | 'EPSG:3812'
source_value_unit: string
observation_year: number
source_version: string
@@ -591,6 +592,9 @@ export interface ThematicRasterProductRead {
legend_max_label: string
included_source_values: number[]
limitation_message: string
+ coverage_zones: string[]
+ configured: boolean
+ status: 'configured' | 'source_not_provisioned'
}
export interface ThematicRasterSelectionResponse {
diff --git a/scripts/README.md b/scripts/README.md
index 4c667ddf..ea7a73e6 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -2,6 +2,24 @@
Setup-, import-, demo- en maintenance-scripts voor GeoIntel.
+## WALOUS source provisioning
+
+Run the networked operator only after checking at least 2 GB of archive space
+plus room for the extracted official GeoTIFFs:
+
+```bash
+python scripts/provision_walous_sources.py \
+ --years 2020 2023 \
+ --destination storage/source-cache/walous
+```
+
+The command accepts only the hard-coded official SPW 2020/2023 archives,
+streams with a 1 GB per-archive cap, rejects changed content lengths, extracts
+only the single GeoTIFF by basename, validates the raster contract and writes
+checksums plus `provisioning-report.json`. Existing valid sources are reused;
+`--force` performs a new download. This is an operator acquisition, not an
+application startup task.
+
## Runtime verification
Inspect interrupted runtime state without changing it:
diff --git a/scripts/audit_api_contracts.py b/scripts/audit_api_contracts.py
index 2c55c5b4..bfb31236 100644
--- a/scripts/audit_api_contracts.py
+++ b/scripts/audit_api_contracts.py
@@ -19,6 +19,7 @@ ALLOWED_NON_ENVELOPE_ENDPOINTS = {
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/bathymetry/image"),
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/image"),
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/thematic/image"),
+ ("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/walous/image"),
}
IGNORED_OPENAPI_PATHS = {
diff --git a/scripts/provision_walous_sources.py b/scripts/provision_walous_sources.py
new file mode 100644
index 00000000..2dab8343
--- /dev/null
+++ b/scripts/provision_walous_sources.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""Provision official WALOUS GeoTIFF source rasters for bounded runtime analysis."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import sys
+from urllib.request import Request, urlopen
+from zipfile import BadZipFile, ZipFile
+
+
+SOURCES = {
+ 2020: {
+ "url": (
+ "https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/"
+ "47b348f1-6e7a-4baa-963c-0232a43c0cff/WAL_OCS_IA__2020_GEOTIFF_3812.zip"
+ ),
+ "expected_archive_bytes": 728_244_755,
+ "target": "walous_land_cover_2020_3812.tif",
+ },
+ 2023: {
+ "url": (
+ "https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/"
+ "4e780ba1-463c-478e-95df-d2f1963a150d/WAL_OCS_IA__2023_GEOTIFF_3812.zip"
+ ),
+ "expected_archive_bytes": 876_014_572,
+ "target": "walous_land_cover_2023_3812.tif",
+ },
+}
+MAX_ARCHIVE_BYTES = 1_000_000_000
+MAX_EXTRACTED_BYTES = 50_000_000_000
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ while chunk := handle.read(8 * 1024 * 1024):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def download(url: str, destination: Path, expected_bytes: int) -> str:
+ temporary = destination.with_suffix(destination.suffix + ".part")
+ temporary.unlink(missing_ok=True)
+ digest = hashlib.sha256()
+ received = 0
+ request = Request(url, headers={"User-Agent": "GeoIntel/1.0 WALOUS-source-provisioner"})
+ try:
+ with urlopen(request, timeout=300) as response, temporary.open("wb") as output:
+ content_length = int(response.headers.get("Content-Length") or 0)
+ if content_length and content_length != expected_bytes:
+ raise RuntimeError(f"official archive size changed: expected {expected_bytes}, advertised {content_length}")
+ while chunk := response.read(8 * 1024 * 1024):
+ received += len(chunk)
+ if received > MAX_ARCHIVE_BYTES:
+ raise RuntimeError("official archive exceeds the governed 1 GB transfer limit")
+ digest.update(chunk)
+ output.write(chunk)
+ if received % (128 * 1024 * 1024) < len(chunk):
+ print(f" downloaded {received / 1024 / 1024:.0f} MiB", flush=True)
+ if received != expected_bytes:
+ raise RuntimeError(f"archive is incomplete: expected {expected_bytes} bytes, received {received}")
+ temporary.replace(destination)
+ return digest.hexdigest()
+ except Exception:
+ temporary.unlink(missing_ok=True)
+ raise
+
+
+def extract_single_geotiff(archive: Path, target: Path) -> None:
+ try:
+ with ZipFile(archive) as bundle:
+ candidates = [item for item in bundle.infolist() if not item.is_dir() and item.filename.lower().endswith((".tif", ".tiff"))]
+ if len(candidates) != 1:
+ raise RuntimeError(f"archive must contain exactly one GeoTIFF, found {len(candidates)}")
+ member = candidates[0]
+ if member.file_size <= 0 or member.file_size > MAX_EXTRACTED_BYTES:
+ raise RuntimeError(f"GeoTIFF uncompressed size is outside the governed limit: {member.file_size}")
+ if Path(member.filename).name != member.filename.replace("\\", "/").split("/")[-1]:
+ # Nested paths are accepted only by basename; extraction never trusts archive paths.
+ pass
+ temporary = target.with_suffix(target.suffix + ".part")
+ temporary.unlink(missing_ok=True)
+ with bundle.open(member) as source, temporary.open("wb") as output:
+ shutil.copyfileobj(source, output, length=8 * 1024 * 1024)
+ temporary.replace(target)
+ except BadZipFile as exc:
+ raise RuntimeError("official WALOUS archive is not a valid ZIP file") from exc
+
+
+def validate_raster(path: Path) -> dict:
+ try:
+ import numpy as np
+ import rasterio
+ from rasterio.enums import Resampling
+ except ImportError as exc:
+ raise RuntimeError("rasterio and numpy are required to validate WALOUS sources") from exc
+ with rasterio.open(path) as source:
+ if source.crs is None or source.crs.to_epsg() != 3812:
+ raise RuntimeError(f"WALOUS raster must use EPSG:3812, found {source.crs}")
+ if source.count != 1:
+ raise RuntimeError(f"WALOUS raster must have one band, found {source.count}")
+ if not all(abs(abs(float(value)) - 1.0) <= 0.05 for value in source.res):
+ raise RuntimeError(f"WALOUS raster must retain 1 m cells, found {source.res}")
+ sample_height = min(2048, source.height)
+ sample_width = min(2048, source.width)
+ sample = source.read(1, out_shape=(sample_height, sample_width), masked=True, resampling=Resampling.nearest)
+ values = np.unique(sample.compressed()).astype(int).tolist()
+ unexpected = sorted(set(values) - set(range(1, 12)))
+ if unexpected:
+ raise RuntimeError(f"WALOUS sample contains classes outside 1-11: {unexpected}")
+ return {
+ "path": str(path),
+ "crs": str(source.crs),
+ "width": int(source.width),
+ "height": int(source.height),
+ "resolution": [float(value) for value in source.res],
+ "bounds": [float(value) for value in source.bounds],
+ "nodata": None if source.nodata is None else float(source.nodata),
+ "sample_classes": values,
+ }
+
+
+def provision(year: int, destination: Path, force: bool) -> dict:
+ source = SOURCES[year]
+ target = destination / source["target"]
+ archive = destination / f"{Path(source['target']).stem}.zip"
+ if target.is_file() and not force:
+ print(f"WALOUS {year}: validating existing source {target}")
+ validation = validate_raster(target)
+ digest = sha256_file(target)
+ else:
+ print(f"WALOUS {year}: downloading official archive")
+ archive_digest = download(source["url"], archive, source["expected_archive_bytes"])
+ print(f"WALOUS {year}: archive sha256 {archive_digest}")
+ extract_single_geotiff(archive, target)
+ validation = validate_raster(target)
+ digest = sha256_file(target)
+ archive.unlink(missing_ok=True)
+ checksum_path = target.with_suffix(".sha256")
+ checksum_path.write_text(f"{digest} {target.name}\n", encoding="ascii")
+ validation.update({"year": year, "sha256": digest, "download_url": source["url"]})
+ print(f"WALOUS {year}: ready ({target.stat().st_size / 1024 / 1024:.0f} MiB)")
+ return validation
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Provision official WALOUS 2020/2023 GeoTIFF sources.")
+ parser.add_argument("--years", nargs="+", type=int, choices=sorted(SOURCES), default=sorted(SOURCES))
+ parser.add_argument("--destination", type=Path, default=Path("storage/source-cache/walous"))
+ parser.add_argument("--force", action="store_true")
+ args = parser.parse_args()
+ args.destination.mkdir(parents=True, exist_ok=True)
+ report = [provision(year, args.destination.resolve(), args.force) for year in args.years]
+ report_path = args.destination / "provisioning-report.json"
+ report_path.write_text(json.dumps({"sources": report}, indent=2) + "\n", encoding="utf-8")
+ print(f"Provisioning report: {report_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc:
+ print(f"WALOUS_PROVISIONING_FAILED: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh
index 82dab99c..4dcf5388 100755
--- a/scripts/run_readiness_check.sh
+++ b/scripts/run_readiness_check.sh
@@ -81,6 +81,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_flanders_geographic_scope.py
${PYTHON_BIN} -m py_compile scripts/provision_flanders_bathymetry_profiles.py
${PYTHON_BIN} -m py_compile scripts/probe_mdk_bathymetry.py
${PYTHON_BIN} -m py_compile scripts/import_spw_bathymetry.py
+${PYTHON_BIN} -m py_compile scripts/provision_walous_sources.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_bwk_natura2000.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_bwk_natura2000.py
${PYTHON_BIN} -m py_compile scripts/provision_agricultural_parcel_history.py