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 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.dhmv import DhmvAcquireRequest, DhmvAcquisitionResult, DhmvProductRead from app.services.dataset_service import DatasetService @dataclass(frozen=True) class DhmvProduct: key: str display_name: str surface_model: str coverage_id: str native_resolution_m: float catalog_url: str limitation_message: str class DhmvAcquisitionService: PROVIDER = "digitaal_vlaanderen_dhmv" SOURCE_CRS = "EPSG:31370" VERTICAL_REFERENCE = "TAW (Tweede Algemene Waterpassing)" ACQUISITION_PERIOD = "2013-2015" SOURCE_VERSION = "DHMV II 2014.01" NODATA = -9999.0 ATTRIBUTION = "Bron: Digitaal Vlaanderen, Digitaal Hoogtemodel Vlaanderen II" LICENSE_NOTE = "Gebruik volgens het gebruiksrecht geografische webdiensten van Digitaal Vlaanderen." WCS_TILE_SIDE_M = 10_000.0 WCS_REQUEST_INTERVAL_SECONDS = 2.0 WCS_RETRY_DELAY_SECONDS = 4.0 WCS_TRANSIENT_STATUS_CODES = frozenset({400, 429, 502, 503, 504}) DTM_CATALOG_URL = ( "https://www.vlaanderen.be/datavindplaats/catalogus/" "digitaal-hoogtemodel-vlaanderen-ii-dtm-raster-1-m" ) DSM_CATALOG_URL = ( "https://www.vlaanderen.be/datavindplaats/catalogus/" "digitaal-hoogtemodel-vlaanderen-ii-dsm-raster-1-m" ) @staticmethod def _products() -> dict[str, DhmvProduct]: products = ( DhmvProduct( key="dtm_1m", display_name="DHMV II terreinmodel (DTM)", surface_model="terrain", coverage_id="DHMVII_DTM_1m", native_resolution_m=1.0, catalog_url=DhmvAcquisitionService.DTM_CATALOG_URL, limitation_message=( "Maaiveldhoogte uit de opnameperiode 2013-2015. Gebouwen en andere objecten zijn verwijderd. " "Afstroming is een afgeleide interpretatie; dit product bevat geen waterdiepte." ), ), DhmvProduct( key="dsm_1m", display_name="DHMV II oppervlaktemodel (DSM)", surface_model="surface", coverage_id="DHMVII_DSM_1m", native_resolution_m=1.0, catalog_url=DhmvAcquisitionService.DSM_CATALOG_URL, limitation_message=( "Oppervlaktehoogte uit de opnameperiode 2013-2015, inclusief gebouwen en vegetatie. " "Dit is geen maaiveldmodel, waterdiepte of rechtstreeks gebouwhoogteproduct." ), ), ) return {product.key: product for product in products} @staticmethod def list_products() -> list[dict[str, Any]]: return [ DhmvProductRead( key=product.key, display_name=product.display_name, surface_model=product.surface_model, coverage_id=product.coverage_id, native_resolution_m=product.native_resolution_m, source_crs=DhmvAcquisitionService.SOURCE_CRS, vertical_reference=DhmvAcquisitionService.VERTICAL_REFERENCE, acquisition_period=DhmvAcquisitionService.ACQUISITION_PERIOD, catalog_url=product.catalog_url, attribution=DhmvAcquisitionService.ATTRIBUTION, limitation_message=product.limitation_message, ).model_dump() for product in DhmvAcquisitionService._products().values() ] @staticmethod def _product(product_key: str) -> DhmvProduct: product = DhmvAcquisitionService._products().get(product_key.strip().lower()) if product is None: raise AppError( code="DHMV_PRODUCT_NOT_SUPPORTED", message="Select DTM or DSM from the governed DHMV II product registry", details={"product_key": product_key}, status_code=422, ) return product @staticmethod def _prepared_request(payload: DhmvAcquireRequest, settings: Settings) -> dict[str, Any]: if not settings.dhmv_enabled: raise AppError(code="DHMV_NOT_CONFIGURED", message="DHMV acquisition is disabled", status_code=503) product = DhmvAcquisitionService._product(payload.product_key) resolution_m = float(payload.resolution_m or settings.dhmv_resolution_m) if resolution_m < product.native_resolution_m or resolution_m > 10.0: raise AppError( code="DHMV_RESOLUTION_NOT_SUPPORTED", message="DHMV analysis resolution must be between the native 1 metre and 10 metres", status_code=422, ) 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="DHMV selection must be a finite non-empty rectangle", status_code=400) transformer = Transformer.from_crs("EPSG:4326", DhmvAcquisitionService.SOURCE_CRS, always_xy=True) lambert_bounds = transformer.transform_bounds(*values, densify_pts=21) width_m = float(lambert_bounds[2] - lambert_bounds[0]) height_m = float(lambert_bounds[3] - lambert_bounds[1]) if width_m < settings.dhmv_min_side_m or height_m < settings.dhmv_min_side_m: raise AppError( code="DHMV_SELECTION_TOO_SMALL", message=f"Select an area of at least {settings.dhmv_min_side_m:g} by {settings.dhmv_min_side_m:g} metres", status_code=422, ) if width_m > settings.dhmv_max_side_m or height_m > settings.dhmv_max_side_m: raise AppError( code="DHMV_SELECTION_TOO_LARGE", message=f"Select an area no larger than {settings.dhmv_max_side_m:g} by {settings.dhmv_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.dhmv_max_pixels: raise AppError( code="DHMV_SELECTION_TOO_LARGE", message="DHMV selection exceeds the configured raster cell limit", details={"pixel_count": width * height, "max_pixels": settings.dhmv_max_pixels}, status_code=422, ) bbox_4326 = [float(value) for value in values] bbox_31370 = [float(value) for value in lambert_bounds] request_identity = { "provider": DhmvAcquisitionService.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(request_identity, sort_keys=True).encode()).hexdigest() params = { "SERVICE": "WCS", "VERSION": "2.0.1", "REQUEST": "GetCoverage", "COVERAGEID": product.coverage_id, "FORMAT": "image/tiff", "SUBSET": [ f"x({bbox_31370[0]:.3f},{bbox_31370[2]:.3f})", f"y({bbox_31370[1]:.3f},{bbox_31370[3]:.3f})", ], "SCALEFACTOR": f"{resolution_m / product.native_resolution_m:g}", } query = [ ("SERVICE", params["SERVICE"]), ("VERSION", params["VERSION"]), ("REQUEST", params["REQUEST"]), ("COVERAGEID", params["COVERAGEID"]), ("FORMAT", params["FORMAT"]), ("SUBSET", params["SUBSET"][0]), ("SUBSET", params["SUBSET"][1]), ("SCALEFACTOR", params["SCALEFACTOR"]), ] return { **request_identity, "product": product, "request_hash": request_hash, "request_url": f"{settings.dhmv_wcs_url}?{urlencode(query)}", "params": params, "bbox_epsg4326": bbox_4326, "bbox_epsg31370": bbox_31370, "width": width, "height": height, } @staticmethod def _wcs_request_url( settings: Settings, product: DhmvProduct, bounds: tuple[float, float, float, float], resolution_m: float, ) -> str: query = [ ("SERVICE", "WCS"), ("VERSION", "2.0.1"), ("REQUEST", "GetCoverage"), ("COVERAGEID", product.coverage_id), ("FORMAT", "image/tiff"), ("SUBSET", f"x({bounds[0]:.3f},{bounds[2]:.3f})"), ("SUBSET", f"y({bounds[1]:.3f},{bounds[3]:.3f})"), ("SCALEFACTOR", f"{resolution_m / product.native_resolution_m:g}"), ] return f"{settings.dhmv_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 + DhmvAcquisitionService.WCS_TILE_SIDE_M, max_y) x = min_x while x < max_x: tile_max_x = min(x + DhmvAcquisitionService.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="DHMV_SELECTION_OUTSIDE_AREA", message="The DHMV selection does not overlap the selected work area", status_code=422, ) return intersection @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 == DhmvAcquisitionService.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 _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-dhmv-acquisition", }, ) max_bytes = settings.dhmv_max_response_mb * 1024 * 1024 try: with (opener or urlopen)(request, timeout=settings.dhmv_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="DHMV_RESPONSE_TOO_LARGE", message="Official DHMV 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="DHMV_PROVIDER_UNAVAILABLE", message="The official DHMV 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="DHMV_PROVIDER_UNAVAILABLE", message="The official DHMV WCS could not complete the bounded request", details={"reason": str(exc)}, status_code=502, ) from exc if len(content) > max_bytes: raise AppError(code="DHMV_RESPONSE_TOO_LARGE", message="Official DHMV 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(): preview = content[:300].decode("utf-8", errors="replace") raise AppError( code="DHMV_PROVIDER_INVALID_RESPONSE", message="The official DHMV service did not return a GeoTIFF coverage", details={"content_type": content_type, "response_preview": preview}, 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.iter_parts(): 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="DHMV_PROVIDER_INVALID_RESPONSE", message="The official DHMV multipart response contains no valid GeoTIFF coverage", status_code=502, ) @staticmethod def _mosaic_geotiffs(coverages: list[bytes], expected_resolution_m: float | None = None) -> bytes: if len(coverages) == 1: return coverages[0] try: import rasterio 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 tiled DHMV coverages", status_code=503, ) from exc memories = [MemoryFile(content) for content in coverages] sources = [] try: sources = [memory.open() for memory in memories] target_resolution = float(expected_resolution_m or abs(float(sources[0].res[0]))) invalid_crs = [index for index, source in enumerate(sources) if source.crs is None or source.crs.to_epsg() != 31370] invalid_bands = [index for index, source in enumerate(sources) if source.count != 1] invalid_resolution = [ { "tile_index": index, "resolution": [abs(float(source.res[0])), abs(float(source.res[1]))], } for index, source in enumerate(sources) if not all( math.isclose(abs(float(value)), target_resolution, rel_tol=0.02, abs_tol=0.05) for value in source.res ) ] if invalid_crs or invalid_bands or invalid_resolution: raise AppError( code="DHMV_TILE_MISMATCH", message="DHMV coverage tiles do not match the governed CRS, band layout and resolution", details={ "invalid_crs_tile_indexes": invalid_crs, "invalid_band_tile_indexes": invalid_bands, "invalid_resolution_tiles": invalid_resolution, "expected_resolution_m": target_resolution, }, status_code=502, ) mosaic, transform = merge( sources, res=(target_resolution, target_resolution), nodata=DhmvAcquisitionService.NODATA, dtype="float32", ) profile = sources[0].profile.copy() profile.pop("blockxsize", None) profile.pop("blockysize", None) profile.update( driver="GTiff", width=int(mosaic.shape[2]), height=int(mosaic.shape[1]), count=1, dtype="float32", crs=DhmvAcquisitionService.SOURCE_CRS, transform=transform, nodata=DhmvAcquisitionService.NODATA, 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="DHMV_TILE_MOSAIC_FAILED", message="DHMV coverage tiles could not be assembled into one georeferenced raster", 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: DhmvProduct = prepared["product"] request_urls = [ DhmvAcquisitionService._wcs_request_url( settings, product, bounds, prepared["resolution_m"], ) for bounds in DhmvAcquisitionService._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(DhmvAcquisitionService.WCS_REQUEST_INTERVAL_SECONDS) try: raw_content, content_type = DhmvAcquisitionService._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 DhmvAcquisitionService.WCS_TRANSIENT_STATUS_CODES: raise time.sleep(DhmvAcquisitionService.WCS_RETRY_DELAY_SECONDS) raw_content, content_type = DhmvAcquisitionService._fetch(request_url, settings, opener) coverage_content = DhmvAcquisitionService._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_content).to_bytes(8, "big")) coverage_hash.update(coverage_content) content_types.append(content_type) coverages.append(coverage_content) return DhmvAcquisitionService._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 import rasterio 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 DHMV 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="DHMV_INVALID_CRS", message="DHMV coverage must use EPSG:31370", status_code=502) if source.count != 1: raise AppError(code="DHMV_INVALID_BANDS", message="DHMV coverage must contain exactly one elevation 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="DHMV_INVALID_RESOLUTION", message="DHMV coverage resolution differs from the governed request", details={"expected_m": prepared["resolution_m"], "actual_m": resolution}, status_code=502, ) transformer = Transformer.from_crs("EPSG:4326", DhmvAcquisitionService.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], ) band = np.ma.asarray(clipped[0], dtype="float32") nodata = float(source.nodata if source.nodata is not None else DhmvAcquisitionService.NODATA) invalid = ~np.isfinite(np.asarray(band.filled(np.nan), dtype="float64")) combined_mask = np.ma.getmaskarray(band) | invalid | (np.asarray(band) == nodata) normalized = np.ma.array(np.asarray(band, dtype="float32"), mask=combined_mask) valid_pixel_count = int(normalized.count()) if valid_pixel_count == 0: raise AppError(code="DHMV_NO_VALID_DATA", message="DHMV coverage contains no valid elevation cells in this selection", status_code=422) profile = source.profile.copy() profile.pop("blockxsize", None) profile.pop("blockysize", None) profile.update( driver="GTiff", width=int(normalized.shape[1]), height=int(normalized.shape[0]), count=1, dtype="float32", crs=DhmvAcquisitionService.SOURCE_CRS, transform=transform, nodata=DhmvAcquisitionService.NODATA, compress="deflate", predictor=3, ) with MemoryFile() as output_memory: with output_memory.open(**profile) as output: output.write(normalized.filled(DhmvAcquisitionService.NODATA), 1) normalized_content = output_memory.read() valid_values = normalized.compressed().astype("float64") return normalized_content, { "width": int(normalized.shape[1]), "height": int(normalized.shape[0]), "valid_pixel_count": valid_pixel_count, "nodata_value": DhmvAcquisitionService.NODATA, "resolution_m": resolution, "minimum_m_taw": float(valid_values.min()), "maximum_m_taw": float(valid_values.max()), } except AppError: raise except Exception as exc: raise AppError( code="DHMV_RASTER_INVALID", message="The official DHMV response could not be validated as a georeferenced elevation raster", details={"reason": str(exc)}, status_code=502, ) from exc @staticmethod def acquire( db, project_id: UUID, payload: DhmvAcquireRequest, *, settings: Settings | None = None, opener: Callable[..., Any] | None = None, ) -> dict[str, Any]: resolved_settings = settings or get_settings() prepared = DhmvAcquisitionService._prepared_request(payload, resolved_settings) product: DhmvProduct = prepared["product"] scope_geometry = DhmvAcquisitionService._scope_geometry(db, project_id, payload.area_id, prepared["bbox_epsg4326"]) resolution_token = f"{prepared['resolution_m']:g}".replace(".", "p") filename = f"dhmvii_{product.surface_model}_{resolution_token}m_{prepared['request_hash'][:12]}.tif" if not payload.force_refresh: cached = DhmvAcquisitionService._cached_dataset(db, project_id, filename) if cached is not None: source_metadata = cached.source_metadata or {} raster_metadata = cached.metadata_json or {} return DhmvAcquisitionResult( output_dataset_id=cached.id, reused=True, provider=DhmvAcquisitionService.PROVIDER, product_key=product.key, display_name=product.display_name, surface_model=product.surface_model, coverage_id=product.coverage_id, native_resolution_m=product.native_resolution_m, resolution_m=float(source_metadata.get("analysis_resolution_m", prepared["resolution_m"])), width=int(raster_metadata.get("width", prepared["width"])), height=int(raster_metadata.get("height", prepared["height"])), valid_pixel_count=int(source_metadata.get("valid_pixel_count", 0)), nodata_value=float(raster_metadata.get("nodata", DhmvAcquisitionService.NODATA)), bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg31370=prepared["bbox_epsg31370"], vertical_reference=DhmvAcquisitionService.VERTICAL_REFERENCE, acquisition_period=DhmvAcquisitionService.ACQUISITION_PERIOD, attribution=DhmvAcquisitionService.ATTRIBUTION, limitation_message=product.limitation_message, ).model_dump(mode="json") coverage_content, transfer = DhmvAcquisitionService._fetch_coverage(prepared, resolved_settings, opener) normalized_content, validation = DhmvAcquisitionService._normalize_raster(coverage_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_content, source=f"Digitaal Vlaanderen WCS {product.coverage_id}", source_name=DhmvAcquisitionService.PROVIDER, temporal_series_key=f"digitaal-vlaanderen:dhmvii:{product.key}:{prepared['request_hash'][:24]}", observed_at=datetime(2015, 12, 31, 23, 59, 59, tzinfo=UTC), valid_from=datetime(2013, 1, 1, tzinfo=UTC), valid_to=datetime(2015, 12, 31, 23, 59, 59, tzinfo=UTC), temporal_granularity="period", source_version=DhmvAcquisitionService.SOURCE_VERSION, content_type="image/tiff", source_metadata={ "provider": DhmvAcquisitionService.PROVIDER, "service": "WCS", "service_version": "2.0.1", "product_key": product.key, "product_display_name": product.display_name, "surface_model": product.surface_model, "coverage_id": product.coverage_id, "native_resolution_m": product.native_resolution_m, "analysis_resolution_m": validation["resolution_m"], "source_crs": DhmvAcquisitionService.SOURCE_CRS, "vertical_reference": DhmvAcquisitionService.VERTICAL_REFERENCE, "vertical_unit": "m", "acquisition_period": DhmvAcquisitionService.ACQUISITION_PERIOD, "observation_date_precision": "period", "nodata_value": validation["nodata_value"], "valid_pixel_count": validation["valid_pixel_count"], "minimum_m_taw": validation["minimum_m_taw"], "maximum_m_taw": validation["maximum_m_taw"], "bbox_epsg4326": prepared["bbox_epsg4326"], "bbox_epsg31370": prepared["bbox_epsg31370"], "catalog_url": product.catalog_url, "attribution": DhmvAcquisitionService.ATTRIBUTION, "license_note": DhmvAcquisitionService.LICENSE_NOTE, "theme": "elevation", "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"], "request_url": prepared["request_url"], "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_content).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": product.limitation_message, "water_depth_available": False, "water_volume_available": False, }, ) return DhmvAcquisitionResult( output_dataset_id=dataset.id, reused=False, provider=DhmvAcquisitionService.PROVIDER, product_key=product.key, display_name=product.display_name, surface_model=product.surface_model, coverage_id=product.coverage_id, native_resolution_m=product.native_resolution_m, resolution_m=validation["resolution_m"], width=validation["width"], height=validation["height"], valid_pixel_count=validation["valid_pixel_count"], nodata_value=validation["nodata_value"], bbox_epsg4326=prepared["bbox_epsg4326"], bbox_epsg31370=prepared["bbox_epsg31370"], vertical_reference=DhmvAcquisitionService.VERTICAL_REFERENCE, acquisition_period=DhmvAcquisitionService.ACQUISITION_PERIOD, attribution=DhmvAcquisitionService.ATTRIBUTION, limitation_message=product.limitation_message, ).model_dump(mode="json")