Initial public release
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
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
This commit is contained in:
@@ -0,0 +1,861 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user