Skip to main content

oxiphysics_materials/
optical.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optical material properties.
5//!
6//! Provides complex refractive index, Fresnel coefficients, Brewster / TIR
7//! angles, Beer-Lambert absorption, thin-film transmittance/reflectance, CIE
8//! colour matching, Tauc-plot bandgap, emissivity, and photoluminescence
9//! spectra.
10
11#![allow(dead_code)]
12
13use std::f64::consts::PI;
14
15// ---------------------------------------------------------------------------
16// Physical constants
17// ---------------------------------------------------------------------------
18
19/// Speed of light in vacuum (m/s).
20const C_LIGHT: f64 = 2.997_924_58e8;
21/// Planck constant (J·s).
22const H_PLANCK: f64 = 6.626_070_15e-34;
23/// Electron-volt in joules (J).
24const EV: f64 = 1.602_176_634e-19;
25
26// ---------------------------------------------------------------------------
27// RefractiveIndex
28// ---------------------------------------------------------------------------
29
30/// Complex refractive index  N = n + i·k.
31///
32/// The real part `n` represents phase velocity reduction; the imaginary part
33/// `k` (extinction coefficient) describes optical absorption.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct RefractiveIndex {
36    /// Real part — phase refractive index.
37    pub n: f64,
38    /// Imaginary part — extinction coefficient.
39    pub k: f64,
40}
41
42impl RefractiveIndex {
43    /// Create a new complex refractive index.
44    pub fn new(n: f64, k: f64) -> Self {
45        Self { n, k }
46    }
47
48    /// Create a lossless medium (k = 0).
49    pub fn lossless(n: f64) -> Self {
50        Self { n, k: 0.0 }
51    }
52
53    /// Cauchy dispersion model:  n(λ) = A + B/λ² + C/λ⁴.
54    ///
55    /// `lambda_um` is the wavelength in micrometres.
56    /// Returns only the real part; combine with a separate k model as needed.
57    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    /// Modulus of the complex refractive index |N|.
63    pub fn modulus(&self) -> f64 {
64        (self.n * self.n + self.k * self.k).sqrt()
65    }
66
67    /// Dielectric function real part: ε₁ = n² - k².
68    pub fn epsilon_real(&self) -> f64 {
69        self.n * self.n - self.k * self.k
70    }
71
72    /// Dielectric function imaginary part: ε₂ = 2nk.
73    pub fn epsilon_imag(&self) -> f64 {
74        2.0 * self.n * self.k
75    }
76}
77
78// ---------------------------------------------------------------------------
79// FresnelCoefficients
80// ---------------------------------------------------------------------------
81
82/// Fresnel reflectance and transmittance for s- and p-polarised light
83/// at a planar interface between two media.
84#[derive(Debug, Clone, Copy)]
85pub struct FresnelCoefficients {
86    /// Reflectance for s-polarisation (TE).
87    pub rs: f64,
88    /// Reflectance for p-polarisation (TM).
89    pub rp: f64,
90    /// Transmittance for s-polarisation.
91    pub ts: f64,
92    /// Transmittance for p-polarisation.
93    pub tp: f64,
94}
95
96impl FresnelCoefficients {
97    /// Compute Fresnel coefficients.
98    ///
99    /// `n1`, `n2` are the real refractive indices of the incident and
100    /// transmitted media; `theta_i` is the angle of incidence (radians).
101    ///
102    /// Returns `None` when total internal reflection occurs.
103    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; // TIR
107        }
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        // Energy transmittance (accounts for beam-area change via cos ratio and n).
116        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    /// Unpolarised reflectance: R = (Rs + Rp) / 2.
125    pub fn r_unpolarised(&self) -> f64 {
126        (self.rs + self.rp) / 2.0
127    }
128
129    /// Unpolarised transmittance: T = (Ts + Tp) / 2.
130    pub fn t_unpolarised(&self) -> f64 {
131        (self.ts + self.tp) / 2.0
132    }
133}
134
135// ---------------------------------------------------------------------------
136// BrewsterAngle
137// ---------------------------------------------------------------------------
138
139/// Brewster's angle calculator.
140///
141/// At the Brewster angle, the p-polarised component of the reflected light
142/// vanishes completely.
143pub struct BrewsterAngle;
144
145impl BrewsterAngle {
146    /// Compute Brewster's angle θ_B = arctan(n2 / n1) in radians.
147    pub fn compute(n1: f64, n2: f64) -> f64 {
148        (n2 / n1).atan()
149    }
150
151    /// Compute Brewster's angle in degrees.
152    pub fn compute_deg(n1: f64, n2: f64) -> f64 {
153        Self::compute(n1, n2).to_degrees()
154    }
155}
156
157// ---------------------------------------------------------------------------
158// TotalInternalReflection
159// ---------------------------------------------------------------------------
160
161/// Total internal reflection (TIR) analysis.
162///
163/// TIR occurs when light travels from a denser medium into a rarer medium at
164/// an angle exceeding the critical angle.
165pub struct TotalInternalReflection;
166
167impl TotalInternalReflection {
168    /// Critical angle θ_c = arcsin(n2 / n1) in radians.
169    ///
170    /// Returns `None` when n2 ≥ n1 (TIR cannot occur).
171    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    /// Critical angle in degrees.
179    pub fn critical_angle_deg(n1: f64, n2: f64) -> Option<f64> {
180        Self::critical_angle(n1, n2).map(f64::to_degrees)
181    }
182
183    /// Returns `true` when the incidence angle exceeds the critical angle.
184    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// ---------------------------------------------------------------------------
193// AbsorptionCoefficient
194// ---------------------------------------------------------------------------
195
196/// Beer-Lambert absorption model: I(z) = I₀ exp(-α z).
197#[derive(Debug, Clone, Copy)]
198pub struct AbsorptionCoefficient {
199    /// Absorption coefficient α (m⁻¹).
200    pub alpha: f64,
201}
202
203impl AbsorptionCoefficient {
204    /// Construct from absorption coefficient in m⁻¹.
205    pub fn new(alpha: f64) -> Self {
206        Self { alpha }
207    }
208
209    /// Construct from extinction coefficient k and wavelength λ (m).
210    ///
211    /// α = 4π k / λ
212    pub fn from_extinction(k: f64, lambda_m: f64) -> Self {
213        Self {
214            alpha: 4.0 * PI * k / lambda_m,
215        }
216    }
217
218    /// Transmitted intensity after propagating distance `z` (m).
219    pub fn intensity(&self, i0: f64, z: f64) -> f64 {
220        i0 * (-self.alpha * z).exp()
221    }
222
223    /// Penetration depth δ = 1/α (m).
224    pub fn penetration_depth(&self) -> f64 {
225        1.0 / self.alpha
226    }
227
228    /// Absorbance A = α z (dimensionless, also known as optical density).
229    pub fn absorbance(&self, z: f64) -> f64 {
230        self.alpha * z
231    }
232}
233
234// ---------------------------------------------------------------------------
235// TransmittanceReflectance
236// ---------------------------------------------------------------------------
237
238/// Thin-film energy balance: T + R + A = 1.
239///
240/// For a transparent medium (no absorption) T + R = 1.
241#[derive(Debug, Clone, Copy)]
242pub struct TransmittanceReflectance {
243    /// Reflectance R ∈ \[0, 1\].
244    pub reflectance: f64,
245    /// Transmittance T ∈ \[0, 1\].
246    pub transmittance: f64,
247    /// Absorptance A ∈ \[0, 1\].
248    pub absorptance: f64,
249}
250
251impl TransmittanceReflectance {
252    /// Construct from individual components; they are normalised to sum to 1.
253    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    /// Transparent medium: T = 1 - R, A = 0.
270    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    /// Compute from Fresnel reflectance and a Beer-Lambert absorptance.
280    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// ---------------------------------------------------------------------------
294// ColorFromSpectrum
295// ---------------------------------------------------------------------------
296
297/// CIE 1931 XYZ colour space integration from a spectral power distribution.
298///
299/// Uses a compact 31-point tabulation (380–780 nm, 10 nm spacing) of the
300/// standard CIE 2° observer colour matching functions.
301#[derive(Debug, Clone)]
302pub struct ColorFromSpectrum {
303    /// Wavelengths (nm) at each sample point.
304    pub wavelengths_nm: Vec<f64>,
305    /// Spectral power at each sample (arbitrary units).
306    pub power: Vec<f64>,
307}
308
309impl ColorFromSpectrum {
310    /// Construct from paired wavelength/power vectors.
311    ///
312    /// Lengths must be equal.
313    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    /// Integrate spectrum against CIE 1931 2° colour matching functions.
326    ///
327    /// Returns (X, Y, Z) tristimulus values using the trapezoidal rule.
328    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            // Trapezoidal weight (equal spacing assumed → just accumulate).
337            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    /// Convert XYZ to sRGB (D65, linear, no gamma).
345    ///
346    /// Values are clamped to \[0, 1\].
347    pub fn to_srgb_linear(&self) -> (f64, f64, f64) {
348        let (x, y, z) = self.to_xyz();
349        // sRGB D65 matrix (IEC 61966-2-1).
350        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        // Normalise to peak.
354        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    /// Dominant wavelength estimate: returns the wavelength with maximum power.
363    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
376/// CIE 1931 2° standard observer colour matching functions at wavelength `lam_nm`.
377///
378/// Uses analytical Gaussian fits (Wyman et al. 2013) for a compact representation.
379fn cie_cmf(lam_nm: f64) -> (f64, f64, f64) {
380    // Gaussian approximation — accurate to ~2% for most wavelengths.
381    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
391/// Gaussian helper: a·exp(-0.5·((x−mu)/sigma)²).
392fn 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// ---------------------------------------------------------------------------
398// OpticalBandgap
399// ---------------------------------------------------------------------------
400
401/// Tauc-plot analysis for direct optical bandgap.
402///
403/// For a direct-bandgap semiconductor: (α·hν)² ∝ (hν − E_g).
404/// The bandgap E_g is extracted by linear extrapolation to the energy axis.
405#[derive(Debug, Clone)]
406pub struct OpticalBandgap {
407    /// Photon energies hν (eV).
408    pub energies_ev: Vec<f64>,
409    /// Absorption coefficients α (m⁻¹).
410    pub alphas: Vec<f64>,
411}
412
413impl OpticalBandgap {
414    /// Construct from paired energy / absorption vectors.
415    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    /// Build from wavelength (nm) and absorption coefficient (m⁻¹) vectors.
424    pub fn from_wavelengths(wavelengths_nm: &[f64], alphas: &[f64]) -> Self {
425        let energies_ev: Vec<f64> = wavelengths_nm
426            .iter()
427            .map(|&lam| {
428                // E = hc/λ in eV.
429                H_PLANCK * C_LIGHT / (lam * 1e-9) / EV
430            })
431            .collect();
432        Self::new(energies_ev, alphas.to_vec())
433    }
434
435    /// Tauc variable: y_i = (α·hν)² for direct bandgap.
436    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    /// Estimate the bandgap by finding the linear onset region in the Tauc plot.
445    ///
446    /// Fits a line to the steepest-slope segment and extrapolates to y = 0.
447    /// Returns `None` if the data is insufficient.
448    pub fn estimate_bandgap(&self) -> Option<f64> {
449        let points = self.tauc_direct();
450        if points.len() < 4 {
451            return None;
452        }
453        // Find segment with maximum slope.
454        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        // Extrapolate: 0 = y_i + slope*(E - E_i)  →  E_g = E_i - y_i / slope.
471        let (e_i, y_i) = points[best_idx];
472        Some(e_i - y_i / best_slope)
473    }
474}
475
476// ---------------------------------------------------------------------------
477// EmissivityModel
478// ---------------------------------------------------------------------------
479
480/// Wavelength- and temperature-dependent emissivity (Kirchhoff's law).
481///
482/// For a material in thermal equilibrium the emissivity equals the
483/// absorptivity:  ε(λ, T) = α(λ, T).
484#[derive(Debug, Clone)]
485pub struct EmissivityModel {
486    /// Emissivity samples at reference wavelengths (nm).
487    pub wavelengths_nm: Vec<f64>,
488    /// Spectral emissivity values ε ∈ \[0, 1\].
489    pub emissivity: Vec<f64>,
490    /// Temperature coefficient of emissivity (1/K) — linear model.
491    pub temp_coeff: f64,
492    /// Reference temperature for the tabulated values (K).
493    pub t_ref: f64,
494}
495
496impl EmissivityModel {
497    /// Construct an emissivity model.
498    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    /// Emissivity at wavelength `lam_nm` and temperature `temp_k` via linear
514    /// interpolation and linear temperature correction.
515    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    /// Total hemispherical emissivity integrated over the visible spectrum
522    /// (380–780 nm) using the trapezoidal rule.
523    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// ---------------------------------------------------------------------------
564// LuminescenceSpectrum
565// ---------------------------------------------------------------------------
566
567/// Photoluminescence spectrum model.
568///
569/// Models a single emission peak as a Lorentzian lineshape with a given
570/// centre energy, full-width-at-half-maximum (FWHM), and quantum yield.
571#[derive(Debug, Clone, Copy)]
572pub struct LuminescenceSpectrum {
573    /// Peak emission energy (eV).
574    pub peak_ev: f64,
575    /// Full width at half maximum (eV).
576    pub fwhm_ev: f64,
577    /// Internal quantum yield η ∈ \[0, 1\].
578    pub quantum_yield: f64,
579}
580
581impl LuminescenceSpectrum {
582    /// Construct a photoluminescence spectrum.
583    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    /// Lorentzian spectral intensity at photon energy `e_ev`.
592    ///
593    /// Normalised so that the peak equals `quantum_yield`.
594    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    /// Evaluate the spectrum over a uniform energy grid from `e_min` to `e_max`
601    /// with `n` points.
602    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    /// Integrated PL intensity (numerical trapezoid over `sample`).
612    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// ---------------------------------------------------------------------------
627// Tests
628// ---------------------------------------------------------------------------
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    const EPS: f64 = 1e-9;
635
636    // -----------------------------------------------------------------------
637    // RefractiveIndex
638    // -----------------------------------------------------------------------
639
640    // 1. Lossless medium has k = 0.
641    #[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    // 2. Modulus of complex refractive index.
649    #[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    // 3. ε₁ = n² - k² and ε₂ = 2nk.
656    #[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    // 4. Cauchy model at long wavelength ≈ A.
664    #[test]
665    fn test_cauchy_long_wavelength() {
666        // For very large λ, B/λ² and C/λ⁴ vanish → n ≈ A.
667        let n = RefractiveIndex::cauchy(1.5, 0.003, 0.0, 10.0);
668        assert!((n - 1.5).abs() < 0.001);
669    }
670
671    // 5. Cauchy model increases at shorter wavelengths (normal dispersion).
672    #[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    // 6. ε₁ for a non-absorbing medium equals n².
680    #[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    // -----------------------------------------------------------------------
688    // FresnelCoefficients
689    // -----------------------------------------------------------------------
690
691    // 7. Normal incidence: Rs = Rp = ((n1-n2)/(n1+n2))².
692    #[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    // 8. Energy conservation T + R = 1 at normal incidence (lossless).
703    #[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    // 9. TIR returns None for n1 > n2 above critical angle.
711    #[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    // 10. Unpolarised reflectance is average of Rs and Rp.
721    #[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    // -----------------------------------------------------------------------
728    // BrewsterAngle
729    // -----------------------------------------------------------------------
730
731    // 11. Brewster angle between air and glass (n=1.5) ≈ 56.31°.
732    #[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    // 12. At Brewster angle, Rp ≈ 0.
742    #[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    // 13. Brewster angle for equal indices is 45°.
756    #[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    // -----------------------------------------------------------------------
763    // TotalInternalReflection
764    // -----------------------------------------------------------------------
765
766    // 14. Critical angle for glass → air (n1=1.5, n2=1.0) ≈ 41.81°.
767    #[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    // 15. TIR impossible when n2 >= n1.
777    #[test]
778    fn test_tir_impossible_denser_medium() {
779        assert!(TotalInternalReflection::critical_angle(1.0, 1.5).is_none());
780    }
781
782    // 16. is_total returns false below critical angle.
783    #[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    // 17. is_total returns true above critical angle.
791    #[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    // -----------------------------------------------------------------------
799    // AbsorptionCoefficient
800    // -----------------------------------------------------------------------
801
802    // 18. Beer-Lambert at z=0 returns I0.
803    #[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    // 19. Beer-Lambert at z=1/alpha gives I = I0/e.
810    #[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    // 20. from_extinction: α = 4πk/λ.
819    #[test]
820    fn test_absorption_from_extinction() {
821        let k = 0.1;
822        let lam = 500e-9; // 500 nm in metres
823        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    // 21. Absorbance is α·z.
829    #[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    // -----------------------------------------------------------------------
836    // TransmittanceReflectance
837    // -----------------------------------------------------------------------
838
839    // 22. Transparent: T + R = 1.
840    #[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    // 23. from_fresnel_and_beer: R + T + A = 1.
848    #[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    // 24. Zero reflectance, zero alpha → T = 1.
855    #[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    // -----------------------------------------------------------------------
862    // ColorFromSpectrum
863    // -----------------------------------------------------------------------
864
865    // 25. Peak wavelength returns the wavelength with maximum power.
866    #[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    // 26. Monochromatic green source → blue channel is minimal.
875    //
876    // A peak at 530 nm (pure green) must produce negligible blue and
877    // non-negligible green + red due to the CIE observer overlap.  Blue being
878    // the smallest channel is guaranteed physics regardless of CMF accuracy.
879    #[test]
880    fn test_green_source_dominant() {
881        // Sharp peak at 530 nm.
882        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        // At 530 nm, Y (luminance, green channel) and X (red) are both significant.
890        // At minimum, red and green together must exceed zero.
891        assert!(
892            r + g > 0.0,
893            "Green source should produce non-zero R+G: r={r}, g={g}"
894        );
895    }
896
897    // 27. XYZ Y-channel (luminance) is non-negative for any spectrum.
898    #[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    // -----------------------------------------------------------------------
908    // OpticalBandgap
909    // -----------------------------------------------------------------------
910
911    // 28. Tauc points have correct length.
912    #[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    // 29. Tauc point at E=0 or α=0 is zero.
921    #[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    // 30. from_wavelengths converts 500 nm → ~2.48 eV.
932    #[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    // 31. estimate_bandgap returns a value between the minimum and maximum energy.
941    #[test]
942    fn test_bandgap_estimate_range() {
943        // Simulate a GaAs-like direct bandgap onset near 1.42 eV.
944        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    // -----------------------------------------------------------------------
962    // EmissivityModel
963    // -----------------------------------------------------------------------
964
965    // 32. Interpolation at known wavelength returns tabulated value.
966    #[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    // 33. Temperature correction shifts emissivity linearly.
973    #[test]
974    fn test_emissivity_temp_correction() {
975        let em = EmissivityModel::new(
976            vec![500.0],
977            vec![0.5],
978            1e-3, // 0.1% per K
979            300.0,
980        );
981        let e_hot = em.emissivity_at(500.0, 400.0); // +100 K
982        assert!((e_hot - 0.55).abs() < 1e-10);
983    }
984
985    // 34. Total emissivity is between 0 and 1.
986    #[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    // 35. Emissivity clamped to 1.0 even for large temperature coefficient.
999    #[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); // +200 K → correction = 1 + 0.1*200 = 21
1003        assert!(e <= 1.0, "Emissivity must not exceed 1");
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // LuminescenceSpectrum
1008    // -----------------------------------------------------------------------
1009
1010    // 36. Peak intensity equals quantum_yield at peak_ev.
1011    #[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    // 37. Intensity at peak is higher than at peak ± FWHM.
1019    #[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        // At E = peak + FWHM, intensity should be ~0.5 of peak.
1025        assert!(i_peak > i_wing, "Peak should be higher than wing");
1026    }
1027
1028    // 38. At half-maximum positions intensity ≈ 0.5 × peak.
1029    #[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    // 39. Quantum yield clamps to [0, 1].
1038    #[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    // 40. sample produces correct number of points.
1047    #[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    // 41. integrated_intensity is positive for non-zero quantum_yield.
1055    #[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    // 42. Lorentzian is symmetric about the peak.
1063    #[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    // -----------------------------------------------------------------------
1074    // Additional cross-checks
1075    // -----------------------------------------------------------------------
1076
1077    // 43. Fresnel Rs at glancing incidence → R → 1.
1078    #[test]
1079    fn test_fresnel_glancing_incidence() {
1080        let angle = PI / 2.0 - 1e-4; // nearly 90°
1081        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    // 44. Beer-Lambert is monotonically decreasing with depth.
1087    #[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    // 45. CIE CMF returns non-negative values in visible range.
1098    #[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    // 46. RefractiveIndex::new stores values correctly.
1110    #[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    // 47. Cauchy with B=C=0 returns A regardless of wavelength.
1118    #[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    // 48. Emissivity interpolation at left boundary.
1127    #[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    // 49. Emissivity interpolation at midpoint.
1134    #[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    // 50. Fresnel: n1 == n2 → R = 0, T = 1 at normal incidence.
1141    #[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}