From f77c695de4da211c37564be7ee5a1a87ffc03af4 Mon Sep 17 00:00:00 2001 From: JuanLeon Lahoz Date: Sat, 16 Apr 2022 16:32:56 +0200 Subject: [PATCH] Use human friendly numbers by default (unless --precision is used) --- src/app.rs | 17 +++- src/format/mod.rs | 158 +++++++++++++++++++++++++++++++++++++ src/main.rs | 26 +++++- src/plot/histogram.rs | 68 ++++++++++++---- src/plot/xy.rs | 47 ++++++++--- src/stats/mod.rs | 36 +++++---- tests/integration_tests.rs | 58 +++++++++++++- 7 files changed, 364 insertions(+), 46 deletions(-) create mode 100644 src/format/mod.rs diff --git a/src/app.rs b/src/app.rs index c291304..68c3573 100644 --- a/src/app.rs +++ b/src/app.rs @@ -94,11 +94,24 @@ fn add_intervals(cmd: Command) -> Command { ) } +fn add_precision(cmd: Command) -> Command { + cmd.arg( + Arg::new("precision") + .long("precision") + .short('p') + .help("Show that number of decimals (if omitted, 'human' units will be used)") + .default_value("-1") + .takes_value(true), + ) +} + pub fn get_app() -> Command<'static> { let mut hist = Command::new("hist") .version(clap::crate_version!()) .about("Plot an histogram from input values"); - hist = add_input(add_regex(add_width(add_min_max(add_intervals(hist))))); + hist = add_input(add_regex(add_width(add_min_max(add_precision( + add_intervals(hist), + ))))); let mut plot = Command::new("plot") .version(clap::crate_version!()) @@ -111,7 +124,7 @@ pub fn get_app() -> Command<'static> { .default_value("40") .takes_value(true), ); - plot = add_input(add_regex(add_width(add_min_max(plot)))); + plot = add_input(add_regex(add_width(add_min_max(add_precision(plot))))); let mut matches = Command::new("matches") .version(clap::crate_version!()) diff --git a/src/format/mod.rs b/src/format/mod.rs new file mode 100644 index 0000000..11d1021 --- /dev/null +++ b/src/format/mod.rs @@ -0,0 +1,158 @@ +use std::ops::Range; + +// Units-based suffixes for human formatting. +const UNITS: &[&str] = &["", " K", " M", " G", " T", " P", " E", " Z", " Y"]; + +#[derive(Debug)] +pub struct F64Formatter { + /// Decimals digits to be used + decimals: usize, + /// Number of times the value will be divided by 1000 + divisor: u8, + /// Suffix (typycally units) to be printed after number + suffix: String, +} + +impl F64Formatter { + /// Initializes a new `HumanF64Formatter` with default values. + pub fn new(decimals: usize) -> F64Formatter { + F64Formatter { + decimals, + divisor: 0, + suffix: "".to_owned(), + } + } + + /// Initializes a new `HumanF64Formatter` for formatting numbers in the + /// provided range. + pub fn new_with_range(range: Range) -> F64Formatter { + // Range + let mut decimals = 3; + let mut divisor = 0_u8; + let mut suffix = UNITS[0].to_owned(); + let difference = range.end - range.start; + if difference == 0.0 { + return F64Formatter { + decimals, + divisor, + suffix, + }; + } + let log = difference.abs().log10() as i64; + if log <= 0 { + decimals = (-log as usize).min(8) + 3; + } else { + decimals = log.rem_euclid(3) as usize; + divisor = ((log - 1) / 3).min(5) as u8; + } + suffix = UNITS[divisor as usize].to_owned(); + F64Formatter { + decimals, + divisor, + suffix, + } + } + + pub fn format(&self, number: f64) -> String { + format!( + "{:.*}{}", + self.decimals, + number / 1000_usize.pow(self.divisor.into()) as f64, + self.suffix + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_format() { + assert_eq!(F64Formatter::new(0).format(1000.0), "1000"); + assert_eq!(F64Formatter::new(3).format(1000.0), "1000.000"); + assert_eq!(F64Formatter::new(1).format(12345.299), "12345.3"); + assert_eq!(F64Formatter::new(10).format(3.0), "3.0000000000"); + } + + #[test] + fn test_human_format_from_zero() { + assert_eq!(F64Formatter::new_with_range(0.0..2.0).format(1.12), "1.120"); + assert_eq!( + F64Formatter::new_with_range(0.0..200.0).format(234.12), + "234.12" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..1000.0).format(234.1234), + "234" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..10000.0).format(234.1234), + "0.2 K" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..100000.0).format(234.1234), + "0.23 K" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..1000000.0).format(234.1234), + "0 K" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..100000000.0).format(1234.1234), + "0.00 M" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..1000000.0).format(234000.1234), + "234 K" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..100000000.0).format(1234000.1234), + "1.23 M" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..100000000.0).format(12340000.1234), + "12.34 M" + ); + } + + #[test] + fn test_human_format_small_numbers() { + assert_eq!( + F64Formatter::new_with_range(0.0..0.0002).format(0.0000043), + "0.000004" + ); + assert_eq!( + F64Formatter::new_with_range(0.0..0.00002).format(0.0000043), + "0.0000043" + ); + assert_eq!( + F64Formatter::new_with_range(20000.0..20000.00002).format(20000.0000043), + "20000.0000043" + ); + } + + #[test] + fn test_human_format_bignum_small_interval() { + assert_eq!( + F64Formatter::new_with_range(100000000.0..100000001.0).format(100000000.12341234), + "100000000.123" + ); + } + + #[test] + fn test_human_format_negative_start() { + assert_eq!( + F64Formatter::new_with_range(-4.0..2.0).format(1.12), + "1.120" + ); + assert_eq!( + F64Formatter::new_with_range(-4.0..-2.0).format(-3.12), + "-3.120" + ); + assert_eq!( + F64Formatter::new_with_range(-10000000.0..10.0).format(-3.12), + "-0.0 M" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 267a389..962f2d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod format; mod plot; mod read; mod stats; @@ -108,13 +109,23 @@ fn histogram(matches: &ArgMatches) -> i32 { if !assert_data(&vec, 1) { return 1; } - let stats = stats::Stats::new(&vec); + let precision_arg: i32 = matches.value_of_t("precision").unwrap(); + let precision = if precision_arg < 0 { + None + } else { + Some(precision_arg as usize) + }; + let stats = stats::Stats::new(&vec, precision); let width = matches.value_of_t("width").unwrap(); let mut intervals: usize = matches.value_of_t("intervals").unwrap(); intervals = intervals.min(vec.len()); - let mut histogram = - plot::Histogram::new(intervals, (stats.max - stats.min) / intervals as f64, stats); + let mut histogram = plot::Histogram::new( + intervals, + (stats.max - stats.min) / intervals as f64, + stats, + precision, + ); histogram.load(&vec); print!("{:width$}", histogram, width = width); 0 @@ -130,10 +141,17 @@ fn plot(matches: &ArgMatches) -> i32 { if !assert_data(&vec, 1) { return 1; } + let precision_arg: i32 = matches.value_of_t("precision").unwrap(); + let precision = if precision_arg < 0 { + None + } else { + Some(precision_arg as usize) + }; let mut plot = plot::XyPlot::new( matches.value_of_t("width").unwrap(), matches.value_of_t("height").unwrap(), - stats::Stats::new(&vec), + stats::Stats::new(&vec, precision), + precision, ); plot.load(&vec); print!("{}", plot); diff --git a/src/plot/histogram.rs b/src/plot/histogram.rs index 23905fd..1b61870 100644 --- a/src/plot/histogram.rs +++ b/src/plot/histogram.rs @@ -3,6 +3,7 @@ use std::ops::Range; use yansi::Color::{Blue, Green, Red}; +use crate::format::F64Formatter; use crate::stats::Stats; #[derive(Debug)] @@ -29,10 +30,11 @@ pub struct Histogram { top: usize, last: usize, stats: Stats, + precision: Option, // If None, then human friendly display will be used } impl Histogram { - pub fn new(size: usize, step: f64, stats: Stats) -> Histogram { + pub fn new(size: usize, step: f64, stats: Stats, precision: Option) -> Histogram { let mut vec = Vec::::with_capacity(size); let mut lower = stats.min; for _ in 0..size { @@ -46,6 +48,7 @@ impl Histogram { top: 0, last: size - 1, stats, + precision, } } @@ -74,8 +77,13 @@ impl Histogram { impl fmt::Display for Histogram { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.stats)?; + let formatter = match self.precision { + None => F64Formatter::new_with_range(self.stats.min..self.stats.max), + Some(n) => F64Formatter::new(n), + }; let writer = HistWriter { width: f.width().unwrap_or(110), + formatter, }; writer.write(f, self) } @@ -83,11 +91,12 @@ impl fmt::Display for Histogram { struct HistWriter { width: usize, + formatter: F64Formatter, } impl HistWriter { pub fn write(&self, f: &mut fmt::Formatter, hist: &Histogram) -> fmt::Result { - let width_range = Self::get_width(hist); + let width_range = self.get_width(hist); let width_count = ((hist.top as f64).log10().ceil() as usize).max(1); let divisor = 1.max(hist.top / self.get_max_bar_len(width_range + width_count)); writeln!( @@ -114,9 +123,9 @@ impl HistWriter { f, "[{range}] [{count}] {bar}", range = Blue.paint(format!( - "{:width$.3} .. {:width$.3}", - bucket.range.start, - bucket.range.end, + "{:>width$} .. {:>width$}", + self.formatter.format(bucket.range.start), + self.formatter.format(bucket.range.end), width = width, )), count = Green.paint(format!("{:width$}", bucket.count, width = width_count)), @@ -124,10 +133,11 @@ impl HistWriter { ) } - fn get_width(hist: &Histogram) -> usize { - format!("{:.3}", hist.stats.min) + fn get_width(&self, hist: &Histogram) -> usize { + self.formatter + .format(hist.stats.min) .len() - .max(format!("{:.3}", hist.max).len()) + .max(self.formatter.format(hist.max).len()) } fn get_max_bar_len(&self, fixed_width: usize) -> usize { @@ -147,8 +157,8 @@ mod tests { #[test] fn test_buckets() { - let stats = Stats::new(&[-2.0, 14.0]); - let mut hist = Histogram::new(8, 2.5, stats); + let stats = Stats::new(&[-2.0, 14.0], None); + let mut hist = Histogram::new(8, 2.5, stats, None); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); @@ -164,15 +174,15 @@ mod tests { #[test] fn test_buckets_bad_stats() { - let mut hist = Histogram::new(6, 1.0, Stats::new(&[-2.0, 4.0])); + let mut hist = Histogram::new(6, 1.0, Stats::new(&[-2.0, 4.0], None), None); hist.load(&[-1.0, 2.0, -1.0, 2.0, 10.0, 10.0, 10.0, -10.0]); assert_eq!(hist.top, 2); } #[test] fn display_test() { - let stats = Stats::new(&[-2.0, 14.0]); - let mut hist = Histogram::new(8, 2.5, stats); + let stats = Stats::new(&[-2.0, 14.0], None); + let mut hist = Histogram::new(8, 2.5, stats, Some(3)); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); @@ -185,7 +195,7 @@ mod tests { #[test] fn display_test_bad_width() { - let mut hist = Histogram::new(8, 2.5, Stats::new(&[-2.0, 14.0])); + let mut hist = Histogram::new(8, 2.5, Stats::new(&[-2.0, 14.0], None), Some(3)); hist.load(&[ -1.0, -1.1, 2.0, 2.0, 2.1, -0.9, 11.0, 11.2, 1.9, 1.99, 1.98, 1.97, 1.96, ]); @@ -193,4 +203,34 @@ mod tests { let display = format!("{:2}", hist); assert!(display.contains("[-2.000 .. 0.500] [3] ∎∎∎\n")); } + + #[test] + fn display_test_human_units() { + let vector = &[ + -1.0, + -12000000.0, + -12000001.0, + -12000002.0, + -12000003.0, + -2000000.0, + 500000.0, + 500000.0, + ]; + let intervals = vector.len(); + let stats = Stats::new(vector, None); + let mut hist = Histogram::new( + intervals, + (stats.max - stats.min) / intervals as f64, + stats, + None, + ); + hist.load(vector); + Paint::disable(); + let display = format!("{}", hist); + assert!(display.contains("[-12.0 M .. -10.4 M] [4] ∎∎∎∎\n")); + assert!(display.contains("[ -2.6 M .. -1.1 M] [1] ∎\n")); + assert!(display.contains("[ -1.1 M .. 0.5 M] [3] ∎∎∎\n")); + assert!(display.contains("Samples = 8; Min = -12.0 M; Max = 0.5 M")); + assert!(display.contains("Average = -6.1 M;")); + } } diff --git a/src/plot/xy.rs b/src/plot/xy.rs index ce5db8b..a58ccec 100644 --- a/src/plot/xy.rs +++ b/src/plot/xy.rs @@ -3,6 +3,7 @@ use std::ops::Range; use yansi::Color::{Blue, Red}; +use crate::format::F64Formatter; use crate::stats::Stats; #[derive(Debug)] @@ -12,16 +13,18 @@ pub struct XyPlot { width: usize, height: usize, stats: Stats, + precision: Option, } impl XyPlot { - pub fn new(width: usize, height: usize, stats: Stats) -> XyPlot { + pub fn new(width: usize, height: usize, stats: Stats, precision: Option) -> XyPlot { XyPlot { x_axis: Vec::with_capacity(width), y_axis: Vec::with_capacity(height), width, height, stats, + precision, } } @@ -44,17 +47,21 @@ impl fmt::Display for XyPlot { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.stats)?; let _step = (self.stats.max - self.stats.min) / self.height as f64; + let f64fmt = match self.precision { + None => F64Formatter::new_with_range(self.stats.min..self.stats.max), + Some(n) => F64Formatter::new(n), + }; let y_width = self .y_axis .iter() - .map(|v| format!("{:.3}", v).len()) + .map(|v| f64fmt.format(*v).len()) .max() .unwrap(); let mut newvec = self.y_axis.to_vec(); newvec.reverse(); - print_line(f, &self.x_axis, newvec[0]..f64::INFINITY, y_width)?; + print_line(f, &self.x_axis, newvec[0]..f64::INFINITY, y_width, &f64fmt)?; for y in newvec.windows(2) { - print_line(f, &self.x_axis, y[1]..y[0], y_width)?; + print_line(f, &self.x_axis, y[1]..y[0], y_width, &f64fmt)?; } Ok(()) } @@ -65,6 +72,7 @@ fn print_line( x_axis: &[f64], range: Range, y_width: usize, + f64fmt: &F64Formatter, ) -> fmt::Result { let mut row = format!("{: width$.3}", range.start, width = y_width)), + Blue.paint(format!( + "{:>width$}", + f64fmt.format(range.start), + width = y_width + )), Red.paint(row), ) } @@ -90,8 +102,8 @@ mod tests { #[test] fn basic_test() { - let stats = Stats::new(&[-1.0, 4.0]); - let mut plot = XyPlot::new(3, 5, stats); + let stats = Stats::new(&[-1.0, 4.0], None); + let mut plot = XyPlot::new(3, 5, stats, Some(3)); plot.load(&[-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, -1.0]); assert_float_eq!(plot.x_axis[0], -0.5, rmax <= f64::EPSILON); assert_float_eq!(plot.x_axis[1], 1.5, rmax <= f64::EPSILON); @@ -104,8 +116,8 @@ mod tests { #[test] fn display_test() { - let stats = Stats::new(&[-1.0, 4.0]); - let mut plot = XyPlot::new(3, 5, stats); + let stats = Stats::new(&[-1.0, 4.0], None); + let mut plot = XyPlot::new(3, 5, stats, Some(3)); plot.load(&[-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, -1.0]); Paint::disable(); let display = format!("{}", plot); @@ -114,4 +126,21 @@ mod tests { assert!(display.contains("[ 1.000] ● ")); assert!(display.contains("[-1.000] ● ●")); } + + #[test] + fn display_test_human_units() { + let vector = &[1000000.0, -1000000.0, -2000000.0, -4000000.0]; + let stats = Stats::new(vector, None); + let mut plot = XyPlot::new(3, 5, stats, None); + plot.load(vector); + Paint::disable(); + let display = format!("{}", plot); + assert!(display.contains("[ 0 K] ● ")); + assert!(display.contains("[-1000 K] ● ")); + assert!(display.contains("[-2000 K] ● ")); + assert!(display.contains("[-3000 K] ")); + assert!(display.contains("[-4000 K] ●")); + assert!(display.contains("Samples = 4; Min = -4000 K; Max = 1000 K")); + assert!(display.contains("Average = -1500 K;")); + } } diff --git a/src/stats/mod.rs b/src/stats/mod.rs index bf22a20..a4f08ae 100644 --- a/src/stats/mod.rs +++ b/src/stats/mod.rs @@ -2,6 +2,8 @@ use std::fmt; use yansi::Color::Blue; +use crate::format::F64Formatter; + #[derive(Debug)] pub struct Stats { pub min: f64, @@ -11,10 +13,11 @@ pub struct Stats { pub var: f64, pub sum: f64, pub samples: usize, + pub precision: Option, // If None, then human friendly display will be used } impl Stats { - pub fn new(vec: &[f64]) -> Stats { + pub fn new(vec: &[f64], precision: Option) -> Stats { let mut max = vec[0]; let mut min = max; let mut temp: f64 = 0.0; @@ -35,25 +38,30 @@ impl Stats { var, sum, samples: vec.len(), + precision, } } } impl fmt::Display for Stats { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let formatter = match self.precision { + None => F64Formatter::new_with_range(self.min..self.max), + Some(n) => F64Formatter::new(n), + }; writeln!( f, "Samples = {len}; Min = {min}; Max = {max}", len = Blue.paint(self.samples.to_string()), - min = Blue.paint(self.min.to_string()), - max = Blue.paint(self.max.to_string()), + min = Blue.paint(formatter.format(self.min)), + max = Blue.paint(formatter.format(self.max)), )?; writeln!( f, "Average = {avg}; Variance = {var}; STD = {std}", - avg = Blue.paint(self.avg.to_string()), - var = Blue.paint(self.var.to_string()), - std = Blue.paint(self.std.to_string()) + avg = Blue.paint(formatter.format(self.avg)), + var = Blue.paint(format!("{:.3}", self.var)), + std = Blue.paint(format!("{:.3}", self.std)), ) } } @@ -66,7 +74,7 @@ mod tests { #[test] fn basic_test() { - let stats = Stats::new(&[1.1, 3.3, 2.2]); + let stats = Stats::new(&[1.1, 3.3, 2.2], Some(3)); assert_eq!(3_usize, stats.samples); assert_float_eq!(stats.sum, 6.6, rmax <= f64::EPSILON); assert_float_eq!(stats.avg, 2.2, rmax <= f64::EPSILON); @@ -78,22 +86,22 @@ mod tests { #[test] fn test_display() { - let stats = Stats::new(&[1.1, 3.3, 2.2]); + let stats = Stats::new(&[1.1, 3.3, 2.2], Some(3)); Paint::disable(); let display = format!("{}", stats); assert!(display.contains("Samples = 3")); - assert!(display.contains("Min = 1.1")); - assert!(display.contains("Max = 3.3")); - assert!(display.contains("Average = 2.2")); + assert!(display.contains("Min = 1.100")); + assert!(display.contains("Max = 3.300")); + assert!(display.contains("Average = 2.200")); } #[test] fn test_big_num() { - let stats = Stats::new(&[123456789.1234, 123456788.1234]); + let stats = Stats::new(&[123456789.1234, 123456788.1234], None); Paint::disable(); let display = format!("{}", stats); assert!(display.contains("Samples = 2")); - assert!(display.contains("Min = 123456788.1234")); - assert!(display.contains("Max = 123456789.1234")); + assert!(display.contains("Min = 123456788.123")); + assert!(display.contains("Max = 123456789.123")); } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 496f2ab..d2ff4df 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -83,10 +83,26 @@ fn test_hist() { .assert() .success() .stdout(predicate::str::contains( - "Samples = 2; Min = 2.4; Max = 4.2", + "Samples = 2; Min = 2.400; Max = 4.200", )); } +#[test] +fn test_hist_human() { + let mut cmd = Command::cargo_bin("lowcharts").unwrap(); + cmd.arg("hist") + .arg("--min") + .arg("1") + .write_stdin("42000000\n24000000\n") + .assert() + .success() + .stdout(predicate::str::contains( + "Samples = 2; Min = 24.0 M; Max = 42.0 M", + )) + .stdout(predicate::str::contains("\n[24.0 M .. 33.0 M] [1] ∎\n")) + .stdout(predicate::str::contains("\n[33.0 M .. 42.0 M] [1] ∎\n")); +} + #[test] fn test_matchbar() { let mut cmd = Command::cargo_bin("lowcharts").unwrap(); @@ -156,7 +172,9 @@ fn test_plot() { .arg("4") .assert() .success() - .stdout(predicate::str::contains("Samples = 4; Min = 1; Max = 4\n")) + .stdout(predicate::str::contains( + "Samples = 4; Min = 1.000; Max = 4.000\n", + )) .stdout(predicate::str::contains("\n[3.250] ●")) .stdout(predicate::str::contains("\n[2.500] ●")) .stdout(predicate::str::contains("\n[1.750] ●")) @@ -167,6 +185,40 @@ fn test_plot() { } } +#[test] +fn test_plot_precision() { + let mut cmd = Command::cargo_bin("lowcharts").unwrap(); + match NamedTempFile::new() { + Ok(ref mut file) => { + writeln!(file, "1").unwrap(); + writeln!(file, "2").unwrap(); + writeln!(file, "3").unwrap(); + writeln!(file, "4").unwrap(); + writeln!(file, "none").unwrap(); + cmd.arg("--verbose") + .arg("--color") + .arg("no") + .arg("plot") + .arg(file.path().to_str().unwrap()) + .arg("--height") + .arg("4") + .arg("--precision") + .arg("1") + .assert() + .success() + .stdout(predicate::str::contains( + "Samples = 4; Min = 1.0; Max = 4.0\n", + )) + .stdout(predicate::str::contains("\n[3.2] ●")) + .stdout(predicate::str::contains("\n[2.5] ●")) + .stdout(predicate::str::contains("\n[1.8] ●")) + .stdout(predicate::str::contains("\n[1.0] ●")) + .stderr(predicate::str::contains("[DEBUG] Cannot parse float")); + } + Err(_) => assert!(false, "Could not create temp file"), + } +} + #[test] fn test_hist_negative_min() { let mut cmd = Command::cargo_bin("lowcharts").unwrap(); @@ -179,7 +231,7 @@ fn test_hist_negative_min() { .assert() .success() .stdout(predicate::str::contains( - "Samples = 2; Min = 2.4; Max = 4.2", + "Samples = 2; Min = 2.400; Max = 4.200", )); }