prop/test/microwaveprop/propagation/scorer_diff_test.exs
Graham McIntire e7a7ae073d Phase 9.3, 9.4, and Phase 3 NEXRAD pipeline
Task 9.3 - Weight recalibration via gradient descent:
- Recalibrator module fits logistic regression weights using Nx
- Trains on QSO positives vs random baseline negatives
- Cross-validates by month, normalizes weights to sum to 1.0
- Mix task: mix recalibrate_scorer --sample 5000 --epochs 2000

Task 9.4 - Side-by-side scorer comparison:
- ScorerDiff.compare/3 re-scores grid with old vs new weights
- Reports mean diff, regressions, improvements, per-band breakdown
- Mix task: mix scorer_diff --new-weights '{...}'

Phase 3 - NEXRAD ingestion pipeline:
- NexradClient fetches IEM n0q composite PNGs, extracts per-point
  box statistics (mean/max dBZ, texture variance)
- NexradObservation schema with unique (lat, lon, observed_at)
- NexradWorker on :nexrad queue for background processing
- nexrad_texture backtest feature in Features module
- mix nexrad_backfill --limit 200

All tasks added to AdminTaskWorker and Release for production use.
1116 tests, 0 failures.
2026-04-10 12:48:36 -05:00

207 lines
6.1 KiB
Elixir

defmodule Microwaveprop.Propagation.ScorerDiffTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.ScorerDiff
@valid_time ~U[2026-04-10 12:00:00Z]
@old_weights %{
"humidity" => 0.18,
"time_of_day" => 0.10,
"td_depression" => 0.10,
"refractivity" => 0.08,
"sky" => 0.08,
"season" => 0.08,
"wind" => 0.05,
"rain" => 0.08,
"pressure" => 0.15,
"pwat" => 0.10
}
# Shift weight from pressure to time_of_day
@new_weights %{
"humidity" => 0.18,
"time_of_day" => 0.20,
"td_depression" => 0.10,
"refractivity" => 0.08,
"sky" => 0.08,
"season" => 0.08,
"wind" => 0.05,
"rain" => 0.08,
"pressure" => 0.05,
"pwat" => 0.10
}
defp insert_score!(attrs) do
%GridScore{}
|> GridScore.changeset(attrs)
|> Repo.insert!()
end
defp make_factors(overrides \\ %{}) do
base = %{
"humidity" => 80,
"time_of_day" => 60,
"td_depression" => 70,
"refractivity" => 55,
"sky" => 88,
"season" => 65,
"wind" => 90,
"rain" => 95,
"pressure" => 40,
"pwat" => 75
}
Map.merge(base, overrides)
end
setup do
# Insert scores at two different bands for the same valid_time
insert_score!(%{
lat: 32.875,
lon: -97.0,
valid_time: @valid_time,
band_mhz: 10_000,
score: 70,
factors: make_factors()
})
insert_score!(%{
lat: 33.0,
lon: -97.125,
valid_time: @valid_time,
band_mhz: 10_000,
score: 50,
factors: make_factors(%{"time_of_day" => 20, "pressure" => 90})
})
insert_score!(%{
lat: 32.875,
lon: -97.0,
valid_time: @valid_time,
band_mhz: 24_000,
score: 65,
factors: make_factors(%{"humidity" => 40, "season" => 90})
})
# Insert an older score that should not be included
insert_score!(%{
lat: 32.875,
lon: -97.0,
valid_time: ~U[2026-04-10 11:00:00Z],
band_mhz: 10_000,
score: 55,
factors: make_factors(%{"humidity" => 30})
})
:ok
end
describe "compare/3" do
test "returns summary with mean_diff, max_diff, regressions, improvements" do
result = ScorerDiff.compare(@old_weights, @new_weights)
assert is_map(result.summary)
assert is_float(result.summary.mean_diff)
assert is_number(result.summary.max_diff)
assert is_integer(result.summary.regressions)
assert is_integer(result.summary.improvements)
end
test "returns band_diffs keyed by band_mhz" do
result = ScorerDiff.compare(@old_weights, @new_weights)
assert Map.has_key?(result.band_diffs, 10_000)
assert Map.has_key?(result.band_diffs, 24_000)
assert is_float(result.band_diffs[10_000].mean_diff)
end
test "returns worst_regressions as a list" do
result = ScorerDiff.compare(@old_weights, @new_weights)
assert is_list(result.worst_regressions)
for entry <- result.worst_regressions do
assert Map.has_key?(entry, :lat)
assert Map.has_key?(entry, :lon)
assert Map.has_key?(entry, :band_mhz)
assert Map.has_key?(entry, :old_score)
assert Map.has_key?(entry, :new_score)
end
end
test "computes correct scores from factors and weights" do
result = ScorerDiff.compare(@old_weights, @new_weights)
# For the first 10 GHz point (lat 32.875, lon -97.0):
# factors: humidity=80, time_of_day=60, td_depression=70, refractivity=55,
# sky=88, season=65, wind=90, rain=95, pressure=40, pwat=75
#
# old: 80*0.18 + 60*0.10 + 70*0.10 + 55*0.08 + 88*0.08 + 65*0.08 + 90*0.05 + 95*0.08 + 40*0.15 + 75*0.10
# = 14.4 + 6.0 + 7.0 + 4.4 + 7.04 + 5.2 + 4.5 + 7.6 + 6.0 + 7.5 = 69.64 -> 70
#
# new: 80*0.18 + 60*0.20 + 70*0.10 + 55*0.08 + 88*0.08 + 65*0.08 + 90*0.05 + 95*0.08 + 40*0.05 + 75*0.10
# = 14.4 + 12.0 + 7.0 + 4.4 + 7.04 + 5.2 + 4.5 + 7.6 + 2.0 + 7.5 = 71.64 -> 72
#
# diff = 72 - 70 = +2 (improvement)
# For the second 10 GHz point (lat 33.0, lon -97.125):
# factors: time_of_day=20, pressure=90 (others same)
#
# old: 80*0.18 + 20*0.10 + 70*0.10 + 55*0.08 + 88*0.08 + 65*0.08 + 90*0.05 + 95*0.08 + 90*0.15 + 75*0.10
# = 14.4 + 2.0 + 7.0 + 4.4 + 7.04 + 5.2 + 4.5 + 7.6 + 13.5 + 7.5 = 73.14 -> 73
#
# new: 80*0.18 + 20*0.20 + 70*0.10 + 55*0.08 + 88*0.08 + 65*0.08 + 90*0.05 + 95*0.08 + 90*0.05 + 75*0.10
# = 14.4 + 4.0 + 7.0 + 4.4 + 7.04 + 5.2 + 4.5 + 7.6 + 4.5 + 7.5 = 66.14 -> 66
#
# diff = 66 - 73 = -7 (regression!)
# We should see at least 1 regression (diff > 5 in magnitude)
assert result.summary.regressions >= 1
# Total count should be 3 (only the most recent valid_time)
assert result.summary.total == 3
end
test "identical weights produce zero diff" do
result = ScorerDiff.compare(@old_weights, @old_weights)
assert result.summary.mean_diff == 0.0
assert result.summary.max_diff == 0
assert result.summary.regressions == 0
assert result.summary.improvements == 0
end
test "only loads scores from the most recent valid_time" do
result = ScorerDiff.compare(@old_weights, @new_weights)
# 3 scores at @valid_time (2 for 10GHz, 1 for 24GHz), 1 at earlier time
assert result.summary.total == 3
end
test "accepts limit option" do
result = ScorerDiff.compare(@old_weights, @new_weights, limit: 1)
# Should only process 1 score
assert result.summary.total == 1
end
end
describe "weighted_sum/2" do
test "computes weighted sum from factors and weights" do
factors = %{"humidity" => 100, "time_of_day" => 50}
weights = %{"humidity" => 0.5, "time_of_day" => 0.5}
assert ScorerDiff.weighted_sum(factors, weights) == 75
end
test "rounds to nearest integer" do
factors = %{"humidity" => 80, "time_of_day" => 60}
weights = %{"humidity" => 0.6, "time_of_day" => 0.4}
# 80*0.6 + 60*0.4 = 48 + 24 = 72
assert ScorerDiff.weighted_sum(factors, weights) == 72
end
end
end