prop/test/microwaveprop/weather_test.exs
Graham McIntire 6489f08138
Add weather data schema, context, sounding params, and IEM ingestion
Store surface observations (ASOS) and upper-air soundings (RAOB) alongside
QSOs for atmospheric propagation correlation. Three new tables: weather_stations,
surface_observations, and soundings with JSONB profiles and pre-computed derived
parameters (refractivity, gradients, duct detection, stability indices).

Includes IEM API client for historical data import and import script seeded
with 95 ASOS + 9 sounding stations from PropCast coverage area.
2026-03-28 15:57:19 -05:00

199 lines
5.9 KiB
Elixir

defmodule Microwaveprop.WeatherTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Weather
@station_attrs %{
station_code: "KDFW",
station_type: "asos",
name: "Dallas/Fort Worth International",
lat: 32.8998,
lon: -97.0403,
elevation_m: 171.0,
state: "TX"
}
@sounding_station_attrs %{
station_code: "FWD",
station_type: "sounding",
wmo_number: 72_249,
name: "Fort Worth",
lat: 32.83,
lon: -97.30
}
describe "find_or_create_station/1" do
test "creates a new station" do
assert {:ok, station} = Weather.find_or_create_station(@station_attrs)
assert station.station_code == "KDFW"
assert station.station_type == "asos"
end
test "returns existing station if already created" do
{:ok, first} = Weather.find_or_create_station(@station_attrs)
{:ok, second} = Weather.find_or_create_station(@station_attrs)
assert first.id == second.id
end
test "returns error for invalid attributes" do
assert {:error, _changeset} = Weather.find_or_create_station(%{station_code: "X"})
end
end
describe "upsert_surface_observation/2" do
test "inserts a new observation" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
obs_attrs = %{
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 75.0,
dewpoint_f: 55.0,
sky_condition: "SCT"
}
assert {:ok, obs} = Weather.upsert_surface_observation(station, obs_attrs)
assert obs.temp_f == 75.0
assert obs.station_id == station.id
end
test "updates existing observation on conflict" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
obs_attrs = %{
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 75.0,
sky_condition: "SCT"
}
{:ok, first} = Weather.upsert_surface_observation(station, obs_attrs)
{:ok, second} = Weather.upsert_surface_observation(station, %{obs_attrs | temp_f: 78.0})
assert first.id == second.id
assert second.temp_f == 78.0
end
end
describe "upsert_sounding/2" do
@sample_profile [
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10},
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 15},
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0, "drct" => 220, "sknt" => 20}
]
test "inserts a new sounding" do
{:ok, station} = Weather.find_or_create_station(@sounding_station_attrs)
sounding_attrs = %{
observed_at: ~U[2026-03-28 12:00:00Z],
profile: @sample_profile,
level_count: 3,
surface_temp_c: 25.0
}
assert {:ok, sounding} = Weather.upsert_sounding(station, sounding_attrs)
assert sounding.level_count == 3
assert sounding.station_id == station.id
end
test "updates existing sounding on conflict" do
{:ok, station} = Weather.find_or_create_station(@sounding_station_attrs)
sounding_attrs = %{
observed_at: ~U[2026-03-28 12:00:00Z],
profile: @sample_profile,
level_count: 3,
surface_temp_c: 25.0
}
{:ok, first} = Weather.upsert_sounding(station, sounding_attrs)
{:ok, second} = Weather.upsert_sounding(station, %{sounding_attrs | surface_temp_c: 26.0})
assert first.id == second.id
assert second.surface_temp_c == 26.0
end
end
describe "weather_for_qso/2" do
test "returns nearby surface observations within time window" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
{:ok, _obs} =
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 75.0
})
# QSO near the station in space and time
result =
Weather.weather_for_qso(
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 18:30:00Z]},
radius_km: 100,
time_window_hours: 3
)
assert length(result.surface_observations) == 1
assert hd(result.surface_observations).temp_f == 75.0
end
test "returns nearby soundings within time window" do
{:ok, station} = Weather.find_or_create_station(@sounding_station_attrs)
{:ok, _sounding} =
Weather.upsert_sounding(station, %{
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10}],
level_count: 1,
surface_temp_c: 25.0
})
result =
Weather.weather_for_qso(
%{lat: 32.85, lon: -97.28, timestamp: ~U[2026-03-28 14:00:00Z]},
radius_km: 100,
time_window_hours: 6
)
assert length(result.soundings) == 1
end
test "excludes observations outside radius" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
{:ok, _obs} =
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 75.0
})
# QSO far away
result =
Weather.weather_for_qso(
%{lat: 40.0, lon: -80.0, timestamp: ~U[2026-03-28 18:00:00Z]},
radius_km: 100,
time_window_hours: 3
)
assert result.surface_observations == []
end
test "excludes observations outside time window" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
{:ok, _obs} =
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2026-03-28 06:00:00Z],
temp_f: 75.0
})
# QSO much later
result =
Weather.weather_for_qso(
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 22:00:00Z]},
radius_km: 100,
time_window_hours: 3
)
assert result.surface_observations == []
end
end
end