572 lines
31 KiB
Python
572 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
from uuid import UUID
|
|
from xml.etree import ElementTree
|
|
|
|
from geoalchemy2.shape import to_shape
|
|
from pyproj import Transformer
|
|
from shapely.geometry import box, mapping
|
|
from shapely.ops import transform as shapely_transform
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import AppError
|
|
from app.models import Area, Dataset, Project
|
|
from app.schemas.flood_hazard import FloodHazardAcquireRequest, FloodHazardAcquisitionResult, FloodHazardProductRead
|
|
from app.services.dataset_service import DatasetService
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FloodHazardProduct:
|
|
key: str
|
|
display_name: str
|
|
mechanism: str
|
|
climate_context: str
|
|
probability_class: str
|
|
return_period_years: int
|
|
coverage_id: str
|
|
published_on: str
|
|
catalog_url: str
|
|
|
|
|
|
class FloodHazardAcquisitionService:
|
|
PROVIDER = "vmm_flood_hazard"
|
|
SOURCE_CRS = "EPSG:31370"
|
|
NATIVE_RESOLUTION_M = 2.0
|
|
SOURCE_VALUE_UNIT = "cm"
|
|
NORMALIZED_VALUE_UNIT = "m"
|
|
NODATA = -9999.0
|
|
SOURCE_VERSION = "VMM OGRK flood hazard maps"
|
|
ATTRIBUTION = "Bron: VMM"
|
|
LICENSE_NOTE = "Publieke toegang; gebruik en bronvermelding volgens de metadata van VMM/GDI-Vlaanderen."
|
|
# The VMM WCS rejects generated coverages above 4.88 MB. At the default
|
|
# 5 metre resolution a 5 km square stays below that provider-side limit.
|
|
WCS_TILE_SIDE_M = 5_000.0
|
|
WCS_REQUEST_INTERVAL_SECONDS = 1.0
|
|
WCS_RETRY_DELAY_SECONDS = 3.0
|
|
WCS_TRANSIENT_STATUS_CODES = frozenset({400, 429, 502, 503, 504})
|
|
SERVICE_CATALOG_URL = "https://www.vlaanderen.be/datavindplaats/catalogus/publieke-inspire-coverage-service-van-ogrk"
|
|
LIMITATION = (
|
|
"Gemodelleerde maximale overstromingsdiepte voor een vast kans- en klimaatscenario. "
|
|
"Dit is geen actuele waterstand, geen bathymetrie en geen permanente diepte of inhoud van een waterlichaam."
|
|
)
|
|
|
|
@staticmethod
|
|
def _products() -> dict[str, FloodHazardProduct]:
|
|
products: list[FloodHazardProduct] = []
|
|
probability = {
|
|
10: ("grote kans", "grote-kans"),
|
|
100: ("middelgrote kans", "middelgrote-kans"),
|
|
1000: ("kleine kans", "kleine-kans"),
|
|
}
|
|
for mechanism, code in (("pluviaal", "PLU"), ("fluviaal", "FLU")):
|
|
for climate_key, climate_code, climate_label, published_on in (
|
|
("current", "noCC", "huidig klimaat", "2021-08-31"),
|
|
("future_2050", "hCC", "klimaatprojectie 2050", "2021-08-31" if mechanism == "fluviaal" else "2019-12-22"),
|
|
):
|
|
for period, (probability_label, probability_slug) in probability.items():
|
|
climate_slug = (
|
|
"huidig-klimaat"
|
|
if climate_key == "current"
|
|
else "toekomstig-klimaat-met-klimaatprojectie-2050"
|
|
)
|
|
catalog_url = (
|
|
"https://www.vlaanderen.be/datavindplaats/catalogus/"
|
|
f"overstromingsgevaarkaart-waterdiepte-{mechanism}-{climate_slug}-{probability_slug}"
|
|
)
|
|
products.append(
|
|
FloodHazardProduct(
|
|
key=f"{mechanism}_{climate_key}_t{period}",
|
|
display_name=(
|
|
f"{mechanism.capitalize()} - {climate_label} - {probability_label} (T{period})"
|
|
),
|
|
mechanism=mechanism,
|
|
climate_context=climate_label,
|
|
probability_class=probability_label,
|
|
return_period_years=period,
|
|
coverage_id=(
|
|
f"Overstromingsgevaarkaarten-{code.replace('PLU', 'PLUVIAAL').replace('FLU', 'FLUVIAAL')}:"
|
|
f"waterdiepte_{code}_{climate_code}_T{period}"
|
|
),
|
|
published_on=published_on,
|
|
catalog_url=catalog_url,
|
|
)
|
|
)
|
|
return {product.key: product for product in products}
|
|
|
|
@staticmethod
|
|
def list_products() -> list[dict[str, Any]]:
|
|
return [
|
|
FloodHazardProductRead(
|
|
key=product.key,
|
|
display_name=product.display_name,
|
|
mechanism=product.mechanism,
|
|
climate_context=product.climate_context,
|
|
probability_class=product.probability_class,
|
|
return_period_years=product.return_period_years,
|
|
coverage_id=product.coverage_id,
|
|
native_resolution_m=FloodHazardAcquisitionService.NATIVE_RESOLUTION_M,
|
|
source_crs=FloodHazardAcquisitionService.SOURCE_CRS,
|
|
source_value_unit=FloodHazardAcquisitionService.SOURCE_VALUE_UNIT,
|
|
normalized_value_unit=FloodHazardAcquisitionService.NORMALIZED_VALUE_UNIT,
|
|
published_on=product.published_on,
|
|
catalog_url=product.catalog_url,
|
|
attribution=FloodHazardAcquisitionService.ATTRIBUTION,
|
|
limitation_message=FloodHazardAcquisitionService.LIMITATION,
|
|
).model_dump()
|
|
for product in FloodHazardAcquisitionService._products().values()
|
|
]
|
|
|
|
@staticmethod
|
|
def _product(product_key: str) -> FloodHazardProduct:
|
|
product = FloodHazardAcquisitionService._products().get(product_key.strip().lower())
|
|
if product is None:
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_PRODUCT_NOT_SUPPORTED",
|
|
message="Select a governed VMM fluvial or pluvial flood-depth scenario",
|
|
details={"product_key": product_key},
|
|
status_code=422,
|
|
)
|
|
return product
|
|
|
|
@staticmethod
|
|
def _prepared_request(payload: FloodHazardAcquireRequest, settings: Settings) -> dict[str, Any]:
|
|
if not settings.flood_hazard_enabled:
|
|
raise AppError(code="FLOOD_HAZARD_NOT_CONFIGURED", message="VMM flood-hazard acquisition is disabled", status_code=503)
|
|
product = FloodHazardAcquisitionService._product(payload.product_key)
|
|
resolution_m = float(payload.resolution_m or settings.flood_hazard_resolution_m)
|
|
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 payload.bbox.min_x >= payload.bbox.max_x or payload.bbox.min_y >= payload.bbox.max_y:
|
|
raise AppError(code="INVALID_BBOX", message="Flood-hazard selection must be a finite non-empty rectangle", status_code=400)
|
|
transformer = Transformer.from_crs("EPSG:4326", FloodHazardAcquisitionService.SOURCE_CRS, always_xy=True)
|
|
metric_bounds = transformer.transform_bounds(*values, densify_pts=21)
|
|
width_m = float(metric_bounds[2] - metric_bounds[0])
|
|
height_m = float(metric_bounds[3] - metric_bounds[1])
|
|
if width_m < settings.flood_hazard_min_side_m or height_m < settings.flood_hazard_min_side_m:
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_SELECTION_TOO_SMALL",
|
|
message=f"Select an area of at least {settings.flood_hazard_min_side_m:g} by {settings.flood_hazard_min_side_m:g} metres",
|
|
status_code=422,
|
|
)
|
|
if width_m > settings.flood_hazard_max_side_m or height_m > settings.flood_hazard_max_side_m:
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_SELECTION_TOO_LARGE",
|
|
message=f"Select an area no larger than {settings.flood_hazard_max_side_m:g} by {settings.flood_hazard_max_side_m:g} metres",
|
|
details={"width_m": width_m, "height_m": height_m},
|
|
status_code=422,
|
|
)
|
|
width = max(1, math.ceil(width_m / resolution_m))
|
|
height = max(1, math.ceil(height_m / resolution_m))
|
|
if width * height > settings.flood_hazard_max_pixels:
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_SELECTION_TOO_LARGE",
|
|
message="Flood-hazard selection exceeds the configured raster cell limit",
|
|
details={"pixel_count": width * height, "max_pixels": settings.flood_hazard_max_pixels},
|
|
status_code=422,
|
|
)
|
|
bbox_4326 = [float(value) for value in values]
|
|
bbox_31370 = [float(value) for value in metric_bounds]
|
|
identity = {
|
|
"provider": FloodHazardAcquisitionService.PROVIDER,
|
|
"coverage_id": product.coverage_id,
|
|
"bbox_epsg4326": [round(value, 8) for value in bbox_4326],
|
|
"bbox_epsg31370": [round(value, 3) for value in bbox_31370],
|
|
"resolution_m": resolution_m,
|
|
"area_id": str(payload.area_id) if payload.area_id else None,
|
|
}
|
|
request_hash = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
|
return {
|
|
**identity,
|
|
"product": product,
|
|
"request_hash": request_hash,
|
|
"bbox_epsg4326": bbox_4326,
|
|
"bbox_epsg31370": bbox_31370,
|
|
"width": width,
|
|
"height": height,
|
|
}
|
|
|
|
@staticmethod
|
|
def _wcs_request_url(settings: Settings, product: FloodHazardProduct, bounds: tuple[float, float, float, float], resolution_m: float) -> str:
|
|
crs = "urn:ogc:def:crs:EPSG::31370"
|
|
query = [
|
|
("SERVICE", "WCS"),
|
|
("VERSION", "1.1.0"),
|
|
("REQUEST", "GetCoverage"),
|
|
("IDENTIFIER", product.coverage_id),
|
|
("BOUNDINGBOX", f"{bounds[0]:.3f},{bounds[1]:.3f},{bounds[2]:.3f},{bounds[3]:.3f},{crs}"),
|
|
("FORMAT", "image/tiff"),
|
|
("GRIDBASECRS", crs),
|
|
("GRIDCS", "urn:ogc:def:cs:OGC:0.0:Grid2dSquareCS"),
|
|
("GRIDTYPE", "urn:ogc:def:method:WCS:1.1:2dSimpleGrid"),
|
|
("GRIDORIGIN", f"{bounds[0]:.3f},{bounds[3]:.3f}"),
|
|
("GRIDOFFSETS", f"{resolution_m:g},-{resolution_m:g}"),
|
|
]
|
|
return f"{settings.flood_hazard_wcs_url}?{urlencode(query)}"
|
|
|
|
@staticmethod
|
|
def _tile_bounds(prepared: dict[str, Any]) -> list[tuple[float, float, float, float]]:
|
|
min_x, min_y, max_x, max_y = prepared["bbox_epsg31370"]
|
|
tiles: list[tuple[float, float, float, float]] = []
|
|
y = min_y
|
|
while y < max_y:
|
|
tile_max_y = min(y + FloodHazardAcquisitionService.WCS_TILE_SIDE_M, max_y)
|
|
x = min_x
|
|
while x < max_x:
|
|
tile_max_x = min(x + FloodHazardAcquisitionService.WCS_TILE_SIDE_M, max_x)
|
|
tiles.append((x, y, tile_max_x, tile_max_y))
|
|
x = tile_max_x
|
|
y = tile_max_y
|
|
return tiles
|
|
|
|
@staticmethod
|
|
def _scope_geometry(db, project_id: UUID, area_id: UUID | None, bbox_epsg4326: list[float]):
|
|
if not db.get(Project, project_id):
|
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
|
selection = box(*bbox_epsg4326)
|
|
if area_id is None:
|
|
return selection
|
|
area = db.get(Area, area_id)
|
|
if not area:
|
|
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)
|
|
intersection = to_shape(area.geometry).intersection(selection)
|
|
if intersection.is_empty or intersection.area <= 0:
|
|
raise AppError(code="FLOOD_HAZARD_SELECTION_OUTSIDE_AREA", message="Selection does not overlap the selected work area", status_code=422)
|
|
return intersection
|
|
|
|
@staticmethod
|
|
def _fetch(request_url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, str]:
|
|
request = Request(request_url, headers={"Accept": "*/*", "User-Agent": "GeoIntel/0.1 bounded-vmm-flood-hazard-acquisition"})
|
|
max_bytes = settings.flood_hazard_max_response_mb * 1024 * 1024
|
|
try:
|
|
with (opener or urlopen)(request, timeout=settings.flood_hazard_timeout_seconds) as response:
|
|
content_type = str(response.headers.get("Content-Type", ""))
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and int(content_length) > max_bytes:
|
|
raise AppError(code="FLOOD_HAZARD_RESPONSE_TOO_LARGE", message="Official VMM response exceeds the configured size limit", status_code=502)
|
|
content = response.read(max_bytes + 1)
|
|
except AppError:
|
|
raise
|
|
except HTTPError as exc:
|
|
preview = exc.read(300).decode("utf-8", errors="replace")
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_PROVIDER_UNAVAILABLE",
|
|
message="The official VMM WCS could not complete the bounded request",
|
|
details={"reason": str(exc), "provider_status_code": int(exc.code), "response_preview": preview},
|
|
status_code=502,
|
|
) from exc
|
|
except (URLError, TimeoutError, OSError) as exc:
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_PROVIDER_UNAVAILABLE",
|
|
message="The official VMM WCS could not complete the bounded request",
|
|
details={"reason": str(exc)},
|
|
status_code=502,
|
|
) from exc
|
|
if len(content) > max_bytes:
|
|
raise AppError(code="FLOOD_HAZARD_RESPONSE_TOO_LARGE", message="Official VMM response exceeds the configured size limit", status_code=502)
|
|
return content, content_type
|
|
|
|
@staticmethod
|
|
def _extract_geotiff(content: bytes, content_type: str) -> bytes:
|
|
if content.startswith((b"II*\x00", b"MM\x00*")):
|
|
return content
|
|
if "multipart" not in content_type.lower():
|
|
provider_exception = None
|
|
if "xml" in content_type.lower() or content.lstrip().startswith(b"<"):
|
|
try:
|
|
root = ElementTree.fromstring(content)
|
|
exception_texts = [
|
|
(element.text or "").strip()
|
|
for element in root.iter()
|
|
if element.tag.rsplit("}", 1)[-1] in {"ExceptionText", "ServiceException"}
|
|
and (element.text or "").strip()
|
|
]
|
|
provider_exception = " ".join(exception_texts) or None
|
|
except ElementTree.ParseError:
|
|
pass
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE",
|
|
message="The official VMM service did not return a GeoTIFF coverage",
|
|
details={
|
|
"content_type": content_type,
|
|
"provider_exception": provider_exception,
|
|
"response_preview": content[:300].decode("utf-8", errors="replace"),
|
|
},
|
|
status_code=502,
|
|
)
|
|
message = BytesParser(policy=default).parsebytes(f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + content)
|
|
for part in message.walk():
|
|
payload = part.get_payload(decode=True) or b""
|
|
if part.get_content_type() == "image/tiff" and payload.startswith((b"II*\x00", b"MM\x00*")):
|
|
return payload
|
|
raise AppError(
|
|
code="FLOOD_HAZARD_PROVIDER_INVALID_RESPONSE",
|
|
message="The official VMM multipart response contains no valid GeoTIFF coverage",
|
|
status_code=502,
|
|
)
|
|
|
|
@staticmethod
|
|
def _mosaic_geotiffs(coverages: list[bytes], expected_resolution_m: float) -> bytes:
|
|
if len(coverages) == 1:
|
|
return coverages[0]
|
|
try:
|
|
from rasterio.io import MemoryFile
|
|
from rasterio.merge import merge
|
|
except ImportError as exc:
|
|
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio is required to assemble VMM flood-hazard tiles", status_code=503) from exc
|
|
memories = [MemoryFile(content) for content in coverages]
|
|
sources = []
|
|
try:
|
|
sources = [memory.open() for memory in memories]
|
|
for source in sources:
|
|
if source.crs is None or source.crs.to_epsg() != 31370 or source.count != 1:
|
|
raise AppError(code="FLOOD_HAZARD_TILE_MISMATCH", message="VMM coverage tiles do not share the governed CRS and band layout", status_code=502)
|
|
if not all(math.isclose(abs(float(value)), expected_resolution_m, rel_tol=0.02, abs_tol=0.05) for value in source.res):
|
|
raise AppError(code="FLOOD_HAZARD_TILE_MISMATCH", message="VMM coverage tile resolution differs from the governed request", status_code=502)
|
|
mosaic, transform = merge(sources, res=(expected_resolution_m, expected_resolution_m), nodata=0.0, dtype="float32")
|
|
profile = sources[0].profile.copy()
|
|
profile.pop("blockxsize", None)
|
|
profile.pop("blockysize", None)
|
|
profile.update(driver="GTiff", width=mosaic.shape[2], height=mosaic.shape[1], count=1, dtype="float32", crs=FloodHazardAcquisitionService.SOURCE_CRS, transform=transform, nodata=0.0, compress="deflate", predictor=3)
|
|
with MemoryFile() as output_memory:
|
|
with output_memory.open(**profile) as output:
|
|
output.write(mosaic)
|
|
return output_memory.read()
|
|
except AppError:
|
|
raise
|
|
except Exception as exc:
|
|
raise AppError(code="FLOOD_HAZARD_TILE_MOSAIC_FAILED", message="VMM flood-hazard tiles could not be assembled", details={"reason": str(exc)}, status_code=502) from exc
|
|
finally:
|
|
for source in sources:
|
|
source.close()
|
|
for memory in memories:
|
|
memory.close()
|
|
|
|
@staticmethod
|
|
def _fetch_coverage(prepared: dict[str, Any], settings: Settings, opener: Callable[..., Any] | None = None) -> tuple[bytes, dict[str, Any]]:
|
|
product: FloodHazardProduct = prepared["product"]
|
|
request_urls = [
|
|
FloodHazardAcquisitionService._wcs_request_url(settings, product, bounds, prepared["resolution_m"])
|
|
for bounds in FloodHazardAcquisitionService._tile_bounds(prepared)
|
|
]
|
|
raw_hash = hashlib.sha256()
|
|
coverage_hash = hashlib.sha256()
|
|
content_types: list[str] = []
|
|
coverages: list[bytes] = []
|
|
for index, request_url in enumerate(request_urls):
|
|
if index > 0 and opener is None:
|
|
time.sleep(FloodHazardAcquisitionService.WCS_REQUEST_INTERVAL_SECONDS)
|
|
try:
|
|
raw_content, content_type = FloodHazardAcquisitionService._fetch(request_url, settings, opener)
|
|
except AppError as exc:
|
|
provider_status = (exc.details or {}).get("provider_status_code")
|
|
if opener is not None or provider_status not in FloodHazardAcquisitionService.WCS_TRANSIENT_STATUS_CODES:
|
|
raise
|
|
time.sleep(FloodHazardAcquisitionService.WCS_RETRY_DELAY_SECONDS)
|
|
raw_content, content_type = FloodHazardAcquisitionService._fetch(request_url, settings, opener)
|
|
coverage = FloodHazardAcquisitionService._extract_geotiff(raw_content, content_type)
|
|
raw_hash.update(len(raw_content).to_bytes(8, "big")); raw_hash.update(raw_content)
|
|
coverage_hash.update(len(coverage).to_bytes(8, "big")); coverage_hash.update(coverage)
|
|
content_types.append(content_type)
|
|
coverages.append(coverage)
|
|
return FloodHazardAcquisitionService._mosaic_geotiffs(coverages, prepared["resolution_m"]), {
|
|
"tile_count": len(request_urls),
|
|
"request_urls": request_urls,
|
|
"response_content_types": content_types,
|
|
"response_sha256": raw_hash.hexdigest(),
|
|
"coverage_sha256": coverage_hash.hexdigest(),
|
|
}
|
|
|
|
@staticmethod
|
|
def _normalize_raster(content: bytes, scope_geometry_4326, prepared: dict[str, Any]) -> tuple[bytes, dict[str, Any]]:
|
|
try:
|
|
import numpy as np
|
|
from rasterio.io import MemoryFile
|
|
from rasterio.mask import mask
|
|
except ImportError as exc:
|
|
raise AppError(code="RASTER_PROCESSING_UNAVAILABLE", message="Rasterio and numpy are required for flood-hazard validation", status_code=503) from exc
|
|
try:
|
|
with MemoryFile(content) as source_memory, source_memory.open() as source:
|
|
if source.crs is None or source.crs.to_epsg() != 31370:
|
|
raise AppError(code="FLOOD_HAZARD_INVALID_CRS", message="VMM flood-hazard coverage must use EPSG:31370", status_code=502)
|
|
if source.count != 1:
|
|
raise AppError(code="FLOOD_HAZARD_INVALID_BANDS", message="VMM flood-hazard coverage must contain one depth band", status_code=502)
|
|
resolution = max(abs(float(source.res[0])), abs(float(source.res[1])))
|
|
if not math.isclose(resolution, prepared["resolution_m"], rel_tol=0.02, abs_tol=0.05):
|
|
raise AppError(code="FLOOD_HAZARD_INVALID_RESOLUTION", message="VMM coverage resolution differs from the governed request", status_code=502)
|
|
transformer = Transformer.from_crs("EPSG:4326", FloodHazardAcquisitionService.SOURCE_CRS, always_xy=True)
|
|
scope_metric = shapely_transform(transformer.transform, scope_geometry_4326)
|
|
clipped, transform = mask(source, [mapping(scope_metric)], crop=True, filled=False, indexes=[1])
|
|
source_values = np.ma.asarray(clipped[0], dtype="float32")
|
|
raw_cm = np.asarray(source_values.filled(0.0), dtype="float32")
|
|
positive = (~np.ma.getmaskarray(source_values)) & np.isfinite(raw_cm) & (raw_cm > 0.0)
|
|
normalized_m = np.full(raw_cm.shape, FloodHazardAcquisitionService.NODATA, dtype="float32")
|
|
normalized_m[positive] = raw_cm[positive] / 100.0
|
|
valid_values = normalized_m[positive].astype("float64")
|
|
profile = source.profile.copy()
|
|
profile.pop("blockxsize", None); profile.pop("blockysize", None)
|
|
profile.update(driver="GTiff", width=normalized_m.shape[1], height=normalized_m.shape[0], count=1, dtype="float32", crs=FloodHazardAcquisitionService.SOURCE_CRS, transform=transform, nodata=FloodHazardAcquisitionService.NODATA, compress="deflate", predictor=3)
|
|
with MemoryFile() as output_memory:
|
|
with output_memory.open(**profile) as output:
|
|
output.write(normalized_m, 1)
|
|
normalized_content = output_memory.read()
|
|
return normalized_content, {
|
|
"width": int(normalized_m.shape[1]),
|
|
"height": int(normalized_m.shape[0]),
|
|
"inundated_pixel_count": int(positive.sum()),
|
|
"nodata_value": FloodHazardAcquisitionService.NODATA,
|
|
"resolution_m": resolution,
|
|
"minimum_depth_m": float(valid_values.min()) if valid_values.size else None,
|
|
"maximum_depth_m": float(valid_values.max()) if valid_values.size else None,
|
|
"source_value_unit": FloodHazardAcquisitionService.SOURCE_VALUE_UNIT,
|
|
"normalized_value_unit": FloodHazardAcquisitionService.NORMALIZED_VALUE_UNIT,
|
|
}
|
|
except AppError:
|
|
raise
|
|
except Exception as exc:
|
|
raise AppError(code="FLOOD_HAZARD_RASTER_INVALID", message="The official VMM response is not a valid georeferenced flood-depth raster", details={"reason": str(exc)}, status_code=502) from exc
|
|
|
|
@staticmethod
|
|
def _cached_dataset(db, project_id: UUID, filename: str) -> Dataset | None:
|
|
candidate = (
|
|
db.query(Dataset)
|
|
.filter(Dataset.project_id == project_id, Dataset.name == filename, Dataset.source_name == FloodHazardAcquisitionService.PROVIDER, Dataset.status == "ready")
|
|
.order_by(Dataset.imported_at.desc())
|
|
.first()
|
|
)
|
|
if candidate and candidate.storage_path and Path(candidate.storage_path).is_file():
|
|
return candidate
|
|
return None
|
|
|
|
@staticmethod
|
|
def acquire(db, project_id: UUID, payload: FloodHazardAcquireRequest, *, settings: Settings | None = None, opener: Callable[..., Any] | None = None) -> dict[str, Any]:
|
|
resolved_settings = settings or get_settings()
|
|
prepared = FloodHazardAcquisitionService._prepared_request(payload, resolved_settings)
|
|
product: FloodHazardProduct = prepared["product"]
|
|
scope_geometry = FloodHazardAcquisitionService._scope_geometry(db, project_id, payload.area_id, prepared["bbox_epsg4326"])
|
|
resolution_token = f"{prepared['resolution_m']:g}".replace(".", "p")
|
|
filename = f"vmm_flood_depth_{product.key}_{resolution_token}m_{prepared['request_hash'][:12]}.tif"
|
|
if not payload.force_refresh:
|
|
cached = FloodHazardAcquisitionService._cached_dataset(db, project_id, filename)
|
|
if cached is not None:
|
|
metadata = cached.source_metadata or {}
|
|
raster = cached.metadata_json or {}
|
|
return FloodHazardAcquisitionResult(
|
|
output_dataset_id=cached.id,
|
|
reused=True,
|
|
provider=FloodHazardAcquisitionService.PROVIDER,
|
|
product_key=product.key,
|
|
display_name=product.display_name,
|
|
mechanism=product.mechanism,
|
|
climate_context=product.climate_context,
|
|
probability_class=product.probability_class,
|
|
return_period_years=product.return_period_years,
|
|
coverage_id=product.coverage_id,
|
|
resolution_m=float(metadata.get("analysis_resolution_m", prepared["resolution_m"])),
|
|
width=int(raster.get("width", prepared["width"])),
|
|
height=int(raster.get("height", prepared["height"])),
|
|
inundated_pixel_count=int(metadata.get("inundated_pixel_count", 0)),
|
|
bbox_epsg4326=prepared["bbox_epsg4326"],
|
|
bbox_epsg31370=prepared["bbox_epsg31370"],
|
|
attribution=FloodHazardAcquisitionService.ATTRIBUTION,
|
|
limitation_message=FloodHazardAcquisitionService.LIMITATION,
|
|
).model_dump(mode="json")
|
|
content, transfer = FloodHazardAcquisitionService._fetch_coverage(prepared, resolved_settings, opener)
|
|
normalized, validation = FloodHazardAcquisitionService._normalize_raster(content, scope_geometry, prepared)
|
|
acquired_at = datetime.now(UTC)
|
|
dataset = DatasetService.import_raster_bytes(
|
|
db,
|
|
project_id=project_id,
|
|
area_id=payload.area_id,
|
|
filename=filename,
|
|
content=normalized,
|
|
source=f"VMM OGRK WCS {product.coverage_id}",
|
|
source_name=FloodHazardAcquisitionService.PROVIDER,
|
|
source_version=FloodHazardAcquisitionService.SOURCE_VERSION,
|
|
content_type="image/tiff",
|
|
source_metadata={
|
|
"provider": FloodHazardAcquisitionService.PROVIDER,
|
|
"service": "WCS",
|
|
"service_version": "1.1.0",
|
|
"product_key": product.key,
|
|
"product_display_name": product.display_name,
|
|
"mechanism": product.mechanism,
|
|
"climate_context": product.climate_context,
|
|
"probability_class": product.probability_class,
|
|
"return_period_years": product.return_period_years,
|
|
"coverage_id": product.coverage_id,
|
|
"native_resolution_m": FloodHazardAcquisitionService.NATIVE_RESOLUTION_M,
|
|
"analysis_resolution_m": validation["resolution_m"],
|
|
"source_crs": FloodHazardAcquisitionService.SOURCE_CRS,
|
|
"source_value_unit": FloodHazardAcquisitionService.SOURCE_VALUE_UNIT,
|
|
"normalized_value_unit": FloodHazardAcquisitionService.NORMALIZED_VALUE_UNIT,
|
|
"inundated_pixel_count": validation["inundated_pixel_count"],
|
|
"minimum_depth_m": validation["minimum_depth_m"],
|
|
"maximum_depth_m": validation["maximum_depth_m"],
|
|
"bbox_epsg4326": prepared["bbox_epsg4326"],
|
|
"bbox_epsg31370": prepared["bbox_epsg31370"],
|
|
"published_on": product.published_on,
|
|
"catalog_url": product.catalog_url,
|
|
"service_catalog_url": FloodHazardAcquisitionService.SERVICE_CATALOG_URL,
|
|
"attribution": FloodHazardAcquisitionService.ATTRIBUTION,
|
|
"license_note": FloodHazardAcquisitionService.LICENSE_NOTE,
|
|
"theme": "flood_hazard",
|
|
"layer_name": "modelled_flood_depth",
|
|
"coverage_scope": "municipality" if payload.area_id else "bounded_selection",
|
|
},
|
|
provenance_metadata={
|
|
"acquisition": "explicit_bounded_tiled_wcs_coverage",
|
|
"acquired_at": acquired_at.isoformat(),
|
|
"request_hash": prepared["request_hash"],
|
|
"tile_count": transfer["tile_count"],
|
|
"tile_request_urls": transfer["request_urls"],
|
|
"response_content_types": transfer["response_content_types"],
|
|
"response_sha256": transfer["response_sha256"],
|
|
"coverage_sha256": transfer["coverage_sha256"],
|
|
"normalized_sha256": hashlib.sha256(normalized).hexdigest(),
|
|
"bbox_epsg4326": prepared["bbox_epsg4326"],
|
|
"bbox_epsg31370": prepared["bbox_epsg31370"],
|
|
"requested_resolution_m": prepared["resolution_m"],
|
|
"clipped_to_area_id": str(payload.area_id) if payload.area_id else None,
|
|
"validation": validation,
|
|
"limitation_message": FloodHazardAcquisitionService.LIMITATION,
|
|
"bathymetry_available": False,
|
|
"permanent_water_depth_available": False,
|
|
"permanent_water_volume_available": False,
|
|
"concurrent_flood_volume_available": False,
|
|
},
|
|
)
|
|
return FloodHazardAcquisitionResult(
|
|
output_dataset_id=dataset.id,
|
|
reused=False,
|
|
provider=FloodHazardAcquisitionService.PROVIDER,
|
|
product_key=product.key,
|
|
display_name=product.display_name,
|
|
mechanism=product.mechanism,
|
|
climate_context=product.climate_context,
|
|
probability_class=product.probability_class,
|
|
return_period_years=product.return_period_years,
|
|
coverage_id=product.coverage_id,
|
|
resolution_m=validation["resolution_m"],
|
|
width=validation["width"],
|
|
height=validation["height"],
|
|
inundated_pixel_count=validation["inundated_pixel_count"],
|
|
bbox_epsg4326=prepared["bbox_epsg4326"],
|
|
bbox_epsg31370=prepared["bbox_epsg31370"],
|
|
attribution=FloodHazardAcquisitionService.ATTRIBUTION,
|
|
limitation_message=FloodHazardAcquisitionService.LIMITATION,
|
|
).model_dump(mode="json")
|