use std::collections::BTreeMap;
use std::path::PathBuf;
use ciborium::Value as CborValue;
use eccodes::{BufMessage, CodesFile, FallibleIterator, KeyWrite, ProductKind};
use tensogram::types::{ByteOrder, GlobalMetadata};
use tensogram::{DecodeOptions, Dtype, decode};
use crate::error::GribError;
const SAMPLE_NAME: &str = "regular_ll_sfc_grib2.tmpl";
fn sample_path() -> Result<PathBuf, GribError> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(paths) = std::env::var("ECCODES_SAMPLES_PATH") {
candidates.extend(paths.split(':').map(PathBuf::from));
}
if let Ok(dir) = std::env::var("ECCODES_DIR") {
let base = PathBuf::from(dir);
candidates.push(base.join("share/eccodes/samples"));
candidates.push(base.join("samples"));
}
for dir in [
"/usr/share/eccodes/samples",
"/usr/local/share/eccodes/samples",
"/opt/homebrew/share/eccodes/samples",
"/opt/homebrew/opt/eccodes/share/eccodes/samples",
] {
candidates.push(PathBuf::from(dir));
}
candidates
.into_iter()
.map(|dir| dir.join(SAMPLE_NAME))
.find(|p| p.exists())
.ok_or_else(|| {
GribError::InvalidData(format!(
"could not locate ecCodes sample '{SAMPLE_NAME}'; \
set ECCODES_SAMPLES_PATH or ECCODES_DIR"
))
})
}
const PRIORITY_KEYS: &[&str] = &[
"edition",
"tablesVersion",
"centre",
"subCentre",
"gridType",
"setLocalDefinition",
"grib2LocalSectionNumber",
"packingType",
"bitsPerValue",
];
pub fn to_grib(message: &[u8]) -> Result<Vec<u8>, GribError> {
let (meta, objects) = decode(message, &DecodeOptions::default())
.map_err(|e| GribError::InvalidData(format!("decode tensogram message: {e}")))?;
let mut out = Vec::new();
for (i, (desc, payload)) in objects.iter().enumerate() {
let repro = repro_keys(&meta, i)?;
let values = payload_to_f64(desc.dtype, desc.byte_order, payload)?;
out.extend_from_slice(&reconstruct_message(&repro, &values)?);
}
Ok(out)
}
fn repro_keys(meta: &GlobalMetadata, i: usize) -> Result<BTreeMap<String, CborValue>, GribError> {
let entry = meta
.base
.get(i)
.ok_or_else(|| GribError::InvalidData(format!("no base metadata for object {i}")))?;
let Some(CborValue::Map(m)) = entry.get("grib_repro") else {
return Err(GribError::InvalidData(format!(
"object {i} has no 'grib_repro' key-set; was it produced by convert-grib?"
)));
};
let mut keys = BTreeMap::new();
for (k, v) in m {
if let CborValue::Text(name) = k {
keys.insert(name.clone(), v.clone());
}
}
Ok(keys)
}
fn payload_to_f64(dtype: Dtype, order: ByteOrder, bytes: &[u8]) -> Result<Vec<f64>, GribError> {
if dtype != Dtype::Float64 {
return Err(GribError::InvalidData(format!(
"to-grib expects float64 objects, got {dtype}"
)));
}
let mut out = Vec::with_capacity(bytes.len() / 8);
for chunk in bytes.chunks_exact(8) {
let mut b = [0u8; 8];
b.copy_from_slice(chunk);
out.push(match order {
ByteOrder::Little => f64::from_le_bytes(b),
ByteOrder::Big => f64::from_be_bytes(b),
});
}
Ok(out)
}
fn reconstruct_message(
repro: &BTreeMap<String, CborValue>,
values: &[f64],
) -> Result<Vec<u8>, GribError> {
let sample_path = sample_path()?;
let mut handle = CodesFile::new_from_file(&sample_path, ProductKind::GRIB)?;
let sample = handle.ref_message_iter().next()?.ok_or_else(|| {
GribError::InvalidData(format!("sample {} has no message", sample_path.display()))
})?;
let mut msg = sample.try_clone()?;
let has_local_section = repro.contains_key("grib2LocalSectionNumber");
for &k in PRIORITY_KEYS {
if k == "setLocalDefinition" {
if has_local_section {
let _ = set_key(&mut msg, k, &CborValue::Integer(1_i64.into()));
}
continue;
}
if let Some(v) = repro.get(k) {
let _ = set_key(&mut msg, k, v);
}
}
for (k, v) in repro {
if PRIORITY_KEYS.contains(&k.as_str()) {
continue;
}
let _ = set_key(&mut msg, k, v);
}
msg.write_key_unchecked("values", values)?;
let tmp = temp_path();
msg.write_to_file(&tmp, false)?;
let bytes = std::fs::read(&tmp)?;
let _ = std::fs::remove_file(&tmp);
Ok(bytes)
}
fn set_key(msg: &mut BufMessage, key: &str, value: &CborValue) -> Result<(), GribError> {
match value {
CborValue::Integer(i) => {
let v = i64::try_from(*i).map_err(|_| {
GribError::InvalidData(format!("key {key}: integer out of i64 range"))
})?;
msg.write_key_unchecked(key, v)?;
}
CborValue::Float(f) => {
msg.write_key_unchecked(key, *f)?;
}
CborValue::Text(s) => {
msg.write_key_unchecked(key, s.as_str())?;
}
CborValue::Bytes(b) => {
msg.write_key_unchecked(key, b.as_slice())?;
}
_ => {}
}
Ok(())
}
fn temp_path() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("tensogram_to_grib_{pid}_{nanos}_{n}.grib2"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_to_f64_rejects_non_float64() {
let err = payload_to_f64(Dtype::Int32, ByteOrder::Little, &[0u8; 4]).unwrap_err();
assert!(
matches!(err, GribError::InvalidData(m) if m.contains("float64")),
"non-float64 payload must be a float64 InvalidData error"
);
}
#[test]
fn payload_to_f64_reads_both_endiannesses() {
let le = payload_to_f64(Dtype::Float64, ByteOrder::Little, &1.5_f64.to_le_bytes()).unwrap();
let be = payload_to_f64(Dtype::Float64, ByteOrder::Big, &1.5_f64.to_be_bytes()).unwrap();
assert_eq!(le, vec![1.5]);
assert_eq!(be, vec![1.5]);
}
#[test]
fn repro_keys_errors_when_object_absent() {
let meta = GlobalMetadata::default();
assert!(
repro_keys(&meta, 0).is_err(),
"no base entry for object 0 must error"
);
}
#[test]
fn repro_keys_errors_without_grib_repro() {
let meta = GlobalMetadata {
base: vec![BTreeMap::new()],
..Default::default()
};
let err = repro_keys(&meta, 0).unwrap_err();
assert!(
matches!(err, GribError::InvalidData(m) if m.contains("grib_repro")),
"an object without grib_repro must name the missing key-set"
);
}
#[test]
fn to_grib_rejects_undecodable_message() {
assert!(
to_grib(b"not a tensogram message").is_err(),
"garbage input must not be decoded as a message"
);
}
}