Files
geointel/scripts/provision_spw_terrain_source.py
T
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

260 lines
9.4 KiB
Python

#!/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 write_text_atomic(path: Path, content: str, *, encoding: str) -> None:
temporary = path.with_suffix(path.suffix + ".part")
temporary.unlink(missing_ok=True)
try:
temporary.write_text(content, encoding=encoding)
temporary.replace(path)
except Exception:
temporary.unlink(missing_ok=True)
raise
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)
write_text_atomic(
target.with_suffix(".sha256"),
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"
write_text_atomic(
report_path,
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