Skip to main content

rustpix_core/
neutron.rs

1//! Neutron event output type.
2//!
3
4/// A detected neutron event after clustering and centroid extraction.
5///
6/// Coordinates are in super-resolution space (default 8x pixel resolution).
7#[derive(Clone, Copy, Debug, Default, PartialEq)]
8#[repr(C)]
9pub struct Neutron {
10    /// X coordinate in super-resolution space.
11    pub x: f64,
12    /// Y coordinate in super-resolution space.
13    pub y: f64,
14    /// Time-of-flight in 25ns units.
15    pub tof: u32,
16    /// Combined time-over-threshold.
17    pub tot: u16,
18    /// Number of hits in cluster.
19    pub n_hits: u16,
20    /// Source chip ID.
21    pub chip_id: u8,
22    /// Reserved for alignment.
23    #[doc(hidden)]
24    pub reserved: [u8; 3],
25}
26
27impl Neutron {
28    /// Create a new neutron from cluster data.
29    #[must_use]
30    pub fn new(x: f64, y: f64, tof: u32, tot: u16, n_hits: u16, chip_id: u8) -> Self {
31        Self {
32            x,
33            y,
34            tof,
35            tot,
36            n_hits,
37            chip_id,
38            reserved: [0; 3],
39        }
40    }
41
42    /// TOF in nanoseconds.
43    #[inline]
44    #[must_use]
45    pub fn tof_ns(&self) -> f64 {
46        f64::from(self.tof) * 25.0
47    }
48
49    /// TOF in milliseconds.
50    #[inline]
51    #[must_use]
52    pub fn tof_ms(&self) -> f64 {
53        self.tof_ns() / 1_000_000.0
54    }
55
56    /// Pixel coordinates (divide by super-resolution factor).
57    #[inline]
58    #[must_use]
59    pub fn pixel_coords(&self, super_res: f64) -> (f64, f64) {
60        (self.x / super_res, self.y / super_res)
61    }
62
63    /// Cluster size category.
64    #[must_use]
65    pub fn cluster_size_category(&self) -> ClusterSize {
66        match self.n_hits {
67            1 => ClusterSize::Single,
68            2..=4 => ClusterSize::Small,
69            5..=10 => ClusterSize::Medium,
70            _ => ClusterSize::Large,
71        }
72    }
73}
74
75/// Cluster size categories for analysis.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum ClusterSize {
78    /// Single-hit cluster.
79    Single,
80    /// Small cluster (2-4 hits).
81    Small,
82    /// Medium cluster (5-10 hits).
83    Medium,
84    /// Large cluster (>10 hits).
85    Large,
86}
87
88/// Statistics for a collection of neutrons.
89#[derive(Clone, Debug, Default)]
90pub struct NeutronStatistics {
91    /// Number of neutrons in the sample.
92    pub count: usize,
93    /// Mean time-of-flight (25ns units).
94    pub mean_tof: f64,
95    /// Standard deviation of time-of-flight (25ns units).
96    pub std_tof: f64,
97    /// Mean time-over-threshold.
98    pub mean_tot: f64,
99    /// Mean cluster size (hits per neutron).
100    pub mean_cluster_size: f64,
101    /// Fraction of single-hit neutrons.
102    pub single_hit_fraction: f64,
103    /// Min/max X coordinate.
104    pub x_range: (f64, f64),
105    /// Min/max Y coordinate.
106    pub y_range: (f64, f64),
107    /// Min/max time-of-flight (25ns units).
108    pub tof_range: (u32, u32),
109}
110
111/// Structure-of-arrays neutron output.
112#[derive(Clone, Debug, Default)]
113pub struct NeutronBatch {
114    /// X coordinates (super-resolution space).
115    pub x: Vec<f64>,
116    /// Y coordinates (super-resolution space).
117    pub y: Vec<f64>,
118    /// Time-of-flight values (25ns units).
119    pub tof: Vec<u32>,
120    /// Time-over-threshold values.
121    pub tot: Vec<u16>,
122    /// Number of hits per neutron.
123    pub n_hits: Vec<u16>,
124    /// Chip ID per neutron.
125    pub chip_id: Vec<u8>,
126}
127
128impl NeutronBatch {
129    /// Create a batch with pre-allocated capacity.
130    #[must_use]
131    pub fn with_capacity(capacity: usize) -> Self {
132        Self {
133            x: Vec::with_capacity(capacity),
134            y: Vec::with_capacity(capacity),
135            tof: Vec::with_capacity(capacity),
136            tot: Vec::with_capacity(capacity),
137            n_hits: Vec::with_capacity(capacity),
138            chip_id: Vec::with_capacity(capacity),
139        }
140    }
141
142    /// Number of neutrons in the batch.
143    #[must_use]
144    pub fn len(&self) -> usize {
145        self.x.len()
146    }
147
148    /// Returns true when the batch is empty.
149    #[must_use]
150    pub fn is_empty(&self) -> bool {
151        self.x.is_empty()
152    }
153
154    /// Append a single neutron to the batch.
155    pub fn push(&mut self, neutron: Neutron) {
156        self.x.push(neutron.x);
157        self.y.push(neutron.y);
158        self.tof.push(neutron.tof);
159        self.tot.push(neutron.tot);
160        self.n_hits.push(neutron.n_hits);
161        self.chip_id.push(neutron.chip_id);
162    }
163
164    /// Append all neutrons from another batch.
165    pub fn append(&mut self, other: &NeutronBatch) {
166        self.x.extend_from_slice(&other.x);
167        self.y.extend_from_slice(&other.y);
168        self.tof.extend_from_slice(&other.tof);
169        self.tot.extend_from_slice(&other.tot);
170        self.n_hits.extend_from_slice(&other.n_hits);
171        self.chip_id.extend_from_slice(&other.chip_id);
172    }
173
174    /// Clear all neutron data from the batch.
175    pub fn clear(&mut self) {
176        self.x.clear();
177        self.y.clear();
178        self.tof.clear();
179        self.tot.clear();
180        self.n_hits.clear();
181        self.chip_id.clear();
182    }
183}
184
185impl NeutronStatistics {
186    /// Calculate statistics from a slice of neutrons.
187    pub fn from_neutrons(neutrons: &[Neutron]) -> Self {
188        if neutrons.is_empty() {
189            return Self::default();
190        }
191
192        let count = neutrons.len();
193        let count_u32_value = u32::try_from(count).unwrap_or(u32::MAX);
194        let count_as_f64 = f64::from(count_u32_value);
195        let sum_tof: f64 = neutrons.iter().map(|n| f64::from(n.tof)).sum();
196        let mean_tof = sum_tof / count_as_f64;
197
198        let variance: f64 = neutrons
199            .iter()
200            .map(|n| (f64::from(n.tof) - mean_tof).powi(2))
201            .sum::<f64>()
202            / count_as_f64;
203        let std_tof = variance.sqrt();
204
205        let mean_total_tot = neutrons.iter().map(|n| f64::from(n.tot)).sum::<f64>() / count_as_f64;
206        let mean_cluster_size =
207            neutrons.iter().map(|n| f64::from(n.n_hits)).sum::<f64>() / count_as_f64;
208        let single_hit_count =
209            u32::try_from(neutrons.iter().filter(|n| n.n_hits == 1).count()).unwrap_or(u32::MAX);
210        let single_hit_fraction = f64::from(single_hit_count) / count_as_f64;
211
212        let x_min = neutrons.iter().map(|n| n.x).fold(f64::INFINITY, f64::min);
213        let x_max = neutrons
214            .iter()
215            .map(|n| n.x)
216            .fold(f64::NEG_INFINITY, f64::max);
217        let y_min = neutrons.iter().map(|n| n.y).fold(f64::INFINITY, f64::min);
218        let y_max = neutrons
219            .iter()
220            .map(|n| n.y)
221            .fold(f64::NEG_INFINITY, f64::max);
222        let tof_min = neutrons.iter().map(|n| n.tof).min().unwrap_or(0);
223        let tof_max = neutrons.iter().map(|n| n.tof).max().unwrap_or(0);
224
225        Self {
226            count,
227            mean_tof,
228            std_tof,
229            mean_tot: mean_total_tot,
230            mean_cluster_size,
231            single_hit_fraction,
232            x_range: (x_min, x_max),
233            y_range: (y_min, y_max),
234            tof_range: (tof_min, tof_max),
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_neutron_creation() {
245        let neutron = Neutron::new(1024.0, 2048.0, 1000, 150, 5, 0);
246        assert!((neutron.x - 1024.0).abs() < f64::EPSILON);
247        assert!((neutron.y - 2048.0).abs() < f64::EPSILON);
248        assert_eq!(neutron.tof, 1000);
249        assert_eq!(neutron.tot, 150);
250        assert_eq!(neutron.n_hits, 5);
251    }
252
253    #[test]
254    fn test_tof_conversions() {
255        let neutron = Neutron::new(0.0, 0.0, 1000, 0, 1, 0);
256        assert!((neutron.tof_ns() - 25_000.0).abs() < f64::EPSILON);
257        assert!((neutron.tof_ms() - 0.025).abs() < f64::EPSILON);
258    }
259
260    #[test]
261    fn test_pixel_coords() {
262        let neutron = Neutron::new(800.0, 1600.0, 0, 0, 1, 0);
263        let (px, py) = neutron.pixel_coords(8.0);
264        assert!((px - 100.0).abs() < f64::EPSILON);
265        assert!((py - 200.0).abs() < f64::EPSILON);
266    }
267
268    #[test]
269    fn test_cluster_size_category() {
270        assert_eq!(
271            Neutron::new(0.0, 0.0, 0, 0, 1, 0).cluster_size_category(),
272            ClusterSize::Single
273        );
274        assert_eq!(
275            Neutron::new(0.0, 0.0, 0, 0, 3, 0).cluster_size_category(),
276            ClusterSize::Small
277        );
278        assert_eq!(
279            Neutron::new(0.0, 0.0, 0, 0, 7, 0).cluster_size_category(),
280            ClusterSize::Medium
281        );
282        assert_eq!(
283            Neutron::new(0.0, 0.0, 0, 0, 15, 0).cluster_size_category(),
284            ClusterSize::Large
285        );
286    }
287
288    #[test]
289    fn test_statistics() {
290        let neutrons = vec![
291            Neutron::new(100.0, 200.0, 1000, 50, 1, 0),
292            Neutron::new(110.0, 210.0, 1010, 60, 3, 0),
293            Neutron::new(105.0, 205.0, 1005, 55, 2, 0),
294        ];
295        let stats = NeutronStatistics::from_neutrons(&neutrons);
296        assert_eq!(stats.count, 3);
297        assert!((stats.mean_tof - 1005.0).abs() < 0.01);
298        assert!((stats.single_hit_fraction - 1.0 / 3.0).abs() < 0.01);
299    }
300}