Skip to main content

rusty_tip/
plotting.rs

1use textplots::{Chart, Plot};
2
3/// Determine the best scale and unit for a given maximum value
4fn determine_scale(max_value: f64) -> (f64, &'static str) {
5    if max_value >= 1.0 {
6        (1.0, "")
7    } else if max_value >= 1e-3 {
8        (1e3, "m")
9    } else if max_value >= 1e-6 {
10        (1e6, "μ")
11    } else if max_value >= 1e-9 {
12        (1e9, "n")
13    } else {
14        (1e12, "p")
15    }
16}
17
18/// Plot any slice of f64 values with automatic dynamic scaling
19///
20/// # Arguments
21/// * `values` - The data values to plot
22/// * `title` - Optional title for the plot
23/// * `width` - Optional plot width (default: 140)
24/// * `height` - Optional plot height (default: 60)
25///
26/// # Examples
27/// ```
28/// use rusty_tip::plotting::plot_values;
29///
30/// let data = vec![1e-12, 2e-12, 1.5e-12, 3e-12];
31/// plot_values(&data, Some("Current Signal"), None, None).unwrap();
32/// ```
33pub fn plot_values(
34    values: &[f64],
35    title: Option<&str>,
36    width: Option<usize>,
37    height: Option<usize>,
38) -> Result<(), Box<dyn std::error::Error>> {
39    if values.is_empty() {
40        return Err("Cannot plot empty data".into());
41    }
42
43    let width = width.unwrap_or(140);
44    let height = height.unwrap_or(60);
45
46    // Find min/max values for scaling
47    let min_value = values.iter().fold(f64::INFINITY, |a, &b| a.min(b));
48    let max_value = values.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
49    let max_abs = max_value.abs().max(min_value.abs());
50
51    // Determine scaling
52    let (value_scale, value_unit) = determine_scale(max_abs);
53
54    // Create frame data for plotting (index, scaled_value)
55    let frame: Vec<(f32, f32)> = values
56        .iter()
57        .enumerate()
58        .map(|(i, &value)| (i as f32, (value * value_scale) as f32))
59        .collect();
60
61    let max_index = (values.len() - 1) as f32;
62    let scaled_min = min_value * value_scale;
63    let scaled_max = max_value * value_scale;
64
65    // Print header and info
66    if let Some(title) = title {
67        println!("{}", title);
68    } else {
69        println!("Data Plot");
70    }
71    println!("X-axis: Sample Index | Y-axis: {}units", value_unit);
72    println!(
73        "Range: {} samples | Values: {:.3} to {:.3} {}units",
74        values.len(),
75        scaled_min,
76        scaled_max,
77        value_unit
78    );
79    println!("{}", "─".repeat(width));
80
81    // Create and display the plot
82    Chart::new(width as u32, height as u32, 0.0, max_index)
83        .lineplot(&textplots::Shape::Lines(&frame))
84        .nice();
85
86    println!("Sample Index →");
87
88    Ok(())
89}
90
91/// Plot with custom Y-axis range (useful when you want to set your own bounds)
92/// Note: textplots doesn't support custom Y ranges, so this clips the data to the range
93pub fn plot_values_with_range(
94    values: &[f64],
95    y_min: f64,
96    y_max: f64,
97    title: Option<&str>,
98    width: Option<usize>,
99    height: Option<usize>,
100) -> Result<(), Box<dyn std::error::Error>> {
101    if values.is_empty() {
102        return Err("Cannot plot empty data".into());
103    }
104
105    let width = width.unwrap_or(140);
106    let height = height.unwrap_or(60);
107    let max_abs = y_max.abs().max(y_min.abs());
108
109    // Determine scaling based on provided range
110    let (value_scale, value_unit) = determine_scale(max_abs);
111
112    // Create frame data, clipping values to the specified range
113    let frame: Vec<(f32, f32)> = values
114        .iter()
115        .enumerate()
116        .map(|(i, &value)| {
117            let clipped_value = value.max(y_min).min(y_max);
118            (i as f32, (clipped_value * value_scale) as f32)
119        })
120        .collect();
121
122    let max_index = (values.len() - 1) as f32;
123    let scaled_y_min = y_min * value_scale;
124    let scaled_y_max = y_max * value_scale;
125
126    // Print header
127    if let Some(title) = title {
128        println!("{}", title);
129    } else {
130        println!("Data Plot (Clipped to Range)");
131    }
132    println!("X-axis: Sample Index | Y-axis: {}units", value_unit);
133    println!(
134        "Range: {} samples | Y-Range: {:.3} to {:.3} {}units (clipped)",
135        values.len(),
136        scaled_y_min,
137        scaled_y_max,
138        value_unit
139    );
140    println!("{}", "─".repeat(width));
141
142    // Create plot (textplots will auto-scale to the data)
143    Chart::new(width as u32, height as u32, 0.0, max_index)
144        .lineplot(&textplots::Shape::Lines(&frame))
145        .nice();
146
147    println!("Sample Index →");
148
149    Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_determine_scale() {
158        // Test different scales
159        assert_eq!(determine_scale(5.0), (1.0, ""));
160        assert_eq!(determine_scale(0.005), (1e3, "m"));
161        assert_eq!(determine_scale(5e-6), (1e6, "μ"));
162        assert_eq!(determine_scale(5e-9), (1e9, "n"));
163        assert_eq!(determine_scale(5e-12), (1e12, "p"));
164    }
165
166    #[test]
167    fn test_plot_values_basic() {
168        let data = vec![1.0, 2.0, 3.0, 2.0, 1.0];
169        // Should not panic
170        assert!(plot_values(&data, Some("Test Plot"), None, None).is_ok());
171    }
172
173    #[test]
174    fn test_plot_empty_data() {
175        let data: Vec<f64> = vec![];
176        assert!(plot_values(&data, None, None, None).is_err());
177    }
178
179    #[test]
180    fn test_plot_small_values() {
181        let data = vec![1e-12, 2e-12, 1.5e-12, 3e-12];
182        // Should not panic and should use pico scaling
183        assert!(plot_values(&data, Some("Picoamp Current"), None, None).is_ok());
184    }
185}