1677 lines
71 KiB
Python
1677 lines
71 KiB
Python
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 LineString, MultiLineString, 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 | None
|
|
valid_from: datetime | None
|
|
valid_to: datetime | None
|
|
primary_metric: dict[str, Any]
|
|
selection_metrics: tuple[dict[str, Any], ...]
|
|
geometry_types: tuple[str, ...] = ("Polygon", "MultiPolygon")
|
|
coverage_zones: tuple[str, ...] = ("flanders",)
|
|
endpoint_kind: str = "wfs"
|
|
response_crs: str = "EPSG:4326"
|
|
identity_field: str | None = None
|
|
requires_coverage_area: bool = False
|
|
|
|
|
|
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,
|
|
},
|
|
),
|
|
endpoint_kind="bwk_wfs",
|
|
),
|
|
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"],
|
|
},
|
|
),
|
|
endpoint_kind="dov_wfs",
|
|
),
|
|
OfficialVectorProduct(
|
|
key="spw_picc_buildings",
|
|
display_name="PICC building footprints",
|
|
theme="buildings",
|
|
provider="Service public de Wallonie",
|
|
source_name="spw_picc",
|
|
reference_layer_name="buildings",
|
|
service_type="ArcGIS REST",
|
|
collection="11",
|
|
source_crs="EPSG:3812",
|
|
source_version="2026-07-11",
|
|
observation_label="Weekly updated PICC snapshot",
|
|
authority_level="authoritative",
|
|
catalog_url=(
|
|
"https://geoportail.wallonie.be/catalogue/"
|
|
"b795de68-726c-4bdf-a62a-a42686aa5b6f.html"
|
|
),
|
|
attribution="Service public de Wallonie (SPW) - PICC",
|
|
license_note="CC BY 4.0; cite SPW PICC and identify modifications.",
|
|
limitation_message=(
|
|
"Topographic building footprints from PICC; these are not cadastral parcels "
|
|
"or legal building registrations."
|
|
),
|
|
source="SPW PICC ArcGIS REST",
|
|
observed_at=None,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
primary_metric={
|
|
"metric_key": "building_footprint_area",
|
|
"method": "intersection_area",
|
|
"label": "Bebouwde voetafdruk",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"is_estimate": False,
|
|
},
|
|
selection_metrics=(
|
|
{
|
|
"metric_key": "building_count",
|
|
"method": "feature_count",
|
|
"label": "Gebouwen",
|
|
"unit": "objecten",
|
|
"geometry_dimension": 2,
|
|
},
|
|
),
|
|
coverage_zones=("wallonia",),
|
|
endpoint_kind="spw_arcgis",
|
|
identity_field="GEOREF_ID",
|
|
requires_coverage_area=True,
|
|
),
|
|
OfficialVectorProduct(
|
|
key="spw_picc_roads",
|
|
display_name="PICC road axes",
|
|
theme="roads",
|
|
provider="Service public de Wallonie",
|
|
source_name="spw_picc",
|
|
reference_layer_name="roads",
|
|
service_type="ArcGIS REST",
|
|
collection="21",
|
|
source_crs="EPSG:3812",
|
|
source_version="2026-07-11",
|
|
observation_label="Weekly updated PICC snapshot",
|
|
authority_level="authoritative",
|
|
catalog_url=(
|
|
"https://geoportail.wallonie.be/catalogue/"
|
|
"b795de68-726c-4bdf-a62a-a42686aa5b6f.html"
|
|
),
|
|
attribution="Service public de Wallonie (SPW) - PICC",
|
|
license_note="CC BY 4.0; cite SPW PICC and identify modifications.",
|
|
limitation_message=(
|
|
"PICC road axes describe topographic road geometry and are not a routing "
|
|
"network or a traffic measurement."
|
|
),
|
|
source="SPW PICC ArcGIS REST",
|
|
observed_at=None,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
primary_metric={
|
|
"metric_key": "road_length",
|
|
"method": "intersection_length",
|
|
"label": "Wegaslengte",
|
|
"unit": "km",
|
|
"geometry_dimension": 1,
|
|
"is_estimate": False,
|
|
},
|
|
selection_metrics=(
|
|
{
|
|
"metric_key": "road_segment_count",
|
|
"method": "feature_count",
|
|
"label": "Wegsegmenten",
|
|
"unit": "objecten",
|
|
"geometry_dimension": 1,
|
|
},
|
|
),
|
|
geometry_types=("LineString", "MultiLineString"),
|
|
coverage_zones=("wallonia",),
|
|
endpoint_kind="spw_arcgis",
|
|
identity_field="GEOREF_ID",
|
|
requires_coverage_area=True,
|
|
),
|
|
OfficialVectorProduct(
|
|
key="spw_picc_waterways",
|
|
display_name="PICC hydrographic axes",
|
|
theme="water",
|
|
provider="Service public de Wallonie",
|
|
source_name="spw_picc",
|
|
reference_layer_name="water",
|
|
service_type="ArcGIS REST",
|
|
collection="28",
|
|
source_crs="EPSG:3812",
|
|
source_version="2026-07-11",
|
|
observation_label="Weekly updated PICC snapshot",
|
|
authority_level="authoritative",
|
|
catalog_url=(
|
|
"https://geoportail.wallonie.be/catalogue/"
|
|
"b795de68-726c-4bdf-a62a-a42686aa5b6f.html"
|
|
),
|
|
attribution="Service public de Wallonie (SPW) - PICC",
|
|
license_note="CC BY 4.0; cite SPW PICC and identify modifications.",
|
|
limitation_message=(
|
|
"Hydrographic axes describe mapped centre lines. They do not provide depth, "
|
|
"discharge or water volume."
|
|
),
|
|
source="SPW PICC ArcGIS REST",
|
|
observed_at=None,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
primary_metric={
|
|
"metric_key": "waterway_length",
|
|
"method": "intersection_length",
|
|
"label": "Waterlooplengte",
|
|
"unit": "km",
|
|
"geometry_dimension": 1,
|
|
"is_estimate": False,
|
|
},
|
|
selection_metrics=(
|
|
{
|
|
"metric_key": "waterway_segment_count",
|
|
"method": "feature_count",
|
|
"label": "Waterloopsegmenten",
|
|
"unit": "objecten",
|
|
"geometry_dimension": 1,
|
|
},
|
|
),
|
|
geometry_types=("LineString", "MultiLineString"),
|
|
coverage_zones=("wallonia",),
|
|
endpoint_kind="spw_arcgis",
|
|
identity_field="GEOREF_ID",
|
|
requires_coverage_area=True,
|
|
),
|
|
OfficialVectorProduct(
|
|
key="spw_picc_water_surfaces",
|
|
display_name="PICC hydrographic surfaces",
|
|
theme="water",
|
|
provider="Service public de Wallonie",
|
|
source_name="spw_picc",
|
|
reference_layer_name="water",
|
|
service_type="ArcGIS REST",
|
|
collection="30",
|
|
source_crs="EPSG:3812",
|
|
source_version="2026-07-11",
|
|
observation_label="Weekly updated PICC snapshot",
|
|
authority_level="authoritative",
|
|
catalog_url=(
|
|
"https://geoportail.wallonie.be/catalogue/"
|
|
"b795de68-726c-4bdf-a62a-a42686aa5b6f.html"
|
|
),
|
|
attribution="Service public de Wallonie (SPW) - PICC",
|
|
license_note="CC BY 4.0; cite SPW PICC and identify modifications.",
|
|
limitation_message=(
|
|
"Mapped hydrographic surface area is not water volume and does not imply "
|
|
"a measured water level."
|
|
),
|
|
source="SPW PICC ArcGIS REST",
|
|
observed_at=None,
|
|
valid_from=None,
|
|
valid_to=None,
|
|
primary_metric={
|
|
"metric_key": "water_surface_area",
|
|
"method": "intersection_area",
|
|
"label": "Wateroppervlakte",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"is_estimate": False,
|
|
},
|
|
selection_metrics=(
|
|
{
|
|
"metric_key": "water_surface_count",
|
|
"method": "feature_count",
|
|
"label": "Wateroppervlakken",
|
|
"unit": "objecten",
|
|
"geometry_dimension": 2,
|
|
},
|
|
),
|
|
coverage_zones=("wallonia",),
|
|
endpoint_kind="spw_arcgis",
|
|
identity_field="GEOREF_ID",
|
|
requires_coverage_area=True,
|
|
),
|
|
OfficialVectorProduct(
|
|
key="urbis_buildings",
|
|
display_name="UrbIS buildings",
|
|
theme="buildings",
|
|
provider="Paradigm Brussels",
|
|
source_name="urbis",
|
|
reference_layer_name="buildings",
|
|
service_type="WFS 2.0",
|
|
collection="urbisvector:Buildings",
|
|
source_crs="EPSG:31370",
|
|
source_version="2026-06-06",
|
|
observation_label="UrbIS revision 6 June 2026",
|
|
authority_level="authoritative",
|
|
catalog_url=(
|
|
"https://datastore.brussels/web/data/dataset/"
|
|
"2cf42541-1813-11ef-8a81-00090ffe0001"
|
|
),
|
|
attribution="Paradigm Brussels - UrbIS",
|
|
license_note="Buildings are published under CC0.",
|
|
limitation_message=(
|
|
"UrbIS building geometry is a regional topographic reference and is not "
|
|
"a legal cadastral registration."
|
|
),
|
|
source="UrbIS WFS",
|
|
observed_at=datetime(2026, 6, 6, tzinfo=UTC),
|
|
valid_from=None,
|
|
valid_to=None,
|
|
primary_metric={
|
|
"metric_key": "building_footprint_area",
|
|
"method": "intersection_area",
|
|
"label": "Bebouwde voetafdruk",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"is_estimate": False,
|
|
},
|
|
selection_metrics=(
|
|
{
|
|
"metric_key": "building_count",
|
|
"method": "feature_count",
|
|
"label": "Gebouwen",
|
|
"unit": "objecten",
|
|
"geometry_dimension": 2,
|
|
},
|
|
),
|
|
coverage_zones=("brussels",),
|
|
endpoint_kind="urbis_wfs",
|
|
response_crs="EPSG:31370",
|
|
identity_field="INSPIRE_ID",
|
|
requires_coverage_area=True,
|
|
),
|
|
OfficialVectorProduct(
|
|
key="urbis_cadastral_parcels",
|
|
display_name="UrbIS cadastral parcels",
|
|
theme="parcels",
|
|
provider="Paradigm Brussels / FPS Finance",
|
|
source_name="urbis",
|
|
reference_layer_name="parcels",
|
|
service_type="WFS 2.0",
|
|
collection="urbisvector:CadastralParcels",
|
|
source_crs="EPSG:31370",
|
|
source_version="2026-06-06",
|
|
observation_label="UrbIS revision 6 June 2026",
|
|
authority_level="authoritative",
|
|
catalog_url=(
|
|
"https://datastore.brussels/web/data/dataset/"
|
|
"2cf42541-1813-11ef-8a81-00090ffe0001"
|
|
),
|
|
attribution="Paradigm Brussels and FPS Finance - cadastral parcel plan",
|
|
license_note=(
|
|
"The FPS Finance open-data cadastral plan licence applies to cadastral parcels."
|
|
),
|
|
limitation_message=(
|
|
"Cadastral parcel geometry is reference data. GeoIntel does not infer "
|
|
"ownership, rights or legal boundaries beyond the published source."
|
|
),
|
|
source="UrbIS WFS",
|
|
observed_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
valid_from=None,
|
|
valid_to=None,
|
|
primary_metric={
|
|
"metric_key": "parcel_area",
|
|
"method": "intersection_area",
|
|
"label": "Perceeloppervlakte",
|
|
"unit": "ha",
|
|
"geometry_dimension": 2,
|
|
"is_estimate": False,
|
|
},
|
|
selection_metrics=(
|
|
{
|
|
"metric_key": "parcel_count",
|
|
"method": "feature_count",
|
|
"label": "Percelen",
|
|
"unit": "objecten",
|
|
"geometry_dimension": 2,
|
|
},
|
|
),
|
|
coverage_zones=("brussels",),
|
|
endpoint_kind="urbis_wfs",
|
|
response_crs="EPSG:31370",
|
|
identity_field="INSPIRE_ID",
|
|
requires_coverage_area=True,
|
|
),
|
|
)
|
|
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=list(product.geometry_types),
|
|
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,
|
|
coverage_zones=list(product.coverage_zones),
|
|
).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 _dimensional(geometry: Any, dimension: int) -> Any | None:
|
|
if geometry is None or geometry.is_empty:
|
|
return None
|
|
if not geometry.is_valid:
|
|
geometry = make_valid(geometry)
|
|
parts: list[Any] = []
|
|
|
|
def collect(item: Any) -> None:
|
|
if item is None or item.is_empty:
|
|
return
|
|
if dimension == 2 and isinstance(item, Polygon):
|
|
parts.append(item)
|
|
elif dimension == 2 and isinstance(item, MultiPolygon):
|
|
parts.extend(part for part in item.geoms if not part.is_empty)
|
|
elif dimension == 1 and isinstance(item, LineString):
|
|
parts.append(item)
|
|
elif dimension == 1 and isinstance(item, MultiLineString):
|
|
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,
|
|
product: OfficialVectorProduct,
|
|
) -> 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 product.endpoint_kind == "spw_arcgis" and not settings.spw_picc_enabled:
|
|
raise AppError(
|
|
code="SPW_PICC_NOT_CONFIGURED",
|
|
message="Bounded SPW PICC acquisition is disabled",
|
|
status_code=503,
|
|
)
|
|
if product.endpoint_kind == "urbis_wfs" and not settings.urbis_enabled:
|
|
raise AppError(
|
|
code="URBIS_NOT_CONFIGURED",
|
|
message="Bounded UrbIS 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)
|
|
area = None
|
|
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,
|
|
)
|
|
if product.requires_coverage_area:
|
|
coverage_area_names = {
|
|
"wallonia": "Wallonia",
|
|
"brussels": "Brussels-Capital Region",
|
|
}
|
|
required_names = [
|
|
coverage_area_names[zone]
|
|
for zone in product.coverage_zones
|
|
if zone in coverage_area_names
|
|
]
|
|
coverage_rows = (
|
|
[area]
|
|
if area is not None and area.name in required_names
|
|
else db.query(Area)
|
|
.filter(
|
|
Area.project_id == project_id,
|
|
Area.name.in_(required_names),
|
|
)
|
|
.all()
|
|
)
|
|
coverage_geometries = [
|
|
to_shape(item.geometry)
|
|
for item in coverage_rows
|
|
if item is not None and item.geometry is not None
|
|
]
|
|
coverage_geometry = (
|
|
OfficialVectorAcquisitionService._polygonal(unary_union(coverage_geometries))
|
|
if coverage_geometries
|
|
else None
|
|
)
|
|
if coverage_geometry is None:
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_COVERAGE_NOT_READY",
|
|
message="The official regional coverage boundary is not persisted in this project",
|
|
details={"required_areas": required_names},
|
|
status_code=409,
|
|
)
|
|
scope_wgs84 = OfficialVectorAcquisitionService._polygonal(
|
|
scope_wgs84.intersection(coverage_geometry)
|
|
)
|
|
if scope_wgs84 is None:
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_OUTSIDE_COVERAGE",
|
|
message="The selection does not intersect the official product coverage",
|
|
details={"coverage_zones": list(product.coverage_zones)},
|
|
status_code=422,
|
|
)
|
|
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:
|
|
content_type = ""
|
|
if hasattr(response, "getheader"):
|
|
content_type = str(response.getheader("Content-Type") or "")
|
|
elif hasattr(response, "headers"):
|
|
content_type = str(response.headers.get("Content-Type") or "")
|
|
if content_type and not any(
|
|
allowed in content_type.lower()
|
|
for allowed in ("application/json", "application/geo+json")
|
|
):
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_PROVIDER_INVALID_CONTENT_TYPE",
|
|
message="The official vector provider returned an unsupported content type",
|
|
details={"content_type": content_type},
|
|
status_code=502,
|
|
)
|
|
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 _spw_url(
|
|
settings: Settings,
|
|
product: OfficialVectorProduct,
|
|
bbox_values: tuple[float, ...],
|
|
start_index: int,
|
|
) -> str:
|
|
query = urlencode(
|
|
{
|
|
"where": "1=1",
|
|
"geometry": ",".join(f"{value:.8f}" for value in bbox_values),
|
|
"geometryType": "esriGeometryEnvelope",
|
|
"inSR": "4326",
|
|
"outSR": "4326",
|
|
"spatialRel": "esriSpatialRelIntersects",
|
|
"outFields": "*",
|
|
"returnGeometry": "true",
|
|
"returnZ": "false",
|
|
"returnM": "false",
|
|
"resultOffset": start_index,
|
|
"resultRecordCount": min(settings.official_vector_page_size, 2000),
|
|
"orderByFields": "OBJECTID",
|
|
"f": "geojson",
|
|
}
|
|
)
|
|
base = settings.spw_picc_mapserver_url.rstrip("/")
|
|
return f"{base}/{product.collection}/query?{query}"
|
|
|
|
@staticmethod
|
|
def _urbis_url(
|
|
settings: Settings,
|
|
product: OfficialVectorProduct,
|
|
metric_bbox: tuple[float, ...],
|
|
start_index: int,
|
|
) -> str:
|
|
query = urlencode(
|
|
{
|
|
"service": "WFS",
|
|
"version": "2.0.0",
|
|
"request": "GetFeature",
|
|
"typeNames": product.collection,
|
|
"srsName": "EPSG:31370",
|
|
"bbox": ",".join(f"{value:.3f}" for value in metric_bbox) + ",EPSG:31370",
|
|
"count": settings.official_vector_page_size,
|
|
"startIndex": start_index,
|
|
"sortBy": product.identity_field or "INSPIRE_ID",
|
|
"outputFormat": "application/json",
|
|
}
|
|
)
|
|
return f"{settings.urbis_wfs_url.rstrip('?')}?{query}"
|
|
|
|
@staticmethod
|
|
def _page_url(
|
|
product: OfficialVectorProduct,
|
|
settings: Settings,
|
|
scope_wgs84: Any,
|
|
scope_metric: Any,
|
|
start_index: int,
|
|
) -> str:
|
|
if product.endpoint_kind == "bwk_wfs":
|
|
return OfficialVectorAcquisitionService._nature_url(
|
|
settings,
|
|
tuple(scope_wgs84.bounds),
|
|
start_index,
|
|
)
|
|
if product.endpoint_kind == "dov_wfs":
|
|
return OfficialVectorAcquisitionService._soil_url(
|
|
settings,
|
|
tuple(scope_metric.bounds),
|
|
start_index,
|
|
)
|
|
if product.endpoint_kind == "spw_arcgis":
|
|
return OfficialVectorAcquisitionService._spw_url(
|
|
settings,
|
|
product,
|
|
tuple(scope_wgs84.bounds),
|
|
start_index,
|
|
)
|
|
if product.endpoint_kind == "urbis_wfs":
|
|
return OfficialVectorAcquisitionService._urbis_url(
|
|
settings,
|
|
product,
|
|
tuple(scope_metric.bounds),
|
|
start_index,
|
|
)
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_PRODUCT_NOT_SUPPORTED",
|
|
message="The official vector product has no governed acquisition adapter",
|
|
status_code=422,
|
|
)
|
|
|
|
@staticmethod
|
|
def _validate_page_url(
|
|
url: str,
|
|
product: OfficialVectorProduct,
|
|
settings: Settings,
|
|
) -> None:
|
|
parsed = urlparse(url)
|
|
configured_url = {
|
|
"bwk_wfs": settings.bwk_wfs_url,
|
|
"dov_wfs": settings.dov_soil_wfs_url,
|
|
"spw_arcgis": settings.spw_picc_mapserver_url,
|
|
"urbis_wfs": settings.urbis_wfs_url,
|
|
}.get(product.endpoint_kind)
|
|
if configured_url is None:
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_PRODUCT_NOT_SUPPORTED",
|
|
message="The official vector product has no configured endpoint",
|
|
status_code=422,
|
|
)
|
|
base = urlparse(configured_url)
|
|
expected_path = (
|
|
f"{base.path.rstrip('/')}/{product.collection}/query"
|
|
if product.endpoint_kind == "spw_arcgis"
|
|
else base.path
|
|
)
|
|
if (
|
|
parsed.scheme != "https"
|
|
or base.scheme != "https"
|
|
or parsed.netloc.casefold() != base.netloc.casefold()
|
|
or parsed.path != expected_path
|
|
or parsed.username
|
|
or parsed.password
|
|
or parsed.fragment
|
|
):
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_PROVIDER_INVALID_PAGINATION",
|
|
message="The official vector request escaped the governed HTTPS endpoint",
|
|
status_code=502,
|
|
)
|
|
|
|
@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_regional_feature(
|
|
product: OfficialVectorProduct,
|
|
feature: dict[str, Any],
|
|
scope_metric: Any,
|
|
coverage_scope: str,
|
|
) -> dict[str, Any] | None:
|
|
dimension = 2 if any("Polygon" in item for item in product.geometry_types) else 1
|
|
try:
|
|
source_geometry = 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 product.response_crs == "EPSG:31370":
|
|
source_metric = OfficialVectorAcquisitionService._dimensional(
|
|
source_geometry,
|
|
dimension,
|
|
)
|
|
else:
|
|
source_wgs84 = OfficialVectorAcquisitionService._dimensional(
|
|
source_geometry,
|
|
dimension,
|
|
)
|
|
source_metric = (
|
|
OfficialVectorAcquisitionService._dimensional(
|
|
transform(_TO_LAMBERT72.transform, source_wgs84),
|
|
dimension,
|
|
)
|
|
if source_wgs84 is not None
|
|
else None
|
|
)
|
|
if source_metric is None or not source_metric.intersects(scope_metric):
|
|
return None
|
|
clipped_metric = OfficialVectorAcquisitionService._dimensional(
|
|
source_metric.intersection(scope_metric),
|
|
dimension,
|
|
)
|
|
if clipped_metric is None:
|
|
return None
|
|
clipped_wgs84 = OfficialVectorAcquisitionService._dimensional(
|
|
transform(_TO_WGS84.transform, clipped_metric),
|
|
dimension,
|
|
)
|
|
if clipped_wgs84 is None:
|
|
return None
|
|
raw = dict(feature.get("properties") or {})
|
|
identity = (
|
|
raw.get(product.identity_field or "")
|
|
or feature.get("id")
|
|
or raw.get("OBJECTID")
|
|
)
|
|
if identity in (None, ""):
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_PROVIDER_INVALID_RESPONSE",
|
|
message=f"{product.display_name} returned a feature without an official identity",
|
|
status_code=502,
|
|
)
|
|
feature_id = f"{product.collection}:{identity}"
|
|
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,
|
|
"coverage_zones": list(product.coverage_zones),
|
|
"source_version": product.source_version,
|
|
"attribution": product.attribution,
|
|
"geometry_clipped_to_selection": not scope_metric.covers(source_metric),
|
|
}
|
|
if dimension == 2:
|
|
properties["clipped_area_ha"] = round(float(clipped_metric.area) / 10_000.0, 8)
|
|
else:
|
|
properties["clipped_length_km"] = round(float(clipped_metric.length) / 1_000.0, 8)
|
|
return {
|
|
"type": "Feature",
|
|
"id": feature_id,
|
|
"geometry": mapping(clipped_wgs84),
|
|
"properties": properties,
|
|
}
|
|
|
|
@staticmethod
|
|
def _normalize_feature(
|
|
product: OfficialVectorProduct,
|
|
feature: dict[str, Any],
|
|
scope_metric: Any,
|
|
coverage_scope: str,
|
|
) -> dict[str, Any] | None:
|
|
if product.endpoint_kind in {"spw_arcgis", "urbis_wfs"}:
|
|
return OfficialVectorAcquisitionService._normalize_regional_feature(
|
|
product,
|
|
feature,
|
|
scope_metric,
|
|
coverage_scope,
|
|
)
|
|
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._page_url(
|
|
product,
|
|
settings,
|
|
scope_wgs84,
|
|
scope_metric,
|
|
0,
|
|
)
|
|
start_index = 0
|
|
seen_pages: set[str] = set()
|
|
while next_url:
|
|
OfficialVectorAcquisitionService._validate_page_url(
|
|
next_url,
|
|
product,
|
|
settings,
|
|
)
|
|
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
|
|
arcgis_has_more = payload.get("exceededTransferLimit") is True
|
|
if product.endpoint_kind == "spw_arcgis":
|
|
if arcgis_has_more and returned_count == 0:
|
|
raise AppError(
|
|
code="OFFICIAL_VECTOR_PROVIDER_INCOMPLETE_RESPONSE",
|
|
message="The SPW provider reported more records but returned an empty page",
|
|
status_code=502,
|
|
)
|
|
next_url = (
|
|
OfficialVectorAcquisitionService._page_url(
|
|
product,
|
|
settings,
|
|
scope_wgs84,
|
|
scope_metric,
|
|
start_index,
|
|
)
|
|
if arcgis_has_more
|
|
else None
|
|
)
|
|
elif 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
|
|
else:
|
|
next_url = OfficialVectorAcquisitionService._page_url(
|
|
product,
|
|
settings,
|
|
scope_wgs84,
|
|
scope_metric,
|
|
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, product
|
|
)
|
|
)
|
|
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 = (
|
|
product.coverage_zones[0]
|
|
if product.requires_coverage_area
|
|
else (
|
|
"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,
|
|
"coverage_zones": list(product.coverage_zones),
|
|
"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 or acquired_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
|
|
)
|