prop/test/microwaveprop/pskr/recalibrator_test.exs
Graham McIntire 48000b0dca
feat(pskr): weekly recalibration analysis on the calibration corpus
`Pskr.Recalibrator.run/0` reads `pskr_calibration_samples`, bins
each sample per (band × feature), and writes spot-density stats to
`pskr_feature_bins` so an operator can read whether a feature
actually discriminates propagation at the threshold granularity
the scorer uses.

Bins, not regression: `BandConfig` already encodes scoring as
discrete thresholds, so the bin output matches that shape and an
operator can copy adjusted thresholds directly without translating
from regression coefficients.

Self-healing: corpus too thin ⇒ run row written with status
`skipped_insufficient_data` and the analysis is a no-op until next
fire. `min_total_samples = 1000` (≈ 4-5 days of CONUS PSKR
activity); per-band threshold is 100. Both surface in the run row's
`notes`.

Auto-applies nothing. Weight changes still go through human review
of `BandConfig.@band_configs` and a code commit. The recalibrator
is a read-only analyst that stays out of the production scoring
path.

Features binned (matching the scorer's discriminating fields):
  * pwat_mm — humidity U-shape candidate
  * hpbl_m — boundary layer (mechanism vs scoring re-eval)
  * min_refractivity_gradient — refractivity threshold validation
  * surface_pressure_mb — pressure-front proxy
  * kp_index — aurora boost magnitude tuning

Schema: two tables.
  * `pskr_recalibration_runs` — one row per fire with corpus
    stats, status, notes
  * `pskr_feature_bins` — one row per (run, band, feature, bin)
    with sample_count, spot_count_total/avg/p50/p90

Cron: `0 4 * * 0` (Sundays 04:00 UTC, off-peak, post-climatology).
Manual reruns enqueue with no args.

Tests cover the empty-corpus skip path, sub-threshold totals,
per-band threshold gating, the actual bin emission, nil-feature
handling, spot-count averaging, and the always-records-a-run
audit invariant. 8 new tests, 3282 total passing.

Backfill pipeline untouched.
2026-05-04 16:18:17 -05:00

163 lines
5.3 KiB
Elixir

defmodule Microwaveprop.Pskr.RecalibratorTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Pskr.CalibrationSample
alias Microwaveprop.Pskr.FeatureBin
alias Microwaveprop.Pskr.RecalibrationRun
alias Microwaveprop.Pskr.Recalibrator
alias Microwaveprop.Repo
defp insert_sample!(attrs) do
base = %{
hour_utc: ~U[2026-05-04 18:00:00Z],
band: "10000",
midpoint_lat: 33.0,
midpoint_lon: -97.0,
spot_count: 5,
pwat_mm: 30.0,
hpbl_m: 500.0,
min_refractivity_gradient: -100.0,
surface_pressure_mb: 1013.0,
kp_index: 2
}
{:ok, _} =
%CalibrationSample{}
|> CalibrationSample.changeset(Map.merge(base, attrs))
|> Repo.insert()
end
# Insert N samples for a band, optionally varying one feature.
# Uses a per-call unique offset so successive `insert_n!` invocations
# don't collide on the (hour, band, midpoint_lat, midpoint_lon) key.
defp insert_n!(n, band, attrs_fn) do
offset = :erlang.unique_integer([:positive])
Enum.each(1..n, fn i ->
attrs = attrs_fn.(i)
mp_lat = 33.0 + (offset * 1000 + i) * 0.0001
mp_lon = -97.0 - (offset * 1000 + i) * 0.0001
insert_sample!(Map.merge(attrs, %{band: band, midpoint_lat: mp_lat, midpoint_lon: mp_lon}))
end)
end
describe "run/0" do
test "skips with status `skipped_insufficient_data` when corpus is empty" do
run = Recalibrator.run()
assert run.status == "skipped_insufficient_data"
assert run.sample_count == 0
assert run.notes =~ "≥ 1000"
assert Repo.aggregate(FeatureBin, :count) == 0
end
test "skips when corpus has < 1000 total samples" do
insert_n!(500, "10000", fn _ -> %{} end)
run = Recalibrator.run()
assert run.status == "skipped_insufficient_data"
assert run.sample_count == 500
assert Repo.aggregate(FeatureBin, :count) == 0
end
test "completes and writes feature bins when corpus is dense enough" do
# Spread samples across PWAT bins so several have non-zero counts.
insert_n!(1200, "10000", fn i ->
pwat = 10.0 + rem(i, 60)
%{pwat_mm: pwat, spot_count: rem(i, 10) + 1}
end)
run = Recalibrator.run()
assert run.status == "completed"
assert run.sample_count == 1200
assert run.band_count == 1
assert run.avg_spots_per_sample > 0
bins = Repo.all(from(fb in FeatureBin, where: fb.run_id == ^run.id))
assert length(bins) > 0
# Every bin row should reference the run, target this band, and
# carry non-negative stats.
Enum.each(bins, fn bin ->
assert bin.run_id == run.id
assert bin.band == "10000"
assert bin.sample_count > 0
assert bin.spot_count_total >= 0
end)
# PWAT should populate every defined bin label since we
# spread values 10-69 across the range.
pwat_bins =
bins
|> Enum.filter(&(&1.feature == "pwat_mm"))
|> Enum.map(& &1.bin_label)
|> Enum.sort()
assert "<15" in pwat_bins
assert "15-25" in pwat_bins
assert "25-40" in pwat_bins
assert "40-55" in pwat_bins
assert ">55" in pwat_bins
end
test "skips bands below the per-band threshold even when total corpus passes" do
# Band A gets enough samples; band B (50 samples) is too thin
# to bin even though the total corpus passes the global
# threshold.
insert_n!(1100, "10000", fn _ -> %{spot_count: 3} end)
insert_n!(50, "144", fn _ -> %{spot_count: 1} end)
run = Recalibrator.run()
assert run.status == "completed"
assert run.sample_count == 1150
# band_count counts distinct bands in the corpus, not just the
# bands we ran analysis for, so 2 here is correct.
assert run.band_count == 2
bins = Repo.all(from(fb in FeatureBin, where: fb.run_id == ^run.id))
bands_with_bins = bins |> Enum.map(& &1.band) |> Enum.uniq()
assert "10000" in bands_with_bins
refute "144" in bands_with_bins
end
test "spot_count_avg reflects the actual cell mean" do
# Force every cell to have spot_count = 4 so we know the avg.
insert_n!(1000, "10000", fn _ -> %{spot_count: 4} end)
run = Recalibrator.run()
pwat_bins = Repo.all(from(fb in FeatureBin, where: fb.run_id == ^run.id and fb.feature == "pwat_mm"))
Enum.each(pwat_bins, fn bin ->
if bin.sample_count > 0 do
assert bin.spot_count_avg == 4.0
end
end)
end
test "ignores samples with nil feature value when binning that feature" do
# Half have pwat_mm; half don't. The pwat bins should only
# count the half that does.
insert_n!(500, "10000", fn _ -> %{pwat_mm: 30.0, spot_count: 1} end)
insert_n!(500, "10000", fn _ -> %{pwat_mm: nil, spot_count: 1} end)
run = Recalibrator.run()
assert run.status == "completed"
pwat_total =
FeatureBin
|> where([fb], fb.run_id == ^run.id and fb.feature == "pwat_mm")
|> Repo.all()
|> Enum.map(& &1.sample_count)
|> Enum.sum()
assert pwat_total == 500
end
test "every run records a row in pskr_recalibration_runs regardless of status" do
Recalibrator.run()
Recalibrator.run()
assert Repo.aggregate(RecalibrationRun, :count) == 2
end
end
end