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