#[derive(Debug, Clone)]
pub struct DesignRegion {
pub nx: usize,
pub nz: usize,
pub dx: f64,
pub eps_min: f64,
pub eps_max: f64,
pub rho: Vec<f64>,
}
impl DesignRegion {
pub fn new(nx: usize, nz: usize, dx: f64, eps_min: f64, eps_max: f64) -> Self {
Self {
nx,
nz,
dx,
eps_min,
eps_max,
rho: vec![0.5; nx * nz],
}
}
pub fn soi_design(nx: usize, nz: usize, resolution_nm: f64) -> Self {
Self::new(nx, nz, resolution_nm * 1e-9, 2.09, 12.11)
}
pub fn epsilon(&self, i: usize, j: usize) -> f64 {
let rho = self.rho[j * self.nx + i];
self.eps_min + rho * (self.eps_max - self.eps_min)
}
pub fn set_rho(&mut self, rho: &[f64]) {
assert_eq!(rho.len(), self.nx * self.nz);
self.rho.copy_from_slice(rho);
}
pub fn n_params(&self) -> usize {
self.nx * self.nz
}
pub fn apply_sigmoid(&mut self, beta: f64, eta: f64) {
for rho in &mut self.rho {
let num = (beta * (*rho - eta)).tanh();
let den = (beta * (1.0 - eta)).tanh();
*rho = (num / den).clamp(-1.0, 1.0) * 0.5 + 0.5;
}
}
pub fn mean_epsilon(&self) -> f64 {
let sum: f64 = self
.rho
.iter()
.map(|&r| self.eps_min + r * (self.eps_max - self.eps_min))
.sum();
sum / self.n_params() as f64
}
pub fn fill_fraction(&self) -> f64 {
let n_filled = self.rho.iter().filter(|&&r| r > 0.5).count();
n_filled as f64 / self.n_params() as f64
}
}
#[derive(Debug, Clone)]
pub struct FomGradient {
pub grad: Vec<f64>,
pub fom: f64,
}
impl FomGradient {
pub fn new(n: usize) -> Self {
Self {
grad: vec![0.0; n],
fom: 0.0,
}
}
pub fn grad_norm(&self) -> f64 {
self.grad.iter().map(|g| g * g).sum::<f64>().sqrt()
}
pub fn max_grad(&self) -> f64 {
self.grad
.iter()
.cloned()
.fold(0.0_f64, |a, b| a.max(b.abs()))
}
}
pub struct AdjointSolver {
pub omega: f64,
pub region: DesignRegion,
pub step_size: f64,
pub fom: f64,
pub iteration: usize,
}
impl AdjointSolver {
pub fn new(omega: f64, region: DesignRegion) -> Self {
Self {
omega,
region,
step_size: 0.01,
fom: 0.0,
iteration: 0,
}
}
pub fn compute_gradient(
&self,
e_fwd: &[[f64; 2]], e_adj: &[[f64; 2]],
) -> FomGradient {
assert_eq!(e_fwd.len(), self.region.n_params());
assert_eq!(e_adj.len(), self.region.n_params());
let de_deps = self.region.eps_max - self.region.eps_min;
let scale = -de_deps * self.omega;
let grad: Vec<f64> = e_fwd
.iter()
.zip(e_adj.iter())
.map(|(&ef, &ea)| {
let overlap_re = ef[0] * ea[0] + ef[1] * ea[1];
scale * overlap_re
})
.collect();
let fom = e_fwd.iter().map(|&[re, im]| re * re + im * im).sum::<f64>();
FomGradient { grad, fom }
}
pub fn update_gradient_ascent(&mut self, gradient: &FomGradient) {
for (rho, &g) in self.region.rho.iter_mut().zip(&gradient.grad) {
*rho = (*rho + self.step_size * g).clamp(0.0, 1.0);
}
self.fom = gradient.fom;
self.iteration += 1;
}
#[allow(clippy::too_many_arguments)]
pub fn update_adam(
&mut self,
gradient: &FomGradient,
m: &mut [f64],
v: &mut [f64],
t: usize,
beta1: f64,
beta2: f64,
epsilon: f64,
) {
let alpha = self.step_size;
let t_f64 = t as f64;
for i in 0..self.region.n_params() {
let g = gradient.grad[i];
m[i] = beta1 * m[i] + (1.0 - beta1) * g;
v[i] = beta2 * v[i] + (1.0 - beta2) * g * g;
let m_hat = m[i] / (1.0 - beta1.powf(t_f64));
let v_hat = v[i] / (1.0 - beta2.powf(t_f64));
self.region.rho[i] =
(self.region.rho[i] + alpha * m_hat / (v_hat.sqrt() + epsilon)).clamp(0.0, 1.0);
}
self.fom = gradient.fom;
self.iteration += 1;
}
pub fn is_converged(&self, gradient: &FomGradient, tolerance: f64) -> bool {
gradient.grad_norm() < tolerance
}
}
pub struct AdjointSolver2d {
pub nx: usize,
pub nz: usize,
pub dx: f64,
pub dt: f64,
pub n_steps: usize,
}
impl AdjointSolver2d {
const GUARD: usize = 5;
const PML: usize = 10;
pub fn new(nx: usize, nz: usize, dx: f64) -> Self {
use crate::fdtd::config::{Dimensions, GridSpacing};
use crate::fdtd::courant::courant_dt;
let spacing = GridSpacing { dx, dy: dx, dz: dx };
let total_nx = nx + 2 * Self::GUARD + 2 * Self::PML;
let total_ny = nz + 2 * Self::GUARD + 2 * Self::PML;
let dt = 0.99
* courant_dt(
Dimensions::TwoD {
nx: total_nx,
ny: total_ny,
},
spacing,
1.0,
);
Self {
nx,
nz,
dx,
dt,
n_steps: 2000,
}
}
pub fn set_fdtd_steps(&mut self, n: usize) {
self.n_steps = n;
}
pub fn run_adjoint(
&self,
region: &DesignRegion,
monitor_cells: &[(usize, usize)],
fom_dconj_e: &[num_complex::Complex64],
wavelength_m: f64,
) -> Result<Vec<num_complex::Complex64>, crate::error::OxiPhotonError> {
use crate::error::OxiPhotonError;
use crate::fdtd::config::BoundaryConfig;
use crate::fdtd::dims::fdtd_2d::Fdtd2dTm;
use num_complex::Complex64;
use std::f64::consts::PI;
if monitor_cells.len() != fom_dconj_e.len() {
return Err(OxiPhotonError::NumericalError(format!(
"run_adjoint: monitor_cells.len()={} != fom_dconj_e.len()={}",
monitor_cells.len(),
fom_dconj_e.len()
)));
}
if wavelength_m <= 0.0 || !wavelength_m.is_finite() {
return Err(OxiPhotonError::InvalidWavelength(wavelength_m));
}
let guard = Self::GUARD;
let pml = Self::PML;
let dx = self.dx;
let total_nx = region.nx + 2 * guard + 2 * pml;
let total_ny = region.nz + 2 * guard + 2 * pml;
let bc = BoundaryConfig::pml(pml);
let mut sim = Fdtd2dTm::new(total_nx, total_ny, dx, dx, &bc);
let dt = sim.dt;
let offset_x = guard + pml;
let offset_y = guard + pml;
for rj in 0..region.nz {
for ri in 0..region.nx {
let gi = ri + offset_x;
let gj = rj + offset_y;
let eps = region.epsilon(ri, rj);
let idx = gj * total_nx + gi;
if idx < sim.eps_r.len() {
sim.eps_r[idx] = eps;
}
}
}
let c = 2.998e8_f64;
let f0 = c / wavelength_m;
let sigma = 4.0 / f0;
let t0 = 4.0 * sigma;
let omega0 = 2.0 * PI * f0;
let monitor_grid: Vec<(usize, usize)> = monitor_cells
.iter()
.map(|&(mi, mj)| {
let gi = (mi + offset_x).min(total_nx - 1);
let gj = (mj + offset_y).min(total_ny - 1);
(gi, gj)
})
.collect();
let n_cells = region.nx * region.nz;
let mut ez_re = vec![0.0_f64; n_cells];
let mut ez_im = vec![0.0_f64; n_cells];
let n_steps = self.n_steps;
for step in 0..n_steps {
let t = step as f64 * dt;
let env = (-(t - t0).powi(2) / (2.0 * sigma * sigma)).exp();
for (k, &(gi, gj)) in monitor_grid.iter().enumerate() {
let w = fom_dconj_e[k];
let carrier = w.re * (omega0 * t).cos() - w.im * (omega0 * t).sin();
let src_val = carrier * env;
sim.inject_ez(gi, gj, src_val);
}
sim.step();
let t_now = sim.current_time();
let phase_re = (omega0 * t_now).cos() * dt;
let phase_im = -(omega0 * t_now).sin() * dt;
for rj in 0..region.nz {
for ri in 0..region.nx {
let gi = ri + offset_x;
let gj = rj + offset_y;
let ez_val = if gi < total_nx && gj < total_ny {
sim.ez[gj * total_nx + gi]
} else {
0.0
};
let cell = rj * region.nx + ri;
ez_re[cell] += ez_val * phase_re;
ez_im[cell] += ez_val * phase_im;
}
}
}
for (&re, &im) in ez_re.iter().zip(ez_im.iter()) {
if !re.is_finite() || !im.is_finite() {
return Err(OxiPhotonError::NumericalError(
"FDTD adjoint simulation produced non-finite fields".to_string(),
));
}
}
let result: Vec<Complex64> = ez_re
.into_iter()
.zip(ez_im)
.map(|(re, im)| Complex64::new(re, im))
.collect();
Ok(result)
}
pub fn run_forward(
&self,
region: &DesignRegion,
source_i: usize,
source_j: usize,
wavelength_m: f64,
) -> Result<Vec<num_complex::Complex64>, crate::error::OxiPhotonError> {
use crate::error::OxiPhotonError;
use crate::fdtd::config::BoundaryConfig;
use crate::fdtd::dims::fdtd_2d::{DftBox2dTm, Fdtd2dTm};
use num_complex::Complex64;
use std::f64::consts::PI;
if wavelength_m <= 0.0 || !wavelength_m.is_finite() {
return Err(OxiPhotonError::InvalidWavelength(wavelength_m));
}
let guard = Self::GUARD;
let pml = Self::PML;
let dx = self.dx;
let total_nx = region.nx + 2 * guard + 2 * pml;
let total_ny = region.nz + 2 * guard + 2 * pml;
let bc = BoundaryConfig::pml(pml);
let mut sim = Fdtd2dTm::new(total_nx, total_ny, dx, dx, &bc);
let dt = sim.dt;
let offset_x = guard + pml;
let offset_y = guard + pml;
for rj in 0..region.nz {
for ri in 0..region.nx {
let gi = ri + offset_x;
let gj = rj + offset_y;
let eps = region.epsilon(ri, rj);
let idx = gj * total_nx + gi;
if idx < sim.eps_r.len() {
sim.eps_r[idx] = eps;
}
}
}
let c = 2.998e8_f64;
let f0 = c / wavelength_m;
let sigma = 4.0 / f0; let t0 = 4.0 * sigma;
let dft = DftBox2dTm::new(&[f0], region.nx, region.nz, dt);
let n_cells = region.nx * region.nz;
let mut ez_re = vec![0.0_f64; n_cells];
let mut ez_im = vec![0.0_f64; n_cells];
let src_gi = (source_i + offset_x).min(total_nx - 1);
let src_gj = (source_j + offset_y).min(total_ny - 1);
let n_steps = self.n_steps;
let omega0 = 2.0 * PI * f0;
let _ = dft;
for step in 0..n_steps {
let t = step as f64 * dt;
let env = (-(t - t0).powi(2) / (2.0 * sigma * sigma)).exp();
let src_val = (omega0 * t).sin() * env;
sim.inject_ez(src_gi, src_gj, src_val);
sim.step();
let t_now = sim.current_time();
let phase_re = (omega0 * t_now).cos() * dt;
let phase_im = -(omega0 * t_now).sin() * dt;
for rj in 0..region.nz {
for ri in 0..region.nx {
let gi = ri + offset_x;
let gj = rj + offset_y;
let ez_val = if gi < total_nx && gj < total_ny {
sim.ez[gj * total_nx + gi]
} else {
0.0
};
let cell = rj * region.nx + ri;
ez_re[cell] += ez_val * phase_re;
ez_im[cell] += ez_val * phase_im;
}
}
}
for (&re, &im) in ez_re.iter().zip(ez_im.iter()) {
if !re.is_finite() || !im.is_finite() {
return Err(OxiPhotonError::NumericalError(
"FDTD forward simulation produced non-finite fields".to_string(),
));
}
}
let result: Vec<Complex64> = ez_re
.into_iter()
.zip(ez_im)
.map(|(re, im)| Complex64::new(re, im))
.collect();
Ok(result)
}
}
pub struct AdjointOptimizer {
pub wavelength: f64,
pub region: DesignRegion,
pub use_fdtd_forward: bool,
pub fdtd_solver: AdjointSolver2d,
pub source_i: usize,
pub source_j: usize,
pub monitor_cells: Vec<(usize, usize)>,
pub step_size: f64,
pub iteration: usize,
}
impl AdjointOptimizer {
pub fn new(region: DesignRegion, wavelength: f64, monitor_cells: Vec<(usize, usize)>) -> Self {
let fdtd_solver = AdjointSolver2d::new(region.nx, region.nz, region.dx);
let source_i = 0;
let source_j = region.nz / 2;
Self {
wavelength,
region,
use_fdtd_forward: true,
fdtd_solver,
source_i,
source_j,
monitor_cells,
step_size: 0.01,
iteration: 0,
}
}
pub fn compute_forward_field(
&self,
) -> Result<Vec<num_complex::Complex64>, crate::error::OxiPhotonError> {
if self.use_fdtd_forward {
self.fdtd_solver.run_forward(
&self.region,
self.source_i,
self.source_j,
self.wavelength,
)
} else {
self.compute_forward_field_analytic()
}
}
pub fn compute_forward_field_analytic(
&self,
) -> Result<Vec<num_complex::Complex64>, crate::error::OxiPhotonError> {
use num_complex::Complex64;
use std::f64::consts::PI;
let nx = self.region.nx;
let nz = self.region.nz;
let c = 2.998e8_f64;
let f0 = c / self.wavelength;
let omega = 2.0 * PI * f0;
let xc = self.source_i as f64;
let zc = self.source_j as f64;
let wx = (nx as f64 / 6.0).max(1.0);
let wz = (nz as f64 / 6.0).max(1.0);
let mut result = Vec::with_capacity(nx * nz);
for rj in 0..nz {
for ri in 0..nx {
let xx = (ri as f64 - xc) / wx;
let zz = (rj as f64 - zc) / wz;
let env = (-0.5 * (xx * xx + zz * zz)).exp();
let phase = omega * ri as f64 * self.region.dx / c;
result.push(Complex64::new(env * phase.cos(), env * phase.sin()));
}
}
Ok(result)
}
pub fn compute_adjoint_field(
&self,
region: &DesignRegion,
fom_dconj_e: &[num_complex::Complex64],
) -> Result<Vec<num_complex::Complex64>, crate::error::OxiPhotonError> {
if self.use_fdtd_forward {
self.fdtd_solver
.run_adjoint(region, &self.monitor_cells, fom_dconj_e, self.wavelength)
} else {
self.compute_adjoint_field_analytic(region, fom_dconj_e)
}
}
pub fn compute_adjoint_field_analytic(
&self,
region: &DesignRegion,
fom_dconj_e: &[num_complex::Complex64],
) -> Result<Vec<num_complex::Complex64>, crate::error::OxiPhotonError> {
use crate::error::OxiPhotonError;
use num_complex::Complex64;
if self.monitor_cells.len() != fom_dconj_e.len() {
return Err(OxiPhotonError::NumericalError(format!(
"compute_adjoint_field_analytic: monitor_cells.len()={} != fom_dconj_e.len()={}",
self.monitor_cells.len(),
fom_dconj_e.len()
)));
}
let nx = region.nx;
let nz = region.nz;
let sigma = region.nx.min(region.nz) as f64 * 0.3;
let sigma_sq = (sigma * sigma).max(1.0);
let mut result = vec![Complex64::new(0.0, 0.0); nx * nz];
for rj in 0..nz {
for ri in 0..nx {
let cell = rj * nx + ri;
let mut acc = Complex64::new(0.0, 0.0);
for (m, &(mi_m, mj_m)) in self.monitor_cells.iter().enumerate() {
let di = ri as f64 - mi_m as f64;
let dj = rj as f64 - mj_m as f64;
let decay = (-(di * di + dj * dj) / sigma_sq).exp();
acc += fom_dconj_e[m] * decay;
}
result[cell] = acc;
}
}
Ok(result)
}
pub fn compute_gradient(
&self,
e_fwd: &[num_complex::Complex64],
e_adj: &[num_complex::Complex64],
) -> Result<crate::error::Result<FomGradient>, crate::error::OxiPhotonError> {
let n = self.region.n_params();
if e_fwd.len() != n || e_adj.len() != n {
return Err(crate::error::OxiPhotonError::NumericalError(format!(
"Field length mismatch: e_fwd={}, e_adj={}, n_params={}",
e_fwd.len(),
e_adj.len(),
n
)));
}
let de = self.region.eps_max - self.region.eps_min;
let fom: f64 = e_fwd.iter().map(|c| c.norm_sqr()).sum();
let grad: Vec<f64> = e_fwd
.iter()
.zip(e_adj.iter())
.map(|(ef, ea)| {
let overlap = ef.re * ea.re + ef.im * ea.im;
-2.0 * de * overlap
})
.collect();
Ok(Ok(FomGradient { grad, fom }))
}
pub fn update_gradient_ascent(&mut self, gradient: &FomGradient) {
for (rho, &g) in self.region.rho.iter_mut().zip(&gradient.grad) {
*rho = (*rho + self.step_size * g).clamp(0.0, 1.0);
}
self.iteration += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inverse::adjoint_3d::{AdjointSolver3d, DesignVariable};
use std::f64::consts::PI;
#[test]
fn design_region_init() {
let dr = DesignRegion::new(10, 10, 20e-9, 2.09, 12.11);
assert_eq!(dr.n_params(), 100);
assert_eq!(dr.rho.len(), 100);
}
#[test]
fn epsilon_interpolation() {
let mut dr = DesignRegion::new(2, 1, 20e-9, 1.0, 4.0);
dr.rho[0] = 0.0;
dr.rho[1] = 1.0;
assert!((dr.epsilon(0, 0) - 1.0).abs() < 1e-10);
assert!((dr.epsilon(1, 0) - 4.0).abs() < 1e-10);
}
#[test]
fn fill_fraction_initial() {
let dr = DesignRegion::new(10, 10, 20e-9, 1.0, 4.0);
assert_eq!(dr.fill_fraction(), 0.0);
}
#[test]
fn fill_fraction_all_ones() {
let mut dr = DesignRegion::new(4, 4, 20e-9, 1.0, 4.0);
for r in &mut dr.rho {
*r = 1.0;
}
assert_eq!(dr.fill_fraction(), 1.0);
}
#[test]
fn sigmoid_binarises() {
let mut dr = DesignRegion::new(4, 1, 20e-9, 1.0, 4.0);
dr.rho = vec![0.1, 0.3, 0.7, 0.9];
dr.apply_sigmoid(100.0, 0.5);
assert!(dr.rho[0] < 0.1);
assert!(dr.rho[3] > 0.9);
}
#[test]
fn gradient_has_correct_length() {
let c = 2.998e8;
let omega = 2.0 * PI * c / 1550e-9;
let dr = DesignRegion::soi_design(4, 4, 20.0);
let solver = AdjointSolver::new(omega, dr);
let n = solver.region.n_params();
let e_fwd = vec![[1.0_f64, 0.0]; n];
let e_adj = vec![[1.0_f64, 0.0]; n];
let grad = solver.compute_gradient(&e_fwd, &e_adj);
assert_eq!(grad.grad.len(), n);
}
#[test]
fn gradient_sign_correct() {
let c = 2.998e8;
let omega = 2.0 * PI * c / 1550e-9;
let dr = DesignRegion::new(1, 1, 20e-9, 1.0, 4.0);
let solver = AdjointSolver::new(omega, dr);
let e_fwd = vec![[1.0, 0.0]];
let e_adj = vec![[1.0, 0.0]];
let grad = solver.compute_gradient(&e_fwd, &e_adj);
assert!(grad.grad[0] < 0.0);
}
#[test]
fn adam_update_stays_in_bounds() {
let c = 2.998e8;
let omega = 2.0 * PI * c / 1550e-9;
let dr = DesignRegion::new(4, 4, 20e-9, 1.0, 4.0);
let mut solver = AdjointSolver::new(omega, dr);
let n = solver.region.n_params();
let e_fwd = vec![[2.0, 0.0]; n];
let e_adj = vec![[2.0, 0.0]; n];
let grad = solver.compute_gradient(&e_fwd, &e_adj);
let mut m = vec![0.0; n];
let mut v = vec![0.0; n];
solver.update_adam(&grad, &mut m, &mut v, 1, 0.9, 0.999, 1e-8);
for &r in &solver.region.rho {
assert!((0.0..=1.0).contains(&r), "rho={r:.4} out of [0,1]");
}
}
#[test]
fn adjoint_solver3d_construction() {
let c = 2.998e8;
let omega = 2.0 * PI * c / 1550e-9;
let solver = AdjointSolver3d::new(8, 8, 8, 20e-9, omega);
assert_eq!(solver.nx, 8);
assert_eq!(solver.e_fwd.len(), 8 * 8 * 8);
assert_eq!(solver.e_adj.len(), 8 * 8 * 8);
}
#[test]
fn adjoint_solver3d_soi_constructor() {
let solver = AdjointSolver3d::soi(10, 6, 20, 20.0);
assert_eq!(solver.nx, 10);
assert_eq!(solver.ny, 6);
assert_eq!(solver.nz, 20);
}
#[test]
fn adjoint_solver3d_fill_design_region() {
let mut solver = AdjointSolver3d::new(10, 6, 20, 20e-9, 1.2e15);
solver.fill_design_region(3, 7, 2, 4, 5, 15, 2.09, 12.11);
let expected = 4 * 2 * 10; assert_eq!(solver.n_variables(), expected);
assert_eq!(solver.gradient.len(), expected);
}
#[test]
fn adjoint_solver3d_forward_field_finite() {
let mut solver = AdjointSolver3d::new(8, 6, 10, 20e-9, 1.2e15);
solver.compute_forward_field();
assert!(
solver
.e_fwd
.iter()
.all(|&[re, im]| re.is_finite() && im.is_finite()),
"Forward field should be finite"
);
assert!(
solver.fom >= 0.0,
"FOM should be non-negative: {:.4e}",
solver.fom
);
}
#[test]
fn adjoint_solver3d_adjoint_field_finite() {
let mut solver = AdjointSolver3d::new(8, 6, 10, 20e-9, 1.2e15);
solver.compute_forward_field();
solver.compute_adjoint_field();
assert!(
solver
.e_adj
.iter()
.all(|&[re, im]| re.is_finite() && im.is_finite()),
"Adjoint field should be finite"
);
}
#[test]
fn adjoint_solver3d_gradient_step_updates_rho() {
let mut solver = AdjointSolver3d::new(8, 6, 10, 20e-9, 1.2e15);
solver.fill_design_region(3, 5, 2, 4, 3, 7, 2.09, 12.11);
let rho_before: Vec<f64> = solver.variables.iter().map(|v| v.rho).collect();
solver.gradient_step(1e-3);
let any_changed = solver
.variables
.iter()
.zip(rho_before.iter())
.any(|(v, &r0)| (v.rho - r0).abs() > 1e-15);
assert!(any_changed, "gradient_step should update at least one rho");
assert_eq!(solver.history.len(), 1);
assert!(solver.iteration == 1);
}
#[test]
fn adjoint_solver3d_rho_stays_in_bounds() {
let mut solver = AdjointSolver3d::new(8, 6, 10, 20e-9, 1.2e15);
solver.fill_design_region(2, 6, 1, 5, 2, 8, 2.09, 12.11);
for _ in 0..5 {
solver.gradient_step(1.0); }
for v in &solver.variables {
assert!(
v.rho >= 0.0 && v.rho <= 1.0,
"rho = {:.4} out of [0, 1]",
v.rho
);
}
}
#[test]
fn design_variable_physical_value() {
let mut var = DesignVariable::new("eps", 0.0, 2.09, 12.11);
assert!((var.physical_value() - 2.09).abs() < 1e-10);
var.rho = 1.0;
assert!((var.physical_value() - 12.11).abs() < 1e-10);
var.rho = 0.5;
assert!((var.physical_value() - 7.1).abs() < 1e-10);
}
#[test]
fn adjoint_gradient_norm_finite() {
let mut solver = AdjointSolver3d::new(6, 4, 8, 20e-9, 1.2e15);
solver.fill_design_region(1, 5, 1, 3, 2, 6, 2.09, 12.11);
solver.compute_forward_field();
solver.compute_adjoint_field();
solver.compute_gradient();
let norm = solver.gradient_norm();
assert!(
norm.is_finite(),
"Gradient norm should be finite: {norm:.4e}"
);
}
}