use num_complex::Complex64;
use std::f64::consts::PI;
use crate::error::OxiPhotonError;
use crate::solar::absorption::AbsorptionMaterial;
use crate::solar::drift_diffusion::material::Q;
use crate::solar::spectrum::SolarSpectrum;
const PLANCK_H: f64 = 6.626_070_15e-34; const SPEED_OF_LIGHT: f64 = 2.997_924_58e8; const ELECTRON_CHARGE: f64 = 1.602_176_634e-19;
#[derive(Debug, Clone)]
pub struct AbsorberLayer {
pub refractive_index: f64,
pub thickness_m: f64,
pub bandgap_wavelength_m: f64,
pub material: Option<AbsorptionMaterial>,
pub extinction_coeff_const: f64,
}
impl AbsorberLayer {
pub fn alpha_at_m(&self, wl_m: f64) -> f64 {
match &self.material {
Some(mat) => mat.alpha_at_nm(wl_m * 1e9),
None => {
4.0 * PI * self.extinction_coeff_const / wl_m
}
}
}
pub fn k_at_m(&self, wl_m: f64) -> f64 {
let alpha = self.alpha_at_m(wl_m);
alpha * wl_m / (4.0 * PI)
}
}
#[derive(Debug, Clone)]
pub struct ArCoatingSpec {
pub n: f64,
pub thickness_m: f64,
}
#[derive(Debug, Clone)]
pub struct BackReflectorSpec {
pub n_real: f64,
pub n_imag: f64,
pub thickness_m: f64,
}
#[derive(Debug, Clone)]
pub struct TexturingSpec {
pub period_m: f64,
pub depth_m: f64,
pub duty_cycle: f64,
pub n_orders: usize,
}
#[derive(Debug, Clone)]
pub struct SolarCellDesign {
pub absorber: AbsorberLayer,
pub ar_coating: Option<ArCoatingSpec>,
pub back_reflector: Option<BackReflectorSpec>,
pub texturing: Option<TexturingSpec>,
pub temperature_k: f64,
pub quantum_yield: f64,
}
#[derive(Debug, Clone)]
pub struct SpectralResponse {
pub wavelengths_m: Vec<f64>,
pub eqe: Vec<f64>,
pub iqe: Vec<f64>,
pub jsc_ma_cm2: f64,
pub photon_recycling_boost: f64,
}
pub fn compute_spectral_response(
cell: &SolarCellDesign,
wavelengths_m: &[f64],
am15g: &SolarSpectrum,
recycling_iterations: usize,
) -> Result<SpectralResponse, OxiPhotonError> {
let n_wl = wavelengths_m.len();
if n_wl < 2 {
return Err(OxiPhotonError::InvalidLayer(
"compute_spectral_response requires at least 2 wavelength points".into(),
));
}
let mut eqe = vec![0.0_f64; n_wl];
let mut iqe = vec![0.0_f64; n_wl];
let mut recycling_factors = vec![1.0_f64; n_wl];
for (wi, &wl) in wavelengths_m.iter().enumerate() {
if wl > cell.absorber.bandgap_wavelength_m {
eqe[wi] = 0.0;
iqe[wi] = 0.0;
recycling_factors[wi] = 1.0;
continue;
}
let (a_optical, r_front) = single_wavelength_absorption(cell, wl);
let n_abs = cell.absorber.refractive_index;
let alpha = cell.absorber.alpha_at_m(wl);
let d = cell.absorber.thickness_m;
let four_n2 = 4.0 * n_abs * n_abs;
let escape_prob = 1.0 / four_n2 + (1.0 - 1.0 / four_n2) * (-alpha * four_n2 * d).exp();
let q = cell.quantum_yield;
let recycling_denom = 1.0 - q * (1.0 - escape_prob);
let a_star = if recycling_denom > 1e-12 {
(a_optical / recycling_denom).min(1.0)
} else {
1.0_f64
};
let mut a = a_star;
for _ in 0..recycling_iterations {
let f_a = (a_optical + q * (1.0 - escape_prob) * a).min(1.0);
let a_new = 0.5 * f_a + 0.5 * a;
let diff = (a_new - a).abs();
a = a_new;
if diff < 1e-8 {
break;
}
}
let recycling_factor = if a_optical > 1e-10 {
a / a_optical
} else {
1.0
};
recycling_factors[wi] = recycling_factor;
eqe[wi] = a.clamp(0.0, 1.0);
iqe[wi] = if (1.0 - r_front) > 1e-10 {
(eqe[wi] / (1.0 - r_front)).min(1.0)
} else {
0.0
};
}
let jsc_a_m2 = trapz_jsc(wavelengths_m, &eqe, am15g);
let jsc_ma_cm2 = jsc_a_m2 * ELECTRON_CHARGE * 0.1;
let n_above: usize = wavelengths_m
.iter()
.filter(|&&wl| wl <= cell.absorber.bandgap_wavelength_m)
.count();
let avg_recycling = if n_above > 0 {
wavelengths_m
.iter()
.zip(recycling_factors.iter())
.filter(|(&wl, _)| wl <= cell.absorber.bandgap_wavelength_m)
.map(|(_, &rf)| rf)
.sum::<f64>()
/ n_above as f64
} else {
1.0
};
Ok(SpectralResponse {
wavelengths_m: wavelengths_m.to_vec(),
eqe,
iqe,
jsc_ma_cm2,
photon_recycling_boost: avg_recycling,
})
}
fn single_wavelength_absorption(cell: &SolarCellDesign, wl_m: f64) -> (f64, f64) {
let nc0 = Complex64::new(1.0, 0.0); let nc4 = Complex64::new(1.0, 0.0);
let (nc1, d1_m) = match &cell.ar_coating {
Some(ar) => (Complex64::new(ar.n, 0.0), ar.thickness_m),
None => (Complex64::new(1.0, 0.0), 0.0),
};
let n_abs = cell.absorber.refractive_index;
let k_abs = cell.absorber.k_at_m(wl_m);
let nc2 = Complex64::new(n_abs, -k_abs); let d2_m = cell.absorber.thickness_m;
let (nc3, d3_m) = match &cell.back_reflector {
Some(br) => (Complex64::new(br.n_real, -br.n_imag), br.thickness_m),
None => (Complex64::new(1.0, 0.0), 0.0),
};
let im = Complex64::new(0.0, 1.0);
let m1 = char_matrix(nc1, d1_m, wl_m, im);
let m2 = char_matrix(nc2, d2_m, wl_m, im);
let m3 = char_matrix(nc3, d3_m, wl_m, im);
let m = mat2_mul(mat2_mul(m1, m2), m3);
let denom = nc0 * m[0][0] + nc0 * nc4 * m[0][1] + m[1][0] + nc4 * m[1][1];
if denom.norm() < 1e-30 {
return (0.0, 0.0);
}
let numer_r = nc0 * m[0][0] + nc0 * nc4 * m[0][1] - m[1][0] - nc4 * m[1][1];
let r_amp = numer_r / denom;
let r_sq = r_amp.norm_sqr();
let reflectance = if r_sq.is_finite() {
r_sq.clamp(0.0, 1.0)
} else {
1.0
};
let t_amp = (Complex64::new(2.0, 0.0) * nc0) / denom;
let transmittance = if nc0.re > 1e-10 {
let t_raw = nc4.re / nc0.re * t_amp.norm_sqr();
if t_raw.is_finite() {
t_raw.clamp(0.0, 1.0 - reflectance)
} else {
0.0
}
} else {
0.0
};
let absorptance = (1.0 - reflectance - transmittance).max(0.0);
(absorptance, reflectance)
}
fn char_matrix(nc: Complex64, d_m: f64, lambda_m: f64, im: Complex64) -> [[Complex64; 2]; 2] {
let delta = Complex64::new(2.0 * PI, 0.0) * nc * d_m / lambda_m;
let cos_d = delta.cos();
let sin_d = delta.sin();
[[cos_d, im * sin_d / nc], [im * nc * sin_d, cos_d]]
}
fn mat2_mul(a: [[Complex64; 2]; 2], b: [[Complex64; 2]; 2]) -> [[Complex64; 2]; 2] {
[
[
a[0][0] * b[0][0] + a[0][1] * b[1][0],
a[0][0] * b[0][1] + a[0][1] * b[1][1],
],
[
a[1][0] * b[0][0] + a[1][1] * b[1][0],
a[1][0] * b[0][1] + a[1][1] * b[1][1],
],
]
}
#[derive(Debug, Clone)]
pub struct DriftDiffusionDeviceConfig {
pub doping: crate::solar::drift_diffusion::DopingProfile,
pub n_nodes: usize,
}
pub fn material_for_absorber(
cell: &SolarCellDesign,
) -> Result<crate::solar::drift_diffusion::SemiconductorMaterial, OxiPhotonError> {
use crate::solar::drift_diffusion::SemiconductorMaterial;
let bw = cell.absorber.bandgap_wavelength_m;
if (bw - 1107e-9_f64).abs() < 50e-9 {
Ok(SemiconductorMaterial::silicon())
} else if (bw - 870e-9_f64).abs() < 50e-9 {
Ok(SemiconductorMaterial::gaas())
} else {
Err(OxiPhotonError::MaterialNotFound(format!(
"No semiconductor material for bandgap_wavelength {:.0} nm",
bw * 1e9
)))
}
}
pub fn single_wavelength_absorption_z(
cell: &SolarCellDesign,
wl_m: f64,
n_z_samples: usize,
) -> (f64, f64, Vec<f64>) {
let nc0 = Complex64::new(1.0, 0.0); let nc4 = Complex64::new(1.0, 0.0);
let (nc1, d1_m) = match &cell.ar_coating {
Some(ar) => (Complex64::new(ar.n, 0.0), ar.thickness_m),
None => (Complex64::new(1.0, 0.0), 0.0),
};
let n_abs = cell.absorber.refractive_index;
let k_abs = cell.absorber.k_at_m(wl_m);
let nc2 = Complex64::new(n_abs, -k_abs);
let d2_m = cell.absorber.thickness_m;
let (nc3, d3_m) = match &cell.back_reflector {
Some(br) => (Complex64::new(br.n_real, -br.n_imag), br.thickness_m),
None => (Complex64::new(1.0, 0.0), 0.0),
};
let im = Complex64::new(0.0, 1.0);
let m1 = char_matrix(nc1, d1_m, wl_m, im);
let m2 = char_matrix(nc2, d2_m, wl_m, im);
let m3 = char_matrix(nc3, d3_m, wl_m, im);
let m_full = mat2_mul(mat2_mul(m1, m2), m3);
let denom = nc0 * m_full[0][0] + nc0 * nc4 * m_full[0][1] + m_full[1][0] + nc4 * m_full[1][1];
if denom.norm() < 1e-30 {
return (0.0, 0.0, vec![0.0; n_z_samples]);
}
let numer_r = nc0 * m_full[0][0] + nc0 * nc4 * m_full[0][1] - m_full[1][0] - nc4 * m_full[1][1];
let r_amp = numer_r / denom;
let r_sq = r_amp.norm_sqr();
let reflectance = if r_sq.is_finite() {
r_sq.clamp(0.0, 1.0)
} else {
1.0
};
let t_amp = (Complex64::new(2.0, 0.0) * nc0) / denom;
let transmittance = if nc0.re > 1e-10 {
let t_raw = nc4.re / nc0.re * t_amp.norm_sqr();
if t_raw.is_finite() {
t_raw.clamp(0.0, 1.0 - reflectance)
} else {
0.0
}
} else {
0.0
};
let absorptance = (1.0 - reflectance - transmittance).max(0.0);
let m_abs_br = mat2_mul(m2, m3);
let e_sub = t_amp;
let h_sub = nc4 * t_amp;
let e_a_in = m_abs_br[0][0] * e_sub + m_abs_br[0][1] * h_sub;
let h_a_in = m_abs_br[1][0] * e_sub + m_abs_br[1][1] * h_sub;
let nc2_safe = if nc2.norm() < 1e-30 {
Complex64::new(1.0, 0.0)
} else {
nc2
};
let e_f0 = (e_a_in + h_a_in / nc2_safe) * 0.5;
let e_b0 = (e_a_in - h_a_in / nc2_safe) * 0.5;
let k_z = Complex64::new(2.0 * PI / wl_m, 0.0) * nc2; let coeff = 4.0 * PI * n_abs * k_abs / (wl_m * nc0.re);
let n_z = n_z_samples.max(1);
let dz = d2_m / n_z as f64;
let mut a_z_raw: Vec<f64> = Vec::with_capacity(n_z);
for k in 0..n_z {
let z = (k as f64 + 0.5) * dz;
let e_fwd = e_f0 * (Complex64::new(0.0, -1.0) * k_z * z).exp();
let e_bwd = e_b0 * (Complex64::new(0.0, 1.0) * k_z * z).exp();
let e_total = e_fwd + e_bwd;
let az = coeff * e_total.norm_sqr();
a_z_raw.push(if az.is_finite() && az >= 0.0 { az } else { 0.0 });
}
let sum_az_dz: f64 = a_z_raw.iter().sum::<f64>() * dz;
let a_z = if sum_az_dz > 1e-30 && absorptance > 1e-30 {
let scale = absorptance / sum_az_dz;
a_z_raw.into_iter().map(|v| v * scale).collect()
} else {
vec![0.0; n_z]
};
(absorptance, reflectance, a_z)
}
pub fn compute_spectral_response_dd(
cell: &SolarCellDesign,
device_config: &DriftDiffusionDeviceConfig,
wavelengths_m: &[f64],
am15g: &SolarSpectrum,
) -> Result<SpectralResponse, OxiPhotonError> {
use crate::solar::drift_diffusion::DriftDiffusionDevice;
let n_wl = wavelengths_m.len();
if n_wl < 2 {
return Err(OxiPhotonError::InvalidLayer(
"compute_spectral_response_dd requires at least 2 wavelength points".into(),
));
}
let mat = material_for_absorber(cell)?;
let n = device_config.n_nodes;
let d_cm = cell.absorber.thickness_m * 100.0; let dx_cm = d_cm / n as f64;
let mut device = DriftDiffusionDevice::new(mat, device_config.doping.clone(), d_cm, n)?;
device.solve_equilibrium()?;
let mut eqe = Vec::with_capacity(n_wl);
let mut iqe = Vec::with_capacity(n_wl);
let mut r_fronts = Vec::with_capacity(n_wl);
for &wl in wavelengths_m {
if wl > cell.absorber.bandgap_wavelength_m {
eqe.push(0.0);
iqe.push(0.0);
r_fronts.push(0.0);
continue;
}
let (absorptance, r_front, a_z) = single_wavelength_absorption_z(cell, wl, n);
r_fronts.push(r_front);
let phi = am15g.photon_flux(wl);
const G_REF_CM2S: f64 = 1e17; let dz_m = cell.absorber.thickness_m / n as f64;
let sum_az_dz: f64 = a_z.iter().sum::<f64>() * dz_m;
let gen_profile_cm3s: Vec<f64> = if sum_az_dz < 1e-30 || phi < 1e-10 {
vec![0.0_f64; n]
} else {
a_z.iter()
.map(|&az| {
let weight = az * dz_m / sum_az_dz;
weight * G_REF_CM2S / dx_cm
})
.collect()
};
let iv = device.solve_illuminated_iv(&[0.0_f64], &gen_profile_cm3s)?;
let j_sc = iv.first().map(|(_, j)| j.abs()).unwrap_or(0.0);
let gen_integral: f64 = gen_profile_cm3s.iter().sum::<f64>() * dx_cm;
let iqe_i = if gen_integral > 1e-20 && j_sc.is_finite() {
(j_sc / (Q * gen_integral)).clamp(0.0, 1.0)
} else {
0.0
};
let eqe_i = (iqe_i * absorptance).clamp(0.0, 1.0);
eqe.push(eqe_i);
iqe.push(iqe_i);
}
let jsc_total = if n_wl >= 2 {
let mut integral = 0.0_f64;
for i in 0..n_wl - 1 {
let wl1 = wavelengths_m[i];
let wl2 = wavelengths_m[i + 1];
let dwl = (wl2 - wl1).abs();
let phi1 = am15g.photon_flux(wl1);
let phi2 = am15g.photon_flux(wl2);
integral += 0.5 * (eqe[i] * phi1 + eqe[i + 1] * phi2) * dwl;
}
ELECTRON_CHARGE * integral * 0.1
} else {
0.0
};
Ok(SpectralResponse {
wavelengths_m: wavelengths_m.to_vec(),
eqe,
iqe,
jsc_ma_cm2: jsc_total,
photon_recycling_boost: 1.0,
})
}
fn trapz_jsc(wavelengths_m: &[f64], eqe: &[f64], am15g: &SolarSpectrum) -> f64 {
let n = wavelengths_m.len();
if n < 2 {
return 0.0;
}
let mut jsc = 0.0_f64;
for i in 0..n - 1 {
let wl1 = wavelengths_m[i];
let wl2 = wavelengths_m[i + 1];
let dwl = (wl2 - wl1).abs();
let irr1 = am15g.irradiance_at(wl1); let irr2 = am15g.irradiance_at(wl2);
let phi1 = irr1 * wl1 / (PLANCK_H * SPEED_OF_LIGHT);
let phi2 = irr2 * wl2 / (PLANCK_H * SPEED_OF_LIGHT);
jsc += 0.5 * (eqe[i] * phi1 + eqe[i + 1] * phi2) * dwl;
}
jsc
}
#[cfg(test)]
mod tests {
use super::*;
fn si_absorber() -> AbsorberLayer {
AbsorberLayer {
refractive_index: 3.5,
thickness_m: 300e-6,
bandgap_wavelength_m: 1107e-9,
material: Some(AbsorptionMaterial::crystalline_silicon()),
extinction_coeff_const: 0.0,
}
}
#[test]
fn single_wavelength_absorption_bounded() {
let cell = SolarCellDesign {
absorber: si_absorber(),
ar_coating: None,
back_reflector: None,
texturing: None,
temperature_k: 300.0,
quantum_yield: 0.0,
};
for wl_nm in [400, 600, 800, 1000] {
let wl_m = wl_nm as f64 * 1e-9;
let (a, r) = single_wavelength_absorption(&cell, wl_m);
assert!(
(0.0..=1.0).contains(&a),
"A out of [0,1] at {wl_nm}nm: {a:.4}"
);
assert!(
(0.0..=1.0).contains(&r),
"R out of [0,1] at {wl_nm}nm: {r:.4}"
);
}
}
#[test]
fn k_derived_from_alpha_positive() {
let abs = si_absorber();
for wl_nm in [400, 700, 1000] {
let k = abs.k_at_m(wl_nm as f64 * 1e-9);
assert!(k >= 0.0, "k should be non-negative, got {k}");
}
}
#[test]
fn photon_recycling_factor_q_zero_no_boost() {
let cell = SolarCellDesign {
absorber: si_absorber(),
ar_coating: None,
back_reflector: None,
texturing: None,
temperature_k: 300.0,
quantum_yield: 0.0,
};
let am15g = SolarSpectrum::am15g();
let wls: Vec<f64> = (400..=800).step_by(100).map(|w| w as f64 * 1e-9).collect();
let r1 = compute_spectral_response(&cell, &wls, &am15g, 1).expect("r1 failed");
let r5 = compute_spectral_response(&cell, &wls, &am15g, 5).expect("r5 failed");
let max_diff = r1
.eqe
.iter()
.zip(r5.eqe.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff < 1e-12,
"q=0 should have no iteration effect: max_diff={max_diff:.2e}"
);
}
}