diff --git a/tests/test_components/test_simulation.py b/tests/test_components/test_simulation.py index 543bb320a..1d8f6700d 100644 --- a/tests/test_components/test_simulation.py +++ b/tests/test_components/test_simulation.py @@ -743,7 +743,7 @@ def test_nyquist(): # fake a scenario where the fmax of the simulation is negative? class MockSim: frequency_range = (-2, -1) - monitors = () + _internal_monitors = () _cached_properties = {} m = MockSim() diff --git a/tidy3d/components/base_sim/simulation.py b/tidy3d/components/base_sim/simulation.py index b6c782f57..13b787ee9 100644 --- a/tidy3d/components/base_sim/simulation.py +++ b/tidy3d/components/base_sim/simulation.py @@ -188,7 +188,7 @@ def scene(self) -> Scene: def get_monitor_by_name(self, name: str) -> AbstractMonitor: """Return monitor named 'name'.""" - for monitor in self.monitors: + for monitor in self._internal_monitors: if monitor.name == name: return monitor raise Tidy3dKeyError(f"No monitor named '{name}'") @@ -361,7 +361,7 @@ def plot_monitors( The supplied or created matplotlib axes. """ bounds = self.bounds - for monitor in self.monitors: + for monitor in self._internal_monitors: ax = monitor.plot(x=x, y=y, z=z, alpha=alpha, ax=ax, sim_bounds=bounds) ax = Scene._set_plot_bounds( bounds=self.simulation_bounds, ax=ax, x=x, y=y, z=z, hlim=hlim, vlim=vlim @@ -671,3 +671,9 @@ def from_scene(cls, scene: Scene, **kwargs) -> AbstractSimulation: medium=scene.medium, **kwargs, ) + + @cached_property + def _internal_monitors(self) -> Tuple[None, ...]: + """Generate a tuple of all monitors wherein any monitors that are generated for + internal use are added to the list of user-supplied monitors.""" + return self.monitors diff --git a/tidy3d/components/lumped_element.py b/tidy3d/components/lumped_element.py index 0d3bd58a2..a5ebc7c57 100644 --- a/tidy3d/components/lumped_element.py +++ b/tidy3d/components/lumped_element.py @@ -20,7 +20,7 @@ from ..components.monitor import FieldMonitor from ..components.structure import MeshOverrideStructure, Structure from ..components.validators import assert_plane, validate_name_str -from ..constants import EPSILON_0, FARAD, HENRY, MICROMETER, OHM +from ..constants import EPSILON_0, FARAD, HENRY, HERTZ, MICROMETER, OHM from ..exceptions import ValidationError from .base import Tidy3dBaseModel, cached_property, skip_if_fields_missing from .geometry.base import Box, ClipOperation, Geometry @@ -96,6 +96,14 @@ class RectangularLumpedElement(LumpedElement, Box): "boundary along their ``normal_axis``, regardless of this option.", ) + monitor_freqs: Optional[FreqArray] = pd.Field( + None, + title="Monitor Frequencies", + description="When set, monitors are added to the simulation that enable the calculation of the" + "voltage and current at the specified frequencies.", + units=HERTZ, + ) + @cached_property def normal_axis(self): """Normal axis of the lumped element, which is the axis where the element has zero size.""" @@ -187,7 +195,7 @@ def to_mesh_overrides(self) -> list[MeshOverrideStructure]: def to_geometry(self, grid: Grid = None) -> Box: """Converts the :class:`RectangularLumpedElement` object to a :class:`.Box`.""" box = Box(size=self.size, center=self.center) - if grid and self.snap_perimeter_to_grid: + if grid is not None and self.snap_perimeter_to_grid: return snap_box_to_grid(grid, box, self._snapping_spec) return box @@ -216,17 +224,17 @@ def _admittance_transfer_function_scaling(self, box: Box = None) -> float: # The final scaling along the normal axis is applied when the resulting 2D medium is averaged with the background media. return size_voltage / size_lateral - def to_monitor(self, freqs: FreqArray) -> FieldMonitor: + def to_monitor(self, freqs: FreqArray, grid: Grid = None) -> FieldMonitor: """Creates a field monitor that can be added to the simulation, which records field data that can be used to later compute voltage and current flowing through the element. """ + box = self.to_geometry(grid) - center = list(self.center) # Size of monitor needs to be nonzero along the normal axis so that the magnetic field on - # both sides of the sheet will be available - mon_size = list(self.size) + # both sides of the sheet will be available. If snapping is used, + mon_size = list(box.size) mon_size[self.normal_axis] = 2 * ( - increment_float(center[self.normal_axis], 1.0) - center[self.normal_axis] + increment_float(box.center[self.normal_axis], 1.0) - box.center[self.normal_axis] ) e_component = "xyz"[self.voltage_axis] @@ -234,7 +242,7 @@ def to_monitor(self, freqs: FreqArray) -> FieldMonitor: h2_component = "xyz"[self.normal_axis] # Create a voltage monitor return FieldMonitor( - center=center, + center=box.center, size=mon_size, freqs=freqs, fields=[f"E{e_component}", f"H{h1_component}", f"H{h2_component}"], diff --git a/tidy3d/components/simulation.py b/tidy3d/components/simulation.py index 0e4c509d5..bf8e36d59 100644 --- a/tidy3d/components/simulation.py +++ b/tidy3d/components/simulation.py @@ -85,7 +85,10 @@ from .structure import MeshOverrideStructure, Structure from .subpixel_spec import SubpixelSpec from .types import TYPE_TAG_STR, Ax, Axis, FreqBound, InterpMethod, Literal, Symmetry, annotate_type -from .validators import assert_objects_in_sim_bounds, validate_mode_objects_symmetry +from .validators import ( + assert_objects_in_sim_bounds, + validate_mode_objects_symmetry, +) from .viz import ( PlotParams, add_ax_if_none, @@ -1286,7 +1289,7 @@ def snap_to_grid(geom: Geometry, axis: Axis) -> Geometry: snapped_center = snap_coordinate_to_grid(self.grid, center, axis) return geom._update_from_bounds(bounds=(snapped_center, snapped_center), axis=axis) - # Convert lumped elements into structures + # Convert lumped elements into structures and add monitors if desired lumped_structures = [] for lumped_element in self.lumped_elements: lumped_structures.append(lumped_element.to_structure(self.grid)) @@ -1344,6 +1347,27 @@ def volumetric_structures(self) -> Tuple[Structure]: volumetric equivalents.""" return self._volumetric_structures_grid(self.grid) + def _internal_monitors_grid(self, grid: Grid) -> Tuple[Monitor]: + """Generate a tuple of all monitors wherein any monitors that are generated for + internal use are added to the list of user-supplied monitors.""" + # Add monitors around lumped elements if desired + internal_monitors = list(self.monitors) + for lumped_element in self.lumped_elements: + if lumped_element.monitor_freqs is not None: + internal_monitors.append( + lumped_element.to_monitor(freqs=lumped_element.monitor_freqs, grid=grid) + ) + # Would be nice to be able to run these validators on the generated monitors. + # _unique_monitor_names = assert_unique_names("monitors") + # _monitors_in_bounds = assert_objects_in_sim_bounds("monitors", strict_inequality=True) + return tuple(internal_monitors) + + @cached_property + def _internal_monitors(self) -> Tuple[Monitor]: + """Generate a tuple of all monitors wherein any monitors that are generated for + internal use are added to the list of user-supplied monitors.""" + return self._internal_monitors_grid(self.grid) + def suggest_mesh_overrides(self, **kwargs) -> List[MeshOverrideStructure]: """Generate a :class:.`MeshOverrideStructure` `List` which is automatically generated from structures in the simulation. @@ -3395,7 +3419,7 @@ def _validate_monitor_size(self) -> None: # Some monitors store much less data than what is needed internally. Make sure that the # internal storage also does not exceed the limit. - for monitor in self.monitors: + for monitor in self._internal_monitors: num_cells = self._monitor_num_cells(monitor) # intermediate storage needed, in GB solver_data = monitor._storage_size_solver(num_cells=num_cells, tmesh=self.tmesh) / 1e9 @@ -3439,7 +3463,7 @@ def warn_mode_size(monitor: AbstractModeMonitor, msg_header: str, custom_loc: Li warn_mode_size(monitor=monitor, msg_header=msg_header, custom_loc=custom_loc) with log as consolidated_logger: - for mnt_ind, monitor in enumerate(self.monitors): + for mnt_ind, monitor in enumerate(self._internal_monitors): if isinstance(monitor, AbstractModeMonitor): msg_header = f"Mode monitor '{monitor.name}' " custom_loc = ["monitors", mnt_ind] @@ -3470,14 +3494,14 @@ def check_num_cells( msg_header = f"Mode source '{source.name}' " check_num_cells(source, source.injection_axis, msg_header) - for monitor in self.monitors: + for monitor in self._internal_monitors: if isinstance(monitor, ModeMonitor): msg_header = f"Mode monitor '{monitor.name}' " check_num_cells(monitor, monitor.normal_axis, msg_header) def _validate_time_monitors_num_steps(self) -> None: """Raise an error if non-0D time monitors have too many time steps.""" - for monitor in self.monitors: + for monitor in self._internal_monitors: if not isinstance(monitor, FieldTimeMonitor) or len(monitor.zero_dims) == 3: continue num_time_steps = monitor.num_steps(self.tmesh) @@ -3499,7 +3523,7 @@ def static_structures(self) -> list[Structure]: def monitors_data_size(self) -> Dict[str, float]: """Dictionary mapping monitor names to their estimated storage size in bytes.""" data_size = {} - for monitor in self.monitors: + for monitor in self._internal_monitors: num_cells = self._monitor_num_cells(monitor) storage_size = float(monitor.storage_size(num_cells=num_cells, tmesh=self.tmesh)) data_size[monitor.name] = storage_size @@ -3601,7 +3625,7 @@ def _warn_time_monitors_outside_run_time(self) -> None: dynamically computed run_time e.g. through a ``_run_time`` cached property. """ with log as consolidated_logger: - for monitor in self.monitors: + for monitor in self._internal_monitors: if isinstance(monitor, TimeMonitor) and monitor.start > self._run_time: consolidated_logger.warning( f"Monitor {monitor.name} has a start time {monitor.start:1.2e}s exceeding" @@ -3617,7 +3641,7 @@ def with_adjoint_monitors(self, sim_fields_keys: list) -> Simulation: structure_indices = {index for (_, index, *_) in sim_fields_keys} mnts_fld, mnts_eps = self.make_adjoint_monitors(structure_indices=structure_indices) - monitors = list(self.monitors) + list(mnts_fld) + list(mnts_eps) + monitors = list(self._internal_monitors) + list(mnts_fld) + list(mnts_eps) return self.copy(update=dict(monitors=monitors)) def make_adjoint_monitors(self, structure_indices: set[int]) -> tuple[list, list]: @@ -3644,7 +3668,7 @@ def freqs_adjoint(self) -> list[float]: """Unique list of all frequencies. For now should be only one.""" freqs = set() - for mnt in self.monitors: + for mnt in self._internal_monitors: if isinstance(mnt, FreqMonitor): freqs.update(mnt.freqs) freqs = sorted(freqs) @@ -4372,7 +4396,7 @@ def nyquist_step(self) -> int: freq_monitor_max = max( ( monitor.frequency_range[1] - for monitor in self.monitors + for monitor in self._internal_monitors if isinstance(monitor, FreqMonitor) and not isinstance(monitor, PermittivityMonitor) ), default=0.0,