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

689 lines
26 KiB
Python

"""Provision a complete, authoritative Mol municipality map workspace.
The script is an explicit operator tool. It fetches the official Mol boundary
and GRB buildings, writes auditable artifacts, and imports them through the
existing GeoIntel API. It is never called automatically by application startup.
"""
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, Iterable
import requests
from requests.adapters import HTTPAdapter
from pyproj import Transformer
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon, mapping, shape
from shapely.ops import transform, unary_union
from shapely.validation import make_valid
from urllib3.util.retry import Retry
MUNICIPALITY_NAME = "Mol"
MUNICIPALITY_NIS_CODE = "13025"
PROJECT_NAME = "Mol Municipality Workbench"
PROJECT_REGION = "Mol, Kempen"
AREA_NAME = "Gemeente Mol - officiële grens"
BOUNDARY_FILENAME = "mol_municipality_boundary.geojson"
BUILDINGS_FILENAME = "mol_grb_gbg_buildings.geojson"
MANIFEST_FILENAME = "mol_municipality_manifest.json"
VRBG_ITEMS_URL = "https://geo.api.vlaanderen.be/VRBG/ogc/features/v1/collections/Refgem/items"
GRB_GBG_ITEMS_URL = "https://geo.api.vlaanderen.be/GRB/ogc/features/v1/collections/GBG/items"
VRBG_ATTRIBUTION = "Bron: Voorlopig referentiebestand gemeentegrenzen, Digitaal Vlaanderen"
GRB_ATTRIBUTION = "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen"
DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data/mol-municipality")
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_PAGE_LIMIT = 1000
DEFAULT_MAX_FEATURES = 100000
GEOJSON_CRS = {"type": "name", "properties": {"name": "EPSG:4326"}}
RETRYABLE_SOURCE_STATUS_CODES = (429, 500, 502, 503, 504)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Fetch and provision the complete official Mol municipality workspace.",
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(os.environ.get("MOL_MUNICIPALITY_OUTPUT_DIR", DEFAULT_OUTPUT_DIR)),
help="Persistent directory for the boundary, GRB buildings and provenance manifest.",
)
parser.add_argument(
"--base-url",
default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL),
help="GeoIntel backend URL. The in-container direct backend avoids reverse-proxy timeouts during large imports.",
)
parser.add_argument(
"--project-name",
default=PROJECT_NAME,
help="Exact idempotent project name used for the municipality workspace.",
)
parser.add_argument(
"--page-limit",
type=int,
default=int(os.environ.get("MOL_GRB_PAGE_LIMIT", str(DEFAULT_PAGE_LIMIT))),
help="GRB OGC API page size.",
)
parser.add_argument(
"--max-features",
type=int,
default=int(os.environ.get("MOL_GRB_MAX_FEATURES", str(DEFAULT_MAX_FEATURES))),
help="Safety cap. The script fails instead of writing a truncated municipality dataset.",
)
parser.add_argument(
"--request-timeout",
type=int,
default=int(os.environ.get("MOL_MUNICIPALITY_REQUEST_TIMEOUT", "180")),
help="Timeout per official source request in seconds.",
)
parser.add_argument(
"--import-timeout",
type=int,
default=int(os.environ.get("MOL_MUNICIPALITY_IMPORT_TIMEOUT", "1800")),
help="Timeout for each GeoIntel API import request in seconds.",
)
parser.add_argument("--force", action="store_true", help="Refetch official source artifacts even when complete local artifacts exist.")
parser.add_argument("--fetch-only", action="store_true", help="Prepare artifacts without creating or updating the GeoIntel workspace.")
return parser.parse_args()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_json(path: Path, payload: dict[str, Any], *, pretty: bool = False) -> None:
path.write_text(
json.dumps(
payload,
ensure_ascii=False,
indent=2 if pretty else None,
separators=None if pretty else (",", ":"),
sort_keys=pretty,
),
encoding="utf-8",
)
def build_source_session() -> requests.Session:
retry = Retry(
total=5,
connect=5,
read=5,
status=5,
backoff_factor=1.0,
status_forcelist=RETRYABLE_SOURCE_STATUS_CODES,
allowed_methods=frozenset({"GET"}),
raise_on_status=True,
)
adapter = HTTPAdapter(max_retries=retry)
session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-Mol-Municipality-Operator/1.0"})
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def next_page_url(payload: dict[str, Any]) -> str | None:
links = payload.get("links") or []
for link in links:
if link.get("rel") == "next" and "geo+json" in str(link.get("type", "")).lower():
return str(link["href"])
for link in links:
if link.get("rel") == "next" and link.get("href"):
return str(link["href"])
return None
def normalize_polygonal(geometry):
if geometry.is_empty:
return None
if not geometry.is_valid:
geometry = make_valid(geometry)
if isinstance(geometry, (Polygon, MultiPolygon)):
return geometry
if isinstance(geometry, GeometryCollection):
polygons = [part for part in geometry.geoms if isinstance(part, (Polygon, MultiPolygon)) and not part.is_empty]
if polygons:
merged = unary_union(polygons)
return merged if isinstance(merged, (Polygon, MultiPolygon)) and not merged.is_empty else None
return None
def fetch_mol_boundary(session: requests.Session, timeout: int) -> tuple[dict[str, Any], Any, str]:
params = {
"f": "application/geo+json",
"limit": "10",
"filter": "NAAM='Mol'",
"filter-lang": "cql2-text",
}
response = session.get(VRBG_ITEMS_URL, params=params, timeout=timeout)
response.raise_for_status()
payload = response.json()
matches = [
feature
for feature in payload.get("features") or []
if str((feature.get("properties") or {}).get("NAAM", "")).casefold() == MUNICIPALITY_NAME.casefold()
]
if len(matches) != 1:
raise RuntimeError(f"Expected exactly one official Mol boundary, received {len(matches)}")
source_feature = matches[0]
properties = dict(source_feature.get("properties") or {})
if str(properties.get("NISCODE")) != MUNICIPALITY_NIS_CODE:
raise RuntimeError(
f"Official Mol boundary NIS code drifted: expected {MUNICIPALITY_NIS_CODE}, received {properties.get('NISCODE')}"
)
boundary = normalize_polygonal(shape(source_feature.get("geometry")))
if boundary is None or not boundary.is_valid:
raise RuntimeError("Official Mol boundary is empty, non-polygonal or invalid")
source_url = response.url
properties.update(
{
"source_name": "vrbg",
"source_feature_id": str(source_feature.get("id") or MUNICIPALITY_NIS_CODE),
"layer_type": "municipality_boundary",
"authority_level": "authoritative",
"coverage_scope": "municipality",
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"attribution": VRBG_ATTRIBUTION,
"source_url": source_url,
}
)
feature = {
"type": "Feature",
"id": str(source_feature.get("id") or f"Refgem.{MUNICIPALITY_NIS_CODE}"),
"geometry": mapping(boundary),
"properties": properties,
}
return feature, boundary, source_url
def iter_grb_pages(
session: requests.Session,
boundary_bounds: tuple[float, float, float, float],
*,
page_limit: int,
timeout: int,
) -> Iterable[tuple[dict[str, Any], str]]:
params = {
"f": "application/geo+json",
"limit": str(page_limit),
"bbox": ",".join(f"{value:.8f}" for value in boundary_bounds),
}
url: str | None = GRB_GBG_ITEMS_URL
seen_urls: set[str] = set()
first_request = True
while url:
if url in seen_urls:
raise RuntimeError(f"GRB pagination loop detected: {url}")
seen_urls.add(url)
response = session.get(url, params=params if first_request else None, timeout=timeout)
first_request = False
response.raise_for_status()
payload = response.json()
yield payload, response.url
url = next_page_url(payload)
def build_municipality_buildings(
pages: Iterable[tuple[dict[str, Any], str]],
boundary,
*,
max_features: int,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
features: list[dict[str, Any]] = []
source_urls: list[str] = []
seen_ids: set[str] = set()
bbox_feature_count = 0
outside_boundary_count = 0
clipped_at_boundary_count = 0
for page, source_url in pages:
source_urls.append(source_url)
for source_feature in page.get("features") or []:
feature_id = str(source_feature.get("id") or "")
if not feature_id:
feature_id = hashlib.sha256(
json.dumps(source_feature.get("geometry"), sort_keys=True).encode("utf-8")
).hexdigest()
if feature_id in seen_ids:
continue
seen_ids.add(feature_id)
bbox_feature_count += 1
source_geometry = normalize_polygonal(shape(source_feature.get("geometry")))
if source_geometry is None or not source_geometry.intersects(boundary):
outside_boundary_count += 1
continue
within_boundary = source_geometry.within(boundary)
clipped_geometry = source_geometry if within_boundary else normalize_polygonal(source_geometry.intersection(boundary))
if clipped_geometry is None:
outside_boundary_count += 1
continue
if not within_boundary:
clipped_at_boundary_count += 1
if len(features) >= max_features:
raise RuntimeError(
f"Mol contains more than the configured {max_features} GRB features; refusing a truncated municipality dataset"
)
properties = dict(source_feature.get("properties") or {})
properties.update(
{
"source_name": "grb",
"source_feature_id": feature_id,
"reference_layer_name": "buildings",
"layer_type": "building",
"authority_level": "authoritative",
"coverage_scope": "municipality",
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"clipped_to_municipality": not within_boundary,
"attribution": GRB_ATTRIBUTION,
}
)
features.append(
{
"type": "Feature",
"id": feature_id,
"geometry": mapping(clipped_geometry),
"properties": properties,
}
)
if not features:
raise RuntimeError("No GRB buildings intersect the official Mol municipality boundary")
return features, {
"pages_fetched": len(source_urls),
"source_urls": source_urls,
"bbox_feature_count": bbox_feature_count,
"municipality_feature_count": len(features),
"outside_boundary_count": outside_boundary_count,
"clipped_at_boundary_count": clipped_at_boundary_count,
"reference_truncated": False,
}
def municipality_area_km2(boundary) -> float:
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
return float(transform(transformer.transform, boundary).area / 1_000_000)
def prepare_artifacts(args: argparse.Namespace) -> tuple[Path, Path, dict[str, Any]]:
output_dir: Path = args.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
boundary_path = output_dir / BOUNDARY_FILENAME
buildings_path = output_dir / BUILDINGS_FILENAME
manifest_path = output_dir / MANIFEST_FILENAME
if not args.force and boundary_path.exists() and buildings_path.exists() and manifest_path.exists():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("status") == "complete" and int(manifest.get("municipality_feature_count") or 0) > 0:
return boundary_path, buildings_path, manifest
if args.page_limit <= 0 or args.max_features <= 0:
raise RuntimeError("--page-limit and --max-features must be positive integers")
with build_source_session() as session:
boundary_feature, boundary, boundary_source_url = fetch_mol_boundary(session, args.request_timeout)
buildings, building_summary = build_municipality_buildings(
iter_grb_pages(
session,
boundary.bounds,
page_limit=args.page_limit,
timeout=args.request_timeout,
),
boundary,
max_features=args.max_features,
)
generated_at = utc_now()
boundary_payload = {
"type": "FeatureCollection",
"name": "Official municipality boundary - Mol",
"crs": GEOJSON_CRS,
"features": [boundary_feature],
"source": "Digitaal Vlaanderen VRBG OGC API Features collection Refgem",
"source_url": boundary_source_url,
"attribution": VRBG_ATTRIBUTION,
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"coverage_scope": "municipality",
"generated_at": generated_at,
}
buildings_payload = {
"type": "FeatureCollection",
"name": "GRB buildings - complete municipality Mol",
"crs": GEOJSON_CRS,
"features": buildings,
"source": "Digitaal Vlaanderen GRB OGC API Features collection GBG",
"source_url": building_summary["source_urls"][0],
"attribution": GRB_ATTRIBUTION,
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"coverage_scope": "municipality",
"reference_truncated": False,
"generated_at": generated_at,
}
write_json(boundary_path, boundary_payload)
write_json(buildings_path, buildings_payload)
manifest = {
"schema_version": 1,
"status": "complete",
"generated_at": generated_at,
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"area_km2": municipality_area_km2(boundary),
"wgs84_bbox": list(boundary.bounds),
"boundary_path": str(boundary_path),
"boundary_sha256": sha256_file(boundary_path),
"buildings_path": str(buildings_path),
"buildings_sha256": sha256_file(buildings_path),
"page_limit": args.page_limit,
"max_features": args.max_features,
"boundary_source_url": boundary_source_url,
"attribution": {"boundary": VRBG_ATTRIBUTION, "buildings": GRB_ATTRIBUTION},
**building_summary,
}
write_json(manifest_path, manifest, pretty=True)
return boundary_path, buildings_path, manifest
def response_data(response: requests.Response) -> Any:
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError(f"GeoIntel API returned non-JSON response ({response.status_code}): {response.text[:500]}") from exc
if not response.ok:
raise RuntimeError(f"GeoIntel API request failed ({response.status_code}): {json.dumps(payload, ensure_ascii=False)[:1000]}")
if not isinstance(payload, dict) or "data" not in payload:
raise RuntimeError("GeoIntel API response does not use the canonical data envelope")
return payload["data"]
def find_or_create_project(session: requests.Session, base_url: str, project_name: str, timeout: int) -> dict[str, Any]:
projects = response_data(session.get(f"{base_url}/api/v1/projects", params={"limit": 200}, timeout=timeout))
existing = next((item for item in projects.get("items") or [] if item.get("name") == project_name), None)
if existing:
return existing
return response_data(
session.post(
f"{base_url}/api/v1/projects",
json={
"name": project_name,
"description": (
"Complete municipality workspace for Mol using the official Digitaal Vlaanderen municipality boundary "
"and the full GRB GBG building reference layer clipped to NIS 13025."
),
"region": PROJECT_REGION,
},
timeout=timeout,
)
)
def find_or_create_area(
session: requests.Session,
base_url: str,
project_id: str,
boundary_path: Path,
timeout: int,
) -> dict[str, Any]:
areas = response_data(
session.get(f"{base_url}/api/v1/projects/{project_id}/areas", params={"limit": 200}, timeout=timeout)
)
existing = next((item for item in areas.get("items") or [] if item.get("name") == AREA_NAME), None)
if existing:
return existing
boundary_payload = json.loads(boundary_path.read_text(encoding="utf-8"))
geometry = boundary_payload["features"][0]["geometry"]
return response_data(
session.post(
f"{base_url}/api/v1/projects/{project_id}/areas",
json={"name": AREA_NAME, "crs": "EPSG:4326", "geometry": geometry},
timeout=timeout,
)
)
def upload_dataset(
session: requests.Session,
base_url: str,
project_id: str,
area_id: str,
path: Path,
*,
dataset_role: str,
source_name: str,
reference_layer_name: str | None,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
temporal_series_key: str,
observed_at: str,
timeout: int,
) -> dict[str, Any]:
form = {
"dataset_type": "vector",
"source": "operator_official_import",
"dataset_role": dataset_role,
"source_name": source_name,
"source_metadata_json": json.dumps(source_metadata, ensure_ascii=False),
"provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False),
"area_id": area_id,
"temporal_series_key": temporal_series_key,
"observed_at": observed_at,
"temporal_granularity": "snapshot",
"source_version": observed_at[:10],
}
if reference_layer_name:
form["reference_layer_name"] = reference_layer_name
with path.open("rb") as handle:
response = session.post(
f"{base_url}/api/v1/projects/{project_id}/datasets/upload",
data=form,
files={"file": (path.name, handle, "application/geo+json")},
timeout=timeout,
)
return response_data(response)
def ensure_temporal_metadata(
session: requests.Session,
base_url: str,
project_id: str,
dataset: dict[str, Any],
*,
temporal_series_key: str,
observed_at: str,
timeout: int,
) -> dict[str, Any]:
if dataset.get("temporal_series_key") == temporal_series_key and dataset.get("observed_at"):
return dataset
return response_data(
session.patch(
f"{base_url}/api/v1/projects/{project_id}/datasets/{dataset['id']}/temporal",
json={
"temporal_series_key": temporal_series_key,
"observed_at": observed_at,
"temporal_granularity": "snapshot",
"source_version": observed_at[:10],
},
timeout=timeout,
)
)
def provision_workspace(
args: argparse.Namespace,
boundary_path: Path,
buildings_path: Path,
manifest: dict[str, Any],
) -> dict[str, Any]:
base_url = args.base_url.rstrip("/")
with requests.Session() as session:
project = find_or_create_project(session, base_url, args.project_name, args.import_timeout)
project_id = str(project["id"])
area = find_or_create_area(session, base_url, project_id, boundary_path, args.import_timeout)
area_id = str(area["id"])
dataset_list = response_data(
session.get(
f"{base_url}/api/v1/projects/{project_id}/datasets",
params={"limit": 200},
timeout=args.import_timeout,
)
)
datasets = list(dataset_list.get("items") or [])
common_provenance = {
"operator_tool": "provision_mol_municipality_workspace.py",
"operator_explicit_fetch": True,
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"coverage_scope": "municipality",
"manifest_path": str(args.output_dir / MANIFEST_FILENAME),
"manifest_generated_at": manifest["generated_at"],
"reference_truncated": False,
}
building_dataset = next((item for item in datasets if item.get("original_filename") == buildings_path.name), None)
if not building_dataset:
building_dataset = upload_dataset(
session,
base_url,
project_id,
area_id,
buildings_path,
dataset_role="reference",
source_name="grb",
reference_layer_name="buildings",
source_metadata={
"provider": "Digitaal Vlaanderen",
"collection": "GRB/GBG",
"authority_level": "authoritative",
"coverage_scope": "municipality",
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"feature_count": manifest["municipality_feature_count"],
"pages_fetched": manifest["pages_fetched"],
"attribution": GRB_ATTRIBUTION,
},
provenance_metadata={
**common_provenance,
"source_url": GRB_GBG_ITEMS_URL,
"artifact_sha256": manifest["buildings_sha256"],
},
temporal_series_key="grb:buildings:mol",
observed_at=manifest["generated_at"],
timeout=args.import_timeout,
)
else:
building_dataset = ensure_temporal_metadata(
session,
base_url,
project_id,
building_dataset,
temporal_series_key="grb:buildings:mol",
observed_at=manifest["generated_at"],
timeout=args.import_timeout,
)
boundary_dataset = next((item for item in datasets if item.get("original_filename") == boundary_path.name), None)
if not boundary_dataset:
boundary_dataset = upload_dataset(
session,
base_url,
project_id,
area_id,
boundary_path,
dataset_role="source",
source_name="vrbg",
reference_layer_name=None,
source_metadata={
"provider": "Digitaal Vlaanderen",
"collection": "VRBG/Refgem",
"authority_level": "authoritative",
"coverage_scope": "municipality",
"layer_type": "municipality_boundary",
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"attribution": VRBG_ATTRIBUTION,
},
provenance_metadata={
**common_provenance,
"source_url": manifest["boundary_source_url"],
"artifact_sha256": manifest["boundary_sha256"],
},
temporal_series_key="vrbg:municipality-boundary:mol",
observed_at=manifest["generated_at"],
timeout=args.import_timeout,
)
else:
boundary_dataset = ensure_temporal_metadata(
session,
base_url,
project_id,
boundary_dataset,
temporal_series_key="vrbg:municipality-boundary:mol",
observed_at=manifest["generated_at"],
timeout=args.import_timeout,
)
return {
"project_id": project_id,
"project_name": project.get("name"),
"area_id": area_id,
"area_name": area.get("name"),
"boundary_dataset_id": str(boundary_dataset["id"]),
"boundary_feature_count": boundary_dataset.get("feature_count"),
"building_dataset_id": str(building_dataset["id"]),
"building_feature_count": building_dataset.get("feature_count"),
}
def main() -> int:
args = parse_args()
try:
boundary_path, buildings_path, manifest = prepare_artifacts(args)
workspace = None if args.fetch_only else provision_workspace(args, boundary_path, buildings_path, manifest)
except (OSError, RuntimeError, requests.RequestException, ValueError, KeyError) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
result = {
"status": "ok",
"mode": "fetch_only" if args.fetch_only else "provisioned",
"manifest_path": str(args.output_dir / MANIFEST_FILENAME),
"municipality": MUNICIPALITY_NAME,
"nis_code": MUNICIPALITY_NIS_CODE,
"area_km2": manifest["area_km2"],
"wgs84_bbox": manifest["wgs84_bbox"],
"municipality_feature_count": manifest["municipality_feature_count"],
"pages_fetched": manifest["pages_fetched"],
"reference_truncated": manifest["reference_truncated"],
"workspace": workspace,
}
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())