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.
453 lines
15 KiB
Rust
453 lines
15 KiB
Rust
//! `.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"));
|
||
}
|
||
}
|