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