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 }