eulumdat/
calculations.rs

1//! Photometric calculations Python bindings
2
3use pyo3::prelude::*;
4use pyo3::types::PyDict;
5
6use ::eulumdat as core;
7use core::{
8    CieFluxCodes as CoreCieFluxCodes, GldfPhotometricData as CoreGldfPhotometricData,
9    PhotometricCalculations as CoreCalcs, PhotometricSummary as CorePhotoSummary,
10    UgrParams as CoreUgrParams, ZonalLumens30 as CoreZonalLumens30,
11};
12
13/// CIE Flux Code values (N1-N5).
14///
15/// The CIE flux code describes the light distribution of a luminaire:
16/// - N1: % flux in lower hemisphere (0-90°) - equivalent to DLOR
17/// - N2: % flux in 0-60° zone
18/// - N3: % flux in 0-40° zone
19/// - N4: % flux in upper hemisphere (90-180°) - equivalent to ULOR
20/// - N5: % flux in 90-120° zone (near-horizontal uplight)
21#[pyclass]
22#[derive(Clone, Debug)]
23pub struct CieFluxCodes {
24    /// N1: % flux in lower hemisphere (0-90°)
25    #[pyo3(get)]
26    pub n1: f64,
27    /// N2: % flux in 0-60° zone
28    #[pyo3(get)]
29    pub n2: f64,
30    /// N3: % flux in 0-40° zone
31    #[pyo3(get)]
32    pub n3: f64,
33    /// N4: % flux in upper hemisphere (90-180°)
34    #[pyo3(get)]
35    pub n4: f64,
36    /// N5: % flux in 90-120° zone
37    #[pyo3(get)]
38    pub n5: f64,
39}
40
41#[pymethods]
42impl CieFluxCodes {
43    /// Format as standard CIE flux code string "N1 N2 N3 N4 N5"
44    fn __str__(&self) -> String {
45        format!(
46            "{:.0} {:.0} {:.0} {:.0} {:.0}",
47            self.n1.round(),
48            self.n2.round(),
49            self.n3.round(),
50            self.n4.round(),
51            self.n5.round()
52        )
53    }
54
55    fn __repr__(&self) -> String {
56        format!(
57            "CieFluxCodes(n1={:.1}, n2={:.1}, n3={:.1}, n4={:.1}, n5={:.1})",
58            self.n1, self.n2, self.n3, self.n4, self.n5
59        )
60    }
61
62    /// Convert to dictionary
63    fn to_dict<'py>(&self, py: Python<'py>) -> Bound<'py, PyDict> {
64        let dict = PyDict::new(py);
65        dict.set_item("n1", self.n1).unwrap();
66        dict.set_item("n2", self.n2).unwrap();
67        dict.set_item("n3", self.n3).unwrap();
68        dict.set_item("n4", self.n4).unwrap();
69        dict.set_item("n5", self.n5).unwrap();
70        dict
71    }
72}
73
74impl From<CoreCieFluxCodes> for CieFluxCodes {
75    fn from(c: CoreCieFluxCodes) -> Self {
76        Self {
77            n1: c.n1,
78            n2: c.n2,
79            n3: c.n3,
80            n4: c.n4,
81            n5: c.n5,
82        }
83    }
84}
85
86/// Zonal lumens in 30° zones.
87///
88/// Flux percentages distributed across 6 zones from nadir to zenith.
89#[pyclass]
90#[derive(Clone, Debug)]
91pub struct ZonalLumens30 {
92    /// 0-30° zone (nadir to 30°)
93    #[pyo3(get)]
94    pub zone_0_30: f64,
95    /// 30-60° zone
96    #[pyo3(get)]
97    pub zone_30_60: f64,
98    /// 60-90° zone (approaching horizontal)
99    #[pyo3(get)]
100    pub zone_60_90: f64,
101    /// 90-120° zone (above horizontal)
102    #[pyo3(get)]
103    pub zone_90_120: f64,
104    /// 120-150° zone
105    #[pyo3(get)]
106    pub zone_120_150: f64,
107    /// 150-180° zone (zenith region)
108    #[pyo3(get)]
109    pub zone_150_180: f64,
110}
111
112#[pymethods]
113impl ZonalLumens30 {
114    /// Get total downward flux (0-90°)
115    fn downward_total(&self) -> f64 {
116        self.zone_0_30 + self.zone_30_60 + self.zone_60_90
117    }
118
119    /// Get total upward flux (90-180°)
120    fn upward_total(&self) -> f64 {
121        self.zone_90_120 + self.zone_120_150 + self.zone_150_180
122    }
123
124    fn __repr__(&self) -> String {
125        format!(
126            "ZonalLumens30(down={:.1}%, up={:.1}%)",
127            self.downward_total(),
128            self.upward_total()
129        )
130    }
131
132    /// Convert to dictionary
133    fn to_dict<'py>(&self, py: Python<'py>) -> Bound<'py, PyDict> {
134        let dict = PyDict::new(py);
135        dict.set_item("zone_0_30", self.zone_0_30).unwrap();
136        dict.set_item("zone_30_60", self.zone_30_60).unwrap();
137        dict.set_item("zone_60_90", self.zone_60_90).unwrap();
138        dict.set_item("zone_90_120", self.zone_90_120).unwrap();
139        dict.set_item("zone_120_150", self.zone_120_150).unwrap();
140        dict.set_item("zone_150_180", self.zone_150_180).unwrap();
141        dict
142    }
143}
144
145impl From<CoreZonalLumens30> for ZonalLumens30 {
146    fn from(z: CoreZonalLumens30) -> Self {
147        Self {
148            zone_0_30: z.zone_0_30,
149            zone_30_60: z.zone_30_60,
150            zone_60_90: z.zone_60_90,
151            zone_90_120: z.zone_90_120,
152            zone_120_150: z.zone_120_150,
153            zone_150_180: z.zone_150_180,
154        }
155    }
156}
157
158/// Complete photometric summary with all calculated values.
159///
160/// Provides a comprehensive overview of luminaire performance
161/// that can be used for reports, GLDF export, or display.
162#[pyclass]
163#[derive(Clone, Debug)]
164pub struct PhotometricSummary {
165    // Flux and efficiency
166    /// Total lamp flux (lm)
167    #[pyo3(get)]
168    pub total_lamp_flux: f64,
169    /// Calculated flux from intensity integration (lm)
170    #[pyo3(get)]
171    pub calculated_flux: f64,
172    /// Light Output Ratio (%)
173    #[pyo3(get)]
174    pub lor: f64,
175    /// Downward Light Output Ratio (%)
176    #[pyo3(get)]
177    pub dlor: f64,
178    /// Upward Light Output Ratio (%)
179    #[pyo3(get)]
180    pub ulor: f64,
181
182    // Efficacy
183    /// Lamp efficacy (lm/W)
184    #[pyo3(get)]
185    pub lamp_efficacy: f64,
186    /// Luminaire efficacy (lm/W)
187    #[pyo3(get)]
188    pub luminaire_efficacy: f64,
189    /// Total system wattage (W)
190    #[pyo3(get)]
191    pub total_wattage: f64,
192
193    // Beam characteristics
194    /// Beam angle - 50% intensity (degrees)
195    #[pyo3(get)]
196    pub beam_angle: f64,
197    /// Field angle - 10% intensity (degrees)
198    #[pyo3(get)]
199    pub field_angle: f64,
200
201    // Intensity statistics
202    /// Maximum intensity (cd/klm)
203    #[pyo3(get)]
204    pub max_intensity: f64,
205    /// Minimum intensity (cd/klm)
206    #[pyo3(get)]
207    pub min_intensity: f64,
208    /// Average intensity (cd/klm)
209    #[pyo3(get)]
210    pub avg_intensity: f64,
211
212    // Spacing criterion
213    /// S/H ratio for C0 plane
214    #[pyo3(get)]
215    pub spacing_c0: f64,
216    /// S/H ratio for C90 plane
217    #[pyo3(get)]
218    pub spacing_c90: f64,
219
220    // Internal storage for cie_flux_codes and zonal_lumens
221    inner_cie_codes: CoreCieFluxCodes,
222    inner_zonal_lumens: CoreZonalLumens30,
223}
224
225#[pymethods]
226impl PhotometricSummary {
227    /// CIE flux codes (N1-N5)
228    #[getter]
229    fn cie_flux_codes(&self) -> CieFluxCodes {
230        self.inner_cie_codes.into()
231    }
232
233    /// Zonal lumens in 30° zones
234    #[getter]
235    fn zonal_lumens(&self) -> ZonalLumens30 {
236        self.inner_zonal_lumens.into()
237    }
238
239    /// Format as multi-line text report.
240    fn to_text(&self) -> String {
241        format!(
242            r#"PHOTOMETRIC SUMMARY
243==================
244
245Luminous Flux
246  Total Lamp Flux:     {:.0} lm
247  Calculated Flux:     {:.0} lm
248  LOR:                 {:.1}%
249  DLOR / ULOR:         {:.1}% / {:.1}%
250
251Efficacy
252  Lamp Efficacy:       {:.1} lm/W
253  Luminaire Efficacy:  {:.1} lm/W
254  Total Wattage:       {:.1} W
255
256CIE Flux Code:         {}
257
258Beam Characteristics
259  Beam Angle (50%):    {:.1}°
260  Field Angle (10%):   {:.1}°
261
262Intensity (cd/klm)
263  Maximum:             {:.1}
264  Minimum:             {:.1}
265  Average:             {:.1}
266
267Spacing Criterion (S/H)
268  C0 Plane:            {:.2}
269  C90 Plane:           {:.2}
270
271Zonal Lumens (%)
272  0-30°:               {:.1}%
273  30-60°:              {:.1}%
274  60-90°:              {:.1}%
275  90-120°:             {:.1}%
276  120-150°:            {:.1}%
277  150-180°:            {:.1}%
278"#,
279            self.total_lamp_flux,
280            self.calculated_flux,
281            self.lor,
282            self.dlor,
283            self.ulor,
284            self.lamp_efficacy,
285            self.luminaire_efficacy,
286            self.total_wattage,
287            self.inner_cie_codes,
288            self.beam_angle,
289            self.field_angle,
290            self.max_intensity,
291            self.min_intensity,
292            self.avg_intensity,
293            self.spacing_c0,
294            self.spacing_c90,
295            self.inner_zonal_lumens.zone_0_30,
296            self.inner_zonal_lumens.zone_30_60,
297            self.inner_zonal_lumens.zone_60_90,
298            self.inner_zonal_lumens.zone_90_120,
299            self.inner_zonal_lumens.zone_120_150,
300            self.inner_zonal_lumens.zone_150_180,
301        )
302    }
303
304    /// Format as single-line compact summary.
305    fn to_compact(&self) -> String {
306        format!(
307            "CIE:{} Beam:{:.0}° Field:{:.0}° Eff:{:.0}lm/W S/H:{:.1}×{:.1}",
308            self.inner_cie_codes,
309            self.beam_angle,
310            self.field_angle,
311            self.luminaire_efficacy,
312            self.spacing_c0,
313            self.spacing_c90,
314        )
315    }
316
317    fn __str__(&self) -> String {
318        self.to_text()
319    }
320
321    fn __repr__(&self) -> String {
322        format!(
323            "PhotometricSummary(flux={:.0}lm, beam={:.1}°, field={:.1}°, eff={:.1}lm/W)",
324            self.total_lamp_flux, self.beam_angle, self.field_angle, self.luminaire_efficacy
325        )
326    }
327
328    /// Convert to dictionary
329    fn to_dict<'py>(&self, py: Python<'py>) -> Bound<'py, PyDict> {
330        let dict = PyDict::new(py);
331        dict.set_item("total_lamp_flux", self.total_lamp_flux)
332            .unwrap();
333        dict.set_item("calculated_flux", self.calculated_flux)
334            .unwrap();
335        dict.set_item("lor", self.lor).unwrap();
336        dict.set_item("dlor", self.dlor).unwrap();
337        dict.set_item("ulor", self.ulor).unwrap();
338        dict.set_item("lamp_efficacy", self.lamp_efficacy).unwrap();
339        dict.set_item("luminaire_efficacy", self.luminaire_efficacy)
340            .unwrap();
341        dict.set_item("total_wattage", self.total_wattage).unwrap();
342        dict.set_item("beam_angle", self.beam_angle).unwrap();
343        dict.set_item("field_angle", self.field_angle).unwrap();
344        dict.set_item("max_intensity", self.max_intensity).unwrap();
345        dict.set_item("min_intensity", self.min_intensity).unwrap();
346        dict.set_item("avg_intensity", self.avg_intensity).unwrap();
347        dict.set_item("spacing_c0", self.spacing_c0).unwrap();
348        dict.set_item("spacing_c90", self.spacing_c90).unwrap();
349        dict.set_item(
350            "cie_flux_code",
351            format!("{}", self.inner_cie_codes).as_str(),
352        )
353        .unwrap();
354        dict.set_item("cie_n1", self.inner_cie_codes.n1).unwrap();
355        dict.set_item("cie_n2", self.inner_cie_codes.n2).unwrap();
356        dict.set_item("cie_n3", self.inner_cie_codes.n3).unwrap();
357        dict.set_item("cie_n4", self.inner_cie_codes.n4).unwrap();
358        dict.set_item("cie_n5", self.inner_cie_codes.n5).unwrap();
359        dict.set_item("zonal_0_30", self.inner_zonal_lumens.zone_0_30)
360            .unwrap();
361        dict.set_item("zonal_30_60", self.inner_zonal_lumens.zone_30_60)
362            .unwrap();
363        dict.set_item("zonal_60_90", self.inner_zonal_lumens.zone_60_90)
364            .unwrap();
365        dict.set_item("zonal_90_120", self.inner_zonal_lumens.zone_90_120)
366            .unwrap();
367        dict.set_item("zonal_120_150", self.inner_zonal_lumens.zone_120_150)
368            .unwrap();
369        dict.set_item("zonal_150_180", self.inner_zonal_lumens.zone_150_180)
370            .unwrap();
371        dict
372    }
373}
374
375impl From<CorePhotoSummary> for PhotometricSummary {
376    fn from(s: CorePhotoSummary) -> Self {
377        Self {
378            total_lamp_flux: s.total_lamp_flux,
379            calculated_flux: s.calculated_flux,
380            lor: s.lor,
381            dlor: s.dlor,
382            ulor: s.ulor,
383            lamp_efficacy: s.lamp_efficacy,
384            luminaire_efficacy: s.luminaire_efficacy,
385            total_wattage: s.total_wattage,
386            beam_angle: s.beam_angle,
387            field_angle: s.field_angle,
388            max_intensity: s.max_intensity,
389            min_intensity: s.min_intensity,
390            avg_intensity: s.avg_intensity,
391            spacing_c0: s.spacing_c0,
392            spacing_c90: s.spacing_c90,
393            inner_cie_codes: s.cie_flux_codes,
394            inner_zonal_lumens: s.zonal_lumens,
395        }
396    }
397}
398
399/// GLDF-compatible photometric data export.
400///
401/// Contains all properties required by the GLDF (Global Lighting Data Format)
402/// specification for photometric data exchange.
403#[pyclass]
404#[derive(Clone, Debug)]
405pub struct GldfPhotometricData {
406    /// CIE Flux Code (e.g., "45 72 95 100 100")
407    #[pyo3(get)]
408    pub cie_flux_code: String,
409    /// Light Output Ratio - total efficiency (%)
410    #[pyo3(get)]
411    pub light_output_ratio: f64,
412    /// Luminous efficacy (lm/W)
413    #[pyo3(get)]
414    pub luminous_efficacy: f64,
415    /// Downward Flux Fraction (%)
416    #[pyo3(get)]
417    pub downward_flux_fraction: f64,
418    /// Downward Light Output Ratio (%)
419    #[pyo3(get)]
420    pub downward_light_output_ratio: f64,
421    /// Upward Light Output Ratio (%)
422    #[pyo3(get)]
423    pub upward_light_output_ratio: f64,
424    /// Luminaire luminance (cd/m²)
425    #[pyo3(get)]
426    pub luminaire_luminance: f64,
427    /// Cut-off angle (degrees)
428    #[pyo3(get)]
429    pub cut_off_angle: f64,
430    /// Photometric classification code
431    #[pyo3(get)]
432    pub photometric_code: String,
433    /// Half peak (beam) divergence C0 (degrees)
434    #[pyo3(get)]
435    pub half_peak_c0: f64,
436    /// Half peak (beam) divergence C90 (degrees)
437    #[pyo3(get)]
438    pub half_peak_c90: f64,
439    /// Tenth peak (field) divergence C0 (degrees)
440    #[pyo3(get)]
441    pub tenth_peak_c0: f64,
442    /// Tenth peak (field) divergence C90 (degrees)
443    #[pyo3(get)]
444    pub tenth_peak_c90: f64,
445    /// BUG rating string
446    #[pyo3(get)]
447    pub bug_rating: String,
448    /// UGR crosswise (if available)
449    #[pyo3(get)]
450    pub ugr_crosswise: Option<f64>,
451    /// UGR endwise (if available)
452    #[pyo3(get)]
453    pub ugr_endwise: Option<f64>,
454}
455
456#[pymethods]
457impl GldfPhotometricData {
458    /// Format as text report
459    fn to_text(&self) -> String {
460        let mut s = String::from("GLDF PHOTOMETRIC DATA\n");
461        s.push_str("=====================\n\n");
462
463        s.push_str(&format!(
464            "CIE Flux Code:           {}\n",
465            self.cie_flux_code
466        ));
467        s.push_str(&format!(
468            "Light Output Ratio:      {:.1}%\n",
469            self.light_output_ratio
470        ));
471        s.push_str(&format!(
472            "Luminous Efficacy:       {:.1} lm/W\n",
473            self.luminous_efficacy
474        ));
475        s.push_str(&format!(
476            "Downward Flux Fraction:  {:.1}%\n",
477            self.downward_flux_fraction
478        ));
479        s.push_str(&format!(
480            "DLOR:                    {:.1}%\n",
481            self.downward_light_output_ratio
482        ));
483        s.push_str(&format!(
484            "ULOR:                    {:.1}%\n",
485            self.upward_light_output_ratio
486        ));
487        s.push_str(&format!(
488            "Luminaire Luminance:     {:.0} cd/m²\n",
489            self.luminaire_luminance
490        ));
491        s.push_str(&format!(
492            "Cut-off Angle:           {:.1}°\n",
493            self.cut_off_angle
494        ));
495
496        if let (Some(cross), Some(end)) = (self.ugr_crosswise, self.ugr_endwise) {
497            s.push_str(&format!(
498                "UGR (4H×8H, 70/50/20):   C: {:.1} / E: {:.1}\n",
499                cross, end
500            ));
501        }
502
503        s.push_str(&format!(
504            "Photometric Code:        {}\n",
505            self.photometric_code
506        ));
507        s.push_str(&format!(
508            "Half Peak Divergence:    {:.1}° / {:.1}° (C0/C90)\n",
509            self.half_peak_c0, self.half_peak_c90
510        ));
511        s.push_str(&format!(
512            "Tenth Peak Divergence:   {:.1}° / {:.1}° (C0/C90)\n",
513            self.tenth_peak_c0, self.tenth_peak_c90
514        ));
515        s.push_str(&format!("BUG Rating:              {}\n", self.bug_rating));
516
517        s
518    }
519
520    fn __str__(&self) -> String {
521        self.to_text()
522    }
523
524    fn __repr__(&self) -> String {
525        format!(
526            "GldfPhotometricData(cie='{}', lor={:.1}%, eff={:.1}lm/W, bug='{}')",
527            self.cie_flux_code, self.light_output_ratio, self.luminous_efficacy, self.bug_rating
528        )
529    }
530
531    /// Convert to dictionary for JSON serialization
532    fn to_dict<'py>(&self, py: Python<'py>) -> Bound<'py, PyDict> {
533        let dict = PyDict::new(py);
534        dict.set_item("cie_flux_code", &self.cie_flux_code).unwrap();
535        dict.set_item("light_output_ratio", self.light_output_ratio)
536            .unwrap();
537        dict.set_item("luminous_efficacy", self.luminous_efficacy)
538            .unwrap();
539        dict.set_item("downward_flux_fraction", self.downward_flux_fraction)
540            .unwrap();
541        dict.set_item(
542            "downward_light_output_ratio",
543            self.downward_light_output_ratio,
544        )
545        .unwrap();
546        dict.set_item("upward_light_output_ratio", self.upward_light_output_ratio)
547            .unwrap();
548        dict.set_item("luminaire_luminance", self.luminaire_luminance)
549            .unwrap();
550        dict.set_item("cut_off_angle", self.cut_off_angle).unwrap();
551        dict.set_item("photometric_code", &self.photometric_code)
552            .unwrap();
553        dict.set_item("half_peak_divergence_c0", self.half_peak_c0)
554            .unwrap();
555        dict.set_item("half_peak_divergence_c90", self.half_peak_c90)
556            .unwrap();
557        dict.set_item("tenth_peak_divergence_c0", self.tenth_peak_c0)
558            .unwrap();
559        dict.set_item("tenth_peak_divergence_c90", self.tenth_peak_c90)
560            .unwrap();
561        dict.set_item("bug_rating", &self.bug_rating).unwrap();
562        if let Some(ugr_c) = self.ugr_crosswise {
563            dict.set_item("ugr_crosswise", ugr_c).unwrap();
564        }
565        if let Some(ugr_e) = self.ugr_endwise {
566            dict.set_item("ugr_endwise", ugr_e).unwrap();
567        }
568        dict
569    }
570}
571
572impl From<CoreGldfPhotometricData> for GldfPhotometricData {
573    fn from(g: CoreGldfPhotometricData) -> Self {
574        Self {
575            cie_flux_code: g.cie_flux_code,
576            light_output_ratio: g.light_output_ratio,
577            luminous_efficacy: g.luminous_efficacy,
578            downward_flux_fraction: g.downward_flux_fraction,
579            downward_light_output_ratio: g.downward_light_output_ratio,
580            upward_light_output_ratio: g.upward_light_output_ratio,
581            luminaire_luminance: g.luminaire_luminance,
582            cut_off_angle: g.cut_off_angle,
583            photometric_code: g.photometric_code,
584            half_peak_c0: g.half_peak_divergence.0,
585            half_peak_c90: g.half_peak_divergence.1,
586            tenth_peak_c0: g.tenth_peak_divergence.0,
587            tenth_peak_c90: g.tenth_peak_divergence.1,
588            bug_rating: g.light_distribution_bug_rating,
589            ugr_crosswise: g.ugr_4h_8h_705020.as_ref().map(|u| u.crosswise),
590            ugr_endwise: g.ugr_4h_8h_705020.as_ref().map(|u| u.endwise),
591        }
592    }
593}
594
595/// Parameters for UGR calculation.
596#[pyclass]
597#[derive(Clone, Debug)]
598pub struct UgrParams {
599    /// Room length (m)
600    #[pyo3(get, set)]
601    pub room_length: f64,
602    /// Room width (m)
603    #[pyo3(get, set)]
604    pub room_width: f64,
605    /// Mounting height above floor (m)
606    #[pyo3(get, set)]
607    pub mounting_height: f64,
608    /// Observer eye height (m)
609    #[pyo3(get, set)]
610    pub eye_height: f64,
611    /// Observer X position (m)
612    #[pyo3(get, set)]
613    pub observer_x: f64,
614    /// Observer Y position (m)
615    #[pyo3(get, set)]
616    pub observer_y: f64,
617    /// Ceiling reflectance (0-1)
618    #[pyo3(get, set)]
619    pub rho_ceiling: f64,
620    /// Wall reflectance (0-1)
621    #[pyo3(get, set)]
622    pub rho_wall: f64,
623    /// Floor reflectance (0-1)
624    #[pyo3(get, set)]
625    pub rho_floor: f64,
626    /// Target illuminance (lux)
627    #[pyo3(get, set)]
628    pub illuminance: f64,
629    /// Luminaire positions as (x, y) tuples
630    luminaire_positions: Vec<(f64, f64)>,
631}
632
633#[pymethods]
634impl UgrParams {
635    /// Create new UGR parameters with default values.
636    #[new]
637    #[pyo3(signature = (room_length=8.0, room_width=4.0, mounting_height=2.8, eye_height=1.2, observer_x=4.0, observer_y=2.0))]
638    fn new(
639        room_length: f64,
640        room_width: f64,
641        mounting_height: f64,
642        eye_height: f64,
643        observer_x: f64,
644        observer_y: f64,
645    ) -> Self {
646        Self {
647            room_length,
648            room_width,
649            mounting_height,
650            eye_height,
651            observer_x,
652            observer_y,
653            luminaire_positions: vec![(2.0, 2.0), (6.0, 2.0)],
654            rho_ceiling: 0.7,
655            rho_wall: 0.5,
656            rho_floor: 0.2,
657            illuminance: 500.0,
658        }
659    }
660
661    /// Create params for a standard office room.
662    #[staticmethod]
663    fn standard_office() -> Self {
664        let core = CoreUgrParams::standard_office();
665        Self::from(core)
666    }
667
668    /// Get luminaire positions
669    #[getter]
670    fn get_luminaire_positions(&self) -> Vec<(f64, f64)> {
671        self.luminaire_positions.clone()
672    }
673
674    /// Set luminaire positions
675    #[setter]
676    fn set_luminaire_positions(&mut self, positions: Vec<(f64, f64)>) {
677        self.luminaire_positions = positions;
678    }
679
680    /// Add a luminaire position
681    fn add_luminaire(&mut self, x: f64, y: f64) {
682        self.luminaire_positions.push((x, y));
683    }
684
685    /// Clear all luminaire positions
686    fn clear_luminaires(&mut self) {
687        self.luminaire_positions.clear();
688    }
689
690    fn __repr__(&self) -> String {
691        format!(
692            "UgrParams(room={:.1}×{:.1}m, h={:.1}m, {} luminaires)",
693            self.room_length,
694            self.room_width,
695            self.mounting_height,
696            self.luminaire_positions.len()
697        )
698    }
699}
700
701impl From<CoreUgrParams> for UgrParams {
702    fn from(p: CoreUgrParams) -> Self {
703        Self {
704            room_length: p.room_length,
705            room_width: p.room_width,
706            mounting_height: p.mounting_height,
707            eye_height: p.eye_height,
708            observer_x: p.observer_x,
709            observer_y: p.observer_y,
710            luminaire_positions: p.luminaire_positions,
711            rho_ceiling: p.rho_ceiling,
712            rho_wall: p.rho_wall,
713            rho_floor: p.rho_floor,
714            illuminance: p.illuminance,
715        }
716    }
717}
718
719impl From<&UgrParams> for CoreUgrParams {
720    fn from(p: &UgrParams) -> Self {
721        Self {
722            room_length: p.room_length,
723            room_width: p.room_width,
724            mounting_height: p.mounting_height,
725            eye_height: p.eye_height,
726            observer_x: p.observer_x,
727            observer_y: p.observer_y,
728            luminaire_positions: p.luminaire_positions.clone(),
729            rho_ceiling: p.rho_ceiling,
730            rho_wall: p.rho_wall,
731            rho_floor: p.rho_floor,
732            illuminance: p.illuminance,
733        }
734    }
735}
736
737/// Helper functions for photometric calculations (implemented on Eulumdat)
738pub struct PhotometricCalcs;
739
740impl PhotometricCalcs {
741    pub fn photometric_summary(ldt: &core::Eulumdat) -> PhotometricSummary {
742        CorePhotoSummary::from_eulumdat(ldt).into()
743    }
744
745    pub fn gldf_data(ldt: &core::Eulumdat) -> GldfPhotometricData {
746        CoreGldfPhotometricData::from_eulumdat(ldt).into()
747    }
748
749    pub fn cie_flux_codes(ldt: &core::Eulumdat) -> CieFluxCodes {
750        CoreCalcs::cie_flux_codes(ldt).into()
751    }
752
753    pub fn zonal_lumens_30(ldt: &core::Eulumdat) -> ZonalLumens30 {
754        CoreCalcs::zonal_lumens_30deg(ldt).into()
755    }
756
757    pub fn beam_angle(ldt: &core::Eulumdat) -> f64 {
758        CoreCalcs::beam_angle(ldt)
759    }
760
761    pub fn field_angle(ldt: &core::Eulumdat) -> f64 {
762        CoreCalcs::field_angle(ldt)
763    }
764
765    pub fn spacing_criteria(ldt: &core::Eulumdat) -> (f64, f64) {
766        CoreCalcs::spacing_criteria(ldt)
767    }
768
769    pub fn downward_flux(ldt: &core::Eulumdat, arc: f64) -> f64 {
770        CoreCalcs::downward_flux(ldt, arc)
771    }
772
773    pub fn cut_off_angle(ldt: &core::Eulumdat) -> f64 {
774        CoreCalcs::cut_off_angle(ldt)
775    }
776
777    pub fn photometric_code(ldt: &core::Eulumdat) -> String {
778        CoreCalcs::photometric_code(ldt)
779    }
780
781    pub fn luminaire_efficacy(ldt: &core::Eulumdat) -> f64 {
782        CoreCalcs::luminaire_efficacy(ldt)
783    }
784
785    pub fn ugr(ldt: &core::Eulumdat, params: &UgrParams) -> f64 {
786        let core_params = CoreUgrParams::from(params);
787        CoreCalcs::ugr(ldt, &core_params)
788    }
789}