feat: operationalize Flemish land and nature themes
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 22:38:25 +02:00
parent 0718d0ebba
commit c787fb2184
27 changed files with 2010 additions and 12 deletions
+12
View File
@@ -25,6 +25,18 @@ GRB_TIMEOUT_SECONDS=180
GRB_MAX_RESPONSE_MB=20 GRB_MAX_RESPONSE_MB=20
GRB_MAX_TOTAL_RESPONSE_MB=256 GRB_MAX_TOTAL_RESPONSE_MB=256
GRB_CACHE_TTL_HOURS=24 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_DCAT_URL=https://doc.statbel.be/publications/DCAT/DCAT_opendata_datasets.ttl
SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5 SOURCE_CATALOG_STATBEL_MAX_RESPONSE_MB=5
SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen
+19
View File
@@ -7,6 +7,25 @@
# Changelog # 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) ## Sprint 239 Governed bounded GRB map acquisition (2026-07-17)
- Added a fixed four-product GRB registry for buildings, roads, water and - Added a fixed four-product GRB registry for buildings, roads, water and
+21
View File
@@ -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_ENABLED`, `MDK_BATHYMETRY_WCS_URL`,
`MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS` and `MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS` and
`MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB`. TLS verification cannot be disabled. `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`.
+26
View File
@@ -32,6 +32,7 @@ from app.schemas import (
ThematicRasterAcquireRequest, ThematicRasterAcquireRequest,
ThematicRasterSelectionRequest, ThematicRasterSelectionRequest,
GrbAcquireRequest, GrbAcquireRequest,
OfficialVectorAcquireRequest,
VectorBBoxResponse, VectorBBoxResponse,
VectorBufferRequest, VectorBufferRequest,
VectorClipRequest, 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.source_catalog_probe_service import SourceCatalogProbeService
from app.services.grb_refresh_plan_service import GrbRefreshPlanService from app.services.grb_refresh_plan_service import GrbRefreshPlanService
from app.services.grb_acquisition_service import GrbAcquisitionService 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.orthophoto_acquisition_service import OrthophotoAcquisitionService
from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.dhmv_acquisition_service import DhmvAcquisitionService
from app.services.terrain_analysis_service import TerrainAnalysisService 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)}) 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) @router.post("/datasets/flood-hazard/acquire", response_model=dict)
def acquire_bounded_flood_hazard( def acquire_bounded_flood_hazard(
project_id: UUID, project_id: UUID,
+60
View File
@@ -87,6 +87,66 @@ class Settings(BaseSettings):
validation_alias="GRB_MAX_TOTAL_RESPONSE_MB", 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") 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_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED")
dhmv_wcs_url: str = Field( dhmv_wcs_url: str = Field(
default="https://geo.api.vlaanderen.be/DHMV/wcs", default="https://geo.api.vlaanderen.be/DHMV/wcs",
+8
View File
@@ -18,6 +18,11 @@ from .source_catalog import (
) )
from .grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary from .grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary
from .grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead from .grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead
from .official_vector import (
OfficialVectorAcquireRequest,
OfficialVectorAcquisitionResult,
OfficialVectorProductRead,
)
from .detection import ( from .detection import (
DetectionListResponse, DetectionListResponse,
DetectionModelCapability, DetectionModelCapability,
@@ -165,6 +170,9 @@ __all__ = [
"GrbAcquireRequest", "GrbAcquireRequest",
"GrbAcquisitionResult", "GrbAcquisitionResult",
"GrbProductRead", "GrbProductRead",
"OfficialVectorAcquireRequest",
"OfficialVectorAcquisitionResult",
"OfficialVectorProductRead",
"DetectionListResponse", "DetectionListResponse",
"DetectionModelCapability", "DetectionModelCapability",
"DetectionModelsResponse", "DetectionModelsResponse",
+54
View File
@@ -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
+1
View File
@@ -30,6 +30,7 @@ class ThematicRasterProductRead(BaseModel):
license_note: str license_note: str
legend_min_label: str legend_min_label: str
legend_max_label: str legend_max_label: str
included_source_values: list[int]
limitation_message: str limitation_message: str
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,7 @@ class ThematicRasterProduct:
legend_min_label: str legend_min_label: str
legend_max_label: str legend_max_label: str
limitation_message: str limitation_message: str
included_source_values: tuple[int, ...] = ()
class ThematicRasterAcquisitionService: class ThematicRasterAcquisitionService:
@@ -100,6 +101,44 @@ class ThematicRasterAcquisitionService:
"synoniem met natuur, bos, publieke toegankelijkheid of planologische bestemming." "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( ThematicRasterProduct(
key="population_density_2019", key="population_density_2019",
display_name="Inwonersdichtheid per hectare 2019", display_name="Inwonersdichtheid per hectare 2019",
@@ -176,6 +215,7 @@ class ThematicRasterAcquisitionService:
license_note=ThematicRasterAcquisitionService.LICENSE_NOTE, license_note=ThematicRasterAcquisitionService.LICENSE_NOTE,
legend_min_label=product.legend_min_label, legend_min_label=product.legend_min_label,
legend_max_label=product.legend_max_label, legend_max_label=product.legend_max_label,
included_source_values=list(product.included_source_values),
limitation_message=product.limitation_message, limitation_message=product.limitation_message,
).model_dump() ).model_dump()
for product in ThematicRasterAcquisitionService._products().values() for product in ThematicRasterAcquisitionService._products().values()
@@ -461,7 +501,29 @@ class ThematicRasterAcquisitionService:
invalid = np.ma.getmaskarray(band) | ~np.isfinite(raw) invalid = np.ma.getmaskarray(band) | ~np.isfinite(raw)
if source.nodata is not None: if source.nodata is not None:
invalid |= np.isclose(raw, float(source.nodata)) 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") values = normalized.compressed().astype("float64")
ThematicRasterAcquisitionService._validate_values(values, product) ThematicRasterAcquisitionService._validate_values(values, product)
profile = source.profile.copy() profile = source.profile.copy()
@@ -482,6 +544,9 @@ class ThematicRasterAcquisitionService:
"maximum_value": float(values.max()), "maximum_value": float(values.max()),
"p02_value": float(np.percentile(values, 2)), "p02_value": float(np.percentile(values, 2)),
"p98_value": float(np.percentile(values, 98)), "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: except AppError:
raise raise
@@ -563,6 +628,7 @@ class ThematicRasterAcquisitionService:
"analysis_resolution_m": product.native_resolution_m, "analysis_resolution_m": product.native_resolution_m,
"source_crs": ThematicRasterAcquisitionService.SOURCE_CRS, "source_crs": ThematicRasterAcquisitionService.SOURCE_CRS,
"source_value_unit": product.source_value_unit, "source_value_unit": product.source_value_unit,
"included_source_values": list(product.included_source_values),
"observation_year": product.observation_year, "observation_year": product.observation_year,
"observation_date_precision": "year", "observation_date_precision": "year",
"valid_pixel_count": validation["valid_pixel_count"], "valid_pixel_count": validation["valid_pixel_count"],
@@ -68,6 +68,10 @@ class ThematicRasterAnalysisService:
@staticmethod @staticmethod
def _unsupported_metrics(product: ThematicRasterProduct) -> list[str]: def _unsupported_metrics(product: ThematicRasterProduct) -> list[str]:
if product.metric_kind == "binary_area": 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"] return ["object_count", "parcel_area", "current_land_use"]
if product.metric_kind == "population_density": if product.metric_kind == "population_density":
return ["current_population", "household_count", "address_level_population"] return ["current_population", "household_count", "address_level_population"]
@@ -152,7 +156,12 @@ class ThematicRasterAnalysisService:
positive_count = int(np.count_nonzero(values >= 0.5)) positive_count = int(np.count_nonzero(values >= 0.5))
positive_area_ha = positive_count * cell_area_m2 / 10_000.0 positive_area_ha = positive_count * cell_area_m2 / 10_000.0
positive_share = positive_count / max(1, valid_cell_count) * 100.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 = [ 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}_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"), 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 = { palettes = {
"space_occupation": np.asarray([[251, 231, 211], [190, 62, 51]], dtype="float64"), "space_occupation": np.asarray([[251, 231, 211], [190, 62, 51]], dtype="float64"),
"open_space": np.asarray([[221, 238, 219], [38, 122, 70]], 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"), "population": np.asarray([[238, 231, 246], [103, 58, 151]], dtype="float64"),
"accessibility": np.asarray([[233, 241, 244], [15, 118, 110]], dtype="float64"), "accessibility": np.asarray([[233, 241, 244], [15, 118, 110]], dtype="float64"),
"services": np.asarray([[255, 244, 191], [182, 109, 22]], dtype="float64"), "services": np.asarray([[255, 244, 191], [182, 109, 22]], dtype="float64"),
@@ -122,21 +122,33 @@ def raster_bytes(values: np.ndarray, resolution: float, *, nodata: float = -9999
return memory.read() 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() products = ThematicRasterAcquisitionService.list_products()
assert [item["key"] for item in products] == [ assert [item["key"] for item in products] == [
"space_occupation_2025", "space_occupation_2025",
"open_space_2022", "open_space_2022",
"forest_land_use_2025",
"agricultural_land_use_2025",
"population_density_2019", "population_density_2019",
"node_value_2022", "node_value_2022",
"service_level_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 {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["coverage_id"].startswith(("lu:", "ni:")) for item in products)
assert all(item["source_crs"] == "EPSG:31370" 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 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: 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() app.dependency_overrides.clear()
assert products.status_code == 200 and set(products.json()) == {"data"} 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.status_code == 200 and set(acquisition.json()) == {"data"}
assert acquisition.json()["data"]["job_type"] == "raster.thematic.acquire" assert acquisition.json()["data"]["job_type"] == "raster.thematic.acquire"
assert selection.status_code == 200 and selection.json()["data"]["theme"] == "population" assert selection.status_code == 200 and selection.json()["data"]["theme"] == "population"
@@ -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
@@ -42,6 +42,11 @@
<Config Name="Catalog Probe Timeout (seconds)" Target="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS" Default="10" Mode="" Description="Per-request timeout for explicit read-only official catalog checks." Type="Variable" Display="advanced" Required="true" Mask="false">10</Config> <Config Name="Catalog Probe Timeout (seconds)" Target="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS" Default="10" Mode="" Description="Per-request timeout for explicit read-only official catalog checks." Type="Variable" Display="advanced" Required="true" Mask="false">10</Config>
<Config Name="Catalog Probe Maximum Response (MiB)" Target="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB" Default="2" Mode="" Description="Maximum capabilities or ISO metadata response size accepted by a catalog probe." Type="Variable" Display="advanced" Required="true" Mask="false">2</Config> <Config Name="Catalog Probe Maximum Response (MiB)" Target="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB" Default="2" Mode="" Description="Maximum capabilities or ISO metadata response size accepted by a catalog probe." Type="Variable" Display="advanced" Required="true" Mask="false">2</Config>
<Config Name="Catalog Probe Cache (seconds)" Target="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS" Default="900" Mode="" Description="Short in-memory cache for repeated official edition checks; use zero to disable." Type="Variable" Display="advanced" Required="true" Mask="false">900</Config> <Config Name="Catalog Probe Cache (seconds)" Target="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS" Default="900" Mode="" Description="Short in-memory cache for repeated official edition checks; use zero to disable." Type="Variable" Display="advanced" Required="true" Mask="false">900</Config>
<Config Name="Official BWK and Soil Acquisition" Target="OFFICIAL_VECTOR_ENABLED" Default="true" Mode="" Description="Allow explicit bounded BWK/Natura 2000 and DOV soil polygon acquisition after a map selection." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="BWK WFS URL" Target="BWK_WFS_URL" Default="https://geo.api.vlaanderen.be/BWK/wfs" Mode="" Description="Official allowlisted INBO BWK and Natura 2000 WFS 2.0 endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/BWK/wfs</Config>
<Config Name="DOV Soil WFS URL" Target="DOV_SOIL_WFS_URL" Default="https://www.dov.vlaanderen.be/geoserver/wfs" Mode="" Description="Official allowlisted DOV WFS endpoint for historical soil type polygons." Type="Variable" Display="advanced" Required="true" Mask="false">https://www.dov.vlaanderen.be/geoserver/wfs</Config>
<Config Name="Official Vector Maximum Side (m)" Target="OFFICIAL_VECTOR_MAX_SIDE_M" Default="20000" Mode="" Description="Maximum side length for one BWK or soil selection before provider access." Type="Variable" Display="advanced" Required="true" Mask="false">20000</Config>
<Config Name="Official Vector Maximum Features" Target="OFFICIAL_VECTOR_MAX_FEATURES" Default="100000" Mode="" Description="Hard feature limit for one bounded BWK or soil acquisition; larger selections fail without truncated persistence." Type="Variable" Display="advanced" Required="true" Mask="false">100000</Config>
<Config Name="Official DHMV Acquisition" Target="DHMV_ENABLED" Default="true" Mode="" Description="Allow bounded official DHMV II terrain and surface raster acquisition." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config> <Config Name="Official DHMV Acquisition" Target="DHMV_ENABLED" Default="true" Mode="" Description="Allow bounded official DHMV II terrain and surface raster acquisition." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="DHMV WCS URL" Target="DHMV_WCS_URL" Default="https://geo.api.vlaanderen.be/DHMV/wcs" Mode="" Description="Official Digitaal Vlaanderen DHMV WCS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/DHMV/wcs</Config> <Config Name="DHMV WCS URL" Target="DHMV_WCS_URL" Default="https://geo.api.vlaanderen.be/DHMV/wcs" Mode="" Description="Official Digitaal Vlaanderen DHMV WCS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/DHMV/wcs</Config>
<Config Name="DHMV Analysis Resolution (m)" Target="DHMV_RESOLUTION_M" Default="5.0" Mode="" Description="Stored analysis grid resolution. Native source resolution remains recorded as 1 metre." Type="Variable" Display="advanced" Required="true" Mask="false">5.0</Config> <Config Name="DHMV Analysis Resolution (m)" Target="DHMV_RESOLUTION_M" Default="5.0" Mode="" Description="Stored analysis grid resolution. Native source resolution remains recorded as 1 metre." Type="Variable" Display="advanced" Required="true" Mask="false">5.0</Config>
+14
View File
@@ -43,6 +43,20 @@ SOURCE_CATALOG_ALZ_RELEASE_URL=https://landbouwcijfers.vlaanderen.be/open-geodat
SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10 SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2 SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2
SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900 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_ENABLED=true
DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs
DHMV_RESOLUTION_M=5.0 DHMV_RESOLUTION_M=5.0
+24
View File
@@ -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_TIMEOUT_SECONDS="${SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS:-10}"
SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB="${SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB:-2}" 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}" 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_ENABLED="${DHMV_ENABLED:-true}"
DHMV_WCS_URL="${DHMV_WCS_URL:-https://geo.api.vlaanderen.be/DHMV/wcs}" DHMV_WCS_URL="${DHMV_WCS_URL:-https://geo.api.vlaanderen.be/DHMV/wcs}"
DHMV_RESOLUTION_M="${DHMV_RESOLUTION_M:-5.0}" 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_TIMEOUT_SECONDS="$SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS" \
-e SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB="$SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB" \ -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 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_ENABLED="$DHMV_ENABLED" \
-e DHMV_WCS_URL="$DHMV_WCS_URL" \ -e DHMV_WCS_URL="$DHMV_WCS_URL" \
-e DHMV_RESOLUTION_M="$DHMV_RESOLUTION_M" \ -e DHMV_RESOLUTION_M="$DHMV_RESOLUTION_M" \
+12
View File
@@ -43,6 +43,18 @@ services:
GRB_MAX_RESPONSE_MB: ${GRB_MAX_RESPONSE_MB:-20} GRB_MAX_RESPONSE_MB: ${GRB_MAX_RESPONSE_MB:-20}
GRB_MAX_TOTAL_RESPONSE_MB: ${GRB_MAX_TOTAL_RESPONSE_MB:-256} GRB_MAX_TOTAL_RESPONSE_MB: ${GRB_MAX_TOTAL_RESPONSE_MB:-256}
GRB_CACHE_TTL_HOURS: ${GRB_CACHE_TTL_HOURS:-24} 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_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_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} SOURCE_CATALOG_ALZ_RELEASE_URL: ${SOURCE_CATALOG_ALZ_RELEASE_URL:-https://landbouwcijfers.vlaanderen.be/open-geodata-landbouwgebruikspercelen}
+48
View File
@@ -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, Future provider output continues to use DatasetService and, for vectors,
VectorFeatureService. Arbitrary service URLs, browser-side fetches, insecure VectorFeatureService. Arbitrary service URLs, browser-side fetches, insecure
TLS bypasses and startup downloads remain forbidden. 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.
+29
View File
@@ -10269,3 +10269,32 @@ Validation:
PostGIS 3.6 and Alembic head `202607160001`. Live browser acceptance showed PostGIS 3.6 and Alembic head `202607160001`. Live browser acceptance showed
all four Flanders GRB cards as `Op aanvraag` and no stale vector content all four Flanders GRB cards as `Op aanvraag` and no stale vector content
underneath an unmeasured on-demand theme. 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.
+44
View File
@@ -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 activation only after all 285 partitions are accounted for. A municipality
with zero source points is retained as an explicit no-profile partition, not with zero source points is retained as an explicit no-profile partition, not
silently omitted. 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.
+28
View File
@@ -480,3 +480,31 @@ normalized properties are:
Null means the provider did not expose a structured value. It is never Null means the provider did not expose a structured value. It is never
converted to zero. Dataset metadata records exact counts, measurement range, converted to zero. Dataset metadata records exact counts, measurement range,
scope, attribution and `volume_supported=false`. 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.
+9
View File
@@ -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 selection and draws only its bounded GeoJSON result. Switching to a
municipality automatically returns to the exact single-Area Dataset. Regional municipality automatically returns to the exact single-Area Dataset. Regional
downloads are recomputed server-side through the same manifest-aware path. 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.
@@ -777,6 +777,11 @@ export function MapWorkspace({
result[product.key] = null result[product.key] = null
} }
} }
if (flandersScopeSelected && officialMapProducts.officialVector.length > 0) {
for (const product of officialMapProducts.officialVector) {
result[product.theme] = null
}
}
return result return result
}, [ }, [
availableMapDatasets, availableMapDatasets,
@@ -785,6 +790,7 @@ export function MapWorkspace({
officialMapProducts.dhmv.length, officialMapProducts.dhmv.length,
officialMapProducts.floodHazard.length, officialMapProducts.floodHazard.length,
officialMapProducts.grb, officialMapProducts.grb,
officialMapProducts.officialVector,
regionalScopeSelected, regionalScopeSelected,
selectedDhmvProductKey, selectedDhmvProductKey,
selectedFloodHazardDatasetId, selectedFloodHazardDatasetId,
@@ -835,6 +841,17 @@ export function MapWorkspace({
limitationMessage: product.limitation_message, 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) const dhmvProduct = officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey)
if (dhmvProduct) { if (dhmvProduct) {
result.set('elevation', { result.set('elevation', {
@@ -6,7 +6,7 @@ import { terrainSelectionToMapSelection } from '../lib/terrainSelection'
import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection' import { floodHazardSelectionToMapSelection } from '../lib/floodHazardSelection'
import { thematicRasterSelectionToMapSelection } from '../lib/thematicRaster' 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 { export interface MapThemeAcquisition {
kind: MapThemeAcquisitionKind kind: MapThemeAcquisitionKind
@@ -92,10 +92,15 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
...commonPayload, ...commonPayload,
product_key: acquisition.productKey, product_key: acquisition.productKey,
}) })
: await datasetsApi.acquireGrb(selectedProjectId, { : acquisition.kind === 'grb'
...commonPayload, ? await datasetsApi.acquireGrb(selectedProjectId, {
product_key: acquisition.productKey as 'buildings' | 'roads' | 'water' | 'parcels', ...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) { if (acquisitionJob.status !== 'success' || !acquisitionJob.output_dataset_id) {
throw new Error( throw new Error(
acquisitionJob.error_message acquisitionJob.error_message
+6 -1
View File
@@ -5,6 +5,7 @@ import type {
DhmvProductRead, DhmvProductRead,
FloodHazardProductRead, FloodHazardProductRead,
GrbProductRead, GrbProductRead,
OfficialVectorProductRead,
ThematicRasterProductRead, ThematicRasterProductRead,
} from '../types' } from '../types'
@@ -13,6 +14,7 @@ interface OfficialMapProducts {
dhmv: DhmvProductRead[] dhmv: DhmvProductRead[]
floodHazard: FloodHazardProductRead[] floodHazard: FloodHazardProductRead[]
grb: GrbProductRead[] grb: GrbProductRead[]
officialVector: OfficialVectorProductRead[]
} }
const EMPTY_PRODUCTS: OfficialMapProducts = { const EMPTY_PRODUCTS: OfficialMapProducts = {
@@ -20,6 +22,7 @@ const EMPTY_PRODUCTS: OfficialMapProducts = {
dhmv: [], dhmv: [],
floodHazard: [], floodHazard: [],
grb: [], grb: [],
officialVector: [],
} }
export function useOfficialMapProducts(selectedProjectId: string | null) { export function useOfficialMapProducts(selectedProjectId: string | null) {
@@ -45,14 +48,16 @@ export function useOfficialMapProducts(selectedProjectId: string | null) {
datasetsApi.listDhmvProducts(selectedProjectId), datasetsApi.listDhmvProducts(selectedProjectId),
datasetsApi.listFloodHazardProducts(selectedProjectId), datasetsApi.listFloodHazardProducts(selectedProjectId),
datasetsApi.listGrbProducts(selectedProjectId), datasetsApi.listGrbProducts(selectedProjectId),
datasetsApi.listOfficialVectorProducts(selectedProjectId),
]) ])
.then(([thematic, dhmv, floodHazard, grb]) => { .then(([thematic, dhmv, floodHazard, grb, officialVector]) => {
if (!cancelled) { if (!cancelled) {
setProducts({ setProducts({
thematic: thematic.items, thematic: thematic.items,
dhmv: dhmv.items, dhmv: dhmv.items,
floodHazard: floodHazard.items, floodHazard: floodHazard.items,
grb: grb.items, grb: grb.items,
officialVector: officialVector.items,
}) })
} }
}) })
+10
View File
@@ -28,6 +28,8 @@ import type {
DhmvProductRead, DhmvProductRead,
GrbAcquireRequest, GrbAcquireRequest,
GrbProductRead, GrbProductRead,
OfficialVectorAcquireRequest,
OfficialVectorProductRead,
TerrainSelectionResponse, TerrainSelectionResponse,
ThematicRasterAcquireRequest, ThematicRasterAcquireRequest,
ThematicRasterProductRead, ThematicRasterProductRead,
@@ -152,6 +154,14 @@ export const datasetsApi = {
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/grb/acquire`, payload), apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/grb/acquire`, payload),
listGrbProducts: (projectId: string): Promise<{ items: GrbProductRead[]; total: number }> => listGrbProducts: (projectId: string): Promise<{ items: GrbProductRead[]; total: number }> =>
apiGet<{ items: GrbProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/grb/products`), apiGet<{ items: GrbProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/grb/products`),
acquireOfficialVector: (projectId: string, payload: OfficialVectorAcquireRequest): Promise<JobRead> =>
apiPost<JobRead>(`/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: ( selectTerrain: (
projectId: string, projectId: string,
datasetId: string, datasetId: string,
+29 -1
View File
@@ -383,6 +383,33 @@ export interface GrbProductRead {
limitation_message: string 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 { export interface TerrainSelectionResponse {
dataset_id: string dataset_id: string
dataset_ids: string[] dataset_ids: string[]
@@ -524,7 +551,7 @@ export interface ThematicRasterAcquireRequest {
export interface ThematicRasterProductRead { export interface ThematicRasterProductRead {
key: string key: string
display_name: 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' metric_kind: 'binary_area' | 'population_density' | 'index_score' | 'normalized_score'
coverage_id: string coverage_id: string
native_resolution_m: number native_resolution_m: number
@@ -537,6 +564,7 @@ export interface ThematicRasterProductRead {
license_note: string license_note: string
legend_min_label: string legend_min_label: string
legend_max_label: string legend_max_label: string
included_source_values: number[]
limitation_message: string limitation_message: string
} }