prop/rust/prop_grid_rs/src/sgrid.rs
Graham McIntire db57b3a1c7
Some checks failed
Build prop-grid-rs / Test, build, push (push) Successful in 4m20s
Build and Push / Build and Push Docker Image (push) Failing after 4m35s
prop-pipeline: .sgrid dense scalars, HRDPS rotated-pole decode-once, memory trim
Item 1 — .sgrid dense binary scalar format replacing chunked gzip+msgpack
- New rust/prop_grid_rs/src/sgrid.rs: magic SGRD, same header layout as .pgrid,
  20-field cell-major f32 body, write_atomic (tmp+rename, NFS-safe)
- New lib/microwaveprop/weather/sgrid.ex: Elixir reader modeled on pgrid.ex
  (pread single-cell reads, bounds-filtered viewport reads, NaN→nil sentinel)
- ScalarFile updated to prefer .sgrid reads, chunked .mp.gz as fallback
- Pipeline writes .sgrid alongside existing chunked format (all three paths)

Item 2 — HRDPS decode-once + rotated-pole index, restore 0.125° resolution
- New rust/prop_grid_rs/src/rotated_pole.rs: CF-convention geographic→rotated
  transform, OnceLock-cached GDS params parsed from wgrib2 -grid, precomputed
  Vec<u32> lookup table mapping target cells to native grid indices
- Native decode path in decoder.rs: wgrib2 -no_header -order we:sn -bin
  (raw f32 dump, ~0.32 s/message) + indexing via lookup table
- HRDPS_STEP: 0.5° → 0.125° (4× finer, ~57k Canadian cells vs ~3.5k)

Item 3 — k8s memory limits: 3Gi → 1.5Gi
  (per-task grid footprint: ~200-400 MB HashMap → ~18 MB dense planes)

Item 4 — CLAUDE.md and profiles_file.ex documentation drift fixed:
  .pgrid primary, .mp.gz legacy, .sgrid added, write_atomic protocol doc,
  cleanup gaps reorganized, 'Only f00 is persisted' corrected to f00..f48
2026-08-01 09:11:59 -05:00

577 lines
19 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.

//! `.sgrid` — dense, cell-major, random-access derived weather scalars.
//!
//! Replaces the 5°×5° chunked gzipped-MessagePack `.mp.gz` scalar
//! artifact. Measured on a full CONUS grid (95,073 cells):
//!
//! | | `.mp.gz` | `.sgrid` |
//! |---------------|------------------------------------------|------------------------------|
//! | write | 0.232 s (rmpv + gzip per chunk) | one `write_all` of ~8.4 MB |
//! | read viewport | gunzip + unpack every overlapping chunk | one `pread` per grid row |
//! | read one cell | gunzip + unpack + iterate one chunk | one 80-byte `pread` |
//!
//! Same header shape as [`pgrid`][crate::pgrid]: magic `SGRD`, version 1,
//! flags byte, self-describing NUL-padded field table, then cell-major
//! `f32` body with `NaN` as the missing-value sentinel.
//!
//! ## Layout (little-endian)
//!
//! ```text
//! magic 4 "SGRD"
//! 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 rather than plane-major: reading one cell across all fields
//! is a single contiguous `pread`. A viewport read is one contiguous
//! `pread` per grid row.
//!
//! The field table is written into the header so the Elixir reader
//! resolves fields by name. Adding a field is backwards compatible.
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::grid::GridSpec;
use crate::weather_scalar_file::ScalarRow;
pub const MAGIC: &[u8; 4] = b"SGRD";
pub const VERSION: u8 = 1;
/// Fixed width of one field-table entry, in bytes.
pub const FIELD_NAME_LEN: usize = 32;
/// On-disk column order. Each `ScalarRow` field maps to one `f32` column.
/// Lat and lon are implied by the grid spec; valid_time is in the header.
/// Names match the atom keys the Elixir reader expects, so callers see
/// the same map shape the `.mp.gz` path produced.
pub const SCALAR_FIELDS: &[&str] = &[
"temperature",
"dewpoint_depression",
"surface_rh",
"surface_pressure_mb",
"surface_refractivity",
"refractivity_gradient",
"bl_height",
"pwat",
"temp_850mb",
"dewpoint_850mb",
"temp_700mb",
"dewpoint_700mb",
"lapse_rate",
"mid_lapse_rate",
"inversion_strength",
"inversion_base_m",
"ducting",
"duct_base_m",
"duct_strength",
"duct_cutoff_ghz",
];
pub fn n_fields() -> usize {
SCALAR_FIELDS.len()
}
/// Byte offset of the body, i.e. the header + field table length.
pub fn header_len() -> usize {
4 + 1 + 1 + 2 + 8 + 8 + 8 + 8 + 8 + 2 + 2 + SCALAR_FIELDS.len() * FIELD_NAME_LEN
}
#[derive(Debug, thiserror::Error)]
pub enum WriteError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
/// `<scores_dir>/weather_scalars/<iso>.sgrid`.
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("weather_scalars")
.join(format!("{iso}.sgrid"))
}
/// HRDPS sibling path — `<scores_dir>/weather_scalars/<iso>.hrdps.sgrid`.
/// Coexists with the HRRR `.sgrid` so both sources can be written
/// independently without clobbering each other.
pub fn path_for_hrdps(scores_dir: &Path, valid_time: DateTime<Utc>) -> PathBuf {
let iso = valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string();
scores_dir
.join("weather_scalars")
.join(format!("{iso}.hrdps.sgrid"))
}
/// Serialise the header for `spec` / `valid_time`.
pub fn encode_header(spec: &GridSpec, valid_time: DateTime<Utc>, hrdps: bool) -> Vec<u8> {
let nf = SCALAR_FIELDS.len();
let mut out = Vec::with_capacity(header_len());
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.push(u8::from(hrdps));
out.extend_from_slice(&(nf 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 SCALAR_FIELDS {
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());
out
}
/// Build the cell-major `f32` body from `rows` and `spec`.
///
/// `rows` MUST be sorted in the order `lat` then `lon` at `lat_step` /
/// `lon_step` increments — exactly the iteration order the pipeline's
/// `fuse_chunk` produces. Any cell not present in `rows` is filled with
/// `f32::NAN` (the missing sentinel).
///
/// Returns a flat `f32` array, length `n_cells * n_fields()`, ready for
/// `write_atomic`.
pub fn build_body(spec: &GridSpec, rows: &[ScalarRow]) -> Vec<f32> {
let n_cells = spec.lat_count * spec.lon_count;
let nf = SCALAR_FIELDS.len();
let mut body = vec![f32::NAN; n_cells * nf];
for row in rows {
// Convert lat/lon to cell index. The grid's `round3` is
// half-away-from-zero; round here to match the pipeline's
// `cell_latlon` output.
let lat = (row.lat * 1000.0).round() / 1000.0;
let lon = (row.lon * 1000.0).round() / 1000.0;
let row_i = ((lat - spec.lat_start) / spec.lat_step).round() as isize;
let col_i = ((lon - spec.lon_start) / spec.lon_step).round() as isize;
if row_i < 0
|| col_i < 0
|| row_i as usize >= spec.lat_count
|| col_i as usize >= spec.lon_count
{
continue;
}
let cell = (row_i as usize) * spec.lon_count + (col_i as usize);
let base = cell * nf;
// Write each field. Order must match SCALAR_FIELDS.
let mut f = 0;
macro_rules! put {
($val:expr) => {
if let Some(v) = $val {
body[base + f] = v as f32;
}
f += 1;
};
}
put!(row.temperature);
put!(row.dewpoint_depression);
put!(row.surface_rh);
put!(row.surface_pressure_mb);
put!(row.surface_refractivity);
put!(row.refractivity_gradient);
put!(row.bl_height);
put!(row.pwat);
put!(row.temp_850mb);
put!(row.dewpoint_850mb);
put!(row.temp_700mb);
put!(row.dewpoint_700mb);
put!(row.lapse_rate);
put!(row.mid_lapse_rate);
put!(row.inversion_strength);
put!(row.inversion_base_m);
// ducting: Option<bool> → 1.0 or NaN
body[base + f] = match row.ducting {
Some(true) => 1.0_f32,
Some(false) => 0.0_f32,
None => f32::NAN,
};
f += 1;
put!(row.duct_base_m);
put!(row.duct_strength);
put!(row.duct_cutoff_ghz);
debug_assert_eq!(f, nf);
}
body
}
/// Write `<scores_dir>/weather_scalars/<iso>.sgrid` atomically
/// (tmp + rename, so an NFS reader sees the old file, the new file, or
/// nothing).
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 * SCALAR_FIELDS.len(),
"sgrid body does not match grid spec"
);
let path = if hrdps {
path_for_hrdps(scores_dir, valid_time)
} else {
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, chunked writes.
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;
let hl = 52 + n_fields * FIELD_NAME_LEN;
if bytes.len() < hl {
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.
pub fn read_cell(bytes: &[u8], header: &Header, cell: usize) -> Option<Vec<f32>> {
let n = header.fields.len();
let start = header_len() + 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_length() {
assert_eq!(SCALAR_FIELDS.len(), 20);
assert_eq!(SCALAR_FIELDS[0], "temperature");
assert_eq!(SCALAR_FIELDS[SCALAR_FIELDS.len() - 1], "duct_cutoff_ghz");
}
#[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.len(), SCALAR_FIELDS.len());
assert_eq!(bytes.len(), header_len());
}
#[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);
bytes[0..4].copy_from_slice(b"XXXX");
assert!(decode_header(&bytes).is_none());
let mut bytes = encode_header(&spec_3x2(), vt, false);
bytes[4] = 99;
assert!(decode_header(&bytes).is_none());
}
#[test]
fn build_body_places_rows_at_correct_cells() {
let spec = spec_3x2();
let n_cells = spec.lat_count * spec.lon_count;
let nf = SCALAR_FIELDS.len();
// | j\i | -100.0 | -99.5 | -99.0 |
// |------|--------|-------|-------|
// | 30.0 | c0 | c1 | c2 |
// | 30.5 | c3 | c4 | c5 |
let rows = vec![
ScalarRow {
lat: 30.0,
lon: -100.0,
temperature: Some(25.0),
..ScalarRow::default()
},
ScalarRow {
lat: 30.5,
lon: -99.0,
temperature: Some(15.0),
dewpoint_depression: Some(5.0),
ducting: Some(true),
..ScalarRow::default()
},
];
let body = build_body(&spec, &rows);
assert_eq!(body.len(), n_cells * nf);
// Cell 0: temperature=25.0, rest NaN
assert!((body[0] - 25.0_f32).abs() < 1e-6);
assert!(body[1].is_nan()); // dewpoint_depression
assert!(body[16].is_nan()); // ducting
// Cell 5 (30.5, -99.0): temperature=15.0, dewpoint_depression=5.0, ducting=1.0
let c5 = 5 * nf;
assert!((body[c5] - 15.0_f32).abs() < 1e-6);
assert!((body[c5 + 1] - 5.0_f32).abs() < 1e-6);
assert_eq!(body[c5 + 16], 1.0_f32); // ducting
assert!(body[c5 + 17].is_nan()); // duct_base_m
// Cell 1: never written, stays NaN
assert!(body[nf].is_nan());
// Cell 4: never written, stays NaN
assert!(body[4 * nf].is_nan());
}
#[test]
fn build_body_ducting_false_is_zero() {
let spec = GridSpec {
lon_start: 0.0,
lon_count: 1,
lon_step: 1.0,
lat_start: 0.0,
lat_count: 1,
lat_step: 1.0,
};
let rows = vec![ScalarRow {
lat: 0.0,
lon: 0.0,
ducting: Some(false),
..ScalarRow::default()
}];
let body = build_body(&spec, &rows);
assert_eq!(
body[SCALAR_FIELDS.iter().position(|n| *n == "ducting").unwrap()],
0.0_f32
);
}
#[test]
fn build_body_out_of_bounds_row_is_skipped() {
let spec = spec_3x2();
let rows = vec![ScalarRow {
lat: 99.0,
lon: 0.0,
temperature: Some(42.0),
..ScalarRow::default()
}];
let body = build_body(&spec, &rows);
// Every cell should be all-NaN
assert!(body.iter().all(|v| v.is_nan()));
}
#[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_cells = spec.lat_count * spec.lon_count;
let nf = SCALAR_FIELDS.len();
// Cell c, field f gets value c*100 + f, except cell 1 (all-missing).
let mut body = vec![f32::NAN; n_cells * nf];
for c in 0..n_cells {
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(".sgrid"));
let raw = std::fs::read(&path).unwrap();
let h = decode_header(&raw).unwrap();
assert_eq!(h.spec, spec);
assert_eq!(
raw.len(),
header_len() + n_cells * 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_cells).is_none(), "out-of-range cell");
}
#[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 * SCALAR_FIELDS.len()];
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("weather_scalars"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(entries.len(), 1, "got {entries:?}");
assert!(entries[0].ends_with(".sgrid"));
}
#[test]
fn hrdps_path_is_sibling_not_clobber() {
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 * SCALAR_FIELDS.len()];
write_atomic(dir.path(), vt, &spec, &body, false).unwrap();
write_atomic(dir.path(), vt, &spec, &body, true).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("weather_scalars"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(entries.len(), 2);
assert!(entries
.iter()
.any(|e| e.ends_with(".sgrid") && !e.contains(".hrdps")));
assert!(entries.iter().any(|e| e.ends_with(".hrdps.sgrid")));
}
}