feat(scope): make Belgium and North Sea operational default
This commit is contained in:
@@ -277,6 +277,48 @@ def test_regional_official_vector_sources_are_configurable_in_every_runtime() ->
|
||||
assert f'Target="{key}"' in template
|
||||
|
||||
|
||||
def test_segmentation_and_mdk_acquisition_are_configurable_in_every_runtime() -> None:
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
unraid_compose = (ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8")
|
||||
run_script = (ROOT / "deploy" / "unraid" / "run-dockerman-container.sh").read_text(encoding="utf-8")
|
||||
env_example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
unraid_env = (ROOT / "deploy" / "unraid" / "geointel.env.example").read_text(encoding="utf-8")
|
||||
template = (ROOT / "deploy" / "unraid" / "geointel-unraid-template.xml").read_text(encoding="utf-8")
|
||||
|
||||
for key in (
|
||||
"YOLO_SEG_ENABLED",
|
||||
"YOLO_SEG_MODEL_PATH",
|
||||
"SAM_ENABLED",
|
||||
"SAM_MODEL_PATH",
|
||||
"SEGMENTATION_MAX_MASKS_PER_TILE",
|
||||
"SEGMENTATION_DUPLICATE_IOU_THRESHOLD",
|
||||
"MDK_BATHYMETRY_ACQUISITION_ENABLED",
|
||||
"MDK_BATHYMETRY_COVERAGE_ID",
|
||||
"MDK_BATHYMETRY_MAX_BBOX_DEG2",
|
||||
):
|
||||
assert key in compose, key
|
||||
assert key in unraid_compose, key
|
||||
assert f'{key}="${{{key}:-' in run_script, key
|
||||
assert f'-e {key}="${key}"' in run_script, key
|
||||
assert f"{key}=" in env_example, key
|
||||
assert f"{key}=" in unraid_env, key
|
||||
assert f'Target="{key}"' in template, key
|
||||
|
||||
|
||||
def test_compose_reconciles_interrupted_runs_after_restart_like_unraid_runtime() -> None:
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
start_script = (ROOT / "deploy" / "unraid" / "all-in-one-start.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert (
|
||||
"GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP: "
|
||||
"${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}"
|
||||
) in compose
|
||||
assert (
|
||||
'GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP='
|
||||
'"${GEOINTEL_RECONCILE_INTERRUPTED_RUNS_ON_STARTUP:-true}"'
|
||||
) in start_script
|
||||
|
||||
|
||||
def test_docker_build_contexts_exclude_vendor_build_and_cache_outputs() -> None:
|
||||
required_patterns = {
|
||||
"node_modules",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.bathymetry import MdkBathymetryAcquireRequest
|
||||
from app.schemas.operations import VectorSelectionBBox
|
||||
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
|
||||
|
||||
CAPABILITIES_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<WCS_Capabilities version="1.0.0" xmlns="http://www.opengis.net/wcs">
|
||||
<ContentMetadata>
|
||||
<CoverageOfferingBrief>
|
||||
<name>depth_model_20m_lat</name>
|
||||
<label>Belgian Continental Shelf depth model</label>
|
||||
</CoverageOfferingBrief>
|
||||
</ContentMetadata>
|
||||
</WCS_Capabilities>
|
||||
"""
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, content: bytes, content_type: str = "application/xml") -> None:
|
||||
self._stream = io.BytesIO(content)
|
||||
self.headers = {"Content-Type": content_type}
|
||||
|
||||
def read(self, limit: int = -1) -> bytes:
|
||||
return self._stream.read(limit)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
def _payload(**overrides) -> MdkBathymetryAcquireRequest:
|
||||
values = {
|
||||
"bbox": VectorSelectionBBox(min_x=2.5, min_y=51.3, max_x=2.6, max_y=51.4),
|
||||
"force_refresh": True,
|
||||
}
|
||||
values.update(overrides)
|
||||
return MdkBathymetryAcquireRequest(**values)
|
||||
|
||||
|
||||
def _settings(**overrides) -> Settings:
|
||||
values = {
|
||||
"mdk_bathymetry_acquisition_enabled": True,
|
||||
"mdk_bathymetry_coverage_id": "depth_model_20m_lat",
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_acquisition_fails_closed_when_disabled() -> None:
|
||||
settings = _settings(mdk_bathymetry_acquisition_enabled=False)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_ACQUISITION_DISABLED"
|
||||
|
||||
|
||||
def test_acquisition_fails_closed_without_coverage_id() -> None:
|
||||
settings = _settings(mdk_bathymetry_coverage_id=None)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_COVERAGE_NOT_CONFIGURED"
|
||||
|
||||
|
||||
def test_acquisition_rejects_oversized_bbox() -> None:
|
||||
settings = _settings(mdk_bathymetry_max_bbox_deg2=0.001)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_BBOX_TOO_LARGE"
|
||||
|
||||
|
||||
def test_acquisition_requires_reachable_probe() -> None:
|
||||
settings = _settings()
|
||||
|
||||
def failing_opener(request, timeout=None):
|
||||
raise OSError("connection refused")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=failing_opener)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_ENDPOINT_NOT_READY"
|
||||
|
||||
|
||||
def test_acquisition_requires_advertised_coverage_id() -> None:
|
||||
settings = _settings(mdk_bathymetry_coverage_id="not_advertised_coverage")
|
||||
|
||||
def opener(request, timeout=None):
|
||||
return FakeResponse(CAPABILITIES_XML)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=opener)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_COVERAGE_NOT_ADVERTISED"
|
||||
|
||||
|
||||
def test_acquisition_rejects_non_geotiff_coverage_response() -> None:
|
||||
settings = _settings()
|
||||
responses = []
|
||||
|
||||
def opener(request, timeout=None):
|
||||
url = request.full_url if hasattr(request, "full_url") else str(request)
|
||||
responses.append(url)
|
||||
if "GetCapabilities" in url:
|
||||
return FakeResponse(CAPABILITIES_XML)
|
||||
return FakeResponse(b"<ServiceExceptionReport>boom</ServiceExceptionReport>", "application/xml")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
MdkBathymetryAcquisitionService.acquire(None, uuid4(), _payload(), settings=settings, opener=opener)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "MDK_BATHYMETRY_INVALID_RESPONSE"
|
||||
assert any("GetCoverage" in url for url in responses)
|
||||
coverage_urls = [url for url in responses if "GetCoverage" in url]
|
||||
assert "coverage=depth_model_20m_lat" in coverage_urls[0]
|
||||
assert "format=GeoTIFF" in coverage_urls[0]
|
||||
|
||||
|
||||
def test_get_coverage_url_is_bounded_and_pinned() -> None:
|
||||
settings = _settings()
|
||||
bbox = [2.5, 51.3, 2.6, 51.4]
|
||||
|
||||
url = MdkBathymetryAcquisitionService._get_coverage_url(settings, "depth_model_20m_lat", bbox)
|
||||
|
||||
assert url.startswith("https://")
|
||||
assert "request=GetCoverage" in url
|
||||
assert "version=1.0.0" in url
|
||||
assert "crs=EPSG%3A4326" in url or "crs=EPSG:4326" in url
|
||||
width, height = MdkBathymetryAcquisitionService._pixel_dimensions(bbox)
|
||||
assert 1 <= width <= MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE
|
||||
assert 1 <= height <= MdkBathymetryAcquisitionService.MAX_PIXELS_PER_SIDE
|
||||
|
||||
|
||||
def test_source_module_never_disables_tls_verification() -> None:
|
||||
source = (
|
||||
Path(__file__).resolve().parents[1] / "app" / "services" / "mdk_bathymetry_acquisition_service.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "_create_unverified_context" not in source
|
||||
assert "CERT_NONE" not in source
|
||||
assert "check_hostname = False" not in source
|
||||
@@ -119,7 +119,7 @@ def test_model_asset_catalog_lists_supported_local_model_files(tmp_path: Path) -
|
||||
assert asset.size_bytes == len(b"local model")
|
||||
assert len(asset.sha256) == 64
|
||||
assert asset.active is True
|
||||
assert asset.status == "available"
|
||||
assert asset.status == "approved"
|
||||
assert asset.will_download_models is False
|
||||
|
||||
|
||||
@@ -134,6 +134,25 @@ def test_model_asset_catalog_resolves_known_asset(tmp_path: Path) -> None:
|
||||
assert asset.model_path == str(model_file)
|
||||
|
||||
|
||||
def test_model_asset_catalog_only_exposes_explicit_active_asset_in_runtime(tmp_path: Path) -> None:
|
||||
active_file = tmp_path / "approved-building-detector.pt"
|
||||
active_file.write_bytes(b"approved")
|
||||
(tmp_path / "training-smoke.pt").write_bytes(b"experiment")
|
||||
(tmp_path / "partial-checkpoint.pt").write_bytes(b"partial")
|
||||
settings = Settings(
|
||||
yolo_models_dir=str(tmp_path),
|
||||
yolo_model_path=str(active_file),
|
||||
yolo_enabled=True,
|
||||
)
|
||||
|
||||
response = ModelAssetCatalogService.list_assets(settings=settings)
|
||||
|
||||
assert response.total == 1
|
||||
assert response.items[0].filename == active_file.name
|
||||
assert response.items[0].active is True
|
||||
assert response.items[0].status == "approved"
|
||||
|
||||
|
||||
def test_model_asset_catalog_rejects_unknown_asset(tmp_path: Path) -> None:
|
||||
settings = Settings(yolo_models_dir=str(tmp_path), yolo_enabled=True)
|
||||
|
||||
|
||||
@@ -119,6 +119,72 @@ def test_regional_product_registry_is_explicit_and_source_specific() -> None:
|
||||
assert products["urbis_buildings"]["coverage_zones"] == ["brussels"]
|
||||
assert products["urbis_buildings"]["license_note"] == "Buildings are published under CC0."
|
||||
assert "FPS Finance" in products["urbis_cadastral_parcels"]["license_note"]
|
||||
# urbis_street_axes is live-validated against the UrbIS WFS capabilities:
|
||||
# urbisvector:StreetAxes exposes INSPIRE_ID and LineString geometry. The
|
||||
# same capabilities document advertises no hydrography feature type, so
|
||||
# Brussels surface water intentionally stays not_configured.
|
||||
assert products["urbis_street_axes"]["coverage_zones"] == ["brussels"]
|
||||
assert products["urbis_street_axes"]["collection"] == "urbisvector:StreetAxes"
|
||||
assert products["urbis_street_axes"]["geometry_types"] == [
|
||||
"LineString",
|
||||
"MultiLineString",
|
||||
]
|
||||
assert products["urbis_street_axes"]["theme"] == "roads"
|
||||
assert products["urbis_land_cover_blocks"]["collection"] == "urbisvector:Blocks"
|
||||
assert products["urbis_land_cover_blocks"]["theme"] == "space_occupation"
|
||||
assert products["urbis_forest_parks"]["theme"] == "forest"
|
||||
assert products["urbis_water_surfaces"]["theme"] == "water"
|
||||
|
||||
|
||||
def test_urbis_land_cover_products_filter_only_documented_block_classes() -> None:
|
||||
scope_wgs84 = Polygon(
|
||||
[(4.35, 50.84), (4.36, 50.84), (4.36, 50.85), (4.35, 50.85), (4.35, 50.84)]
|
||||
)
|
||||
scope_metric = Polygon([_TO_LAMBERT72.transform(x, y) for x, y in scope_wgs84.exterior.coords])
|
||||
min_x, min_y, max_x, max_y = scope_metric.bounds
|
||||
|
||||
def block(block_type: str):
|
||||
return {
|
||||
"type": "Feature",
|
||||
"id": f"Blocks.{block_type}",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[
|
||||
[min_x + 10, min_y + 10],
|
||||
[min_x + 100, min_y + 10],
|
||||
[min_x + 100, min_y + 100],
|
||||
[min_x + 10, min_y + 100],
|
||||
[min_x + 10, min_y + 10],
|
||||
]],
|
||||
},
|
||||
"properties": {
|
||||
"INSPIRE_ID": f"https://databrussels.be/id/block/{block_type}",
|
||||
"TYPE": block_type,
|
||||
},
|
||||
}
|
||||
|
||||
forest_product = OfficialVectorAcquisitionService._product("urbis_forest_parks")
|
||||
water_product = OfficialVectorAcquisitionService._product("urbis_water_surfaces")
|
||||
land_cover_product = OfficialVectorAcquisitionService._product("urbis_land_cover_blocks")
|
||||
|
||||
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||
forest_product, block("FO"), scope_metric, "brussels"
|
||||
) is not None
|
||||
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||
forest_product, block("CB"), scope_metric, "brussels"
|
||||
) is None
|
||||
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||
water_product, block("WB"), scope_metric, "brussels"
|
||||
) is not None
|
||||
assert OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||
water_product, block("GB"), scope_metric, "brussels"
|
||||
) is None
|
||||
normalized = OfficialVectorAcquisitionService._normalize_regional_feature(
|
||||
land_cover_product, block("CB"), scope_metric, "brussels"
|
||||
)
|
||||
assert normalized is not None
|
||||
assert normalized["properties"]["TYPE"] == "CB"
|
||||
assert normalized["properties"]["clipped_area_ha"] > 0
|
||||
|
||||
|
||||
def test_spw_arcgis_paging_is_bounded_stable_and_clipped() -> None:
|
||||
|
||||
@@ -404,8 +404,8 @@ def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbo
|
||||
|
||||
assert "Belgium and North Sea Workbench" in focus
|
||||
assert "nationalProject" in workspace_hook
|
||||
assert "data.areas.length > 0" in workspace_hook
|
||||
assert "dataset.status === 'ready'" in workspace_hook
|
||||
assert "return nationalProject.id" in workspace_hook
|
||||
assert "NATIONAL_WORKSPACE_REGION" in workspace_hook
|
||||
assert "externalApi.resolveCoverage" in coverage_hook
|
||||
assert "coverage.outside_supported_scope" in map_workspace
|
||||
assert "coverageStatusLabel" in map_workspace
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import AppError
|
||||
from app.models import AnalysisRun, Dataset, Job, Project
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.job_service import JobService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Minimal session double without rollback support, mirroring existing test doubles."""
|
||||
|
||||
def __init__(self, objects=None) -> None:
|
||||
self.objects = objects or {}
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
|
||||
def get(self, model, item_id):
|
||||
return self.objects.get((model, item_id))
|
||||
|
||||
def add(self, item) -> None:
|
||||
self.added.append(item)
|
||||
if getattr(item, "id", None) is not None:
|
||||
self.objects[(item.__class__, item.id)] = item
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def refresh(self, item) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _project_and_dataset():
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
project = Project(id=project_id, name="Mol")
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="ortho.tif",
|
||||
dataset_type="raster",
|
||||
source="user_upload",
|
||||
storage_path="storage/uploads/ortho.tif",
|
||||
)
|
||||
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
||||
return db, project_id, dataset_id
|
||||
|
||||
|
||||
def _statuses(db: FakeSession) -> tuple[list[str], list[str]]:
|
||||
runs = [item.status for item in db.added if isinstance(item, AnalysisRun)]
|
||||
jobs = [item.status for item in db.added if isinstance(item, Job)]
|
||||
return runs, jobs
|
||||
|
||||
|
||||
def test_invalid_fixture_detections_mark_run_and_job_failed() -> None:
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
DetectionService.run_detection(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="manual-fixture-detector",
|
||||
confidence_threshold=0.5,
|
||||
parameters_json={"fixture_mode": True, "fixture_detections": "not-a-list"},
|
||||
settings=Settings(_env_file=None),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "INVALID_FIXTURE_DETECTIONS"
|
||||
run_statuses, job_statuses = _statuses(db)
|
||||
assert run_statuses and all(status == "failed" for status in run_statuses)
|
||||
assert job_statuses and all(status == "failed" for status in job_statuses)
|
||||
|
||||
|
||||
def test_invalid_fixture_segmentations_mark_run_and_job_failed() -> None:
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="fixture-segmenter",
|
||||
confidence_threshold=0.5,
|
||||
parameters_json={"fixture_mode": True, "fixture_segmentations": "not-a-list"},
|
||||
settings=Settings(_env_file=None),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "INVALID_FIXTURE_SEGMENTATIONS"
|
||||
run_statuses, job_statuses = _statuses(db)
|
||||
assert run_statuses and all(status == "failed" for status in run_statuses)
|
||||
assert job_statuses and all(status == "failed" for status in job_statuses)
|
||||
|
||||
|
||||
def test_unexpected_error_in_sync_job_marks_job_failed() -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")})
|
||||
|
||||
def exploding_operation():
|
||||
raise RuntimeError("unexpected internal failure")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
JobService.run_sync_job(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
job_type="test.unexpected",
|
||||
parameters={},
|
||||
operation=exploding_operation,
|
||||
)
|
||||
|
||||
jobs = [item for item in db.added if isinstance(item, Job)]
|
||||
assert jobs
|
||||
final_job = jobs[-1]
|
||||
assert final_job.status == "failed"
|
||||
assert "Unexpected internal error" in (final_job.error_message or "")
|
||||
|
||||
|
||||
def test_app_error_in_sync_job_still_marks_job_failed() -> None:
|
||||
project_id = uuid4()
|
||||
db = FakeSession(objects={(Project, project_id): Project(id=project_id, name="Mol")})
|
||||
|
||||
def failing_operation():
|
||||
raise AppError(code="SOME_DOMAIN_ERROR", message="Bounded failure", status_code=422)
|
||||
|
||||
with pytest.raises(AppError):
|
||||
JobService.run_sync_job(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
job_type="test.bounded",
|
||||
parameters={},
|
||||
operation=failing_operation,
|
||||
)
|
||||
|
||||
jobs = [item for item in db.added if isinstance(item, Job)]
|
||||
assert jobs
|
||||
assert jobs[-1].status == "failed"
|
||||
assert jobs[-1].error_message == "Bounded failure"
|
||||
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models import Dataset, Project, Segmentation
|
||||
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, objects=None) -> None:
|
||||
self.objects = objects or {}
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
self.refreshes = []
|
||||
|
||||
def get(self, model, item_id):
|
||||
return self.objects.get((model, item_id))
|
||||
|
||||
def add(self, item) -> None:
|
||||
self.added.append(item)
|
||||
if getattr(item, "id", None) is not None:
|
||||
self.objects[(item.__class__, item.id)] = item
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def refresh(self, item) -> None:
|
||||
self.refreshes.append(item)
|
||||
|
||||
|
||||
class AvailableSegAdapter:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
@staticmethod
|
||||
def dependencies_available() -> bool:
|
||||
return True
|
||||
|
||||
def load_model(self, model_path: Path):
|
||||
return object()
|
||||
|
||||
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"class_name": "building",
|
||||
"confidence": 0.91,
|
||||
"points": [[10.0, 20.0], [30.0, 20.0], [30.0, 40.0], [10.0, 40.0]],
|
||||
"bbox": [10.0, 20.0, 30.0, 40.0],
|
||||
"properties": {"class_id": 0},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class ClassAgnosticSamAdapter(AvailableSegAdapter):
|
||||
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"class_name": "segment",
|
||||
"confidence": None,
|
||||
"points": [[5.0, 5.0], [25.0, 5.0], [25.0, 25.0], [5.0, 25.0]],
|
||||
"bbox": [5.0, 5.0, 25.0, 25.0],
|
||||
"properties": {"class_id": -1},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MissingDependencySegAdapter(AvailableSegAdapter):
|
||||
@staticmethod
|
||||
def dependencies_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _project_and_dataset(dataset_type: str = "raster"):
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
project = Project(id=project_id, name="Mol")
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="ortho.tif",
|
||||
dataset_type=dataset_type,
|
||||
source="user_upload",
|
||||
storage_path="storage/uploads/ortho.tif",
|
||||
)
|
||||
db = FakeSession(objects={(Project, project_id): project, (Dataset, dataset_id): dataset})
|
||||
return db, project_id, dataset_id
|
||||
|
||||
|
||||
def _settings(tmp_path: Path, **overrides) -> Settings:
|
||||
values = {
|
||||
"yolo_seg_enabled": True,
|
||||
"yolo_seg_model_path": str(tmp_path / "seg.pt"),
|
||||
"sam_enabled": True,
|
||||
"sam_model_path": str(tmp_path / "sam.pt"),
|
||||
"yolo_max_tiles": 4,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path, tile_count: int = 1) -> Path:
|
||||
tiles = []
|
||||
for index in range(tile_count):
|
||||
tile_path = tmp_path / f"tile_{index:04d}.tif"
|
||||
tile_path.write_bytes(b"fixture")
|
||||
tiles.append(
|
||||
{
|
||||
"path": str(tile_path),
|
||||
"pixel_window": [0, 0, 100, 100],
|
||||
"bounds": [4.0, 51.0, 5.0, 52.0],
|
||||
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
||||
"index": index,
|
||||
}
|
||||
)
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"tile_set_id": "tiles-fixture",
|
||||
"source_dataset_id": str(uuid4()),
|
||||
"source_raster_id": str(uuid4()),
|
||||
"tile_size": 100,
|
||||
"overlap": 0,
|
||||
"count": tile_count,
|
||||
"tiles": tiles,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest_path
|
||||
|
||||
|
||||
def test_segmentation_models_report_not_configured_when_disabled(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path, yolo_seg_enabled=False, sam_enabled=False)
|
||||
|
||||
models = {
|
||||
model.model_id: model
|
||||
for model in ModelRegistryService.list_segmentation_model_capabilities(settings=settings)
|
||||
}
|
||||
|
||||
assert models["yolo-seg-configured"].configured is False
|
||||
assert models["yolo-seg-configured"].status == "not_configured"
|
||||
assert models["sam-configured"].configured is False
|
||||
assert models["sam-configured"].status == "not_configured"
|
||||
|
||||
|
||||
def test_segmentation_models_report_dependency_unavailable(tmp_path: Path) -> None:
|
||||
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||
(tmp_path / "sam.pt").write_bytes(b"weights")
|
||||
settings = _settings(tmp_path)
|
||||
|
||||
models = {
|
||||
model.model_id: model
|
||||
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=MissingDependencySegAdapter,
|
||||
sam_adapter_class=MissingDependencySegAdapter,
|
||||
)
|
||||
}
|
||||
|
||||
assert models["yolo-seg-configured"].status == "dependency_unavailable"
|
||||
assert models["sam-configured"].status == "dependency_unavailable"
|
||||
|
||||
|
||||
def test_segmentation_models_report_configured_with_local_weights(tmp_path: Path) -> None:
|
||||
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||
(tmp_path / "sam.pt").write_bytes(b"weights")
|
||||
settings = _settings(tmp_path)
|
||||
|
||||
models = {
|
||||
model.model_id: model
|
||||
for model in ModelRegistryService.list_segmentation_model_capabilities(
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||
)
|
||||
}
|
||||
|
||||
assert models["yolo-seg-configured"].configured is True
|
||||
assert models["yolo-seg-configured"].status == "configured"
|
||||
assert models["sam-configured"].configured is True
|
||||
assert models["sam-configured"].status == "configured"
|
||||
|
||||
|
||||
def test_segmentation_dependency_check_uses_real_imports_not_find_spec() -> None:
|
||||
source = (ROOT / "backend" / "app" / "services" / "segmentation_adapter.py").read_text(encoding="utf-8")
|
||||
|
||||
assert 'find_spec("ultralytics")' not in source
|
||||
assert "import ultralytics" in source
|
||||
assert "import torch" in source
|
||||
|
||||
|
||||
def test_configured_segmentation_requires_tile_manifest(tmp_path: Path) -> None:
|
||||
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
settings = _settings(tmp_path)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="yolo-seg-configured",
|
||||
confidence_threshold=0.5,
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||
)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_TILE_MANIFEST_REQUIRED"
|
||||
|
||||
|
||||
def test_configured_yolo_seg_run_persists_georeferenced_masks(tmp_path: Path) -> None:
|
||||
(tmp_path / "seg.pt").write_bytes(b"weights")
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
settings = _settings(tmp_path)
|
||||
manifest_path = _manifest(tmp_path)
|
||||
|
||||
response = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="yolo-seg-configured",
|
||||
confidence_threshold=0.5,
|
||||
tile_manifest_path=str(manifest_path),
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||
)
|
||||
|
||||
assert response.status == "success"
|
||||
assert response.segmentation_count == 1
|
||||
persisted = [item for item in db.added if isinstance(item, Segmentation)]
|
||||
assert len(persisted) == 1
|
||||
segmentation = persisted[0]
|
||||
assert segmentation.class_name == "building"
|
||||
assert segmentation.confidence == pytest.approx(0.91)
|
||||
geometry = to_shape(segmentation.geometry)
|
||||
assert geometry.geom_type == "MultiPolygon"
|
||||
min_x, min_y, max_x, max_y = geometry.bounds
|
||||
assert 4.0 <= min_x <= 5.0
|
||||
assert 51.0 <= min_y <= 52.0
|
||||
assert max_x <= 5.0
|
||||
assert max_y <= 52.0
|
||||
assert segmentation.area_m2 is not None and segmentation.area_m2 > 0
|
||||
assert segmentation.provenance_json["inference"] == "local"
|
||||
assert segmentation.provenance_json["model_id"] == "yolo-seg-configured"
|
||||
|
||||
|
||||
def test_configured_sam_run_is_class_agnostic(tmp_path: Path) -> None:
|
||||
(tmp_path / "sam.pt").write_bytes(b"weights")
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
settings = _settings(tmp_path)
|
||||
manifest_path = _manifest(tmp_path)
|
||||
|
||||
response = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="sam-configured",
|
||||
confidence_threshold=0.5,
|
||||
tile_manifest_path=str(manifest_path),
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||
)
|
||||
|
||||
assert response.status == "success"
|
||||
assert response.segmentation_count == 1
|
||||
persisted = [item for item in db.added if isinstance(item, Segmentation)]
|
||||
assert persisted[0].class_name == "segment"
|
||||
assert persisted[0].confidence is None
|
||||
|
||||
|
||||
def test_unconfigured_segmentation_run_fails_closed(tmp_path: Path) -> None:
|
||||
db, project_id, dataset_id = _project_and_dataset()
|
||||
settings = _settings(tmp_path, yolo_seg_enabled=False)
|
||||
manifest_path = _manifest(tmp_path)
|
||||
|
||||
response = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
model_id="yolo-seg-configured",
|
||||
confidence_threshold=0.5,
|
||||
tile_manifest_path=str(manifest_path),
|
||||
settings=settings,
|
||||
yolo_seg_adapter_class=AvailableSegAdapter,
|
||||
sam_adapter_class=ClassAgnosticSamAdapter,
|
||||
)
|
||||
|
||||
assert response.status == "failed"
|
||||
assert response.error_code == "SEGMENTATION_MODEL_UNAVAILABLE"
|
||||
assert not [item for item in db.added if isinstance(item, Segmentation)]
|
||||
|
||||
|
||||
def test_pixel_points_to_epsg4326_polygon_uses_tile_transform() -> None:
|
||||
tile = {
|
||||
"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01],
|
||||
"bounds": [4.0, 51.0, 5.0, 52.0],
|
||||
"pixel_window": [0, 0, 100, 100],
|
||||
}
|
||||
|
||||
polygon = pixel_points_to_epsg4326_polygon(
|
||||
points=[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]],
|
||||
tile=tile,
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
min_x, min_y, max_x, max_y = polygon.bounds
|
||||
assert min_x == pytest.approx(4.0)
|
||||
assert max_x == pytest.approx(5.0)
|
||||
assert min_y == pytest.approx(51.0)
|
||||
assert max_y == pytest.approx(52.0)
|
||||
|
||||
|
||||
def test_pixel_points_to_epsg4326_polygon_rejects_degenerate_input() -> None:
|
||||
tile = {"transform": [4.0, 0.01, 0.0, 52.0, 0.0, -0.01]}
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
pixel_points_to_epsg4326_polygon(points=[[0.0, 0.0], [1.0, 1.0]], tile=tile)
|
||||
|
||||
assert getattr(exc_info.value, "code", None) == "SEGMENTATION_INVALID_MASK"
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_frontend_declares_mol_as_primary_operating_focus() -> None:
|
||||
def test_frontend_declares_national_scope_as_primary_operating_focus() -> None:
|
||||
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
@@ -23,19 +23,17 @@ def test_frontend_declares_mol_as_primary_operating_focus() -> None:
|
||||
/ "WorkbenchNavigation.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "PRIMARY_FOCUS_LABEL = 'Mol'" in focus
|
||||
assert "PRIMARY_FOCUS_REGION = 'Mol, Kempen'" in focus
|
||||
assert "[5.1167, 51.1919]" in focus
|
||||
assert "isPrimaryFocusProjectData" in focus
|
||||
assert "isPrimaryFocusProjectData(project, data.datasets)" in project_hook
|
||||
assert "NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'" in focus
|
||||
assert "NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'" in focus
|
||||
assert "NATIONAL_MAP_CENTER" in focus
|
||||
assert "return nationalProject.id" in project_hook
|
||||
assert "hasMappedAnalysisContext(data)" in project_hook
|
||||
assert "dataset.dataset_type === 'raster'" in project_hook
|
||||
assert "dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson'" in project_hook
|
||||
assert "const primaryContext = inspectedCandidates.find" in project_hook
|
||||
assert "PRIMARY_FOCUS_AREA_NAME" in project_hook
|
||||
assert "PRIMARY_FOCUS_AREA_GEOJSON" in project_hook
|
||||
assert "4.35,51.28" not in project_hook
|
||||
assert "center: PRIMARY_FOCUS_CENTER" in map_source
|
||||
assert "PRIMARY_FOCUS_AREA_NAME" not in project_hook
|
||||
assert "PRIMARY_FOCUS_AREA_GEOJSON" not in project_hook
|
||||
assert "center: NATIONAL_MAP_CENTER" in map_source
|
||||
assert "zoom: NATIONAL_MAP_ZOOM" in map_source
|
||||
assert "GeoIntel" in navigation
|
||||
assert "Atlas Workbench" in navigation
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_large_vector_persistence_flushes_once_without_per_feature_refresh() ->
|
||||
assert db.refreshes == 0
|
||||
|
||||
|
||||
def test_municipality_workspace_is_wired_into_runtime_and_frontend_priority() -> None:
|
||||
def test_municipality_workspace_remains_a_regression_fixture_without_frontend_priority() -> None:
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
focus = (ROOT / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
|
||||
@@ -154,7 +154,8 @@ def test_municipality_workspace_is_wired_into_runtime_and_frontend_priority() ->
|
||||
assert "py_compile scripts/provision_mol_municipality_workspace.py" in readiness
|
||||
assert "COPY scripts/provision_mol_municipality_workspace.py" in dockerfile
|
||||
assert "PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'" in focus
|
||||
assert "items.find(isPrimaryFocusMunicipalityProject)" in project_hook
|
||||
assert "items.find(isPrimaryFocusMunicipalityProject)" not in project_hook
|
||||
assert "return nationalProject.id" in project_hook
|
||||
assert "datasets.find(isPrimaryFocusMunicipalityBoundaryDataset)" in dataset_hook
|
||||
assert "featureCollectionBounds(featureCollection)" in map_source
|
||||
assert "useMemo(() => getFeatureCollectionBBox(mapFeatureCollection)" in map_workspace
|
||||
|
||||
@@ -8,13 +8,15 @@ def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_regional_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
|
||||
def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
|
||||
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
|
||||
map_workspace = read("frontend/src/components/map/MapWorkspace.tsx")
|
||||
|
||||
national_check = project_hook.index("const nationalProject")
|
||||
regional_check = project_hook.index("const regionalProject")
|
||||
municipality_check = project_hook.index("const municipalityProject")
|
||||
assert regional_check < municipality_check
|
||||
assert national_check < regional_check
|
||||
assert "return nationalProject.id" in project_hook
|
||||
assert "const municipalityProject" not in project_hook
|
||||
assert 'aria-label="Regio"' not in map_workspace
|
||||
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
|
||||
assert "Snel naar een gemeente (optioneel)" in map_workspace
|
||||
@@ -56,7 +58,8 @@ def test_configured_yolo_and_active_asset_are_selected_without_hiding_limitation
|
||||
assert "asset.active" in hook
|
||||
assert "getYoloPreflight" in hook
|
||||
assert 'aria-label="Status gebouwdetectie"' in lab
|
||||
assert "resultaten blijven controleplichtig" in lab
|
||||
assert "Nog niet nationaal gevalideerd" in lab
|
||||
assert "vereisen lokale referentiedata en QA" in lab
|
||||
assert "Modelkalibratie voor beheerders" in lab
|
||||
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ def test_end_user_dataset_sources_are_human_readable() -> None:
|
||||
assert "department_omgeving_land_use: 'Departement Omgeving'" in display
|
||||
assert "statbel: 'Statbel'" in display
|
||||
assert "getDatasetSourceDisplayName(activeThemeDataset)" in workspace
|
||||
assert "resultDataset ? getDatasetSourceDisplayName(resultDataset)" in workspace
|
||||
assert "getDatasetSourceDisplayName(resultDataset)" in workspace
|
||||
assert "Snel naar een gemeente (optioneel)" in workspace
|
||||
assert "latestDatasetBySeries" in catalog
|
||||
assert "Historische meetmomenten" in catalog
|
||||
|
||||
@@ -174,7 +174,10 @@ def test_bathymetry_source_registry_is_honest_and_nationally_extensible() -> Non
|
||||
assert by_key["vha_inland_profiles"]["integration_status"] == "operational"
|
||||
assert by_key["vha_inland_profiles"]["acquisition_supported"] is True
|
||||
assert by_key["mdk_bcp_bathymetry"]["vertical_reference"] == "LAT"
|
||||
assert by_key["mdk_bcp_bathymetry"]["acquisition_supported"] is False
|
||||
# Bounded MDK acquisition now exists but stays fail-closed until the
|
||||
# operator enables it explicitly with a live-validated coverage id.
|
||||
assert by_key["mdk_bcp_bathymetry"]["acquisition_supported"] is True
|
||||
assert by_key["mdk_bcp_bathymetry"]["configured"] is False
|
||||
assert by_key["spw_walloon_waterway_bathymetry"]["vertical_reference"] == "mDNG"
|
||||
assert by_key["spw_walloon_waterway_bathymetry"]["license_note"].startswith("CC BY 4.0")
|
||||
|
||||
|
||||
@@ -411,7 +411,9 @@ def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
|
||||
for item in BathymetryProfileAcquisitionService.list_sources()
|
||||
}
|
||||
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
|
||||
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False
|
||||
# Bounded acquisition is implemented but remains disabled by default.
|
||||
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is True
|
||||
assert sources["mdk_bcp_bathymetry"]["configured"] is False
|
||||
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,10 @@ def test_product_registries_expose_honest_forest_agriculture_nature_and_soil() -
|
||||
"spw_picc_water_surfaces",
|
||||
"urbis_buildings",
|
||||
"urbis_cadastral_parcels",
|
||||
"urbis_street_axes",
|
||||
"urbis_land_cover_blocks",
|
||||
"urbis_forest_parks",
|
||||
"urbis_water_surfaces",
|
||||
} == set(vector)
|
||||
assert vector["bwk_natura2000_2025"]["authority_level"] == "authoritative"
|
||||
assert vector["dov_soil_types"]["authority_level"] == "authoritative_historical_baseline"
|
||||
@@ -401,7 +405,7 @@ def test_official_vector_routes_and_frontend_use_canonical_backend_path(monkeypa
|
||||
|
||||
assert products_response.status_code == 200
|
||||
assert set(products_response.json()) == {"data"}
|
||||
assert products_response.json()["data"]["total"] == 8
|
||||
assert products_response.json()["data"]["total"] == 12
|
||||
assert acquire_response.status_code == 200
|
||||
assert set(acquire_response.json()) == {"data"}
|
||||
assert acquire_response.json()["data"]["job_type"] == "vector.official.acquire"
|
||||
|
||||
Reference in New Issue
Block a user