Simulation Validation
Validating your FDTD simulations is crucial to ensure accuracy and reliability. This guide covers validation methods, best practices, and common checks.
Why Validate?
FDTD simulations can produce incorrect results due to:
Insufficient resolution
Inappropriate time step
Boundary reflections
Source implementation errors
Material model inaccuracies
Always validate against known solutions before trusting new simulations.
Validation Hierarchy
1. Basic Sanity Checks
Courant Stability Condition
FDTD requires dt ≤ Courant limit for stability:
import numpy as np
def check_courant_condition(sim):
"""Verify Courant stability."""
# 3D: dt ≤ 1 / (c * sqrt(1/dx² + 1/dy² + 1/dz²))
# 2D: dt ≤ 1 / (c * sqrt(1/dx² + 1/dy²))
c = 299792458.0 # Speed of light
if sim.is_3d:
courant_limit = 1.0 / (c * np.sqrt(
1/sim.dx**2 + 1/sim.dy**2 + 1/sim.dz**2
))
else:
courant_limit = 1.0 / (c * np.sqrt(
1/sim.dx**2 + 1/sim.dy**2
))
courant_number = sim.dt / courant_limit
print(f"Courant number: {courant_number:.4f}")
print(f" dt = {sim.dt:.3e} s")
print(f" Courant limit = {courant_limit:.3e} s")
if courant_number > 1.0:
raise ValueError("Unstable! Courant number > 1.0")
elif courant_number > 0.99:
print("Warning: Courant number very close to limit")
else:
print("✓ Courant condition satisfied")
return courant_number
# Check your simulation
check_courant_condition(sim)
Energy Conservation
In lossless regions, energy should be conserved:
def check_energy_conservation(sim, monitors):
"""Check energy conservation over time."""
# Calculate total electromagnetic energy
# U = (ε₀ε|E|² + μ₀|H|²) / 2
energies = []
for monitor in monitors:
Ex, Ey, Ez = monitor.get_field_data('Ex', 'Ey', 'Ez')
Hx, Hy, Hz = monitor.get_field_data('Hx', 'Hy', 'Hz')
# Electromagnetic energy density
eps0 = 8.854187817e-12
mu0 = 4 * np.pi * 1e-7
U_E = 0.5 * eps0 * (np.abs(Ex)**2 + np.abs(Ey)**2 + np.abs(Ez)**2)
U_H = 0.5 * mu0 * (np.abs(Hx)**2 + np.abs(Hy)**2 + np.abs(Hz)**2)
total_energy = np.sum(U_E + U_H)
energies.append(total_energy)
# Check energy variation
energy_variation = (np.max(energies) - np.min(energies)) / np.mean(energies)
print(f"Energy variation: {energy_variation*100:.2f}%")
if energy_variation < 0.01:
print("✓ Energy well conserved")
elif energy_variation < 0.05:
print("⚠ Some energy loss (acceptable for lossy materials)")
else:
print("✗ Significant energy loss - check for numerical issues")
return energies
Field Divergence Check
Maxwell’s equations require ∇·D = 0 in source-free regions:
def check_divergence(fields, dx, dy):
"""Check ∇·D = 0."""
# ∇·D = ε₀(∂Ex/∂x + ∂Ey/∂y + ∂Ez/∂z)
dEx_dx = np.gradient(fields.Ex, dx, axis=0)
dEy_dy = np.gradient(fields.Ey, dy, axis=1)
divergence = dEx_dx + dEy_dy
max_div = np.max(np.abs(divergence))
max_field = np.max(np.abs([fields.Ex, fields.Ey]))
relative_div = max_div / max_field if max_field > 0 else 0
print(f"Max divergence (relative): {relative_div:.2e}")
if relative_div < 1e-6:
print("✓ Divergence well satisfied")
else:
print("⚠ Check source implementation")
2. Analytical Validation
Plane Wave Propagation
Validate against analytical plane wave:
def validate_plane_wave():
"""Compare FDTD result with analytical plane wave."""
# Run FDTD simulation
from prismo import Simulation
from prismo.sources import TFSFSource
wavelength = 1.55e-6
frequency = 299792458.0 / wavelength
sim = Simulation(
size=(10e-6, 10e-6, 0.0),
resolution=40e6,
boundary_conditions='pml',
pml_layers=10,
)
source = TFSFSource(
center=(5e-6, 5e-6, 0.0),
size=(6e-6, 6e-6, 0.0),
direction='+x',
polarization='y',
frequency=frequency,
pulse=False, # CW for comparison
)
sim.add_source(source)
monitor = FieldMonitor(
center=(5e-6, 5e-6, 0.0),
size=(8e-6, 8e-6, 0.0),
components=['Ey'],
time_domain=True,
)
sim.add_monitor(monitor)
# Run simulation
sim.run(100e-15)
# Get FDTD result
times, Ey_fdtd = monitor.get_time_data('Ey')
# Analytical result
k = 2 * np.pi / wavelength
omega = 2 * np.pi * frequency
x = sim.grid.x
Ey_analytical = np.sin(k * x - omega * times[-1])
# Compare
error = np.abs(Ey_fdtd[-1, :, 0] - Ey_analytical)
rms_error = np.sqrt(np.mean(error**2))
print(f"RMS error vs analytical: {rms_error:.2e}")
if rms_error < 0.01:
print("✓ Excellent agreement with analytical solution")
elif rms_error < 0.05:
print("✓ Good agreement")
else:
print("✗ Poor agreement - check implementation")
return rms_error
# Run validation
validate_plane_wave()
Resonator Modes
Compare resonant frequencies with analytical solutions:
def validate_cavity_modes():
"""Validate cavity resonances."""
# Rectangular cavity: f_mnp = c/(2π) * sqrt((mπ/Lx)² + (nπ/Ly)² + (pπ/Lz)²)
Lx, Ly = 5e-6, 5e-6
m, n = 1, 1 # Mode numbers
c = 299792458.0
f_analytical = (c / (2 * np.pi)) * np.sqrt(
(m * np.pi / Lx)**2 + (n * np.pi / Ly)**2
)
print(f"Analytical resonance (TE₁₁): {f_analytical/1e12:.3f} THz")
# Run FDTD and extract resonance (would use DFT monitor)
# f_fdtd = ... (from simulation)
# error = abs(f_fdtd - f_analytical) / f_analytical
# print(f"Error: {error*100:.2f}%")
3. Convergence Tests
Spatial Convergence
Resolution should not affect results (for well-resolved simulations):
def test_spatial_convergence():
"""Test convergence with grid refinement."""
resolutions = [20e6, 40e6, 60e6, 80e6] # Points per meter
results = []
for resolution in resolutions:
sim = Simulation(
size=(5e-6, 5e-6, 0.0),
resolution=resolution,
boundary_conditions='pml',
)
# ... add source and monitors ...
sim.run(50e-15)
# Extract metric (e.g., transmission)
transmission = monitor.get_transmission()
results.append(transmission)
print(f"Resolution {resolution/1e6:.0f} ppμm: T = {transmission:.6f}")
# Check convergence
relative_change = abs(results[-1] - results[-2]) / results[-1]
if relative_change < 0.001:
print("✓ Converged (< 0.1% change)")
else:
print(f"⚠ Not fully converged ({relative_change*100:.2f}% change)")
return results
Temporal Convergence
For broadband sources, simulate long enough:
def check_temporal_convergence(monitor, threshold=0.01):
"""Check if fields have decayed sufficiently."""
times, Ex_data = monitor.get_time_data('Ex')
# Check last 10% of simulation
late_time = Ex_data[-len(times)//10:]
initial_max = np.max(np.abs(Ex_data[:len(times)//10]))
late_max = np.max(np.abs(late_time))
decay_ratio = late_max / initial_max if initial_max > 0 else 0
print(f"Late-time field ratio: {decay_ratio:.2e}")
if decay_ratio < threshold:
print("✓ Fields decayed sufficiently")
else:
print("⚠ Run longer for full decay")
return decay_ratio
4. Physical Consistency Checks
Reciprocity
S-parameters should satisfy reciprocity: S₁₂ = S₂₁
def check_reciprocity(s_parameters):
"""Verify reciprocity of S-parameters."""
S12 = s_parameters['S12']
S21 = s_parameters['S21']
error = np.abs(S12 - S21) / np.abs(S21)
max_error = np.max(error)
print(f"Reciprocity error: {max_error*100:.2f}%")
if max_error < 0.01:
print("✓ Reciprocity satisfied")
else:
print("✗ Reciprocity violated - check implementation")
Energy Balance
Power in = Power out + Power absorbed:
def check_energy_balance(flux_monitors):
"""Verify energy conservation via flux monitors."""
power_in = flux_monitors['input'].get_power()
power_out = flux_monitors['output'].get_power()
power_absorbed = flux_monitors['absorption'].get_power()
balance = power_in - (power_out + power_absorbed)
relative_balance = balance / power_in if power_in > 0 else 0
print(f"Energy balance error: {relative_balance*100:.2f}%")
if abs(relative_balance) < 0.05:
print("✓ Energy balanced")
else:
print("⚠ Energy imbalance - check monitors or PML")
Causality
Effects cannot precede causes:
def check_causality(monitor, source_start_time):
"""Ensure no fields before source turns on."""
times, Ex_data = monitor.get_time_data('Ex')
pre_source_idx = times < source_start_time
pre_source_field = Ex_data[pre_source_idx]
max_pre_source = np.max(np.abs(pre_source_field))
if max_pre_source < 1e-10:
print("✓ Causality satisfied")
else:
print("✗ Non-causal behavior detected")
5. Common Validation Examples
Example: Waveguide Mode Validation
def validate_waveguide_modes():
"""Validate mode solver against known results."""
from prismo.modes.solver import ModeSolver
# Silicon strip waveguide (well-studied)
wavelength = 1.55e-6
width = 0.5e-6
height = 0.22e-6
# Create structure
nx, ny = 100, 100
x = np.linspace(-2*width, 2*width, nx)
y = np.linspace(-2*height, 2*height, ny)
X, Y = np.meshgrid(x, y, indexing='ij')
epsilon = np.ones((nx, ny)) * 1.45**2 # SiO2
core_mask = (np.abs(X) < width/2) & (np.abs(Y) < height/2)
epsilon[core_mask] = 3.48**2 # Si
# Solve
solver = ModeSolver(wavelength, x, y, epsilon)
modes = solver.solve(num_modes=1, mode_type='TE')
neff_fdtd = modes[0].neff.real
# Compare with known value (from literature/commercial tools)
neff_reference = 2.45 # Example reference value
error = abs(neff_fdtd - neff_reference) / neff_reference
print(f"FDTD neff: {neff_fdtd:.4f}")
print(f"Reference neff: {neff_reference:.4f}")
print(f"Error: {error*100:.2f}%")
if error < 0.01:
print("✓ Mode solver validated")
else:
print("⚠ Check mode solver implementation or reference")
Validation Checklist
Before trusting your simulation:
Courant condition satisfied (dt < Courant limit)
Resolution adequate (≥20 points per wavelength)
PML reflections minimal (< -30 dB)
Fields decayed at end of simulation
Energy conserved (for lossless regions)
Results converged with resolution
Validated against analytical solution (if available)
Physical constraints satisfied (reciprocity, causality, etc.)
Automated Validation Script
class SimulationValidator:
"""Automated validation checks."""
def __init__(self, sim):
self.sim = sim
self.checks = []
def run_all_checks(self):
"""Run all validation checks."""
print("="*60)
print("SIMULATION VALIDATION")
print("="*60)
# Courant
self.check_courant()
# Resolution
self.check_resolution()
# PML
self.check_pml_reflections()
# Energy
self.check_energy_conservation()
# Summary
self.print_summary()
def check_courant(self):
courant = check_courant_condition(self.sim)
self.checks.append(('Courant', courant < 1.0))
def check_resolution(self):
wavelength = 1.55e-6 # Typical
ppw = wavelength / self.sim.dx # Points per wavelength
self.checks.append(('Resolution', ppw >= 20))
print(f"Points per wavelength: {ppw:.1f}")
def check_pml_reflections(self):
# Would need to measure actual reflections
pass
def check_energy_conservation(self):
# Would need energy monitors
pass
def print_summary(self):
print("\n" + "="*60)
print("VALIDATION SUMMARY")
print("="*60)
passed = sum(1 for _, result in self.checks if result)
total = len(self.checks)
for name, result in self.checks:
status = "✓ PASS" if result else "✗ FAIL"
print(f"{name:20s}: {status}")
print(f"\nPassed {passed}/{total} checks")
if passed == total:
print("✓ All validations passed!")
else:
print("⚠ Some validations failed - review results carefully")
# Usage
validator = SimulationValidator(sim)
validator.run_all_checks()
When to Suspect Problems
🚨 Red flags:
Fields grow exponentially (unstable)
Energy increases over time (non-physical)
Results change significantly with small parameter changes
Symmetry not preserved (for symmetric structures)
Reflections from “absorbing” boundaries
See Also
Quick Start - Getting started
Simulations - Simulation setup
Boundary Conditions - Boundary conditions
Examples - Validation examples
Python validation examples in
tests/validation/