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