Skip to main content

ad_plugins/
roi_stat.rs

1//! NDPluginROIStat: computes basic statistics for multiple ROI regions on each array.
2//!
3//! Each ROI is a rectangular sub-region of a 2D image. For each enabled ROI,
4//! the plugin computes min, max, mean, total, and net (background-subtracted total).
5//! Optionally accumulates time series data in circular buffers.
6
7use std::sync::Arc;
8
9use ad_core::ndarray::{NDArray, NDDataBuffer};
10use ad_core::ndarray_pool::NDArrayPool;
11use ad_core::plugin::registry::{build_plugin_base_registry, ParamInfo, ParamRegistry};
12use ad_core::plugin::runtime::{
13    NDPluginProcess, ParamUpdate, PluginParamSnapshot, PluginRuntimeHandle, ProcessResult,
14};
15use ad_core::plugin::wiring::WiringRegistry;
16use asyn_rs::param::ParamType;
17use asyn_rs::port::PortDriverBase;
18use parking_lot::Mutex;
19
20#[cfg(feature = "parallel")]
21use rayon::prelude::*;
22#[cfg(feature = "parallel")]
23use crate::par_util;
24use crate::time_series::{TimeSeriesData, TimeSeriesSender};
25
26/// Configuration for a single ROI region.
27#[derive(Debug, Clone)]
28pub struct ROIStatROI {
29    pub enabled: bool,
30    /// Offset in pixels: [x, y].
31    pub offset: [usize; 2],
32    /// Size in pixels: [x, y].
33    pub size: [usize; 2],
34    /// Width of the background border (pixels). 0 = no background subtraction.
35    pub bgd_width: usize,
36}
37
38impl Default for ROIStatROI {
39    fn default() -> Self {
40        Self {
41            enabled: true,
42            offset: [0, 0],
43            size: [0, 0],
44            bgd_width: 0,
45        }
46    }
47}
48
49/// Statistics computed for a single ROI.
50#[derive(Debug, Clone, Default)]
51pub struct ROIStatResult {
52    pub min: f64,
53    pub max: f64,
54    pub mean: f64,
55    pub total: f64,
56    /// Net = total - background_average * roi_elements. Zero if bgd_width is 0.
57    pub net: f64,
58}
59
60/// Time-series acquisition mode.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum TSMode {
63    Idle,
64    Acquiring,
65}
66
67/// Number of statistics tracked per ROI (min, max, mean, total, net).
68const NUM_STATS: usize = 5;
69
70/// Per-ROI stat names used for time series channel naming.
71const ROI_STAT_NAMES: [&str; NUM_STATS] = ["MinValue", "MaxValue", "MeanValue", "Total", "Net"];
72
73/// Generate time series channel names for ROIStat with the given number of ROIs.
74/// Produces names like "TS1:MinValue", "TS1:MaxValue", ..., "TS2:MinValue", etc.
75pub fn roi_stat_ts_channel_names(num_rois: usize) -> Vec<String> {
76    let mut names = Vec::with_capacity(num_rois * NUM_STATS);
77    for roi_idx in 0..num_rois {
78        for stat_name in &ROI_STAT_NAMES {
79            names.push(format!("TS{}:{}", roi_idx + 1, stat_name));
80        }
81    }
82    names
83}
84
85/// Parameter indices for NDROIStat plugin-specific params.
86///
87/// Per-ROI params use a single index and are differentiated by asyn addr (0..N).
88#[derive(Clone, Copy, Default)]
89pub struct ROIStatParams {
90    // Global (addr 0)
91    pub reset_all: usize,
92    pub ts_control: usize,
93    pub ts_num_points: usize,
94    pub ts_current_point: usize,
95    pub ts_acquiring: usize,
96    // Per-ROI (same index, different addr)
97    pub use_: usize,
98    pub name: usize,
99    pub reset: usize,
100    pub bgd_width: usize,
101    pub dim0_min: usize,
102    pub dim1_min: usize,
103    pub dim0_size: usize,
104    pub dim1_size: usize,
105    pub dim0_max_size: usize,
106    pub dim1_max_size: usize,
107    pub min_value: usize,
108    pub max_value: usize,
109    pub mean_value: usize,
110    pub total: usize,
111    pub net: usize,
112}
113
114/// Processor that computes ROI statistics on 2D arrays.
115pub struct ROIStatProcessor {
116    rois: Vec<ROIStatROI>,
117    results: Vec<ROIStatResult>,
118    /// Time series buffers: [roi_index][stat_index][time_point].
119    ts_mode: TSMode,
120    ts_buffers: Vec<Vec<Vec<f64>>>,
121    ts_num_points: usize,
122    ts_current: usize,
123    /// Optional sender to push flattened stats to a TimeSeriesPortDriver.
124    ts_sender: Option<TimeSeriesSender>,
125    /// Registered asyn param indices.
126    params: ROIStatParams,
127    /// Shared cell to export params after register_params is called.
128    params_out: Arc<Mutex<ROIStatParams>>,
129}
130
131impl ROIStatProcessor {
132    /// Create a new processor with the given ROI definitions.
133    pub fn new(rois: Vec<ROIStatROI>, ts_num_points: usize) -> Self {
134        let n = rois.len();
135        let results = vec![ROIStatResult::default(); n];
136        let ts_buffers = vec![vec![Vec::new(); NUM_STATS]; n];
137        Self {
138            rois,
139            results,
140            ts_mode: TSMode::Idle,
141            ts_buffers,
142            ts_num_points,
143            ts_current: 0,
144            ts_sender: None,
145            params: ROIStatParams::default(),
146            params_out: Arc::new(Mutex::new(ROIStatParams::default())),
147        }
148    }
149
150    /// Get a shared handle to the params (populated after register_params is called).
151    pub fn params_handle(&self) -> Arc<Mutex<ROIStatParams>> {
152        self.params_out.clone()
153    }
154
155    /// Get the current results for all ROIs.
156    pub fn results(&self) -> &[ROIStatResult] {
157        &self.results
158    }
159
160    /// Get the ROI definitions.
161    pub fn rois(&self) -> &[ROIStatROI] {
162        &self.rois
163    }
164
165    /// Mutable access to ROI definitions.
166    pub fn rois_mut(&mut self) -> &mut Vec<ROIStatROI> {
167        &mut self.rois
168    }
169
170    /// Set the time series mode.
171    pub fn set_ts_mode(&mut self, mode: TSMode) {
172        if mode == TSMode::Acquiring && self.ts_mode != TSMode::Acquiring {
173            // Reset time series on start
174            for roi_bufs in &mut self.ts_buffers {
175                for stat_buf in roi_bufs.iter_mut() {
176                    stat_buf.clear();
177                }
178            }
179            self.ts_current = 0;
180        }
181        self.ts_mode = mode;
182    }
183
184    /// Get time series buffer for a specific ROI and stat index.
185    /// stat_index: 0=min, 1=max, 2=mean, 3=total, 4=net
186    pub fn ts_buffer(&self, roi_index: usize, stat_index: usize) -> &[f64] {
187        if roi_index < self.ts_buffers.len() && stat_index < NUM_STATS {
188            &self.ts_buffers[roi_index][stat_index]
189        } else {
190            &[]
191        }
192    }
193
194    /// Set the sender for pushing time series data to a TimeSeriesPortDriver.
195    pub fn set_ts_sender(&mut self, sender: TimeSeriesSender) {
196        self.ts_sender = Some(sender);
197    }
198
199    /// Compute statistics for a single ROI on a 2D data buffer.
200    pub fn compute_roi_stats(
201        data: &NDDataBuffer,
202        x_size: usize,
203        y_size: usize,
204        roi: &ROIStatROI,
205    ) -> ROIStatResult {
206        let roi_x = roi.offset[0];
207        let roi_y = roi.offset[1];
208        let roi_w = roi.size[0];
209        let roi_h = roi.size[1];
210
211        // Clamp ROI to image bounds
212        if roi_x >= x_size || roi_y >= y_size || roi_w == 0 || roi_h == 0 {
213            return ROIStatResult::default();
214        }
215        let roi_w = roi_w.min(x_size - roi_x);
216        let roi_h = roi_h.min(y_size - roi_y);
217
218        let mut min = f64::MAX;
219        let mut max = f64::MIN;
220        let mut total = 0.0f64;
221        let mut count = 0usize;
222
223        for iy in roi_y..(roi_y + roi_h) {
224            for ix in roi_x..(roi_x + roi_w) {
225                let idx = iy * x_size + ix;
226                if let Some(val) = data.get_as_f64(idx) {
227                    if val < min { min = val; }
228                    if val > max { max = val; }
229                    total += val;
230                    count += 1;
231                }
232            }
233        }
234
235        if count == 0 {
236            return ROIStatResult::default();
237        }
238
239        let mean = total / count as f64;
240
241        // Background subtraction
242        let net = if roi.bgd_width > 0 {
243            let bgd = Self::compute_background(data, x_size, y_size, roi);
244            total - bgd * count as f64
245        } else {
246            total
247        };
248
249        ROIStatResult { min, max, mean, total, net }
250    }
251
252    /// Compute average background from the border of the ROI.
253    fn compute_background(
254        data: &NDDataBuffer,
255        x_size: usize,
256        y_size: usize,
257        roi: &ROIStatROI,
258    ) -> f64 {
259        let roi_x = roi.offset[0];
260        let roi_y = roi.offset[1];
261        let roi_w = roi.size[0].min(x_size.saturating_sub(roi_x));
262        let roi_h = roi.size[1].min(y_size.saturating_sub(roi_y));
263        let bw = roi.bgd_width;
264
265        if bw == 0 || roi_w == 0 || roi_h == 0 {
266            return 0.0;
267        }
268
269        let mut bgd_total = 0.0f64;
270        let mut bgd_count = 0usize;
271
272        for iy in roi_y..(roi_y + roi_h) {
273            for ix in roi_x..(roi_x + roi_w) {
274                // Check if this pixel is in the border region
275                let dx_from_left = ix - roi_x;
276                let dx_from_right = (roi_x + roi_w - 1) - ix;
277                let dy_from_top = iy - roi_y;
278                let dy_from_bottom = (roi_y + roi_h - 1) - iy;
279
280                let in_border = dx_from_left < bw
281                    || dx_from_right < bw
282                    || dy_from_top < bw
283                    || dy_from_bottom < bw;
284
285                if in_border {
286                    let idx = iy * x_size + ix;
287                    if let Some(val) = data.get_as_f64(idx) {
288                        bgd_total += val;
289                        bgd_count += 1;
290                    }
291                }
292            }
293        }
294
295        if bgd_count == 0 { 0.0 } else { bgd_total / bgd_count as f64 }
296    }
297}
298
299impl NDPluginProcess for ROIStatProcessor {
300    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
301        let info = array.info();
302        let x_size = info.x_size;
303        let y_size = info.y_size;
304
305        // Ensure results vec matches rois
306        self.results.resize(self.rois.len(), ROIStatResult::default());
307
308        #[cfg(feature = "parallel")]
309        {
310            let total_elements: usize = self.rois.iter()
311                .filter(|r| r.enabled)
312                .map(|r| r.size[0] * r.size[1])
313                .sum();
314
315            if par_util::should_parallelize(total_elements) {
316                let data = &array.data;
317                let rois = &self.rois;
318                let new_results: Vec<ROIStatResult> = par_util::thread_pool().install(|| {
319                    rois.par_iter()
320                        .map(|roi| {
321                            if roi.enabled {
322                                Self::compute_roi_stats(data, x_size, y_size, roi)
323                            } else {
324                                ROIStatResult::default()
325                            }
326                        })
327                        .collect()
328                });
329                self.results = new_results;
330            } else {
331                for (i, roi) in self.rois.iter().enumerate() {
332                    if !roi.enabled {
333                        self.results[i] = ROIStatResult::default();
334                        continue;
335                    }
336                    self.results[i] = Self::compute_roi_stats(&array.data, x_size, y_size, roi);
337                }
338            }
339        }
340
341        #[cfg(not(feature = "parallel"))]
342        for (i, roi) in self.rois.iter().enumerate() {
343            if !roi.enabled {
344                self.results[i] = ROIStatResult::default();
345                continue;
346            }
347            self.results[i] = Self::compute_roi_stats(&array.data, x_size, y_size, roi);
348        }
349
350        // Accumulate time series
351        if self.ts_mode == TSMode::Acquiring {
352            // Ensure ts_buffers match roi count
353            while self.ts_buffers.len() < self.rois.len() {
354                self.ts_buffers.push(vec![Vec::new(); NUM_STATS]);
355            }
356
357            for (i, result) in self.results.iter().enumerate() {
358                if i >= self.ts_buffers.len() { break; }
359                let stats = [result.min, result.max, result.mean, result.total, result.net];
360                for (s, &val) in stats.iter().enumerate() {
361                    let buf = &mut self.ts_buffers[i][s];
362                    if buf.len() >= self.ts_num_points && self.ts_num_points > 0 {
363                        // Circular: overwrite oldest
364                        let idx = self.ts_current % self.ts_num_points;
365                        if idx < buf.len() {
366                            buf[idx] = val;
367                        }
368                    } else {
369                        buf.push(val);
370                    }
371                }
372            }
373            self.ts_current += 1;
374        }
375
376        // Send flattened stats to TimeSeriesPortDriver if connected
377        if let Some(ref sender) = self.ts_sender {
378            let mut values = Vec::with_capacity(self.results.len() * NUM_STATS);
379            for result in &self.results {
380                values.push(result.min);
381                values.push(result.max);
382                values.push(result.mean);
383                values.push(result.total);
384                values.push(result.net);
385            }
386            let _ = sender.try_send(TimeSeriesData { values });
387        }
388
389        // Build per-ROI param updates
390        let p = &self.params;
391        let mut updates = Vec::new();
392        for (i, result) in self.results.iter().enumerate() {
393            let addr = i as i32;
394            updates.push(ParamUpdate::float64_addr(p.min_value, addr, result.min));
395            updates.push(ParamUpdate::float64_addr(p.max_value, addr, result.max));
396            updates.push(ParamUpdate::float64_addr(p.mean_value, addr, result.mean));
397            updates.push(ParamUpdate::float64_addr(p.total, addr, result.total));
398            updates.push(ParamUpdate::float64_addr(p.net, addr, result.net));
399            updates.push(ParamUpdate::int32_addr(p.dim0_max_size, addr, x_size as i32));
400            updates.push(ParamUpdate::int32_addr(p.dim1_max_size, addr, y_size as i32));
401        }
402        updates.push(ParamUpdate::int32(p.ts_current_point, self.ts_current as i32));
403        updates.push(ParamUpdate::int32(p.ts_acquiring, if self.ts_mode == TSMode::Acquiring { 1 } else { 0 }));
404
405        ProcessResult::sink(updates)
406    }
407
408    fn plugin_type(&self) -> &str {
409        "NDPluginROIStat"
410    }
411
412    fn register_params(&mut self, base: &mut PortDriverBase) -> Result<(), asyn_rs::error::AsynError> {
413        // Global params
414        self.params.reset_all = base.create_param("ROISTAT_RESETALL", ParamType::Int32)?;
415        self.params.ts_control = base.create_param("ROISTAT_TS_CONTROL", ParamType::Int32)?;
416        self.params.ts_num_points = base.create_param("ROISTAT_TS_NUM_POINTS", ParamType::Int32)?;
417        base.set_int32_param(self.params.ts_num_points, 0, self.ts_num_points as i32)?;
418        self.params.ts_current_point = base.create_param("ROISTAT_TS_CURRENT_POINT", ParamType::Int32)?;
419        self.params.ts_acquiring = base.create_param("ROISTAT_TS_ACQUIRING", ParamType::Int32)?;
420
421        // Per-ROI params (single index, differentiated by addr)
422        self.params.use_ = base.create_param("ROISTAT_USE", ParamType::Int32)?;
423        self.params.name = base.create_param("ROISTAT_NAME", ParamType::Octet)?;
424        self.params.reset = base.create_param("ROISTAT_RESET", ParamType::Int32)?;
425        self.params.bgd_width = base.create_param("ROISTAT_BGD_WIDTH", ParamType::Int32)?;
426        self.params.dim0_min = base.create_param("ROISTAT_DIM0_MIN", ParamType::Int32)?;
427        self.params.dim1_min = base.create_param("ROISTAT_DIM1_MIN", ParamType::Int32)?;
428        self.params.dim0_size = base.create_param("ROISTAT_DIM0_SIZE", ParamType::Int32)?;
429        self.params.dim1_size = base.create_param("ROISTAT_DIM1_SIZE", ParamType::Int32)?;
430        self.params.dim0_max_size = base.create_param("ROISTAT_DIM0_MAX_SIZE", ParamType::Int32)?;
431        self.params.dim1_max_size = base.create_param("ROISTAT_DIM1_MAX_SIZE", ParamType::Int32)?;
432        self.params.min_value = base.create_param("ROISTAT_MIN_VALUE", ParamType::Float64)?;
433        self.params.max_value = base.create_param("ROISTAT_MAX_VALUE", ParamType::Float64)?;
434        self.params.mean_value = base.create_param("ROISTAT_MEAN_VALUE", ParamType::Float64)?;
435        self.params.total = base.create_param("ROISTAT_TOTAL", ParamType::Float64)?;
436        self.params.net = base.create_param("ROISTAT_NET", ParamType::Float64)?;
437
438        // Set initial per-ROI values
439        for (i, roi) in self.rois.iter().enumerate() {
440            let addr = i as i32;
441            base.set_int32_param(self.params.use_, addr, roi.enabled as i32)?;
442            base.set_int32_param(self.params.bgd_width, addr, roi.bgd_width as i32)?;
443            base.set_int32_param(self.params.dim0_min, addr, roi.offset[0] as i32)?;
444            base.set_int32_param(self.params.dim1_min, addr, roi.offset[1] as i32)?;
445            base.set_int32_param(self.params.dim0_size, addr, roi.size[0] as i32)?;
446            base.set_int32_param(self.params.dim1_size, addr, roi.size[1] as i32)?;
447        }
448
449        // Export params
450        *self.params_out.lock() = self.params;
451
452        Ok(())
453    }
454
455    fn on_param_change(&mut self, reason: usize, snapshot: &PluginParamSnapshot) {
456        let addr = snapshot.addr as usize;
457        let p = &self.params;
458
459        if reason == p.use_ && addr < self.rois.len() {
460            self.rois[addr].enabled = snapshot.value.as_i32() != 0;
461        } else if reason == p.dim0_min && addr < self.rois.len() {
462            self.rois[addr].offset[0] = snapshot.value.as_i32().max(0) as usize;
463        } else if reason == p.dim1_min && addr < self.rois.len() {
464            self.rois[addr].offset[1] = snapshot.value.as_i32().max(0) as usize;
465        } else if reason == p.dim0_size && addr < self.rois.len() {
466            self.rois[addr].size[0] = snapshot.value.as_i32().max(0) as usize;
467        } else if reason == p.dim1_size && addr < self.rois.len() {
468            self.rois[addr].size[1] = snapshot.value.as_i32().max(0) as usize;
469        } else if reason == p.bgd_width && addr < self.rois.len() {
470            self.rois[addr].bgd_width = snapshot.value.as_i32().max(0) as usize;
471        } else if reason == p.reset && addr < self.rois.len() {
472            self.results[addr] = ROIStatResult::default();
473        } else if reason == p.reset_all {
474            for r in &mut self.results {
475                *r = ROIStatResult::default();
476            }
477        } else if reason == p.ts_control {
478            let mode = if snapshot.value.as_i32() != 0 { TSMode::Acquiring } else { TSMode::Idle };
479            self.set_ts_mode(mode);
480        } else if reason == p.ts_num_points {
481            self.ts_num_points = snapshot.value.as_i32().max(0) as usize;
482        }
483    }
484}
485
486/// Create a ROIStat plugin runtime with multi-addr support.
487///
488/// Returns:
489/// - Plugin runtime handle
490/// - ROIStatParams (for building registry)
491/// - Thread join handle
492pub fn create_roi_stat_runtime(
493    port_name: &str,
494    pool: Arc<NDArrayPool>,
495    queue_size: usize,
496    ndarray_port: &str,
497    wiring: Arc<WiringRegistry>,
498    num_rois: usize,
499) -> (PluginRuntimeHandle, ROIStatParams, std::thread::JoinHandle<()>) {
500    let rois: Vec<ROIStatROI> = (0..num_rois).map(|_| ROIStatROI::default()).collect();
501    let processor = ROIStatProcessor::new(rois, 2048);
502    let params_handle = processor.params_handle();
503
504    let (handle, jh) = ad_core::plugin::runtime::create_plugin_runtime_multi_addr(
505        port_name,
506        processor,
507        pool,
508        queue_size,
509        ndarray_port,
510        wiring,
511        num_rois,
512    );
513
514    // Params were populated by register_params and exported via the shared handle.
515    let roi_stat_params = *params_handle.lock();
516
517    (handle, roi_stat_params, jh)
518}
519
520/// Build a ParamRegistry that maps NDROIStat template record suffixes to asyn param indices.
521pub fn build_roi_stat_registry(h: &PluginRuntimeHandle, rp: &ROIStatParams) -> ParamRegistry {
522    let mut map = build_plugin_base_registry(h);
523
524    // Global params
525    map.insert("ResetAll".into(), ParamInfo::int32(rp.reset_all, "ROISTAT_RESETALL"));
526    map.insert("TSControl".into(), ParamInfo::int32(rp.ts_control, "ROISTAT_TS_CONTROL"));
527    map.insert("TSControl_RBV".into(), ParamInfo::int32(rp.ts_control, "ROISTAT_TS_CONTROL"));
528    map.insert("TSNumPoints".into(), ParamInfo::int32(rp.ts_num_points, "ROISTAT_TS_NUM_POINTS"));
529    map.insert("TSNumPoints_RBV".into(), ParamInfo::int32(rp.ts_num_points, "ROISTAT_TS_NUM_POINTS"));
530    map.insert("TSCurrentPoint".into(), ParamInfo::int32(rp.ts_current_point, "ROISTAT_TS_CURRENT_POINT"));
531    map.insert("TSAcquiring".into(), ParamInfo::int32(rp.ts_acquiring, "ROISTAT_TS_ACQUIRING"));
532
533    // Per-ROI params (same indices for all addrs — addr is resolved via the link)
534    map.insert("Use".into(), ParamInfo::int32(rp.use_, "ROISTAT_USE"));
535    map.insert("Use_RBV".into(), ParamInfo::int32(rp.use_, "ROISTAT_USE"));
536    map.insert("Name".into(), ParamInfo::string(rp.name, "ROISTAT_NAME"));
537    map.insert("Name_RBV".into(), ParamInfo::string(rp.name, "ROISTAT_NAME"));
538    map.insert("Reset".into(), ParamInfo::int32(rp.reset, "ROISTAT_RESET"));
539    map.insert("BgdWidth".into(), ParamInfo::int32(rp.bgd_width, "ROISTAT_BGD_WIDTH"));
540    map.insert("BgdWidth_RBV".into(), ParamInfo::int32(rp.bgd_width, "ROISTAT_BGD_WIDTH"));
541
542    // ROI geometry
543    map.insert("MinX".into(), ParamInfo::int32(rp.dim0_min, "ROISTAT_DIM0_MIN"));
544    map.insert("MinX_RBV".into(), ParamInfo::int32(rp.dim0_min, "ROISTAT_DIM0_MIN"));
545    map.insert("MinY".into(), ParamInfo::int32(rp.dim1_min, "ROISTAT_DIM1_MIN"));
546    map.insert("MinY_RBV".into(), ParamInfo::int32(rp.dim1_min, "ROISTAT_DIM1_MIN"));
547    map.insert("SizeX".into(), ParamInfo::int32(rp.dim0_size, "ROISTAT_DIM0_SIZE"));
548    map.insert("SizeX_RBV".into(), ParamInfo::int32(rp.dim0_size, "ROISTAT_DIM0_SIZE"));
549    map.insert("SizeY".into(), ParamInfo::int32(rp.dim1_size, "ROISTAT_DIM1_SIZE"));
550    map.insert("SizeY_RBV".into(), ParamInfo::int32(rp.dim1_size, "ROISTAT_DIM1_SIZE"));
551    map.insert("MaxSizeX_RBV".into(), ParamInfo::int32(rp.dim0_max_size, "ROISTAT_DIM0_MAX_SIZE"));
552    map.insert("MaxSizeY_RBV".into(), ParamInfo::int32(rp.dim1_max_size, "ROISTAT_DIM1_MAX_SIZE"));
553
554    // Statistics readbacks
555    map.insert("MinValue_RBV".into(), ParamInfo::float64(rp.min_value, "ROISTAT_MIN_VALUE"));
556    map.insert("MaxValue_RBV".into(), ParamInfo::float64(rp.max_value, "ROISTAT_MAX_VALUE"));
557    map.insert("MeanValue_RBV".into(), ParamInfo::float64(rp.mean_value, "ROISTAT_MEAN_VALUE"));
558    map.insert("Total_RBV".into(), ParamInfo::float64(rp.total, "ROISTAT_TOTAL"));
559    map.insert("Net_RBV".into(), ParamInfo::float64(rp.net, "ROISTAT_NET"));
560
561    map
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use ad_core::ndarray::{NDDataType, NDDimension};
568
569    fn make_2d_array(x: usize, y: usize, fill: impl Fn(usize, usize) -> f64) -> NDArray {
570        let mut arr = NDArray::new(
571            vec![NDDimension::new(x), NDDimension::new(y)],
572            NDDataType::Float64,
573        );
574        if let NDDataBuffer::F64(ref mut v) = arr.data {
575            for iy in 0..y {
576                for ix in 0..x {
577                    v[iy * x + ix] = fill(ix, iy);
578                }
579            }
580        }
581        arr
582    }
583
584    #[test]
585    fn test_single_roi_full_image() {
586        let arr = make_2d_array(4, 4, |_x, _y| 10.0);
587        let rois = vec![ROIStatROI {
588            enabled: true,
589            offset: [0, 0],
590            size: [4, 4],
591            bgd_width: 0,
592        }];
593
594        let mut proc = ROIStatProcessor::new(rois, 0);
595        let pool = NDArrayPool::new(1_000_000);
596        proc.process_array(&arr, &pool);
597
598        let r = &proc.results()[0];
599        assert!((r.min - 10.0).abs() < 1e-10);
600        assert!((r.max - 10.0).abs() < 1e-10);
601        assert!((r.mean - 10.0).abs() < 1e-10);
602        assert!((r.total - 160.0).abs() < 1e-10);
603    }
604
605    #[test]
606    fn test_single_roi_subregion() {
607        // 8x8 image, values = x + y * 8
608        let arr = make_2d_array(8, 8, |x, y| (x + y * 8) as f64);
609
610        let rois = vec![ROIStatROI {
611            enabled: true,
612            offset: [2, 2],
613            size: [3, 3],
614            bgd_width: 0,
615        }];
616
617        let mut proc = ROIStatProcessor::new(rois, 0);
618        let pool = NDArrayPool::new(1_000_000);
619        proc.process_array(&arr, &pool);
620
621        let r = &proc.results()[0];
622        // ROI pixels: (2,2)=18, (3,2)=19, (4,2)=20, (2,3)=26, (3,3)=27, (4,3)=28, (2,4)=34, (3,4)=35, (4,4)=36
623        assert!((r.min - 18.0).abs() < 1e-10);
624        assert!((r.max - 36.0).abs() < 1e-10);
625        let expected_total = 18.0 + 19.0 + 20.0 + 26.0 + 27.0 + 28.0 + 34.0 + 35.0 + 36.0;
626        assert!((r.total - expected_total).abs() < 1e-10);
627        assert!((r.mean - expected_total / 9.0).abs() < 1e-10);
628    }
629
630    #[test]
631    fn test_multiple_rois() {
632        let arr = make_2d_array(8, 8, |x, _y| x as f64);
633
634        let rois = vec![
635            ROIStatROI {
636                enabled: true,
637                offset: [0, 0],
638                size: [4, 4],
639                bgd_width: 0,
640            },
641            ROIStatROI {
642                enabled: true,
643                offset: [4, 0],
644                size: [4, 4],
645                bgd_width: 0,
646            },
647        ];
648
649        let mut proc = ROIStatProcessor::new(rois, 0);
650        let pool = NDArrayPool::new(1_000_000);
651        proc.process_array(&arr, &pool);
652
653        let r0 = &proc.results()[0];
654        assert!((r0.min - 0.0).abs() < 1e-10);
655        assert!((r0.max - 3.0).abs() < 1e-10);
656
657        let r1 = &proc.results()[1];
658        assert!((r1.min - 4.0).abs() < 1e-10);
659        assert!((r1.max - 7.0).abs() < 1e-10);
660    }
661
662    #[test]
663    fn test_bgd_width() {
664        // 6x6 image, center 2x2 has value 100, border has value 10
665        let arr = make_2d_array(6, 6, |x, y| {
666            if x >= 2 && x < 4 && y >= 2 && y < 4 {
667                100.0
668            } else {
669                10.0
670            }
671        });
672
673        let rois = vec![ROIStatROI {
674            enabled: true,
675            offset: [1, 1],
676            size: [4, 4],
677            bgd_width: 1,
678        }];
679
680        let mut proc = ROIStatProcessor::new(rois, 0);
681        let pool = NDArrayPool::new(1_000_000);
682        proc.process_array(&arr, &pool);
683
684        let r = &proc.results()[0];
685        // ROI is 4x4 at (1,1): border pixels = 12 (all with value 10), center = 4 (value 100)
686        // bgd average = (12*10 + ... well, border includes some 100s)
687        // Actually border pixels at bgd_width=1: the outer ring of the 4x4 ROI
688        // That outer ring occupies 12 of 16 pixels
689        assert!(r.net < r.total, "net should be less than total with bgd subtraction");
690    }
691
692    #[test]
693    fn test_empty_roi() {
694        let arr = make_2d_array(4, 4, |_, _| 10.0);
695        let rois = vec![ROIStatROI {
696            enabled: true,
697            offset: [0, 0],
698            size: [0, 0],
699            bgd_width: 0,
700        }];
701
702        let mut proc = ROIStatProcessor::new(rois, 0);
703        let pool = NDArrayPool::new(1_000_000);
704        proc.process_array(&arr, &pool);
705
706        let r = &proc.results()[0];
707        assert!((r.total - 0.0).abs() < 1e-10);
708    }
709
710    #[test]
711    fn test_disabled_roi() {
712        let arr = make_2d_array(4, 4, |_, _| 10.0);
713        let rois = vec![ROIStatROI {
714            enabled: false,
715            offset: [0, 0],
716            size: [4, 4],
717            bgd_width: 0,
718        }];
719
720        let mut proc = ROIStatProcessor::new(rois, 0);
721        let pool = NDArrayPool::new(1_000_000);
722        proc.process_array(&arr, &pool);
723
724        let r = &proc.results()[0];
725        assert!((r.total - 0.0).abs() < 1e-10, "disabled ROI should have zero stats");
726    }
727
728    #[test]
729    fn test_roi_out_of_bounds() {
730        let arr = make_2d_array(4, 4, |_, _| 10.0);
731        let rois = vec![ROIStatROI {
732            enabled: true,
733            offset: [10, 10],
734            size: [4, 4],
735            bgd_width: 0,
736        }];
737
738        let mut proc = ROIStatProcessor::new(rois, 0);
739        let pool = NDArrayPool::new(1_000_000);
740        proc.process_array(&arr, &pool);
741
742        let r = &proc.results()[0];
743        assert!((r.total - 0.0).abs() < 1e-10, "out-of-bounds ROI should produce zero stats");
744    }
745
746    #[test]
747    fn test_roi_partially_out_of_bounds() {
748        let arr = make_2d_array(4, 4, |_, _| 5.0);
749        let rois = vec![ROIStatROI {
750            enabled: true,
751            offset: [2, 2],
752            size: [10, 10],  // extends beyond image
753            bgd_width: 0,
754        }];
755
756        let mut proc = ROIStatProcessor::new(rois, 0);
757        let pool = NDArrayPool::new(1_000_000);
758        proc.process_array(&arr, &pool);
759
760        let r = &proc.results()[0];
761        // Should be clamped to 2x2 region
762        assert!((r.total - 20.0).abs() < 1e-10);
763        assert!((r.mean - 5.0).abs() < 1e-10);
764    }
765
766    #[test]
767    fn test_time_series() {
768        let rois = vec![ROIStatROI {
769            enabled: true,
770            offset: [0, 0],
771            size: [4, 4],
772            bgd_width: 0,
773        }];
774
775        let mut proc = ROIStatProcessor::new(rois, 100);
776        let pool = NDArrayPool::new(1_000_000);
777        proc.set_ts_mode(TSMode::Acquiring);
778
779        for i in 0..5 {
780            let arr = make_2d_array(4, 4, |_, _| (i + 1) as f64);
781            proc.process_array(&arr, &pool);
782        }
783
784        // Check mean time series (stat index 2)
785        let ts = proc.ts_buffer(0, 2);
786        assert_eq!(ts.len(), 5);
787        assert!((ts[0] - 1.0).abs() < 1e-10);
788        assert!((ts[4] - 5.0).abs() < 1e-10);
789    }
790
791    #[test]
792    fn test_u8_data() {
793        let mut arr = NDArray::new(
794            vec![NDDimension::new(4), NDDimension::new(4)],
795            NDDataType::UInt8,
796        );
797        if let NDDataBuffer::U8(ref mut v) = arr.data {
798            for (i, val) in v.iter_mut().enumerate() {
799                *val = (i + 1) as u8;
800            }
801        }
802
803        let rois = vec![ROIStatROI {
804            enabled: true,
805            offset: [0, 0],
806            size: [4, 4],
807            bgd_width: 0,
808        }];
809
810        let mut proc = ROIStatProcessor::new(rois, 0);
811        let pool = NDArrayPool::new(1_000_000);
812        proc.process_array(&arr, &pool);
813
814        let r = &proc.results()[0];
815        assert!((r.min - 1.0).abs() < 1e-10);
816        assert!((r.max - 16.0).abs() < 1e-10);
817    }
818
819    #[test]
820    fn test_ts_channel_names() {
821        let names = roi_stat_ts_channel_names(2);
822        assert_eq!(names.len(), 10); // 2 ROIs * 5 stats
823        assert_eq!(names[0], "TS1:MinValue");
824        assert_eq!(names[1], "TS1:MaxValue");
825        assert_eq!(names[4], "TS1:Net");
826        assert_eq!(names[5], "TS2:MinValue");
827        assert_eq!(names[9], "TS2:Net");
828    }
829
830    #[test]
831    fn test_ts_sender_integration() {
832        let (tx, mut rx) = tokio::sync::mpsc::channel::<TimeSeriesData>(16);
833
834        let rois = vec![
835            ROIStatROI { enabled: true, offset: [0, 0], size: [4, 4], bgd_width: 0 },
836            ROIStatROI { enabled: true, offset: [0, 0], size: [2, 2], bgd_width: 0 },
837        ];
838
839        let mut proc = ROIStatProcessor::new(rois, 0);
840        proc.set_ts_sender(tx);
841
842        let pool = NDArrayPool::new(1_000_000);
843        let arr = make_2d_array(4, 4, |_, _| 7.0);
844        proc.process_array(&arr, &pool);
845
846        let data = rx.try_recv().unwrap();
847        // 2 ROIs * 5 stats = 10 values
848        assert_eq!(data.values.len(), 10);
849        // ROI1: min=7, max=7, mean=7, total=112 (4*4*7), net=112
850        assert!((data.values[0] - 7.0).abs() < 1e-10); // min
851        assert!((data.values[1] - 7.0).abs() < 1e-10); // max
852        assert!((data.values[2] - 7.0).abs() < 1e-10); // mean
853        assert!((data.values[3] - 112.0).abs() < 1e-10); // total
854        // ROI2: 2x2 region, total=28 (2*2*7)
855        assert!((data.values[8] - 28.0).abs() < 1e-10); // total
856    }
857}