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. Can run alongside GIROIonosonde: KC2G provides foF2/MUF with good reliability, while GIRO supplements with LUF readings. Stations from each source that the other does not cover are preserved.""" 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)