Skip to main content

ad_plugins_rs/
time_series.rs

1use std::sync::Arc;
2use std::time::Instant;
3
4use asyn_rs::param::ParamType;
5use asyn_rs::port::{PortDriver, PortDriverBase, PortFlags};
6use asyn_rs::runtime::config::RuntimeConfig;
7use asyn_rs::runtime::port::{PortRuntimeHandle, create_port_runtime, port_runtime_unavailable};
8use asyn_rs::user::AsynUser;
9// Same types as `epics_libcom_rs::runtime::task` — `epics-base-rs` re-exports
10// the runtime layer, and this crate already depends on it unconditionally.
11use epics_base_rs::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
12use parking_lot::Mutex;
13
14// ===== Stats-specific channel definitions =====
15
16/// Number of stats channels in the time series.
17pub const NUM_STATS_TS_CHANNELS: usize = 23;
18
19/// Channel names for the 23 NDStats time series channels.
20pub const STATS_TS_CHANNEL_NAMES: [&str; NUM_STATS_TS_CHANNELS] = [
21    "TSMinValue",
22    "TSMinX",
23    "TSMinY",
24    "TSMaxValue",
25    "TSMaxX",
26    "TSMaxY",
27    "TSMeanValue",
28    "TSSigma",
29    "TSTotal",
30    "TSNet",
31    "TSCentroidTotal",
32    "TSCentroidX",
33    "TSCentroidY",
34    "TSSigmaX",
35    "TSSigmaY",
36    "TSSigmaXY",
37    "TSSkewX",
38    "TSSkewY",
39    "TSKurtosisX",
40    "TSKurtosisY",
41    "TSEccentricity",
42    "TSOrientation",
43    "TSTimestamp",
44];
45
46// ===== Generic time series data =====
47
48/// Shared data pushed from a plugin processor to a TS port driver.
49/// `values` length must match the channel count configured on the driver.
50pub struct TimeSeriesData {
51    pub values: Vec<f64>,
52}
53
54/// Sender from plugin -> TS port.
55pub type TimeSeriesSender = tokio::sync::mpsc::Sender<TimeSeriesData>;
56/// Receiver in TS port background thread.
57pub type TimeSeriesReceiver = tokio::sync::mpsc::Receiver<TimeSeriesData>;
58
59/// Registry for pending TS receivers, keyed by upstream plugin port name.
60/// NDStatsConfigure etc. store receivers here; NDTimeSeriesConfigure picks them up.
61pub struct TsReceiverRegistry {
62    inner: std::sync::Mutex<std::collections::HashMap<String, (TimeSeriesReceiver, Vec<String>)>>,
63}
64
65impl TsReceiverRegistry {
66    pub fn new() -> Self {
67        Self {
68            inner: std::sync::Mutex::new(std::collections::HashMap::new()),
69        }
70    }
71
72    /// Store a receiver and its channel names for a given upstream port.
73    pub fn store(
74        &self,
75        upstream_port: &str,
76        receiver: TimeSeriesReceiver,
77        channel_names: Vec<String>,
78    ) {
79        let mut map = self.inner.lock().unwrap();
80        map.insert(upstream_port.to_string(), (receiver, channel_names));
81    }
82
83    /// Take a receiver for the given upstream port (returns None if not found or already taken).
84    pub fn take(&self, upstream_port: &str) -> Option<(TimeSeriesReceiver, Vec<String>)> {
85        let mut map = self.inner.lock().unwrap();
86        map.remove(upstream_port)
87    }
88}
89
90impl Default for TsReceiverRegistry {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96/// Accumulation mode for time series.
97///
98/// Mirrors C++ `TSAcquireMode`: `OneShot` == `TSAcquireModeFixed` (acquisition
99/// stops once `num_points` output points are collected); `RingBuffer` ==
100/// `TSAcquireModeCircular` (the buffer wraps and acquisition continues).
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TimeSeriesMode {
103    /// C++ `TSAcquireModeFixed`: stop at `num_points`.
104    OneShot,
105    /// C++ `TSAcquireModeCircular`: wrap and keep acquiring.
106    RingBuffer,
107}
108
109/// Time-series accumulator: stores scalar/1D values from successive arrays.
110pub struct TimeSeries {
111    pub num_points: usize,
112    pub mode: TimeSeriesMode,
113    buffer: Vec<f64>,
114    write_pos: usize,
115    count: usize,
116}
117
118impl TimeSeries {
119    pub fn new(num_points: usize, mode: TimeSeriesMode) -> Self {
120        Self {
121            num_points,
122            mode,
123            buffer: vec![0.0; num_points],
124            write_pos: 0,
125            count: 0,
126        }
127    }
128
129    /// Add a value (e.g., mean of an array) to the time series.
130    pub fn add_value(&mut self, value: f64) {
131        match self.mode {
132            TimeSeriesMode::OneShot => {
133                if self.write_pos < self.num_points {
134                    self.buffer[self.write_pos] = value;
135                    self.write_pos += 1;
136                    self.count = self.write_pos;
137                }
138            }
139            TimeSeriesMode::RingBuffer => {
140                self.buffer[self.write_pos % self.num_points] = value;
141                self.write_pos += 1;
142                self.count = self.count.max(self.write_pos.min(self.num_points));
143            }
144        }
145    }
146
147    /// Get the accumulated values in order.
148    pub fn values(&self) -> Vec<f64> {
149        match self.mode {
150            TimeSeriesMode::OneShot => self.buffer[..self.count].to_vec(),
151            TimeSeriesMode::RingBuffer => {
152                if self.write_pos <= self.num_points {
153                    self.buffer[..self.count].to_vec()
154                } else {
155                    let start = self.write_pos % self.num_points;
156                    let mut result = Vec::with_capacity(self.num_points);
157                    result.extend_from_slice(&self.buffer[start..]);
158                    result.extend_from_slice(&self.buffer[..start]);
159                    result
160                }
161            }
162        }
163    }
164
165    pub fn count(&self) -> usize {
166        self.count
167    }
168
169    pub fn reset(&mut self) {
170        self.buffer.fill(0.0);
171        self.write_pos = 0;
172        self.count = 0;
173    }
174
175    /// Resize the buffer. Resets all data.
176    pub fn resize(&mut self, num_points: usize) {
177        self.num_points = num_points;
178        self.buffer = vec![0.0; num_points];
179        self.write_pos = 0;
180        self.count = 0;
181    }
182
183    /// Change the accumulation mode. Resets all data.
184    pub fn set_mode(&mut self, mode: TimeSeriesMode) {
185        self.mode = mode;
186        self.reset();
187    }
188}
189
190// ===== Time Series Port Driver =====
191
192/// Param indices for the TS port.
193pub struct TSParams {
194    pub ts_acquire: usize,
195    pub ts_read: usize,
196    pub ts_num_points: usize,
197    pub ts_current_point: usize,
198    pub ts_time_per_point: usize,
199    pub ts_averaging_time: usize,
200    pub ts_num_average: usize,
201    pub ts_elapsed_time: usize,
202    pub ts_acquire_mode: usize,
203    pub ts_time_axis: usize,
204    /// Per-channel waveform param indices (length = num_channels).
205    pub ts_channels: Vec<usize>,
206    /// Channel names (kept for registry building).
207    pub channel_names: Vec<String>,
208    /// Generic time series waveform (for NDTimeSeries.template).
209    pub ts_time_series: usize,
210    /// Timestamp waveform (for NDTimeSeries.template).
211    pub ts_timestamp: usize,
212}
213
214/// Shared state between the data ingestion thread and the TS port driver.
215pub struct SharedTsState {
216    pub buffers: Vec<TimeSeries>,
217    pub acquiring: bool,
218    pub start_time: Option<Instant>,
219    pub num_points: usize,
220    pub mode: TimeSeriesMode,
221    /// Number of input samples averaged into one output time point
222    /// (C++ `numAverage_`). `1` means each input sample is one output point.
223    pub num_average: usize,
224    /// Running per-channel sum of the input samples for the in-progress
225    /// output point (C++ `averageStore_`).
226    average_store: Vec<f64>,
227    /// Number of input samples accumulated into `average_store` so far
228    /// (C++ `numAveraged_`).
229    num_averaged: usize,
230}
231
232impl SharedTsState {
233    fn new(num_channels: usize, num_points: usize) -> Self {
234        let buffers = (0..num_channels)
235            .map(|_| TimeSeries::new(num_points, TimeSeriesMode::OneShot))
236            .collect();
237        Self {
238            buffers,
239            acquiring: false,
240            start_time: None,
241            num_points,
242            mode: TimeSeriesMode::OneShot,
243            num_average: 1,
244            average_store: vec![0.0; num_channels],
245            num_averaged: 0,
246        }
247    }
248
249    /// Reset the running average accumulator (C++ resets `numAveraged_` and
250    /// `averageStore_` on start/erase, resize, mode change).
251    fn reset_average(&mut self) {
252        for v in &mut self.average_store {
253            *v = 0.0;
254        }
255        self.num_averaged = 0;
256    }
257
258    /// Accumulate one input sample vector and, once `num_average` samples
259    /// have been collected, push the per-channel average into the buffers.
260    ///
261    /// Port of the inner loop of C++ `doAddToTimeSeriesT`: each call is one
262    /// input time point; `num_average` of them produce one output point.
263    /// Returns `true` when an output point was emitted this call.
264    fn accumulate(&mut self, values: &[f64]) -> bool {
265        let n = values.len().min(self.average_store.len());
266        for i in 0..n {
267            self.average_store[i] += values[i];
268        }
269        self.num_averaged += 1;
270        if self.num_averaged < self.num_average.max(1) {
271            return false;
272        }
273        let divisor = self.num_averaged as f64;
274        let nb = n.min(self.buffers.len());
275        for i in 0..nb {
276            self.buffers[i].add_value(self.average_store[i] / divisor);
277        }
278        self.reset_average();
279        true
280    }
281}
282
283/// TS port driver: standalone asyn PortDriver for time series waveforms.
284///
285/// Generic over the number of channels — Stats uses 23, ROIStat uses
286/// a different set, and NDTimeSeries standalone can use any count.
287pub struct TimeSeriesPortDriver {
288    base: PortDriverBase,
289    params: TSParams,
290    shared: Arc<Mutex<SharedTsState>>,
291    num_channels: usize,
292    time_per_point: f64,
293}
294
295impl TimeSeriesPortDriver {
296    fn new(
297        port_name: &str,
298        channel_names: &[&str],
299        num_points: usize,
300        shared: Arc<Mutex<SharedTsState>>,
301    ) -> Self {
302        let num_channels = channel_names.len();
303        let mut base = PortDriverBase::new(
304            port_name,
305            1,
306            PortFlags {
307                multi_device: false,
308                can_block: false,
309                destructible: true,
310            },
311        );
312
313        // NDPluginBase params (NDTimeSeries.template includes NDPluginBase.template)
314        let nd_params =
315            ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base).unwrap();
316        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
317        // C++ `NDPluginTimeSeries` derives from `NDPluginDriver` and so runs
318        // the `asynNDArrayDriver` constructor's read-only block; without it
319        // every NDPluginBase read-back this port serves reads back
320        // uninitialized. The four pool statistics stay unwritten: this port
321        // owns no `NDArrayPool`, and publishing zeros for a pool that does not
322        // exist would be inventing state rather than reporting it.
323        let _ = ad_core_rs::driver::ndarray_driver::init_read_only_params(
324            &mut base, &nd_params, port_name,
325        );
326
327        // Register control params
328        let ts_acquire = base.create_param("TS_ACQUIRE", ParamType::Int32).unwrap();
329        let _ = base.set_int32_param(ts_acquire, 0, 0);
330        let ts_read = base.create_param("TS_READ", ParamType::Int32).unwrap();
331        let ts_num_points = base
332            .create_param("TS_NUM_POINTS", ParamType::Int32)
333            .unwrap();
334        let _ = base.set_int32_param(ts_num_points, 0, num_points as i32);
335        let ts_current_point = base
336            .create_param("TS_CURRENT_POINT", ParamType::Int32)
337            .unwrap();
338        let _ = base.set_int32_param(ts_current_point, 0, 0);
339        let ts_time_per_point = base
340            .create_param("TS_TIME_PER_POINT", ParamType::Float64)
341            .unwrap();
342        let ts_averaging_time = base
343            .create_param("TS_AVERAGING_TIME", ParamType::Float64)
344            .unwrap();
345        let ts_num_average = base
346            .create_param("TS_NUM_AVERAGE", ParamType::Int32)
347            .unwrap();
348        let _ = base.set_int32_param(ts_num_average, 0, 1);
349        let ts_elapsed_time = base
350            .create_param("TS_ELAPSED_TIME", ParamType::Float64)
351            .unwrap();
352        let ts_acquire_mode = base
353            .create_param("TS_ACQUIRE_MODE", ParamType::Int32)
354            .unwrap();
355        let _ = base.set_int32_param(ts_acquire_mode, 0, 0);
356        let ts_time_axis = base
357            .create_param("TS_TIME_AXIS", ParamType::Float64Array)
358            .unwrap();
359
360        // Initialize time axis (scaled by time_per_point, default 1.0)
361        let time_per_point = 1.0;
362        let time_axis: Vec<f64> = (0..num_points).map(|i| i as f64 * time_per_point).collect();
363        let _ = base.params.set_float64_array(ts_time_axis, 0, time_axis);
364
365        // Channel waveform params — one Float64Array per channel
366        let mut ts_channels = Vec::with_capacity(num_channels);
367        for name in channel_names {
368            let param_name = format!("TS_CHAN_{name}");
369            let idx = base
370                .create_param(&param_name, ParamType::Float64Array)
371                .unwrap();
372            let _ = base.params.set_float64_array(idx, 0, vec![0.0; num_points]);
373            ts_channels.push(idx);
374        }
375
376        // Generic time series and timestamp waveform params
377        let ts_time_series = base
378            .create_param("TS_TIME_SERIES", ParamType::Float64Array)
379            .unwrap();
380        let ts_timestamp = base
381            .create_param("TS_TIMESTAMP", ParamType::Float64Array)
382            .unwrap();
383
384        let params = TSParams {
385            ts_acquire,
386            ts_read,
387            ts_num_points,
388            ts_current_point,
389            ts_time_per_point,
390            ts_averaging_time,
391            ts_num_average,
392            ts_elapsed_time,
393            ts_acquire_mode,
394            ts_time_axis,
395            ts_channels,
396            channel_names: channel_names.iter().map(|s| s.to_string()).collect(),
397            ts_time_series,
398            ts_timestamp,
399        };
400
401        Self {
402            base,
403            params,
404            shared,
405            num_channels,
406            time_per_point,
407        }
408    }
409
410    /// Build the time axis for the current mode.
411    ///
412    /// Fixed (OneShot) mode uses an ascending axis `i * time_per_point`;
413    /// Circular (RingBuffer) mode uses a signed axis ending at 0, so the
414    /// most recent point is t=0 and older points are negative — C++
415    /// `createAxisArray`: `timeAxis_[i] = -(numTimePoints-1-i)*timePerPoint`.
416    fn build_time_axis(&self, num_points: usize, mode: TimeSeriesMode) -> Vec<f64> {
417        (0..num_points)
418            .map(|i| match mode {
419                TimeSeriesMode::OneShot => i as f64 * self.time_per_point,
420                TimeSeriesMode::RingBuffer => {
421                    -((num_points.saturating_sub(1) - i) as f64) * self.time_per_point
422                }
423            })
424            .collect()
425    }
426
427    /// Recompute and publish the time axis param for the current mode.
428    fn refresh_time_axis(&mut self) {
429        let (num_points, mode) = {
430            let s = self.shared.lock();
431            (s.num_points, s.mode)
432        };
433        let axis = self.build_time_axis(num_points, mode);
434        let _ = self
435            .base
436            .params
437            .set_float64_array(self.params.ts_time_axis, 0, axis);
438    }
439
440    /// Copy buffer data to Float64Array params and call callbacks.
441    fn update_waveform_params(&mut self) {
442        let state = self.shared.lock();
443        let num_points = state.num_points;
444
445        // Update per-channel waveform params
446        for (i, buf) in state.buffers.iter().enumerate() {
447            let mut values = buf.values();
448            values.resize(num_points, 0.0);
449            let _ = self
450                .base
451                .params
452                .set_float64_array(self.params.ts_channels[i], 0, values);
453        }
454
455        // Update current point
456        let current_point = state.buffers[0].count();
457        let _ = self
458            .base
459            .set_int32_param(self.params.ts_current_point, 0, current_point as i32);
460
461        // Update elapsed time
462        if let Some(start) = state.start_time {
463            let elapsed = start.elapsed().as_secs_f64();
464            let _ = self
465                .base
466                .set_float64_param(self.params.ts_elapsed_time, 0, elapsed);
467        }
468
469        // Update acquire status (may have auto-stopped)
470        let acquiring = state.acquiring;
471        drop(state);
472
473        let _ = self
474            .base
475            .set_int32_param(self.params.ts_acquire, 0, if acquiring { 1 } else { 0 });
476
477        // Notify listeners
478        let _ = self.base.call_param_callbacks(0);
479    }
480}
481
482impl PortDriver for TimeSeriesPortDriver {
483    fn base(&self) -> &PortDriverBase {
484        &self.base
485    }
486
487    fn base_mut(&mut self) -> &mut PortDriverBase {
488        &mut self.base
489    }
490
491    fn write_int32(&mut self, user: &mut AsynUser, value: i32) -> asyn_rs::error::AsynResult<()> {
492        let reason = user.reason;
493
494        if reason == self.params.ts_acquire {
495            let mut state = self.shared.lock();
496            if value != 0 {
497                // Start acquiring
498                if !state.acquiring {
499                    // If buffers are empty, this is Erase/Start
500                    if state.buffers[0].count() == 0 {
501                        for buf in state.buffers.iter_mut() {
502                            buf.reset();
503                        }
504                    }
505                    // Start always resets the running average accumulator
506                    // (C++ doTimeSeriesCallbacks / start path).
507                    state.reset_average();
508                    state.acquiring = true;
509                    state.start_time = Some(Instant::now());
510                }
511            } else {
512                // Stop
513                state.acquiring = false;
514            }
515            drop(state);
516            self.base.set_int32_param(reason, 0, value)?;
517            self.base.call_param_callbacks(0)?;
518        } else if reason == self.params.ts_read {
519            // Trigger waveform update
520            self.update_waveform_params();
521        } else if reason == self.params.ts_num_points {
522            let new_size = value.max(1) as usize;
523            {
524                let mut state = self.shared.lock();
525                state.num_points = new_size;
526                for buf in state.buffers.iter_mut() {
527                    buf.resize(new_size);
528                }
529                state.reset_average();
530                state.acquiring = false;
531            }
532
533            // Rebuild the time axis for the current mode.
534            self.refresh_time_axis();
535
536            // Re-initialize channel waveforms
537            for i in 0..self.num_channels {
538                let _ = self.base.params.set_float64_array(
539                    self.params.ts_channels[i],
540                    0,
541                    vec![0.0; new_size],
542                );
543            }
544
545            self.base.set_int32_param(reason, 0, value)?;
546            self.base
547                .set_int32_param(self.params.ts_current_point, 0, 0)?;
548            self.base.set_int32_param(self.params.ts_acquire, 0, 0)?;
549            self.base.call_param_callbacks(0)?;
550        } else if reason == self.params.ts_num_average {
551            // numAverage: input samples averaged per output time point
552            // (C++ P_TSNumAverage). Resets the running accumulator.
553            let n = value.max(1) as usize;
554            {
555                let mut state = self.shared.lock();
556                state.num_average = n;
557                state.reset_average();
558            }
559            self.base.set_int32_param(reason, 0, n as i32)?;
560            self.base.call_param_callbacks(0)?;
561        } else if reason == self.params.ts_acquire_mode {
562            // 0 == TSAcquireModeFixed (OneShot), 1 == TSAcquireModeCircular.
563            let mode = if value == 0 {
564                TimeSeriesMode::OneShot
565            } else {
566                TimeSeriesMode::RingBuffer
567            };
568            {
569                let mut state = self.shared.lock();
570                state.mode = mode;
571                for buf in state.buffers.iter_mut() {
572                    buf.set_mode(mode);
573                }
574                state.reset_average();
575                state.acquiring = false;
576            }
577            // Circular mode flips the time axis to a signed (ending-at-0) one.
578            self.refresh_time_axis();
579
580            self.base.set_int32_param(reason, 0, value)?;
581            self.base.set_int32_param(self.params.ts_acquire, 0, 0)?;
582            self.base.call_param_callbacks(0)?;
583        } else {
584            // Default: store in param cache
585            self.base.set_int32_param(reason, user.addr, value)?;
586            self.base.call_param_callbacks(user.addr)?;
587        }
588
589        Ok(())
590    }
591
592    fn write_float64(&mut self, user: &mut AsynUser, value: f64) -> asyn_rs::error::AsynResult<()> {
593        let reason = user.reason;
594        if reason == self.params.ts_time_per_point {
595            self.time_per_point = value;
596            self.base.set_float64_param(reason, user.addr, value)?;
597            // Rebuild the time axis with the new scaling for the current mode.
598            self.refresh_time_axis();
599            self.base.call_param_callbacks(user.addr)?;
600        } else {
601            self.base.set_float64_param(reason, user.addr, value)?;
602            self.base.call_param_callbacks(user.addr)?;
603        }
604        Ok(())
605    }
606
607    fn read_float64_array(
608        &mut self,
609        user: &AsynUser,
610        buf: &mut [f64],
611    ) -> asyn_rs::error::AsynResult<usize> {
612        let data = self.base.params.get_float64_array(user.reason, user.addr)?;
613        let n = data.len().min(buf.len());
614        buf[..n].copy_from_slice(&data[..n]);
615        Ok(n)
616    }
617}
618
619/// Background thread that receives data from a plugin and accumulates into
620/// shared buffers.
621///
622/// Each received `TimeSeriesData` is one input time point. `num_average`
623/// consecutive input points are averaged into one output time point
624/// (C++ `doAddToTimeSeriesT`). In `OneShot`/Fixed mode acquisition auto-stops
625/// once `num_points` output points exist; in `RingBuffer`/Circular mode the
626/// `TimeSeries` ring buffer wraps and acquisition continues.
627fn ts_data_thread(shared: Arc<Mutex<SharedTsState>>, mut data_rx: TimeSeriesReceiver) {
628    while let Some(data) = data_rx.blocking_recv() {
629        let mut state = shared.lock();
630        if !state.acquiring {
631            continue;
632        }
633        let emitted = state.accumulate(&data.values);
634        // Auto-stop for Fixed (OneShot) mode once num_points output points
635        // have been collected. Only check when an output point was emitted.
636        if emitted
637            && state.mode == TimeSeriesMode::OneShot
638            && state.buffers[0].count() >= state.num_points
639        {
640            state.acquiring = false;
641        }
642    }
643}
644
645/// Create a TS port runtime.
646///
647/// `channel_names` defines the number and names of time series channels.
648/// Returns the port runtime handle, the TS params (for building a registry),
649/// and thread join handles for the actor and data ingestion threads.
650pub fn create_ts_port_runtime(
651    port_name: &str,
652    channel_names: &[&str],
653    num_points: usize,
654    data_rx: TimeSeriesReceiver,
655) -> (
656    PortRuntimeHandle,
657    TSParams,
658    std::thread::JoinHandle<()>,
659    std::thread::JoinHandle<()>,
660) {
661    let num_channels = channel_names.len();
662    let shared = Arc::new(Mutex::new(SharedTsState::new(num_channels, num_points)));
663
664    let driver = TimeSeriesPortDriver::new(port_name, channel_names, num_points, shared.clone());
665
666    // Capture params before the driver is moved into the actor
667    let ts_params = TSParams {
668        ts_acquire: driver.params.ts_acquire,
669        ts_read: driver.params.ts_read,
670        ts_num_points: driver.params.ts_num_points,
671        ts_current_point: driver.params.ts_current_point,
672        ts_time_per_point: driver.params.ts_time_per_point,
673        ts_averaging_time: driver.params.ts_averaging_time,
674        ts_num_average: driver.params.ts_num_average,
675        ts_elapsed_time: driver.params.ts_elapsed_time,
676        ts_acquire_mode: driver.params.ts_acquire_mode,
677        ts_time_axis: driver.params.ts_time_axis,
678        ts_channels: driver.params.ts_channels.clone(),
679        channel_names: driver.params.channel_names.clone(),
680        ts_time_series: driver.params.ts_time_series,
681        ts_timestamp: driver.params.ts_timestamp,
682    };
683
684    // Constructor-shaped like the plugin runtimes in `ad-core-rs`: this
685    // function returns the built port and has no error channel to its
686    // `*Configure` caller, so the only alternative is a handle to a port that
687    // does not exist. C prints and throws here (asynPortDriver.cpp:4036-4040)
688    // and iocsh catches it (iocsh.cpp:1269-1279), leaving the C IOC serving
689    // without the port; we deviate on purpose — see `port_runtime_unavailable`.
690    let (runtime_handle, actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
691        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
692
693    // Spawn data ingestion thread. `NDPluginTimeSeries` derives from
694    // `NDPluginDriver`, so in C this loop runs on the plugin callback threads
695    // built at `NDPluginDriver.cpp:1000` — band and stack from
696    // `asynNDArrayDriver.cpp:876-879`, and a creation failure throws
697    // `epicsThread::unableToCreateThread` (`epicsThread.cpp:214-220`) rather
698    // than returning a status the plugin carries on from.
699    let data_jh = MandatoryThread::new(
700        format!("ts-data-{port_name}"),
701        ThreadPriority::Medium,
702        StackSizeClass::Medium,
703    )
704    .spawn(move || {
705        ts_data_thread(shared, data_rx);
706    });
707
708    (runtime_handle, ts_params, actor_jh, data_jh)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use source_guard::{Comments, production};
715
716    /// # Invariant
717    ///
718    /// MUST: the `ts-data-*` thread be created through [`MandatoryThread`].
719    ///
720    /// `NDPluginTimeSeries` derives from `NDPluginDriver`, so in C this loop
721    /// runs on the callback threads built at `NDPluginDriver.cpp:1000`, and a
722    /// creation failure there throws `epicsThread::unableToCreateThread`
723    /// (`epicsThread.cpp:214-220`) rather than returning a status. Where C ends
724    /// up is not where we do, deliberately: iocsh catches what a command throws
725    /// (`iocsh.cpp:1269-1279`) and st.cmd continues by default
726    /// (`iocsh.cpp:995`, `:1123`), so the C IOC keeps the plugin's port
727    /// registered with nothing ingesting behind it. The `.expect` this replaced
728    /// produced that same zombie by unwinding one thread on a
729    /// `panic = "unwind"` target.
730    #[test]
731    fn the_ts_data_thread_is_mandatory() {
732        let src = include_str!("time_series.rs");
733        let prod = production(src, Comments::Strip);
734        assert_eq!(prod.matches("MandatoryThread::new(").count(), 1);
735        let strays: Vec<&str> = prod
736            .lines()
737            .map(str::trim)
738            .filter(|l| {
739                l.contains(concat!("thread", "::Builder::new()"))
740                    || l.contains(concat!("thread", "::spawn("))
741            })
742            .collect();
743        assert!(
744            strays.is_empty(),
745            "a data thread created outside `MandatoryThread` resolves its own \
746             spawn failure locally: {strays:?}"
747        );
748    }
749
750    #[test]
751    fn test_one_shot() {
752        let mut ts = TimeSeries::new(5, TimeSeriesMode::OneShot);
753        for i in 0..5 {
754            ts.add_value(i as f64);
755        }
756        assert_eq!(ts.count(), 5);
757        assert_eq!(ts.values(), vec![0.0, 1.0, 2.0, 3.0, 4.0]);
758
759        // Adding beyond capacity is a no-op
760        ts.add_value(99.0);
761        assert_eq!(ts.count(), 5);
762    }
763
764    #[test]
765    fn test_ring_buffer() {
766        let mut ts = TimeSeries::new(4, TimeSeriesMode::RingBuffer);
767        for i in 0..6 {
768            ts.add_value(i as f64);
769        }
770        assert_eq!(ts.count(), 4);
771        // Should contain [2, 3, 4, 5] in order
772        assert_eq!(ts.values(), vec![2.0, 3.0, 4.0, 5.0]);
773    }
774
775    #[test]
776    fn test_ring_buffer_partial() {
777        let mut ts = TimeSeries::new(4, TimeSeriesMode::RingBuffer);
778        ts.add_value(10.0);
779        ts.add_value(20.0);
780        assert_eq!(ts.count(), 2);
781        assert_eq!(ts.values(), vec![10.0, 20.0]);
782    }
783
784    #[test]
785    fn test_reset() {
786        let mut ts = TimeSeries::new(3, TimeSeriesMode::OneShot);
787        ts.add_value(1.0);
788        ts.add_value(2.0);
789        ts.reset();
790        assert_eq!(ts.count(), 0);
791        assert!(ts.values().is_empty());
792    }
793
794    #[test]
795    fn test_resize() {
796        let mut ts = TimeSeries::new(5, TimeSeriesMode::OneShot);
797        ts.add_value(1.0);
798        ts.add_value(2.0);
799        ts.resize(3);
800        assert_eq!(ts.num_points, 3);
801        assert_eq!(ts.count(), 0);
802        assert!(ts.values().is_empty());
803    }
804
805    #[test]
806    fn test_set_mode() {
807        let mut ts = TimeSeries::new(5, TimeSeriesMode::OneShot);
808        ts.add_value(1.0);
809        ts.set_mode(TimeSeriesMode::RingBuffer);
810        assert_eq!(ts.mode, TimeSeriesMode::RingBuffer);
811        assert_eq!(ts.count(), 0);
812    }
813
814    // --- TS port driver tests (using a small channel set for simplicity) ---
815
816    const TEST_CHANNELS: [&str; 3] = ["ChA", "ChB", "ChC"];
817
818    #[test]
819    fn test_shared_ts_state_init() {
820        let state = SharedTsState::new(3, 100);
821        assert_eq!(state.buffers.len(), 3);
822        assert_eq!(state.num_points, 100);
823        assert!(!state.acquiring);
824        assert_eq!(state.mode, TimeSeriesMode::OneShot);
825    }
826
827    #[test]
828    fn test_ts_port_driver_create() {
829        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
830        let driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared);
831        assert_eq!(driver.base().port_name, "TEST_TS");
832        assert_eq!(driver.num_channels, 3);
833        assert!(!driver.base().flags.multi_device);
834    }
835
836    #[test]
837    fn test_ts_port_driver_seeds_the_c_read_only_block() {
838        // C++ `NDPluginTimeSeries` derives from `NDPluginDriver` and so from
839        // `asynNDArrayDriver`, whose constructor seeds
840        // asynNDArrayDriver.cpp:954-1005. This port serves the
841        // NDPluginBase.template records that NDTimeSeries.template pulls in,
842        // so an unseeded read-back leaves them UDF/INVALID for the life of
843        // the IOC. Read through the strict getters, as a record does.
844        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
845        let driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared);
846        let base = driver.base();
847        for (name, want) in [
848            ("ARRAY_SIZE_X", 0),
849            ("ARRAY_COUNTER", 0),
850            ("COLOR_MODE", ad_core_rs::color::NDColorMode::Mono as i32),
851            (
852                "ND_ATTRIBUTES_STATUS",
853                ad_core_rs::driver::ndarray_driver::ATTR_STATUS_FILE_NOT_FOUND,
854            ),
855            ("NUM_QUEUED_ARRAYS", 0),
856        ] {
857            let index = base
858                .find_param(name)
859                .unwrap_or_else(|| panic!("{name} missing"));
860            assert_eq!(
861                base.get_int32_param_strict(index, 0)
862                    .unwrap_or_else(|e| panic!("{name} unset after construction: {e:?}")),
863                want,
864                "{name}"
865            );
866        }
867        let template = base.find_param("FILE_TEMPLATE").unwrap();
868        assert_eq!(
869            base.get_string_param_strict(template, 0).unwrap(),
870            b"%s%s_%3.3d.dat"
871        );
872        let self_name = base.find_param("PORT_NAME_SELF").unwrap();
873        assert_eq!(
874            base.get_string_param_strict(self_name, 0).unwrap(),
875            b"TEST_TS"
876        );
877    }
878
879    #[test]
880    fn test_ts_port_driver_write_acquire() {
881        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
882        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared.clone());
883
884        // Start acquiring
885        let mut user = AsynUser::new(driver.params.ts_acquire);
886        driver.write_int32(&mut user, 1).unwrap();
887        assert!(shared.lock().acquiring);
888
889        // Stop acquiring
890        driver.write_int32(&mut user, 0).unwrap();
891        assert!(!shared.lock().acquiring);
892    }
893
894    #[test]
895    fn test_ts_port_driver_write_num_points() {
896        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
897        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared.clone());
898
899        let mut user = AsynUser::new(driver.params.ts_num_points);
900        driver.write_int32(&mut user, 50).unwrap();
901
902        let state = shared.lock();
903        assert_eq!(state.num_points, 50);
904        for buf in &state.buffers {
905            assert_eq!(buf.num_points, 50);
906        }
907    }
908
909    #[test]
910    fn test_ts_port_driver_write_mode() {
911        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
912        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared.clone());
913
914        let mut user = AsynUser::new(driver.params.ts_acquire_mode);
915        driver.write_int32(&mut user, 1).unwrap();
916
917        let state = shared.lock();
918        assert_eq!(state.mode, TimeSeriesMode::RingBuffer);
919        for buf in &state.buffers {
920            assert_eq!(buf.mode, TimeSeriesMode::RingBuffer);
921        }
922    }
923
924    #[test]
925    fn test_ts_port_driver_update_waveforms() {
926        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 10)));
927        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 10, shared.clone());
928
929        // Add some data
930        {
931            let mut state = shared.lock();
932            state.acquiring = true;
933            state.start_time = Some(Instant::now());
934            for buf in state.buffers.iter_mut() {
935                buf.add_value(42.0);
936                buf.add_value(43.0);
937            }
938        }
939
940        // Trigger update
941        driver.update_waveform_params();
942
943        // Check current point was updated
944        let cp = driver
945            .base
946            .get_int32_param(driver.params.ts_current_point, 0)
947            .unwrap();
948        assert_eq!(cp, 2);
949
950        // Check waveform data was written
951        let data = driver
952            .base
953            .params
954            .get_float64_array(driver.params.ts_channels[0], 0)
955            .unwrap();
956        assert_eq!(data[0], 42.0);
957        assert_eq!(data[1], 43.0);
958    }
959
960    #[test]
961    fn test_ts_port_driver_read_array() {
962        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 5)));
963        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 5, shared);
964
965        let user = AsynUser::new(driver.params.ts_time_axis);
966        let mut buf = vec![0.0; 5];
967        let n = driver.read_float64_array(&user, &mut buf).unwrap();
968        assert_eq!(n, 5);
969        assert_eq!(buf, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
970    }
971
972    #[test]
973    fn test_ts_data_ingestion_oneshot() {
974        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 3)));
975        let (tx, rx) = tokio::sync::mpsc::channel(16);
976
977        // Start acquiring
978        shared.lock().acquiring = true;
979
980        let shared_clone = shared.clone();
981        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
982
983        // Send data
984        tx.blocking_send(TimeSeriesData {
985            values: vec![1.0, 10.0, 100.0],
986        })
987        .unwrap();
988        tx.blocking_send(TimeSeriesData {
989            values: vec![2.0, 20.0, 200.0],
990        })
991        .unwrap();
992        tx.blocking_send(TimeSeriesData {
993            values: vec![3.0, 30.0, 300.0],
994        })
995        .unwrap();
996        tx.blocking_send(TimeSeriesData {
997            values: vec![4.0, 40.0, 400.0],
998        })
999        .unwrap(); // beyond capacity
1000
1001        // Close channel and wait for thread
1002        drop(tx);
1003        jh.join().unwrap();
1004
1005        let state = shared.lock();
1006        assert_eq!(state.buffers[0].count(), 3);
1007        assert_eq!(state.buffers[0].values(), vec![1.0, 2.0, 3.0]);
1008        assert_eq!(state.buffers[1].values(), vec![10.0, 20.0, 30.0]);
1009        assert_eq!(state.buffers[2].values(), vec![100.0, 200.0, 300.0]);
1010        assert!(!state.acquiring); // auto-stopped
1011    }
1012
1013    #[test]
1014    fn test_ts_data_ingestion_not_acquiring() {
1015        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 10)));
1016        let (tx, rx) = tokio::sync::mpsc::channel(16);
1017
1018        // Not acquiring (default)
1019        let shared_clone = shared.clone();
1020        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1021
1022        tx.blocking_send(TimeSeriesData {
1023            values: vec![1.0, 2.0, 3.0],
1024        })
1025        .unwrap();
1026
1027        drop(tx);
1028        jh.join().unwrap();
1029
1030        let state = shared.lock();
1031        assert_eq!(state.buffers[0].count(), 0);
1032    }
1033
1034    #[test]
1035    fn test_num_average_averages_input_samples() {
1036        // numAverage = 3: every 3 input samples produce one averaged output
1037        // point. Channel A inputs 0,1,2 -> mean 1; 3,4,5 -> mean 4.
1038        let mut state = SharedTsState::new(1, 10);
1039        state.num_average = 3;
1040        assert!(!state.accumulate(&[0.0]));
1041        assert!(!state.accumulate(&[1.0]));
1042        assert!(state.accumulate(&[2.0])); // emits mean of 0,1,2 = 1
1043        assert!(!state.accumulate(&[3.0]));
1044        assert!(!state.accumulate(&[4.0]));
1045        assert!(state.accumulate(&[5.0])); // emits mean of 3,4,5 = 4
1046        let vals = state.buffers[0].values();
1047        assert_eq!(vals.len(), 2);
1048        assert!((vals[0] - 1.0).abs() < 1e-10);
1049        assert!((vals[1] - 4.0).abs() < 1e-10);
1050    }
1051
1052    #[test]
1053    fn test_num_average_one_is_passthrough() {
1054        // numAverage = 1: each input sample is one output point unchanged.
1055        let mut state = SharedTsState::new(2, 10);
1056        state.num_average = 1;
1057        assert!(state.accumulate(&[5.0, 50.0]));
1058        assert!(state.accumulate(&[6.0, 60.0]));
1059        assert_eq!(state.buffers[0].values(), vec![5.0, 6.0]);
1060        assert_eq!(state.buffers[1].values(), vec![50.0, 60.0]);
1061    }
1062
1063    #[test]
1064    fn test_num_average_drives_ingestion_thread() {
1065        // The data thread must average numAverage=2 samples per output point.
1066        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 5)));
1067        {
1068            let mut s = shared.lock();
1069            s.num_average = 2;
1070            s.acquiring = true;
1071        }
1072        let (tx, rx) = tokio::sync::mpsc::channel(16);
1073        let shared_clone = shared.clone();
1074        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1075
1076        for v in [10.0, 20.0, 30.0, 40.0] {
1077            tx.blocking_send(TimeSeriesData { values: vec![v] })
1078                .unwrap();
1079        }
1080        drop(tx);
1081        jh.join().unwrap();
1082
1083        // 4 input samples / numAverage 2 -> 2 output points: 15, 35.
1084        let state = shared.lock();
1085        let vals = state.buffers[0].values();
1086        assert_eq!(vals.len(), 2);
1087        assert!((vals[0] - 15.0).abs() < 1e-10);
1088        assert!((vals[1] - 35.0).abs() < 1e-10);
1089    }
1090
1091    #[test]
1092    fn test_fixed_mode_stops_at_num_points() {
1093        // Fixed (OneShot) mode: acquisition auto-stops once num_points output
1094        // points are collected, even with more input pending.
1095        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 3)));
1096        {
1097            let mut s = shared.lock();
1098            s.num_average = 1;
1099            s.mode = TimeSeriesMode::OneShot;
1100            s.acquiring = true;
1101        }
1102        let (tx, rx) = tokio::sync::mpsc::channel(16);
1103        let shared_clone = shared.clone();
1104        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1105        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
1106            tx.blocking_send(TimeSeriesData { values: vec![v] })
1107                .unwrap();
1108        }
1109        drop(tx);
1110        jh.join().unwrap();
1111
1112        let state = shared.lock();
1113        assert!(!state.acquiring, "Fixed mode must auto-stop");
1114        assert_eq!(state.buffers[0].count(), 3);
1115        assert_eq!(state.buffers[0].values(), vec![1.0, 2.0, 3.0]);
1116    }
1117
1118    #[test]
1119    fn test_circular_mode_wraps_and_keeps_acquiring() {
1120        // Circular (RingBuffer) mode: the buffer wraps; acquisition does not
1121        // auto-stop.
1122        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 3)));
1123        {
1124            let mut s = shared.lock();
1125            s.num_average = 1;
1126            s.mode = TimeSeriesMode::RingBuffer;
1127            for buf in s.buffers.iter_mut() {
1128                buf.set_mode(TimeSeriesMode::RingBuffer);
1129            }
1130            s.acquiring = true;
1131        }
1132        let (tx, rx) = tokio::sync::mpsc::channel(16);
1133        let shared_clone = shared.clone();
1134        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1135        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
1136            tx.blocking_send(TimeSeriesData { values: vec![v] })
1137                .unwrap();
1138        }
1139        drop(tx);
1140        jh.join().unwrap();
1141
1142        let state = shared.lock();
1143        assert!(state.acquiring, "Circular mode must keep acquiring");
1144        // Ring buffer of 3 keeps the last 3 points: 3,4,5.
1145        assert_eq!(state.buffers[0].values(), vec![3.0, 4.0, 5.0]);
1146    }
1147
1148    #[test]
1149    fn test_acquire_mode_param_drives_behavior_and_axis() {
1150        // Writing TS_ACQUIRE_MODE must switch the buffer mode AND flip the
1151        // time axis from ascending (Fixed) to signed-ending-at-0 (Circular).
1152        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 4)));
1153        let mut driver = TimeSeriesPortDriver::new("TEST_TS_MODE", &["Ch0"], 4, shared.clone());
1154
1155        // Fixed mode axis: 0, 1, 2, 3.
1156        let axis = driver
1157            .base
1158            .params
1159            .get_float64_array(driver.params.ts_time_axis, 0)
1160            .unwrap();
1161        assert_eq!(&*axis, &[0.0, 1.0, 2.0, 3.0]);
1162
1163        // Switch to Circular mode.
1164        let mut user = AsynUser::new(driver.params.ts_acquire_mode);
1165        driver.write_int32(&mut user, 1).unwrap();
1166        assert_eq!(shared.lock().mode, TimeSeriesMode::RingBuffer);
1167
1168        // Circular axis: -3, -2, -1, 0 (most recent point is t=0).
1169        let axis = driver
1170            .base
1171            .params
1172            .get_float64_array(driver.params.ts_time_axis, 0)
1173            .unwrap();
1174        assert_eq!(&*axis, &[-3.0, -2.0, -1.0, 0.0]);
1175    }
1176
1177    #[test]
1178    fn test_num_average_param_drives_state() {
1179        // Writing TS_NUM_AVERAGE must update SharedTsState::num_average.
1180        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 10)));
1181        let mut driver = TimeSeriesPortDriver::new("TEST_TS_NAVG", &["Ch0"], 10, shared.clone());
1182        let mut user = AsynUser::new(driver.params.ts_num_average);
1183        driver.write_int32(&mut user, 5).unwrap();
1184        assert_eq!(shared.lock().num_average, 5);
1185        // A value of 0 is clamped to 1.
1186        driver.write_int32(&mut user, 0).unwrap();
1187        assert_eq!(shared.lock().num_average, 1);
1188    }
1189
1190    #[test]
1191    fn test_create_ts_port_runtime() {
1192        let (_tx, rx) = tokio::sync::mpsc::channel(16);
1193        let (handle, params, _actor_jh, _data_jh) =
1194            create_ts_port_runtime("TEST_TS_RT", &TEST_CHANNELS, 100, rx);
1195        assert_eq!(handle.port_name(), "TEST_TS_RT");
1196        assert_eq!(params.ts_channels.len(), 3);
1197        handle.shutdown();
1198    }
1199}