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

820 lines
28 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.

//! wgrib2 subprocess wrapper. 1:1 port of the hot paths in
//! `lib/microwaveprop/weather/grib2/wgrib2.ex` used by f01..f48:
//! * `extract_grid` (write GRIB2 to tmp → `wgrib2 -lola … bin` → parse)
//! * inventory parsing from stdout
//! * `parse_lola_binary` (Fortran-unformatted IEEE 754 little-endian f32)
//!
//! wgrib2 is called as a child process exactly as the Elixir version
//! does — same CLI flags, same temp-file lifecycle. Output binary is
//! stride-addressed: each message = 4-byte LE record length + N*4 bytes
//! of data + 4-byte duplicate record length.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::field_grid::FieldGrid;
use crate::grid::GridSpec;
const UNDEFINED_VALUE: f32 = 9.999e20;
static UNIQUE: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, thiserror::Error)]
pub enum DecodeError {
#[error("wgrib2 not available on PATH")]
Wgrib2NotAvailable,
#[error("wgrib2 failed (exit {code}): {stderr}")]
Wgrib2Failed { code: i32, stderr: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct Message {
pub var: String,
pub level: String,
}
// The former `CellValues` / `PointGrid` aliases (a HashMap of ~95 k
// per-cell HashMaps) are gone — see `field_grid::FieldGrid`, which
// stores the same data as dense per-message planes. Decoded grids are
// `FieldGrid` everywhere now.
pub fn wgrib2_available() -> bool {
which_wgrib2().is_some()
}
fn which_wgrib2() -> Option<PathBuf> {
if let Ok(val) = std::env::var("WGRIB2") {
return Some(PathBuf::from(val));
}
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join("wgrib2");
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub fn normalize_lon(lon: f64) -> f64 {
if lon < 0.0 {
lon + 360.0
} else {
lon
}
}
pub fn denormalize_lon(lon: f64) -> f64 {
if lon > 180.0 {
lon - 360.0
} else {
lon
}
}
/// Per-cell HRDPS extraction via `wgrib2 -lon LON LAT`.
///
/// `extract_grid` (`-lola`) interpolates the entire source grid onto a
/// target rectangle, which on HRDPS's rotated lat/lon source runs at
/// 10+ minutes per chain step (production observation 2026-04-29). The
/// `-lon` per-point path that the probe used is far cheaper because it
/// only computes the rotation math at the points we ask for. For
/// 57k Canadian-only cells this drops chain-step wall time from "never
/// completes" to roughly 30-90 seconds.
///
/// Batched at `POINT_BATCH` points per wgrib2 invocation to keep the
/// argv list comfortably under Linux's `ARG_MAX` (about 2 MiB) and keep
/// stdout parseable in chunks.
pub fn extract_points(
grib: &[u8],
match_pattern: &str,
points: &[(f64, f64)],
grid_spec: GridSpec,
) -> Result<FieldGrid, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
let grib_path: PathBuf = std::env::temp_dir().join(format!("hrdps_{nanos}_{uniq}.grib2"));
std::fs::write(&grib_path, grib)?;
let result = run_wgrib2_lon_batched(&wgrib2, &grib_path, match_pattern, points, grid_spec);
let _ = std::fs::remove_file(&grib_path);
result
}
// 1000 points per wgrib2 invocation. With ~57k Canadian cells that's
// 57 invocations per chain step — enough to amortize process-spawn
// overhead, small enough that any future wgrib2 regression around
// large -lon arg lists trips on a manageable failure mode.
//
// Note: wgrib2 3.6.0 had a memory-corruption bug surfaced by HRDPS
// rotated lat/lon files at *any* batch size (verified at N=200 in
// production on 2026-04-30 — `free(): invalid size`). The fix was
// bumping the Dockerfile to wgrib2 3.8.0; this batch size is just
// a normal performance tuning choice, not a workaround.
const POINT_BATCH: usize = 1_000;
fn run_wgrib2_lon_batched(
wgrib2: &Path,
grib_path: &Path,
match_pattern: &str,
points: &[(f64, f64)],
grid_spec: GridSpec,
) -> Result<FieldGrid, DecodeError> {
let mut out = FieldGrid::new(grid_spec);
for batch in points.chunks(POINT_BATCH) {
let batch_values = run_wgrib2_lon_one(wgrib2, grib_path, match_pattern, batch, &grid_spec)?;
for (name, cells) in batch_values {
let (_, plane) = out.plane_mut_or_insert(&name);
for (cell, value) in cells {
plane[cell] = value;
}
}
}
Ok(out)
}
/// One wgrib2 `-lon` invocation, keyed `"VAR:LEVEL" -> [(cell_index, value)]`.
/// Values are gathered per-name so the caller can scatter each name into a
/// single plane instead of touching a per-cell map.
type LonBatchValues = HashMap<String, Vec<(usize, f32)>>;
fn run_wgrib2_lon_one(
wgrib2: &Path,
grib_path: &Path,
match_pattern: &str,
batch: &[(f64, f64)],
grid_spec: &GridSpec,
) -> Result<LonBatchValues, DecodeError> {
let mut cmd = Command::new(wgrib2);
cmd.arg(grib_path)
.arg("-s")
.arg("-match")
.arg(match_pattern);
for &(lat, lon) in batch {
cmd.arg("-lon").arg(format!("{lon}")).arg(format!("{lat}"));
}
let Output {
status,
stdout,
stderr,
} = cmd.output()?;
if !status.success() {
// Surface the FIRST chars of stderr (the actual diagnostic)
// rather than stdout (which is mostly per-cell numeric output
// that drowned the interesting failure context). status.code()
// returns None on signal-kill — flag that with -1 so the
// failure mode is distinguishable from a real exit code.
let stderr_text = String::from_utf8_lossy(&stderr).to_string();
let stderr_snippet: String = stderr_text.chars().take(400).collect();
let signaled = status.code().is_none();
let suffix = if signaled {
format!(" (killed by signal, batch={})", batch.len())
} else {
String::new()
};
return Err(DecodeError::Wgrib2Failed {
code: status.code().unwrap_or(-1),
stderr: format!("{stderr_snippet}{suffix}"),
});
}
let text = String::from_utf8_lossy(&stdout);
Ok(parse_lon_output(&text, batch, grid_spec))
}
/// Parse `wgrib2 -s -lon` text output. Each record line is a colon-
/// separated header followed by per-point `lon=…,lat=…,val=…` segments,
/// one per `-lon` arg.
///
/// Example line:
/// `1:0:d=...:TMP:2 m above ground:anl:lon=280.369,lat=43.670,val=280.97:lon=...`
fn parse_lon_output(text: &str, batch: &[(f64, f64)], grid_spec: &GridSpec) -> LonBatchValues {
let mut out: LonBatchValues = HashMap::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
let mut parts = line.split(':');
let _n = parts.next();
let _offset = parts.next();
let _date = parts.next();
let var = match parts.next() {
Some(v) => v,
None => continue,
};
let level = match parts.next() {
Some(l) => l,
None => continue,
};
// Skip the record-type/forecast-time field (anl, "240 min fcst", …).
let _kind = parts.next();
let key = format!("{var}:{level}");
let slot = out.entry(key).or_default();
for segment in parts {
if let Some((lat, lon, val)) = parse_lon_segment(segment, batch) {
if let Some(cell) = crate::field_grid::cell_index_for(grid_spec, lat, lon) {
slot.push((cell, val));
}
}
}
}
out
}
/// `lon=242.958,lat=32.938,val=306.5` → (lat, lon, val) snapped to the
/// nearest point in `batch` (handles wgrib2's lon-180/360 convention by
/// denormalising before matching).
fn parse_lon_segment(segment: &str, batch: &[(f64, f64)]) -> Option<(f64, f64, f32)> {
let mut lon_part = None;
let mut lat_part = None;
let mut val_part = None;
for kv in segment.split(',') {
let (k, v) = kv.split_once('=')?;
let parsed: f64 = v.trim().parse().ok()?;
match k.trim() {
"lon" => lon_part = Some(parsed),
"lat" => lat_part = Some(parsed),
"val" => val_part = Some(parsed),
_ => {}
}
}
let raw_lon = lon_part?;
let raw_lat = lat_part?;
let val = val_part?;
if val > UNDEFINED_VALUE as f64 / 2.0 {
return None;
}
let target_lon = denormalize_lon(raw_lon);
let (lat, lon) = snap_to_batch(raw_lat, target_lon, batch)?;
Some((lat, lon, val as f32))
}
fn snap_to_batch(lat: f64, lon: f64, batch: &[(f64, f64)]) -> Option<(f64, f64)> {
// wgrib2 -lon snaps to the nearest source-grid cell, so the returned
// lat/lon is close to but not exactly equal to what we asked for.
// Match it back to our request (which is on the 0.125° propagation
// grid) via the closest batch entry.
let mut best: Option<((f64, f64), f64)> = None;
for &(p_lat, p_lon) in batch {
let dlat = p_lat - lat;
let dlon = p_lon - lon;
let d2 = dlat * dlat + dlon * dlon;
match best {
None => best = Some(((p_lat, p_lon), d2)),
Some((_, b)) if d2 < b => best = Some(((p_lat, p_lon), d2)),
_ => {}
}
}
let (winner, d2) = best?;
// Reject anything more than 0.5° away — wgrib2 returned a cell that
// doesn't correspond to a point we asked for (probably a rounding
// edge case at the bbox boundary).
if d2 > 0.25 {
return None;
}
Some(winner)
}
/// Extract values at the given grid spec from an in-memory GRIB2 blob.
/// Writes the blob to a temp file, runs wgrib2, parses the output.
pub fn extract_grid(
grib: &[u8],
match_pattern: &str,
grid_spec: GridSpec,
) -> Result<FieldGrid, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let tmp_dir = std::env::temp_dir();
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let grib_path = tmp_dir.join(format!("hrrr_{nanos}_{uniq}.grib2"));
let bin_path = tmp_dir.join(format!("hrrr_{nanos}_{uniq}.grib2.lola.bin"));
let result: Result<FieldGrid, DecodeError> = (|| {
std::fs::write(&grib_path, grib)?;
run_wgrib2_lola(&wgrib2, &grib_path, match_pattern, &grid_spec, &bin_path)
})();
let _ = std::fs::remove_file(&grib_path);
let _ = std::fs::remove_file(&bin_path);
result
}
/// File-path variant — avoids the intermediate `write(tmp, binary)` copy.
pub fn extract_grid_from_file(
grib_path: &Path,
match_pattern: &str,
grid_spec: GridSpec,
) -> Result<FieldGrid, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let bin_path = sibling_tmp_path(grib_path, "lola");
let result = run_wgrib2_lola(&wgrib2, grib_path, match_pattern, &grid_spec, &bin_path);
let _ = std::fs::remove_file(&bin_path);
result
}
fn sibling_tmp_path(path: &Path, suffix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
let mut s = path.as_os_str().to_owned();
s.push(format!(".{suffix}.{nanos}.{uniq}.bin"));
PathBuf::from(s)
}
fn run_wgrib2_lola(
wgrib2: &Path,
grib_path: &Path,
match_pattern: &str,
grid_spec: &GridSpec,
bin_path: &Path,
) -> Result<FieldGrid, DecodeError> {
let lon_spec = format!(
"{}:{}:{}",
normalize_lon(grid_spec.lon_start),
grid_spec.lon_count,
grid_spec.lon_step
);
let lat_spec = format!(
"{}:{}:{}",
grid_spec.lat_start, grid_spec.lat_count, grid_spec.lat_step
);
let Output {
status,
stdout,
stderr,
} = Command::new(wgrib2)
.arg(grib_path)
.arg("-match")
.arg(match_pattern)
.arg("-lola")
.arg(&lon_spec)
.arg(&lat_spec)
.arg(bin_path)
.arg("bin")
.output()?;
if !status.success() {
let mut combined = stdout;
combined.extend_from_slice(&stderr);
let text = String::from_utf8_lossy(&combined).to_string();
let snippet: String = text.chars().take(200).collect();
return Err(DecodeError::Wgrib2Failed {
code: status.code().unwrap_or(-1),
stderr: snippet,
});
}
let inventory = String::from_utf8_lossy(&stdout);
let messages = parse_inventory(&inventory);
match std::fs::read(bin_path) {
Ok(bin) => Ok(parse_lola_binary(&bin, &messages, grid_spec)),
// wgrib2 matched nothing, so it never created the output file.
// An empty grid (right spec, zero planes) is the correct result.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FieldGrid::new(*grid_spec)),
Err(e) => Err(e.into()),
}
}
/// Parse `wgrib2`'s stdout inventory (`"n:offset:date:var:level:..."`).
/// Malformed lines are dropped.
pub fn parse_inventory(out: &str) -> Vec<Message> {
out.lines()
.filter_map(|line| {
let mut parts = line.splitn(8, ':');
let _n = parts.next()?;
let _offset = parts.next()?;
let _date = parts.next()?;
let var = parts.next()?;
let level = parts.next()?;
Some(Message {
var: var.to_string(),
level: level.to_string(),
})
})
.collect()
}
/// Parse the Fortran-unformatted output file from `wgrib2 -lola`.
/// Layout: per message = 4-byte LE u32 record length + N·4 bytes f32 LE
/// data + 4-byte LE u32 record length. `N = nx * ny`. Cells whose value
/// exceeds `UNDEFINED_VALUE / 2` are replaced with `NaN` (the sentinel
/// wgrib2 writes for "no data"; `FieldGrid::at` maps NaN back to `None`,
/// matching the old behaviour of omitting the key entirely).
///
/// wgrib2 writes each record row-major over (lat, lon) in exactly the
/// order `FieldGrid` indexes cells, so the body is a bulk
/// little-endian `f32` decode per message — no per-cell coordinate
/// arithmetic, no hashing, no allocation beyond the plane itself.
pub fn parse_lola_binary(bin: &[u8], messages: &[Message], grid_spec: &GridSpec) -> FieldGrid {
let nx = grid_spec.lon_count;
let ny = grid_spec.lat_count;
let n_cells = nx * ny;
let bytes_per_msg = n_cells * 4;
let record_overhead = 8;
let stride = bytes_per_msg + record_overhead;
let mut out = FieldGrid::new(*grid_spec);
for (msg_idx, msg) in messages.iter().enumerate() {
let data_offset = msg_idx * stride + 4;
if data_offset + bytes_per_msg > bin.len() {
continue;
}
let chunk = &bin[data_offset..data_offset + bytes_per_msg];
let mut plane = Vec::with_capacity(n_cells);
plane.extend(chunk.chunks_exact(4).map(|b| {
// try_into on a chunks_exact(4) window is statically length 4.
let v = f32::from_le_bytes(b.try_into().unwrap());
if v > UNDEFINED_VALUE / 2.0 {
f32::NAN
} else {
v
}
}));
out.push_plane(&format!("{}:{}", msg.var, msg.level), plane);
}
out
}
/// Decode an HRDPS rotated-pole GRIB2 blob using decode-once +
/// lookup-many. Replaces the per-point `-lon` extraction that used to
/// brute-force the rotation math once per output cell.
///
/// 1. Runs `wgrib2 -no_header -order we:sn -bin` to dump the native
/// grid as raw f32 blocks (~0.32 s per message).
/// 2. Parses the inventory from stdout to get variable+level names.
/// 3. For each message, builds a dense `FieldGrid` plane by indexing
/// into the native grid via the precomputed `lookup` table.
///
/// `native_nx` × `native_ny` is the native grid size (2540×1290 for
/// HRDPS 0.0225°). `lookup` maps each cell in `target_spec` to a
/// native grid index, or `None` if the target cell is outside the
/// native domain.
pub fn decode_hrdps_native(
grib: &[u8],
match_pattern: &str,
target_spec: &GridSpec,
lookup: &[Option<u32>],
native_nx: u32,
native_ny: u32,
) -> Result<FieldGrid, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let tmp_dir = std::env::temp_dir();
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let grib_path = tmp_dir.join(format!("hrdps_native_{nanos}_{uniq}.grib2"));
let bin_path = tmp_dir.join(format!("hrdps_native_{nanos}_{uniq}.bin"));
std::fs::write(&grib_path, grib)?;
let result = run_wgrib2_native_bin(
&wgrib2,
&grib_path,
match_pattern,
&bin_path,
target_spec,
lookup,
(native_nx, native_ny),
);
let _ = std::fs::remove_file(&grib_path);
let _ = std::fs::remove_file(&bin_path);
result
}
fn run_wgrib2_native_bin(
wgrib2: &Path,
grib_path: &Path,
match_pattern: &str,
bin_path: &Path,
target_spec: &GridSpec,
lookup: &[Option<u32>],
native_dims: (u32, u32),
) -> Result<FieldGrid, DecodeError> {
let Output {
status,
stdout,
stderr,
} = Command::new(wgrib2)
.arg(grib_path)
.arg("-match")
.arg(match_pattern)
.arg("-no_header")
.arg("-order")
.arg("we:sn")
.arg("-bin")
.arg(bin_path)
.output()?;
if !status.success() {
let stderr_text = String::from_utf8_lossy(&stderr).to_string();
let snippet: String = stderr_text.chars().take(400).collect();
return Err(DecodeError::Wgrib2Failed {
code: status.code().unwrap_or(-1),
stderr: snippet,
});
}
let inventory = String::from_utf8_lossy(&stdout);
let messages = parse_inventory(&inventory);
match std::fs::read(bin_path) {
Ok(bin) => Ok(parse_native_binary(
&bin,
&messages,
target_spec,
lookup,
native_dims,
)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FieldGrid::new(*target_spec)),
Err(e) => Err(e.into()),
}
}
/// Parse the raw `-no_header -order we:sn -bin` output: each message is
/// `nx * ny * 4` bytes of f32 LE, one block per message in the same
/// order as the inventory. No record markers, no headers.
fn parse_native_binary(
bin: &[u8],
messages: &[Message],
target_spec: &GridSpec,
lookup: &[Option<u32>],
(native_nx, native_ny): (u32, u32),
) -> FieldGrid {
let n_cells = target_spec.lat_count * target_spec.lon_count;
let bytes_per_msg = (native_nx as usize) * (native_ny as usize) * 4;
let n_native = (native_nx as usize) * (native_ny as usize);
let mut out = FieldGrid::new(*target_spec);
for (msg_idx, msg) in messages.iter().enumerate() {
let data_offset = msg_idx * bytes_per_msg;
if data_offset + bytes_per_msg > bin.len() {
continue;
}
let chunk = &bin[data_offset..data_offset + bytes_per_msg];
// Decode the entire native grid into f32 values once per message.
// For the 2540×1290 (= 3.28M cell) native grid, this is ~13 MB
// per message, which is acceptable for the ~41 messages HRDPS
// publishes. The alternative would be indexing into raw bytes per
// target cell, which adds per-cell bounds checks and offset math.
let native: Vec<f32> = chunk
.chunks_exact(4)
.map(|b| {
let v = f32::from_le_bytes(b.try_into().unwrap());
if v > UNDEFINED_VALUE / 2.0 {
f32::NAN
} else {
v
}
})
.collect();
debug_assert_eq!(native.len(), n_native);
// Build the target plane by indexing into the native grid.
let name = format!("{}:{}", msg.var, msg.level);
let mut plane = vec![f32::NAN; n_cells];
for (cell, slot) in lookup.iter().enumerate() {
if let Some(native_cell) = slot {
if (*native_cell as usize) < n_native {
plane[cell] = native[*native_cell as usize];
}
}
}
out.push_plane(&name, plane);
}
out
}
pub fn key_to_latlon(key: (i32, i32)) -> (f64, f64) {
(key.0 as f64 / 1000.0, key.1 as f64 / 1000.0)
}
/// Run `wgrib2 -grid` on a GRIB2 file and return the stdout text.
/// Used to extract rotated-pole grid definition parameters at startup
/// so the lookup table can be validated against the actual GDS.
pub fn extract_grid_metadata(grib_path: &Path) -> Result<String, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let Output {
status,
stdout,
stderr,
} = Command::new(&wgrib2).arg(grib_path).arg("-grid").output()?;
if !status.success() {
let stderr_text = String::from_utf8_lossy(&stderr).to_string();
let snippet: String = stderr_text.chars().take(400).collect();
return Err(DecodeError::Wgrib2Failed {
code: status.code().unwrap_or(-1),
stderr: snippet,
});
}
Ok(String::from_utf8_lossy(&stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lon_round_trip() {
for lon in [-125.0, -97.0, -66.0, 0.0, 179.5] {
assert!((denormalize_lon(normalize_lon(lon)) - lon).abs() < 1e-9);
}
}
#[test]
fn inventory_parses_var_level() {
let text = "1:0:d=2026041915:TMP:2 m above ground:anl:\n\
2:1234:d=2026041915:DPT:2 m above ground:anl:\n";
let msgs = parse_inventory(text);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].var, "TMP");
assert_eq!(msgs[1].level, "2 m above ground");
}
#[test]
fn inventory_drops_short_lines() {
let text = "garbage\n1:0:d:TMP:surface:anl:\n";
let msgs = parse_inventory(text);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].var, "TMP");
}
#[test]
fn parse_lon_output_pulls_var_level_and_snaps_to_batch() {
// Match the shape `wgrib2 -s -lon` produces. Two records, two
// points each. Note: lon=280.369 is wgrib2's 0-360 form for
// -79.631; parse_lon_segment denormalizes it.
let text = "1:0:d=2026042912:TMP:2 m above ground:anl:\
lon=280.369,lat=43.670,val=280.97:\
lon=235.000,lat=49.190,val=281.62\n\
2:3527800:d=2026042912:DPT:2 m above ground:anl:\
lon=280.369,lat=43.670,val=275.00:\
lon=235.000,lat=49.190,val=270.00\n";
let batch = vec![(43.670, -79.631), (49.190, -125.000)];
// Spec chosen so both batch points land exactly on a cell.
let spec = GridSpec {
lon_start: -125.0,
lon_count: 400,
lon_step: 0.125,
lat_start: 43.0,
lat_count: 60,
lat_step: 0.125,
};
let values = parse_lon_output(text, &batch, &spec);
let toronto = crate::field_grid::cell_index_for(&spec, 43.670, -79.631)
.expect("toronto lands on the grid");
let tmp = values["TMP:2 m above ground"]
.iter()
.find(|(c, _)| *c == toronto)
.expect("tmp present")
.1;
assert!((tmp - 280.97).abs() < 0.01);
let dpt = values["DPT:2 m above ground"]
.iter()
.find(|(c, _)| *c == toronto)
.expect("dpt present")
.1;
assert!((dpt - 275.00).abs() < 0.01);
}
#[test]
fn parse_lon_segment_drops_undefined_values() {
let batch = vec![(43.670, -79.631)];
let segment = "lon=280.369,lat=43.670,val=9.999e20";
assert!(parse_lon_segment(segment, &batch).is_none());
}
#[test]
fn parse_lon_segment_rejects_far_off_points() {
let batch = vec![(43.670, -79.631)];
// wgrib2 returned a cell for somewhere completely unrelated.
let segment = "lon=240.0,lat=10.0,val=290.0";
assert!(parse_lon_segment(segment, &batch).is_none());
}
#[test]
fn parse_lola_binary_reconstructs_grid() {
// Build a synthetic 2-message, 3×2 grid. Layout matches wgrib2:
// [4 bytes len][6 f32 values][4 bytes len] per message.
let spec = GridSpec {
lon_start: -100.0,
lon_count: 3,
lon_step: 0.5,
lat_start: 30.0,
lat_count: 2,
lat_step: 0.5,
};
let msgs = vec![
Message {
var: "TMP".into(),
level: "surface".into(),
},
Message {
var: "DPT".into(),
level: "surface".into(),
},
];
let mut buf = Vec::new();
for (msg_idx, _msg) in msgs.iter().enumerate() {
let len: u32 = 3 * 2 * 4;
buf.extend_from_slice(&len.to_le_bytes());
for j in 0..2 {
for i in 0..3 {
let v = (msg_idx as f32) * 100.0 + j as f32 * 10.0 + i as f32;
buf.extend_from_slice(&v.to_le_bytes());
}
}
buf.extend_from_slice(&len.to_le_bytes());
}
let grid = parse_lola_binary(&buf, &msgs, &spec);
assert_eq!(grid.n_cells(), 6);
assert_eq!(grid.n_planes(), 2);
let tmp = grid.plane_id("TMP:surface").unwrap();
let dpt = grid.plane_id("DPT:surface").unwrap();
// Cell (30.0, -100.0) → i=0, j=0, msg0 value = 0.0, msg1 = 100.0
let k = grid.cell_index(30.0, -100.0).unwrap();
assert_eq!(grid.at(tmp, k), Some(0.0));
assert_eq!(grid.at(dpt, k), Some(100.0));
// Cell (30.5, -99.0) → i=2, j=1, msg0 = 12.0
let k2 = grid.cell_index(30.5, -99.0).unwrap();
assert_eq!(grid.at(tmp, k2), Some(12.0));
assert_eq!(grid.at(dpt, k2), Some(112.0));
}
#[test]
fn parse_lola_skips_undefined_sentinel() {
let spec = GridSpec {
lon_start: -100.0,
lon_count: 2,
lon_step: 1.0,
lat_start: 30.0,
lat_count: 1,
lat_step: 1.0,
};
let msgs = vec![Message {
var: "TMP".into(),
level: "surface".into(),
}];
let mut buf = Vec::new();
let len: u32 = 2 * 4;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&UNDEFINED_VALUE.to_le_bytes()); // i=0
buf.extend_from_slice(&42.0f32.to_le_bytes()); // i=1
buf.extend_from_slice(&len.to_le_bytes());
let grid = parse_lola_binary(&buf, &msgs, &spec);
let tmp = grid.plane_id("TMP:surface").unwrap();
// i=0 carried the sentinel and must read back as missing; only
// i=1 survived. The old representation expressed this by never
// inserting the key for i=0.
assert_eq!(grid.at(tmp, grid.cell_index(30.0, -100.0).unwrap()), None);
assert_eq!(
grid.at(tmp, grid.cell_index(30.0, -99.0).unwrap()),
Some(42.0)
);
}
#[test]
fn key_to_latlon_roundtrip() {
let (lat, lon) = key_to_latlon((32_500, -97_250));
assert_eq!(lat, 32.5);
assert_eq!(lon, -97.25);
}
}