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