Adds the second ionospheric data source layer. Polls NOAA SWPC's free public JSON services every 5 minutes for three products: * Planetary K-index (1-min cadence) → geomagnetic_observations * 10.7 cm solar flux (hourly) → solar_flux_observations * GOES X-ray flux (1-min, 0.1-0.8nm long-wave band only) → solar_xray_observations All three products feed HF / Es scoring inputs that the propagation engine will start using in a follow-up commit: * Kp drives HF absorption and Es damping (memory notes Kp is inversely correlated with tropo/Es stability). * F10.7 is the SSN proxy for ITU-R P.533 HF MUF prediction. * GOES X-ray long-wave flux is the short-wave fade (SWF) / D-layer absorption proxy — a C-class flare starts attenuating HF within seconds of the X-ray peak. New context `Microwaveprop.SpaceWeather` exposes `upsert_*/1` and `latest_*/0` per product; `SpaceWeatherFetchWorker` pulls all three independently so one endpoint's outage doesn't block the others. Fixtures captured from live SWPC endpoints on 2026-04-15 for the parser tests. Not yet wired into scoring — that's a separate commit.
63 lines
2.1 KiB
Elixir
63 lines
2.1 KiB
Elixir
defmodule Microwaveprop.SpaceWeather.SwpcClientTest do
|
||
use ExUnit.Case, async: true
|
||
|
||
alias Microwaveprop.SpaceWeather.SwpcClient
|
||
|
||
describe "parse_kp/1" do
|
||
test "decodes the SWPC planetary_k_index_1m fixture" do
|
||
body = File.read!("test/fixtures/swpc/kp_sample.json")
|
||
|
||
assert {:ok, rows} = SwpcClient.parse_kp(body)
|
||
assert length(rows) == 10
|
||
|
||
first = hd(rows)
|
||
assert first.valid_time == ~U[2026-04-15 13:49:00Z]
|
||
assert first.kp_index == 0
|
||
assert_in_delta first.estimated_kp, 0.33, 0.001
|
||
end
|
||
|
||
test "returns {:error, _} for malformed bodies" do
|
||
assert {:error, _} = SwpcClient.parse_kp("not json")
|
||
assert {:error, _} = SwpcClient.parse_kp(~s({"not": "an array"}))
|
||
end
|
||
end
|
||
|
||
describe "parse_f107/1" do
|
||
test "decodes the SWPC f107_cm_flux fixture" do
|
||
body = File.read!("test/fixtures/swpc/f107_sample.json")
|
||
|
||
assert {:ok, rows} = SwpcClient.parse_f107(body)
|
||
assert length(rows) == 10
|
||
|
||
first = hd(rows)
|
||
assert first.valid_time == ~U[2026-04-15 17:00:00Z]
|
||
assert first.frequency_mhz == 2800
|
||
assert first.flux_sfu == 112.0
|
||
assert first.reporting_schedule == "Morning"
|
||
end
|
||
|
||
test "keeps nullable historical stats as nil when absent" do
|
||
body = File.read!("test/fixtures/swpc/f107_sample.json")
|
||
{:ok, [first | _]} = SwpcClient.parse_f107(body)
|
||
assert first.ninety_day_mean == nil
|
||
end
|
||
end
|
||
|
||
describe "parse_xrays/1" do
|
||
test "decodes the GOES xrays-1-day fixture, keeping the 0.1-0.8nm band only" do
|
||
body = File.read!("test/fixtures/swpc/xrays_sample.json")
|
||
|
||
assert {:ok, rows} = SwpcClient.parse_xrays(body)
|
||
# Fixture has 12 records (6 timestamps × 2 bands). We keep the
|
||
# long-wavelength band (0.1-0.8nm) which is the standard SWF /
|
||
# D-layer absorption proxy.
|
||
assert length(rows) == 6
|
||
assert Enum.all?(rows, fn r -> r.energy_band == "0.1-0.8nm" end)
|
||
|
||
first = hd(rows)
|
||
assert first.valid_time == ~U[2026-04-14 19:49:00Z]
|
||
assert first.satellite == 18
|
||
assert_in_delta first.flux_wm2, 3.523e-07, 1.0e-09
|
||
end
|
||
end
|
||
end
|