import logging from datetime import datetime, timezone, timedelta from threading import Thread, Event import pytz import requests from core.constants import HTTP_HEADERS from solarconditionsproviders.ionosonde_utils import compute_band_states from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider POLL_INTERVAL = 900 # 15 minutes KC2G_URL = "https://prop.kc2g.com/api/stations.json" HISTORY_HOURS = 24 class KC2GProp(SolarConditionsProvider): """Solar conditions provider using ionosonde data from prop.kc2g.com. The API returns only the latest reading per station, so this provider polls every 15 minutes and accumulates a 24-hour time series by merging each new reading into the persisted ionosonde_data, producing the same data structure as GIROIonosonde. Designed to run alongside GIROIonosonde even though they produce similar data. KC2G is more reliable and is always online, but has fewer stations and does not provide LUF data.""" def __init__(self, provider_config): super().__init__(provider_config) self._thread = None self._stop_event = Event() def start(self): logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.") self._thread = Thread(target=self._run, daemon=True) self._thread.start() def stop(self): self._stop_event.set() def _run(self): while True: self._poll() if self._stop_event.wait(timeout=POLL_INTERVAL): break def _poll(self): try: logging.debug("Polling KC2G ionosonde data...") response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30)) if response.status_code != 200: logging.warning(f"KC2G ionosonde API returned HTTP {response.status_code}") return now = datetime.now(timezone.utc) cutoff_ts = (now - timedelta(hours=HISTORY_HOURS)).timestamp() # Start from existing ionosonde_data so the accumulated time series survives across polls and restarts and # stations provided only by GIROIonosonde are not discarded ionosonde_data = dict(self._solar_conditions.ionosonde_data or {}) updated_count = 0 for reading in response.json(): station = reading.get("station", {}) ursi = station.get("code") name = station.get("name") if not ursi or not name: continue time_str = reading.get("time") if not time_str: continue try: ts = datetime.fromisoformat(time_str) if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) ts_float = ts.timestamp() except ValueError: continue # Skip readings outside our history window (some stations have months-old data) if ts_float < cutoff_ts: continue fof2_val = reading.get("fof2") muf_val = reading.get("mufd") if fof2_val is None and muf_val is None: continue # Merge this reading into the existing time series for the station. existing = ionosonde_data.get(ursi, {}) fof2_dict = dict(existing.get("fof2") or {}) muf_dict = dict(existing.get("muf") or {}) # LUF is not available from KC2G; carry forward whatever GIRO has written. luf_dict = {float(t): v for t, v in (existing.get("luf") or {}).items()} fof2_dict[ts_float] = fof2_val muf_dict[ts_float] = muf_val # Trim all series to the 24-hour window. fof2_dict = {t: v for t, v in fof2_dict.items() if t >= cutoff_ts} muf_dict = {t: v for t, v in muf_dict.items() if t >= cutoff_ts} luf_dict = {t: v for t, v in luf_dict.items() if t >= cutoff_ts} band_states = compute_band_states(fof2_dict, muf_dict, luf_dict) ionosonde_data[ursi] = { "ursi": ursi, "name": name, "fof2": fof2_dict or None, "muf": muf_dict or None, "luf": luf_dict or None, "band_states": band_states, } updated_count += 1 self.update_data({"ionosonde_data": ionosonde_data}) self.status = "OK" self.last_update_time = datetime.now(pytz.UTC) logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.") except Exception: self.status = "Error" logging.exception("Exception in KC2G ionosonde data provider") self._stop_event.wait(timeout=1)