From 46d5d9a1b56931d73026cd02f6334fe8f428c78b Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 23 Mar 2026 21:30:26 -0400 Subject: [PATCH 1/4] perf: reduce VOACAP heatmap server load with aggressive caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With 5000+ users, each unique DE location generated a separate heatmap computation (~600 cells of trig). Server was overwhelmed. - Round DE lat/lon to whole degrees on both client and server — propagation doesn't meaningfully differ within 1 degree, and this lets nearby users share the same cached result - Increase server-side heatmap cache TTL from 5 to 15 minutes - Increase CDN/browser Cache-Control for heatmap from 10 to 15 min - Frontend also sends rounded coordinates so identical URLs hit browser and Cloudflare edge caches Co-Authored-By: Claude Opus 4.6 (1M context) --- server/middleware/index.js | 2 ++ server/routes/propagation.js | 8 +++++--- src/plugins/layers/useVOACAPHeatmap.js | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/server/middleware/index.js b/server/middleware/index.js index 06cb6cab..e1dbc38d 100644 --- a/server/middleware/index.js +++ b/server/middleware/index.js @@ -139,6 +139,8 @@ function applyMiddleware(app, ctx) { cacheDuration = 1800; } else if (p.includes('/solar-indices') || p.includes('/noaa')) { cacheDuration = 300; + } else if (p.includes('/propagation/heatmap')) { + cacheDuration = 900; // 15 min — propagation changes slowly, heavy computation } else if (p.includes('/propagation')) { cacheDuration = 600; } else if (p.includes('/n0nbh') || p.includes('/hamqsl')) { diff --git a/server/routes/propagation.js b/server/routes/propagation.js index 3b203f3b..ccc0dd2d 100644 --- a/server/routes/propagation.js +++ b/server/routes/propagation.js @@ -496,7 +496,7 @@ module.exports = function (app, ctx) { } const PROP_HEATMAP_CACHE = {}; - const PROP_HEATMAP_TTL = 5 * 60 * 1000; // 5 minutes + const PROP_HEATMAP_TTL = 15 * 60 * 1000; // 15 minutes — propagation changes slowly const PROP_HEATMAP_MAX_ENTRIES = 200; // Hard cap on cache entries // Periodic cleanup: purge expired heatmap cache entries every 10 minutes @@ -531,8 +531,10 @@ module.exports = function (app, ctx) { ); app.get('/api/propagation/heatmap', async (req, res) => { - const deLat = parseFloat(req.query.deLat) || 0; - const deLon = parseFloat(req.query.deLon) || 0; + // Round to whole degrees — propagation doesn't meaningfully differ within 1°, + // and this dramatically improves cache hit rate across users + const deLat = Math.round(parseFloat(req.query.deLat) || 0); + const deLon = Math.round(parseFloat(req.query.deLon) || 0); const freq = parseFloat(req.query.freq) || 14; // MHz, default 20m const gridSize = Math.max(5, Math.min(20, parseInt(req.query.grid) || 10)); // 5-20° grid const txMode = (req.query.mode || 'SSB').toUpperCase(); diff --git a/src/plugins/layers/useVOACAPHeatmap.js b/src/plugins/layers/useVOACAPHeatmap.js index 6c9ef77c..1c899579 100644 --- a/src/plugins/layers/useVOACAPHeatmap.js +++ b/src/plugins/layers/useVOACAPHeatmap.js @@ -155,7 +155,9 @@ export function useLayer({ map, enabled, opacity, locator }) { setLoading(true); try { - const url = `/api/propagation/heatmap?deLat=${deLocation.lat.toFixed(1)}&deLon=${deLocation.lon.toFixed(1)}&freq=${band.freq}&grid=${gridSize}&mode=${propMode}&power=${propPower}`; + // Round to whole degrees — propagation doesn't differ within 1°, + // and identical URLs share server + browser + CDN caches + const url = `/api/propagation/heatmap?deLat=${Math.round(deLocation.lat)}&deLon=${Math.round(deLocation.lon)}&freq=${band.freq}&grid=${gridSize}&mode=${propMode}&power=${propPower}`; const res = await fetch(url); if (res.ok) { const json = await res.json(); From 9ceb6fe2447836fec134a529e5758ed7e91e0e2b Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 23 Mar 2026 21:35:12 -0400 Subject: [PATCH 2/4] fix: DX spots use operating location, not operator home QTH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HamQTH returns the operator's HOME location (e.g. Greek QTH for XX9W operator), not where they're currently operating (Macau). This caused DXpedition callsigns to appear at the wrong location. Reordered DX location resolution: 1. Grid from spot data (most precise) 2. Grid from comment 3. DXpedition list cross-reference (all, not just isActive) 4. Prefix/CTY.DAT (operating location from callsign prefix) 5. HamQTH (last resort — home QTH) Previously HamQTH was at step 4 and prefix at step 5, so HamQTH's home location would override the correct operating location for every DXpedition without a grid square. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routes/dxcluster.js | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/server/routes/dxcluster.js b/server/routes/dxcluster.js index 68782948..98b2ff57 100644 --- a/server/routes/dxcluster.js +++ b/server/routes/dxcluster.js @@ -48,7 +48,9 @@ module.exports = function (app, ctx) { const cache = ctx.dxpeditionCache; if (!cache?.data?.dxpeditions) return null; const upper = (call || '').toUpperCase(); - const dxped = cache.data.dxpeditions.find((d) => d.isActive && d.callsign?.toUpperCase() === upper); + // Check ALL DXpeditions (active + upcoming) — NG3K date parsing isn't + // always accurate, and a DXpedition being spotted means it IS active + const dxped = cache.data.dxpeditions.find((d) => d.callsign?.toUpperCase() === upper); if (!dxped || !dxped.entity) return null; // Look up the DXpedition entity in cty.dat's entity list @@ -1679,7 +1681,7 @@ module.exports = function (app, ctx) { } } - // Check if this callsign is a known active DXpedition — use entity coordinates + // Check if this callsign is a known DXpedition — use entity coordinates if (!dxLoc) { const dxpedLoc = lookupDXpeditionLocation(spot.dxCall); if (dxpedLoc) { @@ -1687,19 +1689,10 @@ module.exports = function (app, ctx) { } } - // Fall back to HamQTH cached location (more accurate than prefix) - // HamQTH uses home callsign — but for portable ops, prefix location wins - if (!dxLoc && hamqthLocations[baseCallMap[spot.dxCall] || spot.dxCall]) { - // Only use HamQTH location if there's no operating prefix override - // (i.e. the call is not a compound prefix/callsign like PJ2/W9WI) - const opPrefix = prefixCallMap[spot.dxCall]; - const homeCall = baseCallMap[spot.dxCall]; - if (!opPrefix || opPrefix === homeCall) { - dxLoc = hamqthLocations[homeCall || spot.dxCall]; - } - } - - // Fall back to prefix location (now includes grid-based coordinates!) + // Prefix/CTY.DAT location — shows where the station IS OPERATING, + // which is what matters for the map. Must run before HamQTH which + // returns the operator's HOME location (e.g. XX9W operator lives in + // Greece but is operating from Macau). if (!dxLoc) { dxLoc = prefixLocations[prefixCallMap[spot.dxCall] || spot.dxCall]; if (dxLoc && dxLoc.grid) { @@ -1707,6 +1700,15 @@ module.exports = function (app, ctx) { } } + // HamQTH cached location — only used as last resort for DX station, + // since it returns the operator's home QTH, not the operating location. + // Only use for compound calls where prefix resolution already ran + // (e.g. PJ2/W9WI where prefix gave PJ2 location). + if (!dxLoc && hamqthLocations[baseCallMap[spot.dxCall] || spot.dxCall]) { + const homeCall = baseCallMap[spot.dxCall]; + dxLoc = hamqthLocations[homeCall || spot.dxCall]; + } + // Spotter location - try grid first, then prefix let spotterLoc = null; let spotterGridSquare = null; From 2d02a95d82fefa0e9926c1d6baf7310c3e5038cf Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 23 Mar 2026 21:40:08 -0400 Subject: [PATCH 3/4] perf: propagation chart uses shared solar cache, no more NOAA hammering The /api/propagation endpoint was fetching solar flux and K-index from NOAA on every uncached request (no timeout). With many users clicking different DX targets, NOAA requests piled up, responses hung, and the chart intermittently failed to load. Now uses the same getSolarData() 15-minute cache as the heatmap. Solar data is fetched once and shared across all propagation endpoints. Eliminates redundant NOAA round-trips. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routes/propagation.js | 57 ++++++++---------------------------- 1 file changed, 13 insertions(+), 44 deletions(-) diff --git a/server/routes/propagation.js b/server/routes/propagation.js index ccc0dd2d..97d5e13c 100644 --- a/server/routes/propagation.js +++ b/server/routes/propagation.js @@ -196,44 +196,13 @@ module.exports = function (app, ctx) { ); try { - // Get current space weather data - let sfi = 150, - ssn = 100, - kIndex = 2, - aIndex = 10; - - try { - // Prefer SWPC summary (updates every few hours) + N0NBH for SSN - const [summaryRes, kRes] = await Promise.allSettled([ - fetch('https://services.swpc.noaa.gov/products/summary/10cm-flux.json'), - fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'), - ]); - - if (summaryRes.status === 'fulfilled' && summaryRes.value.ok) { - try { - const summary = await summaryRes.value.json(); - const flux = parseInt(summary?.Flux); - if (flux > 0) sfi = flux; - } catch {} - } - // Fallback: N0NBH cache (daily, same as hamqsl.com) - if (sfi === 150 && n0nbhCache.data?.solarData?.solarFlux) { - const flux = parseInt(n0nbhCache.data.solarData.solarFlux); - if (flux > 0) sfi = flux; - } - // SSN: prefer N0NBH (daily), then estimate from SFI - if (n0nbhCache.data?.solarData?.sunspots) { - const s = parseInt(n0nbhCache.data.solarData.sunspots); - if (s >= 0) ssn = s; - } else { - ssn = Math.max(0, Math.round((sfi - 67) / 0.97)); - } - if (kRes.status === 'fulfilled' && kRes.value.ok) { - const data = await kRes.value.json(); - if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2; - } - } catch (e) { - logDebug('[Propagation] Using default solar values'); + // Solar data — uses shared 15-minute cache (same as heatmap) + const { sfi, ssn, kIndex } = await getSolarData(); + // Also check N0NBH for more accurate SSN if available + let effectiveSSN = ssn; + if (n0nbhCache.data?.solarData?.sunspots) { + const s = parseInt(n0nbhCache.data.solarData.sunspots); + if (s >= 0) effectiveSSN = s; } // Calculate path geometry @@ -254,7 +223,7 @@ module.exports = function (app, ctx) { const currentMonth = new Date().getMonth() + 1; logDebug('[Propagation] Distance:', Math.round(distance), 'km'); - logDebug('[Propagation] Solar: SFI', sfi, 'SSN', ssn, 'K', kIndex); + logDebug('[Propagation] Solar: SFI', sfi, 'SSN', effectiveSSN, 'K', kIndex); const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m']; const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 28]; @@ -285,7 +254,7 @@ module.exports = function (app, ctx) { de.lon, dx.lat, dx.lon, - ssn, + effectiveSSN, currentMonth, txPower, txGain, @@ -351,7 +320,7 @@ module.exports = function (app, ctx) { de.lon, dx.lat, dx.lon, - ssn, + effectiveSSN, currentMonth, currentHour, txPower, @@ -396,7 +365,7 @@ module.exports = function (app, ctx) { midLon, hour, sfi, - ssn, + effectiveSSN, kIndex, de, dx, @@ -425,12 +394,12 @@ module.exports = function (app, ctx) { } // Calculate MUF and LUF - const currentMuf = iturhfpropMuf || calculateMUF(distance, midLat, midLon, currentHour, sfi, ssn); + const currentMuf = iturhfpropMuf || calculateMUF(distance, midLat, midLon, currentHour, sfi, effectiveSSN); const currentLuf = calculateLUF(distance, midLat, midLon, currentHour, sfi, kIndex); res.json({ model: usedITURHFProp ? 'ITU-R P.533-14' : 'Built-in estimation', - solarData: { sfi, ssn, kIndex }, + solarData: { sfi, ssn: effectiveSSN, kIndex }, muf: Math.round(currentMuf * 10) / 10, luf: Math.round(currentLuf * 10) / 10, distance: Math.round(distance), From 21c300f99ff4b1b4514950cc1321c67e598224ca Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 23 Mar 2026 21:45:04 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20propagation=20chart=20stuck=20loadin?= =?UTF-8?q?g=20=E2=80=94=20ITURHFProp=20timeout=20+=20backoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITURHFProp service (proppy-production.up.railway.app) had a 90s timeout on the hourly endpoint and 15s on the single-hour endpoint. When the service was down, every DX click hung for 105 seconds before falling back to built-in calculations. - Hourly timeout: 90s → 10s - Single-hour timeout: 15s → 8s - Added 2-minute negative cache: if ITURHFProp fails, skip it for 2 minutes instead of retrying every request - Built-in model kicks in immediately on failure Co-Authored-By: Claude Opus 4.6 (1M context) --- server/routes/propagation.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/server/routes/propagation.js b/server/routes/propagation.js index 97d5e13c..d35ae5dd 100644 --- a/server/routes/propagation.js +++ b/server/routes/propagation.js @@ -39,11 +39,17 @@ module.exports = function (app, ctx) { maxAge: 5 * 60 * 1000, // 5 minutes }; + // Negative cache: if ITURHFProp fails, don't retry for 2 minutes + // Prevents 90s+ hangs on every DX click when the service is down + let iturhfpropDown = 0; + const ITURHFPROP_BACKOFF = 2 * 60 * 1000; // 2 minutes + /** * Fetch base prediction from ITURHFProp service */ async function fetchITURHFPropPrediction(txLat, txLon, rxLat, rxLon, ssn, month, hour, txPower, txGain) { if (!ITURHFPROP_URL) return null; + if (Date.now() - iturhfpropDown < ITURHFPROP_BACKOFF) return null; const pw = Math.round(txPower || 100); const gn = Math.round((txGain || 0) * 10) / 10; @@ -60,7 +66,7 @@ module.exports = function (app, ctx) { // Create abort controller for timeout const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout + const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s — fail fast const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); @@ -83,6 +89,7 @@ module.exports = function (app, ctx) { return data; } catch (err) { + iturhfpropDown = Date.now(); if (err.name !== 'AbortError') { logErrorOnce('Hybrid', `ITURHFProp: ${err.message}`); } @@ -104,6 +111,7 @@ module.exports = function (app, ctx) { async function fetchITURHFPropHourly(txLat, txLon, rxLat, rxLon, ssn, month, txPower, txGain) { if (!ITURHFPROP_URL) return null; + if (Date.now() - iturhfpropDown < ITURHFPROP_BACKOFF) return null; // service recently failed const pw = Math.round(txPower || 100); const gn = Math.round((txGain || 0) * 10) / 10; @@ -121,7 +129,7 @@ module.exports = function (app, ctx) { const url = `${ITURHFPROP_URL}/api/predict/hourly?txLat=${txLat}&txLon=${txLon}&rxLat=${rxLat}&rxLon=${rxLon}&ssn=${ssn}&month=${month}&txPower=${pw}&txGain=${gn}`; const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 90000); // 90s for 24-hour calc + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s — fail fast, fallback to built-in const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); @@ -138,7 +146,10 @@ module.exports = function (app, ctx) { return data; } catch (err) { - if (err.name !== 'AbortError') { + iturhfpropDown = Date.now(); // back off for 2 minutes + if (err.name === 'AbortError') { + logErrorOnce('ITURHFProp', 'Hourly fetch timed out — using built-in model'); + } else { logErrorOnce('ITURHFProp', `Hourly fetch: ${err.message}`); } return null;