feat: synchronize regional official time series
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 22:19:30 +02:00
parent cfd13307e4
commit a5b4bbdf8f
14 changed files with 609 additions and 51 deletions
+9
View File
@@ -7,6 +7,15 @@
# Changelog # Changelog
## Sprint 194 Regional official time-series synchronization (2026-07-14)
- Generalized the proven Statbel population operator from a hardcoded Mol import to an approved geographic scope while keeping Mol as the backwards-compatible default.
- Added one explicit regional synchronization command for official 2021-2025 population and 2013-2025 modern forest snapshots.
- Kept every fetch operator-triggered, idempotent and behind the canonical DatasetService upload path; no startup fetch, migration or API contract change was introduced.
- Preserved separate Mol/regional series keys and honest partial-sector population and 10 m forest-area limitations.
- Replaced internal provider identifiers with readable source labels in the primary map.
- Added focused scope filtering, command construction, boundary resolution, multi-NIS provenance, packaging and frontend-label tests.
## Sprint 193 End-user regional workbench simplification (2026-07-14) ## Sprint 193 End-user regional workbench simplification (2026-07-14)
- Made the complete `Kempen Regional Workbench` the automatic fresh-session data context and removed the redundant region selector from the primary map. - Made the complete `Kempen Regional Workbench` the automatic fresh-session data context and removed the redundant region selector from the primary map.
+15
View File
@@ -1024,6 +1024,21 @@ Modern raster-derived forest polygons have the same identity limitation. Their
area is measured in EPSG:31370 and is exact within the 10 m source area is measured in EPSG:31370 and is exact within the 10 m source
representation, not a cadastral forest survey. representation, not a cadastral forest survey.
The same source-governed operators can synchronize the approved regional
scope in one explicit pass:
```bash
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 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
estimate. Forest area is measured within the official 10 m representation.
Use `--fetch-only` to validate source artifacts without database mutation.
## Helpful repository scripts ## Helpful repository scripts
- `bash scripts/backend_install.sh` - `bash scripts/backend_install.sh`
@@ -0,0 +1,146 @@
from __future__ import annotations
import argparse
import importlib.util
import io
import json
from pathlib import Path
import sys
import zipfile
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(name: str):
path = SCRIPTS / name
module_name = f"test_{path.stem}"
spec = importlib.util.spec_from_file_location(module_name, path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def population_archive() -> bytes:
text = "\n".join(
(
"CD_REFNIS|CD_SECTOR|TOTAL|TX_DESCR_SECTOR_NL|TX_DESCR_NL",
"13025|13025A00-|120|Mol centrum|Mol",
"13008|13008A00-|240|Geel centrum|Geel",
"11002|11002A00-|360|Antwerpen centrum|Antwerpen",
)
)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("population.csv", text)
return buffer.getvalue()
def test_population_operator_filters_to_the_approved_scope() -> None:
module = load_script("provision_mol_population_history.py")
regional = module.GEOGRAPHIC_SCOPES["kempen-transport-region"]
mol = module.GEOGRAPHIC_SCOPES["mol"]
regional_rows = module.population_rows(population_archive(), regional)
mol_rows = module.population_rows(population_archive(), mol)
assert set(regional_rows) == {"13025A00-", "13008A00-"}
assert regional_rows["13008A00-"]["municipality"] == "Geel"
assert regional_rows["13008A00-"]["nis_code"] == "13008"
assert set(mol_rows) == {"13025A00-"}
assert module.series_key(regional) == "statbel:population-statistical-sector:kempen-transport-region"
assert module.series_key(mol) == "statbel:population-statistical-sector:mol"
def test_population_operator_resolves_the_persisted_scope_boundary(tmp_path: Path) -> None:
module = load_script("provision_mol_population_history.py")
scope = module.GEOGRAPHIC_SCOPES["kempen-transport-region"]
scope_dir = tmp_path / scope.key
scope_dir.mkdir(parents=True)
boundary = scope_dir / "boundary.geojson"
boundary.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
manifest = scope_dir / "kempen_transport_region_scope_manifest.json"
manifest.write_text(
json.dumps(
{
"status": "complete",
"scope_key": scope.key,
"boundary_filename": boundary.name,
}
),
encoding="utf-8",
)
args = argparse.Namespace(boundary_path=None, scope_output_root=tmp_path)
assert module.resolve_boundary_path(args, scope) == boundary
def test_regional_coordinator_builds_explicit_population_and_forest_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",
fetch_only=False,
force=False,
skip_population=False,
skip_landuse=False,
base_url="http://backend:8000",
population_years="2021,2025",
landuse_years="2013,2025",
request_timeout=300,
import_timeout=3600,
max_landuse_features=500000,
)
commands = dict(module.build_operator_commands(args, scope, tmp_path / "boundary.geojson"))
assert set(commands) == {"population", "forest"}
assert commands["population"][0] == sys.executable
assert "--scope" in commands["population"]
assert "kempen-transport-region" in commands["population"]
assert scope.project_name in commands["population"]
assert commands["forest"][0] == sys.executable
assert "--max-features" in commands["forest"]
assert ",".join(scope.nis_codes) in commands["forest"]
assert "--force" not in commands["population"]
assert "--fetch-only" not in commands["forest"]
def test_regional_forest_provenance_does_not_claim_one_municipality() -> None:
module = load_script("provision_official_landuse_timeseries.py")
regional = module.scope_identity("Kempen (28 gemeenten)", "13001,13008,13025")
municipal = module.scope_identity("Mol", "13025")
assert regional == {
"scope_display_name": "Kempen (28 gemeenten)",
"member_nis_codes": ["13001", "13008", "13025"],
"municipality": None,
"nis_code": None,
}
assert municipal["municipality"] == "Mol"
assert municipal["nis_code"] == "13025"
def test_regional_timeseries_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_timeseries.py" in dockerfile
assert "py_compile scripts/provision_regional_timeseries.py" in readiness
def test_end_user_dataset_sources_are_human_readable() -> None:
display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
assert "statbel: 'Statbel'" in display
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
assert "dataset ? getDatasetSourceDisplayName(dataset)" in workspace
+1
View File
@@ -77,6 +77,7 @@ COPY scripts/provision_mol_context_layers.py /app/scripts/provision_mol_context_
COPY scripts/provision_mol_population_history.py /app/scripts/provision_mol_population_history.py COPY scripts/provision_mol_population_history.py /app/scripts/provision_mol_population_history.py
COPY scripts/provision_mol_historical_landuse.py /app/scripts/provision_mol_historical_landuse.py COPY scripts/provision_mol_historical_landuse.py /app/scripts/provision_mol_historical_landuse.py
COPY scripts/provision_official_landuse_timeseries.py /app/scripts/provision_official_landuse_timeseries.py COPY scripts/provision_official_landuse_timeseries.py /app/scripts/provision_official_landuse_timeseries.py
COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_timeseries.py
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
+18
View File
@@ -7960,6 +7960,24 @@ Live operational proof:
Next: Next:
- Build bounded, idempotent regional theme ingestion in municipality-sized partitions, starting with current buildings and retaining per-member provenance before exposing any Kempen-wide metric in the explorer. - Build bounded, idempotent regional theme ingestion in municipality-sized partitions, starting with current buildings and retaining per-member provenance before exposing any Kempen-wide metric in the explorer.
## Sprint 194 - Regional official time-series operator foundation (2026-07-14)
Implemented:
- Made the existing Statbel population operator scope-aware while retaining Mol defaults and artifact compatibility.
- Added approved-scope NIS filtering, per-feature municipality provenance, checksummed scope-boundary resolution and distinct regional temporal-series keys.
- Added `provision_regional_timeseries.py` as one explicit coordinator for population and modern forest imports into `Kempen Regional Workbench`.
- Reused the current Dataset upload API and existing forest polygonization path; no migration, public endpoint, direct PostGIS write, startup fetch or fabricated value was added.
- Corrected regional forest provenance so a 28-member coverage does not claim to be one municipality.
- Added friendly Statbel/GRB/VRBG/Departement Omgeving labels to the primary map.
Validation before deployment:
- Source contracts were rechecked against the official Statbel sector-population page and the official 2025 Landgebruik Vlaanderen catalogue.
- The focused regional/temporal suite passed 19 tests.
- Frontend TypeScript typecheck and production build passed.
Next:
- Deploy the packaged operator, execute the live regional synchronization, verify persisted counts and temporal comparisons, then mark the regional time-series TODO complete only if PostGIS and browser evidence agree.
## Sprint 190 Regional Kempen GRB buildings (2026-07-14) ## Sprint 190 Regional Kempen GRB buildings (2026-07-14)
Implemented: Implemented:
+16
View File
@@ -148,6 +148,22 @@ presented as one continuous measurement method. Raster-derived polygon
identities are unstable, so GeoIntel compares hectares and never invents identities are unstable, so GeoIntel compares hectares and never invents
added/removed forest objects. added/removed forest objects.
### Regional official time series
`scripts/provision_regional_timeseries.py` applies the same population and
modern forest operators to the approved 28-municipality
`kempen-transport-region` scope. It resolves the checksummed VRBG union
boundary and writes to `Kempen Regional Workbench` through the normal dataset
API. Regional keys are
`statbel:population-statistical-sector:kempen-transport-region` and
`department-omgeving:land-use:forest:kempen-transport-region`, so Mol datasets
remain independent observations rather than aliases.
The command is explicit and operator-triggered. No source fetch happens during
startup or map interaction. Partial statistical sectors remain area-weighted
population estimates; forest hectares remain measurements in the harmonized
10 m source representation and are not cadastral forest boundaries.
Official catalogues: Official catalogues:
- https://www.vlaanderen.be/datavindplaats/catalogus/landgebruik-vlaanderen-toestand-2013 - https://www.vlaanderen.be/datavindplaats/catalogus/landgebruik-vlaanderen-toestand-2013
+1 -1
View File
@@ -2,7 +2,7 @@
React + TypeScript + MapLibre workbench for regional geographic analysis. React + TypeScript + MapLibre workbench for regional geographic analysis.
The persisted `Kempen Regional Workbench` is the automatic operational data context. Its current GRB layers are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Mol remains the primary validation and historical-data focus, while explicit project selection stays available under advanced management. The persisted `Kempen Regional Workbench` is the automatic operational data context. Its datasets are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Regional population and modern forest snapshots use the same current/evolution flow as Mol, while explicit project selection stays available under advanced management.
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow. The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
+3 -3
View File
@@ -4,7 +4,7 @@ import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, Qa
import { featureCollectionBounds } from '../../lib/geojsonBounds' import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights' import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName } from '../../lib/datasetDisplay' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -998,7 +998,7 @@ export function MapWorkspace({
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}` ? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}`
: 'Minstens twee expliciet gedateerde snapshots zijn vereist.' : 'Minstens twee expliciet gedateerde snapshots zijn vereist.'
: activeThemeDataset : activeThemeDataset
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.dataset_role ?? 'source'} · ${formatObservationDate(activeThemeDataset.observed_at)}` ? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}`
: activeTheme.description} : activeTheme.description}
</small> </small>
</div> </div>
@@ -1247,7 +1247,7 @@ export function MapWorkspace({
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" /> <span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span> <span>
<strong>{theme.label}</strong> <strong>{theme.label}</strong>
<small>{dataset?.source_name ?? dataset?.source ?? 'Geen bron gekoppeld'}</small> <small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small>
</span> </span>
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b> <b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
</div> </div>
+13 -1
View File
@@ -11,6 +11,18 @@ const DATASET_LABEL_BY_LAYER: Record<string, string> = {
municipality_boundaries: 'Gemeentegrenzen Kempen', municipality_boundaries: 'Gemeentegrenzen Kempen',
} }
const DATASET_SOURCE_LABELS: Record<string, string> = {
department_omgeving_land_use: 'Departement Omgeving',
grb: 'GRB',
statbel: 'Statbel',
vrbg: 'Digitaal Vlaanderen',
}
export function getDatasetSourceDisplayName(dataset: DatasetCreateResponse): string {
const source = (dataset.source_name ?? dataset.source).toLowerCase()
return DATASET_SOURCE_LABELS[source] ?? dataset.source_name ?? dataset.source
}
export function getDatasetDisplayName(dataset: DatasetCreateResponse): string { export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '') const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '')
.toString() .toString()
@@ -19,6 +31,6 @@ export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
if (!label) { if (!label) {
return dataset.name return dataset.name
} }
const source = (dataset.source_name ?? dataset.source).toUpperCase() const source = getDatasetSourceDisplayName(dataset)
return `${label} · ${source}` return `${label} · ${source}`
} }
+17
View File
@@ -1371,6 +1371,23 @@ and distinct source IDs match the manifests exactly, all geometries are
non-empty and valid in EPSG:4326, and immediate repeat runs reuse the same non-empty and valid in EPSG:4326, and immediate repeat runs reuse the same
artifact and Dataset. artifact and Dataset.
## Regional official time series
Synchronize official population and modern forest snapshots after the
geographic scope workspace exists:
```bash
docker exec geointel python /app/scripts/provision_regional_timeseries.py
```
The explicit operator resolves the checksummed official scope boundary and
coordinates Statbel 2021-2025 with Departement Omgeving
2013/2016/2019/2022/2025. It is idempotent and persists only through the
canonical dataset upload API. It never runs at application startup. Prepare
artifacts without persistence using `--fetch-only`; bound a run with
`--skip-population`, `--skip-landuse`, `--population-years` or
`--landuse-years`.
## Tower deployment ## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime: Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
+153 -38
View File
@@ -1,9 +1,14 @@
"""Provision official annual Statbel population snapshots for Mol. """Provision official annual Statbel population snapshots for an approved scope.
The command joins annual population totals to the matching official The command joins annual population totals to the matching official
statistical-sector geometries, clips the result to Mol and imports each year statistical-sector geometries, clips the result to an approved geographic
scope and imports each year
through the existing GeoIntel upload API. It never runs during application through the existing GeoIntel upload API. It never runs during application
startup and it never synthesizes missing population values. startup and it never synthesizes missing population values.
Mol remains the backwards-compatible default. The canonical regional operator
uses ``--scope kempen-transport-region`` and the persisted official scope
boundary produced by ``provision_geographic_scope.py``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +32,8 @@ from shapely.ops import transform
from shapely.validation import make_valid from shapely.validation import make_valid
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from geographic_scopes import GEOGRAPHIC_SCOPES, GeographicScope
MUNICIPALITY_NAME = "Mol" MUNICIPALITY_NAME = "Mol"
MUNICIPALITY_NIS_CODE = "13025" MUNICIPALITY_NIS_CODE = "13025"
@@ -36,6 +43,9 @@ ATTRIBUTION = "Bron: Statbel, bevolking per statistische sector, CC BY 4.0"
DEFAULT_API_URL = "http://127.0.0.1:8000" DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data/mol-population-history") DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data/mol-population-history")
DEFAULT_BOUNDARY_PATH = Path("/app/storage/operator-data/mol-municipality/mol_municipality_boundary.geojson") DEFAULT_BOUNDARY_PATH = Path("/app/storage/operator-data/mol-municipality/mol_municipality_boundary.geojson")
DEFAULT_SCOPE_KEY = "mol"
DEFAULT_SCOPE_OUTPUT_ROOT = Path("/app/storage/operator-data/geographic-scopes")
DEFAULT_REGIONAL_OUTPUT_ROOT = Path("/app/storage/operator-data/official-population")
SECTOR_URL = ( SECTOR_URL = (
"https://statbel.fgov.be/sites/default/files/files/opendata/Statistische%20sectoren/" "https://statbel.fgov.be/sites/default/files/files/opendata/Statistische%20sectoren/"
"sh_statbel_statistical_sectors_31370_{year}0101.geojson.zip" "sh_statbel_statistical_sectors_31370_{year}0101.geojson.zip"
@@ -50,12 +60,19 @@ POPULATION_URLS = {
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Provision official annual Statbel population snapshots for Mol.") parser = argparse.ArgumentParser(description="Provision official annual Statbel population snapshots.")
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", DEFAULT_API_URL)) parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL))
parser.add_argument("--project-name", default=PROJECT_NAME) parser.add_argument("--project-name", default=None)
parser.add_argument("--area-name", default=None, help="Case-insensitive fragment identifying the persisted Area.")
parser.add_argument("--years", default="2021,2022,2023,2024,2025") parser.add_argument("--years", default="2021,2022,2023,2024,2025")
parser.add_argument("--output-dir", type=Path, default=Path(os.environ.get("MOL_POPULATION_OUTPUT_DIR", DEFAULT_OUTPUT_DIR))) parser.add_argument("--output-dir", type=Path, default=None)
parser.add_argument("--boundary-path", type=Path, default=Path(os.environ.get("MOL_BOUNDARY_PATH", DEFAULT_BOUNDARY_PATH))) parser.add_argument("--boundary-path", type=Path, default=None)
parser.add_argument(
"--scope-output-root",
type=Path,
default=Path(os.environ.get("GEOINTEL_SCOPE_OUTPUT_ROOT", DEFAULT_SCOPE_OUTPUT_ROOT)),
)
parser.add_argument("--request-timeout", type=int, default=180) parser.add_argument("--request-timeout", type=int, default=180)
parser.add_argument("--import-timeout", type=int, default=900) parser.add_argument("--import-timeout", type=int, default=900)
parser.add_argument("--force", action="store_true") parser.add_argument("--force", action="store_true")
@@ -75,7 +92,7 @@ def build_session() -> requests.Session:
raise_on_status=True, raise_on_status=True,
) )
session = requests.Session() session = requests.Session()
session.headers.update({"User-Agent": "GeoIntel-Mol-Population-Operator/1.0"}) session.headers.update({"User-Agent": "GeoIntel-Official-Population-Operator/1.0"})
adapter = HTTPAdapter(max_retries=retry) adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter) session.mount("https://", adapter)
session.mount("http://", adapter) session.mount("http://", adapter)
@@ -94,18 +111,56 @@ def response_data(response: requests.Response) -> Any:
return payload["data"] return payload["data"]
def load_boundary(path: Path): def series_key(scope: GeographicScope) -> str:
return f"statbel:population-statistical-sector:{scope.key}"
def resolve_output_dir(args: argparse.Namespace, scope: GeographicScope) -> Path:
if args.output_dir is not None:
return args.output_dir
legacy = os.environ.get("MOL_POPULATION_OUTPUT_DIR")
if scope.key == "mol" and legacy:
return Path(legacy)
if scope.key == "mol":
return DEFAULT_OUTPUT_DIR
return DEFAULT_REGIONAL_OUTPUT_ROOT / scope.key
def resolve_boundary_path(args: argparse.Namespace, scope: GeographicScope) -> Path:
if args.boundary_path is not None:
return args.boundary_path
legacy = os.environ.get("MOL_BOUNDARY_PATH")
if scope.key == "mol" and legacy:
return Path(legacy)
if scope.key == "mol" and DEFAULT_BOUNDARY_PATH.exists():
return DEFAULT_BOUNDARY_PATH
scope_dir = args.scope_output_root / scope.key
manifest_path = scope_dir / f"{scope.key.replace('-', '_')}_scope_manifest.json"
if not manifest_path.exists():
raise RuntimeError(
f"Official scope manifest is missing at {manifest_path}; run provision_geographic_scope.py --scope {scope.key} first"
)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest.get("scope_key") != scope.key or manifest.get("status") != "complete":
raise RuntimeError(f"Official scope manifest at {manifest_path} is incomplete or belongs to another scope")
boundary_path = scope_dir / str(manifest.get("boundary_filename") or "")
if not boundary_path.is_file():
raise RuntimeError(f"Official scope boundary referenced by {manifest_path} is missing")
return boundary_path
def load_boundary(path: Path, scope: GeographicScope):
if not path.exists(): if not path.exists():
raise RuntimeError(f"Mol boundary is missing at {path}; run provision_mol_municipality_workspace.py first") raise RuntimeError(f"Boundary for {scope.display_name} is missing at {path}")
payload = json.loads(path.read_text(encoding="utf-8")) payload = json.loads(path.read_text(encoding="utf-8"))
features = payload.get("features") or [] features = payload.get("features") or []
if len(features) != 1: if len(features) != 1:
raise RuntimeError("Mol boundary artifact must contain exactly one feature") raise RuntimeError(f"Boundary artifact for {scope.display_name} must contain exactly one feature")
boundary = shape(features[0]["geometry"]) boundary = shape(features[0]["geometry"])
if not boundary.is_valid: if not boundary.is_valid:
boundary = make_valid(boundary) boundary = make_valid(boundary)
if boundary.is_empty or not boundary.is_valid: if boundary.is_empty or not boundary.is_valid:
raise RuntimeError("Mol boundary artifact is invalid") raise RuntimeError(f"Boundary artifact for {scope.display_name} is invalid")
return boundary return boundary
@@ -117,7 +172,7 @@ def zip_member_json(content: bytes) -> dict[str, Any]:
return json.loads(archive.read(member).decode("utf-8")) return json.loads(archive.read(member).decode("utf-8"))
def population_rows(content: bytes) -> dict[str, dict[str, Any]]: def population_rows(content: bytes, scope: GeographicScope) -> dict[str, dict[str, Any]]:
with zipfile.ZipFile(io.BytesIO(content)) as archive: with zipfile.ZipFile(io.BytesIO(content)) as archive:
member = next((name for name in archive.namelist() if name.lower().endswith((".txt", ".csv"))), None) member = next((name for name in archive.namelist() if name.lower().endswith((".txt", ".csv"))), None)
if not member: if not member:
@@ -127,9 +182,11 @@ def population_rows(content: bytes) -> dict[str, dict[str, Any]]:
text = raw.decode("utf-8-sig") text = raw.decode("utf-8-sig")
except UnicodeDecodeError: except UnicodeDecodeError:
text = raw.decode("cp1252") text = raw.decode("cp1252")
members = {member.nis_code: member.name for member in scope.members}
rows: dict[str, dict[str, Any]] = {} rows: dict[str, dict[str, Any]] = {}
for row in csv.DictReader(io.StringIO(text), delimiter="|"): for row in csv.DictReader(io.StringIO(text), delimiter="|"):
if str(row.get("CD_REFNIS") or "").strip() != MUNICIPALITY_NIS_CODE: nis_code = str(row.get("CD_REFNIS") or "").strip()
if nis_code not in members:
continue continue
sector_code = str(row.get("CD_SECTOR") or "").strip() sector_code = str(row.get("CD_SECTOR") or "").strip()
total_raw = str(row.get("TOTAL") or "").strip() total_raw = str(row.get("TOTAL") or "").strip()
@@ -139,19 +196,28 @@ def population_rows(content: bytes) -> dict[str, dict[str, Any]]:
"population_total": int(total_raw), "population_total": int(total_raw),
"sector_name_nl": row.get("TX_DESCR_SECTOR_NL"), "sector_name_nl": row.get("TX_DESCR_SECTOR_NL"),
"municipality_name_nl": row.get("TX_DESCR_NL"), "municipality_name_nl": row.get("TX_DESCR_NL"),
"municipality": members[nis_code],
"nis_code": nis_code,
} }
if not rows: if not rows:
raise RuntimeError("Statbel population table contains no usable Mol sectors") raise RuntimeError(f"Statbel population table contains no usable sectors for {scope.display_name}")
return rows return rows
def build_snapshot(year: int, sector_payload: dict[str, Any], population: dict[str, dict[str, Any]], boundary) -> dict[str, Any]: def build_snapshot(
year: int,
sector_payload: dict[str, Any],
population: dict[str, dict[str, Any]],
boundary,
scope: GeographicScope,
) -> dict[str, Any]:
transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) transformer = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
member_codes = set(scope.nis_codes)
features: list[dict[str, Any]] = [] features: list[dict[str, Any]] = []
missing_population = 0 missing_population = 0
for source_feature in sector_payload.get("features") or []: for source_feature in sector_payload.get("features") or []:
properties = source_feature.get("properties") or {} properties = source_feature.get("properties") or {}
if str(properties.get("cd_munty_refnis") or "") != MUNICIPALITY_NIS_CODE: if str(properties.get("cd_munty_refnis") or "") not in member_codes:
continue continue
sector_code = str(properties.get("cd_sector") or "").strip() sector_code = str(properties.get("cd_sector") or "").strip()
population_values = population.get(sector_code) population_values = population.get(sector_code)
@@ -173,8 +239,8 @@ def build_snapshot(year: int, sector_payload: dict[str, Any], population: dict[s
"source_feature_id": sector_code, "source_feature_id": sector_code,
"reference_layer_name": "population", "reference_layer_name": "population",
"authority_level": "authoritative", "authority_level": "authoritative",
"municipality": MUNICIPALITY_NAME, "municipality": population_values["municipality"],
"nis_code": MUNICIPALITY_NIS_CODE, "nis_code": population_values["nis_code"],
"observation_year": year, "observation_year": year,
"attribution": ATTRIBUTION, "attribution": ATTRIBUTION,
} }
@@ -183,28 +249,37 @@ def build_snapshot(year: int, sector_payload: dict[str, Any], population: dict[s
raise RuntimeError(f"No joined population sectors were produced for {year}") raise RuntimeError(f"No joined population sectors were produced for {year}")
return { return {
"type": "FeatureCollection", "type": "FeatureCollection",
"name": f"Statbel population by statistical sector - Mol {year}", "name": f"Statbel population by statistical sector - {scope.display_name} {year}",
"features": features, "features": features,
"municipality": MUNICIPALITY_NAME, "coverage_scope": scope.key,
"nis_code": MUNICIPALITY_NIS_CODE, "scope_type": scope.scope_type,
"member_count": len(scope.members),
"member_nis_codes": list(scope.nis_codes),
"observation_year": year, "observation_year": year,
"missing_population_sector_count": missing_population, "missing_population_sector_count": missing_population,
"attribution": ATTRIBUTION, "attribution": ATTRIBUTION,
} }
def locate_workspace(session: requests.Session, base_url: str, project_name: str, timeout: int): def locate_workspace(
session: requests.Session,
base_url: str,
project_name: str,
area_name: str,
timeout: int,
):
projects = response_data(session.get(f"{base_url}/api/v1/projects", params={"limit": 200}, timeout=timeout)) projects = response_data(session.get(f"{base_url}/api/v1/projects", params={"limit": 200}, timeout=timeout))
project = next((item for item in projects.get("items") or [] if item.get("name") == project_name), None) project = next((item for item in projects.get("items") or [] if item.get("name") == project_name), None)
if not project: if not project:
raise RuntimeError(f"Project {project_name!r} is missing") raise RuntimeError(f"Project {project_name!r} is missing")
project_id = str(project["id"]) project_id = str(project["id"])
areas = response_data(session.get(f"{base_url}/api/v1/projects/{project_id}/areas", params={"limit": 200}, timeout=timeout)) areas = response_data(session.get(f"{base_url}/api/v1/projects/{project_id}/areas", params={"limit": 200}, timeout=timeout))
area = next((item for item in areas.get("items") or [] if "gemeente mol" in str(item.get("name", "")).lower()), None) area_fragment = area_name.strip().casefold()
if not area: matches = [item for item in areas.get("items") or [] if area_fragment in str(item.get("name") or "").casefold()]
raise RuntimeError("Official Mol area is missing") if len(matches) != 1:
raise RuntimeError(f"Expected one official Area matching {area_name!r}, received {len(matches)}")
datasets = response_data(session.get(f"{base_url}/api/v1/projects/{project_id}/datasets", params={"limit": 200}, timeout=timeout)) datasets = response_data(session.get(f"{base_url}/api/v1/projects/{project_id}/datasets", params={"limit": 200}, timeout=timeout))
return project_id, str(area["id"]), list(datasets.get("items") or []) return project_id, str(matches[0]["id"]), list(datasets.get("items") or [])
def upload_snapshot( def upload_snapshot(
@@ -215,16 +290,21 @@ def upload_snapshot(
year: int, year: int,
path: Path, path: Path,
timeout: int, timeout: int,
scope: GeographicScope,
) -> dict[str, Any]: ) -> dict[str, Any]:
observed_at = f"{year}-01-01T00:00:00Z" observed_at = f"{year}-01-01T00:00:00Z"
source_metadata = { source_metadata = {
"provider": "Statbel", "provider": "Statbel",
"authority_level": "authoritative", "authority_level": "authoritative",
"coverage_scope": "municipality", "coverage_scope": scope.key,
"municipality": MUNICIPALITY_NAME, "scope_type": scope.scope_type,
"nis_code": MUNICIPALITY_NIS_CODE, "scope_display_name": scope.display_name,
"member_count": len(scope.members),
"member_nis_codes": list(scope.nis_codes),
"attribution": ATTRIBUTION, "attribution": ATTRIBUTION,
"license": "CC BY 4.0", "license": "CC BY 4.0",
"temporal_series_label": "Officiële bevolkingscijfers per statistische sector",
"observation_date_precision": "year",
"identity_stable": False, "identity_stable": False,
"identity_limitation": "Statistical-sector codes and boundaries can change between annual editions.", "identity_limitation": "Statistical-sector codes and boundaries can change between annual editions.",
"selection_aggregation": { "selection_aggregation": {
@@ -239,6 +319,7 @@ def upload_snapshot(
provenance_metadata = { provenance_metadata = {
"operator_tool": "provision_mol_population_history.py", "operator_tool": "provision_mol_population_history.py",
"operator_explicit_fetch": True, "operator_explicit_fetch": True,
"scope_key": scope.key,
"sector_geometry_url": SECTOR_URL.format(year=year), "sector_geometry_url": SECTOR_URL.format(year=year),
"population_url": POPULATION_URLS[year], "population_url": POPULATION_URLS[year],
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
@@ -255,7 +336,7 @@ def upload_snapshot(
"source_metadata_json": json.dumps(source_metadata, ensure_ascii=False), "source_metadata_json": json.dumps(source_metadata, ensure_ascii=False),
"provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False), "provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False),
"area_id": area_id, "area_id": area_id,
"temporal_series_key": SERIES_KEY, "temporal_series_key": series_key(scope),
"observed_at": observed_at, "observed_at": observed_at,
"valid_from": observed_at, "valid_from": observed_at,
"valid_to": f"{year}-12-31T23:59:59Z", "valid_to": f"{year}-12-31T23:59:59Z",
@@ -270,6 +351,9 @@ def upload_snapshot(
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
scope = GEOGRAPHIC_SCOPES[args.scope]
project_name = args.project_name or scope.project_name
area_name = args.area_name or scope.area_name
try: try:
years = sorted({int(value.strip()) for value in args.years.split(",") if value.strip()}) years = sorted({int(value.strip()) for value in args.years.split(",") if value.strip()})
except ValueError: except ValueError:
@@ -280,14 +364,16 @@ def main() -> int:
print(json.dumps({"status": "error", "message": f"Unsupported years: {unsupported}"}), file=sys.stderr) print(json.dumps({"status": "error", "message": f"Unsupported years: {unsupported}"}), file=sys.stderr)
return 2 return 2
args.output_dir.mkdir(parents=True, exist_ok=True) output_dir = resolve_output_dir(args, scope)
output_dir.mkdir(parents=True, exist_ok=True)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
try: try:
boundary = load_boundary(args.boundary_path) boundary_path = resolve_boundary_path(args, scope)
boundary = load_boundary(boundary_path, scope)
prepared: list[tuple[int, Path, int]] = [] prepared: list[tuple[int, Path, int]] = []
with build_session() as source_session: with build_session() as source_session:
for year in years: for year in years:
path = args.output_dir / f"mol_statbel_population_{year}.geojson" path = output_dir / f"{scope.key.replace('-', '_')}_statbel_population_{year}.geojson"
if args.force or not path.exists(): if args.force or not path.exists():
sectors_response = source_session.get(SECTOR_URL.format(year=year), timeout=args.request_timeout) sectors_response = source_session.get(SECTOR_URL.format(year=year), timeout=args.request_timeout)
sectors_response.raise_for_status() sectors_response.raise_for_status()
@@ -296,8 +382,9 @@ def main() -> int:
snapshot = build_snapshot( snapshot = build_snapshot(
year, year,
zip_member_json(sectors_response.content), zip_member_json(sectors_response.content),
population_rows(population_response.content), population_rows(population_response.content, scope),
boundary, boundary,
scope,
) )
path.write_text(json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") path.write_text(json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
payload = json.loads(path.read_text(encoding="utf-8")) payload = json.loads(path.read_text(encoding="utf-8"))
@@ -308,14 +395,20 @@ def main() -> int:
else: else:
base_url = args.base_url.rstrip("/") base_url = args.base_url.rstrip("/")
with requests.Session() as api_session: with requests.Session() as api_session:
project_id, area_id, existing = locate_workspace(api_session, base_url, args.project_name, args.import_timeout) project_id, area_id, existing = locate_workspace(
api_session,
base_url,
project_name,
area_name,
args.import_timeout,
)
for year, path, count in prepared: for year, path, count in prepared:
observed_at = f"{year}-01-01T00:00:00+00:00" observed_at = f"{year}-01-01T00:00:00+00:00"
dataset = next( dataset = next(
( (
item item
for item in existing for item in existing
if item.get("temporal_series_key") == SERIES_KEY if item.get("temporal_series_key") == series_key(scope)
and str(item.get("observed_at") or "").startswith(observed_at[:10]) and str(item.get("observed_at") or "").startswith(observed_at[:10])
), ),
None, None,
@@ -323,13 +416,35 @@ def main() -> int:
if dataset: if dataset:
results.append({"year": year, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "existing"}) results.append({"year": year, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "existing"})
continue continue
dataset = upload_snapshot(api_session, base_url, project_id, area_id, year, path, args.import_timeout) dataset = upload_snapshot(
api_session,
base_url,
project_id,
area_id,
year,
path,
args.import_timeout,
scope,
)
results.append({"year": year, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "imported"}) results.append({"year": year, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "imported"})
except (OSError, RuntimeError, requests.RequestException, ValueError, KeyError, zipfile.BadZipFile) as exc: except (OSError, RuntimeError, requests.RequestException, ValueError, KeyError, zipfile.BadZipFile) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr) print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1 return 1
print(json.dumps({"status": "ok", "municipality": MUNICIPALITY_NAME, "series": SERIES_KEY, "snapshots": results}, ensure_ascii=False, indent=2)) print(
json.dumps(
{
"status": "ok",
"scope": scope.key,
"display_name": scope.display_name,
"member_count": len(scope.members),
"series": series_key(scope),
"snapshots": results,
},
ensure_ascii=False,
indent=2,
)
)
return 0 return 0
@@ -185,6 +185,16 @@ def series_key(theme: ThemeDefinition, scope_key: str) -> str:
return f"department-omgeving:land-use:{theme.key}:{scope_key.strip().lower()}" return f"department-omgeving:land-use:{theme.key}:{scope_key.strip().lower()}"
def scope_identity(scope_display_name: str, raw_nis_codes: str) -> dict[str, Any]:
nis_codes = [value.strip() for value in raw_nis_codes.split(",") if value.strip()]
return {
"scope_display_name": scope_display_name,
"member_nis_codes": nis_codes,
"municipality": scope_display_name if len(nis_codes) == 1 else None,
"nis_code": nis_codes[0] if len(nis_codes) == 1 else None,
}
def load_boundary(path: Path): def load_boundary(path: Path):
if not path.exists(): if not path.exists():
raise RuntimeError(f"Boundary artifact is missing at {path}") raise RuntimeError(f"Boundary artifact is missing at {path}")
@@ -326,6 +336,7 @@ def polygonize_snapshot(
scope_key: str, scope_key: str,
max_features: int, max_features: int,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
identity = scope_identity(municipality_name, nis_code)
boundary_metric = metric_boundary(boundary) boundary_metric = metric_boundary(boundary)
to_output = Transformer.from_crs(SOURCE_CRS, OUTPUT_CRS, always_xy=True) to_output = Transformer.from_crs(SOURCE_CRS, OUTPUT_CRS, always_xy=True)
raster_sha256 = sha256_file(raster_path) raster_sha256 = sha256_file(raster_path)
@@ -397,8 +408,7 @@ def polygonize_snapshot(
"layer_type": theme.key, "layer_type": theme.key,
"authority_level": "authoritative", "authority_level": "authoritative",
"coverage_scope": scope_key, "coverage_scope": scope_key,
"municipality": municipality_name, **identity,
"nis_code": nis_code,
"observation_year": year, "observation_year": year,
"land_use_class_ids": list(theme.class_ids), "land_use_class_ids": list(theme.class_ids),
"land_use_class_names": [LAND_USE_CLASSES[class_id] for class_id in theme.class_ids], "land_use_class_names": [LAND_USE_CLASSES[class_id] for class_id in theme.class_ids],
@@ -415,8 +425,7 @@ def polygonize_snapshot(
"type": "FeatureCollection", "type": "FeatureCollection",
"name": f"{theme.label} - {municipality_name} {year}", "name": f"{theme.label} - {municipality_name} {year}",
"crs": {"type": "name", "properties": {"name": OUTPUT_CRS}}, "crs": {"type": "name", "properties": {"name": OUTPUT_CRS}},
"municipality": municipality_name, **identity,
"nis_code": nis_code,
"scope_key": scope_key, "scope_key": scope_key,
"observation_year": year, "observation_year": year,
"source_coverage_id": coverage_id(year), "source_coverage_id": coverage_id(year),
@@ -529,6 +538,7 @@ def prepare_snapshot(
) )
write_json_atomic(vector_path, payload) write_json_atomic(vector_path, payload)
vector_sha256 = sha256_file(vector_path) vector_sha256 = sha256_file(vector_path)
identity = scope_identity(args.municipality_name, args.nis_code)
manifest = { manifest = {
"schema_version": 1, "schema_version": 1,
"year": year, "year": year,
@@ -544,8 +554,7 @@ def prepare_snapshot(
"output_crs": OUTPUT_CRS, "output_crs": OUTPUT_CRS,
"source_resolution_metres": SOURCE_RESOLUTION_METRES, "source_resolution_metres": SOURCE_RESOLUTION_METRES,
"boundary_path": str(args.boundary_path), "boundary_path": str(args.boundary_path),
"municipality": args.municipality_name, **identity,
"nis_code": args.nis_code,
"scope_key": args.scope_key, "scope_key": args.scope_key,
"raster_path": str(raster_path), "raster_path": str(raster_path),
"vector_path": str(vector_path), "vector_path": str(vector_path),
@@ -629,6 +638,7 @@ def locate_workspace(session: requests.Session, base_url: str, args: argparse.Na
def build_source_metadata(args: argparse.Namespace, snapshot: PreparedSnapshot) -> dict[str, Any]: def build_source_metadata(args: argparse.Namespace, snapshot: PreparedSnapshot) -> dict[str, Any]:
identity = scope_identity(args.municipality_name, args.nis_code)
return { return {
"provider": "Departement Omgeving", "provider": "Departement Omgeving",
"source_title": f"Landgebruik - Vlaanderen - toestand {snapshot.year}", "source_title": f"Landgebruik - Vlaanderen - toestand {snapshot.year}",
@@ -636,8 +646,7 @@ def build_source_metadata(args: argparse.Namespace, snapshot: PreparedSnapshot)
"coverage_id": coverage_id(snapshot.year), "coverage_id": coverage_id(snapshot.year),
"authority_level": "authoritative", "authority_level": "authoritative",
"coverage_scope": args.scope_key, "coverage_scope": args.scope_key,
"municipality": args.municipality_name, **identity,
"nis_code": args.nis_code,
"attribution": ATTRIBUTION, "attribution": ATTRIBUTION,
"license_note": "Publieke Vlaamse overheidsdata; raadpleeg de toegangs- en gebruiksvoorwaarden in de bronmetadata.", "license_note": "Publieke Vlaamse overheidsdata; raadpleeg de toegangs- en gebruiksvoorwaarden in de bronmetadata.",
"methodology_version": "3", "methodology_version": "3",
+199
View File
@@ -0,0 +1,199 @@
"""Synchronize official population and forest time series for one approved scope.
This is an explicit operator command, never an application-startup task. It
coordinates the existing Statbel and Departement Omgeving import paths and
keeps all persistence behind the canonical dataset upload API.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from geographic_scopes import GEOGRAPHIC_SCOPES, GeographicScope
DEFAULT_API_URL = "http://127.0.0.1:8000"
DEFAULT_SCOPE_KEY = "kempen-transport-region"
DEFAULT_SCOPE_OUTPUT_ROOT = Path("/app/storage/operator-data/geographic-scopes")
DEFAULT_TIMESERIES_OUTPUT_ROOT = Path("/app/storage/operator-data/regional-timeseries")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Synchronize official GeoIntel time series for an approved scope.")
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", DEFAULT_API_URL))
parser.add_argument("--population-years", default="2021,2022,2023,2024,2025")
parser.add_argument("--landuse-years", default="2013,2016,2019,2022,2025")
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_TIMESERIES_OUTPUT_ROOT", DEFAULT_TIMESERIES_OUTPUT_ROOT)),
)
parser.add_argument("--max-landuse-features", type=int, default=500000)
parser.add_argument("--request-timeout", type=int, default=300)
parser.add_argument("--import-timeout", type=int, default=3600)
parser.add_argument("--skip-population", action="store_true")
parser.add_argument("--skip-landuse", action="store_true")
parser.add_argument("--fetch-only", action="store_true")
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def resolve_boundary(scope: GeographicScope, scope_output_root: Path) -> tuple[Path, Path]:
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}; run provision_geographic_scope.py --scope {scope.key} first"
)
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")
boundary_path = scope_dir / str(manifest.get("boundary_filename") or "")
if not boundary_path.is_file():
raise RuntimeError(f"Official scope boundary referenced by {manifest_path} is missing")
return boundary_path, manifest_path
def build_operator_commands(args: argparse.Namespace, scope: GeographicScope, boundary_path: Path) -> list[tuple[str, list[str]]]:
scripts_dir = Path(__file__).resolve().parent
scope_output = args.output_root / scope.key
common_flags = ["--fetch-only"] if args.fetch_only else []
if args.force:
common_flags.append("--force")
commands: list[tuple[str, list[str]]] = []
if not args.skip_population:
commands.append(
(
"population",
[
sys.executable,
str(scripts_dir / "provision_mol_population_history.py"),
"--scope",
scope.key,
"--base-url",
args.base_url,
"--project-name",
scope.project_name,
"--area-name",
scope.area_name,
"--years",
args.population_years,
"--boundary-path",
str(boundary_path),
"--output-dir",
str(scope_output / "population"),
"--request-timeout",
str(args.request_timeout),
"--import-timeout",
str(args.import_timeout),
*common_flags,
],
)
)
if not args.skip_landuse:
commands.append(
(
"forest",
[
sys.executable,
str(scripts_dir / "provision_official_landuse_timeseries.py"),
"--base-url",
args.base_url,
"--project-name",
scope.project_name,
"--area-name",
scope.area_name,
"--municipality-name",
scope.display_name,
"--nis-code",
",".join(scope.nis_codes),
"--scope-key",
scope.key,
"--years",
args.landuse_years,
"--themes",
"forest",
"--boundary-path",
str(boundary_path),
"--output-dir",
str(scope_output / "landuse"),
"--request-timeout",
str(args.request_timeout),
"--import-timeout",
str(args.import_timeout),
"--max-features",
str(args.max_landuse_features),
*common_flags,
],
)
)
if not commands:
raise ValueError("At least one of population or land-use synchronization must remain enabled")
return commands
def run_operator(label: str, command: list[str]) -> dict[str, Any]:
completed = subprocess.run(command, check=False, capture_output=True, text=True, encoding="utf-8")
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "operator returned no diagnostic output"
raise RuntimeError(f"{label} synchronization failed with exit {completed.returncode}: {detail[-2000:]}")
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"{label} synchronization returned invalid JSON: {completed.stdout[-1000:]}") from exc
if payload.get("status") != "ok":
raise RuntimeError(f"{label} synchronization did not report success")
return payload
def main() -> int:
args = parse_args()
scope = GEOGRAPHIC_SCOPES[args.scope]
try:
if args.max_landuse_features <= 0:
raise ValueError("max-landuse-features must be greater than zero")
boundary_path, manifest_path = resolve_boundary(scope, args.scope_output_root)
commands = build_operator_commands(args, scope, boundary_path)
results = {label: run_operator(label, command) for label, command in commands}
except (OSError, RuntimeError, ValueError, KeyError) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
print(
json.dumps(
{
"status": "ok",
"mode": "fetch_only" if args.fetch_only else "synchronized",
"scope": scope.key,
"display_name": scope.display_name,
"member_count": len(scope.members),
"boundary_path": str(boundary_path),
"scope_manifest_path": str(manifest_path),
"results": results,
},
ensure_ascii=False,
indent=2,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -47,6 +47,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_mol_context_layers.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_population_history.py ${PYTHON_BIN} -m py_compile scripts/provision_mol_population_history.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_historical_landuse.py ${PYTHON_BIN} -m py_compile scripts/provision_mol_historical_landuse.py
${PYTHON_BIN} -m py_compile scripts/provision_official_landuse_timeseries.py ${PYTHON_BIN} -m py_compile scripts/provision_official_landuse_timeseries.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_timeseries.py
${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py ${PYTHON_BIN} -m py_compile scripts/geographic_scopes.py
${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py ${PYTHON_BIN} -m py_compile scripts/provision_geographic_scope.py
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py ${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py