//! `.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_
mb`, `tmpc_
mb`,
/// `dwpc_
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> = std::sync::OnceLock::new();
NAMES.get_or_init(|| {
let mut v: Vec = 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),
}
/// `/profiles/.pgrid`.
pub fn path_for(scores_dir: &Path, valid_time: DateTime) -> 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;
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, hrdps: bool) -> Vec {
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 `/profiles/.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,
spec: &GridSpec,
body: &[f32],
hrdps: bool,
) -> Result {
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 = 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,
pub spec: GridSpec,
pub fields: Vec,
}
impl Header {
pub fn field_index(&self, name: &str) -> Option {
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 {
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::::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> {
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"));
}
}