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