From 38ebc48456dce3304737bf5572146a3e7b5d1072 Mon Sep 17 00:00:00 2001 From: JuanLeon Lahoz Date: Sun, 18 Apr 2021 16:58:05 +0200 Subject: [PATCH] Initial version Documentation is pending --- .gitignore | 1 + Cargo.toml | 15 +++++ src/histogram.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 145 +++++++++++++++++++++++++++++++++++++++++++++++ src/plot.rs | 109 +++++++++++++++++++++++++++++++++++ src/reader.rs | 102 +++++++++++++++++++++++++++++++++ src/stats.rs | 83 +++++++++++++++++++++++++++ 7 files changed, 594 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/histogram.rs create mode 100644 src/main.rs create mode 100644 src/plot.rs create mode 100644 src/reader.rs create mode 100644 src/stats.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..66016ec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lowcharts" +version = "0.1.0" +authors = ["JuanLeon Lahoz "] +edition = "2018" + +[dependencies] +clap = "3.0.0-beta.2" +yansi = "0.5.0" +isatty = "0.1" +derive_builder = "0.10.0" +regex = "1.4.5" + +[dev-dependencies] +float_eq = "0.5.0" diff --git a/src/histogram.rs b/src/histogram.rs new file mode 100644 index 0000000..622b123 --- /dev/null +++ b/src/histogram.rs @@ -0,0 +1,139 @@ +use std::fmt; +use std::ops::Range; + +use yansi::Color::{Red, Blue, Green}; + +use crate::stats::Stats; + + +#[derive(Debug)] +pub struct Bucket { + range: Range, + count: usize, +} + +impl Bucket { + fn new(range: Range) -> Bucket { + Bucket { + range, + count: 0, + } + } + + fn inc(&mut self) { + self.count += 1; + } +} + +#[derive(Debug)] +pub struct Histogram { + vec: Vec, + max: f64, + step: f64, + top: usize, + last: usize, + stats: Stats, +} + +impl Histogram { + pub fn new(size: usize, step: f64, stats: Stats) -> Histogram { + let mut b = Histogram { + vec: Vec::with_capacity(size), + max: stats.min + (step * size as f64), + step, + top: 0, + last: size - 1, + stats, + }; + let mut lower = b.stats.min; + for _ in 0..size { + b.vec.push(Bucket::new(lower..lower + step)); + lower += step; + } + b + } + + pub fn load(&mut self, vec: &[f64]) { + for x in vec { + self.add(*x); + } + } + + pub fn add(&mut self, n: f64) { + if let Some(slot) = self.find_slot(n) { + self.vec[slot].inc(); + self.top = self.top.max(self.vec[slot].count); + } + } + + fn find_slot(&self, n: f64) -> Option { + if n < self.stats.min || n > self.max { + None + } else { + Some((((n - self.stats.min) / self.step) as usize).min(self.last)) + } + } +} + +impl fmt::Display for Histogram { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.stats)?; + let writer = HistWriter {width: f.width().unwrap_or(110)}; + writer.write(f, &self) + } +} + +struct HistWriter { + width: usize, +} + +impl HistWriter { + + pub fn write(&self, f: &mut fmt::Formatter, hist: &Histogram) -> fmt::Result { + 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!( + f, + "each {} represents a count of {}", + Red.paint("∎"), + Blue.paint(divisor.to_string()), + )?; + for x in hist.vec.iter() { + self.write_bucket(f, x, divisor, width_range, width_count)?; + } + Ok(()) + } + + + fn write_bucket(&self, f: &mut fmt::Formatter, bucket: &Bucket, divisor: usize, width: usize, width_count: usize) -> fmt::Result { + let bar = Red.paint(format!("{:∎ usize { + format!("{:.3}", hist.stats.min).len().max(format!("{:.3}", hist.max).len()) + } + + fn get_max_bar_len(&self, fixed_width: usize) -> usize { + const EXTRA_CHARS: usize = 10; + if self.width < fixed_width + EXTRA_CHARS { + 75 + } else { + self.width - fixed_width - EXTRA_CHARS + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f2e65be --- /dev/null +++ b/src/main.rs @@ -0,0 +1,145 @@ +use std::env; + +use yansi::Paint; +use yansi::Color::Red; +use yansi::Color::Yellow; +use clap::{AppSettings, Clap}; +use isatty::stdout_isatty; +use regex::Regex; + +#[macro_use] +extern crate derive_builder; + + +mod stats; +mod histogram; +mod reader; +mod plot; + +fn disable_color_if_needed(option: &str) { + match option { + "no" => Paint::disable(), + "auto" => { + match env::var("TERM") { + Ok(value) if value == "dumb" => Paint::disable(), + _ => { + if !stdout_isatty() { + Paint::disable(); + } + } + } + }, + _ => () + } +} + + +/// Tool to draw low-resolution graphs in terminal +#[derive(Clap)] +#[clap(setting = AppSettings::ColoredHelp)] +struct Opts { + /// Input file. If not present or a single dash, standard input will be used. + #[clap(default_value = "-")] + input: String, + /// Filter out values bigger than this + #[clap(long)] + max: Option, + /// Filter out values smaller than this + #[clap(long)] + min: Option, + /// Use colors in the output. Auto means "yes if tty with TERM != dumb and + /// no redirects". + #[clap(short, long, default_value = "auto", possible_values = &["auto", "no", "yes"])] + color: String, + /// Use this many characters as terminal width + #[clap(long, default_value = "110")] + width: usize, + /// Use a regex to capture input values. By default this will use a capture + /// group named "value". If not present, it will use first capture group. + /// If not present, a number per line is expected. Examples of regex are ' + /// 200 \d+ ([0-9.]+)' (1 anonymous capture group) or 'a(a)? + /// (?P[0-9.]+)' (a named capture group). + #[clap(long)] + regex: Option, + #[clap(long)] + /// Be more verbose + verbose: bool, + #[clap(subcommand)] + subcmd: SubCommand, +} + + +#[derive(Clap)] +enum SubCommand { + /// Plot an histogram from input values + Hist(Hist), + /// Plot an 2d plot where y-values are averages of input values (as many + /// averages as wide is the plot) + Plot(Plot), +} + +#[derive(Clap)] +#[clap(setting = AppSettings::ColoredHelp)] +struct Hist { + /// Use that many intervals + #[clap(long, default_value = "20")] + intervals: usize, +} + +#[derive(Clap)] +#[clap(setting = AppSettings::ColoredHelp)] +struct Plot { + /// Use that many rows for the plot + #[clap(long, default_value = "40")] + height: usize, +} + + +fn main() { + let opts: Opts = Opts::parse(); + disable_color_if_needed(&opts.color); + let mut builder = reader::DataReaderBuilder::default(); + builder.verbose(opts.verbose); + if opts.min.is_some() || opts.max.is_some() { + builder.range( + opts.min.unwrap_or(f64::NEG_INFINITY)..opts.max.unwrap_or(f64::INFINITY) + ); + } + if let Some(string) = opts.regex { + match Regex::new(&string) { + Ok(re) => { + builder.regex(re); + }, + _ => eprintln!("[{}]: Failed to parse regex {}", Red.paint("ERROR"), string) + }; + } + let reader = builder.build().unwrap(); + + let vec = reader.read(opts.input); + if vec.is_empty() { + eprintln!("[{}]: No data", Yellow.paint("WARN")); + std::process::exit(0); + } + + let stats = stats::Stats::new(&vec); + match opts.subcmd { + SubCommand::Hist(o) => { + let mut histogram = histogram::Histogram::new( + o.intervals, + (stats.max - stats.min) / o.intervals as f64, + stats + ); + histogram.load(&vec); + println!("{:width$}", histogram, width=opts.width); + }, + SubCommand::Plot(o) => { + let mut plot = plot::Plot::new( + opts.width, + o.height, + stats + ); + plot.load(&vec); + print!("{}", plot); + } + } +} diff --git a/src/plot.rs b/src/plot.rs new file mode 100644 index 0000000..fae40e1 --- /dev/null +++ b/src/plot.rs @@ -0,0 +1,109 @@ +use std::fmt; +use std::ops::Range; + +use yansi::Color::{Red, Blue}; + +use crate::stats::Stats; + + +#[derive(Debug)] +pub struct Plot { + x_axis: Vec, + y_axis: Vec, + width: usize, + height: usize, + stats: Stats, +} + +impl Plot { + pub fn new(width: usize, height: usize, stats: Stats) -> Plot { + Plot { + x_axis: Vec::with_capacity(width), + y_axis: Vec::with_capacity(height), + width, + height, + stats, + } + } + + pub fn load(&mut self, vec: &[f64]) { + self.width = self.width.min(vec.len()); + let num_chunks = vec.len() / self.width; + let iter = vec.chunks(num_chunks); + for x in iter { + let sum: f64 = x.iter().sum(); + self.x_axis.push(sum / x.len() as f64); + } + let step = (self.stats.max - self.stats.min) / self.height as f64; + for y in 0..self.height { + self.y_axis.push(self.stats.min + step * y as f64); + } + } +} + +impl fmt::Display for Plot { + 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 y_width = format!("{:.3}", self.stats.max).len(); + let mut newvec = self.y_axis.to_vec(); + newvec.reverse(); + print_line(f, &self.x_axis, newvec[0]..f64::INFINITY, y_width)?; + for y in newvec.windows(2) { + print_line(f, &self.x_axis, y[1]..y[0], y_width)?; + } + Ok(()) + } +} + +fn print_line(f: &mut fmt::Formatter, x_axis: &[f64], range: Range, y_width: usize) -> fmt::Result { + let mut row = format!("{: >, + #[builder(setter(strip_option), default)] + regex: Option, + #[builder(default)] + verbose: bool, +} + + + +impl DataReader { + + pub fn read(&self, path: String) -> Vec { + let mut vec: Vec = vec![]; + match path.as_str() { + "-" => { + vec = self.read_data(io::stdin().lock().lines()); + } + _ => { + let file = File::open(path); + match file { + Ok(fd) => { + vec = self.read_data(io::BufReader::new(fd).lines()); + } + Err(error) => eprintln!("[{}]: {}", Red.paint("ERROR"), error), + } + } + } + vec + } + + fn read_data(&self, lines: std::io::Lines) -> Vec { + let mut vec: Vec = Vec::new(); + let line_parser = match self.regex { + Some(_) => Self::parse_regex, + None => Self::parse_float + }; + for line in lines { + match line { + Ok(as_string) => if let Some(n) = line_parser(&self, &as_string) { + match &self.range { + Some(range) => if range.contains(&n) { + vec.push(n); + }, + _ => vec.push(n) + } + }, + Err(error) => eprintln!("[{}]: {}", Red.paint("ERROR"), error), + } + } + vec + } + + fn parse_float(&self, line: &str) -> Option { + match line.parse::() { + Ok(n) => Some(n), + Err(parse_error) => { + eprintln!( + "[{}] Cannot parse float ({}) at '{}'", + Red.paint("ERROR"), + parse_error, + line + ); + None + } + } + } + + fn parse_regex(&self, line: &str) -> Option { + match self.regex.as_ref().unwrap().captures(line) { + Some(cap) => { + if let Some(name) = cap.name("value") { + self.parse_float(&name.as_str()) + } else if let Some(capture) = cap.get(1) { + self.parse_float(&capture.as_str()) + } else { + None + } + }, + None => { + if self.verbose { + eprintln!( + "[{}] Regex does not match '{}'", + Magenta.paint("DEBUG"), + line + ); + } + None + } + } + } +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..5caa15c --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,83 @@ +use std::fmt; + +use yansi::Color::Blue; + + +#[derive(Debug)] +pub struct Stats { + pub min: f64, + pub max: f64, + pub avg: f64, + pub std: f64, + pub var: f64, + pub sum: f64, + pub samples: usize, +} + +impl Stats { + + pub fn new(vec: &[f64]) -> Stats { + let mut max = vec[0]; + let mut min = max; + let mut temp: f64 = 0.0; + let sum = vec.iter().sum::(); + let avg = sum / vec.len() as f64; + for val in vec.iter() { + max = max.max(*val); + min = min.min(*val); + temp += (avg - *val).powi(2) + } + let var = temp / vec.len() as f64; + let std = var.sqrt(); + Stats { min, max, avg, std, var, sum, samples: vec.len() } + } +} + +impl fmt::Display for Stats { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!( + f, + "Samples = {len:.5}; Min = {min:.5}; Max = {max:.5}", + len=Blue.paint(self.samples.to_string()), + min=Blue.paint(self.min.to_string()), + max=Blue.paint(self.max.to_string()), + )?; + writeln!( + f, + "Average = {avg:.5}; Variance = {var:.5}; STD = {std:.5}", + avg=Blue.paint(self.avg.to_string()), + var=Blue.paint(self.var.to_string()), + std=Blue.paint(self.std.to_string()) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use float_eq::assert_float_eq; + use yansi::Paint; + + #[test] + fn basic_test() { + let stats = Stats::new(&[1.1, 3.3, 2.2]); + 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); + assert_float_eq!(stats.min, 1.1, rmax <= f64::EPSILON); + assert_float_eq!(stats.max, 3.3, rmax <= f64::EPSILON); + assert_float_eq!(stats.var, 0.8066, abs <= 0.0001); + assert_float_eq!(stats.std, 0.8981, abs <= 0.0001); + } + + #[test] + fn test_display() { + let stats = Stats::new(&[1.1, 3.3, 2.2]); + Paint::disable(); + let display = format!("{}", stats); + assert!(display.find("Samples = 3").is_some()); + assert!(display.find("Min = 1.1").is_some()); + assert!(display.find("Max = 3.3").is_some()); + assert!(display.find("Average = 2.2").is_some()); + } +}