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