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    /// Read-only status: whether a valid background is loaded.
93    pub valid_background: bool,
94    /// Read-only status: whether a valid flat field is loaded.
95    pub valid_flat_field: bool,
96}
97
98impl Default for ProcessConfig {
99    fn default() -> Self {
100        Self {
101            enable_background: false,
102            enable_flat_field: false,
103            enable_offset_scale: false,
104            offset: 0.0,
105            scale: 1.0,
106            enable_low_clip: false,
107            low_clip_thresh: 0.0,
108            low_clip_value: 0.0,
109            enable_high_clip: false,
110            high_clip_thresh: 100.0,
111            high_clip_value: 100.0,
112            scale_flat_field: 255.0,
113            enable_filter: false,
114            filter: FilterConfig::default(),
115            output_type: None,
116            save_background: false,
117            save_flat_field: false,
118            valid_background: false,
119            valid_flat_field: false,
120        }
121    }
122}
123
124/// State for the process plugin (holds background, flat field, and filter state).
125///
126/// Matches the C++ NDPluginProcess which uses a single `pFilter` array.
127pub struct ProcessState {
128    pub config: ProcessConfig,
129    pub background: Option<Vec<f64>>,
130    pub flat_field: Option<Vec<f64>>,
131    /// Single filter buffer (equivalent to C++ `pFilter`).
132    pub filter_state: Option<Vec<f64>>,
133    /// Number of frames filtered since last reset.
134    pub num_filtered: usize,
135}
136
137impl ProcessState {
138    pub fn new(config: ProcessConfig) -> Self {
139        Self {
140            config,
141            background: None,
142            flat_field: None,
143            filter_state: None,
144            num_filtered: 0,
145        }
146    }
147
148    /// Save the current array as background.
149    pub fn save_background(&mut self, array: &NDArray) {
150        let n = array.data.len();
151        let mut bg = vec![0.0f64; n];
152        for i in 0..n {
153            bg[i] = array.data.get_as_f64(i).unwrap_or(0.0);
154        }
155        self.background = Some(bg);
156        self.config.valid_background = true;
157    }
158
159    /// Save the current array as flat field.
160    pub fn save_flat_field(&mut self, array: &NDArray) {
161        let n = array.data.len();
162        let mut ff = vec![0.0f64; n];
163        for i in 0..n {
164            ff[i] = array.data.get_as_f64(i).unwrap_or(0.0);
165        }
166        self.flat_field = Some(ff);
167        self.config.valid_flat_field = true;
168    }
169
170    /// Auto-calculate offset and scale matching C++ NDPluginProcess.
171    ///
172    /// C++: scale = maxScale / (maxValue - minValue); offset = -minValue;
173    /// Also enables offset/scale processing and clipping (matching C++ lines 238-249).
174    pub fn auto_offset_scale(&mut self, array: &NDArray) {
175        let n = array.data.len();
176        if n == 0 {
177            return;
178        }
179        let mut min_val = f64::MAX;
180        let mut max_val = f64::MIN;
181        for i in 0..n {
182            let v = array.data.get_as_f64(i).unwrap_or(0.0);
183            if v < min_val {
184                min_val = v;
185            }
186            if v > max_val {
187                max_val = v;
188            }
189        }
190        let range = max_val - min_val;
191        if range > 0.0 {
192            // C++: maxScale = pow(2, bytesPerElement*8) - 1
193            let bytes_per_elem = match self.config.output_type.unwrap_or(array.data.data_type()) {
194                NDDataType::Int8 | NDDataType::UInt8 => 1,
195                NDDataType::Int16 | NDDataType::UInt16 => 2,
196                NDDataType::Int32 | NDDataType::UInt32 => 4,
197                NDDataType::Int64 | NDDataType::UInt64 => 8,
198                NDDataType::Float32 => 4,
199                NDDataType::Float64 => 8,
200            };
201            let max_scale = 2.0f64.powi(bytes_per_elem * 8) - 1.0;
202            // C++: scale = maxScale/(maxValue-minValue); offset = -minValue;
203            self.config.scale = max_scale / range;
204            self.config.offset = -min_val;
205            // C++ also enables offset/scale and clipping
206            self.config.enable_offset_scale = true;
207            self.config.enable_low_clip = true;
208            self.config.low_clip_thresh = 0.0;
209            self.config.enable_high_clip = true;
210            self.config.high_clip_thresh = max_scale;
211        }
212    }
213
214    /// Apply a named filter type preset, setting the FC/OC/RC coefficients.
215    ///
216    /// Uses the C++ coefficient scheme where:
217    ///   O1 = oScale * (oc[0] + oc[1]/N), O2 = oScale * (oc[2] + oc[3]/N)
218    ///   F1 = fScale * (fc[0] + fc[1]/N), F2 = fScale * (fc[2] + fc[3]/N)
219    ///   data[i]   = oOffset + O1*filter[i] + O2*data[i]
220    ///   filter[i] = fOffset + F1*filter[i] + F2*data[i]
221    pub fn apply_filter_type(&mut self, filter_type: i32) {
222        let fc = &mut self.config.filter;
223        match filter_type {
224            0 => {
225                // RecursiveAve: running average
226                // F1=fScale*(0 + 1/N)=1/N (old filter weight decreases)
227                // F2=fScale*(1 + -1/N)=(N-1)/N (new data weight increases)
228                // Actually: F[n]=(1-1/N)*F[n-1] + (1/N)*data[n]
229                //   fc1=0, fc2=1 → F1=fScale*(0+1/N)=1/N ← weight on filter
230                // Wait, the formula is: F2=fScale*(fc3+fc4/N)
231                // For recursive avg: filter = ((N-1)*filter + data)/N
232                //   F1 applied to filter: want (N-1)/N → fc1=1, fc2=-1
233                //     F1 = fScale*(1 + (-1)/N) = (N-1)/N ✓
234                //   F2 applied to data: want 1/N → fc3=0, fc4=1
235                //     F2 = fScale*(0 + 1/N) = 1/N ✓
236                // O1 applied to filter: want 1 → oc1=1, oc2=0
237                // O2 applied to data: want 0 → oc3=0, oc4=0
238                fc.fc = [1.0, -1.0, 0.0, 1.0];
239                fc.oc = [1.0, 0.0, 0.0, 0.0];
240                fc.rc = [0.0, 1.0]; // reset: filter = data
241                fc.r_offset = 0.0;
242                fc.f_offset = 0.0;
243                fc.f_scale = 1.0;
244                fc.o_offset = 0.0;
245                fc.o_scale = 1.0;
246            }
247            1 => {
248                // Average: accumulate sum in filter, output = filter/N
249                // filter = filter + data → F1=1*filter, F2=1*data
250                //   fc1=1,fc2=0 → F1=fScale*(1+0/N)=1; fc3=1,fc4=0 → F2=fScale*(1+0/N)=1
251                // output = filter/N → O1=1/N*filter
252                //   oc1=0,oc2=1 → O1=oScale*(0+1/N)=1/N; oc3=0,oc4=0 → O2=0
253                fc.fc = [1.0, 0.0, 1.0, 0.0];
254                fc.oc = [0.0, 1.0, 0.0, 0.0];
255                fc.rc = [0.0, 1.0]; // reset: filter = data
256                fc.r_offset = 0.0;
257                fc.f_offset = 0.0;
258                fc.f_scale = 1.0;
259                fc.o_offset = 0.0;
260                fc.o_scale = 1.0;
261            }
262            2 => {
263                // Sum: filter = filter + data, output = filter
264                fc.fc = [1.0, 0.0, 1.0, 0.0];
265                fc.oc = [1.0, 0.0, 0.0, 0.0];
266                fc.rc = [0.0, 1.0];
267                fc.r_offset = 0.0;
268                fc.f_offset = 0.0;
269                fc.f_scale = 1.0;
270                fc.o_offset = 0.0;
271                fc.o_scale = 1.0;
272            }
273            3 => {
274                // Difference: output = data - filter, filter = data
275                // O1=-1*filter, O2=1*data → oc1=-1,oc2=0,oc3=1,oc4=0
276                // F1=0, F2=1*data → fc1=0,fc2=0,fc3=1,fc4=0
277                fc.fc = [0.0, 0.0, 1.0, 0.0];
278                fc.oc = [-1.0, 0.0, 1.0, 0.0];
279                fc.rc = [0.0, 1.0];
280                fc.r_offset = 0.0;
281                fc.f_offset = 0.0;
282                fc.f_scale = 1.0;
283                fc.o_offset = 0.0;
284                fc.o_scale = 1.0;
285            }
286            4 => {
287                // RecursiveAveDiff: output = data - running_avg
288                // Same filter as RecursiveAve but output = data - filter
289                fc.fc = [1.0, -1.0, 0.0, 1.0];
290                fc.oc = [-1.0, 0.0, 1.0, 0.0];
291                fc.rc = [0.0, 1.0];
292                fc.r_offset = 0.0;
293                fc.f_offset = 0.0;
294                fc.f_scale = 1.0;
295                fc.o_offset = 0.0;
296                fc.o_scale = 1.0;
297            }
298            5 => {
299                // CopyToFilter: filter = data, output = filter
300                fc.fc = [0.0, 0.0, 1.0, 0.0];
301                fc.oc = [1.0, 0.0, 0.0, 0.0];
302                fc.rc = [0.0, 1.0];
303                fc.r_offset = 0.0;
304                fc.f_offset = 0.0;
305                fc.f_scale = 1.0;
306                fc.o_offset = 0.0;
307                fc.o_scale = 1.0;
308            }
309            _ => {} // Unknown type — leave coefficients unchanged
310        }
311    }
312
313    /// Reset the filter state, clearing the filter buffer.
314    pub fn reset_filter(&mut self) {
315        self.filter_state = None;
316        self.num_filtered = 0;
317    }
318
319    /// Process an array through the configured pipeline.
320    pub fn process(&mut self, src: &NDArray) -> NDArray {
321        let n = src.data.len();
322        let mut values = vec![0.0f64; n];
323        for i in 0..n {
324            values[i] = src.data.get_as_f64(i).unwrap_or(0.0);
325        }
326
327        // 0. Save background/flat field (one-shot flags)
328        if self.config.save_background {
329            self.save_background(src);
330            self.config.save_background = false;
331        }
332        if self.config.save_flat_field {
333            self.save_flat_field(src);
334            self.config.save_flat_field = false;
335        }
336
337        // Stages 1-4: element-wise operations (background, flat field, offset+scale, clipping)
338        // These can be combined into a single pass and parallelized.
339        let needs_element_ops = self.config.enable_background
340            || self.config.enable_flat_field
341            || self.config.enable_offset_scale
342            || self.config.enable_low_clip
343            || self.config.enable_high_clip;
344
345        if needs_element_ops {
346            let bg = if self.config.enable_background {
347                self.background.as_ref()
348            } else {
349                None
350            };
351            let (ff, ff_scale) = if self.config.enable_flat_field {
352                if let Some(ref ff) = self.flat_field {
353                    let scale = if self.config.scale_flat_field > 0.0 {
354                        self.config.scale_flat_field
355                    } else {
356                        ff.iter().sum::<f64>() / ff.len().max(1) as f64
357                    };
358                    (Some(ff.as_slice()), scale)
359                } else {
360                    (None, 0.0)
361                }
362            } else {
363                (None, 0.0)
364            };
365            let do_offset_scale = self.config.enable_offset_scale;
366            let scale = self.config.scale;
367            let offset = self.config.offset;
368            let do_low_clip = self.config.enable_low_clip;
369            let low_clip_thresh = self.config.low_clip_thresh;
370            let low_clip_value = self.config.low_clip_value;
371            let do_high_clip = self.config.enable_high_clip;
372            let high_clip_thresh = self.config.high_clip_thresh;
373            let high_clip_value = self.config.high_clip_value;
374
375            let apply_stages = |i: usize, v: &mut f64| {
376                // Stage 1: Background subtraction
377                if let Some(bg) = bg {
378                    if i < bg.len() {
379                        *v -= bg[i];
380                    }
381                }
382                // Stage 2: Flat field normalization
383                if let Some(ff) = ff {
384                    if i < ff.len() && ff[i] != 0.0 {
385                        *v = *v * ff_scale / ff[i];
386                    }
387                }
388                // Stage 3: Offset + scale (C++: value = (value + offset) * scale)
389                if do_offset_scale {
390                    *v = (*v + offset) * scale;
391                }
392                // Stage 4: Clipping
393                if do_low_clip && *v < low_clip_thresh {
394                    *v = low_clip_value;
395                }
396                if do_high_clip && *v > high_clip_thresh {
397                    *v = high_clip_value;
398                }
399            };
400
401            #[cfg(feature = "parallel")]
402            let use_parallel = par_util::should_parallelize(n);
403            #[cfg(not(feature = "parallel"))]
404            let use_parallel = false;
405
406            if use_parallel {
407                #[cfg(feature = "parallel")]
408                par_util::thread_pool().install(|| {
409                    values.par_iter_mut().enumerate().for_each(|(i, v)| {
410                        apply_stages(i, v);
411                    });
412                });
413            } else {
414                for (i, v) in values.iter_mut().enumerate() {
415                    apply_stages(i, v);
416                }
417            }
418        }
419
420        // 5. Recursive filter (matching C++ NDPluginProcess algorithm)
421        if self.config.enable_filter {
422            let fc = &self.config.filter;
423
424            // Ensure filter buffer exists and matches element count
425            if let Some(ref f) = self.filter_state {
426                if f.len() != n {
427                    self.filter_state = None;
428                }
429            }
430
431            let mut reset_filter = self.filter_state.is_none();
432            if self.num_filtered >= fc.num_filter && fc.auto_reset {
433                reset_filter = true;
434            }
435
436            // Initialize filter buffer from current data if needed
437            if self.filter_state.is_none() {
438                self.filter_state = Some(values.clone());
439            }
440
441            let filter = self.filter_state.as_mut().unwrap();
442
443            if reset_filter {
444                // C++: filter[i] = rOffset + rc1*filter[i] + rc2*data[i]
445                let r_offset = fc.r_offset;
446                let rc1 = fc.rc[0];
447                let rc2 = fc.rc[1];
448                for i in 0..n {
449                    let new_filter = r_offset + rc1 * filter[i] + rc2 * values[i];
450                    filter[i] = new_filter;
451                }
452                self.num_filtered = 0;
453            }
454
455            // Increment filtered count (C++: if (numFiltered < numFilter) numFiltered++)
456            if self.num_filtered < fc.num_filter {
457                self.num_filtered += 1;
458            }
459
460            // Compute effective coefficients (depend on numFiltered)
461            let nf = self.num_filtered as f64;
462            let o1 = fc.o_scale * (fc.oc[0] + fc.oc[1] / nf);
463            let o2 = fc.o_scale * (fc.oc[2] + fc.oc[3] / nf);
464            let f1 = fc.f_scale * (fc.fc[0] + fc.fc[1] / nf);
465            let f2 = fc.f_scale * (fc.fc[2] + fc.fc[3] / nf);
466            let o_offset = fc.o_offset;
467            let f_offset = fc.f_offset;
468
469            // C++: data[i] = oOffset + O1*filter[i] + O2*data[i]
470            //      filter[i] = fOffset + F1*filter[i] + F2*data[i]
471            for i in 0..n {
472                let new_data = o_offset + o1 * filter[i] + o2 * values[i];
473                let new_filter = f_offset + f1 * filter[i] + f2 * values[i];
474                values[i] = new_data;
475                filter[i] = new_filter;
476            }
477
478            // Suppress output if filterCallbacks is set and we haven't reached numFilter
479            if fc.filter_callbacks > 0 && self.num_filtered != fc.num_filter {
480                // C++: doCallbacks = 0 — skip output
481                // Return the input unchanged (no processing output)
482                return src.clone();
483            }
484        }
485
486        // Build output
487        let out_type = self.config.output_type.unwrap_or(src.data.data_type());
488        let mut out_data = NDDataBuffer::zeros(out_type, n);
489        for i in 0..n {
490            out_data.set_from_f64(i, values[i]);
491        }
492
493        let mut arr = NDArray::new(src.dims.clone(), out_type);
494        arr.data = out_data;
495        arr.unique_id = src.unique_id;
496        arr.timestamp = src.timestamp;
497        arr.attributes = src.attributes.clone();
498        arr
499    }
500}
501
502// --- ProcessProcessor (NDPluginProcess-based) ---
503
504/// Param indices for the process plugin.
505#[derive(Default)]
506struct ProcParamIndices {
507    data_type: Option<usize>,
508    save_background: Option<usize>,
509    enable_background: Option<usize>,
510    valid_background: Option<usize>,
511    save_flat_field: Option<usize>,
512    enable_flat_field: Option<usize>,
513    valid_flat_field: Option<usize>,
514    scale_flat_field: Option<usize>,
515    enable_offset_scale: Option<usize>,
516    auto_offset_scale: Option<usize>,
517    offset: Option<usize>,
518    scale: Option<usize>,
519    enable_low_clip: Option<usize>,
520    low_clip_thresh: Option<usize>,
521    low_clip_value: Option<usize>,
522    enable_high_clip: Option<usize>,
523    high_clip_thresh: Option<usize>,
524    high_clip_value: Option<usize>,
525    enable_filter: Option<usize>,
526    filter_type: Option<usize>,
527    reset_filter: Option<usize>,
528    auto_reset_filter: Option<usize>,
529    filter_callbacks: Option<usize>,
530    num_filter: Option<usize>,
531    num_filtered: Option<usize>,
532    o_offset: Option<usize>,
533    o_scale: Option<usize>,
534    oc: [Option<usize>; 4],
535    f_offset: Option<usize>,
536    f_scale: Option<usize>,
537    fc: [Option<usize>; 4],
538    r_offset: Option<usize>,
539    rc: [Option<usize>; 2],
540}
541
542/// ProcessProcessor wraps existing ProcessState.
543pub struct ProcessProcessor {
544    state: ProcessState,
545    params: ProcParamIndices,
546}
547
548impl ProcessProcessor {
549    pub fn new(config: ProcessConfig) -> Self {
550        Self {
551            state: ProcessState::new(config),
552            params: ProcParamIndices::default(),
553        }
554    }
555
556    pub fn state(&self) -> &ProcessState {
557        &self.state
558    }
559
560    pub fn state_mut(&mut self) -> &mut ProcessState {
561        &mut self.state
562    }
563}
564
565impl NDPluginProcess for ProcessProcessor {
566    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
567        use ad_core_rs::plugin::runtime::ParamUpdate;
568
569        let out = self.state.process(array);
570        let mut result = ProcessResult::arrays(vec![Arc::new(out)]);
571
572        // Push readback params
573        if let Some(idx) = self.params.valid_background {
574            result.param_updates.push(ParamUpdate::int32(
575                idx,
576                if self.state.config.valid_background {
577                    1
578                } else {
579                    0
580                },
581            ));
582        }
583        if let Some(idx) = self.params.valid_flat_field {
584            result.param_updates.push(ParamUpdate::int32(
585                idx,
586                if self.state.config.valid_flat_field {
587                    1
588                } else {
589                    0
590                },
591            ));
592        }
593        if let Some(idx) = self.params.num_filtered {
594            result
595                .param_updates
596                .push(ParamUpdate::int32(idx, self.state.num_filtered as i32));
597        }
598        // Reset save_background/save_flat_field readback to 0 (one-shot)
599        if let Some(idx) = self.params.save_background {
600            result.param_updates.push(ParamUpdate::int32(idx, 0));
601        }
602        if let Some(idx) = self.params.save_flat_field {
603            result.param_updates.push(ParamUpdate::int32(idx, 0));
604        }
605
606        result
607    }
608
609    fn plugin_type(&self) -> &str {
610        "NDPluginProcess"
611    }
612
613    fn register_params(
614        &mut self,
615        base: &mut asyn_rs::port::PortDriverBase,
616    ) -> asyn_rs::error::AsynResult<()> {
617        use asyn_rs::param::ParamType;
618        base.create_param("PROCESS_DATA_TYPE", ParamType::Int32)?;
619        base.create_param("SAVE_BACKGROUND", ParamType::Int32)?;
620        base.create_param("ENABLE_BACKGROUND", ParamType::Int32)?;
621        base.create_param("VALID_BACKGROUND", ParamType::Int32)?;
622        base.create_param("SAVE_FLAT_FIELD", ParamType::Int32)?;
623        base.create_param("ENABLE_FLAT_FIELD", ParamType::Int32)?;
624        base.create_param("VALID_FLAT_FIELD", ParamType::Int32)?;
625        base.create_param("SCALE_FLAT_FIELD", ParamType::Float64)?;
626        base.create_param("ENABLE_OFFSET_SCALE", ParamType::Int32)?;
627        base.create_param("AUTO_OFFSET_SCALE", ParamType::Int32)?;
628        base.create_param("OFFSET", ParamType::Float64)?;
629        base.create_param("SCALE", ParamType::Float64)?;
630        base.create_param("ENABLE_LOW_CLIP", ParamType::Int32)?;
631        base.create_param("LOW_CLIP_THRESH", ParamType::Float64)?;
632        base.create_param("LOW_CLIP_VALUE", ParamType::Float64)?;
633        base.create_param("ENABLE_HIGH_CLIP", ParamType::Int32)?;
634        base.create_param("HIGH_CLIP_THRESH", ParamType::Float64)?;
635        base.create_param("HIGH_CLIP_VALUE", ParamType::Float64)?;
636        base.create_param("ENABLE_FILTER", ParamType::Int32)?;
637        base.create_param("FILTER_TYPE", ParamType::Int32)?;
638        base.create_param("RESET_FILTER", ParamType::Int32)?;
639        base.create_param("AUTO_RESET_FILTER", ParamType::Int32)?;
640        base.create_param("FILTER_CALLBACKS", ParamType::Int32)?;
641        base.create_param("NUM_FILTER", ParamType::Int32)?;
642        base.create_param("NUM_FILTERED", ParamType::Int32)?;
643        base.create_param("FILTER_OOFFSET", ParamType::Float64)?;
644        base.create_param("FILTER_OSCALE", ParamType::Float64)?;
645        base.create_param("FILTER_OC1", ParamType::Float64)?;
646        base.create_param("FILTER_OC2", ParamType::Float64)?;
647        base.create_param("FILTER_OC3", ParamType::Float64)?;
648        base.create_param("FILTER_OC4", ParamType::Float64)?;
649        base.create_param("FILTER_FOFFSET", ParamType::Float64)?;
650        base.create_param("FILTER_FSCALE", ParamType::Float64)?;
651        base.create_param("FILTER_FC1", ParamType::Float64)?;
652        base.create_param("FILTER_FC2", ParamType::Float64)?;
653        base.create_param("FILTER_FC3", ParamType::Float64)?;
654        base.create_param("FILTER_FC4", ParamType::Float64)?;
655        base.create_param("FILTER_ROFFSET", ParamType::Float64)?;
656        base.create_param("FILTER_RC1", ParamType::Float64)?;
657        base.create_param("FILTER_RC2", ParamType::Float64)?;
658
659        // Look up param indices
660        self.params.data_type = base.find_param("PROCESS_DATA_TYPE");
661        self.params.save_background = base.find_param("SAVE_BACKGROUND");
662        self.params.enable_background = base.find_param("ENABLE_BACKGROUND");
663        self.params.valid_background = base.find_param("VALID_BACKGROUND");
664        self.params.save_flat_field = base.find_param("SAVE_FLAT_FIELD");
665        self.params.enable_flat_field = base.find_param("ENABLE_FLAT_FIELD");
666        self.params.valid_flat_field = base.find_param("VALID_FLAT_FIELD");
667        self.params.scale_flat_field = base.find_param("SCALE_FLAT_FIELD");
668        self.params.enable_offset_scale = base.find_param("ENABLE_OFFSET_SCALE");
669        self.params.auto_offset_scale = base.find_param("AUTO_OFFSET_SCALE");
670        self.params.offset = base.find_param("OFFSET");
671        self.params.scale = base.find_param("SCALE");
672        self.params.enable_low_clip = base.find_param("ENABLE_LOW_CLIP");
673        self.params.low_clip_thresh = base.find_param("LOW_CLIP_THRESH");
674        self.params.low_clip_value = base.find_param("LOW_CLIP_VALUE");
675        self.params.enable_high_clip = base.find_param("ENABLE_HIGH_CLIP");
676        self.params.high_clip_thresh = base.find_param("HIGH_CLIP_THRESH");
677        self.params.high_clip_value = base.find_param("HIGH_CLIP_VALUE");
678        self.params.enable_filter = base.find_param("ENABLE_FILTER");
679        self.params.filter_type = base.find_param("FILTER_TYPE");
680        self.params.reset_filter = base.find_param("RESET_FILTER");
681        self.params.auto_reset_filter = base.find_param("AUTO_RESET_FILTER");
682        self.params.filter_callbacks = base.find_param("FILTER_CALLBACKS");
683        self.params.num_filter = base.find_param("NUM_FILTER");
684        self.params.num_filtered = base.find_param("NUM_FILTERED");
685        self.params.o_offset = base.find_param("FILTER_OOFFSET");
686        self.params.o_scale = base.find_param("FILTER_OSCALE");
687        self.params.oc[0] = base.find_param("FILTER_OC1");
688        self.params.oc[1] = base.find_param("FILTER_OC2");
689        self.params.oc[2] = base.find_param("FILTER_OC3");
690        self.params.oc[3] = base.find_param("FILTER_OC4");
691        self.params.f_offset = base.find_param("FILTER_FOFFSET");
692        self.params.f_scale = base.find_param("FILTER_FSCALE");
693        self.params.fc[0] = base.find_param("FILTER_FC1");
694        self.params.fc[1] = base.find_param("FILTER_FC2");
695        self.params.fc[2] = base.find_param("FILTER_FC3");
696        self.params.fc[3] = base.find_param("FILTER_FC4");
697        self.params.r_offset = base.find_param("FILTER_ROFFSET");
698        self.params.rc[0] = base.find_param("FILTER_RC1");
699        self.params.rc[1] = base.find_param("FILTER_RC2");
700        Ok(())
701    }
702
703    fn on_param_change(
704        &mut self,
705        reason: usize,
706        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
707    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
708        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamUpdate};
709
710        let s = &mut self.state;
711        let p = &self.params;
712        let mut updates = Vec::new();
713
714        if Some(reason) == p.data_type {
715            let v = params.value.as_i32();
716            s.config.output_type = if v < 0 {
717                None // Automatic
718            } else {
719                NDDataType::from_ordinal(v as u8)
720            };
721        } else if Some(reason) == p.save_background {
722            if params.value.as_i32() != 0 {
723                s.config.save_background = true;
724            }
725        } else if Some(reason) == p.enable_background {
726            s.config.enable_background = params.value.as_i32() != 0;
727        } else if Some(reason) == p.save_flat_field {
728            if params.value.as_i32() != 0 {
729                s.config.save_flat_field = true;
730            }
731        } else if Some(reason) == p.enable_flat_field {
732            s.config.enable_flat_field = params.value.as_i32() != 0;
733        } else if Some(reason) == p.scale_flat_field {
734            s.config.scale_flat_field = params.value.as_f64();
735        } else if Some(reason) == p.enable_offset_scale {
736            s.config.enable_offset_scale = params.value.as_i32() != 0;
737        } else if Some(reason) == p.auto_offset_scale {
738            if params.value.as_i32() != 0 {
739                if let Some(ref arr) = self.state.background {
740                    // Use background to estimate range if available
741                    let _ = arr; // auto_offset_scale needs an NDArray, deferred to process_array
742                }
743                // Will be applied when next array arrives — set flag
744                // C ADCore applies auto-calc immediately on the latest array.
745                // We apply it as a one-shot in process_array via the flag.
746            }
747        } else if Some(reason) == p.offset {
748            s.config.offset = params.value.as_f64();
749        } else if Some(reason) == p.scale {
750            s.config.scale = params.value.as_f64();
751        } else if Some(reason) == p.enable_low_clip {
752            s.config.enable_low_clip = params.value.as_i32() != 0;
753        } else if Some(reason) == p.low_clip_thresh {
754            s.config.low_clip_thresh = params.value.as_f64();
755        } else if Some(reason) == p.low_clip_value {
756            s.config.low_clip_value = params.value.as_f64();
757        } else if Some(reason) == p.enable_high_clip {
758            s.config.enable_high_clip = params.value.as_i32() != 0;
759        } else if Some(reason) == p.high_clip_thresh {
760            s.config.high_clip_thresh = params.value.as_f64();
761        } else if Some(reason) == p.high_clip_value {
762            s.config.high_clip_value = params.value.as_f64();
763        } else if Some(reason) == p.enable_filter {
764            s.config.enable_filter = params.value.as_i32() != 0;
765        } else if Some(reason) == p.filter_type {
766            s.apply_filter_type(params.value.as_i32());
767            s.reset_filter();
768            // Push updated coefficients back
769            let fc = &s.config.filter;
770            for (i, idx) in p.fc.iter().enumerate() {
771                if let Some(idx) = *idx {
772                    updates.push(ParamUpdate::float64(idx, fc.fc[i]));
773                }
774            }
775            for (i, idx) in p.oc.iter().enumerate() {
776                if let Some(idx) = *idx {
777                    updates.push(ParamUpdate::float64(idx, fc.oc[i]));
778                }
779            }
780            for (i, idx) in p.rc.iter().enumerate() {
781                if let Some(idx) = *idx {
782                    updates.push(ParamUpdate::float64(idx, fc.rc[i]));
783                }
784            }
785            if let Some(idx) = p.f_offset {
786                updates.push(ParamUpdate::float64(idx, fc.f_offset));
787            }
788            if let Some(idx) = p.f_scale {
789                updates.push(ParamUpdate::float64(idx, fc.f_scale));
790            }
791            if let Some(idx) = p.o_offset {
792                updates.push(ParamUpdate::float64(idx, fc.o_offset));
793            }
794            if let Some(idx) = p.o_scale {
795                updates.push(ParamUpdate::float64(idx, fc.o_scale));
796            }
797        } else if Some(reason) == p.reset_filter {
798            if params.value.as_i32() != 0 {
799                s.reset_filter();
800                if let Some(idx) = p.num_filtered {
801                    updates.push(ParamUpdate::int32(idx, 0));
802                }
803            }
804        } else if Some(reason) == p.auto_reset_filter {
805            s.config.filter.auto_reset = params.value.as_i32() != 0;
806        } else if Some(reason) == p.filter_callbacks {
807            s.config.filter.filter_callbacks = params.value.as_i32().max(0) as usize;
808        } else if Some(reason) == p.num_filter {
809            s.config.filter.num_filter = params.value.as_i32().max(1) as usize;
810        } else if Some(reason) == p.o_offset {
811            s.config.filter.o_offset = params.value.as_f64();
812        } else if Some(reason) == p.o_scale {
813            s.config.filter.o_scale = params.value.as_f64();
814        } else if Some(reason) == p.f_offset {
815            s.config.filter.f_offset = params.value.as_f64();
816        } else if Some(reason) == p.f_scale {
817            s.config.filter.f_scale = params.value.as_f64();
818        } else if Some(reason) == p.r_offset {
819            s.config.filter.r_offset = params.value.as_f64();
820        } else {
821            // Check individual OC/FC/RC params
822            for i in 0..4 {
823                if Some(reason) == p.oc[i] {
824                    s.config.filter.oc[i] = params.value.as_f64();
825                    return ParamChangeResult::updates(vec![]);
826                }
827                if Some(reason) == p.fc[i] {
828                    s.config.filter.fc[i] = params.value.as_f64();
829                    return ParamChangeResult::updates(vec![]);
830                }
831            }
832            for i in 0..2 {
833                if Some(reason) == p.rc[i] {
834                    s.config.filter.rc[i] = params.value.as_f64();
835                    return ParamChangeResult::updates(vec![]);
836                }
837            }
838        }
839
840        ParamChangeResult::updates(updates)
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
848
849    fn make_array(vals: &[u8]) -> NDArray {
850        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::UInt8);
851        if let NDDataBuffer::U8(ref mut v) = arr.data {
852            v.copy_from_slice(vals);
853        }
854        arr
855    }
856
857    fn make_f64_array(vals: &[f64]) -> NDArray {
858        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::Float64);
859        if let NDDataBuffer::F64(ref mut v) = arr.data {
860            v.copy_from_slice(vals);
861        }
862        arr
863    }
864
865    #[test]
866    fn test_background_subtraction() {
867        let bg_arr = make_array(&[10, 20, 30]);
868        let input = make_array(&[15, 25, 35]);
869
870        let mut state = ProcessState::new(ProcessConfig {
871            enable_background: true,
872            ..Default::default()
873        });
874        state.save_background(&bg_arr);
875
876        let result = state.process(&input);
877        if let NDDataBuffer::U8(ref v) = result.data {
878            assert_eq!(v[0], 5);
879            assert_eq!(v[1], 5);
880            assert_eq!(v[2], 5);
881        }
882    }
883
884    #[test]
885    fn test_flat_field() {
886        let ff_arr = make_array(&[100, 200, 50]);
887        let input = make_array(&[100, 200, 50]);
888
889        let mut state = ProcessState::new(ProcessConfig {
890            enable_flat_field: true,
891            scale_flat_field: 0.0, // use mean
892            ..Default::default()
893        });
894        state.save_flat_field(&ff_arr);
895
896        let result = state.process(&input);
897        // After flat field: all values should be normalized to the mean
898        if let NDDataBuffer::U8(ref v) = result.data {
899            // ff_mean ~= 116.67, so all values should be ~= 116
900            assert!((v[0] as f64 - 116.67).abs() < 1.0);
901            assert!((v[1] as f64 - 116.67).abs() < 1.0);
902            assert!((v[2] as f64 - 116.67).abs() < 1.0);
903        }
904    }
905
906    #[test]
907    fn test_offset_scale() {
908        let input = make_array(&[10, 20, 30]);
909        let mut state = ProcessState::new(ProcessConfig {
910            enable_offset_scale: true,
911            scale: 2.0,
912            offset: 5.0,
913            ..Default::default()
914        });
915
916        let result = state.process(&input);
917        if let NDDataBuffer::U8(ref v) = result.data {
918            // C++: value = (value + offset) * scale
919            assert_eq!(v[0], 30); // (10+5)*2
920            assert_eq!(v[1], 50); // (20+5)*2
921            assert_eq!(v[2], 70); // (30+5)*2
922        }
923    }
924
925    #[test]
926    fn test_clipping() {
927        let input = make_array(&[5, 50, 200]);
928        let mut state = ProcessState::new(ProcessConfig {
929            enable_low_clip: true,
930            low_clip_thresh: 10.0,
931            low_clip_value: 10.0,
932            enable_high_clip: true,
933            high_clip_thresh: 100.0,
934            high_clip_value: 100.0,
935            ..Default::default()
936        });
937
938        let result = state.process(&input);
939        if let NDDataBuffer::U8(ref v) = result.data {
940            assert_eq!(v[0], 10); // clipped up
941            assert_eq!(v[1], 50); // unchanged
942            assert_eq!(v[2], 100); // clipped down
943        }
944    }
945
946    #[test]
947    fn test_recursive_filter() {
948        // Test a simple recursive filter: filter = 0.5*filter + 0.5*data, output = filter
949        // Using C++ coefficient scheme:
950        //   F1 = fScale*(fc1+fc2/N), F2 = fScale*(fc3+fc4/N)
951        //   For constant F1=0.5, F2=0.5 regardless of N:
952        //   fc1=0.5, fc2=0, fc3=0.5, fc4=0
953        let input1 = make_array(&[100, 100, 100]);
954        let input2 = make_array(&[0, 0, 0]);
955
956        let mut state = ProcessState::new(ProcessConfig {
957            enable_filter: true,
958            filter: FilterConfig {
959                num_filter: 10,
960                fc: [0.5, 0.0, 0.5, 0.0], // F1=0.5, F2=0.5
961                oc: [1.0, 0.0, 0.0, 0.0], // O1=1, O2=0
962                rc: [0.0, 1.0],           // reset: filter = data
963                ..Default::default()
964            },
965            ..Default::default()
966        });
967
968        // Frame 0: reset: filter=100
969        // N=1: F1=0.5, F2=0.5, O1=1
970        // data = 0+1*100+0*100 = 100, filter = 0+0.5*100+0.5*100 = 100
971        let _ = state.process(&input1);
972
973        // Frame 1: data=0, filter=100
974        // N=2: F1=0.5, F2=0.5, O1=1
975        // data = 0+1*100+0*0 = 100, filter = 0+0.5*100+0.5*0 = 50
976        let result = state.process(&input2);
977        if let NDDataBuffer::U8(ref v) = result.data {
978            // Output is filter value BEFORE this update: data = O1*filter = 1*100 = 100
979            assert_eq!(v[0], 100);
980            assert_eq!(v[1], 100);
981        }
982    }
983
984    #[test]
985    fn test_output_type_conversion() {
986        let input = make_array(&[10, 20, 30]);
987        let mut state = ProcessState::new(ProcessConfig {
988            output_type: Some(NDDataType::Float64),
989            ..Default::default()
990        });
991
992        let result = state.process(&input);
993        assert_eq!(result.data.data_type(), NDDataType::Float64);
994    }
995
996    // --- ProcessProcessor tests ---
997
998    #[test]
999    fn test_process_processor() {
1000        let mut proc = ProcessProcessor::new(ProcessConfig {
1001            enable_offset_scale: true,
1002            scale: 2.0,
1003            offset: 1.0,
1004            ..Default::default()
1005        });
1006        let pool = NDArrayPool::new(1_000_000);
1007
1008        let input = make_array(&[10, 20, 30]);
1009        let result = proc.process_array(&input, &pool);
1010        assert_eq!(result.output_arrays.len(), 1);
1011        if let NDDataBuffer::U8(ref v) = result.output_arrays[0].data {
1012            assert_eq!(v[0], 22); // (10+1)*2 = 22 (C++: offset first, then scale)
1013        }
1014    }
1015
1016    // --- New Phase 2-1 tests ---
1017
1018    #[test]
1019    fn test_filter_sum_preset() {
1020        // Sum preset: filter = filter + data, output = filter
1021        // fc=[1,0,1,0], oc=[1,0,0,0], rc=[0,1]
1022        let mut state = ProcessState::new(ProcessConfig {
1023            enable_filter: true,
1024            filter: FilterConfig {
1025                num_filter: 10,
1026                fc: [1.0, 0.0, 1.0, 0.0],
1027                oc: [1.0, 0.0, 0.0, 0.0],
1028                rc: [0.0, 1.0],
1029                ..Default::default()
1030            },
1031            output_type: Some(NDDataType::Float64),
1032            ..Default::default()
1033        });
1034
1035        // Frame 0 (reset): filter = 0 + 0*filter + 1*data = 100
1036        // N=1: F1=1*(1+0/1)=1, F2=1*(1+0/1)=1 → filter=0+1*100+1*100=200? No.
1037        // Actually reset first: filter = rOffset + rc1*filter + rc2*data = 0 + 0*100 + 1*100 = 100
1038        // Then N increments to 1, then normal path:
1039        // F1=fScale*(fc1+fc2/N)=1*(1+0/1)=1, F2=fScale*(fc3+fc4/N)=1*(1+0/1)=1
1040        // O1=oScale*(oc1+oc2/N)=1*(1+0/1)=1, O2=oScale*(oc3+oc4/N)=1*(0+0/1)=0
1041        // data = oOffset + O1*filter + O2*data = 0 + 1*100 + 0*100 = 100
1042        // filter = fOffset + F1*filter + F2*data = 0 + 1*100 + 1*100 = 200
1043        let r0 = state.process(&make_f64_array(&[100.0]));
1044        let v0 = r0.data.get_as_f64(0).unwrap();
1045        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1046
1047        // Frame 1: data=100, filter=200 (from prev)
1048        // N increments to 2
1049        // F1=1*(1+0/2)=1, F2=1*(1+0/2)=1
1050        // O1=1*(1+0/2)=1, O2=0
1051        // data = 0+1*200+0*100 = 200
1052        // filter = 0+1*200+1*100 = 300
1053        let r1 = state.process(&make_f64_array(&[100.0]));
1054        let v1 = r1.data.get_as_f64(0).unwrap();
1055        assert!((v1 - 200.0).abs() < 1e-9, "frame 1: got {v1}");
1056    }
1057
1058    #[test]
1059    fn test_filter_average_preset() {
1060        // Average preset: accumulate in filter, output = filter/N
1061        // fc=[1,0,1,0], oc=[0,1,0,0], rc=[0,1]
1062        let mut state = ProcessState::new(ProcessConfig {
1063            enable_filter: true,
1064            filter: FilterConfig {
1065                num_filter: 10,
1066                fc: [1.0, 0.0, 1.0, 0.0],
1067                oc: [0.0, 1.0, 0.0, 0.0],
1068                rc: [0.0, 1.0],
1069                ..Default::default()
1070            },
1071            output_type: Some(NDDataType::Float64),
1072            ..Default::default()
1073        });
1074
1075        // Frame 0 (reset): filter=100
1076        // N=1: O1=oScale*(0+1/1)=1, O2=0
1077        // data = 0 + 1*100 + 0 = 100
1078        // filter = 0 + 1*100 + 1*100 = 200
1079        let r0 = state.process(&make_f64_array(&[100.0]));
1080        let v0 = r0.data.get_as_f64(0).unwrap();
1081        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1082
1083        // Frame 1: data=200, filter=200
1084        // N=2: O1=oScale*(0+1/2)=0.5, O2=0
1085        // data = 0 + 0.5*200 + 0 = 100
1086        // filter = 0 + 1*200 + 1*200 = 400
1087        let r1 = state.process(&make_f64_array(&[200.0]));
1088        let v1 = r1.data.get_as_f64(0).unwrap();
1089        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1090
1091        // Frame 2: data=300, filter=400
1092        // N=3: O1=1/3, O2=0
1093        // data = 0 + (1/3)*400 + 0 = 133.33...  but wait filter=400
1094        // Actually filter = 0 + 1*400 + 1*300 = 700
1095        // data = 0 + (1/3)*400 + 0 = 133.33
1096        // Hmm, the output should be sum/N. After frame 2: sum=100+200+300=600, N=3, avg=200
1097        // But filter tracks accumulated sum: after reset(100), then +200=300? No.
1098        // Let me re-trace:
1099        //   Reset: filter=100. N=1.
1100        //   Frame 0 normal: filter = 0+1*100+1*100 = 200. data = 0+1*100 = 100.
1101        //   Frame 1: filter = 0+1*200+1*200 = 400. N=2. data = 0+0.5*200 = 100.
1102        //   Frame 2: filter = 0+1*400+1*300 = 700. N=3. data = 0+(1/3)*400 = 133.33
1103        // The issue is the filter accumulates filter+data not just sum of inputs.
1104        // This matches C++ behavior where the filter buffer interacts with data.
1105        let r2 = state.process(&make_f64_array(&[300.0]));
1106        let v2 = r2.data.get_as_f64(0).unwrap();
1107        let expected = 400.0 / 3.0; // ~133.33
1108        assert!((v2 - expected).abs() < 1e-9, "frame 2: got {v2}");
1109    }
1110
1111    #[test]
1112    fn test_filter_recursive_ave() {
1113        // RecursiveAve preset matching C++ behavior
1114        // fc=[1,-1,0,1], oc=[1,0,0,0], rc=[0,1]
1115        // F1=fScale*(1+(-1)/N)=(N-1)/N, F2=fScale*(0+1/N)=1/N
1116        // O1=oScale*(1+0/N)=1, O2=0
1117        let mut state = ProcessState::new(ProcessConfig {
1118            enable_filter: true,
1119            filter: FilterConfig {
1120                num_filter: 10,
1121                fc: [1.0, -1.0, 0.0, 1.0],
1122                oc: [1.0, 0.0, 0.0, 0.0],
1123                rc: [0.0, 1.0],
1124                ..Default::default()
1125            },
1126            output_type: Some(NDDataType::Float64),
1127            ..Default::default()
1128        });
1129
1130        // Frame 0: reset filter=100, N=1
1131        // F1=1*(1-1/1)=0, F2=1*(0+1/1)=1
1132        // O1=1*(1+0/1)=1
1133        // data = 0 + 1*100 + 0*100 = 100
1134        // filter = 0 + 0*100 + 1*100 = 100
1135        let r0 = state.process(&make_f64_array(&[100.0]));
1136        let v0 = r0.data.get_as_f64(0).unwrap();
1137        assert!((v0 - 100.0).abs() < 1e-9, "frame 0: got {v0}");
1138
1139        // Frame 1: data=200, filter=100, N=2
1140        // F1=(2-1)/2=0.5, F2=1/2=0.5
1141        // data = 0 + 1*100 + 0*200 = 100
1142        // filter = 0 + 0.5*100 + 0.5*200 = 150
1143        let r1 = state.process(&make_f64_array(&[200.0]));
1144        let v1 = r1.data.get_as_f64(0).unwrap();
1145        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1146
1147        // Frame 2: data=300, filter=150, N=3
1148        // F1=2/3, F2=1/3
1149        // data = 1*150 = 150
1150        // filter = (2/3)*150 + (1/3)*300 = 100+100 = 200
1151        let r2 = state.process(&make_f64_array(&[300.0]));
1152        let v2 = r2.data.get_as_f64(0).unwrap();
1153        assert!((v2 - 150.0).abs() < 1e-9, "frame 2: got {v2}");
1154    }
1155
1156    #[test]
1157    fn test_save_background_one_shot() {
1158        let mut state = ProcessState::new(ProcessConfig {
1159            save_background: true,
1160            ..Default::default()
1161        });
1162
1163        assert!(!state.config.valid_background);
1164        assert!(state.background.is_none());
1165
1166        // Process with save_background=true: should capture and clear flag
1167        let input = make_array(&[10, 20, 30]);
1168        let _ = state.process(&input);
1169
1170        assert!(
1171            !state.config.save_background,
1172            "save_background should be cleared"
1173        );
1174        assert!(
1175            state.config.valid_background,
1176            "valid_background should be set"
1177        );
1178        assert!(state.background.is_some());
1179
1180        let bg = state.background.as_ref().unwrap();
1181        assert_eq!(bg.len(), 3);
1182        assert!((bg[0] - 10.0).abs() < 1e-9);
1183        assert!((bg[1] - 20.0).abs() < 1e-9);
1184        assert!((bg[2] - 30.0).abs() < 1e-9);
1185
1186        // Process again: flag should remain cleared, background should persist
1187        let input2 = make_array(&[40, 50, 60]);
1188        let _ = state.process(&input2);
1189
1190        assert!(
1191            !state.config.save_background,
1192            "save_background stays cleared"
1193        );
1194        // Background unchanged
1195        let bg2 = state.background.as_ref().unwrap();
1196        assert!((bg2[0] - 10.0).abs() < 1e-9);
1197    }
1198
1199    #[test]
1200    fn test_save_flat_field_one_shot() {
1201        let mut state = ProcessState::new(ProcessConfig {
1202            save_flat_field: true,
1203            ..Default::default()
1204        });
1205
1206        assert!(!state.config.valid_flat_field);
1207        assert!(state.flat_field.is_none());
1208
1209        let input = make_array(&[50, 100, 150]);
1210        let _ = state.process(&input);
1211
1212        assert!(
1213            !state.config.save_flat_field,
1214            "save_flat_field should be cleared"
1215        );
1216        assert!(
1217            state.config.valid_flat_field,
1218            "valid_flat_field should be set"
1219        );
1220        assert!(state.flat_field.is_some());
1221
1222        let ff = state.flat_field.as_ref().unwrap();
1223        assert_eq!(ff.len(), 3);
1224        assert!((ff[0] - 50.0).abs() < 1e-9);
1225        assert!((ff[1] - 100.0).abs() < 1e-9);
1226        assert!((ff[2] - 150.0).abs() < 1e-9);
1227    }
1228
1229    #[test]
1230    fn test_auto_reset_when_num_filter_reached() {
1231        // Sum filter with auto_reset after 3 frames
1232        let mut state = ProcessState::new(ProcessConfig {
1233            enable_filter: true,
1234            filter: FilterConfig {
1235                num_filter: 3,
1236                auto_reset: true,
1237                fc: [1.0, 0.0, 1.0, 0.0], // sum preset
1238                oc: [1.0, 0.0, 0.0, 0.0],
1239                rc: [0.0, 1.0],
1240                ..Default::default()
1241            },
1242            output_type: Some(NDDataType::Float64),
1243            ..Default::default()
1244        });
1245
1246        // Frame 0 (reset): num_filtered becomes 1
1247        let _ = state.process(&make_f64_array(&[100.0]));
1248        assert_eq!(state.num_filtered, 1);
1249
1250        // Frame 1: num_filtered becomes 2
1251        let _ = state.process(&make_f64_array(&[100.0]));
1252        assert_eq!(state.num_filtered, 2);
1253
1254        // Frame 2: num_filtered becomes 3 = num_filter, triggers auto_reset on next
1255        let _ = state.process(&make_f64_array(&[100.0]));
1256        assert_eq!(state.num_filtered, 3);
1257
1258        // Frame 3: auto_reset fires (num_filtered >= num_filter), filter is reset
1259        let _ = state.process(&make_f64_array(&[200.0]));
1260        // After reset + processing, num_filtered should be 1
1261        assert_eq!(state.num_filtered, 1, "fresh start after auto reset");
1262    }
1263
1264    #[test]
1265    fn test_filter_with_offset_scale() {
1266        // Test that f_offset/f_scale and o_offset/o_scale are applied in C++ manner:
1267        // F1 = fScale * (fc1 + fc2/N), O1 = oScale * (oc1 + oc2/N)
1268        // CopyToFilter: fc=[0,0,1,0], oc=[1,0,0,0]
1269        let mut state = ProcessState::new(ProcessConfig {
1270            enable_filter: true,
1271            filter: FilterConfig {
1272                num_filter: 10,
1273                fc: [0.0, 0.0, 1.0, 0.0], // F1=0, F2=fScale*1
1274                oc: [1.0, 0.0, 0.0, 0.0], // O1=oScale*1, O2=0
1275                rc: [0.0, 1.0],
1276                f_offset: 10.0,
1277                f_scale: 2.0,
1278                o_offset: 5.0,
1279                o_scale: 3.0,
1280                ..Default::default()
1281            },
1282            output_type: Some(NDDataType::Float64),
1283            ..Default::default()
1284        });
1285
1286        // Frame 0: reset: filter = 0 + 0*filter + 1*50 = 50
1287        // N=1: F1=2*(0+0/1)=0, F2=2*(1+0/1)=2, O1=3*(1+0/1)=3, O2=0
1288        // data = 5 + 3*50 + 0 = 155
1289        // filter = 10 + 0*50 + 2*50 = 110
1290        let r0 = state.process(&make_f64_array(&[50.0]));
1291        let v0 = r0.data.get_as_f64(0).unwrap();
1292        assert!((v0 - 155.0).abs() < 1e-9, "frame 0: got {v0}");
1293
1294        // Frame 1: data=20, filter=110
1295        // N=2: F1=0, F2=2, O1=3, O2=0
1296        // data = 5 + 3*110 = 335
1297        // filter = 10 + 0 + 2*20 = 50
1298        let r1 = state.process(&make_f64_array(&[20.0]));
1299        let v1 = r1.data.get_as_f64(0).unwrap();
1300        assert!((v1 - 335.0).abs() < 1e-9, "frame 1: got {v1}");
1301    }
1302
1303    #[test]
1304    fn test_reset_filter_manual() {
1305        let mut state = ProcessState::new(ProcessConfig {
1306            enable_filter: true,
1307            filter: FilterConfig {
1308                num_filter: 10,
1309                fc: [1.0, 0.0, 1.0, 0.0],
1310                oc: [1.0, 0.0, 0.0, 0.0],
1311                rc: [0.0, 1.0],
1312                ..Default::default()
1313            },
1314            output_type: Some(NDDataType::Float64),
1315            ..Default::default()
1316        });
1317
1318        // Build up filter state
1319        let _ = state.process(&make_f64_array(&[100.0]));
1320        let _ = state.process(&make_f64_array(&[100.0]));
1321        assert!(state.filter_state.is_some());
1322        assert_eq!(state.num_filtered, 2);
1323
1324        // Manual reset
1325        state.reset_filter();
1326        assert!(state.filter_state.is_none());
1327        assert_eq!(state.num_filtered, 0);
1328
1329        // Next frame should act as first frame (reset mode)
1330        let _ = state.process(&make_f64_array(&[200.0]));
1331        assert_eq!(state.num_filtered, 1);
1332    }
1333}