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