prop/test/microwaveprop/pskr/calibration_sampler_test.exs
Graham McIntire fe2ad2df15
fix(pskr): self-healing follow-up sampler runs for hours with missing HRRR
The producer-only path left older hours stuck at 0% HRRR coverage
forever once their fetch tasks drained: the rolling-window cron only
re-passes the prior hour, so manual lookback runs (or any
out-of-window hour) wrote NULL samples + enqueued tasks, then never
re-ran to UPSERT the now-landed HRRR data.

Fix: split the responsibility cleanly.

* CalibrationSampler.build_for_hour/1 now returns
  %{upserted: int, missing_hrrr_cells: int} so callers know whether
  the producer enqueued anything (i.e. whether a follow-up makes
  sense). Spec-tightened with a new @type result.
* PskrCalibrationWorker, after each hour's sampler pass, schedules a
  follow-up of itself for that exact hour 10 min later when missing
  > 0. The follow-up carries args %{"hour_utc" => ..., "_follow_up"
  => true}; the sentinel prevents follow-ups from chaining further
  follow-ups (rolling-window cron is the safety net).

Tests:
* Updated 4 existing sampler tests to the new map return shape.
* Moved the follow-up assertions from the sampler suite into the
  worker suite where they belong (sampler is now scheduling-free).
* Added 3 worker tests: schedules-when-missing, no-schedule-when-
  covered, follow-up-doesn't-chain.

3313 tests + 228 properties, 0 failures.
2026-05-05 18:00:10 -05:00

267 lines
9.7 KiB
Elixir

defmodule Microwaveprop.Pskr.CalibrationSamplerTest do
use Microwaveprop.DataCase, async: true
import Ecto.Query
alias Microwaveprop.Pskr.CalibrationSample
alias Microwaveprop.Pskr.CalibrationSampler
alias Microwaveprop.Pskr.SpotHourly
alias Microwaveprop.Repo
alias Microwaveprop.SpaceWeather.GeomagneticObservation
alias Microwaveprop.Weather.HrrrProfile
@hour ~U[2026-05-04 18:00:00Z]
defp insert_spot!(attrs) do
{:ok, _} =
%SpotHourly{}
|> SpotHourly.changeset(
Map.merge(
%{
hour_utc: @hour,
band: "10000",
sender_grid: "EM12KL",
receiver_grid: "DM43ST",
spot_count: 1,
midpoint_lat: 33.0,
midpoint_lon: -97.0,
distance_km: 200.0,
modes: ["FT8"]
},
attrs
)
)
|> Repo.insert()
end
defp insert_hrrr!(attrs) do
{:ok, _} =
%HrrrProfile{}
|> HrrrProfile.changeset(
Map.merge(
%{
valid_time: @hour,
lat: 33.0,
lon: -97.0,
run_time: @hour,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
pwat_mm: 30.0,
surface_pressure_mb: 1013.0,
min_refractivity_gradient: -100.0,
hpbl_m: 500.0,
ducting_detected: false,
profile: []
},
attrs
)
)
|> Repo.insert()
end
defp insert_kp!(value) do
{:ok, _} =
%GeomagneticObservation{}
|> GeomagneticObservation.changeset(%{
valid_time: DateTime.add(@hour, 60, :second),
kp_index: value
})
|> Repo.insert()
end
describe "build_for_hour/1" do
test "skips and returns zeros when no spots exist for the hour" do
assert %{upserted: 0, missing_hrrr_cells: 0} = CalibrationSampler.build_for_hour(@hour)
assert Repo.aggregate(CalibrationSample, :count) == 0
end
test "builds one sample per (band, snapped midpoint) cell" do
insert_spot!(%{midpoint_lat: 33.01, midpoint_lon: -97.02})
insert_spot!(%{midpoint_lat: 33.04, midpoint_lon: -97.03, sender_grid: "EM13", spot_count: 3})
# Distinct grid cell (>0.125° away)
insert_spot!(%{midpoint_lat: 35.0, midpoint_lon: -97.0, sender_grid: "EM15"})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
assert %{upserted: 2} = CalibrationSampler.build_for_hour(@hour)
[a, b] = CalibrationSample |> Repo.all() |> Enum.sort_by(& &1.midpoint_lat)
# Two near-midpoints collapsed; one distant cell stayed separate.
assert a.spot_count == 4
assert a.distinct_paths == 2
assert b.spot_count == 1
end
test "snaps midpoints to the 0.125° propagation grid" do
insert_spot!(%{midpoint_lat: 33.06, midpoint_lon: -97.07})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
# 33.06 → nearest 0.125° step → 33.0; -97.07 → -97.125
assert sample.midpoint_lat == 33.0
assert sample.midpoint_lon == -97.125
end
test "joins HRRR atmospheric features at the midpoint" do
insert_spot!(%{})
insert_hrrr!(%{
lat: 33.0,
lon: -97.0,
surface_temp_c: 22.5,
surface_dewpoint_c: 16.0,
pwat_mm: 28.0,
min_refractivity_gradient: -150.0,
hpbl_m: 420.0,
ducting_detected: true
})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.surface_temp_c == 22.5
assert sample.surface_dewpoint_c == 16.0
assert sample.pwat_mm == 28.0
assert sample.min_refractivity_gradient == -150.0
assert sample.hpbl_m == 420.0
assert sample.hrrr_ducting_detected == true
end
test "leaves HRRR fields nil when no nearby profile exists" do
insert_spot!(%{})
# No HRRR profile inserted at all.
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.surface_temp_c == nil
assert sample.hpbl_m == nil
assert sample.hrrr_ducting_detected == nil
end
test "enqueues an hrrr_fetch_tasks row when a cell has no nearby HRRR profile" do
insert_spot!(%{midpoint_lat: 33.0, midpoint_lon: -97.0})
assert %{upserted: 1, missing_hrrr_cells: 1} = CalibrationSampler.build_for_hour(@hour)
[task] =
Repo.all(from(t in "hrrr_fetch_tasks", select: %{points: t.points, status: t.status, valid_time: t.valid_time}))
assert task.status == "queued"
assert task.valid_time == DateTime.to_naive(@hour)
# The cell midpoint, snapped to HRRR grid (0.01° rounding).
assert task.points == [%{"lat" => 33.0, "lon" => -97.0}]
end
test "does not enqueue an hrrr_fetch_tasks row when an HRRR profile already covers the cell" do
insert_spot!(%{})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
assert Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count) == 0
end
test "deduplicates HRRR enqueue points across bands sharing a midpoint" do
insert_spot!(%{band: "10000", midpoint_lat: 33.0, midpoint_lon: -97.0})
insert_spot!(%{band: "24000", midpoint_lat: 33.0, midpoint_lon: -97.0, sender_grid: "EM13"})
insert_spot!(%{band: "10000", midpoint_lat: 35.0, midpoint_lon: -97.0, sender_grid: "EM15"})
CalibrationSampler.build_for_hour(@hour)
[task] = Repo.all(from(t in "hrrr_fetch_tasks", select: %{points: t.points}))
# Two unique midpoints (33.0,-97.0) and (35.0,-97.0); the second band at the
# first midpoint must collapse, otherwise the Rust worker double-fetches the
# same lat/lon for one valid_time.
assert length(task.points) == 2
sorted = Enum.sort_by(task.points, & &1["lat"])
assert sorted == [%{"lat" => 33.0, "lon" => -97.0}, %{"lat" => 35.0, "lon" => -97.0}]
end
test "stamps the latest Kp from SpaceWeather on every sample in the hour" do
insert_spot!(%{})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
insert_kp!(5)
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.kp_index == 5
end
test "computes median distance across the cell's path mix" do
insert_spot!(%{distance_km: 100.0, sender_grid: "EM10"})
insert_spot!(%{distance_km: 200.0, sender_grid: "EM11"})
insert_spot!(%{distance_km: 300.0, sender_grid: "EM12"})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert sample.median_distance_km == 200.0
end
test "deduplicates modes across the cell's spots" do
insert_spot!(%{modes: ["FT8"], sender_grid: "EM10"})
insert_spot!(%{modes: ["FT4", "FT8"], sender_grid: "EM11"})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
[sample] = Repo.all(CalibrationSample)
assert Enum.sort(sample.modes) == ["FT4", "FT8"]
end
test "two-pass pipeline: first run NULL+enqueue, second run UPSERTs with HRRR" do
# End-to-end behaviour the producer was added for: a cell with no
# nearby HRRR profile gets a sample row written with NULL weather
# fields *and* a fetch task enqueued; once the Rust hrrr-point-worker
# delivers the profile (simulated here by directly inserting an
# hrrr_profiles row), the next sampler pass UPSERTs the same
# calibration_samples row, this time with non-NULL HRRR fields.
insert_spot!(%{midpoint_lat: 33.0, midpoint_lon: -97.0})
# ── Pass 1 ─────────────────────────────────────────────────
assert %{upserted: 1, missing_hrrr_cells: 1} = CalibrationSampler.build_for_hour(@hour)
[first_sample] = Repo.all(CalibrationSample)
assert first_sample.surface_temp_c == nil
assert first_sample.hpbl_m == nil
assert Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count) == 1
# ── Rust worker drains the task (simulated) ────────────────
insert_hrrr!(%{
lat: 33.0,
lon: -97.0,
surface_temp_c: 24.0,
surface_dewpoint_c: 18.0,
pwat_mm: 31.0,
min_refractivity_gradient: -120.0,
hpbl_m: 480.0,
ducting_detected: true
})
# ── Pass 2 ─────────────────────────────────────────────────
assert %{upserted: 1, missing_hrrr_cells: 0} = CalibrationSampler.build_for_hour(@hour)
[second_sample] = Repo.all(CalibrationSample)
# Same row, UPSERTed (id preserved, weather populated).
assert second_sample.id == first_sample.id
assert second_sample.surface_temp_c == 24.0
assert second_sample.surface_dewpoint_c == 18.0
assert second_sample.pwat_mm == 31.0
assert second_sample.min_refractivity_gradient == -120.0
assert second_sample.hpbl_m == 480.0
assert second_sample.hrrr_ducting_detected == true
end
test "is idempotent — re-running the same hour upserts the row" do
insert_spot!(%{})
insert_hrrr!(%{lat: 33.0, lon: -97.0})
CalibrationSampler.build_for_hour(@hour)
first_count = Repo.aggregate(CalibrationSample, :count)
# Mutate the spot count and re-run; the sample must update, not duplicate.
Repo.update_all(SpotHourly, set: [spot_count: 99])
CalibrationSampler.build_for_hour(@hour)
second_count = Repo.aggregate(CalibrationSample, :count)
assert first_count == second_count
[sample] = Repo.all(CalibrationSample)
assert sample.spot_count == 99
end
end
end