diff --git a/lib/microwaveprop/propagation/hf_muf.ex b/lib/microwaveprop/propagation/hf_muf.ex new file mode 100644 index 00000000..91776659 --- /dev/null +++ b/lib/microwaveprop/propagation/hf_muf.ex @@ -0,0 +1,93 @@ +defmodule Microwaveprop.Propagation.HfMuf do + @moduledoc """ + HF / F2-layer MUF scoring from GIRO-measured MUFD(3000). + + GIRO ionosondes publish `MUFD` — the maximum usable frequency for a + specific reference ground distance D (we request 3000 km). That is a + *measured* value incorporating the true F2-layer profile and the CCIR + M(3000)F2 climatology, not a thin-layer approximation. + + This module treats the measured MUFD as the anchor and scales it to + the actual path distance using a thin-layer secant-of-incidence ratio + (h ≈ 300 km). That preserves the calibration from the measurement and + only uses the thin-layer formula for a *relative* distance correction, + which is much more accurate than recomputing MUF from scratch. + + Limitations: + + - Single-hop only; paths beyond ~4000 km need multi-hop logic we + don't implement here. + - Uses the nearest ionosonde's MUFD regardless of how far away it is + — for CONUS paths outside New England the F2 layer can look quite + different from Millstone Hill / Alpena. Good enough for a first + pass; a proper solution needs CCIR coefficient tables. + - Ignores D-layer absorption, so low-frequency cutoff (LUF) is not + modelled. The scoring curve is MUF-only. + - Ignores geomagnetic disturbance (Kp) effects. A storm-elevated Kp + can collapse daytime MUF without touching MUFD if the storm + post-dates the nearest observation. + """ + + @f2_layer_height_km 300.0 + @ref_distance_km 3000.0 + + # FOT (Frequency of Optimum Traffic) is the standard 85 % of MUF that + # ITU-R / NOAA quote as the reliable working frequency below the MUF. + @fot_ratio 0.85 + + @doc """ + Scale a measured MUFD(3000) value to a different ground-range + distance using thin-layer secant obliquity. Returns a MUF in MHz. + + At D = 3000 km this is a no-op (returns the input unchanged). + """ + @spec adjust_mufd(number() | nil, number() | nil) :: float() + def adjust_mufd(nil, _), do: 0.0 + def adjust_mufd(_, nil), do: 0.0 + def adjust_mufd(mufd, _) when mufd <= 0, do: 0.0 + def adjust_mufd(_, distance_km) when distance_km <= 0, do: 0.0 + + def adjust_mufd(mufd_ref_mhz, distance_km) do + ratio = sec_i_factor(distance_km) / sec_i_factor(@ref_distance_km) + mufd_ref_mhz * ratio + end + + @doc """ + Frequency of Optimum Traffic — the standard 85 % of MUF used as the + reliable working frequency below the MUF ceiling. + """ + @spec fot(number() | nil) :: float() + def fot(nil), do: 0.0 + def fot(muf) when muf <= 0, do: 0.0 + def fot(muf_mhz), do: muf_mhz * @fot_ratio + + @doc """ + Score an HF band on a path with the given MUF, in the 0–100 scale + used by the rest of the propagation engine. + + - band ≤ 0.75 × MUF → 100 (reliable, comfortably below FOT) + - band ≤ 0.85 × MUF (FOT) → 80 (optimal working band) + - band ≤ 1.00 × MUF → 50 (marginal, pushing the MUF ceiling) + - band ≤ 1.15 × MUF → 20 (fringe, intermittent) + - band > 1.15 × MUF → 0 (dead, well above MUF) + """ + @spec hf_score(number() | nil, number()) :: integer() + def hf_score(nil, _band), do: 0 + def hf_score(muf, _band) when muf <= 0, do: 0 + + def hf_score(muf_mhz, band_mhz) do + ratio = band_mhz / muf_mhz + + cond do + ratio <= 0.75 -> 100 + ratio <= 0.85 -> 80 + ratio <= 1.00 -> 50 + ratio <= 1.15 -> 20 + true -> 0 + end + end + + defp sec_i_factor(distance_km) do + :math.sqrt(1 + :math.pow(distance_km / (2 * @f2_layer_height_km), 2)) + end +end diff --git a/test/microwaveprop/propagation/hf_muf_test.exs b/test/microwaveprop/propagation/hf_muf_test.exs new file mode 100644 index 00000000..73142db8 --- /dev/null +++ b/test/microwaveprop/propagation/hf_muf_test.exs @@ -0,0 +1,99 @@ +defmodule Microwaveprop.Propagation.HfMufTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Propagation.HfMuf + + describe "adjust_mufd/2" do + test "returns 0.0 for nil or non-positive inputs" do + assert HfMuf.adjust_mufd(nil, 2000) == 0.0 + assert HfMuf.adjust_mufd(23.0, nil) == 0.0 + assert HfMuf.adjust_mufd(0.0, 2000) == 0.0 + assert HfMuf.adjust_mufd(23.0, 0) == 0.0 + end + + test "is a no-op at the reference distance (3000 km)" do + # MUFD is *defined* at the 3000 km GIRO DIDBase reference distance. + assert_in_delta HfMuf.adjust_mufd(23.0, 3000), 23.0, 0.001 + end + + test "shorter paths have lower MUF (closer to vertical incidence)" do + short = HfMuf.adjust_mufd(23.0, 1500) + medium = HfMuf.adjust_mufd(23.0, 2500) + ref = HfMuf.adjust_mufd(23.0, 3000) + assert short < medium + assert medium < ref + end + + test "longer paths within single-hop window have higher MUF" do + ref = HfMuf.adjust_mufd(23.0, 3000) + long = HfMuf.adjust_mufd(23.0, 4000) + very_long = HfMuf.adjust_mufd(23.0, 5000) + assert long > ref + assert very_long > long + end + + test "vertical-incidence limit approaches foF2 (roughly 1/sec(i_ref) × MUFD)" do + # As distance → 0 the ray is vertical and MUF → foF2. + # For h=300, sec(i_ref) ≈ 5.10, so at D=0 the scaled MUFD should + # be about MUFD / 5.10 ≈ foF2. + tiny = HfMuf.adjust_mufd(23.0, 1) + assert_in_delta tiny, 23.0 / 5.099, 0.1 + end + end + + describe "fot/1" do + test "returns 0.85 × MUF" do + assert HfMuf.fot(20.0) == 17.0 + assert_in_delta HfMuf.fot(28.35), 24.1, 0.01 + end + + test "returns 0.0 for nil or non-positive inputs" do + assert HfMuf.fot(nil) == 0.0 + assert HfMuf.fot(0.0) == 0.0 + assert HfMuf.fot(-5.0) == 0.0 + end + end + + describe "hf_score/2" do + test "returns 0 for nil or non-positive MUF" do + assert HfMuf.hf_score(nil, 14.0) == 0 + assert HfMuf.hf_score(0.0, 14.0) == 0 + end + + test "returns 0 when band is well above MUF" do + # 28 MHz on a path with MUF=15 MHz — 10m is dead. + assert HfMuf.hf_score(15.0, 28.0) == 0 + end + + test "returns 100 when band is comfortably below FOT" do + # 7 MHz on a path with MUF=30 MHz — 40m is solid, way below FOT (25.5). + assert HfMuf.hf_score(30.0, 7.0) == 100 + end + + test "returns 80 near FOT (85% of MUF)" do + # Band ≈ 0.85 × MUF should land on FOT bucket. + assert HfMuf.hf_score(20.0, 17.0) == 80 + end + + test "returns 50 between FOT and MUF" do + # Band at 0.95 × MUF is marginal. + assert HfMuf.hf_score(20.0, 19.0) == 50 + end + + test "returns 20 in the fringe zone slightly above MUF" do + # Band 10% above MUF — fading, spotty, but sometimes there. + assert HfMuf.hf_score(20.0, 22.0) == 20 + end + + test "is monotonic: higher MUF never decreases the score at a fixed band" do + # 14 MHz across a range of MUFs. + scores = Enum.map([10, 14, 16, 18, 25, 40], &HfMuf.hf_score(&1 * 1.0, 14.0)) + + # Each score must be ≥ the previous. + Enum.reduce(scores, 0, fn s, prev -> + assert s >= prev + s + end) + end + end +end