1use crate::Eulumdat;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub enum LateralType {
26 TypeI,
29 TypeII,
32 TypeIII,
35 TypeIV,
38 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub enum LongitudinalClass {
66 Short,
68 Medium,
70 Long,
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97pub enum CutoffClass {
98 FullCutoff,
100 Cutoff,
102 SemiCutoff,
104 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132pub enum Applicability {
133 Applicable,
136 Uplight,
139 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#[derive(Debug, Clone, PartialEq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157pub struct IesnaClassification {
158 pub applicability: Applicability,
160 pub lateral_type: LateralType,
162 pub longitudinal: LongitudinalClass,
164 pub cutoff: CutoffClass,
166 pub max_candela: f64,
168 pub max_candela_gamma: f64,
170 pub intensity_at_80: f64,
172 pub intensity_at_90: f64,
174 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
184pub fn classify(ldt: &Eulumdat) -> IesnaClassification {
195 let max_candela = ldt.max_intensity();
196
197 let max_candela_gamma = find_peak_gamma(ldt);
199
200 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 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
244fn determine_applicability(ldt: &Eulumdat, lateral: &LateralType, max_gamma: f64) -> Applicability {
246 if ldt.downward_flux_fraction < 50.0 {
248 return Applicability::Uplight;
249 }
250
251 if *lateral == LateralType::TypeV && max_gamma < 30.0 {
254 return Applicability::IndoorType;
255 }
256
257 if ldt.symmetry == crate::Symmetry::VerticalAxis && max_gamma < 15.0 {
260 return Applicability::IndoorType;
261 }
262
263 Applicability::Applicable
264}
265
266fn find_peak_gamma(ldt: &Eulumdat) -> f64 {
268 let mut max_i = 0.0f64;
269 let mut max_gamma = 0.0f64;
270
271 for gi in 0..=180 {
273 let gamma = gi as f64 * 0.5;
274 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
286fn sample_max_across_c_planes(ldt: &Eulumdat, gamma: f64) -> f64 {
288 let mut max_i = 0.0f64;
289 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
300fn classify_lateral(ldt: &Eulumdat) -> LateralType {
305 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 max_dev < 0.25 {
321 return LateralType::TypeV;
322 }
323 }
324
325 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 let max_cd = ldt.max_intensity();
344 let half_max = max_cd * 0.5;
345
346 let mut max_lateral_angle = 0.0f64;
349 for ci in 0..=36 {
350 let c = ci as f64 * 5.0;
351 let i = ldt.sample(c, peak_gamma);
353 if i >= half_max {
354 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
370fn 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
380fn 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 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 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 }
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}