Rewrite the driver in Rust; move the testbench tools out

The repository is now only the HPCS 6500 driver: a Rust library (serial
transport, every protocol command, block parsing, USBPcap captures, a
simulated instrument) and the hpcs6500 CLI with the flags of the former
hpcs6500.py, plus dump/capture for reverse engineering.

The lamp station, lamp export, TM-30, label printer and LED calibration
move to B4L/lamp-testbench. Golden tests pin the Rust output to the Python
driver's: parsed values bit-exact, report text, CSV and number formatting.
The Python driver is at tag python-final.
This commit is contained in:
grabowski committed 2026-10-22 17:01:47 +07:00
1 parent 3ac7cd243b
commit 54242da04b
79 files changed
+23340 -3246

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
//! 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<String> {
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<u8>, 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<u8> = 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<u8>) {
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<u8>, 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);
}
}