7 changed files with 594 additions and 0 deletions
@ -0,0 +1,15 @@
|
||||
[package] |
||||
name = "lowcharts" |
||||
version = "0.1.0" |
||||
authors = ["JuanLeon Lahoz <juanleon.lahoz@gmail.com>"] |
||||
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" |
||||
@ -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<f64>, |
||||
count: usize, |
||||
} |
||||
|
||||
impl Bucket { |
||||
fn new(range: Range<f64>) -> Bucket { |
||||
Bucket { |
||||
range, |
||||
count: 0, |
||||
} |
||||
} |
||||
|
||||
fn inc(&mut self) { |
||||
self.count += 1; |
||||
} |
||||
} |
||||
|
||||
#[derive(Debug)] |
||||
pub struct Histogram { |
||||
vec: Vec<Bucket>, |
||||
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<usize> { |
||||
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!("{:∎<width$}", "", width=bucket.count / divisor)); |
||||
writeln!( |
||||
f, |
||||
"[{range}] [{count}] {bar}", |
||||
range=Blue.paint( |
||||
format!( |
||||
"{:width$.3} .. {:width$.3}", |
||||
bucket.range.start, |
||||
bucket.range.end, |
||||
width = width, |
||||
) |
||||
), |
||||
count=Green.paint(format!("{:width$}", bucket.count, width=width_count)), |
||||
bar=bar |
||||
) |
||||
} |
||||
|
||||
fn get_width(hist: &Histogram) -> 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 |
||||
} |
||||
} |
||||
} |
||||
@ -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<f64>, |
||||
/// Filter out values smaller than this
|
||||
#[clap(long)] |
||||
min: Option<f64>, |
||||
/// 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<value>[0-9.]+)' (a named capture group).
|
||||
#[clap(long)] |
||||
regex: Option<String>, |
||||
#[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); |
||||
} |
||||
} |
||||
} |
||||
@ -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<f64>, |
||||
y_axis: Vec<f64>, |
||||
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<f64>, y_width: usize) -> fmt::Result { |
||||
let mut row = format!("{: <width$}", "", width=x_axis.len()); |
||||
// The reverse in the enumeration is to avoid breaking char boundaries
|
||||
// because of unicode char ● having more bytes than ascii chars.
|
||||
for (x, value) in x_axis.iter().enumerate().rev() { |
||||
if range.contains(value) { |
||||
row.replace_range(x..x+1, "●".as_ref()); |
||||
} |
||||
} |
||||
writeln!( |
||||
f, |
||||
"[{}] {}", |
||||
Blue.paint(format!("{y:.*}", y_width, y=range.start.to_string())), |
||||
Red.paint(row), |
||||
) |
||||
} |
||||
|
||||
|
||||
#[cfg(test)] |
||||
mod tests { |
||||
use super::*; |
||||
use float_eq::assert_float_eq; |
||||
use yansi::Paint; |
||||
|
||||
#[test] |
||||
fn basic_test() { |
||||
let stats = Stats::new(&[-1.0, 4.0]); |
||||
let mut plot = Plot::new(3, 5, stats); |
||||
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); |
||||
assert_float_eq!(plot.x_axis[2], 3.5, rmax <= f64::EPSILON); |
||||
assert_float_eq!(plot.x_axis[3], -1.0, rmax <= f64::EPSILON); |
||||
|
||||
assert_float_eq!(plot.y_axis[0], -1.0, rmax <= f64::EPSILON); |
||||
assert_float_eq!(plot.y_axis[4], 3.0, rmax <= f64::EPSILON); |
||||
} |
||||
|
||||
#[test] |
||||
fn display_test() { |
||||
let stats = Stats::new(&[-1.0, 4.0]); |
||||
let mut plot = Plot::new(3, 5, stats); |
||||
plot.load(&[-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, -1.0]); |
||||
Paint::disable(); |
||||
let display = format!("{}", plot); |
||||
assert!(display.find("[3] ● ").is_some()); |
||||
assert!(display.find("[2] ").is_some()); |
||||
assert!(display.find("[1] ● ").is_some()); |
||||
assert!(display.find("[-1] ● ●").is_some()); |
||||
} |
||||
} |
||||
@ -0,0 +1,102 @@
|
||||
use std::io::{self, BufRead}; |
||||
use std::fs::File; |
||||
use std::ops::Range; |
||||
|
||||
use regex::Regex; |
||||
use yansi::Color::{Red, Magenta}; |
||||
|
||||
|
||||
#[derive(Debug, Default, Builder)] |
||||
pub struct DataReader { |
||||
#[builder(setter(strip_option), default)] |
||||
range: Option<Range<f64>>, |
||||
#[builder(setter(strip_option), default)] |
||||
regex: Option<Regex>, |
||||
#[builder(default)] |
||||
verbose: bool, |
||||
} |
||||
|
||||
|
||||
|
||||
impl DataReader { |
||||
|
||||
pub fn read(&self, path: String) -> Vec<f64> { |
||||
let mut vec: Vec<f64> = 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<T: BufRead>(&self, lines: std::io::Lines<T>) -> Vec<f64> { |
||||
let mut vec: Vec<f64> = 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<f64> { |
||||
match line.parse::<f64>() { |
||||
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<f64> { |
||||
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 |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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::<f64>(); |
||||
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()); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue