feat: synchronize regional official time series
This commit is contained in:
@@ -7,6 +7,15 @@
|
||||
|
||||
# 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)
|
||||
|
||||
- Made the complete `Kempen Regional Workbench` the automatic fresh-session data context and removed the redundant region selector from the primary map.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- `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
|
||||
@@ -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_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_regional_timeseries.py /app/scripts/provision_regional_timeseries.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_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
|
||||
|
||||
@@ -7960,6 +7960,24 @@ Live operational proof:
|
||||
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.
|
||||
|
||||
## 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)
|
||||
|
||||
Implemented:
|
||||
|
||||
@@ -148,6 +148,22 @@ presented as one continuous measurement method. Raster-derived polygon
|
||||
identities are unstable, so GeoIntel compares hectares and never invents
|
||||
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:
|
||||
|
||||
- https://www.vlaanderen.be/datavindplaats/catalogus/landgebruik-vlaanderen-toestand-2013
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, Qa
|
||||
import { featureCollectionBounds } from '../../lib/geojsonBounds'
|
||||
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
|
||||
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_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)}`
|
||||
: 'Minstens twee expliciet gedateerde snapshots zijn vereist.'
|
||||
: activeThemeDataset
|
||||
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.dataset_role ?? 'source'} · ${formatObservationDate(activeThemeDataset.observed_at)}`
|
||||
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}`
|
||||
: activeTheme.description}
|
||||
</small>
|
||||
</div>
|
||||
@@ -1247,7 +1247,7 @@ export function MapWorkspace({
|
||||
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{theme.label}</strong>
|
||||
<small>{dataset?.source_name ?? dataset?.source ?? 'Geen bron gekoppeld'}</small>
|
||||
<small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small>
|
||||
</span>
|
||||
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,18 @@ const DATASET_LABEL_BY_LAYER: Record<string, string> = {
|
||||
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 {
|
||||
const layer = (dataset.reference_layer_name ?? dataset.source_metadata?.layer_name ?? dataset.source_metadata?.layer_type ?? '')
|
||||
.toString()
|
||||
@@ -19,6 +31,6 @@ export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
|
||||
if (!label) {
|
||||
return dataset.name
|
||||
}
|
||||
const source = (dataset.source_name ?? dataset.source).toUpperCase()
|
||||
const source = getDatasetSourceDisplayName(dataset)
|
||||
return `${label} · ${source}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -27,6 +32,8 @@ from shapely.ops import transform
|
||||
from shapely.validation import make_valid
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from geographic_scopes import GEOGRAPHIC_SCOPES, GeographicScope
|
||||
|
||||
|
||||
MUNICIPALITY_NAME = "Mol"
|
||||
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_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_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 = (
|
||||
"https://statbel.fgov.be/sites/default/files/files/opendata/Statistische%20sectoren/"
|
||||
"sh_statbel_statistical_sectors_31370_{year}0101.geojson.zip"
|
||||
@@ -50,12 +60,19 @@ POPULATION_URLS = {
|
||||
|
||||
|
||||
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("--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("--output-dir", type=Path, default=Path(os.environ.get("MOL_POPULATION_OUTPUT_DIR", DEFAULT_OUTPUT_DIR)))
|
||||
parser.add_argument("--boundary-path", type=Path, default=Path(os.environ.get("MOL_BOUNDARY_PATH", DEFAULT_BOUNDARY_PATH)))
|
||||
parser.add_argument("--output-dir", type=Path, default=None)
|
||||
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("--import-timeout", type=int, default=900)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
@@ -75,7 +92,7 @@ def build_session() -> requests.Session:
|
||||
raise_on_status=True,
|
||||
)
|
||||
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)
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
@@ -94,18 +111,56 @@ def response_data(response: requests.Response) -> Any:
|
||||
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():
|
||||
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"))
|
||||
features = payload.get("features") or []
|
||||
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"])
|
||||
if not boundary.is_valid:
|
||||
boundary = make_valid(boundary)
|
||||
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
|
||||
|
||||
|
||||
@@ -117,7 +172,7 @@ def zip_member_json(content: bytes) -> dict[str, Any]:
|
||||
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:
|
||||
member = next((name for name in archive.namelist() if name.lower().endswith((".txt", ".csv"))), None)
|
||||
if not member:
|
||||
@@ -127,9 +182,11 @@ def population_rows(content: bytes) -> dict[str, dict[str, Any]]:
|
||||
text = raw.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.decode("cp1252")
|
||||
members = {member.nis_code: member.name for member in scope.members}
|
||||
rows: dict[str, dict[str, Any]] = {}
|
||||
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
|
||||
sector_code = str(row.get("CD_SECTOR") 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),
|
||||
"sector_name_nl": row.get("TX_DESCR_SECTOR_NL"),
|
||||
"municipality_name_nl": row.get("TX_DESCR_NL"),
|
||||
"municipality": members[nis_code],
|
||||
"nis_code": nis_code,
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
member_codes = set(scope.nis_codes)
|
||||
features: list[dict[str, Any]] = []
|
||||
missing_population = 0
|
||||
for source_feature in sector_payload.get("features") 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
|
||||
sector_code = str(properties.get("cd_sector") or "").strip()
|
||||
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,
|
||||
"reference_layer_name": "population",
|
||||
"authority_level": "authoritative",
|
||||
"municipality": MUNICIPALITY_NAME,
|
||||
"nis_code": MUNICIPALITY_NIS_CODE,
|
||||
"municipality": population_values["municipality"],
|
||||
"nis_code": population_values["nis_code"],
|
||||
"observation_year": year,
|
||||
"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}")
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"name": f"Statbel population by statistical sector - Mol {year}",
|
||||
"name": f"Statbel population by statistical sector - {scope.display_name} {year}",
|
||||
"features": features,
|
||||
"municipality": MUNICIPALITY_NAME,
|
||||
"nis_code": MUNICIPALITY_NIS_CODE,
|
||||
"coverage_scope": scope.key,
|
||||
"scope_type": scope.scope_type,
|
||||
"member_count": len(scope.members),
|
||||
"member_nis_codes": list(scope.nis_codes),
|
||||
"observation_year": year,
|
||||
"missing_population_sector_count": missing_population,
|
||||
"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))
|
||||
project = next((item for item in projects.get("items") or [] if item.get("name") == project_name), None)
|
||||
if not project:
|
||||
raise RuntimeError(f"Project {project_name!r} is missing")
|
||||
project_id = str(project["id"])
|
||||
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)
|
||||
if not area:
|
||||
raise RuntimeError("Official Mol area is missing")
|
||||
area_fragment = area_name.strip().casefold()
|
||||
matches = [item for item in areas.get("items") or [] if area_fragment in str(item.get("name") or "").casefold()]
|
||||
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))
|
||||
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(
|
||||
@@ -215,16 +290,21 @@ def upload_snapshot(
|
||||
year: int,
|
||||
path: Path,
|
||||
timeout: int,
|
||||
scope: GeographicScope,
|
||||
) -> dict[str, Any]:
|
||||
observed_at = f"{year}-01-01T00:00:00Z"
|
||||
source_metadata = {
|
||||
"provider": "Statbel",
|
||||
"authority_level": "authoritative",
|
||||
"coverage_scope": "municipality",
|
||||
"municipality": MUNICIPALITY_NAME,
|
||||
"nis_code": MUNICIPALITY_NIS_CODE,
|
||||
"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),
|
||||
"attribution": ATTRIBUTION,
|
||||
"license": "CC BY 4.0",
|
||||
"temporal_series_label": "Officiële bevolkingscijfers per statistische sector",
|
||||
"observation_date_precision": "year",
|
||||
"identity_stable": False,
|
||||
"identity_limitation": "Statistical-sector codes and boundaries can change between annual editions.",
|
||||
"selection_aggregation": {
|
||||
@@ -239,6 +319,7 @@ def upload_snapshot(
|
||||
provenance_metadata = {
|
||||
"operator_tool": "provision_mol_population_history.py",
|
||||
"operator_explicit_fetch": True,
|
||||
"scope_key": scope.key,
|
||||
"sector_geometry_url": SECTOR_URL.format(year=year),
|
||||
"population_url": POPULATION_URLS[year],
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -255,7 +336,7 @@ def upload_snapshot(
|
||||
"source_metadata_json": json.dumps(source_metadata, ensure_ascii=False),
|
||||
"provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False),
|
||||
"area_id": area_id,
|
||||
"temporal_series_key": SERIES_KEY,
|
||||
"temporal_series_key": series_key(scope),
|
||||
"observed_at": observed_at,
|
||||
"valid_from": observed_at,
|
||||
"valid_to": f"{year}-12-31T23:59:59Z",
|
||||
@@ -270,6 +351,9 @@ def upload_snapshot(
|
||||
|
||||
def main() -> int:
|
||||
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:
|
||||
years = sorted({int(value.strip()) for value in args.years.split(",") if value.strip()})
|
||||
except ValueError:
|
||||
@@ -280,14 +364,16 @@ def main() -> int:
|
||||
print(json.dumps({"status": "error", "message": f"Unsupported years: {unsupported}"}), file=sys.stderr)
|
||||
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]] = []
|
||||
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]] = []
|
||||
with build_session() as source_session:
|
||||
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():
|
||||
sectors_response = source_session.get(SECTOR_URL.format(year=year), timeout=args.request_timeout)
|
||||
sectors_response.raise_for_status()
|
||||
@@ -296,8 +382,9 @@ def main() -> int:
|
||||
snapshot = build_snapshot(
|
||||
year,
|
||||
zip_member_json(sectors_response.content),
|
||||
population_rows(population_response.content),
|
||||
population_rows(population_response.content, scope),
|
||||
boundary,
|
||||
scope,
|
||||
)
|
||||
path.write_text(json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -308,14 +395,20 @@ def main() -> int:
|
||||
else:
|
||||
base_url = args.base_url.rstrip("/")
|
||||
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:
|
||||
observed_at = f"{year}-01-01T00:00:00+00:00"
|
||||
dataset = next(
|
||||
(
|
||||
item
|
||||
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])
|
||||
),
|
||||
None,
|
||||
@@ -323,13 +416,35 @@ def main() -> int:
|
||||
if dataset:
|
||||
results.append({"year": year, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "existing"})
|
||||
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"})
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
|
||||
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):
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"Boundary artifact is missing at {path}")
|
||||
@@ -326,6 +336,7 @@ def polygonize_snapshot(
|
||||
scope_key: str,
|
||||
max_features: int,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
identity = scope_identity(municipality_name, nis_code)
|
||||
boundary_metric = metric_boundary(boundary)
|
||||
to_output = Transformer.from_crs(SOURCE_CRS, OUTPUT_CRS, always_xy=True)
|
||||
raster_sha256 = sha256_file(raster_path)
|
||||
@@ -397,8 +408,7 @@ def polygonize_snapshot(
|
||||
"layer_type": theme.key,
|
||||
"authority_level": "authoritative",
|
||||
"coverage_scope": scope_key,
|
||||
"municipality": municipality_name,
|
||||
"nis_code": nis_code,
|
||||
**identity,
|
||||
"observation_year": year,
|
||||
"land_use_class_ids": list(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",
|
||||
"name": f"{theme.label} - {municipality_name} {year}",
|
||||
"crs": {"type": "name", "properties": {"name": OUTPUT_CRS}},
|
||||
"municipality": municipality_name,
|
||||
"nis_code": nis_code,
|
||||
**identity,
|
||||
"scope_key": scope_key,
|
||||
"observation_year": year,
|
||||
"source_coverage_id": coverage_id(year),
|
||||
@@ -529,6 +538,7 @@ def prepare_snapshot(
|
||||
)
|
||||
write_json_atomic(vector_path, payload)
|
||||
vector_sha256 = sha256_file(vector_path)
|
||||
identity = scope_identity(args.municipality_name, args.nis_code)
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"year": year,
|
||||
@@ -544,8 +554,7 @@ def prepare_snapshot(
|
||||
"output_crs": OUTPUT_CRS,
|
||||
"source_resolution_metres": SOURCE_RESOLUTION_METRES,
|
||||
"boundary_path": str(args.boundary_path),
|
||||
"municipality": args.municipality_name,
|
||||
"nis_code": args.nis_code,
|
||||
**identity,
|
||||
"scope_key": args.scope_key,
|
||||
"raster_path": str(raster_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]:
|
||||
identity = scope_identity(args.municipality_name, args.nis_code)
|
||||
return {
|
||||
"provider": "Departement Omgeving",
|
||||
"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),
|
||||
"authority_level": "authoritative",
|
||||
"coverage_scope": args.scope_key,
|
||||
"municipality": args.municipality_name,
|
||||
"nis_code": args.nis_code,
|
||||
**identity,
|
||||
"attribution": ATTRIBUTION,
|
||||
"license_note": "Publieke Vlaamse overheidsdata; raadpleeg de toegangs- en gebruiksvoorwaarden in de bronmetadata.",
|
||||
"methodology_version": "3",
|
||||
|
||||
@@ -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())
|
||||
@@ -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_historical_landuse.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/provision_geographic_scope.py
|
||||
${PYTHON_BIN} -m py_compile scripts/provision_regional_grb_buildings.py
|
||||
|
||||
Reference in New Issue
Block a user