prop/lib/microwaveprop/propagation/scorer_diff.ex
Graham McIntire 5ec66df135
Cut propagation_scores write cost with DELETE+COPY, skip-factors, UNLOGGED
The scoring+upsert phase was ~4m40s per forecast hour and dominated
wall time. Three stacked optimizations attack it from different
angles.

replace_scores/2 is a new hot-path writer that does DELETE WHERE
valid_time = $1 followed by a plain insert_all (no ON CONFLICT
resolution). The chain worker rewrites the full (valid_time, all
bands) slice every forecast hour, so conflict detection was pure
waste. AsosAdjustmentWorker still uses upsert_scores because it
only rewrites the subset of cells near a station.

factors is now nullable. Forecast hours f01-f18 pass factors: nil
so the JSONB encode + toast write is skipped entirely — roughly
halves the data volume per run. point_detail/4 coalesces nil to
an empty map so the JS popup renders without a TypeError, and
scorer_diff only pulls the most recent valid_time that still has
factors (the f00 row).

propagation_scores is now UNLOGGED, so inserts bypass WAL entirely.
Durability tradeoff: an unclean shutdown truncates the table, but
PropagationGridWorker rebuilds it from HRRR every 3h so a lost
table is re-populated within one cron cycle.

Also adds docs/plans/2026-04-14-duckdb-scores-storage.md — a
speculative plan for a flat-file / DuckDB rewrite with explicit
trigger conditions for when to pick it up (partitioning deferred
too; revisit only if these three don't solve it).
2026-04-14 13:47:35 -05:00

211 lines
6 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)
# Only analysis-hour (f00) rows carry the per-factor breakdown;
# forecast hours skip the JSONB write. Pick the most recent
# valid_time that actually has factors so the diff has something
# to recompute against.
latest_time =
GridScore
|> where([g], not is_nil(g.factors))
|> select([g], max(g.valid_time))
|> Repo.one()
if is_nil(latest_time) do
[]
else
query =
GridScore
|> where([g], g.valid_time == ^latest_time and not is_nil(g.factors))
|> 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