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): compute offset/scale from this
346        // input array and enable offset/scale + clipping (C++ writeInt32 for
347        // NDPluginProcessAutoOffsetScale).
348        if self.config.auto_offset_scale_pending {
349            self.auto_offset_scale(src);
350            self.config.auto_offset_scale_pending = false;
351        }
352
353        // Stages 1-4: element-wise operations (background, flat field, offset+scale, clipping)
354        // These can be combined into a single pass and parallelized.
355        let needs_element_ops = self.config.enable_background
356            || self.config.enable_flat_field
357            || self.config.enable_offset_scale
358            || self.config.enable_low_clip
359            || self.config.enable_high_clip;
360
361        if needs_element_ops {
362            let bg = if self.config.enable_background {
363                self.background.as_ref()
364            } else {
365                None
366            };
367            let (ff, ff_scale) = if self.config.enable_flat_field {
368                if let Some(ref ff) = self.flat_field {
369                    let scale = if self.config.scale_flat_field > 0.0 {
370                        self.config.scale_flat_field
371                    } else {
372                        ff.iter().sum::<f64>() / ff.len().max(1) as f64
373                    };
374                    (Some(ff.as_slice()), scale)
375                } else {
376                    (None, 0.0)
377                }
378            } else {
379                (None, 0.0)
380            };
381            let do_offset_scale = self.config.enable_offset_scale;
382            let scale = self.config.scale;
383            let offset = self.config.offset;
384            let do_low_clip = self.config.enable_low_clip;
385            let low_clip_thresh = self.config.low_clip_thresh;
386            let low_clip_value = self.config.low_clip_value;
387            let do_high_clip = self.config.enable_high_clip;
388            let high_clip_thresh = self.config.high_clip_thresh;
389            let high_clip_value = self.config.high_clip_value;
390
391            let apply_stages = |i: usize, v: &mut f64| {
392                // Stage 1: Background subtraction
393                if let Some(bg) = bg {
394                    if i < bg.len() {
395                        *v -= bg[i];
396                    }
397                }
398                // Stage 2: Flat field normalization
399                if let Some(ff) = ff {
400                    if i < ff.len() && ff[i] != 0.0 {
401                        *v = *v * ff_scale / ff[i];
402                    }
403                }
404                // Stage 3: Offset + scale (C++: value = (value + offset) * scale)
405                if do_offset_scale {
406                    *v = (*v + offset) * scale;
407                }
408                // Stage 4: Clipping
409                if do_low_clip && *v < low_clip_thresh {
410                    *v = low_clip_value;
411                }
412                if do_high_clip && *v > high_clip_thresh {
413                    *v = high_clip_value;
414                }
415            };
416
417            #[cfg(feature = "parallel")]
418            let use_parallel = par_util::should_parallelize(n);
419            #[cfg(not(feature = "parallel"))]
420            let use_parallel = false;
421
422            if use_parallel {
423                #[cfg(feature = "parallel")]
424                par_util::thread_pool().install(|| {
425                    values.par_iter_mut().enumerate().for_each(|(i, v)| {
426                        apply_stages(i, v);
427                    });
428                });
429            } else {
430                for (i, v) in values.iter_mut().enumerate() {
431                    apply_stages(i, v);
432                }
433            }
434        }
435
436        // 5. Recursive filter (matching C++ NDPluginProcess algorithm)
437        if self.config.enable_filter {
438            let fc = &self.config.filter;
439
440            // Ensure filter buffer exists and matches element count
441            if let Some(ref f) = self.filter_state {
442                if f.len() != n {
443                    self.filter_state = None;
444                }
445            }
446
447            let mut reset_filter = self.filter_state.is_none();
448            if self.num_filtered >= fc.num_filter && fc.auto_reset {
449                reset_filter = true;
450            }
451
452            // Initialize filter buffer from current data if needed
453            if self.filter_state.is_none() {
454                self.filter_state = Some(values.clone());
455            }
456
457            let filter = self.filter_state.as_mut().unwrap();
458
459            if reset_filter {
460                // C++: filter[i] = rOffset + rc1*filter[i] + rc2*data[i]
461                let r_offset = fc.r_offset;
462                let rc1 = fc.rc[0];
463                let rc2 = fc.rc[1];
464                for i in 0..n {
465                    let new_filter = r_offset + rc1 * filter[i] + rc2 * values[i];
466                    filter[i] = new_filter;
467                }
468                self.num_filtered = 0;
469            }
470
471            // Increment filtered count (C++: if (numFiltered < numFilter) numFiltered++)
472            if self.num_filtered < fc.num_filter {
473                self.num_filtered += 1;
474            }
475
476            // Compute effective coefficients (depend on numFiltered)
477            let nf = self.num_filtered as f64;
478            let o1 = fc.o_scale * (fc.oc[0] + fc.oc[1] / nf);
479            let o2 = fc.o_scale * (fc.oc[2] + fc.oc[3] / nf);
480            let f1 = fc.f_scale * (fc.fc[0] + fc.fc[1] / nf);
481            let f2 = fc.f_scale * (fc.fc[2] + fc.fc[3] / nf);
482            let o_offset = fc.o_offset;
483            let f_offset = fc.f_offset;
484
485            // C++ NDPluginProcess.cpp:220-227 doProcess:
486            //   newData   = oOffset + O1*filter[i] + O2*data[i];
487            //   newFilter = fOffset + F1*filter[i] + F2*data[i];
488            //   data[i]   = newData;
489            //   filter[i] = newFilter;
490            // Both newData AND newFilter are computed from the ORIGINAL
491            // data[i]; data[i] = newData is assigned only afterward. So the
492            // filter-state update must use the original input, not new_data.
493            for i in 0..n {
494                let new_data = o_offset + o1 * filter[i] + o2 * values[i];
495                let new_filter = f_offset + f1 * filter[i] + f2 * values[i];
496                values[i] = new_data;
497                filter[i] = new_filter;
498            }
499
500            // Suppress output if filterCallbacks is set and we haven't reached
501            // numFilter. C++ sets doCallbacks = 0 and does NOT call
502            // endProcessCallbacks — the frame is dropped, nothing goes
503            // downstream (the unprocessed input is NOT forwarded).
504            if fc.filter_callbacks > 0 && self.num_filtered != fc.num_filter {
505                return None;
506            }
507        }
508
509        // Build output
510        let out_type = self.config.output_type.unwrap_or(src.data.data_type());
511        let mut out_data = NDDataBuffer::zeros(out_type, n);
512        for i in 0..n {
513            out_data.set_from_f64(i, values[i]);
514        }
515
516        let mut arr = NDArray::new(src.dims.clone(), out_type);
517        arr.data = out_data;
518        arr.unique_id = src.unique_id;
519        arr.timestamp = src.timestamp;
520        arr.attributes = src.attributes.clone();
521        Some(arr)
522    }
523}
524
525// --- ProcessProcessor (NDPluginProcess-based) ---
526
527/// Param indices for the process plugin.
528#[derive(Default)]
529struct ProcParamIndices {
530    data_type: Option<usize>,
531    save_background: Option<usize>,
532    enable_background: Option<usize>,
533    valid_background: Option<usize>,
534    save_flat_field: Option<usize>,
535    enable_flat_field: Option<usize>,
536    valid_flat_field: Option<usize>,
537    scale_flat_field: Option<usize>,
538    enable_offset_scale: Option<usize>,
539    auto_offset_scale: Option<usize>,
540    offset: Option<usize>,
541    scale: Option<usize>,
542    enable_low_clip: Option<usize>,
543    low_clip_thresh: Option<usize>,
544    low_clip_value: Option<usize>,
545    enable_high_clip: Option<usize>,
546    high_clip_thresh: Option<usize>,
547    high_clip_value: Option<usize>,
548    enable_filter: Option<usize>,
549    filter_type: Option<usize>,
550    reset_filter: Option<usize>,
551    auto_reset_filter: Option<usize>,
552    filter_callbacks: Option<usize>,
553    num_filter: Option<usize>,
554    num_filtered: Option<usize>,
555    o_offset: Option<usize>,
556    o_scale: Option<usize>,
557    oc: [Option<usize>; 4],
558    f_offset: Option<usize>,
559    f_scale: Option<usize>,
560    fc: [Option<usize>; 4],
561    r_offset: Option<usize>,
562    rc: [Option<usize>; 2],
563}
564
565/// ProcessProcessor wraps existing ProcessState.
566pub struct ProcessProcessor {
567    state: ProcessState,
568    params: ProcParamIndices,
569}
570
571impl ProcessProcessor {
572    pub fn new(config: ProcessConfig) -> Self {
573        Self {
574            state: ProcessState::new(config),
575            params: ProcParamIndices::default(),
576        }
577    }
578
579    pub fn state(&self) -> &ProcessState {
580        &self.state
581    }
582
583    pub fn state_mut(&mut self) -> &mut ProcessState {
584        &mut self.state
585    }
586}
587
588impl NDPluginProcess for ProcessProcessor {
589    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
590        use ad_core_rs::plugin::runtime::ParamUpdate;
591
592        let out = self.state.process(array);
593        // A suppressed frame (filter_callbacks) produces no output array but
594        // still publishes readback params.
595        let mut result = match out {
596            Some(arr) => ProcessResult::arrays(vec![Arc::new(arr)]),
597            None => ProcessResult::sink(vec![]),
598        };
599
600        // Push readback params
601        if let Some(idx) = self.params.valid_background {
602            result.param_updates.push(ParamUpdate::int32(
603                idx,
604                if self.state.config.valid_background {
605                    1
606                } else {
607                    0
608                },
609            ));
610        }
611        if let Some(idx) = self.params.valid_flat_field {
612            result.param_updates.push(ParamUpdate::int32(
613                idx,
614                if self.state.config.valid_flat_field {
615                    1
616                } else {
617                    0
618                },
619            ));
620        }
621        if let Some(idx) = self.params.num_filtered {
622            result
623                .param_updates
624                .push(ParamUpdate::int32(idx, self.state.num_filtered as i32));
625        }
626        // Reset save_background/save_flat_field readback to 0 (one-shot)
627        if let Some(idx) = self.params.save_background {
628            result.param_updates.push(ParamUpdate::int32(idx, 0));
629        }
630        if let Some(idx) = self.params.save_flat_field {
631            result.param_updates.push(ParamUpdate::int32(idx, 0));
632        }
633
634        result
635    }
636
637    fn plugin_type(&self) -> &str {
638        "NDPluginProcess"
639    }
640
641    fn register_params(
642        &mut self,
643        base: &mut asyn_rs::port::PortDriverBase,
644    ) -> asyn_rs::error::AsynResult<()> {
645        use asyn_rs::param::ParamType;
646        base.create_param("PROCESS_DATA_TYPE", ParamType::Int32)?;
647        base.create_param("SAVE_BACKGROUND", ParamType::Int32)?;
648        base.create_param("ENABLE_BACKGROUND", ParamType::Int32)?;
649        base.create_param("VALID_BACKGROUND", ParamType::Int32)?;
650        base.create_param("SAVE_FLAT_FIELD", ParamType::Int32)?;
651        base.create_param("ENABLE_FLAT_FIELD", ParamType::Int32)?;
652        base.create_param("VALID_FLAT_FIELD", ParamType::Int32)?;
653        base.create_param("SCALE_FLAT_FIELD", ParamType::Float64)?;
654        base.create_param("ENABLE_OFFSET_SCALE", ParamType::Int32)?;
655        base.create_param("AUTO_OFFSET_SCALE", ParamType::Int32)?;
656        base.create_param("OFFSET", ParamType::Float64)?;
657        base.create_param("SCALE", ParamType::Float64)?;
658        base.create_param("ENABLE_LOW_CLIP", ParamType::Int32)?;
659        base.create_param("LOW_CLIP_THRESH", ParamType::Float64)?;
660        base.create_param("LOW_CLIP_VALUE", ParamType::Float64)?;
661        base.create_param("ENABLE_HIGH_CLIP", ParamType::Int32)?;
662        base.create_param("HIGH_CLIP_THRESH", ParamType::Float64)?;
663        base.create_param("HIGH_CLIP_VALUE", ParamType::Float64)?;
664        base.create_param("ENABLE_FILTER", ParamType::Int32)?;
665        base.create_param("FILTER_TYPE", ParamType::Int32)?;
666        base.create_param("RESET_FILTER", ParamType::Int32)?;
667        base.create_param("AUTO_RESET_FILTER", ParamType::Int32)?;
668        base.create_param("FILTER_CALLBACKS", ParamType::Int32)?;
669        base.create_param("NUM_FILTER", ParamType::Int32)?;
670        base.create_param("NUM_FILTERED", ParamType::Int32)?;
671        base.create_param("FILTER_OOFFSET", ParamType::Float64)?;
672        base.create_param("FILTER_OSCALE", ParamType::Float64)?;
673        base.create_param("FILTER_OC1", ParamType::Float64)?;
674        base.create_param("FILTER_OC2", ParamType::Float64)?;
675        base.create_param("FILTER_OC3", ParamType::Float64)?;
676        base.create_param("FILTER_OC4", ParamType::Float64)?;
677        base.create_param("FILTER_FOFFSET", ParamType::Float64)?;
678        base.create_param("FILTER_FSCALE", ParamType::Float64)?;
679        base.create_param("FILTER_FC1", ParamType::Float64)?;
680        base.create_param("FILTER_FC2", ParamType::Float64)?;
681        base.create_param("FILTER_FC3", ParamType::Float64)?;
682        base.create_param("FILTER_FC4", ParamType::Float64)?;
683        base.create_param("FILTER_ROFFSET", ParamType::Float64)?;
684        base.create_param("FILTER_RC1", ParamType::Float64)?;
685        base.create_param("FILTER_RC2", ParamType::Float64)?;
686
687        // Look up param indices
688        self.params.data_type = base.find_param("PROCESS_DATA_TYPE");
689        self.params.save_background = base.find_param("SAVE_BACKGROUND");
690        self.params.enable_background = base.find_param("ENABLE_BACKGROUND");
691        self.params.valid_background = base.find_param("VALID_BACKGROUND");
692        self.params.save_flat_field = base.find_param("SAVE_FLAT_FIELD");
693        self.params.enable_flat_field = base.find_param("ENABLE_FLAT_FIELD");
694        self.params.valid_flat_field = base.find_param("VALID_FLAT_FIELD");
695        self.params.scale_flat_field = base.find_param("SCALE_FLAT_FIELD");
696        self.params.enable_offset_scale = base.find_param("ENABLE_OFFSET_SCALE");
697        self.params.auto_offset_scale = base.find_param("AUTO_OFFSET_SCALE");
698        self.params.offset = base.find_param("OFFSET");
699        self.params.scale = base.find_param("SCALE");
700        self.params.enable_low_clip = base.find_param("ENABLE_LOW_CLIP");
701        self.params.low_clip_thresh = base.find_param("LOW_CLIP_THRESH");
702        self.params.low_clip_value = base.find_param("LOW_CLIP_VALUE");
703        self.params.enable_high_clip = base.find_param("ENABLE_HIGH_CLIP");
704        self.params.high_clip_thresh = base.find_param("HIGH_CLIP_THRESH");
705        self.params.high_clip_value = base.find_param("HIGH_CLIP_VALUE");
706        self.params.enable_filter = base.find_param("ENABLE_FILTER");
707        self.params.filter_type = base.find_param("FILTER_TYPE");
708        self.params.reset_filter = base.find_param("RESET_FILTER");
709        self.params.auto_reset_filter = base.find_param("AUTO_RESET_FILTER");
710        self.params.filter_callbacks = base.find_param("FILTER_CALLBACKS");
711        self.params.num_filter = base.find_param("NUM_FILTER");
712        self.params.num_filtered = base.find_param("NUM_FILTERED");
713        self.params.o_offset = base.find_param("FILTER_OOFFSET");
714        self.params.o_scale = base.find_param("FILTER_OSCALE");
715        self.params.oc[0] = base.find_param("FILTER_OC1");
716        self.params.oc[1] = base.find_param("FILTER_OC2");
717        self.params.oc[2] = base.find_param("FILTER_OC3");
718        self.params.oc[3] = base.find_param("FILTER_OC4");
719        self.params.f_offset = base.find_param("FILTER_FOFFSET");
720        self.params.f_scale = base.find_param("FILTER_FSCALE");
721        self.params.fc[0] = base.find_param("FILTER_FC1");
722        self.params.fc[1] = base.find_param("FILTER_FC2");
723        self.params.fc[2] = base.find_param("FILTER_FC3");
724        self.params.fc[3] = base.find_param("FILTER_FC4");
725        self.params.r_offset = base.find_param("FILTER_ROFFSET");
726        self.params.rc[0] = base.find_param("FILTER_RC1");
727        self.params.rc[1] = base.find_param("FILTER_RC2");
728        Ok(())
729    }
730
731    fn on_param_change(
732        &mut self,
733        reason: usize,
734        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
735    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
736        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamUpdate};
737
738        let s = &mut self.state;
739        let p = &self.params;
740        let mut updates = Vec::new();
741
742        if Some(reason) == p.data_type {
743            let v = params.value.as_i32();
744            s.config.output_type = if v < 0 {
745                None // Automatic
746            } else {
747                NDDataType::from_ordinal(v as u8)
748            };
749        } else if Some(reason) == p.save_background {
750            if params.value.as_i32() != 0 {
751                s.config.save_background = true;
752            }
753        } else if Some(reason) == p.enable_background {
754            s.config.enable_background = params.value.as_i32() != 0;
755        } else if Some(reason) == p.save_flat_field {
756            if params.value.as_i32() != 0 {
757                s.config.save_flat_field = true;
758            }
759        } else if Some(reason) == p.enable_flat_field {
760            s.config.enable_flat_field = params.value.as_i32() != 0;
761        } else if Some(reason) == p.scale_flat_field {
762            s.config.scale_flat_field = params.value.as_f64();
763        } else if Some(reason) == p.enable_offset_scale {
764            s.config.enable_offset_scale = params.value.as_i32() != 0;
765        } else if Some(reason) == p.auto_offset_scale {
766            if params.value.as_i32() != 0 {
767                // Arm the one-shot: auto_offset_scale() runs on the next
768                // process() call (it needs an NDArray to read the data
769                // range). C++ resets NDPluginProcessAutoOffsetScale to 0
770                // after handling, so echo a 0 readback here.
771                s.config.auto_offset_scale_pending = true;
772                if let Some(idx) = p.auto_offset_scale {
773                    updates.push(ParamUpdate::int32(idx, 0));
774                }
775            }
776        } else if Some(reason) == p.offset {
777            s.config.offset = params.value.as_f64();
778        } else if Some(reason) == p.scale {
779            s.config.scale = params.value.as_f64();
780        } else if Some(reason) == p.enable_low_clip {
781            s.config.enable_low_clip = params.value.as_i32() != 0;
782        } else if Some(reason) == p.low_clip_thresh {
783            s.config.low_clip_thresh = params.value.as_f64();
784        } else if Some(reason) == p.low_clip_value {
785            s.config.low_clip_value = params.value.as_f64();
786        } else if Some(reason) == p.enable_high_clip {
787            s.config.enable_high_clip = params.value.as_i32() != 0;
788        } else if Some(reason) == p.high_clip_thresh {
789            s.config.high_clip_thresh = params.value.as_f64();
790        } else if Some(reason) == p.high_clip_value {
791            s.config.high_clip_value = params.value.as_f64();
792        } else if Some(reason) == p.enable_filter {
793            s.config.enable_filter = params.value.as_i32() != 0;
794        } else if Some(reason) == p.filter_type {
795            s.apply_filter_type(params.value.as_i32());
796            s.reset_filter();
797            // Push updated coefficients back
798            let fc = &s.config.filter;
799            for (i, idx) in p.fc.iter().enumerate() {
800                if let Some(idx) = *idx {
801                    updates.push(ParamUpdate::float64(idx, fc.fc[i]));
802                }
803            }
804            for (i, idx) in p.oc.iter().enumerate() {
805                if let Some(idx) = *idx {
806                    updates.push(ParamUpdate::float64(idx, fc.oc[i]));
807                }
808            }
809            for (i, idx) in p.rc.iter().enumerate() {
810                if let Some(idx) = *idx {
811                    updates.push(ParamUpdate::float64(idx, fc.rc[i]));
812                }
813            }
814            if let Some(idx) = p.f_offset {
815                updates.push(ParamUpdate::float64(idx, fc.f_offset));
816            }
817            if let Some(idx) = p.f_scale {
818                updates.push(ParamUpdate::float64(idx, fc.f_scale));
819            }
820            if let Some(idx) = p.o_offset {
821                updates.push(ParamUpdate::float64(idx, fc.o_offset));
822            }
823            if let Some(idx) = p.o_scale {
824                updates.push(ParamUpdate::float64(idx, fc.o_scale));
825            }
826        } else if Some(reason) == p.reset_filter {
827            if params.value.as_i32() != 0 {
828                s.reset_filter();
829                if let Some(idx) = p.num_filtered {
830                    updates.push(ParamUpdate::int32(idx, 0));
831                }
832            }
833        } else if Some(reason) == p.auto_reset_filter {
834            s.config.filter.auto_reset = params.value.as_i32() != 0;
835        } else if Some(reason) == p.filter_callbacks {
836            s.config.filter.filter_callbacks = params.value.as_i32().max(0) as usize;
837        } else if Some(reason) == p.num_filter {
838            s.config.filter.num_filter = params.value.as_i32().max(1) as usize;
839        } else if Some(reason) == p.o_offset {
840            s.config.filter.o_offset = params.value.as_f64();
841        } else if Some(reason) == p.o_scale {
842            s.config.filter.o_scale = params.value.as_f64();
843        } else if Some(reason) == p.f_offset {
844            s.config.filter.f_offset = params.value.as_f64();
845        } else if Some(reason) == p.f_scale {
846            s.config.filter.f_scale = params.value.as_f64();
847        } else if Some(reason) == p.r_offset {
848            s.config.filter.r_offset = params.value.as_f64();
849        } else {
850            // Check individual OC/FC/RC params
851            for i in 0..4 {
852                if Some(reason) == p.oc[i] {
853                    s.config.filter.oc[i] = params.value.as_f64();
854                    return ParamChangeResult::updates(vec![]);
855                }
856                if Some(reason) == p.fc[i] {
857                    s.config.filter.fc[i] = params.value.as_f64();
858                    return ParamChangeResult::updates(vec![]);
859                }
860            }
861            for i in 0..2 {
862                if Some(reason) == p.rc[i] {
863                    s.config.filter.rc[i] = params.value.as_f64();
864                    return ParamChangeResult::updates(vec![]);
865                }
866            }
867        }
868
869        ParamChangeResult::updates(updates)
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
877
878    fn make_array(vals: &[u8]) -> NDArray {
879        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::UInt8);
880        if let NDDataBuffer::U8(ref mut v) = arr.data {
881            v.copy_from_slice(vals);
882        }
883        arr
884    }
885
886    fn make_f64_array(vals: &[f64]) -> NDArray {
887        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::Float64);
888        if let NDDataBuffer::F64(ref mut v) = arr.data {
889            v.copy_from_slice(vals);
890        }
891        arr
892    }
893
894    #[test]
895    fn test_background_subtraction() {
896        let bg_arr = make_array(&[10, 20, 30]);
897        let input = make_array(&[15, 25, 35]);
898
899        let mut state = ProcessState::new(ProcessConfig {
900            enable_background: true,
901            ..Default::default()
902        });
903        state.save_background(&bg_arr);
904
905        let result = state.process(&input).unwrap();
906        if let NDDataBuffer::U8(ref v) = result.data {
907            assert_eq!(v[0], 5);
908            assert_eq!(v[1], 5);
909            assert_eq!(v[2], 5);
910        }
911    }
912
913    #[test]
914    fn test_flat_field() {
915        let ff_arr = make_array(&[100, 200, 50]);
916        let input = make_array(&[100, 200, 50]);
917
918        let mut state = ProcessState::new(ProcessConfig {
919            enable_flat_field: true,
920            scale_flat_field: 0.0, // use mean
921            ..Default::default()
922        });
923        state.save_flat_field(&ff_arr);
924
925        let result = state.process(&input).unwrap();
926        // After flat field: all values should be normalized to the mean
927        if let NDDataBuffer::U8(ref v) = result.data {
928            // ff_mean ~= 116.67, so all values should be ~= 116
929            assert!((v[0] as f64 - 116.67).abs() < 1.0);
930            assert!((v[1] as f64 - 116.67).abs() < 1.0);
931            assert!((v[2] as f64 - 116.67).abs() < 1.0);
932        }
933    }
934
935    #[test]
936    fn test_offset_scale() {
937        let input = make_array(&[10, 20, 30]);
938        let mut state = ProcessState::new(ProcessConfig {
939            enable_offset_scale: true,
940            scale: 2.0,
941            offset: 5.0,
942            ..Default::default()
943        });
944
945        let result = state.process(&input).unwrap();
946        if let NDDataBuffer::U8(ref v) = result.data {
947            // C++: value = (value + offset) * scale
948            assert_eq!(v[0], 30); // (10+5)*2
949            assert_eq!(v[1], 50); // (20+5)*2
950            assert_eq!(v[2], 70); // (30+5)*2
951        }
952    }
953
954    #[test]
955    fn test_clipping() {
956        let input = make_array(&[5, 50, 200]);
957        let mut state = ProcessState::new(ProcessConfig {
958            enable_low_clip: true,
959            low_clip_thresh: 10.0,
960            low_clip_value: 10.0,
961            enable_high_clip: true,
962            high_clip_thresh: 100.0,
963            high_clip_value: 100.0,
964            ..Default::default()
965        });
966
967        let result = state.process(&input).unwrap();
968        if let NDDataBuffer::U8(ref v) = result.data {
969            assert_eq!(v[0], 10); // clipped up
970            assert_eq!(v[1], 50); // unchanged
971            assert_eq!(v[2], 100); // clipped down
972        }
973    }
974
975    #[test]
976    fn test_recursive_filter() {
977        // Test a simple recursive filter: filter = 0.5*filter + 0.5*data, output = filter
978        // Using C++ coefficient scheme:
979        //   F1 = fScale*(fc1+fc2/N), F2 = fScale*(fc3+fc4/N)
980        //   For constant F1=0.5, F2=0.5 regardless of N:
981        //   fc1=0.5, fc2=0, fc3=0.5, fc4=0
982        let input1 = make_array(&[100, 100, 100]);
983        let input2 = make_array(&[0, 0, 0]);
984
985        let mut state = ProcessState::new(ProcessConfig {
986            enable_filter: true,
987            filter: FilterConfig {
988                num_filter: 10,
989                fc: [0.5, 0.0, 0.5, 0.0], // F1=0.5, F2=0.5
990                oc: [1.0, 0.0, 0.0, 0.0], // O1=1, O2=0
991                rc: [0.0, 1.0],           // reset: filter = data
992                ..Default::default()
993            },
994            ..Default::default()
995        });
996
997        // C++ NDPluginProcess.cpp:220-227 doProcess recurrence:
998        //   newData   = oOffset + O1*filter[i] + O2*data[i];
999        //   newFilter = fOffset + F1*filter[i] + F2*data[i];  // ORIGINAL data[i]
1000        //   data[i]   = newData;
1001        //   filter[i] = newFilter;
1002        //
1003        // Frame 0: reset: filter = 0 + 0*100 + 1*100 = 100
1004        // N=1: F1=0.5, F2=0.5, O1=1, O2=0
1005        // data   = 0 + 1*100 + 0*100 = 100
1006        // filter = 0 + 0.5*100 + 0.5*100(orig data) = 100
1007        let _ = state.process(&input1);
1008
1009        // Frame 1: data=0, filter=100
1010        // N=2: F1=0.5, F2=0.5, O1=1, O2=0
1011        // data   = 0 + 1*100 + 0*0 = 100
1012        // filter = 0 + 0.5*100 + 0.5*0(orig data) = 50
1013        let result = state.process(&input2).unwrap();
1014        if let NDDataBuffer::U8(ref v) = result.data {
1015            // Output is data = O1*filter = 1*100 = 100
1016            assert_eq!(v[0], 100);
1017            assert_eq!(v[1], 100);
1018        }
1019    }
1020
1021    #[test]
1022    fn test_output_type_conversion() {
1023        let input = make_array(&[10, 20, 30]);
1024        let mut state = ProcessState::new(ProcessConfig {
1025            output_type: Some(NDDataType::Float64),
1026            ..Default::default()
1027        });
1028
1029        let result = state.process(&input).unwrap();
1030        assert_eq!(result.data.data_type(), NDDataType::Float64);
1031    }
1032
1033    // --- ProcessProcessor tests ---
1034
1035    #[test]
1036    fn test_process_processor() {
1037        let mut proc = ProcessProcessor::new(ProcessConfig {
1038            enable_offset_scale: true,
1039            scale: 2.0,
1040            offset: 1.0,
1041            ..Default::default()
1042        });
1043        let pool = NDArrayPool::new(1_000_000);
1044
1045        let input = make_array(&[10, 20, 30]);
1046        let result = proc.process_array(&input, &pool);
1047        assert_eq!(result.output_arrays.len(), 1);
1048        if let NDDataBuffer::U8(ref v) = result.output_arrays[0].data {
1049            assert_eq!(v[0], 22); // (10+1)*2 = 22 (C++: offset first, then scale)
1050        }
1051    }
1052
1053    // --- New Phase 2-1 tests ---
1054
1055    #[test]
1056    fn test_filter_sum_preset() {
1057        // Sum preset: filter = filter + data, output = filter
1058        // fc=[1,0,1,0], oc=[1,0,0,0], rc=[0,1]
1059        let mut state = ProcessState::new(ProcessConfig {
1060            enable_filter: true,
1061            filter: FilterConfig {
1062                num_filter: 10,
1063                fc: [1.0, 0.0, 1.0, 0.0],
1064                oc: [1.0, 0.0, 0.0, 0.0],
1065                rc: [0.0, 1.0],
1066                ..Default::default()
1067            },
1068            output_type: Some(NDDataType::Float64),
1069            ..Default::default()
1070        });
1071
1072        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1073        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1074        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1075        //   data[i]   = newData; filter[i] = newFilter;
1076        //
1077        // Frame 0: reset first: filter = rOffset + rc1*filter + rc2*data
1078        //          = 0 + 0*100 + 1*100 = 100. Then N increments to 1, normal path:
1079        // F1=fScale*(fc1+fc2/N)=1*(1+0/1)=1, F2=fScale*(fc3+fc4/N)=1*(1+0/1)=1
1080        // O1=oScale*(oc1+oc2/N)=1*(1+0/1)=1, O2=oScale*(oc3+oc4/N)=1*(0+0/1)=0
1081        // data   = oOffset + O1*filter + O2*data = 0 + 1*100 + 0*100 = 100
1082        // filter = fOffset + F1*filter + F2*data(orig=100) = 0 + 1*100 + 1*100 = 200
1083        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1084        let v0 = r0.data.get_as_f64(0).unwrap();
1085        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1086
1087        // Frame 1: data=100, filter=200 (from prev)
1088        // N increments to 2
1089        // F1=1*(1+0/2)=1, F2=1*(1+0/2)=1
1090        // O1=1*(1+0/2)=1, O2=0
1091        // data   = 0 + 1*200 + 0*100 = 200
1092        // filter = 0 + 1*200 + 1*data(orig=100) = 300
1093        let r1 = state.process(&make_f64_array(&[100.0])).unwrap();
1094        let v1 = r1.data.get_as_f64(0).unwrap();
1095        assert!((v1 - 200.0).abs() < 1e-9, "frame 1: got {v1}");
1096    }
1097
1098    #[test]
1099    fn test_filter_average_preset() {
1100        // Average preset: accumulate in filter, output = filter/N
1101        // fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1]
1102        let mut state = ProcessState::new(ProcessConfig {
1103            enable_filter: true,
1104            filter: FilterConfig {
1105                num_filter: 10,
1106                fc: [1.0, 0.0, 1.0, 0.0],
1107                oc: [0.0, 1.0, 0.0, 0.0],
1108                rc: [0.0, 1.0],
1109                ..Default::default()
1110            },
1111            output_type: Some(NDDataType::Float64),
1112            ..Default::default()
1113        });
1114
1115        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1116        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1117        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1118        //   data[i]   = newData; filter[i] = newFilter;
1119        //
1120        // Frame 0 (reset): filter=100. N=1: O1=oScale*(0+1/1)=1, O2=0
1121        // data   = 0 + 1*100 + 0 = 100
1122        // filter = 0 + 1*100 + 1*100(orig data) = 200
1123        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1124        let v0 = r0.data.get_as_f64(0).unwrap();
1125        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1126
1127        // Frame 1: data=200, filter=200
1128        // N=2: O1=oScale*(0+1/2)=0.5, O2=0
1129        // data   = 0 + 0.5*200 + 0 = 100
1130        // filter = 0 + 1*200 + 1*200(orig data) = 400
1131        let r1 = state.process(&make_f64_array(&[200.0])).unwrap();
1132        let v1 = r1.data.get_as_f64(0).unwrap();
1133        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1134
1135        // Frame 2: data=300, filter=400
1136        // N=3: O1=1/3, O2=0
1137        // data   = 0 + (1/3)*400 + 0 = 400/3
1138        // filter = 0 + 1*400 + 1*300(orig data) = 700
1139        let r2 = state.process(&make_f64_array(&[300.0])).unwrap();
1140        let v2 = r2.data.get_as_f64(0).unwrap();
1141        let expected = 400.0 / 3.0;
1142        assert!((v2 - expected).abs() < 1e-9, "frame 2: got {v2}");
1143    }
1144
1145    #[test]
1146    fn test_filter_recursive_ave() {
1147        // RecursiveAve preset matching C++ behavior
1148        // fc=[1,-1,0,1], oc=[1,0,0,0], rc=[0,1]
1149        // F1=fScale*(1+(-1)/N)=(N-1)/N, F2=fScale*(0+1/N)=1/N
1150        // O1=oScale*(1+0/N)=1, O2=0
1151        let mut state = ProcessState::new(ProcessConfig {
1152            enable_filter: true,
1153            filter: FilterConfig {
1154                num_filter: 10,
1155                fc: [1.0, -1.0, 0.0, 1.0],
1156                oc: [1.0, 0.0, 0.0, 0.0],
1157                rc: [0.0, 1.0],
1158                ..Default::default()
1159            },
1160            output_type: Some(NDDataType::Float64),
1161            ..Default::default()
1162        });
1163
1164        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1165        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1166        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1167        //   data[i]   = newData; filter[i] = newFilter;
1168        // With O2=0, newData == O1*filter == filter, and the filter update
1169        // newFilter = F1*filter + F2*data(orig) tracks the original input.
1170        //
1171        // Frame 0: reset filter=100, N=1
1172        // F1=1*(1-1/1)=0, F2=1*(0+1/1)=1, O1=1*(1+0/1)=1
1173        // data   = 0 + 1*100 + 0*100 = 100
1174        // filter = 0 + 0*100 + 1*100(orig data) = 100
1175        let r0 = state.process(&make_f64_array(&[100.0])).unwrap();
1176        let v0 = r0.data.get_as_f64(0).unwrap();
1177        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1178
1179        // Frame 1: data=200, filter=100, N=2
1180        // F1=(2-1)/2=0.5, F2=1/2=0.5
1181        // data   = 0 + 1*100 + 0*200 = 100
1182        // filter = 0 + 0.5*100 + 0.5*200(orig data) = 150
1183        let r1 = state.process(&make_f64_array(&[200.0])).unwrap();
1184        let v1 = r1.data.get_as_f64(0).unwrap();
1185        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1186
1187        // Frame 2: data=300, filter=150, N=3
1188        // F1=2/3, F2=1/3, O1=1
1189        // data   = 0 + 1*150 + 0*300 = 150
1190        // filter = (2/3)*150 + (1/3)*300(orig data) = 100 + 100 = 200
1191        let r2 = state.process(&make_f64_array(&[300.0])).unwrap();
1192        let v2 = r2.data.get_as_f64(0).unwrap();
1193        assert!((v2 - 150.0).abs() < 1e-9, "frame 2: got {v2}");
1194    }
1195
1196    #[test]
1197    fn test_save_background_one_shot() {
1198        let mut state = ProcessState::new(ProcessConfig {
1199            save_background: true,
1200            ..Default::default()
1201        });
1202
1203        assert!(!state.config.valid_background);
1204        assert!(state.background.is_none());
1205
1206        // Process with save_background=true: should capture and clear flag
1207        let input = make_array(&[10, 20, 30]);
1208        let _ = state.process(&input);
1209
1210        assert!(
1211            !state.config.save_background,
1212            "save_background should be cleared"
1213        );
1214        assert!(
1215            state.config.valid_background,
1216            "valid_background should be set"
1217        );
1218        assert!(state.background.is_some());
1219
1220        let bg = state.background.as_ref().unwrap();
1221        assert_eq!(bg.len(), 3);
1222        assert!((bg[0] - 10.0).abs() < 1e-9);
1223        assert!((bg[1] - 20.0).abs() < 1e-9);
1224        assert!((bg[2] - 30.0).abs() < 1e-9);
1225
1226        // Process again: flag should remain cleared, background should persist
1227        let input2 = make_array(&[40, 50, 60]);
1228        let _ = state.process(&input2);
1229
1230        assert!(
1231            !state.config.save_background,
1232            "save_background stays cleared"
1233        );
1234        // Background unchanged
1235        let bg2 = state.background.as_ref().unwrap();
1236        assert!((bg2[0] - 10.0).abs() < 1e-9);
1237    }
1238
1239    #[test]
1240    fn test_save_flat_field_one_shot() {
1241        let mut state = ProcessState::new(ProcessConfig {
1242            save_flat_field: true,
1243            ..Default::default()
1244        });
1245
1246        assert!(!state.config.valid_flat_field);
1247        assert!(state.flat_field.is_none());
1248
1249        let input = make_array(&[50, 100, 150]);
1250        let _ = state.process(&input);
1251
1252        assert!(
1253            !state.config.save_flat_field,
1254            "save_flat_field should be cleared"
1255        );
1256        assert!(
1257            state.config.valid_flat_field,
1258            "valid_flat_field should be set"
1259        );
1260        assert!(state.flat_field.is_some());
1261
1262        let ff = state.flat_field.as_ref().unwrap();
1263        assert_eq!(ff.len(), 3);
1264        assert!((ff[0] - 50.0).abs() < 1e-9);
1265        assert!((ff[1] - 100.0).abs() < 1e-9);
1266        assert!((ff[2] - 150.0).abs() < 1e-9);
1267    }
1268
1269    #[test]
1270    fn test_auto_reset_when_num_filter_reached() {
1271        // Sum filter with auto_reset after 3 frames
1272        let mut state = ProcessState::new(ProcessConfig {
1273            enable_filter: true,
1274            filter: FilterConfig {
1275                num_filter: 3,
1276                auto_reset: true,
1277                fc: [1.0, 0.0, 1.0, 0.0], // sum preset
1278                oc: [1.0, 0.0, 0.0, 0.0],
1279                rc: [0.0, 1.0],
1280                ..Default::default()
1281            },
1282            output_type: Some(NDDataType::Float64),
1283            ..Default::default()
1284        });
1285
1286        // Frame 0 (reset): num_filtered becomes 1
1287        let _ = state.process(&make_f64_array(&[100.0]));
1288        assert_eq!(state.num_filtered, 1);
1289
1290        // Frame 1: num_filtered becomes 2
1291        let _ = state.process(&make_f64_array(&[100.0]));
1292        assert_eq!(state.num_filtered, 2);
1293
1294        // Frame 2: num_filtered becomes 3 = num_filter, triggers auto_reset on next
1295        let _ = state.process(&make_f64_array(&[100.0]));
1296        assert_eq!(state.num_filtered, 3);
1297
1298        // Frame 3: auto_reset fires (num_filtered >= num_filter), filter is reset
1299        let _ = state.process(&make_f64_array(&[200.0]));
1300        // After reset + processing, num_filtered should be 1
1301        assert_eq!(state.num_filtered, 1, "fresh start after auto reset");
1302    }
1303
1304    #[test]
1305    fn test_filter_with_offset_scale() {
1306        // Test that f_offset/f_scale and o_offset/o_scale are applied in C++ manner:
1307        // F1 = fScale * (fc1 + fc2/N), O1 = oScale * (oc1 + oc2/N)
1308        // CopyToFilter: fc=[0,0,1,0], oc=[1,0,0,0]
1309        let mut state = ProcessState::new(ProcessConfig {
1310            enable_filter: true,
1311            filter: FilterConfig {
1312                num_filter: 10,
1313                fc: [0.0, 0.0, 1.0, 0.0], // F1=0, F2=fScale*1
1314                oc: [1.0, 0.0, 0.0, 0.0], // O1=oScale*1, O2=0
1315                rc: [0.0, 1.0],
1316                f_offset: 10.0,
1317                f_scale: 2.0,
1318                o_offset: 5.0,
1319                o_scale: 3.0,
1320                ..Default::default()
1321            },
1322            output_type: Some(NDDataType::Float64),
1323            ..Default::default()
1324        });
1325
1326        // C++ NDPluginProcess.cpp:220-227 doProcess (newFilter uses ORIGINAL data[i]):
1327        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1328        //   newFilter = fOffset + F1*filter[i] + F2*data[i];
1329        //   data[i]   = newData; filter[i] = newFilter;
1330        //
1331        // Frame 0: reset: filter = 0 + 0*filter + 1*50 = 50
1332        // N=1: F1=2*(0+0/1)=0, F2=2*(1+0/1)=2, O1=3*(1+0/1)=3, O2=0
1333        // data   = 5 + 3*50 + 0 = 155
1334        // filter = 10 + 0*50 + 2*50(orig data) = 110
1335        let r0 = state.process(&make_f64_array(&[50.0])).unwrap();
1336        let v0 = r0.data.get_as_f64(0).unwrap();
1337        assert!((v0 - 155.0).abs() < 1e-9, "frame 0: got {v0}");
1338
1339        // Frame 1: data=20, filter=110
1340        // N=2: F1=0, F2=2, O1=3, O2=0
1341        // data   = 5 + 3*110 + 0 = 335
1342        // filter = 10 + 0 + 2*20(orig data) = 50
1343        let r1 = state.process(&make_f64_array(&[20.0])).unwrap();
1344        let v1 = r1.data.get_as_f64(0).unwrap();
1345        assert!((v1 - 335.0).abs() < 1e-9, "frame 1: got {v1}");
1346    }
1347
1348    #[test]
1349    fn test_reset_filter_manual() {
1350        let mut state = ProcessState::new(ProcessConfig {
1351            enable_filter: true,
1352            filter: FilterConfig {
1353                num_filter: 10,
1354                fc: [1.0, 0.0, 1.0, 0.0],
1355                oc: [1.0, 0.0, 0.0, 0.0],
1356                rc: [0.0, 1.0],
1357                ..Default::default()
1358            },
1359            output_type: Some(NDDataType::Float64),
1360            ..Default::default()
1361        });
1362
1363        // Build up filter state
1364        let _ = state.process(&make_f64_array(&[100.0]));
1365        let _ = state.process(&make_f64_array(&[100.0]));
1366        assert!(state.filter_state.is_some());
1367        assert_eq!(state.num_filtered, 2);
1368
1369        // Manual reset
1370        state.reset_filter();
1371        assert!(state.filter_state.is_none());
1372        assert_eq!(state.num_filtered, 0);
1373
1374        // Next frame should act as first frame (reset mode)
1375        let _ = state.process(&make_f64_array(&[200.0]));
1376        assert_eq!(state.num_filtered, 1);
1377    }
1378
1379    #[test]
1380    fn test_auto_offset_scale_applied() {
1381        // Regression: arming auto_offset_scale_pending must run
1382        // auto_offset_scale() on the next process() call.
1383        let mut state = ProcessState::new(ProcessConfig {
1384            output_type: Some(NDDataType::UInt8),
1385            ..Default::default()
1386        });
1387        state.config.auto_offset_scale_pending = true;
1388
1389        // input range [10, 30] => offset=-10, scale=255/20=12.75, clipping on.
1390        let out = state.process(&make_f64_array(&[10.0, 20.0, 30.0])).unwrap();
1391        // The pending flag is consumed.
1392        assert!(!state.config.auto_offset_scale_pending);
1393        // offset/scale were computed and enabled.
1394        assert!(state.config.enable_offset_scale);
1395        assert!((state.config.offset - (-10.0)).abs() < 1e-9);
1396        assert!((state.config.scale - 255.0 / 20.0).abs() < 1e-9);
1397        // value = (value + offset) * scale, clipped to [0, 255].
1398        if let NDDataBuffer::U8(v) = &out.data {
1399            assert_eq!(v[0], 0); // (10-10)*12.75 = 0
1400            assert_eq!(v[2], 255); // (30-10)*12.75 = 255
1401        } else {
1402            panic!("expected u8 output");
1403        }
1404    }
1405
1406    #[test]
1407    fn test_filter_callbacks_drops_suppressed_frame() {
1408        // Regression: with filter_callbacks set, a frame that has not yet
1409        // reached num_filter is dropped (process() returns None), not
1410        // forwarded as the raw input.
1411        let mut state = ProcessState::new(ProcessConfig {
1412            enable_filter: true,
1413            filter: FilterConfig {
1414                num_filter: 3,
1415                filter_callbacks: 1,
1416                fc: [1.0, 0.0, 1.0, 0.0],
1417                oc: [0.0, 1.0, 0.0, 0.0],
1418                rc: [0.0, 1.0],
1419                ..Default::default()
1420            },
1421            output_type: Some(NDDataType::Float64),
1422            ..Default::default()
1423        });
1424
1425        // Frames 1 and 2 are below num_filter => suppressed (None).
1426        assert!(state.process(&make_f64_array(&[100.0])).is_none());
1427        assert!(state.process(&make_f64_array(&[100.0])).is_none());
1428        // Frame 3 reaches num_filter => output produced.
1429        assert!(state.process(&make_f64_array(&[100.0])).is_some());
1430    }
1431
1432    #[test]
1433    fn test_filter_recurrence_matches_cpp() {
1434        // Regression: the filter-state update must read the ORIGINAL input
1435        // data[i], not the just-updated newData. C++ computes both newData
1436        // and newFilter from data[i] before assigning data[i] = newData.
1437        //
1438        // C++ NDPluginProcess.cpp:220-227 doProcess:
1439        //   newData   = oOffset + O1*filter[i] + O2*data[i];
1440        //   newFilter = fOffset + F1*filter[i] + F2*data[i];  // ORIGINAL data[i]
1441        //   data[i]   = newData;
1442        //   filter[i] = newFilter;
1443        //
1444        // Average preset: fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1].
1445        // O1=1/N, O2=0, F1=1, F2=1, all offsets/scales default (0/1).
1446        // With O2=0 and oc default, the C++ recurrence is:
1447        //   data[k]   = filter / N
1448        //   filter'   = filter + input   (F2 multiplies the ORIGINAL input)
1449        //
1450        // Hand-computed reference (inputs 100, 200, 300, 400):
1451        //   reset: filter = 100, N = 1
1452        //   k0: N=1  data = 100/1   = 100      filter = 100 + 100 = 200
1453        //   k1: N=2  data = 200/2   = 100      filter = 200 + 200 = 400
1454        //   k2: N=3  data = 400/3   = 133.333  filter = 400 + 300 = 700
1455        //   k3: N=4  data = 700/4   = 175      filter = 700 + 400 = 1100
1456        //
1457        // The STALE/new-data variant (the 650038bb regression) computed
1458        //   filter' = filter + newData
1459        // giving filter = 100,200,300,400 and data = 100,100,100,100 —
1460        // diverging from C++ from frame 1 onward.
1461        let mut state = ProcessState::new(ProcessConfig {
1462            enable_filter: true,
1463            filter: FilterConfig {
1464                num_filter: 100,
1465                fc: [1.0, 0.0, 1.0, 0.0],
1466                oc: [0.0, 1.0, 0.0, 0.0],
1467                rc: [0.0, 1.0],
1468                ..Default::default()
1469            },
1470            output_type: Some(NDDataType::Float64),
1471            ..Default::default()
1472        });
1473
1474        let inputs = [100.0, 200.0, 300.0, 400.0];
1475        let expected_data = [100.0, 100.0, 400.0 / 3.0, 175.0];
1476        let expected_filter = [200.0, 400.0, 700.0, 1100.0];
1477
1478        for k in 0..inputs.len() {
1479            let r = state.process(&make_f64_array(&[inputs[k]])).unwrap();
1480            let v = r.data.get_as_f64(0).unwrap();
1481            assert!(
1482                (v - expected_data[k]).abs() < 1e-9,
1483                "frame {k}: data got {v}, expected {}",
1484                expected_data[k]
1485            );
1486            let fs = state.filter_state.as_ref().unwrap()[0];
1487            assert!(
1488                (fs - expected_filter[k]).abs() < 1e-9,
1489                "frame {k}: filter got {fs}, expected {}",
1490                expected_filter[k]
1491            );
1492        }
1493    }
1494}