Skip to main content

eulumdat/
calculations.rs

1//! Photometric calculations for Eulumdat data.
2//!
3//! Implements standard lighting calculations including:
4//! - Downward flux fraction
5//! - Total luminous output
6//! - Utilization factors (direct ratios)
7
8use crate::eulumdat::{Eulumdat, Symmetry};
9use crate::type_b_conversion::TypeBConversion;
10use std::f64::consts::PI;
11
12/// Photometric calculations on Eulumdat data.
13pub struct PhotometricCalculations;
14
15impl PhotometricCalculations {
16    /// Calculate the downward flux fraction up to a given arc angle.
17    ///
18    /// Integrates the luminous intensity distribution from 0° to the specified
19    /// arc angle to determine the percentage of light directed downward.
20    ///
21    /// # Arguments
22    /// * `ldt` - The Eulumdat data
23    /// * `arc` - The maximum angle from vertical (0° = straight down, 90° = horizontal)
24    ///
25    /// # Returns
26    /// The downward flux fraction as a percentage (0-100).
27    pub fn downward_flux(ldt: &Eulumdat, arc: f64) -> f64 {
28        let total_output = Self::total_output(ldt);
29        if total_output <= 0.0 {
30            return 0.0;
31        }
32
33        let downward = match ldt.symmetry {
34            Symmetry::None => Self::downward_no_symmetry(ldt, arc),
35            Symmetry::VerticalAxis => Self::downward_for_plane(ldt, 0, arc),
36            Symmetry::PlaneC0C180 => Self::downward_c0_c180(ldt, arc),
37            Symmetry::PlaneC90C270 => Self::downward_c90_c270(ldt, arc),
38            Symmetry::BothPlanes => Self::downward_both_planes(ldt, arc),
39        };
40
41        100.0 * downward / total_output
42    }
43
44    /// Calculate downward flux for no symmetry case.
45    fn downward_no_symmetry(ldt: &Eulumdat, arc: f64) -> f64 {
46        let mc = ldt.actual_c_planes();
47        if mc == 0 || ldt.c_angles.is_empty() {
48            return 0.0;
49        }
50
51        let mut sum = 0.0;
52
53        for i in 1..mc {
54            let delta_c = ldt.c_angles[i] - ldt.c_angles[i - 1];
55            sum += delta_c * Self::downward_for_plane(ldt, i - 1, arc);
56        }
57
58        // Handle wrap-around from last plane to first
59        if mc > 1 {
60            let delta_c = 360.0 - ldt.c_angles[mc - 1];
61            sum += delta_c * Self::downward_for_plane(ldt, mc - 1, arc);
62        }
63
64        sum / 360.0
65    }
66
67    /// Calculate downward flux for C0-C180 symmetry.
68    fn downward_c0_c180(ldt: &Eulumdat, arc: f64) -> f64 {
69        let mc = ldt.actual_c_planes();
70        if mc == 0 || ldt.c_angles.is_empty() {
71            return 0.0;
72        }
73
74        let mut sum = 0.0;
75
76        for i in 1..mc {
77            let delta_c = ldt.c_angles[i] - ldt.c_angles[i - 1];
78            sum += 2.0 * delta_c * Self::downward_for_plane(ldt, i - 1, arc);
79        }
80
81        // Handle to 180°
82        if mc > 0 {
83            let delta_c = 180.0 - ldt.c_angles[mc - 1];
84            sum += 2.0 * delta_c * Self::downward_for_plane(ldt, mc - 1, arc);
85        }
86
87        sum / 360.0
88    }
89
90    /// Calculate downward flux for C90-C270 symmetry.
91    fn downward_c90_c270(ldt: &Eulumdat, arc: f64) -> f64 {
92        // Similar to C0-C180 but shifted
93        Self::downward_c0_c180(ldt, arc)
94    }
95
96    /// Calculate downward flux for both planes symmetry.
97    fn downward_both_planes(ldt: &Eulumdat, arc: f64) -> f64 {
98        let mc = ldt.actual_c_planes();
99        if mc == 0 || ldt.c_angles.is_empty() {
100            return 0.0;
101        }
102
103        let mut sum = 0.0;
104
105        for i in 1..mc {
106            let delta_c = ldt.c_angles[i] - ldt.c_angles[i - 1];
107            sum += 4.0 * delta_c * Self::downward_for_plane(ldt, i - 1, arc);
108        }
109
110        // Handle to 90°
111        if mc > 0 {
112            let delta_c = 90.0 - ldt.c_angles[mc - 1];
113            sum += 4.0 * delta_c * Self::downward_for_plane(ldt, mc - 1, arc);
114        }
115
116        sum / 360.0
117    }
118
119    /// Calculate downward flux for a single C-plane up to arc angle.
120    fn downward_for_plane(ldt: &Eulumdat, c_index: usize, arc: f64) -> f64 {
121        if c_index >= ldt.intensities.len() || ldt.g_angles.is_empty() {
122            return 0.0;
123        }
124
125        let intensities = &ldt.intensities[c_index];
126        let mut sum = 0.0;
127
128        for j in 1..ldt.g_angles.len() {
129            let g_prev = ldt.g_angles[j - 1];
130            let g_curr = ldt.g_angles[j];
131
132            // Only integrate up to arc angle
133            if g_prev >= arc {
134                break;
135            }
136
137            let g_end = g_curr.min(arc);
138            let delta_g = g_end - g_prev;
139
140            if delta_g <= 0.0 {
141                continue;
142            }
143
144            // Average intensity in this segment
145            let i_prev = intensities.get(j - 1).copied().unwrap_or(0.0);
146            let i_curr = intensities.get(j).copied().unwrap_or(0.0);
147            let avg_intensity = (i_prev + i_curr) / 2.0;
148
149            // Convert to radians for solid angle calculation
150            let g_prev_rad = g_prev * PI / 180.0;
151            let g_end_rad = g_end * PI / 180.0;
152
153            // Solid angle element: sin(g) * dg
154            let solid_angle = (g_prev_rad.cos() - g_end_rad.cos()).abs();
155
156            sum += avg_intensity * solid_angle;
157        }
158
159        sum * 2.0 * PI
160    }
161
162    /// Calculate total luminous output.
163    ///
164    /// Integrates the luminous intensity over the entire sphere.
165    pub fn total_output(ldt: &Eulumdat) -> f64 {
166        // Use downward_flux with 180° to get full sphere
167        let mc = ldt.actual_c_planes();
168        if mc == 0 {
169            return 0.0;
170        }
171
172        match ldt.symmetry {
173            Symmetry::None => Self::downward_no_symmetry(ldt, 180.0),
174            Symmetry::VerticalAxis => Self::downward_for_plane(ldt, 0, 180.0),
175            Symmetry::PlaneC0C180 => Self::downward_c0_c180(ldt, 180.0),
176            Symmetry::PlaneC90C270 => Self::downward_c90_c270(ldt, 180.0),
177            Symmetry::BothPlanes => Self::downward_both_planes(ldt, 180.0),
178        }
179    }
180
181    /// Calculate the luminous flux from the stored intensity distribution.
182    ///
183    /// This uses the conversion factor to convert from cd/klm to actual lumens.
184    pub fn calculated_luminous_flux(ldt: &Eulumdat) -> f64 {
185        Self::total_output(ldt) * ldt.conversion_factor
186    }
187
188    /// Calculate direct ratios (utilization factors) for standard room indices.
189    ///
190    /// Room indices k: 0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00
191    ///
192    /// # Arguments
193    /// * `ldt` - The Eulumdat data
194    /// * `shr` - Spacing to Height Ratio (typically "1.00", "1.25", or "1.50")
195    ///
196    /// # Returns
197    /// Array of 10 direct ratio values for the standard room indices.
198    pub fn calculate_direct_ratios(ldt: &Eulumdat, shr: &str) -> [f64; 10] {
199        // Coefficient lookup tables from standard
200        let (e, f, g, h) = Self::get_shr_coefficients(shr);
201
202        // Calculate flux values at critical angles
203        let a = Self::downward_flux(ldt, 41.4);
204        let b = Self::downward_flux(ldt, 60.0);
205        let c = Self::downward_flux(ldt, 75.5);
206        let d = Self::downward_flux(ldt, 90.0);
207
208        let mut ratios = [0.0; 10];
209
210        for i in 0..10 {
211            let t = a * e[i] + b * f[i] + c * g[i] + d * h[i];
212            ratios[i] = t / 100_000.0;
213        }
214
215        ratios
216    }
217
218    /// Get SHR coefficients for direct ratio calculation.
219    fn get_shr_coefficients(shr: &str) -> ([f64; 10], [f64; 10], [f64; 10], [f64; 10]) {
220        match shr {
221            "1.00" => (
222                [
223                    943.0, 752.0, 636.0, 510.0, 429.0, 354.0, 286.0, 258.0, 236.0, 231.0,
224                ],
225                [
226                    -317.0, -33.0, 121.0, 238.0, 275.0, 248.0, 190.0, 118.0, -6.0, -99.0,
227                ],
228                [
229                    481.0, 372.0, 310.0, 282.0, 309.0, 363.0, 416.0, 463.0, 512.0, 518.0,
230                ],
231                [
232                    -107.0, -91.0, -67.0, -30.0, -13.0, 35.0, 108.0, 161.0, 258.0, 350.0,
233                ],
234            ),
235            "1.25" => (
236                [
237                    967.0, 808.0, 695.0, 565.0, 476.0, 386.0, 307.0, 273.0, 243.0, 234.0,
238                ],
239                [
240                    -336.0, -82.0, 73.0, 200.0, 249.0, 243.0, 201.0, 137.0, 18.0, -73.0,
241                ],
242                [
243                    451.0, 339.0, 280.0, 255.0, 278.0, 331.0, 384.0, 432.0, 485.0, 497.0,
244                ],
245                [
246                    -82.0, -65.0, -48.0, -20.0, -3.0, 40.0, 108.0, 158.0, 254.0, 342.0,
247                ],
248            ),
249            _ => (
250                [
251                    983.0, 851.0, 744.0, 614.0, 521.0, 418.0, 329.0, 289.0, 252.0, 239.0,
252                ],
253                [
254                    -348.0, -122.0, 31.0, 163.0, 220.0, 231.0, 203.0, 149.0, 39.0, -48.0,
255                ],
256                [
257                    430.0, 315.0, 256.0, 233.0, 253.0, 304.0, 356.0, 404.0, 460.0, 476.0,
258                ],
259                [
260                    -65.0, -44.0, -31.0, -10.0, 6.0, 47.0, 112.0, 158.0, 249.0, 333.0,
261                ],
262            ),
263        }
264    }
265
266    /// Calculate beam angle (full angle where intensity drops to 50% of maximum).
267    ///
268    /// Uses the IES definition: angle between directions where intensity is 50%
269    /// of the **maximum** intensity (FWHM - Full Width at Half Maximum).
270    ///
271    /// **Important**: Per CIE S 017:2020 (17-27-077), beam angle is defined as a
272    /// **full angle**, not a half angle. This function returns the full angle
273    /// (2× the half-angle from nadir).
274    ///
275    /// Reference: <https://cie.co.at/eilvterm/17-27-077>
276    pub fn beam_angle(ldt: &Eulumdat) -> f64 {
277        // Return full angle (2× half angle) per CIE definition
278        Self::angle_at_percentage(ldt, 0.5) * 2.0
279    }
280
281    /// Calculate field angle (full angle where intensity drops to 10% of maximum).
282    ///
283    /// Uses the IES definition: angle between directions where intensity is 10%
284    /// of the **maximum** intensity.
285    ///
286    /// **Important**: Per CIE S 017:2020, field angle is defined as a **full angle**,
287    /// not a half angle. This function returns the full angle (2× the half-angle from nadir).
288    pub fn field_angle(ldt: &Eulumdat) -> f64 {
289        // Return full angle (2× half angle) per CIE definition
290        Self::angle_at_percentage(ldt, 0.1) * 2.0
291    }
292
293    /// Calculate beam angle using CIE definition (center-beam intensity).
294    ///
295    /// Uses the CIE/NEMA definition: angle between directions where intensity
296    /// is 50% of the **center-beam** intensity (intensity at 0° gamma).
297    ///
298    /// **Important**: Per CIE S 017:2020 (17-27-077), beam angle is defined as a
299    /// **full angle**, not a half angle. This function returns the full angle.
300    ///
301    /// This can differ significantly from the IES (max-based) definition for luminaires
302    /// with "batwing" distributions where center-beam intensity is less than
303    /// maximum intensity.
304    pub fn beam_angle_cie(ldt: &Eulumdat) -> f64 {
305        // Return full angle (2× half angle) per CIE definition
306        Self::angle_at_percentage_of_center(ldt, 0.5) * 2.0
307    }
308
309    /// Calculate field angle using CIE definition (center-beam intensity).
310    ///
311    /// Uses the CIE/NEMA definition: angle between directions where intensity
312    /// is 10% of the **center-beam** intensity.
313    ///
314    /// **Important**: Per CIE S 017:2020, field angle is defined as a **full angle**,
315    /// not a half angle. This function returns the full angle.
316    pub fn field_angle_cie(ldt: &Eulumdat) -> f64 {
317        // Return full angle (2× half angle) per CIE definition
318        Self::angle_at_percentage_of_center(ldt, 0.1) * 2.0
319    }
320
321    /// Calculate half beam angle (angle from nadir to 50% intensity).
322    ///
323    /// This returns the **half angle** from nadir (0°) to where intensity drops
324    /// to 50% of maximum. For the full beam angle per CIE definition, use `beam_angle()`.
325    ///
326    /// This is useful for cone diagrams and coverage calculations where the
327    /// half-angle is needed.
328    pub fn half_beam_angle(ldt: &Eulumdat) -> f64 {
329        Self::angle_at_percentage(ldt, 0.5)
330    }
331
332    /// Calculate half field angle (angle from nadir to 10% intensity).
333    ///
334    /// This returns the **half angle** from nadir (0°) to where intensity drops
335    /// to 10% of maximum. For the full field angle per CIE definition, use `field_angle()`.
336    pub fn half_field_angle(ldt: &Eulumdat) -> f64 {
337        Self::angle_at_percentage(ldt, 0.1)
338    }
339
340    /// Get detailed beam/field angle analysis comparing IES and CIE definitions.
341    ///
342    /// Returns a `BeamFieldAnalysis` struct containing:
343    /// - Beam and field angles using both IES (max) and CIE (center-beam) definitions
344    /// - Maximum intensity and center-beam intensity values
345    /// - Whether the distribution has a "batwing" pattern (center < max)
346    ///
347    /// This is useful for understanding luminaires like the examples in the
348    /// Wikipedia "Beam angle" article where the two definitions give different results.
349    pub fn beam_field_analysis(ldt: &Eulumdat) -> BeamFieldAnalysis {
350        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
351            return BeamFieldAnalysis::default();
352        }
353
354        let intensities = &ldt.intensities[0];
355        let max_intensity = intensities.iter().copied().fold(0.0, f64::max);
356        let center_intensity = intensities.first().copied().unwrap_or(0.0);
357
358        // Find the gamma angle of maximum intensity
359        let max_gamma = ldt
360            .g_angles
361            .iter()
362            .zip(intensities.iter())
363            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
364            .map(|(g, _)| *g)
365            .unwrap_or(0.0);
366
367        let is_batwing = center_intensity < max_intensity * 0.95;
368
369        BeamFieldAnalysis {
370            // IES definition (based on maximum intensity) - full angles per CIE S 017:2020
371            beam_angle_ies: Self::angle_at_percentage(ldt, 0.5) * 2.0,
372            field_angle_ies: Self::angle_at_percentage(ldt, 0.1) * 2.0,
373            // CIE definition (based on center-beam intensity) - full angles per CIE S 017:2020
374            beam_angle_cie: Self::angle_at_percentage_of_center(ldt, 0.5) * 2.0,
375            field_angle_cie: Self::angle_at_percentage_of_center(ldt, 0.1) * 2.0,
376            // Reference intensities
377            max_intensity,
378            center_intensity,
379            max_intensity_gamma: max_gamma,
380            // Distribution type
381            is_batwing,
382            // Threshold values for diagram overlays
383            beam_threshold_ies: max_intensity * 0.5,
384            beam_threshold_cie: center_intensity * 0.5,
385            field_threshold_ies: max_intensity * 0.1,
386            field_threshold_cie: center_intensity * 0.1,
387        }
388    }
389
390    /// Find the angle at which intensity drops to a given percentage of maximum.
391    fn angle_at_percentage(ldt: &Eulumdat, percentage: f64) -> f64 {
392        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
393            return 0.0;
394        }
395
396        // Use first C-plane (or average for non-symmetric)
397        let intensities = &ldt.intensities[0];
398        let max_intensity = intensities.iter().copied().fold(0.0, f64::max);
399
400        if max_intensity <= 0.0 {
401            return 0.0;
402        }
403
404        let threshold = max_intensity * percentage;
405
406        // Find where intensity drops below threshold
407        for (i, &intensity) in intensities.iter().enumerate() {
408            if intensity < threshold && i > 0 {
409                // Interpolate between previous and current
410                let prev_intensity = intensities[i - 1];
411                let prev_angle = ldt.g_angles[i - 1];
412                let curr_angle = ldt.g_angles[i];
413
414                if prev_intensity > threshold {
415                    let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
416                    return prev_angle + ratio * (curr_angle - prev_angle);
417                }
418            }
419        }
420
421        // If never drops below threshold, return last angle
422        *ldt.g_angles.last().unwrap_or(&0.0)
423    }
424
425    /// Find the angle at which intensity drops to a given percentage of center-beam intensity.
426    ///
427    /// This implements the CIE/NEMA definition which uses center-beam (nadir) intensity
428    /// as the reference rather than maximum intensity.
429    fn angle_at_percentage_of_center(ldt: &Eulumdat, percentage: f64) -> f64 {
430        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
431            return 0.0;
432        }
433
434        let intensities = &ldt.intensities[0];
435        let center_intensity = intensities.first().copied().unwrap_or(0.0);
436
437        if center_intensity <= 0.0 {
438            // If center intensity is zero, fall back to max-based calculation
439            return Self::angle_at_percentage(ldt, percentage);
440        }
441
442        let threshold = center_intensity * percentage;
443
444        // Find where intensity drops below threshold
445        for (i, &intensity) in intensities.iter().enumerate() {
446            if intensity < threshold && i > 0 {
447                let prev_intensity = intensities[i - 1];
448                let prev_angle = ldt.g_angles[i - 1];
449                let curr_angle = ldt.g_angles[i];
450
451                if prev_intensity > threshold {
452                    let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
453                    return prev_angle + ratio * (curr_angle - prev_angle);
454                }
455            }
456        }
457
458        *ldt.g_angles.last().unwrap_or(&0.0)
459    }
460
461    /// Calculate beam angle for upward light (peak near 180°).
462    ///
463    /// For uplights where maximum intensity is in the upper hemisphere (gamma > 90°),
464    /// this calculates the beam angle centered on the upward peak.
465    ///
466    /// Returns the **full angle** where intensity is above 50% of maximum.
467    pub fn upward_beam_angle(ldt: &Eulumdat) -> f64 {
468        Self::angle_spread_from_peak(ldt, 0.5, true)
469    }
470
471    /// Calculate field angle for upward light (peak near 180°).
472    ///
473    /// Returns the **full angle** where intensity is above 10% of maximum.
474    pub fn upward_field_angle(ldt: &Eulumdat) -> f64 {
475        Self::angle_spread_from_peak(ldt, 0.1, true)
476    }
477
478    /// Calculate beam angle for downward light (peak near 0°).
479    ///
480    /// For downlights where maximum intensity is in the lower hemisphere (gamma < 90°),
481    /// this calculates the beam angle centered on the downward peak.
482    ///
483    /// Returns the **full angle** where intensity is above 50% of maximum.
484    pub fn downward_beam_angle(ldt: &Eulumdat) -> f64 {
485        Self::angle_spread_from_peak(ldt, 0.5, false)
486    }
487
488    /// Calculate field angle for downward light (peak near 0°).
489    ///
490    /// Returns the **full angle** where intensity is above 10% of maximum.
491    pub fn downward_field_angle(ldt: &Eulumdat) -> f64 {
492        Self::angle_spread_from_peak(ldt, 0.1, false)
493    }
494
495    /// Find the angular spread from peak intensity to threshold percentage.
496    ///
497    /// Searches outward from the peak intensity angle in both directions to find
498    /// where intensity drops below the threshold. Works for both downlights
499    /// (peak near 0°) and uplights (peak near 180°).
500    ///
501    /// # Arguments
502    /// * `ldt` - The Eulumdat data
503    /// * `percentage` - Threshold as fraction of peak (0.5 for beam, 0.1 for field)
504    /// * `upward` - If true, find peak in upper hemisphere (90-180°); otherwise lower (0-90°)
505    ///
506    /// # Returns
507    /// The full beam/field angle in degrees
508    fn angle_spread_from_peak(ldt: &Eulumdat, percentage: f64, upward: bool) -> f64 {
509        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
510            return 0.0;
511        }
512
513        let intensities = &ldt.intensities[0];
514        let g_angles = &ldt.g_angles;
515
516        // Define hemisphere boundary
517        let hemisphere_boundary = 90.0;
518
519        // Find peak intensity in the specified hemisphere
520        let (peak_idx, peak_intensity) = if upward {
521            // Search upper hemisphere (gamma >= 90°)
522            intensities
523                .iter()
524                .enumerate()
525                .filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(0.0) >= hemisphere_boundary)
526                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
527                .map(|(i, &v)| (i, v))
528                .unwrap_or((0, 0.0))
529        } else {
530            // Search lower hemisphere (gamma <= 90°)
531            intensities
532                .iter()
533                .enumerate()
534                .filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(180.0) <= hemisphere_boundary)
535                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
536                .map(|(i, &v)| (i, v))
537                .unwrap_or((0, 0.0))
538        };
539
540        if peak_intensity <= 0.0 {
541            return 0.0;
542        }
543
544        let threshold = peak_intensity * percentage;
545        let peak_angle = g_angles.get(peak_idx).copied().unwrap_or(0.0);
546
547        // Determine search boundaries based on hemisphere
548        let (min_angle, max_angle) = if upward {
549            (hemisphere_boundary, 180.0)
550        } else {
551            (0.0, hemisphere_boundary)
552        };
553
554        // Search downward (decreasing gamma) from peak, but stay within hemisphere
555        let mut angle_low = peak_angle;
556        for i in (0..peak_idx).rev() {
557            let angle = g_angles[i];
558            // Stop at hemisphere boundary
559            if angle < min_angle {
560                angle_low = min_angle;
561                break;
562            }
563            let intensity = intensities[i];
564            if intensity < threshold {
565                // Interpolate
566                let next_intensity = intensities.get(i + 1).copied().unwrap_or(peak_intensity);
567                let next_angle = g_angles.get(i + 1).copied().unwrap_or(peak_angle);
568                if next_intensity > threshold && next_intensity > intensity {
569                    let ratio = (next_intensity - threshold) / (next_intensity - intensity);
570                    angle_low = (next_angle - ratio * (next_angle - angle)).max(min_angle);
571                } else {
572                    angle_low = angle;
573                }
574                break;
575            }
576            angle_low = angle;
577        }
578
579        // Search upward (increasing gamma) from peak, but stay within hemisphere
580        let mut angle_high = peak_angle;
581        for i in (peak_idx + 1)..intensities.len() {
582            let angle = g_angles[i];
583            // Stop at hemisphere boundary
584            if angle > max_angle {
585                angle_high = max_angle;
586                break;
587            }
588            let intensity = intensities[i];
589            if intensity < threshold {
590                // Interpolate
591                let prev_intensity = intensities.get(i - 1).copied().unwrap_or(peak_intensity);
592                let prev_angle = g_angles.get(i - 1).copied().unwrap_or(peak_angle);
593                if prev_intensity > threshold && prev_intensity > intensity {
594                    let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
595                    angle_high = (prev_angle + ratio * (angle - prev_angle)).min(max_angle);
596                } else {
597                    angle_high = angle;
598                }
599                break;
600            }
601            angle_high = angle;
602        }
603
604        // Return the full angular spread
605        (angle_high - angle_low).abs()
606    }
607
608    /// Get comprehensive beam angle analysis including both downward and upward components.
609    ///
610    /// For luminaires with significant flux in both hemispheres (e.g., direct-indirect),
611    /// this provides separate beam angles for each direction.
612    pub fn comprehensive_beam_analysis(ldt: &Eulumdat) -> ComprehensiveBeamAnalysis {
613        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
614            return ComprehensiveBeamAnalysis::default();
615        }
616
617        let intensities = &ldt.intensities[0];
618        let g_angles = &ldt.g_angles;
619
620        // Find peaks in each hemisphere
621        let (downward_peak_idx, downward_peak) = intensities
622            .iter()
623            .enumerate()
624            .filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(180.0) <= 90.0)
625            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
626            .map(|(i, &v)| (i, v))
627            .unwrap_or((0, 0.0));
628
629        let (upward_peak_idx, upward_peak) = intensities
630            .iter()
631            .enumerate()
632            .filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(0.0) >= 90.0)
633            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
634            .map(|(i, &v)| (i, v))
635            .unwrap_or((intensities.len().saturating_sub(1), 0.0));
636
637        let downward_peak_gamma = g_angles.get(downward_peak_idx).copied().unwrap_or(0.0);
638        let upward_peak_gamma = g_angles.get(upward_peak_idx).copied().unwrap_or(180.0);
639
640        // Determine if there's significant light in each hemisphere
641        let has_downward = downward_peak > 0.0;
642        let has_upward = upward_peak > 0.0;
643
644        // Calculate angles only for hemispheres with significant light
645        let downward_beam = if has_downward {
646            Self::angle_spread_from_peak(ldt, 0.5, false)
647        } else {
648            0.0
649        };
650        let downward_field = if has_downward {
651            Self::angle_spread_from_peak(ldt, 0.1, false)
652        } else {
653            0.0
654        };
655
656        let upward_beam = if has_upward {
657            Self::angle_spread_from_peak(ldt, 0.5, true)
658        } else {
659            0.0
660        };
661        let upward_field = if has_upward {
662            Self::angle_spread_from_peak(ldt, 0.1, true)
663        } else {
664            0.0
665        };
666
667        // Determine primary direction
668        let primary_direction = if downward_peak >= upward_peak {
669            LightDirection::Downward
670        } else {
671            LightDirection::Upward
672        };
673
674        // Classify distribution type based on relative intensities
675        // A component is "significant" if it's >= 10% of the dominant component
676        let upward_significant = has_upward && upward_peak >= downward_peak * 0.1;
677        let downward_significant = has_downward && downward_peak >= upward_peak * 0.1;
678
679        let distribution_type = if upward_significant && downward_significant {
680            // Both components are significant - it's a mixed distribution
681            if downward_peak > upward_peak {
682                DistributionType::DirectIndirect
683            } else {
684                DistributionType::IndirectDirect
685            }
686        } else if has_upward && upward_peak > downward_peak {
687            // Primarily upward with negligible downward
688            DistributionType::Indirect
689        } else {
690            // Primarily downward with negligible upward
691            DistributionType::Direct
692        };
693
694        ComprehensiveBeamAnalysis {
695            downward_beam_angle: downward_beam,
696            downward_field_angle: downward_field,
697            downward_peak_intensity: downward_peak,
698            downward_peak_gamma,
699            upward_beam_angle: upward_beam,
700            upward_field_angle: upward_field,
701            upward_peak_intensity: upward_peak,
702            upward_peak_gamma,
703            primary_direction,
704            distribution_type,
705        }
706    }
707
708    /// Calculate UGR (Unified Glare Rating) cross-section data.
709    ///
710    /// Returns intensity values at standard viewing angles for UGR calculation.
711    pub fn ugr_crosssection(ldt: &Eulumdat) -> Vec<(f64, f64)> {
712        // Standard UGR angles: 45°, 55°, 65°, 75°, 85°
713        let ugr_angles = [45.0, 55.0, 65.0, 75.0, 85.0];
714
715        ugr_angles
716            .iter()
717            .map(|&angle| {
718                let intensity = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, angle);
719                (angle, intensity)
720            })
721            .collect()
722    }
723
724    // ========================================================================
725    // CIE Flux Codes
726    // ========================================================================
727
728    /// Calculate CIE Flux Codes.
729    ///
730    /// Returns a tuple of 5 values (N1, N2, N3, N4, N5) representing the
731    /// percentage of lamp flux in different angular zones:
732    /// - N1: % in lower hemisphere (0-90°)
733    /// - N2: % in 0-60° zone
734    /// - N3: % in 0-40° zone
735    /// - N4: % in upper hemisphere (90-180°)
736    /// - N5: % in 90-120° zone (near-horizontal uplight)
737    ///
738    /// The flux code is typically written as: N1 N2 N3 N4 N5
739    /// Example: "92 68 42 8 3" means 92% downward, 68% within 60°, etc.
740    pub fn cie_flux_codes(ldt: &Eulumdat) -> CieFluxCodes {
741        let total = Self::total_output(ldt);
742        if total <= 0.0 {
743            return CieFluxCodes::default();
744        }
745
746        // Calculate flux in each zone
747        let flux_40 = Self::downward_flux(ldt, 40.0);
748        let flux_60 = Self::downward_flux(ldt, 60.0);
749        let flux_90 = Self::downward_flux(ldt, 90.0);
750        let flux_120 = Self::downward_flux(ldt, 120.0);
751        let flux_180 = Self::downward_flux(ldt, 180.0);
752
753        CieFluxCodes {
754            n1: flux_90,            // 0-90° (DLOR)
755            n2: flux_60,            // 0-60°
756            n3: flux_40,            // 0-40°
757            n4: flux_180 - flux_90, // 90-180° (ULOR)
758            n5: flux_120 - flux_90, // 90-120° (near-horizontal uplight)
759        }
760    }
761
762    // ========================================================================
763    // Luminaire Efficacy
764    // ========================================================================
765
766    /// Calculate luminaire efficacy in lm/W.
767    ///
768    /// This differs from lamp efficacy by accounting for the Light Output Ratio (LOR).
769    /// Luminaire efficacy = (lamp flux × LOR) / system watts
770    ///
771    /// # Returns
772    /// Luminaire efficacy in lumens per watt (lm/W)
773    pub fn luminaire_efficacy(ldt: &Eulumdat) -> f64 {
774        let total_watts = ldt.total_wattage();
775        if total_watts <= 0.0 {
776            return 0.0;
777        }
778
779        let lamp_flux = ldt.total_luminous_flux();
780        let lor = ldt.light_output_ratio / 100.0;
781
782        (lamp_flux * lor) / total_watts
783    }
784
785    /// Calculate luminaire efficiency (same as LOR but calculated from intensities).
786    ///
787    /// Compares calculated luminous flux to rated lamp flux.
788    ///
789    /// # Returns
790    /// Efficiency as a percentage (0-100)
791    pub fn luminaire_efficiency(ldt: &Eulumdat) -> f64 {
792        let lamp_flux = ldt.total_luminous_flux();
793        if lamp_flux <= 0.0 {
794            return 0.0;
795        }
796
797        let calculated_flux = Self::calculated_luminous_flux(ldt);
798        (calculated_flux / lamp_flux) * 100.0
799    }
800
801    // ========================================================================
802    // Spacing Criterion (S/H Ratio)
803    // ========================================================================
804
805    /// Calculate the spacing criterion (S/H ratio) for uniform illumination.
806    ///
807    /// The spacing criterion indicates the maximum ratio of luminaire spacing
808    /// to mounting height that will provide reasonably uniform illumination.
809    ///
810    /// # Arguments
811    /// * `ldt` - The Eulumdat data
812    /// * `c_plane` - The C-plane to analyze (typically 0 or 90)
813    ///
814    /// # Returns
815    /// The spacing to height ratio (typically 1.0 to 2.0)
816    pub fn spacing_criterion(ldt: &Eulumdat, c_plane: f64) -> f64 {
817        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
818            return 1.0;
819        }
820
821        // Find intensity at nadir (0°)
822        let i_nadir = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, 0.0);
823        if i_nadir <= 0.0 {
824            return 1.0;
825        }
826
827        // Find angle where intensity drops to 50% of nadir
828        let threshold = i_nadir * 0.5;
829        let mut half_angle = 0.0;
830
831        for g in 0..90 {
832            let intensity =
833                crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, g as f64);
834            if intensity < threshold {
835                // Interpolate
836                let prev_intensity = crate::symmetry::SymmetryHandler::get_intensity_at(
837                    ldt,
838                    c_plane,
839                    (g - 1) as f64,
840                );
841                if prev_intensity > threshold {
842                    let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
843                    half_angle = (g - 1) as f64 + ratio;
844                }
845                break;
846            }
847            half_angle = g as f64;
848        }
849
850        // S/H = 2 * tan(half_angle)
851        // Typical values: narrow beam = 0.8-1.0, wide beam = 1.5-2.0
852        let s_h = 2.0 * (half_angle * PI / 180.0).tan();
853
854        // Clamp to reasonable range
855        s_h.clamp(0.5, 3.0)
856    }
857
858    /// Calculate spacing criteria for both principal planes.
859    ///
860    /// # Returns
861    /// (S/H parallel, S/H perpendicular) - spacing ratios for C0 and C90 planes
862    pub fn spacing_criteria(ldt: &Eulumdat) -> (f64, f64) {
863        let s_h_parallel = Self::spacing_criterion(ldt, 0.0);
864        let s_h_perpendicular = Self::spacing_criterion(ldt, 90.0);
865        (s_h_parallel, s_h_perpendicular)
866    }
867
868    /// IES-style spacing criterion based on work plane illuminance uniformity.
869    ///
870    /// This method finds the maximum S/H ratio where illuminance uniformity
871    /// (Emin/Emax) remains above the threshold (default 0.7) on the work plane.
872    ///
873    /// The IES method accounts for:
874    /// - Inverse square law (1/d²)
875    /// - Cosine law for horizontal illuminance (cos³θ)
876    /// - Contribution from adjacent luminaires
877    ///
878    /// # Arguments
879    /// * `ldt` - The Eulumdat data
880    /// * `c_plane` - The C-plane to analyze (0 for 0-180, 90 for 90-270)
881    /// * `uniformity_threshold` - Minimum Emin/Emax ratio (typically 0.7)
882    ///
883    /// # Returns
884    /// The spacing to height ratio (typically 1.0 to 1.5)
885    pub fn spacing_criterion_ies(ldt: &Eulumdat, c_plane: f64, uniformity_threshold: f64) -> f64 {
886        if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
887            return 1.0;
888        }
889
890        // Binary search for maximum S/H that maintains uniformity
891        let mut low = 0.5;
892        let mut high = 3.0;
893
894        for _ in 0..20 {
895            // 20 iterations gives ~6 decimal places precision
896            let mid = (low + high) / 2.0;
897            let uniformity = Self::calculate_illuminance_uniformity(ldt, c_plane, mid);
898
899            if uniformity >= uniformity_threshold {
900                low = mid; // Can space further apart
901            } else {
902                high = mid; // Need closer spacing
903            }
904        }
905
906        low
907    }
908
909    /// Calculate illuminance uniformity for a given spacing ratio.
910    ///
911    /// Simulates illuminance from two luminaires spaced S/H apart along the
912    /// specified C-plane, calculating Emin/Emax at multiple points.
913    fn calculate_illuminance_uniformity(ldt: &Eulumdat, c_plane: f64, s_h: f64) -> f64 {
914        // Sample points between luminaires (at height H=1 for normalization)
915        const NUM_POINTS: usize = 21;
916        let mut illuminances = [0.0; NUM_POINTS];
917
918        // Luminaire positions: one at x=0, one at x=S (where S = s_h * H = s_h * 1)
919        let spacing = s_h;
920
921        for (i, e) in illuminances.iter_mut().enumerate() {
922            // Sample point position (from x=0 to x=spacing)
923            let x = (i as f64 / (NUM_POINTS - 1) as f64) * spacing;
924
925            // Contribution from luminaire 1 at (0, 0)
926            *e += Self::point_illuminance(ldt, c_plane, x, 1.0);
927
928            // Contribution from luminaire 2 at (spacing, 0)
929            *e += Self::point_illuminance(ldt, c_plane, spacing - x, 1.0);
930        }
931
932        // Calculate uniformity = Emin / Emax
933        let e_max = illuminances.iter().cloned().fold(0.0, f64::max);
934        let e_min = illuminances.iter().cloned().fold(f64::MAX, f64::min);
935
936        if e_max > 0.0 {
937            e_min / e_max
938        } else {
939            0.0
940        }
941    }
942
943    /// Calculate horizontal illuminance at a point from a single luminaire.
944    ///
945    /// Uses the inverse square law and cosine³ correction for horizontal surfaces:
946    /// E = I(θ) × cos³(θ) / H²
947    ///
948    /// where θ = atan(x/H) is the angle from nadir.
949    fn point_illuminance(ldt: &Eulumdat, c_plane: f64, x: f64, h: f64) -> f64 {
950        // Angle from nadir
951        let theta = (x / h).atan();
952        let theta_deg = theta.to_degrees();
953
954        // Get intensity at this angle
955        let intensity = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, theta_deg);
956
957        // Horizontal illuminance: E = I × cos³(θ) / H²
958        // (cos³ accounts for both the angle of incidence and distance increase)
959        let cos_theta = theta.cos();
960        intensity * cos_theta.powi(3) / (h * h)
961    }
962
963    /// Calculate IES-style spacing criteria for both principal planes.
964    ///
965    /// Uses illuminance-based uniformity calculation per IES methodology.
966    ///
967    /// # Returns
968    /// (SC 0-180, SC 90-270, SC diagonal)
969    pub fn spacing_criteria_ies(ldt: &Eulumdat) -> (f64, f64, f64) {
970        // IES LM-46 defines acceptable uniformity as Emin/Emax ≥ 0.87
971        // for general indoor lighting applications
972        let sc_0_180 = Self::spacing_criterion_ies(ldt, 0.0, 0.87);
973        let sc_90_270 = Self::spacing_criterion_ies(ldt, 90.0, 0.87);
974        // Diagonal SC is calculated for a 4-luminaire array at the center point
975        // Typically SC_diagonal ≈ SC_principal × 1.1 (geometric factor for diagonal spacing)
976        let sc_diagonal = sc_0_180.min(sc_90_270) * 1.10;
977        (sc_0_180, sc_90_270, sc_diagonal)
978    }
979
980    // ========================================================================
981    // Standard Zonal Lumens
982    // ========================================================================
983
984    /// Calculate luminous flux in standard angular zones.
985    ///
986    /// Returns flux percentages in 10° zones from 0° to 180°.
987    ///
988    /// # Returns
989    /// Array of 18 values representing % flux in each 10° zone
990    pub fn zonal_lumens_10deg(ldt: &Eulumdat) -> [f64; 18] {
991        let mut zones = [0.0; 18];
992        let total = Self::total_output(ldt);
993
994        if total <= 0.0 {
995            return zones;
996        }
997
998        let mut prev_flux = 0.0;
999        for (i, zone) in zones.iter_mut().enumerate() {
1000            let angle = ((i + 1) * 10) as f64;
1001            let cumulative = Self::downward_flux(ldt, angle);
1002            *zone = cumulative - prev_flux;
1003            prev_flux = cumulative;
1004        }
1005
1006        zones
1007    }
1008
1009    /// Calculate luminous flux in standard 30° zones.
1010    ///
1011    /// # Returns
1012    /// ZonalLumens30 struct with flux in each 30° zone
1013    pub fn zonal_lumens_30deg(ldt: &Eulumdat) -> ZonalLumens30 {
1014        let total = Self::total_output(ldt);
1015
1016        if total <= 0.0 {
1017            return ZonalLumens30::default();
1018        }
1019
1020        let f30 = Self::downward_flux(ldt, 30.0);
1021        let f60 = Self::downward_flux(ldt, 60.0);
1022        let f90 = Self::downward_flux(ldt, 90.0);
1023        let f120 = Self::downward_flux(ldt, 120.0);
1024        let f150 = Self::downward_flux(ldt, 150.0);
1025        let f180 = Self::downward_flux(ldt, 180.0);
1026
1027        ZonalLumens30 {
1028            zone_0_30: f30,
1029            zone_30_60: f60 - f30,
1030            zone_60_90: f90 - f60,
1031            zone_90_120: f120 - f90,
1032            zone_120_150: f150 - f120,
1033            zone_150_180: f180 - f150,
1034        }
1035    }
1036
1037    // ========================================================================
1038    // K-Factor (Utilance)
1039    // ========================================================================
1040
1041    /// Calculate the K-factor (utilance) for a room.
1042    ///
1043    /// K = (E_avg × A) / Φ_lamp
1044    ///
1045    /// Where:
1046    /// - E_avg = average illuminance on work plane
1047    /// - A = room area
1048    /// - Φ_lamp = total lamp flux
1049    ///
1050    /// # Arguments
1051    /// * `ldt` - The Eulumdat data
1052    /// * `room_index` - Room index k = (L×W) / (H_m × (L+W))
1053    /// * `reflectances` - (ceiling, wall, floor) reflectances as decimals
1054    ///
1055    /// # Returns
1056    /// K-factor (utilance) as a decimal (0-1)
1057    pub fn k_factor(ldt: &Eulumdat, room_index: f64, reflectances: (f64, f64, f64)) -> f64 {
1058        // Use direct ratio as base
1059        let room_index_idx = Self::room_index_to_idx(room_index);
1060        let direct_ratios = Self::calculate_direct_ratios(ldt, "1.25");
1061        let direct = direct_ratios[room_index_idx];
1062
1063        // Apply reflection factors (simplified model)
1064        let (rho_c, rho_w, rho_f) = reflectances;
1065
1066        // Indirect component depends on room reflectances
1067        let avg_reflectance = (rho_c + rho_w + rho_f) / 3.0;
1068        let indirect_factor = avg_reflectance / (1.0 - avg_reflectance);
1069
1070        // Simplified: K ≈ direct × (1 + indirect_factor × upward_fraction)
1071        let upward_fraction = 1.0 - (ldt.downward_flux_fraction / 100.0);
1072
1073        direct * (1.0 + indirect_factor * upward_fraction * 0.5)
1074    }
1075
1076    /// Convert room index to array index for direct ratio lookup.
1077    fn room_index_to_idx(room_index: f64) -> usize {
1078        // Room indices: 0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00
1079        let indices = [0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00];
1080
1081        for (i, &k) in indices.iter().enumerate() {
1082            if room_index <= k {
1083                return i;
1084            }
1085        }
1086        9 // Return last index if room_index > 5.0
1087    }
1088
1089    // ========================================================================
1090    // Full UGR Calculation
1091    // ========================================================================
1092
1093    /// Calculate UGR (Unified Glare Rating) for a specific room configuration.
1094    ///
1095    /// UGR = 8 × log₁₀((0.25/Lb) × Σ(L²×ω/p²))
1096    ///
1097    /// Where:
1098    /// - Lb = background luminance
1099    /// - L = luminaire luminance in direction of observer
1100    /// - ω = solid angle of luminaire
1101    /// - p = position index
1102    ///
1103    /// # Arguments
1104    /// * `ldt` - The Eulumdat data
1105    /// * `params` - UGR calculation parameters
1106    ///
1107    /// # Returns
1108    /// UGR value (typically 10-30, lower is better)
1109    pub fn ugr(ldt: &Eulumdat, params: &UgrParams) -> f64 {
1110        let luminaire_area = (ldt.length / 1000.0) * (ldt.width / 1000.0);
1111        if luminaire_area <= 0.0 {
1112            return 0.0;
1113        }
1114
1115        // Background luminance (simplified: based on room reflectance and illuminance)
1116        let lb = params.background_luminance();
1117
1118        let mut sum = 0.0;
1119
1120        // Calculate for each luminaire position
1121        for pos in &params.luminaire_positions {
1122            // Viewing angle from observer to luminaire
1123            let dx = pos.0 - params.observer_x;
1124            let dy = pos.1 - params.observer_y;
1125            let dz = params.mounting_height - params.eye_height;
1126
1127            let horizontal_dist = (dx * dx + dy * dy).sqrt();
1128            let viewing_angle = (horizontal_dist / dz).atan() * 180.0 / PI;
1129
1130            // Get luminance in viewing direction
1131            let c_angle = dy.atan2(dx) * 180.0 / PI;
1132            let c_angle = if c_angle < 0.0 {
1133                c_angle + 360.0
1134            } else {
1135                c_angle
1136            };
1137
1138            let intensity =
1139                crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_angle, viewing_angle);
1140
1141            // Luminance = I / A (cd/m²)
1142            let luminance = intensity * 1000.0 / luminaire_area; // Convert from cd/klm
1143
1144            // Solid angle
1145            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
1146            let omega = luminaire_area / (dist * dist);
1147
1148            // Position index (Guth position index, simplified)
1149            let p = Self::guth_position_index(viewing_angle, horizontal_dist, dz);
1150
1151            if p > 0.0 {
1152                sum += (luminance * luminance * omega) / (p * p);
1153            }
1154        }
1155
1156        if sum <= 0.0 || lb <= 0.0 {
1157            return 0.0;
1158        }
1159
1160        8.0 * (0.25 * sum / lb).log10()
1161    }
1162
1163    /// Calculate Guth position index.
1164    fn guth_position_index(gamma: f64, h: f64, v: f64) -> f64 {
1165        // Simplified Guth position index
1166        // Based on viewing angle and geometry
1167        let t = if v > 0.0 { h / v } else { 1.0 };
1168
1169        // Simplified approximation: increases with viewing angle
1170        let p = 1.0 + (gamma / 90.0).powf(2.0) * t;
1171        p.max(1.0)
1172    }
1173
1174    // ========================================================================
1175    // Coefficient of Utilization (CU) Table - IES Zonal Cavity Method
1176    // ========================================================================
1177
1178    /// Calculate Coefficient of Utilization table using Zonal Cavity Method.
1179    ///
1180    /// Returns a table of CU values (as percentages, 0-100+) for standard room cavity
1181    /// ratios (RCR 0-10) and reflectance combinations.
1182    ///
1183    /// Based on IES LM-57 and IES Handbook calculation methods.
1184    pub fn cu_table(ldt: &Eulumdat) -> CuTable {
1185        CuTable::calculate(ldt)
1186    }
1187
1188    // ========================================================================
1189    // Unified Glare Rating (UGR) Table - CIE 117:1995
1190    // ========================================================================
1191
1192    /// Calculate Unified Glare Rating table.
1193    ///
1194    /// Returns UGR values for standard room dimensions and reflectance combinations.
1195    /// Based on CIE 117:1995 and CIE 190:2010 methods.
1196    pub fn ugr_table(ldt: &Eulumdat) -> UgrTable {
1197        UgrTable::calculate(ldt)
1198    }
1199
1200    // ========================================================================
1201    // Candela Tabulation
1202    // ========================================================================
1203
1204    /// Generate candela tabulation data for reports.
1205    ///
1206    /// Returns absolute candela values at each angle, suitable for inclusion
1207    /// in photometric reports (similar to Photometric Toolbox format).
1208    pub fn candela_tabulation(ldt: &Eulumdat) -> CandelaTabulation {
1209        CandelaTabulation::from_eulumdat(ldt)
1210    }
1211}
1212
1213// ============================================================================
1214// PhotometricSummary - Complete calculated metrics
1215// ============================================================================
1216
1217/// Complete photometric summary with all calculated values.
1218///
1219/// This struct provides a comprehensive overview of luminaire performance
1220/// that can be used for reports, GLDF export, or display.
1221#[derive(Debug, Clone, Default, PartialEq)]
1222#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1223pub struct PhotometricSummary {
1224    // Flux and efficiency
1225    /// Total lamp flux (lm)
1226    pub total_lamp_flux: f64,
1227    /// Calculated flux from intensity integration (lm)
1228    pub calculated_flux: f64,
1229    /// Light Output Ratio (%)
1230    pub lor: f64,
1231    /// Downward Light Output Ratio (%)
1232    pub dlor: f64,
1233    /// Upward Light Output Ratio (%)
1234    pub ulor: f64,
1235
1236    // Efficacy
1237    /// Lamp efficacy (lm/W)
1238    pub lamp_efficacy: f64,
1239    /// Luminaire efficacy (lm/W)
1240    pub luminaire_efficacy: f64,
1241    /// Total system wattage (W)
1242    pub total_wattage: f64,
1243
1244    // CIE Flux Codes
1245    /// CIE flux codes (N1-N5)
1246    pub cie_flux_codes: CieFluxCodes,
1247
1248    // Beam characteristics (IES definition - based on maximum intensity)
1249    /// Beam angle - 50% of max intensity (degrees) - IES definition
1250    pub beam_angle: f64,
1251    /// Field angle - 10% of max intensity (degrees) - IES definition
1252    pub field_angle: f64,
1253
1254    // Beam characteristics (CIE definition - based on center-beam intensity)
1255    /// Beam angle - 50% of center intensity (degrees) - CIE definition
1256    pub beam_angle_cie: f64,
1257    /// Field angle - 10% of center intensity (degrees) - CIE definition
1258    pub field_angle_cie: f64,
1259    /// True if distribution is batwing (center < max, IES ≠ CIE)
1260    pub is_batwing: bool,
1261
1262    // Upward beam characteristics (for uplights and direct-indirect luminaires)
1263    /// Upward beam angle - 50% of upward peak (degrees)
1264    pub upward_beam_angle: f64,
1265    /// Upward field angle - 10% of upward peak (degrees)
1266    pub upward_field_angle: f64,
1267    /// Primary light direction (Downward or Upward)
1268    pub primary_direction: LightDirection,
1269    /// Distribution type (Direct, Indirect, DirectIndirect, IndirectDirect)
1270    pub distribution_type: DistributionType,
1271
1272    // Intensity statistics
1273    /// Maximum intensity (cd/klm)
1274    pub max_intensity: f64,
1275    /// Minimum intensity (cd/klm)
1276    pub min_intensity: f64,
1277    /// Average intensity (cd/klm)
1278    pub avg_intensity: f64,
1279
1280    // Spacing criterion
1281    /// S/H ratio for C0 plane
1282    pub spacing_c0: f64,
1283    /// S/H ratio for C90 plane
1284    pub spacing_c90: f64,
1285
1286    // Zonal lumens
1287    /// Zonal lumens in 30° zones
1288    pub zonal_lumens: ZonalLumens30,
1289}
1290
1291impl PhotometricSummary {
1292    /// Calculate complete photometric summary from Eulumdat data.
1293    pub fn from_eulumdat(ldt: &Eulumdat) -> Self {
1294        let cie_codes = PhotometricCalculations::cie_flux_codes(ldt);
1295        let (s_c0, s_c90) = PhotometricCalculations::spacing_criteria(ldt);
1296
1297        Self {
1298            // Flux
1299            total_lamp_flux: ldt.total_luminous_flux(),
1300            calculated_flux: PhotometricCalculations::calculated_luminous_flux(ldt),
1301            lor: ldt.light_output_ratio,
1302            dlor: ldt.downward_flux_fraction,
1303            ulor: 100.0 - ldt.downward_flux_fraction,
1304
1305            // Efficacy
1306            lamp_efficacy: ldt.luminous_efficacy(),
1307            luminaire_efficacy: PhotometricCalculations::luminaire_efficacy(ldt),
1308            total_wattage: ldt.total_wattage(),
1309
1310            // CIE
1311            cie_flux_codes: cie_codes,
1312
1313            // Beam (IES definition)
1314            beam_angle: PhotometricCalculations::beam_angle(ldt),
1315            field_angle: PhotometricCalculations::field_angle(ldt),
1316
1317            // Beam (CIE definition)
1318            beam_angle_cie: PhotometricCalculations::beam_angle_cie(ldt),
1319            field_angle_cie: PhotometricCalculations::field_angle_cie(ldt),
1320            is_batwing: {
1321                let analysis = PhotometricCalculations::beam_field_analysis(ldt);
1322                analysis.is_batwing
1323            },
1324
1325            // Upward beam characteristics
1326            upward_beam_angle: PhotometricCalculations::upward_beam_angle(ldt),
1327            upward_field_angle: PhotometricCalculations::upward_field_angle(ldt),
1328            primary_direction: {
1329                let comp = PhotometricCalculations::comprehensive_beam_analysis(ldt);
1330                comp.primary_direction
1331            },
1332            distribution_type: {
1333                let comp = PhotometricCalculations::comprehensive_beam_analysis(ldt);
1334                comp.distribution_type
1335            },
1336
1337            // Intensity
1338            max_intensity: ldt.max_intensity(),
1339            min_intensity: ldt.min_intensity(),
1340            avg_intensity: ldt.avg_intensity(),
1341
1342            // Spacing
1343            spacing_c0: s_c0,
1344            spacing_c90: s_c90,
1345
1346            // Zonal
1347            zonal_lumens: PhotometricCalculations::zonal_lumens_30deg(ldt),
1348        }
1349    }
1350
1351    /// Format as multi-line text report.
1352    pub fn to_text(&self) -> String {
1353        format!(
1354            r#"PHOTOMETRIC SUMMARY
1355==================
1356
1357Luminous Flux
1358  Total Lamp Flux:     {:.0} lm
1359  Calculated Flux:     {:.0} lm
1360  LOR:                 {:.1}%
1361  DLOR / ULOR:         {:.1}% / {:.1}%
1362
1363Efficacy
1364  Lamp Efficacy:       {:.1} lm/W
1365  Luminaire Efficacy:  {:.1} lm/W
1366  Total Wattage:       {:.1} W
1367
1368CIE Flux Code:         {}
1369
1370Beam Characteristics
1371  Beam Angle (50%):    {:.1}°
1372  Field Angle (10%):   {:.1}°
1373
1374Intensity (cd/klm)
1375  Maximum:             {:.1}
1376  Minimum:             {:.1}
1377  Average:             {:.1}
1378
1379Spacing Criterion (S/H)
1380  C0 Plane:            {:.2}
1381  C90 Plane:           {:.2}
1382
1383Zonal Lumens (%)
1384  0-30°:               {:.1}%
1385  30-60°:              {:.1}%
1386  60-90°:              {:.1}%
1387  90-120°:             {:.1}%
1388  120-150°:            {:.1}%
1389  150-180°:            {:.1}%
1390"#,
1391            self.total_lamp_flux,
1392            self.calculated_flux,
1393            self.lor,
1394            self.dlor,
1395            self.ulor,
1396            self.lamp_efficacy,
1397            self.luminaire_efficacy,
1398            self.total_wattage,
1399            self.cie_flux_codes,
1400            self.beam_angle,
1401            self.field_angle,
1402            self.max_intensity,
1403            self.min_intensity,
1404            self.avg_intensity,
1405            self.spacing_c0,
1406            self.spacing_c90,
1407            self.zonal_lumens.zone_0_30,
1408            self.zonal_lumens.zone_30_60,
1409            self.zonal_lumens.zone_60_90,
1410            self.zonal_lumens.zone_90_120,
1411            self.zonal_lumens.zone_120_150,
1412            self.zonal_lumens.zone_150_180,
1413        )
1414    }
1415
1416    /// Format as single-line compact summary.
1417    pub fn to_compact(&self) -> String {
1418        format!(
1419            "CIE:{} Beam:{:.0}° Field:{:.0}° Eff:{:.0}lm/W S/H:{:.1}×{:.1}",
1420            self.cie_flux_codes,
1421            self.beam_angle,
1422            self.field_angle,
1423            self.luminaire_efficacy,
1424            self.spacing_c0,
1425            self.spacing_c90,
1426        )
1427    }
1428
1429    /// Format as key-value pairs for machine parsing.
1430    pub fn to_key_value(&self) -> Vec<(&'static str, String)> {
1431        vec![
1432            ("total_lamp_flux_lm", format!("{:.1}", self.total_lamp_flux)),
1433            ("calculated_flux_lm", format!("{:.1}", self.calculated_flux)),
1434            ("lor_percent", format!("{:.1}", self.lor)),
1435            ("dlor_percent", format!("{:.1}", self.dlor)),
1436            ("ulor_percent", format!("{:.1}", self.ulor)),
1437            ("lamp_efficacy_lm_w", format!("{:.1}", self.lamp_efficacy)),
1438            (
1439                "luminaire_efficacy_lm_w",
1440                format!("{:.1}", self.luminaire_efficacy),
1441            ),
1442            ("total_wattage_w", format!("{:.1}", self.total_wattage)),
1443            ("cie_flux_code", self.cie_flux_codes.to_string()),
1444            ("cie_n1", format!("{:.1}", self.cie_flux_codes.n1)),
1445            ("cie_n2", format!("{:.1}", self.cie_flux_codes.n2)),
1446            ("cie_n3", format!("{:.1}", self.cie_flux_codes.n3)),
1447            ("cie_n4", format!("{:.1}", self.cie_flux_codes.n4)),
1448            ("cie_n5", format!("{:.1}", self.cie_flux_codes.n5)),
1449            ("beam_angle_deg", format!("{:.1}", self.beam_angle)),
1450            ("field_angle_deg", format!("{:.1}", self.field_angle)),
1451            ("max_intensity_cd_klm", format!("{:.1}", self.max_intensity)),
1452            ("min_intensity_cd_klm", format!("{:.1}", self.min_intensity)),
1453            ("avg_intensity_cd_klm", format!("{:.1}", self.avg_intensity)),
1454            ("spacing_c0", format!("{:.2}", self.spacing_c0)),
1455            ("spacing_c90", format!("{:.2}", self.spacing_c90)),
1456            (
1457                "zonal_0_30_percent",
1458                format!("{:.1}", self.zonal_lumens.zone_0_30),
1459            ),
1460            (
1461                "zonal_30_60_percent",
1462                format!("{:.1}", self.zonal_lumens.zone_30_60),
1463            ),
1464            (
1465                "zonal_60_90_percent",
1466                format!("{:.1}", self.zonal_lumens.zone_60_90),
1467            ),
1468            (
1469                "zonal_90_120_percent",
1470                format!("{:.1}", self.zonal_lumens.zone_90_120),
1471            ),
1472            (
1473                "zonal_120_150_percent",
1474                format!("{:.1}", self.zonal_lumens.zone_120_150),
1475            ),
1476            (
1477                "zonal_150_180_percent",
1478                format!("{:.1}", self.zonal_lumens.zone_150_180),
1479            ),
1480        ]
1481    }
1482}
1483
1484impl std::fmt::Display for PhotometricSummary {
1485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1486        write!(f, "{}", self.to_text())
1487    }
1488}
1489
1490// ============================================================================
1491// GLDF-Compatible Photometric Data
1492// ============================================================================
1493
1494/// GLDF-compatible photometric data export.
1495///
1496/// Contains all properties required by the GLDF (Global Lighting Data Format)
1497/// specification for photometric data exchange.
1498#[derive(Debug, Clone, Default)]
1499pub struct GldfPhotometricData {
1500    /// CIE Flux Code (e.g., "45 72 95 100 100")
1501    pub cie_flux_code: String,
1502    /// Light Output Ratio - total efficiency (%)
1503    pub light_output_ratio: f64,
1504    /// Luminous efficacy (lm/W)
1505    pub luminous_efficacy: f64,
1506    /// Downward Flux Fraction (%)
1507    pub downward_flux_fraction: f64,
1508    /// Downward Light Output Ratio (%)
1509    pub downward_light_output_ratio: f64,
1510    /// Upward Light Output Ratio (%)
1511    pub upward_light_output_ratio: f64,
1512    /// Luminaire luminance (cd/m²) - average luminance at 65° viewing angle
1513    pub luminaire_luminance: f64,
1514    /// Cut-off angle - angle where intensity drops below 2.5% (degrees)
1515    pub cut_off_angle: f64,
1516    /// UGR table values for standard room (4H/8H, 0.70/0.50/0.20)
1517    pub ugr_4h_8h_705020: Option<UgrTableValues>,
1518    /// Photometric classification code
1519    pub photometric_code: String,
1520    /// Tenth peak (field) divergence angles (C0-C180, C90-C270) in degrees
1521    pub tenth_peak_divergence: (f64, f64),
1522    /// Half peak (beam) divergence angles (C0-C180, C90-C270) in degrees
1523    pub half_peak_divergence: (f64, f64),
1524    /// BUG rating (Backlight, Uplight, Glare)
1525    pub light_distribution_bug_rating: String,
1526}
1527
1528/// UGR table values for GLDF export
1529#[derive(Debug, Clone, Default)]
1530pub struct UgrTableValues {
1531    /// UGR crosswise (C90) looking direction
1532    pub crosswise: f64,
1533    /// UGR endwise (C0) looking direction
1534    pub endwise: f64,
1535}
1536
1537/// IES LM-63-19 specific metadata for GLDF integration.
1538///
1539/// Contains information from IES files that doesn't map directly to GLDF
1540/// `DescriptivePhotometry` but is valuable for data provenance, validation,
1541/// and geometry definition.
1542#[derive(Debug, Clone, Default)]
1543pub struct IesMetadata {
1544    /// IES file format version
1545    pub version: String,
1546    /// Test report number (`[TEST]` keyword)
1547    pub test_report: String,
1548    /// Photometric testing laboratory (`[TESTLAB]`)
1549    pub test_lab: String,
1550    /// Date manufacturer issued the file (`[ISSUEDATE]`)
1551    pub issue_date: String,
1552    /// Manufacturer of luminaire (`[MANUFAC]`)
1553    pub manufacturer: String,
1554    /// Luminaire catalog number (`[LUMCAT]`)
1555    pub luminaire_catalog: String,
1556    /// Lamp catalog number (`[LAMPCAT]`)
1557    pub lamp_catalog: String,
1558    /// Ballast description (`[BALLAST]`)
1559    pub ballast: String,
1560
1561    /// File generation type (LM-63-19 Section 5.13)
1562    pub file_generation_type: String,
1563    /// Whether data is from an accredited test lab
1564    pub is_accredited: bool,
1565    /// Whether luminous flux values were scaled
1566    pub is_scaled: bool,
1567    /// Whether angle values were interpolated
1568    pub is_interpolated: bool,
1569    /// Whether data is from computer simulation
1570    pub is_simulation: bool,
1571
1572    /// Luminous opening shape (LM-63-19 Table 1)
1573    pub luminous_shape: String,
1574    /// Luminous opening width in meters (absolute value, negative in IES = curved)
1575    pub luminous_width: f64,
1576    /// Luminous opening length in meters
1577    pub luminous_length: f64,
1578    /// Luminous opening height in meters
1579    pub luminous_height: f64,
1580    /// Whether the shape is rectangular (positive dims) or curved (negative dims)
1581    pub is_rectangular: bool,
1582    /// Whether the shape is circular (|width| == |length|, both negative)
1583    pub is_circular: bool,
1584
1585    /// Photometric type (A/B/C)
1586    pub photometric_type: String,
1587    /// Unit type (Feet/Meters)
1588    pub unit_type: String,
1589
1590    /// TILT information present
1591    pub has_tilt_data: bool,
1592    /// Lamp geometry (1-3) if TILT data present
1593    pub tilt_lamp_geometry: i32,
1594    /// Number of TILT angle/factor pairs
1595    pub tilt_angle_count: usize,
1596
1597    /// IES maintenance category (1-6)
1598    pub maintenance_category: Option<i32>,
1599    /// Ballast factor
1600    pub ballast_factor: f64,
1601    /// Input watts
1602    pub input_watts: f64,
1603    /// Number of lamps
1604    pub num_lamps: i32,
1605    /// Lumens per lamp (-1 = absolute photometry)
1606    pub lumens_per_lamp: f64,
1607    /// Is this absolute photometry (lumens = -1)?
1608    pub is_absolute_photometry: bool,
1609}
1610
1611impl IesMetadata {
1612    /// Create IesMetadata from parsed IesData.
1613    pub fn from_ies_data(ies: &crate::ies::IesData) -> Self {
1614        use crate::ies::{FileGenerationType, LuminousShape, PhotometricType, UnitType};
1615
1616        let shape = &ies.luminous_shape;
1617        let gen_type = &ies.file_generation_type;
1618
1619        Self {
1620            version: ies.version.header().to_string(),
1621            test_report: ies.test.clone(),
1622            test_lab: ies.test_lab.clone(),
1623            issue_date: ies.issue_date.clone(),
1624            manufacturer: ies.manufacturer.clone(),
1625            luminaire_catalog: ies.luminaire_catalog.clone(),
1626            lamp_catalog: ies.lamp_catalog.clone(),
1627            ballast: ies.ballast.clone(),
1628
1629            file_generation_type: gen_type.title().to_string(),
1630            is_accredited: gen_type.is_accredited(),
1631            is_scaled: gen_type.is_scaled(),
1632            is_interpolated: gen_type.is_interpolated(),
1633            is_simulation: matches!(gen_type, FileGenerationType::ComputerSimulation),
1634
1635            luminous_shape: shape.description().to_string(),
1636            luminous_width: ies.width.abs(),
1637            luminous_length: ies.length.abs(),
1638            luminous_height: ies.height.abs(),
1639            is_rectangular: matches!(
1640                shape,
1641                LuminousShape::Rectangular | LuminousShape::RectangularWithSides
1642            ),
1643            is_circular: matches!(
1644                shape,
1645                LuminousShape::Circular | LuminousShape::Sphere | LuminousShape::VerticalCylinder
1646            ),
1647
1648            photometric_type: match ies.photometric_type {
1649                PhotometricType::TypeC => "C".to_string(),
1650                PhotometricType::TypeB => "B".to_string(),
1651                PhotometricType::TypeA => "A".to_string(),
1652            },
1653            unit_type: match ies.unit_type {
1654                UnitType::Feet => "Feet".to_string(),
1655                UnitType::Meters => "Meters".to_string(),
1656            },
1657
1658            has_tilt_data: ies.tilt_data.is_some(),
1659            tilt_lamp_geometry: ies.tilt_data.as_ref().map_or(0, |t| t.lamp_geometry),
1660            tilt_angle_count: ies.tilt_data.as_ref().map_or(0, |t| t.angles.len()),
1661
1662            maintenance_category: ies.maintenance_category,
1663            ballast_factor: ies.ballast_factor,
1664            input_watts: ies.input_watts,
1665            num_lamps: ies.num_lamps,
1666            lumens_per_lamp: ies.lumens_per_lamp,
1667            is_absolute_photometry: ies.lumens_per_lamp < 0.0,
1668        }
1669    }
1670
1671    /// Export as key-value pairs for GLDF integration.
1672    pub fn to_gldf_properties(&self) -> Vec<(&'static str, String)> {
1673        let mut props = vec![];
1674
1675        if !self.version.is_empty() {
1676            props.push(("ies_version", self.version.clone()));
1677        }
1678        if !self.test_report.is_empty() {
1679            props.push(("test_report", self.test_report.clone()));
1680        }
1681        if !self.test_lab.is_empty() {
1682            props.push(("test_lab", self.test_lab.clone()));
1683        }
1684        if !self.issue_date.is_empty() {
1685            props.push(("issue_date", self.issue_date.clone()));
1686        }
1687        if !self.manufacturer.is_empty() {
1688            props.push(("manufacturer", self.manufacturer.clone()));
1689        }
1690        if !self.luminaire_catalog.is_empty() {
1691            props.push(("luminaire_catalog", self.luminaire_catalog.clone()));
1692        }
1693
1694        props.push(("file_generation_type", self.file_generation_type.clone()));
1695        props.push(("is_accredited", self.is_accredited.to_string()));
1696        props.push(("is_scaled", self.is_scaled.to_string()));
1697        props.push(("is_interpolated", self.is_interpolated.to_string()));
1698        props.push(("is_simulation", self.is_simulation.to_string()));
1699
1700        props.push(("luminous_shape", self.luminous_shape.clone()));
1701        if self.luminous_width > 0.0 {
1702            props.push(("luminous_width_m", format!("{:.4}", self.luminous_width)));
1703        }
1704        if self.luminous_length > 0.0 {
1705            props.push(("luminous_length_m", format!("{:.4}", self.luminous_length)));
1706        }
1707        if self.luminous_height > 0.0 {
1708            props.push(("luminous_height_m", format!("{:.4}", self.luminous_height)));
1709        }
1710
1711        props.push(("photometric_type", self.photometric_type.clone()));
1712
1713        if self.has_tilt_data {
1714            props.push(("has_tilt_data", "true".to_string()));
1715            props.push(("tilt_lamp_geometry", self.tilt_lamp_geometry.to_string()));
1716            props.push(("tilt_angle_count", self.tilt_angle_count.to_string()));
1717        }
1718
1719        if let Some(cat) = self.maintenance_category {
1720            props.push(("maintenance_category", cat.to_string()));
1721        }
1722
1723        if self.ballast_factor != 1.0 {
1724            props.push(("ballast_factor", format!("{:.3}", self.ballast_factor)));
1725        }
1726
1727        props.push(("input_watts", format!("{:.1}", self.input_watts)));
1728        props.push(("num_lamps", self.num_lamps.to_string()));
1729
1730        if self.is_absolute_photometry {
1731            props.push(("absolute_photometry", "true".to_string()));
1732        } else {
1733            props.push(("lumens_per_lamp", format!("{:.1}", self.lumens_per_lamp)));
1734        }
1735
1736        props
1737    }
1738
1739    /// Get GLDF-compatible emitter geometry info.
1740    ///
1741    /// Returns (shape_type, width_mm, length_mm, diameter_mm) for GLDF SimpleGeometry.
1742    pub fn to_gldf_emitter_geometry(&self) -> (&'static str, i32, i32, i32) {
1743        let width_mm = (self.luminous_width * 1000.0).round() as i32;
1744        let length_mm = (self.luminous_length * 1000.0).round() as i32;
1745        let diameter_mm = width_mm.max(length_mm);
1746
1747        if self.is_circular {
1748            ("circular", 0, 0, diameter_mm)
1749        } else if self.is_rectangular {
1750            ("rectangular", width_mm, length_mm, 0)
1751        } else {
1752            ("point", 0, 0, 0)
1753        }
1754    }
1755}
1756
1757impl GldfPhotometricData {
1758    /// Calculate GLDF-compatible photometric data from Eulumdat.
1759    pub fn from_eulumdat(ldt: &Eulumdat) -> Self {
1760        let cie_codes = PhotometricCalculations::cie_flux_codes(ldt);
1761        let bug = crate::bug_rating::BugRating::from_eulumdat(ldt);
1762
1763        // Calculate beam/field angles for both planes
1764        let beam_c0 = PhotometricCalculations::beam_angle_for_plane(ldt, 0.0);
1765        let beam_c90 = PhotometricCalculations::beam_angle_for_plane(ldt, 90.0);
1766        let field_c0 = PhotometricCalculations::field_angle_for_plane(ldt, 0.0);
1767        let field_c90 = PhotometricCalculations::field_angle_for_plane(ldt, 90.0);
1768
1769        // Calculate luminaire luminance at 65° (standard viewing angle)
1770        let luminance = PhotometricCalculations::luminaire_luminance(ldt, 65.0);
1771
1772        // Calculate cut-off angle (where intensity < 2.5% of max)
1773        let cut_off = PhotometricCalculations::cut_off_angle(ldt);
1774
1775        // Calculate UGR for standard room configuration
1776        let ugr_values = PhotometricCalculations::ugr_table_values(ldt);
1777
1778        // Generate photometric classification code
1779        let photo_code = PhotometricCalculations::photometric_code(ldt);
1780
1781        Self {
1782            cie_flux_code: cie_codes.to_string(),
1783            light_output_ratio: ldt.light_output_ratio,
1784            luminous_efficacy: PhotometricCalculations::luminaire_efficacy(ldt),
1785            downward_flux_fraction: ldt.downward_flux_fraction,
1786            downward_light_output_ratio: cie_codes.n1 * ldt.light_output_ratio / 100.0,
1787            upward_light_output_ratio: cie_codes.n4 * ldt.light_output_ratio / 100.0,
1788            luminaire_luminance: luminance,
1789            cut_off_angle: cut_off,
1790            ugr_4h_8h_705020: ugr_values,
1791            photometric_code: photo_code,
1792            tenth_peak_divergence: (field_c0, field_c90),
1793            half_peak_divergence: (beam_c0, beam_c90),
1794            light_distribution_bug_rating: format!("{}", bug),
1795        }
1796    }
1797
1798    /// Export as key-value pairs for GLDF XML generation.
1799    pub fn to_gldf_properties(&self) -> Vec<(&'static str, String)> {
1800        let mut props = vec![
1801            ("cie_flux_code", self.cie_flux_code.clone()),
1802            (
1803                "light_output_ratio",
1804                format!("{:.1}", self.light_output_ratio),
1805            ),
1806            (
1807                "luminous_efficacy",
1808                format!("{:.1}", self.luminous_efficacy),
1809            ),
1810            (
1811                "downward_flux_fraction",
1812                format!("{:.1}", self.downward_flux_fraction),
1813            ),
1814            (
1815                "downward_light_output_ratio",
1816                format!("{:.1}", self.downward_light_output_ratio),
1817            ),
1818            (
1819                "upward_light_output_ratio",
1820                format!("{:.1}", self.upward_light_output_ratio),
1821            ),
1822            (
1823                "luminaire_luminance",
1824                format!("{:.0}", self.luminaire_luminance),
1825            ),
1826            ("cut_off_angle", format!("{:.1}", self.cut_off_angle)),
1827            ("photometric_code", self.photometric_code.clone()),
1828            (
1829                "tenth_peak_divergence",
1830                format!(
1831                    "{:.1} / {:.1}",
1832                    self.tenth_peak_divergence.0, self.tenth_peak_divergence.1
1833                ),
1834            ),
1835            (
1836                "half_peak_divergence",
1837                format!(
1838                    "{:.1} / {:.1}",
1839                    self.half_peak_divergence.0, self.half_peak_divergence.1
1840                ),
1841            ),
1842            (
1843                "light_distribution_bug_rating",
1844                self.light_distribution_bug_rating.clone(),
1845            ),
1846        ];
1847
1848        if let Some(ref ugr) = self.ugr_4h_8h_705020 {
1849            props.push((
1850                "ugr_4h_8h_705020_crosswise",
1851                format!("{:.1}", ugr.crosswise),
1852            ));
1853            props.push(("ugr_4h_8h_705020_endwise", format!("{:.1}", ugr.endwise)));
1854        }
1855
1856        props
1857    }
1858
1859    /// Export as formatted text report.
1860    pub fn to_text(&self) -> String {
1861        let mut s = String::from("GLDF PHOTOMETRIC DATA\n");
1862        s.push_str("=====================\n\n");
1863
1864        s.push_str(&format!(
1865            "CIE Flux Code:           {}\n",
1866            self.cie_flux_code
1867        ));
1868        s.push_str(&format!(
1869            "Light Output Ratio:      {:.1}%\n",
1870            self.light_output_ratio
1871        ));
1872        s.push_str(&format!(
1873            "Luminous Efficacy:       {:.1} lm/W\n",
1874            self.luminous_efficacy
1875        ));
1876        s.push_str(&format!(
1877            "Downward Flux Fraction:  {:.1}%\n",
1878            self.downward_flux_fraction
1879        ));
1880        s.push_str(&format!(
1881            "DLOR:                    {:.1}%\n",
1882            self.downward_light_output_ratio
1883        ));
1884        s.push_str(&format!(
1885            "ULOR:                    {:.1}%\n",
1886            self.upward_light_output_ratio
1887        ));
1888        s.push_str(&format!(
1889            "Luminaire Luminance:     {:.0} cd/m²\n",
1890            self.luminaire_luminance
1891        ));
1892        s.push_str(&format!(
1893            "Cut-off Angle:           {:.1}°\n",
1894            self.cut_off_angle
1895        ));
1896
1897        if let Some(ref ugr) = self.ugr_4h_8h_705020 {
1898            s.push_str(&format!(
1899                "UGR (4H×8H, 70/50/20):   C: {:.1} / E: {:.1}\n",
1900                ugr.crosswise, ugr.endwise
1901            ));
1902        }
1903
1904        s.push_str(&format!(
1905            "Photometric Code:        {}\n",
1906            self.photometric_code
1907        ));
1908        s.push_str(&format!(
1909            "Half Peak Divergence:    {:.1}° / {:.1}° (C0/C90)\n",
1910            self.half_peak_divergence.0, self.half_peak_divergence.1
1911        ));
1912        s.push_str(&format!(
1913            "Tenth Peak Divergence:   {:.1}° / {:.1}° (C0/C90)\n",
1914            self.tenth_peak_divergence.0, self.tenth_peak_divergence.1
1915        ));
1916        s.push_str(&format!(
1917            "BUG Rating:              {}\n",
1918            self.light_distribution_bug_rating
1919        ));
1920
1921        s
1922    }
1923}
1924
1925impl std::fmt::Display for GldfPhotometricData {
1926    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1927        write!(f, "{}", self.to_text())
1928    }
1929}
1930
1931// ============================================================================
1932// Additional GLDF-related Calculations
1933// ============================================================================
1934
1935impl PhotometricCalculations {
1936    /// Calculate beam angle (50% intensity) for a specific C-plane.
1937    ///
1938    /// Returns the **full angle** per CIE S 017:2020 definition.
1939    pub fn beam_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
1940        // Return full angle (2× half angle) per CIE definition
1941        Self::angle_at_percentage_for_plane(ldt, c_plane, 0.5) * 2.0
1942    }
1943
1944    /// Calculate field angle (10% intensity) for a specific C-plane.
1945    ///
1946    /// Returns the **full angle** per CIE S 017:2020 definition.
1947    pub fn field_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
1948        // Return full angle (2× half angle) per CIE definition
1949        Self::angle_at_percentage_for_plane(ldt, c_plane, 0.1) * 2.0
1950    }
1951
1952    /// Calculate half beam angle for a specific C-plane.
1953    ///
1954    /// Returns the half angle from nadir to 50% intensity point.
1955    pub fn half_beam_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
1956        Self::angle_at_percentage_for_plane(ldt, c_plane, 0.5)
1957    }
1958
1959    /// Calculate half field angle for a specific C-plane.
1960    ///
1961    /// Returns the half angle from nadir to 10% intensity point.
1962    pub fn half_field_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
1963        Self::angle_at_percentage_for_plane(ldt, c_plane, 0.1)
1964    }
1965
1966    /// Find the angle at which intensity drops to a percentage for a specific plane.
1967    fn angle_at_percentage_for_plane(ldt: &Eulumdat, c_plane: f64, percentage: f64) -> f64 {
1968        if ldt.g_angles.is_empty() {
1969            return 0.0;
1970        }
1971
1972        let i_nadir = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, 0.0);
1973        if i_nadir <= 0.0 {
1974            return 0.0;
1975        }
1976
1977        let threshold = i_nadir * percentage;
1978
1979        for g in 1..90 {
1980            let intensity =
1981                crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, g as f64);
1982            if intensity < threshold {
1983                // Interpolate
1984                let prev = crate::symmetry::SymmetryHandler::get_intensity_at(
1985                    ldt,
1986                    c_plane,
1987                    (g - 1) as f64,
1988                );
1989                if prev > threshold && prev > intensity {
1990                    let ratio = (prev - threshold) / (prev - intensity);
1991                    return (g - 1) as f64 + ratio;
1992                }
1993                return g as f64;
1994            }
1995        }
1996
1997        90.0
1998    }
1999
2000    /// Calculate luminaire luminance at a given viewing angle (cd/m²).
2001    ///
2002    /// L = I / A_projected
2003    /// Where A_projected is the luminous area projected in viewing direction.
2004    pub fn luminaire_luminance(ldt: &Eulumdat, viewing_angle: f64) -> f64 {
2005        // Get luminous area in m²
2006        let la_length = ldt.luminous_area_length / 1000.0;
2007        let la_width = ldt.luminous_area_width / 1000.0;
2008
2009        if la_length <= 0.0 || la_width <= 0.0 {
2010            return 0.0;
2011        }
2012
2013        let area = la_length * la_width;
2014
2015        // Projected area at viewing angle
2016        let angle_rad = viewing_angle.to_radians();
2017        let projected_area = area * angle_rad.cos();
2018
2019        if projected_area <= 0.001 {
2020            return 0.0;
2021        }
2022
2023        // Average intensity at this angle across planes
2024        let i_c0 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, viewing_angle);
2025        let i_c90 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 90.0, viewing_angle);
2026        let avg_intensity = (i_c0 + i_c90) / 2.0;
2027
2028        // Convert from cd/klm to actual cd using total flux
2029        let total_flux = ldt.total_luminous_flux();
2030        let actual_intensity = avg_intensity * total_flux / 1000.0;
2031
2032        // Luminance = I / A
2033        actual_intensity / projected_area
2034    }
2035
2036    /// Calculate cut-off angle (where intensity drops below 2.5% of maximum).
2037    pub fn cut_off_angle(ldt: &Eulumdat) -> f64 {
2038        let max_intensity = ldt.max_intensity();
2039        if max_intensity <= 0.0 {
2040            return 90.0;
2041        }
2042
2043        let threshold = max_intensity * 0.025;
2044
2045        // Search from 90° downward to find where intensity first exceeds threshold
2046        for g in (0..=90).rev() {
2047            let i_c0 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, g as f64);
2048            let i_c90 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 90.0, g as f64);
2049
2050            if i_c0 > threshold || i_c90 > threshold {
2051                return g as f64;
2052            }
2053        }
2054
2055        0.0
2056    }
2057
2058    /// Calculate UGR table values for standard room configuration.
2059    ///
2060    /// Standard configuration: 4H×8H room, reflectances 0.70/0.50/0.20
2061    pub fn ugr_table_values(ldt: &Eulumdat) -> Option<UgrTableValues> {
2062        let luminaire_area = (ldt.length / 1000.0) * (ldt.width / 1000.0);
2063        if luminaire_area <= 0.0 {
2064            return None;
2065        }
2066
2067        // Standard room: 4H wide × 8H long, where H is mounting height
2068        // Typical mounting height for calculation: 2.5m
2069        let h = 2.5;
2070        let room_width = 4.0 * h; // 10m
2071        let room_length = 8.0 * h; // 20m
2072
2073        // Standard reflectances
2074        let rho_c = 0.70;
2075        let rho_w = 0.50;
2076        let rho_f = 0.20;
2077
2078        // Calculate for crosswise (looking along C90 direction)
2079        let params_cross = UgrParams {
2080            room_length,
2081            room_width,
2082            mounting_height: 2.8,
2083            eye_height: 1.2,
2084            observer_x: room_length / 2.0,
2085            observer_y: 1.5, // Near wall, looking across
2086            luminaire_positions: vec![
2087                (room_length / 4.0, room_width / 2.0),
2088                (room_length / 2.0, room_width / 2.0),
2089                (3.0 * room_length / 4.0, room_width / 2.0),
2090            ],
2091            rho_ceiling: rho_c,
2092            rho_wall: rho_w,
2093            rho_floor: rho_f,
2094            illuminance: 500.0,
2095        };
2096
2097        // Calculate for endwise (looking along C0 direction)
2098        let params_end = UgrParams {
2099            room_length,
2100            room_width,
2101            mounting_height: 2.8,
2102            eye_height: 1.2,
2103            observer_x: 1.5, // Near wall, looking along
2104            observer_y: room_width / 2.0,
2105            luminaire_positions: vec![
2106                (room_length / 4.0, room_width / 2.0),
2107                (room_length / 2.0, room_width / 2.0),
2108                (3.0 * room_length / 4.0, room_width / 2.0),
2109            ],
2110            rho_ceiling: rho_c,
2111            rho_wall: rho_w,
2112            rho_floor: rho_f,
2113            illuminance: 500.0,
2114        };
2115
2116        let ugr_cross = Self::ugr(ldt, &params_cross);
2117        let ugr_end = Self::ugr(ldt, &params_end);
2118
2119        // Only return if we got valid values
2120        if ugr_cross > 0.0 || ugr_end > 0.0 {
2121            Some(UgrTableValues {
2122                crosswise: ugr_cross.max(0.0),
2123                endwise: ugr_end.max(0.0),
2124            })
2125        } else {
2126            None
2127        }
2128    }
2129
2130    /// Generate photometric classification code.
2131    ///
2132    /// Format: D/I where:
2133    /// - D = Distribution type (1=direct, 2=semi-direct, 3=general diffuse, 4=semi-indirect, 5=indirect)
2134    /// - I = Intensity class based on max intensity
2135    pub fn photometric_code(ldt: &Eulumdat) -> String {
2136        let dlor = ldt.downward_flux_fraction;
2137
2138        // Distribution type based on downward flux fraction
2139        let dist_type = if dlor >= 90.0 {
2140            "D" // Direct
2141        } else if dlor >= 60.0 {
2142            "SD" // Semi-direct
2143        } else if dlor >= 40.0 {
2144            "GD" // General diffuse
2145        } else if dlor >= 10.0 {
2146            "SI" // Semi-indirect
2147        } else {
2148            "I" // Indirect
2149        };
2150
2151        // Beam classification (using full angle per CIE S 017:2020)
2152        let beam_angle = Self::beam_angle(ldt);
2153        let beam_class = if beam_angle < 40.0 {
2154            "VN" // Very narrow (< 20° half angle)
2155        } else if beam_angle < 60.0 {
2156            "N" // Narrow (20-30° half angle)
2157        } else if beam_angle < 90.0 {
2158            "M" // Medium (30-45° half angle)
2159        } else if beam_angle < 120.0 {
2160            "W" // Wide (45-60° half angle)
2161        } else {
2162            "VW" // Very wide (> 60° half angle)
2163        };
2164
2165        format!("{}-{}", dist_type, beam_class)
2166    }
2167}
2168
2169// ============================================================================
2170// Supporting Types
2171// ============================================================================
2172
2173/// Comprehensive beam and field angle analysis.
2174///
2175/// Compares IES (maximum intensity based) and CIE (center-beam intensity based)
2176/// definitions of beam angle and field angle. This is important for luminaires
2177/// with non-standard distributions like "batwing" patterns.
2178///
2179/// See Wikipedia "Beam angle" article for the distinction between these definitions.
2180#[derive(Debug, Clone, Copy, Default, PartialEq)]
2181pub struct BeamFieldAnalysis {
2182    // IES definition (based on maximum intensity)
2183    /// Beam angle using IES definition (50% of max intensity) in degrees
2184    pub beam_angle_ies: f64,
2185    /// Field angle using IES definition (10% of max intensity) in degrees
2186    pub field_angle_ies: f64,
2187
2188    // CIE/NEMA definition (based on center-beam intensity)
2189    /// Beam angle using CIE definition (50% of center-beam intensity) in degrees
2190    pub beam_angle_cie: f64,
2191    /// Field angle using CIE definition (10% of center-beam intensity) in degrees
2192    pub field_angle_cie: f64,
2193
2194    // Reference intensity values
2195    /// Maximum intensity anywhere in the distribution (cd/klm)
2196    pub max_intensity: f64,
2197    /// Center-beam intensity at nadir/0° gamma (cd/klm)
2198    pub center_intensity: f64,
2199    /// Gamma angle at which maximum intensity occurs (degrees)
2200    pub max_intensity_gamma: f64,
2201
2202    // Distribution type
2203    /// True if this is a "batwing" distribution (center < max)
2204    pub is_batwing: bool,
2205
2206    // Threshold values for diagram overlays
2207    /// 50% of max intensity (IES beam threshold)
2208    pub beam_threshold_ies: f64,
2209    /// 50% of center intensity (CIE beam threshold)
2210    pub beam_threshold_cie: f64,
2211    /// 10% of max intensity (IES field threshold)
2212    pub field_threshold_ies: f64,
2213    /// 10% of center intensity (CIE field threshold)
2214    pub field_threshold_cie: f64,
2215}
2216
2217impl BeamFieldAnalysis {
2218    /// Returns the difference between CIE and IES beam angles.
2219    ///
2220    /// A large positive value indicates a batwing distribution where
2221    /// the CIE definition gives a wider beam angle.
2222    pub fn beam_angle_difference(&self) -> f64 {
2223        self.beam_angle_cie - self.beam_angle_ies
2224    }
2225
2226    /// Returns the difference between CIE and IES field angles.
2227    pub fn field_angle_difference(&self) -> f64 {
2228        self.field_angle_cie - self.field_angle_ies
2229    }
2230
2231    /// Returns the ratio of center intensity to max intensity.
2232    ///
2233    /// A value less than 1.0 indicates a batwing or off-axis peak distribution.
2234    pub fn center_to_max_ratio(&self) -> f64 {
2235        if self.max_intensity > 0.0 {
2236            self.center_intensity / self.max_intensity
2237        } else {
2238            0.0
2239        }
2240    }
2241
2242    /// Get descriptive classification of the distribution type.
2243    pub fn distribution_type(&self) -> &'static str {
2244        let ratio = self.center_to_max_ratio();
2245        if ratio >= 0.95 {
2246            "Standard (center-peak)"
2247        } else if ratio >= 0.7 {
2248            "Mild batwing"
2249        } else if ratio >= 0.4 {
2250            "Moderate batwing"
2251        } else {
2252            "Strong batwing"
2253        }
2254    }
2255
2256    /// Format for display with both IES and CIE values.
2257    pub fn to_string_detailed(&self) -> String {
2258        format!(
2259            "Beam: IES {:.1}° / CIE {:.1}° (Δ{:+.1}°)\n\
2260             Field: IES {:.1}° / CIE {:.1}° (Δ{:+.1}°)\n\
2261             Center/Max: {:.1}% ({})",
2262            self.beam_angle_ies,
2263            self.beam_angle_cie,
2264            self.beam_angle_difference(),
2265            self.field_angle_ies,
2266            self.field_angle_cie,
2267            self.field_angle_difference(),
2268            self.center_to_max_ratio() * 100.0,
2269            self.distribution_type()
2270        )
2271    }
2272}
2273
2274impl std::fmt::Display for BeamFieldAnalysis {
2275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2276        write!(
2277            f,
2278            "Beam: {:.1}° (IES) / {:.1}° (CIE), Field: {:.1}° (IES) / {:.1}° (CIE)",
2279            self.beam_angle_ies, self.beam_angle_cie, self.field_angle_ies, self.field_angle_cie
2280        )
2281    }
2282}
2283
2284/// Primary direction of light output
2285#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2286#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2287pub enum LightDirection {
2288    /// Most light directed downward (gamma 0-90°)
2289    #[default]
2290    Downward,
2291    /// Most light directed upward (gamma 90-180°)
2292    Upward,
2293}
2294
2295impl std::fmt::Display for LightDirection {
2296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2297        match self {
2298            LightDirection::Downward => write!(f, "Downward"),
2299            LightDirection::Upward => write!(f, "Upward"),
2300        }
2301    }
2302}
2303
2304/// Light distribution classification
2305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2306#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2307pub enum DistributionType {
2308    /// Direct lighting only (downward)
2309    #[default]
2310    Direct,
2311    /// Indirect lighting only (upward)
2312    Indirect,
2313    /// Primarily direct with some indirect component
2314    DirectIndirect,
2315    /// Primarily indirect with some direct component
2316    IndirectDirect,
2317}
2318
2319impl std::fmt::Display for DistributionType {
2320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2321        match self {
2322            DistributionType::Direct => write!(f, "Direct"),
2323            DistributionType::Indirect => write!(f, "Indirect"),
2324            DistributionType::DirectIndirect => write!(f, "Direct-Indirect"),
2325            DistributionType::IndirectDirect => write!(f, "Indirect-Direct"),
2326        }
2327    }
2328}
2329
2330/// Comprehensive beam angle analysis for both downward and upward light components.
2331///
2332/// This provides separate beam and field angles for each hemisphere, useful for
2333/// luminaires with light output in both directions (direct-indirect, uplights, etc.).
2334#[derive(Debug, Clone, Copy, Default, PartialEq)]
2335pub struct ComprehensiveBeamAnalysis {
2336    // Downward (lower hemisphere) measurements
2337    /// Beam angle for downward light (50% of downward peak) in degrees
2338    pub downward_beam_angle: f64,
2339    /// Field angle for downward light (10% of downward peak) in degrees
2340    pub downward_field_angle: f64,
2341    /// Peak intensity in lower hemisphere (cd/klm)
2342    pub downward_peak_intensity: f64,
2343    /// Gamma angle of peak intensity in lower hemisphere (degrees)
2344    pub downward_peak_gamma: f64,
2345
2346    // Upward (upper hemisphere) measurements
2347    /// Beam angle for upward light (50% of upward peak) in degrees
2348    pub upward_beam_angle: f64,
2349    /// Field angle for upward light (10% of upward peak) in degrees
2350    pub upward_field_angle: f64,
2351    /// Peak intensity in upper hemisphere (cd/klm)
2352    pub upward_peak_intensity: f64,
2353    /// Gamma angle of peak intensity in upper hemisphere (degrees)
2354    pub upward_peak_gamma: f64,
2355
2356    /// Primary light direction (based on higher peak intensity)
2357    pub primary_direction: LightDirection,
2358    /// Distribution type classification
2359    pub distribution_type: DistributionType,
2360}
2361
2362impl ComprehensiveBeamAnalysis {
2363    /// Returns true if there is significant upward light component
2364    pub fn has_upward_component(&self) -> bool {
2365        self.upward_peak_intensity > 0.0 && self.upward_beam_angle > 0.0
2366    }
2367
2368    /// Returns true if there is significant downward light component
2369    pub fn has_downward_component(&self) -> bool {
2370        self.downward_peak_intensity > 0.0 && self.downward_beam_angle > 0.0
2371    }
2372
2373    /// Returns the ratio of upward to downward peak intensity
2374    pub fn upward_to_downward_ratio(&self) -> f64 {
2375        if self.downward_peak_intensity > 0.0 {
2376            self.upward_peak_intensity / self.downward_peak_intensity
2377        } else if self.upward_peak_intensity > 0.0 {
2378            f64::INFINITY
2379        } else {
2380            0.0
2381        }
2382    }
2383
2384    /// Get the primary beam angle (from the dominant direction)
2385    pub fn primary_beam_angle(&self) -> f64 {
2386        match self.primary_direction {
2387            LightDirection::Downward => self.downward_beam_angle,
2388            LightDirection::Upward => self.upward_beam_angle,
2389        }
2390    }
2391
2392    /// Get the primary field angle (from the dominant direction)
2393    pub fn primary_field_angle(&self) -> f64 {
2394        match self.primary_direction {
2395            LightDirection::Downward => self.downward_field_angle,
2396            LightDirection::Upward => self.upward_field_angle,
2397        }
2398    }
2399}
2400
2401impl std::fmt::Display for ComprehensiveBeamAnalysis {
2402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2403        write!(
2404            f,
2405            "{} ({:.1}°/{:.1}°)",
2406            self.distribution_type,
2407            self.primary_beam_angle(),
2408            self.primary_field_angle()
2409        )?;
2410        if self.has_downward_component() && self.has_upward_component() {
2411            write!(
2412                f,
2413                " [Down: {:.1}°, Up: {:.1}°]",
2414                self.downward_beam_angle, self.upward_beam_angle
2415            )?;
2416        }
2417        Ok(())
2418    }
2419}
2420
2421/// CIE Flux Code values
2422#[derive(Debug, Clone, Copy, Default, PartialEq)]
2423#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2424pub struct CieFluxCodes {
2425    /// N1: % flux in lower hemisphere (0-90°) - equivalent to DLOR
2426    pub n1: f64,
2427    /// N2: % flux in 0-60° zone
2428    pub n2: f64,
2429    /// N3: % flux in 0-40° zone
2430    pub n3: f64,
2431    /// N4: % flux in upper hemisphere (90-180°) - equivalent to ULOR
2432    pub n4: f64,
2433    /// N5: % flux in 90-120° zone (near-horizontal uplight)
2434    pub n5: f64,
2435}
2436
2437impl std::fmt::Display for CieFluxCodes {
2438    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2439        write!(
2440            f,
2441            "{:.0} {:.0} {:.0} {:.0} {:.0}",
2442            self.n1.round(),
2443            self.n2.round(),
2444            self.n3.round(),
2445            self.n4.round(),
2446            self.n5.round()
2447        )
2448    }
2449}
2450
2451/// Zonal lumens in 30° zones
2452#[derive(Debug, Clone, Copy, Default, PartialEq)]
2453#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2454pub struct ZonalLumens30 {
2455    /// 0-30° zone (nadir to 30°)
2456    pub zone_0_30: f64,
2457    /// 30-60° zone
2458    pub zone_30_60: f64,
2459    /// 60-90° zone (approaching horizontal)
2460    pub zone_60_90: f64,
2461    /// 90-120° zone (above horizontal)
2462    pub zone_90_120: f64,
2463    /// 120-150° zone
2464    pub zone_120_150: f64,
2465    /// 150-180° zone (zenith region)
2466    pub zone_150_180: f64,
2467}
2468
2469impl ZonalLumens30 {
2470    /// Get total downward flux (0-90°)
2471    pub fn downward_total(&self) -> f64 {
2472        self.zone_0_30 + self.zone_30_60 + self.zone_60_90
2473    }
2474
2475    /// Get total upward flux (90-180°)
2476    pub fn upward_total(&self) -> f64 {
2477        self.zone_90_120 + self.zone_120_150 + self.zone_150_180
2478    }
2479}
2480
2481/// Parameters for UGR calculation
2482#[derive(Debug, Clone)]
2483pub struct UgrParams {
2484    /// Room length (m)
2485    pub room_length: f64,
2486    /// Room width (m)
2487    pub room_width: f64,
2488    /// Mounting height above floor (m)
2489    pub mounting_height: f64,
2490    /// Observer eye height (m), typically 1.2m seated, 1.7m standing
2491    pub eye_height: f64,
2492    /// Observer X position (m)
2493    pub observer_x: f64,
2494    /// Observer Y position (m)
2495    pub observer_y: f64,
2496    /// Luminaire positions as (x, y) tuples
2497    pub luminaire_positions: Vec<(f64, f64)>,
2498    /// Ceiling reflectance (0-1)
2499    pub rho_ceiling: f64,
2500    /// Wall reflectance (0-1)
2501    pub rho_wall: f64,
2502    /// Floor reflectance (0-1)
2503    pub rho_floor: f64,
2504    /// Target illuminance (lux)
2505    pub illuminance: f64,
2506}
2507
2508impl Default for UgrParams {
2509    fn default() -> Self {
2510        Self {
2511            room_length: 8.0,
2512            room_width: 4.0,
2513            mounting_height: 2.8,
2514            eye_height: 1.2,
2515            observer_x: 4.0,
2516            observer_y: 2.0,
2517            luminaire_positions: vec![(2.0, 2.0), (6.0, 2.0)],
2518            rho_ceiling: 0.7,
2519            rho_wall: 0.5,
2520            rho_floor: 0.2,
2521            illuminance: 500.0,
2522        }
2523    }
2524}
2525
2526impl UgrParams {
2527    /// Calculate background luminance from room parameters
2528    pub fn background_luminance(&self) -> f64 {
2529        // Lb = E × ρ_avg / π
2530        let avg_rho = (self.rho_ceiling + self.rho_wall + self.rho_floor) / 3.0;
2531        self.illuminance * avg_rho / PI
2532    }
2533
2534    /// Create params for a standard office room
2535    pub fn standard_office() -> Self {
2536        Self {
2537            room_length: 6.0,
2538            room_width: 4.0,
2539            mounting_height: 2.8,
2540            eye_height: 1.2,
2541            observer_x: 3.0,
2542            observer_y: 2.0,
2543            luminaire_positions: vec![(2.0, 2.0), (4.0, 2.0)],
2544            rho_ceiling: 0.7,
2545            rho_wall: 0.5,
2546            rho_floor: 0.2,
2547            illuminance: 500.0,
2548        }
2549    }
2550}
2551
2552// ============================================================================
2553// Coefficient of Utilization (CU) Table - IES Zonal Cavity Method
2554// ============================================================================
2555
2556/// Standard reflectance combinations for CU tables.
2557/// Format: (Ceiling%, Wall%, Floor%)
2558pub const CU_REFLECTANCES: [(u8, u8, u8); 18] = [
2559    // RC=80%
2560    (80, 70, 20),
2561    (80, 50, 20),
2562    (80, 30, 20),
2563    (80, 10, 20),
2564    // RC=70%
2565    (70, 70, 20),
2566    (70, 50, 20),
2567    (70, 30, 20),
2568    (70, 10, 20),
2569    // RC=50%
2570    (50, 50, 20),
2571    (50, 30, 20),
2572    (50, 10, 20),
2573    // RC=30%
2574    (30, 50, 20),
2575    (30, 30, 20),
2576    (30, 10, 20),
2577    // RC=10%
2578    (10, 50, 20),
2579    (10, 30, 20),
2580    (10, 10, 20),
2581    // RC=0%
2582    (0, 0, 20),
2583];
2584
2585/// Standard Room Cavity Ratios for CU tables.
2586pub const CU_RCR_VALUES: [u8; 11] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
2587
2588/// Coefficient of Utilization table.
2589///
2590/// Contains CU values (as percentages) for standard room cavity ratios
2591/// and reflectance combinations, following IES Zonal Cavity Method.
2592#[derive(Debug, Clone)]
2593pub struct CuTable {
2594    /// Effective floor cavity reflectance used
2595    pub floor_reflectance: f64,
2596    /// CU values indexed as \[rcr_index\]\[reflectance_index\]
2597    /// Values are percentages (0-100+)
2598    pub values: Vec<Vec<f64>>,
2599    /// Reflectance combinations (ceiling%, wall%, floor%)
2600    pub reflectances: Vec<(u8, u8, u8)>,
2601    /// Room cavity ratios
2602    pub rcr_values: Vec<u8>,
2603}
2604
2605impl Default for CuTable {
2606    fn default() -> Self {
2607        Self {
2608            floor_reflectance: 0.20,
2609            values: Vec::new(),
2610            reflectances: CU_REFLECTANCES.to_vec(),
2611            rcr_values: CU_RCR_VALUES.to_vec(),
2612        }
2613    }
2614}
2615
2616#[allow(dead_code)]
2617impl CuTable {
2618    /// Calculate CU table from Eulumdat data.
2619    pub fn calculate(ldt: &Eulumdat) -> Self {
2620        let mut table = Self::default();
2621
2622        // For each RCR
2623        for &rcr in &CU_RCR_VALUES {
2624            let mut row = Vec::new();
2625
2626            // For each reflectance combination
2627            for &(rc, rw, rf) in &CU_REFLECTANCES {
2628                let cu = Self::calculate_cu(
2629                    ldt,
2630                    rcr as f64,
2631                    rc as f64 / 100.0,
2632                    rw as f64 / 100.0,
2633                    rf as f64 / 100.0,
2634                );
2635                row.push(cu);
2636            }
2637
2638            table.values.push(row);
2639        }
2640
2641        table
2642    }
2643
2644    /// Calculate CU for specific conditions using zonal cavity method.
2645    ///
2646    /// This is the simple/fast version. Use `calculate_cu_ies` for IES-accurate values.
2647    fn calculate_cu(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2648        // Use the IES-accurate calculation by default
2649        Self::calculate_cu_ies(ldt, rcr, rho_c, rho_w, rho_f)
2650    }
2651
2652    /// Calculate CU using IES zonal cavity method (accurate version).
2653    ///
2654    /// Implements the full inter-reflection model per IES Lighting Handbook.
2655    /// CU can exceed 100% due to inter-reflections in high-reflectance rooms.
2656    ///
2657    /// Calibrated against Photometric Toolbox output to match within ~5%.
2658    fn calculate_cu_ies(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2659        // Step 1: Get luminaire flux fractions from zonal lumen data
2660        let downward_fraction = PhotometricCalculations::downward_flux(ldt, 90.0) / 100.0;
2661        let upward_fraction = 1.0 - downward_fraction;
2662
2663        // Step 2: Calculate direct ratio to work plane
2664        // At RCR=0 (infinite room), all downward light reaches floor directly
2665        // At higher RCR, some light hits walls first
2666        let direct_ratio = Self::calculate_direct_ratio_ies(ldt, rcr);
2667
2668        // Step 3: Base CU = direct light reaching work plane
2669        let cu_base = direct_ratio * downward_fraction;
2670
2671        // Step 4: Light hitting walls (doesn't reach floor directly)
2672        let wall_light = downward_fraction * (1.0 - direct_ratio);
2673
2674        // Step 5: Inter-reflection model
2675        // Calibrated based on Photometric Toolbox analysis:
2676        // - At RCR=0, RC=0: CU ≈ downward_fraction
2677        // - At RCR=0, RC=80: CU ≈ downward_fraction + 0.20 (20 point boost)
2678        // - Wall reflectance has minimal effect at RCR=0
2679
2680        // Ceiling contribution: floor sees ceiling, light bounces down
2681        // At RCR=0: ceiling is fully visible, maximum contribution
2682        // At high RCR: walls obstruct view of ceiling
2683        let ceiling_view_factor = 1.0 / (1.0 + rcr * 0.18);
2684
2685        // Light reaching ceiling: upward light + floor-reflected light
2686        let light_to_ceiling = upward_fraction + rho_f * downward_fraction * 0.5;
2687
2688        // Ceiling bounce efficiency (how much reaches floor)
2689        // At RCR=0 with rho_c=0.80: want ~0.20 contribution
2690        // 0.20 = 0.80 × efficiency × view_factor × light_to_ceiling
2691        // For this luminaire: light_to_ceiling ≈ 0.014 + 0.20×0.986×0.5 ≈ 0.11
2692        // So efficiency ≈ 0.20 / (0.80 × 1.0 × 0.11) ≈ 2.3
2693        // But that's too high - the inter-reflection includes multiple bounces
2694        let ceiling_efficiency = 0.25;
2695        let cu_ceiling = light_to_ceiling * rho_c * ceiling_efficiency * ceiling_view_factor;
2696
2697        // Wall contribution: significant at higher RCR
2698        let wall_view_factor = 1.0 - ceiling_view_factor;
2699        let cu_walls = (wall_light + upward_fraction * wall_view_factor) * rho_w * 0.35;
2700
2701        // Multiple inter-reflections (geometric series)
2702        // Average effective reflectance
2703        let rho_avg =
2704            rho_c * ceiling_view_factor * 0.4 + rho_f * 0.4 + rho_w * wall_view_factor * 0.2;
2705
2706        let first_order = cu_base + cu_ceiling + cu_walls;
2707
2708        // Multi-bounce adds: first_order × ρ / (1 - ρ) × floor_capture
2709        let floor_capture = 0.35 / (1.0 + rcr * 0.1);
2710        let cu_multi = if rho_avg < 0.9 {
2711            first_order * rho_avg / (1.0 - rho_avg) * floor_capture
2712        } else {
2713            first_order * 3.0 * floor_capture
2714        };
2715
2716        // Total CU as percentage
2717        let cu_total = (first_order + cu_multi) * 100.0;
2718
2719        // CU typically ranges from 20-130%
2720        cu_total.clamp(0.0, 150.0)
2721    }
2722
2723    /// Calculate direct ratio for work plane illumination.
2724    ///
2725    /// Based on the luminaire's intensity distribution and room cavity ratio.
2726    fn calculate_direct_ratio_ies(ldt: &Eulumdat, rcr: f64) -> f64 {
2727        // The direct ratio depends on how much of the luminaire's downward flux
2728        // actually reaches the work plane (vs hitting walls first)
2729
2730        // At RCR=0 (infinite room), all downward light reaches the floor: ratio = 1.0
2731        // At higher RCR, walls intercept more light
2732
2733        // Calculate the effective cutoff angle based on RCR
2734        // RCR = 5 × h × (L+W) / (L×W)
2735        // For a square room: RCR = 10 × h / L
2736        // The angle θ where light hits walls instead of floor: tan(θ) = L / (2h)
2737        // θ = atan(5 / RCR) for square room
2738
2739        let cutoff_angle = if rcr > 0.1 {
2740            (5.0 / rcr).atan().to_degrees()
2741        } else {
2742            89.0 // Nearly horizontal for very low RCR
2743        };
2744
2745        // Integrate luminaire flux from 0° to cutoff angle
2746        let flux_direct = PhotometricCalculations::downward_flux(ldt, cutoff_angle.min(90.0));
2747        let flux_total = PhotometricCalculations::downward_flux(ldt, 90.0);
2748
2749        if flux_total > 0.0 {
2750            flux_direct / flux_total
2751        } else {
2752            1.0
2753        }
2754    }
2755
2756    /// Calculate effective room reflectance for inter-reflection calculation.
2757    fn effective_room_reflectance(rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2758        // Weight reflectances by approximate surface area ratios
2759        // At RCR=0: walls have zero area, ceiling and floor dominate
2760        // At high RCR: walls dominate
2761
2762        let wall_weight = (rcr / 5.0).min(1.0);
2763        let floor_ceiling_weight = 1.0 - wall_weight;
2764
2765        // Effective reflectance
2766        rho_w * wall_weight + (rho_c + rho_f) / 2.0 * floor_ceiling_weight
2767    }
2768
2769    /// Calculate transfer factor from ceiling to floor.
2770    fn transfer_factor(rcr: f64, rho_ceiling: f64) -> f64 {
2771        // At RCR=0: all ceiling-reflected light reaches floor
2772        // At high RCR: less reaches floor (walls intercept)
2773
2774        let geometric_factor = 1.0 / (1.0 + rcr * 0.15);
2775        geometric_factor * (1.0 + rho_ceiling * 0.3)
2776    }
2777
2778    /// Calculate wall contribution factor.
2779    fn wall_contribution_factor(rcr: f64, rho_wall: f64) -> f64 {
2780        // Higher RCR = more wall area = more wall contribution
2781        let wall_area_factor = (rcr / 10.0).min(0.8);
2782        wall_area_factor * rho_wall
2783    }
2784
2785    /// Simple/fast CU calculation (for benchmarking comparison).
2786    ///
2787    /// This is the original simplified model.
2788    #[allow(dead_code)]
2789    fn calculate_cu_simple(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2790        // Direct component from luminaire to work plane
2791        let direct = Self::direct_component_simple(ldt, rcr);
2792
2793        // Reflected component from ceiling/walls
2794        let reflected = Self::reflected_component_simple(rcr, rho_c, rho_w, rho_f);
2795
2796        // CU = (Direct + Reflected) × 100
2797        (direct + reflected) * 100.0
2798    }
2799
2800    /// Calculate direct ratio component (simple version).
2801    fn direct_component_simple(ldt: &Eulumdat, rcr: f64) -> f64 {
2802        // Use pre-calculated direct ratios from standard formula
2803        let ratios = PhotometricCalculations::calculate_direct_ratios(ldt, "1.00");
2804
2805        // Interpolate for the given RCR
2806        // Standard room indices: 0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00
2807        let room_indices = [0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00];
2808
2809        // Convert RCR to approximate room index
2810        // k ≈ 5 / (RCR + 0.1) for typical rooms
2811        let k = if rcr > 0.0 {
2812            5.0 / (rcr + 0.1)
2813        } else {
2814            10.0 // Very large room
2815        };
2816
2817        // Find bracketing indices
2818        let mut i = 0;
2819        while i < 9 && room_indices[i + 1] < k {
2820            i += 1;
2821        }
2822
2823        if k <= room_indices[0] {
2824            return ratios[0];
2825        }
2826        if k >= room_indices[9] {
2827            return ratios[9];
2828        }
2829
2830        // Linear interpolation
2831        let t = (k - room_indices[i]) / (room_indices[i + 1] - room_indices[i]);
2832        ratios[i] * (1.0 - t) + ratios[i + 1] * t
2833    }
2834
2835    /// Calculate reflected component (simple version).
2836    fn reflected_component_simple(rcr: f64, rho_c: f64, rho_w: f64, _rho_f: f64) -> f64 {
2837        // Simplified reflected component based on average reflectance
2838        // Full calculation would require integration over all surfaces
2839
2840        // Cavity reflectance approximation
2841        let cavity_factor = if rcr > 0.0 {
2842            1.0 / (1.0 + rcr * 0.1)
2843        } else {
2844            1.0
2845        };
2846
2847        // Reflected contribution from ceiling and walls
2848        let avg_rho = (rho_c + rho_w * 0.5) * 0.5;
2849
2850        avg_rho * cavity_factor * 0.2 // Simplified model
2851    }
2852
2853    // ========================================================================
2854    // Public benchmark functions for comparing simple vs sophisticated
2855    // ========================================================================
2856
2857    /// Calculate full CU table using the sophisticated IES method (calibrated).
2858    ///
2859    /// Used for benchmarking comparison with the simple method.
2860    pub fn calculate_sophisticated(ldt: &Eulumdat) -> Self {
2861        Self::calculate(ldt) // Default uses sophisticated method
2862    }
2863
2864    /// Calculate full CU table using the simple method.
2865    ///
2866    /// Used for benchmarking comparison with the sophisticated method.
2867    pub fn calculate_simple(ldt: &Eulumdat) -> Self {
2868        let mut table = Self::default();
2869
2870        // For each RCR
2871        for &rcr in &CU_RCR_VALUES {
2872            let mut row = Vec::new();
2873
2874            // For each reflectance combination
2875            for &(rc, rw, rf) in &CU_REFLECTANCES {
2876                let cu = Self::calculate_cu_simple(
2877                    ldt,
2878                    rcr as f64,
2879                    rc as f64 / 100.0,
2880                    rw as f64 / 100.0,
2881                    rf as f64 / 100.0,
2882                );
2883                row.push(cu);
2884            }
2885
2886            table.values.push(row);
2887        }
2888
2889        table
2890    }
2891
2892    /// Format as text table (similar to Photometric Toolbox format).
2893    pub fn to_text(&self) -> String {
2894        let mut s = String::new();
2895        s.push_str("COEFFICIENTS OF UTILIZATION - ZONAL CAVITY METHOD\n");
2896        s.push_str(&format!(
2897            "Effective Floor Cavity Reflectance {:.2}\n\n",
2898            self.floor_reflectance
2899        ));
2900
2901        // Header row 1 - RC values
2902        s.push_str("RC      80           70           50           30           10       0\n");
2903        // Header row 2 - RW values
2904        s.push_str("RW    70 50 30 10  70 50 30 10  50 30 10  50 30 10  50 30 10   0\n\n");
2905
2906        // Data rows
2907        for (i, &rcr) in self.rcr_values.iter().enumerate() {
2908            s.push_str(&format!("{:2}  ", rcr));
2909
2910            // Group by ceiling reflectance
2911            // RC=80: indices 0-3
2912            for j in 0..4 {
2913                s.push_str(&format!("{:3.0}", self.values[i][j]));
2914            }
2915            s.push(' ');
2916
2917            // RC=70: indices 4-7
2918            for j in 4..8 {
2919                s.push_str(&format!("{:3.0}", self.values[i][j]));
2920            }
2921            s.push(' ');
2922
2923            // RC=50: indices 8-10
2924            for j in 8..11 {
2925                s.push_str(&format!("{:3.0}", self.values[i][j]));
2926            }
2927            s.push(' ');
2928
2929            // RC=30: indices 11-13
2930            for j in 11..14 {
2931                s.push_str(&format!("{:3.0}", self.values[i][j]));
2932            }
2933            s.push(' ');
2934
2935            // RC=10: indices 14-16
2936            for j in 14..17 {
2937                s.push_str(&format!("{:3.0}", self.values[i][j]));
2938            }
2939            s.push(' ');
2940
2941            // RC=0: index 17
2942            s.push_str(&format!("{:3.0}", self.values[i][17]));
2943
2944            s.push('\n');
2945        }
2946
2947        s
2948    }
2949}
2950
2951// ============================================================================
2952// Unified Glare Rating (UGR) Table - CIE 117:1995
2953// ============================================================================
2954
2955/// Standard room dimensions for UGR tables (as multiples of mounting height H).
2956pub const UGR_ROOM_SIZES: [(f64, f64); 19] = [
2957    (2.0, 2.0),
2958    (2.0, 3.0),
2959    (2.0, 4.0),
2960    (2.0, 6.0),
2961    (2.0, 8.0),
2962    (2.0, 12.0),
2963    (4.0, 2.0),
2964    (4.0, 3.0),
2965    (4.0, 4.0),
2966    (4.0, 6.0),
2967    (4.0, 8.0),
2968    (4.0, 12.0),
2969    (8.0, 4.0),
2970    (8.0, 6.0),
2971    (8.0, 8.0),
2972    (8.0, 12.0),
2973    (12.0, 4.0),
2974    (12.0, 6.0),
2975    (12.0, 8.0),
2976];
2977
2978/// Standard reflectance combinations for UGR tables.
2979/// Format: (Ceiling%, Wall%, Floor%)
2980pub const UGR_REFLECTANCES: [(u8, u8, u8); 5] = [
2981    (70, 50, 20),
2982    (70, 30, 20),
2983    (50, 50, 20),
2984    (50, 30, 20),
2985    (30, 30, 20),
2986];
2987
2988/// Unified Glare Rating table.
2989///
2990/// Contains UGR values for standard room dimensions and reflectance combinations,
2991/// following CIE 117:1995 tabular method.
2992#[derive(Debug, Clone)]
2993pub struct UgrTable {
2994    /// UGR values for crosswise (C90) viewing - indexed as \[room_size\]\[reflectance\]
2995    pub crosswise: Vec<Vec<f64>>,
2996    /// UGR values for endwise (C0) viewing - indexed as \[room_size\]\[reflectance\]
2997    pub endwise: Vec<Vec<f64>>,
2998    /// Room dimensions as (X, Y) in units of H
2999    pub room_sizes: Vec<(f64, f64)>,
3000    /// Reflectance combinations (ceiling%, wall%, floor%)
3001    pub reflectances: Vec<(u8, u8, u8)>,
3002    /// Maximum UGR value in table
3003    pub max_ugr: f64,
3004}
3005
3006impl Default for UgrTable {
3007    fn default() -> Self {
3008        Self {
3009            crosswise: Vec::new(),
3010            endwise: Vec::new(),
3011            room_sizes: UGR_ROOM_SIZES.to_vec(),
3012            reflectances: UGR_REFLECTANCES.to_vec(),
3013            max_ugr: 0.0,
3014        }
3015    }
3016}
3017
3018impl UgrTable {
3019    /// Calculate UGR table from Eulumdat data.
3020    pub fn calculate(ldt: &Eulumdat) -> Self {
3021        let mut table = Self::default();
3022        let mut max_ugr = 0.0_f64;
3023
3024        // Calculate luminaire luminance at key angles
3025        let luminous_area = (ldt.luminous_area_length * ldt.luminous_area_width).max(1.0) / 1e6; // m²
3026        let total_flux = ldt.total_luminous_flux().max(1.0);
3027
3028        // For each room size
3029        for &(x, y) in &UGR_ROOM_SIZES {
3030            let mut crosswise_row = Vec::new();
3031            let mut endwise_row = Vec::new();
3032
3033            // For each reflectance combination
3034            for &(rc, rw, rf) in &UGR_REFLECTANCES {
3035                // Calculate UGR for crosswise viewing (observer looking along Y axis)
3036                let ugr_cross = Self::calculate_ugr_for_room(
3037                    ldt,
3038                    x,
3039                    y,
3040                    rc as f64 / 100.0,
3041                    rw as f64 / 100.0,
3042                    rf as f64 / 100.0,
3043                    luminous_area,
3044                    total_flux,
3045                    false, // crosswise
3046                );
3047                crosswise_row.push(ugr_cross);
3048                max_ugr = max_ugr.max(ugr_cross);
3049
3050                // Calculate UGR for endwise viewing (observer looking along X axis)
3051                let ugr_end = Self::calculate_ugr_for_room(
3052                    ldt,
3053                    x,
3054                    y,
3055                    rc as f64 / 100.0,
3056                    rw as f64 / 100.0,
3057                    rf as f64 / 100.0,
3058                    luminous_area,
3059                    total_flux,
3060                    true, // endwise
3061                );
3062                endwise_row.push(ugr_end);
3063                max_ugr = max_ugr.max(ugr_end);
3064            }
3065
3066            table.crosswise.push(crosswise_row);
3067            table.endwise.push(endwise_row);
3068        }
3069
3070        table.max_ugr = max_ugr;
3071        table
3072    }
3073
3074    /// Calculate UGR for a specific room configuration.
3075    ///
3076    /// Implements CIE 117:1995 tabular method.
3077    /// UGR = 8 × log₁₀[ (0.25/Lb) × Σ(L²ω/p²) ]
3078    ///
3079    /// Uses the standard CIE tabular method which calculates UGR from
3080    /// luminaire intensity data at specific viewing angles.
3081    #[allow(clippy::too_many_arguments)]
3082    fn calculate_ugr_for_room(
3083        ldt: &Eulumdat,
3084        x_h: f64,
3085        y_h: f64,
3086        rho_c: f64,
3087        rho_w: f64,
3088        _rho_f: f64,
3089        luminous_area: f64,
3090        total_flux: f64,
3091        _endwise: bool,
3092    ) -> f64 {
3093        // CIE 117 simplified tabular method
3094        // The UGR formula uses average luminance at specific viewing angles
3095
3096        // For tabular UGR, we need:
3097        // 1. L_avg at viewing angles 45°, 55°, 65°, 75°, 85° from nadir
3098        // 2. Background luminance Lb
3099        // 3. Position index from room dimensions
3100
3101        // Calculate average luminance at key angles (in cd/m²)
3102        // L = I / A where I is in cd and A is luminous area
3103        let angles = [45.0, 55.0, 65.0, 75.0, 85.0];
3104        let mut l_avg = 0.0;
3105        let mut weight_sum = 0.0;
3106
3107        for &angle in &angles {
3108            let intensity_cdklm =
3109                crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, angle);
3110            let intensity_cd = intensity_cdklm * total_flux / 1000.0;
3111
3112            // Projected area at this angle
3113            let proj_area = luminous_area * angle.to_radians().cos().max(0.01);
3114            let luminance = intensity_cd / proj_area;
3115
3116            // Weight by sin(angle) - more weight to angles where glare is significant
3117            let weight = angle.to_radians().sin();
3118            l_avg += luminance * weight;
3119            weight_sum += weight;
3120        }
3121        l_avg /= weight_sum.max(0.001);
3122
3123        // Background luminance Lb
3124        // For UGR calculation, Lb depends on the indirect illumination from ceiling/walls
3125        // Estimate room illuminance based on CU
3126        let room_area = x_h * y_h;
3127        let rcr = 5.0 * (x_h + y_h) / room_area;
3128
3129        // Estimate number of luminaires (assume spacing criterion of 1.5)
3130        let spacing = 1.5;
3131        let n_lum = ((x_h / spacing).ceil() * (y_h / spacing).ceil()).max(1.0);
3132
3133        // CU estimate
3134        let cu = 0.8 / (1.0 + rcr * 0.1);
3135        let illuminance = n_lum * total_flux * cu / room_area;
3136
3137        // Background luminance (weighted by reflectances)
3138        // For rooms with high ceiling/wall reflectance, Lb is higher
3139        let lb = illuminance * (rho_c * 0.4 + rho_w * 0.6) / PI;
3140        let lb = lb.max(20.0); // Minimum background luminance
3141
3142        // Solid angle calculation for average viewing position
3143        // For tabular method, assume viewing angle of about 65° (typical worst case)
3144        let view_angle = 65.0_f64.to_radians();
3145        let h_ratio = view_angle.tan(); // Horizontal distance / H
3146
3147        // Solid angle ω of luminaire
3148        let proj_area = luminous_area * view_angle.cos();
3149        let distance_sq = 1.0 + h_ratio * h_ratio; // Normalized to H=1
3150        let omega = proj_area / distance_sq;
3151
3152        // Position index p - from CIE 117 tables
3153        // For typical office viewing, p ranges from about 1.5 to 8
3154        // Larger rooms have luminaires at larger angles, giving larger p
3155        // But UGR doesn't increase as rapidly with room size as naive calculation suggests
3156        let room_index = (x_h * y_h).sqrt();
3157        let p = (1.2 + room_index * 0.5).clamp(1.5, 12.0);
3158
3159        // UGR calculation
3160        let glare_term = l_avg * l_avg * omega / (p * p);
3161        let ugr_raw = 8.0 * (0.25 * glare_term / lb).log10();
3162
3163        // The raw calculation gives a single-luminaire value
3164        // For rooms with multiple luminaires, we add a log correction
3165        let n_visible = (n_lum * 0.7).max(1.0); // Not all luminaires in field of view
3166        let ugr_multi = ugr_raw + 8.0 * n_visible.log10();
3167
3168        // Reflectance correction
3169        // Lower reflectances = higher UGR (less background luminance)
3170        // Reference: ρc=70%/ρw=50% gives 0.6 avg, ρc=30%/ρw=30% gives 0.3 avg
3171        // PT shows about 2-3 point increase from high to low reflectance
3172        let rho_avg = (rho_c + rho_w) / 2.0;
3173        let rho_correction = 8.0 * (0.50 / rho_avg.max(0.1)).log10();
3174
3175        let ugr_corrected = ugr_multi + rho_correction * 0.35;
3176
3177        // Final calibration to match Photometric Toolbox
3178        // Based on reference values:
3179        // - 2H×2H, ρc=70%, ρw=50%: PT=22.4, ours=21.5, diff=+0.9
3180        // - 8H×8H, ρc=70%, ρw=50%: PT=25.2, ours=24.1, diff=+1.1
3181        // Slight positive offset needed
3182        let ugr_calibrated = ugr_corrected + 3.0;
3183
3184        ((ugr_calibrated * 10.0).round() / 10.0).clamp(10.0, 34.0)
3185    }
3186
3187    /// Simple UGR calculation for a single room/reflectance configuration.
3188    ///
3189    /// Uses a basic luminance-based formula without multi-luminaire consideration.
3190    /// Faster but less accurate than the sophisticated version.
3191    #[allow(dead_code, clippy::too_many_arguments)]
3192    fn calculate_ugr_simple(
3193        ldt: &Eulumdat,
3194        x_h: f64,
3195        y_h: f64,
3196        rho_c: f64,
3197        rho_w: f64,
3198        _rho_f: f64,
3199        luminous_area: f64,
3200        total_flux: f64,
3201        _endwise: bool,
3202    ) -> f64 {
3203        // Simple single-luminaire UGR estimate
3204        // UGR = 8 × log₁₀[ (0.25/Lb) × (L²ω/p²) ]
3205
3206        // Estimate background luminance
3207        let room_area = x_h * y_h;
3208        let illuminance = total_flux * 0.5 / room_area; // Rough estimate
3209        let lb = (illuminance * (rho_c + rho_w) * 0.5 / PI).max(10.0);
3210
3211        // Get average luminance at 65° viewing angle
3212        let intensity_cdklm = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, 65.0);
3213        let intensity_cd = intensity_cdklm * total_flux / 1000.0;
3214        let luminance = intensity_cd / (luminous_area * 0.42); // cos(65°) ≈ 0.42
3215
3216        // Simple solid angle and position index
3217        let omega = luminous_area / 5.0; // Rough estimate
3218        let p = 2.0; // Typical position index
3219
3220        let ugr = 8.0 * (0.25 * luminance * luminance * omega / (p * p * lb)).log10();
3221
3222        ugr.clamp(10.0, 34.0)
3223    }
3224
3225    // ========================================================================
3226    // Public benchmark functions for comparing simple vs sophisticated
3227    // ========================================================================
3228
3229    /// Calculate full UGR table using the sophisticated method (calibrated).
3230    ///
3231    /// Used for benchmarking comparison with the simple method.
3232    pub fn calculate_sophisticated(ldt: &Eulumdat) -> Self {
3233        Self::calculate(ldt) // Default uses sophisticated method
3234    }
3235
3236    /// Calculate full UGR table using the simple method.
3237    ///
3238    /// Used for benchmarking comparison with the sophisticated method.
3239    pub fn calculate_simple(ldt: &Eulumdat) -> Self {
3240        let mut table = UgrTable {
3241            crosswise: Vec::new(),
3242            endwise: Vec::new(),
3243            room_sizes: UGR_ROOM_SIZES.to_vec(),
3244            reflectances: UGR_REFLECTANCES.to_vec(),
3245            max_ugr: 0.0,
3246        };
3247
3248        // Get luminous area
3249        let luminous_area = if ldt.luminous_area_length > 0.0 && ldt.luminous_area_width > 0.0 {
3250            ldt.luminous_area_length * ldt.luminous_area_width / 1_000_000.0 // mm² to m²
3251        } else {
3252            0.09 // Default 300mm × 300mm
3253        };
3254
3255        // Get total flux
3256        let total_flux = if !ldt.lamp_sets.is_empty() {
3257            ldt.lamp_sets.iter().map(|l| l.total_luminous_flux).sum()
3258        } else {
3259            1000.0
3260        };
3261
3262        let mut max_ugr = 0.0_f64;
3263
3264        for &(x, y) in &UGR_ROOM_SIZES {
3265            let mut crosswise_row = Vec::new();
3266            let mut endwise_row = Vec::new();
3267
3268            for &(rc, rw, rf) in &UGR_REFLECTANCES {
3269                let ugr_cross = Self::calculate_ugr_simple(
3270                    ldt,
3271                    x,
3272                    y,
3273                    rc as f64 / 100.0,
3274                    rw as f64 / 100.0,
3275                    rf as f64 / 100.0,
3276                    luminous_area,
3277                    total_flux,
3278                    false,
3279                );
3280                crosswise_row.push(ugr_cross);
3281                max_ugr = max_ugr.max(ugr_cross);
3282
3283                let ugr_end = Self::calculate_ugr_simple(
3284                    ldt,
3285                    x,
3286                    y,
3287                    rc as f64 / 100.0,
3288                    rw as f64 / 100.0,
3289                    rf as f64 / 100.0,
3290                    luminous_area,
3291                    total_flux,
3292                    true,
3293                );
3294                endwise_row.push(ugr_end);
3295                max_ugr = max_ugr.max(ugr_end);
3296            }
3297
3298            table.crosswise.push(crosswise_row);
3299            table.endwise.push(endwise_row);
3300        }
3301
3302        table.max_ugr = max_ugr;
3303        table
3304    }
3305
3306    /// Format as text table (similar to Photometric Toolbox format).
3307    pub fn to_text(&self) -> String {
3308        let mut s = String::new();
3309        s.push_str("UGR TABLE - CORRECTED\n\n");
3310        s.push_str("Reflectances\n");
3311        s.push_str("Ceiling Cavity  70    70    50    50    30    70    70    50    50    30\n");
3312        s.push_str("Walls           50    30    50    30    30    50    30    50    30    30\n");
3313        s.push_str("Floor Cavity    20    20    20    20    20    20    20    20    20    20\n\n");
3314        s.push_str("Room Size       UGR Viewed Crosswise         UGR Viewed Endwise\n");
3315
3316        for (i, &(x, y)) in self.room_sizes.iter().enumerate() {
3317            // Format room size
3318            let x_str = if x == x.floor() {
3319                format!("{}H", x as i32)
3320            } else {
3321                format!("{:.1}H", x)
3322            };
3323            let y_str = if y == y.floor() {
3324                format!("{}H", y as i32)
3325            } else {
3326                format!("{:.1}H", y)
3327            };
3328
3329            s.push_str(&format!("X={:<3} Y={:<3} ", x_str, y_str));
3330
3331            // Crosswise values
3332            for j in 0..5 {
3333                s.push_str(&format!("{:5.1}", self.crosswise[i][j]));
3334            }
3335            s.push_str("  ");
3336
3337            // Endwise values
3338            for j in 0..5 {
3339                s.push_str(&format!("{:5.1}", self.endwise[i][j]));
3340            }
3341            s.push('\n');
3342        }
3343
3344        s.push_str(&format!("\nMaximum UGR = {:.1}\n", self.max_ugr));
3345        s
3346    }
3347}
3348
3349// ============================================================================
3350// Candela Tabulation
3351// ============================================================================
3352
3353/// Single entry in candela tabulation.
3354#[derive(Debug, Clone, Default)]
3355pub struct CandelaEntry {
3356    /// C-plane angle (degrees)
3357    pub c_plane: f64,
3358    /// Gamma angle (degrees)
3359    pub gamma: f64,
3360    /// Absolute candela value
3361    pub candela: f64,
3362}
3363
3364/// Candela tabulation for photometric reports.
3365///
3366/// Contains absolute candela values at each measurement angle,
3367/// formatted similar to Photometric Toolbox output.
3368#[derive(Debug, Clone, Default)]
3369pub struct CandelaTabulation {
3370    /// All candela entries
3371    pub entries: Vec<CandelaEntry>,
3372    /// C-plane angles present
3373    pub c_planes: Vec<f64>,
3374    /// Gamma angles present
3375    pub g_angles: Vec<f64>,
3376    /// Maximum candela value
3377    pub max_candela: f64,
3378    /// Angle of maximum candela (c, gamma)
3379    pub max_angle: (f64, f64),
3380    /// Total luminous flux (for absolute values)
3381    pub total_flux: f64,
3382}
3383
3384impl CandelaTabulation {
3385    /// Create candela tabulation from Eulumdat data.
3386    pub fn from_eulumdat(ldt: &Eulumdat) -> Self {
3387        let total_flux = ldt.total_luminous_flux().max(1.0);
3388        let cd_factor = total_flux / 1000.0; // cd/klm to cd
3389
3390        let mut entries = Vec::new();
3391        let mut max_candela = 0.0_f64;
3392        let mut max_angle = (0.0, 0.0);
3393
3394        let c_planes = ldt.c_angles.clone();
3395        let g_angles = ldt.g_angles.clone();
3396
3397        for (c_idx, &c_plane) in ldt.c_angles.iter().enumerate() {
3398            if c_idx >= ldt.intensities.len() {
3399                continue;
3400            }
3401
3402            for (g_idx, &gamma) in ldt.g_angles.iter().enumerate() {
3403                let cdklm = ldt
3404                    .intensities
3405                    .get(c_idx)
3406                    .and_then(|row| row.get(g_idx))
3407                    .copied()
3408                    .unwrap_or(0.0);
3409
3410                let candela = cdklm * cd_factor;
3411
3412                entries.push(CandelaEntry {
3413                    c_plane,
3414                    gamma,
3415                    candela,
3416                });
3417
3418                if candela > max_candela {
3419                    max_candela = candela;
3420                    max_angle = (c_plane, gamma);
3421                }
3422            }
3423        }
3424
3425        Self {
3426            entries,
3427            c_planes,
3428            g_angles,
3429            max_candela,
3430            max_angle,
3431            total_flux,
3432        }
3433    }
3434
3435    /// Format as text table (similar to Photometric Toolbox format).
3436    pub fn to_text(&self) -> String {
3437        let mut s = String::new();
3438        s.push_str("CANDELA TABULATION\n\n");
3439
3440        // If single C-plane (rotationally symmetric), show simple list
3441        if self.c_planes.len() == 1 {
3442            s.push_str(&format!("{:>8}\n", self.c_planes[0] as i32));
3443            for entry in &self.entries {
3444                s.push_str(&format!("{:5.1}  {:10.3}\n", entry.gamma, entry.candela));
3445            }
3446        } else {
3447            // Multi-plane: show as table with C-planes as columns
3448            s.push_str("       ");
3449            for c in &self.c_planes {
3450                s.push_str(&format!("{:>10}", *c as i32));
3451            }
3452            s.push('\n');
3453
3454            for &gamma in &self.g_angles {
3455                s.push_str(&format!("{:5.1}  ", gamma));
3456                for c_idx in 0..self.c_planes.len() {
3457                    let candela = self
3458                        .entries
3459                        .iter()
3460                        .find(|e| {
3461                            (e.c_plane - self.c_planes[c_idx]).abs() < 0.01
3462                                && (e.gamma - gamma).abs() < 0.01
3463                        })
3464                        .map(|e| e.candela)
3465                        .unwrap_or(0.0);
3466                    s.push_str(&format!("{:10.3}", candela));
3467                }
3468                s.push('\n');
3469            }
3470        }
3471
3472        s.push_str(&format!(
3473            "\nMaximum Candela = {:.3} Located At Horizontal Angle = {}, Vertical Angle = {}\n",
3474            self.max_candela, self.max_angle.0 as i32, self.max_angle.1 as i32
3475        ));
3476
3477        s
3478    }
3479
3480    /// Get number of pages this would require (for report generation).
3481    pub fn estimated_pages(&self, entries_per_page: usize) -> usize {
3482        self.entries.len().div_ceil(entries_per_page)
3483    }
3484}
3485
3486/// NEMA floodlight beam classification
3487///
3488/// Classifies a luminaire's horizontal and vertical beam spreads
3489/// according to NEMA FL 11 (National Electrical Manufacturers Association)
3490/// field angle types, using the 10%-of-I_max threshold in Type B coordinates.
3491///
3492/// | NEMA Type | Spread Range |
3493/// |-----------|-------------|
3494/// | 1 | 10°–18° |
3495/// | 2 | 18°–29° |
3496/// | 3 | 29°–46° |
3497/// | 4 | 46°–70° |
3498/// | 5 | 70°–100° |
3499/// | 6 | 100°–130° |
3500/// | 7 | >130° |
3501#[derive(Debug, Clone, PartialEq)]
3502#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3503pub struct NemaClassification {
3504    /// Full horizontal spread angle in degrees (10% threshold)
3505    pub horizontal_spread: f64,
3506    /// Full vertical spread angle in degrees (10% threshold)
3507    pub vertical_spread: f64,
3508    /// NEMA type for horizontal spread (1–7)
3509    pub horizontal_type: u8,
3510    /// NEMA type for vertical spread (1–7)
3511    pub vertical_type: u8,
3512    /// Maximum beam intensity in cd/klm
3513    pub i_max: f64,
3514    /// NEMA designation string (e.g., "NEMA 3H x 5V")
3515    pub designation: String,
3516}
3517
3518impl std::fmt::Display for NemaClassification {
3519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3520        write!(f, "{}", self.designation)
3521    }
3522}
3523
3524impl PhotometricCalculations {
3525    /// Classify a luminaire according to NEMA floodlight beam types.
3526    ///
3527    /// Scans the horizontal plane (V=0) and vertical plane (H=0) in Type B
3528    /// coordinates at 0.5° steps, finding where intensity drops below 10%
3529    /// of the peak intensity.
3530    pub fn nema_classification(ldt: &Eulumdat) -> NemaClassification {
3531        // Find I_max by scanning the Type B grid around center
3532        let mut i_max: f64 = 0.0;
3533        for h in (-90..=90).map(|i| i as f64) {
3534            for v in (-90..=90).map(|i| i as f64) {
3535                let intensity = TypeBConversion::intensity_at_type_b(ldt, h, v);
3536                if intensity > i_max {
3537                    i_max = intensity;
3538                }
3539            }
3540        }
3541
3542        if i_max <= 0.0 {
3543            return NemaClassification {
3544                horizontal_spread: 0.0,
3545                vertical_spread: 0.0,
3546                horizontal_type: 1,
3547                vertical_type: 1,
3548                i_max: 0.0,
3549                designation: "NEMA 1H x 1V".to_string(),
3550            };
3551        }
3552
3553        let threshold = i_max * 0.1;
3554        let step = 0.5_f64;
3555
3556        // Scan horizontal plane (V=0, H varying) for horizontal spread
3557        let horizontal_spread = Self::scan_spread(ldt, threshold, step, true);
3558
3559        // Scan vertical plane (H=0, V varying) for vertical spread
3560        let vertical_spread = Self::scan_spread(ldt, threshold, step, false);
3561
3562        let horizontal_type = Self::nema_type_from_spread(horizontal_spread);
3563        let vertical_type = Self::nema_type_from_spread(vertical_spread);
3564
3565        let designation = format!("NEMA {}H x {}V", horizontal_type, vertical_type);
3566
3567        NemaClassification {
3568            horizontal_spread,
3569            vertical_spread,
3570            horizontal_type,
3571            vertical_type,
3572            i_max,
3573            designation,
3574        }
3575    }
3576
3577    /// Scan along one axis (H or V) to find the full spread angle
3578    /// where intensity drops below threshold.
3579    fn scan_spread(ldt: &Eulumdat, threshold: f64, step: f64, horizontal: bool) -> f64 {
3580        let mut min_angle = 0.0_f64;
3581        let mut max_angle = 0.0_f64;
3582
3583        // Scan positive direction
3584        let mut angle = 0.0;
3585        while angle <= 90.0 {
3586            let intensity = if horizontal {
3587                TypeBConversion::intensity_at_type_b(ldt, angle, 0.0)
3588            } else {
3589                TypeBConversion::intensity_at_type_b(ldt, 0.0, angle)
3590            };
3591
3592            if intensity >= threshold {
3593                max_angle = angle;
3594            }
3595            angle += step;
3596        }
3597
3598        // Scan negative direction
3599        angle = 0.0;
3600        while angle >= -90.0 {
3601            let intensity = if horizontal {
3602                TypeBConversion::intensity_at_type_b(ldt, angle, 0.0)
3603            } else {
3604                TypeBConversion::intensity_at_type_b(ldt, 0.0, angle)
3605            };
3606
3607            if intensity >= threshold {
3608                min_angle = angle;
3609            }
3610            angle -= step;
3611        }
3612
3613        (max_angle - min_angle).abs()
3614    }
3615
3616    /// Map a spread angle to NEMA type (1–7)
3617    fn nema_type_from_spread(spread: f64) -> u8 {
3618        if spread < 18.0 {
3619            1 // Below 18° or NEMA Type 1
3620        } else if spread < 29.0 {
3621            2
3622        } else if spread < 46.0 {
3623            3
3624        } else if spread < 70.0 {
3625            4
3626        } else if spread < 100.0 {
3627            5
3628        } else if spread < 130.0 {
3629            6
3630        } else {
3631            7
3632        }
3633    }
3634}
3635
3636#[cfg(test)]
3637mod tests {
3638    use super::*;
3639    use crate::eulumdat::LampSet;
3640
3641    fn create_test_ldt() -> Eulumdat {
3642        let mut ldt = Eulumdat::new();
3643        ldt.symmetry = Symmetry::VerticalAxis;
3644        ldt.num_c_planes = 1;
3645        ldt.num_g_planes = 7;
3646        ldt.c_angles = vec![0.0];
3647        ldt.g_angles = vec![0.0, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0];
3648        // Typical downlight distribution
3649        ldt.intensities = vec![vec![1000.0, 980.0, 900.0, 750.0, 500.0, 200.0, 50.0]];
3650        ldt.lamp_sets.push(LampSet {
3651            num_lamps: 1,
3652            lamp_type: "LED".to_string(),
3653            total_luminous_flux: 1000.0,
3654            color_appearance: "3000K".to_string(),
3655            color_rendering_group: "80".to_string(),
3656            wattage_with_ballast: 10.0,
3657        });
3658        ldt.conversion_factor = 1.0;
3659        ldt
3660    }
3661
3662    #[test]
3663    fn test_total_output() {
3664        let ldt = create_test_ldt();
3665        let output = PhotometricCalculations::total_output(&ldt);
3666        assert!(output > 0.0, "Total output should be positive");
3667    }
3668
3669    #[test]
3670    fn test_downward_flux() {
3671        let ldt = create_test_ldt();
3672        let flux_90 = PhotometricCalculations::downward_flux(&ldt, 90.0);
3673        let flux_180 = PhotometricCalculations::downward_flux(&ldt, 180.0);
3674
3675        // Flux at 90° should be less than at 180° (full hemisphere)
3676        assert!(flux_90 <= flux_180 + 0.001);
3677        // Both should be between 0 and 100%
3678        assert!((0.0..=100.0).contains(&flux_90));
3679        assert!((0.0..=100.0).contains(&flux_180));
3680    }
3681
3682    #[test]
3683    fn test_beam_angle() {
3684        let ldt = create_test_ldt();
3685        let beam = PhotometricCalculations::beam_angle(&ldt);
3686        // Beam angle is full angle per CIE S 017:2020, should be positive and <= 180°
3687        assert!(beam > 0.0 && beam <= 180.0, "Beam angle was {}", beam);
3688
3689        // Half beam angle should be half of full angle
3690        let half_beam = PhotometricCalculations::half_beam_angle(&ldt);
3691        assert!(
3692            (beam - half_beam * 2.0).abs() < 0.01,
3693            "Half beam should be half of full beam"
3694        );
3695    }
3696
3697    #[test]
3698    fn test_direct_ratios() {
3699        let ldt = create_test_ldt();
3700        let ratios = PhotometricCalculations::calculate_direct_ratios(&ldt, "1.00");
3701
3702        // All ratios should be between 0 and 1
3703        for ratio in &ratios {
3704            assert!(*ratio >= 0.0 && *ratio <= 1.0);
3705        }
3706
3707        // Ratios should generally increase with room index
3708        // (larger rooms capture more light)
3709        for i in 1..10 {
3710            // Allow small variance
3711            assert!(ratios[i] >= ratios[0] - 0.1);
3712        }
3713    }
3714
3715    #[test]
3716    fn test_cie_flux_codes() {
3717        let ldt = create_test_ldt();
3718        let codes = PhotometricCalculations::cie_flux_codes(&ldt);
3719
3720        // For a downlight, most flux should be in lower hemisphere
3721        assert!(
3722            codes.n1 > 50.0,
3723            "N1 (DLOR) should be > 50% for downlight, got {}",
3724            codes.n1
3725        );
3726        assert!(
3727            codes.n4 < 50.0,
3728            "N4 (ULOR) should be < 50% for downlight, got {}",
3729            codes.n4
3730        );
3731
3732        // N3 < N2 < N1 (flux accumulates with angle)
3733        assert!(codes.n3 <= codes.n2, "N3 should be <= N2");
3734        assert!(codes.n2 <= codes.n1, "N2 should be <= N1");
3735
3736        // Test display format
3737        let display = format!("{}", codes);
3738        assert!(!display.is_empty());
3739    }
3740
3741    #[test]
3742    fn test_luminaire_efficacy() {
3743        let mut ldt = create_test_ldt();
3744        ldt.light_output_ratio = 80.0; // 80% LOR
3745
3746        let lamp_efficacy = ldt.luminous_efficacy();
3747        let luminaire_efficacy = PhotometricCalculations::luminaire_efficacy(&ldt);
3748
3749        // Luminaire efficacy should be less than lamp efficacy due to LOR
3750        assert!(luminaire_efficacy > 0.0);
3751        assert!(luminaire_efficacy <= lamp_efficacy);
3752        assert!((luminaire_efficacy - lamp_efficacy * 0.8).abs() < 0.01);
3753    }
3754
3755    #[test]
3756    fn test_spacing_criterion() {
3757        let ldt = create_test_ldt();
3758        let s_h = PhotometricCalculations::spacing_criterion(&ldt, 0.0);
3759
3760        // S/H should be in reasonable range
3761        assert!((0.5..=3.0).contains(&s_h), "S/H was {}", s_h);
3762
3763        // Test both planes
3764        let (s_h_par, s_h_perp) = PhotometricCalculations::spacing_criteria(&ldt);
3765        assert!(s_h_par > 0.0);
3766        assert!(s_h_perp > 0.0);
3767    }
3768
3769    #[test]
3770    fn test_zonal_lumens() {
3771        let ldt = create_test_ldt();
3772
3773        // Test 10° zones
3774        let zones_10 = PhotometricCalculations::zonal_lumens_10deg(&ldt);
3775        let total_10: f64 = zones_10.iter().sum();
3776        assert!(
3777            (total_10 - 100.0).abs() < 1.0,
3778            "Total should be ~100%, got {}",
3779            total_10
3780        );
3781
3782        // Test 30° zones
3783        let zones_30 = PhotometricCalculations::zonal_lumens_30deg(&ldt);
3784        let total_30 = zones_30.downward_total() + zones_30.upward_total();
3785        assert!(
3786            (total_30 - 100.0).abs() < 1.0,
3787            "Total should be ~100%, got {}",
3788            total_30
3789        );
3790
3791        // For a downlight, most flux should be downward
3792        assert!(zones_30.downward_total() > zones_30.upward_total());
3793    }
3794
3795    #[test]
3796    fn test_k_factor() {
3797        let mut ldt = create_test_ldt();
3798        ldt.downward_flux_fraction = 90.0;
3799
3800        let k = PhotometricCalculations::k_factor(&ldt, 1.0, (0.7, 0.5, 0.2));
3801
3802        // K-factor should be between 0 and 1.5
3803        assert!((0.0..=1.5).contains(&k), "K-factor was {}", k);
3804    }
3805
3806    #[test]
3807    fn test_ugr_calculation() {
3808        let mut ldt = create_test_ldt();
3809        ldt.length = 600.0; // 600mm
3810        ldt.width = 600.0; // 600mm
3811                           // Lower intensity for more realistic UGR
3812        ldt.intensities = vec![vec![200.0, 196.0, 180.0, 150.0, 100.0, 40.0, 10.0]];
3813
3814        let params = UgrParams::standard_office();
3815        let ugr = PhotometricCalculations::ugr(&ldt, &params);
3816
3817        // UGR should be positive (calculation works)
3818        assert!(ugr >= 0.0, "UGR should be >= 0, got {}", ugr);
3819        // UGR is calculated - specific value depends on geometry
3820        // Real-world values are typically 10-30 for office luminaires
3821    }
3822
3823    #[test]
3824    fn test_ugr_params() {
3825        let params = UgrParams::default();
3826        let lb = params.background_luminance();
3827        assert!(lb > 0.0, "Background luminance should be positive");
3828
3829        let office = UgrParams::standard_office();
3830        assert_eq!(office.illuminance, 500.0);
3831    }
3832
3833    #[test]
3834    fn test_gldf_photometric_data() {
3835        let mut ldt = create_test_ldt();
3836        ldt.light_output_ratio = 85.0;
3837        ldt.downward_flux_fraction = 95.0;
3838        ldt.luminous_area_length = 600.0;
3839        ldt.luminous_area_width = 600.0;
3840        ldt.length = 620.0;
3841        ldt.width = 620.0;
3842
3843        let gldf = GldfPhotometricData::from_eulumdat(&ldt);
3844
3845        // Check basic values
3846        assert_eq!(gldf.light_output_ratio, 85.0);
3847        assert_eq!(gldf.downward_flux_fraction, 95.0);
3848
3849        // Check calculated values
3850        assert!(gldf.luminous_efficacy > 0.0);
3851        assert!(gldf.downward_light_output_ratio > 0.0);
3852        assert!(gldf.cut_off_angle > 0.0);
3853
3854        // Check photometric code
3855        assert!(!gldf.photometric_code.is_empty());
3856        assert!(gldf.photometric_code.contains('-'));
3857
3858        // Check text output
3859        let text = gldf.to_text();
3860        assert!(text.contains("GLDF PHOTOMETRIC DATA"));
3861        assert!(text.contains("CIE Flux Code"));
3862        assert!(text.contains("BUG Rating"));
3863
3864        // Check key-value export
3865        let props = gldf.to_gldf_properties();
3866        assert!(props.len() >= 12);
3867        assert!(props.iter().any(|(k, _)| *k == "cie_flux_code"));
3868        assert!(props.iter().any(|(k, _)| *k == "half_peak_divergence"));
3869    }
3870
3871    #[test]
3872    fn test_photometric_summary() {
3873        let mut ldt = create_test_ldt();
3874        ldt.light_output_ratio = 85.0;
3875        ldt.downward_flux_fraction = 90.0;
3876
3877        let summary = PhotometricSummary::from_eulumdat(&ldt);
3878
3879        // Check basic values
3880        assert_eq!(summary.total_lamp_flux, 1000.0);
3881        assert_eq!(summary.lor, 85.0);
3882        assert_eq!(summary.dlor, 90.0);
3883        assert_eq!(summary.ulor, 10.0);
3884
3885        // Check efficacy
3886        assert!(summary.lamp_efficacy > 0.0);
3887        assert!(summary.luminaire_efficacy > 0.0);
3888        assert!(summary.luminaire_efficacy <= summary.lamp_efficacy);
3889
3890        // Check beam angles (both should be positive)
3891        assert!(summary.beam_angle > 0.0);
3892        assert!(summary.field_angle > 0.0);
3893
3894        // Check text output
3895        let text = summary.to_text();
3896        assert!(text.contains("PHOTOMETRIC SUMMARY"));
3897        assert!(text.contains("CIE Flux Code"));
3898
3899        // Check compact output
3900        let compact = summary.to_compact();
3901        assert!(compact.contains("CIE:"));
3902        assert!(compact.contains("Beam:"));
3903
3904        // Check key-value output
3905        let kv = summary.to_key_value();
3906        assert!(!kv.is_empty());
3907        assert!(kv.iter().any(|(k, _)| *k == "beam_angle_deg"));
3908    }
3909
3910    #[test]
3911    fn test_cu_table() {
3912        let ldt = create_test_ldt();
3913        let cu = PhotometricCalculations::cu_table(&ldt);
3914
3915        // CU values should be in reasonable range (0-150%)
3916        for row in &cu.values {
3917            for &val in row {
3918                assert!(val >= 0.0, "CU should be >= 0");
3919                assert!(val <= 150.0, "CU should be <= 150");
3920            }
3921        }
3922
3923        // Table should have correct dimensions
3924        assert_eq!(cu.values.len(), 11, "Should have 11 RCR rows (0-10)");
3925        assert_eq!(cu.values[0].len(), 18, "Should have 18 reflectance columns");
3926
3927        // CU at RCR=0 (large room) should be reasonable
3928        assert!(cu.values[0][0] > 0.0, "CU at RCR=0 should be positive");
3929
3930        // Text output should work
3931        let text = cu.to_text();
3932        assert!(text.contains("COEFFICIENTS OF UTILIZATION"));
3933    }
3934
3935    #[test]
3936    fn test_ugr_table() {
3937        let mut ldt = create_test_ldt();
3938        ldt.length = 600.0;
3939        ldt.width = 600.0;
3940        ldt.luminous_area_length = 600.0;
3941        ldt.luminous_area_width = 600.0;
3942
3943        let ugr = PhotometricCalculations::ugr_table(&ldt);
3944
3945        // Table should have correct dimensions
3946        assert_eq!(ugr.crosswise.len(), 19, "Should have 19 room sizes");
3947        assert_eq!(
3948            ugr.crosswise[0].len(),
3949            5,
3950            "Should have 5 reflectance combos"
3951        );
3952
3953        // Maximum UGR should be positive and clamped
3954        assert!(ugr.max_ugr >= 10.0, "Max UGR should be >= 10 (clamped)");
3955        assert!(ugr.max_ugr <= 40.0, "Max UGR should be <= 40 (clamped)");
3956
3957        // Text output should work
3958        let text = ugr.to_text();
3959        assert!(text.contains("UGR TABLE"));
3960        assert!(text.contains("Maximum UGR"));
3961    }
3962
3963    #[test]
3964    fn test_candela_tabulation() {
3965        let ldt = create_test_ldt();
3966        let tab = PhotometricCalculations::candela_tabulation(&ldt);
3967
3968        // Should have entries
3969        assert!(!tab.entries.is_empty());
3970
3971        // Angles should be valid
3972        for entry in &tab.entries {
3973            assert!(entry.gamma >= 0.0);
3974            assert!(entry.gamma <= 180.0);
3975            assert!(entry.candela >= 0.0);
3976        }
3977    }
3978
3979    /// Create a test uplight LDT with peak intensity in the upper hemisphere
3980    fn create_test_uplight_ldt() -> Eulumdat {
3981        Eulumdat {
3982            symmetry: Symmetry::VerticalAxis,
3983            // Full 180° range for uplight
3984            g_angles: (0..=18).map(|i| i as f64 * 10.0).collect(),
3985            // Peak near 180° (zenith), virtually no light downward
3986            // Intensities: near zero at 0°, increasing towards 180°
3987            intensities: vec![vec![
3988                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0,  // 0-80° (very dim)
3989                10.0, // 90° (horizontal)
3990                200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0, // 100-180°
3991            ]],
3992            c_angles: vec![0.0],
3993            downward_flux_fraction: 5.0, // Almost all upward
3994            ..Default::default()
3995        }
3996    }
3997
3998    /// Create a direct-indirect luminaire with peaks in both hemispheres
3999    fn create_test_direct_indirect_ldt() -> Eulumdat {
4000        Eulumdat {
4001            symmetry: Symmetry::VerticalAxis,
4002            g_angles: (0..=18).map(|i| i as f64 * 10.0).collect(),
4003            // Strong downward peak at 0°, moderate upward peak at 180°
4004            intensities: vec![vec![
4005                800.0, 700.0, 500.0, 300.0, 150.0, 80.0, 40.0, 20.0, 15.0, // 0-80°
4006                10.0, // 90° (horizontal)
4007                15.0, 20.0, 40.0, 80.0, 150.0, 250.0, 350.0, 400.0, 450.0, // 100-180°
4008            ]],
4009            c_angles: vec![0.0],
4010            downward_flux_fraction: 60.0, // More downward
4011            ..Default::default()
4012        }
4013    }
4014
4015    #[test]
4016    fn test_upward_beam_angle() {
4017        let ldt = create_test_uplight_ldt();
4018
4019        // Upward beam angle should be positive for uplight
4020        let upward_beam = PhotometricCalculations::upward_beam_angle(&ldt);
4021        assert!(
4022            upward_beam > 0.0,
4023            "Upward beam angle should be positive, got {}",
4024            upward_beam
4025        );
4026
4027        // Upward field angle should be >= beam angle
4028        let upward_field = PhotometricCalculations::upward_field_angle(&ldt);
4029        assert!(
4030            upward_field >= upward_beam,
4031            "Field angle {} should be >= beam angle {}",
4032            upward_field,
4033            upward_beam
4034        );
4035
4036        // For pure uplight, the upward peak intensity should be much higher than downward
4037        let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
4038        assert!(
4039            analysis.upward_peak_intensity > analysis.downward_peak_intensity * 10.0,
4040            "Upward peak {} should be >> downward peak {} for uplight",
4041            analysis.upward_peak_intensity,
4042            analysis.downward_peak_intensity
4043        );
4044    }
4045
4046    #[test]
4047    fn test_comprehensive_beam_analysis_uplight() {
4048        let ldt = create_test_uplight_ldt();
4049        let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
4050
4051        // Primary direction should be upward
4052        assert_eq!(
4053            analysis.primary_direction,
4054            LightDirection::Upward,
4055            "Primary direction should be Upward for uplight"
4056        );
4057
4058        // Distribution type should be Indirect
4059        assert_eq!(
4060            analysis.distribution_type,
4061            DistributionType::Indirect,
4062            "Distribution type should be Indirect for uplight"
4063        );
4064
4065        // Upward peak should be higher than downward peak
4066        assert!(
4067            analysis.upward_peak_intensity > analysis.downward_peak_intensity,
4068            "Upward peak {} should be > downward peak {}",
4069            analysis.upward_peak_intensity,
4070            analysis.downward_peak_intensity
4071        );
4072
4073        // Upward beam angle should be positive
4074        assert!(
4075            analysis.upward_beam_angle > 0.0,
4076            "Upward beam angle should be positive, got {}",
4077            analysis.upward_beam_angle
4078        );
4079    }
4080
4081    #[test]
4082    fn test_comprehensive_beam_analysis_direct_indirect() {
4083        let ldt = create_test_direct_indirect_ldt();
4084        let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
4085
4086        // Should have both components
4087        assert!(
4088            analysis.has_downward_component(),
4089            "Should have downward component"
4090        );
4091        assert!(
4092            analysis.has_upward_component(),
4093            "Should have upward component"
4094        );
4095
4096        // Primary direction should be downward (stronger peak)
4097        assert_eq!(
4098            analysis.primary_direction,
4099            LightDirection::Downward,
4100            "Primary direction should be Downward"
4101        );
4102
4103        // Distribution type should be DirectIndirect
4104        assert_eq!(
4105            analysis.distribution_type,
4106            DistributionType::DirectIndirect,
4107            "Distribution type should be DirectIndirect"
4108        );
4109
4110        // Both beam angles should be positive
4111        assert!(
4112            analysis.downward_beam_angle > 0.0,
4113            "Downward beam angle should be positive"
4114        );
4115        assert!(
4116            analysis.upward_beam_angle > 0.0,
4117            "Upward beam angle should be positive"
4118        );
4119    }
4120
4121    #[test]
4122    fn test_downlight_has_no_upward_beam() {
4123        let ldt = create_test_ldt();
4124        let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
4125
4126        // Primary direction should be downward
4127        assert_eq!(
4128            analysis.primary_direction,
4129            LightDirection::Downward,
4130            "Primary direction should be Downward for standard downlight"
4131        );
4132
4133        // Distribution type should be Direct
4134        assert_eq!(
4135            analysis.distribution_type,
4136            DistributionType::Direct,
4137            "Distribution type should be Direct for standard downlight"
4138        );
4139
4140        // Downward beam should be positive
4141        assert!(
4142            analysis.downward_beam_angle > 0.0,
4143            "Downward beam angle should be positive"
4144        );
4145    }
4146}