Skip to main content

ad_plugins_rs/
process.rs

1use std::sync::Arc;
2
3#[cfg(feature = "parallel")]
4use crate::par_util;
5#[cfg(feature = "parallel")]
6use rayon::prelude::*;
7
8use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType};
9use ad_core_rs::ndarray_pool::NDArrayPool;
10use ad_core_rs::plugin::runtime::{NDPluginProcess, ProcessResult};
11
12/// Recursive filter configuration matching C++ NDPluginProcess.
13///
14/// The C++ filter uses a single filter buffer and numFiltered-dependent coefficients:
15///
16/// Reset:
17///   filter[i] = rOffset + rc1*filter[i] + rc2*data[i]
18///
19/// Normal operation (after numFiltered is incremented):
20///   O1 = oScale * (oc1 + oc2/numFiltered)
21///   O2 = oScale * (oc3 + oc4/numFiltered)
22///   F1 = fScale * (fc1 + fc2/numFiltered)
23///   F2 = fScale * (fc3 + fc4/numFiltered)
24///   data[i]   = oOffset + O1*filter[i] + O2*data[i]
25///   filter[i] = fOffset + F1*filter[i] + F2*data[i]
26#[derive(Debug, Clone)]
27pub struct FilterConfig {
28    /// Number of frames to average before auto-reset (if enabled).
29    pub num_filter: usize,
30    /// Automatically reset the filter when num_filtered reaches num_filter.
31    pub auto_reset: bool,
32    /// Output every N frames (0 = every frame).
33    pub filter_callbacks: usize,
34    /// Output coefficients [OC1, OC2, OC3, OC4].
35    pub oc: [f64; 4],
36    /// Filter coefficients [FC1, FC2, FC3, FC4].
37    pub fc: [f64; 4],
38    /// Reset coefficients [RC1, RC2].
39    pub rc: [f64; 2],
40    /// Reset offset (C++ rOffset).
41    pub r_offset: f64,
42    /// Output offset.
43    pub o_offset: f64,
44    /// Output scale.
45    pub o_scale: f64,
46    /// Filter offset.
47    pub f_offset: f64,
48    /// Filter scale.
49    pub f_scale: f64,
50}
51
52impl Default for FilterConfig {
53    fn default() -> Self {
54        Self {
55            num_filter: 1,
56            auto_reset: false,
57            filter_callbacks: 0,
58            oc: [1.0, 0.0, 0.0, 0.0], // simple passthrough
59            fc: [1.0, 0.0, 0.0, 0.0],
60            rc: [1.0, 0.0],
61            r_offset: 0.0,
62            o_offset: 0.0,
63            o_scale: 1.0,
64            f_offset: 0.0,
65            f_scale: 1.0,
66        }
67    }
68}
69
70/// Process plugin operations applied sequentially to an NDArray.
71#[derive(Debug, Clone)]
72pub struct ProcessConfig {
73    pub enable_background: bool,
74    pub enable_flat_field: bool,
75    pub enable_offset_scale: bool,
76    pub offset: f64,
77    pub scale: f64,
78    pub enable_low_clip: bool,
79    pub low_clip_thresh: f64,
80    pub low_clip_value: f64,
81    pub enable_high_clip: bool,
82    pub high_clip_thresh: f64,
83    pub high_clip_value: f64,
84    pub scale_flat_field: f64,
85    pub enable_filter: bool,
86    pub filter: FilterConfig,
87    pub output_type: Option<NDDataType>,
88    /// One-shot flag: save current input as background on next process().
89    pub save_background: bool,
90    /// One-shot flag: save current input as flat field on next process().
91    pub save_flat_field: bool,
92    /// One-shot flag: compute offset/scale automatically from the next input
93    /// array (C++ `NDPluginProcessAutoOffsetScale`). Cleared after it runs.
94    pub auto_offset_scale_pending: bool,
95    /// Read-only status: whether a valid background is loaded.
96    pub valid_background: bool,
97    /// Read-only status: whether a valid flat field is loaded.
98    pub valid_flat_field: bool,
99}
100
101impl Default for ProcessConfig {
102    fn default() -> Self {
103        Self {
104            enable_background: false,
105            enable_flat_field: false,
106            enable_offset_scale: false,
107            offset: 0.0,
108            scale: 1.0,
109            enable_low_clip: false,
110            low_clip_thresh: 0.0,
111            low_clip_value: 0.0,
112            enable_high_clip: false,
113            high_clip_thresh: 100.0,
114            high_clip_value: 100.0,
115            scale_flat_field: 255.0,
116            enable_filter: false,
117            filter: FilterConfig::default(),
118            output_type: None,
119            save_background: false,
120            save_flat_field: false,
121            auto_offset_scale_pending: false,
122            valid_background: false,
123            valid_flat_field: false,
124        }
125    }
126}
127
128/// State for the process plugin (holds background, flat field, and filter state).
129///
130/// Matches the C++ NDPluginProcess which uses a single `pFilter` array.
131pub struct ProcessState {
132    pub config: ProcessConfig,
133    pub background: Option<Vec<f64>>,
134    pub flat_field: Option<Vec<f64>>,
135    /// Single filter buffer (equivalent to C++ `pFilter`).
136    pub filter_state: Option<Vec<f64>>,
137    /// Number of frames filtered since last reset.
138    pub num_filtered: usize,
139}
140
141impl ProcessState {
142    pub fn new(config: ProcessConfig) -> Self {
143        Self {
144            config,
145            background: None,
146            flat_field: None,
147            filter_state: None,
148            num_filtered: 0,
149        }
150    }
151
152    /// Save the current array as background.
153    pub fn save_background(&mut self, array: &NDArray) {
154        let n = array.data.len();
155        let mut bg = vec![0.0f64; n];
156        for i in 0..n {
157            bg[i] = array.data.get_as_f64(i).unwrap_or(0.0);
158        }
159        self.background = Some(bg);
160        self.config.valid_background = true;
161    }
162
163    /// Save the current array as flat field.
164    pub fn save_flat_field(&mut self, array: &NDArray) {
165        let n = array.data.len();
166        let mut ff = vec![0.0f64; n];
167        for i in 0..n {
168            ff[i] = array.data.get_as_f64(i).unwrap_or(0.0);
169        }
170        self.flat_field = Some(ff);
171        self.config.valid_flat_field = true;
172    }
173
174    /// Auto-calculate offset and scale matching C++ NDPluginProcess.
175    ///
176    /// C++: scale = maxScale / (maxValue - minValue); offset = -minValue;
177    /// Also enables offset/scale processing and clipping (matching C++ lines 238-249).
178    pub fn auto_offset_scale(&mut self, array: &NDArray) {
179        let n = array.data.len();
180        if n == 0 {
181            return;
182        }
183        let mut min_val = f64::MAX;
184        let mut max_val = f64::MIN;
185        for i in 0..n {
186            let v = array.data.get_as_f64(i).unwrap_or(0.0);
187            if v < min_val {
188                min_val = v;
189            }
190            if v > max_val {
191                max_val = v;
192            }
193        }
194        let range = max_val - min_val;
195        if range > 0.0 {
196            // C++: maxScale = pow(2, bytesPerElement*8) - 1
197            let bytes_per_elem = match self.config.output_type.unwrap_or(array.data.data_type()) {
198                NDDataType::Int8 | NDDataType::UInt8 => 1,
199                NDDataType::Int16 | NDDataType::UInt16 => 2,
200                NDDataType::Int32 | NDDataType::UInt32 => 4,
201                NDDataType::Int64 | NDDataType::UInt64 => 8,
202                NDDataType::Float32 => 4,
203                NDDataType::Float64 => 8,
204            };
205            let max_scale = 2.0f64.powi(bytes_per_elem * 8) - 1.0;
206            // C++: scale = maxScale/(maxValue-minValue); offset = -minValue;
207            self.config.scale = max_scale / range;
208            self.config.offset = -min_val;
209            // C++ also enables offset/scale and clipping
210            self.config.enable_offset_scale = true;
211            self.config.enable_low_clip = true;
212            self.config.low_clip_thresh = 0.0;
213            self.config.enable_high_clip = true;
214            self.config.high_clip_thresh = max_scale;
215        }
216    }
217
218    /// Apply a named filter type preset, setting the FC/OC/RC coefficients.
219    ///
220    /// Uses the C++ coefficient scheme where:
221    ///   O1 = oScale * (oc[0] + oc[1]/N), O2 = oScale * (oc[2] + oc[3]/N)
222    ///   F1 = fScale * (fc[0] + fc[1]/N), F2 = fScale * (fc[2] + fc[3]/N)
223    ///   data[i]   = oOffset + O1*filter[i] + O2*data[i]
224    ///   filter[i] = fOffset + F1*filter[i] + F2*data[i]
225    pub fn apply_filter_type(&mut self, filter_type: i32) {
226        let fc = &mut self.config.filter;
227        match filter_type {
228            0 => {
229                // RecursiveAve: running average
230                // F1=fScale*(0 + 1/N)=1/N (old filter weight decreases)
231                // F2=fScale*(1 + -1/N)=(N-1)/N (new data weight increases)
232                // Actually: F[n]=(1-1/N)*F[n-1] + (1/N)*data[n]
233                //   fc1=0, fc2=1 → F1=fScale*(0+1/N)=1/N ← weight on filter
234                // Wait, the formula is: F2=fScale*(fc3+fc4/N)
235                // For recursive avg: filter = ((N-1)*filter + data)/N
236                //   F1 applied to filter: want (N-1)/N → fc1=1, fc2=-1
237                //     F1 = fScale*(1 + (-1)/N) = (N-1)/N ✓
238                //   F2 applied to data: want 1/N → fc3=0, fc4=1
239                //     F2 = fScale*(0 + 1/N) = 1/N ✓
240                // O1 applied to filter: want 1 → oc1=1, oc2=0
241                // O2 applied to data: want 0 → oc3=0, oc4=0
242                fc.fc = [1.0, -1.0, 0.0, 1.0];
243                fc.oc = [1.0, 0.0, 0.0, 0.0];
244                fc.rc = [0.0, 1.0]; // reset: filter = data
245                fc.r_offset = 0.0;
246                fc.f_offset = 0.0;
247                fc.f_scale = 1.0;
248                fc.o_offset = 0.0;
249                fc.o_scale = 1.0;
250            }
251            1 => {
252                // Average: accumulate sum in filter, output = filter/N
253                // filter = filter + data → F1=1*filter, F2=1*data
254                //   fc1=1,fc2=0 → F1=fScale*(1+0/N)=1; fc3=1,fc4=0 → F2=fScale*(1+0/N)=1
255                // output = filter/N → O1=1/N*filter
256                //   oc1=0,oc2=1 → O1=oScale*(0+1/N)=1/N; oc3=0,oc4=0 → O2=0
257                fc.fc = [1.0, 0.0, 1.0, 0.0];
258                fc.oc = [0.0, 1.0, 0.0, 0.0];
259                fc.rc = [0.0, 1.0]; // reset: filter = data
260                fc.r_offset = 0.0;
261                fc.f_offset = 0.0;
262                fc.f_scale = 1.0;
263                fc.o_offset = 0.0;
264                fc.o_scale = 1.0;
265            }
266            2 => {
267                // Sum: filter = filter + data, output = filter
268                fc.fc = [1.0, 0.0, 1.0, 0.0];
269                fc.oc = [1.0, 0.0, 0.0, 0.0];
270                fc.rc = [0.0, 1.0];
271                fc.r_offset = 0.0;
272                fc.f_offset = 0.0;
273                fc.f_scale = 1.0;
274                fc.o_offset = 0.0;
275                fc.o_scale = 1.0;
276            }
277            3 => {
278                // Difference: output = data - filter, filter = data
279                // O1=-1*filter, O2=1*data → oc1=-1,oc2=0,oc3=1,oc4=0
280                // F1=0, F2=1*data → fc1=0,fc2=0,fc3=1,fc4=0
281                fc.fc = [0.0, 0.0, 1.0, 0.0];
282                fc.oc = [-1.0, 0.0, 1.0, 0.0];
283                fc.rc = [0.0, 1.0];
284                fc.r_offset = 0.0;
285                fc.f_offset = 0.0;
286                fc.f_scale = 1.0;
287                fc.o_offset = 0.0;
288                fc.o_scale = 1.0;
289            }
290            4 => {
291                // RecursiveAveDiff: output = data - running_avg
292                // Same filter as RecursiveAve but output = data - filter
293                fc.fc = [1.0, -1.0, 0.0, 1.0];
294                fc.oc = [-1.0, 0.0, 1.0, 0.0];
295                fc.rc = [0.0, 1.0];
296                fc.r_offset = 0.0;
297                fc.f_offset = 0.0;
298                fc.f_scale = 1.0;
299                fc.o_offset = 0.0;
300                fc.o_scale = 1.0;
301            }
302            5 => {
303                // CopyToFilter: filter = data, output = filter
304                fc.fc = [0.0, 0.0, 1.0, 0.0];
305                fc.oc = [1.0, 0.0, 0.0, 0.0];
306                fc.rc = [0.0, 1.0];
307                fc.r_offset = 0.0;
308                fc.f_offset = 0.0;
309                fc.f_scale = 1.0;
310                fc.o_offset = 0.0;
311                fc.o_scale = 1.0;
312            }
313            _ => {} // Unknown type — leave coefficients unchanged
314        }
315    }
316
317    /// Reset the filter state, clearing the filter buffer.
318    pub fn reset_filter(&mut self) {
319        self.filter_state = None;
320        self.num_filtered = 0;
321    }
322
323    /// Process an array through the configured pipeline.
324    /// Process one input array.
325    ///
326    /// Returns `Some(output)` for a normal frame, or `None` when the frame is
327    /// suppressed by the recursive-filter `filter_callbacks` setting (C++ sets
328    /// `doCallbacks = 0` and the frame is dropped — nothing goes downstream).
329    pub fn process(&mut self, src: &NDArray) -> Option<NDArray> {
330        let n = src.data.len();
331        let mut values = vec![0.0f64; n];
332        for i in 0..n {
333            values[i] = src.data.get_as_f64(i).unwrap_or(0.0);
334        }
335
336        // 0. Save background/flat field (one-shot flags)
337        if self.config.save_background {
338            self.save_background(src);
339            self.config.save_background = false;
340        }
341        if self.config.save_flat_field {
342            self.save_flat_field(src);
343            self.config.save_flat_field = false;
344        }
345        // 0b. Auto offset/scale (one-shot): C MEASURES this frame's min/max and
346        // ARMS scale/offset + clipping for the NEXT frame — the trigger frame
347        // itself is emitted with the pre-existing config, NOT the derived scale
348        // (NDPluginProcess.cpp:164-178 only updates min/max; 238-250 arms the
349        // params after the output array is built). Consume the one-shot here and
350        // defer the arming until after this frame's output is produced.
351        let auto_offset_scale_now = self.config.auto_offset_scale_pending;
352        self.config.auto_offset_scale_pending = false;
353
354        // Recompute valid background / flat field each frame from the element
355        // count (C NDPluginProcess.cpp:120-125): a saved buffer is usable only
356        // when its length matches the current frame. A size mismatch
357        // invalidates it — the buffer is dropped entirely, never applied to a
358        // matching prefix.
359        self.config.valid_background = self.background.as_ref().is_some_and(|b| b.len() == n);
360        self.config.valid_flat_field = self.flat_field.as_ref().is_some_and(|f| f.len() == n);
361
362        // Stages 1-4: element-wise operations (background, flat field, offset+scale, clipping)
363        // These can be combined into a single pass and parallelized.
364        let needs_element_ops = self.config.enable_background
365            || self.config.enable_flat_field
366            || self.config.enable_offset_scale
367            || self.config.enable_low_clip
368            || self.config.enable_high_clip;
369
370        if needs_element_ops {
371            // C only takes the background/flat-field pointer when the buffer is
372            // BOTH enabled AND valid for this frame (NDPluginProcess.cpp:127-130).
373            let bg = if self.config.enable_background && self.config.valid_background {
374                self.background.as_ref()
375            } else {
376                None
377            };
378            let (ff, ff_scale) = if self.config.enable_flat_field && self.config.valid_flat_field {
379                if let Some(ref ff) = self.flat_field {
380                    // C++: value *= scaleFlatField / flatField[i]
381                    // (NDPluginProcess.cpp:172). scaleFlatField is used directly
382                    // — there is no mean substitution when it is <= 0.
383                    (Some(ff.as_slice()), self.config.scale_flat_field)
384                } else {
385                    (None, 0.0)
386                }
387            } else {
388                (None, 0.0)
389            };
390            let do_offset_scale = self.config.enable_offset_scale;
391            let scale = self.config.scale;
392            let offset = self.config.offset;
393            let do_low_clip = self.config.enable_low_clip;
394            let low_clip_thresh = self.config.low_clip_thresh;
395            let low_clip_value = self.config.low_clip_value;
396            let do_high_clip = self.config.enable_high_clip;
397            let high_clip_thresh = self.config.high_clip_thresh;
398            let high_clip_value = self.config.high_clip_value;
399
400            let apply_stages = |i: usize, v: &mut f64| {
401                // Stage 1: Background subtraction. bg.len() == n is guaranteed by
402                // the validity gate above, so index directly (C subtracts
403                // background[i] unconditionally for every element).
404                if let Some(bg) = bg {
405                    *v -= bg[i];
406                }
407                // Stage 2: Flat field normalization
408                if let Some(ff) = ff {
409                    if ff[i] != 0.0 {
410                        *v = *v * ff_scale / ff[i];
411                    }
412                }
413                // Stage 3: Offset + scale (C++: value = (value + offset) * scale)
414                if do_offset_scale {
415                    *v = (*v + offset) * scale;
416                }
417                // Stage 4: Clipping — C applies high-clip THEN low-clip
418                // (NDPluginProcess.cpp:175-176). When the two thresholds cross
419                // (high < low) the order changes the result, so it must match.
420                if do_high_clip && *v > high_clip_thresh {
421                    *v = high_clip_value;
422                }
423                if do_low_clip && *v < low_clip_thresh {
424                    *v = low_clip_value;
425                }
426            };
427
428            #[cfg(feature = "parallel")]
429            let use_parallel = par_util::should_parallelize(n);
430            #[cfg(not(feature = "parallel"))]
431            let use_parallel = false;
432
433            if use_parallel {
434                #[cfg(feature = "parallel")]
435                par_util::thread_pool().install(|| {
436                    values.par_iter_mut().enumerate().for_each(|(i, v)| {
437                        apply_stages(i, v);
438                    });
439                });
440            } else {
441                for (i, v) in values.iter_mut().enumerate() {
442                    apply_stages(i, v);
443                }
444            }
445        }
446
447        // 5. Recursive filter (matching C++ NDPluginProcess algorithm)
448        if self.config.enable_filter {
449            let fc = &self.config.filter;
450
451            // Ensure filter buffer exists and matches element count
452            if let Some(ref f) = self.filter_state {
453                if f.len() != n {
454                    self.filter_state = None;
455                }
456            }
457
458            let mut reset_filter = self.filter_state.is_none();
459            if self.num_filtered >= fc.num_filter && fc.auto_reset {
460                reset_filter = true;
461            }
462
463            // Initialize filter buffer from current data if needed
464            if self.filter_state.is_none() {
465                self.filter_state = Some(values.clone());
466            }
467
468            let filter = self.filter_state.as_mut().unwrap();
469
470            if reset_filter {
471                // C++: filter[i] = rOffset + rc1*filter[i] + rc2*data[i]
472                let r_offset = fc.r_offset;
473                let rc1 = fc.rc[0];
474                let rc2 = fc.rc[1];
475                for i in 0..n {
476                    let new_filter = r_offset + rc1 * filter[i] + rc2 * values[i];
477                    filter[i] = new_filter;
478                }
479                self.num_filtered = 0;
480            }
481
482            // Increment filtered count (C++: if (numFiltered < numFilter) numFiltered++)
483            if self.num_filtered < fc.num_filter {
484                self.num_filtered += 1;
485            }
486
487            // Compute effective coefficients (depend on numFiltered)
488            let nf = self.num_filtered as f64;
489            let o1 = fc.o_scale * (fc.oc[0] + fc.oc[1] / nf);
490            let o2 = fc.o_scale * (fc.oc[2] + fc.oc[3] / nf);
491            let f1 = fc.f_scale * (fc.fc[0] + fc.fc[1] / nf);
492            let f2 = fc.f_scale * (fc.fc[2] + fc.fc[3] / nf);
493            let o_offset = fc.o_offset;
494            let f_offset = fc.f_offset;
495
496            // C++ NDPluginProcess.cpp:220-227 doProcess:
497            //   newData   = oOffset + O1*filter[i] + O2*data[i];
498            //   newFilter = fOffset + F1*filter[i] + F2*data[i];
499            //   data[i]   = newData;
500            //   filter[i] = newFilter;
501            // Both newData AND newFilter are computed from the ORIGINAL
502            // data[i]; data[i] = newData is assigned only afterward. So the
503            // filter-state update must use the original input, not new_data.
504            for i in 0..n {
505                let new_data = o_offset + o1 * filter[i] + o2 * values[i];
506                let new_filter = f_offset + f1 * filter[i] + f2 * values[i];
507                values[i] = new_data;
508                filter[i] = new_filter;
509            }
510
511            // Suppress output if filterCallbacks is set and we haven't reached
512            // numFilter. C++ sets doCallbacks = 0 and does NOT call
513            // endProcessCallbacks — the frame is dropped, nothing goes
514            // downstream (the unprocessed input is NOT forwarded).
515            if fc.filter_callbacks > 0 && self.num_filtered != fc.num_filter {
516                return None;
517            }
518        }
519
520        // Build output
521        let out_type = self.config.output_type.unwrap_or(src.data.data_type());
522        let mut out_data = NDDataBuffer::zeros(out_type, n);
523        for i in 0..n {
524            out_data.set_from_f64(i, values[i]);
525        }
526
527        let mut arr = NDArray::new(src.dims.clone(), out_type);
528        arr.data = out_data;
529        arr.unique_id = src.unique_id;
530        arr.timestamp = src.timestamp;
531        arr.attributes = src.attributes.clone();
532
533        // Arm auto offset/scale from THIS frame's data for the NEXT frame
534        // (C NDPluginProcess.cpp:238-250 runs after the output array is built).
535        // Only on the emitted-output path: a suppressed frame produces no output
536        // array, so C (pArrayOut == NULL) does not arm either.
537        if auto_offset_scale_now {
538            self.auto_offset_scale(src);
539        }
540
541        Some(arr)
542    }
543}
544
545// --- ProcessProcessor (NDPluginProcess-based) ---
546
547/// Param indices for the process plugin.
548#[derive(Default)]
549struct ProcParamIndices {
550    data_type: Option<usize>,
551    save_background: Option<usize>,
552    enable_background: Option<usize>,
553    valid_background: Option<usize>,
554    save_flat_field: Option<usize>,
555    enable_flat_field: Option<usize>,
556    valid_flat_field: Option<usize>,
557    scale_flat_field: Option<usize>,
558    enable_offset_scale: Option<usize>,
559    auto_offset_scale: Option<usize>,
560    offset: Option<usize>,
561    scale: Option<usize>,
562    enable_low_clip: Option<usize>,
563    low_clip_thresh: Option<usize>,
564    low_clip_value: Option<usize>,
565    enable_high_clip: Option<usize>,
566    high_clip_thresh: Option<usize>,
567    high_clip_value: Option<usize>,
568    enable_filter: Option<usize>,
569    filter_type: Option<usize>,
570    reset_filter: Option<usize>,
571    auto_reset_filter: Option<usize>,
572    filter_callbacks: Option<usize>,
573    num_filter: Option<usize>,
574    num_filtered: Option<usize>,
575    o_offset: Option<usize>,
576    o_scale: Option<usize>,
577    oc: [Option<usize>; 4],
578    f_offset: Option<usize>,
579    f_scale: Option<usize>,
580    fc: [Option<usize>; 4],
581    r_offset: Option<usize>,
582    rc: [Option<usize>; 2],
583}
584
585/// ProcessProcessor wraps existing ProcessState.
586pub struct ProcessProcessor {
587    state: ProcessState,
588    params: ProcParamIndices,
589}
590
591impl ProcessProcessor {
592    pub fn new(config: ProcessConfig) -> Self {
593        Self {
594            state: ProcessState::new(config),
595            params: ProcParamIndices::default(),
596        }
597    }
598
599    pub fn state(&self) -> &ProcessState {
600        &self.state
601    }
602
603    pub fn state_mut(&mut self) -> &mut ProcessState {
604        &mut self.state
605    }
606}
607
608impl NDPluginProcess for ProcessProcessor {
609    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
610        use ad_core_rs::plugin::runtime::ParamUpdate;
611
612        let out = self.state.process(array);
613        // A suppressed frame (filter_callbacks) produces no output array but
614        // still publishes readback params.
615        let mut result = match out {
616            Some(arr) => ProcessResult::arrays(vec![Arc::new(arr)]),
617            None => ProcessResult::sink(vec![]),
618        };
619
620        // Push readback params
621        if let Some(idx) = self.params.valid_background {
622            result.param_updates.push(ParamUpdate::int32(
623                idx,
624                if self.state.config.valid_background {
625                    1
626                } else {
627                    0
628                },
629            ));
630        }
631        if let Some(idx) = self.params.valid_flat_field {
632            result.param_updates.push(ParamUpdate::int32(
633                idx,
634                if self.state.config.valid_flat_field {
635                    1
636                } else {
637                    0
638                },
639            ));
640        }
641        if let Some(idx) = self.params.num_filtered {
642            result
643                .param_updates
644                .push(ParamUpdate::int32(idx, self.state.num_filtered as i32));
645        }
646        // Reset save_background/save_flat_field readback to 0 (one-shot)
647        if let Some(idx) = self.params.save_background {
648            result.param_updates.push(ParamUpdate::int32(idx, 0));
649        }
650        if let Some(idx) = self.params.save_flat_field {
651            result.param_updates.push(ParamUpdate::int32(idx, 0));
652        }
653
654        result
655    }
656
657    fn plugin_type(&self) -> &str {
658        "NDPluginProcess"
659    }
660
661    fn register_params(
662        &mut self,
663        base: &mut asyn_rs::port::PortDriverBase,
664    ) -> asyn_rs::error::AsynResult<()> {
665        use asyn_rs::param::ParamType;
666        base.create_param("PROCESS_DATA_TYPE", ParamType::Int32)?;
667        base.create_param("SAVE_BACKGROUND", ParamType::Int32)?;
668        base.create_param("ENABLE_BACKGROUND", ParamType::Int32)?;
669        base.create_param("VALID_BACKGROUND", ParamType::Int32)?;
670        base.create_param("SAVE_FLAT_FIELD", ParamType::Int32)?;
671        base.create_param("ENABLE_FLAT_FIELD", ParamType::Int32)?;
672        base.create_param("VALID_FLAT_FIELD", ParamType::Int32)?;
673        base.create_param("SCALE_FLAT_FIELD", ParamType::Float64)?;
674        base.create_param("ENABLE_OFFSET_SCALE", ParamType::Int32)?;
675        base.create_param("AUTO_OFFSET_SCALE", ParamType::Int32)?;
676        base.create_param("OFFSET", ParamType::Float64)?;
677        base.create_param("SCALE", ParamType::Float64)?;
678        base.create_param("ENABLE_LOW_CLIP", ParamType::Int32)?;
679        base.create_param("LOW_CLIP_THRESH", ParamType::Float64)?;
680        base.create_param("LOW_CLIP_VALUE", ParamType::Float64)?;
681        base.create_param("ENABLE_HIGH_CLIP", ParamType::Int32)?;
682        base.create_param("HIGH_CLIP_THRESH", ParamType::Float64)?;
683        base.create_param("HIGH_CLIP_VALUE", ParamType::Float64)?;
684        base.create_param("ENABLE_FILTER", ParamType::Int32)?;
685        base.create_param("FILTER_TYPE", ParamType::Int32)?;
686        base.create_param("RESET_FILTER", ParamType::Int32)?;
687        base.create_param("AUTO_RESET_FILTER", ParamType::Int32)?;
688        base.create_param("FILTER_CALLBACKS", ParamType::Int32)?;
689        base.create_param("NUM_FILTER", ParamType::Int32)?;
690        base.create_param("NUM_FILTERED", ParamType::Int32)?;
691        base.create_param("FILTER_OOFFSET", ParamType::Float64)?;
692        base.create_param("FILTER_OSCALE", ParamType::Float64)?;
693        base.create_param("FILTER_OC1", ParamType::Float64)?;
694        base.create_param("FILTER_OC2", ParamType::Float64)?;
695        base.create_param("FILTER_OC3", ParamType::Float64)?;
696        base.create_param("FILTER_OC4", ParamType::Float64)?;
697        base.create_param("FILTER_FOFFSET", ParamType::Float64)?;
698        base.create_param("FILTER_FSCALE", ParamType::Float64)?;
699        base.create_param("FILTER_FC1", ParamType::Float64)?;
700        base.create_param("FILTER_FC2", ParamType::Float64)?;
701        base.create_param("FILTER_FC3", ParamType::Float64)?;
702        base.create_param("FILTER_FC4", ParamType::Float64)?;
703        base.create_param("FILTER_ROFFSET", ParamType::Float64)?;
704        base.create_param("FILTER_RC1", ParamType::Float64)?;
705        base.create_param("FILTER_RC2", ParamType::Float64)?;
706
707        // Look up param indices
708        self.params.data_type = base.find_param("PROCESS_DATA_TYPE");
709        self.params.save_background = base.find_param("SAVE_BACKGROUND");
710        self.params.enable_background = base.find_param("ENABLE_BACKGROUND");
711        self.params.valid_background = base.find_param("VALID_BACKGROUND");
712        self.params.save_flat_field = base.find_param("SAVE_FLAT_FIELD");
713        self.params.enable_flat_field = base.find_param("ENABLE_FLAT_FIELD");
714        self.params.valid_flat_field = base.find_param("VALID_FLAT_FIELD");
715        self.params.scale_flat_field = base.find_param("SCALE_FLAT_FIELD");
716        self.params.enable_offset_scale = base.find_param("ENABLE_OFFSET_SCALE");
717        self.params.auto_offset_scale = base.find_param("AUTO_OFFSET_SCALE");
718        self.params.offset = base.find_param("OFFSET");
719        self.params.scale = base.find_param("SCALE");
720        self.params.enable_low_clip = base.find_param("ENABLE_LOW_CLIP");
721        self.params.low_clip_thresh = base.find_param("LOW_CLIP_THRESH");
722        self.params.low_clip_value = base.find_param("LOW_CLIP_VALUE");
723        self.params.enable_high_clip = base.find_param("ENABLE_HIGH_CLIP");
724        self.params.high_clip_thresh = base.find_param("HIGH_CLIP_THRESH");
725        self.params.high_clip_value = base.find_param("HIGH_CLIP_VALUE");
726        self.params.enable_filter = base.find_param("ENABLE_FILTER");
727        self.params.filter_type = base.find_param("FILTER_TYPE");
728        self.params.reset_filter = base.find_param("RESET_FILTER");
729        self.params.auto_reset_filter = base.find_param("AUTO_RESET_FILTER");
730        self.params.filter_callbacks = base.find_param("FILTER_CALLBACKS");
731        self.params.num_filter = base.find_param("NUM_FILTER");
732        self.params.num_filtered = base.find_param("NUM_FILTERED");
733        self.params.o_offset = base.find_param("FILTER_OOFFSET");
734        self.params.o_scale = base.find_param("FILTER_OSCALE");
735        self.params.oc[0] = base.find_param("FILTER_OC1");
736        self.params.oc[1] = base.find_param("FILTER_OC2");
737        self.params.oc[2] = base.find_param("FILTER_OC3");
738        self.params.oc[3] = base.find_param("FILTER_OC4");
739        self.params.f_offset = base.find_param("FILTER_FOFFSET");
740        self.params.f_scale = base.find_param("FILTER_FSCALE");
741        self.params.fc[0] = base.find_param("FILTER_FC1");
742        self.params.fc[1] = base.find_param("FILTER_FC2");
743        self.params.fc[2] = base.find_param("FILTER_FC3");
744        self.params.fc[3] = base.find_param("FILTER_FC4");
745        self.params.r_offset = base.find_param("FILTER_ROFFSET");
746        self.params.rc[0] = base.find_param("FILTER_RC1");
747        self.params.rc[1] = base.find_param("FILTER_RC2");
748        Ok(())
749    }
750
751    fn on_param_change(
752        &mut self,
753        reason: usize,
754        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
755    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
756        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamUpdate};
757
758        let s = &mut self.state;
759        let p = &self.params;
760        let mut updates = Vec::new();
761
762        if Some(reason) == p.data_type {
763            let v = params.value.as_i32();
764            s.config.output_type = if v < 0 {
765                None // Automatic
766            } else {
767                NDDataType::from_ordinal(v as u8)
768            };
769        } else if Some(reason) == p.save_background {
770            if params.value.as_i32() != 0 {
771                s.config.save_background = true;
772            }
773        } else if Some(reason) == p.enable_background {
774            s.config.enable_background = params.value.as_i32() != 0;
775        } else if Some(reason) == p.save_flat_field {
776            if params.value.as_i32() != 0 {
777                s.config.save_flat_field = true;
778            }
779        } else if Some(reason) == p.enable_flat_field {
780            s.config.enable_flat_field = params.value.as_i32() != 0;
781        } else if Some(reason) == p.scale_flat_field {
782            s.config.scale_flat_field = params.value.as_f64();
783        } else if Some(reason) == p.enable_offset_scale {
784            s.config.enable_offset_scale = params.value.as_i32() != 0;
785        } else if Some(reason) == p.auto_offset_scale {
786            if params.value.as_i32() != 0 {
787                // Arm the one-shot: auto_offset_scale() runs on the next
788                // process() call (it needs an NDArray to read the data
789                // range). C++ resets NDPluginProcessAutoOffsetScale to 0
790                // after handling, so echo a 0 readback here.
791                s.config.auto_offset_scale_pending = true;
792                if let Some(idx) = p.auto_offset_scale {
793                    updates.push(ParamUpdate::int32(idx, 0));
794                }
795            }
796        } else if Some(reason) == p.offset {
797            s.config.offset = params.value.as_f64();
798        } else if Some(reason) == p.scale {
799            s.config.scale = params.value.as_f64();
800        } else if Some(reason) == p.enable_low_clip {
801            s.config.enable_low_clip = params.value.as_i32() != 0;
802        } else if Some(reason) == p.low_clip_thresh {
803            s.config.low_clip_thresh = params.value.as_f64();
804        } else if Some(reason) == p.low_clip_value {
805            s.config.low_clip_value = params.value.as_f64();
806        } else if Some(reason) == p.enable_high_clip {
807            s.config.enable_high_clip = params.value.as_i32() != 0;
808        } else if Some(reason) == p.high_clip_thresh {
809            s.config.high_clip_thresh = params.value.as_f64();
810        } else if Some(reason) == p.high_clip_value {
811            s.config.high_clip_value = params.value.as_f64();
812        } else if Some(reason) == p.enable_filter {
813            s.config.enable_filter = params.value.as_i32() != 0;
814        } else if Some(reason) == p.filter_type {
815            s.apply_filter_type(params.value.as_i32());
816            s.reset_filter();
817            // Push updated coefficients back
818            let fc = &s.config.filter;
819            for (i, idx) in p.fc.iter().enumerate() {
820                if let Some(idx) = *idx {
821                    updates.push(ParamUpdate::float64(idx, fc.fc[i]));
822                }
823            }
824            for (i, idx) in p.oc.iter().enumerate() {
825                if let Some(idx) = *idx {
826                    updates.push(ParamUpdate::float64(idx, fc.oc[i]));
827                }
828            }
829            for (i, idx) in p.rc.iter().enumerate() {
830                if let Some(idx) = *idx {
831                    updates.push(ParamUpdate::float64(idx, fc.rc[i]));
832                }
833            }
834            if let Some(idx) = p.f_offset {
835                updates.push(ParamUpdate::float64(idx, fc.f_offset));
836            }
837            if let Some(idx) = p.f_scale {
838                updates.push(ParamUpdate::float64(idx, fc.f_scale));
839            }
840            if let Some(idx) = p.o_offset {
841                updates.push(ParamUpdate::float64(idx, fc.o_offset));
842            }
843            if let Some(idx) = p.o_scale {
844                updates.push(ParamUpdate::float64(idx, fc.o_scale));
845            }
846        } else if Some(reason) == p.reset_filter {
847            if params.value.as_i32() != 0 {
848                s.reset_filter();
849                if let Some(idx) = p.num_filtered {
850                    updates.push(ParamUpdate::int32(idx, 0));
851                }
852            }
853        } else if Some(reason) == p.auto_reset_filter {
854            s.config.filter.auto_reset = params.value.as_i32() != 0;
855        } else if Some(reason) == p.filter_callbacks {
856            s.config.filter.filter_callbacks = params.value.as_i32().max(0) as usize;
857        } else if Some(reason) == p.num_filter {
858            s.config.filter.num_filter = params.value.as_i32().max(1) as usize;
859        } else if Some(reason) == p.o_offset {
860            s.config.filter.o_offset = params.value.as_f64();
861        } else if Some(reason) == p.o_scale {
862            s.config.filter.o_scale = params.value.as_f64();
863        } else if Some(reason) == p.f_offset {
864            s.config.filter.f_offset = params.value.as_f64();
865        } else if Some(reason) == p.f_scale {
866            s.config.filter.f_scale = params.value.as_f64();
867        } else if Some(reason) == p.r_offset {
868            s.config.filter.r_offset = params.value.as_f64();
869        } else {
870            // Check individual OC/FC/RC params
871            for i in 0..4 {
872                if Some(reason) == p.oc[i] {
873                    s.config.filter.oc[i] = params.value.as_f64();
874                    return ParamChangeResult::updates(vec![]);
875                }
876                if Some(reason) == p.fc[i] {
877                    s.config.filter.fc[i] = params.value.as_f64();
878                    return ParamChangeResult::updates(vec![]);
879                }
880            }
881            for i in 0..2 {
882                if Some(reason) == p.rc[i] {
883                    s.config.filter.rc[i] = params.value.as_f64();
884                    return ParamChangeResult::updates(vec![]);
885                }
886            }
887        }
888
889        ParamChangeResult::updates(updates)
890    }
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
897
898    fn make_array(vals: &[u8]) -> NDArray {
899        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::UInt8);
900        if let NDDataBuffer::U8(ref mut v) = arr.data {
901            v.copy_from_slice(vals);
902        }
903        arr
904    }
905
906    fn make_f64_array(vals: &[f64]) -> NDArray {
907        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::Float64);
908        if let NDDataBuffer::F64(ref mut v) = arr.data {
909            v.copy_from_slice(vals);
910        }
911        arr
912    }
913
914    #[test]
915    fn test_background_subtraction() {
916        let bg_arr = make_array(&[10, 20, 30]);
917        let input = make_array(&[15, 25, 35]);
918
919        let mut state = ProcessState::new(ProcessConfig {
920            enable_background: true,
921            ..Default::default()
922        });
923        state.save_background(&bg_arr);
924
925        let result = state.process(&input).unwrap();
926        if let NDDataBuffer::U8(ref v) = result.data {
927            assert_eq!(v[0], 5);
928            assert_eq!(v[1], 5);
929            assert_eq!(v[2], 5);
930        }
931    }
932
933    #[test]
934    fn test_adp7_size_mismatched_background_invalidated_not_partial() {
935        // C recomputes validBackground each frame as (pBackground && nElements ==
936        // nBackgroundElements) (NDPluginProcess.cpp:121). A size mismatch
937        // invalidates the whole buffer — it is NOT applied to the matching
938        // prefix.
939        let bg_arr = make_array(&[10, 20]); // 2 elements
940        let input = make_array(&[15, 25, 35]); // 3 elements
941        let mut state = ProcessState::new(ProcessConfig {
942            enable_background: true,
943            ..Default::default()
944        });
945        state.save_background(&bg_arr);
946        assert!(state.config.valid_background); // set at save time (C writeInt32)
947
948        let result = state.process(&input).unwrap();
949        // Size mismatch → background ignored → output unchanged; valid recomputed
950        // false at process time.
951        assert!(!state.config.valid_background);
952        if let NDDataBuffer::U8(ref v) = result.data {
953            assert_eq!(v, &[15, 25, 35]);
954        } else {
955            panic!("expected U8 output");
956        }
957    }
958
959    #[test]
960    fn test_flat_field() {
961        // C++: value *= scaleFlatField / flatField[i] (NDPluginProcess.cpp:172).
962        // scaleFlatField is used directly (no mean substitution).
963        let ff_arr = make_array(&[100, 200, 50]);
964        let input = make_array(&[100, 100, 100]);
965
966        let mut state = ProcessState::new(ProcessConfig {
967            enable_flat_field: true,
968            scale_flat_field: 100.0,
969            ..Default::default()
970        });
971        state.save_flat_field(&ff_arr);
972
973        let result = state.process(&input).unwrap();
974        if let NDDataBuffer::U8(ref v) = result.data {
975            assert_eq!(v[0], 100); // 100*100/100
976            assert_eq!(v[1], 50); //  100*100/200
977            assert_eq!(v[2], 200); // 100*100/50
978        } else {
979            panic!("expected U8 output");
980        }
981    }
982
983    #[test]
984    fn test_adp24_scale_flat_field_zero_zeroes_output() {
985        // C uses scaleFlatField directly: value *= scaleFlatField/flatField[i].
986        // With scaleFlatField == 0 every pixel (whose flatField != 0) becomes 0
987        // — there is NO mean substitution (NDPluginProcess.cpp:171-172).
988        let ff_arr = make_array(&[100, 200, 50]);
989        let input = make_array(&[100, 100, 100]);
990        let mut state = ProcessState::new(ProcessConfig {
991            enable_flat_field: true,
992            scale_flat_field: 0.0,
993            ..Default::default()
994        });
995        state.save_flat_field(&ff_arr);
996        let result = state.process(&input).unwrap();
997        if let NDDataBuffer::U8(ref v) = result.data {
998            assert_eq!(v, &[0, 0, 0]);
999        } else {
1000            panic!("expected U8 output");
1001        }
1002    }
1003
1004    #[test]
1005    fn test_offset_scale() {
1006        let input = make_array(&[10, 20, 30]);
1007        let mut state = ProcessState::new(ProcessConfig {
1008            enable_offset_scale: true,
1009            scale: 2.0,
1010            offset: 5.0,
1011            ..Default::default()
1012        });
1013
1014        let result = state.process(&input).unwrap();
1015        if let NDDataBuffer::U8(ref v) = result.data {
1016            // C++: value = (value + offset) * scale
1017            assert_eq!(v[0], 30); // (10+5)*2
1018            assert_eq!(v[1], 50); // (20+5)*2
1019            assert_eq!(v[2], 70); // (30+5)*2
1020        }
1021    }
1022
1023    #[test]
1024    fn test_clipping() {
1025        let input = make_array(&[5, 50, 200]);
1026        let mut state = ProcessState::new(ProcessConfig {
1027            enable_low_clip: true,
1028            low_clip_thresh: 10.0,
1029            low_clip_value: 10.0,
1030            enable_high_clip: true,
1031            high_clip_thresh: 100.0,
1032            high_clip_value: 100.0,
1033            ..Default::default()
1034        });
1035
1036        let result = state.process(&input).unwrap();
1037        if let NDDataBuffer::U8(ref v) = result.data {
1038            assert_eq!(v[0], 10); // clipped up
1039            assert_eq!(v[1], 50); // unchanged
1040            assert_eq!(v[2], 100); // clipped down
1041        }
1042    }
1043
1044    #[test]
1045    fn test_adp5_clip_order_high_before_low() {
1046        // C applies high-clip THEN low-clip (NDPluginProcess.cpp:175-176). With
1047        // crossing thresholds (high < low) the order is observable:
1048        //   v=200 → high(>100 ⇒ 10) → low(<50 ⇒ 999) ⇒ 999
1049        // Low-then-high would instead give 200 → (not <50) → high(>100 ⇒ 10) ⇒ 10.
1050        let input = make_f64_array(&[200.0]);
1051        let mut state = ProcessState::new(ProcessConfig {
1052            enable_high_clip: true,
1053            high_clip_thresh: 100.0,
1054            high_clip_value: 10.0,
1055            enable_low_clip: true,
1056            low_clip_thresh: 50.0,
1057            low_clip_value: 999.0,
1058            ..Default::default()
1059        });
1060        let result = state.process(&input).unwrap();
1061        if let NDDataBuffer::F64(ref v) = result.data {
1062            assert_eq!(v[0], 999.0);
1063        } else {
1064            panic!("expected F64 output");
1065        }
1066    }
1067
1068    #[test]
1069    fn test_recursive_filter() {
1070        // Test a simple recursive filter: filter = 0.5*filter + 0.5*data, output = filter
1071        // Using C++ coefficient scheme:
1072        //   F1 = fScale*(fc1+fc2/N), F2 = fScale*(fc3+fc4/N)
1073        //   For constant F1=0.5, F2=0.5 regardless of N:
1074        //   fc1=0.5, fc2=0, fc3=0.5, fc4=0
1075        let input1 = make_array(&[100, 100, 100]);
1076        let input2 = make_array(&[0, 0, 0]);
1077
1078        let mut state = ProcessState::new(ProcessConfig {
1079            enable_filter: true,
1080            filter: FilterConfig {
1081                num_filter: 10,
1082                fc: [0.5, 0.0, 0.5, 0.0], // F1=0.5, F2=0.5
1083                oc: [1.0, 0.0, 0.0, 0.0], // O1=1, O2=0
1084                rc: [0.0, 1.0],           // reset: filter = data
1085                ..Default::default()
1086            },
1087            ..Default::default()
1088        });
1089
1090        // C++ NDPluginProcess.cpp:220-227 doProcess recurrence:
1091        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1092        //   newFilter = fOffset + F1*filter[i] + F2*data[i];  // ORIGINAL data[i]
1093        //   data[i]   = newData;
1094        //   filter[i] = newFilter;
1095        //
1096        // Frame 0: reset: filter = 0 + 0*100 + 1*100 = 100
1097        // N=1: F1=0.5, F2=0.5, O1=1, O2=0
1098        // data   = 0 + 1*100 + 0*100 = 100
1099        // filter = 0 + 0.5*100 + 0.5*100(orig data) = 100
1100        let _ = state.process(&input1);
1101
1102        // Frame 1: data=0, filter=100
1103        // N=2: F1=0.5, F2=0.5, O1=1, O2=0
1104        // data   = 0 + 1*100 + 0*0 = 100
1105        // filter = 0 + 0.5*100 + 0.5*0(orig data) = 50
1106        let result = state.process(&input2).unwrap();
1107        if let NDDataBuffer::U8(ref v) = result.data {
1108            // Output is data = O1*filter = 1*100 = 100
1109            assert_eq!(v[0], 100);
1110            assert_eq!(v[1], 100);
1111        }
1112    }
1113
1114    #[test]
1115    fn test_output_type_conversion() {
1116        let input = make_array(&[10, 20, 30]);
1117        let mut state = ProcessState::new(ProcessConfig {
1118            output_type: Some(NDDataType::Float64),
1119            ..Default::default()
1120        });
1121
1122        let result = state.process(&input).unwrap();
1123        assert_eq!(result.data.data_type(), NDDataType::Float64);
1124    }
1125
1126    // --- ProcessProcessor tests ---
1127
1128    #[test]
1129    fn test_process_processor() {
1130        let mut proc = ProcessProcessor::new(ProcessConfig {
1131            enable_offset_scale: true,
1132            scale: 2.0,
1133            offset: 1.0,
1134            ..Default::default()
1135        });
1136        let pool = NDArrayPool::new(1_000_000);
1137
1138        let input = make_array(&[10, 20, 30]);
1139        let result = proc.process_array(&input, &pool);
1140        assert_eq!(result.output_arrays.len(), 1);
1141        if let NDDataBuffer::U8(ref v) = result.output_arrays[0].data {
1142            assert_eq!(v[0], 22); // (10+1)*2 = 22 (C++: offset first, then scale)
1143        }
1144    }
1145
1146    // --- New Phase 2-1 tests ---
1147
1148    #[test]
1149    fn test_filter_sum_preset() {
1150        // Sum preset: filter = filter + data, output = filter
1151        // fc=[1,0,1,0], oc=[1,0,0,0], rc=[0,1]
1152        let mut state = ProcessState::new(ProcessConfig {
1153            enable_filter: true,
1154            filter: FilterConfig {
1155                num_filter: 10,
1156                fc: [1.0, 0.0, 1.0, 0.0],
1157                oc: [1.0, 0.0, 0.0, 0.0],
1158                rc: [0.0, 1.0],
1159                ..Default::default()
1160            },
1161            output_type: Some(NDDataType::Float64),
1162            ..Default::default()
1163        });
1164
1165        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1166        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1167        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1168        //   data[i]   = newData; filter[i] = newFilter;
1169        //
1170        // Frame 0: reset first: filter = rOffset + rc1*filter + rc2*data
1171        //          = 0 + 0*100 + 1*100 = 100. Then N increments to 1, normal path:
1172        // F1=fScale*(fc1+fc2/N)=1*(1+0/1)=1, F2=fScale*(fc3+fc4/N)=1*(1+0/1)=1
1173        // O1=oScale*(oc1+oc2/N)=1*(1+0/1)=1, O2=oScale*(oc3+oc4/N)=1*(0+0/1)=0
1174        // data   = oOffset + O1*filter + O2*data = 0 + 1*100 + 0*100 = 100
1175        // filter = fOffset + F1*filter + F2*data(orig=100) = 0 + 1*100 + 1*100 = 200
1176        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1177        let v0 = r0.data.get_as_f64(0).unwrap();
1178        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1179
1180        // Frame 1: data=100, filter=200 (from prev)
1181        // N increments to 2
1182        // F1=1*(1+0/2)=1, F2=1*(1+0/2)=1
1183        // O1=1*(1+0/2)=1, O2=0
1184        // data   = 0 + 1*200 + 0*100 = 200
1185        // filter = 0 + 1*200 + 1*data(orig=100) = 300
1186        let r1 = state.process(&make_f64_array(&[100.0])).unwrap();
1187        let v1 = r1.data.get_as_f64(0).unwrap();
1188        assert!((v1 - 200.0).abs() < 1e-9, "frame 1: got {v1}");
1189    }
1190
1191    #[test]
1192    fn test_filter_average_preset() {
1193        // Average preset: accumulate in filter, output = filter/N
1194        // fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1]
1195        let mut state = ProcessState::new(ProcessConfig {
1196            enable_filter: true,
1197            filter: FilterConfig {
1198                num_filter: 10,
1199                fc: [1.0, 0.0, 1.0, 0.0],
1200                oc: [0.0, 1.0, 0.0, 0.0],
1201                rc: [0.0, 1.0],
1202                ..Default::default()
1203            },
1204            output_type: Some(NDDataType::Float64),
1205            ..Default::default()
1206        });
1207
1208        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1209        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1210        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1211        //   data[i]   = newData; filter[i] = newFilter;
1212        //
1213        // Frame 0 (reset): filter=100. N=1: O1=oScale*(0+1/1)=1, O2=0
1214        // data   = 0 + 1*100 + 0 = 100
1215        // filter = 0 + 1*100 + 1*100(orig data) = 200
1216        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1217        let v0 = r0.data.get_as_f64(0).unwrap();
1218        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1219
1220        // Frame 1: data=200, filter=200
1221        // N=2: O1=oScale*(0+1/2)=0.5, O2=0
1222        // data   = 0 + 0.5*200 + 0 = 100
1223        // filter = 0 + 1*200 + 1*200(orig data) = 400
1224        let r1 = state.process(&make_f64_array(&[200.0])).unwrap();
1225        let v1 = r1.data.get_as_f64(0).unwrap();
1226        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1227
1228        // Frame 2: data=300, filter=400
1229        // N=3: O1=1/3, O2=0
1230        // data   = 0 + (1/3)*400 + 0 = 400/3
1231        // filter = 0 + 1*400 + 1*300(orig data) = 700
1232        let r2 = state.process(&make_f64_array(&[300.0])).unwrap();
1233        let v2 = r2.data.get_as_f64(0).unwrap();
1234        let expected = 400.0 / 3.0;
1235        assert!((v2 - expected).abs() < 1e-9, "frame 2: got {v2}");
1236    }
1237
1238    #[test]
1239    fn test_filter_recursive_ave() {
1240        // RecursiveAve preset matching C++ behavior
1241        // fc=[1,-1,0,1], oc=[1,0,0,0], rc=[0,1]
1242        // F1=fScale*(1+(-1)/N)=(N-1)/N, F2=fScale*(0+1/N)=1/N
1243        // O1=oScale*(1+0/N)=1, O2=0
1244        let mut state = ProcessState::new(ProcessConfig {
1245            enable_filter: true,
1246            filter: FilterConfig {
1247                num_filter: 10,
1248                fc: [1.0, -1.0, 0.0, 1.0],
1249                oc: [1.0, 0.0, 0.0, 0.0],
1250                rc: [0.0, 1.0],
1251                ..Default::default()
1252            },
1253            output_type: Some(NDDataType::Float64),
1254            ..Default::default()
1255        });
1256
1257        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1258        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1259        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1260        //   data[i]   = newData; filter[i] = newFilter;
1261        // With O2=0, newData == O1*filter == filter, and the filter update
1262        // newFilter = F1*filter + F2*data(orig) tracks the original input.
1263        //
1264        // Frame 0: reset filter=100, N=1
1265        // F1=1*(1-1/1)=0, F2=1*(0+1/1)=1, O1=1*(1+0/1)=1
1266        // data   = 0 + 1*100 + 0*100 = 100
1267        // filter = 0 + 0*100 + 1*100(orig data) = 100
1268        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1269        let v0 = r0.data.get_as_f64(0).unwrap();
1270        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1271
1272        // Frame 1: data=200, filter=100, N=2
1273        // F1=(2-1)/2=0.5, F2=1/2=0.5
1274        // data   = 0 + 1*100 + 0*200 = 100
1275        // filter = 0 + 0.5*100 + 0.5*200(orig data) = 150
1276        let r1 = state.process(&make_f64_array(&[200.0])).unwrap();
1277        let v1 = r1.data.get_as_f64(0).unwrap();
1278        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1279
1280        // Frame 2: data=300, filter=150, N=3
1281        // F1=2/3, F2=1/3, O1=1
1282        // data   = 0 + 1*150 + 0*300 = 150
1283        // filter = (2/3)*150 + (1/3)*300(orig data) = 100 + 100 = 200
1284        let r2 = state.process(&make_f64_array(&[300.0])).unwrap();
1285        let v2 = r2.data.get_as_f64(0).unwrap();
1286        assert!((v2 - 150.0).abs() < 1e-9, "frame 2: got {v2}");
1287    }
1288
1289    #[test]
1290    fn test_save_background_one_shot() {
1291        let mut state = ProcessState::new(ProcessConfig {
1292            save_background: true,
1293            ..Default::default()
1294        });
1295
1296        assert!(!state.config.valid_background);
1297        assert!(state.background.is_none());
1298
1299        // Process with save_background=true: should capture and clear flag
1300        let input = make_array(&[10, 20, 30]);
1301        let _ = state.process(&input);
1302
1303        assert!(
1304            !state.config.save_background,
1305            "save_background should be cleared"
1306        );
1307        assert!(
1308            state.config.valid_background,
1309            "valid_background should be set"
1310        );
1311        assert!(state.background.is_some());
1312
1313        let bg = state.background.as_ref().unwrap();
1314        assert_eq!(bg.len(), 3);
1315        assert!((bg[0] - 10.0).abs() < 1e-9);
1316        assert!((bg[1] - 20.0).abs() < 1e-9);
1317        assert!((bg[2] - 30.0).abs() < 1e-9);
1318
1319        // Process again: flag should remain cleared, background should persist
1320        let input2 = make_array(&[40, 50, 60]);
1321        let _ = state.process(&input2);
1322
1323        assert!(
1324            !state.config.save_background,
1325            "save_background stays cleared"
1326        );
1327        // Background unchanged
1328        let bg2 = state.background.as_ref().unwrap();
1329        assert!((bg2[0] - 10.0).abs() < 1e-9);
1330    }
1331
1332    #[test]
1333    fn test_save_flat_field_one_shot() {
1334        let mut state = ProcessState::new(ProcessConfig {
1335            save_flat_field: true,
1336            ..Default::default()
1337        });
1338
1339        assert!(!state.config.valid_flat_field);
1340        assert!(state.flat_field.is_none());
1341
1342        let input = make_array(&[50, 100, 150]);
1343        let _ = state.process(&input);
1344
1345        assert!(
1346            !state.config.save_flat_field,
1347            "save_flat_field should be cleared"
1348        );
1349        assert!(
1350            state.config.valid_flat_field,
1351            "valid_flat_field should be set"
1352        );
1353        assert!(state.flat_field.is_some());
1354
1355        let ff = state.flat_field.as_ref().unwrap();
1356        assert_eq!(ff.len(), 3);
1357        assert!((ff[0] - 50.0).abs() < 1e-9);
1358        assert!((ff[1] - 100.0).abs() < 1e-9);
1359        assert!((ff[2] - 150.0).abs() < 1e-9);
1360    }
1361
1362    #[test]
1363    fn test_auto_reset_when_num_filter_reached() {
1364        // Sum filter with auto_reset after 3 frames
1365        let mut state = ProcessState::new(ProcessConfig {
1366            enable_filter: true,
1367            filter: FilterConfig {
1368                num_filter: 3,
1369                auto_reset: true,
1370                fc: [1.0, 0.0, 1.0, 0.0], // sum preset
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        // Frame 0 (reset): num_filtered becomes 1
1380        let _ = state.process(&make_f64_array(&[100.0]));
1381        assert_eq!(state.num_filtered, 1);
1382
1383        // Frame 1: num_filtered becomes 2
1384        let _ = state.process(&make_f64_array(&[100.0]));
1385        assert_eq!(state.num_filtered, 2);
1386
1387        // Frame 2: num_filtered becomes 3 = num_filter, triggers auto_reset on next
1388        let _ = state.process(&make_f64_array(&[100.0]));
1389        assert_eq!(state.num_filtered, 3);
1390
1391        // Frame 3: auto_reset fires (num_filtered >= num_filter), filter is reset
1392        let _ = state.process(&make_f64_array(&[200.0]));
1393        // After reset + processing, num_filtered should be 1
1394        assert_eq!(state.num_filtered, 1, "fresh start after auto reset");
1395    }
1396
1397    #[test]
1398    fn test_filter_with_offset_scale() {
1399        // Test that f_offset/f_scale and o_offset/o_scale are applied in C++ manner:
1400        // F1 = fScale * (fc1 + fc2/N), O1 = oScale * (oc1 + oc2/N)
1401        // CopyToFilter: fc=[0,0,1,0], oc=[1,0,0,0]
1402        let mut state = ProcessState::new(ProcessConfig {
1403            enable_filter: true,
1404            filter: FilterConfig {
1405                num_filter: 10,
1406                fc: [0.0, 0.0, 1.0, 0.0], // F1=0, F2=fScale*1
1407                oc: [1.0, 0.0, 0.0, 0.0], // O1=oScale*1, O2=0
1408                rc: [0.0, 1.0],
1409                f_offset: 10.0,
1410                f_scale: 2.0,
1411                o_offset: 5.0,
1412                o_scale: 3.0,
1413                ..Default::default()
1414            },
1415            output_type: Some(NDDataType::Float64),
1416            ..Default::default()
1417        });
1418
1419        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1420        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1421        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1422        //   data[i]   = newData; filter[i] = newFilter;
1423        //
1424        // Frame 0: reset: filter = 0 + 0*filter + 1*50 = 50
1425        // N=1: F1=2*(0+0/1)=0, F2=2*(1+0/1)=2, O1=3*(1+0/1)=3, O2=0
1426        // data   = 5 + 3*50 + 0 = 155
1427        // filter = 10 + 0*50 + 2*50(orig data) = 110
1428        let r0 = state.process(&make_f64_array(&[50.0])).unwrap();
1429        let v0 = r0.data.get_as_f64(0).unwrap();
1430        assert!((v0 - 155.0).abs() < 1e-9, "frame 0: got {v0}");
1431
1432        // Frame 1: data=20, filter=110
1433        // N=2: F1=0, F2=2, O1=3, O2=0
1434        // data   = 5 + 3*110 + 0 = 335
1435        // filter = 10 + 0 + 2*20(orig data) = 50
1436        let r1 = state.process(&make_f64_array(&[20.0])).unwrap();
1437        let v1 = r1.data.get_as_f64(0).unwrap();
1438        assert!((v1 - 335.0).abs() < 1e-9, "frame 1: got {v1}");
1439    }
1440
1441    #[test]
1442    fn test_reset_filter_manual() {
1443        let mut state = ProcessState::new(ProcessConfig {
1444            enable_filter: true,
1445            filter: FilterConfig {
1446                num_filter: 10,
1447                fc: [1.0, 0.0, 1.0, 0.0],
1448                oc: [1.0, 0.0, 0.0, 0.0],
1449                rc: [0.0, 1.0],
1450                ..Default::default()
1451            },
1452            output_type: Some(NDDataType::Float64),
1453            ..Default::default()
1454        });
1455
1456        // Build up filter state
1457        let _ = state.process(&make_f64_array(&[100.0]));
1458        let _ = state.process(&make_f64_array(&[100.0]));
1459        assert!(state.filter_state.is_some());
1460        assert_eq!(state.num_filtered, 2);
1461
1462        // Manual reset
1463        state.reset_filter();
1464        assert!(state.filter_state.is_none());
1465        assert_eq!(state.num_filtered, 0);
1466
1467        // Next frame should act as first frame (reset mode)
1468        let _ = state.process(&make_f64_array(&[200.0]));
1469        assert_eq!(state.num_filtered, 1);
1470    }
1471
1472    #[test]
1473    fn test_adp6_auto_offset_scale_arms_next_frame_not_trigger() {
1474        // C measures the trigger frame's min/max and ARMS scale/offset + clipping
1475        // for the NEXT frame; the trigger frame itself is emitted with the
1476        // pre-existing config (NDPluginProcess.cpp:164-178 measures only, 238-250
1477        // arms after the output array is built).
1478        let mut state = ProcessState::new(ProcessConfig {
1479            output_type: Some(NDDataType::UInt8),
1480            ..Default::default()
1481        });
1482        state.config.auto_offset_scale_pending = true;
1483
1484        // Trigger frame: input range [10, 30]. Offset/scale were OFF going in, so
1485        // the frame is emitted UNSCALED — output == input converted to u8.
1486        let out1 = state.process(&make_f64_array(&[10.0, 20.0, 30.0])).unwrap();
1487        assert!(!state.config.auto_offset_scale_pending); // one-shot consumed
1488        if let NDDataBuffer::U8(v) = &out1.data {
1489            assert_eq!(v, &[10, 20, 30]); // trigger frame NOT transformed
1490        } else {
1491            panic!("expected u8 output");
1492        }
1493        // Params armed from the trigger frame for subsequent frames:
1494        //   offset=-10, scale=255/20=12.75, offset/scale + clipping enabled.
1495        assert!(state.config.enable_offset_scale);
1496        assert!((state.config.offset - (-10.0)).abs() < 1e-9);
1497        assert!((state.config.scale - 255.0 / 20.0).abs() < 1e-9);
1498
1499        // NEXT frame IS transformed with the armed params: (v-10)*12.75, clipped.
1500        let out2 = state.process(&make_f64_array(&[10.0, 20.0, 30.0])).unwrap();
1501        if let NDDataBuffer::U8(v) = &out2.data {
1502            assert_eq!(v[0], 0); // (10-10)*12.75 = 0
1503            assert_eq!(v[2], 255); // (30-10)*12.75 = 255
1504        } else {
1505            panic!("expected u8 output");
1506        }
1507    }
1508
1509    #[test]
1510    fn test_filter_callbacks_drops_suppressed_frame() {
1511        // Regression: with filter_callbacks set, a frame that has not yet
1512        // reached num_filter is dropped (process() returns None), not
1513        // forwarded as the raw input.
1514        let mut state = ProcessState::new(ProcessConfig {
1515            enable_filter: true,
1516            filter: FilterConfig {
1517                num_filter: 3,
1518                filter_callbacks: 1,
1519                fc: [1.0, 0.0, 1.0, 0.0],
1520                oc: [0.0, 1.0, 0.0, 0.0],
1521                rc: [0.0, 1.0],
1522                ..Default::default()
1523            },
1524            output_type: Some(NDDataType::Float64),
1525            ..Default::default()
1526        });
1527
1528        // Frames 1 and 2 are below num_filter => suppressed (None).
1529        assert!(state.process(&make_f64_array(&[100.0])).is_none());
1530        assert!(state.process(&make_f64_array(&[100.0])).is_none());
1531        // Frame 3 reaches num_filter => output produced.
1532        assert!(state.process(&make_f64_array(&[100.0])).is_some());
1533    }
1534
1535    #[test]
1536    fn test_filter_recurrence_matches_cpp() {
1537        // Regression: the filter-state update must read the ORIGINAL input
1538        // data[i], not the just-updated newData. C++ computes both newData
1539        // and newFilter from data[i] before assigning data[i] = newData.
1540        //
1541        // C++ NDPluginProcess.cpp:220-227 doProcess:
1542        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1543        //   newFilter = fOffset + F1*filter[i] + F2*data[i];  // ORIGINAL data[i]
1544        //   data[i]   = newData;
1545        //   filter[i] = newFilter;
1546        //
1547        // Average preset: fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1].
1548        // O1=1/N, O2=0, F1=1, F2=1, all offsets/scales default (0/1).
1549        // With O2=0 and oc default, the C++ recurrence is:
1550        //   data[k]   = filter / N
1551        //   filter'   = filter + input   (F2 multiplies the ORIGINAL input)
1552        //
1553        // Hand-computed reference (inputs 100, 200, 300, 400):
1554        //   reset: filter = 100, N = 1
1555        //   k0: N=1  data = 100/1   = 100      filter = 100 + 100 = 200
1556        //   k1: N=2  data = 200/2   = 100      filter = 200 + 200 = 400
1557        //   k2: N=3  data = 400/3   = 133.333  filter = 400 + 300 = 700
1558        //   k3: N=4  data = 700/4   = 175      filter = 700 + 400 = 1100
1559        //
1560        // The STALE/new-data variant (the 650038bb regression) computed
1561        //   filter' = filter + newData
1562        // giving filter = 100,200,300,400 and data = 100,100,100,100 —
1563        // diverging from C++ from frame 1 onward.
1564        let mut state = ProcessState::new(ProcessConfig {
1565            enable_filter: true,
1566            filter: FilterConfig {
1567                num_filter: 100,
1568                fc: [1.0, 0.0, 1.0, 0.0],
1569                oc: [0.0, 1.0, 0.0, 0.0],
1570                rc: [0.0, 1.0],
1571                ..Default::default()
1572            },
1573            output_type: Some(NDDataType::Float64),
1574            ..Default::default()
1575        });
1576
1577        let inputs = [100.0, 200.0, 300.0, 400.0];
1578        let expected_data = [100.0, 100.0, 400.0 / 3.0, 175.0];
1579        let expected_filter = [200.0, 400.0, 700.0, 1100.0];
1580
1581        for k in 0..inputs.len() {
1582            let r = state.process(&make_f64_array(&[inputs[k]])).unwrap();
1583            let v = r.data.get_as_f64(0).unwrap();
1584            assert!(
1585                (v - expected_data[k]).abs() < 1e-9,
1586                "frame {k}: data got {v}, expected {}",
1587                expected_data[k]
1588            );
1589            let fs = state.filter_state.as_ref().unwrap()[0];
1590            assert!(
1591                (fs - expected_filter[k]).abs() < 1e-9,
1592                "frame {k}: filter got {fs}, expected {}",
1593                expected_filter[k]
1594            );
1595        }
1596    }
1597}