Wires the GEFS fetch pipeline into the existing scoring machinery: - GefsFetchWorker runs Propagation.score_grid_point over every fetched grid cell and persists the result via replace_scores/2, so Day 2-7 valid_times show up on the map through the same Propagation.scores_at/3 path HRRR uses - Empty-args perform/1 seeds the chain by enqueueing f024-f168 (25 jobs at 6-hour cadence) for the most recent GEFS run that should be published given NOMADS' ~3-4h publication lag - Cron: seeds at 05:30, 11:30, 17:30, 23:30 UTC — 5 hours after each 00/06/12/18Z run - GefsClient.build_profile now emits :wind_u / :wind_v atom keys to match the scorer's input shape; DB columns keep the *_mps suffix for unit clarity, remapped at write time GEFS pgrb2a lacks HPBL, native-level duct data, NEXRAD, and commercial-link degradation — the extended-horizon score is a rougher signal than the HRRR-driven one but covers the window HRRR can't reach.
176 lines
5.4 KiB
Elixir
176 lines
5.4 KiB
Elixir
defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
|
|
use Microwaveprop.DataCase, async: false
|
|
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.GefsProfile
|
|
alias Microwaveprop.Workers.GefsFetchWorker
|
|
|
|
describe "perform/1" do
|
|
test "cancels on malformed (non-empty, not a seed) args" do
|
|
args = %{"some_unrelated" => "garbage"}
|
|
assert {:cancel, _} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
|
|
test "cancels when run_hour is not a valid GEFS cycle" do
|
|
args = %{
|
|
"run_time" => "2026-04-18T05:00:00Z",
|
|
"forecast_hour" => 24
|
|
}
|
|
|
|
assert {:cancel, _} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
end
|
|
|
|
describe "build_profile_attrs/3" do
|
|
test "merges lat/lon/valid_time onto a GefsClient profile map" do
|
|
run_time = ~U[2026-04-18 12:00:00Z]
|
|
|
|
profile = %{
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
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,
|
|
profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}]
|
|
}
|
|
|
|
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
|
|
assert attrs.run_time == run_time
|
|
assert attrs.forecast_hour == 24
|
|
assert attrs.valid_time == ~U[2026-04-19 12:00:00Z]
|
|
assert attrs.lat == 32.9
|
|
assert attrs.surface_temp_c == 20.0
|
|
end
|
|
|
|
test "derives SoundingParams-style refractivity metrics when profile is populated" do
|
|
run_time = ~U[2026-04-18 12:00:00Z]
|
|
|
|
profile = %{
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
surface_temp_c: 20.0,
|
|
surface_dewpoint_c: 15.0,
|
|
surface_pressure_mb: 1013.0,
|
|
pwat_mm: 25.0,
|
|
profile: [
|
|
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
|
%{"pres" => 925.0, "tmpc" => 18.0, "dwpc" => 12.0, "hght" => 770.0},
|
|
%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 1500.0}
|
|
]
|
|
}
|
|
|
|
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
|
|
assert is_float(attrs.surface_refractivity)
|
|
assert is_float(attrs.min_refractivity_gradient)
|
|
assert is_boolean(attrs.ducting_detected)
|
|
end
|
|
end
|
|
|
|
describe "extended_horizon_hours/0" do
|
|
test "spans f024 through f168 at 6-hour cadence (25 entries)" do
|
|
hours = GefsFetchWorker.extended_horizon_hours()
|
|
assert List.first(hours) == 24
|
|
assert List.last(hours) == 168
|
|
assert length(hours) == 25
|
|
assert Enum.all?(hours, &(rem(&1, 6) == 0))
|
|
end
|
|
end
|
|
|
|
describe "most_recent_available_run/1" do
|
|
test "returns the run cycle 5 or more hours earlier" do
|
|
# 15:00 UTC → 10:00 UTC → snaps to 06Z
|
|
assert GefsFetchWorker.most_recent_available_run(~U[2026-04-18 15:00:00Z]) ==
|
|
~U[2026-04-18 06:00:00Z]
|
|
end
|
|
|
|
test "rolls across midnight when now is near 04Z" do
|
|
# 04:00 UTC → 23:00 the day before → snaps to 18Z previous day
|
|
assert GefsFetchWorker.most_recent_available_run(~U[2026-04-18 04:00:00Z]) ==
|
|
~U[2026-04-17 18:00:00Z]
|
|
end
|
|
end
|
|
|
|
describe "scoring interop" do
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Weather.GefsClient
|
|
|
|
test "a GEFS-shaped profile produces non-empty band scores" do
|
|
raw = %{
|
|
"TMP:2 m above ground" => 293.15,
|
|
"RH:2 m above ground" => 50.0,
|
|
"PRES:surface" => 101_325.0,
|
|
"PWAT:entire atmosphere (considered as a single layer)" => 20.0,
|
|
"UGRD:10 m above ground" => 3.0,
|
|
"VGRD:10 m above ground" => -1.5,
|
|
"TCDC:entire atmosphere" => 40.0,
|
|
"APCP:surface" => 0.0,
|
|
"TMP:1000 mb" => 293.15,
|
|
"RH:1000 mb" => 50.0,
|
|
"HGT:1000 mb" => 110.0,
|
|
"TMP:925 mb" => 288.15,
|
|
"RH:925 mb" => 40.0,
|
|
"HGT:925 mb" => 780.0,
|
|
"TMP:850 mb" => 283.15,
|
|
"RH:850 mb" => 35.0,
|
|
"HGT:850 mb" => 1500.0
|
|
}
|
|
|
|
scores = raw |> GefsClient.build_profile() |> Propagation.score_grid_point(~U[2026-04-20 18:00:00Z], 32.9, -97.0)
|
|
|
|
assert is_list(scores)
|
|
assert scores != []
|
|
|
|
score = hd(scores)
|
|
assert is_integer(score.band_mhz)
|
|
assert is_integer(score.score)
|
|
assert score.score >= 0
|
|
assert score.score <= 100
|
|
end
|
|
end
|
|
|
|
describe "storing profiles" do
|
|
test "round-trips a full batch through the context" do
|
|
run_time = ~U[2026-04-18 12:00:00Z]
|
|
fh = 24
|
|
valid_time = DateTime.add(run_time, fh * 3600, :second)
|
|
|
|
batch = [
|
|
%{
|
|
run_time: run_time,
|
|
forecast_hour: fh,
|
|
valid_time: valid_time,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
surface_temp_c: 20.0,
|
|
surface_dewpoint_c: 10.0,
|
|
surface_pressure_mb: 1013.0,
|
|
pwat_mm: 20.0,
|
|
profile: []
|
|
},
|
|
%{
|
|
run_time: run_time,
|
|
forecast_hour: fh,
|
|
valid_time: valid_time,
|
|
lat: 33.0,
|
|
lon: -97.0,
|
|
surface_temp_c: 19.5,
|
|
surface_dewpoint_c: 9.5,
|
|
surface_pressure_mb: 1012.5,
|
|
pwat_mm: 19.0,
|
|
profile: []
|
|
}
|
|
]
|
|
|
|
{count, _} = Weather.upsert_gefs_profiles_batch(batch)
|
|
assert count == 2
|
|
|
|
persisted = Microwaveprop.Repo.all(GefsProfile)
|
|
assert length(persisted) == 2
|
|
end
|
|
end
|
|
end
|