- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg) - Move 12+ nested alias/import/require to module top level - Add phx-change/id attributes to 11 raw HTML <form> tags - Remove 4 unused LiveView assigns (:bounds, :data_provider) - Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts) - Break 2 long lines (path_compute.ex:382) - Strengthen weak test assertions (is_binary→byte_size, is_list→!=[]) - Replace Module.concat with Module.safe_concat (2 occurrences) - Replace length/1 > 0 with list != [] (9 occurrences) - Remove no-op assert true, fix no-assertion tests Remaining: 24 socket.assigns introspection warnings (deliberate test pattern for observable behavior testing), 1 formatter-resistant long line, 3 app-code usage warnings.
1385 lines
43 KiB
Elixir
1385 lines
43 KiB
Elixir
defmodule Microwaveprop.WeatherTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.HrrrClient
|
|
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 "build_grid_cache_rows/3 — Rust ProfilesFile shape" do
|
|
@rust_profile %{
|
|
surface_temp_c: 25.0,
|
|
surface_dewpoint_c: 18.0,
|
|
surface_pressure_mb: 1013.0,
|
|
hpbl_m: 1500.0,
|
|
pwat_mm: 35.0,
|
|
native_min_gradient: -120.0,
|
|
profile: [
|
|
%{pres_mb: 1013.0, hght_m: 50.0, tmpc: 25.0, dwpc: 18.0},
|
|
%{pres_mb: 925.0, hght_m: 800.0, tmpc: 18.0, dwpc: 14.0},
|
|
%{pres_mb: 850.0, hght_m: 1500.0, tmpc: 12.0, dwpc: 9.0},
|
|
%{pres_mb: 700.0, hght_m: 3000.0, tmpc: 0.0, dwpc: -5.0}
|
|
]
|
|
}
|
|
|
|
test "derives upper-air + refractivity fields from an atom-keyed Rust profile" do
|
|
grid_data = %{{32.875, -97.0} => @rust_profile}
|
|
valid_time = ~U[2026-04-24 16:00:00Z]
|
|
|
|
[row] = Weather.build_grid_cache_rows(grid_data, valid_time)
|
|
|
|
# Upper-air fields flow from the normalized profile.
|
|
assert is_number(row.temp_850mb)
|
|
assert is_number(row.dewpoint_850mb)
|
|
assert is_number(row.lapse_rate)
|
|
assert is_number(row.inversion_strength)
|
|
|
|
# Surface refractivity is derived when Rust doesn't emit it.
|
|
assert is_number(row.surface_refractivity)
|
|
|
|
# Gradient uses the native_min_gradient fallback when
|
|
# SoundingParams can't derive one (or matches when it can).
|
|
assert is_number(row.refractivity_gradient)
|
|
end
|
|
end
|
|
|
|
describe "reconcile_weather_statuses/0" do
|
|
@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 "scales the longitude band by latitude so high-lat windows still cover ~150 km east/west" do
|
|
# At lat 49° (US/Canada border) cos(lat) ≈ 0.656, so a fixed 1.5°
|
|
# longitude box collapses to ~110 km — narrower than the 150 km
|
|
# window the function documents. A station 1.8° east of the QSO
|
|
# is roughly 132 km away, well inside the intended radius, but a
|
|
# latitude-blind bounding box would exclude it.
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(%{
|
|
@station_attrs
|
|
| station_code: "KHIGH",
|
|
lat: 49.0,
|
|
lon: -121.7
|
|
})
|
|
|
|
Weather.upsert_surface_observation(station, %{
|
|
observed_at: ~U[2023-09-17 17:45:00Z],
|
|
temp_f: 60.0
|
|
})
|
|
|
|
contact =
|
|
create_contact(%{
|
|
station1: "HILAT",
|
|
station2: "HILAT2",
|
|
pos1: %{"lat" => 49.0, "lon" => -123.5},
|
|
pos2: nil,
|
|
grid2: nil
|
|
})
|
|
|
|
Radio.set_enrichment_status!([contact.id], :weather_status, :queued)
|
|
|
|
{:ok, _n} = Weather.reconcile_weather_statuses()
|
|
assert %{weather_status: :complete} = 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 "reconcile_iemre_statuses/0" do
|
|
@iemre_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: nil,
|
|
distance_km: Decimal.new("295")
|
|
}
|
|
|
|
defp create_iemre_contact(attrs \\ %{}) do
|
|
{:ok, c} =
|
|
%Contact{}
|
|
|> Contact.changeset(Map.merge(@iemre_contact_attrs, attrs))
|
|
|> Repo.insert()
|
|
|
|
c
|
|
end
|
|
|
|
test "flips queued → complete when every path-point has an iemre row for the qso date" do
|
|
{rlat, rlon} = Weather.round_to_iemre_grid(32.9, -97.0)
|
|
{:ok, _} = Weather.upsert_iemre_observation(%{lat: rlat, lon: rlon, date: ~D[2023-09-17], hourly: []})
|
|
|
|
contact = create_iemre_contact()
|
|
Radio.set_enrichment_status!([contact.id], :iemre_status, :queued)
|
|
|
|
{:ok, n} = Weather.reconcile_iemre_statuses()
|
|
assert n == 1
|
|
assert %{iemre_status: :complete} = Repo.get(Contact, contact.id)
|
|
end
|
|
|
|
test "leaves :queued contacts whose path points are not all covered" do
|
|
# pos1 → covered, pos2 → not covered. contact_path_points returns
|
|
# [pos1, midpoint, pos2] so partial coverage must not flip.
|
|
pos1 = %{"lat" => 32.9, "lon" => -97.0}
|
|
pos2 = %{"lat" => 30.3, "lon" => -97.7}
|
|
{rlat, rlon} = Weather.round_to_iemre_grid(32.9, -97.0)
|
|
|
|
{:ok, _} =
|
|
Weather.upsert_iemre_observation(%{lat: rlat, lon: rlon, date: ~D[2023-09-17], hourly: []})
|
|
|
|
contact = create_iemre_contact(%{pos1: pos1, pos2: pos2, distance_km: Decimal.new("295")})
|
|
Radio.set_enrichment_status!([contact.id], :iemre_status, :queued)
|
|
|
|
{:ok, n} = Weather.reconcile_iemre_statuses()
|
|
assert n == 0
|
|
assert %{iemre_status: :queued} = Repo.get(Contact, contact.id)
|
|
end
|
|
end
|
|
|
|
describe "reconcile_hrrr_statuses/0" do
|
|
test "flips queued → complete when hrrr_data_fully_present?/1 holds" do
|
|
contact =
|
|
%Contact{}
|
|
|> Contact.changeset(%{
|
|
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: nil,
|
|
distance_km: Decimal.new("295")
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
|
|
{rlat, rlon} = Weather.round_to_hrrr_grid(32.9, -97.0)
|
|
|
|
%HrrrProfile{}
|
|
|> HrrrProfile.changeset(%{
|
|
valid_time: valid_time,
|
|
lat: rlat,
|
|
lon: rlon,
|
|
surface_temp_c: 25.0,
|
|
surface_dewpoint_c: 15.0,
|
|
surface_pressure_mb: 1013.0
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
Radio.set_enrichment_status!([contact.id], :hrrr_status, :queued)
|
|
|
|
{:ok, n} = Weather.reconcile_hrrr_statuses()
|
|
assert n == 1
|
|
assert %{hrrr_status: :complete} = Repo.get(Contact, contact.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.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.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.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.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
|
|
|
|
describe "weather_point_detail/3" do
|
|
test "returns nil when no grid data is available for the point" do
|
|
assert Weather.weather_point_detail(33.0, -97.0, ~U[2026-03-28 18:00:00Z]) == nil
|
|
end
|
|
|
|
test "snaps coordinates to the 0.125° grid before lookup" do
|
|
assert Weather.weather_point_detail(33.064, -97.043, ~U[2026-03-28 18:00:00Z]) == nil
|
|
end
|
|
end
|
|
|
|
describe "available_weather_valid_times/0" do
|
|
test "returns an empty list when no scores are stored" do
|
|
assert Weather.available_weather_valid_times() == []
|
|
end
|
|
end
|
|
|
|
describe "latest_grid_valid_time/0" do
|
|
test "returns nil when no scores are stored" do
|
|
assert Weather.latest_grid_valid_time() == nil
|
|
end
|
|
end
|
|
|
|
describe "available_hrdps_valid_times/0" do
|
|
test "returns an empty list when no HRDPS data is stored" do
|
|
assert Weather.available_hrdps_valid_times() == []
|
|
end
|
|
end
|
|
|
|
describe "warm_grid_cache_from_latest_profile/0" do
|
|
test "is a no-op when no ProfilesFile exists" do
|
|
assert :ok = Weather.warm_grid_cache_from_latest_profile()
|
|
end
|
|
end
|
|
|
|
describe "warm_grid_cache_and_broadcast/1" do
|
|
test "broadcasts an empty grid for a valid_time with no rows" do
|
|
assert :ok = Weather.warm_grid_cache_and_broadcast(~U[2026-04-29 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "materialize_scalar_file/1" do
|
|
test "is a no-op when no ProfilesFile exists for the valid_time" do
|
|
result = Weather.materialize_scalar_file(~U[2026-04-29 12:00:00Z])
|
|
assert result == :ok
|
|
end
|
|
end
|
|
|
|
describe "build_grid_cache_rows/3" do
|
|
test "returns an empty list for an empty grid_data map" do
|
|
rows = Weather.build_grid_cache_rows(%{}, ~U[2026-04-29 12:00:00Z])
|
|
assert rows == []
|
|
end
|
|
|
|
test "honors a bounding-box filter when provided" do
|
|
grid_data = %{
|
|
{32.0, -97.0} => %{surface_temp_c: 25.0, surface_dewpoint_c: 18.0, surface_pressure_mb: 1013.0},
|
|
{40.0, -75.0} => %{surface_temp_c: 18.0, surface_dewpoint_c: 12.0, surface_pressure_mb: 1010.0}
|
|
}
|
|
|
|
bounds = %{"south" => 31.0, "north" => 33.0, "west" => -98.0, "east" => -96.0}
|
|
|
|
rows = Weather.build_grid_cache_rows(grid_data, ~U[2026-04-29 12:00:00Z], bounds)
|
|
# Only the (32, -97) point is inside bounds.
|
|
assert length(rows) == 1
|
|
end
|
|
end
|
|
end
|