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 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, list of 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
ionosonde_data: list = None ionosonde_data: dict = None
# Derived values (populated by infer_descriptions()) # Derived values (populated by infer_descriptions())
# HF radio blackout risk description, derived from xray # 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()}) stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
return stations 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): def start(self):
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.") logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, daemon=True) self._thread = Thread(target=self._run, daemon=True)
@@ -51,34 +61,33 @@ class GIROIonosonde(SolarConditionsProvider):
def _poll(self): def _poll(self):
try: try:
logging.debug(f"Polling {self.name} ionosonde data...") logging.debug(f"Polling GIRO ionosonde data...")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
from_time = now - timedelta(hours=HISTORY_HOURS) 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: for station in self._stations:
if self._stop_event.is_set(): if self._stop_event.is_set():
break break
ursi = station["ursi"] ursi = station["ursi"]
name = station["name"] name = station["name"]
entry = {"ursi": ursi, "name": name, "fof2": None, "muf": None}
try: try:
fof2, muf = self._fetch_station_data(ursi, from_time, now) fof2, muf = self._fetch_station_data(ursi, from_time, now)
entry["fof2"] = fof2
entry["muf"] = muf
if fof2 and muf: if fof2 and muf:
results.append(entry) ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf}
updated_count += 1
except Exception: 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.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) 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: except Exception:
self.status = "Error" 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) 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):
@@ -86,9 +95,7 @@ class GIROIonosonde(SolarConditionsProvider):
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 = ( url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD&DMUF=3000&fromDate={from_str}&toDate={to_str}"
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)) 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
@@ -104,10 +111,11 @@ class GIROIonosonde(SolarConditionsProvider):
line = line.strip() line = line.strip()
if not line or line.startswith('#'): if not line or line.startswith('#'):
continue continue
# Data rows: timestamp CS foF2 QD MUFD QD # Data rows have the following format: timestamp CS foF2 QD MUFD QD
parts = line.split() parts = line.split()
if len(parts) >= 5: if len(parts) >= 5:
try: try:
# Python 3.8 TZ parsing fudge
ts = datetime.fromisoformat(parts[0].replace('Z', '+00:00')).timestamp() ts = datetime.fromisoformat(parts[0].replace('Z', '+00:00')).timestamp()
except ValueError: except ValueError:
continue continue
@@ -16,10 +16,11 @@ class SolarConditionsProvider:
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = None self._solar_conditions = None
def setup(self, solar_conditions): def setup(self, solar_conditions, solar_conditions_cache):
"""Set up the provider, giving it the solar conditions dict to update""" """Set up the provider, giving it the solar conditions object and its backing cache"""
self._solar_conditions = solar_conditions self._solar_conditions = solar_conditions
self._solar_conditions_cache = solar_conditions_cache
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """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): if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value) setattr(self._solar_conditions, key, value)
self._solar_conditions.infer_descriptions() 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 # Globals
spots = Cache('cache/spots_cache') spots = Cache('cache/spots_cache')
alerts = Cache('cache/alerts_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 web_server = None
status_data = {} status_data = {}
spot_providers = [] spot_providers = []
@@ -48,6 +49,7 @@ def shutdown(sig, frame):
lookup_helper.stop() lookup_helper.stop()
spots.close() spots.close()
alerts.close() alerts.close()
solar_conditions_cache.close()
os._exit(0) os._exit(0)
@@ -120,7 +122,7 @@ if __name__ == '__main__':
for entry in config.get("solar-condition-providers", []): for entry in config.get("solar-condition-providers", []):
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry)) solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
for p in solar_condition_providers: 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: if p.enabled:
p.start() 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> <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=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> <script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+2 -2
View File
@@ -69,8 +69,8 @@
</div> </div>
<script src="/js/common.js?v=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/add-spot.js?v=1778868536"></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> <script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+2 -2
View File
@@ -70,8 +70,8 @@
</div> </div>
<script src="/js/common.js?v=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/alerts.js?v=1778868536"></script> <script src="/js/alerts.js?v=1778927183"></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 %}
+3 -3
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=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/spotsbandsandmap.js?v=1778868536"></script> <script src="/js/spotsbandsandmap.js?v=1778927183"></script>
<script src="/js/bands.js?v=1778868536"></script> <script src="/js/bands.js?v=1778927183"></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 %}
+4 -4
View File
@@ -24,7 +24,7 @@
<title>Spothole</title> <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" <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=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=1778868536"></script> <script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778927183"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778868536"></script> <script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778927183"></script>
</head> </head>
<body> <body>
+4 -4
View File
@@ -176,7 +176,7 @@
{% if has_giro_ionosonde %} {% if has_giro_ionosonde %}
<div class="card mt-5"> <div class="card mt-5">
<div class="card-header"> <div class="card-header">
Critical & Maximum Usable Frequencies Ionosonde Data
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
@@ -186,7 +186,7 @@
</select> </select>
</div> </div>
<div id="ionosonde-latest" class="mb-3"></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 class="form-text mt-2">Data from the <a href="https://lgdc.uml.edu/">Lowell GIRO Data Center</a>.</div>
</div> </div>
</div> </div>
@@ -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=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/conditions.js?v=1778868536"></script> <script src="/js/conditions.js?v=1778927183"></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>
+3 -3
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=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/spotsbandsandmap.js?v=1778868536"></script> <script src="/js/spotsbandsandmap.js?v=1778927183"></script>
<script src="/js/map.js?v=1778868536"></script> <script src="/js/map.js?v=1778927183"></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 %}
+3 -3
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=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/spotsbandsandmap.js?v=1778868536"></script> <script src="/js/spotsbandsandmap.js?v=1778927183"></script>
<script src="/js/spots.js?v=1778868536"></script> <script src="/js/spots.js?v=1778927183"></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 %}
+2 -2
View File
@@ -59,8 +59,8 @@
</div> </div>
</div> </div>
<script src="/js/common.js?v=1778868536"></script> <script src="/js/common.js?v=1778927183"></script>
<script src="/js/status.js?v=1778868536"></script> <script src="/js/status.js?v=1778927183"></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>
+7 -8
View File
@@ -15,7 +15,7 @@ info:
### 1.4 ### 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 ### 1.3
@@ -1688,13 +1688,12 @@ components:
description: Electron flux impact description, derived from electron flux level. description: Electron flux impact description, derived from electron flux level.
example: "No impact" example: "No impact"
ionosonde_data: ionosonde_data:
type: array type: object
nullable: true nullable: true
description: > description: >
Ionosonde measurements from the GIRO Data Center, covering active stations listed in the Ionosonde measurements from the GIRO Data Center, keyed by URSI station code. All known stations are
system. Only stations for which data was successfully retrieved are included. Null if the included, but not all of them may contain data.
GIROIonosonde provider has not yet completed its first poll. additionalProperties:
items:
$ref: '#/components/schemas/IonosondeStation' $ref: '#/components/schemas/IonosondeStation'
IonosondeStation: IonosondeStation:
@@ -1712,7 +1711,7 @@ components:
fof2: fof2:
type: object type: object
nullable: true 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: additionalProperties:
type: number type: number
example: example:
@@ -1721,7 +1720,7 @@ components:
muf: muf:
type: object type: object
nullable: true 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: additionalProperties:
type: number type: number
example: example:
+42 -13
View File
@@ -115,7 +115,7 @@ function loadSolarConditions() {
// Ionosonde // 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; ionosondeData = jsonData.ionosonde_data;
populateIonosondeDropdown(ionosondeData); populateIonosondeDropdown(ionosondeData);
renderIonosondeData(); renderIonosondeData();
@@ -366,9 +366,12 @@ function populateIonosondeDropdown(data) {
const savedUrsi = localStorage.getItem('#ionosonde-station:value'); const savedUrsi = localStorage.getItem('#ionosonde-station:value');
const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null; const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null;
select.empty(); 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.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) { if (savedValue && select.find('option[value="' + savedValue + '"]').length) {
select.val(savedValue); select.val(savedValue);
} }
@@ -376,20 +379,25 @@ function populateIonosondeDropdown(data) {
// Render the foF2/MUF data and line chart for the currently selected station // Render the foF2/MUF data and line chart for the currently selected station
function renderIonosondeData() { 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; if (!ionosondeData) return;
const ursi = $('#ionosonde-station').val(); const ursi = $('#ionosonde-station').val();
if (!ursi) return; if (!ursi) return;
const station = ionosondeData.find(function (s) { const station = ionosondeData[ursi];
return s.ursi === 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
// real reason but just to get a suitable blue and red 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-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)';
// Utility function to convert the dict of timestamp-to-value into just a value array in timestamp key order
function toSeries(dict) { function toSeries(dict) {
if (!dict) return []; if (!dict) return [];
return Object.entries(dict) return Object.entries(dict)
@@ -400,23 +408,33 @@ 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 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) // 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 latestTs = allTs.length ? Math.max(...allTs) : null; const minTs = allTs.length ? Math.min(...allTs) : null;
var latestTimeStr = ''; const maxTs = allTs.length ? Math.max(...allTs) : null;
if (latestTs != null) { let latestTimeStr = '';
const latestDate = moment.utc(latestTs * 1000); if (maxTs != null) {
const latestDate = moment.utc(maxTs * 1000);
latestTimeStr = latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')'; 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( $('#ionosonde-latest').html(
'<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-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-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 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 +
'</div>' '</div>'
); );
@@ -424,9 +442,6 @@ function renderIonosondeData() {
ionosondeChart.destroy(); 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 // 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 // printed, and in general looks nicer than arbitrary ticks based on min & max timestamp
const tickStep = 3 * 3600; const tickStep = 3 * 3600;
@@ -437,6 +452,7 @@ function renderIonosondeData() {
} }
tickValues.push(maxTs); tickValues.push(maxTs);
// Build time axis
const timeAxis = { const timeAxis = {
type: 'linear', type: 'linear',
min: minTs, min: minTs,
@@ -450,6 +466,8 @@ function renderIonosondeData() {
maxRotation: 45, maxRotation: 45,
minRotation: 0, minRotation: 0,
callback(value) { 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 dt = new Date(value * 1000);
const h = dt.getUTCHours(); const h = dt.getUTCHours();
const m = dt.getUTCMinutes(); const m = dt.getUTCMinutes();
@@ -463,6 +481,8 @@ function renderIonosondeData() {
grid: {color: gridColor}, 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 = { const freqAxis = {
min: 0, min: 0,
title: {display: true, text: 'Frequency (MHz)', color: textColor}, title: {display: true, text: 'Frequency (MHz)', color: textColor},
@@ -470,6 +490,7 @@ function renderIonosondeData() {
grid: {display: false}, grid: {display: false},
}; };
// List of ham bands for drawing horizontal lines
const AMATEUR_BANDS = [ const AMATEUR_BANDS = [
{label: '160m', freq: 1.8}, {label: '160m', freq: 1.8},
{label: '80m', freq: 3.5}, {label: '80m', freq: 3.5},
@@ -483,6 +504,7 @@ function renderIonosondeData() {
{label: '10m', freq: 28.0}, {label: '10m', freq: 28.0},
]; ];
// Build the horizontal lines for each ham band, including a label on the right-hand side.
const bandLinesPlugin = { const bandLinesPlugin = {
id: 'bandLines', id: 'bandLines',
beforeDatasetsDraw(chart) { beforeDatasetsDraw(chart) {
@@ -492,6 +514,8 @@ function renderIonosondeData() {
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([]); 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); const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) { if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
ctx.beginPath(); ctx.beginPath();
@@ -501,6 +525,7 @@ function renderIonosondeData() {
} }
ctx.font = '10px sans-serif'; ctx.font = '10px sans-serif';
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
// Add the ham band "grid lines"
AMATEUR_BANDS.forEach(({label, freq}) => { AMATEUR_BANDS.forEach(({label, freq}) => {
const y = scales.y.getPixelForValue(freq); const y = scales.y.getPixelForValue(freq);
if (y < chartArea.top || y > chartArea.bottom) return; 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'), { ionosondeChart = new Chart(document.getElementById('ionosonde-chart'), {
type: 'line', type: 'line',
data: { data: {
@@ -549,6 +575,9 @@ function renderIonosondeData() {
}, },
plugins: [bandLinesPlugin], 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 // Called when the ionosonde station select changes