Skip to content

Commit 6bb0d8f

Browse files
committed
fix(mode): fixed bug where mode solver was not respecting location of PEC boundary conditions from the Simulation
1 parent 8e7ff17 commit 6bb0d8f

File tree

4 files changed

+576
-7
lines changed

4 files changed

+576
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9797
- Removed mode solver warnings about evaluating permittivity of a `Medium2D`.
9898
- Maximum number of grid points in an EME simulation is now based solely on transverse grid points. Maximum number of EME cells is unchanged.
9999
- Fixed handling of polygons with holes (interiors) in `subdivide()` function. The function now properly converts polygons with interiors into `PolySlab` geometries using subtraction operations with `GeometryGroup`.
100+
- Fixed mode solver grid extending beyond simulation domain boundaries, causing incorrect PEC boundary application. The solver now properly truncates the computational grid to simulation bounds and zero-pads fields outside the domain.
100101

101102
### Removed
102103
- Removed deprecated `use_complex_fields` parameter from `TwoPhotonAbsorption` and `KerrNonlinearity`. Parameters `beta` and `n2` are now real-valued only, as is `n0` if specified.

tests/test_plugins/test_mode_solver.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from tidy3d.components.mode.derivatives import create_sfactor_b, create_sfactor_f
1616
from tidy3d.components.mode.solver import compute_modes
1717
from tidy3d.components.mode_spec import MODE_DATA_KEYS
18+
from tidy3d.constants import fp_eps
1819
from tidy3d.exceptions import DataError, SetupError
1920
from tidy3d.plugins.mode import ModeSolver
2021
from tidy3d.plugins.mode.mode_solver import MODE_MONITOR_NAME
@@ -1503,3 +1504,328 @@ def test_sort_spec_track_freq():
15031504
assert np.allclose(modes_lowest.Ex.abs, modes_lowest_retracked.Ex.abs)
15041505
assert np.all(modes_lowest.n_eff == modes_lowest_retracked.n_eff)
15051506
assert np.all(modes_lowest.n_group == modes_lowest_retracked.n_group)
1507+
1508+
1509+
def test_mode_solver_pec_boundary_truncation():
1510+
"""Test that fields are correctly zero outside simulation bounds and satisfy PEC boundary conditions.
1511+
1512+
This test creates a rectangular waveguide where the waveguide walls are modeled using the
1513+
PEC boundary conditions of the simulation domain. The mode solver grid may extend beyond
1514+
the simulation bounds due to _discretize_inds_monitor adding extra cells for interpolation.
1515+
This test verifies that:
1516+
1. Fields outside the simulation boundaries are exactly 0
1517+
2. Electric fields tangential to the boundary are 0 at the boundary
1518+
3. Magnetic fields normal to the boundary are 0 at the boundary
1519+
"""
1520+
# Create a simulation with PEC boundaries (default)
1521+
# The simulation size defines the waveguide cross-section
1522+
sim_size = (2.0, 0.0, 1.5) # Waveguide in y-direction, cross-section in x-z plane
1523+
freq0 = td.C_0 / 1.55
1524+
1525+
# Simple simulation with just air - the waveguide walls are the PEC boundaries
1526+
simulation = td.Simulation(
1527+
size=sim_size,
1528+
grid_spec=td.GridSpec.uniform(dl=0.05),
1529+
run_time=1e-14,
1530+
boundary_spec=td.BoundarySpec(
1531+
x=td.Boundary.pec(),
1532+
y=td.Boundary.periodic(), # Propagation direction
1533+
z=td.Boundary.pec(),
1534+
),
1535+
sources=[
1536+
td.PointDipole(
1537+
center=(0, 0, 0),
1538+
source_time=td.GaussianPulse(freq0=freq0, fwidth=freq0 / 10),
1539+
polarization="Ex",
1540+
)
1541+
],
1542+
)
1543+
1544+
# Mode plane perpendicular to propagation direction
1545+
plane = td.Box(center=(0, 0, 0), size=(sim_size[0], 0, sim_size[2]))
1546+
1547+
mode_spec = td.ModeSpec(
1548+
num_modes=2,
1549+
precision="double",
1550+
)
1551+
1552+
ms = ModeSolver(
1553+
simulation=simulation,
1554+
plane=plane,
1555+
mode_spec=mode_spec,
1556+
freqs=[freq0],
1557+
direction="+",
1558+
colocate=False,
1559+
)
1560+
1561+
# Get the mode solver data
1562+
data = ms.data
1563+
1564+
# Get simulation grid boundaries
1565+
sim_bounds = simulation.bounds
1566+
sim_x_min, sim_x_max = sim_bounds[0][0], sim_bounds[1][0]
1567+
sim_z_min, sim_z_max = sim_bounds[0][2], sim_bounds[1][2]
1568+
1569+
# Get solver grid boundaries (may extend beyond simulation)
1570+
solver_grid = ms._solver_grid
1571+
solver_x = solver_grid.boundaries.x
1572+
solver_z = solver_grid.boundaries.z
1573+
1574+
# Check if the solver grid extends beyond simulation bounds
1575+
grid_extends_x_min = solver_x[0] < sim_x_min - fp_eps
1576+
grid_extends_x_max = solver_x[-1] > sim_x_max + fp_eps
1577+
grid_extends_z_min = solver_z[0] < sim_z_min - fp_eps
1578+
grid_extends_z_max = solver_z[-1] > sim_z_max + fp_eps
1579+
1580+
# Test 1: Fields outside simulation boundaries should be exactly 0 (zero-padded)
1581+
for field_name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"):
1582+
field = data.field_components[field_name]
1583+
field_coords_x = field.coords["x"].values
1584+
field_coords_z = field.coords["z"].values
1585+
1586+
# Check fields at x positions outside simulation
1587+
if grid_extends_x_min:
1588+
x_outside_min = field_coords_x[field_coords_x < sim_x_min - fp_eps]
1589+
if len(x_outside_min) > 0:
1590+
field_outside = field.sel(x=x_outside_min)
1591+
assert np.all(field_outside.values == 0.0), (
1592+
f"{field_name} should be exactly 0 outside x_min boundary, got {np.max(np.abs(field_outside.values))}"
1593+
)
1594+
1595+
if grid_extends_x_max:
1596+
x_outside_max = field_coords_x[field_coords_x > sim_x_max + fp_eps]
1597+
if len(x_outside_max) > 0:
1598+
field_outside = field.sel(x=x_outside_max)
1599+
assert np.all(field_outside.values == 0.0), (
1600+
f"{field_name} should be exactly 0 outside x_max boundary, got {np.max(np.abs(field_outside.values))}"
1601+
)
1602+
1603+
# Check fields at z positions outside simulation
1604+
if grid_extends_z_min:
1605+
z_outside_min = field_coords_z[field_coords_z < sim_z_min - fp_eps]
1606+
if len(z_outside_min) > 0:
1607+
field_outside = field.sel(z=z_outside_min)
1608+
assert np.all(field_outside.values == 0.0), (
1609+
f"{field_name} should be exactly 0 outside z_min boundary, got {np.max(np.abs(field_outside.values))}"
1610+
)
1611+
1612+
if grid_extends_z_max:
1613+
z_outside_max = field_coords_z[field_coords_z > sim_z_max + fp_eps]
1614+
if len(z_outside_max) > 0:
1615+
field_outside = field.sel(z=z_outside_max)
1616+
assert np.all(field_outside.values == 0.0), (
1617+
f"{field_name} should be exactly 0 outside z_max boundary, got {np.max(np.abs(field_outside.values))}"
1618+
)
1619+
1620+
# Test 2: Tangential E-fields at PEC boundaries should be exactly 0
1621+
# At x boundaries: Ey and Ez are tangential
1622+
# At z boundaries: Ex and Ey are tangential
1623+
1624+
# Get field coordinates exactly at boundaries
1625+
for field_name in ("Ey", "Ez"):
1626+
field = data.field_components[field_name]
1627+
field_coords_x = field.coords["x"].values
1628+
# Find coordinates exactly at boundaries
1629+
x_at_min = field_coords_x[np.isclose(field_coords_x, sim_x_min, atol=fp_eps)]
1630+
x_at_max = field_coords_x[np.isclose(field_coords_x, sim_x_max, atol=fp_eps)]
1631+
1632+
if len(x_at_min) > 0:
1633+
field_at_boundary = field.sel(x=x_at_min[0])
1634+
assert np.all(field_at_boundary.values == 0.0), (
1635+
f"{field_name} (tangential) should be exactly 0 at x_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1636+
)
1637+
1638+
if len(x_at_max) > 0:
1639+
field_at_boundary = field.sel(x=x_at_max[0])
1640+
assert np.all(field_at_boundary.values == 0.0), (
1641+
f"{field_name} (tangential) should be exactly 0 at x_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1642+
)
1643+
1644+
for field_name in ("Ex", "Ey"):
1645+
field = data.field_components[field_name]
1646+
field_coords_z = field.coords["z"].values
1647+
# Find coordinates exactly at boundaries
1648+
z_at_min = field_coords_z[np.isclose(field_coords_z, sim_z_min, atol=fp_eps)]
1649+
z_at_max = field_coords_z[np.isclose(field_coords_z, sim_z_max, atol=fp_eps)]
1650+
1651+
if len(z_at_min) > 0:
1652+
field_at_boundary = field.sel(z=z_at_min[0])
1653+
assert np.all(field_at_boundary.values == 0.0), (
1654+
f"{field_name} (tangential) should be exactly 0 at z_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1655+
)
1656+
1657+
if len(z_at_max) > 0:
1658+
field_at_boundary = field.sel(z=z_at_max[0])
1659+
assert np.all(field_at_boundary.values == 0.0), (
1660+
f"{field_name} (tangential) should be exactly 0 at z_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1661+
)
1662+
1663+
# Test 3: Normal H-fields at PEC boundaries should be exactly 0
1664+
# At x boundaries: Hx is normal
1665+
# At z boundaries: Hz is normal
1666+
field = data.field_components["Hx"]
1667+
field_coords_x = field.coords["x"].values
1668+
x_at_min = field_coords_x[np.isclose(field_coords_x, sim_x_min, atol=fp_eps)]
1669+
x_at_max = field_coords_x[np.isclose(field_coords_x, sim_x_max, atol=fp_eps)]
1670+
1671+
if len(x_at_min) > 0:
1672+
field_at_boundary = field.sel(x=x_at_min[0])
1673+
assert np.all(field_at_boundary.values == 0.0), (
1674+
f"Hx (normal) should be exactly 0 at x_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1675+
)
1676+
1677+
if len(x_at_max) > 0:
1678+
field_at_boundary = field.sel(x=x_at_max[0])
1679+
assert np.all(field_at_boundary.values == 0.0), (
1680+
f"Hx (normal) should be exactly 0 at x_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1681+
)
1682+
1683+
field = data.field_components["Hz"]
1684+
field_coords_z = field.coords["z"].values
1685+
z_at_min = field_coords_z[np.isclose(field_coords_z, sim_z_min, atol=fp_eps)]
1686+
z_at_max = field_coords_z[np.isclose(field_coords_z, sim_z_max, atol=fp_eps)]
1687+
1688+
if len(z_at_min) > 0:
1689+
field_at_boundary = field.sel(z=z_at_min[0])
1690+
assert np.all(field_at_boundary.values == 0.0), (
1691+
f"Hz (normal) should be exactly 0 at z_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1692+
)
1693+
1694+
if len(z_at_max) > 0:
1695+
field_at_boundary = field.sel(z=z_at_max[0])
1696+
assert np.all(field_at_boundary.values == 0.0), (
1697+
f"Hz (normal) should be exactly 0 at z_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1698+
)
1699+
1700+
1701+
def test_mode_solver_boundary_warning_pmc():
1702+
"""Test that warnings are emitted when mode solver plane intersects unsupported PMC boundaries."""
1703+
# Create a simulation with PMC boundaries on x
1704+
simulation = td.Simulation(
1705+
size=(4, 3, 3),
1706+
grid_spec=td.GridSpec.auto(wavelength=1.0),
1707+
structures=[WAVEGUIDE],
1708+
run_time=1e-12,
1709+
boundary_spec=td.BoundarySpec(
1710+
x=td.Boundary.pmc(),
1711+
y=td.Boundary.pec(),
1712+
z=td.Boundary.pec(),
1713+
),
1714+
)
1715+
1716+
# Mode plane in xz plane that extends to simulation x boundaries
1717+
# The plane is larger than simulation, so it will intersect the PMC boundaries
1718+
plane = td.Box(center=(0, 0, 0), size=(6, 0, 6))
1719+
1720+
mode_spec = td.ModeSpec(num_modes=1)
1721+
ms = ModeSolver(
1722+
simulation=simulation,
1723+
plane=plane,
1724+
mode_spec=mode_spec,
1725+
freqs=[td.C_0 / 1.0],
1726+
)
1727+
1728+
# Access the cached property to trigger the warning
1729+
with AssertLogLevel("WARNING", contains_str="PMC") as ctx:
1730+
_ = ms._sim_boundary_positions
1731+
# Should warn for both x- and x+ sides
1732+
assert ctx.num_records == 2
1733+
1734+
1735+
def test_mode_solver_boundary_warning_periodic():
1736+
"""Test that warnings are emitted when mode solver plane intersects unsupported periodic boundaries."""
1737+
# Create a simulation with periodic boundaries on z
1738+
simulation = td.Simulation(
1739+
size=(4, 3, 3),
1740+
grid_spec=td.GridSpec.auto(wavelength=1.0),
1741+
structures=[WAVEGUIDE],
1742+
run_time=1e-12,
1743+
boundary_spec=td.BoundarySpec(
1744+
x=td.Boundary.pec(),
1745+
y=td.Boundary.pec(),
1746+
z=td.Boundary.periodic(),
1747+
),
1748+
)
1749+
1750+
# Mode plane in xz plane that extends to simulation z boundaries
1751+
plane = td.Box(center=(0, 0, 0), size=(6, 0, 6))
1752+
1753+
mode_spec = td.ModeSpec(num_modes=1)
1754+
ms = ModeSolver(
1755+
simulation=simulation,
1756+
plane=plane,
1757+
mode_spec=mode_spec,
1758+
freqs=[td.C_0 / 1.0],
1759+
)
1760+
1761+
# Access the cached property to trigger the warning
1762+
with AssertLogLevel("WARNING", contains_str="periodic") as ctx:
1763+
_ = ms._sim_boundary_positions
1764+
# Should warn for both z- and z+ sides
1765+
assert ctx.num_records == 2
1766+
1767+
1768+
def test_mode_solver_boundary_warning_bloch():
1769+
"""Test that warnings are emitted when mode solver plane intersects unsupported Bloch boundaries."""
1770+
# Create a simulation with Bloch boundaries on x
1771+
simulation = td.Simulation(
1772+
size=(4, 3, 3),
1773+
grid_spec=td.GridSpec.auto(wavelength=1.0),
1774+
structures=[WAVEGUIDE],
1775+
run_time=1e-12,
1776+
boundary_spec=td.BoundarySpec(
1777+
x=td.Boundary.bloch(bloch_vec=0.1),
1778+
y=td.Boundary.pec(),
1779+
z=td.Boundary.pec(),
1780+
),
1781+
)
1782+
1783+
# Mode plane in xz plane that extends to simulation x boundaries
1784+
plane = td.Box(center=(0, 0, 0), size=(6, 0, 6))
1785+
1786+
mode_spec = td.ModeSpec(num_modes=1)
1787+
ms = ModeSolver(
1788+
simulation=simulation,
1789+
plane=plane,
1790+
mode_spec=mode_spec,
1791+
freqs=[td.C_0 / 1.0],
1792+
)
1793+
1794+
# Access the cached property to trigger the warning
1795+
with AssertLogLevel("WARNING", contains_str="Bloch") as ctx:
1796+
_ = ms._sim_boundary_positions
1797+
# Should warn for both x- and x+ sides
1798+
assert ctx.num_records == 2
1799+
1800+
1801+
def test_mode_solver_boundary_no_warning_when_not_intersecting():
1802+
"""Test that no warnings are emitted when mode solver plane doesn't intersect unsupported boundaries."""
1803+
# Create a simulation with PMC boundaries on x
1804+
simulation = td.Simulation(
1805+
size=(4, 3, 3),
1806+
grid_spec=td.GridSpec.auto(wavelength=1.0),
1807+
structures=[WAVEGUIDE],
1808+
run_time=1e-12,
1809+
boundary_spec=td.BoundarySpec(
1810+
x=td.Boundary.pmc(),
1811+
y=td.Boundary.pec(),
1812+
z=td.Boundary.pec(),
1813+
),
1814+
)
1815+
1816+
# Mode plane in xz plane that is smaller than simulation x bounds
1817+
# The plane doesn't extend to the PMC boundaries
1818+
plane = td.Box(center=(0, 0, 0), size=(2, 0, 2))
1819+
1820+
mode_spec = td.ModeSpec(num_modes=1)
1821+
ms = ModeSolver(
1822+
simulation=simulation,
1823+
plane=plane,
1824+
mode_spec=mode_spec,
1825+
freqs=[td.C_0 / 1.0],
1826+
)
1827+
1828+
# Access the cached property - should NOT trigger any warning
1829+
with AssertLogLevel(None) as ctx:
1830+
_ = ms._sim_boundary_positions
1831+
assert ctx.num_records == 0

0 commit comments

Comments
 (0)