sqil-core 0.0.2__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. sqil_core/__init__.py +6 -2
  2. sqil_core/config.py +13 -0
  3. sqil_core/config_log.py +42 -0
  4. sqil_core/experiment/__init__.py +11 -0
  5. sqil_core/experiment/_analysis.py +95 -0
  6. sqil_core/experiment/_events.py +25 -0
  7. sqil_core/experiment/_experiment.py +553 -0
  8. sqil_core/experiment/data/plottr.py +778 -0
  9. sqil_core/experiment/helpers/_function_override_handler.py +111 -0
  10. sqil_core/experiment/helpers/_labone_wrappers.py +12 -0
  11. sqil_core/experiment/instruments/__init__.py +2 -0
  12. sqil_core/experiment/instruments/_instrument.py +190 -0
  13. sqil_core/experiment/instruments/drivers/SignalCore_SC5511A.py +515 -0
  14. sqil_core/experiment/instruments/local_oscillator.py +205 -0
  15. sqil_core/experiment/instruments/server.py +175 -0
  16. sqil_core/experiment/instruments/setup.yaml +21 -0
  17. sqil_core/experiment/instruments/zurich_instruments.py +55 -0
  18. sqil_core/fit/__init__.py +38 -0
  19. sqil_core/fit/_core.py +1084 -0
  20. sqil_core/fit/_fit.py +1191 -0
  21. sqil_core/fit/_guess.py +232 -0
  22. sqil_core/fit/_models.py +127 -0
  23. sqil_core/fit/_quality.py +266 -0
  24. sqil_core/resonator/__init__.py +13 -0
  25. sqil_core/resonator/_resonator.py +989 -0
  26. sqil_core/utils/__init__.py +85 -5
  27. sqil_core/utils/_analysis.py +415 -0
  28. sqil_core/utils/_const.py +105 -0
  29. sqil_core/utils/_formatter.py +259 -0
  30. sqil_core/utils/_plot.py +373 -0
  31. sqil_core/utils/_read.py +262 -0
  32. sqil_core/utils/_utils.py +164 -0
  33. {sqil_core-0.0.2.dist-info → sqil_core-1.0.0.dist-info}/METADATA +40 -7
  34. sqil_core-1.0.0.dist-info/RECORD +36 -0
  35. {sqil_core-0.0.2.dist-info → sqil_core-1.0.0.dist-info}/WHEEL +1 -1
  36. {sqil_core-0.0.2.dist-info → sqil_core-1.0.0.dist-info}/entry_points.txt +1 -1
  37. sqil_core/utils/analysis.py +0 -68
  38. sqil_core/utils/const.py +0 -38
  39. sqil_core/utils/formatter.py +0 -134
  40. sqil_core/utils/read.py +0 -156
  41. sqil_core-0.0.2.dist-info/RECORD +0 -10
@@ -0,0 +1,989 @@
1
+ from typing import Literal
2
+
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from lmfit import Model
6
+ from scipy.optimize import leastsq, minimize
7
+ from tabulate import tabulate
8
+
9
+ from sqil_core.fit import (
10
+ FitResult,
11
+ fit_circle_algebraic,
12
+ fit_lorentzian,
13
+ fit_output,
14
+ fit_skewed_lorentzian,
15
+ get_best_fit,
16
+ )
17
+ from sqil_core.utils import estimate_linear_background, format_number
18
+ from sqil_core.utils._plot import reset_plot_style, set_plot_style
19
+
20
+
21
+ @fit_output
22
+ def fit_phase_vs_freq_global(
23
+ freq: np.ndarray,
24
+ phase: np.ndarray,
25
+ theta0: float | None = None,
26
+ Q_tot: float | None = None,
27
+ fr: float | None = None,
28
+ disp: bool = False,
29
+ ) -> tuple[np.ndarray, np.ndarray]:
30
+ """
31
+ Fits phase response data as a function of frequency using an arctangent model.
32
+
33
+ This function models the phase response of a superconducting resonator or circuit
34
+ as a function of frequency. It fits the data using the model:
35
+ θ(f) = θ₀ + 2 * arctan(2 * Q_tot * (1 - f / fr))
36
+ where θ₀ is the phase offset, Q_tot is the total quality factor, and fr is the
37
+ resonant frequency. The fitting is performed using the Nelder-Mead optimization
38
+ method to minimize the sum of squared residuals between the measured and modeled phase.
39
+
40
+ Parameters
41
+ ----------
42
+ freq : np.ndarray
43
+ Array of frequency data points (in Hz).
44
+ phase : np.ndarray
45
+ Array of measured phase data (in radians).
46
+ theta0 : float, optional
47
+ Initial guess for the phase offset θ₀. If not provided, defaults to the mean of `phase`.
48
+ Q_tot : float, optional
49
+ Initial guess for the total quality factor. If not provided, defaults to 0.01.
50
+ fr : float, optional
51
+ Initial guess for the resonant frequency. If not provided, defaults to the mean of `freq`.
52
+ disp : bool, optional
53
+ If True, displays optimization progress. Default is True.
54
+
55
+ Returns
56
+ -------
57
+ FitResult
58
+ A `FitResult` object containing:
59
+ - Fitted parameters (`params`).
60
+ - Standard errors (`std_err`).
61
+ - Goodness-of-fit metrics (`red_chi2`).
62
+ - A callable `predict` function for generating fitted responses.
63
+
64
+ Notes
65
+ -----
66
+ - The model assumes the phase response follows the arctangent behavior typical in
67
+ superconducting resonators near resonance.
68
+
69
+ Examples
70
+ --------
71
+ >>> freq = np.linspace(5e9, 6e9, 1000) # Frequency in Hz
72
+ >>> phase = np.random.normal(0, 0.1, size=freq.size) # Simulated noisy phase data
73
+ >>> popt, perr = fit_phase_vs_freq(freq, phase)
74
+ >>> print("Fitted Parameters (θ₀, Q_tot, fr):", popt)
75
+ >>> print("Percentage Errors:", perr)
76
+ """
77
+ if theta0 is None:
78
+ theta0 = np.mean(phase)
79
+ if Q_tot is None:
80
+ Q_tot = 0.01
81
+ if fr is None:
82
+ fr = np.mean(freq) # freq[np.argmin(np.abs(phase - np.mean(phase)))]
83
+
84
+ def objective(x):
85
+ theta0, Q_tot, fr = x
86
+ model = theta0 + 2 * np.arctan(2 * Q_tot * (1 - freq / fr))
87
+ residuals = phase - model
88
+ return np.square(residuals).sum()
89
+
90
+ res = minimize(
91
+ fun=objective,
92
+ x0=[theta0, Q_tot, fr],
93
+ method="Nelder-Mead",
94
+ options={"maxiter": 3000000, "disp": disp},
95
+ )
96
+
97
+ return res, {
98
+ "predict": lambda f: theta0 + 2 * np.arctan(2 * Q_tot * (1 - f / fr)),
99
+ "param_names": ["θ₀", "Q_tot", "fr"],
100
+ }
101
+
102
+
103
+ @fit_output
104
+ def fit_phase_vs_freq(freq, phase, theta0, Q_tot, fr):
105
+ """
106
+ Fits the phase response of a superconducting resonator using an arctangent model.
107
+
108
+ Reference: https://arxiv.org/abs/1410.3365
109
+ This function models the phase response as:
110
+ φ(f) = θ₀ + 2 * arctan(2 * Q_tot * (1 - f / f_r))
111
+
112
+ where:
113
+ - φ(f) is the measured phase response (in radians),
114
+ - θ₀ is the phase offset,
115
+ - Q_tot is the total (loaded) quality factor,
116
+ - f_r is the resonant frequency.
117
+
118
+ The fitting is performed using a stepwise least-squares optimization to accurately
119
+ estimate the parameters θ₀, Q_tot, and f_r from experimental data.
120
+
121
+ Parameters
122
+ ----------
123
+ freq : array-like
124
+ Frequency data (in Hz) at which the phase response was measured.
125
+ phase : array-like
126
+ Unwrapped phase response data (in radians) corresponding to `freq`.
127
+ theta0 : float
128
+ Initial guess for the phase offset θ₀ (in radians).
129
+ Q_tot : float
130
+ Initial guess for the total (loaded) quality factor Q_tot.
131
+ fr : float
132
+ Initial guess for the resonant frequency f_r (in Hz).
133
+
134
+ Returns
135
+ -------
136
+ FitResult
137
+ A `FitResult` object containing:
138
+ - Fitted parameters (`params`).
139
+ - Standard errors (`std_err`).
140
+ - Goodness-of-fit metrics (`red_chi2`).
141
+ - A callable `predict` function for generating fitted responses.
142
+
143
+ Notes
144
+ -----
145
+ - The fitting is performed in multiple stages for improved stability:
146
+ 1. Optimize θ₀ and f_r (fixing Q_tot).
147
+ 2. Optimize Q_tot and f_r (fixing θ₀).
148
+ 3. Optimize f_r alone.
149
+ 4. Optimize Q_tot alone.
150
+ 5. Joint optimization of θ₀, Q_tot, and f_r.
151
+ - This stepwise optimization handles parameter coupling and improves convergence.
152
+
153
+ Example
154
+ -------
155
+ >>> fitted_params, percent_errors = fit_phase_vs_freq(freq, phase, 0.0, 1000, 5e9)
156
+ >>> print(f"Fitted Parameters: θ₀ = {fitted_params[0]}, Q_tot = {fitted_params[1]}, f_r = {fitted_params[2]}")
157
+ >>> print(f"Percentage Errors: θ₀ = {percent_errors[0]}%, Q_tot = {percent_errors[1]}%, f_r = {percent_errors[2]}%")
158
+ """
159
+ # Unwrap the phase of the complex data to avoid discontinuities
160
+ phase = np.unwrap(phase)
161
+
162
+ # Define the distance function to handle phase wrapping
163
+ def dist(x):
164
+ np.absolute(x, x)
165
+ c = (x > np.pi).astype(int)
166
+ return x + c * (-2.0 * x + 2.0 * np.pi)
167
+
168
+ # Step 1: Optimize θ₀ and fr with Q_tot fixed
169
+ def residuals_1(p, x, y, Q_tot):
170
+ theta0, fr = p
171
+ err = dist(y - (theta0 + 2.0 * np.arctan(2.0 * Q_tot * (1.0 - x / fr))))
172
+ return err
173
+
174
+ p0 = [theta0, fr]
175
+ p_final = leastsq(
176
+ lambda a, b, c: residuals_1(a, b, c, Q_tot), p0, args=(freq, phase)
177
+ )
178
+ theta0, fr = p_final[0]
179
+
180
+ # Step 2: Optimize Q_tot and fr with θ₀ fixed
181
+ def residuals_2(p, x, y, theta0):
182
+ Q_tot, fr = p
183
+ err = dist(y - (theta0 + 2.0 * np.arctan(2.0 * Q_tot * (1.0 - x / fr))))
184
+ return err
185
+
186
+ p0 = [Q_tot, fr]
187
+ p_final = leastsq(
188
+ lambda a, b, c: residuals_2(a, b, c, theta0), p0, args=(freq, phase)
189
+ )
190
+ Q_tot, fr = p_final[0]
191
+
192
+ # Step 3: Optimize fr alone
193
+ def residuals_3(p, x, y, theta0, Q_tot):
194
+ fr = p
195
+ err = dist(y - (theta0 + 2.0 * np.arctan(2.0 * Q_tot * (1.0 - x / fr))))
196
+ return err
197
+
198
+ p0 = fr
199
+ p_final = leastsq(
200
+ lambda a, b, c: residuals_3(a, b, c, theta0, Q_tot), p0, args=(freq, phase)
201
+ )
202
+ fr = float(p_final[0].item())
203
+
204
+ # Step 4: Optimize Q_tot alone
205
+ def residuals_4(p, x, y, theta0, fr):
206
+ Q_tot = p
207
+ err = dist(y - (theta0 + 2.0 * np.arctan(2.0 * Q_tot * (1.0 - x / fr))))
208
+ return err
209
+
210
+ p0 = Q_tot
211
+ p_final = leastsq(
212
+ lambda a, b, c: residuals_4(a, b, c, theta0, fr), p0, args=(freq, phase)
213
+ )
214
+ Q_tot = float(p_final[0].item())
215
+
216
+ # Step 5: Joint optimization of θ₀, Q_tot, and fr
217
+ def residuals_5(p, x, y):
218
+ theta0, Q_tot, fr = p
219
+ err = dist(y - (theta0 + 2.0 * np.arctan(2.0 * Q_tot * (1.0 - x / fr))))
220
+ return err
221
+
222
+ p0 = [theta0, Q_tot, fr]
223
+ final_result = leastsq(residuals_5, p0, args=(freq, phase), full_output=True)
224
+
225
+ return (
226
+ final_result,
227
+ {
228
+ "predict": lambda f: theta0 + 2 * np.arctan(2 * Q_tot * (1 - f / fr)),
229
+ "param_names": ["θ₀", "Q_tot", "fr"],
230
+ },
231
+ )
232
+
233
+
234
+ def S11_reflection(
235
+ freq: np.ndarray,
236
+ a: float,
237
+ alpha: float,
238
+ tau: float,
239
+ Q_tot: float,
240
+ fr: float,
241
+ Q_ext: float,
242
+ phi: float,
243
+ mag_bg: np.ndarray | None = None,
244
+ ) -> np.ndarray:
245
+ """
246
+ Calculates the S11 reflection coefficient for a superconducting resonator with an optional magnitude background.
247
+
248
+ This function models the S11 reflection parameter, representing how much of an
249
+ incident signal is reflected by a resonator. It includes both the resonator's
250
+ frequency-dependent response and an optional magnitude background correction,
251
+ providing a more accurate fit for experimental data.
252
+
253
+ The S11 reflection is computed as:
254
+ S11(f) = env(f) * resonator(f)
255
+ where:
256
+ - env(f) = a * mag_bg(f) * exp(i * α) * exp(2πi * (f - f₀) * τ)
257
+ models the environmental response, including amplitude scaling, phase shifts,
258
+ time delays, and optional frequency-dependent magnitude background.
259
+ - resonator(f) = 1 - [2 * Q_tot / |Q_ext|] * exp(i * φ) / [1 + 2i * Q_tot * (f / fr - 1)]
260
+ models the resonator's frequency response.
261
+
262
+ Parameters
263
+ ----------
264
+ freq : np.ndarray
265
+ Array of frequency points (in Hz) at which to evaluate the S11 parameter.
266
+ a : float
267
+ Amplitude scaling factor for the environmental response.
268
+ alpha : float
269
+ Phase offset (in radians) for the environmental response.
270
+ tau : float
271
+ Time delay (in seconds) representing the signal path delay.
272
+ Q_tot : float
273
+ Total quality factor of the resonator (includes internal and external losses).
274
+ fr : float
275
+ Resonant frequency of the resonator (in Hz).
276
+ Q_ext : float
277
+ External quality factor, representing coupling losses to external circuitry.
278
+ phi : float
279
+ Additional phase shift (in radians) in the resonator response.
280
+ mag_bg : np.ndarray or None, optional
281
+ Frequency-dependent magnitude background correction. If provided, it should be
282
+ an array of the same shape as `freq`. Defaults to 1 (no correction).
283
+
284
+ Returns
285
+ -------
286
+ S11 : np.ndarray
287
+ Complex array representing the S11 reflection coefficient across the input frequencies.
288
+
289
+ Notes
290
+ -----
291
+ - Passing mag_bg = np.nan has the same effect of passing mag_bg = None
292
+
293
+ Examples
294
+ --------
295
+ >>> freq = np.linspace(4.9e9, 5.1e9, 500) # Frequency sweep around 5 GHz
296
+ >>> mag_bg = freq**2 + 3 * freq # Example magnitude background
297
+ >>> S11 = S11_reflection(freq, a=1.0, alpha=0.0, tau=1e-9,
298
+ ... Q_tot=5000, fr=5e9, Q_ext=10000, phi=0.0, mag_bg=mag_bg)
299
+ >>> import matplotlib.pyplot as plt
300
+ >>> plt.plot(freq, 20 * np.log10(np.abs(S11))) # Plot magnitude in dB
301
+ >>> plt.xlabel("Frequency (Hz)")
302
+ >>> plt.ylabel("S11 Magnitude (dB)")
303
+ >>> plt.title("S11 Reflection Coefficient with Magnitude Background")
304
+ >>> plt.show()
305
+ """
306
+ if mag_bg is None:
307
+ mag_bg = 1
308
+ elif np.isscalar(mag_bg) and np.isnan(mag_bg):
309
+ mag_bg = 1
310
+
311
+ env = a * mag_bg * np.exp(1j * alpha) * np.exp(2j * np.pi * (freq - freq[0]) * tau)
312
+ resonator = 1 - (2 * Q_tot / np.abs(Q_ext)) * np.exp(1j * phi) / (
313
+ 1 + 2j * Q_tot * (freq / fr - 1)
314
+ )
315
+ return env * resonator
316
+
317
+
318
+ def S21_hanger(
319
+ freq: np.ndarray,
320
+ a: float,
321
+ alpha: float,
322
+ tau: float,
323
+ Q_tot: float,
324
+ fr: float,
325
+ Q_ext: float,
326
+ phi: float,
327
+ mag_bg: np.ndarray | None = None,
328
+ ) -> np.ndarray:
329
+ """
330
+ Calculates the S21 transmission coefficient using the hanger resonator model with an optional magnitude background.
331
+
332
+ This function models the S21 transmission parameter, which describes how much of an
333
+ incident signal is transmitted through a superconducting resonator. The model combines
334
+ the resonator's frequency-dependent response with an environmental background response
335
+ and an optional magnitude background correction to more accurately reflect experimental data.
336
+
337
+ The S21 transmission is computed as:
338
+ S21(f) = env(f) * resonator(f)
339
+ where:
340
+ - env(f) = a * mag_bg(f) * exp(i * α) * exp(2πi * (f - f₀) * τ)
341
+ models the environmental response, accounting for amplitude scaling, phase shifts,
342
+ and signal path delays.
343
+ - resonator(f) = 1 - [Q_tot / |Q_ext|] * exp(i * φ) / [1 + 2i * Q_tot * (f / fr - 1)]
344
+ models the frequency response of the hanger-type resonator.
345
+
346
+ Parameters
347
+ ----------
348
+ freq : np.ndarray
349
+ Array of frequency points (in Hz) at which to evaluate the S21 parameter.
350
+ a : float
351
+ Amplitude scaling factor for the environmental response.
352
+ alpha : float
353
+ Phase offset (in radians) for the environmental response.
354
+ tau : float
355
+ Time delay (in seconds) representing the signal path delay.
356
+ Q_tot : float
357
+ Total quality factor of the resonator (includes internal and external losses).
358
+ fr : float
359
+ Resonant frequency of the resonator (in Hz).
360
+ Q_ext : float
361
+ External quality factor, representing coupling losses to external circuitry.
362
+ phi : float
363
+ Additional phase shift (in radians) in the resonator response.
364
+ mag_bg : np.ndarray or None, optional
365
+ Frequency-dependent magnitude background correction. If provided, it should be
366
+ an array of the same shape as `freq`. Defaults to 1 (no correction).
367
+
368
+ Returns
369
+ -------
370
+ S21 : np.ndarray
371
+ Complex array representing the S21 transmission coefficient across the input frequencies.
372
+
373
+ Notes
374
+ -----
375
+ - Passing mag_bg = np.nan has the same effect of passing mag_bg = None
376
+
377
+ Examples
378
+ --------
379
+ >>> freq = np.linspace(4.9e9, 5.1e9, 500) # Frequency sweep around 5 GHz
380
+ >>> mag_bg = freq**2 + 3 * freq # Example magnitude background
381
+ >>> S21 = S21_hanger(freq, a=1.0, alpha=0.0, tau=1e-9,
382
+ ... Q_tot=5000, fr=5e9, Q_ext=10000, phi=0.0, mag_bg=mag_bg)
383
+ >>> import matplotlib.pyplot as plt
384
+ >>> plt.plot(freq, 20 * np.log10(np.abs(S21))) # Plot magnitude in dB
385
+ >>> plt.xlabel("Frequency (Hz)")
386
+ >>> plt.ylabel("S21 Magnitude (dB)")
387
+ >>> plt.title("S21 Transmission Coefficient with Magnitude Background")
388
+ >>> plt.show()
389
+ """
390
+ if mag_bg is None:
391
+ mag_bg = 1
392
+ elif np.isscalar(mag_bg) and np.isnan(mag_bg):
393
+ mag_bg = 1
394
+
395
+ env = a * mag_bg * np.exp(1j * alpha) * np.exp(2j * np.pi * (freq - freq[0]) * tau)
396
+ resonator = 1 - (Q_tot / np.abs(Q_ext)) * np.exp(1j * phi) / (
397
+ 1 + 2j * Q_tot * (freq / fr - 1)
398
+ )
399
+ return env * resonator
400
+
401
+
402
+ def S21_transmission(
403
+ freq: np.ndarray,
404
+ a: float,
405
+ alpha: float,
406
+ tau: float,
407
+ Q_tot: float,
408
+ fr: float,
409
+ mag_bg: float | None = None,
410
+ ) -> np.ndarray:
411
+ """
412
+ Computes the complex S21 transmission for a single-pole resonator model.
413
+
414
+ This model describes the transmission response of a resonator. The total response
415
+ includes both the resonator and a complex background envelope with a possible linear phase delay.
416
+
417
+ Parameters
418
+ ----------
419
+ freq : np.ndarray
420
+ Frequency array (in Hz) over which the S21 transmission is evaluated.
421
+ a : float
422
+ Amplitude scaling factor of the background envelope.
423
+ alpha : float
424
+ Phase offset of the background envelope (in radians).
425
+ tau : float
426
+ Time delay in the background response (in seconds).
427
+ Q_tot : float
428
+ Total quality factor of the resonator.
429
+ fr : float
430
+ Resonant frequency of the resonator (in Hz).
431
+ mag_bg : float or None, optional
432
+ Optional background magnitude scaling. If `None` or `NaN`, it defaults to 1.
433
+
434
+ Returns
435
+ -------
436
+ np.ndarray
437
+ Complex-valued S21 transmission array over the specified frequency range.
438
+ """
439
+
440
+ if mag_bg is None:
441
+ mag_bg = 1
442
+ elif np.isscalar(mag_bg) and np.isnan(mag_bg):
443
+ mag_bg = 1
444
+
445
+ env = a * np.exp(1j * alpha) * np.exp(2j * np.pi * (freq - freq[0]) * tau)
446
+ resonator = 1 / (1 + 2j * Q_tot * (freq / fr - 1))
447
+ return env * resonator
448
+
449
+
450
+ def S11_reflection_mesh(freq, a, alpha, tau, Q_tot, Q_ext, fr, phi):
451
+ """
452
+ Vectorized S11 reflection function.
453
+
454
+ Parameters
455
+ ----------
456
+ freq : array, shape (N,)
457
+ Frequency points.
458
+ a, alpha, tau, Q_tot, Q_ext, fr, phi : scalar or array
459
+ Parameters of the S11 model.
460
+
461
+ Returns
462
+ -------
463
+ S11 : array
464
+ Complex reflection coefficient. Shape is (M1, M2, ..., N) where M1, M2, ... are the broadcasted shapes of the parameters.
465
+ """
466
+ # Ensure freq is at least 2D for broadcasting (1, N)
467
+ freq = np.atleast_1d(freq) # (N,)
468
+
469
+ # Ensure all parameters are at least 1D arrays for broadcasting
470
+ a = np.atleast_1d(a) # (M1,)
471
+ alpha = np.atleast_1d(alpha) # (M2,)
472
+ tau = np.atleast_1d(tau) # (M3,)
473
+ Q_tot = np.atleast_1d(Q_tot) # (M4,)
474
+ Q_ext = np.atleast_1d(Q_ext) # (M5,)
475
+ fr = np.atleast_1d(fr) # (M6,)
476
+ phi = np.atleast_1d(phi) # (M7,)
477
+
478
+ # Reshape frequency to (1, 1, ..., 1, N) for proper broadcasting
479
+ # This makes sure freq has shape (1, 1, ..., N)
480
+ freq = freq[np.newaxis, ...]
481
+
482
+ # Calculate the envelope part
483
+ env = (
484
+ a[..., np.newaxis]
485
+ * np.exp(1j * alpha[..., np.newaxis])
486
+ * np.exp(2j * np.pi * (freq - freq[..., 0:1]) * tau[..., np.newaxis])
487
+ )
488
+
489
+ # Calculate the resonator part
490
+ resonator = 1 - (
491
+ 2 * Q_tot[..., np.newaxis] / np.abs(Q_ext[..., np.newaxis])
492
+ ) * np.exp(1j * phi[..., np.newaxis]) / (
493
+ 1 + 2j * Q_tot[..., np.newaxis] * (freq / fr[..., np.newaxis] - 1)
494
+ )
495
+
496
+ return env * resonator
497
+
498
+
499
+ def linmag_fit(freq: np.ndarray, data: np.ndarray) -> FitResult:
500
+ """
501
+ Fits the magnitude squared of complex data to a Lorentzian profile.
502
+
503
+ This function computes the normalized magnitude of the input complex data and fits
504
+ its squared value to a Lorentzian function to characterize resonance features.
505
+ If the initial Lorentzian fit quality is poor (based on NRMSE), it attempts a fit
506
+ using a skewed Lorentzian model and returns the better fit.
507
+
508
+ Parameters
509
+ ----------
510
+ freq : np.ndarray
511
+ Frequency values corresponding to the data points.
512
+ data : np.ndarray
513
+ Complex-valued data to be fitted.
514
+
515
+ Returns
516
+ -------
517
+ FitResult
518
+ The best fit result from either the Lorentzian or skewed Lorentzian fit, selected
519
+ based on fit quality.
520
+ """
521
+
522
+ linmag = np.abs(data)
523
+ norm_linmag = linmag / np.max(linmag)
524
+ # Lorentzian fit
525
+ fit_res = fit_lorentzian(freq, norm_linmag**2)
526
+ # If the lorentzian fit is bad, try a skewed lorentzian
527
+ if not fit_res.is_acceptable("nrmse"):
528
+ fit_res_skewed = fit_skewed_lorentzian(freq, norm_linmag**2)
529
+ fit_res = get_best_fit(fit_res, fit_res_skewed)
530
+
531
+ return fit_res
532
+
533
+
534
+ def quick_fit(
535
+ freq: np.ndarray,
536
+ data: np.ndarray,
537
+ measurement: Literal["reflection", "hanger", "transmission"],
538
+ tau: float | None = None,
539
+ Q_tot: float | None = None,
540
+ fr: float | None = None,
541
+ mag_bg: np.ndarray | None = None,
542
+ fit_range: float | None = None,
543
+ bias_toward_fr: bool = False,
544
+ verbose: bool = False,
545
+ do_plot: bool = False,
546
+ ) -> tuple[float, float, float, complex, float, float, float]:
547
+ """
548
+ Extracts resonator parameters from complex S-parameter data using circle fitting for reflection or hanger measurements.
549
+
550
+ This function analyzes complex-valued resonator data by fitting a circle in the complex plane and
551
+ refining key resonator parameters. It estimates or refines the total quality factor (Q_tot),
552
+ resonance frequency (fr). For reflection and hanger it also estimates the external quality
553
+ factor (Q_ext), while correcting for impedance mismatch.
554
+
555
+ Parameters
556
+ ----------
557
+ freq : np.ndarray
558
+ Frequency data.
559
+ data : np.ndarray
560
+ Complex-valued S-parameter data (e.g., S11 for reflection or S21 for hanger configuration).
561
+ measurement : {'reflection', 'hanger'}
562
+ Type of measurement setup. Use 'reflection' for S11 or 'hanger' for S21.
563
+ tau : float, optional
564
+ Initial guess for the cable delay IN RADIANS. If you are passing a value obtained from a linear fit
565
+ divide it by 2pi. If None, it is estimated from a linear fit.
566
+ Q_tot : float, optional
567
+ Initial guess for the total quality factor. If None, it is estimated from a skewed Lorentzian fit.
568
+ fr : float, optional
569
+ Initial guess for the resonance frequency. If None, it is estimated from a skewed Lorentzian fit.
570
+ mag_bg : np.ndarray, optional
571
+ Magnitude background correction data. Defaults to NaN if not provided.
572
+ fit_range: float, optional
573
+ The number x that defines the range [fr-x, fr+x] in which the fitting should be performed. The estimation of
574
+ cable delay and amplitude background will be perfomed on the full data. Defaults to `3 * fr / Q_tot`.
575
+ bias_toward_fr : bool, optional
576
+ If true performs circle fits using the 50% of points closest to the resonance. Defaults is False.
577
+ verbose : bool, optional
578
+ If True, detailed fitting results and progress are printed. Default is False.
579
+ do_plot : bool, optional
580
+ If True, plots the fitted circle and off-resonant point for visualization. Default is False.
581
+
582
+ Returns
583
+ -------
584
+ tuple
585
+ A tuple containing:
586
+ - a (float): Amplitude scaling factor from the off-resonant point.
587
+ - alpha (float): Phase offset from the off-resonant point (in radians).
588
+ - tau (float): Estimated cable delay (in radians).
589
+ - Q_tot (float): Total quality factor.
590
+ - fr (float): Resonance frequency.
591
+ - Q_ext (complex): External quality factor, accounting for impedance mismatch.
592
+ - phi0 (float): Phase shift due to impedance mismatch (in radians).
593
+
594
+ Notes
595
+ -----
596
+ - If `tau` is not provided it is estimated by fitting a line through the last 5% of phase points.
597
+ - If `Q_tot` or `fr` is not provided, they are estimated by fitting a skewed Lorentzian model.
598
+ - Visualization helps assess the quality of intermediate steps.
599
+
600
+ Examples
601
+ --------
602
+ >>> import numpy as np
603
+ >>> data = np.random.rand(100) + 1j * np.random.rand(100)
604
+ >>> a, alpha, Q_tot, Q_ext, fr, phi0, theta0 = quick_fit(data, measurement='reflection', verbose=True, do_plot=True)
605
+ >>> print(f"Resonance Frequency: {fr} Hz, Q_tot: {Q_tot}, Q_ext: {Q_ext}")
606
+ """
607
+ # Sanitize inputs
608
+ if (
609
+ measurement != "reflection"
610
+ and measurement != "hanger"
611
+ and measurement != "transmission"
612
+ ):
613
+ raise Exception(
614
+ f"Invalid measurement type {measurement}. Must be either 'reflection', 'hanger' or 'transmission'"
615
+ )
616
+ if mag_bg is None:
617
+ mag_bg = np.nan
618
+
619
+ # Define amplitude and phase
620
+ linmag = np.abs(data)
621
+ phase = np.unwrap(np.angle(data))
622
+
623
+ # Inital estimate for Q_tot and fr by fitting a lorentzian on
624
+ # the squared manitude data
625
+ if (Q_tot is None) or (fr is None):
626
+ if verbose:
627
+ print("* Lorentzian estimation of fr and Q_tot")
628
+ norm_linmag = linmag / np.max(linmag)
629
+ fit_res = fit_lorentzian(freq, norm_linmag**2)
630
+ _, efr, fwhm, _ = fit_res.params
631
+ eQ_tot = efr / fwhm
632
+ # If the lorentzian fit is bad, try a skewed lorentzian
633
+ if fit_res.metrics["nrmse"] > 0.5:
634
+ fit_res_skewed = fit_skewed_lorentzian(freq, norm_linmag**2)
635
+ (A1, A2, A3, A4, efr, eQ_tot) = fit_res.params
636
+ delta_aic = fit_res["aic"] - fit_res_skewed["aic"]
637
+ fit_res = fit_res_skewed if delta_aic >= 0 else fit_res
638
+
639
+ # Assign only parameters for which no initial guess was provided
640
+ Q_tot = Q_tot or eQ_tot
641
+ fr = fr or efr
642
+ if verbose:
643
+ print(fit_res.model_name)
644
+ fit_res.summary()
645
+ if fr != efr:
646
+ print(f" -> Still considering fr = {fr}\n")
647
+ elif Q_tot != eQ_tot:
648
+ print(f" -> Still considering Q_tot = {Q_tot}\n")
649
+
650
+ # Initial estimate for tau by fitting a line through the last 5% of phase points
651
+ if tau is None:
652
+ if verbose:
653
+ print("* Linear estimation of cable delay from the last 5% of phase points")
654
+ [_, tau] = estimate_linear_background(
655
+ freq, phase, points_cut=0.05, cut_from_back=True
656
+ )
657
+ tau /= 2 * np.pi
658
+ if verbose:
659
+ print(f" -> tau [rad]: {tau}\n")
660
+
661
+ # Remove cable delay
662
+ phase1 = phase - 2 * np.pi * tau * (freq - freq[0])
663
+ data1 = linmag * np.exp(1j * phase1)
664
+
665
+ # Cut data around the estimated resonance frequency
666
+ if fit_range is None:
667
+ fit_range = 3 * fr / Q_tot
668
+ mask = (freq > fr - fit_range) & (freq < fr + fit_range)
669
+ freq, linmag, phase, data = freq[mask], linmag[mask], phase[mask], data[mask]
670
+ phase1, data1 = phase1[mask], data1[mask]
671
+ if not np.isscalar(mag_bg):
672
+ mag_bg = mag_bg[mask]
673
+
674
+ # Move cirle to center
675
+ if bias_toward_fr:
676
+ fr_idx = np.abs(freq - fr).argmin()
677
+ idx_range = int(len(freq) / 4)
678
+ re, im = np.real(data1), np.imag(data1)
679
+ fit_res = fit_circle_algebraic(
680
+ re[fr_idx - idx_range : fr_idx + idx_range],
681
+ im[fr_idx - idx_range : fr_idx + idx_range],
682
+ )
683
+ else:
684
+ fit_res = fit_circle_algebraic(np.real(data1), np.imag(data1))
685
+ (xc, yc, r0) = fit_res.params
686
+ data3 = data1 - xc - 1j * yc
687
+ phase3 = np.unwrap(np.angle(data3))
688
+
689
+ # Fit phase vs frequency
690
+ if verbose:
691
+ print("* Phase vs frequency fit")
692
+ fit_res = fit_phase_vs_freq(freq, phase3, theta0=0, Q_tot=Q_tot, fr=fr)
693
+ (theta0, Q_tot, fr) = fit_res.params
694
+ if verbose:
695
+ fit_res.summary()
696
+
697
+ # Find the off-resonant point
698
+ p_offres = (xc + 1j * yc) + r0 * np.exp(1j * (theta0 + np.pi))
699
+ a = np.abs(p_offres)
700
+ alpha = np.angle(p_offres)
701
+ # Rescale data
702
+ linmag5 = linmag / a
703
+ phase5 = phase1 - alpha
704
+ data5 = linmag5 * np.exp(1j * phase5)
705
+
706
+ Q_ext = None
707
+ phi0 = None
708
+ if measurement == "reflection" or measurement == "hanger":
709
+ # Find impedence mismatch
710
+ if bias_toward_fr:
711
+ fr_idx = np.abs(freq - fr).argmin()
712
+ re, im = np.real(data5), np.imag(data5)
713
+ fit_res = fit_circle_algebraic(
714
+ re[fr_idx - idx_range : fr_idx + idx_range],
715
+ im[fr_idx - idx_range : fr_idx + idx_range],
716
+ )
717
+ else:
718
+ fit_res = fit_circle_algebraic(np.real(data5), np.imag(data5))
719
+ (xc6, yc6, r06) = fit_res.params
720
+ phi0 = -np.arcsin(yc6 / r06)
721
+
722
+ # Q_ext and Q_int
723
+ if measurement == "reflection":
724
+ Q_ext = Q_tot / (r06 * np.exp(-1j * phi0))
725
+ elif measurement == "hanger":
726
+ Q_ext = Q_tot / (2 * r06 * np.exp(-1j * phi0))
727
+
728
+ # Refine phase offset and amplitude scaling
729
+ if measurement == "reflection":
730
+ res6 = S11_reflection(freq, a, alpha, tau, Q_tot, Q_ext, fr, phi0, mag_bg / a)
731
+ elif measurement == "hanger":
732
+ res6 = S21_hanger(freq, a, alpha, tau, Q_tot, Q_ext, fr, phi0, mag_bg / a)
733
+ elif measurement == "transmission":
734
+ res6 = S21_transmission(freq, a, alpha, tau, Q_tot, fr)
735
+ a *= (np.max(linmag) - np.min(linmag)) / (
736
+ np.max(np.abs(res6)) - np.min(np.abs(res6))
737
+ )
738
+ alpha += phase[0] - np.unwrap(np.angle(res6))[0]
739
+
740
+ # Plot small summary
741
+ if do_plot:
742
+ v = np.linspace(0, 2 * np.pi, 100)
743
+ fig, ax = plt.subplots(1, 1, figsize=(6, 6))
744
+ ax.plot(
745
+ np.real(data1),
746
+ np.imag(data1),
747
+ "o",
748
+ label="Data cut (without cable delay)",
749
+ zorder=1,
750
+ )
751
+ ax.plot(xc + r0 * np.cos(v), yc + r0 * np.sin(v), label="Circle fit", zorder=2)
752
+ ax.scatter(
753
+ np.real(p_offres),
754
+ np.imag(p_offres),
755
+ color="tab:cyan",
756
+ label="Off-resonant point",
757
+ zorder=3,
758
+ s=120,
759
+ marker="*",
760
+ )
761
+ ax.scatter(xc, yc, color="tab:orange")
762
+ ax.set_xlabel("Re")
763
+ ax.set_ylabel("Im")
764
+ ax.axis("equal")
765
+ ax.margins(x=0.25, y=0.25)
766
+ ax.grid(True)
767
+ ax.set_title("Data fit and off-resonant point")
768
+ ax.legend()
769
+ # Show
770
+ fig.tight_layout()
771
+ plt.show()
772
+
773
+ return a, alpha, tau, Q_tot, fr, Q_ext, phi0
774
+
775
+
776
+ @fit_output
777
+ def full_fit(
778
+ freq, data, measurement, a, alpha, tau, Q_tot, fr, Q_ext=1, phi0=0, mag_bg=None
779
+ ) -> FitResult:
780
+ """
781
+ Performs a full fit of the measured resonator data using a selected model
782
+ (either reflection or hanger-type measurement). The fitting is handled
783
+ using the lmfit Model framework.
784
+
785
+ IMPORTANT: This fitting function should only be used to refine already
786
+ good initial guesses!
787
+
788
+ Parameters
789
+ ----------
790
+ freq : np.ndarray
791
+ A 1D array of frequency values in Hz.
792
+
793
+ data : np.ndarray
794
+ A 1D array of complex-valued measured resonator data.
795
+
796
+ measurement : str
797
+ Type of measurement. Should be either:
798
+ - `"reflection"`: Uses the `S11_reflection` model.
799
+ - `"hanger"`: Uses the `S21_hanger` model.
800
+
801
+ a : float
802
+ Amplitude scaling factor.
803
+
804
+ alpha : float
805
+ Phase offset parameter.
806
+
807
+ tau : float
808
+ Cable delay or propagation time.
809
+
810
+ Q_tot : float
811
+ Total quality factor of the resonator.
812
+
813
+ fr : float
814
+ Resonant frequency.
815
+
816
+ Q_ext : float
817
+ External quality factor (coupling quality factor).
818
+ Only for reflection and hanger.
819
+
820
+ phi0 : float
821
+ Phase offset at resonance. Only for relfection and hanger.
822
+
823
+ mag_bg : np.ndarray, optional
824
+ A 1D array representing the magnitude background response, if available.
825
+ Default is None.
826
+
827
+ Returns
828
+ -------
829
+ FitResult
830
+ A `FitResult` object containing:
831
+ - Fitted parameters (`params`).
832
+ - Standard errors (`std_err`).
833
+ - Goodness-of-fit metrics (`red_chi2`).
834
+ - A callable `predict` function for generating fitted responses.
835
+
836
+ Example
837
+ -------
838
+ >>> freq = np.linspace(1e9, 2e9, 1000) # Example frequency range
839
+ >>> data = np.exp(1j * freq * 2 * np.pi / 1e9) # Example complex response
840
+ >>> fit_result = full_fit(freq, data, "reflection", 1, 0, 0, 1e4, 2e4, 1.5e9, 0)
841
+ >>> fit_result.summary()
842
+ """
843
+ model_name = None
844
+
845
+ if measurement == "reflection":
846
+
847
+ def S11_reflection_fixed(freq, a, alpha, tau, Q_tot, fr, Q_ext_mag, phi):
848
+ return S11_reflection(
849
+ freq, a, alpha, tau, Q_tot, fr, Q_ext_mag, phi, mag_bg
850
+ )
851
+
852
+ model = Model(S11_reflection_fixed)
853
+ params = model.make_params(
854
+ a=a, alpha=alpha, tau=tau, Q_tot=Q_tot, fr=fr, Q_ext_mag=Q_ext, phi=phi0
855
+ )
856
+ model_name = "S11_reflection"
857
+
858
+ elif measurement == "hanger":
859
+
860
+ def S21_hanger_fixed(freq, a, alpha, tau, Q_tot, fr, Q_ext_mag, phi):
861
+ return S21_hanger(freq, a, alpha, tau, Q_tot, fr, Q_ext_mag, phi, mag_bg)
862
+
863
+ model = Model(S21_hanger_fixed)
864
+ params = model.make_params(
865
+ a=a, alpha=alpha, tau=tau, Q_tot=Q_tot, fr=fr, Q_ext_mag=Q_ext, phi=phi0
866
+ )
867
+ model_name = "S21_hanger"
868
+
869
+ elif measurement == "transmission":
870
+
871
+ def S21_transmission_fixed(freq, a, alpha, tau, Q_tot, fr):
872
+ return S21_transmission(freq, a, alpha, tau, Q_tot, fr, mag_bg)
873
+
874
+ model = Model(S21_transmission_fixed)
875
+ params = model.make_params(a=a, alpha=alpha, tau=tau, Q_tot=Q_tot, fr=fr)
876
+ model_name = "S21_transmission"
877
+
878
+ res = model.fit(data, params, freq=freq)
879
+ return res, {"model_name": model_name}
880
+
881
+
882
+ def plot_resonator(
883
+ freq, data, x_fit=None, y_fit=None, mag_bg: np.ndarray | None = None, title=""
884
+ ):
885
+ """
886
+ Plots the resonator response in three different representations:
887
+ - Complex plane (Re vs. Im)
888
+ - Magnitude response (Amplitude vs. Frequency)
889
+ - Phase response (Phase vs. Frequency)
890
+
891
+ Parameters
892
+ ----------
893
+ freq : np.ndarray
894
+ A 1D array representing the frequency values.
895
+
896
+ data : np.ndarray
897
+ A 1D array of complex-valued data points corresponding to the resonator response.
898
+
899
+ fit : np.ndarray, optional
900
+ A 1D array of complex values representing the fitted model response. Default is None.
901
+
902
+ mag_bg : np.ndarray, optional
903
+ A 1D array representing the background magnitude response, if available. Default is None.
904
+
905
+ title : str, optional
906
+ The title of the plot. Default is an empty string.
907
+ """
908
+
909
+ set_plot_style(plt)
910
+
911
+ fig = plt.figure(figsize=(24, 10))
912
+ gs = fig.add_gridspec(2, 2)
913
+ ms = plt.rcParams.get("lines.markersize")
914
+
915
+ # Subplot on the left (full height, first column)
916
+ ax1 = fig.add_subplot(gs[:, 0]) # Left side spans both rows
917
+ ax1.plot(np.real(data), np.imag(data), "o", color="tab:blue", ms=ms + 1)
918
+ if y_fit is not None:
919
+ ax1.plot(np.real(y_fit), np.imag(y_fit), color="tab:orange")
920
+ ax1.set_aspect("equal")
921
+ ax1.set_xlabel("In-phase")
922
+ ax1.set_ylabel("Quadrature")
923
+ ax1.grid(True)
924
+
925
+ # Subplot on the top-right (first row, second column)
926
+ ax2 = fig.add_subplot(gs[0, 1])
927
+ ax2.plot(freq, np.abs(data), "o", color="tab:blue", ms=ms - 1)
928
+ if y_fit is not None:
929
+ ax2.plot(x_fit, np.abs(y_fit), color="tab:orange")
930
+ if (mag_bg is not None) and (not np.isnan(mag_bg).any()):
931
+ ax2.plot(freq, mag_bg, "-.", color="tab:green")
932
+ ax2.set_ylabel("Magnitude [V]")
933
+ ax2.grid(True)
934
+
935
+ # Subplot on the bottom-right (second row, second column)
936
+ ax3 = fig.add_subplot(gs[1, 1])
937
+ ax3.plot(freq, np.unwrap(np.angle(data)), "o", color="tab:blue", ms=ms - 1)
938
+ if y_fit is not None:
939
+ ax3.plot(x_fit, np.unwrap(np.angle(y_fit)), color="tab:orange")
940
+ ax3.set_ylabel("Phase [rad]")
941
+ ax3.set_xlabel("Frequency [Hz]")
942
+ ax3.grid(True)
943
+
944
+ fig.suptitle(title)
945
+ fig.tight_layout()
946
+
947
+ return fig, (ax1, ax2, ax3)
948
+
949
+
950
+ def print_resonator_params(fit_params, measurement):
951
+ table_data = []
952
+
953
+ if measurement == "reflection" or measurement == "hanger":
954
+ a, alpha, tau, Q_tot, fr, Q_ext_mag, phi0 = fit_params
955
+ Q_ext = Q_ext_mag * np.exp(1j * phi0)
956
+ Q_int = compute_Q_int(Q_tot, Q_ext_mag, phi0)
957
+ kappa_ext = fr / np.real(Q_ext)
958
+ kappa_int = fr / Q_int
959
+ kappa_tot = fr / Q_tot
960
+
961
+ table_data.append(["fr", f"{format_number(fr, 6, unit="Hz", latex=False)}"])
962
+ table_data.append(["Re[Q_ext]", f"{np.real(Q_ext):.0f}"])
963
+ table_data.append(["Q_int", f"{Q_int:.0f}"])
964
+ table_data.append(["Q_tot", f"{Q_tot:.0f}"])
965
+
966
+ table_data.append(
967
+ ["kappa_ext", format_number(kappa_ext, 4, unit="Hz", latex=False)]
968
+ )
969
+ table_data.append(
970
+ ["kappa_int", format_number(kappa_int, 4, unit="Hz", latex=False)]
971
+ )
972
+ table_data.append(
973
+ ["kappa_tot", format_number(kappa_tot, 4, unit="Hz", latex=False)]
974
+ )
975
+ elif measurement == "transmission":
976
+ a, alpha, tau, Q_tot, fr = fit_params
977
+ kappa_tot = fr / Q_tot
978
+ table_data.append(["fr", f"{format_number(fr, 6, unit="Hz", latex=False)}"])
979
+ table_data.append(["Q_tot", f"{Q_tot:.0f}"])
980
+ table_data.append(
981
+ ["kappa_tot", format_number(kappa_tot, 4, unit="Hz", latex=False)]
982
+ )
983
+
984
+ print(tabulate(table_data, headers=["Param", "Value"], tablefmt="github"))
985
+
986
+
987
+ def compute_Q_int(Q_tot, Q_ext_mag, Q_ext_phase):
988
+ """Compute Q_internal given Q_total and the manitude and phase of Q_external."""
989
+ return 1 / (1 / Q_tot - np.real(1 / (Q_ext_mag * np.exp(-1j * Q_ext_phase))))