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

453 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `.pgrid` — dense, cell-major, random-access profile grid.
//!
//! Replaces the gzipped-MessagePack `.mp.gz` profile artifact. Measured
//! on a full CONUS grid (95,073 cells, 13 pressure levels):
//!
//! | | `.mp.gz` | `.pgrid` |
//! |---|---|---|
//! | write | 3.96 s (rmpv tree + gzip -9) | one `write_all` of 22.4 MB |
//! | size | 22.0 MB | 22.4 MB |
//! | read one cell | gunzip + unpack + atomize **the whole file** | one 236-byte `pread` |
//!
//! The write happens 30× an hour (f00 + f01..f48); the single-cell read
//! happens on every `/map` point click, every Skew-T load and every
//! `PathCompute` call. Both were paying for a format designed for
//! neither.
//!
//! ## Layout (little-endian)
//!
//! ```text
//! magic 4 "PGRD"
//! version 1 0x01
//! flags 1 bit0: 0 = hrrr, 1 = hrdps
//! n_fields 2 u16
//! valid_time 8 i64 unix seconds
//! lat_start 8 f64
//! lon_start 8 f64
//! lat_step 8 f64
//! lon_step 8 f64
//! n_rows 2 u16 (latitude)
//! n_cols 2 u16 (longitude)
//! field_table n_fields × 32 NUL-padded ASCII field names
//! body n_rows*n_cols*n_fields × 4 f32, CELL-MAJOR
//! ```
//!
//! **Cell-major** (`body[(cell * n_fields + field)]`) rather than
//! plane-major: reading one cell across all fields — the thing the UI
//! actually does — is then a single contiguous `pread`. A viewport read
//! is one contiguous `pread` per grid row.
//!
//! `f32::NAN` is the missing-value sentinel, matching
//! [`FieldGrid`][crate::field_grid::FieldGrid].
//!
//! The field table is written into the header rather than being implied
//! by the version, so the Elixir reader resolves fields by name. Adding a
//! field is backwards compatible: an older reader simply does not look
//! it up.
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::fetcher;
use crate::grid::GridSpec;
pub const MAGIC: &[u8; 4] = b"PGRD";
pub const VERSION: u8 = 1;
/// Fixed width of one field-table entry, in bytes.
pub const FIELD_NAME_LEN: usize = 32;
/// Per-cell scalar fields, in on-disk order. Names match the atom keys
/// `Microwaveprop.Propagation.ProfilesFile` already whitelists, so the
/// Elixir reader can hand callers the same map shape the `.mp.gz` path
/// produced.
pub const SCALAR_FIELDS: &[&str] = &[
"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",
];
/// Full field list: the scalars above, then `hght_m_<P>mb`, `tmpc_<P>mb`,
/// `dwpc_<P>mb` for each pressure level. `pres_mb` is implied by the
/// field name, so it costs no bytes.
pub fn field_names() -> &'static [String] {
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| {
let mut v: Vec<String> = SCALAR_FIELDS.iter().map(|s| (*s).to_string()).collect();
for &p in fetcher::GRID_PRESSURE_LEVELS {
v.push(format!("hght_m_{p}mb"));
v.push(format!("tmpc_{p}mb"));
v.push(format!("dwpc_{p}mb"));
}
for n in &v {
assert!(
n.len() < FIELD_NAME_LEN,
"field name {n} exceeds {FIELD_NAME_LEN} bytes"
);
}
v
})
}
pub fn n_fields() -> usize {
field_names().len()
}
/// Byte offset of the body, i.e. the header + field table length.
pub fn header_len(n_fields: usize) -> usize {
4 + 1 + 1 + 2 + 8 + 8 + 8 + 8 + 8 + 2 + 2 + n_fields * FIELD_NAME_LEN
}
#[derive(Debug, thiserror::Error)]
pub enum WriteError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
/// `<scores_dir>/profiles/<iso>.pgrid`.
pub fn path_for(scores_dir: &Path, valid_time: DateTime<Utc>) -> PathBuf {
let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
scores_dir.join("profiles").join(format!("{iso}.pgrid"))
}
/// One cell's record: `n_fields()` f32 values in `field_names()` order.
/// The pipeline fills these directly, so no intermediate map is built.
pub type Record = Vec<f32>;
pub fn empty_record() -> Record {
vec![f32::NAN; n_fields()]
}
/// Serialise the header for `spec` / `valid_time`.
pub fn encode_header(spec: &GridSpec, valid_time: DateTime<Utc>, hrdps: bool) -> Vec<u8> {
let names = field_names();
let mut out = Vec::with_capacity(header_len(names.len()));
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.push(u8::from(hrdps));
out.extend_from_slice(&(names.len() as u16).to_le_bytes());
out.extend_from_slice(&valid_time.timestamp().to_le_bytes());
out.extend_from_slice(&spec.lat_start.to_le_bytes());
out.extend_from_slice(&spec.lon_start.to_le_bytes());
out.extend_from_slice(&spec.lat_step.to_le_bytes());
out.extend_from_slice(&spec.lon_step.to_le_bytes());
out.extend_from_slice(&(spec.lat_count as u16).to_le_bytes());
out.extend_from_slice(&(spec.lon_count as u16).to_le_bytes());
for name in names {
let mut buf = [0u8; FIELD_NAME_LEN];
buf[..name.len()].copy_from_slice(name.as_bytes());
out.extend_from_slice(&buf);
}
debug_assert_eq!(out.len(), header_len(names.len()));
out
}
/// Write `<scores_dir>/profiles/<iso>.pgrid` atomically (tmp + rename,
/// so an NFS reader sees the old file, the new file, or nothing).
///
/// `body` is the flat cell-major `f32` array, length
/// `n_cells * n_fields()`.
pub fn write_atomic(
scores_dir: &Path,
valid_time: DateTime<Utc>,
spec: &GridSpec,
body: &[f32],
hrdps: bool,
) -> Result<PathBuf, WriteError> {
assert_eq!(
body.len(),
spec.lat_count * spec.lon_count * n_fields(),
"pgrid body does not match grid spec"
);
let path = path_for(scores_dir, valid_time);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let mut tmp = path.clone().into_os_string();
tmp.push(format!(".tmp.{nanos}.{pid}"));
let tmp = PathBuf::from(tmp);
{
let file = std::fs::File::create(&tmp)?;
let mut w = std::io::BufWriter::with_capacity(1 << 20, file);
w.write_all(&encode_header(spec, valid_time, hrdps))?;
// f32 little-endian. On LE hosts this is the in-memory
// representation, so it is a bulk copy; the explicit conversion
// keeps the format host-independent.
let mut chunk: Vec<u8> = Vec::with_capacity(4 * 8192);
for values in body.chunks(8192) {
chunk.clear();
for v in values {
chunk.extend_from_slice(&v.to_le_bytes());
}
w.write_all(&chunk)?;
}
w.flush()?;
}
match std::fs::rename(&tmp, &path) {
Ok(()) => Ok(path),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(WriteError::Io(e))
}
}
}
// ── Reader (tests + any Rust-side consumer) ──────────────────────────
#[derive(Debug, Clone)]
pub struct Header {
pub hrdps: bool,
pub valid_time: DateTime<Utc>,
pub spec: GridSpec,
pub fields: Vec<String>,
}
impl Header {
pub fn field_index(&self, name: &str) -> Option<usize> {
self.fields.iter().position(|f| f == name)
}
pub fn n_cells(&self) -> usize {
self.spec.lat_count * self.spec.lon_count
}
}
pub fn decode_header(bytes: &[u8]) -> Option<Header> {
if bytes.len() < 52 || &bytes[0..4] != MAGIC || bytes[4] != VERSION {
return None;
}
let hrdps = bytes[5] != 0;
let n_fields = u16::from_le_bytes(bytes[6..8].try_into().ok()?) as usize;
let valid_time =
DateTime::<Utc>::from_timestamp(i64::from_le_bytes(bytes[8..16].try_into().ok()?), 0)?;
let lat_start = f64::from_le_bytes(bytes[16..24].try_into().ok()?);
let lon_start = f64::from_le_bytes(bytes[24..32].try_into().ok()?);
let lat_step = f64::from_le_bytes(bytes[32..40].try_into().ok()?);
let lon_step = f64::from_le_bytes(bytes[40..48].try_into().ok()?);
let lat_count = u16::from_le_bytes(bytes[48..50].try_into().ok()?) as usize;
let lon_count = u16::from_le_bytes(bytes[50..52].try_into().ok()?) as usize;
if bytes.len() < header_len(n_fields) {
return None;
}
let mut fields = Vec::with_capacity(n_fields);
for i in 0..n_fields {
let start = 52 + i * FIELD_NAME_LEN;
let raw = &bytes[start..start + FIELD_NAME_LEN];
let end = raw.iter().position(|&b| b == 0).unwrap_or(FIELD_NAME_LEN);
fields.push(String::from_utf8_lossy(&raw[..end]).into_owned());
}
Some(Header {
hrdps,
valid_time,
spec: GridSpec {
lat_start,
lon_start,
lat_step,
lon_step,
lat_count,
lon_count,
},
fields,
})
}
/// Read one cell's record out of a whole-file buffer. Mirrors what the
/// Elixir reader does with `:file.pread/3`.
pub fn read_cell(bytes: &[u8], header: &Header, cell: usize) -> Option<Vec<f32>> {
let n = header.fields.len();
let start = header_len(n) + cell * n * 4;
let end = start + n * 4;
if end > bytes.len() {
return None;
}
Some(
bytes[start..end]
.chunks_exact(4)
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grid;
use chrono::TimeZone;
fn spec_3x2() -> GridSpec {
GridSpec {
lon_start: -100.0,
lon_count: 3,
lon_step: 0.5,
lat_start: 30.0,
lat_count: 2,
lat_step: 0.5,
}
}
#[test]
fn field_list_shape() {
let names = field_names();
assert_eq!(
names.len(),
SCALAR_FIELDS.len() + fetcher::GRID_PRESSURE_LEVELS.len() * 3
);
assert_eq!(names[0], "surface_temp_c");
assert!(names.contains(&"tmpc_850mb".to_string()));
assert!(names.contains(&"dwpc_700mb".to_string()));
assert!(names.contains(&"hght_m_1000mb".to_string()));
}
#[test]
fn header_round_trips() {
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
let spec = grid::wgrib2_grid_spec();
let bytes = encode_header(&spec, vt, false);
let h = decode_header(&bytes).expect("decodes");
assert!(!h.hrdps);
assert_eq!(h.valid_time, vt);
assert_eq!(h.spec, spec);
assert_eq!(h.fields, field_names());
assert_eq!(bytes.len(), header_len(n_fields()));
}
#[test]
fn header_carries_hrdps_flag() {
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
let bytes = encode_header(&grid::hrdps_grid_spec(), vt, true);
assert!(decode_header(&bytes).unwrap().hrdps);
}
#[test]
fn decode_rejects_bad_magic_and_version() {
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
let mut bytes = encode_header(&spec_3x2(), vt, false);
let good = bytes.clone();
bytes[0..4].copy_from_slice(b"XXXX");
assert!(decode_header(&bytes).is_none());
let mut bytes = good;
bytes[4] = 99;
assert!(decode_header(&bytes).is_none());
}
#[test]
fn write_read_round_trip_per_cell() {
let dir = tempfile::tempdir().unwrap();
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
let spec = spec_3x2();
let n = spec.lat_count * spec.lon_count;
let nf = n_fields();
// Cell c, field f gets value c*100 + f, except cell 1 which is
// all-missing so the NaN sentinel is exercised.
let mut body = vec![f32::NAN; n * nf];
for c in 0..n {
if c == 1 {
continue;
}
for f in 0..nf {
body[c * nf + f] = (c * 100 + f) as f32;
}
}
let path = write_atomic(dir.path(), vt, &spec, &body, false).unwrap();
assert!(path.to_string_lossy().ends_with(".pgrid"));
let raw = std::fs::read(&path).unwrap();
let h = decode_header(&raw).unwrap();
assert_eq!(h.spec, spec);
assert_eq!(
raw.len(),
header_len(nf) + n * nf * 4,
"file is header + dense body, nothing else"
);
let cell0 = read_cell(&raw, &h, 0).unwrap();
assert_eq!(cell0[0], 0.0);
assert_eq!(cell0[nf - 1], (nf - 1) as f32);
let cell2 = read_cell(&raw, &h, 2).unwrap();
assert_eq!(cell2[0], 200.0);
let cell1 = read_cell(&raw, &h, 1).unwrap();
assert!(
cell1.iter().all(|v| v.is_nan()),
"missing cell reads as NaN"
);
assert!(read_cell(&raw, &h, n).is_none(), "out-of-range cell");
}
#[test]
fn named_field_lookup_resolves_to_the_written_value() {
let dir = tempfile::tempdir().unwrap();
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
let spec = spec_3x2();
let nf = n_fields();
let mut body = vec![f32::NAN; spec.lat_count * spec.lon_count * nf];
let names = field_names();
let temp_idx = names.iter().position(|n| n == "surface_temp_c").unwrap();
let t850_idx = names.iter().position(|n| n == "tmpc_850mb").unwrap();
body[4 * nf + temp_idx] = 22.5;
body[4 * nf + t850_idx] = 12.75;
let path = write_atomic(dir.path(), vt, &spec, &body, false).unwrap();
let raw = std::fs::read(&path).unwrap();
let h = decode_header(&raw).unwrap();
let rec = read_cell(&raw, &h, 4).unwrap();
assert_eq!(rec[h.field_index("surface_temp_c").unwrap()], 22.5);
assert_eq!(rec[h.field_index("tmpc_850mb").unwrap()], 12.75);
assert!(h.field_index("no_such_field").is_none());
}
#[test]
fn atomic_write_leaves_no_tmp_files() {
let dir = tempfile::tempdir().unwrap();
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
let spec = spec_3x2();
let body = vec![f32::NAN; spec.lat_count * spec.lon_count * n_fields()];
write_atomic(dir.path(), vt, &spec, &body, false).unwrap();
write_atomic(dir.path(), vt, &spec, &body, false).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("profiles"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(entries.len(), 1, "got {entries:?}");
assert!(entries[0].ends_with(".pgrid"));
}
}