Lays the groundwork for a Day 2-7 propagation outlook driven by NCEP GEFS ensemble-mean output, picking up where HRRR's 18-hour horizon leaves off. - GefsClient: NOMADS URL builder, forecast-hour list (3h to +240, 6h to +384), ensemble-mean message inventory, and a Magnus dewpoint derivation (pgrb2a publishes RH rather than Td) - gefs_profiles table + GefsProfile schema mirroring hrrr_profiles so the scorer can run over rows from either source - Weather.upsert_gefs_profile/1 and upsert_gefs_profiles_batch/1 No worker or UI yet — those land in follow-up commits.
99 lines
3.1 KiB
Elixir
99 lines
3.1 KiB
Elixir
defmodule Microwaveprop.Weather.GefsProfileTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.GefsProfile
|
|
|
|
@valid_time ~U[2026-04-18 18:00:00Z]
|
|
|
|
defp base_attrs(overrides \\ %{}) do
|
|
Map.merge(
|
|
%{
|
|
valid_time: @valid_time,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
run_time: ~U[2026-04-18 12:00:00Z],
|
|
forecast_hour: 6,
|
|
surface_temp_c: 20.0,
|
|
surface_dewpoint_c: 10.0,
|
|
surface_pressure_mb: 1013.0,
|
|
pwat_mm: 20.0,
|
|
wind_u_mps: 2.0,
|
|
wind_v_mps: -1.0,
|
|
cloud_cover_pct: 50.0,
|
|
precip_mm: 0.0
|
|
},
|
|
overrides
|
|
)
|
|
end
|
|
|
|
describe "changeset/2" do
|
|
test "is valid with the minimum required fields" do
|
|
cs = GefsProfile.changeset(%GefsProfile{}, base_attrs())
|
|
assert cs.valid?
|
|
end
|
|
|
|
test "requires valid_time, lat, and lon" do
|
|
cs = GefsProfile.changeset(%GefsProfile{}, %{})
|
|
refute cs.valid?
|
|
assert %{valid_time: _, lat: _, lon: _} = errors_on(cs)
|
|
end
|
|
|
|
test "accepts an optional pressure-level profile array" do
|
|
profile = [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}]
|
|
cs = GefsProfile.changeset(%GefsProfile{}, base_attrs(%{profile: profile}))
|
|
assert cs.valid?
|
|
assert Ecto.Changeset.get_field(cs, :profile) == profile
|
|
end
|
|
end
|
|
|
|
describe "Weather.upsert_gefs_profile/1" do
|
|
test "inserts a new profile" do
|
|
assert {:ok, %GefsProfile{} = profile} = Weather.upsert_gefs_profile(base_attrs())
|
|
assert profile.lat == 32.9
|
|
assert profile.surface_temp_c == 20.0
|
|
end
|
|
|
|
test "is a no-op on conflict (lat, lon, valid_time)" do
|
|
{:ok, _} = Weather.upsert_gefs_profile(base_attrs())
|
|
|
|
{:ok, second} =
|
|
Weather.upsert_gefs_profile(base_attrs(%{surface_temp_c: 999.0}))
|
|
|
|
# on_conflict: :nothing leaves the existing row alone; the returned
|
|
# struct just carries the changeset values, but the DB keeps the old
|
|
# record.
|
|
persisted = Microwaveprop.Repo.get_by(GefsProfile, lat: 32.9, lon: -97.0, valid_time: @valid_time)
|
|
assert persisted.surface_temp_c == 20.0
|
|
assert second.lat == 32.9
|
|
end
|
|
|
|
test "rejects invalid attributes with a changeset error" do
|
|
assert {:error, %Ecto.Changeset{valid?: false}} = Weather.upsert_gefs_profile(%{})
|
|
end
|
|
end
|
|
|
|
describe "Weather.upsert_gefs_profiles_batch/1" do
|
|
test "inserts many rows at once" do
|
|
attrs =
|
|
for fh <- [3, 6, 9] do
|
|
valid = DateTime.add(~U[2026-04-18 12:00:00Z], fh * 3600, :second)
|
|
base_attrs(%{valid_time: valid, forecast_hour: fh})
|
|
end
|
|
|
|
{count, _} = Weather.upsert_gefs_profiles_batch(attrs)
|
|
assert count == 3
|
|
|
|
rows = Microwaveprop.Repo.all(GefsProfile)
|
|
assert length(rows) == 3
|
|
end
|
|
|
|
test "skips conflicting rows quietly" do
|
|
attrs = [base_attrs()]
|
|
{1, _} = Weather.upsert_gefs_profiles_batch(attrs)
|
|
{0, _} = Weather.upsert_gefs_profiles_batch(attrs)
|
|
|
|
assert Microwaveprop.Repo.aggregate(GefsProfile, :count, :id) == 1
|
|
end
|
|
end
|
|
end
|