Constrain selections to active work areas
This commit is contained in:
+5
-3
@@ -9,9 +9,9 @@
|
||||
|
||||
## Sprint 233 Operational correctness and result completion (2026-07-17)
|
||||
|
||||
- Fixed the map-first contract so a drawn or manually entered rectangle remains
|
||||
an exact bbox for vector, raster and temporal analysis. Only the explicit
|
||||
full-work-area action uses persisted Area geometry and its fast paths.
|
||||
- Fixed the map-first contract so drawn/manual selections use `bbox ∩ Area`
|
||||
consistently for vector, raster, temporal, derived-dataset and export 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
|
||||
map-result export for current vector/raster measurements and historical
|
||||
comparisons.
|
||||
@@ -23,6 +23,8 @@
|
||||
- Improved source failure messages, workspace scroll reset, map viewport
|
||||
ergonomics, Detection Lab flow, QA score interpretation and Dutch download
|
||||
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,
|
||||
canonical workspace lookup and the usability guardrails.
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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,11 +126,14 @@ 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(
|
||||
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
|
||||
return 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
|
||||
),
|
||||
|
||||
@@ -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")
|
||||
|
||||
+10
-6
@@ -776,7 +776,11 @@ Rules:
|
||||
|
||||
- Only vector/GeoJSON datasets are supported.
|
||||
- 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.
|
||||
- `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.
|
||||
@@ -1735,7 +1739,7 @@ Map vector selection export request:
|
||||
{
|
||||
"export_kind": "vector_selection",
|
||||
"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": {
|
||||
"min_x": 5.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
|
||||
bbox, write the selected FeatureCollection as a `vector_selection_geojson`
|
||||
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;
|
||||
the frontend only sends this for the explicit `Volledig werkgebied` action.
|
||||
Drawn or manually entered rectangles omit `area_id` and remain exact bbox
|
||||
queries. Raster
|
||||
`area_id` is present, the export scope is `bbox ∩ Area`. The frontend sends the
|
||||
active Area for drawn, manual and full-work-area selections so no result can
|
||||
leak outside the chosen official boundary. A bbox enclosing the complete Area
|
||||
still resolves to the exact Area geometry. Raster
|
||||
datasets are rejected for dataset and selection GeoJSON export.
|
||||
|
||||
### POST `/api/v1/exports/map-result`
|
||||
|
||||
@@ -9904,10 +9904,12 @@ V1 completion status:
|
||||
Implemented:
|
||||
- Corrected the map-first selection scope after live API evidence showed that a
|
||||
drawn bbox plus the active `area_id` returned the complete municipality
|
||||
vector population. Drawn and manual bboxes now omit `area_id`; the explicit
|
||||
full-work-area action keeps the exact persisted Area geometry.
|
||||
- Applied the same scope decision to current theme queries, temporal
|
||||
comparisons, derived selection datasets and vector exports.
|
||||
vector population. The shared selection contract now resolves `bbox ∩ Area`;
|
||||
a bbox enclosing the complete Area keeps the exact persisted geometry and
|
||||
only that case may use the full-Area fast path.
|
||||
- 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
|
||||
`POST /api/v1/exports/map-result`. Current vector, governed raster and
|
||||
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
|
||||
nested desktop scrolling from Detection Lab and added plain-language QA/F1
|
||||
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
|
||||
the existing history disclosure.
|
||||
|
||||
Validation:
|
||||
- Focused operational-correctness, map-selection, export and V1 flow tests
|
||||
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
|
||||
`202607160001`, frontend TypeScript typecheck and the production build.
|
||||
- Live all-in-one deployment, exact bbox/full-Area API comparison and browser
|
||||
acceptance follow from this validated repository state.
|
||||
- Commit `a29c787` passed the first live deployment, exact bbox/full-Area API
|
||||
comparison and persistent Downloads browser handoff. The final constrained
|
||||
boundary and luchtbeeld-filter follow-up is validated below before its
|
||||
replacement deployment.
|
||||
|
||||
+5
-3
@@ -11,9 +11,9 @@ geen open productroadmap meer.
|
||||
vereist.
|
||||
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
|
||||
volledige werkgebied en analyseer alle 15 beschikbare thema's uit PostGIS.
|
||||
- [x] Houd getekende en handmatig ingevoerde rechthoeken als exacte bbox-scope
|
||||
voor vector-, raster- en tijdreeksmetingen; gebruik de persistente
|
||||
Area-geometrie uitsluitend voor de expliciete actie `Volledig werkgebied`.
|
||||
- [x] Gebruik voor getekende en handmatig ingevoerde rechthoeken overal
|
||||
`bbox ∩ Area`; een bbox rond het volledige werkgebied resolveert naar de
|
||||
exacte persistente Area-geometrie en activeert pas dan de full-Area fast path.
|
||||
- [x] Toon betekenisvolle eenheden en metrieklabels voor oppervlakte, lengte,
|
||||
inwoners, hoogte, scenario's en stationsmetingen; objectaantallen zijn
|
||||
ondersteunend.
|
||||
@@ -31,6 +31,8 @@ geen open productroadmap meer.
|
||||
operator- en benchmarkprojecten de standaard kaartcontext niet kan verdringen.
|
||||
- [x] Gebruik de operationele lokale YOLO/PyTorch-keten alleen met persisted
|
||||
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
|
||||
1202 met PostGIS, backend, frontend, Ollama-koppeling en modelconfiguratie.
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
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
|
||||
a station water level, mapped area and line length cannot appear as an
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useMapSelectionQa } from './hooks/useMapSelectionQa'
|
||||
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
||||
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
|
||||
import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis'
|
||||
import { isDetectionImageryDataset } from './lib/datasetCapabilities'
|
||||
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
||||
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
|
||||
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||
@@ -211,6 +212,10 @@ function App(): JSX.Element {
|
||||
[availableVectorDatasets],
|
||||
)
|
||||
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
|
||||
const detectionRasterDatasets = useMemo(
|
||||
() => rasterDatasets.filter(isDetectionImageryDataset),
|
||||
[rasterDatasets],
|
||||
)
|
||||
const {
|
||||
providers,
|
||||
loadingCapabilities,
|
||||
@@ -317,14 +322,14 @@ function App(): JSX.Element {
|
||||
setCalibrationThresholdText,
|
||||
} = useDetectionWorkflow({
|
||||
selectedProjectId,
|
||||
rasterDatasets,
|
||||
rasterDatasets: detectionRasterDatasets,
|
||||
qaIouThreshold,
|
||||
loadProjectData,
|
||||
loadQualityChecks,
|
||||
})
|
||||
const useRasterTileManifestForDetection = () => {
|
||||
const manifestPath = latestRasterTileManifestPath.trim()
|
||||
if (!manifestPath || !selectedDataset || selectedDataset.dataset_type !== 'raster') {
|
||||
if (!manifestPath || !selectedDataset || !isDetectionImageryDataset(selectedDataset)) {
|
||||
return
|
||||
}
|
||||
setDetectionTileManifestPath(manifestPath)
|
||||
@@ -1171,7 +1176,7 @@ function App(): JSX.Element {
|
||||
loadingYoloPreflight={loadingYoloPreflight}
|
||||
yoloPreflightError={yoloPreflightError}
|
||||
selectedProjectId={selectedProjectId}
|
||||
rasterDatasets={rasterDatasets}
|
||||
rasterDatasets={detectionRasterDatasets}
|
||||
referenceDatasets={referenceDatasets}
|
||||
onLoadModels={loadDetectionModels}
|
||||
onRefreshYoloPreflight={() => loadYoloPreflight()}
|
||||
|
||||
@@ -1154,12 +1154,7 @@ export function MapWorkspace({
|
||||
}
|
||||
|
||||
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
|
||||
bbox
|
||||
&& selectedMapArea
|
||||
&& selectedAreaBbox
|
||||
&& bboxesEqual(bbox, selectedAreaBbox)
|
||||
? selectedMapArea.id
|
||||
: undefined
|
||||
bbox && selectedMapArea ? selectedMapArea.id : undefined
|
||||
)
|
||||
|
||||
const startBboxSelection = () => {
|
||||
@@ -1178,7 +1173,7 @@ export function MapWorkspace({
|
||||
setSelectionBbox(bbox)
|
||||
setFirstSelectionCorner(null)
|
||||
setBboxSelectionMode(false)
|
||||
void analyzeSelection(bbox)
|
||||
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
||||
}
|
||||
|
||||
const runAreaExtract = () => {
|
||||
@@ -1387,7 +1382,7 @@ export function MapWorkspace({
|
||||
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
|
||||
setFirstSelectionCorner(null)
|
||||
setBboxSelectionMode(false)
|
||||
void analyzeSelection(bbox)
|
||||
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
||||
}
|
||||
|
||||
const runQuickAoiExtract = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user