feat: add governed agricultural parcel history
This commit is contained in:
@@ -1112,6 +1112,26 @@ the assistant cannot turn 2D water geometry into volume. GeoIntel rejects an
|
||||
answer when Ollama reports `done_reason=length`, so a visibly truncated sentence
|
||||
is never presented as a complete result.
|
||||
|
||||
## Agricultural-use parcel history
|
||||
|
||||
Prepare all definitive 2008-2025 regional editions without database writes:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/provision_agricultural_parcel_history.py --fetch-only
|
||||
```
|
||||
|
||||
Import the checked artifacts through the canonical Dataset upload route:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/provision_agricultural_parcel_history.py
|
||||
```
|
||||
|
||||
Use `--scope mol`, `--years 2008,2019,2025` or `--force` only as explicit
|
||||
operator choices. The default scope is the persisted 28-municipality Kempen
|
||||
transport region. Every annual source ZIP and crop code list remains under the
|
||||
storage volume. PostGIS computes exact hectares for drawn rectangles and
|
||||
persisted Areas; parcel identities are deliberately unavailable for lineage.
|
||||
|
||||
## Helpful repository scripts
|
||||
|
||||
- `bash scripts/backend_install.sh`
|
||||
|
||||
@@ -25,6 +25,7 @@ FULL_AREA_CLIPPED_OPERATOR_TOOLS = {
|
||||
"provision_regional_grb_context.py",
|
||||
"provision_waterinfo_station_history.py",
|
||||
"provision_mol_bwk_natura2000.py",
|
||||
"provision_agricultural_parcel_history.py",
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +87,7 @@ SEMANTIC_SELECTION_METRICS: dict[str, tuple[dict[str, Any], ...]] = {
|
||||
},
|
||||
),
|
||||
"nature_value": (),
|
||||
"agriculture": (),
|
||||
}
|
||||
|
||||
SEMANTIC_COUNT_LABELS = {
|
||||
@@ -96,6 +98,7 @@ SEMANTIC_COUNT_LABELS = {
|
||||
"roads": "Wegsegmenten",
|
||||
"parcels": "Percelen",
|
||||
"nature_value": "BWK-kaartvlakken",
|
||||
"agriculture": "Landbouwgebruikspercelen",
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +124,9 @@ class VectorFeatureService:
|
||||
"biodiversity": "nature_value",
|
||||
"bwk": "nature_value",
|
||||
"natura2000": "nature_value",
|
||||
"agricultural": "agriculture",
|
||||
"landbouw": "agriculture",
|
||||
"landbouwgebruik": "agriculture",
|
||||
}
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, str) or not candidate.strip():
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import box, shape
|
||||
from shapely.ops import transform as transform_geometry
|
||||
|
||||
from app.models import Dataset
|
||||
from app.schemas.operations import VectorSelectionSummary
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BBOX = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.3, "max_y": 51.4, "crs": "EPSG:4326"}
|
||||
|
||||
|
||||
def load_operator():
|
||||
script_path = ROOT / "scripts" / "provision_agricultural_parcel_history.py"
|
||||
spec = importlib.util.spec_from_file_location("agricultural_parcel_history_operator", script_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeRow:
|
||||
def __init__(self, geometry, values: dict): # noqa: ANN001
|
||||
self.geometry = geometry
|
||||
self.values = values
|
||||
|
||||
def __getitem__(self, key): # noqa: ANN001
|
||||
return self.values[key]
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
def __init__(self, rows: list[FakeRow], columns: list[str]):
|
||||
self.rows = rows
|
||||
self.columns = columns
|
||||
|
||||
def iterrows(self):
|
||||
return iter(enumerate(self.rows))
|
||||
|
||||
|
||||
class ScalarQuery:
|
||||
def __init__(self, value: float):
|
||||
self.value = value
|
||||
|
||||
def filter(self, *args): # noqa: ANN002, ARG002
|
||||
return self
|
||||
|
||||
def scalar(self):
|
||||
return 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 ApiResponse:
|
||||
ok = True
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self.data = data
|
||||
|
||||
def json(self):
|
||||
return {"data": self.data}
|
||||
|
||||
|
||||
class PaginatedApiSession:
|
||||
def __init__(self):
|
||||
self.offsets: list[int] = []
|
||||
|
||||
def get(self, url, *, params, timeout): # noqa: ANN001, ARG002
|
||||
self.offsets.append(params["offset"])
|
||||
if params["offset"] == 0:
|
||||
return ApiResponse({"items": [{"id": index} for index in range(200)], "total": 201})
|
||||
return ApiResponse({"items": [{"id": 200}], "total": 201})
|
||||
|
||||
|
||||
def test_only_definitive_2008_through_2025_archives_are_allowed() -> None:
|
||||
module = load_operator()
|
||||
|
||||
assert module.SUPPORTED_YEARS == tuple(range(2008, 2026))
|
||||
assert 2026 not in module.ARCHIVE_URLS
|
||||
assert module.ARCHIVE_URLS[2025].endswith("agpa_2025_2026-05-13_public.zip")
|
||||
assert all(url.startswith("https://www.landbouwvlaanderen.be/bestanden/gis/agpa_") for url in module.ARCHIVE_URLS.values())
|
||||
with pytest.raises(ValueError, match="Supported definitive years"):
|
||||
module.parse_years("2025,2026")
|
||||
|
||||
|
||||
def test_canonical_api_collection_reader_respects_200_item_limit_and_paginates() -> None:
|
||||
module = load_operator()
|
||||
session = PaginatedApiSession()
|
||||
|
||||
items = module.api_items(session, "http://geointel/api/v1/projects/project-id/datasets", 30)
|
||||
|
||||
assert len(items) == 201
|
||||
assert session.offsets == [0, 200]
|
||||
|
||||
|
||||
def test_archive_requires_exactly_one_safe_geopackage(tmp_path: Path) -> None:
|
||||
module = load_operator()
|
||||
valid = tmp_path / "valid.zip"
|
||||
with zipfile.ZipFile(valid, "w") as archive:
|
||||
archive.writestr("agpa_2025.gpkg", b"source")
|
||||
archive.writestr("metadata.pdf", b"metadata")
|
||||
assert module.archive_geopackage_member(valid) == "agpa_2025.gpkg"
|
||||
|
||||
unsafe = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(unsafe, "w") as archive:
|
||||
archive.writestr("nested/agpa_2025.gpkg", b"source")
|
||||
with pytest.raises(RuntimeError, match="unsafe"):
|
||||
module.archive_geopackage_member(unsafe)
|
||||
|
||||
ambiguous = tmp_path / "ambiguous.zip"
|
||||
with zipfile.ZipFile(ambiguous, "w") as archive:
|
||||
archive.writestr("one.gpkg", b"one")
|
||||
archive.writestr("two.gpkg", b"two")
|
||||
with pytest.raises(RuntimeError, match="exactly one"):
|
||||
module.archive_geopackage_member(ambiguous)
|
||||
|
||||
|
||||
def test_crop_code_list_preserves_year_specific_titles_and_reports_conflicts() -> None:
|
||||
module = load_operator()
|
||||
result = module.build_crop_code_list(
|
||||
[
|
||||
{"maincrop_code": "201", "maincrop_title": "Mais", "maincropgroup_title": "Mais"},
|
||||
{"maincrop_code": "201", "maincrop_title": "Korrelmais", "maincropgroup_title": "Mais"},
|
||||
{"maincrop_code": "901", "maincrop_title": "Grasland", "maincropgroup_title": "Grasland"},
|
||||
],
|
||||
year=2025,
|
||||
)
|
||||
|
||||
assert result["year"] == 2025
|
||||
assert len(result["crop_entries"]) == 3
|
||||
assert result["code_title_conflicts"] == {"201": ["Korrelmais", "Mais"]}
|
||||
assert "maincropgroup_title" in result["historical_comparison_rule"]
|
||||
assert module.normalized_group_title("Maïs") == "maize"
|
||||
|
||||
|
||||
def test_features_are_exactly_clipped_in_lambert72_and_keep_source_fields() -> None:
|
||||
module = load_operator()
|
||||
boundary_wgs84 = box(5.10, 51.20, 5.11, 51.21)
|
||||
boundary_lambert72 = transform_geometry(module.TO_LAMBERT72.transform, boundary_wgs84)
|
||||
source_geometry = transform_geometry(module.TO_LAMBERT72.transform, box(5.095, 51.195, 5.105, 51.205))
|
||||
values = {
|
||||
"agpakey": "2025-42",
|
||||
"parcelnumber": "42",
|
||||
"area_ha": 1.25,
|
||||
"maincrop_code": "201",
|
||||
"maincrop_title": "Korrelmais",
|
||||
"maincropgroup_title": "Maïs",
|
||||
"geometry": source_geometry,
|
||||
}
|
||||
frame = FakeFrame([FakeRow(source_geometry, values)], list(values))
|
||||
|
||||
features, summary = module.normalize_frame(
|
||||
frame,
|
||||
year=2025,
|
||||
boundary_lambert72=boundary_lambert72,
|
||||
max_features=10,
|
||||
)
|
||||
|
||||
assert summary["feature_count"] == 1
|
||||
assert summary["clipped_feature_count"] == 1
|
||||
feature = features[0]
|
||||
assert feature["id"] == "alz:2025:2025-42"
|
||||
assert shape(feature["geometry"]).difference(boundary_wgs84.buffer(1e-7)).area < 1e-12
|
||||
properties = feature["properties"]
|
||||
assert properties["maincrop_title"] == "Korrelmais"
|
||||
assert properties["main_crop_group_key"] == "maize"
|
||||
assert properties["geometry_was_clipped"] is True
|
||||
assert properties["historical_parcel_identity_stable"] is False
|
||||
assert properties["clipped_area_ha"] < properties["source_geometry_area_ha"]
|
||||
|
||||
|
||||
def test_duplicate_annual_source_identity_fails_closed() -> None:
|
||||
module = load_operator()
|
||||
boundary = box(100_000, 200_000, 101_000, 201_000)
|
||||
values = {"agpakey": "same", "maincropgroup_title": "Grasland", "geometry": boundary}
|
||||
frame = FakeFrame([FakeRow(boundary, values), FakeRow(boundary, values)], list(values))
|
||||
|
||||
with pytest.raises(RuntimeError, match="duplicate agpakey"):
|
||||
module.normalize_frame(frame, year=2025, boundary_lambert72=boundary, max_features=10)
|
||||
|
||||
|
||||
def test_agriculture_summary_returns_grouped_hectares_without_parcel_lineage_claim() -> None:
|
||||
module = load_operator()
|
||||
dataset = Dataset(
|
||||
id=uuid4(),
|
||||
project_id=uuid4(),
|
||||
name="agricultural_use_parcels_2025.geojson",
|
||||
dataset_type="vector",
|
||||
dataset_role="reference",
|
||||
source_name=module.SOURCE_NAME,
|
||||
reference_layer_name="agriculture",
|
||||
source_metadata={
|
||||
"theme": "agriculture",
|
||||
"semantic_metrics": False,
|
||||
"selection_aggregation": {
|
||||
"metric_key": "declared_agricultural_use_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Aangegeven gebruiksoppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
},
|
||||
"selection_metrics": module.selection_metrics(),
|
||||
},
|
||||
)
|
||||
square_metres = [150_000.0, 40_000.0, 30_000.0, 20_000.0, 10_000.0, 5_000.0, 4_000.0, 3_000.0, 2_000.0, 1_000.0, 500.0, 250.0]
|
||||
|
||||
result = VectorFeatureService.summarize_features_by_bbox(
|
||||
SequenceScalarSession(square_metres),
|
||||
dataset=dataset,
|
||||
bbox=BBOX,
|
||||
total_feature_count=321,
|
||||
full_dataset_area=True,
|
||||
)
|
||||
|
||||
assert result["primary_metric_key"] == "declared_agricultural_use_area"
|
||||
assert result["metric_value"] == 15.0
|
||||
metrics = {item["metric_key"]: item for item in result["metrics"]}
|
||||
assert metrics["grassland_area"]["metric_value"] == 4.0
|
||||
assert metrics["maize_area"]["metric_value"] == 3.0
|
||||
assert metrics["agricultural_water_area"]["metric_value"] == 0.025
|
||||
assert metrics["feature_count"]["metric_value"] == 321
|
||||
assert "perceelidentiteiten" in metrics["grassland_area"]["warning"]
|
||||
VectorSelectionSummary(**result)
|
||||
|
||||
|
||||
def test_operator_uses_canonical_upload_and_is_packaged_for_runtime() -> None:
|
||||
operator = (ROOT / "scripts" / "provision_agricultural_parcel_history.py").read_text(encoding="utf-8")
|
||||
service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8")
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "/datasets/upload" in operator
|
||||
assert "VectorFeature" not in operator
|
||||
assert "INSERT INTO vector_features" not in operator
|
||||
assert "geo.api.vlaanderen.be/Landbgebrperc" not in operator
|
||||
assert '"provision_agricultural_parcel_history.py"' in service
|
||||
assert "COPY scripts/provision_agricultural_parcel_history.py" in dockerfile
|
||||
assert "py_compile scripts/provision_agricultural_parcel_history.py" in readiness
|
||||
assert "id: 'agriculture'" in map_workspace
|
||||
assert "Landbouwgebruikspercelen" in source_catalog
|
||||
|
||||
|
||||
def test_upload_contract_is_annual_definitive_and_scope_specific(tmp_path: Path) -> None:
|
||||
module = load_operator()
|
||||
scope = module.GEOGRAPHIC_SCOPES["mol"]
|
||||
assert module.series_key(scope) == "alz:agricultural-use-parcels:mol"
|
||||
metrics = module.selection_metrics()
|
||||
assert {item["metric_key"] for item in metrics} >= {"grassland_area", "maize_area", "agricultural_water_area"}
|
||||
assert all(item["method"] == "intersection_area" for item in metrics)
|
||||
assert all(item["filter_property"] == "main_crop_group_key" for item in metrics)
|
||||
|
||||
paths = module.artifact_paths(tmp_path, scope.key, 2025)
|
||||
assert paths["archive"].name == "agpa_2025_2026-05-13_public.zip"
|
||||
assert paths["artifact"].name == "agricultural_use_parcels_2025_mol.geojson"
|
||||
Reference in New Issue
Block a user