Files
insar-management-system-v2/backend/app/services/flood_overlay_service.py
T

390 lines
14 KiB
Python

"""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,
}