mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-21 06:47:42 +00:00
Autogenerated type safety parameterisation of all methods
This commit is contained in:
@@ -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("#"):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user