From c939a5c1a1225e2caa32199070e63c80fa1a159e Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Thu, 21 May 2026 20:54:08 +0100 Subject: [PATCH] Short/long/closed display for each band calculated from latest data for each ionosonde station --- core/constants.py | 20 +++++----- data/band.py | 2 + data/solar_conditions.py | 8 ++-- solarconditionsproviders/giroionosonde.py | 44 ++++++++++++++++++++-- templates/about.html | 2 +- templates/add_spot.html | 4 +- templates/alerts.html | 4 +- templates/bands.html | 6 +-- templates/base.html | 8 ++-- templates/conditions.html | 30 +++++++++++++-- templates/map.html | 6 +-- templates/spots.html | 6 +-- templates/status.html | 4 +- webassets/apidocs/openapi.yml | 19 +++++++++- webassets/js/conditions.js | 46 ++++++++++++++--------- 15 files changed, 151 insertions(+), 58 deletions(-) diff --git a/core/constants.py b/core/constants.py index 703fb66..3e68318 100644 --- a/core/constants.py +++ b/core/constants.py @@ -62,17 +62,17 @@ MODE_ALIASES = { BANDS = [ Band(name="2200m", start_freq=135700, end_freq=137800), Band(name="600m", start_freq=472000, end_freq=479000), - Band(name="160m", start_freq=1800000, end_freq=2000000), - Band(name="80m", start_freq=3500000, end_freq=4000000), - Band(name="60m", start_freq=5250000, end_freq=5410000), - Band(name="40m", start_freq=7000000, end_freq=7300000), - Band(name="30m", start_freq=10100000, end_freq=10150000), - Band(name="20m", start_freq=14000000, end_freq=14350000), - Band(name="17m", start_freq=18068000, end_freq=18168000), - Band(name="15m", start_freq=21000000, end_freq=21450000), - Band(name="12m", start_freq=24890000, end_freq=24990000), + Band(name="160m", start_freq=1800000, end_freq=2000000, is_ham_hf=True), + Band(name="80m", start_freq=3500000, end_freq=4000000, is_ham_hf=True), + Band(name="60m", start_freq=5250000, end_freq=5410000, is_ham_hf=True), + Band(name="40m", start_freq=7000000, end_freq=7300000, is_ham_hf=True), + Band(name="30m", start_freq=10100000, end_freq=10150000, is_ham_hf=True), + Band(name="20m", start_freq=14000000, end_freq=14350000, is_ham_hf=True), + Band(name="17m", start_freq=18068000, end_freq=18168000, is_ham_hf=True), + Band(name="15m", start_freq=21000000, end_freq=21450000, is_ham_hf=True), + Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True), Band(name="11m", start_freq=26965000, end_freq=27405000), - Band(name="10m", start_freq=28000000, end_freq=29700000), + Band(name="10m", start_freq=28000000, end_freq=29700000, is_ham_hf=True), Band(name="6m", start_freq=50000000, end_freq=54000000), Band(name="5m", start_freq=56000000, end_freq=60500000), Band(name="4m", start_freq=70000000, end_freq=70500000), diff --git a/data/band.py b/data/band.py index 4d6b50c..321ba44 100644 --- a/data/band.py +++ b/data/band.py @@ -11,3 +11,5 @@ class Band: start_freq: float # Stop frequency, in Hz end_freq: float + # Whether this is an HF amateur radio band + is_ham_hf: bool = False diff --git a/data/solar_conditions.py b/data/solar_conditions.py index 7260e8e..22570a6 100644 --- a/data/solar_conditions.py +++ b/data/solar_conditions.py @@ -161,7 +161,8 @@ class SolarConditions: blackout_forecast_r1r2: dict = None # NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC blackout_forecast_r3_or_greater: dict = None - # Ionosonde measurements from LGDC, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf + # Ionosonde measurements from LGDC, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf, + # band_states ionosonde_data: dict = None # Derived values (populated by infer_descriptions()) @@ -196,6 +197,7 @@ class SolarConditions: self.electron_flux_desc = _lookup_by_threshold(self.electron_flux, ELECTRON_FLUX_DESCRIPTIONS) def to_json(self): - """JSON serialise""" + """JSON serialise. Dict key order is insertion order (Python 3.7+ guarantee), so callers receive + fields in a predictable, logical sequence without relying on sort_keys.""" - return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) + return json.dumps(self, default=lambda o: o.__dict__) diff --git a/solarconditionsproviders/giroionosonde.py b/solarconditionsproviders/giroionosonde.py index 161119b..b3afe87 100644 --- a/solarconditionsproviders/giroionosonde.py +++ b/solarconditionsproviders/giroionosonde.py @@ -6,13 +6,14 @@ from threading import Thread, Event import pytz import requests -from core.constants import HTTP_HEADERS +from core.constants import HTTP_HEADERS, BANDS from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider POLL_INTERVAL = 3600 # 1 hour STATIONS_INDEX = "datafiles/didbase-stations.csv" LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues" HISTORY_HOURS = 24 +HF_BANDS = [b for b in BANDS if b.is_ham_hf] class GIROIonosonde(SolarConditionsProvider): @@ -41,7 +42,8 @@ class GIROIonosonde(SolarConditionsProvider): super().setup(solar_conditions, solar_conditions_cache) self.update_data({"ionosonde_data": { - s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None, "luf": None} + s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None, "luf": None, + "band_states": None} for s in self._stations }}) @@ -75,7 +77,9 @@ class GIROIonosonde(SolarConditionsProvider): try: fof2, muf, luf = self._fetch_station_data(ursi, from_time, now) if fof2 and muf: - ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf, "luf": luf or None} + band_states = self._compute_band_statess(fof2, muf, luf or {}) + ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf, + "luf": luf or None, "band_states": band_states} updated_count += 1 except Exception: logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})") @@ -101,6 +105,40 @@ class GIROIonosonde(SolarConditionsProvider): return None, None, None return self._parse_all(response.text) + @staticmethod + def _latest(d): + """Return the value with the highest timestamp key, or None if the dict is empty.""" + return d[max(d.keys())] if d else None + + @staticmethod + def _compute_band_statess(fof2_dict, muf_dict, luf_dict): + """Compute HF band states from the latest foF2, MUF and LUF values. + + States: + Closed if band frequency is below LUF (if known) or above MUF + Short if band frequency is >= LUF and < foF2 (good for NVIS) + Long if band frequency is >= foF2 and < MUF (good for DX) + """ + + # We have a list of timestamped data for each value, but for this we only want the latest value + fof2 = GIROIonosonde._latest(fof2_dict) + muf = GIROIonosonde._latest(muf_dict) + luf = GIROIonosonde._latest(luf_dict) + if fof2 is None or muf is None: + return {} + band_states = {} + + # Iterate over all ham HF bands, we don't care about the others at this point + for band in HF_BANDS: + freq = band.start_freq / 1000000 + 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 + @staticmethod def _parse_all(text): """Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp.""" diff --git a/templates/about.html b/templates/about.html index cd6b5fa..1fbbd6e 100644 --- a/templates/about.html +++ b/templates/about.html @@ -69,7 +69,7 @@

This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.

- + {% end %} \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index e444b95..65b7129 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -69,8 +69,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/alerts.html b/templates/alerts.html index d843fad..0f05d2d 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -70,8 +70,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/bands.html b/templates/bands.html index 08e9252..03b49e6 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -76,9 +76,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 56f6f0f..a07e51d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -24,7 +24,7 @@ Spothole - + @@ -52,9 +52,9 @@ integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn" crossorigin="anonymous"> - - - + + + diff --git a/templates/conditions.html b/templates/conditions.html index 09898b1..f8309fa 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -185,7 +185,29 @@ style="width: auto;" oninput="ionosondeStationChanged();"> -
+
+ + +
+
Data from the Lowell GIRO Data Center.
@@ -211,7 +233,7 @@
- +
@@ -249,8 +271,8 @@ - - + + diff --git a/templates/map.html b/templates/map.html index 1fc20e2..b4bf605 100644 --- a/templates/map.html +++ b/templates/map.html @@ -94,9 +94,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/spots.html b/templates/spots.html index 1440474..7659b5a 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -104,9 +104,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/status.html b/templates/status.html index 19096cd..a57af30 100644 --- a/templates/status.html +++ b/templates/status.html @@ -59,8 +59,8 @@ - - + + diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index 7325472..6daa365 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -15,7 +15,7 @@ info: ### 1.4 - * `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (foF2 and MUF) sourced from the GIRO Data Center. + * `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (LUF, foF2 and MUF) sourced from the GIRO Data Center as well as implied band states. ### 1.3 @@ -1735,6 +1735,23 @@ components: example: "1747267201.0": 2.10 "1747267501.0": 2.05 + band_states: + type: object + nullable: true + description: > + States of each HF amateur band, derived from the latest foF2, MUF and LUF values. Keyed by band name. Each + value is one of: "Closed" (band frequency is below LUF or above MUF), "Short" (band frequency is at or above + LUF and below foF2, so good for NVIS) or "Long" (band frequency is at or above foF2 and below MUF, so good + for DX). Null if foF2 or MUF data is not yet available. + additionalProperties: + type: string + enum: [Closed, Short, Long] + example: + "160m": "Closed" + "80m": "Short" + "40m": "Long" + "20m": "Long" + "10m": "Closed" SolarConditionsProviderStatus: type: object diff --git a/webassets/js/conditions.js b/webassets/js/conditions.js index 4c1edff..bd47523 100644 --- a/webassets/js/conditions.js +++ b/webassets/js/conditions.js @@ -411,11 +411,15 @@ function renderIonosondeData() { const lufEntries = toSeries(station.luf); const allTs = [...fof2Entries, ...mufEntries, ...lufEntries].map(e => e.ts); if (allTs.length === 0) { - $('#ionosonde-latest').html('
No data available for this station.
'); + $('#ionosonde-no-data').show(); + $('#ionosonde-data-rows').hide(); + $('#ionosonde-band-state').hide(); $('#ionosonde-chart').hide(); if (ionosondeChart) { ionosondeChart.destroy(); ionosondeChart = null; } return; } + $('#ionosonde-no-data').hide(); + $('#ionosonde-data-rows').show(); // Populate latest values summary (visible on all screen sizes) const latestFof2 = fof2Entries.length ? fof2Entries[fof2Entries.length - 1].val : null; @@ -423,25 +427,33 @@ function renderIonosondeData() { const latestLuf = lufEntries.length ? lufEntries[lufEntries.length - 1].val : null; const minTs = allTs.length ? Math.min(...allTs) : null; const maxTs = allTs.length ? Math.max(...allTs) : null; - let latestTimeStr = ''; if (maxTs != null) { const latestDate = moment.utc(maxTs * 1000); - latestTimeStr = latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')'; + $('#ionosonde-latest-time').text(latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')'); + } + $('#ionosonde-latest-luf').text(latestLuf !== null ? latestLuf.toFixed(2) + ' MHz' : 'N/A'); + $('#ionosonde-latest-fof2').text(latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A'); + $('#ionosonde-latest-muf').text(latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A'); + $('#ionosonde-stale-warning').toggle(maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600); + + // Populate band state tables. There are actually two tables to populate, which is pretty janky, but allows us to + // display horizontally on desktop but flip it around to become a vertical list on mobile. + const bandStateClass = {'Closed': 'bg-danger-subtle', 'Short': 'bg-primary-subtle', 'Long': 'bg-success-subtle'}; + const bandStates = station.band_states; + if (bandStates && Object.keys(bandStates).length > 0) { + const headRow = $('#ionosonde-band-state-head').empty(); + const dataRow = $('#ionosonde-band-state-row').empty(); + const vBody = $('#ionosonde-band-state-body').empty(); + Object.entries(bandStates).forEach(([band, state]) => { + const cls = bandStateClass[state] || ''; + headRow.append($('').append($('
').addClass('text-center').text(band)); + dataRow.append($('').addClass('text-center ' + cls).text(state)); + vBody.append($('
').addClass('fw-bold').text(band)).append($('').addClass(cls).text(state))); + }); + $('#ionosonde-band-state').show(); + } else { + $('#ionosonde-band-state').hide(); } - const staleWarning = (maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600) - ? '
Data is more than 12 hours old!
' - : ''; - $('#ionosonde-latest').html( - '
' + - '
Latest values as of ' + latestTimeStr + '
' + - '
' + - '
LUF: ' + (latestLuf !== null ? latestLuf.toFixed(2) + ' MHz' : 'N/A') + '
' + - '
foF2: ' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '
' + - '
MUF (3000 km): ' + (latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A') + '
' + - '
' + - staleWarning + - '' - ); if (ionosondeChart) { ionosondeChart.destroy();