Skip to main content

oxiphysics_materials/
optical_materials.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optical materials — refractive index, photonics, and nonlinear optics.
5//!
6//! Provides:
7//! - Complex refractive index, Fresnel equations, Brewster/critical angles
8//! - Sellmeier and Cauchy dispersion, Abbe number
9//! - Group index, chromatic dispersion
10//! - Anti-reflection coatings, Fabry-Perot etalon
11//! - Birefringence, optical rotation (chirality)
12//! - Beer-Lambert absorption, photoelasticity
13//! - Electro-optic (Pockels/Kerr) effects
14//! - Nonlinear optics (χ² SHG, χ³ SPM/XPM)
15//! - Photoluminescence quantum yield
16//! - Optical bandgap via Tauc plot
17//! - Drude dielectric function
18//! - Metamaterial effective medium (Maxwell Garnett)
19//! - Photonic crystal bandgap estimation
20
21#![allow(dead_code)]
22#![allow(clippy::too_many_arguments)]
23
24use std::f64::consts::PI;
25
26/// Speed of light in vacuum \[m/s\].
27const SPEED_OF_LIGHT: f64 = 2.997_924_58e8;
28
29/// Vacuum permeability μ₀ \[H/m\].
30const MU_0: f64 = 1.256_637_061_4e-6;
31
32/// Vacuum permittivity ε₀ \[F/m\].
33const EPSILON_0: f64 = 8.854_187_817e-12;
34
35/// Planck constant \[J·s\].
36const PLANCK_H: f64 = 6.626_070_15e-34;
37
38/// Elementary charge \[C\].
39const ELEM_CHARGE: f64 = 1.602_176_634e-19;
40
41// ─────────────────────────────────────────────────────────────────────────────
42// OpticalMaterial
43// ─────────────────────────────────────────────────────────────────────────────
44
45/// Optical material characterised by its complex refractive index ñ = n + i·k.
46///
47/// * `n_real` – Real part of refractive index (phase velocity ratio).
48/// * `n_imag` – Imaginary part k (extinction coefficient, ≥ 0 for absorbing media).
49/// * `wavelength_nm` – Wavelength of light in vacuum \[nm\].
50#[derive(Debug, Clone, Copy)]
51pub struct OpticalMaterial {
52    /// Real part of the complex refractive index n.
53    pub n_real: f64,
54    /// Imaginary part (extinction coefficient) k ≥ 0.
55    pub n_imag: f64,
56    /// Vacuum wavelength \[nm\].
57    pub wavelength_nm: f64,
58}
59
60impl OpticalMaterial {
61    /// Create a new [`OpticalMaterial`] with complex index n + ik at wavelength λ.
62    pub fn new(n: f64, k: f64, wl_nm: f64) -> Self {
63        Self {
64            n_real: n,
65            n_imag: k,
66            wavelength_nm: wl_nm,
67        }
68    }
69
70    /// Absorption coefficient α \[m⁻¹\].
71    ///
72    /// α = 4π·k / λ  where λ is in metres.
73    pub fn absorption_coefficient(&self) -> f64 {
74        4.0 * PI * self.n_imag / (self.wavelength_nm * 1e-9)
75    }
76
77    /// Normal-incidence reflectance R from vacuum into this material.
78    ///
79    /// R = ((n−1)² + k²) / ((n+1)² + k²)
80    pub fn reflectance_normal(&self) -> f64 {
81        let n = self.n_real;
82        let k = self.n_imag;
83        let num = (n - 1.0) * (n - 1.0) + k * k;
84        let den = (n + 1.0) * (n + 1.0) + k * k;
85        num / den
86    }
87
88    /// Optical penetration (skin) depth \[m\].
89    ///
90    /// δ = λ / (4π·k)  (wavelength in vacuum in metres).
91    /// Returns infinity when k = 0 (non-absorbing material).
92    pub fn penetration_depth(&self) -> f64 {
93        if self.n_imag == 0.0 {
94            f64::INFINITY
95        } else {
96            self.wavelength_nm * 1e-9 / (4.0 * PI * self.n_imag)
97        }
98    }
99
100    /// Optical impedance Z \[Ω\] (magnitude).
101    ///
102    /// Z = Z₀ / n_real  where Z₀ ≈ 376.73 Ω is the free-space impedance.
103    pub fn optical_impedance(&self) -> f64 {
104        376.730_313_412 / self.n_real
105    }
106}
107
108// ─────────────────────────────────────────────────────────────────────────────
109// Fresnel equations
110// ─────────────────────────────────────────────────────────────────────────────
111
112/// Fresnel amplitude reflection coefficient for s-polarisation.
113///
114/// r_s = (n₁·cos θᵢ − n₂·cos θₜ) / (n₁·cos θᵢ + n₂·cos θₜ)
115///
116/// # Arguments
117/// * `n1` – Refractive index of incident medium.
118/// * `n2` – Refractive index of transmitted medium.
119/// * `theta_i` – Angle of incidence \[rad\].
120pub fn fresnel_rs(n1: f64, n2: f64, theta_i: f64) -> f64 {
121    let cos_i = theta_i.cos();
122    let sin_i = theta_i.sin();
123    let sin_t_sq = (n1 / n2 * sin_i) * (n1 / n2 * sin_i);
124    if sin_t_sq > 1.0 {
125        // Total internal reflection: magnitude is 1, return -1 for phase tracking.
126        return -1.0;
127    }
128    let cos_t = (1.0 - sin_t_sq).sqrt();
129    (n1 * cos_i - n2 * cos_t) / (n1 * cos_i + n2 * cos_t)
130}
131
132/// Fresnel amplitude reflection coefficient for p-polarisation.
133///
134/// r_p = (n₂·cos θᵢ − n₁·cos θₜ) / (n₂·cos θᵢ + n₁·cos θₜ)
135///
136/// # Arguments
137/// * `n1` – Refractive index of incident medium.
138/// * `n2` – Refractive index of transmitted medium.
139/// * `theta_i` – Angle of incidence \[rad\].
140pub fn fresnel_rp(n1: f64, n2: f64, theta_i: f64) -> f64 {
141    let cos_i = theta_i.cos();
142    let sin_i = theta_i.sin();
143    let sin_t_sq = (n1 / n2 * sin_i) * (n1 / n2 * sin_i);
144    if sin_t_sq > 1.0 {
145        return -1.0;
146    }
147    let cos_t = (1.0 - sin_t_sq).sqrt();
148    (n2 * cos_i - n1 * cos_t) / (n2 * cos_i + n1 * cos_t)
149}
150
151/// Power reflectance R and transmittance T at an interface for both polarisations.
152///
153/// Returns `(R_s, T_s, R_p, T_p)` where R + T = 1 for each polarisation.
154///
155/// # Arguments
156/// * `n1` – Refractive index of incident medium.
157/// * `n2` – Refractive index of transmitted medium.
158/// * `theta_i` – Angle of incidence \[rad\].
159pub fn fresnel_power(n1: f64, n2: f64, theta_i: f64) -> (f64, f64, f64, f64) {
160    let rs = fresnel_rs(n1, n2, theta_i);
161    let rp = fresnel_rp(n1, n2, theta_i);
162    let r_s = rs * rs;
163    let r_p = rp * rp;
164    let sin_i = theta_i.sin();
165    let sin_t_sq = (n1 / n2 * sin_i) * (n1 / n2 * sin_i);
166    // Check for TIR.
167    if sin_t_sq > 1.0 {
168        return (1.0, 0.0, 1.0, 0.0);
169    }
170    let cos_i = theta_i.cos();
171    let cos_t = (1.0 - sin_t_sq).sqrt();
172    let factor = (n2 * cos_t) / (n1 * cos_i);
173    let ts_amp = 2.0 * n1 * cos_i / (n1 * cos_i + n2 * cos_t);
174    let tp_amp = 2.0 * n1 * cos_i / (n2 * cos_i + n1 * cos_t);
175    let t_s = factor * ts_amp * ts_amp;
176    let t_p = factor * tp_amp * tp_amp;
177    (r_s, t_s, r_p, t_p)
178}
179
180// ─────────────────────────────────────────────────────────────────────────────
181// Brewster angle
182// ─────────────────────────────────────────────────────────────────────────────
183
184/// Compute the Brewster angle θ_B where r_p = 0.
185///
186/// θ_B = atan(n₂/n₁)
187///
188/// # Arguments
189/// * `n1` – Refractive index of incident medium.
190/// * `n2` – Refractive index of second medium.
191pub fn brewster_angle(n1: f64, n2: f64) -> f64 {
192    (n2 / n1).atan()
193}
194
195// ─────────────────────────────────────────────────────────────────────────────
196// Critical angle
197// ─────────────────────────────────────────────────────────────────────────────
198
199/// Compute the critical angle for total internal reflection.
200///
201/// θ_c = asin(n₂/n₁)  valid only when n₁ > n₂.
202/// Returns `None` if n₁ ≤ n₂ (no total internal reflection possible).
203///
204/// # Arguments
205/// * `n1` – Refractive index of the denser medium (incident side).
206/// * `n2` – Refractive index of the less dense medium (transmitted side).
207pub fn critical_angle(n1: f64, n2: f64) -> Option<f64> {
208    if n1 <= n2 {
209        None
210    } else {
211        Some((n2 / n1).asin())
212    }
213}
214
215// ─────────────────────────────────────────────────────────────────────────────
216// Sellmeier equation
217// ─────────────────────────────────────────────────────────────────────────────
218
219/// Compute refractive index via the Sellmeier dispersion formula.
220///
221/// n²(λ) = 1 + Σᵢ Bᵢ·λ² / (λ² − Cᵢ)
222///
223/// where λ is in micrometres and Cᵢ are the resonance wavelengths squared.
224///
225/// # Arguments
226/// * `wavelength_um` – Wavelength in micrometres \[μm\].
227/// * `b` – Sellmeier B coefficients (dimensionless) \[3\].
228/// * `c` – Sellmeier C coefficients \[μm²\] \[3\].
229pub fn sellmeier_equation(wavelength_um: f64, b: &[f64; 3], c: &[f64; 3]) -> f64 {
230    let lam2 = wavelength_um * wavelength_um;
231    let mut n2 = 1.0;
232    for i in 0..3 {
233        n2 += b[i] * lam2 / (lam2 - c[i]);
234    }
235    n2.sqrt()
236}
237
238// ─────────────────────────────────────────────────────────────────────────────
239// Cauchy equation
240// ─────────────────────────────────────────────────────────────────────────────
241
242/// Compute refractive index via the Cauchy empirical dispersion formula.
243///
244/// n(λ) = A + B/λ² + C/λ⁴
245///
246/// where λ is in micrometres.
247///
248/// # Arguments
249/// * `wavelength_um` – Wavelength in micrometres \[μm\].
250/// * `a` – Cauchy coefficient A (dominant refractive index at long λ).
251/// * `b` – Cauchy coefficient B \[μm²\].
252/// * `c` – Cauchy coefficient C \[μm⁴\].
253pub fn cauchy_equation(wavelength_um: f64, a: f64, b: f64, c: f64) -> f64 {
254    let lam2 = wavelength_um * wavelength_um;
255    a + b / lam2 + c / (lam2 * lam2)
256}
257
258// ─────────────────────────────────────────────────────────────────────────────
259// Abbe number (optical dispersion)
260// ─────────────────────────────────────────────────────────────────────────────
261
262/// Compute the Abbe number V (V-number) of a glass at three wavelengths.
263///
264/// V = (n_d − 1) / (n_F − n_C)
265///
266/// where n_d, n_F, n_C are the refractive indices at the Fraunhofer d, F, and C
267/// spectral lines (587.6 nm, 486.1 nm, 656.3 nm respectively).
268///
269/// # Arguments
270/// * `n_d` – Refractive index at 587.6 nm (yellow, He-d line).
271/// * `n_f` – Refractive index at 486.1 nm (blue, H-F line).
272/// * `n_c` – Refractive index at 656.3 nm (red, H-C line).
273pub fn abbe_number(n_d: f64, n_f: f64, n_c: f64) -> f64 {
274    (n_d - 1.0) / (n_f - n_c)
275}
276
277/// Classify glass type from Abbe number.
278///
279/// * V > 55  → Crown glass (low dispersion).
280/// * 35 ≤ V ≤ 55 → Intermediate.
281/// * V < 35  → Flint glass (high dispersion).
282///
283/// Returns a static string label.
284pub fn glass_type_from_abbe(v: f64) -> &'static str {
285    if v > 55.0 {
286        "crown"
287    } else if v >= 35.0 {
288        "intermediate"
289    } else {
290        "flint"
291    }
292}
293
294// ─────────────────────────────────────────────────────────────────────────────
295// Group refractive index
296// ─────────────────────────────────────────────────────────────────────────────
297
298/// Compute the group refractive index n_g = n − λ·dn/dλ.
299///
300/// Uses a central finite difference for dn/dλ with step `dwl`.
301///
302/// # Arguments
303/// * `n_wl` – Function n(λ) giving refractive index at wavelength λ \[μm\].
304/// * `wl` – Wavelength \[μm\].
305/// * `dwl` – Finite difference step \[μm\].
306pub fn group_refractive_index(n_wl: impl Fn(f64) -> f64, wl: f64, dwl: f64) -> f64 {
307    let dn_dlam = (n_wl(wl + dwl) - n_wl(wl - dwl)) / (2.0 * dwl);
308    n_wl(wl) - wl * dn_dlam
309}
310
311// ─────────────────────────────────────────────────────────────────────────────
312// Chromatic dispersion
313// ─────────────────────────────────────────────────────────────────────────────
314
315/// Compute the group-velocity dispersion (GVD) parameter D.
316///
317/// D = −λ/c · d²n/dλ²   \[ps/(nm·km)\] if wavelength in μm and c in m/s.
318///
319/// Uses a central second finite difference.
320///
321/// # Arguments
322/// * `n_wl` – Function n(λ) giving refractive index at wavelength λ \[μm\].
323/// * `wl` – Wavelength \[μm\].
324/// * `dwl` – Finite difference step \[μm\].
325pub fn chromatic_dispersion(n_wl: impl Fn(f64) -> f64, wl: f64, dwl: f64) -> f64 {
326    let d2n = (n_wl(wl + dwl) - 2.0 * n_wl(wl) + n_wl(wl - dwl)) / (dwl * dwl);
327    -wl / SPEED_OF_LIGHT * d2n
328}
329
330// ─────────────────────────────────────────────────────────────────────────────
331// Beer-Lambert absorption
332// ─────────────────────────────────────────────────────────────────────────────
333
334/// Beer-Lambert law: transmitted intensity I through absorbing medium.
335///
336/// I = I₀ · exp(−α · L)
337///
338/// # Arguments
339/// * `i0` – Incident intensity \[arb. units\].
340/// * `alpha` – Absorption coefficient \[m⁻¹\].
341/// * `length` – Propagation path length \[m\].
342pub fn beer_lambert(i0: f64, alpha: f64, length: f64) -> f64 {
343    i0 * (-alpha * length).exp()
344}
345
346/// Absorbance A = log₁₀(I₀/I) = α·L / ln(10).
347///
348/// # Arguments
349/// * `alpha` – Absorption coefficient \[m⁻¹\].
350/// * `length` – Path length \[m\].
351pub fn absorbance(alpha: f64, length: f64) -> f64 {
352    alpha * length / std::f64::consts::LN_10
353}
354
355// ─────────────────────────────────────────────────────────────────────────────
356// Birefringence
357// ─────────────────────────────────────────────────────────────────────────────
358
359/// Uniaxial birefringent material with ordinary (n_o) and extraordinary (n_e) indices.
360#[derive(Debug, Clone, Copy)]
361pub struct BirefringentMaterial {
362    /// Ordinary refractive index n_o.
363    pub n_ordinary: f64,
364    /// Extraordinary refractive index n_e.
365    pub n_extraordinary: f64,
366}
367
368impl BirefringentMaterial {
369    /// Create a new [`BirefringentMaterial`].
370    pub fn new(n_ordinary: f64, n_extraordinary: f64) -> Self {
371        Self {
372            n_ordinary,
373            n_extraordinary,
374        }
375    }
376
377    /// Birefringence Δn = n_e − n_o.
378    pub fn birefringence(&self) -> f64 {
379        self.n_extraordinary - self.n_ordinary
380    }
381
382    /// Effective extraordinary index n_eff(θ) as a function of propagation angle θ
383    /// relative to the optical axis.
384    ///
385    /// 1/n_eff² = cos²θ/n_o² + sin²θ/n_e²
386    ///
387    /// # Arguments
388    /// * `theta` – Angle between propagation direction and optical axis \[rad\].
389    pub fn effective_index(&self, theta: f64) -> f64 {
390        let no = self.n_ordinary;
391        let ne = self.n_extraordinary;
392        let cos_t = theta.cos();
393        let sin_t = theta.sin();
394        1.0 / ((cos_t * cos_t / (no * no) + sin_t * sin_t / (ne * ne)).sqrt())
395    }
396
397    /// Phase retardation Γ for a plate of thickness `d_m` \[m\] at wavelength `wl_nm` \[nm\].
398    ///
399    /// Γ = 2π·Δn·d / λ
400    pub fn phase_retardation(&self, d_m: f64, wl_nm: f64) -> f64 {
401        2.0 * PI * self.birefringence().abs() * d_m / (wl_nm * 1e-9)
402    }
403
404    /// Quarter-wave plate thickness for wavelength `wl_nm` \[nm\].
405    ///
406    /// d_QWP = λ / (4·|Δn|)
407    pub fn quarter_wave_thickness(&self, wl_nm: f64) -> f64 {
408        wl_nm * 1e-9 / (4.0 * self.birefringence().abs())
409    }
410}
411
412// ─────────────────────────────────────────────────────────────────────────────
413// Optical rotation (chirality)
414// ─────────────────────────────────────────────────────────────────────────────
415
416/// Optical rotation in a chiral medium.
417///
418/// A chiral medium has different refractive indices for left (n_L) and right (n_R)
419/// circularly polarised light.
420#[derive(Debug, Clone, Copy)]
421pub struct ChiralMedium {
422    /// Refractive index for left-circularly polarised light.
423    pub n_left: f64,
424    /// Refractive index for right-circularly polarised light.
425    pub n_right: f64,
426}
427
428impl ChiralMedium {
429    /// Create a new [`ChiralMedium`].
430    pub fn new(n_left: f64, n_right: f64) -> Self {
431        Self { n_left, n_right }
432    }
433
434    /// Specific optical rotation ρ \[rad/m\] at wavelength `wl_nm` \[nm\].
435    ///
436    /// ρ = π·(n_L − n_R) / λ
437    pub fn specific_rotation(&self, wl_nm: f64) -> f64 {
438        PI * (self.n_left - self.n_right) / (wl_nm * 1e-9)
439    }
440
441    /// Total rotation of polarisation plane for path length `l_m` \[m\].
442    ///
443    /// φ = ρ · l
444    pub fn rotation_angle(&self, l_m: f64, wl_nm: f64) -> f64 {
445        self.specific_rotation(wl_nm) * l_m
446    }
447
448    /// Average refractive index n_avg = (n_L + n_R) / 2.
449    pub fn mean_index(&self) -> f64 {
450        (self.n_left + self.n_right) / 2.0
451    }
452}
453
454// ─────────────────────────────────────────────────────────────────────────────
455// Photoelastic effect (stress-optic coefficient)
456// ─────────────────────────────────────────────────────────────────────────────
457
458/// Photoelastic birefringence induced by mechanical stress.
459///
460/// Δn = C · (σ₁ − σ₂)
461///
462/// where C is the stress-optic (Brewster) coefficient.
463///
464/// # Arguments
465/// * `stress_optic_coeff` – Brewster coefficient C \[Pa⁻¹\] (typically ~10⁻¹²).
466/// * `sigma1` – Principal stress along one axis \[Pa\].
467/// * `sigma2` – Principal stress along perpendicular axis \[Pa\].
468pub fn photoelastic_birefringence(stress_optic_coeff: f64, sigma1: f64, sigma2: f64) -> f64 {
469    stress_optic_coeff * (sigma1 - sigma2)
470}
471
472/// Photoelastic fringe order N from birefringence Δn at wavelength `wl_nm` \[nm\]
473/// through thickness `d_m` \[m\].
474///
475/// N = Δn · d / λ
476pub fn photoelastic_fringe_order(delta_n: f64, d_m: f64, wl_nm: f64) -> f64 {
477    delta_n * d_m / (wl_nm * 1e-9)
478}
479
480// ─────────────────────────────────────────────────────────────────────────────
481// Electro-optic effects (Pockels and Kerr)
482// ─────────────────────────────────────────────────────────────────────────────
483
484/// Pockels electro-optic effect: index change linear in electric field.
485///
486/// Δn = −½ · n³ · r · E
487///
488/// # Arguments
489/// * `n0` – Unperturbed refractive index.
490/// * `r_eo` – Electro-optic coefficient r \[m/V\].
491/// * `e_field` – Applied electric field \[V/m\].
492pub fn pockels_index_change(n0: f64, r_eo: f64, e_field: f64) -> f64 {
493    -0.5 * n0 * n0 * n0 * r_eo * e_field
494}
495
496/// Kerr electro-optic effect: index change quadratic in electric field.
497///
498/// Δn = ½ · n³ · K · λ₀ · E²
499///
500/// where K is the Kerr constant \[m/V²\] and λ₀ the vacuum wavelength \[m\].
501///
502/// # Arguments
503/// * `n0` – Unperturbed refractive index.
504/// * `kerr_const` – Kerr constant K \[m/V²\].
505/// * `wl_m` – Vacuum wavelength \[m\].
506/// * `e_field` – Applied electric field \[V/m\].
507pub fn kerr_index_change(n0: f64, kerr_const: f64, wl_m: f64, e_field: f64) -> f64 {
508    0.5 * n0 * n0 * n0 * kerr_const * wl_m * e_field * e_field
509}
510
511/// Pockels half-wave voltage V_π for a longitudinal modulator.
512///
513/// V_π = λ / (2 · n₀³ · r · L/d)
514///
515/// For a transverse modulator the aspect ratio d/L appears explicitly.
516///
517/// # Arguments
518/// * `wl_m` – Vacuum wavelength \[m\].
519/// * `n0` – Unperturbed index.
520/// * `r_eo` – Electro-optic coefficient \[m/V\].
521/// * `length_m` – Crystal length \[m\].
522/// * `electrode_gap_m` – Electrode separation \[m\].
523pub fn pockels_half_wave_voltage(
524    wl_m: f64,
525    n0: f64,
526    r_eo: f64,
527    length_m: f64,
528    electrode_gap_m: f64,
529) -> f64 {
530    wl_m * electrode_gap_m / (n0 * n0 * n0 * r_eo * length_m)
531}
532
533// ─────────────────────────────────────────────────────────────────────────────
534// Nonlinear optics: χ² and χ³
535// ─────────────────────────────────────────────────────────────────────────────
536
537/// Second-harmonic generation (SHG) conversion efficiency in the undepleted pump approximation.
538///
539/// η_SHG = (2ω²·d_eff²·L²·I_pump) / (n₁²·n₂·c³·ε₀)
540///
541/// where d_eff is the effective χ²/2 nonlinear coefficient.
542/// This simplified formula gives the fractional power conversion.
543///
544/// # Arguments
545/// * `d_eff` – Effective nonlinear coefficient d_eff = χ²/2 \[m/V\].
546/// * `length_m` – Crystal length \[m\].
547/// * `pump_intensity` – Pump intensity I \[W/m²\].
548/// * `n1` – Refractive index at pump wavelength.
549/// * `n2` – Refractive index at SH wavelength.
550/// * `wl_m` – Pump wavelength \[m\].
551pub fn shg_efficiency(
552    d_eff: f64,
553    length_m: f64,
554    pump_intensity: f64,
555    n1: f64,
556    n2: f64,
557    wl_m: f64,
558) -> f64 {
559    let omega = 2.0 * PI * SPEED_OF_LIGHT / wl_m;
560    let num = 2.0 * omega * omega * d_eff * d_eff * length_m * length_m * pump_intensity;
561    let den = n1 * n1 * n2 * SPEED_OF_LIGHT * SPEED_OF_LIGHT * SPEED_OF_LIGHT * EPSILON_0;
562    if den.abs() < 1e-60 { 0.0 } else { num / den }
563}
564
565/// Phase-matching bandwidth Δλ for SHG.
566///
567/// Δλ ≈ λ² / (2·L·|n_g1 − n_g2|)
568///
569/// # Arguments
570/// * `wl_m` – Pump wavelength \[m\].
571/// * `length_m` – Crystal length \[m\].
572/// * `n_group1` – Group index at pump wavelength.
573/// * `n_group2` – Group index at SH wavelength.
574pub fn shg_phase_matching_bandwidth(wl_m: f64, length_m: f64, n_group1: f64, n_group2: f64) -> f64 {
575    let delta_ng = (n_group1 - n_group2).abs();
576    if delta_ng < 1e-30 {
577        f64::INFINITY
578    } else {
579        wl_m * wl_m / (2.0 * length_m * delta_ng)
580    }
581}
582
583/// Self-phase modulation (SPM) nonlinear phase shift for χ³ medium.
584///
585/// φ_NL = γ · P₀ · L
586///
587/// where γ = ω·n₂/(c·A_eff) is the nonlinear coefficient \[rad/(W·m)\].
588///
589/// # Arguments
590/// * `n2_nonlinear` – Nonlinear refractive index n₂ (χ³) \[m²/W\].
591/// * `wl_m` – Vacuum wavelength \[m\].
592/// * `aeff_m2` – Effective mode area A_eff \[m²\].
593/// * `peak_power_w` – Peak power P₀ \[W\].
594/// * `length_m` – Medium length \[m\].
595pub fn spm_phase_shift(
596    n2_nonlinear: f64,
597    wl_m: f64,
598    aeff_m2: f64,
599    peak_power_w: f64,
600    length_m: f64,
601) -> f64 {
602    let gamma = 2.0 * PI * n2_nonlinear / (wl_m * aeff_m2);
603    gamma * peak_power_w * length_m
604}
605
606/// Cross-phase modulation (XPM) phase shift on probe due to pump.
607///
608/// φ_XPM = 2 · γ · P_pump · L   (factor 2 vs SPM).
609///
610/// # Arguments
611/// * `n2_nonlinear` – Nonlinear refractive index n₂ \[m²/W\].
612/// * `wl_m` – Probe wavelength \[m\].
613/// * `aeff_m2` – Effective mode area \[m²\].
614/// * `pump_power_w` – Pump peak power \[W\].
615/// * `length_m` – Medium length \[m\].
616pub fn xpm_phase_shift(
617    n2_nonlinear: f64,
618    wl_m: f64,
619    aeff_m2: f64,
620    pump_power_w: f64,
621    length_m: f64,
622) -> f64 {
623    2.0 * spm_phase_shift(n2_nonlinear, wl_m, aeff_m2, pump_power_w, length_m)
624}
625
626// ─────────────────────────────────────────────────────────────────────────────
627// Photoluminescence quantum yield
628// ─────────────────────────────────────────────────────────────────────────────
629
630/// Photoluminescence quantum yield (PLQY) η_PL.
631///
632/// η_PL = N_emitted / N_absorbed  = k_r / (k_r + k_nr)
633///
634/// where k_r is the radiative decay rate and k_nr the non-radiative decay rate.
635///
636/// # Arguments
637/// * `k_radiative` – Radiative rate \[s⁻¹\].
638/// * `k_nonradiative` – Non-radiative rate \[s⁻¹\].
639pub fn plqy(k_radiative: f64, k_nonradiative: f64) -> f64 {
640    let total = k_radiative + k_nonradiative;
641    if total == 0.0 {
642        0.0
643    } else {
644        k_radiative / total
645    }
646}
647
648/// Fluorescence lifetime τ = 1 / (k_r + k_nr) \[s\].
649///
650/// # Arguments
651/// * `k_radiative` – Radiative rate \[s⁻¹\].
652/// * `k_nonradiative` – Non-radiative rate \[s⁻¹\].
653pub fn fluorescence_lifetime(k_radiative: f64, k_nonradiative: f64) -> f64 {
654    1.0 / (k_radiative + k_nonradiative)
655}
656
657// ─────────────────────────────────────────────────────────────────────────────
658// Optical bandgap: Tauc plot
659// ─────────────────────────────────────────────────────────────────────────────
660
661/// Tauc plot ordinate (αhν)^(1/r) used to extract optical bandgap.
662///
663/// For direct-allowed transitions: r = 1/2 → (αhν)².
664/// For indirect-allowed transitions: r = 2 → (αhν)^(1/2).
665///
666/// Linear extrapolation to zero gives E_g.
667///
668/// # Arguments
669/// * `alpha` – Absorption coefficient \[m⁻¹\].
670/// * `photon_energy_ev` – Photon energy hν \[eV\].
671/// * `r` – Tauc exponent (1/2 for direct, 2 for indirect).
672pub fn tauc_ordinate(alpha: f64, photon_energy_ev: f64, r: f64) -> f64 {
673    let alpha_hv = alpha * photon_energy_ev;
674    alpha_hv.powf(1.0 / r)
675}
676
677/// Estimate optical bandgap from linear Tauc fit parameters.
678///
679/// Given the slope `m` and intercept `b` of a linear fit to the Tauc plot
680/// ((αhν)^(1/r) = m·(hν) + b), returns E_g = −b/m.
681///
682/// # Arguments
683/// * `slope` – Slope of the Tauc linear fit.
684/// * `intercept` – Intercept of the Tauc linear fit.
685pub fn tauc_bandgap(slope: f64, intercept: f64) -> f64 {
686    if slope.abs() < 1e-30 {
687        f64::NAN
688    } else {
689        -intercept / slope
690    }
691}
692
693// ─────────────────────────────────────────────────────────────────────────────
694// Drude dielectric function
695// ─────────────────────────────────────────────────────────────────────────────
696
697/// Drude model complex dielectric function ε(ω) for free-electron metal.
698///
699/// ε(ω) = ε_∞ − ω_p² / (ω² + i·ω·γ)
700///
701/// Returns `(ε_real, ε_imag)`.
702///
703/// # Arguments
704/// * `epsilon_inf` – High-frequency dielectric constant ε_∞.
705/// * `omega_p` – Plasma frequency ω_p \[rad/s\].
706/// * `gamma_drude` – Collision (damping) rate γ \[rad/s\].
707/// * `omega` – Angular frequency ω \[rad/s\].
708pub fn drude_dielectric(
709    epsilon_inf: f64,
710    omega_p: f64,
711    gamma_drude: f64,
712    omega: f64,
713) -> (f64, f64) {
714    let denom = omega * omega + gamma_drude * gamma_drude;
715    let re = epsilon_inf - omega_p * omega_p / denom;
716    let im = omega_p * omega_p * gamma_drude / (omega * denom);
717    (re, im)
718}
719
720/// Drude plasma frequency ω_p from carrier density.
721///
722/// ω_p = sqrt(n_e · e² / (ε₀ · m*))
723///
724/// # Arguments
725/// * `carrier_density` – Free carrier density n_e \[m⁻³\].
726/// * `eff_mass` – Effective carrier mass m* \[kg\].
727pub fn drude_plasma_frequency(carrier_density: f64, eff_mass: f64) -> f64 {
728    (carrier_density * ELEM_CHARGE * ELEM_CHARGE / (EPSILON_0 * eff_mass)).sqrt()
729}
730
731/// Drude complex refractive index ñ = sqrt(ε).
732///
733/// Returns `(n, k)` where ñ = n + i·k.
734///
735/// # Arguments
736/// * `epsilon_real` – Real part of dielectric function.
737/// * `epsilon_imag` – Imaginary part of dielectric function.
738pub fn drude_refractive_index(epsilon_real: f64, epsilon_imag: f64) -> (f64, f64) {
739    let magnitude = (epsilon_real * epsilon_real + epsilon_imag * epsilon_imag).sqrt();
740    let n = ((magnitude + epsilon_real) / 2.0).sqrt();
741    let k = ((magnitude - epsilon_real) / 2.0).sqrt();
742    (n, k)
743}
744
745// ─────────────────────────────────────────────────────────────────────────────
746// Metamaterial effective medium (Maxwell Garnett mixing rule)
747// ─────────────────────────────────────────────────────────────────────────────
748
749/// Maxwell Garnett effective permittivity for a composite of inclusions in a host.
750///
751/// ε_eff = ε_host · (ε_incl·(1 + 2f) + ε_host·2·(1 − f)) /
752///                  (ε_incl·(1 − f)   + ε_host·(2 + f))
753///
754/// # Arguments
755/// * `eps_host` – Permittivity of host matrix.
756/// * `eps_incl` – Permittivity of inclusion.
757/// * `fill_fraction` – Volume fraction f of inclusions (0–1).
758pub fn maxwell_garnett(eps_host: f64, eps_incl: f64, fill_fraction: f64) -> f64 {
759    let f = fill_fraction;
760    let num = eps_incl * (1.0 + 2.0 * f) + eps_host * 2.0 * (1.0 - f);
761    let den = eps_incl * (1.0 - f) + eps_host * (2.0 + f);
762    if den.abs() < 1e-30 {
763        eps_host
764    } else {
765        eps_host * num / den
766    }
767}
768
769/// Bruggeman effective medium approximation for binary mixture.
770///
771/// Solves: f·(ε₁ − ε_eff)/(ε₁ + 2ε_eff) + (1−f)·(ε₂ − ε_eff)/(ε₂ + 2ε_eff) = 0
772///
773/// Returns the physical root (ε_eff > 0).
774///
775/// # Arguments
776/// * `eps1` – Permittivity of material 1.
777/// * `eps2` – Permittivity of material 2.
778/// * `f1` – Volume fraction of material 1 (0–1).
779pub fn bruggeman_effective_medium(eps1: f64, eps2: f64, f1: f64) -> f64 {
780    let f2 = 1.0 - f1;
781    // Quadratic: 0 = (2f1 − f2)·ε_eff² + [f1·(ε2 − 2ε1) + f2·(ε1 − 2ε2)]·...
782    // Simplified analytic solution for real epsilon:
783    let b = (f1 * (2.0 * eps1 - eps2) + f2 * (2.0 * eps2 - eps1)) / 3.0;
784    let disc = b * b + eps1 * eps2 / 9.0 + (f1 * eps1 + f2 * eps2) / 3.0;
785    // Use iterative approach: start from linear mix.
786    let mut eps_eff = f1 * eps1 + f2 * eps2;
787    for _ in 0..100 {
788        let t1 = f1 * (eps1 - eps_eff) / (eps1 + 2.0 * eps_eff);
789        let t2 = f2 * (eps2 - eps_eff) / (eps2 + 2.0 * eps_eff);
790        let _b_unused = b;
791        let _disc_unused = disc;
792        let residual = t1 + t2;
793        // Derivative dr/deps_eff.
794        let dt1 = f1 * (-3.0 * eps1) / ((eps1 + 2.0 * eps_eff) * (eps1 + 2.0 * eps_eff));
795        let dt2 = f2 * (-3.0 * eps2) / ((eps2 + 2.0 * eps_eff) * (eps2 + 2.0 * eps_eff));
796        let drv = dt1 + dt2;
797        if drv.abs() < 1e-30 {
798            break;
799        }
800        let step = residual / drv;
801        eps_eff -= step;
802        if step.abs() < 1e-12 {
803            break;
804        }
805    }
806    eps_eff
807}
808
809// ─────────────────────────────────────────────────────────────────────────────
810// Photonic crystal bandgap
811// ─────────────────────────────────────────────────────────────────────────────
812
813/// Photonic crystal 1D bandgap estimation using transfer matrix method.
814///
815/// For a 1D periodic stack (layers of n₁, d₁ and n₂, d₂) the photonic bandgap
816/// centre wavelength and fractional gap width are estimated.
817///
818/// # Arguments
819/// * `n1` – Refractive index of layer 1.
820/// * `n2` – Refractive index of layer 2.
821/// * `d1` – Thickness of layer 1 \[m\].
822/// * `d2` – Thickness of layer 2 \[m\].
823///
824/// Returns `(lambda_center, fractional_gap)` where
825/// λ_c = 2(n₁d₁ + n₂d₂) and Δλ/λ = (4/π)·arcsin(|n₁−n₂|/(n₁+n₂)).
826pub fn photonic_crystal_1d_bandgap(n1: f64, n2: f64, d1: f64, d2: f64) -> (f64, f64) {
827    let lambda_center = 2.0 * (n1 * d1 + n2 * d2);
828    let fractional_gap = (4.0 / PI) * ((n1 - n2).abs() / (n1 + n2)).asin();
829    (lambda_center, fractional_gap)
830}
831
832/// Transfer matrix for a single dielectric layer at normal incidence.
833///
834/// M = \[\[cos δ, -i/n · sin δ\\], \[-i·n · sin δ, cos δ\]]
835///
836/// Returns the 2×2 matrix as `[m11, m12, m21, m22]` (complex components as pairs).
837/// For simplicity returns the real transfer matrix ignoring phase for
838/// a λ/4 layer where δ = π/2 and cos δ = 0.
839///
840/// For a quarter-wave layer: M_real = \[\[0, -1/n\\], \[-n, 0\]].
841///
842/// # Arguments
843/// * `n` – Refractive index of the layer.
844/// * `delta` – Round-trip phase δ = 2πnd/λ \[rad\].
845pub fn transfer_matrix_layer(n: f64, delta: f64) -> [f64; 4] {
846    let cos_d = delta.cos();
847    let sin_d = delta.sin();
848    // Real part of transfer matrix components.
849    [cos_d, -sin_d / n, -n * sin_d, cos_d]
850}
851
852/// Multiply two 2×2 transfer matrices.
853///
854/// Returns M = A × B.
855///
856/// # Arguments
857/// * `a` – First matrix \[m11, m12, m21, m22\].
858/// * `b` – Second matrix.
859pub fn transfer_matrix_multiply(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
860    [
861        a[0] * b[0] + a[1] * b[2],
862        a[0] * b[1] + a[1] * b[3],
863        a[2] * b[0] + a[3] * b[2],
864        a[2] * b[1] + a[3] * b[3],
865    ]
866}
867
868/// Reflectance of an N-period Bragg mirror using transfer matrix method.
869///
870/// # Arguments
871/// * `n0` – Refractive index of incident medium.
872/// * `ns` – Refractive index of substrate.
873/// * `n1` – Refractive index of layer 1.
874/// * `n2` – Refractive index of layer 2.
875/// * `d1` – Thickness of layer 1 \[m\].
876/// * `d2` – Thickness of layer 2 \[m\].
877/// * `wl_m` – Wavelength in vacuum \[m\].
878/// * `n_periods` – Number of bilayer periods.
879pub fn bragg_mirror_reflectance(
880    n0: f64,
881    ns: f64,
882    n1: f64,
883    n2: f64,
884    d1: f64,
885    d2: f64,
886    wl_m: f64,
887    n_periods: usize,
888) -> f64 {
889    let delta1 = 2.0 * PI * n1 * d1 / wl_m;
890    let delta2 = 2.0 * PI * n2 * d2 / wl_m;
891    let m1 = transfer_matrix_layer(n1, delta1);
892    let m2 = transfer_matrix_layer(n2, delta2);
893    let bilayer = transfer_matrix_multiply(m1, m2);
894
895    // Raise bilayer to the n_periods power by repeated squaring.
896    let mut result = [1.0_f64, 0.0, 0.0, 1.0]; // identity
897    let mut base = bilayer;
898    let mut exp = n_periods;
899    while exp > 0 {
900        if exp % 2 == 1 {
901            result = transfer_matrix_multiply(result, base);
902        }
903        base = transfer_matrix_multiply(base, base);
904        exp /= 2;
905    }
906
907    // Compute reflectance: r = (n0*M11 + n0*ns*M12 - M21 - ns*M22) /
908    //                          (n0*M11 + n0*ns*M12 + M21 + ns*M22)
909    let num = n0 * result[0] + n0 * ns * result[1] - result[2] - ns * result[3];
910    let den = n0 * result[0] + n0 * ns * result[1] + result[2] + ns * result[3];
911    if den.abs() < 1e-30 {
912        1.0
913    } else {
914        (num / den) * (num / den)
915    }
916}
917
918// ─────────────────────────────────────────────────────────────────────────────
919// AntiReflectionCoating
920// ─────────────────────────────────────────────────────────────────────────────
921
922/// Single-layer quarter-wave anti-reflection coating.
923#[derive(Debug, Clone, Copy)]
924pub struct AntiReflectionCoating {
925    /// Refractive index of the substrate.
926    pub n_substrate: f64,
927    /// Refractive index of the coating layer.
928    pub n_coating: f64,
929    /// Physical coating thickness \[nm\].
930    pub thickness_nm: f64,
931    /// Design wavelength (for quarter-wave condition) \[nm\].
932    pub wavelength_nm: f64,
933}
934
935impl AntiReflectionCoating {
936    /// Create a new [`AntiReflectionCoating`].
937    pub fn new(n_substrate: f64, n_coating: f64, thickness_nm: f64, wavelength_nm: f64) -> Self {
938        Self {
939            n_substrate,
940            n_coating,
941            thickness_nm,
942            wavelength_nm,
943        }
944    }
945
946    /// Optimal coating refractive index for zero reflection: n_c = sqrt(n_s).
947    pub fn optimal_n(&self) -> f64 {
948        self.n_substrate.sqrt()
949    }
950
951    /// Approximate reflectance of the coated surface at wavelength `wl_nm` \[nm\].
952    ///
953    /// Uses the thin-film two-beam interference formula:
954    ///
955    /// R = (r₁² + r₂² + 2·r₁·r₂·cos δ) / (1 + r₁²·r₂² + 2·r₁·r₂·cos δ)
956    ///
957    /// where r₁ = (1 − n_c)/(1 + n_c), r₂ = (n_c − n_s)/(n_c + n_s),
958    /// and δ = 4π·n_c·d / λ.
959    pub fn reflectance(&self, wl_nm: f64) -> f64 {
960        let r1 = (1.0 - self.n_coating) / (1.0 + self.n_coating);
961        let r2 = (self.n_coating - self.n_substrate) / (self.n_coating + self.n_substrate);
962        let delta = 4.0 * PI * self.n_coating * self.thickness_nm / wl_nm;
963        let cos_d = delta.cos();
964        let num = r1 * r1 + r2 * r2 + 2.0 * r1 * r2 * cos_d;
965        let den = 1.0 + r1 * r1 * r2 * r2 + 2.0 * r1 * r2 * cos_d;
966        if den.abs() < 1e-30 {
967            0.0
968        } else {
969            (num / den).abs()
970        }
971    }
972}
973
974// ─────────────────────────────────────────────────────────────────────────────
975// Etalon transmission and finesse
976// ─────────────────────────────────────────────────────────────────────────────
977
978/// Compute the Fabry-Perot etalon transmission (Airy function).
979///
980/// T = 1 / (1 + F·sin²(δ/2))
981///
982/// where F = 4R/(1−R)² is the coefficient of finesse.
983///
984/// # Arguments
985/// * `r` – Mirror reflectance R (power reflectivity) in \[0, 1\).
986/// * `delta` – Round-trip phase \[rad\], δ = 4π·n·d·cos θ / λ.
987pub fn etalon_transmission(r: f64, delta: f64) -> f64 {
988    let f_coeff = 4.0 * r / ((1.0 - r) * (1.0 - r));
989    let half_sin = (delta / 2.0).sin();
990    1.0 / (1.0 + f_coeff * half_sin * half_sin)
991}
992
993/// Compute the etalon finesse F = π·√R / (1−R).
994///
995/// Higher finesse means narrower transmission peaks and higher resolving power.
996///
997/// # Arguments
998/// * `r` – Mirror reflectance R (power reflectivity) in \[0, 1\).
999pub fn finesse(r: f64) -> f64 {
1000    PI * r.sqrt() / (1.0 - r)
1001}
1002
1003// ─────────────────────────────────────────────────────────────────────────────
1004// Tests
1005// ─────────────────────────────────────────────────────────────────────────────
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    const EPS: f64 = 1e-9;
1012
1013    // 1. reflectance_normal is in [0, 1].
1014    #[test]
1015    fn test_reflectance_normal_range() {
1016        let mat = OpticalMaterial::new(1.5, 0.0, 500.0);
1017        let r = mat.reflectance_normal();
1018        assert!(
1019            (0.0..=1.0).contains(&r),
1020            "Reflectance must be in [0,1], got {r}"
1021        );
1022    }
1023
1024    // 2. reflectance_normal for air-glass (n=1.5, k=0).
1025    #[test]
1026    fn test_reflectance_glass() {
1027        let mat = OpticalMaterial::new(1.5, 0.0, 550.0);
1028        let r = mat.reflectance_normal();
1029        let expected = (0.5 / 2.5) * (0.5 / 2.5);
1030        assert!(
1031            (r - expected).abs() < EPS,
1032            "Glass reflectance: {r} vs {expected}"
1033        );
1034    }
1035
1036    // 3. absorption_coefficient is zero for k=0.
1037    #[test]
1038    fn test_absorption_zero_for_transparent() {
1039        let mat = OpticalMaterial::new(1.5, 0.0, 500.0);
1040        assert_eq!(mat.absorption_coefficient(), 0.0, "No absorption when k=0");
1041    }
1042
1043    // 4. absorption_coefficient is positive for k>0.
1044    #[test]
1045    fn test_absorption_positive_for_absorbing() {
1046        let mat = OpticalMaterial::new(2.0, 0.5, 500.0);
1047        let alpha = mat.absorption_coefficient();
1048        assert!(
1049            alpha > 0.0,
1050            "Absorption coefficient must be positive for k>0, got {alpha}"
1051        );
1052    }
1053
1054    // 5. penetration_depth is infinity when k=0.
1055    #[test]
1056    fn test_penetration_depth_transparent() {
1057        let mat = OpticalMaterial::new(1.5, 0.0, 500.0);
1058        assert!(
1059            mat.penetration_depth().is_infinite(),
1060            "Non-absorbing material: infinite penetration depth"
1061        );
1062    }
1063
1064    // 6. penetration_depth decreases with increasing k.
1065    #[test]
1066    fn test_penetration_depth_decreases_with_k() {
1067        let mat1 = OpticalMaterial::new(2.0, 0.1, 500.0);
1068        let mat2 = OpticalMaterial::new(2.0, 1.0, 500.0);
1069        assert!(
1070            mat2.penetration_depth() < mat1.penetration_depth(),
1071            "Larger k → smaller penetration depth"
1072        );
1073    }
1074
1075    // 7. optical_impedance is positive.
1076    #[test]
1077    fn test_optical_impedance_positive() {
1078        let mat = OpticalMaterial::new(1.5, 0.0, 500.0);
1079        let z = mat.optical_impedance();
1080        assert!(z > 0.0, "Optical impedance must be positive, got {z}");
1081    }
1082
1083    // 8. optical_impedance for vacuum (n=1) is ~376.73 Ω.
1084    #[test]
1085    fn test_optical_impedance_vacuum() {
1086        let mat = OpticalMaterial::new(1.0, 0.0, 500.0);
1087        let z = mat.optical_impedance();
1088        assert!(
1089            (z - 376.730_313_412).abs() < 1e-6,
1090            "Vacuum impedance mismatch: {z}"
1091        );
1092    }
1093
1094    // 9. brewster_angle is positive.
1095    #[test]
1096    fn test_brewster_angle_positive() {
1097        let theta_b = brewster_angle(1.0, 1.5);
1098        assert!(
1099            theta_b > 0.0,
1100            "Brewster angle must be positive, got {theta_b}"
1101        );
1102    }
1103
1104    // 10. r_p is near zero at Brewster angle.
1105    #[test]
1106    fn test_fresnel_rp_zero_at_brewster() {
1107        let n1 = 1.0_f64;
1108        let n2 = 1.5_f64;
1109        let theta_b = brewster_angle(n1, n2);
1110        let rp = fresnel_rp(n1, n2, theta_b);
1111        assert!(
1112            rp.abs() < 1e-10,
1113            "r_p should be zero at Brewster angle, got {rp}"
1114        );
1115    }
1116
1117    // 11. r_s at normal incidence equals (n1-n2)/(n1+n2).
1118    #[test]
1119    fn test_fresnel_rs_normal_incidence() {
1120        let n1 = 1.0_f64;
1121        let n2 = 1.5_f64;
1122        let rs = fresnel_rs(n1, n2, 0.0);
1123        let expected = (n1 - n2) / (n1 + n2);
1124        assert!(
1125            (rs - expected).abs() < EPS,
1126            "r_s at normal incidence: {rs} vs {expected}"
1127        );
1128    }
1129
1130    // 12. critical_angle returns None when n1 <= n2.
1131    #[test]
1132    fn test_critical_angle_none_for_low_n1() {
1133        assert!(critical_angle(1.0, 1.5).is_none(), "No TIR when n1 < n2");
1134    }
1135
1136    // 13. critical_angle returns Some when n1 > n2.
1137    #[test]
1138    fn test_critical_angle_some_for_high_n1() {
1139        let theta_c = critical_angle(1.5, 1.0);
1140        assert!(theta_c.is_some(), "TIR exists when n1 > n2");
1141    }
1142
1143    // 14. critical_angle value is asin(n2/n1).
1144    #[test]
1145    fn test_critical_angle_formula() {
1146        let n1 = 1.5_f64;
1147        let n2 = 1.0_f64;
1148        let theta_c = critical_angle(n1, n2).unwrap();
1149        let expected = (n2 / n1).asin();
1150        assert!(
1151            (theta_c - expected).abs() < EPS,
1152            "Critical angle: {theta_c} vs {expected}"
1153        );
1154    }
1155
1156    // 15. Sellmeier equation returns n > 1 for BK7 glass at 589 nm.
1157    #[test]
1158    fn test_sellmeier_bk7_visible() {
1159        let b = [1.03961212, 0.231792344, 1.01046945];
1160        let c = [0.00600069867, 0.0200179144, 103.560653];
1161        let n = sellmeier_equation(0.589, &b, &c);
1162        assert!(n > 1.0, "Sellmeier should give n > 1, got {n}");
1163        assert!(
1164            (n - 1.5168).abs() < 0.001,
1165            "BK7 at 589 nm: expected ~1.517, got {n}"
1166        );
1167    }
1168
1169    // 16. Abbe number for crown glass is > 55.
1170    #[test]
1171    fn test_abbe_number_crown() {
1172        // BK7-like: n_d=1.5168, n_F=1.5224, n_C=1.5143
1173        let v = abbe_number(1.5168, 1.5224, 1.5143);
1174        assert!(
1175            v > 55.0,
1176            "BK7 Abbe number should be crown glass (>55), got {v:.2}"
1177        );
1178        assert_eq!(glass_type_from_abbe(v), "crown");
1179    }
1180
1181    // 17. Abbe number for flint glass is < 35.
1182    #[test]
1183    fn test_abbe_number_flint() {
1184        let v = abbe_number(1.7, 1.73, 1.68);
1185        assert!(v < 35.0, "Flint glass Abbe number < 35, got {v:.2}");
1186        assert_eq!(glass_type_from_abbe(v), "flint");
1187    }
1188
1189    // 18. Beer-Lambert: I decreases with path length.
1190    #[test]
1191    fn test_beer_lambert_decay() {
1192        let i1 = beer_lambert(1.0, 100.0, 0.01);
1193        let i2 = beer_lambert(1.0, 100.0, 0.02);
1194        assert!(
1195            i2 < i1,
1196            "Beer-Lambert: intensity decreases with path length"
1197        );
1198        assert!(
1199            i1 > 0.0 && i2 > 0.0,
1200            "Transmitted intensity must be positive"
1201        );
1202    }
1203
1204    // 19. Beer-Lambert: zero path length gives I = I₀.
1205    #[test]
1206    fn test_beer_lambert_zero_length() {
1207        let i = beer_lambert(5.0, 100.0, 0.0);
1208        assert!(
1209            (i - 5.0).abs() < EPS,
1210            "Beer-Lambert at zero path: {i} vs 5.0"
1211        );
1212    }
1213
1214    // 20. Birefringence: Δn = n_e - n_o.
1215    #[test]
1216    fn test_birefringence_delta_n() {
1217        let mat = BirefringentMaterial::new(1.658, 1.486); // calcite-like
1218        let delta = mat.birefringence();
1219        let expected = 1.486 - 1.658;
1220        assert!(
1221            (delta - expected).abs() < EPS,
1222            "Birefringence mismatch: {delta}"
1223        );
1224    }
1225
1226    // 21. Birefringence effective index at θ=0 equals n_o.
1227    #[test]
1228    fn test_birefringence_effective_index_at_zero() {
1229        let mat = BirefringentMaterial::new(1.658, 1.486);
1230        let n_eff = mat.effective_index(0.0);
1231        assert!(
1232            (n_eff - mat.n_ordinary).abs() < 1e-6,
1233            "At θ=0, n_eff should equal n_o: {n_eff} vs {}",
1234            mat.n_ordinary
1235        );
1236    }
1237
1238    // 22. Phase retardation is 0 for zero thickness.
1239    #[test]
1240    fn test_phase_retardation_zero_thickness() {
1241        let mat = BirefringentMaterial::new(1.5, 1.6);
1242        let gamma = mat.phase_retardation(0.0, 500.0);
1243        assert!(gamma.abs() < EPS, "Zero thickness → zero retardation");
1244    }
1245
1246    // 23. Optical rotation scales with path length.
1247    #[test]
1248    fn test_optical_rotation_scales_with_length() {
1249        let chiral = ChiralMedium::new(1.500, 1.501);
1250        let phi1 = chiral.rotation_angle(0.01, 500.0);
1251        let phi2 = chiral.rotation_angle(0.02, 500.0);
1252        assert!(
1253            (phi2 - 2.0 * phi1).abs() < 1e-10,
1254            "Rotation scales linearly with length"
1255        );
1256    }
1257
1258    // 24. Pockels index change sign depends on field direction.
1259    #[test]
1260    fn test_pockels_sign() {
1261        let dn_pos = pockels_index_change(2.0, 3e-12, 1e7);
1262        let dn_neg = pockels_index_change(2.0, 3e-12, -1e7);
1263        assert!(
1264            dn_pos < 0.0,
1265            "Pockels: positive field → negative Δn for positive r"
1266        );
1267        assert!(
1268            (dn_pos + dn_neg).abs() < EPS,
1269            "Pockels: Δn is antisymmetric in E"
1270        );
1271    }
1272
1273    // 25. Kerr index change is always positive (quadratic in E).
1274    #[test]
1275    fn test_kerr_quadratic() {
1276        let dn1 = kerr_index_change(1.5, 1e-12, 500e-9, 1e6);
1277        let dn2 = kerr_index_change(1.5, 1e-12, 500e-9, -1e6);
1278        assert!((dn1 - dn2).abs() < EPS, "Kerr effect is symmetric in E");
1279        let dn_double = kerr_index_change(1.5, 1e-12, 500e-9, 2e6);
1280        assert!(
1281            (dn_double - 4.0 * dn1).abs() < 1e-30,
1282            "Kerr scales as E²: dn(2E)=4*dn(E)"
1283        );
1284    }
1285
1286    // 26. PLQY is in [0, 1].
1287    #[test]
1288    fn test_plqy_range() {
1289        let q = plqy(1e8, 1e7);
1290        assert!((0.0..=1.0).contains(&q), "PLQY must be in [0,1], got {q}");
1291    }
1292
1293    // 27. PLQY = 1 when k_nr = 0.
1294    #[test]
1295    fn test_plqy_unity_no_nonradiative() {
1296        let q = plqy(1e9, 0.0);
1297        assert!(
1298            (q - 1.0).abs() < EPS,
1299            "PLQY = 1 with no non-radiative decay, got {q}"
1300        );
1301    }
1302
1303    // 28. Drude dielectric has negative real part below plasma frequency.
1304    #[test]
1305    fn test_drude_below_plasma_freq() {
1306        let omega_p = 1e15_f64;
1307        let (re, _im) = drude_dielectric(1.0, omega_p, 1e13, omega_p * 0.5);
1308        assert!(
1309            re < 0.0,
1310            "Drude ε_real < 0 below plasma frequency, got {re}"
1311        );
1312    }
1313
1314    // 29. Drude imaginary part is positive.
1315    #[test]
1316    fn test_drude_imag_positive() {
1317        let (_re, im) = drude_dielectric(1.0, 1e15, 1e13, 5e14);
1318        assert!(im > 0.0, "Drude ε_imag must be positive, got {im}");
1319    }
1320
1321    // 30. Maxwell Garnett: f=0 gives ε_eff = ε_host.
1322    #[test]
1323    fn test_maxwell_garnett_zero_fill() {
1324        let eps_eff = maxwell_garnett(2.25, 10.0, 0.0);
1325        assert!(
1326            (eps_eff - 2.25).abs() < EPS,
1327            "Maxwell Garnett at f=0 should give ε_host: {eps_eff}"
1328        );
1329    }
1330
1331    // 31. Maxwell Garnett: f=1 gives ε_eff = ε_incl.
1332    #[test]
1333    fn test_maxwell_garnett_full_fill() {
1334        let eps_eff = maxwell_garnett(2.25, 10.0, 1.0);
1335        assert!(
1336            (eps_eff - 10.0).abs() < EPS,
1337            "Maxwell Garnett at f=1 should give ε_incl: {eps_eff}"
1338        );
1339    }
1340
1341    // 32. Photonic crystal bandgap centre scales with period.
1342    #[test]
1343    fn test_photonic_crystal_bandgap_scaling() {
1344        let (lambda1, _) = photonic_crystal_1d_bandgap(2.0, 1.5, 100e-9, 100e-9);
1345        let (lambda2, _) = photonic_crystal_1d_bandgap(2.0, 1.5, 200e-9, 200e-9);
1346        assert!(
1347            (lambda2 - 2.0 * lambda1).abs() < 1e-20,
1348            "Bandgap centre scales with period: {lambda1} vs {lambda2}"
1349        );
1350    }
1351
1352    // 33. Bragg mirror reflectance increases with number of periods.
1353    #[test]
1354    fn test_bragg_mirror_periods() {
1355        let r5 = bragg_mirror_reflectance(1.0, 1.0, 2.3, 1.5, 60e-9, 90e-9, 600e-9, 5);
1356        let r10 = bragg_mirror_reflectance(1.0, 1.0, 2.3, 1.5, 60e-9, 90e-9, 600e-9, 10);
1357        assert!(
1358            r10 >= r5,
1359            "More periods → higher Bragg reflectance: R5={r5:.4}, R10={r10:.4}"
1360        );
1361    }
1362
1363    // 34. etalon_transmission is in [0, 1].
1364    #[test]
1365    fn test_etalon_transmission_range() {
1366        let t = etalon_transmission(0.9, 1.2);
1367        assert!(
1368            (0.0..=1.0).contains(&t),
1369            "Etalon T must be in [0,1], got {t}"
1370        );
1371    }
1372
1373    // 35. etalon_transmission at resonance (delta = 0) equals 1.
1374    #[test]
1375    fn test_etalon_transmission_at_resonance() {
1376        let t = etalon_transmission(0.9, 0.0);
1377        assert!((t - 1.0).abs() < EPS, "Etalon T = 1 at resonance, got {t}");
1378    }
1379
1380    // 36. finesse increases with reflectance.
1381    #[test]
1382    fn test_finesse_increases_with_r() {
1383        let f1 = finesse(0.5);
1384        let f2 = finesse(0.9);
1385        assert!(
1386            f2 > f1,
1387            "Finesse should increase with reflectance: f1={f1}, f2={f2}"
1388        );
1389    }
1390
1391    // 37. AntiReflectionCoating::optimal_n is sqrt(n_substrate).
1392    #[test]
1393    fn test_ar_coating_optimal_n() {
1394        let coating = AntiReflectionCoating::new(2.25, 1.5, 125.0, 500.0);
1395        let n_opt = coating.optimal_n();
1396        let expected = 2.25_f64.sqrt();
1397        assert!(
1398            (n_opt - expected).abs() < EPS,
1399            "Optimal coating n: {n_opt} vs {expected}"
1400        );
1401    }
1402
1403    // 38. fresnel_power: R + T ≈ 1 for non-TIR angle.
1404    #[test]
1405    fn test_fresnel_power_conservation() {
1406        let (rs, ts, rp, tp) = fresnel_power(1.0, 1.5, 0.3);
1407        assert!(
1408            (rs + ts - 1.0).abs() < 1e-10,
1409            "Energy conservation for s-pol: Rs+Ts={}",
1410            rs + ts
1411        );
1412        assert!(
1413            (rp + tp - 1.0).abs() < 1e-10,
1414            "Energy conservation for p-pol: Rp+Tp={}",
1415            rp + tp
1416        );
1417    }
1418
1419    // 39. SHG efficiency scales with L².
1420    #[test]
1421    fn test_shg_efficiency_scales_with_l2() {
1422        let e1 = shg_efficiency(1e-12, 1e-3, 1e12, 1.7, 1.7, 1064e-9);
1423        let e2 = shg_efficiency(1e-12, 2e-3, 1e12, 1.7, 1.7, 1064e-9);
1424        let ratio = e2 / e1;
1425        assert!(
1426            (ratio - 4.0).abs() < 1e-6,
1427            "SHG efficiency scales as L²: ratio={ratio:.6}"
1428        );
1429    }
1430
1431    // 40. SPM phase shift scales linearly with power.
1432    #[test]
1433    fn test_spm_phase_linear_power() {
1434        let phi1 = spm_phase_shift(2.6e-20, 1550e-9, 80e-12, 1e3, 1.0);
1435        let phi2 = spm_phase_shift(2.6e-20, 1550e-9, 80e-12, 2e3, 1.0);
1436        assert!(
1437            (phi2 - 2.0 * phi1).abs() < 1e-20,
1438            "SPM phase shift scales with power: phi1={phi1:.6}, phi2={phi2:.6}"
1439        );
1440    }
1441
1442    // 41. XPM phase is twice SPM for same parameters.
1443    #[test]
1444    fn test_xpm_is_twice_spm() {
1445        let phi_spm = spm_phase_shift(2.6e-20, 1550e-9, 80e-12, 1e3, 1.0);
1446        let phi_xpm = xpm_phase_shift(2.6e-20, 1550e-9, 80e-12, 1e3, 1.0);
1447        assert!(
1448            (phi_xpm - 2.0 * phi_spm).abs() < 1e-20,
1449            "XPM = 2×SPM: spm={phi_spm:.6}, xpm={phi_xpm:.6}"
1450        );
1451    }
1452
1453    // 42. Tauc ordinate is zero when alpha = 0.
1454    #[test]
1455    fn test_tauc_zero_alpha() {
1456        let ord = tauc_ordinate(0.0, 2.5, 0.5);
1457        assert!(
1458            ord.abs() < EPS,
1459            "Tauc ordinate should be zero for alpha=0, got {ord}"
1460        );
1461    }
1462
1463    // 43. Cauchy equation returns n > 1.
1464    #[test]
1465    fn test_cauchy_returns_n_greater_one() {
1466        let n = cauchy_equation(0.55, 1.458, 0.00354, 0.0);
1467        assert!(n > 1.0, "Cauchy must return n > 1, got {n}");
1468    }
1469
1470    // 44. Drude plasma frequency is positive.
1471    #[test]
1472    fn test_drude_plasma_frequency_positive() {
1473        let wp = drude_plasma_frequency(8.5e28, 9.1e-31);
1474        assert!(wp > 0.0, "Plasma frequency must be positive, got {wp:.3e}");
1475    }
1476
1477    // 45. Photoelastic birefringence vanishes for equal stresses.
1478    #[test]
1479    fn test_photoelastic_zero_for_equal_stress() {
1480        let dn = photoelastic_birefringence(1e-12, 1e6, 1e6);
1481        assert!(
1482            dn.abs() < EPS,
1483            "No birefringence for hydrostatic stress, got {dn}"
1484        );
1485    }
1486
1487    // 46. Bruggeman effective medium is between eps1 and eps2.
1488    #[test]
1489    fn test_bruggeman_between_components() {
1490        let e1 = 2.25;
1491        let e2 = 12.0;
1492        let eps_eff = bruggeman_effective_medium(e1, e2, 0.3);
1493        assert!(
1494            eps_eff >= e1 && eps_eff <= e2,
1495            "Bruggeman eps_eff should be between components: {eps_eff}"
1496        );
1497    }
1498}