Skip to main content

ad_plugins/
attr_plot.rs

1//! NDPluginAttrPlot: tracks numeric attribute values over time in circular buffers.
2//!
3//! On the first frame, the plugin scans the array's attribute list and auto-detects
4//! all numeric attributes (those where `as_f64()` returns `Some`). The attribute names
5//! are sorted alphabetically for deterministic ordering. On subsequent frames, each
6//! tracked attribute's value is pushed into a per-attribute circular buffer (VecDeque).
7//!
8//! If the array's `unique_id` decreases relative to the previous frame, all buffers
9//! are reset (indicating a new acquisition).
10
11use std::collections::VecDeque;
12
13use ad_core::ndarray::NDArray;
14use ad_core::ndarray_pool::NDArrayPool;
15use ad_core::plugin::runtime::{NDPluginProcess, ProcessResult};
16
17/// Processor that tracks attribute values over time in circular buffers.
18pub struct AttrPlotProcessor {
19    /// Tracked attribute names (sorted alphabetically).
20    attributes: Vec<String>,
21    /// Per-attribute circular buffer of values.
22    buffers: Vec<VecDeque<f64>>,
23    /// Circular buffer of unique_id values.
24    uid_buffer: VecDeque<f64>,
25    /// Maximum number of points per buffer. 0 = unlimited.
26    max_points: usize,
27    /// Whether we have initialized from the first frame.
28    initialized: bool,
29    /// The unique_id from the last processed frame.
30    last_uid: i32,
31}
32
33impl AttrPlotProcessor {
34    /// Create a new processor with the given maximum buffer size.
35    pub fn new(max_points: usize) -> Self {
36        Self {
37            attributes: Vec::new(),
38            buffers: Vec::new(),
39            uid_buffer: VecDeque::new(),
40            max_points,
41            initialized: false,
42            last_uid: -1,
43        }
44    }
45
46    /// Get the list of tracked attribute names.
47    pub fn attributes(&self) -> &[String] {
48        &self.attributes
49    }
50
51    /// Get the circular buffer for a specific attribute index.
52    pub fn buffer(&self, index: usize) -> Option<&VecDeque<f64>> {
53        self.buffers.get(index)
54    }
55
56    /// Get the unique_id buffer.
57    pub fn uid_buffer(&self) -> &VecDeque<f64> {
58        &self.uid_buffer
59    }
60
61    /// Get the number of tracked attributes.
62    pub fn num_attributes(&self) -> usize {
63        self.attributes.len()
64    }
65
66    /// Find the index of a named attribute. Returns None if not tracked.
67    pub fn find_attribute(&self, name: &str) -> Option<usize> {
68        self.attributes.iter().position(|n| n == name)
69    }
70
71    /// Reset all buffers and re-initialize on the next frame.
72    pub fn reset(&mut self) {
73        self.attributes.clear();
74        self.buffers.clear();
75        self.uid_buffer.clear();
76        self.initialized = false;
77        self.last_uid = -1;
78    }
79
80    /// Push a value to a VecDeque, enforcing max_points as a ring buffer.
81    fn push_capped(buf: &mut VecDeque<f64>, value: f64, max_points: usize) {
82        if max_points > 0 && buf.len() >= max_points {
83            buf.pop_front();
84        }
85        buf.push_back(value);
86    }
87
88    /// Initialize tracked attributes from the first frame.
89    fn initialize_from_array(&mut self, array: &NDArray) {
90        let mut names: Vec<String> = Vec::new();
91        for attr in array.attributes.iter() {
92            if attr.value.as_f64().is_some() {
93                names.push(attr.name.clone());
94            }
95        }
96        names.sort();
97
98        self.buffers = vec![VecDeque::new(); names.len()];
99        self.attributes = names;
100        self.uid_buffer.clear();
101        self.initialized = true;
102    }
103
104    /// Clear all data buffers but keep tracked attributes.
105    fn clear_buffers(&mut self) {
106        for buf in &mut self.buffers {
107            buf.clear();
108        }
109        self.uid_buffer.clear();
110    }
111}
112
113impl NDPluginProcess for AttrPlotProcessor {
114    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
115        // Detect UID decrease (re-acquisition)
116        if self.initialized && array.unique_id < self.last_uid {
117            self.clear_buffers();
118        }
119        self.last_uid = array.unique_id;
120
121        // Initialize on first frame
122        if !self.initialized {
123            self.initialize_from_array(array);
124        }
125
126        // Push unique_id
127        Self::push_capped(&mut self.uid_buffer, array.unique_id as f64, self.max_points);
128
129        // Push each tracked attribute's value
130        for (i, name) in self.attributes.iter().enumerate() {
131            let value = array.attributes.get(name)
132                .and_then(|attr| attr.value.as_f64())
133                .unwrap_or(0.0);
134            Self::push_capped(&mut self.buffers[i], value, self.max_points);
135        }
136
137        ProcessResult::sink(vec![])
138    }
139
140    fn plugin_type(&self) -> &str {
141        "NDPluginAttrPlot"
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use ad_core::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
149    use ad_core::ndarray::{NDDataType, NDDimension};
150
151    fn make_array_with_attrs(uid: i32, attrs: &[(&str, f64)]) -> NDArray {
152        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
153        arr.unique_id = uid;
154        for (name, value) in attrs {
155            arr.attributes.add(NDAttribute {
156                name: name.to_string(),
157                description: String::new(),
158                source: NDAttrSource::Driver,
159                value: NDAttrValue::Float64(*value),
160            });
161        }
162        arr
163    }
164
165    #[test]
166    fn test_attribute_auto_detection() {
167        let mut proc = AttrPlotProcessor::new(100);
168        let pool = NDArrayPool::new(1_000_000);
169
170        let mut arr = make_array_with_attrs(1, &[("Temp", 25.0), ("Gain", 1.5)]);
171        // Add a string attribute that should be excluded
172        arr.attributes.add(NDAttribute {
173            name: "Label".to_string(),
174            description: String::new(),
175            source: NDAttrSource::Driver,
176            value: NDAttrValue::String("test".to_string()),
177        });
178
179        proc.process_array(&arr, &pool);
180
181        // Should detect 2 numeric attributes, sorted
182        assert_eq!(proc.num_attributes(), 2);
183        assert_eq!(proc.attributes()[0], "Gain");
184        assert_eq!(proc.attributes()[1], "Temp");
185    }
186
187    #[test]
188    fn test_value_tracking() {
189        let mut proc = AttrPlotProcessor::new(100);
190        let pool = NDArrayPool::new(1_000_000);
191
192        for i in 0..5 {
193            let arr = make_array_with_attrs(i, &[("Value", i as f64 * 10.0)]);
194            proc.process_array(&arr, &pool);
195        }
196
197        let idx = proc.find_attribute("Value").unwrap();
198        let buf = proc.buffer(idx).unwrap();
199        assert_eq!(buf.len(), 5);
200        assert!((buf[0] - 0.0).abs() < 1e-10);
201        assert!((buf[4] - 40.0).abs() < 1e-10);
202    }
203
204    #[test]
205    fn test_uid_buffer() {
206        let mut proc = AttrPlotProcessor::new(100);
207        let pool = NDArrayPool::new(1_000_000);
208
209        for i in 1..=3 {
210            let arr = make_array_with_attrs(i, &[("X", 1.0)]);
211            proc.process_array(&arr, &pool);
212        }
213
214        let uid_buf = proc.uid_buffer();
215        assert_eq!(uid_buf.len(), 3);
216        assert!((uid_buf[0] - 1.0).abs() < 1e-10);
217        assert!((uid_buf[1] - 2.0).abs() < 1e-10);
218        assert!((uid_buf[2] - 3.0).abs() < 1e-10);
219    }
220
221    #[test]
222    fn test_circular_buffer_max_points() {
223        let mut proc = AttrPlotProcessor::new(3);
224        let pool = NDArrayPool::new(1_000_000);
225
226        for i in 0..5 {
227            let arr = make_array_with_attrs(i, &[("Val", i as f64)]);
228            proc.process_array(&arr, &pool);
229        }
230
231        let idx = proc.find_attribute("Val").unwrap();
232        let buf = proc.buffer(idx).unwrap();
233        // Only last 3 values should remain
234        assert_eq!(buf.len(), 3);
235        assert!((buf[0] - 2.0).abs() < 1e-10);
236        assert!((buf[1] - 3.0).abs() < 1e-10);
237        assert!((buf[2] - 4.0).abs() < 1e-10);
238
239        // UID buffer also limited
240        assert_eq!(proc.uid_buffer().len(), 3);
241    }
242
243    #[test]
244    fn test_uid_decrease_resets_buffers() {
245        let mut proc = AttrPlotProcessor::new(100);
246        let pool = NDArrayPool::new(1_000_000);
247
248        // First acquisition
249        for i in 1..=5 {
250            let arr = make_array_with_attrs(i, &[("X", i as f64)]);
251            proc.process_array(&arr, &pool);
252        }
253
254        let idx = proc.find_attribute("X").unwrap();
255        assert_eq!(proc.buffer(idx).unwrap().len(), 5);
256
257        // New acquisition: UID resets to 1
258        let arr = make_array_with_attrs(1, &[("X", 100.0)]);
259        proc.process_array(&arr, &pool);
260
261        // Buffers should be cleared and have just the new point
262        let buf = proc.buffer(idx).unwrap();
263        assert_eq!(buf.len(), 1);
264        assert!((buf[0] - 100.0).abs() < 1e-10);
265    }
266
267    #[test]
268    fn test_missing_attribute_uses_zero() {
269        let mut proc = AttrPlotProcessor::new(100);
270        let pool = NDArrayPool::new(1_000_000);
271
272        // Frame 1: has attribute
273        let arr1 = make_array_with_attrs(1, &[("Temp", 25.0)]);
274        proc.process_array(&arr1, &pool);
275
276        // Frame 2: attribute missing
277        let arr2 = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
278        let mut arr2 = arr2;
279        arr2.unique_id = 2;
280        proc.process_array(&arr2, &pool);
281
282        let idx = proc.find_attribute("Temp").unwrap();
283        let buf = proc.buffer(idx).unwrap();
284        assert_eq!(buf.len(), 2);
285        assert!((buf[0] - 25.0).abs() < 1e-10);
286        assert!((buf[1] - 0.0).abs() < 1e-10);
287    }
288
289    #[test]
290    fn test_manual_reset() {
291        let mut proc = AttrPlotProcessor::new(100);
292        let pool = NDArrayPool::new(1_000_000);
293
294        let arr = make_array_with_attrs(1, &[("A", 1.0), ("B", 2.0)]);
295        proc.process_array(&arr, &pool);
296        assert_eq!(proc.num_attributes(), 2);
297
298        proc.reset();
299        assert_eq!(proc.num_attributes(), 0);
300        assert!(proc.uid_buffer().is_empty());
301
302        // Re-initializes from next frame
303        let arr2 = make_array_with_attrs(1, &[("C", 3.0)]);
304        proc.process_array(&arr2, &pool);
305        assert_eq!(proc.num_attributes(), 1);
306        assert_eq!(proc.attributes()[0], "C");
307    }
308
309    #[test]
310    fn test_unlimited_buffer() {
311        let mut proc = AttrPlotProcessor::new(0);
312        let pool = NDArrayPool::new(1_000_000);
313
314        for i in 0..100 {
315            let arr = make_array_with_attrs(i, &[("X", i as f64)]);
316            proc.process_array(&arr, &pool);
317        }
318
319        let idx = proc.find_attribute("X").unwrap();
320        assert_eq!(proc.buffer(idx).unwrap().len(), 100);
321    }
322
323    #[test]
324    fn test_multiple_attributes_sorted() {
325        let mut proc = AttrPlotProcessor::new(100);
326        let pool = NDArrayPool::new(1_000_000);
327
328        let arr = make_array_with_attrs(1, &[("Zebra", 1.0), ("Alpha", 2.0), ("Mid", 3.0)]);
329        proc.process_array(&arr, &pool);
330
331        assert_eq!(proc.attributes(), &["Alpha", "Mid", "Zebra"]);
332    }
333
334    #[test]
335    fn test_find_attribute() {
336        let mut proc = AttrPlotProcessor::new(100);
337        let pool = NDArrayPool::new(1_000_000);
338
339        let arr = make_array_with_attrs(1, &[("X", 1.0), ("Y", 2.0)]);
340        proc.process_array(&arr, &pool);
341
342        assert_eq!(proc.find_attribute("X"), Some(0));
343        assert_eq!(proc.find_attribute("Y"), Some(1));
344        assert_eq!(proc.find_attribute("Z"), None);
345    }
346
347    #[test]
348    fn test_plugin_type() {
349        let proc = AttrPlotProcessor::new(100);
350        assert_eq!(proc.plugin_type(), "NDPluginAttrPlot");
351    }
352}