Skip to main content

ad_plugins_rs/
time_series_plugin.rs

1//! Standalone `NDPluginTimeSeries` (port of ADCore `NDPluginTimeSeries.cpp`).
2//!
3//! Ingests raw 1-D or 2-D NDArrays shaped `[numSignalsIn, numTimes]` (signal is
4//! the fastest-varying dimension, `dims[0]`; time is `dims[1]`) and produces, per
5//! signal, a time-series waveform. Every `numAverage` input time points are
6//! averaged into one output point and written into a per-signal circular buffer.
7//!
8//! This is distinct from the receiver-fed [`crate::time_series`] port driver: that
9//! one accumulates already-derived per-frame scalars from Stats/ROIStat/Attribute
10//! (C's *embedded* time series), whereas this plugin is the standalone areaDetector
11//! `NDPluginTimeSeries` that consumes raw detector arrays.
12//!
13//! Output form matches C exactly: `TS_TIME_SERIES` is read at `addr == signal`
14//! (NDTimeSeriesN.template binds `@asyn($(PORT),$(SIGNAL),...)TS_TIME_SERIES` with
15//! `SCAN="I/O Intr"`), so the per-signal waveforms are delivered by the callback
16//! push (C `doCallbacksFloat64Array`), not by a pull read.
17//!
18//! Integer-truncating averaging is the parity-critical detail: C computes the
19//! averaged point as `(epicsType)averageStore_[signal] / numAveraged_`
20//! (NDPluginTimeSeries.cpp:191) — the double *sum* is cast to the element type
21//! first (wrapping/truncating), then integer-divided in the element type's
22//! promoted domain. For e.g. `UInt8` inputs `200,200,200` with `numAverage == 3`
23//! the sum is `600`, `(u8)600 == 88`, and `88 / 3 == 29` — not the `200` a plain
24//! `f64` divide would give. [`averaged_value`] reproduces this deterministically.
25//!
26//! Residual (documented, not a parity gap for the waveform records): C also fires
27//! `doCallbacksGenericPointer` downstream NDArrays — a 2-D array at `addr ==
28//! numSignals_` and a 1-D array per signal — when `NDArrayCallbacks` is set. This
29//! port does not yet emit those downstream NDArrays (the per-address NDArray
30//! delivery is not expressible in the broadcast `ProcessResult` model); the
31//! observable waveform/scalar records are fully implemented.
32
33use std::time::Instant;
34
35use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType};
36use ad_core_rs::ndarray_pool::NDArrayPool;
37use ad_core_rs::plugin::runtime::{
38    NDPluginProcess, ParamChangeResult, ParamUpdate, PluginParamSnapshot, ProcessResult,
39};
40use asyn_rs::param::ParamType;
41use asyn_rs::port::PortDriverBase;
42
43/// C `DEFAULT_NUM_TSPOINTS` (NDPluginTimeSeries.cpp:19).
44const DEFAULT_NUM_TSPOINTS: usize = 2048;
45
46/// C `TSAcquireMode` enum (NDPluginTimeSeries.cpp:21-24).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum AcquireMode {
49    /// `TSAcquireModeFixed == 0`: stop once `numTimePoints` points are collected.
50    Fixed,
51    /// `TSAcquireModeCircular == 1`: wrap the buffer and keep acquiring.
52    Circular,
53}
54
55/// Resolved parameter indices, filled by `register_params`.
56struct Params {
57    ts_acquire: usize,
58    ts_read: usize,
59    ts_num_points: usize,
60    ts_current_point: usize,
61    ts_time_per_point: usize,
62    ts_averaging_time: usize,
63    ts_num_average: usize,
64    ts_elapsed_time: usize,
65    ts_acquire_mode: usize,
66    ts_time_axis: usize,
67    ts_timestamp: usize,
68    ts_time_series: usize,
69}
70
71impl Params {
72    /// Sentinel indices (`usize::MAX`) so an accidental `reason == 0` cannot match
73    /// a real param before `register_params` runs.
74    const fn sentinel() -> Self {
75        Self {
76            ts_acquire: usize::MAX,
77            ts_read: usize::MAX,
78            ts_num_points: usize::MAX,
79            ts_current_point: usize::MAX,
80            ts_time_per_point: usize::MAX,
81            ts_averaging_time: usize::MAX,
82            ts_num_average: usize::MAX,
83            ts_elapsed_time: usize::MAX,
84            ts_acquire_mode: usize::MAX,
85            ts_time_axis: usize::MAX,
86            ts_timestamp: usize::MAX,
87            ts_time_series: usize::MAX,
88        }
89    }
90}
91
92/// Read one element of an NDArray data buffer as `f64` (C casts every sample to
93/// `epicsFloat64` before accumulating: `averageStore_[signal] += (epicsFloat64)*pIn`).
94#[inline]
95fn sample_f64(data: &NDDataBuffer, idx: usize) -> f64 {
96    match data {
97        NDDataBuffer::I8(v) => v[idx] as f64,
98        NDDataBuffer::U8(v) => v[idx] as f64,
99        NDDataBuffer::I16(v) => v[idx] as f64,
100        NDDataBuffer::U16(v) => v[idx] as f64,
101        NDDataBuffer::I32(v) => v[idx] as f64,
102        NDDataBuffer::U32(v) => v[idx] as f64,
103        NDDataBuffer::I64(v) => v[idx] as f64,
104        NDDataBuffer::U64(v) => v[idx] as f64,
105        NDDataBuffer::F32(v) => v[idx] as f64,
106        NDDataBuffer::F64(v) => v[idx],
107    }
108}
109
110/// Compute one averaged time-series point: **divide, then narrow**.
111///
112/// `averageStore_` is a `double` accumulator holding the SUM of `numAveraged_`
113/// samples, and the averaged point is that sum divided by the count, narrowed to
114/// the array's element type on its way into the `epicsType` circular buffer.
115///
116/// **DEVIATION from C, deliberate — CBUG-B25.** C writes
117/// (`NDPluginTimeSeries.cpp:191`):
118///
119/// ```c
120/// pTimeCircular[signal*numTimePoints_ + currentTimePoint_] =
121///     (epicsType)averageStore_[signal]/numAveraged_;
122/// ```
123///
124/// and C++ binds the cast tighter than the divide, so it parses as
125/// `((epicsType)averageStore_[signal]) / numAveraged_` — the SUM is truncated and
126/// wrapped into the narrow element type *before* the division. The parentheses
127/// are simply in the wrong place: three `UInt8` samples of 200 sum to 600, wrap
128/// to 88, and divide to **29** instead of 200. Every averaged integer point whose
129/// running sum exceeds the element range is wrong, and the sums routinely do.
130/// Float signals were never affected (no narrowing to overflow).
131///
132/// Dividing first, the mean of in-range samples is in range, so the narrowing is
133/// an ordinary truncation toward zero — the same one C's `(epicsType)` cast
134/// performs, and the same value C's integer division produces whenever its wrap
135/// did not fire.
136fn averaged_value(sum: f64, num_averaged: usize, dt: NDDataType) -> f64 {
137    let mean = sum / num_averaged.max(1) as f64;
138    match dt {
139        NDDataType::Float32 => mean as f32 as f64,
140        NDDataType::Float64 => mean,
141        // Every integer element type: C's `(epicsType)` cast of the mean.
142        _ => mean.trunc(),
143    }
144}
145
146/// Collapse duplicate `(reason, addr)` param updates, keeping the last value.
147///
148/// C sets a parameter library entry with repeated `setIntegerParam`/
149/// `setDoubleParam` calls and then fires `callParamCallbacks()` **once**, so each
150/// changed parameter is posted a single time with its final value. A single
151/// `process_array` here can touch the same param twice — e.g. a reallocating
152/// frame resets `TS_CURRENT_POINT` to 0 (via `acquire_reset`) and the accumulation
153/// then advances it — so coalescing reproduces C's one-callback-per-param
154/// semantics instead of pushing the transient intermediate value.
155fn coalesce_updates(updates: Vec<ParamUpdate>) -> Vec<ParamUpdate> {
156    fn key(u: &ParamUpdate) -> (usize, i32) {
157        match u {
158            ParamUpdate::Int32 { reason, addr, .. }
159            | ParamUpdate::Float64 { reason, addr, .. }
160            | ParamUpdate::Octet { reason, addr, .. }
161            | ParamUpdate::Float64Array { reason, addr, .. } => (*reason, *addr),
162        }
163    }
164    let mut seen = std::collections::HashSet::new();
165    let mut out = Vec::with_capacity(updates.len());
166    // Keep the last occurrence of each key while preserving relative order.
167    for u in updates.into_iter().rev() {
168        if seen.insert(key(&u)) {
169            out.push(u);
170        }
171    }
172    out.reverse();
173    out
174}
175
176/// Standalone `NDPluginTimeSeries` processor.
177pub struct TimeSeriesProcessor {
178    /// C `maxSignals_` (>= 1): the per-address fan-out and `averageStore_` size.
179    max_signals: usize,
180    /// C `numSignalsIn_`: `dims[0]` of the most recent input. `-1` until the first
181    /// frame, so the first callback always triggers `allocate_arrays`.
182    num_signals_in: i64,
183    /// C `numSignals_ == min(numSignalsIn_, maxSignals_)`.
184    num_signals: usize,
185    /// C `dataType_` (NDFloat64 at construction).
186    data_type: NDDataType,
187    /// C `numTimePoints_`.
188    num_time_points: usize,
189    /// C `currentTimePoint_`.
190    current_time_point: usize,
191    /// C `numAverage_`: input time points averaged into one output point.
192    num_average: usize,
193    /// C `numAveraged_`: input points accumulated so far for the in-progress point.
194    num_averaged: usize,
195    /// C `averageStore_`: running per-signal sum (length `max_signals`).
196    average_store: Vec<f64>,
197    /// C `timePerPoint_`.
198    time_per_point: f64,
199    /// C `averagingTimeRequested_`.
200    averaging_time_requested: f64,
201    /// C `averagingTimeActual_` (also the time-axis scale).
202    averaging_time_actual: f64,
203    /// C `acquireMode_`.
204    acquire_mode: AcquireMode,
205    /// C `P_TSAcquire` value, mirrored so the data plane can gate on it.
206    acquiring: bool,
207    /// C `pTimeCircular_->pData`, stored as `f64` of the already-narrowed
208    /// `epicsType` value: indexed `signal * num_time_points + t`.
209    circular: Vec<f64>,
210    /// C `timeStamp_`: per-output-point source timestamps (length `num_time_points`).
211    time_stamp: Vec<f64>,
212    /// C `startTime_`.
213    start_time: Instant,
214    p: Params,
215}
216
217impl TimeSeriesProcessor {
218    /// Create a processor for `max_signals` signals (clamped to at least 1, per C
219    /// `if (maxSignals < 1) maxSignals = 1`).
220    pub fn new(max_signals: usize) -> Self {
221        let max_signals = max_signals.max(1);
222        let num_time_points = DEFAULT_NUM_TSPOINTS;
223        Self {
224            max_signals,
225            num_signals_in: -1,
226            num_signals: max_signals,
227            data_type: NDDataType::Float64,
228            num_time_points,
229            current_time_point: 0,
230            num_average: 1,
231            num_averaged: 0,
232            average_store: vec![0.0; max_signals],
233            time_per_point: 0.0,
234            averaging_time_requested: 1.0,
235            // computeNumAverage with the default timePerPoint_==0 yields
236            // averagingTimeActual_ == averagingTimeRequested_ == 1.
237            averaging_time_actual: 1.0,
238            acquire_mode: AcquireMode::Fixed,
239            acquiring: false,
240            circular: vec![0.0; max_signals * num_time_points],
241            time_stamp: vec![0.0; num_time_points],
242            start_time: Instant::now(),
243            p: Params::sentinel(),
244        }
245    }
246
247    /// C `createAxisArray` (NDPluginTimeSeries.cpp:147-161): Fixed mode ascends
248    /// `i * averagingTimeActual_`; Circular mode ends at 0 with
249    /// `-(numTimePoints_-1-i) * averagingTimeActual_`. Returns the `TS_TIME_AXIS`
250    /// update (C `doCallbacksFloat64Array(timeAxis_, ..., P_TSTimeAxis, 0)`).
251    fn create_axis_array(&self) -> Vec<ParamUpdate> {
252        let axis: Vec<f64> = (0..self.num_time_points)
253            .map(|i| match self.acquire_mode {
254                AcquireMode::Fixed => i as f64 * self.averaging_time_actual,
255                AcquireMode::Circular => {
256                    -(((self.num_time_points - 1) - i) as f64) * self.averaging_time_actual
257                }
258            })
259            .collect();
260        vec![ParamUpdate::float64_array(self.p.ts_time_axis, axis)]
261    }
262
263    /// C `acquireReset` (NDPluginTimeSeries.cpp:137-145): zero the circular buffer
264    /// and timestamps, reset `currentTimePoint_`, restart `startTime_`. C does
265    /// **not** reset the averaging accumulator here.
266    fn acquire_reset(&mut self) -> Vec<ParamUpdate> {
267        self.circular.iter_mut().for_each(|v| *v = 0.0);
268        self.time_stamp.iter_mut().for_each(|v| *v = 0.0);
269        self.current_time_point = 0;
270        self.start_time = Instant::now();
271        vec![ParamUpdate::int32(self.p.ts_current_point, 0)]
272    }
273
274    /// C `allocateArrays` (NDPluginTimeSeries.cpp:115-135): (re)size the circular
275    /// buffer and timestamps to `numSignals_ * numTimePoints_`, rebuild the axis,
276    /// and reset acquisition.
277    fn allocate_arrays(&mut self) -> Vec<ParamUpdate> {
278        self.circular = vec![0.0; self.num_signals * self.num_time_points];
279        self.time_stamp = vec![0.0; self.num_time_points];
280        let mut updates = self.create_axis_array();
281        updates.extend(self.acquire_reset());
282        updates
283    }
284
285    /// C `computeNumAverage` (NDPluginTimeSeries.cpp:97-113): derive `numAverage_`
286    /// from the requested averaging time and the driver's time-per-point, then
287    /// post the actual averaging time, the averaging count, and the rescaled axis.
288    fn compute_num_average(&mut self) -> Vec<ParamUpdate> {
289        if self.time_per_point == 0.0 {
290            self.num_average = 1;
291            self.averaging_time_actual = self.averaging_time_requested;
292        } else {
293            // C: (int)(averagingTimeRequested_/timePerPoint_ + 0.5), clamped >= 1.
294            let n = (self.averaging_time_requested / self.time_per_point + 0.5) as i64;
295            self.num_average = if n < 1 { 1 } else { n as usize };
296            self.averaging_time_actual = self.time_per_point * self.num_average as f64;
297        }
298        self.num_averaged = 0;
299        let mut updates = vec![
300            ParamUpdate::float64(self.p.ts_averaging_time, self.averaging_time_actual),
301            ParamUpdate::int32(self.p.ts_num_average, self.num_average as i32),
302        ];
303        updates.extend(self.create_axis_array());
304        updates
305    }
306
307    /// C `doTimeSeriesCallbacksT` (NDPluginTimeSeries.cpp:263-290): emit one
308    /// `TS_TIME_SERIES` Float64Array per signal at `addr == signal`. Fixed mode
309    /// emits the filled prefix (`currentTimePoint_` points); Circular mode emits
310    /// the full ring rotated so the oldest point is first.
311    fn do_time_series_callbacks(&self) -> Vec<ParamUpdate> {
312        let ntp = self.num_time_points;
313        let mut updates = Vec::with_capacity(self.num_signals);
314        match self.acquire_mode {
315            AcquireMode::Fixed => {
316                for signal in 0..self.num_signals {
317                    let start = signal * ntp;
318                    let series = self.circular[start..start + self.current_time_point].to_vec();
319                    updates.push(ParamUpdate::float64_array_addr(
320                        self.p.ts_time_series,
321                        signal as i32,
322                        series,
323                    ));
324                }
325            }
326            AcquireMode::Circular => {
327                for signal in 0..self.num_signals {
328                    let base = signal * ntp;
329                    let mut series = Vec::with_capacity(ntp);
330                    let mut time_in = self.current_time_point;
331                    for _ in 0..ntp {
332                        series.push(self.circular[base + time_in]);
333                        time_in += 1;
334                        if time_in >= ntp {
335                            time_in = 0;
336                        }
337                    }
338                    updates.push(ParamUpdate::float64_array_addr(
339                        self.p.ts_time_series,
340                        signal as i32,
341                        series,
342                    ));
343                }
344            }
345        }
346        updates
347    }
348
349    /// C `doAddToTimeSeriesT` (NDPluginTimeSeries.cpp:168-213): accumulate each
350    /// input time point into `averageStore_`; every `numAverage_` points, write
351    /// the integer-truncated average into the circular buffer and advance. In
352    /// Fixed mode a full buffer stops acquisition and fires the per-signal
353    /// callbacks; in Circular mode the write position wraps.
354    fn add_to_time_series(&mut self, array: &NDArray) -> Vec<ParamUpdate> {
355        let mut updates = Vec::new();
356        let num_signals_in = self.num_signals_in.max(0) as usize;
357        if num_signals_in == 0 {
358            return updates;
359        }
360        let data = &array.data;
361        // C reads `numTimes` time points; 1-D arrays are a single time point.
362        let mut num_times = if array.dims.len() == 2 {
363            array.dims[1].size
364        } else {
365            1
366        };
367        // Defensive clamp against a malformed array whose buffer is shorter than
368        // its declared dims (C trusts the dims and would read out of bounds).
369        let max_times = data.len() / num_signals_in;
370        if num_times > max_times {
371            num_times = max_times;
372        }
373
374        let ntp = self.num_time_points;
375        for i in 0..num_times {
376            let base = i * num_signals_in;
377            for s in 0..self.num_signals {
378                self.average_store[s] += sample_f64(data, base + s);
379            }
380            self.num_averaged += 1;
381            if self.num_averaged < self.num_average {
382                continue;
383            }
384            // Collected the desired number of points to average.
385            for s in 0..self.num_signals {
386                let avg = averaged_value(self.average_store[s], self.num_averaged, self.data_type);
387                self.circular[s * ntp + self.current_time_point] = avg;
388                self.average_store[s] = 0.0;
389            }
390            self.num_averaged = 0;
391            self.time_stamp[self.current_time_point] = array.time_stamp;
392            self.current_time_point += 1;
393            if self.current_time_point >= ntp {
394                match self.acquire_mode {
395                    AcquireMode::Fixed => {
396                        // C setIntegerParam(P_TSAcquire, 0); doTimeSeriesCallbacks(); break.
397                        self.acquiring = false;
398                        updates.push(ParamUpdate::int32(self.p.ts_acquire, 0));
399                        updates.extend(self.do_time_series_callbacks());
400                        break;
401                    }
402                    AcquireMode::Circular => {
403                        self.current_time_point = 0;
404                    }
405                }
406            }
407        }
408
409        updates.push(ParamUpdate::int32(
410            self.p.ts_current_point,
411            self.current_time_point as i32,
412        ));
413        let elapsed = self.start_time.elapsed().as_secs_f64();
414        updates.push(ParamUpdate::float64(self.p.ts_elapsed_time, elapsed));
415        updates
416    }
417}
418
419impl NDPluginProcess for TimeSeriesProcessor {
420    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
421        // C processCallbacks: this plugin only handles 1-D or 2-D arrays.
422        let ndims = array.dims.len();
423        if !(1..=2).contains(&ndims) {
424            return ProcessResult::empty();
425        }
426
427        let mut updates: Vec<ParamUpdate> = Vec::new();
428
429        // C: reallocate when the data type or the input signal count changes.
430        let num_signals_in = array.dims[0].size;
431        let dtype = array.data.data_type();
432        if dtype != self.data_type || (num_signals_in as i64) != self.num_signals_in {
433            self.data_type = dtype;
434            self.num_signals_in = num_signals_in as i64;
435            self.num_signals = num_signals_in.min(self.max_signals);
436            updates.extend(self.allocate_arrays());
437        }
438
439        // C: only accumulate while P_TSAcquire is set.
440        if self.acquiring {
441            updates.extend(self.add_to_time_series(array));
442        }
443
444        ProcessResult::sink(coalesce_updates(updates))
445    }
446
447    fn plugin_type(&self) -> &str {
448        "NDPluginTimeSeries"
449    }
450
451    fn register_params(&mut self, base: &mut PortDriverBase) -> asyn_rs::error::AsynResult<()> {
452        // Per-plugin parameters (NDPluginTimeSeries.cpp:69-79).
453        self.p.ts_acquire = base.create_param("TS_ACQUIRE", ParamType::Int32)?;
454        self.p.ts_read = base.create_param("TS_READ", ParamType::Int32)?;
455        self.p.ts_num_points = base.create_param("TS_NUM_POINTS", ParamType::Int32)?;
456        self.p.ts_current_point = base.create_param("TS_CURRENT_POINT", ParamType::Int32)?;
457        self.p.ts_time_per_point = base.create_param("TS_TIME_PER_POINT", ParamType::Float64)?;
458        self.p.ts_averaging_time = base.create_param("TS_AVERAGING_TIME", ParamType::Float64)?;
459        self.p.ts_num_average = base.create_param("TS_NUM_AVERAGE", ParamType::Int32)?;
460        self.p.ts_elapsed_time = base.create_param("TS_ELAPSED_TIME", ParamType::Float64)?;
461        self.p.ts_acquire_mode = base.create_param("TS_ACQUIRE_MODE", ParamType::Int32)?;
462        self.p.ts_time_axis = base.create_param("TS_TIME_AXIS", ParamType::Float64Array)?;
463        self.p.ts_timestamp = base.create_param("TS_TIMESTAMP", ParamType::Float64Array)?;
464        // Per-signal parameter (NDPluginTimeSeries.cpp:82); read at addr == signal.
465        self.p.ts_time_series = base.create_param("TS_TIME_SERIES", ParamType::Float64Array)?;
466
467        // Initial values (C constructor: setIntegerParam(P_TSNumPoints, 2048),
468        // numAverage_ == 1, acquireMode_ == Fixed, etc.).
469        base.set_int32_param(self.p.ts_num_points, 0, self.num_time_points as i32)?;
470        base.set_int32_param(self.p.ts_num_average, 0, self.num_average as i32)?;
471        base.set_int32_param(self.p.ts_acquire, 0, 0)?;
472        base.set_int32_param(self.p.ts_acquire_mode, 0, 0)?;
473        base.set_int32_param(self.p.ts_current_point, 0, 0)?;
474        base.set_float64_param(self.p.ts_averaging_time, 0, self.averaging_time_actual)?;
475        base.set_float64_param(self.p.ts_time_per_point, 0, self.time_per_point)?;
476
477        // Initial Fixed-mode time axis (C constructor allocateArrays -> createAxisArray).
478        let axis: Vec<f64> = (0..self.num_time_points)
479            .map(|i| i as f64 * self.averaging_time_actual)
480            .collect();
481        base.params
482            .set_float64_array(self.p.ts_time_axis, 0, axis)?;
483        Ok(())
484    }
485
486    fn on_param_change(
487        &mut self,
488        reason: usize,
489        params: &PluginParamSnapshot,
490    ) -> ParamChangeResult {
491        let mut updates = Vec::new();
492        if reason == self.p.ts_num_points {
493            // C writeInt32 P_TSNumPoints -> allocateArrays().
494            self.num_time_points = params.value.as_i32().max(1) as usize;
495            updates.extend(self.allocate_arrays());
496        } else if reason == self.p.ts_acquire_mode {
497            // C writeInt32 P_TSAcquireMode -> acquireReset(); createAxisArray().
498            self.acquire_mode = if params.value.as_i32() == 0 {
499                AcquireMode::Fixed
500            } else {
501                AcquireMode::Circular
502            };
503            updates.extend(self.acquire_reset());
504            updates.extend(self.create_axis_array());
505        } else if reason == self.p.ts_acquire {
506            // C writeInt32 P_TSAcquire -> if value acquireReset() else doTimeSeriesCallbacks().
507            if params.value.as_i32() != 0 {
508                self.acquiring = true;
509                updates.extend(self.acquire_reset());
510            } else {
511                self.acquiring = false;
512                updates.extend(self.do_time_series_callbacks());
513            }
514        } else if reason == self.p.ts_read {
515            // C writeInt32 P_TSRead -> doTimeSeriesCallbacks().
516            updates.extend(self.do_time_series_callbacks());
517        } else if reason == self.p.ts_time_per_point {
518            // C writeFloat64 P_TSTimePerPoint -> computeNumAverage().
519            self.time_per_point = params.value.as_f64();
520            updates.extend(self.compute_num_average());
521        } else if reason == self.p.ts_averaging_time {
522            // C writeFloat64 P_TSAveragingTime -> computeNumAverage().
523            self.averaging_time_requested = params.value.as_f64();
524            updates.extend(self.compute_num_average());
525        }
526        ParamChangeResult::updates(updates)
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use ad_core_rs::ndarray::NDDimension;
534    use asyn_rs::port::{PortDriverBase, PortFlags};
535
536    // ---- averaged_value: divide first, then narrow (CBUG-B25) ----
537
538    /// CBUG-B25 — the average of three UInt8 samples of 200 is **200**.
539    ///
540    /// This test was `test_averaged_value_uint8_truncates_not_f64_divide` and
541    /// pinned C's answer, 29: C narrows the running sum to the element type before
542    /// dividing (`(epicsType)averageStore_[signal]/numAveraged_`,
543    /// NDPluginTimeSeries.cpp:191), so 600 wraps to (u8)88 and 88/3 = 29. It even
544    /// asserted `!= 200.0` — the correct value — to prove the divergence.
545    #[test]
546    fn test_averaged_value_uint8_divides_before_narrowing() {
547        assert_eq!(averaged_value(600.0, 3, NDDataType::UInt8), 200.0); // C: 29
548    }
549
550    /// CBUG-B25. Was `test_averaged_value_int8_negative_truncates_toward_zero`,
551    /// pinning C's -29: (i8)(-600) = -88, -88/3 = -29.
552    #[test]
553    fn test_averaged_value_int8_negative_divides_before_narrowing() {
554        assert_eq!(averaged_value(-600.0, 3, NDDataType::Int8), -200.0); // C: -29
555    }
556
557    /// CBUG-B25. Was `test_averaged_value_uint16_wraps`, pinning C's 2232:
558    /// (u16)70000 = 4464, 4464/2 = 2232.
559    #[test]
560    fn test_averaged_value_uint16_does_not_wrap() {
561        assert_eq!(averaged_value(70000.0, 2, NDDataType::UInt16), 35000.0); // C: 2232
562    }
563
564    /// The narrowing itself is still C's `(epicsType)` cast — truncation toward
565    /// zero — applied to the MEAN, which for in-range samples is in range. This
566    /// is also the value C produced whenever its wrap did not fire.
567    #[test]
568    fn test_averaged_value_narrows_the_mean_toward_zero() {
569        // 7/2 = 3.5 -> 3;  -7/2 = -3.5 -> -3 (C integer division truncates too).
570        assert_eq!(averaged_value(7.0, 2, NDDataType::Int8), 3.0);
571        assert_eq!(averaged_value(-7.0, 2, NDDataType::Int8), -3.0);
572        assert_eq!(averaged_value(7.0, 2, NDDataType::UInt16), 3.0);
573    }
574
575    #[test]
576    fn test_averaged_value_int32_in_range_no_wrap() {
577        assert_eq!(averaged_value(600.0, 3, NDDataType::Int32), 200.0);
578    }
579
580    /// Float signals were never affected by CBUG-B25 — no narrowing to overflow.
581    #[test]
582    fn test_averaged_value_float_types_exact() {
583        assert_eq!(averaged_value(600.0, 3, NDDataType::Float64), 200.0);
584        assert_eq!(averaged_value(600.0, 3, NDDataType::Float32), 200.0);
585        // ... and they do NOT truncate.
586        assert_eq!(averaged_value(7.0, 2, NDDataType::Float64), 3.5);
587    }
588
589    #[test]
590    fn test_averaged_value_numaverage_one_is_passthrough() {
591        assert_eq!(averaged_value(200.0, 1, NDDataType::UInt8), 200.0);
592        assert_eq!(averaged_value(-50.0, 1, NDDataType::Int8), -50.0);
593    }
594
595    // ---- process_array end-to-end ----
596
597    fn make_proc(max_signals: usize, port: &str) -> TimeSeriesProcessor {
598        let mut proc = TimeSeriesProcessor::new(max_signals);
599        let mut base = PortDriverBase::new(port, max_signals + 1, PortFlags::default());
600        proc.register_params(&mut base).unwrap();
601        proc
602    }
603
604    fn find_array(res: &ProcessResult, reason: usize, addr: i32) -> Option<Vec<f64>> {
605        res.param_updates.iter().find_map(|u| match u {
606            ParamUpdate::Float64Array {
607                reason: r,
608                addr: a,
609                value,
610            } if *r == reason && *a == addr => Some(value.clone()),
611            _ => None,
612        })
613    }
614
615    fn find_int(res: &ProcessResult, reason: usize) -> Option<i32> {
616        res.param_updates.iter().find_map(|u| match u {
617            ParamUpdate::Int32 {
618                reason: r, value, ..
619            } if *r == reason => Some(*value),
620            _ => None,
621        })
622    }
623
624    /// CBUG-B25, end to end. Was
625    /// `test_process_array_uint8_truncating_average_per_signal`, which asserted
626    /// 29.0 — C's answer, because it narrowed the sum (600) to u8 (88) before
627    /// dividing by 3. The average of three 200s is 200.
628    #[test]
629    fn test_process_array_uint8_average_per_signal() {
630        let mut proc = make_proc(2, "TST_TS_U8");
631        // numAverage = 3 via timePerPoint=1, averagingTime=3.
632        proc.time_per_point = 1.0;
633        proc.averaging_time_requested = 3.0;
634        let _ = proc.compute_num_average();
635        assert_eq!(proc.num_average, 3);
636        proc.acquiring = true;
637
638        let pool = NDArrayPool::new(1_000_000);
639        // 2 signals, 3 time points, every sample 200. Layout signal-fastest.
640        let arr = NDArray::with_data(
641            vec![NDDimension::new(2), NDDimension::new(3)],
642            NDDataBuffer::U8(vec![200; 6]),
643        );
644        let res = proc.process_array(&arr, &pool);
645
646        // One averaged output point per signal: 600 / 3 = 200 (C: 29).
647        assert_eq!(proc.current_time_point, 1);
648        assert_eq!(proc.circular[0 * proc.num_time_points], 200.0);
649        assert_eq!(proc.circular[proc.num_time_points], 200.0); // signal 1, t0
650        // Current point posted; Fixed mode not full yet, so no waveform callback.
651        assert_eq!(find_int(&res, proc.p.ts_current_point), Some(1));
652        assert!(find_array(&res, proc.p.ts_time_series, 0).is_none());
653    }
654
655    #[test]
656    fn test_fixed_mode_fills_stops_and_emits_waveforms() {
657        let mut proc = make_proc(1, "TST_TS_FIX");
658        proc.num_time_points = 2; // small buffer; realloc on first frame
659        proc.acquiring = true; // num_average stays 1
660
661        let pool = NDArrayPool::new(1_000_000);
662        // 1 signal, 3 time points: 10, 20, 30.
663        let arr = NDArray::with_data(
664            vec![NDDimension::new(1), NDDimension::new(3)],
665            NDDataBuffer::F64(vec![10.0, 20.0, 30.0]),
666        );
667        let res = proc.process_array(&arr, &pool);
668
669        // Buffer of 2 fills at the 2nd point: acquisition stops, 3rd point dropped.
670        assert!(!proc.acquiring);
671        assert_eq!(proc.current_time_point, 2);
672        assert_eq!(find_int(&res, proc.p.ts_acquire), Some(0));
673        let wf = find_array(&res, proc.p.ts_time_series, 0).expect("waveform emitted");
674        assert_eq!(wf, vec![10.0, 20.0]);
675    }
676
677    #[test]
678    fn test_circular_mode_wraps_and_rotates_oldest_first() {
679        let mut proc = make_proc(1, "TST_TS_CIRC");
680        proc.num_time_points = 3;
681        proc.acquire_mode = AcquireMode::Circular;
682        proc.acquiring = true;
683
684        let pool = NDArrayPool::new(1_000_000);
685        // 1 signal, 5 time points: 1..=5 into a 3-slot ring.
686        let arr = NDArray::with_data(
687            vec![NDDimension::new(1), NDDimension::new(5)],
688            NDDataBuffer::F64(vec![1.0, 2.0, 3.0, 4.0, 5.0]),
689        );
690        proc.process_array(&arr, &pool);
691
692        // Ring holds [4,5,3] with write position at 2; Circular never stops.
693        assert!(proc.acquiring);
694        assert_eq!(proc.current_time_point, 2);
695        // Reading rotates so the oldest point is first: [3,4,5].
696        let updates = proc.do_time_series_callbacks();
697        let wf = updates
698            .iter()
699            .find_map(|u| match u {
700                ParamUpdate::Float64Array {
701                    reason,
702                    addr,
703                    value,
704                } if *reason == proc.p.ts_time_series && *addr == 0 => Some(value.clone()),
705                _ => None,
706            })
707            .unwrap();
708        assert_eq!(wf, vec![3.0, 4.0, 5.0]);
709    }
710
711    #[test]
712    fn test_one_d_array_is_single_time_point_across_signals() {
713        let mut proc = make_proc(3, "TST_TS_1D");
714        proc.acquiring = true; // num_average == 1
715
716        let pool = NDArrayPool::new(1_000_000);
717        // 1-D array of 3 elements => 3 signals, 1 time point.
718        let arr = NDArray::with_data(
719            vec![NDDimension::new(3)],
720            NDDataBuffer::F64(vec![11.0, 22.0, 33.0]),
721        );
722        proc.process_array(&arr, &pool);
723
724        assert_eq!(proc.num_signals, 3);
725        assert_eq!(proc.current_time_point, 1);
726        let ntp = proc.num_time_points;
727        assert_eq!(proc.circular[0], 11.0);
728        assert_eq!(proc.circular[ntp], 22.0);
729        assert_eq!(proc.circular[2 * ntp], 33.0);
730    }
731
732    #[test]
733    fn test_num_signals_capped_at_max_signals() {
734        let mut proc = make_proc(2, "TST_TS_CAP");
735        proc.acquiring = true;
736
737        let pool = NDArrayPool::new(1_000_000);
738        // 4 input signals but max_signals == 2: only the first 2 are kept.
739        let arr = NDArray::with_data(
740            vec![NDDimension::new(4), NDDimension::new(1)],
741            NDDataBuffer::F64(vec![1.0, 2.0, 3.0, 4.0]),
742        );
743        proc.process_array(&arr, &pool);
744
745        assert_eq!(proc.num_signals, 2);
746        assert_eq!(proc.circular[0], 1.0);
747        assert_eq!(proc.circular[proc.num_time_points], 2.0);
748    }
749
750    #[test]
751    fn test_invalid_ndims_is_ignored() {
752        let mut proc = make_proc(1, "TST_TS_BAD");
753        proc.acquiring = true;
754        let pool = NDArrayPool::new(1_000_000);
755        // 3-D array: C rejects (ndims must be 1 or 2).
756        let arr = NDArray::with_data(
757            vec![
758                NDDimension::new(2),
759                NDDimension::new(2),
760                NDDimension::new(2),
761            ],
762            NDDataBuffer::F64(vec![0.0; 8]),
763        );
764        let res = proc.process_array(&arr, &pool);
765        assert!(res.param_updates.is_empty());
766        assert_eq!(proc.current_time_point, 0);
767    }
768
769    #[test]
770    fn test_acquire_mode_flips_time_axis() {
771        let mut proc = make_proc(1, "TST_TS_AXIS");
772        proc.num_time_points = 4;
773        // Realloc to size the axis (first frame would also do this).
774        let _ = proc.allocate_arrays();
775
776        // Fixed axis: 0, 1, 2, 3 (averagingTimeActual == 1).
777        let fixed = proc.create_axis_array();
778        let fixed_axis = match &fixed[0] {
779            ParamUpdate::Float64Array { value, .. } => value.clone(),
780            _ => panic!("expected axis"),
781        };
782        assert_eq!(fixed_axis, vec![0.0, 1.0, 2.0, 3.0]);
783
784        // Circular axis ends at 0: -3, -2, -1, 0.
785        proc.acquire_mode = AcquireMode::Circular;
786        let circ = proc.create_axis_array();
787        let circ_axis = match &circ[0] {
788            ParamUpdate::Float64Array { value, .. } => value.clone(),
789            _ => panic!("expected axis"),
790        };
791        assert_eq!(circ_axis, vec![-3.0, -2.0, -1.0, 0.0]);
792    }
793
794    #[test]
795    fn test_compute_num_average_from_averaging_time() {
796        let mut proc = make_proc(1, "TST_TS_NAVG");
797        proc.time_per_point = 0.5;
798        proc.averaging_time_requested = 2.0;
799        proc.compute_num_average();
800        // (int)(2.0/0.5 + 0.5) = (int)4.5 = 4; actual = 0.5 * 4 = 2.0.
801        assert_eq!(proc.num_average, 4);
802        assert_eq!(proc.averaging_time_actual, 2.0);
803
804        // timePerPoint == 0 => numAverage 1, actual == requested.
805        proc.time_per_point = 0.0;
806        proc.averaging_time_requested = 7.0;
807        proc.compute_num_average();
808        assert_eq!(proc.num_average, 1);
809        assert_eq!(proc.averaging_time_actual, 7.0);
810    }
811}