Files
geointel/scripts/provision_mol_bwk_natura2000.py
T
Codex 6cb880c76e
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
Expand BWK nature data across Kempen
2026-07-16 00:20:45 +02:00

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())