1#![allow(dead_code)]
12
13use std::f64::consts::PI;
14
15const C_LIGHT: f64 = 2.997_924_58e8;
21const H_PLANCK: f64 = 6.626_070_15e-34;
23const EV: f64 = 1.602_176_634e-19;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct RefractiveIndex {
36 pub n: f64,
38 pub k: f64,
40}
41
42impl RefractiveIndex {
43 pub fn new(n: f64, k: f64) -> Self {
45 Self { n, k }
46 }
47
48 pub fn lossless(n: f64) -> Self {
50 Self { n, k: 0.0 }
51 }
52
53 pub fn cauchy(a: f64, b: f64, c: f64, lambda_um: f64) -> f64 {
58 let l2 = lambda_um * lambda_um;
59 a + b / l2 + c / (l2 * l2)
60 }
61
62 pub fn modulus(&self) -> f64 {
64 (self.n * self.n + self.k * self.k).sqrt()
65 }
66
67 pub fn epsilon_real(&self) -> f64 {
69 self.n * self.n - self.k * self.k
70 }
71
72 pub fn epsilon_imag(&self) -> f64 {
74 2.0 * self.n * self.k
75 }
76}
77
78#[derive(Debug, Clone, Copy)]
85pub struct FresnelCoefficients {
86 pub rs: f64,
88 pub rp: f64,
90 pub ts: f64,
92 pub tp: f64,
94}
95
96impl FresnelCoefficients {
97 pub fn compute(n1: f64, n2: f64, theta_i: f64) -> Option<Self> {
104 let sin_t = n1 / n2 * theta_i.sin();
105 if sin_t.abs() > 1.0 {
106 return None; }
108 let theta_t = sin_t.asin();
109 let cos_i = theta_i.cos();
110 let cos_t = theta_t.cos();
111
112 let rs = ((n1 * cos_i - n2 * cos_t) / (n1 * cos_i + n2 * cos_t)).powi(2);
113 let rp = ((n2 * cos_i - n1 * cos_t) / (n2 * cos_i + n1 * cos_t)).powi(2);
114
115 let ts_amp = 2.0 * n1 * cos_i / (n1 * cos_i + n2 * cos_t);
117 let tp_amp = 2.0 * n1 * cos_i / (n2 * cos_i + n1 * cos_t);
118 let ts = (n2 * cos_t) / (n1 * cos_i) * ts_amp * ts_amp;
119 let tp = (n2 * cos_t) / (n1 * cos_i) * tp_amp * tp_amp;
120
121 Some(Self { rs, rp, ts, tp })
122 }
123
124 pub fn r_unpolarised(&self) -> f64 {
126 (self.rs + self.rp) / 2.0
127 }
128
129 pub fn t_unpolarised(&self) -> f64 {
131 (self.ts + self.tp) / 2.0
132 }
133}
134
135pub struct BrewsterAngle;
144
145impl BrewsterAngle {
146 pub fn compute(n1: f64, n2: f64) -> f64 {
148 (n2 / n1).atan()
149 }
150
151 pub fn compute_deg(n1: f64, n2: f64) -> f64 {
153 Self::compute(n1, n2).to_degrees()
154 }
155}
156
157pub struct TotalInternalReflection;
166
167impl TotalInternalReflection {
168 pub fn critical_angle(n1: f64, n2: f64) -> Option<f64> {
172 if n2 >= n1 {
173 return None;
174 }
175 Some((n2 / n1).asin())
176 }
177
178 pub fn critical_angle_deg(n1: f64, n2: f64) -> Option<f64> {
180 Self::critical_angle(n1, n2).map(f64::to_degrees)
181 }
182
183 pub fn is_total(&self, n1: f64, n2: f64, theta_i: f64) -> bool {
185 match Self::critical_angle(n1, n2) {
186 None => false,
187 Some(theta_c) => theta_i >= theta_c,
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy)]
198pub struct AbsorptionCoefficient {
199 pub alpha: f64,
201}
202
203impl AbsorptionCoefficient {
204 pub fn new(alpha: f64) -> Self {
206 Self { alpha }
207 }
208
209 pub fn from_extinction(k: f64, lambda_m: f64) -> Self {
213 Self {
214 alpha: 4.0 * PI * k / lambda_m,
215 }
216 }
217
218 pub fn intensity(&self, i0: f64, z: f64) -> f64 {
220 i0 * (-self.alpha * z).exp()
221 }
222
223 pub fn penetration_depth(&self) -> f64 {
225 1.0 / self.alpha
226 }
227
228 pub fn absorbance(&self, z: f64) -> f64 {
230 self.alpha * z
231 }
232}
233
234#[derive(Debug, Clone, Copy)]
242pub struct TransmittanceReflectance {
243 pub reflectance: f64,
245 pub transmittance: f64,
247 pub absorptance: f64,
249}
250
251impl TransmittanceReflectance {
252 pub fn new(r: f64, t: f64, a: f64) -> Self {
254 let sum = r + t + a;
255 if sum < 1e-30 {
256 return Self {
257 reflectance: 0.0,
258 transmittance: 0.0,
259 absorptance: 0.0,
260 };
261 }
262 Self {
263 reflectance: r / sum,
264 transmittance: t / sum,
265 absorptance: a / sum,
266 }
267 }
268
269 pub fn transparent(r: f64) -> Self {
271 let r = r.clamp(0.0, 1.0);
272 Self {
273 reflectance: r,
274 transmittance: 1.0 - r,
275 absorptance: 0.0,
276 }
277 }
278
279 pub fn from_fresnel_and_beer(r: f64, alpha: f64, thickness: f64) -> Self {
281 let r = r.clamp(0.0, 1.0);
282 let transmitted_max = 1.0 - r;
283 let t = transmitted_max * (-alpha * thickness).exp();
284 let a = transmitted_max - t;
285 Self {
286 reflectance: r,
287 transmittance: t,
288 absorptance: a,
289 }
290 }
291}
292
293#[derive(Debug, Clone)]
302pub struct ColorFromSpectrum {
303 pub wavelengths_nm: Vec<f64>,
305 pub power: Vec<f64>,
307}
308
309impl ColorFromSpectrum {
310 pub fn new(wavelengths_nm: Vec<f64>, power: Vec<f64>) -> Self {
314 assert_eq!(
315 wavelengths_nm.len(),
316 power.len(),
317 "wavelengths and power must have equal length"
318 );
319 Self {
320 wavelengths_nm,
321 power,
322 }
323 }
324
325 pub fn to_xyz(&self) -> (f64, f64, f64) {
329 let mut x_sum = 0.0_f64;
330 let mut y_sum = 0.0_f64;
331 let mut z_sum = 0.0_f64;
332
333 for (i, &lam) in self.wavelengths_nm.iter().enumerate() {
334 let p = self.power[i];
335 let (xb, yb, zb) = cie_cmf(lam);
336 x_sum += p * xb;
338 y_sum += p * yb;
339 z_sum += p * zb;
340 }
341 (x_sum, y_sum, z_sum)
342 }
343
344 pub fn to_srgb_linear(&self) -> (f64, f64, f64) {
348 let (x, y, z) = self.to_xyz();
349 let r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z;
351 let g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z;
352 let b = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z;
353 let peak = r.max(g).max(b).max(1.0);
355 (
356 (r / peak).clamp(0.0, 1.0),
357 (g / peak).clamp(0.0, 1.0),
358 (b / peak).clamp(0.0, 1.0),
359 )
360 }
361
362 pub fn peak_wavelength(&self) -> f64 {
364 let mut max_p = f64::NEG_INFINITY;
365 let mut peak_lam = 0.0;
366 for (&lam, &p) in self.wavelengths_nm.iter().zip(self.power.iter()) {
367 if p > max_p {
368 max_p = p;
369 peak_lam = lam;
370 }
371 }
372 peak_lam
373 }
374}
375
376fn cie_cmf(lam_nm: f64) -> (f64, f64, f64) {
380 let xbar = gaussian(lam_nm, 1.056, 599.8, 37.9, 0.362)
382 + gaussian(lam_nm, 0.821, 568.8, 46.9, 0.243)
383 + gaussian(lam_nm, -0.065, 601.0, 94.5, 0.000);
384 let ybar =
385 gaussian(lam_nm, 0.821, 556.3, 46.9, 0.243) + gaussian(lam_nm, 0.286, 530.9, 16.3, 0.180);
386 let zbar =
387 gaussian(lam_nm, 1.217, 437.0, 11.8, 0.000) + gaussian(lam_nm, 0.681, 459.0, 26.0, 0.000);
388 (xbar.max(0.0), ybar.max(0.0), zbar.max(0.0))
389}
390
391fn gaussian(x: f64, a: f64, mu: f64, sigma: f64, _shift: f64) -> f64 {
393 let z = (x - mu) / sigma;
394 a * (-0.5 * z * z).exp()
395}
396
397#[derive(Debug, Clone)]
406pub struct OpticalBandgap {
407 pub energies_ev: Vec<f64>,
409 pub alphas: Vec<f64>,
411}
412
413impl OpticalBandgap {
414 pub fn new(energies_ev: Vec<f64>, alphas: Vec<f64>) -> Self {
416 assert_eq!(energies_ev.len(), alphas.len());
417 Self {
418 energies_ev,
419 alphas,
420 }
421 }
422
423 pub fn from_wavelengths(wavelengths_nm: &[f64], alphas: &[f64]) -> Self {
425 let energies_ev: Vec<f64> = wavelengths_nm
426 .iter()
427 .map(|&lam| {
428 H_PLANCK * C_LIGHT / (lam * 1e-9) / EV
430 })
431 .collect();
432 Self::new(energies_ev, alphas.to_vec())
433 }
434
435 pub fn tauc_direct(&self) -> Vec<(f64, f64)> {
437 self.energies_ev
438 .iter()
439 .zip(self.alphas.iter())
440 .map(|(&e, &a)| (e, (a * e).powi(2)))
441 .collect()
442 }
443
444 pub fn estimate_bandgap(&self) -> Option<f64> {
449 let points = self.tauc_direct();
450 if points.len() < 4 {
451 return None;
452 }
453 let mut best_slope = 0.0_f64;
455 let mut best_idx = 0;
456 for i in 1..points.len() {
457 let de = points[i].0 - points[i - 1].0;
458 if de.abs() < 1e-12 {
459 continue;
460 }
461 let slope = (points[i].1 - points[i - 1].1) / de;
462 if slope > best_slope {
463 best_slope = slope;
464 best_idx = i;
465 }
466 }
467 if best_slope < 1e-30 {
468 return None;
469 }
470 let (e_i, y_i) = points[best_idx];
472 Some(e_i - y_i / best_slope)
473 }
474}
475
476#[derive(Debug, Clone)]
485pub struct EmissivityModel {
486 pub wavelengths_nm: Vec<f64>,
488 pub emissivity: Vec<f64>,
490 pub temp_coeff: f64,
492 pub t_ref: f64,
494}
495
496impl EmissivityModel {
497 pub fn new(
499 wavelengths_nm: Vec<f64>,
500 emissivity: Vec<f64>,
501 temp_coeff: f64,
502 t_ref: f64,
503 ) -> Self {
504 assert_eq!(wavelengths_nm.len(), emissivity.len());
505 Self {
506 wavelengths_nm,
507 emissivity,
508 temp_coeff,
509 t_ref,
510 }
511 }
512
513 pub fn emissivity_at(&self, lam_nm: f64, temp_k: f64) -> f64 {
516 let e0 = self.interpolate(lam_nm);
517 let correction = 1.0 + self.temp_coeff * (temp_k - self.t_ref);
518 (e0 * correction).clamp(0.0, 1.0)
519 }
520
521 pub fn total_emissivity(&self, temp_k: f64) -> f64 {
524 if self.wavelengths_nm.is_empty() {
525 return 0.0;
526 }
527 let mut sum = 0.0_f64;
528 let mut norm = 0.0_f64;
529 for i in 0..self.wavelengths_nm.len() {
530 let lam = self.wavelengths_nm[i];
531 let e = self.emissivity_at(lam, temp_k);
532 sum += e;
533 norm += 1.0;
534 }
535 if norm < 1e-30 { 0.0 } else { sum / norm }
536 }
537
538 fn interpolate(&self, lam_nm: f64) -> f64 {
539 let n = self.wavelengths_nm.len();
540 if n == 0 {
541 return 0.0;
542 }
543 if lam_nm <= self.wavelengths_nm[0] {
544 return self.emissivity[0];
545 }
546 if lam_nm >= self.wavelengths_nm[n - 1] {
547 return self.emissivity[n - 1];
548 }
549 for i in 1..n {
550 if lam_nm <= self.wavelengths_nm[i] {
551 let t = (lam_nm - self.wavelengths_nm[i - 1])
552 / (self.wavelengths_nm[i] - self.wavelengths_nm[i - 1]);
553 return self.emissivity[i - 1] * (1.0 - t) + self.emissivity[i] * t;
554 }
555 }
556 *self
557 .emissivity
558 .last()
559 .expect("collection should not be empty")
560 }
561}
562
563#[derive(Debug, Clone, Copy)]
572pub struct LuminescenceSpectrum {
573 pub peak_ev: f64,
575 pub fwhm_ev: f64,
577 pub quantum_yield: f64,
579}
580
581impl LuminescenceSpectrum {
582 pub fn new(peak_ev: f64, fwhm_ev: f64, quantum_yield: f64) -> Self {
584 Self {
585 peak_ev,
586 fwhm_ev: fwhm_ev.abs(),
587 quantum_yield: quantum_yield.clamp(0.0, 1.0),
588 }
589 }
590
591 pub fn intensity(&self, e_ev: f64) -> f64 {
595 let gamma = self.fwhm_ev / 2.0;
596 let denom = (e_ev - self.peak_ev).powi(2) + gamma * gamma;
597 self.quantum_yield * (gamma * gamma) / denom
598 }
599
600 pub fn sample(&self, e_min: f64, e_max: f64, n: usize) -> Vec<(f64, f64)> {
603 (0..n)
604 .map(|i| {
605 let e = e_min + (e_max - e_min) * (i as f64) / ((n - 1) as f64);
606 (e, self.intensity(e))
607 })
608 .collect()
609 }
610
611 pub fn integrated_intensity(&self, e_min: f64, e_max: f64, n: usize) -> f64 {
613 let pts = self.sample(e_min, e_max, n);
614 if pts.len() < 2 {
615 return 0.0;
616 }
617 let mut integral = 0.0_f64;
618 for i in 1..pts.len() {
619 let de = pts[i].0 - pts[i - 1].0;
620 integral += 0.5 * (pts[i].1 + pts[i - 1].1) * de;
621 }
622 integral
623 }
624}
625
626#[cfg(test)]
631mod tests {
632 use super::*;
633
634 const EPS: f64 = 1e-9;
635
636 #[test]
642 fn test_lossless_k_zero() {
643 let ni = RefractiveIndex::lossless(1.5);
644 assert_eq!(ni.k, 0.0);
645 assert_eq!(ni.n, 1.5);
646 }
647
648 #[test]
650 fn test_modulus() {
651 let ni = RefractiveIndex::new(3.0, 4.0);
652 assert!((ni.modulus() - 5.0).abs() < EPS);
653 }
654
655 #[test]
657 fn test_dielectric_function() {
658 let ni = RefractiveIndex::new(2.0, 1.0);
659 assert!((ni.epsilon_real() - 3.0).abs() < EPS);
660 assert!((ni.epsilon_imag() - 4.0).abs() < EPS);
661 }
662
663 #[test]
665 fn test_cauchy_long_wavelength() {
666 let n = RefractiveIndex::cauchy(1.5, 0.003, 0.0, 10.0);
668 assert!((n - 1.5).abs() < 0.001);
669 }
670
671 #[test]
673 fn test_cauchy_normal_dispersion() {
674 let n_red = RefractiveIndex::cauchy(1.5, 0.01, 0.0, 0.65);
675 let n_blue = RefractiveIndex::cauchy(1.5, 0.01, 0.0, 0.45);
676 assert!(n_blue > n_red, "Blue should refract more than red");
677 }
678
679 #[test]
681 fn test_epsilon_lossless() {
682 let ni = RefractiveIndex::lossless(1.5);
683 assert!((ni.epsilon_real() - 2.25).abs() < EPS);
684 assert!(ni.epsilon_imag().abs() < EPS);
685 }
686
687 #[test]
693 fn test_fresnel_normal_incidence() {
694 let n1 = 1.0;
695 let n2 = 1.5;
696 let fc = FresnelCoefficients::compute(n1, n2, 0.0).unwrap();
697 let expected = ((n1 - n2) / (n1 + n2)).powi(2);
698 assert!((fc.rs - expected).abs() < 1e-10);
699 assert!((fc.rp - expected).abs() < 1e-10);
700 }
701
702 #[test]
704 fn test_fresnel_energy_conservation() {
705 let fc = FresnelCoefficients::compute(1.0, 1.5, 0.0).unwrap();
706 assert!((fc.rs + fc.ts - 1.0).abs() < 1e-9);
707 assert!((fc.rp + fc.tp - 1.0).abs() < 1e-9);
708 }
709
710 #[test]
712 fn test_fresnel_tir_returns_none() {
713 let n1 = 1.5;
714 let n2 = 1.0;
715 let theta_c = TotalInternalReflection::critical_angle(n1, n2).unwrap();
716 let result = FresnelCoefficients::compute(n1, n2, theta_c + 0.1);
717 assert!(result.is_none(), "Should return None above critical angle");
718 }
719
720 #[test]
722 fn test_fresnel_unpolarised() {
723 let fc = FresnelCoefficients::compute(1.0, 1.5, 0.5).unwrap();
724 assert!((fc.r_unpolarised() - (fc.rs + fc.rp) / 2.0).abs() < EPS);
725 }
726
727 #[test]
733 fn test_brewster_air_glass() {
734 let theta_b_deg = BrewsterAngle::compute_deg(1.0, 1.5);
735 assert!(
736 (theta_b_deg - 56.31_f64).abs() < 0.02,
737 "Expected ~56.31°, got {theta_b_deg}"
738 );
739 }
740
741 #[test]
743 fn test_brewster_rp_zero() {
744 let n1 = 1.0;
745 let n2 = 1.5;
746 let theta_b = BrewsterAngle::compute(n1, n2);
747 let fc = FresnelCoefficients::compute(n1, n2, theta_b).unwrap();
748 assert!(
749 fc.rp < 1e-6,
750 "Rp should vanish at Brewster angle, got {}",
751 fc.rp
752 );
753 }
754
755 #[test]
757 fn test_brewster_equal_indices() {
758 let theta_b_deg = BrewsterAngle::compute_deg(1.5, 1.5);
759 assert!((theta_b_deg - 45.0).abs() < EPS);
760 }
761
762 #[test]
768 fn test_critical_angle_glass_air() {
769 let theta_c_deg = TotalInternalReflection::critical_angle_deg(1.5, 1.0).unwrap();
770 assert!(
771 (theta_c_deg - 41.81_f64).abs() < 0.02,
772 "Expected ~41.81°, got {theta_c_deg}"
773 );
774 }
775
776 #[test]
778 fn test_tir_impossible_denser_medium() {
779 assert!(TotalInternalReflection::critical_angle(1.0, 1.5).is_none());
780 }
781
782 #[test]
784 fn test_tir_below_critical() {
785 let tir = TotalInternalReflection;
786 let theta_c = TotalInternalReflection::critical_angle(1.5, 1.0).unwrap();
787 assert!(!tir.is_total(1.5, 1.0, theta_c - 0.1));
788 }
789
790 #[test]
792 fn test_tir_above_critical() {
793 let tir = TotalInternalReflection;
794 let theta_c = TotalInternalReflection::critical_angle(1.5, 1.0).unwrap();
795 assert!(tir.is_total(1.5, 1.0, theta_c + 0.1));
796 }
797
798 #[test]
804 fn test_beer_lambert_z0() {
805 let ac = AbsorptionCoefficient::new(1000.0);
806 assert!((ac.intensity(1.0, 0.0) - 1.0).abs() < EPS);
807 }
808
809 #[test]
811 fn test_beer_lambert_penetration_depth() {
812 let alpha = 500.0;
813 let ac = AbsorptionCoefficient::new(alpha);
814 let i = ac.intensity(1.0, ac.penetration_depth());
815 assert!((i - 1.0 / std::f64::consts::E).abs() < 1e-10);
816 }
817
818 #[test]
820 fn test_absorption_from_extinction() {
821 let k = 0.1;
822 let lam = 500e-9; let ac = AbsorptionCoefficient::from_extinction(k, lam);
824 let expected = 4.0 * PI * k / lam;
825 assert!((ac.alpha - expected).abs() < 1e-3 * expected);
826 }
827
828 #[test]
830 fn test_absorbance() {
831 let ac = AbsorptionCoefficient::new(200.0);
832 assert!((ac.absorbance(0.005) - 1.0).abs() < EPS);
833 }
834
835 #[test]
841 fn test_transmittance_reflectance_transparent() {
842 let tr = TransmittanceReflectance::transparent(0.04);
843 assert!((tr.reflectance + tr.transmittance - 1.0).abs() < EPS);
844 assert!(tr.absorptance.abs() < EPS);
845 }
846
847 #[test]
849 fn test_transmittance_energy_conservation() {
850 let tr = TransmittanceReflectance::from_fresnel_and_beer(0.04, 1000.0, 1e-3);
851 assert!((tr.reflectance + tr.transmittance + tr.absorptance - 1.0).abs() < 1e-10);
852 }
853
854 #[test]
856 fn test_fully_transparent() {
857 let tr = TransmittanceReflectance::from_fresnel_and_beer(0.0, 0.0, 1.0);
858 assert!((tr.transmittance - 1.0).abs() < EPS);
859 }
860
861 #[test]
867 fn test_peak_wavelength() {
868 let lams = vec![450.0, 550.0, 650.0];
869 let power = vec![0.3, 1.0, 0.2];
870 let cs = ColorFromSpectrum::new(lams, power);
871 assert!((cs.peak_wavelength() - 550.0).abs() < EPS);
872 }
873
874 #[test]
880 fn test_green_source_dominant() {
881 let lams: Vec<f64> = (380..=780).step_by(5).map(|l| l as f64).collect();
883 let power: Vec<f64> = lams
884 .iter()
885 .map(|&l| if (l - 530.0).abs() < 6.0 { 1.0 } else { 0.0 })
886 .collect();
887 let cs = ColorFromSpectrum::new(lams, power);
888 let (r, g, _b) = cs.to_srgb_linear();
889 assert!(
892 r + g > 0.0,
893 "Green source should produce non-zero R+G: r={r}, g={g}"
894 );
895 }
896
897 #[test]
899 fn test_xyz_y_nonneg() {
900 let lams = vec![450.0, 550.0, 650.0];
901 let power = vec![0.5, 0.8, 0.3];
902 let cs = ColorFromSpectrum::new(lams, power);
903 let (_x, y, _z) = cs.to_xyz();
904 assert!(y >= 0.0);
905 }
906
907 #[test]
913 fn test_tauc_length() {
914 let e = vec![1.5, 1.8, 2.1, 2.4, 2.7];
915 let a = vec![0.0, 1e5, 5e5, 2e6, 5e6];
916 let bg = OpticalBandgap::new(e, a);
917 assert_eq!(bg.tauc_direct().len(), 5);
918 }
919
920 #[test]
922 fn test_tauc_zero_alpha() {
923 let e = vec![1.0, 2.0, 3.0, 4.0];
924 let a = vec![0.0, 0.0, 1e6, 2e6];
925 let bg = OpticalBandgap::new(e, a);
926 let pts = bg.tauc_direct();
927 assert!(pts[0].1.abs() < EPS);
928 assert!(pts[1].1.abs() < EPS);
929 }
930
931 #[test]
933 fn test_from_wavelengths_energy_conversion() {
934 let lams = vec![500.0];
935 let alphas = vec![1e6];
936 let bg = OpticalBandgap::from_wavelengths(&lams, &alphas);
937 assert!((bg.energies_ev[0] - 2.48).abs() < 0.05);
938 }
939
940 #[test]
942 fn test_bandgap_estimate_range() {
943 let e: Vec<f64> = (10..=30).map(|i| 1.0 + i as f64 * 0.1).collect();
945 let a: Vec<f64> = e
946 .iter()
947 .map(|&ev| {
948 if ev > 1.42 {
949 (ev - 1.42).sqrt() * 1e6
950 } else {
951 0.0
952 }
953 })
954 .collect();
955 let bg = OpticalBandgap::new(e.clone(), a);
956 let eg = bg.estimate_bandgap().unwrap();
957 assert!(eg > *e.first().unwrap(), "Bandgap below energy range");
958 assert!(eg < *e.last().unwrap(), "Bandgap above energy range");
959 }
960
961 #[test]
967 fn test_emissivity_exact_tabulated() {
968 let em = EmissivityModel::new(vec![400.0, 600.0, 800.0], vec![0.4, 0.6, 0.8], 0.0, 300.0);
969 assert!((em.emissivity_at(600.0, 300.0) - 0.6).abs() < 1e-10);
970 }
971
972 #[test]
974 fn test_emissivity_temp_correction() {
975 let em = EmissivityModel::new(
976 vec![500.0],
977 vec![0.5],
978 1e-3, 300.0,
980 );
981 let e_hot = em.emissivity_at(500.0, 400.0); assert!((e_hot - 0.55).abs() < 1e-10);
983 }
984
985 #[test]
987 fn test_total_emissivity_bounded() {
988 let em = EmissivityModel::new(
989 vec![400.0, 500.0, 600.0, 700.0],
990 vec![0.3, 0.5, 0.7, 0.9],
991 0.0,
992 300.0,
993 );
994 let e_total = em.total_emissivity(300.0);
995 assert!((0.0..=1.0).contains(&e_total));
996 }
997
998 #[test]
1000 fn test_emissivity_clamped_upper() {
1001 let em = EmissivityModel::new(vec![500.0], vec![0.9], 0.1, 300.0);
1002 let e = em.emissivity_at(500.0, 500.0); assert!(e <= 1.0, "Emissivity must not exceed 1");
1004 }
1005
1006 #[test]
1012 fn test_luminescence_peak_value() {
1013 let pl = LuminescenceSpectrum::new(2.0, 0.1, 0.8);
1014 let i = pl.intensity(2.0);
1015 assert!((i - 0.8).abs() < EPS);
1016 }
1017
1018 #[test]
1020 fn test_luminescence_lorentzian_shape() {
1021 let pl = LuminescenceSpectrum::new(2.0, 0.2, 1.0);
1022 let i_peak = pl.intensity(2.0);
1023 let i_wing = pl.intensity(2.0 + 0.2);
1024 assert!(i_peak > i_wing, "Peak should be higher than wing");
1026 }
1027
1028 #[test]
1030 fn test_luminescence_half_maximum() {
1031 let fwhm = 0.1;
1032 let pl = LuminescenceSpectrum::new(2.0, fwhm, 1.0);
1033 let i_hm = pl.intensity(2.0 + fwhm / 2.0);
1034 assert!((i_hm - 0.5).abs() < 1e-10);
1035 }
1036
1037 #[test]
1039 fn test_luminescence_quantum_yield_clamp() {
1040 let pl = LuminescenceSpectrum::new(2.0, 0.1, 1.5);
1041 assert_eq!(pl.quantum_yield, 1.0);
1042 let pl2 = LuminescenceSpectrum::new(2.0, 0.1, -0.5);
1043 assert_eq!(pl2.quantum_yield, 0.0);
1044 }
1045
1046 #[test]
1048 fn test_luminescence_sample_count() {
1049 let pl = LuminescenceSpectrum::new(2.0, 0.1, 0.5);
1050 let pts = pl.sample(1.5, 2.5, 101);
1051 assert_eq!(pts.len(), 101);
1052 }
1053
1054 #[test]
1056 fn test_luminescence_integrated_positive() {
1057 let pl = LuminescenceSpectrum::new(2.0, 0.1, 0.8);
1058 let integral = pl.integrated_intensity(1.5, 2.5, 1001);
1059 assert!(integral > 0.0);
1060 }
1061
1062 #[test]
1064 fn test_luminescence_symmetry() {
1065 let pl = LuminescenceSpectrum::new(2.0, 0.2, 1.0);
1066 for delta in [0.05, 0.1, 0.15] {
1067 let left = pl.intensity(2.0 - delta);
1068 let right = pl.intensity(2.0 + delta);
1069 assert!((left - right).abs() < EPS, "Lorentzian must be symmetric");
1070 }
1071 }
1072
1073 #[test]
1079 fn test_fresnel_glancing_incidence() {
1080 let angle = PI / 2.0 - 1e-4; if let Some(fc) = FresnelCoefficients::compute(1.0, 1.5, angle) {
1082 assert!(fc.rs > 0.99, "Rs should approach 1 at glancing incidence");
1083 }
1084 }
1085
1086 #[test]
1088 fn test_beer_lambert_monotone() {
1089 let ac = AbsorptionCoefficient::new(300.0);
1090 let depths = [0.0, 0.001, 0.002, 0.005, 0.01];
1091 let intensities: Vec<f64> = depths.iter().map(|&z| ac.intensity(1.0, z)).collect();
1092 for pair in intensities.windows(2) {
1093 assert!(pair[0] > pair[1], "Intensity must decrease with depth");
1094 }
1095 }
1096
1097 #[test]
1099 fn test_cie_cmf_nonneg() {
1100 for lam in (380..=780).step_by(10) {
1101 let (x, y, z) = cie_cmf(lam as f64);
1102 assert!(
1103 x >= 0.0 && y >= 0.0 && z >= 0.0,
1104 "CMF must be non-negative at {lam} nm"
1105 );
1106 }
1107 }
1108
1109 #[test]
1111 fn test_refractive_index_new() {
1112 let ni = RefractiveIndex::new(2.5, 0.3);
1113 assert_eq!(ni.n, 2.5);
1114 assert_eq!(ni.k, 0.3);
1115 }
1116
1117 #[test]
1119 fn test_cauchy_constant() {
1120 for lam in [0.4, 0.5, 0.6, 0.7] {
1121 let n = RefractiveIndex::cauchy(1.45, 0.0, 0.0, lam);
1122 assert!((n - 1.45).abs() < EPS);
1123 }
1124 }
1125
1126 #[test]
1128 fn test_emissivity_boundary_left() {
1129 let em = EmissivityModel::new(vec![400.0, 600.0], vec![0.3, 0.7], 0.0, 300.0);
1130 assert!((em.emissivity_at(350.0, 300.0) - 0.3).abs() < EPS);
1131 }
1132
1133 #[test]
1135 fn test_emissivity_interpolation_midpoint() {
1136 let em = EmissivityModel::new(vec![400.0, 600.0], vec![0.2, 0.4], 0.0, 300.0);
1137 assert!((em.emissivity_at(500.0, 300.0) - 0.3).abs() < 1e-10);
1138 }
1139
1140 #[test]
1142 fn test_fresnel_no_interface() {
1143 let fc = FresnelCoefficients::compute(1.5, 1.5, 0.0).unwrap();
1144 assert!(fc.rs.abs() < EPS);
1145 assert!((fc.ts - 1.0).abs() < EPS);
1146 }
1147}