scikit-rf: S-parameter calculation incorrect for 3-port circuit

Bug S-parameter calculation is incorrect for a 3-port circuit

Procedure to reproduce the bug Take the Wilkinson circuit available on scikit-rf website: https://scikit-rf.readthedocs.io/en/latest/examples/circuit/Wilkinson Power Splitter.html

Copy the code and remove the resistor, which gives a Tee circuit with impedance matching:

# standard imports
import numpy as np
import matplotlib.pyplot as plt
import skrf as rf
rf.stylely()

# frequency band
freq = rf.Frequency(start=0, stop=2, npoints=501, unit='GHz')

# characteristic impedance of the ports
z0_ports = 50

# resistor
R = 100
line_resistor = rf.media.DefinedGammaZ0(frequency=freq, z0=R)
resistor = line_resistor.resistor(R, name='resistor')

# branches
z0_branches = np.sqrt(2)*z0_ports
beta = freq.w/rf.c
line_branches = rf.media.DefinedGammaZ0(frequency=freq, z0=z0_branches, gamma=0+beta*1j)

d = line_branches.theta_2_d(90, deg=True)  # @ 90°(lambda/4)@ 1 GHz is ~ 75 mm
branch1 = line_branches.line(d, unit='m', name='branch1')
branch2 = line_branches.line(d, unit='m', name='branch2')

# ports
port1 = rf.Circuit.Port(freq, name='port1', z0=50)
port2 = rf.Circuit.Port(freq, name='port2', z0=50)
port3 = rf.Circuit.Port(freq, name='port3', z0=50)

# Connection setup
#┬Note that the order of appearance of the port in the setup is important
connections = [
           [(port1, 0), (branch1, 0), (branch2, 0)],
           [(port2, 0), (branch1, 1)],
           [(port3, 0), (branch2, 1)]
        ]

# Building the circuit
C = rf.Circuit(connections)

fig, (ax1,ax2) = plt.subplots(2, 1, sharex=True)
C.network.plot_s_db(ax=ax1, m=0, n=0,  lw=2)  # S11
C.network.plot_s_db(ax=ax1, m=1, n=1,  lw=2)  # S22
ax1.set_ylim(-90, 0)
C.network.plot_s_db(ax=ax2, m=1, n=0,  lw=2)  # S21
C.network.plot_s_db(ax=ax2, m=2, n=0,  ls='--', lw=2)  # S31
ax2.set_ylim(-4, 0)
fig.suptitle('Ideal Wilkinson Divider @ 1 GHz')

Simply execute the code and plot the S-parameters

Expected behavior S21 and S31 should be at -3dB at 1 GHz (middle of the pass-band) because the circuit is now a simple tee, so the power from port 1 is equally split between ports 2 and 3. This behavior was confirmed with a simulation on ADS.

Observed behavior S21 and S31 are -1.5 dB at 1 GHz.

System

  • OS: Windows
  • scikit-rf version: 0.31.0

Additional context I am trying to design Wilkinsons with different port impedances. I obtained various strange/wrong S-parameter results with scikit-rf (sometimes S21 and S31 are at -0.8 dB, sometimes at -4.1 dB, for designs where ADS gives -3 dB). The procedure given here is the simplest I found to show the problem.

About this issue

  • Original URL
  • State: closed
  • Created 5 months ago
  • Comments: 46 (9 by maintainers)

Most upvoted comments

For $S_{ij}$ it should be remembered that it is a power divider. The remaining power that go through after the reflection is split according to the port’s impedances (e.g. if they are equal the power is split in equal parts).

$$ 1 = S_{ii}^2 + \sum_{j=1…n} S_{ij}^2 $$

The power that remains to be split is

$$ \sum_{j=1…n} S_{ij}^2 = 1 - S_{ii}^2 $$

Which is satisfied by

$$ S_{ij} = \frac{2\sqrt{Y_i \cdot Y_j}}{\sum_{k=1…n} Y_{k}} $$

The case of $S_{ii}$ is quite trivial, the port has a mismatch with the equivalent impedance of the other port in parallel, which is more easily computed in terms of admittance.

$$ S_{ii} = \frac{Y_i - \sum_{j\neq i} Y_j}{Y_i + \sum_{j\neq i} Y_j} = \frac{2Y_i}{\sum_{j=1…n} Y_j} - 1 $$

Which is coherent with rf.Circuit # formula (1)

Great! So it confirms that the equation (3) is incorrect in the generalized case of different impedances.

I was working in parallel on this too 😉 I made a new implementation of renormalize_s which reduces the numerical noise when renormalizing the S-param of a N-port junction. But it only supports power waves.

Non-efficient code:

def _Xk(self, cnx_k: list[tuple]) -> np.ndarray:
        """
        Return the scattering matrices [X]_k of the individual intersections k.

        Parameters
        ----------
        cnx_k : list of tuples
            each tuple contains (network, port)

        Returns
        -------
        Xs : :class:`numpy.ndarray`
            shape `f x n x n`
        """
        # Xnn = self._Xnn_k(cnx_k)  # shape: (nb_freq, nb_n)
        # Xmn = self._Xmn_k(cnx_k)  # shape: (nb_freq, nb_n)
        # # for loop version
        # Xs = []
        # for (_Xnn, _Xmn) in zip(Xnn, Xmn):  # for all frequencies
        #       # repeat Xmn along the lines
        #     _X = np.tile(_Xmn, (len(_Xmn), 1))
        #     _X[np.diag_indices(len(_Xmn))] = _Xnn
        #     Xs.append(_X)

        # return np.array(Xs) # shape : nb_freq, nb_n, nb_n

        # vectorized version
        # nb_n = Xnn.shape[1]
        # Xs = np.tile(Xmn, (nb_n, 1, 1)).swapaxes(1, 0)
        # Xs[:, np.arange(nb_n), np.arange(nb_n)] = Xnn

        # return Xs # shape : nb_freq, nb_n, nb_n

        # TEST : Could we use media.splitter() instead ? -> does not work
        # _media = media.DefinedGammaZ0(frequency=self.frequency)
        # Xs = _media.splitter(len(cnx_k), z0=self._cnx_z0(cnx_k))
        # return Xs.s
        n_port = len(cnx_k)
        s = np.zeros((self.frequency.npoints, n_port, n_port), dtype = complex)
        y_k = self._Y_k(cnx_k)
        # sii
        for i in range(n_port):
            (ntw_i, port_i) = cnx_k[i]
            s[:,i,i] = 2. / (ntw_i.z0[:,port_i] * y_k) - 1
            #sji
            for j in range(n_port):
                if j != i:
                    (ntw_j, port_j) = cnx_k[j]
                    y_eq = np.sqrt((1./ntw_i.z0[:,port_i]) \
                                   * (1./ntw_j.z0[:,port_j]))
                    s[:,j,i] = 2. *y_eq / y_k
                    s[:,i,j] = s[:,j,i]
        return s

Let’s take the 50 ohm 2-port splitter. It is lossless, reciprocal and matched. As a matter of fact, it is just a zero-length thru. The scattering parameters are:

$$ S = \begin{bmatrix} 0 & 1\ 1 & 0\ \end{bmatrix} $$

Which are normalized to the port impedance:

$$Z_1 = 50, Z_2 = 50$$

The impedance parameters do not need to normalize to a port impedance, we can convert to them like skrf.network.s2z. Behind the hood, when using renormalize_s, scikit-rf just do z2s(s2z()) with z_old and z_new.

$$ Z = \begin{bmatrix} \frac{1}{2\sqrt{|\Re(Z_1)|}} & 0 \ 0 & \frac{1}{2\sqrt{|\Re(Z_2)|}} \end{bmatrix}^{-1} \left(1 - S \right)^{-1} \left(S \begin{bmatrix}Z_1 & 0 \ 0 & Z_2 \end{bmatrix} + \begin{bmatrix}Z_1 & 0 \ 0 & Z_2 \end{bmatrix}^{*} \right) \begin{bmatrix} \frac{1}{2\sqrt{|\Re(Z_1)|}} & 0 \ 0 & \frac{1}{2\sqrt{|\Re(Z_2)|}} \end{bmatrix} $$

Which gives

$$ Z = \left(\left(\begin{bmatrix} 1 & 0 \ 0 & 1 \end{bmatrix} - \begin{bmatrix} 0 & 1 \ 1 & 0 \end{bmatrix} \right) \begin{bmatrix} 2\sqrt{50} & 0 \ 0 & 2\sqrt{50} \end{bmatrix}\right)^{-1} \left(\begin{bmatrix} 0 & 1 \ 1 & 0 \end{bmatrix} \begin{bmatrix}50 & 0 \ 0 & 50 \end{bmatrix} + \begin{bmatrix}50 & 0 \ 0 & 50 \end{bmatrix} \right) \begin{bmatrix} \frac{1}{2\sqrt{50}} & 0 \ 0 & \frac{1}{2\sqrt{50}} \end{bmatrix} $$

$$ Z = \begin{bmatrix} 2\sqrt{50} & -2\sqrt{50} \ -2\sqrt{50} & 2\sqrt{50} \end{bmatrix}^{-1} \begin{bmatrix} \frac{\sqrt{50}}{2} & \frac{\sqrt{50}}{2} \ \frac{\sqrt{50}}{2} &\frac{\sqrt{50}}{2} \end{bmatrix} $$

And here tadaaa, the left matrix is singular and cannot be inverted ! s2z will still return a result because this matrix is added a small offset by nudge to avoid singularity and gives a matrix with big numbers. Hence probably the loss of precision when using z2s on this result.

I think the z0_port=None provided a kind of backward compatibility. In #651 , a group of users who did electromagnetic simulations were interested in raw impedance. Most users however probably think in terms of 50-ohm measurements.

What is strange to me in the above example is that the transmission line networks are not renormalized when connected to the rf.Circuit.Port impedances. In ADS, the “port” component explicitly solves the impedance problem in S-Parameter simulations.

The issue that we mostly face in scikit-rf is that we want to plot a line network as if has been measured, without cascading explicitly with ports. This is not the case in this example with rf.Circuit.

There is two open questions: what should z0_port default be and why the above example does not renormalize when z0_port=None.