Files
geointel/backend/tests/test_change_detection_area_selection.py
T
JensandClaude Opus 5 12aaf1bb4d 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>
2026-08-22 14:56:15 +02:00

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"]