diff --git a/solarconditionsproviders/giroionosonde.py b/solarconditionsproviders/giroionosonde.py index 1042aec..8a9e398 100644 --- a/solarconditionsproviders/giroionosonde.py +++ b/solarconditionsproviders/giroionosonde.py @@ -10,7 +10,12 @@ from core.constants import HTTP_HEADERS from solarconditionsproviders.ionosonde_utils import compute_band_states from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider -POLL_INTERVAL = 3600 # 1 hour +# 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 @@ -19,8 +24,9 @@ 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. - Can run alongside KC2GProp: GIRO supplements KC2G's foF2/MUF data with LUF readings, and - stations from each source that the other does not cover are preserved.""" + + 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) @@ -61,64 +67,59 @@ class GIROIonosonde(SolarConditionsProvider): 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() - if self._stop_event.wait(timeout=POLL_INTERVAL): + 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(self): + def _poll_station(self, station): + ursi = station["ursi"] + name = station["name"] try: - logging.debug("Polling GIRO ionosonde data...") + 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 {}) - updated_count = 0 - for station in self._stations: - if self._stop_event.is_set(): - break - ursi = station["ursi"] - name = station["name"] - try: - fof2, muf, luf = self._fetch_station_data(ursi, from_time, now) - if not fof2 or not muf: - continue + # 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 {} - # 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, - } - updated_count += 1 - except Exception: - logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})") + 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 {updated_count} stations.") + logging.debug(f"Updated ionosonde data for {ursi} ({name}).") except Exception: self.status = "Error" - logging.exception("Exception in GIRO Ionosonde data provider") - self._stop_event.wait(timeout=1) + 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.""" diff --git a/solarconditionsproviders/ionosonde_utils.py b/solarconditionsproviders/ionosonde_utils.py index 0a3a787..d319f95 100644 --- a/solarconditionsproviders/ionosonde_utils.py +++ b/solarconditionsproviders/ionosonde_utils.py @@ -4,16 +4,20 @@ HF_BANDS = [b for b in BANDS if b.is_ham_hf] def _latest(d) -> float | None: - return float(d[max(d.keys())]) if d else 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. - States: - 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) + 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) diff --git a/solarconditionsproviders/kc2gprop.py b/solarconditionsproviders/kc2gprop.py index d43d72b..41342b1 100644 --- a/solarconditionsproviders/kc2gprop.py +++ b/solarconditionsproviders/kc2gprop.py @@ -16,11 +16,11 @@ 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. + 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.""" + 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__(provider_config) diff --git a/solarconditionsproviders/noaa3dayforecast.py b/solarconditionsproviders/noaa3dayforecast.py index 7a0c0f5..d677e66 100644 --- a/solarconditionsproviders/noaa3dayforecast.py +++ b/solarconditionsproviders/noaa3dayforecast.py @@ -19,6 +19,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): 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: @@ -28,7 +29,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): logging.warning(f"NOAA 3-day forecast: could not find '{section_header}' section") return None - # Find the date header line — the first line within the next few that contains month+day patterns + # 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]): @@ -37,12 +38,12 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): 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: @@ -52,7 +53,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): 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 and percentage values + # 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() @@ -65,6 +66,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): 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 = {} @@ -90,7 +92,6 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): 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 diff --git a/templates/add_spot.html b/templates/add_spot.html index fcb059f..fe45022 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -76,7 +76,7 @@ - + diff --git a/templates/alerts.html b/templates/alerts.html index a31f345..2bd130b 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -75,7 +75,7 @@ - + diff --git a/templates/bands.html b/templates/bands.html index a58a3de..f6046fd 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -77,8 +77,8 @@ - - + + diff --git a/templates/base.html b/templates/base.html index 829c7a7..505cc47 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -10,10 +10,10 @@ - - - - + + + + {% end %} {% block body %}
diff --git a/templates/conditions.html b/templates/conditions.html index 0edd4dd..3f7b2cf 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -284,7 +284,7 @@
- + diff --git a/templates/map.html b/templates/map.html index 16cc546..df82e1f 100644 --- a/templates/map.html +++ b/templates/map.html @@ -95,8 +95,8 @@ - - + + diff --git a/templates/spots.html b/templates/spots.html index 96ecffc..4f67e84 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -116,8 +116,8 @@ - - + + diff --git a/templates/status.html b/templates/status.html index 3fee150..f1cec89 100644 --- a/templates/status.html +++ b/templates/status.html @@ -59,7 +59,7 @@ - +