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.
323 lines
11 KiB
Elixir
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
|