mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-06-23 21:25:12 +00:00
122 lines
4.9 KiB
Python
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)
|