Show on /weather where the detected duct geometry traps each microwave band. Overview mode bins cells by their lowest trapped frequency (Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't reach the selected band. Cutoff is pre-computed per cell in both writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from CellValues), persisted to ScalarFile, and shipped to the JS hook via the existing binary cell pack. Also cleans up six pre-existing length/1 credo warnings in unrelated test files.
530 lines
16 KiB
Elixir
530 lines
16 KiB
Elixir
defmodule Microwaveprop.Weather.UntestedFunctionsTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.GefsProfile
|
|
alias Microwaveprop.Weather.HrrrProfile
|
|
alias Microwaveprop.Weather.SolarIndex
|
|
alias Microwaveprop.Weather.Station
|
|
|
|
defp create_station(attrs \\ %{}) do
|
|
defaults = %{
|
|
station_code: "KDFW",
|
|
station_type: "asos",
|
|
name: "Dallas/Fort Worth Intl",
|
|
lat: 32.9,
|
|
lon: -97.0
|
|
}
|
|
|
|
{:ok, station} =
|
|
%Station{}
|
|
|> Station.changeset(Map.merge(defaults, attrs))
|
|
|> Repo.insert()
|
|
|
|
station
|
|
end
|
|
|
|
describe "upsert_solar_index/1" do
|
|
test "inserts a new solar index record" do
|
|
attrs = %{
|
|
date: ~D[2026-05-01],
|
|
sfi: 150.0,
|
|
sunspot_number: 80,
|
|
ap_index: 5
|
|
}
|
|
|
|
assert {:ok, %SolarIndex{} = idx} = Weather.upsert_solar_index(attrs)
|
|
assert idx.sfi == 150.0
|
|
assert idx.sunspot_number == 80
|
|
assert idx.date == ~D[2026-05-01]
|
|
end
|
|
|
|
test "updates existing record on conflict" do
|
|
Weather.upsert_solar_index(%{date: ~D[2026-05-02], sfi: 140.0, ap_index: 4})
|
|
assert {:ok, %SolarIndex{} = idx} = Weather.upsert_solar_index(%{date: ~D[2026-05-02], sfi: 145.0, ap_index: 4})
|
|
assert idx.sfi == 145.0
|
|
end
|
|
end
|
|
|
|
describe "station_day_pairs_covered/1" do
|
|
test "empty list returns empty MapSet" do
|
|
assert Weather.station_day_pairs_covered([]) == MapSet.new()
|
|
end
|
|
end
|
|
|
|
describe "station_ids_with_surface_observations/3" do
|
|
test "returns station ids that have observations in window" do
|
|
st = create_station()
|
|
{:ok, _obs} = Weather.upsert_surface_observation(st, %{observed_at: ~U[2026-05-01 12:00:00Z], temp_f: 75.0})
|
|
|
|
result = Weather.station_ids_with_surface_observations([st.id], ~U[2026-05-01 00:00:00Z], ~U[2026-05-02 00:00:00Z])
|
|
assert MapSet.member?(result, st.id)
|
|
end
|
|
|
|
test "excludes stations outside the time window" do
|
|
st = create_station(%{station_code: "KLAX"})
|
|
{:ok, _obs} = Weather.upsert_surface_observation(st, %{observed_at: ~U[2026-05-01 12:00:00Z], temp_f: 70.0})
|
|
|
|
result = Weather.station_ids_with_surface_observations([st.id], ~U[2026-06-01 00:00:00Z], ~U[2026-06-02 00:00:00Z])
|
|
assert MapSet.size(result) == 0
|
|
end
|
|
end
|
|
|
|
describe "upsert_hrrr_profile/1" do
|
|
test "inserts a new HRRR profile" do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}]
|
|
|
|
attrs = %{
|
|
valid_time: now,
|
|
run_time: now,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
profile: profile_data,
|
|
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
|
|
}
|
|
|
|
assert {:ok, %HrrrProfile{} = profile} = Weather.upsert_hrrr_profile(attrs)
|
|
assert profile.lat == 32.9
|
|
assert profile.lon == -97.0
|
|
end
|
|
end
|
|
|
|
describe "upsert_gefs_profile/1" do
|
|
test "inserts a new GEFS profile" do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}]
|
|
|
|
attrs = %{
|
|
valid_time: now,
|
|
run_time: now,
|
|
forecast_hour: 0,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
profile: profile_data,
|
|
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
|
|
}
|
|
|
|
assert {:ok, %GefsProfile{} = profile} = Weather.upsert_gefs_profile(attrs)
|
|
assert profile.forecast_hour == 0
|
|
assert profile.lat == 32.9
|
|
end
|
|
end
|
|
|
|
describe "upsert_gefs_profiles_batch/1" do
|
|
test "bulk inserts gefs profiles" do
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}]
|
|
|
|
profiles = [
|
|
%{
|
|
valid_time: now,
|
|
run_time: now,
|
|
forecast_hour: 0,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
profile: profile_data,
|
|
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
|
|
}
|
|
]
|
|
|
|
assert {1, _} = Weather.upsert_gefs_profiles_batch(profiles)
|
|
end
|
|
end
|
|
|
|
describe "available_weather_valid_times/0" do
|
|
test "returns a list (empty when no data)" do
|
|
assert is_list(Weather.available_weather_valid_times())
|
|
end
|
|
end
|
|
|
|
describe "available_hrdps_valid_times/0" do
|
|
test "returns a list (empty when no data)" do
|
|
assert is_list(Weather.available_hrdps_valid_times())
|
|
end
|
|
end
|
|
|
|
describe "round_to_hrrr_grid/2" do
|
|
test "snaps coordinates to HRRR 3km grid" do
|
|
{lat, lon} = Weather.round_to_hrrr_grid(32.91, -97.04)
|
|
assert is_float(lat)
|
|
assert is_float(lon)
|
|
end
|
|
end
|
|
|
|
describe "round_to_iemre_grid/2" do
|
|
test "snaps coordinates to IEMRE 0.125 degree grid" do
|
|
{lat, lon} = Weather.round_to_iemre_grid(32.91, -97.04)
|
|
assert is_float(lat)
|
|
assert is_float(lon)
|
|
end
|
|
end
|
|
|
|
describe "nil guard functions" do
|
|
test "hrrr_data_fully_present?/1 returns false for nil pos1" do
|
|
refute Weather.hrrr_data_fully_present?(%{pos1: nil})
|
|
end
|
|
|
|
test "hrrr_data_fully_present?/1 returns false for nil qso_timestamp" do
|
|
refute Weather.hrrr_data_fully_present?(%{pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: nil})
|
|
end
|
|
|
|
test "hrrr_for_contact/1 returns nil for nil pos1" do
|
|
assert Weather.hrrr_for_contact(%{pos1: nil}) == nil
|
|
end
|
|
|
|
test "narr_for_contact/1 returns nil for nil pos1" do
|
|
assert Weather.narr_for_contact(%{pos1: nil}) == nil
|
|
end
|
|
|
|
test "iemre_for_contact/1 returns nil for nil pos1" do
|
|
assert Weather.iemre_for_contact(%{pos1: nil}) == nil
|
|
end
|
|
|
|
test "iemre_for_path/1 returns empty for nil pos1" do
|
|
assert Weather.iemre_for_path(%{pos1: nil}) == []
|
|
end
|
|
|
|
test "narr_profiles_for_path/1 returns empty for nil pos1" do
|
|
assert Weather.narr_profiles_for_path(%{pos1: nil}) == []
|
|
end
|
|
|
|
test "hrrr_profiles_for_path/1 returns empty for nil pos1" do
|
|
assert Weather.hrrr_profiles_for_path(%{pos1: nil}) == []
|
|
end
|
|
end
|
|
|
|
describe "has_hrrr_profile?/3" do
|
|
test "returns false when no profile exists" do
|
|
refute Weather.has_hrrr_profile?(32.9, -97.0, DateTime.utc_now())
|
|
end
|
|
end
|
|
|
|
describe "has_iemre_observation?/3" do
|
|
test "returns false when no observation exists" do
|
|
refute Weather.has_iemre_observation?(32.9, -97.0, ~D[2026-05-01])
|
|
end
|
|
end
|
|
|
|
describe "purge_grid_point_profiles/0" do
|
|
test "returns a count (0 when no data)" do
|
|
result = Weather.purge_grid_point_profiles()
|
|
assert is_integer(result)
|
|
assert result >= 0
|
|
end
|
|
end
|
|
|
|
describe "find_or_create_station/1" do
|
|
test "creates a new station when not present" do
|
|
assert {:ok, %Station{} = st} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KAUS",
|
|
station_type: "asos",
|
|
name: "Austin",
|
|
lat: 30.2,
|
|
lon: -97.7
|
|
})
|
|
|
|
assert st.station_code == "KAUS"
|
|
end
|
|
|
|
test "returns existing station on duplicate" do
|
|
{:ok, st1} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KORD",
|
|
station_type: "asos",
|
|
name: "Chicago",
|
|
lat: 41.9,
|
|
lon: -87.9
|
|
})
|
|
|
|
{:ok, st2} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KORD",
|
|
station_type: "asos",
|
|
name: "Chicago",
|
|
lat: 41.9,
|
|
lon: -87.9
|
|
})
|
|
|
|
assert st1.id == st2.id
|
|
end
|
|
|
|
test "string-keyed attrs work too" do
|
|
assert {:ok, %Station{}} =
|
|
Weather.find_or_create_station(%{
|
|
"station_code" => "KSEA",
|
|
"station_type" => "asos",
|
|
"name" => "Seattle",
|
|
"lat" => 47.4,
|
|
"lon" => -122.3
|
|
})
|
|
end
|
|
end
|
|
|
|
describe "has_surface_observations?/3" do
|
|
test "returns false when no obs in window" do
|
|
st = create_station(%{station_code: "KSAN"})
|
|
|
|
refute Weather.has_surface_observations?(
|
|
st.id,
|
|
~U[2026-01-01 00:00:00Z],
|
|
~U[2026-01-02 00:00:00Z]
|
|
)
|
|
end
|
|
|
|
test "returns true when an obs exists in window" do
|
|
st = create_station(%{station_code: "KSFO"})
|
|
|
|
{:ok, _} =
|
|
Weather.upsert_surface_observation(st, %{
|
|
observed_at: ~U[2026-05-01 12:00:00Z],
|
|
temp_f: 70.0
|
|
})
|
|
|
|
assert Weather.has_surface_observations?(
|
|
st.id,
|
|
~U[2026-05-01 00:00:00Z],
|
|
~U[2026-05-02 00:00:00Z]
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "station_day_covered?/2" do
|
|
test "false when no obs on that day" do
|
|
st = create_station(%{station_code: "KMIA"})
|
|
refute Weather.station_day_covered?(st.id, ~D[2026-05-01])
|
|
end
|
|
|
|
test "true after upserting an obs on that day" do
|
|
st = create_station(%{station_code: "KBOS"})
|
|
|
|
{:ok, _} =
|
|
Weather.upsert_surface_observation(st, %{
|
|
observed_at: ~U[2026-05-01 14:00:00Z],
|
|
temp_f: 60.0
|
|
})
|
|
|
|
assert Weather.station_day_covered?(st.id, ~D[2026-05-01])
|
|
end
|
|
end
|
|
|
|
describe "station_day_pairs_covered/1 with rows" do
|
|
test "returns the covered subset" do
|
|
st = create_station(%{station_code: "KPHX"})
|
|
|
|
{:ok, _} =
|
|
Weather.upsert_surface_observation(st, %{
|
|
observed_at: ~U[2026-05-01 06:00:00Z],
|
|
temp_f: 80.0
|
|
})
|
|
|
|
result = Weather.station_day_pairs_covered([{st.id, ~D[2026-05-01]}, {st.id, ~D[2026-05-02]}])
|
|
assert MapSet.member?(result, {st.id, ~D[2026-05-01]})
|
|
end
|
|
end
|
|
|
|
describe "has_sounding?/2" do
|
|
test "returns false when no sounding exists" do
|
|
st = create_station(%{station_code: "KFWD", station_type: "sounding"})
|
|
refute Weather.has_sounding?(st.id, ~U[2026-05-01 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "station_ids_with_soundings/2" do
|
|
test "empty result when none match" do
|
|
st = create_station(%{station_code: "KOUN", station_type: "sounding"})
|
|
result = Weather.station_ids_with_soundings([st.id], [~U[2026-05-01 12:00:00Z]])
|
|
assert MapSet.size(result) == 0
|
|
end
|
|
end
|
|
|
|
describe "get_solar_index/1" do
|
|
test "returns nil when none present" do
|
|
assert Weather.get_solar_index(~D[2026-05-01]) == nil
|
|
end
|
|
|
|
test "returns the row after upsert" do
|
|
{:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-03], sfi: 130.0})
|
|
idx = Weather.get_solar_index(~D[2026-05-03])
|
|
assert idx
|
|
assert idx.sfi == 130.0
|
|
end
|
|
end
|
|
|
|
describe "existing_solar_dates/0" do
|
|
test "empty MapSet when DB is empty" do
|
|
assert Weather.existing_solar_dates() == MapSet.new()
|
|
end
|
|
|
|
test "contains dates after upserts" do
|
|
{:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-04], sfi: 120.0})
|
|
{:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-05], sfi: 121.0})
|
|
dates = Weather.existing_solar_dates()
|
|
assert MapSet.member?(dates, ~D[2026-05-04])
|
|
assert MapSet.member?(dates, ~D[2026-05-05])
|
|
end
|
|
end
|
|
|
|
describe "upsert_solar_indices_batch/1" do
|
|
test "empty list returns 0" do
|
|
assert Weather.upsert_solar_indices_batch([]) == 0
|
|
end
|
|
|
|
test "inserts multiple rows" do
|
|
records = [
|
|
%{date: ~D[2026-06-01], sfi: 100.0, ap_index: 5},
|
|
%{date: ~D[2026-06-02], sfi: 105.0, ap_index: 6}
|
|
]
|
|
|
|
count = Weather.upsert_solar_indices_batch(records)
|
|
assert count == 2
|
|
end
|
|
end
|
|
|
|
describe "nearby_stations/4" do
|
|
test "returns empty when no stations in range" do
|
|
assert Weather.nearby_stations(0.0, 0.0, "asos", 10.0) == []
|
|
end
|
|
|
|
test "finds station within radius" do
|
|
_ = create_station(%{station_code: "KDFW2", lat: 32.9, lon: -97.0})
|
|
results = Weather.nearby_stations(32.9, -97.0, "asos", 50.0)
|
|
assert results != []
|
|
end
|
|
end
|
|
|
|
describe "sounding_times_around/1" do
|
|
test "returns a list of DateTime values" do
|
|
times = Weather.sounding_times_around(~U[2026-05-01 12:00:00Z])
|
|
assert is_list(times)
|
|
assert Enum.all?(times, fn t -> match?(%DateTime{}, t) end)
|
|
end
|
|
end
|
|
|
|
describe "latest_grid_valid_time/0" do
|
|
test "returns nil when no rows" do
|
|
assert Weather.latest_grid_valid_time() == nil
|
|
end
|
|
end
|
|
|
|
describe "find_nearest_hrrr/3" do
|
|
test "returns nil when no profile exists" do
|
|
assert Weather.find_nearest_hrrr(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
|
|
end
|
|
end
|
|
|
|
describe "find_nearest_native_profile/3" do
|
|
test "returns nil when no profile exists" do
|
|
assert Weather.find_nearest_native_profile(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
|
|
end
|
|
end
|
|
|
|
describe "find_nearest_iemre/3" do
|
|
test "returns nil when no observation exists" do
|
|
assert Weather.find_nearest_iemre(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
|
|
end
|
|
end
|
|
|
|
describe "find_nearest_narr/3" do
|
|
test "returns nil when no profile exists" do
|
|
assert Weather.find_nearest_narr(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
|
|
end
|
|
end
|
|
|
|
describe "nearest_native_duct_ghz/3" do
|
|
test "returns nil when no native profile" do
|
|
assert Weather.nearest_native_duct_ghz(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil
|
|
end
|
|
end
|
|
|
|
describe "nearest_native_duct_info/3" do
|
|
test "returns an error tuple when no native profile" do
|
|
assert match?({:error, _}, Weather.nearest_native_duct_info(32.9, -97.0, ~U[2026-05-01 12:00:00Z]))
|
|
end
|
|
end
|
|
|
|
describe "nearest_sounding_to/4" do
|
|
test "returns an error tuple when no soundings near point" do
|
|
assert match?({:error, _}, Weather.nearest_sounding_to(32.9, -97.0, ~U[2026-05-01 12:00:00Z]))
|
|
end
|
|
end
|
|
|
|
describe "best_profile_for_contact/1" do
|
|
test "returns nil for contact missing pos1" do
|
|
assert Weather.best_profile_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-05-01 12:00:00Z]}) == nil
|
|
end
|
|
end
|
|
|
|
describe "profiles_along_path/1" do
|
|
test "returns empty list for contact missing pos1" do
|
|
assert Weather.profiles_along_path(%{pos1: nil, pos2: %{"lat" => 33.0, "lon" => -97.0}}) == []
|
|
end
|
|
end
|
|
|
|
describe "upsert_iemre_observation/1" do
|
|
test "inserts a new IEMRE observation" do
|
|
attrs = %{
|
|
date: ~D[2026-05-01],
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
hourly: [
|
|
%{"hour" => 0, "air_temp_f" => 65.0}
|
|
]
|
|
}
|
|
|
|
assert {:ok, _obs} = Weather.upsert_iemre_observation(attrs)
|
|
end
|
|
end
|
|
|
|
describe "backfill_hrrr_scalars/1" do
|
|
test "runs on empty DB without raising" do
|
|
result = Weather.backfill_hrrr_scalars(batch_size: 5, max_batches: 1)
|
|
assert is_integer(result) or match?({:ok, _}, result) or result == :ok
|
|
end
|
|
end
|
|
|
|
describe "analyze_all/1" do
|
|
test "runs on empty DB without raising" do
|
|
_ = Weather.analyze_all(skip_recent_seconds: 0)
|
|
assert true
|
|
end
|
|
end
|
|
|
|
describe "reconcile_*_statuses (smoke)" do
|
|
test "reconcile_iemre_statuses returns count tuple on empty DB" do
|
|
assert {:ok, count} = Weather.reconcile_iemre_statuses()
|
|
assert is_integer(count)
|
|
end
|
|
|
|
test "reconcile_hrrr_statuses returns count tuple on empty DB" do
|
|
assert {:ok, count} = Weather.reconcile_hrrr_statuses()
|
|
assert is_integer(count)
|
|
end
|
|
|
|
test "reconcile_weather_statuses returns count tuple on empty DB" do
|
|
assert {:ok, count} = Weather.reconcile_weather_statuses()
|
|
assert is_integer(count)
|
|
end
|
|
end
|
|
end
|