prop/test/microwaveprop/propagation/pgrid_test.exs
Graham McIntire 63f25a9612
Some checks failed
Build prop-grid-rs / Test, build, push (push) Successful in 5m54s
Build and Push / Build and Push Docker Image (push) Failing after 4m44s
perf(grid-rs): dense grid, fused scoring pass, and columnar .pgrid profiles
Reworks the post-fetch half of the propagation pipeline. Fetch and GRIB2
decode were already cheap — measured against a live HRRR cycle, all 39
pressure messages decode via `wgrib2 -lola` in 0.29 s and the 31 MB
byte-range fetch takes ~3 s — so nothing here touches the decoder. All the
cost was downstream.

Also fixes a broken NOTIFY that made every chain step run up to 5 times.

pg_notify
  `NOTIFY propagation_ready, $1` is a Postgres syntax error: NOTIFY is a
  utility statement whose payload must be a literal, so a bind raises
  42601. It shared a transaction with the `status='done'` UPDATE, so every
  successful step rolled back, stayed 'running', and was requeued by
  reclaim_stale_running up to @max_reclaim_attempts times. Elixir's
  NotifyListener never fired either, so ScoreCache warm and the
  "propagation:updated" fan-out were dead.

FieldGrid
  A decoded grid was HashMap<(i32,i32), HashMap<Arc<str>, f32>> — a dense
  rectangular grid stored as ~95k nested hash maps, costing ~4.6M inserts
  on decode, ~3.7M on merge and ~14M lookups across three derivation
  passes. wgrib2 -lola already emits one dense row-major f32 block per
  message, so keep it: dense per-message planes, names hashed once per
  grid into plane ids, NaN as the missing sentinel. This is what forced
  PROP_GRID_RS_PARALLELISM=1 under a 3Gi limit.

Fused pass
  Three 95k-cell derivation passes plus 23 band-major scoring passes over
  a staged Vec<(f64,f64,Conditions,BandInvariants)> (~19MB re-streamed 23
  times) collapse into one pass: levels extracted once per cell, all 23
  bands scored while the cell is hot, scores accumulated cell-major so
  rayon chunks own disjoint slices. Scores land straight in the dense
  score-file body — no ScorePoint scatter.

.pgrid
  The profile artifact was an rmpv tree plus gzip -9, written 30x an hour,
  and ProfilesFile.read_point/3 gunzipped and unpacked the entire 95k-cell
  file to return one cell on every map click and Skew-T load. Replaced
  with a dense cell-major f32 record array carrying a self-describing
  field table. Elixir reads it via :file.pread; .mp.gz and .etf.gz remain
  readable so files written before this drain out of the 48h window.

  Measured on a full CONUS grid (95,073 cells x 48 planes x 23 bands):
    derive + score + build artifacts   0.022 s
    profile write   3.957 s -> 0.006 s (22.0 MB -> 22.4 MB on disk)
    single-cell read   whole-file decode -> 0.5 us
    23 score files     0.003 s

Also
  - hrrr_points: batched UNNEST upsert replacing one awaited INSERT per
    point. Keeps ON CONFLICT DO UPDATE — the PSKR sampler's two-pass loop
    depends on it.
  - fetcher: real semaphore capping in-flight ranges at
    MAX_PARALLEL_RANGES, which the comment claimed but the code did not do
    (it spawned all 27 while the connection pool was sized for 8).
  - metrics: per-stage histogram. Only chain-step and decode durations
    were instrumented, which is why the write cost stayed invisible.
  - profiles_file: parse_valid_time anchors on the known extension set, so
    sibling-suffixed names like <iso>.hrdps.prop no longer parse as
    <iso>.hrdps and vanish from prune and list operations.
  - PROP_GRID_RS_PARALLELISM 1 -> 3. Memory limit held at 3Gi until RSS is
    observed at the new parallelism.
  - cargo fmt over the crate; worker.rs, hrdps_fetcher.rs and nexrad.rs
    were already unformatted at HEAD and the pre-commit hook gates on it.

HRDPS still runs at 0.5 degrees. wgrib2 -lola scales linearly in output
points on rotated lat/lon (12.5 s wall, 202 s CPU for one message at
0.125 degrees) because it has no inverse projection for those grids; a raw
native dump is 0.32 s. The fix is decode-once plus a closed-form
rotated-pole index, left for a follow-up.
2026-08-01 08:23:36 -05:00

323 lines
11 KiB
Elixir

defmodule Microwaveprop.Propagation.PgridTest do
@moduledoc """
The `.pgrid` reader is the Elixir half of a format shared with
`rust/prop_grid_rs/src/pgrid.rs`. These tests build the binary by hand
to the documented layout, which is what pins the two implementations
together — if the Rust writer's header or field ordering drifts, the
hand-built fixture here still asserts the contract the reader expects.
"""
# NOT async: the setup swaps `:propagation_scores_dir` in the
# application env, which is global. Running concurrently with other
# tests lets them observe this suite's temp directory (and lets them
# observe each other's), which showed up as cross-suite failures in
# ImportLive / ContactEdit / MonitorLive as well as flaky failures here.
use ExUnit.Case, async: false
alias Microwaveprop.Propagation.Pgrid
alias Microwaveprop.Propagation.ProfilesFile
@magic "PGRD"
@version 1
@field_name_len 32
@scalar_fields ~w(
surface_temp_c surface_dewpoint_c surface_pressure_mb hpbl_m pwat_mm
wind_u wind_v cloud_cover_pct precip_mm native_min_gradient
best_duct_freq_ghz max_duct_thickness_m duct_count
nexrad_max_reflectivity_dbz commercial_degradation_db
commercial_baseline_dbm commercial_current_dbm commercial_n_links
surface_refractivity min_refractivity_gradient
)
@levels [1000, 925, 850]
setup do
dir = Path.join(System.tmp_dir!(), "pgrid_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(Path.join(dir, "profiles"))
prev = Application.get_env(:microwaveprop, :propagation_scores_dir)
Application.put_env(:microwaveprop, :propagation_scores_dir, dir)
on_exit(fn ->
if prev,
do: Application.put_env(:microwaveprop, :propagation_scores_dir, prev),
else: Application.delete_env(:microwaveprop, :propagation_scores_dir)
File.rm_rf(dir)
end)
{:ok, dir: dir}
end
defp field_names do
@scalar_fields ++
Enum.flat_map(@levels, fn p -> ["hght_m_#{p}mb", "tmpc_#{p}mb", "dwpc_#{p}mb"] end)
end
defp nan, do: <<0, 0, 192, 127>>
# Build a .pgrid byte-for-byte per the documented layout. `cells` is a
# map of cell_index => %{field_name => value}; everything else is NaN.
defp build(valid_time, opts, cells) do
names = field_names()
n_fields = length(names)
n_rows = Keyword.fetch!(opts, :n_rows)
n_cols = Keyword.fetch!(opts, :n_cols)
table =
names
|> Enum.map(fn n -> n <> String.duplicate(<<0>>, @field_name_len - byte_size(n)) end)
|> IO.iodata_to_binary()
body =
for cell <- 0..(n_rows * n_cols - 1), name <- names, into: <<>> do
case cells |> Map.get(cell, %{}) |> Map.get(name) do
nil -> nan()
v -> <<v::float-little-32>>
end
end
flags = if opts[:hrdps], do: 1, else: 0
unix = DateTime.to_unix(valid_time)
lat_start = Keyword.fetch!(opts, :lat_start)
lon_start = Keyword.fetch!(opts, :lon_start)
header =
@magic <>
<<@version, flags, n_fields::little-16, unix::little-signed-64, lat_start::little-float-64,
lon_start::little-float-64, 0.125::little-float-64, 0.125::little-float-64, n_rows::little-16,
n_cols::little-16>>
header <> table <> body
end
defp write!(valid_time, opts, cells) do
path = Pgrid.path_for(valid_time)
File.mkdir_p!(Path.dirname(path))
File.write!(path, build(valid_time, opts, cells))
path
end
defp vt, do: ~U[2026-04-19 14:00:00Z]
defp grid_opts, do: [n_rows: 2, n_cols: 3, lat_start: 32.0, lon_start: -97.0]
defp full_cell do
%{
"surface_temp_c" => 22.5,
"surface_dewpoint_c" => 17.0,
"surface_pressure_mb" => 1013.2,
"hpbl_m" => 1500.0,
"pwat_mm" => 30.0,
"wind_u" => 3.0,
"wind_v" => 4.0,
"cloud_cover_pct" => 20.0,
"precip_mm" => 0.5,
"duct_count" => 2.0,
"best_duct_freq_ghz" => 18.4,
"surface_refractivity" => 320.0,
"min_refractivity_gradient" => -150.0,
"hght_m_1000mb" => 100.0,
"tmpc_1000mb" => 21.0,
"dwpc_1000mb" => 16.0,
"hght_m_850mb" => 1500.0,
"tmpc_850mb" => 12.75,
"dwpc_850mb" => 7.0
}
end
describe "read_point/3" do
test "returns the cell's scalars with atom keys" do
write!(vt(), grid_opts(), %{0 => full_cell()})
profile = Pgrid.read_point(vt(), 32.0, -97.0)
assert profile.surface_temp_c == 22.5
assert profile.surface_dewpoint_c == 17.0
# `.pgrid` stores f32, so values that aren't exactly representable
# come back with ~7 significant digits (1013.2 -> 1013.2000122).
# The source GRIB2 data is f32 anyway, and HRRR surface pressure is
# good to ~0.1 mb, so this is below the noise floor of the input.
assert_in_delta profile.surface_pressure_mb, 1013.2, 0.001
assert profile.hpbl_m == 1500.0
assert profile.pwat_mm == 30.0
assert profile.wind_u == 3.0
assert profile.cloud_cover_pct == 20.0
assert profile.surface_refractivity == 320.0
assert profile.min_refractivity_gradient == -150.0
end
test "duct_count comes back as an integer, matching the msgpack shape" do
write!(vt(), grid_opts(), %{0 => full_cell()})
assert Pgrid.read_point(vt(), 32.0, -97.0).duct_count === 2
end
test "rebuilds the pressure-level profile list, skipping absent levels" do
write!(vt(), grid_opts(), %{0 => full_cell()})
profile = Pgrid.read_point(vt(), 32.0, -97.0)
# 925 mb was never written, so it must not appear.
assert [l1000, l850] = profile.profile
assert l1000.pres_mb == 1000.0
assert l1000.hght_m == 100.0
assert l1000.tmpc == 21.0
assert l1000.dwpc == 16.0
assert l850.pres_mb == 850.0
assert l850.tmpc == 12.75
end
test "a level with height and temperature but no dewpoint omits :dwpc" do
cell = Map.drop(full_cell(), ["dwpc_1000mb", "hght_m_850mb", "tmpc_850mb", "dwpc_850mb"])
write!(vt(), grid_opts(), %{0 => cell})
assert [level] = Pgrid.read_point(vt(), 32.0, -97.0).profile
assert level.tmpc == 21.0
refute Map.has_key?(level, :dwpc)
end
test "NaN fields are absent rather than surfacing as NaN" do
# Only surface temp written; everything else is the NaN sentinel.
write!(vt(), grid_opts(), %{0 => %{"surface_temp_c" => 10.0}})
profile = Pgrid.read_point(vt(), 32.0, -97.0)
assert profile.surface_temp_c == 10.0
refute Map.has_key?(profile, :pwat_mm)
refute Map.has_key?(profile, :hpbl_m)
refute Map.has_key?(profile, :profile)
end
test "reconstructs the nested commercial_link_degradation map" do
cell =
Map.merge(full_cell(), %{
"commercial_degradation_db" => 6.5,
"commercial_baseline_dbm" => -45.0,
"commercial_current_dbm" => -51.5,
"commercial_n_links" => 3.0
})
write!(vt(), grid_opts(), %{0 => cell})
assert %{
degradation_db: 6.5,
baseline_dbm: -45.0,
current_dbm: -51.5,
n_links: 3
} = Pgrid.read_point(vt(), 32.0, -97.0).commercial_link_degradation
end
test "commercial_* fields are not surfaced at the top level" do
write!(vt(), grid_opts(), %{0 => full_cell()})
profile = Pgrid.read_point(vt(), 32.0, -97.0)
refute Map.has_key?(profile, :commercial_degradation_db)
refute Map.has_key?(profile, :commercial_link_degradation)
end
test "addresses the correct cell for a non-origin coordinate" do
# cell 4 => row 1, col 1 => lat 32.125, lon -96.875
write!(vt(), grid_opts(), %{4 => %{"surface_temp_c" => 99.0}})
assert Pgrid.read_point(vt(), 32.125, -96.875).surface_temp_c == 99.0
assert Pgrid.read_point(vt(), 32.0, -97.0) == nil
end
test "returns nil outside the grid and for a missing file" do
write!(vt(), grid_opts(), %{0 => full_cell()})
assert Pgrid.read_point(vt(), 10.0, -97.0) == nil
assert Pgrid.read_point(vt(), 32.0, -150.0) == nil
assert Pgrid.read_point(~U[2026-04-19 15:00:00Z], 32.0, -97.0) == nil
end
test "an unpopulated cell reads as nil, not an empty map" do
write!(vt(), grid_opts(), %{0 => full_cell()})
assert Pgrid.read_point(vt(), 32.125, -96.875) == nil
end
end
describe "read/1" do
test "returns only populated cells, keyed by snapped lat/lon" do
write!(vt(), grid_opts(), %{
0 => %{"surface_temp_c" => 1.0},
5 => %{"surface_temp_c" => 2.0}
})
assert {:ok, grid} = Pgrid.read(vt())
assert map_size(grid) == 2
assert grid[{32.0, -97.0}].surface_temp_c == 1.0
# cell 5 => row 1, col 2 => lat 32.125, lon -96.75
assert grid[{32.125, -96.75}].surface_temp_c == 2.0
end
test "returns :enoent for a missing file" do
assert Pgrid.read(vt()) == {:error, :enoent}
end
test "rejects a file with the wrong magic" do
path = Pgrid.path_for(vt())
File.mkdir_p!(Path.dirname(path))
bin = build(vt(), grid_opts(), %{0 => full_cell()})
File.write!(path, "XXXX" <> binary_part(bin, 4, byte_size(bin) - 4))
assert Pgrid.read(vt()) == {:error, :enoent}
end
end
describe "header" do
test "carries the hrdps flag and grid geometry" do
bin = build(vt(), Keyword.put(grid_opts(), :hrdps, true), %{})
assert {:ok, header} = Pgrid.parse_header(bin)
assert header.hrdps?
assert header.valid_time == vt()
assert header.n_rows == 2
assert header.n_cols == 3
assert header.lat_start == 32.0
end
test "rejects an unknown version" do
bin = build(vt(), grid_opts(), %{})
bumped = binary_part(bin, 0, 4) <> <<99>> <> binary_part(bin, 5, byte_size(bin) - 5)
assert Pgrid.parse_header(bumped) == :error
end
end
describe "ProfilesFile integration" do
test "read_point/3 prefers .pgrid and returns the same shape" do
write!(vt(), grid_opts(), %{0 => full_cell()})
profile = ProfilesFile.read_point(vt(), 32.0, -97.0)
assert profile.surface_temp_c == 22.5
# Same nested shape the `.mp.gz` reader produced, so Skew-T and
# PathCompute consumers need no change.
assert [%{pres_mb: 1000.0, tmpc: 21.0}, %{pres_mb: 850.0}] = profile.profile
end
test "read_point/3 snaps an off-grid coordinate to the nearest cell" do
write!(vt(), grid_opts(), %{0 => full_cell()})
# 31.98 / -96.99 snap to 32.0 / -97.0.
assert ProfilesFile.read_point(vt(), 31.98, -96.99).surface_temp_c == 22.5
end
test "list_valid_times/0 discovers .pgrid files" do
write!(vt(), grid_opts(), %{0 => full_cell()})
write!(~U[2026-04-19 15:00:00Z], grid_opts(), %{0 => full_cell()})
times = ProfilesFile.list_valid_times()
assert vt() in times
assert ~U[2026-04-19 15:00:00Z] in times
end
test "retain_window/2 prunes .pgrid files outside the window" do
write!(vt(), grid_opts(), %{0 => full_cell()})
write!(~U[2026-04-19 20:00:00Z], grid_opts(), %{0 => full_cell()})
# Keep [14:00, 15:00] — the 20:00 file falls outside.
assert ProfilesFile.retain_window(vt(), 1) == 1
assert File.exists?(Pgrid.path_for(vt()))
refute File.exists?(Pgrid.path_for(~U[2026-04-19 20:00:00Z]))
end
end
end