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