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