//! 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 { 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 { 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 { 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>; fn run_wgrib2_lon_one( wgrib2: &Path, grib_path: &Path, match_pattern: &str, batch: &[(f64, f64)], grid_spec: &GridSpec, ) -> Result { 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 { 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 = (|| { 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 { 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 { 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 { 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], native_nx: u32, native_ny: u32, ) -> Result { 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], native_dims: (u32, u32), ) -> Result { 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], (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 = 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 { 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); } }