defmodule Microwaveprop.Propagation.SporadicE do @moduledoc """ Sporadic-E propagation physics for VHF scoring. Given foEs (the E-layer critical frequency measured by an ionosonde, in MHz) and a ground-range distance in km, computes the single-hop Es MUF via the thin-layer obliquity factor from ITU-R P.534-6 / Davies 1990 *Ionospheric Radio*, Eq. 6.26: MUF_Es = foEs · sec(i) where `sec(i) = √(1 + (D / (2h))²)` with the reflection height `h = 110 km` (standard thin-E approximation). The `es_score/3` function maps a `(foEs, target band, distance)` triple into a 0–100 propagation likelihood for **single-hop Es only**. Multi-hop Es (which extends the range but needs consecutive intense Es patches) and Es-scatter are not modelled here — those are separate factors. Calibration notes: - 50 MHz Es at 2000 km needs foEs ≳ 5.5 MHz (routine summer Es). - 144 MHz Es at 2000 km needs foEs ≳ 15.8 MHz (rare, observed during major Jun/Jul peaks). - 222/432 MHz Es does not occur at physically observed foEs values. - Single-hop Es has a minimum useful range of ~500 km (steeper rays escape the layer rather than reflect) and a maximum of ~2500 km (the geometric horizon limit for a 110 km reflection height). """ # Standard E-layer reflection height for the thin-layer approximation. @e_layer_height_km 110.0 @min_hop_km 500 @max_hop_km 2500 @doc """ Single-hop Es MUF in MHz. Returns 0.0 for nil/non-positive inputs. """ @spec single_hop_muf(number() | nil, number() | nil) :: float() def single_hop_muf(nil, _), do: 0.0 def single_hop_muf(_, nil), do: 0.0 def single_hop_muf(fo_es_mhz, _) when fo_es_mhz <= 0, do: 0.0 def single_hop_muf(_, distance_km) when distance_km <= 0, do: 0.0 def single_hop_muf(fo_es_mhz, distance_km) do sec_i = :math.sqrt(1 + :math.pow(distance_km / (2 * @e_layer_height_km), 2)) fo_es_mhz * sec_i end @doc """ Returns a 0–100 score for single-hop Es propagation of `band_mhz` over a ground-range `distance_km`, given the measured `fo_es_mhz`. - Short paths (< 500 km) and long paths (> 2500 km) are outside the single-hop window → 0. - `nil` or 0 foEs → 0. - Otherwise the score ramps with `MUF_Es / band_mhz`: ≥ 1.20 → 100 (comfortably above the critical threshold) ≥ 1.00 → 80 (just above, marginal Es) ≥ 0.90 → 50 (close, occasional fringe) ≥ 0.70 → 20 (weak fringe, unreliable) below → 0 """ @spec es_score(number() | nil, number(), number() | nil) :: integer() def es_score(nil, _band, _distance), do: 0 def es_score(_fo_es, _band, nil), do: 0 def es_score(fo_es_mhz, _band, _distance) when fo_es_mhz <= 0, do: 0 def es_score(_fo_es, _band, distance_km) when distance_km < @min_hop_km or distance_km > @max_hop_km, do: 0 def es_score(fo_es_mhz, band_mhz, distance_km) do muf = single_hop_muf(fo_es_mhz, distance_km) ratio = muf / band_mhz cond do ratio >= 1.20 -> 100 ratio >= 1.00 -> 80 ratio >= 0.90 -> 50 ratio >= 0.70 -> 20 true -> 0 end end end