Add governed bathymetry profile workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 15:25:00 +02:00
parent c0cab9e3c5
commit 5b4e18059a
31 changed files with 1990 additions and 4 deletions
+27
View File
@@ -1882,3 +1882,30 @@ boundary artifact. Defaults cap each municipality at 30,000 source features and
the assembled snapshot at 300,000 features. It never truncates silently, never
writes `vector_features` directly and never turns the single 2025 state into a
fabricated historical series.
## Mol VHA bathymetry profiles
Provision and verify the official profile points for the exact persisted Mol
Area:
```bash
docker exec geointel python /app/scripts/provision_mol_bathymetry_profiles.py
```
Use `--force` only for a deliberate fresh provider snapshot. The command finds
`Mol Municipality Workbench` and `Gemeente Mol`, calls the canonical
bathymetry acquisition endpoint and then verifies that vector selection count
and semantic metrics match the persisted Job result. It never writes directly
to PostGIS.
For another approved workspace or Area:
```bash
python scripts/provision_mol_bathymetry_profiles.py \
--base-url http://127.0.0.1:8000 \
--project-name "Project name" \
--area-name "Area name fragment"
```
The output is a point dataset with historical evidence. It is not a continuous
water-bottom raster and cannot calculate current water volume.
@@ -0,0 +1,154 @@
"""Provision official VHA cross-section profile points for the persisted Mol Area.
The operator uses only canonical GeoIntel API endpoints. The backend queries the
official VHA service, clips points against the exact persisted Area geometry,
stores the GeoJSON artifact and persists VectorFeature rows through DatasetService.
"""
from __future__ import annotations
import argparse
import json
import os
from typing import Any, Iterable
import requests
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_PROJECT_NAME = "Mol Municipality Workbench"
DEFAULT_AREA_FRAGMENT = "Gemeente Mol"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision official VHA cross-section profiles 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-name", default=DEFAULT_AREA_FRAGMENT)
parser.add_argument("--timeout", type=int, default=900)
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def unwrap(response: requests.Response) -> Any:
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict) or "data" not in payload:
raise RuntimeError(f"Non-canonical API response from {response.url}")
return payload["data"]
def coordinates(geometry: dict[str, Any]) -> Iterable[tuple[float, float]]:
def walk(value: Any):
if isinstance(value, list) and len(value) >= 2 and all(isinstance(item, (int, float)) for item in value[:2]):
yield float(value[0]), float(value[1])
return
if isinstance(value, list):
for child in value:
yield from walk(child)
yield from walk(geometry.get("coordinates", []))
def geometry_bbox(geometry: dict[str, Any]) -> dict[str, float | str]:
points = list(coordinates(geometry))
if not points:
raise RuntimeError("Persisted Area geometry contains no coordinates")
xs = [point[0] for point in points]
ys = [point[1] for point in points]
return {
"min_x": min(xs),
"min_y": min(ys),
"max_x": max(xs),
"max_y": max(ys),
"crs": "EPSG:4326",
}
def main() -> int:
args = parse_args()
base_url = args.base_url.rstrip("/")
session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-Bathymetry-Operator/1.0"})
projects = unwrap(session.get(f"{base_url}/api/v1/projects", params={"limit": 200, "offset": 0}, timeout=60))["items"]
project = next((item for item in projects if item["name"] == args.project_name), None)
if project is None:
raise RuntimeError(f"Project {args.project_name!r} was not found")
areas = unwrap(
session.get(
f"{base_url}/api/v1/projects/{project['id']}/areas",
params={"limit": 200, "offset": 0},
timeout=60,
)
)["items"]
fragment = args.area_name.casefold()
area = next((item for item in areas if fragment in item["name"].casefold()), None)
if area is None:
raise RuntimeError(f"Area containing {args.area_name!r} was not found")
bbox = geometry_bbox(area["geometry"])
job = unwrap(
session.post(
f"{base_url}/api/v1/projects/{project['id']}/datasets/bathymetry/profiles/acquire",
json={
"bbox": bbox,
"area_id": area["id"],
"force_refresh": args.force,
},
timeout=args.timeout,
)
)
if job.get("status") != "success" or not job.get("output_dataset_id"):
raise RuntimeError(f"Bathymetry profile acquisition failed: {job.get('error_message') or job}")
result = job.get("result_json") or {}
dataset_id = job["output_dataset_id"]
selection = unwrap(
session.post(
f"{base_url}/api/v1/projects/{project['id']}/datasets/{dataset_id}/vector/select",
json={"bbox": bbox, "area_id": area["id"], "limit": 1000},
timeout=args.timeout,
)
)
if selection.get("total_feature_count") != result.get("profile_count"):
raise RuntimeError(
"Persisted vector selection count does not match the exact acquired profile count "
f"({selection.get('total_feature_count')} != {result.get('profile_count')})"
)
metrics = {
item["metric_key"]: item
for item in (selection.get("summary") or {}).get("metrics", [])
if isinstance(item, dict) and item.get("metric_key")
}
if metrics.get("profile_count", {}).get("metric_value") != result.get("profile_count"):
raise RuntimeError("Bathymetry profile summary does not expose the persisted profile count")
print(
json.dumps(
{
"status": "ok",
"project_id": project["id"],
"area_id": area["id"],
"area_name": area["name"],
"bbox": bbox,
"dataset_id": dataset_id,
"reused": bool(result.get("reused")),
"profile_count": result.get("profile_count"),
"document_count": result.get("document_count"),
"structured_depth_count": result.get("structured_depth_count"),
"watercourse_count": result.get("watercourse_count"),
"measurement_date_min": result.get("measurement_date_min"),
"measurement_date_max": result.get("measurement_date_max"),
"metrics": list(metrics.values()),
"limitation_message": result.get("limitation_message"),
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -59,6 +59,7 @@ ${PYTHON_BIN} -m py_compile scripts/orthophoto_release_preflight.py
${PYTHON_BIN} -m py_compile scripts/manage_orthophoto_release.py
${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_bathymetry_profiles.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_dhmv.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_flood_hazards.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_flood_hazards.py