Add regional historical land-use operator
This commit is contained in:
+17
-2
@@ -1046,7 +1046,7 @@ storage. They never run on app startup.
|
||||
Historical land-use work can be bounded explicitly:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/provision_mol_historical_landuse.py --years 1778 1969 --themes forest water
|
||||
docker exec geointel python /app/scripts/provision_mol_historical_landuse.py --years 1778,1969 --themes forest,water
|
||||
```
|
||||
|
||||
`GET /api/v1/projects/{project_id}/temporal/series` discovers the series and
|
||||
@@ -1066,7 +1066,8 @@ docker exec geointel python /app/scripts/provision_regional_timeseries.py
|
||||
|
||||
This resolves the retained official boundary and imports five Statbel
|
||||
population snapshots plus five modern forest, water, built-function and
|
||||
transport-infrastructure snapshots into
|
||||
transport-infrastructure snapshots, followed by the 1778/1873/1969 historical
|
||||
building, water and road snapshots, into
|
||||
`Kempen Regional Workbench`. Mol and regional series keys remain separate and
|
||||
existing immutable datasets are reused. Complete statistical sectors use exact
|
||||
published totals; a rectangle cutting a sector remains an area-weighted
|
||||
@@ -1076,6 +1077,20 @@ The regional forest path partitions WCS requests by official municipality to
|
||||
stay within upstream response limits, then builds one retained 10 m mosaic and
|
||||
one normal regional vector Dataset. A failed source request leaves completed
|
||||
partition artifacts reusable and never lowers source resolution silently.
|
||||
Historical WFS retrieval is likewise partitioned by all 28 municipality
|
||||
boundaries because broad WFS counts stop at 10,000. Exact source responses are
|
||||
retained as checksummed gzip artifacts before clipping and regional assembly.
|
||||
Run that stage independently when needed:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/provision_regional_historical_landuse.py
|
||||
```
|
||||
|
||||
Use `--fetch-only` for source/artifact validation without persistence. The
|
||||
historical building class represents mapped built land-use surfaces, not
|
||||
individual building footprints; water remains surface area, not depth or
|
||||
volume; historical roads are mapped road surfaces, not present-day centerline
|
||||
length.
|
||||
|
||||
Official operator datasets record that their geometries were clipped to the
|
||||
persisted Area. When that exact Area is selected, vector totals and aggregate
|
||||
|
||||
@@ -90,11 +90,12 @@ def test_population_operator_resolves_the_persisted_scope_boundary(tmp_path: Pat
|
||||
assert module.resolve_boundary_path(args, scope) == boundary
|
||||
|
||||
|
||||
def test_regional_coordinator_builds_explicit_population_and_forest_commands(tmp_path: Path) -> None:
|
||||
def test_regional_coordinator_builds_explicit_population_and_landuse_commands(tmp_path: Path) -> None:
|
||||
module = load_script("provision_regional_timeseries.py")
|
||||
scope = module.GEOGRAPHIC_SCOPES["kempen-transport-region"]
|
||||
args = argparse.Namespace(
|
||||
output_root=tmp_path / "time-series",
|
||||
scope_output_root=tmp_path / "scopes",
|
||||
fetch_only=False,
|
||||
force=False,
|
||||
skip_population=False,
|
||||
@@ -102,15 +103,19 @@ def test_regional_coordinator_builds_explicit_population_and_forest_commands(tmp
|
||||
base_url="http://backend:8000",
|
||||
population_years="2021,2025",
|
||||
landuse_years="2013,2025",
|
||||
historical_years="1778,1873,1969",
|
||||
historical_themes="buildings,water,roads",
|
||||
request_timeout=300,
|
||||
import_timeout=3600,
|
||||
max_landuse_features=500000,
|
||||
max_historical_features=500000,
|
||||
skip_historical=False,
|
||||
)
|
||||
|
||||
members_path = tmp_path / "municipalities.geojson"
|
||||
commands = dict(module.build_operator_commands(args, scope, tmp_path / "boundary.geojson", members_path))
|
||||
|
||||
assert set(commands) == {"population", "forest"}
|
||||
assert set(commands) == {"population", "forest", "historical_landuse"}
|
||||
assert commands["population"][0] == sys.executable
|
||||
assert "--scope" in commands["population"]
|
||||
assert "kempen-transport-region" in commands["population"]
|
||||
@@ -122,6 +127,10 @@ def test_regional_coordinator_builds_explicit_population_and_forest_commands(tmp
|
||||
assert ",".join(scope.nis_codes) in commands["forest"]
|
||||
assert "--force" not in commands["population"]
|
||||
assert "--fetch-only" not in commands["forest"]
|
||||
assert commands["historical_landuse"][0] == sys.executable
|
||||
assert "provision_regional_historical_landuse.py" in commands["historical_landuse"][1]
|
||||
assert "buildings,water,roads" in commands["historical_landuse"]
|
||||
assert "--scope-output-root" in commands["historical_landuse"]
|
||||
|
||||
|
||||
def test_regional_forest_provenance_does_not_claim_one_municipality() -> None:
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from shapely.geometry import box, mapping, shape
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
|
||||
def load_script():
|
||||
path = SCRIPTS / "provision_regional_historical_landuse.py"
|
||||
spec = importlib.util.spec_from_file_location("test_provision_regional_historical_landuse", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
ok = True
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
self.content = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
self.calls.append((url, kwargs))
|
||||
if not self.responses:
|
||||
raise AssertionError("Unexpected source/API request")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def source_feature(feature_id: str, geometry, landuse_class: str = "bebouwing"):
|
||||
return {
|
||||
"type": "Feature",
|
||||
"id": feature_id,
|
||||
"geometry": mapping(geometry),
|
||||
"properties": {"KLASSE": landuse_class},
|
||||
}
|
||||
|
||||
|
||||
def test_member_boundaries_require_every_approved_municipality(tmp_path: Path) -> None:
|
||||
module = load_script()
|
||||
scope = module.GEOGRAPHIC_SCOPES["kempen-transport-region"]
|
||||
features = []
|
||||
for index, member in enumerate(scope.members):
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": mapping(box(index, 0, index + 0.9, 0.9)),
|
||||
"properties": {"nis_code": member.nis_code, "municipality": member.name},
|
||||
}
|
||||
)
|
||||
path = tmp_path / "members.geojson"
|
||||
path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), encoding="utf-8")
|
||||
|
||||
boundaries = module.load_member_boundaries(path, scope)
|
||||
|
||||
assert list(boundaries) == list(scope.nis_codes)
|
||||
assert len(boundaries) == 28
|
||||
assert boundaries["13025"][0].name == "Mol"
|
||||
|
||||
|
||||
def test_partition_retains_exact_source_response_and_clips_to_municipality(tmp_path: Path) -> None:
|
||||
module = load_script()
|
||||
definition = next(item for item in module.THEMES if item.key == "buildings")
|
||||
member = module.ScopeMember("Mol", "13025")
|
||||
boundary = box(5.0, 51.0, 5.1, 51.1)
|
||||
payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [source_feature("Lgbrk1778.1", box(4.95, 51.02, 5.05, 51.08))],
|
||||
}
|
||||
session = FakeSession([FakeResponse(payload)])
|
||||
|
||||
manifest = module.prepare_partition(
|
||||
session,
|
||||
output_root=tmp_path,
|
||||
year=1778,
|
||||
definition=definition,
|
||||
scope_key="kempen-transport-region",
|
||||
member=member,
|
||||
boundary=boundary,
|
||||
page_size=50,
|
||||
max_features=100,
|
||||
simplify_tolerance_degrees=0.0,
|
||||
timeout=30,
|
||||
force=False,
|
||||
)
|
||||
|
||||
output = json.loads(Path(manifest["output_path"]).read_text(encoding="utf-8"))
|
||||
feature = output["features"][0]
|
||||
raw_path = Path(manifest["output_path"]).parent / manifest["raw_pages"][0]["artifact_path"]
|
||||
assert manifest["feature_count"] == 1
|
||||
assert manifest["source_feature_count"] == 1
|
||||
assert feature["id"] == "Lgbrk1778.1:13025"
|
||||
assert feature["properties"]["original_source_feature_id"] == "Lgbrk1778.1"
|
||||
assert feature["properties"]["coverage_scope"] == "kempen-transport-region"
|
||||
assert shape(feature["geometry"]).bounds == (5.0, 51.02, 5.05, 51.08)
|
||||
assert gzip.decompress(raw_path.read_bytes()) == FakeResponse(payload).content
|
||||
|
||||
cached = module.prepare_partition(
|
||||
FakeSession([]),
|
||||
output_root=tmp_path,
|
||||
year=1778,
|
||||
definition=definition,
|
||||
scope_key="kempen-transport-region",
|
||||
member=member,
|
||||
boundary=boundary,
|
||||
page_size=50,
|
||||
max_features=100,
|
||||
simplify_tolerance_degrees=0.0,
|
||||
timeout=30,
|
||||
force=False,
|
||||
)
|
||||
assert cached["output_sha256"] == manifest["output_sha256"]
|
||||
|
||||
|
||||
def test_regional_snapshot_assembles_unique_partition_features(tmp_path: Path) -> None:
|
||||
module = load_script()
|
||||
scope = module.GeographicScope(
|
||||
key="test-region",
|
||||
display_name="Test region",
|
||||
project_name="Test",
|
||||
project_region="Test",
|
||||
area_name="Test area",
|
||||
authority_name="Authority",
|
||||
authority_url="https://example.test",
|
||||
scope_type="test",
|
||||
limitation_message="Test only",
|
||||
members=(module.ScopeMember("Left", "10001"), module.ScopeMember("Right", "10002")),
|
||||
)
|
||||
definition = next(item for item in module.THEMES if item.key == "water")
|
||||
partitions = []
|
||||
for index, member in enumerate(scope.members):
|
||||
path, _manifest_path, _raw_dir = module.partition_paths(tmp_path, 1873, definition.key, member.nis_code)
|
||||
feature = source_feature(f"water.{index}:{member.nis_code}", box(index, 0, index + 0.5, 0.5), "water")
|
||||
module.atomic_write_json(path, {"type": "FeatureCollection", "features": [feature]})
|
||||
partitions.append(
|
||||
{
|
||||
"municipality": member.name,
|
||||
"nis_code": member.nis_code,
|
||||
"source_feature_count": 1,
|
||||
"feature_count": 1,
|
||||
"raw_pages": [{"artifact_path": "unused"}],
|
||||
"output_path": str(path),
|
||||
"output_sha256": module.sha256_file(path),
|
||||
}
|
||||
)
|
||||
|
||||
output_path, manifest = module.assemble_snapshot(
|
||||
output_root=tmp_path,
|
||||
scope=scope,
|
||||
year=1873,
|
||||
definition=definition,
|
||||
partitions=partitions,
|
||||
max_total_features=10,
|
||||
)
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert manifest["coverage_complete"] is True
|
||||
assert manifest["feature_count"] == 2
|
||||
assert manifest["empty_partitions"] == []
|
||||
assert len({feature["id"] for feature in payload["features"]}) == 2
|
||||
|
||||
|
||||
def test_upload_contract_is_regional_temporal_and_partition_audited(tmp_path: Path) -> None:
|
||||
module = load_script()
|
||||
scope = module.GEOGRAPHIC_SCOPES["kempen-transport-region"]
|
||||
definition = next(item for item in module.THEMES if item.key == "roads")
|
||||
path = tmp_path / "roads.geojson"
|
||||
path.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
|
||||
response_payload = {"data": {"id": "dataset-id", "feature_count": 42}}
|
||||
session = FakeSession([FakeResponse(response_payload)])
|
||||
manifest = {
|
||||
"coverage_complete": True,
|
||||
"empty_partitions": [],
|
||||
"partitions": [{} for _ in scope.members],
|
||||
"partition_identity_sha256": "partition-hash",
|
||||
"output_sha256": "output-hash",
|
||||
"generated_at": "2026-07-15T00:00:00+00:00",
|
||||
}
|
||||
|
||||
result = module.upload_snapshot(
|
||||
session,
|
||||
base_url="http://backend:8000",
|
||||
project_id="project-id",
|
||||
area_id="area-id",
|
||||
scope=scope,
|
||||
year=1969,
|
||||
definition=definition,
|
||||
path=path,
|
||||
manifest=manifest,
|
||||
simplify_tolerance_degrees=0.00001,
|
||||
timeout=30,
|
||||
)
|
||||
data = session.calls[0][1]["data"]
|
||||
source_metadata = json.loads(data["source_metadata_json"])
|
||||
provenance = json.loads(data["provenance_metadata_json"])
|
||||
|
||||
assert result["id"] == "dataset-id"
|
||||
assert data["area_id"] == "area-id"
|
||||
assert data["temporal_series_key"].endswith(":roads:kempen-transport-region")
|
||||
assert data["observed_at"] == "1969-01-01T00:00:00Z"
|
||||
assert source_metadata["member_count"] == 28
|
||||
assert source_metadata["partitioned_source_audit"] is True
|
||||
assert source_metadata["identity_stable"] is False
|
||||
assert provenance["partition_count"] == 28
|
||||
assert provenance["raw_source_responses_retained"] is True
|
||||
|
||||
|
||||
def test_regional_historical_operator_is_packaged_and_release_checked() -> None:
|
||||
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "COPY scripts/provision_regional_historical_landuse.py" in dockerfile
|
||||
assert "py_compile scripts/provision_regional_historical_landuse.py" in readiness
|
||||
Reference in New Issue
Block a user