diff --git a/tests/test_components/test_boundaries.py b/tests/test_components/test_boundaries.py index 9ca0f7d91..7919204ec 100644 --- a/tests/test_components/test_boundaries.py +++ b/tests/test_components/test_boundaries.py @@ -185,3 +185,274 @@ def test_boundaryspec_classmethods(): assert all( isinstance(boundary, PML) for boundary_dim in boundaries for boundary in boundary_dim ) + + +def test_abc_boundary(): + # check basic instance + _ = td.ABCBoundary() + + # check enforced perm (and conductivity) + _ = td.ABCBoundary(permittivity=2) + _ = td.ABCBoundary(permittivity=2, conductivity=0.1) + + with pytest.raises(pydantic.ValidationError): + _ = td.ABCBoundary(permittivity=0) + + with pytest.raises(pydantic.ValidationError): + _ = td.ABCBoundary(permittivity=2, conductivity=-0.1) + + with pytest.raises(pydantic.ValidationError): + _ = td.ABCBoundary(permittivity=None, conductivity=-0.1) + + # test mode abc + wvl_um = 1 + freq0 = td.C_0 / wvl_um + mode_abc = td.ModeABCBoundary( + plane=td.Box(size=(1, 1, 0)), + mode_spec=td.ModeSpec(num_modes=2), + mode_index=1, + frequency=freq0, + ) + + with pytest.raises(pydantic.ValidationError): + _ = td.ModeABCBoundary( + plane=td.Box(size=(1, 1, 0)), + mode_spec=td.ModeSpec(num_modes=2), + mode_index=1, + frequency=-1, + ) + + with pytest.raises(pydantic.ValidationError): + _ = td.ModeABCBoundary( + plane=td.Box(size=(1, 1, 0)), + mode_spec=td.ModeSpec(num_modes=2), + mode_index=-1, + frequency=freq0, + ) + + with pytest.raises(pydantic.ValidationError): + _ = td.ModeABCBoundary( + plane=td.Box(size=(1, 1, 1)), + mode_spec=td.ModeSpec(num_modes=2), + mode_index=0, + frequency=freq0, + ) + + # from mode source + mode_source = td.ModeSource( + size=(1, 1, 0), + source_time=td.GaussianPulse(freq0=freq0, fwidth=0.2 * freq0), + mode_spec=td.ModeSpec(num_modes=2), + mode_index=1, + direction="+", + ) + mode_abc_from_source = td.ModeABCBoundary.from_source(mode_source) + assert mode_abc == mode_abc_from_source + + # from mode monitor + mode_monitor = td.ModeMonitor( + size=(1, 1, 0), mode_spec=td.ModeSpec(num_modes=2), freqs=[freq0], name="mnt" + ) + mode_abc_from_monitor = td.ModeABCBoundary.from_monitor( + mode_monitor, mode_index=1, frequency=freq0 + ) + assert mode_abc == mode_abc_from_monitor + + # in Boundary + _ = td.Boundary( + minus=td.ABCBoundary(permittivity=3), plus=td.ModeABCBoundary(plane=td.Box(size=(1, 1, 0))) + ) + _ = td.Boundary.abc(permittivity=3, conductivity=1e-5) + abc_boundary = td.Boundary.mode_abc( + plane=td.Box(size=(1, 1, 0)), + mode_spec=td.ModeSpec(num_modes=2), + mode_index=1, + frequency=freq0, + ) + abc_boundary_from_source = td.Boundary.mode_abc_from_source(mode_source) + abc_boundary_from_monitor = td.Boundary.mode_abc_from_monitor( + mode_monitor, mode_index=1, frequency=freq0 + ) + assert abc_boundary == abc_boundary_from_source + assert abc_boundary == abc_boundary_from_monitor + + with pytest.raises(pydantic.ValidationError): + _ = td.Boundary(minus=td.Periodic(), plus=td.ABCBoundary()) + + with pytest.raises(pydantic.ValidationError): + _ = td.Boundary(minus=td.Periodic(), plus=td.ModeABCBoundary(plane=td.Box(size=(1, 1, 0)))) + + # in Simulation + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary()), + ) + + # validate ABC medium is not anisotorpic + with pytest.raises(pydantic.ValidationError): + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + medium=td.AnisotropicMedium(xx=td.Medium(), yy=td.Medium(), zz=td.Medium()), + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary()), + ) + + # validate homogeneous medium when permittivity=None, that is, automatic detection + box_crossing_boundary = td.Structure( + geometry=td.Box(size=(0.3, 0.2, td.inf)), + medium=td.Medium(permittivity=2), + ) + # ok if ABC boundary is not crossed + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + structures=[box_crossing_boundary], + run_time=1e-20, + boundary_spec=td.BoundarySpec( + x=td.Boundary.abc(), + y=td.Boundary.abc(), + z=td.Boundary.pml(), + ), + ) + # or if we override manually + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + structures=[box_crossing_boundary], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary(permittivity=2)), + ) + # not ok if ABC boudary is crossed + with pytest.raises(pydantic.ValidationError): + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + structures=[box_crossing_boundary], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary()), + ) + # edge case when a structure exactly coincides with simulation domain + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + structures=[box_crossing_boundary.updated_copy(geometry=td.Box(size=(1, 1, 1)))], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary()), + ) + + # warning for possibly non-uniform custom medium + with AssertLogLevel( + "WARNING", contains_str="Nonuniform custom medium detected on an 'ABCBoundary'" + ): + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + medium=td.CustomMedium( + permittivity=td.SpatialDataArray([[[2, 3]]], coords=dict(x=[0], y=[0], z=[0, 1])) + ), + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary()), + ) + + # disallow ABC boundaries in zero dimensions + with pytest.raises(pydantic.ValidationError): + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 0], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + structures=[box_crossing_boundary], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ABCBoundary()), + ) + + # need to define frequence for ModeABCBoundary + # manually + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides( + td.ModeABCBoundary(plane=td.Box(size=(1, 1, 0)), frequency=freq0) + ), + ) + # or at least one source + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[mode_source], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides(td.ModeABCBoundary(plane=td.Box(size=(1, 1, 0)))), + ) + # multiple sources with different central freqs is still ok, but show warning + with AssertLogLevel( + "WARNING", contains_str="The central frequency of the first source will be used" + ): + _ = td.Simulation( + center=[0, 0, 0], + size=[1, 1, 1], + grid_spec=td.GridSpec.auto( + min_steps_per_wvl=10, + wavelength=wvl_um, + ), + sources=[ + mode_source, + mode_source.updated_copy( + source_time=td.GaussianPulse(freq0=2 * freq0, fwidth=0.2 * freq0) + ), + ], + run_time=1e-20, + boundary_spec=td.BoundarySpec.all_sides( + td.ModeABCBoundary(plane=td.Box(size=(1, 1, 0))) + ), + ) diff --git a/tidy3d/__init__.py b/tidy3d/__init__.py index e3948a8d5..ea5786992 100644 --- a/tidy3d/__init__.py +++ b/tidy3d/__init__.py @@ -96,6 +96,7 @@ # boundary from .components.boundary import ( PML, + ABCBoundary, Absorber, AbsorberParams, BlochBoundary, @@ -106,6 +107,7 @@ DefaultAbsorberParameters, DefaultPMLParameters, DefaultStablePMLParameters, + ModeABCBoundary, PECBoundary, Periodic, PMCBoundary, @@ -534,6 +536,8 @@ def set_logging_level(level: str) -> None: "Periodic", "PECBoundary", "PMCBoundary", + "ABCBoundary", + "ModeABCBoundary", "PML", "StablePML", "Absorber", diff --git a/tidy3d/components/boundary.py b/tidy3d/components/boundary.py index 2e0acc62f..baa0b7f21 100644 --- a/tidy3d/components/boundary.py +++ b/tidy3d/components/boundary.py @@ -3,16 +3,19 @@ from __future__ import annotations from abc import ABC -from typing import List, Tuple, Union +from typing import List, Optional, Tuple, Union import numpy as np import pydantic.v1 as pd from ..constants import EPSILON_0, MU_0, PML_SIGMA -from ..exceptions import DataError, SetupError +from ..exceptions import DataError, SetupError, ValidationError from ..log import log -from .base import Tidy3dBaseModel, cached_property +from .base import Tidy3dBaseModel, cached_property, skip_if_fields_missing +from .geometry.base import Box from .medium import Medium +from .mode_spec import ModeSpec +from .monitor import ModeMonitor, ModeSolverMonitor from .source.field import TFSF, GaussianBeam, ModeSource, PlaneWave from .types import TYPE_TAG_STR, Axis, Complex @@ -48,6 +51,151 @@ class PMCBoundary(BoundaryEdge): """Perfect magnetic conductor boundary condition class.""" +# ABC keyword +class AbstractABCBoundary(BoundaryEdge, ABC): + """One-way wave equation absorbing boundary conditions abstract base class.""" + + +class ABCBoundary(AbstractABCBoundary): + """One-way wave equation absorbing boundary conditions. + See, for example, John B. Schneider, Understanding the Finite-Difference Time-Domain Method, Chapter 6. + """ + + permittivity: Optional[pd.PositiveFloat] = pd.Field( + None, + title="Effective Permittivity", + description="Effective permittivity for determining propagation constant. " + "If ``None``, this value will be automatically inferred from the medium at " + "the domain boundary and the central frequency of the source.", + ) + + conductivity: Optional[pd.NonNegativeFloat] = pd.Field( + None, + title="Effective Conductivity", + description="Effective conductivity for determining propagation constant." + "If ``None``, this value will be automatically inferred from the medium at " + "the domain boundary and the central frequency of the source.", + ) + + @pd.validator("conductivity", always=True) + @skip_if_fields_missing(["permittivity"]) + def _conductivity_only_with_float_permittivity(cls, val, values): + """Validate that conductivity can be provided only with float permittivity.""" + perm = values["permittivity"] + if val is not None and perm is None: + raise ValidationError( + "Field 'conductivity' in 'ABCBoundary' can only be provided " + "simultaneously with 'permittivity'." + ) + return val + + +class ModeABCBoundary(AbstractABCBoundary): + """One-way wave equation absorbing boundary conditions for absorbing a waveguide mode.""" + + mode_spec: ModeSpec = pd.Field( + ModeSpec(), + title="Mode Specification", + description="Parameters that determine the modes computed by the mode solver.", + ) + + mode_index: pd.NonNegativeInt = pd.Field( + 0, + title="Mode Index", + description="Index into the collection of modes returned by mode solver. " + "The absorbing boundary conditions are configured to absorb the specified mode. " + "If larger than ``mode_spec.num_modes``, " + "``num_modes`` in the solver will be set to ``mode_index + 1``.", + ) + + frequency: Optional[pd.PositiveFloat] = pd.Field( + None, + title="Frequency", + description="Frequency at which the absorbed mode is evaluated. If ``None``, then the central frequency of the source is used.", + ) + + plane: Box = pd.Field( + ..., + title="Plane", + description="Cross-sectional plane in which the absorbed mode will be computed.", + ) + + @pd.validator("plane", always=True) + def is_plane(cls, val): + """Raise validation error if not planar.""" + if val.size.count(0.0) != 1: + raise ValidationError( + f"'ModeABCBoundary' target plane must be planar, given size={val.size}" + ) + return val + + @classmethod + def from_source(cls, source: ModeSource) -> ModeABCBoundary: + """Instantiate from a ``ModeSource``. + + Parameters + ---------- + source : :class:`ModeSource` + Mode source. + + Returns + ------- + :class:`ModeABCBoundary` + Boundary conditions for absorbing the desired mode. + + Example + ------- + >>> from tidy3d import GaussianPulse, ModeSource, inf + >>> pulse = GaussianPulse(freq0=200e12, fwidth=20e12) + >>> source = ModeSource(size=(1, 1, 0), source_time=pulse, direction='+') + >>> abc_boundary = ModeABCBoundary.from_source(source=source) + """ + + return cls( + plane=source.bounding_box, + mode_spec=source.mode_spec, + mode_index=source.mode_index, + frequency=source.source_time.freq0, + ) + + @classmethod + def from_monitor( + cls, + monitor: Union[ModeMonitor, ModeSolverMonitor], + mode_index: pd.NonNengativeInt = 0, + frequency: Optional[pd.PositiveFloat] = None, + ) -> ModeABCBoundary: + """Instantiate from a ``ModeMonitor`` or ``ModeSolverMonitor``. + + Parameters + ---------- + monitor : Union[:class:`ModeMonitor`, :class:`ModeSolverMonitor`] + Mode monitor. + mode_index : pd.NonNengativeInt = 0 + Mode index. + frequency : Optional[pd.PositiveFloat] = None + Frequency for estimating propagation index of absorbed mode. + + Returns + ------- + :class:`ModeABCBoundary` + Boundary conditions for absorbing the desired mode. + + Example + ------- + >>> from tidy3d import ModeMonitor + >>> mnt = ModeMonitor(size=(1, 1, 0), freqs=[1e10], name="mnt") + >>> abc_boundary = ModeABCBoundary.from_monitor(monitor=mnt, mode_index=0) + """ + + return cls( + plane=monitor.bounding_box, + mode_spec=monitor.mode_spec, + mode_index=mode_index, + frequency=frequency, + ) + + # """ Bloch boundary """ # sources from which Bloch boundary conditions can be defined @@ -493,7 +641,15 @@ class Absorber(AbsorberSpec): # types of boundaries that can be used in Simulation BoundaryEdgeType = Union[ - Periodic, PECBoundary, PMCBoundary, PML, StablePML, Absorber, BlochBoundary + Periodic, + PECBoundary, + PMCBoundary, + PML, + StablePML, + Absorber, + BlochBoundary, + ABCBoundary, + ModeABCBoundary, ] @@ -557,9 +713,9 @@ def periodic_with_pml(cls, values): plus = values.get("plus") minus = values.get("minus") num_pbc = isinstance(plus, Periodic) + isinstance(minus, Periodic) - num_pml = isinstance(plus, (PML, StablePML, Absorber)) + isinstance( - minus, (PML, StablePML, Absorber) - ) + num_pml = isinstance( + plus, (PML, StablePML, Absorber, ABCBoundary, ModeABCBoundary) + ) + isinstance(minus, (PML, StablePML, Absorber, ABCBoundary, ModeABCBoundary)) if num_pbc == 1 and num_pml == 1: raise SetupError("Cannot have both PML and PBC along the same dimension.") return values @@ -675,6 +831,117 @@ def pmc(cls): minus = PMCBoundary() return cls(plus=plus, minus=minus) + @classmethod + def abc( + cls, + permittivity: Optional[pd.PositiveFloat] = None, + conductivity: Optional[pd.NonNegativeFloat] = None, + ): + """ABC boundary specification on both sides along a dimension. + + Example + ------- + >>> abc = Boundary.abc() + """ + plus = ABCBoundary( + permittivity=permittivity, + conductivity=conductivity, + ) + minus = ABCBoundary( + permittivity=permittivity, + conductivity=conductivity, + ) + return cls(plus=plus, minus=minus) + + @classmethod + def mode_abc( + cls, + plane: Box, + mode_spec: ModeSpec = ModeSpec(), + mode_index: pd.NonNegativeInt = 0, + frequency: Optional[pd.PositiveFloat] = None, + ): + """One-way wave equation mode ABC boundary specification on both sides along a dimension. + + Parameters + ---------- + plane: Box + Cross-sectional plane in which the absorbed mode will be computed. + mode_spec: ModeSpec = ModeSpec() + Parameters that determine the modes computed by the mode solver. + mode_index : pd.NonNengativeInt = 0 + Mode index. + frequency : Optional[pd.PositiveFloat] = None + Frequency for estimating propagation index of absorbed mode. + + Example + ------- + >>> from tidy3d import Box + >>> abc = Boundary.mode_abc(plane=Box(size=(1, 1, 0))) + """ + + plus = ModeABCBoundary( + plane=plane, + mode_spec=mode_spec, + mode_index=mode_index, + frequency=frequency, + ) + minus = ModeABCBoundary( + plane=plane, + mode_spec=mode_spec, + mode_index=mode_index, + frequency=frequency, + ) + + return cls(plus=plus, minus=minus) + + @classmethod + def mode_abc_from_source(cls, source: ModeSource): + """One-way wave equation mode ABC boundary specification on both sides along a dimension constructed from a mode source. + + Parameters + ---------- + source : :class:`ModeSource` + Mode source. + + Example + ------- + >>> from tidy3d import GaussianPulse, ModeSource, inf + >>> pulse = GaussianPulse(freq0=200e12, fwidth=20e12) + >>> source = ModeSource(size=(1, 1, 0), source_time=pulse, direction='+') + >>> abc = Boundary.mode_abc_from_source(source=source) + """ + plus = ModeABCBoundary.from_source(source=source) + minus = ModeABCBoundary.from_source(source=source) + return cls(plus=plus, minus=minus) + + @classmethod + def mode_abc_from_monitor( + cls, + monitor: Union[ModeMonitor, ModeSolverMonitor], + mode_index: pd.NonNengativeInt = 0, + frequency: Optional[pd.PositiveFloat] = None, + ): + """One-way wave equation mode ABC boundary specification on both sides along a dimension constructed from a mode monitor. + + Example + ------- + >>> from tidy3d import ModeMonitor + >>> mnt = ModeMonitor(size=(1, 1, 0), freqs=[1e10], name="mnt") + >>> abc = Boundary.mode_abc_from_monitor(monitor=mnt) + """ + plus = ModeABCBoundary.from_monitor( + monitor=monitor, + mode_index=mode_index, + frequency=frequency, + ) + minus = ModeABCBoundary.from_monitor( + monitor=monitor, + mode_index=mode_index, + frequency=frequency, + ) + return cls(plus=plus, minus=minus) + @classmethod def pml(cls, num_layers: pd.NonNegativeInt = 12, parameters: PMLParams = DefaultPMLParameters): """PML boundary specification on both sides along a dimension. diff --git a/tidy3d/components/simulation.py b/tidy3d/components/simulation.py index ce307acf2..7a775b5fe 100644 --- a/tidy3d/components/simulation.py +++ b/tidy3d/components/simulation.py @@ -27,11 +27,13 @@ from .base_sim.simulation import AbstractSimulation from .boundary import ( PML, + ABCBoundary, Absorber, AbsorberSpec, BlochBoundary, Boundary, BoundarySpec, + ModeABCBoundary, PECBoundary, Periodic, PMCBoundary, @@ -188,7 +190,10 @@ def boundaries_for_zero_dims(cls, val, values): 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_absorbing_bdries = sum( + isinstance(bnd, (AbsorberSpec, ABCBoundary, ModeABCBoundary)) + for bnd in boundary + ) num_bloch_bdries = sum(isinstance(bnd, BlochBoundary) for bnd in boundary) if num_absorbing_bdries > 0: @@ -2916,6 +2921,29 @@ def check_fixed_angle_components(cls, values): return values + @pydantic.validator("boundary_spec", always=True) + @skip_if_fields_missing(["sources"]) + def _validate_frequency_mode_abc(cls, val, values): + """Warn if ModeABCBoundary expects a frequency from a source, but there are multiple sources with different central frequencies.""" + boundaries = val.to_list + need_wavelength = any( + isinstance(edge, ModeABCBoundary) and edge.frequency is None + for edge in np.ravel(boundaries) + ) + + if need_wavelength: + sources = values.get("sources") + + freq0s = [source.source_time.freq0 for source in sources] + if not all(math.isclose(freq0, freq0s[0]) for freq0 in freq0s): + log.warning( + "At least one 'ModeABCBoundary' does not specify frequency at which the absorbed mode must be evaluated. " + "The central frequency of the first source will be used.", + capture=False, + ) + + return val + @pydantic.validator("sources", always=True) def _validate_num_sources(cls, val): """Error if too many sources present.""" @@ -3206,6 +3234,96 @@ def _projection_monitors_homogeneous(cls, val, values): return val + @classmethod + def _get_mediums_on_abc( + cls, boundary_spec, medium, center, size, structures + ) -> Tuple[ + List[MediumType3D], + List[MediumType3D], + List[MediumType3D], + List[MediumType3D], + List[MediumType3D], + List[MediumType3D], + ]: + """For each ABC boundary that needs an automatic medium detection (permittivity=None) + determine mediums it crosses. + """ + + # list of structures including background as a Box() + structure_bg = Structure( + geometry=Box( + size=size, + center=center, + ), + medium=medium, + ) + + surfaces = Box.surfaces( + center=structure_bg.geometry.center, size=structure_bg.geometry.size + ) + + total_structures = [structure_bg] + list(structures) + + mediums = [] + for boundary, surface in zip(np.ravel(boundary_spec.to_list), surfaces): + if isinstance(boundary, ABCBoundary) and boundary.permittivity is None: + mediums.append(Scene.intersecting_media(surface, total_structures)) + else: + mediums.append(None) + + return mediums + + @pydantic.validator("boundary_spec", always=True) + @skip_if_fields_missing(["medium", "center", "size", "structures"]) + def _abc_boundaries_homogeneous(cls, val, values): + """Error if abc boundaries intersect multiple mediums or anisotropic mediums.""" + + if val is None: + return val + + # expand zero dimensions to make the treatment uniform + size = [fp_eps if s == 0 else s for s in values.get("size")] + + mediums_all_sides = cls._get_mediums_on_abc( + boundary_spec=val, + medium=values.get("medium"), + size=size, + center=values.get("center"), + structures=values.get("structures") or [], + ) + + with log as consolidated_logger: + for mediums in mediums_all_sides: + if mediums is not None: + # make sure there is no more than one medium in the returned list + if len(mediums) > 1: + raise SetupError( + f"{len(mediums)} different mediums detected on an 'ABCBoundary'. Boundary must be homogeneous." + "Alternatively, effective permeability and conductivity can be directly provided as " + "parameters for an 'ABCBoundary', in which case this medium check is skipped." + ) + # 0 medium, something is wrong + if len(mediums) < 1: + raise SetupError( + "No medium detected on plane containing 'ABCBoundary', " + "indicating an unexpected error. Please create a github issue so " + "that the problem can be investigated." + ) + # 1 medium, check if the medium is spatially uniform + if not list(mediums)[0].is_spatially_uniform: + consolidated_logger.warning( + "Nonuniform custom medium detected on an 'ABCBoundary'. " + "Boundary must be homogeneous. Make sure custom medium is uniform on the boundary.", + ) + + if isinstance(list(mediums)[0], (AnisotropicMedium, FullyAnisotropicMedium)): + raise SetupError( + "An anisotropic medium is detected on an 'ABCBoundary. " + "Boundary medium must be homogeneous and isotropic." + ) + + return val + @pydantic.validator("monitors", always=True) def _projection_direction(cls, val, values): """Warn if field projection observation points are behind surface projection monitors."""