feat: add governed Mol nature value layer
This commit is contained in:
@@ -1158,3 +1158,18 @@ observations through the canonical dataset upload API. Every station has its
|
||||
own temporal-series key. Water levels and discharges remain Point measurements;
|
||||
they are never averaged across stations or presented as municipal water volume.
|
||||
Use `--fetch-only` to prepare and audit artifacts without persistence.
|
||||
|
||||
## BWK/Natura 2000 state 2025
|
||||
|
||||
Run the governed Mol operator after the regional workspace and Mol Area exist:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/provision_mol_bwk_natura2000.py
|
||||
```
|
||||
|
||||
The command fetches the official INBO WFS, retains raw checksummed pages,
|
||||
clips in EPSG:31370 and imports through DatasetService. `--fetch-only` builds
|
||||
evidence without persistence. A conflicting checksum for an already persisted
|
||||
state-2025 Mol Dataset fails closed instead of creating a silent replacement.
|
||||
PostGIS selection summaries keep BWK value classes separate and label
|
||||
PHAB-derived habitat hectares as estimates.
|
||||
|
||||
@@ -24,6 +24,7 @@ FULL_AREA_CLIPPED_OPERATOR_TOOLS = {
|
||||
"provision_regional_grb_buildings.py",
|
||||
"provision_regional_grb_context.py",
|
||||
"provision_waterinfo_station_history.py",
|
||||
"provision_mol_bwk_natura2000.py",
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +85,7 @@ SEMANTIC_SELECTION_METRICS: dict[str, tuple[dict[str, Any], ...]] = {
|
||||
"warning": "GRB-percelen zijn een grafische referentie en vormen geen juridische grensopmeting.",
|
||||
},
|
||||
),
|
||||
"nature_value": (),
|
||||
}
|
||||
|
||||
SEMANTIC_COUNT_LABELS = {
|
||||
@@ -93,6 +95,7 @@ SEMANTIC_COUNT_LABELS = {
|
||||
"water": "Waterobjecten",
|
||||
"roads": "Wegsegmenten",
|
||||
"parcels": "Percelen",
|
||||
"nature_value": "BWK-kaartvlakken",
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +117,10 @@ class VectorFeatureService:
|
||||
"waterways": "water",
|
||||
"road": "roads",
|
||||
"parcel": "parcels",
|
||||
"nature": "nature_value",
|
||||
"biodiversity": "nature_value",
|
||||
"bwk": "nature_value",
|
||||
"natura2000": "nature_value",
|
||||
}
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, str) or not candidate.strip():
|
||||
@@ -356,6 +363,17 @@ class VectorFeatureService:
|
||||
primary_config = semantic_metrics[0]
|
||||
|
||||
metric_configs = [primary_config]
|
||||
configured_metrics = source_metadata.get("selection_metrics")
|
||||
if isinstance(configured_metrics, list):
|
||||
existing_metric_keys = {str(primary_config.get("metric_key") or "")}
|
||||
for configured_item in configured_metrics:
|
||||
if not isinstance(configured_item, dict):
|
||||
continue
|
||||
metric_key = str(configured_item.get("metric_key") or "").strip()
|
||||
if not metric_key or metric_key in existing_metric_keys:
|
||||
continue
|
||||
metric_configs.append(dict(configured_item))
|
||||
existing_metric_keys.add(metric_key)
|
||||
for semantic_metric in semantic_metrics:
|
||||
signature = (semantic_metric["method"], semantic_metric["unit"])
|
||||
existing = {
|
||||
@@ -419,6 +437,20 @@ class VectorFeatureService:
|
||||
metric_filter = selection_filter
|
||||
if dimension in {1, 2}:
|
||||
metric_filter += (func.ST_Dimension(VectorFeature.geometry) == int(dimension),)
|
||||
filter_property = str(config.get("filter_property") or "").strip()
|
||||
filter_values = config.get("filter_values")
|
||||
if filter_property:
|
||||
if not isinstance(filter_values, list) or not filter_values:
|
||||
raise AppError(
|
||||
code="INVALID_SELECTION_AGGREGATION",
|
||||
message="Dataset selection metric filter requires one or more values",
|
||||
details={"dataset_id": str(dataset.id), "filter_property": filter_property},
|
||||
status_code=500,
|
||||
)
|
||||
normalized_filter_values = [str(value) for value in filter_values]
|
||||
metric_filter += (
|
||||
VectorFeature.properties_json.op("->>")(filter_property).in_(normalized_filter_values),
|
||||
)
|
||||
|
||||
if method == "intersection_area":
|
||||
measured_geometry = (
|
||||
@@ -461,7 +493,7 @@ class VectorFeatureService:
|
||||
aggregate_function = func.avg if method == "mean" else func.sum
|
||||
aggregate_value = (
|
||||
db.query(func.coalesce(aggregate_function(value_expression), 0.0))
|
||||
.filter(*selection_filter)
|
||||
.filter(*metric_filter)
|
||||
.filter(VectorFeature.properties_json.op("->>")(property_name).isnot(None))
|
||||
.scalar()
|
||||
)
|
||||
@@ -469,16 +501,16 @@ class VectorFeatureService:
|
||||
if method == "area_weighted_sum" and not full_dataset_area:
|
||||
partial_feature_count = (
|
||||
db.query(func.count(VectorFeature.id))
|
||||
.filter(*selection_filter)
|
||||
.filter(*metric_filter)
|
||||
.filter(coverage_ratio < 0.999999)
|
||||
.scalar()
|
||||
)
|
||||
is_estimate = bool(partial_feature_count)
|
||||
is_estimate = bool(config.get("is_estimate", False)) or bool(partial_feature_count)
|
||||
if not is_estimate and config.get("warning_only_when_estimate", True):
|
||||
warning = None
|
||||
elif method == "area_weighted_sum":
|
||||
is_estimate = False
|
||||
if config.get("warning_only_when_estimate", True):
|
||||
is_estimate = bool(config.get("is_estimate", False))
|
||||
if not is_estimate and config.get("warning_only_when_estimate", True):
|
||||
warning = None
|
||||
elif method != "feature_count":
|
||||
raise AppError(
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import box, shape
|
||||
from shapely.ops import transform as transform_geometry
|
||||
|
||||
from app.models import Dataset
|
||||
from app.schemas.operations import VectorSelectionSummary
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BBOX = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
|
||||
|
||||
|
||||
def load_operator():
|
||||
script_path = ROOT / "scripts" / "provision_mol_bwk_natura2000.py"
|
||||
spec = importlib.util.spec_from_file_location("bwk_natura2000_operator", script_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
ok = True
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload: dict, url: str):
|
||||
self.payload = payload
|
||||
self.url = url
|
||||
self.content = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses: list[FakeResponse]):
|
||||
self.responses = iter(responses)
|
||||
self.calls: list[tuple[str, dict | None]] = []
|
||||
|
||||
def get(self, url, *, params=None, timeout): # noqa: ANN001, ARG002
|
||||
self.calls.append((url, params))
|
||||
return next(self.responses)
|
||||
|
||||
|
||||
class ScalarQuery:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
def filter(self, *args): # noqa: ANN002, ARG002
|
||||
return self
|
||||
|
||||
def scalar(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class SequenceScalarSession:
|
||||
def __init__(self, values: list[float]):
|
||||
self.values = iter(values)
|
||||
|
||||
def query(self, *args): # noqa: ANN002, ARG002
|
||||
return ScalarQuery(next(self.values))
|
||||
|
||||
|
||||
def test_official_bwk_contract_and_class_labels_are_fixed() -> None:
|
||||
module = load_operator()
|
||||
|
||||
assert module.WFS_URL == "https://geo.api.vlaanderen.be/BWK/wfs"
|
||||
assert module.TYPE_NAME == "BWK:Bwkhab"
|
||||
assert module.SOURCE_VERSION == "2025"
|
||||
assert module.ATTRIBUTION == "Bron: INBO"
|
||||
assert module.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",
|
||||
}
|
||||
|
||||
|
||||
def test_wfs_pagination_follows_server_next_links() -> None:
|
||||
module = load_operator()
|
||||
page_one = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [{"id": "one"}],
|
||||
"numberReturned": 1,
|
||||
"links": [{"rel": "next", "href": "https://geo.api.vlaanderen.be/BWK/wfs?STARTINDEX=1"}],
|
||||
}
|
||||
page_two = {"type": "FeatureCollection", "features": [], "numberReturned": 0, "links": []}
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(page_one, "https://geo.api.vlaanderen.be/BWK/wfs?first"),
|
||||
FakeResponse(page_two, "https://geo.api.vlaanderen.be/BWK/wfs?STARTINDEX=1"),
|
||||
]
|
||||
)
|
||||
|
||||
pages = list(module.iter_wfs_pages(session, (5.0, 51.1, 5.2, 51.3), page_limit=1000, timeout=30))
|
||||
|
||||
assert len(pages) == 2
|
||||
assert session.calls[0][1]["sortBy"] == "UIDN"
|
||||
assert session.calls[0][1]["srsName"] == "EPSG:4326"
|
||||
assert session.calls[1] == ("https://geo.api.vlaanderen.be/BWK/wfs?STARTINDEX=1", None)
|
||||
|
||||
|
||||
def test_wfs_page_limit_without_next_link_uses_controlled_start_index_fallback() -> None:
|
||||
module = load_operator()
|
||||
session = FakeSession(
|
||||
[
|
||||
FakeResponse(
|
||||
{"type": "FeatureCollection", "features": [{"id": "one"}], "numberReturned": 1},
|
||||
"https://geo.api.vlaanderen.be/BWK/wfs",
|
||||
),
|
||||
FakeResponse(
|
||||
{"type": "FeatureCollection", "features": [], "numberReturned": 0},
|
||||
"https://geo.api.vlaanderen.be/BWK/wfs?startIndex=1",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
pages = list(module.iter_wfs_pages(session, (5.0, 51.1, 5.2, 51.3), page_limit=1, timeout=30))
|
||||
|
||||
assert len(pages) == 2
|
||||
assert session.calls[1][1]["startIndex"] == "1"
|
||||
|
||||
|
||||
def test_feature_is_clipped_in_lambert72_and_keeps_bwk_habitat_provenance() -> None:
|
||||
module = load_operator()
|
||||
boundary_wgs84 = box(5.10, 51.20, 5.11, 51.21)
|
||||
boundary_lambert72 = transform_geometry(module.TO_LAMBERT72.transform, boundary_wgs84)
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"id": "Bwkhab.42",
|
||||
"geometry": mapping_box(5.095, 51.195, 5.105, 51.205),
|
||||
"properties": {
|
||||
"UIDN": 42,
|
||||
"EVAL": "wz",
|
||||
"BWKLABEL": "qb + qs",
|
||||
"EENH1": "qb",
|
||||
"EENH2": "qs",
|
||||
"HERK": "225",
|
||||
"HAB1": "9190",
|
||||
"PHAB1": 60,
|
||||
"HAB2": "rbbppm",
|
||||
"PHAB2": 30,
|
||||
"HAB3": "gh",
|
||||
"PHAB3": 10,
|
||||
"HABLEGENDE": "phab",
|
||||
"HERKHAB": "225",
|
||||
"HERKPHAB": "a",
|
||||
},
|
||||
}
|
||||
|
||||
normalized, was_clipped = module.normalize_feature(feature, boundary_lambert72)
|
||||
|
||||
assert normalized is not None
|
||||
assert was_clipped is True
|
||||
normalized_geometry = shape(normalized["geometry"])
|
||||
assert normalized_geometry.difference(boundary_wgs84.buffer(1e-7)).area < 1e-12
|
||||
properties = normalized["properties"]
|
||||
assert properties["bwk_evaluation_code"] == "wz"
|
||||
assert properties["bwk_evaluation_label"].startswith("Complex van waardevolle")
|
||||
assert properties["natura2000_codes"] == "9190"
|
||||
assert properties["regional_biotope_codes"] == "rbbppm"
|
||||
assert properties["natura2000_area_ha"] == pytest.approx(properties["clipped_area_ha"] * 0.6)
|
||||
assert properties["regional_biotope_area_ha"] == pytest.approx(properties["clipped_area_ha"] * 0.3)
|
||||
assert properties["habitat_share_origin_code"] == "a"
|
||||
|
||||
|
||||
def mapping_box(min_x: float, min_y: float, max_x: float, max_y: float) -> dict:
|
||||
return {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[
|
||||
[min_x, min_y],
|
||||
[max_x, min_y],
|
||||
[max_x, max_y],
|
||||
[min_x, max_y],
|
||||
[min_x, min_y],
|
||||
]],
|
||||
}
|
||||
|
||||
|
||||
def test_uncertain_habitat_status_is_not_presented_as_confirmed_habitat() -> None:
|
||||
module = load_operator()
|
||||
|
||||
entries, natura_share, regional_share, uncertain_share = module.habitat_breakdown(
|
||||
{"HAB1": "gh", "PHAB1": 100, "HABLEGENDE": "ohab"}
|
||||
)
|
||||
|
||||
assert entries == [{"code": "gh", "share_percent": 100.0}]
|
||||
assert natura_share == 0
|
||||
assert regional_share == 0
|
||||
assert uncertain_share == 100
|
||||
|
||||
|
||||
def test_nature_value_summary_returns_separate_official_classes_and_habitat_metrics() -> None:
|
||||
module = load_operator()
|
||||
dataset = Dataset(
|
||||
id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
name="bwk_natura2000_2025_mol.geojson",
|
||||
dataset_type="vector",
|
||||
dataset_role="reference",
|
||||
source_name="inbo_bwk_natura2000",
|
||||
reference_layer_name="nature_value",
|
||||
source_metadata={
|
||||
"theme": "nature_value",
|
||||
"semantic_metrics": False,
|
||||
"selection_aggregation": {
|
||||
"metric_key": "bwk_mapped_area",
|
||||
"method": "intersection_area",
|
||||
"label": "BWK-gekarteerde oppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
},
|
||||
"selection_metrics": module.selection_metrics(),
|
||||
},
|
||||
)
|
||||
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
SequenceScalarSession([100_000.0, 10_000.0, 20_000.0, 30_000.0, 40_000.0, 5.5, 2.5, 1.5]),
|
||||
dataset=dataset,
|
||||
bbox=BBOX,
|
||||
total_feature_count=125,
|
||||
full_dataset_area=True,
|
||||
)
|
||||
|
||||
assert result["primary_metric_key"] == "bwk_mapped_area"
|
||||
assert result["metric_value"] == 10.0
|
||||
metrics = {item["metric_key"]: item for item in result["metrics"]}
|
||||
assert metrics["bwk_very_valuable_area"]["metric_value"] == 1.0
|
||||
assert metrics["bwk_valuable_area"]["metric_value"] == 2.0
|
||||
assert metrics["bwk_less_valuable_area"]["metric_value"] == 3.0
|
||||
assert metrics["bwk_mixed_value_area"]["metric_value"] == 4.0
|
||||
assert metrics["natura2000_area"]["metric_value"] == 5.5
|
||||
assert metrics["natura2000_area"]["is_estimate"] is True
|
||||
assert metrics["regional_biotope_area"]["metric_value"] == 2.5
|
||||
assert metrics["uncertain_habitat_area"]["metric_value"] == 1.5
|
||||
assert metrics["feature_count"]["metric_value"] == 125
|
||||
VectorSelectionSummary(**result)
|
||||
|
||||
|
||||
def test_operator_is_packaged_readiness_checked_and_wired_to_map() -> None:
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8")
|
||||
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "COPY scripts/provision_mol_bwk_natura2000.py" in dockerfile
|
||||
assert "py_compile scripts/provision_mol_bwk_natura2000.py" in readiness
|
||||
assert '"provision_mol_bwk_natura2000.py"' in service
|
||||
assert "id: 'nature_value'" in map_workspace
|
||||
assert "Natuurwaarde" in map_workspace
|
||||
assert "source.key === 'bwk'" in source_catalog
|
||||
Reference in New Issue
Block a user