use crate::eulumdat::{Eulumdat, Symmetry};
use crate::type_b_conversion::TypeBConversion;
use std::f64::consts::PI;
pub struct PhotometricCalculations;
impl PhotometricCalculations {
pub fn downward_flux(ldt: &Eulumdat, arc: f64) -> f64 {
let total_output = Self::total_output(ldt);
if total_output <= 0.0 {
return 0.0;
}
let downward = match ldt.symmetry {
Symmetry::None => Self::downward_no_symmetry(ldt, arc),
Symmetry::VerticalAxis => Self::downward_for_plane(ldt, 0, arc),
Symmetry::PlaneC0C180 => Self::downward_c0_c180(ldt, arc),
Symmetry::PlaneC90C270 => Self::downward_c90_c270(ldt, arc),
Symmetry::BothPlanes => Self::downward_both_planes(ldt, arc),
};
100.0 * downward / total_output
}
fn downward_no_symmetry(ldt: &Eulumdat, arc: f64) -> f64 {
let mc = ldt.actual_c_planes();
if mc == 0 || ldt.c_angles.is_empty() {
return 0.0;
}
let mut sum = 0.0;
for i in 1..mc {
let delta_c = ldt.c_angles[i] - ldt.c_angles[i - 1];
sum += delta_c * Self::downward_for_plane(ldt, i - 1, arc);
}
if mc > 1 {
let delta_c = 360.0 - ldt.c_angles[mc - 1];
sum += delta_c * Self::downward_for_plane(ldt, mc - 1, arc);
}
sum / 360.0
}
fn downward_c0_c180(ldt: &Eulumdat, arc: f64) -> f64 {
let mc = ldt.actual_c_planes();
if mc == 0 || ldt.c_angles.is_empty() {
return 0.0;
}
let mut sum = 0.0;
for i in 1..mc {
let delta_c = ldt.c_angles[i] - ldt.c_angles[i - 1];
sum += 2.0 * delta_c * Self::downward_for_plane(ldt, i - 1, arc);
}
if mc > 0 {
let delta_c = 180.0 - ldt.c_angles[mc - 1];
sum += 2.0 * delta_c * Self::downward_for_plane(ldt, mc - 1, arc);
}
sum / 360.0
}
fn downward_c90_c270(ldt: &Eulumdat, arc: f64) -> f64 {
Self::downward_c0_c180(ldt, arc)
}
fn downward_both_planes(ldt: &Eulumdat, arc: f64) -> f64 {
let mc = ldt.actual_c_planes();
if mc == 0 || ldt.c_angles.is_empty() {
return 0.0;
}
let mut sum = 0.0;
for i in 1..mc {
let delta_c = ldt.c_angles[i] - ldt.c_angles[i - 1];
sum += 4.0 * delta_c * Self::downward_for_plane(ldt, i - 1, arc);
}
if mc > 0 {
let delta_c = 90.0 - ldt.c_angles[mc - 1];
sum += 4.0 * delta_c * Self::downward_for_plane(ldt, mc - 1, arc);
}
sum / 360.0
}
fn downward_for_plane(ldt: &Eulumdat, c_index: usize, arc: f64) -> f64 {
if c_index >= ldt.intensities.len() || ldt.g_angles.is_empty() {
return 0.0;
}
let intensities = &ldt.intensities[c_index];
let mut sum = 0.0;
for j in 1..ldt.g_angles.len() {
let g_prev = ldt.g_angles[j - 1];
let g_curr = ldt.g_angles[j];
if g_prev >= arc {
break;
}
let g_end = g_curr.min(arc);
let delta_g = g_end - g_prev;
if delta_g <= 0.0 {
continue;
}
let i_prev = intensities.get(j - 1).copied().unwrap_or(0.0);
let i_curr = intensities.get(j).copied().unwrap_or(0.0);
let avg_intensity = (i_prev + i_curr) / 2.0;
let g_prev_rad = g_prev * PI / 180.0;
let g_end_rad = g_end * PI / 180.0;
let solid_angle = (g_prev_rad.cos() - g_end_rad.cos()).abs();
sum += avg_intensity * solid_angle;
}
sum * 2.0 * PI
}
pub fn total_output(ldt: &Eulumdat) -> f64 {
let mc = ldt.actual_c_planes();
if mc == 0 {
return 0.0;
}
match ldt.symmetry {
Symmetry::None => Self::downward_no_symmetry(ldt, 180.0),
Symmetry::VerticalAxis => Self::downward_for_plane(ldt, 0, 180.0),
Symmetry::PlaneC0C180 => Self::downward_c0_c180(ldt, 180.0),
Symmetry::PlaneC90C270 => Self::downward_c90_c270(ldt, 180.0),
Symmetry::BothPlanes => Self::downward_both_planes(ldt, 180.0),
}
}
pub fn calculated_luminous_flux(ldt: &Eulumdat) -> f64 {
Self::total_output(ldt) * ldt.conversion_factor
}
pub fn calculate_direct_ratios(ldt: &Eulumdat, shr: &str) -> [f64; 10] {
let (e, f, g, h) = Self::get_shr_coefficients(shr);
let a = Self::downward_flux(ldt, 41.4);
let b = Self::downward_flux(ldt, 60.0);
let c = Self::downward_flux(ldt, 75.5);
let d = Self::downward_flux(ldt, 90.0);
let mut ratios = [0.0; 10];
for i in 0..10 {
let t = a * e[i] + b * f[i] + c * g[i] + d * h[i];
ratios[i] = t / 100_000.0;
}
ratios
}
fn get_shr_coefficients(shr: &str) -> ([f64; 10], [f64; 10], [f64; 10], [f64; 10]) {
match shr {
"1.00" => (
[
943.0, 752.0, 636.0, 510.0, 429.0, 354.0, 286.0, 258.0, 236.0, 231.0,
],
[
-317.0, -33.0, 121.0, 238.0, 275.0, 248.0, 190.0, 118.0, -6.0, -99.0,
],
[
481.0, 372.0, 310.0, 282.0, 309.0, 363.0, 416.0, 463.0, 512.0, 518.0,
],
[
-107.0, -91.0, -67.0, -30.0, -13.0, 35.0, 108.0, 161.0, 258.0, 350.0,
],
),
"1.25" => (
[
967.0, 808.0, 695.0, 565.0, 476.0, 386.0, 307.0, 273.0, 243.0, 234.0,
],
[
-336.0, -82.0, 73.0, 200.0, 249.0, 243.0, 201.0, 137.0, 18.0, -73.0,
],
[
451.0, 339.0, 280.0, 255.0, 278.0, 331.0, 384.0, 432.0, 485.0, 497.0,
],
[
-82.0, -65.0, -48.0, -20.0, -3.0, 40.0, 108.0, 158.0, 254.0, 342.0,
],
),
_ => (
[
983.0, 851.0, 744.0, 614.0, 521.0, 418.0, 329.0, 289.0, 252.0, 239.0,
],
[
-348.0, -122.0, 31.0, 163.0, 220.0, 231.0, 203.0, 149.0, 39.0, -48.0,
],
[
430.0, 315.0, 256.0, 233.0, 253.0, 304.0, 356.0, 404.0, 460.0, 476.0,
],
[
-65.0, -44.0, -31.0, -10.0, 6.0, 47.0, 112.0, 158.0, 249.0, 333.0,
],
),
}
}
pub fn beam_angle(ldt: &Eulumdat) -> f64 {
Self::angle_at_percentage(ldt, 0.5) * 2.0
}
pub fn field_angle(ldt: &Eulumdat) -> f64 {
Self::angle_at_percentage(ldt, 0.1) * 2.0
}
pub fn beam_angle_cie(ldt: &Eulumdat) -> f64 {
Self::angle_at_percentage_of_center(ldt, 0.5) * 2.0
}
pub fn field_angle_cie(ldt: &Eulumdat) -> f64 {
Self::angle_at_percentage_of_center(ldt, 0.1) * 2.0
}
pub fn half_beam_angle(ldt: &Eulumdat) -> f64 {
Self::angle_at_percentage(ldt, 0.5)
}
pub fn half_field_angle(ldt: &Eulumdat) -> f64 {
Self::angle_at_percentage(ldt, 0.1)
}
pub fn beam_field_analysis(ldt: &Eulumdat) -> BeamFieldAnalysis {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return BeamFieldAnalysis::default();
}
let intensities = &ldt.intensities[0];
let max_intensity = intensities.iter().copied().fold(0.0, f64::max);
let center_intensity = intensities.first().copied().unwrap_or(0.0);
let max_gamma = ldt
.g_angles
.iter()
.zip(intensities.iter())
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(g, _)| *g)
.unwrap_or(0.0);
let is_batwing = center_intensity < max_intensity * 0.95;
BeamFieldAnalysis {
beam_angle_ies: Self::angle_at_percentage(ldt, 0.5) * 2.0,
field_angle_ies: Self::angle_at_percentage(ldt, 0.1) * 2.0,
beam_angle_cie: Self::angle_at_percentage_of_center(ldt, 0.5) * 2.0,
field_angle_cie: Self::angle_at_percentage_of_center(ldt, 0.1) * 2.0,
max_intensity,
center_intensity,
max_intensity_gamma: max_gamma,
is_batwing,
beam_threshold_ies: max_intensity * 0.5,
beam_threshold_cie: center_intensity * 0.5,
field_threshold_ies: max_intensity * 0.1,
field_threshold_cie: center_intensity * 0.1,
}
}
fn angle_at_percentage(ldt: &Eulumdat, percentage: f64) -> f64 {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return 0.0;
}
let intensities = &ldt.intensities[0];
let max_intensity = intensities.iter().copied().fold(0.0, f64::max);
if max_intensity <= 0.0 {
return 0.0;
}
let threshold = max_intensity * percentage;
for (i, &intensity) in intensities.iter().enumerate() {
if intensity < threshold && i > 0 {
let prev_intensity = intensities[i - 1];
let prev_angle = ldt.g_angles[i - 1];
let curr_angle = ldt.g_angles[i];
if prev_intensity > threshold {
let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
return prev_angle + ratio * (curr_angle - prev_angle);
}
}
}
*ldt.g_angles.last().unwrap_or(&0.0)
}
fn angle_at_percentage_of_center(ldt: &Eulumdat, percentage: f64) -> f64 {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return 0.0;
}
let intensities = &ldt.intensities[0];
let center_intensity = intensities.first().copied().unwrap_or(0.0);
if center_intensity <= 0.0 {
return Self::angle_at_percentage(ldt, percentage);
}
let threshold = center_intensity * percentage;
for (i, &intensity) in intensities.iter().enumerate() {
if intensity < threshold && i > 0 {
let prev_intensity = intensities[i - 1];
let prev_angle = ldt.g_angles[i - 1];
let curr_angle = ldt.g_angles[i];
if prev_intensity > threshold {
let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
return prev_angle + ratio * (curr_angle - prev_angle);
}
}
}
*ldt.g_angles.last().unwrap_or(&0.0)
}
pub fn upward_beam_angle(ldt: &Eulumdat) -> f64 {
Self::angle_spread_from_peak(ldt, 0.5, true)
}
pub fn upward_field_angle(ldt: &Eulumdat) -> f64 {
Self::angle_spread_from_peak(ldt, 0.1, true)
}
pub fn downward_beam_angle(ldt: &Eulumdat) -> f64 {
Self::angle_spread_from_peak(ldt, 0.5, false)
}
pub fn downward_field_angle(ldt: &Eulumdat) -> f64 {
Self::angle_spread_from_peak(ldt, 0.1, false)
}
fn angle_spread_from_peak(ldt: &Eulumdat, percentage: f64, upward: bool) -> f64 {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return 0.0;
}
let intensities = &ldt.intensities[0];
let g_angles = &ldt.g_angles;
let hemisphere_boundary = 90.0;
let (peak_idx, peak_intensity) = if upward {
intensities
.iter()
.enumerate()
.filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(0.0) >= hemisphere_boundary)
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, &v)| (i, v))
.unwrap_or((0, 0.0))
} else {
intensities
.iter()
.enumerate()
.filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(180.0) <= hemisphere_boundary)
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, &v)| (i, v))
.unwrap_or((0, 0.0))
};
if peak_intensity <= 0.0 {
return 0.0;
}
let threshold = peak_intensity * percentage;
let peak_angle = g_angles.get(peak_idx).copied().unwrap_or(0.0);
let (min_angle, max_angle) = if upward {
(hemisphere_boundary, 180.0)
} else {
(0.0, hemisphere_boundary)
};
let mut angle_low = peak_angle;
for i in (0..peak_idx).rev() {
let angle = g_angles[i];
if angle < min_angle {
angle_low = min_angle;
break;
}
let intensity = intensities[i];
if intensity < threshold {
let next_intensity = intensities.get(i + 1).copied().unwrap_or(peak_intensity);
let next_angle = g_angles.get(i + 1).copied().unwrap_or(peak_angle);
if next_intensity > threshold && next_intensity > intensity {
let ratio = (next_intensity - threshold) / (next_intensity - intensity);
angle_low = (next_angle - ratio * (next_angle - angle)).max(min_angle);
} else {
angle_low = angle;
}
break;
}
angle_low = angle;
}
let mut angle_high = peak_angle;
for i in (peak_idx + 1)..intensities.len() {
let angle = g_angles[i];
if angle > max_angle {
angle_high = max_angle;
break;
}
let intensity = intensities[i];
if intensity < threshold {
let prev_intensity = intensities.get(i - 1).copied().unwrap_or(peak_intensity);
let prev_angle = g_angles.get(i - 1).copied().unwrap_or(peak_angle);
if prev_intensity > threshold && prev_intensity > intensity {
let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
angle_high = (prev_angle + ratio * (angle - prev_angle)).min(max_angle);
} else {
angle_high = angle;
}
break;
}
angle_high = angle;
}
(angle_high - angle_low).abs()
}
pub fn comprehensive_beam_analysis(ldt: &Eulumdat) -> ComprehensiveBeamAnalysis {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return ComprehensiveBeamAnalysis::default();
}
let intensities = &ldt.intensities[0];
let g_angles = &ldt.g_angles;
let (downward_peak_idx, downward_peak) = intensities
.iter()
.enumerate()
.filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(180.0) <= 90.0)
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, &v)| (i, v))
.unwrap_or((0, 0.0));
let (upward_peak_idx, upward_peak) = intensities
.iter()
.enumerate()
.filter(|(i, _)| g_angles.get(*i).copied().unwrap_or(0.0) >= 90.0)
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, &v)| (i, v))
.unwrap_or((intensities.len().saturating_sub(1), 0.0));
let downward_peak_gamma = g_angles.get(downward_peak_idx).copied().unwrap_or(0.0);
let upward_peak_gamma = g_angles.get(upward_peak_idx).copied().unwrap_or(180.0);
let has_downward = downward_peak > 0.0;
let has_upward = upward_peak > 0.0;
let downward_beam = if has_downward {
Self::angle_spread_from_peak(ldt, 0.5, false)
} else {
0.0
};
let downward_field = if has_downward {
Self::angle_spread_from_peak(ldt, 0.1, false)
} else {
0.0
};
let upward_beam = if has_upward {
Self::angle_spread_from_peak(ldt, 0.5, true)
} else {
0.0
};
let upward_field = if has_upward {
Self::angle_spread_from_peak(ldt, 0.1, true)
} else {
0.0
};
let primary_direction = if downward_peak >= upward_peak {
LightDirection::Downward
} else {
LightDirection::Upward
};
let upward_significant = has_upward && upward_peak >= downward_peak * 0.1;
let downward_significant = has_downward && downward_peak >= upward_peak * 0.1;
let distribution_type = if upward_significant && downward_significant {
if downward_peak > upward_peak {
DistributionType::DirectIndirect
} else {
DistributionType::IndirectDirect
}
} else if has_upward && upward_peak > downward_peak {
DistributionType::Indirect
} else {
DistributionType::Direct
};
ComprehensiveBeamAnalysis {
downward_beam_angle: downward_beam,
downward_field_angle: downward_field,
downward_peak_intensity: downward_peak,
downward_peak_gamma,
upward_beam_angle: upward_beam,
upward_field_angle: upward_field,
upward_peak_intensity: upward_peak,
upward_peak_gamma,
primary_direction,
distribution_type,
}
}
pub fn ugr_crosssection(ldt: &Eulumdat) -> Vec<(f64, f64)> {
let ugr_angles = [45.0, 55.0, 65.0, 75.0, 85.0];
ugr_angles
.iter()
.map(|&angle| {
let intensity = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, angle);
(angle, intensity)
})
.collect()
}
pub fn cie_flux_codes(ldt: &Eulumdat) -> CieFluxCodes {
let total = Self::total_output(ldt);
if total <= 0.0 {
return CieFluxCodes::default();
}
let flux_40 = Self::downward_flux(ldt, 40.0);
let flux_60 = Self::downward_flux(ldt, 60.0);
let flux_90 = Self::downward_flux(ldt, 90.0);
let flux_120 = Self::downward_flux(ldt, 120.0);
let flux_180 = Self::downward_flux(ldt, 180.0);
CieFluxCodes {
n1: flux_90, n2: flux_60, n3: flux_40, n4: flux_180 - flux_90, n5: flux_120 - flux_90, }
}
pub fn luminaire_efficacy(ldt: &Eulumdat) -> f64 {
let total_watts = ldt.total_wattage();
if total_watts <= 0.0 {
return 0.0;
}
let lamp_flux = ldt.total_luminous_flux();
let lor = ldt.light_output_ratio / 100.0;
(lamp_flux * lor) / total_watts
}
pub fn luminaire_efficiency(ldt: &Eulumdat) -> f64 {
let lamp_flux = ldt.total_luminous_flux();
if lamp_flux <= 0.0 {
return 0.0;
}
let calculated_flux = Self::calculated_luminous_flux(ldt);
(calculated_flux / lamp_flux) * 100.0
}
pub fn spacing_criterion(ldt: &Eulumdat, c_plane: f64) -> f64 {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return 1.0;
}
let i_nadir = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, 0.0);
if i_nadir <= 0.0 {
return 1.0;
}
let threshold = i_nadir * 0.5;
let mut half_angle = 0.0;
for g in 0..90 {
let intensity =
crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, g as f64);
if intensity < threshold {
let prev_intensity = crate::symmetry::SymmetryHandler::get_intensity_at(
ldt,
c_plane,
(g - 1) as f64,
);
if prev_intensity > threshold {
let ratio = (prev_intensity - threshold) / (prev_intensity - intensity);
half_angle = (g - 1) as f64 + ratio;
}
break;
}
half_angle = g as f64;
}
let s_h = 2.0 * (half_angle * PI / 180.0).tan();
s_h.clamp(0.5, 3.0)
}
pub fn spacing_criteria(ldt: &Eulumdat) -> (f64, f64) {
let s_h_parallel = Self::spacing_criterion(ldt, 0.0);
let s_h_perpendicular = Self::spacing_criterion(ldt, 90.0);
(s_h_parallel, s_h_perpendicular)
}
pub fn spacing_criterion_ies(ldt: &Eulumdat, c_plane: f64, uniformity_threshold: f64) -> f64 {
if ldt.intensities.is_empty() || ldt.g_angles.is_empty() {
return 1.0;
}
let mut low = 0.5;
let mut high = 3.0;
for _ in 0..20 {
let mid = (low + high) / 2.0;
let uniformity = Self::calculate_illuminance_uniformity(ldt, c_plane, mid);
if uniformity >= uniformity_threshold {
low = mid; } else {
high = mid; }
}
low
}
fn calculate_illuminance_uniformity(ldt: &Eulumdat, c_plane: f64, s_h: f64) -> f64 {
const NUM_POINTS: usize = 21;
let mut illuminances = [0.0; NUM_POINTS];
let spacing = s_h;
for (i, e) in illuminances.iter_mut().enumerate() {
let x = (i as f64 / (NUM_POINTS - 1) as f64) * spacing;
*e += Self::point_illuminance(ldt, c_plane, x, 1.0);
*e += Self::point_illuminance(ldt, c_plane, spacing - x, 1.0);
}
let e_max = illuminances.iter().cloned().fold(0.0, f64::max);
let e_min = illuminances.iter().cloned().fold(f64::MAX, f64::min);
if e_max > 0.0 {
e_min / e_max
} else {
0.0
}
}
fn point_illuminance(ldt: &Eulumdat, c_plane: f64, x: f64, h: f64) -> f64 {
let theta = (x / h).atan();
let theta_deg = theta.to_degrees();
let intensity = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, theta_deg);
let cos_theta = theta.cos();
intensity * cos_theta.powi(3) / (h * h)
}
pub fn spacing_criteria_ies(ldt: &Eulumdat) -> (f64, f64, f64) {
let sc_0_180 = Self::spacing_criterion_ies(ldt, 0.0, 0.87);
let sc_90_270 = Self::spacing_criterion_ies(ldt, 90.0, 0.87);
let sc_diagonal = sc_0_180.min(sc_90_270) * 1.10;
(sc_0_180, sc_90_270, sc_diagonal)
}
pub fn zonal_lumens_10deg(ldt: &Eulumdat) -> [f64; 18] {
let mut zones = [0.0; 18];
let total = Self::total_output(ldt);
if total <= 0.0 {
return zones;
}
let mut prev_flux = 0.0;
for (i, zone) in zones.iter_mut().enumerate() {
let angle = ((i + 1) * 10) as f64;
let cumulative = Self::downward_flux(ldt, angle);
*zone = cumulative - prev_flux;
prev_flux = cumulative;
}
zones
}
pub fn zonal_lumens_30deg(ldt: &Eulumdat) -> ZonalLumens30 {
let total = Self::total_output(ldt);
if total <= 0.0 {
return ZonalLumens30::default();
}
let f30 = Self::downward_flux(ldt, 30.0);
let f60 = Self::downward_flux(ldt, 60.0);
let f90 = Self::downward_flux(ldt, 90.0);
let f120 = Self::downward_flux(ldt, 120.0);
let f150 = Self::downward_flux(ldt, 150.0);
let f180 = Self::downward_flux(ldt, 180.0);
ZonalLumens30 {
zone_0_30: f30,
zone_30_60: f60 - f30,
zone_60_90: f90 - f60,
zone_90_120: f120 - f90,
zone_120_150: f150 - f120,
zone_150_180: f180 - f150,
}
}
pub fn k_factor(ldt: &Eulumdat, room_index: f64, reflectances: (f64, f64, f64)) -> f64 {
let room_index_idx = Self::room_index_to_idx(room_index);
let direct_ratios = Self::calculate_direct_ratios(ldt, "1.25");
let direct = direct_ratios[room_index_idx];
let (rho_c, rho_w, rho_f) = reflectances;
let avg_reflectance = (rho_c + rho_w + rho_f) / 3.0;
let indirect_factor = avg_reflectance / (1.0 - avg_reflectance);
let upward_fraction = 1.0 - (ldt.downward_flux_fraction / 100.0);
direct * (1.0 + indirect_factor * upward_fraction * 0.5)
}
fn room_index_to_idx(room_index: f64) -> usize {
let indices = [0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00];
for (i, &k) in indices.iter().enumerate() {
if room_index <= k {
return i;
}
}
9 }
pub fn ugr(ldt: &Eulumdat, params: &UgrParams) -> f64 {
let luminaire_area = (ldt.length / 1000.0) * (ldt.width / 1000.0);
if luminaire_area <= 0.0 {
return 0.0;
}
let lb = params.background_luminance();
let mut sum = 0.0;
for pos in ¶ms.luminaire_positions {
let dx = pos.0 - params.observer_x;
let dy = pos.1 - params.observer_y;
let dz = params.mounting_height - params.eye_height;
let horizontal_dist = (dx * dx + dy * dy).sqrt();
let viewing_angle = (horizontal_dist / dz).atan() * 180.0 / PI;
let c_angle = dy.atan2(dx) * 180.0 / PI;
let c_angle = if c_angle < 0.0 {
c_angle + 360.0
} else {
c_angle
};
let intensity =
crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_angle, viewing_angle);
let luminance = intensity * 1000.0 / luminaire_area;
let dist = (dx * dx + dy * dy + dz * dz).sqrt();
let omega = luminaire_area / (dist * dist);
let p = Self::guth_position_index(viewing_angle, horizontal_dist, dz);
if p > 0.0 {
sum += (luminance * luminance * omega) / (p * p);
}
}
if sum <= 0.0 || lb <= 0.0 {
return 0.0;
}
8.0 * (0.25 * sum / lb).log10()
}
fn guth_position_index(gamma: f64, h: f64, v: f64) -> f64 {
let t = if v > 0.0 { h / v } else { 1.0 };
let p = 1.0 + (gamma / 90.0).powf(2.0) * t;
p.max(1.0)
}
pub fn cu_table(ldt: &Eulumdat) -> CuTable {
CuTable::calculate(ldt)
}
pub fn ugr_table(ldt: &Eulumdat) -> UgrTable {
UgrTable::calculate(ldt)
}
pub fn candela_tabulation(ldt: &Eulumdat) -> CandelaTabulation {
CandelaTabulation::from_eulumdat(ldt)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PhotometricSummary {
pub total_lamp_flux: f64,
pub calculated_flux: f64,
pub lor: f64,
pub dlor: f64,
pub ulor: f64,
pub lamp_efficacy: f64,
pub luminaire_efficacy: f64,
pub total_wattage: f64,
pub cie_flux_codes: CieFluxCodes,
pub beam_angle: f64,
pub field_angle: f64,
pub beam_angle_cie: f64,
pub field_angle_cie: f64,
pub is_batwing: bool,
pub upward_beam_angle: f64,
pub upward_field_angle: f64,
pub primary_direction: LightDirection,
pub distribution_type: DistributionType,
pub max_intensity: f64,
pub min_intensity: f64,
pub avg_intensity: f64,
pub spacing_c0: f64,
pub spacing_c90: f64,
pub zonal_lumens: ZonalLumens30,
}
impl PhotometricSummary {
pub fn from_eulumdat(ldt: &Eulumdat) -> Self {
let cie_codes = PhotometricCalculations::cie_flux_codes(ldt);
let (s_c0, s_c90) = PhotometricCalculations::spacing_criteria(ldt);
Self {
total_lamp_flux: ldt.total_luminous_flux(),
calculated_flux: PhotometricCalculations::calculated_luminous_flux(ldt),
lor: ldt.light_output_ratio,
dlor: ldt.downward_flux_fraction,
ulor: 100.0 - ldt.downward_flux_fraction,
lamp_efficacy: ldt.luminous_efficacy(),
luminaire_efficacy: PhotometricCalculations::luminaire_efficacy(ldt),
total_wattage: ldt.total_wattage(),
cie_flux_codes: cie_codes,
beam_angle: PhotometricCalculations::beam_angle(ldt),
field_angle: PhotometricCalculations::field_angle(ldt),
beam_angle_cie: PhotometricCalculations::beam_angle_cie(ldt),
field_angle_cie: PhotometricCalculations::field_angle_cie(ldt),
is_batwing: {
let analysis = PhotometricCalculations::beam_field_analysis(ldt);
analysis.is_batwing
},
upward_beam_angle: PhotometricCalculations::upward_beam_angle(ldt),
upward_field_angle: PhotometricCalculations::upward_field_angle(ldt),
primary_direction: {
let comp = PhotometricCalculations::comprehensive_beam_analysis(ldt);
comp.primary_direction
},
distribution_type: {
let comp = PhotometricCalculations::comprehensive_beam_analysis(ldt);
comp.distribution_type
},
max_intensity: ldt.max_intensity(),
min_intensity: ldt.min_intensity(),
avg_intensity: ldt.avg_intensity(),
spacing_c0: s_c0,
spacing_c90: s_c90,
zonal_lumens: PhotometricCalculations::zonal_lumens_30deg(ldt),
}
}
pub fn to_text(&self) -> String {
format!(
r#"PHOTOMETRIC SUMMARY
==================
Luminous Flux
Total Lamp Flux: {:.0} lm
Calculated Flux: {:.0} lm
LOR: {:.1}%
DLOR / ULOR: {:.1}% / {:.1}%
Efficacy
Lamp Efficacy: {:.1} lm/W
Luminaire Efficacy: {:.1} lm/W
Total Wattage: {:.1} W
CIE Flux Code: {}
Beam Characteristics
Beam Angle (50%): {:.1}°
Field Angle (10%): {:.1}°
Intensity (cd/klm)
Maximum: {:.1}
Minimum: {:.1}
Average: {:.1}
Spacing Criterion (S/H)
C0 Plane: {:.2}
C90 Plane: {:.2}
Zonal Lumens (%)
0-30°: {:.1}%
30-60°: {:.1}%
60-90°: {:.1}%
90-120°: {:.1}%
120-150°: {:.1}%
150-180°: {:.1}%
"#,
self.total_lamp_flux,
self.calculated_flux,
self.lor,
self.dlor,
self.ulor,
self.lamp_efficacy,
self.luminaire_efficacy,
self.total_wattage,
self.cie_flux_codes,
self.beam_angle,
self.field_angle,
self.max_intensity,
self.min_intensity,
self.avg_intensity,
self.spacing_c0,
self.spacing_c90,
self.zonal_lumens.zone_0_30,
self.zonal_lumens.zone_30_60,
self.zonal_lumens.zone_60_90,
self.zonal_lumens.zone_90_120,
self.zonal_lumens.zone_120_150,
self.zonal_lumens.zone_150_180,
)
}
pub fn to_compact(&self) -> String {
format!(
"CIE:{} Beam:{:.0}° Field:{:.0}° Eff:{:.0}lm/W S/H:{:.1}×{:.1}",
self.cie_flux_codes,
self.beam_angle,
self.field_angle,
self.luminaire_efficacy,
self.spacing_c0,
self.spacing_c90,
)
}
pub fn to_key_value(&self) -> Vec<(&'static str, String)> {
vec![
("total_lamp_flux_lm", format!("{:.1}", self.total_lamp_flux)),
("calculated_flux_lm", format!("{:.1}", self.calculated_flux)),
("lor_percent", format!("{:.1}", self.lor)),
("dlor_percent", format!("{:.1}", self.dlor)),
("ulor_percent", format!("{:.1}", self.ulor)),
("lamp_efficacy_lm_w", format!("{:.1}", self.lamp_efficacy)),
(
"luminaire_efficacy_lm_w",
format!("{:.1}", self.luminaire_efficacy),
),
("total_wattage_w", format!("{:.1}", self.total_wattage)),
("cie_flux_code", self.cie_flux_codes.to_string()),
("cie_n1", format!("{:.1}", self.cie_flux_codes.n1)),
("cie_n2", format!("{:.1}", self.cie_flux_codes.n2)),
("cie_n3", format!("{:.1}", self.cie_flux_codes.n3)),
("cie_n4", format!("{:.1}", self.cie_flux_codes.n4)),
("cie_n5", format!("{:.1}", self.cie_flux_codes.n5)),
("beam_angle_deg", format!("{:.1}", self.beam_angle)),
("field_angle_deg", format!("{:.1}", self.field_angle)),
("max_intensity_cd_klm", format!("{:.1}", self.max_intensity)),
("min_intensity_cd_klm", format!("{:.1}", self.min_intensity)),
("avg_intensity_cd_klm", format!("{:.1}", self.avg_intensity)),
("spacing_c0", format!("{:.2}", self.spacing_c0)),
("spacing_c90", format!("{:.2}", self.spacing_c90)),
(
"zonal_0_30_percent",
format!("{:.1}", self.zonal_lumens.zone_0_30),
),
(
"zonal_30_60_percent",
format!("{:.1}", self.zonal_lumens.zone_30_60),
),
(
"zonal_60_90_percent",
format!("{:.1}", self.zonal_lumens.zone_60_90),
),
(
"zonal_90_120_percent",
format!("{:.1}", self.zonal_lumens.zone_90_120),
),
(
"zonal_120_150_percent",
format!("{:.1}", self.zonal_lumens.zone_120_150),
),
(
"zonal_150_180_percent",
format!("{:.1}", self.zonal_lumens.zone_150_180),
),
]
}
}
impl std::fmt::Display for PhotometricSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_text())
}
}
#[derive(Debug, Clone, Default)]
pub struct GldfPhotometricData {
pub cie_flux_code: String,
pub light_output_ratio: f64,
pub luminous_efficacy: f64,
pub downward_flux_fraction: f64,
pub downward_light_output_ratio: f64,
pub upward_light_output_ratio: f64,
pub luminaire_luminance: f64,
pub cut_off_angle: f64,
pub ugr_4h_8h_705020: Option<UgrTableValues>,
pub photometric_code: String,
pub tenth_peak_divergence: (f64, f64),
pub half_peak_divergence: (f64, f64),
pub light_distribution_bug_rating: String,
}
#[derive(Debug, Clone, Default)]
pub struct UgrTableValues {
pub crosswise: f64,
pub endwise: f64,
}
#[derive(Debug, Clone, Default)]
pub struct IesMetadata {
pub version: String,
pub test_report: String,
pub test_lab: String,
pub issue_date: String,
pub manufacturer: String,
pub luminaire_catalog: String,
pub lamp_catalog: String,
pub ballast: String,
pub file_generation_type: String,
pub is_accredited: bool,
pub is_scaled: bool,
pub is_interpolated: bool,
pub is_simulation: bool,
pub luminous_shape: String,
pub luminous_width: f64,
pub luminous_length: f64,
pub luminous_height: f64,
pub is_rectangular: bool,
pub is_circular: bool,
pub photometric_type: String,
pub unit_type: String,
pub has_tilt_data: bool,
pub tilt_lamp_geometry: i32,
pub tilt_angle_count: usize,
pub maintenance_category: Option<i32>,
pub ballast_factor: f64,
pub input_watts: f64,
pub num_lamps: i32,
pub lumens_per_lamp: f64,
pub is_absolute_photometry: bool,
}
impl IesMetadata {
pub fn from_ies_data(ies: &crate::ies::IesData) -> Self {
use crate::ies::{FileGenerationType, LuminousShape, PhotometricType, UnitType};
let shape = &ies.luminous_shape;
let gen_type = &ies.file_generation_type;
Self {
version: ies.version.header().to_string(),
test_report: ies.test.clone(),
test_lab: ies.test_lab.clone(),
issue_date: ies.issue_date.clone(),
manufacturer: ies.manufacturer.clone(),
luminaire_catalog: ies.luminaire_catalog.clone(),
lamp_catalog: ies.lamp_catalog.clone(),
ballast: ies.ballast.clone(),
file_generation_type: gen_type.title().to_string(),
is_accredited: gen_type.is_accredited(),
is_scaled: gen_type.is_scaled(),
is_interpolated: gen_type.is_interpolated(),
is_simulation: matches!(gen_type, FileGenerationType::ComputerSimulation),
luminous_shape: shape.description().to_string(),
luminous_width: ies.width.abs(),
luminous_length: ies.length.abs(),
luminous_height: ies.height.abs(),
is_rectangular: matches!(
shape,
LuminousShape::Rectangular | LuminousShape::RectangularWithSides
),
is_circular: matches!(
shape,
LuminousShape::Circular | LuminousShape::Sphere | LuminousShape::VerticalCylinder
),
photometric_type: match ies.photometric_type {
PhotometricType::TypeC => "C".to_string(),
PhotometricType::TypeB => "B".to_string(),
PhotometricType::TypeA => "A".to_string(),
},
unit_type: match ies.unit_type {
UnitType::Feet => "Feet".to_string(),
UnitType::Meters => "Meters".to_string(),
},
has_tilt_data: ies.tilt_data.is_some(),
tilt_lamp_geometry: ies.tilt_data.as_ref().map_or(0, |t| t.lamp_geometry),
tilt_angle_count: ies.tilt_data.as_ref().map_or(0, |t| t.angles.len()),
maintenance_category: ies.maintenance_category,
ballast_factor: ies.ballast_factor,
input_watts: ies.input_watts,
num_lamps: ies.num_lamps,
lumens_per_lamp: ies.lumens_per_lamp,
is_absolute_photometry: ies.lumens_per_lamp < 0.0,
}
}
pub fn to_gldf_properties(&self) -> Vec<(&'static str, String)> {
let mut props = vec![];
if !self.version.is_empty() {
props.push(("ies_version", self.version.clone()));
}
if !self.test_report.is_empty() {
props.push(("test_report", self.test_report.clone()));
}
if !self.test_lab.is_empty() {
props.push(("test_lab", self.test_lab.clone()));
}
if !self.issue_date.is_empty() {
props.push(("issue_date", self.issue_date.clone()));
}
if !self.manufacturer.is_empty() {
props.push(("manufacturer", self.manufacturer.clone()));
}
if !self.luminaire_catalog.is_empty() {
props.push(("luminaire_catalog", self.luminaire_catalog.clone()));
}
props.push(("file_generation_type", self.file_generation_type.clone()));
props.push(("is_accredited", self.is_accredited.to_string()));
props.push(("is_scaled", self.is_scaled.to_string()));
props.push(("is_interpolated", self.is_interpolated.to_string()));
props.push(("is_simulation", self.is_simulation.to_string()));
props.push(("luminous_shape", self.luminous_shape.clone()));
if self.luminous_width > 0.0 {
props.push(("luminous_width_m", format!("{:.4}", self.luminous_width)));
}
if self.luminous_length > 0.0 {
props.push(("luminous_length_m", format!("{:.4}", self.luminous_length)));
}
if self.luminous_height > 0.0 {
props.push(("luminous_height_m", format!("{:.4}", self.luminous_height)));
}
props.push(("photometric_type", self.photometric_type.clone()));
if self.has_tilt_data {
props.push(("has_tilt_data", "true".to_string()));
props.push(("tilt_lamp_geometry", self.tilt_lamp_geometry.to_string()));
props.push(("tilt_angle_count", self.tilt_angle_count.to_string()));
}
if let Some(cat) = self.maintenance_category {
props.push(("maintenance_category", cat.to_string()));
}
if self.ballast_factor != 1.0 {
props.push(("ballast_factor", format!("{:.3}", self.ballast_factor)));
}
props.push(("input_watts", format!("{:.1}", self.input_watts)));
props.push(("num_lamps", self.num_lamps.to_string()));
if self.is_absolute_photometry {
props.push(("absolute_photometry", "true".to_string()));
} else {
props.push(("lumens_per_lamp", format!("{:.1}", self.lumens_per_lamp)));
}
props
}
pub fn to_gldf_emitter_geometry(&self) -> (&'static str, i32, i32, i32) {
let width_mm = (self.luminous_width * 1000.0).round() as i32;
let length_mm = (self.luminous_length * 1000.0).round() as i32;
let diameter_mm = width_mm.max(length_mm);
if self.is_circular {
("circular", 0, 0, diameter_mm)
} else if self.is_rectangular {
("rectangular", width_mm, length_mm, 0)
} else {
("point", 0, 0, 0)
}
}
}
impl GldfPhotometricData {
pub fn from_eulumdat(ldt: &Eulumdat) -> Self {
let cie_codes = PhotometricCalculations::cie_flux_codes(ldt);
let bug = crate::bug_rating::BugRating::from_eulumdat(ldt);
let beam_c0 = PhotometricCalculations::beam_angle_for_plane(ldt, 0.0);
let beam_c90 = PhotometricCalculations::beam_angle_for_plane(ldt, 90.0);
let field_c0 = PhotometricCalculations::field_angle_for_plane(ldt, 0.0);
let field_c90 = PhotometricCalculations::field_angle_for_plane(ldt, 90.0);
let luminance = PhotometricCalculations::luminaire_luminance(ldt, 65.0);
let cut_off = PhotometricCalculations::cut_off_angle(ldt);
let ugr_values = PhotometricCalculations::ugr_table_values(ldt);
let photo_code = PhotometricCalculations::photometric_code(ldt);
Self {
cie_flux_code: cie_codes.to_string(),
light_output_ratio: ldt.light_output_ratio,
luminous_efficacy: PhotometricCalculations::luminaire_efficacy(ldt),
downward_flux_fraction: ldt.downward_flux_fraction,
downward_light_output_ratio: cie_codes.n1 * ldt.light_output_ratio / 100.0,
upward_light_output_ratio: cie_codes.n4 * ldt.light_output_ratio / 100.0,
luminaire_luminance: luminance,
cut_off_angle: cut_off,
ugr_4h_8h_705020: ugr_values,
photometric_code: photo_code,
tenth_peak_divergence: (field_c0, field_c90),
half_peak_divergence: (beam_c0, beam_c90),
light_distribution_bug_rating: format!("{}", bug),
}
}
pub fn to_gldf_properties(&self) -> Vec<(&'static str, String)> {
let mut props = vec![
("cie_flux_code", self.cie_flux_code.clone()),
(
"light_output_ratio",
format!("{:.1}", self.light_output_ratio),
),
(
"luminous_efficacy",
format!("{:.1}", self.luminous_efficacy),
),
(
"downward_flux_fraction",
format!("{:.1}", self.downward_flux_fraction),
),
(
"downward_light_output_ratio",
format!("{:.1}", self.downward_light_output_ratio),
),
(
"upward_light_output_ratio",
format!("{:.1}", self.upward_light_output_ratio),
),
(
"luminaire_luminance",
format!("{:.0}", self.luminaire_luminance),
),
("cut_off_angle", format!("{:.1}", self.cut_off_angle)),
("photometric_code", self.photometric_code.clone()),
(
"tenth_peak_divergence",
format!(
"{:.1} / {:.1}",
self.tenth_peak_divergence.0, self.tenth_peak_divergence.1
),
),
(
"half_peak_divergence",
format!(
"{:.1} / {:.1}",
self.half_peak_divergence.0, self.half_peak_divergence.1
),
),
(
"light_distribution_bug_rating",
self.light_distribution_bug_rating.clone(),
),
];
if let Some(ref ugr) = self.ugr_4h_8h_705020 {
props.push((
"ugr_4h_8h_705020_crosswise",
format!("{:.1}", ugr.crosswise),
));
props.push(("ugr_4h_8h_705020_endwise", format!("{:.1}", ugr.endwise)));
}
props
}
pub fn to_text(&self) -> String {
let mut s = String::from("GLDF PHOTOMETRIC DATA\n");
s.push_str("=====================\n\n");
s.push_str(&format!(
"CIE Flux Code: {}\n",
self.cie_flux_code
));
s.push_str(&format!(
"Light Output Ratio: {:.1}%\n",
self.light_output_ratio
));
s.push_str(&format!(
"Luminous Efficacy: {:.1} lm/W\n",
self.luminous_efficacy
));
s.push_str(&format!(
"Downward Flux Fraction: {:.1}%\n",
self.downward_flux_fraction
));
s.push_str(&format!(
"DLOR: {:.1}%\n",
self.downward_light_output_ratio
));
s.push_str(&format!(
"ULOR: {:.1}%\n",
self.upward_light_output_ratio
));
s.push_str(&format!(
"Luminaire Luminance: {:.0} cd/m²\n",
self.luminaire_luminance
));
s.push_str(&format!(
"Cut-off Angle: {:.1}°\n",
self.cut_off_angle
));
if let Some(ref ugr) = self.ugr_4h_8h_705020 {
s.push_str(&format!(
"UGR (4H×8H, 70/50/20): C: {:.1} / E: {:.1}\n",
ugr.crosswise, ugr.endwise
));
}
s.push_str(&format!(
"Photometric Code: {}\n",
self.photometric_code
));
s.push_str(&format!(
"Half Peak Divergence: {:.1}° / {:.1}° (C0/C90)\n",
self.half_peak_divergence.0, self.half_peak_divergence.1
));
s.push_str(&format!(
"Tenth Peak Divergence: {:.1}° / {:.1}° (C0/C90)\n",
self.tenth_peak_divergence.0, self.tenth_peak_divergence.1
));
s.push_str(&format!(
"BUG Rating: {}\n",
self.light_distribution_bug_rating
));
s
}
}
impl std::fmt::Display for GldfPhotometricData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_text())
}
}
impl PhotometricCalculations {
pub fn beam_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
Self::angle_at_percentage_for_plane(ldt, c_plane, 0.5) * 2.0
}
pub fn field_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
Self::angle_at_percentage_for_plane(ldt, c_plane, 0.1) * 2.0
}
pub fn half_beam_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
Self::angle_at_percentage_for_plane(ldt, c_plane, 0.5)
}
pub fn half_field_angle_for_plane(ldt: &Eulumdat, c_plane: f64) -> f64 {
Self::angle_at_percentage_for_plane(ldt, c_plane, 0.1)
}
fn angle_at_percentage_for_plane(ldt: &Eulumdat, c_plane: f64, percentage: f64) -> f64 {
if ldt.g_angles.is_empty() {
return 0.0;
}
let i_nadir = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, 0.0);
if i_nadir <= 0.0 {
return 0.0;
}
let threshold = i_nadir * percentage;
for g in 1..90 {
let intensity =
crate::symmetry::SymmetryHandler::get_intensity_at(ldt, c_plane, g as f64);
if intensity < threshold {
let prev = crate::symmetry::SymmetryHandler::get_intensity_at(
ldt,
c_plane,
(g - 1) as f64,
);
if prev > threshold && prev > intensity {
let ratio = (prev - threshold) / (prev - intensity);
return (g - 1) as f64 + ratio;
}
return g as f64;
}
}
90.0
}
pub fn luminaire_luminance(ldt: &Eulumdat, viewing_angle: f64) -> f64 {
let la_length = ldt.luminous_area_length / 1000.0;
let la_width = ldt.luminous_area_width / 1000.0;
if la_length <= 0.0 || la_width <= 0.0 {
return 0.0;
}
let area = la_length * la_width;
let angle_rad = viewing_angle.to_radians();
let projected_area = area * angle_rad.cos();
if projected_area <= 0.001 {
return 0.0;
}
let i_c0 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, viewing_angle);
let i_c90 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 90.0, viewing_angle);
let avg_intensity = (i_c0 + i_c90) / 2.0;
let total_flux = ldt.total_luminous_flux();
let actual_intensity = avg_intensity * total_flux / 1000.0;
actual_intensity / projected_area
}
pub fn cut_off_angle(ldt: &Eulumdat) -> f64 {
let max_intensity = ldt.max_intensity();
if max_intensity <= 0.0 {
return 90.0;
}
let threshold = max_intensity * 0.025;
for g in (0..=90).rev() {
let i_c0 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, g as f64);
let i_c90 = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 90.0, g as f64);
if i_c0 > threshold || i_c90 > threshold {
return g as f64;
}
}
0.0
}
pub fn ugr_table_values(ldt: &Eulumdat) -> Option<UgrTableValues> {
let luminaire_area = (ldt.length / 1000.0) * (ldt.width / 1000.0);
if luminaire_area <= 0.0 {
return None;
}
let h = 2.5;
let room_width = 4.0 * h; let room_length = 8.0 * h;
let rho_c = 0.70;
let rho_w = 0.50;
let rho_f = 0.20;
let params_cross = UgrParams {
room_length,
room_width,
mounting_height: 2.8,
eye_height: 1.2,
observer_x: room_length / 2.0,
observer_y: 1.5, luminaire_positions: vec![
(room_length / 4.0, room_width / 2.0),
(room_length / 2.0, room_width / 2.0),
(3.0 * room_length / 4.0, room_width / 2.0),
],
rho_ceiling: rho_c,
rho_wall: rho_w,
rho_floor: rho_f,
illuminance: 500.0,
};
let params_end = UgrParams {
room_length,
room_width,
mounting_height: 2.8,
eye_height: 1.2,
observer_x: 1.5, observer_y: room_width / 2.0,
luminaire_positions: vec![
(room_length / 4.0, room_width / 2.0),
(room_length / 2.0, room_width / 2.0),
(3.0 * room_length / 4.0, room_width / 2.0),
],
rho_ceiling: rho_c,
rho_wall: rho_w,
rho_floor: rho_f,
illuminance: 500.0,
};
let ugr_cross = Self::ugr(ldt, ¶ms_cross);
let ugr_end = Self::ugr(ldt, ¶ms_end);
if ugr_cross > 0.0 || ugr_end > 0.0 {
Some(UgrTableValues {
crosswise: ugr_cross.max(0.0),
endwise: ugr_end.max(0.0),
})
} else {
None
}
}
pub fn photometric_code(ldt: &Eulumdat) -> String {
let dlor = ldt.downward_flux_fraction;
let dist_type = if dlor >= 90.0 {
"D" } else if dlor >= 60.0 {
"SD" } else if dlor >= 40.0 {
"GD" } else if dlor >= 10.0 {
"SI" } else {
"I" };
let beam_angle = Self::beam_angle(ldt);
let beam_class = if beam_angle < 40.0 {
"VN" } else if beam_angle < 60.0 {
"N" } else if beam_angle < 90.0 {
"M" } else if beam_angle < 120.0 {
"W" } else {
"VW" };
format!("{}-{}", dist_type, beam_class)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BeamFieldAnalysis {
pub beam_angle_ies: f64,
pub field_angle_ies: f64,
pub beam_angle_cie: f64,
pub field_angle_cie: f64,
pub max_intensity: f64,
pub center_intensity: f64,
pub max_intensity_gamma: f64,
pub is_batwing: bool,
pub beam_threshold_ies: f64,
pub beam_threshold_cie: f64,
pub field_threshold_ies: f64,
pub field_threshold_cie: f64,
}
impl BeamFieldAnalysis {
pub fn beam_angle_difference(&self) -> f64 {
self.beam_angle_cie - self.beam_angle_ies
}
pub fn field_angle_difference(&self) -> f64 {
self.field_angle_cie - self.field_angle_ies
}
pub fn center_to_max_ratio(&self) -> f64 {
if self.max_intensity > 0.0 {
self.center_intensity / self.max_intensity
} else {
0.0
}
}
pub fn distribution_type(&self) -> &'static str {
let ratio = self.center_to_max_ratio();
if ratio >= 0.95 {
"Standard (center-peak)"
} else if ratio >= 0.7 {
"Mild batwing"
} else if ratio >= 0.4 {
"Moderate batwing"
} else {
"Strong batwing"
}
}
pub fn to_string_detailed(&self) -> String {
format!(
"Beam: IES {:.1}° / CIE {:.1}° (Δ{:+.1}°)\n\
Field: IES {:.1}° / CIE {:.1}° (Δ{:+.1}°)\n\
Center/Max: {:.1}% ({})",
self.beam_angle_ies,
self.beam_angle_cie,
self.beam_angle_difference(),
self.field_angle_ies,
self.field_angle_cie,
self.field_angle_difference(),
self.center_to_max_ratio() * 100.0,
self.distribution_type()
)
}
}
impl std::fmt::Display for BeamFieldAnalysis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Beam: {:.1}° (IES) / {:.1}° (CIE), Field: {:.1}° (IES) / {:.1}° (CIE)",
self.beam_angle_ies, self.beam_angle_cie, self.field_angle_ies, self.field_angle_cie
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LightDirection {
#[default]
Downward,
Upward,
}
impl std::fmt::Display for LightDirection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LightDirection::Downward => write!(f, "Downward"),
LightDirection::Upward => write!(f, "Upward"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DistributionType {
#[default]
Direct,
Indirect,
DirectIndirect,
IndirectDirect,
}
impl std::fmt::Display for DistributionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DistributionType::Direct => write!(f, "Direct"),
DistributionType::Indirect => write!(f, "Indirect"),
DistributionType::DirectIndirect => write!(f, "Direct-Indirect"),
DistributionType::IndirectDirect => write!(f, "Indirect-Direct"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ComprehensiveBeamAnalysis {
pub downward_beam_angle: f64,
pub downward_field_angle: f64,
pub downward_peak_intensity: f64,
pub downward_peak_gamma: f64,
pub upward_beam_angle: f64,
pub upward_field_angle: f64,
pub upward_peak_intensity: f64,
pub upward_peak_gamma: f64,
pub primary_direction: LightDirection,
pub distribution_type: DistributionType,
}
impl ComprehensiveBeamAnalysis {
pub fn has_upward_component(&self) -> bool {
self.upward_peak_intensity > 0.0 && self.upward_beam_angle > 0.0
}
pub fn has_downward_component(&self) -> bool {
self.downward_peak_intensity > 0.0 && self.downward_beam_angle > 0.0
}
pub fn upward_to_downward_ratio(&self) -> f64 {
if self.downward_peak_intensity > 0.0 {
self.upward_peak_intensity / self.downward_peak_intensity
} else if self.upward_peak_intensity > 0.0 {
f64::INFINITY
} else {
0.0
}
}
pub fn primary_beam_angle(&self) -> f64 {
match self.primary_direction {
LightDirection::Downward => self.downward_beam_angle,
LightDirection::Upward => self.upward_beam_angle,
}
}
pub fn primary_field_angle(&self) -> f64 {
match self.primary_direction {
LightDirection::Downward => self.downward_field_angle,
LightDirection::Upward => self.upward_field_angle,
}
}
}
impl std::fmt::Display for ComprehensiveBeamAnalysis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({:.1}°/{:.1}°)",
self.distribution_type,
self.primary_beam_angle(),
self.primary_field_angle()
)?;
if self.has_downward_component() && self.has_upward_component() {
write!(
f,
" [Down: {:.1}°, Up: {:.1}°]",
self.downward_beam_angle, self.upward_beam_angle
)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CieFluxCodes {
pub n1: f64,
pub n2: f64,
pub n3: f64,
pub n4: f64,
pub n5: f64,
}
impl std::fmt::Display for CieFluxCodes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{:.0} {:.0} {:.0} {:.0} {:.0}",
self.n1.round(),
self.n2.round(),
self.n3.round(),
self.n4.round(),
self.n5.round()
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ZonalLumens30 {
pub zone_0_30: f64,
pub zone_30_60: f64,
pub zone_60_90: f64,
pub zone_90_120: f64,
pub zone_120_150: f64,
pub zone_150_180: f64,
}
impl ZonalLumens30 {
pub fn downward_total(&self) -> f64 {
self.zone_0_30 + self.zone_30_60 + self.zone_60_90
}
pub fn upward_total(&self) -> f64 {
self.zone_90_120 + self.zone_120_150 + self.zone_150_180
}
}
#[derive(Debug, Clone)]
pub struct UgrParams {
pub room_length: f64,
pub room_width: f64,
pub mounting_height: f64,
pub eye_height: f64,
pub observer_x: f64,
pub observer_y: f64,
pub luminaire_positions: Vec<(f64, f64)>,
pub rho_ceiling: f64,
pub rho_wall: f64,
pub rho_floor: f64,
pub illuminance: f64,
}
impl Default for UgrParams {
fn default() -> Self {
Self {
room_length: 8.0,
room_width: 4.0,
mounting_height: 2.8,
eye_height: 1.2,
observer_x: 4.0,
observer_y: 2.0,
luminaire_positions: vec![(2.0, 2.0), (6.0, 2.0)],
rho_ceiling: 0.7,
rho_wall: 0.5,
rho_floor: 0.2,
illuminance: 500.0,
}
}
}
impl UgrParams {
pub fn background_luminance(&self) -> f64 {
let avg_rho = (self.rho_ceiling + self.rho_wall + self.rho_floor) / 3.0;
self.illuminance * avg_rho / PI
}
pub fn standard_office() -> Self {
Self {
room_length: 6.0,
room_width: 4.0,
mounting_height: 2.8,
eye_height: 1.2,
observer_x: 3.0,
observer_y: 2.0,
luminaire_positions: vec![(2.0, 2.0), (4.0, 2.0)],
rho_ceiling: 0.7,
rho_wall: 0.5,
rho_floor: 0.2,
illuminance: 500.0,
}
}
}
pub const CU_REFLECTANCES: [(u8, u8, u8); 18] = [
(80, 70, 20),
(80, 50, 20),
(80, 30, 20),
(80, 10, 20),
(70, 70, 20),
(70, 50, 20),
(70, 30, 20),
(70, 10, 20),
(50, 50, 20),
(50, 30, 20),
(50, 10, 20),
(30, 50, 20),
(30, 30, 20),
(30, 10, 20),
(10, 50, 20),
(10, 30, 20),
(10, 10, 20),
(0, 0, 20),
];
pub const CU_RCR_VALUES: [u8; 11] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
#[derive(Debug, Clone)]
pub struct CuTable {
pub floor_reflectance: f64,
pub values: Vec<Vec<f64>>,
pub reflectances: Vec<(u8, u8, u8)>,
pub rcr_values: Vec<u8>,
}
impl Default for CuTable {
fn default() -> Self {
Self {
floor_reflectance: 0.20,
values: Vec::new(),
reflectances: CU_REFLECTANCES.to_vec(),
rcr_values: CU_RCR_VALUES.to_vec(),
}
}
}
#[allow(dead_code)]
impl CuTable {
pub fn calculate(ldt: &Eulumdat) -> Self {
let mut table = Self::default();
for &rcr in &CU_RCR_VALUES {
let mut row = Vec::new();
for &(rc, rw, rf) in &CU_REFLECTANCES {
let cu = Self::calculate_cu(
ldt,
rcr as f64,
rc as f64 / 100.0,
rw as f64 / 100.0,
rf as f64 / 100.0,
);
row.push(cu);
}
table.values.push(row);
}
table
}
fn calculate_cu(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
Self::calculate_cu_ies(ldt, rcr, rho_c, rho_w, rho_f)
}
fn calculate_cu_ies(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
let downward_fraction = PhotometricCalculations::downward_flux(ldt, 90.0) / 100.0;
let upward_fraction = 1.0 - downward_fraction;
let direct_ratio = Self::calculate_direct_ratio_ies(ldt, rcr);
let cu_base = direct_ratio * downward_fraction;
let wall_light = downward_fraction * (1.0 - direct_ratio);
let ceiling_view_factor = 1.0 / (1.0 + rcr * 0.18);
let light_to_ceiling = upward_fraction + rho_f * downward_fraction * 0.5;
let ceiling_efficiency = 0.25;
let cu_ceiling = light_to_ceiling * rho_c * ceiling_efficiency * ceiling_view_factor;
let wall_view_factor = 1.0 - ceiling_view_factor;
let cu_walls = (wall_light + upward_fraction * wall_view_factor) * rho_w * 0.35;
let rho_avg =
rho_c * ceiling_view_factor * 0.4 + rho_f * 0.4 + rho_w * wall_view_factor * 0.2;
let first_order = cu_base + cu_ceiling + cu_walls;
let floor_capture = 0.35 / (1.0 + rcr * 0.1);
let cu_multi = if rho_avg < 0.9 {
first_order * rho_avg / (1.0 - rho_avg) * floor_capture
} else {
first_order * 3.0 * floor_capture
};
let cu_total = (first_order + cu_multi) * 100.0;
cu_total.clamp(0.0, 150.0)
}
fn calculate_direct_ratio_ies(ldt: &Eulumdat, rcr: f64) -> f64 {
let cutoff_angle = if rcr > 0.1 {
(5.0 / rcr).atan().to_degrees()
} else {
89.0 };
let flux_direct = PhotometricCalculations::downward_flux(ldt, cutoff_angle.min(90.0));
let flux_total = PhotometricCalculations::downward_flux(ldt, 90.0);
if flux_total > 0.0 {
flux_direct / flux_total
} else {
1.0
}
}
fn effective_room_reflectance(rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
let wall_weight = (rcr / 5.0).min(1.0);
let floor_ceiling_weight = 1.0 - wall_weight;
rho_w * wall_weight + (rho_c + rho_f) / 2.0 * floor_ceiling_weight
}
fn transfer_factor(rcr: f64, rho_ceiling: f64) -> f64 {
let geometric_factor = 1.0 / (1.0 + rcr * 0.15);
geometric_factor * (1.0 + rho_ceiling * 0.3)
}
fn wall_contribution_factor(rcr: f64, rho_wall: f64) -> f64 {
let wall_area_factor = (rcr / 10.0).min(0.8);
wall_area_factor * rho_wall
}
#[allow(dead_code)]
fn calculate_cu_simple(ldt: &Eulumdat, rcr: f64, rho_c: f64, rho_w: f64, rho_f: f64) -> f64 {
let direct = Self::direct_component_simple(ldt, rcr);
let reflected = Self::reflected_component_simple(rcr, rho_c, rho_w, rho_f);
(direct + reflected) * 100.0
}
fn direct_component_simple(ldt: &Eulumdat, rcr: f64) -> f64 {
let ratios = PhotometricCalculations::calculate_direct_ratios(ldt, "1.00");
let room_indices = [0.60, 0.80, 1.00, 1.25, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00];
let k = if rcr > 0.0 {
5.0 / (rcr + 0.1)
} else {
10.0 };
let mut i = 0;
while i < 9 && room_indices[i + 1] < k {
i += 1;
}
if k <= room_indices[0] {
return ratios[0];
}
if k >= room_indices[9] {
return ratios[9];
}
let t = (k - room_indices[i]) / (room_indices[i + 1] - room_indices[i]);
ratios[i] * (1.0 - t) + ratios[i + 1] * t
}
fn reflected_component_simple(rcr: f64, rho_c: f64, rho_w: f64, _rho_f: f64) -> f64 {
let cavity_factor = if rcr > 0.0 {
1.0 / (1.0 + rcr * 0.1)
} else {
1.0
};
let avg_rho = (rho_c + rho_w * 0.5) * 0.5;
avg_rho * cavity_factor * 0.2 }
pub fn calculate_sophisticated(ldt: &Eulumdat) -> Self {
Self::calculate(ldt) }
pub fn calculate_simple(ldt: &Eulumdat) -> Self {
let mut table = Self::default();
for &rcr in &CU_RCR_VALUES {
let mut row = Vec::new();
for &(rc, rw, rf) in &CU_REFLECTANCES {
let cu = Self::calculate_cu_simple(
ldt,
rcr as f64,
rc as f64 / 100.0,
rw as f64 / 100.0,
rf as f64 / 100.0,
);
row.push(cu);
}
table.values.push(row);
}
table
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("COEFFICIENTS OF UTILIZATION - ZONAL CAVITY METHOD\n");
s.push_str(&format!(
"Effective Floor Cavity Reflectance {:.2}\n\n",
self.floor_reflectance
));
s.push_str("RC 80 70 50 30 10 0\n");
s.push_str("RW 70 50 30 10 70 50 30 10 50 30 10 50 30 10 50 30 10 0\n\n");
for (i, &rcr) in self.rcr_values.iter().enumerate() {
s.push_str(&format!("{:2} ", rcr));
for j in 0..4 {
s.push_str(&format!("{:3.0}", self.values[i][j]));
}
s.push(' ');
for j in 4..8 {
s.push_str(&format!("{:3.0}", self.values[i][j]));
}
s.push(' ');
for j in 8..11 {
s.push_str(&format!("{:3.0}", self.values[i][j]));
}
s.push(' ');
for j in 11..14 {
s.push_str(&format!("{:3.0}", self.values[i][j]));
}
s.push(' ');
for j in 14..17 {
s.push_str(&format!("{:3.0}", self.values[i][j]));
}
s.push(' ');
s.push_str(&format!("{:3.0}", self.values[i][17]));
s.push('\n');
}
s
}
}
pub const UGR_ROOM_SIZES: [(f64, f64); 19] = [
(2.0, 2.0),
(2.0, 3.0),
(2.0, 4.0),
(2.0, 6.0),
(2.0, 8.0),
(2.0, 12.0),
(4.0, 2.0),
(4.0, 3.0),
(4.0, 4.0),
(4.0, 6.0),
(4.0, 8.0),
(4.0, 12.0),
(8.0, 4.0),
(8.0, 6.0),
(8.0, 8.0),
(8.0, 12.0),
(12.0, 4.0),
(12.0, 6.0),
(12.0, 8.0),
];
pub const UGR_REFLECTANCES: [(u8, u8, u8); 5] = [
(70, 50, 20),
(70, 30, 20),
(50, 50, 20),
(50, 30, 20),
(30, 30, 20),
];
#[derive(Debug, Clone)]
pub struct UgrTable {
pub crosswise: Vec<Vec<f64>>,
pub endwise: Vec<Vec<f64>>,
pub room_sizes: Vec<(f64, f64)>,
pub reflectances: Vec<(u8, u8, u8)>,
pub max_ugr: f64,
}
impl Default for UgrTable {
fn default() -> Self {
Self {
crosswise: Vec::new(),
endwise: Vec::new(),
room_sizes: UGR_ROOM_SIZES.to_vec(),
reflectances: UGR_REFLECTANCES.to_vec(),
max_ugr: 0.0,
}
}
}
impl UgrTable {
pub fn calculate(ldt: &Eulumdat) -> Self {
let mut table = Self::default();
let mut max_ugr = 0.0_f64;
let luminous_area = (ldt.luminous_area_length * ldt.luminous_area_width).max(1.0) / 1e6; let total_flux = ldt.total_luminous_flux().max(1.0);
for &(x, y) in &UGR_ROOM_SIZES {
let mut crosswise_row = Vec::new();
let mut endwise_row = Vec::new();
for &(rc, rw, rf) in &UGR_REFLECTANCES {
let ugr_cross = Self::calculate_ugr_for_room(
ldt,
x,
y,
rc as f64 / 100.0,
rw as f64 / 100.0,
rf as f64 / 100.0,
luminous_area,
total_flux,
false, );
crosswise_row.push(ugr_cross);
max_ugr = max_ugr.max(ugr_cross);
let ugr_end = Self::calculate_ugr_for_room(
ldt,
x,
y,
rc as f64 / 100.0,
rw as f64 / 100.0,
rf as f64 / 100.0,
luminous_area,
total_flux,
true, );
endwise_row.push(ugr_end);
max_ugr = max_ugr.max(ugr_end);
}
table.crosswise.push(crosswise_row);
table.endwise.push(endwise_row);
}
table.max_ugr = max_ugr;
table
}
#[allow(clippy::too_many_arguments)]
fn calculate_ugr_for_room(
ldt: &Eulumdat,
x_h: f64,
y_h: f64,
rho_c: f64,
rho_w: f64,
_rho_f: f64,
luminous_area: f64,
total_flux: f64,
_endwise: bool,
) -> f64 {
let angles = [45.0, 55.0, 65.0, 75.0, 85.0];
let mut l_avg = 0.0;
let mut weight_sum = 0.0;
for &angle in &angles {
let intensity_cdklm =
crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, angle);
let intensity_cd = intensity_cdklm * total_flux / 1000.0;
let proj_area = luminous_area * angle.to_radians().cos().max(0.01);
let luminance = intensity_cd / proj_area;
let weight = angle.to_radians().sin();
l_avg += luminance * weight;
weight_sum += weight;
}
l_avg /= weight_sum.max(0.001);
let room_area = x_h * y_h;
let rcr = 5.0 * (x_h + y_h) / room_area;
let spacing = 1.5;
let n_lum = ((x_h / spacing).ceil() * (y_h / spacing).ceil()).max(1.0);
let cu = 0.8 / (1.0 + rcr * 0.1);
let illuminance = n_lum * total_flux * cu / room_area;
let lb = illuminance * (rho_c * 0.4 + rho_w * 0.6) / PI;
let lb = lb.max(20.0);
let view_angle = 65.0_f64.to_radians();
let h_ratio = view_angle.tan();
let proj_area = luminous_area * view_angle.cos();
let distance_sq = 1.0 + h_ratio * h_ratio; let omega = proj_area / distance_sq;
let room_index = (x_h * y_h).sqrt();
let p = (1.2 + room_index * 0.5).clamp(1.5, 12.0);
let glare_term = l_avg * l_avg * omega / (p * p);
let ugr_raw = 8.0 * (0.25 * glare_term / lb).log10();
let n_visible = (n_lum * 0.7).max(1.0); let ugr_multi = ugr_raw + 8.0 * n_visible.log10();
let rho_avg = (rho_c + rho_w) / 2.0;
let rho_correction = 8.0 * (0.50 / rho_avg.max(0.1)).log10();
let ugr_corrected = ugr_multi + rho_correction * 0.35;
let ugr_calibrated = ugr_corrected + 3.0;
((ugr_calibrated * 10.0).round() / 10.0).clamp(10.0, 34.0)
}
#[allow(dead_code, clippy::too_many_arguments)]
fn calculate_ugr_simple(
ldt: &Eulumdat,
x_h: f64,
y_h: f64,
rho_c: f64,
rho_w: f64,
_rho_f: f64,
luminous_area: f64,
total_flux: f64,
_endwise: bool,
) -> f64 {
let room_area = x_h * y_h;
let illuminance = total_flux * 0.5 / room_area; let lb = (illuminance * (rho_c + rho_w) * 0.5 / PI).max(10.0);
let intensity_cdklm = crate::symmetry::SymmetryHandler::get_intensity_at(ldt, 0.0, 65.0);
let intensity_cd = intensity_cdklm * total_flux / 1000.0;
let luminance = intensity_cd / (luminous_area * 0.42);
let omega = luminous_area / 5.0; let p = 2.0;
let ugr = 8.0 * (0.25 * luminance * luminance * omega / (p * p * lb)).log10();
ugr.clamp(10.0, 34.0)
}
pub fn calculate_sophisticated(ldt: &Eulumdat) -> Self {
Self::calculate(ldt) }
pub fn calculate_simple(ldt: &Eulumdat) -> Self {
let mut table = UgrTable {
crosswise: Vec::new(),
endwise: Vec::new(),
room_sizes: UGR_ROOM_SIZES.to_vec(),
reflectances: UGR_REFLECTANCES.to_vec(),
max_ugr: 0.0,
};
let luminous_area = if ldt.luminous_area_length > 0.0 && ldt.luminous_area_width > 0.0 {
ldt.luminous_area_length * ldt.luminous_area_width / 1_000_000.0 } else {
0.09 };
let total_flux = if !ldt.lamp_sets.is_empty() {
ldt.lamp_sets.iter().map(|l| l.total_luminous_flux).sum()
} else {
1000.0
};
let mut max_ugr = 0.0_f64;
for &(x, y) in &UGR_ROOM_SIZES {
let mut crosswise_row = Vec::new();
let mut endwise_row = Vec::new();
for &(rc, rw, rf) in &UGR_REFLECTANCES {
let ugr_cross = Self::calculate_ugr_simple(
ldt,
x,
y,
rc as f64 / 100.0,
rw as f64 / 100.0,
rf as f64 / 100.0,
luminous_area,
total_flux,
false,
);
crosswise_row.push(ugr_cross);
max_ugr = max_ugr.max(ugr_cross);
let ugr_end = Self::calculate_ugr_simple(
ldt,
x,
y,
rc as f64 / 100.0,
rw as f64 / 100.0,
rf as f64 / 100.0,
luminous_area,
total_flux,
true,
);
endwise_row.push(ugr_end);
max_ugr = max_ugr.max(ugr_end);
}
table.crosswise.push(crosswise_row);
table.endwise.push(endwise_row);
}
table.max_ugr = max_ugr;
table
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("UGR TABLE - CORRECTED\n\n");
s.push_str("Reflectances\n");
s.push_str("Ceiling Cavity 70 70 50 50 30 70 70 50 50 30\n");
s.push_str("Walls 50 30 50 30 30 50 30 50 30 30\n");
s.push_str("Floor Cavity 20 20 20 20 20 20 20 20 20 20\n\n");
s.push_str("Room Size UGR Viewed Crosswise UGR Viewed Endwise\n");
for (i, &(x, y)) in self.room_sizes.iter().enumerate() {
let x_str = if x == x.floor() {
format!("{}H", x as i32)
} else {
format!("{:.1}H", x)
};
let y_str = if y == y.floor() {
format!("{}H", y as i32)
} else {
format!("{:.1}H", y)
};
s.push_str(&format!("X={:<3} Y={:<3} ", x_str, y_str));
for j in 0..5 {
s.push_str(&format!("{:5.1}", self.crosswise[i][j]));
}
s.push_str(" ");
for j in 0..5 {
s.push_str(&format!("{:5.1}", self.endwise[i][j]));
}
s.push('\n');
}
s.push_str(&format!("\nMaximum UGR = {:.1}\n", self.max_ugr));
s
}
}
#[derive(Debug, Clone, Default)]
pub struct CandelaEntry {
pub c_plane: f64,
pub gamma: f64,
pub candela: f64,
}
#[derive(Debug, Clone, Default)]
pub struct CandelaTabulation {
pub entries: Vec<CandelaEntry>,
pub c_planes: Vec<f64>,
pub g_angles: Vec<f64>,
pub max_candela: f64,
pub max_angle: (f64, f64),
pub total_flux: f64,
}
impl CandelaTabulation {
pub fn from_eulumdat(ldt: &Eulumdat) -> Self {
let total_flux = ldt.total_luminous_flux().max(1.0);
let cd_factor = total_flux / 1000.0;
let mut entries = Vec::new();
let mut max_candela = 0.0_f64;
let mut max_angle = (0.0, 0.0);
let c_planes = ldt.c_angles.clone();
let g_angles = ldt.g_angles.clone();
for (c_idx, &c_plane) in ldt.c_angles.iter().enumerate() {
if c_idx >= ldt.intensities.len() {
continue;
}
for (g_idx, &gamma) in ldt.g_angles.iter().enumerate() {
let cdklm = ldt
.intensities
.get(c_idx)
.and_then(|row| row.get(g_idx))
.copied()
.unwrap_or(0.0);
let candela = cdklm * cd_factor;
entries.push(CandelaEntry {
c_plane,
gamma,
candela,
});
if candela > max_candela {
max_candela = candela;
max_angle = (c_plane, gamma);
}
}
}
Self {
entries,
c_planes,
g_angles,
max_candela,
max_angle,
total_flux,
}
}
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("CANDELA TABULATION\n\n");
if self.c_planes.len() == 1 {
s.push_str(&format!("{:>8}\n", self.c_planes[0] as i32));
for entry in &self.entries {
s.push_str(&format!("{:5.1} {:10.3}\n", entry.gamma, entry.candela));
}
} else {
s.push_str(" ");
for c in &self.c_planes {
s.push_str(&format!("{:>10}", *c as i32));
}
s.push('\n');
for &gamma in &self.g_angles {
s.push_str(&format!("{:5.1} ", gamma));
for c_idx in 0..self.c_planes.len() {
let candela = self
.entries
.iter()
.find(|e| {
(e.c_plane - self.c_planes[c_idx]).abs() < 0.01
&& (e.gamma - gamma).abs() < 0.01
})
.map(|e| e.candela)
.unwrap_or(0.0);
s.push_str(&format!("{:10.3}", candela));
}
s.push('\n');
}
}
s.push_str(&format!(
"\nMaximum Candela = {:.3} Located At Horizontal Angle = {}, Vertical Angle = {}\n",
self.max_candela, self.max_angle.0 as i32, self.max_angle.1 as i32
));
s
}
pub fn estimated_pages(&self, entries_per_page: usize) -> usize {
self.entries.len().div_ceil(entries_per_page)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NemaClassification {
pub horizontal_spread: f64,
pub vertical_spread: f64,
pub horizontal_type: u8,
pub vertical_type: u8,
pub i_max: f64,
pub designation: String,
}
impl std::fmt::Display for NemaClassification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.designation)
}
}
impl PhotometricCalculations {
pub fn nema_classification(ldt: &Eulumdat) -> NemaClassification {
let mut i_max: f64 = 0.0;
for h in (-90..=90).map(|i| i as f64) {
for v in (-90..=90).map(|i| i as f64) {
let intensity = TypeBConversion::intensity_at_type_b(ldt, h, v);
if intensity > i_max {
i_max = intensity;
}
}
}
if i_max <= 0.0 {
return NemaClassification {
horizontal_spread: 0.0,
vertical_spread: 0.0,
horizontal_type: 1,
vertical_type: 1,
i_max: 0.0,
designation: "NEMA 1H x 1V".to_string(),
};
}
let threshold = i_max * 0.1;
let step = 0.5_f64;
let horizontal_spread = Self::scan_spread(ldt, threshold, step, true);
let vertical_spread = Self::scan_spread(ldt, threshold, step, false);
let horizontal_type = Self::nema_type_from_spread(horizontal_spread);
let vertical_type = Self::nema_type_from_spread(vertical_spread);
let designation = format!("NEMA {}H x {}V", horizontal_type, vertical_type);
NemaClassification {
horizontal_spread,
vertical_spread,
horizontal_type,
vertical_type,
i_max,
designation,
}
}
fn scan_spread(ldt: &Eulumdat, threshold: f64, step: f64, horizontal: bool) -> f64 {
let mut min_angle = 0.0_f64;
let mut max_angle = 0.0_f64;
let mut angle = 0.0;
while angle <= 90.0 {
let intensity = if horizontal {
TypeBConversion::intensity_at_type_b(ldt, angle, 0.0)
} else {
TypeBConversion::intensity_at_type_b(ldt, 0.0, angle)
};
if intensity >= threshold {
max_angle = angle;
}
angle += step;
}
angle = 0.0;
while angle >= -90.0 {
let intensity = if horizontal {
TypeBConversion::intensity_at_type_b(ldt, angle, 0.0)
} else {
TypeBConversion::intensity_at_type_b(ldt, 0.0, angle)
};
if intensity >= threshold {
min_angle = angle;
}
angle -= step;
}
(max_angle - min_angle).abs()
}
fn nema_type_from_spread(spread: f64) -> u8 {
if spread < 18.0 {
1 } else if spread < 29.0 {
2
} else if spread < 46.0 {
3
} else if spread < 70.0 {
4
} else if spread < 100.0 {
5
} else if spread < 130.0 {
6
} else {
7
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eulumdat::LampSet;
fn create_test_ldt() -> Eulumdat {
let mut ldt = Eulumdat::new();
ldt.symmetry = Symmetry::VerticalAxis;
ldt.num_c_planes = 1;
ldt.num_g_planes = 7;
ldt.c_angles = vec![0.0];
ldt.g_angles = vec![0.0, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0];
ldt.intensities = vec![vec![1000.0, 980.0, 900.0, 750.0, 500.0, 200.0, 50.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
}
#[test]
fn test_total_output() {
let ldt = create_test_ldt();
let output = PhotometricCalculations::total_output(&ldt);
assert!(output > 0.0, "Total output should be positive");
}
#[test]
fn test_downward_flux() {
let ldt = create_test_ldt();
let flux_90 = PhotometricCalculations::downward_flux(&ldt, 90.0);
let flux_180 = PhotometricCalculations::downward_flux(&ldt, 180.0);
assert!(flux_90 <= flux_180 + 0.001);
assert!((0.0..=100.0).contains(&flux_90));
assert!((0.0..=100.0).contains(&flux_180));
}
#[test]
fn test_beam_angle() {
let ldt = create_test_ldt();
let beam = PhotometricCalculations::beam_angle(&ldt);
assert!(beam > 0.0 && beam <= 180.0, "Beam angle was {}", beam);
let half_beam = PhotometricCalculations::half_beam_angle(&ldt);
assert!(
(beam - half_beam * 2.0).abs() < 0.01,
"Half beam should be half of full beam"
);
}
#[test]
fn test_direct_ratios() {
let ldt = create_test_ldt();
let ratios = PhotometricCalculations::calculate_direct_ratios(&ldt, "1.00");
for ratio in &ratios {
assert!(*ratio >= 0.0 && *ratio <= 1.0);
}
for i in 1..10 {
assert!(ratios[i] >= ratios[0] - 0.1);
}
}
#[test]
fn test_cie_flux_codes() {
let ldt = create_test_ldt();
let codes = PhotometricCalculations::cie_flux_codes(&ldt);
assert!(
codes.n1 > 50.0,
"N1 (DLOR) should be > 50% for downlight, got {}",
codes.n1
);
assert!(
codes.n4 < 50.0,
"N4 (ULOR) should be < 50% for downlight, got {}",
codes.n4
);
assert!(codes.n3 <= codes.n2, "N3 should be <= N2");
assert!(codes.n2 <= codes.n1, "N2 should be <= N1");
let display = format!("{}", codes);
assert!(!display.is_empty());
}
#[test]
fn test_luminaire_efficacy() {
let mut ldt = create_test_ldt();
ldt.light_output_ratio = 80.0;
let lamp_efficacy = ldt.luminous_efficacy();
let luminaire_efficacy = PhotometricCalculations::luminaire_efficacy(&ldt);
assert!(luminaire_efficacy > 0.0);
assert!(luminaire_efficacy <= lamp_efficacy);
assert!((luminaire_efficacy - lamp_efficacy * 0.8).abs() < 0.01);
}
#[test]
fn test_spacing_criterion() {
let ldt = create_test_ldt();
let s_h = PhotometricCalculations::spacing_criterion(&ldt, 0.0);
assert!((0.5..=3.0).contains(&s_h), "S/H was {}", s_h);
let (s_h_par, s_h_perp) = PhotometricCalculations::spacing_criteria(&ldt);
assert!(s_h_par > 0.0);
assert!(s_h_perp > 0.0);
}
#[test]
fn test_zonal_lumens() {
let ldt = create_test_ldt();
let zones_10 = PhotometricCalculations::zonal_lumens_10deg(&ldt);
let total_10: f64 = zones_10.iter().sum();
assert!(
(total_10 - 100.0).abs() < 1.0,
"Total should be ~100%, got {}",
total_10
);
let zones_30 = PhotometricCalculations::zonal_lumens_30deg(&ldt);
let total_30 = zones_30.downward_total() + zones_30.upward_total();
assert!(
(total_30 - 100.0).abs() < 1.0,
"Total should be ~100%, got {}",
total_30
);
assert!(zones_30.downward_total() > zones_30.upward_total());
}
#[test]
fn test_k_factor() {
let mut ldt = create_test_ldt();
ldt.downward_flux_fraction = 90.0;
let k = PhotometricCalculations::k_factor(&ldt, 1.0, (0.7, 0.5, 0.2));
assert!((0.0..=1.5).contains(&k), "K-factor was {}", k);
}
#[test]
fn test_ugr_calculation() {
let mut ldt = create_test_ldt();
ldt.length = 600.0; ldt.width = 600.0; ldt.intensities = vec![vec![200.0, 196.0, 180.0, 150.0, 100.0, 40.0, 10.0]];
let params = UgrParams::standard_office();
let ugr = PhotometricCalculations::ugr(&ldt, ¶ms);
assert!(ugr >= 0.0, "UGR should be >= 0, got {}", ugr);
}
#[test]
fn test_ugr_params() {
let params = UgrParams::default();
let lb = params.background_luminance();
assert!(lb > 0.0, "Background luminance should be positive");
let office = UgrParams::standard_office();
assert_eq!(office.illuminance, 500.0);
}
#[test]
fn test_gldf_photometric_data() {
let mut ldt = create_test_ldt();
ldt.light_output_ratio = 85.0;
ldt.downward_flux_fraction = 95.0;
ldt.luminous_area_length = 600.0;
ldt.luminous_area_width = 600.0;
ldt.length = 620.0;
ldt.width = 620.0;
let gldf = GldfPhotometricData::from_eulumdat(&ldt);
assert_eq!(gldf.light_output_ratio, 85.0);
assert_eq!(gldf.downward_flux_fraction, 95.0);
assert!(gldf.luminous_efficacy > 0.0);
assert!(gldf.downward_light_output_ratio > 0.0);
assert!(gldf.cut_off_angle > 0.0);
assert!(!gldf.photometric_code.is_empty());
assert!(gldf.photometric_code.contains('-'));
let text = gldf.to_text();
assert!(text.contains("GLDF PHOTOMETRIC DATA"));
assert!(text.contains("CIE Flux Code"));
assert!(text.contains("BUG Rating"));
let props = gldf.to_gldf_properties();
assert!(props.len() >= 12);
assert!(props.iter().any(|(k, _)| *k == "cie_flux_code"));
assert!(props.iter().any(|(k, _)| *k == "half_peak_divergence"));
}
#[test]
fn test_photometric_summary() {
let mut ldt = create_test_ldt();
ldt.light_output_ratio = 85.0;
ldt.downward_flux_fraction = 90.0;
let summary = PhotometricSummary::from_eulumdat(&ldt);
assert_eq!(summary.total_lamp_flux, 1000.0);
assert_eq!(summary.lor, 85.0);
assert_eq!(summary.dlor, 90.0);
assert_eq!(summary.ulor, 10.0);
assert!(summary.lamp_efficacy > 0.0);
assert!(summary.luminaire_efficacy > 0.0);
assert!(summary.luminaire_efficacy <= summary.lamp_efficacy);
assert!(summary.beam_angle > 0.0);
assert!(summary.field_angle > 0.0);
let text = summary.to_text();
assert!(text.contains("PHOTOMETRIC SUMMARY"));
assert!(text.contains("CIE Flux Code"));
let compact = summary.to_compact();
assert!(compact.contains("CIE:"));
assert!(compact.contains("Beam:"));
let kv = summary.to_key_value();
assert!(!kv.is_empty());
assert!(kv.iter().any(|(k, _)| *k == "beam_angle_deg"));
}
#[test]
fn test_cu_table() {
let ldt = create_test_ldt();
let cu = PhotometricCalculations::cu_table(&ldt);
for row in &cu.values {
for &val in row {
assert!(val >= 0.0, "CU should be >= 0");
assert!(val <= 150.0, "CU should be <= 150");
}
}
assert_eq!(cu.values.len(), 11, "Should have 11 RCR rows (0-10)");
assert_eq!(cu.values[0].len(), 18, "Should have 18 reflectance columns");
assert!(cu.values[0][0] > 0.0, "CU at RCR=0 should be positive");
let text = cu.to_text();
assert!(text.contains("COEFFICIENTS OF UTILIZATION"));
}
#[test]
fn test_ugr_table() {
let mut ldt = create_test_ldt();
ldt.length = 600.0;
ldt.width = 600.0;
ldt.luminous_area_length = 600.0;
ldt.luminous_area_width = 600.0;
let ugr = PhotometricCalculations::ugr_table(&ldt);
assert_eq!(ugr.crosswise.len(), 19, "Should have 19 room sizes");
assert_eq!(
ugr.crosswise[0].len(),
5,
"Should have 5 reflectance combos"
);
assert!(ugr.max_ugr >= 10.0, "Max UGR should be >= 10 (clamped)");
assert!(ugr.max_ugr <= 40.0, "Max UGR should be <= 40 (clamped)");
let text = ugr.to_text();
assert!(text.contains("UGR TABLE"));
assert!(text.contains("Maximum UGR"));
}
#[test]
fn test_candela_tabulation() {
let ldt = create_test_ldt();
let tab = PhotometricCalculations::candela_tabulation(&ldt);
assert!(!tab.entries.is_empty());
for entry in &tab.entries {
assert!(entry.gamma >= 0.0);
assert!(entry.gamma <= 180.0);
assert!(entry.candela >= 0.0);
}
}
fn create_test_uplight_ldt() -> Eulumdat {
Eulumdat {
symmetry: Symmetry::VerticalAxis,
g_angles: (0..=18).map(|i| i as f64 * 10.0).collect(),
intensities: vec![vec![
1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0, ]],
c_angles: vec![0.0],
downward_flux_fraction: 5.0, ..Default::default()
}
}
fn create_test_direct_indirect_ldt() -> Eulumdat {
Eulumdat {
symmetry: Symmetry::VerticalAxis,
g_angles: (0..=18).map(|i| i as f64 * 10.0).collect(),
intensities: vec![vec![
800.0, 700.0, 500.0, 300.0, 150.0, 80.0, 40.0, 20.0, 15.0, 10.0, 15.0, 20.0, 40.0, 80.0, 150.0, 250.0, 350.0, 400.0, 450.0, ]],
c_angles: vec![0.0],
downward_flux_fraction: 60.0, ..Default::default()
}
}
#[test]
fn test_upward_beam_angle() {
let ldt = create_test_uplight_ldt();
let upward_beam = PhotometricCalculations::upward_beam_angle(&ldt);
assert!(
upward_beam > 0.0,
"Upward beam angle should be positive, got {}",
upward_beam
);
let upward_field = PhotometricCalculations::upward_field_angle(&ldt);
assert!(
upward_field >= upward_beam,
"Field angle {} should be >= beam angle {}",
upward_field,
upward_beam
);
let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
assert!(
analysis.upward_peak_intensity > analysis.downward_peak_intensity * 10.0,
"Upward peak {} should be >> downward peak {} for uplight",
analysis.upward_peak_intensity,
analysis.downward_peak_intensity
);
}
#[test]
fn test_comprehensive_beam_analysis_uplight() {
let ldt = create_test_uplight_ldt();
let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
assert_eq!(
analysis.primary_direction,
LightDirection::Upward,
"Primary direction should be Upward for uplight"
);
assert_eq!(
analysis.distribution_type,
DistributionType::Indirect,
"Distribution type should be Indirect for uplight"
);
assert!(
analysis.upward_peak_intensity > analysis.downward_peak_intensity,
"Upward peak {} should be > downward peak {}",
analysis.upward_peak_intensity,
analysis.downward_peak_intensity
);
assert!(
analysis.upward_beam_angle > 0.0,
"Upward beam angle should be positive, got {}",
analysis.upward_beam_angle
);
}
#[test]
fn test_comprehensive_beam_analysis_direct_indirect() {
let ldt = create_test_direct_indirect_ldt();
let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
assert!(
analysis.has_downward_component(),
"Should have downward component"
);
assert!(
analysis.has_upward_component(),
"Should have upward component"
);
assert_eq!(
analysis.primary_direction,
LightDirection::Downward,
"Primary direction should be Downward"
);
assert_eq!(
analysis.distribution_type,
DistributionType::DirectIndirect,
"Distribution type should be DirectIndirect"
);
assert!(
analysis.downward_beam_angle > 0.0,
"Downward beam angle should be positive"
);
assert!(
analysis.upward_beam_angle > 0.0,
"Upward beam angle should be positive"
);
}
#[test]
fn test_downlight_has_no_upward_beam() {
let ldt = create_test_ldt();
let analysis = PhotometricCalculations::comprehensive_beam_analysis(&ldt);
assert_eq!(
analysis.primary_direction,
LightDirection::Downward,
"Primary direction should be Downward for standard downlight"
);
assert_eq!(
analysis.distribution_type,
DistributionType::Direct,
"Distribution type should be Direct for standard downlight"
);
assert!(
analysis.downward_beam_angle > 0.0,
"Downward beam angle should be positive"
);
}
}