.. DO NOT EDIT. .. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY. .. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE: .. "howto/plot_cavity_ringdown_q.py" .. LINE NUMBERS ARE GIVEN BELOW. .. only:: html .. note:: :class: sphx-glr-download-link-note :ref:`Go to the end ` to download the full example code. .. rst-class:: sphx-glr-example-title .. _sphx_glr_howto_plot_cavity_ringdown_q.py: Ring-down: the loaded frequency and Q budget of a coupled resonator =================================================================== A resonator that is coupled to the outside world does not resonate where the closed one does, and it does not store energy as long. Both shifts matter as soon as something has to be tuned *to* the resonator — a particle beam driving the output cavity of a klystron has to be modelled at the loaded frequency, not at the frequency an eigenmode solve of the sealed cavity returns. A **ring-down** measures both in a single transient run: start the march inside the resonator's mode with the feed line empty, then watch what happens. The stored energy decays as .. math:: W(t) = W_0 \, e^{-\omega_0 t / Q_L}, so the slope of :math:`\ln W` is the loaded :math:`Q_L`, while the signal arriving at the port carries the loaded frequency. Splitting :math:`Q_L` into its channels takes one run per channel, because each loss mechanism can be switched off in the model: .. math:: \frac{1}{Q_L} = \frac{1}{Q_\text{fill}} + \frac{1}{Q_\text{wall}} + \frac{1}{Q_\text{ext}}. Two blocks copy into any script: one that turns a resonator mode into the initial field of a march, one that reads :math:`Q` and the loaded frequency off the result. The page checks itself twice — against the closed form :math:`Q_\text{fill} = \omega_0\varepsilon/\sigma` of a homogeneously filled cavity, and against the sum rule above. .. GENERATED FROM PYTHON SOURCE LINES 38-47 .. code-block:: Python import matplotlib.pyplot as plt import numpy as np import magnelio as mio from magnelio import geo, ports, post, sources from magnelio.constants import EPS0 .. GENERATED FROM PYTHON SOURCE LINES 49-59 The resonator and its feed -------------------------- A WR-90 box resonating in TE101 near 8.2 GHz, closed at one end and coupled at the other through a slot in a metal iris to a length of the same waveguide, terminated by a port. The slot width is the coupling knob. Cavity, slot and feed carry the same lossy filling — homogeneously, so that its loss tangent gives a filling Q that does not depend on how the mode redistributes when the coupling is turned up. .. GENERATED FROM PYTHON SOURCE LINES 59-91 .. code-block:: Python A, B = 22.86e-3, 10.16e-3 # WR-90 cross-section [m] D_CAV = 30e-3 # cavity length [m] T_IRIS = 2e-3 # iris thickness [m] L_WG = 30e-3 # feed waveguide to the port [m] W_IRIS = 8e-3 # coupling slot width [m] F_DESIGN = 8.15e9 # where TE101 is expected [Hz] F_MAX = 12e9 TAN_DELTA = 5e-4 # loss tangent of the filling, at F_DESIGN SIGMA = 2 * np.pi * F_DESIGN * EPS0 * TAN_DELTA # the conductivity that realises it SIGMA_CU = 5.8e7 # copper walls [S/m] def resonator(*, coupled, lossy): """The model; ``coupled=False`` seals the cavity (no iris, no port).""" fill = mio.Material.from_isotropic("fill", epsilon=1.0, sigma=SIGMA if lossy else 0.0) model = mio.GeometryModel(background="pec") model.add(geo.Brick.from_corners((0, 0, 0), (A, B, D_CAV), material=fill)) if not coupled: return model z0, z1 = D_CAV, D_CAV + T_IRIS model.add( geo.Brick.from_corners( ((A - W_IRIS) / 2, 0.0, z0), ((A + W_IRIS) / 2, B, z1), material=fill ) ) model.add(geo.Brick.from_corners((0, 0, z1), (A, B, z1 + L_WG), material=fill)) model.add_port(ports.PortWaveguide(name="out", plane="zmax", n_modes=1)) return model .. GENERATED FROM PYTHON SOURCE LINES 92-111 Block 1 — before: the resonator's mode as the initial field ----------------------------------------------------------- A ring-down starts with the resonator charged and the feed line empty. That is the mode of the *uncoupled* resonator, placed on the grid of the coupled model and zero outside the cavity — not the eigenmode of the coupled model, whose solver sees a metal wall where the port is and therefore returns a mode of cavity *and* feed line together. An initial field that already reaches the port plane is refused: the port's transparent boundary is exact only for an exterior that was quiet before ``t = 0``. For a rectangular box the mode is the closed form below. For a resonator without one, solve the sealed model with ``AnalysisEigenmode``, take ``result.field(0)`` and evaluate it with ``field.at(points)`` inside the cavity, zero outside. ``H = 0`` picks the instant of maximum electric field, which is the natural start: the march places the magnetic half-step itself. .. GENERATED FROM PYTHON SOURCE LINES 111-139 .. code-block:: Python def te101_field(x, y, z): """E of the TE101 mode of the sealed cavity, zero beyond it [V/m].""" inside = (z >= 0.0) & (z <= D_CAV) e_y = np.where( inside, np.sin(np.pi * np.clip(x, 0.0, A) / A) * np.sin(np.pi * np.clip(z, 0.0, D_CAV) / D_CAV), 0.0, ) return np.zeros_like(e_y), e_y, np.zeros_like(e_y) def ring_down(model, t_end, *, name="mode0", wall_sigma=None): """March from the resonator's mode and return the result. ``wall_sigma`` turns the metal from perfect conductor into a surface-impedance wall of that conductivity, which is how the wall channel enters the decay. Its branch currents start at zero — the wall carried none before ``t = 0``. """ mesh = mio.Mesh.from_geometry(model, mio.MeshControl(min_nodes_per_wavelength=10), f_max=F_MAX) source = sources.SourceFieldInitial.from_function(mesh.grid, name=name, E=te101_field) walls = {"wall_model": "sibc", "wall_sigma": wall_sigma} if wall_sigma else {} analysis = mio.AnalysisTD(mesh=mesh.with_sources([source]), verbose=False, **walls) return analysis.run(excitations=[name], t_end=t_end, energy_stop_db=None), mesh .. GENERATED FROM PYTHON SOURCE LINES 140-155 Block 2 — after: Q from the decay, frequency from the port ---------------------------------------------------------- ``result.energy_trace`` is the decay curve, recorded at the solver's own check cadence. Fit a straight line to its logarithm and convert the slope: :math:`Q = -\omega_0/\text{slope}`. Fit the *energy*, not the envelope of a field probe: a probe swings through zero twice per period, and a fit to :math:`|E(t)|` reads the zeros along with the peaks. The energy trace is the quantity the leapfrog conserves, so it decays smoothly. The loaded frequency is the peak of the spectrum of what leaves through the port — a ring-down radiates only what the resonator holds, so that spectrum is the loaded resonance itself. .. GENERATED FROM PYTHON SOURCE LINES 155-176 .. code-block:: Python def q_from_ringdown(result, f0, *, floor_db=40.0): """Q from the decay of the stored energy over ``floor_db`` of it.""" trace = result.energy_trace t, w = trace["time"], trace["energy"] keep = w > w.max() * 10 ** (-floor_db / 10) slope = np.polyfit(t[keep], np.log(w[keep]), 1)[0] return -2 * np.pi * f0 / slope def loaded_frequency(result, port): """Peak of the port spectrum [Hz], parabolically interpolated.""" signal = np.asarray(result.signal(port).values) spectrum = np.abs(np.fft.rfft(signal * np.hanning(signal.size))) df = 1.0 / (signal.size * result.dt) k = int(spectrum.argmax()) y0, y1, y2 = np.log(spectrum[k - 1 : k + 2]) return (k + 0.5 * (y0 - y2) / (y0 - 2 * y1 + y2)) * df .. GENERATED FROM PYTHON SOURCE LINES 177-183 One run per loss channel ------------------------ Sealed and lossless first: that run measures nothing physical, it measures the *floor* — how long the discretisation itself holds energy. Everything below has to sit far above it. .. GENERATED FROM PYTHON SOURCE LINES 183-240 .. code-block:: Python def q_fill_exact(f): """Q of a homogeneously filled cavity at *f* — exact, whatever its shape. A conductivity is a *frequency-dependent* loss tangent, ``tan(delta) = sigma / (omega·eps)``, so the filling Q rises with frequency: ``Q_fill = omega·eps / sigma``. """ return 2 * np.pi * f * EPS0 / SIGMA runs = {} runs["sealed, lossless"], _ = ring_down(resonator(coupled=False, lossy=False), 20e-9) runs["sealed, lossy fill"], mesh_sealed = ring_down(resonator(coupled=False, lossy=True), 40e-9) runs["sealed, copper walls"], _ = ring_down( resonator(coupled=False, lossy=False), 150e-9, wall_sigma=SIGMA_CU ) runs["coupled, lossless"], _ = ring_down(resonator(coupled=True, lossy=False), 60e-9) runs["coupled, lossy fill"], _ = ring_down(resonator(coupled=True, lossy=True), 60e-9) f_loaded = loaded_frequency(runs["coupled, lossless"], "out") f_sealed = float( mio.AnalysisEigenmode(mesh=mesh_sealed, n_modes=1, verbose=False).run().frequencies[0] ) q_floor = q_from_ringdown(runs["sealed, lossless"], f_sealed) q_fill = q_from_ringdown(runs["sealed, lossy fill"], f_sealed) q_wall = q_from_ringdown(runs["sealed, copper walls"], f_sealed) q_ext = q_from_ringdown(runs["coupled, lossless"], f_loaded) q_loaded = q_from_ringdown(runs["coupled, lossy fill"], f_loaded) print(f"sealed f0 = {f_sealed / 1e9:.4f} GHz") print(f"loaded f0 = {f_loaded / 1e9:.4f} GHz ({(f_loaded / f_sealed - 1) * 100:+.2f} %)") print(f"numerical floor Q = {q_floor:12.3g}") print( f"Q_fill Q = {q_fill:12.1f} exact {q_fill_exact(f_sealed):.1f}" f" ({(q_fill / q_fill_exact(f_sealed) - 1) * 100:+.3f} %)" ) q_wall_pert = post.wall_loss_Q( mio.AnalysisEigenmode(mesh=mesh_sealed, n_modes=1, verbose=False).run(), 0, sigma=SIGMA_CU, ).Q print( f"Q_wall Q = {q_wall:12.1f} perturbative {q_wall_pert:.1f}" f" ({(q_wall / q_wall_pert - 1) * 100:+.3f} %)" ) print(f"Q_ext Q = {q_ext:12.1f}") print(f"Q_L measured Q = {q_loaded:12.1f}") # The sum rule is evaluated where the loaded run resonates. q_predicted = 1.0 / (1.0 / q_fill_exact(f_loaded) + 1.0 / q_ext) print( f"Q_L from the sum rule = {q_predicted:12.1f} ({(q_loaded / q_predicted - 1) * 100:+.2f} %)" ) .. rst-class:: sphx-glr-script-out .. code-block:: none mesh | feature planes mesh | grid lines mesh | materials mesh | conformal cells mesh | PEC masks mesh | 10 x 5 x 13 cells mesh | feature planes mesh | grid lines mesh | materials mesh | conformal cells mesh | PEC masks mesh | 10 x 5 x 13 cells mesh | feature planes mesh | grid lines mesh | materials mesh | conformal cells mesh | PEC masks mesh | 10 x 5 x 13 cells mesh | feature planes mesh | grid lines mesh | materials mesh | conformal cells mesh | PEC masks mesh | 10 x 5 x 36 cells mesh | feature planes mesh | grid lines mesh | materials mesh | conformal cells mesh | PEC masks mesh | 10 x 5 x 36 cells sealed f0 = 8.2151 GHz loaded f0 = 8.1135 GHz (-1.24 %) numerical floor Q = 5.4e+08 Q_fill Q = 2015.8 exact 2016.0 (-0.007 %) Q_wall Q = 7745.2 perturbative 7768.9 (-0.305 %) Q_ext Q = 1798.0 Q_L measured Q = 944.7 Q_L from the sum rule = 944.8 (-0.01 %) .. GENERATED FROM PYTHON SOURCE LINES 241-259 Three numbers carry the page. ``Q_fill`` reproduces :math:`\omega_0\varepsilon/\sigma` — the exact Q of a homogeneously filled cavity, whatever its shape — to a few hundredths of a percent; ``Q_wall`` agrees with the perturbative surface-resistance evaluation on the same mode, two entirely different routes to the same loss; and the measured ``Q_L`` matches the sum rule built from the channels measured separately. When those hold on a case you can check, the same procedure is trustworthy on a resonator that has no closed form. Note where each Q is evaluated. A conductivity is a loss tangent that falls with frequency, so the filling Q of the *loaded* resonance is not the one measured on the sealed cavity 1.2 % higher up; putting the sealed value into the sum rule is worth half a percent here, and more on a strongly coupled resonator. The full budget of the coupled, filled, copper-walled resonator is what a design actually has to meet: .. GENERATED FROM PYTHON SOURCE LINES 259-266 .. code-block:: Python q_total = 1.0 / (1.0 / q_fill_exact(f_loaded) + 1.0 / q_wall + 1.0 / q_ext) print( f"full budget: 1/Q = 1/{q_fill_exact(f_loaded):.0f} + 1/{q_wall:.0f} + 1/{q_ext:.0f}" f" -> Q_L = {q_total:.1f}" ) .. rst-class:: sphx-glr-script-out .. code-block:: none full budget: 1/Q = 1/1991 + 1/7745 + 1/1798 -> Q_L = 842.1 .. GENERATED FROM PYTHON SOURCE LINES 267-269 The decays, one channel at a time --------------------------------- .. GENERATED FROM PYTHON SOURCE LINES 269-284 .. code-block:: Python fig, ax = plt.subplots(figsize=(7.2, 4.4)) for label, result in runs.items(): trace = result.energy_trace t_ns = trace["time"] * 1e9 w_db = 10 * np.log10(trace["energy"] / trace["energy"][0]) ax.plot(t_ns, w_db, lw=1.3, label=label) ax.set_xlabel("time (ns)") ax.set_ylabel("stored energy (dB rel. start)") ax.set_title("Ring-down of the WR-90 cavity, one loss channel at a time") ax.set_ylim(-20, 2) ax.grid(alpha=0.3) ax.legend(loc="upper right", fontsize=9) fig.tight_layout() .. image-sg:: /howto/images/sphx_glr_plot_cavity_ringdown_q_001.png :alt: Ring-down of the WR-90 cavity, one loss channel at a time :srcset: /howto/images/sphx_glr_plot_cavity_ringdown_q_001.png :class: sphx-glr-single-img .. GENERATED FROM PYTHON SOURCE LINES 285-321 What the number sees -------------------- * **The coupling moves the frequency.** The iris pulls the resonance below the sealed value by a few tenths of a percent here, and much further as the slot opens. That shifted frequency is what anything driving the resonator has to match; the sealed eigenfrequency is the wrong target. * **Start in the resonator, not in the whole volume.** The mode of the coupled model — cavity plus feed line behind a metal wall — is a different mode, and it reaches the port plane, where the transparent boundary assumes quiescence. The library refuses that start rather than returning a plausible wrong Q. * **Why not read Q off S11.** The same structure in the frequency domain has to be marched until the resonator has rung down anyway, or its resonance comes out too shallow. On this case a scattering run of 450 000 steps still gave a group-delay Q about 10 % below the ring-down value of a 39 000-step run, with :math:`|S_{11}|` at 0.94 instead of 1. The ring-down reads the decay directly, which is why it is the method of choice for a high-Q structure. * **Wall losses need a wall model.** Perfect conductor gives a ring-down nothing to decay into; ``wall_model="sibc"`` puts a surface impedance on the metal, and its branch currents simply start at zero — the wall carried no current before ``t = 0``. The agreement with the perturbative evaluation above says that start costs nothing. * **Watch the floor.** The sealed lossless run sets the largest Q the discretisation can express. Keep the Q you report at least an order of magnitude below it. * **Homogeneous filling keeps the budget clean.** With the loss confined to part of the volume, opening the coupling redistributes the mode, the effective filling Q changes with it, and the sum rule stops holding — measured 128 against 190 predicted on a strongly over-coupled variant. Fill uniformly, or measure each channel at the coupling you actually use. .. GENERATED FROM PYTHON SOURCE LINES 321-323 .. code-block:: Python plt.show() .. rst-class:: sphx-glr-timing **Total running time of the script:** (0 minutes 28.264 seconds) .. _sphx_glr_download_howto_plot_cavity_ringdown_q.py: .. only:: html .. container:: sphx-glr-footer sphx-glr-footer-example .. container:: sphx-glr-download sphx-glr-download-jupyter :download:`Download Jupyter notebook: plot_cavity_ringdown_q.ipynb ` .. container:: sphx-glr-download sphx-glr-download-python :download:`Download Python source code: plot_cavity_ringdown_q.py ` .. container:: sphx-glr-download sphx-glr-download-zip :download:`Download zipped: plot_cavity_ringdown_q.zip ` .. only:: html .. rst-class:: sphx-glr-signature `Gallery generated by Sphinx-Gallery `_