From c38be5b5885d56aeb33fc2e078c884724bc577f7 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Thu, 21 May 2026 20:09:11 +0100 Subject: [PATCH] Add LUF to ionosonde data API & chart --- data/solar_conditions.py | 2 +- solarconditionsproviders/giroionosonde.py | 26 ++++++++++++++--------- templates/about.html | 2 +- templates/add_spot.html | 4 ++-- templates/alerts.html | 4 ++-- templates/bands.html | 6 +++--- templates/base.html | 8 +++---- templates/conditions.html | 4 ++-- templates/map.html | 6 +++--- templates/spots.html | 6 +++--- templates/status.html | 4 ++-- webassets/apidocs/openapi.yml | 9 ++++++++ webassets/js/conditions.js | 25 ++++++++++++++++------ 13 files changed, 67 insertions(+), 39 deletions(-) diff --git a/data/solar_conditions.py b/data/solar_conditions.py index f3dc793..7260e8e 100644 --- a/data/solar_conditions.py +++ b/data/solar_conditions.py @@ -161,7 +161,7 @@ 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 + # Ionosonde measurements from LGDC, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf ionosonde_data: dict = None # Derived values (populated by infer_descriptions()) diff --git a/solarconditionsproviders/giroionosonde.py b/solarconditionsproviders/giroionosonde.py index 4f5b6c6..161119b 100644 --- a/solarconditionsproviders/giroionosonde.py +++ b/solarconditionsproviders/giroionosonde.py @@ -17,7 +17,7 @@ HISTORY_HOURS = 24 class GIROIonosonde(SolarConditionsProvider): """Solar conditions provider using ionosonde data from the GIRO Data Center. - Queries foF2 and MUF measurements for all stations in datafiles/didbase-stations.csv.""" + Queries foF2, MUF, and LUF measurements for all stations in datafiles/didbase-stations.csv.""" def __init__(self, provider_config): super().__init__(provider_config) @@ -41,7 +41,7 @@ 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} + s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None, "luf": None} for s in self._stations }}) @@ -73,9 +73,9 @@ class GIROIonosonde(SolarConditionsProvider): ursi = station["ursi"] name = station["name"] try: - fof2, muf = self._fetch_station_data(ursi, from_time, now) + 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} + ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf, "luf": luf or None} updated_count += 1 except Exception: logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})") @@ -91,27 +91,28 @@ class GIROIonosonde(SolarConditionsProvider): self._stop_event.wait(timeout=1) def _fetch_station_data(self, ursi, from_time, to_time): - """Fetch foF2 and MUF readings for a station. Returns (fof2_dict, muf_dict) keyed by UNIX timestamp.""" + """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") to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S") - url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD&DMUF=3000&fromDate={from_str}&toDate={to_str}" + url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}" response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15)) if response.status_code != 200: - return None, None + return None, None, None return self._parse_all(response.text) @staticmethod def _parse_all(text): - """Parse web server response and return (fof2_dict, muf_dict) keyed by UNIX timestamp.""" + """Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp.""" fof2_data = {} muf_data = {} + luf_data = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith('#'): continue - # Data rows have the following format: timestamp CS foF2 QD MUFD QD + # Data rows have the following format: timestamp CS foF2 QD MUFD QD fmin QD parts = line.split() if len(parts) >= 5: try: @@ -127,4 +128,9 @@ class GIROIonosonde(SolarConditionsProvider): muf_data[ts] = float(parts[4]) except ValueError: pass - return fof2_data, muf_data + if len(parts) >= 7: + try: + luf_data[ts] = float(parts[6]) + except ValueError: + pass + return fof2_data, muf_data, luf_data diff --git a/templates/about.html b/templates/about.html index 493ff96..cd6b5fa 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 d427bd2..e444b95 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 1e6a312..d843fad 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 6c3db4d..08e9252 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 f43b23e..56f6f0f 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 35493cf..09898b1 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -249,8 +249,8 @@ - - + + diff --git a/templates/map.html b/templates/map.html index affb7fd..1fc20e2 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 8c26a2b..1440474 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 86257bd..19096cd 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 9dd75cf..7325472 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -1726,6 +1726,15 @@ components: example: "1747267201.0": 21.66 "1747267501.0": 21.80 + luf: + type: object + nullable: true + description: Lowest Usable Frequency (LUF, reported as fmin) in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement. Can be null if there is no data. + additionalProperties: + type: number + example: + "1747267201.0": 2.10 + "1747267501.0": 2.05 SolarConditionsProviderStatus: type: object diff --git a/webassets/js/conditions.js b/webassets/js/conditions.js index d5dab24..4c1edff 100644 --- a/webassets/js/conditions.js +++ b/webassets/js/conditions.js @@ -389,11 +389,12 @@ function renderIonosondeData() { const station = ionosondeData[ursi]; if (!station) return; - // Set up some styles, matching the k-index chart. We use Bootstrap's "primary" and "danger" colours not for any - // real reason but just to get a suitable blue and red that match the other colours Spothole uses + // Set up some styles, matching the k-index chart. We use Bootstrap's "primary", "danger", and "success" colours + // not for any real reason but just to get a suitable blue, red, and green that match the other colours Spothole uses const style = getComputedStyle(document.documentElement); const fof2Color = style.getPropertyValue('--bs-primary').trim(); - const mufColor = style.getPropertyValue('--bs-danger').trim(); + const mufColor = style.getPropertyValue('--bs-success').trim(); + const lufColor = style.getPropertyValue('--bs-danger').trim(); const textColor = style.getPropertyValue('--bs-body-color').trim() || '#666'; const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)'; @@ -407,7 +408,8 @@ function renderIonosondeData() { const fof2Entries = toSeries(station.fof2); const mufEntries = toSeries(station.muf); - const allTs = [...fof2Entries, ...mufEntries].map(e => e.ts); + 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-chart').hide(); @@ -418,6 +420,7 @@ function renderIonosondeData() { // Populate latest values summary (visible on all screen sizes) const latestFof2 = fof2Entries.length ? fof2Entries[fof2Entries.length - 1].val : null; const latestMuf = mufEntries.length ? mufEntries[mufEntries.length - 1].val : null; + 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 = ''; @@ -429,9 +432,11 @@ function renderIonosondeData() { ? '
Data is more than 12 hours old!
' : ''; $('#ionosonde-latest').html( + '
' + + '
Latest values as of ' + latestTimeStr + '
' + '
' + - '
Latest values as of ' + latestTimeStr + '
' + - '
foF2: ' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '
' + + '
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 + @@ -546,6 +551,14 @@ function renderIonosondeData() { type: 'line', data: { datasets: [ + { + label: 'LUF', + data: lufEntries.map(e => ({x: e.ts, y: e.val})), + borderColor: lufColor, + backgroundColor: 'transparent', + pointRadius: 0, + tension: 0.2, + }, { label: 'foF2', data: fof2Entries.map(e => ({x: e.ts, y: e.val})),