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 @@| @@ -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(' | ').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 + ' ' +
- ' ' +
- staleWarning +
- ''
- );
if (ionosondeChart) {
ionosondeChart.destroy();
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') + ' ' +
- ' |