feat: add space weather + AI watch feeds, fix data binding issues
- Add space_weather source (NOAA SWPC: Kp index, X-ray flux, alerts) - Add ai_watch source (arXiv cs.AI/LG/CL, HuggingFace, lab trending) - Fix RSS circuit breaker: per-feed source names prevent cascade trips - Fix displacement field: internally_displaced (was reading idps) - Fix energy prices: handle nested oil.brent/wti structure - Fix climate anomalies: use temp_anomaly_c and precip_anomaly_pct - Fix news ticker: add feed_name to source fallback chain - Remove defunct Reuters RSS feed, update War Zone URL - Disable HTML caching for dev-friendly reloads Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
affaf4b200
commit
561ed8e277
@@ -33,6 +33,8 @@ from world_intel_mcp.sources import (
|
||||
climate,
|
||||
conflict,
|
||||
intelligence,
|
||||
space_weather,
|
||||
ai_watch,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,6 +89,8 @@ async def _fetch_overview() -> dict:
|
||||
"displacement": displacement.fetch_displacement_summary(fetcher),
|
||||
"risk_scores": intelligence.fetch_risk_scores(fetcher),
|
||||
"signal_convergence": intelligence.fetch_signal_convergence(fetcher),
|
||||
"space_weather": space_weather.fetch_space_weather(fetcher),
|
||||
"ai_watch": ai_watch.fetch_ai_watch(fetcher),
|
||||
}
|
||||
|
||||
gathered = await asyncio.gather(
|
||||
@@ -114,16 +118,10 @@ async def _fetch_overview() -> dict:
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INDEX_HTML: str | None = None
|
||||
|
||||
|
||||
async def index(request):
|
||||
"""Serve the dashboard HTML page."""
|
||||
global _INDEX_HTML
|
||||
if _INDEX_HTML is None:
|
||||
html_path = Path(__file__).parent / "index.html"
|
||||
_INDEX_HTML = html_path.read_text()
|
||||
return HTMLResponse(_INDEX_HTML)
|
||||
"""Serve the dashboard HTML page (reloads on each request during dev)."""
|
||||
html_path = Path(__file__).parent / "index.html"
|
||||
return HTMLResponse(html_path.read_text())
|
||||
|
||||
|
||||
async def api_overview(request):
|
||||
|
||||
@@ -704,6 +704,13 @@ function updateHudStats(data) {
|
||||
if (data.displacement && !data.displacement.error && data.displacement.global_totals) {
|
||||
pills.push('<div class="stat-pill"><span class="v warn">' + fmtBigPlain(data.displacement.global_totals.grand_total || 0) + '</span><span class="l">Displaced</span></div>');
|
||||
}
|
||||
if (data.space_weather && !data.space_weather.error && data.space_weather.current_kp != null) {
|
||||
var swKp = data.space_weather.current_kp;
|
||||
pills.push('<div class="stat-pill"><span class="v' + (swKp >= 5 ? ' warn' : '') + '">' + swKp.toFixed(0) + '</span><span class="l">Kp</span></div>');
|
||||
}
|
||||
if (data.ai_watch && !data.ai_watch.error) {
|
||||
pills.push('<div class="stat-pill"><span class="v">' + (data.ai_watch.count || 0) + '</span><span class="l">AI Papers</span></div>');
|
||||
}
|
||||
$('#hudStats').innerHTML = safe(pills.join(''));
|
||||
}
|
||||
|
||||
@@ -776,13 +783,17 @@ function updateDrawer(data) {
|
||||
}
|
||||
}
|
||||
if (data.energy_prices && !data.energy_prices.error) {
|
||||
var prices = data.energy_prices.prices || data.energy_prices.data || data.energy_prices;
|
||||
var entries = Array.isArray(prices) ? prices : Object.entries(prices).map(function(e) { return Object.assign({name: e[0]}, typeof e[1] === 'object' ? e[1] : {value: e[1]}); });
|
||||
if (entries.length) {
|
||||
h += '<div class="sub">Energy</div><table class="dtable"><tbody>';
|
||||
entries.slice(0, 6).forEach(function(item) {
|
||||
var v = item.value || item.price || item.last_value;
|
||||
h += '<tr><td>' + esc((item.name || '?').replace(/_/g, ' ')) + '</td><td class="bright">' + (typeof v === 'number' ? fmtNum(v) : esc(String(v || '\u2014'))) + '</td></tr>';
|
||||
var ep = data.energy_prices;
|
||||
var eRows = [];
|
||||
if (ep.oil) {
|
||||
if (ep.oil.brent && ep.oil.brent.price != null) eRows.push({name: 'Brent Crude', price: ep.oil.brent.price, date: ep.oil.brent.date});
|
||||
if (ep.oil.wti && ep.oil.wti.price != null) eRows.push({name: 'WTI Crude', price: ep.oil.wti.price, date: ep.oil.wti.date});
|
||||
}
|
||||
if (ep.natural_gas && ep.natural_gas.price != null) eRows.push({name: 'Natural Gas', price: ep.natural_gas.price, date: ep.natural_gas.date});
|
||||
if (eRows.length) {
|
||||
h += '<div class="sub">Energy</div><table class="dtable"><thead><tr><th>Commodity</th><th>Price</th><th>Date</th></tr></thead><tbody>';
|
||||
eRows.forEach(function(item) {
|
||||
h += '<tr><td>' + esc(item.name) + '</td><td class="bright">$' + fmtNum(item.price) + '</td><td class="dim">' + esc(item.date || '\u2014') + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
@@ -860,6 +871,25 @@ function updateDrawer(data) {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.space_weather && !data.space_weather.error) {
|
||||
var sw = data.space_weather;
|
||||
h += '<div class="sub">Space Weather</div><div class="mini-row">';
|
||||
var kpVal = sw.current_kp;
|
||||
var kpCls = kpVal >= 7 ? ' crit' : kpVal >= 5 ? ' warn' : '';
|
||||
h += '<div class="mini-box"><div class="v' + kpCls + '">' + (kpVal != null ? fmtNum(kpVal, 1) : '\u2014') + '</div><div class="l">Kp Index</div></div>';
|
||||
h += '<div class="mini-box"><div class="v">' + esc(sw.kp_level || '\u2014') + '</div><div class="l">Geo Level</div></div>';
|
||||
h += '<div class="mini-box"><div class="v">' + esc(sw.latest_flare_class || '\u2014') + '</div><div class="l">X-Ray Flux</div></div>';
|
||||
h += '</div>';
|
||||
var swAlerts = sw.alerts || [];
|
||||
if (swAlerts.length) {
|
||||
h += '<table class="dtable"><thead><tr><th>Alert</th><th>Time</th></tr></thead><tbody>';
|
||||
swAlerts.slice(0, 5).forEach(function(a) {
|
||||
h += '<tr><td class="warn">' + esc(trunc(a.message || '?', 50)) + '</td><td class="dim">' + esc(ago(a.issue_datetime)) + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── INTELLIGENCE ──
|
||||
h += '<div class="sh">INTELLIGENCE</div>';
|
||||
if (data.trending_keywords && !data.trending_keywords.error) {
|
||||
@@ -907,7 +937,7 @@ function updateDrawer(data) {
|
||||
if (origins.length) {
|
||||
h += '<table class="dtable"><thead><tr><th>Origin</th><th>Refugees</th><th>IDPs</th></tr></thead><tbody>';
|
||||
origins.slice(0, 8).forEach(function(o) {
|
||||
h += '<tr><td class="bright">' + esc(o.country_name || o.country || '?') + '</td><td>' + fmtBigPlain(o.refugees || 0) + '</td><td>' + fmtBigPlain(o.idps || 0) + '</td></tr>';
|
||||
h += '<tr><td class="bright">' + esc(o.country_name || o.country || '?') + '</td><td>' + fmtBigPlain(o.refugees || 0) + '</td><td>' + fmtBigPlain(o.internally_displaced || o.idps || 0) + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
@@ -931,14 +961,48 @@ function updateDrawer(data) {
|
||||
if (anomalies.length) {
|
||||
h += '<div class="sub">Climate Anomalies</div><table class="dtable"><thead><tr><th>Zone</th><th>Temp</th><th>Precip</th></tr></thead><tbody>';
|
||||
anomalies.slice(0, 8).forEach(function(a) {
|
||||
var temp = a.temperature_anomaly || a.temp_anomaly || a.temp_deviation;
|
||||
var precip = a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation;
|
||||
h += '<tr><td>' + esc(a.zone || a.name || a.region || '?') + '</td><td class="' + (temp > 0 ? 'warn' : 'up') + '">' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '</td><td class="dim">' + (precip != null ? fmtNum(precip, 1) + 'mm' : '\u2014') + '</td></tr>';
|
||||
var temp = a.temp_anomaly_c != null ? a.temp_anomaly_c : (a.temperature_anomaly || a.temp_anomaly || a.temp_deviation);
|
||||
var precip = a.precip_anomaly_pct != null ? a.precip_anomaly_pct : (a.precipitation_anomaly || a.precip_anomaly || a.precip_deviation);
|
||||
h += '<tr><td>' + esc(a.zone || a.name || a.region || '?') + '</td><td class="' + (temp > 0 ? 'warn' : 'up') + '">' + (temp != null ? (temp > 0 ? '+' : '') + fmtNum(temp, 1) + '\u00B0C' : '\u2014') + '</td><td class="' + (precip > 50 ? 'warn' : precip < -50 ? 'down' : 'dim') + '">' + (precip != null ? (precip > 0 ? '+' : '') + fmtNum(precip, 0) + '%' : '\u2014') + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── AGI WATCH ──
|
||||
h += '<div class="sh">AGI WATCH</div>';
|
||||
if (data.ai_watch && !data.ai_watch.error) {
|
||||
var aiw = data.ai_watch;
|
||||
var labTrend = aiw.lab_trending || [];
|
||||
if (labTrend.length) {
|
||||
h += '<div class="sub">Lab Activity</div><div class="tags">';
|
||||
labTrend.slice(0, 12).forEach(function(l) {
|
||||
h += '<span class="tag' + (l.mentions > 3 ? ' tag-hot' : '') + '">' + esc(l.lab) + ' (' + l.mentions + ')</span>';
|
||||
});
|
||||
h += '</div>';
|
||||
}
|
||||
var byCat = aiw.by_category || {};
|
||||
if (Object.keys(byCat).length) {
|
||||
h += '<div class="mini-row">';
|
||||
for (var catKey in byCat) {
|
||||
if (byCat.hasOwnProperty(catKey)) {
|
||||
h += '<div class="mini-box"><div class="v">' + byCat[catKey] + '</div><div class="l">' + esc(catKey) + '</div></div>';
|
||||
}
|
||||
}
|
||||
h += '</div>';
|
||||
}
|
||||
var aiItems = aiw.items || [];
|
||||
if (aiItems.length) {
|
||||
h += '<table class="dtable"><thead><tr><th>Paper/Post</th><th>Source</th><th>Age</th></tr></thead><tbody>';
|
||||
aiItems.slice(0, 12).forEach(function(item) {
|
||||
h += '<tr><td><a href="' + esc(item.link || '#') + '" target="_blank">' + esc(trunc(item.title || '?', 40)) + '</a></td><td class="dim">' + esc(item.feed_name || '\u2014') + '</td><td class="dim">' + ago(item.published) + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
} else {
|
||||
h += '<div class="dim" style="font-size:0.7rem;padding:4px 0">Loading AI feeds...</div>';
|
||||
}
|
||||
|
||||
$('#drawerBody').innerHTML = safe(h);
|
||||
}
|
||||
|
||||
@@ -949,7 +1013,7 @@ function updateTicker(data) {
|
||||
var articles = data.news_feed.articles || data.news_feed.items || [];
|
||||
if (!articles.length) return;
|
||||
var items = articles.slice(0, 30).map(function(a) {
|
||||
return '<span class="ticker-item">' + esc(trunc(a.title || '?', 70)) + '<span class="src">' + esc(a.source || a.feed || '') + '</span></span>';
|
||||
return '<span class="ticker-item">' + esc(trunc(a.title || '?', 70)) + '<span class="src">' + esc(a.feed_name || a.source || a.feed || '') + '</span></span>';
|
||||
});
|
||||
// Duplicate for seamless loop
|
||||
var all = items.join('<span class="ticker-item sep">\u2022</span>');
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""AI/AGI development tracking source for world-intel-mcp.
|
||||
|
||||
Monitors the latest AI research publications, model releases, and
|
||||
industry developments via RSS feeds from arXiv, Hugging Face, and
|
||||
major AI news outlets. No API keys required.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
try:
|
||||
import feedparser
|
||||
except ImportError:
|
||||
feedparser = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.ai_watch")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feed sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AI_FEEDS: list[tuple[str, str, str]] = [
|
||||
# (name, url, category)
|
||||
("arXiv cs.AI", "https://rss.arxiv.org/rss/cs.AI", "research"),
|
||||
("arXiv cs.LG", "https://rss.arxiv.org/rss/cs.LG", "research"),
|
||||
("arXiv cs.CL", "https://rss.arxiv.org/rss/cs.CL", "research"),
|
||||
("HuggingFace Blog", "https://huggingface.co/blog/feed.xml", "industry"),
|
||||
("The Gradient", "https://thegradient.pub/rss/", "analysis"),
|
||||
("Import AI", "https://importai.substack.com/feed", "newsletter"),
|
||||
]
|
||||
|
||||
# Key AI labs to track mentions of
|
||||
_AI_LABS = [
|
||||
"openai", "anthropic", "google", "deepmind", "meta", "mistral",
|
||||
"xai", "cohere", "stability", "midjourney", "nvidia", "microsoft",
|
||||
"apple", "hugging face", "databricks", "together", "groq",
|
||||
]
|
||||
|
||||
_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _parse_published(entry: dict) -> str | None:
|
||||
"""Parse an RSS entry's published date to ISO 8601 UTC string."""
|
||||
import time as _time
|
||||
|
||||
parsed_tuple = entry.get("published_parsed")
|
||||
if parsed_tuple is not None:
|
||||
try:
|
||||
epoch = _time.mktime(parsed_tuple[:9])
|
||||
dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
pass
|
||||
|
||||
updated_tuple = entry.get("updated_parsed")
|
||||
if updated_tuple is not None:
|
||||
try:
|
||||
epoch = _time.mktime(updated_tuple[:9])
|
||||
dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
pass
|
||||
|
||||
return entry.get("published") or entry.get("updated")
|
||||
|
||||
|
||||
def _extract_lab_mentions(text: str) -> list[str]:
|
||||
"""Extract AI lab names mentioned in text."""
|
||||
lower = text.lower()
|
||||
return [lab for lab in _AI_LABS if lab in lower]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def fetch_ai_watch(
|
||||
fetcher: Fetcher,
|
||||
limit: int = 50,
|
||||
) -> dict:
|
||||
"""Fetch latest AI/AGI developments from research and industry feeds.
|
||||
|
||||
Aggregates recent papers, blog posts, and announcements from key
|
||||
AI sources, sorted by recency. Extracts lab mentions for trend
|
||||
tracking.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher with caching and circuit breaking.
|
||||
limit: Maximum number of items to return.
|
||||
|
||||
Returns:
|
||||
Dict with items list, lab mention counts, source counts, and metadata.
|
||||
"""
|
||||
if feedparser is None:
|
||||
return {
|
||||
"error": "feedparser not installed — run: pip install feedparser",
|
||||
"items": [],
|
||||
"count": 0,
|
||||
}
|
||||
|
||||
all_items: list[dict] = []
|
||||
|
||||
async def _fetch_feed(
|
||||
name: str, url: str, category: str,
|
||||
) -> list[dict]:
|
||||
safe_name = name.lower().replace(" ", "_").replace(".", "_")
|
||||
xml_text = await fetcher.get_xml(
|
||||
url,
|
||||
source=f"ai_watch:{safe_name}",
|
||||
cache_key=f"ai_watch:rss:{safe_name}",
|
||||
cache_ttl=_CACHE_TTL,
|
||||
)
|
||||
|
||||
if xml_text is None:
|
||||
logger.debug("No data from AI feed %s", name)
|
||||
return []
|
||||
|
||||
parsed = feedparser.parse(xml_text)
|
||||
items: list[dict] = []
|
||||
|
||||
for entry in parsed.get("entries", [])[:30]:
|
||||
title = entry.get("title", "")
|
||||
summary = entry.get("summary") or entry.get("description") or ""
|
||||
combined_text = f"{title} {summary}"
|
||||
|
||||
items.append({
|
||||
"title": title,
|
||||
"link": entry.get("link", ""),
|
||||
"published": _parse_published(entry),
|
||||
"summary": summary[:200] if len(summary) > 200 else summary,
|
||||
"feed_name": name,
|
||||
"category": category,
|
||||
"lab_mentions": _extract_lab_mentions(combined_text),
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
# Fetch all feeds in parallel
|
||||
tasks = [_fetch_feed(name, url, cat) for name, url, cat in _AI_FEEDS]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for items in results:
|
||||
all_items.extend(items)
|
||||
|
||||
# Sort by published date descending
|
||||
all_items.sort(
|
||||
key=lambda item: item.get("published") or "",
|
||||
reverse=True,
|
||||
)
|
||||
all_items = all_items[:limit]
|
||||
|
||||
# Compute lab mention counts
|
||||
lab_counts: dict[str, int] = {}
|
||||
for item in all_items:
|
||||
for lab in item.get("lab_mentions", []):
|
||||
lab_counts[lab] = lab_counts.get(lab, 0) + 1
|
||||
|
||||
# Sort by count descending
|
||||
lab_trending = sorted(
|
||||
[{"lab": k, "mentions": v} for k, v in lab_counts.items()],
|
||||
key=lambda x: x["mentions"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Count by category
|
||||
by_category: dict[str, int] = {}
|
||||
for item in all_items:
|
||||
cat = item.get("category", "other")
|
||||
by_category[cat] = by_category.get(cat, 0) + 1
|
||||
|
||||
return {
|
||||
"items": all_items,
|
||||
"count": len(all_items),
|
||||
"lab_trending": lab_trending,
|
||||
"by_category": by_category,
|
||||
"feeds_used": len(_AI_FEEDS),
|
||||
"source": "ai-watch",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
@@ -27,10 +27,9 @@ logger = logging.getLogger("world-intel-mcp.sources.news")
|
||||
|
||||
_RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
"geopolitics": [
|
||||
("Reuters World", "https://feeds.reuters.com/Reuters/worldNews"),
|
||||
("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"),
|
||||
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
|
||||
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
|
||||
("AP Top News", "https://rsshub.app/apnews/topics/apf-topnews"),
|
||||
],
|
||||
"security": [
|
||||
("BleepingComputer", "https://www.bleepingcomputer.com/feed/"),
|
||||
@@ -51,7 +50,7 @@ _RSS_FEEDS: dict[str, list[tuple[str, str]]] = {
|
||||
"military": [
|
||||
("Defense One", "https://www.defenseone.com/rss/"),
|
||||
("War on the Rocks", "https://warontherocks.com/feed/"),
|
||||
("The War Zone", "https://www.thedrive.com/the-war-zone/feed"),
|
||||
("The War Zone", "https://www.twz.com/feed"),
|
||||
],
|
||||
"science": [
|
||||
("Nature", "https://www.nature.com/nature.rss"),
|
||||
@@ -188,7 +187,7 @@ async def fetch_news_feed(
|
||||
safe_name = feed_name.lower().replace(" ", "_")
|
||||
xml_text = await fetcher.get_xml(
|
||||
url,
|
||||
source="rss",
|
||||
source=f"rss:{safe_name}",
|
||||
cache_key=f"news:rss:{safe_name}",
|
||||
cache_ttl=300,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Space weather and solar activity source for world-intel-mcp.
|
||||
|
||||
Provides real-time solar activity monitoring via NOAA's Space Weather
|
||||
Prediction Center (SWPC). No API key required.
|
||||
|
||||
Data includes:
|
||||
- Solar flare activity (X-ray flux class)
|
||||
- Geomagnetic storm indices (Kp, Dst)
|
||||
- Solar wind speed and density
|
||||
- Coronal mass ejection (CME) alerts
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..fetcher import Fetcher
|
||||
|
||||
logger = logging.getLogger("world-intel-mcp.sources.space_weather")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOAA SWPC endpoints (all free, no API key)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SWPC_BASE = "https://services.swpc.noaa.gov"
|
||||
|
||||
# 3-day solar/geomagnetic forecast
|
||||
_FORECAST_URL = f"{_SWPC_BASE}/products/noaa-planetary-k-index-forecast.json"
|
||||
|
||||
# Current planetary K-index (geomagnetic disturbance, 0-9)
|
||||
_KP_URL = f"{_SWPC_BASE}/products/noaa-planetary-k-index.json"
|
||||
|
||||
# Recent solar flares (R1-R5 scale)
|
||||
_FLARE_URL = f"{_SWPC_BASE}/json/goes/primary/xrays-6-hour.json"
|
||||
|
||||
# Solar wind real-time plasma data
|
||||
_PLASMA_URL = f"{_SWPC_BASE}/products/solar-wind/plasma-7-day.json"
|
||||
|
||||
# Alerts and warnings
|
||||
_ALERTS_URL = f"{_SWPC_BASE}/products/alerts.json"
|
||||
|
||||
_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _classify_kp(kp: float) -> str:
|
||||
"""Classify Kp index into storm level."""
|
||||
if kp >= 9:
|
||||
return "G5 Extreme"
|
||||
elif kp >= 8:
|
||||
return "G4 Severe"
|
||||
elif kp >= 7:
|
||||
return "G3 Strong"
|
||||
elif kp >= 6:
|
||||
return "G2 Moderate"
|
||||
elif kp >= 5:
|
||||
return "G1 Minor"
|
||||
elif kp >= 4:
|
||||
return "Active"
|
||||
else:
|
||||
return "Quiet"
|
||||
|
||||
|
||||
def _classify_xray(flux: float) -> str:
|
||||
"""Classify X-ray flux into flare class (A, B, C, M, X)."""
|
||||
if flux >= 1e-4:
|
||||
return f"X{flux / 1e-4:.1f}"
|
||||
elif flux >= 1e-5:
|
||||
return f"M{flux / 1e-5:.1f}"
|
||||
elif flux >= 1e-6:
|
||||
return f"C{flux / 1e-6:.1f}"
|
||||
elif flux >= 1e-7:
|
||||
return f"B{flux / 1e-7:.1f}"
|
||||
else:
|
||||
return "A"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def fetch_space_weather(fetcher: Fetcher) -> dict:
|
||||
"""Fetch current space weather conditions from NOAA SWPC.
|
||||
|
||||
Returns a composite view of solar and geomagnetic activity including
|
||||
current Kp index, latest X-ray flux class, solar wind speed, and
|
||||
any active alerts/warnings.
|
||||
|
||||
Args:
|
||||
fetcher: Shared HTTP fetcher with caching and circuit breaking.
|
||||
|
||||
Returns:
|
||||
Dict with current conditions, alerts, forecast, and metadata.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Fetch all sources in parallel
|
||||
kp_data, flare_data, alerts_data = await asyncio.gather(
|
||||
fetcher.get_json(
|
||||
_KP_URL,
|
||||
source="swpc",
|
||||
cache_key="space:kp",
|
||||
cache_ttl=_CACHE_TTL,
|
||||
),
|
||||
fetcher.get_json(
|
||||
_FLARE_URL,
|
||||
source="swpc",
|
||||
cache_key="space:xray",
|
||||
cache_ttl=_CACHE_TTL,
|
||||
),
|
||||
fetcher.get_json(
|
||||
_ALERTS_URL,
|
||||
source="swpc",
|
||||
cache_key="space:alerts",
|
||||
cache_ttl=_CACHE_TTL,
|
||||
),
|
||||
)
|
||||
|
||||
result: dict = {
|
||||
"current_kp": None,
|
||||
"kp_level": "Unknown",
|
||||
"latest_flare_class": None,
|
||||
"solar_wind_speed_km_s": None,
|
||||
"alerts": [],
|
||||
"kp_recent": [],
|
||||
"source": "noaa-swpc",
|
||||
"timestamp": _utc_now_iso(),
|
||||
}
|
||||
|
||||
# --- Kp index ---
|
||||
if kp_data and isinstance(kp_data, list) and len(kp_data) > 1:
|
||||
# First row is header, rest are data [time_tag, Kp, ...]
|
||||
try:
|
||||
# Get most recent Kp reading
|
||||
latest = kp_data[-1]
|
||||
kp_val = float(latest[1])
|
||||
result["current_kp"] = kp_val
|
||||
result["kp_level"] = _classify_kp(kp_val)
|
||||
|
||||
# Last 8 readings (24 hours of 3-hourly data)
|
||||
recent = []
|
||||
for row in kp_data[-9:-1]: # skip header
|
||||
if isinstance(row, list) and len(row) >= 2:
|
||||
try:
|
||||
recent.append({
|
||||
"time": row[0],
|
||||
"kp": float(row[1]),
|
||||
})
|
||||
except (ValueError, TypeError, IndexError):
|
||||
pass
|
||||
result["kp_recent"] = recent
|
||||
except (ValueError, TypeError, IndexError) as exc:
|
||||
logger.warning("Failed to parse Kp data: %s", exc)
|
||||
|
||||
# --- X-ray flux (flare activity) ---
|
||||
if flare_data and isinstance(flare_data, list) and len(flare_data) > 1:
|
||||
try:
|
||||
# Last entry has the most recent flux reading
|
||||
latest_flare = flare_data[-1]
|
||||
if isinstance(latest_flare, dict):
|
||||
flux = latest_flare.get("flux")
|
||||
if flux is not None:
|
||||
result["latest_flare_class"] = _classify_xray(float(flux))
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
logger.warning("Failed to parse X-ray flux: %s", exc)
|
||||
|
||||
# --- Alerts ---
|
||||
if alerts_data and isinstance(alerts_data, list):
|
||||
alerts = []
|
||||
for alert in alerts_data[:10]:
|
||||
if isinstance(alert, dict):
|
||||
alerts.append({
|
||||
"issue_datetime": alert.get("issue_datetime"),
|
||||
"message": (alert.get("message") or "")[:200],
|
||||
"product_id": alert.get("product_id"),
|
||||
})
|
||||
result["alerts"] = alerts
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user