prop/test/microwaveprop/propagation/sporadic_e_test.exs
Graham McIntire f632ea83bd
Sporadic-E physics module + Ionosphere.nearest_foes
Adds the missing link between the GIRO ionosonde data we just started
ingesting and the VHF/UHF band scoring:

* `Microwaveprop.Propagation.SporadicE` — ITU-R P.534-6 / Davies 1990
  thin-layer Es MUF (sec(i) · foEs with h=110 km), plus an `es_score/3`
  that maps (foEs, target band, hop distance) into a 0-100 single-hop
  propagation likelihood. Calibrated against literature: 50 MHz Es at
  2000 km needs foEs ≳ 5.5 MHz (routine summer); 144 MHz needs
  foEs ≳ 15.8 MHz (rare Jun/Jul peaks only); 440 MHz Es does not occur
  at physical foEs values. Multi-hop Es and Es-scatter are separate
  factors and explicitly out of scope.

* `Ionosphere.nearest_foes/3` — given a lat/lon, returns the latest
  observation from the nearest polled GIRO station (Millstone Hill or
  Alpena for now), with a configurable staleness cutoff (default 2h).
  Returns `{:error, :stale}` or `{:error, :no_data}` so callers can
  choose whether to apply the Es factor at all.

Neither is wired into the grid scorer yet — that's a separate commit
so the integration can be reviewed on its own.
2026-04-15 14:43:48 -05:00

86 lines
3.5 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.SporadicETest do
use ExUnit.Case, async: true
alias Microwaveprop.Propagation.SporadicE
describe "single_hop_muf/2" do
test "returns 0.0 for nil or non-positive inputs" do
assert SporadicE.single_hop_muf(nil, 2000) == 0.0
assert SporadicE.single_hop_muf(5.0, nil) == 0.0
assert SporadicE.single_hop_muf(0.0, 2000) == 0.0
assert SporadicE.single_hop_muf(5.0, 0) == 0.0
assert SporadicE.single_hop_muf(-1.0, 2000) == 0.0
end
test "matches the thin-layer secant-of-incidence formula for known distances" do
# h = 110 km. sec(i) = sqrt(1 + (D/(2h))^2). Values cross-checked
# against literature (Davies 1990, Ionospheric Radio Eq. 6.26).
# At D=2000 km, sec(i) ≈ 9.146, so foEs × 9.146 MHz.
assert_in_delta SporadicE.single_hop_muf(10.0, 2000), 10.0 * 9.146, 0.05
assert_in_delta SporadicE.single_hop_muf(5.0, 1000), 5.0 * 4.654, 0.05
assert_in_delta SporadicE.single_hop_muf(8.0, 1500), 8.0 * 6.891, 0.05
end
test "degenerate short range approaches foEs (vertical incidence)" do
# As D → 0 the ray is nearly vertical and MUF → foEs.
assert_in_delta SporadicE.single_hop_muf(5.0, 1), 5.0, 0.001
end
end
describe "es_score/3" do
test "is 0 when foEs is nil or zero" do
assert SporadicE.es_score(nil, 144, 2000) == 0
assert SporadicE.es_score(0.0, 144, 2000) == 0
end
test "is 0 for paths shorter than the single-hop minimum" do
# Es geometry doesn't support paths much under ~500 km (the
# elevation angle is too steep for the layer to reflect a VHF
# signal regardless of foEs).
assert SporadicE.es_score(20.0, 144, 100) == 0
assert SporadicE.es_score(20.0, 144, 400) == 0
end
test "is 0 for paths beyond the single-hop maximum (~2500 km)" do
# Beyond ~2500 km the geometry exits the single-hop window. We
# don't model multi-hop Es here — that's a different factor.
assert SporadicE.es_score(20.0, 144, 3000) == 0
end
test "scores 50 MHz as ROUTINE when foEs is typical summer Es (6-8 MHz) at 2000 km" do
# foEs 6 MHz × sec(i) at 2000 km ≈ 54.9 MHz, just above 50 MHz.
# 50 MHz Es at 2000 km with foEs=6 is a classic summer opening —
# should score high.
score = SporadicE.es_score(6.0, 50, 2000)
assert score >= 80
end
test "scores 144 MHz as NONE when foEs is typical summer Es" do
# foEs=6 at 2000 km ≈ 54.9 MHz MUF — well below 144 MHz.
# Typical summer Es doesn't propagate 2m.
assert SporadicE.es_score(6.0, 144, 2000) == 0
end
test "scores 144 MHz as VIABLE when foEs reaches intense-Es levels (16+ MHz) at 2000 km" do
# foEs=16 × 9.15 ≈ 146 MHz MUF → 2m Es is feasible. These are
# rare but documented events (June/July peaks).
score = SporadicE.es_score(16.0, 144, 2000)
assert score >= 50
end
test "scores 440 MHz as NONE even at extreme foEs (sporadic-E does not reach 70cm)" do
# foEs would need to be ~48 MHz to get 440 MHz via single-hop Es
# — way beyond any physically observed value.
assert SporadicE.es_score(25.0, 440, 2000) == 0
end
test "higher foEs strictly increases the score at a given band/distance (monotonic)" do
weak = SporadicE.es_score(10.0, 50, 2000)
mid = SporadicE.es_score(14.0, 50, 2000)
strong = SporadicE.es_score(20.0, 50, 2000)
assert weak <= mid
assert mid <= strong
assert strong == 100
end
end
end