feat: add cross-domain Mol data profile
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user