use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::{anyhow, Result};
use crate::eulumdat::{Eulumdat, LampSet, Symmetry, TypeIndicator};
use crate::symmetry::SymmetryHandler;
pub struct IesParser;
fn read_with_encoding_fallback<P: AsRef<Path>>(path: P) -> Result<String> {
let bytes = fs::read(path.as_ref()).map_err(|e| anyhow!("Failed to read file: {}", e))?;
match String::from_utf8(bytes.clone()) {
Ok(content) => Ok(content),
Err(_) => {
Ok(bytes.iter().map(|&b| b as char).collect())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum IesVersion {
Lm63_1991,
Lm63_1995,
#[default]
Lm63_2002,
Lm63_2019,
}
impl IesVersion {
pub fn from_header(header: &str) -> Self {
let header_upper = header.to_uppercase();
if header_upper.contains("LM-63-2019") || header_upper.starts_with("IES:LM-63") {
Self::Lm63_2019
} else if header_upper.contains("LM-63-2002") {
Self::Lm63_2002
} else if header_upper.contains("LM-63-1995") {
Self::Lm63_1995
} else {
Self::Lm63_1991
}
}
pub fn header(&self) -> &'static str {
match self {
Self::Lm63_1991 => "IESNA91",
Self::Lm63_1995 => "IESNA:LM-63-1995",
Self::Lm63_2002 => "IESNA:LM-63-2002",
Self::Lm63_2019 => "IES:LM-63-2019",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum FileGenerationType {
#[default]
Undefined,
ComputerSimulation,
UnaccreditedLab,
UnaccreditedLabScaled,
UnaccreditedLabInterpolated,
UnaccreditedLabInterpolatedScaled,
AccreditedLab,
AccreditedLabScaled,
AccreditedLabInterpolated,
AccreditedLabInterpolatedScaled,
}
impl FileGenerationType {
pub fn from_value(value: f64) -> Self {
let rounded = (value * 100000.0).round() / 100000.0;
match rounded {
v if (v - 1.00001).abs() < 0.000001 => Self::Undefined,
v if (v - 1.00010).abs() < 0.000001 => Self::ComputerSimulation,
v if (v - 1.00000).abs() < 0.000001 => Self::UnaccreditedLab,
v if (v - 1.00100).abs() < 0.000001 => Self::UnaccreditedLabScaled,
v if (v - 1.01000).abs() < 0.000001 => Self::UnaccreditedLabInterpolated,
v if (v - 1.01100).abs() < 0.000001 => Self::UnaccreditedLabInterpolatedScaled,
v if (v - 1.10000).abs() < 0.000001 => Self::AccreditedLab,
v if (v - 1.10100).abs() < 0.000001 => Self::AccreditedLabScaled,
v if (v - 1.11000).abs() < 0.000001 => Self::AccreditedLabInterpolated,
v if (v - 1.11100).abs() < 0.000001 => Self::AccreditedLabInterpolatedScaled,
_ => Self::Undefined,
}
}
pub fn value(&self) -> f64 {
match self {
Self::Undefined => 1.00001,
Self::ComputerSimulation => 1.00010,
Self::UnaccreditedLab => 1.00000,
Self::UnaccreditedLabScaled => 1.00100,
Self::UnaccreditedLabInterpolated => 1.01000,
Self::UnaccreditedLabInterpolatedScaled => 1.01100,
Self::AccreditedLab => 1.10000,
Self::AccreditedLabScaled => 1.10100,
Self::AccreditedLabInterpolated => 1.11000,
Self::AccreditedLabInterpolatedScaled => 1.11100,
}
}
pub fn title(&self) -> &'static str {
match self {
Self::Undefined => "Undefined",
Self::ComputerSimulation => "Computer Simulation",
Self::UnaccreditedLab => "Test at an unaccredited lab",
Self::UnaccreditedLabScaled => "Test at an unaccredited lab that has been lumen scaled",
Self::UnaccreditedLabInterpolated => {
"Test at an unaccredited lab with interpolated angle set"
}
Self::UnaccreditedLabInterpolatedScaled => {
"Test at an unaccredited lab with interpolated angle set that has been lumen scaled"
}
Self::AccreditedLab => "Test at an accredited lab",
Self::AccreditedLabScaled => "Test at an accredited lab that has been lumen scaled",
Self::AccreditedLabInterpolated => {
"Test at an accredited lab with interpolated angle set"
}
Self::AccreditedLabInterpolatedScaled => {
"Test at an accredited lab with interpolated angle set that has been lumen scaled"
}
}
}
pub fn is_accredited(&self) -> bool {
matches!(
self,
Self::AccreditedLab
| Self::AccreditedLabScaled
| Self::AccreditedLabInterpolated
| Self::AccreditedLabInterpolatedScaled
)
}
pub fn is_scaled(&self) -> bool {
matches!(
self,
Self::UnaccreditedLabScaled
| Self::UnaccreditedLabInterpolatedScaled
| Self::AccreditedLabScaled
| Self::AccreditedLabInterpolatedScaled
)
}
pub fn is_interpolated(&self) -> bool {
matches!(
self,
Self::UnaccreditedLabInterpolated
| Self::UnaccreditedLabInterpolatedScaled
| Self::AccreditedLabInterpolated
| Self::AccreditedLabInterpolatedScaled
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum LuminousShape {
#[default]
Point,
Rectangular,
RectangularWithSides,
Circular,
Ellipse,
VerticalCylinder,
VerticalEllipsoidalCylinder,
Sphere,
EllipsoidalSpheroid,
HorizontalCylinderAlong,
HorizontalEllipsoidalCylinderAlong,
HorizontalCylinderPerpendicular,
HorizontalEllipsoidalCylinderPerpendicular,
VerticalCircle,
VerticalEllipse,
}
impl LuminousShape {
pub fn from_dimensions(width: f64, length: f64, height: f64) -> Self {
let w_zero = width.abs() < 0.0001;
let l_zero = length.abs() < 0.0001;
let h_zero = height.abs() < 0.0001;
let w_neg = width < 0.0;
let l_neg = length < 0.0;
let h_neg = height < 0.0;
let w_pos = width > 0.0;
let l_pos = length > 0.0;
let h_pos = height > 0.0;
let wl_equal = (width - length).abs() < 0.0001;
let all_equal = wl_equal && (width - height).abs() < 0.0001;
match (
w_zero, l_zero, h_zero, w_neg, l_neg, h_neg, w_pos, l_pos, h_pos,
) {
(true, true, true, _, _, _, _, _, _) => Self::Point,
(_, _, true, _, _, _, true, true, _) => Self::Rectangular,
(_, _, _, _, _, _, true, true, true) => Self::RectangularWithSides,
(_, _, true, true, true, _, _, _, _) if wl_equal => Self::Circular,
(_, _, true, true, true, _, _, _, _) => Self::Ellipse,
(_, _, _, true, true, true, _, _, _) if all_equal => Self::Sphere,
(_, _, _, true, true, true, _, _, _) => Self::EllipsoidalSpheroid,
(_, _, _, true, true, _, _, _, true) if wl_equal => Self::VerticalCylinder,
(_, _, _, true, true, _, _, _, true) => Self::VerticalEllipsoidalCylinder,
(_, _, _, true, _, true, _, true, _) if (width - height).abs() < 0.0001 => {
Self::HorizontalCylinderAlong
}
(_, _, _, true, _, true, _, true, _) => Self::HorizontalEllipsoidalCylinderAlong,
(_, _, _, _, true, true, true, _, _) if (length - height).abs() < 0.0001 => {
Self::HorizontalCylinderPerpendicular
}
(_, _, _, _, true, true, true, _, _) => {
Self::HorizontalEllipsoidalCylinderPerpendicular
}
(_, true, _, true, _, true, _, _, _) if (width - height).abs() < 0.0001 => {
Self::VerticalCircle
}
(_, true, _, true, _, true, _, _, _) => Self::VerticalEllipse,
_ => Self::Point,
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Point => "Point source",
Self::Rectangular => "Rectangular luminous opening",
Self::RectangularWithSides => "Rectangular with luminous sides",
Self::Circular => "Circular luminous opening",
Self::Ellipse => "Elliptical luminous opening",
Self::VerticalCylinder => "Vertical cylinder",
Self::VerticalEllipsoidalCylinder => "Vertical ellipsoidal cylinder",
Self::Sphere => "Spherical luminous opening",
Self::EllipsoidalSpheroid => "Ellipsoidal spheroid",
Self::HorizontalCylinderAlong => "Horizontal cylinder along photometric horizontal",
Self::HorizontalEllipsoidalCylinderAlong => {
"Horizontal ellipsoidal cylinder along photometric horizontal"
}
Self::HorizontalCylinderPerpendicular => {
"Horizontal cylinder perpendicular to photometric horizontal"
}
Self::HorizontalEllipsoidalCylinderPerpendicular => {
"Horizontal ellipsoidal cylinder perpendicular to photometric horizontal"
}
Self::VerticalCircle => "Vertical circle facing photometric horizontal",
Self::VerticalEllipse => "Vertical ellipse facing photometric horizontal",
}
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TiltData {
pub lamp_geometry: i32,
pub angles: Vec<f64>,
pub factors: Vec<f64>,
}
#[derive(Debug, Clone, Copy, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LampPosition {
pub horizontal: f64,
pub vertical: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum PhotometricType {
TypeA = 3,
TypeB = 2,
#[default]
TypeC = 1,
}
impl PhotometricType {
pub fn from_int(value: i32) -> Result<Self> {
match value {
1 => Ok(Self::TypeC),
2 => Ok(Self::TypeB),
3 => Ok(Self::TypeA),
_ => Err(anyhow!("Invalid photometric type: {}", value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum UnitType {
Feet = 1,
#[default]
Meters = 2,
}
impl UnitType {
pub fn from_int(value: i32) -> Result<Self> {
match value {
1 => Ok(Self::Feet),
2 => Ok(Self::Meters),
_ => Err(anyhow!("Invalid unit type: {}", value)),
}
}
pub fn to_mm_factor(&self) -> f64 {
match self {
UnitType::Feet => 304.8, UnitType::Meters => 1000.0, }
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct IesData {
pub version: IesVersion,
pub version_string: String,
pub keywords: HashMap<String, String>,
pub test: String,
pub test_lab: String,
pub issue_date: String,
pub manufacturer: String,
pub luminaire_catalog: String,
pub luminaire: String,
pub lamp_catalog: String,
pub lamp: String,
pub ballast: String,
pub ballast_catalog: String,
pub test_date: String,
pub maintenance_category: Option<i32>,
pub distribution: String,
pub flash_area: Option<f64>,
pub color_constant: Option<f64>,
pub lamp_position: Option<LampPosition>,
pub near_field: Option<(f64, f64, f64)>,
pub file_gen_info: String,
pub search: String,
pub other: Vec<String>,
pub num_lamps: i32,
pub lumens_per_lamp: f64,
pub multiplier: f64,
pub n_vertical: usize,
pub n_horizontal: usize,
pub photometric_type: PhotometricType,
pub unit_type: UnitType,
pub width: f64,
pub length: f64,
pub height: f64,
pub luminous_shape: LuminousShape,
pub ballast_factor: f64,
pub file_generation_type: FileGenerationType,
pub file_generation_value: f64,
pub input_watts: f64,
pub tilt_mode: String,
pub tilt_data: Option<TiltData>,
pub vertical_angles: Vec<f64>,
pub horizontal_angles: Vec<f64>,
pub candela_values: Vec<Vec<f64>>,
}
impl IesParser {
pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<Eulumdat> {
let content = read_with_encoding_fallback(path)?;
Self::parse(&content)
}
pub fn parse_file_with_options<P: AsRef<Path>>(
path: P,
options: &IesImportOptions,
) -> Result<Eulumdat> {
let content = read_with_encoding_fallback(path)?;
Self::parse_with_options(&content, options)
}
pub fn parse(content: &str) -> Result<Eulumdat> {
let ies_data = Self::parse_ies_data(content)?;
Self::convert_to_eulumdat(ies_data)
}
pub fn parse_with_options(content: &str, options: &IesImportOptions) -> Result<Eulumdat> {
let ies_data = Self::parse_ies_data(content)?;
let mut ldt = Self::convert_to_eulumdat(ies_data)?;
if options.rotate_c_planes.abs() > 0.001 {
ldt.rotate_c_planes(options.rotate_c_planes);
}
Ok(ldt)
}
pub fn parse_to_ies_data(content: &str) -> Result<IesData> {
Self::parse_ies_data(content)
}
fn parse_ies_data(content: &str) -> Result<IesData> {
let mut data = IesData::default();
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return Err(anyhow!("Empty IES file"));
}
let mut line_idx = 0;
let first_line = lines[line_idx].trim();
if first_line.to_uppercase().starts_with("IES")
|| first_line.to_uppercase().starts_with("IESNA")
{
data.version_string = first_line.to_string();
data.version = IesVersion::from_header(first_line);
line_idx += 1;
} else {
data.version_string = "IESNA91".to_string();
data.version = IesVersion::Lm63_1991;
}
let mut current_keyword = String::new();
let mut current_value = String::new();
let mut last_stored_keyword = String::new();
while line_idx < lines.len() {
let line = lines[line_idx].trim();
if line.to_uppercase().starts_with("TILT=") || line.to_uppercase().starts_with("TILT ")
{
if !current_keyword.is_empty() {
Self::store_keyword(&mut data, ¤t_keyword, ¤t_value);
}
break;
}
if line.starts_with('[') {
if !current_keyword.is_empty() {
Self::store_keyword(&mut data, ¤t_keyword, ¤t_value);
last_stored_keyword = current_keyword.clone();
}
if let Some(end_bracket) = line.find(']') {
current_keyword = line[1..end_bracket].to_uppercase();
current_value = line[end_bracket + 1..].trim().to_string();
if current_keyword == "MORE" && !last_stored_keyword.is_empty() {
if let Some(existing) = data.keywords.get_mut(&last_stored_keyword) {
existing.push('\n');
existing.push_str(¤t_value);
}
current_keyword.clear();
current_value.clear();
}
}
}
line_idx += 1;
}
if line_idx < lines.len() {
let tilt_line = lines[line_idx].trim().to_uppercase();
data.tilt_mode = if tilt_line.contains("INCLUDE") {
"INCLUDE".to_string()
} else {
"NONE".to_string()
};
line_idx += 1;
if tilt_line.contains("INCLUDE") {
let mut tilt = TiltData::default();
if line_idx < lines.len() {
if let Ok(geom) = lines[line_idx].trim().parse::<i32>() {
tilt.lamp_geometry = geom;
}
line_idx += 1;
}
if line_idx < lines.len() {
if let Ok(n_pairs) = lines[line_idx].trim().parse::<usize>() {
line_idx += 1;
let mut angle_values: Vec<f64> = Vec::new();
while angle_values.len() < n_pairs && line_idx < lines.len() {
for token in lines[line_idx].split_whitespace() {
if let Ok(val) = token.replace(',', ".").parse::<f64>() {
angle_values.push(val);
}
}
line_idx += 1;
}
tilt.angles = angle_values;
let mut factor_values: Vec<f64> = Vec::new();
while factor_values.len() < n_pairs && line_idx < lines.len() {
for token in lines[line_idx].split_whitespace() {
if let Ok(val) = token.replace(',', ".").parse::<f64>() {
factor_values.push(val);
}
}
line_idx += 1;
}
tilt.factors = factor_values;
}
}
data.tilt_data = Some(tilt);
}
}
let mut numeric_values: Vec<f64> = Vec::new();
while line_idx < lines.len() {
let line = lines[line_idx].trim();
for token in line.split_whitespace() {
if let Ok(val) = token.replace(',', ".").parse::<f64>() {
numeric_values.push(val);
}
}
line_idx += 1;
}
if numeric_values.len() < 13 {
return Err(anyhow!(
"Insufficient photometric data: expected at least 13 values, found {}",
numeric_values.len()
));
}
let mut idx = 0;
data.num_lamps = numeric_values[idx] as i32;
idx += 1;
data.lumens_per_lamp = numeric_values[idx];
idx += 1;
data.multiplier = numeric_values[idx];
idx += 1;
data.n_vertical = numeric_values[idx] as usize;
idx += 1;
data.n_horizontal = numeric_values[idx] as usize;
idx += 1;
data.photometric_type = PhotometricType::from_int(numeric_values[idx] as i32)?;
idx += 1;
data.unit_type = UnitType::from_int(numeric_values[idx] as i32)?;
idx += 1;
data.width = numeric_values[idx];
idx += 1;
data.length = numeric_values[idx];
idx += 1;
data.height = numeric_values[idx];
idx += 1;
data.luminous_shape = LuminousShape::from_dimensions(data.width, data.length, data.height);
data.ballast_factor = numeric_values[idx];
idx += 1;
data.file_generation_value = numeric_values[idx];
data.file_generation_type = FileGenerationType::from_value(data.file_generation_value);
idx += 1;
data.input_watts = numeric_values[idx];
idx += 1;
if idx + data.n_vertical > numeric_values.len() {
return Err(anyhow!("Insufficient vertical angle data"));
}
data.vertical_angles = numeric_values[idx..idx + data.n_vertical].to_vec();
idx += data.n_vertical;
if idx + data.n_horizontal > numeric_values.len() {
return Err(anyhow!("Insufficient horizontal angle data"));
}
data.horizontal_angles = numeric_values[idx..idx + data.n_horizontal].to_vec();
idx += data.n_horizontal;
let expected_candela = data.n_horizontal * data.n_vertical;
if idx + expected_candela > numeric_values.len() {
return Err(anyhow!(
"Insufficient candela data: expected {}, remaining {}",
expected_candela,
numeric_values.len() - idx
));
}
for _ in 0..data.n_horizontal {
let row: Vec<f64> = numeric_values[idx..idx + data.n_vertical].to_vec();
data.candela_values.push(row);
idx += data.n_vertical;
}
Ok(data)
}
fn store_keyword(data: &mut IesData, keyword: &str, value: &str) {
data.keywords.insert(keyword.to_string(), value.to_string());
match keyword {
"TEST" => data.test = value.to_string(),
"TESTLAB" => data.test_lab = value.to_string(),
"ISSUEDATE" => data.issue_date = value.to_string(),
"MANUFAC" => data.manufacturer = value.to_string(),
"LUMCAT" => data.luminaire_catalog = value.to_string(),
"LUMINAIRE" => data.luminaire = value.to_string(),
"LAMPCAT" => data.lamp_catalog = value.to_string(),
"LAMP" => data.lamp = value.to_string(),
"BALLAST" => data.ballast = value.to_string(),
"BALLASTCAT" => data.ballast_catalog = value.to_string(),
"TESTDATE" => data.test_date = value.to_string(),
"MAINTCAT" => data.maintenance_category = value.trim().parse().ok(),
"DISTRIBUTION" => data.distribution = value.to_string(),
"FLASHAREA" => data.flash_area = value.trim().parse().ok(),
"COLORCONSTANT" => data.color_constant = value.trim().parse().ok(),
"LAMPPOSITION" => {
let parts: Vec<f64> = value
.split([' ', ','])
.filter_map(|s| s.trim().parse().ok())
.collect();
if parts.len() >= 2 {
data.lamp_position = Some(LampPosition {
horizontal: parts[0],
vertical: parts[1],
});
}
}
"NEARFIELD" => {
let parts: Vec<f64> = value
.split([' ', ','])
.filter_map(|s| s.trim().parse().ok())
.collect();
if parts.len() >= 3 {
data.near_field = Some((parts[0], parts[1], parts[2]));
}
}
"FILEGENINFO" => {
if data.file_gen_info.is_empty() {
data.file_gen_info = value.to_string();
} else {
data.file_gen_info.push('\n');
data.file_gen_info.push_str(value);
}
}
"SEARCH" => data.search = value.to_string(),
"OTHER" => data.other.push(value.to_string()),
_ => {
}
}
}
fn convert_to_eulumdat(ies: IesData) -> Result<Eulumdat> {
let mut ldt = Eulumdat::new();
ldt.identification = ies.manufacturer.clone();
ldt.luminaire_name = ies.luminaire.clone();
ldt.luminaire_number = ies.luminaire_catalog.clone();
ldt.measurement_report_number = ies.test.clone();
ldt.file_name = ies.test_lab.clone();
ldt.date_user = ies.issue_date.clone();
ldt.symmetry = Self::detect_symmetry(&ies.horizontal_angles);
ldt.type_indicator = if ies.length > ies.width * 2.0 {
TypeIndicator::Linear
} else if ldt.symmetry == Symmetry::VerticalAxis {
TypeIndicator::PointSourceSymmetric
} else {
TypeIndicator::PointSourceOther
};
ldt.c_angles = ies.horizontal_angles.clone();
ldt.g_angles = ies.vertical_angles.clone();
ldt.num_c_planes = ies.n_horizontal;
ldt.num_g_planes = ies.n_vertical;
if ldt.c_angles.len() >= 2 {
ldt.c_plane_distance = ldt.c_angles[1] - ldt.c_angles[0];
}
if ldt.g_angles.len() >= 2 {
ldt.g_plane_distance = ldt.g_angles[1] - ldt.g_angles[0];
}
let mm_factor = ies.unit_type.to_mm_factor();
ldt.length = ies.length * mm_factor;
ldt.width = ies.width * mm_factor;
ldt.height = ies.height * mm_factor;
ldt.luminous_area_length = ldt.length;
ldt.luminous_area_width = ldt.width;
let (num_lamps, total_flux) = if ies.lumens_per_lamp < 0.0 {
let calculated_flux =
Self::calculate_flux_from_intensities(&ies.candela_values, &ies.vertical_angles)
* ies.multiplier;
(-1, calculated_flux)
} else {
(ies.num_lamps, ies.lumens_per_lamp * ies.num_lamps as f64)
};
ldt.lamp_sets.push(LampSet {
num_lamps,
lamp_type: if ies.lamp.is_empty() {
"Unknown".to_string()
} else {
ies.lamp.clone()
},
total_luminous_flux: total_flux,
color_appearance: ies.keywords.get("COLORTEMP").cloned().unwrap_or_default(),
color_rendering_group: ies.keywords.get("CRI").cloned().unwrap_or_default(),
wattage_with_ballast: ies.input_watts,
});
let cd_to_cdklm = if total_flux > 0.0 {
1000.0 / total_flux
} else {
1.0
};
ldt.intensities = ies
.candela_values
.iter()
.map(|row| {
row.iter()
.map(|&v| v * cd_to_cdklm * ies.multiplier)
.collect()
})
.collect();
ldt.conversion_factor = ies.multiplier;
ldt.downward_flux_fraction =
crate::calculations::PhotometricCalculations::downward_flux(&ldt, 90.0);
ldt.light_output_ratio = 100.0;
Ok(ldt)
}
fn detect_symmetry(h_angles: &[f64]) -> Symmetry {
if h_angles.is_empty() {
return Symmetry::None;
}
let min_angle = h_angles.iter().cloned().fold(f64::INFINITY, f64::min);
let max_angle = h_angles.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if h_angles.len() == 1 {
Symmetry::VerticalAxis
} else if (max_angle - 90.0).abs() < 0.1 && min_angle.abs() < 0.1 {
Symmetry::BothPlanes
} else if (max_angle - 180.0).abs() < 0.1 && min_angle.abs() < 0.1 {
Symmetry::PlaneC0C180
} else if (min_angle - 90.0).abs() < 0.1 && (max_angle - 270.0).abs() < 0.1 {
Symmetry::PlaneC90C270
} else {
Symmetry::None
}
}
fn calculate_flux_from_intensities(
candela_values: &[Vec<f64>],
vertical_angles: &[f64],
) -> f64 {
if candela_values.is_empty() || vertical_angles.len() < 2 {
return 0.0;
}
let n_h = candela_values.len();
let n_v = vertical_angles.len();
let avg_intensities: Vec<f64> = (0..n_v)
.map(|v| {
let sum: f64 = candela_values.iter().filter_map(|row| row.get(v)).sum();
sum / n_h as f64
})
.collect();
let mut flux = 0.0;
for i in 0..n_v - 1 {
let gamma1 = vertical_angles[i].to_radians();
let gamma2 = vertical_angles[i + 1].to_radians();
let i1 = avg_intensities[i];
let i2 = avg_intensities[i + 1];
let dg = gamma2 - gamma1;
flux += (i1 * gamma1.sin() + i2 * gamma2.sin()) / 2.0 * dg;
}
flux * 2.0 * std::f64::consts::PI
}
}
pub struct IesExporter;
#[derive(Debug, Clone)]
pub struct IesImportOptions {
pub rotate_c_planes: f64,
}
impl Default for IesImportOptions {
fn default() -> Self {
Self {
rotate_c_planes: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct IesExportOptions {
pub version: IesVersion,
pub file_generation_type: FileGenerationType,
pub issue_date: Option<String>,
pub file_gen_info: Option<String>,
pub test_lab: Option<String>,
pub rotate_c_planes: f64,
}
impl Default for IesExportOptions {
fn default() -> Self {
Self {
version: IesVersion::Lm63_2019,
file_generation_type: FileGenerationType::Undefined,
issue_date: None,
file_gen_info: None,
test_lab: None,
rotate_c_planes: 0.0,
}
}
}
impl IesExporter {
pub fn export(ldt: &Eulumdat) -> String {
Self::export_with_options(ldt, &IesExportOptions::default())
}
pub fn export_2002(ldt: &Eulumdat) -> String {
Self::export_with_options(
ldt,
&IesExportOptions {
version: IesVersion::Lm63_2002,
..Default::default()
},
)
}
pub fn export_with_options(ldt: &Eulumdat, options: &IesExportOptions) -> String {
let rotated;
let ldt = if options.rotate_c_planes.abs() > 0.001 {
rotated = {
let mut copy = ldt.clone();
copy.rotate_c_planes(options.rotate_c_planes);
copy
};
&rotated
} else {
ldt
};
let mut output = String::new();
output.push_str(options.version.header());
output.push('\n');
Self::write_keyword(&mut output, "TEST", &ldt.measurement_report_number);
let test_lab = options.test_lab.as_deref().unwrap_or(&ldt.file_name);
if !test_lab.is_empty() {
Self::write_keyword(&mut output, "TESTLAB", test_lab);
}
if options.version == IesVersion::Lm63_2019 {
let issue_date = options
.issue_date
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
if !ldt.date_user.is_empty() {
&ldt.date_user
} else {
"01-JAN-2025"
}
});
Self::write_keyword(&mut output, "ISSUEDATE", issue_date);
}
if !ldt.identification.is_empty() {
Self::write_keyword(&mut output, "MANUFAC", &ldt.identification);
}
Self::write_keyword(&mut output, "LUMCAT", &ldt.luminaire_number);
Self::write_keyword(&mut output, "LUMINAIRE", &ldt.luminaire_name);
if !ldt.lamp_sets.is_empty() {
Self::write_keyword(&mut output, "LAMP", &ldt.lamp_sets[0].lamp_type);
if ldt.lamp_sets[0].total_luminous_flux > 0.0 {
Self::write_keyword(
&mut output,
"LAMPCAT",
&format!("{:.0} lm", ldt.lamp_sets[0].total_luminous_flux),
);
}
}
if options.version == IesVersion::Lm63_2019 {
if let Some(ref info) = options.file_gen_info {
Self::write_keyword(&mut output, "FILEGENINFO", info);
}
}
output.push_str("TILT=NONE\n");
let num_lamps = ldt.lamp_sets.iter().map(|ls| ls.num_lamps).sum::<i32>();
let total_flux = ldt.total_luminous_flux();
let lumens_per_lamp = if num_lamps < 0 {
-1.0
} else if num_lamps > 0 {
total_flux / num_lamps as f64
} else {
total_flux
};
let (h_angles, v_angles, intensities) = Self::prepare_photometric_data(ldt);
let photometric_type = 1;
let units_type = 2;
let width = ldt.width / 1000.0;
let length = ldt.length / 1000.0;
let height = ldt.height / 1000.0;
let ies_num_lamps = num_lamps.abs().max(1);
output.push_str(&format!(
"{} {:.1} {:.6} {} {} {} {} {:.4} {:.4} {:.4}\n",
ies_num_lamps,
lumens_per_lamp,
ldt.conversion_factor.max(1.0),
v_angles.len(),
h_angles.len(),
photometric_type,
units_type,
width,
length,
height
));
let total_watts = ldt.total_wattage();
let file_gen_value = if options.version == IesVersion::Lm63_2019 {
options.file_generation_type.value()
} else {
1.0 };
output.push_str(&format!("1.0 {:.5} {:.1}\n", file_gen_value, total_watts));
output.push_str(&Self::format_values_multiline(&v_angles, 10));
output.push('\n');
output.push_str(&Self::format_values_multiline(&h_angles, 10));
output.push('\n');
let cdklm_to_cd = total_flux / 1000.0;
for row in &intensities {
let absolute_candela: Vec<f64> = row.iter().map(|&v| v * cdklm_to_cd).collect();
output.push_str(&Self::format_values_multiline(&absolute_candela, 10));
output.push('\n');
}
output
}
fn write_keyword(output: &mut String, keyword: &str, value: &str) {
if !value.is_empty() {
output.push_str(&format!("[{}] {}\n", keyword, value));
}
}
fn prepare_photometric_data(ldt: &Eulumdat) -> (Vec<f64>, Vec<f64>, Vec<Vec<f64>>) {
let v_angles = ldt.g_angles.clone();
let (h_angles, intensities) = match ldt.symmetry {
Symmetry::VerticalAxis => {
(
vec![0.0],
vec![ldt.intensities.first().cloned().unwrap_or_default()],
)
}
Symmetry::PlaneC0C180 => {
let expanded = SymmetryHandler::expand_to_full(ldt);
let h = SymmetryHandler::expand_c_angles(ldt);
let mut h_filtered = Vec::new();
let mut i_filtered = Vec::new();
for (i, &angle) in h.iter().enumerate() {
if angle <= 180.0 && i < expanded.len() {
h_filtered.push(angle);
i_filtered.push(expanded[i].clone());
}
}
(h_filtered, i_filtered)
}
Symmetry::PlaneC90C270 => {
let expanded = SymmetryHandler::expand_to_full(ldt);
let h = SymmetryHandler::expand_c_angles(ldt);
(h, expanded)
}
Symmetry::BothPlanes => {
let h: Vec<f64> = ldt
.c_angles
.iter()
.filter(|&&a| a <= 90.0)
.copied()
.collect();
let i: Vec<Vec<f64>> = ldt.intensities.iter().take(h.len()).cloned().collect();
(h, i)
}
Symmetry::None => {
(ldt.c_angles.clone(), ldt.intensities.clone())
}
};
(h_angles, v_angles, intensities)
}
fn format_values_multiline(values: &[f64], per_line: usize) -> String {
values
.chunks(per_line)
.map(|chunk| {
chunk
.iter()
.map(|&v| format!("{:.2}", v))
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ies_export() {
let mut ldt = Eulumdat::new();
ldt.identification = "Test Manufacturer".to_string();
ldt.luminaire_name = "Test Luminaire".to_string();
ldt.luminaire_number = "LUM-001".to_string();
ldt.measurement_report_number = "TEST-001".to_string();
ldt.symmetry = Symmetry::VerticalAxis;
ldt.num_c_planes = 1;
ldt.num_g_planes = 5;
ldt.c_angles = vec![0.0];
ldt.g_angles = vec![0.0, 22.5, 45.0, 67.5, 90.0];
ldt.intensities = vec![vec![1000.0, 900.0, 700.0, 400.0, 100.0]];
ldt.lamp_sets.push(LampSet {
num_lamps: 1,
lamp_type: "LED".to_string(),
total_luminous_flux: 1000.0,
color_appearance: "3000K".to_string(),
color_rendering_group: "80".to_string(),
wattage_with_ballast: 10.0,
});
ldt.conversion_factor = 1.0;
ldt.length = 100.0;
ldt.width = 100.0;
ldt.height = 50.0;
let ies = IesExporter::export(&ldt);
assert!(ies.contains("IES:LM-63-2019"));
assert!(ies.contains("[LUMINAIRE] Test Luminaire"));
assert!(ies.contains("[MANUFAC] Test Manufacturer"));
assert!(ies.contains("[ISSUEDATE]")); assert!(ies.contains("TILT=NONE"));
let ies_2002 = IesExporter::export_2002(&ldt);
assert!(ies_2002.contains("IESNA:LM-63-2002"));
assert!(!ies_2002.contains("[ISSUEDATE]")); }
#[test]
fn test_ies_parse() {
let ies_content = r#"IESNA:LM-63-2002
[TEST] TEST-001
[MANUFAC] Test Company
[LUMINAIRE] Test Fixture
[LAMP] LED Module
TILT=NONE
1 1000.0 1.0 5 1 1 2 0.1 0.1 0.05
1.0 1.0 10.0
0.0 22.5 45.0 67.5 90.0
0.0
1000.0 900.0 700.0 400.0 100.0
"#;
let ldt = IesParser::parse(ies_content).expect("Failed to parse IES");
assert_eq!(ldt.luminaire_name, "Test Fixture");
assert_eq!(ldt.identification, "Test Company");
assert_eq!(ldt.measurement_report_number, "TEST-001");
assert_eq!(ldt.g_angles.len(), 5);
assert_eq!(ldt.c_angles.len(), 1);
assert_eq!(ldt.symmetry, Symmetry::VerticalAxis);
assert!(!ldt.intensities.is_empty());
}
#[test]
fn test_ies_roundtrip() {
let mut ldt = Eulumdat::new();
ldt.identification = "Roundtrip Test".to_string();
ldt.luminaire_name = "Test Luminaire".to_string();
ldt.symmetry = Symmetry::VerticalAxis;
ldt.c_angles = vec![0.0];
ldt.g_angles = vec![0.0, 45.0, 90.0];
ldt.intensities = vec![vec![500.0, 400.0, 200.0]];
ldt.lamp_sets.push(LampSet {
num_lamps: 1,
lamp_type: "LED".to_string(),
total_luminous_flux: 1000.0,
..Default::default()
});
ldt.length = 100.0;
ldt.width = 100.0;
ldt.height = 50.0;
let ies = IesExporter::export(&ldt);
let parsed = IesParser::parse(&ies).expect("Failed to parse exported IES");
assert_eq!(parsed.luminaire_name, ldt.luminaire_name);
assert_eq!(parsed.g_angles.len(), ldt.g_angles.len());
assert_eq!(parsed.symmetry, Symmetry::VerticalAxis);
}
#[test]
fn test_detect_symmetry() {
assert_eq!(IesParser::detect_symmetry(&[0.0]), Symmetry::VerticalAxis);
assert_eq!(
IesParser::detect_symmetry(&[0.0, 45.0, 90.0]),
Symmetry::BothPlanes
);
assert_eq!(
IesParser::detect_symmetry(&[0.0, 45.0, 90.0, 135.0, 180.0]),
Symmetry::PlaneC0C180
);
assert_eq!(
IesParser::detect_symmetry(&[0.0, 90.0, 180.0, 270.0, 360.0]),
Symmetry::None
);
}
#[test]
fn test_photometric_type() {
assert_eq!(
PhotometricType::from_int(1).unwrap(),
PhotometricType::TypeC
);
assert_eq!(
PhotometricType::from_int(2).unwrap(),
PhotometricType::TypeB
);
assert_eq!(
PhotometricType::from_int(3).unwrap(),
PhotometricType::TypeA
);
assert!(PhotometricType::from_int(0).is_err());
}
#[test]
fn test_unit_conversion() {
assert!((UnitType::Feet.to_mm_factor() - 304.8).abs() < 0.01);
assert!((UnitType::Meters.to_mm_factor() - 1000.0).abs() < 0.01);
}
#[test]
fn test_ies_version_parsing() {
assert_eq!(
IesVersion::from_header("IES:LM-63-2019"),
IesVersion::Lm63_2019
);
assert_eq!(
IesVersion::from_header("IESNA:LM-63-2002"),
IesVersion::Lm63_2002
);
assert_eq!(
IesVersion::from_header("IESNA:LM-63-1995"),
IesVersion::Lm63_1995
);
assert_eq!(IesVersion::from_header("IESNA91"), IesVersion::Lm63_1991);
}
#[test]
fn test_file_generation_type() {
assert_eq!(
FileGenerationType::from_value(1.00001),
FileGenerationType::Undefined
);
assert_eq!(
FileGenerationType::from_value(1.00010),
FileGenerationType::ComputerSimulation
);
assert_eq!(
FileGenerationType::from_value(1.10000),
FileGenerationType::AccreditedLab
);
assert_eq!(
FileGenerationType::from_value(1.10100),
FileGenerationType::AccreditedLabScaled
);
assert!(FileGenerationType::AccreditedLab.is_accredited());
assert!(!FileGenerationType::UnaccreditedLab.is_accredited());
assert!(FileGenerationType::AccreditedLabScaled.is_scaled());
assert!(!FileGenerationType::AccreditedLab.is_scaled());
}
#[test]
fn test_luminous_shape() {
assert_eq!(
LuminousShape::from_dimensions(0.0, 0.0, 0.0),
LuminousShape::Point
);
assert_eq!(
LuminousShape::from_dimensions(0.5, 0.6, 0.0),
LuminousShape::Rectangular
);
assert_eq!(
LuminousShape::from_dimensions(-0.3, -0.3, 0.0),
LuminousShape::Circular
);
assert_eq!(
LuminousShape::from_dimensions(-0.2, -0.2, -0.2),
LuminousShape::Sphere
);
}
#[test]
fn test_ies_2019_parse() {
let ies_content = r#"IES:LM-63-2019
[TEST] ABC1234
[TESTLAB] ABC Laboratories
[ISSUEDATE] 28-FEB-2019
[MANUFAC] Test Company
[LUMCAT] SKYVIEW-123
[LUMINAIRE] LED Wide beam flood
[LAMP] LED Module
[FILEGENINFO] This file was generated from test data
TILT=NONE
1 -1 1.0 5 1 1 2 0.1 0.1 0.0
1.0 1.10000 50.0
0.0 22.5 45.0 67.5 90.0
0.0
1000.0 900.0 700.0 400.0 100.0
"#;
let ies_data = IesParser::parse_to_ies_data(ies_content).expect("Failed to parse IES");
assert_eq!(ies_data.version, IesVersion::Lm63_2019);
assert_eq!(ies_data.test, "ABC1234");
assert_eq!(ies_data.test_lab, "ABC Laboratories");
assert_eq!(ies_data.issue_date, "28-FEB-2019");
assert_eq!(ies_data.manufacturer, "Test Company");
assert_eq!(
ies_data.file_generation_type,
FileGenerationType::AccreditedLab
);
assert_eq!(
ies_data.file_gen_info,
"This file was generated from test data"
);
assert_eq!(ies_data.lumens_per_lamp, -1.0); }
#[test]
fn test_ies_tilt_include() {
let ies_content = r#"IES:LM-63-2019
[TEST] TILT-TEST
[TESTLAB] Test Lab
[ISSUEDATE] 01-JAN-2020
[MANUFAC] Test Mfg
TILT=INCLUDE
1
7
0 15 30 45 60 75 90
1.0 0.95 0.94 0.90 0.88 0.87 0.94
1 1000.0 1.0 3 1 1 2 0.1 0.1 0.0
1.0 1.00001 10.0
0.0 45.0 90.0
0.0
100.0 80.0 50.0
"#;
let ies_data = IesParser::parse_to_ies_data(ies_content).expect("Failed to parse");
assert_eq!(ies_data.tilt_mode, "INCLUDE");
assert!(ies_data.tilt_data.is_some());
let tilt = ies_data.tilt_data.as_ref().unwrap();
assert_eq!(tilt.lamp_geometry, 1);
assert_eq!(tilt.angles.len(), 7);
assert_eq!(tilt.factors.len(), 7);
assert!((tilt.angles[0] - 0.0).abs() < 0.001);
assert!((tilt.factors[0] - 1.0).abs() < 0.001);
}
#[test]
fn test_more_continuation() {
let ies_content = r#"IES:LM-63-2019
[TEST] MORE-TEST
[TESTLAB] Test Lab
[ISSUEDATE] 01-JAN-2020
[MANUFAC] Test Manufacturer
[OTHER] This is the first line of other info
[MORE] This is the second line of other info
TILT=NONE
1 1000.0 1.0 3 1 1 2 0.1 0.1 0.0
1.0 1.00001 10.0
0.0 45.0 90.0
0.0
100.0 80.0 50.0
"#;
let ies_data = IesParser::parse_to_ies_data(ies_content).expect("Failed to parse");
let other_value = ies_data.keywords.get("OTHER").expect("OTHER not found");
assert!(other_value.contains("first line"));
assert!(other_value.contains("second line"));
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct IesValidationWarning {
pub code: &'static str,
pub message: String,
pub severity: IesValidationSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IesValidationSeverity {
Required,
Recommended,
Info,
}
impl std::fmt::Display for IesValidationWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let severity = match self.severity {
IesValidationSeverity::Required => "ERROR",
IesValidationSeverity::Recommended => "WARNING",
IesValidationSeverity::Info => "INFO",
};
write!(f, "[{}:{}] {}", self.code, severity, self.message)
}
}
pub fn validate_ies(data: &IesData) -> Vec<IesValidationWarning> {
let mut warnings = Vec::new();
if data.test.is_empty() {
warnings.push(IesValidationWarning {
code: "IES001",
message: "Missing required keyword [TEST]".to_string(),
severity: IesValidationSeverity::Required,
});
}
if data.test_lab.is_empty() {
warnings.push(IesValidationWarning {
code: "IES002",
message: "Missing required keyword [TESTLAB]".to_string(),
severity: IesValidationSeverity::Required,
});
}
if data.version == IesVersion::Lm63_2019 && data.issue_date.is_empty() {
warnings.push(IesValidationWarning {
code: "IES003",
message: "Missing required keyword [ISSUEDATE] for LM-63-2019".to_string(),
severity: IesValidationSeverity::Required,
});
}
if data.manufacturer.is_empty() {
warnings.push(IesValidationWarning {
code: "IES004",
message: "Missing required keyword [MANUFAC]".to_string(),
severity: IesValidationSeverity::Required,
});
}
if data.luminaire_catalog.is_empty() {
warnings.push(IesValidationWarning {
code: "IES005",
message: "Missing recommended keyword [LUMCAT]".to_string(),
severity: IesValidationSeverity::Recommended,
});
}
if data.luminaire.is_empty() {
warnings.push(IesValidationWarning {
code: "IES006",
message: "Missing recommended keyword [LUMINAIRE]".to_string(),
severity: IesValidationSeverity::Recommended,
});
}
if data.lamp.is_empty() {
warnings.push(IesValidationWarning {
code: "IES007",
message: "Missing recommended keyword [LAMP]".to_string(),
severity: IesValidationSeverity::Recommended,
});
}
let photo_type = data.photometric_type as i32;
if !(1..=3).contains(&photo_type) {
warnings.push(IesValidationWarning {
code: "IES010",
message: format!(
"Invalid photometric type: {} (must be 1, 2, or 3)",
photo_type
),
severity: IesValidationSeverity::Required,
});
}
let unit_type = data.unit_type as i32;
if !(1..=2).contains(&unit_type) {
warnings.push(IesValidationWarning {
code: "IES011",
message: format!(
"Invalid unit type: {} (must be 1=feet or 2=meters)",
unit_type
),
severity: IesValidationSeverity::Required,
});
}
if !data.vertical_angles.is_empty() {
let first_v = data.vertical_angles[0];
let last_v = *data.vertical_angles.last().unwrap();
if data.photometric_type == PhotometricType::TypeC {
if (first_v - 0.0).abs() > 0.01 && (first_v - 90.0).abs() > 0.01 {
warnings.push(IesValidationWarning {
code: "IES020",
message: format!(
"Type C: First vertical angle ({}) must be 0 or 90 degrees",
first_v
),
severity: IesValidationSeverity::Required,
});
}
if (last_v - 90.0).abs() > 0.01 && (last_v - 180.0).abs() > 0.01 {
warnings.push(IesValidationWarning {
code: "IES021",
message: format!(
"Type C: Last vertical angle ({}) must be 90 or 180 degrees",
last_v
),
severity: IesValidationSeverity::Required,
});
}
}
if data.photometric_type == PhotometricType::TypeA
|| data.photometric_type == PhotometricType::TypeB
{
if (first_v - 0.0).abs() > 0.01 && (first_v + 90.0).abs() > 0.01 {
warnings.push(IesValidationWarning {
code: "IES022",
message: format!(
"Type A/B: First vertical angle ({}) must be -90 or 0 degrees",
first_v
),
severity: IesValidationSeverity::Required,
});
}
if (last_v - 90.0).abs() > 0.01 {
warnings.push(IesValidationWarning {
code: "IES023",
message: format!(
"Type A/B: Last vertical angle ({}) must be 90 degrees",
last_v
),
severity: IesValidationSeverity::Required,
});
}
}
for i in 1..data.vertical_angles.len() {
if data.vertical_angles[i] <= data.vertical_angles[i - 1] {
warnings.push(IesValidationWarning {
code: "IES024",
message: format!("Vertical angles not in ascending order at index {}", i),
severity: IesValidationSeverity::Required,
});
break;
}
}
}
if !data.horizontal_angles.is_empty() {
let first_h = data.horizontal_angles[0];
let last_h = *data.horizontal_angles.last().unwrap();
if data.photometric_type == PhotometricType::TypeC {
if (first_h - 0.0).abs() > 0.01 {
warnings.push(IesValidationWarning {
code: "IES030",
message: format!(
"Type C: First horizontal angle ({}) must be 0 degrees",
first_h
),
severity: IesValidationSeverity::Required,
});
}
let valid_last = [
(0.0, "laterally symmetric"),
(90.0, "quadrant symmetric"),
(180.0, "bilateral symmetric"),
(360.0, "no lateral symmetry"),
];
let mut found_valid = false;
for (angle, _) in &valid_last {
if (last_h - angle).abs() < 0.01 {
found_valid = true;
break;
}
}
if !found_valid && data.horizontal_angles.len() > 1 {
warnings.push(IesValidationWarning {
code: "IES031",
message: format!(
"Type C: Last horizontal angle ({}) must be 0, 90, 180, or 360 degrees",
last_h
),
severity: IesValidationSeverity::Required,
});
}
}
for i in 1..data.horizontal_angles.len() {
if data.horizontal_angles[i] <= data.horizontal_angles[i - 1] {
warnings.push(IesValidationWarning {
code: "IES032",
message: format!("Horizontal angles not in ascending order at index {}", i),
severity: IesValidationSeverity::Required,
});
break;
}
}
}
if data.candela_values.len() != data.n_horizontal {
warnings.push(IesValidationWarning {
code: "IES040",
message: format!(
"Candela data has {} horizontal planes, expected {}",
data.candela_values.len(),
data.n_horizontal
),
severity: IesValidationSeverity::Required,
});
}
for (i, row) in data.candela_values.iter().enumerate() {
if row.len() != data.n_vertical {
warnings.push(IesValidationWarning {
code: "IES041",
message: format!(
"Candela row {} has {} values, expected {}",
i,
row.len(),
data.n_vertical
),
severity: IesValidationSeverity::Required,
});
}
}
if data.version == IesVersion::Lm63_2019 {
let valid_values = [
1.00001, 1.00010, 1.00000, 1.00100, 1.01000, 1.01100, 1.10000, 1.10100, 1.11000,
1.11100,
];
let mut found = false;
for &v in &valid_values {
if (data.file_generation_value - v).abs() < 0.000001 {
found = true;
break;
}
}
if !found && data.file_generation_value > 1.0 {
warnings.push(IesValidationWarning {
code: "IES050",
message: format!(
"File generation type value ({}) is not a standard LM-63-2019 value",
data.file_generation_value
),
severity: IesValidationSeverity::Info,
});
}
}
if data.ballast_factor <= 0.0 || data.ballast_factor > 2.0 {
warnings.push(IesValidationWarning {
code: "IES060",
message: format!(
"Unusual ballast factor: {} (typically 0.5-1.5)",
data.ballast_factor
),
severity: IesValidationSeverity::Info,
});
}
let mut has_negative = false;
let mut max_cd = 0.0f64;
for row in &data.candela_values {
for &cd in row {
if cd < 0.0 {
has_negative = true;
}
max_cd = max_cd.max(cd);
}
}
if has_negative {
warnings.push(IesValidationWarning {
code: "IES070",
message: "Negative candela values found".to_string(),
severity: IesValidationSeverity::Required,
});
}
if max_cd > 1_000_000.0 {
warnings.push(IesValidationWarning {
code: "IES071",
message: format!(
"Very high candela value: {:.0} (verify data correctness)",
max_cd
),
severity: IesValidationSeverity::Info,
});
}
warnings
}
pub fn validate_ies_strict(data: &IesData) -> Vec<IesValidationWarning> {
validate_ies(data)
.into_iter()
.filter(|w| w.severity == IesValidationSeverity::Required)
.collect()
}