Skip to main content

ad_plugins_rs/
pos_plugin.rs

1use std::collections::{HashMap, VecDeque};
2use std::sync::Arc;
3
4use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
5use ad_core_rs::ndarray::NDArray;
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::runtime::{NDPluginProcess, ParamUpdate, ProcessResult};
8use serde::Deserialize;
9
10/// Position mode: Discard consumes positions, Keep cycles through them.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PosMode {
13    Discard,
14    Keep,
15}
16
17/// JSON-deserializable position list.
18#[derive(Debug, Deserialize)]
19pub struct PositionList {
20    pub positions: Vec<HashMap<String, f64>>,
21}
22
23/// Asyn param indices for the 17 NDPosPlugin params (resolved in
24/// `register_params`). Names match the C `str_NDPos_*` drvInfo strings
25/// (`NDPosPlugin.h:39-55`) so the `NDPosPlugin.template` records bind.
26#[derive(Default)]
27struct PosParamIndices {
28    filename: Option<usize>,
29    file_valid: Option<usize>,
30    clear: Option<usize>,
31    running: Option<usize>,
32    restart: Option<usize>,
33    delete: Option<usize>,
34    mode: Option<usize>,
35    append: Option<usize>,
36    current_qty: Option<usize>,
37    current_index: Option<usize>,
38    current_pos: Option<usize>,
39    missing_frames: Option<usize>,
40    duplicate_frames: Option<usize>,
41    expected_id: Option<usize>,
42    id_name: Option<usize>,
43    id_difference: Option<usize>,
44    id_start: Option<usize>,
45}
46
47/// NDPosPlugin processor: attaches position metadata to arrays from a position list.
48pub struct PosPluginProcessor {
49    positions: VecDeque<HashMap<String, f64>>,
50    all_positions: Vec<HashMap<String, f64>>,
51    mode: PosMode,
52    index: usize,
53    running: bool,
54    expected_id: i32,
55    /// C `NDPos_IDStart` (default 1): the value `ExpectedID` is reset to on
56    /// every Running write (NDPosPlugin.cpp:420,232-234).
57    id_start: i32,
58    /// C `NDPos_IDDifference` (default 1): the step `ExpectedID` advances by
59    /// per frame and per dropped frame (NDPosPlugin.cpp:103,115,193).
60    id_difference: i32,
61    missing_frames: usize,
62    duplicate_frames: usize,
63    params: PosParamIndices,
64}
65
66impl PosPluginProcessor {
67    pub fn new(mode: PosMode) -> Self {
68        Self {
69            positions: VecDeque::new(),
70            all_positions: Vec::new(),
71            mode,
72            index: 0,
73            running: false,
74            expected_id: 0,
75            id_start: 1,
76            id_difference: 1,
77            missing_frames: 0,
78            duplicate_frames: 0,
79            params: PosParamIndices::default(),
80        }
81    }
82
83    /// Load positions from a JSON string.
84    pub fn load_positions_json(&mut self, json_str: &str) -> Result<usize, serde_json::Error> {
85        let list: PositionList = serde_json::from_str(json_str)?;
86        let count = list.positions.len();
87        self.all_positions = list.positions.clone();
88        self.positions = list.positions.into();
89        self.index = 0;
90        Ok(count)
91    }
92
93    /// Load positions from an XML string (C++ NDPosPlugin `pos_layout` format).
94    ///
95    /// Expected XML format (matching `NDPosPluginFileReader`):
96    /// ```xml
97    /// <pos_layout>
98    ///   <dimensions>
99    ///     <dimension name="x"/>
100    ///     <dimension name="y"/>
101    ///   </dimensions>
102    ///   <positions>
103    ///     <position x="1" y="2"/>
104    ///     <position x="3" y="4"/>
105    ///   </positions>
106    /// </pos_layout>
107    /// ```
108    ///
109    /// Each `<dimension name="N"/>` declares an ordered dimension; each
110    /// `<position .../>` carries one attribute per dimension, and the attribute
111    /// value (parsed as f64) is stored under the dimension name. A position
112    /// missing any declared dimension's attribute is rejected (matching C
113    /// `addPosition` returning `asynError`).
114    pub fn load_positions_xml(&mut self, xml_str: &str) -> Result<usize, String> {
115        let positions = parse_positions_xml(xml_str)?;
116        let count = positions.len();
117        self.all_positions = positions.clone();
118        self.positions = positions.into();
119        self.index = 0;
120        Ok(count)
121    }
122
123    /// Load positions from a string, auto-detecting format.
124    ///
125    /// If the content starts with '<' (after trimming whitespace), it is treated as XML.
126    /// Otherwise, it is treated as JSON.
127    pub fn load_positions_auto(&mut self, content: &str) -> Result<usize, String> {
128        if content.trim_start().starts_with('<') {
129            self.load_positions_xml(content)
130        } else {
131            self.load_positions_json(content)
132                .map_err(|e| format!("JSON parse error: {}", e))
133        }
134    }
135
136    /// Load positions directly.
137    pub fn load_positions(&mut self, positions: Vec<HashMap<String, f64>>) {
138        self.all_positions = positions.clone();
139        self.positions = positions.into();
140        self.index = 0;
141    }
142
143    /// Start processing.
144    ///
145    /// C `writeInt32(NDPos_Running)` resets `ExpectedID` to `IDStart` and does
146    /// nothing else (NDPosPlugin.cpp:230-234) — in particular it does *not*
147    /// clear MissingFrames/DuplicateFrames, which persist across runs until a
148    /// client writes them (they are zeroed only in the constructor).
149    pub fn start(&mut self) {
150        self.running = true;
151        self.expected_id = self.id_start;
152    }
153
154    /// Stop processing.
155    pub fn stop(&mut self) {
156        self.running = false;
157    }
158
159    /// Clear all positions.
160    pub fn clear(&mut self) {
161        self.positions.clear();
162        self.all_positions.clear();
163        self.index = 0;
164    }
165
166    pub fn missing_frames(&self) -> usize {
167        self.missing_frames
168    }
169
170    pub fn duplicate_frames(&self) -> usize {
171        self.duplicate_frames
172    }
173
174    pub fn remaining_positions(&self) -> usize {
175        match self.mode {
176            PosMode::Discard => self.positions.len(),
177            PosMode::Keep => self.all_positions.len(),
178        }
179    }
180
181    fn current_position(&self) -> Option<&HashMap<String, f64>> {
182        match self.mode {
183            PosMode::Discard => self.positions.front(),
184            PosMode::Keep => {
185                if self.index < self.all_positions.len() {
186                    Some(&self.all_positions[self.index])
187                } else {
188                    None
189                }
190            }
191        }
192    }
193
194    /// Whether a position remains to be consumed at the current cursor — C's
195    /// `size > 0` (Discard) / `index < size` (Keep) guard
196    /// (NDPosPlugin.cpp:99,113).
197    fn has_position(&self) -> bool {
198        match self.mode {
199            PosMode::Discard => !self.positions.is_empty(),
200            PosMode::Keep => self.index < self.all_positions.len(),
201        }
202    }
203
204    fn advance(&mut self) {
205        match self.mode {
206            PosMode::Discard => {
207                self.positions.pop_front();
208            }
209            PosMode::Keep => {
210                self.index += 1;
211            }
212        }
213    }
214
215    /// The value C reports in `NDPos_CurrentIndex`: 0 in Discard mode (the
216    /// cursor never moves, the front is always consumed) and the running index
217    /// in Keep mode (NDPosPlugin.cpp:190; Discard never sets CurrentIndex).
218    fn current_index_param(&self) -> i32 {
219        match self.mode {
220            PosMode::Discard => 0,
221            PosMode::Keep => self.index as i32,
222        }
223    }
224
225    /// C exhaustion path: positions ran out, so set `NDPos_Running = IDLE` and
226    /// emit no downstream callback for this frame (NDPosPlugin.cpp:56-59,
227    /// 107-122,197-204).
228    fn exhausted_result(&mut self) -> ProcessResult {
229        self.running = false;
230        let mut updates = Vec::new();
231        push_int(&mut updates, self.params.running, 0);
232        ProcessResult {
233            output_arrays: vec![],
234            param_updates: updates,
235            scatter: false,
236        }
237    }
238}
239
240/// Push an `Int32` param update only when the param index is resolved.
241fn push_int(updates: &mut Vec<ParamUpdate>, idx: Option<usize>, value: i32) {
242    if let Some(i) = idx {
243        updates.push(ParamUpdate::int32(i, value));
244    }
245}
246
247/// Push an `Octet` param update only when the param index is resolved.
248fn push_str(updates: &mut Vec<ParamUpdate>, idx: Option<usize>, value: String) {
249    if let Some(i) = idx {
250        updates.push(ParamUpdate::octet(i, value));
251    }
252}
253
254/// Format `v` the way C++ `std::ostream << double` does by default
255/// (`defaultfloat`, stream precision 6) — equivalent to C `printf("%g", v)`.
256/// C builds the NDPos_CurrentPos string by streaming each position double
257/// (NDPosPlugin.cpp:159), so the observable octet value must use this format
258/// rather than Rust's shortest-round-trip `Display`.
259fn format_cpp_g6(v: f64) -> String {
260    const PREC: i32 = 6;
261    if v == 0.0 {
262        return if v.is_sign_negative() { "-0" } else { "0" }.to_string();
263    }
264    if v.is_nan() {
265        return "nan".to_string();
266    }
267    if v.is_infinite() {
268        return if v < 0.0 { "-inf" } else { "inf" }.to_string();
269    }
270    // Round to PREC significant figures via scientific form, then read the
271    // decimal exponent — this avoids log10 floor off-by-one at powers of ten.
272    let sci = format!("{:.*e}", (PREC - 1) as usize, v);
273    let epos = sci.find('e').unwrap();
274    let exp: i32 = sci[epos + 1..].parse().unwrap();
275    if exp >= -4 && exp < PREC {
276        // %f branch: precision = PREC-1-exp digits after the point.
277        let prec = (PREC - 1 - exp).max(0) as usize;
278        strip_g_trailing(&format!("{:.*}", prec, v))
279    } else {
280        // %e branch: strip mantissa trailing zeros, render a signed 2+digit
281        // exponent (C printf style: "1.23457e+06", "1e-05").
282        let mantissa = strip_g_trailing(&sci[..epos]);
283        let sign = if exp < 0 { '-' } else { '+' };
284        format!("{}e{}{:02}", mantissa, sign, exp.abs())
285    }
286}
287
288/// Strip `%g` trailing zeros: remove trailing '0' digits after a '.', then a
289/// dangling '.'. No-op for strings without a decimal point.
290fn strip_g_trailing(s: &str) -> String {
291    if s.contains('.') {
292        s.trim_end_matches('0').trim_end_matches('.').to_string()
293    } else {
294        s.to_string()
295    }
296}
297
298/// Parse positions from the C++ NDPosPlugin `pos_layout` XML format.
299///
300/// Mirrors `NDPosPluginFileReader`: ordered dimension names are collected from
301/// `<dimension name="N"/>` elements, then each `<position .../>` element is read
302/// for one attribute per declared dimension, building a `map<dimension, value>`.
303/// A position missing any declared dimension's attribute — or whose attribute
304/// value does not parse as f64 — is rejected entirely (C `addPosition` returns
305/// `asynError` and does not push that position).
306///
307/// This is a minimal hand-written parser for this trivial XML format, avoiding
308/// the need for an external XML crate dependency.
309fn parse_positions_xml(xml: &str) -> Result<Vec<HashMap<String, f64>>, String> {
310    // Collect ordered dimension names from <dimension name="N"/> elements.
311    let dimensions: Vec<String> = element_tag_contents(xml, "dimension")
312        .into_iter()
313        .filter_map(|content| parse_tag_attributes(content).remove("name"))
314        .collect();
315
316    let mut positions: Vec<HashMap<String, f64>> = Vec::new();
317    for content in element_tag_contents(xml, "position") {
318        let attrs = parse_tag_attributes(content);
319        // C addPosition first requires the element to carry attributes at all.
320        if attrs.is_empty() {
321            continue;
322        }
323        let mut pos = HashMap::new();
324        let mut ok = true;
325        for dim in &dimensions {
326            match attrs.get(dim).and_then(|v| v.parse::<f64>().ok()) {
327                Some(value) => {
328                    pos.insert(dim.clone(), value);
329                }
330                None => {
331                    // Missing or unparseable dimension attribute → reject position.
332                    ok = false;
333                    break;
334                }
335            }
336        }
337        if ok {
338            positions.push(pos);
339        }
340    }
341
342    Ok(positions)
343}
344
345/// True if `c` validly terminates the element name in `<name` of an opening tag:
346/// whitespace (attributes follow), '>' (tag end), or '/' (self-closing). Used to
347/// reject the longer-named sibling — e.g. `<positions`/`<dimensions` when
348/// scanning for `<position`/`<dimension`.
349fn is_tag_boundary(c: char) -> bool {
350    c.is_ascii_whitespace() || c == '>' || c == '/'
351}
352
353/// Collect the attribute-region slice (the text between `<name` and `>`) of
354/// every `<name ...>` opening tag, skipping any longer-named sibling
355/// (`<names ...>`).
356fn element_tag_contents<'a>(xml: &'a str, name: &str) -> Vec<&'a str> {
357    let prefix = format!("<{}", name);
358    let mut out = Vec::new();
359    let mut from = 0;
360    while let Some(rel) = xml[from..].find(&prefix) {
361        let open = from + rel;
362        let after = open + prefix.len();
363        match xml[after..].chars().next() {
364            Some(c) if is_tag_boundary(c) => {}
365            _ => {
366                // <names ...> or end of string — not this element.
367                from = after;
368                continue;
369            }
370        }
371        let Some(rel_end) = xml[after..].find('>') else {
372            break;
373        };
374        let end = after + rel_end;
375        out.push(&xml[after..end]);
376        from = end + 1;
377    }
378    out
379}
380
381/// Parse `key="value"` / `key='value'` attribute pairs from a tag's
382/// attribute region.
383fn parse_tag_attributes(content: &str) -> HashMap<String, String> {
384    let mut attrs = HashMap::new();
385    let bytes = content.as_bytes();
386    let mut i = 0;
387    while let Some(eq_rel) = content[i..].find('=') {
388        let eq = i + eq_rel;
389        // Key: the identifier immediately preceding '=' (skipping whitespace).
390        let mut k_end = eq;
391        while k_end > i && bytes[k_end - 1].is_ascii_whitespace() {
392            k_end -= 1;
393        }
394        let mut k_start = k_end;
395        while k_start > i
396            && !bytes[k_start - 1].is_ascii_whitespace()
397            && bytes[k_start - 1] != b'/'
398            && bytes[k_start - 1] != b'='
399        {
400            k_start -= 1;
401        }
402        let key = &content[k_start..k_end];
403        // Value: quoted string after '='.
404        let mut j = eq + 1;
405        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
406            j += 1;
407        }
408        if j >= bytes.len() {
409            break;
410        }
411        let quote = bytes[j];
412        if quote != b'"' && quote != b'\'' {
413            i = eq + 1;
414            continue;
415        }
416        j += 1;
417        let val_start = j;
418        while j < bytes.len() && bytes[j] != quote {
419            j += 1;
420        }
421        if j >= bytes.len() {
422            break; // unterminated quote
423        }
424        if !key.is_empty() {
425            attrs.insert(key.to_string(), content[val_start..j].to_string());
426        }
427        i = j + 1;
428    }
429    attrs
430}
431
432impl NDPluginProcess for PosPluginProcessor {
433    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
434        if !self.running {
435            // C only reaches endProcessCallbacks inside `if (running ==
436            // NDPOS_RUNNING)` (NDPosPlugin.cpp:54,202); an idle plugin emits no
437            // downstream callback, it does not pass the frame through.
438            return ProcessResult::empty();
439        }
440
441        // C checks `index >= size` up front and, when the list is already
442        // exhausted, sets Running=IDLE and emits nothing (NDPosPlugin.cpp:56-59,
443        // 197-200).
444        if !self.has_position() {
445            return self.exhausted_result();
446        }
447
448        // Frame ID tracking. C compares against ExpectedID = IDStart from the
449        // very first running frame (NDPosPlugin.cpp:90-135); there is no
450        // "skip the first frame" gate, so a first frame whose uniqueId differs
451        // from IDStart is classified as missing/duplicate immediately.
452        let uid = array.unique_id;
453        if uid > self.expected_id {
454            // Missing frame(s): step ExpectedID by IDDifference, dropping one
455            // position per step, until we catch up or run out
456            // (NDPosPlugin.cpp:99-126). ExpectedID is never re-anchored to uid.
457            while self.expected_id < uid && self.has_position() {
458                self.advance();
459                self.expected_id += self.id_difference;
460                self.missing_frames += 1;
461            }
462            if !self.has_position() {
463                // Positions exhausted mid-gap: C stops and drops the frame
464                // (NDPosPlugin.cpp:107-110,119-122).
465                return self.exhausted_result();
466            }
467        } else if uid < self.expected_id {
468            self.duplicate_frames += 1;
469            // C sets skip=1 (no downstream emit) but still posts
470            // DuplicateFrames (NDPosPlugin.cpp:132-135).
471            let mut updates = Vec::new();
472            push_int(
473                &mut updates,
474                self.params.duplicate_frames,
475                self.duplicate_frames as i32,
476            );
477            return ProcessResult {
478                output_arrays: vec![],
479                param_updates: updates,
480                scatter: false,
481            };
482        }
483
484        // Guaranteed `Some` here: has_position() was rechecked above.
485        let position = self.current_position().unwrap().clone();
486
487        let mut out = array.clone();
488        // C iterates the position std::map (sorted ascending by key), building
489        // the CurrentPos string "[k=v,...]" and attaching each attribute in the
490        // same loop (NDPosPlugin.cpp:149-166). The attribute description is the
491        // fixed "Position of NDArray" (line 161).
492        let mut keys: Vec<&String> = position.keys().collect();
493        keys.sort();
494        let mut current_pos = String::from("[");
495        for (n, key) in keys.iter().enumerate() {
496            let value = position[*key];
497            if n > 0 {
498                current_pos.push(',');
499            }
500            current_pos.push_str(key);
501            current_pos.push('=');
502            current_pos.push_str(&format_cpp_g6(value));
503            out.attributes.add(NDAttribute::new_static(
504                (*key).clone(),
505                "Position of NDArray",
506                NDAttrSource::Driver,
507                NDAttrValue::Float64(value),
508            ));
509        }
510        current_pos.push(']');
511
512        self.advance();
513        // C steps ExpectedID by IDDifference (NDPosPlugin.cpp:193), it does not
514        // re-anchor to the received uniqueId.
515        self.expected_id += self.id_difference;
516
517        // C posts MissingFrames/DuplicateFrames and, after advancing, the new
518        // CurrentQty (Discard) / CurrentIndex (Keep) (NDPosPlugin.cpp:126,134,
519        // 187,190).
520        let mut updates = Vec::new();
521        push_int(
522            &mut updates,
523            self.params.missing_frames,
524            self.missing_frames as i32,
525        );
526        push_int(
527            &mut updates,
528            self.params.duplicate_frames,
529            self.duplicate_frames as i32,
530        );
531        push_int(
532            &mut updates,
533            self.params.current_qty,
534            self.remaining_positions() as i32,
535        );
536        push_int(
537            &mut updates,
538            self.params.current_index,
539            self.current_index_param(),
540        );
541        // C setStringParam(NDPos_CurrentPos, ...) (NDPosPlugin.cpp:166).
542        push_str(&mut updates, self.params.current_pos, current_pos);
543
544        ProcessResult {
545            output_arrays: vec![Arc::new(out)],
546            param_updates: updates,
547            scatter: false,
548        }
549    }
550
551    fn plugin_type(&self) -> &str {
552        // C sets PluginType to "NDPositionPlugin" (NDPosPlugin.cpp:402), not the
553        // class name.
554        "NDPositionPlugin"
555    }
556
557    fn register_params(
558        &mut self,
559        base: &mut asyn_rs::port::PortDriverBase,
560    ) -> asyn_rs::error::AsynResult<()> {
561        use asyn_rs::param::ParamType;
562        // 17 params in C createParam order (NDPosPlugin.cpp:383-399).
563        base.create_param("NDPos_Filename", ParamType::Octet)?;
564        base.create_param("NDPos_FileValid", ParamType::Int32)?;
565        base.create_param("NDPos_Clear", ParamType::Int32)?;
566        base.create_param("NDPos_Running", ParamType::Int32)?;
567        base.create_param("NDPos_Restart", ParamType::Int32)?;
568        base.create_param("NDPos_Delete", ParamType::Int32)?;
569        base.create_param("NDPos_Mode", ParamType::Int32)?;
570        base.create_param("NDPos_Append", ParamType::Int32)?;
571        base.create_param("NDPos_CurrentQty", ParamType::Int32)?;
572        base.create_param("NDPos_CurrentIndex", ParamType::Int32)?;
573        base.create_param("NDPos_CurrentPos", ParamType::Octet)?;
574        base.create_param("NDPos_MissingFrames", ParamType::Int32)?;
575        base.create_param("NDPos_DuplicateFrames", ParamType::Int32)?;
576        base.create_param("NDPos_ExpectedID", ParamType::Int32)?;
577        base.create_param("NDPos_IDName", ParamType::Octet)?;
578        base.create_param("NDPos_IDDifference", ParamType::Int32)?;
579        base.create_param("NDPos_IDStart", ParamType::Int32)?;
580
581        self.params.filename = base.find_param("NDPos_Filename");
582        self.params.file_valid = base.find_param("NDPos_FileValid");
583        self.params.clear = base.find_param("NDPos_Clear");
584        self.params.running = base.find_param("NDPos_Running");
585        self.params.restart = base.find_param("NDPos_Restart");
586        self.params.delete = base.find_param("NDPos_Delete");
587        self.params.mode = base.find_param("NDPos_Mode");
588        self.params.append = base.find_param("NDPos_Append");
589        self.params.current_qty = base.find_param("NDPos_CurrentQty");
590        self.params.current_index = base.find_param("NDPos_CurrentIndex");
591        self.params.current_pos = base.find_param("NDPos_CurrentPos");
592        self.params.missing_frames = base.find_param("NDPos_MissingFrames");
593        self.params.duplicate_frames = base.find_param("NDPos_DuplicateFrames");
594        self.params.expected_id = base.find_param("NDPos_ExpectedID");
595        self.params.id_name = base.find_param("NDPos_IDName");
596        self.params.id_difference = base.find_param("NDPos_IDDifference");
597        self.params.id_start = base.find_param("NDPos_IDStart");
598
599        // C constructor defaults (NDPosPlugin.cpp:402-426).
600        if let Some(i) = self.params.mode {
601            base.set_int32_param(i, 0, self.mode as i32)?;
602        }
603        if let Some(i) = self.params.file_valid {
604            base.set_int32_param(i, 0, 0)?;
605        }
606        if let Some(i) = self.params.current_index {
607            base.set_int32_param(i, 0, 0)?;
608        }
609        if let Some(i) = self.params.current_qty {
610            base.set_int32_param(i, 0, self.remaining_positions() as i32)?;
611        }
612        if let Some(i) = self.params.current_pos {
613            base.set_string_param(i, 0, String::new())?;
614        }
615        if let Some(i) = self.params.running {
616            base.set_int32_param(i, 0, 0)?;
617        }
618        if let Some(i) = self.params.id_name {
619            base.set_string_param(i, 0, String::new())?;
620        }
621        if let Some(i) = self.params.id_difference {
622            base.set_int32_param(i, 0, 1)?;
623        }
624        if let Some(i) = self.params.id_start {
625            base.set_int32_param(i, 0, 1)?;
626        }
627        if let Some(i) = self.params.expected_id {
628            base.set_int32_param(i, 0, 1)?;
629        }
630        if let Some(i) = self.params.missing_frames {
631            base.set_int32_param(i, 0, 0)?;
632        }
633        if let Some(i) = self.params.duplicate_frames {
634            base.set_int32_param(i, 0, 0)?;
635        }
636        Ok(())
637    }
638
639    fn on_param_change(
640        &mut self,
641        reason: usize,
642        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
643    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
644        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamChangeValue};
645
646        let mut updates = Vec::new();
647        if Some(reason) == self.params.running {
648            // C writeInt32(NDPos_Running): start/stop and reset ExpectedID to
649            // IDStart (NDPosPlugin.cpp:230-234).
650            if params.value.as_i32() == 0 {
651                self.stop();
652            } else {
653                self.start();
654                push_int(&mut updates, self.params.expected_id, self.id_start);
655            }
656        } else if Some(reason) == self.params.id_start {
657            // C stores IDStart; it is read on the next Running write.
658            self.id_start = params.value.as_i32();
659        } else if Some(reason) == self.params.id_difference {
660            // C stores IDDifference; it is read each processCallbacks.
661            self.id_difference = params.value.as_i32();
662        } else if Some(reason) == self.params.mode {
663            // C writeInt32(NDPos_Mode): reset index to 0 (NDPosPlugin.cpp:235-237).
664            self.mode = match params.value.as_i32() {
665                1 => PosMode::Keep,
666                _ => PosMode::Discard,
667            };
668            self.index = 0;
669            push_int(&mut updates, self.params.current_index, 0);
670        } else if Some(reason) == self.params.restart {
671            // C writeInt32(NDPos_Restart): reset index, clear CurrentPos
672            // (NDPosPlugin.cpp:238-242).
673            self.index = 0;
674            push_int(&mut updates, self.params.current_index, 0);
675            push_str(&mut updates, self.params.current_pos, String::new());
676        } else if Some(reason) == self.params.delete {
677            // C writeInt32(NDPos_Delete): reset index, clear CurrentPos, clear
678            // positions, CurrentQty=0 (NDPosPlugin.cpp:243-250).
679            self.clear();
680            push_int(&mut updates, self.params.current_index, 0);
681            push_int(&mut updates, self.params.current_qty, 0);
682            push_str(&mut updates, self.params.current_pos, String::new());
683        } else if Some(reason) == self.params.filename {
684            // C writeOctet(NDPos_Filename): validate + load XML, set FileValid,
685            // append positions, set CurrentQty (NDPosPlugin.cpp:295-315).
686            if let ParamChangeValue::Octet(ref xml) = params.value {
687                match self.load_positions_auto(xml) {
688                    Ok(_) => {
689                        push_int(&mut updates, self.params.file_valid, 1);
690                        push_int(
691                            &mut updates,
692                            self.params.current_qty,
693                            self.remaining_positions() as i32,
694                        );
695                    }
696                    Err(_) => {
697                        push_int(&mut updates, self.params.file_valid, 0);
698                    }
699                }
700            }
701        }
702        ParamChangeResult::updates(updates)
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use ad_core_rs::ndarray::{NDDataType, NDDimension};
710
711    fn make_array(id: i32) -> NDArray {
712        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
713        arr.unique_id = id;
714        arr
715    }
716
717    #[test]
718    fn test_discard_mode() {
719        let mut proc = PosPluginProcessor::new(PosMode::Discard);
720        let mut pos1 = HashMap::new();
721        pos1.insert("X".into(), 1.5);
722        pos1.insert("Y".into(), 2.3);
723        let mut pos2 = HashMap::new();
724        pos2.insert("X".into(), 3.1);
725        pos2.insert("Y".into(), 4.2);
726
727        proc.load_positions(vec![pos1, pos2]);
728        proc.start();
729
730        let pool = NDArrayPool::new(1_000_000);
731
732        let result = proc.process_array(&make_array(1), &pool);
733        assert_eq!(result.output_arrays.len(), 1);
734        let x = result.output_arrays[0]
735            .attributes
736            .get("X")
737            .unwrap()
738            .value
739            .as_f64()
740            .unwrap();
741        assert!((x - 1.5).abs() < 1e-10);
742
743        let result = proc.process_array(&make_array(2), &pool);
744        let x = result.output_arrays[0]
745            .attributes
746            .get("X")
747            .unwrap()
748            .value
749            .as_f64()
750            .unwrap();
751        assert!((x - 3.1).abs() < 1e-10);
752
753        assert_eq!(proc.remaining_positions(), 0);
754    }
755
756    #[test]
757    fn test_attribute_description() {
758        // C NDPosPlugin.cpp:161 sets the attribute description "Position of NDArray".
759        let mut proc = PosPluginProcessor::new(PosMode::Discard);
760        let mut pos = HashMap::new();
761        pos.insert("X".into(), 1.5);
762        proc.load_positions(vec![pos]);
763        proc.start();
764
765        let pool = NDArrayPool::new(1_000_000);
766        let result = proc.process_array(&make_array(1), &pool);
767        let attr = result.output_arrays[0].attributes.get("X").unwrap();
768        assert_eq!(attr.description, "Position of NDArray");
769    }
770
771    #[test]
772    fn test_keep_mode() {
773        let mut proc = PosPluginProcessor::new(PosMode::Keep);
774        let mut pos1 = HashMap::new();
775        pos1.insert("X".into(), 10.0);
776        let mut pos2 = HashMap::new();
777        pos2.insert("X".into(), 20.0);
778
779        proc.load_positions(vec![pos1, pos2]);
780        proc.start();
781
782        let pool = NDArrayPool::new(1_000_000);
783
784        let result = proc.process_array(&make_array(1), &pool);
785        let x = result.output_arrays[0]
786            .attributes
787            .get("X")
788            .unwrap()
789            .value
790            .as_f64()
791            .unwrap();
792        assert!((x - 10.0).abs() < 1e-10);
793
794        let result = proc.process_array(&make_array(2), &pool);
795        let x = result.output_arrays[0]
796            .attributes
797            .get("X")
798            .unwrap()
799            .value
800            .as_f64()
801            .unwrap();
802        assert!((x - 20.0).abs() < 1e-10);
803
804        // Stops at end of list (no wrapping): the exhausted frame is dropped and
805        // the plugin goes idle (ADP-38).
806        let result = proc.process_array(&make_array(3), &pool);
807        assert!(result.output_arrays.is_empty());
808        assert!(!proc.running);
809    }
810
811    #[test]
812    fn test_exhaustion_stops_and_drops() {
813        // ADP-38: when positions run out, C sets Running=IDLE and emits no
814        // downstream callback (no bare frame forwarded).
815        let mut proc = PosPluginProcessor::new(PosMode::Discard);
816        proc.params.running = Some(5);
817        let mut p1 = HashMap::new();
818        p1.insert("x".into(), 1.0);
819        proc.load_positions(vec![p1]);
820        proc.start();
821
822        let pool = NDArrayPool::new(1_000_000);
823        // Frame 1 consumes the only position.
824        let r1 = proc.process_array(&make_array(1), &pool);
825        assert_eq!(r1.output_arrays.len(), 1);
826        // Frame 2 finds no positions: dropped, Running posted IDLE, plugin idle.
827        let r2 = proc.process_array(&make_array(2), &pool);
828        assert!(r2.output_arrays.is_empty());
829        assert!(!proc.running);
830        use ad_core_rs::plugin::runtime::ParamUpdate;
831        assert!(r2.param_updates.iter().any(|u| matches!(
832            u,
833            ParamUpdate::Int32 {
834                reason: 5,
835                value: 0,
836                ..
837            }
838        )));
839    }
840
841    #[test]
842    fn test_missing_frames() {
843        let mut proc = PosPluginProcessor::new(PosMode::Discard);
844        let mut pos1 = HashMap::new();
845        pos1.insert("X".into(), 1.0);
846        let mut pos2 = HashMap::new();
847        pos2.insert("X".into(), 2.0);
848        let mut pos3 = HashMap::new();
849        pos3.insert("X".into(), 3.0);
850
851        proc.load_positions(vec![pos1, pos2, pos3]);
852        proc.start();
853
854        let pool = NDArrayPool::new(1_000_000);
855
856        proc.process_array(&make_array(1), &pool);
857
858        // Frame 3 (skip frame 2)
859        let result = proc.process_array(&make_array(3), &pool);
860        assert_eq!(proc.missing_frames(), 1);
861        let x = result.output_arrays[0]
862            .attributes
863            .get("X")
864            .unwrap()
865            .value
866            .as_f64()
867            .unwrap();
868        assert!((x - 3.0).abs() < 1e-10);
869    }
870
871    #[test]
872    fn test_duplicate_frames() {
873        let mut proc = PosPluginProcessor::new(PosMode::Discard);
874        let mut pos1 = HashMap::new();
875        pos1.insert("X".into(), 1.0);
876        let mut pos2 = HashMap::new();
877        pos2.insert("X".into(), 2.0);
878
879        proc.load_positions(vec![pos1, pos2]);
880        proc.start();
881
882        let pool = NDArrayPool::new(1_000_000);
883
884        proc.process_array(&make_array(1), &pool);
885
886        let result = proc.process_array(&make_array(1), &pool);
887        assert_eq!(proc.duplicate_frames(), 1);
888        assert!(result.output_arrays.is_empty());
889    }
890
891    #[test]
892    fn test_load_json() {
893        let mut proc = PosPluginProcessor::new(PosMode::Discard);
894        let json = r#"{"positions": [{"X": 1.5, "Y": 2.3}, {"X": 3.1, "Y": 4.2}]}"#;
895        let count = proc.load_positions_json(json).unwrap();
896        assert_eq!(count, 2);
897        assert_eq!(proc.remaining_positions(), 2);
898    }
899
900    #[test]
901    fn test_idle_drops_frame() {
902        // ADP-35: when idle (not running) C emits no downstream callback; the
903        // frame is dropped, not passed through.
904        let mut proc = PosPluginProcessor::new(PosMode::Discard);
905        let pool = NDArrayPool::new(1_000_000);
906        let result = proc.process_array(&make_array(1), &pool);
907        assert!(result.output_arrays.is_empty());
908    }
909
910    #[test]
911    fn test_load_xml() {
912        let mut proc = PosPluginProcessor::new(PosMode::Discard);
913        let xml = r#"<pos_layout>
914  <dimensions>
915    <dimension name="x"/>
916  </dimensions>
917  <positions>
918    <position x="1.5"/>
919    <position x="2.3"/>
920    <position x="3.7"/>
921  </positions>
922</pos_layout>"#;
923        let count = proc.load_positions_xml(xml).unwrap();
924        assert_eq!(count, 3);
925        assert_eq!(proc.remaining_positions(), 3);
926    }
927
928    #[test]
929    fn test_load_xml_dimension_keyed() {
930        // C NDPosPluginFileReader keys each position attribute by dimension name
931        // and keeps positions in document order (no index sort).
932        let mut proc = PosPluginProcessor::new(PosMode::Discard);
933        let xml = r#"<pos_layout>
934  <dimensions>
935    <dimension name="x"/>
936    <dimension name="y"/>
937  </dimensions>
938  <positions>
939    <position x="10" y="100"/>
940    <position x="20" y="200"/>
941  </positions>
942</pos_layout>"#;
943        let count = proc.load_positions_xml(xml).unwrap();
944        assert_eq!(count, 2);
945
946        proc.start();
947        let pool = NDArrayPool::new(1_000_000);
948
949        let result = proc.process_array(&make_array(1), &pool);
950        let attrs = &result.output_arrays[0].attributes;
951        assert!((attrs.get("x").unwrap().value.as_f64().unwrap() - 10.0).abs() < 1e-10);
952        assert!((attrs.get("y").unwrap().value.as_f64().unwrap() - 100.0).abs() < 1e-10);
953
954        let result = proc.process_array(&make_array(2), &pool);
955        let attrs = &result.output_arrays[0].attributes;
956        assert!((attrs.get("x").unwrap().value.as_f64().unwrap() - 20.0).abs() < 1e-10);
957        assert!((attrs.get("y").unwrap().value.as_f64().unwrap() - 200.0).abs() < 1e-10);
958    }
959
960    #[test]
961    fn test_load_xml_rejects_incomplete_position() {
962        // A position missing a declared dimension's attribute is rejected whole
963        // (C addPosition returns asynError), matching the per-position drop.
964        let mut proc = PosPluginProcessor::new(PosMode::Discard);
965        let xml = r#"<pos_layout>
966  <dimensions>
967    <dimension name="x"/>
968    <dimension name="y"/>
969  </dimensions>
970  <positions>
971    <position x="1" y="2"/>
972    <position x="3"/>
973    <position x="5" y="6"/>
974  </positions>
975</pos_layout>"#;
976        let count = proc.load_positions_xml(xml).unwrap();
977        assert_eq!(count, 2);
978    }
979
980    #[test]
981    fn test_load_auto_json() {
982        let mut proc = PosPluginProcessor::new(PosMode::Discard);
983        let json = r#"{"positions": [{"X": 1.5}]}"#;
984        let count = proc.load_positions_auto(json).unwrap();
985        assert_eq!(count, 1);
986    }
987
988    #[test]
989    fn test_load_auto_xml() {
990        let mut proc = PosPluginProcessor::new(PosMode::Discard);
991        let xml = r#"<pos_layout><dimensions><dimension name="x"/></dimensions><positions><position x="99.9"/></positions></pos_layout>"#;
992        let count = proc.load_positions_auto(xml).unwrap();
993        assert_eq!(count, 1);
994    }
995
996    #[test]
997    fn test_load_xml_empty() {
998        let mut proc = PosPluginProcessor::new(PosMode::Discard);
999        let xml = r#"<pos_layout><dimensions><dimension name="x"/></dimensions><positions></positions></pos_layout>"#;
1000        let count = proc.load_positions_xml(xml).unwrap();
1001        assert_eq!(count, 0);
1002    }
1003
1004    #[test]
1005    fn test_param_posts_to_registered_indices() {
1006        // ADP-33: posts land on the registered MissingFrames/DuplicateFrames/
1007        // CurrentQty indices, never the old hardcoded 0/1.
1008        use ad_core_rs::plugin::runtime::ParamUpdate;
1009        let mut proc = PosPluginProcessor::new(PosMode::Discard);
1010        proc.params.missing_frames = Some(20);
1011        proc.params.duplicate_frames = Some(21);
1012        proc.params.current_qty = Some(22);
1013
1014        let mut p1 = HashMap::new();
1015        p1.insert("x".into(), 1.0);
1016        let mut p2 = HashMap::new();
1017        p2.insert("x".into(), 2.0);
1018        proc.load_positions(vec![p1, p2]);
1019        proc.start();
1020
1021        let pool = NDArrayPool::new(1_000_000);
1022        let result = proc.process_array(&make_array(1), &pool);
1023        // CurrentQty drops to 1 remaining after consuming the first position.
1024        assert!(
1025            result
1026                .param_updates
1027                .iter()
1028                .any(|u| matches!(u, ParamUpdate::Int32 { reason: 20, .. }))
1029        );
1030        assert!(result.param_updates.iter().any(|u| matches!(
1031            u,
1032            ParamUpdate::Int32 {
1033                reason: 22,
1034                value: 1,
1035                ..
1036            }
1037        )));
1038        assert!(
1039            !result
1040                .param_updates
1041                .iter()
1042                .any(|u| matches!(u, ParamUpdate::Int32 { reason: 0, .. }))
1043        );
1044    }
1045
1046    #[test]
1047    fn test_filename_param_loads_positions() {
1048        // ADP-33: writing NDPos_Filename loads the XML, posts FileValid=1 + CurrentQty.
1049        use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1050        let mut proc = PosPluginProcessor::new(PosMode::Discard);
1051        proc.params.filename = Some(0);
1052        proc.params.file_valid = Some(1);
1053        proc.params.current_qty = Some(8);
1054
1055        let xml = r#"<pos_layout><dimensions><dimension name="x"/></dimensions><positions><position x="1"/><position x="2"/></positions></pos_layout>"#;
1056        let snapshot = PluginParamSnapshot {
1057            enable_callbacks: true,
1058            reason: 0,
1059            addr: 0,
1060            value: ParamChangeValue::Octet(xml.to_string()),
1061        };
1062        let result = proc.on_param_change(0, &snapshot);
1063        assert_eq!(proc.remaining_positions(), 2);
1064        assert!(result.param_updates.iter().any(|u| matches!(
1065            u,
1066            ParamUpdate::Int32 {
1067                reason: 1,
1068                value: 1,
1069                ..
1070            }
1071        )));
1072        assert!(result.param_updates.iter().any(|u| matches!(
1073            u,
1074            ParamUpdate::Int32 {
1075                reason: 8,
1076                value: 2,
1077                ..
1078            }
1079        )));
1080    }
1081
1082    #[test]
1083    fn test_running_param_starts_and_stops() {
1084        // ADP-33: writing NDPos_Running routes to start()/stop().
1085        use ad_core_rs::plugin::runtime::{ParamChangeValue, PluginParamSnapshot};
1086        let mut proc = PosPluginProcessor::new(PosMode::Discard);
1087        proc.params.running = Some(3);
1088
1089        let start = PluginParamSnapshot {
1090            enable_callbacks: true,
1091            reason: 3,
1092            addr: 0,
1093            value: ParamChangeValue::Int32(1),
1094        };
1095        proc.on_param_change(3, &start);
1096        assert!(proc.running);
1097
1098        let stop = PluginParamSnapshot {
1099            enable_callbacks: true,
1100            reason: 3,
1101            addr: 0,
1102            value: ParamChangeValue::Int32(0),
1103        };
1104        proc.on_param_change(3, &stop);
1105        assert!(!proc.running);
1106    }
1107
1108    #[test]
1109    fn test_current_pos_string_posted() {
1110        // ADP-32: process_array posts NDPos_CurrentPos as "[k=v,...]" in sorted
1111        // key order (C std::map), C++ %g(6) value formatting.
1112        use ad_core_rs::plugin::runtime::ParamUpdate;
1113        let mut proc = PosPluginProcessor::new(PosMode::Discard);
1114        proc.params.current_pos = Some(30);
1115
1116        let mut p = HashMap::new();
1117        p.insert("y".into(), 2.0);
1118        p.insert("x".into(), 1.5);
1119        proc.load_positions(vec![p]);
1120        proc.start();
1121
1122        let pool = NDArrayPool::new(1_000_000);
1123        let result = proc.process_array(&make_array(1), &pool);
1124        let s = result.param_updates.iter().find_map(|u| match u {
1125            ParamUpdate::Octet {
1126                reason: 30, value, ..
1127            } => Some(value.clone()),
1128            _ => None,
1129        });
1130        assert_eq!(s.as_deref(), Some("[x=1.5,y=2]"));
1131    }
1132
1133    #[test]
1134    fn test_first_frame_id_checked() {
1135        // ADP-37: ExpectedID starts at IDStart (1); a first running frame whose
1136        // uniqueId != IDStart is classified immediately (here frames 1,2 are
1137        // missing), not silently accepted.
1138        let mut proc = PosPluginProcessor::new(PosMode::Discard);
1139        let mut p1 = HashMap::new();
1140        p1.insert("x".into(), 1.0);
1141        let mut p2 = HashMap::new();
1142        p2.insert("x".into(), 2.0);
1143        let mut p3 = HashMap::new();
1144        p3.insert("x".into(), 3.0);
1145        proc.load_positions(vec![p1, p2, p3]);
1146        proc.start();
1147
1148        let pool = NDArrayPool::new(1_000_000);
1149        // First frame arrives as uniqueId 3 → frames 1 and 2 counted missing.
1150        let result = proc.process_array(&make_array(3), &pool);
1151        assert_eq!(proc.missing_frames(), 2);
1152        let x = result.output_arrays[0]
1153            .attributes
1154            .get("x")
1155            .unwrap()
1156            .value
1157            .as_f64()
1158            .unwrap();
1159        assert!((x - 3.0).abs() < 1e-10);
1160    }
1161
1162    #[test]
1163    fn test_id_difference_stepping() {
1164        // ADP-36: ExpectedID steps by IDDifference and is never re-anchored to
1165        // uniqueId. With step 2, frames 1/3/5 are all on-sequence (no missing).
1166        let mut proc = PosPluginProcessor::new(PosMode::Discard);
1167        proc.id_difference = 2;
1168        let mut p1 = HashMap::new();
1169        p1.insert("x".into(), 1.0);
1170        let mut p2 = HashMap::new();
1171        p2.insert("x".into(), 2.0);
1172        let mut p3 = HashMap::new();
1173        p3.insert("x".into(), 3.0);
1174        proc.load_positions(vec![p1, p2, p3]);
1175        proc.start();
1176
1177        let pool = NDArrayPool::new(1_000_000);
1178        proc.process_array(&make_array(1), &pool);
1179        proc.process_array(&make_array(3), &pool);
1180        let r = proc.process_array(&make_array(5), &pool);
1181        assert_eq!(proc.missing_frames(), 0);
1182        let x = r.output_arrays[0]
1183            .attributes
1184            .get("x")
1185            .unwrap()
1186            .value
1187            .as_f64()
1188            .unwrap();
1189        assert!((x - 3.0).abs() < 1e-10);
1190    }
1191
1192    #[test]
1193    fn test_format_cpp_g6() {
1194        // Matches C printf("%g") / C++ default ostream<<double (precision 6).
1195        assert_eq!(format_cpp_g6(1.0), "1");
1196        assert_eq!(format_cpp_g6(1.5), "1.5");
1197        assert_eq!(format_cpp_g6(42.5), "42.5");
1198        assert_eq!(format_cpp_g6(100.0), "100");
1199        assert_eq!(format_cpp_g6(0.1), "0.1");
1200        assert_eq!(format_cpp_g6(0.0001), "0.0001");
1201        assert_eq!(format_cpp_g6(0.00001), "1e-05");
1202        assert_eq!(format_cpp_g6(1_000_000.0), "1e+06");
1203        assert_eq!(format_cpp_g6(1_234_567.0), "1.23457e+06");
1204        assert_eq!(format_cpp_g6(123456.0), "123456");
1205        assert_eq!(format_cpp_g6(-1.5), "-1.5");
1206        assert_eq!(format_cpp_g6(0.0), "0");
1207    }
1208}