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.
86 lines
3.5 KiB
Elixir
86 lines
3.5 KiB
Elixir
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
|