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