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.
207 lines
6.1 KiB
Elixir
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
|