mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
feat(env): NWS weather alerts, NOAA space weather, tropospheric ducting
- Environmental feed system with tick-based adapters - NWS Active Alerts: polls api.weather.gov, zone-based filtering - NOAA SWPC: Kp, SFI, R/S/G scales, band assessment, alert detection - Tropospheric ducting: Open-Meteo GFS refractivity profile, duct classification - !alerts command for active weather warnings - !solar / !hf commands for RF propagation (HF + UHF ducting) - Alert engine integration: severe weather, R3+ blackout, ducting events - LLM context injection for weather/propagation queries - Dashboard RF Propagation card with HF + UHF ducting display - EnvironmentalConfig with per-feed toggles in config.yaml
This commit is contained in:
parent
374fb835c5
commit
549ae4bdfb
20 changed files with 4142 additions and 2652 deletions
1
meshai/env/__init__.py
vendored
Normal file
1
meshai/env/__init__.py
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Environmental feeds package."""
|
||||
273
meshai/env/ducting.py
vendored
Normal file
273
meshai/env/ducting.py
vendored
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Tropospheric ducting assessment adapter using Open-Meteo GFS."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import DuctingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Pressure levels and approximate heights (meters)
|
||||
PRESSURE_LEVELS = {
|
||||
1000: 110, # ~110m
|
||||
925: 760, # ~760m
|
||||
850: 1500, # ~1500m
|
||||
700: 3000, # ~3000m
|
||||
}
|
||||
|
||||
|
||||
class DuctingAdapter:
|
||||
"""Tropospheric ducting assessment from Open-Meteo GFS pressure levels."""
|
||||
|
||||
def __init__(self, config: "DuctingConfig"):
|
||||
self._lat = config.latitude or 42.56
|
||||
self._lon = config.longitude or -114.47
|
||||
self._tick_interval = config.tick_seconds or 10800 # 3 hours
|
||||
self._last_tick = 0.0
|
||||
self._status = {}
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Check tick interval
|
||||
if now - self._last_tick < self._tick_interval:
|
||||
return False
|
||||
|
||||
self._last_tick = now
|
||||
return self._fetch()
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch GFS data from Open-Meteo API.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
# Build API URL
|
||||
hourly_vars = [
|
||||
"temperature_1000hPa", "temperature_925hPa",
|
||||
"temperature_850hPa", "temperature_700hPa",
|
||||
"relative_humidity_1000hPa", "relative_humidity_925hPa",
|
||||
"relative_humidity_850hPa", "relative_humidity_700hPa",
|
||||
"surface_pressure",
|
||||
]
|
||||
|
||||
url = (
|
||||
f"https://api.open-meteo.com/v1/gfs"
|
||||
f"?latitude={self._lat}&longitude={self._lon}"
|
||||
f"&hourly={','.join(hourly_vars)}"
|
||||
f"&forecast_days=1&timezone=auto"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning(f"Ducting API HTTP error: {e.code}")
|
||||
self._last_error = f"HTTP {e.code}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"Ducting API connection error: {e.reason}")
|
||||
self._last_error = str(e.reason)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ducting API error: {e}")
|
||||
self._last_error = str(e)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
self._parse_response(data)
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = True
|
||||
logger.info(f"Ducting assessment updated: {self._status.get('condition', 'unknown')}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ducting parse error: {e}")
|
||||
self._last_error = f"parse error: {e}"
|
||||
return False
|
||||
|
||||
def _parse_response(self, data):
|
||||
"""Parse Open-Meteo response and compute ducting assessment."""
|
||||
hourly = data.get("hourly", {})
|
||||
times = hourly.get("time", [])
|
||||
|
||||
if not times:
|
||||
raise ValueError("No time data in response")
|
||||
|
||||
# Find index closest to current time
|
||||
now = datetime.now()
|
||||
idx = 0
|
||||
for i, t in enumerate(times):
|
||||
try:
|
||||
dt = datetime.fromisoformat(t)
|
||||
if dt <= now:
|
||||
idx = i
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract values for current hour
|
||||
def get_val(key):
|
||||
vals = hourly.get(key, [])
|
||||
return vals[idx] if idx < len(vals) else None
|
||||
|
||||
# Build profile for each pressure level
|
||||
profile = []
|
||||
gradients = []
|
||||
|
||||
levels = sorted(PRESSURE_LEVELS.keys(), reverse=True) # 1000, 925, 850, 700
|
||||
|
||||
for i, pressure in enumerate(levels):
|
||||
temp_key = f"temperature_{pressure}hPa"
|
||||
rh_key = f"relative_humidity_{pressure}hPa"
|
||||
|
||||
t_celsius = get_val(temp_key)
|
||||
rh = get_val(rh_key)
|
||||
|
||||
if t_celsius is None or rh is None:
|
||||
continue
|
||||
|
||||
height_m = PRESSURE_LEVELS[pressure]
|
||||
|
||||
# Calculate radio refractivity N
|
||||
t_kelvin = t_celsius + 273.15
|
||||
|
||||
# Saturation vapor pressure (Magnus formula)
|
||||
e_sat = 6.112 * math.exp(17.67 * t_celsius / (t_celsius + 243.5))
|
||||
# Actual vapor pressure
|
||||
e = (rh / 100.0) * e_sat
|
||||
|
||||
# Radio refractivity
|
||||
n = 77.6 * (pressure / t_kelvin) + 3.73e5 * (e / t_kelvin**2)
|
||||
|
||||
# Modified refractivity (accounts for Earth curvature)
|
||||
h_km = height_m / 1000.0
|
||||
m = n + 157.0 * h_km
|
||||
|
||||
profile.append({
|
||||
"level_hPa": pressure,
|
||||
"height_m": height_m,
|
||||
"N": round(n, 1),
|
||||
"M": round(m, 1),
|
||||
"T_C": round(t_celsius, 1),
|
||||
"RH": round(rh, 1),
|
||||
})
|
||||
|
||||
# Compute gradients between adjacent levels
|
||||
for i in range(len(profile) - 1):
|
||||
lower = profile[i]
|
||||
upper = profile[i + 1]
|
||||
|
||||
dM = upper["M"] - lower["M"]
|
||||
dz = (upper["height_m"] - lower["height_m"]) / 1000.0 # km
|
||||
|
||||
if dz > 0:
|
||||
gradient = dM / dz
|
||||
gradients.append({
|
||||
"from_level": lower["level_hPa"],
|
||||
"to_level": upper["level_hPa"],
|
||||
"from_height_m": lower["height_m"],
|
||||
"to_height_m": upper["height_m"],
|
||||
"gradient": round(gradient, 1),
|
||||
})
|
||||
|
||||
# Classify conditions based on minimum gradient
|
||||
# Standard atmosphere: ~118 M-units/km
|
||||
# Normal: > 79
|
||||
# Super-refraction: 0 to 79
|
||||
# Ducting: < 0 (negative = trapping layer)
|
||||
|
||||
min_gradient = min((g["gradient"] for g in gradients), default=118)
|
||||
min_gradient_layer = None
|
||||
for g in gradients:
|
||||
if g["gradient"] == min_gradient:
|
||||
min_gradient_layer = g
|
||||
break
|
||||
|
||||
if min_gradient < 0:
|
||||
# Ducting detected
|
||||
if min_gradient_layer and min_gradient_layer["from_level"] == 1000:
|
||||
condition = "surface_duct"
|
||||
else:
|
||||
condition = "elevated_duct"
|
||||
|
||||
duct_base = min_gradient_layer["from_height_m"] if min_gradient_layer else 0
|
||||
duct_thickness = (
|
||||
min_gradient_layer["to_height_m"] - min_gradient_layer["from_height_m"]
|
||||
if min_gradient_layer else 0
|
||||
)
|
||||
assessment = "Ducting -- extended UHF range likely"
|
||||
|
||||
elif min_gradient < 79:
|
||||
condition = "super_refraction"
|
||||
duct_base = None
|
||||
duct_thickness = None
|
||||
assessment = "Enhanced range possible"
|
||||
|
||||
else:
|
||||
condition = "normal"
|
||||
duct_base = None
|
||||
duct_thickness = None
|
||||
assessment = "Normal propagation"
|
||||
|
||||
# Update status
|
||||
self._status = {
|
||||
"condition": condition,
|
||||
"min_gradient": round(min_gradient, 1),
|
||||
"duct_thickness_m": duct_thickness,
|
||||
"duct_base_m": duct_base,
|
||||
"profile": profile,
|
||||
"gradients": gradients,
|
||||
"assessment": assessment,
|
||||
"last_update": times[idx] if idx < len(times) else None,
|
||||
"fetched_at": time.time(),
|
||||
"location": {
|
||||
"lat": self._lat,
|
||||
"lon": self._lon,
|
||||
},
|
||||
}
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get current ducting status."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
return {
|
||||
"source": "ducting",
|
||||
"is_loaded": self._is_loaded,
|
||||
"last_error": str(self._last_error) if self._last_error else None,
|
||||
"consecutive_errors": self._consecutive_errors,
|
||||
"event_count": 0,
|
||||
"last_fetch": self._last_tick,
|
||||
}
|
||||
193
meshai/env/nws.py
vendored
Normal file
193
meshai/env/nws.py
vendored
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""NWS Active Alerts adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import NWSConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NWSAlertsAdapter:
|
||||
"""NWS Active Alerts -- polls api.weather.gov"""
|
||||
|
||||
def __init__(self, config: "NWSConfig"):
|
||||
self._areas = config.areas or ["ID"]
|
||||
self._user_agent = config.user_agent or "(meshai, ops@example.com)"
|
||||
self._severity_min = config.severity_min or "moderate"
|
||||
self._tick_interval = config.tick_seconds or 60
|
||||
self._last_tick = 0.0
|
||||
self._events = []
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._backoff_until = 0.0
|
||||
self._is_loaded = False
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Rate limit backoff
|
||||
if now < self._backoff_until:
|
||||
return False
|
||||
|
||||
# Check tick interval
|
||||
if now - self._last_tick < self._tick_interval:
|
||||
return False
|
||||
|
||||
self._last_tick = now
|
||||
return self._fetch()
|
||||
|
||||
def _fetch(self) -> bool:
|
||||
"""Fetch alerts from NWS API.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
areas = ",".join(self._areas)
|
||||
url = f"https://api.weather.gov/alerts/active?area={areas}"
|
||||
|
||||
headers = {
|
||||
"User-Agent": self._user_agent,
|
||||
"Accept": "application/geo+json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 429:
|
||||
self._backoff_until = time.time() + 5
|
||||
logger.warning("NWS rate limited, backing off 5s")
|
||||
else:
|
||||
logger.warning(f"NWS HTTP error: {e.code}")
|
||||
self._last_error = f"HTTP {e.code}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"NWS connection error: {e.reason}")
|
||||
self._last_error = str(e.reason)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"NWS fetch error: {e}")
|
||||
self._last_error = str(e)
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
# Parse response
|
||||
features = data.get("features", [])
|
||||
new_events = []
|
||||
|
||||
# Severity levels for filtering
|
||||
severity_levels = ["unknown", "minor", "moderate", "severe", "extreme"]
|
||||
try:
|
||||
min_idx = severity_levels.index(self._severity_min.lower())
|
||||
except ValueError:
|
||||
min_idx = 2 # default to moderate
|
||||
|
||||
for feature in features:
|
||||
props = feature.get("properties", {})
|
||||
|
||||
# Severity filtering
|
||||
severity = (props.get("severity") or "Unknown").lower()
|
||||
try:
|
||||
sev_idx = severity_levels.index(severity)
|
||||
except ValueError:
|
||||
sev_idx = 0
|
||||
|
||||
if sev_idx < min_idx:
|
||||
continue
|
||||
|
||||
# Parse timestamps
|
||||
onset = self._parse_iso(props.get("onset"))
|
||||
expires = self._parse_iso(props.get("expires"))
|
||||
|
||||
event = {
|
||||
"source": "nws",
|
||||
"event_id": props.get("id", ""),
|
||||
"event_type": props.get("event", "Unknown"),
|
||||
"severity": severity,
|
||||
"headline": props.get("headline", ""),
|
||||
"description": (props.get("description") or "")[:500],
|
||||
"onset": onset,
|
||||
"expires": expires,
|
||||
"areas": props.get("geocode", {}).get("UGC", []),
|
||||
"area_desc": props.get("areaDesc", ""),
|
||||
"fetched_at": time.time(),
|
||||
}
|
||||
|
||||
# Try to get centroid from geometry
|
||||
geom = feature.get("geometry")
|
||||
if geom and geom.get("coordinates"):
|
||||
try:
|
||||
coords = geom["coordinates"]
|
||||
if geom.get("type") == "Polygon" and coords:
|
||||
# Compute centroid of first ring
|
||||
ring = coords[0]
|
||||
lat_sum = sum(c[1] for c in ring)
|
||||
lon_sum = sum(c[0] for c in ring)
|
||||
event["lat"] = lat_sum / len(ring)
|
||||
event["lon"] = lon_sum / len(ring)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_events.append(event)
|
||||
|
||||
# Check if data changed
|
||||
old_ids = {e["event_id"] for e in self._events}
|
||||
new_ids = {e["event_id"] for e in new_events}
|
||||
changed = old_ids != new_ids
|
||||
|
||||
self._events = new_events
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = True
|
||||
|
||||
if changed:
|
||||
logger.info(f"NWS alerts updated: {len(new_events)} active")
|
||||
|
||||
return changed
|
||||
|
||||
def _parse_iso(self, iso_str: str) -> float:
|
||||
"""Parse ISO timestamp to epoch float."""
|
||||
if not iso_str:
|
||||
return 0.0
|
||||
try:
|
||||
# Handle various ISO formats
|
||||
if iso_str.endswith("Z"):
|
||||
iso_str = iso_str[:-1] + "+00:00"
|
||||
dt = datetime.fromisoformat(iso_str)
|
||||
return dt.timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current events."""
|
||||
return self._events
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
return {
|
||||
"source": "nws",
|
||||
"is_loaded": self._is_loaded,
|
||||
"last_error": str(self._last_error) if self._last_error else None,
|
||||
"consecutive_errors": self._consecutive_errors,
|
||||
"event_count": len(self._events),
|
||||
"last_fetch": self._last_tick,
|
||||
}
|
||||
168
meshai/env/store.py
vendored
Normal file
168
meshai/env/store.py
vendored
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Environmental data store with tick-based adapter polling."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import EnvironmentalConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnvironmentalStore:
|
||||
"""Cache and tick-driver for all environmental feed adapters."""
|
||||
|
||||
def __init__(self, config: "EnvironmentalConfig"):
|
||||
self._adapters = {} # name -> adapter instance
|
||||
self._events = {} # (source, event_id) -> event dict
|
||||
self._swpc_status = {} # Kp/SFI/scales snapshot
|
||||
self._ducting_status = {} # tropo ducting assessment
|
||||
self._mesh_zones = config.nws_zones or []
|
||||
|
||||
# Create adapter instances based on config
|
||||
if config.nws.enabled:
|
||||
from .nws import NWSAlertsAdapter
|
||||
self._adapters["nws"] = NWSAlertsAdapter(config.nws)
|
||||
|
||||
if config.swpc.enabled:
|
||||
from .swpc import SWPCAdapter
|
||||
self._adapters["swpc"] = SWPCAdapter(config.swpc)
|
||||
|
||||
if config.ducting.enabled:
|
||||
from .ducting import DuctingAdapter
|
||||
self._adapters["ducting"] = DuctingAdapter(config.ducting)
|
||||
|
||||
logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters")
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Called every second from main loop. Ticks each adapter.
|
||||
|
||||
Returns:
|
||||
True if any data changed
|
||||
"""
|
||||
changed = False
|
||||
for name, adapter in self._adapters.items():
|
||||
try:
|
||||
if adapter.tick():
|
||||
changed = True
|
||||
self._ingest(name, adapter)
|
||||
except Exception as e:
|
||||
logger.warning("Env adapter %s error: %s", name, e)
|
||||
|
||||
self._purge_expired()
|
||||
return changed
|
||||
|
||||
def _ingest(self, name: str, adapter):
|
||||
"""Ingest data from an adapter after it ticks."""
|
||||
if name == "swpc":
|
||||
self._swpc_status = adapter.get_status()
|
||||
# Also ingest any alert events (R-scale >= 3)
|
||||
for evt in adapter.get_events():
|
||||
self._events[(evt["source"], evt["event_id"])] = evt
|
||||
elif name == "ducting":
|
||||
self._ducting_status = adapter.get_status()
|
||||
else:
|
||||
for evt in adapter.get_events():
|
||||
self._events[(evt["source"], evt["event_id"])] = evt
|
||||
|
||||
def _purge_expired(self):
|
||||
"""Remove expired events."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
k for k, v in self._events.items()
|
||||
if v.get("expires") and v["expires"] < now
|
||||
]
|
||||
for k in expired:
|
||||
del self._events[k]
|
||||
|
||||
def get_active(self, source: str = None) -> list:
|
||||
"""Get active events, optionally filtered by source.
|
||||
|
||||
Args:
|
||||
source: Filter to specific source (nws, swpc, etc.)
|
||||
|
||||
Returns:
|
||||
List of event dicts sorted by fetched_at (newest first)
|
||||
"""
|
||||
events = list(self._events.values())
|
||||
if source:
|
||||
events = [e for e in events if e["source"] == source]
|
||||
return sorted(events, key=lambda e: e.get("fetched_at", 0), reverse=True)
|
||||
|
||||
def get_for_zones(self, zones: list) -> list:
|
||||
"""Get events affecting specific NWS zones.
|
||||
|
||||
Args:
|
||||
zones: List of UGC zone codes (e.g., ["IDZ016", "IDZ030"])
|
||||
|
||||
Returns:
|
||||
List of events with overlapping zone coverage
|
||||
"""
|
||||
zone_set = set(zones)
|
||||
return [
|
||||
e for e in self._events.values()
|
||||
if set(e.get("areas", [])) & zone_set
|
||||
]
|
||||
|
||||
def get_swpc_status(self) -> dict:
|
||||
"""Get current SWPC space weather status."""
|
||||
return self._swpc_status
|
||||
|
||||
def get_ducting_status(self) -> dict:
|
||||
"""Get current tropospheric ducting status."""
|
||||
return self._ducting_status
|
||||
|
||||
def get_rf_propagation(self) -> dict:
|
||||
"""Combined HF + UHF propagation summary for dashboard/LLM."""
|
||||
return {
|
||||
"hf": self._swpc_status,
|
||||
"uhf_ducting": self._ducting_status,
|
||||
}
|
||||
|
||||
def get_summary(self) -> str:
|
||||
"""Compact text block for LLM context injection."""
|
||||
lines = []
|
||||
lines.append(f"### Current Conditions (as of {time.strftime('%H:%M:%S MT')}):")
|
||||
|
||||
# NWS alerts
|
||||
nws = self.get_active(source="nws")
|
||||
if nws:
|
||||
lines.append(f"NWS: {len(nws)} active alert(s):")
|
||||
for a in nws[:3]:
|
||||
lines.append(f" - {a['event_type']}: {a['headline'][:120]}")
|
||||
else:
|
||||
lines.append("NWS: No active alerts for mesh area.")
|
||||
|
||||
# HF
|
||||
s = self._swpc_status
|
||||
if s:
|
||||
kp = s.get("kp_current", "?")
|
||||
sfi = s.get("sfi", "?")
|
||||
assessment = s.get("band_assessment", "Unknown")
|
||||
lines.append(f"HF: {assessment} -- SFI {sfi}, Kp {kp}")
|
||||
warnings = s.get("active_warnings", [])
|
||||
if warnings:
|
||||
for w in warnings[:2]:
|
||||
lines.append(f" Warning: {w}")
|
||||
else:
|
||||
lines.append("HF: Space weather data not available.")
|
||||
|
||||
# UHF ducting
|
||||
d = self._ducting_status
|
||||
if d:
|
||||
condition = d.get("condition", "unknown")
|
||||
if condition == "normal":
|
||||
lines.append("UHF Ducting: Normal propagation, no ducting detected.")
|
||||
elif condition in ("super_refraction", "ducting", "surface_duct", "elevated_duct"):
|
||||
gradient = d.get("min_gradient", "?")
|
||||
thickness = d.get("duct_thickness_m", "?")
|
||||
lines.append(f"UHF Ducting: {condition.replace('_', ' ').title()} detected")
|
||||
lines.append(f" dM/dz: {gradient} M-units/km, duct ~{thickness}m thick")
|
||||
lines.append(" Extended range likely on 906 MHz -- expect distant nodes")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_source_health(self) -> list:
|
||||
"""Get health status for all adapters."""
|
||||
return [a.health_status for a in self._adapters.values()]
|
||||
256
meshai/env/swpc.py
vendored
Normal file
256
meshai/env/swpc.py
vendored
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""NOAA Space Weather Prediction Center adapter."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import SWPCConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SWPCAdapter:
|
||||
"""NOAA Space Weather -- multi-endpoint with staggered ticks."""
|
||||
|
||||
# Endpoint definitions: (url, interval_seconds)
|
||||
ENDPOINTS = {
|
||||
"scales": ("https://services.swpc.noaa.gov/products/noaa-scales.json", 300),
|
||||
"kp": ("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json", 600),
|
||||
"alerts": ("https://services.swpc.noaa.gov/products/alerts.json", 120),
|
||||
"f107": ("https://services.swpc.noaa.gov/json/f107_cm_flux.json", 86400),
|
||||
}
|
||||
|
||||
def __init__(self, config: "SWPCConfig"):
|
||||
self._last_tick = {} # endpoint -> last_tick timestamp
|
||||
self._status = {}
|
||||
self._events = []
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = False
|
||||
|
||||
# Initialize tick times to 0
|
||||
for endpoint in self.ENDPOINTS:
|
||||
self._last_tick[endpoint] = 0.0
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Execute one polling tick.
|
||||
|
||||
Returns:
|
||||
True if data changed
|
||||
"""
|
||||
changed = False
|
||||
now = time.time()
|
||||
|
||||
for endpoint, (url, interval) in self.ENDPOINTS.items():
|
||||
if now - self._last_tick[endpoint] >= interval:
|
||||
self._last_tick[endpoint] = now
|
||||
if self._fetch_endpoint(endpoint, url):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self._update_assessment()
|
||||
|
||||
return changed
|
||||
|
||||
def _fetch_endpoint(self, endpoint: str, url: str) -> bool:
|
||||
"""Fetch a single endpoint.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
headers = {
|
||||
"User-Agent": "MeshAI/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
req = Request(url, headers=headers)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
except HTTPError as e:
|
||||
logger.warning(f"SWPC {endpoint} HTTP error: {e.code}")
|
||||
self._last_error = f"{endpoint}: HTTP {e.code}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except URLError as e:
|
||||
logger.warning(f"SWPC {endpoint} connection error: {e.reason}")
|
||||
self._last_error = f"{endpoint}: {e.reason}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"SWPC {endpoint} error: {e}")
|
||||
self._last_error = f"{endpoint}: {e}"
|
||||
self._consecutive_errors += 1
|
||||
return False
|
||||
|
||||
# Parse based on endpoint
|
||||
try:
|
||||
if endpoint == "scales":
|
||||
self._parse_scales(data)
|
||||
elif endpoint == "kp":
|
||||
self._parse_kp(data)
|
||||
elif endpoint == "alerts":
|
||||
self._parse_alerts(data)
|
||||
elif endpoint == "f107":
|
||||
self._parse_f107(data)
|
||||
|
||||
self._consecutive_errors = 0
|
||||
self._last_error = None
|
||||
self._is_loaded = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"SWPC {endpoint} parse error: {e}")
|
||||
self._last_error = f"{endpoint}: parse error"
|
||||
return False
|
||||
|
||||
def _parse_scales(self, data):
|
||||
"""Parse noaa-scales.json.
|
||||
|
||||
Data format: {""-1": {...}, "0": {...}, "1": {...}, ...}
|
||||
"0" is current.
|
||||
"""
|
||||
current = data.get("0", {})
|
||||
|
||||
r_data = current.get("R", {})
|
||||
s_data = current.get("S", {})
|
||||
g_data = current.get("G", {})
|
||||
|
||||
# Handle empty string or None Scale values
|
||||
def parse_scale(val):
|
||||
if val is None or val == "":
|
||||
return 0
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
self._status["r_scale"] = parse_scale(r_data.get("Scale"))
|
||||
self._status["s_scale"] = parse_scale(s_data.get("Scale"))
|
||||
self._status["g_scale"] = parse_scale(g_data.get("Scale"))
|
||||
|
||||
def _parse_kp(self, data):
|
||||
"""Parse noaa-planetary-k-index.json.
|
||||
|
||||
Data format: array of arrays
|
||||
First row is header: ["time_tag", "Kp", "a_running", "station_count"]
|
||||
Last row is most recent.
|
||||
"""
|
||||
if not data or len(data) < 2:
|
||||
return
|
||||
|
||||
# Find Kp column index from header
|
||||
header = data[0]
|
||||
try:
|
||||
kp_idx = header.index("Kp")
|
||||
except ValueError:
|
||||
kp_idx = 1
|
||||
|
||||
# Get last row
|
||||
last_row = data[-1]
|
||||
if len(last_row) > kp_idx:
|
||||
try:
|
||||
self._status["kp_current"] = float(last_row[kp_idx])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Get timestamp
|
||||
if len(last_row) > 0:
|
||||
self._status["kp_timestamp"] = last_row[0]
|
||||
|
||||
def _parse_alerts(self, data):
|
||||
"""Parse alerts.json.
|
||||
|
||||
Data format: array of objects with product_id, issue_datetime, message
|
||||
"""
|
||||
warnings = []
|
||||
if isinstance(data, list):
|
||||
for alert in data[:5]: # Keep most recent 5
|
||||
message = alert.get("message", "")
|
||||
# Extract first line as headline
|
||||
headline = message.split("\n")[0].strip()
|
||||
if headline:
|
||||
warnings.append(headline)
|
||||
|
||||
self._status["active_warnings"] = warnings
|
||||
|
||||
def _parse_f107(self, data):
|
||||
"""Parse f107_cm_flux.json.
|
||||
|
||||
Data format: array of objects with time_tag, flux
|
||||
"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Get most recent entry (last in list)
|
||||
if isinstance(data, list) and data:
|
||||
last = data[-1]
|
||||
if isinstance(last, dict):
|
||||
try:
|
||||
self._status["sfi"] = float(last.get("flux", 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
def _update_assessment(self):
|
||||
"""Compute band assessment from SFI and Kp."""
|
||||
sfi = self._status.get("sfi", 0)
|
||||
kp = self._status.get("kp_current", 0)
|
||||
|
||||
# Band assessment formula
|
||||
if sfi > 150 and kp <= 1:
|
||||
assessment = "Excellent"
|
||||
detail = "Upper HF bands (10m-20m) open, solid DX conditions"
|
||||
elif sfi >= 100 and kp <= 3:
|
||||
assessment = "Good"
|
||||
detail = "Upper HF bands (10m-20m) open, solid DX conditions"
|
||||
elif sfi >= 80 and kp <= 4:
|
||||
assessment = "Fair"
|
||||
detail = "Mid HF bands (20m-40m) usable, upper bands marginal"
|
||||
else:
|
||||
assessment = "Poor"
|
||||
detail = "HF conditions degraded, stick to lower bands (40m-80m)"
|
||||
|
||||
self._status["band_assessment"] = assessment
|
||||
self._status["band_detail"] = detail
|
||||
|
||||
# Generate events for R-scale >= 3
|
||||
self._events = []
|
||||
r_scale = self._status.get("r_scale", 0)
|
||||
if r_scale >= 3:
|
||||
self._events.append({
|
||||
"source": "swpc",
|
||||
"event_id": f"swpc_r{r_scale}_{int(time.time())}",
|
||||
"event_type": f"R{r_scale} Radio Blackout",
|
||||
"severity": "warning" if r_scale >= 3 else "advisory",
|
||||
"headline": f"R{r_scale} HF Radio Blackout -- HF comms degraded",
|
||||
"expires": time.time() + 3600, # 1hr TTL
|
||||
"areas": [],
|
||||
"fetched_at": time.time(),
|
||||
})
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get current SWPC status."""
|
||||
return self._status
|
||||
|
||||
def get_events(self) -> list:
|
||||
"""Get current alert events."""
|
||||
return self._events
|
||||
|
||||
@property
|
||||
def health_status(self) -> dict:
|
||||
"""Get adapter health status."""
|
||||
return {
|
||||
"source": "swpc",
|
||||
"is_loaded": self._is_loaded,
|
||||
"last_error": str(self._last_error) if self._last_error else None,
|
||||
"consecutive_errors": self._consecutive_errors,
|
||||
"event_count": len(self._events),
|
||||
"last_fetch": max(self._last_tick.values()) if self._last_tick else 0,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue