feat: add semantic GIS selection metrics
This commit is contained in:
@@ -7,6 +7,20 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 201 Semantic area-selection metrics (2026-07-15)
|
||||
|
||||
- Replaced count-only primary results for known regional themes with meaningful
|
||||
PostGIS measurements: building footprint, forest, water and parcel area in
|
||||
hectares; road and watercourse length in kilometres; and population in
|
||||
inhabitants.
|
||||
- Kept intersecting feature counts as supporting evidence and added an additive
|
||||
metric list without removing the existing primary summary fields.
|
||||
- Added explicit source limitations: building footprint is not floor area or
|
||||
volume, road length is not traffic capacity and water volume is unavailable
|
||||
without reliable depth or bathymetry.
|
||||
- Updated future regional GRB provisioning metadata so new imports persist the
|
||||
semantic primary aggregation directly.
|
||||
|
||||
## Sprint 200 Operational time-series handoff (2026-07-15)
|
||||
|
||||
- Removed the dead-end Evolution state that appeared when the current building
|
||||
|
||||
@@ -947,6 +947,15 @@ frontend requests at most 1,000 features for the current viewport and surfaces
|
||||
the response `truncated` flag; the backend does not provide or imply an
|
||||
unbounded municipality-wide map response.
|
||||
|
||||
Selection summaries expose a primary metric plus an additive `metrics` list.
|
||||
Known persisted themes are aggregated in `EPSG:31370`: building footprints,
|
||||
forest, water surfaces and parcels return hectares; roads and linear
|
||||
watercourses return kilometres; population keeps its configured inhabitant
|
||||
aggregation. Intersecting feature counts remain available as supporting
|
||||
evidence. Water volume is deliberately unavailable because the current GRB
|
||||
source has no reliable depth/bathymetry dimension; GeoIntel does not manufacture
|
||||
volume from 2D polygons.
|
||||
|
||||
`POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive`
|
||||
uses the same persisted `vector_features` selection but writes the result as a
|
||||
new derived vector dataset. The created dataset uses
|
||||
|
||||
@@ -275,7 +275,7 @@ def select_vector_features(
|
||||
full_dataset_area=full_dataset_area,
|
||||
)
|
||||
result = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
|
||||
if isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"):
|
||||
if VectorFeatureService.supports_selection_summary(dataset):
|
||||
summary_kwargs = {
|
||||
"dataset": dataset,
|
||||
"bbox": payload.bbox.model_dump(),
|
||||
|
||||
@@ -79,6 +79,7 @@ from .operations import (
|
||||
VectorSelectionDeriveRequest,
|
||||
VectorSelectionRequest,
|
||||
VectorSelectionResponse,
|
||||
VectorSelectionMetric,
|
||||
VectorSelectionSummary,
|
||||
VectorStatsRequest,
|
||||
VectorStatsResponse,
|
||||
@@ -143,6 +144,7 @@ __all__ = [
|
||||
"VectorSelectionDeriveRequest",
|
||||
"VectorSelectionRequest",
|
||||
"VectorSelectionResponse",
|
||||
"VectorSelectionMetric",
|
||||
"VectorSelectionSummary",
|
||||
"RasterClipRequest",
|
||||
"RasterStatsResponse",
|
||||
|
||||
@@ -220,14 +220,26 @@ class VectorSelectionDeriveRequest(VectorSelectionRequest):
|
||||
output_name: str | None = None
|
||||
|
||||
|
||||
class VectorSelectionMetric(BaseModel):
|
||||
metric_key: str
|
||||
metric_label: str
|
||||
metric_value: float
|
||||
metric_unit: str
|
||||
aggregation_method: str
|
||||
is_estimate: bool = False
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
class VectorSelectionSummary(BaseModel):
|
||||
metric_label: str
|
||||
metric_value: float
|
||||
metric_unit: str
|
||||
aggregation_method: str
|
||||
primary_metric_key: str | None = None
|
||||
feature_count: int
|
||||
is_estimate: bool = False
|
||||
warning: str | None = None
|
||||
metrics: list[VectorSelectionMetric] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VectorSelectionResponse(BaseModel):
|
||||
|
||||
@@ -26,7 +26,110 @@ FULL_AREA_CLIPPED_OPERATOR_TOOLS = {
|
||||
}
|
||||
|
||||
|
||||
SEMANTIC_SELECTION_METRICS: dict[str, tuple[dict[str, Any], ...]] = {
|
||||
"buildings": (
|
||||
{
|
||||
"metric_key": "footprint_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Bebouwde grondoppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
"warning": "Dit is de grondoppervlakte van gebouwcontouren, niet de totale vloeroppervlakte of het gebouwvolume.",
|
||||
},
|
||||
),
|
||||
"forest": (
|
||||
{
|
||||
"metric_key": "forest_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Bosoppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
},
|
||||
),
|
||||
"water": (
|
||||
{
|
||||
"metric_key": "water_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Wateroppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
"warning": "Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens. De kaartbron levert alleen oppervlakte- en lijngeometrie.",
|
||||
},
|
||||
{
|
||||
"metric_key": "watercourse_length",
|
||||
"method": "intersection_length",
|
||||
"label": "Lengte waterlopen",
|
||||
"unit": "km",
|
||||
"geometry_dimension": 1,
|
||||
},
|
||||
),
|
||||
"roads": (
|
||||
{
|
||||
"metric_key": "road_length",
|
||||
"method": "intersection_length",
|
||||
"label": "Totale weglengte",
|
||||
"unit": "km",
|
||||
"geometry_dimension": 1,
|
||||
"warning": "De lengte volgt de GRB-wegsegmenten en zegt niets over rijstroken, verkeersvolume of verhardingsoppervlakte.",
|
||||
},
|
||||
),
|
||||
"parcels": (
|
||||
{
|
||||
"metric_key": "parcel_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Perceeloppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
"warning": "GRB-percelen zijn een grafische referentie en vormen geen juridische grensopmeting.",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
SEMANTIC_COUNT_LABELS = {
|
||||
"buildings": "Gebouwen",
|
||||
"population": "Statistische sectoren",
|
||||
"forest": "Bosvlakken",
|
||||
"water": "Waterobjecten",
|
||||
"roads": "Wegsegmenten",
|
||||
"parcels": "Percelen",
|
||||
}
|
||||
|
||||
|
||||
class VectorFeatureService:
|
||||
@staticmethod
|
||||
def _dataset_theme(dataset: Dataset) -> str | None:
|
||||
source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
|
||||
candidates = (
|
||||
source_metadata.get("theme"),
|
||||
dataset.reference_layer_name,
|
||||
source_metadata.get("layer_type"),
|
||||
)
|
||||
aliases = {
|
||||
"building": "buildings",
|
||||
"bebouwing": "buildings",
|
||||
"population": "population",
|
||||
"forest": "forest",
|
||||
"forestry": "forest",
|
||||
"waterways": "water",
|
||||
"road": "roads",
|
||||
"parcel": "parcels",
|
||||
}
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, str) or not candidate.strip():
|
||||
continue
|
||||
normalized = candidate.strip().lower()
|
||||
if normalized.startswith("regional_"):
|
||||
normalized = normalized.removeprefix("regional_")
|
||||
normalized = aliases.get(normalized, normalized)
|
||||
if normalized in {*SEMANTIC_SELECTION_METRICS, "population"}:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def supports_selection_summary(dataset: Dataset) -> bool:
|
||||
source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
|
||||
return isinstance(source_metadata.get("selection_aggregation"), dict) or VectorFeatureService._dataset_theme(dataset) is not None
|
||||
|
||||
@staticmethod
|
||||
def can_use_full_area_fast_path(dataset: Dataset, selection_area_id: UUID | None) -> bool:
|
||||
if selection_area_id is None or dataset.area_id != selection_area_id:
|
||||
@@ -175,7 +278,7 @@ class VectorFeatureService:
|
||||
selected_rows = rows[:safe_limit]
|
||||
features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows]
|
||||
summary = None
|
||||
if dataset and isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"):
|
||||
if dataset and VectorFeatureService.supports_selection_summary(dataset):
|
||||
summary = VectorFeatureService.summarize_features_by_bbox(
|
||||
db,
|
||||
dataset=dataset,
|
||||
@@ -232,13 +335,86 @@ class VectorFeatureService:
|
||||
config = source_metadata.get("selection_aggregation")
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
theme = VectorFeatureService._dataset_theme(dataset)
|
||||
configured_metric = {
|
||||
"metric_key": str(config.get("metric_key") or config.get("method") or "feature_count"),
|
||||
"method": str(config.get("method") or "feature_count"),
|
||||
"label": str(config.get("label") or SEMANTIC_COUNT_LABELS.get(theme or "", "Objecten")),
|
||||
"unit": str(config.get("unit") or "objecten"),
|
||||
"warning": str(config["warning"]) if config.get("warning") else None,
|
||||
"is_estimate": bool(config.get("is_estimate", False)),
|
||||
**({"property": config.get("property")} if config.get("property") else {}),
|
||||
}
|
||||
semantic_metrics = [dict(metric) for metric in SEMANTIC_SELECTION_METRICS.get(theme or "", ())]
|
||||
primary_config = configured_metric
|
||||
if configured_metric["method"] == "feature_count" and semantic_metrics:
|
||||
primary_config = semantic_metrics[0]
|
||||
|
||||
metric_configs = [primary_config]
|
||||
for semantic_metric in semantic_metrics:
|
||||
signature = (semantic_metric["method"], semantic_metric["unit"])
|
||||
existing = {
|
||||
(item["method"], item["unit"])
|
||||
for item in metric_configs
|
||||
}
|
||||
if signature not in existing:
|
||||
metric_configs.append(semantic_metric)
|
||||
if not any(item["method"] == "feature_count" for item in metric_configs):
|
||||
metric_configs.append(
|
||||
{
|
||||
"metric_key": "feature_count",
|
||||
"method": "feature_count",
|
||||
"label": SEMANTIC_COUNT_LABELS.get(theme or "", "Objecten"),
|
||||
"unit": "objecten",
|
||||
}
|
||||
)
|
||||
|
||||
metrics = [
|
||||
VectorFeatureService._calculate_selection_metric(
|
||||
db,
|
||||
dataset=dataset,
|
||||
config=metric_config,
|
||||
selection_filter=selection_filter,
|
||||
selection_shape=selection_shape,
|
||||
feature_count=feature_count,
|
||||
full_dataset_area=full_dataset_area,
|
||||
)
|
||||
for metric_config in metric_configs
|
||||
]
|
||||
primary_metric = metrics[0]
|
||||
return {
|
||||
"metric_label": primary_metric["metric_label"],
|
||||
"metric_value": primary_metric["metric_value"],
|
||||
"metric_unit": primary_metric["metric_unit"],
|
||||
"aggregation_method": primary_metric["aggregation_method"],
|
||||
"primary_metric_key": primary_metric["metric_key"],
|
||||
"feature_count": feature_count,
|
||||
"is_estimate": primary_metric["is_estimate"],
|
||||
"warning": primary_metric.get("warning"),
|
||||
"metrics": metrics,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _calculate_selection_metric(
|
||||
db,
|
||||
*,
|
||||
dataset: Dataset,
|
||||
config: dict[str, Any],
|
||||
selection_filter: tuple[Any, ...],
|
||||
selection_shape: Any,
|
||||
feature_count: int,
|
||||
full_dataset_area: bool,
|
||||
) -> dict[str, Any]:
|
||||
method = str(config.get("method") or "feature_count")
|
||||
label = str(config.get("label") or "Objecten")
|
||||
unit = str(config.get("unit") or "objecten")
|
||||
warning = str(config["warning"]) if config.get("warning") else None
|
||||
is_estimate = bool(config.get("is_estimate", False))
|
||||
|
||||
metric_value = float(feature_count)
|
||||
dimension = config.get("geometry_dimension")
|
||||
metric_filter = selection_filter
|
||||
if dimension in {1, 2}:
|
||||
metric_filter += (func.ST_Dimension(VectorFeature.geometry) == int(dimension),)
|
||||
|
||||
if method == "intersection_area":
|
||||
measured_geometry = (
|
||||
VectorFeature.geometry
|
||||
@@ -246,7 +422,7 @@ class VectorFeatureService:
|
||||
else func.ST_Intersection(VectorFeature.geometry, selection_shape)
|
||||
)
|
||||
area_expression = func.ST_Area(func.ST_Transform(measured_geometry, 31370))
|
||||
area_m2 = db.query(func.coalesce(func.sum(area_expression), 0.0)).filter(*selection_filter).scalar()
|
||||
area_m2 = db.query(func.coalesce(func.sum(area_expression), 0.0)).filter(*metric_filter).scalar()
|
||||
divisor = 10_000.0 if unit == "ha" else 1.0
|
||||
metric_value = float(area_m2 or 0.0) / divisor
|
||||
elif method == "intersection_length":
|
||||
@@ -256,7 +432,7 @@ class VectorFeatureService:
|
||||
else func.ST_Intersection(VectorFeature.geometry, selection_shape)
|
||||
)
|
||||
length_expression = func.ST_Length(func.ST_Transform(measured_geometry, 31370))
|
||||
length_m = db.query(func.coalesce(func.sum(length_expression), 0.0)).filter(*selection_filter).scalar()
|
||||
length_m = db.query(func.coalesce(func.sum(length_expression), 0.0)).filter(*metric_filter).scalar()
|
||||
divisor = 1_000.0 if unit == "km" else 1.0
|
||||
metric_value = float(length_m or 0.0) / divisor
|
||||
elif method in {"sum", "area_weighted_sum"}:
|
||||
@@ -307,11 +483,11 @@ class VectorFeatureService:
|
||||
)
|
||||
|
||||
return {
|
||||
"metric_label": label,
|
||||
"metric_key": str(config.get("metric_key") or method),
|
||||
"metric_label": str(config.get("label") or "Objecten"),
|
||||
"metric_value": metric_value,
|
||||
"metric_unit": unit,
|
||||
"aggregation_method": method,
|
||||
"feature_count": feature_count,
|
||||
"is_estimate": is_estimate,
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models import Dataset
|
||||
from app.schemas.operations import VectorSelectionSummary
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
BBOX = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
|
||||
|
||||
|
||||
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 themed_dataset(theme: str, *, method: str = "feature_count") -> Dataset:
|
||||
return Dataset(
|
||||
id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
name=f"regional-{theme}.geojson",
|
||||
dataset_type="vector",
|
||||
dataset_role="reference",
|
||||
source_name="grb",
|
||||
reference_layer_name=theme,
|
||||
source_metadata={
|
||||
"theme": theme,
|
||||
"selection_aggregation": {
|
||||
"method": method,
|
||||
"label": theme.title(),
|
||||
"unit": "objecten",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_building_selection_promotes_footprint_area_and_retains_object_count() -> None:
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
SequenceScalarSession([125_000.0]),
|
||||
dataset=themed_dataset("buildings"),
|
||||
bbox=BBOX,
|
||||
total_feature_count=40,
|
||||
)
|
||||
|
||||
assert result["primary_metric_key"] == "footprint_area"
|
||||
assert result["metric_label"] == "Bebouwde grondoppervlakte"
|
||||
assert result["metric_value"] == 12.5
|
||||
assert result["metric_unit"] == "ha"
|
||||
assert [(item["metric_key"], item["metric_value"]) for item in result["metrics"]] == [
|
||||
("footprint_area", 12.5),
|
||||
("feature_count", 40.0),
|
||||
]
|
||||
assert "niet de totale vloeroppervlakte" in result["warning"]
|
||||
VectorSelectionSummary(**result)
|
||||
|
||||
|
||||
def test_water_selection_reports_surface_length_and_honest_volume_limitation() -> None:
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
SequenceScalarSession([52_500.0, 12_750.0]),
|
||||
dataset=themed_dataset("water"),
|
||||
bbox=BBOX,
|
||||
total_feature_count=23,
|
||||
)
|
||||
|
||||
assert result["metric_value"] == 5.25
|
||||
assert result["metric_unit"] == "ha"
|
||||
assert [(item["metric_key"], item["metric_value"], item["metric_unit"]) for item in result["metrics"]] == [
|
||||
("water_area", 5.25, "ha"),
|
||||
("watercourse_length", 12.75, "km"),
|
||||
("feature_count", 23.0, "objecten"),
|
||||
]
|
||||
assert "Watervolume is niet berekenbaar" in result["warning"]
|
||||
|
||||
|
||||
def test_population_keeps_configured_metric_and_adds_sector_count() -> None:
|
||||
dataset = themed_dataset("population", method="sum")
|
||||
dataset.source_metadata["selection_aggregation"].update(
|
||||
{"metric_key": "population", "property": "population_total", "label": "Inwoners", "unit": "inwoners"}
|
||||
)
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
SequenceScalarSession([86_458.0]),
|
||||
dataset=dataset,
|
||||
bbox=BBOX,
|
||||
total_feature_count=733,
|
||||
)
|
||||
|
||||
assert result["primary_metric_key"] == "population"
|
||||
assert result["metric_value"] == 86_458.0
|
||||
assert result["metrics"][1] == {
|
||||
"metric_key": "feature_count",
|
||||
"metric_label": "Statistische sectoren",
|
||||
"metric_value": 733.0,
|
||||
"metric_unit": "objecten",
|
||||
"aggregation_method": "feature_count",
|
||||
"is_estimate": False,
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
|
||||
def test_future_regional_imports_persist_semantic_aggregation_configuration() -> None:
|
||||
buildings = (ROOT / "scripts/provision_regional_grb_buildings.py").read_text(encoding="utf-8")
|
||||
context = (ROOT / "scripts/provision_regional_grb_context.py").read_text(encoding="utf-8")
|
||||
frontend = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert '"method": "intersection_area"' in buildings
|
||||
assert '"label": "Bebouwde grondoppervlakte"' in buildings
|
||||
assert 'metric_method="intersection_length"' in context
|
||||
assert 'metric_label="Wateroppervlakte"' in context
|
||||
assert 'metric_label="Perceeloppervlakte"' in context
|
||||
assert 'aria-label="Aanvullende gebiedsmetingen"' in frontend
|
||||
assert "activeSelectionResult.summary.warning" in frontend
|
||||
@@ -427,6 +427,42 @@ Response:
|
||||
"geojson": {
|
||||
"type": "FeatureCollection",
|
||||
"features": []
|
||||
},
|
||||
"summary": {
|
||||
"metric_label": "Wateroppervlakte",
|
||||
"metric_value": 5.25,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "intersection_area",
|
||||
"primary_metric_key": "water_area",
|
||||
"feature_count": 23,
|
||||
"is_estimate": false,
|
||||
"warning": "Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens.",
|
||||
"metrics": [
|
||||
{
|
||||
"metric_key": "water_area",
|
||||
"metric_label": "Wateroppervlakte",
|
||||
"metric_value": 5.25,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "intersection_area",
|
||||
"is_estimate": false
|
||||
},
|
||||
{
|
||||
"metric_key": "watercourse_length",
|
||||
"metric_label": "Lengte waterlopen",
|
||||
"metric_value": 12.75,
|
||||
"metric_unit": "km",
|
||||
"aggregation_method": "intersection_length",
|
||||
"is_estimate": false
|
||||
},
|
||||
{
|
||||
"metric_key": "feature_count",
|
||||
"metric_label": "Waterobjecten",
|
||||
"metric_value": 23,
|
||||
"metric_unit": "objecten",
|
||||
"aggregation_method": "feature_count",
|
||||
"is_estimate": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,6 +475,9 @@ Rules:
|
||||
- `area_id` is optional and must belong to the route project. When present, the bbox remains the bounded preview extent but PostGIS filtering and configured aggregations use the persisted Area geometry exactly. This prevents a municipal or regional full-work-area query from counting objects in the surrounding bbox corners.
|
||||
- Results are generated from persisted PostGIS `vector_features`, not from client-side map data.
|
||||
- `feature_count` is the number of GeoJSON features returned in the bounded preview. `total_feature_count` is the exact number of persisted rows intersecting the requested bbox or persisted Area geometry.
|
||||
- `summary` keeps one backwards-compatible primary metric and exposes all relevant measurements in `metrics`. Known themes use metric PostGIS calculations: building/forest/water/parcel surfaces in hectares, road and watercourse lengths in kilometres, population in inhabitants and intersecting feature counts as supporting evidence.
|
||||
- Area and length calculations transform geometry to Belgian Lambert 72 (`EPSG:31370`); they are never calculated in geographic degrees.
|
||||
- Water volume is not inferred from 2D GRB geometry. It remains unavailable until a source provides reliable depth or bathymetry with compatible spatial coverage and provenance.
|
||||
- The response is capped by `limit` and returns `truncated=true` when `total_feature_count` exceeds the returned preview.
|
||||
- `limit` is bounded to `1..1000`. Municipality-scale clients must page spatially by viewport instead of requesting an unbounded municipality FeatureCollection.
|
||||
- The Map workspace uses this existing endpoint for vector datasets above 5,000 features. It starts delivery at zoom level 14, debounces `moveend` requests and explicitly reports `truncated=true` as a request to zoom further in. This is a client delivery policy, not a second API or persistence path.
|
||||
|
||||
@@ -8333,3 +8333,35 @@ Next:
|
||||
- Generalize the audited historical-land-use operator to the approved regional
|
||||
scope, partition source retrieval by municipality and provision the three
|
||||
official editions without merging their methodology into current GRB.
|
||||
|
||||
## Sprint 201 - Semantic area-selection metrics (2026-07-15)
|
||||
|
||||
Implemented:
|
||||
- Extended the canonical persisted-vector selection summary with an additive
|
||||
metric set while preserving the existing primary metric fields and envelope.
|
||||
- Mapped known data themes to useful units: building footprint, forest, water
|
||||
and parcel surfaces in hectares; roads and linear watercourses in kilometres;
|
||||
population in inhabitants; and intersecting feature counts as supporting
|
||||
evidence.
|
||||
- Kept all spatial calculations in PostGIS after transformation to EPSG:31370.
|
||||
No browser-side area/length calculation or synthetic source value was added.
|
||||
- Added honest domain limits for building floor area, road capacity and water
|
||||
volume. The current 2D GRB water source cannot support volume without an
|
||||
independently governed depth/bathymetry dataset.
|
||||
- Updated the regional GRB operator metadata for future imports and added a
|
||||
compact supporting-metric surface to the map-first result panel.
|
||||
|
||||
Validation evidence:
|
||||
- Focused temporal and semantic-selection regression set passed 16 tests.
|
||||
- Full readiness passed 605 backend tests, backend compilation, the API
|
||||
contract audit, one Alembic head, frontend TypeScript typecheck/build and all
|
||||
shell syntax gates.
|
||||
|
||||
Known limitation:
|
||||
- Water volume remains unavailable by design. Adding it requires a compatible
|
||||
depth or bathymetry source, coverage validation, units, observation date and
|
||||
a documented integration method.
|
||||
|
||||
Next:
|
||||
- Validate the semantic metrics against live Mol PostGIS data and then continue
|
||||
the audited regional historical buildings/water/roads import.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
- [x] Default Detection Lab to the configured local YOLO asset and present measured model quality and control requirements honestly.
|
||||
- [x] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope.
|
||||
- [x] Make Evolution automatically open an available regional series and distinguish historical themes from current-only snapshots.
|
||||
- [x] Replace object-count-only map results with semantic PostGIS metrics for hectares, kilometres and inhabitants while retaining counts as supporting evidence.
|
||||
- [ ] Add a governed depth/bathymetry source before exposing water volume; never infer volume from 2D GRB water geometry.
|
||||
- [ ] Extend the official 1778/1873/1969 historical buildings, water and roads series from Mol to the approved regional scope with partitioned source audits.
|
||||
- [x] Connect a drawn rectangle to bounded official orthophoto acquisition, local configured-YOLO detection and persisted GRB QA.
|
||||
|
||||
|
||||
+8
-5
@@ -29,11 +29,14 @@ them explicit. Forest therefore defaults to the official modern 2013-2025
|
||||
10 m series, while the separate 1778-1969 historical map series remains
|
||||
selectable and is never merged into the same trend.
|
||||
|
||||
Selection results use dataset-specific PostGIS summaries. Object layers show
|
||||
intersecting counts, population shows inhabitants with partial-sector
|
||||
estimates clearly marked and land-cover sources show intersected hectares. The
|
||||
advanced workbench remains available but is not required for the primary
|
||||
choose-theme, draw-area, read-result flow.
|
||||
Selection results use dataset-specific PostGIS summaries with end-user units.
|
||||
Building footprints, forest, water surfaces and parcels show intersected
|
||||
hectares; roads and linear watercourses show kilometres; population shows
|
||||
inhabitants with partial-sector estimates clearly marked. Intersecting object
|
||||
counts remain visible as supporting source evidence instead of being the only
|
||||
result. Water explicitly explains that volume cannot be derived without a
|
||||
reliable depth or bathymetry source. The advanced workbench remains available
|
||||
but is not required for the primary choose-theme, draw-area, read-result flow.
|
||||
|
||||
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import GeoMap from '../GeoMap'
|
||||
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
|
||||
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
|
||||
import { featureCollectionBounds } from '../../lib/geojsonBounds'
|
||||
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
|
||||
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
||||
@@ -222,6 +222,11 @@ function resultMetricLabel(result: VectorSelectionResponse): string {
|
||||
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
|
||||
}
|
||||
|
||||
function selectionMetricLabel(metric: VectorSelectionMetric): string {
|
||||
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
|
||||
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
|
||||
}
|
||||
|
||||
function formatTemporalMetric(value: number, unit: string): string {
|
||||
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
|
||||
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
|
||||
@@ -648,6 +653,9 @@ export function MapWorkspace({
|
||||
const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal
|
||||
const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten'
|
||||
const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel
|
||||
const activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter(
|
||||
(metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key,
|
||||
)
|
||||
const activeSecondaryMetric = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
|
||||
? activeMetricUnit === 'ha'
|
||||
? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking`
|
||||
@@ -1350,6 +1358,17 @@ export function MapWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSupportingMetrics.length > 0 ? (
|
||||
<div className="geo-supporting-metrics" aria-label="Aanvullende gebiedsmetingen">
|
||||
{activeSupportingMetrics.map((metric) => (
|
||||
<div key={metric.metric_key}>
|
||||
<span>{metric.metric_label}</span>
|
||||
<strong>{selectionMetricLabel(metric)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="geo-theme-results">
|
||||
<div className="geo-results-title-row">
|
||||
<h4>Alle beschikbare thema’s</h4>
|
||||
@@ -1376,6 +1395,9 @@ export function MapWorkspace({
|
||||
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
|
||||
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
|
||||
) : null}
|
||||
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
|
||||
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
|
||||
) : null}
|
||||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||||
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
|
||||
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
|
||||
|
||||
@@ -6135,6 +6135,33 @@ section {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-supporting-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.geo-supporting-metrics > div {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid #e4ebe8;
|
||||
padding: 0.3rem 0.1rem;
|
||||
}
|
||||
|
||||
.geo-supporting-metrics span {
|
||||
color: #687570;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.geo-supporting-metrics strong {
|
||||
color: #263832;
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-temporal-metrics > div.positive {
|
||||
border-color: #b8d8c5;
|
||||
background: #f1faf4;
|
||||
|
||||
@@ -349,9 +349,21 @@ export interface VectorSelectionSummary {
|
||||
metric_value: number
|
||||
metric_unit: string
|
||||
aggregation_method: string
|
||||
primary_metric_key?: string | null
|
||||
feature_count: number
|
||||
is_estimate: boolean
|
||||
warning?: string | null
|
||||
metrics?: VectorSelectionMetric[]
|
||||
}
|
||||
|
||||
export interface VectorSelectionMetric {
|
||||
metric_key: string
|
||||
metric_label: string
|
||||
metric_value: number
|
||||
metric_unit: string
|
||||
aggregation_method: string
|
||||
is_estimate: boolean
|
||||
warning?: string | null
|
||||
}
|
||||
|
||||
export interface DatasetTemporalUpdate {
|
||||
|
||||
@@ -662,10 +662,12 @@ def provision_dataset(
|
||||
"partition_count": len(partition_paths),
|
||||
"partition_strategy": manifest["partition_strategy"],
|
||||
"selection_aggregation": {
|
||||
"method": "feature_count",
|
||||
"label": "Gebouwen",
|
||||
"unit": "objecten",
|
||||
"metric_key": "footprint_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Bebouwde grondoppervlakte",
|
||||
"unit": "ha",
|
||||
"is_estimate": False,
|
||||
"warning": "Dit is de grondoppervlakte van gebouwcontouren, niet de totale vloeroppervlakte of het gebouwvolume.",
|
||||
},
|
||||
"attribution": GRB_ATTRIBUTION,
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ class LayerDefinition:
|
||||
reference_layer_name: str
|
||||
layer_type: str
|
||||
geometry_types: tuple[str, ...]
|
||||
metric_key: str
|
||||
metric_method: str
|
||||
metric_label: str
|
||||
metric_unit: str
|
||||
metric_warning: str | None
|
||||
limitation_message: str
|
||||
|
||||
|
||||
@@ -80,7 +84,11 @@ LAYERS = (
|
||||
reference_layer_name="roads",
|
||||
layer_type="road",
|
||||
geometry_types=("LineString", "MultiLineString"),
|
||||
metric_label="Wegen",
|
||||
metric_key="road_length",
|
||||
metric_method="intersection_length",
|
||||
metric_label="Totale weglengte",
|
||||
metric_unit="km",
|
||||
metric_warning="De lengte volgt de GRB-wegsegmenten en zegt niets over rijstroken, verkeersvolume of verhardingsoppervlakte.",
|
||||
limitation_message="GRB Wegsegment represents road-network line segments, not traffic volume or routing suitability.",
|
||||
),
|
||||
LayerDefinition(
|
||||
@@ -93,7 +101,11 @@ LAYERS = (
|
||||
reference_layer_name="water",
|
||||
layer_type="water",
|
||||
geometry_types=("LineString", "MultiLineString", "Polygon", "MultiPolygon"),
|
||||
metric_label="Waterobjecten",
|
||||
metric_key="water_area",
|
||||
metric_method="intersection_area",
|
||||
metric_label="Wateroppervlakte",
|
||||
metric_unit="ha",
|
||||
metric_warning="Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens. De kaartbron levert alleen oppervlakte- en lijngeometrie.",
|
||||
limitation_message="GRB water combines surface-water polygons and water-related line collections; counts are object counts, not water volume.",
|
||||
),
|
||||
LayerDefinition(
|
||||
@@ -102,7 +114,11 @@ LAYERS = (
|
||||
reference_layer_name="parcels",
|
||||
layer_type="parcel",
|
||||
geometry_types=("Polygon", "MultiPolygon"),
|
||||
metric_label="Percelen",
|
||||
metric_key="parcel_area",
|
||||
metric_method="intersection_area",
|
||||
metric_label="Perceeloppervlakte",
|
||||
metric_unit="ha",
|
||||
metric_warning="GRB-percelen zijn een grafische referentie en vormen geen juridische grensopmeting.",
|
||||
limitation_message="GRB ADP is a graphical representation of the presumed cadastral parcel location and is not a legal boundary survey.",
|
||||
),
|
||||
)
|
||||
@@ -721,10 +737,12 @@ def provision_dataset(
|
||||
"partition_count": len(partition_paths),
|
||||
"partition_strategy": manifest["partition_strategy"],
|
||||
"selection_aggregation": {
|
||||
"method": "feature_count",
|
||||
"metric_key": definition.metric_key,
|
||||
"method": definition.metric_method,
|
||||
"label": definition.metric_label,
|
||||
"unit": "objecten",
|
||||
"unit": definition.metric_unit,
|
||||
"is_estimate": False,
|
||||
"warning": definition.metric_warning,
|
||||
},
|
||||
"attribution": GRB_ATTRIBUTION,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user