mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-28 11:41:47 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""ShadowBroker SAR (Synthetic Aperture Radar) layer.
|
||||
|
||||
Two operating modes:
|
||||
|
||||
* **Mode A — Catalog ingest** (default-on, free, no account):
|
||||
Hits ASF Search for Sentinel-1 scene metadata over operator-defined AOIs.
|
||||
Disk footprint comparable to the earthquake layer (a few MB).
|
||||
|
||||
* **Mode B — Pre-processed anomaly ingest** (opt-in, free, needs account):
|
||||
Pulls already-computed deformation, flood, water-mask, and damage products
|
||||
from NASA OPERA, Copernicus EGMS, Global Flood Monitoring, Copernicus EMS,
|
||||
and UNOSAT. No local DSP, no GPU, no 2TB cache.
|
||||
|
||||
Anomalies emitted by this layer are signed events through the existing
|
||||
mesh signing path so other nodes can verify their provenance.
|
||||
"""
|
||||
|
||||
from services.sar.sar_aoi import ( # noqa: F401
|
||||
SarAoi,
|
||||
bbox_for_aoi,
|
||||
load_aois,
|
||||
)
|
||||
from services.sar.sar_normalize import ( # noqa: F401
|
||||
ANOMALY_KINDS,
|
||||
SarAnomaly,
|
||||
SarScene,
|
||||
canonical_anomaly_json,
|
||||
evidence_hash_for_payload,
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""SAR area-of-interest (AOI) definitions.
|
||||
|
||||
AOIs are operator-defined regions that the SAR layer watches. They live
|
||||
in ``backend/data/sar_aois.json`` and are loaded once at module init.
|
||||
|
||||
The seed file ships with five obvious watch points so a fresh install
|
||||
has something to do without any configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
AOI_FILE = DATA_DIR / "sar_aois.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SarAoi:
|
||||
"""A region the SAR layer watches.
|
||||
|
||||
Either ``polygon`` (list of [lon, lat] pairs) or ``center`` + ``radius_km``
|
||||
must be set. ``polygon`` takes precedence.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
center_lat: float
|
||||
center_lon: float
|
||||
radius_km: float
|
||||
polygon: list[list[float]] | None = None
|
||||
category: str = "watchlist"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"center": [self.center_lat, self.center_lon],
|
||||
"radius_km": self.radius_km,
|
||||
"polygon": self.polygon,
|
||||
"category": self.category,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> "SarAoi":
|
||||
polygon = raw.get("polygon")
|
||||
if isinstance(polygon, list) and polygon:
|
||||
lats = [pt[1] for pt in polygon if isinstance(pt, (list, tuple)) and len(pt) >= 2]
|
||||
lons = [pt[0] for pt in polygon if isinstance(pt, (list, tuple)) and len(pt) >= 2]
|
||||
center_lat = sum(lats) / len(lats) if lats else 0.0
|
||||
center_lon = sum(lons) / len(lons) if lons else 0.0
|
||||
radius_km = float(raw.get("radius_km") or 25.0)
|
||||
else:
|
||||
polygon = None
|
||||
center = raw.get("center") or [0.0, 0.0]
|
||||
center_lat = float(center[0]) if len(center) > 0 else 0.0
|
||||
center_lon = float(center[1]) if len(center) > 1 else 0.0
|
||||
radius_km = float(raw.get("radius_km") or 25.0)
|
||||
return cls(
|
||||
id=str(raw.get("id", "")).strip().lower(),
|
||||
name=str(raw.get("name", "")).strip() or str(raw.get("id", "")),
|
||||
description=str(raw.get("description", "")).strip(),
|
||||
center_lat=center_lat,
|
||||
center_lon=center_lon,
|
||||
radius_km=radius_km,
|
||||
polygon=polygon,
|
||||
category=str(raw.get("category", "watchlist")).strip().lower() or "watchlist",
|
||||
)
|
||||
|
||||
|
||||
def bbox_for_aoi(aoi: SarAoi) -> tuple[float, float, float, float]:
|
||||
"""Return (min_lon, min_lat, max_lon, max_lat) for an AOI.
|
||||
|
||||
Uses the polygon if set, otherwise approximates a square around the
|
||||
center using the radius (1 deg lat ≈ 111 km).
|
||||
"""
|
||||
if aoi.polygon:
|
||||
lons = [pt[0] for pt in aoi.polygon]
|
||||
lats = [pt[1] for pt in aoi.polygon]
|
||||
return (min(lons), min(lats), max(lons), max(lats))
|
||||
deg_lat = aoi.radius_km / 111.0
|
||||
cos_lat = max(0.05, math.cos(math.radians(aoi.center_lat)))
|
||||
deg_lon = aoi.radius_km / (111.0 * cos_lat)
|
||||
return (
|
||||
aoi.center_lon - deg_lon,
|
||||
aoi.center_lat - deg_lat,
|
||||
aoi.center_lon + deg_lon,
|
||||
aoi.center_lat + deg_lat,
|
||||
)
|
||||
|
||||
|
||||
def wkt_for_aoi(aoi: SarAoi) -> str:
|
||||
"""Build a POLYGON WKT string for ASF Search ``intersectsWith``."""
|
||||
min_lon, min_lat, max_lon, max_lat = bbox_for_aoi(aoi)
|
||||
return (
|
||||
f"POLYGON(({min_lon} {min_lat},"
|
||||
f"{max_lon} {min_lat},"
|
||||
f"{max_lon} {max_lat},"
|
||||
f"{min_lon} {max_lat},"
|
||||
f"{min_lon} {min_lat}))"
|
||||
)
|
||||
|
||||
|
||||
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Great-circle distance between two points in km."""
|
||||
r = 6371.0
|
||||
p1 = math.radians(lat1)
|
||||
p2 = math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def point_in_aoi(lat: float, lon: float, aoi: SarAoi) -> bool:
|
||||
"""Cheap point-in-AOI check using haversine to center."""
|
||||
return haversine_km(lat, lon, aoi.center_lat, aoi.center_lon) <= aoi.radius_km
|
||||
|
||||
|
||||
_aoi_cache: list[SarAoi] | None = None
|
||||
|
||||
|
||||
def load_aois(force: bool = False) -> list[SarAoi]:
|
||||
"""Load AOIs from disk. Cached after first call."""
|
||||
global _aoi_cache
|
||||
if _aoi_cache is not None and not force:
|
||||
return _aoi_cache
|
||||
if not AOI_FILE.exists():
|
||||
logger.warning("SAR AOI file missing: %s", AOI_FILE)
|
||||
_aoi_cache = []
|
||||
return _aoi_cache
|
||||
try:
|
||||
raw = json.loads(AOI_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.error("Failed to load SAR AOIs: %s", exc)
|
||||
_aoi_cache = []
|
||||
return _aoi_cache
|
||||
items = raw.get("aois") if isinstance(raw, dict) else raw
|
||||
if not isinstance(items, list):
|
||||
_aoi_cache = []
|
||||
return _aoi_cache
|
||||
parsed: list[SarAoi] = []
|
||||
for entry in items:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
parsed.append(SarAoi.from_dict(entry))
|
||||
except (TypeError, ValueError) as exc:
|
||||
logger.debug("Skipping malformed AOI %r: %s", entry, exc)
|
||||
_aoi_cache = parsed
|
||||
return _aoi_cache
|
||||
|
||||
|
||||
def save_aois(aois: list[SarAoi]) -> None:
|
||||
"""Persist AOIs to disk and refresh the cache."""
|
||||
global _aoi_cache
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"aois": [aoi.to_dict() for aoi in aois]}
|
||||
AOI_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
_aoi_cache = list(aois)
|
||||
|
||||
|
||||
def add_aoi(aoi: SarAoi) -> None:
|
||||
"""Add or replace an AOI by id."""
|
||||
current = list(load_aois())
|
||||
current = [a for a in current if a.id != aoi.id]
|
||||
current.append(aoi)
|
||||
save_aois(current)
|
||||
|
||||
|
||||
def remove_aoi(aoi_id: str) -> bool:
|
||||
"""Remove an AOI by id. Returns True if anything was removed."""
|
||||
current = list(load_aois())
|
||||
aoi_id = (aoi_id or "").strip().lower()
|
||||
new = [a for a in current if a.id != aoi_id]
|
||||
if len(new) == len(current):
|
||||
return False
|
||||
save_aois(new)
|
||||
return True
|
||||
@@ -0,0 +1,174 @@
|
||||
"""ASF Search catalog client (Mode A).
|
||||
|
||||
Pure metadata. No downloads, no auth, no DSP. Returns a list of
|
||||
``SarScene`` objects so the fetcher can write them straight into
|
||||
``latest_data["sar_scenes"]``.
|
||||
|
||||
ASF Search reference:
|
||||
https://docs.asf.alaska.edu/api/keywords/
|
||||
|
||||
The endpoint accepts ``intersectsWith`` (WKT), ``platform``, ``processingLevel``,
|
||||
``beamMode``, and ``start``/``end`` ISO timestamps among many others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.sar.sar_aoi import SarAoi, wkt_for_aoi
|
||||
from services.sar.sar_normalize import SarScene
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ASF_SEARCH_URL = "https://api.daac.asf.alaska.edu/services/search/param"
|
||||
DEFAULT_LOOKBACK_HOURS = 36
|
||||
DEFAULT_MAX_RESULTS = 30
|
||||
|
||||
|
||||
def _iso_utc(dt: datetime) -> str:
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def search_scenes_for_aoi(
|
||||
aoi: SarAoi,
|
||||
*,
|
||||
lookback_hours: int = DEFAULT_LOOKBACK_HOURS,
|
||||
max_results: int = DEFAULT_MAX_RESULTS,
|
||||
platform: str = "Sentinel-1",
|
||||
processing_level: str = "SLC",
|
||||
beam_mode: str = "IW",
|
||||
) -> list[SarScene]:
|
||||
"""Query ASF for scenes that intersected the AOI in the last N hours.
|
||||
|
||||
Returns an empty list on any error — fetcher logs the failure.
|
||||
"""
|
||||
end = datetime.utcnow()
|
||||
start = end - timedelta(hours=lookback_hours)
|
||||
params = {
|
||||
"platform": platform,
|
||||
"processingLevel": processing_level,
|
||||
"beamMode": beam_mode,
|
||||
"start": _iso_utc(start),
|
||||
"end": _iso_utc(end),
|
||||
"intersectsWith": wkt_for_aoi(aoi),
|
||||
"output": "JSON",
|
||||
"maxResults": str(max_results),
|
||||
}
|
||||
qs = "&".join(f"{k}={_url_encode(v)}" for k, v in params.items())
|
||||
url = f"{ASF_SEARCH_URL}?{qs}"
|
||||
try:
|
||||
resp = fetch_with_curl(url, timeout=20)
|
||||
except (ConnectionError, TimeoutError, OSError) as exc:
|
||||
logger.warning("ASF search failed for %s: %s", aoi.id, exc)
|
||||
return []
|
||||
if resp.status_code != 200:
|
||||
logger.debug("ASF search %s → HTTP %s", aoi.id, resp.status_code)
|
||||
return []
|
||||
try:
|
||||
body = resp.json()
|
||||
except (ValueError, KeyError) as exc:
|
||||
logger.debug("ASF search %s parse failed: %s", aoi.id, exc)
|
||||
return []
|
||||
# ASF returns a list of lists when output=JSON. Flatten.
|
||||
flat: list[dict[str, Any]] = []
|
||||
if isinstance(body, list):
|
||||
for item in body:
|
||||
if isinstance(item, list):
|
||||
flat.extend(x for x in item if isinstance(x, dict))
|
||||
elif isinstance(item, dict):
|
||||
flat.append(item)
|
||||
elif isinstance(body, dict):
|
||||
results = body.get("results") or body.get("features") or []
|
||||
if isinstance(results, list):
|
||||
flat = [x for x in results if isinstance(x, dict)]
|
||||
return [_to_scene(item, aoi) for item in flat if _is_usable(item)]
|
||||
|
||||
|
||||
def _is_usable(item: dict[str, Any]) -> bool:
|
||||
return bool(item.get("granuleName") or item.get("sceneName") or item.get("productID"))
|
||||
|
||||
|
||||
def _to_scene(item: dict[str, Any], aoi: SarAoi) -> SarScene:
|
||||
scene_id = (
|
||||
item.get("granuleName")
|
||||
or item.get("sceneName")
|
||||
or item.get("productID")
|
||||
or ""
|
||||
)
|
||||
bbox = _extract_bbox(item)
|
||||
return SarScene(
|
||||
scene_id=str(scene_id),
|
||||
platform=str(item.get("platform", "Sentinel-1")),
|
||||
mode=str(item.get("beamModeType") or item.get("beamMode", "IW")),
|
||||
level=str(item.get("processingLevel", "SLC")),
|
||||
time=str(item.get("startTime") or item.get("sceneDate") or ""),
|
||||
aoi_id=aoi.id,
|
||||
relative_orbit=_safe_int(item.get("relativeOrbit") or item.get("pathNumber") or 0),
|
||||
flight_direction=str(item.get("flightDirection", "")).upper(),
|
||||
bbox=bbox,
|
||||
download_url=str(item.get("downloadUrl") or item.get("url") or ""),
|
||||
provider="ASF",
|
||||
raw_provider_id=str(item.get("productID") or scene_id),
|
||||
)
|
||||
|
||||
|
||||
def _extract_bbox(item: dict[str, Any]) -> list[float]:
|
||||
"""Best-effort bbox extraction from the ASF item."""
|
||||
for key in ("centerLat", "centerLon"):
|
||||
if key not in item:
|
||||
break
|
||||
try:
|
||||
center_lat = float(item.get("centerLat", 0))
|
||||
center_lon = float(item.get("centerLon", 0))
|
||||
if center_lat or center_lon:
|
||||
return [center_lon - 1, center_lat - 1, center_lon + 1, center_lat + 1]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return [0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def _safe_int(val: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _url_encode(value: str) -> str:
|
||||
"""Tiny URL encoder — avoids importing urllib.parse for one call."""
|
||||
safe = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~()")
|
||||
out: list[str] = []
|
||||
for ch in str(value):
|
||||
if ch in safe:
|
||||
out.append(ch)
|
||||
elif ch == " ":
|
||||
out.append("%20")
|
||||
else:
|
||||
out.append("".join(f"%{b:02X}" for b in ch.encode("utf-8")))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def estimate_next_pass(scenes: list[SarScene]) -> dict[str, Any]:
|
||||
"""Cheap heuristic — given recent scenes, guess when the next pass might be.
|
||||
|
||||
Sentinel-1 has a ~12-day repeat cycle, so the next pass over the same
|
||||
relative orbit is roughly 12 days after the last one. This is a
|
||||
rough hint, not an authoritative orbit prediction.
|
||||
"""
|
||||
if not scenes:
|
||||
return {"next_pass_estimate": None, "confidence": "none"}
|
||||
latest = max(scenes, key=lambda s: s.time)
|
||||
try:
|
||||
dt = datetime.strptime(latest.time[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return {"next_pass_estimate": None, "confidence": "low"}
|
||||
next_pass = dt + timedelta(days=12)
|
||||
return {
|
||||
"next_pass_estimate": _iso_utc(next_pass),
|
||||
"confidence": "estimate",
|
||||
"based_on_scene": latest.scene_id,
|
||||
"repeat_cycle_days": 12,
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"""SAR layer configuration helpers.
|
||||
|
||||
Reads settings from the existing pydantic Settings object so the SAR layer
|
||||
participates in the same two-step opt-in pattern the rest of the mesh uses
|
||||
for risky toggles.
|
||||
|
||||
A small runtime credentials store lives alongside this module so the user
|
||||
can enable Mode B from the frontend without editing .env files. The
|
||||
runtime store wins over the pydantic Settings snapshot — the env values
|
||||
are the fallback, not the primary source, once a runtime override exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RUNTIME_LOCK = threading.Lock()
|
||||
_RUNTIME_FILE = Path(__file__).resolve().parents[2] / "data" / "sar_runtime.json"
|
||||
_RUNTIME_CACHE: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _load_runtime() -> dict[str, Any]:
|
||||
"""Read the runtime credentials store. Cached in-memory."""
|
||||
global _RUNTIME_CACHE
|
||||
if _RUNTIME_CACHE is not None:
|
||||
return _RUNTIME_CACHE
|
||||
if not _RUNTIME_FILE.exists():
|
||||
_RUNTIME_CACHE = {}
|
||||
return _RUNTIME_CACHE
|
||||
try:
|
||||
_RUNTIME_CACHE = json.loads(_RUNTIME_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(_RUNTIME_CACHE, dict):
|
||||
_RUNTIME_CACHE = {}
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("SAR runtime store unreadable: %s", exc)
|
||||
_RUNTIME_CACHE = {}
|
||||
return _RUNTIME_CACHE
|
||||
|
||||
|
||||
def _save_runtime(data: dict[str, Any]) -> None:
|
||||
global _RUNTIME_CACHE
|
||||
with _RUNTIME_LOCK:
|
||||
_RUNTIME_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_RUNTIME_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
_RUNTIME_CACHE = dict(data)
|
||||
|
||||
|
||||
def set_runtime_credentials(
|
||||
*,
|
||||
earthdata_user: str = "",
|
||||
earthdata_token: str = "",
|
||||
copernicus_user: str = "",
|
||||
copernicus_token: str = "",
|
||||
mode_b_opt_in: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist runtime SAR credentials + the two-step opt-in flags.
|
||||
|
||||
Setting ``mode_b_opt_in=True`` flips both MESH_SAR_PRODUCTS_FETCH and
|
||||
MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE in the runtime store. A caller
|
||||
that wants to revert to Mode A only can pass ``mode_b_opt_in=False``.
|
||||
"""
|
||||
current = dict(_load_runtime())
|
||||
if earthdata_user:
|
||||
current["MESH_SAR_EARTHDATA_USER"] = earthdata_user.strip()
|
||||
if earthdata_token:
|
||||
current["MESH_SAR_EARTHDATA_TOKEN"] = earthdata_token.strip()
|
||||
if copernicus_user:
|
||||
current["MESH_SAR_COPERNICUS_USER"] = copernicus_user.strip()
|
||||
if copernicus_token:
|
||||
current["MESH_SAR_COPERNICUS_TOKEN"] = copernicus_token.strip()
|
||||
if mode_b_opt_in:
|
||||
current["MESH_SAR_PRODUCTS_FETCH"] = "allow"
|
||||
current["MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE"] = True
|
||||
else:
|
||||
current["MESH_SAR_PRODUCTS_FETCH"] = "block"
|
||||
current["MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE"] = False
|
||||
current["updated_at"] = int(time.time())
|
||||
_save_runtime(current)
|
||||
return current
|
||||
|
||||
|
||||
def clear_runtime_credentials() -> None:
|
||||
"""Wipe the runtime store and revert to Mode A."""
|
||||
_save_runtime({"updated_at": int(time.time())})
|
||||
|
||||
|
||||
def _settings() -> Any:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
return get_settings()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _flag(name: str, default: bool = False) -> bool:
|
||||
# Runtime store wins — set via the Settings → SAR panel in the app.
|
||||
runtime = _load_runtime()
|
||||
if name in runtime:
|
||||
raw = runtime[name]
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
raw_s = str(raw).strip().lower()
|
||||
if raw_s in {"1", "true", "yes", "on", "allow", "enable", "enabled"}:
|
||||
return True
|
||||
if raw_s in {"0", "false", "no", "off", "block", "disable", "disabled"}:
|
||||
return False
|
||||
s = _settings()
|
||||
if s is not None and hasattr(s, name):
|
||||
try:
|
||||
return bool(getattr(s, name))
|
||||
except Exception:
|
||||
pass
|
||||
raw = os.environ.get(name, "").strip().lower()
|
||||
if raw in {"1", "true", "yes", "on", "allow", "enable", "enabled"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off", "block", "disable", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _str(name: str, default: str = "") -> str:
|
||||
runtime = _load_runtime()
|
||||
if runtime.get(name):
|
||||
return str(runtime[name])
|
||||
s = _settings()
|
||||
if s is not None and hasattr(s, name):
|
||||
try:
|
||||
value = getattr(s, name)
|
||||
if value:
|
||||
return str(value)
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get(name, default) or default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode A — catalog ingest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def catalog_enabled() -> bool:
|
||||
"""Mode A is on by default — only metadata, free, no account."""
|
||||
return _flag("MESH_SAR_CATALOG_ENABLED", default=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode B — pre-processed anomaly ingest (two-step opt-in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def products_fetch_enabled() -> bool:
|
||||
"""Mode B requires two-step opt-in (matches MESH_PRIVATE_CLEARNET_FALLBACK pattern).
|
||||
|
||||
Both flags must be affirmative — a single flag is not enough. This
|
||||
is the same pattern the audit identified as load-bearing for risky
|
||||
toggles in the rest of the codebase.
|
||||
"""
|
||||
raw = _str("MESH_SAR_PRODUCTS_FETCH", default="block").strip().lower()
|
||||
if raw not in {"allow", "enable", "enabled", "true", "on", "1"}:
|
||||
return False
|
||||
return _flag("MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE", default=False)
|
||||
|
||||
|
||||
def products_fetch_status() -> dict[str, Any]:
|
||||
"""Structured status used by the router for the 'how to enable' UX."""
|
||||
raw = _str("MESH_SAR_PRODUCTS_FETCH", default="block").strip().lower()
|
||||
fetch_set = raw in {"allow", "enable", "enabled", "true", "on", "1"}
|
||||
ack_set = _flag("MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE", default=False)
|
||||
enabled = fetch_set and ack_set
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"fetch_flag_set": fetch_set,
|
||||
"acknowledge_flag_set": ack_set,
|
||||
"earthdata_token_set": bool(earthdata_token()),
|
||||
"earthdata_user_set": bool(earthdata_user()),
|
||||
"missing": _missing_for_products(fetch_set, ack_set),
|
||||
"help": {
|
||||
"summary": (
|
||||
"SAR ground-change alerts (Mode B) need two opt-in flags and a "
|
||||
"free NASA Earthdata Login. Everything is free."
|
||||
),
|
||||
"steps": [
|
||||
{
|
||||
"step": 1,
|
||||
"label": "Create a free NASA Earthdata Login",
|
||||
"url": "https://urs.earthdata.nasa.gov/users/new",
|
||||
"why": "Used to fetch OPERA pre-processed SAR products and (optionally) HyP3 jobs.",
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"label": "Generate an Earthdata user token",
|
||||
"url": "https://urs.earthdata.nasa.gov/profile",
|
||||
"why": "Bearer token used in the Authorization header (no password is stored).",
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"label": "Enable Mode B in Settings → SAR → Ground-Change Alerts",
|
||||
"url": "/settings/sar",
|
||||
"why": "Sets MESH_SAR_PRODUCTS_FETCH=allow and MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE=true.",
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"label": "Optional: Copernicus Data Space account (EU coverage)",
|
||||
"url": "https://dataspace.copernicus.eu/",
|
||||
"why": "Used for European Ground Motion Service (EGMS) deformation maps over EU AOIs.",
|
||||
},
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"name": "NASA OPERA",
|
||||
"needs_account": True,
|
||||
"signup_url": "https://urs.earthdata.nasa.gov/users/new",
|
||||
"products": ["DSWx (water)", "DIST-ALERT (vegetation)", "DISP (deformation)"],
|
||||
},
|
||||
{
|
||||
"name": "Copernicus EGMS",
|
||||
"needs_account": True,
|
||||
"signup_url": "https://dataspace.copernicus.eu/",
|
||||
"products": ["EU ground motion velocity (mm/yr)"],
|
||||
},
|
||||
{
|
||||
"name": "Global Flood Monitoring (GFM)",
|
||||
"needs_account": False,
|
||||
"signup_url": "https://global-flood.emergency.copernicus.eu/",
|
||||
"products": ["Daily Sentinel-1 flood polygons"],
|
||||
},
|
||||
{
|
||||
"name": "Copernicus EMS Rapid Mapping",
|
||||
"needs_account": False,
|
||||
"signup_url": "https://emergency.copernicus.eu/mapping/",
|
||||
"products": ["Disaster damage GeoJSON"],
|
||||
},
|
||||
{
|
||||
"name": "UNOSAT",
|
||||
"needs_account": False,
|
||||
"signup_url": "https://unosat.org/",
|
||||
"products": ["UN damage assessments"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _missing_for_products(fetch_set: bool, ack_set: bool) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not fetch_set:
|
||||
missing.append("MESH_SAR_PRODUCTS_FETCH=allow")
|
||||
if not ack_set:
|
||||
missing.append("MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE=true")
|
||||
if not earthdata_token():
|
||||
missing.append("MESH_SAR_EARTHDATA_TOKEN (free from urs.earthdata.nasa.gov)")
|
||||
return missing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credentials (only used in Mode B; Mode A needs nothing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def earthdata_user() -> str:
|
||||
return _str("MESH_SAR_EARTHDATA_USER", default="")
|
||||
|
||||
|
||||
def earthdata_token() -> str:
|
||||
return _str("MESH_SAR_EARTHDATA_TOKEN", default="")
|
||||
|
||||
|
||||
def copernicus_user() -> str:
|
||||
return _str("MESH_SAR_COPERNICUS_USER", default="")
|
||||
|
||||
|
||||
def copernicus_token() -> str:
|
||||
return _str("MESH_SAR_COPERNICUS_TOKEN", default="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenClaw integration toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def openclaw_enabled() -> bool:
|
||||
return _flag("MESH_SAR_OPENCLAW_ENABLED", default=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mesh signing tier gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def require_private_tier_for_publish() -> bool:
|
||||
"""If true, SAR anomalies are only emitted as signed mesh events when
|
||||
the local node is at private_transitional or higher. Default: True.
|
||||
"""
|
||||
return _flag("MESH_SAR_REQUIRE_PRIVATE_TIER", default=True)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Unified SAR scene + anomaly schema.
|
||||
|
||||
Every provider response (ASF, OPERA, EGMS, GFM, EMS, UNOSAT) lands in
|
||||
one of these two shapes before it touches anything else in the system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# All anomaly kinds the SAR layer can emit. Frontend and OpenClaw skill
|
||||
# must use exactly these strings.
|
||||
ANOMALY_KINDS = (
|
||||
"ground_deformation", # mm or mm/yr — InSAR / EGMS / OPERA-DISP
|
||||
"surface_water_change", # OPERA DSWx — water mask delta
|
||||
"vegetation_disturbance", # OPERA DIST-ALERT — canopy loss
|
||||
"flood_extent", # GFM Sentinel-1 flood polygons
|
||||
"damage_assessment", # Copernicus EMS / UNOSAT damage maps
|
||||
"coherence_change", # CCD — something physically changed
|
||||
"scene_pass", # Mode A only — informational, not an anomaly
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SarScene:
|
||||
"""A single SAR acquisition (Mode A — catalog only).
|
||||
|
||||
No pixels — this is just metadata that says "Sentinel-1 flew over
|
||||
this AOI at this time, here is the download URL if you ever want it".
|
||||
"""
|
||||
|
||||
scene_id: str
|
||||
platform: str
|
||||
mode: str # IW / EW / SM / WV
|
||||
level: str # SLC / GRD / RAW
|
||||
time: str # ISO-8601 UTC
|
||||
aoi_id: str
|
||||
relative_orbit: int
|
||||
flight_direction: str # ASCENDING / DESCENDING
|
||||
bbox: list[float] # [min_lon, min_lat, max_lon, max_lat]
|
||||
download_url: str
|
||||
provider: str # ASF / Copernicus / Earthdata
|
||||
raw_provider_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SarAnomaly:
|
||||
"""A pre-processed SAR finding (Mode B).
|
||||
|
||||
Confidence is 0..1. ``magnitude`` + ``magnitude_unit`` interpretation
|
||||
depends on ``kind`` — see ANOMALY_KINDS for the canonical list.
|
||||
"""
|
||||
|
||||
anomaly_id: str
|
||||
kind: str
|
||||
lat: float
|
||||
lon: float
|
||||
magnitude: float
|
||||
magnitude_unit: str
|
||||
confidence: float
|
||||
first_seen: int # epoch seconds
|
||||
last_seen: int # epoch seconds
|
||||
aoi_id: str
|
||||
scene_count: int
|
||||
solver: str # OPERA-DISP, EGMS, GFM, EMS, UNOSAT, ...
|
||||
source_constellation: str # Sentinel-1, ALOS, ...
|
||||
provenance_url: str
|
||||
category: str # infrastructure / conflict / geohazard / watchlist
|
||||
title: str
|
||||
summary: str
|
||||
evidence_hash: str = ""
|
||||
extras: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def to_pin_dict(self) -> dict[str, Any]:
|
||||
"""Convert to the AI Intel pin shape used by ai_pin_store."""
|
||||
return {
|
||||
"lat": self.lat,
|
||||
"lng": self.lon,
|
||||
"label": self.title or f"SAR {self.kind}",
|
||||
"category": _kind_to_pin_category(self.kind, self.category),
|
||||
"description": (
|
||||
f"{self.summary}\n\n"
|
||||
f"Solver: {self.solver}\n"
|
||||
f"Constellation: {self.source_constellation}\n"
|
||||
f"Magnitude: {self.magnitude} {self.magnitude_unit}\n"
|
||||
f"Confidence: {self.confidence:.2f}\n"
|
||||
f"Scenes: {self.scene_count}\n"
|
||||
f"Evidence: {self.evidence_hash[:16] or 'n/a'}"
|
||||
),
|
||||
"source": f"SAR · {self.solver}",
|
||||
"source_url": self.provenance_url,
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
|
||||
|
||||
def _kind_to_pin_category(kind: str, default: str) -> str:
|
||||
"""Map SAR anomaly kind to ShadowBroker pin category color."""
|
||||
return {
|
||||
"ground_deformation": "infrastructure",
|
||||
"surface_water_change": "weather",
|
||||
"vegetation_disturbance": "research",
|
||||
"flood_extent": "weather",
|
||||
"damage_assessment": "threat",
|
||||
"coherence_change": "anomaly",
|
||||
}.get(kind, "satellite")
|
||||
|
||||
|
||||
def canonical_anomaly_json(payload: dict[str, Any]) -> str:
|
||||
"""Stable JSON encoding for evidence_hash + signature payloads."""
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def evidence_hash_for_payload(payload: dict[str, Any]) -> str:
|
||||
"""SHA-256 hex digest used to bind anomaly events to their source data.
|
||||
|
||||
Mirrors the gate_envelope ``envelope_hash`` pattern from the audit:
|
||||
the raw provider response is hashed and the digest is bound into the
|
||||
signed event so downstream consumers can re-verify the lineage.
|
||||
"""
|
||||
return hashlib.sha256(canonical_anomaly_json(payload).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def make_anomaly_id(solver: str, raw_id: str, lat: float, lon: float) -> str:
|
||||
"""Stable, dedup-friendly anomaly id."""
|
||||
base = f"{solver}|{raw_id}|{round(lat, 4)}|{round(lon, 4)}"
|
||||
digest = hashlib.sha256(base.encode("utf-8")).hexdigest()[:16]
|
||||
return f"sar_{solver.lower().replace('-', '_')}_{digest}"
|
||||
|
||||
|
||||
def now_epoch() -> int:
|
||||
return int(time.time())
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Pre-processed SAR product clients (Mode B).
|
||||
|
||||
These clients pull *already-computed* SAR products from third parties.
|
||||
There is no local DSP, no GPU, no scene download — just metadata-and-result
|
||||
JSON over HTTPS.
|
||||
|
||||
Providers (all free):
|
||||
|
||||
* **NASA OPERA via ASF** — DSWx (water), DIST-ALERT (vegetation), DISP (deformation).
|
||||
Needs a free Earthdata bearer token.
|
||||
* **Copernicus EGMS** — EU ground motion velocity (mm/yr).
|
||||
* **Global Flood Monitoring (GFM)** — Daily Sentinel-1 flood polygons.
|
||||
* **Copernicus EMS Rapid Mapping** — Active disaster damage GeoJSON.
|
||||
* **UNOSAT Live** — UN damage assessments.
|
||||
|
||||
Each ``fetch_*_for_aoi`` returns a list of ``SarAnomaly`` ready to be
|
||||
written into ``latest_data["sar_anomalies"]``.
|
||||
|
||||
Network failures, missing tokens, and unavailable providers are all
|
||||
handled by returning an empty list and logging at debug level. This
|
||||
keeps the fetcher loop resilient — one provider being down never blocks
|
||||
the others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from services.network_utils import fetch_with_curl
|
||||
from services.sar.sar_aoi import SarAoi, bbox_for_aoi, point_in_aoi
|
||||
from services.sar.sar_config import (
|
||||
copernicus_token,
|
||||
earthdata_token,
|
||||
)
|
||||
from services.sar.sar_normalize import (
|
||||
SarAnomaly,
|
||||
evidence_hash_for_payload,
|
||||
make_anomaly_id,
|
||||
now_epoch,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CMR_GRANULES_URL = "https://cmr.earthdata.nasa.gov/search/granules.json"
|
||||
EMS_ACTIVATIONS_URL = (
|
||||
"https://rapidmapping.emergency.copernicus.eu/backend/dashboard-api/public-activations-info/"
|
||||
)
|
||||
UNOSAT_HDX_SEARCH_URL = "https://data.humdata.org/api/3/action/package_search"
|
||||
# GFM is only accessible via openEO (OIDC auth + Python client library),
|
||||
# not a simple REST endpoint. Tracked in _gfm_hint_once.
|
||||
_GFM_DISABLED_HINT_LOGGED = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _iso_utc(dt: datetime) -> str:
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _parse_epoch(value: Any) -> int:
|
||||
if value is None:
|
||||
return now_epoch()
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
s = str(value)
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
|
||||
try:
|
||||
return int(datetime.strptime(s[: len(fmt) + 2 if "Z" in fmt else len(fmt)], fmt).timestamp())
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
try:
|
||||
return int(datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").timestamp())
|
||||
except (ValueError, TypeError):
|
||||
return now_epoch()
|
||||
|
||||
|
||||
def _safe_float(val: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NASA OPERA (DSWx + DIST-ALERT) via NASA CMR
|
||||
# ---------------------------------------------------------------------------
|
||||
# CMR is the public, unauthenticated granule search. Token only needed if
|
||||
# we want to download the products themselves; metadata is open. DSWx-S1,
|
||||
# DSWx-HLS, and DIST-ALERT are accessible here; DISP-S1 is not yet seeded
|
||||
# into CMR for arbitrary AOIs so we skip it.
|
||||
|
||||
OPERA_SHORTNAMES = (
|
||||
("OPERA_L3_DSWX-S1_V1", "surface_water_change", "OPERA-DSWx-S1", "Sentinel-1 surface water extent"),
|
||||
("OPERA_L3_DSWX-HLS_V1", "surface_water_change", "OPERA-DSWx-HLS", "HLS surface water extent"),
|
||||
("OPERA_L3_DIST-ALERT-HLS_V1", "vegetation_disturbance", "OPERA-DIST-ALERT", "Vegetation/land-surface disturbance alert"),
|
||||
)
|
||||
|
||||
|
||||
def fetch_opera_for_aoi(aoi: SarAoi, lookback_days: int = 7) -> list[SarAnomaly]:
|
||||
"""Fetch OPERA pre-processed products covering this AOI via NASA CMR.
|
||||
|
||||
CMR granule search is public — no token required for metadata. The
|
||||
Earthdata token is only used (when present) to authenticate against
|
||||
PO.DAAC / LP DAAC if the browse URL is later fetched.
|
||||
"""
|
||||
end = datetime.utcnow()
|
||||
start = end - timedelta(days=lookback_days)
|
||||
min_lon, min_lat, max_lon, max_lat = bbox_for_aoi(aoi)
|
||||
bbox = f"{min_lon},{min_lat},{max_lon},{max_lat}"
|
||||
temporal = f"{_iso_utc(start)},{_iso_utc(end)}"
|
||||
token = earthdata_token()
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
out: list[SarAnomaly] = []
|
||||
for short_name, kind, solver, summary in OPERA_SHORTNAMES:
|
||||
params = {
|
||||
"short_name": short_name,
|
||||
"bounding_box": bbox,
|
||||
"temporal": temporal,
|
||||
"page_size": "20",
|
||||
"sort_key": "-start_date",
|
||||
}
|
||||
qs = "&".join(f"{k}={_url_encode(v)}" for k, v in params.items())
|
||||
url = f"{CMR_GRANULES_URL}?{qs}"
|
||||
try:
|
||||
resp = fetch_with_curl(url, timeout=20, headers=headers)
|
||||
except (ConnectionError, TimeoutError, OSError) as exc:
|
||||
logger.debug("OPERA %s for %s failed: %s", short_name, aoi.id, exc)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
logger.debug("OPERA %s for %s → HTTP %s", short_name, aoi.id, resp.status_code)
|
||||
continue
|
||||
try:
|
||||
body = resp.json()
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
entries = body.get("feed", {}).get("entry", []) if isinstance(body, dict) else []
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for item in entries:
|
||||
anomaly = _opera_cmr_item_to_anomaly(item, aoi, kind, solver, summary)
|
||||
if anomaly is not None:
|
||||
out.append(anomaly)
|
||||
return out
|
||||
|
||||
|
||||
def _opera_cmr_item_to_anomaly(
|
||||
item: dict[str, Any],
|
||||
aoi: SarAoi,
|
||||
kind: str,
|
||||
solver: str,
|
||||
summary: str,
|
||||
) -> SarAnomaly | None:
|
||||
"""Convert a CMR granule entry into an SarAnomaly."""
|
||||
raw_id = str(item.get("id") or item.get("producer_granule_id") or item.get("title") or "")
|
||||
if not raw_id:
|
||||
return None
|
||||
# CMR provides bounding box as "s w n e" strings in the 'boxes' field;
|
||||
# extract the centre as a fallback for display.
|
||||
lat, lon = aoi.center_lat, aoi.center_lon
|
||||
boxes = item.get("boxes")
|
||||
if isinstance(boxes, list) and boxes:
|
||||
parts = str(boxes[0]).split()
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
s, w, n, e = (float(p) for p in parts[:4])
|
||||
lat = (s + n) / 2
|
||||
lon = (w + e) / 2
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
when = _parse_epoch(item.get("time_start") or item.get("updated"))
|
||||
# Preferred browse image for the anomaly link, else the producer URL.
|
||||
prov_url = ""
|
||||
for link in item.get("links") or []:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
rel = str(link.get("rel", ""))
|
||||
if "browse" in rel.lower() or "data#" in rel:
|
||||
prov_url = str(link.get("href") or "")
|
||||
if prov_url:
|
||||
break
|
||||
if not prov_url and item.get("links"):
|
||||
first = item["links"][0] if isinstance(item["links"][0], dict) else {}
|
||||
prov_url = str(first.get("href") or "")
|
||||
payload = {"raw_id": raw_id, "dataset": solver, "time": item.get("time_start")}
|
||||
return SarAnomaly(
|
||||
anomaly_id=make_anomaly_id(solver, raw_id, lat, lon),
|
||||
kind=kind,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
magnitude=0.0,
|
||||
magnitude_unit="",
|
||||
confidence=0.8,
|
||||
first_seen=when,
|
||||
last_seen=when,
|
||||
aoi_id=aoi.id,
|
||||
scene_count=1,
|
||||
solver=solver,
|
||||
source_constellation="Sentinel-1" if "S1" in solver else "HLS",
|
||||
provenance_url=prov_url,
|
||||
category=aoi.category,
|
||||
title=f"{solver}: {summary}",
|
||||
summary=summary,
|
||||
evidence_hash=evidence_hash_for_payload(payload),
|
||||
extras={"raw_id": raw_id, "dataset": solver, "cmr_id": str(item.get("id", ""))},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Copernicus EGMS (EU only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_egms_for_aoi(aoi: SarAoi) -> list[SarAnomaly]:
|
||||
"""Pull EGMS deformation products if a Copernicus token is configured.
|
||||
|
||||
EGMS only covers Europe, so AOIs outside that bbox return [] without
|
||||
a network call.
|
||||
"""
|
||||
if not copernicus_token():
|
||||
logger.debug("EGMS: skipping AOI %s — no Copernicus token", aoi.id)
|
||||
return []
|
||||
if not _aoi_in_europe(aoi):
|
||||
return []
|
||||
# EGMS download API requires per-product manifests; for v1 we emit a
|
||||
# single anomaly that points to the EGMS portal so users get a
|
||||
# direct link. Real product ingestion can come later.
|
||||
payload = {"provider": "EGMS", "aoi": aoi.id}
|
||||
url = f"https://egms.land.copernicus.eu/insar-api/?aoi={aoi.id}"
|
||||
return [
|
||||
SarAnomaly(
|
||||
anomaly_id=make_anomaly_id("EGMS", aoi.id, aoi.center_lat, aoi.center_lon),
|
||||
kind="ground_deformation",
|
||||
lat=aoi.center_lat,
|
||||
lon=aoi.center_lon,
|
||||
magnitude=0.0,
|
||||
magnitude_unit="mm/yr",
|
||||
confidence=0.7,
|
||||
first_seen=now_epoch(),
|
||||
last_seen=now_epoch(),
|
||||
aoi_id=aoi.id,
|
||||
scene_count=0,
|
||||
solver="EGMS",
|
||||
source_constellation="Sentinel-1",
|
||||
provenance_url=url,
|
||||
category=aoi.category,
|
||||
title=f"EGMS coverage available for {aoi.name}",
|
||||
summary=(
|
||||
"European Ground Motion Service has InSAR-derived deformation "
|
||||
"velocity for this AOI. Open the provenance URL to view the map."
|
||||
),
|
||||
evidence_hash=evidence_hash_for_payload(payload),
|
||||
extras={"egms_aoi": aoi.id},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _aoi_in_europe(aoi: SarAoi) -> bool:
|
||||
return -25 <= aoi.center_lon <= 45 and 34 <= aoi.center_lat <= 72
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global Flood Monitoring (GFM) — daily Sentinel-1 flood polygons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_gfm_for_aoi(aoi: SarAoi, lookback_days: int = 7) -> list[SarAnomaly]:
|
||||
"""GFM — disabled: requires openEO client + OIDC auth, not plain REST.
|
||||
|
||||
Copernicus GFM does not expose a plain public REST endpoint; the only
|
||||
supported programmatic access is via the openEO Python client with
|
||||
OIDC auth against openeo.cloud. That is a full integration (Python
|
||||
library, OIDC token refresh, collection loading) and is deliberately
|
||||
not attempted here — fetching it on every cycle with a guessed URL
|
||||
just burned time and polluted logs. Flood coverage comes from OPERA
|
||||
DSWx-S1 (NASA CMR) which is already integrated above.
|
||||
"""
|
||||
global _GFM_DISABLED_HINT_LOGGED
|
||||
if not _GFM_DISABLED_HINT_LOGGED:
|
||||
_GFM_DISABLED_HINT_LOGGED = True
|
||||
logger.info(
|
||||
"SAR GFM provider disabled — requires openEO client + OIDC auth. "
|
||||
"Flood detection falls back to OPERA DSWx-S1 via NASA CMR."
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Copernicus EMS Rapid Mapping (active disaster activations)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EMS_CACHE: dict[str, Any] = {"fetched_at": 0, "activations": []}
|
||||
_EMS_CACHE_TTL_S = 900 # 15 minutes — activation list rarely changes
|
||||
|
||||
|
||||
def _fetch_ems_activations() -> list[dict[str, Any]]:
|
||||
"""Fetch (and cache) the EMS rapid-mapping activation list.
|
||||
|
||||
The dashboard API is paginated; we pull the first 200 results sorted
|
||||
by activation time. Result is cached for ~15 minutes so every AOI
|
||||
call in the same cycle shares one network round-trip.
|
||||
"""
|
||||
import time as _time
|
||||
now = int(_time.time())
|
||||
if now - int(_EMS_CACHE.get("fetched_at", 0)) < _EMS_CACHE_TTL_S:
|
||||
return list(_EMS_CACHE.get("activations", []))
|
||||
|
||||
url = f"{EMS_ACTIVATIONS_URL}?limit=200&offset=0"
|
||||
try:
|
||||
resp = fetch_with_curl(url, timeout=20)
|
||||
except (ConnectionError, TimeoutError, OSError) as exc:
|
||||
logger.debug("EMS activation list fetch failed: %s", exc)
|
||||
return list(_EMS_CACHE.get("activations", []))
|
||||
if resp.status_code != 200:
|
||||
logger.debug("EMS activation list → HTTP %s", resp.status_code)
|
||||
return list(_EMS_CACHE.get("activations", []))
|
||||
try:
|
||||
body = resp.json()
|
||||
except (ValueError, KeyError):
|
||||
return list(_EMS_CACHE.get("activations", []))
|
||||
results = body.get("results") if isinstance(body, dict) else None
|
||||
if not isinstance(results, list):
|
||||
return []
|
||||
_EMS_CACHE["fetched_at"] = now
|
||||
_EMS_CACHE["activations"] = results
|
||||
return results
|
||||
|
||||
|
||||
def _parse_centroid_wkt(wkt: str) -> tuple[float, float] | None:
|
||||
"""Parse 'POINT (lon lat)' into (lat, lon)."""
|
||||
if not wkt or not isinstance(wkt, str):
|
||||
return None
|
||||
s = wkt.strip()
|
||||
if not s.upper().startswith("POINT"):
|
||||
return None
|
||||
try:
|
||||
body = s[s.index("(") + 1 : s.rindex(")")]
|
||||
parts = body.split()
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return (float(parts[1]), float(parts[0]))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_ems_for_aoi(aoi: SarAoi, lookback_days: int = 30) -> list[SarAnomaly]:
|
||||
"""Pull recent EMS rapid-mapping activations near the AOI.
|
||||
|
||||
Uses the new Copernicus EMS Rapid Mapping dashboard API which returns
|
||||
activations with a ``centroid`` WKT point, ISO ``eventTime``,
|
||||
``category``, and ``code``. Result is filtered to activations whose
|
||||
centroid lies within the AOI radius and within the lookback window.
|
||||
"""
|
||||
activations = _fetch_ems_activations()
|
||||
if not activations:
|
||||
return []
|
||||
cutoff = now_epoch() - lookback_days * 86400
|
||||
out: list[SarAnomaly] = []
|
||||
for item in activations:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
coords = _parse_centroid_wkt(str(item.get("centroid", "")))
|
||||
if not coords:
|
||||
continue
|
||||
lat, lon = coords
|
||||
if not point_in_aoi(lat, lon, aoi):
|
||||
continue
|
||||
when = _parse_epoch(item.get("eventTime") or item.get("activationTime"))
|
||||
if when < cutoff:
|
||||
continue
|
||||
code = str(item.get("code") or "")
|
||||
name = str(item.get("name") or f"EMS activation {code}")
|
||||
category = str(item.get("category") or "").lower()
|
||||
countries = item.get("countries") or []
|
||||
country_str = ", ".join(countries) if isinstance(countries, list) else ""
|
||||
payload = {"raw": item, "provider": "EMS"}
|
||||
out.append(
|
||||
SarAnomaly(
|
||||
anomaly_id=make_anomaly_id("EMS", code, lat, lon),
|
||||
kind="damage_assessment" if "damage" not in category else category.replace(" ", "_"),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
magnitude=_safe_float(item.get("n_products")),
|
||||
magnitude_unit="products",
|
||||
confidence=0.95,
|
||||
first_seen=when,
|
||||
last_seen=_parse_epoch(item.get("lastUpdate") or item.get("activationTime")),
|
||||
aoi_id=aoi.id,
|
||||
scene_count=int(item.get("n_aois") or 0),
|
||||
solver="EMS",
|
||||
source_constellation="multi",
|
||||
provenance_url=f"https://rapidmapping.emergency.copernicus.eu/activation/{code}",
|
||||
category=aoi.category,
|
||||
title=name,
|
||||
summary=(
|
||||
f"Copernicus EMS {category or 'activation'} {code} "
|
||||
f"({country_str})." if country_str else
|
||||
f"Copernicus EMS {category or 'activation'} {code}."
|
||||
),
|
||||
evidence_hash=evidence_hash_for_payload(payload),
|
||||
extras={"code": code, "countries": list(countries) if isinstance(countries, list) else []},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UNOSAT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# UNOSAT publishes through the Humanitarian Data Exchange (HDX) using a
|
||||
# standard CKAN API. Country-level filtering is possible via the
|
||||
# package metadata so we can match AOIs by ISO-3166 country name or
|
||||
# bounding box when present.
|
||||
_UNOSAT_CACHE: dict[str, Any] = {"fetched_at": 0, "packages": []}
|
||||
_UNOSAT_CACHE_TTL_S = 1800 # 30 min — UNOSAT publishes infrequently
|
||||
|
||||
# AOI → list of country names UNOSAT uses on HDX. Kept deliberately small;
|
||||
# expand as new AOIs are added. If the AOI id isn't in this map, UNOSAT
|
||||
# falls back to country-agnostic match (spatial is not exposed by HDX).
|
||||
_AOI_COUNTRY_HINTS: dict[str, tuple[str, ...]] = {
|
||||
"kyiv_metro": ("Ukraine",),
|
||||
"gaza_strip": ("State of Palestine", "Palestine", "Israel"),
|
||||
"taiwan_strait": ("Taiwan (Province of China)", "Taiwan"),
|
||||
"san_andreas_central": ("United States of America", "United States"),
|
||||
"three_gorges_dam": ("China",),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_unosat_packages() -> list[dict[str, Any]]:
|
||||
"""Fetch (and cache) recent UNOSAT packages from HDX."""
|
||||
import time as _time
|
||||
now = int(_time.time())
|
||||
if now - int(_UNOSAT_CACHE.get("fetched_at", 0)) < _UNOSAT_CACHE_TTL_S:
|
||||
return list(_UNOSAT_CACHE.get("packages", []))
|
||||
|
||||
url = (
|
||||
f"{UNOSAT_HDX_SEARCH_URL}?q=organization:unosat&rows=50&sort=metadata_modified+desc"
|
||||
)
|
||||
# HDX CKAN returns 406 without explicit Accept + a browser-ish UA.
|
||||
hdx_headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; ShadowBroker-SAR/1.0)",
|
||||
}
|
||||
try:
|
||||
resp = fetch_with_curl(url, timeout=20, headers=hdx_headers)
|
||||
except (ConnectionError, TimeoutError, OSError) as exc:
|
||||
logger.debug("UNOSAT HDX fetch failed: %s", exc)
|
||||
return list(_UNOSAT_CACHE.get("packages", []))
|
||||
if resp.status_code != 200:
|
||||
return list(_UNOSAT_CACHE.get("packages", []))
|
||||
try:
|
||||
body = resp.json()
|
||||
except (ValueError, KeyError):
|
||||
return list(_UNOSAT_CACHE.get("packages", []))
|
||||
result = body.get("result") if isinstance(body, dict) else None
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
packages = result.get("results")
|
||||
if not isinstance(packages, list):
|
||||
return []
|
||||
_UNOSAT_CACHE["fetched_at"] = now
|
||||
_UNOSAT_CACHE["packages"] = packages
|
||||
return packages
|
||||
|
||||
|
||||
def _package_countries(pkg: dict[str, Any]) -> list[str]:
|
||||
"""Extract country names from an HDX package."""
|
||||
# HDX encodes countries as a group list, plus 'solr_additions' JSON string.
|
||||
out: list[str] = []
|
||||
for group in pkg.get("groups") or []:
|
||||
if isinstance(group, dict):
|
||||
name = group.get("display_name") or group.get("title") or group.get("name")
|
||||
if name:
|
||||
out.append(str(name))
|
||||
# solr_additions is a JSON string like '{"countries": ["Mozambique"]}'
|
||||
solr = pkg.get("solr_additions")
|
||||
if isinstance(solr, str) and solr:
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(solr)
|
||||
for c in parsed.get("countries", []):
|
||||
if c and c not in out:
|
||||
out.append(str(c))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def fetch_unosat_for_aoi(aoi: SarAoi, lookback_days: int = 30) -> list[SarAnomaly]:
|
||||
"""Pull UNOSAT damage assessments for this AOI from HDX CKAN.
|
||||
|
||||
HDX doesn't expose precise coordinates, so we filter by country name
|
||||
using ``_AOI_COUNTRY_HINTS``. AOIs without a country hint get no
|
||||
UNOSAT data — this is intentional; false-positive country matches
|
||||
would be worse than silence.
|
||||
"""
|
||||
hints = _AOI_COUNTRY_HINTS.get(aoi.id)
|
||||
if not hints:
|
||||
return []
|
||||
packages = _fetch_unosat_packages()
|
||||
if not packages:
|
||||
return []
|
||||
cutoff = now_epoch() - lookback_days * 86400
|
||||
out: list[SarAnomaly] = []
|
||||
for pkg in packages:
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
countries = _package_countries(pkg)
|
||||
if not any(h in countries for h in hints):
|
||||
continue
|
||||
when = _parse_epoch(pkg.get("metadata_modified") or pkg.get("metadata_created"))
|
||||
if when < cutoff:
|
||||
continue
|
||||
product_id = str(pkg.get("id") or pkg.get("name") or "")
|
||||
title = str(pkg.get("title") or "UNOSAT damage assessment")
|
||||
notes = str(pkg.get("notes") or "")[:400]
|
||||
payload = {"raw_id": product_id, "provider": "UNOSAT", "countries": countries}
|
||||
out.append(
|
||||
SarAnomaly(
|
||||
anomaly_id=make_anomaly_id("UNOSAT", product_id, aoi.center_lat, aoi.center_lon),
|
||||
kind="damage_assessment",
|
||||
lat=aoi.center_lat,
|
||||
lon=aoi.center_lon,
|
||||
magnitude=0.0,
|
||||
magnitude_unit="",
|
||||
confidence=0.9,
|
||||
first_seen=when,
|
||||
last_seen=when,
|
||||
aoi_id=aoi.id,
|
||||
scene_count=0,
|
||||
solver="UNOSAT",
|
||||
source_constellation="multi",
|
||||
provenance_url=f"https://data.humdata.org/dataset/{pkg.get('name', '')}",
|
||||
category=aoi.category,
|
||||
title=title,
|
||||
summary=notes or "UNOSAT satellite analysis published via HDX.",
|
||||
evidence_hash=evidence_hash_for_payload(payload),
|
||||
extras={"hdx_id": product_id, "countries": countries},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared low-level utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _url_encode(value: str) -> str:
|
||||
safe = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~()")
|
||||
out: list[str] = []
|
||||
for ch in str(value):
|
||||
if ch in safe:
|
||||
out.append(ch)
|
||||
elif ch == " ":
|
||||
out.append("%20")
|
||||
else:
|
||||
out.append("".join(f"%{b:02X}" for b in ch.encode("utf-8")))
|
||||
return "".join(out)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""SAR anomaly mesh signing.
|
||||
|
||||
When the local node is at a high enough trust tier, every SAR anomaly
|
||||
emitted by the layer is wrapped in a signed mesh event so other nodes
|
||||
can verify the publisher and the evidence_hash lineage.
|
||||
|
||||
This reuses the existing signing pipeline rather than inventing a new
|
||||
one — the audit identified domain-separated signing as load-bearing for
|
||||
the rest of the mesh, and the SAR layer is required to honor that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from services.sar.sar_config import require_private_tier_for_publish
|
||||
from services.sar.sar_normalize import SarAnomaly
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_PRIVATE_TIERS = {"private_transitional", "private_strong"}
|
||||
|
||||
|
||||
def _current_transport_tier() -> str:
|
||||
try:
|
||||
from services.wormhole_supervisor import get_transport_tier
|
||||
return str(get_transport_tier() or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def can_publish_signed(anomaly: SarAnomaly | None = None) -> tuple[bool, str]:
|
||||
"""Check whether the local node may publish a signed SAR anomaly.
|
||||
|
||||
Returns ``(allowed, reason)``. Caller decides whether to skip the
|
||||
publish entirely or fall back to a local-only write.
|
||||
"""
|
||||
if not require_private_tier_for_publish():
|
||||
return True, "tier gate disabled"
|
||||
tier = _current_transport_tier()
|
||||
if tier in _PRIVATE_TIERS:
|
||||
return True, f"tier={tier}"
|
||||
return False, (
|
||||
f"tier={tier or 'unknown'} — SAR anomalies require private_transitional "
|
||||
f"or higher to be signed and broadcast"
|
||||
)
|
||||
|
||||
|
||||
def build_signed_payload(anomaly: SarAnomaly) -> dict[str, Any]:
|
||||
"""Build the canonical payload that goes into the signed event body.
|
||||
|
||||
The shape mirrors normalize_sar_anomaly_payload in mesh_protocol so
|
||||
the verifier sees exactly what the signer signed.
|
||||
"""
|
||||
return {
|
||||
"anomaly_id": anomaly.anomaly_id,
|
||||
"kind": anomaly.kind,
|
||||
"lat": anomaly.lat,
|
||||
"lon": anomaly.lon,
|
||||
"magnitude": anomaly.magnitude,
|
||||
"magnitude_unit": anomaly.magnitude_unit,
|
||||
"confidence": anomaly.confidence,
|
||||
"first_seen": anomaly.first_seen,
|
||||
"last_seen": anomaly.last_seen,
|
||||
"stack_id": anomaly.aoi_id,
|
||||
"scene_count": anomaly.scene_count,
|
||||
"evidence_hash": anomaly.evidence_hash,
|
||||
"solver": anomaly.solver,
|
||||
"source_constellation": anomaly.source_constellation,
|
||||
}
|
||||
|
||||
|
||||
def emit_signed_anomaly(anomaly: SarAnomaly) -> dict[str, Any]:
|
||||
"""Best-effort signed-event emission for a SAR anomaly.
|
||||
|
||||
Falls back gracefully when the mesh signing infrastructure is not
|
||||
available — the layer never fails just because the mesh is offline.
|
||||
Returns a status dict for diagnostics.
|
||||
"""
|
||||
allowed, reason = can_publish_signed(anomaly)
|
||||
if not allowed:
|
||||
return {"signed": False, "reason": reason}
|
||||
|
||||
payload = build_signed_payload(anomaly)
|
||||
try:
|
||||
from services.mesh.mesh_protocol import normalize_payload
|
||||
normalized = normalize_payload("sar_anomaly", payload)
|
||||
except Exception as exc:
|
||||
logger.debug("SAR signed publish failed at normalize: %s", exc)
|
||||
return {"signed": False, "reason": f"normalize_failed:{exc}"}
|
||||
|
||||
# Sign + hashchain via the same path the rest of the mesh uses. We
|
||||
# do this lazily so a node without mesh infra can still run the SAR
|
||||
# layer in local-only mode.
|
||||
try:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
from services.mesh.mesh_wormhole_persona import sign_root_wormhole_event
|
||||
|
||||
signed = sign_root_wormhole_event(
|
||||
event_type="sar_anomaly",
|
||||
payload=normalized,
|
||||
)
|
||||
if signed:
|
||||
try:
|
||||
infonet.append_signed_event(signed)
|
||||
except Exception:
|
||||
# append is best-effort; the local layer still has the data.
|
||||
pass
|
||||
return {
|
||||
"signed": True,
|
||||
"reason": reason,
|
||||
"node_id": signed.get("node_id", ""),
|
||||
"sequence": signed.get("sequence", 0),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("SAR signed publish failed at sign: %s", exc)
|
||||
return {"signed": False, "reason": f"sign_failed:{exc}"}
|
||||
Reference in New Issue
Block a user