voacap change and rename

pull/27/head
accius 4 days ago
parent 94609e499b
commit 938da9b4a9

@ -1725,9 +1725,9 @@
if (loading || !propagation) {
return (
<div className="panel">
<div className="panel-header">📡 VOACAP</div>
<div className="panel-header">📡 ITU-R P.533</div>
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
Loading predictions...
Calculating predictions...
</div>
</div>
);
@ -1780,8 +1780,8 @@
<div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'}
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}></span>}
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 ITU-R P.533'}
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }} title="Live ionosonde data"></span>}
</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{viewModeLabels[viewMode]} • click to toggle
@ -1818,7 +1818,7 @@
</div>
) : (
<>
{/* MUF/LUF and Data Source Info */}
{/* MUF/LUF/OWF and Data Source Info */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
@ -1828,28 +1828,49 @@
marginBottom: '4px',
fontSize: '11px'
}}>
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<span>
<span style={{ color: 'var(--text-muted)' }}>MUF </span>
<span style={{ color: '#ff8800', fontWeight: '600' }}>{muf || '?'}</span>
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
<span style={{ color: '#ff8800', fontWeight: '600' }}>{propagation.frequencies?.muf || muf || '?'}</span>
</span>
<span>
<span style={{ color: 'var(--text-muted)' }}>OWF </span>
<span style={{ color: '#00ff88', fontWeight: '600' }}>{propagation.frequencies?.owf || (muf ? (muf * 0.85).toFixed(1) : '?')}</span>
</span>
<span>
<span style={{ color: 'var(--text-muted)' }}>LUF </span>
<span style={{ color: '#00aaff', fontWeight: '600' }}>{luf || '?'}</span>
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
<span style={{ color: '#00aaff', fontWeight: '600' }}>{propagation.frequencies?.luf || luf || '?'}</span>
</span>
</div>
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
{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'
}
</span>
</div>
{/* Path info for multi-hop */}
{propagation.pathGeometry?.hops > 1 && (
<div style={{
display: 'flex',
justifyContent: 'center',
gap: '12px',
padding: '2px 8px',
marginBottom: '4px',
fontSize: '10px',
color: 'var(--text-muted)'
}}>
<span>{propagation.pathGeometry.hops}-hop path</span>
<span></span>
<span>~{propagation.pathGeometry.hopLength}km/hop</span>
<span></span>
<span>{propagation.pathGeometry.takeoffAngle}° takeoff</span>
</div>
)}
{viewMode === 'chart' ? (
/* VOACAP Heat Map Chart View */
<div style={{ padding: '4px' }}>
@ -1919,16 +1940,16 @@
fontSize: '11px'
}}>
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
<span style={{ color: 'var(--text-muted)' }}>REL:</span>
<div style={{ width: '8px', height: '8px', background: '#004400', borderRadius: '1px' }} />
<div style={{ width: '8px', height: '8px', background: '#00aa00', borderRadius: '1px' }} />
<div style={{ width: '8px', height: '8px', background: '#88cc00', borderRadius: '1px' }} />
<div style={{ width: '8px', height: '8px', background: '#ffcc00', borderRadius: '1px' }} />
<div style={{ width: '8px', height: '8px', background: '#ff6600', borderRadius: '1px' }} />
<div style={{ width: '8px', height: '8px', background: '#ff0000', borderRadius: '1px' }} />
<span style={{ color: 'var(--text-muted)' }}>BCR:</span>
<div style={{ width: '8px', height: '8px', background: '#004400', borderRadius: '1px' }} title="0-10%" />
<div style={{ width: '8px', height: '8px', background: '#00aa00', borderRadius: '1px' }} title="10-20%" />
<div style={{ width: '8px', height: '8px', background: '#88cc00', borderRadius: '1px' }} title="20-40%" />
<div style={{ width: '8px', height: '8px', background: '#ffcc00', borderRadius: '1px' }} title="40-60%" />
<div style={{ width: '8px', height: '8px', background: '#ff6600', borderRadius: '1px' }} title="60-80%" />
<div style={{ width: '8px', height: '8px', background: '#ff0000', borderRadius: '1px' }} title="80-100%" />
</div>
<div style={{ color: 'var(--text-muted)' }}>
{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}`}
</div>
</div>
</div>
@ -1949,7 +1970,7 @@
{ionospheric?.foF2 ? (
<span><span style={{ color: 'var(--text-muted)' }}>foF2 </span><span style={{ color: '#00ff88' }}>{ionospheric.foF2}</span></span>
) : (
<span><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData.ssn}</span></span>
<span><span style={{ color: 'var(--text-muted)' }}>R₁₂ </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData.ssn}</span></span>
)}
<span><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: solarData.kIndex >= 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex}</span></span>
</div>
@ -1957,7 +1978,7 @@
{currentBands.slice(0, 8).map((band, idx) => (
<div key={band.band} style={{
display: 'grid',
gridTemplateColumns: '32px 1fr 40px',
gridTemplateColumns: '32px 1fr 28px 32px',
gap: '4px',
padding: '2px 0',
alignItems: 'center'
@ -1982,11 +2003,19 @@
</div>
<span style={{
textAlign: 'right',
fontSize: '12px',
fontSize: '11px',
color: getStatusColor(band.status)
}}>
{band.reliability}%
</span>
<span style={{
textAlign: 'right',
fontSize: '10px',
color: 'var(--text-muted)',
fontFamily: 'JetBrains Mono, monospace'
}}>
{band.sUnits || ''}
</span>
</div>
))}
</div>

@ -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) => ({
// ========== STEP 6: Current band status ==========
const currentBands = bands.map((band, idx) => {
const pred = predictions[band][currentHour];
return {
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);
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
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 if (ionoData?.method === 'no-coverage') {
} else {
ionosphericResponse = {
source: 'No ionosonde coverage',
reason: ionoData.reason,
nearestStation: ionoData.nearestStation,
nearestDistance: ionoData.nearestDistance,
method: 'estimated'
source: 'ITU-R P.533 model',
method: 'estimated',
note: 'Using solar indices - ionosonde data unavailable for path'
};
} else {
ionosphericResponse = { source: 'model', method: 'estimated' };
}
res.json({
solarData: { sfi, ssn, kIndex },
model: 'ITU-R P.533-14',
solarData: { sfi, ssn, kIndex, aIndex },
ionospheric: ionosphericResponse,
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,
distance: Math.round(distance),
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';
}

Loading…
Cancel
Save

Powered by TurnKey Linux.