prop/lib/microwaveprop/propagation/hf_muf.ex
Graham McIntire cc3dc41a6b
Fix all medium findings and split contact_live_test.exs
- Fix N+1 queries in radio.ex (preload :user)
- Add encryption_salt to session options
- Consolidate token deletion to single query
- Wrap gunzip in try/rescue for corrupt files
- Add Cache.match_delete/1 to encapsulate ETS access
- Precompute sec_i_factor_ref as module attribute
- Guard EXLA.Backend config to dev/test only
- Split 3505-line contact_live_test.exs into 4 files
- Replace Process.sleep with :sys.get_state synchronization
- Replace Process.alive? with DOM output assertions
- Clarify router.ex comment for non-live_session routes
- Update findings.md to reflect fixes
2026-05-29 15:53:04 -05:00

94 lines
3.5 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
@sec_i_factor_ref :math.sqrt(1 + :math.pow(@ref_distance_km / (2 * @f2_layer_height_km), 2))
# 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
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 0100 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