Support fetching ionosonde data for FoF2 and MUF display on the Conditions page

This commit is contained in:
Ian Renton
2026-05-15 18:25:54 +01:00
parent 2026b46113
commit 64a7b27887
17 changed files with 473 additions and 28 deletions

View File

@@ -13,6 +13,10 @@ info:
## Changelog
### 1.4
* `/solar` response now includes `ionosonde_data`, a list of ionosonde station measurements (foF2 and MUF) sourced from the GIRO Data Center.
### 1.3
* `/spots`, `/spots/stream`, `/alerts`, `/alerts/stream`, and `/lookup/call` now accept optional QRZ.com and HamQTH credentials as query parameters. When supplied, returned data is enriched with operator name, home location etc. from those services.
@@ -1683,6 +1687,46 @@ components:
type: string
description: Electron flux impact description, derived from electron flux level.
example: "No impact"
ionosonde_data:
type: array
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:
$ref: '#/components/schemas/IonosondeStation'
IonosondeStation:
type: object
description: Ionosonde measurement data for a single station, covering approximately the last 24 hours.
properties:
ursi:
type: string
description: URSI code identifying the ionosonde station.
example: DB049
name:
type: string
description: Human-readable name of the ionosonde station.
example: Dourbes
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.
additionalProperties:
type: number
example:
"1747267201.0": 7.45
"1747267501.0": 7.50
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.
additionalProperties:
type: number
example:
"1747267201.0": 21.66
"1747267501.0": 21.80
SolarConditionsProviderStatus:
type: object

View File

@@ -1,8 +1,12 @@
// Cache for the full dxstats API response, so we can reload on the fly if the user changes the value of their continent
// in the select box
let dxStatsData = null;
// Forecast chart
// Kp forecast chart
let kpChart = null;
// Cache for ionosonde data from the API
let ionosondeData = null;
// Ionosonde foF2/MUF chart
let ionosondeChart = null;
// Load solar conditions
function loadSolarConditions() {
@@ -109,6 +113,14 @@ function loadSolarConditions() {
electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
// Ionosonde
if (jsonData.ionosonde_data && jsonData.ionosonde_data.length > 0) {
ionosondeData = jsonData.ionosonde_data;
populateIonosondeDropdown(ionosondeData);
renderIonosondeData();
}
// Forecast
renderKIndexForecast(jsonData.k_index_forecast);
@@ -348,6 +360,203 @@ function renderBlackoutForecast(r1r2Data, r3Data) {
.append(makeRow('R3 or greater', e => e.r3));
}
// Populate the ionosonde station dropdown and restore any saved selection
function populateIonosondeDropdown(data) {
const select = $('#ionosonde-station');
const savedUrsi = localStorage.getItem('#ionosonde-station:value');
const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null;
select.empty();
data.forEach(function (station) {
select.append($('<option>', {value: station.ursi, text: station.name}));
});
if (savedValue && select.find('option[value="' + savedValue + '"]').length) {
select.val(savedValue);
}
}
// Render the foF2/MUF data and line chart for the currently selected station
function renderIonosondeData() {
if (!ionosondeData) return;
const ursi = $('#ionosonde-station').val();
if (!ursi) return;
const station = ionosondeData.find(function (s) {
return s.ursi === ursi;
});
if (!station) return;
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)';
function toSeries(dict) {
if (!dict) return [];
return Object.entries(dict)
.map(([tsStr, val]) => ({ts: parseFloat(tsStr), val}))
.sort((a, b) => a.ts - b.ts);
}
const fof2Entries = toSeries(station.fof2);
const mufEntries = toSeries(station.muf);
const allTs = [...fof2Entries, ...mufEntries].map(e => e.ts);
if (allTs.length === 0) 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);
latestTimeStr = latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')';
}
$('#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-6 py-2">' +
'<span class="me-5">foF2: <strong>' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '</strong></span>' +
'<span>MUF (3000 km): <strong>' + (latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A') + '</strong></span>' +
'</div>' +
'</div>'
);
if (ionosondeChart) {
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;
const tickValues = [];
for (let t = Math.ceil(minTs / tickStep) * tickStep; t <= maxTs; t += tickStep) {
tickValues.push(t);
}
tickValues.push(maxTs);
const timeAxis = {
type: 'linear',
min: minTs,
max: maxTs,
title: {display: true, text: 'Time (UTC)', color: textColor},
afterBuildTicks(axis) {
axis.ticks = tickValues.map(v => ({value: v}));
},
ticks: {
color: textColor,
maxRotation: 45,
minRotation: 0,
callback(value) {
const dt = new Date(value * 1000);
const h = dt.getUTCHours();
const m = dt.getUTCMinutes();
const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
if (h === 0 && m === 0) {
return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})];
}
return timeStr;
},
},
grid: {color: gridColor},
};
const freqAxis = {
min: 0,
title: {display: true, text: 'Frequency (MHz)', color: textColor},
ticks: {color: textColor},
grid: {display: false},
};
const AMATEUR_BANDS = [
{label: '160m', freq: 1.8},
{label: '80m', freq: 3.5},
{label: '60m', freq: 5.3515},
{label: '40m', freq: 7.0},
{label: '30m', freq: 10.1},
{label: '20m', freq: 14.0},
{label: '17m', freq: 18.068},
{label: '15m', freq: 21.0},
{label: '12m', freq: 24.89},
{label: '10m', freq: 28.0},
];
const bandLinesPlugin = {
id: 'bandLines',
beforeDatasetsDraw(chart) {
const {ctx, chartArea, scales} = chart;
if (!scales.y) return;
ctx.save();
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
ctx.beginPath();
ctx.moveTo(chartArea.left, y30);
ctx.lineTo(chartArea.right, y30);
ctx.stroke();
}
ctx.font = '10px sans-serif';
ctx.fillStyle = textColor;
AMATEUR_BANDS.forEach(({label, freq}) => {
const y = scales.y.getPixelForValue(freq);
if (y < chartArea.top || y > chartArea.bottom) return;
ctx.beginPath();
ctx.moveTo(chartArea.left, y);
ctx.lineTo(chartArea.right, y);
ctx.stroke();
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText(label, chartArea.right - 4, y - 2);
});
ctx.restore();
}
};
ionosondeChart = new Chart(document.getElementById('ionosonde-chart'), {
type: 'line',
data: {
datasets: [
{
label: 'foF2',
data: fof2Entries.map(e => ({x: e.ts, y: e.val})),
borderColor: fof2Color,
backgroundColor: 'transparent',
pointRadius: 0,
tension: 0.2,
},
{
label: 'MUF (3000 km)',
data: mufEntries.map(e => ({x: e.ts, y: e.val})),
borderColor: mufColor,
backgroundColor: 'transparent',
pointRadius: 0,
tension: 0.2,
}
]
},
options: {
responsive: true,
aspectRatio: 3,
plugins: {
legend: {display: true, labels: {color: textColor, usePointStyle: true, pointStyle: 'line'}},
tooltip: {enabled: false}
},
scales: {x: timeAxis, y: freqAxis},
},
plugins: [bandLinesPlugin],
});
}
// Called when the ionosonde station select changes
function ionosondeStationChanged() {
saveSettings();
renderIonosondeData();
}
// Render the DX stats table for the currently selected DE continent
function renderDxStats() {
if (!dxStatsData) {