Each fix is covered by a regression test that fails on `main` and passes on this commit. Round 1 (initial review): * propagation: thread `latitude` into the conditions map so `score_season/4` actually picks up regional multipliers * hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at a future cycle that NOAA hasn't published yet * radio: spherical-vector great-circle midpoint replaces the arithmetic mean — anti-meridian paths no longer fold to Greenwich * weather: `reconcile_weather_statuses` scales the longitude band by `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude * radio/maidenhead: clamp 90°/180° below the field-bucket overflow so `from_latlon` never emits invalid characters like 'S' * prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native Duct Boost actually fires * propagation/region (Elixir + Rust): inclusive upper bounds so points exactly at lat_max get the regional multiplier * weather/sounding_params (Elixir + Rust): drop the 10 m gradient floor so HRRR's thin near-surface layers stop hiding sharp ducts * weather/sounding_params: when the profile ends inside a duct, finalize it with the highest sample as the top instead of throwing it away (Rust port already correct) Round 2 (post-fix sweep): * radio + commercial: single canonical haversine in Radio (atan2 form); Commercial delegates instead of carrying a second copy that could disagree at threshold distances * prop_grid_rs/profiles_file: `snap_coords` matches Elixir's step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so Rust-keyed and Elixir-keyed profile maps land on the same cell * weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse` uniformly — wgrib2 dropping the trailing `.0` from a longitude no longer crashes the whole chain step
265 lines
9.1 KiB
Rust
265 lines
9.1 KiB
Rust
//! On-disk profile store in MessagePack + gzip.
|
||
//!
|
||
//! Rust-side writer for the f00 ProfilesFile. Replaces the Elixir ETF
|
||
//! format (`:erlang.term_to_binary/2`, `.etf.gz`) so Rust can produce
|
||
//! the files without porting the Erlang serializer. Elixir's
|
||
//! `ProfilesFile` module learns to read `.mp.gz` alongside the legacy
|
||
//! `.etf.gz` during the cutover.
|
||
//!
|
||
//! Wire layout (top-level msgpack map):
|
||
//!
|
||
//! ```text
|
||
//! {
|
||
//! "v": 1,
|
||
//! "valid_time": "2026-04-19T14:00:00Z",
|
||
//! "cells": [
|
||
//! {
|
||
//! "lat": 32.9, "lon": -97.0,
|
||
//! "profile": {
|
||
//! "native_min_gradient": -150.5,
|
||
//! "best_duct_freq_ghz": 24.0,
|
||
//! "duct_count": 1,
|
||
//! "ducts": [
|
||
//! {"base_m": 100, "top_m": 200, "thickness_m": 100,
|
||
//! "m_deficit": 12.3, "min_freq_ghz": 15.2}
|
||
//! ],
|
||
//! "surface_temp_c": 22.5,
|
||
//! "surface_dewpoint_c": 18.1,
|
||
//! "surface_pressure_mb": 1013.2,
|
||
//! "hpbl_m": 320.0,
|
||
//! "pwat_mm": 25.3,
|
||
//! "profile": [
|
||
//! {"pres_mb":1000,"hght_m":100,"tmpc":20.0,"dwpc":18.0},
|
||
//! ...
|
||
//! ]
|
||
//! }
|
||
//! }
|
||
//! ]
|
||
//! }
|
||
//! ```
|
||
//!
|
||
//! All keys are strings — MessagePack has no atom type, and Elixir's
|
||
//! reader converts known keys back to atoms via a whitelist.
|
||
|
||
use std::fs::File;
|
||
use std::io::{BufWriter, Write};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use flate2::write::GzEncoder;
|
||
use flate2::Compression;
|
||
use serde::Serialize;
|
||
|
||
/// Bump whenever the on-disk layout changes in a way that isn't a
|
||
/// transparent superset. Elixir reader gates on this.
|
||
pub const FORMAT_VERSION: u32 = 1;
|
||
|
||
/// One grid cell to persist. `profile` is an arbitrary map of
|
||
/// string-keyed values — sized at write time. Kept as a
|
||
/// `rmp_serde::Value` so the writer stays agnostic about what the
|
||
/// f00 pipeline decides to include.
|
||
#[derive(Debug, Serialize)]
|
||
pub struct CellEntry {
|
||
pub lat: f64,
|
||
pub lon: f64,
|
||
pub profile: rmpv::Value,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct FileBody<'a> {
|
||
v: u32,
|
||
valid_time: String,
|
||
cells: &'a [CellEntry],
|
||
}
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum WriteError {
|
||
#[error("io: {0}")]
|
||
Io(#[from] std::io::Error),
|
||
#[error("encode: {0}")]
|
||
Encode(#[from] rmp_serde::encode::Error),
|
||
}
|
||
|
||
/// Absolute path the writer lands at for `valid_time`.
|
||
/// Sibling of the Elixir `.etf.gz` file; the Elixir reader prefers
|
||
/// `.mp.gz` when both exist.
|
||
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}.mp.gz"))
|
||
}
|
||
|
||
/// Write `cells` to `<scores_dir>/profiles/<iso>.mp.gz` atomically.
|
||
/// Pattern mirrors `scores_file::write_atomic`: write to a tmp sibling,
|
||
/// rename into place. The NFS rename is atomic, so a concurrent reader
|
||
/// sees either the old file, the new file, or nothing — never a torn
|
||
/// write.
|
||
pub fn write_atomic(
|
||
scores_dir: &Path,
|
||
valid_time: DateTime<Utc>,
|
||
cells: &[CellEntry],
|
||
) -> Result<PathBuf, WriteError> {
|
||
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 = File::create(&tmp)?;
|
||
// ProfilesFile is a write-once / read-many blob — each hourly
|
||
// cycle writes it ~once but every LiveView mount, point_detail
|
||
// click, and forecast scrub pays the read cost (up to ~3 MB
|
||
// uncompressed per file × ~5 cached hours per pod). Level 9 is
|
||
// ~30% slower to encode than the default (6) but 5–10% smaller
|
||
// on semi-structured MessagePack, and our write path already
|
||
// off-loads to `spawn_blocking` so encode latency isn't on the
|
||
// user path. Readers are level-agnostic — same gzip stream.
|
||
let mut gz = GzEncoder::new(BufWriter::new(file), Compression::best());
|
||
let body = FileBody {
|
||
v: FORMAT_VERSION,
|
||
valid_time: valid_time.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||
cells,
|
||
};
|
||
let buf = rmp_serde::to_vec_named(&body)?;
|
||
gz.write_all(&buf)?;
|
||
gz.finish()?;
|
||
}
|
||
|
||
std::fs::rename(&tmp, &path)?;
|
||
Ok(path)
|
||
}
|
||
|
||
/// Snap a lat/lon to the propagation grid step. Mirrors Elixir's
|
||
/// `Microwaveprop.Propagation.ProfilesFile.snap/2`:
|
||
///
|
||
/// ```elixir
|
||
/// Float.round(Float.round(coord / step) * step, 3)
|
||
/// ```
|
||
///
|
||
/// Two implementations of "snap" — Rust's old plain 3-decimal round
|
||
/// and Elixir's step-aware round — disagreed on every off-grid input,
|
||
/// so a coordinate written by Elixir was unreachable from Rust and
|
||
/// vice versa.
|
||
const GRID_STEP: f64 = 0.125;
|
||
|
||
pub fn snap_coords(lat: f64, lon: f64) -> (f64, f64) {
|
||
(snap_step(lat), snap_step(lon))
|
||
}
|
||
|
||
fn snap_step(coord: f64) -> f64 {
|
||
let snapped = (coord / GRID_STEP).round() * GRID_STEP;
|
||
(snapped * 1000.0).round() / 1000.0
|
||
}
|
||
|
||
/// Convenience helper: build a `rmpv::Value` map from an
|
||
/// iterator of `(name, value)` pairs.
|
||
pub fn value_map(entries: impl IntoIterator<Item = (&'static str, rmpv::Value)>) -> rmpv::Value {
|
||
let pairs: Vec<(rmpv::Value, rmpv::Value)> = entries
|
||
.into_iter()
|
||
.map(|(k, v)| (rmpv::Value::String(k.into()), v))
|
||
.collect();
|
||
rmpv::Value::Map(pairs)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use chrono::TimeZone;
|
||
use flate2::read::GzDecoder;
|
||
use std::io::Read;
|
||
|
||
#[test]
|
||
fn writes_and_reads_round_trip() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
|
||
let cells = vec![CellEntry {
|
||
lat: 32.9,
|
||
lon: -97.0,
|
||
profile: value_map([
|
||
("surface_temp_c", rmpv::Value::F64(22.5)),
|
||
("surface_pressure_mb", rmpv::Value::F64(1013.2)),
|
||
("duct_count", rmpv::Value::Integer(1.into())),
|
||
]),
|
||
}];
|
||
|
||
let path = write_atomic(dir.path(), vt, &cells).unwrap();
|
||
assert!(path.exists());
|
||
assert!(path.to_string_lossy().ends_with(".mp.gz"));
|
||
|
||
// Decode and confirm the top-level shape.
|
||
let raw = std::fs::read(&path).unwrap();
|
||
let mut gz = GzDecoder::new(&raw[..]);
|
||
let mut buf = Vec::new();
|
||
gz.read_to_end(&mut buf).unwrap();
|
||
let decoded: rmpv::Value = rmp_serde::from_slice(&buf).unwrap();
|
||
let map = decoded.as_map().unwrap();
|
||
assert!(map
|
||
.iter()
|
||
.any(|(k, v)| k.as_str() == Some("v") && v.as_u64() == Some(1)));
|
||
assert!(map
|
||
.iter()
|
||
.any(|(k, v)| k.as_str() == Some("valid_time")
|
||
&& v.as_str() == Some("2026-04-19T14:00:00Z")));
|
||
let cells_val = &map
|
||
.iter()
|
||
.find(|(k, _)| k.as_str() == Some("cells"))
|
||
.unwrap()
|
||
.1;
|
||
let cells_arr = cells_val.as_array().unwrap();
|
||
assert_eq!(cells_arr.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn atomic_rename_replaces_existing() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
|
||
let first = vec![CellEntry {
|
||
lat: 32.0,
|
||
lon: -97.0,
|
||
profile: value_map([("v", rmpv::Value::Integer(1.into()))]),
|
||
}];
|
||
let second = vec![CellEntry {
|
||
lat: 32.0,
|
||
lon: -97.0,
|
||
profile: value_map([("v", rmpv::Value::Integer(2.into()))]),
|
||
}];
|
||
let p1 = write_atomic(dir.path(), vt, &first).unwrap();
|
||
let p2 = write_atomic(dir.path(), vt, &second).unwrap();
|
||
assert_eq!(p1, p2);
|
||
// Only one file in the profiles dir — the tmp was renamed away.
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn path_for_layout() {
|
||
let vt = Utc.with_ymd_and_hms(2026, 4, 19, 14, 0, 0).unwrap();
|
||
let p = path_for(std::path::Path::new("/data/scores"), vt);
|
||
assert_eq!(
|
||
p.to_str().unwrap(),
|
||
"/data/scores/profiles/2026-04-19T14:00:00Z.mp.gz"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn snap_coords_matches_elixir_step_snap() {
|
||
// The Elixir reader writes the profiles map with keys snapped via
|
||
// `Float.round(Float.round(coord / 0.125) * 0.125, 3)`. This Rust
|
||
// helper has to produce the same keys or `read_point` looks up a
|
||
// coordinate that was never written and the UI shows "no data".
|
||
assert_eq!(snap_coords(32.8998, -97.04031), (32.875, -97.0));
|
||
assert_eq!(snap_coords(32.07, -97.07), (32.125, -97.125));
|
||
// Round-trip — already-snapped coordinates stay put.
|
||
assert_eq!(snap_coords(32.875, -97.0), (32.875, -97.0));
|
||
}
|
||
}
|