Refactor of caching & data storage part 9 #118

This commit is contained in:
Ian Renton
2026-08-02 09:06:10 +01:00
parent 0b0c8aa4f3
commit 2157bf114e
72 changed files with 159 additions and 131 deletions
+173
View File
@@ -0,0 +1,173 @@
import csv
import logging
from datetime import datetime, timezone, timedelta
from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from providers.solarconditions.ionosonde_utils import compute_band_states
from providers.solarconditions.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__("GIRO Ionosonde Data", provider_config)
self._stations = self._load_stations()
self._thread = None
self._stop_event = Event()
# 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.
existing = self._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}})
@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 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}"
try:
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if not http_response.ok:
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
return None, None, None
return self._parse_all(http_response.text)
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing Giro ionosonde API.")
return None, None, None
except ConnectionError:
logging.warning("Connection error when accessing Giro ionosonde API.")
return None, None, None
@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
+112
View File
@@ -0,0 +1,112 @@
import logging
from xml.etree import ElementTree
import pytz
from dateutil import parser as dateutil_parser, tz as dateutil_tz
from providers.solarconditions.http_solar_conditions_provider import HTTPSolarConditionsProvider
POLL_INTERVAL = 3600 # 1 hour
URL = "https://www.hamqsl.com/solarxml.php"
class HamQSL(HTTPSolarConditionsProvider):
"""Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php).
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
def __init__(self, provider_config):
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response):
root = ElementTree.fromstring(http_response.text)
sd = root.find("solardata")
if sd is None:
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
return None
# Some error checking functions in case the data is janky.
def text(tag, default=None):
if sd is None:
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
return default
el = sd.find(tag)
return el.text.strip() if el is not None and el.text else default
def float_val(tag, default=None):
try:
return float(text(tag))
except (ValueError, TypeError):
return default
def int_val(tag, default=None):
try:
return int(text(tag))
except (ValueError, TypeError):
return default
# Process HF band conditions
hf_conditions = {}
calc = sd.find("calculatedconditions")
if calc is not None:
for band_el in calc.findall("band"):
name = band_el.get("name")
time = band_el.get("time")
condition = band_el.text.strip() if band_el.text else None
if name and time and condition:
hf_conditions[f"{name}-{time}"] = condition
# Process VHF propagation conditions
vhf_map = {}
vhf = sd.find("calculatedvhfconditions")
if vhf is not None:
for ph_el in vhf.findall("phenomenon"):
key = (ph_el.get("name"), ph_el.get("location"))
vhf_map[key] = ph_el.text.strip() if ph_el.text else None
# Parse the "updated" timestamp string (format: "28 Mar 2026 0949 GMT") to UTC epoch seconds.
updated = None
updated_str = text("updated")
if updated_str:
try:
tz_abbr = updated_str.split()[-1]
timezone = dateutil_tz.gettz(tz_abbr)
if timezone is None:
raise ValueError("Unknown timezone abbreviation: " + tz_abbr)
dt = dateutil_parser.parse(updated_str, tzinfos={tz_abbr: timezone})
updated = dt.astimezone(pytz.UTC).timestamp()
except (ValueError, IndexError):
logging.warning("HamQSL solar conditions API returned unrecognised timestamp format: " + updated_str)
# Return the data ready to be put into the solar conditions object.
return {
"updated": updated,
"sfi": int_val("solarflux"),
"a_index": int_val("aindex"),
"k_index": int_val("kindex"),
"xray": text("xray"),
"sunspots": int_val("sunspots"),
"proton_flux": int_val("protonflux"),
"electron_flux": int_val("electonflux"),
"aurora": int_val("aurora"),
"aurora_latitude": float_val("latdegree"),
"solar_wind": float_val("solarwind"),
"magnetic_field": float_val("magneticfield"),
"geomag_field": text("geomagfield").title()
.replace("Vr Quiet", "Very Quiet")
.replace("Unsettld", "Unsettled")
.replace("Min Strm", "Minor Storm")
.replace("Maj Strm", "Major Storm")
.replace("Sev Strm", "Severe Storm")
.replace("Ext Strm", "Extreme Storm"),
"geomag_noise": text("signalnoise"),
"hf_conditions": hf_conditions,
"vhf_conditions": {
"vhf_aurora_northern_hemi": (vhf_map.get(("vhf-aurora", "northern_hemi")) or "").title().replace(
"Lat Aur", "Latitude") or None,
"es_2m_europe": vhf_map.get(("E-Skip", "europe")),
"es_4m_europe": vhf_map.get(("E-Skip", "europe_4m")),
"es_6m_europe": vhf_map.get(("E-Skip", "europe_6m")),
"es_2m_na": vhf_map.get(("E-Skip", "north_america")),
},
}
@@ -0,0 +1,69 @@
import logging
from datetime import datetime
from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from providers.solarconditions.solar_conditions_provider import SolarConditionsProvider
class HTTPSolarConditionsProvider(SolarConditionsProvider):
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
_http_response_to_solar_conditions() to parse the specific API response format."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
def start(self):
logging.info(
"Set up query of " + self.name + " solar conditions API every " + str(self._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=self._poll_interval):
break
def _poll(self):
try:
logging.debug("Polling " + self.name + " solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
new_data = self._http_response_to_solar_conditions(http_response)
self.update_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " solar conditions API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing {self.name} solar conditions API.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
self._stop_event.wait(timeout=1)
def _http_response_to_solar_conditions(self, http_response):
"""Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field
names to their new values, or None if the response could not be parsed. Only the fields returned will
be updated on the shared SolarConditions object; any fields not included will be left unchanged."""
raise NotImplementedError("Subclasses must implement this method")
@@ -0,0 +1,37 @@
from core.constants import BANDS
HF_BANDS = [b for b in BANDS if b.is_ham_hf]
def _latest(d) -> float | None:
"""Given a map where the key is a timestamp and the value is a number represented as a string, find the latest
timestamp and return the corresponding value as a float."""
val = str(d[max(d.keys())]) if d else None
return float(val) if (val is not None and val != "None") else None
def compute_band_states(fof2_dict, muf_dict, luf_dict):
"""Compute HF band states from the latest foF2, MUF and LUF values.
Returns a map where the keys are HF bands and the values are as follows:
"Closed" if band frequency is above MUF or below LUF (if known)
"Short" if band frequency is >= LUF and < foF2 (good for NVIS)
"Long" if band frequency is >= foF2 and < MUF (good for DX)
"""
fof2 = _latest(fof2_dict)
muf = _latest(muf_dict)
luf = _latest(luf_dict) if luf_dict else None
if fof2 is None or muf is None:
return {}
band_states = {}
for band in HF_BANDS:
freq = band.start_freq / 1_000_000
if freq > muf or (luf is not None and freq < luf):
band_states[band.name] = "Closed"
elif freq < fof2:
band_states[band.name] = "Short"
else:
band_states[band.name] = "Long"
return band_states
+126
View File
@@ -0,0 +1,126 @@
import logging
from datetime import datetime, timezone, timedelta
from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from providers.solarconditions.ionosonde_utils import compute_band_states
from providers.solarconditions.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__("KC2G Propagation Data", 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...")
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
if not http_response.ok:
logging.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
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 http_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 ConnectionError:
logging.warning("Connection error when accessing KC2G ionosonde API.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing KC2G ionosonde API.")
except Exception:
self.status = "Error"
logging.exception("Exception in KC2G ionosonde data provider")
self._stop_event.wait(timeout=1)
@@ -0,0 +1,174 @@
import logging
import re
from datetime import datetime, timezone
from providers.solarconditions.http_solar_conditions_provider import HTTPSolarConditionsProvider
POLL_INTERVAL = 10800 # Every 3 hours
URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
class NOAA3dayForecast(HTTPSolarConditionsProvider):
"""Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates
corresponding fields in the solar conditions object.."""
def __init__(self, provider_config):
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
@staticmethod
def _parse_percentage_table(lines, section_header, year):
"""Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup
of the solar storm and radio blackout forecast parsing."""
start_idx = None
for i, line in enumerate(lines):
if section_header in line:
start_idx = i
break
if start_idx is None:
logging.warning(f"NOAA 3-day forecast: could not find '{section_header}' section")
return None
# Find the date header line by scanning the next few lines for month & day patterns
date_header_idx = None
for j in range(start_idx + 1, min(start_idx + 6, len(lines))):
if re.search(r'[A-Za-z]{3}\s+\d{2}', lines[j]):
date_header_idx = j
break
if date_header_idx is None:
logging.warning(f"NOAA 3-day forecast: could not find date header after '{section_header}'")
return None
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', lines[date_header_idx])
if not date_matches:
logging.warning(f"NOAA 3-day forecast: no dates in header: {lines[date_header_idx]}")
return None
# Figure out the date based on the line found
column_timestamps = []
for month_str, day_str in date_matches:
try:
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
column_timestamps.append(dt.timestamp())
except ValueError:
logging.warning(f"NOAA 3-day forecast: could not parse date: {month_str} {day_str} {year}")
return None
# Parse data rows. Each non-empty line should have a text label followed by percentage values
result = {}
for line in lines[date_header_idx + 1:]:
line_stripped = line.strip()
if not line_stripped:
if result:
break
continue
pct_matches = list(re.finditer(r'\b(\d+)%', line_stripped))
if not pct_matches:
if result:
break
continue
# Row label is everything before the first percentage value
row_label = line_stripped[:line_stripped.index(pct_matches[0].group())].strip()
row_data = {}
for j, match in enumerate(pct_matches):
if j >= len(column_timestamps):
break
row_data[column_timestamps[j]] = int(match.group(1))
if row_data:
result[row_label] = row_data
return result if result else None
def _http_response_to_solar_conditions(self, http_response):
lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header
start_idx = None
for i, line in enumerate(lines):
if "NOAA Kp index breakdown" in line:
start_idx = i
break
if start_idx is None:
logging.warning("NOAA K-index forecast: could not find 'NOAA Kp index breakdown' section")
return None
# Extract the year from the header line, e.g. "NOAA Kp index breakdown Apr 2-Apr 4, 2026"
header_line = lines[start_idx]
year_match = re.search(r'\b(\d{4})\b', header_line)
if not year_match:
logging.warning("NOAA K-index forecast: could not extract year from: " + header_line)
return None
year = int(year_match.group(1))
# Parse the column date headers on the next line, e.g. " Apr 02 Apr 03 Apr 04"
if start_idx + 1 >= len(lines):
logging.warning("NOAA K-index forecast: missing date header line")
return None
date_header_line = lines[start_idx + 2]
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', date_header_line)
if not date_matches:
logging.warning("NOAA K-index forecast: could not parse date headers from: " + date_header_line)
return None
column_dates = []
for month_str, day_str in date_matches:
try:
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
except ValueError:
logging.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
return None
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
k_index_forecast = {}
for line in lines[start_idx + 3:]:
time_match = re.match(r'^(\d{2})-(\d{2})UT\s+(.*)', line.strip())
if not time_match:
if k_index_forecast:
break
continue
start_hour = int(time_match.group(1))
# Split on 2 or more spaces so that e.g. "5.67 (G2)" stays as one token per column
raw_values = re.split(r' {2,}', time_match.group(3).strip())
for i, val in enumerate(raw_values):
if i >= len(column_dates):
break
# Take only the leading numeric part, discarding any bracketed section
try:
kp = float(val.split()[0])
except (ValueError, IndexError):
continue
date = column_dates[i]
start_dt = datetime(date.year, date.month, date.day, start_hour, 0, 0, tzinfo=timezone.utc)
# Key the data dict by start time
key = start_dt.timestamp()
k_index_forecast[key] = kp
if not k_index_forecast:
logging.warning("NOAA K-index forecast: no data rows parsed")
return None
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
solar_storm_forecast = None
radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year)
if radiation_table:
solar_storm_forecast = radiation_table.get("S1 or greater")
# Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater")
blackout_forecast_r1r2 = None
blackout_forecast_r3_or_greater = None
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
if blackout_table:
blackout_forecast_r1r2 = blackout_table.get("R1-R2")
blackout_forecast_r3_or_greater = blackout_table.get("R3 or greater")
return {
"k_index_forecast": k_index_forecast,
"solar_storm_forecast": solar_storm_forecast,
"blackout_forecast_r1r2": blackout_forecast_r1r2,
"blackout_forecast_r3_or_greater": blackout_forecast_r3_or_greater,
}
@@ -0,0 +1,38 @@
from datetime import datetime
import pytz
from core.data_store import DATA_STORE
class SolarConditionsProvider:
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
propagation data."""
def __init__(self, name, provider_config):
"""Constructor"""
self.name = name
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = DATA_STORE.solar_conditions
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
def update_data(self, new_data):
"""Update the solar conditions object with new data"""
if new_data:
for key, value in new_data.items():
if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value)
self._solar_conditions.infer_descriptions()