mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-08 10:24:48 +02:00
261 lines
8.8 KiB
Python
261 lines
8.8 KiB
Python
"""SAR (Synthetic Aperture Radar) layer endpoints.
|
|
|
|
Exposes:
|
|
- GET /api/sar/status — feature gates + signup links for the UI
|
|
- GET /api/sar/anomalies — Mode B pre-processed anomalies
|
|
- GET /api/sar/scenes — Mode A scene catalog
|
|
- GET /api/sar/coverage — per-AOI coverage and next-pass hints
|
|
- GET /api/sar/aois — operator-defined AOIs
|
|
- POST /api/sar/aois — create or replace an AOI
|
|
- DELETE /api/sar/aois/{aoi_id} — remove an AOI
|
|
- GET /api/sar/near — anomalies within radius_km of (lat, lon)
|
|
|
|
The /status endpoint is the load-bearing UX: when Mode B is disabled it
|
|
returns the structured help payload from sar_config.products_fetch_status()
|
|
so the frontend can render in-app links to the free signup pages instead of
|
|
making the user hunt around.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from auth import require_local_operator
|
|
from limiter import limiter
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
from services.sar.sar_aoi import (
|
|
SarAoi,
|
|
add_aoi,
|
|
haversine_km,
|
|
load_aois,
|
|
remove_aoi,
|
|
)
|
|
from services.sar.sar_config import (
|
|
catalog_enabled,
|
|
clear_runtime_credentials,
|
|
openclaw_enabled,
|
|
products_fetch_enabled,
|
|
products_fetch_status,
|
|
require_private_tier_for_publish,
|
|
set_runtime_credentials,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status — the in-app onboarding hook
|
|
# ---------------------------------------------------------------------------
|
|
@router.get("/api/sar/status")
|
|
@limiter.limit("60/minute")
|
|
async def sar_status(request: Request) -> dict:
|
|
"""Layer status + signup links.
|
|
|
|
The frontend calls this whenever the SAR panel is opened. When Mode B
|
|
is off, the response includes a step-by-step ``help`` block with the
|
|
free signup URLs so the user can enable everything without leaving the
|
|
app.
|
|
"""
|
|
products_status = products_fetch_status()
|
|
return {
|
|
"ok": True,
|
|
"catalog": {
|
|
"mode": "A",
|
|
"enabled": catalog_enabled(),
|
|
"needs_account": False,
|
|
"description": "Free Sentinel-1 scene catalog from ASF Search.",
|
|
},
|
|
"products": {
|
|
"mode": "B",
|
|
**products_status,
|
|
},
|
|
"openclaw_enabled": openclaw_enabled(),
|
|
"require_private_tier": require_private_tier_for_publish(),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data feeds
|
|
# ---------------------------------------------------------------------------
|
|
@router.get("/api/sar/anomalies")
|
|
@limiter.limit("60/minute")
|
|
async def sar_anomalies(
|
|
request: Request,
|
|
kind: str = Query("", description="Optional anomaly kind filter"),
|
|
aoi_id: str = Query("", description="Optional AOI id filter"),
|
|
limit: int = Query(200, ge=1, le=1000),
|
|
) -> dict:
|
|
"""Return the latest cached SAR anomalies (Mode B)."""
|
|
snap = get_latest_data_subset_refs("sar_anomalies")
|
|
items = list(snap.get("sar_anomalies") or [])
|
|
if kind:
|
|
items = [a for a in items if a.get("kind") == kind]
|
|
if aoi_id:
|
|
aoi_id = aoi_id.strip().lower()
|
|
items = [a for a in items if (a.get("stack_id") or "").lower() == aoi_id]
|
|
items = items[:limit]
|
|
return {
|
|
"ok": True,
|
|
"count": len(items),
|
|
"anomalies": items,
|
|
"products_enabled": products_fetch_enabled(),
|
|
}
|
|
|
|
|
|
@router.get("/api/sar/scenes")
|
|
@limiter.limit("60/minute")
|
|
async def sar_scenes(
|
|
request: Request,
|
|
aoi_id: str = Query(""),
|
|
limit: int = Query(200, ge=1, le=1000),
|
|
) -> dict:
|
|
"""Return the latest cached scene catalog (Mode A)."""
|
|
snap = get_latest_data_subset_refs("sar_scenes")
|
|
items = list(snap.get("sar_scenes") or [])
|
|
if aoi_id:
|
|
aoi_id = aoi_id.strip().lower()
|
|
items = [s for s in items if (s.get("aoi_id") or "").lower() == aoi_id]
|
|
items = items[:limit]
|
|
return {
|
|
"ok": True,
|
|
"count": len(items),
|
|
"scenes": items,
|
|
"catalog_enabled": catalog_enabled(),
|
|
}
|
|
|
|
|
|
@router.get("/api/sar/coverage")
|
|
@limiter.limit("60/minute")
|
|
async def sar_coverage(request: Request) -> dict:
|
|
"""Per-AOI coverage and rough next-pass estimate."""
|
|
snap = get_latest_data_subset_refs("sar_aoi_coverage")
|
|
return {
|
|
"ok": True,
|
|
"coverage": list(snap.get("sar_aoi_coverage") or []),
|
|
}
|
|
|
|
|
|
@router.get("/api/sar/near")
|
|
@limiter.limit("60/minute")
|
|
async def sar_near(
|
|
request: Request,
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
lon: float = Query(..., ge=-180, le=180),
|
|
radius_km: float = Query(50, ge=1, le=2000),
|
|
kind: str = Query(""),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
) -> dict:
|
|
"""Return anomalies whose center sits within ``radius_km`` of (lat, lon)."""
|
|
snap = get_latest_data_subset_refs("sar_anomalies")
|
|
items = list(snap.get("sar_anomalies") or [])
|
|
matches = []
|
|
for a in items:
|
|
try:
|
|
a_lat = float(a.get("lat", 0.0))
|
|
a_lon = float(a.get("lon", 0.0))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
d = haversine_km(lat, lon, a_lat, a_lon)
|
|
if d > radius_km:
|
|
continue
|
|
if kind and a.get("kind") != kind:
|
|
continue
|
|
a = dict(a)
|
|
a["distance_km"] = round(d, 2)
|
|
matches.append(a)
|
|
matches.sort(key=lambda x: x.get("distance_km", 0))
|
|
return {
|
|
"ok": True,
|
|
"count": len(matches[:limit]),
|
|
"anomalies": matches[:limit],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AOI CRUD
|
|
# ---------------------------------------------------------------------------
|
|
@router.get("/api/sar/aois")
|
|
@limiter.limit("60/minute")
|
|
async def sar_aoi_list(request: Request) -> dict:
|
|
return {
|
|
"ok": True,
|
|
"aois": [a.to_dict() for a in load_aois(force=True)],
|
|
}
|
|
|
|
|
|
class AoiPayload(BaseModel):
|
|
id: str = Field(..., min_length=1, max_length=64)
|
|
name: str = Field(..., min_length=1, max_length=120)
|
|
description: str = Field("", max_length=400)
|
|
center_lat: float = Field(..., ge=-90, le=90)
|
|
center_lon: float = Field(..., ge=-180, le=180)
|
|
radius_km: float = Field(25.0, ge=1.0, le=500.0)
|
|
category: str = Field("watchlist", max_length=40)
|
|
polygon: list[list[float]] | None = None
|
|
|
|
|
|
@router.post("/api/sar/aois", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("20/minute")
|
|
async def sar_aoi_upsert(request: Request, payload: AoiPayload) -> dict:
|
|
aoi = SarAoi(
|
|
id=payload.id.strip().lower(),
|
|
name=payload.name.strip(),
|
|
description=payload.description.strip(),
|
|
center_lat=payload.center_lat,
|
|
center_lon=payload.center_lon,
|
|
radius_km=payload.radius_km,
|
|
polygon=payload.polygon,
|
|
category=(payload.category or "watchlist").strip().lower(),
|
|
)
|
|
add_aoi(aoi)
|
|
return {"ok": True, "aoi": aoi.to_dict()}
|
|
|
|
|
|
@router.delete("/api/sar/aois/{aoi_id}", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("20/minute")
|
|
async def sar_aoi_delete(request: Request, aoi_id: str) -> dict:
|
|
removed = remove_aoi(aoi_id)
|
|
if not removed:
|
|
raise HTTPException(status_code=404, detail="AOI not found")
|
|
return {"ok": True, "removed": aoi_id}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mode B enable / disable — one-click setup from the frontend
|
|
# ---------------------------------------------------------------------------
|
|
class ModeBEnablePayload(BaseModel):
|
|
earthdata_user: str = Field("", max_length=120)
|
|
earthdata_token: str = Field(..., min_length=8, max_length=2048)
|
|
copernicus_user: str = Field("", max_length=120)
|
|
copernicus_token: str = Field("", max_length=2048)
|
|
|
|
|
|
@router.post("/api/sar/mode-b/enable", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("10/minute")
|
|
async def sar_mode_b_enable(request: Request, payload: ModeBEnablePayload) -> dict:
|
|
"""Store Earthdata (and optional Copernicus) credentials and flip both
|
|
two-step opt-in flags. Returns the fresh status payload so the UI can
|
|
immediately reflect the change.
|
|
"""
|
|
set_runtime_credentials(
|
|
earthdata_user=payload.earthdata_user,
|
|
earthdata_token=payload.earthdata_token,
|
|
copernicus_user=payload.copernicus_user,
|
|
copernicus_token=payload.copernicus_token,
|
|
mode_b_opt_in=True,
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"products": products_fetch_status(),
|
|
}
|
|
|
|
|
|
@router.post("/api/sar/mode-b/disable", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("10/minute")
|
|
async def sar_mode_b_disable(request: Request) -> dict:
|
|
"""Wipe runtime credentials and revert to Mode A only."""
|
|
clear_runtime_credentials()
|
|
return {
|
|
"ok": True,
|
|
"products": products_fetch_status(),
|
|
}
|