First step toward physics-based HF / sporadic-E scoring. Polls GIRO's DIDBase tabular endpoint every 10 minutes for two US ionosondes (Millstone Hill MHJ45, Alpena AL945), parses the plain-text response into observation rows, and upserts into a new ionosonde_observations table keyed on (station_code, valid_time). This gets foEs (E-layer sporadic critical frequency) into the database as a direct measurement — the key input for predicting 144 MHz Es openings via ITU-R P.534-6. foF2 + MUFD are the F2-layer inputs for HF MUF predictions. Not yet wired into scoring. Boulder/Wallops/Austin/Idaho/Point Arguello are in the GIRO catalog but were silent when probed — add them back if/when they come online. Next steps: SWPC JSON (Kp, F10.7, sunspot), GOES X-ray flux, D-RAP text, and the P.534-6 Es scoring factor that uses foEs at midpoint for the 144/440 band configs.
69 lines
2.4 KiB
Elixir
69 lines
2.4 KiB
Elixir
defmodule Microwaveprop.Ionosphere.GiroClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Ionosphere.GiroClient
|
|
|
|
describe "parse_tabular/1" do
|
|
test "decodes the real Millstone Hill fixture into observation maps" do
|
|
body = File.read!("test/fixtures/giro/mhj45_sample.txt")
|
|
|
|
assert {:ok, rows} = GiroClient.parse_tabular(body)
|
|
|
|
# The fixture is 2026-04-15 16:00 → 19:00 UTC at 7.5-minute cadence,
|
|
# so 25 observations.
|
|
assert length(rows) == 25
|
|
|
|
first = hd(rows)
|
|
assert first.valid_time == ~U[2026-04-15 16:00:00Z]
|
|
assert first.confidence_score == 60
|
|
assert_in_delta first.fo_f2_mhz, 6.856, 0.001
|
|
assert_in_delta first.mufd_mhz, 23.63, 0.001
|
|
# foEs was missing ("---") at 16:00.
|
|
assert first.fo_es_mhz == nil
|
|
assert_in_delta first.fo_e_mhz, 3.50, 0.001
|
|
assert_in_delta first.hm_f2_km, 217.2, 0.1
|
|
|
|
# 16:07:30 has foEs == 3.42.
|
|
second = Enum.at(rows, 1)
|
|
assert second.valid_time == ~U[2026-04-15 16:07:30Z]
|
|
assert_in_delta second.fo_es_mhz, 3.42, 0.001
|
|
|
|
last = List.last(rows)
|
|
assert last.valid_time == ~U[2026-04-15 19:00:00Z]
|
|
assert_in_delta last.fo_f2_mhz, 7.400, 0.001
|
|
end
|
|
|
|
test "returns {:ok, []} for a body with only the header" do
|
|
body = """
|
|
# Global Ionospheric Radio Observatory
|
|
# Location: GEO 42.6N 288.5E, URSI-Code MHJ45 MILLSTONE HILL
|
|
#
|
|
#Time CS foF2 QD MUFD QD foEs QD foE QD hmF2 QD
|
|
"""
|
|
|
|
assert {:ok, []} = GiroClient.parse_tabular(body)
|
|
end
|
|
|
|
test "returns {:error, :no_header} when the column header is missing" do
|
|
body = """
|
|
# some junk
|
|
2026-04-15T18:00:00.000Z 80 7.113 // 23.00 //
|
|
"""
|
|
|
|
assert {:error, :no_header} = GiroClient.parse_tabular(body)
|
|
end
|
|
|
|
test "skips data lines whose token count doesn't match the header" do
|
|
# Header declares 5 value columns (10 tokens after time+CS) but the
|
|
# data row is truncated. Should be skipped rather than crashing.
|
|
body = """
|
|
#Time CS foF2 QD MUFD QD foEs QD foE QD hmF2 QD
|
|
2026-04-15T18:00:00.000Z 80 7.113 //
|
|
2026-04-15T18:07:30.000Z 80 7.088 // 22.76 // 3.35 // 3.13 // 246.0 //
|
|
"""
|
|
|
|
assert {:ok, [row]} = GiroClient.parse_tabular(body)
|
|
assert row.valid_time == ~U[2026-04-15 18:07:30Z]
|
|
end
|
|
end
|
|
end
|