From c10b5e4947799051893d053a5d61763f4e892863 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Fri, 3 Apr 2026 18:11:45 +0100 Subject: [PATCH] Add fetching of NOAA 3-day forecast --- solarconditionsproviders/noaa3dayforecast.py | 13 ++- templates/about.html | 2 +- templates/add_spot.html | 4 +- templates/alerts.html | 4 +- templates/bands.html | 6 +- templates/base.html | 9 +- templates/conditions.html | 15 +-- templates/map.html | 6 +- templates/spots.html | 6 +- templates/status.html | 4 +- webassets/js/conditions.js | 97 +++++++++++++------- 11 files changed, 96 insertions(+), 70 deletions(-) diff --git a/solarconditionsproviders/noaa3dayforecast.py b/solarconditionsproviders/noaa3dayforecast.py index b74e23f..71efc19 100644 --- a/solarconditionsproviders/noaa3dayforecast.py +++ b/solarconditionsproviders/noaa3dayforecast.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider -POLL_INTERVAL = 3600 +POLL_INTERVAL = 10800 # Every 3 hours URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt" @@ -132,17 +132,16 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): continue start_hour = int(time_match.group(1)) - raw_values = time_match.group(3).split() + # Split on 2 or more spaces so that e.g. "5.67 (G2)" stays as one token per column + raw_values = re.split(r' {2,}', time_match.group(3).strip()) for i, val in enumerate(raw_values): if i >= len(column_dates): break - # Discard bracketed values - if val.startswith('(') and val.endswith(')'): - continue + # Take only the leading numeric part, discarding any bracketed section try: - kp = float(val) - except ValueError: + kp = float(val.split()[0]) + except (ValueError, IndexError): continue date = column_dates[i] diff --git a/templates/about.html b/templates/about.html index e936997..e8be2f7 100644 --- a/templates/about.html +++ b/templates/about.html @@ -67,7 +67,7 @@

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.

- + {% end %} \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index 6a1f181..d058546 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -69,8 +69,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/alerts.html b/templates/alerts.html index 983a93f..c413b8d 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -56,8 +56,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/bands.html b/templates/bands.html index 9c1c3bd..3f05427 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -62,9 +62,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 4259bf8..b0b413d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -45,11 +45,12 @@ integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"> + - - - - + + + + diff --git a/templates/conditions.html b/templates/conditions.html index c05d037..fc7efa6 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -136,16 +136,9 @@
-
+
K-index Forecast
-
- - - - - -
-
+
@@ -230,8 +223,8 @@
- - + + diff --git a/templates/map.html b/templates/map.html index 366494f..68a722b 100644 --- a/templates/map.html +++ b/templates/map.html @@ -70,9 +70,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/spots.html b/templates/spots.html index aab18cd..a5845cf 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -87,9 +87,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/status.html b/templates/status.html index 7d37923..fe1cc1a 100644 --- a/templates/status.html +++ b/templates/status.html @@ -59,8 +59,8 @@
- - + + diff --git a/webassets/js/conditions.js b/webassets/js/conditions.js index dd1e4a6..6113208 100644 --- a/webassets/js/conditions.js +++ b/webassets/js/conditions.js @@ -1,6 +1,8 @@ // 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 +let kpChart = null; // Load solar conditions function loadSolarConditions() { @@ -114,7 +116,7 @@ function loadSolarConditions() { }); } -// Render the K-index forecast table (rows = 3-hour UTC time slots, columns = forecast dates) +// Render the K-index forecast as a Chart.js bar chart, one bar per 3-hour UTC period function renderKIndexForecast(data) { if (!data) return; @@ -123,40 +125,71 @@ function renderKIndexForecast(data) { .sort((a, b) => a.ts - b.ts); if (entries.length === 0) return; - // Derive the unique UTC dates from sorted entries - const dateSet = new Set(); - entries.forEach(e => dateSet.add(new Date(e.ts * 1000).toISOString().slice(0, 10))); - const dates = [...dateSet]; - - const kpByTs = {}; - entries.forEach(e => { kpByTs[e.ts] = e.kp; }); - - // Header row - const headRow = $('#forecast-kp-table thead tr').empty().append('Time (UTC)'); - dates.forEach(dateStr => { - const label = new Date(dateStr + 'T00:00:00Z') - .toLocaleDateString('en-GB', { day: '2-digit', month: 'short', timeZone: 'UTC' }); - headRow.append(`${label}`); + // x-axis labels. Show date only on the first bar of each day, time on all bars + const labels = entries.map((e, i) => { + const dt = new Date(e.ts * 1000); + const timeStr = String(dt.getUTCHours()).padStart(2, '0') + ':00'; + const dateStr = dt.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', timeZone: 'UTC' }); + const prev = i > 0 ? new Date(entries[i - 1].ts * 1000) : null; + const newDay = !prev || prev.toISOString().slice(0, 10) !== dt.toISOString().slice(0, 10); + return newDay ? [timeStr, dateStr] : timeStr; }); - // Data rows: one per 3-hour slot - const tbody = $('#forecast-kp-table tbody').empty(); - [0, 3, 6, 9, 12, 15, 18, 21].forEach(startHour => { - const endHour = (startHour + 3) % 24; - const timeLabel = String(startHour).padStart(2, '0') + '-' + String(endHour).padStart(2, '0') + 'UT'; - const tr = $('').append(`${timeLabel}`); - dates.forEach(dateStr => { - const [y, m, d] = dateStr.split('-').map(Number); - const slotTs = Date.UTC(y, m - 1, d, startHour, 0, 0) / 1000; - const td = $(''); - const kp = kpByTs[slotTs]; - if (kp !== undefined) { - td.text(kp.toFixed(2)); - td.addClass(kp < 5 ? 'bg-success-subtle' : kp < 7 ? 'bg-warning-subtle' : 'bg-danger-subtle'); + // Inherit colours from Bootstrap CSS variables so that dark mode inherently works. We want bar colours that are not + // quite as saturated as the Bootstrap success/warning/danger colours but not as desaturated as the "subtle" + // versions, so use tinycolor to apply some transparency. + const style = getComputedStyle(document.documentElement); + const withAlpha = hex => tinycolor(hex).setAlpha(0.8).toRgbString(); + const colors = entries.map(e => + e.kp < 4.5 ? withAlpha(style.getPropertyValue('--bs-success').trim()) + : e.kp < 6.5 ? withAlpha(style.getPropertyValue('--bs-warning').trim()) + : withAlpha(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)'; + + if (kpChart) { kpChart.destroy(); } + + kpChart = new Chart(document.getElementById('forecast-kp-chart'), { + type: 'bar', + data: { + labels, + datasets: [{ + data: entries.map(e => e.kp), + backgroundColor: colors, + borderWidth: 0, + }] + }, + options: { + responsive: true, + aspectRatio: 3, + plugins: { + legend: { + display: false + }, + tooltip: { + enabled: false + } + }, + scales: { + x: { + ticks: { color: textColor, maxRotation: 45, minRotation: 0 }, + grid: { color: gridColor }, + }, + y: { + min: 0, + max: 9, + title: { display: true, text: 'Kp', color: textColor }, + // Include geomagnetic storm levels (Gx) on the y-axis as well as the Kp index + ticks: { + stepSize: 1, + color: textColor, + callback: v => v > 4 ? `(G${v - 4}) ${v}` : String(v), + }, + grid: { color: gridColor }, + } } - tr.append(td); - }); - tbody.append(tr); + } }); }