Change detection was the one analysis that ignored the selection entirely. It compared two datasets in full, loaded every feature of both into Python with no spatial predicate, and — with include_unchanged defaulting to true — returned a FeatureCollection holding both datasets. For a regional building layer that is the wrong answer to "what changed here" and a response no browser should be asked to hold. It now accepts bbox and area_id, resolved the way every other analysis resolves them, and loads through an indexed ST_Intersects predicate. Features are deliberately not clipped to the selection. A change class describes a whole object: comparing a clipped earlier footprint against an unclipped later one would report the selection edge itself as a change. Objects the edge crosses are compared in full and counted in a warning. The returned geometry is capped by preview_limit, spending that budget on modified, added and removed before unchanged, while every count still describes the whole selection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
"""Change detection must answer the question the operator actually asked.
|
|
|
|
Every other analysis in the workbench is bounded by the drawn selection.
|
|
Change detection was not: it compared two datasets in full, loaded every
|
|
feature of both into Python, and — with ``include_unchanged`` defaulting to
|
|
true — returned a FeatureCollection containing both datasets entire. For a
|
|
regional building layer that is the wrong answer to "what changed here" and a
|
|
response no browser should be asked to hold.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from shapely.geometry import box
|
|
|
|
from app.core.errors import AppError
|
|
from app.services.change_detection_service import ChangeDetectionService
|
|
|
|
|
|
def _feature(feature_id: str, geometry):
|
|
return {"feature_id": feature_id, "properties": {}, "geometry": geometry}
|
|
|
|
|
|
INSIDE = box(0.0, 0.0, 1.0, 1.0)
|
|
OUTSIDE = box(50.0, 50.0, 51.0, 51.0)
|
|
SELECTION = box(-1.0, -1.0, 2.0, 2.0)
|
|
|
|
|
|
def test_features_outside_the_selection_are_not_compared() -> None:
|
|
kept = ChangeDetectionService.restrict_to_selection(
|
|
[_feature("in", INSIDE), _feature("out", OUTSIDE)],
|
|
SELECTION,
|
|
)
|
|
|
|
assert [item["feature_id"] for item in kept] == ["in"]
|
|
|
|
|
|
def test_a_feature_crossing_the_selection_edge_is_kept_and_flagged() -> None:
|
|
crossing = box(1.5, 1.5, 3.0, 3.0)
|
|
|
|
kept = ChangeDetectionService.restrict_to_selection(
|
|
[_feature("crossing", crossing)],
|
|
SELECTION,
|
|
)
|
|
|
|
assert len(kept) == 1
|
|
assert kept[0]["partially_covered"] is True
|
|
# The geometry is not clipped: a change class describes a whole object, and
|
|
# comparing a clipped 2020 footprint with an unclipped 2024 one would
|
|
# invent change at the selection edge.
|
|
assert kept[0]["geometry"].equals(crossing)
|
|
|
|
|
|
def test_a_feature_wholly_inside_is_not_flagged() -> None:
|
|
kept = ChangeDetectionService.restrict_to_selection([_feature("in", INSIDE)], SELECTION)
|
|
|
|
assert kept[0]["partially_covered"] is False
|
|
|
|
|
|
def test_no_selection_leaves_the_population_untouched() -> None:
|
|
features = [_feature("in", INSIDE), _feature("out", OUTSIDE)]
|
|
|
|
assert ChangeDetectionService.restrict_to_selection(features, None) == features
|
|
|
|
|
|
def test_an_empty_intersection_is_an_explicit_error_not_a_silent_zero() -> None:
|
|
with pytest.raises(AppError) as exc_info:
|
|
ChangeDetectionService.restrict_to_selection([_feature("out", OUTSIDE)], SELECTION, label="Source")
|
|
|
|
assert exc_info.value.code == "CHANGE_DETECTION_SELECTION_EMPTY"
|
|
assert "Source" in exc_info.value.message
|
|
|
|
|
|
def test_the_preview_is_capped_while_the_counts_stay_complete() -> None:
|
|
features = [
|
|
{
|
|
"change_type": "added" if index % 2 else "removed",
|
|
"geometry": box(index, 0, index + 1, 1),
|
|
"source_feature_id": None,
|
|
"target_feature_id": f"t{index}",
|
|
"iou": None,
|
|
"properties": {},
|
|
}
|
|
for index in range(250)
|
|
]
|
|
|
|
preview, truncated = ChangeDetectionService.limit_preview(features, limit=100)
|
|
|
|
assert len(preview) == 100
|
|
assert truncated is True
|
|
|
|
|
|
def test_a_short_result_is_not_reported_as_truncated() -> None:
|
|
features = [
|
|
{
|
|
"change_type": "added",
|
|
"geometry": box(0, 0, 1, 1),
|
|
"source_feature_id": None,
|
|
"target_feature_id": "t",
|
|
"iou": None,
|
|
"properties": {},
|
|
}
|
|
]
|
|
|
|
preview, truncated = ChangeDetectionService.limit_preview(features, limit=100)
|
|
|
|
assert len(preview) == 1
|
|
assert truncated is False
|
|
|
|
|
|
def test_the_preview_prefers_changes_over_unchanged_features() -> None:
|
|
"""A cap must not spend its budget on the least interesting class."""
|
|
|
|
features = [
|
|
{"change_type": "unchanged", "geometry": box(index, 0, index + 1, 1), "source_feature_id": f"s{index}",
|
|
"target_feature_id": f"t{index}", "iou": 1.0, "properties": {}}
|
|
for index in range(100)
|
|
] + [
|
|
{"change_type": "added", "geometry": box(0, 5, 1, 6), "source_feature_id": None,
|
|
"target_feature_id": "new", "iou": None, "properties": {}},
|
|
{"change_type": "modified", "geometry": box(0, 7, 1, 8), "source_feature_id": "s",
|
|
"target_feature_id": "t", "iou": 0.6, "properties": {}},
|
|
]
|
|
|
|
preview, truncated = ChangeDetectionService.limit_preview(features, limit=3)
|
|
|
|
assert truncated is True
|
|
assert sorted(item["change_type"] for item in preview[:2]) == ["added", "modified"]
|