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).
211 lines
6 KiB
Elixir
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
|