prop/test/microwaveprop/weather_test.exs
Graham McIntire 525d9c4ea5
fix(weather): reconcile contact weather_status from SQL, not on view
2,019 contacts sat permanently in weather_status=:queued even after
their ASOS data had been ingested. The only code path that flipped
:queued → :complete was MicrowavepropWeb.ContactLive.Show on page
view — contacts that nobody visited stayed :queued forever.

Adds Weather.reconcile_weather_statuses/0, a single SQL UPDATE that
flips every :queued contact whose ±2h / 150km window now contains
at least one surface observation. Called at the tail of the
existing ContactWeatherEnqueueWorker cron so the correction runs
regardless of whether anyone opens the UI.

Visible effect: the status page's "Weather done" count (previously
capped at 79,975 while data kept landing) will converge on the real
total after the next cron tick.

2838 tests + credo green.
2026-04-24 13:25:15 -05:00

1157 lines
35 KiB
Elixir

defmodule Microwaveprop.WeatherTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.SurfaceObservation
@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 "reconcile_weather_statuses/0" do
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
@contact_attrs %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2023-09-17 17:30:00Z],
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
defp create_contact(attrs \\ %{}) do
{:ok, c} =
%Contact{}
|> Contact.changeset(Map.merge(@contact_attrs, attrs))
|> Repo.insert()
c
end
test "flips queued → complete for contacts with obs in ±2h / 150km window" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2023-09-17 17:45:00Z],
temp_f: 80.0
})
contact = create_contact()
Radio.set_enrichment_status!([contact.id], :weather_status, :queued)
{:ok, n} = Weather.reconcile_weather_statuses()
assert n == 1
assert %{weather_status: :complete} = Repo.get(Contact, contact.id)
end
test "leaves :queued contacts without nearby obs alone" do
{:ok, station} =
Weather.find_or_create_station(%{
@station_attrs
| station_code: "KSEA",
lat: 47.45,
lon: -122.31
})
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2023-09-17 17:45:00Z],
temp_f: 60.0
})
# Contact is in TX; KSEA obs doesn't cover its pos1/pos2.
contact = create_contact()
Radio.set_enrichment_status!([contact.id], :weather_status, :queued)
{:ok, n} = Weather.reconcile_weather_statuses()
assert n == 0
assert %{weather_status: :queued} = Repo.get(Contact, contact.id)
end
test "leaves :complete and :pending contacts alone" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2023-09-17 17:45:00Z],
temp_f: 80.0
})
c_done = create_contact(%{station1: "DONE"})
Radio.set_enrichment_status!([c_done.id], :weather_status, :complete)
c_pending = create_contact(%{station1: "PEND"})
Radio.set_enrichment_status!([c_pending.id], :weather_status, :pending)
{:ok, _} = Weather.reconcile_weather_statuses()
assert %{weather_status: :complete} = Repo.get(Contact, c_done.id)
assert %{weather_status: :pending} = Repo.get(Contact, c_pending.id)
end
end
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_surface_observations/2" do
test "bulk inserts all rows in a single call" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
rows =
for i <- 0..99 do
%{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second),
temp_f: 40.0 + i / 10,
dewpoint_f: 30.0 + i / 10,
sky_condition: "CLR"
}
end
assert {count, _} = Weather.upsert_surface_observations(station, rows)
assert count == 100
assert Repo.aggregate(SurfaceObservation, :count) == 100
end
test "overlapping observed_at updates instead of duplicating" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
rows1 =
for i <- 0..9 do
%{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second),
temp_f: 40.0,
dewpoint_f: 30.0
}
end
{10, _} = Weather.upsert_surface_observations(station, rows1)
rows2 =
for i <- 0..9 do
%{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second),
temp_f: 80.0,
dewpoint_f: 60.0
}
end
{_, _} = Weather.upsert_surface_observations(station, rows2)
assert Repo.aggregate(SurfaceObservation, :count) == 10
obs = Repo.all(SurfaceObservation)
assert Enum.all?(obs, &(&1.temp_f == 80.0))
end
test "returns {0, _} for empty list without hitting the DB" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
assert {0, _} = Weather.upsert_surface_observations(station, [])
end
test "skips rows without observed_at" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
rows = [
%{observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 70.0},
%{observed_at: nil, temp_f: 99.0},
%{temp_f: 99.0}
]
assert {1, _} = Weather.upsert_surface_observations(station, rows)
assert Repo.aggregate(SurfaceObservation, :count) == 1
end
test "dedupes rows sharing (station_id, observed_at), keeping the last" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
ts = ~U[2024-09-22 20:00:00Z]
# IEM occasionally returns two METAR records with the same
# timestamp. Passing both to a single insert_all triggers
# Postgres 21000 cardinality_violation because ON CONFLICT
# cannot update the same row twice in one command.
rows = [
%{observed_at: ts, temp_f: 70.0, dewpoint_f: 60.0},
%{observed_at: ts, temp_f: 72.0, dewpoint_f: 61.0}
]
assert {1, _} = Weather.upsert_surface_observations(station, rows)
assert Repo.aggregate(SurfaceObservation, :count) == 1
[obs] = Repo.all(SurfaceObservation)
assert obs.temp_f == 72.0
assert obs.dewpoint_f == 61.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 "soundings_with_widening_radius/1" do
test "returns soundings at the first radius that finds any" do
# Station ~220 km east of the query point: outside the 150 km ring
# but inside the 300 km ring.
{:ok, far_station} =
Weather.find_or_create_station(%{
station_code: "FAR",
station_type: "sounding",
wmo_number: 72_001,
name: "Far Site",
lat: 32.9,
lon: -94.6
})
Weather.upsert_sounding(far_station, %{
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [%{"pres" => 1013, "hght" => 100, "tmpc" => 20, "dwpc" => 10}],
level_count: 1
})
result =
Weather.soundings_with_widening_radius(%{
lat: 32.9,
lon: -97.0,
timestamp: ~U[2026-03-28 14:00:00Z]
})
assert result.soundings != []
# 130 km is outside 150 but inside 300
assert result.radius_km == 300
refute result.exhausted
end
test "reports exhausted when no soundings exist within the widest radius" do
result =
Weather.soundings_with_widening_radius(%{
lat: 32.9,
lon: -97.0,
timestamp: ~U[2026-03-28 14:00:00Z]
})
assert result.soundings == []
assert result.exhausted
assert result.radius_km == 1000
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 "nearest_sounding_to/3" do
@profile [
%{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0},
%{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0}
]
setup 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: @profile,
level_count: 2,
ducting_detected: true,
min_refractivity_gradient: -250.0
})
%{station: station, sounding: sounding}
end
test "returns the closest sounding within spatial + temporal window", %{sounding: sounding} do
# Roughly 30 km north of Fort Worth sounding station, 1h after the obs.
assert {:ok, found} =
Weather.nearest_sounding_to(33.10, -97.30, ~U[2026-03-28 13:00:00Z])
assert found.id == sounding.id
assert found.ducting_detected == true
end
test "returns {:error, :not_found} when no sounding is in range" do
# Far outside the default 300 km / ±3h window.
assert {:error, :not_found} =
Weather.nearest_sounding_to(45.0, -80.0, ~U[2026-03-28 12:00:00Z])
end
test "returns {:error, :not_found} when the sounding is outside the time window" do
# Same location as the sounding, 8 hours later — outside ±3h.
assert {:error, :not_found} =
Weather.nearest_sounding_to(32.83, -97.30, ~U[2026-03-28 20: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 "preserves existing profile on conflict" do
{:ok, first} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | hpbl_m: 2000.0})
reloaded = Repo.get!(HrrrProfile, first.id)
assert reloaded.hpbl_m == 1500.0
assert Repo.aggregate(HrrrProfile, :count) == 1
end
end
describe "purge_grid_point_profiles/0" do
test "deletes all grid-point rows across partitions while preserving QSO-linked rows" do
# QSO-linked row (is_grid_point defaults to false)
{:ok, qso_linked} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
# Grid-point row in a historical partition
Repo.query!(
"""
INSERT INTO hrrr_profiles
(id, valid_time, lat, lon, is_grid_point, inserted_at, updated_at)
VALUES ($1, $2, $3, $4, true, $5, $5)
""",
[
Ecto.UUID.bingenerate(),
~N[2023-07-15 12:00:00],
32.125,
-97.125,
NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
]
)
# Grid-point row in the current partition
Repo.query!(
"""
INSERT INTO hrrr_profiles
(id, valid_time, lat, lon, is_grid_point, inserted_at, updated_at)
VALUES ($1, $2, $3, $4, true, $5, $5)
""",
[
Ecto.UUID.bingenerate(),
NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
33.125,
-96.125,
NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
]
)
assert Repo.aggregate(HrrrProfile, :count) == 3
assert Weather.purge_grid_point_profiles() == 2
remaining = Repo.all(HrrrProfile)
assert length(remaining) == 1
assert hd(remaining).id == qso_linked.id
end
test "returns 0 when there are no grid-point rows" do
{:ok, _} = Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
assert Weather.purge_grid_point_profiles() == 0
assert Repo.aggregate(HrrrProfile, :count) == 1
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 "hrrr_data_fully_present?/1" do
# nearest_hrrr_hour rounds <:30 down; pick 18:15 so it maps to 18:00 to
# match the valid_time in @hrrr_profile_attrs.
@contact_time ~U[2026-03-28 18:15:00Z]
test "returns true when a pos1-only contact's snapped point has a profile" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
contact = %{
pos1: %{"lat" => 32.9047, "lon" => -97.0382},
pos2: nil,
qso_timestamp: @contact_time
}
assert Weather.hrrr_data_fully_present?(contact)
end
test "returns false when a pos1-only contact's snapped point has no profile" do
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: nil,
qso_timestamp: @contact_time
}
refute Weather.hrrr_data_fully_present?(contact)
end
test "returns true only when every path point (endpoint + midpoint + endpoint) has a profile" do
# Contact from (32.9, -97.04) to (33.1, -96.96) → midpoint ≈ (33.0, -97.0)
for {lat, lon} <- [{32.9, -97.04}, {33.0, -97.0}, {33.1, -96.96}] do
Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | lat: lat, lon: lon})
end
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.04},
pos2: %{"lat" => 33.1, "lon" => -96.96},
qso_timestamp: @contact_time
}
assert Weather.hrrr_data_fully_present?(contact)
end
test "returns false when the midpoint profile is missing" do
# Only endpoints present — midpoint (33.0, -97.0) missing
for {lat, lon} <- [{32.9, -97.04}, {33.1, -96.96}] do
Weather.upsert_hrrr_profile(%{@hrrr_profile_attrs | lat: lat, lon: lon})
end
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.04},
pos2: %{"lat" => 33.1, "lon" => -96.96},
qso_timestamp: @contact_time
}
refute Weather.hrrr_data_fully_present?(contact)
end
test "returns false when pos1 is nil" do
refute Weather.hrrr_data_fully_present?(%{
pos1: nil,
pos2: nil,
qso_timestamp: @contact_time
})
end
test "returns false when qso_timestamp is nil" do
# A Contact with no timestamp can't be mapped to an HRRR hour; guard
# against HrrrClient.nearest_hrrr_hour(nil) crashing here.
refute Weather.hrrr_data_fully_present?(%{
pos1: %{"lat" => 32.9, "lon" => -97.04},
pos2: nil,
qso_timestamp: 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 "preserves existing observation on conflict" do
{:ok, first} = Weather.upsert_iemre_observation(@iemre_attrs)
Weather.upsert_iemre_observation(%{
@iemre_attrs
| hourly: [%{"hour" => 0, "p01m_mm" => 1.5}]
})
reloaded = Repo.get!(IemreObservation, first.id)
assert hd(reloaded.hourly)["p01m_mm"] == 0.0
assert Repo.aggregate(IemreObservation, :count) == 1
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 "find_nearest_native_profile/3" do
@native_attrs %{
valid_time: ~U[2026-03-28 18:00:00Z],
run_time: ~U[2026-03-28 18:00:00Z],
lat: 32.90,
lon: -97.04,
level_count: 2,
heights_m: [10.0, 500.0],
temp_k: [295.0, 285.0],
spfh: [0.010, 0.004],
pressure_pa: [101_000.0, 95_000.0],
u_wind_ms: [2.0, 3.0],
v_wind_ms: [1.0, 1.5],
tke_m2s2: [0.5, 0.1]
}
test "returns nearest native profile within bounding box + time window" do
{:ok, _} =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(@native_attrs)
|> Microwaveprop.Repo.insert()
profile = Weather.find_nearest_native_profile(32.91, -97.05, ~U[2026-03-28 18:20:00Z])
assert profile
assert profile.lat == 32.90
assert profile.level_count == 2
end
test "returns nil when no native profile exists nearby" do
assert Weather.find_nearest_native_profile(40.0, -80.0, ~U[2026-03-28 18:00:00Z]) == nil
end
test "returns nil when the nearest profile is outside the time window" do
{:ok, _} =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(@native_attrs)
|> Microwaveprop.Repo.insert()
# 3+ hours away from the stored 18:00Z profile.
assert Weather.find_nearest_native_profile(32.91, -97.05, ~U[2026-03-28 22: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