Add Gamma SBAS production workflow
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
"""Flood overlay and impact analysis service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from geoalchemy2.functions import ST_Intersects
|
||||
from geoalchemy2.shape import from_shape
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from shapely.geometry import mapping, shape
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from ..config import settings
|
||||
from ..models import FloodDetectionORM, FloodOverlayORM, HazardPointORM, ResultProductORM
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _hazard_point_to_dict(point: HazardPointORM, *, distance_m: float | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": point.id,
|
||||
"name": point.hazard_name,
|
||||
"type": point.hazard_type,
|
||||
"city": point.city,
|
||||
"county": point.county,
|
||||
"township": point.township,
|
||||
"longitude": _to_float(point.longitude),
|
||||
"latitude": _to_float(point.latitude),
|
||||
"distance_m": 0 if distance_m is None else round(float(distance_m), 2),
|
||||
}
|
||||
|
||||
|
||||
def _dinsar_product_to_dict(product: ResultProductORM) -> dict[str, Any]:
|
||||
summary = product.summary_json if isinstance(product.summary_json, dict) else {}
|
||||
deformation = (
|
||||
summary.get("deformation_mm")
|
||||
or summary.get("max_deformation_mm")
|
||||
or summary.get("mean_deformation_mm")
|
||||
or summary.get("deformation")
|
||||
)
|
||||
return {
|
||||
"id": product.id,
|
||||
"product_id": product.product_id,
|
||||
"display_name": product.display_name,
|
||||
"engine": product.engine_code,
|
||||
"status": product.status,
|
||||
"deformation_mm": deformation,
|
||||
"ai_score": product.ai_score,
|
||||
"manifest_path": product.manifest_path,
|
||||
"preview_path": product.preview_path,
|
||||
}
|
||||
|
||||
|
||||
def _open_raster(path: str):
|
||||
import rasterio
|
||||
|
||||
normalized_path = path.replace("\\", "/")
|
||||
try:
|
||||
return rasterio.open(normalized_path)
|
||||
except Exception:
|
||||
pass
|
||||
for ext in (".bin", ".img", ".tif", ".tiff"):
|
||||
candidate = normalized_path + ext
|
||||
try:
|
||||
return rasterio.open(candidate)
|
||||
except Exception:
|
||||
pass
|
||||
raise FileNotFoundError(f"Raster file cannot be opened: {path}")
|
||||
|
||||
|
||||
def _classified_flood_to_geojson(path: str) -> tuple[dict[str, Any], float | None, list[str]]:
|
||||
import rasterio.features
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.geometry import shape as shape_geojson
|
||||
from shapely.ops import transform
|
||||
|
||||
warnings: list[str] = []
|
||||
with _open_raster(path) as src:
|
||||
data = src.read(1)
|
||||
mask = data == 2
|
||||
if not mask.any():
|
||||
return {"type": "FeatureCollection", "features": []}, 0.0, warnings
|
||||
|
||||
polygons = []
|
||||
for geom, value in rasterio.features.shapes(data, mask=mask, transform=src.transform):
|
||||
if int(value) != 2:
|
||||
continue
|
||||
polygon = shape_geojson(geom)
|
||||
if not polygon.is_empty and polygon.is_valid:
|
||||
polygons.append(polygon)
|
||||
|
||||
if not polygons:
|
||||
return {"type": "FeatureCollection", "features": []}, 0.0, warnings
|
||||
|
||||
flood_geom = unary_union(polygons)
|
||||
source_crs = src.crs
|
||||
|
||||
area_km2 = None
|
||||
output_geom = flood_geom
|
||||
if source_crs:
|
||||
try:
|
||||
crs = CRS.from_user_input(source_crs)
|
||||
if not crs.is_geographic:
|
||||
area_km2 = float(flood_geom.area) / 1_000_000.0
|
||||
transformer = Transformer.from_crs(crs, CRS.from_epsg(4326), always_xy=True)
|
||||
output_geom = transform(transformer.transform, flood_geom)
|
||||
else:
|
||||
centroid = flood_geom.centroid
|
||||
zone = int((centroid.x + 180) // 6) + 1
|
||||
epsg = 32600 + zone if centroid.y >= 0 else 32700 + zone
|
||||
transformer = Transformer.from_crs(crs, CRS.from_epsg(epsg), always_xy=True)
|
||||
projected = transform(transformer.transform, flood_geom)
|
||||
area_km2 = float(projected.area) / 1_000_000.0
|
||||
except Exception as exc:
|
||||
warnings.append(f"area calculation failed: {exc}")
|
||||
else:
|
||||
warnings.append("classified raster has no CRS; geometry is stored in source coordinates")
|
||||
|
||||
feature_collection = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"class": 2, "name": "flood"},
|
||||
"geometry": mapping(output_geom),
|
||||
}
|
||||
],
|
||||
}
|
||||
return feature_collection, area_km2, warnings
|
||||
|
||||
|
||||
def _write_geojson(detection_id: int, feature_collection: dict[str, Any]) -> str:
|
||||
out_dir = Path(settings.WATER_RESULTS_DIR or Path(settings.BACKEND_DIR) / "water_results") / "flood_overlays"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = out_dir / f"flood_detection_{detection_id}_overlay.geojson"
|
||||
target.write_text(json.dumps(feature_collection, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return str(target)
|
||||
|
||||
|
||||
def _read_geojson(path: str | None) -> dict[str, Any] | None:
|
||||
if not path or not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as stream:
|
||||
payload = json.load(stream)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _geometry_area_km2(geom) -> float:
|
||||
if geom.is_empty:
|
||||
return 0.0
|
||||
try:
|
||||
from pyproj import CRS, Transformer
|
||||
from shapely.ops import transform
|
||||
|
||||
centroid = geom.centroid
|
||||
zone = int((centroid.x + 180) // 6) + 1
|
||||
epsg = 32600 + zone if centroid.y >= 0 else 32700 + zone
|
||||
transformer = Transformer.from_crs(CRS.from_epsg(4326), CRS.from_epsg(epsg), always_xy=True)
|
||||
projected = transform(transformer.transform, geom)
|
||||
return float(projected.area) / 1_000_000.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _calculate_affected_aois(flood_geom, warnings: list[str], *, limit: int = 50) -> list[dict[str, Any]]:
|
||||
try:
|
||||
from ..routers import dependencies as deps
|
||||
|
||||
deps._load_region_index()
|
||||
deps._load_region_geometry_index()
|
||||
region_by_id = deps._REGION_BY_ID_CACHE or {}
|
||||
geometry_by_id = deps._REGION_GEOMETRY_BY_ID_CACHE or {}
|
||||
except Exception as exc:
|
||||
warnings.append(f"AOI overlay unavailable: {exc}")
|
||||
return []
|
||||
|
||||
affected: list[dict[str, Any]] = []
|
||||
for tree_id, features in geometry_by_id.items():
|
||||
node = region_by_id.get(tree_id) or {}
|
||||
level = node.get("level")
|
||||
if level in {"country", "province"}:
|
||||
continue
|
||||
try:
|
||||
geometries = [shape(feature["geometry"]) for feature in features if feature.get("geometry")]
|
||||
if not geometries:
|
||||
continue
|
||||
region_geom = unary_union(geometries)
|
||||
if region_geom.is_empty or not flood_geom.intersects(region_geom):
|
||||
continue
|
||||
intersection = flood_geom.intersection(region_geom)
|
||||
area_km2 = _geometry_area_km2(intersection)
|
||||
if area_km2 <= 0.0001:
|
||||
continue
|
||||
affected.append(
|
||||
{
|
||||
"tree_id": tree_id,
|
||||
"name": node.get("name") or tree_id,
|
||||
"level": level,
|
||||
"flood_area_km2": round(area_km2, 4),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
affected.sort(key=lambda item: item["flood_area_km2"], reverse=True)
|
||||
return affected[:limit]
|
||||
|
||||
|
||||
def _attach_overlay_payload(overlay: FloodOverlayORM) -> dict[str, Any]:
|
||||
payload = dict(overlay.summary_json) if isinstance(overlay.summary_json, dict) else {}
|
||||
payload["overlay_id"] = overlay.id
|
||||
payload["detection_id"] = overlay.detection_id
|
||||
payload["flood_vector_path"] = overlay.flood_vector_path
|
||||
payload["flood_vector_geojson"] = _read_geojson(overlay.flood_vector_path)
|
||||
return payload
|
||||
|
||||
|
||||
async def run_overlay(detection_id: int, db: AsyncSession, *, near_threshold_m: float = 500.0) -> dict[str, Any]:
|
||||
detection = await db.get(FloodDetectionORM, detection_id)
|
||||
if not detection:
|
||||
raise HTTPException(status_code=404, detail=f"Flood detection id={detection_id} not found")
|
||||
if not detection.classified_path:
|
||||
raise HTTPException(status_code=400, detail="Flood detection has no classified raster")
|
||||
path = detection.classified_path.replace("\\", "/")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Classified raster file does not exist")
|
||||
|
||||
feature_collection, flood_area_km2, warnings = _classified_flood_to_geojson(path)
|
||||
flood_vector_path = _write_geojson(detection_id, feature_collection)
|
||||
impact = await _query_impact_from_geojson(
|
||||
detection_id=detection_id,
|
||||
feature_collection=feature_collection,
|
||||
db=db,
|
||||
near_threshold_m=near_threshold_m,
|
||||
warnings=warnings,
|
||||
)
|
||||
if flood_area_km2 is not None:
|
||||
impact["flood_area_km2"] = round(flood_area_km2, 4)
|
||||
impact["flood_vector_path"] = flood_vector_path
|
||||
|
||||
overlay = FloodOverlayORM(
|
||||
detection_id=detection_id,
|
||||
flood_vector_path=flood_vector_path,
|
||||
hazard_points_hit=len(impact["hazard_points"]["inside_flood"]),
|
||||
hazard_points_near=len(impact["hazard_points"]["near_flood"]),
|
||||
hazard_points_total=impact["hazard_points"]["total_in_scene"],
|
||||
dinsar_products_intersecting=len(impact["dinsar_products"]),
|
||||
affected_area_km2=impact.get("flood_area_km2"),
|
||||
summary_json=impact,
|
||||
)
|
||||
db.add(overlay)
|
||||
await db.flush()
|
||||
impact["overlay_id"] = overlay.id
|
||||
overlay.summary_json = impact
|
||||
if flood_area_km2 is not None:
|
||||
detection.flood_area_km2 = round(flood_area_km2, 4)
|
||||
await db.commit()
|
||||
await db.refresh(overlay)
|
||||
|
||||
return {
|
||||
"id": overlay.id,
|
||||
"detection_id": detection_id,
|
||||
"flood_vector_path": overlay.flood_vector_path,
|
||||
"flood_vector_geojson": feature_collection,
|
||||
"summary": _attach_overlay_payload(overlay),
|
||||
}
|
||||
|
||||
|
||||
async def get_overlay_result(detection_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
overlay = (
|
||||
await db.execute(
|
||||
select(FloodOverlayORM)
|
||||
.where(FloodOverlayORM.detection_id == detection_id)
|
||||
.order_by(FloodOverlayORM.id.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
if overlay and isinstance(overlay.summary_json, dict):
|
||||
return _attach_overlay_payload(overlay)
|
||||
|
||||
detection = await db.get(FloodDetectionORM, detection_id)
|
||||
if not detection:
|
||||
raise HTTPException(status_code=404, detail=f"Flood detection id={detection_id} not found")
|
||||
return {
|
||||
"detection_id": detection_id,
|
||||
"flood_area_km2": detection.flood_area_km2,
|
||||
"hazard_points": {"inside_flood": [], "near_flood": [], "total_in_scene": 0},
|
||||
"dinsar_products": [],
|
||||
"affected_aois": [],
|
||||
"flood_vector_path": None,
|
||||
"flood_vector_geojson": None,
|
||||
"warnings": ["overlay has not been run"],
|
||||
}
|
||||
|
||||
|
||||
async def _query_impact_from_geojson(
|
||||
*,
|
||||
detection_id: int,
|
||||
feature_collection: dict[str, Any],
|
||||
db: AsyncSession,
|
||||
near_threshold_m: float,
|
||||
warnings: list[str],
|
||||
) -> dict[str, Any]:
|
||||
features = feature_collection.get("features") or []
|
||||
if not features:
|
||||
return {
|
||||
"detection_id": detection_id,
|
||||
"flood_area_km2": 0.0,
|
||||
"hazard_points": {"inside_flood": [], "near_flood": [], "total_in_scene": 0},
|
||||
"dinsar_products": [],
|
||||
"affected_aois": [],
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
flood_geom = unary_union([shape(feature["geometry"]) for feature in features if feature.get("geometry")])
|
||||
if flood_geom.is_empty:
|
||||
warnings.append("flood geometry is empty")
|
||||
flood_wkt = flood_geom.wkt
|
||||
area_geom = func.ST_GeomFromText(flood_wkt, 4326)
|
||||
area_geog = func.Geography(area_geom)
|
||||
|
||||
inside_points: list[dict[str, Any]] = []
|
||||
near_points: list[dict[str, Any]] = []
|
||||
dinsar_products: list[dict[str, Any]] = []
|
||||
affected_aois = _calculate_affected_aois(flood_geom, warnings)
|
||||
|
||||
try:
|
||||
inside_rows = (
|
||||
await db.execute(
|
||||
select(HazardPointORM).where(ST_Intersects(HazardPointORM.geom, area_geom))
|
||||
)
|
||||
).scalars().all()
|
||||
inside_ids = {point.id for point in inside_rows}
|
||||
inside_points = [_hazard_point_to_dict(point, distance_m=0) for point in inside_rows]
|
||||
|
||||
near_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
HazardPointORM,
|
||||
func.ST_Distance(func.Geography(HazardPointORM.geom), area_geog).label("distance_m"),
|
||||
).where(func.ST_DWithin(func.Geography(HazardPointORM.geom), area_geog, near_threshold_m))
|
||||
)
|
||||
).all()
|
||||
for point, distance_m in near_rows:
|
||||
if point.id in inside_ids:
|
||||
continue
|
||||
near_points.append(_hazard_point_to_dict(point, distance_m=distance_m))
|
||||
except Exception as exc:
|
||||
warnings.append(f"hazard point overlay unavailable: {exc}")
|
||||
|
||||
try:
|
||||
products = (
|
||||
await db.execute(
|
||||
select(ResultProductORM).where(
|
||||
ResultProductORM.catalog_name == "dinsar",
|
||||
ResultProductORM.status == "READY",
|
||||
ST_Intersects(ResultProductORM.geom, area_geom),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
dinsar_products = [_dinsar_product_to_dict(product) for product in products]
|
||||
except Exception as exc:
|
||||
warnings.append(f"dinsar product overlay unavailable: {exc}")
|
||||
|
||||
return {
|
||||
"detection_id": detection_id,
|
||||
"flood_area_km2": None,
|
||||
"hazard_points": {
|
||||
"inside_flood": inside_points,
|
||||
"near_flood": near_points,
|
||||
"total_in_scene": len(inside_points) + len(near_points),
|
||||
},
|
||||
"dinsar_products": dinsar_products,
|
||||
"affected_aois": affected_aois,
|
||||
"warnings": warnings,
|
||||
}
|
||||
Reference in New Issue
Block a user