Skip to main content

eulumdat/
ies.rs

1//! IES (IESNA LM-63) file format support.
2//!
3//! This module provides parsing and export of IES photometric files according to
4//! ANSI/IES LM-63-2019 and compatible older formats (LM-63-2002, LM-63-1995, LM-63-1991).
5//!
6//! ## IES File Format Overview
7//!
8//! The IES format is the North American standard for photometric data exchange,
9//! developed by the Illuminating Engineering Society (IES).
10//!
11//! ### File Structure
12//!
13//! 1. **Version header**: `IES:LM-63-2019`, `IESNA:LM-63-2002`, or `IESNA91` (older)
14//! 2. **Keywords**: `[KEYWORD] value` format (TEST, MANUFAC, LUMINAIRE, etc.)
15//!    - Required: `[TEST]`, `[TESTLAB]`, `[ISSUEDATE]`, `[MANUFAC]`
16//!    - Optional: `[LUMCAT]`, `[LUMINAIRE]`, `[LAMP]`, `[LAMPCAT]`, `[BALLAST]`, etc.
17//! 3. **TILT specification**: `TILT=NONE` or `TILT=INCLUDE`
18//! 4. **Photometric data**:
19//!    - Line 1: num_lamps, lumens_per_lamp, multiplier, n_vert, n_horiz, photo_type, units, width, length, height
20//!    - Line 2: ballast_factor, file_generation_type, input_watts
21//!    - Vertical angles (n_vert values)
22//!    - Horizontal angles (n_horiz values)
23//!    - Candela values (n_horiz sets of n_vert values each)
24//!
25//! ### Photometric Types
26//!
27//! - **Type A**: Automotive (horizontal angles in horizontal plane)
28//! - **Type B**: Adjustable luminaires (horizontal angles in vertical plane)
29//! - **Type C**: Most common - architectural (vertical angles from nadir)
30//!
31//! ### File Generation Types (LM-63-2019)
32//!
33//! - 1.00001: Undefined
34//! - 1.00010: Computer simulation
35//! - 1.00000: Test at unaccredited lab
36//! - 1.10000: Test at accredited lab
37//! - See [`FileGenerationType`] for full list
38//!
39//! ## Example
40//!
41//! ```rust,no_run
42//! use eulumdat::{Eulumdat, IesParser, IesExporter};
43//!
44//! // Import from IES
45//! let ldt = IesParser::parse_file("luminaire.ies")?;
46//! println!("Luminaire: {}", ldt.luminaire_name);
47//!
48//! // Export to IES
49//! let ies_content = IesExporter::export(&ldt);
50//! std::fs::write("output.ies", ies_content)?;
51//! # Ok::<(), Box<dyn std::error::Error>>(())
52//! ```
53
54use 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
65/// IES file format parser.
66///
67/// Parses IES LM-63 format files (versions 1991, 1995, 2002, 2019).
68///
69/// Supports both UTF-8 and ISO-8859-1 (Latin-1) encoded files, with automatic
70/// detection and conversion. This is necessary because many IES files from
71/// Windows-based tools use ISO-8859-1 encoding.
72pub struct IesParser;
73
74/// Read file with encoding fallback.
75///
76/// Tries UTF-8 first, then falls back to ISO-8859-1 (Latin-1) which is common
77/// for IES files from Windows tools.
78fn 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    // Try UTF-8 first
82    match String::from_utf8(bytes.clone()) {
83        Ok(content) => Ok(content),
84        Err(_) => {
85            // Fall back to ISO-8859-1 (Latin-1)
86            // Every byte is valid in ISO-8859-1, so this always succeeds
87            Ok(bytes.iter().map(|&b| b as char).collect())
88        }
89    }
90}
91
92/// IES file format version.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
95pub enum IesVersion {
96    /// LM-63-1991 (IESNA91)
97    Lm63_1991,
98    /// LM-63-1995 (IESNA:LM-63-1995)
99    Lm63_1995,
100    /// LM-63-2002 (IESNA:LM-63-2002)
101    #[default]
102    Lm63_2002,
103    /// LM-63-2019 (IES:LM-63-2019)
104    Lm63_2019,
105}
106
107impl IesVersion {
108    /// Parse version from header string.
109    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    /// Get the header string for this version.
123    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/// File generation type (LM-63-2019 Section 5.13, Table 2).
134///
135/// Describes how the IES file was generated.
136#[derive(Debug, Clone, Copy, PartialEq, Default)]
137#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
138pub enum FileGenerationType {
139    /// 1.00001 - Undefined or older file
140    #[default]
141    Undefined,
142    /// 1.00010 - Computer simulation (raytracing)
143    ComputerSimulation,
144    /// 1.00000 - Test at unaccredited lab
145    UnaccreditedLab,
146    /// 1.00100 - Test at unaccredited lab, lumen scaled
147    UnaccreditedLabScaled,
148    /// 1.01000 - Test at unaccredited lab, interpolated angles
149    UnaccreditedLabInterpolated,
150    /// 1.01100 - Test at unaccredited lab, interpolated and scaled
151    UnaccreditedLabInterpolatedScaled,
152    /// 1.10000 - Test at accredited lab
153    AccreditedLab,
154    /// 1.10100 - Test at accredited lab, lumen scaled
155    AccreditedLabScaled,
156    /// 1.11000 - Test at accredited lab, interpolated angles
157    AccreditedLabInterpolated,
158    /// 1.11100 - Test at accredited lab, interpolated and scaled
159    AccreditedLabInterpolatedScaled,
160}
161
162impl FileGenerationType {
163    /// Parse from decimal value.
164    pub fn from_value(value: f64) -> Self {
165        // Round to 5 decimal places to handle floating point precision
166        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            // For legacy files, treat ballast-lamp factor as undefined
179            _ => Self::Undefined,
180        }
181    }
182
183    /// Get decimal value for this type.
184    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    /// Get title for this type (per LM-63-2019 Table 2).
200    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    /// Check if this is from an accredited lab.
224    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    /// Check if lumen values were scaled.
235    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    /// Check if angles were interpolated.
246    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/// Luminous opening shape (LM-63-2019 Section 5.11, Table 1).
258///
259/// Determined by the signs of width, length, and height dimensions.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
261#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
262pub enum LuminousShape {
263    /// Point source (all dimensions = 0)
264    #[default]
265    Point,
266    /// Rectangular opening (width > 0, length > 0, height = 0)
267    Rectangular,
268    /// Rectangular with luminous sides (all > 0)
269    RectangularWithSides,
270    /// Circular (width = length < 0, height = 0)
271    Circular,
272    /// Ellipse (width < 0, length < 0, height = 0)
273    Ellipse,
274    /// Vertical cylinder (width = length < 0, height > 0)
275    VerticalCylinder,
276    /// Vertical ellipsoidal cylinder
277    VerticalEllipsoidalCylinder,
278    /// Sphere (all negative, equal)
279    Sphere,
280    /// Ellipsoidal spheroid (all negative, not equal)
281    EllipsoidalSpheroid,
282    /// Horizontal cylinder along photometric horizontal
283    HorizontalCylinderAlong,
284    /// Horizontal ellipsoidal cylinder along photometric horizontal
285    HorizontalEllipsoidalCylinderAlong,
286    /// Horizontal cylinder perpendicular to photometric horizontal
287    HorizontalCylinderPerpendicular,
288    /// Horizontal ellipsoidal cylinder perpendicular to photometric horizontal
289    HorizontalEllipsoidalCylinderPerpendicular,
290    /// Vertical circle facing photometric horizontal
291    VerticalCircle,
292    /// Vertical ellipse facing photometric horizontal
293    VerticalEllipse,
294}
295
296impl LuminousShape {
297    /// Determine shape from width, length, height values.
298    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        // Check for equal negative values (circular shapes)
310        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            // Point: all zero
317            (true, true, true, _, _, _, _, _, _) => Self::Point,
318            // Rectangular: width > 0, length > 0, height = 0
319            (_, _, true, _, _, _, true, true, _) => Self::Rectangular,
320            // Rectangular with sides: all positive
321            (_, _, _, _, _, _, true, true, true) => Self::RectangularWithSides,
322            // Circular: width = length < 0, height = 0
323            (_, _, true, true, true, _, _, _, _) if wl_equal => Self::Circular,
324            // Ellipse: width < 0, length < 0, height = 0
325            (_, _, true, true, true, _, _, _, _) => Self::Ellipse,
326            // Sphere: all negative and equal
327            (_, _, _, true, true, true, _, _, _) if all_equal => Self::Sphere,
328            // Ellipsoidal spheroid: all negative
329            (_, _, _, true, true, true, _, _, _) => Self::EllipsoidalSpheroid,
330            // Vertical cylinder: width = length < 0, height > 0
331            (_, _, _, true, true, _, _, _, true) if wl_equal => Self::VerticalCylinder,
332            // Vertical ellipsoidal cylinder: width < 0, length < 0, height > 0
333            (_, _, _, true, true, _, _, _, true) => Self::VerticalEllipsoidalCylinder,
334            // Horizontal cylinder along: width < 0, length > 0, height < 0
335            (_, _, _, true, _, true, _, true, _) if (width - height).abs() < 0.0001 => {
336                Self::HorizontalCylinderAlong
337            }
338            // Horizontal ellipsoidal cylinder along
339            (_, _, _, true, _, true, _, true, _) => Self::HorizontalEllipsoidalCylinderAlong,
340            // Horizontal cylinder perpendicular: width > 0, length < 0, height < 0
341            (_, _, _, _, true, true, true, _, _) if (length - height).abs() < 0.0001 => {
342                Self::HorizontalCylinderPerpendicular
343            }
344            // Horizontal ellipsoidal cylinder perpendicular
345            (_, _, _, _, true, true, true, _, _) => {
346                Self::HorizontalEllipsoidalCylinderPerpendicular
347            }
348            // Vertical circle: width < 0, length = 0, height < 0
349            (_, true, _, true, _, true, _, _, _) if (width - height).abs() < 0.0001 => {
350                Self::VerticalCircle
351            }
352            // Vertical ellipse: width < 0, length = 0, height < 0
353            (_, true, _, true, _, true, _, _, _) => Self::VerticalEllipse,
354            // Default to point for any unrecognized combination
355            _ => Self::Point,
356        }
357    }
358
359    /// Get description of the shape.
360    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/// TILT data for luminaires with position-dependent output.
388#[derive(Debug, Clone, Default)]
389#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
390pub struct TiltData {
391    /// Lamp to luminaire geometry (1-3)
392    /// 1 = vertical base-up or base-down
393    /// 2 = horizontal, stays horizontal when tilted
394    /// 3 = horizontal, tilts with luminaire
395    pub lamp_geometry: i32,
396    /// Tilt angles in degrees
397    pub angles: Vec<f64>,
398    /// Multiplying factors corresponding to angles
399    pub factors: Vec<f64>,
400}
401
402/// Lamp position within luminaire (LM-63-2019 Annex E).
403#[derive(Debug, Clone, Copy, Default)]
404#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
405pub struct LampPosition {
406    /// Horizontal position angle (0-360°)
407    pub horizontal: f64,
408    /// Vertical position angle (0-180°)
409    pub vertical: f64,
410}
411
412/// Photometric measurement type.
413///
414/// ## Coordinate System Differences
415///
416/// - **Type C**: Vertical polar axis (0° = nadir, 180° = zenith). Standard for downlights, streetlights.
417/// - **Type B**: Horizontal polar axis (0H 0V = beam center). Used for floodlights, sports lighting.
418///   - ⚠️ **TODO**: Implement 90° coordinate rotation for Type B → Type C conversion
419///   - Required transformation matrix: R_x(90°) to align horizontal axis to vertical
420/// - **Type A**: Automotive coordinates. Rare in architectural lighting.
421///   - Currently parsed but may render incorrectly without coordinate mapping
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
423#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
424pub enum PhotometricType {
425    /// Type A - Automotive photometry (rare)
426    TypeA = 3,
427    /// Type B - Adjustable luminaires (floodlights, theatrical)
428    TypeB = 2,
429    /// Type C - Architectural (most common)
430    #[default]
431    TypeC = 1,
432}
433
434impl PhotometricType {
435    /// Create from integer value.
436    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/// Unit type for dimensions.
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
448#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
449pub enum UnitType {
450    /// Dimensions in feet
451    Feet = 1,
452    /// Dimensions in meters
453    #[default]
454    Meters = 2,
455}
456
457impl UnitType {
458    /// Create from integer value.
459    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    /// Conversion factor to millimeters.
468    pub fn to_mm_factor(&self) -> f64 {
469        match self {
470            UnitType::Feet => 304.8,    // 1 foot = 304.8 mm
471            UnitType::Meters => 1000.0, // 1 meter = 1000 mm
472        }
473    }
474}
475
476/// Parsed IES data before conversion to Eulumdat.
477#[derive(Debug, Clone, Default)]
478#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
479pub struct IesData {
480    /// Version (parsed from header)
481    pub version: IesVersion,
482    /// Version string as found in file
483    pub version_string: String,
484    /// All keyword metadata
485    pub keywords: HashMap<String, String>,
486
487    // === Required Keywords (LM-63-2019) ===
488    /// `[TEST]` Test report number
489    pub test: String,
490    /// `[TESTLAB]` Photometric testing laboratory
491    pub test_lab: String,
492    /// `[ISSUEDATE]` Date manufacturer issued the file
493    pub issue_date: String,
494    /// `[MANUFAC]` Manufacturer of luminaire
495    pub manufacturer: String,
496
497    // === Common Optional Keywords ===
498    /// `[LUMCAT]` Luminaire catalog number
499    pub luminaire_catalog: String,
500    /// `[LUMINAIRE]` Luminaire description
501    pub luminaire: String,
502    /// `[LAMPCAT]` Lamp catalog number
503    pub lamp_catalog: String,
504    /// `[LAMP]` Lamp description
505    pub lamp: String,
506    /// `[BALLAST]` Ballast description
507    pub ballast: String,
508    /// `[BALLASTCAT]` Ballast catalog number
509    pub ballast_catalog: String,
510    /// `[TESTDATE]` Date of photometric test
511    pub test_date: String,
512    /// `[MAINTCAT]` IES maintenance category (1-6)
513    pub maintenance_category: Option<i32>,
514    /// `[DISTRIBUTION]` Distribution description
515    pub distribution: String,
516    /// `[FLASHAREA]` Flash area in m²
517    pub flash_area: Option<f64>,
518    /// `[COLORCONSTANT]` Color constant for glare
519    pub color_constant: Option<f64>,
520    /// `[LAMPPOSITION]` Lamp position angles
521    pub lamp_position: Option<LampPosition>,
522    /// `[NEARFIELD]` Near field distances (D1, D2, D3)
523    pub near_field: Option<(f64, f64, f64)>,
524    /// `[FILEGENINFO]` Additional file generation info
525    pub file_gen_info: String,
526    /// `[SEARCH]` User search string
527    pub search: String,
528    /// `[OTHER]` lines (can appear multiple times)
529    pub other: Vec<String>,
530
531    // === Photometric Parameters ===
532    /// Number of lamps
533    pub num_lamps: i32,
534    /// Lumens per lamp (-1 = absolute photometry)
535    pub lumens_per_lamp: f64,
536    /// Candela multiplier
537    pub multiplier: f64,
538    /// Number of vertical angles
539    pub n_vertical: usize,
540    /// Number of horizontal angles
541    pub n_horizontal: usize,
542    /// Photometric type (1=C, 2=B, 3=A)
543    pub photometric_type: PhotometricType,
544    /// Unit type (1=feet, 2=meters)
545    pub unit_type: UnitType,
546    /// Luminous opening width (negative = rounded shape)
547    pub width: f64,
548    /// Luminous opening length (negative = rounded shape)
549    pub length: f64,
550    /// Luminous opening height (negative = rounded shape)
551    pub height: f64,
552    /// Derived luminous shape
553    pub luminous_shape: LuminousShape,
554    /// Ballast factor
555    pub ballast_factor: f64,
556    /// File generation type (LM-63-2019) or ballast-lamp factor (older)
557    pub file_generation_type: FileGenerationType,
558    /// Raw file generation type value (for preservation)
559    pub file_generation_value: f64,
560    /// Input watts
561    pub input_watts: f64,
562
563    // === TILT Data ===
564    /// TILT mode (NONE or INCLUDE)
565    pub tilt_mode: String,
566    /// TILT data if INCLUDE
567    pub tilt_data: Option<TiltData>,
568
569    // === Angle Data ===
570    /// Vertical angles (gamma)
571    pub vertical_angles: Vec<f64>,
572    /// Horizontal angles (C-planes)
573    pub horizontal_angles: Vec<f64>,
574    /// Candela values `[horizontal_index][vertical_index]`
575    pub candela_values: Vec<Vec<f64>>,
576}
577
578impl IesParser {
579    /// Parse an IES file from a file path.
580    ///
581    /// Automatically handles both UTF-8 and ISO-8859-1 (Latin-1) encoded files.
582    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    /// Parse an IES file with import options (e.g. C-plane rotation).
588    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    /// Parse IES content from a string.
597    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    /// Parse IES content with import options (e.g. C-plane rotation).
603    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    /// Parse IES content and return raw IES data structure.
613    ///
614    /// This is useful for accessing IES-specific fields that don't map to Eulumdat.
615    pub fn parse_to_ies_data(content: &str) -> Result<IesData> {
616        Self::parse_ies_data(content)
617    }
618
619    /// Parse IES format into intermediate structure.
620    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        // Parse version header
631        let first_line = lines[line_idx].trim();
632        // LM-63-2019: IES:LM-63-2019
633        // LM-63-2002: IESNA:LM-63-2002
634        // LM-63-1991: IESNA91 or IESNA:LM-63-1991
635        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            // Older format without explicit version
643            data.version_string = "IESNA91".to_string();
644            data.version = IesVersion::Lm63_1991;
645        }
646
647        // Parse keywords until TILT, handling [MORE] continuation
648        let mut current_keyword = String::new();
649        let mut current_value = String::new();
650        let mut last_stored_keyword = String::new(); // Track last keyword for [MORE]
651
652        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                // Save last keyword if any
658                if !current_keyword.is_empty() {
659                    Self::store_keyword(&mut data, &current_keyword, &current_value);
660                }
661                break;
662            }
663
664            // Parse [KEYWORD] value format
665            if line.starts_with('[') {
666                // Save previous keyword
667                if !current_keyword.is_empty() {
668                    Self::store_keyword(&mut data, &current_keyword, &current_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                    // Handle [MORE] continuation (Annex A)
677                    if current_keyword == "MORE" && !last_stored_keyword.is_empty() {
678                        // Append to previous keyword's value
679                        if let Some(existing) = data.keywords.get_mut(&last_stored_keyword) {
680                            existing.push('\n');
681                            existing.push_str(&current_value);
682                        }
683                        current_keyword.clear();
684                        current_value.clear();
685                    }
686                }
687            }
688
689            line_idx += 1;
690        }
691
692        // Handle TILT
693        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                // Parse TILT data (Annex F)
705                let mut tilt = TiltData::default();
706
707                // Lamp to luminaire geometry
708                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                // Number of angle-factor pairs
716                if line_idx < lines.len() {
717                    if let Ok(n_pairs) = lines[line_idx].trim().parse::<usize>() {
718                        line_idx += 1;
719
720                        // Collect angle values
721                        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                        // Collect factor values
733                        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        // Collect remaining numeric data
751        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        // Parse photometric data
763        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        // Line 1: num_lamps, lumens_per_lamp, multiplier, n_vert, n_horiz, photo_type, units, width, length, height
773        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        // Determine luminous shape from dimensions
795        data.luminous_shape = LuminousShape::from_dimensions(data.width, data.length, data.height);
796
797        // Line 2: ballast_factor, file_generation_type, input_watts
798        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        // Vertical angles
807        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        // Horizontal angles
814        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        // Candela values: n_horizontal sets of n_vertical values
821        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    /// Store a keyword value in the IesData structure.
840    fn store_keyword(data: &mut IesData, keyword: &str, value: &str) {
841        // Store in generic keywords map
842        data.keywords.insert(keyword.to_string(), value.to_string());
843
844        // Also store in specific fields for easy access
845        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                // User-defined keywords (starting with _) are stored in generic map only
894            }
895        }
896    }
897
898    /// Convert parsed IES data to Eulumdat structure.
899    fn convert_to_eulumdat(ies: IesData) -> Result<Eulumdat> {
900        let mut ldt = Eulumdat::new();
901
902        // Convert keywords to Eulumdat fields (use parsed fields, not raw keywords)
903        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        // Store issue date in date_user field
909        ldt.date_user = ies.issue_date.clone();
910
911        // Determine symmetry from horizontal angles
912        ldt.symmetry = Self::detect_symmetry(&ies.horizontal_angles);
913
914        // Type indicator based on dimensions and symmetry
915        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        // Store angles
924        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        // Calculate angle spacing
930        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        // Convert dimensions to mm
938        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        // Luminous area (assume same as luminaire for now)
944        ldt.luminous_area_length = ldt.length;
945        ldt.luminous_area_width = ldt.width;
946
947        // Lamp set
948        // CRITICAL: Handle absolute photometry (LED fixtures)
949        // IES Standard: lumens_per_lamp = -1 signals absolute photometry
950        // Eulumdat Convention: num_lamps must be NEGATIVE to signal absolute photometry
951        // This is the "single most important fix for LED compatibility"
952        let (num_lamps, total_flux) = if ies.lumens_per_lamp < 0.0 {
953            // Absolute photometry: calculate flux from intensity distribution
954            // The candela values * multiplier give absolute candelas
955            // We integrate these to get total lumens
956            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            // Relative photometry: positive lamp count
962            (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        // Store intensities (IES candela values are absolute, convert to cd/klm)
979        // Eulumdat uses cd/1000lm, IES uses absolute candela
980        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        // Photometric parameters
997        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; // Default
1001
1002        Ok(ldt)
1003    }
1004
1005    /// Detect symmetry type from horizontal angles.
1006    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            // Single horizontal angle = rotationally symmetric
1016            Symmetry::VerticalAxis
1017        } else if (max_angle - 90.0).abs() < 0.1 && min_angle.abs() < 0.1 {
1018            // 0° to 90° = quadrant symmetry
1019            Symmetry::BothPlanes
1020        } else if (max_angle - 180.0).abs() < 0.1 && min_angle.abs() < 0.1 {
1021            // 0° to 180° = bilateral symmetry
1022            Symmetry::PlaneC0C180
1023        } else if (min_angle - 90.0).abs() < 0.1 && (max_angle - 270.0).abs() < 0.1 {
1024            // 90° to 270°
1025            Symmetry::PlaneC90C270
1026        } else {
1027            // Full 360° or other
1028            Symmetry::None
1029        }
1030    }
1031
1032    /// Calculate luminous flux from intensity distribution (for absolute photometry).
1033    ///
1034    /// Integrates candela values over solid angle to get total lumens.
1035    /// Formula: Φ = ∫∫ I(θ,φ) sin(θ) dθ dφ
1036    ///
1037    /// For rotationally symmetric (single C-plane), this simplifies to:
1038    /// Φ = 2π ∫ I(γ) sin(γ) dγ
1039    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        // Average intensities across all horizontal angles for each vertical angle
1051        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        // Integrate using trapezoidal rule over solid angle
1059        // Φ = 2π ∫ I(γ) sin(γ) dγ
1060        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            // Trapezoidal rule with sin(γ) weighting
1068            // ∫ I(γ) sin(γ) dγ ≈ (I1*sin(γ1) + I2*sin(γ2)) / 2 * Δγ
1069            let dg = gamma2 - gamma1;
1070            flux += (i1 * gamma1.sin() + i2 * gamma2.sin()) / 2.0 * dg;
1071        }
1072
1073        // Multiply by 2π for full revolution (rotationally symmetric)
1074        // For non-symmetric, we would need to integrate over horizontal angles too
1075        flux * 2.0 * std::f64::consts::PI
1076    }
1077}
1078
1079/// IES file format exporter.
1080///
1081/// Exports Eulumdat data to IES LM-63 format (2002 or 2019).
1082pub struct IesExporter;
1083
1084/// Import options for IES files.
1085///
1086/// Use `rotate_c_planes` to correct the C-plane orientation difference between
1087/// EULUMDAT (C0 along luminaire length) and IES (C0 perpendicular to length).
1088/// Set to `90.0` when importing IES files that need EU-axis alignment.
1089#[derive(Debug, Clone)]
1090pub struct IesImportOptions {
1091    /// Rotate C-planes by this many degrees after import (default: 0.0).
1092    /// Set to 90.0 to convert IES C0 orientation → EULUMDAT C0 orientation.
1093    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/// Export options for IES files.
1105#[derive(Debug, Clone)]
1106pub struct IesExportOptions {
1107    /// IES version to export (default: LM-63-2019)
1108    pub version: IesVersion,
1109    /// File generation type (default: Undefined)
1110    pub file_generation_type: FileGenerationType,
1111    /// Issue date (required for LM-63-2019)
1112    pub issue_date: Option<String>,
1113    /// Additional file generation info
1114    pub file_gen_info: Option<String>,
1115    /// Test lab name
1116    pub test_lab: Option<String>,
1117    /// Rotate C-planes by this many degrees before export (default: 0.0).
1118    /// Set to -90.0 to convert EULUMDAT C0 orientation → IES C0 orientation.
1119    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    /// Export Eulumdat data to IES LM-63-2019 format (default).
1137    pub fn export(ldt: &Eulumdat) -> String {
1138        Self::export_with_options(ldt, &IesExportOptions::default())
1139    }
1140
1141    /// Export Eulumdat data to IES LM-63-2002 format (legacy).
1142    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    /// Export with custom options.
1153    pub fn export_with_options(ldt: &Eulumdat, options: &IesExportOptions) -> String {
1154        // Apply C-plane rotation if requested
1155        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        // Header based on version
1170        output.push_str(options.version.header());
1171        output.push('\n');
1172
1173        // Required keywords
1174        Self::write_keyword(&mut output, "TEST", &ldt.measurement_report_number);
1175
1176        // TESTLAB - required in LM-63-2019
1177        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        // ISSUEDATE - required in LM-63-2019
1183        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                        // Default to current date if not provided
1193                        "01-JAN-2025"
1194                    }
1195                });
1196            Self::write_keyword(&mut output, "ISSUEDATE", issue_date);
1197        }
1198
1199        // MANUFAC - required
1200        if !ldt.identification.is_empty() {
1201            Self::write_keyword(&mut output, "MANUFAC", &ldt.identification);
1202        }
1203
1204        // Optional but recommended keywords
1205        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        // FILEGENINFO - new in LM-63-2019
1220        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        // TILT=NONE (most common)
1227        output.push_str("TILT=NONE\n");
1228
1229        // Line 1: Number of lamps, lumens per lamp, multiplier, number of vertical angles,
1230        //         number of horizontal angles, photometric type, units type, width, length, height
1231        let num_lamps = ldt.lamp_sets.iter().map(|ls| ls.num_lamps).sum::<i32>();
1232        let total_flux = ldt.total_luminous_flux();
1233
1234        // CRITICAL: Absolute photometry handling for LED fixtures
1235        // LDT Convention: negative num_lamps signals absolute photometry
1236        // IES Standard: lumens_per_lamp = -1 signals absolute photometry
1237        let lumens_per_lamp = if num_lamps < 0 {
1238            // Absolute photometry: output -1 to signal absolute mode
1239            -1.0
1240        } else if num_lamps > 0 {
1241            // Relative photometry: divide total flux by lamp count
1242            total_flux / num_lamps as f64
1243        } else {
1244            // Fallback: treat as absolute
1245            total_flux
1246        };
1247
1248        // Expand to full distribution for IES
1249        let (h_angles, v_angles, intensities) = Self::prepare_photometric_data(ldt);
1250
1251        // Photometric type: 1 = Type C (vertical angles from 0 at nadir)
1252        let photometric_type = 1;
1253        // Units: 1 = feet, 2 = meters
1254        let units_type = 2;
1255
1256        // Dimensions in meters (convert from mm)
1257        let width = ldt.width / 1000.0;
1258        let length = ldt.length / 1000.0;
1259        let height = ldt.height / 1000.0;
1260
1261        // For IES output, num_lamps should always be positive (1 for absolute mode)
1262        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        // Line 2: Ballast factor, file generation type (LM-63-2019) or ballast-lamp factor, input watts
1279        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 // Legacy ballast-lamp photometric factor
1284        };
1285        output.push_str(&format!("1.0 {:.5} {:.1}\n", file_gen_value, total_watts));
1286
1287        // Vertical angles
1288        output.push_str(&Self::format_values_multiline(&v_angles, 10));
1289        output.push('\n');
1290
1291        // Horizontal angles
1292        output.push_str(&Self::format_values_multiline(&h_angles, 10));
1293        output.push('\n');
1294
1295        // Candela values for each horizontal angle
1296        // Convert from cd/klm back to absolute candela
1297        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    /// Write a keyword line.
1308    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    /// Prepare photometric data for IES export.
1315    ///
1316    /// Returns (horizontal_angles, vertical_angles, intensities).
1317    fn prepare_photometric_data(ldt: &Eulumdat) -> (Vec<f64>, Vec<f64>, Vec<Vec<f64>>) {
1318        // IES uses vertical angles (0 = down, 90 = horizontal, 180 = up)
1319        // Same as Eulumdat G-angles
1320        let v_angles = ldt.g_angles.clone();
1321
1322        // Horizontal angles depend on symmetry
1323        let (h_angles, intensities) = match ldt.symmetry {
1324            Symmetry::VerticalAxis => {
1325                // Single horizontal angle (0°)
1326                (
1327                    vec![0.0],
1328                    vec![ldt.intensities.first().cloned().unwrap_or_default()],
1329                )
1330            }
1331            Symmetry::PlaneC0C180 => {
1332                // 0° to 180°
1333                let expanded = SymmetryHandler::expand_to_full(ldt);
1334                let h = SymmetryHandler::expand_c_angles(ldt);
1335                // Select only the angles and intensities from 0° to 180°
1336                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                // Full 0° to 360° for C90-C270 symmetry
1348                // IES format needs the complete distribution
1349                let expanded = SymmetryHandler::expand_to_full(ldt);
1350                let h = SymmetryHandler::expand_c_angles(ldt);
1351                (h, expanded)
1352            }
1353            Symmetry::BothPlanes => {
1354                // 0° to 90°
1355                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                // Full 0° to 360°
1366                (ldt.c_angles.clone(), ldt.intensities.clone())
1367            }
1368        };
1369
1370        (h_angles, v_angles, intensities)
1371    }
1372
1373    /// Format values with line wrapping.
1374    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        // Default export is now LM-63-2019
1422        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]")); // Required in 2019
1426        assert!(ies.contains("TILT=NONE"));
1427
1428        // Test legacy 2002 export
1429        let ies_2002 = IesExporter::export_2002(&ldt);
1430        assert!(ies_2002.contains("IESNA:LM-63-2002"));
1431        assert!(!ies_2002.contains("[ISSUEDATE]")); // Not required in 2002
1432    }
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        // Export to IES
1480        let ies = IesExporter::export(&ldt);
1481
1482        // Parse back
1483        let parsed = IesParser::parse(&ies).expect("Failed to parse exported IES");
1484
1485        // Verify key fields
1486        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        // Test accredited lab check
1568        assert!(FileGenerationType::AccreditedLab.is_accredited());
1569        assert!(!FileGenerationType::UnaccreditedLab.is_accredited());
1570
1571        // Test scaled check
1572        assert!(FileGenerationType::AccreditedLabScaled.is_scaled());
1573        assert!(!FileGenerationType::AccreditedLab.is_scaled());
1574    }
1575
1576    #[test]
1577    fn test_luminous_shape() {
1578        // Point source
1579        assert_eq!(
1580            LuminousShape::from_dimensions(0.0, 0.0, 0.0),
1581            LuminousShape::Point
1582        );
1583
1584        // Rectangular
1585        assert_eq!(
1586            LuminousShape::from_dimensions(0.5, 0.6, 0.0),
1587            LuminousShape::Rectangular
1588        );
1589
1590        // Circular (negative equal width/length)
1591        assert_eq!(
1592            LuminousShape::from_dimensions(-0.3, -0.3, 0.0),
1593            LuminousShape::Circular
1594        );
1595
1596        // Sphere (all negative equal)
1597        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); // Absolute photometry
1638    }
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        // Check that OTHER contains multi-line content via MORE
1692        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// === IES Validation ===
1699
1700/// IES-specific validation warning.
1701#[derive(Debug, Clone, PartialEq)]
1702pub struct IesValidationWarning {
1703    /// Warning code
1704    pub code: &'static str,
1705    /// Human-readable message
1706    pub message: String,
1707    /// Severity level
1708    pub severity: IesValidationSeverity,
1709}
1710
1711/// Severity level for IES validation.
1712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1713pub enum IesValidationSeverity {
1714    /// Required by LM-63-2019 specification
1715    Required,
1716    /// Recommended but not strictly required
1717    Recommended,
1718    /// Informational warning
1719    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
1733/// Validate IES data according to LM-63-2019 specification.
1734pub fn validate_ies(data: &IesData) -> Vec<IesValidationWarning> {
1735    let mut warnings = Vec::new();
1736
1737    // === Required Keywords (Section 5.2) ===
1738
1739    // IES001: [TEST] is required
1740    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    // IES002: [TESTLAB] is required
1749    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    // IES003: [ISSUEDATE] is required (LM-63-2019)
1758    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    // IES004: [MANUFAC] is required
1767    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    // === Recommended Keywords ===
1776
1777    // IES005: [LUMCAT] recommended
1778    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    // IES006: [LUMINAIRE] recommended
1787    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    // IES007: [LAMP] recommended
1796    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    // === Photometric Type Validation (Section 5.9) ===
1805
1806    // IES010: Valid photometric type
1807    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    // === Unit Type Validation (Section 5.10.1) ===
1820
1821    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    // === Vertical Angle Validation (Section 5.15) ===
1834
1835    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        // Type C photometry
1840        if data.photometric_type == PhotometricType::TypeC {
1841            // First angle must be 0 or 90
1842            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            // Last angle must be 90 or 180
1854            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        // Type A or B photometry
1867        if data.photometric_type == PhotometricType::TypeA
1868            || data.photometric_type == PhotometricType::TypeB
1869        {
1870            // First angle must be -90 or 0
1871            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            // Last angle must be 90
1883            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        // Check ascending order
1896        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    // === Horizontal Angle Validation (Section 5.16) ===
1909
1910    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        // Type C: first must be 0
1915        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            // Last must be 0, 90, 180, or 360
1928            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        // Check ascending order
1954        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    // === Data Dimension Validation ===
1967
1968    // IES040: Verify candela data dimensions
1969    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    // === File Generation Type Validation (LM-63-2019) ===
1997
1998    if data.version == IesVersion::Lm63_2019 {
1999        // Check if file generation type is valid
2000        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        // Only warn if it looks like a non-legacy value
2012        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    // === Ballast Factor Validation ===
2025
2026    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    // === Candela Value Validation ===
2038
2039    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
2072/// Get required warnings only (errors).
2073pub 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}