From 40f50feb10322848c7e9c2937d327f039861714f Mon Sep 17 00:00:00 2001 From: Casey Wojcik Date: Thu, 24 Apr 2025 09:41:42 +0100 Subject: [PATCH] Add support for 2D EME simulations --- CHANGELOG.md | 1 + tests/test_components/test_eme.py | 43 +++++------ tests/test_components/test_mode.py | 8 ++- tidy3d/components/eme/grid.py | 4 +- tidy3d/components/eme/simulation.py | 52 ++++---------- tidy3d/components/mode/simulation.py | 4 +- tidy3d/components/simulation.py | 102 +++++++++++++++------------ 7 files changed, 100 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a480e9f58..b18499c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/test_components/test_eme.py b/tests/test_components/test_eme.py index 0f91a7247..0e9c61760 100644 --- a/tests/test_components/test_eme.py +++ b/tests/test_components/test_eme.py @@ -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: @@ -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=[]) @@ -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): @@ -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(): @@ -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), + ) diff --git a/tests/test_components/test_mode.py b/tests/test_components/test_mode.py index 95bdf1896..df08f470a 100644 --- a/tests/test_components/test_mode.py +++ b/tests/test_components/test_mode.py @@ -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 @@ -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() diff --git a/tidy3d/components/eme/grid.py b/tidy3d/components/eme/grid.py index bb5d9c2ae..18c0403b3 100644 --- a/tidy3d/components/eme/grid.py +++ b/tidy3d/components/eme/grid.py @@ -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 @@ -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 diff --git a/tidy3d/components/eme/simulation.py b/tidy3d/components/eme/simulation.py index c4a844e3d..9c5c3eb8f 100644 --- a/tidy3d/components/eme/simulation.py +++ b/tidy3d/components/eme/simulation.py @@ -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 ( @@ -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( @@ -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.""" @@ -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.""" @@ -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.""" @@ -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() diff --git a/tidy3d/components/mode/simulation.py b/tidy3d/components/mode/simulation.py index 3f2010297..85bf1f875 100644 --- a/tidy3d/components/mode/simulation.py +++ b/tidy3d/components/mode/simulation.py @@ -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, @@ -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() diff --git a/tidy3d/components/simulation.py b/tidy3d/components/simulation.py index 39a737a6e..bd99b16d6 100644 --- a/tidy3d/components/simulation.py +++ b/tidy3d/components/simulation.py @@ -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. @@ -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.""" @@ -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()