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:
parent
fa2c9643c9
commit
6d69989599
2 changed files with 192 additions and 0 deletions
93
lib/microwaveprop/propagation/hf_muf.ex
Normal file
93
lib/microwaveprop/propagation/hf_muf.ex
Normal 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 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
|
||||
99
test/microwaveprop/propagation/hf_muf_test.exs
Normal file
99
test/microwaveprop/propagation/hf_muf_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue