prop/test/microwaveprop/propagation/hf_muf_test.exs
Graham McIntire 6d69989599
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.
2026-04-15 14:59:39 -05:00

99 lines
3.1 KiB
Elixir
Raw 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.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