@ -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 6 m / 10 m
*
* 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 calcula tion for DE:', deLat , deLon , 'to DX:' , dxLat , dxLon ) ;
console . log ( '[ ITU-R P.533] Predic tion 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/OW F
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' ;
}