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.
1157 lines
35 KiB
Elixir
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
|