prop/lib/microwaveprop/propagation/hf_muf.ex
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

93 lines
3.4 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.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