Initial public release
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

This commit is contained in:
Jens
2026-08-31 21:56:53 +02:00
commit faeb58ef6d
1386 changed files with 263203 additions and 0 deletions
@@ -0,0 +1,220 @@
"""Provision all current official Flemish municipality Areas.
The municipality inventory is discovered from the official VRBG RefGem
collection at runtime. The script uses the existing geographic-scope artifact
and API persistence flow; it never writes directly to PostGIS.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import requests
from geographic_scopes import GeographicScope, ScopeMember
from provision_geographic_scope import (
VRBG_ATTRIBUTION,
VRBG_ITEMS_URL,
build_scope_payloads,
build_source_session,
provision_scope,
sha256_file,
write_json_atomic,
)
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_OUTPUT_ROOT = Path("/app/storage/operator-data/geographic-scopes")
MIN_EXPECTED_MUNICIPALITIES = 270
MAX_EXPECTED_MUNICIPALITIES = 300
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision the current official Flanders municipality scope.")
parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL))
parser.add_argument(
"--output-root",
type=Path,
default=Path(os.environ.get("GEOINTEL_SCOPE_OUTPUT_ROOT", DEFAULT_OUTPUT_ROOT)),
)
parser.add_argument("--request-timeout", type=int, default=180)
parser.add_argument("--import-timeout", type=int, default=3600)
parser.add_argument("--force", action="store_true")
parser.add_argument("--fetch-only", action="store_true")
parser.add_argument("--min-municipalities", type=int, default=MIN_EXPECTED_MUNICIPALITIES)
parser.add_argument("--max-municipalities", type=int, default=MAX_EXPECTED_MUNICIPALITIES)
return parser.parse_args()
def discover_flanders_scope(
session: requests.Session,
*,
timeout: int,
min_municipalities: int,
max_municipalities: int,
) -> tuple[GeographicScope, list[dict[str, Any]], str]:
if min_municipalities <= 0 or max_municipalities < min_municipalities:
raise ValueError("Municipality count safety limits are invalid")
response = session.get(
VRBG_ITEMS_URL,
params={"f": "application/geo+json", "limit": "1000"},
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
features = payload.get("features")
if not isinstance(features, list):
raise RuntimeError("Official VRBG response does not contain a feature list")
if not min_municipalities <= len(features) <= max_municipalities:
raise RuntimeError(
f"Official VRBG returned {len(features)} municipalities; expected "
f"{min_municipalities}..{max_municipalities}. Refusing an incomplete or broadened scope."
)
by_code: dict[str, dict[str, Any]] = {}
names: set[str] = set()
for feature in features:
if not isinstance(feature, dict) or not isinstance(feature.get("geometry"), dict):
raise RuntimeError("Official VRBG contains a malformed municipality feature")
properties = feature.get("properties")
if not isinstance(properties, dict):
raise RuntimeError("Official VRBG municipality is missing properties")
nis_code = str(properties.get("NISCODE") or "").strip()
name = str(properties.get("NAAM") or "").strip()
if len(nis_code) != 5 or not nis_code.isdigit() or not name:
raise RuntimeError(f"Official VRBG municipality identity is invalid: {nis_code!r} / {name!r}")
if nis_code in by_code or name.casefold() in names:
raise RuntimeError(f"Official VRBG contains a duplicate municipality: {nis_code} / {name}")
by_code[nis_code] = feature
names.add(name.casefold())
ordered = [by_code[code] for code in sorted(by_code)]
members = tuple(
ScopeMember(
name=str(feature["properties"]["NAAM"]).strip(),
nis_code=str(feature["properties"]["NISCODE"]).strip(),
)
for feature in ordered
)
scope = GeographicScope(
key="flanders",
display_name=f"Vlaanderen ({len(members)} gemeenten)",
project_name="Flanders Regional Workbench",
project_region="Vlaanderen, België",
area_name="Vlaanderen - officiële operationele grens",
authority_name="Digitaal Vlaanderen VRBG",
authority_url=VRBG_ITEMS_URL,
scope_type="region",
limitation_message=(
"Officiële unie van de actuele VRBG-gemeentegrenzen. De operationele regio omvat het Vlaamse "
"landgebied; territoriale zee, EEZ en continentaal plat zijn afzonderlijke maritieme scopes."
),
members=members,
)
return scope, ordered, response.url
def prepare_artifacts(
args: argparse.Namespace,
scope: GeographicScope,
features: list[dict[str, Any]],
source_url: str,
) -> tuple[Path, Path, dict[str, Any]]:
output_dir = args.output_root / scope.key
manifest_path = output_dir / "flanders_scope_manifest.json"
generated_at = datetime.now(timezone.utc).isoformat()
snapshot_date = generated_at[:10]
boundary_path = output_dir / f"flanders_boundary_{snapshot_date}.geojson"
members_path = output_dir / f"flanders_municipalities_{snapshot_date}.geojson"
member_codes = list(scope.nis_codes)
if not args.force and manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
cached_boundary = output_dir / str(manifest.get("boundary_filename") or "")
cached_members = output_dir / str(manifest.get("municipalities_filename") or "")
if (
manifest.get("status") == "complete"
and manifest.get("member_nis_codes") == member_codes
and cached_boundary.is_file()
and cached_members.is_file()
and sha256_file(cached_boundary) == manifest.get("boundary_sha256")
and sha256_file(cached_members) == manifest.get("municipalities_sha256")
):
return cached_boundary, cached_members, manifest
boundary_payload, members_payload, summary = build_scope_payloads(
scope,
features,
source_url=source_url,
generated_at=generated_at,
)
write_json_atomic(boundary_path, boundary_payload)
write_json_atomic(members_path, members_payload)
manifest = {
"schema_version": 1,
"status": "complete",
"generated_at": generated_at,
"observed_at": f"{snapshot_date}T00:00:00Z",
"boundary_filename": boundary_path.name,
"boundary_sha256": sha256_file(boundary_path),
"municipalities_filename": members_path.name,
"municipalities_sha256": sha256_file(members_path),
"vrbg_source_url": source_url,
"vrbg_attribution": VRBG_ATTRIBUTION,
"scope_authority_name": scope.authority_name,
"scope_authority_url": scope.authority_url,
"scope_limitation": scope.limitation_message,
**summary,
}
write_json_atomic(manifest_path, manifest, pretty=True)
return boundary_path, members_path, manifest
def main() -> int:
args = parse_args()
args.operator_tool = "provision_flanders_geographic_scope.py"
try:
with build_source_session() as session:
scope, features, source_url = discover_flanders_scope(
session,
timeout=args.request_timeout,
min_municipalities=args.min_municipalities,
max_municipalities=args.max_municipalities,
)
boundary_path, members_path, manifest = prepare_artifacts(args, scope, features, source_url)
workspace = None if args.fetch_only else provision_scope(
args, scope, boundary_path, members_path, manifest
)
except (OSError, RuntimeError, ValueError, KeyError, requests.RequestException) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
print(
json.dumps(
{
"status": "ok",
"mode": "fetch_only" if args.fetch_only else "provisioned",
"scope": scope.key,
"member_count": manifest["member_count"],
"area_km2": manifest["area_km2"],
"wgs84_bbox": manifest["wgs84_bbox"],
"boundary_path": str(boundary_path),
"municipalities_path": str(members_path),
"workspace": workspace,
"limitation": scope.limitation_message,
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())