Skip to main content

eulumdat_goniosim/
export.rs

1//! Convert detector output to Eulumdat struct for .ldt/.ies export.
2
3use crate::detector::Detector;
4use eulumdat::{Eulumdat, LampSet, Symmetry};
5
6/// Configuration for Eulumdat export.
7#[derive(Debug, Clone)]
8pub struct ExportConfig {
9    /// C-plane interval in degrees (default 15.0).
10    pub c_step_deg: f64,
11    /// Gamma angle interval in degrees (default 5.0).
12    pub g_step_deg: f64,
13    /// Force a symmetry type, or `None` for auto-detect.
14    pub symmetry: Option<Symmetry>,
15    /// Luminaire name.
16    pub luminaire_name: String,
17    /// Manufacturer / identification.
18    pub manufacturer: String,
19    /// Luminaire dimensions in mm: (length, width, height).
20    pub luminaire_dimensions_mm: (f64, f64, f64),
21    /// Luminous area in mm: (length, width).
22    pub luminous_area_mm: (f64, f64),
23}
24
25impl Default for ExportConfig {
26    fn default() -> Self {
27        Self {
28            c_step_deg: 15.0,
29            g_step_deg: 5.0,
30            symmetry: None,
31            luminaire_name: "Simulated Luminaire".to_string(),
32            manufacturer: "eulumdat-goniosim".to_string(),
33            luminaire_dimensions_mm: (100.0, 100.0, 50.0),
34            luminous_area_mm: (80.0, 80.0),
35        }
36    }
37}
38
39/// Build an Eulumdat struct from detector data.
40///
41/// `source_flux_lm` is the flux that was actually emitted into the scene
42/// (luminaire output flux = lamp flux * LOR). This is used for candela
43/// normalization in the detector.
44///
45/// `lamp_flux_lm` is the total lamp flux (before LOR). This is used for
46/// the cd/klm conversion, since EULUMDAT intensities are per 1000 lm of
47/// lamp flux, not luminaire output flux.
48///
49/// If `lamp_flux_lm` is None, `source_flux_lm` is used for both.
50pub fn detector_to_eulumdat(
51    detector: &Detector,
52    source_flux_lm: f64,
53    config: &ExportConfig,
54) -> Eulumdat {
55    detector_to_eulumdat_with_lamp_flux(detector, source_flux_lm, source_flux_lm, config)
56}
57
58/// Build an Eulumdat struct from detector data, with explicit lamp flux.
59pub fn detector_to_eulumdat_with_lamp_flux(
60    detector: &Detector,
61    source_flux_lm: f64,
62    lamp_flux_lm: f64,
63    config: &ExportConfig,
64) -> Eulumdat {
65    detector_to_eulumdat_at_angles(
66        detector,
67        source_flux_lm,
68        lamp_flux_lm,
69        None, // use uniform grid from config
70        None,
71        config,
72    )
73}
74
75/// Build an Eulumdat struct from detector data, sampling at explicit C/G angles.
76///
77/// If `c_angles` or `g_angles` is None, uses uniform grid from config.
78/// This enables exact reproduction of non-uniform C-plane spacing from the source LDT.
79#[allow(clippy::needless_range_loop)]
80pub fn detector_to_eulumdat_at_angles(
81    detector: &Detector,
82    source_flux_lm: f64,
83    lamp_flux_lm: f64,
84    c_angles_opt: Option<&[f64]>,
85    g_angles_opt: Option<&[f64]>,
86    config: &ExportConfig,
87) -> Eulumdat {
88    // Determine the angle grid to use
89    let c_angles: Vec<f64> = match c_angles_opt {
90        Some(angles) => angles.to_vec(),
91        None => {
92            let num_c = (360.0 / config.c_step_deg).round() as usize;
93            (0..num_c).map(|i| i as f64 * config.c_step_deg).collect()
94        }
95    };
96
97    let g_angles: Vec<f64> = match g_angles_opt {
98        Some(angles) => angles.to_vec(),
99        None => {
100            let num_g = (180.0 / config.g_step_deg).round() as usize + 1;
101            (0..num_g).map(|i| i as f64 * config.g_step_deg).collect()
102        }
103    };
104
105    let num_c = c_angles.len();
106    let num_g = g_angles.len();
107
108    // Build intensity grid.
109    // If explicit angles were provided, use the fine-resolution detector's candela grid
110    // and extract values at the closest bin (nearest-neighbor), which avoids the
111    // solid-angle interpolation bias of candela_at().
112    let scale = 1000.0 / lamp_flux_lm.max(1.0);
113
114    let intensities: Vec<Vec<f64>> = if c_angles_opt.is_some() || g_angles_opt.is_some() {
115        // Get full candela grid at detector resolution
116        let full_cd = detector.to_candela(source_flux_lm);
117        let det_c_res = detector.c_resolution_deg();
118        let det_g_res = detector.g_resolution_deg();
119        let det_num_c = detector.num_c();
120        let det_num_g = detector.num_g();
121
122        c_angles
123            .iter()
124            .map(|&c| {
125                // Find nearest C-bin
126                let c_norm = c.rem_euclid(360.0);
127                let ci = ((c_norm / det_c_res).round() as usize).min(det_num_c - 1);
128                g_angles
129                    .iter()
130                    .map(|&g| {
131                        let gi = ((g / det_g_res).round() as usize).min(det_num_g - 1);
132                        full_cd[ci][gi] * scale
133                    })
134                    .collect()
135            })
136            .collect()
137    } else {
138        // Uniform grid — use resample for best accuracy
139        let resampled = detector.resample(config.c_step_deg, config.g_step_deg);
140        let candela = resampled.to_candela(source_flux_lm);
141        candela
142            .iter()
143            .map(|c_plane| c_plane.iter().map(|cd| cd * scale).collect())
144            .collect()
145    };
146
147    // Compute downward flux fraction from detector data
148    let downward_energy: f64 = {
149        let det_bins = detector.bins();
150        let mut down = 0.0;
151        let mut total = 0.0;
152        for ci in 0..detector.num_c() {
153            for gi in 0..detector.num_g() {
154                let g_deg = gi as f64 * detector.g_resolution_deg();
155                let e = det_bins[ci][gi];
156                total += e;
157                if g_deg <= 90.0 {
158                    down += e;
159                }
160            }
161        }
162        if total > 0.0 {
163            100.0 * down / total
164        } else {
165            50.0
166        }
167    };
168
169    // Handle symmetry: reduce C-planes for symmetric sources so that
170    // downstream flux integration produces correct results.
171    let symmetry = config.symmetry.unwrap_or(Symmetry::None);
172
173    let (c_angles, intensities) = match symmetry {
174        Symmetry::VerticalAxis => {
175            // Rotationally symmetric: average ALL C-planes into one.
176            // The integration for VerticalAxis multiplies by 2*pi.
177            let mut avg = vec![0.0; num_g];
178            for gi in 0..num_g {
179                let sum: f64 = intensities.iter().map(|cp| cp[gi]).sum();
180                avg[gi] = sum / num_c as f64;
181            }
182            (vec![0.0], vec![avg])
183        }
184        _ => (c_angles, intensities),
185    };
186    let num_c = c_angles.len();
187
188    let mut ldt = Eulumdat::new();
189    ldt.identification = config.manufacturer.clone();
190    ldt.luminaire_name = config.luminaire_name.clone();
191    ldt.luminaire_number = String::new();
192    ldt.file_name = String::new();
193    ldt.date_user = String::new();
194    ldt.measurement_report_number = "GonioSim".to_string();
195
196    ldt.symmetry = symmetry;
197    ldt.num_c_planes = num_c;
198    // If explicit angles were provided, compute spacing from them (0 = non-uniform)
199    ldt.c_plane_distance = if c_angles_opt.is_some() && num_c > 1 {
200        let d = c_angles[1] - c_angles[0];
201        if c_angles.windows(2).all(|w| (w[1] - w[0] - d).abs() < 0.01) {
202            d
203        } else {
204            0.0
205        }
206    } else {
207        config.c_step_deg
208    };
209    ldt.num_g_planes = num_g;
210    ldt.g_plane_distance = if g_angles_opt.is_some() && num_g > 1 {
211        let d = g_angles[1] - g_angles[0];
212        if g_angles.windows(2).all(|w| (w[1] - w[0] - d).abs() < 0.01) {
213            d
214        } else {
215            0.0
216        }
217    } else {
218        config.g_step_deg
219    };
220
221    ldt.length = config.luminaire_dimensions_mm.0;
222    ldt.width = config.luminaire_dimensions_mm.1;
223    ldt.height = config.luminaire_dimensions_mm.2;
224    ldt.luminous_area_length = config.luminous_area_mm.0;
225    ldt.luminous_area_width = config.luminous_area_mm.1;
226
227    ldt.downward_flux_fraction = downward_energy;
228    ldt.light_output_ratio = 100.0; // simulated = 100% (losses are in the simulation)
229    ldt.conversion_factor = 1.0;
230    ldt.tilt_angle = 0.0;
231
232    ldt.lamp_sets = vec![LampSet {
233        num_lamps: 1,
234        lamp_type: "LED".to_string(),
235        total_luminous_flux: source_flux_lm,
236        color_appearance: "4000K".to_string(),
237        color_rendering_group: "1A".to_string(),
238        wattage_with_ballast: source_flux_lm / 150.0, // assume ~150 lm/W
239    }];
240
241    ldt.direct_ratios = [0.0; 10];
242    ldt.c_angles = c_angles;
243    ldt.g_angles = g_angles;
244    ldt.intensities = intensities;
245
246    ldt
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn export_produces_valid_ldt() {
255        let mut detector = Detector::new(15.0, 5.0);
256        // Simulate some isotropic data
257        for ci in 0..detector.num_c() {
258            for gi in 0..detector.num_g() {
259                let dir = cg_to_direction(ci as f64 * 15.0, gi as f64 * 5.0);
260                detector.record(&dir, 1.0);
261            }
262        }
263
264        let config = ExportConfig::default();
265        let ldt = detector_to_eulumdat(&detector, 1000.0, &config);
266
267        assert_eq!(ldt.luminaire_name, "Simulated Luminaire");
268        assert!(!ldt.intensities.is_empty());
269        assert!(!ldt.c_angles.is_empty());
270        assert!(!ldt.g_angles.is_empty());
271
272        // Should produce valid LDT string
273        let ldt_string = ldt.to_ldt();
274        assert!(!ldt_string.is_empty());
275    }
276
277    /// Helper: convert C/gamma angles back to a direction vector.
278    fn cg_to_direction(c_deg: f64, g_deg: f64) -> nalgebra::Vector3<f64> {
279        let g_rad = g_deg.to_radians();
280        let c_rad = c_deg.to_radians();
281        nalgebra::Vector3::new(
282            g_rad.sin() * c_rad.cos(),
283            g_rad.sin() * c_rad.sin(),
284            -g_rad.cos(),
285        )
286    }
287}