prop/lib/microwaveprop/propagation/scorer_diff.ex
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

206 lines
5.7 KiB
Elixir

defmodule Microwaveprop.Propagation.ScorerDiff do
@moduledoc """
Compare two weight sets on the same propagation scoring data.
Loads the most recent HRRR frame's worth of grid points from
`propagation_scores` (which stores the per-factor scores in JSONB),
re-scores each with both weight sets, and returns diff statistics.
"""
import Ecto.Query
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Repo
@regression_threshold 5
@worst_count 10
@doc """
Compare old and new weights on recent propagation scores.
Options:
* `:limit` — max number of scores to process (default: all for the latest valid_time)
Returns a map with:
* `:summary` — aggregate stats (mean_diff, max_diff, regressions, improvements, total)
* `:band_diffs` — per-band breakdown keyed by band_mhz
* `:worst_regressions` — up to 10 rows with largest score decrease
"""
@spec compare(map(), map(), keyword()) :: %{
summary: map(),
band_diffs: map(),
worst_regressions: [map()]
}
def compare(old_weights, new_weights, opts \\ []) do
scores = load_scores(opts)
diffs = compute_diffs(scores, old_weights, new_weights)
%{
summary: summarize(diffs),
band_diffs: band_breakdown(diffs),
worst_regressions: worst_regressions(diffs)
}
end
@doc """
Compute a weighted sum from a factors map and a weights map.
Both maps use string keys. Returns a rounded integer.
"""
@spec weighted_sum(map(), map()) :: integer()
def weighted_sum(factors, weights) do
weights
|> Enum.reduce(0.0, fn {factor, weight}, acc ->
score = Map.get(factors, factor, 0)
acc + score * weight
end)
|> round()
end
# ── Private ────────────────────────────────────────────────────────
defp load_scores(opts) do
limit = Keyword.get(opts, :limit)
latest_time =
GridScore
|> select([g], max(g.valid_time))
|> Repo.one()
if is_nil(latest_time) do
[]
else
query =
GridScore
|> where([g], g.valid_time == ^latest_time)
|> order_by([g], [g.band_mhz, g.lat, g.lon])
query = if limit, do: limit(query, ^limit), else: query
Repo.all(query)
end
end
defp compute_diffs(scores, old_weights, new_weights) do
Enum.map(scores, fn score ->
factors = score.factors
old_score = weighted_sum(factors, old_weights)
new_score = weighted_sum(factors, new_weights)
diff = new_score - old_score
%{
lat: score.lat,
lon: score.lon,
band_mhz: score.band_mhz,
old_score: old_score,
new_score: new_score,
diff: diff
}
end)
end
defp summarize([]), do: %{mean_diff: 0.0, max_diff: 0, regressions: 0, improvements: 0, total: 0}
defp summarize(diffs) do
total = length(diffs)
abs_diffs = Enum.map(diffs, fn d -> abs(d.diff) end)
mean_diff = Float.round(Enum.sum(abs_diffs) / total, 2)
max_diff = Enum.max(abs_diffs)
regressions = Enum.count(diffs, fn d -> d.diff < -@regression_threshold end)
improvements = Enum.count(diffs, fn d -> d.diff > @regression_threshold end)
%{
mean_diff: mean_diff,
max_diff: max_diff,
regressions: regressions,
improvements: improvements,
total: total
}
end
defp band_breakdown(diffs) do
diffs
|> Enum.group_by(& &1.band_mhz)
|> Map.new(fn {band_mhz, band_diffs} ->
abs_diffs = Enum.map(band_diffs, fn d -> abs(d.diff) end)
mean = Float.round(Enum.sum(abs_diffs) / length(abs_diffs), 2)
regressions = Enum.filter(band_diffs, fn d -> d.diff < 0 end)
max_regression =
case regressions do
[] -> 0
list -> list |> Enum.map(fn d -> abs(d.diff) end) |> Enum.max()
end
{band_mhz, %{mean_diff: mean, max_regression: max_regression}}
end)
end
defp worst_regressions(diffs) do
diffs
|> Enum.filter(fn d -> d.diff < 0 end)
|> Enum.sort_by(fn d -> d.diff end)
|> Enum.take(@worst_count)
|> Enum.map(fn d ->
%{
lat: d.lat,
lon: d.lon,
band_mhz: d.band_mhz,
old_score: d.old_score,
new_score: d.new_score
}
end)
end
@doc """
Format comparison results as a markdown report string.
"""
@spec to_markdown(%{summary: map(), band_diffs: map(), worst_regressions: [map()]}) :: String.t()
def to_markdown(result) do
s = result.summary
summary = """
## Scorer Weight Comparison
| Metric | Value |
|--------|-------|
| Total grid points | #{s.total} |
| Mean absolute diff | #{s.mean_diff} |
| Max absolute diff | #{s.max_diff} |
| Regressions (>#{@regression_threshold} pts) | #{s.regressions} |
| Improvements (>#{@regression_threshold} pts) | #{s.improvements} |
"""
band_header = """
## Per-Band Breakdown
| Band (MHz) | Mean Diff | Max Regression |
|-----------|-----------|----------------|
"""
band_rows =
result.band_diffs
|> Enum.sort_by(fn {band, _} -> band end)
|> Enum.map_join("\n", fn {band, stats} ->
"| #{band} | #{stats.mean_diff} | #{stats.max_regression} |"
end)
worst_header = """
## Worst Regressions
| Lat | Lon | Band | Old | New | Diff |
|-----|-----|------|-----|-----|------|
"""
worst_rows =
Enum.map_join(result.worst_regressions, "\n", fn r ->
diff = r.new_score - r.old_score
"| #{r.lat} | #{r.lon} | #{r.band_mhz} | #{r.old_score} | #{r.new_score} | #{diff} |"
end)
summary <> band_header <> band_rows <> worst_header <> worst_rows
end
end