Constrain selections to active work areas
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 12:58:38 +02:00
parent a29c787238
commit d652db6a1e
20 changed files with 329 additions and 71 deletions
+5 -3
View File
@@ -9,9 +9,9 @@
## Sprint 233 Operational correctness and result completion (2026-07-17) ## Sprint 233 Operational correctness and result completion (2026-07-17)
- Fixed the map-first contract so a drawn or manually entered rectangle remains - Fixed the map-first contract so drawn/manual selections use `bbox ∩ Area`
an exact bbox for vector, raster and temporal analysis. Only the explicit consistently for vector, raster, temporal, derived-dataset and export paths.
full-work-area action uses persisted Area geometry and its fast paths. The full-Area fast path is used only when the bbox covers the complete Area.
- Made vector-selection exports Area-aware and added a canonical server-side - Made vector-selection exports Area-aware and added a canonical server-side
map-result export for current vector/raster measurements and historical map-result export for current vector/raster measurements and historical
comparisons. comparisons.
@@ -23,6 +23,8 @@
- Improved source failure messages, workspace scroll reset, map viewport - Improved source failure messages, workspace scroll reset, map viewport
ergonomics, Detection Lab flow, QA score interpretation and Dutch download ergonomics, Detection Lab flow, QA score interpretation and Dutch download
terminology. terminology.
- Removed governed terrain, flood-hazard and thematic rasters from the
Detection Lab luchtbeeld selector while keeping them available on the map.
- Added focused regression coverage for selection scope, export persistence, - Added focused regression coverage for selection scope, export persistence,
canonical workspace lookup and the usability guardrails. canonical workspace lookup and the usability guardrails.
+1 -1
View File
@@ -2,7 +2,7 @@
FastAPI backend for GeoIntel Kempen Foundation Sprints. FastAPI backend for GeoIntel Kempen Foundation Sprints.
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. Drawn/manual selections send only their bbox; `area_id` is reserved for the explicit full-work-area operation. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser. The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. When an active `area_id` is supplied, vector, temporal, derived-dataset and export paths all use `bbox ∩ Area`. A full-work-area bbox resolves to the exact persisted Area geometry; a boundary-crossing rectangle is clipped to the official boundary.
## Scope implemented ## Scope implemented
- Project CRUD - Project CRUD
+23 -4
View File
@@ -398,14 +398,20 @@ def select_vector_features(
full_dataset_area = False full_dataset_area = False
preclipped_partition_filter = None preclipped_partition_filter = None
if selection_area is not None: if selection_area is not None:
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, selection_area.id) selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
if not full_dataset_area: payload.bbox.model_dump(),
selection_area.geometry,
)
full_dataset_area = covers_full_area and VectorFeatureService.can_use_full_area_fast_path(
dataset,
selection_area.id,
)
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter( preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
dataset, dataset,
getattr(selection_area, "name", None), getattr(selection_area, "name", None),
) )
selection_kwargs.update( selection_kwargs.update(
selection_geometry=selection_area.geometry, selection_geometry=selection_geometry,
selection_area_id=selection_area.id, selection_area_id=selection_area.id,
full_dataset_area=full_dataset_area, full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter, preclipped_partition_filter=preclipped_partition_filter,
@@ -418,7 +424,7 @@ def select_vector_features(
"total_feature_count": result.get("total_feature_count"), "total_feature_count": result.get("total_feature_count"),
} }
if selection_area is not None: if selection_area is not None:
summary_kwargs["selection_geometry"] = selection_area.geometry summary_kwargs["selection_geometry"] = selection_geometry
summary_kwargs["full_dataset_area"] = full_dataset_area summary_kwargs["full_dataset_area"] = full_dataset_area
summary_kwargs["preclipped_partition_filter"] = preclipped_partition_filter summary_kwargs["preclipped_partition_filter"] = preclipped_partition_filter
result["summary"] = VectorFeatureService.summarize_features_by_bbox(db, **summary_kwargs) result["summary"] = VectorFeatureService.summarize_features_by_bbox(db, **summary_kwargs)
@@ -437,10 +443,23 @@ def derive_vector_selection_dataset(
raise HTTPException(status_code=404, detail="Dataset not found") raise HTTPException(status_code=404, detail="Dataset not found")
if dataset.dataset_type not in {"vector", "geojson"}: if dataset.dataset_type not in {"vector", "geojson"}:
raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400) raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400)
selection_geometry = None
selection_area_id = None
if payload.area_id is not None:
selection_area = db.get(Area, payload.area_id)
if selection_area is None or selection_area.project_id != project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
selection_area_id = selection_area.id
derived = VectorOperationsService.derive_selection_dataset( derived = VectorOperationsService.derive_selection_dataset(
db=db, db=db,
dataset_id=dataset_id, dataset_id=dataset_id,
bbox=payload.bbox.model_dump(), bbox=payload.bbox.model_dump(),
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
limit=payload.limit, limit=payload.limit,
output_name=payload.output_name, output_name=payload.output_name,
) )
+12 -6
View File
@@ -227,14 +227,20 @@ class ExportService:
area = db.get(Area, area_id) area = db.get(Area, area_id)
if not area or area.project_id != dataset.project_id: if not area or area.project_id != dataset.project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, area.id) selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
preclipped_partition_filter = ( bbox,
None area.geometry,
if full_dataset_area )
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None)) full_dataset_area = covers_full_area and VectorFeatureService.can_use_full_area_fast_path(
dataset,
area.id,
)
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
dataset,
getattr(area, "name", None),
) )
selection_kwargs.update( selection_kwargs.update(
selection_geometry=area.geometry, selection_geometry=selection_geometry,
selection_area_id=area.id, selection_area_id=area.id,
full_dataset_area=full_dataset_area, full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter, preclipped_partition_filter=preclipped_partition_filter,
@@ -111,6 +111,13 @@ class TemporalAnalysisService:
bbox = payload.bbox.model_dump() bbox = payload.bbox.model_dump()
selection_area = TemporalAnalysisService._get_selection_area(db, project_id, payload.area_id) selection_area = TemporalAnalysisService._get_selection_area(db, project_id, payload.area_id)
selection_geometry = None
selection_covers_full_area = False
if selection_area is not None:
selection_geometry, selection_covers_full_area = VectorFeatureService.constrain_bbox_to_area(
bbox,
selection_area.geometry,
)
summaries: dict[UUID, dict[str, Any]] = {} summaries: dict[UUID, dict[str, Any]] = {}
def summarize(dataset: Dataset) -> dict[str, Any]: def summarize(dataset: Dataset) -> dict[str, Any]:
@@ -119,11 +126,14 @@ class TemporalAnalysisService:
return cached return cached
kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox} kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox}
if selection_area is not None: if selection_area is not None:
kwargs["selection_geometry"] = selection_area.geometry kwargs["selection_geometry"] = selection_geometry
kwargs["full_dataset_area"] = VectorFeatureService.can_use_full_area_fast_path( kwargs["full_dataset_area"] = (
selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(
dataset, dataset,
selection_area.id, selection_area.id,
) )
)
summary = VectorFeatureService.summarize_features_by_bbox(db, **kwargs) summary = VectorFeatureService.summarize_features_by_bbox(db, **kwargs)
summaries[dataset.id] = summary summaries[dataset.id] = summary
return summary return summary
@@ -154,14 +164,16 @@ class TemporalAnalysisService:
later=later, later=later,
bbox=bbox, bbox=bbox,
preview_limit=payload.preview_limit, preview_limit=payload.preview_limit,
selection_geometry=selection_area.geometry if selection_area is not None else None, selection_geometry=selection_geometry,
earlier_full_dataset_area=( earlier_full_dataset_area=(
VectorFeatureService.can_use_full_area_fast_path(earlier, selection_area.id) selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(earlier, selection_area.id)
if selection_area is not None if selection_area is not None
else False else False
), ),
later_full_dataset_area=( later_full_dataset_area=(
VectorFeatureService.can_use_full_area_fast_path(later, selection_area.id) selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(later, selection_area.id)
if selection_area is not None if selection_area is not None
else False else False
), ),
+25 -5
View File
@@ -8,8 +8,7 @@ from uuid import UUID
from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope
from geoalchemy2.shape import from_shape from geoalchemy2.shape import from_shape
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
from shapely.geometry import mapping from shapely.geometry import box, mapping, shape
from shapely.geometry import shape
from shapely.ops import transform as transform_geometry from shapely.ops import transform as transform_geometry
from shapely.validation import make_valid from shapely.validation import make_valid
from sqlalchemy import Float, cast, func from sqlalchemy import Float, cast, func
@@ -189,6 +188,27 @@ class VectorFeatureService:
source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {} 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 return isinstance(source_metadata.get("selection_aggregation"), dict) or VectorFeatureService._dataset_theme(dataset) is not None
@staticmethod
def constrain_bbox_to_area(
bbox: dict[str, Any],
area_geometry: Any,
) -> tuple[Any, bool]:
bbox_geometry = box(
float(bbox["min_x"]),
float(bbox["min_y"]),
float(bbox["max_x"]),
float(bbox["max_y"]),
)
area_shape = to_shape(area_geometry)
constrained_geometry = bbox_geometry.intersection(area_shape)
if constrained_geometry.is_empty or constrained_geometry.area <= 0:
raise AppError(
code="VECTOR_SELECTION_OUTSIDE_AREA",
message="Selection does not overlap the selected work area",
status_code=422,
)
return from_shape(constrained_geometry, srid=4326), constrained_geometry.equals(area_shape)
@staticmethod @staticmethod
def can_use_full_area_fast_path(dataset: Dataset, selection_area_id: UUID | None) -> bool: 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: if selection_area_id is None or dataset.area_id != selection_area_id:
@@ -343,7 +363,7 @@ class VectorFeatureService:
if preclipped_partition_filter is not None: if preclipped_partition_filter is not None:
partition_property, partition_value = preclipped_partition_filter partition_property, partition_value = preclipped_partition_filter
query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value) query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value)
elif not full_dataset_area: if not full_dataset_area:
query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape)) query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape))
if hasattr(query, "count"): if hasattr(query, "count"):
total_feature_count = int(query.count()) total_feature_count = int(query.count())
@@ -413,9 +433,9 @@ class VectorFeatureService:
selection_filter += ( selection_filter += (
VectorFeature.properties_json.op("->>")(partition_property) == partition_value, VectorFeature.properties_json.op("->>")(partition_property) == partition_value,
) )
elif not full_dataset_area: if not full_dataset_area:
selection_filter += (ST_Intersects(VectorFeature.geometry, selection_shape),) selection_filter += (ST_Intersects(VectorFeature.geometry, selection_shape),)
selection_is_preclipped = full_dataset_area or preclipped_partition_filter is not None selection_is_preclipped = full_dataset_area
feature_count = total_feature_count feature_count = total_feature_count
if feature_count is None: if feature_count is None:
feature_count = int(db.query(func.count(VectorFeature.id)).filter(*selection_filter).scalar() or 0) feature_count = int(db.query(func.count(VectorFeature.id)).filter(*selection_filter).scalar() or 0)
@@ -284,6 +284,8 @@ class VectorOperationsService:
db: Session, db: Session,
dataset_id: uuid.UUID, dataset_id: uuid.UUID,
bbox: dict[str, Any], bbox: dict[str, Any],
selection_geometry: Any | None = None,
selection_area_id: uuid.UUID | None = None,
limit: int = 250, limit: int = 250,
output_name: str | None = None, output_name: str | None = None,
) -> DatasetCreateResponse: ) -> DatasetCreateResponse:
@@ -292,7 +294,14 @@ class VectorOperationsService:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404) raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
VectorOperationsService._require_vector_dataset(source_dataset) VectorOperationsService._require_vector_dataset(source_dataset)
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit) selection = VectorFeatureService.select_features_by_bbox(
db,
dataset_id=dataset_id,
bbox=bbox,
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
limit=limit,
)
if selection["feature_count"] <= 0: if selection["feature_count"] <= 0:
raise AppError( raise AppError(
code="VECTOR_OPERATION_EMPTY_RESULT", code="VECTOR_OPERATION_EMPTY_RESULT",
@@ -316,6 +325,7 @@ class VectorOperationsService:
source_name="map_selection", source_name="map_selection",
source_metadata={ source_metadata={
"selection_bbox": selection["selection_bbox"], "selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"feature_count": selection["feature_count"], "feature_count": selection["feature_count"],
"limit": selection["limit"], "limit": selection["limit"],
"truncated": selection["truncated"], "truncated": selection["truncated"],
@@ -326,9 +336,11 @@ class VectorOperationsService:
"source_dataset_id": str(dataset_id), "source_dataset_id": str(dataset_id),
"source_table": "vector_features", "source_table": "vector_features",
"selection_bbox": selection["selection_bbox"], "selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
}, },
metadata_extra={ metadata_extra={
"selection_bbox": selection["selection_bbox"], "selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"source_feature_count": selection["feature_count"], "source_feature_count": selection["feature_count"],
"selection_limit": selection["limit"], "selection_limit": selection["limit"],
"selection_truncated": selection["truncated"], "selection_truncated": selection["truncated"],
@@ -4,8 +4,8 @@ import uuid
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from geoalchemy2.shape import from_shape from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import Polygon from shapely.geometry import Polygon, box
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Dataset, VectorFeature from app.models import Dataset, VectorFeature
@@ -176,7 +176,7 @@ def test_vector_select_route_uses_persisted_area_geometry_when_requested(monkeyp
name="Regional vector", name="Regional vector",
source_metadata={"selection_aggregation": {"method": "feature_count"}}, source_metadata={"selection_aggregation": {"method": "feature_count"}},
) )
area_geometry = object() area_geometry = from_shape(box(5.0, 51.0, 5.3, 51.3), srid=4326)
area = SimpleNamespace(id=area_id, project_id=project_id, geometry=area_geometry) area = SimpleNamespace(id=area_id, project_id=project_id, geometry=area_geometry)
captured: dict[str, object] = {} captured: dict[str, object] = {}
@@ -226,9 +226,10 @@ def test_vector_select_route_uses_persisted_area_geometry_when_requested(monkeyp
) )
assert str(response["data"]["selection_area_id"]) == str(area_id) assert str(response["data"]["selection_area_id"]) == str(area_id)
assert captured["select"]["selection_geometry"] is area_geometry assert to_shape(captured["select"]["selection_geometry"]).equals(box(5.0, 51.0, 5.2, 51.2))
assert captured["select"]["selection_area_id"] == area_id assert captured["select"]["selection_area_id"] == area_id
assert captured["summary"]["selection_geometry"] is area_geometry assert to_shape(captured["summary"]["selection_geometry"]).equals(box(5.0, 51.0, 5.2, 51.2))
assert captured["select"]["full_dataset_area"] is False
def test_vector_select_route_rejects_area_from_another_project(monkeypatch) -> None: def test_vector_select_route_rejects_area_from_another_project(monkeypatch) -> None:
@@ -6,7 +6,10 @@ from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import box
from app.core.errors import AppError
from app.main import app from app.main import app
from app.models import Area, Dataset, Export from app.models import Area, Dataset, Export
from app.schemas.export import ExportCreateResponse from app.schemas.export import ExportCreateResponse
@@ -140,7 +143,8 @@ def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path,
source="fixture", source="fixture",
status="ready", status="ready",
) )
area_geometry = object() area_shape = box(5.0, 51.1, 5.2, 51.3)
area_geometry = from_shape(area_shape, srid=4326)
area = SimpleNamespace(id=area_id, project_id=project_id, name="Gemeente Mol", geometry=area_geometry) area = SimpleNamespace(id=area_id, project_id=project_id, name="Gemeente Mol", geometry=area_geometry)
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area}) db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
selection_bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"} selection_bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
@@ -170,12 +174,78 @@ def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path,
area_id=area_id, area_id=area_id,
) )
assert captured["selection_geometry"] is area_geometry assert to_shape(captured["selection_geometry"]).equals(area_shape)
assert captured["selection_area_id"] == area_id assert captured["selection_area_id"] == area_id
assert captured["full_dataset_area"] is True assert captured["full_dataset_area"] is True
assert response.metadata_json["selection_area_id"] == str(area_id) assert response.metadata_json["selection_area_id"] == str(area_id)
def test_area_constrained_bbox_uses_intersection_and_disables_full_area_fast_path(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name="mol-buildings.geojson",
dataset_type="vector",
source="fixture",
source_metadata={"geometry_clipped_to_area": True},
status="ready",
)
area_shape = box(5.0, 51.0, 5.2, 51.2)
area = SimpleNamespace(
id=area_id,
project_id=project_id,
name="Gemeente Mol - officiële grens",
geometry=from_shape(area_shape, srid=4326),
)
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
crossing_bbox = {"min_x": 4.9, "min_y": 51.1, "max_x": 5.1, "max_y": 51.3, "crs": "EPSG:4326"}
captured: dict = {}
selection_payload = {
"selection_bbox": crossing_bbox,
"selection_area_id": str(area_id),
"feature_count": 0,
"limit": 250,
"truncated": False,
"geojson": {"type": "FeatureCollection", "features": []},
}
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "selection.geojson"))
def fake_select(*_args, **kwargs):
captured.update(kwargs)
return selection_payload
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", fake_select)
ExportService.export_vector_selection_geojson(
db,
dataset_id,
crossing_bbox,
area_id=area_id,
)
assert to_shape(captured["selection_geometry"]).equals(box(5.0, 51.1, 5.1, 51.2))
assert captured["selection_area_id"] == area_id
assert captured["full_dataset_area"] is False
def test_area_constrained_bbox_rejects_selection_outside_work_area() -> None:
area_geometry = from_shape(box(5.0, 51.0, 5.2, 51.2), srid=4326)
outside_bbox = {"min_x": 4.0, "min_y": 50.0, "max_x": 4.1, "max_y": 50.1, "crs": "EPSG:4326"}
try:
VectorFeatureService.constrain_bbox_to_area(outside_bbox, area_geometry)
except AppError as error:
assert error.code == "VECTOR_SELECTION_OUTSIDE_AREA"
assert error.status_code == 422
else:
raise AssertionError("Expected an outside-area selection to be rejected")
def test_frontend_exposes_map_selection_export_action() -> None: def test_frontend_exposes_map_selection_export_action() -> None:
types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") types = (ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8")
exports_api = (ROOT / "frontend" / "src" / "services" / "api" / "exports.ts").read_text(encoding="utf-8") exports_api = (ROOT / "frontend" / "src" / "services" / "api" / "exports.ts").read_text(encoding="utf-8")
@@ -36,9 +36,9 @@ def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
assert "onMapBboxPreview={handleMapBboxPreview}" in workspace assert "onMapBboxPreview={handleMapBboxPreview}" in workspace
assert "onMapBboxSelect={handleMapBboxSelect}" in workspace assert "onMapBboxSelect={handleMapBboxSelect}" in workspace
assert "void analyzeSelection(bbox)" in workspace assert "void analyzeSelection(bbox, areaIdForSelection(bbox))" in workspace
assert "const areaIdForSelection" in workspace assert "const areaIdForSelection" in workspace
assert "&& bboxesEqual(bbox, selectedAreaBbox)" in workspace assert "bbox && selectedMapArea ? selectedMapArea.id : undefined" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "map.on('mousedown'" in geomap assert "map.on('mousedown'" in geomap
assert "map.on('mousemove'" in geomap assert "map.on('mousemove'" in geomap
@@ -6,8 +6,8 @@ from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from geoalchemy2.shape import from_shape from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import Polygon from shapely.geometry import Polygon, box
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Dataset, DatasetVersion from app.models import Dataset, DatasetVersion
@@ -347,6 +347,73 @@ def test_temporal_compare_returns_delta_and_canonical_change_payload(monkeypatch
assert result.geojson["type"] == "FeatureCollection" assert result.geojson["type"] == "FeatureCollection"
def test_temporal_comparison_clips_cross_boundary_bbox_to_selected_area(monkeypatch) -> None:
project_id = uuid4()
area_id = uuid4()
earlier = temporal_dataset(project_id=project_id, observed_year=2021)
later = temporal_dataset(project_id=project_id, observed_year=2024)
area_shape = box(5.0, 51.0, 5.2, 51.2)
area = SimpleNamespace(
id=area_id,
project_id=project_id,
geometry=from_shape(area_shape, srid=4326),
)
captured_geometries = []
identity_capture = {}
monkeypatch.setattr(
TemporalAnalysisService,
"_get_temporal_dataset",
staticmethod(lambda _db, _project_id, dataset_id, _label: earlier if dataset_id == earlier.id else later),
)
monkeypatch.setattr(
TemporalAnalysisService,
"_get_selection_area",
staticmethod(lambda _db, _project_id, requested_area_id: area if requested_area_id == area_id else None),
)
def summarize(_db, *, dataset, bbox, selection_geometry, full_dataset_area): # noqa: ARG001
captured_geometries.append(selection_geometry)
return {
"metric_label": "Oppervlakte",
"metric_value": 10.0 if dataset.id == earlier.id else 12.0,
"metric_unit": "ha",
"aggregation_method": "intersection_area",
"feature_count": 1,
"is_estimate": False,
"warning": None,
}
def compare_identity(*_args, **kwargs):
identity_capture.update(kwargs)
return (
TemporalObjectChanges(available=False),
{"type": "FeatureCollection", "features": []},
[],
)
monkeypatch.setattr(VectorFeatureService, "summarize_features_by_bbox", staticmethod(summarize))
monkeypatch.setattr(TemporalAnalysisService, "_compare_identity_features", staticmethod(compare_identity))
result = TemporalAnalysisService.compare(
SimpleNamespace(),
project_id=project_id,
payload=TemporalComparisonRequest(
earlier_dataset_id=earlier.id,
later_dataset_id=later.id,
area_id=area_id,
bbox={"min_x": 4.9, "min_y": 51.1, "max_x": 5.1, "max_y": 51.3},
),
)
expected = box(5.0, 51.1, 5.1, 51.2)
assert result.metric.absolute_change == 2.0
assert all(to_shape(geometry).equals(expected) for geometry in captured_geometries)
assert to_shape(identity_capture["selection_geometry"]).equals(expected)
assert identity_capture["earlier_full_dataset_area"] is False
assert identity_capture["later_full_dataset_area"] is False
def test_unstable_temporal_identity_returns_clear_end_user_warning() -> None: def test_unstable_temporal_identity_returns_clear_end_user_warning() -> None:
project_id = uuid4() project_id = uuid4()
earlier = temporal_dataset(project_id=project_id, observed_year=2021) earlier = temporal_dataset(project_id=project_id, observed_year=2021)
@@ -33,7 +33,7 @@ def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None
assert "/datasets/raster/terrain/select" in api assert "/datasets/raster/terrain/select" in api
assert "/datasets/raster/flood-hazard/select" in api assert "/datasets/raster/flood-hazard/select" in api
assert "Rasterlaag actief" in app assert "Rasterlaag actief" in app
assert "void analyzeSelection(bbox)" in workspace assert "void analyzeSelection(bbox, areaIdForSelection(bbox))" in workspace
assert "areaIdForSelection(bbox)" in workspace assert "areaIdForSelection(bbox)" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))" in workspace assert "onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))" in workspace
@@ -326,6 +326,21 @@ def test_map_and_detection_workspaces_avoid_page_length_driven_layouts() -> None
assert "max-height: none;" in premium assert "max-height: none;" in premium
def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
root = Path(__file__).resolve().parents[2]
app_source = (root / "frontend/src/App.tsx").read_text(encoding="utf-8")
capability_source = (root / "frontend/src/lib/datasetCapabilities.ts").read_text(encoding="utf-8")
assert "department_omgeving_thematic_raster" in capability_source
assert "digitaal_vlaanderen_dhmv" in capability_source
assert "vmm_flood_hazard" in capability_source
assert "dataset.dataset_type !== 'raster' || dataset.status !== 'ready'" in capability_source
assert "const detectionRasterDatasets = useMemo(" in app_source
assert "rasterDatasets: detectionRasterDatasets" in app_source
assert "rasterDatasets={detectionRasterDatasets}" in app_source
assert "!isDetectionImageryDataset(selectedDataset)" in app_source
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None: def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
exports = (root / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8") exports = (root / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8")
+10 -6
View File
@@ -776,7 +776,11 @@ Rules:
- Only vector/GeoJSON datasets are supported. - Only vector/GeoJSON datasets are supported.
- Coordinates are EPSG:4326 longitude/latitude. - Coordinates are EPSG:4326 longitude/latitude.
- `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. - `area_id` is optional and must belong to the route project. When present,
PostGIS filtering and configured aggregations use `bbox ∩ Area`. A bbox that
encloses the complete Area therefore produces the exact full-work-area
result, while a drawn rectangle that crosses a municipality boundary is
clipped to that official boundary.
- Results are generated from persisted PostGIS `vector_features`, not from client-side map data. - 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. - `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. - `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.
@@ -1735,7 +1739,7 @@ Map vector selection export request:
{ {
"export_kind": "vector_selection", "export_kind": "vector_selection",
"dataset_id": "uuid", "dataset_id": "uuid",
"area_id": "optional-uuid-for-an-exact-full-work-area-export", "area_id": "optional-uuid-used-as-an-official-area-constraint",
"bbox": { "bbox": {
"min_x": 5.0, "min_x": 5.0,
"min_y": 51.0, "min_y": 51.0,
@@ -1789,10 +1793,10 @@ existing Detection/Segmentation GeoJSON conversion services. Vector selection
exports query persisted PostGIS `vector_features` with the supplied EPSG:4326 exports query persisted PostGIS `vector_features` with the supplied EPSG:4326
bbox, write the selected FeatureCollection as a `vector_selection_geojson` bbox, write the selected FeatureCollection as a `vector_selection_geojson`
artifact, and persist bbox/feature-count metadata in the export record. When artifact, and persist bbox/feature-count metadata in the export record. When
`area_id` is present, the persisted Area geometry is the exact export scope; `area_id` is present, the export scope is `bbox ∩ Area`. The frontend sends the
the frontend only sends this for the explicit `Volledig werkgebied` action. active Area for drawn, manual and full-work-area selections so no result can
Drawn or manually entered rectangles omit `area_id` and remain exact bbox leak outside the chosen official boundary. A bbox enclosing the complete Area
queries. Raster still resolves to the exact Area geometry. Raster
datasets are rejected for dataset and selection GeoJSON export. datasets are rejected for dataset and selection GeoJSON export.
### POST `/api/v1/exports/map-result` ### POST `/api/v1/exports/map-result`
+14 -7
View File
@@ -9904,10 +9904,12 @@ V1 completion status:
Implemented: Implemented:
- Corrected the map-first selection scope after live API evidence showed that a - Corrected the map-first selection scope after live API evidence showed that a
drawn bbox plus the active `area_id` returned the complete municipality drawn bbox plus the active `area_id` returned the complete municipality
vector population. Drawn and manual bboxes now omit `area_id`; the explicit vector population. The shared selection contract now resolves `bbox ∩ Area`;
full-work-area action keeps the exact persisted Area geometry. a bbox enclosing the complete Area keeps the exact persisted geometry and
- Applied the same scope decision to current theme queries, temporal only that case may use the full-Area fast path.
comparisons, derived selection datasets and vector exports. - Applied the same constrained geometry to current theme queries, temporal
comparisons, derived selection datasets and vector exports, including
boundary-crossing rectangle regressions.
- Added Area-aware persistent vector selection exports and - Added Area-aware persistent vector selection exports and
`POST /api/v1/exports/map-result`. Current vector, governed raster and `POST /api/v1/exports/map-result`. Current vector, governed raster and
temporal exports are recomputed server-side from persisted data before an temporal exports are recomputed server-side from persisted data before an
@@ -9923,14 +9925,19 @@ Implemented:
workbench container on navigation, bounded the desktop map layout, removed workbench container on navigation, bounded the desktop map layout, removed
nested desktop scrolling from Detection Lab and added plain-language QA/F1 nested desktop scrolling from Detection Lab and added plain-language QA/F1
interpretation. interpretation.
- Limited Detection Lab's luchtbeeld selector to ready imagery rasters;
terrain, flood-hazard and thematic policy rasters remain available only in
their correct map workflows.
- Translated the visible Downloads workflow and moved technical identifiers to - Translated the visible Downloads workflow and moved technical identifiers to
the existing history disclosure. the existing history disclosure.
Validation: Validation:
- Focused operational-correctness, map-selection, export and V1 flow tests - Focused operational-correctness, map-selection, export and V1 flow tests
passed after the corrected selection semantics were applied. passed after the corrected selection semantics were applied.
- The complete readiness gate passed 895 backend tests, backend compilation, - The final complete readiness gate passed 899 backend tests, backend compilation,
documentation smoke, API contract audit, the single Alembic head documentation smoke, API contract audit, the single Alembic head
`202607160001`, frontend TypeScript typecheck and the production build. `202607160001`, frontend TypeScript typecheck and the production build.
- Live all-in-one deployment, exact bbox/full-Area API comparison and browser - Commit `a29c787` passed the first live deployment, exact bbox/full-Area API
acceptance follow from this validated repository state. comparison and persistent Downloads browser handoff. The final constrained
boundary and luchtbeeld-filter follow-up is validated below before its
replacement deployment.
+5 -3
View File
@@ -11,9 +11,9 @@ geen open productroadmap meer.
vereist. vereist.
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het - [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
volledige werkgebied en analyseer alle 15 beschikbare thema's uit PostGIS. volledige werkgebied en analyseer alle 15 beschikbare thema's uit PostGIS.
- [x] Houd getekende en handmatig ingevoerde rechthoeken als exacte bbox-scope - [x] Gebruik voor getekende en handmatig ingevoerde rechthoeken overal
voor vector-, raster- en tijdreeksmetingen; gebruik de persistente `bbox ∩ Area`; een bbox rond het volledige werkgebied resolveert naar de
Area-geometrie uitsluitend voor de expliciete actie `Volledig werkgebied`. exacte persistente Area-geometrie en activeert pas dan de full-Area fast path.
- [x] Toon betekenisvolle eenheden en metrieklabels voor oppervlakte, lengte, - [x] Toon betekenisvolle eenheden en metrieklabels voor oppervlakte, lengte,
inwoners, hoogte, scenario's en stationsmetingen; objectaantallen zijn inwoners, hoogte, scenario's en stationsmetingen; objectaantallen zijn
ondersteunend. ondersteunend.
@@ -31,6 +31,8 @@ geen open productroadmap meer.
operator- en benchmarkprojecten de standaard kaartcontext niet kan verdringen. operator- en benchmarkprojecten de standaard kaartcontext niet kan verdringen.
- [x] Gebruik de operationele lokale YOLO/PyTorch-keten alleen met persisted - [x] Gebruik de operationele lokale YOLO/PyTorch-keten alleen met persisted
detecties, GRB-QA en expliciete controlewaarschuwingen. detecties, GRB-QA en expliciete controlewaarschuwingen.
- [x] Toon in de beeldanalyse alleen operationele luchtbeeldrasters en geen
hoogte-, overstromings- of thematische beleidsrasters.
- [x] Draai de volledige applicatie in de beheersbare Unraid-container op poort - [x] Draai de volledige applicatie in de beheersbare Unraid-container op poort
1202 met PostGIS, backend, frontend, Ollama-koppeling en modelconfiguratie. 1202 met PostGIS, backend, frontend, Ollama-koppeling en modelconfiguratie.
+5 -1
View File
@@ -69,7 +69,11 @@ result. Water explicitly explains that volume cannot be derived without a
reliable depth or bathymetry source. The advanced workbench remains available reliable depth or bathymetry source. The advanced workbench remains available
but is not required for the primary choose-theme, draw-area, read-result flow. 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. Drawn/manual rectangles never inherit the active municipality `area_id`; only `Volledig werkgebied` uses the exact persisted Area geometry. 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. 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 active `area_id` constrains every drawn/manual selection to `bbox ∩ Area`; `Volledig werkgebied` uses a bbox enclosing the Area and therefore resolves to the exact persisted geometry. 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.
Detection Lab only lists ready imagery rasters. Governed height, flood-hazard
and thematic policy rasters remain available in the map explorer but are
excluded from the `Luchtbeeld` selector.
Every cross-theme result now names the measured quantity next to the value, so Every cross-theme result now names the measured quantity next to the value, so
a station water level, mapped area and line length cannot appear as an a station water level, mapped area and line length cannot appear as an
+8 -3
View File
@@ -28,6 +28,7 @@ import { useMapSelectionQa } from './hooks/useMapSelectionQa'
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis' import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis'
import { isDetectionImageryDataset } from './lib/datasetCapabilities'
import { useProviderCapabilities } from './hooks/useProviderCapabilities' import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useProjectWorkspace } from './hooks/useProjectWorkspace' import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow' import { useQualityWorkflow } from './hooks/useQualityWorkflow'
@@ -211,6 +212,10 @@ function App(): JSX.Element {
[availableVectorDatasets], [availableVectorDatasets],
) )
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets]) const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
const detectionRasterDatasets = useMemo(
() => rasterDatasets.filter(isDetectionImageryDataset),
[rasterDatasets],
)
const { const {
providers, providers,
loadingCapabilities, loadingCapabilities,
@@ -317,14 +322,14 @@ function App(): JSX.Element {
setCalibrationThresholdText, setCalibrationThresholdText,
} = useDetectionWorkflow({ } = useDetectionWorkflow({
selectedProjectId, selectedProjectId,
rasterDatasets, rasterDatasets: detectionRasterDatasets,
qaIouThreshold, qaIouThreshold,
loadProjectData, loadProjectData,
loadQualityChecks, loadQualityChecks,
}) })
const useRasterTileManifestForDetection = () => { const useRasterTileManifestForDetection = () => {
const manifestPath = latestRasterTileManifestPath.trim() const manifestPath = latestRasterTileManifestPath.trim()
if (!manifestPath || !selectedDataset || selectedDataset.dataset_type !== 'raster') { if (!manifestPath || !selectedDataset || !isDetectionImageryDataset(selectedDataset)) {
return return
} }
setDetectionTileManifestPath(manifestPath) setDetectionTileManifestPath(manifestPath)
@@ -1171,7 +1176,7 @@ function App(): JSX.Element {
loadingYoloPreflight={loadingYoloPreflight} loadingYoloPreflight={loadingYoloPreflight}
yoloPreflightError={yoloPreflightError} yoloPreflightError={yoloPreflightError}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets} rasterDatasets={detectionRasterDatasets}
referenceDatasets={referenceDatasets} referenceDatasets={referenceDatasets}
onLoadModels={loadDetectionModels} onLoadModels={loadDetectionModels}
onRefreshYoloPreflight={() => loadYoloPreflight()} onRefreshYoloPreflight={() => loadYoloPreflight()}
+3 -8
View File
@@ -1154,12 +1154,7 @@ export function MapWorkspace({
} }
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => ( const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
bbox bbox && selectedMapArea ? selectedMapArea.id : undefined
&& selectedMapArea
&& selectedAreaBbox
&& bboxesEqual(bbox, selectedAreaBbox)
? selectedMapArea.id
: undefined
) )
const startBboxSelection = () => { const startBboxSelection = () => {
@@ -1178,7 +1173,7 @@ export function MapWorkspace({
setSelectionBbox(bbox) setSelectionBbox(bbox)
setFirstSelectionCorner(null) setFirstSelectionCorner(null)
setBboxSelectionMode(false) setBboxSelectionMode(false)
void analyzeSelection(bbox) void analyzeSelection(bbox, areaIdForSelection(bbox))
} }
const runAreaExtract = () => { const runAreaExtract = () => {
@@ -1387,7 +1382,7 @@ export function MapWorkspace({
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => { const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
setFirstSelectionCorner(null) setFirstSelectionCorner(null)
setBboxSelectionMode(false) setBboxSelectionMode(false)
void analyzeSelection(bbox) void analyzeSelection(bbox, areaIdForSelection(bbox))
} }
const runQuickAoiExtract = () => { const runQuickAoiExtract = () => {
+17
View File
@@ -0,0 +1,17 @@
import type { DatasetCreateResponse } from '../types'
const NON_IMAGERY_RASTER_SOURCES = new Set([
'department_omgeving_thematic_raster',
'digitaal_vlaanderen_dhmv',
'vmm_flood_hazard',
])
export function isDetectionImageryDataset(dataset: DatasetCreateResponse): boolean {
if (dataset.dataset_type !== 'raster' || dataset.status !== 'ready') {
return false
}
if (NON_IMAGERY_RASTER_SOURCES.has(dataset.source_name ?? '')) {
return false
}
return true
}