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,619 @@
|
||||
"""Provision the official DOV digital soil map for an approved region.
|
||||
|
||||
The DOV WFS is queried and audited per municipality. Source responses are
|
||||
retained as deterministic gzip artifacts, polygons are clipped in EPSG:31370,
|
||||
and one complete regional GeoJSON snapshot is imported through DatasetService.
|
||||
The 1949-1971 survey remains a historical baseline, never a current drainage
|
||||
or parcel-scale soil investigation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from shapely.geometry import mapping, shape
|
||||
from shapely.ops import transform as transform_geometry
|
||||
|
||||
from geographic_scopes import GEOGRAPHIC_SCOPES, GeographicScope, ScopeMember
|
||||
import provision_mol_soil_map as soil
|
||||
|
||||
|
||||
DEFAULT_SCOPE_KEY = "kempen-transport-region"
|
||||
DEFAULT_SCOPE_OUTPUT_ROOT = Path("/app/storage/operator-data/geographic-scopes")
|
||||
DEFAULT_OUTPUT_ROOT = Path("/app/storage/operator-evidence/dov-soil-map/regional")
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Provision regional official DOV soil reference data.")
|
||||
parser.add_argument("--scope", choices=sorted(GEOGRAPHIC_SCOPES), default=DEFAULT_SCOPE_KEY)
|
||||
parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", soil.DEFAULT_API_URL))
|
||||
parser.add_argument(
|
||||
"--scope-output-root",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("GEOINTEL_SCOPE_OUTPUT_ROOT", DEFAULT_SCOPE_OUTPUT_ROOT)),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-root",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("GEOINTEL_REGIONAL_SOIL_OUTPUT_ROOT", DEFAULT_OUTPUT_ROOT)),
|
||||
)
|
||||
parser.add_argument("--page-limit", type=int, default=1000)
|
||||
parser.add_argument("--max-features-per-partition", type=int, default=20_000)
|
||||
parser.add_argument("--max-total-features", type=int, default=200_000)
|
||||
parser.add_argument("--request-timeout", type=int, default=300)
|
||||
parser.add_argument("--import-timeout", type=int, default=3600)
|
||||
parser.add_argument("--fetch-only", action="store_true")
|
||||
parser.add_argument("--force", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def boundary_sha256(geometry) -> str:
|
||||
encoded = json.dumps(mapping(geometry), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return soil.sha256_bytes(encoded)
|
||||
|
||||
|
||||
def resolve_member_boundaries(scope: GeographicScope, scope_output_root: Path) -> tuple[Path, str]:
|
||||
scope_dir = scope_output_root / scope.key
|
||||
manifest_path = scope_dir / f"{scope.key.replace('-', '_')}_scope_manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
raise RuntimeError(f"Official scope manifest is missing at {manifest_path}")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
manifest.get("status") != "complete"
|
||||
or manifest.get("scope_key") != scope.key
|
||||
or int(manifest.get("member_count") or 0) != len(scope.members)
|
||||
):
|
||||
raise RuntimeError(f"Official scope manifest at {manifest_path} is incomplete or inconsistent")
|
||||
members_path = scope_dir / str(manifest.get("municipalities_filename") or "")
|
||||
expected_sha256 = str(manifest.get("municipalities_sha256") or "")
|
||||
if not members_path.is_file() or not expected_sha256 or soil.sha256_file(members_path) != expected_sha256:
|
||||
raise RuntimeError("Official municipality-boundary artifact is missing or fails its scope checksum")
|
||||
return members_path, expected_sha256
|
||||
|
||||
|
||||
def load_member_boundaries(path: Path, scope: GeographicScope) -> dict[str, tuple[ScopeMember, Any]]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
features = payload.get("features") if isinstance(payload, dict) else None
|
||||
if not isinstance(features, list):
|
||||
raise RuntimeError("Municipality-boundary artifact is not a GeoJSON FeatureCollection")
|
||||
expected = {member.nis_code: member for member in scope.members}
|
||||
selected: dict[str, tuple[ScopeMember, Any]] = {}
|
||||
for feature in features:
|
||||
properties = feature.get("properties") or {}
|
||||
nis_code = str(properties.get("nis_code") or properties.get("NISCODE") or "")
|
||||
if nis_code not in expected:
|
||||
continue
|
||||
if nis_code in selected:
|
||||
raise RuntimeError(f"Municipality-boundary artifact contains duplicate NIS code {nis_code}")
|
||||
geometry = soil.polygonal_geometry(shape(feature.get("geometry")))
|
||||
if geometry is None:
|
||||
raise RuntimeError(f"Municipality boundary for {expected[nis_code].name} is invalid")
|
||||
selected[nis_code] = (expected[nis_code], geometry)
|
||||
missing = [member.name for member in scope.members if member.nis_code not in selected]
|
||||
if missing:
|
||||
raise RuntimeError(f"Municipality-boundary artifact is missing: {', '.join(missing)}")
|
||||
return {member.nis_code: selected[member.nis_code] for member in scope.members}
|
||||
|
||||
|
||||
def partition_paths(output_root: Path, nis_code: str) -> tuple[Path, Path, Path]:
|
||||
partition_dir = output_root / "partitions" / nis_code
|
||||
return partition_dir / "dov_soil_map.geojson", partition_dir / "manifest.json", partition_dir / "raw"
|
||||
|
||||
|
||||
def cached_partition(
|
||||
output_root: Path,
|
||||
member: ScopeMember,
|
||||
boundary_hash: str,
|
||||
) -> dict[str, Any] | None:
|
||||
output_path, manifest_path, _raw_dir = partition_paths(output_root, member.nis_code)
|
||||
if not output_path.is_file() or not manifest_path.is_file():
|
||||
return None
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if (
|
||||
manifest.get("schema_version") != SCHEMA_VERSION
|
||||
or manifest.get("source_version") != soil.SOURCE_VERSION
|
||||
or manifest.get("nis_code") != member.nis_code
|
||||
or manifest.get("boundary_sha256") != boundary_hash
|
||||
or manifest.get("output_sha256") != soil.sha256_file(output_path)
|
||||
):
|
||||
return None
|
||||
for page in manifest.get("raw_pages") or []:
|
||||
page_path = manifest_path.parent / str(page.get("artifact_path") or "")
|
||||
if not page_path.is_file() or page.get("artifact_sha256") != soil.sha256_file(page_path):
|
||||
return None
|
||||
try:
|
||||
raw_bytes = gzip.decompress(page_path.read_bytes())
|
||||
except (OSError, EOFError):
|
||||
return None
|
||||
if page.get("response_sha256") != soil.sha256_bytes(raw_bytes):
|
||||
return None
|
||||
manifest["output_path"] = str(output_path)
|
||||
manifest["manifest_path"] = str(manifest_path)
|
||||
return manifest
|
||||
|
||||
|
||||
def prepare_partition(
|
||||
session,
|
||||
*,
|
||||
output_root: Path,
|
||||
scope: GeographicScope,
|
||||
member: ScopeMember,
|
||||
boundary_wgs84,
|
||||
page_limit: int,
|
||||
max_features: int,
|
||||
timeout: int,
|
||||
force: bool,
|
||||
) -> dict[str, Any]:
|
||||
output_path, manifest_path, raw_dir = partition_paths(output_root, member.nis_code)
|
||||
current_boundary_hash = boundary_sha256(boundary_wgs84)
|
||||
if not force:
|
||||
reusable = cached_partition(output_root, member, current_boundary_hash)
|
||||
if reusable:
|
||||
return reusable
|
||||
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
boundary_lambert72 = soil.polygonal_geometry(transform_geometry(soil.TO_LAMBERT72.transform, boundary_wgs84))
|
||||
if boundary_lambert72 is None:
|
||||
raise RuntimeError(f"Boundary for {member.name} could not be transformed to EPSG:31370")
|
||||
|
||||
retained: list[dict[str, Any]] = []
|
||||
raw_pages: list[dict[str, Any]] = []
|
||||
seen_source_ids: set[str] = set()
|
||||
raw_feature_count = 0
|
||||
duplicate_count = 0
|
||||
rejected_count = 0
|
||||
clipped_count = 0
|
||||
area_by_legend: dict[str, float] = defaultdict(float)
|
||||
area_by_texture: dict[str, float] = defaultdict(float)
|
||||
area_by_drainage: dict[str, float] = defaultdict(float)
|
||||
|
||||
for page_number, (payload, source_url, raw_bytes) in enumerate(
|
||||
soil.iter_wfs_pages(
|
||||
session,
|
||||
boundary_lambert72.bounds,
|
||||
page_limit=page_limit,
|
||||
timeout=timeout,
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
page_features = list(payload.get("features") or [])
|
||||
raw_feature_count += len(page_features)
|
||||
if raw_feature_count > max_features:
|
||||
raise RuntimeError(
|
||||
f"DOV WFS returned more than {max_features} features for {member.name}; refusing truncation"
|
||||
)
|
||||
compressed = gzip.compress(raw_bytes, mtime=0)
|
||||
raw_path = raw_dir / f"dov_soil_map_page_{page_number:05d}.json.gz"
|
||||
soil.write_bytes_atomic(raw_path, compressed)
|
||||
raw_pages.append(
|
||||
{
|
||||
"artifact_path": str(raw_path.relative_to(manifest_path.parent)),
|
||||
"artifact_sha256": soil.sha256_bytes(compressed),
|
||||
"response_sha256": soil.sha256_bytes(raw_bytes),
|
||||
"response_size_bytes": len(raw_bytes),
|
||||
"feature_count": len(page_features),
|
||||
"source_url": source_url,
|
||||
}
|
||||
)
|
||||
for source_feature in page_features:
|
||||
raw = dict(source_feature.get("properties") or {})
|
||||
source_id = str(source_feature.get("id") or f"{soil.TYPE_NAME}:{raw.get('gid')}")
|
||||
if source_id in seen_source_ids:
|
||||
duplicate_count += 1
|
||||
continue
|
||||
seen_source_ids.add(source_id)
|
||||
normalized, was_clipped = soil.normalize_feature(
|
||||
source_feature,
|
||||
boundary_lambert72,
|
||||
municipality=member.name,
|
||||
nis_code=member.nis_code,
|
||||
coverage_scope=scope.key,
|
||||
feature_id_suffix=member.nis_code,
|
||||
)
|
||||
if normalized is None:
|
||||
rejected_count += 1
|
||||
continue
|
||||
clipped_count += int(was_clipped)
|
||||
retained.append(normalized)
|
||||
properties = normalized["properties"]
|
||||
area = float(properties["clipped_area_ha"])
|
||||
area_by_legend[str(properties.get("soil_generalized_legend") or "Onbekend")] += area
|
||||
area_by_texture[str(properties.get("soil_texture_class") or "Onbekend")] += area
|
||||
area_by_drainage[str(properties.get("soil_drainage_class") or "Onbekend")] += area
|
||||
|
||||
if not retained:
|
||||
raise RuntimeError(f"DOV WFS returned no valid soil polygons inside {member.name}")
|
||||
artifact = {
|
||||
"type": "FeatureCollection",
|
||||
"name": f"Digitale bodemkaart - {member.name}",
|
||||
"features": retained,
|
||||
"source": soil.ATTRIBUTION,
|
||||
"source_version": soil.SOURCE_VERSION,
|
||||
"survey_period": soil.SURVEY_PERIOD,
|
||||
"coverage_scope": scope.key,
|
||||
"municipality": member.name,
|
||||
"nis_code": member.nis_code,
|
||||
"reference_truncated": False,
|
||||
}
|
||||
soil.write_json_atomic(output_path, artifact)
|
||||
manifest = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"source_version": soil.SOURCE_VERSION,
|
||||
"survey_period": soil.SURVEY_PERIOD,
|
||||
"scope_key": scope.key,
|
||||
"municipality": member.name,
|
||||
"nis_code": member.nis_code,
|
||||
"boundary_sha256": current_boundary_hash,
|
||||
"boundary_bbox_wgs84": list(boundary_wgs84.bounds),
|
||||
"boundary_bbox_epsg31370": list(boundary_lambert72.bounds),
|
||||
"page_limit": page_limit,
|
||||
"page_count": len(raw_pages),
|
||||
"raw_source_feature_count": raw_feature_count,
|
||||
"feature_count": len(retained),
|
||||
"duplicate_count": duplicate_count,
|
||||
"rejected_or_outside_count": rejected_count,
|
||||
"clipped_feature_count": clipped_count,
|
||||
"reference_truncated": False,
|
||||
"raw_pages": raw_pages,
|
||||
"area_by_generalized_legend_ha": {key: round(value, 6) for key, value in sorted(area_by_legend.items())},
|
||||
"area_by_texture_ha": {key: round(value, 6) for key, value in sorted(area_by_texture.items())},
|
||||
"area_by_drainage_ha": {key: round(value, 6) for key, value in sorted(area_by_drainage.items())},
|
||||
"output_path": str(output_path),
|
||||
"output_sha256": soil.sha256_file(output_path),
|
||||
"generated_at": soil.utc_now(),
|
||||
}
|
||||
soil.write_json_atomic(manifest_path, manifest, pretty=True)
|
||||
manifest["manifest_path"] = str(manifest_path)
|
||||
return manifest
|
||||
|
||||
|
||||
def snapshot_paths(output_root: Path, scope: GeographicScope) -> tuple[Path, Path]:
|
||||
snapshot_dir = output_root / "snapshots" / scope.key
|
||||
return snapshot_dir / "dov_soil_map.geojson", snapshot_dir / "manifest.json"
|
||||
|
||||
|
||||
def assemble_snapshot(
|
||||
*,
|
||||
output_root: Path,
|
||||
scope: GeographicScope,
|
||||
partitions: list[dict[str, Any]],
|
||||
member_boundaries_sha256: str,
|
||||
max_total_features: int,
|
||||
) -> tuple[Path, Path, dict[str, Any]]:
|
||||
if [item.get("nis_code") for item in partitions] != list(scope.nis_codes):
|
||||
raise RuntimeError("Soil partition order/completeness does not match the approved geographic scope")
|
||||
output_path, manifest_path = snapshot_paths(output_root, scope)
|
||||
partition_identity = soil.sha256_bytes(
|
||||
json.dumps(
|
||||
[(item["nis_code"], item["output_sha256"]) for item in partitions],
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
)
|
||||
if output_path.is_file() and manifest_path.is_file():
|
||||
try:
|
||||
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
existing = {}
|
||||
if (
|
||||
existing.get("partition_identity_sha256") == partition_identity
|
||||
and existing.get("output_sha256") == soil.sha256_file(output_path)
|
||||
):
|
||||
return output_path, manifest_path, existing
|
||||
|
||||
features: list[dict[str, Any]] = []
|
||||
feature_ids: set[str] = set()
|
||||
area_by_legend: dict[str, float] = defaultdict(float)
|
||||
area_by_texture: dict[str, float] = defaultdict(float)
|
||||
area_by_drainage: dict[str, float] = defaultdict(float)
|
||||
for partition in partitions:
|
||||
payload = json.loads(Path(str(partition["output_path"])).read_text(encoding="utf-8"))
|
||||
partition_features = payload.get("features") if isinstance(payload, dict) else None
|
||||
if not isinstance(partition_features, list) or len(partition_features) != int(partition["feature_count"]):
|
||||
raise RuntimeError(f"Soil partition feature-count drift for NIS {partition['nis_code']}")
|
||||
for feature in partition_features:
|
||||
feature_id = str(feature.get("id") or "")
|
||||
if not feature_id or feature_id in feature_ids:
|
||||
raise RuntimeError(f"Duplicate or missing regional soil feature id {feature_id!r}")
|
||||
feature_ids.add(feature_id)
|
||||
properties = feature.get("properties") or {}
|
||||
area = float(properties.get("clipped_area_ha") or 0.0)
|
||||
area_by_legend[str(properties.get("soil_generalized_legend") or "Onbekend")] += area
|
||||
area_by_texture[str(properties.get("soil_texture_class") or "Onbekend")] += area
|
||||
area_by_drainage[str(properties.get("soil_drainage_class") or "Onbekend")] += area
|
||||
features.append(feature)
|
||||
if len(features) > max_total_features:
|
||||
raise RuntimeError(
|
||||
f"Regional soil snapshot exceeds the {max_total_features} feature safety limit; refusing truncation"
|
||||
)
|
||||
|
||||
generated_at = soil.utc_now()
|
||||
artifact = {
|
||||
"type": "FeatureCollection",
|
||||
"name": f"Digitale bodemkaart - {scope.display_name}",
|
||||
"features": features,
|
||||
"source": soil.ATTRIBUTION,
|
||||
"source_version": soil.SOURCE_VERSION,
|
||||
"survey_period": soil.SURVEY_PERIOD,
|
||||
"catalog_url": soil.CATALOG_URL,
|
||||
"coverage_scope": scope.key,
|
||||
"member_count": len(scope.members),
|
||||
"reference_truncated": False,
|
||||
"generated_at": generated_at,
|
||||
}
|
||||
soil.write_json_atomic(output_path, artifact)
|
||||
manifest = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"source_version": soil.SOURCE_VERSION,
|
||||
"survey_period": soil.SURVEY_PERIOD,
|
||||
"scope_key": scope.key,
|
||||
"scope_display_name": scope.display_name,
|
||||
"member_count": len(scope.members),
|
||||
"member_nis_codes": list(scope.nis_codes),
|
||||
"member_boundaries_sha256": member_boundaries_sha256,
|
||||
"coverage_complete": len(partitions) == len(scope.members),
|
||||
"feature_count": len(features),
|
||||
"raw_source_feature_count": sum(int(item["raw_source_feature_count"]) for item in partitions),
|
||||
"raw_response_count": sum(int(item["page_count"]) for item in partitions),
|
||||
"partition_identity_sha256": partition_identity,
|
||||
"partitions": [
|
||||
{
|
||||
"municipality": item["municipality"],
|
||||
"nis_code": item["nis_code"],
|
||||
"feature_count": item["feature_count"],
|
||||
"raw_source_feature_count": item["raw_source_feature_count"],
|
||||
"output_sha256": item["output_sha256"],
|
||||
"manifest_path": item["manifest_path"],
|
||||
}
|
||||
for item in partitions
|
||||
],
|
||||
"area_by_generalized_legend_ha": {key: round(value, 6) for key, value in sorted(area_by_legend.items())},
|
||||
"area_by_texture_ha": {key: round(value, 6) for key, value in sorted(area_by_texture.items())},
|
||||
"area_by_drainage_ha": {key: round(value, 6) for key, value in sorted(area_by_drainage.items())},
|
||||
"reference_truncated": False,
|
||||
"output_sha256": soil.sha256_file(output_path),
|
||||
"output_size_bytes": output_path.stat().st_size,
|
||||
"generated_at": generated_at,
|
||||
"limitations": [
|
||||
"The map is based on field data collected between 1949 and 1971.",
|
||||
"Current drainage, land use and local soil disturbance may differ from the mapped class.",
|
||||
"The 1:20,000 source is contextual evidence and not a parcel-scale soil investigation.",
|
||||
"Municipality partitions split source polygons at administrative boundaries; source ids carry a NIS suffix.",
|
||||
],
|
||||
}
|
||||
soil.write_json_atomic(manifest_path, manifest, pretty=True)
|
||||
return output_path, manifest_path, manifest
|
||||
|
||||
|
||||
def locate_workspace(session, base_url: str, scope: GeographicScope, timeout: int):
|
||||
projects = soil.paginated_items(session, f"{base_url}/api/v1/projects", timeout=timeout)
|
||||
project = next((item for item in projects if item.get("name") == scope.project_name), None)
|
||||
if not project:
|
||||
raise RuntimeError(f"Project {scope.project_name!r} is missing")
|
||||
project_id = str(project["id"])
|
||||
areas = soil.paginated_items(session, f"{base_url}/api/v1/projects/{project_id}/areas", timeout=timeout)
|
||||
area = next((item for item in areas if item.get("name") == scope.area_name), None)
|
||||
if not area:
|
||||
raise RuntimeError(f"Official scope Area {scope.area_name!r} is missing")
|
||||
datasets = soil.paginated_items(session, f"{base_url}/api/v1/projects/{project_id}/datasets", timeout=timeout)
|
||||
return project_id, str(area["id"]), datasets
|
||||
|
||||
|
||||
def upload_snapshot(
|
||||
session,
|
||||
*,
|
||||
base_url: str,
|
||||
project_id: str,
|
||||
area_id: str,
|
||||
scope: GeographicScope,
|
||||
path: Path,
|
||||
manifest_path: Path,
|
||||
manifest: dict[str, Any],
|
||||
timeout: int,
|
||||
) -> dict[str, Any]:
|
||||
limitation = (
|
||||
"Historische bodemkartering op schaal 1:20.000 op basis van veldwerk 1949-1971; "
|
||||
"de huidige drainage en lokale bodemtoestand kunnen afwijken."
|
||||
)
|
||||
source_metadata = {
|
||||
"provider": soil.SOURCE_NAME,
|
||||
"theme": "soil",
|
||||
"layer_type": "soil",
|
||||
"source_collection": soil.TYPE_NAME,
|
||||
"source_crs": "EPSG:31370",
|
||||
"persisted_crs": "EPSG:4326",
|
||||
"authority_level": "authoritative_historical_baseline",
|
||||
"coverage_scope": scope.key,
|
||||
"scope_type": scope.scope_type,
|
||||
"scope_display_name": scope.display_name,
|
||||
"member_count": len(scope.members),
|
||||
"member_nis_codes": list(scope.nis_codes),
|
||||
"partitioned_source_audit": True,
|
||||
"coverage_complete": bool(manifest["coverage_complete"]),
|
||||
"feature_count": manifest["feature_count"],
|
||||
"geometry_clipped_to_area": True,
|
||||
"semantic_metrics": False,
|
||||
"survey_period": soil.SURVEY_PERIOD,
|
||||
"source_scale": "1:20,000",
|
||||
"attribution": soil.ATTRIBUTION,
|
||||
"catalog_url": soil.CATALOG_URL,
|
||||
"license_note": "DOV standard attribution and public GDI reuse conditions apply.",
|
||||
"limitation_message": limitation,
|
||||
"selection_aggregation": {
|
||||
"metric_key": "soil_mapped_area",
|
||||
"method": "intersection_area",
|
||||
"label": "Bodemkaartoppervlakte",
|
||||
"unit": "ha",
|
||||
"geometry_dimension": 2,
|
||||
"warning": limitation,
|
||||
},
|
||||
"selection_metrics": soil.selection_metrics(),
|
||||
}
|
||||
provenance_metadata = {
|
||||
"operator_tool": "provision_regional_soil_map.py",
|
||||
"operator_explicit_fetch": True,
|
||||
"geometry_clipped_to_area": True,
|
||||
"source_type_name": soil.TYPE_NAME,
|
||||
"wfs_url": soil.WFS_URL,
|
||||
"catalog_url": soil.CATALOG_URL,
|
||||
"manifest_path": str(manifest_path),
|
||||
"combined_output_sha256": manifest["output_sha256"],
|
||||
"partition_count": len(manifest["partitions"]),
|
||||
"partition_identity_sha256": manifest["partition_identity_sha256"],
|
||||
"raw_source_responses_retained": True,
|
||||
"reference_truncated": False,
|
||||
"generated_at": manifest["generated_at"],
|
||||
"limitations": manifest["limitations"],
|
||||
}
|
||||
with path.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": soil.SOURCE_NAME,
|
||||
"reference_layer_name": "soil",
|
||||
"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": f"dov:digital-soil-map:{scope.key}",
|
||||
"observed_at": soil.OBSERVED_AT,
|
||||
"valid_from": soil.VALID_FROM,
|
||||
"valid_to": soil.VALID_TO,
|
||||
"temporal_granularity": "period",
|
||||
"source_version": soil.SOURCE_VERSION,
|
||||
},
|
||||
files={"file": (path.name, handle, "application/geo+json")},
|
||||
timeout=timeout,
|
||||
)
|
||||
return soil.response_data(response)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
if (
|
||||
args.page_limit < 1
|
||||
or args.page_limit > 5000
|
||||
or args.max_features_per_partition < args.page_limit
|
||||
or args.max_total_features < args.max_features_per_partition
|
||||
):
|
||||
raise ValueError("Invalid soil-map page or feature safety limits")
|
||||
scope = GEOGRAPHIC_SCOPES[args.scope]
|
||||
members_path, members_sha256 = resolve_member_boundaries(scope, args.scope_output_root)
|
||||
boundaries = load_member_boundaries(members_path, scope)
|
||||
with soil.source_session() as source_session:
|
||||
source_session.headers.update({"User-Agent": "GeoIntel-DOV-Soil-Regional-Operator/1.0"})
|
||||
partitions = [
|
||||
prepare_partition(
|
||||
source_session,
|
||||
output_root=args.output_root / scope.key,
|
||||
scope=scope,
|
||||
member=member,
|
||||
boundary_wgs84=boundary,
|
||||
page_limit=args.page_limit,
|
||||
max_features=args.max_features_per_partition,
|
||||
timeout=args.request_timeout,
|
||||
force=args.force,
|
||||
)
|
||||
for member, boundary in boundaries.values()
|
||||
]
|
||||
path, manifest_path, manifest = assemble_snapshot(
|
||||
output_root=args.output_root,
|
||||
scope=scope,
|
||||
partitions=partitions,
|
||||
member_boundaries_sha256=members_sha256,
|
||||
max_total_features=args.max_total_features,
|
||||
)
|
||||
if args.fetch_only:
|
||||
result: dict[str, Any] = {
|
||||
"status": "prepared",
|
||||
"artifact_path": str(path),
|
||||
"feature_count": manifest["feature_count"],
|
||||
}
|
||||
else:
|
||||
with requests.Session() as api_session:
|
||||
project_id, area_id, datasets = locate_workspace(
|
||||
api_session,
|
||||
args.base_url.rstrip("/"),
|
||||
scope,
|
||||
args.import_timeout,
|
||||
)
|
||||
existing = next(
|
||||
(
|
||||
item
|
||||
for item in datasets
|
||||
if item.get("source_name") == soil.SOURCE_NAME
|
||||
and item.get("source_version") == soil.SOURCE_VERSION
|
||||
and str(item.get("area_id") or "") == area_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing:
|
||||
if str(existing.get("checksum_sha256") or "") != manifest["output_sha256"]:
|
||||
raise RuntimeError("A different regional DOV soil artifact already exists; refusing replacement")
|
||||
result = {
|
||||
"status": "existing",
|
||||
"dataset_id": existing["id"],
|
||||
"feature_count": existing.get("feature_count"),
|
||||
}
|
||||
else:
|
||||
dataset = upload_snapshot(
|
||||
api_session,
|
||||
base_url=args.base_url.rstrip("/"),
|
||||
project_id=project_id,
|
||||
area_id=area_id,
|
||||
scope=scope,
|
||||
path=path,
|
||||
manifest_path=manifest_path,
|
||||
manifest=manifest,
|
||||
timeout=args.import_timeout,
|
||||
)
|
||||
result = {
|
||||
"status": "imported",
|
||||
"dataset_id": dataset["id"],
|
||||
"feature_count": dataset.get("feature_count"),
|
||||
}
|
||||
except (KeyError, OSError, RuntimeError, ValueError, 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,
|
||||
"member_count": len(scope.members),
|
||||
"partition_count": len(partitions),
|
||||
"feature_count": manifest["feature_count"],
|
||||
"raw_source_feature_count": manifest["raw_source_feature_count"],
|
||||
"raw_response_count": manifest["raw_response_count"],
|
||||
"manifest_path": str(manifest_path),
|
||||
"metrics": {
|
||||
"area_by_generalized_legend_ha": manifest["area_by_generalized_legend_ha"],
|
||||
"area_by_texture_ha": manifest["area_by_texture_ha"],
|
||||
"area_by_drainage_ha": manifest["area_by_drainage_ha"],
|
||||
},
|
||||
"result": result,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user