import csv 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 # Each station gets polled roughly once every hour (3600 seconds). Note that to avoid a burst of requests to the server # every hour, the requests for data from each station are spaced out throughout the hour, leading to one request being # sent every 1-2 minutes. POLL_INTERVAL = 3600 # To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been # manually determined and a CSV provided of all the stations that we can query for live data. STATIONS_INDEX = "datafiles/didbase-stations.csv" LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues" HISTORY_HOURS = 24 class GIROIonosonde(SolarConditionsProvider): """Solar conditions provider using ionosonde data from the GIRO Data Center. Queries foF2, MUF, and LUF measurements for all stations in datafiles/didbase-stations.csv. Designed to run alongside KC2GProp even though they produce similar data. GIRO has more stations and includes LUF data, but is less reliable and often offline.""" def __init__(self, provider_config): super().__init__(provider_config) self._stations = self._load_stations() self._thread = None self._stop_event = Event() @staticmethod def _load_stations(): stations = [] with open(STATIONS_INDEX, newline='') as f: for row in csv.reader(f): if len(row) >= 2: stations.append({"ursi": row[0].strip(), "name": row[1].strip()}) return stations def setup(self, solar_conditions, solar_conditions_cache): """Pre-populate ionosonde_data with known station names for stations not already present, so the station dropdown is available before the first poll. Does not overwrite existing entries so KC2G cache data is preserved.""" super().setup(solar_conditions, solar_conditions_cache) existing = solar_conditions.ionosonde_data or {} new_entries = { s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None, "luf": None, "band_states": None} for s in self._stations if s["ursi"] not in existing } if new_entries: self.update_data({"ionosonde_data": {**existing, **new_entries}}) def start(self): logging.info(f"Set up query of GIRO 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): # Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets # polled once per hour, just not all at once interval = POLL_INTERVAL / len(self._stations) station_index = 0 while True: self._poll_station(self._stations[station_index]) station_index = (station_index + 1) % len(self._stations) if self._stop_event.wait(timeout=interval): break def _poll_station(self, station): ursi = station["ursi"] name = station["name"] try: logging.debug(f"Polling GIRO ionosonde data for {ursi} ({name})...") now = datetime.now(timezone.utc) from_time = now - timedelta(hours=HISTORY_HOURS) cutoff_ts = from_time.timestamp() fof2, muf, luf = self._fetch_station_data(ursi, from_time, now) if not fof2 or not muf: return # Start from the existing ionosonde_data so stations provided by other providers # (e.g. KC2GProp) are preserved for stations GIRO does not cover. ionosonde_data = dict(self._solar_conditions.ionosonde_data or {}) # Merge GIRO's readings into any existing data for this station. existing = ionosonde_data.get(ursi, {}) merged_fof2 = {**{float(t): v for t, v in (existing.get("fof2") or {}).items()}, **fof2} merged_muf = {**{float(t): v for t, v in (existing.get("muf") or {}).items()}, **muf} merged_luf = dict(luf) if luf else {} merged_fof2 = {t: v for t, v in merged_fof2.items() if t >= cutoff_ts} merged_muf = {t: v for t, v in merged_muf.items() if t >= cutoff_ts} merged_luf = {t: v for t, v in merged_luf.items() if t >= cutoff_ts} band_states = compute_band_states(merged_fof2, merged_muf, merged_luf) ionosonde_data[ursi] = { "ursi": ursi, "name": name, "fof2": merged_fof2 or None, "muf": merged_muf or None, "luf": merged_luf or None, "band_states": band_states, } self.update_data({"ionosonde_data": ionosonde_data}) self.status = "OK" self.last_update_time = datetime.now(pytz.UTC) logging.debug(f"Updated ionosonde data for {ursi} ({name}).") except Exception: self.status = "Error" logging.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})") def _fetch_station_data(self, ursi, from_time, to_time): """Fetch foF2, MUF and LUF readings for a station. Returns (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp.""" from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S") to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S") url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}" response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15)) if response.status_code != 200: return None, None, None return self._parse_all(response.text) @staticmethod def _parse_all(text): """Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp.""" fof2_data = {} muf_data = {} luf_data = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith('#'): continue # Data rows have the following format: timestamp CS foF2 QD MUFD QD fmin QD parts = line.split() if len(parts) >= 5: try: # Python 3.8 TZ parsing fudge ts = datetime.fromisoformat(parts[0].replace('Z', '+00:00')).timestamp() except ValueError: continue try: fof2_data[ts] = float(parts[2]) except ValueError: pass try: muf_data[ts] = float(parts[4]) except ValueError: pass if len(parts) >= 7: try: luf_data[ts] = float(parts[6]) except ValueError: pass return fof2_data, muf_data, luf_data