IEM's ASOS CSV endpoint accepts multiple station= params in a single request. Prior code enqueued one WeatherFetchWorker job per nearby station per QSO endpoint — N jobs × 1 station each — which paid the IemRateLimiter's 1500ms gap and the IEM 429-retry tail N times per contact. Changes: - IemClient.asos_url/3 accepts a list of station codes. - IemClient.fetch_asos_batch/3 fetches N stations in one call and returns rows grouped by station_code (with absent codes filled as empty lists so callers can stub them). - parse_asos_csv/1 now exposes the station_code column it was previously discarding. - WeatherFetchWorker gains an "asos_batch" fetch_type clause that unpacks rows per (station_id, station_code), upserting or stubbing each. The single-station "asos" clause stays for already-queued retryable jobs. - ContactWeatherEnqueueWorker.build_asos_jobs/3 now emits one batch job per (lat, lon, 4h window) covering every uncovered nearby station (sorted for deterministic unique-args). Expected effect on backfill: ~10-20x fewer IEM requests per contact enrichment cycle, matching drop in 429 retry traffic.
318 lines
9.5 KiB
Elixir
318 lines
9.5 KiB
Elixir
defmodule Microwaveprop.Workers.WeatherFetchWorkerTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.IemClient
|
|
alias Microwaveprop.Weather.Sounding
|
|
alias Microwaveprop.Weather.SurfaceObservation
|
|
alias Microwaveprop.Workers.WeatherFetchWorker
|
|
|
|
defp create_asos_station(_) do
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KORD",
|
|
station_type: "asos",
|
|
name: "Chicago O'Hare",
|
|
lat: 41.98,
|
|
lon: -87.9
|
|
})
|
|
|
|
%{station: station}
|
|
end
|
|
|
|
defp create_sounding_station(_) do
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "72451",
|
|
station_type: "sounding",
|
|
name: "Davenport",
|
|
lat: 41.61,
|
|
lon: -90.58
|
|
})
|
|
|
|
%{station: station}
|
|
end
|
|
|
|
defp asos_csv_body do
|
|
"""
|
|
#DEBUG,
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
KORD,2026-03-28 18:53,45.0,30.0,55.0,10.0,270,1013.5,29.92,CLR,null,null
|
|
KORD,2026-03-28 19:53,46.0,31.0,54.0,12.0,280,1013.2,29.91,FEW,0.02,RA
|
|
"""
|
|
end
|
|
|
|
defp raob_json_body do
|
|
%{
|
|
"profiles" => [
|
|
%{
|
|
"valid" => "2026-03-28 12:00:00+00:00",
|
|
"profile" => [
|
|
%{"pres" => 1000.0, "hght" => 200.0, "tmpc" => 15.0, "dwpc" => 10.0, "drct" => 180, "sknt" => 5},
|
|
%{"pres" => 925.0, "hght" => 800.0, "tmpc" => 10.0, "dwpc" => 5.0, "drct" => 200, "sknt" => 10},
|
|
%{"pres" => 850.0, "hght" => 1500.0, "tmpc" => 5.0, "dwpc" => 0.0, "drct" => 220, "sknt" => 15},
|
|
%{"pres" => 700.0, "hght" => 3000.0, "tmpc" => -5.0, "dwpc" => -10.0, "drct" => 240, "sknt" => 25},
|
|
%{"pres" => 500.0, "hght" => 5500.0, "tmpc" => -20.0, "dwpc" => -30.0, "drct" => 260, "sknt" => 40}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
end
|
|
|
|
describe "ASOS batch perform/1" do
|
|
test "fetches one URL covering all stations and ingests per-station rows" do
|
|
{:ok, s1} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KDFW",
|
|
station_type: "asos",
|
|
name: "DFW",
|
|
lat: 32.9,
|
|
lon: -97.0
|
|
})
|
|
|
|
{:ok, s2} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KFTW",
|
|
station_type: "asos",
|
|
name: "FTW",
|
|
lat: 32.82,
|
|
lon: -97.36
|
|
})
|
|
|
|
{:ok, s3} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KAFW",
|
|
station_type: "asos",
|
|
name: "AFW",
|
|
lat: 32.98,
|
|
lon: -97.32
|
|
})
|
|
|
|
# One HTTP call returns rows for s1 + s2; s3 is absent → should stub.
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
body = """
|
|
#DEBUG
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
KDFW,2026-03-28 18:53,75.0,55.0,49.0,12,180,1013.2,29.92,SCT,null,null
|
|
KDFW,2026-03-28 19:53,76.0,54.0,46.0,10,190,1013.0,29.91,FEW,null,null
|
|
KFTW,2026-03-28 18:55,73.0,53.0,49.0,10,190,1013.0,29.91,FEW,null,null
|
|
"""
|
|
|
|
Req.Test.text(conn, body)
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "asos_batch",
|
|
"station_ids" => [s1.id, s2.id, s3.id],
|
|
"station_codes" => ["KDFW", "KFTW", "KAFW"],
|
|
"start_dt" => "2026-03-28T16:00:00Z",
|
|
"end_dt" => "2026-03-28T20:00:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
# 2 real rows for KDFW + 1 for KFTW + 1 stub for KAFW = 4
|
|
assert Repo.aggregate(SurfaceObservation, :count) == 4
|
|
end
|
|
end
|
|
|
|
describe "ASOS perform/1" do
|
|
setup :create_asos_station
|
|
|
|
test "fetches and upserts surface observations", %{station: station} do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.text(conn, asos_csv_body())
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "asos",
|
|
"station_id" => station.id,
|
|
"station_code" => "KORD",
|
|
"start_dt" => "2026-03-28T16:53:00Z",
|
|
"end_dt" => "2026-03-28T20:53:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(SurfaceObservation, :count) == 2
|
|
end
|
|
|
|
test "skips fetch when observations already exist", %{station: station} do
|
|
start_dt = ~U[2026-03-28 16:53:00Z]
|
|
end_dt = ~U[2026-03-28 20:53:00Z]
|
|
|
|
Weather.upsert_surface_observation(station, %{
|
|
observed_at: ~U[2026-03-28 18:53:00Z],
|
|
temp_f: 45.0,
|
|
dewpoint_f: 30.0
|
|
})
|
|
|
|
# Stub should NOT be called — if it is, it will raise
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 500, "Should not be called")
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "asos",
|
|
"station_id" => station.id,
|
|
"station_code" => "KORD",
|
|
"start_dt" => DateTime.to_iso8601(start_dt),
|
|
"end_dt" => DateTime.to_iso8601(end_dt)
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(SurfaceObservation, :count) == 1
|
|
end
|
|
|
|
test "returns error on HTTP failure to trigger Oban retry", %{station: station} do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "asos",
|
|
"station_id" => station.id,
|
|
"station_code" => "KORD",
|
|
"start_dt" => "2026-03-28T16:53:00Z",
|
|
"end_dt" => "2026-03-28T20:53:00Z"
|
|
}
|
|
|
|
assert {:error, "IEM ASOS HTTP 503"} = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
|
|
test "stores stub observation on empty ASOS data so backfill doesn't retry", %{station: station} do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
body = """
|
|
#DEBUG,
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
"""
|
|
|
|
Plug.Conn.send_resp(conn, 200, body)
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "asos",
|
|
"station_id" => station.id,
|
|
"station_code" => "KORD",
|
|
"start_dt" => "2026-03-28T16:53:00Z",
|
|
"end_dt" => "2026-03-28T20:53:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(SurfaceObservation, :count) == 1
|
|
|
|
obs = Repo.one!(SurfaceObservation)
|
|
assert obs.station_id == station.id
|
|
# All weather fields nil — it's a stub
|
|
assert is_nil(obs.temp_f)
|
|
end
|
|
|
|
test "returns :ok when station no longer exists" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 500, "Should not be called")
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "asos",
|
|
"station_id" => Ecto.UUID.generate(),
|
|
"station_code" => "GONE",
|
|
"start_dt" => "2026-03-28T16:53:00Z",
|
|
"end_dt" => "2026-03-28T20:53:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
end
|
|
|
|
describe "RAOB perform/1" do
|
|
setup :create_sounding_station
|
|
|
|
test "fetches and upserts sounding with derived params", %{station: station} do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, raob_json_body())
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "raob",
|
|
"station_id" => station.id,
|
|
"station_code" => "72451",
|
|
"sounding_time" => "2026-03-28T12:00:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(Sounding, :count) == 1
|
|
|
|
sounding = Repo.one(Sounding)
|
|
assert sounding.station_id == station.id
|
|
assert sounding.level_count == 5
|
|
assert sounding.surface_temp_c == 15.0
|
|
end
|
|
|
|
test "skips when sounding already exists", %{station: station} do
|
|
Weather.upsert_sounding(station, %{
|
|
observed_at: ~U[2026-03-28 12:00:00Z],
|
|
profile: [],
|
|
level_count: 0
|
|
})
|
|
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 500, "Should not be called")
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "raob",
|
|
"station_id" => station.id,
|
|
"station_code" => "72451",
|
|
"sounding_time" => "2026-03-28T12:00:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(Sounding, :count) == 1
|
|
end
|
|
|
|
test "stores stub sounding on empty profiles so backfill doesn't retry", %{station: station} do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, %{"profiles" => []})
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "raob",
|
|
"station_id" => station.id,
|
|
"station_code" => "72451",
|
|
"sounding_time" => "2026-03-28T12:00:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(Sounding, :count) == 1
|
|
|
|
sounding = Repo.one!(Sounding)
|
|
assert sounding.profile == []
|
|
assert sounding.level_count == 0
|
|
assert sounding.station_id == station.id
|
|
end
|
|
|
|
test "returns error on HTTP failure", %{station: station} do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
|
end)
|
|
|
|
args = %{
|
|
"fetch_type" => "raob",
|
|
"station_id" => station.id,
|
|
"station_code" => "72451",
|
|
"sounding_time" => "2026-03-28T12:00:00Z"
|
|
}
|
|
|
|
assert {:error, "IEM RAOB HTTP 503"} = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
|
|
test "returns :ok when station no longer exists" do
|
|
args = %{
|
|
"fetch_type" => "raob",
|
|
"station_id" => Ecto.UUID.generate(),
|
|
"station_code" => "GONE",
|
|
"sounding_time" => "2026-03-28T12:00:00Z"
|
|
}
|
|
|
|
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
end
|
|
end
|