Poll UBNT AirFiber radios (AF11X + AF60-LR) every 5 minutes via net-snmp CLI, storing signal metrics in commercial_samples. Fetches ASOS weather alongside each cycle for propagation correlation. Includes 7 seeded link definitions, Oban cron worker, and net-snmp in the Docker image.
39 KiB
Microwave Propagation Algorithm — Unified
Overview
Propagation scoring and prediction for microwave amateur radio bands (10-241 GHz), calibrated against 57,492 QSOs with distance data, validated against 18,540 signal samples from 7 commercial terrestrial links at 11/24/68 GHz, and grounded in ITU-R atmospheric models.
The algorithm has two operating regimes:
- Beyond-LOS — Ham radio paths (50-1000+ km) where atmospheric ducting and refraction are essential. This is the primary use case.
- LOS — Known fixed links or short paths with clear Fresnel clearance where gaseous absorption is the dominant variable.
The regime distinction matters because refractivity effects are inverted between the two: enhanced refraction extends beyond-LOS range but causes multipath fading on short LOS paths.
Calibration Dataset
QSO data: 57,492 tropospheric contacts across 13 bands (ARRL Microwave Contest, 2019-2024), filtered to exclude EME/satellite. Weather-matched against 95 ASOS stations (surface) and 9 RAOB stations (soundings).
Link data: 18,540 samples from 7 commercial links near DFW (March 14-29 2026), correlated with KTKI ASOS and HRRR model refractivity profiles. No rain during observation period.
Confirmed long-range contacts:
- 47 GHz: 116.0 km (Nov 2025), 98.8 km (Jun 2024)
- 24 GHz: 542.1 km (Sep 2002, longest), 101.0 km (Nov 2025)
- 10 GHz: 1,609 km (longest in dataset)
Part 1: Atmospheric Physics
Absolute Humidity
The single most important weather variable. Temperature and relative humidity are proxies; absolute humidity (g/m^3) directly determines gaseous absorption.
rho = 217 * (RH/100) * e_s / T_kelvin
e_s = 6.112 * exp(17.67 * T_c / (T_c + 243.5)) # Magnus formula (hPa)
Surface Refractivity (ITU-R P.453-14)
N = 77.6 * P / T + 3.73e5 * e / T^2
P: pressure (hPa)
T: absolute temperature (K)
e: water vapor pressure (hPa) = 6.112 * exp(17.67 * Td_c / (Td_c + 243.5))
N is a compound variable: both dry air (pressure/temperature) and moisture contribute. At 10 GHz, higher N increases beam bending (beneficial for beyond-LOS). At 24+ GHz, higher N usually means more moisture = more absorption (harmful), though the refractivity benefit partially offsets this.
Modified Refractivity (M-units)
M = N + 0.157 * (h_agl)
h_agl: height above ground level (m)
Ducting occurs where dM/dh < 0 (M decreases with height). Duct strength = delta-M across the inversion layer.
Gaseous Absorption (ITU-R P.676-13)
Total absorption per km = O2 component (fixed) + H2O component (humidity-dependent):
| Band | f (GHz) | O2 (dB/km) | H2O Coeff (dB/km per g/m^3) | Total @ 7.5 g/m^3 | Dominant Constraint |
|---|---|---|---|---|---|
| 10G | 10.368 | 0.008 | 0.0005 | 0.012 | Negligible absorption |
| 24G | 24.192 | 0.015 | 0.012 | 0.105 | 22.235 GHz H2O line |
| 47G | 47.088 | 0.045 | 0.003 | 0.068 | O2 wing + mild H2O |
| 68G | 68.040 | 0.90 | 0.007 | 0.95 | 60 GHz O2 band wing |
| 75G | 76.032 | 0.012 | 0.006 | 0.057 | Window band |
| 122G | 122.250 | 0.80 | 0.010 | 0.875 | 118.75 GHz O2 wing |
| 134G | 134.928 | 0.08 | 0.015 | 0.193 | Between O2 118 & H2O 183 |
| 142G | 142.000 | 0.05 | 0.025 | 0.238 | Approaching H2O 183 |
| 241G | 241.000 | 0.08 | 0.30 | 2.33 | Between H2O 183 & H2O 325 |
The 11 GHz and 24 GHz coefficients are validated by commercial link measurements. The 68 GHz coefficient is directly measured (0.1 dB/km per g/m^3 increase on a 2.8 km path, consistent with ITU-R model when O2 wing contribution is included).
Rain Attenuation (ITU-R P.838-3)
gamma_R = k * R^alpha (dB/km), R = rain rate (mm/hr):
| Band | k_H | alpha_H | Light 4mm/hr | Moderate 10mm/hr | Heavy 25mm/hr |
|---|---|---|---|---|---|
| 10G | 0.010 | 1.28 | 0.05 | 0.19 | 0.56 |
| 24G | 0.070 | 1.07 | 0.31 | 0.81 | 2.04 |
| 47G | 0.187 | 0.93 | 0.68 | 1.58 | 3.69 |
| 68G | 0.310 | 0.86 | 0.98 | 2.18 | 4.73 |
| 75G | 0.345 | 0.84 | 1.07 | 2.40 | 5.18 |
| 122G | 0.498 | 0.77 | 1.32 | 2.93 | 5.91 |
| 241G | 0.550 | 0.70 | 1.30 | 2.76 | 5.20 |
Rain model is NOT validated by measured data (no rain events in link dataset). Coefficients are from ITU-R P.838-3 and interpolation.
Free-Space Path Loss (ITU-R P.525)
FSPL = 20 * log10(d_km) + 20 * log10(f_GHz) + 92.45 (dB)
Fresnel Zone Radius
r_fresnel = sqrt(lambda * d1 * d2 / (d1 + d2))
lambda = 0.3 / f_GHz (meters)
Earth Bulge
bulge = (d1 * d2) / (2 * K * 6371000)
K: effective earth radius factor (standard = 4/3)
Effective K-Factor from Surface N
dN_est = -40 - (N_surface - 315) * 0.25
K = 1 / (1 + 6371 * dN_est * 1e-6)
K clamped to [0.5, 5.0]
Part 2: Key Empirical Findings
These findings drive the scoring model's design. Each contradicts or refines assumptions from simpler models.
Finding 1: Humidity Effect Reverses by Frequency
The most important discovery. At 10 GHz, more moisture = longer paths (refractivity dominates, absorption negligible). At 24+ GHz, more moisture = shorter paths (absorption dominates).
10 GHz — humidity helps (N=52,456 QSOs):
| Abs. Humidity | Avg Dist | P90 Dist |
|---|---|---|
| 5-8 g/m^3 | 193 km | 342 km |
| 11-14 g/m^3 | 215 km | 383 km |
| 17+ g/m^3 | 230 km | 519 km |
24 GHz — humidity hurts (N=3,439 QSOs):
| Abs. Humidity | Avg Dist | P90 Dist |
|---|---|---|
| 5-8 g/m^3 | 115 km | 154 km |
| 11-14 g/m^3 | 105 km | 174 km |
| 17+ g/m^3 | 53 km | 103 km |
47 GHz — humidity hurts, less severely (N=576 QSOs):
| Abs. Humidity | Avg Dist | P90 Dist |
|---|---|---|
| 0-5 g/m^3 | 191 km | 234 km |
| 11-14 g/m^3 | 71 km | 114 km |
Finding 2: Wind Penalty Is Overrated
Data shows no meaningful penalty for wind on achieved distance:
| Wind | 10G Avg | 24G Avg | 47G Avg |
|---|---|---|---|
| Calm (0-3 kts) | 216 | 84 | -- |
| Light (3-7 kts) | 214 | 100 | 77 |
| Moderate (7-12 kts) | 220 | 113 | 77 |
Wind may reduce inversion quality but also creates boundary-layer dynamics that can enhance propagation. Weight reduced from 18% to 8%.
Finding 3: Low Pressure Correlates with Longer Distances
Contradicts the original model's high-pressure = good assumption:
| Pressure | 10G Avg | 10G P90 | 24G Avg | 47G Avg |
|---|---|---|---|---|
| <1010 | 262 | 506 | 119 | 111 |
| 1015-1020 | 217 | 383 | 97 | 74 |
| 1025+ | 196 | 354 | 122 | -- |
Low pressure systems bring frontal boundaries with strong temperature/moisture gradients that create inversions and ducts. The key is gradient structure, not absolute pressure.
Finding 4: Boundary Layer Depth Sweet Spot
Moderate BL depth (500-1000m) produces the best results across all bands — indicating an elevated inversion high enough to trap signals but not so deep that full convective mixing has occurred.
Finding 5: Binary Duct Detection Is Weak
Simple "duct detected yes/no" from soundings shows negligible correlation with distance (avg dist: 214 vs 216 km at 10 GHz). This is because soundings are 12-hourly point samples while conditions evolve continuously, and duct characteristics (strength, height) matter more than binary presence.
Finding 6: Diurnal Signal Variation Sets a Noise Floor
Commercial link data shows 1-5 dB daily variation even on perfectly clear, stable days. The algorithm should convey that even an "EXCELLENT" score has +/-2-3 dB inherent uncertainty.
Finding 7: LOS vs Beyond-LOS Regimes Are Inverted
On short LOS paths (3-7 km), sub-refractive conditions (dN/dh > -40/km) produce the best signal — minimal multipath, clean beam coupling. On long beyond-LOS paths (50-500+ km), enhanced refraction/ducting is essential. The algorithm must handle both regimes.
Part 3: Band Configuration
@band_configs %{
10_000 => %{
label: "10 GHz",
o2_db_km: 0.008,
h2o_coeff: 0.0005,
humidity_effect: :beneficial, # More moisture = more refractivity = longer paths
humidity_penalty: 0.0,
rain_k: 0.010, rain_alpha: 1.28,
seasonal_base: %{1 => 88, 2 => 84, 3 => 72, 4 => 62, 5 => 55,
6 => 42, 7 => 28, 8 => 28, 9 => 52, 10 => 68,
11 => 96, 12 => 88},
seasonal_adj: %{},
typical_range_km: 200,
extended_range_km: 500,
exceptional_range_km: 1000
},
24_000 => %{
label: "24 GHz",
o2_db_km: 0.015,
h2o_coeff: 0.012, # Validated by commercial link data
humidity_effect: :harmful, # 22.235 GHz H2O line shoulder
humidity_penalty: 1.6,
rain_k: 0.070, rain_alpha: 1.07,
seasonal_base: %{1 => 88, 2 => 84, 3 => 72, 4 => 62, 5 => 51,
6 => 34, 7 => 18, 8 => 18, 9 => 48, 10 => 68,
11 => 96, 12 => 88},
seasonal_adj: %{5 => -4, 6 => -8, 7 => -10, 8 => -10, 9 => -4},
typical_range_km: 100,
extended_range_km: 250,
exceptional_range_km: 500
},
47_000 => %{
label: "47 GHz",
o2_db_km: 0.045,
h2o_coeff: 0.003,
humidity_effect: :harmful,
humidity_penalty: 1.0, # Window band, moderate H2O sensitivity
rain_k: 0.187, rain_alpha: 0.93,
seasonal_base: %{1 => 90, 2 => 88, 3 => 78, 4 => 68, 5 => 55,
6 => 38, 7 => 22, 8 => 22, 9 => 48, 10 => 74,
11 => 96, 12 => 90},
seasonal_adj: %{},
typical_range_km: 70,
extended_range_km: 150,
exceptional_range_km: 300
},
68_000 => %{
label: "68 GHz",
o2_db_km: 0.90, # 60 GHz O2 band wing — validated by link data
h2o_coeff: 0.007, # Measured: ~0.1 dB/km per g/m^3 on 2.8 km path
humidity_effect: :harmful,
humidity_penalty: 1.4,
rain_k: 0.310, rain_alpha: 0.86,
seasonal_base: %{1 => 90, 2 => 88, 3 => 78, 4 => 65, 5 => 50,
6 => 32, 7 => 18, 8 => 18, 9 => 44, 10 => 70,
11 => 92, 12 => 90},
seasonal_adj: %{},
typical_range_km: 40,
extended_range_km: 80,
exceptional_range_km: 150
},
75_000 => %{
label: "75 GHz",
o2_db_km: 0.012,
h2o_coeff: 0.006,
humidity_effect: :harmful,
humidity_penalty: 1.2,
rain_k: 0.345, rain_alpha: 0.84,
seasonal_base: %{1 => 90, 2 => 90, 3 => 80, 4 => 68, 5 => 55,
6 => 38, 7 => 22, 8 => 22, 9 => 48, 10 => 74,
11 => 96, 12 => 90},
seasonal_adj: %{},
typical_range_km: 50,
extended_range_km: 120,
exceptional_range_km: 250
},
122_000 => %{
label: "122 GHz",
o2_db_km: 0.80, # 118.75 GHz O2 wing — weather independent
h2o_coeff: 0.010,
humidity_effect: :harmful,
humidity_penalty: 1.0,
rain_k: 0.498, rain_alpha: 0.77,
seasonal_base: %{1 => 92, 2 => 90, 3 => 78, 4 => 62, 5 => 45,
6 => 28, 7 => 15, 8 => 15, 9 => 38, 10 => 68,
11 => 92, 12 => 92},
seasonal_adj: %{},
typical_range_km: 30,
extended_range_km: 80,
exceptional_range_km: 140
},
134_000 => %{
label: "134 GHz",
o2_db_km: 0.08,
h2o_coeff: 0.015,
humidity_effect: :harmful,
humidity_penalty: 1.3,
rain_k: 0.520, rain_alpha: 0.75,
seasonal_base: %{1 => 92, 2 => 90, 3 => 78, 4 => 65, 5 => 48,
6 => 30, 7 => 18, 8 => 18, 9 => 42, 10 => 70,
11 => 92, 12 => 92},
seasonal_adj: %{},
typical_range_km: 40,
extended_range_km: 100,
exceptional_range_km: 160
},
241_000 => %{
label: "241 GHz",
o2_db_km: 0.08,
h2o_coeff: 0.30, # Extreme H2O sensitivity (183/325 GHz lines)
humidity_effect: :harmful,
humidity_penalty: 3.0,
rain_k: 0.550, rain_alpha: 0.70,
seasonal_base: %{1 => 95, 2 => 92, 3 => 75, 4 => 55, 5 => 35,
6 => 15, 7 => 8, 8 => 8, 9 => 30, 10 => 65,
11 => 95, 12 => 95},
seasonal_adj: %{},
typical_range_km: 10,
extended_range_km: 50,
exceptional_range_km: 115
}
}
Part 4: Scoring Functions (Beyond-LOS Regime)
All scores return 0-100. The beyond-LOS regime is the primary use case for ham radio propagation prediction.
1. Humidity Score — Frequency-Dependent
The critical insight: moisture helps at 10 GHz (refractivity) and hurts at 24+ GHz (absorption).
def score_humidity(abs_humidity_gm3, band_config) do
case band_config.humidity_effect do
:beneficial ->
# 10 GHz: more moisture = higher surface N = more beam bending
# Extreme humidity risks scintillation
cond do
abs_humidity_gm3 < 4 -> 55 # Very dry — poor refractivity
abs_humidity_gm3 < 7 -> 70 # Dry
abs_humidity_gm3 < 10 -> 82 # Moderate
abs_humidity_gm3 < 14 -> 90 # Good refractivity
abs_humidity_gm3 < 18 -> 95 # Excellent refractivity
abs_humidity_gm3 < 22 -> 88 # High — scintillation onset
true -> 75 # Tropical — scintillation risk
end
:harmful ->
# 24+ GHz: H2O absorption dominates
# Penalty factor scales by band (1.0 for 47G window, 1.6 for 24G near line, 3.0 for 241G)
r = abs_humidity_gm3 * band_config.humidity_penalty
cond do
r <= 6 -> 100
r <= 9 -> round(95 - (r - 6) / 3 * 20)
r <= 13 -> round(75 - (r - 9) / 4 * 30)
r <= 18 -> round(45 - (r - 13) / 5 * 35)
true -> max(0, round(10 - (r - 18) * 2))
end
end
end
2. Time of Day Score — Inversion Lifecycle
The strongest diurnal predictor in the data. Dawn shows the highest P90 distances; late evening shows elevated averages.
@sunrise_table [7.4, 7.3, 7.0, 6.7, 6.35, 6.25,
6.35, 6.65, 6.9, 7.1, 7.35, 7.45]
def score_time_of_day(utc_hour, utc_minute, month) do
offset = if month >= 3 and month <= 10, do: -5, else: -6 # CDT/CST
local = rem(utc_hour + utc_minute / 60 + offset + 24, 24)
sunrise = Enum.at(@sunrise_table, month - 1)
d = local - sunrise # hours relative to sunrise
cond do
d >= -1.5 and d <= 1.5 ->
{100, "Peak — inversion maximum"}
d > 1.5 and d <= 3.0 ->
{78, "Good — inversion eroding"}
d > -3.0 and d < -1.5 ->
{82, "Pre-dawn — inversion building"}
d > 3.0 and d <= 6.0 ->
{38, "Marginal — boundary layer mixing"}
local >= 20.0 or local <= 1.0 ->
{72, "Evening — cooling, inversion reforming"}
d > 6.0 ->
{18, "Afternoon — full convective mixing"}
true ->
{55, "Night — gradual cooling"}
end
end
3. Temperature-Dewpoint Depression — Frequency-Split
Large depression = dry aloft = favorable for 24+ GHz. Small depression = moist = favorable for 10 GHz refractivity (but near-saturation risks fog).
def score_td_depression(temp_f, dewpoint_f, band_config) do
dep = temp_f - dewpoint_f
case band_config.humidity_effect do
:beneficial ->
cond do
dep < 3 -> 40 # Near saturation — fog/scattering risk
dep < 8 -> 75 # Moist — good refractivity
dep < 14 -> 85 # Moderate — balanced
dep < 22 -> 70 # Dry — reduced refractivity
true -> 55 # Very dry — poor refractivity
end
:harmful ->
cond do
dep > 22 -> 96 # Very dry aloft
dep > 14 -> 80 # Good stability
dep > 8 -> 60 # Moderate
dep > 4 -> 38 # Marginal
true -> 18 # Humid aloft
end
end
end
4. Sky Cover Score
Data shows modest impact at 24/47 GHz, near-zero at 10 GHz. VV (vertical visibility / fog) is a moderate penalty due to near-surface moisture content.
def score_sky(condition) do
case condition do
c when c in ["CLR", "SKC"] -> 100
"FEW" -> 88
"SCT" -> 60
"BKN" -> 25
"OVC" -> 5
"VV" -> 5
_ -> 50
end
end
5. Season Score
Per-band lookup with optional adjustments. 24 GHz gets additional summer penalties due to Gulf moisture.
def score_season(month, band_config) do
base = Map.get(band_config.seasonal_base, month, 50)
adj = Map.get(band_config.seasonal_adj, month, 0)
max(0, min(100, base + adj))
end
6. Wind Score — Reduced Weight
Data shows minimal impact on achieved distance. Retain mild penalty only for very high winds (turbulent scintillation).
def score_wind(speed_kts) do
cond do
speed_kts < 5 -> 100
speed_kts < 10 -> 90
speed_kts < 15 -> 75
speed_kts < 20 -> 55
speed_kts < 25 -> 35
true -> 15
end
end
7. Rain Score
Not validated by measured data. Based on ITU-R P.838-3 attenuation per km. At 10 GHz, moderate rain is tolerable. Above 75 GHz, even light rain effectively kills the path.
def score_rain(rain_rate_mmhr, band_config) do
if rain_rate_mmhr == nil or rain_rate_mmhr == 0 do
100
else
gamma = band_config.rain_k * :math.pow(rain_rate_mmhr, band_config.rain_alpha)
cond do
gamma < 0.1 -> 95
gamma < 0.5 -> 75
gamma < 1.0 -> 50
gamma < 2.0 -> 25
gamma < 5.0 -> 10
true -> 0
end
end
end
8. Pressure Score — Gradient-Focused
Data contradicts the original high-pressure-is-good model. Frontal boundaries (changing pressure) create the strongest refractive gradients. Rising post-frontal pressure and slowly falling pre-frontal pressure both score well.
def score_pressure(current_mb, previous_mb) do
case previous_mb do
nil ->
# No trend — mild scoring on absolute value
cond do
current_mb > 1025 -> 55 # Strong ridge
current_mb > 1018 -> 65 # Mild high
current_mb > 1010 -> 60 # Normal
current_mb > 1005 -> 55 # Low
true -> 40 # Very low — active weather
end
prev ->
delta = current_mb - prev
cond do
delta > 2.5 -> 80 # Rising rapidly — post-frontal clearing
delta > 0.8 -> 70 # Rising — stabilizing
delta > -0.5 -> 60 # Steady — neutral
delta > -2.0 -> 65 # Falling slowly — approaching front, duct possible
true -> 45 # Falling rapidly — active weather
end
end
end
9. Refractivity Score — When Sounding/HRRR Data Available
Best predictor when available, but only available from 12-hourly soundings or HRRR model. Binary duct detection is weak; refractivity gradient and BL depth are stronger signals.
def score_refractivity(sounding_or_hrrr, band_config) do
cond do
sounding_or_hrrr == nil -> 50 # No data — neutral
sounding_or_hrrr.min_dn_dh < -500 ->
# Super-refraction — ducting probable
case band_config.humidity_effect do
:beneficial -> 98 # 10 GHz benefits most from ducting
:harmful -> 85 # Higher bands benefit but absorption limits range
end
sounding_or_hrrr.min_dn_dh < -200 ->
80 # Enhanced refraction
sounding_or_hrrr.bl_depth_m != nil and
sounding_or_hrrr.bl_depth_m >= 500 and
sounding_or_hrrr.bl_depth_m <= 1000 ->
78 # Sweet-spot BL depth — elevated inversion
sounding_or_hrrr.min_dn_dh < -100 ->
65 # Mild enhancement
true ->
50 # Standard conditions
end
end
Part 5: Composite Score
Weights
| Factor | Weight | Rationale |
|---|---|---|
| Humidity | 22% | Dominant variable, but split role by frequency |
| Time of Day | 18% | Strongest diurnal predictor in QSO data |
| Td Depression | 14% | Proxy for humidity aloft — strong signal |
| Sky Cover | 10% | Modest effect, mainly at higher frequencies |
| Season | 10% | Long-term baseline |
| Wind | 8% | Data shows minimal impact; penalty only for extremes |
| Rain | 8% | Critical for 24+ GHz paths |
| Pressure | 5% | Weak standalone predictor |
| Refractivity | 5% | Best predictor when available, but often unavailable |
def composite_score(factors) do
round(
factors.humidity * 0.22 +
factors.time_of_day * 0.18 +
factors.td_depression * 0.14 +
factors.sky * 0.10 +
factors.season * 0.10 +
factors.wind * 0.08 +
factors.rain * 0.08 +
factors.pressure * 0.05 +
factors.refractivity * 0.05
)
end
Score Tiers with Per-Band Range Estimates
| Score | Label | 10G | 24G | 47G | 75G |
|---|---|---|---|---|---|
| 80-100 | EXCELLENT | 400-1000+ km | 200-500 km | 120-300 km | 80-200+ km |
| 65-79 | GOOD | 250-400 km | 120-200 km | 80-120 km | 50-80 km |
| 50-64 | MARGINAL | 150-250 km | 70-120 km | 50-80 km | 30-50 km |
| 33-49 | POOR | 80-150 km | 40-70 km | 25-50 km | 15-30 km |
| 0-32 | NEGLIGIBLE | <80 km | <40 km | <25 km | <15 km |
| Color | Hex |
|---|---|
| EXCELLENT | #00ffa3 |
| GOOD | #7dffd4 |
| MARGINAL | #ffe566 |
| POOR | #ff9044 |
| NEGLIGIBLE | #ff4f4f |
Part 6: LOS Regime Scoring
For known fixed links or short paths with confirmed Fresnel clearance. Key difference: sub-refraction is neutral/beneficial (minimal multipath), and gaseous absorption is the primary variable.
LOS Refractivity Score
def score_refractivity_los(dn_dh) do
cond do
dn_dh > 0 -> 60 # Strong sub-refraction — unusual but not harmful
dn_dh > -30 -> 85 # Moderate sub-refraction — stable, clean signal
dn_dh > -40 -> 75 # Near standard
dn_dh > -80 -> 60 # Enhanced — multipath onset
dn_dh > -157 -> 45 # Strong enhancement — multipath likely
true -> 30 # Super-refraction — significant multipath fading
end
end
LOS Surface N Score
Higher N often means more moisture = more absorption at 24+ GHz. Validated by link data: N < 310 gave best 68 GHz signal, N > 340 gave worst.
def score_surface_n(n_value, band_config) do
case band_config.humidity_effect do
:beneficial ->
cond do
n_value > 350 -> 90
n_value > 330 -> 80
n_value > 315 -> 65
n_value > 300 -> 50
true -> 35
end
:harmful ->
cond do
n_value < 300 -> 90
n_value < 315 -> 80
n_value < 330 -> 65
n_value < 345 -> 50
true -> 35
end
end
end
LOS vs Beyond-LOS Selection
def compute_score(conditions, band_config, path_type \\ :beyond_los) do
base_factors = %{
humidity: score_humidity(conditions.abs_humidity, band_config),
wind: score_wind(conditions.wind_speed_kts),
sky: score_sky(conditions.sky_condition),
time_of_day: score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month) |> elem(0),
td_depression: score_td_depression(conditions.temp_f, conditions.dewpoint_f, band_config),
season: score_season(conditions.month, band_config),
pressure: score_pressure(conditions.slp, conditions.prev_slp),
rain: score_rain(conditions.rain_rate, band_config)
}
factors = case path_type do
:beyond_los ->
Map.put(base_factors, :refractivity,
score_refractivity(conditions.sounding, band_config))
:los ->
Map.put(base_factors, :refractivity,
score_refractivity_los(conditions.dn_dh))
end
%{score: composite_score(factors), factors: factors}
end
Part 7: Link Budget
For point-to-point path analysis with known station parameters.
EIRP
eirp_dbm = tx_power_dbm + tx_antenna_dbi - feed_loss_db
Receiver Sensitivity
sensitivity_dbm = -174 + noise_figure_db + 10 * log10(bandwidth_hz)
CW: bandwidth = 500 Hz
SSB: bandwidth = 2700 Hz
Total Path Loss
total_loss = FSPL + gaseous_absorption + rain_attenuation + diffraction_loss - duct_enhancement
gaseous_absorption = (o2_db_km + h2o_coeff * rho) * distance_km
rain_attenuation = gamma_R * distance_km * rain_effective_fraction
Duct Enhancement (Beyond-LOS Only)
Calibrated against confirmed contacts:
def duct_enhancement_db(prop_score) do
cond do
prop_score >= 80 -> -14 # 14 dB improvement
prop_score >= 65 -> -10
prop_score >= 50 -> -6
prop_score >= 33 -> -2
true -> 0
end
end
Knife-Edge Diffraction (ITU-R P.526)
def knife_edge_loss(v) do
cond do
v <= -0.7787 -> 0 # Clear
v <= 0 -> -20 * :math.log10(0.5 - 0.62 * v)
v <= 1 -> -20 * :math.log10(0.5 * :math.exp(-0.95 * v))
v <= 2.4 ->
inner = max(0, 0.1184 - (0.38 - 0.1 * v) ** 2)
-20 * :math.log10(0.4 - :math.sqrt(inner))
true -> 20 * :math.log10(v) + 13.0 # Asymptotic
end
end
Success Probability
def margin_to_success(margin_db, prop_score) do
margin_pct = cond do
margin_db <= 0 -> 0
margin_db <= 10 -> margin_db / 10 * 20
margin_db <= 15 -> 20 + (margin_db - 10) / 5 * 20
margin_db <= 20 -> 40 + (margin_db - 15) / 5 * 20
margin_db <= 25 -> 60 + (margin_db - 20) / 5 * 20
margin_db <= 30 -> 80 + (margin_db - 25) / 5 * 20
true -> 100
end
# Propagation modulation: score 100 -> x1.30, score 50 -> x1.00, score 0 -> x0.70
prop_factor = 0.70 + (prop_score / 100) * 0.60
max(0, min(99, round(margin_pct * prop_factor)))
end
Note: Antenna Height & Duct Coupling Geometry
Antenna height and dish elevation angle affect how efficiently a station couples into an atmospheric duct. This is a real physical effect but is second-order to duct characteristics at the ranges this model targets (50-1000+ km).
Why it's not in the scoring model:
- At >300 km, the duct's own refractive gradient (k-factor) dominates over all antenna geometry. The required aim angle to graze a duct converges toward 0° regardless of antenna height.
- Antenna height differences in the 15-21m range (typical amateur stations) shift beam geometry by ~0.001° at long range — well within the ±2-3 dB noise floor from diurnal variation.
- The primary benefit of antenna height (50+ ft) is clearing local obstructions and ground clutter in the near field (0-20 km), not geometric coupling to the duct layer.
- VE4MA (50 ft, flat prairie) and W5LUA (70 ft, suburban) achieve similar range classes, confirming duct geometry is the dominant term.
Where it matters — beamwidth vs frequency:
At 10 GHz a typical 60cm dish has ~3° beamwidth, making elevation angle errors forgiving. At 24 GHz beamwidth shrinks to ~1.5°, at 47 GHz to <1°. A 0.3° aim error that is irrelevant at 10 GHz becomes a contact killer at 47 GHz. If station profiles (antenna height, dish size, elevation setting) are added in the future, a frequency-dependent beamwidth coupling penalty in margin_to_success would be the right integration point — penalizing paths where the required aim angle to the detected duct layer exceeds the antenna's half-power beamwidth.
Part 8: Short-Term Prediction Model
Approach
Extrapolate current conditions forward 1-6 hours using observed trends, diurnal models, and forecast data when available.
Prediction Confidence
Based on commercial link signal prediction accuracy:
| Horizon | Observed Accuracy | Confidence |
|---|---|---|
| Current | +/- 1 dB | 95% |
| +30 min | +/- 1.5 dB | 90% |
| +1 hr | +/- 2 dB | 85% |
| +2 hr | +/- 3 dB | 75% |
| +3 hr | +/- 4 dB | 60% |
| +6 hr | +/- 5 dB | 40% |
Diurnal Temperature Model
def project_temperature(current_temp_f, trend_per_hour, hours_ahead,
future_local_hour, month) do
sunrise = Enum.at(@sunrise_table, month - 1)
diurnal_rate = cond do
future_local_hour < sunrise - 1 -> -0.5 # Pre-dawn: slow cooling
future_local_hour < sunrise + 2 -> 0.0 # Sunrise transition
future_local_hour < 15 -> 2.0 # Morning: warming
future_local_hour < 18 -> 0.5 # Late afternoon
future_local_hour < 21 -> -1.5 # Evening: cooling
true -> -1.0 # Night: slow cooling
end
# Blend: current trend dominates short-term, diurnal model dominates long-term
weight = min(1.0, hours_ahead / 4.0)
blended_rate = trend_per_hour * (1.0 - weight) + diurnal_rate * weight
current_temp_f + blended_rate * hours_ahead
end
Prediction Flow
def predict_scores(current_obs, obs_3hr_ago, forecast, band_config) do
temp_trend = (current_obs.temp_f - obs_3hr_ago.temp_f) / 3
dp_trend = (current_obs.dewpoint_f - obs_3hr_ago.dewpoint_f) / 3
pressure_trend = (current_obs.slp - obs_3hr_ago.slp) / 3
for hours_ahead <- 1..6 do
future_time = DateTime.add(current_obs.observed_at, hours_ahead * 3600)
month = future_time.month
projected_temp = project_temperature(current_obs.temp_f, temp_trend,
hours_ahead, future_time.hour, month)
projected_dp = current_obs.dewpoint_f + dp_trend * hours_ahead
projected_slp = current_obs.slp + pressure_trend * hours_ahead
projected_sky = forecast_value(forecast, :sky, hours_ahead) || current_obs.sky_condition
projected_rain = forecast_value(forecast, :rain_rate, hours_ahead) || 0
projected_wind = forecast_value(forecast, :wind_kts, hours_ahead) || current_obs.wind_speed_kts
# Compute absolute humidity from projected values
tc = (projected_temp - 32) * 5 / 9
td_c = (projected_dp - 32) * 5 / 9
es = 6.112 * :math.exp(17.67 * tc / (tc + 243.5))
ed = 6.112 * :math.exp(17.67 * td_c / (td_c + 243.5))
rh = min(100, ed / es * 100)
abs_hum = 217 * (rh / 100) * es / (tc + 273.15)
factors = %{
humidity: score_humidity(abs_hum, band_config),
wind: score_wind(projected_wind),
sky: score_sky(projected_sky),
time_of_day: score_time_of_day(future_time.hour, future_time.minute, month) |> elem(0),
td_depression: score_td_depression(projected_temp, projected_dp, band_config),
season: score_season(month, band_config),
pressure: score_pressure(projected_slp, current_obs.slp),
rain: score_rain(projected_rain, band_config),
refractivity: 50 # Cannot predict from surface obs alone
}
%{
hours_ahead: hours_ahead,
time: future_time,
score: composite_score(factors),
factors: factors,
confidence: prediction_confidence(hours_ahead)
}
end
end
def prediction_confidence(hours_ahead) do
case hours_ahead do
1 -> 0.85
2 -> 0.75
3 -> 0.60
4 -> 0.50
5 -> 0.40
6 -> 0.30
_ -> 0.20
end
end
Part 9: Sounding & Refractivity Analysis
Refractivity Profile from Sounding
def compute_refractivity_profile(levels, sfc_height_m) do
Enum.map(levels, fn level ->
t_k = level.temp_c + 273.15
e = 6.1121 * :math.exp((18.678 - level.temp_c / 234.5) * (level.temp_c / (257.14 + level.temp_c)))
e_actual = if level.dewpoint_c, do: 6.1121 * :math.exp((18.678 - level.dewpoint_c / 234.5) * (level.dewpoint_c / (257.14 + level.dewpoint_c))), else: 0
n = 77.6 * level.pressure_hpa / t_k + 3.73e5 * e_actual / (t_k * t_k)
h_agl = level.height_m - sfc_height_m
m = n + 0.157 * h_agl
%{height_agl: h_agl, n: n, m: m, temp_c: level.temp_c, dewpoint_c: level.dewpoint_c}
end)
end
Duct Detection
Duct exists where dM/dh < 0. Filter for strength > 2 M-units.
def detect_ducts(profile) do
profile
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce({[], nil}, fn [below, above], {ducts, duct_start} ->
dm = above.m - below.m
cond do
dm < 0 and duct_start == nil ->
{ducts, %{base: below.height_agl, base_m: below.m}}
dm >= 0 and duct_start != nil ->
strength = duct_start.base_m - below.m
if strength > 2 do
duct = %{base: duct_start.base, top: below.height_agl, strength: strength}
{[duct | ducts], nil}
else
{ducts, nil}
end
true ->
{ducts, duct_start}
end
end)
|> elem(0)
|> Enum.reverse()
end
Inversion Detection
Temperature increasing with height. Merge adjacent inversions within 200m gap. Filter: strength >= 0.5C, base < 5000m AGL.
Stability Indices
K-Index = (T850 - T500) + Td850 - (T700 - Td700)
Lifted Index = T500 - (Tsfc - (h500 - h_sfc) * 0.00976)
LI < 0: Unstable (convection likely, inversion destroyed)
LI > 0: Stable (inversion maintained)
Precipitable Water = sum[(MR_i + MR_{i-1}) / 2 * dP / (9.81 * 10)]
MR = 622 * e / (P - e)
Boundary Layer Depth
Find height where potential temperature (theta = T + 9.8 * h/1000) exceeds surface theta by 2C. The 500-1000m sweet spot indicates an elevated inversion — high enough to trap signals, not so deep that full mixing has occurred.
Part 10: Band-Specific Propagation Mechanisms
Coupling Sensitivity by Frequency
Duct coupling geometry becomes increasingly critical at higher frequencies due to narrower antenna beamwidths. A dish aimed 0.3° away from the optimal duct grazing angle:
- 10 GHz (~3° beamwidth): Still within half-power beam — negligible loss
- 24 GHz (~1.5° beamwidth): Approaching beam edge — moderate coupling loss
- 47 GHz (<1° beamwidth): Outside half-power beam — potential contact killer
- 75+ GHz (<0.5° beamwidth): Precision aim required — elevation error dominates
For surface ducts, the beam must arrive at <0.5° grazing incidence to be trapped. For elevated ducts (500-1500m AGL), the optimal elevation angle is path-distance dependent: slightly positive at close range, near-zero at the "sweet spot" distance, and slightly negative at extreme range due to Earth curvature.
10 GHz (3cm) — Tropospheric Ducting Band
Primary mechanisms: Ducting, enhanced refraction Key variable: Refractivity profile, NOT humidity absorption (0.012 dB/km total is negligible) Best conditions: Moderate-high humidity (12-20 g/m^3), temperature inversions, stable atmosphere, late evening through early morning Unique: Largely insensitive to rain. Can propagate through cloud decks. Marine ducting produces 1000+ km coastal paths. Frontal boundaries create strong refractive gradients.
24 GHz (1.2cm) — Water Vapor Line Band
Primary mechanisms: Ducting (reduced by absorption), enhanced refraction Key variable: Absolute humidity (22.235 GHz H2O line makes this THE most humidity-sensitive band) Best conditions: Very dry air (<8 g/m^3), cold season (Nov-Mar), clear skies, pre-dawn through early morning Unique: 10x more sensitive to water vapor than 10 GHz. Summer Gulf moisture devastates range. Rain scatter is a viable alternative mechanism (710 km QSO documented).
47 GHz (6mm) — Atmospheric Window
Primary mechanisms: Ducting (in atmospheric window), enhanced LOS Key variable: Balance of humidity and refractivity; very dry air dramatically helps Best conditions: Dry air (<8 g/m^3), clear skies, strong inversions, early morning Unique: Window between 22 GHz H2O and 60 GHz O2. O2 absorption ~0.045 dB/km is fixed. Ducting is the ONLY way beyond ~150 km.
68 GHz — V-Band Edge
Primary mechanisms: LOS only (O2 absorption limits range) Key variable: O2 wing absorption (~0.9 dB/km, weather-independent) + humidity Best conditions: Cold/dry air, no precipitation, short paths Unique: Validated by link data showing 3-5 dB diurnal fades on 2.8 km path. O2 absorption caps practical range regardless of conditions. Viable for short links (<5 km), very challenging for beyond-LOS.
75 GHz (4mm) — Window Band
Primary mechanisms: Rare ducting, enhanced LOS Key variable: Dry air + no precipitation Best conditions: Very dry (<5 g/m^3), no rain, strong inversions, winter Unique: 289 km record (California marine duct). Only 81 QSOs with distance data. Rain attenuation severe (~1 dB/km at 4 mm/hr).
122 GHz (2.5mm) — O2 Line Wing
Primary mechanisms: Enhanced LOS, rare ducting Key variable: O2 absorption from 118.75 GHz line (~0.8 dB/km, cannot be improved by weather) Best conditions: Cold temperatures (reduce O2 line broadening), very dry, no rain Unique: 139 km record (California, February). Practically limited to ~50 km reliable paths.
134 GHz — Mini Window
Primary mechanisms: Enhanced LOS Key variable: Between O2 118 and H2O 183 lines Best conditions: Cold, dry, no precipitation Unique: 157 km record (Germany, March). Better than 122 GHz due to distance from O2 line.
241 GHz (1.2mm) — Submillimeter
Primary mechanisms: LOS only Key variable: H2O absorption dominates (~0.3 dB/km per g/m^3) Best conditions: Extremely dry (<3 g/m^3), winter-only in most US locations, high altitude stations Unique: 114 km record (Virginia, January). Total path loss at 100 km is ~410 dB without ducting. Realistic to display "viable / not viable" rather than a score.
Part 11: Data Flow & Implementation
Surface Observations (ASOS, every 5-20 min)
-> temp, dewpoint, wind, pressure, sky, visibility
-> compute: abs_humidity, Td depression
-> per-band scoring functions
-> composite score per band
-> 6-hour prediction timeline
Sounding Data (RAOB 00Z/12Z) + HRRR Model (hourly)
-> refractivity profile, dN/dh gradient, ducts, BL depth
-> refractivity score component
-> regime classification (LOS vs beyond-LOS for specific paths)
Terrain Data (SRTM)
-> path profile, Fresnel clearance, earth bulge
-> determines LOS vs beyond-LOS regime
-> diffraction loss calculation
Link Budget (point-to-point)
-> FSPL + gaseous + rain + diffraction - duct enhancement
-> margin = RX power - sensitivity
-> success % = margin_to_success(margin, prop_score)
Display: Band Conditions Panel
For each band:
- Current score (0-100, colored badge)
- Estimated range (km, from score tier table)
- Key limiting factor ("High humidity: 16 g/m^3", "Strong inversion detected")
- Trend arrow (improving/stable/degrading from last hour)
- 6-hour prediction timeline with confidence shading
Constants Reference
| Constant | Value | Source |
|---|---|---|
| Earth radius | 6371 km | WGS-84 mean |
| Standard K-factor | 4/3 | Standard atmosphere |
| Standard surface N | 315 | ITU-R P.453 |
| Standard dN/dh | -40 /km | ITU-R P.453 |
| Humidity penalty 24 GHz | 1.6 | Near 22.235 GHz H2O peak |
| Humidity penalty 47 GHz | 1.0 | Atmospheric window |
| Humidity penalty 68 GHz | 1.4 | 60 GHz O2 wing + H2O |
| Humidity penalty 241 GHz | 3.0 | Between H2O 183 & 325 |
| Duct M-unit threshold | 2 | Noise filter |
| Inversion min strength | 0.5C | Below is noise |
| Inversion height limit | 5000m AGL | Above irrelevant |
| BL depth sweet spot | 500-1000m | Empirical from QSO data |
| Signal prediction floor | +/- 2-3 dB | Measured from link data |
ITU-R References
- P.453-14: Radio refractivity
- P.525: Free-space path loss
- P.676-13: Gaseous absorption (O2 + H2O)
- P.838-3: Specific rain attenuation
- P.526: Diffraction
- P.452: Interference between stations
Data References
- ARRL Microwave Contest QSO database: 57,492 contacts with distance (2019-2024)
- IEM ASOS observations: 95 stations, +/-2hr window match
- IEM RAOB soundings: 9 stations, +/-6hr window match
- Commercial link data: 18,540 samples, 7 links at 11/24/68 GHz (March 2026)
- HRRR model: hourly refractivity profiles (March 2026 validation period)