prop/test/microwaveprop/propagation/grid_task_enqueuer_test.exs
Graham McIntire 020f05f8eb
feat(hrdps): grid_tasks source column, Canadian mask, HrdpsGridWorker
Stage 3 (Elixir foundation) of the HRDPS plan. Lands the scaffolding
that lets the Rust prop-grid-rs HRDPS branch be wired up cleanly when
it ships, without further DB or Elixir-side changes.

What ships:

- `grid_tasks.source` column (default 'hrrr', backfill is a no-op).
  Replaces the (run_time, forecast_hour, kind) unique index with a
  4-column key (..., source) so HRRR and HRDPS analysis/forecast rows
  for the same cycle coexist.
- `Grid.hrdps_only_points/0` — Canadian cells inside HRDPS bbox
  (49-60°N, -141 to -52°W) but outside HRRR's CONUS bbox. Disjoint
  from `conus_points/0` by construction so the two grids never
  double-write the same (lat, lon).
- `Grid.point_source/1` — `:hrrr | :hrdps | :neither` classification
  for a (lat, lon) pair. Routes per-QSO enrichment to the right NWP
  source.
- `GridTaskEnqueuer.seed_with_analysis/2` and `seed/2` accept a
  `:source` option; defaults to "hrrr" so existing callers are
  unchanged.
- `HrdpsGridWorker` cycle seeder mirroring PropagationGridWorker's
  shape but for HRDPS's 4×/day cadence. `:hrdps` queue added to base
  Oban config.

What's deferred:

- The Rust `prop-grid-rs` worker doesn't yet branch on
  `task.source`. Until that ships, `HrdpsGridWorker` stays out of
  runtime.exs cron — it would just produce rows that the Rust worker
  mishandles as HRRR. Module doc explicitly notes the dormant state.
- Map overlay extension and FreshnessMonitor HRDPS staleness deferred
  to follow-up sessions once real data flows.

Activation path documented in the updated plan file.

Coverage cap recorded as Stage 8 decision: 60°N for v1 (SRTM stops
there). Arctic CDEM deferred to a follow-up plan.
2026-04-29 17:13:00 -05:00

215 lines
6.6 KiB
Elixir

defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
use Microwaveprop.DataCase, async: true
import Ecto.Query
alias Microwaveprop.Propagation.GridTaskEnqueuer
alias Microwaveprop.Repo
test "seeds one row per fh=1..18" do
run_time = ~U[2026-04-19 15:00:00Z]
assert {:ok, 18} = GridTaskEnqueuer.seed(run_time)
rows =
Repo.all(
from(g in "grid_tasks",
where: g.run_time == ^run_time,
select: %{forecast_hour: g.forecast_hour, status: g.status, valid_time: g.valid_time}
)
)
assert length(rows) == 18
assert rows |> Enum.map(& &1.forecast_hour) |> Enum.sort() == Enum.to_list(1..18)
assert Enum.all?(rows, &(&1.status == "queued"))
# Raw schemaless selects return NaiveDateTime; compare against the
# equivalent naive form.
expected_naive = DateTime.to_naive(run_time)
Enum.each(rows, fn r ->
expected = NaiveDateTime.add(expected_naive, r.forecast_hour * 3600, :second)
assert NaiveDateTime.compare(r.valid_time, expected) == :eq
end)
end
test "is idempotent: reseeding the same run_time inserts nothing" do
run_time = ~U[2026-04-19 16:00:00Z]
assert {:ok, 18} = GridTaskEnqueuer.seed(run_time)
assert {:ok, 0} = GridTaskEnqueuer.seed(run_time)
count = Repo.one(from(g in "grid_tasks", where: g.run_time == ^run_time, select: count()))
assert count == 18
end
test "never emits a row for fh=0 — Elixir keeps that chain step" do
run_time = ~U[2026-04-19 17:00:00Z]
assert {:ok, 18} = GridTaskEnqueuer.seed(run_time)
f0_count = Repo.one(from(g in "grid_tasks", where: g.run_time == ^run_time and g.forecast_hour == 0, select: count()))
assert f0_count == 0
end
describe "source-aware seeding" do
test "seed/2 defaults to source='hrrr'" do
run_time = ~U[2026-04-29 12:00:00Z]
assert {:ok, 18} = GridTaskEnqueuer.seed(run_time)
sources =
Repo.all(from(g in "grid_tasks", where: g.run_time == ^run_time, select: g.source))
assert Enum.uniq(sources) == ["hrrr"]
end
test "seed_with_analysis/2 with source='hrdps' tags every row" do
run_time = ~U[2026-04-29 12:00:00Z]
assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps")
sources =
Repo.all(from(g in "grid_tasks", where: g.run_time == ^run_time, select: g.source))
assert length(sources) == 19
assert Enum.uniq(sources) == ["hrdps"]
end
test "HRRR and HRDPS chains for the same run_time coexist (4-column unique key)" do
run_time = ~U[2026-04-29 18:00:00Z]
assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrrr")
assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps")
total = Repo.one(from(g in "grid_tasks", where: g.run_time == ^run_time, select: count()))
assert total == 38
end
test "reseeding the same run_time + source is idempotent" do
run_time = ~U[2026-04-29 06:00:00Z]
assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps")
assert {:ok, 0} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps")
end
end
describe "reclaim_stale_running/1" do
test "flips stale-running rows back to queued and clears claimed_at" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second)
insert_task(%{
run_time: ~U[2026-04-19 20:00:00Z],
forecast_hour: 5,
valid_time: ~U[2026-04-20 01:00:00Z],
status: "running",
attempt: 1,
claimed_at: stale
})
assert %{requeued: 1, failed: 0} = GridTaskEnqueuer.reclaim_stale_running()
row =
Repo.one!(
from(t in "grid_tasks",
where: t.run_time == ^~U[2026-04-19 20:00:00Z] and t.forecast_hour == 5,
select: %{status: t.status, claimed_at: t.claimed_at, attempt: t.attempt}
)
)
assert row.status == "queued"
assert is_nil(row.claimed_at)
# attempt is NOT incremented — claim_next does that on re-claim.
assert row.attempt == 1
end
test "leaves recently-claimed running rows untouched" do
now = DateTime.truncate(DateTime.utc_now(), :second)
fresh = DateTime.add(now, -60, :second)
insert_task(%{
run_time: ~U[2026-04-19 21:00:00Z],
forecast_hour: 6,
valid_time: ~U[2026-04-20 03:00:00Z],
status: "running",
attempt: 1,
claimed_at: fresh
})
assert %{requeued: 0, failed: 0} = GridTaskEnqueuer.reclaim_stale_running()
row =
Repo.one!(
from(t in "grid_tasks",
where: t.run_time == ^~U[2026-04-19 21:00:00Z] and t.forecast_hour == 6,
select: %{status: t.status}
)
)
assert row.status == "running"
end
test "flips stale rows past max attempts to failed" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second)
insert_task(%{
run_time: ~U[2026-04-19 22:00:00Z],
forecast_hour: 7,
valid_time: ~U[2026-04-20 05:00:00Z],
status: "running",
attempt: 5,
claimed_at: stale
})
assert %{requeued: 0, failed: 1} = GridTaskEnqueuer.reclaim_stale_running()
row =
Repo.one!(
from(t in "grid_tasks",
where: t.run_time == ^~U[2026-04-19 22:00:00Z] and t.forecast_hour == 7,
select: %{status: t.status, error: t.error, claimed_at: t.claimed_at}
)
)
assert row.status == "failed"
assert is_nil(row.claimed_at)
assert row.error =~ "reclaim"
end
test "does not touch queued or done rows even if claimed_at is ancient" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second)
insert_task(%{
run_time: ~U[2026-04-19 23:00:00Z],
forecast_hour: 8,
valid_time: ~U[2026-04-20 07:00:00Z],
status: "done",
attempt: 1,
claimed_at: stale,
completed_at: stale
})
assert %{requeued: 0, failed: 0} = GridTaskEnqueuer.reclaim_stale_running()
end
end
defp insert_task(attrs) do
now = DateTime.truncate(DateTime.utc_now(), :microsecond)
row =
Map.merge(
%{
id: Ecto.UUID.bingenerate(),
status: "queued",
attempt: 0,
kind: "forecast",
claimed_at: nil,
completed_at: nil,
error: nil,
inserted_at: now,
updated_at: now
},
attrs
)
{1, _} = Repo.insert_all("grid_tasks", [row])
end
end