Add Flemish bathymetry partition workflow
This commit is contained in:
@@ -1728,3 +1728,29 @@ The Dataset exposes profile count and nullable structured depth/width metrics.
|
||||
It does not claim a continuous bed model, current depth or volume. Use
|
||||
`scripts/provision_mol_bathymetry_profiles.py` for the canonical Mol operator
|
||||
flow.
|
||||
|
||||
Provision the complete current Flemish land scope and then run the resumable
|
||||
VHA municipality coordinator:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/provision_flanders_geographic_scope.py
|
||||
docker exec geointel python /app/scripts/provision_flanders_bathymetry_profiles.py
|
||||
```
|
||||
|
||||
The first command discovers all current VRBG RefGem municipalities, validates
|
||||
a 270..300 safety range and persists their exact Areas. The second writes its
|
||||
manifest after every partition. Repeating it reuses completed source
|
||||
identities; `--force` deliberately refreshes them. A partial `--members` or
|
||||
`--max-partitions` run never marks regional coverage complete.
|
||||
|
||||
Inspect the MDK North Sea WCS without downloading coverage:
|
||||
|
||||
```bash
|
||||
docker exec geointel python /app/scripts/probe_mdk_bathymetry.py
|
||||
```
|
||||
|
||||
Exit code `0` means verified capabilities; `2` means a truthful blocked
|
||||
readiness state such as TLS or endpoint failure. Runtime controls are
|
||||
`MDK_BATHYMETRY_PROBE_ENABLED`, `MDK_BATHYMETRY_WCS_URL`,
|
||||
`MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS` and
|
||||
`MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB`. TLS verification cannot be disabled.
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas import (
|
||||
FloodHazardAcquireRequest,
|
||||
FloodHazardPartitionSelectionRequest,
|
||||
FloodHazardSelectionRequest,
|
||||
BathymetryPartitionFinalizeRequest,
|
||||
BathymetryProfileAcquireRequest,
|
||||
ThematicRasterAcquireRequest,
|
||||
ThematicRasterSelectionRequest,
|
||||
@@ -54,6 +55,7 @@ from app.services.terrain_analysis_service import TerrainAnalysisService
|
||||
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
|
||||
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
|
||||
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
||||
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
||||
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
|
||||
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
||||
from app.utils.response import envelope
|
||||
@@ -223,6 +225,13 @@ def list_bathymetry_sources(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.get("/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness", response_model=dict)
|
||||
def probe_mdk_bathymetry_readiness(project_id: UUID, db: Session = Depends(get_db)):
|
||||
if not db.get(Project, project_id):
|
||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||
return envelope(MdkBathymetryProbeService.probe())
|
||||
|
||||
|
||||
@router.post("/datasets/bathymetry/profiles/acquire", response_model=dict)
|
||||
def acquire_bounded_bathymetry_profiles(
|
||||
project_id: UUID,
|
||||
@@ -239,6 +248,15 @@ def acquire_bounded_bathymetry_profiles(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/bathymetry/profiles/partitions/finalize", response_model=dict)
|
||||
def finalize_bathymetry_profile_partitions(
|
||||
project_id: UUID,
|
||||
payload: BathymetryPartitionFinalizeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return envelope(BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload))
|
||||
|
||||
|
||||
@router.post("/datasets/thematic-raster/acquire", response_model=dict)
|
||||
def acquire_bounded_thematic_raster(
|
||||
project_id: UUID,
|
||||
|
||||
@@ -123,6 +123,23 @@ class Settings(BaseSettings):
|
||||
le=256,
|
||||
validation_alias="BATHYMETRY_PROFILES_MAX_RESPONSE_MB",
|
||||
)
|
||||
mdk_bathymetry_probe_enabled: bool = Field(default=True, validation_alias="MDK_BATHYMETRY_PROBE_ENABLED")
|
||||
mdk_bathymetry_wcs_url: str = Field(
|
||||
default="https://bathy.agentschapmdk.be/spatialfusionserver/services/ows/wcs/EL_wcs",
|
||||
validation_alias="MDK_BATHYMETRY_WCS_URL",
|
||||
)
|
||||
mdk_bathymetry_probe_timeout_seconds: int = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
le=120,
|
||||
validation_alias="MDK_BATHYMETRY_PROBE_TIMEOUT_SECONDS",
|
||||
)
|
||||
mdk_bathymetry_probe_max_response_mb: int = Field(
|
||||
default=4,
|
||||
ge=1,
|
||||
le=16,
|
||||
validation_alias="MDK_BATHYMETRY_PROBE_MAX_RESPONSE_MB",
|
||||
)
|
||||
thematic_raster_enabled: bool = Field(default=True, validation_alias="THEMATIC_RASTER_ENABLED")
|
||||
thematic_raster_wcs_url: str = Field(
|
||||
default="https://www.mercator.vlaanderen.be/raadpleegdienstenmercatorpubliek/wcs",
|
||||
|
||||
@@ -66,8 +66,11 @@ from .flood_hazard import (
|
||||
FloodHazardSelectionSummary,
|
||||
)
|
||||
from .bathymetry import (
|
||||
BathymetryPartitionFinalizeRequest,
|
||||
BathymetryPartitionFinalizationResult,
|
||||
BathymetryProfileAcquireRequest,
|
||||
BathymetryProfileAcquisitionResult,
|
||||
BathymetrySourceProbeRead,
|
||||
BathymetrySourceRead,
|
||||
)
|
||||
from .thematic_raster import (
|
||||
@@ -209,6 +212,9 @@ __all__ = [
|
||||
"FloodHazardSelectionSummary",
|
||||
"BathymetryProfileAcquireRequest",
|
||||
"BathymetryProfileAcquisitionResult",
|
||||
"BathymetryPartitionFinalizeRequest",
|
||||
"BathymetryPartitionFinalizationResult",
|
||||
"BathymetrySourceProbeRead",
|
||||
"BathymetrySourceRead",
|
||||
"ThematicRasterAcquireRequest",
|
||||
"ThematicRasterAcquisitionResult",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .operations import VectorSelectionBBox
|
||||
|
||||
@@ -25,7 +26,7 @@ class BathymetrySourceRead(BaseModel):
|
||||
vertical_reference: str
|
||||
horizontal_crs: str
|
||||
native_resolution: str | None = None
|
||||
integration_status: Literal["operational", "available_not_integrated", "catalog_only"]
|
||||
integration_status: Literal["operational", "probe_only", "available_not_integrated", "catalog_only"]
|
||||
acquisition_supported: bool
|
||||
configured: bool
|
||||
service_url: str | None = None
|
||||
@@ -50,3 +51,61 @@ class BathymetryProfileAcquisitionResult(BaseModel):
|
||||
measurement_date_max: str | None = None
|
||||
attribution: str
|
||||
limitation_message: str
|
||||
|
||||
|
||||
class BathymetryPartitionFinalizeRequest(BaseModel):
|
||||
partition_scope_key: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9][a-z0-9_-]*$")
|
||||
expected_area_ids: list[UUID] = Field(min_length=1, max_length=500)
|
||||
dataset_ids: list[UUID] = Field(default_factory=list, max_length=500)
|
||||
no_profile_area_ids: list[UUID] = Field(default_factory=list, max_length=500)
|
||||
manifest_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
observed_at: datetime
|
||||
|
||||
@field_validator("expected_area_ids", "dataset_ids", "no_profile_area_ids")
|
||||
@classmethod
|
||||
def require_unique_ids(cls, value: list[UUID]) -> list[UUID]:
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError("Partition identifiers must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class BathymetryPartitionFinalizationResult(BaseModel):
|
||||
partition_scope_key: str
|
||||
regional_partitions_complete: bool
|
||||
partition_count: int = Field(ge=1)
|
||||
data_partition_count: int = Field(ge=0)
|
||||
no_profile_partition_count: int = Field(ge=0)
|
||||
profile_count: int = Field(ge=0)
|
||||
document_count: int = Field(ge=0)
|
||||
structured_depth_count: int = Field(ge=0)
|
||||
measurement_date_min: str | None = None
|
||||
measurement_date_max: str | None = None
|
||||
dataset_ids: list[UUID]
|
||||
manifest_sha256: str
|
||||
observed_at: datetime
|
||||
limitation_message: str
|
||||
|
||||
|
||||
class BathymetrySourceProbeRead(BaseModel):
|
||||
source_key: str
|
||||
status: Literal[
|
||||
"disabled",
|
||||
"invalid_configuration",
|
||||
"tls_error",
|
||||
"endpoint_unavailable",
|
||||
"invalid_capabilities",
|
||||
"reachable",
|
||||
]
|
||||
configured_url: str
|
||||
capabilities_url: str | None = None
|
||||
tls_verified: bool
|
||||
capabilities_reachable: bool
|
||||
acquisition_supported: bool = False
|
||||
wcs_version: str | None = None
|
||||
coverage_identifiers: list[str] = Field(default_factory=list)
|
||||
advertised_formats: list[str] = Field(default_factory=list)
|
||||
advertised_crs: list[str] = Field(default_factory=list)
|
||||
response_sha256: str | None = None
|
||||
checked_at: datetime
|
||||
message: str
|
||||
limitation_message: str
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import ssl
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.request import Request, urlopen
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.schemas.bathymetry import BathymetrySourceProbeRead
|
||||
|
||||
|
||||
class MdkBathymetryProbeService:
|
||||
SOURCE_KEY = "mdk_bcp_bathymetry"
|
||||
LIMITATION = (
|
||||
"Deze probe leest alleen WCS GetCapabilities met strikte TLS-controle. "
|
||||
"GeoIntel downloadt of activeert geen Noordzee-raster totdat endpoint, coverage-id, CRS, LAT, "
|
||||
"nodata, resolutie, begrenzing en responslimieten live zijn gevalideerd."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _capabilities_url(configured_url: str) -> str:
|
||||
parsed = urlsplit(configured_url.strip())
|
||||
if parsed.scheme.lower() != "https" or not parsed.hostname:
|
||||
raise ValueError("The MDK WCS probe requires an absolute HTTPS URL")
|
||||
parameters = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
||||
parameters.update({"service": "WCS", "request": "GetCapabilities", "version": "1.0.0"})
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(parameters), ""))
|
||||
|
||||
@staticmethod
|
||||
def _read_capabilities(
|
||||
capabilities_url: str,
|
||||
settings: Settings,
|
||||
opener: Callable[..., Any] | None,
|
||||
) -> tuple[bytes, str]:
|
||||
request = Request(
|
||||
capabilities_url,
|
||||
headers={
|
||||
"Accept": "application/xml,text/xml;q=0.9,*/*;q=0.1",
|
||||
"User-Agent": "GeoIntel/1.0 MDK-bathymetry-readiness-probe",
|
||||
},
|
||||
)
|
||||
with (opener or urlopen)(request, timeout=settings.mdk_bathymetry_probe_timeout_seconds) as response:
|
||||
limit = settings.mdk_bathymetry_probe_max_response_mb * 1024 * 1024
|
||||
content = response.read(limit + 1)
|
||||
if len(content) > limit:
|
||||
raise ValueError("MDK WCS capabilities response exceeded the configured size limit")
|
||||
content_type = str(response.headers.get("Content-Type") or "") if hasattr(response, "headers") else ""
|
||||
return content, content_type
|
||||
|
||||
@staticmethod
|
||||
def _local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1].casefold()
|
||||
|
||||
@staticmethod
|
||||
def _parse_capabilities(content: bytes) -> dict[str, Any]:
|
||||
root = ElementTree.fromstring(content)
|
||||
root_name = MdkBathymetryProbeService._local_name(root.tag)
|
||||
if "capabilities" not in root_name:
|
||||
raise ValueError("MDK endpoint did not return a WCS capabilities document")
|
||||
|
||||
coverage_identifiers: set[str] = set()
|
||||
advertised_formats: set[str] = set()
|
||||
advertised_crs: set[str] = set()
|
||||
for element in root.iter():
|
||||
local_name = MdkBathymetryProbeService._local_name(element.tag)
|
||||
text = (element.text or "").strip()
|
||||
if local_name in {"coverageofferingbrief", "coverageoffering"}:
|
||||
for child in element:
|
||||
if MdkBathymetryProbeService._local_name(child.tag) in {"name", "identifier"}:
|
||||
identifier = (child.text or "").strip()
|
||||
if identifier:
|
||||
coverage_identifiers.add(identifier)
|
||||
break
|
||||
if local_name in {"format", "formats"} and text:
|
||||
advertised_formats.add(text)
|
||||
if local_name in {"requestresponsecrss", "requestcrss", "responsecrss", "nativecrss", "crs"} and text:
|
||||
advertised_crs.add(text)
|
||||
for attribute_value in element.attrib.values():
|
||||
normalized = str(attribute_value).strip()
|
||||
if "EPSG" in normalized.upper() or "CRS:" in normalized.upper():
|
||||
advertised_crs.add(normalized)
|
||||
|
||||
return {
|
||||
"wcs_version": str(root.attrib.get("version") or "") or None,
|
||||
"coverage_identifiers": sorted(coverage_identifiers),
|
||||
"advertised_formats": sorted(advertised_formats),
|
||||
"advertised_crs": sorted(advertised_crs),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _result(
|
||||
*,
|
||||
settings: Settings,
|
||||
status: str,
|
||||
checked_at: datetime,
|
||||
message: str,
|
||||
capabilities_url: str | None = None,
|
||||
tls_verified: bool = False,
|
||||
capabilities_reachable: bool = False,
|
||||
response_sha256: str | None = None,
|
||||
parsed: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
parsed = parsed or {}
|
||||
return BathymetrySourceProbeRead(
|
||||
source_key=MdkBathymetryProbeService.SOURCE_KEY,
|
||||
status=status,
|
||||
configured_url=settings.mdk_bathymetry_wcs_url,
|
||||
capabilities_url=capabilities_url,
|
||||
tls_verified=tls_verified,
|
||||
capabilities_reachable=capabilities_reachable,
|
||||
acquisition_supported=False,
|
||||
wcs_version=parsed.get("wcs_version"),
|
||||
coverage_identifiers=parsed.get("coverage_identifiers") or [],
|
||||
advertised_formats=parsed.get("advertised_formats") or [],
|
||||
advertised_crs=parsed.get("advertised_crs") or [],
|
||||
response_sha256=response_sha256,
|
||||
checked_at=checked_at,
|
||||
message=message,
|
||||
limitation_message=MdkBathymetryProbeService.LIMITATION,
|
||||
).model_dump(mode="json")
|
||||
|
||||
@staticmethod
|
||||
def probe(
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
checked_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
resolved_settings = settings or get_settings()
|
||||
now = checked_at or datetime.now(UTC)
|
||||
if not resolved_settings.mdk_bathymetry_probe_enabled:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="disabled",
|
||||
checked_at=now,
|
||||
message="MDK bathymetry readiness probing is disabled.",
|
||||
)
|
||||
try:
|
||||
capabilities_url = MdkBathymetryProbeService._capabilities_url(
|
||||
resolved_settings.mdk_bathymetry_wcs_url
|
||||
)
|
||||
except ValueError as exc:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="invalid_configuration",
|
||||
checked_at=now,
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
try:
|
||||
content, content_type = MdkBathymetryProbeService._read_capabilities(
|
||||
capabilities_url, resolved_settings, opener
|
||||
)
|
||||
except HTTPError as exc:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="endpoint_unavailable",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
tls_verified=True,
|
||||
message=f"MDK WCS GetCapabilities returned HTTP {exc.code}.",
|
||||
)
|
||||
except ssl.SSLCertVerificationError:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="tls_error",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
message="MDK WCS TLS certificate validation failed; insecure fallback is prohibited.",
|
||||
)
|
||||
except URLError as exc:
|
||||
reason = exc.reason
|
||||
is_tls_error = isinstance(reason, (ssl.SSLError, ssl.CertificateError)) or "certificate" in str(
|
||||
reason
|
||||
).casefold()
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="tls_error" if is_tls_error else "endpoint_unavailable",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
message=(
|
||||
"MDK WCS TLS certificate validation failed; insecure fallback is prohibited."
|
||||
if is_tls_error
|
||||
else "MDK WCS GetCapabilities could not be reached."
|
||||
),
|
||||
)
|
||||
except (TimeoutError, OSError) as exc:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="endpoint_unavailable",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
message=f"MDK WCS GetCapabilities could not be reached ({type(exc).__name__}).",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="invalid_capabilities",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
tls_verified=True,
|
||||
capabilities_reachable=True,
|
||||
message=str(exc),
|
||||
)
|
||||
|
||||
response_sha256 = hashlib.sha256(content).hexdigest()
|
||||
try:
|
||||
parsed = MdkBathymetryProbeService._parse_capabilities(content)
|
||||
except (ElementTree.ParseError, ValueError) as exc:
|
||||
detail = " ".join(str(exc).split())
|
||||
if content_type:
|
||||
detail = f"{detail} Content-Type: {content_type}."
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="invalid_capabilities",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
tls_verified=True,
|
||||
capabilities_reachable=True,
|
||||
response_sha256=response_sha256,
|
||||
message=detail,
|
||||
)
|
||||
|
||||
if not parsed["coverage_identifiers"]:
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="invalid_capabilities",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
tls_verified=True,
|
||||
capabilities_reachable=True,
|
||||
response_sha256=response_sha256,
|
||||
parsed=parsed,
|
||||
message="MDK WCS capabilities are reachable but advertise no coverage identifier.",
|
||||
)
|
||||
return MdkBathymetryProbeService._result(
|
||||
settings=resolved_settings,
|
||||
status="reachable",
|
||||
checked_at=now,
|
||||
capabilities_url=capabilities_url,
|
||||
tls_verified=True,
|
||||
capabilities_reachable=True,
|
||||
response_sha256=response_sha256,
|
||||
parsed=parsed,
|
||||
message=(
|
||||
"MDK WCS capabilities are reachable with verified TLS. "
|
||||
"Raster acquisition remains disabled pending bounded coverage validation."
|
||||
),
|
||||
)
|
||||
@@ -282,8 +282,11 @@ def test_bathymetry_acquisition_persists_reference_dataset_through_dataset_servi
|
||||
assert captured["reference_layer_name"] == "bathymetry_profiles"
|
||||
assert captured["source_metadata"]["theme"] == "bathymetry"
|
||||
assert captured["source_metadata"]["volume_supported"] is False
|
||||
assert captured["provenance_metadata"]["water_volume_available"] is False
|
||||
assert captured["source_metadata"]["municipality"] == "Mol"
|
||||
assert captured["source_metadata"]["regional_partitions_complete"] is False
|
||||
payload = json.loads(captured["content"])
|
||||
assert payload["features"][0]["properties"]["municipality"] == "Mol"
|
||||
assert captured["provenance_metadata"]["water_volume_available"] is False
|
||||
assert len(payload["features"]) == 2
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from urllib.error import URLError
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models import Area, Dataset, DatasetVersion, Project
|
||||
from app.schemas.bathymetry import BathymetryPartitionFinalizeRequest
|
||||
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
|
||||
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import provision_flanders_geographic_scope as flanders_scope # noqa: E402
|
||||
|
||||
|
||||
class BinaryResponse:
|
||||
def __init__(self, content: bytes, *, content_type: str = "application/xml"):
|
||||
self.content = content
|
||||
self.headers = {"Content-Type": content_type}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, size=-1):
|
||||
return self.content if size < 0 else self.content[:size]
|
||||
|
||||
|
||||
class JsonResponse:
|
||||
def __init__(self, payload, *, url="https://geo.api.vlaanderen.be/VRBG/items"):
|
||||
self.payload = payload
|
||||
self.url = url
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class SourceSession:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def get(self, *_args, **_kwargs):
|
||||
return JsonResponse(self.payload)
|
||||
|
||||
|
||||
class VersionQuery:
|
||||
def __init__(self, versions):
|
||||
self.versions = versions
|
||||
|
||||
def filter(self, *_args):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self.versions
|
||||
|
||||
|
||||
class FinalizeSession:
|
||||
def __init__(self, rows, versions=None):
|
||||
self.rows = rows
|
||||
self.versions = versions or []
|
||||
self.commit_count = 0
|
||||
|
||||
def get(self, model, row_id):
|
||||
return self.rows.get((model, row_id))
|
||||
|
||||
def query(self, model):
|
||||
assert model is DatasetVersion
|
||||
return VersionQuery(self.versions)
|
||||
|
||||
def commit(self):
|
||||
self.commit_count += 1
|
||||
|
||||
|
||||
def capabilities_xml() -> bytes:
|
||||
return b"""<?xml version="1.0"?>
|
||||
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
|
||||
<ContentMetadata>
|
||||
<CoverageOfferingBrief>
|
||||
<name>EL.GridCoverage</name>
|
||||
<label>Belgian Continental Shelf bathymetry</label>
|
||||
<lonLatEnvelope srsName="urn:ogc:def:crs:OGC:1.3:CRS84" />
|
||||
</CoverageOfferingBrief>
|
||||
</ContentMetadata>
|
||||
<Capability><Request><GetCoverage><resultFormat><formats>GeoTIFF</formats></resultFormat></GetCoverage></Request></Capability>
|
||||
</WCS_Capabilities>"""
|
||||
|
||||
|
||||
def test_mdk_probe_parses_capabilities_without_enabling_acquisition() -> None:
|
||||
seen = {}
|
||||
|
||||
def opener(request, timeout):
|
||||
seen["url"] = request.full_url
|
||||
seen["timeout"] = timeout
|
||||
return BinaryResponse(capabilities_xml())
|
||||
|
||||
result = MdkBathymetryProbeService.probe(
|
||||
settings=Settings(_env_file=None),
|
||||
opener=opener,
|
||||
checked_at=datetime(2026, 7, 17, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result["status"] == "reachable"
|
||||
assert result["tls_verified"] is True
|
||||
assert result["capabilities_reachable"] is True
|
||||
assert result["acquisition_supported"] is False
|
||||
assert result["coverage_identifiers"] == ["EL.GridCoverage"]
|
||||
assert result["advertised_formats"] == ["GeoTIFF"]
|
||||
assert result["response_sha256"]
|
||||
assert "request=GetCapabilities" in seen["url"]
|
||||
assert seen["timeout"] == 20
|
||||
|
||||
|
||||
def test_mdk_probe_reports_tls_failure_and_never_uses_insecure_fallback() -> None:
|
||||
calls = 0
|
||||
|
||||
def opener(_request, timeout):
|
||||
nonlocal calls
|
||||
assert timeout == 20
|
||||
calls += 1
|
||||
raise URLError(ssl.SSLCertVerificationError("hostname mismatch"))
|
||||
|
||||
result = MdkBathymetryProbeService.probe(
|
||||
settings=Settings(_env_file=None),
|
||||
opener=opener,
|
||||
)
|
||||
|
||||
assert calls == 1
|
||||
assert result["status"] == "tls_error"
|
||||
assert result["tls_verified"] is False
|
||||
assert result["capabilities_reachable"] is False
|
||||
assert "insecure fallback is prohibited" in result["message"]
|
||||
|
||||
|
||||
def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
db = SimpleNamespace(get=lambda model, row_id: Project(id=project_id, name="Mol") if model is Project else None)
|
||||
monkeypatch.setattr(
|
||||
MdkBathymetryProbeService,
|
||||
"probe",
|
||||
lambda: {
|
||||
"source_key": "mdk_bcp_bathymetry",
|
||||
"status": "tls_error",
|
||||
"acquisition_supported": False,
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
response = TestClient(app).get(
|
||||
f"/api/v1/projects/{project_id}/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness"
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert set(response.json()) == {"data"}
|
||||
assert response.json()["data"]["status"] == "tls_error"
|
||||
assert response.json()["data"]["acquisition_supported"] is False
|
||||
|
||||
|
||||
def test_partition_finalization_requires_complete_area_accounting_and_updates_versions() -> None:
|
||||
project_id = uuid4()
|
||||
area_ids = [uuid4(), uuid4()]
|
||||
dataset_id = uuid4()
|
||||
project = Project(id=project_id, name="Flanders")
|
||||
areas = [
|
||||
Area(id=area_ids[0], project_id=project_id, name="Gemeente Mol - officiële grens"),
|
||||
Area(id=area_ids[1], project_id=project_id, name="Gemeente Geel - officiële grens"),
|
||||
]
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
area_id=area_ids[0],
|
||||
name="vha.geojson",
|
||||
dataset_type="vector",
|
||||
source="VHA",
|
||||
source_name=BathymetryProfileAcquisitionService.PROVIDER,
|
||||
source_metadata={
|
||||
"profile_count": 3,
|
||||
"document_count": 2,
|
||||
"structured_depth_count": 1,
|
||||
"measurement_date_min": "1990-01-01",
|
||||
"measurement_date_max": "2020-01-01",
|
||||
},
|
||||
provenance_metadata={},
|
||||
status="ready",
|
||||
)
|
||||
version = DatasetVersion(id=uuid4(), dataset_id=dataset_id, version=1)
|
||||
rows = {
|
||||
(Project, project_id): project,
|
||||
(Area, area_ids[0]): areas[0],
|
||||
(Area, area_ids[1]): areas[1],
|
||||
(Dataset, dataset_id): dataset,
|
||||
}
|
||||
db = FinalizeSession(rows, [version])
|
||||
payload = BathymetryPartitionFinalizeRequest(
|
||||
partition_scope_key="flanders",
|
||||
expected_area_ids=area_ids,
|
||||
dataset_ids=[dataset_id],
|
||||
no_profile_area_ids=[area_ids[1]],
|
||||
manifest_sha256="a" * 64,
|
||||
observed_at=datetime(2026, 7, 17, tzinfo=UTC),
|
||||
)
|
||||
|
||||
result = BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload)
|
||||
|
||||
assert result["regional_partitions_complete"] is True
|
||||
assert result["partition_count"] == 2
|
||||
assert result["data_partition_count"] == 1
|
||||
assert result["no_profile_partition_count"] == 1
|
||||
assert result["profile_count"] == 3
|
||||
assert dataset.source_metadata["coverage_scope"] == "flanders"
|
||||
assert dataset.source_metadata["municipality"] == "Mol"
|
||||
assert dataset.source_metadata["partitioned_source_audit"] is True
|
||||
assert version.source_metadata == dataset.source_metadata
|
||||
assert version.provenance_metadata == dataset.provenance_metadata
|
||||
assert db.commit_count == 1
|
||||
|
||||
incomplete = payload.model_copy(update={"no_profile_area_ids": []})
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
BathymetryProfileAcquisitionService.finalize_partitions(
|
||||
FinalizeSession(rows, [version]),
|
||||
project_id,
|
||||
incomplete,
|
||||
)
|
||||
assert exc_info.value.code == "BATHYMETRY_PARTITION_MANIFEST_INCOMPLETE"
|
||||
|
||||
|
||||
def test_flanders_scope_discovery_uses_complete_unique_vrbg_inventory() -> None:
|
||||
features = [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": f"Refgem.{index:05d}",
|
||||
"properties": {"NISCODE": f"{index:05d}", "NAAM": f"Gemeente {index:03d}"},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[4.0, 50.5], [4.1, 50.5], [4.1, 50.6], [4.0, 50.5]]],
|
||||
},
|
||||
}
|
||||
for index in range(1, 286)
|
||||
]
|
||||
scope, selected, source_url = flanders_scope.discover_flanders_scope(
|
||||
SourceSession({"features": list(reversed(features))}),
|
||||
timeout=30,
|
||||
min_municipalities=270,
|
||||
max_municipalities=300,
|
||||
)
|
||||
|
||||
assert scope.key == "flanders"
|
||||
assert scope.project_name == "Flanders Regional Workbench"
|
||||
assert len(scope.members) == 285
|
||||
assert len(set(scope.nis_codes)) == 285
|
||||
assert [item["properties"]["NISCODE"] for item in selected] == sorted(scope.nis_codes)
|
||||
assert source_url.startswith("https://geo.api.vlaanderen.be/")
|
||||
|
||||
|
||||
def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
for script in (
|
||||
"provision_flanders_geographic_scope.py",
|
||||
"provision_flanders_bathymetry_profiles.py",
|
||||
"probe_mdk_bathymetry.py",
|
||||
):
|
||||
assert f"COPY scripts/{script}" in dockerfile
|
||||
assert f"py_compile scripts/{script}" in readiness
|
||||
|
||||
sources = {
|
||||
item["key"]: item
|
||||
for item in BathymetryProfileAcquisitionService.list_sources()
|
||||
}
|
||||
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
|
||||
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False
|
||||
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
|
||||
Reference in New Issue
Block a user