Skip to main content

ad_plugins_rs/
process.rs

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