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/// 4-tap recursive filter configuration.
13///
14/// Filter step:
15///   F_n = FC1*I_n + FC2*F_{n-1} + FC3*(F_{n-2} - FOffset) + FC4*(F_{n-3} - FOffset)
16///   F_n = FOffset + FScale * F_n
17///
18/// Output step:
19///   O_n = OC1*F_n + OC2*F_{n-1} + OC3*(O_{n-1} - OOffset) + OC4*(O_{n-2} - OOffset)
20///   O_n = OOffset + OScale * O_n
21///
22/// Reset (first frame or auto-reset):
23///   F_0 = RC1 * I + RC2 * F_prev  (F_prev = 0 initially)
24#[derive(Debug, Clone)]
25pub struct FilterConfig {
26    /// Number of frames to average before auto-reset (if enabled).
27    pub num_filter: usize,
28    /// Automatically reset the filter when num_filtered reaches num_filter.
29    pub auto_reset: bool,
30    /// Output every N frames (0 = every frame).
31    pub filter_callbacks: usize,
32    /// Output coefficients [OC1, OC2, OC3, OC4].
33    pub oc: [f64; 4],
34    /// Filter coefficients [FC1, FC2, FC3, FC4].
35    pub fc: [f64; 4],
36    /// Reset coefficients [RC1, RC2].
37    pub rc: [f64; 2],
38    /// Output offset.
39    pub o_offset: f64,
40    /// Output scale.
41    pub o_scale: f64,
42    /// Filter offset.
43    pub f_offset: f64,
44    /// Filter scale.
45    pub f_scale: f64,
46}
47
48impl Default for FilterConfig {
49    fn default() -> Self {
50        Self {
51            num_filter: 1,
52            auto_reset: false,
53            filter_callbacks: 0,
54            oc: [1.0, 0.0, 0.0, 0.0], // simple passthrough
55            fc: [1.0, 0.0, 0.0, 0.0],
56            rc: [1.0, 0.0],
57            o_offset: 0.0,
58            o_scale: 1.0,
59            f_offset: 0.0,
60            f_scale: 1.0,
61        }
62    }
63}
64
65/// Process plugin operations applied sequentially to an NDArray.
66#[derive(Debug, Clone)]
67pub struct ProcessConfig {
68    pub enable_background: bool,
69    pub enable_flat_field: bool,
70    pub enable_offset_scale: bool,
71    pub offset: f64,
72    pub scale: f64,
73    pub enable_low_clip: bool,
74    pub low_clip_thresh: f64,
75    pub low_clip_value: f64,
76    pub enable_high_clip: bool,
77    pub high_clip_thresh: f64,
78    pub high_clip_value: f64,
79    pub scale_flat_field: f64,
80    pub enable_filter: bool,
81    pub filter: FilterConfig,
82    pub output_type: Option<NDDataType>,
83    /// One-shot flag: save current input as background on next process().
84    pub save_background: bool,
85    /// One-shot flag: save current input as flat field on next process().
86    pub save_flat_field: bool,
87    /// Read-only status: whether a valid background is loaded.
88    pub valid_background: bool,
89    /// Read-only status: whether a valid flat field is loaded.
90    pub valid_flat_field: bool,
91}
92
93impl Default for ProcessConfig {
94    fn default() -> Self {
95        Self {
96            enable_background: false,
97            enable_flat_field: false,
98            enable_offset_scale: false,
99            offset: 0.0,
100            scale: 1.0,
101            enable_low_clip: false,
102            low_clip_thresh: 0.0,
103            low_clip_value: 0.0,
104            enable_high_clip: false,
105            high_clip_thresh: 100.0,
106            high_clip_value: 100.0,
107            scale_flat_field: 255.0,
108            enable_filter: false,
109            filter: FilterConfig::default(),
110            output_type: None,
111            save_background: false,
112            save_flat_field: false,
113            valid_background: false,
114            valid_flat_field: false,
115        }
116    }
117}
118
119/// State for the process plugin (holds background, flat field, and filter history).
120pub struct ProcessState {
121    pub config: ProcessConfig,
122    pub background: Option<Vec<f64>>,
123    pub flat_field: Option<Vec<f64>>,
124    /// F_{n-1}: most recent filter state.
125    pub filter_state: Option<Vec<f64>>,
126    /// [F_{n-2}, F_{n-3}]: older filter history.
127    pub filter_state_prev: Option<Vec<Vec<f64>>>,
128    /// O_{n-1}: most recent output state.
129    pub output_state: Option<Vec<f64>>,
130    /// O_{n-2}: previous output state.
131    pub output_state_prev: Option<Vec<f64>>,
132    /// Number of frames filtered since last reset.
133    pub num_filtered: usize,
134}
135
136impl ProcessState {
137    pub fn new(config: ProcessConfig) -> Self {
138        Self {
139            config,
140            background: None,
141            flat_field: None,
142            filter_state: None,
143            filter_state_prev: None,
144            output_state: None,
145            output_state_prev: None,
146            num_filtered: 0,
147        }
148    }
149
150    /// Save the current array as background.
151    pub fn save_background(&mut self, array: &NDArray) {
152        let n = array.data.len();
153        let mut bg = vec![0.0f64; n];
154        for i in 0..n {
155            bg[i] = array.data.get_as_f64(i).unwrap_or(0.0);
156        }
157        self.background = Some(bg);
158        self.config.valid_background = true;
159    }
160
161    /// Save the current array as flat field.
162    pub fn save_flat_field(&mut self, array: &NDArray) {
163        let n = array.data.len();
164        let mut ff = vec![0.0f64; n];
165        for i in 0..n {
166            ff[i] = array.data.get_as_f64(i).unwrap_or(0.0);
167        }
168        self.flat_field = Some(ff);
169        self.config.valid_flat_field = true;
170    }
171
172    /// Auto-calculate offset and scale so that the output range fits the output data type.
173    /// Sets offset = -min*scale, scale = max_out_range / (max - min).
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            let out_max = match self.config.output_type.unwrap_or(array.data.data_type()) {
193                NDDataType::Int8 => 127.0,
194                NDDataType::UInt8 => 255.0,
195                NDDataType::Int16 => 32767.0,
196                NDDataType::UInt16 => 65535.0,
197                NDDataType::Int32 => 2_147_483_647.0,
198                NDDataType::UInt32 => 4_294_967_295.0,
199                _ => 65535.0,
200            };
201            self.config.scale = out_max / range;
202            self.config.offset = -min_val * self.config.scale;
203        }
204    }
205
206    /// Apply a named filter type preset, setting the FC/OC/RC coefficients.
207    pub fn apply_filter_type(&mut self, filter_type: i32) {
208        let fc = &mut self.config.filter;
209        match filter_type {
210            0 => {
211                // RecursiveAve: running average
212                // F[n] = (1/N)*I[n] + ((N-1)/N)*F[n-1]
213                // O[n] = F[n]
214                let n = fc.num_filter.max(1) as f64;
215                fc.fc = [1.0 / n, (n - 1.0) / n, 0.0, 0.0];
216                fc.oc = [1.0, 0.0, 0.0, 0.0];
217                fc.rc = [1.0, 0.0];
218                fc.f_offset = 0.0;
219                fc.f_scale = 1.0;
220                fc.o_offset = 0.0;
221                fc.o_scale = 1.0;
222            }
223            1 => {
224                // Average: accumulate then divide
225                // F[n] = F[n-1] + I[n], O[n] = F[n] / N
226                let n = fc.num_filter.max(1) as f64;
227                fc.fc = [1.0, 1.0, 0.0, 0.0];
228                fc.oc = [1.0 / n, 0.0, 0.0, 0.0];
229                fc.rc = [1.0, 0.0];
230                fc.f_offset = 0.0;
231                fc.f_scale = 1.0;
232                fc.o_offset = 0.0;
233                fc.o_scale = 1.0;
234            }
235            2 => {
236                // Sum: F[n] = F[n-1] + I[n]
237                fc.fc = [1.0, 1.0, 0.0, 0.0];
238                fc.oc = [1.0, 0.0, 0.0, 0.0];
239                fc.rc = [1.0, 0.0];
240                fc.f_offset = 0.0;
241                fc.f_scale = 1.0;
242                fc.o_offset = 0.0;
243                fc.o_scale = 1.0;
244            }
245            3 => {
246                // Difference: O[n] = I[n] - F[n-1], F[n] = I[n]
247                fc.fc = [1.0, 0.0, 0.0, 0.0];
248                fc.oc = [1.0, -1.0, 0.0, 0.0];
249                fc.rc = [1.0, 0.0];
250                fc.f_offset = 0.0;
251                fc.f_scale = 1.0;
252                fc.o_offset = 0.0;
253                fc.o_scale = 1.0;
254            }
255            4 => {
256                // RecursiveAveDiff: like RecursiveAve but output is difference from avg
257                let n = fc.num_filter.max(1) as f64;
258                fc.fc = [1.0 / n, (n - 1.0) / n, 0.0, 0.0];
259                fc.oc = [-1.0, 0.0, 0.0, 0.0];
260                fc.rc = [1.0, 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            5 => {
267                // CopyToFilter: F[n] = I[n], O[n] = F[n]
268                fc.fc = [1.0, 0.0, 0.0, 0.0];
269                fc.oc = [1.0, 0.0, 0.0, 0.0];
270                fc.rc = [1.0, 0.0];
271                fc.f_offset = 0.0;
272                fc.f_scale = 1.0;
273                fc.o_offset = 0.0;
274                fc.o_scale = 1.0;
275            }
276            _ => {} // Unknown type — leave coefficients unchanged
277        }
278    }
279
280    /// Reset the filter state, clearing all history buffers.
281    pub fn reset_filter(&mut self) {
282        self.filter_state = None;
283        self.filter_state_prev = None;
284        self.output_state = None;
285        self.output_state_prev = None;
286        self.num_filtered = 0;
287    }
288
289    /// Process an array through the configured pipeline.
290    pub fn process(&mut self, src: &NDArray) -> NDArray {
291        let n = src.data.len();
292        let mut values = vec![0.0f64; n];
293        for i in 0..n {
294            values[i] = src.data.get_as_f64(i).unwrap_or(0.0);
295        }
296
297        // 0. Save background/flat field (one-shot flags)
298        if self.config.save_background {
299            self.save_background(src);
300            self.config.save_background = false;
301        }
302        if self.config.save_flat_field {
303            self.save_flat_field(src);
304            self.config.save_flat_field = false;
305        }
306
307        // Stages 1-4: element-wise operations (background, flat field, offset+scale, clipping)
308        // These can be combined into a single pass and parallelized.
309        let needs_element_ops = self.config.enable_background
310            || self.config.enable_flat_field
311            || self.config.enable_offset_scale
312            || self.config.enable_low_clip
313            || self.config.enable_high_clip;
314
315        if needs_element_ops {
316            let bg = if self.config.enable_background {
317                self.background.as_ref()
318            } else {
319                None
320            };
321            let (ff, ff_scale) = if self.config.enable_flat_field {
322                if let Some(ref ff) = self.flat_field {
323                    let scale = if self.config.scale_flat_field > 0.0 {
324                        self.config.scale_flat_field
325                    } else {
326                        ff.iter().sum::<f64>() / ff.len().max(1) as f64
327                    };
328                    (Some(ff.as_slice()), scale)
329                } else {
330                    (None, 0.0)
331                }
332            } else {
333                (None, 0.0)
334            };
335            let do_offset_scale = self.config.enable_offset_scale;
336            let scale = self.config.scale;
337            let offset = self.config.offset;
338            let do_low_clip = self.config.enable_low_clip;
339            let low_clip_thresh = self.config.low_clip_thresh;
340            let low_clip_value = self.config.low_clip_value;
341            let do_high_clip = self.config.enable_high_clip;
342            let high_clip_thresh = self.config.high_clip_thresh;
343            let high_clip_value = self.config.high_clip_value;
344
345            let apply_stages = |i: usize, v: &mut f64| {
346                // Stage 1: Background subtraction
347                if let Some(bg) = bg {
348                    if i < bg.len() {
349                        *v -= bg[i];
350                    }
351                }
352                // Stage 2: Flat field normalization
353                if let Some(ff) = ff {
354                    if i < ff.len() && ff[i] != 0.0 {
355                        *v = *v * ff_scale / ff[i];
356                    }
357                }
358                // Stage 3: Offset + scale
359                if do_offset_scale {
360                    *v = *v * scale + offset;
361                }
362                // Stage 4: Clipping
363                if do_low_clip && *v < low_clip_thresh {
364                    *v = low_clip_value;
365                }
366                if do_high_clip && *v > high_clip_thresh {
367                    *v = high_clip_value;
368                }
369            };
370
371            #[cfg(feature = "parallel")]
372            let use_parallel = par_util::should_parallelize(n);
373            #[cfg(not(feature = "parallel"))]
374            let use_parallel = false;
375
376            if use_parallel {
377                #[cfg(feature = "parallel")]
378                par_util::thread_pool().install(|| {
379                    values.par_iter_mut().enumerate().for_each(|(i, v)| {
380                        apply_stages(i, v);
381                    });
382                });
383            } else {
384                for (i, v) in values.iter_mut().enumerate() {
385                    apply_stages(i, v);
386                }
387            }
388        }
389
390        // 5. 4-tap recursive filter
391        if self.config.enable_filter {
392            let fc = &self.config.filter;
393            let is_first_frame = self.filter_state.is_none();
394
395            if is_first_frame {
396                // Reset mode: F_0 = RC1 * I + RC2 * F_prev (F_prev = 0 initially)
397                let rc1 = fc.rc[0];
398                let rc2 = fc.rc[1];
399
400                let mut f_new = vec![0.0f64; n];
401                // On very first frame, F_prev is all zeros
402                let f_prev = self.filter_state.as_ref();
403                for i in 0..n {
404                    let fp = f_prev.map_or(0.0, |p| p[i]);
405                    f_new[i] = rc1 * values[i] + rc2 * fp;
406                }
407
408                // Output on reset: O = OC1*F_new (no history for OC2/OC3/OC4 terms)
409                let mut o_new = vec![0.0f64; n];
410                for i in 0..n {
411                    o_new[i] = fc.oc[0] * f_new[i];
412                    o_new[i] = fc.o_offset + fc.o_scale * o_new[i];
413                }
414
415                // Store history
416                self.filter_state_prev = Some(vec![vec![0.0; n], vec![0.0; n]]);
417                self.output_state_prev = Some(vec![0.0; n]);
418                self.output_state = Some(o_new.clone());
419                self.filter_state = Some(f_new);
420                self.num_filtered = 1;
421
422                values = o_new;
423            } else {
424                // Normal 4-tap recursive filter
425                let f_prev = self.filter_state.as_ref().unwrap(); // F_{n-1}
426                let f_prev_history = self.filter_state_prev.as_ref().unwrap();
427                let f_prev2 = &f_prev_history[0]; // F_{n-2}
428                let f_prev3 = &f_prev_history[1]; // F_{n-3}
429
430                let o_prev = self.output_state.as_ref().unwrap(); // O_{n-1}
431                let o_prev2 = self.output_state_prev.as_ref().unwrap(); // O_{n-2}
432
433                let f_offset = fc.f_offset;
434                let f_scale = fc.f_scale;
435                let o_offset = fc.o_offset;
436                let o_scale = fc.o_scale;
437                let fc_coeffs = fc.fc;
438                let oc_coeffs = fc.oc;
439
440                let mut f_new = vec![0.0f64; n];
441                let mut o_new = vec![0.0f64; n];
442
443                for i in 0..n {
444                    // Filter: F_n = FC1*I + FC2*F_{n-1} + FC3*(F_{n-2}-FOffset) + FC4*(F_{n-3}-FOffset)
445                    f_new[i] = fc_coeffs[0] * values[i]
446                        + fc_coeffs[1] * f_prev[i]
447                        + fc_coeffs[2] * (f_prev2[i] - f_offset)
448                        + fc_coeffs[3] * (f_prev3[i] - f_offset);
449                    // Apply filter scale
450                    f_new[i] = f_offset + f_scale * f_new[i];
451
452                    // Output: O_n = OC1*F_n + OC2*F_{n-1} + OC3*(O_{n-1}-OOffset) + OC4*(O_{n-2}-OOffset)
453                    o_new[i] = oc_coeffs[0] * f_new[i]
454                        + oc_coeffs[1] * f_prev[i]
455                        + oc_coeffs[2] * (o_prev[i] - o_offset)
456                        + oc_coeffs[3] * (o_prev2[i] - o_offset);
457                    // Apply output scale
458                    o_new[i] = o_offset + o_scale * o_new[i];
459                }
460
461                // Shift filter history: [n-2, n-3] <- [n-1, old n-2]
462                let old_f_prev = f_prev.clone();
463                self.filter_state_prev = Some(vec![old_f_prev, f_prev2.clone()]);
464                // Shift output history: O_{n-2} <- O_{n-1}
465                self.output_state_prev = Some(o_prev.clone());
466                self.output_state = Some(o_new.clone());
467                self.filter_state = Some(f_new);
468
469                self.num_filtered += 1;
470
471                // Auto-reset: reset filter state when num_filtered reaches num_filter
472                if fc.auto_reset && fc.num_filter > 0 && self.num_filtered >= fc.num_filter {
473                    self.reset_filter();
474                }
475
476                values = o_new;
477            }
478        }
479
480        // Build output
481        let out_type = self.config.output_type.unwrap_or(src.data.data_type());
482        let mut out_data = NDDataBuffer::zeros(out_type, n);
483        for i in 0..n {
484            out_data.set_from_f64(i, values[i]);
485        }
486
487        let mut arr = NDArray::new(src.dims.clone(), out_type);
488        arr.data = out_data;
489        arr.unique_id = src.unique_id;
490        arr.timestamp = src.timestamp;
491        arr.attributes = src.attributes.clone();
492        arr
493    }
494}
495
496// --- ProcessProcessor (NDPluginProcess-based) ---
497
498/// Param indices for the process plugin.
499#[derive(Default)]
500struct ProcParamIndices {
501    data_type: Option<usize>,
502    save_background: Option<usize>,
503    enable_background: Option<usize>,
504    valid_background: Option<usize>,
505    save_flat_field: Option<usize>,
506    enable_flat_field: Option<usize>,
507    valid_flat_field: Option<usize>,
508    scale_flat_field: Option<usize>,
509    enable_offset_scale: Option<usize>,
510    auto_offset_scale: Option<usize>,
511    offset: Option<usize>,
512    scale: Option<usize>,
513    enable_low_clip: Option<usize>,
514    low_clip_thresh: Option<usize>,
515    low_clip_value: Option<usize>,
516    enable_high_clip: Option<usize>,
517    high_clip_thresh: Option<usize>,
518    high_clip_value: Option<usize>,
519    enable_filter: Option<usize>,
520    filter_type: Option<usize>,
521    reset_filter: Option<usize>,
522    auto_reset_filter: Option<usize>,
523    filter_callbacks: Option<usize>,
524    num_filter: Option<usize>,
525    num_filtered: Option<usize>,
526    o_offset: Option<usize>,
527    o_scale: Option<usize>,
528    oc: [Option<usize>; 4],
529    f_offset: Option<usize>,
530    f_scale: Option<usize>,
531    fc: [Option<usize>; 4],
532    r_offset: Option<usize>,
533    rc: [Option<usize>; 2],
534}
535
536/// ProcessProcessor wraps existing ProcessState.
537pub struct ProcessProcessor {
538    state: ProcessState,
539    params: ProcParamIndices,
540}
541
542impl ProcessProcessor {
543    pub fn new(config: ProcessConfig) -> Self {
544        Self {
545            state: ProcessState::new(config),
546            params: ProcParamIndices::default(),
547        }
548    }
549
550    pub fn state(&self) -> &ProcessState {
551        &self.state
552    }
553
554    pub fn state_mut(&mut self) -> &mut ProcessState {
555        &mut self.state
556    }
557}
558
559impl NDPluginProcess for ProcessProcessor {
560    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
561        use ad_core_rs::plugin::runtime::ParamUpdate;
562
563        let out = self.state.process(array);
564        let mut result = ProcessResult::arrays(vec![Arc::new(out)]);
565
566        // Push readback params
567        if let Some(idx) = self.params.valid_background {
568            result.param_updates.push(ParamUpdate::int32(
569                idx,
570                if self.state.config.valid_background {
571                    1
572                } else {
573                    0
574                },
575            ));
576        }
577        if let Some(idx) = self.params.valid_flat_field {
578            result.param_updates.push(ParamUpdate::int32(
579                idx,
580                if self.state.config.valid_flat_field {
581                    1
582                } else {
583                    0
584                },
585            ));
586        }
587        if let Some(idx) = self.params.num_filtered {
588            result
589                .param_updates
590                .push(ParamUpdate::int32(idx, self.state.num_filtered as i32));
591        }
592        // Reset save_background/save_flat_field readback to 0 (one-shot)
593        if let Some(idx) = self.params.save_background {
594            result.param_updates.push(ParamUpdate::int32(idx, 0));
595        }
596        if let Some(idx) = self.params.save_flat_field {
597            result.param_updates.push(ParamUpdate::int32(idx, 0));
598        }
599
600        result
601    }
602
603    fn plugin_type(&self) -> &str {
604        "NDPluginProcess"
605    }
606
607    fn register_params(
608        &mut self,
609        base: &mut asyn_rs::port::PortDriverBase,
610    ) -> asyn_rs::error::AsynResult<()> {
611        use asyn_rs::param::ParamType;
612        base.create_param("PROCESS_DATA_TYPE", ParamType::Int32)?;
613        base.create_param("SAVE_BACKGROUND", ParamType::Int32)?;
614        base.create_param("ENABLE_BACKGROUND", ParamType::Int32)?;
615        base.create_param("VALID_BACKGROUND", ParamType::Int32)?;
616        base.create_param("SAVE_FLAT_FIELD", ParamType::Int32)?;
617        base.create_param("ENABLE_FLAT_FIELD", ParamType::Int32)?;
618        base.create_param("VALID_FLAT_FIELD", ParamType::Int32)?;
619        base.create_param("SCALE_FLAT_FIELD", ParamType::Float64)?;
620        base.create_param("ENABLE_OFFSET_SCALE", ParamType::Int32)?;
621        base.create_param("AUTO_OFFSET_SCALE", ParamType::Int32)?;
622        base.create_param("OFFSET", ParamType::Float64)?;
623        base.create_param("SCALE", ParamType::Float64)?;
624        base.create_param("ENABLE_LOW_CLIP", ParamType::Int32)?;
625        base.create_param("LOW_CLIP_THRESH", ParamType::Float64)?;
626        base.create_param("LOW_CLIP_VALUE", ParamType::Float64)?;
627        base.create_param("ENABLE_HIGH_CLIP", ParamType::Int32)?;
628        base.create_param("HIGH_CLIP_THRESH", ParamType::Float64)?;
629        base.create_param("HIGH_CLIP_VALUE", ParamType::Float64)?;
630        base.create_param("ENABLE_FILTER", ParamType::Int32)?;
631        base.create_param("FILTER_TYPE", ParamType::Int32)?;
632        base.create_param("RESET_FILTER", ParamType::Int32)?;
633        base.create_param("AUTO_RESET_FILTER", ParamType::Int32)?;
634        base.create_param("FILTER_CALLBACKS", ParamType::Int32)?;
635        base.create_param("NUM_FILTER", ParamType::Int32)?;
636        base.create_param("NUM_FILTERED", ParamType::Int32)?;
637        base.create_param("FILTER_OOFFSET", ParamType::Float64)?;
638        base.create_param("FILTER_OSCALE", ParamType::Float64)?;
639        base.create_param("FILTER_OC1", ParamType::Float64)?;
640        base.create_param("FILTER_OC2", ParamType::Float64)?;
641        base.create_param("FILTER_OC3", ParamType::Float64)?;
642        base.create_param("FILTER_OC4", ParamType::Float64)?;
643        base.create_param("FILTER_FOFFSET", ParamType::Float64)?;
644        base.create_param("FILTER_FSCALE", ParamType::Float64)?;
645        base.create_param("FILTER_FC1", ParamType::Float64)?;
646        base.create_param("FILTER_FC2", ParamType::Float64)?;
647        base.create_param("FILTER_FC3", ParamType::Float64)?;
648        base.create_param("FILTER_FC4", ParamType::Float64)?;
649        base.create_param("FILTER_ROFFSET", ParamType::Float64)?;
650        base.create_param("FILTER_RC1", ParamType::Float64)?;
651        base.create_param("FILTER_RC2", ParamType::Float64)?;
652
653        // Look up param indices
654        self.params.data_type = base.find_param("PROCESS_DATA_TYPE");
655        self.params.save_background = base.find_param("SAVE_BACKGROUND");
656        self.params.enable_background = base.find_param("ENABLE_BACKGROUND");
657        self.params.valid_background = base.find_param("VALID_BACKGROUND");
658        self.params.save_flat_field = base.find_param("SAVE_FLAT_FIELD");
659        self.params.enable_flat_field = base.find_param("ENABLE_FLAT_FIELD");
660        self.params.valid_flat_field = base.find_param("VALID_FLAT_FIELD");
661        self.params.scale_flat_field = base.find_param("SCALE_FLAT_FIELD");
662        self.params.enable_offset_scale = base.find_param("ENABLE_OFFSET_SCALE");
663        self.params.auto_offset_scale = base.find_param("AUTO_OFFSET_SCALE");
664        self.params.offset = base.find_param("OFFSET");
665        self.params.scale = base.find_param("SCALE");
666        self.params.enable_low_clip = base.find_param("ENABLE_LOW_CLIP");
667        self.params.low_clip_thresh = base.find_param("LOW_CLIP_THRESH");
668        self.params.low_clip_value = base.find_param("LOW_CLIP_VALUE");
669        self.params.enable_high_clip = base.find_param("ENABLE_HIGH_CLIP");
670        self.params.high_clip_thresh = base.find_param("HIGH_CLIP_THRESH");
671        self.params.high_clip_value = base.find_param("HIGH_CLIP_VALUE");
672        self.params.enable_filter = base.find_param("ENABLE_FILTER");
673        self.params.filter_type = base.find_param("FILTER_TYPE");
674        self.params.reset_filter = base.find_param("RESET_FILTER");
675        self.params.auto_reset_filter = base.find_param("AUTO_RESET_FILTER");
676        self.params.filter_callbacks = base.find_param("FILTER_CALLBACKS");
677        self.params.num_filter = base.find_param("NUM_FILTER");
678        self.params.num_filtered = base.find_param("NUM_FILTERED");
679        self.params.o_offset = base.find_param("FILTER_OOFFSET");
680        self.params.o_scale = base.find_param("FILTER_OSCALE");
681        self.params.oc[0] = base.find_param("FILTER_OC1");
682        self.params.oc[1] = base.find_param("FILTER_OC2");
683        self.params.oc[2] = base.find_param("FILTER_OC3");
684        self.params.oc[3] = base.find_param("FILTER_OC4");
685        self.params.f_offset = base.find_param("FILTER_FOFFSET");
686        self.params.f_scale = base.find_param("FILTER_FSCALE");
687        self.params.fc[0] = base.find_param("FILTER_FC1");
688        self.params.fc[1] = base.find_param("FILTER_FC2");
689        self.params.fc[2] = base.find_param("FILTER_FC3");
690        self.params.fc[3] = base.find_param("FILTER_FC4");
691        self.params.r_offset = base.find_param("FILTER_ROFFSET");
692        self.params.rc[0] = base.find_param("FILTER_RC1");
693        self.params.rc[1] = base.find_param("FILTER_RC2");
694        Ok(())
695    }
696
697    fn on_param_change(
698        &mut self,
699        reason: usize,
700        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
701    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
702        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamUpdate};
703
704        let s = &mut self.state;
705        let p = &self.params;
706        let mut updates = Vec::new();
707
708        if Some(reason) == p.data_type {
709            let v = params.value.as_i32();
710            s.config.output_type = if v < 0 {
711                None // Automatic
712            } else {
713                NDDataType::from_ordinal(v as u8)
714            };
715        } else if Some(reason) == p.save_background {
716            if params.value.as_i32() != 0 {
717                s.config.save_background = true;
718            }
719        } else if Some(reason) == p.enable_background {
720            s.config.enable_background = params.value.as_i32() != 0;
721        } else if Some(reason) == p.save_flat_field {
722            if params.value.as_i32() != 0 {
723                s.config.save_flat_field = true;
724            }
725        } else if Some(reason) == p.enable_flat_field {
726            s.config.enable_flat_field = params.value.as_i32() != 0;
727        } else if Some(reason) == p.scale_flat_field {
728            s.config.scale_flat_field = params.value.as_f64();
729        } else if Some(reason) == p.enable_offset_scale {
730            s.config.enable_offset_scale = params.value.as_i32() != 0;
731        } else if Some(reason) == p.auto_offset_scale {
732            if params.value.as_i32() != 0 {
733                if let Some(ref arr) = self.state.background {
734                    // Use background to estimate range if available
735                    let _ = arr; // auto_offset_scale needs an NDArray, deferred to process_array
736                }
737                // Will be applied when next array arrives — set flag
738                // C ADCore applies auto-calc immediately on the latest array.
739                // We apply it as a one-shot in process_array via the flag.
740            }
741        } else if Some(reason) == p.offset {
742            s.config.offset = params.value.as_f64();
743        } else if Some(reason) == p.scale {
744            s.config.scale = params.value.as_f64();
745        } else if Some(reason) == p.enable_low_clip {
746            s.config.enable_low_clip = params.value.as_i32() != 0;
747        } else if Some(reason) == p.low_clip_thresh {
748            s.config.low_clip_thresh = params.value.as_f64();
749        } else if Some(reason) == p.low_clip_value {
750            s.config.low_clip_value = params.value.as_f64();
751        } else if Some(reason) == p.enable_high_clip {
752            s.config.enable_high_clip = params.value.as_i32() != 0;
753        } else if Some(reason) == p.high_clip_thresh {
754            s.config.high_clip_thresh = params.value.as_f64();
755        } else if Some(reason) == p.high_clip_value {
756            s.config.high_clip_value = params.value.as_f64();
757        } else if Some(reason) == p.enable_filter {
758            s.config.enable_filter = params.value.as_i32() != 0;
759        } else if Some(reason) == p.filter_type {
760            s.apply_filter_type(params.value.as_i32());
761            s.reset_filter();
762            // Push updated coefficients back
763            let fc = &s.config.filter;
764            for (i, idx) in p.fc.iter().enumerate() {
765                if let Some(idx) = *idx {
766                    updates.push(ParamUpdate::float64(idx, fc.fc[i]));
767                }
768            }
769            for (i, idx) in p.oc.iter().enumerate() {
770                if let Some(idx) = *idx {
771                    updates.push(ParamUpdate::float64(idx, fc.oc[i]));
772                }
773            }
774            for (i, idx) in p.rc.iter().enumerate() {
775                if let Some(idx) = *idx {
776                    updates.push(ParamUpdate::float64(idx, fc.rc[i]));
777                }
778            }
779            if let Some(idx) = p.f_offset {
780                updates.push(ParamUpdate::float64(idx, fc.f_offset));
781            }
782            if let Some(idx) = p.f_scale {
783                updates.push(ParamUpdate::float64(idx, fc.f_scale));
784            }
785            if let Some(idx) = p.o_offset {
786                updates.push(ParamUpdate::float64(idx, fc.o_offset));
787            }
788            if let Some(idx) = p.o_scale {
789                updates.push(ParamUpdate::float64(idx, fc.o_scale));
790            }
791        } else if Some(reason) == p.reset_filter {
792            if params.value.as_i32() != 0 {
793                s.reset_filter();
794                if let Some(idx) = p.num_filtered {
795                    updates.push(ParamUpdate::int32(idx, 0));
796                }
797            }
798        } else if Some(reason) == p.auto_reset_filter {
799            s.config.filter.auto_reset = params.value.as_i32() != 0;
800        } else if Some(reason) == p.filter_callbacks {
801            s.config.filter.filter_callbacks = params.value.as_i32().max(0) as usize;
802        } else if Some(reason) == p.num_filter {
803            s.config.filter.num_filter = params.value.as_i32().max(1) as usize;
804        } else if Some(reason) == p.o_offset {
805            s.config.filter.o_offset = params.value.as_f64();
806        } else if Some(reason) == p.o_scale {
807            s.config.filter.o_scale = params.value.as_f64();
808        } else if Some(reason) == p.f_offset {
809            s.config.filter.f_offset = params.value.as_f64();
810        } else if Some(reason) == p.f_scale {
811            s.config.filter.f_scale = params.value.as_f64();
812        } else if Some(reason) == p.r_offset {
813            // r_offset is not in FilterConfig but could be added
814        } else {
815            // Check individual OC/FC/RC params
816            for i in 0..4 {
817                if Some(reason) == p.oc[i] {
818                    s.config.filter.oc[i] = params.value.as_f64();
819                    return ParamChangeResult::updates(vec![]);
820                }
821                if Some(reason) == p.fc[i] {
822                    s.config.filter.fc[i] = params.value.as_f64();
823                    return ParamChangeResult::updates(vec![]);
824                }
825            }
826            for i in 0..2 {
827                if Some(reason) == p.rc[i] {
828                    s.config.filter.rc[i] = params.value.as_f64();
829                    return ParamChangeResult::updates(vec![]);
830                }
831            }
832        }
833
834        ParamChangeResult::updates(updates)
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
842
843    fn make_array(vals: &[u8]) -> NDArray {
844        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::UInt8);
845        if let NDDataBuffer::U8(ref mut v) = arr.data {
846            v.copy_from_slice(vals);
847        }
848        arr
849    }
850
851    fn make_f64_array(vals: &[f64]) -> NDArray {
852        let mut arr = NDArray::new(vec![NDDimension::new(vals.len())], NDDataType::Float64);
853        if let NDDataBuffer::F64(ref mut v) = arr.data {
854            v.copy_from_slice(vals);
855        }
856        arr
857    }
858
859    #[test]
860    fn test_background_subtraction() {
861        let bg_arr = make_array(&[10, 20, 30]);
862        let input = make_array(&[15, 25, 35]);
863
864        let mut state = ProcessState::new(ProcessConfig {
865            enable_background: true,
866            ..Default::default()
867        });
868        state.save_background(&bg_arr);
869
870        let result = state.process(&input);
871        if let NDDataBuffer::U8(ref v) = result.data {
872            assert_eq!(v[0], 5);
873            assert_eq!(v[1], 5);
874            assert_eq!(v[2], 5);
875        }
876    }
877
878    #[test]
879    fn test_flat_field() {
880        let ff_arr = make_array(&[100, 200, 50]);
881        let input = make_array(&[100, 200, 50]);
882
883        let mut state = ProcessState::new(ProcessConfig {
884            enable_flat_field: true,
885            scale_flat_field: 0.0, // use mean
886            ..Default::default()
887        });
888        state.save_flat_field(&ff_arr);
889
890        let result = state.process(&input);
891        // After flat field: all values should be normalized to the mean
892        if let NDDataBuffer::U8(ref v) = result.data {
893            // ff_mean ~= 116.67, so all values should be ~= 116
894            assert!((v[0] as f64 - 116.67).abs() < 1.0);
895            assert!((v[1] as f64 - 116.67).abs() < 1.0);
896            assert!((v[2] as f64 - 116.67).abs() < 1.0);
897        }
898    }
899
900    #[test]
901    fn test_offset_scale() {
902        let input = make_array(&[10, 20, 30]);
903        let mut state = ProcessState::new(ProcessConfig {
904            enable_offset_scale: true,
905            scale: 2.0,
906            offset: 5.0,
907            ..Default::default()
908        });
909
910        let result = state.process(&input);
911        if let NDDataBuffer::U8(ref v) = result.data {
912            assert_eq!(v[0], 25); // 10*2+5
913            assert_eq!(v[1], 45); // 20*2+5
914            assert_eq!(v[2], 65); // 30*2+5
915        }
916    }
917
918    #[test]
919    fn test_clipping() {
920        let input = make_array(&[5, 50, 200]);
921        let mut state = ProcessState::new(ProcessConfig {
922            enable_low_clip: true,
923            low_clip_thresh: 10.0,
924            low_clip_value: 10.0,
925            enable_high_clip: true,
926            high_clip_thresh: 100.0,
927            high_clip_value: 100.0,
928            ..Default::default()
929        });
930
931        let result = state.process(&input);
932        if let NDDataBuffer::U8(ref v) = result.data {
933            assert_eq!(v[0], 10); // clipped up
934            assert_eq!(v[1], 50); // unchanged
935            assert_eq!(v[2], 100); // clipped down
936        }
937    }
938
939    #[test]
940    fn test_recursive_filter() {
941        // Reproduce old IIR behavior: filter_coeff=0.5 maps to
942        // fc: [0.5, 0.5, 0.0, 0.0], oc: [1.0, 0.0, 0.0, 0.0]
943        let input1 = make_array(&[100, 100, 100]);
944        let input2 = make_array(&[0, 0, 0]);
945
946        let mut state = ProcessState::new(ProcessConfig {
947            enable_filter: true,
948            filter: FilterConfig {
949                fc: [0.5, 0.5, 0.0, 0.0],
950                oc: [1.0, 0.0, 0.0, 0.0],
951                ..Default::default()
952            },
953            ..Default::default()
954        });
955
956        let _ = state.process(&input1); // first frame: reset => F_0 = RC1*I = 1.0*100 = 100
957        let result = state.process(&input2); // F_1 = 0.5*0 + 0.5*100 = 50, O = 1.0*50 = 50
958        if let NDDataBuffer::U8(ref v) = result.data {
959            assert_eq!(v[0], 50);
960            assert_eq!(v[1], 50);
961        }
962    }
963
964    #[test]
965    fn test_output_type_conversion() {
966        let input = make_array(&[10, 20, 30]);
967        let mut state = ProcessState::new(ProcessConfig {
968            output_type: Some(NDDataType::Float64),
969            ..Default::default()
970        });
971
972        let result = state.process(&input);
973        assert_eq!(result.data.data_type(), NDDataType::Float64);
974    }
975
976    // --- ProcessProcessor tests ---
977
978    #[test]
979    fn test_process_processor() {
980        let mut proc = ProcessProcessor::new(ProcessConfig {
981            enable_offset_scale: true,
982            scale: 2.0,
983            offset: 1.0,
984            ..Default::default()
985        });
986        let pool = NDArrayPool::new(1_000_000);
987
988        let input = make_array(&[10, 20, 30]);
989        let result = proc.process_array(&input, &pool);
990        assert_eq!(result.output_arrays.len(), 1);
991        if let NDDataBuffer::U8(ref v) = result.output_arrays[0].data {
992            assert_eq!(v[0], 21); // 10*2+1
993        }
994    }
995
996    // --- New Phase 2-1 tests ---
997
998    #[test]
999    fn test_4tap_filter_averaging() {
1000        // Set up a running-average filter: FC=[1/N, (N-1)/N, 0, 0], OC=[1,0,0,0]
1001        // This is a simple exponential moving average with N=4.
1002        let mut state = ProcessState::new(ProcessConfig {
1003            enable_filter: true,
1004            filter: FilterConfig {
1005                fc: [0.25, 0.75, 0.0, 0.0],
1006                oc: [1.0, 0.0, 0.0, 0.0],
1007                ..Default::default()
1008            },
1009            output_type: Some(NDDataType::Float64),
1010            ..Default::default()
1011        });
1012
1013        // Frame 1: constant 100 => reset: F_0 = 1.0*100 = 100
1014        let r1 = state.process(&make_f64_array(&[100.0]));
1015        let v1 = r1.data.get_as_f64(0).unwrap();
1016        assert!((v1 - 100.0).abs() < 1e-9, "frame 1: got {v1}");
1017
1018        // Frame 2: constant 100 => F_1 = 0.25*100 + 0.75*100 = 100
1019        let r2 = state.process(&make_f64_array(&[100.0]));
1020        let v2 = r2.data.get_as_f64(0).unwrap();
1021        assert!((v2 - 100.0).abs() < 1e-9, "frame 2: got {v2}");
1022
1023        // Frame 3: input 0 => F_2 = 0.25*0 + 0.75*100 = 75
1024        let r3 = state.process(&make_f64_array(&[0.0]));
1025        let v3 = r3.data.get_as_f64(0).unwrap();
1026        assert!((v3 - 75.0).abs() < 1e-9, "frame 3: got {v3}");
1027
1028        // Frame 4: input 0 => F_3 = 0.25*0 + 0.75*75 = 56.25
1029        let r4 = state.process(&make_f64_array(&[0.0]));
1030        let v4 = r4.data.get_as_f64(0).unwrap();
1031        assert!((v4 - 56.25).abs() < 1e-9, "frame 4: got {v4}");
1032    }
1033
1034    #[test]
1035    fn test_4tap_filter_all_taps() {
1036        // Use all 4 filter taps and 4 output taps to verify the full recurrence.
1037        let mut state = ProcessState::new(ProcessConfig {
1038            enable_filter: true,
1039            filter: FilterConfig {
1040                fc: [0.5, 0.3, 0.1, 0.1],
1041                oc: [0.7, 0.2, 0.05, 0.05],
1042                rc: [1.0, 0.0],
1043                f_offset: 0.0,
1044                f_scale: 1.0,
1045                o_offset: 0.0,
1046                o_scale: 1.0,
1047                ..Default::default()
1048            },
1049            output_type: Some(NDDataType::Float64),
1050            ..Default::default()
1051        });
1052
1053        // Frame 0: reset => F_0 = RC1*I = 10.0, O_0 = OC1*F_0 = 7.0
1054        let _ = state.process(&make_f64_array(&[10.0]));
1055
1056        // Frame 1: I=20
1057        // F_1 = 0.5*20 + 0.3*10 + 0.1*(0-0) + 0.1*(0-0) = 10+3 = 13
1058        // O_1 = 0.7*13 + 0.2*10 + 0.05*(7-0) + 0.05*(0-0) = 9.1+2+0.35 = 11.45
1059        let r1 = state.process(&make_f64_array(&[20.0]));
1060        let v1 = r1.data.get_as_f64(0).unwrap();
1061        assert!((v1 - 11.45).abs() < 1e-9, "frame 1: got {v1}");
1062
1063        // Frame 2: I=30
1064        // F_2 = 0.5*30 + 0.3*13 + 0.1*(10-0) + 0.1*(0-0) = 15+3.9+1 = 19.9
1065        // O_2 = 0.7*19.9 + 0.2*13 + 0.05*(11.45-0) + 0.05*(7-0) = 13.93+2.6+0.5725+0.35 = 17.4525
1066        let r2 = state.process(&make_f64_array(&[30.0]));
1067        let v2 = r2.data.get_as_f64(0).unwrap();
1068        assert!((v2 - 17.4525).abs() < 1e-9, "frame 2: got {v2}");
1069    }
1070
1071    #[test]
1072    fn test_save_background_one_shot() {
1073        let mut state = ProcessState::new(ProcessConfig {
1074            save_background: true,
1075            ..Default::default()
1076        });
1077
1078        assert!(!state.config.valid_background);
1079        assert!(state.background.is_none());
1080
1081        // Process with save_background=true: should capture and clear flag
1082        let input = make_array(&[10, 20, 30]);
1083        let _ = state.process(&input);
1084
1085        assert!(
1086            !state.config.save_background,
1087            "save_background should be cleared"
1088        );
1089        assert!(
1090            state.config.valid_background,
1091            "valid_background should be set"
1092        );
1093        assert!(state.background.is_some());
1094
1095        let bg = state.background.as_ref().unwrap();
1096        assert_eq!(bg.len(), 3);
1097        assert!((bg[0] - 10.0).abs() < 1e-9);
1098        assert!((bg[1] - 20.0).abs() < 1e-9);
1099        assert!((bg[2] - 30.0).abs() < 1e-9);
1100
1101        // Process again: flag should remain cleared, background should persist
1102        let input2 = make_array(&[40, 50, 60]);
1103        let _ = state.process(&input2);
1104
1105        assert!(
1106            !state.config.save_background,
1107            "save_background stays cleared"
1108        );
1109        // Background unchanged
1110        let bg2 = state.background.as_ref().unwrap();
1111        assert!((bg2[0] - 10.0).abs() < 1e-9);
1112    }
1113
1114    #[test]
1115    fn test_save_flat_field_one_shot() {
1116        let mut state = ProcessState::new(ProcessConfig {
1117            save_flat_field: true,
1118            ..Default::default()
1119        });
1120
1121        assert!(!state.config.valid_flat_field);
1122        assert!(state.flat_field.is_none());
1123
1124        let input = make_array(&[50, 100, 150]);
1125        let _ = state.process(&input);
1126
1127        assert!(
1128            !state.config.save_flat_field,
1129            "save_flat_field should be cleared"
1130        );
1131        assert!(
1132            state.config.valid_flat_field,
1133            "valid_flat_field should be set"
1134        );
1135        assert!(state.flat_field.is_some());
1136
1137        let ff = state.flat_field.as_ref().unwrap();
1138        assert_eq!(ff.len(), 3);
1139        assert!((ff[0] - 50.0).abs() < 1e-9);
1140        assert!((ff[1] - 100.0).abs() < 1e-9);
1141        assert!((ff[2] - 150.0).abs() < 1e-9);
1142    }
1143
1144    #[test]
1145    fn test_auto_reset_when_num_filter_reached() {
1146        let mut state = ProcessState::new(ProcessConfig {
1147            enable_filter: true,
1148            filter: FilterConfig {
1149                num_filter: 3,
1150                auto_reset: true,
1151                fc: [0.5, 0.5, 0.0, 0.0],
1152                oc: [1.0, 0.0, 0.0, 0.0],
1153                ..Default::default()
1154            },
1155            output_type: Some(NDDataType::Float64),
1156            ..Default::default()
1157        });
1158
1159        // Frame 0 (reset): num_filtered becomes 1
1160        let _ = state.process(&make_f64_array(&[100.0]));
1161        assert_eq!(state.num_filtered, 1);
1162
1163        // Frame 1: num_filtered becomes 2
1164        let _ = state.process(&make_f64_array(&[100.0]));
1165        assert_eq!(state.num_filtered, 2);
1166
1167        // Frame 2: num_filtered would become 3, triggers auto_reset => 0
1168        let _ = state.process(&make_f64_array(&[100.0]));
1169        assert_eq!(state.num_filtered, 0, "auto_reset should have fired");
1170        assert!(
1171            state.filter_state.is_none(),
1172            "filter state should be cleared"
1173        );
1174        assert!(
1175            state.output_state.is_none(),
1176            "output state should be cleared"
1177        );
1178
1179        // Frame 3 (after reset): acts as a new first frame
1180        let _ = state.process(&make_f64_array(&[200.0]));
1181        assert_eq!(state.num_filtered, 1, "fresh start after reset");
1182    }
1183
1184    #[test]
1185    fn test_filter_with_offset_scale() {
1186        // Test that f_offset/f_scale and o_offset/o_scale are applied correctly.
1187        let mut state = ProcessState::new(ProcessConfig {
1188            enable_filter: true,
1189            filter: FilterConfig {
1190                fc: [1.0, 0.0, 0.0, 0.0],
1191                oc: [1.0, 0.0, 0.0, 0.0],
1192                f_offset: 10.0,
1193                f_scale: 2.0,
1194                o_offset: 5.0,
1195                o_scale: 3.0,
1196                ..Default::default()
1197            },
1198            output_type: Some(NDDataType::Float64),
1199            ..Default::default()
1200        });
1201
1202        // Frame 0 (reset): F_0 = RC1*I = 1.0*50 = 50
1203        // Output on reset: O = OC1*F = 1.0*50 = 50, then O = o_offset + o_scale*O = 5+3*50 = 155
1204        let r0 = state.process(&make_f64_array(&[50.0]));
1205        let v0 = r0.data.get_as_f64(0).unwrap();
1206        assert!((v0 - 155.0).abs() < 1e-9, "frame 0: got {v0}");
1207
1208        // Frame 1: I=20
1209        // F_1 = FC1*20 = 20, then F_1 = f_offset + f_scale*F_1 = 10+2*20 = 50
1210        // O_1 = OC1*F_1 = 50, then O_1 = o_offset + o_scale*O_1 = 5+3*50 = 155
1211        let r1 = state.process(&make_f64_array(&[20.0]));
1212        let v1 = r1.data.get_as_f64(0).unwrap();
1213        assert!((v1 - 155.0).abs() < 1e-9, "frame 1: got {v1}");
1214    }
1215
1216    #[test]
1217    fn test_reset_filter_manual() {
1218        let mut state = ProcessState::new(ProcessConfig {
1219            enable_filter: true,
1220            filter: FilterConfig {
1221                fc: [0.5, 0.5, 0.0, 0.0],
1222                oc: [1.0, 0.0, 0.0, 0.0],
1223                ..Default::default()
1224            },
1225            output_type: Some(NDDataType::Float64),
1226            ..Default::default()
1227        });
1228
1229        // Build up filter state
1230        let _ = state.process(&make_f64_array(&[100.0]));
1231        let _ = state.process(&make_f64_array(&[100.0]));
1232        assert!(state.filter_state.is_some());
1233        assert_eq!(state.num_filtered, 2);
1234
1235        // Manual reset
1236        state.reset_filter();
1237        assert!(state.filter_state.is_none());
1238        assert!(state.output_state.is_none());
1239        assert_eq!(state.num_filtered, 0);
1240
1241        // Next frame should act as first frame (reset mode)
1242        let r = state.process(&make_f64_array(&[200.0]));
1243        let v = r.data.get_as_f64(0).unwrap();
1244        assert!(
1245            (v - 200.0).abs() < 1e-9,
1246            "after reset, first frame: got {v}"
1247        );
1248        assert_eq!(state.num_filtered, 1);
1249    }
1250}