feat(gefs): scaffold extended-horizon forecast ingestion
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.
This commit is contained in:
parent
d54f4cd907
commit
a5c7dce147
6 changed files with 468 additions and 0 deletions
|
|
@ -5,6 +5,7 @@ defmodule Microwaveprop.Weather do
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.ProfilesFile
|
alias Microwaveprop.Propagation.ProfilesFile
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Weather.GefsProfile
|
||||||
alias Microwaveprop.Weather.GridCache
|
alias Microwaveprop.Weather.GridCache
|
||||||
alias Microwaveprop.Weather.HrrrNativeProfile
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||||
alias Microwaveprop.Weather.HrrrProfile
|
alias Microwaveprop.Weather.HrrrProfile
|
||||||
|
|
@ -727,6 +728,46 @@ defmodule Microwaveprop.Weather do
|
||||||
|> Map.drop([:profile, :duct_characteristics, :surface_temp_c, :surface_dewpoint_c])
|
|> Map.drop([:profile, :duct_characteristics, :surface_temp_c, :surface_dewpoint_c])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec upsert_gefs_profile(map()) :: {:ok, GefsProfile.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def upsert_gefs_profile(attrs) do
|
||||||
|
changeset = GefsProfile.changeset(%GefsProfile{}, attrs)
|
||||||
|
|
||||||
|
if changeset.valid? do
|
||||||
|
Repo.insert(changeset,
|
||||||
|
on_conflict: :nothing,
|
||||||
|
conflict_target: [:lat, :lon, :valid_time]
|
||||||
|
)
|
||||||
|
else
|
||||||
|
{:error, changeset}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec upsert_gefs_profiles_batch([map()]) :: {non_neg_integer(), nil}
|
||||||
|
def upsert_gefs_profiles_batch(profiles) do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
profiles
|
||||||
|
|> Enum.chunk_every(500)
|
||||||
|
|> Enum.reduce({0, nil}, fn chunk, {total_count, _} ->
|
||||||
|
entries =
|
||||||
|
Enum.map(chunk, fn attrs ->
|
||||||
|
Map.merge(attrs, %{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
{count, rows} =
|
||||||
|
Repo.insert_all(GefsProfile, entries,
|
||||||
|
on_conflict: :nothing,
|
||||||
|
conflict_target: [:lat, :lon, :valid_time]
|
||||||
|
)
|
||||||
|
|
||||||
|
{total_count + count, rows}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
@spec upsert_hrrr_profile(map()) :: {:ok, HrrrProfile.t()} | {:error, Ecto.Changeset.t()}
|
@spec upsert_hrrr_profile(map()) :: {:ok, HrrrProfile.t()} | {:error, Ecto.Changeset.t()}
|
||||||
def upsert_hrrr_profile(attrs) do
|
def upsert_hrrr_profile(attrs) do
|
||||||
%HrrrProfile{}
|
%HrrrProfile{}
|
||||||
|
|
|
||||||
112
lib/microwaveprop/weather/gefs_client.ex
Normal file
112
lib/microwaveprop/weather/gefs_client.ex
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
defmodule Microwaveprop.Weather.GefsClient do
|
||||||
|
@moduledoc """
|
||||||
|
Client for the NCEP Global Ensemble Forecast System (GEFS) served
|
||||||
|
anonymously from NOMADS at
|
||||||
|
`https://nomads.ncep.noaa.gov/pub/data/nccf/com/gens/prod/`.
|
||||||
|
|
||||||
|
GEFS is the 0.5° / 31-member physics-based global ensemble. It runs
|
||||||
|
four times per day (00/06/12/18Z) with 3-hourly output to +240 h and
|
||||||
|
6-hourly output to +384 h. The `pgrb2ap5/` product family contains
|
||||||
|
the subset of surface and pressure-level variables the propagation
|
||||||
|
scorer needs for extended-horizon (Day 2-7) outlooks, once HRRR's
|
||||||
|
18 h horizon runs out.
|
||||||
|
|
||||||
|
This module exposes the URL, time, and message-inventory helpers.
|
||||||
|
Byte-range idx parsing and HTTP range fetch logic are shared with
|
||||||
|
`Microwaveprop.Weather.HrrrClient` since the GRIB2/idx format is
|
||||||
|
identical; the GEFS-specific pieces are the NOMADS path shape, the
|
||||||
|
3-then-6-hourly forecast-hour list, and the Magnus dewpoint
|
||||||
|
derivation (GEFS pgrb2a publishes RH rather than Td).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@base_url "https://nomads.ncep.noaa.gov/pub/data/nccf/com/gens/prod"
|
||||||
|
|
||||||
|
@run_hours [0, 6, 12, 18]
|
||||||
|
|
||||||
|
# Variables the propagation scorer reads. All present in pgrb2a f000-f384
|
||||||
|
# per an April 2026 .idx probe. DPT is absent from GEFS output — derive
|
||||||
|
# it from TMP + RH via Magnus (see `dewpoint_from_rh/2`). HPBL is also
|
||||||
|
# absent; the scorer tolerates a nil HPBL without penalising the score.
|
||||||
|
@surface_messages [
|
||||||
|
%{var: "TMP", level: "2 m above ground"},
|
||||||
|
%{var: "RH", level: "2 m above ground"},
|
||||||
|
%{var: "PRES", level: "surface"},
|
||||||
|
%{var: "PWAT", level: "entire atmosphere (considered as a single layer)"},
|
||||||
|
%{var: "UGRD", level: "10 m above ground"},
|
||||||
|
%{var: "VGRD", level: "10 m above ground"},
|
||||||
|
%{var: "TCDC", level: "entire atmosphere"},
|
||||||
|
%{var: "APCP", level: "surface"}
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Rounds a wall-clock `DateTime` down to the nearest GEFS run cycle
|
||||||
|
(00/06/12/18Z) on the same UTC day.
|
||||||
|
"""
|
||||||
|
@spec nearest_run(DateTime.t()) :: DateTime.t()
|
||||||
|
def nearest_run(%DateTime{} = dt) do
|
||||||
|
run_hour = dt.hour |> div(6) |> Kernel.*(6)
|
||||||
|
|
||||||
|
dt
|
||||||
|
|> DateTime.truncate(:second)
|
||||||
|
|> Map.put(:hour, run_hour)
|
||||||
|
|> Map.put(:minute, 0)
|
||||||
|
|> Map.put(:second, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds the NOMADS URL for the GEFS ensemble-mean (`geavg`) pgrb2a 0.5°
|
||||||
|
GRIB2 file at the given `run_date`, `run_hour`, and `forecast_hour`.
|
||||||
|
"""
|
||||||
|
@spec ensmean_url(Date.t(), non_neg_integer(), non_neg_integer()) :: String.t()
|
||||||
|
def ensmean_url(%Date{} = run_date, run_hour, forecast_hour) when run_hour in @run_hours and forecast_hour >= 0 do
|
||||||
|
date_str = Calendar.strftime(run_date, "%Y%m%d")
|
||||||
|
hh = pad2(run_hour)
|
||||||
|
fff = pad3(forecast_hour)
|
||||||
|
|
||||||
|
"#{@base_url}/gefs.#{date_str}/#{hh}/atmos/pgrb2ap5/geavg.t#{hh}z.pgrb2a.0p50.f#{fff}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Appends `.idx` to a GRIB2 URL to locate its wgrib2 byte-range index."
|
||||||
|
@spec idx_url(String.t()) :: String.t()
|
||||||
|
def idx_url(grib_url) when is_binary(grib_url), do: grib_url <> ".idx"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the list of GEFS forecast hours published per run: 3-hourly
|
||||||
|
from f000 through f240, then 6-hourly from f246 through f384.
|
||||||
|
"""
|
||||||
|
@spec forecast_hours() :: [non_neg_integer()]
|
||||||
|
def forecast_hours do
|
||||||
|
short = Enum.to_list(0..240//3)
|
||||||
|
long = Enum.to_list(246..384//6)
|
||||||
|
short ++ long
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
GRIB2 message descriptors the propagation scorer needs from the
|
||||||
|
GEFS pgrb2a product. Values match the `var` and `level` strings that
|
||||||
|
appear in the file's `.idx` sidecar exactly.
|
||||||
|
"""
|
||||||
|
@spec surface_messages() :: [%{var: String.t(), level: String.t()}]
|
||||||
|
def surface_messages, do: @surface_messages
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Derives dewpoint in °C from air temperature (°C) and relative humidity
|
||||||
|
(%) using the Magnus formula with the Bolton (1980) coefficients.
|
||||||
|
|
||||||
|
GEFS pgrb2a publishes 2 m RH but not 2 m DPT, so downstream code that
|
||||||
|
consumed HRRR's `surface_dewpoint_c` must call this helper to fill in
|
||||||
|
the equivalent value. RH is clamped to a small positive floor to keep
|
||||||
|
the logarithm finite at 0 %.
|
||||||
|
"""
|
||||||
|
@spec dewpoint_from_rh(float(), float()) :: float()
|
||||||
|
def dewpoint_from_rh(temp_c, rh_pct) when is_number(temp_c) and is_number(rh_pct) do
|
||||||
|
a = 17.625
|
||||||
|
b = 243.04
|
||||||
|
rh = max(rh_pct, 0.01) / 100.0
|
||||||
|
gamma = :math.log(rh) + a * temp_c / (b + temp_c)
|
||||||
|
b * gamma / (a - gamma)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pad2(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||||
|
defp pad3(n), do: n |> Integer.to_string() |> String.pad_leading(3, "0")
|
||||||
|
end
|
||||||
60
lib/microwaveprop/weather/gefs_profile.ex
Normal file
60
lib/microwaveprop/weather/gefs_profile.ex
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
defmodule Microwaveprop.Weather.GefsProfile do
|
||||||
|
@moduledoc """
|
||||||
|
Grid-point atmospheric profile sourced from NCEP GEFS ensemble-mean
|
||||||
|
(`geavg`) pgrb2a 0.5° output. Mirrors the shape of
|
||||||
|
`Microwaveprop.Weather.HrrrProfile` so the existing scorer can run
|
||||||
|
over rows from either source for a given `valid_time`.
|
||||||
|
|
||||||
|
GEFS publishes relative humidity rather than dewpoint, so
|
||||||
|
`surface_dewpoint_c` on these rows is derived by
|
||||||
|
`Microwaveprop.Weather.GefsClient.dewpoint_from_rh/2` at ingest time.
|
||||||
|
HPBL is not in the pgrb2a product; that column intentionally doesn't
|
||||||
|
exist here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
schema "gefs_profiles" do
|
||||||
|
field :valid_time, :utc_datetime
|
||||||
|
field :lat, :float
|
||||||
|
field :lon, :float
|
||||||
|
field :run_time, :utc_datetime
|
||||||
|
field :forecast_hour, :integer
|
||||||
|
field :profile, {:array, :map}
|
||||||
|
field :pwat_mm, :float
|
||||||
|
field :surface_temp_c, :float
|
||||||
|
field :surface_dewpoint_c, :float
|
||||||
|
field :surface_pressure_mb, :float
|
||||||
|
field :surface_refractivity, :float
|
||||||
|
field :min_refractivity_gradient, :float
|
||||||
|
field :ducting_detected, :boolean, default: false
|
||||||
|
field :duct_characteristics, {:array, :map}
|
||||||
|
field :wind_u_mps, :float
|
||||||
|
field :wind_v_mps, :float
|
||||||
|
field :cloud_cover_pct, :float
|
||||||
|
field :precip_mm, :float
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@required_fields ~w(valid_time lat lon)a
|
||||||
|
@optional_fields ~w(run_time forecast_hour profile pwat_mm surface_temp_c surface_dewpoint_c
|
||||||
|
surface_pressure_mb surface_refractivity min_refractivity_gradient
|
||||||
|
ducting_detected duct_characteristics wind_u_mps wind_v_mps
|
||||||
|
cloud_cover_pct precip_mm)a
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
|
def changeset(gefs_profile, attrs) do
|
||||||
|
gefs_profile
|
||||||
|
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||||
|
|> validate_required(@required_fields)
|
||||||
|
|> unique_constraint([:lat, :lon, :valid_time])
|
||||||
|
end
|
||||||
|
end
|
||||||
33
priv/repo/migrations/20260418191400_create_gefs_profiles.exs
Normal file
33
priv/repo/migrations/20260418191400_create_gefs_profiles.exs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.CreateGefsProfiles do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:gefs_profiles, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :valid_time, :utc_datetime, null: false
|
||||||
|
add :lat, :float, null: false
|
||||||
|
add :lon, :float, null: false
|
||||||
|
add :run_time, :utc_datetime
|
||||||
|
add :forecast_hour, :integer
|
||||||
|
add :profile, {:array, :map}, default: []
|
||||||
|
add :pwat_mm, :float
|
||||||
|
add :surface_temp_c, :float
|
||||||
|
add :surface_dewpoint_c, :float
|
||||||
|
add :surface_pressure_mb, :float
|
||||||
|
add :surface_refractivity, :float
|
||||||
|
add :min_refractivity_gradient, :float
|
||||||
|
add :ducting_detected, :boolean, default: false
|
||||||
|
add :duct_characteristics, {:array, :map}
|
||||||
|
add :wind_u_mps, :float
|
||||||
|
add :wind_v_mps, :float
|
||||||
|
add :cloud_cover_pct, :float
|
||||||
|
add :precip_mm, :float
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:gefs_profiles, [:lat, :lon, :valid_time])
|
||||||
|
create index(:gefs_profiles, [:valid_time])
|
||||||
|
create index(:gefs_profiles, [:run_time])
|
||||||
|
end
|
||||||
|
end
|
||||||
123
test/microwaveprop/weather/gefs_client_test.exs
Normal file
123
test/microwaveprop/weather/gefs_client_test.exs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
defmodule Microwaveprop.Weather.GefsClientTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Weather.GefsClient
|
||||||
|
|
||||||
|
describe "nearest_run/1" do
|
||||||
|
test "rounds down to the same 6-hour cycle within the cycle window" do
|
||||||
|
assert GefsClient.nearest_run(~U[2026-04-18 13:30:00Z]) == ~U[2026-04-18 12:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "snaps to 00Z when given 02:15" do
|
||||||
|
assert GefsClient.nearest_run(~U[2026-04-18 02:15:00Z]) == ~U[2026-04-18 00:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "snaps to 18Z when given 23:59" do
|
||||||
|
assert GefsClient.nearest_run(~U[2026-04-18 23:59:00Z]) == ~U[2026-04-18 18:00:00Z]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is a no-op when already on a cycle boundary" do
|
||||||
|
assert GefsClient.nearest_run(~U[2026-04-18 06:00:00Z]) == ~U[2026-04-18 06:00:00Z]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "ensmean_url/3" do
|
||||||
|
test "builds a pgrb2ap5 ensemble-mean URL for a run + forecast hour" do
|
||||||
|
url = GefsClient.ensmean_url(~D[2026-04-18], 12, 24)
|
||||||
|
|
||||||
|
assert url ==
|
||||||
|
"https://nomads.ncep.noaa.gov/pub/data/nccf/com/gens/prod/gefs.20260418/12/atmos/pgrb2ap5/geavg.t12z.pgrb2a.0p50.f024"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pads forecast hour to three digits" do
|
||||||
|
url = GefsClient.ensmean_url(~D[2026-04-18], 0, 6)
|
||||||
|
assert String.ends_with?(url, "geavg.t00z.pgrb2a.0p50.f006")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles 240-hour forecast horizon" do
|
||||||
|
url = GefsClient.ensmean_url(~D[2026-04-18], 12, 240)
|
||||||
|
assert String.ends_with?(url, "geavg.t12z.pgrb2a.0p50.f240")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "pads single-digit runs" do
|
||||||
|
url = GefsClient.ensmean_url(~D[2026-04-18], 6, 24)
|
||||||
|
assert String.contains?(url, "/06/atmos/")
|
||||||
|
assert String.contains?(url, "geavg.t06z.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "idx_url/1" do
|
||||||
|
test "appends .idx to the GRIB2 URL" do
|
||||||
|
base = GefsClient.ensmean_url(~D[2026-04-18], 12, 24)
|
||||||
|
assert GefsClient.idx_url(base) == base <> ".idx"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "forecast_hours/0" do
|
||||||
|
test "returns 3-hourly hours out to 240 then 6-hourly to 384" do
|
||||||
|
hours = GefsClient.forecast_hours()
|
||||||
|
assert Enum.take(hours, 4) == [0, 3, 6, 9]
|
||||||
|
assert 240 in hours
|
||||||
|
assert 246 in hours
|
||||||
|
assert 384 in hours
|
||||||
|
assert List.last(hours) == 384
|
||||||
|
refute 243 in hours
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "surface_messages/0" do
|
||||||
|
test "lists the variables the scorer needs from the pgrb2a file" do
|
||||||
|
msgs = GefsClient.surface_messages()
|
||||||
|
|
||||||
|
vars = Enum.map(msgs, & &1.var)
|
||||||
|
assert "TMP" in vars
|
||||||
|
assert "RH" in vars
|
||||||
|
assert "PRES" in vars
|
||||||
|
assert "PWAT" in vars
|
||||||
|
assert "UGRD" in vars
|
||||||
|
assert "VGRD" in vars
|
||||||
|
assert "TCDC" in vars
|
||||||
|
assert "APCP" in vars
|
||||||
|
end
|
||||||
|
|
||||||
|
test "2m TMP and RH use the '2 m above ground' level string" do
|
||||||
|
msgs = GefsClient.surface_messages()
|
||||||
|
|
||||||
|
assert %{var: "TMP", level: "2 m above ground"} in msgs
|
||||||
|
assert %{var: "RH", level: "2 m above ground"} in msgs
|
||||||
|
end
|
||||||
|
|
||||||
|
test "10m winds use the '10 m above ground' level string" do
|
||||||
|
msgs = GefsClient.surface_messages()
|
||||||
|
|
||||||
|
assert %{var: "UGRD", level: "10 m above ground"} in msgs
|
||||||
|
assert %{var: "VGRD", level: "10 m above ground"} in msgs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "dewpoint_from_rh/2" do
|
||||||
|
test "returns the input temperature when RH is 100%" do
|
||||||
|
# At saturation, Td == T. Magnus returns T exactly.
|
||||||
|
assert_in_delta GefsClient.dewpoint_from_rh(20.0, 100.0), 20.0, 0.05
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a cooler dewpoint than temperature when RH is below saturation" do
|
||||||
|
td = GefsClient.dewpoint_from_rh(25.0, 50.0)
|
||||||
|
assert td < 25.0
|
||||||
|
# Tables put Td(25 °C, 50% RH) at ~13.9 °C. Allow ±0.5 °C slack.
|
||||||
|
assert_in_delta td, 13.9, 0.5
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles very dry air without crashing" do
|
||||||
|
td = GefsClient.dewpoint_from_rh(30.0, 5.0)
|
||||||
|
assert is_float(td)
|
||||||
|
assert td < 0.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clamps RH<=0 to a very-dry fallback rather than returning NaN/Inf" do
|
||||||
|
td = GefsClient.dewpoint_from_rh(10.0, 0.0)
|
||||||
|
assert is_float(td)
|
||||||
|
assert td < -30.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
99
test/microwaveprop/weather/gefs_profile_test.exs
Normal file
99
test/microwaveprop/weather/gefs_profile_test.exs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
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
|
||||||
Loading…
Add table
Reference in a new issue