Source code for tidy3d.plugins.smatrix.component_modelers.terminal

"""Tool for generating an S matrix automatically from a Tidy3d simulation and terminal port definitions."""

from __future__ import annotations

from typing import Any, Optional, Union

import numpy as np
import pydantic.v1 as pd

from tidy3d import ClipOperation, GeometryGroup, GridSpec, PolySlab
from tidy3d.components.base import cached_property, skip_if_fields_missing
from tidy3d.components.boundary import BroadbandModeABCSpec
from tidy3d.components.frequency_extrapolation import (
    AbstractLowFrequencySmoothingSpec,
    LowFrequencySmoothingSpec,
)
from tidy3d.components.geometry.base import Box
from tidy3d.components.geometry.bound_ops import bounds_union
from tidy3d.components.geometry.utils import _shift_object
from tidy3d.components.geometry.utils_2d import snap_coordinate_to_grid
from tidy3d.components.index import SimulationMap
from tidy3d.components.microwave.base import MicrowaveBaseModel
from tidy3d.components.monitor import DirectivityMonitor, ModeMonitor
from tidy3d.components.simulation import Simulation
from tidy3d.components.source.time import GaussianPulse
from tidy3d.components.types import Ax, Complex, Coordinate
from tidy3d.components.types.base import annotate_type
from tidy3d.components.viz import add_ax_if_none, equal_aspect
from tidy3d.constants import C_0, MICROMETER, OHM, fp_eps, inf
from tidy3d.exceptions import SetupError, Tidy3dKeyError, ValidationError
from tidy3d.log import log
from tidy3d.plugins.smatrix.component_modelers.base import (
    FWIDTH_FRAC,
    AbstractComponentModeler,
)
from tidy3d.plugins.smatrix.data.data_array import PortDataArray
from tidy3d.plugins.smatrix.ports.base_lumped import AbstractLumpedPort
from tidy3d.plugins.smatrix.ports.coaxial_lumped import CoaxialLumpedPort
from tidy3d.plugins.smatrix.ports.rectangular_lumped import LumpedPort
from tidy3d.plugins.smatrix.ports.types import TerminalPortType
from tidy3d.plugins.smatrix.ports.wave import WavePort
from tidy3d.plugins.smatrix.types import NetworkElement, NetworkIndex, SParamDef

AUTO_RADIATION_MONITOR_NAME = "radiation"
AUTO_RADIATION_MONITOR_BUFFER = 2
AUTO_RADIATION_MONITOR_NUM_POINTS_THETA = 100
AUTO_RADIATION_MONITOR_NUM_POINTS_PHI = 200


[docs] class DirectivityMonitorSpec(MicrowaveBaseModel): """ Specification for automatically generating a :class:`.DirectivityMonitor`. When included in the :attr:`.TerminalComponentModeler.radiation_monitors` tuple, a :class:`.DirectivityMonitor` will be automatically generated with the specified parameters. This allows users to mix manual :class:`.DirectivityMonitor` objects with automatically generated ones, each with customizable parameters. Note ---- The default origin (`custom_origin`) for defining observation points in the automatically generated monitor is set to (0, 0, 0) in the global coordinate system. Example ------- >>> auto_monitor = DirectivityMonitorSpec( ... name="custom_auto", ... buffer=3, ... num_theta_points=50, ... num_phi_points=100 ... ) """ name: Optional[str] = pd.Field( None, title="Monitor Name", description=f"Optional name for the auto-generated monitor. " f"If not provided, defaults to '{AUTO_RADIATION_MONITOR_NAME}_' + index of the monitor in the list of radiation monitors.", ) freqs: Optional[tuple[pd.NonNegativeInt, ...]] = pd.Field( None, title="Frequencies", description="Frequencies to obtain fields at. If not provided, uses all frequencies " "from the :class:`.TerminalComponentModeler`. Must be a subset of modeler frequencies if provided.", ) buffer: pd.NonNegativeInt = pd.Field( AUTO_RADIATION_MONITOR_BUFFER, title="Buffer Distance", description="Number of grid cells to maintain between monitor and PML/domain boundaries. " f"Default: {AUTO_RADIATION_MONITOR_BUFFER} cells.", ) num_theta_points: pd.NonNegativeInt = pd.Field( AUTO_RADIATION_MONITOR_NUM_POINTS_THETA, title="Elevation Angle Points", description="Number of elevation angle (theta) sample points from 0 to Ο€. " f"Default: {AUTO_RADIATION_MONITOR_NUM_POINTS_THETA}.", ) num_phi_points: pd.NonNegativeInt = pd.Field( AUTO_RADIATION_MONITOR_NUM_POINTS_PHI, title="Azimuthal Angle Points", description="Number of azimuthal angle (phi) sample points from -Ο€ to Ο€. " f"Default: {AUTO_RADIATION_MONITOR_NUM_POINTS_PHI}.", ) custom_origin: Optional[Coordinate] = pd.Field( (0, 0, 0), title="Local Origin", description="Local origin used for defining observation points. If ``None``, uses the " "monitor's center.", units=MICROMETER, )
class ModelerLowFrequencySmoothingSpec(AbstractLowFrequencySmoothingSpec): """Specifies the low frequency smoothing parameters for the terminal component simulation. This specification affects only results at wave ports. Specifically, the mode decomposition data for frequencies for which the total simulation time in units of the corresponding period (T = 1/f) is less than the specified minimum sampling time will be overridden by extrapolation from the data in the trusted frequency range. The trusted frequency range is defined in terms of minimum and maximum sampling times (the total simulation time divided by the corresponding period). Example ------- >>> low_freq_smoothing = ModelerLowFrequencySmoothingSpec( ... min_sampling_time=3, ... max_sampling_time=6, ... order=1, ... max_deviation=0.5, ... ) """
[docs] class TerminalComponentModeler(AbstractComponentModeler, MicrowaveBaseModel): """ Tool for modeling two-terminal multiport devices and computing port parameters with lumped and wave ports. Notes ----- **References** .. [1] R. B. Marks and D. F. Williams, "A general waveguide circuit theory," J. Res. Natl. Inst. Stand. Technol., vol. 97, pp. 533, 1992. .. [2] D. M. Pozar, Microwave Engineering, 4th ed. Hoboken, NJ, USA: John Wiley & Sons, 2012. """ ports: tuple[TerminalPortType, ...] = pd.Field( (), title="Terminal Ports", description="Collection of lumped and wave ports associated with the network. " "For each port, one simulation will be run with a source that is associated with the port.", ) run_only: Optional[tuple[NetworkIndex, ...]] = pd.Field( None, title="Run Only", description="Set of matrix indices that define the simulations to run. " "If ``None``, simulations will be run for all indices in the scattering matrix. " "If a tuple is given, simulations will be run only for the given matrix indices.", ) element_mappings: tuple[tuple[NetworkElement, NetworkElement, Complex], ...] = pd.Field( (), title="Element Mappings", description="Tuple of S matrix element mappings, each described by a tuple of " "(input_element, output_element, coefficient), where the coefficient is the " "element_mapping coefficient describing the relationship between the input and output " "matrix element. If all elements of a given column of the scattering matrix are defined " "by ``element_mappings``, the simulation corresponding to this column is skipped automatically.", ) radiation_monitors: tuple[ annotate_type(Union[DirectivityMonitor, DirectivityMonitorSpec]), ... ] = pd.Field( (), title="Radiation Monitors", description="Facilitates the calculation of figures-of-merit for antennas. " "These monitors will be included in every simulation and record the radiated fields. " "Users can specify a combination of :class:`.DirectivityMonitor` objects for manual placement and :class:`.DirectivityMonitorSpec` " "objects for automatic generation.", ) assume_ideal_excitation: bool = pd.Field( False, title="Assume Ideal Excitation", description="If ``True``, only the excited port is assumed to have a nonzero incident wave " "amplitude power. This choice simplifies the calculation of the scattering matrix. " "If ``False``, every entry in the vector of incident wave amplitudes (a) is calculated " "explicitly. This choice requires a matrix inversion when calculating the scattering " "matrix, but may lead to more accurate scattering parameters when there are " "reflections from simulation boundaries. ", ) s_param_def: SParamDef = pd.Field( "pseudo", title="Scattering Parameter Definition", description="Whether to compute scattering parameters using the 'pseudo' or 'power' wave definitions.", ) low_freq_smoothing: Optional[ModelerLowFrequencySmoothingSpec] = pd.Field( None, title="Low Frequency Smoothing", description="The low frequency smoothing parameters for the terminal component simulation.", ) @property def _sim_with_sources(self) -> Simulation: """Instance of :class:`.Simulation` with all sources and absorbers added for each port, for plotting.""" sources = [port.to_source(self._source_time) for port in self.ports] absorbers = [ port.to_absorber() for port in self.ports if isinstance(port, WavePort) and port.absorber ] return self.simulation.updated_copy( sources=sources, internal_absorbers=absorbers, validate=False )
[docs] @equal_aspect @add_ax_if_none def plot_sim( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, **kwargs: Any, ) -> Ax: """Plot a :class:`.Simulation` with all sources and absorbers. This is a convenience method to visualize the simulation setup for troubleshooting. It shows all sources and absorbers for each port. Parameters ---------- x : float, optional x-coordinate for the cross-section. y : float, optional y-coordinate for the cross-section. z : float, optional z-coordinate for the cross-section. ax : matplotlib.axes.Axes, optional Axes to plot on. **kwargs Keyword arguments passed to :meth:`.Simulation.plot`. Returns ------- matplotlib.axes.Axes The axes with the plot. """ return self._sim_with_sources.plot(x=x, y=y, z=z, ax=ax, **kwargs)
[docs] @equal_aspect @add_ax_if_none def plot_sim_eps( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, **kwargs: Any, ) -> Ax: """Plot permittivity of the :class:`.Simulation`. This method shows the permittivity distribution of the simulation with all sources and absorbers added for each port. Parameters ---------- x : float, optional x-coordinate for the cross-section. y : float, optional y-coordinate for the cross-section. z : float, optional z-coordinate for the cross-section. ax : matplotlib.axes.Axes, optional Axes to plot on. **kwargs Keyword arguments passed to :meth:`.Simulation.plot_eps`. Returns ------- matplotlib.axes.Axes The axes with the plot. """ return self._sim_with_sources.plot_eps(x=x, y=y, z=z, ax=ax, **kwargs)
[docs] @staticmethod def network_index(port: TerminalPortType, mode_index: Optional[int] = None) -> NetworkIndex: """Converts the port, and a ``mode_index`` when the port is a :class:`.WavePort`, to a unique string specifier. Parameters ---------- port : ``TerminalPortType`` The port to convert to an index. mode_index : Optional[int] Selects a single mode from those supported by the ``port``, which is only used when the ``port`` is a :class:`.WavePort` Returns ------- NetworkIndex A unique string that is used to identify the row/column of the scattering matrix. """ return TerminalComponentModeler.get_task_name(port=port, mode_index=mode_index)
@cached_property def network_dict(self) -> dict[NetworkIndex, tuple[TerminalPortType, int]]: """Dictionary associating each unique ``NetworkIndex`` to a port and mode index.""" network_dict = {} for port in self.ports: if isinstance(port, WavePort): for mode_index in port._mode_indices: key = self.network_index(port, mode_index) network_dict[key] = (port, mode_index) else: key = self.network_index(port, None) network_dict[key] = (port, None) return network_dict @staticmethod def _construct_matrix_indices_monitor( ports: tuple[TerminalPortType, ...], ) -> tuple[NetworkIndex, ...]: """Construct matrix indices for monitoring from terminal ports. Parameters ---------- ports : tuple[TerminalPortType, ...] Tuple of terminal port objects (LumpedPort, CoaxialLumpedPort, or WavePort). Returns ------- tuple[NetworkIndex, ...] Tuple of network index strings. """ matrix_indices = [] for port in ports: if isinstance(port, WavePort): for mode_index in port._mode_indices: matrix_indices.append(TerminalComponentModeler.network_index(port, mode_index)) else: matrix_indices.append(TerminalComponentModeler.network_index(port)) return tuple(matrix_indices) @cached_property def matrix_indices_monitor(self) -> tuple[NetworkIndex, ...]: """Tuple of all the possible matrix indices.""" return self._construct_matrix_indices_monitor(self.ports) @cached_property def matrix_indices_source(self) -> tuple[NetworkIndex, ...]: """Tuple of all the source matrix indices, which may be less than the total number of ports.""" return super().matrix_indices_source @cached_property def matrix_indices_run_sim(self) -> tuple[NetworkIndex, ...]: """Tuple of all the matrix indices that will be used to run simulations.""" return super().matrix_indices_run_sim @cached_property def sim_dict(self) -> SimulationMap: """Generate all the :class:`.Simulation` objects for the port parameter calculation.""" # Check base simulation for grid size at ports TerminalComponentModeler._check_grid_size_at_ports(self.base_sim, self._lumped_ports) TerminalComponentModeler._check_grid_size_at_wave_ports(self.base_sim, self._wave_ports) sim_dict = {} # Now, create simulations with wave port sources and mode solver monitors for computing port modes for network_index in self.matrix_indices_run_sim: task_name, sim_with_src = self._add_source_to_sim(network_index) # update simulation sim_dict[task_name] = sim_with_src return SimulationMap(keys=tuple(sim_dict.keys()), values=tuple(sim_dict.values())) @cached_property def _base_sim_no_radiation_monitors(self) -> Simulation: """The intermediate base simulation with all grid refinement options, port loads (if present), and monitors added, which is only missing the source excitations and radiation monitors. """ # internal mesh override and snapping points are automatically generated from lumped elements. lumped_resistors = [port.to_load() for port in self._lumped_ports] # Apply the highest frequency in the simulation to define the grid, rather than the # source's central frequency, to ensure an accurate solution over the entire range grid_spec = self.simulation.grid_spec.copy( update={ "wavelength": C_0 / np.max(self.freqs), } ) # Make an initial simulation with new grid_spec to determine where LumpedPorts are snapped sim_wo_source = self.simulation.updated_copy( grid_spec=grid_spec, lumped_elements=lumped_resistors, validate=False, deep=False, ) snap_centers = {} for port in self._lumped_ports: port_center_on_axis = port.center[port.injection_axis] new_port_center = snap_coordinate_to_grid( sim_wo_source.grid, port_center_on_axis, port.injection_axis ) snap_centers[port.name] = new_port_center # Create monitors and snap to the center positions field_monitors = [ mon for port in self.ports for mon in port.to_monitors( self.freqs, snap_center=snap_centers.get(port.name), grid=sim_wo_source.grid ) ] new_mnts = list(self.simulation.monitors) + field_monitors new_lumped_elements = list(self.simulation.lumped_elements) + [ port.to_load(snap_center=snap_centers[port.name]) for port in self._lumped_ports ] # Add mesh overrides for any wave ports present mesh_overrides = list(sim_wo_source.grid_spec.override_structures) for wave_port in self._wave_ports: if wave_port.num_grid_cells is not None: mesh_overrides.extend(wave_port.to_mesh_overrides()) new_grid_spec = sim_wo_source.grid_spec.updated_copy(override_structures=mesh_overrides) new_absorbers = list(sim_wo_source.internal_absorbers) for wave_port in self._wave_ports: if wave_port.absorber: # absorbers are shifted together with sources mode_src_pos = wave_port.center[ wave_port.injection_axis ] + self._shift_value_signed(wave_port) port_absorber = wave_port.to_absorber( snap_center=mode_src_pos, freq_spec=BroadbandModeABCSpec( frequency_range=(np.min(self.freqs), np.max(self.freqs)) ), ) new_absorbers.append(port_absorber) update_dict = { "monitors": new_mnts, "lumped_elements": new_lumped_elements, "grid_spec": new_grid_spec, "internal_absorbers": new_absorbers, } # propagate the low frequency smoothing specification to the simulation mode_monitors = [mnt.name for mnt in field_monitors if isinstance(mnt, ModeMonitor)] if mode_monitors and self.low_freq_smoothing is not None: update_dict["low_freq_smoothing"] = LowFrequencySmoothingSpec( monitors=mode_monitors, min_sampling_time=self.low_freq_smoothing.min_sampling_time, max_sampling_time=self.low_freq_smoothing.max_sampling_time, order=self.low_freq_smoothing.order, max_deviation=self.low_freq_smoothing.max_deviation, ) # update base simulation with updated set of shared components sim_wo_source = sim_wo_source.updated_copy( **update_dict, validate=False, deep=False, ) # extrude port structures sim_wo_source = self._extrude_port_structures(sim=sim_wo_source) return sim_wo_source @cached_property def _finalized_radiation_monitors(self) -> tuple[DirectivityMonitor, ...]: """ The tuple of DirectivityMonitor objects for the radiation monitors. Expands any DirectivityMonitorSpec instances to actual DirectivityMonitor objects. DirectivityMonitor objects are kept as-is. """ base_sim = self._base_sim_no_radiation_monitors finalized = [] for index, rad_mon in enumerate(self.radiation_monitors): if isinstance(rad_mon, DirectivityMonitorSpec): # Generate DirectivityMonitor from DirectivityMonitorSpec spec if not rad_mon.name: mon_name = f"{AUTO_RADIATION_MONITOR_NAME}_{index}" rad_mon = rad_mon.updated_copy(name=mon_name) try: generated = self._generate_radiation_monitor( simulation=base_sim, auto_spec=rad_mon ) finalized.append(generated) except ValueError as e: raise ValueError( "Automatic construction of radiation monitors failed. " "Please address the reason or provide a tuple of DirectivityMonitor " "objects to the 'radiation_monitors' parameter." ) from e else: # DirectivityMonitor - use as-is finalized.append(rad_mon) return tuple(finalized) @cached_property def base_sim(self) -> Simulation: """The base simulation with all components added, including radiation monitors.""" base_sim_tmp = self._base_sim_no_radiation_monitors mnts_with_radiation = list(base_sim_tmp.monitors) + list(self._finalized_radiation_monitors) grid_spec = GridSpec.from_grid(base_sim_tmp.grid) grid_spec.attrs["from_grid_spec"] = base_sim_tmp.grid_spec # We skipped validations up to now, here we finally validate the base sim return base_sim_tmp.updated_copy(monitors=mnts_with_radiation, grid_spec=grid_spec) def _generate_radiation_monitor( self, simulation: Simulation, auto_spec: DirectivityMonitorSpec ) -> DirectivityMonitor: """ Generates a DirectivityMonitor object for the simulation. The monitor is placed at a specified buffer distance from PML boundaries (or domain boundaries if no PML). It samples the whole sphere with specified angular resolution. The monitor is validated to ensure it is far enough from simulation structures. Parameters ---------- simulation : Simulation The simulation for which to generate the monitor. auto_spec : DirectivityMonitorSpec Specification for auto-generation. Returns ------- DirectivityMonitor The generated monitor configured to measure radiation in all directions. Raises ------ ValueError If the monitor is not far enough from structures. """ # Extract parameters from auto_spec monitor_name = auto_spec.name monitor_buffer = auto_spec.buffer num_theta = auto_spec.num_theta_points num_phi = auto_spec.num_phi_points monitor_freqs = auto_spec.freqs or self.freqs # Get PML thicknesses in all directions pml_layers = simulation.num_pml_layers # List of (minus, plus) layers for each axis grid = simulation.grid boundaries = grid.boundaries.to_list # List of coordinate arrays for each axis num_cells = grid.num_cells # List of number of cells for each axis # Calculate monitor span using the specified buffer distance mnt_span = [ (minus_pml + monitor_buffer, num_cells_axis - plus_pml - monitor_buffer) for (minus_pml, plus_pml), num_cells_axis in zip(pml_layers, num_cells) ] # Calculate monitor bounds mnt_bounds = [ (coords[start_idx], coords[end_idx]) if sim_size_axis > 0 else (-inf, inf) for (start_idx, end_idx), coords, sim_size_axis in zip( mnt_span, boundaries, simulation.size ) ] mnt_bounds = np.transpose(mnt_bounds) mnt_box = Box.from_bounds(mnt_bounds[0], mnt_bounds[1]) # Create angle arrays for full sphere sampling # theta: elevation angle [0, pi] # phi: azimuthal angle [-pi, pi] theta = np.linspace(0, np.pi, num_theta) phi = np.linspace(-np.pi, np.pi, num_phi) # Create the monitor monitor = DirectivityMonitor( center=mnt_box.center, size=mnt_box.size, freqs=monitor_freqs, name=monitor_name, theta=theta, phi=phi, custom_origin=auto_spec.custom_origin, ) # Validate that monitor is far enough from structures self._validate_radiation_monitor_buffer(simulation, mnt_span, monitor_buffer) return monitor def _validate_radiation_monitor_buffer( self, simulation: Simulation, mnt_span: list[tuple[int, int]], buffer: int ) -> None: """Validate that the radiation monitor is far enough from simulation structures. Checks that each side of the monitor is at least AUTO_RADIATION_MONITOR_BUFFER cells away from the union of all structures and lumped elements, using grid cell indices. Parameters ---------- simulation : Simulation The simulation containing structures and lumped elements. mnt_span : list[tuple[int, int]] The span (start, stop) indices of the monitor in each axis. buffer : int The buffer distance to use. Raises ------ ValueError If the monitor is not far enough from structures. """ # Get finalized simulation to include all structures finalized_sim = simulation._finalized # Get all structures (including finalized ones) structures = finalized_sim.structures # Get lumped elements lumped_elements = simulation.lumped_elements # If no structures or lumped elements, validation passes if not structures and not lumped_elements: return # Calculate union of bounding boxes for all structures and lumped elements all_geoms = [] # Add structures for struct in structures: all_geoms.append(struct.geometry) # Add lumped elements (they have geometry) for elem in lumped_elements: all_geoms.append(elem.to_geometry()) # Compute union of all bounds if all_geoms: union_bounds = all_geoms[0].bounds for geom in all_geoms[1:]: union_bounds = bounds_union(union_bounds, geom.bounds) # Convert union bounds to Box and get grid cell indices union_box = Box.from_bounds(union_bounds[0], union_bounds[1]) grid = simulation.grid union_inds = grid.discretize_inds(union_box, extend=True) # Check each axis for axis in range(3): mnt_start, mnt_end = mnt_span[axis] union_start, union_end = union_inds[axis] axis_name = "xyz"[axis] # Check minus side: union should be at least BUFFER cells away from monitor start buffer_minus = union_start - mnt_start if buffer_minus < buffer: raise ValueError( f"Automatically generated radiation monitor is too close to structures on the negative {axis_name} side. " f"Buffer: {buffer_minus} cells, required: {buffer} cells. " f"Please increase simulation domain size." ) # Check plus side: union should be at least BUFFER cells away from monitor end buffer_plus = mnt_end - union_end if buffer_plus < buffer: raise ValueError( f"Automatically generated radiation monitor is too close to structures on the positive {axis_name} side. " f"Buffer: {buffer_plus} cells, required: {buffer} cells. " f"Please increase simulation domain size." ) def _add_source_to_sim(self, source_index: NetworkIndex) -> tuple[str, Simulation]: """Adds the source corresponding to the ``source_index`` to the base simulation.""" port, mode_index = self.network_dict[source_index] if isinstance(port, WavePort): # Source is placed just before the field monitor of the port mode_src_pos = port.center[port.injection_axis] + self._shift_value_signed(port) port_source = port.to_source( self._source_time, snap_center=mode_src_pos, mode_index=mode_index ) else: port_center_on_axis = port.center[port.injection_axis] new_port_center = snap_coordinate_to_grid( self.base_sim.grid, port_center_on_axis, port.injection_axis ) port_source = port.to_source( self._source_time, snap_center=new_port_center, grid=self.base_sim.grid ) task_name = self.get_task_name(port=port, mode_index=mode_index) return ( task_name, self.base_sim.updated_copy(sources=[port_source], validate=False, deep=False), ) @cached_property def _source_time(self): """Helper to create a time domain pulse for the frequency range of interest.""" if self.custom_source_time is not None: return self.custom_source_time if len(self.freqs) == 1: freq0 = self.freqs[0] return GaussianPulse(freq0=self.freqs[0], fwidth=freq0 * FWIDTH_FRAC) # Using the minimum_source_bandwidth, ensure we don't create a pulse that is too narrowband # when fmin and fmax are close together return GaussianPulse.from_frequency_range( fmin=np.min(self.freqs), fmax=np.max(self.freqs), remove_dc_component=self.remove_dc_component, minimum_source_bandwidth=FWIDTH_FRAC, ) @pd.validator("simulation") def _validate_3d_simulation(cls, val): """Error if :class:`.Simulation` is not a 3D simulation""" if val.size.count(0.0) > 0: raise ValidationError( f"'{cls.__name__}' must be setup with a 3D simulation with all sizes greater than 0." ) return val @pd.validator("ports") @skip_if_fields_missing(["simulation"]) def _validate_port_refinement_usage(cls, val, values): """Warn if port refinement options are enabled, but the supplied simulation does not contain a grid type that will make use of them.""" sim: Simulation = values.get("simulation") # If grid spec is using AutoGrid # then set up is acceptable if sim.grid_spec.auto_grid_used: return val for port in val: if port._is_using_mesh_refinement: log.warning( f"A port with name '{port.name}' has mesh refinement options enabled, but the " "'Simulation' passed to the 'TerminalComponentModeler' was setup with a 'GridSpec' which " "does not support mesh refinement. For accurate simulations, please setup the " "'Simulation' to use an 'AutoGrid'. To suppress this warning, please explicitly disable " "mesh refinement options in the port, which are by default enabled. For example, set " "the 'enable_snapping_points=False' and 'num_grid_cells=None' for lumped ports." ) return val @pd.validator("radiation_monitors") @skip_if_fields_missing(["freqs"]) def _validate_radiation_monitors(cls, val, values): """Validate radiation monitors configuration. Validates that: - DirectivityMonitor frequencies are a subset of modeler frequencies - DirectivityMonitorSpec frequencies (if provided) are a subset of modeler frequencies """ modeler_freqs = set(values.get("freqs", [])) for index, rad_mon in enumerate(val): # Only validate freqs if explicitly provided # freqs are provided always in DirectivityMonitor # in DirectivityMonitorSpec, freqs may be not provided, # in this case, we use the modeler frequencies, so no validation is needed if rad_mon.freqs is not None: mon_freqs = set(rad_mon.freqs) is_subset = modeler_freqs.issuperset(mon_freqs) if not is_subset: mon_name = rad_mon.name or f"{AUTO_RADIATION_MONITOR_NAME}_{index}" raise ValidationError( f"The frequencies in the radiation monitor '{mon_name}' " f"must be equal to or a subset of the frequencies in the '{cls.__name__}'." ) return val @staticmethod def _check_grid_size_at_ports( simulation: Simulation, ports: list[Union[LumpedPort, CoaxialLumpedPort]] ) -> None: """Raises :class:`.SetupError` if the grid is too coarse at port locations""" yee_grid = simulation.grid.yee for port in ports: port._check_grid_size(yee_grid) @staticmethod def _check_grid_size_at_wave_ports(simulation: Simulation, ports: list[WavePort]) -> None: """Raises :class:`.SetupError` if the grid is too coarse at port locations""" for port in ports: disc_grid = simulation.discretize(port) check_axes = port.transverse_axes msg_header = f"'WavePort' '{port.name}' " for axis in check_axes: sim_size = simulation.size[axis] dim_cells = disc_grid.num_cells[axis] if sim_size > 0 and dim_cells <= 2: small_dim = "xyz"[axis] raise SetupError( msg_header + f"is too small along the " f"'{small_dim}' axis. Less than '3' grid cells were detected. " "Please ensure that the port's 'num_grid_cells' is not 'None'. " "You also may need to use an 'AutoGrid' or `QuasiUniformGrid` " "for the simulation passed to the 'TerminalComponentModeler'." ) @cached_property def _lumped_ports(self) -> list[AbstractLumpedPort]: """A list of all lumped ports in the :class:`.TerminalComponentModeler`""" return [port for port in self.ports if isinstance(port, AbstractLumpedPort)] @cached_property def _wave_ports(self) -> list[WavePort]: """A list of all wave ports in the :class:`.TerminalComponentModeler`""" return [port for port in self.ports if isinstance(port, WavePort)] @staticmethod def _set_port_data_array_attributes(data_array: PortDataArray) -> PortDataArray: """Helper to set additional metadata for ``PortDataArray``.""" data_array.name = "Z0" return data_array.assign_attrs(units=OHM, long_name="characteristic impedance")
[docs] def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor: """Find and return a :class:`.DirectivityMonitor` monitor by its name. Parameters ---------- monitor_name : str Name of the monitor to find. Returns ------- :class:`.DirectivityMonitor` The monitor matching the given name. Raises ------ ``Tidy3dKeyError`` If no monitor with the given name exists. """ for monitor in self._finalized_radiation_monitors: if monitor.name == monitor_name: return monitor raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.")
[docs] def task_name_from_index(self, source_index: NetworkIndex) -> str: """Compute task name for a given network index without constructing simulations.""" port, mode_index = self.network_dict[source_index] return self.get_task_name(port=port, mode_index=mode_index)
def _extrude_port_structures(self, sim: Simulation) -> Simulation: """ Extrude structures intersecting a port plane when a wave port lies on a structure boundary. This method checks wave ports with ``extrude_structures==True`` and automatically extends the boundary structures to PEC plates associated with internal absorbers in the direction opposite to the mode source. This ensures that mode sources and internal absorbers are fully contained within the extrusion. Parameters ---------- sim : Simulation Simulation object containing mode sources, internal absorbers, and monitors, after mesh overrides and snapping points are applied. Returns ------- Simulation Updated simulation with extruded structures added to ``simulation.structures``. """ # create list with extruded structures new_structures = [] all_new_structures = [] # get all mode sources from TerminalComponentModeler that correspond to ports with ``extrude_structures`` flag set to ``True``. for port in self.ports: if isinstance(port, WavePort) and port.extrude_structures: # compute snap_center and shift the internal absorber associated with the current port snap_center = port.center[port.injection_axis] + self._shift_value_signed(port) absorber = port.to_absorber(snap_center=snap_center) shifted_absorber = _shift_object( obj=absorber, grid=sim.grid, bounds=sim.bounds, direction=absorber.direction, shift=absorber.grid_shift, ) # get the PEC box with its face surfaces (box, inj_axis, direction) = sim._pec_frame_box(shifted_absorber, expand=True) surfaces = box.surfaces(box.size, box.center) # get extrusion coordinates and a cutting plane for inference of intersecting structures. sign = 1 if direction == "+" else -1 back_pec_plane = surfaces[2 * inj_axis + (1 if direction == "+" else 0)] # get extrusion extent along injection axis extrude_to = back_pec_plane.center[inj_axis] # move cutting plane beyond the waveport plane along the `ModeSource` injection direction. center = list(back_pec_plane.center) center[inj_axis] = port.center[inj_axis] - sign * fp_eps * box.size[inj_axis] cutting_plane = back_pec_plane.updated_copy(center=center) # define extrusion bounds extrusion_bounds = [cutting_plane.center[inj_axis], extrude_to][::sign] # loop over structures and extrude those that intersect a waveport plane for structure in sim.structures: # get geometries that intersect the plane on which the waveport is defined shapely_geom = cutting_plane.intersections_with(structure.geometry) polygon_list = [] for geom in shapely_geom: polygon_list = polygon_list + ClipOperation.to_polygon_list(geom) new_geoms = [] # loop over identified geometries and extrude them for polygon in polygon_list: # construct outer shell of an extruded geometry first exterior_vertices = np.array(polygon.exterior.coords) outer_shell = PolySlab( axis=inj_axis, slab_bounds=extrusion_bounds, vertices=exterior_vertices ) # construct innner shells that represent holes hole_polyslabs = [ PolySlab( axis=inj_axis, slab_bounds=extrusion_bounds, vertices=np.array(hole.coords), ) for hole in polygon.interiors ] # construct final geometry by removing inner holes from outer shell if hole_polyslabs: holes = GeometryGroup(geometries=hole_polyslabs) extruded_slab_new = ClipOperation( operation="difference", geometry_a=outer_shell, geometry_b=holes ) else: extruded_slab_new = outer_shell # append extruded geometry new_geoms.append(extruded_slab_new) if len(polygon_list) != 0: # update structure and add it to the list new_struct = structure.updated_copy( geometry=GeometryGroup(geometries=new_geoms) ) new_structures.append(new_struct) # if current port does not intersect any structures raise error if not new_structures: raise SetupError( f"The 'WavePort' '{port.name}' does not intersect any structures." f"Please ensure that it is located within or at the boundary of a structure." ) all_new_structures = all_new_structures + new_structures new_structures = [] # if new structures are extruded (Lumped Port extrusion is ignored) if all_new_structures: # update structures in simulation while keeping the same grid sim = sim.updated_copy( grid_spec=GridSpec.from_grid(sim.grid), structures=[*sim.structures, *all_new_structures], validate=False, deep=False, ) return sim
TerminalComponentModeler.update_forward_refs()