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.
131 lines
4 KiB
Elixir
131 lines
4 KiB
Elixir
defmodule Microwaveprop.IonosphereTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Ionosphere
|
|
alias Microwaveprop.Ionosphere.Observation
|
|
|
|
@valid_attrs %{
|
|
station_code: "MHJ45",
|
|
valid_time: ~U[2026-04-15 18:00:00Z],
|
|
confidence_score: 80,
|
|
fo_f2_mhz: 7.113,
|
|
fo_e_mhz: 3.10,
|
|
fo_es_mhz: 3.08,
|
|
hm_f2_km: 242.0,
|
|
mufd_mhz: 23.00
|
|
}
|
|
|
|
describe "upsert_observations/2" do
|
|
test "inserts new observations and returns the inserted count" do
|
|
assert {:ok, 2} =
|
|
Ionosphere.upsert_observations("MHJ45", [
|
|
Map.delete(@valid_attrs, :station_code),
|
|
@valid_attrs
|
|
|> Map.delete(:station_code)
|
|
|> Map.put(:valid_time, ~U[2026-04-15 18:07:30Z])
|
|
])
|
|
|
|
assert Repo.aggregate(Observation, :count, :id) == 2
|
|
end
|
|
|
|
test "re-upserting the same (station, valid_time) replaces fields and does not duplicate" do
|
|
{:ok, 1} = Ionosphere.upsert_observations("MHJ45", [Map.delete(@valid_attrs, :station_code)])
|
|
|
|
updated =
|
|
@valid_attrs
|
|
|> Map.delete(:station_code)
|
|
|> Map.put(:fo_es_mhz, 9.5)
|
|
|
|
assert {:ok, 1} = Ionosphere.upsert_observations("MHJ45", [updated])
|
|
assert Repo.aggregate(Observation, :count, :id) == 1
|
|
|
|
[row] = Repo.all(Observation)
|
|
assert row.fo_es_mhz == 9.5
|
|
end
|
|
|
|
test "stamps station_code on each observation" do
|
|
{:ok, 1} =
|
|
Ionosphere.upsert_observations("AL945", [
|
|
Map.delete(@valid_attrs, :station_code)
|
|
])
|
|
|
|
[row] = Repo.all(Observation)
|
|
assert row.station_code == "AL945"
|
|
end
|
|
|
|
test "returns 0 and does nothing for an empty list" do
|
|
assert {:ok, 0} = Ionosphere.upsert_observations("MHJ45", [])
|
|
assert Repo.aggregate(Observation, :count, :id) == 0
|
|
end
|
|
end
|
|
|
|
describe "latest_observation/1" do
|
|
test "returns the most recent observation for a station" do
|
|
older = Map.delete(@valid_attrs, :station_code)
|
|
|
|
newer =
|
|
@valid_attrs
|
|
|> Map.delete(:station_code)
|
|
|> Map.put(:valid_time, ~U[2026-04-15 19:00:00Z])
|
|
|> Map.put(:fo_es_mhz, 10.0)
|
|
|
|
{:ok, _} = Ionosphere.upsert_observations("MHJ45", [older, newer])
|
|
|
|
row = Ionosphere.latest_observation("MHJ45")
|
|
assert row.valid_time == ~U[2026-04-15 19:00:00Z]
|
|
assert row.fo_es_mhz == 10.0
|
|
end
|
|
|
|
test "returns nil when no observations exist for the station" 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
|