Files
geointel/scripts/provision_release_golden_areas.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

342 lines
12 KiB
Python

#!/usr/bin/env python3
"""Provision and fingerprint the bounded Belgium/North Sea RC journey areas.
The command is dry-run-first. With --apply it creates only missing Area rows
through the public API. Existing Mol and Kempen authoritative geometries are
copied into the national release workspace and retained as source evidence;
no dataset, feature or source artifact is created by this command.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_BASE_URL = os.environ.get("GE_INTEL_BASE_URL", "http://127.0.0.1:1202")
NATIONAL_PROJECT = "Belgium and North Sea Workbench"
MOL_PROJECT = "Mol Municipality Workbench"
KEMPEN_PROJECT = "Kempen Regional Workbench"
def rectangle(minx: float, miny: float, maxx: float, maxy: float) -> dict[str, Any]:
return {
"type": "MultiPolygon",
"coordinates": [
[
[
[minx, miny],
[maxx, miny],
[maxx, maxy],
[minx, maxy],
[minx, miny],
]
]
],
}
GOLDEN_AREAS = (
{
"key": "wallonia_urban_rural",
"project": NATIONAL_PROJECT,
"name": "RC Golden - Wallonia urban-rural",
"geometry": rectangle(4.80, 50.42, 4.95, 50.53),
"expected_zones": ["wallonia"],
},
{
"key": "brussels_urban",
"project": NATIONAL_PROJECT,
"name": "RC Golden - Brussels urban",
"geometry": rectangle(4.32, 50.82, 4.39, 50.88),
"expected_zones": ["brussels"],
},
{
"key": "language_boundary",
"project": NATIONAL_PROJECT,
"name": "RC Golden - language boundary",
"geometry": rectangle(4.05, 50.70, 4.20, 50.80),
"expected_zones": ["flanders", "wallonia"],
},
{
"key": "coast_land_sea",
"project": NATIONAL_PROJECT,
"name": "RC Golden - coast land-sea",
"geometry": rectangle(2.88, 51.20, 2.98, 51.28),
"expected_zones": ["flanders", "territorial_sea"],
},
{
"key": "north_sea_multi_zone",
"project": NATIONAL_PROJECT,
"name": "RC Golden - North Sea multi-zone",
"geometry": rectangle(2.55, 51.35, 2.75, 51.55),
"expected_zones": [
"territorial_sea",
"exclusive_economic_zone",
"continental_shelf",
],
},
)
SOURCE_AREAS: tuple[dict[str, Any], ...] = (
{
"key": "mol_municipality",
"source_project": MOL_PROJECT,
"target_name": "RC Golden - Mol municipality",
"predicate": lambda name: name.startswith("Gemeente Mol"),
"expected_zones": ["flanders"],
},
{
"key": "kempen_region",
"source_project": KEMPEN_PROJECT,
"target_name": "RC Golden - Kempen region",
"predicate": lambda name: "Vervoerregio Kempen" in name,
"expected_zones": ["flanders"],
},
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--output", type=Path)
parser.add_argument(
"--apply",
action="store_true",
help="Create missing bounded RC Areas through the canonical API.",
)
return parser.parse_args()
class ApiClient:
def __init__(self, base_url: str) -> None:
self.base_url = base_url.rstrip("/")
def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=60) as response:
body = json.load(response)
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise RuntimeError(f"{method} {path} failed: {exc.reason}") from exc
if not isinstance(body, dict) or "data" not in body:
raise RuntimeError(f"{method} {path} did not return the canonical data envelope")
return body["data"]
def canonical_hash(value: Any) -> str:
serialized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float]:
points: list[tuple[float, float]] = []
def walk(value: Any) -> None:
if not isinstance(value, list):
return
if len(value) >= 2 and isinstance(value[0], (int, float)) and isinstance(value[1], (int, float)):
points.append((float(value[0]), float(value[1])))
return
for child in value:
walk(child)
walk(geometry.get("coordinates"))
if not points:
raise RuntimeError("Golden Area geometry has no coordinates")
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return {"minx": min(xs), "miny": min(ys), "maxx": max(xs), "maxy": max(ys)}
def find_one(items: list[dict[str, Any]], predicate: Callable[[str], bool], label: str) -> dict[str, Any]:
matches = [item for item in items if predicate(str(item.get("name", "")))]
if len(matches) != 1:
raise RuntimeError(f"Expected exactly one {label}; found {len(matches)}")
return matches[0]
def main() -> int:
args = parse_args()
client = ApiClient(args.base_url)
projects_payload = client.request("GET", "/api/v1/projects?limit=200")
projects = projects_payload.get("items", [])
projects_by_name = {str(project["name"]): project for project in projects}
required_projects = {NATIONAL_PROJECT, MOL_PROJECT, KEMPEN_PROJECT}
missing_projects = sorted(required_projects - set(projects_by_name))
if missing_projects:
raise RuntimeError(f"Required release projects are missing: {', '.join(missing_projects)}")
areas_by_project: dict[str, list[dict[str, Any]]] = {}
for project_name in required_projects:
project_id = projects_by_name[project_name]["id"]
payload = client.request("GET", f"/api/v1/projects/{project_id}/areas?limit=200")
areas_by_project[project_name] = list(payload.get("items", []))
evidence: list[dict[str, Any]] = []
missing_area_names: list[str] = []
for definition in GOLDEN_AREAS:
project_name = str(definition["project"])
project = projects_by_name[project_name]
existing = [
area
for area in areas_by_project[project_name]
if area.get("name") == definition["name"]
]
if len(existing) > 1:
raise RuntimeError(f"Duplicate release Area name: {definition['name']}")
created = False
if not existing:
if not args.apply:
missing_area_names.append(str(definition["name"]))
continue
area = client.request(
"POST",
f"/api/v1/projects/{project['id']}/areas",
{
"name": definition["name"],
"geometry": definition["geometry"],
"crs": "EPSG:4326",
},
)
areas_by_project[project_name].append(area)
created = True
else:
area = existing[0]
geometry = area.get("geometry")
if not isinstance(geometry, dict):
raise RuntimeError(f"Area {definition['name']} has no serialized geometry")
expected_hash = canonical_hash(definition["geometry"])
actual_hash = canonical_hash(geometry)
if actual_hash != expected_hash:
raise RuntimeError(
f"Area {definition['name']} geometry drifted: expected {expected_hash}, got {actual_hash}"
)
evidence.append(
{
"key": definition["key"],
"project_name": project_name,
"project_id": str(project["id"]),
"area_id": str(area["id"]),
"area_name": str(area["name"]),
"bbox": geometry_bbox(geometry),
"geometry_sha256": actual_hash,
"expected_zones": definition["expected_zones"],
"created": created,
}
)
if missing_area_names:
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
for name in missing_area_names:
print(f"- {name}", file=sys.stderr)
return 3
national_project = projects_by_name[NATIONAL_PROJECT]
for definition in SOURCE_AREAS:
source_project_name = str(definition["source_project"])
source_area = find_one(
areas_by_project[source_project_name],
definition["predicate"],
f"{definition['key']} Area in {source_project_name}",
)
geometry = source_area.get("geometry")
if not isinstance(geometry, dict):
raise RuntimeError(f"Area {source_area['name']} has no serialized geometry")
existing = [
area
for area in areas_by_project[NATIONAL_PROJECT]
if area.get("name") == definition["target_name"]
]
if len(existing) > 1:
raise RuntimeError(f"Duplicate release Area name: {definition['target_name']}")
created = False
if not existing:
if not args.apply:
missing_area_names.append(str(definition["target_name"]))
continue
area = client.request(
"POST",
f"/api/v1/projects/{national_project['id']}/areas",
{
"name": definition["target_name"],
"geometry": geometry,
"crs": "EPSG:4326",
},
)
areas_by_project[NATIONAL_PROJECT].append(area)
created = True
else:
area = existing[0]
target_geometry = area.get("geometry")
if not isinstance(target_geometry, dict):
raise RuntimeError(f"Area {area['name']} has no serialized geometry")
source_hash = canonical_hash(geometry)
target_hash = canonical_hash(target_geometry)
if target_hash != source_hash:
raise RuntimeError(
f"Area {definition['target_name']} geometry drifted from "
f"{source_project_name}: expected {source_hash}, got {target_hash}"
)
evidence.append(
{
"key": definition["key"],
"project_name": NATIONAL_PROJECT,
"project_id": str(national_project["id"]),
"area_id": str(area["id"]),
"area_name": str(area["name"]),
"bbox": geometry_bbox(target_geometry),
"geometry_sha256": target_hash,
"expected_zones": definition["expected_zones"],
"created": created,
"source_project_name": source_project_name,
"source_project_id": str(projects_by_name[source_project_name]["id"]),
"source_area_id": str(source_area["id"]),
"source_area_name": str(source_area["name"]),
}
)
if missing_area_names:
print("Missing release Areas (rerun with --apply):", file=sys.stderr)
for name in missing_area_names:
print(f"- {name}", file=sys.stderr)
return 3
evidence.sort(key=lambda item: item["key"])
manifest = {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"base_url": args.base_url.rstrip("/"),
"area_count": len(evidence),
"areas": evidence,
}
output = json.dumps(manifest, indent=2, sort_keys=True)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(f"{output}\n", encoding="utf-8")
print(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())