Backtest branch's unknown-feature guard was dead code: the if block's
{:error, _} return was discarded and execution fell through, running the
backtest with a just-created atom and writing a report for nonsense
features. Two compounding issues:
- function_exported?/3 returns false for modules not yet loaded in the
BEAM, so even valid feature names triggered the warning path on a
cold VM.
- String.to_atom/1 on caller-supplied strings created unbounded atoms.
Fix: Code.ensure_loaded?(Features) before the check, String.to_existing_atom/1
with rescue (Features exports __info__(:functions) so every valid function
atom already exists), and an explicit early return via a with pipeline so
the error tuple actually leaves the worker.
386 lines
14 KiB
Elixir
386 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.
|
|
|
|
* `scorer_diff` — documented no-op.
|
|
|
|
* 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 — task=scorer_diff" do
|
|
test "is a documented no-op" do
|
|
# The propagation_scores table was dropped; this branch only
|
|
# exists to drain in-flight Oban rows from the pre-cutover era.
|
|
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "scorer_diff"}})
|
|
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" => "scorer_diff"})
|
|
|
|
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
|