Skip to main content

laddu_physics/
binning.rs

1//! Validated axes and shared bin-assignment semantics.
2
3use std::sync::Arc;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::{LadduPhysicsError, LadduPhysicsResult};
8
9/// Policy for a value exactly equal to an axis's final upper edge.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum FinalUpperEdge {
12    /// Treat the final upper edge as outside the bounded axis.
13    #[default]
14    Exclusive,
15    /// Assign the final upper edge to the last bin.
16    Inclusive,
17}
18
19/// A finite, strictly increasing one-dimensional bin axis.
20#[derive(Clone, Debug, PartialEq, Serialize)]
21#[serde(transparent)]
22pub struct BinningAxis {
23    edges: Arc<[f64]>,
24}
25
26impl<'de> Deserialize<'de> for BinningAxis {
27    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
28    where
29        D: Deserializer<'de>,
30    {
31        let edges = Vec::<f64>::deserialize(deserializer)?;
32        Self::new(edges).map_err(serde::de::Error::custom)
33    }
34}
35
36impl BinningAxis {
37    /// Validate and construct an axis from explicit edges.
38    ///
39    /// # Errors
40    /// Returns an error unless at least two finite, strictly increasing edges are supplied.
41    pub fn new(edges: impl IntoIterator<Item = f64>) -> LadduPhysicsResult<Self> {
42        let edges: Vec<_> = edges.into_iter().collect();
43        if edges.len() < 2 {
44            return Err(LadduPhysicsError::invalid_length(
45                "bin axis edges",
46                "at least 2",
47                edges.len(),
48            ));
49        }
50        if edges.iter().any(|edge| !edge.is_finite())
51            || edges.windows(2).any(|pair| pair[0] >= pair[1])
52        {
53            return Err(LadduPhysicsError::invalid_relation(
54                "bin axis edges must be finite and strictly increasing",
55            ));
56        }
57        Ok(Self {
58            edges: edges.into(),
59        })
60    }
61
62    /// Construct uniformly spaced bins over finite increasing bounds.
63    ///
64    /// # Errors
65    /// Returns an error for zero bins or non-finite/non-increasing bounds.
66    pub fn uniform(count: usize, min: f64, max: f64) -> LadduPhysicsResult<Self> {
67        if count == 0 || !min.is_finite() || !max.is_finite() || min >= max {
68            return Err(LadduPhysicsError::invalid_relation(
69                "uniform bins require a positive count and finite min < max",
70            ));
71        }
72        let width = (max - min) / count as f64;
73        Self::new((0..=count).map(|index| min + index as f64 * width))
74    }
75
76    /// Validated edge values.
77    pub fn edges(&self) -> &[f64] {
78        &self.edges
79    }
80
81    /// Number of bins along the axis.
82    pub fn bin_count(&self) -> usize {
83        self.edges.len() - 1
84    }
85
86    /// Assign a value using half-open internal bins and an explicit final-edge policy.
87    pub fn index(&self, value: f64, final_upper: FinalUpperEdge) -> Option<usize> {
88        if !value.is_finite() || value < self.edges[0] || value > *self.edges.last()? {
89            return None;
90        }
91        if value == *self.edges.last()? {
92            return (final_upper == FinalUpperEdge::Inclusive).then(|| self.bin_count() - 1);
93        }
94        let upper = self.edges.partition_point(|edge| *edge <= value);
95        upper
96            .checked_sub(1)
97            .filter(|index| *index < self.bin_count())
98    }
99}
100
101/// Return the row-major shape for ordered axes.
102pub fn bin_shape(axes: &[BinningAxis]) -> Vec<usize> {
103    axes.iter().map(BinningAxis::bin_count).collect()
104}
105
106/// Return the flattened bin count, or `None` when the ordered shape overflows `usize`.
107pub fn checked_bin_count(axes: &[BinningAxis]) -> Option<usize> {
108    axes.iter()
109        .try_fold(1usize, |count, axis| count.checked_mul(axis.bin_count()))
110}
111
112/// Assign ordered coordinates to a row-major flattened bin.
113///
114/// Returns `None` for a rank mismatch, non-finite coordinate, or out-of-range coordinate.
115pub fn flat_bin_index(
116    axes: &[BinningAxis],
117    coordinates: &[f64],
118    final_upper: FinalUpperEdge,
119) -> Option<usize> {
120    if axes.len() != coordinates.len() {
121        return None;
122    }
123    axes.iter()
124        .zip(coordinates)
125        .try_fold(0usize, |flat, (axis, value)| {
126            flat.checked_mul(axis.bin_count())?
127                .checked_add(axis.index(*value, final_upper)?)
128        })
129}
130
131/// Assign one event from axis-major coordinate columns to a flattened bin.
132///
133/// Returns `None` for mismatched axes/columns, a missing event row, a non-finite
134/// coordinate, an out-of-range coordinate, or flattened-index overflow.
135pub fn flat_bin_index_for_event(
136    axes: &[BinningAxis],
137    coordinate_columns: &[Vec<f64>],
138    event: usize,
139    final_upper: FinalUpperEdge,
140) -> Option<usize> {
141    if axes.len() != coordinate_columns.len() {
142        return None;
143    }
144    axes.iter()
145        .zip(coordinate_columns)
146        .try_fold(0usize, |flat, (axis, coordinates)| {
147            flat.checked_mul(axis.bin_count())?
148                .checked_add(axis.index(*coordinates.get(event)?, final_upper)?)
149        })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn validates_edges_and_assigns_boundaries_explicitly() {
158        assert!(BinningAxis::new([0.0]).is_err());
159        assert!(BinningAxis::new([0.0, f64::NAN]).is_err());
160        assert!(BinningAxis::new([0.0, 0.0]).is_err());
161
162        let axis = BinningAxis::new([0.0, 1.0, 3.0]).unwrap();
163        assert_eq!(axis.index(0.0, FinalUpperEdge::Exclusive), Some(0));
164        assert_eq!(axis.index(1.0, FinalUpperEdge::Exclusive), Some(1));
165        assert_eq!(axis.index(3.0, FinalUpperEdge::Exclusive), None);
166        assert_eq!(axis.index(3.0, FinalUpperEdge::Inclusive), Some(1));
167        assert_eq!(axis.index(f64::NAN, FinalUpperEdge::Inclusive), None);
168    }
169
170    #[test]
171    fn multidimensional_assignment_is_row_major() {
172        let axes = [
173            BinningAxis::new([0.0, 1.0, 2.0]).unwrap(),
174            BinningAxis::new([10.0, 20.0, 30.0, 40.0]).unwrap(),
175        ];
176        assert_eq!(bin_shape(&axes), vec![2, 3]);
177        assert_eq!(
178            flat_bin_index(&axes, &[1.5, 25.0], FinalUpperEdge::Exclusive),
179            Some(4)
180        );
181        assert_eq!(
182            flat_bin_index(&axes, &[2.0, 40.0], FinalUpperEdge::Exclusive),
183            None
184        );
185        let columns = vec![vec![1.5], vec![25.0]];
186        assert_eq!(
187            flat_bin_index_for_event(&axes, &columns, 0, FinalUpperEdge::Exclusive),
188            Some(4)
189        );
190    }
191}