#!/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())