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