mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-06-23 21:25:12 +00:00
Short/long/closed display for each band calculated from latest data for each ionosonde station
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -69,8 +69,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/add-spot.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/add-spot.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -70,8 +70,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/alerts.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/alerts.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -76,9 +76,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1779390551"></script>
|
||||
<script src="/js/bands.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1779393248"></script>
|
||||
<script src="/js/bands.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<title>Spothole</title>
|
||||
|
||||
<link rel="stylesheet" href="/css/style.css?v=1779390551" type="text/css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=1779393248" type="text/css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
|
||||
@@ -52,9 +52,9 @@
|
||||
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1779390551"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1779390551"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1779390551"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1779393248"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1779393248"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1779393248"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -185,7 +185,29 @@
|
||||
style="width: auto;" oninput="ionosondeStationChanged();">
|
||||
</select>
|
||||
</div>
|
||||
<div id="ionosonde-latest" class="mb-3"></div>
|
||||
<div id="ionosonde-latest" class="mb-3">
|
||||
<div id="ionosonde-no-data" class="alert alert-warning mt-2 mb-0 py-2" style="display:none;">No data available for this station.</div>
|
||||
<div id="ionosonde-data-rows" style="display:none;">
|
||||
<div class="row align-items-center me-0">
|
||||
<div class="col-12 py-2 text-muted">Latest values as of <span id="ionosonde-latest-time"></span></div>
|
||||
</div>
|
||||
<div class="row align-items-center me-0">
|
||||
<div class="col-12 col-md-4 py-2">LUF: <strong id="ionosonde-latest-luf"></strong></div>
|
||||
<div class="col-12 col-md-4 py-2">foF2: <strong id="ionosonde-latest-fof2"></strong></div>
|
||||
<div class="col-12 col-md-4 py-2">MUF (3000 km): <strong id="ionosonde-latest-muf"></strong></div>
|
||||
</div>
|
||||
<div id="ionosonde-stale-warning" class="alert alert-warning mt-2 mb-0 py-2" style="display:none;">Data is more than 12 hours old!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ionosonde-band-state" class="mb-3" style="display:none;">
|
||||
<table class="table table-sm table-bordered mb-0 d-none d-md-table" style="table-layout: fixed;">
|
||||
<thead><tr id="ionosonde-band-state-head"></tr></thead>
|
||||
<tbody><tr id="ionosonde-band-state-row"></tr></tbody>
|
||||
</table>
|
||||
<table class="table table-sm table-bordered mb-0 d-md-none">
|
||||
<tbody id="ionosonde-band-state-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<canvas id="ionosonde-chart" class="mt-3 mb-3 hideonmobile"></canvas>
|
||||
<div class="form-text mt-2">Data from the <a href="https://lgdc.uml.edu/">Lowell GIRO Data Center</a>.</div>
|
||||
</div>
|
||||
@@ -211,7 +233,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<table class="table table-sm table-bordered mb-0" style="table-layout: fixed;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
@@ -249,8 +271,8 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/conditions.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/conditions.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1779390551"></script>
|
||||
<script src="/js/map.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1779393248"></script>
|
||||
<script src="/js/map.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -104,9 +104,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1779390551"></script>
|
||||
<script src="/js/spots.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1779393248"></script>
|
||||
<script src="/js/spots.js?v=1779393248"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -59,8 +59,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1779390551"></script>
|
||||
<script src="/js/status.js?v=1779390551"></script>
|
||||
<script src="/js/common.js?v=1779393248"></script>
|
||||
<script src="/js/status.js?v=1779393248"></script>
|
||||
<script>
|
||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<div class="alert alert-warning mt-2 mb-0 py-2">No data available for this station.</div>');
|
||||
$('#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($('<th>').addClass('text-center').text(band));
|
||||
dataRow.append($('<td>').addClass('text-center ' + cls).text(state));
|
||||
vBody.append($('<tr>').append($('<td>').addClass('fw-bold').text(band)).append($('<td>').addClass(cls).text(state)));
|
||||
});
|
||||
$('#ionosonde-band-state').show();
|
||||
} else {
|
||||
$('#ionosonde-band-state').hide();
|
||||
}
|
||||
const staleWarning = (maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600)
|
||||
? '<div class="alert alert-warning mt-2 mb-0 py-2">Data is more than 12 hours old!</div>'
|
||||
: '';
|
||||
$('#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="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-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>' +
|
||||
staleWarning +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
if (ionosondeChart) {
|
||||
ionosondeChart.destroy();
|
||||
|
||||
Reference in New Issue
Block a user