Skip to main content

eulumdat/
iesna_classification.rs

1//! IESNA roadway luminaire classification.
2//!
3//! Implements the IESNA (IES) classification system for roadway and area
4//! lighting luminaires per ANSI/IES RP-8 and TM-15:
5//!
6//! - **Lateral distribution type** (I, II, III, IV, V): Based on the location
7//!   of the half-maximum isocandela trace relative to the road axis.
8//! - **Longitudinal classification** (Short, Medium, Long, Very Long): Based on
9//!   the maximum candela angle in the C0-C180 plane.
10//! - **Cutoff classification** (Full Cutoff, Cutoff, Semi-Cutoff, Non-Cutoff):
11//!   Based on the intensity at high angles (80° and 90° from nadir).
12
13use crate::Eulumdat;
14
15// ============================================================================
16// Lateral distribution type (Type I–V)
17// ============================================================================
18
19/// IESNA lateral light distribution type.
20///
21/// Classifies how light is spread laterally (perpendicular to the road axis).
22/// Based on the width of the half-maximum isocandela trace on the C90-C270 plane.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub enum LateralType {
26    /// Type I: Narrow symmetric distribution, for walkways and narrow paths.
27    /// Peak intensity near C0/C180, half-max width < 15° from road axis.
28    TypeI,
29    /// Type II: Slightly wider than Type I, for narrower roadways.
30    /// Half-max width 15°–25° from road axis.
31    TypeII,
32    /// Type III: Asymmetric, throws light to one side.
33    /// Half-max extends 25°–40° from road axis.
34    TypeIII,
35    /// Type IV: Semi-cutoff forward throw.
36    /// Half-max extends 40°–55° from road axis, minimal backlight.
37    TypeIV,
38    /// Type V: Symmetric circular distribution (area lighting).
39    /// Approximately equal intensity in all lateral directions.
40    TypeV,
41}
42
43impl std::fmt::Display for LateralType {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::TypeI => write!(f, "Type I"),
47            Self::TypeII => write!(f, "Type II"),
48            Self::TypeIII => write!(f, "Type III"),
49            Self::TypeIV => write!(f, "Type IV"),
50            Self::TypeV => write!(f, "Type V"),
51        }
52    }
53}
54
55// ============================================================================
56// Longitudinal classification (Short / Medium / Long)
57// ============================================================================
58
59/// IESNA longitudinal light distribution classification.
60///
61/// Based on the angle of maximum candela in the C0-C180 (along-road) plane,
62/// relative to nadir.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub enum LongitudinalClass {
66    /// Short: Maximum candela at gamma < 52° (1.0 MH throw).
67    Short,
68    /// Medium: Maximum candela at 52°–63° (1.0–2.25 MH throw).
69    Medium,
70    /// Long: Maximum candela at 63°–70° (2.25–2.75 MH throw).
71    Long,
72    /// Very Long: Maximum candela at gamma > 70° (>2.75 MH throw).
73    VeryLong,
74}
75
76impl std::fmt::Display for LongitudinalClass {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Short => write!(f, "Short"),
80            Self::Medium => write!(f, "Medium"),
81            Self::Long => write!(f, "Long"),
82            Self::VeryLong => write!(f, "Very Long"),
83        }
84    }
85}
86
87// ============================================================================
88// Cutoff classification
89// ============================================================================
90
91/// IESNA cutoff classification.
92///
93/// Based on the intensity at high angles relative to the maximum intensity.
94/// Determines how well the luminaire controls glare and uplight.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97pub enum CutoffClass {
98    /// Full Cutoff: No light at or above 90°, ≤10% at 80°.
99    FullCutoff,
100    /// Cutoff: ≤2.5% at 90°, ≤25% at 80°.
101    Cutoff,
102    /// Semi-Cutoff: ≤5% at 90°, ≤50% at 80°.
103    SemiCutoff,
104    /// Non-Cutoff: Exceeds Semi-Cutoff limits.
105    NonCutoff,
106}
107
108impl std::fmt::Display for CutoffClass {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Self::FullCutoff => write!(f, "Full Cutoff"),
112            Self::Cutoff => write!(f, "Cutoff"),
113            Self::SemiCutoff => write!(f, "Semi-Cutoff"),
114            Self::NonCutoff => write!(f, "Non-Cutoff"),
115        }
116    }
117}
118
119// ============================================================================
120// Combined classification result
121// ============================================================================
122
123/// Whether the IESNA roadway classification is applicable to this luminaire.
124///
125/// The IES RP-8 classification system is designed for outdoor roadway and area
126/// lighting. It is not meaningful for:
127/// - Indoor luminaires (ceiling, recessed, pendant)
128/// - Uplights (primary emission above horizontal)
129/// - Decorative/accent fixtures
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132pub enum Applicability {
133    /// Classification is applicable — luminaire has significant downward outdoor
134    /// distribution characteristics.
135    Applicable,
136    /// Luminaire is primarily an uplight (>50% flux above horizontal).
137    /// Roadway classification is not meaningful.
138    Uplight,
139    /// Luminaire is primarily indoor (symmetric, short throw, no cutoff control).
140    /// Classification values are computed but may not be meaningful.
141    IndoorType,
142}
143
144impl std::fmt::Display for Applicability {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            Self::Applicable => write!(f, "Applicable"),
148            Self::Uplight => write!(f, "Not applicable (uplight)"),
149            Self::IndoorType => write!(f, "Not applicable (indoor type)"),
150        }
151    }
152}
153
154/// Complete IESNA roadway luminaire classification.
155#[derive(Debug, Clone, PartialEq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157pub struct IesnaClassification {
158    /// Whether this classification is meaningful for this luminaire.
159    pub applicability: Applicability,
160    /// Lateral distribution type (I–V).
161    pub lateral_type: LateralType,
162    /// Longitudinal throw classification.
163    pub longitudinal: LongitudinalClass,
164    /// Cutoff classification.
165    pub cutoff: CutoffClass,
166    /// Maximum candela value (cd/klm).
167    pub max_candela: f64,
168    /// Gamma angle of maximum candela in C0-C180 plane (degrees).
169    pub max_candela_gamma: f64,
170    /// Intensity at 80° from nadir as percentage of max.
171    pub intensity_at_80: f64,
172    /// Intensity at 90° from nadir as percentage of max.
173    pub intensity_at_90: f64,
174    /// Designation string, e.g. "Type III Medium Cutoff".
175    pub designation: String,
176}
177
178impl std::fmt::Display for IesnaClassification {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        write!(f, "{}", self.designation)
181    }
182}
183
184// ============================================================================
185// Classification logic
186// ============================================================================
187
188/// Classify a luminaire according to IESNA roadway standards.
189///
190/// Always computes the classification values, but also indicates whether
191/// the classification is meaningful for the given luminaire type.
192/// Indoor luminaires, uplights, and decorative fixtures will have
193/// `applicability != Applicable`.
194pub fn classify(ldt: &Eulumdat) -> IesnaClassification {
195    let max_candela = ldt.max_intensity();
196
197    // Find angle of peak intensity in C0-C180 plane
198    let max_candela_gamma = find_peak_gamma(ldt);
199
200    // Intensity ratios at critical angles
201    let i_at_80 = sample_max_across_c_planes(ldt, 80.0);
202    let i_at_90 = sample_max_across_c_planes(ldt, 90.0);
203
204    let pct_at_80 = if max_candela > 0.0 {
205        i_at_80 / max_candela * 100.0
206    } else {
207        0.0
208    };
209    let pct_at_90 = if max_candela > 0.0 {
210        i_at_90 / max_candela * 100.0
211    } else {
212        0.0
213    };
214
215    let lateral = classify_lateral(ldt);
216    let longitudinal = classify_longitudinal(max_candela_gamma);
217    let cutoff = classify_cutoff(pct_at_80, pct_at_90);
218
219    // Determine applicability
220    let applicability = determine_applicability(ldt, &lateral, max_candela_gamma);
221
222    let designation = if applicability == Applicability::Applicable {
223        format!("{} {} {}", lateral, longitudinal, cutoff)
224    } else {
225        format!(
226            "{} {} {} ({})",
227            lateral, longitudinal, cutoff, applicability
228        )
229    };
230
231    IesnaClassification {
232        applicability,
233        lateral_type: lateral,
234        longitudinal,
235        cutoff,
236        max_candela,
237        max_candela_gamma,
238        intensity_at_80: pct_at_80,
239        intensity_at_90: pct_at_90,
240        designation,
241    }
242}
243
244/// Determine whether the IESNA roadway classification is meaningful.
245fn determine_applicability(ldt: &Eulumdat, lateral: &LateralType, max_gamma: f64) -> Applicability {
246    // Check if primarily uplight: downward_flux_fraction < 50%
247    if ldt.downward_flux_fraction < 50.0 {
248        return Applicability::Uplight;
249    }
250
251    // Check if indoor type: symmetric (Type V) with short throw and
252    // peak near nadir (typical of ceiling-mounted indoor fixtures)
253    if *lateral == LateralType::TypeV && max_gamma < 30.0 {
254        return Applicability::IndoorType;
255    }
256
257    // Rotationally symmetric sources with very narrow beam at nadir
258    // are typically indoor downlights
259    if ldt.symmetry == crate::Symmetry::VerticalAxis && max_gamma < 15.0 {
260        return Applicability::IndoorType;
261    }
262
263    Applicability::Applicable
264}
265
266/// Find the gamma angle of peak intensity, searching C0 and C180 planes.
267fn find_peak_gamma(ldt: &Eulumdat) -> f64 {
268    let mut max_i = 0.0f64;
269    let mut max_gamma = 0.0f64;
270
271    // Search in 0.5° steps from 0° to 90°
272    for gi in 0..=180 {
273        let gamma = gi as f64 * 0.5;
274        // Check C0 and C180 (along-road planes)
275        let i_c0 = ldt.sample(0.0, gamma);
276        let i_c180 = ldt.sample(180.0, gamma);
277        let i = i_c0.max(i_c180);
278        if i > max_i {
279            max_i = i;
280            max_gamma = gamma;
281        }
282    }
283    max_gamma
284}
285
286/// Maximum intensity at a given gamma across all C-planes.
287fn sample_max_across_c_planes(ldt: &Eulumdat, gamma: f64) -> f64 {
288    let mut max_i = 0.0f64;
289    // Sample every 5° in C
290    for ci in 0..72 {
291        let c = ci as f64 * 5.0;
292        let i = ldt.sample(c, gamma);
293        if i > max_i {
294            max_i = i;
295        }
296    }
297    max_i
298}
299
300/// Classify lateral distribution type (I–V).
301///
302/// Based on the angular width of the half-maximum isocandela in the
303/// C90-C270 plane (perpendicular to road axis).
304fn classify_lateral(ldt: &Eulumdat) -> LateralType {
305    // Check if distribution is approximately symmetric (Type V)
306    // by comparing C0, C90, C180, C270 at gamma=60°
307    let i_c0 = ldt.sample(0.0, 60.0);
308    let i_c90 = ldt.sample(90.0, 60.0);
309    let i_c180 = ldt.sample(180.0, 60.0);
310    let i_c270 = ldt.sample(270.0, 60.0);
311
312    let avg = (i_c0 + i_c90 + i_c180 + i_c270) / 4.0;
313    if avg > 0.0 {
314        let max_dev = [i_c0, i_c90, i_c180, i_c270]
315            .iter()
316            .map(|&i| ((i - avg) / avg).abs())
317            .fold(0.0f64, f64::max);
318
319        // If intensity varies < 25% across all directions → Type V (circular)
320        if max_dev < 0.25 {
321            return LateralType::TypeV;
322        }
323    }
324
325    // Find the half-maximum width in the C90-C270 plane
326    // Peak is typically at or near C90 or C270
327    let mut peak_c90 = 0.0f64;
328    let mut peak_gamma = 0.0;
329    for gi in 0..=18 {
330        let gamma = gi as f64 * 5.0;
331        let i = ldt.sample(90.0, gamma).max(ldt.sample(270.0, gamma));
332        if i > peak_c90 {
333            peak_c90 = i;
334            peak_gamma = gamma;
335        }
336    }
337
338    if peak_c90 <= 0.0 {
339        return LateralType::TypeI;
340    }
341
342    // Find the maximum candela overall to compute half-max threshold
343    let max_cd = ldt.max_intensity();
344    let half_max = max_cd * 0.5;
345
346    // Measure the lateral spread: at the peak gamma angle, sweep C from 0° to 180°
347    // and find where intensity exceeds half-max
348    let mut max_lateral_angle = 0.0f64;
349    for ci in 0..=36 {
350        let c = ci as f64 * 5.0;
351        // Check both sides (C and 360-C)
352        let i = ldt.sample(c, peak_gamma);
353        if i >= half_max {
354            // Lateral angle = min angle from road axis (C0/C180)
355            let lat = c.min(180.0 - c).min((360.0 - c).abs());
356            if lat > max_lateral_angle {
357                max_lateral_angle = lat;
358            }
359        }
360    }
361
362    match max_lateral_angle {
363        a if a < 15.0 => LateralType::TypeI,
364        a if a < 25.0 => LateralType::TypeII,
365        a if a < 40.0 => LateralType::TypeIII,
366        _ => LateralType::TypeIV,
367    }
368}
369
370/// Classify longitudinal throw based on the gamma angle of maximum candela.
371fn classify_longitudinal(max_gamma: f64) -> LongitudinalClass {
372    match max_gamma {
373        g if g < 52.0 => LongitudinalClass::Short,
374        g if g < 63.0 => LongitudinalClass::Medium,
375        g if g < 70.0 => LongitudinalClass::Long,
376        _ => LongitudinalClass::VeryLong,
377    }
378}
379
380/// Classify cutoff based on intensity at 80° and 90° as percentage of max.
381fn classify_cutoff(pct_at_80: f64, pct_at_90: f64) -> CutoffClass {
382    if pct_at_90 <= 0.5 && pct_at_80 <= 10.0 {
383        CutoffClass::FullCutoff
384    } else if pct_at_90 <= 2.5 && pct_at_80 <= 25.0 {
385        CutoffClass::Cutoff
386    } else if pct_at_90 <= 5.0 && pct_at_80 <= 50.0 {
387        CutoffClass::SemiCutoff
388    } else {
389        CutoffClass::NonCutoff
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn classify_road_luminaire() {
399        let content = include_str!("../../eulumdat-wasm/templates/road_luminaire.ldt");
400        let ldt = Eulumdat::parse(content).unwrap();
401        let cls = classify(&ldt);
402
403        eprintln!("Road luminaire: {}", cls.designation);
404        eprintln!("  Lateral: {}", cls.lateral_type);
405        eprintln!(
406            "  Longitudinal: {} (peak gamma={:.1}°)",
407            cls.longitudinal, cls.max_candela_gamma
408        );
409        eprintln!(
410            "  Cutoff: {} (80°={:.1}%, 90°={:.1}%)",
411            cls.cutoff, cls.intensity_at_80, cls.intensity_at_90
412        );
413        eprintln!("  Max cd/klm: {:.1}", cls.max_candela);
414
415        // Road luminaires are typically Type II-IV, Medium-Long throw
416        assert!(
417            matches!(
418                cls.lateral_type,
419                LateralType::TypeII | LateralType::TypeIII | LateralType::TypeIV
420            ),
421            "Road luminaire should be Type II-IV, got {}",
422            cls.lateral_type
423        );
424    }
425
426    #[test]
427    fn classify_fluorescent() {
428        let content = include_str!("../../eulumdat-wasm/templates/fluorescent_luminaire.ldt");
429        let ldt = Eulumdat::parse(content).unwrap();
430        let cls = classify(&ldt);
431
432        eprintln!("Fluorescent: {}", cls.designation);
433        eprintln!("  Lateral: {}", cls.lateral_type);
434        eprintln!(
435            "  Longitudinal: {} (peak gamma={:.1}°)",
436            cls.longitudinal, cls.max_candela_gamma
437        );
438        eprintln!(
439            "  Cutoff: {} (80°={:.1}%, 90°={:.1}%)",
440            cls.cutoff, cls.intensity_at_80, cls.intensity_at_90
441        );
442
443        // Indoor fluorescent: typically Type V (symmetric), Short throw
444        assert_eq!(
445            cls.longitudinal,
446            LongitudinalClass::Short,
447            "Fluorescent should be Short throw, got {}",
448            cls.longitudinal
449        );
450    }
451
452    #[test]
453    fn classify_projector() {
454        let content = include_str!("../../eulumdat-wasm/templates/projector.ldt");
455        let ldt = Eulumdat::parse(content).unwrap();
456        let cls = classify(&ldt);
457
458        eprintln!("Projector: {}", cls.designation);
459        eprintln!("  Lateral: {}", cls.lateral_type);
460        eprintln!(
461            "  Longitudinal: {} (peak gamma={:.1}°)",
462            cls.longitudinal, cls.max_candela_gamma
463        );
464        eprintln!(
465            "  Cutoff: {} (80°={:.1}%, 90°={:.1}%)",
466            cls.cutoff, cls.intensity_at_80, cls.intensity_at_90
467        );
468    }
469
470    #[test]
471    fn classify_uplight() {
472        let content = include_str!("../../eulumdat-wasm/templates/floor_uplight.ldt");
473        let ldt = Eulumdat::parse(content).unwrap();
474        let cls = classify(&ldt);
475
476        eprintln!("Uplight: {}", cls.designation);
477        // Uplights emit upward, so gamma of max intensity > 90° typically
478        // The classification is designed for roadway luminaires but should still work
479    }
480
481    #[test]
482    fn cutoff_thresholds() {
483        assert_eq!(classify_cutoff(0.0, 0.0), CutoffClass::FullCutoff);
484        assert_eq!(classify_cutoff(10.0, 0.5), CutoffClass::FullCutoff);
485        assert_eq!(classify_cutoff(10.1, 0.5), CutoffClass::Cutoff);
486        assert_eq!(classify_cutoff(25.0, 2.5), CutoffClass::Cutoff);
487        assert_eq!(classify_cutoff(25.1, 2.5), CutoffClass::SemiCutoff);
488        assert_eq!(classify_cutoff(50.0, 5.0), CutoffClass::SemiCutoff);
489        assert_eq!(classify_cutoff(50.1, 5.0), CutoffClass::NonCutoff);
490        assert_eq!(classify_cutoff(60.0, 10.0), CutoffClass::NonCutoff);
491    }
492
493    #[test]
494    fn longitudinal_thresholds() {
495        assert_eq!(classify_longitudinal(0.0), LongitudinalClass::Short);
496        assert_eq!(classify_longitudinal(51.9), LongitudinalClass::Short);
497        assert_eq!(classify_longitudinal(52.0), LongitudinalClass::Medium);
498        assert_eq!(classify_longitudinal(62.9), LongitudinalClass::Medium);
499        assert_eq!(classify_longitudinal(63.0), LongitudinalClass::Long);
500        assert_eq!(classify_longitudinal(69.9), LongitudinalClass::Long);
501        assert_eq!(classify_longitudinal(70.0), LongitudinalClass::VeryLong);
502    }
503
504    #[test]
505    fn display_formatting() {
506        assert_eq!(format!("{}", LateralType::TypeIII), "Type III");
507        assert_eq!(format!("{}", LongitudinalClass::Medium), "Medium");
508        assert_eq!(format!("{}", CutoffClass::FullCutoff), "Full Cutoff");
509    }
510}