Add vector change detection foundation
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import box
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models import Dataset, Job, VectorFeature
|
||||
from app.schemas.analysis import ChangeDetectionSummary
|
||||
from app.services.change_detection_service import ChangeDetectionService
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def filter(self, *criteria):
|
||||
for criterion in criteria:
|
||||
left = getattr(criterion, "left", None)
|
||||
right = getattr(criterion, "right", None)
|
||||
operator = getattr(criterion, "operator", None)
|
||||
name = getattr(left, "name", None)
|
||||
value = getattr(right, "value", right)
|
||||
if name and operator and operator.__name__ == "eq":
|
||||
self.rows = [row for row in self.rows if getattr(row, name) == value]
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, objects=None, query_rows=None) -> None:
|
||||
self.objects = objects or {}
|
||||
self.query_rows = query_rows or {}
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
self.refreshes = []
|
||||
|
||||
def get(self, model, item_id):
|
||||
return self.objects.get((model, item_id))
|
||||
|
||||
def query(self, model):
|
||||
return FakeQuery(self.query_rows.get(model, []))
|
||||
|
||||
def add(self, item) -> None:
|
||||
self.added.append(item)
|
||||
if getattr(item, "id", None) is not None:
|
||||
self.objects[(item.__class__, item.id)] = item
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def refresh(self, item) -> None:
|
||||
self.refreshes.append(item)
|
||||
|
||||
|
||||
def _dataset(dataset_id, project_id, name):
|
||||
return Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
dataset_type="vector",
|
||||
source="manual",
|
||||
dataset_role="source",
|
||||
)
|
||||
|
||||
|
||||
def _feature(dataset_id, source_feature_id, geometry):
|
||||
return VectorFeature(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset_id,
|
||||
source_feature_id=source_feature_id,
|
||||
geometry=from_shape(geometry, srid=4326),
|
||||
properties_json={"source_feature_id": source_feature_id},
|
||||
)
|
||||
|
||||
|
||||
def test_change_detection_compares_persisted_vector_features() -> None:
|
||||
project_id = uuid4()
|
||||
source_dataset_id = uuid4()
|
||||
target_dataset_id = uuid4()
|
||||
source_dataset = _dataset(source_dataset_id, project_id, "before.geojson")
|
||||
target_dataset = _dataset(target_dataset_id, project_id, "after.geojson")
|
||||
rows = [
|
||||
_feature(source_dataset_id, "source-unchanged", box(0, 0, 1, 1)),
|
||||
_feature(source_dataset_id, "source-removed", box(10, 10, 11, 11)),
|
||||
_feature(target_dataset_id, "target-unchanged", box(0, 0, 1, 1)),
|
||||
_feature(target_dataset_id, "target-added", box(20, 20, 21, 21)),
|
||||
]
|
||||
db = FakeSession(
|
||||
objects={(Dataset, source_dataset_id): source_dataset, (Dataset, target_dataset_id): target_dataset},
|
||||
query_rows={VectorFeature: rows},
|
||||
)
|
||||
|
||||
result = ChangeDetectionService.compare_vector_datasets(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
iou_threshold=0.8,
|
||||
)
|
||||
|
||||
change_types = [feature["properties"]["change_type"] for feature in result.geojson["features"]]
|
||||
assert result.source_feature_count == 2
|
||||
assert result.target_feature_count == 2
|
||||
assert result.added_count == 1
|
||||
assert result.removed_count == 1
|
||||
assert result.unchanged_count == 1
|
||||
assert sorted(change_types) == ["added", "removed", "unchanged"]
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_change_detection_endpoint_returns_canonical_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
source_dataset_id = uuid4()
|
||||
target_dataset_id = uuid4()
|
||||
source_dataset = _dataset(source_dataset_id, project_id, "before.geojson")
|
||||
target_dataset = _dataset(target_dataset_id, project_id, "after.geojson")
|
||||
db = FakeSession(objects={(Dataset, source_dataset_id): source_dataset, (Dataset, target_dataset_id): target_dataset})
|
||||
summary = ChangeDetectionSummary(
|
||||
source_dataset_id=source_dataset_id,
|
||||
target_dataset_id=target_dataset_id,
|
||||
source_feature_count=1,
|
||||
target_feature_count=1,
|
||||
added_count=0,
|
||||
removed_count=0,
|
||||
unchanged_count=1,
|
||||
iou_threshold=0.8,
|
||||
warnings=[],
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
geojson={"type": "FeatureCollection", "features": []},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.routes.analysis.ChangeDetectionService.compare_vector_datasets",
|
||||
lambda **_kwargs: summary,
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/analysis/change-detection",
|
||||
json={
|
||||
"source_dataset_id": str(source_dataset_id),
|
||||
"target_dataset_id": str(target_dataset_id),
|
||||
"iou_threshold": 0.8,
|
||||
"include_unchanged": True,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert set(payload) == {"data"}
|
||||
assert payload["data"]["job_type"] == "analysis.change-detection"
|
||||
assert payload["data"]["status"] == "success"
|
||||
assert payload["data"]["result_json"]["unchanged_count"] == 1
|
||||
assert any(isinstance(item, Job) for item in db.added)
|
||||
Reference in New Issue
Block a user