Add governed buildings register snapshot
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 16:56:08 +02:00
parent 48c0d21fed
commit 553e457b26
16 changed files with 1779 additions and 20 deletions
+17
View File
@@ -7,6 +7,23 @@
# Changelog
## Sprint 206 Governed Buildings and Addresses Register snapshot (2026-07-15)
- Added an explicit operator for the current official Digitaal Vlaanderen
building, building-unit and address OGC collections with complete pagination,
safety limits, retries and retained raw SHA256 evidence.
- Added exact EPSG:31370 Area clipping, EPSG:4326 building persistence and
classified reconciliation against checksummed persisted GRB partitions.
- Persisted only building lifecycle data and aggregate unit/address counts;
full addresses, street names and house/box numbers remain outside queryable
output, and ambiguous address/GRB relations are never forced.
- Added exact footprint, lifecycle, unit, address-status and confirmed-GRB
selection metrics, including correct filtered `feature_count` execution.
- Made the Map workspace prefer the richer Mol register snapshot only for the
matching Mol Area and retain regional GRB coverage everywhere else.
- Added source-inventory presentation, runtime packaging and focused operator,
privacy, reconciliation, metric and UI tests.
## Sprint 205 Governed agricultural-use parcel history (2026-07-15)
- Added an explicit ALZ operator for the definitive 2008-2025 annual
+22
View File
@@ -1132,6 +1132,28 @@ transport region. Every annual source ZIP and crop code list remains under the
storage volume. PostGIS computes exact hectares for drawn rectangles and
persisted Areas; parcel identities are deliberately unavailable for lineage.
## Buildings and Addresses Register snapshot
After the Mol Area and regional GRB buildings have been provisioned, prepare
the official register evidence with:
```bash
docker exec geointel python /app/scripts/provision_buildings_addresses_register.py --fetch-only
```
Review the generated manifest and then persist through DatasetService:
```bash
docker exec geointel python /app/scripts/provision_buildings_addresses_register.py
```
The resulting `building_registry` Dataset uses ordinary EPSG:4326
`vector_features`; no register-specific table or direct operator database write
exists. Exact PostGIS selection exposes footprint hectares, lifecycle counts,
aggregate unit/address counts and GRB reconciliation counts. Raw address pages
are checksummed storage evidence only. Address labels and house/box numbers are
not copied into queryable properties.
## Helpful repository scripts
- `bash scripts/backend_install.sh`
@@ -26,6 +26,7 @@ FULL_AREA_CLIPPED_OPERATOR_TOOLS = {
"provision_waterinfo_station_history.py",
"provision_mol_bwk_natura2000.py",
"provision_agricultural_parcel_history.py",
"provision_buildings_addresses_register.py",
}
@@ -144,6 +145,7 @@ class VectorFeatureService:
"agricultural": "agriculture",
"landbouw": "agriculture",
"landbouwgebruik": "agriculture",
"building_registry": "buildings",
}
for candidate in candidates:
if not isinstance(candidate, str) or not candidate.strip():
@@ -538,6 +540,10 @@ class VectorFeatureService:
is_estimate = bool(config.get("is_estimate", False))
if not is_estimate and config.get("warning_only_when_estimate", True):
warning = None
elif method == "feature_count" and (filter_property or dimension in {1, 2}):
metric_value = float(
db.query(func.count(VectorFeature.id)).filter(*metric_filter).scalar() or 0
)
elif method != "feature_count":
raise AppError(
code="INVALID_SELECTION_AGGREGATION",
@@ -0,0 +1,356 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
from uuid import uuid4
from shapely.geometry import Point, box, mapping
from shapely.ops import transform as transform_geometry
from app.models import Dataset
from app.schemas.operations import VectorSelectionSummary
from app.services.vector_feature_service import VectorFeatureService
ROOT = Path(__file__).resolve().parents[2]
BBOX = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.3, "max_y": 51.4, "crs": "EPSG:4326"}
def load_operator():
script_path = ROOT / "scripts" / "provision_buildings_addresses_register.py"
spec = importlib.util.spec_from_file_location("buildings_addresses_register_operator", script_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def building_feature(object_id: str, geometry, status: str = "Gerealiseerd") -> dict: # noqa: ANN001
return {
"type": "Feature",
"id": f"Gebouw.{object_id}",
"geometry": mapping(geometry),
"properties": {
"ObjectId": int(object_id),
"VersieId": "2026-07-15T08:00:00+02:00",
"GeometrieMethode": "IngemetenGRB",
"GebouwStatus": status,
},
}
def unit_feature(object_id: str, building_id: str, point: Point) -> dict:
return {
"type": "Feature",
"id": f"Gebouweenheid.{object_id}",
"geometry": mapping(point),
"properties": {
"ObjectId": int(object_id),
"GebouwObjectId": int(building_id),
"GebouweenheidStatus": "Gerealiseerd",
"Functie": "NietGekend",
},
}
def address_feature(object_id: str, point: Point) -> dict:
return {
"type": "Feature",
"id": f"Adres.{object_id}",
"geometry": mapping(point),
"properties": {
"ObjectId": int(object_id),
"AdresStatus": "InGebruik",
"PositieSpecificatie": "Gebouweenheid",
"VolledigAdres": "Teststraat 1 bus 2, 2400 Mol",
"Straatnaam": "Teststraat",
"Huisnummer": "1",
"Busnummer": "2",
},
}
class ScalarQuery:
def __init__(self, value: float):
self.value = value
def filter(self, *args): # noqa: ANN002, ARG002
return self
def scalar(self):
return self.value
class SequenceScalarSession:
def __init__(self, values: list[float]):
self.values = iter(values)
def query(self, *args): # noqa: ANN002, ARG002
return ScalarQuery(next(self.values))
class OfficialResponse:
status_code = 200
def __init__(self, payload: dict, url: str):
self.payload = payload
self.url = url
self.content = json.dumps(payload).encode("utf-8")
def json(self):
return self.payload
def raise_for_status(self):
return None
class TwoPageOfficialSession:
def __init__(self):
self.calls = 0
def get(self, url, *, params, timeout): # noqa: ANN001, ARG002
self.calls += 1
if self.calls == 1:
payload = {
"type": "FeatureCollection",
"features": [building_feature("1", box(5.10, 51.20, 5.101, 51.201))],
"links": [{"rel": "next", "href": f"{url}?startIndex=1"}],
}
else:
payload = {
"type": "FeatureCollection",
"features": [building_feature("2", box(5.102, 51.20, 5.103, 51.201))],
"links": [],
}
return OfficialResponse(payload, f"{url}?page={self.calls}")
def normalized_fixture(module): # noqa: ANN001
boundary_wgs84 = box(5.09, 51.19, 5.12, 51.22)
boundary_lambert72 = transform_geometry(module.TO_LAMBERT72.transform, boundary_wgs84)
polygon = box(5.10, 51.20, 5.105, 51.205)
buildings, summary = module.normalize_buildings(
[building_feature("100", polygon)],
boundary_lambert72,
)
return boundary_wgs84, boundary_lambert72, polygon, buildings, summary
def test_official_collection_pagination_retains_checksummed_pages(tmp_path: Path) -> None:
module = load_operator()
session = TwoPageOfficialSession()
raw_dir = tmp_path / "raw"
features, summary = module.fetch_collection(
session,
url=module.BUILDING_ITEMS_URL,
name="buildings",
bbox=(5.0, 51.0, 5.2, 51.2),
raw_dir=raw_dir,
page_limit=1,
max_features=10,
timeout=30,
)
assert [feature["properties"]["ObjectId"] for feature in features] == [1, 2]
assert summary["page_count"] == 2
assert all((tmp_path / page["path"]).is_file() for page in summary["pages"])
assert all(len(page["sha256"]) == 64 for page in summary["pages"])
def test_buildings_are_clipped_in_lambert72_and_keep_lifecycle_status() -> None:
module = load_operator()
boundary = box(5.10, 51.20, 5.11, 51.21)
boundary_lambert72 = transform_geometry(module.TO_LAMBERT72.transform, boundary)
source = box(5.095, 51.195, 5.105, 51.205)
buildings, summary = module.normalize_buildings(
[building_feature("100", source, "InAanbouw")],
boundary_lambert72,
)
assert summary == {"rejected_or_outside_count": 0, "clipped_count": 1}
record = buildings["100"]
assert record["status_key"] == "under_construction"
assert record["was_clipped"] is True
assert record["geometry_wgs84"].difference(boundary.buffer(1e-7)).area < 1e-12
assert record["area_ha"] > 0
def test_official_unit_relation_and_exact_address_position_are_aggregated_without_labels() -> None:
module = load_operator()
_, boundary_lambert72, polygon, buildings, _ = normalized_fixture(module)
point = polygon.centroid
units, unit_summary = module.normalize_units(
[unit_feature("200", "100", point)],
boundary_lambert72,
buildings,
)
address_counts, address_summary = module.link_addresses(
[address_feature("300", point)],
boundary_lambert72,
buildings,
units,
)
module.reconcile_with_grb(
buildings,
[{"source_feature_id": "GRB.1", "geometry_wgs84": polygon}],
)
output, totals = module.build_output_features(
buildings,
units,
address_counts,
observed_date=module.date(2026, 7, 15),
area_name="Gemeente Mol - officiële grens",
)
assert unit_summary["orphan_building_count"] == 0
assert address_summary["match_method_counts"] == {"unit_position_exact": 1}
assert totals["linked_unit_count"] == 1
assert totals["linked_address_count"] == 1
properties = output[0]["properties"]
assert properties["unit_count"] == 1
assert properties["active_address_count"] == 1
assert properties["grb_match_status"] == "matched"
for prohibited in ("VolledigAdres", "Straatnaam", "Huisnummer", "Busnummer", "HuisnummerLabel"):
assert prohibited not in properties
def test_ambiguous_unit_position_is_reported_and_never_forced() -> None:
module = load_operator()
boundary = box(5.09, 51.19, 5.12, 51.22)
boundary_lambert72 = transform_geometry(module.TO_LAMBERT72.transform, boundary)
point = Point(5.105, 51.205)
buildings, _ = module.normalize_buildings(
[
building_feature("100", box(5.10, 51.20, 5.106, 51.21)),
building_feature("101", box(5.104, 51.20, 5.11, 51.21)),
],
boundary_lambert72,
)
units, _ = module.normalize_units(
[unit_feature("200", "100", point), unit_feature("201", "101", point)],
boundary_lambert72,
buildings,
)
counts, summary = module.link_addresses(
[address_feature("300", point)],
boundary_lambert72,
buildings,
units,
)
assert summary["ambiguous_address_count"] == 1
assert summary["matched_address_count"] == 0
assert not counts
def test_grb_reconciliation_distinguishes_exact_and_unmatched_geometry() -> None:
module = load_operator()
_, _, polygon, buildings, _ = normalized_fixture(module)
buildings["101"] = {
**buildings["100"],
"object_id": "101",
"geometry_wgs84": box(5.11, 51.21, 5.115, 51.215),
"geometry_lambert72": transform_geometry(
module.TO_LAMBERT72.transform,
box(5.11, 51.21, 5.115, 51.215),
),
}
summary = module.reconcile_with_grb(
buildings,
[{"source_feature_id": "GRB.1", "geometry_wgs84": polygon}],
)
assert buildings["100"]["grb_match_method"] == "exact_geometry"
assert buildings["100"]["grb_match_confidence"] == 1.0
assert buildings["101"]["grb_match_status"] == "unmatched"
assert summary["match_status_counts"] == {"matched": 1, "unmatched": 1}
assert summary["match_rate"] == 0.5
def test_status_and_relation_metrics_use_filtered_server_owned_aggregations() -> None:
module = load_operator()
metrics = module.selection_metrics()
assert {item["metric_key"] for item in metrics} >= {
"registered_building_count",
"realized_building_count",
"building_unit_count",
"linked_address_count",
"active_address_count",
"grb_matched_building_count",
}
status_metrics = [item for item in metrics if item["metric_key"].endswith("building_count")]
assert any(item.get("filter_property") == "building_status_key" for item in status_metrics)
assert "huishoudens" in next(item for item in metrics if item["metric_key"] == "linked_address_count")["warning"]
def test_filtered_feature_count_and_numeric_relations_validate_as_selection_summary() -> None:
module = load_operator()
dataset = Dataset(
id=uuid4(),
project_id=uuid4(),
name="buildings_addresses_register.geojson",
dataset_type="vector",
dataset_role="reference",
source_name=module.SOURCE_NAME,
reference_layer_name="building_registry",
source_metadata={
"theme": "buildings",
"semantic_metrics": False,
"selection_aggregation": {
"metric_key": "building_footprint_area",
"method": "intersection_area",
"label": "Gebouwgrondoppervlakte",
"unit": "ha",
"geometry_dimension": 2,
},
"selection_metrics": module.selection_metrics(),
},
)
session = SequenceScalarSession([100_000, 2, 0, 1, 0, 4, 3, 4, 3, 2])
result = VectorFeatureService.summarize_features_by_bbox(
session,
dataset=dataset,
bbox=BBOX,
total_feature_count=3,
full_dataset_area=True,
)
metrics = {item["metric_key"]: item for item in result["metrics"]}
assert result["metric_value"] == 10.0
assert metrics["registered_building_count"]["metric_value"] == 3
assert metrics["realized_building_count"]["metric_value"] == 2
assert metrics["building_unit_count"]["metric_value"] == 4
assert metrics["active_address_count"]["metric_value"] == 3
assert metrics["grb_matched_building_count"]["metric_value"] == 2
VectorSelectionSummary(**result)
def test_operator_is_canonical_packaged_and_mol_scoped_in_explorer() -> None:
operator = (ROOT / "scripts/provision_buildings_addresses_register.py").read_text(encoding="utf-8")
service = (ROOT / "backend/app/services/vector_feature_service.py").read_text(encoding="utf-8")
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")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
catalog = (ROOT / "frontend/src/components/datasets/SourceCatalogPanel.tsx").read_text(encoding="utf-8")
display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8")
assert "/datasets/upload" in operator
assert "VectorFeature" not in operator
assert "INSERT INTO vector_features" not in operator
assert "VolledigAdres" in operator and '"VolledigAdres", "Straatnaam"' in operator
assert '"provision_buildings_addresses_register.py"' in service
assert "COPY scripts/provision_buildings_addresses_register.py" in dockerfile
assert "py_compile scripts/provision_buildings_addresses_register.py" in readiness
assert "datasetCoversSelectedArea" in workspace
assert "Gebouwen- en Adressenregister" in catalog
assert "building_registry: 'Gebouwenregister'" in display
assert "digitaal_vlaanderen_buildings_addresses_register: 'Digitaal Vlaanderen'" in display
+1
View File
@@ -80,6 +80,7 @@ COPY scripts/provision_official_landuse_timeseries.py /app/scripts/provision_off
COPY scripts/provision_waterinfo_station_history.py /app/scripts/provision_waterinfo_station_history.py
COPY scripts/provision_mol_bwk_natura2000.py /app/scripts/provision_mol_bwk_natura2000.py
COPY scripts/provision_agricultural_parcel_history.py /app/scripts/provision_agricultural_parcel_history.py
COPY scripts/provision_buildings_addresses_register.py /app/scripts/provision_buildings_addresses_register.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
+15
View File
@@ -1639,6 +1639,21 @@ Datasets share one scope-specific temporal series, but declare
`identity_stable=false`; their temporal response compares area totals and
returns no parcel-level added/removed/modified claims.
For `reference_layer_name=building_registry`, the primary metric is exact
intersected building-footprint area in hectares. Supplemental server-owned
metrics expose register building/status counts, aggregate building-unit and
address-status counts, and confirmed GRB matches. Filtered `feature_count`
metrics apply their configured property filter in PostGIS just like filtered
area/sum metrics. Address labels and house/box numbers are never part of the
queryable Feature properties or response contract.
The governed snapshot is scoped to its persisted `area_id`. The frontend may
prefer it over the regional GRB building layer only when that exact Area is
active; another municipality or the full region must continue to use the
regional GRB Dataset. No new register-specific API endpoint exists: upload,
GeoJSON, exact selection and temporal provenance use the existing Dataset
contracts.
## Local GeoIntel assistant
The assistant is an optional read-only language interface over persisted
+38 -6
View File
@@ -329,12 +329,44 @@ quality metrics.
## Gebouwenregister
- Naam: Gebouwenregister Vlaanderen
- Type: vector/API/metadata
- Gebruik: gebouwmetadata en statusinformatie
- Toegang: nader te bepalen
- Cache: PostGIS/metadata tabellen
- Prioriteit: V2
The governed operator `scripts/provision_buildings_addresses_register.py`
reads the official Digitaal Vlaanderen OGC API Features collections
`Gebouw`, `Gebouweenheid` and `Adres`. The source is continuously updated;
GeoIntel therefore creates a dated snapshot rather than claiming an annual
historical series. Raw response pages, request URLs and SHA256 checksums are
retained as operator evidence.
Building polygons are clipped against the exact persisted Area in
EPSG:31370, transformed to EPSG:4326 and persisted through the normal Dataset
upload route with `source_name=digitaal_vlaanderen_buildings_addresses_register`
and `reference_layer_name=building_registry`. Register lifecycle state remains
separate from GRB geometry. Each register building receives an explicit
`matched`, `review`, `ambiguous` or `unmatched` GRB reconciliation result; a
low-confidence or duplicate match is never silently promoted.
Building units use the official `GebouwObjectId` relation. The public address
collection does not expose that relation directly, so addresses are linked
only through an exact, unambiguous unit position or unambiguous polygon
containment. Ambiguous and unmatched rows are counted in the manifest and are
never forced to the nearest building.
The queryable layer contains building polygons, lifecycle status and aggregate
unit/address counts only. Street names, full addresses, house numbers and box
numbers are excluded. Address counts are not households, dwellings, residents
or population. Raw source pages remain restricted operator evidence and are
not exposed by the API or map.
Official endpoints and catalogues:
- https://geo.api.vlaanderen.be/Gebouwenregister/ogc/features/v1/collections/Gebouw/items
- https://geo.api.vlaanderen.be/Gebouwenregister/ogc/features/v1/collections/Gebouweenheid/items
- https://geo.api.vlaanderen.be/Adressenregister/ogc/features/v1/collections/Adres/items
- https://www.vlaanderen.be/datavindplaats/catalogus/gebouwen-en-adressenregister
- https://www.vlaanderen.be/datavindplaats/catalogus/gebouwenregister
Digitaal Vlaanderen is transitioning the download products in summer 2026.
This operator uses the current production OGC API and does not depend on the
retiring `/v2/extract` path.
## Lokale demo datasets
+29 -8
View File
@@ -61,22 +61,43 @@ GRB-gebouwpolygonen worden gebruikt als ground-truth proxy. Niet absoluut perfec
### Rol
Aanvullende gebouwinformatie bij geometrieën.
Authoritative register lifecycle and relation metadata attached to a dated
building snapshot. It complements GRB; it does not replace the separate GRB
footprint evidence or its QA role.
### Gebruik
- gebouwmetadata
- identificatie
- status
- koppeling met GRB-gebouwpolygonen indien mogelijk
- stable building object and version identity
- official lifecycle status and geometry method
- aggregate registered building-unit counts by lifecycle status
- aggregate address counts by register status
- classified reconciliation against persisted GRB geometry
### Type
Vector/API/metadata.
Official OGC API Features input, normalized polygon GeoJSON upload and
PostGIS `vector_features` output.
### Prioriteit
### Persisted contract
V2 of V1.5.
- `dataset_role=reference`
- `source_name=digitaal_vlaanderen_buildings_addresses_register`
- `reference_layer_name=building_registry`
- `temporal_granularity=snapshot`
- stable building identity within the register, but no fabricated historical
observation between snapshots
- polygon geometry clipped in EPSG:31370 and persisted as EPSG:4326
- selection metrics: exact footprint hectares, building lifecycle counts,
unit counts, address-status counts and confirmed GRB match counts
### Privacy and semantics
Queryable properties may contain register object ids, lifecycle status,
geometry method, aggregate unit/address counts and GRB reconciliation evidence.
They must not contain `VolledigAdres`, `Straatnaam`, `Huisnummer`,
`HuisnummerLabel` or `Busnummer`. Address and unit counts may not be labelled
as population, residents, households or dwellings. Building footprint is
ground area, not floor area, height or volume.
## OpenStreetMap
+21
View File
@@ -133,6 +133,27 @@ artifact; Dataset and vector_feature rows remain the queryable PostGIS state.
The manifest binds source, crop-code list and upload artifact checksums. A
checksum conflict with an existing annual Dataset fails closed.
Buildings and Addresses Register snapshot evidence lives under:
```text
storage/operator-evidence/buildings-addresses-register/mol/{observed-date}/
raw/
buildings_page_*.json
building_units_page_*.json
addresses_page_*.json
buildings_addresses_register.geojson
buildings_addresses_register.manifest.json
```
Raw pages contain the unmodified official response and are retained only as
checksummed operator evidence. They can contain address labels and must never
be served as a map/API artifact. The normalized GeoJSON deliberately contains
one polygon per register building with lifecycle state, aggregate relation
counts and classified GRB reconciliation only. It enters PostGIS exclusively
through DatasetService and ordinary `vector_features`; the operator never
writes database rows directly. The manifest binds all raw pages, the normalized
artifact, exact Area boundary and every GRB partition used for reconciliation.
Offline demo export artifacts can be inspected and cleaned with:
```bash
+8
View File
@@ -484,6 +484,14 @@ Object additions/removals stay hidden because annual parcel identity is not
stable. The Sources inventory only labels the series available after real
Datasets exist.
When the governed Buildings and Addresses Register snapshot is loaded for Mol,
the `Bebouwing` theme automatically prefers that richer Dataset only while the
exact Mol Area is active. It shows register lifecycle, unit/address aggregate
metrics and GRB reconciliation without exposing address labels. Selecting
another municipality or the complete Kempen scope falls back to the complete
regional GRB building layer. This avoids presenting a Mol-only snapshot as
regional coverage.
## Useful repository scripts
- `bash scripts/frontend_install.sh`
@@ -108,6 +108,12 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
)
const bwkDatasets = ready.filter((dataset) => dataset.source_name === 'inbo_bwk_natura2000')
const agricultureDatasets = ready.filter((dataset) => dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels')
const buildingsRegisterDatasets = ready.filter(
(dataset) => dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register',
)
const latestBuildingsRegister = [...buildingsRegisterDatasets].sort(
(left, right) => new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime(),
)[0]
const agricultureYears = agricultureDatasets
.flatMap((dataset) => dataset.observed_at ? [new Date(dataset.observed_at).getUTCFullYear()] : [])
const pendingSources = AVAILABLE_SOURCES.filter((source) => {
@@ -115,6 +121,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
if (source.key === 'historical_orthophoto') return historicalOrthophotos.length === 0
if (source.key === 'bwk') return bwkDatasets.length === 0
if (source.key === 'agriculture') return agricultureDatasets.length === 0
if (source.key === 'buildings_register') return buildingsRegisterDatasets.length === 0
return true
})
const themes = Object.keys(THEME_LABELS).map((theme) => {
@@ -174,7 +181,7 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
))}
</div>
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 ? (
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 ? (
<div className="source-catalog-loaded" aria-label="Aanvullende ingeladen bronnen">
{waterinfoDatasets.length > 0 ? (
<article>
@@ -204,6 +211,17 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
<p>Oppervlakte en officiële hoofdteeltgroepen zijn historisch vergelijkbaar; perceelidentiteiten blijven bewust niet gekoppeld tussen jaren.</p>
</article>
) : null}
{latestBuildingsRegister ? (
<article>
<strong>Gebouwen- en Adressenregister</strong>
<span>
{(latestBuildingsRegister.feature_count ?? 0).toLocaleString('nl-BE')} gebouwen · {' '}
{Number(latestBuildingsRegister.source_metadata?.['building_unit_count'] ?? 0).toLocaleString('nl-BE')} eenheden · {' '}
{Number(latestBuildingsRegister.source_metadata?.['linked_address_count'] ?? 0).toLocaleString('nl-BE')} gekoppelde adressen
</span>
<p>Registerstatus en geaggregeerde koppelingen voor Mol; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
</article>
) : null}
</div>
) : null}
+20 -5
View File
@@ -120,8 +120,22 @@ function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme):
return theme.tokens.some((token) => searchText.includes(token))
}
function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme): DatasetCreateResponse | null {
const candidates = datasets.filter((dataset) => datasetMatchesTheme(dataset, theme))
function datasetCoversSelectedArea(dataset: DatasetCreateResponse, selectedAreaId: string | null): boolean {
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
if (coverageScope !== 'municipality' || !dataset.area_id) {
return true
}
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
function pickThemeDataset(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
): DatasetCreateResponse | null {
const candidates = datasets.filter(
(dataset) => datasetMatchesTheme(dataset, theme) && datasetCoversSelectedArea(dataset, selectedAreaId),
)
candidates.sort((left, right) => {
const score = (dataset: DatasetCreateResponse) =>
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
@@ -129,6 +143,7 @@ function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme):
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 120_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0) +
(dataset.observed_at ? new Date(dataset.observed_at).getTime() / 100_000_000 : 0) +
(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0)
@@ -636,9 +651,9 @@ export function MapWorkspace({
const themeDatasetMap = useMemo(
() =>
Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme)]),
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]),
) as Record<DataThemeId, DatasetCreateResponse | null>,
[availableMapDatasets],
[availableMapDatasets, selectedMapAreaId],
)
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
@@ -740,7 +755,7 @@ export function MapWorkspace({
}, [activeTemporalSeries])
useEffect(() => {
if (advancedMode || !activeThemeDataset || (selectedMapDataset && datasetMatchesTheme(selectedMapDataset, activeTheme))) {
if (advancedMode || !activeThemeDataset || selectedMapDataset?.id === activeThemeDataset.id) {
return
}
onOpenDatasetInMap(activeThemeDataset)
+2
View File
@@ -9,6 +9,7 @@ const DATASET_LABEL_BY_LAYER: Record<string, string> = {
forest: 'Bos en groen',
nature_value: 'Natuurwaarde',
agriculture: 'Landbouwgebruikspercelen',
building_registry: 'Gebouwenregister',
regional_boundary: 'Grens vervoerregio Kempen',
municipality_boundaries: 'Gemeentegrenzen Kempen',
}
@@ -17,6 +18,7 @@ const DATASET_SOURCE_LABELS: Record<string, string> = {
department_omgeving_land_use: 'Departement Omgeving',
agentschap_landbouw_zeevisserij_agricultural_parcels: 'Agentschap Landbouw en Zeevisserij',
digitaal_vlaanderen_orthophoto: 'Digitaal Vlaanderen',
digitaal_vlaanderen_buildings_addresses_register: 'Digitaal Vlaanderen',
grb: 'GRB',
historical_landuse: 'Digitaal Vlaanderen',
inbo_bwk_natura2000: 'INBO',
+27
View File
@@ -1481,6 +1481,33 @@ Datasets. `--force` refreshes retained evidence but cannot silently replace a
conflicting persisted annual checksum. Use `--scope mol` for an independent
municipal series.
## Buildings and Addresses Register snapshot
Prepare and audit the current official Mol snapshot without persistence:
```bash
docker exec geointel python /app/scripts/provision_buildings_addresses_register.py --fetch-only
```
Import the audited artifact through the canonical Dataset upload route:
```bash
docker exec geointel python /app/scripts/provision_buildings_addresses_register.py
```
The operator requires a persisted Mol Area and the complete regional GRB
buildings Dataset with valid manifest/partition checksums. It reads only the
official `Gebouw`, `Gebouweenheid` and `Adres` OGC collections, clips in
EPSG:31370 and retains every raw response page under the storage volume.
`--force` refetches evidence; it cannot overwrite a conflicting snapshot for
the same Area/date. Safety limits are configurable with `--page-limit`,
`--max-buildings`, `--max-units` and `--max-addresses`.
Only aggregate unit/address counts enter the queryable building layer. Review
`address_relations`, `grb_reconciliation`, checksums and limitations in the
manifest before accepting a broader import. Raw address response pages are
operator evidence and must not be published.
## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
File diff suppressed because it is too large Load Diff
+1
View File
@@ -50,6 +50,7 @@ ${PYTHON_BIN} -m py_compile scripts/provision_official_landuse_timeseries.py
${PYTHON_BIN} -m py_compile scripts/provision_waterinfo_station_history.py
${PYTHON_BIN} -m py_compile scripts/provision_mol_bwk_natura2000.py
${PYTHON_BIN} -m py_compile scripts/provision_agricultural_parcel_history.py
${PYTHON_BIN} -m py_compile scripts/provision_buildings_addresses_register.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