486 lines
20 KiB
Python
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,
|
|
)
|