release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:56:50 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187059 additions and 14005 deletions
+29
View File
@@ -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,
)
+188
View File
@@ -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
+174
View File
@@ -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,
}
+297
View File
@@ -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)
+141
View File
@@ -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())
+561
View File
@@ -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)
+119
View File
@@ -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}"}