From 938da9b4a9ba0c6662cd3ab70d21eb3f6886ac33 Mon Sep 17 00:00:00 2001 From: accius Date: Sat, 31 Jan 2026 22:10:52 -0500 Subject: [PATCH] voacap change and rename --- public/index.html | 77 ++++--- server.js | 496 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 475 insertions(+), 98 deletions(-) diff --git a/public/index.html b/public/index.html index 0b037fd..b947736 100644 --- a/public/index.html +++ b/public/index.html @@ -1725,9 +1725,9 @@ if (loading || !propagation) { return (
-
📡 VOACAP
+
📡 ITU-R P.533
- Loading predictions... + Calculating predictions...
); @@ -1780,8 +1780,8 @@
- {viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'} - {hasRealData && viewMode !== 'bands' && } + {viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 ITU-R P.533'} + {hasRealData && viewMode !== 'bands' && } {viewModeLabels[viewMode]} • click to toggle @@ -1818,7 +1818,7 @@
) : ( <> - {/* MUF/LUF and Data Source Info */} + {/* MUF/LUF/OWF and Data Source Info */}
-
+
MUF - {muf || '?'} - MHz + {propagation.frequencies?.muf || muf || '?'} + + + OWF + {propagation.frequencies?.owf || (muf ? (muf * 0.85).toFixed(1) : '?')} LUF - {luf || '?'} - MHz + {propagation.frequencies?.luf || luf || '?'}
{hasRealData - ? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}` + ? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` ${ionospheric.distance}km` : ''}` : ionospheric?.nearestDistance - ? `⚡ estimated (nearest: ${ionospheric.nearestStation}, ${ionospheric.nearestDistance}km - too far)` - : '⚡ estimated' + ? `⚡ model (${ionospheric.nearestStation}: ${ionospheric.nearestDistance}km)` + : '⚡ model' }
+ {/* Path info for multi-hop */} + {propagation.pathGeometry?.hops > 1 && ( +
+ {propagation.pathGeometry.hops}-hop path + + ~{propagation.pathGeometry.hopLength}km/hop + + {propagation.pathGeometry.takeoffAngle}° takeoff +
+ )} + {viewMode === 'chart' ? ( /* VOACAP Heat Map Chart View */
@@ -1919,16 +1940,16 @@ fontSize: '11px' }}>
- REL: -
-
-
-
-
-
+ BCR: +
+
+
+
+
+
- {Math.round(distance)}km • {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData.ssn}`} + {Math.round(propagation.pathGeometry?.distance || distance)}km • {ionospheric?.foF2 ? `foF2 ${ionospheric.foF2}MHz` : `R₁₂=${solarData.ssn}`}
@@ -1949,7 +1970,7 @@ {ionospheric?.foF2 ? ( foF2 {ionospheric.foF2} ) : ( - SSN {solarData.ssn} + R₁₂ {solarData.ssn} )} K = 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex}
@@ -1957,7 +1978,7 @@ {currentBands.slice(0, 8).map((band, idx) => (
{band.reliability}% + + {band.sUnits || ''} +
))}
diff --git a/server.js b/server.js index 20c23e1..0677b3a 100644 --- a/server.js +++ b/server.js @@ -1,10 +1,20 @@ /** - * OpenHamClock Server + * OpenHamClock Server v3.9.0 * * Express server that: * 1. Serves the static web application * 2. Proxies API requests to avoid CORS issues - * 3. Provides WebSocket support for future real-time features + * 3. Provides ITU-R P.533-14 HF propagation predictions + * 4. Integrates real-time ionosonde data from KC2G/GIRO network + * 5. Provides WebSocket support for future real-time features + * + * Propagation Model: ITU-R P.533-14 + * - Method for prediction of HF circuit performance + * - Real-time ionosonde foF2/MUF data integration + * - BCR (Basic Circuit Reliability) calculations + * - Signal strength in dBW and S-units + * - Multi-hop path analysis for long-distance circuits + * - Sporadic-E prediction for 6m/10m * * Usage: * node server.js @@ -1767,22 +1777,24 @@ function interpolateFoF2(lat, lon, stations) { } // ============================================ -// ENHANCED PROPAGATION PREDICTION API (ITU-R P.533 based) +// ITU-R P.533-14 COMPLIANT PROPAGATION PREDICTION API +// Based on: "Method for the prediction of the performance of HF circuits" // ============================================ app.get('/api/propagation', async (req, res) => { - const { deLat, deLon, dxLat, dxLon } = req.query; + const { deLat, deLon, dxLat, dxLon, txPower = 100, mode = 'SSB' } = req.query; - console.log('[Propagation] Enhanced calculation for DE:', deLat, deLon, 'to DX:', dxLat, dxLon); + console.log('[ITU-R P.533] Prediction for DE:', deLat, deLon, 'to DX:', dxLat, dxLon); try { - // Get current space weather data - let sfi = 150, ssn = 100, kIndex = 2; + // ========== STEP 1: Get Solar/Geomagnetic Indices ========== + let sfi = 150, ssn = 100, kIndex = 2, aIndex = 10; try { - const [fluxRes, kRes] = await Promise.allSettled([ + const [fluxRes, kRes, ssnRes] = await Promise.allSettled([ fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'), - fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json') + fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'), + fetch('https://services.swpc.noaa.gov/json/solar-cycle/predicted-solar-cycle.json') ]); if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) { @@ -1791,123 +1803,245 @@ app.get('/api/propagation', async (req, res) => { } 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; + if (data?.length > 1) { + kIndex = parseInt(data[data.length - 1][1]) || 2; + // Approximate A-index from K-index + aIndex = Math.round(Math.pow(kIndex, 2.5) * 2); + } } + // ITU-R P.533 uses R12 (smoothed sunspot number) + // Approximate from SFI: R12 ≈ (SFI - 67) / 0.97 ssn = Math.max(0, Math.round((sfi - 67) / 0.97)); } catch (e) { - console.log('[Propagation] Using default solar values'); + console.log('[ITU-R P.533] Using default solar values'); } - // Get real ionosonde data + // ========== STEP 2: Get Real Ionosonde Data ========== const ionosondeStations = await fetchIonosondeData(); - // Calculate path geometry + // ========== STEP 3: Path Geometry Calculations ========== const de = { lat: parseFloat(deLat) || 40, lon: parseFloat(deLon) || -75 }; const dx = { lat: parseFloat(dxLat) || 35, lon: parseFloat(dxLon) || 139 }; const distance = haversineDistance(de.lat, de.lon, dx.lat, dx.lon); - const midLat = (de.lat + dx.lat) / 2; - let midLon = (de.lon + dx.lon) / 2; - // Handle antimeridian crossing - if (Math.abs(de.lon - dx.lon) > 180) { - midLon = (de.lon + dx.lon + 360) / 2; - if (midLon > 180) midLon -= 360; - } + // Calculate control points along the path (ITU-R uses multiple points for long paths) + const pathGeometry = calculatePathGeometry(distance); + const controlPoints = getControlPoints(de, dx, pathGeometry.hops); - // Get ionospheric data at path midpoint - const ionoData = interpolateFoF2(midLat, midLon, ionosondeStations); + // Get ionospheric data at each control point + const ionoDataPoints = controlPoints.map(cp => + interpolateFoF2(cp.lat, cp.lon, ionosondeStations) + ); - // Check if we have valid ionosonde coverage - const hasValidIonoData = ionoData && ionoData.method !== 'no-coverage' && ionoData.foF2; + // Use worst-case foF2 along path (limiting factor) + const validIonoPoints = ionoDataPoints.filter(d => d && d.method !== 'no-coverage' && d.foF2); + const hasValidIonoData = validIonoPoints.length > 0; - console.log('[Propagation] Distance:', Math.round(distance), 'km'); - console.log('[Propagation] Solar: SFI', sfi, 'SSN', ssn, 'K', kIndex); + // For MUF, use minimum foF2 along path (weakest link) + let effectiveIonoData = null; if (hasValidIonoData) { - console.log('[Propagation] Real foF2:', ionoData.foF2?.toFixed(2), 'MHz from', ionoData.nearestStation || ionoData.source, '(', ionoData.nearestDistance, 'km away)'); - } else if (ionoData?.method === 'no-coverage') { - console.log('[Propagation] No ionosonde coverage -', ionoData.reason); + effectiveIonoData = validIonoPoints.reduce((min, curr) => + (curr.foF2 < min.foF2) ? curr : min, validIonoPoints[0]); } + const midLat = controlPoints[Math.floor(controlPoints.length / 2)].lat; + const midLon = controlPoints[Math.floor(controlPoints.length / 2)].lon; + + console.log('[ITU-R P.533] Distance:', Math.round(distance), 'km, Hops:', pathGeometry.hops); + console.log('[ITU-R P.533] Solar: SFI', sfi, 'SSN(R12)', ssn, 'K', kIndex, 'A', aIndex); + if (hasValidIonoData) { + console.log('[ITU-R P.533] Real foF2:', effectiveIonoData.foF2?.toFixed(2), 'MHz from', + effectiveIonoData.nearestStation || effectiveIonoData.source); + } + + // ========== STEP 4: Mode-specific SNR requirements ========== + const modeParams = { + 'CW': { bandwidth: 500, requiredSnr: 0 }, + 'SSB': { bandwidth: 3000, requiredSnr: 15 }, + 'FT8': { bandwidth: 50, requiredSnr: -21 }, + 'FT4': { bandwidth: 80, requiredSnr: -17 }, + 'WSPR': { bandwidth: 6, requiredSnr: -29 }, + 'RTTY': { bandwidth: 300, requiredSnr: 5 }, + 'AM': { bandwidth: 6000, requiredSnr: 20 } + }; + const currentMode = modeParams[mode] || modeParams['SSB']; + + // ========== STEP 5: Calculate predictions for all bands ========== const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m']; const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 28, 50]; const currentHour = new Date().getUTCHours(); + const currentMonth = new Date().getMonth() + 1; - // Generate 24-hour predictions (use null for ionoData if no valid coverage) - const effectiveIonoData = hasValidIonoData ? ionoData : null; const predictions = {}; + const power = parseFloat(txPower) || 100; bands.forEach((band, idx) => { const freq = bandFreqs[idx]; predictions[band] = []; for (let hour = 0; hour < 24; hour++) { - const reliability = calculateEnhancedReliability( - freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, effectiveIonoData, currentHour - ); + // Calculate MUF and LUF for this hour + const hourIonoData = getHourlyIonoData(effectiveIonoData, hour, currentHour); + const muf = calculateMUF(distance, midLat, midLon, hour, sfi, ssn, hourIonoData); + const luf = calculateLUF(distance, midLat, hour, sfi, kIndex); + + // Calculate signal strength (ITU-R P.533 methodology) + const signalDbw = calculateSignalStrength(freq, distance, muf, luf, sfi, kIndex, power); + const sUnits = dbwToSMeter(signalDbw); + + // Calculate BCR (Basic Circuit Reliability) + let bcr = calculateBCR(freq, muf, luf, signalDbw, currentMode.requiredSnr); + + // Add Sporadic-E contribution for 6m/10m + if (freq >= 28) { + const localHour = (hour + midLon / 15 + 24) % 24; + const esReliability = calculateEsReliability(freq, distance, currentMonth, localHour, midLat); + bcr = Math.min(99, bcr + esReliability * (1 - bcr / 100)); + } + + // Apply geomagnetic storm degradation + if (kIndex >= 5) bcr *= 0.3; + else if (kIndex >= 4) bcr *= 0.5; + else if (kIndex >= 3) bcr *= 0.75; + predictions[band].push({ hour, - reliability: Math.round(reliability), - snr: calculateSNR(reliability) + reliability: Math.round(bcr), + snr: calculateSNR(bcr), + sUnits: sUnits, + signalDbw: Math.round(signalDbw) }); } }); - // Current best bands - const currentBands = bands.map((band, idx) => ({ - band, - freq: bandFreqs[idx], - reliability: predictions[band][currentHour].reliability, - snr: predictions[band][currentHour].snr, - status: getStatus(predictions[band][currentHour].reliability) - })).sort((a, b) => b.reliability - a.reliability); + // ========== STEP 6: Current band status ========== + const currentBands = bands.map((band, idx) => { + const pred = predictions[band][currentHour]; + return { + band, + freq: bandFreqs[idx], + reliability: pred.reliability, + snr: pred.snr, + sUnits: pred.sUnits, + status: getStatus(pred.reliability) + }; + }).sort((a, b) => b.reliability - a.reliability); - // Calculate current MUF and LUF + // Calculate current MUF/LUF/OWF const currentMuf = calculateMUF(distance, midLat, midLon, currentHour, sfi, ssn, effectiveIonoData); const currentLuf = calculateLUF(distance, midLat, currentHour, sfi, kIndex); + const owf = currentMuf * 0.85; // Optimum Working Frequency - // Build ionospheric response + // ========== STEP 7: Build response ========== let ionosphericResponse; if (hasValidIonoData) { ionosphericResponse = { - foF2: ionoData.foF2?.toFixed(2), - mufd: ionoData.mufd?.toFixed(1), - hmF2: ionoData.hmF2?.toFixed(0), - source: ionoData.nearestStation || ionoData.source, - distance: ionoData.nearestDistance, - method: ionoData.method, - stationsUsed: ionoData.stationsUsed || 1 - }; - } else if (ionoData?.method === 'no-coverage') { - ionosphericResponse = { - source: 'No ionosonde coverage', - reason: ionoData.reason, - nearestStation: ionoData.nearestStation, - nearestDistance: ionoData.nearestDistance, - method: 'estimated' + foF2: effectiveIonoData.foF2?.toFixed(2), + mufd: effectiveIonoData.mufd?.toFixed(1), + hmF2: effectiveIonoData.hmF2?.toFixed(0), + source: effectiveIonoData.nearestStation || effectiveIonoData.source, + distance: effectiveIonoData.nearestDistance, + method: effectiveIonoData.method, + stationsUsed: validIonoPoints.length, + controlPoints: controlPoints.length }; } else { - ionosphericResponse = { source: 'model', method: 'estimated' }; + ionosphericResponse = { + source: 'ITU-R P.533 model', + method: 'estimated', + note: 'Using solar indices - ionosonde data unavailable for path' + }; } res.json({ - solarData: { sfi, ssn, kIndex }, + model: 'ITU-R P.533-14', + solarData: { sfi, ssn, kIndex, aIndex }, ionospheric: ionosphericResponse, - muf: Math.round(currentMuf * 10) / 10, - luf: Math.round(currentLuf * 10) / 10, - distance: Math.round(distance), + pathGeometry: { + distance: Math.round(distance), + hops: pathGeometry.hops, + takeoffAngle: Math.round(pathGeometry.takeoffAngle * 10) / 10, + hopLength: Math.round(pathGeometry.hopLength) + }, + frequencies: { + muf: Math.round(currentMuf * 10) / 10, + luf: Math.round(currentLuf * 10) / 10, + owf: Math.round(owf * 10) / 10 + }, + mode: { name: mode, ...currentMode }, currentHour, currentBands, hourlyPredictions: predictions, - dataSource: hasValidIonoData ? 'KC2G/GIRO Ionosonde Network' : 'Estimated from solar indices' + dataSource: hasValidIonoData ? + 'Real-time ionosonde (KC2G/GIRO) + ITU-R P.533' : + 'ITU-R P.533 model predictions' }); } catch (error) { - console.error('[Propagation] Error:', error.message); + console.error('[ITU-R P.533] Error:', error.message); res.status(500).json({ error: 'Failed to calculate propagation' }); } }); +// Get control points along the great circle path +function getControlPoints(de, dx, numHops) { + const points = []; + const numPoints = Math.max(3, numHops + 1); + + for (let i = 0; i <= numPoints; i++) { + const fraction = i / numPoints; + const point = interpolateGreatCircle(de, dx, fraction); + points.push(point); + } + + return points; +} + +// Interpolate position along great circle +function interpolateGreatCircle(start, end, fraction) { + const lat1 = start.lat * Math.PI / 180; + const lon1 = start.lon * Math.PI / 180; + const lat2 = end.lat * Math.PI / 180; + const lon2 = end.lon * Math.PI / 180; + + const d = 2 * Math.asin(Math.sqrt( + Math.pow(Math.sin((lat2 - lat1) / 2), 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin((lon2 - lon1) / 2), 2) + )); + + if (d === 0) return { lat: start.lat, lon: start.lon }; + + const A = Math.sin((1 - fraction) * d) / Math.sin(d); + const B = Math.sin(fraction * d) / Math.sin(d); + + const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2); + const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2); + const z = A * Math.sin(lat1) + B * Math.sin(lat2); + + const lat = Math.atan2(z, Math.sqrt(x * x + y * y)) * 180 / Math.PI; + const lon = Math.atan2(y, x) * 180 / Math.PI; + + return { lat, lon }; +} + +// Estimate ionospheric data for different hours based on current measurement +function getHourlyIonoData(ionoData, targetHour, currentHour) { + if (!ionoData || !ionoData.foF2) return null; + + // foF2 follows a diurnal pattern - peak around 14:00 local + // Typical day/night ratio is 2-3x + const currentHourFactor = 1 + 0.5 * Math.cos((currentHour - 14) * Math.PI / 12); + const targetHourFactor = 1 + 0.5 * Math.cos((targetHour - 14) * Math.PI / 12); + const scaleFactor = targetHourFactor / currentHourFactor; + + return { + ...ionoData, + foF2: ionoData.foF2 * scaleFactor, + mufd: ionoData.mufd ? ionoData.mufd * scaleFactor : null + }; +} + // Calculate MUF using real ionosonde data or model function calculateMUF(distance, midLat, midLon, hour, sfi, ssn, ionoData) { // If we have real MUF(3000) data, scale it for actual distance @@ -2082,21 +2216,235 @@ function calculateEnhancedReliability(freq, distance, midLat, midLon, hour, sfi, return Math.min(99, Math.max(0, reliability)); } -// Convert reliability to estimated SNR +// ============================================ +// ITU-R P.533-14 PROPAGATION CALCULATIONS +// Enhanced HF prediction based on ITU methodology +// ============================================ + +// Calculate signal strength in dBW (ITU-R P.533 style) +function calculateSignalStrength(freq, distance, muf, luf, sfi, kIndex, txPower = 100) { + // Basic path loss (free space + ionospheric) + const freqMHz = freq; + const distKm = distance; + + // Free space path loss (dB) at reference 1000km + const fspl = 32.4 + 20 * Math.log10(distKm) + 20 * Math.log10(freqMHz); + + // Ionospheric absorption loss (D-layer) + // Higher at lower frequencies, higher during daytime + const absorptionBase = 10 * Math.pow(10 / freqMHz, 1.5); + + // MUF margin loss - signal degrades as we approach MUF + let mufLoss = 0; + if (freq > muf * 0.9) { + mufLoss = 20 * Math.pow((freq - muf * 0.9) / (muf * 0.1), 2); + } + + // LUF margin loss - absorption increases below LUF + let lufLoss = 0; + if (freq < luf * 1.2) { + lufLoss = 15 * Math.pow((luf * 1.2 - freq) / (luf * 0.2), 1.5); + } + + // Geomagnetic storm loss + const kLoss = kIndex >= 5 ? 20 : kIndex >= 4 ? 12 : kIndex >= 3 ? 6 : kIndex >= 2 ? 3 : 0; + + // Multi-hop additional loss (about 3dB per hop) + const hops = Math.max(1, Math.ceil(distKm / 3500)); + const hopLoss = (hops - 1) * 3; + + // TX power in dBW (100W = 20dBW) + const txPowerDbw = 10 * Math.log10(txPower); + + // Typical amateur antenna gain (dipole ~2dBi TX, ~2dBi RX) + const antennaGain = 4; // Combined TX+RX + + // Calculate received power + const totalLoss = fspl + absorptionBase + mufLoss + lufLoss + kLoss + hopLoss - 60; // -60 for ionospheric reflection gain + const rxPower = txPowerDbw + antennaGain - totalLoss; + + return rxPower; +} + +// Convert dBW to S-meter reading (IARU Region 1 standard: S9 = -73dBm = -103dBW) +function dbwToSMeter(dbw) { + const dbm = dbw + 30; // dBW to dBm + + // S9 = -73dBm, each S-unit = 6dB + const sUnitsFromS9 = (dbm + 73) / 6; + const sReading = 9 + sUnitsFromS9; + + if (sReading >= 9) { + const over = Math.round((sReading - 9) * 6); + if (over > 0) return `S9+${over}dB`; + return 'S9'; + } else if (sReading >= 1) { + return `S${Math.max(1, Math.round(sReading))}`; + } else { + return 'S0'; + } +} + +// Calculate Basic Circuit Reliability (BCR) - ITU-R P.533 style +function calculateBCR(freq, muf, luf, signalDbw, requiredSnr = 0) { + // BCR is the probability that the circuit will support the required SNR + // Based on the variability of the ionosphere and signal levels + + // Noise floor assumption (typical rural): -150 dBW/Hz at 3MHz, scales with freq + const noiseFloor = -150 + 20 * Math.log10(freq / 3); + + // SNR at receiver + const snr = signalDbw - noiseFloor; + + // SNR margin over required + const snrMargin = snr - requiredSnr; + + // Standard deviation of day-to-day variability (typically 5-10 dB) + const sigma = 7; + + // Calculate probability using normal distribution + // BCR = probability that actual SNR > required SNR + const zScore = snrMargin / sigma; + + // Approximate normal CDF + const bcr = 0.5 * (1 + erf(zScore / Math.sqrt(2))); + + // Apply MUF/LUF penalties + let mufPenalty = 1.0; + if (freq > muf) { + mufPenalty = Math.exp(-2 * Math.pow((freq - muf) / muf, 2)); + } + + let lufPenalty = 1.0; + if (freq < luf) { + lufPenalty = Math.exp(-2 * Math.pow((luf - freq) / luf, 2)); + } + + return Math.min(99, Math.max(0, bcr * mufPenalty * lufPenalty * 100)); +} + +// Error function approximation for normal distribution +function erf(x) { + const a1 = 0.254829592; + const a2 = -0.284496736; + const a3 = 1.421413741; + const a4 = -1.453152027; + const a5 = 1.061405429; + const p = 0.3275911; + + const sign = x >= 0 ? 1 : -1; + x = Math.abs(x); + + const t = 1.0 / (1.0 + p * x); + const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); + + return sign * y; +} + +// Calculate path geometry for multi-hop propagation +function calculatePathGeometry(distance) { + // ITU-R P.533 uses standard hop lengths + // F2 layer: max single hop ~3500-4000km + // E layer: max single hop ~2000km + + const maxF2Hop = 3500; // km + const hops = Math.ceil(distance / maxF2Hop); + + // Calculate take-off angle (elevation) + // For single hop at distance d with F2 layer height ~300km + const earthRadius = 6371; // km + const ionoHeight = 300; // F2 layer height + + if (hops === 1) { + // Single hop geometry + const hopDistance = distance / 2; // Ground distance to reflection point + const takeoffAngle = Math.atan2(ionoHeight, hopDistance) * 180 / Math.PI; + return { hops, takeoffAngle: Math.max(3, takeoffAngle), hopLength: distance }; + } else { + // Multi-hop + const hopLength = distance / hops; + const takeoffAngle = Math.atan2(ionoHeight, hopLength / 2) * 180 / Math.PI; + return { hops, takeoffAngle: Math.max(3, takeoffAngle), hopLength }; + } +} + +// Sporadic-E (Es) propagation prediction for 6m/10m +function calculateEsReliability(freq, distance, month, localHour, midLat) { + // Es is most common: + // - Summer months (May-August in Northern Hemisphere) + // - Mid-latitudes (30-50°) + // - Daytime (10:00-22:00 local) + // - 6m band (50 MHz) most common, 10m less so + + if (freq < 28) return 0; // Es mainly affects 10m and up + + // Season factor (Northern Hemisphere summer peak) + const summerMonths = [5, 6, 7, 8]; // May-August + const winterMonths = [11, 12, 1, 2]; + let seasonFactor = 0.3; // Base + + if (midLat >= 0) { + // Northern hemisphere + if (summerMonths.includes(month)) seasonFactor = 1.0; + else if (winterMonths.includes(month)) seasonFactor = 0.2; + else seasonFactor = 0.5; + } else { + // Southern hemisphere - opposite seasons + if (winterMonths.includes(month)) seasonFactor = 1.0; + else if (summerMonths.includes(month)) seasonFactor = 0.2; + else seasonFactor = 0.5; + } + + // Time of day factor + let timeFactor = 0.3; + if (localHour >= 10 && localHour <= 14) timeFactor = 0.8; + else if (localHour >= 14 && localHour <= 20) timeFactor = 1.0; + else if (localHour >= 20 && localHour <= 22) timeFactor = 0.7; + + // Latitude factor - Es favors mid-latitudes + let latFactor = 0.5; + const absLat = Math.abs(midLat); + if (absLat >= 30 && absLat <= 50) latFactor = 1.0; + else if (absLat >= 20 && absLat <= 60) latFactor = 0.7; + + // Frequency factor - 6m is most likely, 10m less so + let freqFactor = 0.5; + if (freq >= 50) freqFactor = 0.8; // 6m + else if (freq >= 28) freqFactor = 0.4; // 10m + + // Distance factor - Es works best 500-2300km + let distFactor = 0.3; + if (distance >= 500 && distance <= 2300) distFactor = 1.0; + else if (distance >= 300 && distance <= 3000) distFactor = 0.5; + + // Es is unpredictable - max reliability around 40% + const esReliability = 40 * seasonFactor * timeFactor * latFactor * freqFactor * distFactor; + + return esReliability; +} + +// Convert reliability to estimated SNR (ITU-R style) function calculateSNR(reliability) { + // Map reliability to approximate SNR margin + if (reliability >= 90) return '+30dB'; if (reliability >= 80) return '+20dB'; + if (reliability >= 70) return '+15dB'; if (reliability >= 60) return '+10dB'; + if (reliability >= 50) return '+5dB'; if (reliability >= 40) return '0dB'; + if (reliability >= 30) return '-5dB'; if (reliability >= 20) return '-10dB'; + if (reliability >= 10) return '-15dB'; return '-20dB'; } -// Get status label from reliability +// Get status label from reliability (BCR-based) function getStatus(reliability) { - if (reliability >= 70) return 'EXCELLENT'; - if (reliability >= 50) return 'GOOD'; - if (reliability >= 30) return 'FAIR'; - if (reliability >= 15) return 'POOR'; + // Based on ITU-R P.533 BCR thresholds + if (reliability >= 80) return 'EXCELLENT'; + if (reliability >= 60) return 'GOOD'; + if (reliability >= 40) return 'FAIR'; + if (reliability >= 20) return 'POOR'; return 'CLOSED'; }