prop/test/microwaveprop/propagation/grid_score_test.exs
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

95 lines
2.8 KiB
Elixir

defmodule Microwaveprop.Propagation.GridScoreTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Propagation.GridScore
@valid_attrs %{
lat: 32.875,
lon: -97.0,
valid_time: ~U[2026-03-28 18:00:00Z],
band_mhz: 10_000,
score: 72,
factors: %{
"humidity" => 85,
"time_of_day" => 60,
"td_depression" => 70,
"refractivity" => 55,
"sky" => 80,
"season" => 65,
"wind" => 50,
"rain" => 90,
"pressure" => 45
}
}
describe "changeset/2" do
test "valid attributes" do
changeset = GridScore.changeset(%GridScore{}, @valid_attrs)
assert changeset.valid?
end
test "requires the core fields except factors" do
changeset = GridScore.changeset(%GridScore{}, %{})
errors = errors_on(changeset)
assert %{
lat: ["can't be blank"],
lon: ["can't be blank"],
valid_time: ["can't be blank"],
band_mhz: ["can't be blank"],
score: ["can't be blank"]
} = errors
refute Map.has_key?(errors, :factors)
end
test "persists with nil factors (forecast-hour rows skip the factor breakdown)" do
attrs = Map.delete(@valid_attrs, :factors)
changeset = GridScore.changeset(%GridScore{}, attrs)
assert changeset.valid?
assert {:ok, score} = Repo.insert(changeset)
assert score.factors == nil
end
test "persists to database" do
changeset = GridScore.changeset(%GridScore{}, @valid_attrs)
assert {:ok, score} = Repo.insert(changeset)
assert score.lat == 32.875
assert score.lon == -97.0
assert score.band_mhz == 10_000
assert score.score == 72
assert score.factors["humidity"] == 85
end
test "enforces unique lat + lon + valid_time + band_mhz" do
assert {:ok, _} =
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
assert {:error, changeset} =
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
assert %{lat: ["has already been taken"]} = errors_on(changeset)
end
test "allows same location at different times" do
assert {:ok, _} =
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
later_attrs = Map.put(@valid_attrs, :valid_time, ~U[2026-03-28 19:00:00Z])
assert {:ok, _} =
%GridScore{} |> GridScore.changeset(later_attrs) |> Repo.insert()
end
test "allows same location and time for different bands" do
assert {:ok, _} =
%GridScore{} |> GridScore.changeset(@valid_attrs) |> Repo.insert()
other_band_attrs = Map.put(@valid_attrs, :band_mhz, 24_000)
assert {:ok, _} =
%GridScore{} |> GridScore.changeset(other_band_attrs) |> Repo.insert()
end
end
end