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.
199 lines
5.9 KiB
Elixir
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
|