diff --git a/backend/app/services/vector_feature_service.py b/backend/app/services/vector_feature_service.py index 4742bba2..4795e4f4 100644 --- a/backend/app/services/vector_feature_service.py +++ b/backend/app/services/vector_feature_service.py @@ -209,8 +209,8 @@ class VectorFeatureService: intersection_area = func.ST_Area( func.ST_Transform(func.ST_Intersection(VectorFeature.geometry, envelope), 31370) ) - value_expression = numeric_value * intersection_area / func.nullif(source_area, 0.0) - is_estimate = True + coverage_ratio = intersection_area / func.nullif(source_area, 0.0) + value_expression = numeric_value * coverage_ratio aggregate_value = ( db.query(func.coalesce(func.sum(value_expression), 0.0)) .filter(*selection_filter) @@ -218,6 +218,16 @@ class VectorFeatureService: .scalar() ) metric_value = float(aggregate_value or 0.0) + if method == "area_weighted_sum": + partial_feature_count = ( + db.query(func.count(VectorFeature.id)) + .filter(*selection_filter) + .filter(coverage_ratio < 0.999999) + .scalar() + ) + is_estimate = bool(partial_feature_count) + if not is_estimate and config.get("warning_only_when_estimate", True): + warning = None elif method != "feature_count": raise AppError( code="INVALID_SELECTION_AGGREGATION", diff --git a/backend/tests/test_sprint187_temporal_map_foundation.py b/backend/tests/test_sprint187_temporal_map_foundation.py index 09ec3530..e6d7dda5 100644 --- a/backend/tests/test_sprint187_temporal_map_foundation.py +++ b/backend/tests/test_sprint187_temporal_map_foundation.py @@ -38,6 +38,14 @@ class ScalarSession: return ScalarQuery(self.value) +class SequenceScalarSession: + def __init__(self, values: list[float]): + self.values = iter(values) + + def query(self, *args): # noqa: ANN002, ARG002 + return ScalarQuery(next(self.values)) + + class VersionQuery: def __init__(self, latest: DatasetVersion | None): self.latest = latest @@ -183,6 +191,40 @@ def test_selection_area_aggregation_returns_hectares_without_loading_all_feature assert result["feature_count"] == 40 +def test_population_area_weighting_is_exact_for_full_features_and_estimated_for_partial_features() -> None: + dataset = temporal_dataset(project_id=uuid4(), observed_year=2025, metric_method="area_weighted_sum") + dataset.source_metadata["selection_aggregation"].update( + { + "property": "population_total", + "label": "Inwoners", + "unit": "inwoners", + "warning": "Partial-sector estimate", + "warning_only_when_estimate": True, + } + ) + bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"} + + full = VectorFeatureService.summarize_features_by_bbox( + SequenceScalarSession([38_675.0, 0]), + dataset=dataset, + bbox=bbox, + total_feature_count=49, + ) + partial = VectorFeatureService.summarize_features_by_bbox( + SequenceScalarSession([1_250.5, 2]), + dataset=dataset, + bbox=bbox, + total_feature_count=3, + ) + + assert full["metric_value"] == 38_675.0 + assert full["is_estimate"] is False + assert full["warning"] is None + assert partial["metric_value"] == 1_250.5 + assert partial["is_estimate"] is True + assert partial["warning"] == "Partial-sector estimate" + + def test_temporal_compare_returns_delta_and_canonical_change_payload(monkeypatch) -> None: project_id = uuid4() earlier = temporal_dataset(project_id=project_id, observed_year=2021) @@ -244,6 +286,7 @@ def test_temporal_frontend_and_official_operator_contracts_exist() -> None: assert "Vergelijk periode" in workspace assert "/temporal/compare" in temporal_api assert "Statbel" in population and "area_weighted_sum" in population + assert '"identity_stable": False' in population assert "HistLandgebruik" in landuse and "intersection_area" in landuse assert "