fix: clarify population evolution semantics
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 15:41:38 +02:00
parent 2fbefaa040
commit 986640d5b3
5 changed files with 64 additions and 5 deletions
+12 -2
View File
@@ -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",
@@ -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 "<wfs:GetFeature" in landuse and "session.post(" in landuse
assert 'lambda value: value.startswith("weg"), "weg*"' in landuse