diff --git a/.env.example b/.env.example
index d6baff7f..f981ad33 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,18 @@ GRB_TIMEOUT_SECONDS=180
GRB_MAX_RESPONSE_MB=20
GRB_MAX_TOTAL_RESPONSE_MB=256
GRB_CACHE_TTL_HOURS=24
+OFFICIAL_VECTOR_ENABLED=true
+BWK_WFS_URL=https://geo.api.vlaanderen.be/BWK/wfs
+DOV_SOIL_WFS_URL=https://www.dov.vlaanderen.be/geoserver/wfs
+OFFICIAL_VECTOR_MIN_SIDE_M=10
+OFFICIAL_VECTOR_MAX_SIDE_M=20000
+OFFICIAL_VECTOR_PAGE_SIZE=1000
+OFFICIAL_VECTOR_MAX_PAGES=200
+OFFICIAL_VECTOR_MAX_FEATURES=100000
+OFFICIAL_VECTOR_TIMEOUT_SECONDS=180
+OFFICIAL_VECTOR_MAX_RESPONSE_MB=20
+OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB=256
+OFFICIAL_VECTOR_CACHE_TTL_HOURS=24
SOURCE_CATALOG_STATBEL_DCAT_URL=https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl
SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5
SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05633796..8dcc55f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,25 @@
# Changelog
+## Sprint 240 Operational forest, agriculture, nature and soil themes (2026-07-17)
+
+- Extended the governed Landgebruik Vlaanderen 2025 raster registry with
+ binary forest (class 12) and agricultural-use (classes 13 and 14) products.
+- Added bounded INBO BWK/Natura 2000 and DOV soil acquisition with exact
+ `bbox intersection Area` clipping, complete provider pagination, hard
+ response/feature limits, checksums, request-identity caching and canonical
+ Dataset/VectorFeature persistence.
+- Added end-user hectare metrics for forest, agricultural land use, biological
+ value classes, habitat shares and historical soil classes. PHAB-derived
+ hectares remain visibly estimated.
+- Exposed all four themes through the existing Flanders map selection flow.
+ Provider calls remain backend-only and full-Flanders monolithic requests
+ remain outside the bounded safety limits.
+- Kept source semantics explicit: land-use agriculture is not the definitive
+ ALZ parcel declaration series, forest is not a legal forest boundary or
+ biomass model, and DOV soil is a 1949-1971 historical baseline rather than
+ a current site investigation.
+
## Sprint 239 Governed bounded GRB map acquisition (2026-07-17)
- Added a fixed four-product GRB registry for buildings, roads, water and
diff --git a/backend/README.md b/backend/README.md
index b19dd7a5..789f1b7f 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1803,3 +1803,24 @@ readiness state such as TLS or endpoint failure. Runtime controls are
`MDK_BATHYMETRY_PROBE_ENABLED`, `MDK_BATHYMETRY_WCS_URL`,
`MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS` and
`MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB`. TLS verification cannot be disabled.
+
+## Governed forest, agriculture, nature and soil acquisition
+
+The thematic raster registry includes forest and agricultural land-use masks
+derived from Landgebruik Vlaanderen 2025 classes 12 and 13/14. They use the
+existing thematic acquisition and selection routes.
+
+Two polygon products are exposed through
+`/datasets/official-vector/products` and
+`/datasets/official-vector/acquire`: INBO BWK/Natura 2000 2025 and the DOV
+digital soil map. Both require an EPSG:4326 rectangle, optionally intersect it
+with a persisted Area, clip in EPSG:31370 and persist through
+`DatasetService.import_vector_bytes`.
+
+Runtime controls are `OFFICIAL_VECTOR_ENABLED`, `BWK_WFS_URL`,
+`DOV_SOIL_WFS_URL`, `OFFICIAL_VECTOR_MIN_SIDE_M`,
+`OFFICIAL_VECTOR_MAX_SIDE_M`, `OFFICIAL_VECTOR_PAGE_SIZE`,
+`OFFICIAL_VECTOR_MAX_PAGES`, `OFFICIAL_VECTOR_MAX_FEATURES`,
+`OFFICIAL_VECTOR_TIMEOUT_SECONDS`, `OFFICIAL_VECTOR_MAX_RESPONSE_MB`,
+`OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB` and
+`OFFICIAL_VECTOR_CACHE_TTL_HOURS`.
diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py
index d46a1a0f..96435431 100644
--- a/backend/app/api/routes/datasets.py
+++ b/backend/app/api/routes/datasets.py
@@ -32,6 +32,7 @@ from app.schemas import (
ThematicRasterAcquireRequest,
ThematicRasterSelectionRequest,
GrbAcquireRequest,
+ OfficialVectorAcquireRequest,
VectorBBoxResponse,
VectorBufferRequest,
VectorClipRequest,
@@ -51,6 +52,7 @@ from app.services.source_freshness_service import SourceFreshnessService
from app.services.source_catalog_probe_service import SourceCatalogProbeService
from app.services.grb_refresh_plan_service import GrbRefreshPlanService
from app.services.grb_acquisition_service import GrbAcquisitionService
+from app.services.official_vector_acquisition_service import OfficialVectorAcquisitionService
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
from app.services.dhmv_acquisition_service import DhmvAcquisitionService
from app.services.terrain_analysis_service import TerrainAnalysisService
@@ -219,6 +221,30 @@ def list_grb_products(project_id: UUID, db: Session = Depends(get_db)):
return envelope({"items": items, "total": len(items)})
+@router.post("/datasets/official-vector/acquire", response_model=dict)
+def acquire_bounded_official_vector(
+ project_id: UUID,
+ payload: OfficialVectorAcquireRequest,
+ db: Session = Depends(get_db),
+):
+ job = JobService.run_sync_job(
+ db=db,
+ project_id=project_id,
+ job_type="vector.official.acquire",
+ parameters=payload.model_dump(mode="json"),
+ operation=lambda: OfficialVectorAcquisitionService.acquire(db, project_id, payload),
+ )
+ return envelope(job)
+
+
+@router.get("/datasets/official-vector/products", response_model=dict)
+def list_official_vector_products(project_id: UUID, db: Session = Depends(get_db)):
+ if not db.get(Project, project_id):
+ raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
+ items = OfficialVectorAcquisitionService.list_products()
+ return envelope({"items": items, "total": len(items)})
+
+
@router.post("/datasets/flood-hazard/acquire", response_model=dict)
def acquire_bounded_flood_hazard(
project_id: UUID,
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 20878e09..c9f8da17 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -87,6 +87,66 @@ class Settings(BaseSettings):
validation_alias="GRB_MAX_TOTAL_RESPONSE_MB",
)
grb_cache_ttl_hours: int = Field(default=24, ge=0, le=8760, validation_alias="GRB_CACHE_TTL_HOURS")
+ official_vector_enabled: bool = Field(default=True, validation_alias="OFFICIAL_VECTOR_ENABLED")
+ bwk_wfs_url: str = Field(
+ default="https://geo.api.vlaanderen.be/BWK/wfs",
+ validation_alias="BWK_WFS_URL",
+ )
+ dov_soil_wfs_url: str = Field(
+ default="https://www.dov.vlaanderen.be/geoserver/wfs",
+ validation_alias="DOV_SOIL_WFS_URL",
+ )
+ official_vector_min_side_m: float = Field(
+ default=10.0,
+ gt=0,
+ validation_alias="OFFICIAL_VECTOR_MIN_SIDE_M",
+ )
+ official_vector_max_side_m: float = Field(
+ default=20_000.0,
+ gt=0,
+ validation_alias="OFFICIAL_VECTOR_MAX_SIDE_M",
+ )
+ official_vector_page_size: int = Field(
+ default=1000,
+ ge=1,
+ le=2000,
+ validation_alias="OFFICIAL_VECTOR_PAGE_SIZE",
+ )
+ official_vector_max_pages: int = Field(
+ default=200,
+ ge=1,
+ le=1000,
+ validation_alias="OFFICIAL_VECTOR_MAX_PAGES",
+ )
+ official_vector_max_features: int = Field(
+ default=100_000,
+ ge=1,
+ validation_alias="OFFICIAL_VECTOR_MAX_FEATURES",
+ )
+ official_vector_timeout_seconds: int = Field(
+ default=180,
+ ge=1,
+ le=600,
+ validation_alias="OFFICIAL_VECTOR_TIMEOUT_SECONDS",
+ )
+ official_vector_max_response_mb: int = Field(
+ default=20,
+ ge=1,
+ le=100,
+ validation_alias="OFFICIAL_VECTOR_MAX_RESPONSE_MB",
+ )
+ official_vector_max_total_response_mb: int = Field(
+ default=256,
+ ge=1,
+ le=2048,
+ validation_alias="OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB",
+ )
+ official_vector_cache_ttl_hours: int = Field(
+ default=24,
+ ge=0,
+ le=8760,
+ validation_alias="OFFICIAL_VECTOR_CACHE_TTL_HOURS",
+ )
dhmv_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED")
dhmv_wcs_url: str = Field(
default="https://geo.api.vlaanderen.be/DHMV/wcs",
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index f3b51e01..68ba5a20 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -18,6 +18,11 @@ from .source_catalog import (
)
from .grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary
from .grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead
+from .official_vector import (
+ OfficialVectorAcquireRequest,
+ OfficialVectorAcquisitionResult,
+ OfficialVectorProductRead,
+)
from .detection import (
DetectionListResponse,
DetectionModelCapability,
@@ -165,6 +170,9 @@ __all__ = [
"GrbAcquireRequest",
"GrbAcquisitionResult",
"GrbProductRead",
+ "OfficialVectorAcquireRequest",
+ "OfficialVectorAcquisitionResult",
+ "OfficialVectorProductRead",
"DetectionListResponse",
"DetectionModelCapability",
"DetectionModelsResponse",
diff --git a/backend/app/schemas/official_vector.py b/backend/app/schemas/official_vector.py
new file mode 100644
index 00000000..48242049
--- /dev/null
+++ b/backend/app/schemas/official_vector.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from uuid import UUID
+
+from pydantic import BaseModel
+
+from .operations import VectorSelectionBBox
+
+
+class OfficialVectorAcquireRequest(BaseModel):
+ bbox: VectorSelectionBBox
+ area_id: UUID | None = None
+ product_key: str
+ force_refresh: bool = False
+
+
+class OfficialVectorProductRead(BaseModel):
+ key: str
+ display_name: str
+ theme: str
+ provider: str
+ source_name: str
+ reference_layer_name: str
+ service_type: str
+ collection: str
+ geometry_types: list[str]
+ source_crs: str
+ source_version: str
+ observation_label: str
+ authority_level: str
+ catalog_url: str
+ attribution: str
+ license_note: str
+ limitation_message: str
+
+
+class OfficialVectorAcquisitionResult(BaseModel):
+ output_dataset_id: UUID
+ reused: bool
+ product_key: str
+ display_name: str
+ theme: str
+ provider: str
+ source_name: str
+ reference_layer_name: str
+ service_type: str
+ collection: str
+ feature_count: int
+ candidate_feature_count: int
+ page_count: int
+ bbox_epsg4326: list[float]
+ source_version: str
+ attribution: str
+ limitation_message: str
diff --git a/backend/app/schemas/thematic_raster.py b/backend/app/schemas/thematic_raster.py
index fddb2218..6d32cdd8 100644
--- a/backend/app/schemas/thematic_raster.py
+++ b/backend/app/schemas/thematic_raster.py
@@ -30,6 +30,7 @@ class ThematicRasterProductRead(BaseModel):
license_note: str
legend_min_label: str
legend_max_label: str
+ included_source_values: list[int]
limitation_message: str
diff --git a/backend/app/services/official_vector_acquisition_service.py b/backend/app/services/official_vector_acquisition_service.py
new file mode 100644
index 00000000..3d9cacec
--- /dev/null
+++ b/backend/app/services/official_vector_acquisition_service.py
@@ -0,0 +1,1023 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+import hashlib
+import json
+import math
+from pathlib import Path
+import re
+from typing import Any, Callable
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode, urlparse
+from urllib.request import HTTPRedirectHandler, Request, build_opener
+from uuid import UUID
+
+from geoalchemy2.shape import to_shape
+from pyproj import Transformer
+from shapely.geometry import MultiPolygon, Polygon, box, mapping, shape
+from shapely.ops import transform, unary_union
+from shapely.validation import make_valid
+
+from app.core.config import Settings, get_settings
+from app.core.errors import AppError
+from app.models import Area, Dataset, Project
+from app.schemas.official_vector import (
+ OfficialVectorAcquireRequest,
+ OfficialVectorAcquisitionResult,
+ OfficialVectorProductRead,
+)
+from app.services.dataset_service import DatasetService
+
+
+class _RejectRedirects(HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ del req, fp, code, msg, headers, newurl
+ return None
+
+
+_NO_REDIRECT_OPENER = build_opener(_RejectRedirects())
+_TO_LAMBERT72 = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
+_TO_WGS84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
+
+
+@dataclass(frozen=True)
+class OfficialVectorProduct:
+ key: str
+ display_name: str
+ theme: str
+ provider: str
+ source_name: str
+ reference_layer_name: str
+ service_type: str
+ collection: str
+ source_crs: str
+ source_version: str
+ observation_label: str
+ authority_level: str
+ catalog_url: str
+ attribution: str
+ license_note: str
+ limitation_message: str
+ source: str
+ observed_at: datetime
+ valid_from: datetime | None
+ valid_to: datetime | None
+ primary_metric: dict[str, Any]
+ selection_metrics: tuple[dict[str, Any], ...]
+
+
+class OfficialVectorAcquisitionService:
+ _BWK_EVALUATION_LABELS = {
+ "z": "Biologisch zeer waardevol",
+ "w": "Biologisch waardevol",
+ "m": "Biologisch minder waardevol",
+ "wz": "Complex van biologisch waardevolle en zeer waardevolle elementen",
+ "mwz": "Complex van minder waardevolle, waardevolle en zeer waardevolle elementen",
+ "mz": "Complex van minder waardevolle en zeer waardevolle elementen",
+ "mw": "Complex van minder waardevolle en waardevolle elementen",
+ }
+
+ @staticmethod
+ def _products() -> dict[str, OfficialVectorProduct]:
+ share_warning = (
+ "PHAB-aandelen gelden voor het volledige bronpolygoon. Bij een gedeeltelijke selectie worden "
+ "ze evenredig geschaald en blijven ze dus een oppervlakte-inschatting."
+ )
+ products = (
+ OfficialVectorProduct(
+ key="bwk_natura2000_2025",
+ display_name="BWK en Natura 2000-habitatkaart 2025",
+ theme="nature_value",
+ provider="INBO / Digitaal Vlaanderen",
+ source_name="inbo_bwk_natura2000",
+ reference_layer_name="nature_value",
+ service_type="WFS 2.0",
+ collection="BWK:Bwkhab",
+ source_crs="EPSG:31370",
+ source_version="2025",
+ observation_label="Toestand 2025",
+ authority_level="authoritative",
+ catalog_url=(
+ "https://www.vlaanderen.be/datavindplaats/catalogus/"
+ "biologische-waarderingskaart-en-natura-2000-habitatkaart-toestand-2025"
+ ),
+ attribution="Bron: INBO",
+ license_note="Hergebruik volgens de open-datavoorwaarden en bronvermelding van INBO.",
+ limitation_message=(
+ "De BWK is een gebiedsdekkende kartering, geen terreinmeting op aanvraag. "
+ "PHAB-oppervlakten zijn proportionele schattingen binnen bronpolygonen."
+ ),
+ source="INBO BWK WFS",
+ observed_at=datetime(2025, 12, 10, tzinfo=UTC),
+ valid_from=None,
+ valid_to=None,
+ primary_metric={
+ "metric_key": "nature_mapped_area",
+ "method": "intersection_area",
+ "label": "Gekarteerde natuuroppervlakte",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "is_estimate": False,
+ },
+ selection_metrics=(
+ {
+ "metric_key": "bwk_very_valuable_area",
+ "method": "intersection_area",
+ "label": "Biologisch zeer waardevol",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "bwk_evaluation_code",
+ "filter_values": ["z"],
+ },
+ {
+ "metric_key": "bwk_valuable_area",
+ "method": "intersection_area",
+ "label": "Biologisch waardevol",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "bwk_evaluation_code",
+ "filter_values": ["w"],
+ },
+ {
+ "metric_key": "bwk_less_valuable_area",
+ "method": "intersection_area",
+ "label": "Biologisch minder waardevol",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "bwk_evaluation_code",
+ "filter_values": ["m"],
+ },
+ {
+ "metric_key": "bwk_mixed_value_area",
+ "method": "intersection_area",
+ "label": "Gemengde BWK-waardering",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "bwk_evaluation_code",
+ "filter_values": ["wz", "mwz", "mz", "mw"],
+ },
+ {
+ "metric_key": "natura2000_area",
+ "method": "area_weighted_sum",
+ "property": "natura2000_area_ha",
+ "label": "Natura 2000-habitat",
+ "unit": "ha",
+ "is_estimate": True,
+ "warning": share_warning,
+ "warning_only_when_estimate": False,
+ },
+ {
+ "metric_key": "regional_biotope_area",
+ "method": "area_weighted_sum",
+ "property": "regional_biotope_area_ha",
+ "label": "Regionaal belangrijk biotoop",
+ "unit": "ha",
+ "is_estimate": True,
+ "warning": share_warning,
+ "warning_only_when_estimate": False,
+ },
+ ),
+ ),
+ OfficialVectorProduct(
+ key="dov_soil_types",
+ display_name="Digitale bodemkaart Vlaanderen - bodemtypes",
+ theme="soil",
+ provider="Databank Ondergrond Vlaanderen",
+ source_name="dov_soil_map",
+ reference_layer_name="soil",
+ service_type="WFS 2.0",
+ collection="bodemkaart:bodemtypes",
+ source_crs="EPSG:31370",
+ source_version="Digitale uitgave juni 2017",
+ observation_label="Veldkartering 1949-1971",
+ authority_level="authoritative_historical_baseline",
+ catalog_url=(
+ "https://www.vlaanderen.be/datavindplaats/catalogus/"
+ "digitale-bodemkaart-van-het-vlaams-gewest-bodemtypes"
+ ),
+ attribution="Databank Ondergrond Vlaanderen - Digitale bodemkaart: bodemtypes",
+ license_note="DOV-bronvermelding en de publieke GDI-hergebruikvoorwaarden zijn van toepassing.",
+ limitation_message=(
+ "Historische bodemkartering op schaal 1:20.000 op basis van veldwerk 1949-1971. "
+ "De huidige drainage en lokale bodemtoestand kunnen afwijken; dit is geen terreinonderzoek."
+ ),
+ source="DOV WFS bodemtypes",
+ observed_at=datetime(1971, 12, 31, 23, 59, 59, tzinfo=UTC),
+ valid_from=datetime(1949, 1, 1, tzinfo=UTC),
+ valid_to=datetime(1971, 12, 31, 23, 59, 59, tzinfo=UTC),
+ primary_metric={
+ "metric_key": "soil_mapped_area",
+ "method": "intersection_area",
+ "label": "Bodemkaartoppervlakte",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "is_estimate": False,
+ },
+ selection_metrics=(
+ {
+ "metric_key": "soil_dry_sand_area",
+ "method": "intersection_area",
+ "label": "Gekarteerd als droog zand",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Droog zand", "Zeer droog zand"],
+ },
+ {
+ "metric_key": "soil_moist_sand_area",
+ "method": "intersection_area",
+ "label": "Gekarteerd als vochtig zand",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Vochtig zand"],
+ },
+ {
+ "metric_key": "soil_wet_sand_area",
+ "method": "intersection_area",
+ "label": "Gekarteerd als nat zand",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Nat zand", "Zeer nat zand"],
+ },
+ {
+ "metric_key": "soil_anthropogenic_area",
+ "method": "intersection_area",
+ "label": "Antropogene bodemklasse",
+ "unit": "ha",
+ "geometry_dimension": 2,
+ "filter_property": "soil_generalized_legend",
+ "filter_values": ["Antropogeen"],
+ },
+ ),
+ ),
+ )
+ return {product.key: product for product in products}
+
+ @staticmethod
+ def list_products() -> list[dict[str, Any]]:
+ return [
+ OfficialVectorProductRead(
+ key=product.key,
+ display_name=product.display_name,
+ theme=product.theme,
+ provider=product.provider,
+ source_name=product.source_name,
+ reference_layer_name=product.reference_layer_name,
+ service_type=product.service_type,
+ collection=product.collection,
+ geometry_types=["Polygon", "MultiPolygon"],
+ source_crs=product.source_crs,
+ source_version=product.source_version,
+ observation_label=product.observation_label,
+ authority_level=product.authority_level,
+ catalog_url=product.catalog_url,
+ attribution=product.attribution,
+ license_note=product.license_note,
+ limitation_message=product.limitation_message,
+ ).model_dump()
+ for product in OfficialVectorAcquisitionService._products().values()
+ ]
+
+ @staticmethod
+ def _product(product_key: str) -> OfficialVectorProduct:
+ product = OfficialVectorAcquisitionService._products().get(product_key.strip().lower())
+ if product is None:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PRODUCT_NOT_SUPPORTED",
+ message="Select a governed official vector product",
+ details={"product_key": product_key},
+ status_code=422,
+ )
+ return product
+
+ @staticmethod
+ def _polygonal(geometry: Any) -> Any | None:
+ if geometry is None or geometry.is_empty:
+ return None
+ if not geometry.is_valid:
+ geometry = make_valid(geometry)
+ parts: list[Polygon] = []
+
+ def collect(item: Any) -> None:
+ if item is None or item.is_empty:
+ return
+ if isinstance(item, Polygon):
+ parts.append(item)
+ elif isinstance(item, MultiPolygon):
+ parts.extend(part for part in item.geoms if not part.is_empty)
+ elif hasattr(item, "geoms"):
+ for part in item.geoms:
+ collect(part)
+
+ collect(geometry)
+ if not parts:
+ return None
+ result = unary_union(parts)
+ if not result.is_valid:
+ result = make_valid(result)
+ return result if not result.is_empty and result.is_valid else None
+
+ @staticmethod
+ def _validate_scope(
+ db,
+ project_id: UUID,
+ payload: OfficialVectorAcquireRequest,
+ settings: Settings,
+ ) -> tuple[Any, Any, list[float], list[float]]:
+ if not settings.official_vector_enabled:
+ raise AppError(
+ code="OFFICIAL_VECTOR_NOT_CONFIGURED",
+ message="Bounded official vector acquisition is disabled",
+ status_code=503,
+ )
+ if not db.get(Project, project_id):
+ raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
+ if payload.bbox.crs.upper() != "EPSG:4326":
+ raise AppError(
+ code="OFFICIAL_VECTOR_INVALID_CRS",
+ message="Official vector acquisition requires EPSG:4326",
+ status_code=400,
+ )
+ values = (payload.bbox.min_x, payload.bbox.min_y, payload.bbox.max_x, payload.bbox.max_y)
+ if (
+ not all(math.isfinite(value) for value in values)
+ or values[0] >= values[2]
+ or values[1] >= values[3]
+ or values[0] < -180
+ or values[2] > 180
+ or values[1] < -90
+ or values[3] > 90
+ ):
+ raise AppError(
+ code="OFFICIAL_VECTOR_INVALID_BBOX",
+ message="Bounding box is invalid for EPSG:4326",
+ status_code=400,
+ )
+ metric_bounds = _TO_LAMBERT72.transform_bounds(*values, densify_pts=21)
+ width_m = metric_bounds[2] - metric_bounds[0]
+ height_m = metric_bounds[3] - metric_bounds[1]
+ if width_m < settings.official_vector_min_side_m or height_m < settings.official_vector_min_side_m:
+ raise AppError(
+ code="OFFICIAL_VECTOR_SELECTION_TOO_SMALL",
+ message=f"Select at least {settings.official_vector_min_side_m:g} by "
+ f"{settings.official_vector_min_side_m:g} metres",
+ status_code=422,
+ )
+ if width_m > settings.official_vector_max_side_m or height_m > settings.official_vector_max_side_m:
+ raise AppError(
+ code="OFFICIAL_VECTOR_SELECTION_TOO_LARGE",
+ message=f"Select no more than {settings.official_vector_max_side_m:g} by "
+ f"{settings.official_vector_max_side_m:g} metres",
+ details={"width_m": width_m, "height_m": height_m},
+ status_code=422,
+ )
+ scope_wgs84 = box(*values)
+ if payload.area_id:
+ area = db.get(Area, payload.area_id)
+ if area is None:
+ raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
+ if area.project_id != project_id:
+ raise AppError(
+ code="INVALID_DATASET_SCOPE",
+ message="Area does not belong to this project",
+ status_code=400,
+ )
+ scope_wgs84 = OfficialVectorAcquisitionService._polygonal(
+ scope_wgs84.intersection(to_shape(area.geometry))
+ )
+ if scope_wgs84 is None:
+ raise AppError(
+ code="OFFICIAL_VECTOR_SCOPE_EMPTY",
+ message="The selection does not intersect the selected area",
+ status_code=400,
+ )
+ scope_metric = OfficialVectorAcquisitionService._polygonal(
+ transform(_TO_LAMBERT72.transform, scope_wgs84)
+ )
+ if scope_metric is None:
+ raise AppError(
+ code="OFFICIAL_VECTOR_SCOPE_INVALID",
+ message="The selection could not be transformed to EPSG:31370",
+ status_code=400,
+ )
+ return scope_wgs84, scope_metric, [float(value) for value in values], [
+ float(value) for value in scope_metric.bounds
+ ]
+
+ @staticmethod
+ def _read_page(
+ url: str,
+ settings: Settings,
+ opener: Callable[..., Any] | None,
+ ) -> tuple[dict[str, Any], str, int]:
+ request = Request(
+ url,
+ headers={
+ "Accept": "application/geo+json, application/json",
+ "User-Agent": "GeoIntel/1.0 bounded-official-vector-acquisition",
+ },
+ )
+ try:
+ with (opener or _NO_REDIRECT_OPENER.open)(
+ request,
+ timeout=settings.official_vector_timeout_seconds,
+ ) as response:
+ limit = settings.official_vector_max_response_mb * 1024 * 1024
+ content = response.read(limit + 1)
+ except HTTPError as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_HTTP_ERROR",
+ message="The official vector provider returned an HTTP error",
+ details={"status_code": exc.code},
+ status_code=502,
+ ) from exc
+ except (TimeoutError, URLError, OSError) as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_UNAVAILABLE",
+ message="The official vector provider is unavailable",
+ status_code=502,
+ ) from exc
+ if len(content) > limit:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_RESPONSE_TOO_LARGE",
+ message="An official vector response page exceeded the configured limit",
+ status_code=502,
+ )
+ try:
+ payload = json.loads(content.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="The official vector provider returned invalid GeoJSON",
+ status_code=502,
+ ) from exc
+ if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection":
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="The official vector provider returned a non-FeatureCollection response",
+ status_code=502,
+ )
+ return payload, hashlib.sha256(content).hexdigest(), len(content)
+
+ @staticmethod
+ def _nature_url(
+ settings: Settings,
+ bbox_values: tuple[float, ...],
+ start_index: int,
+ ) -> str:
+ query = urlencode(
+ {
+ "service": "WFS",
+ "version": "2.0.0",
+ "request": "GetFeature",
+ "typeNames": "BWK:Bwkhab",
+ "srsName": "EPSG:4326",
+ "bbox": ",".join(f"{value:.8f}" for value in bbox_values) + ",EPSG:4326",
+ "count": settings.official_vector_page_size,
+ "startIndex": start_index,
+ "sortBy": "UIDN",
+ "outputFormat": "application/json",
+ }
+ )
+ return f"{settings.bwk_wfs_url.rstrip('?')}?{query}"
+
+ @staticmethod
+ def _soil_url(settings: Settings, metric_bbox: tuple[float, ...], start_index: int) -> str:
+ query = urlencode(
+ {
+ "service": "WFS",
+ "version": "2.0.0",
+ "request": "GetFeature",
+ "typeNames": "bodemkaart:bodemtypes",
+ "srsName": "EPSG:4326",
+ "bbox": ",".join(f"{value:.3f}" for value in metric_bbox) + ",EPSG:31370",
+ "count": settings.official_vector_page_size,
+ "startIndex": start_index,
+ "sortBy": "gid",
+ "outputFormat": "application/json",
+ }
+ )
+ return f"{settings.dov_soil_wfs_url.rstrip('?')}?{query}"
+
+ @staticmethod
+ def _habitat_breakdown(properties: dict[str, Any]) -> tuple[list[dict[str, Any]], float, float, float]:
+ entries: list[dict[str, Any]] = []
+ natura_share = regional_share = uncertain_share = 0.0
+ for index in range(1, 6):
+ code = str(properties.get(f"HAB{index}") or "").strip()
+ if not code:
+ continue
+ raw_share = properties.get(f"PHAB{index}")
+ try:
+ share = max(0.0, min(100.0, float(raw_share or 0)))
+ except (TypeError, ValueError):
+ share = 0.0
+ entries.append({"code": code, "share_percent": share})
+ lowered = code.lower()
+ if re.match(r"^\d", code):
+ natura_share += share
+ elif lowered.startswith("rbb"):
+ regional_share += share
+ elif lowered.startswith("ohab"):
+ uncertain_share += share
+ if str(properties.get("HABLEGENDE") or "").strip().lower() == "ohab" and uncertain_share <= 0:
+ uncertain_share = 100.0
+ return entries, min(100.0, natura_share), min(100.0, regional_share), min(100.0, uncertain_share)
+
+ @staticmethod
+ def _normalize_feature(
+ product: OfficialVectorProduct,
+ feature: dict[str, Any],
+ scope_metric: Any,
+ coverage_scope: str,
+ ) -> dict[str, Any] | None:
+ try:
+ source_wgs84 = OfficialVectorAcquisitionService._polygonal(shape(feature.get("geometry")))
+ except Exception as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_GEOMETRY",
+ message=f"{product.display_name} returned invalid geometry",
+ status_code=502,
+ ) from exc
+ if source_wgs84 is None:
+ return None
+ source_metric = OfficialVectorAcquisitionService._polygonal(
+ transform(_TO_LAMBERT72.transform, source_wgs84)
+ )
+ if source_metric is None or not source_metric.intersects(scope_metric):
+ return None
+ clipped_metric = OfficialVectorAcquisitionService._polygonal(source_metric.intersection(scope_metric))
+ if clipped_metric is None or clipped_metric.area <= 0:
+ return None
+ clipped_wgs84 = OfficialVectorAcquisitionService._polygonal(
+ transform(_TO_WGS84.transform, clipped_metric)
+ )
+ if clipped_wgs84 is None:
+ return None
+ raw = dict(feature.get("properties") or {})
+ if product.theme == "nature_value":
+ raw_id = str(feature.get("id") or raw.get("UIDN") or raw.get("OIDN") or "").strip()
+ if not raw_id:
+ raw_id = hashlib.sha256(json.dumps(feature.get("geometry"), sort_keys=True).encode()).hexdigest()
+ feature_id = f"BWK:Bwkhab:{raw.get('UIDN') or raw_id}"
+ evaluation = str(raw.get("EVAL") or "").strip().lower()
+ habitats, natura_share, regional_share, uncertain_share = (
+ OfficialVectorAcquisitionService._habitat_breakdown(raw)
+ )
+ area_ha = float(clipped_metric.area) / 10_000.0
+ properties = {
+ **raw,
+ "source_name": product.source_name,
+ "source_collection": product.collection,
+ "source_feature_id": feature_id,
+ "reference_layer_name": product.reference_layer_name,
+ "theme": product.theme,
+ "authority_level": product.authority_level,
+ "coverage_scope": coverage_scope,
+ "source_version": product.source_version,
+ "attribution": product.attribution,
+ "bwk_evaluation_code": evaluation or "unknown",
+ "bwk_evaluation_label": OfficialVectorAcquisitionService._BWK_EVALUATION_LABELS.get(
+ evaluation, "Onbekende of ontbrekende BWK-waardering"
+ ),
+ "bwk_label": str(raw.get("BWKLABEL") or "").strip(),
+ "bwk_units": ", ".join(
+ str(raw.get(f"EENH{index}") or "").strip()
+ for index in range(1, 9)
+ if str(raw.get(f"EENH{index}") or "").strip()
+ ),
+ "habitat_entries": habitats,
+ "clipped_area_ha": round(area_ha, 8),
+ "natura2000_share_percent": natura_share,
+ "regional_biotope_share_percent": regional_share,
+ "uncertain_habitat_share_percent": uncertain_share,
+ "natura2000_area_ha": round(area_ha * natura_share / 100.0, 8),
+ "regional_biotope_area_ha": round(area_ha * regional_share / 100.0, 8),
+ "uncertain_habitat_area_ha": round(area_ha * uncertain_share / 100.0, 8),
+ "geometry_clipped_to_selection": not scope_metric.covers(source_metric),
+ }
+ else:
+ gid = raw.get("gid")
+ map_polygon_id = raw.get("id_kaartvlak")
+ feature_id = str(feature.get("id") or f"{product.collection}:{gid or map_polygon_id}").strip()
+ if not feature_id:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="DOV returned a soil polygon without an official identity",
+ status_code=502,
+ )
+ properties = {
+ **raw,
+ "source_name": product.source_name,
+ "source_collection": product.collection,
+ "source_feature_id": feature_id,
+ "source_gid": gid,
+ "source_map_polygon_id": map_polygon_id,
+ "reference_layer_name": product.reference_layer_name,
+ "theme": product.theme,
+ "authority_level": product.authority_level,
+ "coverage_scope": coverage_scope,
+ "source_version": product.source_version,
+ "survey_period": "1949-1971",
+ "soil_type_code": raw.get("Bodemtype"),
+ "unified_soil_type_code": raw.get("Unibodemtype"),
+ "soil_series_code": raw.get("Bodemserie"),
+ "soil_series_description": raw.get("Beknopte_omschrijving_bodemserie"),
+ "soil_generalized_legend": raw.get("Gegeneraliseerde_legende"),
+ "soil_texture_class_code": raw.get("Textuurklasse_code"),
+ "soil_texture_class": raw.get("Textuurklasse"),
+ "soil_drainage_class_code": raw.get("Drainageklasse_code"),
+ "soil_drainage_class": raw.get("Drainageklasse"),
+ "soil_profile_group_code": raw.get("Profielontwikkelingsgroep_code"),
+ "soil_profile_group": raw.get("Profielontwikkelingsgroep"),
+ "soil_substrate_code": raw.get("Substraat_code"),
+ "soil_substrate": raw.get("Substraat_Vlaanderen") or raw.get("Substraat_legende"),
+ "soil_region": raw.get("Streek"),
+ "classification_type": raw.get("Type_classificatie"),
+ "soil_map_title": raw.get("Eenduidige_legende_titel"),
+ "clipped_area_ha": round(float(clipped_metric.area) / 10_000.0, 8),
+ "attribution": product.attribution,
+ "geometry_clipped_to_selection": not scope_metric.covers(source_metric),
+ "historical_drainage_limitation": (
+ "Drainage class derives from field data collected between 1949 and 1971 and may differ today."
+ ),
+ }
+ return {
+ "type": "Feature",
+ "id": feature_id,
+ "geometry": mapping(clipped_wgs84),
+ "properties": properties,
+ }
+
+ @staticmethod
+ def _fetch_features(
+ product: OfficialVectorProduct,
+ scope_wgs84: Any,
+ scope_metric: Any,
+ coverage_scope: str,
+ settings: Settings,
+ opener: Callable[..., Any] | None,
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
+ retained: list[dict[str, Any]] = []
+ seen_ids: set[str] = set()
+ request_urls: list[str] = []
+ response_hashes: list[str] = []
+ total_bytes = candidate_count = 0
+ expected_total: int | None = None
+ next_url = (
+ OfficialVectorAcquisitionService._nature_url(settings, tuple(scope_wgs84.bounds), 0)
+ if product.theme == "nature_value"
+ else OfficialVectorAcquisitionService._soil_url(settings, tuple(scope_metric.bounds), 0)
+ )
+ start_index = 0
+ seen_pages: set[str] = set()
+ while next_url:
+ parsed = urlparse(next_url)
+ configured_url = (
+ settings.bwk_wfs_url
+ if product.theme == "nature_value"
+ else settings.dov_soil_wfs_url
+ )
+ base = urlparse(configured_url)
+ if (
+ parsed.scheme != "https"
+ or base.scheme != "https"
+ or parsed.netloc.casefold() != base.netloc.casefold()
+ or parsed.path != base.path
+ ):
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_PAGINATION",
+ message="The official WFS request escaped the governed endpoint",
+ status_code=502,
+ )
+ if next_url in seen_pages:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_PAGINATION_LOOP",
+ message="The official provider repeated a pagination URL",
+ status_code=502,
+ )
+ if len(request_urls) >= settings.official_vector_max_pages:
+ raise AppError(
+ code="OFFICIAL_VECTOR_SELECTION_TOO_LARGE",
+ message="Official vector acquisition exceeded the configured page limit",
+ status_code=422,
+ )
+ seen_pages.add(next_url)
+ payload, response_hash, response_size = OfficialVectorAcquisitionService._read_page(
+ next_url, settings, opener
+ )
+ request_urls.append(next_url)
+ response_hashes.append(response_hash)
+ total_bytes += response_size
+ if total_bytes > settings.official_vector_max_total_response_mb * 1024 * 1024:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_RESPONSE_TOO_LARGE",
+ message="The complete official vector response exceeded the configured transfer limit",
+ status_code=502,
+ )
+ source_features = payload.get("features")
+ if not isinstance(source_features, list):
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="The official FeatureCollection has no feature list",
+ status_code=502,
+ )
+ raw_matched = payload.get("numberMatched", payload.get("totalFeatures"))
+ if raw_matched not in (None, "unknown"):
+ try:
+ matched = int(raw_matched)
+ except (TypeError, ValueError) as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="The official WFS returned an invalid numberMatched value",
+ status_code=502,
+ ) from exc
+ if expected_total is None:
+ expected_total = matched
+ elif expected_total != matched:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_UNSTABLE_PAGINATION",
+ message="The official WFS numberMatched changed during pagination",
+ status_code=502,
+ )
+ for source_feature in source_features:
+ candidate_count += 1
+ if not isinstance(source_feature, dict):
+ continue
+ normalized = OfficialVectorAcquisitionService._normalize_feature(
+ product, source_feature, scope_metric, coverage_scope
+ )
+ if normalized is None or normalized["id"] in seen_ids:
+ continue
+ seen_ids.add(normalized["id"])
+ if len(retained) >= settings.official_vector_max_features:
+ raise AppError(
+ code="OFFICIAL_VECTOR_SELECTION_TOO_LARGE",
+ message="The selection exceeds the configured feature limit; draw a smaller rectangle",
+ details={"max_features": settings.official_vector_max_features},
+ status_code=422,
+ )
+ retained.append(normalized)
+ returned = payload.get("numberReturned", len(source_features))
+ try:
+ returned_count = int(returned)
+ except (TypeError, ValueError) as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="The official WFS returned an invalid numberReturned value",
+ status_code=502,
+ ) from exc
+ if returned_count != len(source_features):
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
+ message="The official WFS numberReturned does not match its feature payload",
+ status_code=502,
+ )
+ start_index += returned_count
+ if returned_count == 0 or (
+ expected_total is not None and start_index >= expected_total
+ ) or (expected_total is None and returned_count < settings.official_vector_page_size):
+ if expected_total is not None and start_index != expected_total:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PROVIDER_INCOMPLETE_RESPONSE",
+ message="The official WFS did not return every matched feature",
+ details={"received": start_index, "expected": expected_total},
+ status_code=502,
+ )
+ next_url = None
+ elif product.theme == "nature_value":
+ next_url = OfficialVectorAcquisitionService._nature_url(
+ settings, tuple(scope_wgs84.bounds), start_index
+ )
+ else:
+ next_url = OfficialVectorAcquisitionService._soil_url(
+ settings, tuple(scope_metric.bounds), start_index
+ )
+ return retained, {
+ "candidate_feature_count": candidate_count,
+ "feature_count": len(retained),
+ "page_count": len(request_urls),
+ "request_urls": request_urls,
+ "response_sha256": response_hashes,
+ "response_size_bytes": total_bytes,
+ "reference_truncated": False,
+ }
+
+ @staticmethod
+ def _cached_dataset(
+ db,
+ project_id: UUID,
+ product: OfficialVectorProduct,
+ request_hash: str,
+ settings: Settings,
+ ) -> Dataset | None:
+ if settings.official_vector_cache_ttl_hours <= 0:
+ return None
+ candidates = (
+ db.query(Dataset)
+ .filter(
+ Dataset.project_id == project_id,
+ Dataset.source_name == product.source_name,
+ Dataset.reference_layer_name == product.reference_layer_name,
+ Dataset.status == "ready",
+ )
+ .order_by(Dataset.imported_at.desc())
+ .all()
+ )
+ cutoff = datetime.now(UTC) - timedelta(hours=settings.official_vector_cache_ttl_hours)
+ for candidate in candidates:
+ provenance = candidate.provenance_metadata if isinstance(candidate.provenance_metadata, dict) else {}
+ if (
+ provenance.get("request_hash") == request_hash
+ and candidate.storage_path
+ and Path(candidate.storage_path).is_file()
+ and candidate.imported_at is not None
+ and candidate.imported_at >= cutoff
+ ):
+ return candidate
+ return None
+
+ @staticmethod
+ def _result(
+ dataset: Dataset,
+ product: OfficialVectorProduct,
+ *,
+ reused: bool,
+ bbox_values: list[float],
+ ) -> dict[str, Any]:
+ source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
+ provenance = dataset.provenance_metadata if isinstance(dataset.provenance_metadata, dict) else {}
+ metadata = dataset.metadata_json if isinstance(dataset.metadata_json, dict) else {}
+ return OfficialVectorAcquisitionResult(
+ output_dataset_id=dataset.id,
+ reused=reused,
+ product_key=product.key,
+ display_name=product.display_name,
+ theme=product.theme,
+ provider=product.provider,
+ source_name=product.source_name,
+ reference_layer_name=product.reference_layer_name,
+ service_type=product.service_type,
+ collection=product.collection,
+ feature_count=int(metadata.get("feature_count", source_metadata.get("feature_count", 0))),
+ candidate_feature_count=int(provenance.get("candidate_feature_count", 0)),
+ page_count=int(provenance.get("page_count", 0)),
+ bbox_epsg4326=bbox_values,
+ source_version=str(dataset.source_version or product.source_version),
+ attribution=product.attribution,
+ limitation_message=product.limitation_message,
+ ).model_dump(mode="json")
+
+ @staticmethod
+ def acquire(
+ db,
+ project_id: UUID,
+ payload: OfficialVectorAcquireRequest,
+ *,
+ settings: Settings | None = None,
+ opener: Callable[..., Any] | None = None,
+ ) -> dict[str, Any]:
+ resolved_settings = settings or get_settings()
+ product = OfficialVectorAcquisitionService._product(payload.product_key)
+ scope_wgs84, scope_metric, bbox_values, metric_bounds = (
+ OfficialVectorAcquisitionService._validate_scope(
+ db, project_id, payload, resolved_settings
+ )
+ )
+ request_identity = {
+ "product_key": product.key,
+ "bbox_epsg4326": [round(value, 8) for value in bbox_values],
+ "area_id": str(payload.area_id) if payload.area_id else None,
+ "source_version": product.source_version,
+ }
+ request_hash = hashlib.sha256(
+ json.dumps(request_identity, sort_keys=True).encode()
+ ).hexdigest()
+ if not payload.force_refresh:
+ cached = OfficialVectorAcquisitionService._cached_dataset(
+ db, project_id, product, request_hash, resolved_settings
+ )
+ if cached is not None:
+ return OfficialVectorAcquisitionService._result(
+ cached, product, reused=True, bbox_values=bbox_values
+ )
+ area = db.get(Area, payload.area_id) if payload.area_id else None
+ coverage_scope = (
+ "municipality"
+ if area is not None and area.name.strip().lower().startswith("gemeente ")
+ else "bounded_selection"
+ )
+ features, transfer = OfficialVectorAcquisitionService._fetch_features(
+ product,
+ scope_wgs84,
+ scope_metric,
+ coverage_scope,
+ resolved_settings,
+ opener,
+ )
+ acquired_at = datetime.now(UTC)
+ artifact = json.dumps(
+ {
+ "type": "FeatureCollection",
+ "name": product.display_name,
+ "crs": {"type": "name", "properties": {"name": "EPSG:4326"}},
+ "features": features,
+ },
+ ensure_ascii=False,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ filename = (
+ f"{product.source_name}_{product.key}_{request_hash[:12]}.geojson"
+ )
+ source_metadata = {
+ "provider": product.provider,
+ "service": product.service_type,
+ "product_key": product.key,
+ "product_display_name": product.display_name,
+ "source_collection": product.collection,
+ "authority_level": product.authority_level,
+ "theme": product.theme,
+ "layer_type": product.reference_layer_name,
+ "coverage_scope": coverage_scope,
+ "geometry_clipped_to_area": payload.area_id is not None,
+ "geometry_clipped_to_selection": True,
+ "bbox_epsg4326": bbox_values,
+ "bbox_epsg31370": metric_bounds,
+ "feature_count": len(features),
+ "identity_stable": True,
+ "source_storage_crs": product.source_crs,
+ "persisted_crs": "EPSG:4326",
+ "selection_aggregation": product.primary_metric,
+ "selection_metrics": list(product.selection_metrics),
+ "attribution": product.attribution,
+ "license_note": product.license_note,
+ "catalog_url": product.catalog_url,
+ "limitation_message": product.limitation_message,
+ }
+ if product.theme == "soil":
+ source_metadata.update(
+ {
+ "survey_period": "1949-1971",
+ "source_scale": "1:20,000",
+ "semantic_metrics": False,
+ }
+ )
+ provenance_metadata = {
+ "acquisition": f"explicit_bounded_{product.service_type.lower().replace(' ', '_')}",
+ "acquired_at": acquired_at.isoformat(),
+ "request_hash": request_hash,
+ "request_urls": transfer["request_urls"],
+ "response_sha256": transfer["response_sha256"],
+ "response_size_bytes": transfer["response_size_bytes"],
+ "page_count": transfer["page_count"],
+ "candidate_feature_count": transfer["candidate_feature_count"],
+ "exact_feature_count": transfer["feature_count"],
+ "reference_truncated": False,
+ "artifact_sha256": hashlib.sha256(artifact).hexdigest(),
+ "clipped_to_area_id": str(payload.area_id) if payload.area_id else None,
+ "scope_geometry_type": scope_wgs84.geom_type,
+ "catalog_url": product.catalog_url,
+ "limitation_message": product.limitation_message,
+ }
+ try:
+ dataset_response = DatasetService.import_vector_bytes(
+ db,
+ project_id=project_id,
+ area_id=payload.area_id,
+ filename=filename,
+ content=artifact,
+ source=product.source,
+ source_name=product.source_name,
+ dataset_role="reference",
+ reference_layer_name=product.reference_layer_name,
+ temporal_series_key=f"{product.source_name}:{product.key}:{request_hash[:24]}",
+ observed_at=product.observed_at,
+ valid_from=product.valid_from,
+ valid_to=product.valid_to,
+ temporal_granularity="period" if product.valid_from else "snapshot",
+ source_version=product.source_version,
+ source_metadata=source_metadata,
+ provenance_metadata=provenance_metadata,
+ )
+ except AppError:
+ raise
+ except Exception as exc:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PERSISTENCE_FAILED",
+ message="The validated official vector selection could not be persisted",
+ details={"reason": str(exc)},
+ status_code=500,
+ ) from exc
+ persisted = db.get(Dataset, dataset_response.id)
+ if persisted is None:
+ raise AppError(
+ code="OFFICIAL_VECTOR_PERSISTENCE_FAILED",
+ message="The persisted official vector dataset could not be reloaded",
+ status_code=500,
+ )
+ return OfficialVectorAcquisitionService._result(
+ persisted, product, reused=False, bbox_values=bbox_values
+ )
diff --git a/backend/app/services/thematic_raster_acquisition_service.py b/backend/app/services/thematic_raster_acquisition_service.py
index 82c61ac5..b1b444ba 100644
--- a/backend/app/services/thematic_raster_acquisition_service.py
+++ b/backend/app/services/thematic_raster_acquisition_service.py
@@ -45,6 +45,7 @@ class ThematicRasterProduct:
legend_min_label: str
legend_max_label: str
limitation_message: str
+ included_source_values: tuple[int, ...] = ()
class ThematicRasterAcquisitionService:
@@ -100,6 +101,44 @@ class ThematicRasterAcquisitionService:
"synoniem met natuur, bos, publieke toegankelijkheid of planologische bestemming."
),
),
+ ThematicRasterProduct(
+ key="forest_land_use_2025",
+ display_name="Bos volgens Landgebruik Vlaanderen 2025",
+ theme="forest",
+ metric_kind="binary_area",
+ coverage_id="lu:lu_landgebruik_vlaa_2025_v3",
+ native_resolution_m=10.0,
+ source_value_unit="class_0_1",
+ observation_year=2025,
+ source_version="Toestand 2025 versie 3",
+ catalog_url="https://www.vlaanderen.be/datavindplaats/catalogus/landgebruik-vlaanderen-toestand-2025",
+ legend_min_label="Geen bosklasse",
+ legend_max_label="Bos",
+ limitation_message=(
+ "10 m-afleiding van bronklasse 12 (bos) uit Landgebruik Vlaanderen 2025. De oppervlakte is "
+ "resolutiegebonden en vormt geen juridische bosgrens, boomtelling, kroonbedekking of houtvolume."
+ ),
+ included_source_values=(12,),
+ ),
+ ThematicRasterProduct(
+ key="agricultural_land_use_2025",
+ display_name="Akker en landbouwgrasland 2025",
+ theme="agriculture",
+ metric_kind="binary_area",
+ coverage_id="lu:lu_landgebruik_vlaa_2025_v3",
+ native_resolution_m=10.0,
+ source_value_unit="class_0_1",
+ observation_year=2025,
+ source_version="Toestand 2025 versie 3",
+ catalog_url="https://www.vlaanderen.be/datavindplaats/catalogus/landgebruik-vlaanderen-toestand-2025",
+ legend_min_label="Ander landgebruik",
+ legend_max_label="Akker of landbouwgrasland",
+ limitation_message=(
+ "10 m-afleiding van bronklassen 13 (akker) en 14 (grasland in landbouwgebruik). Dit is werkelijk "
+ "landgebruik en geen ALZ-perceelaangifte, eigendomsgrens, teeltregister of juridische bestemming."
+ ),
+ included_source_values=(13, 14),
+ ),
ThematicRasterProduct(
key="population_density_2019",
display_name="Inwonersdichtheid per hectare 2019",
@@ -176,6 +215,7 @@ class ThematicRasterAcquisitionService:
license_note=ThematicRasterAcquisitionService.LICENSE_NOTE,
legend_min_label=product.legend_min_label,
legend_max_label=product.legend_max_label,
+ included_source_values=list(product.included_source_values),
limitation_message=product.limitation_message,
).model_dump()
for product in ThematicRasterAcquisitionService._products().values()
@@ -461,7 +501,29 @@ class ThematicRasterAcquisitionService:
invalid = np.ma.getmaskarray(band) | ~np.isfinite(raw)
if source.nodata is not None:
invalid |= np.isclose(raw, float(source.nodata))
- normalized = np.ma.array(raw, mask=invalid)
+ source_values = np.ma.array(raw, mask=invalid).compressed().astype("float64")
+ if product.included_source_values:
+ rounded = np.rint(source_values)
+ if not np.allclose(source_values, rounded, atol=0.0001):
+ raise AppError(
+ code="THEMATIC_RASTER_INVALID_VALUES",
+ message="Categorical land-use coverage contains non-integer source classes",
+ status_code=502,
+ )
+ if source_values.size and (
+ float(source_values.min()) < 0
+ or float(source_values.max()) > 255
+ ):
+ raise AppError(
+ code="THEMATIC_RASTER_INVALID_VALUES",
+ message="Categorical land-use coverage contains source classes outside the governed range",
+ status_code=502,
+ )
+ source_classes = np.where(invalid, 0, np.rint(raw)).astype("int16")
+ binary = np.isin(source_classes, product.included_source_values).astype("float32")
+ normalized = np.ma.array(binary, mask=invalid)
+ else:
+ normalized = np.ma.array(raw, mask=invalid)
values = normalized.compressed().astype("float64")
ThematicRasterAcquisitionService._validate_values(values, product)
profile = source.profile.copy()
@@ -482,6 +544,9 @@ class ThematicRasterAcquisitionService:
"maximum_value": float(values.max()),
"p02_value": float(np.percentile(values, 2)),
"p98_value": float(np.percentile(values, 98)),
+ "included_source_values": list(product.included_source_values),
+ "source_minimum_value": float(source_values.min()),
+ "source_maximum_value": float(source_values.max()),
}
except AppError:
raise
@@ -563,6 +628,7 @@ class ThematicRasterAcquisitionService:
"analysis_resolution_m": product.native_resolution_m,
"source_crs": ThematicRasterAcquisitionService.SOURCE_CRS,
"source_value_unit": product.source_value_unit,
+ "included_source_values": list(product.included_source_values),
"observation_year": product.observation_year,
"observation_date_precision": "year",
"valid_pixel_count": validation["valid_pixel_count"],
diff --git a/backend/app/services/thematic_raster_analysis_service.py b/backend/app/services/thematic_raster_analysis_service.py
index 473d4a58..62f9e2f7 100644
--- a/backend/app/services/thematic_raster_analysis_service.py
+++ b/backend/app/services/thematic_raster_analysis_service.py
@@ -68,6 +68,10 @@ class ThematicRasterAnalysisService:
@staticmethod
def _unsupported_metrics(product: ThematicRasterProduct) -> list[str]:
if product.metric_kind == "binary_area":
+ if product.theme == "forest":
+ return ["tree_count", "canopy_cover", "timber_volume", "legal_forest_boundary"]
+ if product.theme == "agriculture":
+ return ["declared_parcel_area", "crop_declaration", "ownership", "cadastral_area"]
return ["object_count", "parcel_area", "current_land_use"]
if product.metric_kind == "population_density":
return ["current_population", "household_count", "address_level_population"]
@@ -152,7 +156,12 @@ class ThematicRasterAnalysisService:
positive_count = int(np.count_nonzero(values >= 0.5))
positive_area_ha = positive_count * cell_area_m2 / 10_000.0
positive_share = positive_count / max(1, valid_cell_count) * 100.0
- label = "Ruimtebeslag" if product.theme == "space_occupation" else "Open ruimte"
+ label = {
+ "space_occupation": "Ruimtebeslag",
+ "open_space": "Open ruimte",
+ "forest": "Bos",
+ "agriculture": "Akker en landbouwgrasland",
+ }[product.theme]
metrics = [
metric(f"{product.theme}_area_ha", f"{label} in selectie", positive_area_ha, "ha", "positive_source_cells_times_cell_area"),
metric(f"{product.theme}_share_pct", f"Aandeel {label.lower()}", positive_share, "%", "positive_source_cells_divided_by_valid_selected_cells"),
@@ -216,6 +225,8 @@ class ThematicRasterAnalysisService:
palettes = {
"space_occupation": np.asarray([[251, 231, 211], [190, 62, 51]], dtype="float64"),
"open_space": np.asarray([[221, 238, 219], [38, 122, 70]], dtype="float64"),
+ "forest": np.asarray([[223, 237, 226], [43, 117, 72]], dtype="float64"),
+ "agriculture": np.asarray([[245, 237, 204], [166, 122, 35]], dtype="float64"),
"population": np.asarray([[238, 231, 246], [103, 58, 151]], dtype="float64"),
"accessibility": np.asarray([[233, 241, 244], [15, 118, 110]], dtype="float64"),
"services": np.asarray([[255, 244, 191], [182, 109, 22]], dtype="float64"),
diff --git a/backend/tests/test_sprint213_thematic_rasters.py b/backend/tests/test_sprint213_thematic_rasters.py
index 17944aa3..ff34250e 100644
--- a/backend/tests/test_sprint213_thematic_rasters.py
+++ b/backend/tests/test_sprint213_thematic_rasters.py
@@ -122,21 +122,33 @@ def raster_bytes(values: np.ndarray, resolution: float, *, nodata: float = -9999
return memory.read()
-def test_registry_contains_five_governed_non_water_policy_products() -> None:
+def test_registry_contains_governed_policy_products_including_forest_and_agriculture() -> None:
products = ThematicRasterAcquisitionService.list_products()
assert [item["key"] for item in products] == [
"space_occupation_2025",
"open_space_2022",
+ "forest_land_use_2025",
+ "agricultural_land_use_2025",
"population_density_2019",
"node_value_2022",
"service_level_2022",
]
- assert {item["theme"] for item in products} == {"space_occupation", "open_space", "population", "accessibility", "services"}
+ assert {item["theme"] for item in products} == {
+ "space_occupation",
+ "open_space",
+ "forest",
+ "agriculture",
+ "population",
+ "accessibility",
+ "services",
+ }
assert {item["native_resolution_m"] for item in products} == {10.0, 100.0}
assert all(item["coverage_id"].startswith(("lu:", "ni:")) for item in products)
assert all(item["source_crs"] == "EPSG:31370" for item in products)
assert all(item["attribution"] and item["license_note"] and item["limitation_message"] for item in products)
+ assert next(item for item in products if item["theme"] == "forest")["included_source_values"] == [12]
+ assert next(item for item in products if item["theme"] == "agriculture")["included_source_values"] == [13, 14]
def test_request_is_bounded_allowlisted_and_uses_native_wcs_resolution() -> None:
@@ -520,7 +532,7 @@ def test_api_uses_canonical_envelopes(monkeypatch) -> None:
app.dependency_overrides.clear()
assert products.status_code == 200 and set(products.json()) == {"data"}
- assert products.json()["data"]["total"] == 5
+ assert products.json()["data"]["total"] == 7
assert acquisition.status_code == 200 and set(acquisition.json()) == {"data"}
assert acquisition.json()["data"]["job_type"] == "raster.thematic.acquire"
assert selection.status_code == 200 and selection.json()["data"]["theme"] == "population"
diff --git a/backend/tests/test_sprint240_official_flemish_themes.py b/backend/tests/test_sprint240_official_flemish_themes.py
new file mode 100644
index 00000000..0ca9d88b
--- /dev/null
+++ b/backend/tests/test_sprint240_official_flemish_themes.py
@@ -0,0 +1,407 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+from urllib.parse import parse_qs, urlparse
+from uuid import uuid4
+
+import numpy as np
+import pytest
+from fastapi.testclient import TestClient
+from geoalchemy2.shape import from_shape
+from rasterio.io import MemoryFile
+from rasterio.transform import from_origin
+from shapely.geometry import MultiPolygon, Polygon
+
+from app.core.config import Settings
+from app.core.errors import AppError
+from app.db.session import get_db
+from app.main import app
+from app.models import Area, Dataset, Job, Project
+from app.schemas.official_vector import OfficialVectorAcquireRequest
+from app.services.dataset_service import DatasetService
+from app.services.official_vector_acquisition_service import OfficialVectorAcquisitionService
+from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+class FakeQuery:
+ def __init__(self, result=None):
+ self.result = result
+
+ def filter(self, *_args):
+ return self
+
+ def order_by(self, *_args):
+ return self
+
+ def all(self):
+ return self.result if isinstance(self.result, list) else []
+
+
+class FakeSession:
+ def __init__(self, rows=None, query_result=None):
+ self.rows = rows or {}
+ self.query_result = query_result
+ self.added = []
+
+ def get(self, model, row_id):
+ row = self.rows.get((model, row_id))
+ if row is not None:
+ return row
+ return next((item for item in self.added if isinstance(item, model) and item.id == row_id), None)
+
+ def add(self, row):
+ self.added.append(row)
+
+ def commit(self):
+ return None
+
+ def rollback(self):
+ return None
+
+ def refresh(self, row):
+ return row
+
+ def query(self, _model):
+ return FakeQuery(self.query_result)
+
+
+class JsonResponse:
+ def __init__(self, payload):
+ self.content = json.dumps(payload).encode("utf-8")
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def read(self, size=-1):
+ return self.content if size < 0 else self.content[:size]
+
+
+def request(product_key: str, *, area_id=None) -> OfficialVectorAcquireRequest:
+ return OfficialVectorAcquireRequest(
+ bbox={
+ "min_x": 5.15,
+ "min_y": 51.18,
+ "max_x": 5.17,
+ "max_y": 51.20,
+ "crs": "EPSG:4326",
+ },
+ area_id=area_id,
+ product_key=product_key,
+ force_refresh=True,
+ )
+
+
+def polygon_feature(feature_id: str, *, properties=None) -> dict:
+ return {
+ "type": "Feature",
+ "id": feature_id,
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [[
+ [5.155, 51.185],
+ [5.175, 51.185],
+ [5.175, 51.195],
+ [5.155, 51.195],
+ [5.155, 51.185],
+ ]],
+ },
+ "properties": properties or {},
+ }
+
+
+def test_product_registries_expose_honest_forest_agriculture_nature_and_soil() -> None:
+ raster = {item["key"]: item for item in ThematicRasterAcquisitionService.list_products()}
+ vector = {item["key"]: item for item in OfficialVectorAcquisitionService.list_products()}
+
+ assert raster["forest_land_use_2025"]["included_source_values"] == [12]
+ assert raster["agricultural_land_use_2025"]["included_source_values"] == [13, 14]
+ assert "geen juridische bosgrens" in raster["forest_land_use_2025"]["limitation_message"].lower()
+ assert "geen alz-perceelaangifte" in raster["agricultural_land_use_2025"]["limitation_message"].lower()
+ assert set(vector) == {"bwk_natura2000_2025", "dov_soil_types"}
+ assert vector["bwk_natura2000_2025"]["authority_level"] == "authoritative"
+ assert vector["dov_soil_types"]["authority_level"] == "authoritative_historical_baseline"
+ assert "1949-1971" in vector["dov_soil_types"]["observation_label"]
+
+
+def test_land_use_classes_are_converted_to_binary_masks_without_nodata_cast_warning() -> None:
+ values = np.asarray([[12.0, 13.0], [14.0, -9999.0]], dtype="float32")
+ with MemoryFile() as source_memory:
+ with source_memory.open(
+ driver="GTiff",
+ width=2,
+ height=2,
+ count=1,
+ dtype="float32",
+ crs="EPSG:31370",
+ transform=from_origin(200_000, 210_020, 10, 10),
+ nodata=-9999.0,
+ ) as source:
+ source.write(values, 1)
+ from pyproj import Transformer
+ to_wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
+ scope = Polygon([
+ to_wgs84.transform(200_000, 210_000),
+ to_wgs84.transform(200_020, 210_000),
+ to_wgs84.transform(200_020, 210_020),
+ to_wgs84.transform(200_000, 210_020),
+ to_wgs84.transform(200_000, 210_000),
+ ])
+ content, validation = ThematicRasterAcquisitionService._normalize_raster(
+ source_memory.read(),
+ scope,
+ {
+ "product": ThematicRasterAcquisitionService._product("forest_land_use_2025"),
+ "width": 2,
+ "height": 2,
+ "bbox_epsg31370": [200_000, 210_000, 200_020, 210_020],
+ },
+ )
+ with MemoryFile(content) as normalized_memory:
+ with normalized_memory.open() as normalized:
+ output = normalized.read(1, masked=True)
+
+ assert output.compressed().tolist() == [1.0, 0.0, 0.0]
+ assert validation["included_source_values"] == [12]
+ assert validation["source_minimum_value"] == 12.0
+ assert validation["source_maximum_value"] == 14.0
+
+
+def test_bwk_wfs_pagination_clips_geometry_and_preserves_semantics() -> None:
+ product = OfficialVectorAcquisitionService._product("bwk_natura2000_2025")
+ scope_wgs84 = Polygon([
+ (5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)
+ ])
+ from shapely.ops import transform
+ from app.services.official_vector_acquisition_service import _TO_LAMBERT72
+
+ scope_metric = transform(_TO_LAMBERT72.transform, scope_wgs84)
+ calls = []
+
+ def opener(raw_request, timeout):
+ assert timeout == 180
+ calls.append(raw_request.full_url)
+ query = parse_qs(urlparse(raw_request.full_url).query)
+ assert query["typeNames"] == ["BWK:Bwkhab"]
+ assert query["sortBy"] == ["UIDN"]
+ feature = polygon_feature(
+ "Bwkhab.1",
+ properties={"UIDN": 42, "EVAL": "z", "HAB1": "2310", "PHAB1": 60},
+ )
+ if query.get("startIndex") == ["1"]:
+ return JsonResponse({
+ "type": "FeatureCollection",
+ "numberReturned": 0,
+ "features": [],
+ })
+ return JsonResponse({
+ "type": "FeatureCollection",
+ "numberReturned": 1,
+ "features": [feature],
+ })
+
+ features, transfer = OfficialVectorAcquisitionService._fetch_features(
+ product,
+ scope_wgs84,
+ scope_metric,
+ "bounded_selection",
+ Settings(_env_file=None, OFFICIAL_VECTOR_PAGE_SIZE=1),
+ opener,
+ )
+
+ assert len(calls) == 2
+ assert transfer["reference_truncated"] is False
+ assert features[0]["id"] == "BWK:Bwkhab:42"
+ assert features[0]["properties"]["bwk_evaluation_code"] == "z"
+ assert features[0]["properties"]["natura2000_share_percent"] == 60
+ assert features[0]["properties"]["geometry_clipped_to_selection"] is True
+
+
+def test_bwk_rejects_a_non_https_configured_endpoint_before_network_access() -> None:
+ product = OfficialVectorAcquisitionService._product("bwk_natura2000_2025")
+ scope_wgs84 = Polygon([
+ (5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)
+ ])
+ from shapely.ops import transform
+ from app.services.official_vector_acquisition_service import _TO_LAMBERT72
+
+ scope_metric = transform(_TO_LAMBERT72.transform, scope_wgs84)
+
+ def opener(_request, timeout):
+ del _request, timeout
+ raise AssertionError("network access must not occur")
+
+ with pytest.raises(AppError) as exc_info:
+ OfficialVectorAcquisitionService._fetch_features(
+ product,
+ scope_wgs84,
+ scope_metric,
+ "bounded_selection",
+ Settings(_env_file=None, BWK_WFS_URL="http://example.invalid/wfs"),
+ opener,
+ )
+
+ assert exc_info.value.code == "OFFICIAL_VECTOR_PROVIDER_INVALID_PAGINATION"
+
+
+def test_dov_wfs_uses_stable_complete_pagination_and_historical_fields() -> None:
+ product = OfficialVectorAcquisitionService._product("dov_soil_types")
+ scope_wgs84 = Polygon([
+ (5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)
+ ])
+ from shapely.ops import transform
+ from app.services.official_vector_acquisition_service import _TO_LAMBERT72
+
+ scope_metric = transform(_TO_LAMBERT72.transform, scope_wgs84)
+
+ def opener(raw_request, timeout):
+ assert timeout == 180
+ query = parse_qs(urlparse(raw_request.full_url).query)
+ assert query["typeNames"] == ["bodemkaart:bodemtypes"]
+ assert query["sortBy"] == ["gid"]
+ return JsonResponse({
+ "type": "FeatureCollection",
+ "numberMatched": 1,
+ "numberReturned": 1,
+ "features": [polygon_feature(
+ "bodemtypes.7",
+ properties={
+ "gid": 7,
+ "Bodemtype": "Zcg",
+ "Gegeneraliseerde_legende": "Droog zand",
+ "Drainageklasse": "Matig droog",
+ },
+ )],
+ })
+
+ features, transfer = OfficialVectorAcquisitionService._fetch_features(
+ product,
+ scope_wgs84,
+ scope_metric,
+ "bounded_selection",
+ Settings(_env_file=None),
+ opener,
+ )
+
+ assert transfer["page_count"] == 1
+ assert transfer["candidate_feature_count"] == 1
+ assert features[0]["properties"]["soil_type_code"] == "Zcg"
+ assert features[0]["properties"]["soil_generalized_legend"] == "Droog zand"
+ assert features[0]["properties"]["survey_period"] == "1949-1971"
+
+
+def test_nature_acquisition_persists_only_through_dataset_service(monkeypatch) -> None:
+ project_id, area_id, dataset_id = uuid4(), uuid4(), uuid4()
+ municipality = MultiPolygon([Polygon([
+ (5.15, 51.18), (5.17, 51.18), (5.17, 51.20), (5.15, 51.20), (5.15, 51.18)
+ ])])
+ db = FakeSession({
+ (Project, project_id): Project(id=project_id, name="Vlaanderen"),
+ (Area, area_id): Area(
+ id=area_id,
+ project_id=project_id,
+ name="Gemeente Mol",
+ geometry=from_shape(municipality, srid=4326),
+ ),
+ })
+ captured = {}
+
+ def opener(_request, timeout):
+ del timeout
+ return JsonResponse({
+ "type": "FeatureCollection",
+ "features": [polygon_feature(
+ "Bwkhab.1",
+ properties={"UIDN": 42, "EVAL": "w", "HAB1": "rbbmr", "PHAB1": 100},
+ )],
+ "links": [],
+ })
+
+ def persist(_db, **kwargs):
+ captured.update(kwargs)
+ dataset = Dataset(
+ id=dataset_id,
+ project_id=project_id,
+ area_id=area_id,
+ name=kwargs["filename"],
+ dataset_type="vector",
+ source=kwargs["source"],
+ dataset_role=kwargs["dataset_role"],
+ source_name=kwargs["source_name"],
+ reference_layer_name=kwargs["reference_layer_name"],
+ observed_at=kwargs["observed_at"],
+ source_version=kwargs["source_version"],
+ source_metadata=kwargs["source_metadata"],
+ provenance_metadata=kwargs["provenance_metadata"],
+ metadata_json={"feature_count": 1},
+ status="ready",
+ )
+ db.rows[(Dataset, dataset_id)] = dataset
+ return SimpleNamespace(id=dataset_id)
+
+ monkeypatch.setattr(DatasetService, "import_vector_bytes", persist)
+ result = OfficialVectorAcquisitionService.acquire(
+ db,
+ project_id,
+ request("bwk_natura2000_2025", area_id=area_id),
+ settings=Settings(_env_file=None),
+ opener=opener,
+ )
+
+ assert result["output_dataset_id"] == str(dataset_id)
+ assert captured["dataset_role"] == "reference"
+ assert captured["source_name"] == "inbo_bwk_natura2000"
+ assert captured["reference_layer_name"] == "nature_value"
+ assert captured["source_metadata"]["selection_aggregation"]["metric_key"] == "nature_mapped_area"
+ assert captured["source_metadata"]["selection_metrics"][4]["is_estimate"] is True
+ assert captured["provenance_metadata"]["reference_truncated"] is False
+ assert json.loads(captured["content"])["features"][0]["properties"]["coverage_scope"] == "municipality"
+
+
+def test_official_vector_routes_and_frontend_use_canonical_backend_path(monkeypatch) -> None:
+ project_id, dataset_id = uuid4(), uuid4()
+ db = FakeSession({(Project, project_id): Project(id=project_id, name="Vlaanderen")})
+ monkeypatch.setattr(
+ OfficialVectorAcquisitionService,
+ "acquire",
+ lambda *_args, **_kwargs: {
+ "output_dataset_id": str(dataset_id),
+ "product_key": "bwk_natura2000_2025",
+ "feature_count": 1,
+ },
+ )
+ app.dependency_overrides[get_db] = lambda: db
+ try:
+ client = TestClient(app)
+ products_response = client.get(
+ f"/api/v1/projects/{project_id}/datasets/official-vector/products"
+ )
+ acquire_response = client.post(
+ f"/api/v1/projects/{project_id}/datasets/official-vector/acquire",
+ json=request("bwk_natura2000_2025").model_dump(mode="json"),
+ )
+ finally:
+ app.dependency_overrides.clear()
+
+ assert products_response.status_code == 200
+ assert set(products_response.json()) == {"data"}
+ assert products_response.json()["data"]["total"] == 2
+ assert acquire_response.status_code == 200
+ assert set(acquire_response.json()) == {"data"}
+ assert acquire_response.json()["data"]["job_type"] == "vector.official.acquire"
+ assert any(isinstance(item, Job) for item in db.added)
+
+ selection_hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
+ catalog_hook = (ROOT / "frontend/src/hooks/useOfficialMapProducts.ts").read_text(encoding="utf-8")
+ workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
+ assert "datasetsApi.acquireOfficialVector" in selection_hook
+ assert "datasetsApi.listOfficialVectorProducts" in catalog_hook
+ assert "officialMapProducts.officialVector" in workspace
+ assert "geo.api.vlaanderen.be" not in workspace
diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml
index b91e0b5c..fd49f609 100644
--- a/deploy/unraid/geointel-unraid-template.xml
+++ b/deploy/unraid/geointel-unraid-template.xml
@@ -42,6 +42,11 @@
10
2
900
+ true
+ https://geo.api.vlaanderen.be/BWK/wfs
+ https://www.dov.vlaanderen.be/geoserver/wfs
+ 20000
+ 100000
true
https://geo.api.vlaanderen.be/DHMV/wcs
5.0
diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example
index 15bd61b7..004af607 100644
--- a/deploy/unraid/geointel.env.example
+++ b/deploy/unraid/geointel.env.example
@@ -43,6 +43,20 @@ SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodat
SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2
SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900
+
+# Bounded official BWK/Natura 2000 and DOV soil polygons, loaded only after a map selection.
+OFFICIAL_VECTOR_ENABLED=true
+BWK_WFS_URL=https://geo.api.vlaanderen.be/BWK/wfs
+DOV_SOIL_WFS_URL=https://www.dov.vlaanderen.be/geoserver/wfs
+OFFICIAL_VECTOR_MIN_SIDE_M=10
+OFFICIAL_VECTOR_MAX_SIDE_M=20000
+OFFICIAL_VECTOR_PAGE_SIZE=1000
+OFFICIAL_VECTOR_MAX_PAGES=200
+OFFICIAL_VECTOR_MAX_FEATURES=100000
+OFFICIAL_VECTOR_TIMEOUT_SECONDS=180
+OFFICIAL_VECTOR_MAX_RESPONSE_MB=20
+OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB=256
+OFFICIAL_VECTOR_CACHE_TTL_HOURS=24
DHMV_ENABLED=true
DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs
DHMV_RESOLUTION_M=5.0
diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh
index c2deabf6..75386419 100644
--- a/deploy/unraid/run-dockerman-container.sh
+++ b/deploy/unraid/run-dockerman-container.sh
@@ -35,6 +35,18 @@ SOURCE_CATALOG_ALZ_RELEASE_URL="${SOURCE_CATALOG_ALZ_RELEASE_URL:-https://landbo
SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS="${SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS:-10}"
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB="${SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB:-2}"
SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS="${SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS:-900}"
+OFFICIAL_VECTOR_ENABLED="${OFFICIAL_VECTOR_ENABLED:-true}"
+BWK_WFS_URL="${BWK_WFS_URL:-https://geo.api.vlaanderen.be/BWK/wfs}"
+DOV_SOIL_WFS_URL="${DOV_SOIL_WFS_URL:-https://www.dov.vlaanderen.be/geoserver/wfs}"
+OFFICIAL_VECTOR_MIN_SIDE_M="${OFFICIAL_VECTOR_MIN_SIDE_M:-10}"
+OFFICIAL_VECTOR_MAX_SIDE_M="${OFFICIAL_VECTOR_MAX_SIDE_M:-20000}"
+OFFICIAL_VECTOR_PAGE_SIZE="${OFFICIAL_VECTOR_PAGE_SIZE:-1000}"
+OFFICIAL_VECTOR_MAX_PAGES="${OFFICIAL_VECTOR_MAX_PAGES:-200}"
+OFFICIAL_VECTOR_MAX_FEATURES="${OFFICIAL_VECTOR_MAX_FEATURES:-100000}"
+OFFICIAL_VECTOR_TIMEOUT_SECONDS="${OFFICIAL_VECTOR_TIMEOUT_SECONDS:-180}"
+OFFICIAL_VECTOR_MAX_RESPONSE_MB="${OFFICIAL_VECTOR_MAX_RESPONSE_MB:-20}"
+OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB="${OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB:-256}"
+OFFICIAL_VECTOR_CACHE_TTL_HOURS="${OFFICIAL_VECTOR_CACHE_TTL_HOURS:-24}"
DHMV_ENABLED="${DHMV_ENABLED:-true}"
DHMV_WCS_URL="${DHMV_WCS_URL:-https://geo.api.vlaanderen.be/DHMV/wcs}"
DHMV_RESOLUTION_M="${DHMV_RESOLUTION_M:-5.0}"
@@ -153,6 +165,18 @@ docker run -d \
-e SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS="$SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS" \
-e SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB="$SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB" \
-e SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS="$SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS" \
+ -e OFFICIAL_VECTOR_ENABLED="$OFFICIAL_VECTOR_ENABLED" \
+ -e BWK_WFS_URL="$BWK_WFS_URL" \
+ -e DOV_SOIL_WFS_URL="$DOV_SOIL_WFS_URL" \
+ -e OFFICIAL_VECTOR_MIN_SIDE_M="$OFFICIAL_VECTOR_MIN_SIDE_M" \
+ -e OFFICIAL_VECTOR_MAX_SIDE_M="$OFFICIAL_VECTOR_MAX_SIDE_M" \
+ -e OFFICIAL_VECTOR_PAGE_SIZE="$OFFICIAL_VECTOR_PAGE_SIZE" \
+ -e OFFICIAL_VECTOR_MAX_PAGES="$OFFICIAL_VECTOR_MAX_PAGES" \
+ -e OFFICIAL_VECTOR_MAX_FEATURES="$OFFICIAL_VECTOR_MAX_FEATURES" \
+ -e OFFICIAL_VECTOR_TIMEOUT_SECONDS="$OFFICIAL_VECTOR_TIMEOUT_SECONDS" \
+ -e OFFICIAL_VECTOR_MAX_RESPONSE_MB="$OFFICIAL_VECTOR_MAX_RESPONSE_MB" \
+ -e OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB="$OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB" \
+ -e OFFICIAL_VECTOR_CACHE_TTL_HOURS="$OFFICIAL_VECTOR_CACHE_TTL_HOURS" \
-e DHMV_ENABLED="$DHMV_ENABLED" \
-e DHMV_WCS_URL="$DHMV_WCS_URL" \
-e DHMV_RESOLUTION_M="$DHMV_RESOLUTION_M" \
diff --git a/docker-compose.yml b/docker-compose.yml
index 5cc9e3c0..052dcbf1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,6 +43,18 @@ services:
GRB_MAX_RESPONSE_MB: ${GRB_MAX_RESPONSE_MB:-20}
GRB_MAX_TOTAL_RESPONSE_MB: ${GRB_MAX_TOTAL_RESPONSE_MB:-256}
GRB_CACHE_TTL_HOURS: ${GRB_CACHE_TTL_HOURS:-24}
+ OFFICIAL_VECTOR_ENABLED: ${OFFICIAL_VECTOR_ENABLED:-true}
+ BWK_WFS_URL: ${BWK_WFS_URL:-https://geo.api.vlaanderen.be/BWK/wfs}
+ DOV_SOIL_WFS_URL: ${DOV_SOIL_WFS_URL:-https://www.dov.vlaanderen.be/geoserver/wfs}
+ OFFICIAL_VECTOR_MIN_SIDE_M: ${OFFICIAL_VECTOR_MIN_SIDE_M:-10}
+ OFFICIAL_VECTOR_MAX_SIDE_M: ${OFFICIAL_VECTOR_MAX_SIDE_M:-20000}
+ OFFICIAL_VECTOR_PAGE_SIZE: ${OFFICIAL_VECTOR_PAGE_SIZE:-1000}
+ OFFICIAL_VECTOR_MAX_PAGES: ${OFFICIAL_VECTOR_MAX_PAGES:-200}
+ OFFICIAL_VECTOR_MAX_FEATURES: ${OFFICIAL_VECTOR_MAX_FEATURES:-100000}
+ OFFICIAL_VECTOR_TIMEOUT_SECONDS: ${OFFICIAL_VECTOR_TIMEOUT_SECONDS:-180}
+ OFFICIAL_VECTOR_MAX_RESPONSE_MB: ${OFFICIAL_VECTOR_MAX_RESPONSE_MB:-20}
+ OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB: ${OFFICIAL_VECTOR_MAX_TOTAL_RESPONSE_MB:-256}
+ OFFICIAL_VECTOR_CACHE_TTL_HOURS: ${OFFICIAL_VECTOR_CACHE_TTL_HOURS:-24}
SOURCE_CATALOG_STATBEL_DCAT_URL: ${SOURCE_CATALOG_STATBEL_DCAT_URL:-https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl}
SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB: ${SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB:-5}
SOURCE_CATALOG_ALZ_RELEASE_URL: ${SOURCE_CATALOG_ALZ_RELEASE_URL:-https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen}
diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md
index ac6ebe22..9e88b0cb 100644
--- a/docs/API_CONTRACTS.md
+++ b/docs/API_CONTRACTS.md
@@ -2195,3 +2195,51 @@ return an empty, honest result. No provider request occurs during analysis.
Future provider output continues to use DatasetService and, for vectors,
VectorFeatureService. Arbitrary service URLs, browser-side fetches, insecure
TLS bypasses and startup downloads remain forbidden.
+## Governed official nature and soil acquisition
+
+### GET `/api/v1/projects/{project_id}/datasets/official-vector/products`
+
+Returns the fixed official vector registry in the canonical `{ "data": ... }`
+envelope. The allowlist contains `bwk_natura2000_2025` and `dov_soil_types`;
+arbitrary collection names or URLs are never accepted.
+
+### POST `/api/v1/projects/{project_id}/datasets/official-vector/acquire`
+
+Request:
+
+```json
+{
+ "bbox": {
+ "min_x": 5.05,
+ "min_y": 51.15,
+ "max_x": 5.25,
+ "max_y": 51.30,
+ "crs": "EPSG:4326"
+ },
+ "area_id": "optional-area-uuid",
+ "product_key": "bwk_natura2000_2025",
+ "force_refresh": false
+}
+```
+
+The synchronous `vector.official.acquire` Job validates the metric request
+size, intersects `bbox` with the persisted Area, retrieves every bounded page,
+clips polygon geometry in EPSG:31370 and persists EPSG:4326 features through
+`DatasetService`. A repeated exact request can reuse the 24-hour cache.
+Provider errors, unstable or incomplete WFS pagination and safety-limit violations
+fail without persisting a truncated Dataset.
+
+`bwk_natura2000_2025` preserves BWK `EVAL`, `EENH*`, `HAB*` and `PHAB*`
+semantics. `dov_soil_types` preserves mapped soil, texture, drainage, profile
+and substrate classes and is dated as the 1949-1971 survey period. Neither
+contract accepts a caller-supplied endpoint.
+
+## Governed Landgebruik Vlaanderen forest and agriculture
+
+The existing
+`GET /api/v1/projects/{project_id}/datasets/thematic-raster/products` registry
+also returns `forest_land_use_2025` for source class 12 and
+`agricultural_land_use_2025` for source classes 13 and 14. Both use the
+existing thematic acquisition and selection contracts. Persisted rasters are
+binary masks; the source class allowlist and original source-value range are
+retained and validated.
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index 85e46af4..666fd8fa 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -10269,3 +10269,32 @@ Validation:
PostGIS 3.6 and Alembic head `202607160001`. Live browser acceptance showed
all four Flanders GRB cards as `Op aanvraag` and no stale vector content
underneath an unmeasured on-demand theme.
+
+## Sprint 240 - Operational forest, agriculture, nature and soil (2026-07-17)
+
+Implemented:
+- Added governed Landgebruik Vlaanderen 2025 class masks for forest and
+ agricultural use while keeping definitive ALZ parcels as a separate source.
+- Added one allowlisted official-vector service for BWK/Natura 2000 2025 and
+ DOV soil types with exact Area intersection, complete pagination, metric CRS
+ clipping, checksums, cache identity and canonical Dataset persistence.
+- Added source-faithful selection metadata for forest/agricultural hectares,
+ biological value, estimated PHAB habitat areas and historical soil classes.
+- Connected all four themes to the existing Flanders product catalog and
+ all-theme selection flow without introducing browser-side provider traffic.
+- Added Compose and editable Unraid runtime settings for provider endpoints
+ and transfer/feature guardrails.
+
+Validation:
+- Live read-only provider probes confirmed the BWK `BWK:Bwkhab` WFS collection,
+ stable `UIDN` paging and expected `EVAL`/`HAB`/`PHAB` fields.
+- Live DOV WFS probing confirmed `bodemkaart:bodemtypes`, stable
+ `numberMatched`/`numberReturned` and expected soil attributes.
+- Direct service probing over a small Mol rectangle completed without
+ truncation: 75 BWK source/retained polygons and 29 DOV candidates resulting
+ in 28 clipped soil polygons.
+- The complete readiness gate passed 950 backend tests, backend compilation,
+ the 120-route contract audit, Alembic head `202607160001`, frontend
+ TypeScript typecheck and the production Vite build.
+- Tower deployment and browser acceptance results are recorded after the
+ rebuilt all-in-one runtime is verified.
diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md
index a2569a65..a242e536 100644
--- a/docs/DATA_SOURCES.md
+++ b/docs/DATA_SOURCES.md
@@ -755,3 +755,47 @@ profiles per Area, writes an atomic resumable manifest and requests regional
activation only after all 285 partitions are accounted for. A municipality
with zero source points is retained as an explicit no-profile partition, not
silently omitted.
+# Operational bounded Flanders theme sources
+
+## Landgebruik Vlaanderen 2025: forest and agricultural use
+
+- Catalog:
+ `https://www.vlaanderen.be/datavindplaats/catalogus/landgebruik-vlaanderen-toestand-2025`
+- WCS coverage: `lu:lu_landgebruik_vlaa_2025_v3`
+- Source CRS: EPSG:31370
+- Native grid: 10 metres
+- Forest class: 12
+- Agricultural-use classes: 13 (arable) and 14 (grassland in agricultural use)
+
+GeoIntel validates the categorical source grid and persists a binary analysis
+mask for the requested product. Forest hectares are grid-derived land-use
+hectares, not legal forest boundaries, canopy cover, tree counts or wood
+volume. Agricultural-use hectares describe land use and are not ALZ parcel
+declarations, crop registrations, ownership boundaries or legal zoning.
+Definitive ALZ yearly snapshots remain a separate historical series.
+
+## BWK and Natura 2000 2025
+
+- Catalog:
+ `https://www.vlaanderen.be/datavindplaats/catalogus/biologische-waarderingskaart-en-natura-2000-habitatkaart-toestand-2025`
+- WFS 2.0: `https://geo.api.vlaanderen.be/BWK/wfs`
+- Collection: `BWK:Bwkhab`
+- Source storage and metric CRS: EPSG:31370
+- Persisted geometry: EPSG:4326
+- Attribution: `Bron: INBO`
+
+Bounded map acquisition retains official biological evaluation and habitat
+attributes. Exact intersection hectares are used for BWK value classes.
+Natura 2000 and regionally important biotope hectares derived from `PHAB*`
+shares are marked as estimates because shares belong to the complete source
+polygon and are proportionally scaled after clipping.
+
+## DOV digital soil map in the map flow
+
+The `dov_soil_types` product uses
+`https://www.dov.vlaanderen.be/geoserver/wfs`, collection
+`bodemkaart:bodemtypes`, stable WFS 2.0 paging ordered by `gid` and a
+consistent `numberMatched`. Geometry is clipped in EPSG:31370 and persisted in
+EPSG:4326. It remains an authoritative historical 1:20,000 baseline based on
+field work from 1949-1971, not a current drainage statement or site
+investigation.
diff --git a/docs/DATA_SPECIFICATION.md b/docs/DATA_SPECIFICATION.md
index e3051a3a..0e0ab669 100644
--- a/docs/DATA_SPECIFICATION.md
+++ b/docs/DATA_SPECIFICATION.md
@@ -480,3 +480,31 @@ normalized properties are:
Null means the provider did not expose a structured value. It is never
converted to zero. Dataset metadata records exact counts, measurement range,
scope, attribution and `volume_supported=false`.
+# Operational Flanders theme specification
+
+## Land-use forest and agriculture masks
+
+The governed 2025 Landgebruik Vlaanderen v3 coverage is categorical. GeoIntel
+derives two product-specific binary rasters only after validating integer
+source classes: forest class 12, and agricultural land-use classes 13 and 14.
+The measurement is intersected grid area in hectares at 10 m resolution.
+Forest volume, tree count, legal forest status, agricultural crop declaration,
+ownership and zoning are unsupported. The agricultural mask does not replace
+the temporal ALZ parcel series.
+
+## BWK and Natura 2000 polygons
+
+The `bwk_natura2000_2025` product follows stable allowlisted WFS 2.0 pagination,
+clips geometries in Lambert 72 and persists through DatasetService. Official
+`EVAL`, `EENH1..8`, `HAB1..5`, `PHAB1..5`, `HERK`, `HERKHAB`,
+`HERKPHAB` and `HABLEGENDE` remain available. BWK class areas are exact
+intersections; PHAB-derived habitat areas remain estimates.
+
+## DOV soil polygons
+
+The `dov_soil_types` product persists polygon geometry as EPSG:4326 and
+clips/measures in EPSG:31370. Soil type, unified type, series, generalized
+legend, texture, drainage, profile, substrate and region remain source
+attributes. The observation is the 1949-1971 field-survey period and the
+digital edition is June 2017. It is an authoritative historical baseline at
+1:20,000, not evidence of current drainage or parcel-level site conditions.
diff --git a/frontend/README.md b/frontend/README.md
index 20d1a1bd..3f8715da 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -704,3 +704,12 @@ A regional rectangle or full-Area analysis calls the partitioned backend
selection and draws only its bounded GeoJSON result. Switching to a
municipality automatically returns to the exact single-Area Dataset. Regional
downloads are recomputed server-side through the same manifest-aware path.
+
+## On-demand forest, agriculture, nature and soil
+
+In the Flanders workspace, `Bos`, `Landbouw`, `Natuurwaarde` and `Bodem` are
+discoverable before local provisioning. They remain `Op aanvraag` until the
+user selects a municipality or draws a bounded rectangle. Forest and
+agriculture use thematic raster analysis; nature value and soil use the
+persisted-vector GeoJSON pattern. The browser calls only the GeoIntel API and
+never contacts WCS, WFS or OGC providers directly.
diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx
index cc034811..79779684 100644
--- a/frontend/src/components/map/MapWorkspace.tsx
+++ b/frontend/src/components/map/MapWorkspace.tsx
@@ -777,6 +777,11 @@ export function MapWorkspace({
result[product.key] = null
}
}
+ if (flandersScopeSelected && officialMapProducts.officialVector.length > 0) {
+ for (const product of officialMapProducts.officialVector) {
+ result[product.theme] = null
+ }
+ }
return result
}, [
availableMapDatasets,
@@ -785,6 +790,7 @@ export function MapWorkspace({
officialMapProducts.dhmv.length,
officialMapProducts.floodHazard.length,
officialMapProducts.grb,
+ officialMapProducts.officialVector,
regionalScopeSelected,
selectedDhmvProductKey,
selectedFloodHazardDatasetId,
@@ -835,6 +841,17 @@ export function MapWorkspace({
limitationMessage: product.limitation_message,
})
}
+ for (const product of officialMapProducts.officialVector) {
+ result.set(product.theme, {
+ kind: 'official_vector',
+ productKey: product.key,
+ displayName: product.display_name,
+ theme: product.theme,
+ availabilityLabel: `${product.observation_label} · officiële vectorbron · laad bij selectie`,
+ attribution: product.attribution,
+ limitationMessage: product.limitation_message,
+ })
+ }
const dhmvProduct = officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
if (dhmvProduct) {
result.set('elevation', {
diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts
index 04987c5c..98619f69 100644
--- a/frontend/src/hooks/useMapThemeSelectionInsights.ts
+++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts
@@ -6,7 +6,7 @@ import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster'
-export type MapThemeAcquisitionKind = 'thematic_raster' | 'dhmv' | 'flood_hazard' | 'grb'
+export type MapThemeAcquisitionKind = 'thematic_raster' | 'dhmv' | 'flood_hazard' | 'grb' | 'official_vector'
export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind
@@ -92,10 +92,15 @@ export function useMapThemeSelectionInsights(
...commonPayload,
product_key: acquisition.productKey,
})
- : await datasetsApi.acquireGrb(selectedProjectId, {
- ...commonPayload,
- product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
- })
+ : acquisition.kind === 'grb'
+ ? await datasetsApi.acquireGrb(selectedProjectId, {
+ ...commonPayload,
+ product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels',
+ })
+ : await datasetsApi.acquireOfficialVector(selectedProjectId, {
+ ...commonPayload,
+ product_key: acquisition.productKey,
+ })
if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error(
acquisitionJob.error_message
diff --git a/frontend/src/hooks/useOfficialMapProducts.ts b/frontend/src/hooks/useOfficialMapProducts.ts
index b856af41..b5ff8a83 100644
--- a/frontend/src/hooks/useOfficialMapProducts.ts
+++ b/frontend/src/hooks/useOfficialMapProducts.ts
@@ -5,6 +5,7 @@ import type {
DhmvProductRead,
FloodHazardProductRead,
GrbProductRead,
+ OfficialVectorProductRead,
ThematicRasterProductRead,
} from '../types'
@@ -13,6 +14,7 @@ interface OfficialMapProducts {
dhmv: DhmvProductRead[]
floodHazard: FloodHazardProductRead[]
grb: GrbProductRead[]
+ officialVector: OfficialVectorProductRead[]
}
const EMPTY_PRODUCTS: OfficialMapProducts = {
@@ -20,6 +22,7 @@ const EMPTY_PRODUCTS: OfficialMapProducts = {
dhmv: [],
floodHazard: [],
grb: [],
+ officialVector: [],
}
export function useOfficialMapProducts(selectedProjectId: string | null) {
@@ -45,14 +48,16 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
datasetsApi.listDhmvProducts(selectedProjectId),
datasetsApi.listFloodHazardProducts(selectedProjectId),
datasetsApi.listGrbProducts(selectedProjectId),
+ datasetsApi.listOfficialVectorProducts(selectedProjectId),
])
- .then(([thematic, dhmv, floodHazard, grb]) => {
+ .then(([thematic, dhmv, floodHazard, grb, officialVector]) => {
if (!cancelled) {
setProducts({
thematic: thematic.items,
dhmv: dhmv.items,
floodHazard: floodHazard.items,
grb: grb.items,
+ officialVector: officialVector.items,
})
}
})
diff --git a/frontend/src/services/api/datasets.ts b/frontend/src/services/api/datasets.ts
index 2dc6edbf..0f9fd63e 100644
--- a/frontend/src/services/api/datasets.ts
+++ b/frontend/src/services/api/datasets.ts
@@ -28,6 +28,8 @@ import type {
DhmvProductRead,
GrbAcquireRequest,
GrbProductRead,
+ OfficialVectorAcquireRequest,
+ OfficialVectorProductRead,
TerrainSelectionResponse,
ThematicRasterAcquireRequest,
ThematicRasterProductRead,
@@ -152,6 +154,14 @@ export const datasetsApi = {
apiPost(`/api/v1/projects/${projectId}/datasets/grb/acquire`, payload),
listGrbProducts: (projectId: string): Promise<{ items: GrbProductRead[]; total: number }> =>
apiGet<{ items: GrbProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/grb/products`),
+ acquireOfficialVector: (projectId: string, payload: OfficialVectorAcquireRequest): Promise =>
+ apiPost(`/api/v1/projects/${projectId}/datasets/official-vector/acquire`, payload),
+ listOfficialVectorProducts: (
+ projectId: string,
+ ): Promise<{ items: OfficialVectorProductRead[]; total: number }> =>
+ apiGet<{ items: OfficialVectorProductRead[]; total: number }>(
+ `/api/v1/projects/${projectId}/datasets/official-vector/products`,
+ ),
selectTerrain: (
projectId: string,
datasetId: string,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c2ed680a..71237b31 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -383,6 +383,33 @@ export interface GrbProductRead {
limitation_message: string
}
+export interface OfficialVectorAcquireRequest {
+ bbox: VectorSelectionBBox
+ area_id?: string | null
+ product_key: string
+ force_refresh?: boolean
+}
+
+export interface OfficialVectorProductRead {
+ key: string
+ display_name: string
+ theme: 'nature_value' | 'soil'
+ provider: string
+ source_name: string
+ reference_layer_name: string
+ service_type: 'OGC API Features' | 'WFS 2.0'
+ collection: string
+ geometry_types: string[]
+ source_crs: string
+ source_version: string
+ observation_label: string
+ authority_level: 'authoritative' | 'authoritative_historical_baseline'
+ catalog_url: string
+ attribution: string
+ license_note: string
+ limitation_message: string
+}
+
export interface TerrainSelectionResponse {
dataset_id: string
dataset_ids: string[]
@@ -524,7 +551,7 @@ export interface ThematicRasterAcquireRequest {
export interface ThematicRasterProductRead {
key: string
display_name: string
- theme: 'space_occupation' | 'open_space' | 'population' | 'accessibility' | 'services'
+ theme: 'space_occupation' | 'open_space' | 'forest' | 'agriculture' | 'population' | 'accessibility' | 'services'
metric_kind: 'binary_area' | 'population_density' | 'index_score' | 'normalized_score'
coverage_id: string
native_resolution_m: number
@@ -537,6 +564,7 @@ export interface ThematicRasterProductRead {
license_note: string
legend_min_label: string
legend_max_label: string
+ included_source_values: number[]
limitation_message: string
}