Skip to main content

ad_plugins_rs/
process.rs

1use std::sync::Arc;
2
3#[cfg(feature = "parallel")]
4use crate::par_util;
5#[cfg(feature = "parallel")]
6use rayon::prelude::*;
7
8use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType};
9use ad_core_rs::ndarray_pool::NDArrayPool;
10use ad_core_rs::plugin::runtime::{NDPluginProcess, ProcessResult};
11
12/// Recursive filter configuration matching C++ NDPluginProcess.
13///
14/// The C++ filter uses a single filter buffer and numFiltered-dependent coefficients:
15///
16/// Reset:
17///   filter[i] = rOffset + rc1*filter[i] + rc2*data[i]
18///
19/// Normal operation (after numFiltered is incremented):
20///   O1 = oScale * (oc1 + oc2/numFiltered)
21///   O2 = oScale * (oc3 + oc4/numFiltered)
22///   F1 = fScale * (fc1 + fc2/numFiltered)
23///   F2 = fScale * (fc3 + fc4/numFiltered)
24///   data[i]   = oOffset + O1*filter[i] + O2*data[i]
25///   filter[i] = fOffset + F1*filter[i] + F2*data[i]
26#[derive(Debug, Clone)]
27pub struct FilterConfig {
28    /// Number of frames to average before auto-reset (if enabled).
29    pub num_filter: usize,
30    /// Automatically reset the filter when num_filtered reaches num_filter.
31    pub auto_reset: bool,
32    /// Output every N frames (0 = every frame).
33    pub filter_callbacks: usize,
34    /// Output coefficients [OC1, OC2, OC3, OC4].
35    pub oc: [f64; 4],
36    /// Filter coefficients [FC1, FC2, FC3, FC4].
37    pub fc: [f64; 4],
38    /// Reset coefficients [RC1, RC2].
39    pub rc: [f64; 2],
40    /// Reset offset (C++ rOffset).
41    pub r_offset: f64,
42    /// Output offset.
43    pub o_offset: f64,
44    /// Output scale.
45    pub o_scale: f64,
46    /// Filter offset.
47    pub f_offset: f64,
48    /// Filter scale.
49    pub f_scale: f64,
50}
51
52impl Default for FilterConfig {
53    fn default() -> Self {
54        Self {
55            num_filter: 1,
56            auto_reset: false,
57            filter_callbacks: 0,
58            oc: [1.0, 0.0, 0.0, 0.0], // simple passthrough
59            fc: [1.0, 0.0, 0.0, 0.0],
60            rc: [1.0, 0.0],
61            r_offset: 0.0,
62            o_offset: 0.0,
63            o_scale: 1.0,
64            f_offset: 0.0,
65            f_scale: 1.0,
66        }
67    }
68}
69
70/// Process plugin operations applied sequentially to an NDArray.
71#[derive(Debug, Clone)]
72pub struct ProcessConfig {
73    pub enable_background: bool,
74    pub enable_flat_field: bool,
75    pub enable_offset_scale: bool,
76    pub offset: f64,
77    pub scale: f64,
78    pub enable_low_clip: bool,
79    pub low_clip_thresh: f64,
80    pub low_clip_value: f64,
81    pub enable_high_clip: bool,
82    pub high_clip_thresh: f64,
83    pub high_clip_value: f64,
84    pub scale_flat_field: f64,
85    pub enable_filter: bool,
86    pub filter: FilterConfig,
87    pub output_type: Option<NDDataType>,
88    /// One-shot flag: compute offset/scale automatically from the next input
89    /// array (C++ `NDPluginProcessAutoOffsetScale`). Cleared after it runs.
90    pub auto_offset_scale_pending: bool,
91    /// Read-only status: whether a valid background is loaded.
92    pub valid_background: bool,
93    /// Read-only status: whether a valid flat field is loaded.
94    pub valid_flat_field: bool,
95}
96
97impl Default for ProcessConfig {
98    fn default() -> Self {
99        Self {
100            enable_background: false,
101            enable_flat_field: false,
102            enable_offset_scale: false,
103            offset: 0.0,
104            scale: 1.0,
105            enable_low_clip: false,
106            low_clip_thresh: 0.0,
107            low_clip_value: 0.0,
108            enable_high_clip: false,
109            high_clip_thresh: 100.0,
110            high_clip_value: 100.0,
111            scale_flat_field: 255.0,
112            enable_filter: false,
113            filter: FilterConfig::default(),
114            output_type: None,
115            auto_offset_scale_pending: false,
116            valid_background: false,
117            valid_flat_field: false,
118        }
119    }
120}
121
122/// C++ `pNDArrayPool->convert(pArray, &pOut, NDFloat64)` reduced to what the
123/// background / flat-field buffers actually need: the elements as f64.
124fn elements_as_f64(array: &NDArray) -> Vec<f64> {
125    (0..array.data.len())
126        .map(|i| array.data.get_as_f64(i).unwrap_or(0.0))
127        .collect()
128}
129
130/// State for the process plugin (holds background, flat field, and filter state).
131///
132/// Matches the C++ NDPluginProcess which uses a single `pFilter` array.
133pub struct ProcessState {
134    pub config: ProcessConfig,
135    pub background: Option<Vec<f64>>,
136    pub flat_field: Option<Vec<f64>>,
137    /// Single filter buffer (equivalent to C++ `pFilter`).
138    ///
139    /// Invariant (NDPluginProcess.cpp:182-187): this buffer is dropped **only**
140    /// when its element count no longer matches the incoming frame. No
141    /// parameter write may free it — a requested reset re-seeds the contents in
142    /// place via the RC coefficients, it does not discard them.
143    pub filter_state: Option<Vec<f64>>,
144    /// Number of frames filtered since last reset.
145    pub num_filtered: usize,
146    /// Pending `ResetFilter` request (C++ local `resetFilter`, read from the
147    /// parameter at NDPluginProcess.cpp:73 and cleared at :91-93). Consumed by
148    /// [`ProcessState::process`], which is the only owner allowed to act on it.
149    reset_filter_pending: bool,
150    /// C++ `this->pArrays[0]`: the plugin's most recent **output** array, cached
151    /// by `NDPluginDriver::endProcessCallbacks` (NDPluginDriver.cpp:262-277) —
152    /// fully processed and already in the output data type, NOT the raw input.
153    ///
154    /// This is what SaveBackground/SaveFlatField copy
155    /// (NDPluginProcess.cpp:292, :301), so it must exist as real state; there is
156    /// no way to answer "save the current array" from an input frame.
157    ///
158    /// Invariant: written only by [`ProcessState::process`], and only on the path
159    /// that actually emits an array — a filter-suppressed frame leaves C's
160    /// `doCallbacks = 0`, so `endProcessCallbacks` never runs and `pArrays[0]`
161    /// keeps the previous output.
162    last_output: Option<NDArray>,
163}
164
165/// C's recursive-filter term: `if (coef) acc += coef * term`
166/// (NDPluginProcess.cpp:206-207 and :221-225 — all six terms of the filter are
167/// written this way).
168///
169/// The guard is not an optimisation, it is semantics: `0.0 * NaN` and
170/// `0.0 * inf` are NaN in IEEE-754, so multiplying an unused term by a zero
171/// coefficient does NOT drop it — it poisons the sum. C's `if` drops it. That
172/// matters most for `filter[]`, which feeds the next frame: one non-finite
173/// sample (a Float64/Float32 input carrying NaN, or an inf produced by a large
174/// coefficient) makes every later output NaN for as long as the filter lives,
175/// even with the filter coefficients set to zero to disable that term.
176///
177/// `coef != 0.0` is exactly C's truth test on a double: false for `+0.0` and
178/// `-0.0`, true for everything else including NaN.
179#[inline]
180fn accumulate(acc: f64, coef: f64, term: f64) -> f64 {
181    if coef != 0.0 { acc + coef * term } else { acc }
182}
183
184impl ProcessState {
185    pub fn new(config: ProcessConfig) -> Self {
186        Self {
187            config,
188            background: None,
189            flat_field: None,
190            filter_state: None,
191            num_filtered: 0,
192            reset_filter_pending: false,
193            last_output: None,
194        }
195    }
196
197    /// The plugin's last output array — C++ `this->pArrays[0]`. `None` until the
198    /// first frame is emitted.
199    pub fn last_output(&self) -> Option<&NDArray> {
200        self.last_output.as_ref()
201    }
202
203    /// C++ `NDPluginProcess::writeInt32(NDPluginProcessSaveBackground)`
204    /// (NDPluginProcess.cpp:287-298), performed **synchronously on the parameter
205    /// write**, not deferred to the next frame:
206    ///
207    /// ```text
208    /// setIntegerParam(SaveBackground, 0);
209    /// if (pBackground) pBackground->release();
210    /// pBackground = NULL;
211    /// setIntegerParam(ValidBackground, 0);
212    /// if (pArrays[0]) {
213    ///     convert(pArrays[0], &pBackground, NDFloat64);
214    ///     nBackgroundElements = arrayInfo.nElements;
215    ///     setIntegerParam(ValidBackground, 1);
216    /// }
217    /// ```
218    ///
219    /// So the old buffer is dropped and ValidBackground cleared even when there
220    /// is no array to save from, and the source is the last OUTPUT array — the
221    /// one this plugin already emitted, in the output data type.
222    pub fn save_background(&mut self) {
223        let saved = self.last_output.as_ref().map(elements_as_f64);
224        self.config.valid_background = saved.is_some();
225        self.background = saved;
226    }
227
228    /// C++ `NDPluginProcess::writeInt32(NDPluginProcessSaveFlatField)`
229    /// (NDPluginProcess.cpp:299-310) — the SaveBackground sequence above, on the
230    /// flat-field buffer.
231    pub fn save_flat_field(&mut self) {
232        let saved = self.last_output.as_ref().map(elements_as_f64);
233        self.config.valid_flat_field = saved.is_some();
234        self.flat_field = saved;
235    }
236
237    /// Auto-calculate offset and scale matching C++ NDPluginProcess.
238    ///
239    /// C++: scale = maxScale / (maxValue - minValue); offset = -minValue;
240    /// Also enables offset/scale processing and clipping (matching C++ lines 238-249).
241    pub fn auto_offset_scale(&mut self, array: &NDArray) {
242        let n = array.data.len();
243        if n == 0 {
244            return;
245        }
246        let mut min_val = f64::MAX;
247        let mut max_val = f64::MIN;
248        for i in 0..n {
249            let v = array.data.get_as_f64(i).unwrap_or(0.0);
250            if v < min_val {
251                min_val = v;
252            }
253            if v > max_val {
254                max_val = v;
255            }
256        }
257        let range = max_val - min_val;
258        if range > 0.0 {
259            // C++: maxScale = pow(2, bytesPerElement*8) - 1
260            let bytes_per_elem = match self.config.output_type.unwrap_or(array.data.data_type()) {
261                NDDataType::Int8 | NDDataType::UInt8 => 1,
262                NDDataType::Int16 | NDDataType::UInt16 => 2,
263                NDDataType::Int32 | NDDataType::UInt32 => 4,
264                NDDataType::Int64 | NDDataType::UInt64 => 8,
265                NDDataType::Float32 => 4,
266                NDDataType::Float64 => 8,
267            };
268            let max_scale = 2.0f64.powi(bytes_per_elem * 8) - 1.0;
269            // C++: scale = maxScale/(maxValue-minValue); offset = -minValue;
270            self.config.scale = max_scale / range;
271            self.config.offset = -min_val;
272            // C++ also enables offset/scale and clipping
273            self.config.enable_offset_scale = true;
274            self.config.enable_low_clip = true;
275            self.config.low_clip_thresh = 0.0;
276            self.config.enable_high_clip = true;
277            self.config.high_clip_thresh = max_scale;
278        }
279    }
280
281    /// Apply a named filter type preset, setting the FC/OC/RC coefficients.
282    ///
283    /// Uses the C++ coefficient scheme where:
284    ///   O1 = oScale * (oc[0] + oc[1]/N), O2 = oScale * (oc[2] + oc[3]/N)
285    ///   F1 = fScale * (fc[0] + fc[1]/N), F2 = fScale * (fc[2] + fc[3]/N)
286    ///   data[i]   = oOffset + O1*filter[i] + O2*data[i]
287    ///   filter[i] = fOffset + F1*filter[i] + F2*data[i]
288    pub fn apply_filter_type(&mut self, filter_type: i32) {
289        let fc = &mut self.config.filter;
290        match filter_type {
291            0 => {
292                // RecursiveAve: running average
293                // F1=fScale*(0 + 1/N)=1/N (old filter weight decreases)
294                // F2=fScale*(1 + -1/N)=(N-1)/N (new data weight increases)
295                // Actually: F[n]=(1-1/N)*F[n-1] + (1/N)*data[n]
296                //   fc1=0, fc2=1 → F1=fScale*(0+1/N)=1/N ← weight on filter
297                // Wait, the formula is: F2=fScale*(fc3+fc4/N)
298                // For recursive avg: filter = ((N-1)*filter + data)/N
299                //   F1 applied to filter: want (N-1)/N → fc1=1, fc2=-1
300                //     F1 = fScale*(1 + (-1)/N) = (N-1)/N ✓
301                //   F2 applied to data: want 1/N → fc3=0, fc4=1
302                //     F2 = fScale*(0 + 1/N) = 1/N ✓
303                // O1 applied to filter: want 1 → oc1=1, oc2=0
304                // O2 applied to data: want 0 → oc3=0, oc4=0
305                fc.fc = [1.0, -1.0, 0.0, 1.0];
306                fc.oc = [1.0, 0.0, 0.0, 0.0];
307                fc.rc = [0.0, 1.0]; // reset: filter = data
308                fc.r_offset = 0.0;
309                fc.f_offset = 0.0;
310                fc.f_scale = 1.0;
311                fc.o_offset = 0.0;
312                fc.o_scale = 1.0;
313            }
314            1 => {
315                // Average: accumulate sum in filter, output = filter/N
316                // filter = filter + data → F1=1*filter, F2=1*data
317                //   fc1=1,fc2=0 → F1=fScale*(1+0/N)=1; fc3=1,fc4=0 → F2=fScale*(1+0/N)=1
318                // output = filter/N → O1=1/N*filter
319                //   oc1=0,oc2=1 → O1=oScale*(0+1/N)=1/N; oc3=0,oc4=0 → O2=0
320                fc.fc = [1.0, 0.0, 1.0, 0.0];
321                fc.oc = [0.0, 1.0, 0.0, 0.0];
322                fc.rc = [0.0, 1.0]; // reset: filter = data
323                fc.r_offset = 0.0;
324                fc.f_offset = 0.0;
325                fc.f_scale = 1.0;
326                fc.o_offset = 0.0;
327                fc.o_scale = 1.0;
328            }
329            2 => {
330                // Sum: filter = filter + data, output = filter
331                fc.fc = [1.0, 0.0, 1.0, 0.0];
332                fc.oc = [1.0, 0.0, 0.0, 0.0];
333                fc.rc = [0.0, 1.0];
334                fc.r_offset = 0.0;
335                fc.f_offset = 0.0;
336                fc.f_scale = 1.0;
337                fc.o_offset = 0.0;
338                fc.o_scale = 1.0;
339            }
340            3 => {
341                // Difference: output = data - filter, filter = data
342                // O1=-1*filter, O2=1*data → oc1=-1,oc2=0,oc3=1,oc4=0
343                // F1=0, F2=1*data → fc1=0,fc2=0,fc3=1,fc4=0
344                fc.fc = [0.0, 0.0, 1.0, 0.0];
345                fc.oc = [-1.0, 0.0, 1.0, 0.0];
346                fc.rc = [0.0, 1.0];
347                fc.r_offset = 0.0;
348                fc.f_offset = 0.0;
349                fc.f_scale = 1.0;
350                fc.o_offset = 0.0;
351                fc.o_scale = 1.0;
352            }
353            4 => {
354                // RecursiveAveDiff: output = data - running_avg
355                // Same filter as RecursiveAve but output = data - filter
356                fc.fc = [1.0, -1.0, 0.0, 1.0];
357                fc.oc = [-1.0, 0.0, 1.0, 0.0];
358                fc.rc = [0.0, 1.0];
359                fc.r_offset = 0.0;
360                fc.f_offset = 0.0;
361                fc.f_scale = 1.0;
362                fc.o_offset = 0.0;
363                fc.o_scale = 1.0;
364            }
365            5 => {
366                // CopyToFilter: filter = data, output = filter
367                fc.fc = [0.0, 0.0, 1.0, 0.0];
368                fc.oc = [1.0, 0.0, 0.0, 0.0];
369                fc.rc = [0.0, 1.0];
370                fc.r_offset = 0.0;
371                fc.f_offset = 0.0;
372                fc.f_scale = 1.0;
373                fc.o_offset = 0.0;
374                fc.o_scale = 1.0;
375            }
376            _ => {} // Unknown type — leave coefficients unchanged
377        }
378    }
379
380    /// Request a filter reset on the next processed frame.
381    ///
382    /// This is the `ResetFilter` parameter write. C only clears the PV
383    /// (NDPluginProcess.cpp:91-93) and lets `processCallbacks` act on the local
384    /// flag; `pFilter` keeps its contents, so the reset formula at :204-209
385    /// (`newFilter = rOffset + rc1*filter[i] + rc2*data[i]`) evaluates against
386    /// the **previous** filter buffer. Freeing the buffer here would make
387    /// `filter[i] == data[i]` on the next frame and change the reinitialized
388    /// value whenever `RC1 != 0`.
389    pub fn reset_filter(&mut self) {
390        self.reset_filter_pending = true;
391    }
392
393    /// Process an array through the configured pipeline.
394    /// Process one input array.
395    ///
396    /// Returns `Some(output)` for a normal frame, or `None` when the frame is
397    /// suppressed by the recursive-filter `filter_callbacks` setting (C++ sets
398    /// `doCallbacks = 0` and the frame is dropped — nothing goes downstream).
399    pub fn process(&mut self, src: &NDArray) -> Option<NDArray> {
400        let n = src.data.len();
401        let mut values = vec![0.0f64; n];
402        for i in 0..n {
403            values[i] = src.data.get_as_f64(i).unwrap_or(0.0);
404        }
405
406        // C reads the ResetFilter parameter once per frame and clears the PV
407        // immediately (NDPluginProcess.cpp:73, :91-93) — before the EnableFilter
408        // block, so a reset requested while filtering is disabled is consumed
409        // and lost. Take the flag here for the same reason.
410        let reset_requested = self.reset_filter_pending;
411        self.reset_filter_pending = false;
412
413        // Auto offset/scale (one-shot): C MEASURES this frame's min/max and
414        // ARMS scale/offset + clipping for the NEXT frame — the trigger frame
415        // itself is emitted with the pre-existing config, NOT the derived scale
416        // (NDPluginProcess.cpp:164-178 only updates min/max; 238-250 arms the
417        // params after the output array is built). Consume the one-shot here and
418        // defer the arming until after this frame's output is produced.
419        let auto_offset_scale_now = self.config.auto_offset_scale_pending;
420        self.config.auto_offset_scale_pending = false;
421
422        // Recompute valid background / flat field each frame from the element
423        // count (C NDPluginProcess.cpp:120-125): a saved buffer is usable only
424        // when its length matches the current frame. A size mismatch
425        // invalidates it — the buffer is dropped entirely, never applied to a
426        // matching prefix.
427        self.config.valid_background = self.background.as_ref().is_some_and(|b| b.len() == n);
428        self.config.valid_flat_field = self.flat_field.as_ref().is_some_and(|f| f.len() == n);
429
430        // Stages 1-4: element-wise operations (background, flat field, offset+scale, clipping)
431        // These can be combined into a single pass and parallelized.
432        let needs_element_ops = self.config.enable_background
433            || self.config.enable_flat_field
434            || self.config.enable_offset_scale
435            || self.config.enable_low_clip
436            || self.config.enable_high_clip;
437
438        if needs_element_ops {
439            // C only takes the background/flat-field pointer when the buffer is
440            // BOTH enabled AND valid for this frame (NDPluginProcess.cpp:127-130).
441            let bg = if self.config.enable_background && self.config.valid_background {
442                self.background.as_ref()
443            } else {
444                None
445            };
446            let (ff, ff_scale) = if self.config.enable_flat_field && self.config.valid_flat_field {
447                if let Some(ref ff) = self.flat_field {
448                    // C++: value *= scaleFlatField / flatField[i]
449                    // (NDPluginProcess.cpp:172). scaleFlatField is used directly
450                    // — there is no mean substitution when it is <= 0.
451                    (Some(ff.as_slice()), self.config.scale_flat_field)
452                } else {
453                    (None, 0.0)
454                }
455            } else {
456                (None, 0.0)
457            };
458            let do_offset_scale = self.config.enable_offset_scale;
459            let scale = self.config.scale;
460            let offset = self.config.offset;
461            let do_low_clip = self.config.enable_low_clip;
462            let low_clip_thresh = self.config.low_clip_thresh;
463            let low_clip_value = self.config.low_clip_value;
464            let do_high_clip = self.config.enable_high_clip;
465            let high_clip_thresh = self.config.high_clip_thresh;
466            let high_clip_value = self.config.high_clip_value;
467
468            let apply_stages = |i: usize, v: &mut f64| {
469                // Stage 1: Background subtraction. bg.len() == n is guaranteed by
470                // the validity gate above, so index directly (C subtracts
471                // background[i] unconditionally for every element).
472                if let Some(bg) = bg {
473                    *v -= bg[i];
474                }
475                // Stage 2: Flat field normalization
476                if let Some(ff) = ff {
477                    if ff[i] != 0.0 {
478                        *v = *v * ff_scale / ff[i];
479                    }
480                }
481                // Stage 3: Offset + scale (C++: value = (value + offset) * scale)
482                if do_offset_scale {
483                    *v = (*v + offset) * scale;
484                }
485                // Stage 4: Clipping — C applies high-clip THEN low-clip
486                // (NDPluginProcess.cpp:175-176). When the two thresholds cross
487                // (high < low) the order changes the result, so it must match.
488                if do_high_clip && *v > high_clip_thresh {
489                    *v = high_clip_value;
490                }
491                if do_low_clip && *v < low_clip_thresh {
492                    *v = low_clip_value;
493                }
494            };
495
496            #[cfg(feature = "parallel")]
497            let use_parallel = par_util::should_parallelize(n);
498            #[cfg(not(feature = "parallel"))]
499            let use_parallel = false;
500
501            if use_parallel {
502                #[cfg(feature = "parallel")]
503                par_util::thread_pool().install(|| {
504                    values.par_iter_mut().enumerate().for_each(|(i, v)| {
505                        apply_stages(i, v);
506                    });
507                });
508            } else {
509                for (i, v) in values.iter_mut().enumerate() {
510                    apply_stages(i, v);
511                }
512            }
513        }
514
515        // 5. Recursive filter (matching C++ NDPluginProcess algorithm)
516        if self.config.enable_filter {
517            let fc = &self.config.filter;
518
519            // C++ NDPluginProcess.cpp:181-201. The filter buffer is released
520            // ONLY on an element-count mismatch (:184); a fresh buffer is then
521            // seeded from the current frame and forces a reset (:198).
522            if let Some(ref f) = self.filter_state {
523                if f.len() != n {
524                    self.filter_state = None;
525                }
526            }
527
528            let mut reset_filter = reset_requested;
529            if self.filter_state.is_none() {
530                // No current filter array: seed it from this frame, reset (:189-199).
531                self.filter_state = Some(values.clone());
532                reset_filter = true;
533            }
534            if self.num_filtered >= fc.num_filter && fc.auto_reset {
535                reset_filter = true;
536            }
537
538            let filter = self.filter_state.as_mut().unwrap();
539
540            if reset_filter {
541                // C++ NDPluginProcess.cpp:204-209:
542                //   newFilter = rOffset;
543                //   if (rc1) newFilter += rc1*filter[i];
544                //   if (rc2) newFilter += rc2*data[i];
545                let r_offset = fc.r_offset;
546                let rc1 = fc.rc[0];
547                let rc2 = fc.rc[1];
548                for i in 0..n {
549                    let mut new_filter = accumulate(r_offset, rc1, filter[i]);
550                    new_filter = accumulate(new_filter, rc2, values[i]);
551                    filter[i] = new_filter;
552                }
553                self.num_filtered = 0;
554            }
555
556            // Increment filtered count (C++: if (numFiltered < numFilter) numFiltered++)
557            if self.num_filtered < fc.num_filter {
558                self.num_filtered += 1;
559            }
560
561            // Compute effective coefficients (depend on numFiltered)
562            let nf = self.num_filtered as f64;
563            let o1 = fc.o_scale * (fc.oc[0] + fc.oc[1] / nf);
564            let o2 = fc.o_scale * (fc.oc[2] + fc.oc[3] / nf);
565            let f1 = fc.f_scale * (fc.fc[0] + fc.fc[1] / nf);
566            let f2 = fc.f_scale * (fc.fc[2] + fc.fc[3] / nf);
567            let o_offset = fc.o_offset;
568            let f_offset = fc.f_offset;
569
570            // C++ NDPluginProcess.cpp:219-227 doProcess:
571            //   newData   = oOffset;
572            //   if (O1) newData += O1 * filter[i];
573            //   if (O2) newData += O2 * data[i];
574            //   newFilter = fOffset;
575            //   if (F1) newFilter += F1 * filter[i];
576            //   if (F2) newFilter += F2 * data[i];
577            //   data[i]   = newData;
578            //   filter[i] = newFilter;
579            // Both newData AND newFilter are computed from the ORIGINAL
580            // data[i]; data[i] = newData is assigned only afterward. So the
581            // filter-state update must use the original input, not new_data.
582            for i in 0..n {
583                let mut new_data = accumulate(o_offset, o1, filter[i]);
584                new_data = accumulate(new_data, o2, values[i]);
585                let mut new_filter = accumulate(f_offset, f1, filter[i]);
586                new_filter = accumulate(new_filter, f2, values[i]);
587                values[i] = new_data;
588                filter[i] = new_filter;
589            }
590
591            // Suppress output if filterCallbacks is set and we haven't reached
592            // numFilter. C++ sets doCallbacks = 0 and does NOT call
593            // endProcessCallbacks — the frame is dropped, nothing goes
594            // downstream (the unprocessed input is NOT forwarded).
595            if fc.filter_callbacks > 0 && self.num_filtered != fc.num_filter {
596                return None;
597            }
598        }
599
600        // Build output
601        let out_type = self.config.output_type.unwrap_or(src.data.data_type());
602        let mut out_data = NDDataBuffer::zeros(out_type, n);
603        for i in 0..n {
604            out_data.set_from_f64(i, values[i]);
605        }
606
607        let mut arr = NDArray::new(src.dims.clone(), out_type);
608        arr.data = out_data;
609        arr.unique_id = src.unique_id;
610        arr.timestamp = src.timestamp;
611        arr.attributes = src.attributes.clone();
612
613        // Arm auto offset/scale from THIS frame's data for the NEXT frame
614        // (C NDPluginProcess.cpp:238-250 runs after the output array is built).
615        // Only on the emitted-output path: a suppressed frame produces no output
616        // array, so C (pArrayOut == NULL) does not arm either.
617        if auto_offset_scale_now {
618            self.auto_offset_scale(src);
619        }
620
621        // C `endProcessCallbacks` caches the emitted array in pArrays[0]
622        // (NDPluginDriver.cpp:262-277). It runs only on this path — a
623        // filter-suppressed frame returned above and leaves the previous output
624        // in place. This is the ONLY writer of `last_output`.
625        self.last_output = Some(arr.clone());
626
627        Some(arr)
628    }
629}
630
631// --- ProcessProcessor (NDPluginProcess-based) ---
632
633/// Param indices for the process plugin.
634#[derive(Default)]
635struct ProcParamIndices {
636    data_type: Option<usize>,
637    save_background: Option<usize>,
638    enable_background: Option<usize>,
639    valid_background: Option<usize>,
640    save_flat_field: Option<usize>,
641    enable_flat_field: Option<usize>,
642    valid_flat_field: Option<usize>,
643    scale_flat_field: Option<usize>,
644    enable_offset_scale: Option<usize>,
645    auto_offset_scale: Option<usize>,
646    offset: Option<usize>,
647    scale: Option<usize>,
648    enable_low_clip: Option<usize>,
649    low_clip_thresh: Option<usize>,
650    low_clip_value: Option<usize>,
651    enable_high_clip: Option<usize>,
652    high_clip_thresh: Option<usize>,
653    high_clip_value: Option<usize>,
654    enable_filter: Option<usize>,
655    filter_type: Option<usize>,
656    reset_filter: Option<usize>,
657    auto_reset_filter: Option<usize>,
658    filter_callbacks: Option<usize>,
659    num_filter: Option<usize>,
660    num_filtered: Option<usize>,
661    o_offset: Option<usize>,
662    o_scale: Option<usize>,
663    oc: [Option<usize>; 4],
664    f_offset: Option<usize>,
665    f_scale: Option<usize>,
666    fc: [Option<usize>; 4],
667    r_offset: Option<usize>,
668    rc: [Option<usize>; 2],
669}
670
671/// ProcessProcessor wraps existing ProcessState.
672pub struct ProcessProcessor {
673    state: ProcessState,
674    params: ProcParamIndices,
675}
676
677impl ProcessProcessor {
678    pub fn new(config: ProcessConfig) -> Self {
679        Self {
680            state: ProcessState::new(config),
681            params: ProcParamIndices::default(),
682        }
683    }
684
685    pub fn state(&self) -> &ProcessState {
686        &self.state
687    }
688
689    pub fn state_mut(&mut self) -> &mut ProcessState {
690        &mut self.state
691    }
692}
693
694impl NDPluginProcess for ProcessProcessor {
695    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
696        use ad_core_rs::plugin::runtime::ParamUpdate;
697
698        let out = self.state.process(array);
699        // A suppressed frame (filter_callbacks) produces no output array but
700        // still publishes readback params.
701        let mut result = match out {
702            Some(arr) => ProcessResult::arrays(vec![Arc::new(arr)]),
703            None => ProcessResult::sink(vec![]),
704        };
705
706        // Push readback params
707        if let Some(idx) = self.params.valid_background {
708            result.param_updates.push(ParamUpdate::int32(
709                idx,
710                if self.state.config.valid_background {
711                    1
712                } else {
713                    0
714                },
715            ));
716        }
717        if let Some(idx) = self.params.valid_flat_field {
718            result.param_updates.push(ParamUpdate::int32(
719                idx,
720                if self.state.config.valid_flat_field {
721                    1
722                } else {
723                    0
724                },
725            ));
726        }
727        if let Some(idx) = self.params.num_filtered {
728            result
729                .param_updates
730                .push(ParamUpdate::int32(idx, self.state.num_filtered as i32));
731        }
732        // SaveBackground/SaveFlatField are NOT touched here: C clears those PVs in
733        // writeInt32 (:288, :300), where the save itself happens. processCallbacks
734        // never writes them.
735        //
736        // C clears the ResetFilter PV inside processCallbacks (:91-93), not on
737        // the parameter write.
738        if let Some(idx) = self.params.reset_filter {
739            result.param_updates.push(ParamUpdate::int32(idx, 0));
740        }
741
742        result
743    }
744
745    fn plugin_type(&self) -> &str {
746        "NDPluginProcess"
747    }
748
749    fn register_params(
750        &mut self,
751        base: &mut asyn_rs::port::PortDriverBase,
752    ) -> asyn_rs::error::AsynResult<()> {
753        use asyn_rs::param::ParamType;
754        base.create_param("PROCESS_DATA_TYPE", ParamType::Int32)?;
755        base.create_param("SAVE_BACKGROUND", ParamType::Int32)?;
756        base.create_param("ENABLE_BACKGROUND", ParamType::Int32)?;
757        base.create_param("VALID_BACKGROUND", ParamType::Int32)?;
758        base.create_param("SAVE_FLAT_FIELD", ParamType::Int32)?;
759        base.create_param("ENABLE_FLAT_FIELD", ParamType::Int32)?;
760        base.create_param("VALID_FLAT_FIELD", ParamType::Int32)?;
761        base.create_param("SCALE_FLAT_FIELD", ParamType::Float64)?;
762        base.create_param("ENABLE_OFFSET_SCALE", ParamType::Int32)?;
763        base.create_param("AUTO_OFFSET_SCALE", ParamType::Int32)?;
764        base.create_param("OFFSET", ParamType::Float64)?;
765        base.create_param("SCALE", ParamType::Float64)?;
766        base.create_param("ENABLE_LOW_CLIP", ParamType::Int32)?;
767        base.create_param("LOW_CLIP_THRESH", ParamType::Float64)?;
768        base.create_param("LOW_CLIP_VALUE", ParamType::Float64)?;
769        base.create_param("ENABLE_HIGH_CLIP", ParamType::Int32)?;
770        base.create_param("HIGH_CLIP_THRESH", ParamType::Float64)?;
771        base.create_param("HIGH_CLIP_VALUE", ParamType::Float64)?;
772        base.create_param("ENABLE_FILTER", ParamType::Int32)?;
773        base.create_param("FILTER_TYPE", ParamType::Int32)?;
774        base.create_param("RESET_FILTER", ParamType::Int32)?;
775        base.create_param("AUTO_RESET_FILTER", ParamType::Int32)?;
776        base.create_param("FILTER_CALLBACKS", ParamType::Int32)?;
777        base.create_param("NUM_FILTER", ParamType::Int32)?;
778        base.create_param("NUM_FILTERED", ParamType::Int32)?;
779        base.create_param("FILTER_OOFFSET", ParamType::Float64)?;
780        base.create_param("FILTER_OSCALE", ParamType::Float64)?;
781        base.create_param("FILTER_OC1", ParamType::Float64)?;
782        base.create_param("FILTER_OC2", ParamType::Float64)?;
783        base.create_param("FILTER_OC3", ParamType::Float64)?;
784        base.create_param("FILTER_OC4", ParamType::Float64)?;
785        base.create_param("FILTER_FOFFSET", ParamType::Float64)?;
786        base.create_param("FILTER_FSCALE", ParamType::Float64)?;
787        base.create_param("FILTER_FC1", ParamType::Float64)?;
788        base.create_param("FILTER_FC2", ParamType::Float64)?;
789        base.create_param("FILTER_FC3", ParamType::Float64)?;
790        base.create_param("FILTER_FC4", ParamType::Float64)?;
791        base.create_param("FILTER_ROFFSET", ParamType::Float64)?;
792        base.create_param("FILTER_RC1", ParamType::Float64)?;
793        base.create_param("FILTER_RC2", ParamType::Float64)?;
794
795        // Look up param indices
796        self.params.data_type = base.find_param("PROCESS_DATA_TYPE");
797        self.params.save_background = base.find_param("SAVE_BACKGROUND");
798        self.params.enable_background = base.find_param("ENABLE_BACKGROUND");
799        self.params.valid_background = base.find_param("VALID_BACKGROUND");
800        self.params.save_flat_field = base.find_param("SAVE_FLAT_FIELD");
801        self.params.enable_flat_field = base.find_param("ENABLE_FLAT_FIELD");
802        self.params.valid_flat_field = base.find_param("VALID_FLAT_FIELD");
803        self.params.scale_flat_field = base.find_param("SCALE_FLAT_FIELD");
804        self.params.enable_offset_scale = base.find_param("ENABLE_OFFSET_SCALE");
805        self.params.auto_offset_scale = base.find_param("AUTO_OFFSET_SCALE");
806        self.params.offset = base.find_param("OFFSET");
807        self.params.scale = base.find_param("SCALE");
808        self.params.enable_low_clip = base.find_param("ENABLE_LOW_CLIP");
809        self.params.low_clip_thresh = base.find_param("LOW_CLIP_THRESH");
810        self.params.low_clip_value = base.find_param("LOW_CLIP_VALUE");
811        self.params.enable_high_clip = base.find_param("ENABLE_HIGH_CLIP");
812        self.params.high_clip_thresh = base.find_param("HIGH_CLIP_THRESH");
813        self.params.high_clip_value = base.find_param("HIGH_CLIP_VALUE");
814        self.params.enable_filter = base.find_param("ENABLE_FILTER");
815        self.params.filter_type = base.find_param("FILTER_TYPE");
816        self.params.reset_filter = base.find_param("RESET_FILTER");
817        self.params.auto_reset_filter = base.find_param("AUTO_RESET_FILTER");
818        self.params.filter_callbacks = base.find_param("FILTER_CALLBACKS");
819        self.params.num_filter = base.find_param("NUM_FILTER");
820        self.params.num_filtered = base.find_param("NUM_FILTERED");
821        self.params.o_offset = base.find_param("FILTER_OOFFSET");
822        self.params.o_scale = base.find_param("FILTER_OSCALE");
823        self.params.oc[0] = base.find_param("FILTER_OC1");
824        self.params.oc[1] = base.find_param("FILTER_OC2");
825        self.params.oc[2] = base.find_param("FILTER_OC3");
826        self.params.oc[3] = base.find_param("FILTER_OC4");
827        self.params.f_offset = base.find_param("FILTER_FOFFSET");
828        self.params.f_scale = base.find_param("FILTER_FSCALE");
829        self.params.fc[0] = base.find_param("FILTER_FC1");
830        self.params.fc[1] = base.find_param("FILTER_FC2");
831        self.params.fc[2] = base.find_param("FILTER_FC3");
832        self.params.fc[3] = base.find_param("FILTER_FC4");
833        self.params.r_offset = base.find_param("FILTER_ROFFSET");
834        self.params.rc[0] = base.find_param("FILTER_RC1");
835        self.params.rc[1] = base.find_param("FILTER_RC2");
836        Ok(())
837    }
838
839    fn on_param_change(
840        &mut self,
841        reason: usize,
842        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
843    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
844        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamUpdate};
845
846        let s = &mut self.state;
847        let p = &self.params;
848        let mut updates = Vec::new();
849
850        if Some(reason) == p.data_type {
851            let v = params.value.as_i32();
852            s.config.output_type = if v < 0 {
853                None // Automatic
854            } else {
855                NDDataType::from_ordinal(v as u8)
856            };
857        } else if Some(reason) == p.save_background {
858            // C `writeInt32` (:287-298) acts on ANY write to SaveBackground,
859            // including a 0 — there is no value test — and does the whole save
860            // right here: clear the PV, drop the old buffer, then copy pArrays[0]
861            // (the last OUTPUT array) if one exists and latch ValidBackground.
862            s.save_background();
863            updates.push(ParamUpdate::int32(reason, 0));
864            if let Some(idx) = p.valid_background {
865                updates.push(ParamUpdate::int32(idx, s.config.valid_background as i32));
866            }
867        } else if Some(reason) == p.enable_background {
868            s.config.enable_background = params.value.as_i32() != 0;
869        } else if Some(reason) == p.save_flat_field {
870            // C `writeInt32` (:299-310), same shape as SaveBackground above.
871            s.save_flat_field();
872            updates.push(ParamUpdate::int32(reason, 0));
873            if let Some(idx) = p.valid_flat_field {
874                updates.push(ParamUpdate::int32(idx, s.config.valid_flat_field as i32));
875            }
876        } else if Some(reason) == p.enable_flat_field {
877            s.config.enable_flat_field = params.value.as_i32() != 0;
878        } else if Some(reason) == p.scale_flat_field {
879            s.config.scale_flat_field = params.value.as_f64();
880        } else if Some(reason) == p.enable_offset_scale {
881            s.config.enable_offset_scale = params.value.as_i32() != 0;
882        } else if Some(reason) == p.auto_offset_scale {
883            if params.value.as_i32() != 0 {
884                // Arm the one-shot: auto_offset_scale() runs on the next
885                // process() call (it needs an NDArray to read the data
886                // range). C++ resets NDPluginProcessAutoOffsetScale to 0
887                // after handling, so echo a 0 readback here.
888                s.config.auto_offset_scale_pending = true;
889                if let Some(idx) = p.auto_offset_scale {
890                    updates.push(ParamUpdate::int32(idx, 0));
891                }
892            }
893        } else if Some(reason) == p.offset {
894            s.config.offset = params.value.as_f64();
895        } else if Some(reason) == p.scale {
896            s.config.scale = params.value.as_f64();
897        } else if Some(reason) == p.enable_low_clip {
898            s.config.enable_low_clip = params.value.as_i32() != 0;
899        } else if Some(reason) == p.low_clip_thresh {
900            s.config.low_clip_thresh = params.value.as_f64();
901        } else if Some(reason) == p.low_clip_value {
902            s.config.low_clip_value = params.value.as_f64();
903        } else if Some(reason) == p.enable_high_clip {
904            s.config.enable_high_clip = params.value.as_i32() != 0;
905        } else if Some(reason) == p.high_clip_thresh {
906            s.config.high_clip_thresh = params.value.as_f64();
907        } else if Some(reason) == p.high_clip_value {
908            s.config.high_clip_value = params.value.as_f64();
909        } else if Some(reason) == p.enable_filter {
910            s.config.enable_filter = params.value.as_i32() != 0;
911        } else if Some(reason) == p.filter_type {
912            // C maps FilterType to coefficients in the database
913            // (NDProcess.template:809-825 `FilterTypeSeq` writes FC/OC/RC only)
914            // and NDPluginProcess::writeInt32 (:274-329) never touches pFilter
915            // or numFiltered. Only the coefficients change here.
916            s.apply_filter_type(params.value.as_i32());
917            // Push updated coefficients back
918            let fc = &s.config.filter;
919            for (i, idx) in p.fc.iter().enumerate() {
920                if let Some(idx) = *idx {
921                    updates.push(ParamUpdate::float64(idx, fc.fc[i]));
922                }
923            }
924            for (i, idx) in p.oc.iter().enumerate() {
925                if let Some(idx) = *idx {
926                    updates.push(ParamUpdate::float64(idx, fc.oc[i]));
927                }
928            }
929            for (i, idx) in p.rc.iter().enumerate() {
930                if let Some(idx) = *idx {
931                    updates.push(ParamUpdate::float64(idx, fc.rc[i]));
932                }
933            }
934            if let Some(idx) = p.f_offset {
935                updates.push(ParamUpdate::float64(idx, fc.f_offset));
936            }
937            if let Some(idx) = p.f_scale {
938                updates.push(ParamUpdate::float64(idx, fc.f_scale));
939            }
940            if let Some(idx) = p.o_offset {
941                updates.push(ParamUpdate::float64(idx, fc.o_offset));
942            }
943            if let Some(idx) = p.o_scale {
944                updates.push(ParamUpdate::float64(idx, fc.o_scale));
945            }
946        } else if Some(reason) == p.reset_filter {
947            if params.value.as_i32() != 0 {
948                // Arm the reset; the next processed frame consumes it, clears
949                // the PV and zeroes NumFiltered (NDPluginProcess.cpp:91-93,
950                // :204-210). C does neither at parameter-write time.
951                s.reset_filter();
952            }
953        } else if Some(reason) == p.auto_reset_filter {
954            s.config.filter.auto_reset = params.value.as_i32() != 0;
955        } else if Some(reason) == p.filter_callbacks {
956            s.config.filter.filter_callbacks = params.value.as_i32().max(0) as usize;
957        } else if Some(reason) == p.num_filter {
958            s.config.filter.num_filter = params.value.as_i32().max(1) as usize;
959        } else if Some(reason) == p.o_offset {
960            s.config.filter.o_offset = params.value.as_f64();
961        } else if Some(reason) == p.o_scale {
962            s.config.filter.o_scale = params.value.as_f64();
963        } else if Some(reason) == p.f_offset {
964            s.config.filter.f_offset = params.value.as_f64();
965        } else if Some(reason) == p.f_scale {
966            s.config.filter.f_scale = params.value.as_f64();
967        } else if Some(reason) == p.r_offset {
968            s.config.filter.r_offset = params.value.as_f64();
969        } else {
970            // Check individual OC/FC/RC params
971            for i in 0..4 {
972                if Some(reason) == p.oc[i] {
973                    s.config.filter.oc[i] = params.value.as_f64();
974                    return ParamChangeResult::updates(vec![]);
975                }
976                if Some(reason) == p.fc[i] {
977                    s.config.filter.fc[i] = params.value.as_f64();
978                    return ParamChangeResult::updates(vec![]);
979                }
980            }
981            for i in 0..2 {
982                if Some(reason) == p.rc[i] {
983                    s.config.filter.rc[i] = params.value.as_f64();
984                    return ParamChangeResult::updates(vec![]);
985                }
986            }
987        }
988
989        ParamChangeResult::updates(updates)
990    }
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
997
998    fn make_array(vals: &[u8]) -> NDArray {
999        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::UInt8);
1000        if let NDDataBuffer::U8(ref mut v) = arr.data {
1001            v.copy_from_slice(vals);
1002        }
1003        arr
1004    }
1005
1006    /// Put `arr` in C's `pArrays[0]` and write SaveBackground — the only route by
1007    /// which C ever fills pBackground (NDPluginProcess.cpp:293-297).
1008    fn seed_background(state: &mut ProcessState, arr: &NDArray) {
1009        state.last_output = Some(arr.clone());
1010        state.save_background();
1011    }
1012
1013    /// Same for the flat field (NDPluginProcess.cpp:304-308).
1014    fn seed_flat_field(state: &mut ProcessState, arr: &NDArray) {
1015        state.last_output = Some(arr.clone());
1016        state.save_flat_field();
1017    }
1018
1019    fn make_f64_array(vals: &[f64]) -> NDArray {
1020        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::Float64);
1021        if let NDDataBuffer::F64(ref mut v) = arr.data {
1022            v.copy_from_slice(vals);
1023        }
1024        arr
1025    }
1026
1027    #[test]
1028    fn test_background_subtraction() {
1029        let bg_arr = make_array(&[10, 20, 30]);
1030        let input = make_array(&[15, 25, 35]);
1031
1032        let mut state = ProcessState::new(ProcessConfig {
1033            enable_background: true,
1034            ..Default::default()
1035        });
1036        seed_background(&mut state, &bg_arr);
1037
1038        let result = state.process(&input).unwrap();
1039        if let NDDataBuffer::U8(ref v) = result.data {
1040            assert_eq!(v[0], 5);
1041            assert_eq!(v[1], 5);
1042            assert_eq!(v[2], 5);
1043        }
1044    }
1045
1046    #[test]
1047    fn test_adp7_size_mismatched_background_invalidated_not_partial() {
1048        // C recomputes validBackground each frame as (pBackground && nElements ==
1049        // nBackgroundElements) (NDPluginProcess.cpp:121). A size mismatch
1050        // invalidates the whole buffer — it is NOT applied to the matching
1051        // prefix.
1052        let bg_arr = make_array(&[10, 20]); // 2 elements
1053        let input = make_array(&[15, 25, 35]); // 3 elements
1054        let mut state = ProcessState::new(ProcessConfig {
1055            enable_background: true,
1056            ..Default::default()
1057        });
1058        seed_background(&mut state, &bg_arr);
1059        assert!(state.config.valid_background); // set at save time (C writeInt32)
1060
1061        let result = state.process(&input).unwrap();
1062        // Size mismatch → background ignored → output unchanged; valid recomputed
1063        // false at process time.
1064        assert!(!state.config.valid_background);
1065        if let NDDataBuffer::U8(ref v) = result.data {
1066            assert_eq!(v, &[15, 25, 35]);
1067        } else {
1068            panic!("expected U8 output");
1069        }
1070    }
1071
1072    #[test]
1073    fn test_flat_field() {
1074        // C++: value *= scaleFlatField / flatField[i] (NDPluginProcess.cpp:172).
1075        // scaleFlatField is used directly (no mean substitution).
1076        let ff_arr = make_array(&[100, 200, 50]);
1077        let input = make_array(&[100, 100, 100]);
1078
1079        let mut state = ProcessState::new(ProcessConfig {
1080            enable_flat_field: true,
1081            scale_flat_field: 100.0,
1082            ..Default::default()
1083        });
1084        seed_flat_field(&mut state, &ff_arr);
1085
1086        let result = state.process(&input).unwrap();
1087        if let NDDataBuffer::U8(ref v) = result.data {
1088            assert_eq!(v[0], 100); // 100*100/100
1089            assert_eq!(v[1], 50); //  100*100/200
1090            assert_eq!(v[2], 200); // 100*100/50
1091        } else {
1092            panic!("expected U8 output");
1093        }
1094    }
1095
1096    #[test]
1097    fn test_adp24_scale_flat_field_zero_zeroes_output() {
1098        // C uses scaleFlatField directly: value *= scaleFlatField/flatField[i].
1099        // With scaleFlatField == 0 every pixel (whose flatField != 0) becomes 0
1100        // — there is NO mean substitution (NDPluginProcess.cpp:171-172).
1101        let ff_arr = make_array(&[100, 200, 50]);
1102        let input = make_array(&[100, 100, 100]);
1103        let mut state = ProcessState::new(ProcessConfig {
1104            enable_flat_field: true,
1105            scale_flat_field: 0.0,
1106            ..Default::default()
1107        });
1108        seed_flat_field(&mut state, &ff_arr);
1109        let result = state.process(&input).unwrap();
1110        if let NDDataBuffer::U8(ref v) = result.data {
1111            assert_eq!(v, &[0, 0, 0]);
1112        } else {
1113            panic!("expected U8 output");
1114        }
1115    }
1116
1117    #[test]
1118    fn test_offset_scale() {
1119        let input = make_array(&[10, 20, 30]);
1120        let mut state = ProcessState::new(ProcessConfig {
1121            enable_offset_scale: true,
1122            scale: 2.0,
1123            offset: 5.0,
1124            ..Default::default()
1125        });
1126
1127        let result = state.process(&input).unwrap();
1128        if let NDDataBuffer::U8(ref v) = result.data {
1129            // C++: value = (value + offset) * scale
1130            assert_eq!(v[0], 30); // (10+5)*2
1131            assert_eq!(v[1], 50); // (20+5)*2
1132            assert_eq!(v[2], 70); // (30+5)*2
1133        }
1134    }
1135
1136    #[test]
1137    fn test_clipping() {
1138        let input = make_array(&[5, 50, 200]);
1139        let mut state = ProcessState::new(ProcessConfig {
1140            enable_low_clip: true,
1141            low_clip_thresh: 10.0,
1142            low_clip_value: 10.0,
1143            enable_high_clip: true,
1144            high_clip_thresh: 100.0,
1145            high_clip_value: 100.0,
1146            ..Default::default()
1147        });
1148
1149        let result = state.process(&input).unwrap();
1150        if let NDDataBuffer::U8(ref v) = result.data {
1151            assert_eq!(v[0], 10); // clipped up
1152            assert_eq!(v[1], 50); // unchanged
1153            assert_eq!(v[2], 100); // clipped down
1154        }
1155    }
1156
1157    #[test]
1158    fn test_adp5_clip_order_high_before_low() {
1159        // C applies high-clip THEN low-clip (NDPluginProcess.cpp:175-176). With
1160        // crossing thresholds (high < low) the order is observable:
1161        //   v=200 → high(>100 ⇒ 10) → low(<50 ⇒ 999) ⇒ 999
1162        // Low-then-high would instead give 200 → (not <50) → high(>100 ⇒ 10) ⇒ 10.
1163        let input = make_f64_array(&[200.0]);
1164        let mut state = ProcessState::new(ProcessConfig {
1165            enable_high_clip: true,
1166            high_clip_thresh: 100.0,
1167            high_clip_value: 10.0,
1168            enable_low_clip: true,
1169            low_clip_thresh: 50.0,
1170            low_clip_value: 999.0,
1171            ..Default::default()
1172        });
1173        let result = state.process(&input).unwrap();
1174        if let NDDataBuffer::F64(ref v) = result.data {
1175            assert_eq!(v[0], 999.0);
1176        } else {
1177            panic!("expected F64 output");
1178        }
1179    }
1180
1181    #[test]
1182    fn test_recursive_filter() {
1183        // Test a simple recursive filter: filter = 0.5*filter + 0.5*data, output = filter
1184        // Using C++ coefficient scheme:
1185        //   F1 = fScale*(fc1+fc2/N), F2 = fScale*(fc3+fc4/N)
1186        //   For constant F1=0.5, F2=0.5 regardless of N:
1187        //   fc1=0.5, fc2=0, fc3=0.5, fc4=0
1188        let input1 = make_array(&[100, 100, 100]);
1189        let input2 = make_array(&[0, 0, 0]);
1190
1191        let mut state = ProcessState::new(ProcessConfig {
1192            enable_filter: true,
1193            filter: FilterConfig {
1194                num_filter: 10,
1195                fc: [0.5, 0.0, 0.5, 0.0], // F1=0.5, F2=0.5
1196                oc: [1.0, 0.0, 0.0, 0.0], // O1=1, O2=0
1197                rc: [0.0, 1.0],           // reset: filter = data
1198                ..Default::default()
1199            },
1200            ..Default::default()
1201        });
1202
1203        // C++ NDPluginProcess.cpp:220-227 doProcess recurrence:
1204        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1205        //   newFilter = fOffset + F1*filter[i] + F2*data[i];  // ORIGINAL data[i]
1206        //   data[i]   = newData;
1207        //   filter[i] = newFilter;
1208        //
1209        // Frame 0: reset: filter = 0 + 0*100 + 1*100 = 100
1210        // N=1: F1=0.5, F2=0.5, O1=1, O2=0
1211        // data   = 0 + 1*100 + 0*100 = 100
1212        // filter = 0 + 0.5*100 + 0.5*100(orig data) = 100
1213        let _ = state.process(&input1);
1214
1215        // Frame 1: data=0, filter=100
1216        // N=2: F1=0.5, F2=0.5, O1=1, O2=0
1217        // data   = 0 + 1*100 + 0*0 = 100
1218        // filter = 0 + 0.5*100 + 0.5*0(orig data) = 50
1219        let result = state.process(&input2).unwrap();
1220        if let NDDataBuffer::U8(ref v) = result.data {
1221            // Output is data = O1*filter = 1*100 = 100
1222            assert_eq!(v[0], 100);
1223            assert_eq!(v[1], 100);
1224        }
1225    }
1226
1227    #[test]
1228    fn test_output_type_conversion() {
1229        let input = make_array(&[10, 20, 30]);
1230        let mut state = ProcessState::new(ProcessConfig {
1231            output_type: Some(NDDataType::Float64),
1232            ..Default::default()
1233        });
1234
1235        let result = state.process(&input).unwrap();
1236        assert_eq!(result.data.data_type(), NDDataType::Float64);
1237    }
1238
1239    // --- ProcessProcessor tests ---
1240
1241    #[test]
1242    fn test_process_processor() {
1243        let mut proc = ProcessProcessor::new(ProcessConfig {
1244            enable_offset_scale: true,
1245            scale: 2.0,
1246            offset: 1.0,
1247            ..Default::default()
1248        });
1249        let pool = NDArrayPool::new(1_000_000);
1250
1251        let input = make_array(&[10, 20, 30]);
1252        let result = proc.process_array(&input, &pool);
1253        assert_eq!(result.output_arrays.len(), 1);
1254        if let NDDataBuffer::U8(ref v) = result.output_arrays[0].data {
1255            assert_eq!(v[0], 22); // (10+1)*2 = 22 (C++: offset first, then scale)
1256        }
1257    }
1258
1259    // --- New Phase 2-1 tests ---
1260
1261    #[test]
1262    fn test_filter_sum_preset() {
1263        // Sum preset: filter = filter + data, output = filter
1264        // fc=[1,0,1,0], oc=[1,0,0,0], rc=[0,1]
1265        let mut state = ProcessState::new(ProcessConfig {
1266            enable_filter: true,
1267            filter: FilterConfig {
1268                num_filter: 10,
1269                fc: [1.0, 0.0, 1.0, 0.0],
1270                oc: [1.0, 0.0, 0.0, 0.0],
1271                rc: [0.0, 1.0],
1272                ..Default::default()
1273            },
1274            output_type: Some(NDDataType::Float64),
1275            ..Default::default()
1276        });
1277
1278        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1279        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1280        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1281        //   data[i]   = newData; filter[i] = newFilter;
1282        //
1283        // Frame 0: reset first: filter = rOffset + rc1*filter + rc2*data
1284        //          = 0 + 0*100 + 1*100 = 100. Then N increments to 1, normal path:
1285        // F1=fScale*(fc1+fc2/N)=1*(1+0/1)=1, F2=fScale*(fc3+fc4/N)=1*(1+0/1)=1
1286        // O1=oScale*(oc1+oc2/N)=1*(1+0/1)=1, O2=oScale*(oc3+oc4/N)=1*(0+0/1)=0
1287        // data   = oOffset + O1*filter + O2*data = 0 + 1*100 + 0*100 = 100
1288        // filter = fOffset + F1*filter + F2*data(orig=100) = 0 + 1*100 + 1*100 = 200
1289        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1290        let v0 = r0.data.get_as_f64(0).unwrap();
1291        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1292
1293        // Frame 1: data=100, filter=200 (from prev)
1294        // N increments to 2
1295        // F1=1*(1+0/2)=1, F2=1*(1+0/2)=1
1296        // O1=1*(1+0/2)=1, O2=0
1297        // data   = 0 + 1*200 + 0*100 = 200
1298        // filter = 0 + 1*200 + 1*data(orig=100) = 300
1299        let r1 = state.process(&make_f64_array(&[100.0])).unwrap();
1300        let v1 = r1.data.get_as_f64(0).unwrap();
1301        assert!((v1 - 200.0).abs() < 1e-9, "frame 1: got {v1}");
1302    }
1303
1304    #[test]
1305    fn test_filter_average_preset() {
1306        // Average preset: accumulate in filter, output = filter/N
1307        // fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1]
1308        let mut state = ProcessState::new(ProcessConfig {
1309            enable_filter: true,
1310            filter: FilterConfig {
1311                num_filter: 10,
1312                fc: [1.0, 0.0, 1.0, 0.0],
1313                oc: [0.0, 1.0, 0.0, 0.0],
1314                rc: [0.0, 1.0],
1315                ..Default::default()
1316            },
1317            output_type: Some(NDDataType::Float64),
1318            ..Default::default()
1319        });
1320
1321        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1322        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1323        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1324        //   data[i]   = newData; filter[i] = newFilter;
1325        //
1326        // Frame 0 (reset): filter=100. N=1: O1=oScale*(0+1/1)=1, O2=0
1327        // data   = 0 + 1*100 + 0 = 100
1328        // filter = 0 + 1*100 + 1*100(orig data) = 200
1329        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1330        let v0 = r0.data.get_as_f64(0).unwrap();
1331        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1332
1333        // Frame 1: data=200, filter=200
1334        // N=2: O1=oScale*(0+1/2)=0.5, O2=0
1335        // data   = 0 + 0.5*200 + 0 = 100
1336        // filter = 0 + 1*200 + 1*200(orig data) = 400
1337        let r1 = state.process(&make_f64_array(&[200.0])).unwrap();
1338        let v1 = r1.data.get_as_f64(0).unwrap();
1339        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1340
1341        // Frame 2: data=300, filter=400
1342        // N=3: O1=1/3, O2=0
1343        // data   = 0 + (1/3)*400 + 0 = 400/3
1344        // filter = 0 + 1*400 + 1*300(orig data) = 700
1345        let r2 = state.process(&make_f64_array(&[300.0])).unwrap();
1346        let v2 = r2.data.get_as_f64(0).unwrap();
1347        let expected = 400.0 / 3.0;
1348        assert!((v2 - expected).abs() < 1e-9, "frame 2: got {v2}");
1349    }
1350
1351    #[test]
1352    fn test_filter_recursive_ave() {
1353        // RecursiveAve preset matching C++ behavior
1354        // fc=[1,-1,0,1], oc=[1,0,0,0], rc=[0,1]
1355        // F1=fScale*(1+(-1)/N)=(N-1)/N, F2=fScale*(0+1/N)=1/N
1356        // O1=oScale*(1+0/N)=1, O2=0
1357        let mut state = ProcessState::new(ProcessConfig {
1358            enable_filter: true,
1359            filter: FilterConfig {
1360                num_filter: 10,
1361                fc: [1.0, -1.0, 0.0, 1.0],
1362                oc: [1.0, 0.0, 0.0, 0.0],
1363                rc: [0.0, 1.0],
1364                ..Default::default()
1365            },
1366            output_type: Some(NDDataType::Float64),
1367            ..Default::default()
1368        });
1369
1370        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1371        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1372        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1373        //   data[i]   = newData; filter[i] = newFilter;
1374        // With O2=0, newData == O1*filter == filter, and the filter update
1375        // newFilter = F1*filter + F2*data(orig) tracks the original input.
1376        //
1377        // Frame 0: reset filter=100, N=1
1378        // F1=1*(1-1/1)=0, F2=1*(0+1/1)=1, O1=1*(1+0/1)=1
1379        // data   = 0 + 1*100 + 0*100 = 100
1380        // filter = 0 + 0*100 + 1*100(orig data) = 100
1381        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1382        let v0 = r0.data.get_as_f64(0).unwrap();
1383        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1384
1385        // Frame 1: data=200, filter=100, N=2
1386        // F1=(2-1)/2=0.5, F2=1/2=0.5
1387        // data   = 0 + 1*100 + 0*200 = 100
1388        // filter = 0 + 0.5*100 + 0.5*200(orig data) = 150
1389        let r1 = state.process(&make_f64_array(&[200.0])).unwrap();
1390        let v1 = r1.data.get_as_f64(0).unwrap();
1391        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1392
1393        // Frame 2: data=300, filter=150, N=3
1394        // F1=2/3, F2=1/3, O1=1
1395        // data   = 0 + 1*150 + 0*300 = 150
1396        // filter = (2/3)*150 + (1/3)*300(orig data) = 100 + 100 = 200
1397        let r2 = state.process(&make_f64_array(&[300.0])).unwrap();
1398        let v2 = r2.data.get_as_f64(0).unwrap();
1399        assert!((v2 - 150.0).abs() < 1e-9, "frame 2: got {v2}");
1400    }
1401
1402    #[test]
1403    fn test_r9_68_save_background_copies_the_last_output_synchronously() {
1404        // R9-68. C's writeInt32(SaveBackground) (NDPluginProcess.cpp:287-298) saves
1405        // `this->pArrays[0]` — the plugin's last OUTPUT array — on the spot and
1406        // latches ValidBackground=1 there. The port armed a one-shot flag and saved
1407        // the next frame's INPUT instead, so the background was a different array
1408        // (unprocessed, and one frame late).
1409        //
1410        // This test replaces test_save_background_one_shot, which pinned that
1411        // invented deferred-input behaviour.
1412        let mut state = ProcessState::new(ProcessConfig {
1413            enable_offset_scale: true,
1414            offset: 0.0,
1415            scale: 2.0,
1416            output_type: Some(NDDataType::Float64),
1417            ..Default::default()
1418        });
1419
1420        // No frame yet: C's pArrays[0] is NULL, so the save leaves the background
1421        // empty and ValidBackground at 0 (:291-292 clear unconditionally, :293
1422        // guards the copy).
1423        state.save_background();
1424        assert!(state.background.is_none());
1425        assert!(!state.config.valid_background);
1426
1427        // One frame through: input 10,20,30 → output (x + 0) * 2 = 20,40,60.
1428        let out = state.process(&make_array(&[10, 20, 30])).unwrap();
1429        assert_eq!(out.data.get_as_f64(0), Some(20.0));
1430
1431        // SaveBackground now copies THAT OUTPUT (20,40,60), not the input and not
1432        // the next frame.
1433        state.save_background();
1434        assert!(
1435            state.config.valid_background,
1436            "ValidBackground latches at once"
1437        );
1438        let bg = state.background.as_ref().unwrap();
1439        assert_eq!(
1440            bg.as_slice(),
1441            &[20.0, 40.0, 60.0],
1442            "background is the OUTPUT array"
1443        );
1444
1445        // The next frame must not overwrite the background — the old one-shot did.
1446        let _ = state.process(&make_array(&[1, 2, 3]));
1447        assert_eq!(
1448            state.background.as_ref().unwrap().as_slice(),
1449            &[20.0, 40.0, 60.0]
1450        );
1451    }
1452
1453    #[test]
1454    fn test_r9_68_save_flat_field_copies_the_last_output_synchronously() {
1455        // Same contract on the flat-field buffer (NDPluginProcess.cpp:299-310).
1456        let mut state = ProcessState::new(ProcessConfig {
1457            enable_offset_scale: true,
1458            offset: 1.0,
1459            scale: 1.0,
1460            output_type: Some(NDDataType::Float64),
1461            ..Default::default()
1462        });
1463
1464        state.save_flat_field();
1465        assert!(state.flat_field.is_none());
1466        assert!(!state.config.valid_flat_field);
1467
1468        // Output = (input + 1) * 1 → 51, 101, 151.
1469        let _ = state.process(&make_array(&[50, 100, 150])).unwrap();
1470        state.save_flat_field();
1471
1472        assert!(state.config.valid_flat_field);
1473        assert_eq!(
1474            state.flat_field.as_ref().unwrap().as_slice(),
1475            &[51.0, 101.0, 151.0],
1476            "flat field is the OUTPUT array, not the input"
1477        );
1478
1479        let _ = state.process(&make_array(&[7, 7, 7]));
1480        assert_eq!(
1481            state.flat_field.as_ref().unwrap().as_slice(),
1482            &[51.0, 101.0, 151.0]
1483        );
1484    }
1485
1486    #[test]
1487    fn test_r9_68_save_background_write_of_zero_still_saves() {
1488        // C's writeInt32 branches on the FUNCTION, never on the value
1489        // (NDPluginProcess.cpp:287): a caput of 0 to SaveBackground runs the same
1490        // release-and-resave sequence. The port gated on `value != 0`.
1491        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1492        use asyn_rs::port::{PortDriverBase, PortFlags};
1493
1494        let mut proc = ProcessProcessor::new(ProcessConfig {
1495            output_type: Some(NDDataType::Float64),
1496            ..Default::default()
1497        });
1498
1499        let mut base = PortDriverBase::new("R9_68", 1, PortFlags::default());
1500        proc.register_params(&mut base).unwrap();
1501        let pool = NDArrayPool::new(1_000_000);
1502        let _ = proc.process_array(&make_array(&[4, 5, 6]), &pool);
1503
1504        let reason = proc.params.save_background.unwrap();
1505        let valid = proc.params.valid_background.unwrap();
1506        let snapshot = PluginParamSnapshot {
1507            enable_callbacks: true,
1508            reason,
1509            addr: 0,
1510            value: ParamChangeValue::Int32(0),
1511        };
1512        let result = proc.on_param_change(reason, &snapshot);
1513
1514        assert_eq!(
1515            proc.state.background.as_ref().unwrap().as_slice(),
1516            &[4.0, 5.0, 6.0],
1517            "a 0 write saves the background too"
1518        );
1519        // The PV self-clears and ValidBackground is published from the same write.
1520        let int_update = |r: usize| {
1521            result.param_updates.iter().find_map(|u| match u {
1522                ParamUpdate::Int32 {
1523                    reason: ur, value, ..
1524                } if *ur == r => Some(*value),
1525                _ => None,
1526            })
1527        };
1528        assert_eq!(int_update(reason), Some(0), "SaveBackground echoes 0");
1529        assert_eq!(
1530            int_update(valid),
1531            Some(1),
1532            "ValidBackground latches on the write"
1533        );
1534    }
1535
1536    #[test]
1537    fn test_auto_reset_when_num_filter_reached() {
1538        // Sum filter with auto_reset after 3 frames
1539        let mut state = ProcessState::new(ProcessConfig {
1540            enable_filter: true,
1541            filter: FilterConfig {
1542                num_filter: 3,
1543                auto_reset: true,
1544                fc: [1.0, 0.0, 1.0, 0.0], // sum preset
1545                oc: [1.0, 0.0, 0.0, 0.0],
1546                rc: [0.0, 1.0],
1547                ..Default::default()
1548            },
1549            output_type: Some(NDDataType::Float64),
1550            ..Default::default()
1551        });
1552
1553        // Frame 0 (reset): num_filtered becomes 1
1554        let _ = state.process(&make_f64_array(&[100.0]));
1555        assert_eq!(state.num_filtered, 1);
1556
1557        // Frame 1: num_filtered becomes 2
1558        let _ = state.process(&make_f64_array(&[100.0]));
1559        assert_eq!(state.num_filtered, 2);
1560
1561        // Frame 2: num_filtered becomes 3 = num_filter, triggers auto_reset on next
1562        let _ = state.process(&make_f64_array(&[100.0]));
1563        assert_eq!(state.num_filtered, 3);
1564
1565        // Frame 3: auto_reset fires (num_filtered >= num_filter), filter is reset
1566        let _ = state.process(&make_f64_array(&[200.0]));
1567        // After reset + processing, num_filtered should be 1
1568        assert_eq!(state.num_filtered, 1, "fresh start after auto reset");
1569    }
1570
1571    #[test]
1572    fn test_filter_with_offset_scale() {
1573        // Test that f_offset/f_scale and o_offset/o_scale are applied in C++ manner:
1574        // F1 = fScale * (fc1 + fc2/N), O1 = oScale * (oc1 + oc2/N)
1575        // CopyToFilter: fc=[0,0,1,0], oc=[1,0,0,0]
1576        let mut state = ProcessState::new(ProcessConfig {
1577            enable_filter: true,
1578            filter: FilterConfig {
1579                num_filter: 10,
1580                fc: [0.0, 0.0, 1.0, 0.0], // F1=0, F2=fScale*1
1581                oc: [1.0, 0.0, 0.0, 0.0], // O1=oScale*1, O2=0
1582                rc: [0.0, 1.0],
1583                f_offset: 10.0,
1584                f_scale: 2.0,
1585                o_offset: 5.0,
1586                o_scale: 3.0,
1587                ..Default::default()
1588            },
1589            output_type: Some(NDDataType::Float64),
1590            ..Default::default()
1591        });
1592
1593        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1594        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1595        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1596        //   data[i]   = newData; filter[i] = newFilter;
1597        //
1598        // Frame 0: reset: filter = 0 + 0*filter + 1*50 = 50
1599        // N=1: F1=2*(0+0/1)=0, F2=2*(1+0/1)=2, O1=3*(1+0/1)=3, O2=0
1600        // data   = 5 + 3*50 + 0 = 155
1601        // filter = 10 + 0*50 + 2*50(orig data) = 110
1602        let r0 = state.process(&make_f64_array(&[50.0])).unwrap();
1603        let v0 = r0.data.get_as_f64(0).unwrap();
1604        assert!((v0 - 155.0).abs() < 1e-9, "frame 0: got {v0}");
1605
1606        // Frame 1: data=20, filter=110
1607        // N=2: F1=0, F2=2, O1=3, O2=0
1608        // data   = 5 + 3*110 + 0 = 335
1609        // filter = 10 + 0 + 2*20(orig data) = 50
1610        let r1 = state.process(&make_f64_array(&[20.0])).unwrap();
1611        let v1 = r1.data.get_as_f64(0).unwrap();
1612        assert!((v1 - 335.0).abs() < 1e-9, "frame 1: got {v1}");
1613    }
1614
1615    #[test]
1616    fn test_reset_filter_manual() {
1617        let mut state = ProcessState::new(ProcessConfig {
1618            enable_filter: true,
1619            filter: FilterConfig {
1620                num_filter: 10,
1621                fc: [1.0, 0.0, 1.0, 0.0],
1622                oc: [1.0, 0.0, 0.0, 0.0],
1623                rc: [0.0, 1.0],
1624                ..Default::default()
1625            },
1626            output_type: Some(NDDataType::Float64),
1627            ..Default::default()
1628        });
1629
1630        // Build up filter state
1631        let _ = state.process(&make_f64_array(&[100.0]));
1632        let _ = state.process(&make_f64_array(&[100.0]));
1633        assert!(state.filter_state.is_some());
1634        assert_eq!(state.num_filtered, 2);
1635
1636        // Manual reset: C only clears the ResetFilter PV (NDPluginProcess.cpp:91-93).
1637        // The buffer stays, and NumFiltered is zeroed by the next frame's reset
1638        // loop (:210), not by the parameter write.
1639        state.reset_filter();
1640        assert!(
1641            state.filter_state.is_some(),
1642            "buffer must survive the reset"
1643        );
1644        assert_eq!(state.num_filtered, 2);
1645
1646        // Next frame runs the reset formula, so num_filtered restarts at 1.
1647        let _ = state.process(&make_f64_array(&[200.0]));
1648        assert_eq!(state.num_filtered, 1);
1649    }
1650
1651    #[test]
1652    fn test_r6_69_manual_reset_keeps_previous_filter_contents() {
1653        // R6-69 / NDPluginProcess.cpp:91,184,204-209 — ResetFilter does not free
1654        // pFilter; it is released only on an element-count mismatch. The reset
1655        // formula therefore reads the PREVIOUS filter contents:
1656        //   newFilter = rOffset + rc1*filter[i] + rc2*data[i]
1657        // With RC1 != 0 that differs from a filter re-seeded off the current frame.
1658        //
1659        // CopyToFilter (fc=[0,0,1,0], oc=[1,0,0,0]) makes filter[i] == the last
1660        // frame's input and data[i] == the pre-update filter, so the values below
1661        // are easy to follow.
1662        let cfg = || ProcessConfig {
1663            enable_filter: true,
1664            filter: FilterConfig {
1665                num_filter: 10,
1666                fc: [0.0, 0.0, 1.0, 0.0],
1667                oc: [1.0, 0.0, 0.0, 0.0],
1668                rc: [0.5, 2.0], // rc1 = 0.5 (reads the old filter), rc2 = 2.0
1669                r_offset: 1.0,
1670                ..Default::default()
1671            },
1672            output_type: Some(NDDataType::Float64),
1673            ..Default::default()
1674        };
1675
1676        let mut state = ProcessState::new(cfg());
1677        // Frame 0 seeds the buffer from the frame itself (no prior filter):
1678        //   filter = 1.0 + 0.5*100 + 2.0*100 = 251, then CopyToFilter -> 100.
1679        let _ = state.process(&make_f64_array(&[100.0]));
1680        assert_eq!(state.filter_state.as_ref().unwrap()[0], 100.0);
1681
1682        // Arm the manual reset, then send a frame of 10.
1683        state.reset_filter();
1684        let out = state.process(&make_f64_array(&[10.0])).unwrap();
1685
1686        // Reset uses the PREVIOUS filter (100), not the current data (10):
1687        //   newFilter = 1.0 + 0.5*100 + 2.0*10 = 71
1688        // Output (O1 = 1) is that reinitialized filter value.
1689        assert_eq!(out.data.get_as_f64(0).unwrap(), 71.0);
1690        assert_eq!(state.num_filtered, 1);
1691        // A buffer re-seeded from the current frame would have given
1692        // 1.0 + 0.5*10 + 2.0*10 = 26 — the pre-fix behaviour.
1693    }
1694
1695    #[test]
1696    fn test_r6_69_element_count_mismatch_frees_the_buffer() {
1697        // The one path that DOES release pFilter (NDPluginProcess.cpp:182-187):
1698        // a frame whose element count differs from the buffer's.
1699        let mut state = ProcessState::new(ProcessConfig {
1700            enable_filter: true,
1701            filter: FilterConfig {
1702                num_filter: 10,
1703                fc: [0.0, 0.0, 1.0, 0.0],
1704                oc: [1.0, 0.0, 0.0, 0.0],
1705                rc: [0.5, 2.0],
1706                r_offset: 1.0,
1707                ..Default::default()
1708            },
1709            output_type: Some(NDDataType::Float64),
1710            ..Default::default()
1711        });
1712
1713        let _ = state.process(&make_f64_array(&[100.0]));
1714        assert_eq!(state.filter_state.as_ref().unwrap().len(), 1);
1715
1716        // Two elements now: the old buffer is dropped and re-seeded from this
1717        // frame, so the reset reads filter[i] == data[i] == 10.
1718        //   newFilter = 1.0 + 0.5*10 + 2.0*10 = 26
1719        let out = state.process(&make_f64_array(&[10.0, 10.0])).unwrap();
1720        assert_eq!(state.filter_state.as_ref().unwrap().len(), 2);
1721        assert_eq!(out.data.get_as_f64(0).unwrap(), 26.0);
1722        assert_eq!(state.num_filtered, 1);
1723    }
1724
1725    #[test]
1726    fn test_adp6_auto_offset_scale_arms_next_frame_not_trigger() {
1727        // C measures the trigger frame's min/max and ARMS scale/offset + clipping
1728        // for the NEXT frame; the trigger frame itself is emitted with the
1729        // pre-existing config (NDPluginProcess.cpp:164-178 measures only, 238-250
1730        // arms after the output array is built).
1731        let mut state = ProcessState::new(ProcessConfig {
1732            output_type: Some(NDDataType::UInt8),
1733            ..Default::default()
1734        });
1735        state.config.auto_offset_scale_pending = true;
1736
1737        // Trigger frame: input range [10, 30]. Offset/scale were OFF going in, so
1738        // the frame is emitted UNSCALED — output == input converted to u8.
1739        let out1 = state.process(&make_f64_array(&[10.0, 20.0, 30.0])).unwrap();
1740        assert!(!state.config.auto_offset_scale_pending); // one-shot consumed
1741        if let NDDataBuffer::U8(v) = &out1.data {
1742            assert_eq!(v, &[10, 20, 30]); // trigger frame NOT transformed
1743        } else {
1744            panic!("expected u8 output");
1745        }
1746        // Params armed from the trigger frame for subsequent frames:
1747        //   offset=-10, scale=255/20=12.75, offset/scale + clipping enabled.
1748        assert!(state.config.enable_offset_scale);
1749        assert!((state.config.offset - (-10.0)).abs() < 1e-9);
1750        assert!((state.config.scale - 255.0 / 20.0).abs() < 1e-9);
1751
1752        // NEXT frame IS transformed with the armed params: (v-10)*12.75, clipped.
1753        let out2 = state.process(&make_f64_array(&[10.0, 20.0, 30.0])).unwrap();
1754        if let NDDataBuffer::U8(v) = &out2.data {
1755            assert_eq!(v[0], 0); // (10-10)*12.75 = 0
1756            assert_eq!(v[2], 255); // (30-10)*12.75 = 255
1757        } else {
1758            panic!("expected u8 output");
1759        }
1760    }
1761
1762    #[test]
1763    fn test_filter_callbacks_drops_suppressed_frame() {
1764        // Regression: with filter_callbacks set, a frame that has not yet
1765        // reached num_filter is dropped (process() returns None), not
1766        // forwarded as the raw input.
1767        let mut state = ProcessState::new(ProcessConfig {
1768            enable_filter: true,
1769            filter: FilterConfig {
1770                num_filter: 3,
1771                filter_callbacks: 1,
1772                fc: [1.0, 0.0, 1.0, 0.0],
1773                oc: [0.0, 1.0, 0.0, 0.0],
1774                rc: [0.0, 1.0],
1775                ..Default::default()
1776            },
1777            output_type: Some(NDDataType::Float64),
1778            ..Default::default()
1779        });
1780
1781        // Frames 1 and 2 are below num_filter => suppressed (None).
1782        assert!(state.process(&make_f64_array(&[100.0])).is_none());
1783        assert!(state.process(&make_f64_array(&[100.0])).is_none());
1784        // Frame 3 reaches num_filter => output produced.
1785        assert!(state.process(&make_f64_array(&[100.0])).is_some());
1786    }
1787
1788    #[test]
1789    fn test_filter_recurrence_matches_cpp() {
1790        // Regression: the filter-state update must read the ORIGINAL input
1791        // data[i], not the just-updated newData. C++ computes both newData
1792        // and newFilter from data[i] before assigning data[i] = newData.
1793        //
1794        // C++ NDPluginProcess.cpp:220-227 doProcess:
1795        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1796        //   newFilter = fOffset + F1*filter[i] + F2*data[i];  // ORIGINAL data[i]
1797        //   data[i]   = newData;
1798        //   filter[i] = newFilter;
1799        //
1800        // Average preset: fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1].
1801        // O1=1/N, O2=0, F1=1, F2=1, all offsets/scales default (0/1).
1802        // With O2=0 and oc default, the C++ recurrence is:
1803        //   data[k]   = filter / N
1804        //   filter'   = filter + input   (F2 multiplies the ORIGINAL input)
1805        //
1806        // Hand-computed reference (inputs 100, 200, 300, 400):
1807        //   reset: filter = 100, N = 1
1808        //   k0: N=1  data = 100/1   = 100      filter = 100 + 100 = 200
1809        //   k1: N=2  data = 200/2   = 100      filter = 200 + 200 = 400
1810        //   k2: N=3  data = 400/3   = 133.333  filter = 400 + 300 = 700
1811        //   k3: N=4  data = 700/4   = 175      filter = 700 + 400 = 1100
1812        //
1813        // The STALE/new-data variant (the 650038bb regression) computed
1814        //   filter' = filter + newData
1815        // giving filter = 100,200,300,400 and data = 100,100,100,100 —
1816        // diverging from C++ from frame 1 onward.
1817        let mut state = ProcessState::new(ProcessConfig {
1818            enable_filter: true,
1819            filter: FilterConfig {
1820                num_filter: 100,
1821                fc: [1.0, 0.0, 1.0, 0.0],
1822                oc: [0.0, 1.0, 0.0, 0.0],
1823                rc: [0.0, 1.0],
1824                ..Default::default()
1825            },
1826            output_type: Some(NDDataType::Float64),
1827            ..Default::default()
1828        });
1829
1830        let inputs = [100.0, 200.0, 300.0, 400.0];
1831        let expected_data = [100.0, 100.0, 400.0 / 3.0, 175.0];
1832        let expected_filter = [200.0, 400.0, 700.0, 1100.0];
1833
1834        for k in 0..inputs.len() {
1835            let r = state.process(&make_f64_array(&[inputs[k]])).unwrap();
1836            let v = r.data.get_as_f64(0).unwrap();
1837            assert!(
1838                (v - expected_data[k]).abs() < 1e-9,
1839                "frame {k}: data got {v}, expected {}",
1840                expected_data[k]
1841            );
1842            let fs = state.filter_state.as_ref().unwrap()[0];
1843            assert!(
1844                (fs - expected_filter[k]).abs() < 1e-9,
1845                "frame {k}: filter got {fs}, expected {}",
1846                expected_filter[k]
1847            );
1848        }
1849    }
1850    /// R12-63. C guards every filter term with `if (coef)`
1851    /// (NDPluginProcess.cpp:206-207, 221-225), so a ZERO coefficient DROPS its
1852    /// term. Multiplying instead is not equivalent: `0.0 * NaN` is NaN, so a
1853    /// single non-finite input sample poisons `filter[]` — permanently, because
1854    /// filter[] feeds the next frame — even though the coefficients say that
1855    /// term is unused.
1856    ///
1857    /// Setup: RC1=RC2=0 with ROFFSET=5, so C's reset writes `filter[i] = 5` and
1858    /// never touches the NaN it seeded the filter from. OC3=OC4=0 (O2=0) and
1859    /// FC3=FC4=0 (F2=0), so the NaN input data is dropped from both sums too.
1860    /// C output: `oOffset + O1*filter[i]` = 5 for EVERY element.
1861    #[test]
1862    fn r12_63_a_zero_coefficient_drops_its_term_instead_of_multiplying_it() {
1863        let input = make_f64_array(&[1.0, f64::NAN, 3.0]);
1864
1865        let mut state = ProcessState::new(ProcessConfig {
1866            enable_filter: true,
1867            filter: FilterConfig {
1868                num_filter: 2,
1869                rc: [0.0, 0.0],
1870                r_offset: 5.0,
1871                oc: [1.0, 0.0, 0.0, 0.0],
1872                fc: [1.0, 0.0, 0.0, 0.0],
1873                ..Default::default()
1874            },
1875            ..Default::default()
1876        });
1877
1878        let result = state.process(&input).unwrap();
1879        let NDDataBuffer::F64(ref v) = result.data else {
1880            panic!("expected an F64 output buffer, got {:?}", result.data);
1881        };
1882        assert_eq!(
1883            v.as_slice(),
1884            [5.0, 5.0, 5.0],
1885            "RC1=RC2=0 makes C's reset `filter[i] = rOffset`; O2=0 drops the NaN \
1886             data term. Every element is rOffset — 0.0 * NaN must not be summed in"
1887        );
1888
1889        // And the poison must not be latent in the filter state either: a second,
1890        // fully finite frame still comes out clean.
1891        let clean = make_f64_array(&[7.0, 8.0, 9.0]);
1892        let result = state.process(&clean).unwrap();
1893        let NDDataBuffer::F64(ref v) = result.data else {
1894            panic!("expected an F64 output buffer");
1895        };
1896        assert!(
1897            v.iter().all(|x| x.is_finite()),
1898            "the NaN must not survive in filter[] across frames: {v:?}"
1899        );
1900    }
1901}