Note
Go to the end to download the full example code.
Exciting both ports: the full S-matrix#
The lines simulated so far were uniform and matched, so a single
excitation told the whole story. This tutorial puts a real
discontinuity into the coaxial line of the
previous tutorial — a short air gap in the
dielectric — and asks the questions that need the complete
scattering matrix: how much reflects from each side, and is the
device symmetric and reciprocal? One run call with both ports
excited delivers the full matrix; a three-section transmission-line
cascade provides the closed-form reference.
The problem#
Take the RG-58 class line from the previous tutorial and replace the middle 4 mm of polyethylene by air. The air-filled section has a higher line impedance (the \(\ln(r_o/r_i)\) is the same, but \(\sqrt{\varepsilon_r}\) drops from 1.5 to 1), so the wave sees two impedance steps, 4 mm apart. Each step reflects; the two reflections interfere. Transmission-line theory turns this into a closed form: cascade the ABCD matrices of the three sections and convert to S-parameters — our reference for the whole band.
A two-port has four S-parameters \(S_{ij}\), read “output i, input j”: \(S_{11}\) and \(S_{21}\) describe excitation at port 1, \(S_{22}\) and \(S_{12}\) excitation at port 2. A time-domain solver computes one excitation per run — so the full matrix of a 2-port needs two runs. You only have to say so; the analysis runs them back to back and merges the results.
import math
import tempfile
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import magnelio as mio
from magnelio import geo, ports
from magnelio.constants import *
r_i = 0.405e-3 # inner conductor radius [m]
r_o = 1.475e-3 # shield radius [m]
eps_r = 2.25 # polyethylene
L_pe = 4e-3 # each polyethylene section [m]
L_gap = 4e-3 # the air gap [m]
L = 2 * L_pe + L_gap
f_max = 6e9
Geometry: three dielectric sections#
Same construction as before — annuli around a continuous inner
conductor, PEC background as the shield — except that the dielectric
now comes in three axial sections. A loop over (start, length,
material) triples keeps the construction readable; this pattern
scales to structures with many more sections than three.
# Built-in materials can be named by string ("pec", "air", "vacuum");
# only parameterised materials need explicit construction.
pe = mio.Material.from_isotropic(name="polyethylene", epsilon=eps_r)
model = mio.GeometryModel(background="pec")
inner = geo.Cylinder(origin=(0, 0, 0), radius=r_i, height=L, axis="z", material="pec")
sections = [(0.0, L_pe, pe), (L_pe, L_gap, "air"), (L_pe + L_gap, L_pe, pe)]
for z0, length, mat in sections:
outer = geo.Cylinder(origin=(0, 0, z0), radius=r_o, height=length, axis="z", material=mat)
model.add(geo.Difference(outer, inner))
model.add(inner)
model.add_port(
ports.PortAnalytical(
name="port1",
plane="zmin",
family="coax",
inner_radius=r_i,
outer_radius=r_o,
epsilon_r=eps_r,
)
)
model.add_port(
ports.PortAnalytical(
name="port2",
plane="zmax",
family="coax",
inner_radius=r_i,
outer_radius=r_o,
epsilon_r=eps_r,
)
)
mesh = mio.Mesh.from_geometry(model, mio.MeshControl(max_cell_size=0.12e-3), f_max=f_max)
print(f"grid: {mesh.Nx} x {mesh.Ny} x {mesh.Nz} cells")
mesh | feature planes
mesh | grid lines
mesh | materials
mesh | conformal cells
mesh | PEC masks
mesh | 25 x 25 x 102 cells
grid: 25 x 25 x 102 cells
Two excitations, one call#
excited lists what to launch: bare port names mean “fundamental
mode”. The analysis performs one time-domain run per entry and
merges everything into a single result that answers for every
\(S_{ij}\).
# f_max is omitted: the analysis band defaults to the design frequency
# the mesh was generated for (``mesh.f_max``).
analysis = mio.AnalysisScatteringTD(mesh=mesh, verbose=False)
result = analysis.run(excited=["port1", "port2"])
The quickest look is the built-in plot, which draws any selection of \(S_{ij}\) channels in one call:
fig, ax = result.plot_s(("port1", "port1"), ("port2", "port1"), ("port1", "port2"))
# S12 and S21 coincide to plotting accuracy -- that is reciprocity,
# checked numerically below; dash one so both stay visible.
for line in ax.lines:
if line.get_label().startswith("S(port1 ←") and "port2" in line.get_label():
line.set_linestyle("--")
ax.legend()
ax.set_title("Coax line with a 4 mm air gap")

Text(0.5, 1.0, 'Coax line with a 4 mm air gap')
Reading the matrix: symmetry and reciprocity#
Two structural statements can be checked immediately. The device is geometrically symmetric (the gap sits in the middle), so \(S_{22} = S_{11}\). And like every passive structure made of reciprocal materials it must be reciprocal: \(S_{12} = S_{21}\) — regardless of any symmetry. Both hold to numerical precision:
max |S12 - S21| (reciprocity): 3.66e-07
max |S11 - S22| (symmetry) : 1.92e-07
In practice these identities are cheap health checks: a broken reciprocity points at a solver or normalisation problem, a broken symmetry (in a geometrically symmetric model) at a meshing artefact.
Against transmission-line theory#
The analytic reference: three line sections in cascade, with the ideal impedances of the circular cross-section (\(Z_\mathrm{PE} = 51.7\,\Omega\), \(Z_\mathrm{air} = 77.5\,\Omega\)).
f = result.f_axis
z_pe = ETA0 / (2 * math.pi * math.sqrt(eps_r)) * math.log(r_o / r_i)
z_air = ETA0 / (2 * math.pi) * math.log(r_o / r_i)
def abcd_line(z, beta, length):
c, s = np.cos(beta * length), np.sin(beta * length)
return np.array([[c, 1j * z * s], [1j * s / z, c]])
s11_tl = np.empty(f.size, complex)
for k, fk in enumerate(f):
w = 2 * np.pi * fk
m = (
abcd_line(z_pe, w * math.sqrt(eps_r) / C0, L_pe)
@ abcd_line(z_air, w / C0, L_gap)
@ abcd_line(z_pe, w * math.sqrt(eps_r) / C0, L_pe)
)
a, b, c, d = m[0, 0], m[0, 1], m[1, 0], m[1, 1]
s11_tl[k] = (a + b / z_pe - c * z_pe - d) / (a + b / z_pe + c * z_pe + d)
fig, ax = plt.subplots()
ax.plot(f / 1e9, 20 * np.log10(np.abs(s11)), label="|S11| simulated")
ax.plot(f / 1e9, 20 * np.log10(np.abs(s11_tl)), "--", label="|S11| line theory")
ax.set_xlabel("frequency [GHz]")
ax.set_ylabel("magnitude [dB]")
ax.set_title("Reflection at the air gap vs. cascade model")
ax.grid(True)
ax.legend()
print(f"max ||S11|sim - |S11|theory|: {np.abs(np.abs(s11) - np.abs(s11_tl)).max():.1e}")

max ||S11|sim - |S11|theory|: 2.6e-04
The two curves agree to better than 0.001 in magnitude across the band. Note why the agreement is this good even though the previous tutorial showed the staircased impedance a few percent off the ideal value: both sections share the same staircased cross-section, so the impedance ratio between them — which is what sets the reflection — is almost exactly the ideal one. Errors that are common to both sides of a comparison cancel; S-parameters are full of such benign cancellations, and knowing about them is the difference between distrusting every percent and knowing where the percents matter.
Exporting the matrix#
S-parameter results leave Magnelio in standard formats.
to_touchstone writes the industry-standard .s2p file that
every circuit simulator reads; to_skrf hands the live matrix to
scikit-rf if it is installed.
The file holds one port per excited channel — here the two ports at
mode 0 — and the .sNp extension has to agree with that count,
because Touchstone records it nowhere else. Leave the extension off
and it is filled in for you.
s2p = Path(tempfile.mkdtemp()) / "coax_gap.s2p"
result.to_touchstone(s2p)
print(s2p.read_text().splitlines()[0])
print(f"... {len(s2p.read_text().splitlines())} lines")
! magnelio S-parameter export (power-wave S-parameters)
... 207 lines
Where to go next#
New in this tutorial: multiple excitations in one run call, the
\(S_{ij}\) bookkeeping, reciprocity/symmetry as built-in sanity
checks, a transmission-line cascade as reference, and the Touchstone
export. The next tutorial raises the frequency until the line
carries more than one mode — and the S-matrix gains mode indices on
top of the port indices.
Total running time of the script: (0 minutes 7.758 seconds)