use crate::Eulumdat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LateralType {
TypeI,
TypeII,
TypeIII,
TypeIV,
TypeV,
}
impl std::fmt::Display for LateralType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TypeI => write!(f, "Type I"),
Self::TypeII => write!(f, "Type II"),
Self::TypeIII => write!(f, "Type III"),
Self::TypeIV => write!(f, "Type IV"),
Self::TypeV => write!(f, "Type V"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LongitudinalClass {
Short,
Medium,
Long,
VeryLong,
}
impl std::fmt::Display for LongitudinalClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Short => write!(f, "Short"),
Self::Medium => write!(f, "Medium"),
Self::Long => write!(f, "Long"),
Self::VeryLong => write!(f, "Very Long"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CutoffClass {
FullCutoff,
Cutoff,
SemiCutoff,
NonCutoff,
}
impl std::fmt::Display for CutoffClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FullCutoff => write!(f, "Full Cutoff"),
Self::Cutoff => write!(f, "Cutoff"),
Self::SemiCutoff => write!(f, "Semi-Cutoff"),
Self::NonCutoff => write!(f, "Non-Cutoff"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Applicability {
Applicable,
Uplight,
IndoorType,
}
impl std::fmt::Display for Applicability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Applicable => write!(f, "Applicable"),
Self::Uplight => write!(f, "Not applicable (uplight)"),
Self::IndoorType => write!(f, "Not applicable (indoor type)"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IesnaClassification {
pub applicability: Applicability,
pub lateral_type: LateralType,
pub longitudinal: LongitudinalClass,
pub cutoff: CutoffClass,
pub max_candela: f64,
pub max_candela_gamma: f64,
pub intensity_at_80: f64,
pub intensity_at_90: f64,
pub designation: String,
}
impl std::fmt::Display for IesnaClassification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.designation)
}
}
pub fn classify(ldt: &Eulumdat) -> IesnaClassification {
let max_candela = ldt.max_intensity();
let max_candela_gamma = find_peak_gamma(ldt);
let i_at_80 = sample_max_across_c_planes(ldt, 80.0);
let i_at_90 = sample_max_across_c_planes(ldt, 90.0);
let pct_at_80 = if max_candela > 0.0 {
i_at_80 / max_candela * 100.0
} else {
0.0
};
let pct_at_90 = if max_candela > 0.0 {
i_at_90 / max_candela * 100.0
} else {
0.0
};
let lateral = classify_lateral(ldt);
let longitudinal = classify_longitudinal(max_candela_gamma);
let cutoff = classify_cutoff(pct_at_80, pct_at_90);
let applicability = determine_applicability(ldt, &lateral, max_candela_gamma);
let designation = if applicability == Applicability::Applicable {
format!("{} {} {}", lateral, longitudinal, cutoff)
} else {
format!(
"{} {} {} ({})",
lateral, longitudinal, cutoff, applicability
)
};
IesnaClassification {
applicability,
lateral_type: lateral,
longitudinal,
cutoff,
max_candela,
max_candela_gamma,
intensity_at_80: pct_at_80,
intensity_at_90: pct_at_90,
designation,
}
}
fn determine_applicability(ldt: &Eulumdat, lateral: &LateralType, max_gamma: f64) -> Applicability {
if ldt.downward_flux_fraction < 50.0 {
return Applicability::Uplight;
}
if *lateral == LateralType::TypeV && max_gamma < 30.0 {
return Applicability::IndoorType;
}
if ldt.symmetry == crate::Symmetry::VerticalAxis && max_gamma < 15.0 {
return Applicability::IndoorType;
}
Applicability::Applicable
}
fn find_peak_gamma(ldt: &Eulumdat) -> f64 {
let mut max_i = 0.0f64;
let mut max_gamma = 0.0f64;
for gi in 0..=180 {
let gamma = gi as f64 * 0.5;
let i_c0 = ldt.sample(0.0, gamma);
let i_c180 = ldt.sample(180.0, gamma);
let i = i_c0.max(i_c180);
if i > max_i {
max_i = i;
max_gamma = gamma;
}
}
max_gamma
}
fn sample_max_across_c_planes(ldt: &Eulumdat, gamma: f64) -> f64 {
let mut max_i = 0.0f64;
for ci in 0..72 {
let c = ci as f64 * 5.0;
let i = ldt.sample(c, gamma);
if i > max_i {
max_i = i;
}
}
max_i
}
fn classify_lateral(ldt: &Eulumdat) -> LateralType {
let i_c0 = ldt.sample(0.0, 60.0);
let i_c90 = ldt.sample(90.0, 60.0);
let i_c180 = ldt.sample(180.0, 60.0);
let i_c270 = ldt.sample(270.0, 60.0);
let avg = (i_c0 + i_c90 + i_c180 + i_c270) / 4.0;
if avg > 0.0 {
let max_dev = [i_c0, i_c90, i_c180, i_c270]
.iter()
.map(|&i| ((i - avg) / avg).abs())
.fold(0.0f64, f64::max);
if max_dev < 0.25 {
return LateralType::TypeV;
}
}
let mut peak_c90 = 0.0f64;
let mut peak_gamma = 0.0;
for gi in 0..=18 {
let gamma = gi as f64 * 5.0;
let i = ldt.sample(90.0, gamma).max(ldt.sample(270.0, gamma));
if i > peak_c90 {
peak_c90 = i;
peak_gamma = gamma;
}
}
if peak_c90 <= 0.0 {
return LateralType::TypeI;
}
let max_cd = ldt.max_intensity();
let half_max = max_cd * 0.5;
let mut max_lateral_angle = 0.0f64;
for ci in 0..=36 {
let c = ci as f64 * 5.0;
let i = ldt.sample(c, peak_gamma);
if i >= half_max {
let lat = c.min(180.0 - c).min((360.0 - c).abs());
if lat > max_lateral_angle {
max_lateral_angle = lat;
}
}
}
match max_lateral_angle {
a if a < 15.0 => LateralType::TypeI,
a if a < 25.0 => LateralType::TypeII,
a if a < 40.0 => LateralType::TypeIII,
_ => LateralType::TypeIV,
}
}
fn classify_longitudinal(max_gamma: f64) -> LongitudinalClass {
match max_gamma {
g if g < 52.0 => LongitudinalClass::Short,
g if g < 63.0 => LongitudinalClass::Medium,
g if g < 70.0 => LongitudinalClass::Long,
_ => LongitudinalClass::VeryLong,
}
}
fn classify_cutoff(pct_at_80: f64, pct_at_90: f64) -> CutoffClass {
if pct_at_90 <= 0.5 && pct_at_80 <= 10.0 {
CutoffClass::FullCutoff
} else if pct_at_90 <= 2.5 && pct_at_80 <= 25.0 {
CutoffClass::Cutoff
} else if pct_at_90 <= 5.0 && pct_at_80 <= 50.0 {
CutoffClass::SemiCutoff
} else {
CutoffClass::NonCutoff
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_road_luminaire() {
let content = include_str!("../../eulumdat-wasm/templates/road_luminaire.ldt");
let ldt = Eulumdat::parse(content).unwrap();
let cls = classify(&ldt);
eprintln!("Road luminaire: {}", cls.designation);
eprintln!(" Lateral: {}", cls.lateral_type);
eprintln!(
" Longitudinal: {} (peak gamma={:.1}°)",
cls.longitudinal, cls.max_candela_gamma
);
eprintln!(
" Cutoff: {} (80°={:.1}%, 90°={:.1}%)",
cls.cutoff, cls.intensity_at_80, cls.intensity_at_90
);
eprintln!(" Max cd/klm: {:.1}", cls.max_candela);
assert!(
matches!(
cls.lateral_type,
LateralType::TypeII | LateralType::TypeIII | LateralType::TypeIV
),
"Road luminaire should be Type II-IV, got {}",
cls.lateral_type
);
}
#[test]
fn classify_fluorescent() {
let content = include_str!("../../eulumdat-wasm/templates/fluorescent_luminaire.ldt");
let ldt = Eulumdat::parse(content).unwrap();
let cls = classify(&ldt);
eprintln!("Fluorescent: {}", cls.designation);
eprintln!(" Lateral: {}", cls.lateral_type);
eprintln!(
" Longitudinal: {} (peak gamma={:.1}°)",
cls.longitudinal, cls.max_candela_gamma
);
eprintln!(
" Cutoff: {} (80°={:.1}%, 90°={:.1}%)",
cls.cutoff, cls.intensity_at_80, cls.intensity_at_90
);
assert_eq!(
cls.longitudinal,
LongitudinalClass::Short,
"Fluorescent should be Short throw, got {}",
cls.longitudinal
);
}
#[test]
fn classify_projector() {
let content = include_str!("../../eulumdat-wasm/templates/projector.ldt");
let ldt = Eulumdat::parse(content).unwrap();
let cls = classify(&ldt);
eprintln!("Projector: {}", cls.designation);
eprintln!(" Lateral: {}", cls.lateral_type);
eprintln!(
" Longitudinal: {} (peak gamma={:.1}°)",
cls.longitudinal, cls.max_candela_gamma
);
eprintln!(
" Cutoff: {} (80°={:.1}%, 90°={:.1}%)",
cls.cutoff, cls.intensity_at_80, cls.intensity_at_90
);
}
#[test]
fn classify_uplight() {
let content = include_str!("../../eulumdat-wasm/templates/floor_uplight.ldt");
let ldt = Eulumdat::parse(content).unwrap();
let cls = classify(&ldt);
eprintln!("Uplight: {}", cls.designation);
}
#[test]
fn cutoff_thresholds() {
assert_eq!(classify_cutoff(0.0, 0.0), CutoffClass::FullCutoff);
assert_eq!(classify_cutoff(10.0, 0.5), CutoffClass::FullCutoff);
assert_eq!(classify_cutoff(10.1, 0.5), CutoffClass::Cutoff);
assert_eq!(classify_cutoff(25.0, 2.5), CutoffClass::Cutoff);
assert_eq!(classify_cutoff(25.1, 2.5), CutoffClass::SemiCutoff);
assert_eq!(classify_cutoff(50.0, 5.0), CutoffClass::SemiCutoff);
assert_eq!(classify_cutoff(50.1, 5.0), CutoffClass::NonCutoff);
assert_eq!(classify_cutoff(60.0, 10.0), CutoffClass::NonCutoff);
}
#[test]
fn longitudinal_thresholds() {
assert_eq!(classify_longitudinal(0.0), LongitudinalClass::Short);
assert_eq!(classify_longitudinal(51.9), LongitudinalClass::Short);
assert_eq!(classify_longitudinal(52.0), LongitudinalClass::Medium);
assert_eq!(classify_longitudinal(62.9), LongitudinalClass::Medium);
assert_eq!(classify_longitudinal(63.0), LongitudinalClass::Long);
assert_eq!(classify_longitudinal(69.9), LongitudinalClass::Long);
assert_eq!(classify_longitudinal(70.0), LongitudinalClass::VeryLong);
}
#[test]
fn display_formatting() {
assert_eq!(format!("{}", LateralType::TypeIII), "Type III");
assert_eq!(format!("{}", LongitudinalClass::Medium), "Medium");
assert_eq!(format!("{}", CutoffClass::FullCutoff), "Full Cutoff");
}
}