Skip to main content

ad_plugins_rs/
stats.rs

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