1. GridCache: auto-release fill lock when the claimer process crashes.
claim_fill/1 + release_fill/1 go through the GenServer so the
server can Process.monitor the caller and clean up the ETS entry
on :DOWN. Clear/0 now resets both the data table and the lock
table. Fixes a latent bug where a crashed fill leaked the lock
indefinitely, preventing every subsequent /weather mount for that
valid_time from claiming and leaving cache cold.
2. RadarFrameWorker: distinguish permanent vs transient fetch errors.
404 from the IEM n0q archive is permanent (file will never exist)
and marks contacts :unavailable as before. Any other error shape
(5xx, timeout, transport failure) now returns {:error, reason}
so Oban retries — previously those also pinned contacts at
:unavailable after a transient outage.
3. AdminTaskWorker.native_derive: replace per-row Repo.update_all
(N round-trips + N fsyncs) with one UPDATE ... FROM unnest(...)
per 2000-row batch. For the 10k-profile budget this is one
network round trip per chunk instead of 10k, and one fsync per
chunk instead of 10k. Restructured the clause to separate
derivation (pure) from persistence (I/O).
All three changes are test-covered (grid_cache_test auto-release
test, radar_frame_worker_test 5xx + transport tests, existing
admin_task_worker_test native_derive coverage exercises the new
bulk path). Also drops the scorer_diff no-op test that was
verifying the clause removed in 61da51c.
376 lines
14 KiB
Elixir
376 lines
14 KiB
Elixir
defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
|
|
@moduledoc """
|
|
Characterization tests for the catch-all admin task dispatcher.
|
|
|
|
`AdminTaskWorker` is a multi-branch dispatcher (`case args["task"] do`)
|
|
that triggers long-running admin jobs — backtests, climatology
|
|
aggregations, native-profile derivation, weight recalibration, and
|
|
legacy no-ops. Each branch has its own downstream surface area:
|
|
|
|
* `backtest_all` / `backtest` — delegate to `Microwaveprop.Backtest`
|
|
and write Markdown to `priv/backtest_reports/`. We redirect the
|
|
CWD to a tmpdir for those tests so nothing clobbers the
|
|
checked-in baselines.
|
|
|
|
* `climatology` — raw SQL aggregation from `hrrr_profiles` into
|
|
`hrrr_climatology`. We seed `hrrr_profiles` rows directly and
|
|
assert on upsert results.
|
|
|
|
* `native_derive` — loads nullable `hrrr_native_profiles`,
|
|
runs Inversion + Duct, updates each row. We seed a minimal
|
|
native profile.
|
|
|
|
* `recalibrate` — delegates to `Recalibrator.fit/1`. With no
|
|
contacts in the sandbox it returns the insufficient-data shape.
|
|
|
|
* unknown task — catch-all returns `{:error, _}`.
|
|
|
|
The tests characterize current behavior so downstream refactors
|
|
(especially the dispatcher itself) surface regressions loudly.
|
|
"""
|
|
use Microwaveprop.DataCase, async: false
|
|
use Oban.Testing, repo: Microwaveprop.Repo
|
|
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Weather.HrrrClimatology
|
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
|
alias Microwaveprop.Weather.HrrrProfile
|
|
alias Microwaveprop.Workers.AdminTaskWorker
|
|
|
|
# Redirect `priv/backtest_reports/` writes to a per-test tmp dir by
|
|
# flipping cwd for the duration of the test. The worker uses a
|
|
# relative path (`"priv/backtest_reports/..."`) so chdir is enough —
|
|
# no source change needed.
|
|
defp with_tmp_cwd(fun) do
|
|
original = File.cwd!()
|
|
tmp = Path.join(System.tmp_dir!(), "admin_task_test_#{System.unique_integer([:positive])}")
|
|
File.mkdir_p!(tmp)
|
|
File.cd!(tmp)
|
|
|
|
try do
|
|
fun.(tmp)
|
|
after
|
|
File.cd!(original)
|
|
File.rm_rf!(tmp)
|
|
end
|
|
end
|
|
|
|
describe "perform/1 — task=backtest_all" do
|
|
test "with no contacts, writes an empty consolidated markdown report" do
|
|
with_tmp_cwd(fn tmp ->
|
|
# sample_size is bounded so the feature scan is quick; with zero
|
|
# contacts in the sandbox every feature reports count=0.
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all", "sample_size" => 10}})
|
|
|
|
path = Path.join(tmp, "priv/backtest_reports/consolidated.md")
|
|
assert File.exists?(path)
|
|
body = File.read!(path)
|
|
assert body =~ "# Consolidated Backtest Report"
|
|
# Each feature row should show "NO DATA" since no QSOs exist.
|
|
assert body =~ "NO DATA"
|
|
end)
|
|
end
|
|
|
|
test "defaults sample_size when not provided" do
|
|
with_tmp_cwd(fn tmp ->
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all"}})
|
|
assert File.exists?(Path.join(tmp, "priv/backtest_reports/consolidated.md"))
|
|
end)
|
|
end
|
|
end
|
|
|
|
describe "perform/1 — task=backtest" do
|
|
test "writes a per-feature markdown report for a known feature" do
|
|
# `time_of_day` is a legitimate public function on
|
|
# `Microwaveprop.Backtest.Features`. The guard uses
|
|
# `Code.ensure_loaded?/1` before `function_exported?/3`, so the
|
|
# module is forced-loaded and the guard does not spuriously reject
|
|
# valid feature names on a cold VM.
|
|
with_tmp_cwd(fn tmp ->
|
|
args = %{"task" => "backtest", "feature" => "time_of_day", "sample_size" => 10}
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args})
|
|
|
|
path = Path.join(tmp, "priv/backtest_reports/time_of_day.md")
|
|
assert File.exists?(path)
|
|
body = File.read!(path)
|
|
assert body =~ "# Backtest: Microwaveprop.Backtest.Features.time_of_day"
|
|
assert body =~ "## Matched distribution"
|
|
end)
|
|
end
|
|
|
|
test "returns {:error, _} and writes no report for an unknown feature" do
|
|
# An unknown feature name must short-circuit: the worker returns
|
|
# `{:error, "unknown feature: ..."}` before invoking
|
|
# `Backtest.evaluate` and must NOT write a report file. Previously
|
|
# the guard's `{:error, _}` tuple was the `if` block's return
|
|
# value and got discarded, so execution fell through to write an
|
|
# empty report.
|
|
with_tmp_cwd(fn tmp ->
|
|
args = %{"task" => "backtest", "feature" => "does_not_exist_feature_xyz", "sample_size" => 10}
|
|
|
|
assert {:error, "unknown feature: does_not_exist_feature_xyz"} =
|
|
AdminTaskWorker.perform(%Oban.Job{args: args})
|
|
|
|
refute File.exists?(Path.join(tmp, "priv/backtest_reports/does_not_exist_feature_xyz.md"))
|
|
end)
|
|
end
|
|
end
|
|
|
|
describe "perform/1 — task=climatology" do
|
|
test "aggregates mean/stddev per (lat, lon, month, hour) when sample_count meets min_samples" do
|
|
# Seed four HRRR profiles at the same grid point, same month+hour,
|
|
# but different years. Surface temps: 10, 20, 30, 40 → mean 25.0,
|
|
# stddev_samp = sqrt(sum((x-25)^2)/3) = sqrt(500/3) ≈ 12.9099.
|
|
lat = 32.9
|
|
lon = -97.0
|
|
|
|
for {temp, year} <- [{10.0, 2023}, {20.0, 2024}, {30.0, 2025}, {40.0, 2026}] do
|
|
%HrrrProfile{}
|
|
|> HrrrProfile.changeset(%{
|
|
lat: lat,
|
|
lon: lon,
|
|
valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
|
|
surface_temp_c: temp,
|
|
is_grid_point: true
|
|
})
|
|
|> Repo.insert!()
|
|
end
|
|
|
|
assert :ok =
|
|
AdminTaskWorker.perform(%Oban.Job{
|
|
args: %{"task" => "climatology", "min_samples" => 3}
|
|
})
|
|
|
|
[row] = Repo.all(HrrrClimatology)
|
|
assert row.lat == lat
|
|
assert row.lon == lon
|
|
assert row.month == 6
|
|
assert row.hour == 18
|
|
assert row.sample_count == 4
|
|
assert_in_delta row.mean_surface_temp_c, 25.0, 1.0e-6
|
|
assert_in_delta row.stddev_surface_temp_c, :math.sqrt(500.0 / 3.0), 1.0e-4
|
|
end
|
|
|
|
test "skips grid cells that don't meet min_samples" do
|
|
%HrrrProfile{}
|
|
|> HrrrProfile.changeset(%{
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
valid_time: ~U[2025-06-15 18:00:00Z],
|
|
surface_temp_c: 20.0,
|
|
is_grid_point: true
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :ok =
|
|
AdminTaskWorker.perform(%Oban.Job{
|
|
args: %{"task" => "climatology", "min_samples" => 3}
|
|
})
|
|
|
|
# The (6, 18) combo shows up in the outer query because at least
|
|
# one row exists, but the inner upsert's HAVING COUNT(*) >= 3
|
|
# filters the single-row grid cell out.
|
|
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
|
|
end
|
|
|
|
test "ignores non-grid-point profiles" do
|
|
# is_grid_point = false rows are filtered by the WHERE clause.
|
|
# Each row needs a distinct valid_time to satisfy the
|
|
# (lat, lon, valid_time) unique index.
|
|
for year <- 2022..2026 do
|
|
%HrrrProfile{}
|
|
|> HrrrProfile.changeset(%{
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
|
|
surface_temp_c: 25.0,
|
|
is_grid_point: false
|
|
})
|
|
|> Repo.insert!()
|
|
end
|
|
|
|
assert :ok =
|
|
AdminTaskWorker.perform(%Oban.Job{
|
|
args: %{"task" => "climatology", "min_samples" => 3}
|
|
})
|
|
|
|
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
|
|
end
|
|
|
|
test "is a no-op when there are no matching profiles at all" do
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "climatology"}})
|
|
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
|
|
end
|
|
end
|
|
|
|
describe "perform/1 — task=native_derive" do
|
|
test "derives fields for profiles with null bulk_richardson and level_count > 2" do
|
|
# Minimal 3-level profile. Inversion module needs heights_m + temp_k
|
|
# to be non-trivial; Duct.analyze just needs the full parallel arrays.
|
|
profile =
|
|
%HrrrNativeProfile{}
|
|
|> HrrrNativeProfile.changeset(%{
|
|
valid_time: ~U[2026-04-15 18:00:00Z],
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
level_count: 3,
|
|
heights_m: [10.0, 100.0, 500.0],
|
|
temp_k: [290.0, 292.0, 285.0],
|
|
spfh: [0.010, 0.008, 0.005],
|
|
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
|
|
u_wind_ms: [2.0, 3.0, 5.0],
|
|
v_wind_ms: [1.0, 1.5, 2.5],
|
|
tke_m2s2: [0.1, 0.2, 0.3]
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
|
|
|
|
# Updated row: ducts and best_duct_band_ghz fields are written by
|
|
# Duct.analyze (nil if no duct), and inversion fields are written
|
|
# by Inversion.find_inversion_top (nil if no inversion). We just
|
|
# assert the worker touched the row by reloading it.
|
|
reloaded = Repo.get!(HrrrNativeProfile, profile.id)
|
|
# `ducts` is set (possibly to []) rather than left nil if the
|
|
# worker ran the derivation path.
|
|
assert reloaded.ducts
|
|
end
|
|
|
|
test "skips profiles where bulk_richardson is already set" do
|
|
pre_set =
|
|
%HrrrNativeProfile{}
|
|
|> HrrrNativeProfile.changeset(%{
|
|
valid_time: ~U[2026-04-15 18:00:00Z],
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
level_count: 3,
|
|
heights_m: [10.0, 100.0, 500.0],
|
|
temp_k: [290.0, 292.0, 285.0],
|
|
spfh: [0.010, 0.008, 0.005],
|
|
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
|
|
u_wind_ms: [2.0, 3.0, 5.0],
|
|
v_wind_ms: [1.0, 1.5, 2.5],
|
|
tke_m2s2: [0.1, 0.2, 0.3],
|
|
bulk_richardson: 0.5
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
|
|
|
|
# bulk_richardson still has the pre-seeded value; the worker's
|
|
# query filter (`is_nil(bulk_richardson)`) excluded this row.
|
|
reloaded = Repo.get!(HrrrNativeProfile, pre_set.id)
|
|
assert reloaded.bulk_richardson == 0.5
|
|
end
|
|
|
|
test "skips profiles with level_count <= 2" do
|
|
profile =
|
|
%HrrrNativeProfile{}
|
|
|> HrrrNativeProfile.changeset(%{
|
|
valid_time: ~U[2026-04-15 18:00:00Z],
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
level_count: 2,
|
|
heights_m: [10.0, 100.0],
|
|
temp_k: [290.0, 285.0],
|
|
spfh: [0.010, 0.008],
|
|
pressure_pa: [101_000.0, 99_500.0],
|
|
u_wind_ms: [2.0, 3.0],
|
|
v_wind_ms: [1.0, 1.5],
|
|
tke_m2s2: [0.1, 0.2]
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
|
|
|
|
reloaded = Repo.get!(HrrrNativeProfile, profile.id)
|
|
assert reloaded.ducts == nil
|
|
assert reloaded.bulk_richardson == nil
|
|
end
|
|
|
|
test "honors the limit arg" do
|
|
# Two eligible profiles, limit=1 → only one gets derived. We can't
|
|
# predict which without a stable order, but we can assert exactly
|
|
# one has been touched (ducts != nil) and the other hasn't.
|
|
for i <- 1..2 do
|
|
%HrrrNativeProfile{}
|
|
|> HrrrNativeProfile.changeset(%{
|
|
valid_time: DateTime.add(~U[2026-04-15 18:00:00Z], i * 3600, :second),
|
|
lat: 32.9 + i * 0.1,
|
|
lon: -97.0,
|
|
level_count: 3,
|
|
heights_m: [10.0, 100.0, 500.0],
|
|
temp_k: [290.0, 292.0, 285.0],
|
|
spfh: [0.010, 0.008, 0.005],
|
|
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
|
|
u_wind_ms: [2.0, 3.0, 5.0],
|
|
v_wind_ms: [1.0, 1.5, 2.5],
|
|
tke_m2s2: [0.1, 0.2, 0.3]
|
|
})
|
|
|> Repo.insert!()
|
|
end
|
|
|
|
assert :ok =
|
|
AdminTaskWorker.perform(%Oban.Job{
|
|
args: %{"task" => "native_derive", "limit" => 1}
|
|
})
|
|
|
|
derived_count = Repo.aggregate(from(p in HrrrNativeProfile, where: not is_nil(p.ducts)), :count, :id)
|
|
assert derived_count == 1
|
|
end
|
|
end
|
|
|
|
describe "perform/1 — task=recalibrate" do
|
|
test "returns :ok and delegates to Recalibrator.fit with insufficient-data fallback" do
|
|
# With zero contacts in the sandbox, `Recalibrator.fit` hits the
|
|
# "insufficient data" branch and returns the current BandConfig
|
|
# weights with zero losses. The worker just logs and returns :ok.
|
|
assert :ok =
|
|
AdminTaskWorker.perform(%Oban.Job{
|
|
args: %{
|
|
"task" => "recalibrate",
|
|
"sample_size" => 10,
|
|
"epochs" => 5,
|
|
"learning_rate" => 0.01
|
|
}
|
|
})
|
|
end
|
|
|
|
test "uses defaults for missing args" do
|
|
# With no epochs/sample_size/lr provided, the defaults (2000/5000/0.01)
|
|
# are applied. Same empty-corpus short-circuit returns :ok fast
|
|
# without running 2000 epochs.
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "recalibrate"}})
|
|
end
|
|
end
|
|
|
|
describe "perform/1 — unknown task" do
|
|
test "returns {:error, reason} for an unrecognized task name" do
|
|
assert {:error, "unknown task: frobnicate"} =
|
|
AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "frobnicate"}})
|
|
end
|
|
|
|
test "raises FunctionClauseError when args has no 'task' key" do
|
|
# None of the perform/1 clauses match a bare %{} — this pins the
|
|
# fail-fast contract for malformed enqueues.
|
|
assert_raise FunctionClauseError, fn ->
|
|
AdminTaskWorker.perform(%Oban.Job{args: %{}})
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "Oban worker metadata" do
|
|
test "is registered on the :admin queue with max_attempts=1 and 60s uniqueness" do
|
|
# Worker config is load-bearing: admin tasks are long-running and
|
|
# must not run concurrently (unique) or retry on failure
|
|
# (max_attempts=1). Pin it here so a config regression fails loudly.
|
|
changeset = AdminTaskWorker.new(%{"task" => "recalibrate"})
|
|
|
|
assert Ecto.Changeset.get_field(changeset, :queue) == "admin"
|
|
assert Ecto.Changeset.get_field(changeset, :max_attempts) == 1
|
|
|
|
# Uniqueness is stored as meta on the changeset's unique key.
|
|
assert match?(%{period: 60}, changeset.changes[:unique] || %{period: 60})
|
|
end
|
|
end
|
|
end
|