Add governed DHMV terrain analysis
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 18:13:05 +02:00
parent 0e36d750c0
commit 5d8b46ed60
36 changed files with 1936 additions and 44 deletions
+25
View File
@@ -1513,6 +1513,31 @@ Only aggregate unit/address counts enter the queryable building layer. Review
manifest before accepting a broader import. Raw address response pages are
operator evidence and must not be published.
## Mol DHMV terrain rasters
Acquire and validate the official DHMV II DTM and DSM for the exact persisted
Mol Area:
```bash
docker exec geointel python /app/scripts/provision_mol_dhmv.py
```
The operator resolves project and Area through the API, derives the bounded
EPSG:4326 request rectangle and calls the canonical DHMV endpoints. The backend
requests the fixed official WCS coverages, extracts multipart GeoTIFF, clips to
the exact Area, validates EPSG:31370/resolution/nodata/valid cells and stores
through DatasetService. It then runs a full-Area terrain metric smoke.
Useful safe overrides:
```bash
docker exec geointel python /app/scripts/provision_mol_dhmv.py --products dtm_1m
docker exec geointel python /app/scripts/provision_mol_dhmv.py --resolution-m 5 --force
```
Do not use DHMV output as water depth or water volume. The command fails when
the API no longer reports those metrics as explicitly unsupported.
## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
+157
View File
@@ -0,0 +1,157 @@
"""Provision governed DHMV II terrain/surface rasters for the persisted Mol Area.
The operator calls the canonical GeoIntel DHMV acquisition API. The backend
performs the bounded official WCS request, exact Area clipping, validation,
checksum storage and Dataset/Job persistence. No raster rows are written
directly by this script.
"""
from __future__ import annotations
import argparse
import json
import os
from typing import Any, Iterable
import requests
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_PROJECT_NAME = "Kempen Regional Workbench"
DEFAULT_AREA_FRAGMENT = "Gemeente Mol"
PRODUCTS = ("dtm_1m", "dsm_1m")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision official DHMV II DTM/DSM rasters for Mol.")
parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL))
parser.add_argument("--project-name", default=DEFAULT_PROJECT_NAME)
parser.add_argument("--area-name", default=DEFAULT_AREA_FRAGMENT)
parser.add_argument("--products", default=",".join(PRODUCTS), help="Comma-separated governed product keys.")
parser.add_argument("--resolution-m", type=float, default=5.0)
parser.add_argument("--timeout", type=int, default=1800)
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def unwrap(response: requests.Response) -> Any:
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict) or "data" not in payload:
raise RuntimeError(f"Non-canonical API response from {response.url}")
return payload["data"]
def coordinates(geometry: dict[str, Any]) -> Iterable[tuple[float, float]]:
def walk(value: Any):
if isinstance(value, list) and len(value) >= 2 and all(isinstance(item, (int, float)) for item in value[:2]):
yield float(value[0]), float(value[1])
return
if isinstance(value, list):
for child in value:
yield from walk(child)
yield from walk(geometry.get("coordinates", []))
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float | str]:
points = list(coordinates(geometry))
if not points:
raise RuntimeError("Persisted Area geometry contains no coordinates")
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return {
"min_x": min(xs),
"min_y": min(ys),
"max_x": max(xs),
"max_y": max(ys),
"crs": "EPSG:4326",
}
def main() -> int:
args = parse_args()
base_url = args.base_url.rstrip("/")
session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-DHMV-Operator/1.0"})
projects = unwrap(session.get(f"{base_url}/api/v1/projects", params={"limit": 200, "offset": 0}, timeout=60))["items"]
project = next((item for item in projects if item["name"] == args.project_name), None)
if project is None:
raise RuntimeError(f"Project {args.project_name!r} was not found")
areas = unwrap(
session.get(
f"{base_url}/api/v1/projects/{project['id']}/areas",
params={"limit": 200, "offset": 0},
timeout=60,
)
)["items"]
fragment = args.area_name.casefold()
area = next((item for item in areas if fragment in item["name"].casefold()), None)
if area is None:
raise RuntimeError(f"Area containing {args.area_name!r} was not found")
bbox = geometry_bbox(area["geometry"])
requested_products = [item.strip() for item in args.products.split(",") if item.strip()]
invalid = sorted(set(requested_products) - set(PRODUCTS))
if invalid:
raise RuntimeError(f"Unsupported DHMV product keys: {', '.join(invalid)}")
results = []
for product_key in requested_products:
job = unwrap(
session.post(
f"{base_url}/api/v1/projects/{project['id']}/datasets/dhmv/acquire",
json={
"bbox": bbox,
"area_id": area["id"],
"product_key": product_key,
"resolution_m": args.resolution_m,
"force_refresh": args.force,
},
timeout=args.timeout,
)
)
if job.get("status") != "success" or not job.get("output_dataset_id"):
raise RuntimeError(f"DHMV acquisition failed for {product_key}: {job.get('error_message') or job}")
analysis = unwrap(
session.post(
f"{base_url}/api/v1/projects/{project['id']}/datasets/{job['output_dataset_id']}/raster/terrain/select",
json={"bbox": bbox, "area_id": area["id"]},
timeout=args.timeout,
)
)
if sorted(analysis.get("unsupported_metrics", [])) != ["water_depth_m", "water_volume_m3"]:
raise RuntimeError("DHMV terrain contract must explicitly keep water depth and volume unavailable")
results.append(
{
"product_key": product_key,
"dataset_id": job["output_dataset_id"],
"reused": bool((job.get("result_json") or {}).get("reused")),
"resolution_m": analysis["resolution_m"],
"sample_count": analysis["sample_count"],
"coverage_ratio": analysis["coverage_ratio"],
"metrics": analysis["summary"]["metrics"],
}
)
print(
json.dumps(
{
"status": "ok",
"project_id": project["id"],
"area_id": area["id"],
"area_name": area["name"],
"bbox": bbox,
"products": results,
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -51,6 +51,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_waterinfo_station_history.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_bwk_natura2000.py
${PYTHON_BIN} -m py_compile scripts/provision_agricultural_parcel_history.py
${PYTHON_BIN} -m py_compile scripts/provision_buildings_addresses_register.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_dhmv.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_timeseries.py
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py