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