use core::fmt;
use crate::axis::Axis;
use crate::compile::{Compiled, grid_from_csx};
use crate::constants::C0;
use crate::engine::{EdgeMaterials, Grid, TimeStep};
use crate::fdtd::{Boundary, Face, Fdtd};
use crate::{Error, Result};
use csx::properties::MaterialQuantity;
use csx::{BoundingBox, ContinuousStructure, CoordSystem, Primitive, PropertyKind, Vec3};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Info,
Warning,
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Location {
Global,
Cell {
axis: Axis,
index: usize,
from: f64,
to: f64,
},
Face(Face),
Primitive {
property: String,
index: usize,
},
}
impl fmt::Display for Location {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Location::Global => f.write_str("mesh"),
Location::Cell {
axis,
index,
from,
to,
} => write!(f, "{axis} cell {index} ({axis} = {from} .. {to})"),
Location::Face(face) => write!(f, "{face} face"),
Location::Primitive { property, index } => {
write!(f, "property {property:?} primitive #{index}")
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MeshIssue {
pub severity: Severity,
pub message: String,
pub location: Location,
}
impl fmt::Display for MeshIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.severity, self.message)?;
if self.location != Location::Global {
write!(f, " [{}]", self.location)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MeshStats {
pub nodes: [usize; 3],
pub cells: usize,
pub min_cell: [f64; 3],
pub max_cell: [f64; 3],
pub min_cell_index: [usize; 3],
pub f_max: Option<f64>,
pub lambda_min: Option<f64>,
pub min_cells_per_wavelength: Option<f64>,
pub dt: f64,
pub dt_typical: f64,
pub engine_bytes: u64,
pub compile_bytes: u64,
pub bytes_per_step: u64,
pub max_steps: u64,
pub excitation_steps: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MeshReport {
pub issues: Vec<MeshIssue>,
pub stats: MeshStats,
}
impl MeshReport {
pub fn is_ok(&self) -> bool {
self.errors().next().is_none()
}
pub fn with_severity(&self, severity: Severity) -> impl Iterator<Item = &MeshIssue> {
self.issues.iter().filter(move |i| i.severity == severity)
}
pub fn errors(&self) -> impl Iterator<Item = &MeshIssue> {
self.with_severity(Severity::Error)
}
pub fn warnings(&self) -> impl Iterator<Item = &MeshIssue> {
self.with_severity(Severity::Warning)
}
pub fn infos(&self) -> impl Iterator<Item = &MeshIssue> {
self.with_severity(Severity::Info)
}
pub fn messages(&self, min: Severity) -> Vec<String> {
self.issues
.iter()
.filter(|i| i.severity >= min)
.map(|i| format!("mesh {i}"))
.collect()
}
fn push(&mut self, severity: Severity, location: Location, message: String) {
self.issues.push(MeshIssue {
severity,
message,
location,
});
}
}
fn mib(b: u64) -> f64 {
b as f64 / (1024.0 * 1024.0)
}
impl fmt::Display for MeshReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = &self.stats;
let (ne, nw) = (self.errors().count(), self.warnings().count());
if ne == 0 && nw == 0 {
writeln!(f, "mesh check: OK")?;
} else {
writeln!(f, "mesh check: {ne} error(s), {nw} warning(s)")?;
}
let c = s.nodes.map(|n| n.saturating_sub(1));
writeln!(
f,
" cells: {} x {} x {} = {} ({} nodes)",
c[0],
c[1],
c[2],
s.cells,
s.nodes.iter().product::<usize>()
)?;
writeln!(
f,
" cell size [m]: x {:.3e} .. {:.3e}, y {:.3e} .. {:.3e}, z {:.3e} .. {:.3e}",
s.min_cell[0],
s.max_cell[0],
s.min_cell[1],
s.max_cell[1],
s.min_cell[2],
s.max_cell[2]
)?;
match (s.f_max, s.lambda_min, s.min_cells_per_wavelength) {
(Some(fm), Some(l), Some(n)) => writeln!(
f,
" f_max {fm:.4e} Hz, lambda_min {l:.4e} m, worst resolution {n:.1} cells/lambda"
)?,
_ => writeln!(f, " f_max unknown: resolution not checked")?,
}
writeln!(
f,
" dt {:.4e} s ({:.2} x the median-cell time step)",
s.dt,
if s.dt_typical > 0.0 {
s.dt / s.dt_typical
} else {
0.0
}
)?;
write!(
f,
" memory ~{:.1} MiB (compile peak ~{:.1} MiB), ~{:.1} MiB traffic/step, up to {} steps",
mib(s.engine_bytes),
mib(s.compile_bytes),
mib(s.bytes_per_step),
s.max_steps
)?;
if let Some(n) = s.excitation_steps {
write!(f, " (excitation: {n} steps)")?;
}
writeln!(f)?;
for i in &self.issues {
writeln!(f, " {i}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CheckOptions {
pub cells_per_wavelength: f64,
pub min_cells_per_wavelength: f64,
pub max_grading: f64,
pub timestep_ratio: f64,
pub pml_max_spread: f64,
pub snap_tolerance: f64,
pub update_model: bool,
}
impl Default for CheckOptions {
fn default() -> Self {
Self {
cells_per_wavelength: 20.0,
min_cells_per_wavelength: 10.0,
max_grading: 1.5,
timestep_ratio: 0.2,
pml_max_spread: 2.0,
snap_tolerance: 1e-6,
update_model: true,
}
}
}
impl CheckOptions {
pub fn cells_per_wavelength(mut self, n: f64) -> Self {
self.cells_per_wavelength = n;
self
}
pub fn min_cells_per_wavelength(mut self, n: f64) -> Self {
self.min_cells_per_wavelength = n;
self
}
pub fn max_grading(mut self, r: f64) -> Self {
self.max_grading = r;
self
}
pub fn timestep_ratio(mut self, r: f64) -> Self {
self.timestep_ratio = r;
self
}
pub fn pml_max_spread(mut self, r: f64) -> Self {
self.pml_max_spread = r;
self
}
fn validate(&self) -> Result<()> {
let pos = |v: f64, what: &str, min: f64| {
if v.is_finite() && v >= min {
Ok(())
} else {
Err(Error::Invalid(format!(
"mesh check: {what} = {v} must be finite and >= {min}"
)))
}
};
pos(self.cells_per_wavelength, "cells_per_wavelength", 0.0)?;
pos(
self.min_cells_per_wavelength,
"min_cells_per_wavelength",
0.0,
)?;
pos(self.max_grading, "max_grading", 1.0)?;
pos(self.timestep_ratio, "timestep_ratio", 0.0)?;
pos(self.pml_max_spread, "pml_max_spread", 1.0)?;
pos(self.snap_tolerance, "snap_tolerance", 0.0)
}
}
pub fn check(model: &ContinuousStructure, fdtd: &Fdtd) -> Result<MeshReport> {
check_with(model, fdtd, &CheckOptions::default())
}
pub fn check_with(
model: &ContinuousStructure,
fdtd: &Fdtd,
opts: &CheckOptions,
) -> Result<MeshReport> {
opts.validate()?;
fdtd.validate()?;
let grid = grid_from_csx(&model.grid)?;
let grid = crate::compile::cylindrical::multigrid(
grid,
&model.grid,
fdtd.get_multigrid(),
&mut Vec::new(),
)?;
let model = updated(model, opts)?;
let model = model.as_ref();
let ctx = Ctx::new(model, &grid, fdtd, opts);
let index = ctx.slab_index_from_model();
let mut r = ctx.run(&index, timestep_of(fdtd));
ctx.check_sheets(&mut r);
Ok(r)
}
pub fn check_compiled(
model: &ContinuousStructure,
fdtd: &Fdtd,
compiled: &Compiled,
opts: &CheckOptions,
) -> Result<MeshReport> {
opts.validate()?;
let model = updated(model, opts)?;
let model = model.as_ref();
let mut ctx = Ctx::new(model, &compiled.grid, fdtd, opts);
ctx.unit = compiled.delta_unit;
ctx.pml = compiled.pml.iter().map(|&(f, p)| (f, p.cells)).collect();
let index = if compiled.thin_wires.is_empty() {
slab_index_from_materials(&compiled.grid, &compiled.materials)
} else {
let mut m = compiled.materials.clone();
for w in &compiled.thin_wires {
w.remove(&compiled.grid, &mut m)?;
}
slab_index_from_materials(&compiled.grid, &m)
};
let mut r = ctx.run(&index, compiled.timestep);
for w in &compiled.warnings {
let sev = if w.contains("is not on a mesh plane") || w.contains("line not on mesh lines") {
Severity::Error
} else if w.contains("staircased") {
Severity::Info
} else {
continue;
};
r.push(
sev,
Location::Global,
format!("{CSX_DISCRETISATION_PREFIX}{w}"),
);
}
Ok(r)
}
pub const CSX_DISCRETISATION_PREFIX: &str = "csx discretisation: ";
pub fn suggest_max_cell(model: &ContinuousStructure, fdtd: &Fdtd, n: f64) -> Result<f64> {
let f = fdtd.get_max_frequency().ok_or_else(|| {
Error::Invalid("no maximum frequency (set a waveform or Fdtd::max_frequency)".into())
})?;
if !(n.is_finite() && n > 0.0) {
return Err(Error::Invalid(format!(
"cells per wavelength {n} must be finite and > 0"
)));
}
let unit = model.grid.delta_unit;
if !(unit.is_finite() && unit > 0.0) {
return Err(Error::Invalid(format!(
"grid delta unit {unit} must be finite and > 0"
)));
}
let mut idx = background_index(model);
for (p, prim) in model.primitives() {
if let Some(m) = p.kind.material() {
let b = prim.bounding_box_in(CoordSystem::Cartesian, model.coord_system);
idx = idx.max(material_index(m, &b));
}
}
Ok(C0 / (f * idx * n) / unit)
}
fn updated<'a>(
model: &'a ContinuousStructure,
opts: &CheckOptions,
) -> Result<std::borrow::Cow<'a, ContinuousStructure>> {
if opts.update_model {
let mut m = model.clone();
m.update()?;
Ok(std::borrow::Cow::Owned(m))
} else {
Ok(std::borrow::Cow::Borrowed(model))
}
}
fn timestep_of(fdtd: &Fdtd) -> TimeStep {
match fdtd.get_timestep() {
Some(dt) => TimeStep::Fixed(dt),
None => TimeStep::Courant(fdtd.get_timestep_factor()),
}
}
fn index_of(eps: f64, mu: f64) -> f64 {
let p = eps * mu;
if p.is_finite() && p > 0.0 {
p.sqrt()
} else {
1.0
}
}
fn background_index(model: &ContinuousStructure) -> f64 {
let b = &model.background;
index_of(b.epsilon.value(), b.mue.value())
}
fn material_index(m: &csx::Material, b: &BoundingBox) -> f64 {
let max3 = |v: [f64; 3]| v.into_iter().fold(f64::NEG_INFINITY, f64::max);
let weighted = m.quantity(MaterialQuantity::Epsilon).is_weighted()
|| m.quantity(MaterialQuantity::Mue).is_weighted();
if !weighted || b.is_empty() || !(b.min.is_finite() && b.max.is_finite()) {
let e = max3(core::array::from_fn(|d| {
m.base_value(MaterialQuantity::Epsilon, d)
}));
let u = max3(core::array::from_fn(|d| {
m.base_value(MaterialQuantity::Mue, d)
}));
return index_of(e, u);
}
let mut pts: Vec<Vec3> = b.corners().to_vec();
pts.push(b.center());
pts.into_iter()
.map(|p| {
index_of(
max3(m.values_at(MaterialQuantity::Epsilon, p)),
max3(m.values_at(MaterialQuantity::Mue, p)),
)
})
.fold(1.0, f64::max)
}
type SlabIndex = [Vec<f64>; 3];
fn slab_index_from_materials(grid: &Grid, m: &EdgeMaterials) -> SlabIndex {
let n = grid.nodes();
let mut out: SlabIndex = core::array::from_fn(|a| vec![1.0; n[a] - 1]);
let mut idx = 0;
for i in 0..n[0] {
for j in 0..n[1] {
for k in 0..n[2] {
let mut e = f64::NEG_INFINITY;
let mut u = f64::NEG_INFINITY;
for ax in Axis::ALL {
if !m.pec_mask(ax)[idx] {
e = e.max(m.eps_r(ax)[idx]);
}
u = u.max(m.mu_r(ax)[idx]);
}
if e.is_finite() {
let v = index_of(e, u);
for (a, c) in [i, j, k].into_iter().enumerate() {
let s = &mut out[a];
if c < s.len() {
s[c] = s[c].max(v);
}
if c > 0 {
s[c - 1] = s[c - 1].max(v);
}
}
}
idx += 1;
}
}
}
out
}
struct Ctx<'a> {
model: &'a ContinuousStructure,
grid: &'a Grid,
unit: f64,
fdtd: &'a Fdtd,
opts: &'a CheckOptions,
pml: Vec<(Face, usize)>,
}
impl<'a> Ctx<'a> {
fn new(
model: &'a ContinuousStructure,
grid: &'a Grid,
fdtd: &'a Fdtd,
opts: &'a CheckOptions,
) -> Self {
let pml = fdtd
.get_boundaries()
.iter()
.filter_map(|(f, b)| match b {
Boundary::Pml(p) => Some((f, p.cells)),
_ => None,
})
.collect();
Self {
model,
grid,
unit: model.grid.delta_unit,
fdtd,
opts,
pml,
}
}
fn unit_of(&self, axis: Axis) -> f64 {
if self.grid.is_cylindrical() && axis == Axis::Y {
1.0
} else {
self.unit
}
}
fn du_lines(&self, axis: Axis) -> Vec<f64> {
let u = self.unit_of(axis);
self.grid.lines(axis).iter().map(|x| x / u).collect()
}
fn cell_loc(&self, axis: Axis, index: usize) -> Location {
let l = self.grid.lines(axis);
let u = self.unit_of(axis);
Location::Cell {
axis,
index,
from: l[index] / u,
to: l[index + 1] / u,
}
}
fn phys_deltas(&self, axis: Axis) -> Vec<f64> {
let d = self.grid.deltas(axis);
if self.grid.is_cylindrical() && axis == Axis::Y {
let r = self.grid.lines(Axis::X);
let rmax = r[r.len() - 1];
d.into_iter().map(|x| x * rmax).collect()
} else {
d
}
}
fn bbox(&self, prim: &Primitive) -> BoundingBox {
let cs = if self.grid.is_cylindrical() {
CoordSystem::Cylindrical
} else {
CoordSystem::Cartesian
};
prim.bounding_box_in(cs, self.model.coord_system)
}
fn overlaps_domain(&self, b: &BoundingBox) -> bool {
(0..3).all(|a| {
let l = self.grid.lines(Axis::ALL[a]);
let u = self.unit_of(Axis::ALL[a]);
let (lo, hi) = (l[0] / u, l[l.len() - 1] / u);
let tol = 1e-9 * (hi - lo).abs();
b.max[a] >= lo - tol && b.min[a] <= hi + tol
})
}
fn slab_index_from_model(&self) -> SlabIndex {
let bg = background_index(self.model);
let mut out: SlabIndex = core::array::from_fn(|a| vec![bg; self.grid.nodes()[a] - 1]);
for (p, prim) in self.model.primitives() {
let Some(m) = p.kind.material() else {
continue;
};
let b = self.bbox(prim);
if b.is_empty() || !self.overlaps_domain(&b) {
continue;
}
let v = material_index(m, &b);
for axis in Axis::ALL {
let a = axis.index();
let l = self.du_lines(axis);
for (c, s) in out[a].iter_mut().enumerate() {
let tol = 1e-9 * (l[c + 1] - l[c]);
if l[c + 1] > b.min[a] + tol && l[c] < b.max[a] - tol {
*s = s.max(v);
}
}
}
}
out
}
fn run(&self, index: &SlabIndex, timestep: TimeStep) -> MeshReport {
let mut r = MeshReport::default();
self.stats(&mut r, timestep);
self.check_resolution(&mut r, index);
self.check_grading(&mut r);
self.check_timestep(&mut r);
self.check_pml(&mut r);
r
}
fn stats(&self, r: &mut MeshReport, timestep: TimeStep) {
let g = self.grid;
let s = &mut r.stats;
s.nodes = g.nodes();
s.cells = g.num_cells();
for axis in Axis::ALL {
let a = axis.index();
let d = self.phys_deltas(axis);
let (mut imin, mut dmin, mut dmax) = (0, f64::INFINITY, 0.0f64);
for (i, &x) in d.iter().enumerate() {
if x < dmin {
(imin, dmin) = (i, x);
}
dmax = dmax.max(x);
}
s.min_cell[a] = dmin;
s.max_cell[a] = dmax;
s.min_cell_index[a] = imin;
}
let limit = crate::engine::courant_limit(g);
let factor = match timestep {
TimeStep::Courant(f) => f,
TimeStep::Fixed(dt) => dt / limit,
};
s.dt = match timestep.resolve(g) {
Ok(dt) => dt,
Err(e) => {
let dt = self.fdtd.get_timestep_factor() * limit;
r.issues.push(MeshIssue {
severity: Severity::Error,
message: format!("{e}; estimates use {dt:.4e} s"),
location: Location::Global,
});
dt
}
};
let median: f64 = Axis::ALL
.iter()
.map(|&ax| {
let mut d = self.phys_deltas(ax);
d.sort_by(f64::total_cmp);
let m = d[d.len() / 2];
1.0 / (m * m)
})
.sum();
s.dt_typical = factor.clamp(0.0, 1.0) / (C0 * median.sqrt());
if s.dt_typical <= 0.0 {
s.dt_typical = s.dt;
}
let nodes = g.num_nodes() as u64;
let mut pml_nodes = 0u64;
for &(face, n) in &self.pml {
let a = face.axis().index();
let others: u64 = (0..3)
.filter(|&b| b != a)
.map(|b| s.nodes[b] as u64)
.product();
pml_nodes += (n.min(s.nodes[a] - 1) as u64 + 1) * others;
}
s.engine_bytes = nodes * 72 + pml_nodes * 16;
s.compile_bytes = s.engine_bytes + nodes * 210;
s.bytes_per_step = nodes * (72 + 24) + pml_nodes * 32;
s.max_steps = self.fdtd.timestep_limit(s.dt);
s.excitation_steps = self
.fdtd
.get_waveform()
.and_then(|w| w.duration())
.filter(|d| d.is_finite())
.map(|d| (d / s.dt).ceil() as u64);
s.f_max = self.fdtd.get_max_frequency();
if let Some(n) = s.excitation_steps
&& n > s.max_steps
{
let max_steps = s.max_steps;
r.push(
Severity::Warning,
Location::Global,
format!(
"the excitation needs {n} steps but the run is limited to {max_steps} steps"
),
);
}
}
fn check_resolution(&self, r: &mut MeshReport, index: &SlabIndex) {
let Some(f) = self.fdtd.get_max_frequency() else {
r.push(
Severity::Info,
Location::Global,
"no maximum frequency known (waveform without bounded spectrum and no \
Fdtd::max_frequency): cell sizes not checked against the wavelength"
.into(),
);
return;
};
let (n_ok, n_err) = (
self.opts.cells_per_wavelength,
self.opts.min_cells_per_wavelength,
);
let mut n_max = 1.0f64;
let mut worst_all = f64::INFINITY;
for axis in Axis::ALL {
let a = axis.index();
let d = self.phys_deltas(axis);
let mut worst: Option<(usize, f64, f64)> = None; let (mut count, mut count_err) = (0usize, 0usize);
for (i, &dx) in d.iter().enumerate() {
let n = index[a][i];
n_max = n_max.max(n);
let per = C0 / (f * n) / dx;
worst_all = worst_all.min(per);
if per < n_ok {
count += 1;
if per < n_err {
count_err += 1;
}
if worst.is_none_or(|w| per < w.1) {
worst = Some((i, per, n));
}
}
}
if let Some((i, per, n)) = worst {
let sev = if count_err > 0 {
Severity::Error
} else {
Severity::Warning
};
let lam = C0 / (f * n);
r.push(
sev,
self.cell_loc(axis, i),
format!(
"{count} {axis} cell(s) coarser than lambda/{n_ok} at f_max = {f:.4e} Hz \
({count_err} below lambda/{n_err}); worst: {:.4} (drawing units) = \
lambda/{per:.1} with n = {n:.3}, needs <= {:.4}",
d[i] / self.unit,
lam / n_ok / self.unit
),
);
}
}
r.stats.lambda_min = Some(C0 / (f * n_max));
r.stats.min_cells_per_wavelength = Some(worst_all);
}
fn check_grading(&self, r: &mut MeshReport) {
let lim = self.opts.max_grading * (1.0 + 1e-9);
for axis in Axis::ALL {
let mut d = self.grid.deltas(axis);
let n = d.len();
if self.fdtd.get_boundaries().is_periodic(axis) && n > 1 {
d.push(d[0]);
}
let mut count = 0usize;
let mut worst: Option<(usize, f64)> = None;
for (i, w) in d.windows(2).enumerate() {
let q = (w[1] / w[0]).max(w[0] / w[1]);
if q > lim {
count += 1;
if worst.is_none_or(|x| q > x.1) {
worst = Some((if w[1] > w[0] { (i + 1) % n } else { i }, q));
}
}
}
if let Some((i, q)) = worst {
r.push(
Severity::Warning,
self.cell_loc(axis, i),
format!(
"{count} {axis} neighbour cell size ratio(s) above {}; worst {q:.2}",
self.opts.max_grading
),
);
}
}
}
fn check_timestep(&self, r: &mut MeshReport) {
if self.grid.is_cylindrical() {
return self.check_timestep_cylindrical(r);
}
let s = &r.stats;
let a = (0..3)
.min_by(|&x, &y| s.min_cell[x].total_cmp(&s.min_cell[y]))
.unwrap_or(0);
let axis = Axis::ALL[a];
let ratio = s.dt / s.dt_typical;
let msg = format!(
"time step {:.4e} s, limited by the smallest cells x[{}] = {:.3e} m, y[{}] = {:.3e} m, \
z[{}] = {:.3e} m; {:.2} x the step of the median cells",
s.dt,
s.min_cell_index[0],
s.min_cell[0],
s.min_cell_index[1],
s.min_cell[1],
s.min_cell_index[2],
s.min_cell[2],
ratio
);
let loc = self.cell_loc(axis, s.min_cell_index[a]);
if ratio < self.opts.timestep_ratio {
r.push(
Severity::Warning,
loc,
format!("{msg}: tiny cells dominate the time step"),
);
} else {
r.push(Severity::Info, loc, msg);
}
}
fn check_timestep_cylindrical(&self, r: &mut MeshReport) {
let s = &r.stats;
let rho = self.grid.lines(Axis::X);
let inner = if self.grid.has_axis() {
0.5 * rho[1]
} else {
rho[0]
};
let arc = inner * self.grid.min_delta(Axis::Y) * self.grid.alpha_factor(0) as f64;
let ratio = s.dt / s.dt_typical;
let msg = format!(
"time step {:.4e} s (stability limit of the cylindrical operator); the shortest \
arc next to {} is {arc:.3e} m; {ratio:.2} x the step of the median cells",
s.dt,
if self.grid.has_axis() {
"the axis"
} else {
"rho_min"
}
);
let loc = Location::Global;
if ratio < self.opts.timestep_ratio {
r.push(
Severity::Warning,
loc,
format!(
"{msg}: the arcs near the axis dominate the time step (fewer alpha lines, \
a larger rho_min or multigrid near the axis help)"
),
);
} else {
r.push(Severity::Info, loc, msg);
}
}
fn check_pml(&self, r: &mut MeshReport) {
let cells = self.grid.cells();
for &(face, n) in &self.pml {
let axis = face.axis();
let a = axis.index();
let nc = cells[a];
if n >= nc {
r.push(
Severity::Error,
Location::Face(face),
format!("PML needs {n} cells but the {axis} axis has only {nc} cells"),
);
continue;
}
if !face.is_max()
&& let Some(&(_, m)) = self.pml.iter().find(|(f, _)| *f == face.opposite())
&& n + m >= nc
{
r.push(
Severity::Error,
Location::Face(face),
format!(
"PMLs on both {axis} faces ({n} + {m} cells) leave no interior cells \
({nc} cells on the axis)"
),
);
}
let d = self.grid.deltas(axis);
let range = if face.is_max() {
nc - n - 1..nc
} else {
0..n + 1
};
let (lo, hi) = d[range.clone()]
.iter()
.fold((f64::INFINITY, 0.0f64), |(a, b), &x| (a.min(x), b.max(x)));
if hi / lo > self.opts.pml_max_spread {
r.push(
Severity::Warning,
Location::Face(face),
format!(
"PML cells are strongly graded (sizes {lo:.3e} .. {hi:.3e} m, ratio {:.2}); \
use roughly uniform cells in the absorbing layer",
hi / lo
),
);
}
}
self.check_pml_intrusion(r);
}
fn check_pml_intrusion(&self, r: &mut MeshReport) {
if self.pml.is_empty() {
return;
}
let bg = &self.model.background;
for p in self.model.properties() {
let relevant = match &p.kind {
PropertyKind::Metal
| PropertyKind::ConductingSheet(_)
| PropertyKind::LumpedElement(_)
| PropertyKind::Excitation(_) => true,
PropertyKind::Material(_)
| PropertyKind::LorentzMaterial(_)
| PropertyKind::DebyeMaterial(_)
| PropertyKind::DiscMaterial(_) => {
let m = p.kind.material().expect("material kind");
let same = |q: MaterialQuantity, v: f64| {
!m.quantity(q).is_weighted() && (0..3).all(|d| m.base_value(q, d) == v)
};
let plain = matches!(p.kind, PropertyKind::Material(_));
!(plain
&& same(MaterialQuantity::Epsilon, bg.epsilon.value())
&& same(MaterialQuantity::Mue, bg.mue.value())
&& same(MaterialQuantity::Kappa, bg.kappa.value())
&& same(MaterialQuantity::Sigma, bg.sigma.value()))
}
_ => false,
};
if !relevant {
continue;
}
for (k, prim) in p.primitives.iter().enumerate() {
let b = self.bbox(prim);
if b.is_empty() || !self.overlaps_domain(&b) {
continue;
}
let mut faces = Vec::new();
for &(face, n) in &self.pml {
let axis = face.axis();
let a = axis.index();
let l = self.du_lines(axis);
let nc = l.len() - 1;
if n >= nc {
continue;
}
let hit = if face.is_max() {
let inner = l[nc - n];
let tol = 1e-6 * (l[nc - n] - l[nc - n - 1]);
b.max[a] > inner + tol
} else {
let inner = l[n];
let tol = 1e-6 * (l[n + 1] - l[n]);
b.min[a] < inner - tol
};
if hit {
faces.push(face.name());
}
}
if !faces.is_empty() {
r.push(
Severity::Warning,
Location::Primitive {
property: p.name.clone(),
index: k,
},
format!(
"{} {:?} extends into the PML of face(s) {}",
p.kind.type_name(),
p.name,
faces.join(", ")
),
);
}
}
}
}
fn snapped(&self, a: usize, v: f64) -> Option<core::result::Result<(), f64>> {
let l = self.du_lines(Axis::ALL[a]);
let n = l.len();
let (lo, hi) = (l[0], l[n - 1]);
let i = l.partition_point(|&x| x < v);
let mut best = (f64::INFINITY, 0.0, 0.0); for j in [i.saturating_sub(1), i.min(n - 1)] {
let local = if j + 1 < n {
l[j + 1] - l[j]
} else {
l[j] - l[j - 1]
};
let local = if j > 0 {
local.min(l[j] - l[j - 1])
} else {
local
};
let dist = (l[j] - v).abs();
if dist < best.0 {
best = (dist, l[j], local);
}
}
let (dist, line, local) = best;
if dist <= self.opts.snap_tolerance * local {
return Some(Ok(()));
}
if v < lo || v > hi {
return None;
}
Some(Err(line))
}
fn check_sheets(&self, r: &mut MeshReport) {
for p in self.model.properties() {
if !matches!(
p.kind,
PropertyKind::Metal | PropertyKind::ConductingSheet(_)
) {
continue;
}
for (k, prim) in p.primitives.iter().enumerate() {
let b = self.bbox(prim);
if b.is_empty() || !(b.min.is_finite() && b.max.is_finite()) {
continue;
}
let flat: Vec<usize> = (0..3)
.filter(|&a| {
let l = self.du_lines(Axis::ALL[a]);
let scale = l[l.len() - 1] - l[0];
b.max[a] - b.min[a] <= self.opts.snap_tolerance * scale.abs()
})
.collect();
let what = match (flat.len(), prim.dimension()) {
(2, _) => "line",
(1, 2) => "sheet",
_ => continue,
};
let loc = || Location::Primitive {
property: p.name.clone(),
index: k,
};
let mut outside = false;
for &a in &flat {
let axis = Axis::ALL[a];
let v = b.min[a];
match self.snapped(a, v) {
Some(Ok(())) => {}
Some(Err(line)) => {
r.push(
Severity::Error,
loc(),
format!(
"{} {what} {:?} at {axis} = {v} is not on a mesh line \
(nearest {line}) and will be ignored; add a mesh line there",
p.kind.type_name(),
p.name
),
);
}
None => outside = true,
}
}
if outside {
r.push(
Severity::Warning,
loc(),
format!(
"{} {what} {:?} lies outside the mesh",
p.kind.type_name(),
p.name
),
);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compile::{CompileOptions, compile};
use crate::fdtd::Boundaries;
use crate::waveform::Waveform;
use csx::primitives::BoxShape;
use csx::{Material, Property};
fn model(cells: usize, h: f64) -> ContinuousStructure {
let mut m = ContinuousStructure::new();
m.grid.delta_unit = 1e-3;
for a in 0..3 {
m.grid
.set_lines(a, (0..=cells).map(|i| i as f64 * h))
.unwrap();
}
m
}
fn fdtd() -> Fdtd {
Fdtd::new().waveform(Waveform::gaussian(5e9, 5e9))
}
fn add_box(m: &mut ContinuousStructure, p: Property, lo: [f64; 3], hi: [f64; 3]) {
let id = m.add_property(p);
m.add_primitive(id, Primitive::new(BoxShape::new(lo, hi)))
.unwrap();
}
#[test]
fn good_mesh_passes() {
let m = model(30, 1.0); let r = check(&m, &fdtd()).unwrap();
assert!(r.is_ok(), "{r}");
assert_eq!(r.warnings().count(), 0, "{r}");
let n = r.stats.min_cells_per_wavelength.unwrap();
assert!((n - 29.979).abs() < 1e-2, "{n}");
assert_eq!(r.stats.cells, 27_000);
assert_eq!(r.stats.nodes, [31; 3]);
assert_eq!(r.stats.engine_bytes, 31u64.pow(3) * 72);
assert!((r.stats.dt / r.stats.dt_typical - 1.0).abs() < 1e-12);
}
#[test]
fn coarse_mesh_flagged() {
let r = check(&model(10, 2.0), &fdtd()).unwrap();
assert!(r.is_ok(), "{r}");
assert_eq!(r.warnings().count(), 3, "{r}");
assert!(
r.warnings()
.all(|i| matches!(i.location, Location::Cell { .. }))
);
let r = check(&model(10, 4.0), &fdtd()).unwrap();
assert!(!r.is_ok());
assert_eq!(r.errors().count(), 3, "{r}");
let opts = CheckOptions::default()
.cells_per_wavelength(7.0)
.min_cells_per_wavelength(5.0);
let r = check_with(&model(10, 4.0), &fdtd(), &opts).unwrap();
assert!(r.is_ok() && r.warnings().count() == 0, "{r}");
}
#[test]
fn no_frequency_is_info() {
let r = check(&model(10, 4.0), &Fdtd::new()).unwrap();
assert!(r.is_ok());
assert!(
r.infos()
.any(|i| i.message.contains("no maximum frequency"))
);
assert!(r.stats.f_max.is_none());
let e = suggest_max_cell(&model(10, 1.0), &Fdtd::new(), 20.0);
assert!(e.is_err());
}
#[test]
fn high_permittivity_needs_finer_cells() {
let mut m = model(30, 1.0);
add_box(
&mut m,
Property::material("sub", Material::new(4.0, 0.0)),
[0.0, 0.0, 0.0],
[30.0, 30.0, 5.0],
);
let r = check(&m, &fdtd()).unwrap();
assert!(r.is_ok(), "{r}");
assert_eq!(r.warnings().count(), 3, "{r}");
let z = r
.warnings()
.find(|i| matches!(i.location, Location::Cell { axis: Axis::Z, .. }))
.unwrap();
assert!(z.message.starts_with("5 z cell(s)"), "{}", z.message);
assert!((r.stats.lambda_min.unwrap() - C0 / 1e10 / 2.0).abs() < 1e-9);
let c = compile(&m, &fdtd(), &CompileOptions::default()).unwrap();
let rc = check_compiled(&m, &fdtd(), &c, &CheckOptions::default()).unwrap();
assert_eq!(rc.warnings().count(), 3, "{rc}");
let h = suggest_max_cell(&m, &fdtd(), 20.0).unwrap();
assert!((h - C0 / 1e10 / 2.0 / 20.0 / 1e-3).abs() < 1e-9, "{h}");
}
#[test]
fn grading_flagged() {
let mut m = model(30, 1.0);
let mut x: Vec<f64> = (0..=10).map(f64::from).collect();
x.extend([10.5, 11.0]); x.extend((12..=30).map(f64::from));
m.grid.set_lines(0, x).unwrap();
let r = check(&m, &fdtd()).unwrap();
let w: Vec<_> = r.warnings().collect();
assert_eq!(w.len(), 1, "{r}");
assert!(w[0].message.contains("ratio(s) above 1.5; worst 2.00"));
assert!(matches!(
w[0].location,
Location::Cell {
axis: Axis::X,
index: 9,
..
} | Location::Cell {
axis: Axis::X,
index: 12,
..
}
));
}
#[test]
fn grading_wraps_across_periodic_faces() {
let mut m = model(30, 1.0);
let mut x = vec![0.0, 0.5]; x.extend((1..=30).map(f64::from));
m.grid.set_lines(0, x).unwrap();
let count = |f: &Fdtd| {
let r = check(&m, f).unwrap();
r.warnings()
.find(|i| i.message.contains("x neighbour"))
.map(|i| i.message.clone())
};
let open = count(&fdtd()).unwrap();
assert!(open.starts_with("1 x neighbour"), "{open}");
let periodic = fdtd()
.boundary(crate::Face::XMin, Boundary::Periodic)
.boundary(crate::Face::XMax, Boundary::Periodic);
let wrapped = count(&periodic).unwrap();
assert!(wrapped.starts_with("2 x neighbour"), "{wrapped}");
}
#[test]
fn csx_smoothed_mesh_passes_grading() {
let mut lines = csx::grid::smooth_lines(&[0.0, 0.2, 30.0], 1.0, 1.4).unwrap();
lines.dedup();
let mut m = model(30, 1.0);
m.grid.set_lines(0, lines).unwrap();
let r = check(&m, &fdtd()).unwrap();
assert!(r.warnings().all(|i| !i.message.contains("ratio")), "{r}");
}
#[test]
fn tiny_cells_dominate_timestep() {
let mut m = model(30, 1.0);
m.grid.add_line(2, 10.01).unwrap();
let r = check(&m, &fdtd()).unwrap();
let w = r
.warnings()
.find(|i| i.message.contains("dominate"))
.expect("time step warning");
assert!(matches!(
w.location,
Location::Cell {
axis: Axis::Z,
index: 10,
..
}
));
assert!(r.stats.dt < 0.2 * r.stats.dt_typical);
assert_eq!(r.stats.min_cell_index[2], 10);
}
#[test]
fn pml_checks() {
let pml = fdtd().boundaries(Boundaries::all(Boundary::pml(8)));
let mut m = model(30, 1.0);
add_box(&mut m, Property::metal("inside"), [10.0; 3], [20.0; 3]);
let r = check(&m, &pml).unwrap();
assert!(r.is_ok() && r.warnings().count() == 0, "{r}");
assert!(r.stats.engine_bytes > 31u64.pow(3) * 72);
add_box(
&mut m,
Property::material("diel", Material::new(2.0, 0.0)),
[5.0, 10.0, 10.0],
[20.0, 20.0, 25.0],
);
add_box(
&mut m,
Property::material("air", Material::new(1.0, 0.0)),
[0.0; 3],
[30.0; 3],
);
let r = check(&m, &pml).unwrap();
let w: Vec<_> = r.warnings().collect();
assert_eq!(w.len(), 1, "{r}");
assert!(w[0].message.contains("PML of face(s) xmin, zmax"), "{r}");
assert_eq!(
w[0].location,
Location::Primitive {
property: "diel".into(),
index: 0
}
);
let r = check(&model(10, 1.0), &pml).unwrap();
assert_eq!(r.errors().count(), 3, "{r}"); let r = check(
&model(10, 1.0),
&fdtd().boundary(Face::XMin, Boundary::pml(20)),
)
.unwrap();
assert!(r.errors().any(|i| i.location == Location::Face(Face::XMin)));
let mut m = model(30, 1.0);
let mut x = vec![-12.0, -6.0, -3.0, -1.5];
x.extend((0..=30).map(f64::from));
m.grid.set_lines(0, x).unwrap();
let r = check(&m, &fdtd().boundary(Face::XMin, Boundary::pml(4))).unwrap();
assert!(
r.warnings()
.any(|i| i.location == Location::Face(Face::XMin)
&& i.message.contains("strongly graded")),
"{r}"
);
}
#[test]
fn sheets_off_mesh() {
let mut m = model(30, 1.0);
add_box(
&mut m,
Property::metal("ok"),
[5.0, 5.0, 10.0],
[9.0, 9.0, 10.0],
);
let r = check(&m, &fdtd()).unwrap();
assert!(r.is_ok() && r.warnings().count() == 0, "{r}");
add_box(
&mut m,
Property::metal("off"),
[5.0, 5.0, 10.4],
[9.0, 9.0, 10.4],
);
add_box(
&mut m,
Property::metal("wire"),
[5.5, 5.0, 3.0],
[5.5, 9.0, 3.0],
);
let r = check(&m, &fdtd()).unwrap();
let e: Vec<_> = r.errors().collect();
assert_eq!(e.len(), 2, "{r}");
assert!(e[0].message.contains("z = 10.4") && e[0].message.contains("nearest 10"));
assert!(e[1].message.contains("line") && e[1].message.contains("x = 5.5"));
let c = compile(&m, &fdtd(), &CompileOptions::default()).unwrap();
let rc = check_compiled(&m, &fdtd(), &c, &CheckOptions::default()).unwrap();
assert_eq!(rc.errors().count(), 2, "{rc}");
}
#[test]
fn steps_and_limits() {
let f = fdtd().max_timesteps(10);
let r = check(&model(30, 1.0), &f).unwrap();
assert_eq!(r.stats.max_steps, 10);
let n = r.stats.excitation_steps.unwrap();
assert!(n > 10);
assert!(r.warnings().any(|i| i.message.contains("excitation needs")));
let r = check(&model(30, 1.0), &fdtd().timestep(1.0)).unwrap();
assert!(!r.is_ok());
}
#[test]
fn report_formatting() {
let mut m = model(10, 4.0);
m.grid.add_line(0, 0.2).unwrap();
let r = check(&m, &fdtd()).unwrap();
let s = r.to_string();
assert!(
s.starts_with("mesh check: 3 error(s), 2 warning(s)\n"),
"{s}"
);
assert!(
s.contains(" cells: 11 x 10 x 10 = 1100 (1452 nodes)"),
"{s}"
);
assert!(s.contains("f_max 1.0000e10 Hz"), "{s}");
assert!(
s.contains("error: 10 x cell(s) coarser than lambda/20"),
"{s}"
);
assert!(s.contains("[x cell "), "{s}");
let msgs = r.messages(Severity::Warning);
assert_eq!(msgs.len(), 5);
assert!(msgs.iter().all(|m| m.starts_with("mesh ")));
let ok = check(&model(30, 1.0), &fdtd()).unwrap().to_string();
assert!(ok.starts_with("mesh check: OK\n"), "{ok}");
assert!(ok.contains("info: time step"), "{ok}");
let i = MeshIssue {
severity: Severity::Warning,
message: "m".into(),
location: Location::Face(Face::YMax),
};
assert_eq!(i.to_string(), "warning: m [ymax face]");
}
}