Skip to content

Commit 5a716d5

Browse files
committed
fix(mode): fixed bug where mode solver was not respecting location of PEC boundary conditions from the Simulation
1 parent fb43388 commit 5a716d5

File tree

6 files changed

+477
-8
lines changed

6 files changed

+477
-8
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Changed
1313

1414
### Fixed
15+
- Fix to `outer_dot` when frequencies stored in the data were not in increasing order. Previously, the result would be provided with re-sorted frequencies, which would not match the order of the original data.
16+
- Fixed bug where an extra spatial coordinate could appear in `complex_flux` and `ImpedanceCalculator` results.
17+
- Fixed normal for `Box` shape gradient computation to always point outward from boundary which is needed for correct PEC handling.
18+
- Fixed `Box` gradients within `GeometryGroup` where the group intersection boundaries were forwarded.
19+
- Fixed `Box` gradients to use automatic permittivity detection for inside/outside permittivity.
20+
- Fixed bug where `ModeSolver` and `Simulation` did not place PEC boundaries at the same location. The solver now truncates the computational grid to simulation bounds and zero-pads fields outside the domain.
1521

1622
## [2.10.0] - 2025-12-18
1723

tests/test_plugins/test_mode_solver.py

Lines changed: 245 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 TOL_DEGENERATE_CANDIDATE, EigSolver, 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
@@ -1561,3 +1562,247 @@ def test_degenerate_mode_processing():
15611562
msg = f"Found {len(indices)} off-diagonal values > {threshold}:\n"
15621563
msg += "\n".join(f" |S[{i},{j}]| = {np.abs(S[i, j]):.4e}" for i, j in indices)
15631564
assert not np.any(problem_mask), msg
1565+
1566+
1567+
def test_mode_solver_pec_boundary_truncation():
1568+
"""Test that fields are correctly zero outside simulation bounds and satisfy PEC boundary conditions.
1569+
1570+
This test creates a rectangular waveguide where the waveguide walls are modeled using the
1571+
PEC boundary conditions of the simulation domain. The mode solver grid may extend beyond
1572+
the simulation bounds due to _discretize_inds_monitor adding extra cells for interpolation.
1573+
This test verifies that:
1574+
1. Fields outside the simulation boundaries are exactly 0
1575+
2. Electric fields tangential to the boundary are 0 at the boundary
1576+
3. Magnetic fields normal to the boundary are 0 at the boundary
1577+
"""
1578+
# Create a simulation with PEC boundaries (default)
1579+
# The simulation size defines the waveguide cross-section
1580+
sim_size = (2.0, 0.0, 1.5) # Waveguide in y-direction, cross-section in x-z plane
1581+
freq0 = td.C_0 / 1.55
1582+
1583+
# Simple simulation with just air - the waveguide walls are the PEC boundaries
1584+
simulation = td.Simulation(
1585+
size=sim_size,
1586+
grid_spec=td.GridSpec.uniform(dl=0.05),
1587+
run_time=1e-14,
1588+
boundary_spec=td.BoundarySpec(
1589+
x=td.Boundary.pec(),
1590+
y=td.Boundary.periodic(), # Propagation direction
1591+
z=td.Boundary.pec(),
1592+
),
1593+
sources=[
1594+
td.PointDipole(
1595+
center=(0, 0, 0),
1596+
source_time=td.GaussianPulse(freq0=freq0, fwidth=freq0 / 10),
1597+
polarization="Ex",
1598+
)
1599+
],
1600+
)
1601+
1602+
# Mode plane perpendicular to propagation direction
1603+
plane = td.Box(center=(0, 0, 0), size=(sim_size[0], 0, sim_size[2]))
1604+
1605+
mode_spec = td.ModeSpec(
1606+
num_modes=2,
1607+
precision="double",
1608+
)
1609+
1610+
ms = ModeSolver(
1611+
simulation=simulation,
1612+
plane=plane,
1613+
mode_spec=mode_spec,
1614+
freqs=[freq0],
1615+
direction="+",
1616+
colocate=False,
1617+
)
1618+
1619+
# Get the mode solver data
1620+
data = ms.data
1621+
1622+
# Get simulation boundaries
1623+
sim_bounds = simulation.bounds
1624+
sim_x_min, sim_x_max = sim_bounds[0][0], sim_bounds[1][0]
1625+
sim_z_min, sim_z_max = sim_bounds[0][2], sim_bounds[1][2]
1626+
1627+
# Test 1: Fields outside simulation boundaries should be exactly 0 (zero-padded)
1628+
for field_name in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"):
1629+
field = data.field_components[field_name]
1630+
field_coords_x = field.coords["x"].values
1631+
field_coords_z = field.coords["z"].values
1632+
1633+
# Check fields at x positions outside simulation
1634+
x_outside_min = field_coords_x[
1635+
(field_coords_x < sim_x_min)
1636+
& ~np.isclose(field_coords_x, sim_x_min, rtol=fp_eps, atol=fp_eps)
1637+
]
1638+
if len(x_outside_min) > 0:
1639+
field_outside = field.sel(x=x_outside_min)
1640+
assert np.all(field_outside.values == 0.0), (
1641+
f"{field_name} should be exactly 0 outside x_min boundary, got {np.max(np.abs(field_outside.values))}"
1642+
)
1643+
1644+
x_outside_max = field_coords_x[
1645+
(field_coords_x > sim_x_max)
1646+
& ~np.isclose(field_coords_x, sim_x_max, rtol=fp_eps, atol=fp_eps)
1647+
]
1648+
if len(x_outside_max) > 0:
1649+
field_outside = field.sel(x=x_outside_max)
1650+
assert np.all(field_outside.values == 0.0), (
1651+
f"{field_name} should be exactly 0 outside x_max boundary, got {np.max(np.abs(field_outside.values))}"
1652+
)
1653+
1654+
# Check fields at z positions outside simulation
1655+
z_outside_min = field_coords_z[
1656+
(field_coords_z < sim_z_min)
1657+
& ~np.isclose(field_coords_z, sim_z_min, rtol=fp_eps, atol=fp_eps)
1658+
]
1659+
if len(z_outside_min) > 0:
1660+
field_outside = field.sel(z=z_outside_min)
1661+
assert np.all(field_outside.values == 0.0), (
1662+
f"{field_name} should be exactly 0 outside z_min boundary, got {np.max(np.abs(field_outside.values))}"
1663+
)
1664+
1665+
z_outside_max = field_coords_z[
1666+
(field_coords_z > sim_z_max)
1667+
& ~np.isclose(field_coords_z, sim_z_max, rtol=fp_eps, atol=fp_eps)
1668+
]
1669+
if len(z_outside_max) > 0:
1670+
field_outside = field.sel(z=z_outside_max)
1671+
assert np.all(field_outside.values == 0.0), (
1672+
f"{field_name} should be exactly 0 outside z_max boundary, got {np.max(np.abs(field_outside.values))}"
1673+
)
1674+
1675+
# Test 2: Tangential E-fields at PEC boundaries should be exactly 0
1676+
# At x boundaries: Ey and Ez are tangential
1677+
# At z boundaries: Ex and Ey are tangential
1678+
1679+
# Get field coordinates exactly at boundaries
1680+
for field_name in ("Ey", "Ez"):
1681+
field = data.field_components[field_name]
1682+
field_coords_x = field.coords["x"].values
1683+
# Find coordinates exactly at boundaries
1684+
x_at_min = field_coords_x[np.isclose(field_coords_x, sim_x_min, rtol=fp_eps, atol=fp_eps)]
1685+
x_at_max = field_coords_x[np.isclose(field_coords_x, sim_x_max, rtol=fp_eps, atol=fp_eps)]
1686+
1687+
if len(x_at_min) > 0:
1688+
field_at_boundary = field.sel(x=x_at_min[0])
1689+
assert np.all(field_at_boundary.values == 0.0), (
1690+
f"{field_name} (tangential) should be exactly 0 at x_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1691+
)
1692+
1693+
if len(x_at_max) > 0:
1694+
field_at_boundary = field.sel(x=x_at_max[0])
1695+
assert np.all(field_at_boundary.values == 0.0), (
1696+
f"{field_name} (tangential) should be exactly 0 at x_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1697+
)
1698+
1699+
for field_name in ("Ex", "Ey"):
1700+
field = data.field_components[field_name]
1701+
field_coords_z = field.coords["z"].values
1702+
# Find coordinates exactly at boundaries
1703+
z_at_min = field_coords_z[np.isclose(field_coords_z, sim_z_min, rtol=fp_eps, atol=fp_eps)]
1704+
z_at_max = field_coords_z[np.isclose(field_coords_z, sim_z_max, rtol=fp_eps, atol=fp_eps)]
1705+
1706+
if len(z_at_min) > 0:
1707+
field_at_boundary = field.sel(z=z_at_min[0])
1708+
assert np.all(field_at_boundary.values == 0.0), (
1709+
f"{field_name} (tangential) should be exactly 0 at z_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1710+
)
1711+
1712+
if len(z_at_max) > 0:
1713+
field_at_boundary = field.sel(z=z_at_max[0])
1714+
assert np.all(field_at_boundary.values == 0.0), (
1715+
f"{field_name} (tangential) should be exactly 0 at z_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1716+
)
1717+
1718+
# Test 3: Normal H-fields at PEC boundaries should be exactly 0
1719+
# At x boundaries: Hx is normal
1720+
# At z boundaries: Hz is normal
1721+
field = data.field_components["Hx"]
1722+
field_coords_x = field.coords["x"].values
1723+
x_at_min = field_coords_x[np.isclose(field_coords_x, sim_x_min, rtol=fp_eps, atol=fp_eps)]
1724+
x_at_max = field_coords_x[np.isclose(field_coords_x, sim_x_max, rtol=fp_eps, atol=fp_eps)]
1725+
1726+
if len(x_at_min) > 0:
1727+
field_at_boundary = field.sel(x=x_at_min[0])
1728+
assert np.all(field_at_boundary.values == 0.0), (
1729+
f"Hx (normal) should be exactly 0 at x_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1730+
)
1731+
1732+
if len(x_at_max) > 0:
1733+
field_at_boundary = field.sel(x=x_at_max[0])
1734+
assert np.all(field_at_boundary.values == 0.0), (
1735+
f"Hx (normal) should be exactly 0 at x_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1736+
)
1737+
1738+
field = data.field_components["Hz"]
1739+
field_coords_z = field.coords["z"].values
1740+
z_at_min = field_coords_z[np.isclose(field_coords_z, sim_z_min, rtol=fp_eps, atol=fp_eps)]
1741+
z_at_max = field_coords_z[np.isclose(field_coords_z, sim_z_max, rtol=fp_eps, atol=fp_eps)]
1742+
1743+
if len(z_at_min) > 0:
1744+
field_at_boundary = field.sel(z=z_at_min[0])
1745+
assert np.all(field_at_boundary.values == 0.0), (
1746+
f"Hz (normal) should be exactly 0 at z_min PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1747+
)
1748+
1749+
if len(z_at_max) > 0:
1750+
field_at_boundary = field.sel(z=z_at_max[0])
1751+
assert np.all(field_at_boundary.values == 0.0), (
1752+
f"Hz (normal) should be exactly 0 at z_max PEC boundary, got {np.max(np.abs(field_at_boundary.values))}"
1753+
)
1754+
1755+
1756+
@pytest.mark.parametrize(
1757+
"boundary_type,boundary_axis,warning_str,plane_size,expected_warnings",
1758+
[
1759+
("pmc", "x", "PMC", (6, 0, 6), 2),
1760+
("periodic", "z", "periodic", (6, 0, 6), 2),
1761+
("bloch", "x", "Bloch", (6, 0, 6), 2),
1762+
("pmc", "x", None, (2, 0, 2), 0), # No warning when plane doesn't intersect boundary
1763+
],
1764+
ids=["pmc", "periodic", "bloch", "no_intersection"],
1765+
)
1766+
def test_mode_solver_boundary_warning(
1767+
boundary_type, boundary_axis, warning_str, plane_size, expected_warnings
1768+
):
1769+
"""Test that warnings are emitted when mode solver plane intersects unsupported boundaries."""
1770+
# Set up boundary spec based on test parameters
1771+
if boundary_type == "pmc":
1772+
test_boundary = td.Boundary.pmc()
1773+
elif boundary_type == "periodic":
1774+
test_boundary = td.Boundary.periodic()
1775+
elif boundary_type == "bloch":
1776+
test_boundary = td.Boundary.bloch(bloch_vec=0.1)
1777+
1778+
# Apply the test boundary to the specified axis, PEC to others
1779+
boundary_spec = td.BoundarySpec(
1780+
x=test_boundary if boundary_axis == "x" else td.Boundary.pec(),
1781+
y=td.Boundary.pec(),
1782+
z=test_boundary if boundary_axis == "z" else td.Boundary.pec(),
1783+
)
1784+
1785+
simulation = td.Simulation(
1786+
size=(4, 3, 3),
1787+
grid_spec=td.GridSpec.auto(wavelength=1.0),
1788+
structures=[WAVEGUIDE],
1789+
run_time=1e-12,
1790+
boundary_spec=boundary_spec,
1791+
)
1792+
1793+
plane = td.Box(center=(0, 0, 0), size=plane_size)
1794+
1795+
mode_spec = td.ModeSpec(num_modes=1)
1796+
ms = ModeSolver(
1797+
simulation=simulation,
1798+
plane=plane,
1799+
mode_spec=mode_spec,
1800+
freqs=[td.C_0 / 1.0],
1801+
)
1802+
1803+
# Access the cached property to trigger the warning
1804+
with AssertLogLevel(
1805+
"WARNING" if expected_warnings > 0 else None, contains_str=warning_str
1806+
) as ctx:
1807+
_ = ms._sim_boundary_positions
1808+
assert ctx.num_records == expected_warnings

0 commit comments

Comments
 (0)