Skip to main content

eulumdat_goniosim/
detector.rs

1//! Spherical goniophotometer detector for photon collection and binning.
2
3use nalgebra::Vector3;
4use std::f64::consts::PI;
5
6/// Spherical detector that collects escaping photons and bins them by direction.
7///
8/// Uses CIE photometric coordinates:
9/// - C-angle: azimuth (0-360), C0 = +X, C90 = +Y
10/// - Gamma: from nadir (0-180), gamma=0 = -Z (down), gamma=180 = +Z (up)
11#[derive(Debug, Clone)]
12pub struct Detector {
13    /// Accumulated energy per bin: `bins[c_index][g_index]`.
14    bins: Vec<Vec<f64>>,
15    /// Photon count per bin: `counts[c_index][g_index]`.
16    counts: Vec<Vec<u64>>,
17    /// C-angle resolution in degrees.
18    c_resolution_deg: f64,
19    /// Gamma resolution in degrees.
20    g_resolution_deg: f64,
21    /// Number of C bins (360 / c_resolution).
22    num_c: usize,
23    /// Number of gamma bins (180 / g_resolution + 1).
24    num_g: usize,
25    /// Total accumulated energy (sum of all bins).
26    total_energy: f64,
27}
28
29impl Detector {
30    /// Create a new detector with the given angular resolution.
31    pub fn new(c_resolution_deg: f64, g_resolution_deg: f64) -> Self {
32        let num_c = (360.0 / c_resolution_deg).round() as usize;
33        let num_g = (180.0 / g_resolution_deg).round() as usize + 1;
34        Self {
35            bins: vec![vec![0.0; num_g]; num_c],
36            counts: vec![vec![0; num_g]; num_c],
37            c_resolution_deg,
38            g_resolution_deg,
39            num_c,
40            num_g,
41            total_energy: 0.0,
42        }
43    }
44
45    /// Record an escaping photon by its world-space direction and energy.
46    pub fn record(&mut self, direction: &Vector3<f64>, energy: f64) {
47        let (c, g) = direction_to_cg(direction);
48        let ci = ((c / self.c_resolution_deg).floor() as usize).min(self.num_c - 1);
49        let gi = ((g / self.g_resolution_deg).round() as usize).min(self.num_g - 1);
50        self.bins[ci][gi] += energy;
51        self.counts[ci][gi] += 1;
52        self.total_energy += energy;
53    }
54
55    /// Convert accumulated bins to candela values.
56    ///
57    /// `cd = (energy_in_bin / solid_angle_of_bin) * (source_flux / total_energy)`
58    #[allow(clippy::needless_range_loop)]
59    pub fn to_candela(&self, source_flux_lm: f64) -> Vec<Vec<f64>> {
60        if self.total_energy <= 0.0 {
61            return self.bins.clone();
62        }
63
64        let dc_rad = self.c_resolution_deg.to_radians();
65        let dg_rad = self.g_resolution_deg.to_radians();
66        let flux_per_energy = source_flux_lm / self.total_energy;
67
68        let mut candela = vec![vec![0.0; self.num_g]; self.num_c];
69
70        for ci in 0..self.num_c {
71            for gi in 0..self.num_g {
72                let g_center_rad = (gi as f64 * self.g_resolution_deg).to_radians();
73                let solid_angle = solid_angle_for_bin(g_center_rad, dg_rad, dc_rad);
74
75                if solid_angle > 0.0 {
76                    // Flux in bin = energy * flux_per_energy
77                    // Intensity (cd) = flux / solid_angle / (4*pi) ...
78                    // Actually: cd = luminous_flux_in_bin / solid_angle
79                    let flux_in_bin = self.bins[ci][gi] * flux_per_energy;
80                    candela[ci][gi] = flux_in_bin / solid_angle;
81                }
82            }
83        }
84
85        candela
86    }
87
88    /// Total detected flux in lumens (for energy conservation validation).
89    pub fn total_flux(&self, source_flux_lm: f64) -> f64 {
90        if self.total_energy <= 0.0 {
91            return 0.0;
92        }
93        self.total_energy * (source_flux_lm / self.total_energy)
94    }
95
96    /// Total accumulated energy.
97    pub fn total_energy(&self) -> f64 {
98        self.total_energy
99    }
100
101    /// Number of C bins.
102    pub fn num_c(&self) -> usize {
103        self.num_c
104    }
105
106    /// Number of gamma bins.
107    pub fn num_g(&self) -> usize {
108        self.num_g
109    }
110
111    /// C resolution in degrees.
112    pub fn c_resolution_deg(&self) -> f64 {
113        self.c_resolution_deg
114    }
115
116    /// Gamma resolution in degrees.
117    pub fn g_resolution_deg(&self) -> f64 {
118        self.g_resolution_deg
119    }
120
121    /// Access raw bins (energy).
122    pub fn bins(&self) -> &Vec<Vec<f64>> {
123        &self.bins
124    }
125
126    /// Access raw counts.
127    pub fn counts(&self) -> &Vec<Vec<u64>> {
128        &self.counts
129    }
130
131    /// Merge another detector's data into this one (for parallel accumulation).
132    pub fn merge(&mut self, other: &Detector) {
133        assert_eq!(self.num_c, other.num_c);
134        assert_eq!(self.num_g, other.num_g);
135        for ci in 0..self.num_c {
136            for gi in 0..self.num_g {
137                self.bins[ci][gi] += other.bins[ci][gi];
138                self.counts[ci][gi] += other.counts[ci][gi];
139            }
140        }
141        self.total_energy += other.total_energy;
142    }
143
144    /// Sample the candela value at a specific (C, gamma) angle pair.
145    ///
146    /// Uses bilinear interpolation from the fine-resolution bins.
147    /// This allows extracting cd values at the source LDT's exact
148    /// (non-uniform) C-plane angles.
149    pub fn candela_at(&self, c_deg: f64, g_deg: f64, source_flux_lm: f64) -> f64 {
150        if self.total_energy <= 0.0 {
151            return 0.0;
152        }
153
154        let flux_per_energy = source_flux_lm / self.total_energy;
155        let dc_rad = self.c_resolution_deg.to_radians();
156        let dg_rad = self.g_resolution_deg.to_radians();
157
158        // Find surrounding bin indices
159        let c_norm = c_deg.rem_euclid(360.0);
160        let ci_f = c_norm / self.c_resolution_deg;
161        let gi_f = g_deg / self.g_resolution_deg;
162
163        let ci0 = (ci_f.floor() as usize).min(self.num_c - 1);
164        let ci1 = (ci0 + 1) % self.num_c;
165        let gi0 = (gi_f.floor() as usize).min(self.num_g - 1);
166        let gi1 = (gi0 + 1).min(self.num_g - 1);
167
168        let cf = ci_f - ci_f.floor(); // fractional part
169        let gf = gi_f - gi_f.floor();
170
171        // Get candela for the 4 surrounding bins
172        let cd = |ci: usize, gi: usize| -> f64 {
173            let g_center_rad = (gi as f64 * self.g_resolution_deg).to_radians();
174            let sa = solid_angle_for_bin(g_center_rad, dg_rad, dc_rad);
175            if sa > 0.0 {
176                self.bins[ci][gi] * flux_per_energy / sa
177            } else {
178                0.0
179            }
180        };
181
182        // Bilinear interpolation
183        let v00 = cd(ci0, gi0);
184        let v10 = cd(ci1, gi0);
185        let v01 = cd(ci0, gi1);
186        let v11 = cd(ci1, gi1);
187
188        let v0 = v00 * (1.0 - cf) + v10 * cf;
189        let v1 = v01 * (1.0 - cf) + v11 * cf;
190        v0 * (1.0 - gf) + v1 * gf
191    }
192
193    /// Resample to a coarser resolution for export.
194    pub fn resample(&self, c_step_deg: f64, g_step_deg: f64) -> Detector {
195        let mut resampled = Detector::new(c_step_deg, g_step_deg);
196
197        // Map each bin in self to the corresponding bin in resampled
198        for ci in 0..self.num_c {
199            let c_angle = ci as f64 * self.c_resolution_deg;
200            let new_ci = ((c_angle / c_step_deg).floor() as usize).min(resampled.num_c - 1);
201
202            for gi in 0..self.num_g {
203                let g_angle = gi as f64 * self.g_resolution_deg;
204                let new_gi = ((g_angle / g_step_deg).round() as usize).min(resampled.num_g - 1);
205
206                resampled.bins[new_ci][new_gi] += self.bins[ci][gi];
207                resampled.counts[new_ci][new_gi] += self.counts[ci][gi];
208            }
209        }
210        resampled.total_energy = self.total_energy;
211
212        resampled
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Coordinate conversion
218// ---------------------------------------------------------------------------
219
220/// Convert a world-space direction to CIE photometric coordinates (C, gamma).
221///
222/// Convention:
223/// - gamma = 0 → nadir (-Z, straight down)
224/// - gamma = 90 → horizontal
225/// - gamma = 180 → zenith (+Z, straight up)
226/// - C = 0 → +X, C = 90 → +Y
227fn direction_to_cg(dir: &Vector3<f64>) -> (f64, f64) {
228    // gamma: angle from -Z axis
229    let gamma = (-dir.z).acos().to_degrees();
230
231    // C: azimuth from +X towards +Y
232    let c = dir.y.atan2(dir.x).to_degrees();
233    let c = if c < 0.0 { c + 360.0 } else { c };
234
235    (c, gamma)
236}
237
238/// Solid angle of a detector bin centered at gamma, with angular widths dg and dc.
239///
240/// Uses the exact integral: omega = dc * |cos(g - dg/2) - cos(g + dg/2)|
241/// This is correct for all bins including the poles.
242fn solid_angle_for_bin(g_center_rad: f64, dg_rad: f64, dc_rad: f64) -> f64 {
243    let g_lo = (g_center_rad - dg_rad / 2.0).max(0.0);
244    let g_hi = (g_center_rad + dg_rad / 2.0).min(PI);
245    let d_cos = (g_lo.cos() - g_hi.cos()).abs();
246    dc_rad * d_cos
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn direction_to_cg_nadir() {
255        let (c, g) = direction_to_cg(&Vector3::new(0.0, 0.0, -1.0));
256        assert!(g.abs() < 1.0, "Nadir should be gamma ~0, got {g}");
257        let _ = c; // C is undefined at poles
258    }
259
260    #[test]
261    fn direction_to_cg_zenith() {
262        let (_, g) = direction_to_cg(&Vector3::new(0.0, 0.0, 1.0));
263        assert!(
264            (g - 180.0).abs() < 1.0,
265            "Zenith should be gamma ~180, got {g}"
266        );
267    }
268
269    #[test]
270    fn direction_to_cg_horizontal_front() {
271        let (c, g) = direction_to_cg(&Vector3::new(1.0, 0.0, 0.0));
272        assert!(
273            (g - 90.0).abs() < 1.0,
274            "Horizontal should be gamma ~90, got {g}"
275        );
276        assert!(c.abs() < 1.0 || (c - 360.0).abs() < 1.0, "C0 = +X, got {c}");
277    }
278
279    #[test]
280    fn direction_to_cg_horizontal_right() {
281        let (c, g) = direction_to_cg(&Vector3::new(0.0, 1.0, 0.0));
282        assert!((g - 90.0).abs() < 1.0);
283        assert!((c - 90.0).abs() < 1.0, "C90 = +Y, got {c}");
284    }
285
286    #[test]
287    fn detector_records_and_merges() {
288        let mut d1 = Detector::new(10.0, 5.0);
289        let mut d2 = Detector::new(10.0, 5.0);
290
291        d1.record(&Vector3::new(0.0, 0.0, -1.0), 1.0);
292        d2.record(&Vector3::new(0.0, 0.0, -1.0), 2.0);
293
294        d1.merge(&d2);
295        assert!((d1.total_energy - 3.0).abs() < 1e-10);
296    }
297
298    #[test]
299    fn solid_angle_equator_larger_than_pole() {
300        let dg = 1.0f64.to_radians();
301        let dc = 1.0f64.to_radians();
302        let sa_equator = solid_angle_for_bin(90.0f64.to_radians(), dg, dc);
303        let sa_pole = solid_angle_for_bin(0.0, dg, dc);
304        assert!(sa_equator > sa_pole);
305    }
306}