scirs2_fft/
waterfall.rs

1//! Waterfall plot module for time-frequency analysis visualization
2//!
3//! This module provides functionality for creating waterfall plot data, which
4//! represents how the frequency spectrum of a signal changes over time.
5//! Waterfall plots are particularly useful for visualizing dynamic spectral
6//! characteristics of signals.
7//!
8//! Unlike spectrograms (which typically show time on the x-axis, frequency on
9//! the y-axis, and intensity as color), waterfall plots can be thought of as
10//! a 3D representation with time, frequency, and amplitude/intensity axes.
11//!
12//! This module provides functions to generate the data needed for waterfall
13//! plots in different formats and coordinate systems.
14
15use crate::error::{FFTError, FFTResult};
16use crate::spectrogram::{spectrogram, spectrogram_normalized};
17use crate::window::Window;
18use scirs2_core::ndarray::{Array1, Array2, Array3};
19use scirs2_core::numeric::NumCast;
20use std::f64::consts::PI;
21
22/// Generate data for a 3D waterfall plot from a time-domain signal.
23///
24/// This function computes a spectrogram and organizes the data for a 3D
25/// waterfall plot visualization, with time, frequency, and amplitude as axes.
26///
27/// # Arguments
28///
29/// * `x` - Input signal array
30/// * `fs` - Sampling frequency of the signal (default: 1.0)
31/// * `nperseg` - Length of each segment (default: 256)
32/// * `noverlap` - Number of points to overlap between segments (default: `nperseg // 2`)
33/// * `log_scale` - Whether to use logarithmic (dB) scale for amplitudes (default: true)
34/// * `db_range` - Dynamic range in dB for normalization when using log scale (default: 60.0)
35///
36/// # Returns
37///
38/// * A tuple containing:
39///   - Time values array (t) - Times at the center of each segment
40///   - Frequency values array (f) - Frequency bins
41///   - Waterfall data array (Z) - 3D array with shape [time, frequency, 3] where the last dimension
42///     contains [t, f, amplitude] coordinates for each point
43///
44/// # Errors
45///
46/// Returns an error if the computation fails or if parameters are invalid.
47///
48/// # Examples
49///
50/// ```
51/// use scirs2_fft::waterfall_3d;
52/// use std::f64::consts::PI;
53///
54/// // Generate a chirp signal
55/// let fs = 1000.0; // 1 kHz sampling rate
56/// let t = (0..1000).map(|i| i as f64 / fs).collect::<Vec<_>>();
57/// let chirp = t.iter().map(|&ti| (2.0 * PI * (10.0 + 50.0 * ti) * ti).sin()).collect::<Vec<_>>();
58///
59/// // Compute waterfall plot data
60/// let (times, freqs, data) = waterfall_3d(
61///     &chirp,
62///     Some(fs),
63///     Some(128),
64///     Some(64),
65///     Some(true),
66///     Some(80.0),
67/// ).unwrap();
68///
69/// // Verify dimensions
70/// assert_eq!(data.shape()[0], times.len());    // Time dimension
71/// assert_eq!(data.shape()[1], freqs.len());    // Frequency dimension
72/// assert_eq!(data.shape()[2], 3);              // X, Y, Z coordinates
73/// ```
74#[allow(dead_code)]
75pub fn waterfall_3d<T>(
76    x: &[T],
77    fs: Option<f64>,
78    nperseg: Option<usize>,
79    noverlap: Option<usize>,
80    log_scale: Option<bool>,
81    db_range: Option<f64>,
82) -> FFTResult<(Array1<f64>, Array1<f64>, Array3<f64>)>
83where
84    T: NumCast + Copy + std::fmt::Debug,
85{
86    // Set default parameters
87    let fs = fs.unwrap_or(1.0);
88    let nperseg = nperseg.unwrap_or(256);
89    let noverlap = noverlap.unwrap_or(nperseg / 2);
90    let log_scale = log_scale.unwrap_or(true);
91    let db_range = db_range.unwrap_or(60.0);
92
93    // Compute spectrogram
94    let (freqs, times, spec_data) = if log_scale {
95        // Use normalized spectrogram (log scale)
96        spectrogram_normalized(x, Some(fs), Some(nperseg), Some(noverlap), Some(db_range))?
97    } else {
98        // Use linear amplitude spectrogram
99        spectrogram(
100            x,
101            Some(fs),
102            Some(Window::Hann),
103            Some(nperseg),
104            Some(noverlap),
105            None,
106            None,
107            Some("density"),
108            Some("magnitude"),
109        )?
110    };
111
112    // Get dimensions
113    let n_times = times.len();
114    let n_freqs = freqs.len();
115
116    // Create arrays for coordinates and copy data
117    let mut waterfall_data = Array3::zeros((n_times, n_freqs, 3));
118
119    // Fill the data array with [t, f, amplitude] coordinates
120    for t_idx in 0..n_times {
121        for f_idx in 0..n_freqs {
122            let amplitude = spec_data[[f_idx, t_idx]];
123            waterfall_data[[t_idx, f_idx, 0]] = times[t_idx]; // Time coordinate (x)
124            waterfall_data[[t_idx, f_idx, 1]] = freqs[f_idx]; // Frequency coordinate (y)
125            waterfall_data[[t_idx, f_idx, 2]] = amplitude; // Amplitude (z)
126        }
127    }
128
129    // Convert to array1d for return
130    let times_array = Array1::from_vec(times);
131    let freqs_array = Array1::from_vec(freqs);
132
133    Ok((times_array, freqs_array, waterfall_data))
134}
135
136/// Generate data for a mesh grid waterfall plot from a time-domain signal.
137///
138/// This function computes a spectrogram and organizes the data as separate
139/// time, frequency, and amplitude arrays suitable for mesh or surface plotting.
140///
141/// # Arguments
142///
143/// * `x` - Input signal array
144/// * `fs` - Sampling frequency of the signal (default: 1.0)
145/// * `nperseg` - Length of each segment (default: 256)
146/// * `noverlap` - Number of points to overlap between segments (default: `nperseg // 2`)
147/// * `log_scale` - Whether to use logarithmic (dB) scale for amplitudes (default: true)
148/// * `db_range` - Dynamic range in dB for normalization when using log scale (default: 60.0)
149///
150/// # Returns
151///
152/// * A tuple containing:
153///   - Time mesh grid (T) - 2D array of time values
154///   - Frequency mesh grid (F) - 2D array of frequency values
155///   - Amplitude data (Z) - 2D array of amplitude values
156///
157/// # Errors
158///
159/// Returns an error if the computation fails or if parameters are invalid.
160///
161/// # Examples
162///
163/// ```
164/// use scirs2_fft::waterfall_mesh;
165/// use std::f64::consts::PI;
166///
167/// // Generate a chirp signal
168/// let fs = 1000.0; // 1 kHz sampling rate
169/// let t = (0..1000).map(|i| i as f64 / fs).collect::<Vec<_>>();
170/// let chirp = t.iter().map(|&ti| (2.0 * PI * (10.0 + 50.0 * ti) * ti).sin()).collect::<Vec<_>>();
171///
172/// // Compute waterfall mesh data
173/// let (time_mesh, freq_mesh, amplitudes) = waterfall_mesh(
174///     &chirp,
175///     Some(fs),
176///     Some(128),
177///     Some(64),
178///     Some(true),
179///     Some(80.0),
180/// ).unwrap();
181///
182/// // Verify dimensions are consistent
183/// assert_eq!(time_mesh.shape(), freq_mesh.shape());
184/// assert_eq!(time_mesh.shape(), amplitudes.shape());
185/// ```
186#[allow(dead_code)]
187pub fn waterfall_mesh<T>(
188    x: &[T],
189    fs: Option<f64>,
190    nperseg: Option<usize>,
191    noverlap: Option<usize>,
192    log_scale: Option<bool>,
193    db_range: Option<f64>,
194) -> FFTResult<(Array2<f64>, Array2<f64>, Array2<f64>)>
195where
196    T: NumCast + Copy + std::fmt::Debug,
197{
198    // Set default parameters
199    let fs = fs.unwrap_or(1.0);
200    let nperseg = nperseg.unwrap_or(256);
201    let noverlap = noverlap.unwrap_or(nperseg / 2);
202    let log_scale = log_scale.unwrap_or(true);
203    let db_range = db_range.unwrap_or(60.0);
204
205    // Compute spectrogram
206    let (freqs, times, spec_data) = if log_scale {
207        // Use normalized spectrogram (log scale)
208        spectrogram_normalized(x, Some(fs), Some(nperseg), Some(noverlap), Some(db_range))?
209    } else {
210        // Use linear amplitude spectrogram
211        spectrogram(
212            x,
213            Some(fs),
214            Some(Window::Hann),
215            Some(nperseg),
216            Some(noverlap),
217            None,
218            None,
219            Some("density"),
220            Some("magnitude"),
221        )?
222    };
223
224    // Create mesh grids for time and frequency
225    // These are 2D arrays where each row/column has the same time/frequency value
226    let n_times = times.len();
227    let n_freqs = freqs.len();
228
229    let mut time_mesh = Array2::zeros((n_freqs, n_times));
230    let mut freq_mesh = Array2::zeros((n_freqs, n_times));
231
232    // Fill the mesh grids
233    for t_idx in 0..n_times {
234        for f_idx in 0..n_freqs {
235            time_mesh[[f_idx, t_idx]] = times[t_idx];
236            freq_mesh[[f_idx, t_idx]] = freqs[f_idx];
237        }
238    }
239
240    // Return the time mesh, frequency mesh, and amplitude data
241    // Note: we transpose the amplitude data to match the mesh orientation
242    Ok((time_mesh, freq_mesh, spec_data))
243}
244
245/// Generate data for a stacked line waterfall plot from a time-domain signal.
246///
247/// This function computes a spectrogram and organizes the data as a series of
248/// line plots (spectra) at different time points, with optional offset between
249/// lines to create a 3D stacking effect.
250///
251/// # Arguments
252///
253/// * `x` - Input signal array
254/// * `fs` - Sampling frequency of the signal (default: 1.0)
255/// * `nperseg` - Length of each segment (default: 256)
256/// * `noverlap` - Number of points to overlap between segments (default: `nperseg // 2`)
257/// * `n_lines` - Number of spectral lines to include (default: 20, evenly spaced in time)
258/// * `offset` - Vertical offset between consecutive lines (default: 0.1)
259/// * `log_scale` - Whether to use logarithmic (dB) scale for amplitudes (default: true)
260/// * `db_range` - Dynamic range in dB for normalization when using log scale (default: 60.0)
261///
262/// # Returns
263///
264/// * A tuple containing:
265///   - Times array (t) - Times for each spectral line
266///   - Frequencies array (f) - Frequency bins
267///   - Stacked spectra data (Z) - 3D array where each [i, :, :] slice contains
268///     the [frequency, amplitude] coordinates for the i-th time point
269///
270/// # Errors
271///
272/// Returns an error if the computation fails or if parameters are invalid.
273///
274/// # Examples
275///
276/// ```
277/// use scirs2_fft::waterfall_lines;
278/// use std::f64::consts::PI;
279///
280/// // Generate a chirp signal
281/// let fs = 1000.0; // 1 kHz sampling rate
282/// let t = (0..1000).map(|i| i as f64 / fs).collect::<Vec<_>>();
283/// let chirp = t.iter().map(|&ti| (2.0 * PI * (10.0 + 50.0 * ti) * ti).sin()).collect::<Vec<_>>();
284///
285/// // Compute stacked line data
286/// let (times, freqs, lines) = waterfall_lines(
287///     &chirp,
288///     Some(fs),
289///     Some(128),
290///     Some(64),
291///     Some(10),    // 10 lines
292///     Some(0.2),   // Offset between lines
293///     Some(true),
294///     Some(80.0),
295/// ).unwrap();
296///
297/// // Each line contains frequency and amplitude data
298/// assert_eq!(lines.shape()[0], times.len());      // Number of time points
299/// assert_eq!(lines.shape()[1], freqs.len());      // Number of frequency points
300/// assert_eq!(lines.shape()[2], 2);                // [frequency, amplitude] pairs
301/// ```
302#[allow(clippy::too_many_arguments)]
303#[allow(dead_code)]
304pub fn waterfall_lines<T>(
305    x: &[T],
306    fs: Option<f64>,
307    nperseg: Option<usize>,
308    noverlap: Option<usize>,
309    n_lines: Option<usize>,
310    offset: Option<f64>,
311    log_scale: Option<bool>,
312    db_range: Option<f64>,
313) -> FFTResult<(Array1<f64>, Array1<f64>, Array3<f64>)>
314where
315    T: NumCast + Copy + std::fmt::Debug,
316{
317    // Set default parameters
318    let fs = fs.unwrap_or(1.0);
319    let nperseg = nperseg.unwrap_or(256);
320    let noverlap = noverlap.unwrap_or(nperseg / 2);
321    let log_scale = log_scale.unwrap_or(true);
322    let db_range = db_range.unwrap_or(60.0);
323
324    // Compute spectrogram
325    let (freqs, times, spec_data) = if log_scale {
326        // Use normalized spectrogram (log scale)
327        spectrogram_normalized(x, Some(fs), Some(nperseg), Some(noverlap), Some(db_range))?
328    } else {
329        // Use linear amplitude spectrogram
330        spectrogram(
331            x,
332            Some(fs),
333            Some(Window::Hann),
334            Some(nperseg),
335            Some(noverlap),
336            None,
337            None,
338            Some("density"),
339            Some("magnitude"),
340        )?
341    };
342
343    // Determine the time points to use
344    let n_times = times.len();
345    let n_lines = n_lines.unwrap_or(20).min(n_times);
346    let offset = offset.unwrap_or(0.1);
347
348    // Select evenly spaced time indices
349    let time_indices: Vec<usize> = if n_lines >= n_times {
350        // Use all time points if n_lines >= number of available times
351        (0..n_times).collect()
352    } else {
353        // Select evenly spaced indices
354        (0..n_lines)
355            .map(|i| i * (n_times - 1) / (n_lines - 1))
356            .collect()
357    };
358
359    // Extract the times for these indices
360    let selected_times: Vec<f64> = time_indices.iter().map(|&idx| times[idx]).collect();
361
362    // Create 3D array for line data
363    let n_freqs = freqs.len();
364    let mut line_data = Array3::zeros((n_lines, n_freqs, 2));
365
366    // Fill the line data with [frequency, amplitude] pairs
367    // Add increasing offsets to amplitude for 3D effect
368    for (line_idx, &time_idx) in time_indices.iter().enumerate() {
369        let line_offset = line_idx as f64 * offset;
370
371        for f_idx in 0..n_freqs {
372            // Original amplitude from spectrogram
373            let amplitude = spec_data[[f_idx, time_idx]];
374
375            // Store frequency and offset amplitude
376            line_data[[line_idx, f_idx, 0]] = freqs[f_idx]; // Frequency
377            line_data[[line_idx, f_idx, 1]] = amplitude + line_offset; // Amplitude with offset
378        }
379    }
380
381    // Convert vectors to Array1 for return
382    let times_array = Array1::from_vec(selected_times);
383    let freqs_array = Array1::from_vec(freqs);
384
385    Ok((times_array, freqs_array, line_data))
386}
387
388/// Generate coordinates for a color mesh waterfall plot from a time-domain signal.
389///
390/// This function computes a spectrogram and organizes the data as arrays of
391/// vertices, faces, and colors suitable for triangulated surface plots.
392///
393/// # Arguments
394///
395/// * `x` - Input signal array
396/// * `fs` - Sampling frequency of the signal (default: 1.0)
397/// * `nperseg` - Length of each segment (default: 256)
398/// * `noverlap` - Number of points to overlap between segments (default: `nperseg // 2`)
399/// * `log_scale` - Whether to use logarithmic (dB) scale for amplitudes (default: true)
400/// * `db_range` - Dynamic range in dB for normalization when using log scale (default: 60.0)
401///
402/// # Returns
403///
404/// * A tuple containing:
405///   - Vertices array - Nx3 array of \[x,y,z\] coordinates for each vertex
406///   - Faces array - Mx3 array of vertex indices defining triangular faces
407///   - Colors array - Nx3 array of \[r,g,b\] colors for each vertex (normalized to \[0,1\])
408///
409/// # Errors
410///
411/// Returns an error if the computation fails or if parameters are invalid.
412///
413/// # Examples
414///
415/// ```
416/// use scirs2_fft::waterfall_mesh_colored;
417/// use std::f64::consts::PI;
418///
419/// // Generate a chirp signal
420/// let fs = 1000.0; // 1 kHz sampling rate
421/// let t = (0..1000).map(|i| i as f64 / fs).collect::<Vec<_>>();
422/// let chirp = t.iter().map(|&ti| (2.0 * PI * (10.0 + 50.0 * ti) * ti).sin()).collect::<Vec<_>>();
423///
424/// // Compute colored mesh data
425/// let (vertices, faces, colors) = waterfall_mesh_colored(
426///     &chirp,
427///     Some(fs),
428///     Some(128),
429///     Some(64),
430///     Some(true),
431///     Some(80.0),
432/// ).unwrap();
433///
434/// // Verify dimensions
435/// assert_eq!(vertices.shape()[1], 3);  // [x,y,z] coordinates
436/// assert_eq!(faces.shape()[1], 3);     // Triangle vertex indices
437/// assert_eq!(colors.shape()[1], 3);    // [r,g,b] values
438/// assert_eq!(vertices.shape()[0], colors.shape()[0]);  // Same number of vertices and colors
439/// ```
440#[allow(dead_code)]
441pub fn waterfall_mesh_colored<T>(
442    x: &[T],
443    fs: Option<f64>,
444    nperseg: Option<usize>,
445    noverlap: Option<usize>,
446    log_scale: Option<bool>,
447    db_range: Option<f64>,
448) -> FFTResult<(Array2<f64>, Array2<usize>, Array2<f64>)>
449where
450    T: NumCast + Copy + std::fmt::Debug,
451{
452    // Set default parameters
453    let fs = fs.unwrap_or(1.0);
454    let nperseg = nperseg.unwrap_or(256);
455    let noverlap = noverlap.unwrap_or(nperseg / 2);
456    let log_scale = log_scale.unwrap_or(true);
457    let db_range = db_range.unwrap_or(60.0);
458
459    // Compute spectrogram
460    let (freqs, times, spec_data) = if log_scale {
461        // Use normalized spectrogram (log scale)
462        spectrogram_normalized(x, Some(fs), Some(nperseg), Some(noverlap), Some(db_range))?
463    } else {
464        // Use linear amplitude spectrogram
465        spectrogram(
466            x,
467            Some(fs),
468            Some(Window::Hann),
469            Some(nperseg),
470            Some(noverlap),
471            None,
472            None,
473            Some("density"),
474            Some("magnitude"),
475        )?
476    };
477
478    let n_times = times.len();
479    let n_freqs = freqs.len();
480
481    // Create vertices (time, frequency, amplitude coordinates)
482    let n_vertices = n_times * n_freqs;
483    let mut vertices = Array2::zeros((n_vertices, 3));
484    let mut colors = Array2::zeros((n_vertices, 3));
485
486    // Fill vertices and calculate colors
487    let mut vertex_idx = 0;
488    for (t_idx, &t) in times.iter().enumerate() {
489        for (f_idx, &f) in freqs.iter().enumerate() {
490            let amplitude = spec_data[[f_idx, t_idx]];
491
492            // Set vertex coordinates
493            vertices[[vertex_idx, 0]] = t; // Time (x coordinate)
494            vertices[[vertex_idx, 1]] = f; // Frequency (y coordinate)
495            vertices[[vertex_idx, 2]] = amplitude; // Amplitude (z coordinate)
496
497            // Set vertex color using a simple blue -> red color map based on amplitude
498            colors[[vertex_idx, 0]] = amplitude; // Red component
499            colors[[vertex_idx, 1]] = amplitude * 0.5; // Green component
500            colors[[vertex_idx, 2]] = 1.0 - amplitude; // Blue component
501
502            vertex_idx += 1;
503        }
504    }
505
506    // Create triangular faces
507    // Each rectangular cell in the grid becomes two triangular faces
508    let n_cells_t = n_times - 1;
509    let n_cells_f = n_freqs - 1;
510    let n_faces = 2 * n_cells_t * n_cells_f;
511    let mut faces = Array2::zeros((n_faces, 3));
512
513    let mut face_idx = 0;
514    for t in 0..n_cells_t {
515        for f in 0..n_cells_f {
516            // Calculate the vertex indices for this cell
517            let v00 = t * n_freqs + f; // Top-left
518            let v01 = t * n_freqs + (f + 1); // Top-right
519            let v10 = (t + 1) * n_freqs + f; // Bottom-left
520            let v11 = (t + 1) * n_freqs + (f + 1); // Bottom-right
521
522            // First triangle (top-left, bottom-left, bottom-right)
523            faces[[face_idx, 0]] = v00;
524            faces[[face_idx, 1]] = v10;
525            faces[[face_idx, 2]] = v11;
526            face_idx += 1;
527
528            // Second triangle (top-left, bottom-right, top-right)
529            faces[[face_idx, 0]] = v00;
530            faces[[face_idx, 1]] = v11;
531            faces[[face_idx, 2]] = v01;
532            face_idx += 1;
533        }
534    }
535
536    Ok((vertices, faces, colors))
537}
538
539/// Apply a colormap to amplitude values for visualization.
540///
541/// This function maps amplitude values to RGB colors using various
542/// colormap schemes commonly used in scientific visualization.
543///
544/// # Arguments
545///
546/// * `amplitudes` - Array of amplitude values in range [0, 1]
547/// * `colormap` - Name of the colormap to use (default: "jet")
548///
549/// # Returns
550///
551/// * RGB array where each row corresponds to an input amplitude
552///
553/// # Errors
554///
555/// Returns an error if the colormap name is not recognized.
556///
557/// # Examples
558///
559/// ```
560/// use scirs2_fft::waterfall::apply_colormap;
561/// use scirs2_core::ndarray::Array1;
562///
563/// // Create some test amplitudes
564/// let amplitudes = Array1::from_vec(vec![0.0, 0.25, 0.5, 0.75, 1.0]);
565///
566/// // Apply jet colormap
567/// let colors = apply_colormap(&amplitudes, "jet").unwrap();
568///
569/// // Check output dimensions
570/// assert_eq!(colors.shape(), &[5, 3]);  // 5 colors, each with RGB components
571///
572/// // Check that values are in valid range [0, 1]
573/// for color in colors.outer_iter() {
574///     for &component in color {
575///         assert!(0.0 <= component && component <= 1.0);
576///     }
577/// }
578/// ```
579#[allow(dead_code)]
580pub fn apply_colormap(amplitudes: &Array1<f64>, colormap: &str) -> FFTResult<Array2<f64>> {
581    // Verify that _amplitudes are in range [0, 1]
582    for &amp in amplitudes.iter() {
583        if !(0.0..=1.0).contains(&amp) {
584            return Err(FFTError::ValueError(
585                "Amplitude values must be in range [0, 1]".to_string(),
586            ));
587        }
588    }
589
590    // Create output array
591    let n_values = amplitudes.len();
592    let mut colors = Array2::zeros((n_values, 3));
593
594    match colormap {
595        "jet" => {
596            // Jet colormap: blue -> cyan -> yellow -> red
597            for (i, &amp) in amplitudes.iter().enumerate() {
598                // Red component
599                colors[[i, 0]] = if amp < 0.35 {
600                    0.0
601                } else if amp < 0.66 {
602                    (amp - 0.35) / 0.31
603                } else {
604                    1.0
605                };
606
607                // Green component
608                colors[[i, 1]] = if amp < 0.125 {
609                    0.0
610                } else if amp < 0.375 {
611                    (amp - 0.125) / 0.25
612                } else if amp < 0.875 {
613                    1.0
614                } else {
615                    (1.0 - amp) / 0.125
616                };
617
618                // Blue component
619                colors[[i, 2]] = if amp < 0.125 {
620                    (0.5 + amp * 4.0).min(1.0)
621                } else if amp < 0.375 {
622                    1.0
623                } else if amp < 0.625 {
624                    (0.625 - amp) / 0.25
625                } else {
626                    0.0
627                };
628            }
629        }
630        "viridis" => {
631            // Viridis colormap: dark blue -> green -> yellow
632            for (i, &amp) in amplitudes.iter().enumerate() {
633                // Simplified approximation of viridis
634                colors[[i, 0]] = amp.powf(1.5) * 0.9; // Red increases nonlinearly
635                colors[[i, 1]] = amp.powf(0.8) * 0.9; // Green increases faster
636                colors[[i, 2]] = if amp < 0.5 {
637                    0.4 + (0.5 - amp) * 0.6 // Blue decreases from start
638                } else {
639                    0.4 * (1.0 - amp) // Blue continues decreasing
640                };
641            }
642        }
643        "plasma" => {
644            // Plasma colormap: dark purple -> red -> yellow
645            for (i, &amp) in amplitudes.iter().enumerate() {
646                colors[[i, 0]] = 0.05 + amp.powf(0.7) * 0.95; // Red increases quickly
647                colors[[i, 1]] = amp.powf(2.0) * 0.9; // Green increases slowly then faster
648                colors[[i, 2]] = if amp < 0.7 {
649                    0.5 - amp * 0.5 // Blue decreases linearly at first
650                } else {
651                    0.15 * (1.0 - amp) / 0.3 // Blue continues decreasing
652                };
653            }
654        }
655        "grayscale" => {
656            // Grayscale: black -> white
657            for (i, &amp) in amplitudes.iter().enumerate() {
658                colors[[i, 0]] = amp; // Red
659                colors[[i, 1]] = amp; // Green
660                colors[[i, 2]] = amp; // Blue
661            }
662        }
663        "hot" => {
664            // Hot colormap: black -> red -> yellow -> white
665            for (i, &amp) in amplitudes.iter().enumerate() {
666                colors[[i, 0]] = (amp * 3.0).min(1.0); // Red rises fastest
667                colors[[i, 1]] = ((amp - 0.33) * 3.0).clamp(0.0, 1.0); // Green rises next
668                colors[[i, 2]] = ((amp - 0.66) * 3.0).clamp(0.0, 1.0); // Blue rises last
669            }
670        }
671        _ => {
672            return Err(FFTError::ValueError(format!(
673                "Unknown colormap: {colormap}. Use 'jet', 'viridis', 'plasma', 'grayscale', or 'hot'."
674            )));
675        }
676    }
677
678    Ok(colors)
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    // Generate a test signal (chirp: frequency increases with time)
686    fn generate_chirp(fs: f64, duration: f64) -> Vec<f64> {
687        let n_samples = (fs * duration) as usize;
688        let t: Vec<f64> = (0..n_samples).map(|i| i as f64 / fs).collect();
689
690        t.iter()
691            .map(|&ti| (2.0 * PI * (10.0 + 50.0 * ti) * ti).sin())
692            .collect()
693    }
694
695    #[test]
696    fn test_waterfall_3d_dimensions() {
697        // Generate a test signal
698        let fs = 1000.0;
699        let signal = generate_chirp(fs, 1.0);
700
701        // Compute waterfall plot data
702        let (times, freqs, data) = waterfall_3d(
703            &signal,
704            Some(fs),
705            Some(128),
706            Some(64),
707            Some(true),
708            Some(60.0),
709        )
710        .unwrap();
711
712        // Check dimensions
713        assert_eq!(data.shape()[0], times.len()); // Time dimension
714        assert_eq!(data.shape()[1], freqs.len()); // Frequency dimension
715        assert_eq!(data.shape()[2], 3); // X, Y, Z coordinates
716
717        // Check ranges
718        for t in times.iter() {
719            assert!(0.0 <= *t && *t <= 1.0); // Time should be in [0, 1] range
720        }
721
722        for f in freqs.iter() {
723            assert!(0.0 <= *f && *f <= fs / 2.0); // Frequency should be in [0, fs/2] range
724        }
725
726        // Check amplitudes are normalized
727        for i in 0..data.shape()[0] {
728            for j in 0..data.shape()[1] {
729                assert!(0.0 <= data[[i, j, 2]] && data[[i, j, 2]] <= 1.0);
730            }
731        }
732    }
733
734    #[test]
735    fn test_waterfall_mesh_dimensions() {
736        // Generate a test signal
737        let fs = 1000.0;
738        let signal = generate_chirp(fs, 1.0);
739
740        // Compute waterfall mesh data
741        let (time_mesh, freq_mesh, amplitudes) = waterfall_mesh(
742            &signal,
743            Some(fs),
744            Some(128),
745            Some(64),
746            Some(true),
747            Some(60.0),
748        )
749        .unwrap();
750
751        // Check that dimensions are consistent
752        assert_eq!(time_mesh.shape(), freq_mesh.shape());
753        assert_eq!(time_mesh.shape(), amplitudes.shape());
754
755        // Check that values are in expected ranges
756        assert!(0.0 <= time_mesh.iter().fold(f64::MAX, |a, &b| a.min(b)));
757        assert!(time_mesh.iter().fold(f64::MIN, |a, &b| a.max(b)) <= 1.0);
758
759        assert!(0.0 <= freq_mesh.iter().fold(f64::MAX, |a, &b| a.min(b)));
760        assert!(freq_mesh.iter().fold(f64::MIN, |a, &b| a.max(b)) <= fs / 2.0);
761
762        // Amplitudes should be normalized
763        assert!(0.0 <= amplitudes.iter().fold(f64::MAX, |a, &b| a.min(b)));
764        assert!(amplitudes.iter().fold(f64::MIN, |a, &b| a.max(b)) <= 1.0);
765    }
766
767    #[test]
768    fn test_waterfall_lines() {
769        // Generate a test signal
770        let fs = 1000.0;
771        let signal = generate_chirp(fs, 1.0);
772
773        // Number of lines to use
774        let n_lines = 10;
775
776        // Compute stacked lines data
777        let (times, freqs, lines) = waterfall_lines(
778            &signal,
779            Some(fs),
780            Some(128),
781            Some(64),
782            Some(n_lines),
783            Some(0.2),
784            Some(true),
785            Some(60.0),
786        )
787        .unwrap();
788
789        // Check dimensions
790        assert_eq!(times.len(), n_lines);
791        assert_eq!(lines.shape()[0], n_lines);
792        assert_eq!(lines.shape()[1], freqs.len());
793        assert_eq!(lines.shape()[2], 2);
794
795        // Check that times are monotonically increasing
796        for i in 1..times.len() {
797            assert!(times[i] > times[i - 1]);
798        }
799
800        // Check that frequencies match
801        for i in 0..n_lines {
802            for j in 0..freqs.len() {
803                assert_eq!(lines[[i, j, 0]], freqs[j]);
804            }
805        }
806
807        // Check that successive lines have the correct offset
808        let offset = 0.2;
809        for i in 1..n_lines {
810            let prev_line_max = (0..freqs.len())
811                .map(|j| lines[[i - 1, j, 1]])
812                .fold(f64::MIN, f64::max);
813
814            let curr_line_min = (0..freqs.len())
815                .map(|j| lines[[i, j, 1]])
816                .fold(f64::MAX, f64::min);
817
818            // The minimum of the current line should be at least the offset higher
819            // than the base level (not necessarily the max) of the previous line
820            assert!(curr_line_min > prev_line_max - 1.0 + offset);
821        }
822    }
823
824    #[test]
825    fn test_waterfall_mesh_colored() {
826        // Generate a test signal
827        let fs = 1000.0;
828        let signal = generate_chirp(fs, 1.0);
829
830        // Compute colored mesh data
831        let (vertices, faces, colors) = waterfall_mesh_colored(
832            &signal,
833            Some(fs),
834            Some(128),
835            Some(64),
836            Some(true),
837            Some(60.0),
838        )
839        .unwrap();
840
841        // Check dimensions
842        assert_eq!(vertices.shape()[1], 3); // [x,y,z] coordinates
843        assert_eq!(faces.shape()[1], 3); // Triangle vertex indices
844        assert_eq!(colors.shape()[1], 3); // [r,g,b] values
845        assert_eq!(vertices.shape()[0], colors.shape()[0]); // Same number of vertices and colors
846
847        // Check that vertex indices in faces are within range
848        let max_vertex_idx = vertices.shape()[0] - 1;
849        for face in faces.outer_iter() {
850            for &idx in face {
851                assert!(idx <= max_vertex_idx);
852            }
853        }
854
855        // Check valid color range
856        for color in colors.outer_iter() {
857            for &component in color {
858                assert!((0.0..=1.0).contains(&component));
859            }
860        }
861    }
862
863    #[test]
864    fn test_apply_colormap() {
865        // Create test amplitudes
866        let amplitudes = Array1::from_vec(vec![0.0, 0.25, 0.5, 0.75, 1.0]);
867
868        // Test each colormap
869        let colormaps = ["jet", "viridis", "plasma", "grayscale", "hot"];
870
871        for &cmap in &colormaps {
872            let colors = apply_colormap(&amplitudes, cmap).unwrap();
873
874            // Check dimensions
875            assert_eq!(colors.shape(), &[5, 3]);
876
877            // Check valid range for colors (with small epsilon for floating-point precision)
878            let epsilon = 1e-10;
879            for row in colors.outer_iter() {
880                for &component in row {
881                    assert!(
882                        -epsilon <= component && component <= 1.0 + epsilon,
883                        "Color component out of range: {component}"
884                    );
885                }
886            }
887
888            // For grayscale colormap, all RGB components should be equal
889            if cmap == "grayscale" {
890                for i in 0..amplitudes.len() {
891                    assert_eq!(colors[[i, 0]], colors[[i, 1]]);
892                    assert_eq!(colors[[i, 1]], colors[[i, 2]]);
893                    assert_eq!(colors[[i, 0]], amplitudes[i]);
894                }
895            }
896        }
897
898        // Test invalid colormap name
899        let result = apply_colormap(&amplitudes, "invalid");
900        assert!(result.is_err());
901    }
902}