- Radio context with QSO schema and CSV import script (58K contacts) - Solar index schema, GFZ client, and daily Oban cron worker - LiveView dashboard with QSO table and weather correlation views - Parallelize weather import script using Task.async_stream (10 concurrent workers) - Replace sequential rate_limit sleeps with concurrency-based backpressure - Atomic progress counter for interleave-safe reporting
340 lines
10 KiB
Elixir
340 lines
10 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 with station preloaded" 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
|
|
obs = hd(result.surface_observations)
|
|
assert obs.temp_f == 75.0
|
|
assert obs.station.station_code == "KDFW"
|
|
end
|
|
|
|
test "returns nearby soundings within time window with station preloaded" 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
|
|
assert hd(result.soundings).station.station_code == "FWD"
|
|
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
|
|
|
|
describe "has_surface_observations?/3" do
|
|
test "returns true when observations exist in the time window" do
|
|
{:ok, station} = Weather.find_or_create_station(@station_attrs)
|
|
|
|
Weather.upsert_surface_observation(station, %{
|
|
observed_at: ~U[2026-03-28 18:00:00Z],
|
|
temp_f: 75.0
|
|
})
|
|
|
|
assert Weather.has_surface_observations?(
|
|
station.id,
|
|
~U[2026-03-28 16:00:00Z],
|
|
~U[2026-03-28 20:00:00Z]
|
|
)
|
|
end
|
|
|
|
test "returns false when no observations exist in the time window" do
|
|
{:ok, station} = Weather.find_or_create_station(@station_attrs)
|
|
|
|
refute Weather.has_surface_observations?(
|
|
station.id,
|
|
~U[2026-03-28 16:00:00Z],
|
|
~U[2026-03-28 20:00:00Z]
|
|
)
|
|
end
|
|
|
|
test "returns false when observations are outside the time window" do
|
|
{:ok, station} = Weather.find_or_create_station(@station_attrs)
|
|
|
|
Weather.upsert_surface_observation(station, %{
|
|
observed_at: ~U[2026-03-28 06:00:00Z],
|
|
temp_f: 60.0
|
|
})
|
|
|
|
refute Weather.has_surface_observations?(
|
|
station.id,
|
|
~U[2026-03-28 16:00:00Z],
|
|
~U[2026-03-28 20:00:00Z]
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "has_sounding?/2" do
|
|
test "returns true when a sounding exists at the given time" do
|
|
{:ok, station} = Weather.find_or_create_station(@sounding_station_attrs)
|
|
|
|
Weather.upsert_sounding(station, %{
|
|
observed_at: ~U[2026-03-28 12:00:00Z],
|
|
profile: [%{"pres" => 1013.0, "tmpc" => 25.0}],
|
|
level_count: 1
|
|
})
|
|
|
|
assert Weather.has_sounding?(station.id, ~U[2026-03-28 12:00:00Z])
|
|
end
|
|
|
|
test "returns false when no sounding exists at the given time" do
|
|
{:ok, station} = Weather.find_or_create_station(@sounding_station_attrs)
|
|
|
|
refute Weather.has_sounding?(station.id, ~U[2026-03-28 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "existing_solar_dates/0" do
|
|
test "returns set of dates already in the database" do
|
|
Weather.upsert_solar_index(%{date: ~D[2024-06-15], sfi: 150.2})
|
|
Weather.upsert_solar_index(%{date: ~D[2024-06-16], sfi: 145.0})
|
|
|
|
dates = Weather.existing_solar_dates()
|
|
assert MapSet.member?(dates, ~D[2024-06-15])
|
|
assert MapSet.member?(dates, ~D[2024-06-16])
|
|
refute MapSet.member?(dates, ~D[2024-06-17])
|
|
end
|
|
|
|
test "returns empty set when no solar indices exist" do
|
|
assert Weather.existing_solar_dates() == MapSet.new()
|
|
end
|
|
end
|
|
|
|
describe "get_solar_index/1" do
|
|
test "returns the solar index record for a given date" do
|
|
{:ok, record} = Weather.upsert_solar_index(%{date: ~D[2024-06-15], sfi: 150.2, sunspot_number: 120})
|
|
|
|
found = Weather.get_solar_index(~D[2024-06-15])
|
|
assert found.id == record.id
|
|
assert found.sfi == 150.2
|
|
end
|
|
|
|
test "returns nil when no record exists" do
|
|
assert Weather.get_solar_index(~D[2024-01-01]) == nil
|
|
end
|
|
end
|
|
|
|
describe "upsert_solar_index/1" do
|
|
test "inserts a new solar index record" do
|
|
attrs = %{
|
|
date: ~D[2024-06-15],
|
|
sfi: 150.2,
|
|
sfi_adjusted: 148.5,
|
|
sunspot_number: 120,
|
|
ap_index: 8,
|
|
kp_values: [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667]
|
|
}
|
|
|
|
assert {:ok, record} = Weather.upsert_solar_index(attrs)
|
|
assert record.date == ~D[2024-06-15]
|
|
assert record.sfi == 150.2
|
|
assert record.sunspot_number == 120
|
|
assert record.ap_index == 8
|
|
assert length(record.kp_values) == 8
|
|
end
|
|
|
|
test "updates existing record on date conflict" do
|
|
attrs = %{
|
|
date: ~D[2024-06-15],
|
|
sfi: 150.2,
|
|
sfi_adjusted: 148.5,
|
|
sunspot_number: 120,
|
|
ap_index: 8,
|
|
kp_values: [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667]
|
|
}
|
|
|
|
{:ok, first} = Weather.upsert_solar_index(attrs)
|
|
{:ok, second} = Weather.upsert_solar_index(%{attrs | sfi: 155.0})
|
|
|
|
assert first.id == second.id
|
|
assert second.sfi == 155.0
|
|
end
|
|
|
|
test "inserts record with nil optional fields" do
|
|
attrs = %{date: ~D[1932-01-01], sfi: nil, sfi_adjusted: nil, sunspot_number: 22, ap_index: 15}
|
|
|
|
assert {:ok, record} = Weather.upsert_solar_index(attrs)
|
|
assert record.date == ~D[1932-01-01]
|
|
assert is_nil(record.sfi)
|
|
assert record.sunspot_number == 22
|
|
end
|
|
end
|
|
end
|