Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
32 changes: 17 additions & 15 deletions server/routes/dxcluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1679,34 +1681,34 @@ 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) {
dxLoc = dxpedLoc;
}
}

// 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) {
dxGridSquare = dxLoc.grid;
}
}

// 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;
Expand Down
82 changes: 32 additions & 50 deletions server/routes/propagation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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}`);
}
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -196,44 +207,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
Expand All @@ -254,7 +234,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];

Expand Down Expand Up @@ -285,7 +265,7 @@ module.exports = function (app, ctx) {
de.lon,
dx.lat,
dx.lon,
ssn,
effectiveSSN,
currentMonth,
txPower,
txGain,
Expand Down Expand Up @@ -351,7 +331,7 @@ module.exports = function (app, ctx) {
de.lon,
dx.lat,
dx.lon,
ssn,
effectiveSSN,
currentMonth,
currentHour,
txPower,
Expand Down Expand Up @@ -396,7 +376,7 @@ module.exports = function (app, ctx) {
midLon,
hour,
sfi,
ssn,
effectiveSSN,
kIndex,
de,
dx,
Expand Down Expand Up @@ -425,12 +405,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),
Expand Down Expand Up @@ -496,7 +476,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
Expand Down Expand Up @@ -531,8 +511,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();
Expand Down
4 changes: 3 additions & 1 deletion src/plugins/layers/useVOACAPHeatmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading