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
+1 -1
View File
@@ -2,7 +2,7 @@
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
- Project CRUD
+27 -8
View File
@@ -398,14 +398,20 @@ def select_vector_features(
full_dataset_area = False
preclipped_partition_filter = None
if selection_area is not None:
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, selection_area.id)
if not full_dataset_area:
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
dataset,
getattr(selection_area, "name", None),
)
selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_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(
dataset,
getattr(selection_area, "name", None),
)
selection_kwargs.update(
selection_geometry=selection_area.geometry,
selection_geometry=selection_geometry,
selection_area_id=selection_area.id,
full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter,
@@ -418,7 +424,7 @@ def select_vector_features(
"total_feature_count": result.get("total_feature_count"),
}
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["preclipped_partition_filter"] = preclipped_partition_filter
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")
if dataset.dataset_type not in {"vector", "geojson"}:
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(
db=db,
dataset_id=dataset_id,
bbox=payload.bbox.model_dump(),
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
limit=payload.limit,
output_name=payload.output_name,
)
+12 -6
View File
@@ -227,14 +227,20 @@ class ExportService:
area = db.get(Area, area_id)
if not area or area.project_id != dataset.project_id:
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)
preclipped_partition_filter = (
None
if full_dataset_area
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None))
selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
bbox,
area.geometry,
)
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_geometry=area.geometry,
selection_geometry=selection_geometry,
selection_area_id=area.id,
full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter,
@@ -111,6 +111,13 @@ class TemporalAnalysisService:
bbox = payload.bbox.model_dump()
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]] = {}
def summarize(dataset: Dataset) -> dict[str, Any]:
@@ -119,10 +126,13 @@ class TemporalAnalysisService:
return cached
kwargs: dict[str, Any] = {"dataset": dataset, "bbox": bbox}
if selection_area is not None:
kwargs["selection_geometry"] = selection_area.geometry
kwargs["full_dataset_area"] = VectorFeatureService.can_use_full_area_fast_path(
dataset,
selection_area.id,
kwargs["selection_geometry"] = selection_geometry
kwargs["full_dataset_area"] = (
selection_covers_full_area
and VectorFeatureService.can_use_full_area_fast_path(
dataset,
selection_area.id,
)
)
summary = VectorFeatureService.summarize_features_by_bbox(db, **kwargs)
summaries[dataset.id] = summary
@@ -154,14 +164,16 @@ class TemporalAnalysisService:
later=later,
bbox=bbox,
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=(
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
else False
),
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
else False
),
+25 -5
View File
@@ -8,8 +8,7 @@ from uuid import UUID
from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope
from geoalchemy2.shape import from_shape
from geoalchemy2.shape import to_shape
from shapely.geometry import mapping
from shapely.geometry import shape
from shapely.geometry import box, mapping, shape
from shapely.ops import transform as transform_geometry
from shapely.validation import make_valid
from sqlalchemy import Float, cast, func
@@ -189,6 +188,27 @@ class VectorFeatureService:
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 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
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:
@@ -343,7 +363,7 @@ class VectorFeatureService:
if preclipped_partition_filter is not None:
partition_property, partition_value = preclipped_partition_filter
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))
if hasattr(query, "count"):
total_feature_count = int(query.count())
@@ -413,9 +433,9 @@ class VectorFeatureService:
selection_filter += (
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_is_preclipped = full_dataset_area or preclipped_partition_filter is not None
selection_is_preclipped = full_dataset_area
feature_count = total_feature_count
if feature_count is None:
feature_count = int(db.query(func.count(VectorFeature.id)).filter(*selection_filter).scalar() or 0)
@@ -284,6 +284,8 @@ class VectorOperationsService:
db: Session,
dataset_id: uuid.UUID,
bbox: dict[str, Any],
selection_geometry: Any | None = None,
selection_area_id: uuid.UUID | None = None,
limit: int = 250,
output_name: str | None = None,
) -> DatasetCreateResponse:
@@ -292,7 +294,14 @@ class VectorOperationsService:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
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:
raise AppError(
code="VECTOR_OPERATION_EMPTY_RESULT",
@@ -316,6 +325,7 @@ class VectorOperationsService:
source_name="map_selection",
source_metadata={
"selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"feature_count": selection["feature_count"],
"limit": selection["limit"],
"truncated": selection["truncated"],
@@ -326,9 +336,11 @@ class VectorOperationsService:
"source_dataset_id": str(dataset_id),
"source_table": "vector_features",
"selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
},
metadata_extra={
"selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"source_feature_count": selection["feature_count"],
"selection_limit": selection["limit"],
"selection_truncated": selection["truncated"],
@@ -4,8 +4,8 @@ import uuid
from pathlib import Path
from types import SimpleNamespace
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import Polygon, box
from app.core.errors import AppError
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",
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)
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 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["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:
@@ -6,7 +6,10 @@ from types import SimpleNamespace
from uuid import uuid4
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.models import Area, Dataset, Export
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",
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)
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"}
@@ -170,12 +174,78 @@ def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path,
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["full_dataset_area"] is True
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:
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")
@@ -36,9 +36,9 @@ def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
assert "onMapBboxPreview={handleMapBboxPreview}" 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 "&& bboxesEqual(bbox, selectedAreaBbox)" in workspace
assert "bbox && selectedMapArea ? selectedMapArea.id : undefined" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "map.on('mousedown'" in geomap
assert "map.on('mousemove'" in geomap
@@ -6,8 +6,8 @@ from types import SimpleNamespace
from uuid import uuid4
import pytest
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import Polygon, box
from app.core.errors import AppError
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"
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:
project_id = uuid4()
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/flood-hazard/select" in api
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 "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" 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
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:
root = Path(__file__).resolve().parents[2]
exports = (root / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8")