Autogenerated type safety parameterisation of all methods

This commit is contained in:
Ian Renton
2026-09-20 20:02:19 +01:00
parent 6037e742cc
commit 324dd1414b
132 changed files with 1228 additions and 706 deletions
+22 -17
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import csv
import logging
from datetime import datetime, timedelta, timezone
from threading import Event, Thread
from typing import Any
import pytz
import requests
@@ -31,17 +34,17 @@ class GIROIonosonde(SolarConditionsProvider):
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):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("GIRO Ionosonde Data", provider_config)
self._stations = self._load_stations()
self._thread = None
self._stop_event = Event()
self._stations: list[dict[str, str]] = self._load_stations()
self._thread: Thread | None = None
self._stop_event: 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 = {
existing: dict[str, Any] = self._solar_conditions.ionosonde_data or {}
new_entries: dict[str, Any] = {
s["ursi"]: {
"ursi": s["ursi"],
"name": s["name"],
@@ -57,27 +60,27 @@ class GIROIonosonde(SolarConditionsProvider):
self.update_data({"ionosonde_data": {**existing, **new_entries}})
@staticmethod
def _load_stations():
stations = []
def _load_stations() -> list[dict[str, str]]:
stations: list[dict[str, str]] = []
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):
def start(self) -> None:
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
# 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)
@@ -88,7 +91,7 @@ class GIROIonosonde(SolarConditionsProvider):
if self._stop_event.wait(timeout=interval):
break
def _poll_station(self, station):
def _poll_station(self, station: dict[str, str]) -> None:
ursi = station["ursi"]
name = station["name"]
try:
@@ -139,7 +142,9 @@ class GIROIonosonde(SolarConditionsProvider):
self.status = "Error"
logger.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})")
def _fetch_station_data(self, ursi, from_time, to_time):
def _fetch_station_data(
self, ursi: str, from_time: datetime, to_time: datetime
) -> tuple[dict[float, float] | None, dict[float, float] | None, dict[float, float] | None]:
"""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")
@@ -159,12 +164,12 @@ class GIROIonosonde(SolarConditionsProvider):
return None, None, None
@staticmethod
def _parse_all(text):
def _parse_all(text: str) -> tuple[dict[float, float], dict[float, float], dict[float, float]]:
"""Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
fof2_data = {}
muf_data = {}
luf_data = {}
fof2_data: dict[float, float] = {}
muf_data: dict[float, float] = {}
luf_data: dict[float, float] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
+11 -7
View File
@@ -1,7 +1,11 @@
from __future__ import annotations
import logging
from typing import Any
from xml.etree import ElementTree
import pytz
import requests
from dateutil import parser as dateutil_parser
from dateutil import tz as dateutil_tz
@@ -19,10 +23,10 @@ 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):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response):
def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
root = ElementTree.fromstring(http_response.text)
sd = root.find("solardata")
if sd is None:
@@ -31,27 +35,27 @@ class HamQSL(HTTPSolarConditionsProvider):
# Some error checking functions in case the data is janky.
def text(tag, default=None):
def text(tag: str, default: str | None = None) -> str | None:
if sd is None:
logger.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):
def float_val(tag: str, default: float | None = None) -> float | None:
try:
return float(text(tag))
except (ValueError, TypeError):
return default
def int_val(tag, default=None):
def int_val(tag: str, default: int | None = None) -> int | None:
try:
return int(text(tag))
except (ValueError, TypeError):
return default
# Process HF band conditions
hf_conditions = {}
hf_conditions: dict[str, str] = {}
calc = sd.find("calculatedconditions")
if calc is not None:
for band_el in calc.findall("band"):
@@ -62,7 +66,7 @@ class HamQSL(HTTPSolarConditionsProvider):
hf_conditions[f"{name}-{time}"] = condition
# Process VHF propagation conditions
vhf_map = {}
vhf_map: dict[tuple[str | None, str | None], str | None] = {}
vhf = sd.find("calculatedvhfconditions")
if vhf is not None:
for ph_el in vhf.findall("phenomenon"):
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging
from datetime import datetime
from threading import Event, Thread
from typing import Any
import pytz
import requests
@@ -16,32 +19,32 @@ 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):
def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
super().__init__(name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
self._url: str = url
self._poll_interval: float = poll_interval
self._thread: Thread | None = None
self._stop_event: Event = Event()
def start(self):
def start(self) -> None:
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval):
break
def _poll(self):
def _poll(self) -> None:
try:
logger.debug(f"Polling {self.name} solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
@@ -66,7 +69,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
logger.exception(f"Exception in HTTP Solar Conditions Provider ({self.name})")
self._stop_event.wait(timeout=1)
def _http_response_to_solar_conditions(self, http_response):
def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
"""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."""
+10 -3
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
from core.constants import BANDS
from data.band import Band
HF_BANDS = [b for b in BANDS if b.is_ham_hf]
HF_BANDS: list[Band] = [b for b in BANDS if b.is_ham_hf]
def _latest(d) -> float | None:
def _latest(d: dict[float, float | str] | None) -> 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."""
@@ -11,7 +14,11 @@ def _latest(d) -> float | None:
return float(val) if (val is not None and val != "None") else None
def compute_band_states(fof2_dict, muf_dict, luf_dict):
def compute_band_states(
fof2_dict: dict[float, float | str] | None,
muf_dict: dict[float, float | str] | None,
luf_dict: dict[float, float | str] | None,
) -> dict[str, str]:
"""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:
+11 -8
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from threading import Event, Thread
from typing import Any
import pytz
import requests
@@ -25,30 +28,30 @@ class KC2GProp(SolarConditionsProvider):
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):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("KC2G Propagation Data", provider_config)
self._thread = None
self._stop_event = Event()
self._thread: Thread | None = None
self._stop_event: Event = Event()
def start(self):
def start(self) -> None:
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=POLL_INTERVAL):
break
def _poll(self):
def _poll(self) -> None:
try:
logger.debug("Polling KC2G ionosonde data...")
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
@@ -61,7 +64,7 @@ class KC2GProp(SolarConditionsProvider):
# 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 {})
ionosonde_data: dict[str, Any] = dict(self._solar_conditions.ionosonde_data or {})
updated_count = 0
for reading in http_response.json():
+17 -12
View File
@@ -1,6 +1,11 @@
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import Any
import requests
from providers.solarconditions.http_solar_conditions_provider import (
HTTPSolarConditionsProvider,
@@ -16,11 +21,11 @@ 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):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
@staticmethod
def _parse_percentage_table(lines, section_header, year):
def _parse_percentage_table(lines: list[str], section_header: str, year: int) -> dict[str, dict[float, int]] | None:
"""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."""
@@ -48,7 +53,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Figure out the date based on the line found
column_timestamps = []
column_timestamps: list[float] = []
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)
@@ -58,7 +63,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Parse data rows. Each non-empty line should have a text label followed by percentage values
result = {}
result: dict[str, dict[float, int]] = {}
for line in lines[date_header_idx + 1 :]:
line_stripped = line.strip()
if not line_stripped:
@@ -73,7 +78,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
# Row label is everything before the first percentage value
row_label = line_stripped[: line_stripped.index(pct_matches[0].group())].strip()
row_data = {}
row_data: dict[float, int] = {}
for j, match in enumerate(pct_matches):
if j >= len(column_timestamps):
break
@@ -83,7 +88,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return result if result else None
def _http_response_to_solar_conditions(self, http_response):
def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header
@@ -115,7 +120,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
logger.warning(f"NOAA K-index forecast: could not parse date headers from: {date_header_line}")
return None
column_dates = []
column_dates: list[date] = []
for month_str, day_str in date_matches:
try:
column_dates.append(
@@ -126,7 +131,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
k_index_forecast = {}
k_index_forecast: dict[float, float] = {}
for line in lines[start_idx + 3 :]:
time_match = re.match(r"^(\d{2})-(\d{2})UT\s+(.*)", line.strip())
if not time_match:
@@ -167,14 +172,14 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
solar_storm_forecast = None
solar_storm_forecast: dict[float, int] | None = 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_forecast_r1r2: dict[float, int] | None = None
blackout_forecast_r3_or_greater: dict[float, int] | None = None
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
if blackout_table:
blackout_forecast_r1r2 = blackout_table.get("R1-R2")
@@ -1,34 +1,38 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytz
from core.data_store import DATA_STORE
from data.solar_conditions import SolarConditions
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):
def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
"""Constructor"""
self.name = name
self.enabled = provider_config.get("enabled", True)
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.get()
self.name: str = name
self.enabled: bool = provider_config.get("enabled", True)
self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.status: str = "Not Started" if self.enabled else "Disabled"
self._solar_conditions: SolarConditions = DATA_STORE.solar_conditions.get()
def start(self):
def start(self) -> None:
"""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):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
def update_data(self, new_data):
def update_data(self, new_data: dict[str, Any] | None) -> None:
"""Update the solar conditions object with new data"""
if new_data: