prop/algo.md
Graham McIntire 10e8feb486
Add commercial link monitoring via SNMP polling
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.
2026-03-30 13:02:59 -05:00

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:

  1. Beyond-LOS — Ham radio paths (50-1000+ km) where atmospheric ducting and refraction are essential. This is the primary use case.
  2. 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

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)