1use crate::eulumdat::{Eulumdat, Symmetry};
9use crate::type_b_conversion::TypeBConversion;
10use std::f64::consts::PI;
11
12pub struct PhotometricCalculations;
14
15impl PhotometricCalculations {
16 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 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 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 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 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 fn downward_c90_c270(ldt: &Eulumdat, arc: f64) -> f64 {
92 Self::downward_c0_c180(ldt, arc)
94 }
95
96 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 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 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 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 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 let g_prev_rad = g_prev * PI / 180.0;
151 let g_end_rad = g_end * PI / 180.0;
152
153 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 pub fn total_output(ldt: &Eulumdat) -> f64 {
166 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 pub fn calculated_luminous_flux(ldt: &Eulumdat) -> f64 {
185 Self::total_output(ldt) * ldt.conversion_factor
186 }
187
188 pub fn calculate_direct_ratios(ldt: &Eulumdat, shr: &str) -> [f64; 10] {
199 let (e, f, g, h) = Self::get_shr_coefficients(shr);
201
202 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 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 pub fn beam_angle(ldt: &Eulumdat) -> f64 {
277 Self::angle_at_percentage(ldt, 0.5) * 2.0
279 }
280
281 pub fn field_angle(ldt: &Eulumdat) -> f64 {
289 Self::angle_at_percentage(ldt, 0.1) * 2.0
291 }
292
293 pub fn beam_angle_cie(ldt: &Eulumdat) -> f64 {
305 Self::angle_at_percentage_of_center(ldt, 0.5) * 2.0
307 }
308
309 pub fn field_angle_cie(ldt: &Eulumdat) -> f64 {
317 Self::angle_at_percentage_of_center(ldt, 0.1) * 2.0
319 }
320
321 pub fn half_beam_angle(ldt: &Eulumdat) -> f64 {
329 Self::angle_at_percentage(ldt, 0.5)
330 }
331
332 pub fn half_field_angle(ldt: &Eulumdat) -> f64 {
337 Self::angle_at_percentage(ldt, 0.1)
338 }
339
340 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 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 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 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 max_intensity,
378 center_intensity,
379 max_intensity_gamma: max_gamma,
380 is_batwing,
382 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 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 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 for (i, &intensity) in intensities.iter().enumerate() {
408 if intensity < threshold && i > 0 {
409 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 *ldt.g_angles.last().unwrap_or(&0.0)
423 }
424
425 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 return Self::angle_at_percentage(ldt, percentage);
440 }
441
442 let threshold = center_intensity * percentage;
443
444 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 pub fn upward_beam_angle(ldt: &Eulumdat) -> f64 {
468 Self::angle_spread_from_peak(ldt, 0.5, true)
469 }
470
471 pub fn upward_field_angle(ldt: &Eulumdat) -> f64 {
475 Self::angle_spread_from_peak(ldt, 0.1, true)
476 }
477
478 pub fn downward_beam_angle(ldt: &Eulumdat) -> f64 {
485 Self::angle_spread_from_peak(ldt, 0.5, false)
486 }
487
488 pub fn downward_field_angle(ldt: &Eulumdat) -> f64 {
492 Self::angle_spread_from_peak(ldt, 0.1, false)
493 }
494
495 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 let hemisphere_boundary = 90.0;
518
519 let (peak_idx, peak_intensity) = if upward {
521 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 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 let (min_angle, max_angle) = if upward {
549 (hemisphere_boundary, 180.0)
550 } else {
551 (0.0, hemisphere_boundary)
552 };
553
554 let mut angle_low = peak_angle;
556 for i in (0..peak_idx).rev() {
557 let angle = g_angles[i];
558 if angle < min_angle {
560 angle_low = min_angle;
561 break;
562 }
563 let intensity = intensities[i];
564 if intensity < threshold {
565 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 let mut angle_high = peak_angle;
581 for i in (peak_idx + 1)..intensities.len() {
582 let angle = g_angles[i];
583 if angle > max_angle {
585 angle_high = max_angle;
586 break;
587 }
588 let intensity = intensities[i];
589 if intensity < threshold {
590 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 (angle_high - angle_low).abs()
606 }
607
608 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 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 let has_downward = downward_peak > 0.0;
642 let has_upward = upward_peak > 0.0;
643
644 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 let primary_direction = if downward_peak >= upward_peak {
669 LightDirection::Downward
670 } else {
671 LightDirection::Upward
672 };
673
674 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 if downward_peak > upward_peak {
682 DistributionType::DirectIndirect
683 } else {
684 DistributionType::IndirectDirect
685 }
686 } else if has_upward && upward_peak > downward_peak {
687 DistributionType::Indirect
689 } else {
690 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 pub fn ugr_crosssection(ldt: &Eulumdat) -> Vec<(f64, f64)> {
712 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 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 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, n2: flux_60, n3: flux_40, n4: flux_180 - flux_90, n5: flux_120 - flux_90, }
760 }
761
762 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 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 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 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 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 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 let s_h = 2.0 * (half_angle * PI / 180.0).tan();
853
854 s_h.clamp(0.5, 3.0)
856 }
857
858 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 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 let mut low = 0.5;
892 let mut high = 3.0;
893
894 for _ in 0..20 {
895 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; } else {
902 high = mid; }
904 }
905
906 low
907 }
908
909 fn calculate_illuminance_uniformity(ldt: &Eulumdat, c_plane: f64, s_h: f64) -> f64 {
914 const NUM_POINTS: usize = 21;
916 let mut illuminances = [0.0; NUM_POINTS];
917
918 let spacing = s_h;
920
921 for (i, e) in illuminances.iter_mut().enumerate() {
922 let x = (i as f64 / (NUM_POINTS - 1) as f64) * spacing;
924
925 *e += Self::point_illuminance(ldt, c_plane, x, 1.0);
927
928 *e += Self::point_illuminance(ldt, c_plane, spacing - x, 1.0);
930 }
931
932 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 fn point_illuminance(ldt: &Eulumdat, c_plane: f64, x: f64, h: f64) -> f64 {
950 let theta = (x / h).atan();
952 let theta_deg = theta.to_degrees();
953
954 let intensity = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, theta_deg);
956
957 let cos_theta = theta.cos();
960 intensity * cos_theta.powi(3) / (h * h)
961 }
962
963 pub fn spacing_criteria_ies(ldt: &Eulumdat) -> (f64, f64, f64) {
970 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 let sc_diagonal = sc_0_180.min(sc_90_270) * 1.10;
977 (sc_0_180, sc_90_270, sc_diagonal)
978 }
979
980 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 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 pub fn k_factor(ldt: &Eulumdat, room_index: f64, reflectances: (f64, f64, f64)) -> f64 {
1058 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 let (rho_c, rho_w, rho_f) = reflectances;
1065
1066 let avg_reflectance = (rho_c + rho_w + rho_f) / 3.0;
1068 let indirect_factor = avg_reflectance / (1.0 - avg_reflectance);
1069
1070 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 fn room_index_to_idx(room_index: f64) -> usize {
1078 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 }
1088
1089 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 let lb = params.background_luminance();
1117
1118 let mut sum = 0.0;
1119
1120 for pos in ¶ms.luminaire_positions {
1122 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 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 let luminance = intensity * 1000.0 / luminaire_area; let dist = (dx * dx + dy * dy + dz * dz).sqrt();
1146 let omega = luminaire_area / (dist * dist);
1147
1148 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 fn guth_position_index(gamma: f64, h: f64, v: f64) -> f64 {
1165 let t = if v > 0.0 { h / v } else { 1.0 };
1168
1169 let p = 1.0 + (gamma / 90.0).powf(2.0) * t;
1171 p.max(1.0)
1172 }
1173
1174 pub fn cu_table(ldt: &Eulumdat) -> CuTable {
1185 CuTable::calculate(ldt)
1186 }
1187
1188 pub fn ugr_table(ldt: &Eulumdat) -> UgrTable {
1197 UgrTable::calculate(ldt)
1198 }
1199
1200 pub fn candela_tabulation(ldt: &Eulumdat) -> CandelaTabulation {
1209 CandelaTabulation::from_eulumdat(ldt)
1210 }
1211}
1212
1213#[derive(Debug, Clone, Default, PartialEq)]
1222#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1223pub struct PhotometricSummary {
1224 pub total_lamp_flux: f64,
1227 pub calculated_flux: f64,
1229 pub lor: f64,
1231 pub dlor: f64,
1233 pub ulor: f64,
1235
1236 pub lamp_efficacy: f64,
1239 pub luminaire_efficacy: f64,
1241 pub total_wattage: f64,
1243
1244 pub cie_flux_codes: CieFluxCodes,
1247
1248 pub beam_angle: f64,
1251 pub field_angle: f64,
1253
1254 pub beam_angle_cie: f64,
1257 pub field_angle_cie: f64,
1259 pub is_batwing: bool,
1261
1262 pub upward_beam_angle: f64,
1265 pub upward_field_angle: f64,
1267 pub primary_direction: LightDirection,
1269 pub distribution_type: DistributionType,
1271
1272 pub max_intensity: f64,
1275 pub min_intensity: f64,
1277 pub avg_intensity: f64,
1279
1280 pub spacing_c0: f64,
1283 pub spacing_c90: f64,
1285
1286 pub zonal_lumens: ZonalLumens30,
1289}
1290
1291impl PhotometricSummary {
1292 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 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 lamp_efficacy: ldt.luminous_efficacy(),
1307 luminaire_efficacy: PhotometricCalculations::luminaire_efficacy(ldt),
1308 total_wattage: ldt.total_wattage(),
1309
1310 cie_flux_codes: cie_codes,
1312
1313 beam_angle: PhotometricCalculations::beam_angle(ldt),
1315 field_angle: PhotometricCalculations::field_angle(ldt),
1316
1317 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_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 max_intensity: ldt.max_intensity(),
1339 min_intensity: ldt.min_intensity(),
1340 avg_intensity: ldt.avg_intensity(),
1341
1342 spacing_c0: s_c0,
1344 spacing_c90: s_c90,
1345
1346 zonal_lumens: PhotometricCalculations::zonal_lumens_30deg(ldt),
1348 }
1349 }
1350
1351 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 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 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#[derive(Debug, Clone, Default)]
1499pub struct GldfPhotometricData {
1500 pub cie_flux_code: String,
1502 pub light_output_ratio: f64,
1504 pub luminous_efficacy: f64,
1506 pub downward_flux_fraction: f64,
1508 pub downward_light_output_ratio: f64,
1510 pub upward_light_output_ratio: f64,
1512 pub luminaire_luminance: f64,
1514 pub cut_off_angle: f64,
1516 pub ugr_4h_8h_705020: Option<UgrTableValues>,
1518 pub photometric_code: String,
1520 pub tenth_peak_divergence: (f64, f64),
1522 pub half_peak_divergence: (f64, f64),
1524 pub light_distribution_bug_rating: String,
1526}
1527
1528#[derive(Debug, Clone, Default)]
1530pub struct UgrTableValues {
1531 pub crosswise: f64,
1533 pub endwise: f64,
1535}
1536
1537#[derive(Debug, Clone, Default)]
1543pub struct IesMetadata {
1544 pub version: String,
1546 pub test_report: String,
1548 pub test_lab: String,
1550 pub issue_date: String,
1552 pub manufacturer: String,
1554 pub luminaire_catalog: String,
1556 pub lamp_catalog: String,
1558 pub ballast: String,
1560
1561 pub file_generation_type: String,
1563 pub is_accredited: bool,
1565 pub is_scaled: bool,
1567 pub is_interpolated: bool,
1569 pub is_simulation: bool,
1571
1572 pub luminous_shape: String,
1574 pub luminous_width: f64,
1576 pub luminous_length: f64,
1578 pub luminous_height: f64,
1580 pub is_rectangular: bool,
1582 pub is_circular: bool,
1584
1585 pub photometric_type: String,
1587 pub unit_type: String,
1589
1590 pub has_tilt_data: bool,
1592 pub tilt_lamp_geometry: i32,
1594 pub tilt_angle_count: usize,
1596
1597 pub maintenance_category: Option<i32>,
1599 pub ballast_factor: f64,
1601 pub input_watts: f64,
1603 pub num_lamps: i32,
1605 pub lumens_per_lamp: f64,
1607 pub is_absolute_photometry: bool,
1609}
1610
1611impl IesMetadata {
1612 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 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 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 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 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 let luminance = PhotometricCalculations::luminaire_luminance(ldt, 65.0);
1771
1772 let cut_off = PhotometricCalculations::cut_off_angle(ldt);
1774
1775 let ugr_values = PhotometricCalculations::ugr_table_values(ldt);
1777
1778 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 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 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
1931impl PhotometricCalculations {
1936 pub fn beam_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
1940 Self::angle_at_percentage_for_plane(ldt, c_plane, 0.5) * 2.0
1942 }
1943
1944 pub fn field_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
1948 Self::angle_at_percentage_for_plane(ldt, c_plane, 0.1) * 2.0
1950 }
1951
1952 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 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 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 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 pub fn luminaire_luminance(ldt: &Eulumdat, viewing_angle: f64) -> f64 {
2005 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 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 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 let total_flux = ldt.total_luminous_flux();
2030 let actual_intensity = avg_intensity * total_flux / 1000.0;
2031
2032 actual_intensity / projected_area
2034 }
2035
2036 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 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 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 let h = 2.5;
2070 let room_width = 4.0 * h; let room_length = 8.0 * h; let rho_c = 0.70;
2075 let rho_w = 0.50;
2076 let rho_f = 0.20;
2077
2078 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, 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 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, 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, ¶ms_cross);
2117 let ugr_end = Self::ugr(ldt, ¶ms_end);
2118
2119 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 pub fn photometric_code(ldt: &Eulumdat) -> String {
2136 let dlor = ldt.downward_flux_fraction;
2137
2138 let dist_type = if dlor >= 90.0 {
2140 "D" } else if dlor >= 60.0 {
2142 "SD" } else if dlor >= 40.0 {
2144 "GD" } else if dlor >= 10.0 {
2146 "SI" } else {
2148 "I" };
2150
2151 let beam_angle = Self::beam_angle(ldt);
2153 let beam_class = if beam_angle < 40.0 {
2154 "VN" } else if beam_angle < 60.0 {
2156 "N" } else if beam_angle < 90.0 {
2158 "M" } else if beam_angle < 120.0 {
2160 "W" } else {
2162 "VW" };
2164
2165 format!("{}-{}", dist_type, beam_class)
2166 }
2167}
2168
2169#[derive(Debug, Clone, Copy, Default, PartialEq)]
2181pub struct BeamFieldAnalysis {
2182 pub beam_angle_ies: f64,
2185 pub field_angle_ies: f64,
2187
2188 pub beam_angle_cie: f64,
2191 pub field_angle_cie: f64,
2193
2194 pub max_intensity: f64,
2197 pub center_intensity: f64,
2199 pub max_intensity_gamma: f64,
2201
2202 pub is_batwing: bool,
2205
2206 pub beam_threshold_ies: f64,
2209 pub beam_threshold_cie: f64,
2211 pub field_threshold_ies: f64,
2213 pub field_threshold_cie: f64,
2215}
2216
2217impl BeamFieldAnalysis {
2218 pub fn beam_angle_difference(&self) -> f64 {
2223 self.beam_angle_cie - self.beam_angle_ies
2224 }
2225
2226 pub fn field_angle_difference(&self) -> f64 {
2228 self.field_angle_cie - self.field_angle_ies
2229 }
2230
2231 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2286#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2287pub enum LightDirection {
2288 #[default]
2290 Downward,
2291 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2306#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2307pub enum DistributionType {
2308 #[default]
2310 Direct,
2311 Indirect,
2313 DirectIndirect,
2315 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
2335pub struct ComprehensiveBeamAnalysis {
2336 pub downward_beam_angle: f64,
2339 pub downward_field_angle: f64,
2341 pub downward_peak_intensity: f64,
2343 pub downward_peak_gamma: f64,
2345
2346 pub upward_beam_angle: f64,
2349 pub upward_field_angle: f64,
2351 pub upward_peak_intensity: f64,
2353 pub upward_peak_gamma: f64,
2355
2356 pub primary_direction: LightDirection,
2358 pub distribution_type: DistributionType,
2360}
2361
2362impl ComprehensiveBeamAnalysis {
2363 pub fn has_upward_component(&self) -> bool {
2365 self.upward_peak_intensity > 0.0 && self.upward_beam_angle > 0.0
2366 }
2367
2368 pub fn has_downward_component(&self) -> bool {
2370 self.downward_peak_intensity > 0.0 && self.downward_beam_angle > 0.0
2371 }
2372
2373 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
2423#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2424pub struct CieFluxCodes {
2425 pub n1: f64,
2427 pub n2: f64,
2429 pub n3: f64,
2431 pub n4: f64,
2433 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
2453#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2454pub struct ZonalLumens30 {
2455 pub zone_0_30: f64,
2457 pub zone_30_60: f64,
2459 pub zone_60_90: f64,
2461 pub zone_90_120: f64,
2463 pub zone_120_150: f64,
2465 pub zone_150_180: f64,
2467}
2468
2469impl ZonalLumens30 {
2470 pub fn downward_total(&self) -> f64 {
2472 self.zone_0_30 + self.zone_30_60 + self.zone_60_90
2473 }
2474
2475 pub fn upward_total(&self) -> f64 {
2477 self.zone_90_120 + self.zone_120_150 + self.zone_150_180
2478 }
2479}
2480
2481#[derive(Debug, Clone)]
2483pub struct UgrParams {
2484 pub room_length: f64,
2486 pub room_width: f64,
2488 pub mounting_height: f64,
2490 pub eye_height: f64,
2492 pub observer_x: f64,
2494 pub observer_y: f64,
2496 pub luminaire_positions: Vec<(f64, f64)>,
2498 pub rho_ceiling: f64,
2500 pub rho_wall: f64,
2502 pub rho_floor: f64,
2504 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 pub fn background_luminance(&self) -> f64 {
2529 let avg_rho = (self.rho_ceiling + self.rho_wall + self.rho_floor) / 3.0;
2531 self.illuminance * avg_rho / PI
2532 }
2533
2534 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
2552pub const CU_REFLECTANCES: [(u8, u8, u8); 18] = [
2559 (80, 70, 20),
2561 (80, 50, 20),
2562 (80, 30, 20),
2563 (80, 10, 20),
2564 (70, 70, 20),
2566 (70, 50, 20),
2567 (70, 30, 20),
2568 (70, 10, 20),
2569 (50, 50, 20),
2571 (50, 30, 20),
2572 (50, 10, 20),
2573 (30, 50, 20),
2575 (30, 30, 20),
2576 (30, 10, 20),
2577 (10, 50, 20),
2579 (10, 30, 20),
2580 (10, 10, 20),
2581 (0, 0, 20),
2583];
2584
2585pub const CU_RCR_VALUES: [u8; 11] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
2587
2588#[derive(Debug, Clone)]
2593pub struct CuTable {
2594 pub floor_reflectance: f64,
2596 pub values: Vec<Vec<f64>>,
2599 pub reflectances: Vec<(u8, u8, u8)>,
2601 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 pub fn calculate(ldt: &Eulumdat) -> Self {
2620 let mut table = Self::default();
2621
2622 for &rcr in &CU_RCR_VALUES {
2624 let mut row = Vec::new();
2625
2626 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 fn calculate_cu(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2648 Self::calculate_cu_ies(ldt, rcr, rho_c, rho_w, rho_f)
2650 }
2651
2652 fn calculate_cu_ies(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2659 let downward_fraction = PhotometricCalculations::downward_flux(ldt, 90.0) / 100.0;
2661 let upward_fraction = 1.0 - downward_fraction;
2662
2663 let direct_ratio = Self::calculate_direct_ratio_ies(ldt, rcr);
2667
2668 let cu_base = direct_ratio * downward_fraction;
2670
2671 let wall_light = downward_fraction * (1.0 - direct_ratio);
2673
2674 let ceiling_view_factor = 1.0 / (1.0 + rcr * 0.18);
2684
2685 let light_to_ceiling = upward_fraction + rho_f * downward_fraction * 0.5;
2687
2688 let ceiling_efficiency = 0.25;
2695 let cu_ceiling = light_to_ceiling * rho_c * ceiling_efficiency * ceiling_view_factor;
2696
2697 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 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 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 let cu_total = (first_order + cu_multi) * 100.0;
2718
2719 cu_total.clamp(0.0, 150.0)
2721 }
2722
2723 fn calculate_direct_ratio_ies(ldt: &Eulumdat, rcr: f64) -> f64 {
2727 let cutoff_angle = if rcr > 0.1 {
2740 (5.0 / rcr).atan().to_degrees()
2741 } else {
2742 89.0 };
2744
2745 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 fn effective_room_reflectance(rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2758 let wall_weight = (rcr / 5.0).min(1.0);
2763 let floor_ceiling_weight = 1.0 - wall_weight;
2764
2765 rho_w * wall_weight + (rho_c + rho_f) / 2.0 * floor_ceiling_weight
2767 }
2768
2769 fn transfer_factor(rcr: f64, rho_ceiling: f64) -> f64 {
2771 let geometric_factor = 1.0 / (1.0 + rcr * 0.15);
2775 geometric_factor * (1.0 + rho_ceiling * 0.3)
2776 }
2777
2778 fn wall_contribution_factor(rcr: f64, rho_wall: f64) -> f64 {
2780 let wall_area_factor = (rcr / 10.0).min(0.8);
2782 wall_area_factor * rho_wall
2783 }
2784
2785 #[allow(dead_code)]
2789 fn calculate_cu_simple(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
2790 let direct = Self::direct_component_simple(ldt, rcr);
2792
2793 let reflected = Self::reflected_component_simple(rcr, rho_c, rho_w, rho_f);
2795
2796 (direct + reflected) * 100.0
2798 }
2799
2800 fn direct_component_simple(ldt: &Eulumdat, rcr: f64) -> f64 {
2802 let ratios = PhotometricCalculations::calculate_direct_ratios(ldt, "1.00");
2804
2805 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 let k = if rcr > 0.0 {
2812 5.0 / (rcr + 0.1)
2813 } else {
2814 10.0 };
2816
2817 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 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 fn reflected_component_simple(rcr: f64, rho_c: f64, rho_w: f64, _rho_f: f64) -> f64 {
2837 let cavity_factor = if rcr > 0.0 {
2842 1.0 / (1.0 + rcr * 0.1)
2843 } else {
2844 1.0
2845 };
2846
2847 let avg_rho = (rho_c + rho_w * 0.5) * 0.5;
2849
2850 avg_rho * cavity_factor * 0.2 }
2852
2853 pub fn calculate_sophisticated(ldt: &Eulumdat) -> Self {
2861 Self::calculate(ldt) }
2863
2864 pub fn calculate_simple(ldt: &Eulumdat) -> Self {
2868 let mut table = Self::default();
2869
2870 for &rcr in &CU_RCR_VALUES {
2872 let mut row = Vec::new();
2873
2874 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 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 s.push_str("RC 80 70 50 30 10 0\n");
2903 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 for (i, &rcr) in self.rcr_values.iter().enumerate() {
2908 s.push_str(&format!("{:2} ", rcr));
2909
2910 for j in 0..4 {
2913 s.push_str(&format!("{:3.0}", self.values[i][j]));
2914 }
2915 s.push(' ');
2916
2917 for j in 4..8 {
2919 s.push_str(&format!("{:3.0}", self.values[i][j]));
2920 }
2921 s.push(' ');
2922
2923 for j in 8..11 {
2925 s.push_str(&format!("{:3.0}", self.values[i][j]));
2926 }
2927 s.push(' ');
2928
2929 for j in 11..14 {
2931 s.push_str(&format!("{:3.0}", self.values[i][j]));
2932 }
2933 s.push(' ');
2934
2935 for j in 14..17 {
2937 s.push_str(&format!("{:3.0}", self.values[i][j]));
2938 }
2939 s.push(' ');
2940
2941 s.push_str(&format!("{:3.0}", self.values[i][17]));
2943
2944 s.push('\n');
2945 }
2946
2947 s
2948 }
2949}
2950
2951pub 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
2978pub 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#[derive(Debug, Clone)]
2993pub struct UgrTable {
2994 pub crosswise: Vec<Vec<f64>>,
2996 pub endwise: Vec<Vec<f64>>,
2998 pub room_sizes: Vec<(f64, f64)>,
3000 pub reflectances: Vec<(u8, u8, u8)>,
3002 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 pub fn calculate(ldt: &Eulumdat) -> Self {
3021 let mut table = Self::default();
3022 let mut max_ugr = 0.0_f64;
3023
3024 let luminous_area = (ldt.luminous_area_length * ldt.luminous_area_width).max(1.0) / 1e6; let total_flux = ldt.total_luminous_flux().max(1.0);
3027
3028 for &(x, y) in &UGR_ROOM_SIZES {
3030 let mut crosswise_row = Vec::new();
3031 let mut endwise_row = Vec::new();
3032
3033 for &(rc, rw, rf) in &UGR_REFLECTANCES {
3035 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, );
3047 crosswise_row.push(ugr_cross);
3048 max_ugr = max_ugr.max(ugr_cross);
3049
3050 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, );
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 #[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 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 let proj_area = luminous_area * angle.to_radians().cos().max(0.01);
3114 let luminance = intensity_cd / proj_area;
3115
3116 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 let room_area = x_h * y_h;
3127 let rcr = 5.0 * (x_h + y_h) / room_area;
3128
3129 let spacing = 1.5;
3131 let n_lum = ((x_h / spacing).ceil() * (y_h / spacing).ceil()).max(1.0);
3132
3133 let cu = 0.8 / (1.0 + rcr * 0.1);
3135 let illuminance = n_lum * total_flux * cu / room_area;
3136
3137 let lb = illuminance * (rho_c * 0.4 + rho_w * 0.6) / PI;
3140 let lb = lb.max(20.0); let view_angle = 65.0_f64.to_radians();
3145 let h_ratio = view_angle.tan(); let proj_area = luminous_area * view_angle.cos();
3149 let distance_sq = 1.0 + h_ratio * h_ratio; let omega = proj_area / distance_sq;
3151
3152 let room_index = (x_h * y_h).sqrt();
3157 let p = (1.2 + room_index * 0.5).clamp(1.5, 12.0);
3158
3159 let glare_term = l_avg * l_avg * omega / (p * p);
3161 let ugr_raw = 8.0 * (0.25 * glare_term / lb).log10();
3162
3163 let n_visible = (n_lum * 0.7).max(1.0); let ugr_multi = ugr_raw + 8.0 * n_visible.log10();
3167
3168 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 let ugr_calibrated = ugr_corrected + 3.0;
3183
3184 ((ugr_calibrated * 10.0).round() / 10.0).clamp(10.0, 34.0)
3185 }
3186
3187 #[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 let room_area = x_h * y_h;
3208 let illuminance = total_flux * 0.5 / room_area; let lb = (illuminance * (rho_c + rho_w) * 0.5 / PI).max(10.0);
3210
3211 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); let omega = luminous_area / 5.0; let p = 2.0; let ugr = 8.0 * (0.25 * luminance * luminance * omega / (p * p * lb)).log10();
3221
3222 ugr.clamp(10.0, 34.0)
3223 }
3224
3225 pub fn calculate_sophisticated(ldt: &Eulumdat) -> Self {
3233 Self::calculate(ldt) }
3235
3236 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 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 } else {
3252 0.09 };
3254
3255 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 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 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 for j in 0..5 {
3333 s.push_str(&format!("{:5.1}", self.crosswise[i][j]));
3334 }
3335 s.push_str(" ");
3336
3337 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#[derive(Debug, Clone, Default)]
3355pub struct CandelaEntry {
3356 pub c_plane: f64,
3358 pub gamma: f64,
3360 pub candela: f64,
3362}
3363
3364#[derive(Debug, Clone, Default)]
3369pub struct CandelaTabulation {
3370 pub entries: Vec<CandelaEntry>,
3372 pub c_planes: Vec<f64>,
3374 pub g_angles: Vec<f64>,
3376 pub max_candela: f64,
3378 pub max_angle: (f64, f64),
3380 pub total_flux: f64,
3382}
3383
3384impl CandelaTabulation {
3385 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; 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 pub fn to_text(&self) -> String {
3437 let mut s = String::new();
3438 s.push_str("CANDELA TABULATION\n\n");
3439
3440 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 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 pub fn estimated_pages(&self, entries_per_page: usize) -> usize {
3482 self.entries.len().div_ceil(entries_per_page)
3483 }
3484}
3485
3486#[derive(Debug, Clone, PartialEq)]
3502#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
3503pub struct NemaClassification {
3504 pub horizontal_spread: f64,
3506 pub vertical_spread: f64,
3508 pub horizontal_type: u8,
3510 pub vertical_type: u8,
3512 pub i_max: f64,
3514 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 pub fn nema_classification(ldt: &Eulumdat) -> NemaClassification {
3531 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 let horizontal_spread = Self::scan_spread(ldt, threshold, step, true);
3558
3559 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 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 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 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 fn nema_type_from_spread(spread: f64) -> u8 {
3618 if spread < 18.0 {
3619 1 } 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 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 assert!(flux_90 <= flux_180 + 0.001);
3677 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 assert!(beam > 0.0 && beam <= 180.0, "Beam angle was {}", beam);
3688
3689 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 for ratio in &ratios {
3704 assert!(*ratio >= 0.0 && *ratio <= 1.0);
3705 }
3706
3707 for i in 1..10 {
3710 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 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 assert!(codes.n3 <= codes.n2, "N3 should be <= N2");
3734 assert!(codes.n2 <= codes.n1, "N2 should be <= N1");
3735
3736 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; let lamp_efficacy = ldt.luminous_efficacy();
3747 let luminaire_efficacy = PhotometricCalculations::luminaire_efficacy(&ldt);
3748
3749 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 assert!((0.5..=3.0).contains(&s_h), "S/H was {}", s_h);
3762
3763 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 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 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 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 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; ldt.width = 600.0; 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, ¶ms);
3816
3817 assert!(ugr >= 0.0, "UGR should be >= 0, got {}", ugr);
3819 }
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 assert_eq!(gldf.light_output_ratio, 85.0);
3847 assert_eq!(gldf.downward_flux_fraction, 95.0);
3848
3849 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 assert!(!gldf.photometric_code.is_empty());
3856 assert!(gldf.photometric_code.contains('-'));
3857
3858 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 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 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 assert!(summary.lamp_efficacy > 0.0);
3887 assert!(summary.luminaire_efficacy > 0.0);
3888 assert!(summary.luminaire_efficacy <= summary.lamp_efficacy);
3889
3890 assert!(summary.beam_angle > 0.0);
3892 assert!(summary.field_angle > 0.0);
3893
3894 let text = summary.to_text();
3896 assert!(text.contains("PHOTOMETRIC SUMMARY"));
3897 assert!(text.contains("CIE Flux Code"));
3898
3899 let compact = summary.to_compact();
3901 assert!(compact.contains("CIE:"));
3902 assert!(compact.contains("Beam:"));
3903
3904 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 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 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 assert!(cu.values[0][0] > 0.0, "CU at RCR=0 should be positive");
3929
3930 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 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 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 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 assert!(!tab.entries.is_empty());
3970
3971 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 fn create_test_uplight_ldt() -> Eulumdat {
3981 Eulumdat {
3982 symmetry: Symmetry::VerticalAxis,
3983 g_angles: (0..=18).map(|i| i as f64 * 10.0).collect(),
3985 intensities: vec![vec![
3988 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0, ]],
3992 c_angles: vec![0.0],
3993 downward_flux_fraction: 5.0, ..Default::default()
3995 }
3996 }
3997
3998 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 intensities: vec![vec![
4005 800.0, 700.0, 500.0, 300.0, 150.0, 80.0, 40.0, 20.0, 15.0, 10.0, 15.0, 20.0, 40.0, 80.0, 150.0, 250.0, 350.0, 400.0, 450.0, ]],
4009 c_angles: vec![0.0],
4010 downward_flux_fraction: 60.0, ..Default::default()
4012 }
4013 }
4014
4015 #[test]
4016 fn test_upward_beam_angle() {
4017 let ldt = create_test_uplight_ldt();
4018
4019 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 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 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 assert_eq!(
4053 analysis.primary_direction,
4054 LightDirection::Upward,
4055 "Primary direction should be Upward for uplight"
4056 );
4057
4058 assert_eq!(
4060 analysis.distribution_type,
4061 DistributionType::Indirect,
4062 "Distribution type should be Indirect for uplight"
4063 );
4064
4065 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 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 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 assert_eq!(
4098 analysis.primary_direction,
4099 LightDirection::Downward,
4100 "Primary direction should be Downward"
4101 );
4102
4103 assert_eq!(
4105 analysis.distribution_type,
4106 DistributionType::DirectIndirect,
4107 "Distribution type should be DirectIndirect"
4108 );
4109
4110 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 assert_eq!(
4128 analysis.primary_direction,
4129 LightDirection::Downward,
4130 "Primary direction should be Downward for standard downlight"
4131 );
4132
4133 assert_eq!(
4135 analysis.distribution_type,
4136 DistributionType::Direct,
4137 "Distribution type should be Direct for standard downlight"
4138 );
4139
4140 assert!(
4142 analysis.downward_beam_angle > 0.0,
4143 "Downward beam angle should be positive"
4144 );
4145 }
4146}