Files
geointel/scripts/provision_thematic_rasters.py
T
Codex 035ec3b233
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: add cross-domain Mol data profile
2026-07-16 03:06:36 +02:00

183 lines
7.7 KiB
Python

"""Provision governed Flemish thematic rasters through the GeoIntel API.
The safe default loads all five products for the official Mol municipality
Area. Use --all-municipalities with an explicitly named regional project to
load every persisted municipality Area. The operator never writes to PostGIS
or storage directly and never accepts an arbitrary external service URL.
"""
from __future__ import annotations
import argparse
import json
import os
from typing import Any
import requests
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_PROJECT_NAME = "Mol Municipality Workbench"
DEFAULT_AREA_FRAGMENT = "Gemeente Mol"
DEFAULT_PRODUCTS = (
"space_occupation_2025",
"open_space_2022",
"population_density_2019",
"node_value_2022",
"service_level_2022",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision governed Flemish thematic raster products.")
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", default=DEFAULT_AREA_FRAGMENT, help="Case-insensitive Area name fragment.")
parser.add_argument("--products", default=",".join(DEFAULT_PRODUCTS))
parser.add_argument("--all-municipalities", action="store_true", help="Process every Area whose name starts with 'Gemeente '.")
parser.add_argument("--force-refresh", action="store_true")
parser.add_argument("--timeout", type=int, default=900)
parser.add_argument("--dry-run", action="store_true")
return parser.parse_args()
def unwrap(response: requests.Response) -> Any:
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError(f"GeoIntel returned non-JSON HTTP {response.status_code}: {response.text[:300]}") from exc
if not response.ok:
error = payload.get("error") if isinstance(payload, dict) else None
message = error.get("message") if isinstance(error, dict) else response.text[:300]
raise RuntimeError(f"GeoIntel HTTP {response.status_code}: {message}")
return payload.get("data") if isinstance(payload, dict) and "data" in payload else payload
def paged_items(session: requests.Session, url: str, timeout: int) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
offset = 0
while True:
separator = "&" if "?" in url else "?"
page = unwrap(session.get(f"{url}{separator}limit=200&offset={offset}", timeout=timeout))
rows = list(page.get("items") or [])
items.extend(rows)
total = int(page.get("total") or 0)
if not rows or len(items) >= total:
return items
offset += len(rows)
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, Any]:
points: list[tuple[float, float]] = []
def visit(value: Any) -> None:
if isinstance(value, list) and len(value) >= 2 and all(isinstance(item, (int, float)) for item in value[:2]):
points.append((float(value[0]), float(value[1])))
return
if isinstance(value, list):
for item in value:
visit(item)
visit(geometry.get("coordinates"))
if not points:
raise RuntimeError("Persisted Area geometry contains no coordinates")
return {
"min_x": min(point[0] for point in points),
"min_y": min(point[1] for point in points),
"max_x": max(point[0] for point in points),
"max_y": max(point[1] for point in points),
"crs": "EPSG:4326",
}
def find_project(projects: list[dict[str, Any]], name: str) -> dict[str, Any]:
matches = [project for project in projects if str(project.get("name", "")).casefold() == name.casefold()]
if len(matches) != 1:
raise RuntimeError(f"Expected exactly one project named {name!r}, found {len(matches)}")
return matches[0]
def select_areas(areas: list[dict[str, Any]], fragment: str, all_municipalities: bool) -> list[dict[str, Any]]:
if all_municipalities:
selected = [area for area in areas if str(area.get("name", "")).casefold().startswith("gemeente ")]
else:
selected = [area for area in areas if fragment.casefold() in str(area.get("name", "")).casefold()]
if not selected:
raise RuntimeError("No persisted Area matches the requested scope")
selected.sort(key=lambda item: str(item.get("name", "")).casefold())
return selected
def main() -> int:
args = parse_args()
base_url = args.base_url.rstrip("/")
requested_products = [value.strip() for value in args.products.split(",") if value.strip()]
if not requested_products:
raise RuntimeError("Select at least one thematic raster product")
session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-Thematic-Raster-Operator/1.0"})
projects = paged_items(session, f"{base_url}/api/v1/projects", args.timeout)
project = find_project(projects, args.project_name)
project_id = str(project["id"])
areas = paged_items(session, f"{base_url}/api/v1/projects/{project_id}/areas", args.timeout)
selected_areas = select_areas(areas, args.area, args.all_municipalities)
registry = unwrap(session.get(f"{base_url}/api/v1/projects/{project_id}/datasets/thematic-raster/products", timeout=args.timeout))
products = {str(item["key"]): item for item in registry.get("items") or []}
unknown = sorted(set(requested_products) - set(products))
if unknown:
raise RuntimeError(f"Products are not present in the canonical registry: {', '.join(unknown)}")
print(json.dumps({
"status": "planned" if args.dry_run else "running",
"project_id": project_id,
"project_name": project["name"],
"area_count": len(selected_areas),
"products": requested_products,
}, ensure_ascii=False))
if args.dry_run:
for area in selected_areas:
print(json.dumps({"area_id": area["id"], "area_name": area["name"], "bbox": geometry_bbox(area["geometry"])}, ensure_ascii=False))
return 0
results: list[dict[str, Any]] = []
for area in selected_areas:
bbox = geometry_bbox(area["geometry"])
for product_key in requested_products:
acquisition = unwrap(session.post(
f"{base_url}/api/v1/projects/{project_id}/datasets/thematic-raster/acquire",
json={
"bbox": bbox,
"area_id": area["id"],
"product_key": product_key,
"force_refresh": args.force_refresh,
},
timeout=args.timeout,
))
if acquisition.get("status") != "success" or not acquisition.get("output_dataset_id"):
raise RuntimeError(f"Acquisition failed for {area['name']} / {product_key}: {acquisition}")
dataset_id = str(acquisition["output_dataset_id"])
analysis = unwrap(session.post(
f"{base_url}/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/thematic/select",
json={"bbox": bbox, "area_id": area["id"]},
timeout=args.timeout,
))
result = {
"area_id": area["id"],
"area_name": area["name"],
"product_key": product_key,
"dataset_id": dataset_id,
"reused": bool((acquisition.get("result_json") or {}).get("reused")),
"metric": analysis.get("summary"),
"coverage_ratio": analysis.get("coverage_ratio"),
}
results.append(result)
print(json.dumps(result, ensure_ascii=False))
print(json.dumps({"status": "complete", "dataset_count": len(results), "project_id": project_id}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())