disclosure from data Change detection had only added/removed/unchanged, so a building extended by an annexe dropped below the IoU threshold and was reported twice: once as removed and once as added. That hides exactly the category a change-detection product exists to show and inflates both counts. A "modified" class now covers the band between the modified floor and the unchanged threshold. Matching also ran as a full cross product with no spatial index, unlike the QA matcher beside it: two municipal building layers meant hundreds of millions of geometry intersections. It uses an STRtree and considers larger footprints first, so a big footprint is not left over after a small neighbour claimed its counterpart. The assistant guaranteed honesty about estimated values by rewriting the model's sentences with regular expressions, which only fires when it recognises the phrasing the model happened to produce. estimate_disclosures derives the same statement from the metric metadata, so it holds regardless of how the answer was worded. The prose substitution stays as a second layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
3.9 KiB
Python
115 lines
3.9 KiB
Python
"""An extended building is a change, not a deletion plus a new building.
|
|
|
|
With only added/removed/unchanged, a footprint that grew by an annexe drops
|
|
below the IoU threshold and is reported twice: once as removed and once as
|
|
added. That hides exactly the category a change-detection product exists to
|
|
show, and inflates both counts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from shapely.geometry import box
|
|
|
|
from app.services.change_detection_service import ChangeDetectionService
|
|
|
|
|
|
def _feature(feature_id: str, geometry):
|
|
return {"feature_id": feature_id, "properties": {}, "geometry": geometry}
|
|
|
|
|
|
def _classify(source, target, *, iou_threshold=0.8, modified_threshold=0.3):
|
|
return ChangeDetectionService._classify_features(
|
|
source,
|
|
target,
|
|
iou_threshold=iou_threshold,
|
|
modified_threshold=modified_threshold,
|
|
)
|
|
|
|
|
|
def test_an_extended_footprint_is_reported_as_modified() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b1-new", box(0, 0, 10, 14))] # IoU 100/140 = 0.71
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert [item["change_type"] for item in result] == ["modified"]
|
|
assert result[0]["source_feature_id"] == "b1"
|
|
assert result[0]["target_feature_id"] == "b1-new"
|
|
assert result[0]["iou"] == pytest.approx(100 / 140)
|
|
|
|
|
|
def test_a_nearly_identical_footprint_is_unchanged() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b1", box(0, 0, 10, 10.2))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert [item["change_type"] for item in result] == ["unchanged"]
|
|
|
|
|
|
def test_a_genuinely_new_building_stays_added() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert sorted(item["change_type"] for item in result) == ["added", "unchanged"]
|
|
|
|
|
|
def test_a_demolished_building_stays_removed() -> None:
|
|
source = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))]
|
|
target = [_feature("b1", box(0, 0, 10, 10))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert sorted(item["change_type"] for item in result) == ["removed", "unchanged"]
|
|
|
|
|
|
def test_barely_overlapping_footprints_are_not_called_modified() -> None:
|
|
"""Below the modified floor the two are separate objects, not one changed."""
|
|
|
|
source = [_feature("b1", box(0, 0, 10, 10))]
|
|
target = [_feature("b2", box(9, 9, 19, 19))] # IoU ~0.005
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert sorted(item["change_type"] for item in result) == ["added", "removed"]
|
|
|
|
|
|
def test_each_target_is_claimed_at_most_once() -> None:
|
|
source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(0, 0, 10, 12))]
|
|
target = [_feature("t", box(0, 0, 10, 10))]
|
|
|
|
result = _classify(source, target)
|
|
|
|
claimed = [item for item in result if item["target_feature_id"] == "t"]
|
|
assert len(claimed) == 1
|
|
|
|
|
|
def test_classification_is_independent_of_input_order() -> None:
|
|
source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(30, 30, 40, 40))]
|
|
target = [_feature("a2", box(0, 0, 10, 14)), _feature("c", box(70, 70, 80, 80))]
|
|
|
|
forward = _classify(source, target)
|
|
reverse = _classify(list(reversed(source)), list(reversed(target)))
|
|
|
|
def signature(items):
|
|
return sorted(
|
|
(item["change_type"], item["source_feature_id"], item["target_feature_id"]) for item in items
|
|
)
|
|
|
|
assert signature(forward) == signature(reverse)
|
|
|
|
|
|
def test_large_populations_do_not_use_a_full_cross_product() -> None:
|
|
"""A spatial index keeps a city-sized comparison tractable."""
|
|
|
|
source = [_feature(f"s{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)]
|
|
target = [_feature(f"t{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)]
|
|
|
|
result = _classify(source, target)
|
|
|
|
assert all(item["change_type"] == "unchanged" for item in result)
|
|
assert len(result) == 400
|