Add LUF to ionosonde data API & chart

This commit is contained in:
Ian Renton
2026-05-21 20:09:11 +01:00
parent d655354d05
commit c38be5b588
13 changed files with 67 additions and 39 deletions

View File

@@ -161,7 +161,7 @@ class SolarConditions:
blackout_forecast_r1r2: dict = None blackout_forecast_r1r2: dict = None
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r3_or_greater: dict = None 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 ionosonde_data: dict = None
# Derived values (populated by infer_descriptions()) # Derived values (populated by infer_descriptions())

View File

@@ -17,7 +17,7 @@ HISTORY_HOURS = 24
class GIROIonosonde(SolarConditionsProvider): class GIROIonosonde(SolarConditionsProvider):
"""Solar conditions provider using ionosonde data from the GIRO Data Center. """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): def __init__(self, provider_config):
super().__init__(provider_config) super().__init__(provider_config)
@@ -41,7 +41,7 @@ class GIROIonosonde(SolarConditionsProvider):
super().setup(solar_conditions, solar_conditions_cache) super().setup(solar_conditions, solar_conditions_cache)
self.update_data({"ionosonde_data": { 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 for s in self._stations
}}) }})
@@ -73,9 +73,9 @@ class GIROIonosonde(SolarConditionsProvider):
ursi = station["ursi"] ursi = station["ursi"]
name = station["name"] name = station["name"]
try: 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: 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 updated_count += 1
except Exception: except Exception:
logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})") logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})")
@@ -91,27 +91,28 @@ class GIROIonosonde(SolarConditionsProvider):
self._stop_event.wait(timeout=1) self._stop_event.wait(timeout=1)
def _fetch_station_data(self, ursi, from_time, to_time): 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") from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
to_str = to_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)) response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if response.status_code != 200: if response.status_code != 200:
return None, None return None, None, None
return self._parse_all(response.text) return self._parse_all(response.text)
@staticmethod @staticmethod
def _parse_all(text): 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 = {} fof2_data = {}
muf_data = {} muf_data = {}
luf_data = {}
for line in text.splitlines(): for line in text.splitlines():
line = line.strip() line = line.strip()
if not line or line.startswith('#'): if not line or line.startswith('#'):
continue 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() parts = line.split()
if len(parts) >= 5: if len(parts) >= 5:
try: try:
@@ -127,4 +128,9 @@ class GIROIonosonde(SolarConditionsProvider):
muf_data[ts] = float(parts[4]) muf_data[ts] = float(parts[4])
except ValueError: except ValueError:
pass 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

View File

@@ -69,7 +69,7 @@
<p>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.</p> <p>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.</p>
</div> </div>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -69,8 +69,8 @@
</div> </div>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/add-spot.js?v=1778927183"></script> <script src="/js/add-spot.js?v=1779390551"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -70,8 +70,8 @@
</div> </div>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/alerts.js?v=1778927183"></script> <script src="/js/alerts.js?v=1779390551"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -76,9 +76,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/spotsbandsandmap.js?v=1778927183"></script> <script src="/js/spotsbandsandmap.js?v=1779390551"></script>
<script src="/js/bands.js?v=1778927183"></script> <script src="/js/bands.js?v=1779390551"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -24,7 +24,7 @@
<title>Spothole</title> <title>Spothole</title>
<link rel="stylesheet" href="/css/style.css?v=1778927183" type="text/css"> <link rel="stylesheet" href="/css/style.css?v=1779390551" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" /> <link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
@@ -52,9 +52,9 @@
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn" integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1778927183"></script> <script src="https://misc.ianrenton.com/jsutils/utils.js?v=1779390551"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778927183"></script> <script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1779390551"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778927183"></script> <script src="https://misc.ianrenton.com/jsutils/geo.js?v=1779390551"></script>
</head> </head>
<body> <body>

View File

@@ -249,8 +249,8 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/conditions.js?v=1778927183"></script> <script src="/js/conditions.js?v=1779390551"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>

View File

@@ -94,9 +94,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/spotsbandsandmap.js?v=1778927183"></script> <script src="/js/spotsbandsandmap.js?v=1779390551"></script>
<script src="/js/map.js?v=1778927183"></script> <script src="/js/map.js?v=1779390551"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -104,9 +104,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/spotsbandsandmap.js?v=1778927183"></script> <script src="/js/spotsbandsandmap.js?v=1779390551"></script>
<script src="/js/spots.js?v=1778927183"></script> <script src="/js/spots.js?v=1779390551"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -59,8 +59,8 @@
</div> </div>
</div> </div>
<script src="/js/common.js?v=1778927183"></script> <script src="/js/common.js?v=1779390551"></script>
<script src="/js/status.js?v=1778927183"></script> <script src="/js/status.js?v=1779390551"></script>
<script> <script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --> $(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script> </script>

View File

@@ -1726,6 +1726,15 @@ components:
example: example:
"1747267201.0": 21.66 "1747267201.0": 21.66
"1747267501.0": 21.80 "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: SolarConditionsProviderStatus:
type: object type: object

View File

@@ -389,11 +389,12 @@ function renderIonosondeData() {
const station = ionosondeData[ursi]; const station = ionosondeData[ursi];
if (!station) return; if (!station) return;
// Set up some styles, matching the k-index chart. We use Bootstrap's "primary" and "danger" colours not for any // Set up some styles, matching the k-index chart. We use Bootstrap's "primary", "danger", and "success" colours
// real reason but just to get a suitable blue and red that match the other colours Spothole uses // 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 style = getComputedStyle(document.documentElement);
const fof2Color = style.getPropertyValue('--bs-primary').trim(); 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 textColor = style.getPropertyValue('--bs-body-color').trim() || '#666';
const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)'; 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 fof2Entries = toSeries(station.fof2);
const mufEntries = toSeries(station.muf); 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) { if (allTs.length === 0) {
$('#ionosonde-latest').html('<div class="alert alert-warning mt-2 mb-0 py-2">No data available for this station.</div>'); $('#ionosonde-latest').html('<div class="alert alert-warning mt-2 mb-0 py-2">No data available for this station.</div>');
$('#ionosonde-chart').hide(); $('#ionosonde-chart').hide();
@@ -418,6 +420,7 @@ function renderIonosondeData() {
// Populate latest values summary (visible on all screen sizes) // Populate latest values summary (visible on all screen sizes)
const latestFof2 = fof2Entries.length ? fof2Entries[fof2Entries.length - 1].val : null; const latestFof2 = fof2Entries.length ? fof2Entries[fof2Entries.length - 1].val : null;
const latestMuf = mufEntries.length ? mufEntries[mufEntries.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 minTs = allTs.length ? Math.min(...allTs) : null;
const maxTs = allTs.length ? Math.max(...allTs) : null; const maxTs = allTs.length ? Math.max(...allTs) : null;
let latestTimeStr = ''; let latestTimeStr = '';
@@ -429,9 +432,11 @@ function renderIonosondeData() {
? '<div class="alert alert-warning mt-2 mb-0 py-2">Data is more than 12 hours old!</div>' ? '<div class="alert alert-warning mt-2 mb-0 py-2">Data is more than 12 hours old!</div>'
: ''; : '';
$('#ionosonde-latest').html( $('#ionosonde-latest').html(
'<div class="row align-items-center me-0">' +
'<div class="col-12 py-2 text-muted">Latest values as of ' + latestTimeStr + '</div></div>' +
'<div class="row border-bottom align-items-center me-0">' + '<div class="row border-bottom align-items-center me-0">' +
'<div class="col-12 col-md-6 py-2 text-muted">Latest values as of ' + latestTimeStr + '</div>' + '<div class="col-12 col-md-4 py-2">LUF: <strong>' + (latestLuf !== null ? latestLuf.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' +
'<div class="col-12 col-md-2 py-2">foF2: <strong>' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' + '<div class="col-12 col-md-4 py-2">foF2: <strong>' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' +
'<div class="col-12 col-md-4 py-2">MUF (3000 km): <strong>' + (latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' + '<div class="col-12 col-md-4 py-2">MUF (3000 km): <strong>' + (latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' +
'</div>' + '</div>' +
staleWarning + staleWarning +
@@ -546,6 +551,14 @@ function renderIonosondeData() {
type: 'line', type: 'line',
data: { data: {
datasets: [ datasets: [
{
label: 'LUF',
data: lufEntries.map(e => ({x: e.ts, y: e.val})),
borderColor: lufColor,
backgroundColor: 'transparent',
pointRadius: 0,
tension: 0.2,
},
{ {
label: 'foF2', label: 'foF2',
data: fof2Entries.map(e => ({x: e.ts, y: e.val})), data: fof2Entries.map(e => ({x: e.ts, y: e.val})),