From ed163fc8d6d95a35fe286d2a6b2b69c2807bb004 Mon Sep 17 00:00:00 2001 From: George-Guryev-flxcmp Date: Tue, 30 Sep 2025 17:36:21 -0400 Subject: [PATCH] feat(rf): add S-parameter de-embedding support to TCM --- CHANGELOG.md | 1 + .../test_terminal_component_modeler.py | 64 +++++++++ tidy3d/plugins/smatrix/data/data_array.py | 16 +++ tidy3d/plugins/smatrix/data/terminal.py | 121 +++++++++++++++++- 4 files changed, 200 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46ba8575ef..b84b3ef78e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added S-parameter de-embedding to `TerminalComponentModelerData`, enabling recalculation with shifted reference planes. - Added support for `tidy3d-extras`, an optional plugin that enables more accurate local mode solving via subpixel averaging. - Added support for `symlog` and `log` scale plotting in `Scene.plot_eps()` and `Scene.plot_structures_property()` methods. The `symlog` scale provides linear behavior near zero and logarithmic behavior elsewhere, while 'log' is a base 10 logarithmic scale. - Added `LowFrequencySmoothingSpec` and `ModelerLowFrequencySmoothingSpec` for automatic smoothing of mode monitor data at low frequencies where DFT sampling is insufficient. diff --git a/tests/test_plugins/smatrix/test_terminal_component_modeler.py b/tests/test_plugins/smatrix/test_terminal_component_modeler.py index 0c4f4cb171..8f3afc7bfb 100644 --- a/tests/test_plugins/smatrix/test_terminal_component_modeler.py +++ b/tests/test_plugins/smatrix/test_terminal_component_modeler.py @@ -24,6 +24,7 @@ TerminalPortDataArray, WavePort, ) +from tidy3d.plugins.smatrix.data.data_array import PortNameDataArray from tidy3d.plugins.smatrix.ports.base_lumped import AbstractLumpedPort from tidy3d.plugins.smatrix.utils import s_to_z, validate_square_matrix @@ -1531,3 +1532,66 @@ def test_low_freq_smoothing_spec_sim_dict(): modeler = modeler.updated_copy(low_freq_smoothing=None) for sim in modeler.sim_dict.values(): assert sim.low_freq_smoothing is None + + +def test_S_parameter_deembedding(monkeypatch, tmp_path): + """Test S-parameter de-embedding.""" + + z_grid = td.UniformGrid(dl=1 * 1e3) + xy_grid = td.UniformGrid(dl=0.1 * 1e3) + grid_spec = td.GridSpec(grid_x=xy_grid, grid_y=xy_grid, grid_z=z_grid) + modeler = make_coaxial_component_modeler(port_types=(WavePort, WavePort), grid_spec=grid_spec) + + # Make sure the smatrix and impedance calculations work for reduced simulations + modeler_data = run_component_modeler(monkeypatch, modeler) + s_matrix = modeler_data.smatrix() + + # set up port shifts + port_names = [port.name for port in modeler.ports] + coords = {"port": port_names} + shift_vec = [0, 0] + port_shifts = PortNameDataArray(data=shift_vec, coords=coords) + + # make sure that de-embedded S-matrices are identical to the original one if reference planes are not shifted + S_dmb = modeler_data.change_port_reference_planes(smatrix=s_matrix, port_shifts=port_shifts) + S_dmb_shortcut = modeler_data.smatrix_deembedded(port_shifts=port_shifts) + assert np.allclose(S_dmb.data.values, s_matrix.data.values) + assert np.allclose(S_dmb_shortcut.data.values, s_matrix.data.values) + + # make sure S-parameters are different if reference planes are moved + port_shifts = PortNameDataArray(data=[-100, 200], coords=coords) + S_dmb = modeler_data.change_port_reference_planes(smatrix=s_matrix, port_shifts=port_shifts) + S_dmb_shortcut = modeler_data.smatrix_deembedded(port_shifts=port_shifts) + assert not np.allclose(S_dmb.data.values, s_matrix.data.values) + assert np.allclose(S_dmb.data.values, S_dmb_shortcut.data.values) + + # test if `.smatrix_deembedded()` raises a `ValueError` when at least one port to be shifted is not defined in TCM + port_shifts_wrong = PortNameDataArray(data=[10, -10], coords={"port": ["wave_1", "LP_wave_2"]}) + + with pytest.raises(ValueError): + S_dmb = modeler_data.smatrix_deembedded(port_shifts=port_shifts_wrong) + + # set up a new TCM with a mixture of `WavePort` and `CoaxialLumpedPort` + modeler_LP = make_coaxial_component_modeler( + port_types=(WavePort, CoaxialLumpedPort), grid_spec=grid_spec + ) + modeler_data_LP = run_component_modeler(monkeypatch, modeler_LP) + + # test if `.smatrix_deembedded()` raises a `ValueError` when one tries to de-embed a lumped port + port_shifts_LP = PortNameDataArray(data=[10, -10], coords={"port": ["wave_1", "coax_2"]}) + with pytest.raises(ValueError): + S_dmb = modeler_data_LP.smatrix_deembedded(port_shifts=port_shifts_LP) + + # update port shifts so that a reference plane is shifted only for `WavePort` port + port_shifts_LP = PortNameDataArray(data=[100], coords={"port": ["wave_1"]}) + + # get a new S-matrix + s_matrix_LP = modeler_data_LP.smatrix() + + # de-embed S-matrix + S_dmb = modeler_data_LP.change_port_reference_planes( + smatrix=s_matrix_LP, port_shifts=port_shifts_LP + ) + S_dmb_shortcut = modeler_data_LP.smatrix_deembedded(port_shifts=port_shifts_LP) + assert not np.allclose(S_dmb.data.values, s_matrix_LP.data.values) + assert np.allclose(S_dmb_shortcut.data.values, S_dmb.data.values) diff --git a/tidy3d/plugins/smatrix/data/data_array.py b/tidy3d/plugins/smatrix/data/data_array.py index a715a43a76..b982c03b00 100644 --- a/tidy3d/plugins/smatrix/data/data_array.py +++ b/tidy3d/plugins/smatrix/data/data_array.py @@ -65,3 +65,19 @@ class TerminalPortDataArray(DataArray): __slots__ = () _dims = ("f", "port_out", "port_in") _data_attrs = {"long_name": "terminal-based port matrix element"} + + +class PortNameDataArray(DataArray): + """Array of values indexed by port name. + + Example + ------- + >>> import numpy as np + >>> port_names = ["port1", "port2"] + >>> coords = dict(port_name=port_names) + >>> data = (1 + 1j) * np.random.random((2,)) + >>> port_data = PortNameDataArray(data, coords=coords) + """ + + __slots__ = () + _dims = "port_name" diff --git a/tidy3d/plugins/smatrix/data/terminal.py b/tidy3d/plugins/smatrix/data/terminal.py index f1d0d24a05..7e92e26967 100644 --- a/tidy3d/plugins/smatrix/data/terminal.py +++ b/tidy3d/plugins/smatrix/data/terminal.py @@ -13,10 +13,16 @@ from tidy3d.components.data.sim_data import SimulationData from tidy3d.components.microwave.base import MicrowaveBaseModel from tidy3d.components.microwave.data.monitor_data import AntennaMetricsData +from tidy3d.constants import C_0 +from tidy3d.log import log from tidy3d.plugins.smatrix.component_modelers.terminal import TerminalComponentModeler from tidy3d.plugins.smatrix.data.base import AbstractComponentModelerData -from tidy3d.plugins.smatrix.data.data_array import PortDataArray, TerminalPortDataArray -from tidy3d.plugins.smatrix.ports.types import TerminalPortType +from tidy3d.plugins.smatrix.data.data_array import ( + PortDataArray, + PortNameDataArray, + TerminalPortDataArray, +) +from tidy3d.plugins.smatrix.ports.types import LumpedPortType, TerminalPortType from tidy3d.plugins.smatrix.types import NetworkIndex, SParamDef from tidy3d.plugins.smatrix.utils import ( ab_to_s, @@ -117,6 +123,117 @@ def smatrix( ) return smatrix_data + def change_port_reference_planes( + self, smatrix: MicrowaveSMatrixData, port_shifts: PortNameDataArray = None + ) -> MicrowaveSMatrixData: + """ + Performs S-parameter de-embedding by shifting reference planes ``port_shifts`` um. + + Parameters + ---------- + smatrix : :class:`.MicrowaveSMatrixData` + S-parameters before reference planes are shifted. + port_shifts : :class:`.PortNameDataArray` + Data array of shifts of wave ports' reference planes. + The sign of a port shift reflects direction with respect to the axis normal to a ``WavePort`` plane: + E.g.: ``PortNameDataArray(data=-a, coords={"port": "WP1"})`` defines a shift in the first ``WavePort`` by + ``a`` um in the direction opposite to the positive axis direction (the axis normal to the port plane). + + Returns + ------- + :class:`MicrowaveSMatrixData` + De-embedded S-parameters with respect to updated reference frames. + """ + + # get s-parameters with respect to current `WavePort` locations + S_matrix = smatrix.data.values + S_new = np.zeros_like(S_matrix, dtype=complex) + N_freq, N_ports, _ = S_matrix.shape + + # pre-allocate memory for effective propagation constants + kvecs = np.zeros((N_freq, N_ports), dtype=complex) + shifts_vec = np.zeros(N_ports) + directions_vec = np.ones(N_ports) + + port_idxs = [] + n_complex_new = [] + + # extract raw data + key = self.data.keys_tuple[0] + data = self.data[key].data + ports = self.modeler.ports + + # get port names and names of ports to be shifted + port_names = [port.name for port in ports] + shift_names = port_shifts.coords["port"].values + + # Build a mapping for quick lookup from monitor name to monitor data + mode_map = {mode_data.monitor.name: mode_data for mode_data in data} + + # form a numpy vector of port shifts + for shift_name in shift_names: + # ensure that port shifts were defined for valid ports + if shift_name not in port_names: + raise ValueError( + "The specified port could not be found in the simulation! " + f"Please, make sure the port name is from the following list {port_names}" + ) + + # get index of a shifted port in port_names list + idx = port_names.index(shift_name) + port = ports[idx] + + # if de-embedding is requested for lumped port + if isinstance(port, LumpedPortType): + raise ValueError( + "De-embedding currently supports only 'WavePort' instances. " + f"Received type: '{type(port).__name__}'." + ) + # alternatively we can send a warning and set `shifts_vector[index]` to 0. + # shifts_vector[index] = 0.0 + else: + shifts_vec[idx] = port_shifts.sel(port=shift_name).values + directions_vec[idx] = -1 if port.direction == "-" else 1 + port_idxs.append(idx) + + # Collect corresponding mode_data + mode_data = mode_map[port._mode_monitor_name] + n_complex = mode_data.n_complex.sel(mode_index=port.mode_index) + n_complex_new.append(np.squeeze(n_complex.data)) + + # flatten port shift vector + shifts_vec = np.ravel(shifts_vec) + directions_vec = np.ravel(directions_vec) + + # Convert to stacked arrays + freqs = np.array(self.modeler.freqs) + n_complex_new = np.array(n_complex_new).T + + # construct transformation matrix P_inv + kvecs[:, port_idxs] = 2 * np.pi * freqs[:, np.newaxis] * n_complex_new / C_0 + phase = -kvecs * shifts_vec * directions_vec + P_inv = np.exp(1j * phase) + + # de-embed S-parameters: S_new = P_inv @ S_matrix @ P_inv + S_new = S_matrix * P_inv[:, :, np.newaxis] * P_inv[:, np.newaxis, :] + + # create a new Port Data Array + smat_data = TerminalPortDataArray(S_new, coords=smatrix.data.coords) + + return smatrix.updated_copy(data=smat_data) + + def smatrix_deembedded(self, port_shifts: np.ndarray = None) -> MicrowaveSMatrixData: + """Interface function returns de-embedded S-parameter matrix.""" + return self.change_port_reference_planes(self.smatrix(), port_shifts=port_shifts) + + @pd.root_validator(pre=False) + def _warn_rf_license(cls, values): + log.warning( + "ℹ️ ⚠️ RF simulations are subject to new license requirements in the future. You have instantiated at least one RF-specific component.", + log_once=True, + ) + return values + def _monitor_data_at_port_amplitude( self, port: TerminalPortType,