Files
geointel/scripts/provision_agricultural_parcel_history.py
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

862 lines
38 KiB
Python

"""Provision definitive Flemish agricultural-use parcels for an explicit scope.
The operator downloads only the official annual ALZ archives, retains those
archives as checksummed evidence, validates the GeoPackage schema and CRS,
clips parcel geometry to the persisted GeoIntel Area in EPSG:31370 and uploads
one canonical vector Dataset per year. It never writes to vector_features
directly and never uses the provisional current-campaign snapshot.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import json
import math
import os
import re
import sys
import tempfile
import zipfile
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlsplit
import requests
from pyproj import Transformer
from requests.adapters import HTTPAdapter
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon, mapping, shape
from shapely.ops import transform as transform_geometry
from shapely.ops import unary_union
from shapely.validation import make_valid
from urllib3.util.retry import Retry
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from geographic_scopes import GEOGRAPHIC_SCOPES, GeographicScope # noqa: E402
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_SCOPE_KEY = "kempen-transport-region"
DEFAULT_OUTPUT_ROOT = Path("/app/storage/operator-evidence/agricultural-use-parcels")
CATALOG_URL = "https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen"
DATA_CATALOG_URL = "https://www.vlaanderen.be/datavindplaats/catalogus/open-geodata-landbouwgebruikspercelen"
ATTRIBUTION = "Agentschap Landbouw en Zeevisserij - Landbouwcijfers"
SOURCE_NAME = "agentschap_landbouw_zeevisserij_agricultural_parcels"
REFERENCE_LAYER_NAME = "agriculture"
SOURCE_CRS = "EPSG:31370"
OUTPUT_CRS = "EPSG:4326"
SCHEMA_VERSION = 1
MAX_ARCHIVE_BYTES = 250 * 1024 * 1024
MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
MAX_ARCHIVE_MEMBERS = 32
ARCHIVE_HOST = "www.landbouwvlaanderen.be"
ARCHIVE_PATH_PATTERN = re.compile(r"^/bestanden/gis/agpa_(20[0-9]{2})_(20[0-9]{2}-[0-9]{2}-[0-9]{2})_public\.zip$")
ARCHIVE_URLS = {
2008: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2008_2022-03-23_public.zip",
2009: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2009_2022-03-23_public.zip",
2010: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2010_2022-03-23_public.zip",
2011: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2011_2022-03-23_public.zip",
2012: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2012_2022-03-23_public.zip",
2013: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2013_2022-03-23_public.zip",
2014: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2014_2022-03-23_public.zip",
2015: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2015_2022-03-23_public.zip",
2016: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2016_2022-03-23_public.zip",
2017: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2017_2022-03-23_public.zip",
2018: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2018_2022-03-23_public.zip",
2019: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2019_2020-03-20_public.zip",
2020: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2020_2021-03-19_public.zip",
2021: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2021_2022-03-15_public.zip",
2022: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2022_2023-06-26_public.zip",
2023: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2023_2024-03-28_public.zip",
2024: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2024_2025-03-27_public.zip",
2025: "https://www.landbouwvlaanderen.be/bestanden/gis/agpa_2025_2026-05-13_public.zip",
}
SUPPORTED_YEARS = tuple(ARCHIVE_URLS)
@dataclass(frozen=True)
class AgriculturalReleaseConfig:
year: int
archive_url: str
@property
def definitive_version(self) -> str:
return f"{self.year}-v3"
STABLE_REQUIRED_FIELDS = {
"agpakey",
"parcelnumber",
"area_ha",
"reference_id",
"springcrop_code",
"springcrop_title",
"maincrop_code",
"maincrop_title",
"maincropgroup_title",
"productionmethod_code",
"productionmethod_title",
"municipality_code",
"municipality_title",
}
GROUP_ALIASES = {
"grasland": "grassland",
"mais": "maize",
"granen/zaden/peulvruchten": "grains_seeds_legumes",
"granen, zaden en peulvruchten": "grains_seeds_legumes",
"aardappelen": "potatoes",
"groenten/kruiden/sierplanten": "horticulture",
"groenten, kruiden en sierplanten": "horticulture",
"suikerbieten": "sugar_beets",
"voedergewassen": "fodder_crops",
"fruit en noten": "fruit_nuts",
"overige gewassen": "other_crops",
"vlas en hennep": "flax_hemp",
"houtachtige gewassen": "woody_crops",
"landbouwinfrastructuur": "agricultural_infrastructure",
"water": "water",
}
METRIC_GROUPS = (
("grassland_area", "Grasland", ("grassland",)),
("maize_area", "Mais", ("maize",)),
("grains_seeds_legumes_area", "Granen, zaden en peulvruchten", ("grains_seeds_legumes",)),
("potatoes_area", "Aardappelen", ("potatoes",)),
("horticulture_area", "Groenten, kruiden en sierplanten", ("horticulture",)),
("sugar_beets_area", "Suikerbieten", ("sugar_beets",)),
("fodder_crops_area", "Voedergewassen", ("fodder_crops",)),
("fruit_nuts_area", "Fruit en noten", ("fruit_nuts",)),
("other_crops_area", "Overige gewassen", ("other_crops", "flax_hemp", "woody_crops")),
("agricultural_infrastructure_area", "Landbouwinfrastructuur", ("agricultural_infrastructure",)),
("agricultural_water_area", "Water binnen de aangifte", ("water",)),
)
TO_LAMBERT72 = Transformer.from_crs(OUTPUT_CRS, SOURCE_CRS, always_xy=True)
TO_WGS84 = Transformer.from_crs(SOURCE_CRS, OUTPUT_CRS, always_xy=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision definitive ALZ agricultural-use parcel history.")
parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL))
parser.add_argument("--scope", choices=sorted(GEOGRAPHIC_SCOPES), default=DEFAULT_SCOPE_KEY)
parser.add_argument("--years", default=",".join(str(year) for year in SUPPORTED_YEARS))
parser.add_argument(
"--archive-url",
help="Exact official archive URL for one explicitly confirmed future definitive edition.",
)
parser.add_argument("--output-root", type=Path, default=Path(os.environ.get("GEOINTEL_AGRICULTURE_OUTPUT_ROOT", DEFAULT_OUTPUT_ROOT)))
parser.add_argument("--request-timeout", type=int, default=900)
parser.add_argument("--import-timeout", type=int, default=3600)
parser.add_argument("--max-features", type=int, default=250_000)
parser.add_argument("--max-archive-mb", type=int, default=250)
parser.add_argument("--force", action="store_true", help="Redownload and rebuild retained evidence artifacts.")
parser.add_argument("--fetch-only", action="store_true", help="Prepare evidence without importing Datasets.")
return parser.parse_args()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_json_atomic(path: Path, payload: Any, *, pretty: bool = False) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".partial")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2 if pretty else None, sort_keys=pretty, separators=None if pretty else (",", ":")),
encoding="utf-8",
)
temporary.replace(path)
def build_session() -> requests.Session:
retry = Retry(
total=5,
connect=5,
read=5,
status=5,
backoff_factor=1.0,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET"}),
raise_on_status=True,
)
session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-ALZ-Agricultural-Parcels-Operator/1.0"})
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def response_data(response: requests.Response) -> Any:
try:
payload = response.json()
except requests.JSONDecodeError as exc:
raise RuntimeError(f"GeoIntel API returned non-JSON content ({response.status_code})") from exc
if not response.ok:
if isinstance(payload, dict):
message = payload.get("message") or payload.get("error") or response.text
else:
message = response.text
raise RuntimeError(f"GeoIntel API request failed ({response.status_code}): {message}")
if isinstance(payload, dict) and "data" in payload:
return payload["data"]
return payload
def api_items(session: requests.Session, url: str, timeout: int) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
offset = 0
expected_total: int | None = None
while expected_total is None or offset < expected_total:
response = session.get(url, params={"limit": 200, "offset": offset}, timeout=timeout)
data = response_data(response)
if isinstance(data, dict):
page = data.get("items") or data.get("results") or []
page_total = int(data.get("total") if data.get("total") is not None else len(page))
else:
page = data
page_total = len(page) if isinstance(page, list) else 0
if not isinstance(page, list):
raise RuntimeError(f"Expected a list response from {url}")
if expected_total is None:
expected_total = page_total
elif page_total != expected_total:
raise RuntimeError("GeoIntel pagination total changed while reading the agricultural workspace")
items.extend(item for item in page if isinstance(item, dict))
if not page:
break
offset += len(page)
if expected_total is not None and len(items) != expected_total:
raise RuntimeError(f"GeoIntel list response for {url} returned {len(items)} of {expected_total} items")
return items
def locate_workspace(
session: requests.Session,
base_url: str,
scope: GeographicScope,
timeout: int,
) -> tuple[str, str, Any, list[dict[str, Any]]]:
projects = api_items(session, f"{base_url}/api/v1/projects", timeout)
project = next((item for item in projects if item.get("name") == scope.project_name), None)
if project is None:
raise RuntimeError(f"Project {scope.project_name!r} is missing; provision the geographic scope first")
project_id = str(project["id"])
areas = api_items(session, f"{base_url}/api/v1/projects/{project_id}/areas", timeout)
area = next((item for item in areas if item.get("name") == scope.area_name), None)
if area is None:
raise RuntimeError(f"Area {scope.area_name!r} is missing from project {scope.project_name!r}")
boundary = polygonal_geometry(shape(area.get("geometry")))
if boundary is None:
raise RuntimeError(f"Area {scope.area_name!r} has no valid polygon geometry")
datasets = api_items(session, f"{base_url}/api/v1/projects/{project_id}/datasets", timeout)
return project_id, str(area["id"]), boundary, datasets
def parse_years(raw: str) -> list[int]:
try:
years = sorted({int(value.strip()) for value in raw.split(",") if value.strip()})
except ValueError as exc:
raise ValueError("Years must be a comma-separated list of integers") from exc
unsupported = [year for year in years if year not in ARCHIVE_URLS]
if not years or unsupported:
raise ValueError(f"Supported definitive years are {SUPPORTED_YEARS}; unsupported: {unsupported}")
return years
def validate_archive_url(url: str, *, expected_year: int) -> str:
parsed = urlsplit(url)
match = ARCHIVE_PATH_PATTERN.fullmatch(parsed.path)
if (
parsed.scheme != "https"
or parsed.hostname != ARCHIVE_HOST
or parsed.port not in {None, 443}
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or not match
or int(match.group(1)) != expected_year
):
raise ValueError("Agricultural release archive is outside the official ALZ URL contract")
try:
datetime.strptime(match.group(2), "%Y-%m-%d")
except ValueError as exc:
raise ValueError("Agricultural release archive contains an invalid publication date") from exc
return url
def resolve_release_config(year: int, *, archive_url: str | None = None) -> AgriculturalReleaseConfig:
known_url = ARCHIVE_URLS.get(year)
if known_url is not None:
if archive_url is not None and archive_url != known_url:
raise ValueError(f"The official retained archive identity for {year} may not be overridden")
return AgriculturalReleaseConfig(year=year, archive_url=known_url)
if year <= max(SUPPORTED_YEARS) or not archive_url:
raise ValueError(
f"One future definitive edition after {max(SUPPORTED_YEARS)} may be supplied with --archive-url"
)
return AgriculturalReleaseConfig(year=year, archive_url=validate_archive_url(archive_url, expected_year=year))
def resolve_release_configs(raw_years: str, *, archive_url: str | None = None) -> list[AgriculturalReleaseConfig]:
if archive_url is None:
return [resolve_release_config(year) for year in parse_years(raw_years)]
try:
years = sorted({int(value.strip()) for value in raw_years.split(",") if value.strip()})
except ValueError as exc:
raise ValueError("Years must be a comma-separated list of integers") from exc
if len(years) != 1:
raise ValueError("--archive-url requires exactly one explicitly selected definitive year")
return [resolve_release_config(years[0], archive_url=archive_url)]
def polygonal_geometry(geometry):
if geometry is None or geometry.is_empty:
return None
if not geometry.is_valid:
geometry = make_valid(geometry)
if isinstance(geometry, (Polygon, MultiPolygon)):
return geometry
if isinstance(geometry, GeometryCollection):
polygons = [part for part in geometry.geoms if isinstance(part, (Polygon, MultiPolygon)) and not part.is_empty]
if not polygons:
return None
merged = unary_union(polygons)
return merged if isinstance(merged, (Polygon, MultiPolygon)) and not merged.is_empty else None
return None
def normalized_group_title(value: Any) -> str | None:
if value is None:
return None
normalized = str(value).strip().casefold().replace("ï", "i")
return GROUP_ALIASES.get(normalized, normalized.replace(" ", "_")) if normalized else None
def json_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, float) and not math.isfinite(value):
return None
if isinstance(value, (str, int, float, bool)):
return value
if hasattr(value, "item"):
try:
return json_value(value.item())
except (TypeError, ValueError):
pass
return str(value)
def download_archive(
session: requests.Session,
url: str,
destination: Path,
*,
timeout: int,
max_bytes: int,
force: bool,
) -> dict[str, Any]:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.is_file() and not force:
validate_archive(destination)
return {"status": "reused", "sha256": sha256_file(destination), "size_bytes": destination.stat().st_size}
temporary = destination.with_suffix(destination.suffix + ".partial")
temporary.unlink(missing_ok=True)
response = session.get(url, timeout=timeout, stream=True)
response.raise_for_status()
final_url = str(getattr(response, "url", "") or url)
expected_year = int(ARCHIVE_PATH_PATTERN.fullmatch(urlsplit(url).path).group(1))
if validate_archive_url(final_url, expected_year=expected_year) != url:
raise RuntimeError("Official archive download redirected to a different release identity")
content_length = int(response.headers.get("content-length") or 0)
if content_length > max_bytes:
raise RuntimeError(f"Official archive exceeds the configured {max_bytes // (1024 * 1024)} MiB limit")
size = 0
try:
with temporary.open("wb") as handle:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
size += len(chunk)
if size > max_bytes:
raise RuntimeError(f"Official archive exceeded the configured {max_bytes // (1024 * 1024)} MiB limit while streaming")
handle.write(chunk)
validate_archive(temporary)
temporary.replace(destination)
except Exception:
temporary.unlink(missing_ok=True)
raise
return {"status": "downloaded", "sha256": sha256_file(destination), "size_bytes": destination.stat().st_size}
def archive_geopackage_member(path: Path) -> str:
with zipfile.ZipFile(path) as archive:
entries = archive.infolist()
if len(entries) > MAX_ARCHIVE_MEMBERS:
raise RuntimeError(f"Official archive contains more than {MAX_ARCHIVE_MEMBERS} members")
if sum(item.file_size for item in entries if not item.is_dir()) > MAX_EXTRACTED_BYTES:
raise RuntimeError("Official archive exceeds the extracted-size safety limit")
members = [item.filename for item in entries if not item.is_dir() and item.filename.lower().endswith(".gpkg")]
if len(members) != 1:
raise RuntimeError(f"Official archive must contain exactly one GeoPackage; found {len(members)}")
member = members[0]
if Path(member).name != member or ".." in Path(member).parts:
raise RuntimeError("Official archive contains an unsafe GeoPackage path")
return member
def validate_archive(path: Path) -> str:
try:
return archive_geopackage_member(path)
except zipfile.BadZipFile as exc:
raise RuntimeError(f"Official source archive {path.name} is not a valid ZIP") from exc
def extract_geopackage(archive_path: Path, destination_dir: Path) -> Path:
member = validate_archive(archive_path)
destination = destination_dir / Path(member).name
with zipfile.ZipFile(archive_path) as archive, archive.open(member) as source, destination.open("wb") as target:
extracted_bytes = 0
for chunk in iter(lambda: source.read(1024 * 1024), b""):
extracted_bytes += len(chunk)
if extracted_bytes > MAX_EXTRACTED_BYTES:
raise RuntimeError("Official GeoPackage exceeded the extracted-size safety limit while streaming")
target.write(chunk)
if destination.stat().st_size == 0:
raise RuntimeError("Extracted official GeoPackage is empty")
return destination
def load_pyogrio():
try:
import pyogrio # type: ignore[import-not-found]
except ImportError as exc:
raise RuntimeError("Agricultural parcel preparation requires the optional GeoIntel GIS dependencies (pyogrio/geopandas)") from exc
return pyogrio
def inspect_geopackage(pyogrio, path: Path) -> tuple[str, set[str], int]:
layers = pyogrio.list_layers(path)
polygon_layers = [str(row[0]) for row in layers if "Polygon" in str(row[1])]
if len(polygon_layers) != 1:
raise RuntimeError(f"Expected one polygon layer in {path.name}; found {polygon_layers}")
layer = polygon_layers[0]
info = pyogrio.read_info(path, layer=layer)
crs = str(info.get("crs") or "")
if "31370" not in crs:
raise RuntimeError(f"Expected EPSG:31370 agricultural parcels; received {crs or 'no CRS'}")
info_fields = info.get("fields")
fields = {str(value).lower() for value in info_fields} if info_fields is not None else set()
missing = sorted(STABLE_REQUIRED_FIELDS - fields)
if missing:
raise RuntimeError(f"Agricultural parcel schema is missing stable fields: {', '.join(missing)}")
return layer, fields, int(info.get("features") or 0)
def build_crop_code_list(records: Iterable[dict[str, Any]], *, year: int) -> dict[str, Any]:
crops: dict[tuple[str, str, str], dict[str, Any]] = {}
groups: set[str] = set()
title_by_code: dict[str, set[str]] = {}
for record in records:
code = str(json_value(record.get("maincrop_code")) or "").strip()
title = str(json_value(record.get("maincrop_title")) or "").strip()
group_title = str(json_value(record.get("maincropgroup_title")) or "").strip()
if not code and not title and not group_title:
continue
crops[(code, title, group_title)] = {"code": code or None, "title": title or None, "group_title": group_title or None}
if group_title:
groups.add(group_title)
if code and title:
title_by_code.setdefault(code, set()).add(title)
conflicts = {code: sorted(titles) for code, titles in title_by_code.items() if len(titles) > 1}
return {
"year": year,
"source": ATTRIBUTION,
"generated_from": "full official annual GeoPackage attributes",
"crop_entries": sorted(crops.values(), key=lambda item: (str(item["code"]), str(item["title"]), str(item["group_title"]))),
"main_crop_groups": sorted(groups),
"code_title_conflicts": conflicts,
"historical_comparison_rule": "Use maincropgroup_title for comparable grouped hectares; detailed crop code/title remains source-faithful per year.",
}
def dataframe_records(frame, columns: Iterable[str]) -> Iterable[dict[str, Any]]:
for row in frame.itertuples(index=False, name=None):
yield {column: json_value(value) for column, value in zip(columns, row)}
def normalize_frame(frame, *, year: int, boundary_lambert72, max_features: int) -> tuple[list[dict[str, Any]], dict[str, Any]]:
features: list[dict[str, Any]] = []
seen_ids: set[str] = set()
clipped_count = 0
source_area_ha = 0.0
exact_area_ha = 0.0
for _, row in frame.iterrows():
source_geometry = polygonal_geometry(row.geometry)
if source_geometry is None or not source_geometry.intersects(boundary_lambert72):
continue
clipped = polygonal_geometry(source_geometry.intersection(boundary_lambert72))
if clipped is None or clipped.area <= 0:
continue
properties = {str(column).lower(): json_value(row[column]) for column in frame.columns if str(column).lower() != "geometry"}
agpa_key = str(properties.get("agpakey") or "").strip()
if not agpa_key:
raise RuntimeError(f"Agricultural parcel {year} contains a feature without agpakey")
source_feature_id = f"alz:{year}:{agpa_key}"
if source_feature_id in seen_ids:
raise RuntimeError(f"Agricultural parcel {year} contains duplicate agpakey {agpa_key}")
seen_ids.add(source_feature_id)
clipped_wgs84 = polygonal_geometry(transform_geometry(TO_WGS84.transform, clipped))
if clipped_wgs84 is None:
raise RuntimeError(f"Agricultural parcel {agpa_key} could not be transformed to EPSG:4326")
source_area = float(source_geometry.area) / 10_000.0
exact_area = float(clipped.area) / 10_000.0
source_area_ha += source_area
exact_area_ha += exact_area
was_clipped = not source_geometry.within(boundary_lambert72)
clipped_count += int(was_clipped)
properties.update(
{
"source_feature_id": source_feature_id,
"source_year": year,
"source_agpa_key": agpa_key,
"main_crop_group_key": normalized_group_title(properties.get("maincropgroup_title")),
"source_geometry_area_ha": round(source_area, 8),
"clipped_area_ha": round(exact_area, 8),
"geometry_was_clipped": was_clipped,
"historical_parcel_identity_stable": False,
}
)
features.append({"type": "Feature", "id": source_feature_id, "geometry": mapping(clipped_wgs84), "properties": properties})
if len(features) > max_features:
raise RuntimeError(f"Clipped agricultural parcel count exceeds the configured limit of {max_features}")
if not features:
raise RuntimeError(f"Official agricultural parcel archive {year} has no features inside the persisted scope")
return features, {
"feature_count": len(features),
"clipped_feature_count": clipped_count,
"source_geometry_area_ha": round(source_area_ha, 4),
"clipped_area_ha": round(exact_area_ha, 4),
}
def artifact_paths(
output_root: Path,
scope_key: str,
year: int,
*,
archive_url: str | None = None,
) -> dict[str, Path]:
release = resolve_release_config(year, archive_url=archive_url)
directory = output_root / scope_key / str(year)
return {
"directory": directory,
"archive": directory / Path(urlsplit(release.archive_url).path).name,
"artifact": directory / f"agricultural_use_parcels_{year}_{scope_key}.geojson",
"codelist": directory / f"agricultural_use_parcels_{year}_crop_codes.json",
"manifest": directory / f"agricultural_use_parcels_{year}_{scope_key}.manifest.json",
}
def reusable_artifact(
paths: dict[str, Path],
*,
year: int,
scope_key: str,
archive_url: str | None = None,
) -> dict[str, Any] | None:
if not all(paths[key].is_file() for key in ("archive", "artifact", "codelist", "manifest")):
return None
manifest = json.loads(paths["manifest"].read_text(encoding="utf-8"))
if manifest.get("schema_version") != SCHEMA_VERSION or manifest.get("year") != year or manifest.get("scope_key") != scope_key:
return None
release = resolve_release_config(year, archive_url=archive_url)
if manifest.get("source_url") != release.archive_url:
return None
if manifest.get("source_archive_sha256") != sha256_file(paths["archive"]):
return None
if manifest.get("artifact_sha256") != sha256_file(paths["artifact"]):
return None
if manifest.get("crop_code_list_sha256") != sha256_file(paths["codelist"]):
return None
validate_archive(paths["archive"])
return manifest
def prepare_year(
session: requests.Session,
*,
year: int,
scope: GeographicScope,
boundary_wgs84,
output_root: Path,
request_timeout: int,
max_archive_bytes: int,
max_features: int,
force: bool,
archive_url: str | None = None,
) -> tuple[dict[str, Path], dict[str, Any]]:
release = resolve_release_config(year, archive_url=archive_url)
paths = artifact_paths(output_root, scope.key, year, archive_url=release.archive_url)
paths["directory"].mkdir(parents=True, exist_ok=True)
if not force:
reused = reusable_artifact(paths, year=year, scope_key=scope.key, archive_url=release.archive_url)
if reused is not None:
return paths, reused
download = download_archive(
session,
release.archive_url,
paths["archive"],
timeout=request_timeout,
max_bytes=max_archive_bytes,
force=force,
)
boundary_lambert72 = polygonal_geometry(transform_geometry(TO_LAMBERT72.transform, boundary_wgs84))
if boundary_lambert72 is None:
raise RuntimeError(f"Scope {scope.key} could not be transformed to EPSG:31370")
pyogrio = load_pyogrio()
with tempfile.TemporaryDirectory(prefix=f"agpa-{year}-", dir=paths["directory"]) as temporary:
gpkg_path = extract_geopackage(paths["archive"], Path(temporary))
layer, fields, source_feature_count = inspect_geopackage(pyogrio, gpkg_path)
code_columns = [field for field in ("maincrop_code", "maincrop_title", "maincropgroup_title") if field in fields]
code_frame = pyogrio.read_dataframe(gpkg_path, layer=layer, columns=code_columns, read_geometry=False)
code_list = build_crop_code_list(dataframe_records(code_frame, code_columns), year=year)
write_json_atomic(paths["codelist"], code_list, pretty=True)
frame = pyogrio.read_dataframe(gpkg_path, layer=layer, bbox=boundary_lambert72.bounds)
features, summary = normalize_frame(frame, year=year, boundary_lambert72=boundary_lambert72, max_features=max_features)
artifact = {"type": "FeatureCollection", "name": paths["artifact"].stem, "features": features}
write_json_atomic(paths["artifact"], artifact)
manifest = {
"schema_version": SCHEMA_VERSION,
"generated_at": utc_now(),
"year": year,
"scope_key": scope.key,
"scope_name": scope.display_name,
"scope_type": scope.scope_type,
"member_nis_codes": list(scope.nis_codes),
"source_url": release.archive_url,
"catalog_url": CATALOG_URL,
"data_catalog_url": DATA_CATALOG_URL,
"attribution": ATTRIBUTION,
"source_crs": SOURCE_CRS,
"output_crs": OUTPUT_CRS,
"source_archive_path": str(paths["archive"]),
"source_archive_sha256": download["sha256"],
"source_archive_size_bytes": download["size_bytes"],
"source_feature_count": source_feature_count,
"source_fields": sorted(fields),
"crop_code_list_path": str(paths["codelist"]),
"crop_code_list_sha256": sha256_file(paths["codelist"]),
"artifact_path": str(paths["artifact"]),
"artifact_sha256": sha256_file(paths["artifact"]),
**summary,
"limitations": [
"Parcel identities are not stable across campaign years; evolution compares grouped area totals, not parcel lineage.",
"The layer describes declared agricultural use at the annual reference deadline and can include water, hedges, buildings and infrastructure.",
"Detailed crop codes and titles remain source-faithful per year; only official main-crop groups are used for comparable historical metrics.",
"The current-campaign provisional snapshot is intentionally excluded.",
],
}
write_json_atomic(paths["manifest"], manifest, pretty=True)
return paths, manifest
def selection_metrics() -> list[dict[str, Any]]:
warning = "Historische evolutie vergelijkt officiele hoofdteeltgroepen; individuele perceelidentiteiten zijn niet stabiel tussen campagnejaren."
return [
{
"metric_key": metric_key,
"method": "intersection_area",
"label": label,
"unit": "ha",
"geometry_dimension": 2,
"filter_property": "main_crop_group_key",
"filter_values": list(group_keys),
"warning": warning,
}
for metric_key, label, group_keys in METRIC_GROUPS
]
def series_key(scope: GeographicScope) -> str:
return f"alz:agricultural-use-parcels:{scope.key}"
def upload_artifact(
session: requests.Session,
*,
base_url: str,
project_id: str,
area_id: str,
scope: GeographicScope,
year: int,
paths: dict[str, Path],
manifest: dict[str, Any],
timeout: int,
) -> dict[str, Any]:
source_metadata = {
"provider": "Agentschap Landbouw en Zeevisserij",
"theme": "agriculture",
"layer_name": f"Landbouwgebruikspercelen {year}",
"authority_level": "authoritative",
"coverage_scope": scope.scope_type,
"scope_key": scope.key,
"scope_name": scope.display_name,
"member_nis_codes": list(scope.nis_codes),
"feature_count": manifest["feature_count"],
"geometry_clipped_to_area": True,
"identity_stable": False,
"semantic_metrics": False,
"attribution": ATTRIBUTION,
"catalog_url": CATALOG_URL,
"selection_aggregation": {
"metric_key": "declared_agricultural_use_area",
"method": "intersection_area",
"label": "Aangegeven gebruiksoppervlakte",
"unit": "ha",
"geometry_dimension": 2,
"warning": "De aangifte bevat naast teelten ook onder meer water, hagen, gebouwen en landbouwinfrastructuur; dit is geen eigendoms- of kadastrale oppervlakte.",
},
"selection_metrics": selection_metrics(),
}
provenance_metadata = {
"operator_tool": "provision_agricultural_parcel_history.py",
"operator_explicit_fetch": True,
"geometry_clipped_to_area": True,
"source_archive_url": manifest["source_url"],
"source_archive_path": str(paths["archive"]),
"source_archive_sha256": manifest["source_archive_sha256"],
"crop_code_list_path": str(paths["codelist"]),
"crop_code_list_sha256": manifest["crop_code_list_sha256"],
"manifest_path": str(paths["manifest"]),
"artifact_sha256": manifest["artifact_sha256"],
"catalog_url": CATALOG_URL,
"limitations": manifest["limitations"],
}
with paths["artifact"].open("rb") as handle:
response = session.post(
f"{base_url}/api/v1/projects/{project_id}/datasets/upload",
data={
"dataset_type": "vector",
"source": "operator_official_import",
"dataset_role": "reference",
"source_name": SOURCE_NAME,
"reference_layer_name": REFERENCE_LAYER_NAME,
"source_metadata_json": json.dumps(source_metadata, ensure_ascii=False),
"provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False),
"area_id": area_id,
"temporal_series_key": series_key(scope),
"observed_at": f"{year}-01-01T00:00:00Z",
"temporal_granularity": "year",
"source_version": f"{year}-definitive",
},
files={"file": (paths["artifact"].name, handle, "application/geo+json")},
timeout=timeout,
)
return response_data(response)
def existing_dataset_for_year(datasets: list[dict[str, Any]], *, area_id: str, year: int) -> dict[str, Any] | None:
return next(
(
dataset
for dataset in datasets
if dataset.get("source_name") == SOURCE_NAME
and dataset.get("source_version") == f"{year}-definitive"
and str(dataset.get("area_id") or "") == area_id
),
None,
)
def main() -> int:
args = parse_args()
try:
releases = resolve_release_configs(args.years, archive_url=args.archive_url)
if args.max_features < 1 or args.max_archive_mb < 1:
raise ValueError("Feature and archive safety limits must be positive")
scope = GEOGRAPHIC_SCOPES[args.scope]
base_url = args.base_url.rstrip("/")
with requests.Session() as api_session:
project_id, area_id, boundary, datasets = locate_workspace(api_session, base_url, scope, args.import_timeout)
results: list[dict[str, Any]] = []
with build_session() as official_session:
for release in releases:
year = release.year
paths, manifest = prepare_year(
official_session,
year=year,
scope=scope,
boundary_wgs84=boundary,
output_root=args.output_root,
request_timeout=args.request_timeout,
max_archive_bytes=min(args.max_archive_mb * 1024 * 1024, MAX_ARCHIVE_BYTES),
max_features=args.max_features,
force=args.force,
archive_url=release.archive_url,
)
existing = existing_dataset_for_year(datasets, area_id=area_id, year=year)
if existing is not None:
persisted_checksum = str(existing.get("checksum_sha256") or "")
if persisted_checksum and persisted_checksum != manifest["artifact_sha256"]:
raise RuntimeError(f"A different {year} agricultural parcel artifact is already persisted; refusing silent replacement")
result = {"year": year, "status": "existing", "dataset_id": existing["id"], "feature_count": existing.get("feature_count")}
elif args.fetch_only:
result = {"year": year, "status": "prepared", "artifact_path": str(paths["artifact"]), "feature_count": manifest["feature_count"]}
else:
dataset = upload_artifact(
api_session,
base_url=base_url,
project_id=project_id,
area_id=area_id,
scope=scope,
year=year,
paths=paths,
manifest=manifest,
timeout=args.import_timeout,
)
result = {"year": year, "status": "imported", "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count")}
results.append({**result, "area_ha": manifest["clipped_area_ha"], "manifest_path": str(paths["manifest"])})
except (KeyError, OSError, RuntimeError, ValueError, zipfile.BadZipFile, requests.RequestException) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
print(
json.dumps(
{
"status": "ok",
"scope": scope.key,
"project": scope.project_name,
"area": scope.area_name,
"series_key": series_key(scope),
"years": results,
"historical_identity_stable": False,
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())