HfMuf physics: distance-adjusted MUF from GIRO MUFD(3000)

First HF prediction building block. GIRO publishes MUFD — the F2-layer
maximum usable frequency at the 3000 km reference distance — directly
from station measurements, already calibrated against CCIR M(3000)F2
climatology. Rather than recomputing MUF from scratch (which would
require the ~2 MB CCIR coefficient tables), 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.

That keeps the measurement calibration (~3.3× foF2 at our fixture, vs
~5.1× from a naive thin-layer formula) and only uses the thin-layer
math for the relative distance correction.

* adjust_mufd/2 — scales MUFD(3000) to an arbitrary hop distance.
  At D=3000 it's a no-op; shorter paths get lower MUF (near-vertical),
  longer paths within the single-hop window get higher MUF.
* fot/1 — standard 0.85 × MUF Frequency of Optimum Traffic.
* hf_score/2 — 0-100 band-vs-MUF score with 5-tier curve:
  100 (≤ 0.75×MUF) / 80 (FOT) / 50 (≤ MUF) / 20 (fringe) / 0 (dead).

Limitations are in the moduledoc: single-hop only, nearest-station
bias, no LUF/D-layer absorption, no Kp geomagnetic storm modeling.
Those are separate commits once this has bake time.

Not yet wired into PathLive — follow-up commit will add HF bands to
BandConfig and wire the panel.
This commit is contained in:
Graham McIntire 2026-04-15 14:59:38 -05:00
parent fa2c9643c9
commit 6d69989599
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 192 additions and 0 deletions

View file

@ -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 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

View file

@ -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