Skip to main content

ad_plugins_rs/
fft.rs

1use std::sync::Arc;
2
3use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
4use ad_core_rs::ndarray_pool::NDArrayPool;
5use ad_core_rs::plugin::runtime::{NDPluginProcess, ProcessResult};
6use parking_lot::Mutex;
7use rustfft::num_complex::Complex;
8use rustfft::{Fft, FftPlanner};
9
10/// FFT direction (forward or inverse transform).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FFTDirection {
13    Forward,
14    Inverse,
15}
16
17/// Configuration for FFT processing.
18///
19/// The transform rank (1-D vs 2-D) is NOT configured here: like C
20/// `NDPluginFFT::processCallbacks` (NDPluginFFT.cpp:298-315) it is taken from
21/// the input array's `ndims` on every frame, so a 1-D input drives a 1-D FFT
22/// and a 2-D input a full 2-D FFT.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct FFTConfig {
25    pub direction: FFTDirection,
26    /// Zero out DC component (k=0) in the output magnitudes.
27    pub suppress_dc: bool,
28    /// Average N frames of magnitude. 0 or 1 means no averaging.
29    pub num_average: usize,
30}
31
32impl Default for FFTConfig {
33    fn default() -> Self {
34        Self {
35            direction: FFTDirection::Forward,
36            suppress_dc: false,
37            num_average: 0,
38        }
39    }
40}
41
42/// Smallest power of two greater than or equal to `n` (C++ `nextPow2`).
43///
44/// `next_pow2(0)` and `next_pow2(1)` return 1.
45pub fn next_pow2(n: usize) -> usize {
46    if n <= 1 {
47        return 1;
48    }
49    let mut p = 1usize;
50    while p < n {
51        p <<= 1;
52    }
53    p
54}
55
56/// Compute 1D FFT magnitude for each row of a 2D array using rustfft.
57/// Returns a Float64 array with half the *padded* width (positive frequencies
58/// only). Like C++ NDPluginFFT, each row is zero-padded to the next power of
59/// two before the transform, and `nFreqX = paddedWidth / 2`.
60/// Magnitudes are normalized by the padded length.
61pub fn fft_1d_rows(src: &NDArray, suppress_dc: bool) -> Option<NDArray> {
62    if src.dims.is_empty() {
63        return None;
64    }
65
66    let width = src.dims[0].size;
67    let height = if src.dims.len() >= 2 {
68        src.dims[1].size
69    } else {
70        1
71    };
72
73    if width == 0 {
74        return None;
75    }
76
77    // C++ rounds the time dimension up to the next power of two and zero-pads.
78    let padded = next_pow2(width);
79
80    let mut planner = FftPlanner::<f64>::new();
81    let fft = planner.plan_fft_forward(padded);
82
83    // C++: nFreqX = paddedWidth / 2 (only positive frequencies)
84    let n_freq = padded / 2;
85    if n_freq == 0 {
86        return None;
87    }
88    let scale = 1.0 / padded as f64;
89
90    let mut magnitudes = vec![0.0f64; n_freq * height];
91    let mut row_buf = vec![Complex::new(0.0, 0.0); padded];
92
93    for row in 0..height {
94        // Fill complex buffer: real = pixel value, imag = 0; tail zero-padded.
95        for c in row_buf.iter_mut() {
96            *c = Complex::new(0.0, 0.0);
97        }
98        for i in 0..width {
99            row_buf[i] = Complex::new(src.data.get_as_f64(row * width + i).unwrap_or(0.0), 0.0);
100        }
101
102        fft.process(&mut row_buf);
103
104        // Compute magnitudes (normalized by padded N, only first half)
105        for i in 0..n_freq {
106            magnitudes[row * n_freq + i] = row_buf[i].norm() * scale;
107        }
108
109        if suppress_dc {
110            magnitudes[row * n_freq] = 0.0;
111        }
112    }
113
114    let dims = if height > 1 {
115        vec![NDDimension::new(n_freq), NDDimension::new(height)]
116    } else {
117        vec![NDDimension::new(n_freq)]
118    };
119    let mut arr = NDArray::new(dims, NDDataType::Float64);
120    arr.data = NDDataBuffer::F64(magnitudes);
121    arr.unique_id = src.unique_id;
122    arr.timestamp = src.timestamp;
123    arr.attributes = src.attributes.clone();
124    Some(arr)
125}
126
127/// Compute 2D FFT magnitude using separable row-then-column FFT via rustfft.
128pub fn fft_2d(src: &NDArray, suppress_dc: bool) -> Option<NDArray> {
129    if src.dims.len() < 2 {
130        return None;
131    }
132
133    let src_w = src.dims[0].size;
134    let src_h = src.dims[1].size;
135
136    if src_w == 0 || src_h == 0 {
137        return None;
138    }
139
140    // C++ zero-pads each dimension to the next power of two.
141    let w = next_pow2(src_w);
142    let h = next_pow2(src_h);
143
144    let mut planner = FftPlanner::<f64>::new();
145    let fft_row = planner.plan_fft_forward(w);
146    let fft_col = planner.plan_fft_forward(h);
147
148    // Step 1: Row FFTs — build a padded w×h complex buffer (zero-padded).
149    let mut data = vec![Complex::new(0.0, 0.0); w * h];
150    let mut row_buf = vec![Complex::new(0.0, 0.0); w];
151
152    for row in 0..src_h {
153        for c in row_buf.iter_mut() {
154            *c = Complex::new(0.0, 0.0);
155        }
156        for i in 0..src_w {
157            row_buf[i] = Complex::new(src.data.get_as_f64(row * src_w + i).unwrap_or(0.0), 0.0);
158        }
159        fft_row.process(&mut row_buf);
160        data[row * w..(row * w + w)].copy_from_slice(&row_buf);
161    }
162
163    // Step 2: Column FFTs
164    let mut col_buf = vec![Complex::new(0.0, 0.0); h];
165
166    for col in 0..w {
167        // Extract column
168        for row in 0..h {
169            col_buf[row] = data[row * w + col];
170        }
171        fft_col.process(&mut col_buf);
172        // Write back
173        for row in 0..h {
174            data[row * w + col] = col_buf[row];
175        }
176    }
177
178    // Step 3: Compute magnitudes (half spectrum, normalized by padded N*M)
179    let n_freq_x = w / 2;
180    let n_freq_y = h / 2;
181    if n_freq_x == 0 || n_freq_y == 0 {
182        return None;
183    }
184    let scale = 1.0 / (w * h) as f64;
185
186    let mut magnitudes = vec![0.0f64; n_freq_x * n_freq_y];
187    for fy in 0..n_freq_y {
188        for fx in 0..n_freq_x {
189            magnitudes[fy * n_freq_x + fx] = data[fy * w + fx].norm() * scale;
190        }
191    }
192
193    if suppress_dc {
194        magnitudes[0] = 0.0;
195    }
196
197    let dims = vec![NDDimension::new(n_freq_x), NDDimension::new(n_freq_y)];
198    let mut arr = NDArray::new(dims, NDDataType::Float64);
199    arr.data = NDDataBuffer::F64(magnitudes);
200    arr.unique_id = src.unique_id;
201    arr.timestamp = src.timestamp;
202    arr.attributes = src.attributes.clone();
203    Some(arr)
204}
205
206/// FFT processing engine with cached planner and optional magnitude averaging.
207#[derive(Default)]
208struct FFTParamIndices {
209    direction: Option<usize>,
210    suppress_dc: Option<usize>,
211    num_average: Option<usize>,
212    num_averaged: Option<usize>,
213    reset_average: Option<usize>,
214    time_per_point: Option<usize>,
215    /// `FFTTimeSeries` waveform — the input time series (nTimeX points).
216    time_series: Option<usize>,
217    /// `FFTReal` waveform — real part of the spectrum (nFreqX points).
218    real: Option<usize>,
219    /// `FFTImaginary` waveform — imaginary part of the spectrum.
220    imaginary: Option<usize>,
221    /// `FFTAbsValue` waveform — magnitude of the spectrum.
222    abs_value: Option<usize>,
223    /// `FFTTimeAxis` waveform — `i * timePerPoint`.
224    time_axis: Option<usize>,
225    /// `FFTFreqAxis` waveform — frequency-axis values.
226    freq_axis: Option<usize>,
227}
228
229/// Everything a frame mutates: the tuning config and the running-average
230/// accumulator. One transform advances both, so they live under one lock
231/// rather than one lock each.
232///
233/// The planner is deliberately NOT here. It is a plan cache, not frame
234/// state, and keeping it in this mutex would force the transform itself to
235/// run under the lock that a param write contends for -- the shape C avoids
236/// by releasing at NDPluginFFT.cpp:334.
237struct FFTState {
238    config: FFTConfig,
239    /// Running average magnitude buffer.
240    avg_buffer: Option<Vec<f64>>,
241    /// Number of frames accumulated so far.
242    avg_count: usize,
243    /// Cached dimensions to detect changes.
244    cached_dims: Vec<usize>,
245    /// Seconds per input time point (C++ `timePerPoint_`); scales the time
246    /// and frequency axis waveforms.
247    time_per_point: f64,
248}
249
250pub struct FFTProcessor {
251    state: Mutex<FFTState>,
252    /// Plan cache, shared across frames and across pool workers. Locked only
253    /// for the plan lookup itself (one or two per frame), never across a
254    /// transform.
255    planner: Mutex<FftPlanner<f64>>,
256    params: FFTParamIndices,
257}
258
259impl FFTProcessor {
260    pub fn new() -> Self {
261        Self::with_config(FFTConfig::default())
262    }
263
264    pub fn with_config(config: FFTConfig) -> Self {
265        Self {
266            state: Mutex::new(FFTState {
267                config,
268                avg_buffer: None,
269                avg_count: 0,
270                cached_dims: Vec::new(),
271                time_per_point: 1.0,
272            }),
273            planner: Mutex::new(FftPlanner::new()),
274            params: FFTParamIndices::default(),
275        }
276    }
277}
278
279impl FFTState {
280    /// Check if dimensions changed and reset averaging state if so.
281    fn check_dims_changed(&mut self, dims: &[NDDimension]) {
282        let current: Vec<usize> = dims.iter().map(|d| d.size).collect();
283        if current != self.cached_dims {
284            self.cached_dims = current;
285            self.avg_buffer = None;
286            self.avg_count = 0;
287        }
288    }
289
290    /// Apply magnitude averaging using exponential moving average (matching C++).
291    ///
292    /// C++: `FFTAbsValue_[j] = FFTAbsValue_[j] * oldFraction + new[j] * newFraction`
293    /// where `oldFraction = 1 - 1/numAveraged`, `newFraction = 1/numAveraged`.
294    fn apply_averaging(&mut self, magnitudes: &[f64]) -> Vec<f64> {
295        let num_avg = self.config.num_average;
296        if num_avg <= 1 {
297            return magnitudes.to_vec();
298        }
299
300        let buf = self
301            .avg_buffer
302            .get_or_insert_with(|| vec![0.0; magnitudes.len()]);
303
304        // Reset if buffer size changed
305        if buf.len() != magnitudes.len() {
306            *buf = vec![0.0; magnitudes.len()];
307            self.avg_count = 0;
308        }
309
310        self.avg_count += 1;
311        // Cap at num_average for the weighting
312        let n = self.avg_count.min(num_avg) as f64;
313        let new_fraction = 1.0 / n;
314        let old_fraction = 1.0 - new_fraction;
315
316        // C++ exponential moving average
317        for (b, &m) in buf.iter_mut().zip(magnitudes.iter()) {
318            *b = *b * old_fraction + m * new_fraction;
319        }
320
321        buf.clone()
322    }
323}
324
325/// The per-frame view of the configuration: everything the transform reads
326/// and nothing it writes.
327///
328/// C takes exactly this snapshot under the port lock -- `suppressDC` at
329/// NDPluginFFT.cpp:325 and `timePerPoint_` at :330 -- and then releases at
330/// :334 ("things below don't access shared memory") before converting the
331/// input and running `computeFFT_1D`/`computeFFT_2D`. Splitting the config
332/// off `FFTState` is what lets us do the same: the transform borrows this
333/// value, so it cannot be holding the state lock while it runs.
334struct FFTFrame<'a> {
335    config: FFTConfig,
336    time_per_point: f64,
337    planner: &'a Mutex<FftPlanner<f64>>,
338}
339
340impl FFTFrame<'_> {
341    /// Look a forward plan up in the shared cache. The lock spans the lookup
342    /// only -- never `Fft::process`.
343    fn plan_forward(&self, len: usize) -> Arc<dyn Fft<f64>> {
344        self.planner.lock().plan_fft_forward(len)
345    }
346
347    /// Inverse counterpart of [`FFTFrame::plan_forward`].
348    fn plan_inverse(&self, len: usize) -> Arc<dyn Fft<f64>> {
349        self.planner.lock().plan_fft_inverse(len)
350    }
351
352    /// Compute FFT using cached planner for plan reuse across frames.
353    ///
354    /// The rank is taken from the input array's dimension count, matching C
355    /// `NDPluginFFT::processCallbacks` (NDPluginFFT.cpp:298-315): `ndims==1`
356    /// drives a 1-D FFT, `ndims==2` a full 2-D FFT, and any other rank is
357    /// rejected (C prints an error and returns with no output).
358    fn compute_fft(&self, src: &NDArray) -> Option<NDArray> {
359        let suppress_dc = self.config.suppress_dc;
360
361        match (src.dims.len(), self.config.direction) {
362            (1, FFTDirection::Forward) => self.compute_fft_1d_rows_forward(src, suppress_dc),
363            (1, FFTDirection::Inverse) => self.compute_fft_1d_rows_inverse(src, suppress_dc),
364            (2, FFTDirection::Forward) => self.compute_fft_2d_forward(src, suppress_dc),
365            (2, FFTDirection::Inverse) => self.compute_fft_2d_inverse(src, suppress_dc),
366            _ => None,
367        }
368    }
369
370    /// Compute the 1D forward FFT of the first row of `src`, returning the
371    /// extracted time series and the half-spectrum complex values.
372    ///
373    /// This drives the C++ `FFTTimeSeries`/`FFTReal`/`FFTImaginary`/
374    /// `FFTAbsValue` waveform records, which in C++ are 1D arrays over the
375    /// first time axis. Returns `(time_series, real, imag)` where `time_series`
376    /// has `padded` elements (nTimeX) — C posts the zero-extended padded series
377    /// (NDPluginFFT.cpp: timeSeries is the nTimeX-long calloc buffer) — and
378    /// `real`/`imag` have `padded/2` elements (nFreqX). The DC bin is zeroed in
379    /// the two spectral arrays when `suppress_dc` is set (C++ behaviour); the
380    /// time series is never DC-suppressed.
381    fn compute_row_spectrum(
382        &self,
383        src: &NDArray,
384        suppress_dc: bool,
385    ) -> Option<(Vec<f64>, Vec<f64>, Vec<f64>)> {
386        if src.dims.is_empty() {
387            return None;
388        }
389        let width = src.dims[0].size;
390        if width == 0 {
391            return None;
392        }
393        let padded = next_pow2(width);
394        let n_freq = padded / 2;
395        if n_freq == 0 {
396            return None;
397        }
398        let fft = self.plan_forward(padded);
399
400        // The first row, zero-extended to the padded length nTimeX. C posts the
401        // padded series (calloc'd to nTimeX, the input copied into [0,width)),
402        // so FFTTimeSeries and FFTTimeAxis are nTimeX long, not width long.
403        let mut time_series = vec![0.0f64; padded];
404        for (i, slot) in time_series.iter_mut().enumerate().take(width) {
405            *slot = src.data.get_as_f64(i).unwrap_or(0.0);
406        }
407
408        let mut row_buf = vec![Complex::new(0.0, 0.0); padded];
409        for (i, &v) in time_series.iter().enumerate() {
410            row_buf[i] = Complex::new(v, 0.0);
411        }
412        fft.process(&mut row_buf);
413
414        let mut real = vec![0.0f64; n_freq];
415        let mut imag = vec![0.0f64; n_freq];
416        for i in 0..n_freq {
417            real[i] = row_buf[i].re;
418            imag[i] = row_buf[i].im;
419        }
420        if suppress_dc {
421            real[0] = 0.0;
422            imag[0] = 0.0;
423        }
424        Some((time_series, real, imag))
425    }
426
427    /// Frequency-axis values for `n_freq` bins (C++ `createAxisArrays`):
428    /// `freqStep = 0.5 / timePerPoint / (nFreqX - 1)`.
429    fn freq_axis(&self, n_freq: usize) -> Vec<f64> {
430        if n_freq <= 1 {
431            return vec![0.0; n_freq];
432        }
433        let tpp = if self.time_per_point > 0.0 {
434            self.time_per_point
435        } else {
436            1.0
437        };
438        let step = 0.5 / tpp / (n_freq - 1) as f64;
439        (0..n_freq).map(|i| i as f64 * step).collect()
440    }
441
442    /// Time-axis values for `n_time` points: `i * timePerPoint`.
443    fn time_axis(&self, n_time: usize) -> Vec<f64> {
444        let tpp = if self.time_per_point > 0.0 {
445            self.time_per_point
446        } else {
447            1.0
448        };
449        (0..n_time).map(|i| i as f64 * tpp).collect()
450    }
451
452    fn compute_fft_1d_rows_forward(&self, src: &NDArray, suppress_dc: bool) -> Option<NDArray> {
453        if src.dims.is_empty() {
454            return None;
455        }
456
457        let width = src.dims[0].size;
458        let height = if src.dims.len() >= 2 {
459            src.dims[1].size
460        } else {
461            1
462        };
463
464        if width == 0 {
465            return None;
466        }
467
468        // C++ zero-pads the time series to the next power of two.
469        let padded = next_pow2(width);
470        let fft = self.plan_forward(padded);
471
472        // C++: nFreqX = paddedWidth / 2 (only positive frequencies)
473        let n_freq = padded / 2;
474        if n_freq == 0 {
475            return None;
476        }
477        let scale = 1.0 / padded as f64;
478
479        let mut magnitudes = vec![0.0f64; n_freq * height];
480        let mut row_buf = vec![Complex::new(0.0, 0.0); padded];
481
482        for row in 0..height {
483            for c in row_buf.iter_mut() {
484                *c = Complex::new(0.0, 0.0);
485            }
486            for i in 0..width {
487                row_buf[i] = Complex::new(src.data.get_as_f64(row * width + i).unwrap_or(0.0), 0.0);
488            }
489            fft.process(&mut row_buf);
490            for i in 0..n_freq {
491                magnitudes[row * n_freq + i] = row_buf[i].norm() * scale;
492            }
493            if suppress_dc {
494                magnitudes[row * n_freq] = 0.0;
495            }
496        }
497
498        let dims = if height > 1 {
499            vec![NDDimension::new(n_freq), NDDimension::new(height)]
500        } else {
501            vec![NDDimension::new(n_freq)]
502        };
503        let mut arr = NDArray::new(dims, NDDataType::Float64);
504        arr.data = NDDataBuffer::F64(magnitudes);
505        arr.unique_id = src.unique_id;
506        arr.timestamp = src.timestamp;
507        arr.attributes = src.attributes.clone();
508        Some(arr)
509    }
510
511    fn compute_fft_1d_rows_inverse(&self, src: &NDArray, suppress_dc: bool) -> Option<NDArray> {
512        if src.dims.is_empty() {
513            return None;
514        }
515
516        let width = src.dims[0].size;
517        let height = if src.dims.len() >= 2 {
518            src.dims[1].size
519        } else {
520            1
521        };
522
523        if width == 0 {
524            return None;
525        }
526
527        let fft = self.plan_inverse(width);
528        let scale = 1.0 / width as f64;
529
530        // An inverse transform of a real-valued spectrum yields signed real
531        // samples: take the real part, not the modulus, so negative samples
532        // survive a forward->inverse round trip.
533        let mut samples = vec![0.0f64; width * height];
534        let mut row_buf = vec![Complex::new(0.0, 0.0); width];
535
536        for row in 0..height {
537            for i in 0..width {
538                row_buf[i] = Complex::new(src.data.get_as_f64(row * width + i).unwrap_or(0.0), 0.0);
539            }
540            if suppress_dc {
541                row_buf[0] = Complex::new(0.0, 0.0);
542            }
543            fft.process(&mut row_buf);
544            for (i, c) in row_buf.iter().enumerate() {
545                samples[row * width + i] = c.re * scale;
546            }
547        }
548
549        let dims = src.dims.clone();
550        let mut arr = NDArray::new(dims, NDDataType::Float64);
551        arr.data = NDDataBuffer::F64(samples);
552        arr.unique_id = src.unique_id;
553        arr.timestamp = src.timestamp;
554        arr.attributes = src.attributes.clone();
555        Some(arr)
556    }
557
558    fn compute_fft_2d_forward(&self, src: &NDArray, suppress_dc: bool) -> Option<NDArray> {
559        if src.dims.len() < 2 {
560            return None;
561        }
562
563        let src_w = src.dims[0].size;
564        let src_h = src.dims[1].size;
565
566        if src_w == 0 || src_h == 0 {
567            return None;
568        }
569
570        // C++ zero-pads each dimension to the next power of two.
571        let w = next_pow2(src_w);
572        let h = next_pow2(src_h);
573
574        let fft_row = self.plan_forward(w);
575        let fft_col = self.plan_forward(h);
576
577        let mut data = vec![Complex::new(0.0, 0.0); w * h];
578        let mut row_buf = vec![Complex::new(0.0, 0.0); w];
579
580        for row in 0..src_h {
581            for c in row_buf.iter_mut() {
582                *c = Complex::new(0.0, 0.0);
583            }
584            for i in 0..src_w {
585                row_buf[i] = Complex::new(src.data.get_as_f64(row * src_w + i).unwrap_or(0.0), 0.0);
586            }
587            fft_row.process(&mut row_buf);
588            data[row * w..(row * w + w)].copy_from_slice(&row_buf);
589        }
590
591        let mut col_buf = vec![Complex::new(0.0, 0.0); h];
592        for col in 0..w {
593            for row in 0..h {
594                col_buf[row] = data[row * w + col];
595            }
596            fft_col.process(&mut col_buf);
597            for row in 0..h {
598                data[row * w + col] = col_buf[row];
599            }
600        }
601
602        // C++: nFreqX = paddedX/2, nFreqY = paddedY/2; normalize by padded N*M
603        let n_freq_x = w / 2;
604        let n_freq_y = h / 2;
605        if n_freq_x == 0 || n_freq_y == 0 {
606            return None;
607        }
608        let scale = 1.0 / (w * h) as f64;
609
610        let mut magnitudes = vec![0.0f64; n_freq_x * n_freq_y];
611        for fy in 0..n_freq_y {
612            for fx in 0..n_freq_x {
613                magnitudes[fy * n_freq_x + fx] = data[fy * w + fx].norm() * scale;
614            }
615        }
616
617        if suppress_dc {
618            magnitudes[0] = 0.0;
619        }
620
621        let dims = vec![NDDimension::new(n_freq_x), NDDimension::new(n_freq_y)];
622        let mut arr = NDArray::new(dims, NDDataType::Float64);
623        arr.data = NDDataBuffer::F64(magnitudes);
624        arr.unique_id = src.unique_id;
625        arr.timestamp = src.timestamp;
626        arr.attributes = src.attributes.clone();
627        Some(arr)
628    }
629
630    fn compute_fft_2d_inverse(&self, src: &NDArray, suppress_dc: bool) -> Option<NDArray> {
631        if src.dims.len() < 2 {
632            return None;
633        }
634
635        let w = src.dims[0].size;
636        let h = src.dims[1].size;
637
638        if w == 0 || h == 0 {
639            return None;
640        }
641
642        let fft_row = self.plan_inverse(w);
643        let fft_col = self.plan_inverse(h);
644        let scale = 1.0 / (w * h) as f64;
645
646        let mut data = vec![Complex::new(0.0, 0.0); w * h];
647        for i in 0..w * h {
648            data[i] = Complex::new(src.data.get_as_f64(i).unwrap_or(0.0), 0.0);
649        }
650
651        if suppress_dc {
652            data[0] = Complex::new(0.0, 0.0);
653        }
654
655        let mut col_buf = vec![Complex::new(0.0, 0.0); h];
656        for col in 0..w {
657            for row in 0..h {
658                col_buf[row] = data[row * w + col];
659            }
660            fft_col.process(&mut col_buf);
661            for row in 0..h {
662                data[row * w + col] = col_buf[row];
663            }
664        }
665
666        let mut row_buf = vec![Complex::new(0.0, 0.0); w];
667        for row in 0..h {
668            row_buf.copy_from_slice(&data[row * w..(row * w + w)]);
669            fft_row.process(&mut row_buf);
670            data[row * w..(row * w + w)].copy_from_slice(&row_buf);
671        }
672
673        // Inverse transform yields signed real samples: keep the real part.
674        let samples: Vec<f64> = data.iter().map(|c| c.re * scale).collect();
675
676        let dims = vec![NDDimension::new(w), NDDimension::new(h)];
677        let mut arr = NDArray::new(dims, NDDataType::Float64);
678        arr.data = NDDataBuffer::F64(samples);
679        arr.unique_id = src.unique_id;
680        arr.timestamp = src.timestamp;
681        arr.attributes = src.attributes.clone();
682        Some(arr)
683    }
684}
685
686impl Default for FFTProcessor {
687    fn default() -> Self {
688        Self::new()
689    }
690}
691
692impl NDPluginProcess for FFTProcessor {
693    fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
694        use ad_core_rs::plugin::runtime::ParamUpdate;
695
696        // C processes only 1-D and 2-D inputs (NDPluginFFT.cpp:298-315); any
697        // other rank prints an error and returns before allocating, computing,
698        // or emitting any waveform. Gate the whole frame on the input rank so a
699        // 3-D+ array yields no NDArray and no first-row waveforms.
700        let rank = array.dims.len();
701        if rank != 1 && rank != 2 {
702            return ProcessResult::sink(Vec::new());
703        }
704
705        // C reads the frame's tuning under the port lock, then releases before
706        // the transform (NDPluginFFT.cpp:334). Take the same snapshot here and
707        // drop the state lock: everything from `compute_fft` to the axis
708        // waveforms below reads only the snapshot, so a param write lands
709        // while the transform runs instead of queueing behind it.
710        let (frame, avg_count) = {
711            let mut state = self.state.lock();
712            state.check_dims_changed(&array.dims);
713            (
714                FFTFrame {
715                    config: state.config,
716                    time_per_point: state.time_per_point,
717                    planner: &self.planner,
718                },
719                state.avg_count,
720            )
721        };
722
723        let result = frame.compute_fft(array);
724        let mut updates = Vec::new();
725        if let Some(idx) = self.params.num_averaged {
726            updates.push(ParamUpdate::int32(idx, avg_count as i32));
727        }
728
729        // Emit the C++ NDPluginFFT waveform records. On a forward transform
730        // these are the time series, the real/imaginary/abs spectrum, and
731        // the time/frequency axes (C++ doFFTCallbacks / createAxisArrays).
732        // The inverse transform has no spectrum to publish.
733        //
734        // `apply_averaging` advances the EMA state, so it must be invoked at
735        // most once per frame. The averaged FFTAbsValue waveform and the
736        // averaged NDArray output therefore share a single averaging pass.
737        let mut averaged_mags: Option<Vec<f64>> = None;
738        if frame.config.direction == FFTDirection::Forward {
739            let suppress_dc = frame.config.suppress_dc;
740            if let Some((time_series, real, imag)) = frame.compute_row_spectrum(array, suppress_dc)
741            {
742                let n_time = time_series.len();
743                let n_freq = real.len();
744                if let Some(idx) = self.params.time_series {
745                    updates.push(ParamUpdate::float64_array(idx, time_series));
746                }
747                if let Some(idx) = self.params.real {
748                    updates.push(ParamUpdate::float64_array(idx, real));
749                }
750                if let Some(idx) = self.params.imaginary {
751                    updates.push(ParamUpdate::float64_array(idx, imag));
752                }
753                if let Some(idx) = self.params.time_axis {
754                    updates.push(ParamUpdate::float64_array(idx, frame.time_axis(n_time)));
755                }
756                if let Some(idx) = self.params.freq_axis {
757                    updates.push(ParamUpdate::float64_array(idx, frame.freq_axis(n_freq)));
758                }
759            }
760        }
761
762        match result {
763            Some(mut out) => {
764                // The EMA accumulator is the one part of the frame that cannot
765                // run released: it is shared mutable state advanced once per
766                // frame. C re-takes the lock for exactly this
767                // (NDPluginFFT.cpp:373) and re-reads NumAverage inside
768                // `doArrayCallbacks` (:189-203) rather than trusting the value
769                // it snapshotted before the transform, so re-read it here too.
770                if let NDDataBuffer::F64(ref mags) = out.data {
771                    let mut state = self.state.lock();
772                    if state.config.num_average > 1 {
773                        let averaged = state.apply_averaging(mags);
774                        drop(state);
775                        averaged_mags = Some(averaged.clone());
776                        out.data = NDDataBuffer::F64(averaged);
777                    }
778                }
779                // FFTAbsValue waveform mirrors the (possibly averaged) NDArray
780                // magnitude buffer — for 1D forward this is the half-spectrum
781                // magnitude that the NDArray output already carries.
782                if frame.config.direction == FFTDirection::Forward {
783                    if let Some(idx) = self.params.abs_value {
784                        let abs = match (&averaged_mags, &out.data) {
785                            (Some(avg), _) => avg.clone(),
786                            (None, NDDataBuffer::F64(mags)) => mags.clone(),
787                            _ => Vec::new(),
788                        };
789                        if !abs.is_empty() {
790                            updates.push(ParamUpdate::float64_array(idx, abs));
791                        }
792                    }
793                }
794                let mut r = ProcessResult::arrays(vec![Arc::new(out)]);
795                r.param_updates = updates;
796                r
797            }
798            None => ProcessResult::sink(updates),
799        }
800    }
801
802    fn plugin_type(&self) -> &str {
803        "NDPluginFFT"
804    }
805
806    fn register_params(
807        &mut self,
808        base: &mut asyn_rs::port::PortDriverBase,
809    ) -> asyn_rs::error::AsynResult<()> {
810        use asyn_rs::param::ParamType;
811        base.create_param("FFT_TIME_PER_POINT", ParamType::Float64)?;
812        base.create_param("FFT_TIME_AXIS", ParamType::Float64Array)?;
813        base.create_param("FFT_FREQ_AXIS", ParamType::Float64Array)?;
814        base.create_param("FFT_DIRECTION", ParamType::Int32)?;
815        base.create_param("FFT_SUPPRESS_DC", ParamType::Int32)?;
816        base.create_param("FFT_NUM_AVERAGE", ParamType::Int32)?;
817        base.create_param("FFT_NUM_AVERAGED", ParamType::Int32)?;
818        base.create_param("FFT_RESET_AVERAGE", ParamType::Int32)?;
819        base.create_param("FFT_TIME_SERIES", ParamType::Float64Array)?;
820        base.create_param("FFT_REAL", ParamType::Float64Array)?;
821        base.create_param("FFT_IMAGINARY", ParamType::Float64Array)?;
822        base.create_param("FFT_ABS_VALUE", ParamType::Float64Array)?;
823
824        self.params.direction = base.find_param("FFT_DIRECTION");
825        self.params.suppress_dc = base.find_param("FFT_SUPPRESS_DC");
826        self.params.num_average = base.find_param("FFT_NUM_AVERAGE");
827        self.params.num_averaged = base.find_param("FFT_NUM_AVERAGED");
828        self.params.reset_average = base.find_param("FFT_RESET_AVERAGE");
829        self.params.time_per_point = base.find_param("FFT_TIME_PER_POINT");
830        self.params.time_series = base.find_param("FFT_TIME_SERIES");
831        self.params.real = base.find_param("FFT_REAL");
832        self.params.imaginary = base.find_param("FFT_IMAGINARY");
833        self.params.abs_value = base.find_param("FFT_ABS_VALUE");
834        self.params.time_axis = base.find_param("FFT_TIME_AXIS");
835        self.params.freq_axis = base.find_param("FFT_FREQ_AXIS");
836        Ok(())
837    }
838
839    fn on_param_change(
840        &self,
841        reason: usize,
842        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
843    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
844        let mut state = self.state.lock();
845        if Some(reason) == self.params.direction {
846            state.config.direction = if params.value.as_i32() == 0 {
847                FFTDirection::Forward
848            } else {
849                FFTDirection::Inverse
850            };
851        } else if Some(reason) == self.params.suppress_dc {
852            state.config.suppress_dc = params.value.as_i32() != 0;
853        } else if Some(reason) == self.params.num_average {
854            state.config.num_average = params.value.as_i32().max(0) as usize;
855        } else if Some(reason) == self.params.reset_average {
856            if params.value.as_i32() != 0 {
857                state.avg_buffer = None;
858                state.avg_count = 0;
859            }
860        } else if Some(reason) == self.params.time_per_point {
861            // Scales the FFTTimeAxis / FFTFreqAxis waveforms.
862            let v = params.value.as_f64();
863            if v > 0.0 {
864                state.time_per_point = v;
865            }
866        }
867        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    #[test]
876    fn test_fft_1d_dc() {
877        // Constant signal: DC component should dominate
878        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
879        if let NDDataBuffer::F64(ref mut v) = arr.data {
880            for i in 0..8 {
881                v[i] = 1.0;
882            }
883        }
884
885        let result = fft_1d_rows(&arr, false).unwrap();
886        // Output is half spectrum: N/2 = 4 bins
887        assert_eq!(result.dims[0].size, 4);
888        if let NDDataBuffer::F64(ref v) = result.data {
889            // DC component normalized by N: 8/8 = 1.0
890            assert!((v[0] - 1.0).abs() < 1e-10);
891            // Other components should be ~0
892            assert!(v[1].abs() < 1e-10);
893        }
894    }
895
896    #[test]
897    fn test_fft_1d_sine() {
898        // Sine wave at frequency 1: peak at k=1 and k=N-1
899        let n = 16;
900        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
901        if let NDDataBuffer::F64(ref mut v) = arr.data {
902            for i in 0..n {
903                v[i] = (2.0 * std::f64::consts::PI * i as f64 / n as f64).sin();
904            }
905        }
906
907        let result = fft_1d_rows(&arr, false).unwrap();
908        // Output is N/2 = 8 bins
909        assert_eq!(result.dims[0].size, 8);
910        if let NDDataBuffer::F64(ref v) = result.data {
911            // DC should be ~0
912            assert!(v[0].abs() < 1e-10);
913            // Peak at k=1, normalized by N: magnitude = N/2 / N = 0.5
914            assert!((v[1] - 0.5).abs() < 1e-10);
915            // k=2 should be small
916            assert!(v[2].abs() < 1e-10);
917        }
918    }
919
920    #[test]
921    fn test_fft_2d_dimensions() {
922        let arr = NDArray::new(
923            vec![NDDimension::new(4), NDDimension::new(4)],
924            NDDataType::UInt8,
925        );
926        let result = fft_2d(&arr, false).unwrap();
927        // Half spectrum: 4/2 x 4/2 = 2x2
928        assert_eq!(result.dims[0].size, 2);
929        assert_eq!(result.dims[1].size, 2);
930        assert_eq!(result.data.data_type(), NDDataType::Float64);
931    }
932
933    #[test]
934    fn test_fft_1d_suppress_dc() {
935        // Constant signal: DC component should be suppressed
936        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
937        if let NDDataBuffer::F64(ref mut v) = arr.data {
938            for i in 0..8 {
939                v[i] = 1.0;
940            }
941        }
942
943        let result = fft_1d_rows(&arr, true).unwrap();
944        if let NDDataBuffer::F64(ref v) = result.data {
945            // DC component should be zeroed out
946            assert!((v[0]).abs() < 1e-15);
947            // Other components should still be ~0 for constant signal
948            assert!(v[1].abs() < 1e-10);
949        } else {
950            panic!("expected F64 data");
951        }
952    }
953
954    #[test]
955    fn test_fft_2d_suppress_dc() {
956        // 4x4 constant array, suppress_dc should zero out [0,0]
957        let mut arr = NDArray::new(
958            vec![NDDimension::new(4), NDDimension::new(4)],
959            NDDataType::Float64,
960        );
961        if let NDDataBuffer::F64(ref mut v) = arr.data {
962            for val in v.iter_mut() {
963                *val = 3.0;
964            }
965        }
966
967        let result = fft_2d(&arr, true).unwrap();
968        if let NDDataBuffer::F64(ref v) = result.data {
969            // DC at [0,0] should be zeroed
970            assert!((v[0]).abs() < 1e-15);
971        } else {
972            panic!("expected F64 data");
973        }
974    }
975
976    #[test]
977    fn test_fft_2d_known_dc() {
978        // 4x4 constant=2.0 => DC = 4*4*2 = 32, normalized by 4*4 = 16 => 2.0
979        let mut arr = NDArray::new(
980            vec![NDDimension::new(4), NDDimension::new(4)],
981            NDDataType::Float64,
982        );
983        if let NDDataBuffer::F64(ref mut v) = arr.data {
984            for val in v.iter_mut() {
985                *val = 2.0;
986            }
987        }
988
989        let result = fft_2d(&arr, false).unwrap();
990        // Half spectrum: 2x2
991        assert_eq!(result.dims[0].size, 2);
992        assert_eq!(result.dims[1].size, 2);
993        if let NDDataBuffer::F64(ref v) = result.data {
994            // DC normalized by N*M: 32 / 16 = 2.0
995            assert!((v[0] - 2.0).abs() < 1e-10, "DC = {}, expected 2", v[0]);
996            // All other bins should be ~0
997            for i in 1..v.len() {
998                assert!(v[i].abs() < 1e-10, "bin {} = {}, expected ~0", i, v[i]);
999            }
1000        } else {
1001            panic!("expected F64 data");
1002        }
1003    }
1004
1005    #[test]
1006    fn test_fft_1d_known_cosine_peaks() {
1007        // Cosine at frequency 3 in N=16: peaks at k=3 and k=N-3=13
1008        let n = 16;
1009        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1010        if let NDDataBuffer::F64(ref mut v) = arr.data {
1011            for i in 0..n {
1012                v[i] = (2.0 * std::f64::consts::PI * 3.0 * i as f64 / n as f64).cos();
1013            }
1014        }
1015
1016        let result = fft_1d_rows(&arr, false).unwrap();
1017        // Half spectrum: 8 bins
1018        assert_eq!(result.dims[0].size, 8);
1019        if let NDDataBuffer::F64(ref v) = result.data {
1020            // DC should be ~0
1021            assert!(v[0].abs() < 1e-10);
1022            // k=3 should have magnitude N/2 / N = 8/16 = 0.5
1023            assert!(
1024                (v[3] - 0.5).abs() < 1e-10,
1025                "k=3 magnitude = {}, expected 0.5",
1026                v[3]
1027            );
1028            // Other bins in first half should be ~0
1029            for k in [1, 2, 4, 5, 6, 7] {
1030                assert!(
1031                    v[k].abs() < 1e-10,
1032                    "k={} magnitude = {}, expected ~0",
1033                    k,
1034                    v[k]
1035                );
1036            }
1037        } else {
1038            panic!("expected F64 data");
1039        }
1040    }
1041
1042    #[test]
1043    fn test_processor_with_config() {
1044        let config = FFTConfig {
1045            direction: FFTDirection::Forward,
1046            suppress_dc: true,
1047            num_average: 0,
1048        };
1049        let proc = FFTProcessor::with_config(config);
1050        let pool = NDArrayPool::new(0);
1051
1052        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
1053        if let NDDataBuffer::F64(ref mut v) = arr.data {
1054            for i in 0..8 {
1055                v[i] = 5.0;
1056            }
1057        }
1058
1059        let result = proc.process_array(&arr, &pool);
1060        assert_eq!(result.output_arrays.len(), 1);
1061        if let NDDataBuffer::F64(ref v) = result.output_arrays[0].data {
1062            // suppress_dc: DC should be 0
1063            assert!(v[0].abs() < 1e-15);
1064        } else {
1065            panic!("expected F64 data");
1066        }
1067    }
1068
1069    #[test]
1070    fn test_processor_averaging() {
1071        let config = FFTConfig {
1072            direction: FFTDirection::Forward,
1073            suppress_dc: false,
1074            num_average: 2,
1075        };
1076        let proc = FFTProcessor::with_config(config);
1077        let pool = NDArrayPool::new(0);
1078
1079        // Frame 1: constant = 2.0 => DC magnitude (normalized) = 2.0
1080        let mut arr1 = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
1081        if let NDDataBuffer::F64(ref mut v) = arr1.data {
1082            for i in 0..8 {
1083                v[i] = 2.0;
1084            }
1085        }
1086
1087        // Frame 2: constant = 4.0 => DC magnitude (normalized) = 4.0
1088        let mut arr2 = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
1089        if let NDDataBuffer::F64(ref mut v) = arr2.data {
1090            for i in 0..8 {
1091                v[i] = 4.0;
1092            }
1093        }
1094
1095        let r1 = proc.process_array(&arr1, &pool);
1096        assert_eq!(r1.output_arrays.len(), 1);
1097        // After 1 frame: exponential avg with N=1, so output = 2.0
1098        if let NDDataBuffer::F64(ref v) = r1.output_arrays[0].data {
1099            assert!((v[0] - 2.0).abs() < 1e-10, "partial avg DC = {}", v[0]);
1100        }
1101
1102        let r2 = proc.process_array(&arr2, &pool);
1103        assert_eq!(r2.output_arrays.len(), 1);
1104        // After 2 frames: exp avg = 2.0*(1-1/2) + 4.0*(1/2) = 1.0 + 2.0 = 3.0
1105        if let NDDataBuffer::F64(ref v) = r2.output_arrays[0].data {
1106            assert!((v[0] - 3.0).abs() < 1e-10, "averaged DC = {}", v[0]);
1107        }
1108    }
1109
1110    #[test]
1111    fn test_processor_averaging_dimension_change_resets() {
1112        let config = FFTConfig {
1113            direction: FFTDirection::Forward,
1114            suppress_dc: false,
1115            num_average: 3,
1116        };
1117        let proc = FFTProcessor::with_config(config);
1118        let pool = NDArrayPool::new(0);
1119
1120        // Frame 1: width=8
1121        let mut arr1 = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
1122        if let NDDataBuffer::F64(ref mut v) = arr1.data {
1123            for i in 0..8 {
1124                v[i] = 1.0;
1125            }
1126        }
1127        let _ = proc.process_array(&arr1, &pool);
1128        assert_eq!(proc.state.lock().avg_count, 1);
1129
1130        // Frame 2: width=4 — dimension change should reset
1131        let mut arr2 = NDArray::new(vec![NDDimension::new(4)], NDDataType::Float64);
1132        if let NDDataBuffer::F64(ref mut v) = arr2.data {
1133            for i in 0..4 {
1134                v[i] = 1.0;
1135            }
1136        }
1137        let _ = proc.process_array(&arr2, &pool);
1138        // After dimension change, avg_count should be 1 (reset + one new frame)
1139        assert_eq!(proc.state.lock().avg_count, 1);
1140    }
1141
1142    #[test]
1143    fn test_fft_1d_multirow() {
1144        // 2 rows, each a different constant
1145        let w = 4;
1146        let h = 2;
1147        let mut arr = NDArray::new(
1148            vec![NDDimension::new(w), NDDimension::new(h)],
1149            NDDataType::Float64,
1150        );
1151        if let NDDataBuffer::F64(ref mut v) = arr.data {
1152            // Row 0: all 1.0
1153            for i in 0..w {
1154                v[i] = 1.0;
1155            }
1156            // Row 1: all 3.0
1157            for i in w..2 * w {
1158                v[i] = 3.0;
1159            }
1160        }
1161
1162        let result = fft_1d_rows(&arr, false).unwrap();
1163        let n_freq = w / 2; // half spectrum
1164        assert_eq!(result.dims[0].size, n_freq);
1165        if let NDDataBuffer::F64(ref v) = result.data {
1166            // Row 0 DC = 4*1/4 = 1.0 (normalized by N=4)
1167            assert!((v[0] - 1.0).abs() < 1e-10);
1168            // Row 1 DC = 4*3/4 = 3.0
1169            assert!((v[n_freq] - 3.0).abs() < 1e-10);
1170        } else {
1171            panic!("expected F64 data");
1172        }
1173    }
1174
1175    #[test]
1176    fn test_inverse_fft_1d() {
1177        // IFFT of a known forward FFT should give back the original magnitudes
1178        // For a real constant signal, forward FFT gives [N, 0, 0, ...0]
1179        // IFFT of [N, 0, ...0] (real input) should give constant = 1.0 for each sample
1180        let n = 8;
1181        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1182        if let NDDataBuffer::F64(ref mut v) = arr.data {
1183            v[0] = 8.0; // DC = N
1184            // rest are 0
1185        }
1186
1187        let config = FFTConfig {
1188            direction: FFTDirection::Inverse,
1189            suppress_dc: false,
1190            num_average: 0,
1191        };
1192        let proc = FFTProcessor::with_config(config);
1193        let pool = NDArrayPool::new(0);
1194
1195        let result = proc.process_array(&arr, &pool);
1196        assert_eq!(result.output_arrays.len(), 1);
1197        if let NDDataBuffer::F64(ref v) = result.output_arrays[0].data {
1198            // Each sample should be magnitude 1.0 (8/8 = 1.0 after normalization)
1199            for i in 0..n {
1200                assert!(
1201                    (v[i] - 1.0).abs() < 1e-10,
1202                    "sample {} = {}, expected 1.0",
1203                    i,
1204                    v[i]
1205                );
1206            }
1207        } else {
1208            panic!("expected F64 data");
1209        }
1210    }
1211
1212    #[test]
1213    fn test_fft_preserves_metadata() {
1214        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::Float64);
1215        arr.unique_id = 42;
1216        if let NDDataBuffer::F64(ref mut v) = arr.data {
1217            v[0] = 1.0;
1218        }
1219
1220        let result = fft_1d_rows(&arr, false).unwrap();
1221        assert_eq!(result.unique_id, 42);
1222        assert_eq!(result.timestamp, arr.timestamp);
1223    }
1224
1225    #[test]
1226    fn test_next_pow2() {
1227        assert_eq!(next_pow2(0), 1);
1228        assert_eq!(next_pow2(1), 1);
1229        assert_eq!(next_pow2(2), 2);
1230        assert_eq!(next_pow2(3), 4);
1231        assert_eq!(next_pow2(5), 8);
1232        assert_eq!(next_pow2(8), 8);
1233        assert_eq!(next_pow2(100), 128);
1234    }
1235
1236    #[test]
1237    fn test_fft_1d_pads_to_power_of_two() {
1238        // Regression: a non-power-of-2 width is zero-padded to the next
1239        // power of two; nFreqX = paddedWidth / 2.
1240        let n = 5; // -> padded to 8 -> n_freq = 4
1241        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1242        if let NDDataBuffer::F64(ref mut v) = arr.data {
1243            for i in 0..n {
1244                v[i] = 1.0;
1245            }
1246        }
1247        let result = fft_1d_rows(&arr, false).unwrap();
1248        assert_eq!(result.dims[0].size, 4); // 8 / 2, not 5 / 2 = 2
1249    }
1250
1251    #[test]
1252    fn test_fft_2d_pads_to_power_of_two() {
1253        // 6x3 -> padded 8x4 -> n_freq 4x2.
1254        let arr = NDArray::new(
1255            vec![NDDimension::new(6), NDDimension::new(3)],
1256            NDDataType::Float64,
1257        );
1258        let result = fft_2d(&arr, false).unwrap();
1259        assert_eq!(result.dims[0].size, 4); // 8 / 2
1260        assert_eq!(result.dims[1].size, 2); // 4 / 2
1261    }
1262
1263    #[test]
1264    fn test_adp9_processor_selects_2d_fft_from_input_rank() {
1265        // C dispatches on ndims (NDPluginFFT.cpp:298-315): a 2-D input drives a
1266        // full 2-D FFT, NOT per-row 1-D FFTs. The processor must produce 2-D
1267        // magnitude dims nFreqX x nFreqY ([2,2] for a 4x4 input), not [2,4].
1268        let proc = FFTProcessor::new();
1269        let pool = NDArrayPool::new(0);
1270
1271        let mut arr = NDArray::new(
1272            vec![NDDimension::new(4), NDDimension::new(4)],
1273            NDDataType::Float64,
1274        );
1275        if let NDDataBuffer::F64(ref mut v) = arr.data {
1276            v.iter_mut().for_each(|x| *x = 2.0);
1277        }
1278        let result = proc.process_array(&arr, &pool);
1279        assert_eq!(result.output_arrays.len(), 1);
1280        let out = &result.output_arrays[0];
1281        assert_eq!(out.dims.len(), 2);
1282        assert_eq!(out.dims[0].size, 2); // nFreqX = 4/2
1283        assert_eq!(out.dims[1].size, 2); // nFreqY = 4/2 (per-row 1-D would be 4)
1284        if let NDDataBuffer::F64(ref v) = out.data {
1285            // 2-D DC: 32 / 16 = 2.0.
1286            assert!((v[0] - 2.0).abs() < 1e-10, "DC = {}", v[0]);
1287        }
1288    }
1289
1290    #[test]
1291    fn test_adp9_processor_keeps_1d_input_1d() {
1292        // A 1-D input still drives a 1-D FFT (ndims==1): dims [nFreqX].
1293        let proc = FFTProcessor::new();
1294        let pool = NDArrayPool::new(0);
1295        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
1296        if let NDDataBuffer::F64(ref mut v) = arr.data {
1297            v.iter_mut().for_each(|x| *x = 1.0);
1298        }
1299        let result = proc.process_array(&arr, &pool);
1300        let out = &result.output_arrays[0];
1301        assert_eq!(out.dims.len(), 1);
1302        assert_eq!(out.dims[0].size, 4); // 8/2
1303    }
1304
1305    #[test]
1306    fn test_adp9_processor_rejects_rank_above_2() {
1307        // ndims>2 is rejected with no NDArray and no waveforms (C error+return
1308        // before allocate/compute/callbacks).
1309        let proc = fft_proc_with_params(FFTConfig::default());
1310        let pool = NDArrayPool::new(0);
1311        let arr = NDArray::new(
1312            vec![
1313                NDDimension::new(3),
1314                NDDimension::new(4),
1315                NDDimension::new(4),
1316            ],
1317            NDDataType::Float64,
1318        );
1319        let result = proc.process_array(&arr, &pool);
1320        assert_eq!(result.output_arrays.len(), 0);
1321        assert!(
1322            result.param_updates.is_empty(),
1323            "rank>2 must emit no waveforms, got {} updates",
1324            result.param_updates.len()
1325        );
1326    }
1327
1328    // ---- FFT waveform emission tests ----
1329
1330    use ad_core_rs::plugin::runtime::ParamUpdate;
1331
1332    /// Register the FFT params on a scratch port and return the processor.
1333    fn fft_proc_with_params(config: FFTConfig) -> FFTProcessor {
1334        let mut proc = FFTProcessor::with_config(config);
1335        let mut base =
1336            asyn_rs::port::PortDriverBase::new("FFT_TEST", 1, asyn_rs::port::PortFlags::default());
1337        proc.register_params(&mut base).unwrap();
1338        proc
1339    }
1340
1341    /// Find a Float64Array update by param reason.
1342    fn find_array_update(updates: &[ParamUpdate], reason: usize) -> Option<&[f64]> {
1343        updates.iter().find_map(|u| match u {
1344            ParamUpdate::Float64Array {
1345                reason: r, value, ..
1346            } if *r == reason => Some(value.as_slice()),
1347            _ => None,
1348        })
1349    }
1350
1351    #[test]
1352    fn test_fft_emits_all_waveforms() {
1353        // A forward FFT must emit FFTTimeSeries, FFTReal, FFTImaginary,
1354        // FFTAbsValue, FFTTimeAxis and FFTFreqAxis waveforms.
1355        let proc = fft_proc_with_params(FFTConfig::default());
1356        let pool = NDArrayPool::new(0);
1357
1358        let n = 16;
1359        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1360        if let NDDataBuffer::F64(ref mut v) = arr.data {
1361            for i in 0..n {
1362                v[i] = (2.0 * std::f64::consts::PI * 3.0 * i as f64 / n as f64).cos();
1363            }
1364        }
1365        let result = proc.process_array(&arr, &pool);
1366        let u = &result.param_updates;
1367
1368        // All six FFT waveforms must be present, addressed by their param
1369        // reasons, and carry non-empty payloads.
1370        for reason in [
1371            proc.params.time_series.unwrap(),
1372            proc.params.real.unwrap(),
1373            proc.params.imaginary.unwrap(),
1374            proc.params.abs_value.unwrap(),
1375            proc.params.time_axis.unwrap(),
1376            proc.params.freq_axis.unwrap(),
1377        ] {
1378            let wf = find_array_update(u, reason)
1379                .unwrap_or_else(|| panic!("missing waveform for reason {reason}"));
1380            assert!(!wf.is_empty(), "waveform {reason} is empty");
1381        }
1382        let array_updates = u
1383            .iter()
1384            .filter(|x| matches!(x, ParamUpdate::Float64Array { .. }))
1385            .count();
1386        assert_eq!(
1387            array_updates, 6,
1388            "expected 6 waveform updates, got {array_updates}"
1389        );
1390    }
1391
1392    #[test]
1393    fn test_fft_real_imaginary_match_spectrum() {
1394        // For a cosine at frequency 3 in N=16, the real part peaks at bin 3
1395        // (cosine -> real, even) and the imaginary part is ~0 everywhere.
1396        let proc = fft_proc_with_params(FFTConfig::default());
1397        let real_reason = proc.params.real.unwrap();
1398        let imag_reason = proc.params.imaginary.unwrap();
1399        let abs_reason = proc.params.abs_value.unwrap();
1400        let ts_reason = proc.params.time_series.unwrap();
1401        let pool = NDArrayPool::new(0);
1402
1403        let n = 16;
1404        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1405        if let NDDataBuffer::F64(ref mut v) = arr.data {
1406            for i in 0..n {
1407                v[i] = (2.0 * std::f64::consts::PI * 3.0 * i as f64 / n as f64).cos();
1408            }
1409        }
1410        let result = proc.process_array(&arr, &pool);
1411        let u = &result.param_updates;
1412
1413        let real = find_array_update(u, real_reason).unwrap();
1414        let imag = find_array_update(u, imag_reason).unwrap();
1415        let abs = find_array_update(u, abs_reason).unwrap();
1416        let ts = find_array_update(u, ts_reason).unwrap();
1417
1418        // n_freq = 16/2 = 8.
1419        assert_eq!(real.len(), 8);
1420        assert_eq!(imag.len(), 8);
1421        // Real part of a cosine: peak at bin 3 (= N/2 = 8), zero elsewhere.
1422        assert!((real[3] - 8.0).abs() < 1e-9, "real[3] = {}", real[3]);
1423        for k in [0usize, 1, 2, 4, 5, 6, 7] {
1424            assert!(real[k].abs() < 1e-9, "real[{k}] = {}", real[k]);
1425            assert!(imag[k].abs() < 1e-9, "imag[{k}] = {}", imag[k]);
1426        }
1427        // imag[3] is also ~0 for a pure cosine.
1428        assert!(imag[3].abs() < 1e-9, "imag[3] = {}", imag[3]);
1429        // FFTAbsValue at bin 3: magnitude 8 normalized by N=16 -> 0.5.
1430        assert!((abs[3] - 0.5).abs() < 1e-9, "abs[3] = {}", abs[3]);
1431        // Time series is the raw input row.
1432        assert_eq!(ts.len(), n);
1433        assert!((ts[0] - 1.0).abs() < 1e-9);
1434    }
1435
1436    #[test]
1437    fn test_fft_axes_scale_with_time_per_point() {
1438        // FFTTimeAxis = i*timePerPoint; FFTFreqAxis step = 0.5/tpp/(nFreq-1).
1439        let proc = fft_proc_with_params(FFTConfig::default());
1440        let time_axis_reason = proc.params.time_axis.unwrap();
1441        let freq_axis_reason = proc.params.freq_axis.unwrap();
1442        let tpp_reason = proc.params.time_per_point.unwrap();
1443        let pool = NDArrayPool::new(0);
1444
1445        // Set timePerPoint = 0.5 s.
1446        use ad_core_rs::plugin::runtime::{ParamChangeValue, PluginParamSnapshot};
1447        proc.on_param_change(
1448            tpp_reason,
1449            &PluginParamSnapshot {
1450                enable_callbacks: true,
1451                reason: tpp_reason,
1452                addr: 0,
1453                value: ParamChangeValue::Float64(0.5),
1454            },
1455        );
1456
1457        let n = 8;
1458        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1459        if let NDDataBuffer::F64(ref mut v) = arr.data {
1460            v[0] = 1.0;
1461        }
1462        let result = proc.process_array(&arr, &pool);
1463        let u = &result.param_updates;
1464
1465        let time_axis = find_array_update(u, time_axis_reason).unwrap();
1466        let freq_axis = find_array_update(u, freq_axis_reason).unwrap();
1467
1468        // Time axis: 8 points stepped by 0.5.
1469        assert_eq!(time_axis.len(), 8);
1470        assert!((time_axis[1] - 0.5).abs() < 1e-12);
1471        assert!((time_axis[7] - 3.5).abs() < 1e-12);
1472        // Freq axis: 4 bins, step = 0.5 / 0.5 / (4-1) = 1/3.
1473        assert_eq!(freq_axis.len(), 4);
1474        let step = 0.5 / 0.5 / 3.0;
1475        assert!((freq_axis[1] - step).abs() < 1e-12);
1476        assert!((freq_axis[3] - 3.0 * step).abs() < 1e-12);
1477    }
1478
1479    #[test]
1480    fn test_adp25_timeseries_and_timeaxis_use_padded_length() {
1481        // C posts FFTTimeSeries and FFTTimeAxis at nTimeX = nextPow2(width),
1482        // zero-extending the series (NDPluginFFT.cpp allocateArrays +
1483        // doArrayCallbacks/createAxisArrays). width=5 -> padded 8.
1484        let proc = fft_proc_with_params(FFTConfig::default());
1485        let ts_reason = proc.params.time_series.unwrap();
1486        let time_axis_reason = proc.params.time_axis.unwrap();
1487        let real_reason = proc.params.real.unwrap();
1488        let freq_axis_reason = proc.params.freq_axis.unwrap();
1489        let pool = NDArrayPool::new(0);
1490
1491        let n = 5;
1492        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1493        if let NDDataBuffer::F64(ref mut v) = arr.data {
1494            for (i, x) in v.iter_mut().enumerate() {
1495                *x = (i + 1) as f64; // 1,2,3,4,5
1496            }
1497        }
1498        let result = proc.process_array(&arr, &pool);
1499        let u = &result.param_updates;
1500
1501        let ts = find_array_update(u, ts_reason).unwrap();
1502        let time_axis = find_array_update(u, time_axis_reason).unwrap();
1503        let real = find_array_update(u, real_reason).unwrap();
1504        let freq_axis = find_array_update(u, freq_axis_reason).unwrap();
1505
1506        // TimeSeries padded to nTimeX=8, zero-extended past the 5 inputs.
1507        assert_eq!(ts.len(), 8);
1508        assert_eq!(&ts[..5], &[1.0, 2.0, 3.0, 4.0, 5.0]);
1509        assert_eq!(&ts[5..], &[0.0, 0.0, 0.0]);
1510        // TimeAxis matches the padded length.
1511        assert_eq!(time_axis.len(), 8);
1512        // Real spectrum and FreqAxis stay at nFreqX = padded/2 = 4.
1513        assert_eq!(real.len(), 4);
1514        assert_eq!(freq_axis.len(), 4);
1515    }
1516
1517    #[test]
1518    fn test_fft_inverse_emits_no_spectrum_waveforms() {
1519        // The inverse transform has no spectrum to publish.
1520        let config = FFTConfig {
1521            direction: FFTDirection::Inverse,
1522            suppress_dc: false,
1523            num_average: 0,
1524        };
1525        let proc = fft_proc_with_params(config);
1526        let pool = NDArrayPool::new(0);
1527        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::Float64);
1528        if let NDDataBuffer::F64(ref mut v) = arr.data {
1529            v[0] = 8.0;
1530        }
1531        let result = proc.process_array(&arr, &pool);
1532        let array_updates = result
1533            .param_updates
1534            .iter()
1535            .filter(|x| matches!(x, ParamUpdate::Float64Array { .. }))
1536            .count();
1537        assert_eq!(
1538            array_updates, 0,
1539            "inverse FFT must not emit spectrum waveforms"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_inverse_fft_preserves_sign() {
1545        // Regression: the inverse transform must yield signed real samples.
1546        // Build a spectrum whose inverse is a signed cosine and verify the
1547        // output contains negative values (the old code took the modulus).
1548        let n = 8;
1549        let mut arr = NDArray::new(vec![NDDimension::new(n)], NDDataType::Float64);
1550        if let NDDataBuffer::F64(ref mut v) = arr.data {
1551            // Spectrum with a single non-DC bin: inverse is a real cosine
1552            // that swings negative.
1553            v[1] = 4.0;
1554            v[n - 1] = 4.0;
1555        }
1556        let config = FFTConfig {
1557            direction: FFTDirection::Inverse,
1558            suppress_dc: false,
1559            num_average: 0,
1560        };
1561        let proc = FFTProcessor::with_config(config);
1562        let pool = NDArrayPool::new(0);
1563        let result = proc.process_array(&arr, &pool);
1564        if let NDDataBuffer::F64(ref v) = result.output_arrays[0].data {
1565            let has_negative = v.iter().any(|&x| x < -1e-6);
1566            assert!(
1567                has_negative,
1568                "inverse FFT must keep negative samples: {v:?}"
1569            );
1570        } else {
1571            panic!("expected F64 data");
1572        }
1573    }
1574}