Skip to main content

ad_plugins_rs/
stats.rs

1// RTEMS-EXEC-MODEL-ALLOW(1): checked, not waived — all 1 ran and passed
2// on the exec backend (measured on this tree:
3// `EPICS_RS_BUILD_EXEC_BACKEND=thread cargo nextest run -p ad-plugins-rs
4// --all-features`, 556/556). ad-plugins-rs became a census subject when
5// its `build.rs` began deriving `tokio_backend`; nothing here builds a
6// CA server, and the reactor these obtain comes from `#[tokio::test]`
7// itself, which the backend does not remove.
8use std::sync::Arc;
9
10// Not gated on `parallel`: `should_parallelize` is the whole decision now
11// and this file asks it on both arms.
12use crate::par_util;
13#[cfg(feature = "parallel")]
14use rayon::prelude::*;
15
16use ad_core_rs::ndarray::{NDArray, NDDataBuffer};
17use ad_core_rs::ndarray_pool::NDArrayPool;
18use ad_core_rs::plugin::runtime::{
19    NDPluginProcess, ParamUpdate, PluginParamSnapshot, PluginRuntimeHandle, ProcessResult,
20};
21use ad_core_rs::plugin::wiring::WiringRegistry;
22use asyn_rs::param::ParamType;
23use asyn_rs::port::PortDriverBase;
24use parking_lot::Mutex;
25
26/// Parameter indices for NDStats plugin-specific params.
27#[derive(Clone, Copy, Default)]
28pub struct NDStatsParams {
29    pub compute_statistics: usize,
30    pub bgd_width: usize,
31    pub min_value: usize,
32    pub max_value: usize,
33    pub mean_value: usize,
34    pub sigma_value: usize,
35    pub total: usize,
36    pub net: usize,
37    pub min_x: usize,
38    pub min_y: usize,
39    pub max_x: usize,
40    pub max_y: usize,
41    pub compute_centroid: usize,
42    pub centroid_threshold: usize,
43    pub centroid_total: usize,
44    pub centroid_x: usize,
45    pub centroid_y: usize,
46    pub sigma_x: usize,
47    pub sigma_y: usize,
48    pub sigma_xy: usize,
49    pub skewness_x: usize,
50    pub skewness_y: usize,
51    pub kurtosis_x: usize,
52    pub kurtosis_y: usize,
53    pub eccentricity: usize,
54    pub orientation: usize,
55    pub compute_histogram: usize,
56    pub hist_size: usize,
57    pub hist_min: usize,
58    pub hist_max: usize,
59    pub hist_below: usize,
60    pub hist_above: usize,
61    pub hist_entropy: usize,
62    pub compute_profiles: usize,
63    pub cursor_x: usize,
64    pub cursor_y: usize,
65    pub cursor_val: usize,
66    pub profile_size_x: usize,
67    pub profile_size_y: usize,
68    pub skewx_value: usize,
69    pub skewy_value: usize,
70    pub profile_average_x: usize,
71    pub profile_average_y: usize,
72    pub profile_threshold_x: usize,
73    pub profile_threshold_y: usize,
74    pub profile_centroid_x: usize,
75    pub profile_centroid_y: usize,
76    pub profile_cursor_x: usize,
77    pub profile_cursor_y: usize,
78    pub hist_array: usize,
79    pub hist_x_array: usize,
80}
81
82/// Statistics computed from an NDArray.
83#[derive(Debug, Clone, Default)]
84pub struct StatsResult {
85    pub min: f64,
86    pub max: f64,
87    pub mean: f64,
88    pub sigma: f64,
89    pub total: f64,
90    pub net: f64,
91    pub num_elements: usize,
92    pub min_x: usize,
93    pub min_y: usize,
94    pub max_x: usize,
95    pub max_y: usize,
96    pub histogram: Vec<f64>,
97    pub hist_below: f64,
98    pub hist_above: f64,
99    pub hist_entropy: f64,
100    pub profile_avg_x: Vec<f64>,
101    pub profile_avg_y: Vec<f64>,
102    pub profile_threshold_x: Vec<f64>,
103    pub profile_threshold_y: Vec<f64>,
104    pub profile_centroid_x: Vec<f64>,
105    pub profile_centroid_y: Vec<f64>,
106    pub profile_cursor_x: Vec<f64>,
107    pub profile_cursor_y: Vec<f64>,
108    pub cursor_value: f64,
109}
110
111/// Centroid and higher-order moment results.
112#[derive(Debug, Clone, Default)]
113pub struct CentroidResult {
114    pub centroid_x: f64,
115    pub centroid_y: f64,
116    pub sigma_x: f64,
117    pub sigma_y: f64,
118    pub sigma_xy: f64,
119    pub centroid_total: f64,
120    pub skewness_x: f64,
121    pub skewness_y: f64,
122    pub kurtosis_x: f64,
123    pub kurtosis_y: f64,
124    pub eccentricity: f64,
125    pub orientation: f64,
126}
127
128/// Profile computation results.
129#[derive(Debug, Clone, Default)]
130pub struct ProfileResult {
131    pub avg_x: Vec<f64>,
132    pub avg_y: Vec<f64>,
133    pub threshold_x: Vec<f64>,
134    pub threshold_y: Vec<f64>,
135    pub centroid_x: Vec<f64>,
136    pub centroid_y: Vec<f64>,
137    pub cursor_x: Vec<f64>,
138    pub cursor_y: Vec<f64>,
139}
140
141/// Compute min/max/mean/sigma/total from an NDDataBuffer, with min/max positions
142/// and optional background subtraction.
143///
144/// When `bgd_width > 0`, the background is computed as N-dimensional edge
145/// strips (per dimension a low-edge and a high-edge strip, each spanning the
146/// full extent of the other dimensions) exactly as C++ `NDPluginStats`
147/// `doComputeStatistics` does — corner pixels are counted twice. The
148/// per-pixel average background is subtracted: `net = total - bgd_avg *
149/// num_elements`, where `bgd_avg = bgd_counts / bgd_pixels`. This works for
150/// any dimensionality (1-D, 2-D, 3-D+). When `bgd_width == 0`, `net = total`.
151pub fn compute_stats(
152    data: &NDDataBuffer,
153    dims: &[ad_core_rs::ndarray::NDDimension],
154    bgd_width: usize,
155) -> StatsResult {
156    macro_rules! stats_for {
157        ($vec:expr) => {{
158            let v = $vec;
159            if v.is_empty() {
160                return StatsResult::default();
161            }
162
163            let (min, max, min_idx, max_idx, total, variance);
164
165            #[cfg(feature = "parallel")]
166            {
167                if par_util::should_parallelize(v.len()) {
168                    // Parallel: fold+reduce for min/max/total
169                    let (pmin, pmax, pmin_idx, pmax_idx, ptotal) =
170                        par_util::thread_pool().install(|| {
171                            v.par_iter()
172                                .enumerate()
173                                .fold(
174                                    || (f64::MAX, f64::MIN, 0usize, 0usize, 0.0f64),
175                                    |(mn, mx, mn_i, mx_i, s), (i, &elem)| {
176                                        let f = elem as f64;
177                                        let (new_mn, new_mn_i) =
178                                            if f < mn { (f, i) } else { (mn, mn_i) };
179                                        let (new_mx, new_mx_i) =
180                                            if f > mx { (f, i) } else { (mx, mx_i) };
181                                        (new_mn, new_mx, new_mn_i, new_mx_i, s + f)
182                                    },
183                                )
184                                .reduce(
185                                    || (f64::MAX, f64::MIN, 0, 0, 0.0),
186                                    |(mn1, mx1, mn_i1, mx_i1, s1), (mn2, mx2, mn_i2, mx_i2, s2)| {
187                                        let (rmn, rmn_i) = if mn1 <= mn2 {
188                                            (mn1, mn_i1)
189                                        } else {
190                                            (mn2, mn_i2)
191                                        };
192                                        let (rmx, rmx_i) = if mx1 >= mx2 {
193                                            (mx1, mx_i1)
194                                        } else {
195                                            (mx2, mx_i2)
196                                        };
197                                        (rmn, rmx, rmn_i, rmx_i, s1 + s2)
198                                    },
199                                )
200                        });
201                    min = pmin;
202                    max = pmax;
203                    min_idx = pmin_idx;
204                    max_idx = pmax_idx;
205                    total = ptotal;
206                    let mean_tmp = total / v.len() as f64;
207                    variance = par_util::thread_pool().install(|| {
208                        v.par_iter()
209                            .map(|&elem| {
210                                let d = elem as f64 - mean_tmp;
211                                d * d
212                            })
213                            .sum::<f64>()
214                    });
215                } else {
216                    let mut lmin = v[0] as f64;
217                    let mut lmax = v[0] as f64;
218                    let mut lmin_idx: usize = 0;
219                    let mut lmax_idx: usize = 0;
220                    let mut ltotal = 0.0f64;
221                    for (i, &elem) in v.iter().enumerate() {
222                        let f = elem as f64;
223                        if f < lmin {
224                            lmin = f;
225                            lmin_idx = i;
226                        }
227                        if f > lmax {
228                            lmax = f;
229                            lmax_idx = i;
230                        }
231                        ltotal += f;
232                    }
233                    min = lmin;
234                    max = lmax;
235                    min_idx = lmin_idx;
236                    max_idx = lmax_idx;
237                    total = ltotal;
238                    let mean_tmp = total / v.len() as f64;
239                    let mut lvar = 0.0f64;
240                    for &elem in v.iter() {
241                        let d = elem as f64 - mean_tmp;
242                        lvar += d * d;
243                    }
244                    variance = lvar;
245                }
246            }
247
248            #[cfg(not(feature = "parallel"))]
249            {
250                let mut lmin = v[0] as f64;
251                let mut lmax = v[0] as f64;
252                let mut lmin_idx: usize = 0;
253                let mut lmax_idx: usize = 0;
254                let mut ltotal = 0.0f64;
255                for (i, &elem) in v.iter().enumerate() {
256                    let f = elem as f64;
257                    if f < lmin {
258                        lmin = f;
259                        lmin_idx = i;
260                    }
261                    if f > lmax {
262                        lmax = f;
263                        lmax_idx = i;
264                    }
265                    ltotal += f;
266                }
267                min = lmin;
268                max = lmax;
269                min_idx = lmin_idx;
270                max_idx = lmax_idx;
271                total = ltotal;
272                let mean_tmp = total / v.len() as f64;
273                let mut lvar = 0.0f64;
274                for &elem in v.iter() {
275                    let d = elem as f64 - mean_tmp;
276                    lvar += d * d;
277                }
278                variance = lvar;
279            }
280
281            let mean = total / v.len() as f64;
282            let sigma = (variance / v.len() as f64).sqrt();
283            let x_size = dims.first().map_or(v.len(), |d| d.size);
284
285            // Background subtraction.
286            //
287            // C parity: NDPluginStats.cpp:488-530 `doComputeStatistics` background
288            // section. The background is the union of, per dimension, a low-edge
289            // strip and a high-edge strip (each spanning the full extent of every
290            // other dimension). Strip totals/pixel-counts are SUMMED, so pixels in
291            // the corner of multiple strips are counted twice in both `bgdCounts`
292            // and `bgdPixels` — the C++ source documents this as intentional
293            // (NDPluginStats.cpp:484-487). Works for any dimensionality (1-D,
294            // 2-D, 3-D+).
295            let net = if bgd_width > 0 && !dims.is_empty() {
296                let sizes: Vec<usize> = dims.iter().map(|d| d.size).collect();
297                // Row-major strides: dim 0 varies fastest (matches the x_size /
298                // y_size index math used above).
299                let ndims = sizes.len();
300                let mut strides = vec![1usize; ndims];
301                for i in 1..ndims {
302                    strides[i] = strides[i - 1] * sizes[i - 1];
303                }
304
305                // Sum a strip: dimension `sd` restricted to [s_off, s_off+s_len),
306                // every other dimension spanning its full extent. Returns
307                // (sum, pixel_count).
308                let strip = |sd: usize, s_off: usize, s_len: usize| -> (f64, usize) {
309                    if s_len == 0 {
310                        return (0.0, 0);
311                    }
312                    // Number of pixels in the strip = s_len * product of other dims.
313                    let mut count = s_len;
314                    for (d, &sz) in sizes.iter().enumerate() {
315                        if d != sd {
316                            count *= sz;
317                        }
318                    }
319                    let mut sum = 0.0f64;
320                    // Iterate over every flat coordinate in the strip by counting
321                    // through per-dimension coordinates.
322                    let mut coords = vec![0usize; ndims];
323                    for _ in 0..count {
324                        let mut flat = 0usize;
325                        for d in 0..ndims {
326                            let c = if d == sd {
327                                coords[d] + s_off
328                            } else {
329                                coords[d]
330                            };
331                            flat += c * strides[d];
332                        }
333                        if flat < v.len() {
334                            sum += v[flat] as f64;
335                        }
336                        // Increment the mixed-radix coordinate counter. The radix
337                        // for the strip dimension is `s_len`; for others it is the
338                        // full dimension size.
339                        for d in 0..ndims {
340                            let radix = if d == sd { s_len } else { sizes[d] };
341                            coords[d] += 1;
342                            if coords[d] < radix {
343                                break;
344                            }
345                            coords[d] = 0;
346                        }
347                    }
348                    (sum, count)
349                };
350
351                let mut bgd_counts = 0.0f64;
352                let mut bgd_pixels = 0usize;
353                for (d, &dim_size) in sizes.iter().enumerate() {
354                    // Low-edge strip: offset 0, size min(bgd_width, dim_size).
355                    let low_len = bgd_width.min(dim_size);
356                    let (low_sum, low_n) = strip(d, 0, low_len);
357                    bgd_counts += low_sum;
358                    bgd_pixels += low_n;
359                    // High-edge strip: offset max(0, dim_size - bgd_width),
360                    // size min(bgd_width, dim_size - offset).
361                    let high_off = dim_size.saturating_sub(bgd_width);
362                    let high_len = bgd_width.min(dim_size - high_off);
363                    let (high_sum, high_n) = strip(d, high_off, high_len);
364                    bgd_counts += high_sum;
365                    bgd_pixels += high_n;
366                }
367                // C parity: NDPluginStats.cpp:527 — `if (bgdPixels < 1) bgdPixels = 1`.
368                let bgd_avg = bgd_counts / bgd_pixels.max(1) as f64;
369                total - bgd_avg * v.len() as f64
370            } else {
371                total
372            };
373
374            StatsResult {
375                min,
376                max,
377                mean,
378                sigma,
379                total,
380                net,
381                num_elements: v.len(),
382                min_x: if x_size > 0 { min_idx % x_size } else { 0 },
383                min_y: if x_size > 0 { min_idx / x_size } else { 0 },
384                max_x: if x_size > 0 { max_idx % x_size } else { 0 },
385                max_y: if x_size > 0 { max_idx / x_size } else { 0 },
386                ..StatsResult::default()
387            }
388        }};
389    }
390
391    match data {
392        NDDataBuffer::I8(v) => stats_for!(v),
393        NDDataBuffer::U8(v) => stats_for!(v),
394        NDDataBuffer::I16(v) => stats_for!(v),
395        NDDataBuffer::U16(v) => stats_for!(v),
396        NDDataBuffer::I32(v) => stats_for!(v),
397        NDDataBuffer::U32(v) => stats_for!(v),
398        NDDataBuffer::I64(v) => stats_for!(v),
399        NDDataBuffer::U64(v) => stats_for!(v),
400        NDDataBuffer::F32(v) => stats_for!(v),
401        NDDataBuffer::F64(v) => stats_for!(v),
402    }
403}
404
405/// Compute centroid, sigma, and higher-order moments for a 2D array.
406///
407/// Pixels with value < `threshold` are excluded from all moment accumulation.
408pub fn compute_centroid(
409    data: &NDDataBuffer,
410    x_size: usize,
411    y_size: usize,
412    threshold: f64,
413) -> CentroidResult {
414    let n = x_size * y_size;
415    if n == 0 || data.len() < n {
416        return CentroidResult::default();
417    }
418
419    // Collect values into a flat f64 vec for potential parallel access
420    let vals: Vec<f64> = (0..n).map(|i| data.get_as_f64(i).unwrap_or(0.0)).collect();
421
422    // Pass 1: compute M00 (total), M10, M01 for centroid
423    let (m00, m10, m01);
424
425    #[cfg(feature = "parallel")]
426    {
427        if par_util::should_parallelize(n) {
428            let xs = x_size;
429            let thr = threshold;
430            let (pm00, pm10, pm01) = par_util::thread_pool().install(|| {
431                vals.par_iter()
432                    .enumerate()
433                    .fold(
434                        || (0.0f64, 0.0f64, 0.0f64),
435                        |(s00, s10, s01), (i, &val)| {
436                            if val < thr {
437                                return (s00, s10, s01);
438                            }
439                            let ix = i % xs;
440                            let iy = i / xs;
441                            (s00 + val, s10 + val * ix as f64, s01 + val * iy as f64)
442                        },
443                    )
444                    .reduce(
445                        || (0.0, 0.0, 0.0),
446                        |(a0, a1, a2), (b0, b1, b2)| (a0 + b0, a1 + b1, a2 + b2),
447                    )
448            });
449            m00 = pm00;
450            m10 = pm10;
451            m01 = pm01;
452        } else {
453            let mut lm00 = 0.0f64;
454            let mut lm10 = 0.0f64;
455            let mut lm01 = 0.0f64;
456            for iy in 0..y_size {
457                for ix in 0..x_size {
458                    let val = vals[iy * x_size + ix];
459                    if val < threshold {
460                        continue;
461                    }
462                    lm00 += val;
463                    lm10 += val * ix as f64;
464                    lm01 += val * iy as f64;
465                }
466            }
467            m00 = lm00;
468            m10 = lm10;
469            m01 = lm01;
470        }
471    }
472
473    #[cfg(not(feature = "parallel"))]
474    {
475        let mut lm00 = 0.0f64;
476        let mut lm10 = 0.0f64;
477        let mut lm01 = 0.0f64;
478        for iy in 0..y_size {
479            for ix in 0..x_size {
480                let val = vals[iy * x_size + ix];
481                if val < threshold {
482                    continue;
483                }
484                lm00 += val;
485                lm10 += val * ix as f64;
486                lm01 += val * iy as f64;
487            }
488        }
489        m00 = lm00;
490        m10 = lm10;
491        m01 = lm01;
492    }
493
494    if m00 == 0.0 {
495        return CentroidResult::default();
496    }
497
498    let cx = m10 / m00;
499    let cy = m01 / m00;
500
501    // Pass 2: compute central moments up to 4th order
502    let (mu20, mu02, mu11, m30_central, m03_central, m40_central, m04_central);
503
504    #[cfg(feature = "parallel")]
505    {
506        if par_util::should_parallelize(n) {
507            let xs = x_size;
508            let thr = threshold;
509            let (p20, p02, p11, p30, p03, p40, p04) = par_util::thread_pool().install(|| {
510                vals.par_iter()
511                    .enumerate()
512                    .fold(
513                        || (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64),
514                        |(s20, s02, s11, s30, s03, s40, s04), (i, &val)| {
515                            if val < thr {
516                                return (s20, s02, s11, s30, s03, s40, s04);
517                            }
518                            let ix = i % xs;
519                            let iy = i / xs;
520                            let dx = ix as f64 - cx;
521                            let dy = iy as f64 - cy;
522                            let dx2 = dx * dx;
523                            let dy2 = dy * dy;
524                            (
525                                s20 + val * dx2,
526                                s02 + val * dy2,
527                                s11 + val * dx * dy,
528                                s30 + val * dx2 * dx,
529                                s03 + val * dy2 * dy,
530                                s40 + val * dx2 * dx2,
531                                s04 + val * dy2 * dy2,
532                            )
533                        },
534                    )
535                    .reduce(
536                        || (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
537                        |(a0, a1, a2, a3, a4, a5, a6), (b0, b1, b2, b3, b4, b5, b6)| {
538                            (
539                                a0 + b0,
540                                a1 + b1,
541                                a2 + b2,
542                                a3 + b3,
543                                a4 + b4,
544                                a5 + b5,
545                                a6 + b6,
546                            )
547                        },
548                    )
549            });
550            mu20 = p20;
551            mu02 = p02;
552            mu11 = p11;
553            m30_central = p30;
554            m03_central = p03;
555            m40_central = p40;
556            m04_central = p04;
557        } else {
558            let mut l20 = 0.0f64;
559            let mut l02 = 0.0f64;
560            let mut l11 = 0.0f64;
561            let mut l30 = 0.0f64;
562            let mut l03 = 0.0f64;
563            let mut l40 = 0.0f64;
564            let mut l04 = 0.0f64;
565            for iy in 0..y_size {
566                for ix in 0..x_size {
567                    let val = vals[iy * x_size + ix];
568                    if val < threshold {
569                        continue;
570                    }
571                    let dx = ix as f64 - cx;
572                    let dy = iy as f64 - cy;
573                    let dx2 = dx * dx;
574                    let dy2 = dy * dy;
575                    l20 += val * dx2;
576                    l02 += val * dy2;
577                    l11 += val * dx * dy;
578                    l30 += val * dx2 * dx;
579                    l03 += val * dy2 * dy;
580                    l40 += val * dx2 * dx2;
581                    l04 += val * dy2 * dy2;
582                }
583            }
584            mu20 = l20;
585            mu02 = l02;
586            mu11 = l11;
587            m30_central = l30;
588            m03_central = l03;
589            m40_central = l40;
590            m04_central = l04;
591        }
592    }
593
594    #[cfg(not(feature = "parallel"))]
595    {
596        let mut l20 = 0.0f64;
597        let mut l02 = 0.0f64;
598        let mut l11 = 0.0f64;
599        let mut l30 = 0.0f64;
600        let mut l03 = 0.0f64;
601        let mut l40 = 0.0f64;
602        let mut l04 = 0.0f64;
603        for iy in 0..y_size {
604            for ix in 0..x_size {
605                let val = vals[iy * x_size + ix];
606                if val < threshold {
607                    continue;
608                }
609                let dx = ix as f64 - cx;
610                let dy = iy as f64 - cy;
611                let dx2 = dx * dx;
612                let dy2 = dy * dy;
613                l20 += val * dx2;
614                l02 += val * dy2;
615                l11 += val * dx * dy;
616                l30 += val * dx2 * dx;
617                l03 += val * dy2 * dy;
618                l40 += val * dx2 * dx2;
619                l04 += val * dy2 * dy2;
620            }
621        }
622        mu20 = l20;
623        mu02 = l02;
624        mu11 = l11;
625        m30_central = l30;
626        m03_central = l03;
627        m40_central = l40;
628        m04_central = l04;
629    }
630
631    let sigma_x = (mu20 / m00).sqrt();
632    let sigma_y = (mu02 / m00).sqrt();
633    let sigma_xy = if sigma_x > 0.0 && sigma_y > 0.0 {
634        (mu11 / m00) / (sigma_x * sigma_y)
635    } else {
636        0.0
637    };
638
639    // Skewness: M30_central / (M00 * sigma_x^3)
640    let skewness_x = if sigma_x > 0.0 {
641        m30_central / (m00 * sigma_x.powi(3))
642    } else {
643        0.0
644    };
645    let skewness_y = if sigma_y > 0.0 {
646        m03_central / (m00 * sigma_y.powi(3))
647    } else {
648        0.0
649    };
650
651    // Excess kurtosis: M40_central / (M00 * sigma_x^4) - 3
652    let kurtosis_x = if sigma_x > 0.0 {
653        m40_central / (m00 * sigma_x.powi(4)) - 3.0
654    } else {
655        0.0
656    };
657    let kurtosis_y = if sigma_y > 0.0 {
658        m04_central / (m00 * sigma_y.powi(4)) - 3.0
659    } else {
660        0.0
661    };
662
663    // Eccentricity: ((mu20 - mu02)^2 - 4*mu11^2) / (mu20 + mu02)^2
664    // Uses un-normalized central moments (normalization cancels in the ratio)
665    let denom = mu20 + mu02;
666    let eccentricity = if denom > 0.0 {
667        ((mu20 - mu02).powi(2) - 4.0 * mu11.powi(2)) / denom.powi(2)
668    } else {
669        0.0
670    };
671
672    // Orientation: 0.5 * atan2(2*mu11, mu20 - mu02) in degrees
673    let orientation = 0.5 * (2.0 * mu11).atan2(mu20 - mu02) * 180.0 / std::f64::consts::PI;
674
675    CentroidResult {
676        centroid_x: cx,
677        centroid_y: cy,
678        sigma_x,
679        sigma_y,
680        sigma_xy,
681        centroid_total: m00,
682        skewness_x,
683        skewness_y,
684        kurtosis_x,
685        kurtosis_y,
686        eccentricity,
687        orientation,
688    }
689}
690
691/// Compute histogram of pixel values.
692///
693/// Returns (histogram, below_count, above_count, entropy).
694/// - `hist_size`: number of bins
695/// - `hist_min` / `hist_max`: value range for binning
696/// - bin index = `((val - hist_min) * (hist_size - 1) / (hist_max - hist_min) + 0.5) as usize`
697/// - Values below `hist_min` increment `below_count`; above `hist_max` increment `above_count`
698/// - Entropy = `-sum(p * ln(p))` for non-zero bins where `p = count / total_count`
699pub fn compute_histogram(
700    data: &NDDataBuffer,
701    hist_size: usize,
702    hist_min: f64,
703    hist_max: f64,
704) -> (Vec<f64>, f64, f64, f64) {
705    if hist_size == 0 || hist_max <= hist_min {
706        return (vec![], 0.0, 0.0, 0.0);
707    }
708
709    let mut histogram = vec![0.0f64; hist_size];
710    let mut below = 0.0f64;
711    let mut above = 0.0f64;
712    let range = hist_max - hist_min;
713    let n = data.len();
714
715    let use_parallel = par_util::should_parallelize(n);
716
717    if use_parallel {
718        #[cfg(feature = "parallel")]
719        {
720            let vals: Vec<f64> = (0..n).map(|i| data.get_as_f64(i).unwrap_or(0.0)).collect();
721            let chunk_size = (n / rayon::current_num_threads().max(1)).max(1024);
722            let hs = hist_size;
723            let hmin = hist_min;
724            let hmax = hist_max;
725            let rng = range;
726            let chunk_results: Vec<(Vec<f64>, f64, f64)> = par_util::thread_pool().install(|| {
727                vals.par_chunks(chunk_size)
728                    .map(|chunk| {
729                        let mut local_hist = vec![0.0f64; hs];
730                        let mut local_below = 0.0f64;
731                        let mut local_above = 0.0f64;
732                        for &val in chunk {
733                            if val < hmin {
734                                local_below += 1.0;
735                            } else if val > hmax {
736                                local_above += 1.0;
737                            } else {
738                                let bin = ((val - hmin) * (hs - 1) as f64 / rng + 0.5) as usize;
739                                let bin = bin.min(hs - 1);
740                                local_hist[bin] += 1.0;
741                            }
742                        }
743                        (local_hist, local_below, local_above)
744                    })
745                    .collect()
746            });
747            for (local_hist, local_below, local_above) in chunk_results {
748                below += local_below;
749                above += local_above;
750                for (i, &count) in local_hist.iter().enumerate() {
751                    histogram[i] += count;
752                }
753            }
754        }
755    } else {
756        for i in 0..n {
757            let val = data.get_as_f64(i).unwrap_or(0.0);
758            if val < hist_min {
759                below += 1.0;
760            } else if val > hist_max {
761                above += 1.0;
762            } else {
763                let bin = ((val - hist_min) * (hist_size - 1) as f64 / range + 0.5) as usize;
764                let bin = bin.min(hist_size - 1);
765                histogram[bin] += 1.0;
766            }
767        }
768    }
769
770    // Compute entropy matching C++: -sum(count * ln(count)) / nElements
771    // Zero-count bins are treated as count=1 (so ln(1)=0, effectively skipped)
772    let n_elements = data.len() as f64;
773    let entropy = if n_elements > 0.0 {
774        let mut ent = 0.0f64;
775        for &count in &histogram {
776            let c = if count <= 0.0 { 1.0 } else { count };
777            ent += c * c.ln();
778        }
779        -ent / n_elements
780    } else {
781        0.0
782    };
783
784    (histogram, below, above, entropy)
785}
786
787/// Compute profile projections for a 2D image.
788///
789/// - Average X/Y: column/row averages over the full image
790/// - Threshold X/Y: column/row averages using only pixels >= threshold
791/// - Centroid X/Y: single row/column at the centroid position (rounded)
792/// - Cursor X/Y: single row/column at cursor position
793pub fn compute_profiles(
794    data: &NDDataBuffer,
795    x_size: usize,
796    y_size: usize,
797    threshold: f64,
798    centroid_x: f64,
799    centroid_y: f64,
800    cursor_x: usize,
801    cursor_y: usize,
802) -> ProfileResult {
803    if x_size == 0 || y_size == 0 || data.len() < x_size * y_size {
804        return ProfileResult::default();
805    }
806
807    let mut avg_x = vec![0.0f64; x_size];
808    let mut avg_y = vec![0.0f64; y_size];
809    let mut thresh_x_sum = vec![0.0f64; x_size];
810    let mut thresh_x_cnt = vec![0usize; x_size];
811    let mut thresh_y_sum = vec![0.0f64; y_size];
812    let mut thresh_y_cnt = vec![0usize; y_size];
813
814    // Accumulate sums for average and threshold profiles
815    for iy in 0..y_size {
816        for ix in 0..x_size {
817            let val = data.get_as_f64(iy * x_size + ix).unwrap_or(0.0);
818            avg_x[ix] += val;
819            avg_y[iy] += val;
820            if val >= threshold {
821                thresh_x_sum[ix] += val;
822                thresh_x_cnt[ix] += 1;
823                thresh_y_sum[iy] += val;
824                thresh_y_cnt[iy] += 1;
825            }
826        }
827    }
828
829    // Average profiles: divide column sums by y_size, row sums by x_size
830    for ix in 0..x_size {
831        avg_x[ix] /= y_size as f64;
832    }
833    for iy in 0..y_size {
834        avg_y[iy] /= x_size as f64;
835    }
836
837    // Threshold profiles: divide by count of pixels above threshold
838    let threshold_x: Vec<f64> = thresh_x_sum
839        .iter()
840        .zip(thresh_x_cnt.iter())
841        .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
842        .collect();
843    let threshold_y: Vec<f64> = thresh_y_sum
844        .iter()
845        .zip(thresh_y_cnt.iter())
846        .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
847        .collect();
848
849    // Centroid/cursor profiles: extract a single row/column at the requested
850    // position. C clamps the index to the valid range (NDPluginStats.cpp:341-360,
851    // `MAX(.,0)` then `MIN(.,size-1)`): an out-of-range centroid or user cursor
852    // collapses to the edge row/column, never a zero-filled profile. (x_size and
853    // y_size are both > 0 here — the function early-returns on a zero dimension.)
854    let cy_row = ((centroid_y + 0.5).max(0.0) as usize).min(y_size - 1);
855    let cx_col = ((centroid_x + 0.5).max(0.0) as usize).min(x_size - 1);
856    let cur_y = cursor_y.min(y_size - 1);
857    let cur_x = cursor_x.min(x_size - 1);
858
859    let centroid_x_profile: Vec<f64> = (0..x_size)
860        .map(|ix| data.get_as_f64(cy_row * x_size + ix).unwrap_or(0.0))
861        .collect();
862    let centroid_y_profile: Vec<f64> = (0..y_size)
863        .map(|iy| data.get_as_f64(iy * x_size + cx_col).unwrap_or(0.0))
864        .collect();
865    let cursor_x_profile: Vec<f64> = (0..x_size)
866        .map(|ix| data.get_as_f64(cur_y * x_size + ix).unwrap_or(0.0))
867        .collect();
868    let cursor_y_profile: Vec<f64> = (0..y_size)
869        .map(|iy| data.get_as_f64(iy * x_size + cur_x).unwrap_or(0.0))
870        .collect();
871
872    ProfileResult {
873        avg_x,
874        avg_y,
875        threshold_x,
876        threshold_y,
877        centroid_x: centroid_x_profile,
878        centroid_y: centroid_y_profile,
879        cursor_x: cursor_x_profile,
880        cursor_y: cursor_y_profile,
881    }
882}
883
884/// Pure processing logic for statistics computation.
885/// The compute-enable flags and their tuning values, all written by
886/// `on_param_change` and read as one set per frame.
887#[derive(Debug, Clone, Copy)]
888struct StatsConfig {
889    do_compute_statistics: bool,
890    do_compute_centroid: bool,
891    do_compute_histogram: bool,
892    do_compute_profiles: bool,
893    bgd_width: usize,
894    centroid_threshold: f64,
895    cursor_x: usize,
896    cursor_y: usize,
897    hist_size: usize,
898    hist_min: f64,
899    hist_max: f64,
900}
901
902impl Default for StatsConfig {
903    fn default() -> Self {
904        Self {
905            do_compute_statistics: true,
906            do_compute_centroid: true,
907            do_compute_histogram: false,
908            do_compute_profiles: false,
909            bgd_width: 0,
910            centroid_threshold: 0.0,
911            cursor_x: 0,
912            cursor_y: 0,
913            hist_size: 256,
914            hist_min: 0.0,
915            hist_max: 255.0,
916        }
917    }
918}
919
920pub struct StatsProcessor {
921    latest_stats: Arc<Mutex<StatsResult>>,
922    config: Mutex<StatsConfig>,
923    params: NDStatsParams,
924    /// Shared cell to export params after register_params is called.
925    params_out: Arc<Mutex<NDStatsParams>>,
926    /// Optional sender to push time series data to the TS port driver.
927    ts_sender: Option<crate::time_series::TimeSeriesSender>,
928}
929
930impl StatsProcessor {
931    pub fn new() -> Self {
932        Self {
933            latest_stats: Arc::new(Mutex::new(StatsResult::default())),
934            config: Mutex::new(StatsConfig::default()),
935            params: NDStatsParams::default(),
936            params_out: Arc::new(Mutex::new(NDStatsParams::default())),
937            ts_sender: None,
938        }
939    }
940
941    /// Get a cloneable handle to the latest stats.
942    pub fn stats_handle(&self) -> Arc<Mutex<StatsResult>> {
943        self.latest_stats.clone()
944    }
945
946    /// Get a shared handle to the params (populated after register_params is called).
947    pub fn params_handle(&self) -> Arc<Mutex<NDStatsParams>> {
948        self.params_out.clone()
949    }
950
951    /// Set the time series sender for pushing data to the TS port driver.
952    pub fn set_ts_sender(&mut self, sender: crate::time_series::TimeSeriesSender) {
953        self.ts_sender = Some(sender);
954    }
955}
956
957impl Default for StatsProcessor {
958    fn default() -> Self {
959        Self::new()
960    }
961}
962
963impl NDPluginProcess for StatsProcessor {
964    fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
965        let p = &self.params;
966        let info = array.info();
967        let cfg = *self.config.lock();
968
969        let mut result = if cfg.do_compute_statistics {
970            compute_stats(&array.data, &array.dims, cfg.bgd_width)
971        } else {
972            StatsResult::default()
973        };
974
975        // Centroid computation
976        let mut centroid = CentroidResult::default();
977        if cfg.do_compute_centroid {
978            // C rejects ndims>2 (NDPluginStats.cpp:205 `if (ndims>2) return
979            // asynError`): centroid is computed only for a true 2-D image, never
980            // by treating the first two dims of a 4-D (or [x,y,1]) array as a
981            // slice. The `>= 2` lower bound is unchanged (1-D is handled
982            // elsewhere); adding `== 2` only removes the ndims>2 path.
983            if info.color_size <= 1 && array.dims.len() == 2 {
984                centroid = compute_centroid(
985                    &array.data,
986                    info.x_size,
987                    info.y_size,
988                    cfg.centroid_threshold,
989                );
990            }
991        }
992
993        // Histogram computation
994        if cfg.do_compute_histogram {
995            let (histogram, below, above, entropy) =
996                compute_histogram(&array.data, cfg.hist_size, cfg.hist_min, cfg.hist_max);
997            result.histogram = histogram;
998            result.hist_below = below;
999            result.hist_above = above;
1000            result.hist_entropy = entropy;
1001        }
1002
1003        // Profile computation. C also rejects ndims>2 here (NDPluginStats.cpp:338),
1004        // so profiles are computed only for a true 2-D image.
1005        if cfg.do_compute_profiles && info.color_size <= 1 && array.dims.len() == 2 {
1006            let profiles = compute_profiles(
1007                &array.data,
1008                info.x_size,
1009                info.y_size,
1010                cfg.centroid_threshold,
1011                centroid.centroid_x,
1012                centroid.centroid_y,
1013                cfg.cursor_x,
1014                cfg.cursor_y,
1015            );
1016            result.profile_avg_x = profiles.avg_x;
1017            result.profile_avg_y = profiles.avg_y;
1018            result.profile_threshold_x = profiles.threshold_x;
1019            result.profile_threshold_y = profiles.threshold_y;
1020            result.profile_centroid_x = profiles.centroid_x;
1021            result.profile_centroid_y = profiles.centroid_y;
1022            result.profile_cursor_x = profiles.cursor_x;
1023            result.profile_cursor_y = profiles.cursor_y;
1024        }
1025
1026        // Compute cursor value: pixel at (cursor_x, cursor_y). C clamps the
1027        // cursor to the last valid pixel (NDPluginStats.cpp:357-362) and always
1028        // reads it — an out-of-range cursor yields the edge pixel, never 0.
1029        if info.color_size <= 1 && array.dims.len() == 2 && info.x_size > 0 && info.y_size > 0 {
1030            let cx = cfg.cursor_x.min(info.x_size - 1);
1031            let cy = cfg.cursor_y.min(info.y_size - 1);
1032            result.cursor_value = array.data.get_as_f64(cy * info.x_size + cx).unwrap_or(0.0);
1033        }
1034
1035        let mut updates = vec![
1036            ParamUpdate::float64(p.min_value, result.min),
1037            ParamUpdate::float64(p.max_value, result.max),
1038            ParamUpdate::float64(p.mean_value, result.mean),
1039            ParamUpdate::float64(p.sigma_value, result.sigma),
1040            ParamUpdate::float64(p.total, result.total),
1041            ParamUpdate::float64(p.net, result.net),
1042            ParamUpdate::float64(p.min_x, result.min_x as f64),
1043            ParamUpdate::float64(p.min_y, result.min_y as f64),
1044            ParamUpdate::float64(p.max_x, result.max_x as f64),
1045            ParamUpdate::float64(p.max_y, result.max_y as f64),
1046            ParamUpdate::float64(p.centroid_x, centroid.centroid_x),
1047            ParamUpdate::float64(p.centroid_y, centroid.centroid_y),
1048            ParamUpdate::float64(p.sigma_x, centroid.sigma_x),
1049            ParamUpdate::float64(p.sigma_y, centroid.sigma_y),
1050            ParamUpdate::float64(p.sigma_xy, centroid.sigma_xy),
1051            ParamUpdate::float64(p.centroid_total, centroid.centroid_total),
1052            ParamUpdate::float64(p.skewness_x, centroid.skewness_x),
1053            ParamUpdate::float64(p.skewness_y, centroid.skewness_y),
1054            ParamUpdate::float64(p.kurtosis_x, centroid.kurtosis_x),
1055            ParamUpdate::float64(p.kurtosis_y, centroid.kurtosis_y),
1056            ParamUpdate::float64(p.eccentricity, centroid.eccentricity),
1057            ParamUpdate::float64(p.orientation, centroid.orientation),
1058            ParamUpdate::int32(p.hist_below, result.hist_below as i32),
1059            ParamUpdate::int32(p.hist_above, result.hist_above as i32),
1060            ParamUpdate::float64(p.hist_entropy, result.hist_entropy),
1061            ParamUpdate::float64(p.cursor_val, result.cursor_value),
1062            ParamUpdate::int32(p.profile_size_x, info.x_size as i32),
1063            ParamUpdate::int32(p.profile_size_y, info.y_size as i32),
1064        ];
1065
1066        // Histogram waveforms: the counts (HIST_ARRAY) and the bin X axis
1067        // (HIST_X_ARRAY). C++ NDPluginStats::computeHistX fills the X axis
1068        // with bin left edges: `scale = (histMax - histMin) / histSize` and
1069        // `histX[i] = histMin + i*scale` for i in 0..histSize. The divisor is
1070        // the bin count (histSize), not histSize-1, so the last bin's X is
1071        // histMin + (histSize-1)*scale, strictly below histMax.
1072        if cfg.do_compute_histogram && !result.histogram.is_empty() {
1073            updates.push(ParamUpdate::float64_array(
1074                p.hist_array,
1075                result.histogram.clone(),
1076            ));
1077            let n = result.histogram.len();
1078            let step = (cfg.hist_max - cfg.hist_min) / n as f64;
1079            let hist_x: Vec<f64> = (0..n).map(|i| cfg.hist_min + i as f64 * step).collect();
1080            updates.push(ParamUpdate::float64_array(p.hist_x_array, hist_x));
1081        }
1082
1083        // Profile waveforms: emit the computed X/Y projections to asyn
1084        // clients (C++ doCallbacksFloat64Array for each PROFILE_* waveform).
1085        if cfg.do_compute_profiles && !result.profile_avg_x.is_empty() {
1086            updates.push(ParamUpdate::float64_array(
1087                p.profile_average_x,
1088                result.profile_avg_x.clone(),
1089            ));
1090            updates.push(ParamUpdate::float64_array(
1091                p.profile_average_y,
1092                result.profile_avg_y.clone(),
1093            ));
1094            updates.push(ParamUpdate::float64_array(
1095                p.profile_threshold_x,
1096                result.profile_threshold_x.clone(),
1097            ));
1098            updates.push(ParamUpdate::float64_array(
1099                p.profile_threshold_y,
1100                result.profile_threshold_y.clone(),
1101            ));
1102            updates.push(ParamUpdate::float64_array(
1103                p.profile_centroid_x,
1104                result.profile_centroid_x.clone(),
1105            ));
1106            updates.push(ParamUpdate::float64_array(
1107                p.profile_centroid_y,
1108                result.profile_centroid_y.clone(),
1109            ));
1110            updates.push(ParamUpdate::float64_array(
1111                p.profile_cursor_x,
1112                result.profile_cursor_x.clone(),
1113            ));
1114            updates.push(ParamUpdate::float64_array(
1115                p.profile_cursor_y,
1116                result.profile_cursor_y.clone(),
1117            ));
1118        }
1119
1120        // Send time series data to TS port driver (if configured)
1121        if let Some(ref sender) = self.ts_sender {
1122            let ts_data = crate::time_series::TimeSeriesData {
1123                values: vec![
1124                    result.min,
1125                    result.min_x as f64,
1126                    result.min_y as f64,
1127                    result.max,
1128                    result.max_x as f64,
1129                    result.max_y as f64,
1130                    result.mean,
1131                    result.sigma,
1132                    result.total,
1133                    result.net,
1134                    centroid.centroid_total,
1135                    centroid.centroid_x,
1136                    centroid.centroid_y,
1137                    centroid.sigma_x,
1138                    centroid.sigma_y,
1139                    centroid.sigma_xy,
1140                    centroid.skewness_x,
1141                    centroid.skewness_y,
1142                    centroid.kurtosis_x,
1143                    centroid.kurtosis_y,
1144                    centroid.eccentricity,
1145                    centroid.orientation,
1146                    // C `timeSeries[TSTimestamp] = pArray->timeStamp`
1147                    // (NDPluginStats.cpp:577) — the standalone double.
1148                    array.time_stamp,
1149                ],
1150            };
1151            let _ = sender.try_send(ts_data);
1152        }
1153
1154        *self.latest_stats.lock() = result;
1155        // C++ Stats forwards the input array to downstream plugins
1156        ProcessResult {
1157            output_arrays: vec![Arc::new(array.clone())],
1158            param_updates: updates,
1159            scatter: false,
1160        }
1161    }
1162
1163    fn plugin_type(&self) -> &str {
1164        "NDPluginStats"
1165    }
1166
1167    fn register_params(
1168        &mut self,
1169        base: &mut PortDriverBase,
1170    ) -> Result<(), asyn_rs::error::AsynError> {
1171        self.params.compute_statistics =
1172            base.create_param("COMPUTE_STATISTICS", ParamType::Int32)?;
1173        base.set_int32_param(self.params.compute_statistics, 0, 1)?;
1174
1175        self.params.bgd_width = base.create_param("BGD_WIDTH", ParamType::Int32)?;
1176        self.params.min_value = base.create_param("MIN_VALUE", ParamType::Float64)?;
1177        self.params.max_value = base.create_param("MAX_VALUE", ParamType::Float64)?;
1178        self.params.mean_value = base.create_param("MEAN_VALUE", ParamType::Float64)?;
1179        self.params.sigma_value = base.create_param("SIGMA_VALUE", ParamType::Float64)?;
1180        self.params.total = base.create_param("TOTAL", ParamType::Float64)?;
1181        self.params.net = base.create_param("NET", ParamType::Float64)?;
1182        self.params.min_x = base.create_param("MIN_X", ParamType::Float64)?;
1183        self.params.min_y = base.create_param("MIN_Y", ParamType::Float64)?;
1184        self.params.max_x = base.create_param("MAX_X", ParamType::Float64)?;
1185        self.params.max_y = base.create_param("MAX_Y", ParamType::Float64)?;
1186
1187        self.params.compute_centroid = base.create_param("COMPUTE_CENTROID", ParamType::Int32)?;
1188        base.set_int32_param(self.params.compute_centroid, 0, 1)?;
1189
1190        self.params.centroid_threshold =
1191            base.create_param("CENTROID_THRESHOLD", ParamType::Float64)?;
1192        self.params.centroid_total = base.create_param("CENTROID_TOTAL", ParamType::Float64)?;
1193        self.params.centroid_x = base.create_param("CENTROIDX_VALUE", ParamType::Float64)?;
1194        self.params.centroid_y = base.create_param("CENTROIDY_VALUE", ParamType::Float64)?;
1195        self.params.sigma_x = base.create_param("SIGMAX_VALUE", ParamType::Float64)?;
1196        self.params.sigma_y = base.create_param("SIGMAY_VALUE", ParamType::Float64)?;
1197        self.params.sigma_xy = base.create_param("SIGMAXY_VALUE", ParamType::Float64)?;
1198        self.params.skewness_x = base.create_param("SKEWNESSX_VALUE", ParamType::Float64)?;
1199        self.params.skewness_y = base.create_param("SKEWNESSY_VALUE", ParamType::Float64)?;
1200        self.params.kurtosis_x = base.create_param("KURTOSISX_VALUE", ParamType::Float64)?;
1201        self.params.kurtosis_y = base.create_param("KURTOSISY_VALUE", ParamType::Float64)?;
1202        self.params.eccentricity = base.create_param("ECCENTRICITY_VALUE", ParamType::Float64)?;
1203        self.params.orientation = base.create_param("ORIENTATION_VALUE", ParamType::Float64)?;
1204
1205        self.params.compute_histogram = base.create_param("COMPUTE_HISTOGRAM", ParamType::Int32)?;
1206        self.params.hist_size = base.create_param("HIST_SIZE", ParamType::Int32)?;
1207        base.set_int32_param(self.params.hist_size, 0, 256)?;
1208        self.params.hist_min = base.create_param("HIST_MIN", ParamType::Float64)?;
1209        self.params.hist_max = base.create_param("HIST_MAX", ParamType::Float64)?;
1210        base.set_float64_param(self.params.hist_max, 0, 255.0)?;
1211        // HIST_BELOW/HIST_ABOVE are integer pixel counts: C registers them as
1212        // asynInt32 and pushes via setIntegerParam (NDPluginStats.cpp:827-828,
1213        // 627-628; epicsInt32 fields NDPluginStats.h:86-87). A client reading
1214        // these RBVs must see DBR_LONG, not DBR_DOUBLE.
1215        self.params.hist_below = base.create_param("HIST_BELOW", ParamType::Int32)?;
1216        self.params.hist_above = base.create_param("HIST_ABOVE", ParamType::Int32)?;
1217        self.params.hist_entropy = base.create_param("HIST_ENTROPY", ParamType::Float64)?;
1218
1219        self.params.compute_profiles = base.create_param("COMPUTE_PROFILES", ParamType::Int32)?;
1220        self.params.cursor_x = base.create_param("CURSOR_X", ParamType::Int32)?;
1221        base.set_int32_param(self.params.cursor_x, 0, 0)?;
1222        self.params.cursor_y = base.create_param("CURSOR_Y", ParamType::Int32)?;
1223        base.set_int32_param(self.params.cursor_y, 0, 0)?;
1224
1225        self.params.cursor_val = base.create_param("CURSOR_VAL", ParamType::Float64)?;
1226        self.params.profile_size_x = base.create_param("PROFILE_SIZE_X", ParamType::Int32)?;
1227        self.params.profile_size_y = base.create_param("PROFILE_SIZE_Y", ParamType::Int32)?;
1228
1229        self.params.skewx_value = base.create_param("SKEWX_VALUE", ParamType::Float64)?;
1230        self.params.skewy_value = base.create_param("SKEWY_VALUE", ParamType::Float64)?;
1231        self.params.profile_average_x =
1232            base.create_param("PROFILE_AVERAGE_X", ParamType::Float64Array)?;
1233        self.params.profile_average_y =
1234            base.create_param("PROFILE_AVERAGE_Y", ParamType::Float64Array)?;
1235        self.params.profile_threshold_x =
1236            base.create_param("PROFILE_THRESHOLD_X", ParamType::Float64Array)?;
1237        self.params.profile_threshold_y =
1238            base.create_param("PROFILE_THRESHOLD_Y", ParamType::Float64Array)?;
1239        self.params.profile_centroid_x =
1240            base.create_param("PROFILE_CENTROID_X", ParamType::Float64Array)?;
1241        self.params.profile_centroid_y =
1242            base.create_param("PROFILE_CENTROID_Y", ParamType::Float64Array)?;
1243        self.params.profile_cursor_x =
1244            base.create_param("PROFILE_CURSOR_X", ParamType::Float64Array)?;
1245        self.params.profile_cursor_y =
1246            base.create_param("PROFILE_CURSOR_Y", ParamType::Float64Array)?;
1247        self.params.hist_array = base.create_param("HIST_ARRAY", ParamType::Float64Array)?;
1248        self.params.hist_x_array = base.create_param("HIST_X_ARRAY", ParamType::Float64Array)?;
1249
1250        // Export params so create_stats_runtime can retrieve them after the move
1251        *self.params_out.lock() = self.params;
1252
1253        Ok(())
1254    }
1255
1256    fn on_param_change(
1257        &self,
1258        reason: usize,
1259        snapshot: &PluginParamSnapshot,
1260    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
1261        let p = &self.params;
1262        let mut cfg = self.config.lock();
1263        if reason == p.compute_statistics {
1264            cfg.do_compute_statistics = snapshot.value.as_i32() != 0;
1265        } else if reason == p.compute_centroid {
1266            cfg.do_compute_centroid = snapshot.value.as_i32() != 0;
1267        } else if reason == p.compute_histogram {
1268            cfg.do_compute_histogram = snapshot.value.as_i32() != 0;
1269        } else if reason == p.compute_profiles {
1270            cfg.do_compute_profiles = snapshot.value.as_i32() != 0;
1271        } else if reason == p.bgd_width {
1272            cfg.bgd_width = snapshot.value.as_i32().max(0) as usize;
1273        } else if reason == p.centroid_threshold {
1274            cfg.centroid_threshold = snapshot.value.as_f64();
1275        } else if reason == p.cursor_x {
1276            cfg.cursor_x = snapshot.value.as_i32().max(0) as usize;
1277        } else if reason == p.cursor_y {
1278            cfg.cursor_y = snapshot.value.as_i32().max(0) as usize;
1279        } else if reason == p.hist_size {
1280            cfg.hist_size = (snapshot.value.as_i32().max(1)) as usize;
1281        } else if reason == p.hist_min {
1282            cfg.hist_min = snapshot.value.as_f64();
1283        } else if reason == p.hist_max {
1284            cfg.hist_max = snapshot.value.as_f64();
1285        }
1286        ad_core_rs::plugin::runtime::ParamChangeResult::empty()
1287    }
1288}
1289
1290/// Create a stats plugin runtime with an integrated time series port.
1291///
1292/// Returns:
1293/// Create a stats plugin runtime. The TS receiver is stored in the registry
1294/// for later pickup by `NDTimeSeriesConfigure`.
1295pub fn create_stats_runtime(
1296    port_name: &str,
1297    pool: Arc<NDArrayPool>,
1298    queue_size: usize,
1299    ndarray_port: &str,
1300    wiring: Arc<WiringRegistry>,
1301    ts_registry: &crate::time_series::TsReceiverRegistry,
1302) -> (
1303    PluginRuntimeHandle,
1304    Arc<Mutex<StatsResult>>,
1305    NDStatsParams,
1306    std::thread::JoinHandle<()>,
1307) {
1308    let (ts_tx, ts_rx) = tokio::sync::mpsc::channel(256);
1309
1310    let mut processor = StatsProcessor::new();
1311    processor.set_ts_sender(ts_tx);
1312    let stats_handle = processor.stats_handle();
1313    let params_handle = processor.params_handle();
1314
1315    let (plugin_handle, data_jh) = ad_core_rs::plugin::runtime::create_plugin_runtime(
1316        port_name,
1317        processor,
1318        pool,
1319        queue_size,
1320        ndarray_port,
1321        wiring,
1322    );
1323
1324    let stats_params = *params_handle.lock();
1325
1326    // Store the TS receiver for NDTimeSeriesConfigure to pick up
1327    let channel_names: Vec<String> = crate::time_series::STATS_TS_CHANNEL_NAMES
1328        .iter()
1329        .map(|s| s.to_string())
1330        .collect();
1331    ts_registry.store(port_name, ts_rx, channel_names);
1332
1333    (plugin_handle, stats_handle, stats_params, data_jh)
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338    use super::*;
1339    use ad_core_rs::ndarray::{NDDataType, NDDimension};
1340
1341    #[test]
1342    fn test_ts_timestamp_channel_is_the_standalone_double() {
1343        // R8-66 family: C `timeSeries[TSTimestamp] = pArray->timeStamp`
1344        // (NDPluginStats.cpp:577) — the standalone double a driver sets from its
1345        // own clock, not a value derived from epicsTS. The port sent
1346        // `timestamp.as_f64()`.
1347        use crate::time_series::{NUM_STATS_TS_CHANNELS, STATS_TS_CHANNEL_NAMES};
1348        use ad_core_rs::ndarray::NDArray;
1349        use ad_core_rs::ndarray_pool::NDArrayPool;
1350        use ad_core_rs::plugin::runtime::NDPluginProcess;
1351
1352        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
1353        let mut processor = StatsProcessor::new();
1354        processor.set_ts_sender(tx);
1355
1356        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
1357        arr.data = NDDataBuffer::U8(vec![1, 2, 3, 4]);
1358        arr.timestamp = ad_core_rs::timestamp::EpicsTimestamp {
1359            sec: 100,
1360            nsec: 500_000_000,
1361        };
1362        arr.time_stamp = 7.25; // hardware clock, unrelated to epicsTS
1363
1364        processor.process_array(&arr, &NDArrayPool::new(1_000_000));
1365
1366        let ts = rx.try_recv().expect("stats pushes a TS sample per frame");
1367        assert_eq!(ts.values.len(), NUM_STATS_TS_CHANNELS);
1368        let idx = STATS_TS_CHANNEL_NAMES
1369            .iter()
1370            .position(|n| *n == "TSTimestamp")
1371            .unwrap();
1372        assert!(
1373            (ts.values[idx] - 7.25).abs() < 1e-9,
1374            "TSTimestamp carries pArray->timeStamp, got {}",
1375            ts.values[idx]
1376        );
1377    }
1378
1379    #[test]
1380    fn test_compute_stats_u8() {
1381        let dims = vec![NDDimension::new(5)];
1382        let data = NDDataBuffer::U8(vec![10, 20, 30, 40, 50]);
1383        let stats = compute_stats(&data, &dims, 0);
1384        assert_eq!(stats.min, 10.0);
1385        assert_eq!(stats.max, 50.0);
1386        assert_eq!(stats.mean, 30.0);
1387        assert_eq!(stats.total, 150.0);
1388        assert_eq!(stats.num_elements, 5);
1389    }
1390
1391    #[test]
1392    fn test_compute_stats_sigma() {
1393        let dims = vec![NDDimension::new(8)];
1394        let data = NDDataBuffer::F64(vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]);
1395        let stats = compute_stats(&data, &dims, 0);
1396        assert!((stats.mean - 5.0).abs() < 1e-10);
1397        assert!((stats.sigma - 2.0).abs() < 1e-10);
1398    }
1399
1400    #[test]
1401    fn test_compute_stats_u16() {
1402        let dims = vec![NDDimension::new(3)];
1403        let data = NDDataBuffer::U16(vec![100, 200, 300]);
1404        let stats = compute_stats(&data, &dims, 0);
1405        assert_eq!(stats.min, 100.0);
1406        assert_eq!(stats.max, 300.0);
1407        assert_eq!(stats.mean, 200.0);
1408    }
1409
1410    #[test]
1411    fn test_compute_stats_f64() {
1412        let dims = vec![NDDimension::new(3)];
1413        let data = NDDataBuffer::F64(vec![1.5, 2.5, 3.5]);
1414        let stats = compute_stats(&data, &dims, 0);
1415        assert!((stats.min - 1.5).abs() < 1e-10);
1416        assert!((stats.max - 3.5).abs() < 1e-10);
1417        assert!((stats.mean - 2.5).abs() < 1e-10);
1418    }
1419
1420    #[test]
1421    fn test_compute_stats_single_element() {
1422        let dims = vec![NDDimension::new(1)];
1423        let data = NDDataBuffer::I32(vec![42]);
1424        let stats = compute_stats(&data, &dims, 0);
1425        assert_eq!(stats.min, 42.0);
1426        assert_eq!(stats.max, 42.0);
1427        assert_eq!(stats.mean, 42.0);
1428        assert_eq!(stats.sigma, 0.0);
1429        assert_eq!(stats.num_elements, 1);
1430    }
1431
1432    #[test]
1433    fn test_compute_stats_empty() {
1434        let data = NDDataBuffer::U8(vec![]);
1435        let stats = compute_stats(&data, &[], 0);
1436        assert_eq!(stats.num_elements, 0);
1437    }
1438
1439    #[test]
1440    fn test_compute_stats_min_max_position() {
1441        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1442        // 4x4 array: min at [0], max at [15]
1443        let data = NDDataBuffer::U8((1..=16).collect());
1444        let stats = compute_stats(&data, &dims, 0);
1445        assert_eq!(stats.min_x, 0); // index 0 -> x=0, y=0
1446        assert_eq!(stats.min_y, 0);
1447        assert_eq!(stats.max_x, 3); // index 15 -> x=3, y=3
1448        assert_eq!(stats.max_y, 3);
1449    }
1450
1451    #[test]
1452    fn test_compute_stats_net_no_bgd() {
1453        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1454        let data = NDDataBuffer::U8((1..=16).collect());
1455        let stats = compute_stats(&data, &dims, 0);
1456        // With bgd_width=0, net should equal total
1457        assert_eq!(stats.net, stats.total);
1458    }
1459
1460    #[test]
1461    fn test_compute_stats_bgd_subtraction() {
1462        // 4x4 image with uniform value 10, plus a bright center pixel
1463        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1464        let mut pixels = vec![10u16; 16];
1465        // Put a bright spot at (2,2) = index 10
1466        pixels[2 * 4 + 2] = 110;
1467        let data = NDDataBuffer::U16(pixels);
1468        let stats = compute_stats(&data, &dims, 1);
1469
1470        // With bgd_width=1, all edge pixels (1 pixel from each edge) are used for background.
1471        // In a 4x4 image with bgd_width=1, only pixels at (1,1), (2,1), (1,2), (2,2) are interior.
1472        // Edge pixels are the 12 remaining pixels. 11 of them are 10, one at (2,2) might be edge or not.
1473        // Actually (2,2) is interior (ix=2 is not <1 and not >=3, iy=2 is not <1 and not >=3).
1474        // So edge pixels: 12 pixels all with value 10. bgd_avg = 10.0
1475        // net = total - bgd_avg * num_elements
1476        // total = 15*10 + 110 = 260
1477        // net = 260 - 10.0 * 16 = 260 - 160 = 100
1478        assert!((stats.net - 100.0).abs() < 1e-10);
1479    }
1480
1481    /// BUG 2 regression: 2-D background matches C++ `NDPluginStats`
1482    /// edge-strip computation, including corner double-counting.
1483    ///
1484    /// 4x4 image, bgd_width=1. C++ strips (dim 0 = x fastest):
1485    ///   dim x: low strip ix=0 (4 px), high strip ix=3 (4 px)
1486    ///   dim y: low strip iy=0 (4 px), high strip iy=3 (4 px)
1487    /// bgd_pixels = 16 (the 4 corners are counted twice; the 4 interior
1488    /// pixels are never counted). bgd_counts is the sum over those 16
1489    /// strip slots, corners contributing twice.
1490    #[test]
1491    fn test_compute_stats_bgd_2d_corner_double_count() {
1492        // 4x4, row-major, dim0=x fastest. Asymmetric data so that corner
1493        // double-counting demonstrably changes the result:
1494        //   corners      = 100   (idx 0, 3, 12, 15)
1495        //   other edges  = 10    (idx 1, 2, 4, 7, 8, 11, 13, 14)
1496        //   interior     = 1     (idx 5, 6, 9, 10)
1497        // Rows (y):
1498        //   y0: [100,  10,  10, 100]
1499        //   y1: [ 10,   1,   1,  10]
1500        //   y2: [ 10,   1,   1,  10]
1501        //   y3: [100,  10,  10, 100]
1502        let dims = vec![NDDimension::new(4), NDDimension::new(4)];
1503        let mut pixels = vec![1u16; 16];
1504        for &i in &[1usize, 2, 4, 7, 8, 11, 13, 14] {
1505            pixels[i] = 10;
1506        }
1507        for &i in &[0usize, 3, 12, 15] {
1508            pixels[i] = 100;
1509        }
1510        let total_expected: f64 = pixels.iter().map(|&p| p as f64).sum();
1511        let data = NDDataBuffer::U16(pixels);
1512        let stats = compute_stats(&data, &dims, 1);
1513
1514        // C++ strip sum (bgd_width=1):
1515        //   x low strip  (ix=0): idx 0,4,8,12  -> 100,10,10,100 = 220
1516        //   x high strip (ix=3): idx 3,7,11,15 -> 100,10,10,100 = 220
1517        //   y low strip  (iy=0): idx 0,1,2,3   -> 100,10,10,100 = 220
1518        //   y high strip (iy=3): idx 12,13,14,15 -> 100,10,10,100 = 220
1519        // Each corner (100) appears in two strips => double-counted.
1520        let bgd_counts = 220 + 220 + 220 + 220; // 880
1521        let bgd_pixels = 16; // 4 strips * 4 px each
1522        let bgd_avg = bgd_counts as f64 / bgd_pixels as f64; // 55.0
1523        let expected_net = total_expected - bgd_avg * 16.0;
1524        assert!(
1525            (stats.net - expected_net).abs() < 1e-9,
1526            "net {} != expected {}",
1527            stats.net,
1528            expected_net
1529        );
1530
1531        // A once-each perimeter (12 distinct pixels) would average
1532        // (4*100 + 8*10)/12 = 40.0, NOT 55.0 — proving the corners are
1533        // double-counted exactly as C++ documents.
1534        let perimeter_avg = (4.0 * 100.0 + 8.0 * 10.0) / 12.0;
1535        assert!(
1536            (bgd_avg - perimeter_avg).abs() > 1e-9,
1537            "corner double-count must change bgd_avg vs a once-each perimeter"
1538        );
1539        assert!((bgd_avg - 55.0).abs() < 1e-9);
1540    }
1541
1542    /// BUG 2 regression: background works for 1-D arrays (C++ runs the
1543    /// strip algorithm for any `ndims`, not just >= 2).
1544    #[test]
1545    fn test_compute_stats_bgd_1d() {
1546        // 1-D, 8 elements: [10, 20, 30, 40, 50, 60, 70, 80], bgd_width=2.
1547        let dims = vec![NDDimension::new(8)];
1548        let data = NDDataBuffer::U16(vec![10, 20, 30, 40, 50, 60, 70, 80]);
1549        let stats = compute_stats(&data, &dims, 2);
1550
1551        // Single dimension: low strip indices 0,1 -> 10,20;
1552        // high strip offset 8-2=6, indices 6,7 -> 70,80.
1553        // No corner overlap in 1-D (strips disjoint here).
1554        let bgd_counts = 10 + 20 + 70 + 80;
1555        let bgd_pixels = 4;
1556        let bgd_avg = bgd_counts as f64 / bgd_pixels as f64; // 45.0
1557        let total = (10 + 20 + 30 + 40 + 50 + 60 + 70 + 80) as f64;
1558        let expected_net = total - bgd_avg * 8.0;
1559        assert!(
1560            (stats.net - expected_net).abs() < 1e-9,
1561            "1-D net {} != expected {}",
1562            stats.net,
1563            expected_net
1564        );
1565    }
1566
1567    /// BUG 2 regression: background works for 3-D arrays.
1568    #[test]
1569    fn test_compute_stats_bgd_3d() {
1570        // 2x2x2 array, every element = 1, bgd_width = 1.
1571        // With bgd_width >= every dim size, every strip covers the whole
1572        // array; corners counted many times. bgd_avg must still be 1.0
1573        // (uniform data), so net = total - 1.0 * 8 = 0.
1574        let dims = vec![
1575            NDDimension::new(2),
1576            NDDimension::new(2),
1577            NDDimension::new(2),
1578        ];
1579        let data = NDDataBuffer::U8(vec![1u8; 8]);
1580        let stats = compute_stats(&data, &dims, 1);
1581        assert!(
1582            stats.net.abs() < 1e-9,
1583            "3-D uniform net should be 0, got {}",
1584            stats.net
1585        );
1586        assert_eq!(stats.total, 8.0);
1587    }
1588
1589    #[test]
1590    fn test_centroid_uniform() {
1591        let data = NDDataBuffer::U8(vec![1; 16]);
1592        let c = compute_centroid(&data, 4, 4, 0.0);
1593        assert!((c.centroid_x - 1.5).abs() < 1e-10);
1594        assert!((c.centroid_y - 1.5).abs() < 1e-10);
1595    }
1596
1597    #[test]
1598    fn test_centroid_corner() {
1599        let mut d = vec![0u8; 16];
1600        d[0] = 255;
1601        let data = NDDataBuffer::U8(d);
1602        let c = compute_centroid(&data, 4, 4, 0.0);
1603        assert!((c.centroid_x - 0.0).abs() < 1e-10);
1604        assert!((c.centroid_y - 0.0).abs() < 1e-10);
1605    }
1606
1607    #[test]
1608    fn test_centroid_threshold() {
1609        // 4x4 image: background of 5, bright spot of 100 at (2,2)
1610        let mut pixels = vec![5u8; 16];
1611        pixels[2 * 4 + 2] = 100;
1612        let data = NDDataBuffer::U8(pixels);
1613
1614        // With threshold=50, only the bright pixel should be counted
1615        let c = compute_centroid(&data, 4, 4, 50.0);
1616        assert!((c.centroid_x - 2.0).abs() < 1e-10);
1617        assert!((c.centroid_y - 2.0).abs() < 1e-10);
1618        assert!((c.centroid_total - 100.0).abs() < 1e-10);
1619    }
1620
1621    #[test]
1622    fn test_centroid_higher_moments_symmetric() {
1623        // Symmetric distribution: skewness should be ~0, eccentricity ~0 for uniform
1624        let data = NDDataBuffer::U8(vec![1; 16]);
1625        let c = compute_centroid(&data, 4, 4, 0.0);
1626        // Symmetric -> skewness ~0
1627        assert!(c.skewness_x.abs() < 1e-10);
1628        assert!(c.skewness_y.abs() < 1e-10);
1629        // Uniform 4x4 -> sigma_x == sigma_y -> eccentricity ~0
1630        assert!(c.eccentricity.abs() < 1e-10);
1631    }
1632
1633    #[test]
1634    fn test_histogram_basic() {
1635        // 10 values: 0..9, hist range [0, 9], 10 bins
1636        let data = NDDataBuffer::F64((0..10).map(|x| x as f64).collect());
1637        let (hist, below, above, entropy) = compute_histogram(&data, 10, 0.0, 9.0);
1638        assert_eq!(hist.len(), 10);
1639        assert_eq!(below, 0.0);
1640        assert_eq!(above, 0.0);
1641        // Each bin should have ~1 count (uniform distribution)
1642        let total: f64 = hist.iter().sum();
1643        assert!((total - 10.0).abs() < 1e-10);
1644        // C++ entropy: -sum(count * ln(count)) / nElements
1645        // Uniform: each bin has 1, so sum(1*ln(1)) = 0, entropy = 0
1646        assert!(entropy.abs() < 1e-10);
1647    }
1648
1649    #[test]
1650    fn test_histogram_below_above() {
1651        let data = NDDataBuffer::F64(vec![-1.0, 0.5, 1.5, 3.0]);
1652        let (hist, below, above, _entropy) = compute_histogram(&data, 2, 0.0, 2.0);
1653        assert_eq!(below, 1.0); // -1.0 is below
1654        assert_eq!(above, 1.0); // 3.0 is above
1655        let total_in_bins: f64 = hist.iter().sum();
1656        assert!((total_in_bins - 2.0).abs() < 1e-10); // 0.5 and 1.5
1657    }
1658
1659    #[test]
1660    fn test_histogram_single_value() {
1661        let data = NDDataBuffer::F64(vec![5.0; 100]);
1662        let (hist, below, above, entropy) = compute_histogram(&data, 10, 0.0, 10.0);
1663        assert_eq!(below, 0.0);
1664        assert_eq!(above, 0.0);
1665        // C++ entropy: one bin has 100, 9 bins have 0→1
1666        // sum = 100*ln(100) + 9*(1*ln(1)) = 100*ln(100)
1667        // entropy = -100*ln(100)/100 = -ln(100)
1668        let expected = -(100.0f64.ln());
1669        assert!((entropy - expected).abs() < 1e-10);
1670        let total: f64 = hist.iter().sum();
1671        assert!((total - 100.0).abs() < 1e-10);
1672    }
1673
1674    #[test]
1675    fn test_profiles_8x8() {
1676        // 8x8 image with value = row index (0..7 repeated across columns)
1677        let mut pixels = vec![0.0f64; 64];
1678        for iy in 0..8 {
1679            for ix in 0..8 {
1680                pixels[iy * 8 + ix] = iy as f64;
1681            }
1682        }
1683        let data = NDDataBuffer::F64(pixels);
1684
1685        let profiles = compute_profiles(
1686            &data, 8, 8, 0.0, // threshold
1687            3.5, // centroid_x (center)
1688            3.5, // centroid_y (center)
1689            0,   // cursor_x
1690            7,   // cursor_y
1691        );
1692
1693        // Average X profile: each column has the same values (0..7), avg = 3.5
1694        assert_eq!(profiles.avg_x.len(), 8);
1695        for &v in &profiles.avg_x {
1696            assert!((v - 3.5).abs() < 1e-10, "avg_x should be 3.5, got {v}");
1697        }
1698
1699        // Average Y profile: each row has uniform value = row index, avg = row index
1700        assert_eq!(profiles.avg_y.len(), 8);
1701        for (iy, &v) in profiles.avg_y.iter().enumerate() {
1702            assert!(
1703                (v - iy as f64).abs() < 1e-10,
1704                "avg_y[{iy}] should be {iy}, got {v}"
1705            );
1706        }
1707
1708        // Cursor X profile: row at cursor_y=7 -> all pixels are 7.0
1709        assert_eq!(profiles.cursor_x.len(), 8);
1710        for &v in &profiles.cursor_x {
1711            assert!((v - 7.0).abs() < 1e-10);
1712        }
1713
1714        // Cursor Y profile: column at cursor_x=0 -> values are 0,1,2,...,7
1715        assert_eq!(profiles.cursor_y.len(), 8);
1716        for (iy, &v) in profiles.cursor_y.iter().enumerate() {
1717            assert!((v - iy as f64).abs() < 1e-10);
1718        }
1719
1720        // Centroid X profile: row at round(centroid_y=3.5+0.5)=4 -> all pixels are 4.0
1721        assert_eq!(profiles.centroid_x.len(), 8);
1722        for &v in &profiles.centroid_x {
1723            assert!((v - 4.0).abs() < 1e-10);
1724        }
1725
1726        // Centroid Y profile: column at round(centroid_x=3.5+0.5)=4 -> values are 0,1,...,7
1727        assert_eq!(profiles.centroid_y.len(), 8);
1728        for (iy, &v) in profiles.centroid_y.iter().enumerate() {
1729            assert!((v - iy as f64).abs() < 1e-10);
1730        }
1731    }
1732
1733    #[test]
1734    fn test_adp14_out_of_range_cursor_clamps_to_edge_not_zeros() {
1735        // C clamps an out-of-range cursor/centroid to the last valid line
1736        // (NDPluginStats.cpp:341-360), never returning a zero-filled profile.
1737        // 8x8 image with value = row index.
1738        let mut pixels = vec![0.0f64; 64];
1739        for iy in 0..8 {
1740            for ix in 0..8 {
1741                pixels[iy * 8 + ix] = iy as f64;
1742            }
1743        }
1744        let data = NDDataBuffer::F64(pixels);
1745
1746        let profiles = compute_profiles(
1747            &data, 8, 8, 0.0,   // threshold
1748            100.0, // centroid_x out of range -> clamp to col 7
1749            100.0, // centroid_y out of range -> clamp to row 7
1750            50,    // cursor_x out of range -> clamp to col 7
1751            50,    // cursor_y out of range -> clamp to row 7
1752        );
1753
1754        // Cursor X profile: clamped row 7 -> all 7.0 (NOT zeros).
1755        assert_eq!(profiles.cursor_x.len(), 8);
1756        for &v in &profiles.cursor_x {
1757            assert!((v - 7.0).abs() < 1e-10, "cursor_x should clamp to row 7");
1758        }
1759        // Cursor Y profile: clamped col 7 -> values 0..7 (NOT zeros).
1760        for (iy, &v) in profiles.cursor_y.iter().enumerate() {
1761            assert!(
1762                (v - iy as f64).abs() < 1e-10,
1763                "cursor_y should clamp to col 7"
1764            );
1765        }
1766        // Centroid X profile: clamped row 7 -> all 7.0.
1767        for &v in &profiles.centroid_x {
1768            assert!((v - 7.0).abs() < 1e-10, "centroid_x should clamp to row 7");
1769        }
1770        // Centroid Y profile: clamped col 7 -> values 0..7.
1771        for (iy, &v) in profiles.centroid_y.iter().enumerate() {
1772            assert!(
1773                (v - iy as f64).abs() < 1e-10,
1774                "centroid_y should clamp to col 7"
1775            );
1776        }
1777    }
1778
1779    #[test]
1780    fn test_profiles_threshold() {
1781        // 4x4 image: all 1.0 except one bright pixel at (2,1) = 10.0
1782        let mut pixels = vec![1.0f64; 16];
1783        pixels[1 * 4 + 2] = 10.0;
1784        let data = NDDataBuffer::F64(pixels);
1785
1786        let profiles = compute_profiles(
1787            &data, 4, 4, 5.0, // threshold
1788            2.0, 1.0, 0, 0,
1789        );
1790
1791        // Threshold X profile: only column 2 has a pixel >= 5.0 (at row 1)
1792        assert_eq!(profiles.threshold_x.len(), 4);
1793        assert!((profiles.threshold_x[2] - 10.0).abs() < 1e-10);
1794        // Other columns: no pixels above threshold
1795        assert!((profiles.threshold_x[0] - 0.0).abs() < 1e-10);
1796        assert!((profiles.threshold_x[1] - 0.0).abs() < 1e-10);
1797        assert!((profiles.threshold_x[3] - 0.0).abs() < 1e-10);
1798
1799        // Threshold Y profile: only row 1 has a pixel >= 5.0
1800        assert_eq!(profiles.threshold_y.len(), 4);
1801        assert!((profiles.threshold_y[1] - 10.0).abs() < 1e-10);
1802        assert!((profiles.threshold_y[0] - 0.0).abs() < 1e-10);
1803    }
1804
1805    #[test]
1806    fn test_stats_processor_direct() {
1807        let proc = StatsProcessor::new();
1808        let pool = NDArrayPool::new(1_000_000);
1809
1810        let mut arr = NDArray::new(vec![NDDimension::new(5)], NDDataType::UInt8);
1811        if let NDDataBuffer::U8(ref mut v) = arr.data {
1812            v[0] = 10;
1813            v[1] = 20;
1814            v[2] = 30;
1815            v[3] = 40;
1816            v[4] = 50;
1817        }
1818
1819        let result = proc.process_array(&arr, &pool);
1820        // C++ Stats forwards the input array to downstream plugins
1821        assert_eq!(result.output_arrays.len(), 1, "stats forwards the array");
1822
1823        let stats = proc.stats_handle().lock().clone();
1824        assert_eq!(stats.min, 10.0);
1825        assert_eq!(stats.max, 50.0);
1826        assert_eq!(stats.mean, 30.0);
1827    }
1828
1829    #[test]
1830    fn test_stats_emits_histogram_and_profile_arrays() {
1831        use ad_core_rs::plugin::runtime::ParamUpdate;
1832        let mut proc = StatsProcessor::new();
1833        // Register params so the array reasons are distinct, non-zero indices.
1834        let mut base = asyn_rs::port::PortDriverBase::new(
1835            "_stats_scratch_",
1836            1,
1837            asyn_rs::port::PortFlags::default(),
1838        );
1839        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
1840        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
1841        proc.register_params(&mut base).unwrap();
1842
1843        proc.config.lock().do_compute_histogram = true;
1844        proc.config.lock().do_compute_profiles = true;
1845        proc.config.lock().hist_size = 8;
1846        proc.config.lock().hist_min = 0.0;
1847        proc.config.lock().hist_max = 7.0;
1848        let pool = NDArrayPool::new(1_000_000);
1849
1850        let mut arr = NDArray::new(
1851            vec![NDDimension::new(4), NDDimension::new(4)],
1852            NDDataType::UInt8,
1853        );
1854        if let NDDataBuffer::U8(ref mut v) = arr.data {
1855            for (i, val) in v.iter_mut().enumerate() {
1856                *val = (i % 8) as u8;
1857            }
1858        }
1859
1860        let result = proc.process_array(&arr, &pool);
1861        let p = proc.params;
1862        // HIST_ARRAY, HIST_X_ARRAY and the 8 PROFILE_* waveforms must be
1863        // pushed as float64 array updates.
1864        let array_reasons: Vec<usize> = result
1865            .param_updates
1866            .iter()
1867            .filter_map(|u| match u {
1868                ParamUpdate::Float64Array { reason, value, .. } => {
1869                    assert!(!value.is_empty(), "array waveform must not be empty");
1870                    Some(*reason)
1871                }
1872                _ => None,
1873            })
1874            .collect();
1875        for reason in [
1876            p.hist_array,
1877            p.hist_x_array,
1878            p.profile_average_x,
1879            p.profile_average_y,
1880            p.profile_threshold_x,
1881            p.profile_threshold_y,
1882            p.profile_centroid_x,
1883            p.profile_centroid_y,
1884            p.profile_cursor_x,
1885            p.profile_cursor_y,
1886        ] {
1887            assert!(
1888                array_reasons.contains(&reason),
1889                "missing array update for reason {reason}"
1890            );
1891        }
1892    }
1893
1894    #[test]
1895    fn test_adp13_ndims_gt_2_skips_centroid_and_profiles() {
1896        use ad_core_rs::plugin::runtime::ParamUpdate;
1897        // C rejects ndims>2 for centroid/profiles (NDPluginStats.cpp:205,338):
1898        // a 4-D array must NOT have its centroid/profiles computed on the first
1899        // two dims as if it were a 2-D image.
1900        let mut proc = StatsProcessor::new();
1901        let mut base = asyn_rs::port::PortDriverBase::new(
1902            "_stats_adp13_",
1903            1,
1904            asyn_rs::port::PortFlags::default(),
1905        );
1906        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
1907        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
1908        proc.register_params(&mut base).unwrap();
1909        proc.config.lock().do_compute_centroid = true;
1910        proc.config.lock().do_compute_profiles = true;
1911        let pool = NDArrayPool::new(1_000_000);
1912
1913        // 4-D mono array [x=4, y=4, z=2, w=2]. The first 4x4 plane has a bright
1914        // column at x=3, so a (wrong) 2-D centroid would be clearly nonzero.
1915        let mut arr = NDArray::new(
1916            vec![
1917                NDDimension::new(4),
1918                NDDimension::new(4),
1919                NDDimension::new(2),
1920                NDDimension::new(2),
1921            ],
1922            NDDataType::UInt8,
1923        );
1924        if let NDDataBuffer::U8(ref mut v) = arr.data {
1925            for (i, val) in v.iter_mut().enumerate() {
1926                *val = if i % 4 == 3 { 100 } else { 0 };
1927            }
1928        }
1929
1930        let result = proc.process_array(&arr, &pool);
1931        let p = proc.params;
1932
1933        // Centroid left at 0 (not computed on a slice).
1934        let centroid_x = result.param_updates.iter().find_map(|u| match u {
1935            ParamUpdate::Float64 { reason, value, .. } if *reason == p.centroid_x => Some(*value),
1936            _ => None,
1937        });
1938        assert_eq!(
1939            centroid_x,
1940            Some(0.0),
1941            "centroid_x must not be computed for ndims>2"
1942        );
1943
1944        // No profile waveforms emitted for a >2-D array.
1945        let profile_reasons = [
1946            p.profile_average_x,
1947            p.profile_average_y,
1948            p.profile_threshold_x,
1949            p.profile_threshold_y,
1950            p.profile_centroid_x,
1951            p.profile_centroid_y,
1952            p.profile_cursor_x,
1953            p.profile_cursor_y,
1954        ];
1955        for u in &result.param_updates {
1956            if let ParamUpdate::Float64Array { reason, .. } = u {
1957                assert!(
1958                    !profile_reasons.contains(reason),
1959                    "no profile waveform may be emitted for ndims>2"
1960                );
1961            }
1962        }
1963    }
1964
1965    #[test]
1966    fn test_adp30_hist_below_above_emitted_as_int32() {
1967        use ad_core_rs::plugin::runtime::ParamUpdate;
1968        // C registers HIST_BELOW/HIST_ABOVE as asynParamInt32 and writes them
1969        // via setIntegerParam (NDPluginStats.cpp:827-828,627-628); a client
1970        // reading the RBVs must get DBR_LONG (Int32), not DBR_DOUBLE.
1971        let mut proc = StatsProcessor::new();
1972        let mut base = asyn_rs::port::PortDriverBase::new(
1973            "_stats_adp30_",
1974            1,
1975            asyn_rs::port::PortFlags::default(),
1976        );
1977        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
1978        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
1979        proc.register_params(&mut base).unwrap();
1980        proc.config.lock().do_compute_histogram = true;
1981        proc.config.lock().hist_size = 4;
1982        proc.config.lock().hist_min = 2.0;
1983        proc.config.lock().hist_max = 5.0;
1984        let pool = NDArrayPool::new(1_000_000);
1985
1986        // 8 pixels: 0,1 below min(2) → below=2; 9,9,9 above max(5) → above=3;
1987        // 3,3,4 in range.
1988        let mut arr = NDArray::new(
1989            vec![NDDimension::new(4), NDDimension::new(2)],
1990            NDDataType::UInt8,
1991        );
1992        if let NDDataBuffer::U8(ref mut v) = arr.data {
1993            v.copy_from_slice(&[0, 1, 3, 3, 9, 9, 9, 4]);
1994        }
1995
1996        let result = proc.process_array(&arr, &pool);
1997        let p = proc.params;
1998
1999        let below = result.param_updates.iter().find_map(|u| match u {
2000            ParamUpdate::Int32 { reason, value, .. } if *reason == p.hist_below => Some(*value),
2001            _ => None,
2002        });
2003        let above = result.param_updates.iter().find_map(|u| match u {
2004            ParamUpdate::Int32 { reason, value, .. } if *reason == p.hist_above => Some(*value),
2005            _ => None,
2006        });
2007        assert_eq!(
2008            below,
2009            Some(2),
2010            "HIST_BELOW must be emitted as an Int32 count"
2011        );
2012        assert_eq!(
2013            above,
2014            Some(3),
2015            "HIST_ABOVE must be emitted as an Int32 count"
2016        );
2017
2018        // And never as Float64 — the param type must be Int32 end to end.
2019        for u in &result.param_updates {
2020            if let ParamUpdate::Float64 { reason, .. } = u {
2021                assert!(
2022                    *reason != p.hist_below && *reason != p.hist_above,
2023                    "HIST_BELOW/HIST_ABOVE must not be emitted as Float64"
2024                );
2025            }
2026        }
2027    }
2028
2029    #[test]
2030    fn test_hist_x_array_uses_bin_count_divisor() {
2031        use ad_core_rs::plugin::runtime::ParamUpdate;
2032        // C++ NDPluginStats::computeHistX: scale = (histMax-histMin)/histSize,
2033        // histX[i] = histMin + i*scale. For histSize=256, min=0, max=255 the
2034        // last bin X must be ~254.0 (= 255*255/256), NOT 255.0.
2035        let mut proc = StatsProcessor::new();
2036        let mut base = asyn_rs::port::PortDriverBase::new(
2037            "_stats_histx_",
2038            1,
2039            asyn_rs::port::PortFlags::default(),
2040        );
2041        let _ = ad_core_rs::params::ndarray_driver::NDArrayDriverParams::create(&mut base);
2042        let _ = ad_core_rs::plugin::params::PluginBaseParams::create(&mut base);
2043        proc.register_params(&mut base).unwrap();
2044
2045        proc.config.lock().do_compute_histogram = true;
2046        proc.config.lock().hist_size = 256;
2047        proc.config.lock().hist_min = 0.0;
2048        proc.config.lock().hist_max = 255.0;
2049        let pool = NDArrayPool::new(1_000_000);
2050
2051        let mut arr = NDArray::new(
2052            vec![NDDimension::new(4), NDDimension::new(4)],
2053            NDDataType::UInt8,
2054        );
2055        if let NDDataBuffer::U8(ref mut v) = arr.data {
2056            for (i, val) in v.iter_mut().enumerate() {
2057                *val = (i * 16) as u8;
2058            }
2059        }
2060
2061        let result = proc.process_array(&arr, &pool);
2062        let hist_x = result
2063            .param_updates
2064            .iter()
2065            .find_map(|u| match u {
2066                ParamUpdate::Float64Array { reason, value, .. }
2067                    if *reason == proc.params.hist_x_array =>
2068                {
2069                    Some(value.clone())
2070                }
2071                _ => None,
2072            })
2073            .expect("HIST_X_ARRAY must be emitted");
2074
2075        assert_eq!(hist_x.len(), 256, "256 bins");
2076        let scale = 255.0 / 256.0;
2077        assert!(
2078            (hist_x[0] - 0.0).abs() < 1e-9,
2079            "bin 0 X must be histMin (0.0), got {}",
2080            hist_x[0]
2081        );
2082        assert!(
2083            (hist_x[1] - scale).abs() < 1e-9,
2084            "bin 1 X must be {scale}, got {}",
2085            hist_x[1]
2086        );
2087        assert!(
2088            (hist_x[255] - 255.0 * scale).abs() < 1e-9,
2089            "last bin X must be ~254.004 (255*255/256), got {}",
2090            hist_x[255]
2091        );
2092        assert!(
2093            hist_x[255] < 255.0,
2094            "last bin X must be strictly below histMax, got {}",
2095            hist_x[255]
2096        );
2097    }
2098
2099    #[test]
2100    fn test_stats_runtime_end_to_end() {
2101        let pool = Arc::new(NDArrayPool::new(1_000_000));
2102        let wiring = Arc::new(WiringRegistry::new());
2103        let ts_registry = crate::time_series::TsReceiverRegistry::new();
2104        let (handle, stats, _params, _jh) =
2105            create_stats_runtime("STATS_RT", pool, 10, "", wiring, &ts_registry);
2106
2107        // Plugins default to disabled — enable for test, and fence until the
2108        // data thread has applied the flip (the write only queues it).
2109        handle
2110            .port_runtime()
2111            .port_handle()
2112            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
2113            .unwrap();
2114        assert!(
2115            handle.wait_params_applied(std::time::Duration::from_secs(10)),
2116            "data thread did not apply EnableCallbacks"
2117        );
2118
2119        let mut arr = NDArray::new(
2120            vec![NDDimension::new(4), NDDimension::new(4)],
2121            NDDataType::UInt8,
2122        );
2123        if let NDDataBuffer::U8(ref mut v) = arr.data {
2124            for (i, val) in v.iter_mut().enumerate() {
2125                *val = (i + 1) as u8;
2126            }
2127        }
2128
2129        let rt = tokio::runtime::Builder::new_current_thread()
2130            .enable_all()
2131            .build()
2132            .unwrap();
2133        rt.block_on(handle.array_sender().publish(Arc::new(arr)));
2134
2135        // Wait on the observable itself — the stats land when the data thread
2136        // processes the array; a fixed sleep is a race on a loaded machine.
2137        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2138        while stats.lock().num_elements != 16 {
2139            assert!(
2140                std::time::Instant::now() < deadline,
2141                "timed out waiting for stats to be computed"
2142            );
2143            std::thread::sleep(std::time::Duration::from_millis(2));
2144        }
2145
2146        let result = stats.lock().clone();
2147        assert_eq!(result.min, 1.0);
2148        assert_eq!(result.max, 16.0);
2149        assert_eq!(result.num_elements, 16);
2150    }
2151}