eulumdat/
types.rs

1//! Core types for Python bindings
2
3use pyo3::exceptions::PyValueError;
4use pyo3::prelude::*;
5
6use ::eulumdat as core;
7use core::{
8    diagram::{
9        ButterflyDiagram as CoreButterflyDiagram, CartesianDiagram as CoreCartesianDiagram,
10        HeatmapDiagram as CoreHeatmapDiagram, PolarDiagram as CorePolarDiagram,
11    },
12    BugDiagram as CoreBugDiagram, IesExporter, IesParser,
13};
14
15use crate::{
16    bug_rating::{BugRating, ZoneLumens},
17    calculations::{
18        CieFluxCodes, GldfPhotometricData, PhotometricCalcs, PhotometricSummary, UgrParams,
19        ZonalLumens30,
20    },
21    diagram::SvgTheme,
22    error::to_py_err,
23    validation::ValidationWarning,
24};
25
26/// Type indicator for the luminaire.
27#[pyclass(eq, eq_int)]
28#[derive(Clone, Copy, PartialEq, Eq)]
29pub enum TypeIndicator {
30    /// Point source with symmetry about the vertical axis (Ityp = 1)
31    PointSourceSymmetric = 1,
32    /// Linear luminaire (Ityp = 2)
33    Linear = 2,
34    /// Point source with any other symmetry (Ityp = 3)
35    PointSourceOther = 3,
36}
37
38#[pymethods]
39impl TypeIndicator {
40    /// Create from integer value (1-3).
41    #[staticmethod]
42    fn from_int(value: i32) -> PyResult<Self> {
43        match value {
44            1 => Ok(Self::PointSourceSymmetric),
45            2 => Ok(Self::Linear),
46            3 => Ok(Self::PointSourceOther),
47            _ => Err(PyValueError::new_err(format!(
48                "Invalid type indicator: {} (must be 1-3)",
49                value
50            ))),
51        }
52    }
53
54    /// Convert to integer value.
55    fn as_int(&self) -> i32 {
56        *self as i32
57    }
58
59    fn __repr__(&self) -> String {
60        match self {
61            Self::PointSourceSymmetric => "TypeIndicator.PointSourceSymmetric".to_string(),
62            Self::Linear => "TypeIndicator.Linear".to_string(),
63            Self::PointSourceOther => "TypeIndicator.PointSourceOther".to_string(),
64        }
65    }
66}
67
68impl From<core::TypeIndicator> for TypeIndicator {
69    fn from(t: core::TypeIndicator) -> Self {
70        match t {
71            core::TypeIndicator::PointSourceSymmetric => Self::PointSourceSymmetric,
72            core::TypeIndicator::Linear => Self::Linear,
73            core::TypeIndicator::PointSourceOther => Self::PointSourceOther,
74        }
75    }
76}
77
78impl From<TypeIndicator> for core::TypeIndicator {
79    fn from(t: TypeIndicator) -> Self {
80        match t {
81            TypeIndicator::PointSourceSymmetric => Self::PointSourceSymmetric,
82            TypeIndicator::Linear => Self::Linear,
83            TypeIndicator::PointSourceOther => Self::PointSourceOther,
84        }
85    }
86}
87
88/// Symmetry indicator for the luminaire.
89#[pyclass(eq, eq_int)]
90#[derive(Clone, Copy, PartialEq, Eq)]
91pub enum Symmetry {
92    /// No symmetry (Isym = 0) - full 360° data required
93    None = 0,
94    /// Symmetry about the vertical axis (Isym = 1) - only 1 C-plane needed
95    VerticalAxis = 1,
96    /// Symmetry to plane C0-C180 (Isym = 2) - half the C-planes needed
97    PlaneC0C180 = 2,
98    /// Symmetry to plane C90-C270 (Isym = 3) - half the C-planes needed
99    PlaneC90C270 = 3,
100    /// Symmetry to both planes (Isym = 4) - quarter C-planes needed
101    BothPlanes = 4,
102}
103
104#[pymethods]
105impl Symmetry {
106    /// Create from integer value (0-4).
107    #[staticmethod]
108    fn from_int(value: i32) -> PyResult<Self> {
109        match value {
110            0 => Ok(Self::None),
111            1 => Ok(Self::VerticalAxis),
112            2 => Ok(Self::PlaneC0C180),
113            3 => Ok(Self::PlaneC90C270),
114            4 => Ok(Self::BothPlanes),
115            _ => Err(PyValueError::new_err(format!(
116                "Invalid symmetry: {} (must be 0-4)",
117                value
118            ))),
119        }
120    }
121
122    /// Convert to integer value.
123    fn as_int(&self) -> i32 {
124        *self as i32
125    }
126
127    /// Get human-readable description.
128    fn description(&self) -> &'static str {
129        match self {
130            Self::None => "no symmetry",
131            Self::VerticalAxis => "symmetry about the vertical axis",
132            Self::PlaneC0C180 => "symmetry to plane C0-C180",
133            Self::PlaneC90C270 => "symmetry to plane C90-C270",
134            Self::BothPlanes => "symmetry to plane C0-C180 and to plane C90-C270",
135        }
136    }
137
138    fn __repr__(&self) -> String {
139        match self {
140            Self::None => "Symmetry.None".to_string(),
141            Self::VerticalAxis => "Symmetry.VerticalAxis".to_string(),
142            Self::PlaneC0C180 => "Symmetry.PlaneC0C180".to_string(),
143            Self::PlaneC90C270 => "Symmetry.PlaneC90C270".to_string(),
144            Self::BothPlanes => "Symmetry.BothPlanes".to_string(),
145        }
146    }
147}
148
149impl From<core::Symmetry> for Symmetry {
150    fn from(s: core::Symmetry) -> Self {
151        match s {
152            core::Symmetry::None => Self::None,
153            core::Symmetry::VerticalAxis => Self::VerticalAxis,
154            core::Symmetry::PlaneC0C180 => Self::PlaneC0C180,
155            core::Symmetry::PlaneC90C270 => Self::PlaneC90C270,
156            core::Symmetry::BothPlanes => Self::BothPlanes,
157        }
158    }
159}
160
161impl From<Symmetry> for core::Symmetry {
162    fn from(s: Symmetry) -> Self {
163        match s {
164            Symmetry::None => Self::None,
165            Symmetry::VerticalAxis => Self::VerticalAxis,
166            Symmetry::PlaneC0C180 => Self::PlaneC0C180,
167            Symmetry::PlaneC90C270 => Self::PlaneC90C270,
168            Symmetry::BothPlanes => Self::BothPlanes,
169        }
170    }
171}
172
173/// Lamp set configuration.
174#[pyclass]
175#[derive(Clone)]
176pub struct LampSet {
177    /// Number of lamps in this set.
178    #[pyo3(get, set)]
179    pub num_lamps: i32,
180    /// Type of lamps (description string).
181    #[pyo3(get, set)]
182    pub lamp_type: String,
183    /// Total luminous flux of this lamp set in lumens.
184    #[pyo3(get, set)]
185    pub total_luminous_flux: f64,
186    /// Color appearance / color temperature.
187    #[pyo3(get, set)]
188    pub color_appearance: String,
189    /// Color rendering group / CRI.
190    #[pyo3(get, set)]
191    pub color_rendering_group: String,
192    /// Wattage including ballast in watts.
193    #[pyo3(get, set)]
194    pub wattage_with_ballast: f64,
195}
196
197#[pymethods]
198impl LampSet {
199    #[new]
200    #[pyo3(signature = (num_lamps=1, lamp_type="".to_string(), total_luminous_flux=0.0, color_appearance="".to_string(), color_rendering_group="".to_string(), wattage_with_ballast=0.0))]
201    fn new(
202        num_lamps: i32,
203        lamp_type: String,
204        total_luminous_flux: f64,
205        color_appearance: String,
206        color_rendering_group: String,
207        wattage_with_ballast: f64,
208    ) -> Self {
209        Self {
210            num_lamps,
211            lamp_type,
212            total_luminous_flux,
213            color_appearance,
214            color_rendering_group,
215            wattage_with_ballast,
216        }
217    }
218
219    fn __repr__(&self) -> String {
220        format!(
221            "LampSet(num_lamps={}, lamp_type='{}', flux={:.1} lm, wattage={:.1} W)",
222            self.num_lamps, self.lamp_type, self.total_luminous_flux, self.wattage_with_ballast
223        )
224    }
225}
226
227impl From<&core::LampSet> for LampSet {
228    fn from(ls: &core::LampSet) -> Self {
229        Self {
230            num_lamps: ls.num_lamps,
231            lamp_type: ls.lamp_type.clone(),
232            total_luminous_flux: ls.total_luminous_flux,
233            color_appearance: ls.color_appearance.clone(),
234            color_rendering_group: ls.color_rendering_group.clone(),
235            wattage_with_ballast: ls.wattage_with_ballast,
236        }
237    }
238}
239
240impl From<&LampSet> for core::LampSet {
241    fn from(ls: &LampSet) -> Self {
242        Self {
243            num_lamps: ls.num_lamps,
244            lamp_type: ls.lamp_type.clone(),
245            total_luminous_flux: ls.total_luminous_flux,
246            color_appearance: ls.color_appearance.clone(),
247            color_rendering_group: ls.color_rendering_group.clone(),
248            wattage_with_ballast: ls.wattage_with_ballast,
249        }
250    }
251}
252
253/// Main EULUMDAT data structure.
254///
255/// This class contains all data from an EULUMDAT (LDT) file.
256#[pyclass]
257pub struct Eulumdat {
258    inner: core::Eulumdat,
259}
260
261#[pymethods]
262impl Eulumdat {
263    /// Create a new empty Eulumdat structure.
264    #[new]
265    fn new() -> Self {
266        Self {
267            inner: core::Eulumdat::new(),
268        }
269    }
270
271    /// Parse from a string containing LDT data.
272    #[staticmethod]
273    fn parse(content: &str) -> PyResult<Self> {
274        core::Eulumdat::parse(content)
275            .map(|inner| Self { inner })
276            .map_err(to_py_err)
277    }
278
279    /// Load from a file path.
280    #[staticmethod]
281    fn from_file(path: &str) -> PyResult<Self> {
282        core::Eulumdat::from_file(path)
283            .map(|inner| Self { inner })
284            .map_err(to_py_err)
285    }
286
287    /// Parse from IES format string.
288    #[staticmethod]
289    fn parse_ies(content: &str) -> PyResult<Self> {
290        IesParser::parse(content)
291            .map(|inner| Self { inner })
292            .map_err(to_py_err)
293    }
294
295    /// Load from an IES file path.
296    #[staticmethod]
297    fn from_ies_file(path: &str) -> PyResult<Self> {
298        IesParser::parse_file(path)
299            .map(|inner| Self { inner })
300            .map_err(to_py_err)
301    }
302
303    /// Convert to LDT format string.
304    fn to_ldt(&self) -> String {
305        self.inner.to_ldt()
306    }
307
308    /// Export to IES format string.
309    fn to_ies(&self) -> String {
310        IesExporter::export(&self.inner)
311    }
312
313    /// Save to a file path.
314    fn save(&self, path: &str) -> PyResult<()> {
315        self.inner.save(path).map_err(to_py_err)
316    }
317
318    /// Validate the data and return any warnings.
319    fn validate(&self) -> Vec<ValidationWarning> {
320        self.inner
321            .validate()
322            .into_iter()
323            .map(|w| ValidationWarning {
324                code: w.code.to_string(),
325                message: w.message,
326            })
327            .collect()
328    }
329
330    // === Identification ===
331
332    /// Identification string.
333    #[getter]
334    fn identification(&self) -> &str {
335        &self.inner.identification
336    }
337
338    #[setter]
339    fn set_identification(&mut self, value: String) {
340        self.inner.identification = value;
341    }
342
343    // === Type and Symmetry ===
344
345    /// Type indicator.
346    #[getter]
347    fn type_indicator(&self) -> TypeIndicator {
348        self.inner.type_indicator.into()
349    }
350
351    #[setter]
352    fn set_type_indicator(&mut self, value: TypeIndicator) {
353        self.inner.type_indicator = value.into();
354    }
355
356    /// Symmetry indicator.
357    #[getter]
358    fn symmetry(&self) -> Symmetry {
359        self.inner.symmetry.into()
360    }
361
362    #[setter]
363    fn set_symmetry(&mut self, value: Symmetry) {
364        self.inner.symmetry = value.into();
365    }
366
367    // === Grid Definition ===
368
369    /// Number of C-planes.
370    #[getter]
371    fn num_c_planes(&self) -> usize {
372        self.inner.num_c_planes
373    }
374
375    #[setter]
376    fn set_num_c_planes(&mut self, value: usize) {
377        self.inner.num_c_planes = value;
378    }
379
380    /// Distance between C-planes in degrees.
381    #[getter]
382    fn c_plane_distance(&self) -> f64 {
383        self.inner.c_plane_distance
384    }
385
386    #[setter]
387    fn set_c_plane_distance(&mut self, value: f64) {
388        self.inner.c_plane_distance = value;
389    }
390
391    /// Number of gamma angles.
392    #[getter]
393    fn num_g_planes(&self) -> usize {
394        self.inner.num_g_planes
395    }
396
397    #[setter]
398    fn set_num_g_planes(&mut self, value: usize) {
399        self.inner.num_g_planes = value;
400    }
401
402    /// Distance between gamma angles in degrees.
403    #[getter]
404    fn g_plane_distance(&self) -> f64 {
405        self.inner.g_plane_distance
406    }
407
408    #[setter]
409    fn set_g_plane_distance(&mut self, value: f64) {
410        self.inner.g_plane_distance = value;
411    }
412
413    // === Metadata ===
414
415    /// Measurement report number.
416    #[getter]
417    fn measurement_report_number(&self) -> &str {
418        &self.inner.measurement_report_number
419    }
420
421    #[setter]
422    fn set_measurement_report_number(&mut self, value: String) {
423        self.inner.measurement_report_number = value;
424    }
425
426    /// Luminaire name.
427    #[getter]
428    fn luminaire_name(&self) -> &str {
429        &self.inner.luminaire_name
430    }
431
432    #[setter]
433    fn set_luminaire_name(&mut self, value: String) {
434        self.inner.luminaire_name = value;
435    }
436
437    /// Luminaire number.
438    #[getter]
439    fn luminaire_number(&self) -> &str {
440        &self.inner.luminaire_number
441    }
442
443    #[setter]
444    fn set_luminaire_number(&mut self, value: String) {
445        self.inner.luminaire_number = value;
446    }
447
448    /// File name.
449    #[getter]
450    fn file_name(&self) -> &str {
451        &self.inner.file_name
452    }
453
454    #[setter]
455    fn set_file_name(&mut self, value: String) {
456        self.inner.file_name = value;
457    }
458
459    /// Date/user field.
460    #[getter]
461    fn date_user(&self) -> &str {
462        &self.inner.date_user
463    }
464
465    #[setter]
466    fn set_date_user(&mut self, value: String) {
467        self.inner.date_user = value;
468    }
469
470    // === Physical Dimensions (in mm) ===
471
472    /// Length/diameter of luminaire (mm).
473    #[getter]
474    fn length(&self) -> f64 {
475        self.inner.length
476    }
477
478    #[setter]
479    fn set_length(&mut self, value: f64) {
480        self.inner.length = value;
481    }
482
483    /// Width of luminaire (mm), 0 for circular.
484    #[getter]
485    fn width(&self) -> f64 {
486        self.inner.width
487    }
488
489    #[setter]
490    fn set_width(&mut self, value: f64) {
491        self.inner.width = value;
492    }
493
494    /// Height of luminaire (mm).
495    #[getter]
496    fn height(&self) -> f64 {
497        self.inner.height
498    }
499
500    #[setter]
501    fn set_height(&mut self, value: f64) {
502        self.inner.height = value;
503    }
504
505    /// Length/diameter of luminous area (mm).
506    #[getter]
507    fn luminous_area_length(&self) -> f64 {
508        self.inner.luminous_area_length
509    }
510
511    #[setter]
512    fn set_luminous_area_length(&mut self, value: f64) {
513        self.inner.luminous_area_length = value;
514    }
515
516    /// Width of luminous area (mm), 0 for circular.
517    #[getter]
518    fn luminous_area_width(&self) -> f64 {
519        self.inner.luminous_area_width
520    }
521
522    #[setter]
523    fn set_luminous_area_width(&mut self, value: f64) {
524        self.inner.luminous_area_width = value;
525    }
526
527    /// Height of luminous area at C0 plane (mm).
528    #[getter]
529    fn height_c0(&self) -> f64 {
530        self.inner.height_c0
531    }
532
533    #[setter]
534    fn set_height_c0(&mut self, value: f64) {
535        self.inner.height_c0 = value;
536    }
537
538    /// Height of luminous area at C90 plane (mm).
539    #[getter]
540    fn height_c90(&self) -> f64 {
541        self.inner.height_c90
542    }
543
544    #[setter]
545    fn set_height_c90(&mut self, value: f64) {
546        self.inner.height_c90 = value;
547    }
548
549    /// Height of luminous area at C180 plane (mm).
550    #[getter]
551    fn height_c180(&self) -> f64 {
552        self.inner.height_c180
553    }
554
555    #[setter]
556    fn set_height_c180(&mut self, value: f64) {
557        self.inner.height_c180 = value;
558    }
559
560    /// Height of luminous area at C270 plane (mm).
561    #[getter]
562    fn height_c270(&self) -> f64 {
563        self.inner.height_c270
564    }
565
566    #[setter]
567    fn set_height_c270(&mut self, value: f64) {
568        self.inner.height_c270 = value;
569    }
570
571    // === Optical Properties ===
572
573    /// Downward flux fraction (DFF) in percent.
574    #[getter]
575    fn downward_flux_fraction(&self) -> f64 {
576        self.inner.downward_flux_fraction
577    }
578
579    #[setter]
580    fn set_downward_flux_fraction(&mut self, value: f64) {
581        self.inner.downward_flux_fraction = value;
582    }
583
584    /// Light output ratio of luminaire (LORL) in percent.
585    #[getter]
586    fn light_output_ratio(&self) -> f64 {
587        self.inner.light_output_ratio
588    }
589
590    #[setter]
591    fn set_light_output_ratio(&mut self, value: f64) {
592        self.inner.light_output_ratio = value;
593    }
594
595    /// Conversion factor for luminous intensities.
596    #[getter]
597    fn conversion_factor(&self) -> f64 {
598        self.inner.conversion_factor
599    }
600
601    #[setter]
602    fn set_conversion_factor(&mut self, value: f64) {
603        self.inner.conversion_factor = value;
604    }
605
606    /// Tilt angle during measurement in degrees.
607    #[getter]
608    fn tilt_angle(&self) -> f64 {
609        self.inner.tilt_angle
610    }
611
612    #[setter]
613    fn set_tilt_angle(&mut self, value: f64) {
614        self.inner.tilt_angle = value;
615    }
616
617    // === Lamp Configuration ===
618
619    /// Lamp sets.
620    #[getter]
621    fn lamp_sets(&self) -> Vec<LampSet> {
622        self.inner.lamp_sets.iter().map(LampSet::from).collect()
623    }
624
625    #[setter]
626    fn set_lamp_sets(&mut self, value: Vec<LampSet>) {
627        self.inner.lamp_sets = value.iter().map(core::LampSet::from).collect();
628    }
629
630    // === Utilization Factors ===
631
632    /// Direct ratios for room indices.
633    #[getter]
634    fn direct_ratios(&self) -> Vec<f64> {
635        self.inner.direct_ratios.to_vec()
636    }
637
638    #[setter]
639    fn set_direct_ratios(&mut self, value: Vec<f64>) -> PyResult<()> {
640        if value.len() != 10 {
641            return Err(PyValueError::new_err(
642                "direct_ratios must have exactly 10 values",
643            ));
644        }
645        self.inner.direct_ratios.copy_from_slice(&value);
646        Ok(())
647    }
648
649    // === Photometric Data ===
650
651    /// C-plane angles in degrees.
652    #[getter]
653    fn c_angles(&self) -> Vec<f64> {
654        self.inner.c_angles.clone()
655    }
656
657    #[setter]
658    fn set_c_angles(&mut self, value: Vec<f64>) {
659        self.inner.c_angles = value;
660    }
661
662    /// G-plane (gamma) angles in degrees.
663    #[getter]
664    fn g_angles(&self) -> Vec<f64> {
665        self.inner.g_angles.clone()
666    }
667
668    #[setter]
669    fn set_g_angles(&mut self, value: Vec<f64>) {
670        self.inner.g_angles = value;
671    }
672
673    /// Luminous intensity distribution in cd/klm.
674    /// Indexed as intensities[c_plane_index][g_plane_index].
675    #[getter]
676    fn intensities(&self) -> Vec<Vec<f64>> {
677        self.inner.intensities.clone()
678    }
679
680    #[setter]
681    fn set_intensities(&mut self, value: Vec<Vec<f64>>) {
682        self.inner.intensities = value;
683    }
684
685    // === Computed Properties ===
686
687    /// Get the actual number of C-planes based on symmetry.
688    fn actual_c_planes(&self) -> usize {
689        self.inner.actual_c_planes()
690    }
691
692    /// Get total luminous flux from all lamp sets.
693    fn total_luminous_flux(&self) -> f64 {
694        self.inner.total_luminous_flux()
695    }
696
697    /// Get total wattage from all lamp sets.
698    fn total_wattage(&self) -> f64 {
699        self.inner.total_wattage()
700    }
701
702    /// Get luminous efficacy in lm/W.
703    fn luminous_efficacy(&self) -> f64 {
704        self.inner.luminous_efficacy()
705    }
706
707    /// Get the maximum intensity value.
708    fn max_intensity(&self) -> f64 {
709        self.inner.max_intensity()
710    }
711
712    /// Get the minimum intensity value.
713    fn min_intensity(&self) -> f64 {
714        self.inner.min_intensity()
715    }
716
717    /// Get the average intensity value.
718    fn avg_intensity(&self) -> f64 {
719        self.inner.avg_intensity()
720    }
721
722    /// Get intensity at a specific C and G angle index.
723    fn get_intensity(&self, c_index: usize, g_index: usize) -> Option<f64> {
724        self.inner.get_intensity(c_index, g_index)
725    }
726
727    /// Sample intensity at any C and G angle using bilinear interpolation.
728    ///
729    /// This method handles symmetry automatically - you can query any angle
730    /// in the full 0-360° C range and 0-180° G range regardless of stored symmetry.
731    ///
732    /// Args:
733    ///     c_angle: C-plane angle in degrees (will be normalized to 0-360)
734    ///     g_angle: Gamma angle in degrees (will be clamped to 0-180)
735    ///
736    /// Returns:
737    ///     Intensity in cd/klm at the specified angle
738    fn sample(&self, c_angle: f64, g_angle: f64) -> f64 {
739        self.inner.sample(c_angle, g_angle)
740    }
741
742    /// Sample normalized intensity (0.0 to 1.0) at any C and G angle.
743    ///
744    /// Returns intensity relative to maximum intensity, useful for visualization.
745    ///
746    /// Args:
747    ///     c_angle: C-plane angle in degrees
748    ///     g_angle: Gamma angle in degrees
749    ///
750    /// Returns:
751    ///     Normalized intensity (0.0 to 1.0)
752    fn sample_normalized(&self, c_angle: f64, g_angle: f64) -> f64 {
753        let max = self.inner.max_intensity();
754        if max <= 0.0 {
755            return 0.0;
756        }
757        self.inner.sample(c_angle, g_angle) / max
758    }
759
760    // === Diagram Generation ===
761
762    /// Generate a polar diagram SVG.
763    #[pyo3(signature = (width=500.0, height=500.0, theme=SvgTheme::Light))]
764    fn polar_svg(&self, width: f64, height: f64, theme: SvgTheme) -> String {
765        let diagram = CorePolarDiagram::from_eulumdat(&self.inner);
766        diagram.to_svg(width, height, &theme.to_core())
767    }
768
769    /// Generate a butterfly diagram SVG.
770    #[pyo3(signature = (width=500.0, height=400.0, rotation=60.0, theme=SvgTheme::Light))]
771    fn butterfly_svg(&self, width: f64, height: f64, rotation: f64, theme: SvgTheme) -> String {
772        let diagram = CoreButterflyDiagram::from_eulumdat(&self.inner, width, height, rotation);
773        diagram.to_svg(width, height, &theme.to_core())
774    }
775
776    /// Generate a cartesian diagram SVG.
777    #[pyo3(signature = (width=600.0, height=400.0, max_curves=8, theme=SvgTheme::Light))]
778    fn cartesian_svg(&self, width: f64, height: f64, max_curves: usize, theme: SvgTheme) -> String {
779        let diagram = CoreCartesianDiagram::from_eulumdat(&self.inner, width, height, max_curves);
780        diagram.to_svg(width, height, &theme.to_core())
781    }
782
783    /// Generate a heatmap diagram SVG.
784    #[pyo3(signature = (width=700.0, height=500.0, theme=SvgTheme::Light))]
785    fn heatmap_svg(&self, width: f64, height: f64, theme: SvgTheme) -> String {
786        let diagram = CoreHeatmapDiagram::from_eulumdat(&self.inner, width, height);
787        diagram.to_svg(width, height, &theme.to_core())
788    }
789
790    /// Generate a BUG rating diagram SVG.
791    #[pyo3(signature = (width=400.0, height=350.0, theme=SvgTheme::Light))]
792    fn bug_svg(&self, width: f64, height: f64, theme: SvgTheme) -> String {
793        let diagram = CoreBugDiagram::from_eulumdat(&self.inner);
794        diagram.to_svg(width, height, &theme.to_core())
795    }
796
797    /// Generate a LCS (Luminaire Classification System) diagram SVG.
798    #[pyo3(signature = (width=510.0, height=315.0, theme=SvgTheme::Light))]
799    fn lcs_svg(&self, width: f64, height: f64, theme: SvgTheme) -> String {
800        let diagram = CoreBugDiagram::from_eulumdat(&self.inner);
801        diagram.to_lcs_svg(width, height, &theme.to_core())
802    }
803
804    /// Calculate BUG rating.
805    fn bug_rating(&self) -> BugRating {
806        let diagram = CoreBugDiagram::from_eulumdat(&self.inner);
807        BugRating {
808            b: diagram.rating.b,
809            u: diagram.rating.u,
810            g: diagram.rating.g,
811        }
812    }
813
814    /// Calculate zone lumens for BUG rating.
815    fn zone_lumens(&self) -> ZoneLumens {
816        let diagram = CoreBugDiagram::from_eulumdat(&self.inner);
817        ZoneLumens {
818            bl: diagram.zones.bl,
819            bm: diagram.zones.bm,
820            bh: diagram.zones.bh,
821            bvh: diagram.zones.bvh,
822            fl: diagram.zones.fl,
823            fm: diagram.zones.fm,
824            fh: diagram.zones.fh,
825            fvh: diagram.zones.fvh,
826            ul: diagram.zones.ul,
827            uh: diagram.zones.uh,
828        }
829    }
830
831    /// Generate a BUG diagram SVG with detailed zone lumens breakdown.
832    #[pyo3(signature = (width=600.0, height=400.0, theme=SvgTheme::Light))]
833    fn bug_svg_with_details(&self, width: f64, height: f64, theme: SvgTheme) -> String {
834        let diagram = CoreBugDiagram::from_eulumdat(&self.inner);
835        diagram.to_svg_with_details(width, height, &theme.to_core())
836    }
837
838    // === Photometric Calculations ===
839
840    /// Calculate complete photometric summary.
841    ///
842    /// Returns a PhotometricSummary containing all calculated photometric values
843    /// including CIE flux codes, beam/field angles, spacing criteria, etc.
844    fn photometric_summary(&self) -> PhotometricSummary {
845        PhotometricCalcs::photometric_summary(&self.inner)
846    }
847
848    /// Calculate GLDF-compatible photometric data.
849    ///
850    /// Returns data structured for GLDF (Global Lighting Data Format) export.
851    fn gldf_data(&self) -> GldfPhotometricData {
852        PhotometricCalcs::gldf_data(&self.inner)
853    }
854
855    /// Calculate CIE flux codes (N1-N5).
856    ///
857    /// Returns:
858    ///     CieFluxCodes with N1 (DLOR), N2 (0-60°), N3 (0-40°), N4 (ULOR), N5 (90-120°)
859    fn cie_flux_codes(&self) -> CieFluxCodes {
860        PhotometricCalcs::cie_flux_codes(&self.inner)
861    }
862
863    /// Calculate beam angle (50% intensity drop).
864    ///
865    /// Returns:
866    ///     Beam angle in degrees
867    fn beam_angle(&self) -> f64 {
868        PhotometricCalcs::beam_angle(&self.inner)
869    }
870
871    /// Calculate field angle (10% intensity drop).
872    ///
873    /// Returns:
874    ///     Field angle in degrees
875    fn field_angle(&self) -> f64 {
876        PhotometricCalcs::field_angle(&self.inner)
877    }
878
879    /// Calculate spacing criteria (S/H ratios) for both principal planes.
880    ///
881    /// Returns:
882    ///     Tuple of (S/H for C0 plane, S/H for C90 plane)
883    fn spacing_criteria(&self) -> (f64, f64) {
884        PhotometricCalcs::spacing_criteria(&self.inner)
885    }
886
887    /// Calculate zonal lumens in 30° zones.
888    ///
889    /// Returns:
890    ///     ZonalLumens30 with flux percentages in 6 zones from nadir to zenith
891    fn zonal_lumens_30(&self) -> ZonalLumens30 {
892        PhotometricCalcs::zonal_lumens_30(&self.inner)
893    }
894
895    /// Calculate downward flux fraction up to a given arc angle.
896    ///
897    /// Args:
898    ///     arc: Maximum angle from vertical (0° = straight down, 90° = horizontal)
899    ///
900    /// Returns:
901    ///     Percentage of light directed downward (0-100)
902    fn downward_flux(&self, arc: f64) -> f64 {
903        PhotometricCalcs::downward_flux(&self.inner, arc)
904    }
905
906    /// Calculate cut-off angle (where intensity drops below 2.5% of max).
907    ///
908    /// Returns:
909    ///     Cut-off angle in degrees
910    fn cut_off_angle(&self) -> f64 {
911        PhotometricCalcs::cut_off_angle(&self.inner)
912    }
913
914    /// Generate photometric classification code.
915    ///
916    /// Format: D-N where D=distribution type, N=beam classification
917    /// Distribution: D (direct), SD (semi-direct), GD (general diffuse),
918    ///               SI (semi-indirect), I (indirect)
919    /// Beam: VN (very narrow), N (narrow), M (medium), W (wide), VW (very wide)
920    ///
921    /// Returns:
922    ///     Classification code string (e.g., "D-M" for direct medium beam)
923    fn photometric_code(&self) -> String {
924        PhotometricCalcs::photometric_code(&self.inner)
925    }
926
927    /// Calculate luminaire efficacy (accounting for LOR).
928    ///
929    /// Differs from lamp efficacy by including light output ratio losses.
930    ///
931    /// Returns:
932    ///     Luminaire efficacy in lm/W
933    fn luminaire_efficacy_lor(&self) -> f64 {
934        PhotometricCalcs::luminaire_efficacy(&self.inner)
935    }
936
937    /// Calculate UGR (Unified Glare Rating) for a room configuration.
938    ///
939    /// Args:
940    ///     params: UgrParams with room geometry and luminaire positions
941    ///
942    /// Returns:
943    ///     UGR value (typically 10-30, lower is better)
944    fn calculate_ugr(&self, params: &UgrParams) -> f64 {
945        PhotometricCalcs::ugr(&self.inner, params)
946    }
947
948    fn __repr__(&self) -> String {
949        format!(
950            "Eulumdat(name='{}', symmetry={:?}, c_planes={}, g_angles={})",
951            self.inner.luminaire_name,
952            self.inner.symmetry,
953            self.inner.c_angles.len(),
954            self.inner.g_angles.len()
955        )
956    }
957}