Beschikbare Vlaamse kaartbronnen worden gecontroleerd…
) : null} - {officialMapProductsError && flandersScopeSelected ? ( + {officialMapProductsError && selectedProjectId ? ({officialMapProductsError}
) : null} @@ -1877,7 +2012,12 @@ export function MapWorkspace({ (item) => item.source_name === 'digitaal_vlaanderen_dhmv' && datasetProductKey(item) === productKey - && datasetCoversSelectedArea(item, selectedMapAreaId, regionalScopeSelected), + && datasetCoversSelectedArea( + item, + selectedMapAreaId, + selectedMapArea?.name, + regionalScopeSelected, + ), ) if (dataset) { onOpenDatasetInMap(dataset) diff --git a/frontend/src/components/map/mapWorkspaceUtils.test.ts b/frontend/src/components/map/mapWorkspaceUtils.test.ts index 6d565141..eb7fade0 100644 --- a/frontend/src/components/map/mapWorkspaceUtils.test.ts +++ b/frontend/src/components/map/mapWorkspaceUtils.test.ts @@ -3,7 +3,9 @@ import { bboxesEqual, normalizeBboxFromCorners, parseBboxInput, + productCoversZones, resultMetricLabel, + selectedAreaCoverageZones, selectionAreaSquareMetres, } from './mapWorkspaceUtils' import type { VectorSelectionResponse } from '../../types' @@ -47,4 +49,18 @@ describe('map workspace selection guards', () => { } as unknown as VectorSelectionResponse expect(resultMetricLabel(result)).toBe('14,24 ha') }) + + it('maps national work areas to regional provider zones without merging sources', () => { + expect(selectedAreaCoverageZones('Belgium land')).toEqual([ + 'belgium', + 'flanders', + 'wallonia', + 'brussels', + ]) + expect(selectedAreaCoverageZones('RC Ardennes inland')).toEqual(['wallonia']) + expect(selectedAreaCoverageZones('Brussels-Capital Region')).toEqual(['brussels']) + expect(selectedAreaCoverageZones('Language boundary')).toEqual(['flanders', 'wallonia']) + expect(productCoversZones(['wallonia'], ['flanders', 'wallonia'])).toBe(true) + expect(productCoversZones(['brussels'], ['wallonia'])).toBe(false) + }) }) diff --git a/frontend/src/components/map/mapWorkspaceUtils.ts b/frontend/src/components/map/mapWorkspaceUtils.ts index 41912718..14897476 100644 --- a/frontend/src/components/map/mapWorkspaceUtils.ts +++ b/frontend/src/components/map/mapWorkspaceUtils.ts @@ -13,6 +13,28 @@ import { const MOL_PROJECT_NAME = 'Mol Municipality Workbench' const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench' +export function selectedAreaCoverageZones(areaName: string | null | undefined): string[] | null { + const normalized = String(areaName ?? '').toLowerCase() + if (!normalized) return null + if (normalized.includes('coast land-sea')) return ['flanders', 'belgian_north_sea'] + if (normalized.includes('language boundary')) return ['flanders', 'wallonia'] + if (normalized.includes('north sea')) { + return ['belgian_north_sea', 'territorial_sea', 'exclusive_economic_zone', 'continental_shelf'] + } + if (normalized.includes('territorial sea')) return ['territorial_sea'] + if (normalized.includes('exclusive economic zone')) return ['exclusive_economic_zone'] + if (normalized.includes('continental shelf')) return ['continental_shelf'] + if (normalized.includes('brussels')) return ['brussels'] + if (normalized.includes('wallonia') || normalized.includes('ardennes')) return ['wallonia'] + if (normalized.includes('flanders') || normalized.includes('mol') || normalized.includes('kempen')) return ['flanders'] + if (normalized.includes('belgium land')) return ['belgium', 'flanders', 'wallonia', 'brussels'] + return null +} + +export function productCoversZones(productZones: string[], selectedZones: string[] | null): boolean { + return selectedZones === null || selectedZones.some((zone) => productZones.includes(zone)) +} + export function operationalScopeProjectLabel(project: ProjectRead): string { if (project.name === MOL_PROJECT_NAME) { return 'Mol' diff --git a/frontend/src/hooks/useOfficialMapProducts.ts b/frontend/src/hooks/useOfficialMapProducts.ts index b5ff8a83..2e43e26b 100644 --- a/frontend/src/hooks/useOfficialMapProducts.ts +++ b/frontend/src/hooks/useOfficialMapProducts.ts @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { datasetsApi } from '../services/api' +import { useCallback, useEffect, useState } from 'react' +import { datasetsApi, externalApi } from '../services/api' import { formatError } from '../lib/formatError' import type { DhmvProductRead, @@ -9,7 +9,7 @@ import type { ThematicRasterProductRead, } from '../types' -interface OfficialMapProducts { +export interface OfficialMapProducts { thematic: ThematicRasterProductRead[] dhmv: DhmvProductRead[] floodHazard: FloodHazardProductRead[] @@ -78,5 +78,26 @@ export function useOfficialMapProducts(selectedProjectId: string | null) { } }, [selectedProjectId]) - return { products, loading, error } + const resolveCoverage = useCallback( + async (bbox: { minx: number; miny: number; maxx: number; maxy: number }) => { + if (!selectedProjectId) { + setError('Selecteer eerst een werkruimte.') + return null + } + try { + const coverage = await externalApi.resolveCoverage({ + projectId: selectedProjectId, + bbox, + }) + setError(null) + return coverage + } catch (requestError) { + setError(formatError(requestError, 'De regionale databronnen konden niet veilig worden bepaald.')) + return null + } + }, + [selectedProjectId], + ) + + return { products, loading, error, resolveCoverage } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 675463dd..87eeb93d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -393,11 +393,11 @@ export interface OfficialVectorAcquireRequest { export interface OfficialVectorProductRead { key: string display_name: string - theme: 'nature_value' | 'soil' + theme: 'buildings' | 'roads' | 'water' | 'parcels' | 'nature_value' | 'soil' provider: string source_name: string reference_layer_name: string - service_type: 'OGC API Features' | 'WFS 2.0' + service_type: 'OGC API Features' | 'WFS 2.0' | 'ArcGIS REST' collection: string geometry_types: string[] source_crs: string @@ -408,6 +408,7 @@ export interface OfficialVectorProductRead { attribution: string license_note: string limitation_message: string + coverage_zones: string[] } export interface TerrainSelectionResponse { diff --git a/scripts/geographic_scopes.py b/scripts/geographic_scopes.py index f5162c13..cef6bd18 100644 --- a/scripts/geographic_scopes.py +++ b/scripts/geographic_scopes.py @@ -27,6 +27,7 @@ class GeographicScope: scope_type: str limitation_message: str members: tuple[ScopeMember, ...] + all_municipalities: bool = False @property def nis_codes(self) -> tuple[str, ...]: @@ -96,18 +97,37 @@ KEMPEN_TRANSPORT_REGION_SCOPE = GeographicScope( ), ) +BELGIUM_SCOPE = GeographicScope( + key="belgium", + display_name="België", + project_name="Belgium and North Sea Workbench", + project_region="Belgium and Belgian North Sea", + area_name="Belgium land", + authority_name="National Geographic Institute (NGI), AdminVector", + authority_url="https://www.geo.be/catalog/details/fb1e2993-2020-428c-9188-eb5f75e284b9", + scope_type="country", + limitation_message=( + "De nationale Statbel-sectorlaag dekt het Belgische landgebied. " + "Belgische maritieme zones bevatten geen statistische bevolkingssectoren." + ), + members=(), + all_municipalities=True, +) + GEOGRAPHIC_SCOPES = { scope.key: scope - for scope in (MOL_SCOPE, KEMPEN_TRANSPORT_REGION_SCOPE) + for scope in (MOL_SCOPE, KEMPEN_TRANSPORT_REGION_SCOPE, BELGIUM_SCOPE) } def validate_scope(scope: GeographicScope) -> None: if not scope.key or not scope.project_name or not scope.area_name: raise ValueError("Geographic scope identity fields must not be empty") - if len(scope.members) == 0: + if len(scope.members) == 0 and not scope.all_municipalities: raise ValueError(f"Geographic scope {scope.key} has no members") + if scope.all_municipalities and scope.scope_type != "country": + raise ValueError(f"Geographic scope {scope.key} can include all municipalities only for a country scope") names = [member.name.casefold() for member in scope.members] codes = [member.nis_code for member in scope.members] if len(names) != len(set(names)): diff --git a/scripts/manage_statbel_population_release.py b/scripts/manage_statbel_population_release.py index aabf9a5f..47a77341 100644 --- a/scripts/manage_statbel_population_release.py +++ b/scripts/manage_statbel_population_release.py @@ -50,7 +50,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("action", choices=("plan", "stage", "review", "apply")) parser.add_argument("--project-id", required=True, help="GeoIntel project UUID for the governed scope") - parser.add_argument("--scope", choices=(DEFAULT_SCOPE,), default=DEFAULT_SCOPE) + parser.add_argument("--scope", choices=tuple(sorted(GEOGRAPHIC_SCOPES)), default=DEFAULT_SCOPE) parser.add_argument("--api-url", default=os.environ.get("GEOINTEL_API_URL", DEFAULT_API_URL)) parser.add_argument("--confirm-edition", help="Exact official population year required after plan") parser.add_argument("--confirm-layout", choices=("standard", "new"), help="Exact planned REDEGEO layout") diff --git a/scripts/provision_belgium_north_sea_scope.py b/scripts/provision_belgium_north_sea_scope.py index be459edc..680b2575 100644 --- a/scripts/provision_belgium_north_sea_scope.py +++ b/scripts/provision_belgium_north_sea_scope.py @@ -99,6 +99,15 @@ AREA_NAMES = { "continental_shelf": "Belgian continental shelf beyond territorial sea", } +THEME_BY_LAYER = { + "belgium_land_boundary": "administrative", + "belgium_regions": "administrative", + "belgium_provinces": "administrative", + "belgium_municipalities": "administrative", + "marine_legal_scopes": "marine_environment", + "marine_spatial_plan_2026": "maritime_planning", +} + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Provision the Belgium and Belgian North Sea scope.") @@ -656,6 +665,7 @@ def _upload_dataset( source_metadata = { "provider": source_name, "authority_level": "authoritative", + "theme": THEME_BY_LAYER[reference_layer_name], "coverage_zones": coverage_zones, "reference_layer_name": reference_layer_name, "source_url": source_url, diff --git a/scripts/provision_mol_population_history.py b/scripts/provision_mol_population_history.py index 96a812f5..496712a2 100644 --- a/scripts/provision_mol_population_history.py +++ b/scripts/provision_mol_population_history.py @@ -255,6 +255,23 @@ def resolve_boundary_path(args: argparse.Namespace, scope: GeographicScope) -> P return Path(legacy) if scope.key == "mol" and DEFAULT_BOUNDARY_PATH.exists(): return DEFAULT_BOUNDARY_PATH + if scope.key == "belgium": + national_scope_dir = args.scope_output_root / "belgium-north-sea" + manifest_path = national_scope_dir / "manifest.json" + boundary_path = national_scope_dir / "belgium_land_boundary.geojson" + if not manifest_path.is_file() or not boundary_path.is_file(): + raise RuntimeError( + "Official Belgium boundary evidence is missing; " + "run provision_belgium_north_sea_scope.py --fetch-only first" + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + artifact = (manifest.get("artifacts") or {}).get("belgium_land_boundary") or {} + if ( + manifest.get("scope") != "belgium-and-belgian-north-sea" + or artifact.get("sha256") != sha256_path(boundary_path) + ): + raise RuntimeError("Official Belgium boundary evidence does not match the retained manifest") + return 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(): @@ -330,13 +347,14 @@ def scoped_population_rows(source_rows: list[dict[str, Any]], scope: GeographicS if sector_code in seen: raise RuntimeError(f"Statbel population table contains duplicate sector {sector_code}") seen.add(sector_code) - if nis_code not in members: + if not scope.all_municipalities and nis_code not in members: continue + municipality_name = members.get(nis_code) or str(row.get("TX_DESCR_NL") or nis_code).strip() rows[sector_code] = { "population_total": int(total_raw), "sector_name_nl": row.get("TX_DESCR_SECTOR_NL"), "municipality_name_nl": row.get("TX_DESCR_NL"), - "municipality": members[nis_code], + "municipality": municipality_name, "nis_code": nis_code, } if not rows: @@ -358,7 +376,10 @@ def build_snapshot( missing_population = 0 for source_feature in sector_payload.get("features") or []: properties = source_feature.get("properties") or {} - if str(properties.get("cd_munty_refnis") or "") not in member_codes: + if ( + not scope.all_municipalities + and str(properties.get("cd_munty_refnis") or "") not in member_codes + ): continue sector_code = str(properties.get("cd_sector") or "").strip() population_values = population.get(sector_code) @@ -406,8 +427,8 @@ def build_snapshot( "features": features, "coverage_scope": scope.key, "scope_type": scope.scope_type, - "member_count": len(scope.members), - "member_nis_codes": list(scope.nis_codes), + "member_count": int(accounting.get("member_count") or len(scope.members)), + "member_nis_codes": list(accounting.get("member_nis_codes") or scope.nis_codes), "geometry_clipped_to_area": True, "observation_year": year, "missing_population_sector_count": missing_population, @@ -594,14 +615,21 @@ def upload_snapshot( observed_at = f"{year}-01-01T00:00:00Z" preflight = load_preflight_manifest(preflight_path, path, year, scope) accounting = preflight["scope_accounting"] + coverage_zones = ( + ["belgium", "flanders", "wallonia", "brussels"] + if scope.key == "belgium" + else ["flanders"] + ) source_metadata = { "provider": "Statbel", "authority_level": "authoritative", + "theme": "population", + "coverage_zones": coverage_zones, "coverage_scope": scope.key, "scope_type": scope.scope_type, "scope_display_name": scope.display_name, - "member_count": len(scope.members), - "member_nis_codes": list(scope.nis_codes), + "member_count": int(accounting.get("member_count") or len(scope.members)), + "member_nis_codes": list(accounting.get("member_nis_codes") or scope.nis_codes), "geometry_clipped_to_area": True, "attribution": ATTRIBUTION, "license": "CC BY 4.0", @@ -742,6 +770,7 @@ def main() -> int: "path": path, "manifest_path": manifest_path if manifest_path.is_file() else None, "feature_count": len(payload.get("features") or []), + "member_count": int(payload.get("member_count") or len(scope.members)), "preflight_status": preflight_status, } ) @@ -814,7 +843,10 @@ def main() -> int: "status": "ok", "scope": scope.key, "display_name": scope.display_name, - "member_count": len(scope.members), + "member_count": max( + (int(item.get("member_count") or 0) for item in prepared), + default=len(scope.members), + ), "series": series_key(scope), "snapshots": results, }, diff --git a/scripts/statbel_population_preflight.py b/scripts/statbel_population_preflight.py index 09f8b364..0c978ceb 100644 --- a/scripts/statbel_population_preflight.py +++ b/scripts/statbel_population_preflight.py @@ -509,6 +509,10 @@ def parse_geometry_archive(content: bytes, year: int) -> GeometryArchiveData: def _validate_scope(scope: GeographicScope, geometry: GeometryArchiveData, population: PopulationArchiveData) -> None: requested = set(scope.nis_codes) + if scope.all_municipalities: + if not geometry.municipality_by_sector or not population.rows: + _fail("STATBEL_SCOPE_REJECTED", "National geographic scope has no source municipalities.") + return if not requested or any(not MUNICIPALITY_CODE_PATTERN.fullmatch(value) for value in requested): _fail("STATBEL_SCOPE_REJECTED", "Approved geographic scope contains invalid NIS codes.") geometry_codes = set(geometry.municipality_by_sector.values()) @@ -540,7 +544,16 @@ def _baseline_summary(path: Path | None, *, year: int, scope: GeographicScope) - baseline_year=baseline_year, candidate_year=year, ) - if set(str(value) for value in payload.get("member_nis_codes") or []) != set(scope.nis_codes): + baseline_scope = payload.get("coverage_scope") + if ( + (scope.all_municipalities and baseline_scope != scope.key) + or (not scope.all_municipalities and baseline_scope not in {None, scope.key}) + ): + _fail("STATBEL_BASELINE_SCOPE_MISMATCH", "Baseline snapshot does not use the same approved scope.") + if ( + not scope.all_municipalities + and set(str(value) for value in payload.get("member_nis_codes") or []) != set(scope.nis_codes) + ): _fail("STATBEL_BASELINE_SCOPE_MISMATCH", "Baseline snapshot does not use the same approved scope.") features = payload.get("features") if not isinstance(features, list) or not features: @@ -625,7 +638,11 @@ def validate_statbel_release( national_unlocated_total = sum(int(population.rows[code]["TOTAL"]) for code in population_without_geometry) if national_spatial_total + national_unlocated_total != population.population_total: _fail("STATBEL_TOTAL_RECONCILIATION_FAILED", "National population accounting does not reconcile.") - requested = set(scope.nis_codes) + requested = ( + set(geometry.municipality_by_sector.values()) + if scope.all_municipalities + else set(scope.nis_codes) + ) scope_spatial_codes = { code for code in geometry_codes if geometry.municipality_by_sector[code] in requested } @@ -700,8 +717,8 @@ def validate_statbel_release( "scope_accounting": { "scope_key": scope.key, "scope_display_name": scope.display_name, - "member_count": len(scope.members), - "member_nis_codes": list(scope.nis_codes), + "member_count": len(requested), + "member_nis_codes": sorted(requested), "spatial_sector_count": len(scope_spatial_codes), "spatial_population_total": scope_spatial_total, "unlocated_row_count": len(scope_unlocated_codes),