fix(map): make area analysis scale-aware
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-21 21:59:56 +02:00
parent 80631607f7
commit 20829f1a24
20 changed files with 619 additions and 56 deletions
@@ -0,0 +1,87 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.db.session import get_db
from app.models import Area, Dataset
from app.schemas.common import Envelope
from app.schemas.operations import VectorSelectionResponse
from app.schemas.selection_partitions import VectorPartitionSelectionRequest
from app.services.vector_feature_service import VectorFeatureService
from app.utils.response import envelope
router = APIRouter(prefix="/projects/{project_id}", tags=["selection-partitions"])
def _product_identity(dataset: Dataset) -> str:
metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
return str(metadata.get("product_key") or dataset.reference_layer_name or "")
@router.post(
"/datasets/vector/partitions/select",
response_model=Envelope[VectorSelectionResponse],
)
def select_vector_partitions(
project_id: UUID,
payload: VectorPartitionSelectionRequest,
db: Session = Depends(get_db),
):
datasets = db.query(Dataset).filter(Dataset.id.in_(payload.dataset_ids)).all()
by_id = {dataset.id: dataset for dataset in datasets}
ordered = [by_id.get(dataset_id) for dataset_id in payload.dataset_ids]
if any(dataset is None or dataset.project_id != project_id for dataset in ordered):
raise AppError(code="DATASET_NOT_FOUND", message="One or more selection partitions were not found", status_code=404)
typed_datasets = [dataset for dataset in ordered if dataset is not None]
if any(dataset.dataset_type not in {"vector", "geojson"} or dataset.status != "ready" for dataset in typed_datasets):
raise AppError(
code="INVALID_VECTOR_PARTITIONS",
message="Every selection partition must be a ready vector dataset",
status_code=409,
)
source_names = {dataset.source_name for dataset in typed_datasets}
product_keys = {_product_identity(dataset) for dataset in typed_datasets}
if len(source_names) != 1 or len(product_keys) != 1:
raise AppError(
code="VECTOR_PARTITION_SOURCE_MISMATCH",
message="Selection partitions must belong to one governed source product",
details={"source_names": sorted(str(value) for value in source_names), "product_keys": sorted(product_keys)},
status_code=409,
)
selection_geometry = None
selection_area_id = None
if payload.area_id is not None:
selection_area = db.get(Area, payload.area_id)
if selection_area is None or selection_area.project_id != project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
selection_area_id = selection_area.id
representative = typed_datasets[0]
dataset_ids = [dataset.id for dataset in typed_datasets]
result = VectorFeatureService.select_features_by_bbox(
db,
dataset_id=representative.id,
dataset_ids=dataset_ids,
bbox=payload.bbox.model_dump(),
limit=payload.limit,
dataset=representative,
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
deduplicate_source_features=True,
)
result.update(
partition_count=len(dataset_ids),
source_name=representative.source_name,
dataset_ids=dataset_ids,
)
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
+2 -1
View File
@@ -11,7 +11,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, temporal
from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal
from app.core.config import get_settings
from app.core.errors import AppError
from app.core.logging import configure_logging
@@ -91,6 +91,7 @@ def create_app() -> FastAPI:
app.include_router(qa.router, prefix=settings.api_prefix)
app.include_router(detection.router, prefix=settings.api_prefix)
app.include_router(segmentation.router, prefix=settings.api_prefix)
app.include_router(selection_partitions.router, prefix=settings.api_prefix)
app.include_router(temporal.router, prefix=settings.api_prefix)
app.include_router(assistant.router, prefix=settings.api_prefix)
+1
View File
@@ -58,6 +58,7 @@ class TerrainSelectionRequest(BaseModel):
class TerrainPartitionSelectionRequest(TerrainSelectionRequest):
product_key: str = "dtm_1m"
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=16)
class TerrainMetric(BaseModel):
+1
View File
@@ -61,6 +61,7 @@ class FloodHazardSelectionRequest(BaseModel):
class FloodHazardPartitionSelectionRequest(FloodHazardSelectionRequest):
product_key: str = "pluviaal_current_t100"
dataset_ids: list[UUID] | None = Field(default=None, min_length=1, max_length=16)
class FloodHazardMetric(BaseModel):
@@ -0,0 +1,14 @@
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, Field
from .operations import VectorSelectionBBox
class VectorPartitionSelectionRequest(BaseModel):
dataset_ids: list[UUID] = Field(min_length=1, max_length=16)
bbox: VectorSelectionBBox
area_id: UUID | None = None
limit: int = Field(default=1000, ge=1, le=1000)
@@ -225,6 +225,7 @@ class FloodHazardAnalysisService:
selection_geometry_4326=selection_4326,
nodata=FloodHazardAcquisitionService.NODATA,
max_pixels=resolved_settings.flood_hazard_max_pixels,
dataset_ids=payload.dataset_ids,
)
try:
import numpy as np
@@ -51,17 +51,17 @@ class RasterPartitionAnalysisService:
source_name: str,
product_key: str,
bbox: tuple[float, float, float, float],
dataset_ids: list[UUID] | None = None,
) -> list[Dataset]:
rows = (
db.query(Dataset)
.filter(
Dataset.project_id == project_id,
Dataset.source_name == source_name,
Dataset.dataset_type == "raster",
Dataset.status == "ready",
)
.all()
query = db.query(Dataset).filter(
Dataset.project_id == project_id,
Dataset.source_name == source_name,
Dataset.dataset_type == "raster",
Dataset.status == "ready",
)
if dataset_ids is not None:
query = query.filter(Dataset.id.in_(dataset_ids))
rows = query.all()
candidates = [
dataset
for dataset in rows
@@ -78,6 +78,13 @@ class RasterPartitionAnalysisService:
details={"source_name": source_name, "product_key": product_key},
status_code=404,
)
if dataset_ids is not None and {dataset.id for dataset in candidates} != set(dataset_ids):
raise AppError(
code="RASTER_PARTITION_SOURCE_MISMATCH",
message="Every requested raster partition must match the governed source product and selection",
details={"requested_count": len(dataset_ids), "eligible_count": len(candidates)},
status_code=409,
)
if len(candidates) > RasterPartitionAnalysisService.MAX_PARTITIONS:
raise AppError(
code="RASTER_PARTITION_LIMIT_EXCEEDED",
@@ -100,6 +107,7 @@ class RasterPartitionAnalysisService:
selection_geometry_4326,
nodata: float,
max_pixels: int,
dataset_ids: list[UUID] | None = None,
) -> RasterPartitionSelection:
try:
import numpy as np
@@ -120,6 +128,7 @@ class RasterPartitionAnalysisService:
source_name=source_name,
product_key=product_key,
bbox=bbox,
dataset_ids=dataset_ids,
)
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
selection_metric = shapely_transform(transformer.transform, selection_geometry_4326)
@@ -235,6 +235,7 @@ class TerrainAnalysisService:
selection_geometry_4326=selection_4326,
nodata=DhmvAcquisitionService.NODATA,
max_pixels=resolved_settings.dhmv_max_pixels,
dataset_ids=payload.dataset_ids,
)
surface_models = {
str((dataset.source_metadata or {}).get("surface_model") or "")
+12 -3
View File
@@ -11,7 +11,7 @@ from geoalchemy2.shape import to_shape
from shapely.geometry import box, mapping, shape
from shapely.ops import transform as transform_geometry
from shapely.validation import make_valid
from sqlalchemy import Float, cast, func
from sqlalchemy import Float, String, cast, func
from app.core.errors import AppError
from app.models import Dataset, VectorFeature
@@ -548,6 +548,8 @@ class VectorFeatureService:
selection_area_id: UUID | None = None,
full_dataset_area: bool = False,
preclipped_partition_filter: tuple[str, str] | None = None,
dataset_ids: list[UUID] | None = None,
deduplicate_source_features: bool = False,
) -> dict[str, Any]:
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
safe_limit = max(1, min(int(limit), 1000))
@@ -561,13 +563,19 @@ class VectorFeatureService:
4326,
)
query = db.query(VectorFeature).filter(VectorFeature.dataset_id == dataset_id)
selected_dataset_ids = dataset_ids or [dataset_id]
query = db.query(VectorFeature).filter(VectorFeature.dataset_id.in_(selected_dataset_ids))
if preclipped_partition_filter is not None:
partition_property, partition_value = preclipped_partition_filter
query = query.filter(VectorFeature.properties_json.op("->>")(partition_property) == partition_value)
if not full_dataset_area:
query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape))
if hasattr(query, "count"):
if deduplicate_source_features:
identity = func.coalesce(VectorFeature.source_feature_id, cast(VectorFeature.id, String))
total_feature_count = int(
query.with_entities(func.count(func.distinct(identity))).scalar() or 0
)
elif hasattr(query, "count"):
total_feature_count = int(query.count())
else: # Lightweight unit-test sessions do not always implement Query.count().
total_feature_count = len(query.all())
@@ -585,6 +593,7 @@ class VectorFeatureService:
summary = VectorFeatureService.summarize_features_by_bbox(
db,
dataset=dataset,
dataset_ids=selected_dataset_ids,
bbox=normalized_bbox,
total_feature_count=total_feature_count,
selection_geometry=selection_geometry,