prop/rust/prop_grid_rs/src/planes.rs
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

372 lines
13 KiB
Rust

//! Resolved plane ids for every field the derivation paths read.
//!
//! `FieldGrid` stores planes by `"VAR:LEVEL"` name, but hashing that
//! name once per cell per field is exactly the cost the struct-of-arrays
//! rewrite exists to remove. `GridPlanes` resolves the whole set **once
//! per grid**; after that every access is an array index.
//!
//! All three consumers share this table — `pipeline::cell_to_conditions`,
//! the profile writer, and `weather_scalar_file::derive_row` — so the
//! pressure-level walk that each of them used to redo independently
//! happens once per cell via [`GridPlanes::levels_at`].
use crate::fetcher;
use crate::field_grid::{FieldGrid, PlaneId};
use crate::sounding_params::Level;
/// Plane ids for one pressure level's three variables. `dpt` is
/// optional because HRRR occasionally publishes a level without
/// dewpoint, and the old code tolerated that via `Option`.
#[derive(Debug, Clone, Copy)]
pub struct LevelPlanes {
pub pres_mb: f64,
pub tmp: PlaneId,
pub hgt: PlaneId,
pub dpt: Option<PlaneId>,
}
#[derive(Debug, Clone)]
pub struct GridPlanes {
// Surface / column fields.
pub tmp_2m: Option<PlaneId>,
pub dpt_2m: Option<PlaneId>,
pub ugrd_10m: Option<PlaneId>,
pub vgrd_10m: Option<PlaneId>,
pub tcdc: Option<PlaneId>,
pub pwat: Option<PlaneId>,
pub pres_sfc: Option<PlaneId>,
pub hpbl: Option<PlaneId>,
pub apcp: Option<PlaneId>,
// f00 enrichment planes.
pub nexrad_dbz: Option<PlaneId>,
pub native_min_gradient: Option<PlaneId>,
pub best_duct_freq_ghz: Option<PlaneId>,
pub max_duct_thickness_m: Option<PlaneId>,
pub duct_count: Option<PlaneId>,
pub commercial_degradation_db: Option<PlaneId>,
pub commercial_baseline_dbm: Option<PlaneId>,
pub commercial_current_dbm: Option<PlaneId>,
pub commercial_n_links: Option<PlaneId>,
/// One entry per `fetcher::GRID_PRESSURE_LEVELS` level that is
/// actually present in the grid, in the same order.
pub levels: Vec<LevelPlanes>,
}
impl GridPlanes {
pub fn resolve(grid: &FieldGrid) -> Self {
let levels = fetcher::grid_level_keys()
.iter()
.filter_map(|k| {
// TMP and HGT are both required — the old per-cell
// filter used `?` on each, so a level missing either
// was skipped entirely.
let tmp = grid.plane_id(&k.tmp)?;
let hgt = grid.plane_id(&k.hgt)?;
Some(LevelPlanes {
pres_mb: k.pres_mb,
tmp,
hgt,
dpt: grid.plane_id(&k.dpt),
})
})
.collect();
Self {
tmp_2m: grid.plane_id("TMP:2 m above ground"),
dpt_2m: grid.plane_id("DPT:2 m above ground"),
ugrd_10m: grid.plane_id("UGRD:10 m above ground"),
vgrd_10m: grid.plane_id("VGRD:10 m above ground"),
tcdc: grid.plane_id("TCDC:entire atmosphere"),
pwat: grid.plane_id("PWAT:entire atmosphere (considered as a single layer)"),
pres_sfc: grid.plane_id("PRES:surface"),
hpbl: grid.plane_id("HPBL:surface"),
apcp: grid.plane_id("APCP:surface"),
nexrad_dbz: grid.plane_id("nexrad_max_reflectivity_dbz"),
native_min_gradient: grid.plane_id("native_min_gradient"),
best_duct_freq_ghz: grid.plane_id("best_duct_freq_ghz"),
max_duct_thickness_m: grid.plane_id("max_duct_thickness_m"),
duct_count: grid.plane_id("duct_count"),
commercial_degradation_db: grid.plane_id("commercial_degradation_db"),
commercial_baseline_dbm: grid.plane_id("commercial_baseline_dbm"),
commercial_current_dbm: grid.plane_id("commercial_current_dbm"),
commercial_n_links: grid.plane_id("commercial_n_links"),
levels,
}
}
/// Fill `out` with this cell's pressure-level profile, ascending by
/// pressure index exactly as `fetcher::grid_level_keys()` orders it.
/// Levels whose TMP or HGT is missing at this cell are skipped, which
/// matches the `filter_map` the old per-cell code used.
///
/// Takes `&mut Vec` so callers reuse one buffer across all ~95 k
/// cells instead of allocating a fresh `Vec<Level>` three times per
/// cell (once each for conditions, profile, and scalars).
#[inline]
pub fn levels_at(&self, grid: &FieldGrid, cell: usize, out: &mut Vec<Level>) {
out.clear();
for lp in &self.levels {
let (Some(t), Some(h)) = (grid.at(lp.tmp, cell), grid.at(lp.hgt, cell)) else {
continue;
};
out.push(Level {
pres_mb: lp.pres_mb,
hght_m: h as f64,
tmpc: (t as f64) - 273.15,
dwpc: grid.at_opt(lp.dpt, cell).map(|v| (v as f64) - 273.15),
});
}
}
}
/// Fill one `.pgrid` record (`pgrid::field_names()` order) for `cell`.
///
/// Writes straight into the caller's flat body slice, so the profile
/// artifact costs one `f32` store per field per cell — no `rmpv::Value`
/// tree, no per-level `String` keys, no compression pass. That is the
/// whole point of the format change: the old `.mp.gz` write measured
/// 3.96 s per CONUS grid against 0.039 s for the entire derivation.
pub fn fill_pgrid_record(
grid: &FieldGrid,
p: &GridPlanes,
cell: usize,
levels: &[Level],
out: &mut [f32],
) {
use crate::pgrid;
debug_assert_eq!(out.len(), pgrid::n_fields());
let idx = pgrid_scalar_indices();
let mut put = |slot: usize, v: Option<f32>| {
out[slot] = v.unwrap_or(f32::NAN);
};
put(
idx.surface_temp_c,
grid.at_opt(p.tmp_2m, cell).map(|v| v - 273.15),
);
put(
idx.surface_dewpoint_c,
grid.at_opt(p.dpt_2m, cell).map(|v| v - 273.15),
);
put(
idx.surface_pressure_mb,
grid.at_opt(p.pres_sfc, cell).map(|v| v / 100.0),
);
put(idx.hpbl_m, grid.at_opt(p.hpbl, cell));
put(idx.pwat_mm, grid.at_opt(p.pwat, cell));
put(idx.wind_u, grid.at_opt(p.ugrd_10m, cell));
put(idx.wind_v, grid.at_opt(p.vgrd_10m, cell));
put(idx.cloud_cover_pct, grid.at_opt(p.tcdc, cell));
put(idx.precip_mm, grid.at_opt(p.apcp, cell));
put(
idx.native_min_gradient,
grid.at_opt(p.native_min_gradient, cell),
);
put(
idx.best_duct_freq_ghz,
grid.at_opt(p.best_duct_freq_ghz, cell),
);
put(
idx.max_duct_thickness_m,
grid.at_opt(p.max_duct_thickness_m, cell),
);
put(idx.duct_count, grid.at_opt(p.duct_count, cell));
put(
idx.nexrad_max_reflectivity_dbz,
grid.at_opt(p.nexrad_dbz, cell),
);
put(
idx.commercial_degradation_db,
grid.at_opt(p.commercial_degradation_db, cell),
);
put(
idx.commercial_baseline_dbm,
grid.at_opt(p.commercial_baseline_dbm, cell),
);
put(
idx.commercial_current_dbm,
grid.at_opt(p.commercial_current_dbm, cell),
);
put(
idx.commercial_n_links,
grid.at_opt(p.commercial_n_links, cell),
);
// Pre-computed refractivity scalars: the Skew-T page and contact
// detail need a fallback for when SoundingParams.derive yields nil
// because some level lacks dewpoint.
put(
idx.surface_refractivity,
crate::sounding_params::surface_refractivity(levels).map(|v| v as f32),
);
put(
idx.min_refractivity_gradient,
crate::sounding_params::min_refractivity_gradient(levels.to_vec()).map(|v| v as f32),
);
// Pressure levels. `levels` is filtered (levels missing TMP/HGT at
// this cell are absent), so match each back to its slot by pressure
// rather than by position.
for l in levels {
let Some(base) = pgrid_level_base(l.pres_mb) else {
continue;
};
out[base] = l.hght_m as f32;
out[base + 1] = l.tmpc as f32;
out[base + 2] = l.dwpc.map(|v| v as f32).unwrap_or(f32::NAN);
}
}
/// Slot of `hght_m_<P>mb` for a pressure level, or `None` if that level
/// is not part of the on-disk schema.
fn pgrid_level_base(pres_mb: f64) -> Option<usize> {
let p = pres_mb.round() as u16;
let i = crate::fetcher::GRID_PRESSURE_LEVELS
.iter()
.position(|&lv| lv == p)?;
Some(crate::pgrid::SCALAR_FIELDS.len() + i * 3)
}
/// Resolved slot for each scalar field, computed once.
struct PgridScalarIndices {
surface_temp_c: usize,
surface_dewpoint_c: usize,
surface_pressure_mb: usize,
hpbl_m: usize,
pwat_mm: usize,
wind_u: usize,
wind_v: usize,
cloud_cover_pct: usize,
precip_mm: usize,
native_min_gradient: usize,
best_duct_freq_ghz: usize,
max_duct_thickness_m: usize,
duct_count: usize,
nexrad_max_reflectivity_dbz: usize,
commercial_degradation_db: usize,
commercial_baseline_dbm: usize,
commercial_current_dbm: usize,
commercial_n_links: usize,
surface_refractivity: usize,
min_refractivity_gradient: usize,
}
fn pgrid_scalar_indices() -> &'static PgridScalarIndices {
static IDX: std::sync::OnceLock<PgridScalarIndices> = std::sync::OnceLock::new();
IDX.get_or_init(|| {
let at = |name: &str| {
crate::pgrid::SCALAR_FIELDS
.iter()
.position(|f| *f == name)
.unwrap_or_else(|| panic!("pgrid scalar field {name} missing from SCALAR_FIELDS"))
};
PgridScalarIndices {
surface_temp_c: at("surface_temp_c"),
surface_dewpoint_c: at("surface_dewpoint_c"),
surface_pressure_mb: at("surface_pressure_mb"),
hpbl_m: at("hpbl_m"),
pwat_mm: at("pwat_mm"),
wind_u: at("wind_u"),
wind_v: at("wind_v"),
cloud_cover_pct: at("cloud_cover_pct"),
precip_mm: at("precip_mm"),
native_min_gradient: at("native_min_gradient"),
best_duct_freq_ghz: at("best_duct_freq_ghz"),
max_duct_thickness_m: at("max_duct_thickness_m"),
duct_count: at("duct_count"),
nexrad_max_reflectivity_dbz: at("nexrad_max_reflectivity_dbz"),
commercial_degradation_db: at("commercial_degradation_db"),
commercial_baseline_dbm: at("commercial_baseline_dbm"),
commercial_current_dbm: at("commercial_current_dbm"),
commercial_n_links: at("commercial_n_links"),
surface_refractivity: at("surface_refractivity"),
min_refractivity_gradient: at("min_refractivity_gradient"),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grid::GridSpec;
fn one_cell_spec() -> GridSpec {
GridSpec {
lon_start: -100.0,
lon_count: 1,
lon_step: 0.125,
lat_start: 30.0,
lat_count: 1,
lat_step: 0.125,
}
}
#[test]
fn absent_fields_resolve_to_none() {
let grid = FieldGrid::new(one_cell_spec());
let p = GridPlanes::resolve(&grid);
assert!(p.tmp_2m.is_none());
assert!(p.apcp.is_none());
assert!(p.levels.is_empty());
}
#[test]
fn levels_at_skips_levels_missing_temp_or_height() {
let mut grid = FieldGrid::new(one_cell_spec());
let keys = fetcher::grid_level_keys();
// Level 0 complete, level 1 has TMP but no HGT plane at all.
grid.push_plane(&keys[0].tmp, vec![295.15]);
grid.push_plane(&keys[0].hgt, vec![100.0]);
grid.push_plane(&keys[0].dpt, vec![290.15]);
grid.push_plane(&keys[1].tmp, vec![293.15]);
let p = GridPlanes::resolve(&grid);
let mut out = Vec::new();
p.levels_at(&grid, 0, &mut out);
assert_eq!(out.len(), 1, "level without HGT must be dropped");
assert_eq!(out[0].pres_mb, keys[0].pres_mb);
assert!((out[0].tmpc - 22.0).abs() < 1e-3);
assert!((out[0].dwpc.unwrap() - 17.0).abs() < 1e-3);
}
#[test]
fn levels_at_tolerates_missing_dewpoint_at_a_cell() {
let mut grid = FieldGrid::new(one_cell_spec());
let keys = fetcher::grid_level_keys();
grid.push_plane(&keys[0].tmp, vec![295.15]);
grid.push_plane(&keys[0].hgt, vec![100.0]);
// DPT plane exists but this cell has no value.
grid.push_plane(&keys[0].dpt, vec![f32::NAN]);
let p = GridPlanes::resolve(&grid);
let mut out = Vec::new();
p.levels_at(&grid, 0, &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].dwpc, None);
}
#[test]
fn levels_at_clears_the_buffer_between_cells() {
let mut grid = FieldGrid::new(one_cell_spec());
let keys = fetcher::grid_level_keys();
grid.push_plane(&keys[0].tmp, vec![295.15]);
grid.push_plane(&keys[0].hgt, vec![100.0]);
let p = GridPlanes::resolve(&grid);
let mut out = vec![Level {
pres_mb: 1.0,
hght_m: 1.0,
tmpc: 1.0,
dwpc: None,
}];
p.levels_at(&grid, 0, &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].pres_mb, keys[0].pres_mb);
}
}