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.
This commit is contained in:
parent
8afbe1932f
commit
f632ea83bd
4 changed files with 282 additions and 0 deletions
|
|
@ -49,4 +49,68 @@ defmodule Microwaveprop.Ionosphere do
|
|||
limit: 1
|
||||
)
|
||||
end
|
||||
|
||||
# Station coordinates for the nearest-lookup. Must stay in sync with
|
||||
# `IonosphereFetchWorker.stations/0` — these are the stations we're
|
||||
# actively polling. Kept here (rather than imported) to avoid a
|
||||
# context → worker dependency.
|
||||
@stations [
|
||||
%{code: "MHJ45", lat: 42.6, lon: -71.5},
|
||||
%{code: "AL945", lat: 45.1, lon: -83.6}
|
||||
]
|
||||
|
||||
@default_max_age_seconds 2 * 3600
|
||||
|
||||
@doc """
|
||||
Look up the nearest polled ionosonde station's latest observation for
|
||||
the given lat/lon.
|
||||
|
||||
Options:
|
||||
- `:max_age_seconds` — reject observations older than this (default 2h).
|
||||
Returns `{:error, :stale}` if the nearest station has no fresh data.
|
||||
|
||||
Returns `{:ok, observation}`, `{:error, :stale}`, or `{:error, :no_data}`.
|
||||
"""
|
||||
@spec nearest_foes(number(), number(), keyword()) ::
|
||||
{:ok, Observation.t()} | {:error, :stale | :no_data}
|
||||
def nearest_foes(lat, lon, opts \\ []) do
|
||||
max_age = Keyword.get(opts, :max_age_seconds, @default_max_age_seconds)
|
||||
nearest_station = nearest_station_to(lat, lon)
|
||||
|
||||
case latest_observation(nearest_station.code) do
|
||||
nil ->
|
||||
{:error, :no_data}
|
||||
|
||||
%Observation{valid_time: vt} = obs ->
|
||||
age = DateTime.diff(DateTime.utc_now(), vt, :second)
|
||||
|
||||
if age <= max_age do
|
||||
{:ok, obs}
|
||||
else
|
||||
{:error, :stale}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp nearest_station_to(lat, lon) do
|
||||
Enum.min_by(@stations, fn s -> haversine_km(lat, lon, s.lat, s.lon) end)
|
||||
end
|
||||
|
||||
# Standard haversine distance in km. Good enough for station-pick
|
||||
# ranking — we're not computing hop geometry here.
|
||||
defp haversine_km(lat1, lon1, lat2, lon2) do
|
||||
r = 6371.0
|
||||
dlat = :math.pi() * (lat2 - lat1) / 180
|
||||
dlon = :math.pi() * (lon2 - lon1) / 180
|
||||
lat1_rad = :math.pi() * lat1 / 180
|
||||
lat2_rad = :math.pi() * lat2 / 180
|
||||
|
||||
a =
|
||||
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
|
||||
:math.cos(lat1_rad) * :math.cos(lat2_rad) *
|
||||
:math.sin(dlon / 2) * :math.sin(dlon / 2)
|
||||
|
||||
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
|
||||
r * c
|
||||
end
|
||||
end
|
||||
|
|
|
|||
84
lib/microwaveprop/propagation/sporadic_e.ex
Normal file
84
lib/microwaveprop/propagation/sporadic_e.ex
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
defmodule Microwaveprop.Propagation.SporadicE do
|
||||
@moduledoc """
|
||||
Sporadic-E propagation physics for VHF scoring.
|
||||
|
||||
Given foEs (the E-layer critical frequency measured by an ionosonde,
|
||||
in MHz) and a ground-range distance in km, computes the single-hop
|
||||
Es MUF via the thin-layer obliquity factor from ITU-R P.534-6 /
|
||||
Davies 1990 *Ionospheric Radio*, Eq. 6.26:
|
||||
|
||||
MUF_Es = foEs · sec(i)
|
||||
|
||||
where `sec(i) = √(1 + (D / (2h))²)` with the reflection height
|
||||
`h = 110 km` (standard thin-E approximation).
|
||||
|
||||
The `es_score/3` function maps a `(foEs, target band, distance)`
|
||||
triple into a 0–100 propagation likelihood for **single-hop Es
|
||||
only**. Multi-hop Es (which extends the range but needs consecutive
|
||||
intense Es patches) and Es-scatter are not modelled here — those
|
||||
are separate factors.
|
||||
|
||||
Calibration notes:
|
||||
- 50 MHz Es at 2000 km needs foEs ≳ 5.5 MHz (routine summer Es).
|
||||
- 144 MHz Es at 2000 km needs foEs ≳ 15.8 MHz (rare, observed during
|
||||
major Jun/Jul peaks).
|
||||
- 440 MHz Es does not occur at physically observed foEs values.
|
||||
- Single-hop Es has a minimum useful range of ~500 km (steeper rays
|
||||
escape the layer rather than reflect) and a maximum of ~2500 km
|
||||
(the geometric horizon limit for a 110 km reflection height).
|
||||
"""
|
||||
|
||||
# Standard E-layer reflection height for the thin-layer approximation.
|
||||
@e_layer_height_km 110.0
|
||||
|
||||
@min_hop_km 500
|
||||
@max_hop_km 2500
|
||||
|
||||
@doc """
|
||||
Single-hop Es MUF in MHz. Returns 0.0 for nil/non-positive inputs.
|
||||
"""
|
||||
@spec single_hop_muf(number() | nil, number() | nil) :: float()
|
||||
def single_hop_muf(nil, _), do: 0.0
|
||||
def single_hop_muf(_, nil), do: 0.0
|
||||
def single_hop_muf(fo_es_mhz, _) when fo_es_mhz <= 0, do: 0.0
|
||||
def single_hop_muf(_, distance_km) when distance_km <= 0, do: 0.0
|
||||
|
||||
def single_hop_muf(fo_es_mhz, distance_km) do
|
||||
sec_i = :math.sqrt(1 + :math.pow(distance_km / (2 * @e_layer_height_km), 2))
|
||||
fo_es_mhz * sec_i
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a 0–100 score for single-hop Es propagation of `band_mhz`
|
||||
over a ground-range `distance_km`, given the measured `fo_es_mhz`.
|
||||
|
||||
- Short paths (< 500 km) and long paths (> 2500 km) are outside the
|
||||
single-hop window → 0.
|
||||
- `nil` or 0 foEs → 0.
|
||||
- Otherwise the score ramps with `MUF_Es / band_mhz`:
|
||||
≥ 1.20 → 100 (comfortably above the critical threshold)
|
||||
≥ 1.00 → 80 (just above, marginal Es)
|
||||
≥ 0.90 → 50 (close, occasional fringe)
|
||||
≥ 0.70 → 20 (weak fringe, unreliable)
|
||||
below → 0
|
||||
"""
|
||||
@spec es_score(number() | nil, number(), number() | nil) :: integer()
|
||||
def es_score(nil, _band, _distance), do: 0
|
||||
def es_score(_fo_es, _band, nil), do: 0
|
||||
def es_score(fo_es_mhz, _band, _distance) when fo_es_mhz <= 0, do: 0
|
||||
|
||||
def es_score(_fo_es, _band, distance_km) when distance_km < @min_hop_km or distance_km > @max_hop_km, do: 0
|
||||
|
||||
def es_score(fo_es_mhz, band_mhz, distance_km) do
|
||||
muf = single_hop_muf(fo_es_mhz, distance_km)
|
||||
ratio = muf / band_mhz
|
||||
|
||||
cond do
|
||||
ratio >= 1.20 -> 100
|
||||
ratio >= 1.00 -> 80
|
||||
ratio >= 0.90 -> 50
|
||||
ratio >= 0.70 -> 20
|
||||
true -> 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -80,4 +80,52 @@ defmodule Microwaveprop.IonosphereTest do
|
|||
assert Ionosphere.latest_observation("MHJ45") == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "nearest_foes/3" do
|
||||
setup do
|
||||
recent = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
{:ok, _} =
|
||||
Ionosphere.upsert_observations("MHJ45", [
|
||||
%{valid_time: recent, fo_es_mhz: 7.5, fo_f2_mhz: 8.0, mufd_mhz: 25.0}
|
||||
])
|
||||
|
||||
{:ok, _} =
|
||||
Ionosphere.upsert_observations("AL945", [
|
||||
%{valid_time: recent, fo_es_mhz: 12.3, fo_f2_mhz: 7.5, mufd_mhz: 24.0}
|
||||
])
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "returns the nearest station's latest foEs for a grid point in New England" do
|
||||
# 42°N, -72°W is essentially on Millstone Hill (42.6, -71.5).
|
||||
assert {:ok, obs} = Ionosphere.nearest_foes(42.0, -72.0)
|
||||
assert obs.station_code == "MHJ45"
|
||||
assert obs.fo_es_mhz == 7.5
|
||||
end
|
||||
|
||||
test "returns the nearest station's latest foEs for a grid point in Michigan" do
|
||||
# 45°N, -84°W is next to Alpena (45.1, -83.6).
|
||||
assert {:ok, obs} = Ionosphere.nearest_foes(45.0, -84.0)
|
||||
assert obs.station_code == "AL945"
|
||||
assert obs.fo_es_mhz == 12.3
|
||||
end
|
||||
|
||||
test "returns {:error, :stale} when the nearest station's latest value is older than max_age" do
|
||||
stale = ~U[2020-01-01 00:00:00Z]
|
||||
|
||||
Microwaveprop.Repo.update_all(
|
||||
Observation,
|
||||
set: [valid_time: stale]
|
||||
)
|
||||
|
||||
assert {:error, :stale} = Ionosphere.nearest_foes(42.0, -72.0, max_age_seconds: 3600)
|
||||
end
|
||||
|
||||
test "returns {:error, :no_data} when no observations have been ingested" do
|
||||
Microwaveprop.Repo.delete_all(Observation)
|
||||
assert {:error, :no_data} = Ionosphere.nearest_foes(42.0, -72.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
86
test/microwaveprop/propagation/sporadic_e_test.exs
Normal file
86
test/microwaveprop/propagation/sporadic_e_test.exs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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
|
||||
Loading…
Add table
Reference in a new issue