use crate::error::OxiPhotonError;
#[derive(Debug, Clone)]
pub struct ThermalFdtd3d {
pub nx: usize,
pub ny: usize,
pub nz: usize,
pub dx: f64, pub dy: f64, pub dz: f64,
pub temperature: Vec<f64>,
pub base_temperature: f64,
pub dn_dt: Vec<f64>,
pub eps_base: Vec<f64>,
pub eps_current: Vec<f64>,
}
impl ThermalFdtd3d {
pub fn new(nx: usize, ny: usize, nz: usize, dx: f64, dy: f64, dz: f64, base_temp: f64) -> Self {
let n = nx * ny * nz;
Self {
nx,
ny,
nz,
dx,
dy,
dz,
temperature: vec![base_temp; n],
base_temperature: base_temp,
dn_dt: vec![0.0; n],
eps_base: vec![1.0; n],
eps_current: vec![1.0; n],
}
}
#[inline]
pub fn idx(&self, i: usize, j: usize, k: usize) -> usize {
i * self.ny * self.nz + j * self.nz + k
}
#[allow(clippy::too_many_arguments)]
pub fn set_thermo_optic(
&mut self,
i0: usize,
i1: usize,
j0: usize,
j1: usize,
k0: usize,
k1: usize,
dn_dt: f64,
) {
for i in i0..i1.min(self.nx) {
for j in j0..j1.min(self.ny) {
for k in k0..k1.min(self.nz) {
let idx = self.idx(i, j, k);
self.dn_dt[idx] = dn_dt;
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn set_eps_region(
&mut self,
i0: usize,
i1: usize,
j0: usize,
j1: usize,
k0: usize,
k1: usize,
eps: f64,
) {
for i in i0..i1.min(self.nx) {
for j in j0..j1.min(self.ny) {
for k in k0..k1.min(self.nz) {
let idx = self.idx(i, j, k);
self.eps_base[idx] = eps;
self.eps_current[idx] = eps;
}
}
}
}
pub fn apply_temperature_field(&mut self) {
let t_base = self.base_temperature;
for idx in 0..(self.nx * self.ny * self.nz) {
let n0 = self.eps_base[idx].max(0.0).sqrt();
let delta_t = self.temperature[idx] - t_base;
let n = n0 + self.dn_dt[idx] * delta_t;
let n_clamped = n.max(1e-6);
self.eps_current[idx] = n_clamped * n_clamped;
}
}
pub fn set_uniform_temperature(&mut self, temp_k: f64) {
for t in self.temperature.iter_mut() {
*t = temp_k;
}
}
pub fn set_temperature_profile(&mut self, profile: &[f64]) -> Result<(), OxiPhotonError> {
let expected = self.nx * self.ny * self.nz;
if profile.len() != expected {
return Err(OxiPhotonError::NumericalError(format!(
"Temperature profile length {} does not match grid size {}",
profile.len(),
expected
)));
}
self.temperature.copy_from_slice(profile);
Ok(())
}
pub fn refractive_index_at(&self, i: usize, j: usize, k: usize) -> f64 {
let idx = self.idx(i, j, k);
self.eps_current[idx].max(0.0).sqrt()
}
pub fn max_delta_t(&self) -> f64 {
self.temperature
.iter()
.map(|&t| (t - self.base_temperature).abs())
.fold(0.0_f64, f64::max)
}
pub fn mean_temperature(&self) -> f64 {
let n = self.temperature.len();
if n == 0 {
return self.base_temperature;
}
self.temperature.iter().sum::<f64>() / n as f64
}
}
#[derive(Debug, Clone)]
pub struct HeatSolver3d {
pub nx: usize,
pub ny: usize,
pub nz: usize,
pub dx: f64, pub dy: f64, pub dz: f64, pub dt: f64,
pub temperature: Vec<f64>,
pub thermal_diffusivity: Vec<f64>,
pub volumetric_heat_capacity: Vec<f64>,
pub heat_source: Vec<f64>,
pub ambient_temperature: f64,
pub convection_coefficient: f64,
}
impl HeatSolver3d {
#[allow(clippy::too_many_arguments)]
pub fn new(
nx: usize,
ny: usize,
nz: usize,
dx: f64,
dy: f64,
dz: f64,
dt: f64,
ambient_temp: f64,
) -> Self {
let n = nx * ny * nz;
let alpha_air = 2.1e-5_f64;
let rho_cp_default = 1.0e6_f64;
Self {
nx,
ny,
nz,
dx,
dy,
dz,
dt,
temperature: vec![ambient_temp; n],
thermal_diffusivity: vec![alpha_air; n],
volumetric_heat_capacity: vec![rho_cp_default; n],
heat_source: vec![0.0; n],
ambient_temperature: ambient_temp,
convection_coefficient: 0.0,
}
}
#[allow(clippy::too_many_arguments)]
pub fn for_silicon(
nx: usize,
ny: usize,
nz: usize,
dx: f64,
dy: f64,
dz: f64,
dt: f64,
ambient_temp: f64,
) -> Self {
let mut sim = Self::new(nx, ny, nz, dx, dy, dz, dt, ambient_temp);
let n = nx * ny * nz;
let alpha_si = 8.8e-5_f64;
let rho_cp_si = 1.66e6_f64;
sim.thermal_diffusivity = vec![alpha_si; n];
sim.volumetric_heat_capacity = vec![rho_cp_si; n];
sim
}
#[allow(clippy::too_many_arguments)]
pub fn for_copper(
nx: usize,
ny: usize,
nz: usize,
dx: f64,
dy: f64,
dz: f64,
dt: f64,
ambient_temp: f64,
) -> Self {
let mut sim = Self::new(nx, ny, nz, dx, dy, dz, dt, ambient_temp);
let n = nx * ny * nz;
let alpha_cu = 1.17e-4_f64;
let rho_cp_cu = 3.44e6_f64;
sim.thermal_diffusivity = vec![alpha_cu; n];
sim.volumetric_heat_capacity = vec![rho_cp_cu; n];
sim
}
#[inline]
pub fn idx(&self, i: usize, j: usize, k: usize) -> usize {
i * self.ny * self.nz + j * self.nz + k
}
fn laplacian(&self, temp: &[f64], i: usize, j: usize, k: usize) -> f64 {
let get =
|ii: usize, jj: usize, kk: usize| temp[ii * self.ny * self.nz + jj * self.nz + kk];
let ip = if i + 1 < self.nx { i + 1 } else { i };
let im = if i > 0 { i - 1 } else { i };
let jp = if j + 1 < self.ny { j + 1 } else { j };
let jm = if j > 0 { j - 1 } else { j };
let kp = if k + 1 < self.nz { k + 1 } else { k };
let km = if k > 0 { k - 1 } else { k };
let t_c = get(i, j, k);
let d2x = (get(ip, j, k) - 2.0 * t_c + get(im, j, k)) / (self.dx * self.dx);
let d2y = (get(i, jp, k) - 2.0 * t_c + get(i, jm, k)) / (self.dy * self.dy);
let d2z = (get(i, j, kp) - 2.0 * t_c + get(i, j, km)) / (self.dz * self.dz);
d2x + d2y + d2z
}
pub fn step(&mut self) {
let t_old = self.temperature.clone();
let mut t_new = t_old.clone();
for i in 0..self.nx {
for j in 0..self.ny {
for k in 0..self.nz {
let idx = self.idx(i, j, k);
let lap = self.laplacian(&t_old, i, j, k);
let alpha = self.thermal_diffusivity[idx];
let q = self.heat_source[idx];
t_new[idx] = t_old[idx] + self.dt * (alpha * lap + q);
}
}
}
if self.convection_coefficient > 0.0 {
let h = self.convection_coefficient;
let t_amb = self.ambient_temperature;
let dt = self.dt;
for j in 0..self.ny {
for k in 0..self.nz {
let i0 = self.idx(0, j, k);
let i1 = self.idx(self.nx - 1, j, k);
let rcp0 = self.volumetric_heat_capacity[i0];
let rcp1 = self.volumetric_heat_capacity[i1];
if rcp0 > 1e-30 {
t_new[i0] += -2.0 * h * dt * (t_old[i0] - t_amb) / (rcp0 * self.dx);
}
if rcp1 > 1e-30 {
t_new[i1] += -2.0 * h * dt * (t_old[i1] - t_amb) / (rcp1 * self.dx);
}
}
}
for i in 0..self.nx {
for k in 0..self.nz {
let j0 = self.idx(i, 0, k);
let j1 = self.idx(i, self.ny - 1, k);
let rcp0 = self.volumetric_heat_capacity[j0];
let rcp1 = self.volumetric_heat_capacity[j1];
if rcp0 > 1e-30 {
t_new[j0] += -2.0 * h * dt * (t_old[j0] - t_amb) / (rcp0 * self.dy);
}
if rcp1 > 1e-30 {
t_new[j1] += -2.0 * h * dt * (t_old[j1] - t_amb) / (rcp1 * self.dy);
}
}
}
for i in 0..self.nx {
for j in 0..self.ny {
let k0 = self.idx(i, j, 0);
let k1 = self.idx(i, j, self.nz - 1);
let rcp0 = self.volumetric_heat_capacity[k0];
let rcp1 = self.volumetric_heat_capacity[k1];
if rcp0 > 1e-30 {
t_new[k0] += -2.0 * h * dt * (t_old[k0] - t_amb) / (rcp0 * self.dz);
}
if rcp1 > 1e-30 {
t_new[k1] += -2.0 * h * dt * (t_old[k1] - t_amb) / (rcp1 * self.dz);
}
}
}
}
self.temperature = t_new;
}
pub fn inject_heat_source(&mut self, sigma: &[f64], e_field_sq: &[f64]) {
let n = self.nx * self.ny * self.nz;
let n_sigma = sigma.len().min(n);
let n_esq = e_field_sq.len().min(n);
for idx in 0..n {
let s = if idx < n_sigma { sigma[idx] } else { 0.0 };
let esq = if idx < n_esq { e_field_sq[idx] } else { 0.0 };
self.heat_source[idx] = s * esq;
}
}
#[allow(clippy::too_many_arguments)]
pub fn set_diffusivity_region(
&mut self,
i0: usize,
i1: usize,
j0: usize,
j1: usize,
k0: usize,
k1: usize,
alpha: f64,
) {
for i in i0..i1.min(self.nx) {
for j in j0..j1.min(self.ny) {
for k in k0..k1.min(self.nz) {
let idx = self.idx(i, j, k);
self.thermal_diffusivity[idx] = alpha;
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn set_rho_cp_region(
&mut self,
i0: usize,
i1: usize,
j0: usize,
j1: usize,
k0: usize,
k1: usize,
rho_cp: f64,
) {
for i in i0..i1.min(self.nx) {
for j in j0..j1.min(self.ny) {
for k in k0..k1.min(self.nz) {
let idx = self.idx(i, j, k);
self.volumetric_heat_capacity[idx] = rho_cp;
}
}
}
}
pub fn set_temperature(&mut self, profile: &[f64]) -> Result<(), OxiPhotonError> {
let expected = self.nx * self.ny * self.nz;
if profile.len() != expected {
return Err(OxiPhotonError::NumericalError(format!(
"Temperature profile length {} does not match grid size {}",
profile.len(),
expected
)));
}
self.temperature.copy_from_slice(profile);
Ok(())
}
pub fn max_temperature(&self) -> f64 {
self.temperature
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn check_cfl_stability(&self) -> bool {
let alpha_max = self
.thermal_diffusivity
.iter()
.cloned()
.fold(0.0_f64, f64::max);
if alpha_max < 1e-30 {
return true; }
let ds_sq_min = self.dx.powi(2).min(self.dy.powi(2)).min(self.dz.powi(2));
self.dt < ds_sq_min / (6.0 * alpha_max)
}
pub fn is_steady_state(&self, threshold_k_per_s: f64) -> bool {
for i in 0..self.nx {
for j in 0..self.ny {
for k in 0..self.nz {
let idx = self.idx(i, j, k);
let alpha = self.thermal_diffusivity[idx];
let lap = self.laplacian(&self.temperature, i, j, k);
let q = self.heat_source[idx];
let rate = (alpha * lap + q).abs();
if rate >= threshold_k_per_s {
return false;
}
}
}
}
true
}
}
pub struct ThermoOpticCoupler {
pub thermal: ThermalFdtd3d,
pub heat_solver: HeatSolver3d,
pub optical_steps_per_thermal: usize,
pub sigma_e: Vec<f64>,
}
impl ThermoOpticCoupler {
pub fn new(
thermal: ThermalFdtd3d,
heat_solver: HeatSolver3d,
optical_steps_per_thermal: usize,
sigma_e: Vec<f64>,
) -> Self {
Self {
thermal,
heat_solver,
optical_steps_per_thermal,
sigma_e,
}
}
pub fn update_optical_from_thermal(&mut self) {
let n = self
.thermal
.temperature
.len()
.min(self.heat_solver.temperature.len());
self.thermal.temperature[..n].copy_from_slice(&self.heat_solver.temperature[..n]);
self.thermal.apply_temperature_field();
}
pub fn compute_e_field_squared(ex: &[f64], ey: &[f64], ez: &[f64]) -> Vec<f64> {
let n = ex.len().max(ey.len()).max(ez.len());
let mut out = vec![0.0_f64; n];
for idx in 0..n {
let vx = if idx < ex.len() { ex[idx] } else { 0.0 };
let vy = if idx < ey.len() { ey[idx] } else { 0.0 };
let vz = if idx < ez.len() { ez[idx] } else { 0.0 };
out[idx] = vx * vx + vy * vy + vz * vz;
}
out
}
pub fn thermal_update_step(&mut self, ex: &[f64], ey: &[f64], ez: &[f64]) {
let e_sq = Self::compute_e_field_squared(ex, ey, ez);
let sigma_clone = self.sigma_e.clone();
self.heat_solver.inject_heat_source(&sigma_clone, &e_sq);
self.heat_solver.step();
self.update_optical_from_thermal();
}
}
#[cfg(test)]
mod tests {
use super::*;
const BASE_T: f64 = 300.0;
fn small_thermal_grid() -> ThermalFdtd3d {
ThermalFdtd3d::new(4, 4, 4, 1e-6, 1e-6, 1e-6, BASE_T)
}
fn small_heat_solver(dt: f64) -> HeatSolver3d {
HeatSolver3d::new(4, 4, 4, 1e-6, 1e-6, 1e-6, dt, BASE_T)
}
#[test]
fn test_thermal_fdtd_creation() {
let grid = small_thermal_grid();
for &t in &grid.temperature {
assert!(
(t - BASE_T).abs() < 1e-10,
"Initial temperature should be {BASE_T} K, got {t}"
);
}
for &eps in &grid.eps_current {
assert!(
(eps - 1.0).abs() < 1e-10,
"Default permittivity should be 1.0, got {eps}"
);
}
}
#[test]
fn test_set_uniform_temperature() {
let mut grid = small_thermal_grid();
let new_t = 400.0_f64;
grid.set_uniform_temperature(new_t);
for &t in &grid.temperature {
assert!(
(t - new_t).abs() < 1e-10,
"All cells should be {new_t} K, got {t}"
);
}
}
#[test]
fn test_apply_temperature_field() {
let mut grid = small_thermal_grid();
let eps_base = 12.25; grid.set_eps_region(0, 4, 0, 4, 0, 4, eps_base);
let dn_dt = 1.8e-4_f64;
grid.set_thermo_optic(0, 4, 0, 4, 0, 4, dn_dt);
let delta_t = 100.0;
grid.set_uniform_temperature(BASE_T + delta_t);
grid.apply_temperature_field();
let n0 = eps_base.sqrt();
let n_expected = n0 + dn_dt * delta_t;
let eps_expected = n_expected * n_expected;
for &eps in &grid.eps_current {
assert!(
(eps - eps_expected).abs() < 1e-6,
"ε(T) should be {eps_expected:.6}, got {eps:.6}"
);
}
assert!(
eps_expected > eps_base,
"ε(T) should increase for positive dn/dT and positive ΔT"
);
}
#[test]
fn test_thermal_fdtd_profile_size_error() {
let mut grid = small_thermal_grid();
let bad_profile = vec![350.0_f64; 3]; let result = grid.set_temperature_profile(&bad_profile);
assert!(
result.is_err(),
"Should return error for mismatched profile size"
);
}
#[test]
fn test_heat_solver_creation() {
let dt = 5.0e-9_f64;
let solver = small_heat_solver(dt);
assert!(
solver.check_cfl_stability(),
"CFL should be satisfied for dt={dt:.2e} with default air diffusivity"
);
for &t in &solver.temperature {
assert!(
(t - BASE_T).abs() < 1e-10,
"Initial temperature should be {BASE_T} K"
);
}
}
#[test]
fn test_heat_solver_step() {
let nx = 6;
let ny = 6;
let nz = 6;
let dx = 1e-6_f64;
let alpha_si = 8.8e-5_f64; let dt = 1.0e-9_f64;
let mut solver = HeatSolver3d::new(nx, ny, nz, dx, dx, dx, dt, BASE_T);
solver.set_diffusivity_region(0, nx, 0, ny, 0, nz, alpha_si);
let hot_idx = solver.idx(3, 3, 3);
solver.temperature[hot_idx] = BASE_T + 200.0;
let t_before = solver.temperature[hot_idx];
solver.step();
let t_after = solver.temperature[hot_idx];
assert!(
t_after < t_before,
"Hot spot should cool after one step: {t_before:.2} → {t_after:.2}"
);
let nb_idx = solver.idx(4, 3, 3);
assert!(
solver.temperature[nb_idx] > BASE_T,
"Neighbour cell should warm above ambient after hot-spot step"
);
}
#[test]
fn test_cfl_stability() {
let dx = 1e-6_f64;
let alpha = 8.8e-5_f64; let dt_stable = 1.0e-9_f64;
let solver_ok = HeatSolver3d::new(4, 4, 4, dx, dx, dx, dt_stable, BASE_T);
let mut solver_ok = solver_ok;
solver_ok.set_diffusivity_region(0, 4, 0, 4, 0, 4, alpha);
assert!(
solver_ok.check_cfl_stability(),
"dt={dt_stable:.2e} should be stable for silicon"
);
let dt_bad = 1.0e-7_f64;
let mut solver_bad = HeatSolver3d::new(4, 4, 4, dx, dx, dx, dt_bad, BASE_T);
solver_bad.set_diffusivity_region(0, 4, 0, 4, 0, 4, alpha);
assert!(
!solver_bad.check_cfl_stability(),
"dt={dt_bad:.2e} should be unstable for silicon"
);
}
#[test]
fn test_inject_heat_source() {
let mut solver = small_heat_solver(1e-9);
let n = 4 * 4 * 4;
let sigma = vec![1.0e4_f64; n]; let e_sq = vec![1.0e6_f64; n];
solver.inject_heat_source(&sigma, &e_sq);
let expected_q = 1.0e10_f64;
for (idx, &q) in solver.heat_source.iter().enumerate() {
assert!(
(q - expected_q).abs() < 1e3,
"Heat source at {idx} should be {expected_q:.2e}, got {q:.2e}"
);
}
}
#[test]
fn test_thermo_optic_coupler_e_field_squared() {
let n = 8;
let ex = vec![1.0_f64; n];
let ey = vec![2.0_f64; n];
let ez = vec![3.0_f64; n];
let e_sq = ThermoOpticCoupler::compute_e_field_squared(&ex, &ey, &ez);
for &v in &e_sq {
assert!((v - 14.0).abs() < 1e-10, "|E|² should be 14.0, got {v}");
}
}
}