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