Files
spothole/solarconditionsproviders/kc2gprop.py

122 lines
4.9 KiB
Python

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)