feat: add cross-domain Mol data profile
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-16 03:06:36 +02:00
parent 4aa0d6da24
commit 035ec3b233
39 changed files with 3049 additions and 22 deletions
+27
View File
@@ -1613,6 +1613,33 @@ take a long time because every VMM WCS tile is bounded, rate-limited and
validated. This is expected operator work; the app never fetches these rasters
on page load or map click.
## Cross-domain Mol profile
Load the five official policy rasters for the exact Mol municipality Area and
immediately verify each persisted selection result:
```bash
docker exec geointel python /app/scripts/provision_thematic_rasters.py
```
Plan the later complete Kempen rollout without source fetches or writes:
```bash
docker exec geointel python /app/scripts/provision_thematic_rasters.py \
--project-name "Kempen Regional Workbench" --all-municipalities --dry-run
```
Load the official DOV soil map for Mol:
```bash
docker exec geointel python /app/scripts/provision_mol_soil_map.py
```
`--fetch-only` builds the soil artifact and manifest without API import;
`--force` is the only way to bypass an existing ready soil Dataset. Both
operators use canonical APIs and persistent operator-evidence storage. They do
not run on application startup.
## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
+661
View File
@@ -0,0 +1,661 @@
"""Provision the official DOV digital soil map for the municipality of Mol.
The operator follows every bounded WFS page, retains checksummed source
responses, clips soil polygons to the persisted Mol Area in EPSG:31370 and
imports the result through GeoIntel's canonical dataset upload route. It does
not write directly to vector_features and it does not treat the historical
1949-1971 field survey as a current drainage observation.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import requests
from pyproj import Transformer
from requests.adapters import HTTPAdapter
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
from shapely.ops import transform as transform_geometry
from shapely.ops import unary_union
from shapely.validation import make_valid
from urllib3.util.retry import Retry
WFS_URL = "https://www.dov.vlaanderen.be/geoserver/wfs"
CATALOG_URL = (
"https://www.vlaanderen.be/datavindplaats/catalogus/"
"digitale-bodemkaart-van-het-vlaams-gewest-bodemtypes"
)
TYPE_NAME = "bodemkaart:bodemtypes"
SOURCE_NAME = "dov_soil_map"
SOURCE_VERSION = "Digitale uitgave juni 2017"
SURVEY_PERIOD = "1949-1971"
OBSERVED_AT = "1971-12-31T23:59:59Z"
VALID_FROM = "1949-01-01T00:00:00Z"
VALID_TO = OBSERVED_AT
ATTRIBUTION = "Databank Ondergrond Vlaanderen - Digitale bodemkaart: bodemtypes"
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_OUTPUT_DIR = "/app/storage/operator-evidence/dov-soil-map/mol"
DEFAULT_PROJECT_NAME = "Mol Municipality Workbench"
DEFAULT_AREA_FRAGMENT = "Gemeente Mol"
DATASET_FILENAME = "dov_soil_map_mol.geojson"
MANIFEST_FILENAME = "dov_soil_map_mol.manifest.json"
SCHEMA_VERSION = 1
TO_LAMBERT72 = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
TO_WGS84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision the official DOV soil map for Mol.")
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-fragment", default=DEFAULT_AREA_FRAGMENT)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(os.environ.get("GEOINTEL_SOIL_MAP_OUTPUT_DIR", DEFAULT_OUTPUT_DIR)),
)
parser.add_argument("--page-limit", type=int, default=500)
parser.add_argument("--max-features", type=int, default=20_000)
parser.add_argument("--request-timeout", type=int, default=180)
parser.add_argument("--import-timeout", type=int, default=1800)
parser.add_argument("--force", action="store_true")
parser.add_argument("--fetch-only", action="store_true")
return parser.parse_args()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
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_bytes_atomic(path: Path, value: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(value)
temporary.replace(path)
def write_json_atomic(path: Path, value: Any, *, pretty: bool = False) -> None:
encoded = json.dumps(
value,
ensure_ascii=False,
indent=2 if pretty else None,
separators=None if pretty else (",", ":"),
).encode("utf-8")
write_bytes_atomic(path, encoded)
def source_session() -> requests.Session:
retry = Retry(
total=5,
connect=5,
read=5,
status=5,
backoff_factor=1.0,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET"}),
raise_on_status=True,
)
session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-DOV-Soil-Mol-Operator/1.0"})
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def response_data(response: requests.Response) -> Any:
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError(f"GeoIntel API returned non-JSON ({response.status_code}): {response.text[:300]}") from exc
if not response.ok:
raise RuntimeError(
f"GeoIntel API failed ({response.status_code}): {json.dumps(payload, ensure_ascii=False)[:800]}"
)
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 paginated_items(session: requests.Session, url: str, *, timeout: int) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
offset = 0
total: int | None = None
while total is None or offset < total:
page = response_data(session.get(url, params={"limit": 200, "offset": offset}, timeout=timeout))
page_items = list(page.get("items") or [])
page_total = int(page.get("total") or 0)
if total is None:
total = page_total
elif page_total != total:
raise RuntimeError("GeoIntel pagination total changed while locating the Mol workspace")
items.extend(page_items)
if not page_items:
break
offset += len(page_items)
if total is not None and len(items) != total:
raise RuntimeError(f"GeoIntel pagination returned {len(items)} of {total} items")
return items
def polygonal_geometry(geometry):
if geometry is None or geometry.is_empty:
return None
if not geometry.is_valid:
geometry = make_valid(geometry)
polygons: list[Polygon] = []
def collect(candidate) -> None:
if candidate is None or candidate.is_empty:
return
if isinstance(candidate, Polygon):
polygons.append(candidate)
elif isinstance(candidate, MultiPolygon):
polygons.extend(part for part in candidate.geoms if not part.is_empty)
elif hasattr(candidate, "geoms"):
for part in candidate.geoms:
collect(part)
collect(geometry)
if not polygons:
return None
result = unary_union(polygons)
if not result.is_valid:
result = make_valid(result)
return result if not result.is_empty and result.is_valid else None
def locate_workspace(
session: requests.Session,
base_url: str,
project_name: str,
area_fragment: str,
timeout: int,
) -> tuple[str, str, Any, list[dict[str, Any]]]:
projects = paginated_items(session, f"{base_url}/api/v1/projects", timeout=timeout)
project = next((item for item in projects if item.get("name") == project_name), None)
if not project:
raise RuntimeError(f"Project {project_name!r} is missing")
project_id = str(project["id"])
areas = paginated_items(session, f"{base_url}/api/v1/projects/{project_id}/areas", timeout=timeout)
area = next(
(item for item in areas if area_fragment.casefold() in str(item.get("name") or "").casefold()),
None,
)
if not area or not area.get("geometry"):
raise RuntimeError(f"Persisted Mol Area containing {area_fragment!r} is missing")
boundary_wgs84 = polygonal_geometry(shape(area["geometry"]))
if boundary_wgs84 is None:
raise RuntimeError("Persisted Mol Area is not valid polygonal geometry")
datasets = paginated_items(session, f"{base_url}/api/v1/projects/{project_id}/datasets", timeout=timeout)
return project_id, str(area["id"]), boundary_wgs84, datasets
def iter_wfs_pages(
session: requests.Session,
bbox_lambert72: tuple[float, float, float, float],
*,
page_limit: int,
timeout: int,
) -> Iterable[tuple[dict[str, Any], str, bytes]]:
start_index = 0
expected_total: int | None = None
while True:
params = {
"service": "WFS",
"version": "2.0.0",
"request": "GetFeature",
"typeNames": TYPE_NAME,
"srsName": "EPSG:4326",
"bbox": ",".join(f"{value:.3f}" for value in bbox_lambert72) + ",EPSG:31370",
"count": str(page_limit),
"startIndex": str(start_index),
"sortBy": "gid",
"outputFormat": "application/json",
}
response = session.get(WFS_URL, params=params, timeout=timeout)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection":
raise RuntimeError("DOV WFS returned an invalid FeatureCollection")
features = list(payload.get("features") or [])
matched = int(payload.get("numberMatched") or payload.get("totalFeatures") or 0)
if expected_total is None:
expected_total = matched
elif matched != expected_total:
raise RuntimeError("DOV WFS numberMatched changed during pagination")
yield payload, response.url, response.content
returned = int(payload.get("numberReturned") or len(features))
if returned != len(features):
raise RuntimeError("DOV WFS numberReturned does not match its feature payload")
start_index += returned
if returned == 0 or start_index >= expected_total:
if start_index != expected_total:
raise RuntimeError(f"DOV WFS returned {start_index} of {expected_total} matched features")
break
def normalize_feature(feature: dict[str, Any], boundary_lambert72) -> tuple[dict[str, Any] | None, bool]:
geometry_payload = feature.get("geometry")
if not geometry_payload:
return None, False
source_wgs84 = polygonal_geometry(shape(geometry_payload))
if source_wgs84 is None:
return None, False
source_lambert72 = polygonal_geometry(transform_geometry(TO_LAMBERT72.transform, source_wgs84))
if source_lambert72 is None or not source_lambert72.intersects(boundary_lambert72):
return None, False
was_clipped = not source_lambert72.within(boundary_lambert72)
clipped_lambert72 = polygonal_geometry(source_lambert72.intersection(boundary_lambert72))
if clipped_lambert72 is None or clipped_lambert72.area <= 0:
return None, was_clipped
clipped_wgs84 = polygonal_geometry(transform_geometry(TO_WGS84.transform, clipped_lambert72))
if clipped_wgs84 is None:
return None, was_clipped
raw = dict(feature.get("properties") or {})
gid = raw.get("gid")
map_polygon_id = raw.get("id_kaartvlak")
source_id = str(feature.get("id") or f"{TYPE_NAME}:{gid or map_polygon_id}")
properties = {
"source_name": SOURCE_NAME,
"source_collection": TYPE_NAME,
"source_feature_id": source_id,
"source_gid": gid,
"source_map_polygon_id": map_polygon_id,
"reference_layer_name": "soil",
"theme": "soil",
"authority_level": "authoritative_historical_baseline",
"coverage_scope": "municipality",
"municipality": "Mol",
"nis_code": "13025",
"source_version": SOURCE_VERSION,
"survey_period": SURVEY_PERIOD,
"soil_type_code": raw.get("Bodemtype"),
"unified_soil_type_code": raw.get("Unibodemtype"),
"soil_series_code": raw.get("Bodemserie"),
"soil_series_description": raw.get("Beknopte_omschrijving_bodemserie"),
"soil_generalized_legend": raw.get("Gegeneraliseerde_legende"),
"soil_texture_class_code": raw.get("Textuurklasse_code"),
"soil_texture_class": raw.get("Textuurklasse"),
"soil_drainage_class_code": raw.get("Drainageklasse_code"),
"soil_drainage_class": raw.get("Drainageklasse"),
"soil_profile_group_code": raw.get("Profielontwikkelingsgroep_code"),
"soil_profile_group": raw.get("Profielontwikkelingsgroep"),
"soil_substrate_code": raw.get("Substraat_code"),
"soil_substrate": raw.get("Substraat_Vlaanderen") or raw.get("Substraat_legende"),
"soil_region": raw.get("Streek"),
"classification_type": raw.get("Type_classificatie"),
"soil_map_title": raw.get("Eenduidige_legende_titel"),
"clipped_area_ha": round(float(clipped_lambert72.area) / 10_000.0, 8),
"attribution": ATTRIBUTION,
"historical_drainage_limitation": (
"Drainage class derives from field data collected between 1949 and 1971 and may differ today."
),
}
return {
"type": "Feature",
"id": source_id,
"geometry": mapping(clipped_wgs84),
"properties": properties,
}, was_clipped
def prepare_artifact(
session: requests.Session,
boundary_wgs84,
output_dir: Path,
*,
page_limit: int,
max_features: int,
timeout: int,
) -> tuple[Path, Path, dict[str, Any]]:
output_dir.mkdir(parents=True, exist_ok=True)
raw_dir = output_dir / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
boundary_lambert72 = polygonal_geometry(transform_geometry(TO_LAMBERT72.transform, boundary_wgs84))
if boundary_lambert72 is None:
raise RuntimeError("Mol boundary could not be transformed to EPSG:31370")
retained: list[dict[str, Any]] = []
raw_pages: list[dict[str, Any]] = []
source_urls: list[str] = []
seen_ids: set[str] = set()
raw_feature_count = 0
duplicate_count = 0
rejected_count = 0
clipped_count = 0
area_by_legend: dict[str, float] = defaultdict(float)
area_by_texture: dict[str, float] = defaultdict(float)
area_by_drainage: dict[str, float] = defaultdict(float)
for page_number, (payload, source_url, raw_bytes) in enumerate(
iter_wfs_pages(
session,
boundary_lambert72.bounds,
page_limit=page_limit,
timeout=timeout,
),
start=1,
):
page_path = raw_dir / f"dov_soil_map_page_{page_number:05d}.json"
write_bytes_atomic(page_path, raw_bytes)
features = list(payload.get("features") or [])
raw_feature_count += len(features)
if raw_feature_count > max_features:
raise RuntimeError(
f"DOV WFS exceeded the {max_features} feature safety limit; refusing a truncated import"
)
raw_pages.append(
{
"path": str(page_path.relative_to(output_dir)),
"sha256": sha256_bytes(raw_bytes),
"size_bytes": len(raw_bytes),
"feature_count": len(features),
"source_url": source_url,
}
)
source_urls.append(source_url)
for feature in features:
raw = dict(feature.get("properties") or {})
source_id = str(feature.get("id") or f"{TYPE_NAME}:{raw.get('gid')}")
if source_id in seen_ids:
duplicate_count += 1
continue
seen_ids.add(source_id)
normalized, was_clipped = normalize_feature(feature, boundary_lambert72)
if normalized is None:
rejected_count += 1
continue
if was_clipped:
clipped_count += 1
retained.append(normalized)
properties = normalized["properties"]
area = float(properties["clipped_area_ha"])
area_by_legend[str(properties.get("soil_generalized_legend") or "Onbekend")] += area
area_by_texture[str(properties.get("soil_texture_class") or "Onbekend")] += area
area_by_drainage[str(properties.get("soil_drainage_class") or "Onbekend")] += area
if not retained:
raise RuntimeError("DOV WFS returned no valid soil polygons inside the persisted Mol Area")
generated_at = utc_now()
artifact = {
"type": "FeatureCollection",
"name": "Digitale bodemkaart - Gemeente Mol",
"features": retained,
"source": ATTRIBUTION,
"source_version": SOURCE_VERSION,
"survey_period": SURVEY_PERIOD,
"catalog_url": CATALOG_URL,
"generated_at": generated_at,
}
artifact_path = output_dir / DATASET_FILENAME
write_json_atomic(artifact_path, artifact)
manifest = {
"schema_version": SCHEMA_VERSION,
"source_version": SOURCE_VERSION,
"survey_period": SURVEY_PERIOD,
"source_type_name": TYPE_NAME,
"wfs_url": WFS_URL,
"catalog_url": CATALOG_URL,
"attribution": ATTRIBUTION,
"generated_at": generated_at,
"crs_source_service": "EPSG:31370",
"crs_response_and_persisted": "EPSG:4326",
"crs_clip_and_area_measurement": "EPSG:31370",
"boundary_sha256": sha256_bytes(json.dumps(mapping(boundary_wgs84), sort_keys=True).encode("utf-8")),
"boundary_bbox_wgs84": list(boundary_wgs84.bounds),
"boundary_bbox_epsg31370": list(boundary_lambert72.bounds),
"page_limit": page_limit,
"page_count": len(raw_pages),
"raw_source_feature_count": raw_feature_count,
"feature_count": len(retained),
"duplicate_count": duplicate_count,
"rejected_or_outside_count": rejected_count,
"clipped_feature_count": clipped_count,
"reference_truncated": False,
"raw_pages": raw_pages,
"source_urls": source_urls,
"area_by_generalized_legend_ha": {key: round(value, 6) for key, value in sorted(area_by_legend.items())},
"area_by_texture_ha": {key: round(value, 6) for key, value in sorted(area_by_texture.items())},
"area_by_drainage_ha": {key: round(value, 6) for key, value in sorted(area_by_drainage.items())},
"artifact_path": str(artifact_path),
"artifact_sha256": sha256_file(artifact_path),
"artifact_size_bytes": artifact_path.stat().st_size,
"limitations": [
"The map is based on field data collected between 1949 and 1971.",
"Current drainage, land use and local soil disturbance may differ from the mapped class.",
"The 1:20,000 source is contextual evidence and not a parcel-scale soil investigation.",
],
}
manifest_path = output_dir / MANIFEST_FILENAME
write_json_atomic(manifest_path, manifest, pretty=True)
return artifact_path, manifest_path, manifest
def selection_metrics() -> list[dict[str, Any]]:
return [
{
"metric_key": "soil_dry_sand_area",
"method": "intersection_area",
"label": "Gekarteerd als droog zand",
"unit": "ha",
"geometry_dimension": 2,
"filter_property": "soil_generalized_legend",
"filter_values": ["Droog zand", "Zeer droog zand"],
},
{
"metric_key": "soil_moist_sand_area",
"method": "intersection_area",
"label": "Gekarteerd als vochtig zand",
"unit": "ha",
"geometry_dimension": 2,
"filter_property": "soil_generalized_legend",
"filter_values": ["Vochtig zand"],
},
{
"metric_key": "soil_wet_sand_area",
"method": "intersection_area",
"label": "Gekarteerd als nat zand",
"unit": "ha",
"geometry_dimension": 2,
"filter_property": "soil_generalized_legend",
"filter_values": ["Nat zand", "Zeer nat zand"],
},
{
"metric_key": "soil_anthropogenic_area",
"method": "intersection_area",
"label": "Antropogene bodemklasse",
"unit": "ha",
"geometry_dimension": 2,
"filter_property": "soil_generalized_legend",
"filter_values": ["Antropogeen"],
},
]
def upload_artifact(
session: requests.Session,
*,
base_url: str,
project_id: str,
area_id: str,
artifact_path: Path,
manifest_path: Path,
manifest: dict[str, Any],
timeout: int,
) -> dict[str, Any]:
limitation = (
"Historische bodemkartering op schaal 1:20.000 op basis van veldwerk 1949-1971; "
"de huidige drainage en lokale bodemtoestand kunnen afwijken."
)
source_metadata = {
"provider": SOURCE_NAME,
"theme": "soil",
"layer_type": "soil",
"source_collection": TYPE_NAME,
"source_crs": "EPSG:31370",
"persisted_crs": "EPSG:4326",
"authority_level": "authoritative_historical_baseline",
"coverage_scope": "municipality",
"municipality": "Mol",
"nis_code": "13025",
"feature_count": manifest["feature_count"],
"geometry_clipped_to_area": True,
"semantic_metrics": False,
"survey_period": SURVEY_PERIOD,
"source_scale": "1:20,000",
"attribution": ATTRIBUTION,
"catalog_url": CATALOG_URL,
"license_note": "DOV standard attribution and public GDI reuse conditions apply.",
"limitation_message": limitation,
"selection_aggregation": {
"metric_key": "soil_mapped_area",
"method": "intersection_area",
"label": "Bodemkaartoppervlakte",
"unit": "ha",
"geometry_dimension": 2,
"warning": limitation,
},
"selection_metrics": selection_metrics(),
}
provenance_metadata = {
"operator_tool": "provision_mol_soil_map.py",
"operator_explicit_fetch": True,
"geometry_clipped_to_area": True,
"source_type_name": TYPE_NAME,
"wfs_url": WFS_URL,
"catalog_url": CATALOG_URL,
"manifest_path": str(manifest_path),
"artifact_sha256": manifest["artifact_sha256"],
"raw_page_checksums": {page["path"]: page["sha256"] for page in manifest["raw_pages"]},
"source_urls": manifest["source_urls"],
"reference_truncated": False,
"generated_at": manifest["generated_at"],
"limitations": manifest["limitations"],
}
with artifact_path.open("rb") as handle:
response = session.post(
f"{base_url}/api/v1/projects/{project_id}/datasets/upload",
data={
"dataset_type": "vector",
"source": "operator_official_import",
"dataset_role": "reference",
"source_name": SOURCE_NAME,
"reference_layer_name": "soil",
"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": "dov:digital-soil-map:mol",
"observed_at": OBSERVED_AT,
"valid_from": VALID_FROM,
"valid_to": VALID_TO,
"temporal_granularity": "period",
"source_version": SOURCE_VERSION,
},
files={"file": (artifact_path.name, handle, "application/geo+json")},
timeout=timeout,
)
return response_data(response)
def main() -> int:
args = parse_args()
if args.page_limit < 1 or args.page_limit > 2000 or args.max_features < args.page_limit:
print(json.dumps({"status": "error", "message": "Invalid page or feature safety limits"}), file=sys.stderr)
return 2
base_url = args.base_url.rstrip("/")
api_session = requests.Session()
try:
project_id, area_id, boundary, datasets = locate_workspace(
api_session,
base_url,
args.project_name,
args.area_fragment,
args.request_timeout,
)
existing = next(
(
item
for item in datasets
if item.get("source_name") == SOURCE_NAME
and str(item.get("area_id") or "") == area_id
and item.get("status") == "ready"
),
None,
)
if existing and not args.force:
result = {
"status": "reused",
"project_id": project_id,
"area_id": area_id,
"dataset_id": existing["id"],
"feature_count": existing.get("feature_count"),
}
else:
artifact_path, manifest_path, manifest = prepare_artifact(
source_session(),
boundary,
args.output_dir,
page_limit=args.page_limit,
max_features=args.max_features,
timeout=args.request_timeout,
)
if args.fetch_only:
result = {
"status": "prepared",
"project_id": project_id,
"area_id": area_id,
"artifact_path": str(artifact_path),
"feature_count": manifest["feature_count"],
}
else:
dataset = upload_artifact(
api_session,
base_url=base_url,
project_id=project_id,
area_id=area_id,
artifact_path=artifact_path,
manifest_path=manifest_path,
manifest=manifest,
timeout=args.import_timeout,
)
result = {
"status": "imported",
"project_id": project_id,
"area_id": area_id,
"dataset_id": dataset["id"],
"feature_count": dataset.get("feature_count") or manifest["feature_count"],
"artifact_path": str(artifact_path),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (OSError, RuntimeError, requests.RequestException, ValueError) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+182
View File
@@ -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())
+2
View File
@@ -56,6 +56,8 @@ ${PYTHON_BIN} -m py_compile scripts/provision_buildings_addresses_register.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_dhmv.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_flood_hazards.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_flood_hazards.py
${PYTHON_BIN} -m py_compile scripts/provision_thematic_rasters.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_soil_map.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_timeseries.py
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py