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(&litudes, "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 & in amplitudes.iter() {
583 if !(0.0..=1.0).contains(&) {
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, &) 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, &) 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, &) 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, &) 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, &) 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(&litudes, 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(&litudes, "invalid");
900 assert!(result.is_err());
901 }
902}