"""Real geomagnetic activity from NOAA's Space Weather Prediction Center. The planetary K-index (Kp) is a genuine measurement of geomagnetic disturbance, derived from magnetometer observatories worldwide and published by NOAA SWPC. Scale is 0-9: below 4 is quiet, 5+ is an official geomagnetic storm, 8-9 is severe. Why this belongs in a séance app: geomagnetic storms are the single most commonly cited "explanation" in paranormal circles, and unlike most such claims this one is a real, measured, publicly published number. So the app can honestly say the planet's magnetic field is disturbed tonight, because NOAA measured it. We report the measurement and let the fiction sit on top; we never claim the storm causes anything. Source: https://services.swpc.noaa.gov/json/planetary_k_index_1m.json Free, no API key, no attribution requirement (US government public data). Design constraints this module respects: - Never blocks a summon. Every lookup is served from cache; a refresh that fails leaves the previous value in place, and a cold cache simply reports None. A séance must not wait on, or fail because of, a third party's web service. - Polite polling. Kp updates at most every minute, so the cache TTL is generous and concurrent callers share a single in-flight refresh rather than stampeding NOAA. """ import asyncio import time import httpx SWPC_KP_URL = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json" # Kp is published every minute but changes slowly; 10 minutes is plenty # fresh for a "is the field disturbed tonight" reading and keeps us a # courteous consumer of a free public service. CACHE_TTL_S = 600 # Timeout tight enough that nothing user-facing ever notices. The value is # a garnish; if NOAA is slow we simply go without it. REQUEST_TIMEOUT_S = 4.0 # Official NOAA G-scale thresholds. STORM_THRESHOLD = 5.0 SEVERE_THRESHOLD = 8.0 def storm_label(kp: float) -> str: """NOAA's own severity wording, not invented adjectives.""" if kp >= SEVERE_THRESHOLD: return "severe geomagnetic storm" if kp >= 7: return "strong geomagnetic storm" if kp >= 6: return "moderate geomagnetic storm" if kp >= STORM_THRESHOLD: return "minor geomagnetic storm" if kp >= 4: return "unsettled" return "quiet" def disturbance(kp: float) -> float: """Kp mapped to 0..1 for blending with the celestial reading. Divided by 9 (the scale maximum) rather than by an observed range, so the number means "how far up the actual Kp scale we are" and stays interpretable against NOAA's published values. """ return max(0.0, min(1.0, kp / 9.0)) class GeomagneticCache: """Shared, non-blocking cache of the latest Kp reading.""" def __init__(self, url: str = SWPC_KP_URL, ttl_s: float = CACHE_TTL_S): self._url = url self._ttl = ttl_s self._kp: float | None = None self._fetched_at = 0.0 # Serialises refreshes so N concurrent summons trigger one request, # not N. self._lock = asyncio.Lock() @property def cached_kp(self) -> float | None: """Last known value without triggering any network I/O.""" return self._kp def _is_fresh(self) -> bool: return self._kp is not None and (time.monotonic() - self._fetched_at) < self._ttl async def get(self) -> float | None: """Current Kp, refreshing if stale. Returns None only when we have never successfully fetched. A failed refresh deliberately keeps serving the stale value: an hour-old real measurement is far better than nothing, and geomagnetic conditions do not change fast enough for staleness to mislead. """ if self._is_fresh(): return self._kp async with self._lock: # Another waiter may have refreshed while we queued. if self._is_fresh(): return self._kp try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_S) as client: response = await client.get(self._url) response.raise_for_status() payload = response.json() kp = _latest_kp(payload) if kp is not None: self._kp = kp self._fetched_at = time.monotonic() except Exception: # Deliberately broad: DNS failure, timeout, TLS problem, # malformed JSON, NOAA schema change — none of them are # worth failing or delaying a séance over. Keep the stale # value and try again after the TTL. pass return self._kp async def reading(self) -> dict | None: """Full reading for display/prompting, or None if never fetched.""" kp = await self.get() if kp is None: return None return { "kp": kp, "label": storm_label(kp), "disturbance": disturbance(kp), "storm": kp >= STORM_THRESHOLD, } def _latest_kp(payload: object) -> float | None: """Pull the most recent Kp out of SWPC's payload. The live `planetary_k_index_1m` feed is a list of objects, verified against the real service: [{"time_tag": "2026-07-28T05:54:00", "kp_index": 1, "estimated_kp": 1.00, "kp": "1Z"}, ...] `estimated_kp` is preferred because it is a float and carries the fractional precision Kp actually has; `kp_index` is the integer rounding of it. The `kp` string field is deliberately ignored — it carries a letter suffix ("1Z") and is a display form, not a number. Some other SWPC endpoints serve a header-row + array-of-arrays shape instead, so that is handled too rather than assumed away — the difference is invisible to callers and costs a few lines. Parsed from the end backwards, since the feed appends chronologically and can carry trailing rows with unusable values. Any shape this does not recognise degrades to None rather than raising inside a summon. """ if not isinstance(payload, list) or not payload: return None def _valid(value: object) -> float | None: try: kp = float(value) # type: ignore[arg-type] except (TypeError, ValueError): return None return kp if 0.0 <= kp <= 9.0 else None # Live shape: list of objects. if isinstance(payload[0], dict): for row in reversed(payload): if not isinstance(row, dict): continue for key in ("estimated_kp", "kp_index"): kp = _valid(row.get(key)) if kp is not None: return kp return None # Legacy/alternate shape: header row then data rows. if len(payload) < 2: return None header = payload[0] kp_col = 1 if isinstance(header, list): for i, name in enumerate(header): if isinstance(name, str) and name.strip().lower() in ("kp", "kp_index", "estimated_kp"): kp_col = i break for row in reversed(payload[1:]): if not isinstance(row, list) or len(row) <= kp_col: continue kp = _valid(row[kp_col]) if kp is not None: return kp return None # App-wide instance; tests substitute their own. geomagnetic_cache = GeomagneticCache()