4 Commits
15 changed files with 105 additions and 65 deletions
+2 -2
View File
@@ -161,8 +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, list of dicts with keys: ursi, name, fof2, muf
ionosonde_data: list = None
# Ionosonde measurements from LGDC, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf
ionosonde_data: dict = None
# Derived values (populated by infer_descriptions())
# HF radio blackout risk description, derived from xray
+22 -14
View File
@@ -35,6 +35,16 @@ class GIROIonosonde(SolarConditionsProvider):
stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
return stations
def setup(self, solar_conditions, solar_conditions_cache):
"""Prepopulate the ionosonde_data map with known URSI and station names, so that the API exposes this structure
even before we actually have any data in it."""
super().setup(solar_conditions, solar_conditions_cache)
self.update_data({"ionosonde_data": {
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None}
for s in self._stations
}})
def start(self):
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, daemon=True)
@@ -51,34 +61,33 @@ class GIROIonosonde(SolarConditionsProvider):
def _poll(self):
try:
logging.debug(f"Polling {self.name} ionosonde data...")
logging.debug(f"Polling GIRO ionosonde data...")
now = datetime.now(timezone.utc)
from_time = now - timedelta(hours=HISTORY_HOURS)
results = []
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
updated_count = 0
for station in self._stations:
if self._stop_event.is_set():
break
ursi = station["ursi"]
name = station["name"]
entry = {"ursi": ursi, "name": name, "fof2": None, "muf": None}
try:
fof2, muf = self._fetch_station_data(ursi, from_time, now)
entry["fof2"] = fof2
entry["muf"] = muf
if fof2 and muf:
results.append(entry)
ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf}
updated_count += 1
except Exception:
logging.debug(f"Could not fetch ionosonde data for {ursi} ({name})")
logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})")
self.update_data({"ionosonde_data": results})
self.update_data({"ionosonde_data": ionosonde_data})
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug(f"Received ionosonde data for {len(results)} stations from {self.name}.")
logging.debug(f"Updated ionosonde data for {updated_count} stations.")
except Exception:
self.status = "Error"
logging.exception(f"Exception in GIRO Ionosonde data provider ({self.name})")
logging.exception(f"Exception in GIRO Ionosonde data provider")
self._stop_event.wait(timeout=1)
def _fetch_station_data(self, ursi, from_time, to_time):
@@ -86,9 +95,7 @@ class GIROIonosonde(SolarConditionsProvider):
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&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
@@ -104,10 +111,11 @@ class GIROIonosonde(SolarConditionsProvider):
line = line.strip()
if not line or line.startswith('#'):
continue
# Data rows: timestamp CS foF2 QD MUFD QD
# Data rows have the following format: timestamp CS foF2 QD MUFD QD
parts = line.split()
if len(parts) >= 5:
try:
# Python 3.8 TZ parsing fudge
ts = datetime.fromisoformat(parts[0].replace('Z', '+00:00')).timestamp()
except ValueError:
continue
@@ -16,10 +16,11 @@ class SolarConditionsProvider:
self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = None
def setup(self, solar_conditions):
"""Set up the provider, giving it the solar conditions dict to update"""
def setup(self, solar_conditions, solar_conditions_cache):
"""Set up the provider, giving it the solar conditions object and its backing cache"""
self._solar_conditions = solar_conditions
self._solar_conditions_cache = solar_conditions_cache
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -39,3 +40,4 @@ class SolarConditionsProvider:
if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value)
self._solar_conditions.infer_descriptions()
self._solar_conditions_cache['solar_conditions'] = self._solar_conditions
+4 -2
View File
@@ -18,7 +18,8 @@ from server.webserver import WebServer
# Globals
spots = Cache('cache/spots_cache')
alerts = Cache('cache/alerts_cache')
solar_conditions = SolarConditions()
solar_conditions_cache = Cache('cache/solar_conditions_cache')
solar_conditions = solar_conditions_cache.get('solar_conditions', SolarConditions())
web_server = None
status_data = {}
spot_providers = []
@@ -48,6 +49,7 @@ def shutdown(sig, frame):
lookup_helper.stop()
spots.close()
alerts.close()
solar_conditions_cache.close()
os._exit(0)
@@ -120,7 +122,7 @@ if __name__ == '__main__':
for entry in config.get("solar-condition-providers", []):
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
for p in solar_condition_providers:
p.setup(solar_conditions=solar_conditions)
p.setup(solar_conditions=solar_conditions, solar_conditions_cache=solar_conditions_cache)
if p.enabled:
p.start()
+1 -1
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>
</div>
<script src="/js/common.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -69,8 +69,8 @@
</div>
<script src="/js/common.js?v=1778868536"></script>
<script src="/js/add-spot.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/add-spot.js?v=1778927183"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -70,8 +70,8 @@
</div>
<script src="/js/common.js?v=1778868536"></script>
<script src="/js/alerts.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/alerts.js?v=1778927183"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+3 -3
View File
@@ -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=1778868536"></script>
<script src="/js/spotsbandsandmap.js?v=1778868536"></script>
<script src="/js/bands.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/spotsbandsandmap.js?v=1778927183"></script>
<script src="/js/bands.js?v=1778927183"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+4 -4
View File
@@ -24,7 +24,7 @@
<title>Spothole</title>
<link rel="stylesheet" href="/css/style.css?v=1778868536" type="text/css">
<link rel="stylesheet" href="/css/style.css?v=1778927183" 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=1778868536"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778868536"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778868536"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1778927183"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778927183"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778927183"></script>
</head>
<body>
+4 -4
View File
@@ -176,7 +176,7 @@
{% if has_giro_ionosonde %}
<div class="card mt-5">
<div class="card-header">
Critical & Maximum Usable Frequencies
Ionosonde Data
</div>
<div class="card-body">
<div class="mb-3">
@@ -186,7 +186,7 @@
</select>
</div>
<div id="ionosonde-latest" class="mb-3"></div>
<canvas id="ionosonde-chart" class="mt-3 mb-3 d-none d-md-block"></canvas>
<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>
</div>
@@ -249,8 +249,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=1778868536"></script>
<script src="/js/conditions.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/conditions.js?v=1778927183"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
+3 -3
View File
@@ -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=1778868536"></script>
<script src="/js/spotsbandsandmap.js?v=1778868536"></script>
<script src="/js/map.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/spotsbandsandmap.js?v=1778927183"></script>
<script src="/js/map.js?v=1778927183"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+3 -3
View File
@@ -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=1778868536"></script>
<script src="/js/spotsbandsandmap.js?v=1778868536"></script>
<script src="/js/spots.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/spotsbandsandmap.js?v=1778927183"></script>
<script src="/js/spots.js?v=1778927183"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -59,8 +59,8 @@
</div>
</div>
<script src="/js/common.js?v=1778868536"></script>
<script src="/js/status.js?v=1778868536"></script>
<script src="/js/common.js?v=1778927183"></script>
<script src="/js/status.js?v=1778927183"></script>
<script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script>
+7 -8
View File
@@ -15,7 +15,7 @@ info:
### 1.4
* `/solar` response now includes `ionosonde_data`, a list of ionosonde station measurements (foF2 and MUF) sourced from the GIRO Data Center.
* `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (foF2 and MUF) sourced from the GIRO Data Center.
### 1.3
@@ -1688,13 +1688,12 @@ components:
description: Electron flux impact description, derived from electron flux level.
example: "No impact"
ionosonde_data:
type: array
type: object
nullable: true
description: >
Ionosonde measurements from the GIRO Data Center, covering active stations listed in the
system. Only stations for which data was successfully retrieved are included. Null if the
GIROIonosonde provider has not yet completed its first poll.
items:
Ionosonde measurements from the GIRO Data Center, keyed by URSI station code. All known stations are
included, but not all of them may contain data.
additionalProperties:
$ref: '#/components/schemas/IonosondeStation'
IonosondeStation:
@@ -1712,7 +1711,7 @@ components:
fof2:
type: object
nullable: true
description: F2 layer critical frequency (foF2) measurements in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement.
description: F2 layer critical frequency (foF2) measurements 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:
@@ -1721,7 +1720,7 @@ components:
muf:
type: object
nullable: true
description: Maximum Usable Frequency (MUF) for a 3000 km path in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement.
description: Maximum Usable Frequency (MUF) for a 3000 km path 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:
+42 -13
View File
@@ -115,7 +115,7 @@ function loadSolarConditions() {
// Ionosonde
if (jsonData.ionosonde_data && jsonData.ionosonde_data.length > 0) {
if (jsonData.ionosonde_data && Object.keys(jsonData.ionosonde_data).length > 0) {
ionosondeData = jsonData.ionosonde_data;
populateIonosondeDropdown(ionosondeData);
renderIonosondeData();
@@ -366,9 +366,12 @@ function populateIonosondeDropdown(data) {
const savedUrsi = localStorage.getItem('#ionosonde-station:value');
const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null;
select.empty();
data.forEach(function (station) {
// Sort by station name rather than URSI because station name is what's displayed, and any out-of-order names might
// confuse the user
Object.values(data).sort((a, b) => a.name.localeCompare(b.name)).forEach(function (station) {
select.append($('<option>', {value: station.ursi, text: station.name}));
});
// Select one by default if the user's localStorage has an existing selection for this
if (savedValue && select.find('option[value="' + savedValue + '"]').length) {
select.val(savedValue);
}
@@ -376,20 +379,25 @@ function populateIonosondeDropdown(data) {
// Render the foF2/MUF data and line chart for the currently selected station
function renderIonosondeData() {
// First make sure that we have some data, that a station entry is selected in the drop-down box, and that the
// data contains an entry for that station. If not, this represents an odd state (over and above just "no data for
// this station", so bail out at this point. The user will have to reselect something from the list, or wait until
// the API is behaving itself again.
if (!ionosondeData) return;
const ursi = $('#ionosonde-station').val();
if (!ursi) return;
const station = ionosondeData.find(function (s) {
return s.ursi === ursi;
});
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
const style = getComputedStyle(document.documentElement);
const fof2Color = style.getPropertyValue('--bs-primary').trim();
const mufColor = 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)';
// Utility function to convert the dict of timestamp-to-value into just a value array in timestamp key order
function toSeries(dict) {
if (!dict) return [];
return Object.entries(dict)
@@ -400,23 +408,33 @@ function renderIonosondeData() {
const fof2Entries = toSeries(station.fof2);
const mufEntries = toSeries(station.muf);
const allTs = [...fof2Entries, ...mufEntries].map(e => e.ts);
if (allTs.length === 0) return;
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-chart').hide();
if (ionosondeChart) { ionosondeChart.destroy(); ionosondeChart = null; }
return;
}
// 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 latestTs = allTs.length ? Math.max(...allTs) : null;
var latestTimeStr = '';
if (latestTs != null) {
const latestDate = moment.utc(latestTs * 1000);
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() + ')';
}
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 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-2 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>'
);
@@ -424,9 +442,6 @@ function renderIonosondeData() {
ionosondeChart.destroy();
}
const minTs = Math.min(...allTs);
const maxTs = Math.max(...allTs);
// Compute tick positions at 3-hour UTC boundaries so midnight always lands on a tick, which triggers the date being
// printed, and in general looks nicer than arbitrary ticks based on min & max timestamp
const tickStep = 3 * 3600;
@@ -437,6 +452,7 @@ function renderIonosondeData() {
}
tickValues.push(maxTs);
// Build time axis
const timeAxis = {
type: 'linear',
min: minTs,
@@ -450,6 +466,8 @@ function renderIonosondeData() {
maxRotation: 45,
minRotation: 0,
callback(value) {
// Use the same type of display as in the k-index chart, where the labels on the axis are just HH:mm
// unless that's 00:00, in which case add the short date as well.
const dt = new Date(value * 1000);
const h = dt.getUTCHours();
const m = dt.getUTCMinutes();
@@ -463,6 +481,8 @@ function renderIonosondeData() {
grid: {color: gridColor},
};
// Build frequency axis. This is pretty normal except there's no grid, because we draw extra horizontal lines for
// the amateur radio bands which function as grid lines for the frequency axis.
const freqAxis = {
min: 0,
title: {display: true, text: 'Frequency (MHz)', color: textColor},
@@ -470,6 +490,7 @@ function renderIonosondeData() {
grid: {display: false},
};
// List of ham bands for drawing horizontal lines
const AMATEUR_BANDS = [
{label: '160m', freq: 1.8},
{label: '80m', freq: 3.5},
@@ -483,6 +504,7 @@ function renderIonosondeData() {
{label: '10m', freq: 28.0},
];
// Build the horizontal lines for each ham band, including a label on the right-hand side.
const bandLinesPlugin = {
id: 'bandLines',
beforeDatasetsDraw(chart) {
@@ -492,6 +514,8 @@ function renderIonosondeData() {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
// Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having
// no top "border" gridline
const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
ctx.beginPath();
@@ -501,6 +525,7 @@ function renderIonosondeData() {
}
ctx.font = '10px sans-serif';
ctx.fillStyle = textColor;
// Add the ham band "grid lines"
AMATEUR_BANDS.forEach(({label, freq}) => {
const y = scales.y.getPixelForValue(freq);
if (y < chartArea.top || y > chartArea.bottom) return;
@@ -516,6 +541,7 @@ function renderIonosondeData() {
}
};
// Create the chart itself
ionosondeChart = new Chart(document.getElementById('ionosonde-chart'), {
type: 'line',
data: {
@@ -549,6 +575,9 @@ function renderIonosondeData() {
},
plugins: [bandLinesPlugin],
});
// Chart canvas is normally hidden until we get here with some definitely good data. Now we have that so show it
$('#ionosonde-chart').show();
}
// Called when the ionosonde station select changes