mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
360 lines
12 KiB
Python
360 lines
12 KiB
Python
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Any
|
|
|
|
from flask import Blueprint, Response, jsonify
|
|
|
|
from utils.logging import get_logger
|
|
from utils.responses import api_error
|
|
|
|
logger = get_logger("intercept.space_weather")
|
|
|
|
space_weather_bp = Blueprint("space_weather", __name__, url_prefix="/space-weather")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TTL Cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_cache: dict[str, dict[str, Any]] = {}
|
|
|
|
# Cache TTLs in seconds
|
|
TTL_REALTIME = 300 # 5 min for real-time data
|
|
TTL_FORECAST = 1800 # 30 min for forecasts
|
|
TTL_DAILY = 3600 # 1 hr for daily summaries
|
|
TTL_IMAGE = 600 # 10 min for images
|
|
|
|
|
|
def _cache_get(key: str) -> Any | None:
|
|
entry = _cache.get(key)
|
|
if entry and time.time() < entry["expires"]:
|
|
return entry["data"]
|
|
return None
|
|
|
|
|
|
def _cache_set(key: str, data: Any, ttl: int) -> None:
|
|
_cache[key] = {"data": data, "expires": time.time() + ttl}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TIMEOUT = 15 # seconds
|
|
|
|
SWPC_BASE = "https://services.swpc.noaa.gov"
|
|
SWPC_JSON = f"{SWPC_BASE}/products"
|
|
|
|
|
|
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT/1.0"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch %s: %s", url, exc)
|
|
return None
|
|
|
|
|
|
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT/1.0"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.read().decode()
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch %s: %s", url, exc)
|
|
return None
|
|
|
|
|
|
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT/1.0"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.read()
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch %s: %s", url, exc)
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data source fetchers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
|
|
cached = _cache_get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
data = _fetch_json(url)
|
|
if data is not None:
|
|
_cache_set(cache_key, data, ttl)
|
|
return data
|
|
|
|
|
|
def _fetch_kp_index() -> Any | None:
|
|
return _fetch_cached_json("kp_index", f"{SWPC_JSON}/noaa-planetary-k-index.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_kp_forecast() -> Any | None:
|
|
return _fetch_cached_json("kp_forecast", f"{SWPC_JSON}/noaa-planetary-k-index-forecast.json", TTL_FORECAST)
|
|
|
|
|
|
def _fetch_scales() -> Any | None:
|
|
return _fetch_cached_json("scales", f"{SWPC_JSON}/noaa-scales.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_flux() -> Any | None:
|
|
return _fetch_cached_json("flux", f"{SWPC_JSON}/10cm-flux-30-day.json", TTL_DAILY)
|
|
|
|
|
|
def _fetch_alerts() -> Any | None:
|
|
return _fetch_cached_json("alerts", f"{SWPC_JSON}/alerts.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_solar_wind_plasma() -> Any | None:
|
|
return _fetch_cached_json("sw_plasma", f"{SWPC_JSON}/solar-wind/plasma-6-hour.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_solar_wind_mag() -> Any | None:
|
|
return _fetch_cached_json("sw_mag", f"{SWPC_JSON}/solar-wind/mag-6-hour.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_xrays() -> Any | None:
|
|
return _fetch_cached_json("xrays", f"{SWPC_BASE}/json/goes/primary/xrays-1-day.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_xray_flares() -> Any | None:
|
|
return _fetch_cached_json("xray_flares", f"{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json", TTL_REALTIME)
|
|
|
|
|
|
def _fetch_flare_probability() -> Any | None:
|
|
return _fetch_cached_json("flare_prob", f"{SWPC_BASE}/json/solar_probabilities.json", TTL_FORECAST)
|
|
|
|
|
|
def _fetch_solar_regions() -> Any | None:
|
|
return _fetch_cached_json("solar_regions", f"{SWPC_BASE}/json/solar_regions.json", TTL_DAILY)
|
|
|
|
|
|
def _fetch_sunspot_report() -> Any | None:
|
|
return _fetch_cached_json("sunspot_report", f"{SWPC_BASE}/json/sunspot_report.json", TTL_DAILY)
|
|
|
|
|
|
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
|
|
"""Parse HamQSL solar XML into a dict of band conditions."""
|
|
try:
|
|
root = ET.fromstring(xml_text)
|
|
solar = root.find(".//solardata")
|
|
if solar is None:
|
|
return None
|
|
result: dict[str, Any] = {}
|
|
# Scalar fields
|
|
for tag in (
|
|
"sfi",
|
|
"aindex",
|
|
"kindex",
|
|
"kindexnt",
|
|
"xray",
|
|
"sunspots",
|
|
"heliumline",
|
|
"protonflux",
|
|
"electonflux",
|
|
"aurora",
|
|
"normalization",
|
|
"latdegree",
|
|
"solarwind",
|
|
"magneticfield",
|
|
"calculatedconditions",
|
|
"calculatedvhfconditions",
|
|
"geomagfield",
|
|
"signalnoise",
|
|
"fof2",
|
|
"muffactor",
|
|
"muf",
|
|
):
|
|
el = solar.find(tag)
|
|
if el is not None and el.text:
|
|
result[tag] = el.text.strip()
|
|
# Band conditions
|
|
bands: list[dict[str, str]] = []
|
|
for band_el in solar.findall(".//calculatedconditions/band"):
|
|
bands.append(
|
|
{
|
|
"name": band_el.get("name", ""),
|
|
"time": band_el.get("time", ""),
|
|
"condition": band_el.text.strip() if band_el.text else "",
|
|
}
|
|
)
|
|
result["bands"] = bands
|
|
# VHF conditions
|
|
vhf: list[dict[str, str]] = []
|
|
for phen_el in solar.findall(".//calculatedvhfconditions/phenomenon"):
|
|
vhf.append(
|
|
{
|
|
"name": phen_el.get("name", ""),
|
|
"location": phen_el.get("location", ""),
|
|
"condition": phen_el.text.strip() if phen_el.text else "",
|
|
}
|
|
)
|
|
result["vhf"] = vhf
|
|
return result
|
|
except ET.ParseError as exc:
|
|
logger.warning("Failed to parse HamQSL XML: %s", exc)
|
|
return None
|
|
|
|
|
|
def _fetch_band_conditions() -> dict[str, Any] | None:
|
|
cached = _cache_get("band_conditions")
|
|
if cached is not None:
|
|
return cached
|
|
xml_text = _fetch_text("https://www.hamqsl.com/solarxml.php")
|
|
if xml_text is None:
|
|
return None
|
|
data = _parse_hamqsl_xml(xml_text)
|
|
if data is not None:
|
|
_cache_set("band_conditions", data, TTL_FORECAST)
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image proxy whitelist
|
|
# ---------------------------------------------------------------------------
|
|
|
|
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
|
|
# D-RAP absorption maps
|
|
"drap_global": {
|
|
"url": f"{SWPC_BASE}/images/animations/d-rap/global/latest.png",
|
|
"content_type": "image/png",
|
|
},
|
|
"drap_5": {
|
|
"url": f"{SWPC_BASE}/images/d-rap/global_f05.png",
|
|
"content_type": "image/png",
|
|
},
|
|
"drap_10": {
|
|
"url": f"{SWPC_BASE}/images/d-rap/global_f10.png",
|
|
"content_type": "image/png",
|
|
},
|
|
"drap_15": {
|
|
"url": f"{SWPC_BASE}/images/d-rap/global_f15.png",
|
|
"content_type": "image/png",
|
|
},
|
|
"drap_20": {
|
|
"url": f"{SWPC_BASE}/images/d-rap/global_f20.png",
|
|
"content_type": "image/png",
|
|
},
|
|
"drap_25": {
|
|
"url": f"{SWPC_BASE}/images/d-rap/global_f25.png",
|
|
"content_type": "image/png",
|
|
},
|
|
"drap_30": {
|
|
"url": f"{SWPC_BASE}/images/d-rap/global_f30.png",
|
|
"content_type": "image/png",
|
|
},
|
|
# Aurora forecast
|
|
"aurora_north": {
|
|
"url": f"{SWPC_BASE}/images/animations/ovation/north/latest.jpg",
|
|
"content_type": "image/jpeg",
|
|
},
|
|
# SDO solar imagery
|
|
"sdo_193": {
|
|
"url": "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg",
|
|
"content_type": "image/jpeg",
|
|
},
|
|
"sdo_304": {
|
|
"url": "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg",
|
|
"content_type": "image/jpeg",
|
|
},
|
|
"sdo_magnetogram": {
|
|
"url": "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg",
|
|
"content_type": "image/jpeg",
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@space_weather_bp.route("/data")
|
|
def get_data():
|
|
"""Return aggregated space weather data from all sources."""
|
|
fetchers = {
|
|
"kp_index": _fetch_kp_index,
|
|
"kp_forecast": _fetch_kp_forecast,
|
|
"scales": _fetch_scales,
|
|
"flux": _fetch_flux,
|
|
"alerts": _fetch_alerts,
|
|
"solar_wind_plasma": _fetch_solar_wind_plasma,
|
|
"solar_wind_mag": _fetch_solar_wind_mag,
|
|
"xrays": _fetch_xrays,
|
|
"xray_flares": _fetch_xray_flares,
|
|
"flare_probability": _fetch_flare_probability,
|
|
"solar_regions": _fetch_solar_regions,
|
|
"sunspot_report": _fetch_sunspot_report,
|
|
"band_conditions": _fetch_band_conditions,
|
|
}
|
|
data = {}
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor:
|
|
futures = {executor.submit(fn): key for key, fn in fetchers.items()}
|
|
for future in concurrent.futures.as_completed(futures):
|
|
data[futures[future]] = future.result()
|
|
data["timestamp"] = time.time()
|
|
return jsonify(data)
|
|
|
|
|
|
@space_weather_bp.route("/image/<key>")
|
|
def get_image(key: str):
|
|
"""Proxy and cache whitelisted space weather images."""
|
|
entry = IMAGE_WHITELIST.get(key)
|
|
if not entry:
|
|
return api_error("Unknown image key", 404)
|
|
|
|
cache_key = f"img_{key}"
|
|
cached = _cache_get(cache_key)
|
|
if cached is not None:
|
|
return Response(cached, content_type=entry["content_type"], headers={"Cache-Control": "public, max-age=300"})
|
|
|
|
img_data = _fetch_bytes(entry["url"])
|
|
if img_data is None:
|
|
return api_error("Failed to fetch image", 502)
|
|
|
|
_cache_set(cache_key, img_data, TTL_IMAGE)
|
|
return Response(img_data, content_type=entry["content_type"], headers={"Cache-Control": "public, max-age=300"})
|
|
|
|
|
|
@space_weather_bp.route("/prefetch-images")
|
|
def prefetch_images():
|
|
"""Warm the image cache by fetching all whitelisted images in parallel."""
|
|
# Only fetch images not already cached
|
|
to_fetch = {}
|
|
for key, entry in IMAGE_WHITELIST.items():
|
|
cache_key = f"img_{key}"
|
|
if _cache_get(cache_key) is None:
|
|
to_fetch[key] = entry
|
|
|
|
if not to_fetch:
|
|
return jsonify({"status": "all cached", "count": 0})
|
|
|
|
def _fetch_and_cache(key: str, entry: dict) -> bool:
|
|
img_data = _fetch_bytes(entry["url"])
|
|
if img_data:
|
|
_cache_set(f"img_{key}", img_data, TTL_IMAGE)
|
|
return True
|
|
return False
|
|
|
|
fetched = 0
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
|
futures = {executor.submit(_fetch_and_cache, k, e): k for k, e in to_fetch.items()}
|
|
for future in concurrent.futures.as_completed(futures):
|
|
if future.result():
|
|
fetched += 1
|
|
|
|
return jsonify({"status": "ok", "fetched": fetched, "cached": len(IMAGE_WHITELIST) - len(to_fetch)})
|