Skip to main content

ad_plugins_rs/
attribute.rs

1//! NDPluginAttribute: extracts named attribute values from each array.
2//!
3//! Supports `maxAttributes` attribute channels (addr 0..maxAttributes-1), each
4//! tracking a different attribute by name. Special pseudo-attribute names
5//! "NDArrayUniqueId" and "NDArrayTimeStamp" read from the array header.
6
7use ad_core_rs::ndarray::NDArray;
8use ad_core_rs::ndarray_pool::NDArrayPool;
9use ad_core_rs::plugin::runtime::{
10    NDPluginProcess, ParamChangeResult, ParamChangeValue, ParamUpdate, PluginParamSnapshot,
11    ProcessResult,
12};
13use asyn_rs::error::AsynError;
14use asyn_rs::param::ParamType;
15use asyn_rs::port::PortDriverBase;
16
17use crate::time_series::{TimeSeriesData, TimeSeriesSender};
18
19/// Parameter indices for NDPluginAttribute.
20#[derive(Clone, Copy, Default)]
21pub struct AttributeParams {
22    pub attr_name: usize,
23    pub value: usize,
24    pub value_sum: usize,
25    pub reset: usize,
26}
27
28/// State for a single attribute channel.
29#[derive(Clone)]
30struct AttrChannel {
31    name: String,
32    value: f64,
33    value_sum: f64,
34}
35
36impl Default for AttrChannel {
37    fn default() -> Self {
38        Self {
39            name: String::new(),
40            value: 0.0,
41            value_sum: 0.0,
42        }
43    }
44}
45
46impl AttrChannel {
47    fn extract_value(&self, array: &NDArray) -> Option<f64> {
48        if self.name.is_empty() {
49            return None;
50        }
51        match self.name.as_str() {
52            "NDArrayUniqueId" => Some(array.unique_id as f64),
53            "NDArrayTimeStamp" => Some(array.timestamp.as_f64()),
54            "NDArrayEpicsTSSec" => Some(array.timestamp.sec as f64),
55            "NDArrayEpicsTSnSec" => Some(array.timestamp.nsec as f64),
56            _ => array
57                .attributes
58                .get(&self.name)
59                .and_then(|attr| attr.value.as_f64()),
60        }
61    }
62}
63
64/// Processor that extracts multiple attribute values from each array.
65pub struct AttributeProcessor {
66    channels: Vec<AttrChannel>,
67    params: AttributeParams,
68    ts_sender: Option<TimeSeriesSender>,
69}
70
71impl AttributeProcessor {
72    /// `num_channels` is C `maxAttributes_` (the per-frame channel count, floored
73    /// to >=1; NDPluginAttribute.cpp:184). Channel 0 is seeded with `attr_name`.
74    pub fn new(attr_name: &str, num_channels: usize) -> Self {
75        let mut channels = vec![AttrChannel::default(); num_channels.max(1)];
76        channels[0].name = attr_name.to_string();
77        Self {
78            channels,
79            params: AttributeParams::default(),
80            ts_sender: None,
81        }
82    }
83
84    pub fn set_ts_sender(&mut self, sender: TimeSeriesSender) {
85        self.ts_sender = Some(sender);
86    }
87
88    /// Access the registered param indices (populated after register_params).
89    pub fn params(&self) -> &AttributeParams {
90        &self.params
91    }
92
93    /// Reset value and value_sum for all channels (C parity: resets all, not just one).
94    pub fn reset(&mut self) {
95        for ch in self.channels.iter_mut() {
96            ch.value = 0.0;
97            ch.value_sum = 0.0;
98        }
99    }
100
101    /// Current extracted value for channel 0.
102    pub fn value(&self) -> f64 {
103        self.channels[0].value
104    }
105
106    /// Current accumulated sum for channel 0.
107    pub fn value_sum(&self) -> f64 {
108        self.channels[0].value_sum
109    }
110
111    /// The attribute name being tracked by channel 0.
112    pub fn attr_name(&self) -> &str {
113        &self.channels[0].name
114    }
115
116    /// Set the attribute name for channel 0.
117    pub fn set_attr_name(&mut self, name: &str) {
118        self.channels[0].name = name.to_string();
119    }
120}
121
122impl NDPluginProcess for AttributeProcessor {
123    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
124        let mut updates = Vec::new();
125
126        for (i, ch) in self.channels.iter_mut().enumerate() {
127            if ch.name.is_empty() {
128                continue;
129            }
130            // C `continue`s on a missing or non-numeric attribute: no
131            // setDoubleParam, no ValSum accumulation, no callParamCallbacks(i)
132            // for that channel (NDPluginAttribute.cpp:72-80). Only post when the
133            // value was actually refreshed this frame.
134            if let Some(val) = ch.extract_value(array) {
135                ch.value = val;
136                ch.value_sum += val;
137                let addr = i as i32;
138                updates.push(ParamUpdate::float64_addr(self.params.value, addr, ch.value));
139                updates.push(ParamUpdate::float64_addr(
140                    self.params.value_sum,
141                    addr,
142                    ch.value_sum,
143                ));
144            }
145        }
146
147        // Send to time series
148        if let Some(ref sender) = self.ts_sender {
149            let values: Vec<f64> = self.channels.iter().map(|ch| ch.value).collect();
150            let _ = sender.try_send(TimeSeriesData { values });
151        }
152
153        ProcessResult::sink(updates)
154    }
155
156    fn plugin_type(&self) -> &str {
157        "NDPluginAttribute"
158    }
159
160    /// C `NDPluginAttribute.cpp:203` sets `NDArrayCallbacks = 0`: this plugin
161    /// extracts attribute time series and does not deliver arrays downstream.
162    fn does_array_callbacks(&self) -> bool {
163        false
164    }
165
166    fn register_params(&mut self, base: &mut PortDriverBase) -> Result<(), AsynError> {
167        self.params.attr_name = base.create_param("ATTR_ATTRNAME", ParamType::Octet)?;
168        self.params.value = base.create_param("ATTR_VAL", ParamType::Float64)?;
169        self.params.value_sum = base.create_param("ATTR_VAL_SUM", ParamType::Float64)?;
170        self.params.reset = base.create_param("ATTR_RESET", ParamType::Int32)?;
171        Ok(())
172    }
173
174    fn on_param_change(
175        &mut self,
176        reason: usize,
177        params: &PluginParamSnapshot,
178    ) -> ParamChangeResult {
179        let addr = params.addr as usize;
180
181        if reason == self.params.attr_name {
182            if addr < self.channels.len() {
183                if let ParamChangeValue::Octet(s) = &params.value {
184                    self.channels[addr].name = s.clone();
185                }
186            }
187        } else if reason == self.params.reset {
188            // C zeros Val/ValSum for all channels on ANY write to the reset
189            // param — there is no value test (NDPluginAttribute.cpp:123-128).
190            let mut updates = Vec::new();
191            for (i, ch) in self.channels.iter_mut().enumerate() {
192                ch.value = 0.0;
193                ch.value_sum = 0.0;
194                let a = i as i32;
195                updates.push(ParamUpdate::float64_addr(self.params.value, a, 0.0));
196                updates.push(ParamUpdate::float64_addr(self.params.value_sum, a, 0.0));
197            }
198            return ParamChangeResult::updates(updates);
199        }
200
201        ParamChangeResult::updates(vec![])
202    }
203}
204
205/// Time-series channel names, one per attribute channel. The length is C
206/// `maxAttributes_` (the TS NDArray dim, NDPluginAttribute.cpp:98), so it tracks
207/// the configured channel count rather than a fixed 8.
208pub fn attr_ts_channel_names(num_channels: usize) -> Vec<String> {
209    (0..num_channels.max(1))
210        .map(|i| {
211            if i == 0 {
212                "TSArrayValue".to_string()
213            } else {
214                format!("TSArrayValue{i}")
215            }
216        })
217        .collect()
218}
219
220/// Create an Attribute plugin runtime. The TS receiver is stored in the registry
221/// for later pickup by `NDTimeSeriesConfigure`.
222pub fn create_attribute_runtime(
223    port_name: &str,
224    pool: std::sync::Arc<ad_core_rs::ndarray_pool::NDArrayPool>,
225    queue_size: usize,
226    ndarray_port: &str,
227    wiring: std::sync::Arc<ad_core_rs::plugin::wiring::WiringRegistry>,
228    ts_registry: &crate::time_series::TsReceiverRegistry,
229    max_attributes: i32,
230) -> (
231    ad_core_rs::plugin::runtime::PluginRuntimeHandle,
232    std::thread::JoinHandle<()>,
233) {
234    // C: maxAttributes_ = max(maxAttributes, 1) is the per-frame channel count
235    // and the TS length; the NDPluginDriver base address count is
236    // max(maxAttributes, 2) (NDPluginAttribute.cpp:175,184).
237    let num_channels = max_attributes.max(1) as usize;
238    let num_addr = max_attributes.max(2) as usize;
239
240    let (ts_tx, ts_rx) = tokio::sync::mpsc::channel(256);
241
242    let mut processor = AttributeProcessor::new("", num_channels);
243    processor.set_ts_sender(ts_tx);
244
245    let (handle, data_jh) = ad_core_rs::plugin::runtime::create_plugin_runtime_multi_addr(
246        port_name,
247        processor,
248        pool,
249        queue_size,
250        ndarray_port,
251        wiring,
252        num_addr,
253    );
254
255    ts_registry.store(port_name, ts_rx, attr_ts_channel_names(num_channels));
256
257    (handle, data_jh)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
264    use ad_core_rs::ndarray::{NDDataType, NDDimension};
265
266    fn make_array_with_attr(name: &str, value: f64, uid: i32) -> NDArray {
267        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
268        arr.unique_id = uid;
269        arr.attributes.add(NDAttribute::new_static(
270            name,
271            String::new(),
272            NDAttrSource::Driver,
273            NDAttrValue::Float64(value),
274        ));
275        arr
276    }
277
278    #[test]
279    fn test_extract_named_attribute() {
280        let mut proc = AttributeProcessor::new("Temperature", 8);
281        let pool = NDArrayPool::new(1_000_000);
282
283        let arr = make_array_with_attr("Temperature", 25.5, 1);
284        let result = proc.process_array(&arr, &pool);
285
286        assert!(
287            result.output_arrays.is_empty(),
288            "attribute plugin is a sink"
289        );
290        assert!((proc.value() - 25.5).abs() < 1e-10);
291        assert!((proc.value_sum() - 25.5).abs() < 1e-10);
292    }
293
294    #[test]
295    fn test_sum_accumulation() {
296        let mut proc = AttributeProcessor::new("Intensity", 8);
297        let pool = NDArrayPool::new(1_000_000);
298
299        let arr1 = make_array_with_attr("Intensity", 10.0, 1);
300        proc.process_array(&arr1, &pool);
301        assert!((proc.value_sum() - 10.0).abs() < 1e-10);
302
303        let arr2 = make_array_with_attr("Intensity", 20.0, 2);
304        proc.process_array(&arr2, &pool);
305        assert!((proc.value() - 20.0).abs() < 1e-10);
306        assert!((proc.value_sum() - 30.0).abs() < 1e-10);
307    }
308
309    #[test]
310    fn test_reset() {
311        let mut proc = AttributeProcessor::new("Count", 8);
312        let pool = NDArrayPool::new(1_000_000);
313
314        let arr1 = make_array_with_attr("Count", 100.0, 1);
315        proc.process_array(&arr1, &pool);
316        assert!((proc.value_sum() - 100.0).abs() < 1e-10);
317
318        proc.reset();
319        assert!((proc.value_sum() - 0.0).abs() < 1e-10);
320        assert!((proc.value() - 0.0).abs() < 1e-10);
321    }
322
323    #[test]
324    fn test_special_attr_unique_id() {
325        let mut proc = AttributeProcessor::new("NDArrayUniqueId", 8);
326        let pool = NDArrayPool::new(1_000_000);
327
328        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
329        arr.unique_id = 42;
330
331        proc.process_array(&arr, &pool);
332        assert!((proc.value() - 42.0).abs() < 1e-10);
333    }
334
335    #[test]
336    fn test_special_attr_timestamp() {
337        let mut proc = AttributeProcessor::new("NDArrayTimeStamp", 8);
338        let pool = NDArrayPool::new(1_000_000);
339
340        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
341        arr.timestamp = ad_core_rs::timestamp::EpicsTimestamp {
342            sec: 100,
343            nsec: 500_000_000,
344        };
345
346        proc.process_array(&arr, &pool);
347        assert!((proc.value() - 100.5).abs() < 1e-9);
348    }
349
350    #[test]
351    fn test_missing_attribute() {
352        let mut proc = AttributeProcessor::new("NonExistent", 8);
353        let pool = NDArrayPool::new(1_000_000);
354
355        let arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
356        proc.process_array(&arr, &pool);
357
358        assert!((proc.value() - 0.0).abs() < 1e-10);
359        assert!((proc.value_sum() - 0.0).abs() < 1e-10);
360    }
361
362    #[test]
363    fn test_string_attribute_ignored() {
364        let mut proc = AttributeProcessor::new("Label", 8);
365        let pool = NDArrayPool::new(1_000_000);
366
367        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
368        arr.attributes.add(NDAttribute::new_static(
369            "Label",
370            String::new(),
371            NDAttrSource::Driver,
372            NDAttrValue::String("hello".to_string()),
373        ));
374
375        proc.process_array(&arr, &pool);
376        assert!((proc.value() - 0.0).abs() < 1e-10);
377    }
378
379    #[test]
380    fn test_int32_attribute() {
381        let mut proc = AttributeProcessor::new("Counter", 8);
382        let pool = NDArrayPool::new(1_000_000);
383
384        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
385        arr.attributes.add(NDAttribute::new_static(
386            "Counter",
387            String::new(),
388            NDAttrSource::Driver,
389            NDAttrValue::Int32(7),
390        ));
391
392        proc.process_array(&arr, &pool);
393        assert!((proc.value() - 7.0).abs() < 1e-10);
394    }
395
396    #[test]
397    fn test_channel_count_follows_max_attributes() {
398        // C maxAttributes_ sizes the per-frame channel loop and the TS NDArray
399        // length (NDPluginAttribute.cpp:55,98,184); neither is fixed at 8.
400        assert_eq!(attr_ts_channel_names(16).len(), 16);
401        assert_eq!(attr_ts_channel_names(2).len(), 2);
402        assert_eq!(attr_ts_channel_names(0).len(), 1); // floored to >=1
403
404        let mut proc = AttributeProcessor::new("Temp", 16);
405        proc.params.value = 2;
406        proc.params.value_sum = 3;
407        proc.channels[15].name = "High".to_string();
408
409        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
410        arr.attributes.add(NDAttribute::new_static(
411            "Temp",
412            String::new(),
413            NDAttrSource::Driver,
414            NDAttrValue::Float64(1.0),
415        ));
416        arr.attributes.add(NDAttribute::new_static(
417            "High",
418            String::new(),
419            NDAttrSource::Driver,
420            NDAttrValue::Float64(9.0),
421        ));
422
423        let r = proc.process_array(&arr, &NDArrayPool::new(1_000_000));
424        // Channel 15 — beyond the old fixed 8 — must post its value.
425        assert!(
426            r.param_updates.iter().any(|u| matches!(
427                u,
428                ParamUpdate::Float64 { reason: 2, addr: 15, value } if *value == 9.0
429            )),
430            "channel 15 must post with a 16-channel processor"
431        );
432    }
433
434    #[test]
435    fn test_missing_attribute_skips_post() {
436        // C `continue`s (no setDoubleParam / callParamCallbacks) for a channel
437        // whose attribute is absent this frame (NDPluginAttribute.cpp:72-80).
438        let mut proc = AttributeProcessor::new("Temp", 8);
439        proc.params.value = 2;
440        proc.params.value_sum = 3;
441        let pool = NDArrayPool::new(1_000_000);
442
443        let r1 = proc.process_array(&make_array_with_attr("Temp", 5.0, 1), &pool);
444        assert!(
445            r1.param_updates
446                .iter()
447                .any(|u| matches!(u, ParamUpdate::Float64 { reason: 2, .. })),
448            "present attribute must post ATTR_VAL"
449        );
450
451        let bare = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
452        let r2 = proc.process_array(&bare, &pool);
453        assert!(
454            !r2.param_updates
455                .iter()
456                .any(|u| matches!(u, ParamUpdate::Float64 { reason: 2, .. })),
457            "missing attribute must not re-post stale ATTR_VAL"
458        );
459        // C retains the last successfully-read Val across the missing frame.
460        assert!((proc.value() - 5.0).abs() < 1e-10);
461    }
462
463    #[test]
464    fn test_reset_clears_on_zero_write() {
465        // C NDPluginAttribute::writeInt32 zeros Val/ValSum on ANY write to the
466        // reset param, including value 0 (NDPluginAttribute.cpp:123-128).
467        let mut proc = AttributeProcessor::new("Count", 8);
468        proc.params.value = 2;
469        proc.params.value_sum = 3;
470        proc.params.reset = 7;
471
472        let pool = NDArrayPool::new(1_000_000);
473        proc.process_array(&make_array_with_attr("Count", 100.0, 1), &pool);
474        assert!((proc.value_sum() - 100.0).abs() < 1e-10);
475
476        let snapshot = PluginParamSnapshot {
477            enable_callbacks: true,
478            reason: 7,
479            addr: 0,
480            value: ParamChangeValue::Int32(0),
481        };
482        let result = proc.on_param_change(7, &snapshot);
483
484        assert!((proc.value() - 0.0).abs() < 1e-10);
485        assert!((proc.value_sum() - 0.0).abs() < 1e-10);
486        assert!(
487            result.param_updates.iter().any(|u| matches!(
488                u,
489                ParamUpdate::Float64 {
490                    reason: 2,
491                    value,
492                    ..
493                } if *value == 0.0
494            )),
495            "zero write must post cleared ATTR_VAL"
496        );
497    }
498
499    #[test]
500    fn test_set_attr_name() {
501        let mut proc = AttributeProcessor::new("A", 8);
502        assert_eq!(proc.attr_name(), "A");
503
504        proc.set_attr_name("B");
505        assert_eq!(proc.attr_name(), "B");
506
507        let pool = NDArrayPool::new(1_000_000);
508        let arr = make_array_with_attr("B", 99.0, 1);
509        proc.process_array(&arr, &pool);
510        assert!((proc.value() - 99.0).abs() < 1e-10);
511    }
512}