Add Flemish bathymetry partition workflow
This commit is contained in:
@@ -15,8 +15,10 @@ from shapely.geometry import Point, box, mapping
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models import Area, Dataset, Project
|
||||
from app.models import Area, Dataset, DatasetVersion, Project
|
||||
from app.schemas.bathymetry import (
|
||||
BathymetryPartitionFinalizeRequest,
|
||||
BathymetryPartitionFinalizationResult,
|
||||
BathymetryProfileAcquireRequest,
|
||||
BathymetryProfileAcquisitionResult,
|
||||
BathymetrySourceRead,
|
||||
@@ -70,16 +72,16 @@ class BathymetryProfileAcquisitionService:
|
||||
"vertical_reference": "LAT",
|
||||
"horizontal_crs": "bronafhankelijk; expliciet per WCS-respons",
|
||||
"native_resolution": "20 x 20 m",
|
||||
"integration_status": "available_not_integrated",
|
||||
"integration_status": "probe_only",
|
||||
"acquisition_supported": False,
|
||||
"configured": False,
|
||||
"service_url": "https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/WCS_Public",
|
||||
"service_url": "https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
|
||||
"catalog_url": "https://www.vlaanderen.be/datavindplaats/catalogus/dieptemodel-van-de-zeebodem-belgisch-continentaal-plat-noordzee",
|
||||
"attribution": "Agentschap Maritieme Dienstverlening en Kust",
|
||||
"license_note": "Zie de officiële datasetmetadata en gebruiksvoorwaarden.",
|
||||
"limitation_message": (
|
||||
"Niet operationeel in GeoIntel totdat WCS, maritieme begrenzing, tegels, LAT-semantiek en "
|
||||
"servercertificaten in een live integratietest zijn gevalideerd."
|
||||
"Alleen een read-only GetCapabilities-probe is beschikbaar. Rasteracquisitie blijft uit totdat "
|
||||
"WCS, maritieme begrenzing, tegels, LAT-semantiek en servercertificaten live zijn gevalideerd."
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -404,6 +406,8 @@ class BathymetryProfileAcquisitionService:
|
||||
raw_features: list[dict[str, Any]],
|
||||
scope_geometry,
|
||||
watercourse_names: dict[int, dict[str, str | None]],
|
||||
*,
|
||||
partition_properties: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
dates: list[str] = []
|
||||
@@ -465,6 +469,7 @@ class BathymetryProfileAcquisitionService:
|
||||
"provider": BathymetryProfileAcquisitionService.PROVIDER,
|
||||
"measurement_semantics": "historical_cross_section_profile_point",
|
||||
"vertical_reference": "document-specific",
|
||||
**(partition_properties or {}),
|
||||
},
|
||||
"geometry": mapping(point),
|
||||
}
|
||||
@@ -487,6 +492,17 @@ class BathymetryProfileAcquisitionService:
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _municipality_name(area: Area | None) -> str | None:
|
||||
if area is None:
|
||||
return None
|
||||
normalized = str(area.name or "").strip()
|
||||
prefix = "Gemeente "
|
||||
if not normalized.casefold().startswith(prefix.casefold()):
|
||||
return None
|
||||
municipality = normalized[len(prefix) :].split(" - ", 1)[0].strip()
|
||||
return municipality or None
|
||||
|
||||
@staticmethod
|
||||
def _cached_dataset(db, project_id: UUID, filename: str) -> Dataset | None:
|
||||
return (
|
||||
@@ -541,6 +557,8 @@ class BathymetryProfileAcquisitionService:
|
||||
scope_geometry = BathymetryProfileAcquisitionService._scope_geometry(
|
||||
db, project_id, payload.area_id, bbox_values
|
||||
)
|
||||
area = db.get(Area, payload.area_id) if payload.area_id else None
|
||||
municipality = BathymetryProfileAcquisitionService._municipality_name(area)
|
||||
exact_bbox = tuple(float(value) for value in scope_geometry.bounds)
|
||||
request_identity = {
|
||||
"provider": BathymetryProfileAcquisitionService.PROVIDER,
|
||||
@@ -568,7 +586,16 @@ class BathymetryProfileAcquisitionService:
|
||||
vhag_codes, resolved_settings, opener
|
||||
)
|
||||
feature_collection, summary = BathymetryProfileAcquisitionService._normalize_features(
|
||||
raw_features, scope_geometry, names
|
||||
raw_features,
|
||||
scope_geometry,
|
||||
names,
|
||||
partition_properties={
|
||||
"partition_area_id": str(area.id),
|
||||
"partition_area_name": area.name,
|
||||
**({"municipality": municipality} if municipality else {}),
|
||||
}
|
||||
if area
|
||||
else None,
|
||||
)
|
||||
if summary["profile_count"] == 0:
|
||||
raise AppError(
|
||||
@@ -585,6 +612,11 @@ class BathymetryProfileAcquisitionService:
|
||||
"theme": "bathymetry",
|
||||
"layer_name": "bathymetry_profiles",
|
||||
"coverage_scope": "municipality" if payload.area_id else "bounded_selection",
|
||||
"partition_area_id": str(area.id) if area else None,
|
||||
"partition_area_name": area.name if area else None,
|
||||
"municipality": municipality,
|
||||
"partitioned_source_audit": False,
|
||||
"regional_partitions_complete": False,
|
||||
"bbox_epsg4326": list(exact_bbox),
|
||||
**summary,
|
||||
"selection_aggregation": {
|
||||
@@ -669,3 +701,160 @@ class BathymetryProfileAcquisitionService:
|
||||
)
|
||||
persisted = db.get(Dataset, dataset.id)
|
||||
return BathymetryProfileAcquisitionService._result(persisted, reused=False)
|
||||
|
||||
@staticmethod
|
||||
def finalize_partitions(
|
||||
db,
|
||||
project_id: UUID,
|
||||
payload: BathymetryPartitionFinalizeRequest,
|
||||
) -> dict[str, Any]:
|
||||
if not db.get(Project, project_id):
|
||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||
|
||||
expected_area_ids = set(payload.expected_area_ids)
|
||||
dataset_ids = set(payload.dataset_ids)
|
||||
no_profile_area_ids = set(payload.no_profile_area_ids)
|
||||
|
||||
areas: dict[UUID, Area] = {}
|
||||
for area_id in expected_area_ids:
|
||||
area = db.get(Area, area_id)
|
||||
if area is None or area.project_id != project_id:
|
||||
raise AppError(
|
||||
code="BATHYMETRY_PARTITION_AREA_INVALID",
|
||||
message="Every expected partition Area must belong to the project",
|
||||
details={"area_id": str(area_id)},
|
||||
status_code=400,
|
||||
)
|
||||
if BathymetryProfileAcquisitionService._municipality_name(area) is None:
|
||||
raise AppError(
|
||||
code="BATHYMETRY_PARTITION_AREA_INVALID",
|
||||
message="Bathymetry partitions must use persisted municipality Areas",
|
||||
details={"area_id": str(area_id), "area_name": area.name},
|
||||
status_code=400,
|
||||
)
|
||||
areas[area_id] = area
|
||||
|
||||
if not no_profile_area_ids.issubset(expected_area_ids):
|
||||
raise AppError(
|
||||
code="BATHYMETRY_PARTITION_MANIFEST_INVALID",
|
||||
message="No-profile partitions must be part of the expected Area set",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
datasets: list[Dataset] = []
|
||||
data_area_ids: set[UUID] = set()
|
||||
for dataset_id in payload.dataset_ids:
|
||||
dataset = db.get(Dataset, dataset_id)
|
||||
if (
|
||||
dataset is None
|
||||
or dataset.project_id != project_id
|
||||
or dataset.source_name != BathymetryProfileAcquisitionService.PROVIDER
|
||||
or dataset.status != "ready"
|
||||
or dataset.area_id not in expected_area_ids
|
||||
):
|
||||
raise AppError(
|
||||
code="BATHYMETRY_PARTITION_DATASET_INVALID",
|
||||
message="Every partition Dataset must be a ready VHA profile Dataset scoped to an expected Area",
|
||||
details={"dataset_id": str(dataset_id)},
|
||||
status_code=400,
|
||||
)
|
||||
if dataset.area_id in data_area_ids:
|
||||
raise AppError(
|
||||
code="BATHYMETRY_PARTITION_DATASET_DUPLICATE",
|
||||
message="A complete manifest may reference only one profile Dataset per Area",
|
||||
details={"area_id": str(dataset.area_id)},
|
||||
status_code=400,
|
||||
)
|
||||
data_area_ids.add(dataset.area_id)
|
||||
datasets.append(dataset)
|
||||
|
||||
accounted_area_ids = data_area_ids.union(no_profile_area_ids)
|
||||
if accounted_area_ids != expected_area_ids:
|
||||
raise AppError(
|
||||
code="BATHYMETRY_PARTITION_MANIFEST_INCOMPLETE",
|
||||
message="Every expected Area must have one ready Dataset or an explicit no-profile result",
|
||||
details={
|
||||
"missing_area_ids": sorted(str(value) for value in expected_area_ids - accounted_area_ids),
|
||||
"unexpected_area_ids": sorted(str(value) for value in accounted_area_ids - expected_area_ids),
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
profile_count = 0
|
||||
document_count = 0
|
||||
structured_depth_count = 0
|
||||
dates_min: list[str] = []
|
||||
dates_max: list[str] = []
|
||||
shared_metadata = {
|
||||
"partition_scope_key": payload.partition_scope_key,
|
||||
"partition_count": len(expected_area_ids),
|
||||
"data_partition_count": len(datasets),
|
||||
"no_profile_partition_count": len(no_profile_area_ids),
|
||||
"partition_manifest_sha256": payload.manifest_sha256,
|
||||
"partition_manifest_observed_at": payload.observed_at.isoformat(),
|
||||
"partitioned_source_audit": True,
|
||||
"regional_partitions_complete": True,
|
||||
}
|
||||
shared_provenance = {
|
||||
"partition_manifest_sha256": payload.manifest_sha256,
|
||||
"partition_manifest_observed_at": payload.observed_at.isoformat(),
|
||||
"partition_scope_key": payload.partition_scope_key,
|
||||
"regional_partitions_complete": True,
|
||||
"no_profile_area_ids": sorted(str(value) for value in no_profile_area_ids),
|
||||
}
|
||||
|
||||
for dataset in datasets:
|
||||
source_metadata = dict(dataset.source_metadata or {})
|
||||
provenance_metadata = dict(dataset.provenance_metadata or {})
|
||||
profile_count += int(source_metadata.get("profile_count") or 0)
|
||||
document_count += int(source_metadata.get("document_count") or 0)
|
||||
structured_depth_count += int(source_metadata.get("structured_depth_count") or 0)
|
||||
if source_metadata.get("measurement_date_min"):
|
||||
dates_min.append(str(source_metadata["measurement_date_min"]))
|
||||
if source_metadata.get("measurement_date_max"):
|
||||
dates_max.append(str(source_metadata["measurement_date_max"]))
|
||||
area = areas[dataset.area_id]
|
||||
source_metadata.update(
|
||||
{
|
||||
**shared_metadata,
|
||||
"coverage_scope": payload.partition_scope_key,
|
||||
"partition_area_id": str(area.id),
|
||||
"partition_area_name": area.name,
|
||||
"municipality": BathymetryProfileAcquisitionService._municipality_name(area),
|
||||
}
|
||||
)
|
||||
provenance_metadata.update(shared_provenance)
|
||||
dataset.source_metadata = source_metadata
|
||||
dataset.provenance_metadata = provenance_metadata
|
||||
|
||||
if dataset_ids:
|
||||
versions = (
|
||||
db.query(DatasetVersion)
|
||||
.filter(DatasetVersion.dataset_id.in_(dataset_ids))
|
||||
.all()
|
||||
)
|
||||
for version in versions:
|
||||
version.source_metadata = dict(
|
||||
next(dataset.source_metadata for dataset in datasets if dataset.id == version.dataset_id)
|
||||
)
|
||||
version.provenance_metadata = dict(
|
||||
next(dataset.provenance_metadata for dataset in datasets if dataset.id == version.dataset_id)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return BathymetryPartitionFinalizationResult(
|
||||
partition_scope_key=payload.partition_scope_key,
|
||||
regional_partitions_complete=True,
|
||||
partition_count=len(expected_area_ids),
|
||||
data_partition_count=len(datasets),
|
||||
no_profile_partition_count=len(no_profile_area_ids),
|
||||
profile_count=profile_count,
|
||||
document_count=document_count,
|
||||
structured_depth_count=structured_depth_count,
|
||||
measurement_date_min=min(dates_min) if dates_min else None,
|
||||
measurement_date_max=max(dates_max) if dates_max else None,
|
||||
dataset_ids=payload.dataset_ids,
|
||||
manifest_sha256=payload.manifest_sha256,
|
||||
observed_at=payload.observed_at,
|
||||
limitation_message=BathymetryProfileAcquisitionService.LIMITATION,
|
||||
).model_dump(mode="json")
|
||||
|
||||
Reference in New Issue
Block a user