Skip to content

Add support for 2D EME simulations #2410

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved error message and handling when attempting to load a non-existent task ID.
- `ClipOperation` now fails validation if traced fields are detected.
- Warn if more than 20 frequencies are used in EME, as this may lead to slower or more expensive simulations.
- EME now supports 2D simulations.

## [2.8.3] - 2025-04-24

Expand Down
43 changes: 19 additions & 24 deletions tests/test_components/test_eme.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def test_eme_grid():
assert mode_plane.size[dim] == 0
else:
assert mode_plane.center[dim] == sim_geom.center[dim]
assert mode_plane.size[dim] == sim_geom.size[dim]
assert mode_plane.size[dim] == td.inf

# test that boundary planes span sim and lie at cell boundaries
for grid in grids:
Expand Down Expand Up @@ -296,22 +296,6 @@ def test_eme_simulation():
_ = sim2.plot(y=0, ax=AX)
_ = sim2.plot(z=0, ax=AX)

# must be 3D
with pytest.raises(pd.ValidationError):
_ = td.EMESimulation(
size=(0, 2, 2),
freqs=[td.C_0],
axis=2,
eme_grid_spec=td.EMEUniformGrid(num_cells=2, mode_spec=td.EMEModeSpec()),
)
with pytest.raises(pd.ValidationError):
_ = td.EMESimulation(
size=(2, 2, 0),
freqs=[td.C_0],
axis=2,
eme_grid_spec=td.EMEUniformGrid(num_cells=2, mode_spec=td.EMEModeSpec()),
)

# need at least one freq
with pytest.raises(pd.ValidationError):
_ = sim.updated_copy(freqs=[])
Expand Down Expand Up @@ -422,10 +406,6 @@ def test_eme_simulation():
with pytest.raises(pd.ValidationError):
_ = sim.updated_copy(monitors=[monitor])

# test boundary and source validation
with pytest.raises(SetupError):
_ = sim.updated_copy(boundary_spec=td.BoundarySpec.all_sides(td.Periodic()))

# test max sim size and freqs
sim_bad = sim.updated_copy(size=(1000, 1000, 1000))
with pytest.raises(SetupError):
Expand Down Expand Up @@ -1244,10 +1224,10 @@ def test_eme_sim_subsection():
subsection = eme_sim.subsection(region=region, eme_grid_spec="identical")
assert subsection.size[2] == 1

# 2d subsection errors
# 2d subsection
region = td.Box(size=(2, 2, 0))
with pytest.raises(pd.ValidationError):
subsection = eme_sim.subsection(region=region)
subsection = eme_sim.subsection(region=region)
assert subsection.size[2] == 0


def test_eme_periodicity():
Expand Down Expand Up @@ -1365,3 +1345,18 @@ def test_eme_grid_from_structures():
names=[None, "wg", None],
num_reps=[1, 2, 1],
)


def test_eme_sim_2d():
freq0 = td.C_0 / 1.55
sim_size = (3, 0, 3)
eme_grid_spec = td.EMEUniformGrid(num_cells=5, mode_spec=td.EMEModeSpec())
monitor = td.EMEFieldMonitor(size=(td.inf, td.inf, td.inf), name="field")
eme_sim = td.EMESimulation(
size=sim_size,
axis=2,
freqs=[freq0],
eme_grid_spec=eme_grid_spec,
monitors=[monitor],
port_offsets=(0.5, 0),
)
8 changes: 6 additions & 2 deletions tests/test_components/test_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,16 @@ def get_mode_sim():
permittivity_monitor = td.PermittivityMonitor(
size=(1, 1, 0), center=(0, 0, 0), name="eps", freqs=FS
)
boundary_spec = td.BoundarySpec(
x=td.Boundary.pml(), y=td.Boundary.periodic(), z=td.Boundary.pml()
)
sim = td.ModeSimulation(
size=SIZE_2D,
freqs=FS,
mode_spec=mode_spec,
grid_spec=td.GridSpec.auto(wavelength=td.C_0 / FS[0]),
monitors=[permittivity_monitor],
boundary_spec=boundary_spec,
)
return sim

Expand Down Expand Up @@ -122,12 +126,12 @@ def test_mode_sim():
_ = sim.updated_copy(freqs=FS[0], grid_spec=grid_spec)
# multiple freqs are ok
_ = sim.updated_copy(grid_spec=td.GridSpec.uniform(dl=0.2), freqs=[1e10] + list(sim.freqs))
_ = td.ModeSimulation(
_ = sim.updated_copy(
size=sim.size, freqs=list(sim.freqs) + [1e10], grid_spec=grid_spec, mode_spec=MODE_SPEC
)

# size limit
sim_too_large = sim.updated_copy(size=(2000, 2000, 0), plane=None)
sim_too_large = sim.updated_copy(size=(2000, 0, 2000), plane=None)
with pytest.raises(SetupError):
sim_too_large.validate_pre_upload()

Expand Down
4 changes: 2 additions & 2 deletions tidy3d/components/eme/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import numpy as np
import pydantic.v1 as pd

from ...constants import RADIAN, fp_eps
from ...constants import RADIAN, fp_eps, inf
from ...exceptions import SetupError, ValidationError
from ..base import Tidy3dBaseModel, skip_if_fields_missing
from ..geometry.base import Box
Expand Down Expand Up @@ -762,7 +762,7 @@ def num_cells(self) -> pd.NonNegativeInteger:
@property
def mode_planes(self) -> List[Box]:
"""Planes for mode solving, aligned with cell centers."""
size = list(self.size)
size = [inf, inf, inf]
center = list(self.center)
axis = self.axis
size[axis] = 0
Expand Down
52 changes: 14 additions & 38 deletions tidy3d/components/eme/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@
from ..medium import FullyAnisotropicMedium
from ..monitor import AbstractModeMonitor, ModeSolverMonitor, Monitor, MonitorType
from ..scene import Scene
from ..simulation import AbstractYeeGridSimulation, Simulation
from ..simulation import AbstractYeeGridSimulation, Simulation, validate_boundaries_for_zero_dims
from ..types import Ax, Axis, FreqArray, Symmetry, annotate_type
from ..validators import MIN_FREQUENCY, validate_freqs_min, validate_freqs_not_empty
from ..validators import (
MIN_FREQUENCY,
validate_freqs_min,
validate_freqs_not_empty,
)
from ..viz import add_ax_if_none, equal_aspect
from .grid import EMECompositeGrid, EMEExplicitGrid, EMEGrid, EMEGridSpec, EMEGridSpecType
from .monitor import (
Expand Down Expand Up @@ -187,12 +191,12 @@ class EMESimulation(AbstractYeeGridSimulation):
boundary_spec: BoundarySpec = pd.Field(
BoundarySpec.all_sides(PECBoundary()),
title="Boundaries",
description="Specification of boundary conditions along each dimension. If ``None``, "
"PML boundary conditions are applied on all sides. NOTE: for EME simulations, this "
"is required to be PECBoundary on all sides. To capture radiative effects, "
"move the boundary farther away from the waveguide in the tangential directions, "
"and increase the number of modes. The 'ModeSpec' can also be used to try "
"different boundary conditions.",
description="Specification of boundary conditions along each dimension. "
"By default, PEC boundary conditions are applied on all sides. "
"This field is for consistency with FDTD simulations; however, please note that "
"regardless of the 'boundary_spec', the mode solver terminates the mode plane "
"with PEC boundary. The 'EMEModeSpec' can be used to "
"apply PML layers in the mode solver.",
)

sources: Tuple[None, ...] = pd.Field(
Expand Down Expand Up @@ -256,16 +260,6 @@ class EMESimulation(AbstractYeeGridSimulation):
_freqs_not_empty = validate_freqs_not_empty()
_freqs_lower_bound = validate_freqs_min()

@pd.validator("size", always=True)
def _validate_fully_3d(cls, val):
"""An EME simulation must be fully 3D."""
if val.count(0.0) != 0:
raise ValidationError(
"'EMESimulation' cannot have any component of 'size' equal to "
f"zero, given 'size={val}'."
)
return val

@pd.validator("grid_spec", always=True)
def _validate_auto_grid_wavelength(cls, val, values):
"""Handle the case where grid_spec is auto and wavelength is not provided."""
Expand Down Expand Up @@ -587,7 +581,6 @@ def _post_init_validators(self) -> None:
self._validate_sweep_spec()
self._validate_symmetry()
self._validate_monitor_setup()
self._validate_sources_and_boundary()

def validate_pre_upload(self) -> None:
"""Validate the fully initialized EME simulation is ok for upload to our servers."""
Expand Down Expand Up @@ -795,25 +788,6 @@ def _validate_monitor_setup(self):
"('num_reps != 1' in any 'EMEGridSpec'.)"
)

def _validate_sources_and_boundary(self):
"""Disallow sources and boundary."""
if self.boundary_spec != BoundarySpec.all_sides(PECBoundary()):
raise SetupError(
"In an EME simulation, the 'boundary_spec' must be `PECBoundary` "
"on all sides (the default value). The boundary condition along "
"the propagation axis is always transparent; boundary conditions "
"in the tangential directions are imposed in 'mode_spec' in the "
"EME grid."
)
# commented because sources has type Tuple[None, ...]
# if self.sources != ():
# raise SetupError(
# "EME simulations do not currently support sources. "
# "The simulation performs full bidirectional propagation in the "
# "'port_mode' basis. After running the simulation, use "
# "'smatrix_in_basis' to use another set of modes. "
# )

def _validate_size(self) -> None:
"""Ensures the simulation is within size limits before simulation is uploaded."""

Expand Down Expand Up @@ -1206,3 +1180,5 @@ def _cell_index_pairs(self) -> List[pd.NonNegativeInt]:
else:
pairs = set(self.eme_grid_spec._cell_index_pairs)
return list(pairs)

_boundaries_for_zero_dims = validate_boundaries_for_zero_dims()
4 changes: 3 additions & 1 deletion tidy3d/components/mode/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from ..grid.grid_spec import GridSpec
from ..mode_spec import ModeSpec
from ..monitor import ModeMonitor, ModeSolverMonitor, PermittivityMonitor
from ..simulation import AbstractYeeGridSimulation, Simulation
from ..simulation import AbstractYeeGridSimulation, Simulation, validate_boundaries_for_zero_dims
from ..source.field import ModeSource
from ..types import (
TYPE_TAG_STR,
Expand Down Expand Up @@ -513,3 +513,5 @@ def plot_pml_mode_plane(

def validate_pre_upload(self, source_required: bool = False):
self._mode_solver.validate_pre_upload(source_required=source_required)

_boundaries_for_zero_dims = validate_boundaries_for_zero_dims()
102 changes: 55 additions & 47 deletions tidy3d/components/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,59 @@
FIXED_ANGLE_DT_SAFETY_FACTOR = 0.9


def validate_boundaries_for_zero_dims():
"""Error if absorbing boundaries, bloch boundaries, unmatching pec/pmc, or symmetry is used along a zero dimension."""

@pydantic.validator("boundary_spec", allow_reuse=True, always=True)
@skip_if_fields_missing(["size", "symmetry"])
def boundaries_for_zero_dims(cls, val, values):
"""Error if absorbing boundaries, bloch boundaries, unmatching pec/pmc, or symmetry is used along a zero dimension."""
boundaries = val.to_list
size = values.get("size")
symmetry = values.get("symmetry")
axis_names = "xyz"

for dim, (boundary, symmetry_dim, size_dim) in enumerate(zip(boundaries, symmetry, size)):
if size_dim == 0:
axis = axis_names[dim]
num_absorbing_bdries = sum(isinstance(bnd, AbsorberSpec) for bnd in boundary)
num_bloch_bdries = sum(isinstance(bnd, BlochBoundary) for bnd in boundary)

if num_absorbing_bdries > 0:
raise SetupError(
f"The simulation has zero size along the {axis} axis, so "
"using a PML or absorbing boundary along that axis is incorrect. "
f"Use either 'Periodic' or 'BlochBoundary' along {axis}."
)

if num_bloch_bdries > 0:
raise SetupError(
f"The simulation has zero size along the {axis} axis, "
"using a Bloch boundary along such an axis is not supported because of "
"the Bloch vector definition in units of '2 * pi / (size along dimension)'. Use a small "
"but nonzero size along the dimension instead."
)

if symmetry_dim != 0:
raise SetupError(
f"The simulation has zero size along the {axis} axis, so "
"using symmetry along that axis is incorrect. Use 'PECBoundary' "
"or 'PMCBoundary' to select source polarization if needed and set "
f"Simulation.symmetry to 0 along {axis}."
)

if boundary[0] != boundary[1]:
raise SetupError(
f"The simulation has zero size along the {axis} axis. "
f"The boundary condition for {axis} plus and {axis} "
"minus must be the same."
)

return val

return boundaries_for_zero_dims


class AbstractYeeGridSimulation(AbstractSimulation, ABC):
"""
Abstract class for a simulation involving electromagnetic fields defined on a Yee grid.
Expand Down Expand Up @@ -2803,53 +2856,6 @@ def check_fixed_angle_components(cls, values):

return values

@pydantic.validator("boundary_spec", always=True)
@skip_if_fields_missing(["size", "symmetry"])
def boundaries_for_zero_dims(cls, val, values):
"""Error if absorbing boundaries, bloch boundaries, unmatching pec/pmc, or symmetry is used along a zero dimension."""
boundaries = val.to_list
size = values.get("size")
symmetry = values.get("symmetry")
axis_names = "xyz"

for dim, (boundary, symmetry_dim, size_dim) in enumerate(zip(boundaries, symmetry, size)):
if size_dim == 0:
axis = axis_names[dim]
num_absorbing_bdries = sum(isinstance(bnd, AbsorberSpec) for bnd in boundary)
num_bloch_bdries = sum(isinstance(bnd, BlochBoundary) for bnd in boundary)

if num_absorbing_bdries > 0:
raise SetupError(
f"The simulation has zero size along the {axis} axis, so "
"using a PML or absorbing boundary along that axis is incorrect. "
f"Use either 'Periodic' or 'BlochBoundary' along {axis}."
)

if num_bloch_bdries > 0:
raise SetupError(
f"The simulation has zero size along the {axis} axis, "
"using a Bloch boundary along such an axis is not supported because of "
"the Bloch vector definition in units of '2 * pi / (size along dimension)'. Use a small "
"but nonzero size along the dimension instead."
)

if symmetry_dim != 0:
raise SetupError(
f"The simulation has zero size along the {axis} axis, so "
"using symmetry along that axis is incorrect. Use 'PECBoundary' "
"or 'PMCBoundary' to select source polarization if needed and set "
f"Simulation.symmetry to 0 along {axis}."
)

if boundary[0] != boundary[1]:
raise SetupError(
f"The simulation has zero size along the {axis} axis. "
f"The boundary condition for {axis} plus and {axis} "
"minus must be the same."
)

return val

@pydantic.validator("sources", always=True)
def _validate_num_sources(cls, val):
"""Error if too many sources present."""
Expand Down Expand Up @@ -5201,3 +5207,5 @@ def from_scene(cls, scene: Scene, **kwargs) -> Simulation:
medium=scene.medium,
**kwargs,
)

_boundaries_for_zero_dims = validate_boundaries_for_zero_dims()