Skip to content

Commit 5825d9c

Browse files
committed
fixed the calculation of voltage/current for lossy modes
support both pseudo and power wave formulations, while defaulting to pseudo wave definition support non-conjugated dot product
1 parent eaf1f0a commit 5825d9c

File tree

5 files changed

+459
-46
lines changed

5 files changed

+459
-46
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- By default, batch downloads will skip files that already exist locally. To force re-downloading and replace existing files, pass the `replace_existing=True` argument to `Batch.load()`, `Batch.download()`, or `BatchData.load()`.
1515
- The `BatchData.load_sim_data()` function now overwrites any previously downloaded simulation files (instead of skipping them).
1616
- Tighter `TOL_EIGS` used for mode solver, since `scipy` sometimes failed to find modes.
17+
- The `TerminalComponentModeler` defaults to the pseudo wave definition of scattering parameters. The new field `S_def` can be used to switch between either pseudo or power wave definitions.
1718

1819
### Fixed
1920
- Giving opposite boundaries different names no longer causes a symmetry validator failure.
2021
- Fixed issue with parameters in `InverseDesignResult` sometimes being outside of the valid parameter range.
2122
- Fixed performance regression for multi-frequency adjoint calculations.
23+
- Calculation of voltage and current in the `WavePort`, when one type of path integral is supplied and the transmission line mode is lossy.
2224

2325
## [2.9.0rc2] - 2025-07-17
2426

tests/test_plugins/smatrix/test_terminal_component_modeler.py

Lines changed: 316 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import numpy as np
55
import pydantic.v1 as pd
66
import pytest
7+
import skrf
78
import xarray as xr
89

910
import tidy3d as td
@@ -40,7 +41,7 @@ def run_component_modeler(monkeypatch, modeler: TerminalComponentModeler):
4041
monkeypatch.setattr(
4142
TerminalComponentModeler,
4243
"_compute_F",
43-
lambda matrix: 1.0 / (2.0 * np.sqrt(np.abs(matrix) + 1e-4)),
44+
lambda Z_numpy, S_def: 1.0 / (2.0 * np.sqrt(np.abs(Z_numpy) + 1e-4)),
4445
)
4546
monkeypatch.setattr(
4647
TerminalComponentModeler,
@@ -77,6 +78,152 @@ def check_lumped_port_components_snapped_correctly(modeler: TerminalComponentMod
7778
assert center_load == center_current_monitor
7879

7980

81+
def make_t_network_impedance_matrix(
82+
series_a: complex, series_b: complex, shunt_c: complex
83+
) -> np.ndarray:
84+
"""Create impedance matrix for T-network with series elements A, B and shunt element C.
85+
86+
Network topology:
87+
Port1 ----[A]----+----[B]---- Port2
88+
|
89+
[C]
90+
|
91+
GND
92+
"""
93+
z11 = series_a + shunt_c
94+
z21 = shunt_c
95+
z12 = shunt_c
96+
z22 = series_b + shunt_c
97+
return np.array([[z11, z12], [z21, z22]])
98+
99+
100+
def calc_transmission_line_S_matrix_pseudo(Z0, Zref1, Zref2, gamma, length):
101+
"""
102+
Calculate complete 2x2 S-parameter matrix for a transmission line
103+
using pseudo wave definition
104+
105+
[1] S. Amakawa, "Scattered reflections on scattering parameters—Demystifying complex-referenced
106+
S parameters—," IEICE Trans. Electron., vol. E99-C, no. 10, pp. 1100-1112, Oct. 2016.
107+
108+
Parameters:
109+
-----------
110+
Z0 : complex or array-like
111+
Characteristic impedance (can be frequency-dependent)
112+
Zref1 : complex or array-like
113+
Reference impedance at port 1 (can be frequency-dependent)
114+
Zref2 : complex or array-like
115+
Reference impedance at port 2 (can be frequency-dependent)
116+
gamma : complex or array-like
117+
Propagation constant (can be frequency-dependent)
118+
length : float
119+
Length (scalar only)
120+
121+
Returns:
122+
--------
123+
np.ndarray :
124+
S-parameter matrix of shape (nfreq, 2, 2)
125+
"""
126+
127+
# Calculate hyperbolic functions
128+
tanh_gamma_ell = np.tanh(gamma * length)
129+
cosh_gamma_ell = np.cosh(gamma * length)
130+
131+
# Common denominator for all S-parameters
132+
denom = (Z0**2 + Zref1 * Zref2) * tanh_gamma_ell + Z0 * (Zref1 + Zref2)
133+
134+
# Calculate S11
135+
numerator_S11 = (Z0**2 - Zref1 * Zref2) * tanh_gamma_ell + Z0 * (Zref2 - Zref1)
136+
S11 = numerator_S11 / denom
137+
138+
# Calculate S22
139+
numerator_S22 = (Z0**2 - Zref1 * Zref2) * tanh_gamma_ell + Z0 * (Zref1 - Zref2)
140+
S22 = numerator_S22 / denom
141+
142+
# Calculate S21 (transmission from port 1 to port 2)
143+
numerator_S21 = (
144+
np.sqrt(np.real(Zref1) / np.real(Zref2)) * (np.abs(Zref2) / np.abs(Zref1)) * 2 * Z0 * Zref1
145+
)
146+
S21 = numerator_S21 / (denom * cosh_gamma_ell)
147+
148+
# Calculate S12 (transmission from port 2 to port 1)
149+
numerator_S12 = (
150+
np.sqrt(np.real(Zref2) / np.real(Zref1)) * (np.abs(Zref1) / np.abs(Zref2)) * 2 * Z0 * Zref2
151+
)
152+
S12 = numerator_S12 / (denom * cosh_gamma_ell)
153+
154+
# Construct the S-parameter matrix (nfreq, 2, 2)
155+
nfreq = len(np.atleast_1d(S11))
156+
S_matrix = np.zeros((nfreq, 2, 2), dtype=complex)
157+
S_matrix[:, 0, 0] = S11
158+
S_matrix[:, 0, 1] = S12
159+
S_matrix[:, 1, 0] = S21
160+
S_matrix[:, 1, 1] = S22
161+
162+
return S_matrix
163+
164+
165+
def calc_transmission_line_S_matrix_power(Z0, Zref1, Zref2, gamma, length):
166+
"""
167+
Calculate complete 2x2 S-parameter matrix for a transmission line
168+
using power wave definition
169+
170+
[1] S. Amakawa, "Scattered reflections on scattering parameters—Demystifying complex-referenced
171+
S parameters—," IEICE Trans. Electron., vol. E99-C, no. 10, pp. 1100-1112, Oct. 2016.
172+
173+
Parameters:
174+
-----------
175+
Z0 : complex or array-like
176+
Characteristic impedance (can be frequency-dependent)
177+
Zref1 : complex or array-like
178+
Reference impedance at port 1 (can be frequency-dependent)
179+
Zref2 : complex or array-like
180+
Reference impedance at port 2 (can be frequency-dependent)
181+
gamma : complex or array-like
182+
Propagation constant (can be frequency-dependent)
183+
length : float
184+
Length (scalar only)
185+
186+
Returns:
187+
--------
188+
np.ndarray :
189+
S-parameter matrix of shape (nfreq, 2, 2)
190+
"""
191+
192+
# Calculate hyperbolic functions
193+
tanh_gamma_ell = np.tanh(gamma * length)
194+
cosh_gamma_ell = np.cosh(gamma * length)
195+
196+
# Complex conjugates
197+
Zref1_conj = np.conj(Zref1)
198+
Zref2_conj = np.conj(Zref2)
199+
200+
# Common denominator
201+
denom = (Z0**2 + Zref1 * Zref2) * tanh_gamma_ell + Z0 * (Zref1 + Zref2)
202+
203+
# S11 with conjugate terms
204+
numerator_S11 = (Z0**2 - Zref1_conj * Zref2) * tanh_gamma_ell + Z0 * (Zref2 - Zref1_conj)
205+
S11 = numerator_S11 / denom
206+
207+
# S22 with conjugate terms
208+
numerator_S22 = (Z0**2 - Zref1 * Zref2_conj) * tanh_gamma_ell + Z0 * (Zref1 - Zref2_conj)
209+
S22 = numerator_S22 / denom
210+
211+
# S21 and S12 (transmission parameters)
212+
numerator_trans = 2 * Z0 * np.sqrt(np.real(Zref1) * np.real(Zref2))
213+
S21 = numerator_trans / (denom * cosh_gamma_ell)
214+
S12 = S21 # For reciprocal network
215+
216+
# Construct the S-parameter matrix (nfreq, 2, 2)
217+
nfreq = len(np.atleast_1d(S11))
218+
S_matrix = np.zeros((nfreq, 2, 2), dtype=complex)
219+
S_matrix[:, 0, 0] = S11
220+
S_matrix[:, 0, 1] = S12
221+
S_matrix[:, 1, 0] = S21
222+
S_matrix[:, 1, 1] = S22
223+
224+
return S_matrix
225+
226+
80227
def test_validate_no_sources(tmp_path):
81228
modeler = make_component_modeler(planar_pec=True, path_dir=str(tmp_path))
82229
source = td.PointDipole(
@@ -156,15 +303,25 @@ def test_run_component_modeler(monkeypatch, tmp_path):
156303

157304

158305
def test_s_to_z_component_modeler():
159-
# Test case is 2 port T network with reference impedance of 50 Ohm
306+
"""Test conversion of S parameters to impedance matrix,
307+
for a simple test case of 2 port T network with reference impedance of 50 Ohm
308+
309+
Network topology:
310+
Port1 ----[A]----+----[B]---- Port2
311+
|
312+
[C]
313+
|
314+
GND
315+
"""
160316
A = 20 + 30j
161317
B = 50 - 15j
162318
C = 60
163319

164-
Z11 = A + C
165-
Z21 = C
166-
Z12 = C
167-
Z22 = B + C
320+
Z = make_t_network_impedance_matrix(A, B, C)
321+
Z11 = Z[0, 0]
322+
Z21 = Z[1, 0]
323+
Z12 = Z[0, 1]
324+
Z22 = Z[1, 1]
168325

169326
Z0 = 50.0
170327
# Manual creation of S parameters Pozar Table 4.2
@@ -211,6 +368,53 @@ def test_s_to_z_component_modeler():
211368
assert np.isclose(z_matrix_at_f[1, 1], Z22)
212369

213370

371+
def test_complex_reference_s_to_z_component_modeler():
372+
"""Test conversion of S parameters to impedance matrix,
373+
for a test case of 2 port T network with complex reference impedances, which requires
374+
identifying the precise definition used for S parameters
375+
376+
Network topology:
377+
Port1 ----[A]----+----[B]---- Port2
378+
|
379+
[C]
380+
|
381+
GND
382+
"""
383+
A = np.array([0, 21 + 31j, 60])
384+
B = np.array([0, 51 - 16j, 40])
385+
C = np.array([50, 61, 0])
386+
387+
freqs = np.array([1e9, 2e9, 3e9])
388+
# Build Z for each frequency with different A, B, C
389+
Z = np.stack([make_t_network_impedance_matrix(a, b, c) for a, b, c in zip(A, B, C)], axis=0)
390+
# Choose some port reference impedances
391+
z0 = np.stack(3 * [np.array([50 + 5j, 60 - 2j])], axis=0)
392+
393+
skrf_S_50ohm = skrf.Network.from_z(z=Z, f=freqs)
394+
skrf_S_power = skrf.Network.from_z(z=Z, f=freqs, s_def="power", z0=z0)
395+
skrf_S_pseudo = skrf.Network.from_z(z=Z, f=freqs, s_def="pseudo", z0=z0)
396+
397+
ports = ["port1", "port2"]
398+
smatrix = TerminalPortDataArray(
399+
skrf_S_50ohm.s, coords={"f": freqs, "port_out": ports, "port_in": ports}
400+
)
401+
# Test real reference impedance calculations
402+
z_tidy3d = TerminalComponentModeler.s_to_z(smatrix, reference=50, S_def="power")
403+
assert np.all(np.isclose(z_tidy3d.values, Z))
404+
z_tidy3d = TerminalComponentModeler.s_to_z(smatrix, reference=50, S_def="pseudo")
405+
assert np.all(np.isclose(z_tidy3d.values, Z))
406+
407+
# Test complex reference impedance calculations
408+
z0_tidy3d = PortDataArray(data=z0, coords={"f": freqs, "port": ports})
409+
smatrix.values = skrf_S_power.s
410+
z_tidy3d = TerminalComponentModeler.s_to_z(smatrix, reference=z0_tidy3d, S_def="power")
411+
assert np.all(np.isclose(z_tidy3d.values, Z))
412+
413+
smatrix.values = skrf_S_pseudo.s
414+
z_tidy3d = TerminalComponentModeler.s_to_z(smatrix, reference=z0_tidy3d, S_def="pseudo")
415+
assert np.all(np.isclose(z_tidy3d.values, Z))
416+
417+
214418
def test_ab_to_s_component_modeler():
215419
coords = {
216420
"f": np.array([1e8]),
@@ -900,3 +1104,109 @@ def test_get_combined_antenna_parameters_data(monkeypatch, tmp_path):
9001104
assert not np.allclose(
9011105
antenna_params.radiation_efficiency, single_port_params.radiation_efficiency
9021106
)
1107+
1108+
1109+
def test_internal_construct_smatrix_with_port_vi(monkeypatch):
1110+
"""Test _internal_construct_smatrix method by monkeypatching compute_port_VI
1111+
with precomputed voltage and current values and comparing the final S-matrix to expected results.
1112+
"""
1113+
# Create a simple 2-port modeler for testing
1114+
modeler = make_component_modeler(planar_pec=False)
1115+
freqs = np.array([1e9, 5e9, 10e9])
1116+
modeler = modeler.updated_copy(freqs=freqs)
1117+
1118+
# Some test data from a 20 mm lossy microstrip
1119+
# Data is given using engineering convention exp(jwt)
1120+
length = 0.02
1121+
gamma = np.array(
1122+
[
1123+
35.845260386378 + 52.964956959149j,
1124+
48.283102945750 + 208.91753284900j,
1125+
50.594134809653 + 397.12168963974j,
1126+
]
1127+
)
1128+
Z0 = np.array(
1129+
[
1130+
18.725191534567 + 12.672421364213j,
1131+
34.038884625562 + 7.8654410284980j,
1132+
35.725175635077 + 4.5490999181327j,
1133+
]
1134+
)
1135+
# Break the reference impedance symmetry
1136+
Zref = np.column_stack((0.5 * Z0, 2 * Z0))
1137+
# Calculate analytical S matrices for power and pseudo wave formulations
1138+
S_pseudo = calc_transmission_line_S_matrix_pseudo(Z0, Zref[:, 0], Zref[:, 1], gamma, length)
1139+
S_power = calc_transmission_line_S_matrix_power(Z0, Zref[:, 0], Zref[:, 1], gamma, length)
1140+
1141+
# Calculate A and B matrices where A is diagonal and B = S @ A
1142+
A = np.tile(np.eye(2), (len(freqs), 1, 1)) # Identity matrix for each frequency
1143+
B = S_pseudo @ A
1144+
# Now get Voltages and Currents at each port due to excitations from each port
1145+
Vscale = np.abs(Zref[:, :, np.newaxis]) / np.sqrt(np.real(Zref[:, :, np.newaxis]))
1146+
Iscale = Vscale / Zref[:, :, np.newaxis]
1147+
voltages = Vscale * (A + B) # (f x port_out x port_in)
1148+
currents = Iscale * (A - B) # (f x port_out x port_in)
1149+
1150+
port_names = [port.name for port in modeler.ports]
1151+
1152+
# Create mock batch data
1153+
batch_data = {}
1154+
for j, port_in in enumerate(modeler.ports):
1155+
task_name = modeler._task_name(port_in)
1156+
batch_data[task_name] = {}
1157+
for i, port_out in enumerate(modeler.ports):
1158+
# Initialize with zeros - user should replace with actual values
1159+
batch_data[task_name][port_out.name] = {
1160+
"voltage": FreqDataArray(voltages[:, i, j], coords={"f": freqs}),
1161+
"current": FreqDataArray(currents[:, i, j], coords={"f": freqs}),
1162+
}
1163+
1164+
# Mock the compute_port_VI method
1165+
def mock_compute_port_vi(port_out, sim_data):
1166+
"""Mock compute_port_VI to return voltage and current from dummy sim_data."""
1167+
port_name = port_out.name
1168+
voltage = sim_data[port_name]["voltage"]
1169+
current = sim_data[port_name]["current"]
1170+
return voltage, current
1171+
1172+
# Mock port reference impedances to return constant Z0
1173+
def mock_port_impedances(self, batch_data):
1174+
coords = {"f": np.array(freqs), "port": port_names}
1175+
return PortDataArray(Zref, coords=coords)
1176+
1177+
# Apply monkeypatches
1178+
monkeypatch.setattr(
1179+
TerminalComponentModeler, "compute_port_VI", staticmethod(mock_compute_port_vi)
1180+
)
1181+
monkeypatch.setattr(
1182+
TerminalComponentModeler, "_port_reference_impedances", mock_port_impedances
1183+
)
1184+
1185+
# Test the _internal_construct_smatrix method
1186+
S_computed = modeler._internal_construct_smatrix(batch_data).values
1187+
1188+
def check_S_matrix(S_computed, S_expected, tol=1e-12):
1189+
# Check that S-matrix has correct shape
1190+
assert S_computed.shape == (len(freqs), len(port_names), len(port_names))
1191+
1192+
# Compare computed S-matrix with analytical values at each frequency
1193+
for freq_idx in range(len(freqs)):
1194+
S_computed_at_freq = S_computed[freq_idx, :, :]
1195+
S_expected_at_freq = S_expected[freq_idx, :, :]
1196+
max_rel_err = np.max(
1197+
np.abs((S_computed_at_freq - S_expected_at_freq) / (S_expected_at_freq + 1e-14))
1198+
)
1199+
assert np.allclose(S_computed_at_freq, S_expected_at_freq, rtol=tol, atol=tol), (
1200+
f"S-matrix mismatch at frequency index {freq_idx}\n"
1201+
f"Expected:\n{S_expected_at_freq}\n"
1202+
f"Computed:\n{S_computed_at_freq}\n"
1203+
f"Difference:\n{S_computed_at_freq - S_expected_at_freq}\n"
1204+
f"Max relative error: {max_rel_err:.2e}"
1205+
)
1206+
1207+
# Check pseudo wave S matrix
1208+
check_S_matrix(S_computed, S_pseudo)
1209+
1210+
# Check power wave S matrix
1211+
S_computed = modeler._internal_construct_smatrix(batch_data, S_def="power").values
1212+
check_S_matrix(S_computed, S_power)

tidy3d/components/monitor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@ class AbstractModeMonitor(PlanarMonitor, FreqMonitor):
352352
"primal grid nodes).",
353353
)
354354

355+
conjugated_dot_product: bool = pydantic.Field(
356+
True,
357+
title="Conjugated Dot Product",
358+
description="Use conjugated or non-conjugated dot product for mode decomposition.",
359+
)
360+
355361
def plot(
356362
self,
357363
x: Optional[float] = None,

0 commit comments

Comments
 (0)