Notebooks/RF Amplifier Analysis — rfplot, Stability, Gain, Polish

RF Amplifier Analysis — rfplot, Stability, Gain, Polish

This is the second half of the rustlab RF S-parameter walkthrough. The companion notebook sparameters_intro.md covered loading, conversions, cascading, and Smith charts. Here we focus on what a measurement actually tells you about a 2-port device: the standard frequency-domain review panel, port-level metrics (VSWR, return / insertion loss), stability assessment via Rollett K and µ-parameters, simultaneous- conjugate-match design with Γms / Γml, gain limits (MAG / MSG), and graphical stability / gain analysis via circles on the Smith chart.

The final section covers the Phase 6 polish features: frequency-grid interpolation for cross-sweep cascading, time-domain reflectometry via IFFT, Touchstone noise-parameter access, and mixed-mode 4-port conversion for differential designs.

Setup — the same synthetic LNA

We start from the same hand-built 2-port LNA as the intro notebook so the numerical results are comparable:

rustlab
clf
f = [1e9, 2e9, 3e9, 4e9, 5e9, 6e9];
mag = [0.50  2.50  0.05  0.40;
       0.45  3.00  0.06  0.38;
       0.40  3.20  0.07  0.36;
       0.38  3.10  0.08  0.34;
       0.36  2.80  0.09  0.32;
       0.34  2.40  0.10  0.30];
ang_deg = [ 150  -45   90  170;
            140  -60   85  165;
            130  -75   80  160;
            120  -90   75  155;
            110 -105   70  150;
            100 -120   65  145];
Sc = mag .* exp(j * ang_deg * pi/180);   % columns: S11, S21, S12, S22
S = zeros3(6, 2, 2);
S(:, 1, 1) = Sc(:, 1);
S(:, 2, 1) = Sc(:, 2);
S(:, 1, 2) = Sc(:, 3);
S(:, 2, 2) = Sc(:, 4);
s = sparameters(S, f);
disp(s)
sparameters: 2-port S, 6 frequencies [1 GHz .. 6 GHz], Z0 = 50 Ω

The 2×2 review panel — rfplot(s)

The default rfplot call gives you the canonical 2×2 "what did the VNA measure" panel: |S11| and |S21| on the top row, |S12| and |S22| on the bottom, all in dB on a log-frequency axis. This is the standard RF first-look.

rustlab
clf
rfplot(s)

Layout convention matches every commercial RF tool:

|S11| (input return loss)|S21| (forward gain)
|S12| (reverse isolation)|S22| (output return loss)

For non-2-port networks the same call falls back to a single |S11| dB trace; the multi-trace 2×2 layout is specifically the 2-port review.

Single-trace forms — phase, unwrap, group delay

Five single-trace variants pull out specific quantities:

rustlab
clf
rfplot(s, "db", 2, 1)
title("Forward gain |S21| in dB")
rustlab
clf
rfplot(s, "phase", 2, 1)
title("S21 phase (wrapped, degrees)")

For a delay-line or amplifier with non-trivial phase response, wrapped phase is hard to read. The "unwrap" form removes the ±2π jumps:

rustlab
clf
rfplot(s, "unwrap", 2, 1)
title("S21 unwrapped phase (degrees)")

And group delay \(\tau_g = -d\varphi/d\omega\) is the natural metric for linearity / dispersion:

rustlab
clf
rfplot(s, "groupdelay", 2, 1)
title("S21 group delay (s)")

Group delay uses central differences on the unwrapped phase with forward/backward differences at the endpoints. Constant group delay across the band of interest indicates a linear-phase response.

Port-level metrics

Three quick scalar metrics per frequency: voltage standing-wave ratio at each port, return loss, and insertion loss between any two ports.

rustlab
v1  = vswr(s, 1);
v2  = vswr(s, 2);
rl1 = return_loss(s, 1);
rl2 = return_loss(s, 2);
il  = insertion_loss(s, 2, 1);

fprintf("%-6s  %7s  %7s  %7s  %7s\n", ...
        "f/GHz", "VSWR1", "VSWR2", "RL1/dB", "IL/dB")
for k = 1:len(f)
  fprintf("%-6.2f  %7.2f  %7.2f  %7.2f  %7.2f\n", ...
          f(k)/1e9, v1(k), v2(k), rl1(k), il(k))
end
f/GHz     VSWR1    VSWR2   RL1/dB    IL/dB
1.00       3.00     2.33     6.02    -7.96
2.00       2.64     2.23     6.94    -9.54
3.00       2.33     2.12     7.96   -10.10
4.00       2.23     2.03     8.40    -9.83
5.00       2.12     1.94     8.87    -8.94
6.00       2.03     1.86     9.37    -7.60

vswr caps at \(10^6\) for fully-reflecting ports (|S| → 1); return loss floors at 200 dB for matched ports (|S| → 0). Both keep the results plottable and finite.

Stability — Rollett K and µ-parameters

A 2-port is unconditionally stable at a given frequency if and only if both of these hold:

  • \(K > 1\) where \(K = \dfrac{1 - |S_{11}|^2 - |S_{22}|^2 + |\Delta|^2}{2\,|S_{12}\,S_{21}|}\) is Rollett's stability factor and \(\Delta = S_{11}\,S_{22} - S_{12}\,S_{21}\).
  • \(|\Delta| < 1\).

Equivalently, a single-number test exists: \(\mu_1 > 1\), where \(\mu_1 = \dfrac{1 - |S_{11}|^2}{|S_{22} - \Delta\,S_{11}^*| + |S_{12}\,S_{21}|}\).

stabilitymu returns both µ1 and µ2; either > 1 is necessary and sufficient for unconditional stability.

rustlab
K  = stabilityk(s);
[m1, m2] = stabilitymu(s);
fprintf("%-6s  %6s  %6s  %6s\n", "f/GHz", "K", "mu1", "mu2")
for k = 1:len(f)
  fprintf("%-6.2f  %6.2f  %6.2f  %6.2f\n", f(k)/1e9, K(k), m1(k), m2(k))
end
f/GHz        K     mu1     mu2
1.00      2.57    1.72    1.52
2.00      1.96    1.57    1.47
3.00      1.71    1.50    1.45
4.00      1.61    1.47    1.42
5.00      1.63    1.50    1.46
6.00      1.75    1.60    1.54

K > 1 and µ1 > 1 across the entire 1–6 GHz band — this amplifier is unconditionally stable, so the conjugate-match design that follows is valid.

Maximum available gain (MAG) and the conjugate match

For an unconditionally-stable amplifier, the optimum source and load terminations satisfying \(\Gamma_{\text{in}}(\Gamma_L) = \Gamma_S^*\) and \(\Gamma_{\text{out}}(\Gamma_S) = \Gamma_L^*\) exist in closed form:

\[\Gamma_{ms} = \frac{B_1 - \text{sign}(B_1)\sqrt{B_1^2 - 4|C_1|^2}}{2\,C_1}\]

where \(B_1 = 1 + |S_{11}|^2 - |S_{22}|^2 - |\Delta|^2\) and \(C_1 = S_{11} - \Delta\,S_{22}^*\). Γml has the symmetric form with ports swapped. With those terminations, the transducer power gain equals the maximum available gain:

\[\text{MAG} = \frac{|S_{21}|}{|S_{12}|}\left(K - \sqrt{K^2 - 1}\right)\]

For K ≤ 1 (potentially unstable), MAG is undefined and gainmax returns instead the maximum stable gain \(\text{MSG} = |S_{21}/S_{12}|\).

rustlab
gms = gammams(s);
gml = gammaml(s);
mag_db = gainmax(s);
fprintf("%-6s  %16s  %16s  %7s\n", ...
        "f/GHz", "Γms", "Γml", "MAG/dB")
for k = 1:len(f)
  fprintf("%-6.2f  %6.3f∠%5.1f°    %6.3f∠%5.1f°    %7.2f\n", ...
          f(k)/1e9, ...
          abs(gms(k)), angle(gms(k))*180/pi, ...
          abs(gml(k)), angle(gml(k))*180/pi, ...
          mag_db(k))
end
f/GHz                Γms               Γml   MAG/dB
1.00     0.524∠-156.7°     0.430∠178.5°      10.06
2.00     0.499∠-149.6°     0.437∠-179.1°      11.38
3.00     0.473∠-141.9°     0.439∠-174.9°      11.70
4.00     0.468∠-132.3°     0.436∠-170.3°      11.32
5.00     0.450∠-121.6°     0.418∠-164.5°      10.29
6.00     0.421∠-110.2°     0.388∠-158.0°       8.78

Both Γms and Γml lie inside the unit disk (their magnitudes < 1) — expected for an unconditionally-stable network. MAG ranges from about 8 dB at the band edges to 12 dB at the peak around 3 GHz.

Reflection coefficient with termination

gammain(s, Γ_load) and gammaout(s, Γ_source) compute the input or output reflection for a given termination. The defining identity for the simultaneous conjugate match is that

\[\Gamma_{\text{in}}(\Gamma_{ml}) = \Gamma_{ms}^* \quad \text{and} \quad \Gamma_{\text{out}}(\Gamma_{ms}) = \Gamma_{ml}^*\]

gammain accepts a scalar (broadcast across frequencies) or a per- frequency vector matching n_freqs.

rustlab
gin_check = gammain(s, gml);
gout_check = gammaout(s, gms);
fprintf("Γin(Γml) - conj(Γms) at 3 GHz: %.2e\n", ...
        abs(gin_check(3) - conj(gms(3))))
fprintf("Γout(Γms) - conj(Γml) at 3 GHz: %.2e\n", ...
        abs(gout_check(3) - conj(gml(3))))
Γin(Γml) - conj(Γms) at 3 GHz: 2.36e-16
Γout(Γms) - conj(Γml) at 3 GHz: 1.76e-16

Both diffs come out at machine precision — the identity holds exactly.

Stability circles on the Smith chart

Even for unconditionally-stable networks it's useful to visualize the stability boundary: the locus of source (or load) reflections at which the other port's reflection touches the unit disk. stability_circles returns a tagged struct containing per-frequency centre and radius vectors; render them with smith_circle() in a loop:

rustlab
clf
smith(s)
marker(0, "matched")

in_circles = stability_circles(s, "input");
cs = in_circles.centres;
rs = in_circles.radii;
for k = 1:len(f)
  smith_circle(cs(k), real(rs(k)))
end
title("S11, S22 with input stability circles overlaid")

For this LNA the input stability circles lie entirely outside the unit disk — the geometric witness of unconditional stability. (If a circle crossed into the unit disk, sources with reflections inside that intersection would drive |Γout| > 1 at that frequency, i.e. oscillation.)

Constant-gain circles

gain_circles(s, gain_db) returns the locus of load reflections that achieve a specified operating-power gain. Useful for amplifier matching design: pick an operating gain a couple of dB below MAG, draw the circle, and any load termination on that locus delivers that gain (trading off bandwidth, noise figure, etc.).

rustlab
clf
smith(s)
marker(0, "matched")

% At 3 GHz (MAG ≈ 11.7 dB), draw circles at 10 dB and 11 dB.
% (Stash struct fields in scalars first — `struct.field(k)` parses as a
% call to a function named `field`, not as field-access + index.)
g10 = gain_circles(s, 10);
g11 = gain_circles(s, 11);
c10 = g10.centres;  r10 = g10.radii;
c11 = g11.centres;  r11 = g11.radii;
smith_circle(c10(3), real(r10(3)), "10 dB")
smith_circle(c11(3), real(r11(3)), "11 dB")
title("Gain circles at 3 GHz (MAG ≈ 11.7 dB)")

As the requested gain approaches MAG the circle shrinks to a single point at Γml; gains beyond MAG return NaN radii (no real solution).

Phase 6 — polish features

The remaining features cover four common practical needs.

Cross-grid cascading via interp_freq

When two measurements live on different VNA sweeps, you can't cascade them directly — the frequency grids must match. interp_freq resamples linearly onto a new monotonic grid:

rustlab
% Resample the LNA onto a finer 200 MHz grid (26 points across 1–6 GHz):
f_fine = linspace(1e9, 6e9, 26);
s_fine = interp_freq(s, f_fine);
fprintf("Original: %d points,  Interpolated: %d points\n", ...
        len(freqs(s)), len(freqs(s_fine)))
fprintf("|S21| at 2.5 GHz (interp): %.2f dB\n", ...
        mag2db(abs(s21(s_fine)(8))))
Original: 6 points,  Interpolated: 26 points
|S21| at 2.5 GHz (interp): 9.70 dB

Extrapolation past the original sweep range is rejected with a clear error — RF data is bandlimited; extrapolated S-parameters are garbage.

Time-domain reflectometry — s2td

s2td(s, i, j) IFFTs a single Sij(f) trace into the time domain. Useful for TDR (locating impedance discontinuities along a cable) and impulse- response inspection. The frequency grid must be uniform — that's exactly what interp_freq produces.

rustlab
clf
[t, step] = s2td(s_fine, 2, 1, "step");
plot(t * 1e9, step)
title("S21 step response (TDR-style)")
xlabel("Time (ns)")
ylabel("Step response")
grid on

Two modes available: "step" (default, integrates the impulse response — the standard TDR view) and "impulse" (the raw impulse response). The internal IFFT uses a conjugate-symmetric 2N-point spectrum so the time- domain signal is real, zero-padded to the next power of two for finer time resolution.

Touchstone noise parameters

Many real .s2p files include a noise-parameter block after the S-parameter rows: five columns per row with frequency, \(F_{\min}\) in dB, \(|\Gamma_{\text{opt}}|\), ∠Γopt in degrees, and the normalised equivalent noise resistance \(R_n / Z_0\).

The reader picks them up automatically when present and attaches them as extra fields on the sparameters struct. The LNA in this notebook is built from S only, so it has no noise block. A file that does, such as examples/rf/data/lna_with_noise.s2p, loads with sparameters(path) from rustlab run (a notebook jails paths to the notebook directory). The Touchstone writer emits S only; a noise block is not round-tripped.

rustlab
if has_noise(s)
  fprintf("has_noise(s): yes\n")
else
  fprintf("has_noise(s): no (this synthesised network has no noise block)\n")
end
has_noise(s): no (this synthesised network has no noise block)

When loading a real noise-bearing .s2p, the accessor builtins return the per-noise-frequency vectors:

text
nfmin(s_noise)      → real Vector, dB
gamma_opt(s_noise)  → complex Vector
rn(s_noise)         → real Vector (Rn/Z0)
noise_freqs(s_noise) → real Vector, Hz
has_noise(s_noise)  → Bool

The noise frequencies need not match the S grid — VNAs commonly sweep many fewer noise points than S points.

Mixed-mode for 4-port differential designs

For high-speed differential designs (SerDes, USB, PCIe, RF balanced amps) the single-ended 4-port S-parameter matrix isn't the natural representation — you want differential / common-mode (Sdd, Sdc, Scd, Scc) instead. s2smm transforms a 4-port single-ended network to its mixed-mode form via the standard Bockelman/Eisenstadt orthogonal transform; smm2s is its inverse.

Port pairing convention: ports 1 (positive) and 3 (negative) form differential pair 1; ports 2 and 4 form differential pair 2. The result is organised as the block matrix [Sdd | Sdc; Scd | Scc] with port order [d1, d2, c1, c2].

rustlab
% Build a near-ideal 4-port differential pair: strong within-pair
% transmission, small cross-coupling, small return loss.
P = 0.10 * eye(4);                      % small return loss every port
P(2, 1) = 0.70; P(1, 2) = 0.70;          % within-pair main path
P(4, 3) = 0.70; P(3, 4) = 0.70;
P(4, 1) = -0.05; P(1, 4) = -0.05;        % small cross-coupling
P(2, 3) = -0.05; P(3, 2) = -0.05;
% The same 4×4 matrix at both frequencies: one row of 16 per frequency,
% then reshape to [n_freqs, 4, 4] (column-major, so S4(k, :, :) = P).
S4 = reshape(repmat(reshape(P, 1, 16), 2, 1), 2, 4, 4);
s4 = sparameters(S4, [1e9, 2e9]);
smm = s2smm(s4);
fprintf("Single-ended tag: %s   Mixed-mode tag: %s\n", ...
        parameter_type(s4), parameter_type(smm))

P = smm.parameters;
fprintf("Sdd21 (diff → diff): %.3f∠%.1f°\n", ...
        abs(P(1, 2, 1)), angle(P(1, 2, 1))*180/pi)
fprintf("Scd21 (common → diff, ideally zero): %.3f\n", abs(P(1, 4, 1)))
Single-ended tag: S   Mixed-mode tag: Smm
Sdd21 (diff → diff): 0.750∠0.0°
Scd21 (common → diff, ideally zero): 0.000

A round-trip through smm2s recovers the single-ended network exactly because the transform is orthogonal:

rustlab
back = smm2s(smm);
P_orig = s4.parameters;
P_back = back.parameters;
diff = abs(P_back(1, 2, 1) - P_orig(1, 2, 1));
fprintf("smm2s round-trip diff at port (2,1): %.2e\n", diff)
smm2s round-trip diff at port (2,1): 3.33e-16

Summary

Across the two notebooks, the toolbox surface is:

  • Data + plotting (intro): sparameters, nports/freqs/sij/s11..s22, parameter conversions s2z/y/t/abcd and reverses, cascade, deembed, newref, save, smith, marker.
  • Analysis (this notebook): rfplot 2×2 + single-trace variants, vswr, return_loss, insertion_loss, gammain/gammaout, stabilityk, stabilitymu, gammams/gammaml, gainmax, stability_circles, gain_circles, smith_circle.
  • Polish (this notebook): interp_freq, s2td, noise accessors (nfmin, gamma_opt, rn, noise_freqs, has_noise), mixed-mode s2smm/smm2s, Touchstone v2 keyword tolerance.

The standalone runnable scripts in examples/rf/ are organised by phase if you want a smaller starting point for any single topic: load_s2p.rlab, cascade_attenuator.rlab, smith_chart.rlab, measurement_review.rlab, amplifier_stability.rlab, polish_features.rlab.