added ionosonde data

pull/27/head
accius 4 days ago
parent 9987442c7c
commit 5c1f7c2e62

@ -8,11 +8,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Planned
- Satellite tracking with pass predictions
- SOTA API integration
- WebSocket DX cluster connection
- Azimuthal equidistant projection option
## [3.6.0] - 2026-01-31
### Added
- **Real-Time Ionosonde Data Integration** - Enhanced propagation predictions using actual ionospheric measurements
- Fetches real-time foF2, MUF(3000), hmF2 data from KC2G/GIRO ionosonde network (~100 stations worldwide)
- Inverse distance weighted interpolation for path midpoint ionospheric parameters
- 10-minute data cache with automatic refresh
- New `/api/ionosonde` endpoint to access raw station data
### Changed
- **ITU-R P.533-based MUF Calculation** - More accurate Maximum Usable Frequency estimation
- Uses real foF2 and M(3000)F2 values when available
- Distance-scaled MUF calculation for varying path lengths
- Fallback to solar index estimation when ionosonde data unavailable
- **Improved LUF Calculation** - Better Lowest Usable Frequency (D-layer absorption) model
- Accounts for solar zenith angle, solar flux, and geomagnetic activity
- Day/night variation with proper diurnal profile
- **Enhanced Reliability Algorithm** - ITU-R P.533 inspired reliability calculations
- Optimum Working Frequency (OWF) centered predictions
- Multi-hop path loss consideration
- Polar path and auroral absorption penalties
- Low-band nighttime enhancement
### UI Improvements
- Propagation panel shows MUF and LUF values in MHz
- Data source indicator (📡 ionosonde name vs ⚡ estimated)
- Green dot indicator when using real ionosonde data
- foF2 value displayed when available (replaces SSN in bar view)
- Distance now shown in km (not Kkm)
### Technical
- New `fetchIonosondeData()` function with caching
- `interpolateFoF2()` for spatial interpolation of ionospheric parameters
- `calculateMUF()` and `calculateLUF()` helper functions
- `calculateEnhancedReliability()` with proper diurnal scaling
## [3.3.0] - 2026-01-30
### Added

@ -1,6 +1,6 @@
{
"name": "openhamclock",
"version": "3.3.0",
"version": "3.6.0",
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
"main": "server.js",
"scripts": {

@ -1191,7 +1191,10 @@
);
}
const { solarData, distance, currentBands, currentHour, hourlyPredictions } = propagation;
const { solarData, distance, currentBands, currentHour, hourlyPredictions, muf, luf, ionospheric, dataSource } = propagation;
// Check if we have real ionosonde data
const hasRealData = ionospheric?.method === 'direct' || ionospheric?.method === 'interpolated';
// Get reliability color for heat map (VOACAP style - red=good, green=poor)
const getHeatColor = (rel) => {
@ -1228,12 +1231,39 @@
return (
<div className="panel" style={{ cursor: 'pointer' }} onClick={() => setViewMode(v => v === 'bars' ? 'chart' : 'bars')}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>📡 VOACAP DE-DX</span>
<span>📡 HF Propagation {hasRealData && <span style={{ color: '#00ff88', fontSize: '10px' }}></span>}</span>
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
{viewMode === 'bars' ? '▦ bars' : '▤ chart'} • click to toggle
</span>
</div>
{/* MUF/LUF and Data Source Info */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
padding: '4px 8px',
background: hasRealData ? 'rgba(0, 255, 136, 0.1)' : 'var(--bg-tertiary)',
borderRadius: '4px',
marginBottom: '4px',
fontSize: '11px'
}}>
<div style={{ display: 'flex', gap: '12px' }}>
<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>
<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>
</div>
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
{hasRealData ? `📡 ${ionospheric?.source || 'ionosonde'}` : '⚡ estimated'}
</span>
</div>
{viewMode === 'chart' ? (
/* VOACAP Heat Map Chart View */
<div style={{ padding: '4px' }}>
@ -1300,7 +1330,7 @@
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '12px'
fontSize: '11px'
}}>
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
<span style={{ color: 'var(--text-muted)' }}>REL:</span>
@ -1312,7 +1342,7 @@
<div style={{ width: '8px', height: '8px', background: '#ff0000', borderRadius: '1px' }} />
</div>
<div style={{ color: 'var(--text-muted)' }}>
{Math.round(distance/1000)}Kkm, SSN={solarData.ssn}
{Math.round(distance)}km • {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData.ssn}`}
</div>
</div>
</div>
@ -1327,10 +1357,14 @@
marginBottom: '4px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontSize: '12px'
fontSize: '11px'
}}>
<span><span style={{ color: 'var(--text-muted)' }}>SFI </span><span style={{ color: 'var(--accent-amber)' }}>{solarData.sfi}</span></span>
<span><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData.ssn}</span></span>
{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)' }}>K </span><span style={{ color: solarData.kIndex >= 4 ? '#ff4444' : '#00ff88' }}>{solarData.kIndex}</span></span>
</div>
@ -2585,7 +2619,7 @@
{/* BOTTOM - Footer */}
<div style={{ ...panelStyle, gridRow: '3', gridColumn: '1 / -1', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px' }}>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
OpenHamClock v3.5.1 • In memory of Elwood Downey WB0OEW
OpenHamClock v3.6.0 • In memory of Elwood Downey WB0OEW
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
Click map to set DX • 73 de {config.callsign}
@ -3170,7 +3204,7 @@
>
{config.callsign}
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>v3.5.1</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>v3.6.0</span>
</div>
{/* UTC Clock */}

@ -784,17 +784,163 @@ app.get('/api/satellites/tle', async (req, res) => {
});
// ============================================
// VOACAP / HF PROPAGATION PREDICTION API
// IONOSONDE DATA API (Real-time ionospheric data from KC2G/GIRO)
// ============================================
// Cache for ionosonde data (refresh every 10 minutes)
let ionosondeCache = {
data: null,
timestamp: 0,
maxAge: 10 * 60 * 1000 // 10 minutes
};
// Fetch real-time ionosonde data from KC2G (GIRO network)
async function fetchIonosondeData() {
const now = Date.now();
// Return cached data if fresh
if (ionosondeCache.data && (now - ionosondeCache.timestamp) < ionosondeCache.maxAge) {
return ionosondeCache.data;
}
try {
const response = await fetch('https://prop.kc2g.com/api/stations.json', {
headers: { 'User-Agent': 'OpenHamClock/3.5' },
timeout: 15000
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
// Filter to only recent data (within last 2 hours) with valid readings
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
const validStations = data.filter(s => {
if (!s.fof2 || !s.station) return false;
const stationTime = new Date(s.time);
return stationTime > twoHoursAgo && s.cs > 0; // confidence score > 0
}).map(s => ({
code: s.station.code,
name: s.station.name,
lat: parseFloat(s.station.latitude),
lon: parseFloat(s.station.longitude) > 180 ? parseFloat(s.station.longitude) - 360 : parseFloat(s.station.longitude),
foF2: s.fof2,
mufd: s.mufd, // MUF at 3000km
hmF2: s.hmf2, // Height of F2 layer
md: parseFloat(s.md) || 3.0, // M(3000)F2 factor
confidence: s.cs,
time: s.time
}));
ionosondeCache = {
data: validStations,
timestamp: now
};
console.log(`[Ionosonde] Fetched ${validStations.length} valid stations from KC2G`);
return validStations;
} catch (error) {
console.error('[Ionosonde] Fetch error:', error.message);
return ionosondeCache.data || [];
}
}
// API endpoint to get ionosonde data
app.get('/api/ionosonde', async (req, res) => {
try {
const stations = await fetchIonosondeData();
res.json({
count: stations.length,
timestamp: new Date().toISOString(),
stations: stations
});
} catch (error) {
console.error('[Ionosonde] API error:', error.message);
res.status(500).json({ error: 'Failed to fetch ionosonde data' });
}
});
// Calculate distance between two points in km
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
// Interpolate foF2 at a given location using inverse distance weighting
function interpolateFoF2(lat, lon, stations) {
if (!stations || stations.length === 0) return null;
// Calculate distances to all stations
const stationsWithDist = stations.map(s => ({
...s,
distance: haversineDistance(lat, lon, s.lat, s.lon)
})).filter(s => s.foF2 > 0);
if (stationsWithDist.length === 0) return null;
// Sort by distance and take nearest 5
stationsWithDist.sort((a, b) => a.distance - b.distance);
const nearest = stationsWithDist.slice(0, 5);
// If very close to a station, use its value directly
if (nearest[0].distance < 100) {
return {
foF2: nearest[0].foF2,
mufd: nearest[0].mufd,
hmF2: nearest[0].hmF2,
md: nearest[0].md,
source: nearest[0].name,
confidence: nearest[0].confidence,
method: 'direct'
};
}
// Inverse distance weighted interpolation
let sumWeights = 0;
let sumFoF2 = 0;
let sumMufd = 0;
let sumHmF2 = 0;
let sumMd = 0;
nearest.forEach(s => {
const weight = (s.confidence / 100) / Math.pow(s.distance, 2);
sumWeights += weight;
sumFoF2 += s.foF2 * weight;
if (s.mufd) sumMufd += s.mufd * weight;
if (s.hmF2) sumHmF2 += s.hmF2 * weight;
if (s.md) sumMd += s.md * weight;
});
return {
foF2: sumFoF2 / sumWeights,
mufd: sumMufd > 0 ? sumMufd / sumWeights : null,
hmF2: sumHmF2 > 0 ? sumHmF2 / sumWeights : null,
md: sumMd > 0 ? sumMd / sumWeights : 3.0,
nearestStation: nearest[0].name,
nearestDistance: Math.round(nearest[0].distance),
stationsUsed: nearest.length,
method: 'interpolated'
};
}
// ============================================
// ENHANCED PROPAGATION PREDICTION API (ITU-R P.533 based)
// ============================================
app.get('/api/propagation', async (req, res) => {
const { deLat, deLon, dxLat, dxLon } = req.query;
console.log('[Propagation] Calculating for DE:', deLat, deLon, 'to DX:', dxLat, dxLon);
console.log('[Propagation] Enhanced calculation for DE:', deLat, deLon, 'to DX:', dxLat, dxLon);
try {
// Get current space weather data for calculations
let sfi = 150, ssn = 100, kIndex = 2; // Defaults
// Get current space weather data
let sfi = 150, ssn = 100, kIndex = 2;
try {
const [fluxRes, kRes] = await Promise.allSettled([
@ -810,26 +956,39 @@ app.get('/api/propagation', async (req, res) => {
const data = await kRes.value.json();
if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2;
}
// Estimate SSN from SFI: SSN ≈ (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('[Propagation] Solar data - SFI:', sfi, 'SSN:', ssn, 'K:', kIndex);
// Get real ionosonde data
const ionosondeStations = await fetchIonosondeData();
// Calculate distance and bearing
// Calculate path geometry
const de = { lat: parseFloat(deLat) || 40, lon: parseFloat(deLon) || -75 };
const dx = { lat: parseFloat(dxLat) || 35, lon: parseFloat(dxLon) || 139 };
const distance = calculateDistance(de.lat, de.lon, dx.lat, dx.lon);
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;
}
// Get ionospheric data at path midpoint
const ionoData = interpolateFoF2(midLat, midLon, ionosondeStations);
console.log('[Propagation] Distance:', Math.round(distance), 'km, MidLat:', midLat.toFixed(1));
console.log('[Propagation] Distance:', Math.round(distance), 'km');
console.log('[Propagation] Solar: SFI', sfi, 'SSN', ssn, 'K', kIndex);
if (ionoData) {
console.log('[Propagation] Real foF2:', ionoData.foF2?.toFixed(2), 'MHz from', ionoData.nearestStation || ionoData.source);
}
// Calculate propagation for each band at each hour
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]; // MHz
const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 28, 50];
const currentHour = new Date().getUTCHours();
// Generate 24-hour predictions
@ -840,8 +999,8 @@ app.get('/api/propagation', async (req, res) => {
predictions[band] = [];
for (let hour = 0; hour < 24; hour++) {
const reliability = calculateBandReliability(
freq, distance, midLat, hour, sfi, ssn, kIndex, de, dx
const reliability = calculateEnhancedReliability(
freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, ionoData, currentHour
);
predictions[band].push({
hour,
@ -851,7 +1010,7 @@ app.get('/api/propagation', async (req, res) => {
}
});
// Get current best bands
// Current best bands
const currentBands = bands.map((band, idx) => ({
band,
freq: bandFreqs[idx],
@ -860,12 +1019,27 @@ app.get('/api/propagation', async (req, res) => {
status: getStatus(predictions[band][currentHour].reliability)
})).sort((a, b) => b.reliability - a.reliability);
// Calculate current MUF and LUF
const currentMuf = calculateMUF(distance, midLat, midLon, currentHour, sfi, ssn, ionoData);
const currentLuf = calculateLUF(distance, midLat, currentHour, sfi, kIndex);
res.json({
solarData: { sfi, ssn, kIndex },
ionospheric: ionoData ? {
foF2: ionoData.foF2?.toFixed(2),
mufd: ionoData.mufd?.toFixed(1),
hmF2: ionoData.hmF2?.toFixed(0),
source: ionoData.nearestStation || ionoData.source,
method: ionoData.method,
stationsUsed: ionoData.stationsUsed || 1
} : { source: 'model', method: 'estimated' },
muf: Math.round(currentMuf * 10) / 10,
luf: Math.round(currentLuf * 10) / 10,
distance: Math.round(distance),
currentHour,
currentBands,
hourlyPredictions: predictions
hourlyPredictions: predictions,
dataSource: ionoData ? 'KC2G/GIRO Ionosonde Network' : 'Estimated from solar indices'
});
} catch (error) {
@ -874,77 +1048,178 @@ app.get('/api/propagation', async (req, res) => {
}
});
// Calculate great circle distance in km
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
// 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
if (ionoData?.mufd) {
// MUF scales with distance: MUF(d) ≈ MUF(3000) * sqrt(3000/d) for d < 3000km
// For d > 3000km, MUF(d) ≈ MUF(3000) * (1 + 0.1 * log(d/3000))
if (distance < 3000) {
return ionoData.mufd * Math.sqrt(distance / 3000);
} else {
return ionoData.mufd * (1 + 0.15 * Math.log10(distance / 3000));
}
}
// If we have foF2, calculate MUF using M(3000)F2 factor
if (ionoData?.foF2) {
const M = ionoData.md || 3.0; // M(3000)F2 factor, typically 2.5-3.5
const muf3000 = ionoData.foF2 * M;
// Scale for actual distance
if (distance < 3000) {
return muf3000 * Math.sqrt(distance / 3000);
} else {
return muf3000 * (1 + 0.15 * Math.log10(distance / 3000));
}
}
// Fallback: Estimate foF2 from solar indices
// foF2 ≈ 0.9 * sqrt(SSN + 15) * diurnal_factor
const hourFactor = 1 + 0.4 * Math.cos((hour - 14) * Math.PI / 12); // Peak at 14:00 local
const latFactor = 1 - Math.abs(midLat) / 150; // Higher latitudes = lower foF2
const foF2_est = 0.9 * Math.sqrt(ssn + 15) * hourFactor * latFactor;
// Standard M(3000)F2 factor
const M = 3.0;
const muf3000 = foF2_est * M;
// Scale for distance
if (distance < 3000) {
return muf3000 * Math.sqrt(distance / 3000);
} else {
return muf3000 * (1 + 0.15 * Math.log10(distance / 3000));
}
}
// Calculate band reliability percentage (simplified VOACAP-style)
function calculateBandReliability(freq, distance, midLat, hour, sfi, ssn, kIndex, de, dx) {
// Maximum Usable Frequency estimation
// MUF ≈ criticalFreq * secant(zenith angle) * sqrt(1 + distance/4000)
// Calculate LUF (Lowest Usable Frequency) based on D-layer absorption
function calculateLUF(distance, midLat, hour, sfi, kIndex) {
// LUF increases with:
// - Higher solar flux (more D-layer ionization)
// - Daytime (D-layer forms during day)
// - Shorter paths (higher elevation angles = more time in D-layer)
// - Geomagnetic activity
// Local solar time at midpoint (approximate)
const localHour = hour; // Would need proper calculation with midLon
// Day/night factor: D-layer absorption is much higher during daytime
let dayFactor = 0.3; // Night
if (localHour >= 6 && localHour <= 18) {
// Daytime - peaks around noon
dayFactor = 0.5 + 0.5 * Math.cos((localHour - 12) * Math.PI / 6);
}
// Solar flux factor: higher SFI = more absorption
const sfiFactor = 1 + (sfi - 70) / 200;
// Distance factor: shorter paths have higher LUF (higher angles)
const distFactor = Math.max(0.5, 1 - distance / 10000);
// Critical frequency varies with solar activity and time
// foF2 ≈ 0.85 * sqrt(ssn + 12) * (1 + 0.3 * cos(hour * PI / 12))
const hourFactor = 1 + 0.4 * Math.cos((hour - 12) * Math.PI / 12);
const foF2 = 0.9 * Math.sqrt(ssn + 15) * hourFactor;
// Latitude factor: polar paths have more absorption
const latFactor = 1 + Math.abs(midLat) / 90 * 0.5;
// Distance factor (longer paths need lower angles, higher MUF)
const distFactor = Math.sqrt(1 + distance / 3500);
// K-index: geomagnetic storms increase absorption
const kFactor = 1 + kIndex * 0.1;
// Latitude factor (higher latitudes = more absorption, lower MUF)
const latFactor = 1 - Math.abs(midLat) / 200;
// Base LUF is around 2 MHz for long night paths
const baseLuf = 2.0;
return baseLuf * dayFactor * sfiFactor * distFactor * latFactor * kFactor;
}
// Estimated MUF
const muf = foF2 * distFactor * latFactor * 3.5;
// Enhanced reliability calculation using real ionosonde data
function calculateEnhancedReliability(freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, ionoData, currentHour) {
// Calculate MUF and LUF for this hour
// For non-current hours, we need to estimate how foF2 changes
let hourIonoData = ionoData;
if (ionoData && hour !== currentHour) {
// Estimate foF2 change based on diurnal variation
// foF2 typically varies by factor of 2-3 between day and night
const currentHourFactor = 1 + 0.4 * Math.cos((currentHour - 14) * Math.PI / 12);
const targetHourFactor = 1 + 0.4 * Math.cos((hour - 14) * Math.PI / 12);
const scaleFactor = targetHourFactor / currentHourFactor;
hourIonoData = {
...ionoData,
foF2: ionoData.foF2 * scaleFactor,
mufd: ionoData.mufd ? ionoData.mufd * scaleFactor : null
};
}
// Lowest Usable Frequency (absorption limit)
// LUF increases with solar activity and during daytime
const dayNight = isDaytime(hour, (de.lon + dx.lon) / 2) ? 1.5 : 0.5;
const luf = 2 + (sfi / 100) * dayNight + kIndex * 0.5;
const muf = calculateMUF(distance, midLat, midLon, hour, sfi, ssn, hourIonoData);
const luf = calculateLUF(distance, midLat, hour, sfi, kIndex);
// Calculate reliability based on frequency vs MUF/LUF
// Calculate reliability based on frequency position relative to MUF/LUF
let reliability = 0;
if (freq > muf) {
// Frequency above MUF - poor propagation
reliability = Math.max(0, 50 - (freq - muf) * 10);
if (freq > muf * 1.1) {
// Well above MUF - very poor
reliability = Math.max(0, 30 - (freq - muf) * 5);
} else if (freq > muf) {
// Slightly above MUF - marginal (sometimes works due to scatter)
reliability = 30 + (muf * 1.1 - freq) / (muf * 0.1) * 20;
} else if (freq < luf * 0.8) {
// Well below LUF - absorbed
reliability = Math.max(0, 20 - (luf - freq) * 10);
} else if (freq < luf) {
// Frequency below LUF - too much absorption
reliability = Math.max(0, 50 - (luf - freq) * 15);
// Near LUF - marginal
reliability = 20 + (freq - luf * 0.8) / (luf * 0.2) * 30;
} else {
// Frequency in usable range
const midFreq = (muf + luf) / 2;
const optimalness = 1 - Math.abs(freq - midFreq) / (muf - luf);
reliability = 50 + optimalness * 45;
// In usable range - calculate optimum
// Optimum Working Frequency (OWF) is typically 80-85% of MUF
const owf = muf * 0.85;
const range = muf - luf;
if (range <= 0) {
reliability = 30; // Very narrow window
} else {
// Higher reliability near OWF, tapering toward MUF and LUF
const position = (freq - luf) / range; // 0 at LUF, 1 at MUF
const optimalPosition = 0.75; // 75% up from LUF = OWF
if (position < optimalPosition) {
// Below OWF - reliability increases as we approach OWF
reliability = 50 + (position / optimalPosition) * 45;
} else {
// Above OWF - reliability decreases as we approach MUF
reliability = 95 - ((position - optimalPosition) / (1 - optimalPosition)) * 45;
}
}
}
// K-index degradation (geomagnetic storms)
if (kIndex >= 5) reliability *= 0.3;
if (kIndex >= 7) reliability *= 0.1;
else if (kIndex >= 6) reliability *= 0.2;
else if (kIndex >= 5) reliability *= 0.4;
else if (kIndex >= 4) reliability *= 0.6;
else if (kIndex >= 3) reliability *= 0.8;
// Distance adjustment - very long paths are harder
if (distance > 15000) reliability *= 0.7;
else if (distance > 10000) reliability *= 0.85;
// Very long paths (multiple hops) are harder
const hops = Math.ceil(distance / 3500);
if (hops > 1) {
reliability *= Math.pow(0.92, hops - 1); // ~8% loss per additional hop
}
// Polar path penalty (auroral absorption)
if (Math.abs(midLat) > 60) {
reliability *= 0.7;
if (kIndex >= 3) reliability *= 0.7; // Additional penalty during storms
}
// High bands need higher solar activity
if (freq >= 21 && sfi < 100) reliability *= (sfi / 100);
if (freq >= 28 && sfi < 120) reliability *= (sfi / 120);
// High bands need sufficient solar activity
if (freq >= 21 && sfi < 100) reliability *= Math.sqrt(sfi / 100);
if (freq >= 28 && sfi < 120) reliability *= Math.sqrt(sfi / 120);
if (freq >= 50 && sfi < 150) reliability *= Math.pow(sfi / 150, 1.5);
return Math.min(99, Math.max(0, reliability));
}
// Low bands work better at night
const localHour = (hour + midLon / 15 + 24) % 24;
const isNight = localHour < 6 || localHour > 18;
if (freq <= 7 && isNight) reliability *= 1.1;
if (freq <= 3.5 && !isNight) reliability *= 0.7;
// Check if it's daytime at given longitude
function isDaytime(utcHour, longitude) {
const localHour = (utcHour + longitude / 15 + 24) % 24;
return localHour >= 6 && localHour <= 18;
return Math.min(99, Math.max(0, reliability));
}
// Convert reliability to estimated SNR

Loading…
Cancel
Save

Powered by TurnKey Linux.