-
Notifications
You must be signed in to change notification settings - Fork 70
feat(microwave): add TerminalWavePort for terminal-based S-parameter extraction #3238
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
base: develop
Are you sure you want to change the base?
Conversation
TerminalWavePort: Comprehensive Workflow DocumentationOverview
High-Level Architecture1. TerminalWavePort DefinitionClass HierarchyKey Fields
Terminal Labeling ConventionExample Definitionport = TerminalWavePort(
center=(0, 0, 0),
size=(10, 10, 0),
direction="+",
terminal_specs=AutoImpedanceSpec(), # Auto-detect conductors
differential_pairs=(("T0", "T1"),), # T0+/T1- form differential pair
reference_impedance=50,
)2. Terminal Detection & Mode Spec ResolutionAuto-Detection FlowMode Spec StructureMicrowaveTerminalModeSpec(
num_modes=2, # Number of quasi-TEM modes
impedance_specs={ # V/I path definitions per terminal
"T0": CustomImpedanceSpec(...),
"T1": CustomImpedanceSpec(...),
},
terminals_mapping={ # Terminal → single-ended mapping
"T0": "T0", # Single-ended: identity
"Diff0@comm": ("T0", "T1"), # Differential: pair of terminals
"Diff0@diff": ("T0", "T1"),
}
)3. Terminals Mapping & Differential PairsTwo-Stage TransformationThe transformation from mode space to final terminal space involves two stages: Q-Matrix TransformationThe Q-matrix transforms from single-ended terminals to final terminals (which may include differential pairs): Differential Pair Q-matrix (for pair T0+, T1-): For a system with 3 single-ended terminals (T0, T1, T2) where T0 and T1 form a differential pair: Terminal Ordering4. Source Generation: MicrowaveTerminalSourceSource Creation FlowTerminal Mode Excitation5. FDTD Simulation & MonitoringSimulation SetupMonitor Data Structure6. Voltage/Current ComputationFrom Mode Amplitudes to Terminal V/I7. Wave Amplitude & S-Parameter ComputationWave Amplitude FormulasS-Matrix Construction8. Reference Impedance HandlingImpedance Matrix StructureFor 9. Complete Data Flow Diagram10. C++ Binding IntegrationPerformance-critical computations have been migrated to C++ via
11. VisualizationThe Usage via
|
17a0592 to
3afcdd8
Compare
tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py
Outdated
Show resolved
Hide resolved
yaugenst-flex
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RF snapping now requires extras, is this by design? Would this mean extras is no longer optional?
Yes, extras would be required for many RF simulations now. However, in this particular case, I plan to move snapping back, as so many frontend tests depend on it. So in this PR, only voltage/current transformation matrix in mode solver depends on extras. |
3baef04 to
30d934e
Compare
96ddea3 to
8fa7dbe
Compare
Diff CoverageDiff: origin/develop...HEAD, staged and unstaged changes
Summary
tidy3d/components/data/monitor_data.pyLines 897-905 897 if conjugate:
898 if use_symmetric_form:
899 fields_self = {key: field.conj() for key, field in fields_self.items()}
900 else:
! 901 fields_other = {key: field.conj() for key, field in fields_other.items()}
902 dim1, dim2 = self._tangential_dims
903 d_area = self._diff_area
904
905 # After interpolation, the tangential coordinates should match. However, the two arraysLines 918-926 918 integrand = xr.DataArray(
919 e_self_x_h_other - h_self_x_e_other, coords=fields_self["E" + dim1].coords
920 )
921 else:
! 922 integrand = xr.DataArray(e_self_x_h_other, coords=fields_self["E" + dim1].coords)
923
924 integrand *= d_area
925 else:
926 # Broadcasting is needed, which may be complicated depending on the dimensions order.Lines 937-945 937 h_self_x_e_other = fields_self["H" + dim1] * fields_other["E" + dim2]
938 h_self_x_e_other -= fields_self["H" + dim2] * fields_other["E" + dim1]
939 integrand = (e_self_x_h_other - h_self_x_e_other) * d_area
940 else:
! 941 integrand = e_self_x_h_other * d_area
942
943 # Integrate over plane
944 coefficient = 0.25 if use_symmetric_form else 0.5
945 return ModeAmpsDataArray(coefficient * integrand.sum(dim=d_area.dims))Lines 1055-1063 1055 if conjugate:
1056 if use_symmetric_form:
1057 fields_self = {key: field.conj() for key, field in fields_self.items()}
1058 else:
! 1059 fields_other = {key: field.conj() for key, field in fields_other.items()}
1060
1061 # Tangential field component names
1062 dim1, dim2 = tan_dims
1063 e_1 = "E" + dim1Lines 1118-1126 1118 if use_symmetric_form:
1119 h_self_x_e_other = h_self_1 * e_other_2 - h_self_2 * e_other_1
1120 summand = 0.25 * (e_self_x_h_other - h_self_x_e_other) * d_area
1121 else:
! 1122 summand = 0.5 * e_self_x_h_other * d_area
1123 return summand
1124
1125 result = self._outer_fn_summation(
1126 fields_1=fields_self,tidy3d/components/microwave/data/dataset.pyLines 117-125 117
118 @cached_property
119 def Z0_matrix(self) -> ImpedanceFreqTerminalTerminalDataArray:
120 """The characteristic impedance matrix (diagonal matrix here)."""
! 121 return self.Z0
122
123 @cached_property
124 def voltage_transform_inv(self) -> VoltageFreqTerminalModeDataArray:
125 """Inverse of the voltage transformation matrix.Lines 128-136 128 -------
129 VoltageFreqTerminalModeDataArray
130 Inverse of the voltage transform matrix that maps modes to terminals.
131 """
! 132 return xr.apply_ufunc(
133 np.linalg.inv,
134 self.voltage_transform,
135 input_core_dims=[["terminal_index", "mode_index"]],
136 output_core_dims=[["terminal_index", "mode_index"]],tidy3d/components/microwave/data/monitor_data.pyLines 276-298 276 Optional[TerminalFieldDataset]
277 Dataset containing Ex, Ey, Ez, Hx, Hy, Hz field components indexed by terminal_index,
278 or None if transmission_line_terminal_data is not set.
279 """
! 280 if self.transmission_line_terminal_data is None:
! 281 return None
282
283 # Transform each field component: field_terminal = voltage_transform^-1 @ field_mode
284 # Use the cached inverse of the voltage transform matrix
! 285 voltage_transform_inv = self.transmission_line_terminal_data.voltage_transform_inv
! 286 field_dict = {}
! 287 for field_name in ["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]:
! 288 mode_field = getattr(self, field_name, None)
! 289 if mode_field is not None:
290 # Use xarray.dot for the matrix multiplication over mode_index
! 291 terminal_field = xr.dot(voltage_transform_inv, mode_field, dims="mode_index")
! 292 field_dict[field_name] = ScalarTerminalFieldDataArray(terminal_field)
293
! 294 return TerminalFieldDataset(**field_dict)
295
296 @property
297 def modes_info(self) -> xr.Dataset:
298 """Dataset collecting various properties of the stored modes."""Lines 509-520 509 )
510
511 # Add transmission line terminal data handling if present
512 if self.transmission_line_terminal_data is not None:
! 513 transmission_line_terminal_data_reordered = (
514 self.transmission_line_terminal_data._apply_mode_reorder(sort_inds_2d)
515 )
! 516 main_data_reordered = main_data_reordered.updated_copy(
517 transmission_line_terminal_data=transmission_line_terminal_data_reordered
518 )
519 return main_data_reorderedtidy3d/components/microwave/mode_spec.pyLines 139-149 139 val = self.impedance_specs
140 num_modes = self.num_modes
141
142 if num_modes == "auto":
! 143 if not isinstance(val, (tuple, list)):
! 144 if not isinstance(val, AutoImpedanceSpec):
! 145 raise SetupError(
146 "num_modes='auto' with a single non-AutoImpedanceSpec cannot determine "
147 "the number of modes. Provide a tuple of specs, "
148 "or use AutoImpedanceSpec for automatic conductor detection."
149 )Lines 258-276 258 @model_validator(mode="after")
259 def _validate_terminal_mode_spec(self) -> Self:
260 """Validate impedance definitions consistency, num_modes match, and terminals mapping."""
261 # Validate consistent impedance definitions
! 262 val = self.impedance_specs
! 263 if len(val) > 0:
! 264 specs = list(val.values())
! 265 first_definition = specs[0].impedance_definition
! 266 for impedance_spec in specs[1:]:
! 267 if impedance_spec.impedance_definition != first_definition:
! 268 raise SetupError("Inconsistent impedance definitions across terminals.")
269
270 # Check impedance specs consistent with num_modes
! 271 if len(val) != self.num_modes:
! 272 raise SetupError(
273 f"Given {len(val)} impedance specifications in the 'MicrowaveTerminalModeSpec', "
274 f"but the number of modes requested is {self.num_modes}. Please ensure that the "
275 "number of impedance specifications is equal to the number of modes."
276 )Lines 275-286 275 "number of impedance specifications is equal to the number of modes."
276 )
277
278 # Check terminals mapping consistency with impedance specs
! 279 terminals_mapping = self.terminals_mapping
! 280 if terminals_mapping is not None:
! 281 if len(terminals_mapping) != len(val):
! 282 raise SetupError(
283 f"Given {len(terminals_mapping)} terminals mapping in the 'MicrowaveTerminalModeSpec', "
284 f"but the number of impedance specifications is {len(val)}. Please ensure that the "
285 "number of terminals mapping is equal to the number of impedance specifications."
286 )Lines 284-320 284 f"but the number of impedance specifications is {len(val)}. Please ensure that the "
285 "number of terminals mapping is equal to the number of impedance specifications."
286 )
287
! 288 for terminal_label in terminals_mapping.values():
289 # Handle both single terminal (str) and differential pair (tuple[str, str])
! 290 labels_to_check = (
291 (terminal_label,) if isinstance(terminal_label, str) else terminal_label
292 )
! 293 for label in labels_to_check:
! 294 if label not in val.keys():
! 295 raise SetupError(
296 f"Terminal label '{label}' is not present in the impedance specifications."
297 )
298
! 299 return self
300
301 @cached_property
302 def impedance_definition(self) -> ImpedanceDefinition:
303 """Impedance definition (consistent across all terminals)."""
! 304 return next(iter(self.impedance_specs.values())).impedance_definition
305
306 @cached_property
307 def _terminal_indices(self) -> list[str]:
308 """List of terminal indices."""
! 309 if self.terminals_mapping is None:
! 310 return list(self.impedance_specs.keys())
! 311 return list(self.terminals_mapping.keys())
312
313 @cached_property
314 def _impedance_specs_as_tuple(self) -> tuple[Optional[ImpedanceSpecType]]:
315 """Gets the impedance_specs field converted to a tuple."""
! 316 return tuple(self.impedance_specs.values())
317
318
319 MicrowaveModeSpecType = Union[MicrowaveModeSpec, MicrowaveTerminalModeSpec]tidy3d/components/microwave/path_integrals/factory.pyLines 178-205 178 SetupError
179 If path integrals cannot be constructed from the impedance specifications.
180 """
181
! 182 integrals_dict = {}
183
184 # impedance_specs is a dict mapping terminal labels to CustomImpedanceSpec
! 185 impedance_specs = microwave_terminal_mode_spec.impedance_specs
186
! 187 for terminal_label, impedance_spec in impedance_specs.items():
188 # Get voltage and current specs from CustomImpedanceSpec
! 189 v_spec = impedance_spec.voltage_spec
! 190 i_spec = impedance_spec.current_spec
191
! 192 try:
! 193 v_integral = None
! 194 i_integral = None
! 195 if v_spec is not None:
! 196 v_integral = make_voltage_integral(v_spec)
! 197 if i_spec is not None:
! 198 i_integral = make_current_integral(i_spec)
! 199 integrals_dict[terminal_label] = (v_integral, i_integral)
! 200 except Exception as e:
! 201 raise SetupError(
202 f"Failed to construct path integrals for terminal '{terminal_label}' "
203 "from the impedance specification. "
204 "Please create a github issue so that the problem can be investigated."
205 ) from eLines 203-208 203 "from the impedance specification. "
204 "Please create a github issue so that the problem can be investigated."
205 ) from e
206
! 207 return integrals_dicttidy3d/components/microwave/path_integrals/mode_plane_analyzer.pyLines 207-226 207 ------
208 SetupError
209 If no valid isolated conductors are found in the mode plane.
210 """
! 211 mode_symmetry_3d = self._get_mode_symmetry(sim_box, symmetry)
! 212 min_b_3d, max_b_3d = self._get_mode_limits(grid, mode_symmetry_3d)
213
! 214 intersection_plane = Box.from_bounds(min_b_3d, max_b_3d)
! 215 conductor_shapely = self._get_isolated_conductors_as_shapely(intersection_plane, structures)
216
! 217 conductor_shapely = self._filter_conductors_touching_sim_bounds(
218 (min_b_3d, max_b_3d), mode_symmetry_3d, conductor_shapely
219 )
220
! 221 if len(conductor_shapely) < 1:
! 222 raise SetupError(
223 "No valid isolated conductors were found in the mode plane. Please ensure that a 'Structure' "
224 "with a medium of type 'PEC' or 'LossyMetalMedium' intersects the mode plane and is not touching "
225 "the boundaries of the mode plane."
226 )Lines 224-232 224 "with a medium of type 'PEC' or 'LossyMetalMedium' intersects the mode plane and is not touching "
225 "the boundaries of the mode plane."
226 )
227
! 228 return conductor_shapely
229
230 def get_conductor_bounding_boxes(
231 self,
232 structures: list[Structure],tidy3d/components/microwave/path_integrals/specs/impedance.pyLines 135-148 135 - VI: Both voltage and current specs provided.
136 - PI: Only current spec provided.
137 - PV: Only voltage spec provided.
138 """
! 139 if self.voltage_spec is not None and self.current_spec is not None:
! 140 return ImpedanceDefinition.VI
! 141 elif self.current_spec is not None:
! 142 return ImpedanceDefinition.PI
143 else:
! 144 return ImpedanceDefinition.PV
145
146 def _check_path_integrals_within_box(self, box: Box) -> None:
147 """Raise SetupError if a path specification is defined outside a candidate box."""
148 for spec, spec_type in [Lines 178-186 178 def from_bounding_box(
179 cls, bounding_box: Box, current_sign: Direction = "+"
180 ) -> CustomImpedanceSpec:
181 """Create a custom impedance specification from a bounding box."""
! 182 return cls(
183 current_spec=AxisAlignedCurrentIntegralSpec(
184 center=bounding_box.center,
185 size=bounding_box.size,
186 sign=current_sign,tidy3d/components/microwave/path_integrals/specs/voltage.pyLines 140-148 140 if plot_markers:
141 ax.plot(xs, ys, markevery=[0, -1], **plot_kwargs)
142 else:
143 # Plot without markers
! 144 ax.plot(xs, ys, **{**plot_kwargs, "marker": ""})
145
146 # Plot special end points
147 if plot_markers:
148 end_kwargs = plot_params_voltage_plus.include_kwargs(**path_kwargs).to_kwargs()Lines 213-221 213 if plot_markers:
214 ax.plot(xs, ys, markevery=[0, -1], **plot_kwargs)
215 else:
216 # Plot without markers
! 217 ax.plot(xs, ys, **{**plot_kwargs, "marker": ""})
218
219 # Plot special end points
220 if plot_markers:
221 end_kwargs = plot_params_voltage_plus.include_kwargs(**path_kwargs).to_kwargs()tidy3d/components/microwave/source.pyLines 66-78 66
67 @model_validator(mode="after")
68 def _validate_terminal_index(self) -> Self:
69 """Validate that terminal_index exists in mode_spec terminal indices."""
! 70 terminal_indices = self.mode_spec._terminal_indices
! 71 if self.terminal_index not in terminal_indices:
! 72 raise ValueError(
73 f"terminal_index '{self.terminal_index}' not found in mode_spec. "
74 f"Available terminals: {terminal_indices}"
75 )
76
! 77 return selftidy3d/components/mode/mode_solver.pyLines 230-238 230 @model_validator(mode="after")
231 def _validate_mode_spec(self) -> Self:
232 """Validate that num_modes is an integer."""
233 if not isinstance(self.mode_spec.num_modes, int):
! 234 raise ValidationError("num_modes must be an integer.")
235 return self
236
237 @field_validator("simulation")
238 @classmethodLines 647-655 647 # Calculate and add the transmission line data
648 if self._has_microwave_mode_spec:
649 mode_solver_data = self._add_microwave_data(mode_solver_data)
650 if self._has_microwave_terminal_mode_spec:
! 651 mode_solver_data = self._add_microwave_terminal_data(mode_solver_data)
652 return mode_solver_data
653
654 @cached_property
655 def bend_axis_3d(self) -> Axis:Lines 1476-1488 1476 def _make_path_integrals_for_terminal(
1477 self,
1478 ) -> dict[str, tuple[Optional[VoltageIntegralType], Optional[CurrentIntegralType]]]:
1479 """Wrapper for making path integrals for each terminal from the MicrowaveTerminalModeSpec."""
! 1480 if not self._has_microwave_terminal_mode_spec:
! 1481 raise ValueError(
1482 "Cannot make path integrals for when 'mode_spec' is not a 'MicrowaveTerminalModeSpec'."
1483 )
! 1484 return make_path_integrals_for_terminal(self.mode_spec)
1485
1486 def _generate_transmission_line_data(
1487 self, mode_solver_data: MicrowaveModeSolverData
1488 ) -> TransmissionLineDataset:Lines 1553-1578 1553 Transform matrix with dimensions (f, terminal_index, mode_index).
1554 """
1555 # Stack voltage/current data for each terminal across modes, then stack terminals
1556 # Result: (f, terminal_index, mode_index)
! 1557 stacked_list = []
! 1558 for terminal_label in terminal_labels:
1559 # Concat across modes for this terminal: list of FreqDataArray -> (f, mode_index)
! 1560 mode_data = xr.concat(input_list[terminal_label], dim="mode_index")
! 1561 stacked_list.append(mode_data)
1562
1563 # Stack all terminals: (f, mode_index) per terminal -> (f, terminal_index, mode_index)
! 1564 result = xr.concat(stacked_list, dim="terminal_index")
! 1565 result = result.assign_coords(terminal_index=terminal_labels)
1566
1567 # Assign explicit mode_index coordinates
! 1568 num_modes = len(input_list[terminal_labels[0]])
! 1569 result = result.assign_coords(mode_index=np.arange(num_modes))
1570
1571 # Ensure dimension order is (f, terminal_index, mode_index)
! 1572 result = result.transpose("f", "terminal_index", "mode_index")
1573
! 1574 return result
1575
1576 @staticmethod
1577 def _construct_differential_pair_transform(
1578 terminals_mapping: Optional[dict[str, Union[str, tuple[str, str]]]],Lines 1601-1646 1601 np.ndarray
1602 Transformation matrix Q with shape (num_output_terminals, num_input_terminals).
1603 """
1604 # if terminals_mapping is None, return an identity matrix
! 1605 if terminals_mapping is None:
! 1606 return np.eye(len(terminal_labels))
1607
1608 # Create mapping from terminal labels to indices for fast lookup
! 1609 label_to_idx = {label: idx for idx, label in enumerate(terminal_labels)}
1610
1611 # Initialize Q matrix
! 1612 num_output = len(terminals_mapping)
! 1613 num_input = len(terminal_labels)
! 1614 Q = np.zeros((num_output, num_input))
1615
1616 # Fill Q matrix based on terminals_mapping
! 1617 for output_idx, (output_label, input_mapping) in enumerate(terminals_mapping.items()):
! 1618 if isinstance(input_mapping, str):
1619 # Single-ended terminal: direct mapping
! 1620 input_idx = label_to_idx[input_mapping]
! 1621 Q[output_idx, input_idx] = 1.0
1622 else:
1623 # Differential pair: tuple (label1, label2)
! 1624 label1, label2 = input_mapping
! 1625 idx1 = label_to_idx[label1]
! 1626 idx2 = label_to_idx[label2]
1627
! 1628 if output_label.endswith("@comm"):
! 1629 factor = 0.5 if voltage_transform else 1.0
! 1630 Q[output_idx, idx1] = factor
! 1631 Q[output_idx, idx2] = factor
! 1632 elif output_label.endswith("@diff"):
! 1633 factor = 1 if voltage_transform else 0.5
! 1634 Q[output_idx, idx1] = factor
! 1635 Q[output_idx, idx2] = -factor
1636 else:
1637 # Should not happen based on the design, but handle gracefully
! 1638 raise ValueError(
1639 f"Differential pair terminal '{output_label}' must end with '@comm' or '@diff'"
1640 )
1641
! 1642 return Q
1643
1644 def _generate_transmission_line_terminal_data(
1645 self, mode_solver_data: MicrowaveModeSolverData
1646 ) -> TransmissionLineTerminalDataset:Lines 1661-1697 1661 Dataset containing Z0 matrix (terminal_index_out × terminal_index_in),
1662 voltage transformation matrix (terminal_index × mode_index),
1663 and current transformation matrix (terminal_index × mode_index).
1664 """
! 1665 integrals_dict = self._make_path_integrals_for_terminal()
! 1666 impedance_definition = self.mode_spec.impedance_definition
1667 # Need to operate on the full symmetry expanded fields
! 1668 mode_solver_data_expanded = mode_solver_data.symmetry_expanded_copy
1669
1670 # Initialize dictionaries to collect voltage/current for each terminal across all modes
1671 # Structure: {terminal_label: [voltage_mode0, voltage_mode1, ...]}
! 1672 V_list = {terminal_label: [] for terminal_label in integrals_dict.keys()}
! 1673 I_list = {terminal_label: [] for terminal_label in integrals_dict.keys()}
1674
1675 # Loop over mode indices
! 1676 for mode_index in range(self.mode_spec.num_modes):
! 1677 single_mode_data = mode_solver_data_expanded._isel(mode_index=[mode_index])
! 1678 for terminal_label, (v_integral, i_integral) in integrals_dict.items():
! 1679 impedance_calc = ImpedanceCalculator(
1680 voltage_integral=v_integral, current_integral=i_integral
1681 )
! 1682 voltage, current = impedance_calc.compute_voltage_current(
1683 single_mode_data,
1684 )
1685 # Append to list for this terminal
! 1686 V_list[terminal_label].append(voltage)
! 1687 I_list[terminal_label].append(current)
1688
1689 # Validate that terminal transforms feature is available
! 1690 check_tidy3d_extras_licensed_feature("terminal_transformation_matrix")
1691
1692 # Get input terminal labels (before differential pair transformation)
! 1693 terminal_labels = list(integrals_dict.keys())
1694
1695 # Determine which arrays are needed based on impedance definition
1696 # VI (0): needs v_list, i_list
1697 # PI (1): needs i_list, power_matrixLines 1695-1718 1695 # Determine which arrays are needed based on impedance definition
1696 # VI (0): needs v_list, i_list
1697 # PI (1): needs i_list, power_matrix
1698 # PV (2): needs v_list, power_matrix
! 1699 need_v = impedance_definition in (ImpedanceDefinition.VI, ImpedanceDefinition.PV)
! 1700 need_i = impedance_definition in (ImpedanceDefinition.VI, ImpedanceDefinition.PI)
! 1701 need_p = impedance_definition in (ImpedanceDefinition.PI, ImpedanceDefinition.PV)
1702
1703 # Assemble V and I arrays as numpy arrays: (num_freqs, num_terminals, num_modes)
! 1704 v_array = (
1705 self._assemble_transform_matrices(V_list, terminal_labels).values if need_v else None
1706 )
! 1707 i_array = (
1708 self._assemble_transform_matrices(I_list, terminal_labels).values if need_i else None
1709 )
1710
1711 # Compute power matrix if needed: (num_freqs, num_modes, num_modes)
! 1712 power_matrix = None
! 1713 if need_p:
! 1714 power_matrix = (
1715 2
1716 * mode_solver_data_expanded.outer_dot(
1717 mode_solver_data_expanded, conjugate=True, use_symmetric_form=False
1718 )Lines 1718-1734 1718 )
1719 ).values
1720
1721 # Build Q matrices for differential pair transformation
! 1722 Q_voltage = self._construct_differential_pair_transform(
1723 self.mode_spec.terminals_mapping, terminal_labels, voltage_transform=True
1724 )
! 1725 Q_current = self._construct_differential_pair_transform(
1726 self.mode_spec.terminals_mapping, terminal_labels, voltage_transform=False
1727 )
1728
1729 # Call C++ binding to compute transforms and Z0
! 1730 voltage_transform_arr, current_transform_arr, z0_arr = tidy3d_extras[
1731 "mod"
1732 ].extension._compute_terminal_transforms(
1733 v_array,
1734 i_array,Lines 1738-1753 1738 impedance_definition,
1739 )
1740
1741 # Get output terminal labels (after differential pair transformation)
! 1742 out_terminal_labels = list(self.mode_spec._terminal_indices)
1743
1744 # Get coordinates from mode solver data
! 1745 freqs = mode_solver_data.n_eff.coords["f"].values
! 1746 mode_indices = mode_solver_data.n_eff.coords["mode_index"].values
1747
1748 # Wrap voltage_transform: (num_freqs, num_terminals, num_modes)
! 1749 voltage_transform = VoltageFreqTerminalModeDataArray(
1750 xr.DataArray(
1751 voltage_transform_arr,
1752 coords={
1753 "f": freqs,Lines 1758-1766 1758 )
1759 )
1760
1761 # Wrap current_transform: (num_freqs, num_terminals, num_modes)
! 1762 current_transform = CurrentFreqTerminalModeDataArray(
1763 xr.DataArray(
1764 current_transform_arr,
1765 coords={
1766 "f": freqs,Lines 1771-1779 1771 )
1772 )
1773
1774 # Wrap Z0: (num_freqs, num_terminals, num_terminals)
! 1775 Z0 = ImpedanceFreqTerminalTerminalDataArray(
1776 xr.DataArray(
1777 z0_arr,
1778 coords={
1779 "f": freqs,Lines 1783-1791 1783 dims=["f", "terminal_index_out", "terminal_index_in"],
1784 )
1785 )
1786
! 1787 return TransmissionLineTerminalDataset(
1788 Z0=Z0, voltage_transform=voltage_transform, current_transform=current_transform
1789 )
1790
1791 def _add_microwave_data(Lines 1798-1807 1798 def _add_microwave_terminal_data(
1799 self, mode_solver_data: MicrowaveModeSolverData
1800 ) -> MicrowaveModeSolverData:
1801 """Calculate and add microwave terminal data to ``mode_solver_data`` which uses the path specifications."""
! 1802 mw_data = self._generate_transmission_line_terminal_data(mode_solver_data)
! 1803 return mode_solver_data.updated_copy(transmission_line_terminal_data=mw_data)
1804
1805 @cached_property
1806 def data(self) -> ModeSolverDataType:
1807 """:class:`.ModeSolverData` containing the field and effective index data.tidy3d/components/source/field.pyLines 371-403 371
372 @cached_property
373 def angle_theta(self) -> float:
374 """Polar angle of propagation."""
! 375 return self.mode_spec.angle_theta
376
377 @cached_property
378 def angle_phi(self) -> float:
379 """Azimuth angle of propagation."""
! 380 return self.mode_spec.angle_phi
381
382 @cached_property
383 def _dir_vector(self) -> tuple[float, float, float]:
384 """Source direction normal vector in cartesian coordinates."""
! 385 radius = 1.0 if self.direction == "+" else -1.0
! 386 dx = radius * np.cos(self.angle_phi) * np.sin(self.angle_theta)
! 387 dy = radius * np.sin(self.angle_phi) * np.sin(self.angle_theta)
! 388 dz = radius * np.cos(self.angle_theta)
! 389 return self.unpop_axis(dz, (dx, dy), axis=self._injection_axis)
390
391 @cached_property
392 def _bend_axis(self) -> Axis:
393 """Bend axis for curved sources."""
! 394 if self.mode_spec.bend_radius is None:
! 395 return None
! 396 in_plane = [0, 0]
! 397 in_plane[self.mode_spec.bend_axis] = 1
! 398 direction = self.unpop_axis(0, in_plane, axis=self.injection_axis)
! 399 return direction.index(1)
400
401
402 class ModeSource(AbstractModeSource):
403 """Injects current source to excite modal profile on finite extent plane.tidy3d/plugins/smatrix/analysis/terminal.pyLines 154-162 154 # so here we just choose the first one.
155 first_sim_index = modeler_data.modeler.matrix_indices_run_sim[0]
156 port, selection_index = modeler_data.modeler.network_dict[first_sim_index]
157 if isinstance(port, TerminalWavePort):
! 158 task_name = modeler_data.modeler.get_task_name(port=port, terminal_index=selection_index)
159 elif isinstance(port, WavePort):
160 task_name = modeler_data.modeler.get_task_name(port=port, mode_index=selection_index)
161 else:
162 task_name = modeler_data.modeler.get_task_name(port=port)Lines 352-360 352 # Apply sign to Z: Z --> sign @ Z (flip rows for negative diagonal)
353 if np.any(sign_vec == -1):
354 # sign_vec has shape (f, port), Z_numpy has shape (f, port_out, port_in)
355 # Apply: Z_new[f,i,j] = sign[f,i] * Z[f,i,j]
! 356 Z_numpy = sign_vec[:, :, np.newaxis] * Z_numpy
357
358 # Compute F
359 F_numpy = compute_F(Z_numpy, s_param_def)tidy3d/plugins/smatrix/component_modelers/base.pyLines 244-254 244 return f"{port.name}@{mode_index}"
245 return f"{port.name}@{port._mode_indices()[0]}"
246 elif isinstance(port, TerminalWavePort):
247 # TerminalWavePorts has no default
! 248 if terminal_index is None:
! 249 raise ValueError("'terminal_index' must be specified for a terminal port.")
! 250 return f"{port.name}@{terminal_index}"
251 else:
252 # Modal ports default to 0
253 if mode_index is not None:
254 return f"{port.name}@{mode_index}"tidy3d/plugins/smatrix/component_modelers/terminal.pyLines 98-106 98 np.ndarray
99 Packed label centers (pixels), same shape as ``anchor_centers_px``.
100 """
101 if anchor_centers_px.size == 0:
! 102 return anchor_centers_px
103
104 centers = np.asarray(anchor_centers_px, dtype=float).copy()
105 widths = np.asarray(widths_px, dtype=float)
106 order = np.argsort(centers)Lines 111-119 111 half = widths[idx] / 2
112 left = centers[idx] - half
113 if prev_right is None:
114 if left < x_min_px:
! 115 centers[idx] += x_min_px - left
116 else:
117 min_left = prev_right + pad_px
118 if left < min_left:
119 centers[idx] += min_left - leftLines 130-142 130 half = widths[idx] / 2
131 right = centers[idx] + half
132 if next_left is None:
133 if right > x_max_px:
! 134 centers[idx] -= right - x_max_px
135 else:
136 max_right = next_left - pad_px
137 if right > max_right:
! 138 centers[idx] -= right - max_right
139 next_left = centers[idx] - half
140
141 # Ensure the first label doesn't underflow the left bound; if it does,
142 # shift right and do one final forward stabilization pass.Lines 142-162 142 # shift right and do one final forward stabilization pass.
143 first = order[0]
144 underflow = x_min_px - (centers[first] - widths[first] / 2)
145 if underflow > 0:
! 146 centers[order] += underflow
! 147 prev_right = None
! 148 for idx in order:
! 149 half = widths[idx] / 2
! 150 left = centers[idx] - half
! 151 if prev_right is None:
! 152 if left < x_min_px:
! 153 centers[idx] += x_min_px - left
154 else:
! 155 min_left = prev_right + pad_px
! 156 if left < min_left:
! 157 centers[idx] += min_left - left
! 158 prev_right = centers[idx] + half
159
160 return centers
161 Lines 438-446 438 -------
439 matplotlib.axes.Axes
440 The axes with the plot.
441 """
! 442 return self._sim_with_sources.plot_grid(x=x, y=y, z=z, ax=ax, **kwargs)
443
444 @equal_aspect
445 @add_ax_if_none
446 def plot_sim_eps(Lines 505-543 505 -------
506 matplotlib.axes.Axes
507 The axes with the plot.
508 """
! 509 if isinstance(port, TerminalPortType):
! 510 if port not in self.ports:
! 511 raise ValueError(f"Port {port} not found in the modeler.")
! 512 elif isinstance(port, str):
! 513 port = self.get_port_by_name(port)
514 else:
! 515 raise ValueError(
516 f"Invalid port type: {type(port)}. Must be a string or a TerminalPortType object."
517 )
518
! 519 injection_axis = port.injection_axis
! 520 plot_kwargs = {"ax": ax, **kwargs}
! 521 plot_kwargs.setdefault("monitor_alpha", 0)
! 522 plot_kwargs.setdefault("source_alpha", 0)
! 523 plot_kwargs["xyz"[injection_axis]] = port.center[injection_axis]
! 524 ax = self._sim_with_sources.plot(**plot_kwargs)
525
526 # Set default xlim and ylim based on port bounds in the transverse plane
! 527 from tidy3d.components.geometry.base import Box
528
! 529 _, (xmin, ymin) = Box.pop_axis(port.bounds[0], axis=injection_axis)
! 530 _, (xmax, ymax) = Box.pop_axis(port.bounds[1], axis=injection_axis)
531
532 # Add padding with shaded regions and set axis limits
! 533 self._add_port_padding_shading(ax, xmin, xmax, ymin, ymax)
534
535 # Plot bounding boxes and labels for TerminalWavePorts
! 536 if isinstance(port, TerminalWavePort):
! 537 ax = self._plot_terminal_wave_port(ax, port, label_font_size)
538
! 539 return ax
540
541 def mode_solver_for_port(self, port_name: str) -> ModeSolver:
542 """Get the mode solver object for a given port.Lines 550-561 550 -------
551 ModeSolver
552 The mode solver for the given port.
553 """
! 554 port = self.get_port_by_name(port_name)
! 555 if not isinstance(port, WavePortType):
! 556 raise ValueError("Mode solver is only supported for TerminalWavePort and WavePort.")
! 557 return port.to_mode_solver(
558 self.base_sim, self.freqs, mode_spec=self._resolved_mode_specs.get(port_name)
559 )
560
561 def _add_port_padding_shading(Lines 575-598 575 Minimum y-coordinate of the port.
576 ymax : float
577 Maximum y-coordinate of the port.
578 """
! 579 from matplotlib.patches import Rectangle
580
581 # Calculate padding based on port size
! 582 port_width = xmax - xmin
! 583 port_height = ymax - ymin
! 584 padding = TERMINAL_BOX_PADDING_FRACTION * max(port_width, port_height)
585
586 # Padded bounds (outer region)
! 587 xmin_padded = xmin - padding
! 588 xmax_padded = xmax + padding
! 589 ymin_padded = ymin - padding
! 590 ymax_padded = ymax + padding
591
592 # Add shaded rectangles for padding regions (top, bottom, left, right)
593 # Bottom padding region
! 594 bottom_rect = Rectangle(
595 (xmin_padded, ymin_padded),
596 xmax_padded - xmin_padded,
597 padding,
598 **plot_params_padding_shade,Lines 596-607 596 xmax_padded - xmin_padded,
597 padding,
598 **plot_params_padding_shade,
599 )
! 600 ax.add_patch(bottom_rect)
601
602 # Top padding region
! 603 top_rect = Rectangle(
604 (xmin_padded, ymax),
605 xmax_padded - xmin_padded,
606 padding,
607 **plot_params_padding_shade,Lines 605-616 605 xmax_padded - xmin_padded,
606 padding,
607 **plot_params_padding_shade,
608 )
! 609 ax.add_patch(top_rect)
610
611 # Left padding region
! 612 left_rect = Rectangle(
613 (xmin_padded, ymin),
614 padding,
615 port_height,
616 **plot_params_padding_shade,Lines 614-625 614 padding,
615 port_height,
616 **plot_params_padding_shade,
617 )
! 618 ax.add_patch(left_rect)
619
620 # Right padding region
! 621 right_rect = Rectangle(
622 (xmax, ymin),
623 padding,
624 port_height,
625 **plot_params_padding_shade,Lines 623-635 623 padding,
624 port_height,
625 **plot_params_padding_shade,
626 )
! 627 ax.add_patch(right_rect)
628
629 # Set the expanded limits
! 630 ax.set_xlim(xmin_padded, xmax_padded)
! 631 ax.set_ylim(ymin_padded, ymax_padded)
632
633 def _add_packed_label_lane(
634 self,
635 ax: Ax,Lines 652-688 652 Parameters for label styling.
653 arrow_params : dict[str, Any]
654 Parameters for arrow styling.
655 """
! 656 if not items:
! 657 return
658
! 659 fig = ax.figure
660
661 # Create temporary texts (single draw) to measure rendered extents.
! 662 temp_texts = [ax.text(0.0, 0.0, str(it["label"]), **label_params) for it in items]
! 663 fig.canvas.draw()
! 664 renderer = fig.canvas.get_renderer()
665
! 666 widths_px = np.array([t.get_window_extent(renderer=renderer).width for t in temp_texts])
! 667 for t in temp_texts:
! 668 t.remove()
669
670 # Axis bounds in display coordinates.
! 671 ax_bbox = ax.get_window_extent(renderer=renderer)
! 672 pad_px = max(4.0, 0.25 * float(label_params.get("fontsize", 12)))
! 673 x_min_px = ax_bbox.x0 + pad_px
! 674 x_max_px = ax_bbox.x1 - pad_px
675
676 # Desired anchor centers in display x.
! 677 anchors_x = np.array([float(it["x_anchor"]) for it in items])
! 678 lane_y_disp = ax.transData.transform((0.0, lane_y))[1]
! 679 anchors_disp = ax.transData.transform(
680 np.column_stack([anchors_x, np.full_like(anchors_x, lane_y)])
681 )
! 682 anchor_centers_px = anchors_disp[:, 0]
683
! 684 packed_centers_px = _pack_label_centers_1d(
685 anchor_centers_px=anchor_centers_px,
686 widths_px=widths_px,
687 x_min_px=x_min_px,
688 x_max_px=x_max_px,Lines 689-714 689 pad_px=pad_px,
690 )
691
692 # Convert packed centers back to data x at y=lane_y.
! 693 packed_points_data = ax.transData.inverted().transform(
694 np.column_stack([packed_centers_px, np.full_like(packed_centers_px, lane_y_disp)])
695 )
! 696 packed_x_data = packed_points_data[:, 0]
697
698 # Use the lane y (data) to create a display-scale marker size.
! 699 marker_ms = max(3.0, 0.35 * float(label_params.get("fontsize", 12)))
700
! 701 for it, x_text in zip(items, packed_x_data):
702 # Connect to a point on the box edge that is closest to the label x.
703 # This makes the association clearer when adjacent boxes have similar centers.
! 704 x_min_box = float(it.get("x_min", it["x_anchor"]))
! 705 x_max_box = float(it.get("x_max", it["x_anchor"]))
! 706 x_conn = float(np.clip(x_text, x_min_box, x_max_box))
! 707 y_anchor = float(it["y_anchor"])
708
709 # Small marker at the connection point improves readability without heavy clutter.
! 710 ax.plot(
711 [x_conn],
712 [y_anchor],
713 marker="o",
714 markersize=marker_ms,Lines 717-725 717 zorder=float(label_params.get("zorder", 11)) + 0.1,
718 markeredgewidth=0.0,
719 )
720
! 721 ax.annotate(
722 str(it["label"]),
723 xy=(x_conn, y_anchor),
724 xytext=(float(x_text), float(lane_y)),
725 arrowprops=arrow_params,Lines 751-796 751 -------
752 tuple[list[dict[str, float | str]], list[float]]
753 Terminal items for labeling and list of max y-coordinates.
754 """
! 755 from tidy3d.components.microwave.path_integrals.specs.impedance import (
756 CustomImpedanceSpec,
757 )
758
! 759 plot_coord = {0: "x", 1: "y", 2: "z"}[injection_axis]
! 760 plot_kwargs = {plot_coord: port.center[injection_axis], "ax": ax}
761
! 762 terminal_items: list[dict[str, float | str]] = []
! 763 terminal_box_ymax: list[float] = []
764
! 765 for terminal_label, impedance_spec in impedance_specs.items():
! 766 if isinstance(impedance_spec, CustomImpedanceSpec):
767 # Determine which spec to use (current takes priority)
! 768 path_spec = None
! 769 if impedance_spec.current_spec is not None:
! 770 path_spec = impedance_spec.current_spec
! 771 path_spec.plot(**plot_kwargs, color=TERMINAL_BOX_COLOR, plot_arrow=False)
! 772 elif impedance_spec.voltage_spec is not None:
! 773 path_spec = impedance_spec.voltage_spec
! 774 path_spec.plot(**plot_kwargs, color=TERMINAL_BOX_COLOR, plot_markers=False)
775
776 # Get bounding box from the path spec for labeling
! 777 if path_spec is not None:
778 # Get bounds and convert to 2D coordinates based on injection axis
! 779 bounds_3d = path_spec.bounds
! 780 _, (xmin, ymin) = Box.pop_axis(bounds_3d[0], axis=injection_axis)
! 781 _, (xmax, ymax) = Box.pop_axis(bounds_3d[1], axis=injection_axis)
782
783 # Apply padding for visualization
! 784 padding = TERMINAL_BOX_PADDING_FRACTION * max(xmax - xmin, ymax - ymin)
! 785 box_xmin = xmin - padding
! 786 box_xmax = xmax + padding
! 787 box_ymax = ymax + padding
788
! 789 terminal_box_ymax.append(box_ymax)
790
! 791 box_center_x = (box_xmin + box_xmax) / 2
! 792 terminal_items.append(
793 {
794 "label": str(terminal_label),
795 "x_anchor": float(box_center_x),
796 "y_anchor": float(box_ymax),Lines 798-806 798 "x_max": float(box_xmax),
799 }
800 )
801
! 802 return terminal_items, terminal_box_ymax
803
804 def _plot_and_collect_diff_pair_info(
805 self,
806 ax: Ax,Lines 825-881 825 -------
826 tuple[list[dict[str, float | str]], list[float]]
827 Differential pair items for labeling and list of min y-coordinates.
828 """
! 829 import matplotlib.patches as patches
830
! 831 diff_items: list[dict[str, float | str]] = []
! 832 diff_box_ymin: list[float] = []
833
! 834 if not port.differential_pairs:
! 835 return diff_items, diff_box_ymin
836
! 837 for pair_idx, (idx1, idx2) in enumerate(port.differential_pairs):
838 # Get impedance specs for both terminals in the pair
! 839 if idx1 in impedance_specs and idx2 in impedance_specs:
! 840 spec1 = impedance_specs[idx1]
! 841 spec2 = impedance_specs[idx2]
842
843 # Get path specs (current takes priority)
! 844 path_spec1 = (
845 spec1.current_spec if spec1.current_spec is not None else spec1.voltage_spec
846 )
! 847 path_spec2 = (
848 spec2.current_spec if spec2.current_spec is not None else spec2.voltage_spec
849 )
850
! 851 if path_spec1 is not None and path_spec2 is not None:
852 # Get bounds for both specs and compute combined bounding box
! 853 bounds1_3d = path_spec1.bounds
! 854 bounds2_3d = path_spec2.bounds
855
! 856 _, (xmin1, ymin1) = Box.pop_axis(bounds1_3d[0], axis=injection_axis)
! 857 _, (xmax1, ymax1) = Box.pop_axis(bounds1_3d[1], axis=injection_axis)
! 858 _, (xmin2, ymin2) = Box.pop_axis(bounds2_3d[0], axis=injection_axis)
! 859 _, (xmax2, ymax2) = Box.pop_axis(bounds2_3d[1], axis=injection_axis)
860
861 # Combined bounds
! 862 xmin = min(xmin1, xmin2)
! 863 xmax = max(xmax1, xmax2)
! 864 ymin = min(ymin1, ymin2)
! 865 ymax = max(ymax1, ymax2)
866
867 # Apply padding
! 868 padding = TERMINAL_BOX_PADDING_FRACTION * max(xmax - xmin, ymax - ymin)
! 869 box_xmin = xmin - padding
! 870 box_xmax = xmax + padding
! 871 box_ymin = ymin - padding
! 872 box_ymax = ymax + padding
873
! 874 diff_box_ymin.append(box_ymin)
875
876 # Create rectangle for differential pair with blue dashed border
! 877 rect = patches.Rectangle(
878 (box_xmin, box_ymin),
879 box_xmax - box_xmin,
880 box_ymax - box_ymin,
881 **plot_params_diff_pair_box,Lines 879-890 879 box_xmax - box_xmin,
880 box_ymax - box_ymin,
881 **plot_params_diff_pair_box,
882 )
! 883 ax.add_patch(rect)
884
! 885 box_center_x = (box_xmin + box_xmax) / 2
! 886 diff_items.append(
887 {
888 "label": f"{DEFAULT_DIFFERENTIAL_PAIR_LABEL_PREFIX}{pair_idx}: ({idx1}⁺, {idx2}⁻)",
889 "x_anchor": float(box_center_x),
890 "y_anchor": float(box_ymin),Lines 892-900 892 "x_max": float(box_xmax),
893 }
894 )
895
! 896 return diff_items, diff_box_ymin
897
898 def _place_labels_with_lane(
899 self,
900 ax: Ax,Lines 920-944 920 Parameters for arrow styling.
921 placement : str
922 Either "top" or "bottom" for label placement.
923 """
! 924 if not box_y_coords:
! 925 return
926
! 927 ylim0, ylim1 = ax.get_ylim()
! 928 yrange = float(ylim1 - ylim0) if ylim1 != ylim0 else 1.0
! 929 lane_margin = 0.08 * yrange
930
! 931 if placement == "top":
! 932 lane_y = max(box_y_coords) + lane_margin
! 933 if lane_y > ylim1:
! 934 ax.set_ylim(ylim0, lane_y + lane_margin)
935 else: # bottom
! 936 lane_y = min(box_y_coords) - lane_margin
! 937 if lane_y < ylim0:
! 938 ax.set_ylim(lane_y - lane_margin, ylim1)
939
! 940 self._add_packed_label_lane(
941 ax=ax,
942 lane_y=lane_y,
943 items=items,
944 label_params=label_params,Lines 963-985 963 -------
964 matplotlib.axes.Axes
965 The axes with the plot.
966 """
! 967 mode_spec = self._resolved_mode_specs[port.name]
! 968 impedance_specs = mode_spec.impedance_specs
! 969 injection_axis = port.injection_axis
970
971 # Plot individual terminal paths and collect info for labeling
! 972 terminal_items, terminal_box_ymax = self._plot_and_collect_terminal_info(
973 ax, port, impedance_specs, injection_axis
974 )
975
976 # Place terminal labels on top lane
! 977 label_params = plot_params_terminal_label.copy()
! 978 if label_font_size is not None:
! 979 label_params["fontsize"] = label_font_size
980
! 981 self._place_labels_with_lane(
982 ax=ax,
983 items=terminal_items,
984 box_y_coords=terminal_box_ymax,
985 label_params=label_params,Lines 987-1004 987 placement="top",
988 )
989
990 # Plot differential pair boxes and collect info for labeling
! 991 diff_items, diff_box_ymin = self._plot_and_collect_diff_pair_info(
992 ax, port, impedance_specs, injection_axis
993 )
994
995 # Place differential pair labels on bottom lane
! 996 diff_label_params = plot_params_diff_pair_label.copy()
! 997 if label_font_size is not None:
! 998 diff_label_params["fontsize"] = label_font_size
999
! 1000 self._place_labels_with_lane(
1001 ax=ax,
1002 items=diff_items,
1003 box_y_coords=diff_box_ymin,
1004 label_params=diff_label_params,Lines 1005-1013 1005 arrow_params=plot_params_diff_pair_arrow,
1006 placement="bottom",
1007 )
1008
! 1009 return ax
1010
1011 @staticmethod
1012 def network_index(
1013 port: TerminalPortType,Lines 1047-1057 1047 ):
1048 key = self.network_index(port, mode_index=mode_index)
1049 network_dict[key] = (port, mode_index)
1050 elif isinstance(port, TerminalWavePort):
! 1051 for terminal_index in self._resolved_mode_specs[port.name]._terminal_indices:
! 1052 key = self.network_index(port, terminal_index=terminal_index)
! 1053 network_dict[key] = (port, terminal_index)
1054 else:
1055 key = self.network_index(port, None)
1056 network_dict[key] = (port, None)
1057 return network_dictLines 1079-1088 1079 mode_spec=self._resolved_mode_specs.get(port.name)
1080 ):
1081 matrix_indices.append(self.network_index(port, mode_index=mode_index))
1082 elif isinstance(port, TerminalWavePort):
! 1083 for terminal_index in self._resolved_mode_specs[port.name]._terminal_indices:
! 1084 matrix_indices.append(self.network_index(port, terminal_index=terminal_index))
1085 else:
1086 matrix_indices.append(self.network_index(port))
1087 return tuple(matrix_indices)Lines 1473-1481 1473 mode_src_pos = port.center[port.injection_axis] + self._shift_value_signed(port)
1474 resolved_spec = self._resolved_mode_specs[port.name]
1475 # use terminal_index if TerminalWavePort, otherwise use mode_index
1476 if isinstance(port, TerminalWavePort):
! 1477 index_kwargs["terminal_index"] = selection_index
1478 else:
1479 index_kwargs["mode_index"] = selection_index
1480 port_source = port.to_source(
1481 self._source_time,Lines 1607-1621 1607 if port.mode_spec.num_modes != "auto":
1608 continue
1609
1610 # Get the resolved mode spec for this port
! 1611 resolved_mode_spec = self._resolved_mode_specs.get(port.name)
! 1612 num_modes = resolved_mode_spec.num_modes
1613
1614 # Check that indices are within range of num_modes
! 1615 invalid_indices = [idx for idx in mode_selection if idx >= num_modes]
! 1616 if invalid_indices:
! 1617 raise ValidationError(
1618 f"'mode_spec.mode_selection' contains indices {invalid_indices} that are >= "
1619 f"'mode_spec.num_modes' ({num_modes}) for port '{port.name}'. Valid range is 0 to {num_modes - 1}."
1620 )Lines 1630-1643 1630 continue
1631
1632 # Only validate if the original mode_spec.num_modes was 'auto'
1633 # (if it was not 'auto', validation already happened in WavePort)
! 1634 if port.mode_spec.num_modes != "auto":
! 1635 continue
1636
! 1637 num_modes = self._resolved_mode_specs[port.name].num_modes
! 1638 if mode_index >= num_modes:
! 1639 raise ValidationError(
1640 f"'mode_index' is >= "
1641 f"'mode_spec.num_modes' ({num_modes}) for port '{port.name}'. Valid range is 0 to {num_modes - 1}."
1642 )Lines 1655-1667 1655 if isinstance(port, WavePort) and port.mode_spec.num_modes != "auto":
1656 continue
1657
1658 # Get the resolved mode spec for this port
! 1659 resolved_mode_spec = self._resolved_mode_specs.get(port.name)
1660
! 1661 num_modes = resolved_mode_spec.num_modes
! 1662 if num_modes > 1:
! 1663 log.warning(
1664 f"Port '{port.name}': Absorber is enabled with {num_modes} modes. "
1665 "Absorption is not properly implemented for multimode cases yet and will be "
1666 "added in a future release. For now, please extend the transmission line into the PML region."
1667 )Lines 1718-1732 1718 Queries both WavePort and TerminalWavePort.
1719
1720 Each conductor (terminal) is identified by its label and maps to a tuple of (shape, bounding_box).
1721 """
! 1722 conductors_dict = {}
! 1723 sim = self._base_sim_with_grid_and_lumped_elements
! 1724 for port in self._terminal_wave_ports + self._wave_ports:
! 1725 conductors_dict[port.name] = port.get_isolated_floating_conductors(
1726 sim.volumetric_structures, sim.grid, sim.symmetry, sim.simulation_geometry
1727 )
! 1728 return conductors_dict
1729
1730 @cached_property
1731 def _resolved_mode_specs(self) -> dict[str, MicrowaveModeSpecType]:
1732 """Returns a dict mapping port names to their mode specs."""Lines 1736-1745 1736 mode_spec = port._mode_spec
1737 # handled unresolved mode spec here.
1738 if mode_spec is None:
1739 # Get number of conductors
! 1740 conductors = self._floating_isolated_conductors_at_waveport[port.name]
! 1741 mode_spec = port._mode_spec_from_isolated_floating_conductors(conductors)
1742
1743 mode_specs[port.name] = mode_spec
1744
1745 return mode_specsLines 1776-1784 1776 def task_name_from_index(self, source_index: NetworkIndex) -> str:
1777 """Compute task name for a given network index without constructing simulations."""
1778 port, selection_index = self.network_dict[source_index]
1779 if isinstance(port, TerminalWavePort):
! 1780 return self.get_task_name(port=port, terminal_index=selection_index)
1781 elif isinstance(port, WavePort):
1782 return self.get_task_name(port=port, mode_index=selection_index)
1783 else:
1784 return self.get_task_name(port=port)tidy3d/plugins/smatrix/data/terminal.pyLines 501-509 501 -------
502 :class:`.MicrowaveSMatrixData`
503 S-matrix renormalized to the new reference impedance.
504 """
! 505 raise NotImplementedError("Renormalization algorithm to be implemented")
506
507 @cached_property
508 def port_voltage_current_matrices(self) -> tuple[TerminalPortDataArray, TerminalPortDataArray]:
509 """Compute voltage and current matrices for all port combinations.tidy3d/plugins/smatrix/ports/wave.pyLines 133-141 133
134 # Handle scalar complex values
135 if isinstance(val, (int, float, complex)):
136 if np.real(val) <= 0:
! 137 raise ValidationError(
138 f"Reference impedance must have positive real part. Got {val} with real part {np.real(val)}."
139 )
140 return valLines 140-155 140 return val
141
142 # Handle DataArray (ImpedanceModeDataArray or ImpedanceTerminalDataArray)
143 # Check all values have positive real part
! 144 real_parts = np.real(val.values)
! 145 if np.any(real_parts <= 0):
! 146 min_real = np.min(real_parts)
! 147 raise ValidationError(
148 f"All reference impedance values must have positive real part. "
149 f"Found minimum real part: {min_real}."
150 )
! 151 return val
152
153 def get_reference_impedance_matrix(
154 self, sim_mode_data: Union[SimulationData, MicrowaveModeData]
155 ) -> Union[ImpedanceFreqModeModeDataArray, ImpedanceFreqTerminalTerminalDataArray]:Lines 163-186 163 return computed_Z0
164
165 # User-specified reference impedance - build diagonal matrix
166 # Detect whether computed_Z0 is mode-based or terminal-based
! 167 is_mode_based = "mode_index_out" in computed_Z0.dims
168
! 169 if is_mode_based:
! 170 dim_prefix = "mode"
! 171 result_cls = ImpedanceFreqModeModeDataArray
172 else:
! 173 dim_prefix = "terminal"
! 174 result_cls = ImpedanceFreqTerminalTerminalDataArray
175
! 176 dim_1d, dim_out, dim_in = (f"{dim_prefix}_index{s}" for s in ("", "_out", "_in"))
177
! 178 indices = computed_Z0.coords[dim_out].values
! 179 num_indices = len(indices)
180
181 # Create identity matrix as xarray DataArray for broadcasting
! 182 eye = xr.DataArray(
183 np.eye(num_indices),
184 coords={dim_out: indices, dim_in: indices},
185 )Lines 184-194 184 coords={dim_out: indices, dim_in: indices},
185 )
186
187 # Handle scalar Complex vs DataArray reference impedance
! 188 if isinstance(self.reference_impedance, (int, float, complex)):
189 # Scalar case: create uniform reference values for all indices
! 190 ref_values = xr.DataArray(
191 np.full(num_indices, self.reference_impedance),
192 coords={dim_1d: indices},
193 )
194 else:Lines 193-208 193 )
194 else:
195 # Data array: use values for each mode/terminal, fallback to default for missing ones
196 # Reindex to match indices, filling missing with default value
! 197 ref_values = self.reference_impedance.reindex(
198 {dim_1d: indices}, fill_value=DEFAULT_REFERENCE_IMPEDANCE_VALUE
199 )
200
201 # Rename and broadcast to diagonal matrix
! 202 ref_diag = ref_values.rename({dim_1d: dim_out}) * eye
203
! 204 return result_cls(ref_diag)
205
206 @abstractmethod
207 def get_characteristic_impedance_matrix(
208 self, sim_mode_data: Union[SimulationData, MicrowaveModeData]Lines 240-248 240
241 @cached_property
242 def _mode_plane_analyzer(self) -> ModePlaneAnalyzer:
243 """Mode plane analyzer for the port."""
! 244 return ModePlaneAnalyzer(
245 center=self.center,
246 size=self.size,
247 field_data_colocated=MONITOR_COLOCATE,
248 )Lines 271-284 271 -------
272 dict[str, tuple[Shapely, Box]]:
273 Mapping from terminal name to terminal shape and bounding box.
274 """
! 275 bounding_boxes, shapes = self._mode_plane_analyzer.get_conductor_bounding_boxes(
276 structures, grid, symmetry, sim_box
277 )
! 278 labels = [f"{DEFAULT_TERMINAL_LABEL_PREFIX}{i}" for i in range(len(shapes))]
279
! 280 return {label: (shape, bbox) for label, shape, bbox in zip(labels, shapes, bounding_boxes)}
281
282 def _validate_resolved_mode_spec(
283 self, mode_spec: Optional[MicrowaveModeSpecType] = None
284 ) -> MicrowaveModeSpecType:Lines 284-292 284 ) -> MicrowaveModeSpecType:
285 """If the resolved mode_spec is not provided, validate that self._mode_spec not None."""
286 if mode_spec is not None:
287 if mode_spec.num_modes == "auto":
! 288 raise SetupError(
289 "The supplied mode specification has num_modes='auto'. "
290 "Please pass a mode_spec with an explicit number of modes."
291 )
292 return mode_specLines 291-299 291 )
292 return mode_spec
293
294 if self._mode_spec is None:
! 295 raise SetupError(
296 "Mode specification cannot be resolved in WavePort alone. "
297 "Please pass a mode_spec with an explicit number of modes."
298 )
299 return self._mode_specLines 436-444 436 ) -> MicrowaveModeData:
437 """Get the mode data from the simulation data or mode data directly."""
438 if isinstance(sim_mode_data, SimulationData):
439 return sim_mode_data[self._mode_monitor_name]
! 440 return sim_mode_data
441
442 @property
443 def _is_using_mesh_refinement(self) -> bool:
444 """Check if this wave port is using mesh refinement options.Lines 511-523 511 # 1) num_modes already specified
512 if num_modes != "auto":
513 return self.mode_spec
514 # 2) num_modes='auto', and can be refered from the size of impedance_specs
! 515 impedance_specs = self.mode_spec.impedance_specs
! 516 if isinstance(impedance_specs, (list, tuple)):
! 517 return self.mode_spec.updated_copy(num_modes=len(impedance_specs))
518 # 3) num_modes='auto', and cannot be refered from the size of impedance_specs
! 519 return None
520
521 def _mode_spec_from_isolated_floating_conductors(
522 self, conductors: dict[str, tuple[Shapely, Box]]
523 ) -> MicrowaveModeSpec:Lines 521-529 521 def _mode_spec_from_isolated_floating_conductors(
522 self, conductors: dict[str, tuple[Shapely, Box]]
523 ) -> MicrowaveModeSpec:
524 """Update num_modes from the number of isolated floating conductors."""
! 525 return self.mode_spec.updated_copy(num_modes=len(conductors))
526
527 @field_validator("mode_spec", mode="after")
528 @classmethod
529 def _validate_path_integrals_within_port(Lines 571-579 571 # Check that indices are within range of num_modes
572 mode_spec = self.mode_spec
573 num_modes = mode_spec.num_modes
574 if num_modes == "auto":
! 575 return self
576 invalid_indices = [idx for idx in indices if idx >= num_modes]
577 if invalid_indices:
578 raise ValidationError(
579 f"'mode_selection' contains indices {invalid_indices} that are >= "Lines 719-733 719 :class:`.FreqModeDataArray`
720 Frequency-dependent characteristic impedance Z0 for the specified mode.
721 The impedance is complex-valued and varies with frequency.
722 """
! 723 reference_impedance_matrix = self.get_reference_impedance_matrix(sim_mode_data)
724 # Select diagonal element and reshape to (f, mode_index) format
! 725 Z0_selected = reference_impedance_matrix.sel(
726 mode_index_out=mode_index, mode_index_in=mode_index
727 )
728 # Expand dims to add mode_index dimension and cast to FreqModeDataArray
! 729 return FreqModeDataArray(Z0_selected.expand_dims(mode_index=[mode_index]))
730
731
732 class TerminalWavePort(AbstractWavePort):
733 """Class representing a single terminal-driven wave port.Lines 793-807 793 # Check for duplicates - flatten all terminal labels from all pairs
794 terminals_present = set()
795 terminals_duplicates = set()
796 for pair in val:
! 797 for terminal_label in pair:
! 798 if terminal_label in terminals_present:
! 799 terminals_duplicates.add(terminal_label)
! 800 terminals_present.add(terminal_label)
801
802 if terminals_duplicates:
! 803 raise ValidationError(
804 f"Terminal labels {sorted(terminals_duplicates)} appear more than once in differential_pairs. "
805 "Each terminal can only be used in one differential pair."
806 )
807 return valLines 810-825 810 def _validate_terminal_specs(self) -> Self:
811 """Validate terminal_specs: if it's a list of CustomImpedanceSpec, validate that current and voltage specs
812 are defined consistently: if one of them is None, it must be None for all CustomImpedanceSpec in the list.
813 """
! 814 val = self.terminal_specs
815 # Skip validation for AutoImpedanceSpec
! 816 if not isinstance(val, (tuple, list)):
! 817 return self
818
819 # Check for empty tuple/list
! 820 if len(val) == 0:
! 821 raise ValidationError(
822 "Empty 'terminal_specs' tuple is not allowed. "
823 "Please provide at least one CustomImpedanceSpec, or use AutoImpedanceSpec "
824 "for automatic terminal detection."
825 )Lines 824-841 824 "for automatic terminal detection."
825 )
826
827 # Check consistency of voltage_spec and current_spec across all CustomImpedanceSpec
! 828 has_voltage_spec = []
! 829 has_current_spec = []
830
! 831 for spec in val:
! 832 has_voltage_spec.append(spec.voltage_spec is not None)
! 833 has_current_spec.append(spec.current_spec is not None)
834
835 # Check if voltage_spec consistency: all None or all not None
! 836 if not all(has_voltage_spec) and any(has_voltage_spec):
! 837 raise ValidationError(
838 "Inconsistent voltage specifications in terminal_specs: "
839 "If voltage_spec is defined for one terminal, it must be defined for all terminals."
840 )Lines 839-853 839 "If voltage_spec is defined for one terminal, it must be defined for all terminals."
840 )
841
842 # Check if current_spec consistency: all None or all not None
! 843 if not all(has_current_spec) and any(has_current_spec):
! 844 raise ValidationError(
845 "Inconsistent current specifications in terminal_specs: "
846 "If current_spec is defined for one terminal, it must be defined for all terminals."
847 )
848
! 849 return self
850
851 @model_validator(mode="after")
852 def _check_absorber_if_extruding_structures(self) -> Self:
853 """Raise validation error when ``extrude_structures`` is set to ``True``Lines 852-865 852 def _check_absorber_if_extruding_structures(self) -> Self:
853 """Raise validation error when ``extrude_structures`` is set to ``True``
854 while ``absorber`` is set to ``False``."""
855
! 856 if self.extrude_structures and not self.absorber:
! 857 raise ValidationError(
858 "Structure extrusion for a waveport requires an internal absorber. Set `absorber=True` to enable it."
859 )
860
! 861 return self
862
863 @cached_property
864 def _mode_spec(self) -> Optional[MicrowaveTerminalModeSpec]:
865 """Mode specification for the port if it can be resolved in this module;Lines 865-885 865 """Mode specification for the port if it can be resolved in this module;
866 otherwise, return None.
867 """
868 # 1) in auto mode, needs more information to be determined
! 869 if not isinstance(self.terminal_specs, (list, tuple)):
! 870 return None
871 # 2) manual definition
! 872 num_modes = len(self.terminal_specs)
! 873 terminal_labels = [f"{DEFAULT_TERMINAL_LABEL_PREFIX}{i}" for i in range(num_modes)]
! 874 impedance_specs = dict(zip(terminal_labels, self.terminal_specs))
! 875 terminals_mapping = self._get_terminals_mapping(terminal_labels)
! 876 mode_spec = MicrowaveTerminalModeSpec(
877 impedance_specs=impedance_specs,
878 num_modes=num_modes,
879 terminals_mapping=terminals_mapping,
880 )
! 881 return mode_spec
882
883 def _mode_spec_from_isolated_floating_conductors(
884 self, conductors: dict[str, tuple[Shapely, Box]]
885 ) -> MicrowaveTerminalModeSpec:Lines 885-899 885 ) -> MicrowaveTerminalModeSpec:
886 """Construct mode specification from isolated floating conductors
887 when terminal_specs is an AutoImpedanceSpec.
888 """
! 889 terminal_labels = list(conductors)
! 890 impedance_specs = {
891 label: CustomImpedanceSpec.from_bounding_box(box)
892 for label, (_, box) in conductors.items()
893 }
! 894 terminals_mapping = self._get_terminals_mapping(terminal_labels)
! 895 return MicrowaveTerminalModeSpec(
896 impedance_specs=impedance_specs,
897 num_modes=len(terminal_labels),
898 terminals_mapping=terminals_mapping,
899 )Lines 906-915 906 mode_spec : MicrowaveModeSpecType, optional
907 Resolved mode specification with integer num_modes. If None,
908 uses self._mode_spec but raises SetupError if num_modes='auto'.
909 """
! 910 mode_spec = self._validate_resolved_mode_spec(mode_spec)
! 911 return tuple(range(mode_spec.num_modes))
912
913 @cached_property
914 def _differential_pair_mapping(self) -> dict[str, tuple[str, str]]:
915 """Get a mapping from differential pairs to single-ended terminals.Lines 917-935 917 Returns
918 -------
919 dict[str, tuple[str, str]]: Mapping from differential pairs to single-ended terminals.
920 """
! 921 mapping = {}
! 922 for pair_idx, (label1, label2) in enumerate(self.differential_pairs):
923 # Create labels for common mode and differential mode
! 924 comm_label = f"{DEFAULT_DIFFERENTIAL_PAIR_LABEL_PREFIX}{pair_idx}@comm"
! 925 diff_label = f"{DEFAULT_DIFFERENTIAL_PAIR_LABEL_PREFIX}{pair_idx}@diff"
926
927 # Map both modes to the same pair of single-ended terminals
! 928 mapping[comm_label] = (label1, label2)
! 929 mapping[diff_label] = (label1, label2)
930
! 931 return mapping
932
933 def _get_active_single_ended_terminals(self, single_ended_labels: list[str]) -> list[str]:
934 """Get the single-ended terminals for S-parameter computation (excluding the
935 ones used in differential pairs).Lines 943-957 943 -------
944 list[str]
945 List of single-ended terminal labels not used in any differential pair.
946 """
! 947 active_terminals = single_ended_labels.copy()
! 948 for pair_idx, pair in enumerate(self.differential_pairs):
! 949 for label in pair:
! 950 try:
! 951 active_terminals.remove(label)
! 952 except ValueError:
! 953 raise SetupError(
954 f"Port '{self.name}': Differential pair {pair_idx} references terminal "
955 f"label '{label}' that is not present in the available single-ended "
956 f"terminals: {single_ended_labels}. "
957 f"Please ensure all differential pair labels match detected terminals."Lines 955-963 955 f"label '{label}' that is not present in the available single-ended "
956 f"terminals: {single_ended_labels}. "
957 f"Please ensure all differential pair labels match detected terminals."
958 ) from None
! 959 return active_terminals
960
961 def _get_terminals_mapping(
962 self, single_ended_labels: list[str]
963 ) -> dict[str, Union[str, tuple[str, str]]]:Lines 977-990 977 Mapping from terminal (single-ended terminal or differential pair) to single-ended terminals.
978 Keys are ordered with single-ended terminals first, followed by differential pairs.
979 """
980 # Start with single-ended terminals (these come first in the ordering)
! 981 mapping = {
982 label: label for label in self._get_active_single_ended_terminals(single_ended_labels)
983 }
984 # Then add differential pairs (these come after single-ended terminals)
! 985 mapping.update(self._differential_pair_mapping)
! 986 return mapping
987
988 def to_source(
989 self,
990 source_time: SourceTimeType,Lines 1005-1020 1005 mode_spec : MicrowaveModeSpecType, optional
1006 Resolved mode specification with integer num_modes. If None,
1007 uses self._mode_spec but raises SetupError if num_modes='auto'.
1008 """
! 1009 center = list(self.center)
! 1010 if snap_center:
! 1011 center[self.injection_axis] = snap_center
1012 # Use provided mode_spec if given, otherwise fall back to self._mode_spec
! 1013 mode_spec = self._validate_resolved_mode_spec(mode_spec)
! 1014 if terminal_index is None:
! 1015 terminal_index = mode_spec._terminal_indices[0]
! 1016 return MicrowaveTerminalSource(
1017 center=center,
1018 size=self.size,
1019 source_time=source_time,
1020 mode_spec=mode_spec,Lines 1027-1058 1027 def get_characteristic_impedance_matrix(
1028 self, sim_mode_data: Union[SimulationData, MicrowaveModeData]
1029 ) -> ImpedanceFreqTerminalTerminalDataArray:
1030 """Retrieve the characteristic impedance matrix of the port."""
! 1031 mode_data = self._get_mode_data(sim_mode_data)
! 1032 return mode_data.transmission_line_terminal_data.Z0_matrix
1033
1034 def compute_voltage(self, sim_data: SimulationData) -> VoltageFreqTerminalDataArray:
1035 """Helper to compute voltage across the port."""
! 1036 mode_data: MicrowaveModeData = sim_data[self._mode_monitor_name]
! 1037 voltage_transform = mode_data.transmission_line_terminal_data.voltage_transform
! 1038 amps = mode_data.amps
! 1039 fwd_amps = amps.sel(direction="+").squeeze()
! 1040 bwd_amps = amps.sel(direction="-").squeeze()
1041 # Matrix multiply: voltage_transform[f, terminal_index, mode_index] @ amps[f, mode_index]
1042 # to get voltage[f, terminal_index]
! 1043 return voltage_transform.dot(fwd_amps + bwd_amps, dim="mode_index")
1044
1045 def compute_current(self, sim_data: SimulationData) -> CurrentFreqTerminalDataArray:
1046 """Helper to compute current flowing through the port."""
! 1047 mode_data: MicrowaveModeData = sim_data[self._mode_monitor_name]
! 1048 current_transform = mode_data.transmission_line_terminal_data.current_transform
! 1049 amps = mode_data.amps
! 1050 fwd_amps = amps.sel(direction="+").squeeze()
! 1051 bwd_amps = amps.sel(direction="-").squeeze()
1052 # In ModeData, fwd_amps and bwd_amps are not relative to
1053 # the direction fields are stored
! 1054 sign = 1.0
! 1055 if self.direction == "-":
! 1056 sign = -1.0
! 1057 return sign * current_transform.dot(fwd_amps - bwd_amps, dim="mode_index")tidy3d/plugins/smatrix/utils.pyLines 173-181 173 # Automatically detect if Z is diagonal and use appropriate method
174 if _is_diagonal_matrix(Z_numpy):
175 return _compute_F_diagonal(Z_numpy, s_param_def)
176 else:
! 177 return _compute_F_full(Z_numpy, s_param_def)
178
179
180 def _compute_F_diagonal(Z_numpy: np.ndarray, s_param_def: SParamDef) -> np.ndarray:
181 """Compute F matrix assuming Z is diagonal (fast element-wise operations).Lines 232-251 232 -------
233 np.ndarray
234 F matrix with shape (f, port_out, port_in).
235 """
! 236 num_freqs = Z_numpy.shape[0]
237
! 238 if s_param_def == "power":
239 # F = 0.5 * (Real[Z])^(-1/2)
! 240 Z_real = np.real(Z_numpy)
241 # Vectorize matrix inverse square root over the frequency dimension
! 242 F = np.array([0.5 * linalg.inv(linalg.sqrtm(Z_real[f_idx])) for f_idx in range(num_freqs)])
! 243 return F
! 244 elif s_param_def == "pseudo":
245 # F = 0.5 * (Real(Z^-1))^(1/2)
246 # First compute Z^-1, then take real part, then matrix square root
! 247 F = np.array(
248 [
249 0.5 * linalg.sqrtm(np.real(np.linalg.inv(Z_numpy[f_idx])))
250 for f_idx in range(num_freqs)
251 ]Lines 249-262 249 0.5 * linalg.sqrtm(np.real(np.linalg.inv(Z_numpy[f_idx])))
250 for f_idx in range(num_freqs)
251 ]
252 )
! 253 return F
254 elif s_param_def == "symmetric_pseudo":
255 # F = 0.5 * Z^(-1/2)
256 # Matrix inverse square root of complex Z
! 257 F = np.array([0.5 * linalg.inv(linalg.sqrtm(Z_numpy[f_idx])) for f_idx in range(num_freqs)])
! 258 return F
259 else:
260 raise ValueError(
261 f"Unsupported S-parameter definition '{s_param_def}'. "
262 "Supported values are 'pseudo', 'symmetric_pseudo', and 'power'." |
Add terminal mode excitation support to the microwave S-matrix plugin, enabling accurate characterization of transmission line structures. - Implement TerminalComponentModeler for multi-port S-parameter extraction - Add wave port support with automatic mode computation - Support terminal-based voltage/current path integral definitions - Add visualization methods for port geometry and field plots - Extend DataArray with terminal-specific coordinate handling - Add terminal mode support to ModeSolver fix(microwave): revision round 1 Fix _dims as bare strings in ModeDataArray and TerminalDataArray that would break set()-based validation, pass resolved mode_spec in de-embedding _mode_indices() call, inherit impedance specs from AbstractImpedanceSpec, and remove commented-out development code. fix(microwave): revision round 2 - Move xarray import out of TYPE_CHECKING to fix NameError in terminal_fields - Use _terminal_indices instead of terminals_mapping.keys() to avoid AttributeError when None - Add VoltageFreqTerminalDataArray to DATA_ARRAY_TYPES for HDF5 round-trip fix(microwave): fix model_dump data loss and missing runtime imports in mode_solver - Replace model_dump() with direct attribute access when converting ModeSolverData to MicrowaveModeSolverData, preventing silent loss of impedance_specs - Move CurrentFreqTerminalModeDataArray and ImpedanceFreqTerminalTerminalDataArray from TYPE_CHECKING to regular imports (used at runtime) fix(microwave): remove unused _apply_matrix_operation method fix(microwave): address review feedback on terminal wave port PR - Update outdated docstring in _generate_transmission_line_terminal_data that incorrectly described "dummy values" instead of actual computation - Register FreqModeModeDataArray and ImpedanceFreqModeModeDataArray in DATA_ARRAY_TYPES for consistent serialization support - Update docs to reference TerminalWavePort alongside WavePort Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| main_data_reordered = main_data_reordered.updated_copy( | ||
| transmission_line_data=transmission_line_data_reordered | ||
| ) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing terminal data handling in group index post-processing
Low Severity
The _group_index_post_process method filters frequency dimensions from transmission_line_data but does not handle the new transmission_line_terminal_data. The sibling method _apply_mode_reorder was updated to handle both datasets, creating an inconsistency. While the current mode solver execution order adds terminal data after group index processing, this asymmetry is fragile and would silently produce frequency dimension mismatches if the order ever changes.
e92a216 to
ed24da2
Compare


Summary
TerminalWavePortfor terminal-based S-parameter extraction with automatic transmission line mode computationTerminalComponentModelerfor multi-port S-parameter extraction with differential pair supportsnap_box_to_grid) to C++ viatidy3d_extrasTest plan
TerminalComponentModeler🤖 Generated with Claude Code
Note
Medium Risk
Touches mode-solver postprocessing and modal overlap math and expands public schemas/types, which could affect RF results/serialization if edge cases slip through, but most changes are additive with backwards-compatible defaults.
Overview
Enables terminal-based RF excitation and S-parameter extraction by introducing
TerminalWavePort/MicrowaveTerminalSourceand a newMicrowaveTerminalModeSpecflow that computes per-terminal voltage/current integrals, differential-pair transforms, and terminal impedance matrices.Extends the mode-solver/microwave data model with terminal-indexed datasets and data arrays (
TransmissionLineTerminalDataset, terminal field datasets, terminal-index dims), addsZ0_matrixhelpers, and updates overlap integrals (dot/outer_dot) to support both symmetric and asymmetric forms used by transmission-line analysis.Updates JSON schemas and docs to expose the new terminal types (including
num_modes="auto"for microwave mode specs) and adds tests for impedance-matrix handling, WavePort multimode absorber warnings, and automated label packing for terminal plotting; also hardens geometry merging for zero-area Shapely types and improves HDF5 unicode coord serialization.Written by Cursor Bugbot for commit ed24da2. This will update automatically on new commits. Configure here.