bound change detection to the operator's selection
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>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user