1use std::collections::HashMap;
55use std::fs;
56use std::path::Path;
57
58#[cfg(feature = "serde")]
59use serde::{Deserialize, Serialize};
60
61use crate::error::{anyhow, Result};
62use crate::eulumdat::{Eulumdat, LampSet, Symmetry, TypeIndicator};
63use crate::symmetry::SymmetryHandler;
64
65pub struct IesParser;
73
74fn read_with_encoding_fallback<P: AsRef<Path>>(path: P) -> Result<String> {
79 let bytes = fs::read(path.as_ref()).map_err(|e| anyhow!("Failed to read file: {}", e))?;
80
81 match String::from_utf8(bytes.clone()) {
83 Ok(content) => Ok(content),
84 Err(_) => {
85 Ok(bytes.iter().map(|&b| b as char).collect())
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
95pub enum IesVersion {
96 Lm63_1991,
98 Lm63_1995,
100 #[default]
102 Lm63_2002,
103 Lm63_2019,
105}
106
107impl IesVersion {
108 pub fn from_header(header: &str) -> Self {
110 let header_upper = header.to_uppercase();
111 if header_upper.contains("LM-63-2019") || header_upper.starts_with("IES:LM-63") {
112 Self::Lm63_2019
113 } else if header_upper.contains("LM-63-2002") {
114 Self::Lm63_2002
115 } else if header_upper.contains("LM-63-1995") {
116 Self::Lm63_1995
117 } else {
118 Self::Lm63_1991
119 }
120 }
121
122 pub fn header(&self) -> &'static str {
124 match self {
125 Self::Lm63_1991 => "IESNA91",
126 Self::Lm63_1995 => "IESNA:LM-63-1995",
127 Self::Lm63_2002 => "IESNA:LM-63-2002",
128 Self::Lm63_2019 => "IES:LM-63-2019",
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Default)]
137#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
138pub enum FileGenerationType {
139 #[default]
141 Undefined,
142 ComputerSimulation,
144 UnaccreditedLab,
146 UnaccreditedLabScaled,
148 UnaccreditedLabInterpolated,
150 UnaccreditedLabInterpolatedScaled,
152 AccreditedLab,
154 AccreditedLabScaled,
156 AccreditedLabInterpolated,
158 AccreditedLabInterpolatedScaled,
160}
161
162impl FileGenerationType {
163 pub fn from_value(value: f64) -> Self {
165 let rounded = (value * 100000.0).round() / 100000.0;
167 match rounded {
168 v if (v - 1.00001).abs() < 0.000001 => Self::Undefined,
169 v if (v - 1.00010).abs() < 0.000001 => Self::ComputerSimulation,
170 v if (v - 1.00000).abs() < 0.000001 => Self::UnaccreditedLab,
171 v if (v - 1.00100).abs() < 0.000001 => Self::UnaccreditedLabScaled,
172 v if (v - 1.01000).abs() < 0.000001 => Self::UnaccreditedLabInterpolated,
173 v if (v - 1.01100).abs() < 0.000001 => Self::UnaccreditedLabInterpolatedScaled,
174 v if (v - 1.10000).abs() < 0.000001 => Self::AccreditedLab,
175 v if (v - 1.10100).abs() < 0.000001 => Self::AccreditedLabScaled,
176 v if (v - 1.11000).abs() < 0.000001 => Self::AccreditedLabInterpolated,
177 v if (v - 1.11100).abs() < 0.000001 => Self::AccreditedLabInterpolatedScaled,
178 _ => Self::Undefined,
180 }
181 }
182
183 pub fn value(&self) -> f64 {
185 match self {
186 Self::Undefined => 1.00001,
187 Self::ComputerSimulation => 1.00010,
188 Self::UnaccreditedLab => 1.00000,
189 Self::UnaccreditedLabScaled => 1.00100,
190 Self::UnaccreditedLabInterpolated => 1.01000,
191 Self::UnaccreditedLabInterpolatedScaled => 1.01100,
192 Self::AccreditedLab => 1.10000,
193 Self::AccreditedLabScaled => 1.10100,
194 Self::AccreditedLabInterpolated => 1.11000,
195 Self::AccreditedLabInterpolatedScaled => 1.11100,
196 }
197 }
198
199 pub fn title(&self) -> &'static str {
201 match self {
202 Self::Undefined => "Undefined",
203 Self::ComputerSimulation => "Computer Simulation",
204 Self::UnaccreditedLab => "Test at an unaccredited lab",
205 Self::UnaccreditedLabScaled => "Test at an unaccredited lab that has been lumen scaled",
206 Self::UnaccreditedLabInterpolated => {
207 "Test at an unaccredited lab with interpolated angle set"
208 }
209 Self::UnaccreditedLabInterpolatedScaled => {
210 "Test at an unaccredited lab with interpolated angle set that has been lumen scaled"
211 }
212 Self::AccreditedLab => "Test at an accredited lab",
213 Self::AccreditedLabScaled => "Test at an accredited lab that has been lumen scaled",
214 Self::AccreditedLabInterpolated => {
215 "Test at an accredited lab with interpolated angle set"
216 }
217 Self::AccreditedLabInterpolatedScaled => {
218 "Test at an accredited lab with interpolated angle set that has been lumen scaled"
219 }
220 }
221 }
222
223 pub fn is_accredited(&self) -> bool {
225 matches!(
226 self,
227 Self::AccreditedLab
228 | Self::AccreditedLabScaled
229 | Self::AccreditedLabInterpolated
230 | Self::AccreditedLabInterpolatedScaled
231 )
232 }
233
234 pub fn is_scaled(&self) -> bool {
236 matches!(
237 self,
238 Self::UnaccreditedLabScaled
239 | Self::UnaccreditedLabInterpolatedScaled
240 | Self::AccreditedLabScaled
241 | Self::AccreditedLabInterpolatedScaled
242 )
243 }
244
245 pub fn is_interpolated(&self) -> bool {
247 matches!(
248 self,
249 Self::UnaccreditedLabInterpolated
250 | Self::UnaccreditedLabInterpolatedScaled
251 | Self::AccreditedLabInterpolated
252 | Self::AccreditedLabInterpolatedScaled
253 )
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
261#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
262pub enum LuminousShape {
263 #[default]
265 Point,
266 Rectangular,
268 RectangularWithSides,
270 Circular,
272 Ellipse,
274 VerticalCylinder,
276 VerticalEllipsoidalCylinder,
278 Sphere,
280 EllipsoidalSpheroid,
282 HorizontalCylinderAlong,
284 HorizontalEllipsoidalCylinderAlong,
286 HorizontalCylinderPerpendicular,
288 HorizontalEllipsoidalCylinderPerpendicular,
290 VerticalCircle,
292 VerticalEllipse,
294}
295
296impl LuminousShape {
297 pub fn from_dimensions(width: f64, length: f64, height: f64) -> Self {
299 let w_zero = width.abs() < 0.0001;
300 let l_zero = length.abs() < 0.0001;
301 let h_zero = height.abs() < 0.0001;
302 let w_neg = width < 0.0;
303 let l_neg = length < 0.0;
304 let h_neg = height < 0.0;
305 let w_pos = width > 0.0;
306 let l_pos = length > 0.0;
307 let h_pos = height > 0.0;
308
309 let wl_equal = (width - length).abs() < 0.0001;
311 let all_equal = wl_equal && (width - height).abs() < 0.0001;
312
313 match (
314 w_zero, l_zero, h_zero, w_neg, l_neg, h_neg, w_pos, l_pos, h_pos,
315 ) {
316 (true, true, true, _, _, _, _, _, _) => Self::Point,
318 (_, _, true, _, _, _, true, true, _) => Self::Rectangular,
320 (_, _, _, _, _, _, true, true, true) => Self::RectangularWithSides,
322 (_, _, true, true, true, _, _, _, _) if wl_equal => Self::Circular,
324 (_, _, true, true, true, _, _, _, _) => Self::Ellipse,
326 (_, _, _, true, true, true, _, _, _) if all_equal => Self::Sphere,
328 (_, _, _, true, true, true, _, _, _) => Self::EllipsoidalSpheroid,
330 (_, _, _, true, true, _, _, _, true) if wl_equal => Self::VerticalCylinder,
332 (_, _, _, true, true, _, _, _, true) => Self::VerticalEllipsoidalCylinder,
334 (_, _, _, true, _, true, _, true, _) if (width - height).abs() < 0.0001 => {
336 Self::HorizontalCylinderAlong
337 }
338 (_, _, _, true, _, true, _, true, _) => Self::HorizontalEllipsoidalCylinderAlong,
340 (_, _, _, _, true, true, true, _, _) if (length - height).abs() < 0.0001 => {
342 Self::HorizontalCylinderPerpendicular
343 }
344 (_, _, _, _, true, true, true, _, _) => {
346 Self::HorizontalEllipsoidalCylinderPerpendicular
347 }
348 (_, true, _, true, _, true, _, _, _) if (width - height).abs() < 0.0001 => {
350 Self::VerticalCircle
351 }
352 (_, true, _, true, _, true, _, _, _) => Self::VerticalEllipse,
354 _ => Self::Point,
356 }
357 }
358
359 pub fn description(&self) -> &'static str {
361 match self {
362 Self::Point => "Point source",
363 Self::Rectangular => "Rectangular luminous opening",
364 Self::RectangularWithSides => "Rectangular with luminous sides",
365 Self::Circular => "Circular luminous opening",
366 Self::Ellipse => "Elliptical luminous opening",
367 Self::VerticalCylinder => "Vertical cylinder",
368 Self::VerticalEllipsoidalCylinder => "Vertical ellipsoidal cylinder",
369 Self::Sphere => "Spherical luminous opening",
370 Self::EllipsoidalSpheroid => "Ellipsoidal spheroid",
371 Self::HorizontalCylinderAlong => "Horizontal cylinder along photometric horizontal",
372 Self::HorizontalEllipsoidalCylinderAlong => {
373 "Horizontal ellipsoidal cylinder along photometric horizontal"
374 }
375 Self::HorizontalCylinderPerpendicular => {
376 "Horizontal cylinder perpendicular to photometric horizontal"
377 }
378 Self::HorizontalEllipsoidalCylinderPerpendicular => {
379 "Horizontal ellipsoidal cylinder perpendicular to photometric horizontal"
380 }
381 Self::VerticalCircle => "Vertical circle facing photometric horizontal",
382 Self::VerticalEllipse => "Vertical ellipse facing photometric horizontal",
383 }
384 }
385}
386
387#[derive(Debug, Clone, Default)]
389#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
390pub struct TiltData {
391 pub lamp_geometry: i32,
396 pub angles: Vec<f64>,
398 pub factors: Vec<f64>,
400}
401
402#[derive(Debug, Clone, Copy, Default)]
404#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
405pub struct LampPosition {
406 pub horizontal: f64,
408 pub vertical: f64,
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
423#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
424pub enum PhotometricType {
425 TypeA = 3,
427 TypeB = 2,
429 #[default]
431 TypeC = 1,
432}
433
434impl PhotometricType {
435 pub fn from_int(value: i32) -> Result<Self> {
437 match value {
438 1 => Ok(Self::TypeC),
439 2 => Ok(Self::TypeB),
440 3 => Ok(Self::TypeA),
441 _ => Err(anyhow!("Invalid photometric type: {}", value)),
442 }
443 }
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
448#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
449pub enum UnitType {
450 Feet = 1,
452 #[default]
454 Meters = 2,
455}
456
457impl UnitType {
458 pub fn from_int(value: i32) -> Result<Self> {
460 match value {
461 1 => Ok(Self::Feet),
462 2 => Ok(Self::Meters),
463 _ => Err(anyhow!("Invalid unit type: {}", value)),
464 }
465 }
466
467 pub fn to_mm_factor(&self) -> f64 {
469 match self {
470 UnitType::Feet => 304.8, UnitType::Meters => 1000.0, }
473 }
474}
475
476#[derive(Debug, Clone, Default)]
478#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
479pub struct IesData {
480 pub version: IesVersion,
482 pub version_string: String,
484 pub keywords: HashMap<String, String>,
486
487 pub test: String,
490 pub test_lab: String,
492 pub issue_date: String,
494 pub manufacturer: String,
496
497 pub luminaire_catalog: String,
500 pub luminaire: String,
502 pub lamp_catalog: String,
504 pub lamp: String,
506 pub ballast: String,
508 pub ballast_catalog: String,
510 pub test_date: String,
512 pub maintenance_category: Option<i32>,
514 pub distribution: String,
516 pub flash_area: Option<f64>,
518 pub color_constant: Option<f64>,
520 pub lamp_position: Option<LampPosition>,
522 pub near_field: Option<(f64, f64, f64)>,
524 pub file_gen_info: String,
526 pub search: String,
528 pub other: Vec<String>,
530
531 pub num_lamps: i32,
534 pub lumens_per_lamp: f64,
536 pub multiplier: f64,
538 pub n_vertical: usize,
540 pub n_horizontal: usize,
542 pub photometric_type: PhotometricType,
544 pub unit_type: UnitType,
546 pub width: f64,
548 pub length: f64,
550 pub height: f64,
552 pub luminous_shape: LuminousShape,
554 pub ballast_factor: f64,
556 pub file_generation_type: FileGenerationType,
558 pub file_generation_value: f64,
560 pub input_watts: f64,
562
563 pub tilt_mode: String,
566 pub tilt_data: Option<TiltData>,
568
569 pub vertical_angles: Vec<f64>,
572 pub horizontal_angles: Vec<f64>,
574 pub candela_values: Vec<Vec<f64>>,
576}
577
578impl IesParser {
579 pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<Eulumdat> {
583 let content = read_with_encoding_fallback(path)?;
584 Self::parse(&content)
585 }
586
587 pub fn parse_file_with_options<P: AsRef<Path>>(
589 path: P,
590 options: &IesImportOptions,
591 ) -> Result<Eulumdat> {
592 let content = read_with_encoding_fallback(path)?;
593 Self::parse_with_options(&content, options)
594 }
595
596 pub fn parse(content: &str) -> Result<Eulumdat> {
598 let ies_data = Self::parse_ies_data(content)?;
599 Self::convert_to_eulumdat(ies_data)
600 }
601
602 pub fn parse_with_options(content: &str, options: &IesImportOptions) -> Result<Eulumdat> {
604 let ies_data = Self::parse_ies_data(content)?;
605 let mut ldt = Self::convert_to_eulumdat(ies_data)?;
606 if options.rotate_c_planes.abs() > 0.001 {
607 ldt.rotate_c_planes(options.rotate_c_planes);
608 }
609 Ok(ldt)
610 }
611
612 pub fn parse_to_ies_data(content: &str) -> Result<IesData> {
616 Self::parse_ies_data(content)
617 }
618
619 fn parse_ies_data(content: &str) -> Result<IesData> {
621 let mut data = IesData::default();
622 let lines: Vec<&str> = content.lines().collect();
623
624 if lines.is_empty() {
625 return Err(anyhow!("Empty IES file"));
626 }
627
628 let mut line_idx = 0;
629
630 let first_line = lines[line_idx].trim();
632 if first_line.to_uppercase().starts_with("IES")
636 || first_line.to_uppercase().starts_with("IESNA")
637 {
638 data.version_string = first_line.to_string();
639 data.version = IesVersion::from_header(first_line);
640 line_idx += 1;
641 } else {
642 data.version_string = "IESNA91".to_string();
644 data.version = IesVersion::Lm63_1991;
645 }
646
647 let mut current_keyword = String::new();
649 let mut current_value = String::new();
650 let mut last_stored_keyword = String::new(); while line_idx < lines.len() {
653 let line = lines[line_idx].trim();
654
655 if line.to_uppercase().starts_with("TILT=") || line.to_uppercase().starts_with("TILT ")
656 {
657 if !current_keyword.is_empty() {
659 Self::store_keyword(&mut data, ¤t_keyword, ¤t_value);
660 }
661 break;
662 }
663
664 if line.starts_with('[') {
666 if !current_keyword.is_empty() {
668 Self::store_keyword(&mut data, ¤t_keyword, ¤t_value);
669 last_stored_keyword = current_keyword.clone();
670 }
671
672 if let Some(end_bracket) = line.find(']') {
673 current_keyword = line[1..end_bracket].to_uppercase();
674 current_value = line[end_bracket + 1..].trim().to_string();
675
676 if current_keyword == "MORE" && !last_stored_keyword.is_empty() {
678 if let Some(existing) = data.keywords.get_mut(&last_stored_keyword) {
680 existing.push('\n');
681 existing.push_str(¤t_value);
682 }
683 current_keyword.clear();
684 current_value.clear();
685 }
686 }
687 }
688
689 line_idx += 1;
690 }
691
692 if line_idx < lines.len() {
694 let tilt_line = lines[line_idx].trim().to_uppercase();
695 data.tilt_mode = if tilt_line.contains("INCLUDE") {
696 "INCLUDE".to_string()
697 } else {
698 "NONE".to_string()
699 };
700
701 line_idx += 1;
702
703 if tilt_line.contains("INCLUDE") {
704 let mut tilt = TiltData::default();
706
707 if line_idx < lines.len() {
709 if let Ok(geom) = lines[line_idx].trim().parse::<i32>() {
710 tilt.lamp_geometry = geom;
711 }
712 line_idx += 1;
713 }
714
715 if line_idx < lines.len() {
717 if let Ok(n_pairs) = lines[line_idx].trim().parse::<usize>() {
718 line_idx += 1;
719
720 let mut angle_values: Vec<f64> = Vec::new();
722 while angle_values.len() < n_pairs && line_idx < lines.len() {
723 for token in lines[line_idx].split_whitespace() {
724 if let Ok(val) = token.replace(',', ".").parse::<f64>() {
725 angle_values.push(val);
726 }
727 }
728 line_idx += 1;
729 }
730 tilt.angles = angle_values;
731
732 let mut factor_values: Vec<f64> = Vec::new();
734 while factor_values.len() < n_pairs && line_idx < lines.len() {
735 for token in lines[line_idx].split_whitespace() {
736 if let Ok(val) = token.replace(',', ".").parse::<f64>() {
737 factor_values.push(val);
738 }
739 }
740 line_idx += 1;
741 }
742 tilt.factors = factor_values;
743 }
744 }
745
746 data.tilt_data = Some(tilt);
747 }
748 }
749
750 let mut numeric_values: Vec<f64> = Vec::new();
752 while line_idx < lines.len() {
753 let line = lines[line_idx].trim();
754 for token in line.split_whitespace() {
755 if let Ok(val) = token.replace(',', ".").parse::<f64>() {
756 numeric_values.push(val);
757 }
758 }
759 line_idx += 1;
760 }
761
762 if numeric_values.len() < 13 {
764 return Err(anyhow!(
765 "Insufficient photometric data: expected at least 13 values, found {}",
766 numeric_values.len()
767 ));
768 }
769
770 let mut idx = 0;
771
772 data.num_lamps = numeric_values[idx] as i32;
774 idx += 1;
775 data.lumens_per_lamp = numeric_values[idx];
776 idx += 1;
777 data.multiplier = numeric_values[idx];
778 idx += 1;
779 data.n_vertical = numeric_values[idx] as usize;
780 idx += 1;
781 data.n_horizontal = numeric_values[idx] as usize;
782 idx += 1;
783 data.photometric_type = PhotometricType::from_int(numeric_values[idx] as i32)?;
784 idx += 1;
785 data.unit_type = UnitType::from_int(numeric_values[idx] as i32)?;
786 idx += 1;
787 data.width = numeric_values[idx];
788 idx += 1;
789 data.length = numeric_values[idx];
790 idx += 1;
791 data.height = numeric_values[idx];
792 idx += 1;
793
794 data.luminous_shape = LuminousShape::from_dimensions(data.width, data.length, data.height);
796
797 data.ballast_factor = numeric_values[idx];
799 idx += 1;
800 data.file_generation_value = numeric_values[idx];
801 data.file_generation_type = FileGenerationType::from_value(data.file_generation_value);
802 idx += 1;
803 data.input_watts = numeric_values[idx];
804 idx += 1;
805
806 if idx + data.n_vertical > numeric_values.len() {
808 return Err(anyhow!("Insufficient vertical angle data"));
809 }
810 data.vertical_angles = numeric_values[idx..idx + data.n_vertical].to_vec();
811 idx += data.n_vertical;
812
813 if idx + data.n_horizontal > numeric_values.len() {
815 return Err(anyhow!("Insufficient horizontal angle data"));
816 }
817 data.horizontal_angles = numeric_values[idx..idx + data.n_horizontal].to_vec();
818 idx += data.n_horizontal;
819
820 let expected_candela = data.n_horizontal * data.n_vertical;
822 if idx + expected_candela > numeric_values.len() {
823 return Err(anyhow!(
824 "Insufficient candela data: expected {}, remaining {}",
825 expected_candela,
826 numeric_values.len() - idx
827 ));
828 }
829
830 for _ in 0..data.n_horizontal {
831 let row: Vec<f64> = numeric_values[idx..idx + data.n_vertical].to_vec();
832 data.candela_values.push(row);
833 idx += data.n_vertical;
834 }
835
836 Ok(data)
837 }
838
839 fn store_keyword(data: &mut IesData, keyword: &str, value: &str) {
841 data.keywords.insert(keyword.to_string(), value.to_string());
843
844 match keyword {
846 "TEST" => data.test = value.to_string(),
847 "TESTLAB" => data.test_lab = value.to_string(),
848 "ISSUEDATE" => data.issue_date = value.to_string(),
849 "MANUFAC" => data.manufacturer = value.to_string(),
850 "LUMCAT" => data.luminaire_catalog = value.to_string(),
851 "LUMINAIRE" => data.luminaire = value.to_string(),
852 "LAMPCAT" => data.lamp_catalog = value.to_string(),
853 "LAMP" => data.lamp = value.to_string(),
854 "BALLAST" => data.ballast = value.to_string(),
855 "BALLASTCAT" => data.ballast_catalog = value.to_string(),
856 "TESTDATE" => data.test_date = value.to_string(),
857 "MAINTCAT" => data.maintenance_category = value.trim().parse().ok(),
858 "DISTRIBUTION" => data.distribution = value.to_string(),
859 "FLASHAREA" => data.flash_area = value.trim().parse().ok(),
860 "COLORCONSTANT" => data.color_constant = value.trim().parse().ok(),
861 "LAMPPOSITION" => {
862 let parts: Vec<f64> = value
863 .split([' ', ','])
864 .filter_map(|s| s.trim().parse().ok())
865 .collect();
866 if parts.len() >= 2 {
867 data.lamp_position = Some(LampPosition {
868 horizontal: parts[0],
869 vertical: parts[1],
870 });
871 }
872 }
873 "NEARFIELD" => {
874 let parts: Vec<f64> = value
875 .split([' ', ','])
876 .filter_map(|s| s.trim().parse().ok())
877 .collect();
878 if parts.len() >= 3 {
879 data.near_field = Some((parts[0], parts[1], parts[2]));
880 }
881 }
882 "FILEGENINFO" => {
883 if data.file_gen_info.is_empty() {
884 data.file_gen_info = value.to_string();
885 } else {
886 data.file_gen_info.push('\n');
887 data.file_gen_info.push_str(value);
888 }
889 }
890 "SEARCH" => data.search = value.to_string(),
891 "OTHER" => data.other.push(value.to_string()),
892 _ => {
893 }
895 }
896 }
897
898 fn convert_to_eulumdat(ies: IesData) -> Result<Eulumdat> {
900 let mut ldt = Eulumdat::new();
901
902 ldt.identification = ies.manufacturer.clone();
904 ldt.luminaire_name = ies.luminaire.clone();
905 ldt.luminaire_number = ies.luminaire_catalog.clone();
906 ldt.measurement_report_number = ies.test.clone();
907 ldt.file_name = ies.test_lab.clone();
908 ldt.date_user = ies.issue_date.clone();
910
911 ldt.symmetry = Self::detect_symmetry(&ies.horizontal_angles);
913
914 ldt.type_indicator = if ies.length > ies.width * 2.0 {
916 TypeIndicator::Linear
917 } else if ldt.symmetry == Symmetry::VerticalAxis {
918 TypeIndicator::PointSourceSymmetric
919 } else {
920 TypeIndicator::PointSourceOther
921 };
922
923 ldt.c_angles = ies.horizontal_angles.clone();
925 ldt.g_angles = ies.vertical_angles.clone();
926 ldt.num_c_planes = ies.n_horizontal;
927 ldt.num_g_planes = ies.n_vertical;
928
929 if ldt.c_angles.len() >= 2 {
931 ldt.c_plane_distance = ldt.c_angles[1] - ldt.c_angles[0];
932 }
933 if ldt.g_angles.len() >= 2 {
934 ldt.g_plane_distance = ldt.g_angles[1] - ldt.g_angles[0];
935 }
936
937 let mm_factor = ies.unit_type.to_mm_factor();
939 ldt.length = ies.length * mm_factor;
940 ldt.width = ies.width * mm_factor;
941 ldt.height = ies.height * mm_factor;
942
943 ldt.luminous_area_length = ldt.length;
945 ldt.luminous_area_width = ldt.width;
946
947 let (num_lamps, total_flux) = if ies.lumens_per_lamp < 0.0 {
953 let calculated_flux =
957 Self::calculate_flux_from_intensities(&ies.candela_values, &ies.vertical_angles)
958 * ies.multiplier;
959 (-1, calculated_flux)
960 } else {
961 (ies.num_lamps, ies.lumens_per_lamp * ies.num_lamps as f64)
963 };
964
965 ldt.lamp_sets.push(LampSet {
966 num_lamps,
967 lamp_type: if ies.lamp.is_empty() {
968 "Unknown".to_string()
969 } else {
970 ies.lamp.clone()
971 },
972 total_luminous_flux: total_flux,
973 color_appearance: ies.keywords.get("COLORTEMP").cloned().unwrap_or_default(),
974 color_rendering_group: ies.keywords.get("CRI").cloned().unwrap_or_default(),
975 wattage_with_ballast: ies.input_watts,
976 });
977
978 let cd_to_cdklm = if total_flux > 0.0 {
981 1000.0 / total_flux
982 } else {
983 1.0
984 };
985
986 ldt.intensities = ies
987 .candela_values
988 .iter()
989 .map(|row| {
990 row.iter()
991 .map(|&v| v * cd_to_cdklm * ies.multiplier)
992 .collect()
993 })
994 .collect();
995
996 ldt.conversion_factor = ies.multiplier;
998 ldt.downward_flux_fraction =
999 crate::calculations::PhotometricCalculations::downward_flux(&ldt, 90.0);
1000 ldt.light_output_ratio = 100.0; Ok(ldt)
1003 }
1004
1005 fn detect_symmetry(h_angles: &[f64]) -> Symmetry {
1007 if h_angles.is_empty() {
1008 return Symmetry::None;
1009 }
1010
1011 let min_angle = h_angles.iter().cloned().fold(f64::INFINITY, f64::min);
1012 let max_angle = h_angles.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1013
1014 if h_angles.len() == 1 {
1015 Symmetry::VerticalAxis
1017 } else if (max_angle - 90.0).abs() < 0.1 && min_angle.abs() < 0.1 {
1018 Symmetry::BothPlanes
1020 } else if (max_angle - 180.0).abs() < 0.1 && min_angle.abs() < 0.1 {
1021 Symmetry::PlaneC0C180
1023 } else if (min_angle - 90.0).abs() < 0.1 && (max_angle - 270.0).abs() < 0.1 {
1024 Symmetry::PlaneC90C270
1026 } else {
1027 Symmetry::None
1029 }
1030 }
1031
1032 fn calculate_flux_from_intensities(
1040 candela_values: &[Vec<f64>],
1041 vertical_angles: &[f64],
1042 ) -> f64 {
1043 if candela_values.is_empty() || vertical_angles.len() < 2 {
1044 return 0.0;
1045 }
1046
1047 let n_h = candela_values.len();
1048 let n_v = vertical_angles.len();
1049
1050 let avg_intensities: Vec<f64> = (0..n_v)
1052 .map(|v| {
1053 let sum: f64 = candela_values.iter().filter_map(|row| row.get(v)).sum();
1054 sum / n_h as f64
1055 })
1056 .collect();
1057
1058 let mut flux = 0.0;
1061 for i in 0..n_v - 1 {
1062 let gamma1 = vertical_angles[i].to_radians();
1063 let gamma2 = vertical_angles[i + 1].to_radians();
1064 let i1 = avg_intensities[i];
1065 let i2 = avg_intensities[i + 1];
1066
1067 let dg = gamma2 - gamma1;
1070 flux += (i1 * gamma1.sin() + i2 * gamma2.sin()) / 2.0 * dg;
1071 }
1072
1073 flux * 2.0 * std::f64::consts::PI
1076 }
1077}
1078
1079pub struct IesExporter;
1083
1084#[derive(Debug, Clone)]
1090pub struct IesImportOptions {
1091 pub rotate_c_planes: f64,
1094}
1095
1096impl Default for IesImportOptions {
1097 fn default() -> Self {
1098 Self {
1099 rotate_c_planes: 0.0,
1100 }
1101 }
1102}
1103
1104#[derive(Debug, Clone)]
1106pub struct IesExportOptions {
1107 pub version: IesVersion,
1109 pub file_generation_type: FileGenerationType,
1111 pub issue_date: Option<String>,
1113 pub file_gen_info: Option<String>,
1115 pub test_lab: Option<String>,
1117 pub rotate_c_planes: f64,
1120}
1121
1122impl Default for IesExportOptions {
1123 fn default() -> Self {
1124 Self {
1125 version: IesVersion::Lm63_2019,
1126 file_generation_type: FileGenerationType::Undefined,
1127 issue_date: None,
1128 file_gen_info: None,
1129 test_lab: None,
1130 rotate_c_planes: 0.0,
1131 }
1132 }
1133}
1134
1135impl IesExporter {
1136 pub fn export(ldt: &Eulumdat) -> String {
1138 Self::export_with_options(ldt, &IesExportOptions::default())
1139 }
1140
1141 pub fn export_2002(ldt: &Eulumdat) -> String {
1143 Self::export_with_options(
1144 ldt,
1145 &IesExportOptions {
1146 version: IesVersion::Lm63_2002,
1147 ..Default::default()
1148 },
1149 )
1150 }
1151
1152 pub fn export_with_options(ldt: &Eulumdat, options: &IesExportOptions) -> String {
1154 let rotated;
1156 let ldt = if options.rotate_c_planes.abs() > 0.001 {
1157 rotated = {
1158 let mut copy = ldt.clone();
1159 copy.rotate_c_planes(options.rotate_c_planes);
1160 copy
1161 };
1162 &rotated
1163 } else {
1164 ldt
1165 };
1166
1167 let mut output = String::new();
1168
1169 output.push_str(options.version.header());
1171 output.push('\n');
1172
1173 Self::write_keyword(&mut output, "TEST", &ldt.measurement_report_number);
1175
1176 let test_lab = options.test_lab.as_deref().unwrap_or(&ldt.file_name);
1178 if !test_lab.is_empty() {
1179 Self::write_keyword(&mut output, "TESTLAB", test_lab);
1180 }
1181
1182 if options.version == IesVersion::Lm63_2019 {
1184 let issue_date = options
1185 .issue_date
1186 .as_deref()
1187 .filter(|s| !s.is_empty())
1188 .unwrap_or_else(|| {
1189 if !ldt.date_user.is_empty() {
1190 &ldt.date_user
1191 } else {
1192 "01-JAN-2025"
1194 }
1195 });
1196 Self::write_keyword(&mut output, "ISSUEDATE", issue_date);
1197 }
1198
1199 if !ldt.identification.is_empty() {
1201 Self::write_keyword(&mut output, "MANUFAC", &ldt.identification);
1202 }
1203
1204 Self::write_keyword(&mut output, "LUMCAT", &ldt.luminaire_number);
1206 Self::write_keyword(&mut output, "LUMINAIRE", &ldt.luminaire_name);
1207
1208 if !ldt.lamp_sets.is_empty() {
1209 Self::write_keyword(&mut output, "LAMP", &ldt.lamp_sets[0].lamp_type);
1210 if ldt.lamp_sets[0].total_luminous_flux > 0.0 {
1211 Self::write_keyword(
1212 &mut output,
1213 "LAMPCAT",
1214 &format!("{:.0} lm", ldt.lamp_sets[0].total_luminous_flux),
1215 );
1216 }
1217 }
1218
1219 if options.version == IesVersion::Lm63_2019 {
1221 if let Some(ref info) = options.file_gen_info {
1222 Self::write_keyword(&mut output, "FILEGENINFO", info);
1223 }
1224 }
1225
1226 output.push_str("TILT=NONE\n");
1228
1229 let num_lamps = ldt.lamp_sets.iter().map(|ls| ls.num_lamps).sum::<i32>();
1232 let total_flux = ldt.total_luminous_flux();
1233
1234 let lumens_per_lamp = if num_lamps < 0 {
1238 -1.0
1240 } else if num_lamps > 0 {
1241 total_flux / num_lamps as f64
1243 } else {
1244 total_flux
1246 };
1247
1248 let (h_angles, v_angles, intensities) = Self::prepare_photometric_data(ldt);
1250
1251 let photometric_type = 1;
1253 let units_type = 2;
1255
1256 let width = ldt.width / 1000.0;
1258 let length = ldt.length / 1000.0;
1259 let height = ldt.height / 1000.0;
1260
1261 let ies_num_lamps = num_lamps.abs().max(1);
1263
1264 output.push_str(&format!(
1265 "{} {:.1} {:.6} {} {} {} {} {:.4} {:.4} {:.4}\n",
1266 ies_num_lamps,
1267 lumens_per_lamp,
1268 ldt.conversion_factor.max(1.0),
1269 v_angles.len(),
1270 h_angles.len(),
1271 photometric_type,
1272 units_type,
1273 width,
1274 length,
1275 height
1276 ));
1277
1278 let total_watts = ldt.total_wattage();
1280 let file_gen_value = if options.version == IesVersion::Lm63_2019 {
1281 options.file_generation_type.value()
1282 } else {
1283 1.0 };
1285 output.push_str(&format!("1.0 {:.5} {:.1}\n", file_gen_value, total_watts));
1286
1287 output.push_str(&Self::format_values_multiline(&v_angles, 10));
1289 output.push('\n');
1290
1291 output.push_str(&Self::format_values_multiline(&h_angles, 10));
1293 output.push('\n');
1294
1295 let cdklm_to_cd = total_flux / 1000.0;
1298 for row in &intensities {
1299 let absolute_candela: Vec<f64> = row.iter().map(|&v| v * cdklm_to_cd).collect();
1300 output.push_str(&Self::format_values_multiline(&absolute_candela, 10));
1301 output.push('\n');
1302 }
1303
1304 output
1305 }
1306
1307 fn write_keyword(output: &mut String, keyword: &str, value: &str) {
1309 if !value.is_empty() {
1310 output.push_str(&format!("[{}] {}\n", keyword, value));
1311 }
1312 }
1313
1314 fn prepare_photometric_data(ldt: &Eulumdat) -> (Vec<f64>, Vec<f64>, Vec<Vec<f64>>) {
1318 let v_angles = ldt.g_angles.clone();
1321
1322 let (h_angles, intensities) = match ldt.symmetry {
1324 Symmetry::VerticalAxis => {
1325 (
1327 vec![0.0],
1328 vec![ldt.intensities.first().cloned().unwrap_or_default()],
1329 )
1330 }
1331 Symmetry::PlaneC0C180 => {
1332 let expanded = SymmetryHandler::expand_to_full(ldt);
1334 let h = SymmetryHandler::expand_c_angles(ldt);
1335 let mut h_filtered = Vec::new();
1337 let mut i_filtered = Vec::new();
1338 for (i, &angle) in h.iter().enumerate() {
1339 if angle <= 180.0 && i < expanded.len() {
1340 h_filtered.push(angle);
1341 i_filtered.push(expanded[i].clone());
1342 }
1343 }
1344 (h_filtered, i_filtered)
1345 }
1346 Symmetry::PlaneC90C270 => {
1347 let expanded = SymmetryHandler::expand_to_full(ldt);
1350 let h = SymmetryHandler::expand_c_angles(ldt);
1351 (h, expanded)
1352 }
1353 Symmetry::BothPlanes => {
1354 let h: Vec<f64> = ldt
1356 .c_angles
1357 .iter()
1358 .filter(|&&a| a <= 90.0)
1359 .copied()
1360 .collect();
1361 let i: Vec<Vec<f64>> = ldt.intensities.iter().take(h.len()).cloned().collect();
1362 (h, i)
1363 }
1364 Symmetry::None => {
1365 (ldt.c_angles.clone(), ldt.intensities.clone())
1367 }
1368 };
1369
1370 (h_angles, v_angles, intensities)
1371 }
1372
1373 fn format_values_multiline(values: &[f64], per_line: usize) -> String {
1375 values
1376 .chunks(per_line)
1377 .map(|chunk| {
1378 chunk
1379 .iter()
1380 .map(|&v| format!("{:.2}", v))
1381 .collect::<Vec<_>>()
1382 .join(" ")
1383 })
1384 .collect::<Vec<_>>()
1385 .join("\n")
1386 }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::*;
1392
1393 #[test]
1394 fn test_ies_export() {
1395 let mut ldt = Eulumdat::new();
1396 ldt.identification = "Test Manufacturer".to_string();
1397 ldt.luminaire_name = "Test Luminaire".to_string();
1398 ldt.luminaire_number = "LUM-001".to_string();
1399 ldt.measurement_report_number = "TEST-001".to_string();
1400 ldt.symmetry = Symmetry::VerticalAxis;
1401 ldt.num_c_planes = 1;
1402 ldt.num_g_planes = 5;
1403 ldt.c_angles = vec![0.0];
1404 ldt.g_angles = vec![0.0, 22.5, 45.0, 67.5, 90.0];
1405 ldt.intensities = vec![vec![1000.0, 900.0, 700.0, 400.0, 100.0]];
1406 ldt.lamp_sets.push(LampSet {
1407 num_lamps: 1,
1408 lamp_type: "LED".to_string(),
1409 total_luminous_flux: 1000.0,
1410 color_appearance: "3000K".to_string(),
1411 color_rendering_group: "80".to_string(),
1412 wattage_with_ballast: 10.0,
1413 });
1414 ldt.conversion_factor = 1.0;
1415 ldt.length = 100.0;
1416 ldt.width = 100.0;
1417 ldt.height = 50.0;
1418
1419 let ies = IesExporter::export(&ldt);
1420
1421 assert!(ies.contains("IES:LM-63-2019"));
1423 assert!(ies.contains("[LUMINAIRE] Test Luminaire"));
1424 assert!(ies.contains("[MANUFAC] Test Manufacturer"));
1425 assert!(ies.contains("[ISSUEDATE]")); assert!(ies.contains("TILT=NONE"));
1427
1428 let ies_2002 = IesExporter::export_2002(&ldt);
1430 assert!(ies_2002.contains("IESNA:LM-63-2002"));
1431 assert!(!ies_2002.contains("[ISSUEDATE]")); }
1433
1434 #[test]
1435 fn test_ies_parse() {
1436 let ies_content = r#"IESNA:LM-63-2002
1437[TEST] TEST-001
1438[MANUFAC] Test Company
1439[LUMINAIRE] Test Fixture
1440[LAMP] LED Module
1441TILT=NONE
14421 1000.0 1.0 5 1 1 2 0.1 0.1 0.05
14431.0 1.0 10.0
14440.0 22.5 45.0 67.5 90.0
14450.0
14461000.0 900.0 700.0 400.0 100.0
1447"#;
1448
1449 let ldt = IesParser::parse(ies_content).expect("Failed to parse IES");
1450
1451 assert_eq!(ldt.luminaire_name, "Test Fixture");
1452 assert_eq!(ldt.identification, "Test Company");
1453 assert_eq!(ldt.measurement_report_number, "TEST-001");
1454 assert_eq!(ldt.g_angles.len(), 5);
1455 assert_eq!(ldt.c_angles.len(), 1);
1456 assert_eq!(ldt.symmetry, Symmetry::VerticalAxis);
1457 assert!(!ldt.intensities.is_empty());
1458 }
1459
1460 #[test]
1461 fn test_ies_roundtrip() {
1462 let mut ldt = Eulumdat::new();
1463 ldt.identification = "Roundtrip Test".to_string();
1464 ldt.luminaire_name = "Test Luminaire".to_string();
1465 ldt.symmetry = Symmetry::VerticalAxis;
1466 ldt.c_angles = vec![0.0];
1467 ldt.g_angles = vec![0.0, 45.0, 90.0];
1468 ldt.intensities = vec![vec![500.0, 400.0, 200.0]];
1469 ldt.lamp_sets.push(LampSet {
1470 num_lamps: 1,
1471 lamp_type: "LED".to_string(),
1472 total_luminous_flux: 1000.0,
1473 ..Default::default()
1474 });
1475 ldt.length = 100.0;
1476 ldt.width = 100.0;
1477 ldt.height = 50.0;
1478
1479 let ies = IesExporter::export(&ldt);
1481
1482 let parsed = IesParser::parse(&ies).expect("Failed to parse exported IES");
1484
1485 assert_eq!(parsed.luminaire_name, ldt.luminaire_name);
1487 assert_eq!(parsed.g_angles.len(), ldt.g_angles.len());
1488 assert_eq!(parsed.symmetry, Symmetry::VerticalAxis);
1489 }
1490
1491 #[test]
1492 fn test_detect_symmetry() {
1493 assert_eq!(IesParser::detect_symmetry(&[0.0]), Symmetry::VerticalAxis);
1494 assert_eq!(
1495 IesParser::detect_symmetry(&[0.0, 45.0, 90.0]),
1496 Symmetry::BothPlanes
1497 );
1498 assert_eq!(
1499 IesParser::detect_symmetry(&[0.0, 45.0, 90.0, 135.0, 180.0]),
1500 Symmetry::PlaneC0C180
1501 );
1502 assert_eq!(
1503 IesParser::detect_symmetry(&[0.0, 90.0, 180.0, 270.0, 360.0]),
1504 Symmetry::None
1505 );
1506 }
1507
1508 #[test]
1509 fn test_photometric_type() {
1510 assert_eq!(
1511 PhotometricType::from_int(1).unwrap(),
1512 PhotometricType::TypeC
1513 );
1514 assert_eq!(
1515 PhotometricType::from_int(2).unwrap(),
1516 PhotometricType::TypeB
1517 );
1518 assert_eq!(
1519 PhotometricType::from_int(3).unwrap(),
1520 PhotometricType::TypeA
1521 );
1522 assert!(PhotometricType::from_int(0).is_err());
1523 }
1524
1525 #[test]
1526 fn test_unit_conversion() {
1527 assert!((UnitType::Feet.to_mm_factor() - 304.8).abs() < 0.01);
1528 assert!((UnitType::Meters.to_mm_factor() - 1000.0).abs() < 0.01);
1529 }
1530
1531 #[test]
1532 fn test_ies_version_parsing() {
1533 assert_eq!(
1534 IesVersion::from_header("IES:LM-63-2019"),
1535 IesVersion::Lm63_2019
1536 );
1537 assert_eq!(
1538 IesVersion::from_header("IESNA:LM-63-2002"),
1539 IesVersion::Lm63_2002
1540 );
1541 assert_eq!(
1542 IesVersion::from_header("IESNA:LM-63-1995"),
1543 IesVersion::Lm63_1995
1544 );
1545 assert_eq!(IesVersion::from_header("IESNA91"), IesVersion::Lm63_1991);
1546 }
1547
1548 #[test]
1549 fn test_file_generation_type() {
1550 assert_eq!(
1551 FileGenerationType::from_value(1.00001),
1552 FileGenerationType::Undefined
1553 );
1554 assert_eq!(
1555 FileGenerationType::from_value(1.00010),
1556 FileGenerationType::ComputerSimulation
1557 );
1558 assert_eq!(
1559 FileGenerationType::from_value(1.10000),
1560 FileGenerationType::AccreditedLab
1561 );
1562 assert_eq!(
1563 FileGenerationType::from_value(1.10100),
1564 FileGenerationType::AccreditedLabScaled
1565 );
1566
1567 assert!(FileGenerationType::AccreditedLab.is_accredited());
1569 assert!(!FileGenerationType::UnaccreditedLab.is_accredited());
1570
1571 assert!(FileGenerationType::AccreditedLabScaled.is_scaled());
1573 assert!(!FileGenerationType::AccreditedLab.is_scaled());
1574 }
1575
1576 #[test]
1577 fn test_luminous_shape() {
1578 assert_eq!(
1580 LuminousShape::from_dimensions(0.0, 0.0, 0.0),
1581 LuminousShape::Point
1582 );
1583
1584 assert_eq!(
1586 LuminousShape::from_dimensions(0.5, 0.6, 0.0),
1587 LuminousShape::Rectangular
1588 );
1589
1590 assert_eq!(
1592 LuminousShape::from_dimensions(-0.3, -0.3, 0.0),
1593 LuminousShape::Circular
1594 );
1595
1596 assert_eq!(
1598 LuminousShape::from_dimensions(-0.2, -0.2, -0.2),
1599 LuminousShape::Sphere
1600 );
1601 }
1602
1603 #[test]
1604 fn test_ies_2019_parse() {
1605 let ies_content = r#"IES:LM-63-2019
1606[TEST] ABC1234
1607[TESTLAB] ABC Laboratories
1608[ISSUEDATE] 28-FEB-2019
1609[MANUFAC] Test Company
1610[LUMCAT] SKYVIEW-123
1611[LUMINAIRE] LED Wide beam flood
1612[LAMP] LED Module
1613[FILEGENINFO] This file was generated from test data
1614TILT=NONE
16151 -1 1.0 5 1 1 2 0.1 0.1 0.0
16161.0 1.10000 50.0
16170.0 22.5 45.0 67.5 90.0
16180.0
16191000.0 900.0 700.0 400.0 100.0
1620"#;
1621
1622 let ies_data = IesParser::parse_to_ies_data(ies_content).expect("Failed to parse IES");
1623
1624 assert_eq!(ies_data.version, IesVersion::Lm63_2019);
1625 assert_eq!(ies_data.test, "ABC1234");
1626 assert_eq!(ies_data.test_lab, "ABC Laboratories");
1627 assert_eq!(ies_data.issue_date, "28-FEB-2019");
1628 assert_eq!(ies_data.manufacturer, "Test Company");
1629 assert_eq!(
1630 ies_data.file_generation_type,
1631 FileGenerationType::AccreditedLab
1632 );
1633 assert_eq!(
1634 ies_data.file_gen_info,
1635 "This file was generated from test data"
1636 );
1637 assert_eq!(ies_data.lumens_per_lamp, -1.0); }
1639
1640 #[test]
1641 fn test_ies_tilt_include() {
1642 let ies_content = r#"IES:LM-63-2019
1643[TEST] TILT-TEST
1644[TESTLAB] Test Lab
1645[ISSUEDATE] 01-JAN-2020
1646[MANUFAC] Test Mfg
1647TILT=INCLUDE
16481
16497
16500 15 30 45 60 75 90
16511.0 0.95 0.94 0.90 0.88 0.87 0.94
16521 1000.0 1.0 3 1 1 2 0.1 0.1 0.0
16531.0 1.00001 10.0
16540.0 45.0 90.0
16550.0
1656100.0 80.0 50.0
1657"#;
1658
1659 let ies_data = IesParser::parse_to_ies_data(ies_content).expect("Failed to parse");
1660
1661 assert_eq!(ies_data.tilt_mode, "INCLUDE");
1662 assert!(ies_data.tilt_data.is_some());
1663
1664 let tilt = ies_data.tilt_data.as_ref().unwrap();
1665 assert_eq!(tilt.lamp_geometry, 1);
1666 assert_eq!(tilt.angles.len(), 7);
1667 assert_eq!(tilt.factors.len(), 7);
1668 assert!((tilt.angles[0] - 0.0).abs() < 0.001);
1669 assert!((tilt.factors[0] - 1.0).abs() < 0.001);
1670 }
1671
1672 #[test]
1673 fn test_more_continuation() {
1674 let ies_content = r#"IES:LM-63-2019
1675[TEST] MORE-TEST
1676[TESTLAB] Test Lab
1677[ISSUEDATE] 01-JAN-2020
1678[MANUFAC] Test Manufacturer
1679[OTHER] This is the first line of other info
1680[MORE] This is the second line of other info
1681TILT=NONE
16821 1000.0 1.0 3 1 1 2 0.1 0.1 0.0
16831.0 1.00001 10.0
16840.0 45.0 90.0
16850.0
1686100.0 80.0 50.0
1687"#;
1688
1689 let ies_data = IesParser::parse_to_ies_data(ies_content).expect("Failed to parse");
1690
1691 let other_value = ies_data.keywords.get("OTHER").expect("OTHER not found");
1693 assert!(other_value.contains("first line"));
1694 assert!(other_value.contains("second line"));
1695 }
1696}
1697
1698#[derive(Debug, Clone, PartialEq)]
1702pub struct IesValidationWarning {
1703 pub code: &'static str,
1705 pub message: String,
1707 pub severity: IesValidationSeverity,
1709}
1710
1711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1713pub enum IesValidationSeverity {
1714 Required,
1716 Recommended,
1718 Info,
1720}
1721
1722impl std::fmt::Display for IesValidationWarning {
1723 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1724 let severity = match self.severity {
1725 IesValidationSeverity::Required => "ERROR",
1726 IesValidationSeverity::Recommended => "WARNING",
1727 IesValidationSeverity::Info => "INFO",
1728 };
1729 write!(f, "[{}:{}] {}", self.code, severity, self.message)
1730 }
1731}
1732
1733pub fn validate_ies(data: &IesData) -> Vec<IesValidationWarning> {
1735 let mut warnings = Vec::new();
1736
1737 if data.test.is_empty() {
1741 warnings.push(IesValidationWarning {
1742 code: "IES001",
1743 message: "Missing required keyword [TEST]".to_string(),
1744 severity: IesValidationSeverity::Required,
1745 });
1746 }
1747
1748 if data.test_lab.is_empty() {
1750 warnings.push(IesValidationWarning {
1751 code: "IES002",
1752 message: "Missing required keyword [TESTLAB]".to_string(),
1753 severity: IesValidationSeverity::Required,
1754 });
1755 }
1756
1757 if data.version == IesVersion::Lm63_2019 && data.issue_date.is_empty() {
1759 warnings.push(IesValidationWarning {
1760 code: "IES003",
1761 message: "Missing required keyword [ISSUEDATE] for LM-63-2019".to_string(),
1762 severity: IesValidationSeverity::Required,
1763 });
1764 }
1765
1766 if data.manufacturer.is_empty() {
1768 warnings.push(IesValidationWarning {
1769 code: "IES004",
1770 message: "Missing required keyword [MANUFAC]".to_string(),
1771 severity: IesValidationSeverity::Required,
1772 });
1773 }
1774
1775 if data.luminaire_catalog.is_empty() {
1779 warnings.push(IesValidationWarning {
1780 code: "IES005",
1781 message: "Missing recommended keyword [LUMCAT]".to_string(),
1782 severity: IesValidationSeverity::Recommended,
1783 });
1784 }
1785
1786 if data.luminaire.is_empty() {
1788 warnings.push(IesValidationWarning {
1789 code: "IES006",
1790 message: "Missing recommended keyword [LUMINAIRE]".to_string(),
1791 severity: IesValidationSeverity::Recommended,
1792 });
1793 }
1794
1795 if data.lamp.is_empty() {
1797 warnings.push(IesValidationWarning {
1798 code: "IES007",
1799 message: "Missing recommended keyword [LAMP]".to_string(),
1800 severity: IesValidationSeverity::Recommended,
1801 });
1802 }
1803
1804 let photo_type = data.photometric_type as i32;
1808 if !(1..=3).contains(&photo_type) {
1809 warnings.push(IesValidationWarning {
1810 code: "IES010",
1811 message: format!(
1812 "Invalid photometric type: {} (must be 1, 2, or 3)",
1813 photo_type
1814 ),
1815 severity: IesValidationSeverity::Required,
1816 });
1817 }
1818
1819 let unit_type = data.unit_type as i32;
1822 if !(1..=2).contains(&unit_type) {
1823 warnings.push(IesValidationWarning {
1824 code: "IES011",
1825 message: format!(
1826 "Invalid unit type: {} (must be 1=feet or 2=meters)",
1827 unit_type
1828 ),
1829 severity: IesValidationSeverity::Required,
1830 });
1831 }
1832
1833 if !data.vertical_angles.is_empty() {
1836 let first_v = data.vertical_angles[0];
1837 let last_v = *data.vertical_angles.last().unwrap();
1838
1839 if data.photometric_type == PhotometricType::TypeC {
1841 if (first_v - 0.0).abs() > 0.01 && (first_v - 90.0).abs() > 0.01 {
1843 warnings.push(IesValidationWarning {
1844 code: "IES020",
1845 message: format!(
1846 "Type C: First vertical angle ({}) must be 0 or 90 degrees",
1847 first_v
1848 ),
1849 severity: IesValidationSeverity::Required,
1850 });
1851 }
1852
1853 if (last_v - 90.0).abs() > 0.01 && (last_v - 180.0).abs() > 0.01 {
1855 warnings.push(IesValidationWarning {
1856 code: "IES021",
1857 message: format!(
1858 "Type C: Last vertical angle ({}) must be 90 or 180 degrees",
1859 last_v
1860 ),
1861 severity: IesValidationSeverity::Required,
1862 });
1863 }
1864 }
1865
1866 if data.photometric_type == PhotometricType::TypeA
1868 || data.photometric_type == PhotometricType::TypeB
1869 {
1870 if (first_v - 0.0).abs() > 0.01 && (first_v + 90.0).abs() > 0.01 {
1872 warnings.push(IesValidationWarning {
1873 code: "IES022",
1874 message: format!(
1875 "Type A/B: First vertical angle ({}) must be -90 or 0 degrees",
1876 first_v
1877 ),
1878 severity: IesValidationSeverity::Required,
1879 });
1880 }
1881
1882 if (last_v - 90.0).abs() > 0.01 {
1884 warnings.push(IesValidationWarning {
1885 code: "IES023",
1886 message: format!(
1887 "Type A/B: Last vertical angle ({}) must be 90 degrees",
1888 last_v
1889 ),
1890 severity: IesValidationSeverity::Required,
1891 });
1892 }
1893 }
1894
1895 for i in 1..data.vertical_angles.len() {
1897 if data.vertical_angles[i] <= data.vertical_angles[i - 1] {
1898 warnings.push(IesValidationWarning {
1899 code: "IES024",
1900 message: format!("Vertical angles not in ascending order at index {}", i),
1901 severity: IesValidationSeverity::Required,
1902 });
1903 break;
1904 }
1905 }
1906 }
1907
1908 if !data.horizontal_angles.is_empty() {
1911 let first_h = data.horizontal_angles[0];
1912 let last_h = *data.horizontal_angles.last().unwrap();
1913
1914 if data.photometric_type == PhotometricType::TypeC {
1916 if (first_h - 0.0).abs() > 0.01 {
1917 warnings.push(IesValidationWarning {
1918 code: "IES030",
1919 message: format!(
1920 "Type C: First horizontal angle ({}) must be 0 degrees",
1921 first_h
1922 ),
1923 severity: IesValidationSeverity::Required,
1924 });
1925 }
1926
1927 let valid_last = [
1929 (0.0, "laterally symmetric"),
1930 (90.0, "quadrant symmetric"),
1931 (180.0, "bilateral symmetric"),
1932 (360.0, "no lateral symmetry"),
1933 ];
1934 let mut found_valid = false;
1935 for (angle, _) in &valid_last {
1936 if (last_h - angle).abs() < 0.01 {
1937 found_valid = true;
1938 break;
1939 }
1940 }
1941 if !found_valid && data.horizontal_angles.len() > 1 {
1942 warnings.push(IesValidationWarning {
1943 code: "IES031",
1944 message: format!(
1945 "Type C: Last horizontal angle ({}) must be 0, 90, 180, or 360 degrees",
1946 last_h
1947 ),
1948 severity: IesValidationSeverity::Required,
1949 });
1950 }
1951 }
1952
1953 for i in 1..data.horizontal_angles.len() {
1955 if data.horizontal_angles[i] <= data.horizontal_angles[i - 1] {
1956 warnings.push(IesValidationWarning {
1957 code: "IES032",
1958 message: format!("Horizontal angles not in ascending order at index {}", i),
1959 severity: IesValidationSeverity::Required,
1960 });
1961 break;
1962 }
1963 }
1964 }
1965
1966 if data.candela_values.len() != data.n_horizontal {
1970 warnings.push(IesValidationWarning {
1971 code: "IES040",
1972 message: format!(
1973 "Candela data has {} horizontal planes, expected {}",
1974 data.candela_values.len(),
1975 data.n_horizontal
1976 ),
1977 severity: IesValidationSeverity::Required,
1978 });
1979 }
1980
1981 for (i, row) in data.candela_values.iter().enumerate() {
1982 if row.len() != data.n_vertical {
1983 warnings.push(IesValidationWarning {
1984 code: "IES041",
1985 message: format!(
1986 "Candela row {} has {} values, expected {}",
1987 i,
1988 row.len(),
1989 data.n_vertical
1990 ),
1991 severity: IesValidationSeverity::Required,
1992 });
1993 }
1994 }
1995
1996 if data.version == IesVersion::Lm63_2019 {
1999 let valid_values = [
2001 1.00001, 1.00010, 1.00000, 1.00100, 1.01000, 1.01100, 1.10000, 1.10100, 1.11000,
2002 1.11100,
2003 ];
2004 let mut found = false;
2005 for &v in &valid_values {
2006 if (data.file_generation_value - v).abs() < 0.000001 {
2007 found = true;
2008 break;
2009 }
2010 }
2011 if !found && data.file_generation_value > 1.0 {
2013 warnings.push(IesValidationWarning {
2014 code: "IES050",
2015 message: format!(
2016 "File generation type value ({}) is not a standard LM-63-2019 value",
2017 data.file_generation_value
2018 ),
2019 severity: IesValidationSeverity::Info,
2020 });
2021 }
2022 }
2023
2024 if data.ballast_factor <= 0.0 || data.ballast_factor > 2.0 {
2027 warnings.push(IesValidationWarning {
2028 code: "IES060",
2029 message: format!(
2030 "Unusual ballast factor: {} (typically 0.5-1.5)",
2031 data.ballast_factor
2032 ),
2033 severity: IesValidationSeverity::Info,
2034 });
2035 }
2036
2037 let mut has_negative = false;
2040 let mut max_cd = 0.0f64;
2041 for row in &data.candela_values {
2042 for &cd in row {
2043 if cd < 0.0 {
2044 has_negative = true;
2045 }
2046 max_cd = max_cd.max(cd);
2047 }
2048 }
2049
2050 if has_negative {
2051 warnings.push(IesValidationWarning {
2052 code: "IES070",
2053 message: "Negative candela values found".to_string(),
2054 severity: IesValidationSeverity::Required,
2055 });
2056 }
2057
2058 if max_cd > 1_000_000.0 {
2059 warnings.push(IesValidationWarning {
2060 code: "IES071",
2061 message: format!(
2062 "Very high candela value: {:.0} (verify data correctness)",
2063 max_cd
2064 ),
2065 severity: IesValidationSeverity::Info,
2066 });
2067 }
2068
2069 warnings
2070}
2071
2072pub fn validate_ies_strict(data: &IesData) -> Vec<IesValidationWarning> {
2074 validate_ies(data)
2075 .into_iter()
2076 .filter(|w| w.severity == IesValidationSeverity::Required)
2077 .collect()
2078}