Files
insar-management-system-v2/backend/app/routers/landsar_lt1_production.py
T

486 lines
20 KiB
Python

from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM, RadarDataORM, SARSceneGeoORM
from ..services.job_handlers import JOB_TYPE_SAR_SCENE_PREPROCESS
from ..services.job_queue_service import job_queue_service
from ..services.landsar_lt1_production_service import landsar_lt1_production_service
from ..services.task_service import task_service
from ..utils import normalize_satellite_family
from .dependencies import _add_operation_audit_log, _get_current_user, _require_admin
router = APIRouter()
STATIC_ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
class LandsarLt1ImageProductionRequest(BaseModel):
source_asset_ids: List[int] = Field(default_factory=list)
radar_data_ids: List[int] = Field(default_factory=list)
mode: str = "scene"
task_name: Optional[str] = None
@field_validator("mode")
@classmethod
def _validate_mode(cls, value):
mode = str(value or "scene").strip().lower()
if mode == "stack":
mode = "batch"
if mode not in {"scene", "batch"}:
raise ValueError("mode must be scene or batch")
return mode
def _dedupe_positive_ids(values: List[int]) -> List[int]:
result: List[int] = []
for value in values or []:
try:
parsed = int(value)
except (TypeError, ValueError):
continue
if parsed > 0 and parsed not in result:
result.append(parsed)
return result
def _scene_product_marker(scene: SARSceneGeoORM) -> Dict[str, Any]:
return {
"scene_id": scene.id,
"radar_data_id": scene.radar_data_id,
"product_id": f"sar_scene_geo:{scene.id}",
"product_family": "lt1_analysis_ready_geotiff",
"engine_code": scene.analysis_engine,
"profile_code": scene.analysis_profile,
"analysis_tif_path": scene.analysis_tif_path,
"analysis_dir": scene.analysis_dir,
"analysis_preview_path": scene.analysis_preview_path,
"status": scene.status,
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
}
def _scene_asset_items(scene: SARSceneGeoORM) -> List[Dict[str, Any]]:
candidates = [
(1, "analysis_tif", "analysis_ready.tif", scene.analysis_tif_path, "image/tiff", True),
(2, "preview", "preview.png", scene.analysis_preview_path, "image/png", False),
]
metadata = scene.analysis_metadata_json if isinstance(scene.analysis_metadata_json, dict) else {}
manifest_path = str(metadata.get("manifest_path") or "").strip()
if manifest_path:
candidates.append((3, "manifest", "manifest.json", manifest_path, "application/json", False))
if scene.analysis_dir:
quality_path = os.path.join(scene.analysis_dir, "quality.json")
candidates.append((4, "quality", "quality.json", quality_path, "application/json", False))
assets: List[Dict[str, Any]] = []
for asset_id, role, name, path, media_type, primary in candidates:
if not path:
continue
assets.append(
{
"id": asset_id,
"role": role,
"name": name,
"relative_path": os.path.basename(path),
"absolute_path": path,
"format": os.path.splitext(path)[1].lower().lstrip(".") or None,
"media_type": media_type,
"is_required": primary,
"is_primary": primary,
"exists": os.path.isfile(path),
"file_size": os.path.getsize(path) if os.path.isfile(path) else None,
}
)
return assets
async def _resolve_lt1_radars_for_request(
db: AsyncSession,
request: LandsarLt1ImageProductionRequest,
) -> List[RadarDataORM]:
source_asset_ids = _dedupe_positive_ids(request.source_asset_ids)
radar_data_ids = _dedupe_positive_ids(request.radar_data_ids)
filters = []
if radar_data_ids:
filters.append(RadarDataORM.id.in_(radar_data_ids))
if source_asset_ids:
filters.append(RadarDataORM.source_product_ref_id.in_(source_asset_ids))
if not filters:
return []
result = await db.execute(select(RadarDataORM).where(*([filters[0]] if len(filters) == 1 else [filters[0] | filters[1]])))
radars = list(result.scalars().all())
unique: Dict[int, RadarDataORM] = {}
for radar in radars:
if not radar.id:
continue
family = normalize_satellite_family(radar.satellite_family or radar.satellite)
if str(family or "").upper() != "LT1":
continue
unique[int(radar.id)] = radar
return [unique[key] for key in sorted(unique.keys())]
async def _produced_radars_for_request(
db: AsyncSession,
request: LandsarLt1ImageProductionRequest,
) -> Dict[int, dict]:
radars = await _resolve_lt1_radars_for_request(db, request)
radar_ids = [int(item.id) for item in radars if item.id]
if not radar_ids:
return {}
result = await db.execute(
select(SARSceneGeoORM).where(
SARSceneGeoORM.radar_data_id.in_(radar_ids),
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
SARSceneGeoORM.analysis_engine == "lt_gamma",
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
)
)
return {int(scene.radar_data_id): _scene_product_marker(scene) for scene in result.scalars().all()}
async def _active_radars_for_request(
db: AsyncSession,
request: LandsarLt1ImageProductionRequest,
) -> Dict[int, dict]:
radars = await _resolve_lt1_radars_for_request(db, request)
radar_ids = [int(item.id) for item in radars if item.id]
if not radar_ids:
return {}
result = await db.execute(
select(SARSceneGeoORM).where(
SARSceneGeoORM.radar_data_id.in_(radar_ids),
SARSceneGeoORM.status.in_(["PENDING", "RUNNING"]),
)
)
return {int(scene.radar_data_id): _scene_product_marker(scene) for scene in result.scalars().all()}
def _already_produced_blocker(produced: Dict[int, dict]) -> str:
first_id = sorted(produced.keys())[0]
marker = produced[first_id] or {}
product_id = marker.get("product_id") or "unknown"
return f"Radar data {first_id} already has an analysis-ready GeoTIFF: {product_id}"
def _active_blocker(active: Dict[int, dict]) -> str:
first_id = sorted(active.keys())[0]
marker = active[first_id] or {}
return f"Radar data {first_id} already has an active GeoTIFF production task (scene_id={marker.get('scene_id')})."
@router.get("/landsar-lt1-production/capabilities")
async def get_landsar_lt1_capabilities(
current_user: AuthUserORM = Depends(_get_current_user),
):
_ = current_user
legacy = landsar_lt1_production_service.check_capabilities()
return {
"catalog_name": "sar_scene_geo",
"supported_profiles": ["lt1_gamma_geocoded_mli"],
"engine": "lt_gamma",
"available": True,
"status": "configured",
"message": "LT-1 image production uses the existing Gamma single-scene pipeline: multilook, geocode, and analysis-ready GeoTIFF registration.",
"legacy_landsar_import": legacy,
}
@router.post("/landsar-lt1-production/preview")
async def preview_landsar_lt1_production(
request: LandsarLt1ImageProductionRequest,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
blockers: List[str] = []
warnings: List[str] = []
radars = await _resolve_lt1_radars_for_request(db, request)
if not radars:
blockers.append("No LT-1 radar records were resolved from the selected source assets.")
if request.mode == "scene" and len(radars) != 1:
blockers.append("Scene mode requires exactly one LT-1 source asset.")
if request.mode == "batch" and len(radars) < 1:
blockers.append("Batch mode requires at least one LT-1 source asset.")
produced = await _produced_radars_for_request(db, request)
if produced:
blockers.append(_already_produced_blocker(produced))
active = await _active_radars_for_request(db, request)
if active:
blockers.append(_active_blocker(active))
if request.mode == "batch":
warnings.append("Batch mode submits one independent geocoded GeoTIFF task per scene; it does not build a D-InSAR stack.")
preview = {
"allow_submit": not blockers,
"blockers": blockers,
"warnings": warnings,
"mode": request.mode,
"profile_code": "lt1_gamma_geocoded_mli",
"engine": "lt_gamma",
"scene_count": len(radars),
"source_asset_count": len(_dedupe_positive_ids(request.source_asset_ids)),
"radar_data_count": len(radars),
"produced_radars": produced,
"active_radars": active,
"scenes": [
{
"radar_data_id": radar.id,
"source_asset_id": radar.source_product_ref_id,
"satellite": radar.satellite,
"imaging_date": radar.imaging_date,
"imaging_mode": radar.imaging_mode,
"polarization": radar.polarization,
"file_path": radar.file_path,
}
for radar in radars
],
}
return preview
@router.post("/landsar-lt1-production/run", status_code=202)
async def queue_landsar_lt1_production(
request: LandsarLt1ImageProductionRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
_ = admin_user
preview = await preview_landsar_lt1_production(request, current_user=admin_user, db=db)
if preview.get("blockers"):
raise HTTPException(status_code=400, detail={"blockers": preview.get("blockers")})
queued: List[Dict[str, Any]] = []
radars = await _resolve_lt1_radars_for_request(db, request)
for radar in radars:
result = await db.execute(
select(SARSceneGeoORM)
.where(SARSceneGeoORM.radar_data_id == int(radar.id))
.with_for_update(skip_locked=True)
)
scene = result.scalar_one_or_none()
if scene and scene.status in ("PENDING", "RUNNING"):
raise HTTPException(status_code=409, detail=f"Radar data {radar.id} already has an active GeoTIFF production task.")
if scene and scene.status == "DONE" and scene.analysis_tif_path:
raise HTTPException(status_code=409, detail=f"Radar data {radar.id} already has an analysis-ready GeoTIFF.")
if not scene:
scene = SARSceneGeoORM(radar_data_id=int(radar.id), status="PENDING")
db.add(scene)
await db.flush()
else:
scene.status = "PENDING"
scene.error_msg = None
await db.flush()
scene_id = int(scene.id)
await db.commit()
payload = {
"scene_id": scene_id,
"radar_data_id": int(radar.id),
"engine": "lt_gamma",
"source_asset_id": radar.source_product_ref_id,
"requested_from": "landsar_lt1_production",
}
task_label = request.task_name or radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}"
task_type = f"LT1_SCENE_GEOTIFF_{scene_id}"
try:
task_id = await task_service.create_task(
task_type,
f"LT-1 geocoded GeoTIFF: {task_label}",
params=payload,
)
job_id = await job_queue_service.create_job(
JOB_TYPE_SAR_SCENE_PREPROCESS,
payload=payload,
task_id=task_id,
max_attempts=3,
)
except Exception as exc:
failed_scene = await db.get(SARSceneGeoORM, scene_id)
if failed_scene and failed_scene.status == "PENDING":
failed_scene.status = "FAILED"
failed_scene.error_msg = "Job queue failed"
await db.commit()
raise HTTPException(status_code=409 if "conflict" in str(exc).lower() else 400, detail=str(exc)) from exc
queued.append(
{
"task_id": task_id,
"job_id": job_id,
"scene_id": scene_id,
"radar_data_id": int(radar.id),
"source_asset_id": radar.source_product_ref_id,
}
)
await _add_operation_audit_log(
db,
request=http_request,
action="lt1_geotiff_production_queued",
resource="landsar-lt1-production/run",
detail={
"queued": queued,
"mode": request.mode,
"scene_count": len(queued),
},
)
await db.commit()
return {
"message": "LT-1 geocoded GeoTIFF production job queued.",
"task_id": queued[0]["task_id"] if len(queued) == 1 else None,
"job_id": queued[0]["job_id"] if len(queued) == 1 else None,
"queued": queued,
"preview": preview,
}
@router.get("/landsar-lt1-production/products")
async def list_landsar_lt1_products(
limit: int = 100,
offset: int = 0,
status: Optional[str] = None,
query: Optional[str] = None,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
safe_limit = max(1, min(500, int(limit or 100)))
safe_offset = max(0, int(offset or 0))
filters = [
SARSceneGeoORM.analysis_engine == "lt_gamma",
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
]
if status:
filters.append(SARSceneGeoORM.status == str(status).strip().upper())
if query:
like = f"%{str(query).strip()}%"
filters.append(RadarDataORM.product_unique_id.ilike(like) | RadarDataORM.unique_id.ilike(like) | RadarDataORM.file_path.ilike(like))
total_result = await db.execute(
select(func.count(SARSceneGeoORM.id))
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
)
total = int(total_result.scalar_one() or 0)
result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
.order_by(SARSceneGeoORM.updated_at.desc().nullslast(), SARSceneGeoORM.id.desc())
.limit(safe_limit)
.offset(safe_offset)
)
items = []
for scene, radar in result.all():
marker = _scene_product_marker(scene)
items.append(
{
"id": scene.id,
"product_id": marker["product_id"],
"catalog_name": "sar_scene_geo",
"product_family": "lt1_analysis_ready_geotiff",
"product_type": "analysis_ready_geotiff",
"display_name": radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}",
"task_name": "",
"profile_code": scene.analysis_profile,
"engine_code": scene.analysis_engine,
"status": scene.status,
"health_status": "OK" if scene.status == "DONE" and scene.analysis_tif_path else "PENDING",
"publish_dir": scene.analysis_dir,
"manifest_path": (scene.analysis_metadata_json or {}).get("manifest_path") if isinstance(scene.analysis_metadata_json, dict) else None,
"native_output_dir": scene.analysis_dir,
"primary_asset_path": scene.analysis_tif_path,
"summary": {
"scene_count": 1,
"radar_data_id": radar.id,
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m,
"backscatter_unit": scene.analysis_backscatter_unit,
},
"tags": {"engine": scene.analysis_engine, "profile": scene.analysis_profile},
"produced_at": scene.updated_at.isoformat() if scene.updated_at else None,
"published_at": scene.updated_at.isoformat() if scene.updated_at else None,
"registered_at": scene.created_at.isoformat() if scene.created_at else None,
}
)
return {"total": total, "limit": safe_limit, "offset": safe_offset, "items": items}
@router.get("/landsar-lt1-production/products/{product_db_id}")
async def get_landsar_lt1_product_detail(
product_db_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(
SARSceneGeoORM.id == product_db_id,
SARSceneGeoORM.analysis_engine == "lt_gamma",
SARSceneGeoORM.analysis_profile == "lt1_gamma_geocoded_mli",
)
)
row = result.first()
if row is None:
raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF product not found")
scene, radar = row
marker = _scene_product_marker(scene)
detail = {
"id": scene.id,
"product_id": marker["product_id"],
"catalog_name": "sar_scene_geo",
"product_family": "lt1_analysis_ready_geotiff",
"product_type": "analysis_ready_geotiff",
"display_name": radar.product_unique_id or radar.unique_id or f"radar_id={radar.id}",
"profile_code": scene.analysis_profile,
"engine_code": scene.analysis_engine,
"status": scene.status,
"publish_dir": scene.analysis_dir,
"primary_asset_path": scene.analysis_tif_path,
"summary": {
"scene_count": 1,
"radar_data_id": radar.id,
"source_asset_ids": [radar.source_product_ref_id] if radar.source_product_ref_id else [],
"imaging_date": radar.imaging_date,
"polarization": radar.polarization,
"pixel_size_m": scene.pixel_size_m,
"backscatter_unit": scene.analysis_backscatter_unit,
},
"assets": _scene_asset_items(scene),
}
return detail
@router.get("/landsar-lt1-production/products/{product_db_id}/assets/{asset_id}")
async def get_landsar_lt1_product_asset(
product_db_id: int,
asset_id: int,
current_user: AuthUserORM = Depends(_get_current_user),
db: AsyncSession = Depends(get_db),
):
_ = current_user
scene = await db.get(SARSceneGeoORM, product_db_id)
if scene is None or scene.analysis_engine != "lt_gamma" or scene.analysis_profile != "lt1_gamma_geocoded_mli":
raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF product not found")
asset = next((item for item in _scene_asset_items(scene) if int(item["id"]) == int(asset_id)), None)
if asset is None:
raise HTTPException(status_code=404, detail="LT-1 geocoded GeoTIFF asset not found")
absolute_path = str(asset.get("absolute_path") or "")
if not absolute_path or not os.path.isfile(absolute_path):
raise HTTPException(status_code=404, detail="Asset file not found")
return FileResponse(
absolute_path,
media_type=str(asset.get("media_type") or "application/octet-stream"),
filename=str(asset.get("name") or os.path.basename(absolute_path)),
headers=STATIC_ASSET_CACHE_HEADERS,
)