feat: complete Wallonia land cover and terrain sources
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-22 06:28:46 +02:00
parent d45d34186d
commit cee6cd05ae
46 changed files with 2542 additions and 276 deletions
+21 -4
View File
@@ -4,22 +4,39 @@ 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
Run the networked operator only after checking at least 3 GB of archive space
plus room for the extracted official GeoTIFFs:
```bash
python scripts/provision_walous_sources.py \
--years 2020 2023 \
--years 2018 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
The command accepts only the hard-coded official SPW 2018/2020/2023 archives,
streams with a 1.25 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.
## SPW Wallonia terrain source provisioning
The official 1 m MNT is a large operator asset, never an implicit startup
download. Reserve at least 90 GB temporarily for archive plus extraction and
run:
```bash
python scripts/provision_spw_terrain_source.py \
--destination storage/source-cache/spw-terrain
```
The provisioner accepts only the fixed official SPW artifact, enforces a
30-60 GB archive range and a single safe GeoTIFF member, validates EPSG:3812,
one band, native 1 m cells and representative elevation samples, writes
source/archive SHA-256 evidence and removes the archive after successful
extraction unless `--keep-archive` is supplied.
## Runtime verification
Inspect interrupted runtime state without changing it:
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Provision the official Wallonia 1 m MNT for bounded GeoIntel 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
SOURCE_URL = (
"https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/"
"fe13bc84-e371-46ca-9632-8ad4139f1ee5/RELIEF_WALLONIE_MNT_1M_2021_2022_GEOTIFF_3812.zip"
)
TARGET_FILENAME = "spw_mnt_1m_2021_2022_3812.tif"
ARCHIVE_FILENAME = "spw_mnt_1m_2021_2022_3812.zip"
MIN_ARCHIVE_BYTES = 30_000_000_000
MAX_ARCHIVE_BYTES = 60_000_000_000
MAX_EXTRACTED_BYTES = 80_000_000_000
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
while chunk := handle.read(16 * 1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def download(destination: Path) -> str:
temporary = destination.with_suffix(destination.suffix + ".part")
temporary.unlink(missing_ok=True)
digest = hashlib.sha256()
received = 0
request = Request(
SOURCE_URL,
headers={"User-Agent": "GeoIntel/1.0 SPW-terrain-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 not MIN_ARCHIVE_BYTES <= content_length <= MAX_ARCHIVE_BYTES
):
raise RuntimeError(
f"official archive size is outside the governed range: {content_length}"
)
while chunk := response.read(16 * 1024 * 1024):
received += len(chunk)
if received > MAX_ARCHIVE_BYTES:
raise RuntimeError(
"official archive exceeds the governed 60 GB transfer limit"
)
digest.update(chunk)
output.write(chunk)
if received % (1024 * 1024 * 1024) < len(chunk):
print(
f" downloaded {received / 1024 / 1024 / 1024:.1f} GiB",
flush=True,
)
if received < MIN_ARCHIVE_BYTES:
raise RuntimeError(
f"official archive is unexpectedly small: {received} bytes"
)
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}"
)
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=16 * 1024 * 1024)
temporary.replace(target)
except BadZipFile as exc:
raise RuntimeError("official SPW MNT archive is not a valid ZIP file") from exc
def validate_raster(path: Path) -> dict:
try:
import numpy as np
import rasterio
from rasterio.windows import Window
except ImportError as exc:
raise RuntimeError(
"rasterio and numpy are required to validate the SPW MNT source"
) from exc
with rasterio.open(path) as source:
if source.crs is None or source.crs.to_epsg() != 3812:
raise RuntimeError(f"SPW MNT must use EPSG:3812, found {source.crs}")
if source.count != 1:
raise RuntimeError(f"SPW MNT 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"SPW MNT must retain 1 m cells, found {source.res}")
sample_windows = []
sample_size = 512
for x_fraction, y_fraction in (
(0.1, 0.1),
(0.5, 0.5),
(0.9, 0.9),
(0.1, 0.9),
(0.9, 0.1),
):
col = max(
0,
min(
source.width - sample_size,
round(source.width * x_fraction - sample_size / 2),
),
)
row = max(
0,
min(
source.height - sample_size,
round(source.height * y_fraction - sample_size / 2),
),
)
sample_windows.append(
Window(
col,
row,
min(sample_size, source.width),
min(sample_size, source.height),
)
)
samples = [
source.read(1, window=window, masked=True).compressed().astype("float64")
for window in sample_windows
]
values = np.concatenate([sample for sample in samples if sample.size])
values = values[np.isfinite(values)]
if not values.size:
raise RuntimeError(
"SPW MNT validation samples contain no finite elevation values"
)
if float(values.min()) < -100.0 or float(values.max()) > 1000.0:
raise RuntimeError(
f"SPW MNT samples contain implausible values: {values.min()}..{values.max()}"
)
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),
"dtype": source.dtypes[0],
"sample_min_m": float(values.min()),
"sample_max_m": float(values.max()),
}
def provision(destination: Path, force: bool, keep_archive: bool) -> dict:
destination.mkdir(parents=True, exist_ok=True)
target = destination / TARGET_FILENAME
archive = destination / ARCHIVE_FILENAME
archive_digest = None
if target.is_file() and not force:
print(f"SPW MNT: validating existing source {target}")
else:
if archive.is_file():
archive_size = archive.stat().st_size
if not MIN_ARCHIVE_BYTES <= archive_size <= MAX_ARCHIVE_BYTES:
raise RuntimeError(
f"existing archive size is outside the governed range: {archive_size}"
)
print(f"SPW MNT: using existing archive {archive}")
archive_digest = sha256_file(archive)
else:
print("SPW MNT: downloading official archive")
archive_digest = download(archive)
print(f"SPW MNT: archive sha256 {archive_digest}")
extract_single_geotiff(archive, target)
if not keep_archive:
archive.unlink(missing_ok=True)
validation = validate_raster(target)
source_digest = sha256_file(target)
target.with_suffix(".sha256").write_text(
f"{source_digest} {target.name}\n", encoding="ascii"
)
validation.update(
{
"source_sha256": source_digest,
"archive_sha256": archive_digest,
"download_url": SOURCE_URL,
"catalog_url": "https://geoportail.wallonie.be/catalogue/fe13bc84-e371-46ca-9632-8ad4139f1ee5.html",
}
)
report_path = destination / "provisioning-report.json"
report_path.write_text(json.dumps(validation, indent=2) + "\n", encoding="utf-8")
print(f"SPW MNT: ready ({target.stat().st_size / 1024 / 1024 / 1024:.1f} GiB)")
print(f"Provisioning report: {report_path}")
return validation
def main() -> int:
parser = argparse.ArgumentParser(
description="Provision the official Wallonia 1 m MNT 2021-2022 GeoTIFF."
)
parser.add_argument(
"--destination", type=Path, default=Path("storage/source-cache/spw-terrain")
)
parser.add_argument("--force", action="store_true")
parser.add_argument("--keep-archive", action="store_true")
args = parser.parse_args()
provision(args.destination.resolve(), args.force, args.keep_archive)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"SPW_TERRAIN_PROVISIONING_FAILED: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
+141 -23
View File
@@ -14,6 +14,14 @@ from zipfile import BadZipFile, ZipFile
SOURCES = {
2018: {
"url": (
"https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/"
"a0ad23a1-1845-4bd5-8c2f-0f62d3f1ec75/WALOUS_OCS__2018_GEOTIFF_3812.zip"
),
"expected_archive_bytes": 1_122_785_133,
"target": "walous_land_cover_2018_3812.tif",
},
2020: {
"url": (
"https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/"
@@ -31,9 +39,46 @@ SOURCES = {
"target": "walous_land_cover_2023_3812.tif",
},
}
MAX_ARCHIVE_BYTES = 1_000_000_000
MAX_ARCHIVE_BYTES = 1_250_000_000
MAX_EXTRACTED_BYTES = 50_000_000_000
WALOUS_CLASS_CODES = {1, 2, 3, 4, 5, 6, 7, 8, 9, 80, 90}
WALOUS_CLASS_CODES = {
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
11,
15,
18,
19,
28,
29,
31,
38,
39,
51,
55,
58,
59,
62,
71,
73,
75,
80,
81,
83,
85,
90,
91,
93,
95,
}
WALOUS_CANONICAL_CLASS_CODES = {1, 2, 3, 4, 5, 6, 7, 8, 9, 80, 90}
def sha256_file(path: Path) -> str:
@@ -49,22 +94,30 @@ def download(url: str, destination: Path, expected_bytes: int) -> str:
temporary.unlink(missing_ok=True)
digest = hashlib.sha256()
received = 0
request = Request(url, headers={"User-Agent": "GeoIntel/1.0 WALOUS-source-provisioner"})
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}")
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")
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}")
raise RuntimeError(
f"archive is incomplete: expected {expected_bytes} bytes, received {received}"
)
temporary.replace(destination)
return digest.hexdigest()
except Exception:
@@ -75,13 +128,25 @@ def download(url: str, destination: Path, expected_bytes: int) -> str:
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"))]
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)}")
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]:
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")
@@ -99,21 +164,37 @@ def validate_raster(path: Path) -> dict:
import rasterio
from rasterio.enums import Resampling
except ImportError as exc:
raise RuntimeError("rasterio and numpy are required to validate WALOUS sources") from 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}")
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}")
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)
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) - WALOUS_CLASS_CODES)
allowed_codes = (
WALOUS_CLASS_CODES if "2018" in path.name else WALOUS_CANONICAL_CLASS_CODES
)
unexpected = sorted(set(values) - allowed_codes)
if unexpected:
raise RuntimeError(f"WALOUS sample contains classes outside the official 11-class code set: {unexpected}")
raise RuntimeError(
f"WALOUS sample contains classes outside the official 11-class code set: {unexpected}"
)
return {
"path": str(path),
"crs": str(source.crs),
@@ -123,6 +204,9 @@ def validate_raster(path: Path) -> dict:
"bounds": [float(value) for value in source.bounds],
"nodata": None if source.nodata is None else float(source.nodata),
"sample_classes": values,
"implicit_source_nodata_values": [0]
if "2018" in path.name and 0 in values
else [],
}
@@ -135,8 +219,17 @@ def provision(year: int, destination: Path, force: bool) -> dict:
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"])
if (
archive.is_file()
and archive.stat().st_size == source["expected_archive_bytes"]
):
print(f"WALOUS {year}: using existing official archive {archive}")
archive_digest = sha256_file(archive)
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)
@@ -150,15 +243,40 @@ def provision(year: int, destination: Path, force: bool) -> dict:
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 = argparse.ArgumentParser(
description="Provision official WALOUS 2018/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 = [
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")
retained: dict[int, dict] = {}
if report_path.is_file():
try:
retained = {
int(item["year"]): item
for item in json.loads(report_path.read_text(encoding="utf-8")).get(
"sources", []
)
if isinstance(item, dict) and item.get("year") in SOURCES
}
except (OSError, ValueError, TypeError):
retained = {}
retained.update({int(item["year"]): item for item in report})
report_path.write_text(
json.dumps({"sources": [retained[year] for year in sorted(retained)]}, indent=2)
+ "\n",
encoding="utf-8",
)
print(f"Provisioning report: {report_path}")
return 0