Skip to main content

goad/
bins.rs

1use nalgebra::Vector3;
2use pyo3::prelude::*;
3#[cfg(feature = "stub-gen")]
4use pyo3_stub_gen::derive::*;
5use serde::{Deserialize, Deserializer, Serialize};
6
7/// Represents a solid angle bin with theta and phi bins
8#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
9pub struct SolidAngleBin {
10    pub theta: AngleBin,
11    pub phi: AngleBin,
12}
13
14impl SolidAngleBin {
15    /// Create a new bin from theta and phi bins
16    pub fn new(theta_bin: AngleBin, phi_bin: AngleBin) -> Self {
17        SolidAngleBin {
18            theta: theta_bin,
19            phi: phi_bin,
20        }
21    }
22
23    pub fn solid_angle(&self) -> f32 {
24        2.0 * (self.theta.center).to_radians().sin().abs()
25            * (0.5 * self.theta.width()).to_radians().sin()
26            * self.phi.width().to_radians()
27    }
28
29    /// Returns the unit observation vector for this bin's center direction.
30    ///
31    /// Uses the inverted z-axis convention where z → -z, converting from
32    /// spherical (theta, phi) to Cartesian coordinates.
33    pub fn unit_vector(&self) -> Vector3<f32> {
34        let (sin_theta, cos_theta) = self.theta.center.to_radians().sin_cos();
35        let (sin_phi, cos_phi) = self.phi.center.to_radians().sin_cos();
36        Vector3::new(sin_theta * cos_phi, sin_theta * sin_phi, -cos_theta)
37    }
38}
39
40/// Represents an angular bin with edges and center. Fields: `min`, `max`, `center`
41#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize)]
42pub struct AngleBin {
43    pub min: f32,    // min edge
44    pub max: f32,    // max edge
45    pub center: f32, // center
46}
47
48impl AngleBin {
49    /// Create a new bin from edges
50    pub fn new(min: f32, max: f32) -> Self {
51        AngleBin {
52            min,
53            max,
54            center: (min + max) / 2.0,
55        }
56    }
57
58    /// Create a bin from center and width
59    pub fn from_center_width(center: f32, width: f32) -> Self {
60        AngleBin {
61            min: center - width / 2.0,
62            max: center + width / 2.0,
63            center,
64        }
65    }
66
67    /// Get the width of the bin
68    pub fn width(&self) -> f32 {
69        self.max - self.min
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_interval_bins() {
79        let values = vec![0.0, 1.0, 2.0];
80        let spacings = vec![0.5, 0.5];
81        let result = interval_spacings(&values, &spacings);
82        let expected = vec![0.0, 0.5, 1.0, 1.5, 2.0];
83        assert_eq!(result, expected);
84    }
85
86    #[test]
87    #[should_panic]
88    fn test_interval_bins_bad_angle() {
89        let values = vec![0.0, 1.0, 2.0];
90        let spacings = vec![0.3, 0.5];
91        interval_spacings(&values, &spacings);
92    }
93
94    #[test]
95    fn test_simple_bins() {
96        let num_theta = 3;
97        let num_phi = 3;
98        let result = simple_bins(num_theta, num_phi);
99        // Check that we have the right number of bins
100        assert_eq!(result.len(), 9);
101        // Check first bin centers
102        assert_eq!(result[0].phi.center, 60.0);
103        assert_eq!(result[0].phi.center, 60.0);
104        // Check bin edges for first theta bin
105        assert_eq!(result[0].theta.min, 0.0);
106        assert_eq!(result[0].theta.max, 60.0);
107    }
108}
109
110#[derive(Debug, Clone, Serialize, PartialEq)]
111pub enum Scheme {
112    Simple {
113        num_theta: usize,
114        num_phi: usize,
115        delta_theta: f32,
116        delta_phi: f32,
117    },
118    Interval {
119        thetas: Vec<f32>,
120        theta_spacings: Vec<f32>,
121        phis: Vec<f32>,
122        phi_spacings: Vec<f32>,
123    },
124    Custom {
125        bins: Vec<[[f32; 2]; 2]>, // Each bin is [[theta_min, theta_max], [phi_min, phi_max]]
126        file: Option<String>,
127    },
128}
129
130// Custom deserializer to handle missing delta_theta and delta_phi
131impl<'de> Deserialize<'de> for Scheme {
132    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
133    where
134        D: Deserializer<'de>,
135    {
136        #[derive(Deserialize)]
137        struct SimpleHelper {
138            num_theta: usize,
139            num_phi: usize,
140            #[serde(default)]
141            delta_theta: Option<f32>,
142            #[serde(default)]
143            delta_phi: Option<f32>,
144        }
145
146        #[derive(Deserialize)]
147        struct IntervalHelper {
148            thetas: Vec<f32>,
149            theta_spacings: Vec<f32>,
150            phis: Vec<f32>,
151            phi_spacings: Vec<f32>,
152        }
153
154        #[derive(Deserialize)]
155        struct CustomHelper {
156            #[serde(default)]
157            bins: Vec<[[f32; 2]; 2]>,
158            file: Option<String>,
159        }
160
161        #[derive(Deserialize)]
162        enum SchemeHelper {
163            Simple(SimpleHelper),
164            Interval(IntervalHelper),
165            Custom(CustomHelper),
166        }
167
168        let helper = SchemeHelper::deserialize(deserializer)?;
169        match helper {
170            SchemeHelper::Simple(SimpleHelper {
171                num_theta,
172                num_phi,
173                delta_theta,
174                delta_phi,
175            }) => {
176                // Calculate deltas if not provided
177                let delta_theta = delta_theta.unwrap_or(180.0 / num_theta as f32);
178                let delta_phi = delta_phi.unwrap_or(360.0 / num_phi as f32);
179                Ok(Scheme::Simple {
180                    num_theta,
181                    num_phi,
182                    delta_theta,
183                    delta_phi,
184                })
185            }
186            SchemeHelper::Interval(IntervalHelper {
187                thetas,
188                theta_spacings,
189                phis,
190                phi_spacings,
191            }) => Ok(Scheme::Interval {
192                thetas,
193                theta_spacings,
194                phis,
195                phi_spacings,
196            }),
197            SchemeHelper::Custom(CustomHelper { mut bins, file }) => {
198                // If file is specified, load bins from file
199                if let Some(ref filepath) = file {
200                    #[derive(Deserialize)]
201                    struct CustomBinsFile {
202                        bins: Vec<[[f32; 2]; 2]>,
203                    }
204
205                    let content = std::fs::read_to_string(filepath).map_err(|e| {
206                        serde::de::Error::custom(format!(
207                            "Failed to read custom bins file '{}': {}",
208                            filepath, e
209                        ))
210                    })?;
211
212                    let file_data: CustomBinsFile = toml::from_str(&content).map_err(|e| {
213                        serde::de::Error::custom(format!(
214                            "Failed to parse custom bins file '{}': {}",
215                            filepath, e
216                        ))
217                    })?;
218
219                    bins = file_data.bins;
220                }
221
222                Ok(Scheme::Custom { bins, file })
223            }
224        }
225    }
226}
227
228impl Scheme {
229    pub fn new_simple(num_theta: usize, num_phi: usize) -> Self {
230        let delta_theta = 180.0 / num_theta as f32;
231        let delta_phi = 360.0 / num_phi as f32;
232        Scheme::Simple {
233            num_theta,
234            num_phi,
235            delta_theta,
236            delta_phi,
237        }
238    }
239
240    /// Returns the theta range (min, max) for this scheme.
241    pub fn theta_range(&self) -> (f32, f32) {
242        match self {
243            Scheme::Simple { .. } => (0.0, 180.0),
244            Scheme::Interval { thetas, .. } => {
245                let min = thetas.first().copied().unwrap_or(0.0);
246                let max = thetas.last().copied().unwrap_or(180.0);
247                (min, max)
248            }
249            Scheme::Custom { bins, .. } => {
250                if bins.is_empty() {
251                    return (0.0, 0.0);
252                }
253                let min = bins.iter().map(|b| b[0][0]).fold(f32::INFINITY, f32::min);
254                let max = bins
255                    .iter()
256                    .map(|b| b[0][1])
257                    .fold(f32::NEG_INFINITY, f32::max);
258                (min, max)
259            }
260        }
261    }
262
263    /// Generate the bins for this scheme.
264    pub fn generate(&self) -> Vec<SolidAngleBin> {
265        match self {
266            Scheme::Simple {
267                num_theta, num_phi, ..
268            } => simple_bins(*num_theta, *num_phi),
269            Scheme::Interval {
270                thetas,
271                theta_spacings,
272                phis,
273                phi_spacings,
274            } => interval_bins(theta_spacings, thetas, phi_spacings, phis),
275            Scheme::Custom { bins, .. } => custom_bins(bins),
276        }
277    }
278}
279
280/// Angular binning scheme for scattering calculations.
281///
282/// Defines how to discretize the scattering sphere into angular bins
283/// for Mueller matrix and amplitude computations. Supports simple
284/// regular grids, custom intervals, and arbitrary bin arrangements.
285#[cfg_attr(feature = "stub-gen", gen_stub_pyclass)]
286#[pyclass(module = "goad._goad")]
287#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
288pub struct BinningScheme {
289    pub scheme: Scheme,
290}
291
292#[cfg_attr(feature = "stub-gen", gen_stub_pymethods)]
293#[pymethods]
294impl BinningScheme {
295    #[new]
296    fn py_new(bins: Vec<[[f32; 2]; 2]>) -> Self {
297        BinningScheme {
298            scheme: Scheme::Custom { bins, file: None },
299        }
300    }
301
302    /// Create a simple binning scheme with uniform theta and phi spacing
303    #[staticmethod]
304    fn simple(num_theta: usize, num_phi: usize) -> PyResult<Self> {
305        if num_theta <= 0 {
306            return Err(pyo3::exceptions::PyValueError::new_err(
307                "num_theta must be greater than 0",
308            ));
309        }
310        if num_phi <= 0 {
311            return Err(pyo3::exceptions::PyValueError::new_err(
312                "num_phi must be greater than 0",
313            ));
314        }
315
316        Ok(BinningScheme {
317            scheme: Scheme::new_simple(num_theta, num_phi),
318        })
319    }
320
321    /// Create an interval binning scheme with variable spacing
322    #[staticmethod]
323    fn interval(
324        thetas: Vec<f32>,
325        theta_spacings: Vec<f32>,
326        phis: Vec<f32>,
327        phi_spacings: Vec<f32>,
328    ) -> Self {
329        BinningScheme {
330            scheme: Scheme::Interval {
331                thetas,
332                theta_spacings,
333                phis,
334                phi_spacings,
335            },
336        }
337    }
338
339    /// Create a custom binning scheme with explicit bin edges
340    /// Each bin is specified as [[theta_min, theta_max], [phi_min, phi_max]]
341    #[staticmethod]
342    fn custom(bins: Vec<[[f32; 2]; 2]>) -> Self {
343        BinningScheme {
344            scheme: Scheme::Custom { bins, file: None },
345        }
346    }
347
348    /// Returns a list of all theta bin centre values
349    fn thetas(&self) -> Vec<f32> {
350        let bins = match &self.scheme {
351            Scheme::Simple { num_theta, .. } => simple_spacings(*num_theta, 180.0)
352                .iter()
353                .map(|&bin| bin.center)
354                .collect(),
355            Scheme::Interval {
356                thetas,
357                theta_spacings,
358                ..
359            } => edges_to_bins(interval_spacings(&thetas, &theta_spacings))
360                .iter()
361                .map(|bin| bin.center)
362                .collect(),
363            Scheme::Custom { bins, .. } => custom_bins(&bins)
364                .iter()
365                .map(|bin| bin.theta.center)
366                .collect(),
367        };
368        bins
369    }
370
371    /// Returns a list of all phi bin centre values
372    fn phis(&self) -> Vec<f32> {
373        let bins = match &self.scheme {
374            Scheme::Simple { num_phi, .. } => simple_spacings(*num_phi, 360.0)
375                .iter()
376                .map(|&bin| bin.center)
377                .collect(),
378            Scheme::Interval {
379                phis, phi_spacings, ..
380            } => edges_to_bins(interval_spacings(&phis, &phi_spacings))
381                .iter()
382                .map(|bin| bin.center)
383                .collect(),
384            Scheme::Custom { bins, .. } => custom_bins(&bins)
385                .iter()
386                .map(|bin| bin.phi.center)
387                .collect(),
388        };
389        bins
390    }
391
392    /// Returns all 2D bins as a numpy array of shape (n_bins, 2) with columns [theta, phi]
393    fn bins<'py>(&self, py: Python<'py>) -> Bound<'py, numpy::PyArray2<f32>> {
394        use numpy::IntoPyArray;
395        let solid_bins = self.scheme.generate();
396        let flat: Vec<f32> = solid_bins
397            .iter()
398            .flat_map(|bin| vec![bin.theta.center, bin.phi.center])
399            .collect();
400        ndarray::Array2::from_shape_vec((solid_bins.len(), 2), flat)
401            .unwrap()
402            .into_pyarray(py)
403    }
404
405    /// Returns unique 1D theta bins as a numpy array
406    fn bins_1d<'py>(&self, py: Python<'py>) -> Bound<'py, numpy::PyArray1<f32>> {
407        use numpy::IntoPyArray;
408        let thetas = self.thetas();
409        // Get unique thetas (they may repeat for each phi)
410        let mut unique_thetas: Vec<f32> = thetas.clone();
411        unique_thetas.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
412        ndarray::Array1::from_vec(unique_thetas).into_pyarray(py)
413    }
414
415    /// Returns the number of bins
416    fn num_bins(&self) -> usize {
417        self.scheme.generate().len()
418    }
419}
420
421pub fn interval_spacings(splits: &[f32], spacings: &[f32]) -> Vec<f32> {
422    let num_values = splits.len();
423    let mut values = Vec::new();
424
425    for i in 0..num_values - 1 {
426        // Iterate over the splits
427
428        // compute the number of values between the splits
429        let jmax = ((splits[i + 1] - splits[i]) / spacings[i]).round() as usize;
430
431        // validate that the split is close to an integer multiple of the spacing
432        let remainder = (splits[i + 1] - splits[i]) % spacings[i];
433        if remainder.abs() > 1e-3 && (spacings[i] - remainder).abs() > 1e-3 {
434            panic!(
435                "Invalid spacing: split at index {} (value: {}) to index {} (value: {}) is not an integer multiple of spacing {}. Computed remainder: {}",
436                i,
437                splits[i],
438                i + 1,
439                splits[i + 1],
440                spacings[i],
441                remainder
442            );
443        }
444
445        for j in 0..=jmax {
446            let val = splits[i] + j as f32 * spacings[i];
447
448            // Iterate over the number of values between the splits
449            if i != num_values - 2 && j == jmax {
450                // skip the last value unless it is the last split
451                continue;
452            }
453
454            values.push(val);
455        }
456    }
457
458    values
459}
460
461pub fn interval_bins(
462    theta_spacing: &Vec<f32>,
463    theta_splits: &Vec<f32>,
464    phi_spacing: &Vec<f32>,
465    phi_splits: &Vec<f32>,
466) -> Vec<SolidAngleBin> {
467    // Get edge positions
468    let theta_edges = interval_spacings(theta_splits, theta_spacing);
469    let phi_edges = interval_spacings(phi_splits, phi_spacing);
470
471    // Convert edges to bins
472    let theta_bins = edges_to_bins(theta_edges);
473    let phi_bins = edges_to_bins(phi_edges);
474
475    let mut bins = Vec::new();
476    for theta_bin in theta_bins.iter() {
477        for phi_bin in phi_bins.iter() {
478            bins.push(SolidAngleBin::new(*theta_bin, *phi_bin));
479        }
480    }
481
482    bins
483}
484
485fn edges_to_bins(edges: Vec<f32>) -> Vec<AngleBin> {
486    let bins: Vec<AngleBin> = edges
487        .windows(2)
488        .map(|edges| AngleBin::new(edges[0], edges[1]))
489        .collect();
490    bins
491}
492
493/// Helper function to generate evenly spaced angle bins
494fn simple_spacings(num_bins: usize, limit: f32) -> Vec<AngleBin> {
495    let dangle = limit / (num_bins as f32);
496    (0..num_bins)
497        .map(|i| {
498            let min = i as f32 * dangle;
499            let max = (i + 1) as f32 * dangle;
500            AngleBin::new(min, max)
501        })
502        .collect()
503}
504
505/// Generate theta and phi bin combinations
506pub fn simple_bins(num_theta: usize, num_phi: usize) -> Vec<SolidAngleBin> {
507    let theta_bins = simple_spacings(num_theta, 180.0);
508    let phi_bins = simple_spacings(num_phi, 360.0);
509
510    // meshgrid
511    let mut bins = Vec::new();
512    for theta_bin in theta_bins.iter() {
513        for phi_bin in phi_bins.iter() {
514            bins.push(SolidAngleBin::new(*theta_bin, *phi_bin));
515        }
516    }
517
518    bins
519}
520
521/// Generate custom bins from explicit edge specifications
522/// Each bin is [[theta_min, theta_max], [phi_min, phi_max]]
523pub fn custom_bins(bin_specs: &[[[f32; 2]; 2]]) -> Vec<SolidAngleBin> {
524    bin_specs
525        .iter()
526        .map(|&[[theta_min, theta_max], [phi_min, phi_max]]| {
527            let theta_bin = AngleBin::new(theta_min, theta_max);
528            let phi_bin = AngleBin::new(phi_min, phi_max);
529            SolidAngleBin::new(theta_bin, phi_bin)
530        })
531        .collect()
532}
533
534// pub fn generate_bins(bin_type: &Scheme) -> Vec<SolidAngleBin> {
535//     match bin_type {
536//         Scheme::Simple {
537//             num_theta, num_phi, ..
538//         } => simple_bins(*num_theta, *num_phi),
539//         Scheme::Interval {
540//             thetas,
541//             theta_spacings,
542//             phis,
543//             phi_spacings,
544//         } => interval_bins(theta_spacings, thetas, phi_spacings, phis),
545//         Scheme::Custom { bins, .. } => custom_bins(bins),
546//     }
547// }
548
549/// Gets the index of a theta-phi bin, assuming a `Simple` binning scheme, given an input theta and phi.
550pub fn get_n_simple(
551    num_theta: usize,
552    num_phi: usize,
553    delta_theta: f32,
554    delta_phi: f32,
555    theta: f32,
556    phi: f32,
557) -> Option<usize> {
558    let n_theta = ((theta / delta_theta).floor() as usize).min(num_theta - 1);
559    let n_phi = ((phi / delta_phi).floor() as usize).min(num_phi - 1);
560    Some(n_theta * num_phi + n_phi)
561}
562
563/// Gets the index of a theta-phi bin by linearly searching through the bins until a match is found. Returns `None` if no match is found.
564pub fn get_n_linear_search(bins: &[SolidAngleBin], theta: f32, phi: f32) -> Option<usize> {
565    // Find the corresponding bin in the bins array
566    let mut bin_idx = None;
567    for (i, bin) in bins.iter().enumerate() {
568        if theta >= bin.theta.min
569            && theta < bin.theta.max
570            && phi >= bin.phi.min
571            && phi < bin.phi.max
572        {
573            bin_idx = Some(i);
574            break;
575        }
576    }
577    bin_idx
578}