prop/test/microwaveprop/weather_test.exs
Graham McIntire 254e64dedc
Rename qsos to contacts throughout codebase, keep DB table name
Rename all modules, functions, variables, routes, and UI text from
qso/qsos to contact/contacts. Database table stays as "qsos" to avoid
migration. Add /qsos -> /contacts redirects for old URLs.
2026-04-01 11:25:04 -05:00

714 lines
21 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_contact/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_contact(
%{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_contact(
%{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_contact(
%{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_contact(
%{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 "nearby_stations/4" do
test "returns stations within radius" do
{:ok, _near} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.90,
lon: -97.04
})
{:ok, _far} =
Weather.find_or_create_station(%{
station_code: "KORD",
station_type: "asos",
name: "Chicago O'Hare",
lat: 41.98,
lon: -87.90
})
stations = Weather.nearby_stations(32.85, -97.05, "asos", 50)
codes = Enum.map(stations, & &1.station_code)
assert "KDFW" in codes
refute "KORD" in codes
end
test "filters by station type" do
{:ok, _asos} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.90,
lon: -97.04
})
{:ok, _sounding} =
Weather.find_or_create_station(%{
station_code: "FWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
stations = Weather.nearby_stations(32.85, -97.10, "sounding", 100)
codes = Enum.map(stations, & &1.station_code)
assert "FWD" in codes
refute "KDFW" in codes
end
test "returns empty list when no stations in radius" do
{:ok, _} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.90,
lon: -97.04
})
assert Weather.nearby_stations(45.0, -80.0, "asos", 50) == []
end
end
describe "sounding_times_around/1" do
test "returns bracketing 00Z and 12Z for afternoon UTC" do
dt = ~U[2026-03-28 18:00:00Z]
times = Weather.sounding_times_around(dt)
assert times == [~U[2026-03-28 00:00:00Z], ~U[2026-03-28 12:00:00Z]]
end
test "returns bracketing times for morning UTC" do
dt = ~U[2026-03-28 06:00:00Z]
times = Weather.sounding_times_around(dt)
assert times == [~U[2026-03-27 12:00:00Z], ~U[2026-03-28 00:00:00Z]]
end
test "returns single time at exactly 00Z" do
dt = ~U[2026-03-28 00:00:00Z]
times = Weather.sounding_times_around(dt)
assert times == [~U[2026-03-27 12:00:00Z], ~U[2026-03-28 00:00:00Z]]
end
test "returns single time at exactly 12Z" do
dt = ~U[2026-03-28 12:00:00Z]
times = Weather.sounding_times_around(dt)
assert times == [~U[2026-03-28 00:00:00Z], ~U[2026-03-28 12:00:00Z]]
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
@hrrr_profile_attrs %{
valid_time: ~U[2026-03-28 18:00:00Z],
lat: 32.90,
lon: -97.04,
run_time: ~U[2026-03-28 18:00:00Z],
profile: [
%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0},
%{"pres" => 975.0, "hght" => 350, "tmpc" => 23.0, "dwpc" => 16.0}
],
hpbl_m: 1500.0,
pwat_mm: 25.0,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.5,
min_refractivity_gradient: -45.0,
ducting_detected: false
}
describe "upsert_hrrr_profile/1" do
test "inserts a new HRRR profile" do
assert {:ok, profile} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
assert profile.lat == 32.90
assert profile.hpbl_m == 1500.0
end
test "updates existing profile on conflict" do
{:ok, first} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
{:ok, second} = Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | hpbl_m: 2000.0})
assert first.id == second.id
assert second.hpbl_m == 2000.0
end
end
describe "has_hrrr_profile?/3" do
test "returns true when a profile exists at the given location and time" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
assert Weather.has_hrrr_profile?(32.90, -97.04, ~U[2026-03-28 18:00:00Z])
end
test "returns false when no profile exists" do
refute Weather.has_hrrr_profile?(32.90, -97.04, ~U[2026-03-28 18:00:00Z])
end
test "returns false for different location" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
refute Weather.has_hrrr_profile?(40.0, -80.0, ~U[2026-03-28 18:00:00Z])
end
end
describe "hrrr_for_contact/1" do
test "returns nearest HRRR profile for a QSO" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
contact = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profile = Weather.hrrr_for_contact(contact)
assert profile
assert profile.lat == 32.90
end
test "returns nil when no HRRR profiles exist nearby" do
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.hrrr_for_contact(contact) == nil
end
test "returns nil when QSO has no pos1" do
assert Weather.hrrr_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
end
end
describe "round_to_hrrr_grid/2" do
test "rounds coordinates to 2 decimal places" do
assert Weather.round_to_hrrr_grid(32.9047, -97.0382) == {32.90, -97.04}
end
test "handles negative values correctly" do
assert Weather.round_to_hrrr_grid(-33.456, -97.999) == {-33.46, -98.0}
end
end
describe "round_to_iemre_grid/2" do
test "rounds to nearest 0.125 degree" do
assert Weather.round_to_iemre_grid(32.9047, -97.0382) == {32.875, -97.0}
end
test "handles exact grid points" do
assert Weather.round_to_iemre_grid(33.0, -97.125) == {33.0, -97.125}
end
test "handles negative values correctly" do
assert Weather.round_to_iemre_grid(-33.44, -97.99) == {-33.5, -98.0}
end
end
@iemre_attrs %{
lat: 32.875,
lon: -97.0,
date: ~D[2026-03-28],
hourly: [%{"hour" => 0, "p01m_mm" => 0.0}]
}
describe "upsert_iemre_observation/1" do
test "inserts a new IEMRE observation" do
assert {:ok, obs} = Weather.upsert_iemre_observation(@iemre_attrs)
assert obs.lat == 32.875
assert obs.date == ~D[2026-03-28]
end
test "updates existing observation on conflict" do
{:ok, first} = Weather.upsert_iemre_observation(@iemre_attrs)
{:ok, second} =
Weather.upsert_iemre_observation(%{
@iemre_attrs
| hourly: [%{"hour" => 0, "p01m_mm" => 1.5}]
})
assert first.id == second.id
assert hd(second.hourly)["p01m_mm"] == 1.5
end
end
describe "has_iemre_observation?/3" do
test "returns true when observation exists" do
Weather.upsert_iemre_observation(@iemre_attrs)
assert Weather.has_iemre_observation?(32.875, -97.0, ~D[2026-03-28])
end
test "returns false when no observation exists" do
refute Weather.has_iemre_observation?(32.875, -97.0, ~D[2026-03-28])
end
test "returns false for different location" do
Weather.upsert_iemre_observation(@iemre_attrs)
refute Weather.has_iemre_observation?(40.0, -80.0, ~D[2026-03-28])
end
end
describe "hrrr_profiles_for_path/1" do
test "returns HRRR profiles for all path points" do
# Profile at pos1
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
# Profile near pos2
Weather.upsert_hrrr_profile(%{
@hrrr_profile_attrs
| lat: 30.30,
lon: -97.70
})
contact = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
pos2: %{"lat" => 30.31, "lon" => -97.71},
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profiles = Weather.hrrr_profiles_for_path(contact)
assert length(profiles) >= 2
end
test "returns empty list when no HRRR profiles exist nearby" do
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: %{"lat" => 41.0, "lon" => -81.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.hrrr_profiles_for_path(contact) == []
end
test "returns profiles for pos1 only when pos2 is nil" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
contact = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
pos2: nil,
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profiles = Weather.hrrr_profiles_for_path(contact)
assert length(profiles) == 1
end
end
describe "iemre_for_path/1" do
test "returns IEMRE observations for all path points" do
Weather.upsert_iemre_observation(@iemre_attrs)
# Observation near pos2
Weather.upsert_iemre_observation(%{
lat: 30.25,
lon: -97.75,
date: ~D[2026-03-28],
hourly: [%{"hour" => 0, "p01m_mm" => 1.0}]
})
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
observations = Weather.iemre_for_path(contact)
assert length(observations) >= 2
end
test "returns empty list when no observations exist" do
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: %{"lat" => 41.0, "lon" => -81.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.iemre_for_path(contact) == []
end
test "returns observation for pos1 only when pos2 is nil" do
Weather.upsert_iemre_observation(@iemre_attrs)
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: nil,
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
observations = Weather.iemre_for_path(contact)
assert length(observations) == 1
end
end
describe "find_nearest_hrrr/3" do
test "returns nearest HRRR profile for given lat, lon, and timestamp" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
profile = Weather.find_nearest_hrrr(32.91, -97.05, ~U[2026-03-28 18:30:00Z])
assert profile
assert profile.lat == 32.90
end
test "returns nil when no HRRR profiles exist nearby" do
assert Weather.find_nearest_hrrr(40.0, -80.0, ~U[2026-03-28 18:00:00Z]) == nil
end
end
describe "iemre_for_contact/1" do
test "returns IEMRE observation matching QSO pos1 and date" do
Weather.upsert_iemre_observation(@iemre_attrs)
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
obs = Weather.iemre_for_contact(contact)
assert obs
assert obs.lat == 32.875
end
test "returns nil when no IEMRE observation exists" do
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.iemre_for_contact(contact) == nil
end
test "returns nil when QSO has no pos1" do
assert Weather.iemre_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
end
end
end