- GefsClient.build_profile/1: converts raw wgrib2 output into the scorer-shaped profile, deriving Td from RH via Magnus at both the surface and each pressure level - GefsClient.fetch_grid_profiles/3: downloads the idx sidecar from NOMADS, byte-ranges only the surface messages, hands the slim GRIB2 binary to wgrib2 -lola for bilinear regridding from 0.5° onto the CONUS 0.125° grid - GefsFetchWorker: one Oban job per (run_time, forecast_hour), upserts into gefs_profiles and runs SoundingParams.derive so the same refractivity/ducting metrics flow through Oban gets a new :gefs queue (2 slots dev/config, 1 slot per prod pod). Test env gets a gefs_req_options Req.Test stub slot so future integration tests can drive the full fetch path. No cron wiring or scorer integration yet — that's Step 3.
114 lines
3.3 KiB
Elixir
114 lines
3.3 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 "rejects args missing run_time or forecast_hour" do
|
|
job = %Oban.Job{args: %{}}
|
|
assert {:cancel, _} = GefsFetchWorker.perform(job)
|
|
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 "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
|