Complete operational map result workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 12:35:50 +02:00
parent 8266929b2e
commit a29c787238
33 changed files with 1121 additions and 107 deletions
+203 -2
View File
@@ -11,20 +11,199 @@ from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck
from app.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
from app.schemas.dhmv import TerrainPartitionSelectionRequest, TerrainSelectionRequest
from app.schemas.export import (
ExportContentResponse,
ExportCreateResponse,
ExportListResponse,
ExportRead,
MapResultExportRequest,
)
from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest
from app.schemas.temporal import TemporalComparisonRequest
from app.schemas.thematic_raster import ThematicRasterSelectionRequest
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.segmentation_service import SegmentationService
from app.services.storage_service import StorageService
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.terrain_analysis_service import TerrainAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.services.vector_feature_service import VectorFeatureService
class ExportService:
@staticmethod
def export_map_result(
db: Session,
payload: MapResultExportRequest,
) -> ExportCreateResponse:
if payload.mode == "evolution":
comparison = TemporalAnalysisService.compare(
db,
project_id=payload.project_id,
payload=TemporalComparisonRequest(
earlier_dataset_id=payload.earlier_dataset_id,
later_dataset_id=payload.later_dataset_id,
bbox=payload.bbox,
area_id=payload.area_id,
),
)
content = comparison.model_dump(mode="json")
target_id = str(payload.later_dataset_id)
filename = ExportService._filename(
payload.name,
f"{target_id}-evolution.json",
".json",
)
export_path = StorageService.dataset_export_path(
str(payload.project_id),
target_id,
filename,
)
metadata = {
"source": "map_evolution",
"project_id": str(payload.project_id),
"earlier_dataset_id": str(payload.earlier_dataset_id),
"later_dataset_id": str(payload.later_dataset_id),
"selection_bbox": payload.bbox.model_dump(mode="json"),
"selection_area_id": str(payload.area_id) if payload.area_id else None,
"theme_id": payload.theme_id,
"server_recomputed": True,
}
export = ExportService._write_json_export(
db,
project_id=payload.project_id,
analysis_run_id=None,
export_type="map_evolution_json",
storage_path=export_path,
content=content,
metadata=metadata,
)
return ExportService._create_response(export)
dataset = db.get(Dataset, payload.dataset_id)
if not dataset or dataset.project_id != payload.project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type in DatasetService.VECTOR_TYPES:
return ExportService.export_vector_selection_geojson(
db,
dataset.id,
payload.bbox.model_dump(mode="json"),
area_id=payload.area_id,
name=payload.name,
limit=1000,
)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Map-result export requires a vector or governed raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
if dataset.source_name == "digitaal_vlaanderen_dhmv":
result = (
TerrainAnalysisService.analyze_partitions(
db,
payload.project_id,
TerrainPartitionSelectionRequest(
bbox=payload.bbox,
area_id=payload.area_id,
product_key=payload.product_key or "dtm_1m",
),
)
if payload.partitioned
else TerrainAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
TerrainSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
)
elif dataset.source_name == "vmm_flood_hazard":
result = (
FloodHazardAnalysisService.analyze_partitions(
db,
payload.project_id,
FloodHazardPartitionSelectionRequest(
bbox=payload.bbox,
area_id=payload.area_id,
product_key=payload.product_key or "pluviaal_current_t100",
),
)
if payload.partitioned
else FloodHazardAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
FloodHazardSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
)
elif dataset.source_name == "department_omgeving_thematic_raster":
result = ThematicRasterAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
ThematicRasterSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
else:
raise AppError(
code="MAP_RESULT_EXPORT_UNSUPPORTED",
message="This raster source does not expose a governed map-result export",
details={"source_name": dataset.source_name},
status_code=400,
)
content = {
"mode": "current",
"theme_id": payload.theme_id,
"dataset": {
"id": str(dataset.id),
"name": dataset.name,
"source_name": dataset.source_name,
},
"result": result,
}
filename = ExportService._filename(
payload.name,
f"{dataset.id}-map-analysis.json",
".json",
)
export_path = StorageService.dataset_export_path(
str(payload.project_id),
str(dataset.id),
filename,
)
metadata = {
"source": "map_analysis",
"project_id": str(payload.project_id),
"dataset_id": str(dataset.id),
"selection_bbox": payload.bbox.model_dump(mode="json"),
"selection_area_id": str(payload.area_id) if payload.area_id else None,
"theme_id": payload.theme_id,
"partitioned": payload.partitioned,
"product_key": payload.product_key,
"server_recomputed": True,
}
export = ExportService._write_json_export(
db,
project_id=payload.project_id,
analysis_run_id=None,
export_type="map_analysis_json",
storage_path=export_path,
content=content,
metadata=metadata,
)
return ExportService._create_response(export)
@staticmethod
def export_vector_selection_geojson(
db: Session,
dataset_id: uuid.UUID,
bbox: dict[str, Any],
area_id: uuid.UUID | None = None,
limit: int = 250,
name: str | None = None,
) -> ExportCreateResponse:
@@ -39,7 +218,28 @@ class ExportService:
status_code=400,
)
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
selection_kwargs: dict[str, Any] = {
"dataset_id": dataset_id,
"bbox": bbox,
"limit": limit,
}
if area_id is not None:
area = db.get(Area, area_id)
if not area or area.project_id != dataset.project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, area.id)
preclipped_partition_filter = (
None
if full_dataset_area
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None))
)
selection_kwargs.update(
selection_geometry=area.geometry,
selection_area_id=area.id,
full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter,
)
selection = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson")
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
metadata = {
@@ -48,6 +248,7 @@ class ExportService:
"dataset_id": str(dataset.id),
"dataset_type": dataset.dataset_type,
"selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"feature_count": selection["feature_count"],
"limit": selection["limit"],
"truncated": selection["truncated"],
+10 -2
View File
@@ -11,8 +11,16 @@ from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
class ProjectService:
@staticmethod
def list_projects(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted").order_by(Project.created_at.desc())
def list_projects(
db: Session,
limit: int = 50,
offset: int = 0,
name: str | None = None,
) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted")
if name:
query = query.filter(Project.name == name.strip())
query = query.order_by(Project.created_at.desc())
total = query.count()
items = query.offset(offset).limit(limit).all()
return [ProjectRead.model_validate(item) for item in items], total