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
+96 -11
View File
@@ -4,6 +4,7 @@ Central location for latest_data, source_timestamps, and the data lock.
Every fetcher imports from here instead of maintaining its own copy.
"""
import copy
import threading
import logging
from datetime import datetime
@@ -42,6 +43,7 @@ class DashboardData(TypedDict, total=False):
gps_jamming: List[Dict[str, Any]]
satellites: List[Dict[str, Any]]
satellite_source: str
satellite_analysis: Dict[str, Any]
prediction_markets: List[Dict[str, Any]]
sigint: List[Dict[str, Any]]
sigint_totals: Dict[str, Any]
@@ -61,6 +63,12 @@ class DashboardData(TypedDict, total=False):
fimi: Dict[str, Any]
psk_reporter: List[Dict[str, Any]]
correlations: List[Dict[str, Any]]
uap_sightings: List[Dict[str, Any]]
wastewater: List[Dict[str, Any]]
crowdthreat: List[Dict[str, Any]]
sar_scenes: List[Dict[str, Any]]
sar_anomalies: List[Dict[str, Any]]
sar_aoi_coverage: List[Dict[str, Any]]
# In-memory store
@@ -105,6 +113,12 @@ latest_data: DashboardData = {
"fimi": {},
"psk_reporter": [],
"correlations": [],
"uap_sightings": [],
"wastewater": [],
"crowdthreat": [],
"sar_scenes": [],
"sar_anomalies": [],
"sar_aoi_coverage": [],
}
# Per-source freshness timestamps
@@ -117,9 +131,21 @@ source_freshness: dict[str, dict] = {}
def _mark_fresh(*keys):
"""Record the current UTC time for one or more data source keys."""
now = datetime.utcnow().isoformat()
global _data_version
changed: list[tuple[str, int, int]] = [] # (layer, version, count)
with _data_lock:
for k in keys:
source_timestamps[k] = now
_layer_versions[k] = _layer_versions.get(k, 0) + 1
# Grab entity count while we hold the lock (cheap len())
val = latest_data.get(k)
count = len(val) if isinstance(val, list) else (1 if val is not None else 0)
changed.append((k, _layer_versions[k], count))
# Publish partial fetch progress immediately so the frontend can
# observe newly available data without waiting for the entire tier.
_data_version += 1
# Notify SSE listeners outside the lock to avoid deadlocks
_notify_layer_change(changed)
# Thread lock for safe reads/writes to latest_data
@@ -129,16 +155,73 @@ _data_lock = threading.Lock()
# Used for cheap ETag generation instead of MD5-hashing the full response.
_data_version: int = 0
# Per-layer version counters — incremented only when that specific layer
# refreshes. Used by get_layer_slice for per-layer incremental updates
# and by the SSE stream to push targeted layer_changed notifications.
_layer_versions: dict[str, int] = {}
# ---------------------------------------------------------------------------
# Layer-change notification callbacks (thread → async SSE bridge)
# ---------------------------------------------------------------------------
_layer_change_callbacks: list = []
_layer_change_callbacks_lock = threading.Lock()
def register_layer_change_callback(callback) -> None:
"""Register a callback invoked on every _mark_fresh().
Signature: callback(layer: str, version: int, count: int)
Called from fetcher threads — must be thread-safe.
"""
with _layer_change_callbacks_lock:
_layer_change_callbacks.append(callback)
def unregister_layer_change_callback(callback) -> None:
"""Remove a previously registered callback."""
with _layer_change_callbacks_lock:
try:
_layer_change_callbacks.remove(callback)
except ValueError:
pass
def _notify_layer_change(changed: list[tuple[str, int, int]]) -> None:
"""Fire all registered callbacks for each changed layer."""
with _layer_change_callbacks_lock:
cbs = list(_layer_change_callbacks)
for cb in cbs:
for layer, version, count in changed:
try:
cb(layer, version, count)
except Exception:
pass
def get_layer_versions() -> dict[str, int]:
"""Return a snapshot of all per-layer version counters."""
with _data_lock:
return dict(_layer_versions)
def get_layer_version(layer: str) -> int:
"""Return the version counter for a single layer (0 if never refreshed)."""
with _data_lock:
return _layer_versions.get(layer, 0)
def bump_data_version() -> None:
"""Increment the data version counter after a fetch cycle completes."""
global _data_version
_data_version += 1
with _data_lock:
_data_version += 1
def get_data_version() -> int:
"""Return the current data version (for ETag generation)."""
return _data_version
with _data_lock:
return _data_version
_active_layers_version: int = 0
@@ -156,21 +239,17 @@ def get_active_layers_version() -> int:
def get_latest_data_subset(*keys: str) -> DashboardData:
"""Return a shallow snapshot of only the requested top-level keys.
"""Return a deep snapshot of only the requested top-level keys.
This avoids cloning the entire dashboard store for endpoints that only need
a small tier-specific subset.
a small tier-specific subset. Deep copy ensures callers cannot mutate
nested structures (e.g. individual flight dicts) and affect the live store.
"""
with _data_lock:
snap: DashboardData = {}
for key in keys:
value = latest_data.get(key)
if isinstance(value, list):
snap[key] = list(value)
elif isinstance(value, dict):
snap[key] = dict(value)
else:
snap[key] = value
snap[key] = copy.deepcopy(value)
return snap
@@ -231,10 +310,16 @@ active_layers: dict[str, bool] = {
"satnogs": True,
"tinygs": True,
"ukraine_alerts": True,
"power_plants": False,
"power_plants": True,
"viirs_nightlights": False,
"psk_reporter": True,
"correlations": True,
"contradictions": True,
"uap_sightings": True,
"wastewater": True,
"ai_intel": True,
"crowdthreat": True,
"sar": True,
}
@@ -0,0 +1,177 @@
"""OpenSky aircraft metadata: ICAO24 hex -> ICAO type code + friendly model.
OpenSky's /states/all does not include aircraft type, so OpenSky-sourced
flights arrive with ``t`` field empty. This module bulk-loads the public
OpenSky aircraft database (one snapshot CSV per month, ~108 MB uncompressed,
~600k aircraft) once every 5 days and exposes a fast in-memory hex lookup.
The data is also useful when adsb.lol's live API is degraded: even the
adsb.lol /v2 feed sometimes returns aircraft with empty ``t`` for newly seen
transponders, and the lookup gracefully fills those in too.
"""
from __future__ import annotations
import csv
import logging
import threading
import time
import xml.etree.ElementTree as ET
from typing import Any
import requests
logger = logging.getLogger(__name__)
_BUCKET_LIST_URL = (
"https://s3.opensky-network.org/data-samples?prefix=metadata/&list-type=2"
)
_BUCKET_BASE = "https://s3.opensky-network.org/data-samples/"
_S3_NS = "{http://s3.amazonaws.com/doc/2006-03-01/}"
_REFRESH_INTERVAL_S = 5 * 24 * 3600
_LIST_TIMEOUT_S = 30
_DOWNLOAD_TIMEOUT_S = 600
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.7 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
_lock = threading.RLock()
_aircraft_by_hex: dict[str, dict[str, str]] = {}
_last_refresh = 0.0
_in_progress = False
def _latest_snapshot_key() -> str:
"""Discover the most recent aircraft-database-complete snapshot key."""
response = requests.get(
_BUCKET_LIST_URL,
timeout=_LIST_TIMEOUT_S,
headers={"User-Agent": _USER_AGENT},
)
response.raise_for_status()
root = ET.fromstring(response.text)
keys: list[str] = []
for content in root.iter(f"{_S3_NS}Contents"):
key_el = content.find(f"{_S3_NS}Key")
if key_el is None or not key_el.text:
continue
if "aircraft-database-complete-" in key_el.text and key_el.text.endswith(".csv"):
keys.append(key_el.text)
if not keys:
raise RuntimeError("no aircraft-database-complete snapshot found in bucket listing")
return sorted(keys)[-1]
def _stream_csv_index(url: str) -> dict[str, dict[str, str]]:
"""Stream-parse the OpenSky aircraft CSV into a hex-keyed index.
The CSV uses single-quote quoting, so csv.DictReader is configured with
``quotechar="'"``. Rows are processed line-by-line via iter_lines() to
keep memory bounded even though the file is ~108 MB.
"""
with requests.get(
url,
timeout=_DOWNLOAD_TIMEOUT_S,
stream=True,
headers={"User-Agent": _USER_AGENT},
) as response:
response.raise_for_status()
line_iter = (
line.decode("utf-8", errors="replace")
for line in response.iter_lines(decode_unicode=False)
if line
)
reader = csv.DictReader(line_iter, quotechar="'")
index: dict[str, dict[str, str]] = {}
for row in reader:
hex_code = (row.get("icao24") or "").strip().lower()
if not hex_code or hex_code == "000000":
continue
typecode = (row.get("typecode") or "").strip().upper()
model = (row.get("model") or "").strip()
mfr = (row.get("manufacturerName") or "").strip()
registration = (row.get("registration") or "").strip().upper()
operator = (row.get("operator") or "").strip()
if not (typecode or model):
continue
entry: dict[str, str] = {}
if typecode:
entry["typecode"] = typecode
if model:
entry["model"] = model
if mfr:
entry["manufacturer"] = mfr
if registration:
entry["registration"] = registration
if operator:
entry["operator"] = operator
index[hex_code] = entry
return index
def refresh_aircraft_database(force: bool = False) -> bool:
"""Download the latest OpenSky aircraft snapshot and rebuild the index.
Returns True if a refresh was performed (success or attempted), False if
skipped because the cache is still fresh or another refresh is in flight.
"""
global _last_refresh, _in_progress
now = time.time()
with _lock:
if _in_progress:
return False
if not force and (now - _last_refresh) < _REFRESH_INTERVAL_S and _aircraft_by_hex:
return False
_in_progress = True
try:
started = time.time()
key = _latest_snapshot_key()
index = _stream_csv_index(_BUCKET_BASE + key)
with _lock:
_aircraft_by_hex.clear()
_aircraft_by_hex.update(index)
_last_refresh = time.time()
logger.info(
"aircraft database refreshed in %.1fs from %s: %d aircraft",
time.time() - started,
key,
len(index),
)
return True
except (requests.RequestException, OSError, ValueError, ET.ParseError) as exc:
logger.warning("aircraft database refresh failed: %s", exc)
return True
finally:
with _lock:
_in_progress = False
def lookup_aircraft(icao24: str) -> dict[str, str] | None:
"""Return the metadata record for an ICAO24 hex code, or None."""
key = (icao24 or "").strip().lower()
if not key:
return None
with _lock:
entry = _aircraft_by_hex.get(key)
return dict(entry) if entry else None
def lookup_aircraft_type(icao24: str) -> str:
"""Return the ICAO type code (e.g. 'B738', 'GLF4') or '' if unknown."""
entry = lookup_aircraft(icao24)
if not entry:
return ""
return entry.get("typecode", "")
def aircraft_database_status() -> dict[str, Any]:
with _lock:
return {
"last_refresh": _last_refresh,
"aircraft": len(_aircraft_by_hex),
"in_progress": _in_progress,
}
+129
View File
@@ -0,0 +1,129 @@
"""CrowdThreat fetcher — crowdsourced global threat intelligence.
Polls verified threat reports from CrowdThreat's public API and normalises
them into map-ready records with category-based icon IDs.
No API key required — the /threats endpoint is unauthenticated.
"""
import logging
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh, is_any_active
from services.fetchers.retry import with_retry
logger = logging.getLogger("services.data_fetcher")
_CT_BASE = "https://backend.crowdthreat.world"
# CrowdThreat category_id → icon ID used on the MapLibre layer
_CATEGORY_ICON = {
1: "ct-security", # Security & Conflict (red)
2: "ct-crime", # Crime & Safety (blue)
3: "ct-aviation", # Aviation (green)
4: "ct-maritime", # Maritime (teal)
5: "ct-infrastructure", # Industrial & Infra (orange)
6: "ct-special", # Special Threats (purple)
7: "ct-social", # Social & Political (pink)
8: "ct-other", # Other (gray)
}
_CATEGORY_COLOUR = {
1: "#ef4444", # red
2: "#3b82f6", # blue
3: "#22c55e", # green
4: "#14b8a6", # teal
5: "#f97316", # orange
6: "#a855f7", # purple
7: "#ec4899", # pink
8: "#6b7280", # gray
}
@with_retry(max_retries=2, base_delay=5)
def fetch_crowdthreat():
"""Fetch verified threat reports from CrowdThreat public API."""
if not is_any_active("crowdthreat"):
return
try:
resp = fetch_with_curl(f"{_CT_BASE}/threats", timeout=20)
if not resp or resp.status_code != 200:
logger.warning("CrowdThreat API returned %s", getattr(resp, "status_code", "None"))
return
payload = resp.json()
raw_threats = payload.get("data", {}).get("threats", [])
if not raw_threats:
logger.debug("CrowdThreat returned 0 threats")
return
except Exception as e:
logger.error("CrowdThreat fetch error: %s", e)
return
processed = []
for t in raw_threats:
loc = t.get("location") or {}
lng_lat = loc.get("lng_lat")
if not lng_lat or len(lng_lat) < 2:
continue
try:
lng = float(lng_lat[0])
lat = float(lng_lat[1])
except (TypeError, ValueError):
continue
cat = t.get("category") or {}
cat_id = cat.get("id", 8)
subcat = t.get("subcategory") or {}
threat_type = t.get("type") or {}
dates = t.get("dates") or {}
occurred = dates.get("occurred") or {}
reported = dates.get("reported") or {}
# Extract all available detail from the API response
summary = (t.get("summary") or t.get("description") or "").strip()
verification = (t.get("verification_status") or t.get("status") or "").strip()
country_obj = loc.get("country") or {}
country = country_obj.get("name", "") if isinstance(country_obj, dict) else str(country_obj or "")
media = t.get("media") or t.get("images") or t.get("attachments") or []
source_url = t.get("source_url") or t.get("url") or t.get("link") or ""
severity = t.get("severity") or t.get("severity_level") or t.get("risk_level") or ""
votes = t.get("votes") or t.get("upvotes") or 0
reporter = t.get("user") or t.get("reporter") or {}
reporter_name = reporter.get("name", "") if isinstance(reporter, dict) else ""
processed.append({
"id": t.get("id"),
"title": t.get("title", ""),
"summary": summary[:500] if summary else "",
"lat": lat,
"lng": lng,
"address": loc.get("name", ""),
"city": loc.get("city", ""),
"country": country,
"category": cat.get("name", "Other"),
"category_id": cat_id,
"category_colour": _CATEGORY_COLOUR.get(cat_id, "#6b7280"),
"subcategory": subcat.get("name", ""),
"threat_type": threat_type.get("name", ""),
"icon_id": _CATEGORY_ICON.get(cat_id, "ct-other"),
"occurred": occurred.get("raw", ""),
"occurred_iso": occurred.get("iso", ""),
"timeago": occurred.get("timeago", ""),
"reported": reported.get("raw", ""),
"verification": verification,
"severity": str(severity),
"source_url": source_url,
"media_urls": [m.get("url") or m for m in media[:3]] if isinstance(media, list) else [],
"votes": int(votes) if votes else 0,
"reporter": reporter_name,
"source": "CrowdThreat",
})
logger.info("CrowdThreat: fetched %d verified threats", len(processed))
with _data_lock:
latest_data["crowdthreat"] = processed
_mark_fresh("crowdthreat")
+855 -1
View File
@@ -1,14 +1,19 @@
"""Earth-observation fetchers — earthquakes, FIRMS fires, space weather, weather radar,
severe weather alerts, air quality, volcanoes."""
import concurrent.futures
import csv
import hashlib
import io
import json
import logging
import os
import re
import shutil
import subprocess
import time
import heapq
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
@@ -596,3 +601,852 @@ def fetch_viirs_change_nodes():
if nodes:
_mark_fresh("viirs_change_nodes")
logger.info(f"VIIRS change nodes: {len(nodes)} nodes from {len(_VIIRS_AOIS)} AOIs")
# ---------------------------------------------------------------------------
# UAP Sightings (NUFORC — National UAP Reporting Center)
# ---------------------------------------------------------------------------
# Shape → canonical category mapping for consistent frontend filtering
_UAP_SHAPE_MAP = {
"light": "light", "fireball": "fireball", "orb": "orb",
"sphere": "orb", "circle": "orb", "oval": "orb", "egg": "orb",
"triangle": "triangle", "delta": "triangle", "chevron": "triangle",
"boomerang": "triangle",
"cigar": "cigar", "cylinder": "cigar", "tube": "cigar",
"disk": "disk", "disc": "disk", "saucer": "disk",
"diamond": "diamond", "cone": "diamond", "cross": "diamond",
"rectangle": "rectangle", "square": "rectangle",
"formation": "formation", "cluster": "formation",
"changing": "changing", "flash": "flash", "star": "light",
"tic-tac": "tic-tac", "tic tac": "tic-tac",
}
# US state → approximate centroid for coarse geocoding when city lookup fails
_US_STATE_COORDS: dict[str, tuple[float, float]] = {
"AL": (32.8, -86.8), "AK": (64.2, -152.5), "AZ": (34.0, -111.1),
"AR": (35.2, -91.8), "CA": (36.8, -119.4), "CO": (39.6, -105.3),
"CT": (41.6, -72.7), "DE": (39.3, -75.5), "FL": (27.8, -81.8),
"GA": (32.7, -83.5), "HI": (19.9, -155.6), "ID": (44.1, -114.7),
"IL": (40.3, -89.0), "IN": (40.3, -86.1), "IA": (42.0, -93.2),
"KS": (39.0, -98.5), "KY": (37.8, -84.3), "LA": (31.2, -92.5),
"ME": (45.3, -69.4), "MD": (39.0, -76.6), "MA": (42.4, -71.4),
"MI": (44.3, -85.6), "MN": (46.7, -94.7), "MS": (32.7, -89.5),
"MO": (38.6, -91.8), "MT": (46.8, -110.4), "NE": (41.5, -99.9),
"NV": (38.8, -116.4), "NH": (43.2, -71.6), "NJ": (40.1, -74.4),
"NM": (34.5, -106.0), "NY": (43.0, -75.0), "NC": (35.6, -79.8),
"ND": (47.5, -100.5), "OH": (40.4, -82.9), "OK": (35.0, -97.1),
"OR": (43.8, -120.6), "PA": (41.2, -77.2), "RI": (41.6, -71.5),
"SC": (33.8, -81.2), "SD": (43.9, -99.4), "TN": (35.5, -86.6),
"TX": (31.0, -97.6), "UT": (39.3, -111.1), "VT": (44.6, -72.6),
"VA": (37.4, -78.7), "WA": (47.4, -120.7), "WV": (38.6, -80.6),
"WI": (43.8, -88.8), "WY": (43.1, -107.6), "DC": (38.9, -77.0),
}
def _normalize_uap_shape(raw: str) -> str:
"""Normalize a raw NUFORC shape string to a canonical category."""
key = raw.strip().lower()
return _UAP_SHAPE_MAP.get(key, "unknown")
def _reverse_geocode_state(lat: float, lng: float) -> tuple[str, str]:
"""Best-effort reverse-geocode a lat/lng to (state_abbr, country).
Uses the _US_STATE_COORDS centroid table for fast approximate matching.
Returns ('', 'Unknown') if no close match is found.
"""
best_state = ""
best_dist = 999.0
for st, (slat, slng) in _US_STATE_COORDS.items():
d = ((lat - slat) ** 2 + (lng - slng) ** 2) ** 0.5
if d < best_dist:
best_dist = d
best_state = st
if best_dist < 5.0: # ~5 degrees tolerance
return best_state, "US"
return "", "Unknown"
# ── NUFORC Mapbox Tilequery API ─────────────────────────────────────────
# NUFORC's website switched to a JS-rendered Mapbox GL map. The old HTML
# table scraper is defunct. We now query the Mapbox Tilequery API against
# NUFORC's public tileset to get precise sighting coordinates.
#
# Tileset: nuforc.cmm18aqea06bu1mmselhpnano-0ce5v
# Layer: Sightings Fields: Count, From, To, LinkLat, LinkLon
#
# We sample a grid of points across the US/world with a 100 km radius and
# filter to sightings within the last 60 days.
_NUFORC_TILESET = "nuforc.cmm18aqea06bu1mmselhpnano-0ce5v"
_NUFORC_TOKEN = os.environ.get("NUFORC_MAPBOX_TOKEN", "").strip()
_NUFORC_RADIUS_M = 200_000 # 200 km query radius
_NUFORC_LIMIT = 50 # max features per tilequery call
_NUFORC_RECENT_DAYS = int(os.environ.get("NUFORC_RECENT_DAYS", "60"))
_NUFORC_GEOCODE_WORKERS = max(1, int(os.environ.get("NUFORC_GEOCODE_WORKERS", "1")))
# Photon (Komoot) is more lenient than Nominatim — ~200ms per query in
# practice, so a 0.3s spacing keeps us well under any soft throttle while
# still rebuilding a full 12-month window in ~10 minutes.
_NUFORC_GEOCODE_SPACING_S = float(os.environ.get("NUFORC_GEOCODE_SPACING_S", "0.3"))
_NUFORC_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
_NUFORC_SIGHTINGS_CACHE_FILE = _NUFORC_DATA_DIR / "nuforc_recent_sightings.json"
_NUFORC_LOCATION_CACHE_FILE = _NUFORC_DATA_DIR / "nuforc_location_cache.json"
# Live NUFORC databank scraping (wpDataTables server-side AJAX).
# The HuggingFace mirror froze at 2023-12-20, so we pull directly from
# nuforc.org's monthly sub-index. Each month page embeds a wdtNonce we
# must extract, then POST to admin-ajax.php to get the DataTables JSON.
_NUFORC_LIVE_INDEX_URL = "https://nuforc.org/subndx/?id=e{yyyymm}"
_NUFORC_LIVE_AJAX_URL = (
"https://nuforc.org/wp-admin/admin-ajax.php"
"?action=get_wdtable&table_id=1&wdt_var1=YearMonth&wdt_var2={yyyymm}"
)
_NUFORC_LIVE_NONCE_RE = re.compile(
r'id=["\']wdtNonceFrontendServerSide_1["\'][^>]*value=["\']([a-f0-9]+)["\']'
)
_NUFORC_LIVE_SIGHTING_ID_RE = re.compile(r"id=(\d+)")
_NUFORC_LIVE_USER_AGENT = "Mozilla/5.0 (ShadowBroker-OSINT NUFORC-fetcher)"
_NUFORC_LIVE_SESSION_COOKIES = _NUFORC_DATA_DIR / "nuforc_session.cookies"
# Sample grid covering continental US, Alaska, Hawaii, Canada, UK, Australia
_TILEQUERY_GRID: list[tuple[float, float]] = [
# Continental US — ~4° spacing (lon, lat)
(-122.4, 37.8), (-118.2, 34.1), (-112.1, 33.4), (-104.9, 39.7),
(-95.4, 29.8), (-96.8, 32.8), (-87.6, 41.9), (-84.4, 33.7),
(-81.7, 41.5), (-80.2, 25.8), (-77.0, 38.9), (-74.0, 40.7),
(-71.1, 42.4), (-90.2, 38.6), (-93.3, 44.9), (-111.9, 40.8),
(-122.7, 45.5), (-86.2, 39.8), (-106.6, 35.1), (-73.9, 43.2),
(-76.6, 39.3), (-97.5, 35.5), (-83.0, 42.3), (-117.2, 32.7),
(-82.5, 28.0), (-78.6, 35.8), (-90.1, 30.0), (-71.4, 41.8),
# Alaska, Hawaii
(-149.9, 61.2), (-155.5, 19.9),
# Canada
(-79.4, 43.7), (-123.1, 49.3), (-73.6, 45.5),
# UK & Europe
(-0.1, 51.5), (-3.2, 55.9),
# Australia
(151.2, -33.9), (144.9, -37.8),
]
def _fetch_nuforc_tilequery(lng: float, lat: float) -> list[dict]:
"""Query NUFORC Mapbox tileset around a single point, return raw features."""
if not _NUFORC_TOKEN:
return []
url = (
f"https://api.mapbox.com/v4/{_NUFORC_TILESET}/tilequery/"
f"{lng},{lat}.json"
f"?radius={_NUFORC_RADIUS_M}&limit={_NUFORC_LIMIT}"
f"&access_token={_NUFORC_TOKEN}"
)
try:
resp = fetch_with_curl(url, timeout=12)
if resp.status_code == 200:
data = resp.json()
return data.get("features", [])
except Exception:
pass
return []
def _parse_nuforc_tile_date(value: str) -> datetime | None:
raw = str(value or "").strip()
if not raw:
return None
raw = raw.replace("T", " ")
raw = re.sub(r"\s+local$", "", raw, flags=re.IGNORECASE)
raw = re.sub(r"\s+utc$", "", raw, flags=re.IGNORECASE)
for fmt in (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
"%m/%d/%Y %H:%M",
"%m/%d/%Y",
):
try:
return datetime.strptime(raw, fmt)
except (TypeError, ValueError):
continue
match = re.match(r"^(\d{4}-\d{2}-\d{2})", raw)
if match:
try:
return datetime.strptime(match.group(1), "%Y-%m-%d")
except ValueError:
return None
return None
def _load_nuforc_sightings_cache(*, force_refresh: bool = False) -> list[dict] | None:
if force_refresh or not _NUFORC_SIGHTINGS_CACHE_FILE.exists():
return None
try:
raw = json.loads(_NUFORC_SIGHTINGS_CACHE_FILE.read_text(encoding="utf-8"))
built = raw.get("built", "")
built_dt = datetime.fromisoformat(built) if built else None
if built_dt is None:
return None
if (datetime.utcnow() - built_dt).total_seconds() > 86400:
return None
sightings = raw.get("sightings")
if isinstance(sightings, list):
if len(sightings) <= 0:
logger.info("UAP sightings: cache is fresh but empty; rebuilding")
return None
logger.info(
"UAP sightings: loaded %d cached reports from %s",
len(sightings),
built,
)
return sightings
except Exception as e:
logger.warning("UAP sightings: cache load error: %s", e)
return None
def _save_nuforc_sightings_cache(sightings: list[dict]) -> None:
if not sightings:
logger.warning("UAP sightings: refusing to save empty daily cache")
return
try:
_NUFORC_DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"built": datetime.utcnow().isoformat(),
"count": len(sightings),
"sightings": sightings,
}
_NUFORC_SIGHTINGS_CACHE_FILE.write_text(
json.dumps(payload, separators=(",", ":")),
encoding="utf-8",
)
except Exception as e:
logger.warning("UAP sightings: cache save error: %s", e)
def _load_nuforc_location_cache() -> dict[str, list[float] | None]:
if not _NUFORC_LOCATION_CACHE_FILE.exists():
return {}
try:
raw = json.loads(_NUFORC_LOCATION_CACHE_FILE.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return {}
cache: dict[str, list[float] | None] = {}
for key, value in raw.items():
if not isinstance(key, str):
continue
if (
isinstance(value, list)
and len(value) == 2
and all(isinstance(v, (int, float)) for v in value)
):
cache[key] = [float(value[0]), float(value[1])]
elif value is None:
cache[key] = None
return cache
except Exception as e:
logger.warning("UAP sightings: location cache load error: %s", e)
return {}
def _save_nuforc_location_cache(cache: dict[str, list[float] | None]) -> None:
try:
_NUFORC_DATA_DIR.mkdir(parents=True, exist_ok=True)
_NUFORC_LOCATION_CACHE_FILE.write_text(
json.dumps(cache, separators=(",", ":")),
encoding="utf-8",
)
except Exception as e:
logger.warning("UAP sightings: location cache save error: %s", e)
def _normalize_uap_location(raw: str) -> str:
return re.sub(r"\s+", " ", str(raw or "").strip())
def _uap_country_from_location(location: str, state: str) -> str:
if state:
return "US"
upper = location.upper()
if "USA" in upper or "UNITED STATES" in upper:
return "US"
parts = [part.strip() for part in location.split(",") if part.strip()]
if not parts:
return "Unknown"
country = parts[-1]
return country.upper() if len(country) == 2 else country
_US_COUNTRY_ALIASES = {
"", "USA", "US", "U.S.", "U.S.A.",
"UNITED STATES", "UNITED STATES OF AMERICA",
}
def _uap_geocode_candidates(
location: str, city: str, state: str, country: str = ""
) -> list[str]:
"""Build geocode query candidates in priority order.
NUFORC's live databank is international, so we must query with the
actual country first. Only when the country is empty or explicitly US
do we fall back to the legacy USA-assumption behavior.
"""
candidates: list[str] = []
c = (country or "").strip()
c_upper = c.upper()
is_us = c_upper in _US_COUNTRY_ALIASES
if not is_us:
# Non-US: try country-qualified queries first to prevent the
# geocoder from fuzzy-matching to a same-named US city.
if city and state:
candidates.append(f"{city}, {state}, {c}")
if city:
candidates.append(f"{city}, {c}")
if city and state:
candidates.append(f"{city}, {state}")
if city:
candidates.append(city)
else:
if city and state:
candidates.append(f"{city}, {state}, USA")
candidates.append(f"{city}, {state}")
if city:
candidates.append(city)
normalized = _normalize_uap_location(location)
if normalized:
candidates.append(normalized)
parts = [part.strip() for part in normalized.split(",") if part.strip()]
if len(parts) >= 2:
candidates.append(", ".join(parts[:2]))
if parts:
candidates.append(parts[0])
deduped: list[str] = []
seen: set[str] = set()
for candidate in candidates:
key = candidate.lower()
if key in seen:
continue
seen.add(key)
deduped.append(candidate)
return deduped
def _photon_lookup(query: str) -> list[float] | None:
"""Query Komoot's public Photon instance (OSM-based, no API key).
Returns [lat, lng] on success, None on any failure. We bypass the
shared search_geocode() helper on purpose: it falls back to an
airport-name token matcher on failure that confidently returns
completely wrong coordinates, which poisoned the cache for years.
"""
from urllib.parse import urlencode
params = urlencode({"q": query, "limit": 1})
url = f"https://photon.komoot.io/api?{params}"
try:
res = fetch_with_curl(
url,
headers={
"User-Agent": "ShadowBroker-OSINT/1.0 (NUFORC-UAP-layer)",
"Accept-Language": "en",
},
timeout=10,
)
except Exception:
return None
if not res or res.status_code != 200:
return None
try:
payload = res.json()
except Exception:
return None
features = (payload or {}).get("features") or []
if not features:
return None
try:
# GeoJSON order is [lng, lat] — flip to our [lat, lng] convention.
coords = features[0]["geometry"]["coordinates"]
return [float(coords[1]), float(coords[0])]
except (KeyError, IndexError, TypeError, ValueError):
return None
def _geocode_uap_location(
location: str, city: str, state: str, country: str = ""
) -> list[float] | None:
"""Resolve a NUFORC sighting location to [lat, lng] via Photon.
Returns None on failure. The caller caches None alongside real hits
so we don't retry unresolvable queries every run.
"""
for query in _uap_geocode_candidates(location, city, state, country):
coords = _photon_lookup(query)
if coords:
return coords
return None
def _build_uap_sighting_id(row: dict, occurred: str, location: str) -> str:
raw_id = str(row.get("Sighting", "") or row.get("sighting", "")).strip()
if raw_id:
return raw_id
digest = hashlib.sha1(
f"{occurred}|{location}|{row.get('Summary', '')}|{row.get('Text', '')}".encode("utf-8", "ignore")
).hexdigest()[:12]
return f"NUFORC-{digest}"
def _nuforc_months_for_window(days: int) -> list[str]:
"""Enumerate YYYYMM strings covering the rolling `days`-day window.
Returned newest first. Always includes the current month even if the
window technically starts later, because new reports land there.
"""
today = datetime.utcnow().date()
start = today - timedelta(days=days)
months: list[str] = []
cur = today.replace(day=1)
start_floor = start.replace(day=1)
while cur >= start_floor:
months.append(cur.strftime("%Y%m"))
if cur.month == 1:
cur = cur.replace(year=cur.year - 1, month=12)
else:
cur = cur.replace(month=cur.month - 1)
return months
def _nuforc_fetch_month_live(yyyymm: str, cookie_jar: Path) -> list[dict]:
"""Pull one month of NUFORC sightings via the live wpDataTables AJAX.
Returns a list of raw row dicts with the fields we care about:
id, occurred (YYYY-MM-DD), posted (YYYY-MM-DD), city, state, country,
shape_raw, summary, explanation. Empty list on any failure — caller
decides whether a failure is fatal.
"""
from services.fetchers.nuforc_enrichment import _parse_date
curl_bin = shutil.which("curl") or "curl"
index_url = _NUFORC_LIVE_INDEX_URL.format(yyyymm=yyyymm)
ajax_url = _NUFORC_LIVE_AJAX_URL.format(yyyymm=yyyymm)
# Step 1: GET the month index to capture session cookies + fresh nonce.
try:
index_res = subprocess.run(
[
curl_bin, "-sL",
"-A", _NUFORC_LIVE_USER_AGENT,
"-c", str(cookie_jar),
"-b", str(cookie_jar),
index_url,
],
capture_output=True, text=True, timeout=60,
encoding="utf-8", errors="replace",
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("NUFORC live: index fetch failed for %s: %s", yyyymm, e)
return []
if index_res.returncode != 0 or not index_res.stdout:
logger.warning(
"NUFORC live: index fetch exit=%s for %s", index_res.returncode, yyyymm,
)
return []
nonce_match = _NUFORC_LIVE_NONCE_RE.search(index_res.stdout)
if not nonce_match:
logger.warning("NUFORC live: wdtNonce not found on index page for %s", yyyymm)
return []
nonce = nonce_match.group(1)
# Step 2: POST to admin-ajax.php with length=-1 to pull the whole month.
post_data = (
"draw=1"
"&columns%5B0%5D%5Bdata%5D=0&columns%5B0%5D%5Bsearchable%5D=true&columns%5B0%5D%5Borderable%5D=false"
"&columns%5B1%5D%5Bdata%5D=1&columns%5B1%5D%5Bsearchable%5D=true&columns%5B1%5D%5Borderable%5D=true"
"&order%5B0%5D%5Bcolumn%5D=1&order%5B0%5D%5Bdir%5D=desc"
"&start=0&length=-1"
"&search%5Bvalue%5D=&search%5Bregex%5D=false"
f"&wdtNonce={nonce}"
)
try:
ajax_res = subprocess.run(
[
curl_bin, "-sL",
"-A", _NUFORC_LIVE_USER_AGENT,
"-c", str(cookie_jar),
"-b", str(cookie_jar),
"-X", "POST",
"-H", f"Referer: {index_url}",
"-H", "X-Requested-With: XMLHttpRequest",
"-H", "Content-Type: application/x-www-form-urlencoded",
"--data", post_data,
ajax_url,
],
capture_output=True, text=True, timeout=120,
encoding="utf-8", errors="replace",
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("NUFORC live: ajax fetch failed for %s: %s", yyyymm, e)
return []
if ajax_res.returncode != 0 or not ajax_res.stdout:
logger.warning(
"NUFORC live: ajax fetch exit=%s for %s", ajax_res.returncode, yyyymm,
)
return []
try:
payload = json.loads(ajax_res.stdout)
except json.JSONDecodeError as e:
logger.warning("NUFORC live: ajax JSON decode failed for %s: %s", yyyymm, e)
return []
raw_rows = payload.get("data") or []
out: list[dict] = []
for raw in raw_rows:
if not isinstance(raw, list) or len(raw) < 8:
continue
link_html = str(raw[0] or "")
occurred_raw = str(raw[1] or "")
city = str(raw[2] or "").strip()
state = str(raw[3] or "").strip()
country = str(raw[4] or "").strip()
shape_raw = (str(raw[5] or "").strip() or "Unknown")
summary = str(raw[6] or "").strip()
reported_raw = str(raw[7] or "")
explanation = str(raw[9] or "").strip() if len(raw) > 9 and raw[9] else ""
occurred_ymd = _parse_date(occurred_raw)
if not occurred_ymd:
continue
if not city and not state and not country:
continue
id_match = _NUFORC_LIVE_SIGHTING_ID_RE.search(link_html)
if id_match:
sighting_id = f"NUFORC-{id_match.group(1)}"
else:
digest = hashlib.sha1(
f"{occurred_ymd}|{city}|{state}|{summary}".encode("utf-8", "ignore")
).hexdigest()[:12]
sighting_id = f"NUFORC-{digest}"
if summary and len(summary) > 280:
summary = summary[:277] + "..."
if not summary:
summary = "Sighting reported"
out.append({
"id": sighting_id,
"occurred": occurred_ymd,
"posted": _parse_date(reported_raw) or occurred_ymd,
"city": city,
"state": state,
"country": country,
"shape_raw": shape_raw,
"summary": summary,
"explanation": explanation,
})
return out
def _build_recent_uap_sightings() -> list[dict]:
"""Build the rolling 1-year UAP sightings layer from live NUFORC data.
Hits nuforc.org's public sub-index once per month in the window, drops
anything outside the exact day-precision cutoff, dedupes by sighting id,
geocodes city+state via the existing location cache, and returns rows
keyed to the same schema the frontend already renders.
"""
cutoff_dt = datetime.utcnow() - timedelta(days=_NUFORC_RECENT_DAYS)
cutoff_str = cutoff_dt.strftime("%Y-%m-%d")
months = _nuforc_months_for_window(_NUFORC_RECENT_DAYS)
try:
_NUFORC_DATA_DIR.mkdir(parents=True, exist_ok=True)
except Exception:
pass
rows: list[dict] = []
locations: dict[str, tuple[str, str]] = {}
seen_ids: set[str] = set()
total_pulled = 0
months_with_data = 0
for yyyymm in months:
month_rows = _nuforc_fetch_month_live(yyyymm, _NUFORC_LIVE_SESSION_COOKIES)
if month_rows:
months_with_data += 1
total_pulled += len(month_rows)
for row in month_rows:
if row["occurred"] < cutoff_str:
continue
if row["id"] in seen_ids:
continue
seen_ids.add(row["id"])
# Build the geocode key as "City, State, Country" to match the
# existing 3,000+ entry location cache (format: "Toronto, ON, Canada").
parts = [row["city"], row["state"], row["country"]]
location = _normalize_uap_location(
", ".join(p for p in parts if p) if any(parts) else ""
)
if not location:
continue
row["location"] = location
locations.setdefault(location, (row["city"], row["state"], row["country"]))
row["shape"] = (
_normalize_uap_shape(row["shape_raw"])
if row["shape_raw"] != "Unknown"
else "unknown"
)
if not row["country"]:
row["country"] = _uap_country_from_location(location, row["state"])
rows.append(row)
# Clean up the cookie jar — we don't reuse it across runs.
try:
if _NUFORC_LIVE_SESSION_COOKIES.exists():
_NUFORC_LIVE_SESSION_COOKIES.unlink()
except Exception:
pass
# Source-integrity canary: if the upstream plugin changed its
# DataTables schema or the wdtNonce regex is stale, total_pulled
# collapses to ~0 without any HTTP error. assert_canary logs a loud
# ERROR so the failure is visible in the health registry and the
# daily refresh log, instead of silently serving a stale cache.
from services.slo import assert_canary
assert_canary("uap_sightings", total_pulled)
if not rows:
raise RuntimeError(
f"NUFORC live: zero rows pulled across {len(months)} months "
f"(months_with_data={months_with_data})"
)
from services.geocode_validate import coord_in_country
location_cache = _load_nuforc_location_cache()
missing_locations = [location for location in locations if location not in location_cache]
if missing_locations:
logger.info(
"UAP sightings: geocoding %d new locations (throttled at %.1fs spacing)",
len(missing_locations),
_NUFORC_GEOCODE_SPACING_S,
)
# Sequential with spacing — Photon is fast and lenient but we
# stay sub-second to be polite. Incremental cache saves every 50
# hits keep long runs resumable.
resolved = 0
bbox_rejected = 0
save_every = 50
for idx, location in enumerate(missing_locations):
city, state, country = locations[location]
coords = None
try:
coords = _geocode_uap_location(location, city, state, country)
except Exception:
coords = None
# Country-bbox post-filter: reject namesake collisions like
# "Milan, WI" landing in Milan, Italy. Unknown countries
# (bbox not registered) are passed through unchanged.
if coords and country:
inside = coord_in_country(coords[0], coords[1], country)
if inside is False:
logger.warning(
"UAP sightings: bbox reject %r -> (%.3f, %.3f) not in %s",
location, coords[0], coords[1], country,
)
coords = None
bbox_rejected += 1
location_cache[location] = coords
if coords:
resolved += 1
if (idx + 1) % save_every == 0:
_save_nuforc_location_cache(location_cache)
logger.info(
"UAP sightings: geocoded %d/%d (%d resolved, %d bbox-rejected)",
idx + 1, len(missing_locations), resolved, bbox_rejected,
)
if idx + 1 < len(missing_locations):
time.sleep(_NUFORC_GEOCODE_SPACING_S)
_save_nuforc_location_cache(location_cache)
logger.info(
"UAP sightings: geocoding complete — %d/%d resolved, %d bbox-rejected",
resolved, len(missing_locations), bbox_rejected,
)
sightings: list[dict] = []
skipped_unmapped = 0
skipped_bbox = 0
for row in rows:
coords = location_cache.get(row["location"])
if not coords:
skipped_unmapped += 1
continue
# Apply bbox filter to pre-existing cache entries too — this
# cleans up the ~1-2% of cached coords that pre-dated the bbox
# check without requiring a full cache rebuild.
if row.get("country"):
inside = coord_in_country(coords[0], coords[1], row["country"])
if inside is False:
skipped_bbox += 1
continue
sightings.append(
{
"id": row["id"],
"date_time": row["occurred"],
"city": row["city"],
"state": row["state"],
"country": row["country"],
"shape": row["shape"],
"shape_raw": row["shape_raw"],
"duration": row.get("duration", ""),
"summary": row["summary"],
"posted": row["posted"],
"lat": float(coords[0]),
"lng": float(coords[1]),
"count": 1,
"source": "NUFORC",
}
)
if row.get("explanation"):
sightings[-1]["explanation"] = row["explanation"]
sightings.sort(
key=lambda sighting: (
sighting.get("date_time", ""),
sighting.get("posted", ""),
str(sighting.get("id", "")),
),
reverse=True,
)
logger.info(
"UAP sightings: %d mapped reports from %d rows across %d months "
"(cutoff %s, %d unmapped, %d bbox-rejected)",
len(sightings),
total_pulled,
len(months),
cutoff_str,
skipped_unmapped,
skipped_bbox,
)
return sightings
@with_retry(max_retries=1, base_delay=5)
def fetch_uap_sightings(*, force_refresh: bool = False):
"""Fetch last-year UAP sightings from NUFORC.
Startup reads the cached daily snapshot when it is still fresh. The daily
scheduler forces a rebuild so this layer updates once per day instead of
churning continuously.
"""
from services.fetchers._store import is_any_active
if not is_any_active("uap_sightings"):
return
sightings = _load_nuforc_sightings_cache(force_refresh=force_refresh)
if sightings is None:
sightings = _build_recent_uap_sightings()
_save_nuforc_sightings_cache(sightings)
with _data_lock:
latest_data["uap_sightings"] = sightings
_mark_fresh("uap_sightings")
return
cutoff = datetime.utcnow() - timedelta(days=_NUFORC_RECENT_DAYS)
# Query the grid concurrently (up to 8 threads)
all_features: list[dict] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
futures = {
pool.submit(_fetch_nuforc_tilequery, lng, lat): (lng, lat)
for lng, lat in _TILEQUERY_GRID
}
for fut in concurrent.futures.as_completed(futures, timeout=60):
try:
all_features.extend(fut.result())
except Exception:
pass
# Deduplicate by (LinkLat, LinkLon) and filter to recent sightings
seen: set[tuple[str, str]] = set()
sightings: list[dict] = []
enriched_count = 0
for feat in all_features:
props = feat.get("properties", {})
link_lat = props.get("LinkLat", "")
link_lon = props.get("LinkLon", "")
if not link_lat or not link_lon:
continue
key = (link_lat, link_lon)
if key in seen:
continue
seen.add(key)
# Filter by date — keep if the latest sighting date >= cutoff
to_date = props.get("To", "")
from_date = props.get("From", "")
latest_date = to_date or from_date
latest_dt = _parse_nuforc_tile_date(latest_date)
if latest_dt is not None and latest_dt < cutoff:
continue
try:
lat = float(link_lat)
lng = float(link_lon)
except (ValueError, TypeError):
continue
count = int(props.get("Count", "1") or "1")
state_abbr, country = _reverse_geocode_state(lat, lng)
# Enrich with HF NUFORC dataset (shape, duration, city, summary)
enrichment = enrich_sighting(state_abbr, from_date, to_date)
city = enrichment.get("city", "")
shape_raw = enrichment.get("shape_raw", "Unknown")
shape = _normalize_uap_shape(shape_raw) if shape_raw != "Unknown" else "unknown"
duration = enrichment.get("duration", "")
summary = enrichment.get("summary", "")
if enrichment:
enriched_count += 1
# Build display summary: prefer enriched text, fall back to count-based
if not summary:
summary = f"{count} sighting(s) reported" if count > 1 else "Sighting reported"
sightings.append({
"id": f"NUFORC-{hash(key) & 0xFFFFFFFF:08x}",
"date_time": from_date if from_date == to_date else f"{from_date} to {to_date}",
"city": city,
"state": state_abbr,
"country": country,
"shape": shape,
"shape_raw": shape_raw,
"duration": duration,
"summary": summary,
"posted": to_date,
"lat": lat,
"lng": lng,
"count": count,
"source": "NUFORC",
})
logger.info(
f"UAP sightings: {len(sightings)} recent from NUFORC tilequery "
f"({len(all_features)} raw, {enriched_count} enriched)"
)
with _data_lock:
latest_data["uap_sightings"] = sightings
if sightings:
_mark_fresh("uap_sightings")
+194 -23
View File
@@ -1,20 +1,24 @@
"""
Fuel burn & CO2 emissions estimator for private jets.
Fuel burn & CO2 emissions estimator.
Based on manufacturer-published cruise fuel burn rates (GPH at long-range cruise).
1 US gallon of Jet-A produces ~21.1 lbs (9.57 kg) of CO2.
Piston entries use 100LL (avgas), which is close enough to Jet-A in CO2 yield
(~8.4 kg/gal vs 9.57 kg/gal); we keep one constant to stay simple — the result
is a slight over-estimate for piston aircraft, which is preferable to under.
"""
JET_A_CO2_KG_PER_GALLON = 9.57
# ICAO type code -> gallons per hour at long-range cruise
FUEL_BURN_GPH: dict[str, int] = {
# Gulfstream
# ── Gulfstream ─────────────────────────────────────────────────────
"GLF6": 430, # G650/G650ER
"G700": 480, # G700
"GLF5": 390, # G550
"GVSP": 400, # GV-SP
"GLF4": 330, # G-IV
# Bombardier
# ── Bombardier business ────────────────────────────────────────────
"GL7T": 490, # Global 7500
"GLEX": 430, # Global Express/6000/6500
"GL5T": 420, # Global 5000/5500
@@ -22,51 +26,208 @@ FUEL_BURN_GPH: dict[str, int] = {
"CL60": 310, # Challenger 604/605
"CL30": 200, # Challenger 300
"CL65": 320, # Challenger 650
# Dassault
# ── Bombardier regional jets ──────────────────────────────────────
"CRJ2": 360, # CRJ-100/200
"CRJ7": 380, # CRJ-700
"CRJ9": 410, # CRJ-900
"CRJX": 440, # CRJ-1000
# ── Dassault ───────────────────────────────────────────────────────
"F7X": 350, # Falcon 7X
"F8X": 370, # Falcon 8X
"F900": 285, # Falcon 900/900EX/900LX
"F2TH": 230, # Falcon 2000
"FA50": 240, # Falcon 50
# Cessna
# ── Cessna Citation ────────────────────────────────────────────────
"CITX": 280, # Citation X
"C750": 280, # Citation X (alt code)
"C68A": 195, # Citation Latitude
"C700": 230, # Citation Longitude
"C680": 220, # Citation Sovereign
"C560": 190, # Citation Excel/XLS
"C56X": 195, # Citation Excel/XLS/XLS+
"C560": 190, # Citation Excel/XLS (legacy)
"C550": 165, # Citation II/Bravo/V
"C525": 80, # Citation CJ1
"C25A": 100, # CJ1+ / 525A
"C25B": 110, # CJ2+ / 525B
"C25C": 130, # CJ4 (some operators)
"C510": 75, # Citation Mustang
"C650": 240, # Citation III/VI/VII
"CJ3": 120, # CJ3
"CJ4": 135, # CJ4
# Boeing
"B737": 850, # BBJ (737)
"B738": 920, # BBJ2 (737-800)
# ── Cessna piston / turboprop singles & twins ─────────────────────
"C172": 9, # Skyhawk
"C152": 6,
"C150": 6,
"C170": 8,
"C177": 11,
"C180": 12,
"C182": 13, # Skylane
"C185": 14,
"C206": 15,
"C208": 50, # Caravan (turboprop)
"C210": 18,
"C310": 32,
"C340": 38,
"C414": 36,
"C421": 40,
# ── Boeing mainline ────────────────────────────────────────────────
"B737": 850, # 737-700 / BBJ
"B738": 920, # 737-800
"B739": 880, # 737-900/900ER
"B38M": 700, # 737-8 MAX
"B39M": 740, # 737-9 MAX
"B752": 1100, # 757-200
"B753": 1200, # 757-300
"B762": 1400, # 767-200
"B763": 1450, # 767-300/300ER
"B764": 1500, # 767-400ER
"B772": 1850, # 777-200
"B77L": 1900, # 777-200LR / 777F
"B77W": 2050, # 777-300ER
"B788": 1200, # 787-8
# Airbus
"A318": 780, # ACJ318
"A319": 850, # ACJ319
"A320": 900, # ACJ320
"B789": 1300, # 787-9
"B78X": 1350, # 787-10
"B744": 3050, # 747-400
"B748": 2900, # 747-8
# ── Airbus mainline ────────────────────────────────────────────────
"A318": 780, # A318
"A319": 850, # A319
"A320": 900, # A320
"A321": 990, # A321
"A19N": 580, # A319neo
"A20N": 580, # A320neo
"A21N": 700, # A321neo
"A332": 1500, # A330-200
"A333": 1550, # A330-300
"A338": 1300, # A330-800neo
"A339": 1350, # A330-900neo
"A343": 1800, # A340-300
"A346": 2100, # A340-600
# Pilatus
"A359": 1450, # A350-900
"A35K": 1600, # A350-1000
"A388": 3200, # A380-800
# ── Embraer regional / business ───────────────────────────────────
"E135": 300, # Legacy 600/650 (regional ERJ-135 base)
"E145": 320, # ERJ-145
"E170": 460, # E170
"E75L": 490, # E175-LR
"E75S": 490, # E175 standard
"E175": 490, # E175 (some)
"E190": 580, # E190
"E195": 600, # E195
"E290": 510, # E190-E2
"E295": 540, # E195-E2
"E50P": 135, # Phenom 300 (also Phenom 100 var)
"E55P": 185, # Praetor 500 / Legacy 500
"E545": 170, # Praetor 500 (alt)
"E500": 80, # Phenom 100
# ── ATR / Bombardier / Saab turboprops ────────────────────────────
"AT43": 230, # ATR 42-300/-320
"AT45": 230, # ATR 42-500
"AT46": 250, # ATR 42-600
"AT72": 300, # ATR 72-200/-210
"AT75": 280, # ATR 72-500
"AT76": 280, # ATR 72-600
"DH8A": 220, # Dash 8 -100
"DH8B": 240, # Dash 8 -200
"DH8C": 280, # Dash 8 -300
"DH8D": 300, # Dash 8 Q400
"SF34": 200, # Saab 340
"SB20": 220, # Saab 2000
# ── Pilatus / Daher single-engine turboprops ──────────────────────
"PC24": 115, # PC-24
"PC12": 60, # PC-12
# Embraer
"E55P": 185, # Legacy 500
"E135": 300, # Legacy 600/650
"E50P": 135, # Phenom 300
"E500": 80, # Phenom 100
# Learjet
"TBM7": 60, # TBM 700/850
"TBM8": 65, # TBM 850 alt
"TBM9": 70, # TBM 900/930/940/960
"M600": 60, # Piper M600
"P46T": 22, # PA-46 Meridian (turboprop variant)
# ── Learjet ────────────────────────────────────────────────────────
"LJ60": 195, # Learjet 60
"LJ75": 185, # Learjet 75
"LJ45": 175, # Learjet 45
# Hawker
"LJ31": 165, # Learjet 31
"LJ40": 175, # Learjet 40
"LJ55": 195, # Learjet 55
# ── Hawker / Beechjet ─────────────────────────────────────────────
"H25B": 210, # Hawker 800/800XP
"H25C": 215, # Hawker 900XP
# Beechcraft
"BE40": 150, # Beechjet 400 / Hawker 400XP
"PRM1": 130, # Premier I
# ── Beechcraft King Air ───────────────────────────────────────────
"B350": 100, # King Air 350
"B200": 80, # King Air 200/250
"BE20": 80, # K-Air 200 (alt)
"BE9L": 60, # K-Air 90
"BE9T": 70, # K-Air F90
"BE10": 100, # K-Air 100
"BE30": 90, # K-Air 300
# ── Beechcraft / Cirrus / Piper / Mooney pistons ──────────────────
"BE23": 9, # Sundowner
"BE33": 13, # Bonanza 33
"BE35": 14, # Bonanza V-tail
"BE36": 16, # A36 Bonanza
"BE55": 24, # Baron 55
"BE58": 28, # Baron 58
"BE76": 17, # Duchess
"BE95": 20, # Travel Air
"P28A": 10, # PA-28 Warrior/Archer
"P28B": 11, # PA-28 Cherokee
"P28R": 12, # PA-28R Arrow
"P32R": 14, # PA-32R Lance/Saratoga
"PA11": 5, # Cub Special
"PA12": 6, # Super Cruiser
"PA18": 6, # Super Cub
"PA22": 8, # Tri-Pacer
"PA23": 18, # Apache / Aztec
"PA24": 12, # Comanche
"PA25": 12, # Pawnee
"PA28": 10, # PA-28 generic
"PA30": 16, # Twin Comanche
"PA31": 30, # Navajo
"PA32": 14, # Cherokee Six / Saratoga
"PA34": 18, # Seneca
"PA38": 5, # Tomahawk
"PA44": 17, # Seminole
"PA46": 18, # Malibu / Mirage / Matrix
"M20P": 12, # Mooney M20 (generic)
"SR20": 11, # Cirrus SR20
"SR22": 16, # Cirrus SR22
"S22T": 19, # SR22T (turbo)
"DA40": 9, # Diamond DA40
"DA42": 14, # Diamond DA42 TwinStar
"DA62": 17, # Diamond DA62
"DV20": 6, # Diamond Katana
# ── Helicopters (civilian) ────────────────────────────────────────
"A109": 60, # AW109
"A119": 50, # AW119
"A139": 130, # AW139
"A169": 90, # AW169
"A189": 145, # AW189
"AS35": 55, # AS350 AStar
"AS50": 55, # AStar (alt)
"AS65": 110, # Dauphin
"B06": 35, # Bell 206 JetRanger
"B407": 50, # Bell 407
"B412": 145, # Bell 412
"B429": 80, # Bell 429
"B505": 35, # Bell 505
"EC30": 50, # H125 / EC130
"EC35": 70, # EC135
"EC45": 85, # EC145
"EC75": 130, # EC175
"H125": 55,
"H130": 50,
"H135": 70,
"H145": 85,
"H155": 110,
"H160": 95,
"H175": 130,
"R22": 9, # Robinson R22 (piston)
"R44": 16, # Robinson R44 (piston)
"R66": 30, # Robinson R66 (turbine)
"S76": 140, # Sikorsky S-76
"S92": 220, # Sikorsky S-92
}
# Common string names -> ICAO type code
@@ -108,13 +269,23 @@ def get_emissions_info(model: str) -> dict | None:
if not model:
return None
model_clean = model.strip()
model_upper = model_clean.upper()
# Try direct ICAO code match first
gph = FUEL_BURN_GPH.get(model_clean.upper())
gph = FUEL_BURN_GPH.get(model_upper)
if gph is None:
# Try alias lookup
code = _ALIASES.get(model_clean)
if code:
gph = FUEL_BURN_GPH.get(code)
if gph is None:
# Friendly names from the Plane-Alert DB often lead with the ICAO type
# code as the first token (e.g. "B200 Super King Air"). Probe each
# token against FUEL_BURN_GPH directly.
for token in model_upper.replace("-", " ").replace(",", " ").split():
candidate = FUEL_BURN_GPH.get(token)
if candidate is not None:
gph = candidate
break
if gph is None:
# Fuzzy: check if any alias is a substring
model_lower = model_clean.lower()
+164 -170
View File
@@ -13,12 +13,13 @@ import concurrent.futures
import random
import requests
from datetime import datetime
from cachetools import TTLCache
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.fetchers.plane_alert import enrich_with_plane_alert, enrich_with_tracked_names
from services.fetchers.emissions import get_emissions_info
from services.fetchers.retry import with_retry
from services.fetchers.route_database import lookup_route
from services.fetchers.aircraft_database import lookup_aircraft_type
from services.constants import GPS_JAMMING_NACP_THRESHOLD, GPS_JAMMING_MIN_RATIO, GPS_JAMMING_MIN_AIRCRAFT
logger = logging.getLogger("services.data_fetcher")
@@ -76,6 +77,7 @@ opensky_client = OpenSkyClient(
# Throttling and caching for OpenSky (400 req/day limit)
last_opensky_fetch = 0
cached_opensky_flights = []
_opensky_cache_lock = threading.Lock()
# ---------------------------------------------------------------------------
# Supplemental ADS-B sources for blind-spot gap-filling
@@ -98,6 +100,7 @@ _AIRPLANES_LIVE_DELAY_SECONDS = 1.2
_AIRPLANES_LIVE_DELAY_JITTER_SECONDS = 0.4
last_supplemental_fetch = 0
cached_supplemental_flights = []
_supplemental_cache_lock = threading.Lock()
# Helicopter type codes (backend classification)
_HELI_TYPES_BACKEND = {
@@ -255,10 +258,11 @@ flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen:
_trails_lock = threading.Lock()
_MAX_TRACKED_TRAILS = 2000
# Routes cache
dynamic_routes_cache = TTLCache(maxsize=5000, ttl=7200)
routes_fetch_in_progress = False
_routes_lock = threading.Lock()
# Route enrichment is now served from services.fetchers.route_database, which
# bulk-loads vrs-standing-data.adsb.lol/routes.csv.gz once per day and looks up
# callsigns from an in-memory index. Replaces the legacy /api/0/routeset POST,
# which was both blocked under the ShadowBroker UA (HTTP 451) and broken
# upstream (returning 201 with empty body even for unblocked clients).
def _fetch_supplemental_sources(seen_hex: set) -> list:
@@ -266,12 +270,13 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
global last_supplemental_fetch, cached_supplemental_flights
now = time.time()
if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL:
return [
f
for f in cached_supplemental_flights
if f.get("hex", "").lower().strip() not in seen_hex
]
with _supplemental_cache_lock:
if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL:
return [
f
for f in cached_supplemental_flights
if f.get("hex", "").lower().strip() not in seen_hex
]
new_supplemental = []
supplemental_hex = set()
@@ -363,8 +368,9 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
fi_count = len(new_supplemental) - ap_count
cached_supplemental_flights = new_supplemental
last_supplemental_fetch = now
with _supplemental_cache_lock:
cached_supplemental_flights = new_supplemental
last_supplemental_fetch = now
if new_supplemental:
_mark_fresh("supplemental_flights")
@@ -375,73 +381,6 @@ def _fetch_supplemental_sources(seen_hex: set) -> list:
return new_supplemental
def fetch_routes_background(sampled):
global routes_fetch_in_progress
with _routes_lock:
if routes_fetch_in_progress:
return
routes_fetch_in_progress = True
try:
callsigns_to_query = []
for f in sampled:
c_sign = str(f.get("flight", "")).strip()
if c_sign and c_sign != "UNKNOWN":
callsigns_to_query.append(
{"callsign": c_sign, "lat": f.get("lat", 0), "lng": f.get("lon", 0)}
)
batch_size = 100
batches = [
callsigns_to_query[i : i + batch_size]
for i in range(0, len(callsigns_to_query), batch_size)
]
for batch in batches:
try:
r = fetch_with_curl(
"https://api.adsb.lol/api/0/routeset",
method="POST",
json_data={"planes": batch},
timeout=15,
)
if r.status_code == 200:
route_data = r.json()
route_list = []
if isinstance(route_data, dict):
route_list = route_data.get("value", [])
elif isinstance(route_data, list):
route_list = route_data
for route in route_list:
callsign = route.get("callsign", "")
airports = route.get("_airports", [])
if airports and len(airports) >= 2:
orig_apt = airports[0]
dest_apt = airports[-1]
with _routes_lock:
dynamic_routes_cache[callsign] = {
"orig_name": f"{orig_apt.get('iata', '')}: {orig_apt.get('name', 'Unknown')}",
"dest_name": f"{dest_apt.get('iata', '')}: {dest_apt.get('name', 'Unknown')}",
"orig_loc": [orig_apt.get("lon", 0), orig_apt.get("lat", 0)],
"dest_loc": [dest_apt.get("lon", 0), dest_apt.get("lat", 0)],
}
time.sleep(0.25)
except (
requests.RequestException,
ConnectionError,
TimeoutError,
ValueError,
KeyError,
json.JSONDecodeError,
OSError,
) as e:
logger.debug(f"Route batch request failed: {e}")
finally:
with _routes_lock:
routes_fetch_in_progress = False
def _classify_and_publish(all_adsb_flights):
"""Shared pipeline: normalize raw ADS-B data → classify → merge → publish to latest_data.
@@ -453,13 +392,6 @@ def _classify_and_publish(all_adsb_flights):
if not all_adsb_flights:
return
with _routes_lock:
already_running = routes_fetch_in_progress
if not already_running:
threading.Thread(
target=fetch_routes_background, args=(all_adsb_flights,), daemon=True
).start()
for f in all_adsb_flights:
try:
lat = f.get("lat")
@@ -478,8 +410,7 @@ def _classify_and_publish(all_adsb_flights):
origin_name = "UNKNOWN"
dest_name = "UNKNOWN"
with _routes_lock:
cached_route = dynamic_routes_cache.get(flight_str)
cached_route = lookup_route(flight_str)
if cached_route:
origin_name = cached_route["orig_name"]
dest_name = cached_route["dest_name"]
@@ -501,7 +432,18 @@ def _classify_and_publish(all_adsb_flights):
gs_knots = f.get("gs")
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
model_upper = f.get("t", "").upper()
# OpenSky's /states/all doesn't carry the aircraft type, so its
# records arrive with t="Unknown". Backfill from the OpenSky
# aircraft metadata DB by ICAO24 hex so heli classification and
# downstream emissions enrichment both see a real type code.
raw_type = str(f.get("t") or "").strip()
if not raw_type or raw_type.lower() == "unknown":
looked_up_type = lookup_aircraft_type(f.get("hex", ""))
if looked_up_type:
f["t"] = looked_up_type
raw_type = looked_up_type
model_upper = raw_type.upper()
if model_upper == "TWR":
continue
@@ -543,8 +485,14 @@ def _classify_and_publish(all_adsb_flights):
for f in flights:
enrich_with_plane_alert(f)
enrich_with_tracked_names(f)
# Attach fuel-burn / CO2 emissions estimate when model is known
# Attach fuel-burn / CO2 emissions estimate when model is known.
# OpenSky's /states/all doesn't carry aircraft type, so OpenSky-sourced
# flights arrive with model="Unknown". For tracked planes, the
# Plane-Alert DB has the friendly type name in alert_type, and the
# emissions aliases table already maps those names to ICAO codes.
model = f.get("model")
if not model or model.strip().lower() in {"", "unknown"}:
model = f.get("alert_type") or ""
if model:
emi = get_emissions_info(model)
if emi:
@@ -618,6 +566,10 @@ def _classify_and_publish(all_adsb_flights):
latest_data["flights"] = flights
# Merge tracked civilian flights with tracked military flights
# Stale tracked flights (not seen in any ADS-B source for >5 min) are dropped.
_TRACKED_STALE_S = 300 # 5 minutes
_merge_ts = time.time()
with _data_lock:
existing_tracked = copy.deepcopy(latest_data.get("tracked_flights", []))
@@ -625,10 +577,12 @@ def _classify_and_publish(all_adsb_flights):
for t in tracked:
icao = t.get("icao24", "").upper()
if icao:
t["_seen_at"] = _merge_ts
fresh_tracked_map[icao] = t
merged_tracked = []
seen_icaos = set()
stale_dropped = 0
for old_t in existing_tracked:
icao = old_t.get("icao24", "").upper()
if icao in fresh_tracked_map:
@@ -639,8 +593,13 @@ def _classify_and_publish(all_adsb_flights):
merged_tracked.append(fresh)
seen_icaos.add(icao)
else:
merged_tracked.append(old_t)
seen_icaos.add(icao)
# Keep stale entry only if it was seen recently
age = _merge_ts - old_t.get("_seen_at", 0)
if age < _TRACKED_STALE_S:
merged_tracked.append(old_t)
seen_icaos.add(icao)
else:
stale_dropped += 1
for icao, t in fresh_tracked_map.items():
if icao not in seen_icaos:
@@ -649,10 +608,12 @@ def _classify_and_publish(all_adsb_flights):
with _data_lock:
latest_data["tracked_flights"] = merged_tracked
logger.info(
f"Tracked flights: {len(merged_tracked)} total ({len(fresh_tracked_map)} fresh from civilian)"
f"Tracked flights: {len(merged_tracked)} total ({len(fresh_tracked_map)} fresh from civilian, {stale_dropped} stale dropped)"
)
# --- Trail Accumulation ---
_TRAIL_INTERVAL_S = 600 # only record a new trail point every 10 minutes
def _accumulate_trail(f, now_ts, check_route=True):
hex_id = f.get("icao24", "").lower()
if not hex_id:
@@ -668,7 +629,11 @@ def _classify_and_publish(all_adsb_flights):
if hex_id not in flight_trails:
flight_trails[hex_id] = {"points": [], "last_seen": now_ts}
trail_data = flight_trails[hex_id]
if (
# Only append a new point if 10 minutes have passed since the last one
last_point_ts = trail_data["points"][-1][3] if trail_data["points"] else 0
if now_ts - last_point_ts < _TRAIL_INTERVAL_S:
trail_data["last_seen"] = now_ts
elif (
trail_data["points"]
and trail_data["points"][-1][0] == point[0]
and trail_data["points"][-1][1] == point[1]
@@ -688,22 +653,26 @@ def _classify_and_publish(all_adsb_flights):
tracked_snapshot = copy.deepcopy(latest_data.get("tracked_flights", []))
raw_flights_snapshot = list(latest_data.get("flights", []))
all_lists = [commercial, private_jets, private_ga, existing_tracked]
# Commercial/private: skip trail if route is known (route line replaces trail)
route_check_lists = [commercial, private_jets, private_ga]
# Tracked + military: ALWAYS accumulate trails (high-interest flights)
always_trail_lists = [existing_tracked, military_snapshot]
seen_hexes = set()
trail_count = 0
with _trails_lock:
for flist in all_lists:
for flist in route_check_lists:
for f in flist:
count, hex_id = _accumulate_trail(f, now_ts, check_route=True)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
for mf in military_snapshot:
count, hex_id = _accumulate_trail(mf, now_ts, check_route=False)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
for flist in always_trail_lists:
for f in flist:
count, hex_id = _accumulate_trail(f, now_ts, check_route=False)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
tracked_hexes = {t.get("icao24", "").lower() for t in tracked_snapshot}
stale_keys = []
@@ -889,79 +858,100 @@ def _enrich_with_opensky_and_supplemental(adsb_flights):
now = time.time()
global last_opensky_fetch, cached_opensky_flights
if now - last_opensky_fetch > 300:
with _opensky_cache_lock:
_need_opensky = now - last_opensky_fetch > 300
if not _need_opensky:
opensky_snapshot = list(cached_opensky_flights)
if _need_opensky:
token = opensky_client.get_token()
if token:
opensky_regions = [
{
"name": "Africa",
"bbox": {"lamin": -35.0, "lomin": -20.0, "lamax": 38.0, "lomax": 55.0},
},
{
"name": "Asia",
"bbox": {"lamin": 0.0, "lomin": 30.0, "lamax": 75.0, "lomax": 150.0},
},
{
"name": "South America",
"bbox": {"lamin": -60.0, "lomin": -95.0, "lamax": 15.0, "lomax": -30.0},
},
]
# One global /states/all query = 4 credits flat per OpenSky
# docs (https://openskynetwork.github.io/opensky-api/rest.html).
# At the current 5-minute cadence that's 4 × 288 = 1152
# credits/day, ~29% of the 4000-credit standard daily quota,
# and returns every aircraft worldwide in a single call.
# The previous 3-regional-bbox approach cost 12 credits/cycle
# AND missed North America, Europe, and Oceania entirely.
new_opensky_flights = []
for os_reg in opensky_regions:
try:
bb = os_reg["bbox"]
os_url = f"https://opensky-network.org/api/states/all?lamin={bb['lamin']}&lomin={bb['lomin']}&lamax={bb['lamax']}&lomax={bb['lomax']}"
headers = {"Authorization": f"Bearer {token}"}
os_res = requests.get(os_url, headers=headers, timeout=15)
try:
os_url = "https://opensky-network.org/api/states/all"
headers = {"Authorization": f"Bearer {token}"}
os_res = requests.get(os_url, headers=headers, timeout=30)
if os_res.status_code == 200:
os_data = os_res.json()
states = os_data.get("states") or []
logger.info(
f"OpenSky: Fetched {len(states)} states for {os_reg['name']}"
if os_res.status_code == 200:
os_data = os_res.json()
states = os_data.get("states") or []
remaining = os_res.headers.get("X-Rate-Limit-Remaining", "?")
logger.info(
f"OpenSky: fetched {len(states)} global states "
f"(credits remaining: {remaining})"
)
for s in states:
if s[5] is None or s[6] is None:
continue
new_opensky_flights.append(
{
"hex": s[0],
"flight": s[1].strip() if s[1] else "UNKNOWN",
"r": s[2],
"lon": s[5],
"lat": s[6],
"alt_baro": (s[7] * 3.28084) if s[7] else 0,
"track": s[10] or 0,
"gs": (s[9] * 1.94384) if s[9] else 0,
"t": "Unknown",
"is_opensky": True,
}
)
elif os_res.status_code == 429:
retry_after = os_res.headers.get("X-Rate-Limit-Retry-After-Seconds", "?")
logger.warning(
f"OpenSky daily quota exhausted (4000 credits). "
f"Retry after {retry_after}s. Serving stale data until reset."
)
else:
logger.warning(
f"OpenSky /states/all failed: HTTP {os_res.status_code}"
)
except (
requests.RequestException,
ConnectionError,
TimeoutError,
ValueError,
KeyError,
json.JSONDecodeError,
OSError,
) as ex:
logger.error(f"OpenSky global fetch error: {ex}")
for s in states:
new_opensky_flights.append(
{
"hex": s[0],
"flight": s[1].strip() if s[1] else "UNKNOWN",
"r": s[2],
"lon": s[5],
"lat": s[6],
"alt_baro": (s[7] * 3.28084) if s[7] else 0,
"track": s[10] or 0,
"gs": (s[9] * 1.94384) if s[9] else 0,
"t": "Unknown",
"is_opensky": True,
}
)
else:
logger.warning(
f"OpenSky API {os_reg['name']} failed: {os_res.status_code}"
)
except (
requests.RequestException,
ConnectionError,
TimeoutError,
ValueError,
KeyError,
json.JSONDecodeError,
OSError,
) as ex:
logger.error(f"OpenSky fetching error for {os_reg['name']}: {ex}")
cached_opensky_flights = new_opensky_flights
last_opensky_fetch = now
with _opensky_cache_lock:
if new_opensky_flights:
cached_opensky_flights = new_opensky_flights
last_opensky_fetch = now
opensky_snapshot = new_opensky_flights or list(cached_opensky_flights)
else:
# Token refresh failed — fall back to existing cached data
with _opensky_cache_lock:
opensky_snapshot = list(cached_opensky_flights)
# Merge OpenSky (dedup by hex)
for osf in cached_opensky_flights:
for osf in opensky_snapshot:
h = osf.get("hex")
if h and h.lower().strip() not in seen_hex:
all_flights.append(osf)
seen_hex.add(h.lower().strip())
# Publish OpenSky-merged data immediately so users see flights even if
# supplemental gap-fill is slow or rate-limited (airplanes.live can take
# 100+ seconds when its regional endpoints are throttled).
if len(all_flights) > len(adsb_flights):
logger.info(
f"OpenSky merge: {len(all_flights) - len(adsb_flights)} additional aircraft, "
"publishing before supplemental gap-fill"
)
_classify_and_publish(all_flights)
# Supplemental gap-fill
try:
gap_fill = _fetch_supplemental_sources(seen_hex)
@@ -1008,14 +998,18 @@ def fetch_flights():
if adsb_flights:
logger.info(f"adsb.lol: {len(adsb_flights)} aircraft — publishing immediately")
_classify_and_publish(adsb_flights)
# Phase 2: kick off slow enrichment in background
threading.Thread(
target=_enrich_with_opensky_and_supplemental,
args=(adsb_flights,),
daemon=True,
).start()
else:
logger.warning("adsb.lol returned 0 aircraft")
logger.warning(
"adsb.lol returned 0 aircraft — relying on OpenSky/supplemental sources"
)
# Phase 2: always run — OpenSky is the fallback when adsb.lol blocks us
# (it has been known to 451 the bulk regional endpoint), and supplemental
# gap-fill should always run regardless of Phase 1 success.
threading.Thread(
target=_enrich_with_opensky_and_supplemental,
args=(adsb_flights,),
daemon=True,
).start()
except Exception as e:
logger.error(f"Error fetching flights: {e}")
+158 -26
View File
@@ -1,10 +1,13 @@
"""Ship and geopolitics fetchers — AIS vessels, carriers, frontlines, GDELT, LiveUAmap, fishing."""
import csv
import concurrent.futures
import io
import math
import os
import logging
import time
from urllib.parse import urlencode
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.fetchers.retry import with_retry
@@ -27,20 +30,24 @@ def fetch_ships():
from services.ais_stream import get_ais_vessels
from services.carrier_tracker import get_carrier_positions
ships = []
try:
carriers = get_carrier_positions()
ships.extend(carriers)
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
logger.error(f"Carrier tracker error (non-fatal): {e}")
carriers = []
with concurrent.futures.ThreadPoolExecutor(max_workers=2, thread_name_prefix="ship_fetch") as executor:
carrier_future = executor.submit(get_carrier_positions)
ais_future = executor.submit(get_ais_vessels)
try:
ais_vessels = get_ais_vessels()
ships.extend(ais_vessels)
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
logger.error(f"AIS stream error (non-fatal): {e}")
ais_vessels = []
try:
carriers = carrier_future.result()
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
logger.error(f"Carrier tracker error (non-fatal): {e}")
carriers = []
try:
ais_vessels = ais_future.result()
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
logger.error(f"AIS stream error (non-fatal): {e}")
ais_vessels = []
ships = list(carriers or [])
ships.extend(ais_vessels or [])
# Enrich ships with yacht alert data (tracked superyachts)
from services.fetchers.yacht_alert import enrich_with_yacht_alert
@@ -200,52 +207,177 @@ def update_liveuamap():
# ---------------------------------------------------------------------------
# Fishing Activity (Global Fishing Watch)
# ---------------------------------------------------------------------------
def _fishing_vessel_key(event: dict) -> str:
vessel_ssvid = str(event.get("vessel_ssvid", "") or "").strip()
if vessel_ssvid:
return f"ssvid:{vessel_ssvid}"
vessel_id = str(event.get("vessel_id", "") or "").strip()
if vessel_id:
return f"vid:{vessel_id}"
vessel_name = str(event.get("vessel_name", "") or "").strip().upper()
vessel_flag = str(event.get("vessel_flag", "") or "").strip().upper()
if vessel_name:
return f"name:{vessel_name}|flag:{vessel_flag}"
return f"event:{event.get('id', '')}"
def _fishing_event_rank(event: dict) -> tuple[str, str, float, str]:
return (
str(event.get("end", "") or ""),
str(event.get("start", "") or ""),
float(event.get("duration_hrs", 0) or 0),
str(event.get("id", "") or ""),
)
def _dedupe_fishing_events(events: list[dict]) -> list[dict]:
latest_by_vessel: dict[str, dict] = {}
counts_by_vessel: dict[str, int] = {}
for event in events:
vessel_key = _fishing_vessel_key(event)
counts_by_vessel[vessel_key] = counts_by_vessel.get(vessel_key, 0) + 1
current = latest_by_vessel.get(vessel_key)
if current is None or _fishing_event_rank(event) > _fishing_event_rank(current):
latest_by_vessel[vessel_key] = event
deduped: list[dict] = []
for vessel_key, event in latest_by_vessel.items():
event_copy = dict(event)
event_copy["event_count"] = counts_by_vessel.get(vessel_key, 1)
deduped.append(event_copy)
deduped.sort(key=_fishing_event_rank, reverse=True)
return deduped
_FISHING_FETCH_INTERVAL_S = 3600 # once per hour — GFW data has ~5 day lag
_last_fishing_fetch_ts: float = 0.0
@with_retry(max_retries=1, base_delay=5)
def fetch_fishing_activity():
"""Fetch recent fishing events from Global Fishing Watch (~5 day lag)."""
from services.fetchers._store import is_any_active
global _last_fishing_fetch_ts
from services.fetchers._store import is_any_active, latest_data
if not is_any_active("fishing_activity"):
return
# Skip if we already have data and fetched less than an hour ago
now = time.time()
if latest_data.get("fishing_activity") and (now - _last_fishing_fetch_ts) < _FISHING_FETCH_INTERVAL_S:
return
token = os.environ.get("GFW_API_TOKEN", "")
if not token:
logger.debug("GFW_API_TOKEN not set, skipping fishing activity fetch")
return
events = []
try:
url = (
"https://gateway.api.globalfishingwatch.org/v3/events"
"?datasets[0]=public-global-fishing-events:latest"
"&limit=500&sort=start&sort-direction=DESC"
)
import datetime as _dt
_end = _dt.date.today().isoformat()
_start = (_dt.date.today() - _dt.timedelta(days=7)).isoformat()
page_size = max(1, int(os.environ.get("GFW_EVENTS_PAGE_SIZE", "500") or "500"))
offset = 0
seen_offsets: set[int] = set()
seen_ids: set[str] = set()
headers = {"Authorization": f"Bearer {token}"}
response = fetch_with_curl(url, timeout=30, headers=headers)
if response.status_code == 200:
entries = response.json().get("entries", [])
while True:
if offset in seen_offsets:
logger.warning("Fishing activity pagination repeated offset=%s; stopping fetch", offset)
break
seen_offsets.add(offset)
query = urlencode(
{
"datasets[0]": "public-global-fishing-events:latest",
"start-date": _start,
"end-date": _end,
"limit": page_size,
"offset": offset,
}
)
url = f"https://gateway.api.globalfishingwatch.org/v3/events?{query}"
response = fetch_with_curl(url, timeout=30, headers=headers)
if response.status_code != 200:
logger.warning(
"Fishing activity fetch failed at offset=%s: HTTP %s",
offset,
response.status_code,
)
break
payload = response.json() or {}
entries = payload.get("entries", [])
if not entries:
break
added_this_page = 0
for e in entries:
pos = e.get("position", {})
vessel = e.get("vessel") or {}
lat = pos.get("lat")
lng = pos.get("lon")
if lat is None or lng is None:
continue
event_id = str(e.get("id", "") or "")
if event_id and event_id in seen_ids:
continue
if event_id:
seen_ids.add(event_id)
dur = e.get("event", {}).get("duration", 0) or 0
events.append(
{
"id": e.get("id", ""),
"id": event_id,
"type": e.get("type", "fishing"),
"lat": lat,
"lng": lng,
"start": e.get("start", ""),
"end": e.get("end", ""),
"vessel_name": (e.get("vessel") or {}).get("name", "Unknown"),
"vessel_flag": (e.get("vessel") or {}).get("flag", ""),
"vessel_id": str(vessel.get("id", "") or ""),
"vessel_ssvid": str(vessel.get("ssvid", "") or ""),
"vessel_name": vessel.get("name", "Unknown"),
"vessel_flag": vessel.get("flag", ""),
"duration_hrs": round(dur / 3600, 1),
}
)
logger.info(f"Fishing activity: {len(events)} events")
added_this_page += 1
if len(entries) < page_size:
break
next_offset = payload.get("nextOffset")
if next_offset is None:
next_offset = (payload.get("pagination") or {}).get("nextOffset")
if next_offset is None:
next_offset = offset + page_size
try:
next_offset = int(next_offset)
except (TypeError, ValueError):
next_offset = offset + page_size
if next_offset <= offset:
logger.warning(
"Fishing activity pagination produced non-increasing next offset=%s; stopping fetch",
next_offset,
)
break
if added_this_page == 0:
logger.warning(
"Fishing activity page at offset=%s added no new events; stopping fetch",
offset,
)
break
offset = next_offset
raw_event_count = len(events)
events = _dedupe_fishing_events(events)
logger.info("Fishing activity: %s raw events -> %s deduped vessels", raw_event_count, len(events))
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as e:
logger.error(f"Error fetching fishing activity: {e}")
with _data_lock:
latest_data["fishing_activity"] = events
if events:
_mark_fresh("fishing_activity")
_last_fishing_fetch_ts = time.time()
+46 -2
View File
@@ -25,7 +25,10 @@ logger = logging.getLogger("services.data_fetcher")
_API_URL = "https://meshtastic.liamcottle.net/api/v1/nodes"
_CACHE_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "meshtastic_nodes_cache.json"
_FETCH_TIMEOUT = 90 # seconds — response is ~37MB, needs time on slow connections
_MAX_AGE_HOURS = 4 # discard nodes not seen within this window (matches refresh interval)
_MAX_AGE_HOURS = 24 # discard nodes not seen within this window
# Skip network fetch if cached data is fresher than this — the API is a
# one-person hobby service, so we prefer stale data over hammering it.
_CACHE_TRUST_HOURS = 20
# Track when we last fetched so the frontend can show staleness
_last_fetch_ts: float = 0.0
@@ -141,13 +144,54 @@ def fetch_meshtastic_nodes():
return
global _last_fetch_ts
# Trust a recent cache on disk — avoids hammering the upstream HTTP API
# when every install polls on roughly the same cadence.
try:
if _CACHE_FILE.exists():
mtime = _CACHE_FILE.stat().st_mtime
if time.time() - mtime < _CACHE_TRUST_HOURS * 3600:
# If memory is empty (cold start), hydrate from cache and skip fetch.
with _data_lock:
has_memory = bool(latest_data.get("meshtastic_map_nodes"))
if not has_memory:
cached = _load_cache()
if cached:
with _data_lock:
latest_data["meshtastic_map_nodes"] = cached
latest_data["meshtastic_map_fetched_at"] = mtime
_mark_fresh("meshtastic_map")
logger.info(
"Meshtastic map: cache fresh (<%.0fh), skipping network fetch",
_CACHE_TRUST_HOURS,
)
return
else:
logger.info(
"Meshtastic map: cache fresh (<%.0fh), skipping network fetch",
_CACHE_TRUST_HOURS,
)
return
except Exception as e:
logger.debug(f"Meshtastic cache freshness check failed: {e}")
# Build a polite User-Agent. Include the operator callsign when set so
# the upstream service can correlate per-install traffic if needed.
try:
from services.config import get_settings
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
except Exception:
callsign = ""
ua_base = "ShadowBroker-OSINT/0.9.7 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base
try:
logger.info("Fetching Meshtastic map nodes from API...")
resp = requests.get(
_API_URL,
timeout=_FETCH_TIMEOUT,
headers={
"User-Agent": "ShadowBroker/1.0 (OSINT dashboard, 4h polling)",
"User-Agent": user_agent,
"Accept": "application/json",
},
)
+16 -4
View File
@@ -2,6 +2,7 @@
import json
import logging
import time
import requests
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
@@ -296,17 +297,23 @@ def fetch_military_flights():
with _data_lock:
latest_data["military_flights"] = remaining_mil
# Store tracked military flights — update positions for existing entries
# Store tracked military flights — update positions for existing entries.
# Drop stale entries not refreshed by ANY source (civilian or military) within 5 min.
_TRACKED_STALE_S = 300 # 5 minutes
_merge_ts = time.time()
with _data_lock:
existing_tracked = list(latest_data.get("tracked_flights", []))
fresh_mil_map = {}
for t in tracked_mil:
icao = t.get("icao24", "").upper()
if icao:
t["_seen_at"] = _merge_ts
fresh_mil_map[icao] = t
updated_tracked = []
seen_icaos = set()
stale_dropped = 0
for old_t in existing_tracked:
icao = old_t.get("icao24", "").upper()
if icao in fresh_mil_map:
@@ -317,11 +324,16 @@ def fetch_military_flights():
updated_tracked.append(fresh)
seen_icaos.add(icao)
else:
updated_tracked.append(old_t)
seen_icaos.add(icao)
# Keep stale entry only if it was seen recently
age = _merge_ts - old_t.get("_seen_at", 0)
if age < _TRACKED_STALE_S:
updated_tracked.append(old_t)
seen_icaos.add(icao)
else:
stale_dropped += 1
for icao, t in fresh_mil_map.items():
if icao not in seen_icaos:
updated_tracked.append(t)
with _data_lock:
latest_data["tracked_flights"] = updated_tracked
logger.info(f"Tracked flights: {len(updated_tracked)} total ({len(tracked_mil)} from military)")
logger.info(f"Tracked flights: {len(updated_tracked)} total ({len(tracked_mil)} from military, {stale_dropped} stale dropped)")
+17
View File
@@ -1,6 +1,8 @@
"""News fetching, geocoding, clustering, and risk assessment."""
import re
import time
import logging
import calendar
import concurrent.futures
import requests
import feedparser
@@ -11,6 +13,10 @@ from services.oracle_service import enrich_news_items, compute_global_threat_lev
logger = logging.getLogger("services.data_fetcher")
# Maximum article age in seconds. Anything older than this is dropped
# during each fetch cycle so the threat feed stays current.
_MAX_ARTICLE_AGE_SECS = 48 * 3600 # 48 hours
# Keyword -> coordinate mapping for geocoding news articles
_KEYWORD_COORDS = {
@@ -178,6 +184,17 @@ def fetch_news():
if not feed:
continue
for entry in feed.entries[:5]:
# Drop articles older than the max-age threshold so the
# threat feed doesn't show stale stories across cycles.
pp = entry.get("published_parsed")
if pp:
try:
entry_epoch = calendar.timegm(pp)
if time.time() - entry_epoch > _MAX_ARTICLE_AGE_SECS:
continue
except (TypeError, ValueError, OverflowError):
pass # unparseable date — keep the article
title = entry.get('title', '')
summary = entry.get('summary', '')
@@ -0,0 +1,360 @@
"""NUFORC Enrichment — downloads the Hugging Face NUFORC dataset and builds
a compact spatial+temporal index for enriching tilequery hits with shape,
duration, city, and summary text.
The full CSV (~170 MB) is streamed once and processed into a lightweight JSON
cache (~1-3 MB) stored at ``backend/data/nuforc_enrichment.json``. Subsequent
startups load from cache until it expires (30 days).
Index structure::
{
"built": "2026-04-08T12:00:00",
"count": 12345,
"by_state": {
"AZ": [
{"d": "2024-01-15", "city": "Tucson", "shape": "triangle",
"dur": "5 minutes", "summary": "Bright triangular object..."},
...
],
...
}
}
Entries within each state are sorted by date descending (newest first).
"""
import csv
import gzip
import io
import json
import logging
import os
import re
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from services.network_utils import fetch_with_curl
logger = logging.getLogger(__name__)
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
_CACHE_FILE = _DATA_DIR / "nuforc_enrichment.json"
_CACHE_TTL_DAYS = 1 # Rebuild daily — fresh data each cycle
# HuggingFace dataset — use the structured string export, not the old flat blob.
_HF_CSV_URL = (
"https://huggingface.co/datasets/kcimc/NUFORC/resolve/main/nuforc_str.csv"
)
# Only keep sightings from the last N years for the enrichment index
_KEEP_YEARS = 5
# ── In-memory index ────────────────────────────────────────────────────────
_index: dict | None = None
_index_lock = threading.Lock()
_building = False
# US state abbreviations for parsing "City, ST" locations
_US_STATES = {
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
"DC",
}
def _parse_location(loc: str) -> tuple[str, str]:
"""Parse 'City, ST' or 'City, ST (explanation)' → (city, state_abbr).
Returns ('', '') if unparseable.
"""
if not loc:
return "", ""
loc = re.sub(r"\s*\(.*\)\s*$", "", loc).strip()
parts = [p.strip() for p in loc.split(",") if p.strip()]
if len(parts) < 2:
return "", ""
for idx in range(len(parts) - 1):
candidate = parts[idx + 1].upper().strip()
if candidate in _US_STATES:
city = ", ".join(parts[: idx + 1]).strip()
return city, candidate
candidate = parts[-1].upper().strip()
if candidate in _US_STATES:
return ", ".join(parts[:-1]).strip(), candidate
return parts[0], ""
def _parse_date(date_str: str) -> str:
"""Best-effort parse NUFORC date strings → 'YYYY-MM-DD'.
Returns '' on failure.
"""
if not date_str:
return ""
cleaned = str(date_str).strip()
cleaned = re.sub(r"\s+local$", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s+utc$", "", cleaned, flags=re.IGNORECASE)
cleaned = cleaned.replace("T", " ")
for fmt in (
"%m/%d/%Y %H:%M",
"%m/%d/%Y %I:%M:%S %p",
"%m/%d/%Y",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
):
try:
return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d")
except (ValueError, TypeError):
continue
match = re.match(r"^(\d{4}-\d{2}-\d{2})", cleaned)
if match:
return match.group(1)
return ""
def _load_cache() -> dict | None:
"""Load the on-disk cache if it exists and is fresh enough."""
if not _CACHE_FILE.exists():
return None
try:
raw = _CACHE_FILE.read_text(encoding="utf-8")
data = json.loads(raw)
built = data.get("built", "")
if built:
built_dt = datetime.fromisoformat(built)
if datetime.utcnow() - built_dt < timedelta(days=_CACHE_TTL_DAYS):
if int(data.get("count", 0) or 0) <= 0:
logger.info("NUFORC enrichment: cache is fresh but empty; rebuilding")
return None
logger.info(
"NUFORC enrichment: loaded cache (%d entries, built %s)",
data.get("count", 0), built,
)
return data
else:
logger.info("NUFORC enrichment: cache expired (built %s)", built)
except Exception as e:
logger.warning("NUFORC enrichment: cache load error: %s", e)
return None
def _save_cache(data: dict):
"""Persist the enrichment index to disk."""
try:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
_CACHE_FILE.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
logger.info("NUFORC enrichment: saved cache (%d entries)", data.get("count", 0))
except Exception as e:
logger.warning("NUFORC enrichment: cache save error: %s", e)
def _download_and_build() -> dict | None:
"""Stream-download the HF CSV and build the enrichment index.
Returns the index dict or None on failure.
"""
cutoff = datetime.utcnow() - timedelta(days=_KEEP_YEARS * 365)
cutoff_str = cutoff.strftime("%Y-%m-%d")
logger.info("NUFORC enrichment: downloading HF dataset (this may take a minute)...")
try:
resp = fetch_with_curl(_HF_CSV_URL, timeout=180, follow_redirects=True)
if not resp or resp.status_code != 200:
logger.warning(
"NUFORC enrichment: download failed HTTP %s",
getattr(resp, "status_code", "None"),
)
return None
except Exception as e:
logger.error("NUFORC enrichment: download error: %s", e)
return None
# Parse CSV from response text
by_state: dict[str, list[dict]] = {}
total = 0
kept = 0
try:
reader = csv.DictReader(io.StringIO(resp.text))
for row in reader:
total += 1
occurred = _parse_date(
row.get("Occurred", "")
or row.get("Date / Time", "")
or row.get("Date", "")
)
if not occurred or occurred < cutoff_str:
continue
city, state = _parse_location(
row.get("Location", "")
or row.get("City", "")
or row.get("location", "")
)
if not state:
continue # can't index without state
shape = (row.get("Shape", "") or row.get("shape", "") or "").strip()
duration = (row.get("Duration", "") or row.get("duration", "") or "").strip()
summary = (
row.get("Summary", "")
or row.get("summary", "")
or row.get("Text", "")
or row.get("text", "")
or ""
).strip()
if summary and len(summary) > 200:
summary = summary[:197] + "..."
entry = {"d": occurred, "city": city, "shape": shape}
if duration:
entry["dur"] = duration
if summary:
entry["sum"] = summary
by_state.setdefault(state, []).append(entry)
kept += 1
except Exception as e:
logger.error("NUFORC enrichment: CSV parse error: %s", e)
return None
# Sort each state's entries by date descending (newest first)
for st in by_state:
by_state[st].sort(key=lambda e: e["d"], reverse=True)
data = {
"built": datetime.utcnow().isoformat(),
"count": kept,
"by_state": by_state,
}
logger.info(
"NUFORC enrichment: built index — %d entries from %d total rows (%d states)",
kept, total, len(by_state),
)
return data
def _ensure_index():
"""Load or build the enrichment index (thread-safe, non-blocking)."""
global _index, _building
with _index_lock:
if _index is not None:
return
if _building:
return # another thread is already building
_building = True
# Try loading from disk first
cached = _load_cache()
if cached:
with _index_lock:
_index = cached
_building = False
return
# Download and build in background so we don't block startup
def _build():
global _index, _building
try:
result = _download_and_build()
if result:
_save_cache(result)
with _index_lock:
_index = result
else:
logger.warning("NUFORC enrichment: build failed, enrichment unavailable")
finally:
with _index_lock:
_building = False
thread = threading.Thread(target=_build, name="nuforc-enrichment", daemon=True)
thread.start()
def refresh_enrichment_index():
"""Force-rebuild the enrichment index. Called by the daily cron job.
Downloads the latest HF CSV, rebuilds the in-memory + disk cache.
Runs synchronously (meant to be called from a background thread).
"""
global _index
logger.info("NUFORC enrichment: daily refresh starting...")
result = _download_and_build()
if result:
_save_cache(result)
with _index_lock:
_index = result
logger.info("NUFORC enrichment: daily refresh complete (%d entries)", result.get("count", 0))
else:
logger.warning("NUFORC enrichment: daily refresh failed, keeping stale index")
def enrich_sighting(state: str, from_date: str, to_date: str) -> dict:
"""Look up enrichment data for a tilequery hit.
Args:
state: 2-letter US state code (from reverse geocode)
from_date: earliest sighting date (YYYY-MM-DD)
to_date: latest sighting date (YYYY-MM-DD)
Returns:
Dict with optional keys: city, shape, duration, summary.
Empty dict if no match found.
"""
_ensure_index()
with _index_lock:
idx = _index
if not idx or not state:
return {}
entries = idx.get("by_state", {}).get(state, [])
if not entries:
return {}
# Find the best match by date proximity
target = to_date or from_date
if not target:
# No date filter — just return the most recent entry for this state
e = entries[0]
else:
best = None
best_dist = 999999
for e in entries:
# Simple string distance on dates (YYYY-MM-DD sorts lexicographically)
try:
t = datetime.strptime(target, "%Y-%m-%d")
d = datetime.strptime(e["d"], "%Y-%m-%d")
dist = abs((t - d).days)
except (ValueError, TypeError):
continue
if dist < best_dist:
best_dist = dist
best = e
if dist == 0:
break # exact date match
if best is None or best_dist > 90:
return {} # no match within 3 months
e = best
result = {}
if e.get("city"):
result["city"] = e["city"]
if e.get("shape"):
result["shape"] = e["shape"]
result["shape_raw"] = e["shape"]
if e.get("dur"):
result["duration"] = e["dur"]
if e.get("sum"):
result["summary"] = e["sum"]
return result
+464 -16
View File
@@ -8,14 +8,33 @@ full metadata (volume, end dates, descriptions, source badges).
import json
import logging
import math
import os
import threading
import time
from urllib.parse import urlencode
from cachetools import TTLCache, cached
logger = logging.getLogger("services.data_fetcher")
_market_cache = TTLCache(maxsize=1, ttl=60) # 60-second TTL — markets change fast
# Delta tracking: {market_title: previous_consensus_pct}
_prev_probabilities: dict[str, float] = {}
_market_cache = TTLCache(maxsize=1, ttl=300)
_POLYMARKET_PAGE_DELAY_S = float(os.environ.get("MESH_POLYMARKET_PAGE_DELAY_S", "0.02"))
_KALSHI_PAGE_DELAY_S = float(os.environ.get("MESH_KALSHI_PAGE_DELAY_S", "0.08"))
_provider_pace_lock = threading.Lock()
_provider_last_request_at: dict[str, float] = {}
def _pace_provider(provider: str, min_interval_s: float) -> None:
if min_interval_s <= 0:
return
with _provider_pace_lock:
now = time.monotonic()
wait_s = min_interval_s - (now - _provider_last_request_at.get(provider, 0.0))
if wait_s > 0:
time.sleep(wait_s)
now = time.monotonic()
_provider_last_request_at[provider] = now
def _finite_or_none(value):
@@ -28,7 +47,7 @@ def _finite_or_none(value):
# ---------------------------------------------------------------------------
# Category classification
# ---------------------------------------------------------------------------
CATEGORIES = ["POLITICS", "CONFLICT", "NEWS", "FINANCE", "CRYPTO"]
CATEGORIES = ["POLITICS", "CONFLICT", "NEWS", "FINANCE", "CRYPTO", "SPORTS"]
_KALSHI_CATEGORY_MAP = {
"Politics": "POLITICS",
@@ -38,7 +57,7 @@ _KALSHI_CATEGORY_MAP = {
"Tech": "FINANCE",
"Science": "NEWS",
"Climate and Weather": "NEWS",
"Sports": "NEWS",
"Sports": "SPORTS",
"Culture": "NEWS",
}
@@ -62,7 +81,14 @@ _TAG_CATEGORY_MAP = {
"Ethereum": "CRYPTO",
"AI": "NEWS",
"Science": "NEWS",
"Sports": "NEWS",
"Sports": "SPORTS",
"NBA": "SPORTS",
"NFL": "SPORTS",
"MLB": "SPORTS",
"NHL": "SPORTS",
"Soccer": "SPORTS",
"Tennis": "SPORTS",
"Golf": "SPORTS",
"Culture": "NEWS",
"Entertainment": "NEWS",
"Tech": "FINANCE",
@@ -152,6 +178,26 @@ _KEYWORD_CATEGORIES = {
"market cap",
"revenue",
],
"SPORTS": [
"nba",
"nfl",
"mlb",
"nhl",
"wnba",
"soccer",
"football",
"basketball",
"baseball",
"hockey",
"ufc",
"mma",
"tennis",
"golf",
"championship",
"playoffs",
"world cup",
"super bowl",
],
}
@@ -177,21 +223,186 @@ def _classify_category(title: str, poly_tags: list[str], kalshi_category: str) -
return "NEWS"
def _polymarket_event_to_entry(ev: dict) -> dict | None:
title = ev.get("title", "")
if not title:
return None
markets = ev.get("markets", [])
best_pct = None
total_volume = 0
outcomes = []
for m in markets:
raw_op = m.get("outcomePrices")
price = None
try:
op = json.loads(raw_op) if isinstance(raw_op, str) else raw_op
if isinstance(op, list) and len(op) >= 1:
price = _finite_or_none(op[0])
except (json.JSONDecodeError, ValueError, TypeError):
pass
if price is None:
price = _finite_or_none(m.get("lastTradePrice") or m.get("bestBid"))
pct = None
if price is not None:
try:
pct = round(price * 100, 1)
if best_pct is None or pct > best_pct:
best_pct = pct
except (ValueError, TypeError):
pass
volume = _finite_or_none(m.get("volume", 0) or 0)
if volume is not None:
total_volume += volume
oname = m.get("groupItemTitle") or ""
if oname and pct is not None:
outcomes.append({"name": oname, "pct": pct})
if len(outcomes) > 2:
outcomes.sort(key=lambda x: x["pct"], reverse=True)
else:
outcomes = []
tag_labels = [t.get("label", "") for t in ev.get("tags", []) if t.get("label")]
return {
"title": title,
"source": "polymarket",
"pct": best_pct,
"slug": ev.get("slug", ""),
"description": ev.get("description") or "",
"end_date": ev.get("endDate"),
"volume": round(total_volume, 2),
"volume_24h": round(_finite_or_none(ev.get("volume24hr", 0) or 0) or 0, 2),
"tags": tag_labels,
"outcomes": outcomes,
}
def _kalshi_market_pct(m: dict) -> float | None:
bid = _finite_or_none(m.get("yes_bid_dollars"))
ask = _finite_or_none(m.get("yes_ask_dollars"))
last = _finite_or_none(m.get("last_price_dollars"))
if bid is not None and ask is not None and ask >= bid:
return round(((bid + ask) / 2) * 100, 1)
if last is not None:
return round(last * 100, 1)
cents = _finite_or_none(m.get("yes_price") or m.get("last_price"))
if cents is None:
return None
return round(cents * 100, 1) if cents <= 1 else round(cents, 1)
def _kalshi_market_volume(m: dict) -> float:
for key in ("volume_24h_fp", "volume_fp", "dollar_volume", "volume"):
value = _finite_or_none(m.get(key))
if value is not None:
return value
return 0
def _kalshi_market_category(m: dict) -> str:
text = " ".join(
str(m.get(k, "") or "")
for k in ("ticker", "event_ticker", "mve_collection_ticker", "title", "yes_sub_title", "no_sub_title")
).lower()
if any(token in text for token in ("sports", "xnba", "xnfl", "xmlb", "xnhl", "soccer", "tennis", "golf")):
return "Sports"
return str(m.get("category", "") or "")
def _kalshi_event_to_entry(ev: dict, markets: list[dict] | None = None) -> dict | None:
title = ev.get("title", "")
if not title:
return None
markets = markets or ev.get("markets", []) or []
best_pct = None
total_volume = 0.0
close_dates = []
outcomes = []
first_ticker = ""
descriptions = []
for m in markets:
first_ticker = first_ticker or m.get("ticker", "")
pct = _kalshi_market_pct(m)
if pct is not None:
if best_pct is None or pct > best_pct:
best_pct = pct
oname = m.get("yes_sub_title") or m.get("sub_title") or m.get("title") or ""
if oname and oname != title:
outcomes.append({"name": oname, "pct": pct})
total_volume += _kalshi_market_volume(m)
cd = m.get("close_time") or m.get("close_date") or m.get("expiration_time")
if cd:
close_dates.append(cd)
desc = (m.get("rules_primary") or m.get("rules_secondary") or "").strip()
if desc:
descriptions.append(desc)
if len(outcomes) > 2:
outcomes.sort(key=lambda x: x["pct"], reverse=True)
else:
outcomes = []
desc = (ev.get("settle_details") or ev.get("underlying") or "").strip()
if not desc and descriptions:
desc = descriptions[0]
return {
"title": title,
"source": "kalshi",
"pct": best_pct,
"ticker": first_ticker or ev.get("event_ticker", "") or ev.get("ticker", ""),
"description": desc,
"sub_title": ev.get("sub_title", ""),
"end_date": max(close_dates) if close_dates else None,
"volume": round(total_volume, 2),
"category": ev.get("category", ""),
"outcomes": outcomes,
}
def _kalshi_market_to_entry(m: dict) -> dict | None:
title = m.get("title") or m.get("yes_sub_title") or ""
if not title:
return None
pct = _kalshi_market_pct(m)
volume = _kalshi_market_volume(m)
desc = (m.get("rules_primary") or m.get("rules_secondary") or "").strip()
end_date = m.get("close_time") or m.get("expiration_time") or m.get("expected_expiration_time")
return {
"title": title,
"source": "kalshi",
"pct": pct,
"ticker": m.get("ticker", "") or m.get("event_ticker", ""),
"description": desc,
"sub_title": m.get("subtitle", ""),
"end_date": end_date,
"volume": round(volume, 2),
"category": _kalshi_market_category(m),
"outcomes": [],
}
# ---------------------------------------------------------------------------
# Polymarket
# ---------------------------------------------------------------------------
def _fetch_polymarket_events() -> list[dict]:
"""Fetch active events from Polymarket Gamma API (no auth required).
Fetches up to 500 events (multiple pages) for better search coverage.
Fetches paginated active events, bounded by MESH_POLYMARKET_MAX_EVENTS
so boot-time refresh does not become unbounded.
"""
from services.network_utils import fetch_with_curl
all_events = []
for offset in range(0, 500, 100):
page_size = 250
max_events = int(os.environ.get("MESH_POLYMARKET_MAX_EVENTS", "5000"))
for offset in range(0, max_events, page_size):
try:
_pace_provider("polymarket", _POLYMARKET_PAGE_DELAY_S)
resp = fetch_with_curl(
f"https://gamma-api.polymarket.com/events?active=true&closed=false&limit=100&offset={offset}",
f"https://gamma-api.polymarket.com/events?active=true&closed=false&limit={page_size}&offset={offset}",
timeout=15,
)
if not resp or resp.status_code != 200:
@@ -200,6 +411,8 @@ def _fetch_polymarket_events() -> list[dict]:
if not isinstance(page, list) or not page:
break
all_events.extend(page)
if len(page) < page_size:
break
except Exception as e:
logger.warning(f"Polymarket page offset={offset} error: {e}")
break
@@ -286,6 +499,42 @@ def _fetch_kalshi_events() -> list[dict]:
"""Fetch active events from Kalshi public API (no auth required)."""
from services.network_utils import fetch_with_curl
try:
max_events = int(os.environ.get("MESH_KALSHI_MAX_EVENTS", "2000"))
page_size = 200
markets = []
cursor = ""
while len(markets) < max_events:
params = {"status": "open", "limit": str(page_size)}
if cursor:
params["cursor"] = cursor
_pace_provider("kalshi", _KALSHI_PAGE_DELAY_S)
resp = fetch_with_curl(
f"https://api.elections.kalshi.com/trade-api/v2/markets?{urlencode(params)}",
timeout=15,
)
if not resp or resp.status_code != 200:
break
data = resp.json()
page = data.get("markets", []) if isinstance(data, dict) else []
if not page:
break
markets.extend(page)
cursor = data.get("cursor") or ""
if not cursor or len(page) < page_size:
break
results = []
for market in markets:
entry = _kalshi_market_to_entry(market)
if entry:
results.append(entry)
if results:
logger.info(f"Kalshi: fetched {len(results)} active events from v2")
return results
except Exception as e:
logger.warning(f"Kalshi v2 fetch error, falling back to legacy v1: {e}")
try:
resp = fetch_with_curl(
"https://api.elections.kalshi.com/v1/events?status=open&limit=100",
@@ -540,11 +789,11 @@ def fetch_prediction_markets():
# ---------------------------------------------------------------------------
# Direct API search (not limited to cached data)
# ---------------------------------------------------------------------------
def search_polymarket_direct(query: str, limit: int = 20) -> list[dict]:
def search_polymarket_direct(query: str, limit: int = 20, offset: int = 0) -> list[dict]:
"""Search Polymarket by scanning API pages for title matches.
The Gamma API has no text search parameter, so we scan cached events
plus additional pages until we find enough matches or exhaust the scan.
Prefer Polymarket's public search endpoint, then fall back to scanning
Gamma event pages if search is unavailable.
"""
from services.network_utils import fetch_with_curl
@@ -552,11 +801,53 @@ def search_polymarket_direct(query: str, limit: int = 20) -> list[dict]:
q_words = set(q_lower.split())
results = []
try:
params = urlencode({"q": query, "limit": str(limit), "offset": str(max(0, offset))})
_pace_provider("polymarket", _POLYMARKET_PAGE_DELAY_S)
resp = fetch_with_curl(
f"https://gamma-api.polymarket.com/public-search?{params}",
timeout=15,
)
if resp and resp.status_code == 200:
data = resp.json()
events = data.get("events", []) if isinstance(data, dict) else []
for ev in events:
if ev.get("closed") or ev.get("active") is False:
continue
entry = _polymarket_event_to_entry(ev)
if not entry:
continue
category = _classify_category(entry["title"], entry.get("tags", []), "")
pct = _finite_or_none(entry.get("pct"))
sources = [{"name": "POLY", "pct": pct}] if pct is not None else []
results.append(
{
"title": entry["title"],
"polymarket_pct": pct,
"kalshi_pct": None,
"consensus_pct": pct,
"description": entry.get("description", ""),
"end_date": entry.get("end_date"),
"volume": entry.get("volume", 0),
"volume_24h": entry.get("volume_24h", 0),
"kalshi_volume": 0,
"category": category,
"sources": sources,
"slug": entry.get("slug", ""),
"outcomes": entry.get("outcomes", []),
}
)
logger.info(f"Polymarket search '{query}': {len(results)} results via public-search")
return results[:limit]
except Exception as e:
logger.warning(f"Polymarket public-search '{query}' error: {e}")
# Scan up to 2000 events (10 pages of 200) looking for title matches
for offset in range(0, 2000, 200):
for scan_offset in range(0, 3000, 200):
try:
_pace_provider("polymarket", _POLYMARKET_PAGE_DELAY_S)
resp = fetch_with_curl(
f"https://gamma-api.polymarket.com/events?active=true&closed=false&limit=200&offset={offset}",
f"https://gamma-api.polymarket.com/events?active=true&closed=false&limit=200&offset={scan_offset}",
timeout=15,
)
if not resp or resp.status_code != 200:
@@ -637,11 +928,168 @@ def search_polymarket_direct(query: str, limit: int = 20) -> list[dict]:
}
)
# Stop scanning if we have enough results
if len(results) >= limit:
if len(results) >= offset + limit:
break
except Exception as e:
logger.warning(f"Polymarket search scan offset={offset} error: {e}")
logger.warning(f"Polymarket search scan offset={scan_offset} error: {e}")
break
logger.info(f"Polymarket search '{query}': {len(results)} results (scanned API)")
return results[:limit]
return results[offset : offset + limit]
def search_kalshi_direct(query: str, limit: int = 20, offset: int = 0) -> list[dict]:
"""Search Kalshi events by scanning API pages for title matches."""
from services.network_utils import fetch_with_curl
q_lower = query.lower()
q_words = set(q_lower.split())
results = []
try:
max_scan = int(os.environ.get("MESH_KALSHI_SEARCH_SCAN_EVENTS", "1200"))
page_size = 200
cursor = ""
scanned = 0
while scanned < max_scan and len(results) < offset + limit:
params = {"status": "open", "limit": str(page_size)}
if cursor:
params["cursor"] = cursor
_pace_provider("kalshi", _KALSHI_PAGE_DELAY_S)
resp = fetch_with_curl(
f"https://api.elections.kalshi.com/trade-api/v2/markets?{urlencode(params)}",
timeout=15,
)
if not resp or resp.status_code != 200:
break
data = resp.json()
markets = data.get("markets", []) if isinstance(data, dict) else []
if not markets:
break
scanned += len(markets)
for market in markets:
haystack = " ".join(
str(market.get(k, "") or "")
for k in ("title", "yes_sub_title", "no_sub_title", "event_ticker", "ticker")
).lower()
if q_lower not in haystack and not any(w in haystack for w in q_words):
continue
entry = _kalshi_market_to_entry(market)
if not entry:
continue
pct = _finite_or_none(entry.get("pct"))
sources = [{"name": "KALSHI", "pct": pct}] if pct is not None else []
category = _classify_category(entry["title"], [], entry.get("category", ""))
results.append({
"title": entry["title"],
"polymarket_pct": None,
"kalshi_pct": pct,
"consensus_pct": pct,
"description": entry.get("description", ""),
"end_date": entry.get("end_date"),
"volume": 0,
"volume_24h": 0,
"kalshi_volume": entry.get("volume", 0),
"category": category,
"sources": sources,
"slug": "",
"kalshi_ticker": entry.get("ticker", ""),
"outcomes": entry.get("outcomes", []),
})
if len(results) >= offset + limit:
break
cursor = data.get("cursor") or ""
if not cursor or len(markets) < page_size:
break
if results:
logger.info(f"Kalshi search '{query}': {len(results)} results via v2 scan")
return results[offset : offset + limit]
except Exception as e:
logger.warning(f"Kalshi v2 search '{query}' error, falling back to legacy v1: {e}")
try:
resp = fetch_with_curl(
"https://api.elections.kalshi.com/v1/events?status=open&limit=200",
timeout=15,
)
if not resp or resp.status_code != 200:
return []
data = resp.json()
events = data.get("events", []) if isinstance(data, dict) else []
for ev in events:
title = ev.get("title", "")
if not title:
continue
title_lower = title.lower()
if q_lower not in title_lower and not any(w in title_lower for w in q_words):
continue
markets = ev.get("markets", [])
best_pct = None
total_volume = 0
close_dates = []
outcomes = []
for m in markets:
price = m.get("yes_price") or m.get("last_price")
pct = None
if price is not None:
try:
price = _finite_or_none(price)
if price is None:
raise ValueError("non-finite")
pct = round(price, 1)
if pct <= 1:
pct = round(pct * 100, 1)
if best_pct is None or pct > best_pct:
best_pct = pct
except (ValueError, TypeError):
pass
try:
volume = _finite_or_none(
m.get("dollar_volume", 0) or m.get("volume", 0) or 0
)
if volume is not None:
total_volume += int(volume)
except (ValueError, TypeError):
pass
cd = m.get("close_date")
if cd:
close_dates.append(cd)
oname = m.get("title") or m.get("subtitle", "")
if oname and pct is not None:
outcomes.append({"name": oname, "pct": pct})
if len(outcomes) > 2:
outcomes.sort(key=lambda x: x["pct"], reverse=True)
else:
outcomes = []
desc = (ev.get("settle_details") or ev.get("underlying") or "").strip()
category = _classify_category(title, [], ev.get("category", ""))
sources = []
if best_pct is not None:
sources.append({"name": "KALSHI", "pct": best_pct})
results.append({
"title": title,
"polymarket_pct": None,
"kalshi_pct": best_pct,
"consensus_pct": best_pct,
"description": desc,
"end_date": max(close_dates) if close_dates else None,
"volume": total_volume,
"volume_24h": 0,
"kalshi_volume": total_volume,
"category": category,
"sources": sources,
"slug": "",
"kalshi_ticker": ev.get("ticker", ""),
"outcomes": outcomes,
})
if len(results) >= offset + limit:
break
except Exception as e:
logger.warning(f"Kalshi search '{query}' error: {e}")
logger.info(f"Kalshi search '{query}': {len(results)} results")
return results[offset : offset + limit]
+166
View File
@@ -0,0 +1,166 @@
"""Static route + airport database loaded from vrs-standing-data.adsb.lol.
Replaces the per-batch /api/0/routeset POST with a single daily bulk download.
Routes change ~weekly when airlines update schedules, so a 24h refresh cadence
is far more than sufficient and removes ~all live-API pressure on adsb.lol.
"""
from __future__ import annotations
import csv
import gzip
import io
import logging
import threading
import time
from typing import Any
import requests
logger = logging.getLogger(__name__)
_ROUTES_URL = "https://vrs-standing-data.adsb.lol/routes.csv.gz"
_AIRPORTS_URL = "https://vrs-standing-data.adsb.lol/airports.csv.gz"
_REFRESH_INTERVAL_S = 5 * 24 * 3600
_HTTP_TIMEOUT_S = 60
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.7 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
_lock = threading.RLock()
_routes_by_callsign: dict[str, dict[str, Any]] = {}
_airports_by_icao: dict[str, dict[str, Any]] = {}
_last_refresh = 0.0
_refresh_in_progress = False
def _fetch_csv_gz(url: str) -> list[dict[str, str]]:
response = requests.get(
url,
timeout=_HTTP_TIMEOUT_S,
headers={"User-Agent": _USER_AGENT, "Accept-Encoding": "gzip"},
)
response.raise_for_status()
text = gzip.decompress(response.content).decode("utf-8-sig")
return list(csv.DictReader(io.StringIO(text)))
def _build_route_index(rows: list[dict[str, str]]) -> dict[str, dict[str, Any]]:
index: dict[str, dict[str, Any]] = {}
for row in rows:
callsign = (row.get("Callsign") or "").strip().upper()
airport_codes = (row.get("AirportCodes") or "").strip()
if not callsign or not airport_codes:
continue
icaos = [c.strip() for c in airport_codes.split("-") if c.strip()]
if len(icaos) < 2:
continue
index[callsign] = {
"airline_code": (row.get("AirlineCode") or "").strip(),
"airport_codes": airport_codes,
"airport_icaos": icaos,
}
return index
def _build_airport_index(rows: list[dict[str, str]]) -> dict[str, dict[str, Any]]:
index: dict[str, dict[str, Any]] = {}
for row in rows:
icao = (row.get("ICAO") or "").strip().upper()
if not icao:
continue
try:
lat = float(row.get("Latitude") or 0)
lon = float(row.get("Longitude") or 0)
except (TypeError, ValueError):
continue
index[icao] = {
"name": (row.get("Name") or "").strip(),
"iata": (row.get("IATA") or "").strip(),
"country": (row.get("CountryISO2") or "").strip(),
"lat": lat,
"lon": lon,
}
return index
def refresh_route_database(force: bool = False) -> bool:
"""Pull routes.csv.gz + airports.csv.gz and rebuild the in-memory indexes.
Returns True if a refresh was performed (success or attempted), False if
skipped because the cache is still fresh or another refresh is in flight.
"""
global _last_refresh, _refresh_in_progress
now = time.time()
with _lock:
if _refresh_in_progress:
return False
if not force and (now - _last_refresh) < _REFRESH_INTERVAL_S and _routes_by_callsign:
return False
_refresh_in_progress = True
try:
started = time.time()
airport_rows = _fetch_csv_gz(_AIRPORTS_URL)
route_rows = _fetch_csv_gz(_ROUTES_URL)
airports = _build_airport_index(airport_rows)
routes = _build_route_index(route_rows)
with _lock:
_airports_by_icao.clear()
_airports_by_icao.update(airports)
_routes_by_callsign.clear()
_routes_by_callsign.update(routes)
_last_refresh = time.time()
logger.info(
"route database refreshed in %.1fs: %d routes, %d airports",
time.time() - started,
len(routes),
len(airports),
)
return True
except (requests.RequestException, OSError, ValueError) as exc:
logger.warning("route database refresh failed: %s", exc)
return True
finally:
with _lock:
_refresh_in_progress = False
def lookup_route(callsign: str) -> dict[str, Any] | None:
"""Resolve a callsign to {orig_name, dest_name, orig_loc, dest_loc} or None.
Matches the shape produced by the legacy fetch_routes_background cache so
the caller in flights.py can be a drop-in replacement.
"""
key = (callsign or "").strip().upper()
if not key:
return None
with _lock:
route = _routes_by_callsign.get(key)
if not route:
return None
icaos = route["airport_icaos"]
orig = _airports_by_icao.get(icaos[0].upper())
dest = _airports_by_icao.get(icaos[-1].upper())
if not orig or not dest:
return None
return {
"orig_name": f"{orig['iata']}: {orig['name']}" if orig["iata"] else orig["name"],
"dest_name": f"{dest['iata']}: {dest['name']}" if dest["iata"] else dest["name"],
"orig_loc": [orig["lon"], orig["lat"]],
"dest_loc": [dest["lon"], dest["lat"]],
}
def route_database_status() -> dict[str, Any]:
with _lock:
return {
"last_refresh": _last_refresh,
"routes": len(_routes_by_callsign),
"airports": len(_airports_by_icao),
"in_progress": _refresh_in_progress,
}
+74
View File
@@ -0,0 +1,74 @@
"""SAR catalog fetcher (Mode A — default-on, free, no account).
Hits ASF Search every hour for Sentinel-1 scenes that touched any of
the operator-defined AOIs in the last ~36h. Pure metadata, no
downloads.
Result is written to ``latest_data["sar_scenes"]`` and a per-AOI
coverage summary to ``latest_data["sar_aoi_coverage"]``.
"""
from __future__ import annotations
import logging
from services.fetchers._store import _data_lock, _mark_fresh, is_any_active, latest_data
from services.fetchers.retry import with_retry
from services.sar.sar_aoi import load_aois
from services.sar.sar_catalog_client import estimate_next_pass, search_scenes_for_aoi
from services.sar.sar_config import catalog_enabled
logger = logging.getLogger(__name__)
@with_retry(max_retries=1, base_delay=2)
def fetch_sar_catalog() -> None:
"""Refresh the SAR scene catalog for all configured AOIs."""
if not catalog_enabled():
return
if not is_any_active("sar"):
return
aois = load_aois()
if not aois:
logger.debug("SAR catalog: no AOIs configured")
return
all_scenes: list[dict] = []
coverage: list[dict] = []
for aoi in aois:
try:
scenes = search_scenes_for_aoi(aoi)
except (ConnectionError, TimeoutError, OSError, ValueError) as exc:
logger.debug("SAR catalog %s: %s", aoi.id, exc)
scenes = []
scene_dicts = [s.to_dict() for s in scenes]
all_scenes.extend(scene_dicts)
next_pass = estimate_next_pass(scenes)
coverage.append(
{
"aoi_id": aoi.id,
"aoi_name": aoi.name,
"category": aoi.category,
"center_lat": aoi.center_lat,
"center_lon": aoi.center_lon,
"radius_km": aoi.radius_km,
"recent_scene_count": len(scene_dicts),
"latest_scene_time": (
max((s["time"] for s in scene_dicts), default="")
if scene_dicts
else ""
),
**next_pass,
}
)
with _data_lock:
latest_data["sar_scenes"] = all_scenes
latest_data["sar_aoi_coverage"] = coverage
if all_scenes or coverage:
_mark_fresh("sar_scenes", "sar_aoi_coverage")
logger.info(
"SAR catalog: %d scenes across %d AOIs",
len(all_scenes),
len(aois),
)
+103
View File
@@ -0,0 +1,103 @@
"""SAR pre-processed product fetcher (Mode B — opt-in, free, account needed).
Pulls already-computed deformation, flood, water, and damage products
from NASA OPERA, Copernicus EGMS, GFM, EMS, and UNOSAT. No local DSP.
Two-step opt-in: ``MESH_SAR_PRODUCTS_FETCH=allow`` AND
``MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE=true``. When either flag is
unset, this fetcher logs a single startup hint and returns.
"""
from __future__ import annotations
import logging
from typing import Any
from services.fetchers._store import _data_lock, _mark_fresh, is_any_active, latest_data
from services.fetchers.retry import with_retry
from services.sar.sar_aoi import load_aois
from services.sar.sar_config import products_fetch_enabled, products_fetch_status
from services.sar.sar_normalize import SarAnomaly
from services.sar.sar_products_client import (
fetch_egms_for_aoi,
fetch_ems_for_aoi,
fetch_gfm_for_aoi,
fetch_opera_for_aoi,
fetch_unosat_for_aoi,
)
from services.sar.sar_signing import emit_signed_anomaly
logger = logging.getLogger(__name__)
_LOGGED_DISABLED_HINT = False
def _hint_disabled_once() -> None:
global _LOGGED_DISABLED_HINT
if _LOGGED_DISABLED_HINT:
return
_LOGGED_DISABLED_HINT = True
status = products_fetch_status()
missing = ", ".join(status.get("missing", [])) or "nothing"
logger.info(
"SAR Mode B (ground-change alerts) is disabled. Missing: %s. "
"Enable in Settings → SAR or set the env vars listed in .env.example. "
"Free signup: https://urs.earthdata.nasa.gov/users/new",
missing,
)
@with_retry(max_retries=1, base_delay=3)
def fetch_sar_products() -> None:
"""Refresh pre-processed SAR anomalies for all configured AOIs."""
if not products_fetch_enabled():
_hint_disabled_once()
return
if not is_any_active("sar"):
return
aois = load_aois()
if not aois:
logger.debug("SAR products: no AOIs configured")
return
seen_ids: set[str] = set()
all_anomalies: list[dict[str, Any]] = []
publish_summary = {"signed": 0, "skipped": 0, "reasons": {}}
for aoi in aois:
for fetcher in (
fetch_opera_for_aoi,
fetch_egms_for_aoi,
fetch_gfm_for_aoi,
fetch_ems_for_aoi,
fetch_unosat_for_aoi,
):
try:
anomalies: list[SarAnomaly] = fetcher(aoi) or []
except (ConnectionError, TimeoutError, OSError, ValueError, KeyError, TypeError) as exc:
logger.debug("SAR %s for %s failed: %s", fetcher.__name__, aoi.id, exc)
anomalies = []
for a in anomalies:
if a.anomaly_id in seen_ids:
continue
seen_ids.add(a.anomaly_id)
all_anomalies.append(a.to_dict())
status = emit_signed_anomaly(a)
if status.get("signed"):
publish_summary["signed"] += 1
else:
publish_summary["skipped"] += 1
reason = status.get("reason", "unknown")
publish_summary["reasons"][reason] = (
publish_summary["reasons"].get(reason, 0) + 1
)
with _data_lock:
latest_data["sar_anomalies"] = all_anomalies
if all_anomalies:
_mark_fresh("sar_anomalies")
logger.info(
"SAR products: %d anomalies (%d signed, %d skipped)",
len(all_anomalies),
publish_summary["signed"],
publish_summary["skipped"],
)
+485 -352
View File
@@ -5,6 +5,11 @@ CelesTrak Fair Use Policy (https://celestrak.org/NORAD/elements/):
- Use If-Modified-Since headers for conditional requests
- No parallel/concurrent connections one request at a time
- Set a descriptive User-Agent
Analysis features (derived from cached TLEs no extra network requests):
- Maneuver detection: TLE-to-TLE comparison per satellite
- Decay anomaly: mean-motion change rate monitoring
- Overflight counting: 24h ground-track sampling for a bounding box
"""
import math
@@ -41,6 +46,67 @@ _sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json"
_SAT_CACHE_META_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache_meta.json"
# ── Historical TLE storage for maneuver & decay detection ───────────────────
# Stores the previous TLE snapshot keyed by NORAD_CAT_ID.
# Populated when a fresh CelesTrak fetch replaces cached data.
# Persisted to disk so analysis survives restarts.
_SAT_HISTORY_PATH = Path(__file__).parent.parent.parent / "data" / "sat_tle_history.json"
_tle_history: dict[int, dict] = {} # {norad_id: {elements + "epoch_ts"}}
def _load_tle_history():
"""Load previous TLE snapshot from disk."""
global _tle_history
try:
if _SAT_HISTORY_PATH.exists():
with open(_SAT_HISTORY_PATH, "r") as f:
raw = json.load(f)
_tle_history = {int(k): v for k, v in raw.items()}
logger.info(f"Satellites: Loaded TLE history for {len(_tle_history)} objects")
except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e:
logger.warning(f"Satellites: Failed to load TLE history: {e}")
_tle_history = {}
def _save_tle_history():
"""Persist current TLE snapshot as history for next comparison."""
try:
_SAT_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_SAT_HISTORY_PATH, "w") as f:
json.dump(_tle_history, f)
except (IOError, OSError) as e:
logger.warning(f"Satellites: Failed to save TLE history: {e}")
def _snapshot_current_tles(gp_data):
"""Capture orbital elements from current GP data as the new 'previous' snapshot.
Called once per CelesTrak fetch (every 24h). The old snapshot becomes
the comparison baseline for maneuver/decay detection.
"""
global _tle_history
new_snapshot = {}
for sat in gp_data:
norad_id = sat.get("NORAD_CAT_ID")
if norad_id is None:
continue
epoch_str = sat.get("EPOCH", "")
try:
epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
epoch_ts = epoch_dt.timestamp()
except (ValueError, TypeError):
epoch_ts = 0
new_snapshot[int(norad_id)] = {
"MEAN_MOTION": sat.get("MEAN_MOTION"),
"ECCENTRICITY": sat.get("ECCENTRICITY"),
"INCLINATION": sat.get("INCLINATION"),
"RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"),
"BSTAR": sat.get("BSTAR"),
"epoch_ts": epoch_ts,
}
_tle_history = new_snapshot
_save_tle_history()
def _load_sat_cache():
"""Load satellite GP data from local disk cache."""
@@ -99,360 +165,368 @@ def _save_cache_meta():
# Satellite intelligence classification database
# Matched by substring against OBJECT_NAME (case-insensitive).
# Order matters — first match wins, so specific names go before generic prefixes.
_SAT_INTEL_DB = [
(
"USA 224",
{
"country": "USA",
"mission": "military_recon",
"sat_type": "KH-11 Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
},
),
(
"USA 245",
{
"country": "USA",
"mission": "military_recon",
"sat_type": "KH-11 Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
},
),
(
"USA 290",
{
"country": "USA",
"mission": "military_recon",
"sat_type": "KH-11 Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
},
),
(
"USA 314",
{
"country": "USA",
"mission": "military_recon",
"sat_type": "KH-11 Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
},
),
(
"USA 338",
{
"country": "USA",
"mission": "military_recon",
"sat_type": "Keyhole Successor",
"wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN",
},
),
(
"TOPAZ",
{
"country": "Russia",
"mission": "military_recon",
"sat_type": "Optical Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)",
},
),
(
"PERSONA",
{
"country": "Russia",
"mission": "military_recon",
"sat_type": "Optical Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)",
},
),
(
"KONDOR",
{
"country": "Russia",
"mission": "military_sar",
"sat_type": "SAR Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)",
},
),
(
"BARS-M",
{
"country": "Russia",
"mission": "military_recon",
"sat_type": "Mapping Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/Bars-M",
},
),
(
"YAOGAN",
{
"country": "China",
"mission": "military_recon",
"sat_type": "Remote Sensing / ELINT",
"wiki": "https://en.wikipedia.org/wiki/Yaogan",
},
),
(
"GAOFEN",
{
"country": "China",
"mission": "military_recon",
"sat_type": "High-Res Imaging",
"wiki": "https://en.wikipedia.org/wiki/Gaofen",
},
),
(
"JILIN",
{
"country": "China",
"mission": "commercial_imaging",
"sat_type": "Video / Imaging",
"wiki": "https://en.wikipedia.org/wiki/Jilin-1",
},
),
(
"OFEK",
{
"country": "Israel",
"mission": "military_recon",
"sat_type": "Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/Ofeq",
},
),
(
"CSO",
{
"country": "France",
"mission": "military_recon",
"sat_type": "Optical Reconnaissance",
"wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)",
},
),
(
"IGS",
{
"country": "Japan",
"mission": "military_recon",
"sat_type": "Intelligence Gathering",
"wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite",
},
),
(
"CAPELLA",
{
"country": "USA",
"mission": "sar",
"sat_type": "SAR Imaging",
"wiki": "https://en.wikipedia.org/wiki/Capella_Space",
},
),
(
"ICEYE",
{
"country": "Finland",
"mission": "sar",
"sat_type": "SAR Microsatellite",
"wiki": "https://en.wikipedia.org/wiki/ICEYE",
},
),
(
"COSMO-SKYMED",
{
"country": "Italy",
"mission": "sar",
"sat_type": "SAR Constellation",
"wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed",
},
),
(
"TANDEM",
{
"country": "Germany",
"mission": "sar",
"sat_type": "SAR Interferometry",
"wiki": "https://en.wikipedia.org/wiki/TanDEM-X",
},
),
(
"PAZ",
{
"country": "Spain",
"mission": "sar",
"sat_type": "SAR Imaging",
"wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)",
},
),
(
"WORLDVIEW",
{
"country": "USA",
"mission": "commercial_imaging",
"sat_type": "Maxar High-Res",
"wiki": "https://en.wikipedia.org/wiki/WorldView-3",
},
),
(
"GEOEYE",
{
"country": "USA",
"mission": "commercial_imaging",
"sat_type": "Maxar Imaging",
"wiki": "https://en.wikipedia.org/wiki/GeoEye-1",
},
),
(
"PLEIADES",
{
"country": "France",
"mission": "commercial_imaging",
"sat_type": "Airbus Imaging",
"wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)",
},
),
(
"SPOT",
{
"country": "France",
"mission": "commercial_imaging",
"sat_type": "Airbus Medium-Res",
"wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)",
},
),
(
"PLANET",
{
"country": "USA",
"mission": "commercial_imaging",
"sat_type": "PlanetScope",
"wiki": "https://en.wikipedia.org/wiki/Planet_Labs",
},
),
(
"SKYSAT",
{
"country": "USA",
"mission": "commercial_imaging",
"sat_type": "Planet Video",
"wiki": "https://en.wikipedia.org/wiki/SkySat",
},
),
(
"BLACKSKY",
{
"country": "USA",
"mission": "commercial_imaging",
"sat_type": "BlackSky Imaging",
"wiki": "https://en.wikipedia.org/wiki/BlackSky",
},
),
(
"NROL",
{
"country": "USA",
"mission": "sigint",
"sat_type": "Classified NRO",
"wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office",
},
),
(
"MENTOR",
{
"country": "USA",
"mission": "sigint",
"sat_type": "SIGINT / ELINT",
"wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)",
},
),
(
"LUCH",
{
"country": "Russia",
"mission": "sigint",
"sat_type": "Relay / SIGINT",
"wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)",
},
),
(
"SHIJIAN",
{
"country": "China",
"mission": "sigint",
"sat_type": "ELINT / Tech Demo",
"wiki": "https://en.wikipedia.org/wiki/Shijian",
},
),
(
"NAVSTAR",
{
"country": "USA",
"mission": "navigation",
"sat_type": "GPS",
"wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks",
},
),
(
"GLONASS",
{
"country": "Russia",
"mission": "navigation",
"sat_type": "GLONASS",
"wiki": "https://en.wikipedia.org/wiki/GLONASS",
},
),
(
"BEIDOU",
{
"country": "China",
"mission": "navigation",
"sat_type": "BeiDou",
"wiki": "https://en.wikipedia.org/wiki/BeiDou",
},
),
(
"GALILEO",
{
"country": "EU",
"mission": "navigation",
"sat_type": "Galileo",
"wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)",
},
),
(
"SBIRS",
{
"country": "USA",
"mission": "early_warning",
"sat_type": "Missile Warning",
"wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System",
},
),
(
"TUNDRA",
{
"country": "Russia",
"mission": "early_warning",
"sat_type": "Missile Warning",
"wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)",
},
),
(
"ISS",
{
"country": "Intl",
"mission": "space_station",
"sat_type": "Space Station",
"wiki": "https://en.wikipedia.org/wiki/International_Space_Station",
},
),
(
"TIANGONG",
{
"country": "China",
"mission": "space_station",
"sat_type": "Space Station",
"wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station",
},
),
# ── USA Keyhole / Reconnaissance ────────────────────────────────────────
("USA 224", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
("USA 245", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
("USA 290", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
("USA 314", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
("USA 338", {"country": "USA", "mission": "military_recon", "sat_type": "Keyhole Successor", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}),
# ── USA SIGINT / NRO ────────────────────────────────────────────────────
("NROL", {"country": "USA", "mission": "sigint", "sat_type": "Classified NRO", "wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office"}),
("MENTOR", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT / ELINT (Orion)", "wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)"}),
("TRUMPET", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT (HEO)", "wiki": "https://en.wikipedia.org/wiki/Trumpet_(satellite)"}),
("INTRUDER", {"country": "USA", "mission": "sigint", "sat_type": "Naval SIGINT (NOSS)", "wiki": "https://en.wikipedia.org/wiki/Naval_Ocean_Surveillance_System"}),
# ── USA Early Warning / Missile Defense ─────────────────────────────────
("SBIRS", {"country": "USA", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System"}),
("DSP", {"country": "USA", "mission": "early_warning", "sat_type": "Defense Support Program", "wiki": "https://en.wikipedia.org/wiki/Defense_Support_Program"}),
# ── USA Communications (Military) ───────────────────────────────────────
("MUOS", {"country": "USA", "mission": "military_comms", "sat_type": "Mobile User Objective System", "wiki": "https://en.wikipedia.org/wiki/Mobile_User_Objective_System"}),
("AEHF", {"country": "USA", "mission": "military_comms", "sat_type": "Advanced EHF", "wiki": "https://en.wikipedia.org/wiki/Advanced_Extremely_High_Frequency"}),
("WGS", {"country": "USA", "mission": "military_comms", "sat_type": "Wideband Global SATCOM", "wiki": "https://en.wikipedia.org/wiki/Wideband_Global_SATCOM"}),
("MILSTAR", {"country": "USA", "mission": "military_comms", "sat_type": "Milstar Secure Comms", "wiki": "https://en.wikipedia.org/wiki/Milstar"}),
# ── USA Navigation ──────────────────────────────────────────────────────
("NAVSTAR", {"country": "USA", "mission": "navigation", "sat_type": "GPS", "wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks"}),
# ── Russia Reconnaissance ───────────────────────────────────────────────
("TOPAZ", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}),
("PERSONA", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}),
("KONDOR", {"country": "Russia", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)"}),
("BARS-M", {"country": "Russia", "mission": "military_recon", "sat_type": "Mapping Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Bars-M"}),
("RAZDAN", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Razdan_(satellite)"}),
("LOTOS", {"country": "Russia", "mission": "sigint", "sat_type": "ELINT (Lotos-S)", "wiki": "https://en.wikipedia.org/wiki/Lotos-S"}),
("PION", {"country": "Russia", "mission": "sigint", "sat_type": "Naval SIGINT/Radar", "wiki": "https://en.wikipedia.org/wiki/Pion-NKS"}),
("LUCH", {"country": "Russia", "mission": "sigint", "sat_type": "Relay / SIGINT", "wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)"}),
# ── Russia Early Warning & Navigation ───────────────────────────────────
("TUNDRA", {"country": "Russia", "mission": "early_warning", "sat_type": "Missile Warning (EKS)", "wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)"}),
("GLONASS", {"country": "Russia", "mission": "navigation", "sat_type": "GLONASS", "wiki": "https://en.wikipedia.org/wiki/GLONASS"}),
# ── China Military / Intel ──────────────────────────────────────────────
("YAOGAN", {"country": "China", "mission": "military_recon", "sat_type": "Remote Sensing / ELINT", "wiki": "https://en.wikipedia.org/wiki/Yaogan"}),
("GAOFEN", {"country": "China", "mission": "military_recon", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/Gaofen"}),
("JILIN", {"country": "China", "mission": "commercial_imaging", "sat_type": "Video / Imaging", "wiki": "https://en.wikipedia.org/wiki/Jilin-1"}),
("SHIJIAN", {"country": "China", "mission": "sigint", "sat_type": "ELINT / Tech Demo", "wiki": "https://en.wikipedia.org/wiki/Shijian"}),
("TONGXIN JISHU SHIYAN", {"country": "China", "mission": "military_comms", "sat_type": "Military Comms Test", "wiki": "https://en.wikipedia.org/wiki/Tongxin_Jishu_Shiyan"}),
("BEIDOU", {"country": "China", "mission": "navigation", "sat_type": "BeiDou", "wiki": "https://en.wikipedia.org/wiki/BeiDou"}),
("TIANGONG", {"country": "China", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/Tiangong_space_station"}),
# ── Allied Military / Intel ─────────────────────────────────────────────
("OFEK", {"country": "Israel", "mission": "military_recon", "sat_type": "Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Ofeq"}),
("EROS", {"country": "Israel", "mission": "commercial_imaging", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/EROS_(satellite)"}),
("CSO", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)"}),
("HELIOS", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Helios_(satellite)"}),
("CERES", {"country": "France", "mission": "sigint", "sat_type": "ELINT Constellation", "wiki": "https://en.wikipedia.org/wiki/CERES_(satellite)"}),
("IGS", {"country": "Japan", "mission": "military_recon", "sat_type": "Intelligence Gathering", "wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite"}),
("KOMPSAT", {"country": "South Korea", "mission": "military_recon", "sat_type": "Multi-Purpose Satellite", "wiki": "https://en.wikipedia.org/wiki/KOMPSAT"}),
("SAR-LUPE", {"country": "Germany", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/SAR-Lupe"}),
("SARAH", {"country": "Germany", "mission": "military_sar", "sat_type": "SAR Successor (SARah)", "wiki": "https://en.wikipedia.org/wiki/SARah"}),
# ── Commercial SAR ──────────────────────────────────────────────────────
("CAPELLA", {"country": "USA", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/Capella_Space"}),
("ICEYE", {"country": "Finland", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/ICEYE"}),
("COSMO-SKYMED", {"country": "Italy", "mission": "sar", "sat_type": "SAR Constellation", "wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed"}),
("TANDEM", {"country": "Germany", "mission": "sar", "sat_type": "SAR Interferometry", "wiki": "https://en.wikipedia.org/wiki/TanDEM-X"}),
("PAZ", {"country": "Spain", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)"}),
("UMBRA", {"country": "USA", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/Umbra_(company)"}),
# ── Commercial Optical Imaging ──────────────────────────────────────────
("WORLDVIEW", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar High-Res", "wiki": "https://en.wikipedia.org/wiki/WorldView-3"}),
("GEOEYE", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Imaging", "wiki": "https://en.wikipedia.org/wiki/GeoEye-1"}),
("LEGION", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Legion", "wiki": "https://en.wikipedia.org/wiki/WorldView_Legion"}),
("PLEIADES", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Imaging", "wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)"}),
("SPOT", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Medium-Res", "wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)"}),
("SKYSAT", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Planet Video", "wiki": "https://en.wikipedia.org/wiki/SkySat"}),
("BLACKSKY", {"country": "USA", "mission": "commercial_imaging", "sat_type": "BlackSky Imaging", "wiki": "https://en.wikipedia.org/wiki/BlackSky"}),
# ── Starlink (separate category) ────────────────────────────────────────
("STARLINK", {"country": "USA", "mission": "starlink", "sat_type": "Starlink Mega-Constellation", "wiki": "https://en.wikipedia.org/wiki/Starlink"}),
# ── Other Constellations ────────────────────────────────────────────────
("ONEWEB", {"country": "UK", "mission": "constellation", "sat_type": "OneWeb LEO Broadband", "wiki": "https://en.wikipedia.org/wiki/OneWeb"}),
("GALILEO", {"country": "EU", "mission": "navigation", "sat_type": "Galileo", "wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)"}),
# ── Space Stations ──────────────────────────────────────────────────────
("ISS", {"country": "Intl", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/International_Space_Station"}),
# ── Generic fallback patterns (last resort) ─────────────────────────────
("PLANET", {"country": "USA", "mission": "commercial_imaging", "sat_type": "PlanetScope", "wiki": "https://en.wikipedia.org/wiki/Planet_Labs"}),
]
# CelesTrak SATCAT owner codes → country mapping for satellites not matched by name.
# Used as a secondary classifier alongside name-pattern matching.
_OWNER_CODE_MAP = {
"US": "USA", "CIS": "Russia", "PRC": "China", "ISS": "Intl",
"FR": "France", "UK": "UK", "GER": "Germany", "JPN": "Japan",
"IND": "India", "ISRA": "Israel", "IT": "Italy", "KOR": "South Korea",
"ESA": "EU", "NATO": "NATO", "TURK": "Turkey", "UAE": "UAE",
"AUS": "Australia", "CA": "Canada", "SPN": "Spain", "FIN": "Finland",
"BRAZ": "Brazil", "IRAN": "Iran", "NKOR": "North Korea",
}
# ── Maneuver detection thresholds (per Lemmens & Krag 2014, Kim et al. 2021) ─
# These are above TLE fitting noise but low enough to catch real maneuvers.
_MANEUVER_THRESHOLDS = {
"period_min": 0.1, # minutes — above TLE noise (~0.010.05 min)
"inclination_deg": 0.05, # degrees — above J2 secular drift (~0.001°/day)
"eccentricity": 0.005, # above TLE fitting noise (~0.00010.001)
"raan_residual_deg": 0.5, # degrees — only after J2 correction (Vallado §9.4)
}
# ── Decay anomaly threshold ─────────────────────────────────────────────────
# Flag if mean motion change rate exceeds this (rev/day per day).
# Normal drag-induced decay is ~0.001 rev/day/day for LEO.
_DECAY_MM_RATE_THRESHOLD = 0.01 # rev/day per day
def _j2_raan_rate(inclination_deg, mean_motion_revday):
"""Expected RAAN precession rate due to J2 (Vallado §9.4).
Returns degrees/day. Negative for prograde orbits.
"""
J2 = 1.08263e-3
Re = 6378.137 # km
mu = 398600.4418 # km^3/s^2
n_rad_s = mean_motion_revday * 2 * math.pi / 86400.0
if n_rad_s <= 0:
return 0.0
a = (mu / (n_rad_s ** 2)) ** (1.0 / 3.0) # semi-major axis in km
if a <= Re:
return 0.0
cos_i = math.cos(math.radians(inclination_deg))
raan_rate = -1.5 * n_rad_s * J2 * (Re / a) ** 2 * cos_i
return math.degrees(raan_rate) * 86400.0 / (2 * math.pi) # deg/day
def detect_maneuvers(current_gp_data):
"""Compare current TLEs against stored history to detect orbital maneuvers.
Returns list of maneuver alert dicts. Only runs when _tle_history is populated
(i.e., after the second CelesTrak fetch or from persisted history).
Thresholds from Lemmens & Krag (2014), Kim et al. (2021).
"""
if not _tle_history:
return []
alerts = []
for sat in current_gp_data:
norad_id = sat.get("NORAD_CAT_ID")
if norad_id is None:
continue
norad_id = int(norad_id)
prev = _tle_history.get(norad_id)
if prev is None:
continue
cur_mm = sat.get("MEAN_MOTION")
cur_inc = sat.get("INCLINATION")
cur_ecc = sat.get("ECCENTRICITY")
cur_raan = sat.get("RA_OF_ASC_NODE")
prev_mm = prev.get("MEAN_MOTION")
prev_inc = prev.get("INCLINATION")
prev_ecc = prev.get("ECCENTRICITY")
prev_raan = prev.get("RA_OF_ASC_NODE")
if any(v is None for v in (cur_mm, cur_inc, cur_ecc, cur_raan,
prev_mm, prev_inc, prev_ecc, prev_raan)):
continue
# Convert mean motion (rev/day) to period (minutes)
cur_period = 1440.0 / cur_mm if cur_mm > 0 else 0
prev_period = 1440.0 / prev_mm if prev_mm > 0 else 0
reasons = []
t = _MANEUVER_THRESHOLDS
delta_period = abs(cur_period - prev_period)
if delta_period > t["period_min"]:
reasons.append(f"period Δ{delta_period:+.3f} min")
delta_inc = abs(cur_inc - prev_inc)
if delta_inc > t["inclination_deg"]:
reasons.append(f"inclination Δ{delta_inc:+.4f}°")
delta_ecc = abs(cur_ecc - prev_ecc)
if delta_ecc > t["eccentricity"]:
reasons.append(f"eccentricity Δ{delta_ecc:+.6f}")
# RAAN with J2 correction — only flag residual beyond expected precession
epoch_str = sat.get("EPOCH", "")
try:
epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
epoch_ts = epoch_dt.timestamp()
except (ValueError, TypeError):
epoch_ts = 0
prev_epoch_ts = prev.get("epoch_ts", 0)
dt_days = (epoch_ts - prev_epoch_ts) / 86400.0 if (epoch_ts and prev_epoch_ts) else 1.0
if dt_days > 0:
expected_raan_drift = _j2_raan_rate(cur_inc, cur_mm) * dt_days
actual_raan_change = cur_raan - prev_raan
# Normalize to [-180, 180]
actual_raan_change = (actual_raan_change + 180) % 360 - 180
raan_residual = abs(actual_raan_change - expected_raan_drift)
if raan_residual > t["raan_residual_deg"]:
reasons.append(f"RAAN residual {raan_residual:.3f}° (J2-corrected)")
if reasons:
alerts.append({
"norad_id": norad_id,
"name": sat.get("OBJECT_NAME", "UNKNOWN"),
"type": "maneuver",
"reasons": reasons,
"epoch": sat.get("EPOCH", ""),
"delta_period_min": round(delta_period, 4),
"delta_inclination_deg": round(delta_inc, 5),
"delta_eccentricity": round(delta_ecc, 7),
})
logger.info(f"Satellites: Maneuver scan — {len(alerts)} detections from {len(current_gp_data)} objects")
return alerts
def detect_decay_anomalies(current_gp_data):
"""Flag satellites with abnormal mean-motion change rates (possible decay).
A rapidly increasing mean motion indicates orbital decay the satellite
is losing altitude. Normal LEO drag is ~0.001 rev/day/day.
"""
if not _tle_history:
return []
alerts = []
for sat in current_gp_data:
norad_id = sat.get("NORAD_CAT_ID")
if norad_id is None:
continue
norad_id = int(norad_id)
prev = _tle_history.get(norad_id)
if prev is None:
continue
cur_mm = sat.get("MEAN_MOTION")
prev_mm = prev.get("MEAN_MOTION")
if cur_mm is None or prev_mm is None:
continue
epoch_str = sat.get("EPOCH", "")
try:
epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
epoch_ts = epoch_dt.timestamp()
except (ValueError, TypeError):
continue
prev_epoch_ts = prev.get("epoch_ts", 0)
dt_days = (epoch_ts - prev_epoch_ts) / 86400.0 if (epoch_ts and prev_epoch_ts) else 0
if dt_days < 0.5:
continue # Need at least 12h between TLEs for meaningful comparison
mm_rate = (cur_mm - prev_mm) / dt_days # rev/day per day
if abs(mm_rate) > _DECAY_MM_RATE_THRESHOLD:
cur_alt_km = (8681663.7 / (cur_mm ** (2.0 / 3.0))) - 6371.0 if cur_mm > 0 else 0
alerts.append({
"norad_id": norad_id,
"name": sat.get("OBJECT_NAME", "UNKNOWN"),
"type": "decay_anomaly",
"mm_rate": round(mm_rate, 6),
"current_mm": round(cur_mm, 4),
"approx_alt_km": round(cur_alt_km, 1),
"epoch": sat.get("EPOCH", ""),
"dt_days": round(dt_days, 2),
})
logger.info(f"Satellites: Decay scan — {len(alerts)} anomalies detected")
return alerts
def compute_overflights(gp_data, bbox, hours=24, step_minutes=10):
"""Count unique satellites whose ground track enters a bounding box.
Args:
gp_data: Full GP catalog (list of dicts with orbital elements).
bbox: Dict with keys 's', 'w', 'n', 'e' (degrees).
hours: Look-back window (default 24h).
step_minutes: Sampling interval (default 10 min).
Returns dict with total count and per-mission breakdown.
Uses SGP4 propagation CPU cost is ~O(catalog_size × timesteps).
Only propagates satellites that could plausibly overfly the bbox latitude range.
"""
if not gp_data or not bbox:
return {"total": 0, "by_mission": {}, "satellites": []}
south, west = bbox["s"], bbox["w"]
north, east = bbox["n"], bbox["e"]
now = datetime.utcnow()
steps = int(hours * 60 / step_minutes)
# Pre-filter: only propagate sats whose inclination allows them to reach bbox latitude
max_lat = max(abs(south), abs(north))
candidates = [s for s in gp_data if s.get("INCLINATION") is not None
and s.get("INCLINATION") >= max_lat * 0.8] # 20% margin
seen_ids = set()
results = []
by_mission = {}
for s in candidates:
norad_id = s.get("NORAD_CAT_ID")
mean_motion = s.get("MEAN_MOTION")
ecc = s.get("ECCENTRICITY")
incl = s.get("INCLINATION")
raan = s.get("RA_OF_ASC_NODE")
argp = s.get("ARG_OF_PERICENTER")
ma = s.get("MEAN_ANOMALY")
bstar = s.get("BSTAR", 0)
epoch_str = s.get("EPOCH", "")
if any(v is None for v in (mean_motion, ecc, incl, raan, argp, ma, epoch_str)):
continue
try:
epoch_dt = datetime.strptime(epoch_str[:19], "%Y-%m-%dT%H:%M:%S")
epoch_jd, epoch_fr = jday(
epoch_dt.year, epoch_dt.month, epoch_dt.day,
epoch_dt.hour, epoch_dt.minute, epoch_dt.second,
)
sat_obj = Satrec()
sat_obj.sgp4init(
WGS72, "i", norad_id or 0,
(epoch_jd + epoch_fr) - 2433281.5,
bstar, 0.0, 0.0, ecc,
math.radians(argp), math.radians(incl), math.radians(ma),
mean_motion * 2 * math.pi / 1440.0, math.radians(raan),
)
except (ValueError, TypeError):
continue
for step in range(steps):
t = now - timedelta(minutes=step * step_minutes)
jd_t, fr_t = jday(t.year, t.month, t.day, t.hour, t.minute, t.second)
e, r, _ = sat_obj.sgp4(jd_t, fr_t)
if e != 0:
continue
x, y, z = r
gmst = _gmst(jd_t + fr_t)
lng_rad = math.atan2(y, x) - gmst
lat_deg = math.degrees(math.atan2(z, math.sqrt(x * x + y * y)))
lng_deg = math.degrees(lng_rad) % 360
if lng_deg > 180:
lng_deg -= 360
# Check bounding box (handles antimeridian crossing)
lat_in = south <= lat_deg <= north
if west <= east:
lng_in = west <= lng_deg <= east
else:
lng_in = lng_deg >= west or lng_deg <= east
if lat_in and lng_in and norad_id not in seen_ids:
seen_ids.add(norad_id)
name = s.get("OBJECT_NAME", "UNKNOWN")
# Classify for mission breakdown
mission = "unknown"
for key, meta in _SAT_INTEL_DB:
if key.upper() in name.upper():
mission = meta.get("mission", "unknown")
break
by_mission[mission] = by_mission.get(mission, 0) + 1
results.append({"norad_id": norad_id, "name": name, "mission": mission})
break # Already counted this sat, move to next
return {"total": len(results), "by_mission": by_mission, "satellites": results}
def _parse_tle_to_gp(name, norad_id, line1, line2):
"""Convert TLE two-line element to CelesTrak GP-style dict."""
@@ -539,9 +613,18 @@ def fetch_satellites():
if not is_any_active("satellites"):
return
sats = []
maneuver_alerts = []
decay_alerts = []
starlink_summary = {}
data = None
classified = None
try:
now_ts = time.time()
# On first call, load TLE history from disk for maneuver detection
if not _tle_history:
_load_tle_history()
# On first call, try disk cache before hitting CelesTrak
if _sat_gp_cache["data"] is None:
disk_data = _load_sat_cache()
@@ -594,6 +677,9 @@ def fetch_satellites():
if lm:
_sat_gp_cache["last_modified"] = lm
_save_sat_cache(gp_data)
# Snapshot current TLEs as history before overwriting
# (the old _tle_history becomes the comparison baseline)
_snapshot_current_tles(gp_data)
logger.info(
f"Satellites: Downloaded {len(gp_data)} GP records from CelesTrak"
)
@@ -651,11 +737,14 @@ def fetch_satellites():
and _sat_classified_cache["data"]
):
classified = _sat_classified_cache["data"]
starlink_summary = _sat_classified_cache.get("starlink_summary", {})
logger.info(
f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)"
)
else:
classified = []
starlink_count = 0
starlink_shells = {} # inclination shell → count
for sat in data:
name = sat.get("OBJECT_NAME", "UNKNOWN").upper()
intel = None
@@ -663,8 +752,24 @@ def fetch_satellites():
if key.upper() in name:
intel = dict(meta)
break
if not intel:
# Secondary classification via SATCAT owner code
owner = sat.get("OWNER", sat.get("OBJECT_OWNER", ""))
if owner in _OWNER_CODE_MAP:
intel = {"country": _OWNER_CODE_MAP[owner], "mission": "general", "sat_type": "Unclassified"}
if not intel:
continue
# Starlink: count and summarize but don't propagate individually
# (6000+ sats would be too expensive to position every 60s)
if intel.get("mission") == "starlink":
starlink_count += 1
inc = sat.get("INCLINATION")
if inc is not None:
shell_key = f"{round(inc, 0):.0f}°"
starlink_shells[shell_key] = starlink_shells.get(shell_key, 0) + 1
continue # Skip individual propagation
entry = {
"id": sat.get("NORAD_CAT_ID"),
"name": sat.get("OBJECT_NAME", "UNKNOWN"),
@@ -679,14 +784,35 @@ def fetch_satellites():
}
entry.update(intel)
classified.append(entry)
starlink_summary = {
"total": starlink_count,
"shells": starlink_shells,
}
_sat_classified_cache["data"] = classified
_sat_classified_cache["starlink_summary"] = starlink_summary
_sat_classified_cache["gp_fetch_ts"] = _sat_gp_cache["last_fetch"]
logger.info(
f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog"
f"Satellites: {len(classified)} intel-classified, "
f"{starlink_count} Starlink (summarized), "
f"out of {len(data)} total in catalog"
)
all_sats = classified
# ── Run analysis detectors against the full GP catalog ──────────────
# These use cached TLEs only — no extra network requests.
maneuver_alerts = []
decay_alerts = []
try:
maneuver_alerts = detect_maneuvers(data)
except (ValueError, TypeError, KeyError, ZeroDivisionError) as e:
logger.error(f"Satellites: Maneuver detection error: {e}")
try:
decay_alerts = detect_decay_anomalies(data)
except (ValueError, TypeError, KeyError, ZeroDivisionError) as e:
logger.error(f"Satellites: Decay detection error: {e}")
now = datetime.utcnow()
jd, fr = jday(
now.year, now.month, now.day, now.hour, now.minute, now.second + now.microsecond / 1e6
@@ -800,6 +926,13 @@ def fetch_satellites():
with _data_lock:
latest_data["satellites"] = sats
latest_data["satellite_source"] = _sat_gp_cache.get("source", "none")
latest_data["satellite_analysis"] = {
"maneuvers": maneuver_alerts,
"decay_anomalies": decay_alerts,
"starlink": starlink_summary,
"catalog_size": len(data) if data else 0,
"classified_count": len(classified) if classified else 0,
}
_mark_fresh("satellites")
else:
with _data_lock:
+216
View File
@@ -0,0 +1,216 @@
"""WastewaterSCAN fetcher — pathogen surveillance via wastewater monitoring.
Data source: Stanford/Emory WastewaterSCAN project
- Plant locations: https://storage.googleapis.com/wastewater-dev-data/json/plants.json
- Time series: https://storage.googleapis.com/wastewater-dev-data/json/{uuid}.json
All data is public, no authentication required. ~192 treatment plants across
the US with daily sampling for COVID (N Gene), Influenza A/B, RSV, Norovirus,
MPXV, Measles, H5N1, and others.
"""
import logging
import time
import concurrent.futures
from datetime import datetime, timedelta
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.fetchers.retry import with_retry
logger = logging.getLogger(__name__)
_GCS_BASE = "https://storage.googleapis.com/wastewater-dev-data/json"
# Cache the plants list for 24 hours (it rarely changes)
_plants_cache: list[dict] = []
_plants_cache_ts: float = 0
_PLANTS_CACHE_TTL = 86400 # 24 hours
# Key pathogen targets to extract — maps internal target name to display label
_TARGET_DISPLAY: dict[str, str] = {
"N Gene": "COVID-19",
"Influenza A F1R1": "Influenza A",
"Influenza B": "Influenza B",
"RSV": "RSV",
"Noro_G2": "Norovirus",
"MPXV_G2R_WA": "Mpox",
"InfA_H5": "H5N1 (Bird Flu)",
"HMPV_4": "HMPV",
"Rota": "Rotavirus",
"HAV": "Hepatitis A",
"C_auris": "Candida auris",
"EVD68": "Enterovirus D68",
}
# Activity categories that represent elevated/alert levels
_ALERT_CATEGORIES = {"high", "very high", "above normal"}
def _fetch_plants() -> list[dict]:
"""Fetch the full plants list from GCS, with 24h caching."""
global _plants_cache, _plants_cache_ts
if _plants_cache and (time.time() - _plants_cache_ts) < _PLANTS_CACHE_TTL:
return _plants_cache
url = f"{_GCS_BASE}/plants.json"
resp = fetch_with_curl(url, timeout=30)
if resp.status_code != 200:
logger.warning(f"WastewaterSCAN plants fetch failed: HTTP {resp.status_code}")
return _plants_cache # return stale cache on failure
data = resp.json()
plants = data.get("plants", [])
_plants_cache = plants
_plants_cache_ts = time.time()
logger.info(f"WastewaterSCAN: cached {len(plants)} plant locations")
return plants
def _fetch_plant_latest(plant_id: str) -> dict | None:
"""Fetch the most recent sample for a single plant.
Returns a dict with pathogen levels or None on failure.
"""
url = f"{_GCS_BASE}/{plant_id}.json"
try:
resp = fetch_with_curl(url, timeout=12)
if resp.status_code != 200:
return None
data = resp.json()
samples = data.get("samples", [])
if not samples:
return None
# Find the most recent sample (last element, sorted by date)
latest = samples[-1]
collection_date = latest.get("collection_date", "")
# Skip samples older than 30 days
try:
sample_dt = datetime.strptime(collection_date, "%Y-%m-%d")
if sample_dt < datetime.utcnow() - timedelta(days=30):
return None
except (ValueError, TypeError):
pass
# Extract key pathogen levels
targets = latest.get("targets", {})
pathogens: list[dict] = []
alert_count = 0
for target_key, display_name in _TARGET_DISPLAY.items():
target_data = targets.get(target_key)
if not target_data:
continue
concentration = target_data.get("gc_g_dry_weight", 0) or 0
activity = target_data.get("activity_category", "not calculated")
normalized = target_data.get("gc_g_dry_weight_pmmov", 0) or 0
if concentration <= 0 and normalized <= 0:
continue # no detection
is_alert = activity.lower() in _ALERT_CATEGORIES
if is_alert:
alert_count += 1
pathogens.append({
"name": display_name,
"target_key": target_key,
"concentration": round(concentration, 1),
"normalized": round(normalized, 6),
"activity": activity,
"alert": is_alert,
})
if not pathogens:
return None
return {
"collection_date": collection_date,
"pathogens": pathogens,
"alert_count": alert_count,
}
except Exception as e:
logger.debug(f"WastewaterSCAN: failed to fetch plant {plant_id}: {e}")
return None
@with_retry(max_retries=1, base_delay=5)
def fetch_wastewater():
"""Fetch WastewaterSCAN plant locations and latest pathogen levels.
1. Fetches the plant list (cached 24h) for locations.
2. Concurrently fetches time series for all plants, extracting only
the most recent sample's pathogen data.
3. Merges into a flat list suitable for map rendering.
"""
from services.fetchers._store import is_any_active
if not is_any_active("wastewater"):
return
plants = _fetch_plants()
if not plants:
logger.warning("WastewaterSCAN: no plant data available")
return
# Build base records from plant metadata
plant_map: dict[str, dict] = {}
for p in plants:
point = p.get("point") or {}
coords = point.get("coordinates") or []
if len(coords) < 2:
continue
pid = p.get("id") or p.get("uuid", "")
if not pid:
continue
plant_map[pid] = {
"id": pid,
"name": p.get("name", ""),
"site_name": p.get("site_name", ""),
"city": p.get("city", ""),
"state": p.get("state", ""),
"country": p.get("country", "US"),
"population": p.get("sewershed_pop"),
"lat": coords[1],
"lng": coords[0],
"pathogens": [],
"alert_count": 0,
"collection_date": "",
"source": "WastewaterSCAN",
}
# Fetch latest samples concurrently (up to 12 threads)
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
futures = {
pool.submit(_fetch_plant_latest, pid): pid
for pid in plant_map
}
for fut in concurrent.futures.as_completed(futures, timeout=120):
pid = futures[fut]
try:
result = fut.result()
if result:
plant_map[pid]["pathogens"] = result["pathogens"]
plant_map[pid]["alert_count"] = result["alert_count"]
plant_map[pid]["collection_date"] = result["collection_date"]
except Exception:
pass
nodes = list(plant_map.values())
active_nodes = [n for n in nodes if n["pathogens"]]
logger.info(
f"WastewaterSCAN: {len(nodes)} plants, "
f"{len(active_nodes)} with recent pathogen data, "
f"{sum(n['alert_count'] for n in nodes)} total alerts"
)
with _data_lock:
latest_data["wastewater"] = nodes
if nodes:
_mark_fresh("wastewater")