//! Number formatting that matches Python, so files written by the Rust tools //! are byte-identical to the ones the Python tools wrote (CSV exports, lamp //! bundles): `repr(float)`, `format(x, ".6g")`, `format(x, ".2f")` and //! `round(x, n)`. //! //! Python rounds the exact binary value half to even (David Gay's dtoa); //! Rust's shortest formatting breaks ties differently (`93.72439575195313` //! vs Python's `93.72439575195312`), so the digits are derived here from the //! exact decimal expansion. /// Python `repr(x)` / `str(x)` of a float: shortest round-trip digits, /// exponent form below 1e-4 and from 1e16 (`1e-05`, `1e+16`), always a /// `.0` on whole numbers. pub fn repr(x: f64) -> String { if let Some(s) = special(x) { return s; } if x == 0.0 { return if x.is_sign_negative() { "-0.0".into() } else { "0.0".into() }; } let (neg, digits, exp) = exact(x); let (mut d, mut e) = (digits.clone(), exp); for p in 1..=17 { let (rd, re) = round_sig(&digits, exp, p); if to_f64(&rd, re) == x.abs() { (d, e) = (rd, re); break; } } trim(&mut d); let mut out = String::with_capacity(24); if neg { out.push('-'); } if (-4..16).contains(&e) { place(&mut out, &d, e, 0); if !out.contains('.') { out.push_str(".0"); } } else { sci(&mut out, &d, e); } out } /// Python `format(x, f".{prec}g")`. pub fn g(x: f64, prec: usize) -> String { if let Some(s) = special(x) { return s; } let p = prec.max(1); if x == 0.0 { return if x.is_sign_negative() { "-0".into() } else { "0".into() }; } let (neg, digits, exp) = exact(x); let (mut d, e) = round_sig(&digits, exp, p); trim(&mut d); let mut out = String::new(); if neg { out.push('-'); } if e >= -4 && e < p as i32 { place(&mut out, &d, e, 0); } else { sci(&mut out, &d, e); } out } /// Python `format(x, f".{prec}f")`. pub fn fixed(x: f64, prec: usize) -> String { if let Some(s) = special(x) { return s; } let mut out = String::new(); if x.is_sign_negative() { out.push('-'); } if x == 0.0 { place(&mut out, b"0", 0, prec); return out; } let (_, digits, exp) = exact(x); // significant digits kept when rounding at the prec-th decimal let keep = exp + 1 + prec as i32; let (d, e) = if keep > 0 { round_sig(&digits, exp, keep as usize) } else if keep == 0 && round_up_from(&digits, 0, b'0') { (b"1".to_vec(), -(prec as i32)) } else { (b"0".to_vec(), 0) }; place(&mut out, &d, e, prec); out } /// Python `round(x, ndigits)` (half to even on the exact value). pub fn round(x: f64, ndigits: usize) -> f64 { if !x.is_finite() { return x; } fixed(x, ndigits).parse().unwrap_or(x) } fn special(x: f64) -> Option { if x.is_nan() { Some("nan".into()) } else if x.is_infinite() { Some(if x > 0.0 { "inf".into() } else { "-inf".into() }) } else { None } } /// Exact decimal expansion of a finite non-zero x: (negative, significant /// digits without trailing zeros, decimal exponent of the first digit). fn exact(x: f64) -> (bool, Vec, i32) { // every f64 has at most 767 significant decimal digits; Rust prints them exactly let s = format!("{:.800e}", x.abs()); let (mant, e) = s.split_once('e').expect("exponent"); let mut d: Vec = mant.bytes().filter(u8::is_ascii_digit).collect(); trim(&mut d); (x.is_sign_negative(), d, e.parse().expect("exponent")) } fn trim(d: &mut Vec) { while d.len() > 1 && d.last() == Some(&b'0') { d.pop(); } } /// Does the tail starting at `from` round up: more than half, or exactly /// half after an odd last kept digit? fn round_up_from(d: &[u8], from: usize, last_kept: u8) -> bool { let Some(&first) = d.get(from) else { return false; }; if first != b'5' { return first > b'5'; } if d[from + 1..].iter().any(|&c| c != b'0') { return true; } (last_kept - b'0') % 2 == 1 } /// Round to `n` significant digits, half to even. fn round_sig(d: &[u8], exp: i32, n: usize) -> (Vec, i32) { if d.len() <= n { return (d.to_vec(), exp); } let mut k = d[..n].to_vec(); if round_up_from(d, n, k[n - 1]) { let mut i = n; loop { if i == 0 { // 99.9 -> 100: one more digit in front, one fewer at the end k.insert(0, b'1'); k.pop(); return (k, exp + 1); } i -= 1; if k[i] == b'9' { k[i] = b'0'; } else { k[i] += 1; break; } } } (k, exp) } fn to_f64(d: &[u8], e: i32) -> f64 { let s = format!( "{}e{}", std::str::from_utf8(d).expect("digits"), e - d.len() as i32 + 1 ); s.parse().expect("number") } /// Positional notation of digits `d` (first digit at 10^e) with at least /// `min_decimals` decimals. fn place(out: &mut String, d: &[u8], e: i32, min_decimals: usize) { // digit at position 10^i let digit = |i: i32| -> char { let idx = e - i; if idx >= 0 && (idx as usize) < d.len() { d[idx as usize] as char } else { '0' } }; for i in (0..=e.max(0)).rev() { out.push(digit(i)); } let lowest = e - d.len() as i32 + 1; let decimals = (-lowest).max(min_decimals as i32); if decimals > 0 { out.push('.'); for i in 1..=decimals { out.push(digit(-i)); } } } fn sci(out: &mut String, d: &[u8], e: i32) { out.push(d[0] as char); if d.len() > 1 { out.push('.'); out.push_str(std::str::from_utf8(&d[1..]).expect("digits")); } out.push('e'); out.push(if e < 0 { '-' } else { '+' }); let a = e.unsigned_abs(); if a < 10 { out.push('0'); } out.push_str(&a.to_string()); } #[cfg(test)] mod tests { use super::*; #[test] #[allow(clippy::excessive_precision)] fn examples() { assert_eq!(repr(1.0), "1.0"); assert_eq!(repr(100.0), "100.0"); assert_eq!(repr(0.1 + 0.2), "0.30000000000000004"); assert_eq!(repr(1e-5), "1e-05"); assert_eq!(repr(0.0001), "0.0001"); assert_eq!(repr(1e16), "1e+16"); assert_eq!(repr(1234567890123456.0), "1234567890123456.0"); assert_eq!(repr(93.724395751953125), "93.72439575195312"); assert_eq!(repr(-617.35406494140625), "-617.3540649414062"); assert_eq!(g(0.00191776, 6), "0.00191776"); assert_eq!(g(1.5e-5, 6), "1.5e-05"); assert_eq!(g(123456.5, 6), "123456"); assert_eq!(g(1234567.0, 6), "1.23457e+06"); assert_eq!(g(999999.5, 6), "1e+06"); assert_eq!(g(100.0, 6), "100"); assert_eq!(g(0.0, 6), "0"); assert_eq!(g(f64::NAN, 6), "nan"); assert_eq!(fixed(380.0, 2), "380.00"); assert_eq!(fixed(0.125, 2), "0.12"); assert_eq!(fixed(0.375, 2), "0.38"); assert_eq!(fixed(2.5, 0), "2"); assert_eq!(fixed(0.5, 0), "0"); assert_eq!(fixed(1.5, 0), "2"); assert_eq!(fixed(-0.001, 2), "-0.00"); assert_eq!(fixed(0.006, 2), "0.01"); assert_eq!(fixed(99.995, 2), "100.00"); assert_eq!(fixed(0.004, 2), "0.00"); assert_eq!(round(83.25, 1), 83.2); } }