diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf296b96..690f8127 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/backend/README.md b/backend/README.md
index 7315c1b0..6518cc6c 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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`
diff --git a/backend/tests/test_sprint194_regional_timeseries.py b/backend/tests/test_sprint194_regional_timeseries.py
new file mode 100644
index 00000000..d89cffbd
--- /dev/null
+++ b/backend/tests/test_sprint194_regional_timeseries.py
@@ -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
diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one
index 55dca368..98280089 100644
--- a/deploy/unraid/Dockerfile.all-in-one
+++ b/deploy/unraid/Dockerfile.all-in-one
@@ -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
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index c5816905..8e8324c5 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -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:
diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md
index 1639b808..457cc02b 100644
--- a/docs/DATA_SOURCES.md
+++ b/docs/DATA_SOURCES.md
@@ -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
diff --git a/frontend/README.md b/frontend/README.md
index 1ce42cb9..ee3fce74 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -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.
diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx
index d8ca5465..f81903e1 100644
--- a/frontend/src/components/map/MapWorkspace.tsx
+++ b/frontend/src/components/map/MapWorkspace.tsx
@@ -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}
@@ -1247,7 +1247,7 @@ export function MapWorkspace({
{theme.label}
- {dataset?.source_name ?? dataset?.source ?? 'Geen bron gekoppeld'}
+ {dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}
{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}
diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts
index 56b1cbae..93a8be5d 100644
--- a/frontend/src/lib/datasetDisplay.ts
+++ b/frontend/src/lib/datasetDisplay.ts
@@ -11,6 +11,18 @@ const DATASET_LABEL_BY_LAYER: Record = {
municipality_boundaries: 'Gemeentegrenzen Kempen',
}
+const DATASET_SOURCE_LABELS: Record = {
+ 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}`
}
diff --git a/scripts/README.md b/scripts/README.md
index e163c662..53bf89ea 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -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:
diff --git a/scripts/provision_mol_population_history.py b/scripts/provision_mol_population_history.py
index ce5f45c0..f60dd0f2 100644
--- a/scripts/provision_mol_population_history.py
+++ b/scripts/provision_mol_population_history.py
@@ -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
diff --git a/scripts/provision_official_landuse_timeseries.py b/scripts/provision_official_landuse_timeseries.py
index d98d0a1f..baa2bde5 100644
--- a/scripts/provision_official_landuse_timeseries.py
+++ b/scripts/provision_official_landuse_timeseries.py
@@ -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",
diff --git a/scripts/provision_regional_timeseries.py b/scripts/provision_regional_timeseries.py
new file mode 100644
index 00000000..641fd850
--- /dev/null
+++ b/scripts/provision_regional_timeseries.py
@@ -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())
diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh
index ec4eeeb4..d1433abe 100755
--- a/scripts/run_readiness_check.sh
+++ b/scripts/run_readiness_check.sh
@@ -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