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 _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
315        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
316
317        // Register control params
318        let ts_acquire = base.create_param("TS_ACQUIRE", ParamType::Int32).unwrap();
319        let _ = base.set_int32_param(ts_acquire, 0, 0);
320        let ts_read = base.create_param("TS_READ", ParamType::Int32).unwrap();
321        let ts_num_points = base
322            .create_param("TS_NUM_POINTS", ParamType::Int32)
323            .unwrap();
324        let _ = base.set_int32_param(ts_num_points, 0, num_points as i32);
325        let ts_current_point = base
326            .create_param("TS_CURRENT_POINT", ParamType::Int32)
327            .unwrap();
328        let _ = base.set_int32_param(ts_current_point, 0, 0);
329        let ts_time_per_point = base
330            .create_param("TS_TIME_PER_POINT", ParamType::Float64)
331            .unwrap();
332        let ts_averaging_time = base
333            .create_param("TS_AVERAGING_TIME", ParamType::Float64)
334            .unwrap();
335        let ts_num_average = base
336            .create_param("TS_NUM_AVERAGE", ParamType::Int32)
337            .unwrap();
338        let _ = base.set_int32_param(ts_num_average, 0, 1);
339        let ts_elapsed_time = base
340            .create_param("TS_ELAPSED_TIME", ParamType::Float64)
341            .unwrap();
342        let ts_acquire_mode = base
343            .create_param("TS_ACQUIRE_MODE", ParamType::Int32)
344            .unwrap();
345        let _ = base.set_int32_param(ts_acquire_mode, 0, 0);
346        let ts_time_axis = base
347            .create_param("TS_TIME_AXIS", ParamType::Float64Array)
348            .unwrap();
349
350        // Initialize time axis (scaled by time_per_point, default 1.0)
351        let time_per_point = 1.0;
352        let time_axis: Vec<f64> = (0..num_points).map(|i| i as f64 * time_per_point).collect();
353        let _ = base.params.set_float64_array(ts_time_axis, 0, time_axis);
354
355        // Channel waveform params — one Float64Array per channel
356        let mut ts_channels = Vec::with_capacity(num_channels);
357        for name in channel_names {
358            let param_name = format!("TS_CHAN_{name}");
359            let idx = base
360                .create_param(&param_name, ParamType::Float64Array)
361                .unwrap();
362            let _ = base.params.set_float64_array(idx, 0, vec![0.0; num_points]);
363            ts_channels.push(idx);
364        }
365
366        // Generic time series and timestamp waveform params
367        let ts_time_series = base
368            .create_param("TS_TIME_SERIES", ParamType::Float64Array)
369            .unwrap();
370        let ts_timestamp = base
371            .create_param("TS_TIMESTAMP", ParamType::Float64Array)
372            .unwrap();
373
374        let params = TSParams {
375            ts_acquire,
376            ts_read,
377            ts_num_points,
378            ts_current_point,
379            ts_time_per_point,
380            ts_averaging_time,
381            ts_num_average,
382            ts_elapsed_time,
383            ts_acquire_mode,
384            ts_time_axis,
385            ts_channels,
386            channel_names: channel_names.iter().map(|s| s.to_string()).collect(),
387            ts_time_series,
388            ts_timestamp,
389        };
390
391        Self {
392            base,
393            params,
394            shared,
395            num_channels,
396            time_per_point,
397        }
398    }
399
400    /// Build the time axis for the current mode.
401    ///
402    /// Fixed (OneShot) mode uses an ascending axis `i * time_per_point`;
403    /// Circular (RingBuffer) mode uses a signed axis ending at 0, so the
404    /// most recent point is t=0 and older points are negative — C++
405    /// `createAxisArray`: `timeAxis_[i] = -(numTimePoints-1-i)*timePerPoint`.
406    fn build_time_axis(&self, num_points: usize, mode: TimeSeriesMode) -> Vec<f64> {
407        (0..num_points)
408            .map(|i| match mode {
409                TimeSeriesMode::OneShot => i as f64 * self.time_per_point,
410                TimeSeriesMode::RingBuffer => {
411                    -((num_points.saturating_sub(1) - i) as f64) * self.time_per_point
412                }
413            })
414            .collect()
415    }
416
417    /// Recompute and publish the time axis param for the current mode.
418    fn refresh_time_axis(&mut self) {
419        let (num_points, mode) = {
420            let s = self.shared.lock();
421            (s.num_points, s.mode)
422        };
423        let axis = self.build_time_axis(num_points, mode);
424        let _ = self
425            .base
426            .params
427            .set_float64_array(self.params.ts_time_axis, 0, axis);
428    }
429
430    /// Copy buffer data to Float64Array params and call callbacks.
431    fn update_waveform_params(&mut self) {
432        let state = self.shared.lock();
433        let num_points = state.num_points;
434
435        // Update per-channel waveform params
436        for (i, buf) in state.buffers.iter().enumerate() {
437            let mut values = buf.values();
438            values.resize(num_points, 0.0);
439            let _ = self
440                .base
441                .params
442                .set_float64_array(self.params.ts_channels[i], 0, values);
443        }
444
445        // Update current point
446        let current_point = state.buffers[0].count();
447        let _ = self
448            .base
449            .set_int32_param(self.params.ts_current_point, 0, current_point as i32);
450
451        // Update elapsed time
452        if let Some(start) = state.start_time {
453            let elapsed = start.elapsed().as_secs_f64();
454            let _ = self
455                .base
456                .set_float64_param(self.params.ts_elapsed_time, 0, elapsed);
457        }
458
459        // Update acquire status (may have auto-stopped)
460        let acquiring = state.acquiring;
461        drop(state);
462
463        let _ = self
464            .base
465            .set_int32_param(self.params.ts_acquire, 0, if acquiring { 1 } else { 0 });
466
467        // Notify listeners
468        let _ = self.base.call_param_callbacks(0);
469    }
470}
471
472impl PortDriver for TimeSeriesPortDriver {
473    fn base(&self) -> &PortDriverBase {
474        &self.base
475    }
476
477    fn base_mut(&mut self) -> &mut PortDriverBase {
478        &mut self.base
479    }
480
481    fn write_int32(&mut self, user: &mut AsynUser, value: i32) -> asyn_rs::error::AsynResult<()> {
482        let reason = user.reason;
483
484        if reason == self.params.ts_acquire {
485            let mut state = self.shared.lock();
486            if value != 0 {
487                // Start acquiring
488                if !state.acquiring {
489                    // If buffers are empty, this is Erase/Start
490                    if state.buffers[0].count() == 0 {
491                        for buf in state.buffers.iter_mut() {
492                            buf.reset();
493                        }
494                    }
495                    // Start always resets the running average accumulator
496                    // (C++ doTimeSeriesCallbacks / start path).
497                    state.reset_average();
498                    state.acquiring = true;
499                    state.start_time = Some(Instant::now());
500                }
501            } else {
502                // Stop
503                state.acquiring = false;
504            }
505            drop(state);
506            self.base.set_int32_param(reason, 0, value)?;
507            self.base.call_param_callbacks(0)?;
508        } else if reason == self.params.ts_read {
509            // Trigger waveform update
510            self.update_waveform_params();
511        } else if reason == self.params.ts_num_points {
512            let new_size = value.max(1) as usize;
513            {
514                let mut state = self.shared.lock();
515                state.num_points = new_size;
516                for buf in state.buffers.iter_mut() {
517                    buf.resize(new_size);
518                }
519                state.reset_average();
520                state.acquiring = false;
521            }
522
523            // Rebuild the time axis for the current mode.
524            self.refresh_time_axis();
525
526            // Re-initialize channel waveforms
527            for i in 0..self.num_channels {
528                let _ = self.base.params.set_float64_array(
529                    self.params.ts_channels[i],
530                    0,
531                    vec![0.0; new_size],
532                );
533            }
534
535            self.base.set_int32_param(reason, 0, value)?;
536            self.base
537                .set_int32_param(self.params.ts_current_point, 0, 0)?;
538            self.base.set_int32_param(self.params.ts_acquire, 0, 0)?;
539            self.base.call_param_callbacks(0)?;
540        } else if reason == self.params.ts_num_average {
541            // numAverage: input samples averaged per output time point
542            // (C++ P_TSNumAverage). Resets the running accumulator.
543            let n = value.max(1) as usize;
544            {
545                let mut state = self.shared.lock();
546                state.num_average = n;
547                state.reset_average();
548            }
549            self.base.set_int32_param(reason, 0, n as i32)?;
550            self.base.call_param_callbacks(0)?;
551        } else if reason == self.params.ts_acquire_mode {
552            // 0 == TSAcquireModeFixed (OneShot), 1 == TSAcquireModeCircular.
553            let mode = if value == 0 {
554                TimeSeriesMode::OneShot
555            } else {
556                TimeSeriesMode::RingBuffer
557            };
558            {
559                let mut state = self.shared.lock();
560                state.mode = mode;
561                for buf in state.buffers.iter_mut() {
562                    buf.set_mode(mode);
563                }
564                state.reset_average();
565                state.acquiring = false;
566            }
567            // Circular mode flips the time axis to a signed (ending-at-0) one.
568            self.refresh_time_axis();
569
570            self.base.set_int32_param(reason, 0, value)?;
571            self.base.set_int32_param(self.params.ts_acquire, 0, 0)?;
572            self.base.call_param_callbacks(0)?;
573        } else {
574            // Default: store in param cache
575            self.base.set_int32_param(reason, user.addr, value)?;
576            self.base.call_param_callbacks(user.addr)?;
577        }
578
579        Ok(())
580    }
581
582    fn write_float64(&mut self, user: &mut AsynUser, value: f64) -> asyn_rs::error::AsynResult<()> {
583        let reason = user.reason;
584        if reason == self.params.ts_time_per_point {
585            self.time_per_point = value;
586            self.base.set_float64_param(reason, user.addr, value)?;
587            // Rebuild the time axis with the new scaling for the current mode.
588            self.refresh_time_axis();
589            self.base.call_param_callbacks(user.addr)?;
590        } else {
591            self.base.set_float64_param(reason, user.addr, value)?;
592            self.base.call_param_callbacks(user.addr)?;
593        }
594        Ok(())
595    }
596
597    fn read_float64_array(
598        &mut self,
599        user: &AsynUser,
600        buf: &mut [f64],
601    ) -> asyn_rs::error::AsynResult<usize> {
602        let data = self.base.params.get_float64_array(user.reason, user.addr)?;
603        let n = data.len().min(buf.len());
604        buf[..n].copy_from_slice(&data[..n]);
605        Ok(n)
606    }
607}
608
609/// Background thread that receives data from a plugin and accumulates into
610/// shared buffers.
611///
612/// Each received `TimeSeriesData` is one input time point. `num_average`
613/// consecutive input points are averaged into one output time point
614/// (C++ `doAddToTimeSeriesT`). In `OneShot`/Fixed mode acquisition auto-stops
615/// once `num_points` output points exist; in `RingBuffer`/Circular mode the
616/// `TimeSeries` ring buffer wraps and acquisition continues.
617fn ts_data_thread(shared: Arc<Mutex<SharedTsState>>, mut data_rx: TimeSeriesReceiver) {
618    while let Some(data) = data_rx.blocking_recv() {
619        let mut state = shared.lock();
620        if !state.acquiring {
621            continue;
622        }
623        let emitted = state.accumulate(&data.values);
624        // Auto-stop for Fixed (OneShot) mode once num_points output points
625        // have been collected. Only check when an output point was emitted.
626        if emitted
627            && state.mode == TimeSeriesMode::OneShot
628            && state.buffers[0].count() >= state.num_points
629        {
630            state.acquiring = false;
631        }
632    }
633}
634
635/// Create a TS port runtime.
636///
637/// `channel_names` defines the number and names of time series channels.
638/// Returns the port runtime handle, the TS params (for building a registry),
639/// and thread join handles for the actor and data ingestion threads.
640pub fn create_ts_port_runtime(
641    port_name: &str,
642    channel_names: &[&str],
643    num_points: usize,
644    data_rx: TimeSeriesReceiver,
645) -> (
646    PortRuntimeHandle,
647    TSParams,
648    std::thread::JoinHandle<()>,
649    std::thread::JoinHandle<()>,
650) {
651    let num_channels = channel_names.len();
652    let shared = Arc::new(Mutex::new(SharedTsState::new(num_channels, num_points)));
653
654    let driver = TimeSeriesPortDriver::new(port_name, channel_names, num_points, shared.clone());
655
656    // Capture params before the driver is moved into the actor
657    let ts_params = TSParams {
658        ts_acquire: driver.params.ts_acquire,
659        ts_read: driver.params.ts_read,
660        ts_num_points: driver.params.ts_num_points,
661        ts_current_point: driver.params.ts_current_point,
662        ts_time_per_point: driver.params.ts_time_per_point,
663        ts_averaging_time: driver.params.ts_averaging_time,
664        ts_num_average: driver.params.ts_num_average,
665        ts_elapsed_time: driver.params.ts_elapsed_time,
666        ts_acquire_mode: driver.params.ts_acquire_mode,
667        ts_time_axis: driver.params.ts_time_axis,
668        ts_channels: driver.params.ts_channels.clone(),
669        channel_names: driver.params.channel_names.clone(),
670        ts_time_series: driver.params.ts_time_series,
671        ts_timestamp: driver.params.ts_timestamp,
672    };
673
674    // Constructor-shaped like the plugin runtimes in `ad-core-rs`: this
675    // function returns the built port and has no error channel to its
676    // `*Configure` caller, so the only alternative is a handle to a port that
677    // does not exist. C prints and throws here (asynPortDriver.cpp:4036-4040)
678    // and iocsh catches it (iocsh.cpp:1274-1284), leaving the C IOC serving
679    // without the port; we deviate on purpose — see `port_runtime_unavailable`.
680    let (runtime_handle, actor_jh) = create_port_runtime(driver, RuntimeConfig::default())
681        .unwrap_or_else(|e| port_runtime_unavailable(port_name, &e));
682
683    // Spawn data ingestion thread. `NDPluginTimeSeries` derives from
684    // `NDPluginDriver`, so in C this loop runs on the plugin callback threads
685    // built at `NDPluginDriver.cpp:1016` — band and stack from
686    // `asynNDArrayDriver.cpp:876-879`, and a creation failure throws
687    // `epicsThread::unableToCreateThread` (`epicsThread.cpp:214-220`) rather
688    // than returning a status the plugin carries on from.
689    let data_jh = MandatoryThread::new(
690        format!("ts-data-{port_name}"),
691        ThreadPriority::Medium,
692        StackSizeClass::Medium,
693    )
694    .spawn(move || {
695        ts_data_thread(shared, data_rx);
696    });
697
698    (runtime_handle, ts_params, actor_jh, data_jh)
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    /// # Invariant
706    ///
707    /// MUST: the `ts-data-*` thread be created through [`MandatoryThread`].
708    ///
709    /// `NDPluginTimeSeries` derives from `NDPluginDriver`, so in C this loop
710    /// runs on the callback threads built at `NDPluginDriver.cpp:1016`, and a
711    /// creation failure there throws `epicsThread::unableToCreateThread`
712    /// (`epicsThread.cpp:214-220`) rather than returning a status. Where C ends
713    /// up is not where we do, deliberately: iocsh catches what a command throws
714    /// (`iocsh.cpp:1274-1284`) and st.cmd continues by default
715    /// (`iocsh.cpp:1001`, `:1129`), so the C IOC keeps the plugin's port
716    /// registered with nothing ingesting behind it. The `.expect` this replaced
717    /// produced that same zombie by unwinding one thread on a
718    /// `panic = "unwind"` target.
719    #[test]
720    fn the_ts_data_thread_is_mandatory() {
721        let src = include_str!("time_series.rs");
722        let prod = match src.find("\n#[cfg(test)]") {
723            Some(i) => &src[..i],
724            None => src,
725        };
726        assert_eq!(prod.matches("MandatoryThread::new(").count(), 1);
727        let strays: Vec<&str> = prod
728            .lines()
729            .map(str::trim)
730            .filter(|l| !l.starts_with("//"))
731            .filter(|l| {
732                l.contains(concat!("thread", "::Builder::new()"))
733                    || l.contains(concat!("thread", "::spawn("))
734            })
735            .collect();
736        assert!(
737            strays.is_empty(),
738            "a data thread created outside `MandatoryThread` resolves its own \
739             spawn failure locally: {strays:?}"
740        );
741    }
742
743    #[test]
744    fn test_one_shot() {
745        let mut ts = TimeSeries::new(5, TimeSeriesMode::OneShot);
746        for i in 0..5 {
747            ts.add_value(i as f64);
748        }
749        assert_eq!(ts.count(), 5);
750        assert_eq!(ts.values(), vec![0.0, 1.0, 2.0, 3.0, 4.0]);
751
752        // Adding beyond capacity is a no-op
753        ts.add_value(99.0);
754        assert_eq!(ts.count(), 5);
755    }
756
757    #[test]
758    fn test_ring_buffer() {
759        let mut ts = TimeSeries::new(4, TimeSeriesMode::RingBuffer);
760        for i in 0..6 {
761            ts.add_value(i as f64);
762        }
763        assert_eq!(ts.count(), 4);
764        // Should contain [2, 3, 4, 5] in order
765        assert_eq!(ts.values(), vec![2.0, 3.0, 4.0, 5.0]);
766    }
767
768    #[test]
769    fn test_ring_buffer_partial() {
770        let mut ts = TimeSeries::new(4, TimeSeriesMode::RingBuffer);
771        ts.add_value(10.0);
772        ts.add_value(20.0);
773        assert_eq!(ts.count(), 2);
774        assert_eq!(ts.values(), vec![10.0, 20.0]);
775    }
776
777    #[test]
778    fn test_reset() {
779        let mut ts = TimeSeries::new(3, TimeSeriesMode::OneShot);
780        ts.add_value(1.0);
781        ts.add_value(2.0);
782        ts.reset();
783        assert_eq!(ts.count(), 0);
784        assert!(ts.values().is_empty());
785    }
786
787    #[test]
788    fn test_resize() {
789        let mut ts = TimeSeries::new(5, TimeSeriesMode::OneShot);
790        ts.add_value(1.0);
791        ts.add_value(2.0);
792        ts.resize(3);
793        assert_eq!(ts.num_points, 3);
794        assert_eq!(ts.count(), 0);
795        assert!(ts.values().is_empty());
796    }
797
798    #[test]
799    fn test_set_mode() {
800        let mut ts = TimeSeries::new(5, TimeSeriesMode::OneShot);
801        ts.add_value(1.0);
802        ts.set_mode(TimeSeriesMode::RingBuffer);
803        assert_eq!(ts.mode, TimeSeriesMode::RingBuffer);
804        assert_eq!(ts.count(), 0);
805    }
806
807    // --- TS port driver tests (using a small channel set for simplicity) ---
808
809    const TEST_CHANNELS: [&str; 3] = ["ChA", "ChB", "ChC"];
810
811    #[test]
812    fn test_shared_ts_state_init() {
813        let state = SharedTsState::new(3, 100);
814        assert_eq!(state.buffers.len(), 3);
815        assert_eq!(state.num_points, 100);
816        assert!(!state.acquiring);
817        assert_eq!(state.mode, TimeSeriesMode::OneShot);
818    }
819
820    #[test]
821    fn test_ts_port_driver_create() {
822        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
823        let driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared);
824        assert_eq!(driver.base().port_name, "TEST_TS");
825        assert_eq!(driver.num_channels, 3);
826        assert!(!driver.base().flags.multi_device);
827    }
828
829    #[test]
830    fn test_ts_port_driver_write_acquire() {
831        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
832        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared.clone());
833
834        // Start acquiring
835        let mut user = AsynUser::new(driver.params.ts_acquire);
836        driver.write_int32(&mut user, 1).unwrap();
837        assert!(shared.lock().acquiring);
838
839        // Stop acquiring
840        driver.write_int32(&mut user, 0).unwrap();
841        assert!(!shared.lock().acquiring);
842    }
843
844    #[test]
845    fn test_ts_port_driver_write_num_points() {
846        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
847        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared.clone());
848
849        let mut user = AsynUser::new(driver.params.ts_num_points);
850        driver.write_int32(&mut user, 50).unwrap();
851
852        let state = shared.lock();
853        assert_eq!(state.num_points, 50);
854        for buf in &state.buffers {
855            assert_eq!(buf.num_points, 50);
856        }
857    }
858
859    #[test]
860    fn test_ts_port_driver_write_mode() {
861        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 100)));
862        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 100, shared.clone());
863
864        let mut user = AsynUser::new(driver.params.ts_acquire_mode);
865        driver.write_int32(&mut user, 1).unwrap();
866
867        let state = shared.lock();
868        assert_eq!(state.mode, TimeSeriesMode::RingBuffer);
869        for buf in &state.buffers {
870            assert_eq!(buf.mode, TimeSeriesMode::RingBuffer);
871        }
872    }
873
874    #[test]
875    fn test_ts_port_driver_update_waveforms() {
876        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 10)));
877        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 10, shared.clone());
878
879        // Add some data
880        {
881            let mut state = shared.lock();
882            state.acquiring = true;
883            state.start_time = Some(Instant::now());
884            for buf in state.buffers.iter_mut() {
885                buf.add_value(42.0);
886                buf.add_value(43.0);
887            }
888        }
889
890        // Trigger update
891        driver.update_waveform_params();
892
893        // Check current point was updated
894        let cp = driver
895            .base
896            .get_int32_param(driver.params.ts_current_point, 0)
897            .unwrap();
898        assert_eq!(cp, 2);
899
900        // Check waveform data was written
901        let data = driver
902            .base
903            .params
904            .get_float64_array(driver.params.ts_channels[0], 0)
905            .unwrap();
906        assert_eq!(data[0], 42.0);
907        assert_eq!(data[1], 43.0);
908    }
909
910    #[test]
911    fn test_ts_port_driver_read_array() {
912        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 5)));
913        let mut driver = TimeSeriesPortDriver::new("TEST_TS", &TEST_CHANNELS, 5, shared);
914
915        let user = AsynUser::new(driver.params.ts_time_axis);
916        let mut buf = vec![0.0; 5];
917        let n = driver.read_float64_array(&user, &mut buf).unwrap();
918        assert_eq!(n, 5);
919        assert_eq!(buf, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
920    }
921
922    #[test]
923    fn test_ts_data_ingestion_oneshot() {
924        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 3)));
925        let (tx, rx) = tokio::sync::mpsc::channel(16);
926
927        // Start acquiring
928        shared.lock().acquiring = true;
929
930        let shared_clone = shared.clone();
931        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
932
933        // Send data
934        tx.blocking_send(TimeSeriesData {
935            values: vec![1.0, 10.0, 100.0],
936        })
937        .unwrap();
938        tx.blocking_send(TimeSeriesData {
939            values: vec![2.0, 20.0, 200.0],
940        })
941        .unwrap();
942        tx.blocking_send(TimeSeriesData {
943            values: vec![3.0, 30.0, 300.0],
944        })
945        .unwrap();
946        tx.blocking_send(TimeSeriesData {
947            values: vec![4.0, 40.0, 400.0],
948        })
949        .unwrap(); // beyond capacity
950
951        // Close channel and wait for thread
952        drop(tx);
953        jh.join().unwrap();
954
955        let state = shared.lock();
956        assert_eq!(state.buffers[0].count(), 3);
957        assert_eq!(state.buffers[0].values(), vec![1.0, 2.0, 3.0]);
958        assert_eq!(state.buffers[1].values(), vec![10.0, 20.0, 30.0]);
959        assert_eq!(state.buffers[2].values(), vec![100.0, 200.0, 300.0]);
960        assert!(!state.acquiring); // auto-stopped
961    }
962
963    #[test]
964    fn test_ts_data_ingestion_not_acquiring() {
965        let shared = Arc::new(Mutex::new(SharedTsState::new(3, 10)));
966        let (tx, rx) = tokio::sync::mpsc::channel(16);
967
968        // Not acquiring (default)
969        let shared_clone = shared.clone();
970        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
971
972        tx.blocking_send(TimeSeriesData {
973            values: vec![1.0, 2.0, 3.0],
974        })
975        .unwrap();
976
977        drop(tx);
978        jh.join().unwrap();
979
980        let state = shared.lock();
981        assert_eq!(state.buffers[0].count(), 0);
982    }
983
984    #[test]
985    fn test_num_average_averages_input_samples() {
986        // numAverage = 3: every 3 input samples produce one averaged output
987        // point. Channel A inputs 0,1,2 -> mean 1; 3,4,5 -> mean 4.
988        let mut state = SharedTsState::new(1, 10);
989        state.num_average = 3;
990        assert!(!state.accumulate(&[0.0]));
991        assert!(!state.accumulate(&[1.0]));
992        assert!(state.accumulate(&[2.0])); // emits mean of 0,1,2 = 1
993        assert!(!state.accumulate(&[3.0]));
994        assert!(!state.accumulate(&[4.0]));
995        assert!(state.accumulate(&[5.0])); // emits mean of 3,4,5 = 4
996        let vals = state.buffers[0].values();
997        assert_eq!(vals.len(), 2);
998        assert!((vals[0] - 1.0).abs() < 1e-10);
999        assert!((vals[1] - 4.0).abs() < 1e-10);
1000    }
1001
1002    #[test]
1003    fn test_num_average_one_is_passthrough() {
1004        // numAverage = 1: each input sample is one output point unchanged.
1005        let mut state = SharedTsState::new(2, 10);
1006        state.num_average = 1;
1007        assert!(state.accumulate(&[5.0, 50.0]));
1008        assert!(state.accumulate(&[6.0, 60.0]));
1009        assert_eq!(state.buffers[0].values(), vec![5.0, 6.0]);
1010        assert_eq!(state.buffers[1].values(), vec![50.0, 60.0]);
1011    }
1012
1013    #[test]
1014    fn test_num_average_drives_ingestion_thread() {
1015        // The data thread must average numAverage=2 samples per output point.
1016        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 5)));
1017        {
1018            let mut s = shared.lock();
1019            s.num_average = 2;
1020            s.acquiring = true;
1021        }
1022        let (tx, rx) = tokio::sync::mpsc::channel(16);
1023        let shared_clone = shared.clone();
1024        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1025
1026        for v in [10.0, 20.0, 30.0, 40.0] {
1027            tx.blocking_send(TimeSeriesData { values: vec![v] })
1028                .unwrap();
1029        }
1030        drop(tx);
1031        jh.join().unwrap();
1032
1033        // 4 input samples / numAverage 2 -> 2 output points: 15, 35.
1034        let state = shared.lock();
1035        let vals = state.buffers[0].values();
1036        assert_eq!(vals.len(), 2);
1037        assert!((vals[0] - 15.0).abs() < 1e-10);
1038        assert!((vals[1] - 35.0).abs() < 1e-10);
1039    }
1040
1041    #[test]
1042    fn test_fixed_mode_stops_at_num_points() {
1043        // Fixed (OneShot) mode: acquisition auto-stops once num_points output
1044        // points are collected, even with more input pending.
1045        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 3)));
1046        {
1047            let mut s = shared.lock();
1048            s.num_average = 1;
1049            s.mode = TimeSeriesMode::OneShot;
1050            s.acquiring = true;
1051        }
1052        let (tx, rx) = tokio::sync::mpsc::channel(16);
1053        let shared_clone = shared.clone();
1054        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1055        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
1056            tx.blocking_send(TimeSeriesData { values: vec![v] })
1057                .unwrap();
1058        }
1059        drop(tx);
1060        jh.join().unwrap();
1061
1062        let state = shared.lock();
1063        assert!(!state.acquiring, "Fixed mode must auto-stop");
1064        assert_eq!(state.buffers[0].count(), 3);
1065        assert_eq!(state.buffers[0].values(), vec![1.0, 2.0, 3.0]);
1066    }
1067
1068    #[test]
1069    fn test_circular_mode_wraps_and_keeps_acquiring() {
1070        // Circular (RingBuffer) mode: the buffer wraps; acquisition does not
1071        // auto-stop.
1072        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 3)));
1073        {
1074            let mut s = shared.lock();
1075            s.num_average = 1;
1076            s.mode = TimeSeriesMode::RingBuffer;
1077            for buf in s.buffers.iter_mut() {
1078                buf.set_mode(TimeSeriesMode::RingBuffer);
1079            }
1080            s.acquiring = true;
1081        }
1082        let (tx, rx) = tokio::sync::mpsc::channel(16);
1083        let shared_clone = shared.clone();
1084        let jh = std::thread::spawn(move || ts_data_thread(shared_clone, rx));
1085        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
1086            tx.blocking_send(TimeSeriesData { values: vec![v] })
1087                .unwrap();
1088        }
1089        drop(tx);
1090        jh.join().unwrap();
1091
1092        let state = shared.lock();
1093        assert!(state.acquiring, "Circular mode must keep acquiring");
1094        // Ring buffer of 3 keeps the last 3 points: 3,4,5.
1095        assert_eq!(state.buffers[0].values(), vec![3.0, 4.0, 5.0]);
1096    }
1097
1098    #[test]
1099    fn test_acquire_mode_param_drives_behavior_and_axis() {
1100        // Writing TS_ACQUIRE_MODE must switch the buffer mode AND flip the
1101        // time axis from ascending (Fixed) to signed-ending-at-0 (Circular).
1102        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 4)));
1103        let mut driver = TimeSeriesPortDriver::new("TEST_TS_MODE", &["Ch0"], 4, shared.clone());
1104
1105        // Fixed mode axis: 0, 1, 2, 3.
1106        let axis = driver
1107            .base
1108            .params
1109            .get_float64_array(driver.params.ts_time_axis, 0)
1110            .unwrap();
1111        assert_eq!(&*axis, &[0.0, 1.0, 2.0, 3.0]);
1112
1113        // Switch to Circular mode.
1114        let mut user = AsynUser::new(driver.params.ts_acquire_mode);
1115        driver.write_int32(&mut user, 1).unwrap();
1116        assert_eq!(shared.lock().mode, TimeSeriesMode::RingBuffer);
1117
1118        // Circular axis: -3, -2, -1, 0 (most recent point is t=0).
1119        let axis = driver
1120            .base
1121            .params
1122            .get_float64_array(driver.params.ts_time_axis, 0)
1123            .unwrap();
1124        assert_eq!(&*axis, &[-3.0, -2.0, -1.0, 0.0]);
1125    }
1126
1127    #[test]
1128    fn test_num_average_param_drives_state() {
1129        // Writing TS_NUM_AVERAGE must update SharedTsState::num_average.
1130        let shared = Arc::new(Mutex::new(SharedTsState::new(1, 10)));
1131        let mut driver = TimeSeriesPortDriver::new("TEST_TS_NAVG", &["Ch0"], 10, shared.clone());
1132        let mut user = AsynUser::new(driver.params.ts_num_average);
1133        driver.write_int32(&mut user, 5).unwrap();
1134        assert_eq!(shared.lock().num_average, 5);
1135        // A value of 0 is clamped to 1.
1136        driver.write_int32(&mut user, 0).unwrap();
1137        assert_eq!(shared.lock().num_average, 1);
1138    }
1139
1140    #[test]
1141    fn test_create_ts_port_runtime() {
1142        let (_tx, rx) = tokio::sync::mpsc::channel(16);
1143        let (handle, params, _actor_jh, _data_jh) =
1144            create_ts_port_runtime("TEST_TS_RT", &TEST_CHANNELS, 100, rx);
1145        assert_eq!(handle.port_name(), "TEST_TS_RT");
1146        assert_eq!(params.ts_channels.len(), 3);
1147        handle.shutdown();
1148    }
1149}