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
828 lines
32 KiB
Python
828 lines
32 KiB
Python
"""Provision the official 2025 BWK/Natura 2000 dataset for Mol.
|
|
|
|
The operator follows every WFS page, retains raw checksummed responses, clips
|
|
polygon geometry to the persisted Mol Area in EPSG:31370 and imports the final
|
|
GeoJSON through the canonical DatasetService upload route. It never writes
|
|
directly to vector_features.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
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://geo.api.vlaanderen.be/BWK/wfs"
|
|
CATALOG_URL = (
|
|
"https://www.vlaanderen.be/datavindplaats/catalogus/"
|
|
"biologische-waarderingskaart-en-natura-2000-habitatkaart-toestand-2025"
|
|
)
|
|
REPORT_URL = "https://doi.org/10.21436/inbor.129502912"
|
|
TYPE_NAME = "BWK:Bwkhab"
|
|
SOURCE_VERSION = "2025"
|
|
TEMPORAL_SERIES_KEY = "inbo-bwk-natura2000:mol"
|
|
PUBLICATION_DATE = "2025-12-10T00:00:00Z"
|
|
ATTRIBUTION = "Bron: INBO"
|
|
DEFAULT_API_URL = "http://127.0.0.1:8000"
|
|
DEFAULT_OUTPUT_DIR = "/app/storage/operator-evidence/bwk-natura2000-2025/mol"
|
|
DEFAULT_PROJECT_NAME = "Kempen Regional Workbench"
|
|
DEFAULT_AREA_NAME = "Gemeente Mol - officiele grens"
|
|
DATASET_FILENAME = "bwk_natura2000_2025_mol.geojson"
|
|
MANIFEST_FILENAME = "bwk_natura2000_2025_mol.manifest.json"
|
|
SCHEMA_VERSION = 1
|
|
|
|
EVALUATION_LABELS = {
|
|
"z": "Biologisch zeer waardevol",
|
|
"w": "Biologisch waardevol",
|
|
"m": "Biologisch minder waardevol",
|
|
"wz": "Complex van waardevolle en zeer waardevolle elementen",
|
|
"mwz": "Complex van minder waardevolle, waardevolle en zeer waardevolle elementen",
|
|
"mz": "Complex van minder waardevolle en zeer waardevolle elementen",
|
|
"mw": "Complex van minder waardevolle en waardevolle elementen",
|
|
}
|
|
|
|
HABITAT_STATUS_LABELS = {
|
|
"gh": "Geen Natura 2000-habitat aangeduid",
|
|
"hab": "Volledig habitatwaardig kaartvlak",
|
|
"phab": "Gedeeltelijk habitatwaardig kaartvlak",
|
|
"ohab": "Onzeker habitat of kennislacune",
|
|
}
|
|
|
|
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 official BWK/Natura 2000 state 2025 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_NAME)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
type=Path,
|
|
default=Path(os.environ.get("GEOINTEL_BWK_OUTPUT_DIR", DEFAULT_OUTPUT_DIR)),
|
|
)
|
|
parser.add_argument("--page-limit", type=int, default=1000)
|
|
parser.add_argument("--max-features", type=int, default=30_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", help="Refetch source pages and rebuild the artifact.")
|
|
parser.add_argument("--fetch-only", action="store_true", help="Build evidence without importing a Dataset.")
|
|
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-BWK-Natura2000-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 reading the 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)
|
|
return
|
|
if isinstance(candidate, MultiPolygon):
|
|
polygons.extend(part for part in candidate.geoms if not part.is_empty)
|
|
return
|
|
if 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_name: 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)
|
|
normalized_target = area_name.casefold().replace("officiele", "officiële")
|
|
area = next(
|
|
(
|
|
item
|
|
for item in areas
|
|
if str(item.get("name") or "").casefold().replace("officiele", "officiële") == normalized_target
|
|
),
|
|
None,
|
|
)
|
|
if not area:
|
|
area = next((item for item in areas if "gemeente mol" in str(item.get("name") or "").casefold()), None)
|
|
if not area or not area.get("geometry"):
|
|
raise RuntimeError(f"Persisted Mol Area {area_name!r} with geometry is missing")
|
|
boundary_wgs84 = polygonal_geometry(shape(area["geometry"]))
|
|
if boundary_wgs84 is None:
|
|
raise RuntimeError("Persisted Mol Area geometry is invalid or not polygonal")
|
|
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 next_page_url(payload: dict[str, Any]) -> str | None:
|
|
for link in payload.get("links") or []:
|
|
if str(link.get("rel") or "").lower() == "next" and link.get("href"):
|
|
return str(link["href"])
|
|
return None
|
|
|
|
|
|
def iter_wfs_pages(
|
|
session: requests.Session,
|
|
bbox: tuple[float, float, float, float],
|
|
*,
|
|
page_limit: int,
|
|
timeout: int,
|
|
) -> Iterable[tuple[dict[str, Any], str, bytes]]:
|
|
url: str | None = WFS_URL
|
|
base_params: dict[str, str] = {
|
|
"service": "WFS",
|
|
"version": "2.0.0",
|
|
"request": "GetFeature",
|
|
"typeNames": TYPE_NAME,
|
|
"srsName": "EPSG:4326",
|
|
"bbox": ",".join(f"{value:.8f}" for value in bbox) + ",EPSG:4326",
|
|
"count": str(page_limit),
|
|
"sortBy": "UIDN",
|
|
"outputFormat": "application/json",
|
|
}
|
|
params: dict[str, str] | None = dict(base_params)
|
|
seen: set[str] = set()
|
|
next_start_index = 0
|
|
while url:
|
|
request_key = requests.Request("GET", url, params=params).prepare().url or url
|
|
if request_key in seen:
|
|
raise RuntimeError("BWK WFS pagination loop detected")
|
|
seen.add(request_key)
|
|
response = session.get(url, params=params, timeout=timeout)
|
|
params = None
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection":
|
|
raise RuntimeError("BWK WFS returned an invalid FeatureCollection")
|
|
yield payload, response.url, response.content
|
|
next_url = next_page_url(payload)
|
|
number_returned = int(payload.get("numberReturned") or len(payload.get("features") or []))
|
|
next_start_index += number_returned
|
|
if next_url:
|
|
url = next_url
|
|
params = None
|
|
elif number_returned >= page_limit:
|
|
url = WFS_URL
|
|
params = {**base_params, "startIndex": str(next_start_index)}
|
|
else:
|
|
url = None
|
|
params = None
|
|
|
|
|
|
def habitat_breakdown(properties: dict[str, Any]) -> tuple[list[dict[str, Any]], float, float, float]:
|
|
entries: list[dict[str, Any]] = []
|
|
natura_share = 0.0
|
|
regional_share = 0.0
|
|
uncertain_share = 0.0
|
|
for index in range(1, 6):
|
|
code = str(properties.get(f"HAB{index}") or "").strip()
|
|
try:
|
|
share = max(0.0, min(100.0, float(properties.get(f"PHAB{index}") or 0.0)))
|
|
except (TypeError, ValueError):
|
|
share = 0.0
|
|
if not code or share <= 0:
|
|
continue
|
|
normalized = code.lower()
|
|
entries.append({"code": code, "share_percent": share})
|
|
if re.match(r"^\d", normalized):
|
|
natura_share += share
|
|
elif normalized.startswith("rbb"):
|
|
regional_share += share
|
|
elif normalized == "ohab":
|
|
uncertain_share += share
|
|
status = str(properties.get("HABLEGENDE") or "").strip().lower()
|
|
if status == "ohab" and uncertain_share <= 0:
|
|
uncertain_share = 100.0
|
|
return entries, min(natura_share, 100.0), min(regional_share, 100.0), min(uncertain_share, 100.0)
|
|
|
|
|
|
def normalize_feature(
|
|
feature: dict[str, Any],
|
|
boundary_lambert72,
|
|
*,
|
|
municipality: str = "Mol",
|
|
nis_code: str = "13025",
|
|
coverage_scope: str = "municipality",
|
|
feature_id_suffix: str | None = None,
|
|
) -> 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_properties = dict(feature.get("properties") or {})
|
|
source_id = str(feature.get("id") or raw_properties.get("UIDN") or raw_properties.get("OIDN") or "").strip()
|
|
if not source_id:
|
|
source_id = sha256_bytes(json.dumps(geometry_payload, sort_keys=True).encode("utf-8"))
|
|
stable_id = f"BWK:Bwkhab:{raw_properties.get('UIDN') or source_id}"
|
|
if feature_id_suffix:
|
|
stable_id = f"{stable_id}:{feature_id_suffix}"
|
|
evaluation_code = str(raw_properties.get("EVAL") or "").strip().lower()
|
|
habitat_status = str(raw_properties.get("HABLEGENDE") or "").strip().lower()
|
|
habitats, natura_share, regional_share, uncertain_share = habitat_breakdown(raw_properties)
|
|
clipped_area_ha = float(clipped_lambert72.area) / 10_000.0
|
|
natura_codes = [entry["code"] for entry in habitats if re.match(r"^\d", str(entry["code"]))]
|
|
regional_codes = [entry["code"] for entry in habitats if str(entry["code"]).lower().startswith("rbb")]
|
|
|
|
properties = {
|
|
**raw_properties,
|
|
"source_name": "inbo_bwk_natura2000",
|
|
"source_collection": TYPE_NAME,
|
|
"source_feature_id": stable_id,
|
|
"reference_layer_name": "nature_value",
|
|
"theme": "nature_value",
|
|
"authority_level": "authoritative",
|
|
"coverage_scope": coverage_scope,
|
|
"municipality": municipality,
|
|
"nis_code": nis_code,
|
|
"source_version": SOURCE_VERSION,
|
|
"attribution": ATTRIBUTION,
|
|
"bwk_evaluation_code": evaluation_code or "unknown",
|
|
"bwk_evaluation_label": EVALUATION_LABELS.get(evaluation_code, "Onbekende of ontbrekende BWK-waardering"),
|
|
"bwk_label": str(raw_properties.get("BWKLABEL") or "").strip(),
|
|
"bwk_units": ", ".join(
|
|
str(raw_properties.get(f"EENH{index}") or "").strip()
|
|
for index in range(1, 9)
|
|
if str(raw_properties.get(f"EENH{index}") or "").strip()
|
|
),
|
|
"bwk_origin_code": str(raw_properties.get("HERK") or "").strip(),
|
|
"habitat_status_code": habitat_status or "unknown",
|
|
"habitat_status_label": HABITAT_STATUS_LABELS.get(habitat_status, "Onbekende of ontbrekende habitatstatus"),
|
|
"natura2000_codes": ", ".join(natura_codes),
|
|
"regional_biotope_codes": ", ".join(regional_codes),
|
|
"habitat_entries": habitats,
|
|
"habitat_origin_code": str(raw_properties.get("HERKHAB") or "").strip(),
|
|
"habitat_share_origin_code": str(raw_properties.get("HERKPHAB") or "").strip(),
|
|
"clipped_area_ha": round(clipped_area_ha, 8),
|
|
"natura2000_share_percent": natura_share,
|
|
"regional_biotope_share_percent": regional_share,
|
|
"uncertain_habitat_share_percent": uncertain_share,
|
|
"natura2000_area_ha": round(clipped_area_ha * natura_share / 100.0, 8),
|
|
"regional_biotope_area_ha": round(clipped_area_ha * regional_share / 100.0, 8),
|
|
"uncertain_habitat_area_ha": round(clipped_area_ha * uncertain_share / 100.0, 8),
|
|
"habitat_share_spatial_limitation": (
|
|
"PHAB percentages apply to the full source polygon; a partial map selection scales them by intersected area."
|
|
),
|
|
}
|
|
return {
|
|
"type": "Feature",
|
|
"id": stable_id,
|
|
"geometry": mapping(clipped_wgs84),
|
|
"properties": properties,
|
|
}, was_clipped
|
|
|
|
|
|
def reusable_artifact(output_dir: Path) -> tuple[Path, Path, dict[str, Any]] | None:
|
|
artifact_path = output_dir / DATASET_FILENAME
|
|
manifest_path = output_dir / MANIFEST_FILENAME
|
|
if not artifact_path.is_file() or not manifest_path.is_file():
|
|
return None
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
if manifest.get("schema_version") != SCHEMA_VERSION or manifest.get("source_version") != SOURCE_VERSION:
|
|
return None
|
|
if manifest.get("artifact_sha256") != sha256_file(artifact_path):
|
|
return None
|
|
for page in manifest.get("raw_pages") or []:
|
|
page_path = output_dir / str(page.get("path") or "")
|
|
if not page_path.is_file() or page.get("sha256") != sha256_file(page_path):
|
|
return None
|
|
return artifact_path, manifest_path, manifest
|
|
|
|
|
|
def prepare_artifact(
|
|
session: requests.Session,
|
|
boundary_wgs84,
|
|
output_dir: Path,
|
|
*,
|
|
page_limit: int,
|
|
max_features: int,
|
|
timeout: int,
|
|
force: bool,
|
|
) -> tuple[Path, Path, dict[str, Any]]:
|
|
if not force:
|
|
reusable = reusable_artifact(output_dir)
|
|
if reusable:
|
|
return reusable
|
|
|
|
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
|
|
|
|
for page_number, (payload, source_url, raw_bytes) in enumerate(
|
|
iter_wfs_pages(session, boundary_wgs84.bounds, page_limit=page_limit, timeout=timeout),
|
|
start=1,
|
|
):
|
|
page_path = raw_dir / f"bwk_bwkhab_page_{page_number:05d}.json"
|
|
write_bytes_atomic(page_path, raw_bytes)
|
|
page_features = list(payload.get("features") or [])
|
|
raw_feature_count += len(page_features)
|
|
if raw_feature_count > max_features:
|
|
raise RuntimeError(
|
|
f"BWK WFS returned more than 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(page_features),
|
|
"source_url": source_url,
|
|
}
|
|
)
|
|
source_urls.append(source_url)
|
|
for source_feature in page_features:
|
|
source_identity = str(
|
|
source_feature.get("id")
|
|
or (source_feature.get("properties") or {}).get("UIDN")
|
|
or ""
|
|
)
|
|
if source_identity and source_identity in seen_ids:
|
|
duplicate_count += 1
|
|
continue
|
|
if source_identity:
|
|
seen_ids.add(source_identity)
|
|
normalized, was_clipped = normalize_feature(source_feature, boundary_lambert72)
|
|
if normalized is None:
|
|
rejected_count += 1
|
|
continue
|
|
if was_clipped:
|
|
clipped_count += 1
|
|
retained.append(normalized)
|
|
|
|
if not retained:
|
|
raise RuntimeError("BWK WFS returned no valid polygon features inside the persisted Mol Area")
|
|
|
|
evaluation_area: dict[str, float] = defaultdict(float)
|
|
habitat_status_area: dict[str, float] = defaultdict(float)
|
|
natura_area = 0.0
|
|
regional_area = 0.0
|
|
uncertain_area = 0.0
|
|
for feature in retained:
|
|
properties = feature["properties"]
|
|
area = float(properties["clipped_area_ha"])
|
|
evaluation_area[str(properties["bwk_evaluation_code"])] += area
|
|
habitat_status_area[str(properties["habitat_status_code"])] += area
|
|
natura_area += float(properties["natura2000_area_ha"])
|
|
regional_area += float(properties["regional_biotope_area_ha"])
|
|
uncertain_area += float(properties["uncertain_habitat_area_ha"])
|
|
|
|
generated_at = utc_now()
|
|
artifact = {
|
|
"type": "FeatureCollection",
|
|
"name": "BWK en Natura 2000 - toestand 2025 - Gemeente Mol",
|
|
"features": retained,
|
|
"source": "INBO BWK/Natura 2000 WFS",
|
|
"source_version": SOURCE_VERSION,
|
|
"attribution": ATTRIBUTION,
|
|
"catalog_url": CATALOG_URL,
|
|
"report_url": REPORT_URL,
|
|
"coverage_scope": "municipality",
|
|
"municipality": "Mol",
|
|
"nis_code": "13025",
|
|
"reference_truncated": False,
|
|
"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,
|
|
"source_type_name": TYPE_NAME,
|
|
"wfs_url": WFS_URL,
|
|
"catalog_url": CATALOG_URL,
|
|
"report_url": REPORT_URL,
|
|
"attribution": ATTRIBUTION,
|
|
"generated_at": generated_at,
|
|
"crs_source": "EPSG:4326",
|
|
"crs_clip": "EPSG:31370",
|
|
"crs_persisted": "EPSG:4326",
|
|
"boundary_sha256": sha256_bytes(json.dumps(mapping(boundary_wgs84), sort_keys=True).encode("utf-8")),
|
|
"boundary_bbox_wgs84": list(boundary_wgs84.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,
|
|
"evaluation_area_ha": {key: round(value, 6) for key, value in sorted(evaluation_area.items())},
|
|
"habitat_status_area_ha": {key: round(value, 6) for key, value in sorted(habitat_status_area.items())},
|
|
"natura2000_area_ha": round(natura_area, 6),
|
|
"regional_biotope_area_ha": round(regional_area, 6),
|
|
"uncertain_habitat_area_ha": round(uncertain_area, 6),
|
|
"artifact_path": str(artifact_path),
|
|
"artifact_sha256": sha256_file(artifact_path),
|
|
"artifact_size_bytes": artifact_path.stat().st_size,
|
|
"limitations": [
|
|
"The 2025 edition is the best available map state, not one uniform 2025 field survey.",
|
|
"PHAB percentages may be theoretical shares; partial selections scale shares by intersected polygon area.",
|
|
"The real field situation remains authoritative for policy and legal use.",
|
|
],
|
|
}
|
|
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]]:
|
|
share_warning = (
|
|
"Oppervlakte op basis van de officiele PHAB-aandelen. Automatisch verdeelde aandelen kunnen lokaal "
|
|
"afwijken van de terreinsituatie."
|
|
)
|
|
return [
|
|
{
|
|
"metric_key": "bwk_very_valuable_area",
|
|
"method": "intersection_area",
|
|
"label": "Biologisch zeer waardevol",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"filter_property": "bwk_evaluation_code",
|
|
"filter_values": ["z"],
|
|
},
|
|
{
|
|
"metric_key": "bwk_valuable_area",
|
|
"method": "intersection_area",
|
|
"label": "Biologisch waardevol",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"filter_property": "bwk_evaluation_code",
|
|
"filter_values": ["w"],
|
|
},
|
|
{
|
|
"metric_key": "bwk_less_valuable_area",
|
|
"method": "intersection_area",
|
|
"label": "Biologisch minder waardevol",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"filter_property": "bwk_evaluation_code",
|
|
"filter_values": ["m"],
|
|
},
|
|
{
|
|
"metric_key": "bwk_mixed_value_area",
|
|
"method": "intersection_area",
|
|
"label": "Gemengde BWK-waardering",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"filter_property": "bwk_evaluation_code",
|
|
"filter_values": ["wz", "mwz", "mz", "mw"],
|
|
},
|
|
{
|
|
"metric_key": "natura2000_area",
|
|
"method": "area_weighted_sum",
|
|
"property": "natura2000_area_ha",
|
|
"label": "Natura 2000-habitat",
|
|
"unit": "ha",
|
|
"is_estimate": True,
|
|
"warning": share_warning,
|
|
"warning_only_when_estimate": False,
|
|
},
|
|
{
|
|
"metric_key": "regional_biotope_area",
|
|
"method": "area_weighted_sum",
|
|
"property": "regional_biotope_area_ha",
|
|
"label": "Regionaal belangrijk biotoop",
|
|
"unit": "ha",
|
|
"is_estimate": True,
|
|
"warning": share_warning,
|
|
"warning_only_when_estimate": False,
|
|
},
|
|
{
|
|
"metric_key": "uncertain_habitat_area",
|
|
"method": "area_weighted_sum",
|
|
"property": "uncertain_habitat_area_ha",
|
|
"label": "Onzeker habitat / kennislacune",
|
|
"unit": "ha",
|
|
"is_estimate": True,
|
|
"warning": "Dit is een maximale potentiele oppervlakte van kaartvlakken met de status ohab, geen bevestigd habitat.",
|
|
"warning_only_when_estimate": False,
|
|
},
|
|
]
|
|
|
|
|
|
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]:
|
|
source_metadata = {
|
|
"provider": "Instituut voor Natuur- en Bosonderzoek",
|
|
"theme": "nature_value",
|
|
"layer_name": "BWK/Natura 2000 toestand 2025",
|
|
"authority_level": "authoritative",
|
|
"coverage_scope": "municipality",
|
|
"municipality": "Mol",
|
|
"nis_code": "13025",
|
|
"feature_count": manifest["feature_count"],
|
|
"geometry_clipped_to_area": True,
|
|
"semantic_metrics": False,
|
|
"attribution": ATTRIBUTION,
|
|
"catalog_url": CATALOG_URL,
|
|
"report_url": REPORT_URL,
|
|
"selection_aggregation": {
|
|
"metric_key": "bwk_mapped_area",
|
|
"method": "intersection_area",
|
|
"label": "BWK-gekarteerde oppervlakte",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"warning": (
|
|
"Uitgave 2025 is de best beschikbare kaarttoestand, maar niet elk kaartvlak is in 2025 op terrein gekarteerd."
|
|
),
|
|
},
|
|
"selection_metrics": selection_metrics(),
|
|
}
|
|
provenance_metadata = {
|
|
"operator_tool": "provision_mol_bwk_natura2000.py",
|
|
"operator_explicit_fetch": True,
|
|
"geometry_clipped_to_area": True,
|
|
"source_type_name": TYPE_NAME,
|
|
"wfs_url": WFS_URL,
|
|
"catalog_url": CATALOG_URL,
|
|
"report_url": REPORT_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": "inbo_bwk_natura2000",
|
|
"reference_layer_name": "nature_value",
|
|
"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": PUBLICATION_DATE,
|
|
"temporal_granularity": "snapshot",
|
|
"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 > 5000 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("/")
|
|
try:
|
|
with requests.Session() as api_session:
|
|
project_id, area_id, boundary, existing = locate_workspace(
|
|
api_session,
|
|
base_url,
|
|
args.project_name,
|
|
args.area_name,
|
|
args.import_timeout,
|
|
)
|
|
with source_session() as official_session:
|
|
artifact_path, manifest_path, manifest = prepare_artifact(
|
|
official_session,
|
|
boundary,
|
|
args.output_dir,
|
|
page_limit=args.page_limit,
|
|
max_features=args.max_features,
|
|
timeout=args.request_timeout,
|
|
force=args.force,
|
|
)
|
|
existing_dataset = next(
|
|
(
|
|
item
|
|
for item in existing
|
|
if item.get("source_name") == "inbo_bwk_natura2000"
|
|
and item.get("source_version") == SOURCE_VERSION
|
|
and str(item.get("area_id") or "") == area_id
|
|
),
|
|
None,
|
|
)
|
|
if existing_dataset:
|
|
persisted_checksum = str(existing_dataset.get("checksum_sha256") or "")
|
|
if persisted_checksum and persisted_checksum != manifest["artifact_sha256"]:
|
|
raise RuntimeError(
|
|
"A different BWK 2025 Mol artifact is already persisted; refusing a silent duplicate or replacement"
|
|
)
|
|
result = {
|
|
"status": "existing",
|
|
"dataset_id": existing_dataset["id"],
|
|
"feature_count": existing_dataset.get("feature_count") or manifest["feature_count"],
|
|
}
|
|
elif args.fetch_only:
|
|
result = {
|
|
"status": "prepared",
|
|
"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",
|
|
"dataset_id": dataset["id"],
|
|
"feature_count": dataset.get("feature_count"),
|
|
}
|
|
except (KeyError, OSError, RuntimeError, ValueError, 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",
|
|
"project": args.project_name,
|
|
"area": args.area_name,
|
|
"source_version": SOURCE_VERSION,
|
|
"manifest_path": str(manifest_path),
|
|
"metrics": {
|
|
"evaluation_area_ha": manifest["evaluation_area_ha"],
|
|
"natura2000_area_ha": manifest["natura2000_area_ha"],
|
|
"regional_biotope_area_ha": manifest["regional_biotope_area_ha"],
|
|
"uncertain_habitat_area_ha": manifest["uncertain_habitat_area_ha"],
|
|
},
|
|
"result": result,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|