Skip to main content

ad_plugins_rs/
attr_plot.rs

1//! NDPluginAttrPlot: caches numeric NDArray attribute values over an
2//! acquisition and exposes selected ones as waveform records.
3//!
4//! Port of ADCore `NDPluginAttrPlot`. The C++ model separates two counts:
5//!
6//! * `n_attributes` — the maximum number of *tracked* numeric attributes.
7//!   Attribute names are discovered from the first frame of an acquisition
8//!   (and re-discovered after a reset), sorted, and capped to `n_attributes`.
9//!   One circular buffer per tracked attribute.
10//! * `n_data_blocks` — the number of *waveform outputs* (asyn addresses).
11//!   Each data block has an independent `DataSelect` value that maps it to a
12//!   tracked attribute index, or to the special UID buffer (`-1`), or to
13//!   nothing (`-2`).
14//!
15//! `DataLabel` is the human-readable name of the attribute a block is bound
16//! to; `NPts` is the current number of cached points. The waveform emitted
17//! for a block is padded out to `cache_size` with the last valid point to
18//! avoid plot artifacts (C++ `callback_data`).
19//!
20//! The two callback paths are separate in C and separate here. `AP_NPts` and
21//! the attribute/label/selection metadata are posted from the frame path
22//! (C++ `processCallbacks`, `NDPluginAttrPlot.cpp:126-146`); `AP_Data` is
23//! posted only from `callback_data` (`:96-124`), which C++ drives from
24//! `ExposeDataTask::run` once per `ND_ATTRPLOT_DATA_EXPOSURE_PERIOD` (1 s,
25//! `NDPluginAttrPlot.h:39`) and once per `DataSelect` write (`:283-289`).
26//! Posting the waveform per frame would make a `camonitor` on `AP_Data` fire
27//! at the detector frame rate.
28
29use std::collections::VecDeque;
30
31/// C++ `ND_ATTRPLOT_DATA_EXPOSURE_PERIOD` (`NDPluginAttrPlot.h:39`) — the
32/// period of `ExposeDataTask::run`'s `AP_Data` post.
33const ATTRPLOT_DATA_EXPOSURE_PERIOD: std::time::Duration = std::time::Duration::from_secs(1);
34
35use ad_core_rs::ndarray::NDArray;
36use ad_core_rs::ndarray_pool::NDArrayPool;
37use ad_core_rs::plugin::runtime::{
38    NDPluginProcess, ParamChangeResult, ParamUpdate, PluginParamSnapshot, ProcessResult,
39};
40use parking_lot::Mutex;
41
42/// `DataSelect` value meaning "this block plots the UID buffer".
43pub const ATTRPLOT_UID_INDEX: i32 = -1;
44/// `DataSelect` value meaning "this block plots nothing".
45pub const ATTRPLOT_NONE_INDEX: i32 = -2;
46/// `DataLabel` text for the UID buffer.
47pub const ATTRPLOT_UID_LABEL: &str = "UID";
48/// `DataLabel` text for an unbound block.
49pub const ATTRPLOT_NONE_LABEL: &str = "None";
50
51/// Processor that tracks attribute values over time in circular buffers.
52pub struct AttrPlotProcessor {
53    state: Mutex<AttrPlotState>,
54}
55
56/// Everything one frame mutates. A frame resets, rebuilds, pushes and posts in
57/// one pass, so the whole set moves under a single lock.
58struct AttrPlotState {
59    /// Maximum number of tracked attributes (C++ `n_attributes_`).
60    n_attributes: usize,
61    /// Number of waveform output blocks (C++ `n_data_blocks_`).
62    n_data_blocks: usize,
63    /// Cache size per buffer; `0` means unlimited.
64    cache_size: usize,
65    /// Tracked attribute names (sorted, length <= `n_attributes`).
66    attributes: Vec<String>,
67    /// One circular buffer per tracked attribute.
68    buffers: Vec<VecDeque<f64>>,
69    /// Circular buffer of unique_id values.
70    uid_buffer: VecDeque<f64>,
71    /// Per-block attribute selection: index into `attributes`, or one of the
72    /// `ATTRPLOT_UID_INDEX` / `ATTRPLOT_NONE_INDEX` sentinels.
73    data_selections: Vec<i32>,
74    /// Whether attributes have been discovered for the current acquisition.
75    initialized: bool,
76    /// The unique_id from the last processed frame.
77    last_uid: i32,
78    /// When `AP_Data` was last posted. `None` until the first exposure, which
79    /// is why the first frame of an acquisition always exposes.
80    last_expose: Option<std::time::Instant>,
81    /// Param indices (set after registration).
82    params: AttrPlotParams,
83}
84
85/// Param reasons resolved after `register_params`.
86#[derive(Default)]
87struct AttrPlotParams {
88    /// `AP_Data` — Float64Array, addressed by data block.
89    data: Option<usize>,
90    /// `AP_DataLabel` — Octet, addressed by data block.
91    data_label: Option<usize>,
92    /// `AP_DataSelect` — Int32, addressed by data block.
93    data_select: Option<usize>,
94    /// `AP_Attribute` — Octet, addressed by attribute index.
95    attribute: Option<usize>,
96    /// `AP_Reset` — Int32.
97    reset: Option<usize>,
98    /// `AP_NPts` — Int32.
99    npts: Option<usize>,
100}
101
102impl AttrPlotProcessor {
103    /// Create a processor.
104    ///
105    /// * `n_attributes` — maximum tracked attributes.
106    /// * `cache_size` — per-buffer cache size (`0` = unlimited).
107    /// * `n_data_blocks` — number of waveform output blocks.
108    pub fn new(n_attributes: usize, cache_size: usize, n_data_blocks: usize) -> Self {
109        Self {
110            state: Mutex::new(AttrPlotState::new(n_attributes, cache_size, n_data_blocks)),
111        }
112    }
113
114    /// Get the list of tracked attribute names.
115    pub fn attributes(&self) -> Vec<String> {
116        self.state.lock().attributes.clone()
117    }
118
119    /// Get the circular buffer for a specific attribute index.
120    pub fn buffer(&self, index: usize) -> Option<VecDeque<f64>> {
121        self.state.lock().buffers.get(index).cloned()
122    }
123
124    /// Get the unique_id buffer.
125    pub fn uid_buffer(&self) -> VecDeque<f64> {
126        self.state.lock().uid_buffer.clone()
127    }
128
129    /// Get the number of tracked attributes.
130    pub fn num_attributes(&self) -> usize {
131        self.state.lock().attributes.len()
132    }
133
134    /// Get the number of waveform output blocks.
135    pub fn num_data_blocks(&self) -> usize {
136        self.state.lock().n_data_blocks
137    }
138
139    /// Find the index of a named attribute. Returns `None` if not tracked.
140    pub fn find_attribute(&self, name: &str) -> Option<usize> {
141        self.state.lock().find_attribute(name)
142    }
143
144    /// Current `DataSelect` value for a block.
145    pub fn data_select(&self, block: usize) -> Option<i32> {
146        self.state.lock().data_select(block)
147    }
148
149    /// Bind a data block to an attribute index (or a UID/NONE sentinel).
150    pub fn set_data_select(&self, block: usize, value: i32) -> Result<(), &'static str> {
151        self.state.lock().set_data_select(block, value)
152    }
153
154    /// The `DataLabel` text for a block, derived from its `DataSelect`.
155    pub fn data_label(&self, block: usize) -> String {
156        self.state.lock().data_label(block)
157    }
158
159    /// Reset all buffers; the next frame re-discovers attributes.
160    pub fn reset(&self) {
161        self.state.lock().reset();
162    }
163}
164
165impl AttrPlotState {
166    fn new(n_attributes: usize, cache_size: usize, n_data_blocks: usize) -> Self {
167        Self {
168            n_attributes,
169            n_data_blocks,
170            cache_size,
171            attributes: Vec::new(),
172            buffers: Vec::new(),
173            uid_buffer: VecDeque::new(),
174            data_selections: vec![ATTRPLOT_NONE_INDEX; n_data_blocks],
175            initialized: false,
176            last_uid: -1,
177            last_expose: None,
178            params: AttrPlotParams::default(),
179        }
180    }
181
182    /// Find the index of a named attribute. Returns `None` if not tracked.
183    fn find_attribute(&self, name: &str) -> Option<usize> {
184        self.attributes.iter().position(|n| n == name)
185    }
186
187    /// Current `DataSelect` value for a block.
188    fn data_select(&self, block: usize) -> Option<i32> {
189        self.data_selections.get(block).copied()
190    }
191
192    /// Bind a data block to an attribute index (or a UID/NONE sentinel).
193    ///
194    /// Mirrors C++ `writeInt32(NDAttrPlotDataSelect)`: rejects out-of-range
195    /// blocks and selections that point past the tracked attributes.
196    fn set_data_select(&mut self, block: usize, value: i32) -> Result<(), &'static str> {
197        if block >= self.n_data_blocks {
198            return Err("data block index out of range");
199        }
200        // C rejects only a strictly positive selection past the end; value 0 is
201        // always accepted, even with no tracked attributes (NDPluginAttrPlot.cpp:283).
202        if value > 0 && (value as usize) >= self.attributes.len() {
203            return Err("attribute selection out of range");
204        }
205        self.data_selections[block] = value;
206        Ok(())
207    }
208
209    /// The `DataLabel` text for a block, derived from its `DataSelect`.
210    fn data_label(&self, block: usize) -> String {
211        match self.data_selections.get(block).copied() {
212            Some(ATTRPLOT_UID_INDEX) => ATTRPLOT_UID_LABEL.to_string(),
213            Some(sel) if sel >= 0 && (sel as usize) < self.attributes.len() => {
214                self.attributes[sel as usize].clone()
215            }
216            _ => ATTRPLOT_NONE_LABEL.to_string(),
217        }
218    }
219
220    /// Reset all buffers; the next frame re-discovers attributes.
221    fn reset(&mut self) {
222        self.initialized = false;
223        self.uid_buffer.clear();
224        for buf in &mut self.buffers {
225            buf.clear();
226        }
227        self.last_uid = -1;
228    }
229
230    /// Push a value into a ring buffer, enforcing `cache_size`.
231    fn push_capped(buf: &mut VecDeque<f64>, value: f64, cache_size: usize) {
232        if cache_size > 0 && buf.len() >= cache_size {
233            buf.pop_front();
234        }
235        buf.push_back(value);
236    }
237
238    /// Discover the tracked attributes from a frame (C++ `rebuild_attributes`).
239    ///
240    /// Existing block selections are preserved by attribute *name*: a block
241    /// that pointed at "Temp" before the rebuild still points at "Temp"
242    /// afterwards (or `NONE` if "Temp" is no longer present).
243    fn rebuild_attributes(&mut self, array: &NDArray) {
244        // Remember each block's current selection by name.
245        let prior: Vec<Option<String>> = self
246            .data_selections
247            .iter()
248            .map(|&sel| match sel {
249                ATTRPLOT_UID_INDEX => Some(ATTRPLOT_UID_LABEL.to_string()),
250                s if s >= 0 && (s as usize) < self.attributes.len() => {
251                    Some(self.attributes[s as usize].clone())
252                }
253                _ => None,
254            })
255            .collect();
256
257        let mut names: Vec<String> = Vec::new();
258        for attr in array.attributes.iter() {
259            if attr.value.as_f64().is_some() {
260                names.push(attr.name.clone());
261            }
262        }
263        names.sort();
264        names.truncate(self.n_attributes);
265
266        self.buffers = vec![VecDeque::new(); names.len()];
267        self.attributes = names;
268
269        // Re-resolve each block selection against the new attribute list.
270        for (i, want) in prior.into_iter().enumerate() {
271            self.data_selections[i] = match want {
272                Some(ref n) if n == ATTRPLOT_UID_LABEL => ATTRPLOT_UID_INDEX,
273                Some(n) => self
274                    .attributes
275                    .iter()
276                    .position(|a| a == &n)
277                    .map(|p| p as i32)
278                    .unwrap_or(ATTRPLOT_NONE_INDEX),
279                None => ATTRPLOT_NONE_INDEX,
280            };
281        }
282        self.initialized = true;
283    }
284
285    /// Push the current frame's attribute values into the buffers.
286    fn push_data(&mut self, array: &NDArray) {
287        Self::push_capped(
288            &mut self.uid_buffer,
289            array.unique_id as f64,
290            self.cache_size,
291        );
292        for (i, name) in self.attributes.iter().enumerate() {
293            let value = array
294                .attributes
295                .get(name)
296                .and_then(|attr| attr.value.as_f64())
297                .unwrap_or(f64::NAN);
298            Self::push_capped(&mut self.buffers[i], value, self.cache_size);
299        }
300    }
301
302    /// Build the padded waveform for one data block (C++ `callback_data`).
303    ///
304    /// Returns the values padded to `cache_size` (or to the current point
305    /// count when `cache_size` is unlimited) with the last valid point.
306    fn block_waveform(&self, block: usize) -> Vec<f64> {
307        let selected = self
308            .data_selections
309            .get(block)
310            .copied()
311            .unwrap_or(ATTRPLOT_NONE_INDEX);
312        let src: Option<&VecDeque<f64>> = match selected {
313            ATTRPLOT_UID_INDEX => Some(&self.uid_buffer),
314            s if s >= 0 && (s as usize) < self.buffers.len() => Some(&self.buffers[s as usize]),
315            _ => None,
316        };
317        let size = self.uid_buffer.len();
318        // Target length: the fixed cache size, or the live count if unlimited.
319        let target = if self.cache_size > 0 {
320            self.cache_size
321        } else {
322            size
323        };
324        let mut out: Vec<f64> = match src {
325            Some(buf) => buf.iter().copied().collect(),
326            None => vec![f64::NAN; size],
327        };
328        // Pad the tail with the last valid point to suppress plot artifacts.
329        let pad = out.last().copied().unwrap_or(f64::NAN);
330        if out.len() < target {
331            out.resize(target, pad);
332        } else {
333            out.truncate(target);
334        }
335        out
336    }
337
338    /// The `AP_Data` waveforms — C++ `callback_data`
339    /// (`NDPluginAttrPlot.cpp:96-124`). Posted on the exposure period and on a
340    /// `DataSelect` write, never per frame.
341    fn data_updates(&self) -> Vec<ParamUpdate> {
342        let mut updates = Vec::new();
343        if let Some(data) = self.params.data {
344            for block in 0..self.n_data_blocks {
345                updates.push(ParamUpdate::float64_array_addr(
346                    data,
347                    block as i32,
348                    self.block_waveform(block),
349                ));
350            }
351        }
352        updates
353    }
354
355    /// Whether the exposure period has elapsed, arming the next one when it
356    /// has. C++ posts from a 1 Hz thread; without a periodic hook in the
357    /// plugin runtime the frame path carries the clock, so the cadence is
358    /// "at most once per period" rather than "once per period even with no
359    /// frames".
360    fn expose_due(&mut self) -> bool {
361        let now = std::time::Instant::now();
362        let due = match self.last_expose {
363            None => true,
364            Some(prev) => now.duration_since(prev) >= ATTRPLOT_DATA_EXPOSURE_PERIOD,
365        };
366        if due {
367            self.last_expose = Some(now);
368        }
369        due
370    }
371
372    /// The metadata + point-count updates — C++ `callback_attributes`,
373    /// `callback_selected` and the `NDAttrPlotNPts` write in
374    /// `processCallbacks`.
375    fn meta_updates(&self) -> Vec<ParamUpdate> {
376        let mut updates = Vec::new();
377        if let Some(label) = self.params.data_label {
378            for block in 0..self.n_data_blocks {
379                updates.push(ParamUpdate::octet_addr(
380                    label,
381                    block as i32,
382                    self.data_label(block),
383                ));
384            }
385        }
386        if let Some(select) = self.params.data_select {
387            for block in 0..self.n_data_blocks {
388                updates.push(ParamUpdate::int32_addr(
389                    select,
390                    block as i32,
391                    self.data_selections[block],
392                ));
393            }
394        }
395        // Per-attribute name.
396        if let Some(attribute) = self.params.attribute {
397            for i in 0..self.n_attributes {
398                let name = self.attributes.get(i).cloned().unwrap_or_default();
399                updates.push(ParamUpdate::octet_addr(attribute, i as i32, name));
400            }
401        }
402        if let Some(npts) = self.params.npts {
403            updates.push(ParamUpdate::int32(npts, self.uid_buffer.len() as i32));
404        }
405        updates
406    }
407
408    /// Metadata plus an unconditional `AP_Data` post — the shape C++ produces
409    /// for a `DataSelect` write (`callback_selected` then `callback_data`,
410    /// `NDPluginAttrPlot.cpp:286-287`). Re-arms the exposure clock so the
411    /// forced post also counts as this period's exposure.
412    fn build_updates(&mut self) -> Vec<ParamUpdate> {
413        self.last_expose = Some(std::time::Instant::now());
414        let mut updates = self.data_updates();
415        updates.extend(self.meta_updates());
416        updates
417    }
418}
419
420impl NDPluginProcess for AttrPlotProcessor {
421    fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
422        let mut state = self.state.lock();
423
424        // Re-acquisition: a UID at or below the last cached one resets.
425        if !state.uid_buffer.is_empty() && array.unique_id <= state.last_uid {
426            state.reset();
427        }
428        state.last_uid = array.unique_id;
429
430        if !state.initialized {
431            state.rebuild_attributes(array);
432        }
433        state.push_data(array);
434
435        // C++ `processCallbacks` posts NPts and the metadata; `AP_Data` comes
436        // from the exposure path only (ADP-53).
437        let mut updates = state.meta_updates();
438        if state.expose_due() {
439            updates.extend(state.data_updates());
440        }
441        ProcessResult::sink(updates)
442    }
443
444    fn plugin_type(&self) -> &str {
445        // C sets PluginType to "NDAttrPlot" (NDPluginAttrPlot.cpp:87), not the
446        // class name.
447        "NDAttrPlot"
448    }
449
450    fn register_params(
451        &mut self,
452        base: &mut asyn_rs::port::PortDriverBase,
453    ) -> asyn_rs::error::AsynResult<()> {
454        use asyn_rs::param::ParamType;
455        let state = self.state.get_mut();
456        base.create_param("AP_Data", ParamType::Float64Array)?;
457        base.create_param("AP_DataLabel", ParamType::Octet)?;
458        base.create_param("AP_DataSelect", ParamType::Int32)?;
459        base.create_param("AP_Attribute", ParamType::Octet)?;
460        base.create_param("AP_Reset", ParamType::Int32)?;
461        base.create_param("AP_NPts", ParamType::Int32)?;
462
463        state.params.data = base.find_param("AP_Data");
464        state.params.data_label = base.find_param("AP_DataLabel");
465        state.params.data_select = base.find_param("AP_DataSelect");
466        state.params.attribute = base.find_param("AP_Attribute");
467        state.params.reset = base.find_param("AP_Reset");
468        state.params.npts = base.find_param("AP_NPts");
469        Ok(())
470    }
471
472    fn on_param_change(&self, reason: usize, params: &PluginParamSnapshot) -> ParamChangeResult {
473        let mut state = self.state.lock();
474        if Some(reason) == state.params.data_select {
475            let block = params.addr as usize;
476            let value = params.value.as_i32();
477            if state.set_data_select(block, value).is_ok() {
478                // Re-emit label + waveform for the rebound block.
479                return ParamChangeResult::updates(state.build_updates());
480            }
481        } else if Some(reason) == state.params.reset {
482            // C calls reset_data() on ANY write to the reset param — there is no
483            // value test (NDPluginAttrPlot.cpp:290-292).
484            state.reset();
485            return ParamChangeResult::updates(state.build_updates());
486        }
487        ParamChangeResult::updates(vec![])
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
495    use ad_core_rs::ndarray::{NDDataType, NDDimension};
496
497    fn make_array_with_attrs(uid: i32, attrs: &[(&str, f64)]) -> NDArray {
498        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
499        arr.unique_id = uid;
500        for (name, value) in attrs {
501            arr.attributes.add(NDAttribute::new_static(
502                *name,
503                String::new(),
504                NDAttrSource::Driver,
505                NDAttrValue::Float64(*value),
506            ));
507        }
508        arr
509    }
510
511    /// Register the AttrPlot params on a scratch port so `params.data` is
512    /// resolved and `AP_Data` updates are actually built.
513    fn proc_with_params(n_attributes: usize, cache: usize, blocks: usize) -> AttrPlotProcessor {
514        let mut proc = AttrPlotProcessor::new(n_attributes, cache, blocks);
515        let mut base = asyn_rs::port::PortDriverBase::new(
516            "ATTRPLOT_ADP53",
517            blocks.max(n_attributes),
518            asyn_rs::port::PortFlags::default(),
519        );
520        proc.register_params(&mut base).unwrap();
521        proc
522    }
523
524    fn has_data_update(result: &ProcessResult, reason: usize) -> bool {
525        result
526            .param_updates
527            .iter()
528            .any(|u| matches!(u, ParamUpdate::Float64Array { reason: r, .. } if *r == reason))
529    }
530
531    /// ADP-53: `AP_Data` is posted on the exposure period, not per frame.
532    /// C++ drives `callback_data` from `ExposeDataTask::run` every
533    /// `ND_ATTRPLOT_DATA_EXPOSURE_PERIOD`; `processCallbacks` posts only
534    /// `NDAttrPlotNPts` (`NDPluginAttrPlot.cpp:126-146`). Three frames
535    /// processed back to back fall inside one period, so exactly one of them
536    /// may carry the waveform.
537    #[test]
538    fn test_adp53_ap_data_is_rate_limited_to_the_exposure_period() {
539        let proc = proc_with_params(8, 100, 4);
540        let data_reason = proc.state.lock().params.data.expect("AP_Data registered");
541        let npts_reason = proc.state.lock().params.npts.expect("AP_NPts registered");
542        let pool = NDArrayPool::new(1_000_000);
543
544        let mut exposures = 0;
545        for uid in 1..=3 {
546            let arr = make_array_with_attrs(uid, &[("Temp", 25.0 + uid as f64)]);
547            let result = proc.process_array(&arr, &pool);
548            if has_data_update(&result, data_reason) {
549                exposures += 1;
550            }
551            // NPts is the frame-path post and must be on every frame, as
552            // C++ `processCallbacks` writes it every call.
553            assert!(
554                result.param_updates.iter().any(|u| matches!(
555                    u,
556                    ParamUpdate::Int32 { reason: r, .. } if *r == npts_reason
557                )),
558                "frame {uid} must still post AP_NPts",
559            );
560        }
561        assert_eq!(
562            exposures, 1,
563            "three frames inside one exposure period must post AP_Data once",
564        );
565    }
566
567    /// ADP-53: a `DataSelect` write posts `AP_Data` regardless of the clock —
568    /// C++ `writeInt32` calls `callback_selected()` then `callback_data()`
569    /// (`NDPluginAttrPlot.cpp:283-289`).
570    #[test]
571    fn test_adp53_data_select_write_exposes_immediately() {
572        let proc = proc_with_params(8, 100, 4);
573        let data_reason = proc.state.lock().params.data.expect("AP_Data registered");
574        let select_reason = proc
575            .state
576            .lock()
577            .params
578            .data_select
579            .expect("AP_DataSelect registered");
580        let pool = NDArrayPool::new(1_000_000);
581
582        let arr = make_array_with_attrs(1, &[("Temp", 25.0)]);
583        proc.process_array(&arr, &pool); // consumes this period's exposure
584
585        let snapshot = PluginParamSnapshot {
586            enable_callbacks: true,
587            reason: select_reason,
588            addr: 0,
589            value: ad_core_rs::plugin::runtime::ParamChangeValue::Int32(0),
590        };
591        let result = proc.on_param_change(select_reason, &snapshot);
592        assert!(
593            result.param_updates.iter().any(|u| {
594                matches!(u, ParamUpdate::Float64Array { reason: r, .. } if *r == data_reason)
595            }),
596            "a DataSelect write must post AP_Data even inside the period",
597        );
598    }
599
600    #[test]
601    fn test_attribute_auto_detection() {
602        let proc = AttrPlotProcessor::new(8, 100, 4);
603        let pool = NDArrayPool::new(1_000_000);
604
605        let mut arr = make_array_with_attrs(1, &[("Temp", 25.0), ("Gain", 1.5)]);
606        arr.attributes.add(NDAttribute::new_static(
607            "Label",
608            String::new(),
609            NDAttrSource::Driver,
610            NDAttrValue::String("test".to_string()),
611        ));
612        proc.process_array(&arr, &pool);
613
614        assert_eq!(proc.num_attributes(), 2);
615        assert_eq!(proc.attributes()[0], "Gain");
616        assert_eq!(proc.attributes()[1], "Temp");
617    }
618
619    #[test]
620    fn test_n_attributes_caps_tracked_count() {
621        // n_attributes = 2: only the first 2 (sorted) attributes are tracked.
622        let proc = AttrPlotProcessor::new(2, 100, 1);
623        let pool = NDArrayPool::new(1_000_000);
624        let arr = make_array_with_attrs(1, &[("D", 4.0), ("A", 1.0), ("C", 3.0), ("B", 2.0)]);
625        proc.process_array(&arr, &pool);
626        assert_eq!(proc.num_attributes(), 2);
627        assert_eq!(proc.attributes(), vec!["A", "B"]);
628    }
629
630    #[test]
631    fn test_data_select_maps_block_to_attribute() {
632        // 3 attributes, 2 data blocks. Block 0 -> "B" (idx 1), block 1 -> UID.
633        let proc = AttrPlotProcessor::new(8, 100, 2);
634        let pool = NDArrayPool::new(1_000_000);
635        let arr = make_array_with_attrs(1, &[("A", 10.0), ("B", 20.0), ("C", 30.0)]);
636        proc.process_array(&arr, &pool);
637
638        proc.set_data_select(0, 1).unwrap(); // "B"
639        proc.set_data_select(1, ATTRPLOT_UID_INDEX).unwrap();
640
641        assert_eq!(proc.data_label(0), "B");
642        assert_eq!(proc.data_label(1), ATTRPLOT_UID_LABEL);
643
644        let wf0 = proc.state.lock().block_waveform(0);
645        assert!((wf0[0] - 20.0).abs() < 1e-10, "block 0 plots attribute B");
646        let wf1 = proc.state.lock().block_waveform(1);
647        assert!((wf1[0] - 1.0).abs() < 1e-10, "block 1 plots UID");
648    }
649
650    #[test]
651    fn test_data_select_rejects_out_of_range() {
652        let proc = AttrPlotProcessor::new(8, 100, 2);
653        let pool = NDArrayPool::new(1_000_000);
654        let arr = make_array_with_attrs(1, &[("A", 1.0)]);
655        proc.process_array(&arr, &pool);
656
657        // Only 1 attribute -> selection 1 is out of range.
658        assert!(proc.set_data_select(0, 1).is_err());
659        // Block 5 does not exist.
660        assert!(proc.set_data_select(5, 0).is_err());
661        // Valid: attribute 0 and the UID sentinel.
662        assert!(proc.set_data_select(0, 0).is_ok());
663        assert!(proc.set_data_select(1, ATTRPLOT_UID_INDEX).is_ok());
664    }
665
666    #[test]
667    fn test_data_select_zero_accepted_with_no_attributes() {
668        // C accepts DataSelect 0 before any frame, even with no tracked
669        // attributes (the reject is `value > 0`, NDPluginAttrPlot.cpp:283).
670        let proc = AttrPlotProcessor::new(8, 100, 2);
671        assert!(proc.attributes().is_empty());
672        assert!(proc.set_data_select(0, 0).is_ok());
673        assert_eq!(proc.state.lock().data_selections[0], 0);
674    }
675
676    #[test]
677    fn test_unbound_block_label_is_none() {
678        let proc = AttrPlotProcessor::new(8, 100, 3);
679        let pool = NDArrayPool::new(1_000_000);
680        let arr = make_array_with_attrs(1, &[("A", 1.0)]);
681        proc.process_array(&arr, &pool);
682        // Block 2 was never selected.
683        assert_eq!(proc.data_label(2), ATTRPLOT_NONE_LABEL);
684        assert_eq!(proc.data_select(2), Some(ATTRPLOT_NONE_INDEX));
685    }
686
687    #[test]
688    fn test_npts_tracks_point_count() {
689        let proc = AttrPlotProcessor::new(8, 100, 1);
690        let pool = NDArrayPool::new(1_000_000);
691        for i in 1..=4 {
692            let arr = make_array_with_attrs(i, &[("X", i as f64)]);
693            proc.process_array(&arr, &pool);
694        }
695        assert_eq!(proc.uid_buffer().len(), 4);
696    }
697
698    #[test]
699    fn test_waveform_padded_to_cache_size() {
700        // cache_size = 6, only 3 points pushed -> waveform padded to 6 with
701        // the last point.
702        let proc = AttrPlotProcessor::new(8, 6, 1);
703        let pool = NDArrayPool::new(1_000_000);
704        for i in 1..=3 {
705            let arr = make_array_with_attrs(i, &[("X", i as f64 * 10.0)]);
706            proc.process_array(&arr, &pool);
707        }
708        proc.set_data_select(0, 0).unwrap();
709        let wf = proc.state.lock().block_waveform(0);
710        assert_eq!(wf.len(), 6);
711        assert!((wf[0] - 10.0).abs() < 1e-10);
712        assert!((wf[2] - 30.0).abs() < 1e-10);
713        // Tail padded with the last point (30.0).
714        assert!((wf[3] - 30.0).abs() < 1e-10);
715        assert!((wf[5] - 30.0).abs() < 1e-10);
716    }
717
718    #[test]
719    fn test_data_select_preserved_across_rebuild() {
720        // Bind block 0 to "Temp", then re-acquire (UID resets). After the
721        // rebuild block 0 must still point at "Temp".
722        let proc = AttrPlotProcessor::new(8, 100, 1);
723        let pool = NDArrayPool::new(1_000_000);
724        let arr = make_array_with_attrs(5, &[("Gain", 1.0), ("Temp", 25.0)]);
725        proc.process_array(&arr, &pool);
726        let temp_idx = proc.find_attribute("Temp").unwrap() as i32;
727        proc.set_data_select(0, temp_idx).unwrap();
728
729        // Re-acquisition (UID drops); same attributes.
730        let arr2 = make_array_with_attrs(1, &[("Gain", 2.0), ("Temp", 99.0)]);
731        proc.process_array(&arr2, &pool);
732        assert_eq!(proc.data_label(0), "Temp");
733        let wf = proc.state.lock().block_waveform(0);
734        assert!((wf[0] - 99.0).abs() < 1e-10);
735    }
736
737    #[test]
738    fn test_value_tracking() {
739        let proc = AttrPlotProcessor::new(8, 100, 1);
740        let pool = NDArrayPool::new(1_000_000);
741        for i in 1..=5 {
742            let arr = make_array_with_attrs(i, &[("Value", i as f64 * 10.0)]);
743            proc.process_array(&arr, &pool);
744        }
745        let idx = proc.find_attribute("Value").unwrap();
746        let buf = proc.buffer(idx).unwrap();
747        assert_eq!(buf.len(), 5);
748        assert!((buf[0] - 10.0).abs() < 1e-10);
749        assert!((buf[4] - 50.0).abs() < 1e-10);
750    }
751
752    #[test]
753    fn test_circular_buffer_cache_size() {
754        let proc = AttrPlotProcessor::new(8, 3, 1);
755        let pool = NDArrayPool::new(1_000_000);
756        for i in 1..=5 {
757            let arr = make_array_with_attrs(i, &[("Val", i as f64)]);
758            proc.process_array(&arr, &pool);
759        }
760        let idx = proc.find_attribute("Val").unwrap();
761        let buf = proc.buffer(idx).unwrap();
762        assert_eq!(buf.len(), 3);
763        assert!((buf[0] - 3.0).abs() < 1e-10);
764        assert!((buf[2] - 5.0).abs() < 1e-10);
765    }
766
767    #[test]
768    fn test_uid_decrease_resets_buffers() {
769        let proc = AttrPlotProcessor::new(8, 100, 1);
770        let pool = NDArrayPool::new(1_000_000);
771        for i in 1..=5 {
772            let arr = make_array_with_attrs(i, &[("X", i as f64)]);
773            proc.process_array(&arr, &pool);
774        }
775        let idx = proc.find_attribute("X").unwrap();
776        assert_eq!(proc.buffer(idx).unwrap().len(), 5);
777
778        let arr = make_array_with_attrs(1, &[("X", 100.0)]);
779        proc.process_array(&arr, &pool);
780        let buf = proc.buffer(idx).unwrap();
781        assert_eq!(buf.len(), 1);
782        assert!((buf[0] - 100.0).abs() < 1e-10);
783    }
784
785    #[test]
786    fn test_missing_attribute_uses_nan() {
787        let proc = AttrPlotProcessor::new(8, 100, 1);
788        let pool = NDArrayPool::new(1_000_000);
789        let arr1 = make_array_with_attrs(1, &[("Temp", 25.0)]);
790        proc.process_array(&arr1, &pool);
791
792        let mut arr2 = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
793        arr2.unique_id = 2;
794        proc.process_array(&arr2, &pool);
795
796        let idx = proc.find_attribute("Temp").unwrap();
797        let buf = proc.buffer(idx).unwrap();
798        assert_eq!(buf.len(), 2);
799        assert!((buf[0] - 25.0).abs() < 1e-10);
800        assert!(buf[1].is_nan());
801    }
802
803    #[test]
804    fn test_manual_reset() {
805        let proc = AttrPlotProcessor::new(8, 100, 1);
806        let pool = NDArrayPool::new(1_000_000);
807        let arr = make_array_with_attrs(5, &[("A", 1.0), ("B", 2.0)]);
808        proc.process_array(&arr, &pool);
809        assert_eq!(proc.num_attributes(), 2);
810
811        proc.reset();
812        // Re-initializes from the next frame.
813        let arr2 = make_array_with_attrs(1, &[("C", 3.0)]);
814        proc.process_array(&arr2, &pool);
815        assert_eq!(proc.num_attributes(), 1);
816        assert_eq!(proc.attributes()[0], "C");
817    }
818
819    #[test]
820    fn test_unlimited_buffer() {
821        let proc = AttrPlotProcessor::new(8, 0, 1);
822        let pool = NDArrayPool::new(1_000_000);
823        for i in 1..=100 {
824            let arr = make_array_with_attrs(i, &[("X", i as f64)]);
825            proc.process_array(&arr, &pool);
826        }
827        let idx = proc.find_attribute("X").unwrap();
828        assert_eq!(proc.buffer(idx).unwrap().len(), 100);
829    }
830
831    #[test]
832    fn test_plugin_type() {
833        // C PluginType is "NDAttrPlot" (NDPluginAttrPlot.cpp:87).
834        let proc = AttrPlotProcessor::new(8, 100, 1);
835        assert_eq!(proc.plugin_type(), "NDAttrPlot");
836    }
837}