From de4a3fb8db30f41301455c1f1c2a4e41cb60d57c Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 6 May 2025 14:49:48 +0200 Subject: [PATCH 01/43] resonatorV2 first version --- .../circle_fit/circle_fit_2019/circuit.py | 2 +- src/qkit/analysis/resonatorV2.py | 385 ++++++++++++++++++ tests/resonator_fits/SVSEWX_VNA_tracedata.h5 | Bin 0 -> 579360 bytes tests/resonator_fits/resonator_fits.py | 38 ++ 4 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 src/qkit/analysis/resonatorV2.py create mode 100644 tests/resonator_fits/SVSEWX_VNA_tracedata.h5 create mode 100644 tests/resonator_fits/resonator_fits.py diff --git a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py index 86611b216..a1fad2209 100644 --- a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py +++ b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py @@ -400,7 +400,7 @@ def _fit_phase(self, z_data, guesses=None): """ Fits the phase response of a strongly overcoupled (Qi >> Qc) resonator in reflection which corresponds to a circle centered around the origin - (cf‌. phase_centered()). + (cf. phase_centered()). inputs: - z_data: Scattering data of which the phase should be fit. Data must be diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonatorV2.py new file mode 100644 index 000000000..7bb17d345 --- /dev/null +++ b/src/qkit/analysis/resonatorV2.py @@ -0,0 +1,385 @@ +import numpy as np +from abc import ABC, abstractmethod +import scipy.optimize as spopt +import scipy.ndimage +import logging + +class ResonatorFitBase(ABC): + """ + Defines the core functionality any fit function should offer, namely freq, amp & phase in, simulated freq, amp, phase + extracted data out. + Contrary to previous version, fit-functionality is completely decoupled from the h5 file format, this should be handled in the respective measurement script instead. + Complex IQ data + view should be created in measurement script by default as well. + + extract_data dict contains information like f_res, Qc, etc. and its (final) keys should be static accessible for each fit function class overriding this base implementation. + This allows preparing hdf datasets for them without them being calculated yet + """ + def __init__(self): + self.freq_fit: np.ndarray[float] = None + self.amp_fit: np.ndarray[float] = None + self.pha_fit: np.ndarray[float] = None + self.extract_data: dict[str, float] = {} + self.out_nop = 501 # number of points the output fits are plotted with + + @abstractmethod + def do_fit(self, freq: np.ndarray[float] = [0, 1], amp: np.ndarray[float] = None, pha: np.ndarray[float] = None): + logging.error("Somehow ran into abstract resonator fit base class fit-function") + self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) + self.amp_fit = np.ones(self.out_nop) + self.pha_fit = np.zeros(self.out_nop) + self.extract_data = {} + return self + +class CircleFit(ResonatorFitBase): + def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: float = None, isolation: int = 15, guesses: list[float] = None): + super().__init__() + self.extract_data = { + "f_res": None, + "f_res_err": None, + "Qc": None, + #"Qc_err": None, + "Qc_no_dia_corr": None, + "Qc_no_dia_corr_err": None, + "Qc_max": None, + "Qc_min": None, + "Qi": None, + "Qi_err": None, + "Qi_no_dia_corr": None, + "Qi_no_dia_corr_err": None, + "Qi_max": None, + "Qi_min": None, + "Ql": None, + "Ql_err": None, + "a": None, + "alpha": None, + "chi2": None, + "delay": None, + "delay_remaining": None, + "fano_b": None, + "phi": None, + "phi_err": None, + "theta": None, + } + # fit parameters + self.n_ports = n_ports # 1: reflection port, 2: notch port + self.fit_delay_max_iterations = fit_delay_max_iterations + self.fixed_delay = fixed_delay + self.isolation = isolation + self.guesses = guesses # init guess (f_res, Ql, delay) for phase fit + + def do_fit(self, freq: np.ndarray[float], amp: np.ndarray[float], pha: np.ndarray[float]): + z = amp*np.exp(1j*pha) + """ helper functions""" + _phase_centered = lambda f, fr, Ql, theta, delay=0: theta - 2*np.pi*delay*(f-fr) + 2.*np.arctan(2.*Ql*(1. - f/fr)) + _periodic_boundary = lambda angle: (angle + np.pi) % (2*np.pi) - np.pi + Sij = lambda f, fr, Ql, Qc, phi=0, a=1, alpha=0, delay=0: a*np.exp(1j*(alpha-2*np.pi*f*delay)) * (1 - 2*Ql/(Qc*np.cos(phi)*np.exp(-1j*phi)*self.n_ports*(1 + 2j*Ql*(f/fr-1)))) + def _fit_circle(z_data: np.ndarray): + """ + Analytical fit of a circle to the scattering data z_data. Cf. Sebastian + Probst: "Efficient and robust analysis of complex scattering data under + noise in microwave resonators" (arXiv:1410.3365v2) + """ + + # Normalize circle to deal with comparable numbers + x_norm = 0.5*(np.max(z_data.real) + np.min(z_data.real)) + y_norm = 0.5*(np.max(z_data.imag) + np.min(z_data.imag)) + z_data = z_data[:] - (x_norm + 1j*y_norm) + amp_norm = np.max(np.abs(z_data)) + z_data = z_data / amp_norm + + # Calculate matrix of moments + xi = z_data.real + xi_sqr = xi*xi + yi = z_data.imag + yi_sqr = yi*yi + zi = xi_sqr+yi_sqr + Nd = float(len(xi)) + xi_sum = xi.sum() + yi_sum = yi.sum() + zi_sum = zi.sum() + xiyi_sum = (xi*yi).sum() + xizi_sum = (xi*zi).sum() + yizi_sum = (yi*zi).sum() + M = np.array([ + [(zi*zi).sum(), xizi_sum, yizi_sum, zi_sum], + [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum], + [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum], + [zi_sum, xi_sum, yi_sum, Nd] + ]) + + # Lets skip line breaking at 80 characters for a moment :D + a0 = ((M[2][0]*M[3][2]-M[2][2]*M[3][0])*M[1][1]-M[1][2]*M[2][0]*M[3][1]-M[1][0]*M[2][1]*M[3][2]+M[1][0]*M[2][2]*M[3][1]+M[1][2]*M[2][1]*M[3][0])*M[0][3]+(M[0][2]*M[2][3]*M[3][0]-M[0][2]*M[2][0]*M[3][3]+M[0][0]*M[2][2]*M[3][3]-M[0][0]*M[2][3]*M[3][2])*M[1][1]+(M[0][1]*M[1][3]*M[3][0]-M[0][1]*M[1][0]*M[3][3]-M[0][0]*M[1][3]*M[3][1])*M[2][2]+(-M[0][1]*M[1][2]*M[2][3]-M[0][2]*M[1][3]*M[2][1])*M[3][0]+((M[2][3]*M[3][1]-M[2][1]*M[3][3])*M[1][2]+M[2][1]*M[3][2]*M[1][3])*M[0][0]+(M[1][0]*M[2][3]*M[3][2]+M[2][0]*(M[1][2]*M[3][3]-M[1][3]*M[3][2]))*M[0][1]+((M[2][1]*M[3][3]-M[2][3]*M[3][1])*M[1][0]+M[1][3]*M[2][0]*M[3][1])*M[0][2] + a1 = (((M[3][0]-2.*M[2][2])*M[1][1]-M[1][0]*M[3][1]+M[2][2]*M[3][0]+2.*M[1][2]*M[2][1]-M[2][0]*M[3][2])*M[0][3]+(2.*M[2][0]*M[3][2]-M[0][0]*M[3][3]-2.*M[2][2]*M[3][0]+2.*M[0][2]*M[2][3])*M[1][1]+(-M[0][0]*M[3][3]+2.*M[0][1]*M[1][3]+2.*M[1][0]*M[3][1])*M[2][2]+(-M[0][1]*M[1][3]+2.*M[1][2]*M[2][1]-M[0][2]*M[2][3])*M[3][0]+(M[1][3]*M[3][1]+M[2][3]*M[3][2])*M[0][0]+(M[1][0]*M[3][3]-2.*M[1][2]*M[2][3])*M[0][1]+(M[2][0]*M[3][3]-2.*M[1][3]*M[2][1])*M[0][2]-2.*M[1][2]*M[2][0]*M[3][1]-2.*M[1][0]*M[2][1]*M[3][2]) + a2 = ((2.*M[1][1]-M[3][0]+2.*M[2][2])*M[0][3]+(2.*M[3][0]-4.*M[2][2])*M[1][1]-2.*M[2][0]*M[3][2]+2.*M[2][2]*M[3][0]+M[0][0]*M[3][3]+4.*M[1][2]*M[2][1]-2.*M[0][1]*M[1][3]-2.*M[1][0]*M[3][1]-2.*M[0][2]*M[2][3]) + a3 = (-2.*M[3][0]+4.*M[1][1]+4.*M[2][2]-2.*M[0][3]) + a4 = -4. + + def char_pol(x): + return a0 + a1*x + a2*x**2 + a3*x**3 + a4*x**4 + + def d_char_pol(x): + return a1 + 2*a2*x + 3*a3*x**2 + 4*a4*x**3 + + eta = spopt.newton(char_pol, 0., fprime=d_char_pol) + + M[3][0] = M[3][0] + 2*eta + M[0][3] = M[0][3] + 2*eta + M[1][1] = M[1][1] - eta + M[2][2] = M[2][2] - eta + + U,s,Vt = np.linalg.svd(M) + A_vec = Vt[np.argmin(s),:] + + xc = -A_vec[1]/(2.*A_vec[0]) + yc = -A_vec[2]/(2.*A_vec[0]) + # The term *sqrt term corrects for the constraint, because it may be + # altered due to numerical inaccuracies during calculation + r0 = 1./(2.*np.absolute(A_vec[0]))*np.sqrt( + A_vec[1]*A_vec[1]+A_vec[2]*A_vec[2]-4.*A_vec[0]*A_vec[3] + ) + + return xc*amp_norm+x_norm, yc*amp_norm+y_norm, r0*amp_norm + def _fit_phase(z_data: np.ndarray, guesses = self.guesses): + """ + Fits the phase response of a strongly overcoupled (Qi >> Qc) resonator + in reflection which corresponds to a circle centered around the origin + (cf. phase_centered()). + + inputs: + - z_data: Scattering data of which the phase should be fit. Data must be + distributed around origin ("circle-like"). + - guesses (opt.): If not given, initial guesses for the fit parameters + will be determined. If given, should contain useful + guesses for fit parameters as a tuple (fr, Ql, delay) + + outputs: + - fr: Resonance frequency + - Ql: Loaded quality factor + - theta: Offset phase + - delay: Time delay between output and input signal leading to linearly + frequency dependent phase shift + """ + phase = np.unwrap(np.angle(z_data)) + + # For centered circle roll-off should be close to 2pi. If not warn user. + if np.max(phase) - np.min(phase) <= 0.8*2*np.pi: + logging.warning( + "Data does not cover a full circle (only {:.1f}".format( + np.max(phase) - np.min(phase) + ) + +" rad). Increase the frequency span around the resonance?" + ) + roll_off = np.max(phase) - np.min(phase) + else: + roll_off = 2*np.pi + + # Set useful starting parameters + if guesses is None: + # Use maximum of derivative of phase as guess for fr + phase_smooth = scipy.ndimage.gaussian_filter1d(phase, 30) + phase_derivative = np.gradient(phase_smooth) + fr_guess = freq[np.argmax(np.abs(phase_derivative))] + Ql_guess = 2*fr_guess / (freq[-1] - freq[0]) + # Estimate delay from background slope of phase (substract roll-off) + slope = phase[-1] - phase[0] + roll_off + delay_guess = -slope / (2*np.pi*(freq[-1]-freq[0])) + else: + fr_guess, Ql_guess, delay_guess = guesses + # This one seems stable and we do not need a manual guess for it + theta_guess = 0.5*(np.mean(phase[:5]) + np.mean(phase[-5:])) + + # Fit model with less parameters first to improve stability of fit + + def residuals_Ql(params): + Ql, = params + return residuals_full((fr_guess, Ql, theta_guess, delay_guess)) + def residuals_fr_theta(params): + fr, theta = params + return residuals_full((fr, Ql_guess, theta, delay_guess)) + def residuals_delay(params): + delay, = params + return residuals_full((fr_guess, Ql_guess, theta_guess, delay)) + def residuals_fr_Ql(params): + fr, Ql = params + return residuals_full((fr, Ql, theta_guess, delay_guess)) + def residuals_full(params): + return np.pi - np.abs(np.pi - np.abs(phase - _phase_centered(freq, *params))) + + p_final = spopt.leastsq(residuals_Ql, [Ql_guess]) + Ql_guess, = p_final[0] + p_final = spopt.leastsq(residuals_fr_theta, [fr_guess, theta_guess]) + fr_guess, theta_guess = p_final[0] + p_final = spopt.leastsq(residuals_delay, [delay_guess]) + delay_guess, = p_final[0] + p_final = spopt.leastsq(residuals_fr_Ql, [fr_guess, Ql_guess]) + fr_guess, Ql_guess = p_final[0] + p_final = spopt.leastsq(residuals_full, [fr_guess, Ql_guess, theta_guess, delay_guess]) + + return p_final[0] + + """delay""" + if self.fixed_delay is not None: + self.extract_data["delay"] = self.fixed_delay + else: + xc, yc, r0 = _fit_circle(z) + z_data = z - complex(xc, yc) + fr, Ql, theta, delay = _fit_phase(z_data) + delay *= 0.05 + + for i in range(self.fit_delay_max_iterations): + # Translate new best fit data to origin + z_data = z * np.exp(2j*np.pi*delay*freq) + xc, yc, r0 = _fit_circle(z_data) + z_data -= complex(xc, yc) + + # Find correction to current delay + guesses = (fr, Ql, 5e-11) + fr, Ql, theta, delay_corr = _fit_phase(z_data, guesses) + + # Stop if correction would be smaller than "measurable" + phase_fit = _phase_centered(freq, fr, Ql, theta, delay_corr) + residuals = np.unwrap(np.angle(z_data)) - phase_fit + if 2*np.pi*(np.max(freq) - np.min(freq))*delay_corr <= np.std(residuals): + break + + # Avoid overcorrection that makes procedure switch between positive + # and negative delays + if delay_corr*delay < 0: # different sign -> be careful + if abs(delay_corr) > abs(delay): + delay *= 0.5 + else: + delay += 0.1*np.sign(delay_corr)*5e-11 + else: # same direction -> can converge faster + if abs(delay_corr) >= 1e-8: + delay += min(delay_corr, delay) + elif abs(delay_corr) >= 1e-9: + delay *= 1.1 + else: + delay += delay_corr + + if 2*np.pi*(freq[-1]-freq[0])*delay_corr > np.std(residuals): + logging.warning("Delay could not be fit properly!") + + self.extract_data["delay"] = delay + + """calibrate""" + z_data = z*np.exp(2j*np.pi*delay*freq) # correct delay + xc, yc, r0 = _fit_circle(z_data) + z_data -= complex(xc, yc) + + # Find off-resonant point by fitting offset phase + # (centered circle corresponds to lossless resonator in reflection) + fr, Ql, theta, delay_remaining = _fit_phase(z_data) + theta = _periodic_boundary(theta) + beta = _periodic_boundary(theta - np.pi) + offrespoint = complex(xc, yc) + r0*np.exp(1j*beta) + a = np.absolute(offrespoint) + alpha = np.angle(offrespoint) + phi = _periodic_boundary(beta - alpha) + + r0 /= a + + # Store results in dictionary + self.extract_data["delay_remaining"] = delay_remaining + self.extract_data["a"] = a + self.extract_data["alpha"] = alpha + self.extract_data["theta"] = theta + self.extract_data["phi"] = phi + self.extract_data["f_res"] = fr + self.extract_data["Ql"] = Ql + + """normalize""" + z_norm = z/a*np.exp(1j*(-alpha + 2.*np.pi*delay*freq)) + + """extract Qs""" + absQc = Ql / (self.n_ports*r0) + # For Qc, take real part of 1/(complex Qc) (diameter correction method) + Qc = absQc / np.cos(phi) + Qi = 1/(1/Ql - 1/Qc) + Qi_no_dia_corr = 1/(1/Ql - 1/absQc) + + self.extract_data["Qc"] = Qc + self.extract_data["Qc_no_dia_corr"] = absQc + self.extract_data["Qi"] = Qi + self.extract_data["Qi_no_dia_corr"] = Qi_no_dia_corr + + """errors""" + residuals = z_norm - Sij(freq, fr, Ql, Qc, phi) + chi = np.abs(residuals) + # Unit vectors pointing in the correct directions for the derivative + directions = residuals / chi + # Prepare for fast construction of Jacobian + conj_directions = np.conj(directions) + + # Construct transpose of Jacobian matrix + Jt = np.array([ + np.real(-4j*Ql**2*np.exp(1j*phi)*freq / (self.n_ports * absQc*(fr+2j*Ql*(freq-fr))**2)*conj_directions), + np.real(-2*np.exp(1j*phi) / (self.n_ports * absQc*(1+2j*Ql*(freq/fr-1))**2)*conj_directions), + np.real(2*Ql*np.exp(1j*phi) / (self.n_ports * absQc**2 * (1+2j*Ql*(freq/fr-1)))*conj_directions), + np.real(-2j*Ql*np.exp(1j*phi) / (self.n_ports * absQc * (1.+2j*Ql*(freq/fr-1)))*conj_directions) + ]) + A = np.dot(Jt, np.transpose(Jt)) + # 4 fit parameters reduce degrees of freedom for reduced chi square + chi_square = 1/float(len(freq)-4) * np.sum(chi**2) + try: + cov = np.linalg.inv(A)*chi_square + except: + logging.warning("Error calculation failed!") + cov = None + + if cov is not None: + fr_err, Ql_err, absQc_err, phi_err = np.sqrt(np.diag(cov)) + # Calculate error of Qi with error propagation + # without diameter correction + dQl = 1/((1/Ql - 1/absQc) * Ql)**2 + dabsQc = -1/((1/Ql - 1/absQc) * absQc)**2 + Qi_no_dia_corr_err = np.sqrt(dQl**2*cov[1][1] + dabsQc**2*cov[2][2] + 2.*dQl*dabsQc*cov[1][2]) + # with diameter correction + dQl = 1/((1/Ql - 1/Qc) * Ql)**2 + dabsQc = -np.cos(phi) / ((1/Ql - 1/Qc) * absQc)**2 + dphi = -np.sin(phi) / ((1/Ql - 1/Qc)**2 * absQc) + Qi_err = np.sqrt(dQl**2*cov[1][1] + dabsQc**2*cov[2][2] + dphi**2*cov[3][3] + 2*(dQl*dabsQc*cov[1][2]+ dQl*dphi*cov[1][3]+ dabsQc*dphi*cov[2][3])) + + self.extract_data["f_res_err"] = fr_err + self.extract_data["Ql_err"] = Ql_err + self.extract_data["Qc_no_dia_corr_err"] = absQc_err + self.extract_data["phi_err"] = phi_err + self.extract_data["Qi_err"] = Qi_err + self.extract_data["Qi_no_dia_corr_err"] = Qi_no_dia_corr_err + self.extract_data["chi_square"] = chi_square + + """calc fano range""" + b = 10**(-self.isolation/20) + b = b / (1 - b) + + if np.sin(phi) > b: + logging.warning("Measurement cannot be explained with assumed Fano leakage!") + + # Calculate error on radius of circle + R_mid = r0 * np.cos(phi) + R_err = r0 * np.sqrt(b**2 - np.sin(phi)**2) + R_min = R_mid - R_err + R_max = R_mid + R_err + + # Convert to ranges of quality factors + Qc_min = Ql / (self.n_ports*R_max) + Qc_max = Ql / (self.n_ports*R_min) + Qi_min = Ql / (1 - self.n_ports*R_min) + Qi_max = Ql / (1 - self.n_ports*R_max) + + # Handle unphysical results + if R_max >= 1/self.n_ports: + Qi_max = np.nan + + self.extract_data["Qc_min"] = Qc_min + self.extract_data["Qc_max"] = Qc_max + self.extract_data["Qi_min"] = Qi_min + self.extract_data["Qi_max"] = Qi_max + self.extract_data["fano_b"] = b + + """model data""" + self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) + z_fit = Sij(self.freq_fit, fr, Ql, Qc, phi, a, alpha, delay) + self.amp_fit = np.abs(z_fit) + self.pha_fit = np.angle(z_fit) + + return self \ No newline at end of file diff --git a/tests/resonator_fits/SVSEWX_VNA_tracedata.h5 b/tests/resonator_fits/SVSEWX_VNA_tracedata.h5 new file mode 100644 index 0000000000000000000000000000000000000000..8d705b587b890b1d533512fc437c2b43776558cd GIT binary patch literal 579360 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%0D`3Yl?|rqr?Z5WtPs8K$&dcBA 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 literal 0 HcmV?d00001 diff --git a/tests/resonator_fits/resonator_fits.py b/tests/resonator_fits/resonator_fits.py new file mode 100644 index 000000000..36e5d0e29 --- /dev/null +++ b/tests/resonator_fits/resonator_fits.py @@ -0,0 +1,38 @@ +import pytest + +def test_circlefit(): + from qkit.analysis.resonatorV2 import CircleFit + from qkit.storage.store import Data # for reading file + import numpy as np + + datafile = Data("C:/Users/mariu/Desktop/Ordner/Studium/qkit_development/tests/resonator_fits/SVSEWX_VNA_tracedata.h5") + freq = np.array(datafile.data.frequency) + amp = np.array(datafile.data.amplitude) + pha = np.array(datafile.data.phase) + + my_circle_fit = CircleFit(n_ports=2) # notch port + my_circle_fit.do_fit(freq, amp, pha) + + print(my_circle_fit.extract_data) + + # check reasonable qc and f_res in 2 sigma interval + assert my_circle_fit.extract_data["Qc"] > 0 + assert (my_circle_fit.extract_data["f_res"] > 5.57019e9*(1 - 2/334)) & (my_circle_fit.extract_data["f_res"] < 5.57019e9*(1 + 2/334)) + + if __name__ == "__main__": + import matplotlib.pyplot as plt + + plt.plot(freq, amp, "ko") + plt.plot(my_circle_fit.freq_fit, my_circle_fit.amp_fit, "r-") + plt.title("Amplitude") + plt.show() + + plt.plot(freq, pha, "ko") + plt.plot(my_circle_fit.freq_fit, my_circle_fit.pha_fit, "r-") + plt.title("Phase") + plt.show() + + plt.plot(amp*np.cos(pha), amp*np.sin(pha), "ko") + plt.plot(my_circle_fit.amp_fit*np.cos(my_circle_fit.pha_fit), my_circle_fit.amp_fit*np.sin(my_circle_fit.pha_fit), "r-") + plt.title("IQ Circle") + plt.show() From a759dc71ca79f1374ad567ba24c4e1435963b87d Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Wed, 7 May 2025 18:37:01 +0200 Subject: [PATCH 02/43] First untested version --- src/qkit/analysis/resonatorV2.py | 75 +++++- src/qkit/measure/spectroscopy/spectroscopy.py | 226 +++++++++++------- tests/resonator_fits/resonator_fits.py | 3 + 3 files changed, 222 insertions(+), 82 deletions(-) diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonatorV2.py index 7bb17d345..9a263edcc 100644 --- a/src/qkit/analysis/resonatorV2.py +++ b/src/qkit/analysis/resonatorV2.py @@ -28,6 +28,7 @@ def do_fit(self, freq: np.ndarray[float] = [0, 1], amp: np.ndarray[float] = None self.pha_fit = np.zeros(self.out_nop) self.extract_data = {} return self + class CircleFit(ResonatorFitBase): def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: float = None, isolation: int = 15, guesses: list[float] = None): @@ -382,4 +383,76 @@ def residuals_full(params): self.amp_fit = np.abs(z_fit) self.pha_fit = np.angle(z_fit) - return self \ No newline at end of file + return self + + +class LorentzianFit(ResonatorFitBase): + def __init__(self): + super().__init__() + self.extract_data = { + "f_res": None, + "f_res_err": None, + "Qc": None, + "Qc_err": None, + # TODO + } + + def do_fit(self, freq, amp, pha): + # TODO + logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") + self.extract_data["f_res"] = 1 + self.extract_data["f_res_err"] = 0.5 + self.extract_data["Qc"] = 1 + self.extract_data["Qc_err"] = 0.5 + return super().do_fit(freq, amp, pha) + + +class SkewedLorentzianFit(ResonatorFitBase): + def __init__(self): + super().__init__() + self.extract_data = { + "f_res": None, + "f_res_err": None, + "Qc": None, + "Qc_err": None, + # TODO + } + + def do_fit(self, freq, amp, pha): + # TODO + logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") + self.extract_data["f_res"] = 1 + self.extract_data["f_res_err"] = 0.5 + self.extract_data["Qc"] = 1 + self.extract_data["Qc_err"] = 0.5 + return super().do_fit(freq, amp, pha) + + +class FanoFit(ResonatorFitBase): + def __init__(self): + super().__init__() + self.extract_data = { + "f_res": None, + "f_res_err": None, + "Qc": None, + "Qc_err": None, + # TODO + } + + def do_fit(self, freq, amp, pha): + # TODO + logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") + self.extract_data["f_res"] = 1 + self.extract_data["f_res_err"] = 0.5 + self.extract_data["Qc"] = 1 + self.extract_data["Qc_err"] = 0.5 + return super().do_fit(freq, amp, pha) + + +FitNames: dict[str, ResonatorFitBase] = { + 'lorentzian': LorentzianFit, + 'skewed_lorentzian': SkewedLorentzianFit, + 'circle_fit_reflection': CircleFit, + 'circle_fit_notch': CircleFit, + 'fano': FanoFit, +} \ No newline at end of file diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 59fbb19e0..094eb352e 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -23,13 +23,14 @@ import threading import qkit +import qkit.analysis.resonatorV2 as resonatorFit if qkit.module_available("matplotlib"): import matplotlib.pylab as plt if qkit.module_available("scipy"): from scipy.optimize import curve_fit from scipy.interpolate import interp1d, UnivariateSpline from qkit.storage import store as hdf -from qkit.analysis.resonator import Resonator as resonator +#from qkit.analysis.resonator import Resonator as resonator from qkit.gui.plot import plot as qviewkit from qkit.gui.notebook.Progress_Bar import Progress_Bar from qkit.measure.measurement_class import Measurement @@ -259,7 +260,9 @@ def _prepare_measurement_file(self): self._mo.append(self._measurement_object.get_JSON()) # write logfile and instrument settings - self._write_settings_dataset() + self._settings = self._data_file.add_textlist('settings') + settings = waf.get_instrument_settings(self._data_file.get_filepath()) + self._settings.append(settings) self._log = waf.open_log_file(self._data_file.get_filepath()) self._data_freq = self._data_file.add_coordinate('frequency', unit='Hz') @@ -271,37 +274,61 @@ def _prepare_measurement_file(self): self._data_time = self._data_file.add_coordinate('time', unit='s') self._data_time.add(np.arange(0, self._nop, 1) * self.vna.get_sweeptime() / (self._nop - 1)) sweep_vector = self._data_time + + if self._fit_resonator: + self._fit_select = (self._freqpoints >= self._f_min) & (self._freqpoints <= self._f_max) + + self._fit_freq = self._data_file.add_coordinate('_fit_frequency', unit='Hz', folder="analysis") + self._fit_freq.add(self._freqpoints[self._fit_select]) if self._scan_dim == 1: self._data_real = self._data_file.add_value_vector('real', x=sweep_vector, unit='', save_timestamp=True) self._data_imag = self._data_file.add_value_vector('imag', x=sweep_vector, unit='', save_timestamp=True) - self._data_amp = self._data_file.add_value_vector('amplitude', x=sweep_vector, unit='arb. unit', - save_timestamp=True) - self._data_pha = self._data_file.add_value_vector('phase', x=sweep_vector, unit='rad', - save_timestamp=True) + self._data_amp = self._data_file.add_value_vector('amplitude', x=sweep_vector, unit='arb. unit', save_timestamp=True) + self._data_pha = self._data_file.add_value_vector('phase', x=sweep_vector, unit='rad', save_timestamp=True) + + self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) + + if self._fit_resonator: + self._fit_amp = self._data_file.add_value_vector("_fit_amplitude", x=self._fit_freq, unit="arb. unit", folder="analysis") + self._fit_pha = self._data_file.add_value_vector("_fit_phase", x=self._fit_freq, unit="rad", folder="analysis") + self._fit_real = self._data_file.add_value_vector("_fit_real", x=self._fit_freq, unit="", folder="analysis") + self._fit_imag = self._data_file.add_value_vector("_fit_imag", x=self._fit_freq, unit="", folder="analysis") + # extract data is single datapoint for 1D measure, add as coordinate after measurement manually if self._scan_dim == 2: self._data_x = self._data_file.add_coordinate(self.x_coordname, unit=self.x_unit) self._data_x.add(self.x_vec) - self._data_amp = self._data_file.add_value_matrix('amplitude', x=self._data_x, y=sweep_vector, - unit='arb. unit', save_timestamp=True) - self._data_pha = self._data_file.add_value_matrix('phase', x=self._data_x, y=sweep_vector, unit='rad', - save_timestamp=True) + + self._data_real = self._data_file.add_value_matrix('real', x=self._data_x, y=sweep_vector, unit='', save_timestamp=False) + self._data_imag = self._data_file.add_value_matrix('imag', x=self._data_x, y=sweep_vector, unit='', save_timestamp=False) + self._data_amp = self._data_file.add_value_matrix('amplitude', x=self._data_x, y=sweep_vector, unit='arb. unit', save_timestamp=True) + self._data_pha = self._data_file.add_value_matrix('phase', x=self._data_x, y=sweep_vector, unit='rad', save_timestamp=True) + + self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) + + if self._fit_resonator: + self._fit_amp = self._data_file.add_value_matrix("_fit_amplitude", x=self._data_x, y=sweep_vector, unit="arb. unit", folder="analysis") + self._fit_pha = self._data_file.add_value_matrix("_fit_phase", x=self._data_x, y=sweep_vector, unit="rad", folder="analysis") + self._fit_real = self._data_file.add_value_matrix("_fit_real", x=self._data_x, y=sweep_vector, unit="", folder="analysis") + self._fit_imag = self._data_file.add_value_matrix("_fit_imag", x=self._data_x, y=sweep_vector, unit="", folder="analysis") + + self._fit_extracts = {} + for key in self._fit_function.extract_data.keys(): + self._fit_extracts[key] = self._data_file.add_value_vector("fit_" + key, x=self._data_x, unit="", folder="analysis") if self.log_function != None: # use logging self._log_value = [] for i in range(len(self.log_function)): self._log_value.append( - self._data_file.add_value_vector(self.log_name[i], x=self._data_x, unit=self.log_unit[i], - dtype=self.log_dtype[i])) + self._data_file.add_value_vector(self.log_name[i], x=self._data_x, unit=self.log_unit[i], dtype=self.log_dtype[i]) + ) if self._nop < 10: """creates view: plot middle point vs x-parameter, for qubit measurements""" self._views = [ - self._data_file.add_view("amplitude_midpoint",x=self._data_x,y=self._data_amp, - view_params=dict(transpose=True,default_trace=self._nop // 2,linecolors=[(200,200,100)])), - self._data_file.add_view("phase_midpoint", x=self._data_x, y=self._data_pha, - view_params=dict(transpose=True, default_trace=self._nop // 2, linecolors=[(200, 200, 100)])) + self._data_file.add_view("amplitude_midpoint", x=self._data_x, y=self._data_amp, view_params=dict(transpose=True, default_trace=self._nop // 2,linecolors=[(200, 200, 100)])), + self._data_file.add_view("phase_midpoint", x=self._data_x, y=self._data_pha, view_params=dict(transpose=True, default_trace=self._nop // 2, linecolors=[(200, 200, 100)])) ] if self._scan_dim == 3: @@ -311,23 +338,32 @@ def _prepare_measurement_file(self): self._data_y.add(self.y_vec) if self._nop == 0: # saving in a 2D matrix instead of a 3D box HR: does not work yet !!! test things before you put them online. - self._data_amp = self._data_file.add_value_matrix('amplitude', x=self._data_x, y=self._data_y, - unit='arb. unit', save_timestamp=False) - self._data_pha = self._data_file.add_value_matrix('phase', x=self._data_x, y=self._data_y, unit='rad', - save_timestamp=False) + self._data_amp = self._data_file.add_value_matrix('amplitude', x=self._data_x, y=self._data_y, unit='arb. unit', save_timestamp=False) + self._data_pha = self._data_file.add_value_matrix('phase', x=self._data_x, y=self._data_y, unit='rad', save_timestamp=False) else: - self._data_amp = self._data_file.add_value_box('amplitude', x=self._data_x, y=self._data_y, - z=sweep_vector, unit='arb. unit', - save_timestamp=False) - self._data_pha = self._data_file.add_value_box('phase', x=self._data_x, y=self._data_y, - z=sweep_vector, unit='rad', save_timestamp=False) + self._data_real = self._data_file.add_value_box('real', x=self._data_x, y=self._data_y, z=sweep_vector, unit='', save_timestamp=False) + self._data_imag = self._data_file.add_value_box('imag', x=self._data_x, y=self._data_y, z=sweep_vector, unit='', save_timestamp=False) + self._data_amp = self._data_file.add_value_box('amplitude', x=self._data_x, y=self._data_y, z=sweep_vector, unit='arb. unit', save_timestamp=True) + self._data_pha = self._data_file.add_value_box('phase', x=self._data_x, y=self._data_y, z=sweep_vector, unit='rad', save_timestamp=True) + + self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) + + if self._fit_resonator: + self._fit_amp = self._data_file.add_value_box("_fit_amplitude", x=self._data_x, y=self._data_y, z=sweep_vector, unit="arb. unit", folder="analysis") + self._fit_pha = self._data_file.add_value_box("_fit_phase", x=self._data_x, y=self._data_y, z=sweep_vector, unit="rad", folder="analysis") + self._fit_real = self._data_file.add_value_box("_fit_real", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") + self._fit_imag = self._data_file.add_value_box("_fit_imag", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") + + self._fit_extracts = {} + for key in self._fit_function.extract_data.keys(): + self._fit_extracts[key] = self._data_file.add_value_matrix("fit_" + key, x=self._data_x, y=self._data_y, unit="", folder="analysis") if self.log_function != None: # use logging self._log_value = [] for i in range(len(self.log_function)): self._log_value.append( - self._data_file.add_value_vector(self.log_name[i], x=self._data_x, unit=self.log_unit[i], - dtype=self.log_dtype[i])) + self._data_file.add_value_vector(self.log_name[i], x=self._data_x, unit=self.log_unit[i], dtype=self.log_dtype[i]) + ) if self.log_function_2D != None: # use 2D logging self._log_y_value_2D = [] @@ -341,8 +377,15 @@ def _prepare_measurement_file(self): self._log_value_2D = [] for i in range(len(self.log_function_2D)): self._log_value_2D.append( - self._data_file.add_value_matrix(self.log_name_2D[i], x=self._data_x, y=self._log_y_value_2D[i], unit=self.log_unit_2D[i], - dtype=self.log_dtype_2D[i], folder='data')) # possibly use "data1" + self._data_file.add_value_matrix(self.log_name_2D[i], x=self._data_x, y=self._log_y_value_2D[i], unit=self.log_unit_2D[i], dtype=self.log_dtype_2D[i], folder='data') + ) # possibly use "data1" + + if self._fit_resonator: + self._iq_view.add(self._fit_real, self._fit_imag) + self._amp_view = self._data_file.add_view("AmplitudeFit", self._data_freq, self._data_amp) + self._amp_view.add(self._fit_freq, self._fit_amp) + self._pha_view = self._data_file.add_view("PhaseFit", self._data_freq, self._data_pha) + self._pha_view.add(self._fit_freq, self._fit_pha) if self.comment: self._data_file.add_comment(self.comment) @@ -350,11 +393,6 @@ def _prepare_measurement_file(self): if self.qviewkit_singleInstance and self.open_qviewkit and self._qvk_process: self._qvk_process.terminate() # terminate an old qviewkit instance - def _write_settings_dataset(self): - self._settings = self._data_file.add_textlist('settings') - settings = waf.get_instrument_settings(self._data_file.get_filepath()) - self._settings.append(settings) - def measure_1D(self, rescan=True, web_visible=True): ''' measure method to record a single (averaged) VNA trace, S11 or S21 according to the setting on the VNA @@ -379,9 +417,7 @@ def measure_1D(self, rescan=True, web_visible=True): """opens qviewkit to plot measurement, amp and pha are opened by default""" if self.open_qviewkit: - self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase']) - if self._fit_resonator: - self._resonator = resonator(self._data_file.get_filepath()) + self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase', 'views/IQ']) qkit.flow.start() if rescan: @@ -426,7 +462,18 @@ def measure_1D(self, rescan=True, web_visible=True): self._data_real.append(data_real) self._data_imag.append(data_imag) if self._fit_resonator: - self._do_fit_resonator() + self._fit_function.do_fit(self._freqpoints[self._fit_select], np.array(data_amp)[self._fit_select], np.array(data_pha)[self._fit_select]) + # add fit data to file + self._fit_amp.append(self._fit_function.amp_fit) + self._fit_pha.append(self._fit_function.pha_fit) + self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) + self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) + + self._fit_extracts = {} + for key, val in self._fit_function.extract_data: + # define extract data in 1D case here instead of before measurement, so the file ist not too clustered if measurement fails + self._fit_extracts[key] = self._data_file.add_coordinate("fit_" + key, unit="", folder="analysis") + self._fit_extracts[key].append(val) qkit.flow.end() self._end_measurement() @@ -459,8 +506,7 @@ def measure_2D(self, web_visible=True): if self.exp_name: self._file_name += '_' + self.exp_name - if self.progress_bar: self._p = Progress_Bar(len(self.x_vec), '2D VNA sweep ' + self.dirname, - self.vna.get_sweeptime_averages()) + if self.progress_bar: self._p = Progress_Bar(len(self.x_vec), '2D VNA sweep ' + self.dirname, self.vna.get_sweeptime_averages()) self._prepare_measurement_vna() self._prepare_measurement_file() @@ -469,12 +515,10 @@ def measure_2D(self, web_visible=True): if self._nop < 10: self._data_file.hf.hf.attrs['default_ds'] =['views/amplitude_midpoint', 'views/phase_midpoint'] else: - self._data_file.hf.hf.attrs['default_ds'] = ['data0/amplitude_midpoint', 'data0/phase_midpoint'] + self._data_file.hf.hf.attrs['default_ds'] = ['amplitude', 'phase', 'views/IQ'] if self.open_qviewkit: - self._qvk_process = qviewkit.plot(self._data_file.get_filepath(),datasets=list(self._data_file.hf.hf.attrs['default_ds'])) - if self._fit_resonator: - self._resonator = resonator(self._data_file.get_filepath()) + self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=list(self._data_file.hf.hf.attrs['default_ds'])) self._measure() def measure_3D(self, web_visible=True): @@ -512,10 +556,8 @@ def measure_3D(self, web_visible=True): self._prepare_measurement_file() """opens qviewkit to plot measurement, amp and pha are opened by default""" """only middle point in freq array is plotted vs x and y""" - if self.open_qviewkit: self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), - datasets=['amplitude', 'phase']) - if self._fit_resonator: - self._resonator = resonator(self._data_file.get_filepath()) + if self.open_qviewkit: + self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase', "views/IQ"]) if self.progress_bar: if self.landscape.xylandscapes: @@ -586,8 +628,8 @@ def _measure(self): # loop: y_obj with parameters from y_vec (only 3D measurement) if self.landscape.xylandscapes and not self.landscape.perform_measurement_at_point(x, y, ix): # if point is not of interest (not close to one of the functions) - data_amp = np.full(int(self._nop), np.NaN, dtype=np.float16) - data_pha = np.full(int(self._nop), np.NaN, dtype=np.float16) # fill with NaNs + data_amp = np.full(int(self._nop), np.nan, dtype=np.float16) + data_pha = np.full(int(self._nop), np.nan, dtype=np.float16) # fill with NaNs else: self.y_set_obj(y) sleep(self.tdy) @@ -625,8 +667,20 @@ def _measure(self): else: self._data_amp.append(data_amp) self._data_pha.append(data_pha) + self._data_real.append(data_amp*np.cos(data_pha)) + self._data_imag.append(data_amp*np.sin(data_pha)) + if self._fit_resonator: - self._do_fit_resonator() + self._fit_function.do_fit(self._freqpoints[self._fit_select], data_amp[self._fit_select], data_pha[self._fit_select]) + + self._fit_amp.append(self._fit_function.amp_fit) + self._fit_pha.append(self._fit_function.pha_fit) + self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) + self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) + + for key, val in self._fit_function.extract_data: + self._fit_extracts[key].append(val) + qkit.flow.sleep() """ filling of value-box is done here. @@ -634,6 +688,12 @@ def _measure(self): """ self._data_amp.next_matrix() self._data_pha.next_matrix() + self._data_real.next_matrix() + self._data_imag.next_matrix() + self._fit_amp.next_matrix() + self._fit_pha.next_matrix() + self._fit_real.next_matrix() + self._fit_imag.next_matrix() if self._scan_dim == 2: if self.averaging_start_ready: @@ -652,14 +712,28 @@ def _measure(self): data_amp, data_pha = self.vna.get_tracedata() else: data_amp, data_pha = self.landscape.get_tracedata_xz(x) + + if self.progress_bar: + self._p.iterate() + self._data_amp.append(data_amp) self._data_pha.append(data_pha) + self._data_real.append(data_amp*np.cos(data_pha)) + self._data_imag.append(data_amp*np.sin(data_pha)) if self._fit_resonator: - self._do_fit_resonator() - if self.progress_bar: - self._p.iterate() + self._fit_function.do_fit(self._freqpoints[self._fit_select], data_amp[self._fit_select], data_pha[self._fit_select]) + + self._fit_amp.append(self._fit_function.amp_fit) + self._fit_pha.append(self._fit_function.pha_fit) + self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) + self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) + + for key, val in self._fit_function.extract_data: + self._fit_extracts[key].append(val) + qkit.flow.sleep() + finally: self._end_measurement() qkit.flow.end() @@ -677,53 +751,43 @@ def _end_measurement(self): self.dirname = None if self.averaging_start_ready: self.vna.post_measurement() - def set_resonator_fit(self, fit_resonator=True, fit_function='', f_min=None, f_max=None): + def set_resonator_fit(self, fit_resonator=False, fit_function = None, f_min=None, f_max=None, **fit_opt_kwargs): ''' sets fit parameter for resonator fit_resonator (bool): True or False, default: True (optional) - fit_function (string): function which will be fitted to the data (optional) + fit_function (string): function which will be fitted to the data + 'lorentzian','skewed_lorentzian','circle_fit_reflection', 'circle_fit_notch','fano' f_min (float): lower frequency boundary for the fitting function, default: None (optional) f_max (float): upper frequency boundary for the fitting function, default: None (optional) - fit types: 'lorentzian','skewed_lorentzian','circle_fit_reflection', 'circle_fit_notch','fano' + fit_opt_kwargs: kwargs to be passed to the fit function ''' if not fit_resonator: self._fit_resonator = False return - self._functions = {'lorentzian': 0, 'skewed_lorentzian': 1, 'circle_fit_reflection': 2, 'circle_fit_notch': 3, - 'fano': 4, 'all_fits': 5} + if fit_function == "circle_fit_reflection": # this distinction is handled manually here + fit_opt_kwargs.update({"n_ports": 1}) + if fit_function == "circle_fit_notch": + fit_opt_kwargs.update({"n_ports": 2}) try: - self._fit_function = self._functions[fit_function] + self._fit_function = resonatorFit.FitNames[fit_function](**fit_opt_kwargs) # init fit-class here except KeyError: - logging.error( - 'Fit function not properly set. Must be either \'lorentzian\', \'skewed_lorentzian\', \'circle_fit_reflection\', \'circle_fit_notch\', \'fano\', or \'all_fits\'.') + logging.error('Fit function not properly set. Must be either \'lorentzian\', \'skewed_lorentzian\', \'circle_fit_reflection\', \'circle_fit_notch\', \'fano\'.') else: self._fit_resonator = True - self._f_min = f_min - self._f_max = f_max + self._f_min = f_min if f_min is not None else -np.inf + self._f_max = f_max if f_min is not None else np.inf - def _do_fit_resonator(self): + def _do_fit_resonator(self, freq, amp, pha): ''' calls fit function in resonator class - fit function is specified in self.set_fit, with boundaries f_mim and f_max + fit function is specified in self.set_resonator_fit, with boundaries f_min and f_max only the last 'slice' of data is fitted, since we fit live while measuring. ''' - if self._fit_function == 0: # lorentzian - self._resonator.fit_lorentzian(f_min=self._f_min, f_max=self._f_max) - elif self._fit_function == 1: # skewed_lorentzian - self._resonator.fit_skewed_lorentzian(f_min=self._f_min, f_max=self._f_max) - elif self._fit_function == 2: # circle_reflection - self._resonator.fit_circle(reflection=True, f_min=self._f_min, f_max=self._f_max) - elif self._fit_function == 3: # circle_notch - self._resonator.fit_circle(notch=True, f_min=self._f_min, f_max=self._f_max) - elif self._fit_function == 4: # fano - self._resonator.fit_fano(f_min=self._f_min, f_max=self._f_max) - elif self._fit_function == 5: #all fits - logging.warning("Please performe fits individually, fit all is currently not supported.") - # self._resonator.fit_all_fits(f_min=self._f_min, f_max = self._f_max) - else: - logging.error("Fit function set in spectrum.set_resonator_fit is not supported. Must be either \'lorentzian\', \'skewed_lorentzian\', \'circle_fit_reflection\', \'circle_fit_notch\', \'fano\', or \'all_fits\'.") + select = (freq >= self._f_min) & (freq <= self._f_max) + self._fit_function.do_fit(freq[select], amp[select], pha[select]) + # fit result stored in _fit_function object. Storing this data handled back in main measure loop def set_tdx(self, tdx): self.tdx = tdx diff --git a/tests/resonator_fits/resonator_fits.py b/tests/resonator_fits/resonator_fits.py index 36e5d0e29..2d0ad20a3 100644 --- a/tests/resonator_fits/resonator_fits.py +++ b/tests/resonator_fits/resonator_fits.py @@ -36,3 +36,6 @@ def test_circlefit(): plt.plot(my_circle_fit.amp_fit*np.cos(my_circle_fit.pha_fit), my_circle_fit.amp_fit*np.sin(my_circle_fit.pha_fit), "r-") plt.title("IQ Circle") plt.show() + +if __name__ == "__main__": + test_circlefit() \ No newline at end of file From f4f7a034ded8b6c35bb266957c506ce801f51ac9 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Thu, 8 May 2025 13:26:44 +0200 Subject: [PATCH 03/43] Bug fixes automatic circle fit --- src/qkit/analysis/resonatorV2.py | 13 ++++++--- src/qkit/measure/spectroscopy/spectroscopy.py | 27 +++++++++++-------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonatorV2.py index 9a263edcc..ab34f7df5 100644 --- a/src/qkit/analysis/resonatorV2.py +++ b/src/qkit/analysis/resonatorV2.py @@ -52,7 +52,7 @@ def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: "Ql_err": None, "a": None, "alpha": None, - "chi2": None, + "chi_square": None, "delay": None, "delay_remaining": None, "fano_b": None, @@ -69,6 +69,12 @@ def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: def do_fit(self, freq: np.ndarray[float], amp: np.ndarray[float], pha: np.ndarray[float]): z = amp*np.exp(1j*pha) + # init with empty data + self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) + self.amp_fit: np.full(self.out_nop, np.nan) + self.pha_fit: np.full(self.out_nop, np.nan) + for key in self.extract_data.keys(): + self.extract_data[key] = np.nan """ helper functions""" _phase_centered = lambda f, fr, Ql, theta, delay=0: theta - 2*np.pi*delay*(f-fr) + 2.*np.arctan(2.*Ql*(1. - f/fr)) _periodic_boundary = lambda angle: (angle + np.pi) % (2*np.pi) - np.pi @@ -219,7 +225,7 @@ def residuals_full(params): """delay""" if self.fixed_delay is not None: - self.extract_data["delay"] = self.fixed_delay + delay = self.fixed_delay else: xc, yc, r0 = _fit_circle(z) z_data = z - complex(xc, yc) @@ -260,7 +266,7 @@ def residuals_full(params): if 2*np.pi*(freq[-1]-freq[0])*delay_corr > np.std(residuals): logging.warning("Delay could not be fit properly!") - self.extract_data["delay"] = delay + self.extract_data["delay"] = delay """calibrate""" z_data = z*np.exp(2j*np.pi*delay*freq) # correct delay @@ -378,7 +384,6 @@ def residuals_full(params): self.extract_data["fano_b"] = b """model data""" - self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) z_fit = Sij(self.freq_fit, fr, Ql, Qc, phi, a, alpha, delay) self.amp_fit = np.abs(z_fit) self.pha_fit = np.angle(z_fit) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 094eb352e..17f5a7862 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -45,8 +45,8 @@ class spectrum(object): usage: m = spectrum(vna = vna1) - m.set_x_parameters(arange(-0.05,0.05,0.01),'flux coil current',coil.set_current, unit = 'mA') outer scan in 3D - m.set_y_parameters(arange(4e9,7e9,10e6),'excitation frequency',mw_src1.set_frequency, unit = 'Hz') + m.set_x_parameters(arange(-0.05,0.05,0.01),'flux coil current',coil.set_current, unit = 'mA') # outer scan in 2D & 3D + m.set_y_parameters(arange(4e9,7e9,10e6),'excitation frequency',mw_src1.set_frequency, unit = 'Hz') # inner scan in 3D m.landscape.generate_fit_function_xy(...) for 3D scan, can be called several times and appends the current landscape m.landscape.generate_fit_function_xz(...) for 2D or 3D scan, adjusts the vna freqs with respect to x @@ -277,9 +277,7 @@ def _prepare_measurement_file(self): if self._fit_resonator: self._fit_select = (self._freqpoints >= self._f_min) & (self._freqpoints <= self._f_max) - self._fit_freq = self._data_file.add_coordinate('_fit_frequency', unit='Hz', folder="analysis") - self._fit_freq.add(self._freqpoints[self._fit_select]) if self._scan_dim == 1: self._data_real = self._data_file.add_value_vector('real', x=sweep_vector, unit='', save_timestamp=True) @@ -464,13 +462,14 @@ def measure_1D(self, rescan=True, web_visible=True): if self._fit_resonator: self._fit_function.do_fit(self._freqpoints[self._fit_select], np.array(data_amp)[self._fit_select], np.array(data_pha)[self._fit_select]) # add fit data to file + self._fit_freq.add(self._fit_function.freq_fit) self._fit_amp.append(self._fit_function.amp_fit) self._fit_pha.append(self._fit_function.pha_fit) self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) self._fit_extracts = {} - for key, val in self._fit_function.extract_data: + for key, val in self._fit_function.extract_data.items(): # define extract data in 1D case here instead of before measurement, so the file ist not too clustered if measurement fails self._fit_extracts[key] = self._data_file.add_coordinate("fit_" + key, unit="", folder="analysis") self._fit_extracts[key].append(val) @@ -624,7 +623,8 @@ def _measure(self): self._log_value_2D[i].append(f()) if self._scan_dim == 3: - for y in self.y_vec: + fit_extracts_helper = {} # for book-keeping current y-line + for iy, y in enumerate(self.y_vec): # loop: y_obj with parameters from y_vec (only 3D measurement) if self.landscape.xylandscapes and not self.landscape.perform_measurement_at_point(x, y, ix): # if point is not of interest (not close to one of the functions) @@ -673,13 +673,17 @@ def _measure(self): if self._fit_resonator: self._fit_function.do_fit(self._freqpoints[self._fit_select], data_amp[self._fit_select], data_pha[self._fit_select]) + self._fit_freq.add(self._fit_function.freq_fit) if (ix == 0) & (iy == 0) else None self._fit_amp.append(self._fit_function.amp_fit) self._fit_pha.append(self._fit_function.pha_fit) self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) - for key, val in self._fit_function.extract_data: - self._fit_extracts[key].append(val) + for key, val in self._fit_function.extract_data.items(): + if iy == 0: + fit_extracts_helper[key] = np.full(len(self.y_vec), np.nan) + fit_extracts_helper[key][iy] = val + self._fit_extracts[key].append(fit_extracts_helper[key], reset=(iy != 0)) qkit.flow.sleep() """ @@ -724,12 +728,13 @@ def _measure(self): if self._fit_resonator: self._fit_function.do_fit(self._freqpoints[self._fit_select], data_amp[self._fit_select], data_pha[self._fit_select]) + self._fit_freq.add(self._fit_function.freq_fit) if (ix == 0) else None self._fit_amp.append(self._fit_function.amp_fit) self._fit_pha.append(self._fit_function.pha_fit) self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) - for key, val in self._fit_function.extract_data: + for key, val in self._fit_function.extract_data.items(): self._fit_extracts[key].append(val) qkit.flow.sleep() @@ -775,8 +780,8 @@ def set_resonator_fit(self, fit_resonator=False, fit_function = None, f_min=None logging.error('Fit function not properly set. Must be either \'lorentzian\', \'skewed_lorentzian\', \'circle_fit_reflection\', \'circle_fit_notch\', \'fano\'.') else: self._fit_resonator = True - self._f_min = f_min if f_min is not None else -np.inf - self._f_max = f_max if f_min is not None else np.inf + self._f_min = -np.inf if f_min is None else f_min + self._f_max = np.inf if f_max is None else f_max def _do_fit_resonator(self, freq, amp, pha): ''' From cbca9af1e01d7c6f03c069cff1ea194ad5505347 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 12 May 2025 15:46:49 +0200 Subject: [PATCH 04/43] Added autofit file --- src/qkit/analysis/resonatorV2.py | 114 ++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonatorV2.py index ab34f7df5..b38956f18 100644 --- a/src/qkit/analysis/resonatorV2.py +++ b/src/qkit/analysis/resonatorV2.py @@ -3,6 +3,7 @@ import scipy.optimize as spopt import scipy.ndimage import logging +from qkit.storage.store import Data as qkitData class ResonatorFitBase(ABC): """ @@ -29,6 +30,115 @@ def do_fit(self, freq: np.ndarray[float] = [0, 1], amp: np.ndarray[float] = None self.extract_data = {} return self + +def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float = None, f_max: float = None): + # ensure file is qkit.storage.store.Data + if type(file) == str: + try: + file = qkitData(file) # assume path + except: + logging.error("'{}' is not a valid path".format(file)) + raise AttributeError + # check if file has freq, amp, phase + try: + freq_data = np.array(file.data.frequency) + amp_data = np.array(file.data.amplitude) + pha_data = np.array(file.data.phase) + except: + logging.error("Could not access frequency, amplitude and/or phase. Is this really a VNA measurement?") + raise AttributeError + # do not allow overriding existing fits + try: + dummy = file.analysis._fit_frequency + logging.error("File already contains fit data. Please access and store everything manually.") + raise ValueError + except: + pass + # add frequency coordinate + f_min = -np.inf if f_min is None else f_min + f_max = np.inf if f_max is None else f_max + selly = (freq_data >= f_min) & (freq_data <= f_max) + file_freq_fit = file.add_coordinate("_fit_frequency", unit="Hz", folder="analysis") + file_freq_fit.add(freq_data[selly]) + file_extracts = {} + + if len(amp_data.shape) == 1: # 1D measurement + # add result datastores + file_amp_fit = file.add_value_vector("_fit_amplitude", x=file_freq_fit, unit="arb. unit", folder="analysis") + file_pha_fit = file.add_value_vector("_fit_phase", x=file_freq_fit, unit="rad", folder="analysis") + file_real_fit = file.add_value_vector("_fit_real", x=file_freq_fit, unit="", folder="analysis") + file_imag_fit = file.add_value_vector("_fit_imag", x=file_freq_fit, unit="", folder="analysis") + for key in fit_func.extract_data.keys(): + file_extracts[key] = file.add_coordinate("fit_" + key, folder="analysis") + # actual fitting + fit_func.do_fit(freq_data[selly], amp_data[selly], pha_data[selly]) + # fill entries + file_amp_fit.append(fit_func.amp_fit) + file_pha_fit.append(fit_func.pha_fit) + file_real_fit.append(fit_func.amp_fit*np.cos(fit_func.pha_fit)) + file_imag_fit.append(fit_func.amp_fit*np.sin(fit_func.pha_fit)) + for key, val in fit_func.extract_data.items(): + file_extracts[key].add(np.array([val])) + + elif len(amp_data.shape) == 2: # 2D measurement + # add result datastores + file_amp_fit = file.add_value_matrix("_fit_amplitude", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="arb. unit", folder="analysis") + file_pha_fit = file.add_value_matrix("_fit_phase", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="rad", folder="analysis") + file_real_fit = file.add_value_matrix("_fit_real", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="", folder="analysis") + file_imag_fit = file.add_value_matrix("_fit_imag", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="", folder="analysis") + buffer_extracts = {} + for key in fit_func.extract_data.keys(): + file_extracts[key] = file.add_value_vector("fit_" + key, x=file[file.data.amplitude.x_ds_url], folder="analysis") + buffer_extracts[key] = np.full(file[file.data.amplitude.x_ds_url].shape[0], np.nan) + for ix in range(amp_data.shape[0]): + # actual fitting + fit_func.do_fit(freq_data[selly], amp_data[ix, selly], pha_data[ix, selly]) + # fill entries + file_amp_fit.append(fit_func.amp_fit) + file_pha_fit.append(fit_func.pha_fit) + file_real_fit.append(fit_func.amp_fit*np.cos(fit_func.pha_fit)) + file_imag_fit.append(fit_func.amp_fit*np.sin(fit_func.pha_fit)) + for key, val in fit_func.extract_data.items(): + buffer_extracts[key][ix] = val + file_extracts[key].append(buffer_extracts[key], reset=True) + + elif len(amp_data.shape) == 3: # 3D measurement + # add result datastores + file_amp_fit = file.add_value_box("_fit_amplitude", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="arb. unit", folder="analysis") + file_pha_fit = file.add_value_box("_fit_phase", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="rad", folder="analysis") + file_real_fit = file.add_value_box("_fit_real", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="", folder="analysis") + file_imag_fit = file.add_value_box("_fit_imag", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="", folder="analysis") + for key in fit_func.extract_data.keys(): + file_extracts[key] = file.add_value_matrix("fit_" + key, x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], folder="analysis") + buffer_extracts = {} + import itertools # alternative to nested loops that allows easier breaking when fill is reached + for ix, iy in itertools.product(range(amp_data.shape[0]), range(amp_data.shape[1])): + if iy == 0: + for key in fit_func.extract_data.keys(): + buffer_extracts[key] = np.full(file[file.data.amplitude.y_ds_url].shape[0], np.nan) + # actual fitting + fit_func.do_fit(freq_data[selly], amp_data[ix, iy, selly], pha_data[ix, iy, selly]) + # fill entries + file_amp_fit.append(fit_func.amp_fit) + file_pha_fit.append(fit_func.pha_fit) + file_real_fit.append(fit_func.amp_fit*np.cos(fit_func.pha_fit)) + file_imag_fit.append(fit_func.amp_fit*np.sin(fit_func.pha_fit)) + for key, val in fit_func.extract_data.items(): + buffer_extracts[key][iy] = val + file_extracts[key].append(buffer_extracts[key], reset=(iy != 0)) + if (ix + 1 == file.data.amplitude.fill[0]) & (iy + 1 == file.data.amplitude.fill[1]): + # The measurement stopped somewhere in the middle of a xy-sweep, no more data to analyze + break + if iy + 1 == amp_data.shape[1]: + file_amp_fit.next_matrix() + file_pha_fit.next_matrix() + file_real_fit.next_matrix() + file_imag_fit.next_matrix() + else: + logging.error("What?") + raise NotImplementedError() + + class CircleFit(ResonatorFitBase): def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: float = None, isolation: int = 15, guesses: list[float] = None): @@ -71,8 +181,8 @@ def do_fit(self, freq: np.ndarray[float], amp: np.ndarray[float], pha: np.ndarra z = amp*np.exp(1j*pha) # init with empty data self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) - self.amp_fit: np.full(self.out_nop, np.nan) - self.pha_fit: np.full(self.out_nop, np.nan) + self.amp_fit = np.full(self.out_nop, np.nan) + self.pha_fit = np.full(self.out_nop, np.nan) for key in self.extract_data.keys(): self.extract_data[key] = np.nan """ helper functions""" From bc70cf760e8d1b62098f3635fec4d37ee90b98a4 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 12 May 2025 16:01:15 +0200 Subject: [PATCH 05/43] fug bixes --- src/qkit/analysis/resonatorV2.py | 7 ++++--- src/qkit/measure/spectroscopy/spectroscopy.py | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonatorV2.py index b38956f18..d13c167b6 100644 --- a/src/qkit/analysis/resonatorV2.py +++ b/src/qkit/analysis/resonatorV2.py @@ -4,6 +4,7 @@ import scipy.ndimage import logging from qkit.storage.store import Data as qkitData +from qkit.storage.hdf_dataset import hdf_dataset class ResonatorFitBase(ABC): """ @@ -60,7 +61,7 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float selly = (freq_data >= f_min) & (freq_data <= f_max) file_freq_fit = file.add_coordinate("_fit_frequency", unit="Hz", folder="analysis") file_freq_fit.add(freq_data[selly]) - file_extracts = {} + file_extracts: dict[str, hdf_dataset] = {} if len(amp_data.shape) == 1: # 1D measurement # add result datastores @@ -86,7 +87,7 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float file_pha_fit = file.add_value_matrix("_fit_phase", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="rad", folder="analysis") file_real_fit = file.add_value_matrix("_fit_real", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="", folder="analysis") file_imag_fit = file.add_value_matrix("_fit_imag", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="", folder="analysis") - buffer_extracts = {} + buffer_extracts: dict[str, np.ndarray] = {} for key in fit_func.extract_data.keys(): file_extracts[key] = file.add_value_vector("fit_" + key, x=file[file.data.amplitude.x_ds_url], folder="analysis") buffer_extracts[key] = np.full(file[file.data.amplitude.x_ds_url].shape[0], np.nan) @@ -110,7 +111,7 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float file_imag_fit = file.add_value_box("_fit_imag", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="", folder="analysis") for key in fit_func.extract_data.keys(): file_extracts[key] = file.add_value_matrix("fit_" + key, x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], folder="analysis") - buffer_extracts = {} + buffer_extracts: dict[str, np.ndarray] = {} import itertools # alternative to nested loops that allows easier breaking when fill is reached for ix, iy in itertools.product(range(amp_data.shape[0]), range(amp_data.shape[1])): if iy == 0: diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 17f5a7862..17a121e3b 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -694,10 +694,11 @@ def _measure(self): self._data_pha.next_matrix() self._data_real.next_matrix() self._data_imag.next_matrix() - self._fit_amp.next_matrix() - self._fit_pha.next_matrix() - self._fit_real.next_matrix() - self._fit_imag.next_matrix() + if self._fit_resonator: + self._fit_amp.next_matrix() + self._fit_pha.next_matrix() + self._fit_real.next_matrix() + self._fit_imag.next_matrix() if self._scan_dim == 2: if self.averaging_start_ready: From 4d3a754fb1f8959e77ce080665fd43b80a67c2d1 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 12 May 2025 19:16:51 +0200 Subject: [PATCH 06/43] Fore mug bixes --- src/qkit/analysis/resonatorV2.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonatorV2.py index d13c167b6..2a684dcf3 100644 --- a/src/qkit/analysis/resonatorV2.py +++ b/src/qkit/analysis/resonatorV2.py @@ -5,6 +5,7 @@ import logging from qkit.storage.store import Data as qkitData from qkit.storage.hdf_dataset import hdf_dataset +from qkit.storage.hdf_view import dataset_view class ResonatorFitBase(ABC): """ @@ -60,7 +61,7 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float f_max = np.inf if f_max is None else f_max selly = (freq_data >= f_min) & (freq_data <= f_max) file_freq_fit = file.add_coordinate("_fit_frequency", unit="Hz", folder="analysis") - file_freq_fit.add(freq_data[selly]) + file_freq_fit.add(np.linspace(np.min(freq_data[selly]), np.max(freq_data[selly]), fit_func.out_nop)) file_extracts: dict[str, hdf_dataset] = {} if len(amp_data.shape) == 1: # 1D measurement @@ -83,13 +84,13 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float elif len(amp_data.shape) == 2: # 2D measurement # add result datastores - file_amp_fit = file.add_value_matrix("_fit_amplitude", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="arb. unit", folder="analysis") - file_pha_fit = file.add_value_matrix("_fit_phase", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="rad", folder="analysis") - file_real_fit = file.add_value_matrix("_fit_real", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="", folder="analysis") - file_imag_fit = file.add_value_matrix("_fit_imag", x=file[file.data.amplitude.x_ds_url], y=file_freq_fit, unit="", folder="analysis") + file_amp_fit = file.add_value_matrix("_fit_amplitude", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file_freq_fit, unit="arb. unit", folder="analysis") + file_pha_fit = file.add_value_matrix("_fit_phase", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file_freq_fit, unit="rad", folder="analysis") + file_real_fit = file.add_value_matrix("_fit_real", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file_freq_fit, unit="", folder="analysis") + file_imag_fit = file.add_value_matrix("_fit_imag", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file_freq_fit, unit="", folder="analysis") buffer_extracts: dict[str, np.ndarray] = {} for key in fit_func.extract_data.keys(): - file_extracts[key] = file.add_value_vector("fit_" + key, x=file[file.data.amplitude.x_ds_url], folder="analysis") + file_extracts[key] = file.add_value_vector("fit_" + key, x=file.get_dataset(file.data.amplitude.x_ds_url), folder="analysis") buffer_extracts[key] = np.full(file[file.data.amplitude.x_ds_url].shape[0], np.nan) for ix in range(amp_data.shape[0]): # actual fitting @@ -105,12 +106,12 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float elif len(amp_data.shape) == 3: # 3D measurement # add result datastores - file_amp_fit = file.add_value_box("_fit_amplitude", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="arb. unit", folder="analysis") - file_pha_fit = file.add_value_box("_fit_phase", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="rad", folder="analysis") - file_real_fit = file.add_value_box("_fit_real", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="", folder="analysis") - file_imag_fit = file.add_value_box("_fit_imag", x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], z=file_freq_fit, unit="", folder="analysis") + file_amp_fit = file.add_value_box("_fit_amplitude", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file.get_dataset(file.data.amplitude.y_ds_url), z=file_freq_fit, unit="arb. unit", folder="analysis") + file_pha_fit = file.add_value_box("_fit_phase", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file.get_dataset(file.data.amplitude.y_ds_url), z=file_freq_fit, unit="rad", folder="analysis") + file_real_fit = file.add_value_box("_fit_real", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file.get_dataset(file.data.amplitude.y_ds_url), z=file_freq_fit, unit="", folder="analysis") + file_imag_fit = file.add_value_box("_fit_imag", x=file.get_dataset(file.data.amplitude.x_ds_url), y=file.get_dataset(file.data.amplitude.y_ds_url), z=file_freq_fit, unit="", folder="analysis") for key in fit_func.extract_data.keys(): - file_extracts[key] = file.add_value_matrix("fit_" + key, x=file[file.data.amplitude.x_ds_url], y=file[file.data.amplitude.y_ds_url], folder="analysis") + file_extracts[key] = file.add_value_matrix("fit_" + key, x=file.get_dataset(file.data.amplitude.x_ds_url), y=file.get_dataset(file.data.amplitude.y_ds_url), folder="analysis") buffer_extracts: dict[str, np.ndarray] = {} import itertools # alternative to nested loops that allows easier breaking when fill is reached for ix, iy in itertools.product(range(amp_data.shape[0]), range(amp_data.shape[1])): @@ -138,9 +139,15 @@ def autofit_file(file: qkitData | str, fit_func: ResonatorFitBase, f_min: float else: logging.error("What?") raise NotImplementedError() + + # add/update views + file["/entry/views/IQ"].attrs.create("xy_1", "/entry/analysis0/_fit_real:/entry/analysis0/_fit_imag") + file["/entry/views/IQ"].attrs.create("xy_1_filter", "None") + file["/entry/views/IQ"].attrs.create("overlays", 1) + file.add_view("AmplitudeFit", file.data.frequency, file.data.amplitude).add(file_freq_fit, file_amp_fit) + file.add_view("PhaseFit", file.data.frequency, file.data.phase).add(file_freq_fit, file_pha_fit) - class CircleFit(ResonatorFitBase): def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: float = None, isolation: int = 15, guesses: list[float] = None): super().__init__() From 395f73f85221910008795ea9a092afe7a77fe5e2 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 13 May 2025 17:02:30 +0200 Subject: [PATCH 07/43] Added untested unified logging --- src/qkit/analysis/circle_fit/__init__.py | 11 - .../circle_fit/circle_fit_2019/__init__.py | 0 .../circle_fit/circle_fit_2019/circuit.py | 666 -------------- .../circle_fit/circle_fit_classic/__init__.py | 0 .../circle_fit_classic/calibration.py | 71 -- .../circle_fit_classic/circlefit.py | 396 -------- .../circle_fit/circle_fit_classic/circuit.py | 501 ---------- .../circle_fit_classic/utilities.py | 187 ---- src/qkit/analysis/resonator.py | 862 ------------------ .../{resonatorV2.py => resonator_fitting.py} | 1 - src/qkit/analysis/spectroscopy.py | 125 --- src/qkit/measure/logging_base.py | 81 ++ src/qkit/measure/spectroscopy/spectroscopy.py | 188 ++-- src/qkit/measure/transport/transport.py | 213 +---- src/qkit/storage/store.py | 4 +- 15 files changed, 185 insertions(+), 3121 deletions(-) delete mode 100644 src/qkit/analysis/circle_fit/__init__.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_2019/__init__.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/__init__.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py delete mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py delete mode 100644 src/qkit/analysis/resonator.py rename src/qkit/analysis/{resonatorV2.py => resonator_fitting.py} (99%) delete mode 100644 src/qkit/analysis/spectroscopy.py create mode 100644 src/qkit/measure/logging_base.py diff --git a/src/qkit/analysis/circle_fit/__init__.py b/src/qkit/analysis/circle_fit/__init__.py deleted file mode 100644 index dc7f8c971..000000000 --- a/src/qkit/analysis/circle_fit/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -import qkit -import logging - -circle_fit_version = qkit.cfg.get("circle_fit_version", 1) - -if circle_fit_version == 1: - from .circle_fit_classic import calibration, circlefit, circuit, utilities -elif circle_fit_version == 2: - from .circle_fit_2019 import circuit -else: - logging.warning("Circle fit version not properly set in configuration!") \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_2019/__init__.py b/src/qkit/analysis/circle_fit/circle_fit_2019/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py deleted file mode 100644 index a1fad2209..000000000 --- a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py +++ /dev/null @@ -1,666 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -""" -@author: dennis.rieger@kit.edu / 2019 - -inspired by and based on resonator tools of Sebastian Probst -https://github.com/sebastianprobst/resonator_tools -""" - -import numpy as np -import logging -import scipy.optimize as spopt -from scipy import stats -from scipy.interpolate import splrep, splev -from scipy.ndimage.filters import gaussian_filter1d -plot_enable = False -try: - import qkit - if qkit.module_available("matplotlib"): - import matplotlib.pyplot as plt - plot_enable = True -except (ImportError, AttributeError): - try: - import matplotlib.pyplot as plt - plot_enable = True - except ImportError: - plot_enable = False - -class circuit: - """ - Base class for common routines and definitions shared between both ports. - - inputs: - - f_data: Frequencies for which scattering data z_data_raw is taken - - z_data_raw: Measured values for scattering parameter S11 or S21 taken at - frequencies f_data - """ - - def __init__(self, f_data, z_data_raw=None): - self.f_data = np.array(f_data) - self.z_data_raw = np.array(z_data_raw) - self.z_data_norm = None - - self.fitresults = {} - - self.fit_delay_max_iterations = 5 - - @classmethod - def Sij(cls, f, fr, Ql, Qc, phi=0., a=1., alpha=0., delay=0.): - """ - Full model for S11 of single-port reflection measurements or S21 of - notch configuration measurements. The models only differ in a factor of - 2 which is set by the dedicated port classes inheriting from this class. - - inputs: - - fr: Resonance frequency - - Ql: Loaded quality factor - - Qc: Coupling (aka external) quality factor. Calculated with - diameter correction method, i.e. 1/Qc = Re{1/|Qc| * exp(i*phi)} - - phi (opt.): Angle of the circle's rotation around the off-resonant - point due to impedance mismatches or Fano interference. - - a, alpha (opt.): Arbitrary scaling and rotation of the circle w.r.t. - origin - - delay (opt.): Time delay between output and input signal leading to - linearly frequency dependent phase shift - """ - complexQc = Qc*np.cos(phi)*np.exp(-1j*phi) - return a*np.exp(1j*(alpha-2*np.pi*f*delay)) * ( - 1. - 2.*Ql / (complexQc * cls.n_ports * (1. + 2j*Ql*(f/fr-1.))) - ) - - def autofit(self, calc_errors=True, fixed_delay=None, isolation=15): - """ - Automatically calibrate data, normalize it and extract quality factors. - If the autofit fails or the results look bad, please discuss with - author. - """ - - if fixed_delay is None: - self._fit_delay() - else: - self.delay = fixed_delay - # Store result in dictionary (also for backwards-compatibility) - self.fitresults["delay"] = self.delay - self._calibrate() - self._normalize() - self._extract_Qs(calc_errors=calc_errors) - self.calc_fano_range(isolation=isolation) - - # Prepare model data for plotting - self.z_data_sim = self.Sij( - self.f_data, self.fr, self.Ql, self.Qc, self.phi, - self.a, self.alpha, self.delay - ) - self.z_data_sim_norm = self.Sij( - self.f_data, self.fr, self.Ql, self.Qc, self.phi - ) - - def _fit_delay(self): - """ - Finds the cable delay by repeatedly centering the "circle" and fitting - the slope of the phase response. - """ - - # Translate data to origin - xc, yc, r0 = self._fit_circle(self.z_data_raw) - z_data = self.z_data_raw - complex(xc, yc) - # Find first estimate of parameters - fr, Ql, theta, self.delay = self._fit_phase(z_data) - - # Do not overreact (see end of for loop) - self.delay *= 0.05 - - # Iterate to improve result for delay - for i in range(self.fit_delay_max_iterations): - # Translate new best fit data to origin - z_data = self.z_data_raw * np.exp(2j*np.pi*self.delay*self.f_data) - xc, yc, r0 = self._fit_circle(z_data) - z_data -= complex(xc, yc) - - # Find correction to current delay - guesses = (fr, Ql, 5e-11) - fr, Ql, theta, delay_corr = self._fit_phase(z_data, guesses) - - # Stop if correction would be smaller than "measurable" - phase_fit = self.phase_centered(self.f_data, fr, Ql, theta, delay_corr) - residuals = np.unwrap(np.angle(z_data)) - phase_fit - if 2*np.pi*(self.f_data[-1]-self.f_data[0])*delay_corr <= np.std(residuals): - break - - # Avoid overcorrection that makes procedure switch between positive - # and negative delays - if delay_corr*self.delay < 0: # different sign -> be careful - if abs(delay_corr) > abs(self.delay): - self.delay *= 0.5 - else: - # delay += 0.1*delay_corr - self.delay += 0.1*np.sign(delay_corr)*5e-11 - else: # same direction -> can converge faster - if abs(delay_corr) >= 1e-8: - self.delay += min(delay_corr, self.delay) - elif abs(delay_corr) >= 1e-9: - self.delay *= 1.1 - else: - self.delay += delay_corr - - if 2*np.pi*(self.f_data[-1]-self.f_data[0])*delay_corr > np.std(residuals): - logging.warning( - "Delay could not be fit properly!" - ) - - # Store result in dictionary (also for backwards-compatibility) - self.fitresults["delay"] = self.delay - - def _calibrate(self): - """ - Finds the parameters for normalization of the scattering data. See - Sij for explanation of parameters. - """ - - # Correct for delay and translate circle to origin - z_data = self.z_data_raw * np.exp(2j*np.pi*self.delay*self.f_data) - xc, yc, self.r0 = self._fit_circle(z_data) - zc = complex(xc, yc) - z_data -= zc - - # Find off-resonant point by fitting offset phase - # (centered circle corresponds to lossless resonator in reflection) - self.fr, self.Ql, theta, self.delay_remaining = self._fit_phase(z_data) - self.theta = self._periodic_boundary(theta) - beta = self._periodic_boundary(theta - np.pi) - offrespoint = zc + self.r0*np.cos(beta) + 1j*self.r0*np.sin(beta) - self.offrespoint = offrespoint - self.a = np.absolute(offrespoint) - self.alpha = np.angle(offrespoint) - self.phi = self._periodic_boundary(beta - self.alpha) - - # Store radius for later calculation - self.r0 /= self.a - - # Store results in dictionary (also for backwards-compatibility) - self.fitresults.update({ - "delay_remaining": self.delay_remaining, - "a": self.a, - "alpha": self.alpha, - "theta": self.theta, - "phi": self.phi, - "fr": self.fr, - "Ql": self.Ql - }) - - def _normalize(self): - """ - Transforms scattering data into canonical position with off-resonant - point at (1, 0) (does not correct for rotation phi of circle around - off-resonant point). - """ - self.z_data_norm = self.z_data_raw / self.a*np.exp( - 1j*(-self.alpha + 2.*np.pi*self.delay*self.f_data) - ) - - def _extract_Qs(self, refine_results=False, calc_errors=True): - """ - Calculates Qc and Qi from radius of circle. All needed info is known - already from the calibration procedure. - """ - - self.absQc = self.Ql / (self.n_ports*self.r0) - # For Qc, take real part of 1/(complex Qc) (diameter correction method) - self.Qc = self.absQc / np.cos(self.phi) - self.Qi = 1. / (1./self.Ql - 1./self.Qc) - self.Qi_no_dia_corr = 1. / (1./self.Ql - 1./self.absQc) - - # Store results in dictionary (also for backwards-compatibility) - self.fitresults.update({ - "fr": self.fr, - "Ql": self.Ql, - "Qc": self.Qc, - "Qc_no_dia_corr": self.absQc, - "Qi": self.Qi, - "Qi_no_dia_corr": self.Qi_no_dia_corr , - }) - - # Calculate errors if wanted - if calc_errors: - chi_square, cov = self._get_covariance() - - if cov is not None: - fr_err, Ql_err, absQc_err, phi_err = np.sqrt(np.diag(cov)) - # Calculate error of Qi with error propagation - # without diameter correction - dQl = 1. / ((1./self.Ql - 1./self.absQc) * self.Ql)**2 - dabsQc = -1. / ((1./self.Ql - 1./self.absQc) * self.absQc)**2 - Qi_no_dia_corr_err = np.sqrt( - dQl**2*cov[1][1] - + dabsQc**2*cov[2][2] - + 2.*dQl*dabsQc*cov[1][2] - ) - # with diameter correction - dQl = 1. / ((1./self.Ql - 1./self.Qc) * self.Ql)**2 - dabsQc = -np.cos(self.phi) / ( - (1./self.Ql - 1./self.Qc) * self.absQc - )**2 - dphi = -np.sin(self.phi) / ( - (1./self.Ql - 1./self.Qc)**2 * self.absQc - ) - Qi_err = np.sqrt( - dQl**2*cov[1][1] - + dabsQc**2*cov[2][2] - + dphi**2*cov[3][3] - + 2*( - dQl*dabsQc*cov[1][2] - + dQl*dphi*cov[1][3] - + dabsQc*dphi*cov[2][3] - ) - ) - self.fitresults.update({ - "fr_err": fr_err, - "Ql_err": Ql_err, - "absQc_err": absQc_err, - "phi_err": phi_err, - "Qi_err": Qi_err, - "Qi_no_dia_corr_err": Qi_no_dia_corr_err, - "chi_square": chi_square - }) - else: - logging.warning("Error calculation failed!") - else: - # Just calculate reduced chi square (4 fit parameters reduce degrees - # of freedom) - self.fitresults["chi_square"] = (1. / (len(self.f_data) - 4.) - * np.sum(np.abs(self._get_residuals_reflection)**2)) - - def calc_fano_range(self, isolation=15, b=None): - """ - Calculates the systematic Qi (and Qc) uncertainty range based on - Fano interference with given strength of the background path - (cf. Rieger & Guenzler et al., arXiv:2209.03036). - - inputs: either of - - isolation (dB): Suppression of the interference path by this value. - The corresponding relative background amplitude b - is calculated with b = 10**(-isolation/20). - - b (lin): Relative background path amplitude of Fano. - - outputs (added to fitresults dictionary): - - Qi_min, Qi_max: Systematic uncertainty range for Qi - - Qc_min, Qc_max: Systematic uncertainty range for Qc - - fano_b: Relative background path amplitude of Fano. - """ - - if b is None: - b = 10**(-isolation/20) - - b = b / (1 - b) - - if np.sin(self.phi) > b: - logging.warning( - "Measurement cannot be explained with assumed Fano leakage!" - ) - self.Qi_min = np.nan - self.Qi_max = np.nan - self.Qc_min = np.nan - self.Qc_max = np.nan - - # Calculate error on radius of circle - R_mid = self.r0 * np.cos(self.phi) - R_err = self.r0 * np.sqrt(b**2 - np.sin(self.phi)**2) - R_min = R_mid - R_err - R_max = R_mid + R_err - - # Convert to ranges of quality factors - self.Qc_min = self.Ql / (self.n_ports*R_max) - self.Qc_max = self.Ql / (self.n_ports*R_min) - self.Qi_min = self.Ql / (1 - self.n_ports*R_min) - self.Qi_max = self.Ql / (1 - self.n_ports*R_max) - - # Handle unphysical results - if R_max >= 1./self.n_ports: - self.Qi_max = np.inf - - # Store results in dictionary - self.fitresults.update({ - "Qc_min": self.Qc_min, - "Qc_max": self.Qc_max, - "Qi_min": self.Qi_min, - "Qi_max": self.Qi_max, - "fano_b": b - }) - - def _fit_circle(self, z_data, refine_results=False): - """ - Analytical fit of a circle to the scattering data z_data. Cf. Sebastian - Probst: "Efficient and robust analysis of complex scattering data under - noise in microwave resonators" (arXiv:1410.3365v2) - """ - - # Normalize circle to deal with comparable numbers - x_norm = 0.5*(np.max(z_data.real) + np.min(z_data.real)) - y_norm = 0.5*(np.max(z_data.imag) + np.min(z_data.imag)) - z_data = z_data[:] - (x_norm + 1j*y_norm) - amp_norm = np.max(np.abs(z_data)) - z_data = z_data / amp_norm - - # Calculate matrix of moments - xi = z_data.real - xi_sqr = xi*xi - yi = z_data.imag - yi_sqr = yi*yi - zi = xi_sqr+yi_sqr - Nd = float(len(xi)) - xi_sum = xi.sum() - yi_sum = yi.sum() - zi_sum = zi.sum() - xiyi_sum = (xi*yi).sum() - xizi_sum = (xi*zi).sum() - yizi_sum = (yi*zi).sum() - M = np.array([ - [(zi*zi).sum(), xizi_sum, yizi_sum, zi_sum], - [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum], - [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum], - [zi_sum, xi_sum, yi_sum, Nd] - ]) - - # Lets skip line breaking at 80 characters for a moment :D - a0 = ((M[2][0]*M[3][2]-M[2][2]*M[3][0])*M[1][1]-M[1][2]*M[2][0]*M[3][1]-M[1][0]*M[2][1]*M[3][2]+M[1][0]*M[2][2]*M[3][1]+M[1][2]*M[2][1]*M[3][0])*M[0][3]+(M[0][2]*M[2][3]*M[3][0]-M[0][2]*M[2][0]*M[3][3]+M[0][0]*M[2][2]*M[3][3]-M[0][0]*M[2][3]*M[3][2])*M[1][1]+(M[0][1]*M[1][3]*M[3][0]-M[0][1]*M[1][0]*M[3][3]-M[0][0]*M[1][3]*M[3][1])*M[2][2]+(-M[0][1]*M[1][2]*M[2][3]-M[0][2]*M[1][3]*M[2][1])*M[3][0]+((M[2][3]*M[3][1]-M[2][1]*M[3][3])*M[1][2]+M[2][1]*M[3][2]*M[1][3])*M[0][0]+(M[1][0]*M[2][3]*M[3][2]+M[2][0]*(M[1][2]*M[3][3]-M[1][3]*M[3][2]))*M[0][1]+((M[2][1]*M[3][3]-M[2][3]*M[3][1])*M[1][0]+M[1][3]*M[2][0]*M[3][1])*M[0][2] - a1 = (((M[3][0]-2.*M[2][2])*M[1][1]-M[1][0]*M[3][1]+M[2][2]*M[3][0]+2.*M[1][2]*M[2][1]-M[2][0]*M[3][2])*M[0][3]+(2.*M[2][0]*M[3][2]-M[0][0]*M[3][3]-2.*M[2][2]*M[3][0]+2.*M[0][2]*M[2][3])*M[1][1]+(-M[0][0]*M[3][3]+2.*M[0][1]*M[1][3]+2.*M[1][0]*M[3][1])*M[2][2]+(-M[0][1]*M[1][3]+2.*M[1][2]*M[2][1]-M[0][2]*M[2][3])*M[3][0]+(M[1][3]*M[3][1]+M[2][3]*M[3][2])*M[0][0]+(M[1][0]*M[3][3]-2.*M[1][2]*M[2][3])*M[0][1]+(M[2][0]*M[3][3]-2.*M[1][3]*M[2][1])*M[0][2]-2.*M[1][2]*M[2][0]*M[3][1]-2.*M[1][0]*M[2][1]*M[3][2]) - a2 = ((2.*M[1][1]-M[3][0]+2.*M[2][2])*M[0][3]+(2.*M[3][0]-4.*M[2][2])*M[1][1]-2.*M[2][0]*M[3][2]+2.*M[2][2]*M[3][0]+M[0][0]*M[3][3]+4.*M[1][2]*M[2][1]-2.*M[0][1]*M[1][3]-2.*M[1][0]*M[3][1]-2.*M[0][2]*M[2][3]) - a3 = (-2.*M[3][0]+4.*M[1][1]+4.*M[2][2]-2.*M[0][3]) - a4 = -4. - - def char_pol(x): - return a0 + a1*x + a2*x**2 + a3*x**3 + a4*x**4 - - def d_char_pol(x): - return a1 + 2*a2*x + 3*a3*x**2 + 4*a4*x**3 - - eta = spopt.newton(char_pol, 0., fprime=d_char_pol) - - M[3][0] = M[3][0] + 2*eta - M[0][3] = M[0][3] + 2*eta - M[1][1] = M[1][1] - eta - M[2][2] = M[2][2] - eta - - U,s,Vt = np.linalg.svd(M) - A_vec = Vt[np.argmin(s),:] - - xc = -A_vec[1]/(2.*A_vec[0]) - yc = -A_vec[2]/(2.*A_vec[0]) - # The term *sqrt term corrects for the constraint, because it may be - # altered due to numerical inaccuracies during calculation - r0 = 1./(2.*np.absolute(A_vec[0]))*np.sqrt( - A_vec[1]*A_vec[1]+A_vec[2]*A_vec[2]-4.*A_vec[0]*A_vec[3] - ) - - return xc*amp_norm+x_norm, yc*amp_norm+y_norm, r0*amp_norm - - def _fit_phase(self, z_data, guesses=None): - """ - Fits the phase response of a strongly overcoupled (Qi >> Qc) resonator - in reflection which corresponds to a circle centered around the origin - (cf. phase_centered()). - - inputs: - - z_data: Scattering data of which the phase should be fit. Data must be - distributed around origin ("circle-like"). - - guesses (opt.): If not given, initial guesses for the fit parameters - will be determined. If given, should contain useful - guesses for fit parameters as a tuple (fr, Ql, delay) - - outputs: - - fr: Resonance frequency - - Ql: Loaded quality factor - - theta: Offset phase - - delay: Time delay between output and input signal leading to linearly - frequency dependent phase shift - """ - phase = np.unwrap(np.angle(z_data)) - - # For centered circle roll-off should be close to 2pi. If not warn user. - if np.max(phase) - np.min(phase) <= 0.8*2*np.pi: - logging.warning( - "Data does not cover a full circle (only {:.1f}".format( - np.max(phase) - np.min(phase) - ) - +" rad). Increase the frequency span around the resonance?" - ) - roll_off = np.max(phase) - np.min(phase) - else: - roll_off = 2*np.pi - - # Set useful starting parameters - if guesses is None: - # Use maximum of derivative of phase as guess for fr - phase_smooth = gaussian_filter1d(phase, 30) - phase_derivative = np.gradient(phase_smooth) - fr_guess = self.f_data[np.argmax(np.abs(phase_derivative))] - Ql_guess = 2*fr_guess / (self.f_data[-1] - self.f_data[0]) - # Estimate delay from background slope of phase (substract roll-off) - slope = phase[-1] - phase[0] + roll_off - delay_guess = -slope / (2*np.pi*(self.f_data[-1]-self.f_data[0])) - else: - fr_guess, Ql_guess, delay_guess = guesses - # This one seems stable and we do not need a manual guess for it - theta_guess = 0.5*(np.mean(phase[:5]) + np.mean(phase[-5:])) - - # Fit model with less parameters first to improve stability of fit - - def residuals_Ql(params): - Ql, = params - return residuals_full((fr_guess, Ql, theta_guess, delay_guess)) - def residuals_fr_theta(params): - fr, theta = params - return residuals_full((fr, Ql_guess, theta, delay_guess)) - def residuals_delay(params): - delay, = params - return residuals_full((fr_guess, Ql_guess, theta_guess, delay)) - def residuals_fr_Ql(params): - fr, Ql = params - return residuals_full((fr, Ql, theta_guess, delay_guess)) - def residuals_full(params): - return self._phase_dist( - phase - circuit.phase_centered(self.f_data, *params) - ) - - p_final = spopt.leastsq(residuals_Ql, [Ql_guess]) - Ql_guess, = p_final[0] - p_final = spopt.leastsq(residuals_fr_theta, [fr_guess, theta_guess]) - fr_guess, theta_guess = p_final[0] - p_final = spopt.leastsq(residuals_delay, [delay_guess]) - delay_guess, = p_final[0] - p_final = spopt.leastsq(residuals_fr_Ql, [fr_guess, Ql_guess]) - fr_guess, Ql_guess = p_final[0] - p_final = spopt.leastsq(residuals_full, [ - fr_guess, Ql_guess, theta_guess, delay_guess - ]) - - return p_final[0] - - @classmethod - def phase_centered(cls, f, fr, Ql, theta, delay=0.): - """ - Yields the phase response of a strongly overcoupled (Qi >> Qc) resonator - in reflection which corresponds to a circle centered around the origin. - Additionally, a linear background slope is accounted for if needed. - - inputs: - - fr: Resonance frequency - - Ql: Loaded quality factor (and since Qi >> Qc also Ql = Qc) - - theta: Offset phase - - delay (opt.): Time delay between output and input signal leading to - linearly frequency dependent phase shift - """ - return theta - 2*np.pi*delay*(f-fr) + 2.*np.arctan(2.*Ql*(1. - f/fr)) - - def _phase_dist(self, angle): - """ - Maps angle [-2pi, +2pi] to phase distance on circle [0, pi] - """ - return np.pi - np.abs(np.pi - np.abs(angle)) - - def _periodic_boundary(self, angle): - """ - Maps arbitrary angle to interval [-np.pi, np.pi) - """ - return (angle + np.pi) % (2*np.pi) - np.pi - - def _get_residuals(self): - """ - Calculates deviation of measured data from fit. - """ - return self.z_data_norm - self.Sij( - self.f_data, self.fr, self.Ql, self.Qc, self.phi - ) - - def _get_covariance(self): - """ - Calculates reduced chi square and covariance matrix for fit. - """ - residuals = self._get_residuals() - chi = np.abs(residuals) - # Unit vectors pointing in the correct directions for the derivative - directions = residuals / chi - # Prepare for fast construction of Jacobian - conj_directions = np.conj(directions) - - # Construct transpose of Jacobian matrix - Jt = np.array([ - np.real(self._dSij_dfr()*conj_directions), - np.real(self._dSij_dQl()*conj_directions), - np.real(self._dSij_dabsQc()*conj_directions), - np.real(self._dSij_dphi()*conj_directions) - ]) - A = np.dot(Jt, np.transpose(Jt)) - # 4 fit parameters reduce degrees of freedom for reduced chi square - chi_square = 1./float(len(self.f_data)-4) * np.sum(chi**2) - try: - cov = np.linalg.inv(A)*chi_square - except: - cov = None - return chi_square, cov - - def _dSij_dfr(self): - """ - Derivative of Sij w.r.t. fr - """ - return -4j*self.Ql**2*np.exp(1j*self.phi)*self.f_data / ( - self.n_ports * self.absQc*(self.fr+2j*self.Ql*(self.f_data-self.fr))**2 - ) - - def _dSij_dQl(self): - """ - Derivative of Sij w.r.t. Ql - """ - return -2.*np.exp(1j*self.phi) / ( - self.n_ports * self.absQc*(1.+2j*self.Ql*(self.f_data/self.fr-1))**2 - ) - - def _dSij_dabsQc(self): - """ - Derivative of Sij w.r.t. absQc - """ - return 2.*self.Ql*np.exp(1j*self.phi) / ( - self.n_ports * self.absQc**2 * (1.+2j*self.Ql*(self.f_data/self.fr-1)) - ) - - def _dSij_dphi(self): - """ - Derivative of Sij w.r.t. phi - """ - return -2j*self.Ql*np.exp(1j*self.phi) / ( - self.n_ports * self.absQc * (1.+2j*self.Ql*(self.f_data/self.fr-1)) - ) - - """ - Functions for plotting results - """ - def plotall(self): - if not plot_enable: - raise ImportError("matplotlib not found") - real = self.z_data_raw.real - imag = self.z_data_raw.imag - real2 = self.z_data_sim.real - imag2 = self.z_data_sim.imag - plt.subplot(221, aspect="equal") - plt.axvline(0, c="k", ls="--", lw=1) - plt.axhline(0, c="k", ls="--", lw=1) - plt.plot(real,imag,label='rawdata') - plt.plot(real2,imag2,label='fit') - plt.xlabel('Re(S21)') - plt.ylabel('Im(S21)') - plt.legend() - plt.subplot(222) - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_sim),label='fit') - plt.xlabel('f (GHz)') - plt.ylabel('|S21|') - plt.legend() - plt.subplot(223) - plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') - plt.plot(self.f_data*1e-9,np.angle(self.z_data_sim),label='fit') - plt.xlabel('f (GHz)') - plt.ylabel('arg(|S21|)') - plt.legend() - plt.show() - - def plotcalibrateddata(self): - if not plot_enable: - raise ImportError("matplotlib not found") - real = self.z_data_norm.real - imag = self.z_data_norm.imag - plt.subplot(221) - plt.plot(real,imag,label='rawdata') - plt.xlabel('Re(S21)') - plt.ylabel('Im(S21)') - plt.legend() - plt.subplot(222) - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_norm),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('|S21|') - plt.legend() - plt.subplot(223) - plt.plot(self.f_data*1e-9,np.angle(self.z_data_norm),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('arg(|S21|)') - plt.legend() - plt.show() - - def plotrawdata(self): - if not plot_enable: - raise ImportError("matplotlib not found") - real = self.z_data_raw.real - imag = self.z_data_raw.imag - plt.subplot(221) - plt.plot(real,imag,label='rawdata') - plt.xlabel('Re(S21)') - plt.ylabel('Im(S21)') - plt.legend() - plt.subplot(222) - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('|S21|') - plt.legend() - plt.subplot(223) - plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('arg(|S21|)') - plt.legend() - plt.show() - -class reflection_port(circuit): - """ - Circlefit class for single-port resonator probed in reflection. - """ - - # See Sij of circuit class for explanation - n_ports = 1. - -class notch_port(circuit): - """ - Circlefit class for two-port resonator probed in transmission. - """ - - # See Sij of circuit class for explanation - n_ports = 2. \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/__init__.py b/src/qkit/analysis/circle_fit/circle_fit_classic/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py b/src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py deleted file mode 100644 index 3d81952d4..000000000 --- a/src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py +++ /dev/null @@ -1,71 +0,0 @@ - -import numpy as np -from scipy import sparse -from scipy.interpolate import interp1d - -class calibration(object): - ''' - some useful tools for manual calibration - ''' - def normalize_zdata(self,z_data,cal_z_data): - return z_data/cal_z_data - - def normalize_amplitude(self,z_data,cal_ampdata): - return z_data/cal_ampdata - - def normalize_phase(self,z_data,cal_phase): - return z_data*np.exp(-1j*cal_phase) - - def normalize_by_func(self,f_data,z_data,func): - return z_data/func(f_data) - - def _baseline_als(self,y, lam, p, niter=10): - ''' - see http://zanran_storage.s3.amazonaws.com/www.science.uva.nl/ContentPages/443199618.pdf - "Asymmetric Least Squares Smoothing" by P. Eilers and H. Boelens in 2005. - http://stackoverflow.com/questions/29156532/python-baseline-correction-library - "There are two parameters: p for asymmetry and lambda for smoothness. Both have to be - tuned to the data at hand. We found that generally 0.001<=p<=0.1 is a good choice - (for a signal with positive peaks) and 10e2<=lambda<=10e9, but exceptions may occur." - ''' - L = len(y) - D = sparse.csc_matrix(np.diff(np.eye(L), 2)) - w = np.ones(L) - for i in xrange(niter): - W = sparse.spdiags(w, 0, L, L) - Z = W + lam * D.dot(D.transpose()) - z = sparse.linalg.spsolve(Z, w*y) - w = p * (y > z) + (1-p) * (y < z) - return z - - def fit_baseline_amp(self,z_data,lam,p,niter=10): - ''' - for this to work, you need to analyze a large part of the baseline - tune lam and p until you get the desired result - ''' - return self._baseline_als(np.absolute(z_data),lam,p,niter=niter) - - def baseline_func_amp(self,z_data,f_data,lam,p,niter=10): - ''' - for this to work, you need to analyze a large part of the baseline - tune lam and p until you get the desired result - returns the baseline as a function - the points in between the datapoints are computed by cubic interpolation - ''' - return interp1d(f_data, self._baseline_als(np.absolute(z_data),lam,p,niter=niter), kind='cubic') - - def baseline_func_phase(self,z_data,f_data,lam,p,niter=10): - ''' - for this to work, you need to analyze a large part of the baseline - tune lam and p until you get the desired result - returns the baseline as a function - the points in between the datapoints are computed by cubic interpolation - ''' - return interp1d(f_data, self._baseline_als(np.angle(z_data),lam,p,niter=niter), kind='cubic') - - def fit_baseline_phase(self,z_data,lam,p,niter=10): - ''' - for this to work, you need to analyze a large part of the baseline - tune lam and p until you get the desired result - ''' - return self._baseline_als(np.angle(z_data),lam,p,niter=niter) diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py b/src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py deleted file mode 100644 index ca4f99799..000000000 --- a/src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py +++ /dev/null @@ -1,396 +0,0 @@ - -import numpy as np -import scipy.optimize as spopt -from scipy import stats - - -class circlefit(object): - ''' - contains all the circlefit procedures - see http://scitation.aip.org/content/aip/journal/rsi/86/2/10.1063/1.4907935 - arxiv version: http://arxiv.org/abs/1410.3365 - ''' - def _remove_cable_delay(self,f_data,z_data, delay): - return z_data/np.exp(2j*np.pi*f_data*delay) - - def _center(self,z_data,zc): - return z_data-zc - - def _dist(self,x): - np.absolute(x,x) - c = (x > np.pi).astype(np.int) - return x+c*(-2.*x+2.*np.pi) - - def _periodic_boundary(self,x,bound): - return np.fmod(x,bound)-np.trunc(x/bound)*bound - - def _phase_fit_wslope(self,f_data,z_data,theta0, Ql, fr, slope): - phase = np.angle(z_data) - def residuals(p,x,y): - theta0, Ql, fr, slope = p - err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr))-slope*x)) - return err - p0 = [theta0, Ql, fr, slope] - p_final = spopt.leastsq(residuals,p0,args=(np.array(f_data),np.array(phase))) - return p_final[0] - - def _phase_fit(self,f_data,z_data,theta0, Ql, fr): - phase = np.angle(z_data) - def residuals_1(p,x,y,Ql): - theta0, fr = p - err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) - return err - def residuals_2(p,x,y,theta0): - Ql, fr = p - err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) - return err - def residuals_3(p,x,y,theta0,Ql): - fr = p - err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) - return err - def residuals_4(p,x,y,theta0,fr): - Ql = p - err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) - return err - def residuals_5(p,x,y): - theta0, Ql, fr = p - err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) - return err - p0 = [theta0, fr] - p_final = spopt.leastsq(lambda a,b,c: residuals_1(a,b,c,Ql),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) - theta0, fr = p_final[0] - p0 = [Ql, fr] - p_final = spopt.leastsq(lambda a,b,c: residuals_2(a,b,c,theta0),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) - Ql, fr = p_final[0] - p0 = fr - p_final = spopt.leastsq(lambda a,b,c: residuals_3(a,b,c,theta0,Ql),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) - fr = float(p_final[0]) - p0 = Ql - p_final = spopt.leastsq(lambda a,b,c: residuals_4(a,b,c,theta0,fr),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) - Ql = float(p_final[0]) - p0 = [theta0, Ql, fr] - p_final = spopt.leastsq(residuals_5,p0,args=(f_data,phase)) - return p_final[0] - - def _fit_skewed_lorentzian(self,f_data,z_data): - amplitude = np.absolute(z_data) - amplitude_sqr = amplitude**2 - A1a = np.minimum(amplitude_sqr[0],amplitude_sqr[-1]) - A3a = -np.max(amplitude_sqr) - fra = f_data[np.argmin(amplitude_sqr)] - def residuals(p,x,y): - A2, A4, Ql = p - err = y -(A1a+A2*(x-fra)+(A3a+A4*(x-fra))/(1.+4.*Ql**2*((x-fra)/fra)**2)) - return err - p0 = [0., 0., 1e3] - p_final = spopt.leastsq(residuals,p0,args=(np.array(f_data),np.array(amplitude_sqr))) - A2a, A4a, Qla = p_final[0] - - def residuals2(p,x,y): - A1, A2, A3, A4, fr, Ql = p - err = y -(A1+A2*(x-fr)+(A3+A4*(x-fr))/(1.+4.*Ql**2*((x-fr)/fr)**2)) - return err - p0 = [A1a, A2a , A3a, A4a, fra, Qla] - p_final = spopt.leastsq(residuals2,p0,args=(np.array(f_data),np.array(amplitude_sqr))) - #A1, A2, A3, A4, fr, Ql = p_final[0] - #print(p_final[0][5]) - return p_final[0] - - def _fit_circle(self,z_data, refine_results=False): - def calc_moments(z_data): - xi = z_data.real - xi_sqr = xi*xi - yi = z_data.imag - yi_sqr = yi*yi - zi = xi_sqr+yi_sqr - Nd = float(len(xi)) - xi_sum = xi.sum() - yi_sum = yi.sum() - zi_sum = zi.sum() - xiyi_sum = (xi*yi).sum() - xizi_sum = (xi*zi).sum() - yizi_sum = (yi*zi).sum() - return np.array([ [(zi*zi).sum(), xizi_sum, yizi_sum, zi_sum], \ - [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum], \ - [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum], \ - [zi_sum, xi_sum, yi_sum, Nd] ]) - - M = calc_moments(z_data) - - a0 = ((M[2][0]*M[3][2]-M[2][2]*M[3][0])*M[1][1]-M[1][2]*M[2][0]*M[3][1]-M[1][0]*M[2][1]*M[3][2]+M[1][0]*M[2][2]*M[3][1]+M[1][2]*M[2][1]*M[3][0])*M[0][3]+(M[0][2]*M[2][3]*M[3][0]-M[0][2]*M[2][0]*M[3][3]+M[0][0]*M[2][2]*M[3][3]-M[0][0]*M[2][3]*M[3][2])*M[1][1]+(M[0][1]*M[1][3]*M[3][0]-M[0][1]*M[1][0]*M[3][3]-M[0][0]*M[1][3]*M[3][1])*M[2][2]+(-M[0][1]*M[1][2]*M[2][3]-M[0][2]*M[1][3]*M[2][1])*M[3][0]+((M[2][3]*M[3][1]-M[2][1]*M[3][3])*M[1][2]+M[2][1]*M[3][2]*M[1][3])*M[0][0]+(M[1][0]*M[2][3]*M[3][2]+M[2][0]*(M[1][2]*M[3][3]-M[1][3]*M[3][2]))*M[0][1]+((M[2][1]*M[3][3]-M[2][3]*M[3][1])*M[1][0]+M[1][3]*M[2][0]*M[3][1])*M[0][2] - a1 = (((M[3][0]-2.*M[2][2])*M[1][1]-M[1][0]*M[3][1]+M[2][2]*M[3][0]+2.*M[1][2]*M[2][1]-M[2][0]*M[3][2])*M[0][3]+(2.*M[2][0]*M[3][2]-M[0][0]*M[3][3]-2.*M[2][2]*M[3][0]+2.*M[0][2]*M[2][3])*M[1][1]+(-M[0][0]*M[3][3]+2.*M[0][1]*M[1][3]+2.*M[1][0]*M[3][1])*M[2][2]+(-M[0][1]*M[1][3]+2.*M[1][2]*M[2][1]-M[0][2]*M[2][3])*M[3][0]+(M[1][3]*M[3][1]+M[2][3]*M[3][2])*M[0][0]+(M[1][0]*M[3][3]-2.*M[1][2]*M[2][3])*M[0][1]+(M[2][0]*M[3][3]-2.*M[1][3]*M[2][1])*M[0][2]-2.*M[1][2]*M[2][0]*M[3][1]-2.*M[1][0]*M[2][1]*M[3][2]) - a2 = ((2.*M[1][1]-M[3][0]+2.*M[2][2])*M[0][3]+(2.*M[3][0]-4.*M[2][2])*M[1][1]-2.*M[2][0]*M[3][2]+2.*M[2][2]*M[3][0]+M[0][0]*M[3][3]+4.*M[1][2]*M[2][1]-2.*M[0][1]*M[1][3]-2.*M[1][0]*M[3][1]-2.*M[0][2]*M[2][3]) - a3 = (-2.*M[3][0]+4.*M[1][1]+4.*M[2][2]-2.*M[0][3]) - a4 = -4. - - def func(x): - return a0+a1*x+a2*x*x+a3*x*x*x+a4*x*x*x*x - - def d_func(x): - return a1+2*a2*x+3*a3*x*x+4*a4*x*x*x - - x0 = spopt.fsolve(func, 0., fprime=d_func) - - def solve_eq_sys(val,M): - #prepare - M[3][0] = M[3][0]+2*val - M[0][3] = M[0][3]+2*val - M[1][1] = M[1][1]-val - M[2][2] = M[2][2]-val - return np.linalg.svd(M) - - U,s,Vt = solve_eq_sys(x0[0],M) - - A_vec = Vt[np.argmin(s),:] - - xc = -A_vec[1]/(2.*A_vec[0]) - yc = -A_vec[2]/(2.*A_vec[0]) - # the term *sqrt term corrects for the constraint, because it may be altered due to numerical inaccuracies during calculation - r0 = 1./(2.*np.absolute(A_vec[0]))*np.sqrt(A_vec[1]*A_vec[1]+A_vec[2]*A_vec[2]-4.*A_vec[0]*A_vec[3]) - if refine_results: - print("agebraic r0: " + str(r0)) - xc,yc,r0 = self._fit_circle_iter(z_data, xc, yc, r0) - r0 = self._fit_circle_iter_radialweight(z_data, xc, yc, r0) - print("iterative r0: " + str(r0)) - return xc, yc, r0 - - def _guess_delay(self,f_data,z_data): - phase2 = np.unwrap(np.angle(z_data)) - gradient, intercept, r_value, p_value, std_err = stats.linregress(f_data,phase2) - return gradient*(-1.)/(np.pi*2.) - - - def _fit_delay(self,f_data,z_data,delay=0.,maxiter=0): - def residuals(p,x,y): - phasedelay = p - z_data_temp = y*np.exp(1j*(2.*np.pi*phasedelay*x)) - xc,yc,r0 = self._fit_circle(z_data_temp) - err = np.sqrt((z_data_temp.real-xc)**2+(z_data_temp.imag-yc)**2)-r0 - return err - p_final = spopt.leastsq(residuals,delay,args=(f_data,z_data),maxfev=maxiter,ftol=1e-12,xtol=1e-12) - return p_final[0][0] - - def _fit_delay_alt_bigdata(self,f_data,z_data,delay=0.,maxiter=0): - def residuals(p,x,y): - phasedelay = p - z_data_temp = 1j*2.*np.pi*phasedelay*x - np.exp(z_data_temp,out=z_data_temp) - np.multiply(y,z_data_temp,out=z_data_temp) - #z_data_temp = y*np.exp(1j*(2.*np.pi*phasedelay*x)) - xc,yc,r0 = self._fit_circle(z_data_temp) - err = np.sqrt((z_data_temp.real-xc)**2+(z_data_temp.imag-yc)**2)-r0 - return err - p_final = spopt.leastsq(residuals,delay,args=(f_data,z_data),maxfev=maxiter,ftol=1e-12,xtol=1e-12) - return p_final[0][0] - - def _fit_entire_model(self,f_data,z_data,fr,absQc,Ql,phi0,delay,a=1.,alpha=0.,maxiter=0): - ''' - fits the whole model: a*exp(i*alpha)*exp(-2*pi*i*f*delay) * [ 1 - {Ql/Qc*exp(i*phi0)} / {1+2*i*Ql*(f-fr)/fr} ] - ''' - def funcsqr(p,x): - fr,absQc,Ql,phi0,delay,a,alpha = p - return np.array([np.absolute( ( a*np.exp(np.complex(0,alpha))*np.exp(np.complex(0,-2.*np.pi*delay*x[i])) * ( 1 - (Ql/absQc*np.exp(np.complex(0,phi0)))/(np.complex(1,2*Ql*(x[i]-fr)/fr)) ) ) )**2 for i in range(len(x))]) - def residuals(p,x,y): - fr,absQc,Ql,phi0,delay,a,alpha = p - err = [np.absolute( y[i] - ( a*np.exp(np.complex(0,alpha))*np.exp(np.complex(0,-2.*np.pi*delay*x[i])) * ( 1 - (Ql/absQc*np.exp(np.complex(0,phi0)))/(np.complex(1,2*Ql*(x[i]-fr)/fr)) ) ) ) for i in range(len(x))] - return err - p0 = [fr,absQc,Ql,phi0,delay,a,alpha] - (popt, params_cov, infodict, errmsg, ier) = spopt.leastsq(residuals,p0,args=(np.array(f_data),np.array(z_data)),full_output=True,maxfev=maxiter) - len_ydata = len(np.array(f_data)) - if (len_ydata > len(p0)) and params_cov is not None: #p_final[1] is cov_x data #this caculation is from scipy curve_fit routine - no idea if this works correctly... - s_sq = (funcsqr(popt, np.array(f_data))).sum()/(len_ydata-len(p0)) - params_cov = params_cov * s_sq - else: - params_cov = np.inf - return popt, params_cov, infodict, errmsg, ier - - # - - def _optimizedelay(self,f_data,z_data,Ql,fr,maxiter=4): - xc,yc,r0 = self._fit_circle(z_data) - z_data = self._center(z_data,np.complex(xc,yc)) - theta, Ql, fr, slope = self._phase_fit_wslope(f_data,z_data,0.,Ql,fr,0.) - delay = 0. - for i in range(maxiter-1): #interate to get besser phase delay term - delay = delay - slope/(2.*2.*np.pi) - z_data_corr = self._remove_cable_delay(f_data,z_data,delay) - xc, yc, r0 = self._fit_circle(z_data_corr) - z_data_corr2 = self._center(z_data_corr,np.complex(xc,yc)) - theta0, Ql, fr, slope = self._phase_fit_wslope(f_data,z_data_corr2,0.,Ql,fr,0.) - delay = delay - slope/(2.*2.*np.pi) #start final interation - return delay - - def _fit_circle_iter(self,z_data, xc, yc, rc): - ''' - this is the radial weighting procedure - it improves your fitting value for the radius = Ql/Qc - use this to improve your fit in presence of heavy noise - after having used the standard algebraic fir_circle() function - the weight here is: W=1/sqrt((xc-xi)^2+(yc-yi)^2) - this works, because the center of the circle is usually much less - corrupted by noise than the radius - ''' - xdat = z_data.real - ydat = z_data.imag - def fitfunc(x,y,xc,yc): - return np.sqrt((x-xc)**2+(y-yc)**2) - def residuals(p,x,y): - xc,yc,r = p - temp = (r-fitfunc(x,y,xc,yc)) - return temp - p0 = [xc,yc,rc] - p_final = spopt.leastsq(residuals,p0,args=(xdat,ydat)) - xc,yc,rc = p_final[0] - return xc,yc,rc - - def _fit_circle_iter_radialweight(self,z_data, xc, yc, rc): - ''' - this is the radial weighting procedure - it improves your fitting value for the radius = Ql/Qc - use this to improve your fit in presence of heavy noise - after having used the standard algebraic fir_circle() function - the weight here is: W=1/sqrt((xc-xi)^2+(yc-yi)^2) - this works, because the center of the circle is usually much less - corrupted by noise than the radius - ''' - xdat = z_data.real - ydat = z_data.imag - def fitfunc(x,y): - return np.sqrt((x-xc)**2+(y-yc)**2) - def weight(x,y): - try: - res = 1./np.sqrt((xc-x)**2+(yc-y)**2) - except: - res = 1. - return res - def residuals(p,x,y): - r = p[0] - temp = (r-fitfunc(x,y))*weight(x,y) - return temp - p0 = [rc] - p_final = spopt.leastsq(residuals,p0,args=(xdat,ydat)) - return p_final[0][0] - - def _get_errors(self,residual,xdata,ydata,fitparams): - ''' - wrapper for get_cov, only gives the errors and chisquare - ''' - chisqr, cov = self._get_cov(residual,xdata,ydata,fitparams) - if cov!=None: - errors = np.sqrt(np.diagonal(cov)) - else: - errors = None - return chisqr, errors - - def _residuals_notch_full(self,p,x,y): - fr,absQc,Ql,phi0,delay,a,alpha = p - err = np.absolute( y - ( a*np.exp(np.complex(0,alpha))*np.exp(np.complex(0,-2.*np.pi*delay*x)) * ( 1 - (Ql/absQc*np.exp(np.complex(0,phi0)))/(np.complex(1,2*Ql*(x-fr)/float(fr))) ) ) ) - return err - - def _residuals_notch_ideal(self,p,x,y): - fr,absQc,Ql,phi0 = p - #if fr == 0: print(p) - err = np.absolute( y - ( ( 1. - (Ql/float(absQc)*np.exp(1j*phi0))/(1+2j*Ql*(x-fr)/float(fr)) ) ) ) - #if np.isinf((np.complex(1,2*Ql*(x-fr)/float(fr))).imag): - # print(np.complex(1,2*Ql*(x-fr)/float(fr))) - # print("x: " + str(x)) - # print("Ql: " +str(Ql)) - #print("fr: " +str(fr)) - return err - - def _residuals_notch_ideal_complex(self,p,x,y): - fr,absQc,Ql,phi0 = p - #if fr == 0: print(p) - err = y - ( ( 1. - (Ql/float(absQc)*np.exp(1j*phi0))/(1+2j*Ql*(x-fr)/float(fr)) ) ) - #if np.isinf((np.complex(1,2*Ql*(x-fr)/float(fr))).imag): - # print(np.complex(1,2*Ql*(x-fr)/float(fr))) - # print("x: " + str(x)) - # print("Ql: " +str(Ql)) - #print("fr: " +str(fr)) - return err - - def _residuals_directrefl(self,p,x,y): - fr,Qc,Ql = p - #if fr == 0: print(p) - err = y - ( 2.*Ql/Qc - 1. + 2j*Ql*(fr-x)/fr ) / ( 1. - 2j*Ql*(fr-x)/fr ) - #if np.isinf((np.complex(1,2*Ql*(x-fr)/float(fr))).imag): - # print(np.complex(1,2*Ql*(x-fr)/float(fr))) - # print("x: " + str(x)) - # print("Ql: " +str(Ql)) - #print("fr: " +str(fr)) - return err - - def _residuals_transm_ideal(self,p,x,y): - fr,Ql = p - err = np.absolute( y - ( 1./(np.complex(1,2*Ql*(x-fr)/float(fr))) ) ) - return err - - - def _get_cov_fast_notch(self,xdata,ydata,fitparams): #enhanced by analytical derivatives - #derivatives of notch_ideal model with respect to parameters - def dS21_dQl(p,f): - fr,absQc,Ql,phi0 = p - return - (np.exp(1j*phi0) * fr**2) / (absQc * (fr+2j*Ql*f-2j*Ql*fr)**2 ) - - def dS21_dQc(p,f): - fr,absQc,Ql,phi0 = p - return (np.exp(1j*phi0) * Ql*fr) / (2j*(f-fr)*absQc**2*Ql+absQc**2*fr ) - - def dS21_dphi0(p,f): - fr,absQc,Ql,phi0 = p - return - (1j*Ql*fr*np.exp(1j*phi0) ) / (2j*(f-fr)*absQc*Ql+absQc*fr ) - - def dS21_dfr(p,f): - fr,absQc,Ql,phi0 = p - return - (2j*Ql**2*f*np.exp(1j*phi0) ) / (absQc * (fr+2j*Ql*f-2j*Ql*fr)**2 ) - - u = self._residuals_notch_ideal_complex(fitparams,xdata,ydata) - chi = np.absolute(u) - u = u/chi # unit vector pointing in the correct direction for the derivative - - aa = dS21_dfr(fitparams,xdata) - bb = dS21_dQc(fitparams,xdata) - cc = dS21_dQl(fitparams,xdata) - dd = dS21_dphi0(fitparams,xdata) - - Jt = np.array([aa.real*u.real+aa.imag*u.imag, bb.real*u.real+bb.imag*u.imag\ - , cc.real*u.real+cc.imag*u.imag, dd.real*u.real+dd.imag*u.imag ]) - A = np.dot(Jt,np.transpose(Jt)) - chisqr = 1./float(len(xdata)-len(fitparams)) * (chi**2).sum() - try: - cov = np.linalg.inv(A)*chisqr - except: - cov = None - return chisqr, cov - - def _get_cov_fast_directrefl(self,xdata,ydata,fitparams): #enhanced by analytical derivatives - #derivatives of notch_ideal model with respect to parameters - def dS21_dQl(p,f): - fr,Qc,Ql = p - return 2.*fr**2/( Qc*(2j*Ql*fr-2j*Ql*f+fr)**2 ) - - def dS21_dQc(p,f): - fr,Qc,Ql = p - return 2.*Ql*fr/(2j*Qc**2*(f-fr)*Ql-Qc**2*fr) - - def dS21_dfr(p,f): - fr,Qc,Ql = p - return - 4j*Ql**2*f/(Qc*(2j*Ql*fr-2j*Ql*f+fr)**2) - - u = self._residuals_directrefl(fitparams,xdata,ydata) - chi = np.absolute(u) - u = u/chi # unit vector pointing in the correct direction for the derivative - - aa = dS21_dfr(fitparams,xdata) - bb = dS21_dQc(fitparams,xdata) - cc = dS21_dQl(fitparams,xdata) - - Jt = np.array([aa.real*u.real+aa.imag*u.imag, bb.real*u.real+bb.imag*u.imag\ - , cc.real*u.real+cc.imag*u.imag ]) - A = np.dot(Jt,np.transpose(Jt)) - chisqr = 1./float(len(xdata)-len(fitparams)) * (chi**2).sum() - try: - cov = np.linalg.inv(A)*chisqr - except: - cov = None - return chisqr, cov \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py b/src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py deleted file mode 100644 index c229bce7a..000000000 --- a/src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py +++ /dev/null @@ -1,501 +0,0 @@ -import warnings -import numpy as np -import scipy.optimize as spopt -from scipy.constants import hbar - -from .utilities import plotting, save_load, Watt2dBm, dBm2Watt -from .circlefit import circlefit -from .calibration import calibration - -## -## z_data_raw denotes the raw data -## z_data denotes the normalized data -## - -class reflection_port(circlefit, save_load, plotting, calibration): - ''' - normal direct port probed in reflection - ''' - def __init__(self, f_data=None, z_data_raw=None): - self.porttype = 'direct' - self.fitresults = {} - self.z_data = None - if f_data is not None: - self.f_data = np.array(f_data) - else: - self.f_data=None - if z_data_raw is not None: - self.z_data_raw = np.array(z_data_raw) - else: - self.z_data=None - - def _S11(self,f,fr,k_c,k_i): - ''' - use either frequency or angular frequency units - for all quantities - k_l=k_c+k_i: total (loaded) coupling rate - k_c: coupling rate - k_i: internal loss rate - ''' - return ((k_c-k_i)+2j*(f-fr))/((k_c+k_i)-2j*(f-fr)) - - def get_delay(self,f_data,z_data,delay=None,ignoreslope=True,guess=True): - ''' - ignoreslope option not used here - retrieves the cable delay assuming the ideal resonance has a circular shape - modifies the cable delay until the shape Im(S21) vs Re(S21) is circular - see "do_calibration" - ''' - maxval = np.max(np.absolute(z_data)) - z_data = z_data/maxval - A1, A2, A3, A4, fr, Ql = self._fit_skewed_lorentzian(f_data,z_data) - if ignoreslope==True: - A2 = 0 - else: - z_data = (np.sqrt(np.absolute(z_data)**2-A2*(f_data-fr))) * np.exp(np.angle(z_data)*1j) #usually not necessary - if delay==None: - if guess==True: - delay = self._guess_delay(f_data,z_data) - else: - delay=0. - delay = self._fit_delay(f_data,z_data,delay,maxiter=200) - params = [A1, A2, A3, A4, fr, Ql] - return delay, params - - def do_calibration(self,f_data,z_data,ignoreslope=True,guessdelay=True): - ''' - calculating parameters for normalization - ''' - delay, params = self.get_delay(f_data,z_data,ignoreslope=ignoreslope,guess=guessdelay) - z_data = np.sqrt(np.absolute(z_data)**2-params[1]*(f_data-params[4]))*np.exp(2.*1j*np.pi*delay*f_data)*np.exp(1j*np.angle(z_data)) - xc, yc, r0 = self._fit_circle(z_data) - zc = np.complex(xc,yc) - fitparams = self._phase_fit(f_data,self._center(z_data,zc),0.,np.absolute(params[5]),params[4]) - theta, Ql, fr = fitparams - beta = self._periodic_boundary(theta+np.pi,np.pi) ### - offrespoint = np.complex((xc+r0*np.cos(beta)),(yc+r0*np.sin(beta))) - alpha = self._periodic_boundary(np.angle(offrespoint)+np.pi,np.pi) - #a = np.absolute(offrespoint) - #alpha = np.angle(zc) - a = r0 + np.absolute(zc) - return delay, a, alpha, fr, Ql, params[1], params[4] - - def do_normalization(self,f_data,z_data,delay,amp_norm,alpha,A2,frcal): - ''' - transforming resonator into canonical position - ''' - return (np.sqrt(np.absolute(z_data)**2-A2*(f_data-frcal)))/amp_norm*np.exp(1j*(-alpha+2.*np.pi*delay*f_data))*np.exp(1j*np.angle(z_data)) - - def circlefit(self,f_data,z_data,fr=None,Ql=None,refine_results=False,calc_errors=True): - ''' - S11 version of the circlefit - ''' - - if fr==None: fr=f_data[np.argmin(np.absolute(z_data))] - if Ql==None: Ql=1e6 - xc, yc, r0 = self._fit_circle(z_data,refine_results=refine_results) - phi0 = -np.arcsin(yc/r0) - theta0 = self._periodic_boundary(phi0+np.pi,np.pi) - z_data_corr = self._center(z_data,np.complex(xc,yc)) - theta0, Ql, fr = self._phase_fit(f_data,z_data_corr,theta0,Ql,fr) - #print("Ql from phasefit is: " + str(Ql)) - Qi = Ql/(1.-r0) - Qc = 1./(1./Ql-1./Qi) - - results = {"Qi":Qi,"Qc":Qc,"Ql":Ql,"fr":fr,"theta0":theta0} - - #calculation of the error - p = [fr,Qc,Ql] - #chi_square, errors = rt.get_errors(rt.residuals_notch_ideal,f_data,z_data,p) - if calc_errors==True: - chi_square, cov = self._get_cov_fast_directrefl(f_data,z_data,p) - #chi_square, cov = rt.get_cov(rt.residuals_notch_ideal,f_data,z_data,p) - - if cov is not None: - errors = np.sqrt(np.diagonal(cov)) - fr_err,Qc_err,Ql_err = errors - #calc Qi with error prop (sum the squares of the variances and covariaces) - dQl = 1./((1./Ql-1./Qc)**2*Ql**2) - dQc = - 1./((1./Ql-1./Qc)**2*Qc**2) - Qi_err = np.sqrt((dQl**2*cov[2][2]) + (dQc**2*cov[1][1])+(2*dQl*dQc*cov[2][1])) #with correlations - errors = {"Ql_err":Ql_err, "Qc_err":Qc_err, "fr_err":fr_err,"chi_square":chi_square,"Qi_err":Qi_err} - results.update( errors ) - else: - print("WARNING: Error calculation failed!") - else: - #just calc chisquared: - fun2 = lambda x: self._residuals_notch_ideal(x,f_data,z_data)**2 - chi_square = 1./float(len(f_data)-len(p)) * (fun2(p)).sum() - errors = {"chi_square":chi_square} - results.update(errors) - - return results - - - def autofit(self): - ''' - automatic calibration and fitting - ''' - delay, amp_norm, alpha, fr, Ql, A2, frcal =\ - self.do_calibration(self.f_data,self.z_data_raw,ignoreslope=True,guessdelay=False) - self.z_data = self.do_normalization(self.f_data,self.z_data_raw,delay,amp_norm,alpha,A2,frcal) - self.fitresults = self.circlefit(self.f_data,self.z_data,fr,Ql,refine_results=False,calc_errors=True) - self.z_data_sim = A2*(self.f_data-frcal)+self._S11_directrefl(self.f_data,fr=self.fitresults["fr"],Ql=self.fitresults["Ql"],Qc=self.fitresults["Qc"],a=amp_norm,alpha=alpha,delay=delay) - - def _S11_directrefl(self,f,fr=10e9,Ql=900,Qc=1000.,a=1.,alpha=0.,delay=.0): - ''' - full model for notch type resonances - ''' - return a*np.exp(np.complex(0,alpha))*np.exp(-2j*np.pi*f*delay) * ( 2.*Ql/Qc - 1. + 2j*Ql*(fr-f)/fr ) / ( 1. - 2j*Ql*(fr-f)/fr ) - - def get_single_photon_limit(self,unit='dBm'): - ''' - returns the amout of power in units of W necessary - to maintain one photon on average in the cavity - unit can be 'dbm' or 'watt' - ''' - if self.fitresults!={}: - fr = self.fitresults['fr'] - k_c = fr/self.fitresults['Qc'] - k_i = fr/self.fitresults['Qi'] - if unit=='dBm': - return Watt2dBm(1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2))) - elif unit=='watt': - return 1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2)) - - else: - warnings.warn('Please perform the fit first',UserWarning) - return None - - def get_photons_in_resonator(self,power,unit='dBm'): - ''' - returns the average number of photons - for a given power (defaul unit is 'dbm') - unit can be 'dBm' or 'watt' - ''' - if self.fitresults!={}: - if unit=='dBm': - power = dBm2Watt(power) - fr = self.fitresults['fr'] - k_c = fr/self.fitresults['Qc'] - k_i = fr/self.fitresults['Qi'] - return 4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2) * power - else: - warnings.warn('Please perform the fit first',UserWarning) - return None - -class notch_port(circlefit, save_load, plotting, calibration): - ''' - notch type port probed in transmission - ''' - def __init__(self, f_data=None, z_data_raw=None): - self.porttype = 'notch' - self.fitresults = {} - self.z_data = None - if f_data is not None: - self.f_data = np.array(f_data) - else: - self.f_data=None - if z_data_raw is not None: - self.z_data_raw = np.array(z_data_raw) - else: - self.z_data_raw=None - - def get_delay(self,f_data,z_data,delay=None,ignoreslope=True,guess=True): - ''' - retrieves the cable delay assuming the ideal resonance has a circular shape - modifies the cable delay until the shape Im(S21) vs Re(S21) is circular - see "do_calibration" - ''' - maxval = np.max(np.absolute(z_data)) - z_data = z_data/maxval - A1, A2, A3, A4, fr, Ql = self._fit_skewed_lorentzian(f_data,z_data) - if ignoreslope==True: - A2 = 0 - else: - z_data = (np.absolute(z_data)-A2*(f_data-fr)) * np.exp(np.angle(z_data)*1j) #usually not necessary - if delay==None: - if guess==True: - delay = self._guess_delay(f_data,z_data) - else: - delay=0. - delay = self._fit_delay(f_data,z_data,delay,maxiter=200) - params = [A1, A2, A3, A4, fr, Ql] - return delay, params - - def do_calibration(self,f_data,z_data,ignoreslope=True,guessdelay=True): - ''' - performs an automated calibration and tries to determine the prefactors a, alpha, delay - fr, Ql, and a possible slope are extra information, which can be used as start parameters for subsequent fits - see also "do_normalization" - the calibration procedure works for transmission line resonators as well - ''' - delay, params = self.get_delay(f_data,z_data,ignoreslope=ignoreslope,guess=guessdelay) - z_data = (z_data-params[1]*(f_data-params[4]))*np.exp(2.*1j*np.pi*delay*f_data) - xc, yc, r0 = self._fit_circle(z_data) - zc = np.complex(xc,yc) - fitparams = self._phase_fit(f_data,self._center(z_data,zc),0.,np.absolute(params[5]),params[4]) - theta, Ql, fr = fitparams - beta = self._periodic_boundary(theta+np.pi,np.pi) - offrespoint = np.complex((xc+r0*np.cos(beta)),(yc+r0*np.sin(beta))) - alpha = np.angle(offrespoint) - a = np.absolute(offrespoint) - return delay, a, alpha, fr, Ql, params[1], params[4] - - def do_normalization(self,f_data,z_data,delay,amp_norm,alpha,A2,frcal): - ''' - removes the prefactors a, alpha, delay and returns the calibrated data, see also "do_calibration" - works also for transmission line resonators - ''' - return (z_data-A2*(f_data-frcal))/amp_norm*np.exp(1j*(-alpha+2.*np.pi*delay*f_data)) - - def circlefit(self,f_data,z_data,fr=None,Ql=None,refine_results=False,calc_errors=True): - ''' - performs a circle fit on a frequency vs. complex resonator scattering data set - Data has to be normalized!! - INPUT: - f_data,z_data: input data (frequency, complex S21 data) - OUTPUT: - outpus a dictionary {key:value} consisting of the fit values, errors and status information about the fit - values: {"phi0":phi0, "Ql":Ql, "absolute(Qc)":absQc, "Qi": Qi, "electronic_delay":delay, "complexQc":complQc, "resonance_freq":fr, "prefactor_a":a, "prefactor_alpha":alpha} - errors: {"phi0_err":phi0_err, "Ql_err":Ql_err, "absolute(Qc)_err":absQc_err, "Qi_err": Qi_err, "electronic_delay_err":delay_err, "resonance_freq_err":fr_err, "prefactor_a_err":a_err, "prefactor_alpha_err":alpha_err} - for details, see: - [1] (not diameter corrected) Jiansong Gao, "The Physics of Superconducting Microwave Resonators" (PhD Thesis), Appendix E, California Institute of Technology, (2008) - [2] (diameter corrected) M. S. Khalil, et. al., J. Appl. Phys. 111, 054510 (2012) - [3] (fitting techniques) N. CHERNOV AND C. LESORT, "Least Squares Fitting of Circles", Journal of Mathematical Imaging and Vision 23, 239, (2005) - [4] (further fitting techniques) P. J. Petersan, S. M. Anlage, J. Appl. Phys, 84, 3392 (1998) - the program fits the circle with the algebraic technique described in [3], the rest of the fitting is done with the scipy.optimize least square fitting toolbox - also, check out [5] S. Probst et al. "Efficient and reliable analysis of noisy complex scatterung resonator data for superconducting quantum circuits" (in preparation) - ''' - - if fr==None: fr=f_data[np.argmin(np.absolute(z_data))] - if Ql==None: Ql=1e6 - xc, yc, r0 = self._fit_circle(z_data,refine_results=refine_results) - phi0 = -np.arcsin(yc/r0) - theta0 = self._periodic_boundary(phi0+np.pi,np.pi) - z_data_corr = self._center(z_data,np.complex(xc,yc)) - theta0, Ql, fr = self._phase_fit(f_data,z_data_corr,theta0,Ql,fr) - #print("Ql from phasefit is: " + str(Ql)) - absQc = Ql/(2.*r0) - complQc = absQc*np.exp(1j*((-1.)*phi0)) - Qc = 1./(1./complQc).real # here, taking the real part of (1/complQc) from diameter correction method - Qi_dia_corr = 1./(1./Ql-1./Qc) - Qi_no_corr = 1./(1./Ql-1./absQc) - - results = {"Qi_dia_corr":Qi_dia_corr,"Qi_no_corr":Qi_no_corr,"absQc":absQc,"Qc_dia_corr":Qc,"Ql":Ql,"fr":fr,"theta0":theta0,"phi0":phi0} - - #calculation of the error - p = [fr,absQc,Ql,phi0] - #chi_square, errors = rt.get_errors(rt.residuals_notch_ideal,f_data,z_data,p) - if calc_errors==True: - chi_square, cov = self._get_cov_fast_notch(f_data,z_data,p) - #chi_square, cov = rt.get_cov(rt.residuals_notch_ideal,f_data,z_data,p) - - if cov is not None: - errors = np.sqrt(np.diagonal(cov)) - fr_err,absQc_err,Ql_err,phi0_err = errors - #calc Qi with error prop (sum the squares of the variances and covariaces) - dQl = 1./((1./Ql-1./absQc)**2*Ql**2) - dabsQc = - 1./((1./Ql-1./absQc)**2*absQc**2) - Qi_no_corr_err = np.sqrt((dQl**2*cov[2][2]) + (dabsQc**2*cov[1][1])+(2*dQl*dabsQc*cov[2][1])) #with correlations - #calc Qi dia corr with error prop - dQl = 1/((1/Ql-np.cos(phi0)/absQc)**2 *Ql**2) - dabsQc = -np.cos(phi0)/((1/Ql-np.cos(phi0)/absQc)**2 *absQc**2) - dphi0 = -np.sin(phi0)/((1/Ql-np.cos(phi0)/absQc)**2 *absQc) - ##err1 = ( (dQl*cov[2][2])**2 + (dabsQc*cov[1][1])**2 + (dphi0*cov[3][3])**2 ) - err1 = ( (dQl**2*cov[2][2]) + (dabsQc**2*cov[1][1]) + (dphi0**2*cov[3][3]) ) - err2 = ( dQl*dabsQc*cov[2][1] + dQl*dphi0*cov[2][3] + dabsQc*dphi0*cov[1][3] ) - Qi_dia_corr_err = np.sqrt(err1+2*err2) # including correlations - errors = {"phi0_err":phi0_err, "Ql_err":Ql_err, "absQc_err":absQc_err, "fr_err":fr_err,"chi_square":chi_square,"Qi_no_corr_err":Qi_no_corr_err,"Qi_dia_corr_err": Qi_dia_corr_err} - results.update( errors ) - else: - print("WARNING: Error calculation failed!") - else: - #just calc chisquared: - fun2 = lambda x: self._residuals_notch_ideal(x,f_data,z_data)**2 - chi_square = 1./float(len(f_data)-len(p)) * (fun2(p)).sum() - errors = {"chi_square":chi_square} - results.update(errors) - - return results - - def autofit(self): - ''' - automatic calibration and fitting - ''' - delay, amp_norm, alpha, fr, Ql, A2, frcal =\ - self.do_calibration(self.f_data,self.z_data_raw,ignoreslope=True,guessdelay=True) - self.z_data = self.do_normalization(self.f_data,self.z_data_raw,delay,amp_norm,alpha,A2,frcal) - self.fitresults = self.circlefit(self.f_data,self.z_data,fr,Ql,refine_results=False,calc_errors=True) - self.z_data_sim = A2*(self.f_data-frcal)+self._S21_notch(self.f_data,fr=self.fitresults["fr"],Ql=self.fitresults["Ql"],Qc=self.fitresults["absQc"],phi=self.fitresults["phi0"],a=amp_norm,alpha=alpha,delay=delay) - - def _S21_notch(self,f,fr=10e9,Ql=900,Qc=1000.,phi=0.,a=1.,alpha=0.,delay=.0): - ''' - full model for notch type resonances - ''' - return a*np.exp(np.complex(0,alpha))*np.exp(-2j*np.pi*f*delay)*(1.-Ql/Qc*np.exp(1j*phi)/(1.+2j*Ql*(f-fr)/fr)) - - def get_single_photon_limit(self,unit='dBm'): - ''' - returns the amout of power in units of W necessary - to maintain one photon on average in the cavity - unit can be 'dBm' or 'watt' - ''' - if self.fitresults!={}: - fr = self.fitresults['fr'] - k_c = fr/self.fitresults['absQc'] - k_i = fr/self.fitresults['Qi_dia_corr'] - if unit=='dBm': - return Watt2dBm(1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2))) - elif unit=='watt': - return 1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2)) - else: - warnings.warn('Please perform the fit first',UserWarning) - return None - - def get_photons_in_resonator(self,power,unit='dBm'): - ''' - returns the average number of photons - for a given power in units of W - unit can be 'dBm' or 'watt' - ''' - if self.fitresults!={}: - if unit=='dBm': - power = dBm2Watt(power) - fr = self.fitresults['fr'] - k_c = fr/self.fitresults['Qc'] - k_i = fr/self.fitresults['Qi'] - return 4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2) * power - else: - warnings.warn('Please perform the fit first',UserWarning) - return None - -class transmission_port(circlefit,save_load,plotting): - ''' - a class for handling transmission measurements - ''' - - def __init__(self,f_data=None,z_data_raw=None): - self.porttype = 'transm' - self.fitresults = {} - if f_data!=None: - self.f_data = np.array(f_data) - else: - self.f_data=None - if z_data_raw!=None: - self.z_data_raw = np.array(z_data_raw) - else: - self.z_data=None - - def _S21(self,f,fr,Ql,A): - return A**2/(1.+4.*Ql**2*((f-fr)/fr)**2) - - def fit(self): - self.ampsqr = (np.absolute(self.z_data_raw))**2 - p = [self.f_data[np.argmax(self.ampsqr)],1000.,np.amax(self.ampsqr)] - popt, pcov = spopt.curve_fit(self._S21, self.f_data, self.ampsqr,p) - errors = np.sqrt(np.diag(pcov)) - self.fitresults = {'fr':popt[0],'fr_err':errors[0],'Ql':popt[1],'Ql_err':errors[1],'Ampsqr':popt[2],'Ampsqr_err':errors[2]} - -class resonator(object): - ''' - Universal resonator analysis class - It can handle different kinds of ports and assymetric resonators. - ''' - def __init__(self, ports = {}, comment = None): - ''' - initializes the resonator class object - ports (dictionary {key:value}): specify the name and properties of the coupling ports - e.g. ports = {'1':'direct', '2':'notch'} - comment: add a comment - ''' - self.comment = comment - self.port = {} - self.transm = {} - if len(ports) > 0: - for key, pname in ports.iteritems(): - if pname=='direct': - self.port.update({key:reflection_port()}) - elif pname=='notch': - self.port.update({key:notch_port()}) - else: - warnings.warn("Undefined input type! Use 'direct' or 'notch'.", SyntaxWarning) - if len(self.port) == 0: warnings.warn("Resonator has no coupling ports!", UserWarning) - - def add_port(self,key,pname): - if pname=='direct': - self.port.update({key:reflection_port()}) - elif pname=='notch': - self.port.update({key:notch_port()}) - else: - warnings.warn("Undefined input type! Use 'direct' or 'notch'.", SyntaxWarning) - if len(self.port) == 0: warnings.warn("Resonator has no coupling ports!", UserWarning) - - def delete_port(self,key): - del self.port[key] - if len(self.port) == 0: warnings.warn("Resonator has no coupling ports!", UserWarning) - - def get_Qi(self): - ''' - based on the number of ports and the corresponding measurements - it calculates the internal losses - ''' - pass - - def get_single_photon_limit(self,port): - ''' - returns the amout of power necessary to maintain one photon - on average in the cavity - ''' - pass - - def get_photons_in_resonator(self,power,port): - ''' - returns the average number of photons - for a given power - ''' - pass - - def add_transm_meas(self,port1, port2): - ''' - input: port1 - output: port2 - adds a transmission measurement - connecting two direct ports S21 - ''' - key = port1 + " -> " + port2 - self.port.update({key:transm()}) - pass - - -class batch_processing(object): - ''' - A class for batch processing of resonator data as a function of another variable - Typical applications are power scans, magnetic field scans etc. - ''' - - def __init__(self,porttype): - ''' - porttype = 'notch', 'direct', 'transm' - results is an array of dictionaries containing the fitresults - ''' - self.porttype = porttype - self.results = [] - - def autofit(self,cal_dataslice = 0): - ''' - fits all data - cal_dataslice: choose scatteringdata which should be used for calibration - of the amplitude and phase, default = 0 (first) - ''' - pass - -class coupled_resonators(batch_processing): - ''' - A class for fitting a resonator coupled to a second one - ''' - - def __init__(self,porttype): - self.porttype = porttype - self.results = [] - \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py b/src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py deleted file mode 100644 index 611218631..000000000 --- a/src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py +++ /dev/null @@ -1,187 +0,0 @@ -import warnings -import numpy as np -plot_enable = False -try: - import qkit - if qkit.module_available("matplotlib"): - import matplotlib.pyplot as plt - plot_enable = True -except (ImportError, AttributeError): - try: - import matplotlib.pyplot as plt - plot_enable = True - except ImportError: - plot_enable = False - -def Watt2dBm(x): - ''' - converts from units of watts to dBm - ''' - return 10.*np.log10(x*1000.) - -def dBm2Watt(x): - ''' - converts from units of watts to dBm - ''' - return 10**(x/10.) /1000. - -class plotting(object): - ''' - some helper functions for plotting - ''' - def plotall(self): - if not plot_enable: - raise ImportError("matplotlib not found") - real = self.z_data_raw.real - imag = self.z_data_raw.imag - real2 = self.z_data_sim.real - imag2 = self.z_data_sim.imag - plt.subplot(221) - plt.plot(real,imag,label='rawdata') - plt.plot(real2,imag2,label='fit') - plt.xlabel('Re(S21)') - plt.ylabel('Im(S21)') - plt.legend() - plt.subplot(222) - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_sim),label='fit') - plt.xlabel('f (GHz)') - plt.ylabel('|S21|') - plt.legend() - plt.subplot(223) - plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') - plt.plot(self.f_data*1e-9,np.angle(self.z_data_sim),label='fit') - plt.xlabel('f (GHz)') - plt.ylabel('arg(|S21|)') - plt.legend() - plt.show() - - def plotcalibrateddata(self): - if not plot_enable: - raise ImportError("matplotlib not found") - real = self.z_data.real - imag = self.z_data.imag - plt.subplot(221) - plt.plot(real,imag,label='rawdata') - plt.xlabel('Re(S21)') - plt.ylabel('Im(S21)') - plt.legend() - plt.subplot(222) - plt.plot(self.f_data*1e-9,np.absolute(self.z_data),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('|S21|') - plt.legend() - plt.subplot(223) - plt.plot(self.f_data*1e-9,np.angle(self.z_data),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('arg(|S21|)') - plt.legend() - plt.show() - - def plotrawdata(self): - if not plot_enable: - raise ImportError("matplotlib not found") - real = self.z_data_raw.real - imag = self.z_data_raw.imag - plt.subplot(221) - plt.plot(real,imag,label='rawdata') - plt.xlabel('Re(S21)') - plt.ylabel('Im(S21)') - plt.legend() - plt.subplot(222) - plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('|S21|') - plt.legend() - plt.subplot(223) - plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') - plt.xlabel('f (GHz)') - plt.ylabel('arg(|S21|)') - plt.legend() - plt.show() - -class save_load(object): - ''' - procedures for loading and saving data used by other classes - ''' - def _ConvToCompl(self,x,y,dtype): - ''' - dtype = 'realimag', 'dBmagphaserad', 'linmagphaserad', 'dBmagphasedeg', 'linmagphasedeg' - ''' - if dtype=='realimag': - return x+1j*y - elif dtype=='linmagphaserad': - return x*np.exp(1j*y) - elif dtype=='dBmagphaserad': - return 10**(x/20.)*np.exp(1j*y) - elif dtype=='linmagphasedeg': - return x*np.exp(1j*y/180.*np.pi) - elif dtype=='dBmagphasedeg': - return 10**(x/20.)*np.exp(1j*y/180.*np.pi) - else: warnings.warn("Undefined input type! Use 'realimag', 'dBmagphaserad', 'linmagphaserad', 'dBmagphasedeg' or 'linmagphasedeg'.", SyntaxWarning) - - def add_data(self,f_data,z_data): - self.f_data = np.array(f_data) - self.z_data_raw = np.array(z_data) - - def cut_data(self,f1,f2): - def findpos(f_data,val): - pos = 0 - for i in range(len(f_data)): - if f_data[i]=f_min and f<=f_max in the frequency-array - the fit functions are fitted only in this area - the data in the .h5-file is NOT changed - ''' - if data.ndim == 1: - return data[(self._frequency >= self._f_min) & (self._frequency <= self._f_max)] - if data.ndim == 2: - ret_array=np.empty(shape=(data.shape[0],self._fit_frequency.shape[0]),dtype=np.float64) - for i,a in enumerate(data): - ret_array[i]=data[i][(self._frequency >= self._f_min) & (self._frequency <= self._f_max)] - return ret_array - - def _get_datasets(self): - ''' - reads out the file - ''' - if not self._hf: - logging.info('No hf file kown yet!') - return - - self._ds_amp = self._hf.get_dataset(self.ds_url_amp) - self._ds_pha = self._hf.get_dataset(self.ds_url_pha) - self._ds_type = self._ds_amp.ds_type - - self._amplitude = np.array(self._hf[self.ds_url_amp],dtype=np.float64) - self._phase = np.array(self._hf[self.ds_url_pha],dtype=np.float64) - self._frequency = np.array(self._hf[self.ds_url_freq],dtype=np.float64) - - try: - self._x_co = self._hf.get_dataset(self._ds_amp.x_ds_url) - except: - try: - self._x_co = self._hf.get_dataset(self.ds_url_power) # hardcode a std url - except: - logging.warning('Unable to open any x_coordinate. Please set manually using \'set_x_coord()\'.') - try: - self._y_co = self._hf.get_dataset(self._ds_amp.y_ds_url) - except: - try: self._y_co = self._hf.get_dataset(self.ds_url_freq) # hardcode a std url - except: - logging.warning('Unable to open any y_coordinate. Please set manually using \'set_y_coord()\'.') - self._datasets_loaded = True - - def _prepare_f_range(self,f_min,f_max): - ''' - prepares the data to be fitted: - f_min (float): lower boundary - f_max (float): upper boundary - ''' - - self._f_min = np.min(self._frequency) - self._f_max = np.max(self._frequency) - - ''' - f_min f_max do not have to be exactly an entry in the freq-array - ''' - if f_min: - for freq in self._frequency: - if freq > f_min: - self._f_min = freq - break - if f_max: - for freq in self._frequency: - if freq > f_max: - self._f_max = freq - break - - ''' - cut the data-arrays with f_min/f_max and fit_all information - ''' - self._fit_frequency = np.array(self._set_data_range(self._frequency)) - self._fit_amplitude = np.array(self._set_data_range(self._amplitude)) - self._fit_phase = np.array(self._set_data_range(self._phase)) - - self._frequency_co = self._hf.add_coordinate('frequency',folder='analysis', unit = 'Hz') - self._frequency_co.add(self._fit_frequency) - - def _update_data(self): - self._amplitude = np.array(self._hf[self.ds_url_amp],dtype=np.float64) - self._phase = np.array(self._hf[self.ds_url_pha],dtype=np.float64) - - def _get_starting_values(self): - pass - - def fit_circle(self,reflection = False, notch = False, fit_all = False, f_min = None, f_max=None): - self._fit_all = fit_all - self._circle_reflection = reflection - self._circle_notch = notch - if not reflection and not notch: - self._circle_notch = True - - if not self._datasets_loaded: - self._get_datasets() - - self._update_data() - self._prepare_f_range(f_min, f_max) - - if self._first_circle: - self._prepare_circle() - self._first_circle = False - - if self._circle_reflection: - self._circle_port = circuit.reflection_port(f_data = self._fit_frequency) - elif self._circle_notch: - self._circle_port = circuit.notch_port(f_data = self._fit_frequency) - - self._do_fit_circle() - - def _do_fit_circle(self): - ''' - Creates corresponding ports in circuit.py in the qkit/analysis folder - circle fit for amp and pha data in the f_min-f_max frequency range - fit parameter, errors, and generated amp/pha data are stored in the hdf-file - - input: - fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) - ''' - - self._get_data_circle() - trace = 0 - self.debug("circle fit:") - for z_data_raw in self._z_data_raw: - - z_data_raw.real = self._pre_filter_data(z_data_raw.real) - z_data_raw.imag = self._pre_filter_data(z_data_raw.imag) - self.debug("fitting trace: "+str(trace)) - self._circle_port.z_data_raw = z_data_raw - - try: - self._circle_port.autofit() - except: - err=np.array([np.nan for f in self._fit_frequency]) - self._circ_amp_gen.append(err) - self._circ_pha_gen.append(err) - self._circ_real_gen.append(err) - self._circ_imag_gen.append(err) - - for key in iter(self._results): - self._results[str(key)].append(np.nan) - - else: - self._circ_amp_gen.append(np.absolute(self._circle_port.z_data_sim)) - self._circ_pha_gen.append(np.angle(self._circle_port.z_data_sim)) - self._circ_real_gen.append(np.real(self._circle_port.z_data_sim)) - self._circ_imag_gen.append(np.imag(self._circle_port.z_data_sim)) - - for key in iter(self._results): - self._results[str(key)].append(float(self._circle_port.fitresults[str(key)])) - trace+=1 - - def _prepare_circle(self): - ''' - creates the datasets for the circle fit in the hdf-file - ''' - self._results = {} - circle_fit_version = qkit.cfg.get("circle_fit_version", 1) - if circle_fit_version == 1: - if self._circle_notch: - self._result_keys = ["Qi_dia_corr", "Qi_no_corr", "absQc", "Qc_dia_corr", "Ql", - "fr", "theta0", "phi0", "phi0_err", "Ql_err", "absQc_err", - "fr_err", "chi_square", "Qi_no_corr_err", "Qi_dia_corr_err"] - elif self._circle_reflection: - self._result_keys = ["Qi", "Qc", "Ql", "fr", "theta0", "Ql_err", - "Qc_err", "fr_err", "chi_square", "Qi_err"] - - elif circle_fit_version == 2: - self._result_keys = ["delay", "delay_remaining", "a", "alpha", "theta", "phi", "fr", "Ql", "Qc", - "Qc_no_dia_corr", "Qi", "Qi_no_dia_corr", "fr_err", "Ql_err", "absQc_err", - "phi_err", "Qi_err", "Qi_no_dia_corr_err", "chi_square", "Qi_min", "Qi_max", - "Qc_min", "Qc_max", "fano_b"] - else: - logging.warning("Circle fit version not properly set in configuration!") - - if self._ds_type == ds_types['vector']: # data from measure_1d - self._data_real_gen = self._hf.add_value_vector('data_real_gen', self._frequency_co, folder='analysis', - unit='') - self._data_imag_gen = self._hf.add_value_vector('data_imag_gen', self._frequency_co, folder='analysis', - unit='') - - self._circ_amp_gen = self._hf.add_value_vector('circ_amp_gen', self._frequency_co, folder='analysis', - unit='arb. unit') - self._circ_pha_gen = self._hf.add_value_vector('circ_pha_gen', self._frequency_co, folder='analysis', - unit='rad') - self._circ_real_gen = self._hf.add_value_vector('circ_real_gen', self._frequency_co, folder='analysis', - unit='') - self._circ_imag_gen = self._hf.add_value_vector('circ_imag_gen', self._frequency_co, folder='analysis', - unit='') - - for key in self._result_keys: - self._results[key] = self._hf.add_coordinate('circ_' + key, folder='analysis', unit='') - - if self._ds_type == ds_types['matrix']: # data from measure_2d - self._data_real_gen = self._hf.add_value_matrix('data_real_gen', self._x_co, self._frequency_co, - folder='analysis', unit='') - self._data_imag_gen = self._hf.add_value_matrix('data_imag_gen', self._x_co, self._frequency_co, - folder='analysis', unit='') - - self._circ_amp_gen = self._hf.add_value_matrix('circ_amp_gen', self._x_co, self._frequency_co, - folder='analysis', unit='arb. unit') - self._circ_pha_gen = self._hf.add_value_matrix('circ_pha_gen', self._x_co, self._frequency_co, - folder='analysis', unit='rad') - self._circ_real_gen = self._hf.add_value_matrix('circ_real_gen', self._x_co, self._frequency_co, - folder='analysis', unit='') - self._circ_imag_gen = self._hf.add_value_matrix('circ_imag_gen', self._x_co, self._frequency_co, - folder='analysis', unit='') - - for key in self._result_keys: - self._results[key] = self._hf.add_value_vector('circ_' + key, folder='analysis', x=self._x_co, unit='') - - circ_view_amp = self._hf.add_view('circ_amp', x=self._y_co, y=self._ds_amp) - circ_view_amp.add(x=self._frequency_co, y=self._circ_amp_gen) - circ_view_pha = self._hf.add_view('circ_pha', x=self._y_co, y=self._ds_pha) - circ_view_pha.add(x=self._frequency_co, y=self._circ_pha_gen) - circ_view_iq = self._hf.add_view('circ_IQ', x=self._data_real_gen, y=self._data_imag_gen, - view_params={'aspect': 1.0}) - circ_view_iq.add(x=self._circ_real_gen, y=self._circ_imag_gen) - - def _get_data_circle(self): - ''' - calc complex data from amp and pha - ''' - if not self._fit_all: - self._z_data_raw = np.empty((1,self._fit_frequency.shape[0]), dtype=np.complex64) - - if self._fit_amplitude.ndim == 1: - self._z_data_raw[0] = np.array(self._fit_amplitude*np.exp(1j*self._fit_phase),dtype=np.complex64) - else: - self._z_data_raw[0] = np.array(self._fit_amplitude[-1]*np.exp(1j*self._fit_phase[-1]),dtype=np.complex64) - - self._data_real_gen.append(self._z_data_raw[0].real) - self._data_imag_gen.append(self._z_data_raw[0].imag) - - if self._fit_all: - self._z_data_raw = np.empty((self._fit_amplitude.shape), dtype=np.complex64) - for i,a in enumerate(self._fit_amplitude): - self._z_data_raw[i] = self._fit_amplitude[i]*np.exp(1j*self._fit_phase[i]) - self._data_real_gen.append(self._z_data_raw[i].real) - self._data_imag_gen.append(self._z_data_raw[i].imag) - - def _get_last_amp_trace(self): - tmp_amp = np.empty((1,self._fit_frequency.shape[0])) - if self._fit_amplitude.ndim==1: - tmp_amp[0] = self._fit_amplitude - else: - tmp_amp[0] = self._fit_amplitude[-1] - self._fit_amplitude = np.empty((1,self._fit_frequency.shape[0])) - self._fit_amplitude[0] = tmp_amp[0] - - def fit_lorentzian(self,fit_all = False,f_min=None,f_max=None,pre_filter_data=None): - ''' - lorentzian fit for amp data in the f_min-f_max frequency range - squared amps are fitted at lorentzian using scipy.leastsq - fit parameter, chi2, and generated amp are stored in the hdf-file - - input: - fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) - f_min (float): lower boundary for data to be fitted (optional, default: None, results in min(frequency-array)) - f_max (float): upper boundary for data to be fitted (optional, default: None, results in max(frequency-array)) - ''' - def residuals(p,x,y): - f0,k,a,offs=p - err = y-(a/(1+4*((x-f0)/k)**2)+offs) - return err - - self._fit_all = fit_all - - if not self._datasets_loaded: - self._get_datasets() - - self._update_data() - self._prepare_f_range(f_min,f_max) - if self._first_lorentzian: - self._prepare_lorentzian() - self._first_lorentzian=False - - ''' - fit_amplitude is always 2dim np array. - for 1dim data, shape: (1, # fit frequency points) - for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) - ''' - if not self._fit_all: - self._get_last_amp_trace() - - for amplitudes in self._fit_amplitude: - amplitudes = np.absolute(amplitudes) - amplitudes_sq = amplitudes**2 - '''extract starting parameter for lorentzian from data''' - s_offs = np.mean(np.array([amplitudes_sq[:int(np.size(amplitudes_sq)*.1)], amplitudes_sq[int(np.size(amplitudes_sq)-int(np.size(amplitudes_sq)*.1)):]])) - '''offset is calculated from the first and last 10% of the data to improve fitting on tight windows''' - - if np.abs(np.max(amplitudes_sq)-np.mean(amplitudes_sq)) > np.abs(np.min(amplitudes_sq)-np.mean(amplitudes_sq)): - '''peak is expected''' - s_a = np.abs((np.max(amplitudes_sq)-np.mean(amplitudes_sq))) - s_f0 = self._fit_frequency[np.argmax(amplitudes_sq)] - else: - '''dip is expected''' - s_a = -np.abs((np.min(amplitudes_sq)-np.mean(amplitudes_sq))) - s_f0 = self._fit_frequency[np.argmin(amplitudes_sq)] - - '''estimate peak/dip width''' - mid = s_offs + .5*s_a #estimated mid region between base line and peak/dip - m = [] #mid points - for i in range(len(amplitudes_sq)-1): - if np.sign(amplitudes_sq[i]-mid) != np.sign(amplitudes_sq[i+1]-mid):#mid level crossing - m.append(i) - if len(m)>1: - s_k = self._fit_frequency[m[-1]]-self._fit_frequency[m[0]] - else: - s_k = .15*(self._fit_frequency[-1]-self._fit_frequency[0]) #try 15% of window - p0=[s_f0, s_k, s_a, s_offs] - try: - fit = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) - except: - self._lrnz_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) - self._lrnz_f0.append(np.nan) - self._lrnz_k.append(np.nan) - self._lrnz_a.append(np.nan) - self._lrnz_offs.append(np.nan) - self._lrnz_Ql.append(np.nan) - self._lrnz_chi2_fit.append(np.nan) - else: - popt=fit[0] - chi2 = self._lorentzian_fit_chi2(popt,amplitudes_sq) - self._lrnz_amp_gen.append(np.sqrt(np.array(self._lorentzian_from_fit(popt)))) - self._lrnz_f0.append(float(popt[0])) - self._lrnz_k.append(float(np.fabs(float(popt[1])))) - self._lrnz_a.append(float(popt[2])) - self._lrnz_offs.append(float(popt[3])) - self._lrnz_Ql.append(float(float(popt[0])/np.fabs(float(popt[1])))) - self._lrnz_chi2_fit.append(float(chi2)) - - def _prepare_lorentzian(self): - ''' - creates the datasets for the lorentzian fit in the hdf-file - ''' - if self._ds_type == ds_types['vector']: # data from measure_1d - self._lrnz_amp_gen = self._hf.add_value_vector('lrnz_amp_gen', folder = 'analysis', x = self._frequency_co, unit = 'arb. unit') - self._lrnz_f0 = self._hf.add_coordinate('lrnz_f0', folder = 'analysis', unit = 'Hz') - self._lrnz_k = self._hf.add_coordinate('lrnz_k', folder = 'analysis', unit = 'Hz') - self._lrnz_a = self._hf.add_coordinate('lrnz_a', folder = 'analysis', unit = '') - self._lrnz_offs = self._hf.add_coordinate('lrnz_offs', folder = 'analysis', unit = '') - self._lrnz_Ql = self._hf.add_coordinate('lrnz_ql', folder = 'analysis', unit = '') - - self._lrnz_chi2_fit = self._hf.add_coordinate('lrnz_chi2' , folder = 'analysis', unit = '') - - if self._ds_type == ds_types['matrix']: # data from measure_2d - self._lrnz_amp_gen = self._hf.add_value_matrix('lrnz_amp_gen', folder = 'analysis', x = self._x_co, y = self._frequency_co, unit = 'arb. unit') - self._lrnz_f0 = self._hf.add_value_vector('lrnz_f0', folder = 'analysis', x = self._x_co, unit = 'Hz') - self._lrnz_k = self._hf.add_value_vector('lrnz_k', folder = 'analysis', x = self._x_co, unit = 'Hz') - self._lrnz_a = self._hf.add_value_vector('lrnz_a', folder = 'analysis', x = self._x_co, unit = '') - self._lrnz_offs = self._hf.add_value_vector('lrnz_offs', folder = 'analysis', x = self._x_co, unit = '') - self._lrnz_Ql = self._hf.add_value_vector('lrnz_ql', folder = 'analysis', x = self._x_co, unit = '') - - self._lrnz_chi2_fit = self._hf.add_value_vector('lrnz_chi2' , folder = 'analysis', x = self._x_co, unit = '') - - lrnz_view = self._hf.add_view("lrnz_fit", x = self._y_co, y = self._ds_amp) - lrnz_view.add(x=self._frequency_co, y=self._lrnz_amp_gen) - - def _lorentzian_from_fit(self,fit): - return fit[2] / (1 + (4*((self._fit_frequency-fit[0])/fit[1]) ** 2)) + fit[3] - - def _lorentzian_fit_chi2(self, fit, amplitudes_sq): - chi2 = np.sum((self._lorentzian_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) - return chi2 - - def fit_skewed_lorentzian(self, fit_all = False, f_min=None, f_max=None,pre_filter_data=None): - ''' - skewed lorentzian fit for amp data in the f_min-f_max frequency range - squared amps are fitted at skewed lorentzian using scipy.leastsq - fit parameter, chi2, and generated amp are stored in the hdf-file - - input: - fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) - f_min (float): lower boundary for data to be fitted (optional, default: None, results in min(frequency-array)) - f_max (float): upper boundary for data to be fitted (optional, default: None, results in max(frequency-array)) - ''' - def residuals(p,x,y): - A2, A4, Qr = p - err = y -(A1a+A2*(x-fra)+(A3a+A4*(x-fra))/(1.+4.*Qr**2*((x-fra)/fra)**2)) - return err - def residuals2(p,x,y): - A1, A2, A3, A4, fr, Qr = p - err = y -(A1+A2*(x-fr)+(A3+A4*(x-fr))/(1.+4.*Qr**2*((x-fr)/fr)**2)) - return err - - self._fit_all = fit_all - - if not self._datasets_loaded: - self._get_datasets() - self._update_data() - - self._prepare_f_range(f_min,f_max) - if self._first_skewed_lorentzian: - self._prepare_skewed_lorentzian() - self._first_skewed_lorentzian = False - - ''' - fit_amplitude is always 2dim np array. - for 1dim data, shape: (1, # fit frequency points) - for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) - ''' - if not self._fit_all: - self._get_last_amp_trace() - - for amplitudes in self._fit_amplitude: - "fits a skewed lorenzian to reflection amplitudes of a resonator" - # prefilter the data - amplitudes = self._pre_filter_data(amplitudes) - - amplitudes = np.absolute(amplitudes) - amplitudes_sq = amplitudes**2 - - A1a = np.minimum(amplitudes_sq[0],amplitudes_sq[-1]) - A3a = -np.max(amplitudes_sq) - fra = self._fit_frequency[np.argmin(amplitudes_sq)] - - p0 = [0., 0., 1e3] - - try: - p_final = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) - A2a, A4a, Qra = p_final[0] - - p0 = [A1a, A2a , A3a, A4a, fra, Qra] - p_final = leastsq(residuals2,p0,args=(self._fit_frequency,amplitudes_sq)) - popt=p_final[0] - except: - self._skwd_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) - self._skwd_f0.append(np.nan) - self._skwd_a1.append(np.nan) - self._skwd_a2.append(np.nan) - self._skwd_a3.append(np.nan) - self._skwd_a4.append(np.nan) - self._skwd_Qr.append(np.nan) - self._skwd_chi2_fit.append(np.nan) - else: - chi2 = self._skewed_fit_chi2(popt,amplitudes_sq) - amp_gen = np.sqrt(np.array(self._skewed_from_fit(popt))) - - self._skwd_amp_gen.append(amp_gen) - self._skwd_f0.append(float(popt[4])) - self._skwd_a1.append(float(popt[0])) - self._skwd_a2.append(float(popt[1])) - self._skwd_a3.append(float(popt[2])) - self._skwd_a4.append(float(popt[3])) - self._skwd_Qr.append(float(popt[5])) - self._skwd_chi2_fit.append(float(chi2)) - - self._skwd_Qi.append(self._skewed_estimate_Qi(popt)) - - def _prepare_skewed_lorentzian(self): - ''' - creates the datasets for the skewed lorentzian fit in the hdf-file - ''' - if self._ds_type == ds_types['vector']: # data from measure_1d - self._skwd_amp_gen = self._hf.add_value_vector('sklr_amp_gen', folder = 'analysis', x = self._frequency_co, unit = 'arb. unit') - self._skwd_f0 = self._hf.add_coordinate('sklr_f0', folder = 'analysis', unit = 'Hz') - self._skwd_a1 = self._hf.add_coordinate('sklr_a1', folder = 'analysis', unit = 'Hz') - self._skwd_a2 = self._hf.add_coordinate('sklr_a2', folder = 'analysis', unit = '') - self._skwd_a3 = self._hf.add_coordinate('sklr_a3', folder = 'analysis', unit = '') - self._skwd_a4 = self._hf.add_coordinate('sklr_a4', folder = 'analysis', unit = '') - self._skwd_Qr = self._hf.add_coordinate('sklr_qr', folder = 'analysis', unit = '') - self._skwd_Qi = self._hf.add_coordinate('sklr_qi', folder = 'analysis', unit = '') - - self._skwd_chi2_fit = self._hf.add_coordinate('sklr_chi2' , folder = 'analysis', unit = '') - - if self._ds_type == ds_types['matrix']: # data from measure_2d - self._skwd_amp_gen = self._hf.add_value_matrix('sklr_amp_gen', folder = 'analysis', x = self._x_co, y = self._frequency_co, unit = 'arb. unit') - self._skwd_f0 = self._hf.add_value_vector('sklr_f0', folder = 'analysis', x = self._x_co, unit = 'Hz') - self._skwd_a1 = self._hf.add_value_vector('sklr_a1', folder = 'analysis', x = self._x_co, unit = 'Hz') - self._skwd_a2 = self._hf.add_value_vector('sklr_a2', folder = 'analysis', x = self._x_co, unit = '') - self._skwd_a3 = self._hf.add_value_vector('sklr_a3', folder = 'analysis', x = self._x_co, unit = '') - self._skwd_a4 = self._hf.add_value_vector('sklr_a4', folder = 'analysis', x = self._x_co, unit = '') - self._skwd_Qr = self._hf.add_value_vector('sklr_qr', folder = 'analysis', x = self._x_co, unit = '') - self._skwd_Qi = self._hf.add_value_vector('sklr_qi', folder = 'analysis', x = self._x_co, unit = '') - - self._skwd_chi2_fit = self._hf.add_value_vector('sklr_chi2' , folder = 'analysis', x = self._x_co, unit = '') - - skwd_view = self._hf.add_view('sklr_fit', x = self._y_co, y = self._ds_amp) - skwd_view.add(x=self._frequency_co, y=self._skwd_amp_gen) - - def _skewed_fit_chi2(self, fit, amplitudes_sq): - chi2 = np.sum((self._skewed_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) - return chi2 - - def _skewed_from_fit(self,p): - A1, A2, A3, A4, fr, Qr = p - return A1+A2*(self._fit_frequency-fr)+(A3+A4*(self._fit_frequency-fr))/(1.+4.*Qr**2*((self._fit_frequency-fr)/fr)**2) - - def _skewed_estimate_Qi(self,p): - - #this is a very clumsy numerical estimate of the Qi factor based on the +3dB method.# - A1, A2, A3, A4, fr, Qr = p - def skewed_from_fit(p,f): - A1, A2, A3, A4, fr, Qr = p - return A1+A2*(f-fr)+(A3+A4*(f-fr))/(1.+4.*Qr**2*((f-fr)/fr)**2) - - #df = fr/(Qr*10000) - fmax = fr+fr/Qr - fs = np.linspace(fr,fmax,1000,dtype=np.float64) - Amin = skewed_from_fit(p,fr) - #print("---") - #print("Amin, 2*Amin",Amin, 2*Amin) - - for f in fs: - A = skewed_from_fit(p,f) - #print(A, f) - if A>2*Amin: - break - qi = fr/(2*(f-fr)) - #print("f, A, fr/2*(f-fr)", f,A, qi) - - return float(qi) - - def _prepare_fano(self): - "create the datasets for the fano fit in the hdf-file" - if self._ds_type == ds_types['vector']: # data from measure_1d - self._fano_amp_gen = self._hf.add_value_vector('fano_amp_gen', folder = 'analysis', x = self._frequency_co, unit = 'arb. unit') - self._fano_q_fit = self._hf.add_coordinate('fano_q' , folder = 'analysis', unit = '') - self._fano_bw_fit = self._hf.add_coordinate('fano_bw', folder = 'analysis', unit = 'Hz') - self._fano_fr_fit = self._hf.add_coordinate('fano_fr', folder = 'analysis', unit = 'Hz') - self._fano_a_fit = self._hf.add_coordinate('fano_a' , folder = 'analysis', unit = '') - - self._fano_chi2_fit = self._hf.add_coordinate('fano_chi2' , folder = 'analysis', unit = '') - self._fano_Ql_fit = self._hf.add_coordinate('fano_Ql' , folder = 'analysis', unit = '') - self._fano_Q0_fit = self._hf.add_coordinate('fano_Q0' , folder = 'analysis', unit = '') - - if self._ds_type == ds_types['matrix']: # data from measure_2d - self._fano_amp_gen = self._hf.add_value_matrix('fano_amp_gen', folder = 'analysis', x = self._x_co, y = self._frequency_co, unit = 'arb. unit') - self._fano_q_fit = self._hf.add_value_vector('fano_q' , folder = 'analysis', x = self._x_co, unit = '') - self._fano_bw_fit = self._hf.add_value_vector('fano_bw', folder = 'analysis', x = self._x_co, unit = 'Hz') - self._fano_fr_fit = self._hf.add_value_vector('fano_fr', folder = 'analysis', x = self._x_co, unit = 'Hz') - self._fano_a_fit = self._hf.add_value_vector('fano_a' , folder = 'analysis', x = self._x_co, unit = '') - - self._fano_chi2_fit = self._hf.add_value_vector('fano_chi2' , folder = 'analysis', x = self._x_co, unit = '') - self._fano_Ql_fit = self._hf.add_value_vector('fano_Ql' , folder = 'analysis', x = self._x_co, unit = '') - self._fano_Q0_fit = self._hf.add_value_vector('fano_Q0' , folder = 'analysis', x = self._x_co, unit = '') - - fano_view = self._hf.add_view('fano_fit', x = self._y_co, y = self._ds_amp) - fano_view.add(x=self._frequency_co, y=self._fano_amp_gen) - - def fit_fano(self,fit_all = False, f_min=None, f_max=None,pre_filter_data=None): - ''' - fano fit for amp data in the f_min-f_max frequency range - squared amps are fitted at fano using scipy.leastsq - fit parameter, chi2, q0, and generated amp are stored in the hdf-file - - input: - fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) - f_min (float): lower boundary for data to be fitted (optional, default: None, results in min(frequency-array)) - f_max (float): upper boundary for data to be fitted (optional, default: None, results in max(frequency-array)) - ''' - - self._fit_all = fit_all - if not self._datasets_loaded: - self._get_datasets() - self._update_data() - self._prepare_f_range(f_min,f_max) - if self._first_fano: - self._prepare_fano() - self._first_fano = False - - ''' - fit_amplitude is always 2dim np array. - for 1dim data, shape: (1, # fit frequency points) - for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) - ''' - if not self._fit_all: - self._get_last_amp_trace() - - for amplitudes in self._fit_amplitude: - amplitude_sq = (np.absolute(amplitudes))**2 - try: - fit = self._do_fit_fano(amplitude_sq) - amplitudes_gen = self._fano_reflection_from_fit(fit) - - '''calculate the chi2 of fit and data''' - chi2 = self._fano_fit_chi2(fit, amplitude_sq) - - except: - self._fano_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) - self._fano_q_fit.append(np.nan) - self._fano_bw_fit.append(np.nan) - self._fano_fr_fit.append(np.nan) - self._fano_a_fit.append(np.nan) - self._fano_chi2_fit.append(np.nan) - self._fano_Ql_fit.append(np.nan) - self._fano_Q0_fit.append(np.nan) - - else: - ''' save the fitted data to the hdf_file''' - self._fano_amp_gen.append(np.sqrt(np.absolute(amplitudes_gen))) - self._fano_q_fit.append(float(fit[0])) - self._fano_bw_fit.append(float(fit[1])) - self._fano_fr_fit.append(float(fit[2])) - self._fano_a_fit.append(float(fit[3])) - self._fano_chi2_fit.append(float(chi2)) - self._fano_Ql_fit.append(float(fit[2])/float(fit[1])) - q0=self._fano_fit_q0(np.sqrt(np.absolute(amplitudes_gen)),float(fit[2])) - self._fano_Q0_fit.append(q0) - - def _fano_reflection(self,f,q,bw,fr,a=1,b=1): - ''' - evaluates the fano function in reflection at the - frequency f - with - resonator frequency fr - attenuation a (linear) - fano-factor q - bandwidth bw - ''' - return a*(1 - self._fano_transmission(f,q,bw,fr)) - - def _fano_transmission(self,f,q,bw,fr,a=1,b=1): - ''' - evaluates the normalized transmission fano function at the - frequency f - with - resonator frequency fr - attenuation a (linear) - fano-factor q - bandwidth bw - ''' - F = 2*(f-fr)/bw - return ( 1/(1+q**2) * (F+q)**2 / (F**2+1)) - - def _do_fit_fano(self, amplitudes_sq): - # initial guess - bw = 1e6 - q = 1 #np.sqrt(1-amplitudes_sq).min() # 1-Amp_sq = 1-1+q^2 => A_min = q - fr = self._fit_frequency[np.argmin(amplitudes_sq)] - a = amplitudes_sq.max() - - p0 = [q, bw, fr, a] - - def fano_residuals(p,frequency,amplitude_sq): - q, bw, fr, a = p - err = amplitude_sq-self._fano_reflection(frequency,q,bw,fr=fr,a=a) - return err - - p_fit = leastsq(fano_residuals,p0,args=(self._fit_frequency,np.array(amplitudes_sq))) - #print(("q:%g bw:%g fr:%g a:%g")% (p_fit[0][0],p_fit[0][1],p_fit[0][2],p_fit[0][3])) - return p_fit[0] - - def _fano_reflection_from_fit(self,fit): - return self._fano_reflection(self._fit_frequency,fit[0],fit[1],fit[2],fit[3]) - - def _fano_fit_chi2(self,fit,amplitudes_sq): - chi2 = np.sum((self._fano_reflection_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) - return chi2 - - def _fano_fit_q0(self,amp_gen,fr): - ''' - calculates q0 from 3dB bandwidth above minimum in fit function - ''' - amp_3dB=10*np.log10((np.min(amp_gen)))+3 - amp_3dB_lin=10**(amp_3dB/10) - f_3dB=[] - for i in range(len(amp_gen)-1): - if np.sign(amp_gen[i]-amp_3dB_lin) != np.sign(amp_gen[i+1]-amp_3dB_lin):#crossing@amp_3dB - f_3dB.append(self._fit_frequency[i]) - if len(f_3dB)>1: - q0 = fr/(f_3dB[1]-f_3dB[0]) - return float(q0) - else: return np.nan - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser( - description="resonator.py hdf-based simple resonator fit frontend / KIT 2015") - - parser.add_argument('-f','--file', type=str, help='hdf filename to open') - parser.add_argument('-lf','--lorentzian-fit', default=False,action='store_true', help='(optional) lorentzian fit') - parser.add_argument('-ff','--fano-fit', default=False,action='store_true', help='(optional) fano fit') - parser.add_argument('-cf','--circle-fit', default=False,action='store_true', help='(optional) circle fit') - parser.add_argument('-slf','--skewed-lorentzian-fit', default=False,action='store_true',help='(optional) skewed lorentzian fit') - parser.add_argument('-all','--fit-all', default=False,action='store_true',help='(optional) fit all entries in dataset') - parser.add_argument('-fr','--frequency-range', type=str, help='(optional) frequency range for fitting, comma separated') - parser.add_argument('-fg','--filter-gaussian', default=False, action='store_true', help='(optional) (pre-) filter data: gaussian') - parser.add_argument('-fm','--filter-median', default=False, action='store_true', help='(optional) (pre-) filter data: median') - parser.add_argument('-fp','--filter-params',type=str, help='(optional) (pre-) filter data: parameter') - parser.add_argument('-d','--debug-output', default=False, action='store_true', help='(optional) debug: more verbose') - parser.add_argument('-t','--type', type=str, help='resonator type: (r)eflection or (n)otch') - args=parser.parse_args() - #argsfile=None - if args.file: - R = Resonator(args.file) - if args.debug_output: - R._debug = True - - fit_all = args.fit_all - - if args.frequency_range: - freq_range=args.frequency_range.split(',') - f_min=int(float(freq_range[0])) - f_max=int(float(freq_range[1])) - else: - f_min=None - f_max=None - - if args.filter_median: - if args.filter_params: - filter_params = args.filter_params.split(',') - R.set_prefilter(median=True,params=[float(filter_params[0])]) - else: - R.set_prefilter(median=True) - - if args.filter_gaussian: - if args.filter_params: - filter_params = args.filter_params.split(',') - R.set_prefilter(gaussian=True,params=[float(filter_params[0])]) - else: - R.set_prefilter(gaussian=True) - - if args.circle_fit: - if args.type == 'r': - R.fit_circle(reflection = True, fit_all=fit_all, f_min=f_min,f_max=f_max) - elif args.type == 'n': - R.fit_circle(notch = True, fit_all=fit_all, f_min=f_min,f_max=f_max) - else: - R.fit_circle(notch = True, fit_all=fit_all, f_min=f_min,f_max=f_max) - if args.lorentzian_fit: - R.fit_lorentzian(fit_all=fit_all, f_min=f_min,f_max=f_max) - if args.skewed_lorentzian_fit: - R.fit_skewed_lorentzian(fit_all=fit_all, f_min=f_min,f_max=f_max) - if args.fano_fit: - R.fit_fano(fit_all=fit_all, f_min=f_min,f_max=f_max) - R.close() - else: - print("no file supplied. type -h for help") diff --git a/src/qkit/analysis/resonatorV2.py b/src/qkit/analysis/resonator_fitting.py similarity index 99% rename from src/qkit/analysis/resonatorV2.py rename to src/qkit/analysis/resonator_fitting.py index 2a684dcf3..94fafbaf9 100644 --- a/src/qkit/analysis/resonatorV2.py +++ b/src/qkit/analysis/resonator_fitting.py @@ -5,7 +5,6 @@ import logging from qkit.storage.store import Data as qkitData from qkit.storage.hdf_dataset import hdf_dataset -from qkit.storage.hdf_view import dataset_view class ResonatorFitBase(ABC): """ diff --git a/src/qkit/analysis/spectroscopy.py b/src/qkit/analysis/spectroscopy.py deleted file mode 100644 index 35a440e83..000000000 --- a/src/qkit/analysis/spectroscopy.py +++ /dev/null @@ -1,125 +0,0 @@ -# -*- coding: utf-8 -*- -# spectroscopy.py analysis class for qkit spectroscopy measurement data -# Micha Wildermuth, micha.wildermuth@kit.edu 2023 - -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -import numpy as np -from qkit.analysis.qdata import qData -from qkit.analysis.circle_fit.circle_fit_2019 import circuit - - -class spectrum(qData): - """ - This is an analysis class for spectrum-like spectroscopy measurements taken by - `qkit.measure.spectroscopy.spectroscopy.py`. - """ - - def __init__(self): - """ - Initializes an analysis class for spectrum-like spectroscopy measurements taken by - `qkit.measure.spectroscopy.spectroscopy.py`. - - Parameters - ---------- - None - - Returns - ------- - None - - Examples - -------- - >>> import numpy as np - >>> import qkit - QKIT configuration initialized -> available as qkit.cfg[...] - >>> qkit.start() - Starting QKIT framework ... -> qkit.core.startup - Loading module ... S10_logging.py - Loading module ... S12_lockfile.py - Loading module ... S14_setup_directories.py - Loading module ... S20_check_for_updates.py - Loading module ... S25_info_service.py - Loading module ... S30_qkit_start.py - Loading module ... S65_load_RI_service.py - Loading module ... S70_load_visa.py - Loading module ... S80_load_file_service.py - Loading module ... S85_init_measurement.py - Loading module ... S98_started.py - Loading module ... S99_init_user.py - Initialized the file info database (qkit.fid) in 0.000 seconds. - - >>> from qkit.analysis.qdata import spectrum - >>> s = spectrum() - """ - super().__init__() - self.circuit = circuit - - def load(self, uuid): - """ - Loads qkit spectroscopy data with given uuid . - - Parameters - ---------- - uuid: str - Qkit identification name, that is looked for and loaded. - - Returns - ------- - None - - Examples - -------- - >>> s.load(uuid='XXXXXX') - """ - super().load(uuid=uuid) - if self.m_type != 'spectroscopy': - raise AttributeError('No spectroscopy data loaded. Use data acquired with spectroscopy measurement class or general qData class.') - self.scan_dim = self.df.data.amplitude.attrs['ds_type'] # scan dimension (1D, 2D, ...) - self._get_xy_parameter(self.df.data.amplitude) - self.circlefit = None - - def open_qviewkit(self, uuid=None, ds=None): - """ - Opens qkit measurement data with given uuid in qviewkit. - - Parameters - ---------- - uuid: str - Qkit identification name, that is looked for and opened in qviewkit. - ds: str | list(str) - Datasets that are opened instantaneously. Default for spectroscopy data is 'amplitude' and 'phase'. - - Returns - ------- - None - """ - if ds is None: - ds = [_ds if _ds in self.df.data.__dict__.keys() else None for _ds in ['amplitude', 'phase']] - super().open_qviewkit(uuid=uuid, ds=ds) - - def setup_circlefit(self, type, f_data=None, z_data_raw=None): - if f_data is None: - f_data = self.frequency - if z_data_raw is None: - if hasattr(self, 'real') and hasattr(self, 'imag'): - z_data_raw = self.real + 1j * self.imag - elif hasattr(self, 'amplitude') and hasattr(self, 'phase'): - z_data_raw = self.amplitude + np.exp(1j * self.phase) - else: - raise NameError('no S21 data available. Please load either real and imaginary data or amplitude and phase data.') - self.circlefit = {'reflection': self.circuit.reflection_port, - 'notch': self.circuit.notch_port}[type](f_data=f_data, - z_data_raw=z_data_raw) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py new file mode 100644 index 000000000..946734b25 --- /dev/null +++ b/src/qkit/measure/logging_base.py @@ -0,0 +1,81 @@ +from qkit.storage.store import Data +from qkit.storage.hdf_dataset import hdf_dataset +import typing +import numpy as np + +class logFunc(object): + """ + Unified logging class to offer log-functionality at different points of 1D - 3D spectroscopy or transport measurements. + + The to be logged function can either be single-valued or yield a trace of (n) datapoints (independent from set x/y-parameters). + + For 3D measurements the function can be called + a) once at the beginning [shape (1) or (n)] + b) at each x-iteration [shape (x) or (x, n)] -> old spectroscopy.set_log_function & spectroscopy.set_log_function_2d + c) only for the first x-value at each y-iteration [shape (y) or (y, n)] + d) at each x- & y-iteration [shape (x, y) or (x, y, n)] -> old transport.set_log_function for the single-valued case + with 2D measurements being naturally limited to cases a), b) and 1D measurements to case a). + + This behavior can be controlled via handing over x_vec, y_vec or trace_info arguments. While the first two should be the respective h5 dataset, which + is already defined in the main measurement, 'trace_vec provides information about the additional coordinate a trace log function may sweep over as + (*values_array*, name, unit). The same base coordinate may be chosen for different trace log functions. + """ + def __init__(self, file: Data, func: typing.Callable, name: str, unit: str = "", x_ds: hdf_dataset = None, y_ds: hdf_dataset = None, trace_info: tuple[np.ndarray, str, str] = None): + self.file = file + self.func = func + self.name = name + self.unit = unit + self.signature = "" + self.x_ds = x_ds + if not (x_ds is None): + self.signature += "x" + self.y_ds = y_ds + if not (y_ds is None): + self.signature += "y" + self.trace_info = trace_info + if not (trace_info is None): + self.signature += "n" + self.trace_ds: hdf_dataset = None + self.log_ds: hdf_dataset = None + self.buffer1d: np.ndarray = None + def prepare_file(self): + # prepare trace base coordinate if necessary + if "n" in self.signature: + try: + self.trace_ds = self.file.get_dataset("/entry/data0/{}".format(self.trace_info[1])) + # base coordinate already exists in file + except: + self.trace_ds = self.file.add_coordinate(self.trace_info[1], self.trace_info[2]) + self.trace_ds.add(self.trace_info[0]) + # the logic is admittably more complicated here, writing down all 8 possible cases of x,y,n present or not helps + if len(self.signature) == 0: + self.log_ds = self.file.add_coordinate(self.name, self.unit) + elif len(self.signature) == 1: + self.log_ds = self.file.add_value_vector(self.name, {"x":self.x_ds,"y":self.y_ds,"n":self.trace_ds}[self.signature], self.unit) + elif len(self.signature) == 2: + self.log_ds = self.file.add_value_matrix(self.name, self.x_ds if "x" in self.signature else self.y_ds, self.trace_ds if "n" in self.signature else self.y_ds, self.unit) + elif len(self.signature) == 3: + self.log_ds = self.file.add_value_box(self.name, self.x_ds, self.y_ds, self.trace_ds, self.unit) + + def logIfDesired(self, ix=0, iy=0): + if (ix == 0 or "x" in self.signature) and (iy == 0 or "y" in self.signature): # log function call desired + if len(self.signature) == 0: # "" + # we will only reach here once, no further case-logic required + self.log_ds.add(np.array([self.func()])) + elif "n" in self.signature: # "n", "xn", "yn", "xyn" + self.log_ds.append(self.func()) + if len(self.signature) == 3: + if iy + 1 == self.y_ds.shape[0]: + # who doesnt love 4x nested ifs edge cases? + self.log_ds.next_matrix() + elif "y" in self.signature: # "y", "xy" + if iy == 0: + self.buffer1d = np.full(self.y_ds.shape[0], np.nan) + self.buffer1d[iy] = self.func() + self.log_ds.append(self.buffer1d, reset=(iy != 0)) + else: # "x" + if ix == 0: + self.buffer1d = np.full(self.x_ds.shape[0], np.nan) + self.buffer1d[ix] = self.func() + self.log_ds.append(self.buffer1d, reset=(ix != 0)) + \ No newline at end of file diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 17a121e3b..5c7bb91db 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -23,7 +23,7 @@ import threading import qkit -import qkit.analysis.resonatorV2 as resonatorFit +import qkit.analysis.resonator_fitting as resonatorFit if qkit.module_available("matplotlib"): import matplotlib.pylab as plt if qkit.module_available("scipy"): @@ -34,6 +34,7 @@ from qkit.gui.plot import plot as qviewkit from qkit.gui.notebook.Progress_Bar import Progress_Bar from qkit.measure.measurement_class import Measurement +from qkit.measure.logging_base import logFunc import qkit.measure.write_additional_files as waf @@ -80,8 +81,8 @@ def __init__(self, vna, exp_name='', sample=None): self._fit_resonator = False self._plot_comment = "" - self.set_log_function() - self.set_log_function_2D() + self.log_init_params = [] # buffer is necessary to allow adjusting x/y parameter sweeps after setting log functions + self.log_funcs: list[logFunc] = [] self.open_qviewkit = True self.qviewkit_singleInstance = False @@ -94,97 +95,51 @@ def __init__(self, vna, exp_name='', sample=None): self._qvk_process = False self._scan_dim = None self._scan_time = False + + def add_logger(self, func, name, unit, over_x=True, over_y=False, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): + """ + Migration from set_log_function & set_log_function_2D: + + -------- + def get_T(): + ... # returns a float + def a(): + ... # returns a float + trace_base = np.linspace(1, 2, 101) + def b_trace(): + ... # returns a trace + + # obsolete + # spec.set_log_function([get_T, a], ["temp", "a_name"], ["K", "a_unit"]) + # spec.set_log_function_2D([b_trace], ["b_name"], ["b_unit"], [trace_base], ["base_name"], ["base_unit"]) - def set_log_function(self, func=None, name=None, unit=None, log_dtype=None): - ''' - A function (object) can be passed to the measurement loop which is excecuted before every x iteration - but after executing the x_object setter in 2D measurements and before every line (but after setting - the x value) in 3D measurements. - The return value of the function of type float or similar is stored in a value vector in the h5 file. + # do instead + spec.add_logger(get_T, "temp", "K", over_y=True) # over_y optional for more logged temperatures + spec.add_logger(a, "a_name", "a_unit") # default migration + spec.add_logger(b_trace, "b_name", "b_unit", is_trace=True, trace_base_vals=trace_base, trace_base_name="base_name", trace_base_unit="base_unit") # traces + ------ - Call without any arguments to delete all log functions. The timestamp is automatically saved. + Alternatively more options like logging over y-iterations aswell or skipping the x-iteration are possible now, see qkit/measure/logging_base for details. + """ + self.log_init_params += [(func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file - func: function object in list form - name: name of logging parameter appearing in h5 file, default: 'log_param' - unit: unit of logging parameter, default: '' - log_dtype: h5 data type, default: 'f' (float32) - ''' + def clear_loggers(self): + """ + Clear all set log functions + """ + self.log_init_params = [] - if name == None: - try: - name = ['log_param'] * len(func) - except Exception: - name = None - if unit == None: - try: - unit = [''] * len(func) - except Exception: - unit = None - if log_dtype == None: - try: - log_dtype = ['f'] * len(func) - except Exception: - log_dtype = None - - self.log_function = [] - self.log_name = [] - self.log_unit = [] - self.log_dtype = [] - - if func != None: - for i, f in enumerate(func): - self.log_function.append(f) - self.log_name.append(name[i]) - self.log_unit.append(unit[i]) - self.log_dtype.append(log_dtype[i]) + def set_log_function(self, func=None, name=None, unit=None, log_dtype=None): + ''' + Obsolete since covered by 'add_logger'. + ''' + logging.error("'set_log_function' is obsolete, 'add_logger' is used instead. See respective qkit/src/qkit/measure/spectroscopy function docs for how to migrate.") def set_log_function_2D(self, func=None, name=None, unit=None, y=None, y_name=None, y_unit=None, log_dtype=None): ''' - A function (object) can be passed to the measurement loop which is excecuted before every x iteration - but after executing the x_object setter in 2D measurements and before every line (but after setting - the x value) in 3D measurements. - The return values of the function of type 1D-list or similar is stored in a value matrix in the h5 file. - - Call without any arguments to delete all log functions. The timestamp is automatically saved. - - func: function object in list form, returning a list each - name: name of logging parameter appearing in h5 file, default: 'log_param' - unit: unit of logging parameter, default: '' - log_dtype: h5 data type, default: 'f' (float32) + Obsolete since covered by 'add_logger'. ''' - if name == None: - try: - name = ['log_param'] * len(func) - except Exception: - name = None - if unit == None: - try: - unit = [''] * len(func) - except Exception: - unit = None - if log_dtype == None: - try: - log_dtype = ['f'] * len(func) - except Exception: - log_dtype = None - - self.log_function_2D = [] - self.log_name_2D = [] - self.log_unit_2D = [] - self.log_y_2D = [] - self.log_y_name_2D = [] - self.log_y_unit_2D = [] - self.log_dtype_2D = [] - - if func != None: - for i, _ in enumerate(func): - self.log_function_2D.append(func[i]) - self.log_name_2D.append(name[i]) - self.log_unit_2D.append(unit[i]) - self.log_dtype_2D.append(log_dtype[i]) - self.log_y_2D.append(y[i]) - self.log_y_name_2D.append(y_name[i]) - self.log_y_unit_2D.append(y_unit[i]) + logging.error("'set_log_function_2D' is obsolete, 'add_logger' is used instead. See respective qkit/src/qkit/measure/spectroscopy function docs for how to migrate.") def set_x_parameters(self, x_vec, x_coordname, x_set_obj, x_unit=""): """ @@ -275,7 +230,8 @@ def _prepare_measurement_file(self): self._data_time.add(np.arange(0, self._nop, 1) * self.vna.get_sweeptime() / (self._nop - 1)) sweep_vector = self._data_time - if self._fit_resonator: + # for automatic circlefit + if self._fit_resonator: self._fit_select = (self._freqpoints >= self._f_min) & (self._freqpoints <= self._f_max) self._fit_freq = self._data_file.add_coordinate('_fit_frequency', unit='Hz', folder="analysis") @@ -315,13 +271,6 @@ def _prepare_measurement_file(self): for key in self._fit_function.extract_data.keys(): self._fit_extracts[key] = self._data_file.add_value_vector("fit_" + key, x=self._data_x, unit="", folder="analysis") - if self.log_function != None: # use logging - self._log_value = [] - for i in range(len(self.log_function)): - self._log_value.append( - self._data_file.add_value_vector(self.log_name[i], x=self._data_x, unit=self.log_unit[i], dtype=self.log_dtype[i]) - ) - if self._nop < 10: """creates view: plot middle point vs x-parameter, for qubit measurements""" self._views = [ @@ -356,28 +305,6 @@ def _prepare_measurement_file(self): for key in self._fit_function.extract_data.keys(): self._fit_extracts[key] = self._data_file.add_value_matrix("fit_" + key, x=self._data_x, y=self._data_y, unit="", folder="analysis") - if self.log_function != None: # use logging - self._log_value = [] - for i in range(len(self.log_function)): - self._log_value.append( - self._data_file.add_value_vector(self.log_name[i], x=self._data_x, unit=self.log_unit[i], dtype=self.log_dtype[i]) - ) - - if self.log_function_2D != None: # use 2D logging - self._log_y_value_2D = [] - for i in range(len(self.log_y_name_2D)): - if self.log_y_name_2D[i] not in self.log_y_name_2D[:i]: # add y coordinate for 2D logging - self._log_y_value_2D.append(self._data_file.add_coordinate(self.log_y_name_2D[i], unit=self.log_y_unit_2D[i], folder='data')) # possibly use "data1" - self._log_y_value_2D[i].add(self.log_y_2D[i]) - else: # use y coordinate for 2D logging if it is already added - self._log_y_value_2D.append(self._log_y_value_2D[np.squeeze(np.argwhere(self.log_y_name_2D[i] == np.array(self.log_y_name_2D[:i])))]) - - self._log_value_2D = [] - for i in range(len(self.log_function_2D)): - self._log_value_2D.append( - self._data_file.add_value_matrix(self.log_name_2D[i], x=self._data_x, y=self._log_y_value_2D[i], unit=self.log_unit_2D[i], dtype=self.log_dtype_2D[i], folder='data') - ) # possibly use "data1" - if self._fit_resonator: self._iq_view.add(self._fit_real, self._fit_imag) self._amp_view = self._data_file.add_view("AmplitudeFit", self._data_freq, self._data_amp) @@ -385,6 +312,14 @@ def _prepare_measurement_file(self): self._pha_view = self._data_file.add_view("PhaseFit", self._data_freq, self._data_pha) self._pha_view.add(self._fit_freq, self._fit_pha) + for init_tuple in self.log_init_params: + func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple + self.log_funcs += [logFunc(self._data_file, func, name, unit, + self._data_x if (self._scan_dim >= 2) and over_x else None, + self._data_y if (self._scan_dim == 3) and over_y else None, + (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] + self.log_funcs[-1].prepare_file() + if self.comment: self._data_file.add_comment(self.comment) @@ -418,6 +353,10 @@ def measure_1D(self, rescan=True, web_visible=True): self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase', 'views/IQ']) qkit.flow.start() + + for lf in self.log_funcs: + lf.logIfDesired() + if rescan: if self.averaging_start_ready: self.vna.start_measurement() @@ -614,14 +553,6 @@ def _measure(self): self.x_set_obj(x) sleep(self.tdx) - if self.log_function != None: - for i, f in enumerate(self.log_function): - self._log_value[i].append(float(f())) - - if self.log_function_2D != None: - for i, f in enumerate(self.log_function_2D): - self._log_value_2D[i].append(f()) - if self._scan_dim == 3: fit_extracts_helper = {} # for book-keeping current y-line for iy, y in enumerate(self.y_vec): @@ -633,6 +564,10 @@ def _measure(self): else: self.y_set_obj(y) sleep(self.tdy) + + for lf in self.log_funcs: + lf.logIfDesired(ix, iy) + if self.averaging_start_ready: self.vna.start_measurement() # Check if the VNA is STILL in ready state, then add some delay. @@ -701,6 +636,9 @@ def _measure(self): self._fit_imag.next_matrix() if self._scan_dim == 2: + for lf in self.log_funcs: + lf.logIfDesired(ix) + if self.averaging_start_ready: self.vna.start_measurement() if self.vna.ready(): @@ -771,7 +709,7 @@ def set_resonator_fit(self, fit_resonator=False, fit_function = None, f_min=None if not fit_resonator: self._fit_resonator = False return - if fit_function == "circle_fit_reflection": # this distinction is handled manually here + if fit_function == "circle_fit_reflection": # this distinction is handled here manually fit_opt_kwargs.update({"n_ports": 1}) if fit_function == "circle_fit_notch": fit_opt_kwargs.update({"n_ports": 2}) diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index 38990c6ed..eff7a799f 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -29,6 +29,7 @@ from qkit.gui.plot import plot as qviewkit from qkit.gui.notebook.Progress_Bar import Progress_Bar from qkit.measure.measurement_class import Measurement +from qkit.measure.logging_base import logFunc import qkit.measure.write_additional_files as waf @@ -118,8 +119,9 @@ def __init__(self, IV_Device): # x and y data self._hdf_x = None self._hdf_y = None + self.log_init_params = [] # buffer is necessary to allow adjusting x/y parameter sweeps after setting log functions + self.log_funcs: list[logFunc] = [] # xy, 2D & 3D scan variables - self.set_log_function() self._x_name = '' self._y_name = '' self._scan_dim = None @@ -666,128 +668,33 @@ def set_xy_parameters(self, x_name, x_func, x_vec, x_unit, y_name, y_func, y_uni self._x_dt = x_dt return - def set_log_function(self, func=None, name=None, unit=None, dtype='f'): + def add_logger(self, func, name, unit, over_x=True, over_y=True, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): """ - Saves desired values obtained by a function in the .h5-file as a value vector with name , unit and in data format . - The function (object) can be passed to the measurement loop which is executed before every x iteration - but after executing the x_object setter in 2D measurements and before every line (but after setting - the x value) in 3D measurements. - The return value of the function of type float or similar is stored in a value vector in the h5 file. - - Parameters - ---------- - func: array_likes of callable objects - A callable object that returns the value to be saved. - name: array_likes of strings - Names of logging parameter appearing in h5 file. Default is 'log_param'. - unit: array_likes of strings - Units of logging parameter. Default is ''. - dtype: array_likes of dtypes - h5 data type to be used in the data file. Default is 'f' (float64). + Migration from set_log_function: + + -------- + def get_T(): + ... # returns a float + def a(): + ... # returns a float - Returns - ------- - None - """ - # TODO: dtype = float instead of 'f' - # log-function - if callable(func): - func = [func] - elif func is None: - func = [None] - elif np.iterable(func): - for fun in func: - if not callable(fun): - raise ValueError('{:s}: Cannot set {!s} as y-function: callable object needed'.format(__name__, fun)) - else: - raise ValueError('{:s}: Cannot set {!s} as log-function: callable object of iterable object of callable objects needed'.format(__name__, func)) - self.log_function = func - # log-name - if name is None: - try: - name = ['log_param']*len(func) - except Exception: - name = [None] - elif type(name) is str: - name = [name]*len(func) - elif np.iterable(name): - for _name in name: - if type(_name) is not str: - raise ValueError('{:s}: Cannot set {!s} as log-name: string needed'.format(__name__, _name)) - else: - raise ValueError('{:s}: Cannot set {!s} as log-name: string of iterable object of strings needed'.format(__name__, name)) - self.log_name = name - # log-unit - if unit is None: - try: - unit = ['log_unit']*len(func) - except Exception: - unit = [None] - elif type(unit) is str: - unit = [unit]*len(func) - elif np.iterable(unit): - for _unit in unit: - if type(_unit) is not str: - raise ValueError('{:s}: Cannot set {!s} as log-unit: string needed'.format(__name__, _unit)) - else: - raise ValueError('{:s}: Cannot set {!s} as log-unit: string of iterable object of strings needed'.format(__name__, unit)) - self.log_unit = unit - # log-dtype - if dtype is None: - try: - dtype = [float]*len(func) - except Exception: - dtype = [None] - elif type(dtype) is type: - dtype = [dtype]*len(func) - elif np.iterable(dtype): - for _dtype in dtype: - if type(_dtype) is not str: - raise ValueError('{:s}: Cannot set {!s} as log-dtype: string needed'.format(__name__, _dtype)) - else: - raise ValueError('{:s}: Cannot set {!s} as log-dtype: string of iterable object of strings needed'.format(__name__, dtype)) - self.log_dtype = dtype - return - - def get_log_function(self): + # obsolete + # tr.set_log_function([get_T, a], ["temp", "a_name"], ["K", "a_unit"]) + + # do instead + tr.add_logger(get_T, "temp", "K") # default migration + tr.add_logger(a, "a_name", "a_unit", over_y=False) # skip y-iteration if desired + ------ + + Alternatively more options like logging traces or skipping the x-iteration are possible now, see qkit/measure/logging_base for details. """ - Gets the current log_function settings. - - Parameters - ---------- - None - - Returns - ------- - func: array_likes of callable objects - A callable object that returns the value to be saved. - name: array_likes of strings - Names of logging parameter appearing in h5 file. Default is 'log_param'. - unit: array_likes of strings - Units of logging parameter. Default is ''. - dtype: array_likes of dtypes - h5 data type to be used in the data file. Default is float (float64). - """ - return [f.__name__ for f in self.log_function], self.log_name, self.log_unit, self.log_dtype - - def reset_log_function(self): + self.log_init_params += [(func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file + + def clear_loggers(self): """ - Resets all log_function settings. - - Parameters - ---------- - None - - Returns - ------- - None + Clear all set log functions """ - self.log_function = [] - self.log_name = [] - self.log_unit = [] - self.log_dtype = [] - self._hdf_log = [] - return + self.log_init_params = [] def set_fit_IV(self, func, name, unit='', **kwargs): """ @@ -1188,8 +1095,6 @@ def _pass(arg): self._pb.iterate() time.sleep(self._x_dt) elif self._scan_dim in [1, 2, 3]: # IV curve - _rst_log_hdf_appnd = False # variable to save points of log-function in 2D-matrix - self._rst_fit_hdf_appnd = False for self.ix, (x, x_func) in enumerate([(None, _pass)] if self._scan_dim < 2 else [(x, self._x_set_obj) for x in self._x_vec]): # loop: x_obj with parameters from x_vec if 2D or 3D else pass(None) x_func(x) time.sleep(self._x_dt) @@ -1197,19 +1102,8 @@ def _pass(arg): y_func(y) time.sleep(self._tdy) # log function - if self.log_function != [None]: - for j, f in enumerate(self.log_function): - if self._scan_dim == 1: - self._data_log[j] = np.array([float(f())]) # np.asarray(f(), dtype=float) - self._hdf_log[j].append(self._data_log[j]) - elif self._scan_dim == 2: - self._data_log[j][self.ix] = float(f()) - self._hdf_log[j].append(self._data_log[j], reset=True) - elif self._scan_dim == 3: - self._data_log[j][self.ix, self.iy] = float(f()) - self._hdf_log[j].append(self._data_log[j][self.ix], reset=_rst_log_hdf_appnd) - if self._scan_dim == 3: # reset needs to be updated for all log-functions simultaneously and thus outside of the loop - _rst_log_hdf_appnd = not bool(self.iy+1 == len(self._y_vec)) + for lf in self.log_funcs: + lf.logIfDesired(self.ix, self.iy) # iterate sweeps and take data self._get_sweepdata() # filling of value-box by storing data in the next 2d structure after every y-loop @@ -1305,6 +1199,7 @@ def _prepare_measurement_file(self): self._hdf_dIdV = [] self._hdf_fit = [] self._data_fit = [] + if self._scan_dim == 0: ''' xy ''' # add data variables @@ -1324,6 +1219,7 @@ def _prepare_measurement_file(self): self._data_file.add_view('{:s}_vs_{:s}'.format(*np.array(self._y_name)[np.array(view[::-1])]), x=self._hdf_y[view[0]], y=self._hdf_y[view[1]]) + elif self._scan_dim == 1: ''' 1D scan ''' # add data variables @@ -1364,8 +1260,6 @@ def _prepare_measurement_file(self): folder='analysis', comment=self._get_fit_comment(i))) self._data_fit.append(np.nan) - # log-function - self._add_log_value_vector() # add views self._add_views() elif self._scan_dim == 2: @@ -1414,8 +1308,6 @@ def _prepare_measurement_file(self): folder='analysis', comment=self._get_fit_comment(i))) self._data_fit.append(np.ones(len(self._x_vec)) * np.nan) - # log-function - self._add_log_value_vector() # add views self._add_views() elif self._scan_dim == 3: @@ -1473,10 +1365,18 @@ def _prepare_measurement_file(self): folder='analysis', comment=self._get_fit_comment(i))) self._data_fit.append(np.ones((len(self._x_vec), len(self._y_vec))) * np.nan) - # log-function - self._add_log_value_vector() # add views self._add_views() + + # logging + for init_tuple in self.log_init_params: + func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple + self.log_funcs += [logFunc(self._data_file, func, name, unit, + self._hdf_x if (self._scan_dim >= 2) and over_x else None, + self._hdf_y if (self._scan_dim == 3) and over_y else None, + (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] + self.log_funcs[-1].prepare_file() + ''' add comment ''' if self._comment: self._data_file.add_comment(self._comment) @@ -1577,41 +1477,6 @@ def _get_fit_comment(self, i): 'dVdI={:s}'.format(self._hdf_dVdI[i].name)], ['{:s}={!s}'.format(key, val) for key, val in self._fit_kwargs.items()] if self._fit_kwargs else []]))+\ ')' - - def _add_log_value_vector(self): - """ - Adds all value vectors for log-function parameter. - - Parameters - ---------- - None - - Returns - ------- - None - """ - if self.log_function != [None]: - self._hdf_log = [] - self._data_log = [] - for i, _ in enumerate(self.log_function): - if self._scan_dim == 1: - self._hdf_log.append(self._data_file.add_coordinate(self.log_name[i], - unit=self.log_unit[i])) - self._data_log.append(np.nan) - elif self._scan_dim == 2: - self._hdf_log.append(self._data_file.add_value_vector(self.log_name[i], - x=self._hdf_x, - unit=self.log_unit[i], - dtype=self.log_dtype[i])) - self._data_log.append(np.ones(len(self._x_vec))*np.nan) - elif self._scan_dim == 3: - self._hdf_log.append(self._data_file.add_value_matrix(self.log_name[i], - x=self._hdf_x, - y=self._hdf_y, - unit=self.log_unit[i], - dtype=self.log_dtype[i])) - self._data_log.append(np.ones((len(self._x_vec), len(self._y_vec)))*np.nan) - return def _add_views(self): """ diff --git a/src/qkit/storage/store.py b/src/qkit/storage/store.py index 7464beeba..fedb6d3bd 100644 --- a/src/qkit/storage/store.py +++ b/src/qkit/storage/store.py @@ -297,8 +297,8 @@ class and in the end the entries can be sorted by all params. self.hf.agrp.attrs[param] = value - def get_dataset(self,ds_url): - return hdf_dataset(self.hf,ds_url = ds_url) + def get_dataset(self, ds_url): + return hdf_dataset(self.hf, ds_url=ds_url) def save_finished(self): pass From 2af58bbd915e998152e00124742d1ed3bc5c20e7 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 13 May 2025 17:50:27 +0200 Subject: [PATCH 08/43] merge conflict with extra sleep --- src/qkit/measure/transport/transport.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index eff7a799f..8f0fd7e24 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1104,6 +1104,7 @@ def _pass(arg): # log function for lf in self.log_funcs: lf.logIfDesired(self.ix, self.iy) + qkit.flow.sleep(0.2) # iterate sweeps and take data self._get_sweepdata() # filling of value-box by storing data in the next 2d structure after every y-loop From 5b38efbf93acd5f5fc96e8eeaef09f4cce0601c3 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 13 May 2025 18:56:25 +0200 Subject: [PATCH 09/43] fug bixes --- src/qkit/measure/logging_base.py | 6 +++--- src/qkit/measure/transport/transport.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 946734b25..11964e8aa 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -65,17 +65,17 @@ def logIfDesired(self, ix=0, iy=0): elif "n" in self.signature: # "n", "xn", "yn", "xyn" self.log_ds.append(self.func()) if len(self.signature) == 3: - if iy + 1 == self.y_ds.shape[0]: + if iy + 1 == self.y_ds.ds.shape[0]: # who doesnt love 4x nested ifs edge cases? self.log_ds.next_matrix() elif "y" in self.signature: # "y", "xy" if iy == 0: - self.buffer1d = np.full(self.y_ds.shape[0], np.nan) + self.buffer1d = np.full(self.y_ds.ds.shape[0], np.nan) self.buffer1d[iy] = self.func() self.log_ds.append(self.buffer1d, reset=(iy != 0)) else: # "x" if ix == 0: - self.buffer1d = np.full(self.x_ds.shape[0], np.nan) + self.buffer1d = np.full(self.x_ds.ds.shape[0], np.nan) self.buffer1d[ix] = self.func() self.log_ds.append(self.buffer1d, reset=(ix != 0)) \ No newline at end of file diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index 8f0fd7e24..efd699ddb 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1103,8 +1103,8 @@ def _pass(arg): time.sleep(self._tdy) # log function for lf in self.log_funcs: + time.sleep(0.2) lf.logIfDesired(self.ix, self.iy) - qkit.flow.sleep(0.2) # iterate sweeps and take data self._get_sweepdata() # filling of value-box by storing data in the next 2d structure after every y-loop From 6317247ccf4dbc01313fa24e45b6faabc4ac6cab Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Wed, 14 May 2025 13:20:13 +0200 Subject: [PATCH 10/43] race conditions? --- src/qkit/measure/logging_base.py | 14 +++++++++----- src/qkit/measure/spectroscopy/spectroscopy.py | 4 ++-- src/qkit/measure/transport/transport.py | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 11964e8aa..7205945be 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -20,21 +20,23 @@ class logFunc(object): is already defined in the main measurement, 'trace_vec provides information about the additional coordinate a trace log function may sweep over as (*values_array*, name, unit). The same base coordinate may be chosen for different trace log functions. """ - def __init__(self, file: Data, func: typing.Callable, name: str, unit: str = "", x_ds: hdf_dataset = None, y_ds: hdf_dataset = None, trace_info: tuple[np.ndarray, str, str] = None): + def __init__(self, file: Data, func: typing.Callable, name: str, unit: str = "", x_ds_url: str = None, y_ds_url: str = None, trace_info: tuple[np.ndarray, str, str] = None): self.file = file self.func = func self.name = name self.unit = unit self.signature = "" - self.x_ds = x_ds - if not (x_ds is None): + self.x_ds_url = x_ds_url + if not (x_ds_url is None): self.signature += "x" - self.y_ds = y_ds - if not (y_ds is None): + self.y_ds_url = y_ds_url + if not (y_ds_url is None): self.signature += "y" self.trace_info = trace_info if not (trace_info is None): self.signature += "n" + self.x_ds: hdf_dataset = None + self.y_ds: hdf_dataset = None self.trace_ds: hdf_dataset = None self.log_ds: hdf_dataset = None self.buffer1d: np.ndarray = None @@ -48,6 +50,8 @@ def prepare_file(self): self.trace_ds = self.file.add_coordinate(self.trace_info[1], self.trace_info[2]) self.trace_ds.add(self.trace_info[0]) # the logic is admittably more complicated here, writing down all 8 possible cases of x,y,n present or not helps + self.x_ds = self.file.get_dataset(self.x_ds_url) + self.y_ds = self.file.get_dataset(self.y_ds_url) if len(self.signature) == 0: self.log_ds = self.file.add_coordinate(self.name, self.unit) elif len(self.signature) == 1: diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 5c7bb91db..e471daff5 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -315,8 +315,8 @@ def _prepare_measurement_file(self): for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple self.log_funcs += [logFunc(self._data_file, func, name, unit, - self._data_x if (self._scan_dim >= 2) and over_x else None, - self._data_y if (self._scan_dim == 3) and over_y else None, + self._data_x.ds_url if (self._scan_dim >= 2) and over_x else None, + self._data_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] self.log_funcs[-1].prepare_file() diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index efd699ddb..328439092 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1373,8 +1373,8 @@ def _prepare_measurement_file(self): for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple self.log_funcs += [logFunc(self._data_file, func, name, unit, - self._hdf_x if (self._scan_dim >= 2) and over_x else None, - self._hdf_y if (self._scan_dim == 3) and over_y else None, + self._hdf_x.ds_url if (self._scan_dim >= 2) and over_x else None, + self._hdf_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] self.log_funcs[-1].prepare_file() From b35d583d1f83ae0cc9db33b4729c29128df8e741 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Thu, 15 May 2025 06:26:36 +0200 Subject: [PATCH 11/43] suppressed circlefit warning amount --- src/qkit/analysis/resonator_fitting.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/qkit/analysis/resonator_fitting.py b/src/qkit/analysis/resonator_fitting.py index 94fafbaf9..bcaabca07 100644 --- a/src/qkit/analysis/resonator_fitting.py +++ b/src/qkit/analysis/resonator_fitting.py @@ -285,7 +285,7 @@ def _fit_phase(z_data: np.ndarray, guesses = self.guesses): phase = np.unwrap(np.angle(z_data)) # For centered circle roll-off should be close to 2pi. If not warn user. - if np.max(phase) - np.min(phase) <= 0.8*2*np.pi: + if np.max(phase) - np.min(phase) <= 2*np.pi/4: logging.warning( "Data does not cover a full circle (only {:.1f}".format( np.max(phase) - np.min(phase) @@ -293,6 +293,14 @@ def _fit_phase(z_data: np.ndarray, guesses = self.guesses): +" rad). Increase the frequency span around the resonance?" ) roll_off = np.max(phase) - np.min(phase) + elif np.max(phase) - np.min(phase) <= 2*np.pi*4/5: + logging.debug( + "Data does not cover a full circle (only {:.1f}".format( + np.max(phase) - np.min(phase) + ) + +" rad). Increase the frequency span around the resonance?" + ) + roll_off = np.max(phase) - np.min(phase) else: roll_off = 2*np.pi @@ -381,7 +389,7 @@ def residuals_full(params): delay += delay_corr if 2*np.pi*(freq[-1]-freq[0])*delay_corr > np.std(residuals): - logging.warning("Delay could not be fit properly!") + logging.debug("Delay could not be fit properly!") self.extract_data["delay"] = delay From 5a9527ee74f585544152f97213b6c0acc0cf4b51 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Fri, 16 May 2025 10:43:56 +0200 Subject: [PATCH 12/43] WIP --- src/qkit/measure/logging_base.py | 30 ++++++------- src/qkit/measure/spectroscopy/spectroscopy.py | 44 +++++++++---------- src/qkit/storage/hdf_file.py | 9 ++-- 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 7205945be..d7bfa0324 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -20,23 +20,25 @@ class logFunc(object): is already defined in the main measurement, 'trace_vec provides information about the additional coordinate a trace log function may sweep over as (*values_array*, name, unit). The same base coordinate may be chosen for different trace log functions. """ - def __init__(self, file: Data, func: typing.Callable, name: str, unit: str = "", x_ds_url: str = None, y_ds_url: str = None, trace_info: tuple[np.ndarray, str, str] = None): + def __init__(self, file: Data, func: typing.Callable, name: str, unit: str = "", x_ds: hdf_dataset = None, y_ds: hdf_dataset = None, trace_info: tuple[np.ndarray, str, str] = None): self.file = file self.func = func self.name = name self.unit = unit self.signature = "" - self.x_ds_url = x_ds_url - if not (x_ds_url is None): + self.x_ds = x_ds + self.x_len: int = None + if not (x_ds is None): self.signature += "x" - self.y_ds_url = y_ds_url - if not (y_ds_url is None): + self.x_len = x_ds.ds.shape[0] + self.y_ds = y_ds + self.y_len : int = None + if not (y_ds is None): self.signature += "y" + self.y_len = y_ds.ds.shape[0] self.trace_info = trace_info if not (trace_info is None): self.signature += "n" - self.x_ds: hdf_dataset = None - self.y_ds: hdf_dataset = None self.trace_ds: hdf_dataset = None self.log_ds: hdf_dataset = None self.buffer1d: np.ndarray = None @@ -50,8 +52,6 @@ def prepare_file(self): self.trace_ds = self.file.add_coordinate(self.trace_info[1], self.trace_info[2]) self.trace_ds.add(self.trace_info[0]) # the logic is admittably more complicated here, writing down all 8 possible cases of x,y,n present or not helps - self.x_ds = self.file.get_dataset(self.x_ds_url) - self.y_ds = self.file.get_dataset(self.y_ds_url) if len(self.signature) == 0: self.log_ds = self.file.add_coordinate(self.name, self.unit) elif len(self.signature) == 1: @@ -61,25 +61,25 @@ def prepare_file(self): elif len(self.signature) == 3: self.log_ds = self.file.add_value_box(self.name, self.x_ds, self.y_ds, self.trace_ds, self.unit) - def logIfDesired(self, ix=0, iy=0): + def logIfDesired(self, ix=0, iy=0): if (ix == 0 or "x" in self.signature) and (iy == 0 or "y" in self.signature): # log function call desired + print("lög") if len(self.signature) == 0: # "" # we will only reach here once, no further case-logic required self.log_ds.add(np.array([self.func()])) elif "n" in self.signature: # "n", "xn", "yn", "xyn" self.log_ds.append(self.func()) if len(self.signature) == 3: - if iy + 1 == self.y_ds.ds.shape[0]: + if iy + 1 == self.y_len: # who doesnt love 4x nested ifs edge cases? self.log_ds.next_matrix() elif "y" in self.signature: # "y", "xy" if iy == 0: - self.buffer1d = np.full(self.y_ds.ds.shape[0], np.nan) + self.buffer1d = np.full(self.y_len, np.nan) self.buffer1d[iy] = self.func() self.log_ds.append(self.buffer1d, reset=(iy != 0)) else: # "x" if ix == 0: - self.buffer1d = np.full(self.x_ds.ds.shape[0], np.nan) + self.buffer1d = np.full(self.x_len, np.nan) self.buffer1d[ix] = self.func() - self.log_ds.append(self.buffer1d, reset=(ix != 0)) - \ No newline at end of file + self.log_ds.append(self.buffer1d, reset=(ix != 0)) \ No newline at end of file diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index e471daff5..48409c331 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -267,7 +267,7 @@ def _prepare_measurement_file(self): self._fit_real = self._data_file.add_value_matrix("_fit_real", x=self._data_x, y=sweep_vector, unit="", folder="analysis") self._fit_imag = self._data_file.add_value_matrix("_fit_imag", x=self._data_x, y=sweep_vector, unit="", folder="analysis") - self._fit_extracts = {} + self._fit_extracts: dict[str, hdf.hdf_dataset] = {} for key in self._fit_function.extract_data.keys(): self._fit_extracts[key] = self._data_file.add_value_vector("fit_" + key, x=self._data_x, unit="", folder="analysis") @@ -301,7 +301,7 @@ def _prepare_measurement_file(self): self._fit_real = self._data_file.add_value_box("_fit_real", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") self._fit_imag = self._data_file.add_value_box("_fit_imag", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") - self._fit_extracts = {} + self._fit_extracts: dict[str, hdf.hdf_dataset] = {} for key in self._fit_function.extract_data.keys(): self._fit_extracts[key] = self._data_file.add_value_matrix("fit_" + key, x=self._data_x, y=self._data_y, unit="", folder="analysis") @@ -315,8 +315,8 @@ def _prepare_measurement_file(self): for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple self.log_funcs += [logFunc(self._data_file, func, name, unit, - self._data_x.ds_url if (self._scan_dim >= 2) and over_x else None, - self._data_y.ds_url if (self._scan_dim == 3) and over_y else None, + self._data_x if (self._scan_dim >= 2) and over_x else None, + self._data_y if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] self.log_funcs[-1].prepare_file() @@ -407,7 +407,7 @@ def measure_1D(self, rescan=True, web_visible=True): self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) - self._fit_extracts = {} + self._fit_extracts: dict[str, hdf.hdf_dataset] = {} for key, val in self._fit_function.extract_data.items(): # define extract data in 1D case here instead of before measurement, so the file ist not too clustered if measurement fails self._fit_extracts[key] = self._data_file.add_coordinate("fit_" + key, unit="", folder="analysis") @@ -544,7 +544,6 @@ def _measure(self): measures and plots the data depending on the measurement type. the measurement loops feature the setting of the objects and saving the data in the .h5 file. ''' - qkit.flow.start() try: """ loop: x_obj with parameters from x_vec @@ -565,27 +564,26 @@ def _measure(self): self.y_set_obj(y) sleep(self.tdy) - for lf in self.log_funcs: - lf.logIfDesired(ix, iy) - if self.averaging_start_ready: self.vna.start_measurement() # Check if the VNA is STILL in ready state, then add some delay. # If you manually decrease the poll_inveral, I guess you know what you are doing and will disable this safety query. if self.vna_poll_interval >= 0.1 and self.vna.ready(): logging.debug("VNA STILL ready... Adding delay") - qkit.flow.sleep( - .2) # just to make sure, the ready command does not *still* show ready + sleep(0.2) # just to make sure, the ready command does not *still* show ready while not self.vna.ready(): - qkit.flow.sleep(min(self.vna.get_sweeptime_averages(query=False) / 11., self.vna_poll_interval)) + sleep(min(self.vna.get_sweeptime_averages(query=False) / 11., self.vna_poll_interval)) else: self.vna.avg_clear() - qkit.flow.sleep(self._sweeptime_averages) + sleep(self._sweeptime_averages) # if "avg_status" in self.vna.get_function_names(): # while self.vna.avg_status() < self.vna.get_averages(): # qkit.flow.sleep(.2) #maybe one would like to adjust this at a later point + + for lf in self.log_funcs: + lf.logIfDesired(ix, iy) """ measurement """ if not self.landscape.xzlandscape_func: # normal scan @@ -620,7 +618,6 @@ def _measure(self): fit_extracts_helper[key][iy] = val self._fit_extracts[key].append(fit_extracts_helper[key], reset=(iy != 0)) - qkit.flow.sleep() """ filling of value-box is done here. after every y-loop the data is stored the next 2d structure @@ -636,20 +633,22 @@ def _measure(self): self._fit_imag.next_matrix() if self._scan_dim == 2: - for lf in self.log_funcs: - lf.logIfDesired(ix) - + if self.averaging_start_ready: self.vna.start_measurement() if self.vna.ready(): logging.debug("VNA STILL ready... Adding delay") - qkit.flow.sleep(.2) # just to make sure, the ready command does not *still* show ready + sleep(.2) # just to make sure, the ready command does not *still* show ready while not self.vna.ready(): - qkit.flow.sleep(min(self.vna.get_sweeptime_averages(query=False) / 11., .2)) + sleep(min(self.vna.get_sweeptime_averages(query=False) / 11., .2)) else: self.vna.avg_clear() - qkit.flow.sleep(self._sweeptime_averages) + sleep(self._sweeptime_averages) + + for lf in self.log_funcs: + lf.logIfDesired(ix) + """ measurement """ if not self.landscape.xzlandscape_func: # normal scan data_amp, data_pha = self.vna.get_tracedata() @@ -676,11 +675,8 @@ def _measure(self): for key, val in self._fit_function.extract_data.items(): self._fit_extracts[key].append(val) - qkit.flow.sleep() - finally: self._end_measurement() - qkit.flow.end() def _end_measurement(self): ''' @@ -714,7 +710,7 @@ def set_resonator_fit(self, fit_resonator=False, fit_function = None, f_min=None if fit_function == "circle_fit_notch": fit_opt_kwargs.update({"n_ports": 2}) try: - self._fit_function = resonatorFit.FitNames[fit_function](**fit_opt_kwargs) # init fit-class here + self._fit_function: resonatorFit.ResonatorFitBase = resonatorFit.FitNames[fit_function](**fit_opt_kwargs) # init fit-class here except KeyError: logging.error('Fit function not properly set. Must be either \'lorentzian\', \'skewed_lorentzian\', \'circle_fit_reflection\', \'circle_fit_notch\', \'fano\'.') else: diff --git a/src/qkit/storage/hdf_file.py b/src/qkit/storage/hdf_file.py index 268889189..144bde851 100644 --- a/src/qkit/storage/hdf_file.py +++ b/src/qkit/storage/hdf_file.py @@ -39,7 +39,7 @@ def __init__(self,output_file, mode,**kw): self.create_file(output_file, mode) self.newfile = False - if self.hf.attrs.get("qt-file",None) or self.hf.attrs.get("qkit",None): + if self.hf.attrs.get("qt-file", None) or self.hf.attrs.get("qkit", None): "File existed before and was created by qkit." self.setup_required_groups() else: @@ -52,7 +52,7 @@ def __init__(self,output_file, mode,**kw): self.grp.attrs[k] = kw[k] def create_file(self,output_file, mode): - self.hf = h5py.File(output_file, mode,**file_kwargs ) + self.hf = h5py.File(output_file, mode, **file_kwargs) def set_base_attributes(self): "stores some attributes and creates the default data group" @@ -145,10 +145,7 @@ def create_dataset(self,name, tracelength, ds_type = ds_types['vector'], # we store text as unicode; this seems somewhat non-standard for hdf if ds_type == ds_types['txt']: - try: - dtype = h5py.special_dtype(vlen=unicode) # python 2 - except NameError: - dtype = h5py.special_dtype(vlen=str) # python 3 + dtype = h5py.special_dtype(vlen=str) #create the dataset ...; delete it first if it exists, unless it is data if name in self.grp.keys(): if folder == "data": From ddb414414f3b573f950689a680641e65dba215ea Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Fri, 16 May 2025 11:28:22 +0200 Subject: [PATCH 13/43] WIP --- src/qkit/measure/logging_base.py | 52 ++++++++++--------- src/qkit/measure/spectroscopy/spectroscopy.py | 6 +-- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index d7bfa0324..044e64a52 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -20,66 +20,68 @@ class logFunc(object): is already defined in the main measurement, 'trace_vec provides information about the additional coordinate a trace log function may sweep over as (*values_array*, name, unit). The same base coordinate may be chosen for different trace log functions. """ - def __init__(self, file: Data, func: typing.Callable, name: str, unit: str = "", x_ds: hdf_dataset = None, y_ds: hdf_dataset = None, trace_info: tuple[np.ndarray, str, str] = None): - self.file = file + def __init__(self, file_name: str, func: typing.Callable, name: str, unit: str = "", x_ds_url: str = None, y_ds_url: str = None, trace_info: tuple[np.ndarray, str, str] = None): + self.file = Data(file_name) self.func = func self.name = name self.unit = unit + print("Logging {} in file {}".format(name, file_name)) # TODO remove self.signature = "" - self.x_ds = x_ds - self.x_len: int = None - if not (x_ds is None): + self.x_ds_url = x_ds_url + if not (x_ds_url is None): self.signature += "x" - self.x_len = x_ds.ds.shape[0] - self.y_ds = y_ds - self.y_len : int = None - if not (y_ds is None): + self.x_len: int = None + self.y_ds_url = y_ds_url + if not (y_ds_url is None): self.signature += "y" - self.y_len = y_ds.ds.shape[0] + self.y_len: int = None self.trace_info = trace_info if not (trace_info is None): self.signature += "n" - self.trace_ds: hdf_dataset = None - self.log_ds: hdf_dataset = None self.buffer1d: np.ndarray = None def prepare_file(self): # prepare trace base coordinate if necessary if "n" in self.signature: try: - self.trace_ds = self.file.get_dataset("/entry/data0/{}".format(self.trace_info[1])) + trace_ds = self.file.get_dataset("/entry/data0/{}".format(self.trace_info[1])) # base coordinate already exists in file except: - self.trace_ds = self.file.add_coordinate(self.trace_info[1], self.trace_info[2]) - self.trace_ds.add(self.trace_info[0]) + trace_ds = self.file.add_coordinate(self.trace_info[1], self.trace_info[2]) + trace_ds.add(self.trace_info[0]) # the logic is admittably more complicated here, writing down all 8 possible cases of x,y,n present or not helps if len(self.signature) == 0: - self.log_ds = self.file.add_coordinate(self.name, self.unit) + self.file.add_coordinate(self.name, self.unit) elif len(self.signature) == 1: - self.log_ds = self.file.add_value_vector(self.name, {"x":self.x_ds,"y":self.y_ds,"n":self.trace_ds}[self.signature], self.unit) + self.file.add_value_vector(self.name, {"x":self.file.get_dataset(self.x_ds_url),"y":self.file.get_dataset(self.y_ds_url),"n":trace_ds}[self.signature], self.unit) elif len(self.signature) == 2: - self.log_ds = self.file.add_value_matrix(self.name, self.x_ds if "x" in self.signature else self.y_ds, self.trace_ds if "n" in self.signature else self.y_ds, self.unit) + self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit) elif len(self.signature) == 3: - self.log_ds = self.file.add_value_box(self.name, self.x_ds, self.y_ds, self.trace_ds, self.unit) + self.file.add_value_box(self.name, self.file.get_dataset(self.x_ds_url), self.file.get_dataset(self.y_ds_url), trace_ds, self.unit) + + if "x" in self.signature: + self.x_len = self.file.get_dataset(self.x_ds_url).ds.shape[0] + if "y" in self.signature: + self.y_len = self.file.get_dataset(self.y_ds_url).ds.shape[0] def logIfDesired(self, ix=0, iy=0): if (ix == 0 or "x" in self.signature) and (iy == 0 or "y" in self.signature): # log function call desired - print("lög") + log_ds = self.file.get_dataset("/entry/data0/{}".format(self.name)) if len(self.signature) == 0: # "" # we will only reach here once, no further case-logic required - self.log_ds.add(np.array([self.func()])) + log_ds.add(np.array([self.func()])) elif "n" in self.signature: # "n", "xn", "yn", "xyn" - self.log_ds.append(self.func()) + log_ds.append(self.func()) if len(self.signature) == 3: if iy + 1 == self.y_len: # who doesnt love 4x nested ifs edge cases? - self.log_ds.next_matrix() + log_ds.next_matrix() elif "y" in self.signature: # "y", "xy" if iy == 0: self.buffer1d = np.full(self.y_len, np.nan) self.buffer1d[iy] = self.func() - self.log_ds.append(self.buffer1d, reset=(iy != 0)) + log_ds.append(self.buffer1d, reset=(iy != 0)) else: # "x" if ix == 0: self.buffer1d = np.full(self.x_len, np.nan) self.buffer1d[ix] = self.func() - self.log_ds.append(self.buffer1d, reset=(ix != 0)) \ No newline at end of file + log_ds.append(self.buffer1d, reset=(ix != 0)) \ No newline at end of file diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 48409c331..ddc1284ee 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -314,9 +314,9 @@ def _prepare_measurement_file(self): for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple - self.log_funcs += [logFunc(self._data_file, func, name, unit, - self._data_x if (self._scan_dim >= 2) and over_x else None, - self._data_y if (self._scan_dim == 3) and over_y else None, + self.log_funcs += [logFunc(self._file_name, func, name, unit, + self._data_x.ds_url if (self._scan_dim >= 2) and over_x else None, + self._data_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] self.log_funcs[-1].prepare_file() From e444ef92ab3b82df5dbe89117ff7caed28fbdeb6 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 19 May 2025 09:44:35 +0200 Subject: [PATCH 14/43] WIP --- src/qkit/measure/spectroscopy/spectroscopy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index ddc1284ee..35e796f40 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -314,7 +314,7 @@ def _prepare_measurement_file(self): for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple - self.log_funcs += [logFunc(self._file_name, func, name, unit, + self.log_funcs += [logFunc(self._data_file._name, func, name, unit, self._data_x.ds_url if (self._scan_dim >= 2) and over_x else None, self._data_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] From 6038eeffc770953464401c69eed7c2e7a0454cbd Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Mon, 19 May 2025 13:05:25 +0200 Subject: [PATCH 15/43] Bug fixes + tested --- src/qkit/measure/logging_base.py | 22 +++++++++---------- src/qkit/measure/spectroscopy/spectroscopy.py | 2 +- src/qkit/measure/transport/transport.py | 2 +- src/qkit/storage/hdf_dataset.py | 13 ++++++----- src/qkit/storage/hdf_file.py | 4 ++-- src/qkit/storage/store.py | 2 +- 6 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 044e64a52..071651ee5 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -25,7 +25,7 @@ def __init__(self, file_name: str, func: typing.Callable, name: str, unit: str = self.func = func self.name = name self.unit = unit - print("Logging {} in file {}".format(name, file_name)) # TODO remove + # print("Logging {} in file {}".format(name, file_name)) # TODO remove self.signature = "" self.x_ds_url = x_ds_url if not (x_ds_url is None): @@ -39,6 +39,7 @@ def __init__(self, file_name: str, func: typing.Callable, name: str, unit: str = if not (trace_info is None): self.signature += "n" self.buffer1d: np.ndarray = None + self.log_ds: hdf_dataset = None def prepare_file(self): # prepare trace base coordinate if necessary if "n" in self.signature: @@ -50,13 +51,13 @@ def prepare_file(self): trace_ds.add(self.trace_info[0]) # the logic is admittably more complicated here, writing down all 8 possible cases of x,y,n present or not helps if len(self.signature) == 0: - self.file.add_coordinate(self.name, self.unit) + self.log_ds = self.file.add_coordinate(self.name, self.unit) elif len(self.signature) == 1: - self.file.add_value_vector(self.name, {"x":self.file.get_dataset(self.x_ds_url),"y":self.file.get_dataset(self.y_ds_url),"n":trace_ds}[self.signature], self.unit) + self.log_ds = self.file.add_value_vector(self.name, {"x":self.file.get_dataset(self.x_ds_url),"y":self.file.get_dataset(self.y_ds_url),"n":trace_ds}[self.signature], self.unit) elif len(self.signature) == 2: - self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit) + self.log_ds = self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit) elif len(self.signature) == 3: - self.file.add_value_box(self.name, self.file.get_dataset(self.x_ds_url), self.file.get_dataset(self.y_ds_url), trace_ds, self.unit) + self.log_ds = self.file.add_value_box(self.name, self.file.get_dataset(self.x_ds_url), self.file.get_dataset(self.y_ds_url), trace_ds, self.unit) if "x" in self.signature: self.x_len = self.file.get_dataset(self.x_ds_url).ds.shape[0] @@ -65,23 +66,22 @@ def prepare_file(self): def logIfDesired(self, ix=0, iy=0): if (ix == 0 or "x" in self.signature) and (iy == 0 or "y" in self.signature): # log function call desired - log_ds = self.file.get_dataset("/entry/data0/{}".format(self.name)) if len(self.signature) == 0: # "" # we will only reach here once, no further case-logic required - log_ds.add(np.array([self.func()])) + self.log_ds.append(np.array([self.func()]), reset=True) elif "n" in self.signature: # "n", "xn", "yn", "xyn" - log_ds.append(self.func()) + self.log_ds.append(self.func()) if len(self.signature) == 3: if iy + 1 == self.y_len: # who doesnt love 4x nested ifs edge cases? - log_ds.next_matrix() + self.log_ds.next_matrix() elif "y" in self.signature: # "y", "xy" if iy == 0: self.buffer1d = np.full(self.y_len, np.nan) self.buffer1d[iy] = self.func() - log_ds.append(self.buffer1d, reset=(iy != 0)) + self.log_ds.append(self.buffer1d, reset=(iy != 0)) else: # "x" if ix == 0: self.buffer1d = np.full(self.x_len, np.nan) self.buffer1d[ix] = self.func() - log_ds.append(self.buffer1d, reset=(ix != 0)) \ No newline at end of file + self.log_ds.append(self.buffer1d, reset=(ix != 0)) \ No newline at end of file diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 35e796f40..6a917c9ef 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -314,7 +314,7 @@ def _prepare_measurement_file(self): for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple - self.log_funcs += [logFunc(self._data_file._name, func, name, unit, + self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, self._data_x.ds_url if (self._scan_dim >= 2) and over_x else None, self._data_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index 328439092..ac3c73d90 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1372,7 +1372,7 @@ def _prepare_measurement_file(self): # logging for init_tuple in self.log_init_params: func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple - self.log_funcs += [logFunc(self._data_file, func, name, unit, + self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, self._hdf_x.ds_url if (self._scan_dim >= 2) and over_x else None, self._hdf_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] diff --git a/src/qkit/storage/hdf_dataset.py b/src/qkit/storage/hdf_dataset.py index 4496da32d..fb3886432 100644 --- a/src/qkit/storage/hdf_dataset.py +++ b/src/qkit/storage/hdf_dataset.py @@ -69,14 +69,15 @@ def _new_ds_defaults(self, name, unit, folder, comment): # the first dataset is used to extract a few attributes self.first = True - def _read_ds_from_hdf(self,ds_url): - ds = self.hf[str(ds_url)] + def _read_ds_from_hdf(self, ds_url): + self.first = False # assume ds has been properly initialized if we're reading it + self.ds = self.hf[str(ds_url)] - for attr in ds.attrs.keys(): - val = ds.attrs.get(attr) - setattr(self,attr,val) + for attr in self.ds.attrs.keys(): + val = self.ds.attrs.get(attr) + setattr(self, attr, val) - self.ds_url = ds_url + self.ds_url = ds_url def _setup_metadata(self): ds = self.ds diff --git a/src/qkit/storage/hdf_file.py b/src/qkit/storage/hdf_file.py index 144bde851..aed54d193 100644 --- a/src/qkit/storage/hdf_file.py +++ b/src/qkit/storage/hdf_file.py @@ -32,7 +32,7 @@ class H5_file(object): trick of placing added data in the correct position in the dataset. """ - def __init__(self,output_file, mode,**kw): + def __init__(self, output_file, mode, **kw): """Inits the H5_file at the path 'output_file' with the access mode 'mode' """ @@ -51,7 +51,7 @@ def __init__(self,output_file, mode,**kw): for k in kw: self.grp.attrs[k] = kw[k] - def create_file(self,output_file, mode): + def create_file(self, output_file, mode): self.hf = h5py.File(output_file, mode, **file_kwargs) def set_base_attributes(self): diff --git a/src/qkit/storage/store.py b/src/qkit/storage/store.py index fedb6d3bd..864ebd6dd 100644 --- a/src/qkit/storage/store.py +++ b/src/qkit/storage/store.py @@ -50,7 +50,7 @@ def __init__(self, name = None, mode = 'r+', copy_file = False): logging.debug("Could not add newly generated h5 File '{}' to qkit.fid database: {}".format(name,e)) else: self._filepath = os.path.abspath(self._name) - self._folder,self._filename = os.path.split(self._filepath) + self._folder, self._filename = os.path.split(self._filepath) "setup the file" try: self.hf = H5_file(self._filepath, mode) From a7ebecadb30bf0aa87ddd8b534a0b5c986fa2077 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Thu, 22 May 2025 10:53:11 +0200 Subject: [PATCH 16/43] Fix 1D log trying to access non-existent dataset --- src/qkit/measure/logging_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 071651ee5..d4893aa84 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -53,7 +53,7 @@ def prepare_file(self): if len(self.signature) == 0: self.log_ds = self.file.add_coordinate(self.name, self.unit) elif len(self.signature) == 1: - self.log_ds = self.file.add_value_vector(self.name, {"x":self.file.get_dataset(self.x_ds_url),"y":self.file.get_dataset(self.y_ds_url),"n":trace_ds}[self.signature], self.unit) + self.log_ds = self.file.add_value_vector(self.name, (self.file.get_dataset(self.x_ds_url) if self.signature == "x" else (self.file.get_dataset(self.y_ds_url) if self.signature == "y" else trace_ds)), self.unit) elif len(self.signature) == 2: self.log_ds = self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit) elif len(self.signature) == 3: From 8995af7ccb6b8c8e45637a64fbfd6547f87d0bd4 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 26 May 2025 14:29:44 +0200 Subject: [PATCH 17/43] added storeRealImag parameter to spectroscopy to roughly half file size if desired, at cost of no convenient IQ-view and pre-calculated datasets --- src/qkit/measure/spectroscopy/spectroscopy.py | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 6a917c9ef..521f5481a 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -79,6 +79,7 @@ def __init__(self, vna, exp_name='', sample=None): self.progress_bar = True self._fit_resonator = False + self.storeRealImag = False self._plot_comment = "" self.log_init_params = [] # buffer is necessary to allow adjusting x/y parameter sweeps after setting log functions @@ -236,36 +237,36 @@ def _prepare_measurement_file(self): self._fit_freq = self._data_file.add_coordinate('_fit_frequency', unit='Hz', folder="analysis") if self._scan_dim == 1: - self._data_real = self._data_file.add_value_vector('real', x=sweep_vector, unit='', save_timestamp=True) - self._data_imag = self._data_file.add_value_vector('imag', x=sweep_vector, unit='', save_timestamp=True) + self._data_real = self._data_file.add_value_vector('real', x=sweep_vector, unit='', save_timestamp=True) if self.storeRealImag else None + self._data_imag = self._data_file.add_value_vector('imag', x=sweep_vector, unit='', save_timestamp=True) if self.storeRealImag else None self._data_amp = self._data_file.add_value_vector('amplitude', x=sweep_vector, unit='arb. unit', save_timestamp=True) self._data_pha = self._data_file.add_value_vector('phase', x=sweep_vector, unit='rad', save_timestamp=True) - self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) + self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) if self.storeRealImag else None if self._fit_resonator: self._fit_amp = self._data_file.add_value_vector("_fit_amplitude", x=self._fit_freq, unit="arb. unit", folder="analysis") self._fit_pha = self._data_file.add_value_vector("_fit_phase", x=self._fit_freq, unit="rad", folder="analysis") - self._fit_real = self._data_file.add_value_vector("_fit_real", x=self._fit_freq, unit="", folder="analysis") - self._fit_imag = self._data_file.add_value_vector("_fit_imag", x=self._fit_freq, unit="", folder="analysis") + self._fit_real = self._data_file.add_value_vector("_fit_real", x=self._fit_freq, unit="", folder="analysis") if self.storeRealImag else None + self._fit_imag = self._data_file.add_value_vector("_fit_imag", x=self._fit_freq, unit="", folder="analysis") if self.storeRealImag else None # extract data is single datapoint for 1D measure, add as coordinate after measurement manually if self._scan_dim == 2: self._data_x = self._data_file.add_coordinate(self.x_coordname, unit=self.x_unit) self._data_x.add(self.x_vec) - self._data_real = self._data_file.add_value_matrix('real', x=self._data_x, y=sweep_vector, unit='', save_timestamp=False) - self._data_imag = self._data_file.add_value_matrix('imag', x=self._data_x, y=sweep_vector, unit='', save_timestamp=False) + self._data_real = self._data_file.add_value_matrix('real', x=self._data_x, y=sweep_vector, unit='', save_timestamp=False) if self.storeRealImag else None + self._data_imag = self._data_file.add_value_matrix('imag', x=self._data_x, y=sweep_vector, unit='', save_timestamp=False) if self.storeRealImag else None self._data_amp = self._data_file.add_value_matrix('amplitude', x=self._data_x, y=sweep_vector, unit='arb. unit', save_timestamp=True) self._data_pha = self._data_file.add_value_matrix('phase', x=self._data_x, y=sweep_vector, unit='rad', save_timestamp=True) - self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) + self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) if self.storeRealImag else None if self._fit_resonator: self._fit_amp = self._data_file.add_value_matrix("_fit_amplitude", x=self._data_x, y=sweep_vector, unit="arb. unit", folder="analysis") self._fit_pha = self._data_file.add_value_matrix("_fit_phase", x=self._data_x, y=sweep_vector, unit="rad", folder="analysis") - self._fit_real = self._data_file.add_value_matrix("_fit_real", x=self._data_x, y=sweep_vector, unit="", folder="analysis") - self._fit_imag = self._data_file.add_value_matrix("_fit_imag", x=self._data_x, y=sweep_vector, unit="", folder="analysis") + self._fit_real = self._data_file.add_value_matrix("_fit_real", x=self._data_x, y=sweep_vector, unit="", folder="analysis") if self.storeRealImag else None + self._fit_imag = self._data_file.add_value_matrix("_fit_imag", x=self._data_x, y=sweep_vector, unit="", folder="analysis") if self.storeRealImag else None self._fit_extracts: dict[str, hdf.hdf_dataset] = {} for key in self._fit_function.extract_data.keys(): @@ -288,25 +289,25 @@ def _prepare_measurement_file(self): self._data_amp = self._data_file.add_value_matrix('amplitude', x=self._data_x, y=self._data_y, unit='arb. unit', save_timestamp=False) self._data_pha = self._data_file.add_value_matrix('phase', x=self._data_x, y=self._data_y, unit='rad', save_timestamp=False) else: - self._data_real = self._data_file.add_value_box('real', x=self._data_x, y=self._data_y, z=sweep_vector, unit='', save_timestamp=False) - self._data_imag = self._data_file.add_value_box('imag', x=self._data_x, y=self._data_y, z=sweep_vector, unit='', save_timestamp=False) + self._data_real = self._data_file.add_value_box('real', x=self._data_x, y=self._data_y, z=sweep_vector, unit='', save_timestamp=False) if self.storeRealImag else None + self._data_imag = self._data_file.add_value_box('imag', x=self._data_x, y=self._data_y, z=sweep_vector, unit='', save_timestamp=False) if self.storeRealImag else None self._data_amp = self._data_file.add_value_box('amplitude', x=self._data_x, y=self._data_y, z=sweep_vector, unit='arb. unit', save_timestamp=True) self._data_pha = self._data_file.add_value_box('phase', x=self._data_x, y=self._data_y, z=sweep_vector, unit='rad', save_timestamp=True) - self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) + self._iq_view = self._data_file.add_view("IQ", x=self._data_real, y=self._data_imag, view_params={'aspect': 1.0}) if self.storeRealImag else None if self._fit_resonator: self._fit_amp = self._data_file.add_value_box("_fit_amplitude", x=self._data_x, y=self._data_y, z=sweep_vector, unit="arb. unit", folder="analysis") self._fit_pha = self._data_file.add_value_box("_fit_phase", x=self._data_x, y=self._data_y, z=sweep_vector, unit="rad", folder="analysis") - self._fit_real = self._data_file.add_value_box("_fit_real", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") - self._fit_imag = self._data_file.add_value_box("_fit_imag", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") + self._fit_real = self._data_file.add_value_box("_fit_real", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") if self.storeRealImag else None + self._fit_imag = self._data_file.add_value_box("_fit_imag", x=self._data_x, y=self._data_y, z=sweep_vector, unit="", folder="analysis") if self.storeRealImag else None self._fit_extracts: dict[str, hdf.hdf_dataset] = {} for key in self._fit_function.extract_data.keys(): self._fit_extracts[key] = self._data_file.add_value_matrix("fit_" + key, x=self._data_x, y=self._data_y, unit="", folder="analysis") if self._fit_resonator: - self._iq_view.add(self._fit_real, self._fit_imag) + self._iq_view.add(self._fit_real, self._fit_imag) if self.storeRealImag else None self._amp_view = self._data_file.add_view("AmplitudeFit", self._data_freq, self._data_amp) self._amp_view.add(self._fit_freq, self._fit_amp) self._pha_view = self._data_file.add_view("PhaseFit", self._data_freq, self._data_pha) @@ -392,20 +393,20 @@ def measure_1D(self, rescan=True, web_visible=True): if self.progress_bar: self._p.iterate() data_amp, data_pha = self.vna.get_tracedata() - data_real, data_imag = self.vna.get_tracedata('RealImag') + data_real, data_imag = self.vna.get_tracedata('RealImag') if self.storeRealImag else (None, None) self._data_amp.append(data_amp) self._data_pha.append(data_pha) - self._data_real.append(data_real) - self._data_imag.append(data_imag) + self._data_real.append(data_real) if self.storeRealImag else None + self._data_imag.append(data_imag) if self.storeRealImag else None if self._fit_resonator: self._fit_function.do_fit(self._freqpoints[self._fit_select], np.array(data_amp)[self._fit_select], np.array(data_pha)[self._fit_select]) # add fit data to file self._fit_freq.add(self._fit_function.freq_fit) self._fit_amp.append(self._fit_function.amp_fit) self._fit_pha.append(self._fit_function.pha_fit) - self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) - self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) + self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) if self.storeRealImag else None + self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) if self.storeRealImag else None self._fit_extracts: dict[str, hdf.hdf_dataset] = {} for key, val in self._fit_function.extract_data.items(): @@ -600,8 +601,8 @@ def _measure(self): else: self._data_amp.append(data_amp) self._data_pha.append(data_pha) - self._data_real.append(data_amp*np.cos(data_pha)) - self._data_imag.append(data_amp*np.sin(data_pha)) + self._data_real.append(data_amp*np.cos(data_pha)) if self.storeRealImag else None + self._data_imag.append(data_amp*np.sin(data_pha)) if self.storeRealImag else None if self._fit_resonator: self._fit_function.do_fit(self._freqpoints[self._fit_select], data_amp[self._fit_select], data_pha[self._fit_select]) @@ -609,8 +610,8 @@ def _measure(self): self._fit_freq.add(self._fit_function.freq_fit) if (ix == 0) & (iy == 0) else None self._fit_amp.append(self._fit_function.amp_fit) self._fit_pha.append(self._fit_function.pha_fit) - self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) - self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) + self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) if self.storeRealImag else None + self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) if self.storeRealImag else None for key, val in self._fit_function.extract_data.items(): if iy == 0: @@ -624,13 +625,13 @@ def _measure(self): """ self._data_amp.next_matrix() self._data_pha.next_matrix() - self._data_real.next_matrix() - self._data_imag.next_matrix() + self._data_real.next_matrix() if self.storeRealImag else None + self._data_imag.next_matrix() if self.storeRealImag else None if self._fit_resonator: self._fit_amp.next_matrix() self._fit_pha.next_matrix() - self._fit_real.next_matrix() - self._fit_imag.next_matrix() + self._fit_real.next_matrix() if self.storeRealImag else None + self._fit_imag.next_matrix() if self.storeRealImag else None if self._scan_dim == 2: @@ -660,8 +661,8 @@ def _measure(self): self._data_amp.append(data_amp) self._data_pha.append(data_pha) - self._data_real.append(data_amp*np.cos(data_pha)) - self._data_imag.append(data_amp*np.sin(data_pha)) + self._data_real.append(data_amp*np.cos(data_pha)) if self.storeRealImag else None + self._data_imag.append(data_amp*np.sin(data_pha)) if self.storeRealImag else None if self._fit_resonator: self._fit_function.do_fit(self._freqpoints[self._fit_select], data_amp[self._fit_select], data_pha[self._fit_select]) @@ -669,8 +670,8 @@ def _measure(self): self._fit_freq.add(self._fit_function.freq_fit) if (ix == 0) else None self._fit_amp.append(self._fit_function.amp_fit) self._fit_pha.append(self._fit_function.pha_fit) - self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) - self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) + self._fit_real.append(self._fit_function.amp_fit*np.cos(self._fit_function.pha_fit)) if self.storeRealImag else None + self._fit_imag.append(self._fit_function.amp_fit*np.sin(self._fit_function.pha_fit)) if self.storeRealImag else None for key, val in self._fit_function.extract_data.items(): self._fit_extracts[key].append(val) From 14f64fdec0df70e536ac6fb5bd65aabcb0a64cd8 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 27 May 2025 10:36:44 +0200 Subject: [PATCH 18/43] Added fano + (skewed) lorentzian maths as comments --- src/qkit/analysis/resonator_fitting.py | 323 ++++++++++++++++++++++++- 1 file changed, 316 insertions(+), 7 deletions(-) diff --git a/src/qkit/analysis/resonator_fitting.py b/src/qkit/analysis/resonator_fitting.py index bcaabca07..0702e57e1 100644 --- a/src/qkit/analysis/resonator_fitting.py +++ b/src/qkit/analysis/resonator_fitting.py @@ -522,8 +522,8 @@ def __init__(self): self.extract_data = { "f_res": None, "f_res_err": None, - "Qc": None, - "Qc_err": None, + "Ql": None, + "Ql_err": None, # TODO } @@ -532,9 +532,90 @@ def do_fit(self, freq, amp, pha): logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") self.extract_data["f_res"] = 1 self.extract_data["f_res_err"] = 0.5 - self.extract_data["Qc"] = 1 - self.extract_data["Qc_err"] = 0.5 + self.extract_data["Ql"] = 1 + self.extract_data["Ql_err"] = 0.5 return super().do_fit(freq, amp, pha) + """ + Old implementation: + + def residuals(p,x,y): + f0,k,a,offs=p + err = y-(a/(1+4*((x-f0)/k)**2)+offs) + return err + + self._fit_all = fit_all + + if not self._datasets_loaded: + self._get_datasets() + + self._update_data() + self._prepare_f_range(f_min,f_max) + if self._first_lorentzian: + self._prepare_lorentzian() + self._first_lorentzian=False + + ''' + fit_amplitude is always 2dim np array. + for 1dim data, shape: (1, # fit frequency points) + for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) + ''' + if not self._fit_all: + self._get_last_amp_trace() + + for amplitudes in self._fit_amplitude: + amplitudes = np.absolute(amplitudes) + amplitudes_sq = amplitudes**2 + '''extract starting parameter for lorentzian from data''' + s_offs = np.mean(np.array([amplitudes_sq[:int(np.size(amplitudes_sq)*.1)], amplitudes_sq[int(np.size(amplitudes_sq)-int(np.size(amplitudes_sq)*.1)):]])) + '''offset is calculated from the first and last 10% of the data to improve fitting on tight windows''' + + if np.abs(np.max(amplitudes_sq)-np.mean(amplitudes_sq)) > np.abs(np.min(amplitudes_sq)-np.mean(amplitudes_sq)): + '''peak is expected''' + s_a = np.abs((np.max(amplitudes_sq)-np.mean(amplitudes_sq))) + s_f0 = self._fit_frequency[np.argmax(amplitudes_sq)] + else: + '''dip is expected''' + s_a = -np.abs((np.min(amplitudes_sq)-np.mean(amplitudes_sq))) + s_f0 = self._fit_frequency[np.argmin(amplitudes_sq)] + + '''estimate peak/dip width''' + mid = s_offs + .5*s_a #estimated mid region between base line and peak/dip + m = [] #mid points + for i in range(len(amplitudes_sq)-1): + if np.sign(amplitudes_sq[i]-mid) != np.sign(amplitudes_sq[i+1]-mid):#mid level crossing + m.append(i) + if len(m)>1: + s_k = self._fit_frequency[m[-1]]-self._fit_frequency[m[0]] + else: + s_k = .15*(self._fit_frequency[-1]-self._fit_frequency[0]) #try 15% of window + p0=[s_f0, s_k, s_a, s_offs] + try: + fit = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) + except: + self._lrnz_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) + self._lrnz_f0.append(np.nan) + self._lrnz_k.append(np.nan) + self._lrnz_a.append(np.nan) + self._lrnz_offs.append(np.nan) + self._lrnz_Ql.append(np.nan) + self._lrnz_chi2_fit.append(np.nan) + else: + popt=fit[0] + chi2 = self._lorentzian_fit_chi2(popt,amplitudes_sq) + self._lrnz_amp_gen.append(np.sqrt(np.array(self._lorentzian_from_fit(popt)))) + self._lrnz_f0.append(float(popt[0])) + self._lrnz_k.append(float(np.fabs(float(popt[1])))) + self._lrnz_a.append(float(popt[2])) + self._lrnz_offs.append(float(popt[3])) + self._lrnz_Ql.append(float(float(popt[0])/np.fabs(float(popt[1])))) + self._lrnz_chi2_fit.append(float(chi2)) + def _lorentzian_from_fit(self,fit): + return fit[2] / (1 + (4*((self._fit_frequency-fit[0])/fit[1]) ** 2)) + fit[3] + + def _lorentzian_fit_chi2(self, fit, amplitudes_sq): + chi2 = np.sum((self._lorentzian_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) + return chi2 + """ class SkewedLorentzianFit(ResonatorFitBase): @@ -543,8 +624,8 @@ def __init__(self): self.extract_data = { "f_res": None, "f_res_err": None, - "Qc": None, - "Qc_err": None, + "Ql": None, + "Ql_err": None, # TODO } @@ -556,7 +637,118 @@ def do_fit(self, freq, amp, pha): self.extract_data["Qc"] = 1 self.extract_data["Qc_err"] = 0.5 return super().do_fit(freq, amp, pha) - + """ + Old implementation: + + ''' + def residuals(p,x,y): + A2, A4, Qr = p + err = y -(A1a+A2*(x-fra)+(A3a+A4*(x-fra))/(1.+4.*Qr**2*((x-fra)/fra)**2)) + return err + def residuals2(p,x,y): + A1, A2, A3, A4, fr, Qr = p + err = y -(A1+A2*(x-fr)+(A3+A4*(x-fr))/(1.+4.*Qr**2*((x-fr)/fr)**2)) + return err + + self._fit_all = fit_all + + if not self._datasets_loaded: + self._get_datasets() + self._update_data() + + self._prepare_f_range(f_min,f_max) + if self._first_skewed_lorentzian: + self._prepare_skewed_lorentzian() + self._first_skewed_lorentzian = False + + ''' + fit_amplitude is always 2dim np array. + for 1dim data, shape: (1, # fit frequency points) + for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) + ''' + if not self._fit_all: + self._get_last_amp_trace() + + for amplitudes in self._fit_amplitude: + "fits a skewed lorenzian to reflection amplitudes of a resonator" + # prefilter the data + amplitudes = self._pre_filter_data(amplitudes) + + amplitudes = np.absolute(amplitudes) + amplitudes_sq = amplitudes**2 + + A1a = np.minimum(amplitudes_sq[0],amplitudes_sq[-1]) + A3a = -np.max(amplitudes_sq) + fra = self._fit_frequency[np.argmin(amplitudes_sq)] + + p0 = [0., 0., 1e3] + + try: + p_final = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) + A2a, A4a, Qra = p_final[0] + + p0 = [A1a, A2a , A3a, A4a, fra, Qra] + p_final = leastsq(residuals2,p0,args=(self._fit_frequency,amplitudes_sq)) + popt=p_final[0] + except: + self._skwd_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) + self._skwd_f0.append(np.nan) + self._skwd_a1.append(np.nan) + self._skwd_a2.append(np.nan) + self._skwd_a3.append(np.nan) + self._skwd_a4.append(np.nan) + self._skwd_Qr.append(np.nan) + self._skwd_chi2_fit.append(np.nan) + else: + chi2 = self._skewed_fit_chi2(popt,amplitudes_sq) + amp_gen = np.sqrt(np.array(self._skewed_from_fit(popt))) + + self._skwd_amp_gen.append(amp_gen) + self._skwd_f0.append(float(popt[4])) + self._skwd_a1.append(float(popt[0])) + self._skwd_a2.append(float(popt[1])) + self._skwd_a3.append(float(popt[2])) + self._skwd_a4.append(float(popt[3])) + self._skwd_Qr.append(float(popt[5])) + self._skwd_chi2_fit.append(float(chi2)) + + self._skwd_Qi.append(self._skewed_estimate_Qi(popt)) + + + def _skewed_fit_chi2(self, fit, amplitudes_sq): + chi2 = np.sum((self._skewed_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) + return chi2 + + def _skewed_from_fit(self,p): + A1, A2, A3, A4, fr, Qr = p + return A1+A2*(self._fit_frequency-fr)+(A3+A4*(self._fit_frequency-fr))/(1.+4.*Qr**2*((self._fit_frequency-fr)/fr)**2) + + def _skewed_estimate_Qi(self,p): + + #this is a very clumsy numerical estimate of the Qi factor based on the +3dB method.# + A1, A2, A3, A4, fr, Qr = p + def skewed_from_fit(p,f): + A1, A2, A3, A4, fr, Qr = p + return A1+A2*(f-fr)+(A3+A4*(f-fr))/(1.+4.*Qr**2*((f-fr)/fr)**2) + + #df = fr/(Qr*10000) + fmax = fr+fr/Qr + fs = np.linspace(fr,fmax,1000,dtype=np.float64) + Amin = skewed_from_fit(p,fr) + #print("---") + #print("Amin, 2*Amin",Amin, 2*Amin) + + for f in fs: + A = skewed_from_fit(p,f) + #print(A, f) + if A>2*Amin: + break + qi = fr/(2*(f-fr)) + #print("f, A, fr/2*(f-fr)", f,A, qi) + + return float(qi) + """ + class FanoFit(ResonatorFitBase): def __init__(self): @@ -577,6 +769,123 @@ def do_fit(self, freq, amp, pha): self.extract_data["Qc"] = 1 self.extract_data["Qc_err"] = 0.5 return super().do_fit(freq, amp, pha) + """ + Old implementation: + + + self._fit_all = fit_all + if not self._datasets_loaded: + self._get_datasets() + self._update_data() + self._prepare_f_range(f_min,f_max) + if self._first_fano: + self._prepare_fano() + self._first_fano = False + + ''' + fit_amplitude is always 2dim np array. + for 1dim data, shape: (1, # fit frequency points) + for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) + ''' + if not self._fit_all: + self._get_last_amp_trace() + + for amplitudes in self._fit_amplitude: + amplitude_sq = (np.absolute(amplitudes))**2 + try: + fit = self._do_fit_fano(amplitude_sq) + amplitudes_gen = self._fano_reflection_from_fit(fit) + + '''calculate the chi2 of fit and data''' + chi2 = self._fano_fit_chi2(fit, amplitude_sq) + + except: + self._fano_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) + self._fano_q_fit.append(np.nan) + self._fano_bw_fit.append(np.nan) + self._fano_fr_fit.append(np.nan) + self._fano_a_fit.append(np.nan) + self._fano_chi2_fit.append(np.nan) + self._fano_Ql_fit.append(np.nan) + self._fano_Q0_fit.append(np.nan) + + else: + ''' save the fitted data to the hdf_file''' + self._fano_amp_gen.append(np.sqrt(np.absolute(amplitudes_gen))) + self._fano_q_fit.append(float(fit[0])) + self._fano_bw_fit.append(float(fit[1])) + self._fano_fr_fit.append(float(fit[2])) + self._fano_a_fit.append(float(fit[3])) + self._fano_chi2_fit.append(float(chi2)) + self._fano_Ql_fit.append(float(fit[2])/float(fit[1])) + q0=self._fano_fit_q0(np.sqrt(np.absolute(amplitudes_gen)),float(fit[2])) + self._fano_Q0_fit.append(q0) + + def _fano_reflection(self,f,q,bw,fr,a=1,b=1): + ''' + evaluates the fano function in reflection at the + frequency f + with + resonator frequency fr + attenuation a (linear) + fano-factor q + bandwidth bw + ''' + return a*(1 - self._fano_transmission(f,q,bw,fr)) + + def _fano_transmission(self,f,q,bw,fr,a=1,b=1): + ''' + evaluates the normalized transmission fano function at the + frequency f + with + resonator frequency fr + attenuation a (linear) + fano-factor q + bandwidth bw + ''' + F = 2*(f-fr)/bw + return ( 1/(1+q**2) * (F+q)**2 / (F**2+1)) + + def _do_fit_fano(self, amplitudes_sq): + # initial guess + bw = 1e6 + q = 1 #np.sqrt(1-amplitudes_sq).min() # 1-Amp_sq = 1-1+q^2 => A_min = q + fr = self._fit_frequency[np.argmin(amplitudes_sq)] + a = amplitudes_sq.max() + + p0 = [q, bw, fr, a] + + def fano_residuals(p,frequency,amplitude_sq): + q, bw, fr, a = p + err = amplitude_sq-self._fano_reflection(frequency,q,bw,fr=fr,a=a) + return err + + p_fit = leastsq(fano_residuals,p0,args=(self._fit_frequency,np.array(amplitudes_sq))) + #print(("q:%g bw:%g fr:%g a:%g")% (p_fit[0][0],p_fit[0][1],p_fit[0][2],p_fit[0][3])) + return p_fit[0] + + def _fano_reflection_from_fit(self,fit): + return self._fano_reflection(self._fit_frequency,fit[0],fit[1],fit[2],fit[3]) + + def _fano_fit_chi2(self,fit,amplitudes_sq): + chi2 = np.sum((self._fano_reflection_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) + return chi2 + + def _fano_fit_q0(self,amp_gen,fr): + ''' + calculates q0 from 3dB bandwidth above minimum in fit function + ''' + amp_3dB=10*np.log10((np.min(amp_gen)))+3 + amp_3dB_lin=10**(amp_3dB/10) + f_3dB=[] + for i in range(len(amp_gen)-1): + if np.sign(amp_gen[i]-amp_3dB_lin) != np.sign(amp_gen[i+1]-amp_3dB_lin):#crossing@amp_3dB + f_3dB.append(self._fit_frequency[i]) + if len(f_3dB)>1: + q0 = fr/(f_3dB[1]-f_3dB[0]) + return float(q0) + else: return np.nan + """ FitNames: dict[str, ResonatorFitBase] = { From 228668a1853445ab9c11f70b7876e27cbfc1cec7 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 27 May 2025 14:02:10 +0200 Subject: [PATCH 19/43] set_log_function backwards compatibility, untested --- src/qkit/measure/logging_base.py | 11 +-- src/qkit/measure/spectroscopy/spectroscopy.py | 85 +++++++++++++++++-- src/qkit/measure/transport/transport.py | 82 +++++++++++++++++- 3 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index d4893aa84..822070827 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -20,11 +20,12 @@ class logFunc(object): is already defined in the main measurement, 'trace_vec provides information about the additional coordinate a trace log function may sweep over as (*values_array*, name, unit). The same base coordinate may be chosen for different trace log functions. """ - def __init__(self, file_name: str, func: typing.Callable, name: str, unit: str = "", x_ds_url: str = None, y_ds_url: str = None, trace_info: tuple[np.ndarray, str, str] = None): + def __init__(self, file_name: str, func: typing.Callable, name: str, unit: str = "", dtype: str = "f", x_ds_url: str = None, y_ds_url: str = None, trace_info: tuple[np.ndarray, str, str] = None): self.file = Data(file_name) self.func = func self.name = name self.unit = unit + self.dtype = dtype # print("Logging {} in file {}".format(name, file_name)) # TODO remove self.signature = "" self.x_ds_url = x_ds_url @@ -51,13 +52,13 @@ def prepare_file(self): trace_ds.add(self.trace_info[0]) # the logic is admittably more complicated here, writing down all 8 possible cases of x,y,n present or not helps if len(self.signature) == 0: - self.log_ds = self.file.add_coordinate(self.name, self.unit) + self.log_ds = self.file.add_coordinate(self.name, self.unit) # coordinate dtype hardcoded as float elif len(self.signature) == 1: - self.log_ds = self.file.add_value_vector(self.name, (self.file.get_dataset(self.x_ds_url) if self.signature == "x" else (self.file.get_dataset(self.y_ds_url) if self.signature == "y" else trace_ds)), self.unit) + self.log_ds = self.file.add_value_vector(self.name, {"x":self.file.get_dataset(self.x_ds_url),"y":self.file.get_dataset(self.y_ds_url),"n":trace_ds}[self.signature], self.unit, dtype=self.dtype) elif len(self.signature) == 2: - self.log_ds = self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit) + self.log_ds = self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit, dtype=self.dtype) elif len(self.signature) == 3: - self.log_ds = self.file.add_value_box(self.name, self.file.get_dataset(self.x_ds_url), self.file.get_dataset(self.y_ds_url), trace_ds, self.unit) + self.log_ds = self.file.add_value_box(self.name, self.file.get_dataset(self.x_ds_url), self.file.get_dataset(self.y_ds_url), trace_ds, self.unit, dtype=self.dtype) if "x" in self.signature: self.x_len = self.file.get_dataset(self.x_ds_url).ds.shape[0] diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 521f5481a..764f01483 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -37,7 +37,6 @@ from qkit.measure.logging_base import logFunc import qkit.measure.write_additional_files as waf - ################################################################## class spectrum(object): @@ -97,7 +96,7 @@ def __init__(self, vna, exp_name='', sample=None): self._scan_dim = None self._scan_time = False - def add_logger(self, func, name, unit, over_x=True, over_y=False, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): + def add_logger(self, func, name="log_param", unit="", dtype="f", over_x=True, over_y=False, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): """ Migration from set_log_function & set_log_function_2D: @@ -122,7 +121,7 @@ def b_trace(): Alternatively more options like logging over y-iterations aswell or skipping the x-iteration are possible now, see qkit/measure/logging_base for details. """ - self.log_init_params += [(func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file + self.log_init_params += [(func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file def clear_loggers(self): """ @@ -132,15 +131,83 @@ def clear_loggers(self): def set_log_function(self, func=None, name=None, unit=None, log_dtype=None): ''' - Obsolete since covered by 'add_logger'. + A function (object) can be passed to the measurement loop which is excecuted before every x iteration + but after executing the x_object setter in 2D measurements and before every line (but after setting + the x value) in 3D measurements. + The return value of the function of type float or similar is stored in a value vector in the h5 file. + + Call without any arguments to delete all log functions. The timestamp is automatically saved. + + func: function object in list form + name: name of logging parameter appearing in h5 file, default: 'log_param' + unit: unit of logging parameter, default: '' + log_dtype: h5 data type, default: 'f' (float32) ''' - logging.error("'set_log_function' is obsolete, 'add_logger' is used instead. See respective qkit/src/qkit/measure/spectroscopy function docs for how to migrate.") + #logging.error("'set_log_function' is obsolete, 'add_logger' is used instead. See respective qkit/src/qkit/measure/spectroscopy function docs for how to migrate.") + if name == None: + try: + name = ['log_param'] * len(func) + except Exception: + name = None + if unit == None: + try: + unit = [''] * len(func) + except Exception: + unit = None + if log_dtype == None: + try: + log_dtype = ['f'] * len(func) + except Exception: + log_dtype = None + + # remove all previously set 1d loggers but keep 2d loggers depending on generalizd is_trace parameter + self.log_init_params = [elm for elm in self.log_init_params if elm[6]] + + if func == None: + return + + for i in range(len(func)): + self.add_logger(func[i], name[i], unit[i], log_dtype[i]) def set_log_function_2D(self, func=None, name=None, unit=None, y=None, y_name=None, y_unit=None, log_dtype=None): ''' - Obsolete since covered by 'add_logger'. + A function (object) can be passed to the measurement loop which is excecuted before every x iteration + but after executing the x_object setter in 2D measurements and before every line (but after setting + the x value) in 3D measurements. + The return values of the function of type 1D-list or similar is stored in a value matrix in the h5 file. + + Call without any arguments to delete all log functions. The timestamp is automatically saved. + + func: function object in list form, returning a list each + name: name of logging parameter appearing in h5 file, default: 'log_param' + unit: unit of logging parameter, default: '' + log_dtype: h5 data type, default: 'f' (float32) ''' - logging.error("'set_log_function_2D' is obsolete, 'add_logger' is used instead. See respective qkit/src/qkit/measure/spectroscopy function docs for how to migrate.") + #logging.error("'set_log_function_2D' is obsolete, 'add_logger' is used instead. See respective qkit/src/qkit/measure/spectroscopy function docs for how to migrate.") + if name == None: + try: + name = ['log_param'] * len(func) + except Exception: + name = None + if unit == None: + try: + unit = [''] * len(func) + except Exception: + unit = None + if log_dtype == None: + try: + log_dtype = ['f'] * len(func) + except Exception: + log_dtype = None + + # remove all previously set 2d loggers but keep 1d loggers depending on generalized is_trace parameter + self.log_init_params = [elm for elm in self.log_init_params if not elm[6]] + + if func == None: + return + + for i in range(len(func)): + self.add_logger(func[i], name[i], unit[i], log_dtype[i], True, False, True, y[i], y_name[i], y_unit[i]) def set_x_parameters(self, x_vec, x_coordname, x_set_obj, x_unit=""): """ @@ -314,8 +381,8 @@ def _prepare_measurement_file(self): self._pha_view.add(self._fit_freq, self._fit_pha) for init_tuple in self.log_init_params: - func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple - self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, + func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple + self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, dtype, self._data_x.ds_url if (self._scan_dim >= 2) and over_x else None, self._data_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index ac3c73d90..44b8f344d 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -668,7 +668,7 @@ def set_xy_parameters(self, x_name, x_func, x_vec, x_unit, y_name, y_func, y_uni self._x_dt = x_dt return - def add_logger(self, func, name, unit, over_x=True, over_y=True, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): + def add_logger(self, func, name="log_param", unit="", dtype="f", over_x=True, over_y=True, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): """ Migration from set_log_function: @@ -688,9 +688,85 @@ def a(): Alternatively more options like logging traces or skipping the x-iteration are possible now, see qkit/measure/logging_base for details. """ - self.log_init_params += [(func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file + self.log_init_params += [(func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file + + def set_log_function(self, func=None, name=None, unit=None, dtype='f'): + """ + Saves desired values obtained by a function in the .h5-file as a value vector with name , unit and in data format . + The function (object) can be passed to the measurement loop which is executed before every x iteration + but after executing the x_object setter in 2D measurements and before every line (but after setting + the x value) in 3D measurements. + The return value of the function of type float or similar is stored in a value vector in the h5 file. + + Parameters + ---------- + func: array_likes of callable objects + A callable object that returns the value to be saved. + name: array_likes of strings + Names of logging parameter appearing in h5 file. Default is 'log_param'. + unit: array_likes of strings + Units of logging parameter. Default is ''. + dtype: array_likes of dtypes + h5 data type to be used in the data file. Default is 'f' (float64). + + Returns + ------- + None + """ + # TODO: dtype = float instead of 'f' + # log-function & general case selection of one logger, multiple or reset + if callable(func): + self.add_logger(func, "log_param" if name is None else name, "" if unit is None else unit, dtype) + elif func is None: + self.reset_log_function() + return + elif np.iterable(func): + for fun in func: + if not callable(fun): + raise ValueError('{:s}: Cannot set {!s} as y-function: callable object needed'.format(__name__, fun)) + else: + raise ValueError('{:s}: Cannot set {!s} as log-function: callable object or iterable object of callable objects needed'.format(__name__, func)) + # continue with multiple loggers handling + + # log-name + if name is None: + name = ['log_param_{}'.format(i) for i in range(len(func))] + elif np.iterable(name): + for _name in name: + if type(_name) is not str: + raise ValueError('{:s}: Cannot set {!s} as log-name: string needed'.format(__name__, _name)) + else: + raise ValueError('{:s}: Cannot set {!s} as log-name: string or iterable object of strings needed'.format(__name__, name)) + + # log-unit + if unit is None: + unit = ['']*len(func) + elif type(unit) is str: + unit = [unit]*len(func) + elif np.iterable(unit): + for _unit in unit: + if type(_unit) is not str: + raise ValueError('{:s}: Cannot set {!s} as log-unit: string needed'.format(__name__, _unit)) + else: + raise ValueError('{:s}: Cannot set {!s} as log-unit: string or iterable object of strings needed'.format(__name__, unit)) + + # log-dtype + if dtype is None: + dtype = ["f"]*len(func) + elif type(dtype) is str: + dtype = [dtype]*len(func) + elif np.iterable(dtype): + for _dtype in dtype: + if type(_dtype) is not str: + raise ValueError('{:s}: Cannot set {!s} as log-dtype: string needed'.format(__name__, _dtype)) + else: + raise ValueError('{:s}: Cannot set {!s} as log-dtype: string of iterable object of strings needed'.format(__name__, dtype)) + + # add iterables + for i in range(len(func)): + self.add_logger(func[i], name[i], unit[i], dtype[i]) - def clear_loggers(self): + def reset_log_function(self): """ Clear all set log functions """ From 94cbfb941df710ff21ab036d58c175d63a09a5d5 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 27 May 2025 16:37:10 +0200 Subject: [PATCH 20/43] restored old resonator/circlefit implementations, made new resonator circlefit depend on old mathematical implementation instead, untested --- src/qkit/analysis/circle_fit/__init__.py | 11 + .../circle_fit/circle_fit_2019/__init__.py | 0 .../circle_fit/circle_fit_2019/circuit.py | 666 ++++++++++++++ .../circle_fit/circle_fit_classic/__init__.py | 0 .../circle_fit_classic/calibration.py | 71 ++ .../circle_fit_classic/circlefit.py | 396 ++++++++ .../circle_fit/circle_fit_classic/circuit.py | 501 ++++++++++ .../circle_fit_classic/utilities.py | 187 ++++ src/qkit/analysis/resonator.py | 862 ++++++++++++++++++ src/qkit/analysis/resonator_fitting.py | 728 ++------------- src/qkit/measure/spectroscopy/spectroscopy.py | 2 +- 11 files changed, 2760 insertions(+), 664 deletions(-) create mode 100644 src/qkit/analysis/circle_fit/__init__.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_2019/__init__.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/__init__.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py create mode 100644 src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py create mode 100644 src/qkit/analysis/resonator.py diff --git a/src/qkit/analysis/circle_fit/__init__.py b/src/qkit/analysis/circle_fit/__init__.py new file mode 100644 index 000000000..dc7f8c971 --- /dev/null +++ b/src/qkit/analysis/circle_fit/__init__.py @@ -0,0 +1,11 @@ +import qkit +import logging + +circle_fit_version = qkit.cfg.get("circle_fit_version", 1) + +if circle_fit_version == 1: + from .circle_fit_classic import calibration, circlefit, circuit, utilities +elif circle_fit_version == 2: + from .circle_fit_2019 import circuit +else: + logging.warning("Circle fit version not properly set in configuration!") \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_2019/__init__.py b/src/qkit/analysis/circle_fit/circle_fit_2019/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py new file mode 100644 index 000000000..86611b216 --- /dev/null +++ b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +@author: dennis.rieger@kit.edu / 2019 + +inspired by and based on resonator tools of Sebastian Probst +https://github.com/sebastianprobst/resonator_tools +""" + +import numpy as np +import logging +import scipy.optimize as spopt +from scipy import stats +from scipy.interpolate import splrep, splev +from scipy.ndimage.filters import gaussian_filter1d +plot_enable = False +try: + import qkit + if qkit.module_available("matplotlib"): + import matplotlib.pyplot as plt + plot_enable = True +except (ImportError, AttributeError): + try: + import matplotlib.pyplot as plt + plot_enable = True + except ImportError: + plot_enable = False + +class circuit: + """ + Base class for common routines and definitions shared between both ports. + + inputs: + - f_data: Frequencies for which scattering data z_data_raw is taken + - z_data_raw: Measured values for scattering parameter S11 or S21 taken at + frequencies f_data + """ + + def __init__(self, f_data, z_data_raw=None): + self.f_data = np.array(f_data) + self.z_data_raw = np.array(z_data_raw) + self.z_data_norm = None + + self.fitresults = {} + + self.fit_delay_max_iterations = 5 + + @classmethod + def Sij(cls, f, fr, Ql, Qc, phi=0., a=1., alpha=0., delay=0.): + """ + Full model for S11 of single-port reflection measurements or S21 of + notch configuration measurements. The models only differ in a factor of + 2 which is set by the dedicated port classes inheriting from this class. + + inputs: + - fr: Resonance frequency + - Ql: Loaded quality factor + - Qc: Coupling (aka external) quality factor. Calculated with + diameter correction method, i.e. 1/Qc = Re{1/|Qc| * exp(i*phi)} + - phi (opt.): Angle of the circle's rotation around the off-resonant + point due to impedance mismatches or Fano interference. + - a, alpha (opt.): Arbitrary scaling and rotation of the circle w.r.t. + origin + - delay (opt.): Time delay between output and input signal leading to + linearly frequency dependent phase shift + """ + complexQc = Qc*np.cos(phi)*np.exp(-1j*phi) + return a*np.exp(1j*(alpha-2*np.pi*f*delay)) * ( + 1. - 2.*Ql / (complexQc * cls.n_ports * (1. + 2j*Ql*(f/fr-1.))) + ) + + def autofit(self, calc_errors=True, fixed_delay=None, isolation=15): + """ + Automatically calibrate data, normalize it and extract quality factors. + If the autofit fails or the results look bad, please discuss with + author. + """ + + if fixed_delay is None: + self._fit_delay() + else: + self.delay = fixed_delay + # Store result in dictionary (also for backwards-compatibility) + self.fitresults["delay"] = self.delay + self._calibrate() + self._normalize() + self._extract_Qs(calc_errors=calc_errors) + self.calc_fano_range(isolation=isolation) + + # Prepare model data for plotting + self.z_data_sim = self.Sij( + self.f_data, self.fr, self.Ql, self.Qc, self.phi, + self.a, self.alpha, self.delay + ) + self.z_data_sim_norm = self.Sij( + self.f_data, self.fr, self.Ql, self.Qc, self.phi + ) + + def _fit_delay(self): + """ + Finds the cable delay by repeatedly centering the "circle" and fitting + the slope of the phase response. + """ + + # Translate data to origin + xc, yc, r0 = self._fit_circle(self.z_data_raw) + z_data = self.z_data_raw - complex(xc, yc) + # Find first estimate of parameters + fr, Ql, theta, self.delay = self._fit_phase(z_data) + + # Do not overreact (see end of for loop) + self.delay *= 0.05 + + # Iterate to improve result for delay + for i in range(self.fit_delay_max_iterations): + # Translate new best fit data to origin + z_data = self.z_data_raw * np.exp(2j*np.pi*self.delay*self.f_data) + xc, yc, r0 = self._fit_circle(z_data) + z_data -= complex(xc, yc) + + # Find correction to current delay + guesses = (fr, Ql, 5e-11) + fr, Ql, theta, delay_corr = self._fit_phase(z_data, guesses) + + # Stop if correction would be smaller than "measurable" + phase_fit = self.phase_centered(self.f_data, fr, Ql, theta, delay_corr) + residuals = np.unwrap(np.angle(z_data)) - phase_fit + if 2*np.pi*(self.f_data[-1]-self.f_data[0])*delay_corr <= np.std(residuals): + break + + # Avoid overcorrection that makes procedure switch between positive + # and negative delays + if delay_corr*self.delay < 0: # different sign -> be careful + if abs(delay_corr) > abs(self.delay): + self.delay *= 0.5 + else: + # delay += 0.1*delay_corr + self.delay += 0.1*np.sign(delay_corr)*5e-11 + else: # same direction -> can converge faster + if abs(delay_corr) >= 1e-8: + self.delay += min(delay_corr, self.delay) + elif abs(delay_corr) >= 1e-9: + self.delay *= 1.1 + else: + self.delay += delay_corr + + if 2*np.pi*(self.f_data[-1]-self.f_data[0])*delay_corr > np.std(residuals): + logging.warning( + "Delay could not be fit properly!" + ) + + # Store result in dictionary (also for backwards-compatibility) + self.fitresults["delay"] = self.delay + + def _calibrate(self): + """ + Finds the parameters for normalization of the scattering data. See + Sij for explanation of parameters. + """ + + # Correct for delay and translate circle to origin + z_data = self.z_data_raw * np.exp(2j*np.pi*self.delay*self.f_data) + xc, yc, self.r0 = self._fit_circle(z_data) + zc = complex(xc, yc) + z_data -= zc + + # Find off-resonant point by fitting offset phase + # (centered circle corresponds to lossless resonator in reflection) + self.fr, self.Ql, theta, self.delay_remaining = self._fit_phase(z_data) + self.theta = self._periodic_boundary(theta) + beta = self._periodic_boundary(theta - np.pi) + offrespoint = zc + self.r0*np.cos(beta) + 1j*self.r0*np.sin(beta) + self.offrespoint = offrespoint + self.a = np.absolute(offrespoint) + self.alpha = np.angle(offrespoint) + self.phi = self._periodic_boundary(beta - self.alpha) + + # Store radius for later calculation + self.r0 /= self.a + + # Store results in dictionary (also for backwards-compatibility) + self.fitresults.update({ + "delay_remaining": self.delay_remaining, + "a": self.a, + "alpha": self.alpha, + "theta": self.theta, + "phi": self.phi, + "fr": self.fr, + "Ql": self.Ql + }) + + def _normalize(self): + """ + Transforms scattering data into canonical position with off-resonant + point at (1, 0) (does not correct for rotation phi of circle around + off-resonant point). + """ + self.z_data_norm = self.z_data_raw / self.a*np.exp( + 1j*(-self.alpha + 2.*np.pi*self.delay*self.f_data) + ) + + def _extract_Qs(self, refine_results=False, calc_errors=True): + """ + Calculates Qc and Qi from radius of circle. All needed info is known + already from the calibration procedure. + """ + + self.absQc = self.Ql / (self.n_ports*self.r0) + # For Qc, take real part of 1/(complex Qc) (diameter correction method) + self.Qc = self.absQc / np.cos(self.phi) + self.Qi = 1. / (1./self.Ql - 1./self.Qc) + self.Qi_no_dia_corr = 1. / (1./self.Ql - 1./self.absQc) + + # Store results in dictionary (also for backwards-compatibility) + self.fitresults.update({ + "fr": self.fr, + "Ql": self.Ql, + "Qc": self.Qc, + "Qc_no_dia_corr": self.absQc, + "Qi": self.Qi, + "Qi_no_dia_corr": self.Qi_no_dia_corr , + }) + + # Calculate errors if wanted + if calc_errors: + chi_square, cov = self._get_covariance() + + if cov is not None: + fr_err, Ql_err, absQc_err, phi_err = np.sqrt(np.diag(cov)) + # Calculate error of Qi with error propagation + # without diameter correction + dQl = 1. / ((1./self.Ql - 1./self.absQc) * self.Ql)**2 + dabsQc = -1. / ((1./self.Ql - 1./self.absQc) * self.absQc)**2 + Qi_no_dia_corr_err = np.sqrt( + dQl**2*cov[1][1] + + dabsQc**2*cov[2][2] + + 2.*dQl*dabsQc*cov[1][2] + ) + # with diameter correction + dQl = 1. / ((1./self.Ql - 1./self.Qc) * self.Ql)**2 + dabsQc = -np.cos(self.phi) / ( + (1./self.Ql - 1./self.Qc) * self.absQc + )**2 + dphi = -np.sin(self.phi) / ( + (1./self.Ql - 1./self.Qc)**2 * self.absQc + ) + Qi_err = np.sqrt( + dQl**2*cov[1][1] + + dabsQc**2*cov[2][2] + + dphi**2*cov[3][3] + + 2*( + dQl*dabsQc*cov[1][2] + + dQl*dphi*cov[1][3] + + dabsQc*dphi*cov[2][3] + ) + ) + self.fitresults.update({ + "fr_err": fr_err, + "Ql_err": Ql_err, + "absQc_err": absQc_err, + "phi_err": phi_err, + "Qi_err": Qi_err, + "Qi_no_dia_corr_err": Qi_no_dia_corr_err, + "chi_square": chi_square + }) + else: + logging.warning("Error calculation failed!") + else: + # Just calculate reduced chi square (4 fit parameters reduce degrees + # of freedom) + self.fitresults["chi_square"] = (1. / (len(self.f_data) - 4.) + * np.sum(np.abs(self._get_residuals_reflection)**2)) + + def calc_fano_range(self, isolation=15, b=None): + """ + Calculates the systematic Qi (and Qc) uncertainty range based on + Fano interference with given strength of the background path + (cf. Rieger & Guenzler et al., arXiv:2209.03036). + + inputs: either of + - isolation (dB): Suppression of the interference path by this value. + The corresponding relative background amplitude b + is calculated with b = 10**(-isolation/20). + - b (lin): Relative background path amplitude of Fano. + + outputs (added to fitresults dictionary): + - Qi_min, Qi_max: Systematic uncertainty range for Qi + - Qc_min, Qc_max: Systematic uncertainty range for Qc + - fano_b: Relative background path amplitude of Fano. + """ + + if b is None: + b = 10**(-isolation/20) + + b = b / (1 - b) + + if np.sin(self.phi) > b: + logging.warning( + "Measurement cannot be explained with assumed Fano leakage!" + ) + self.Qi_min = np.nan + self.Qi_max = np.nan + self.Qc_min = np.nan + self.Qc_max = np.nan + + # Calculate error on radius of circle + R_mid = self.r0 * np.cos(self.phi) + R_err = self.r0 * np.sqrt(b**2 - np.sin(self.phi)**2) + R_min = R_mid - R_err + R_max = R_mid + R_err + + # Convert to ranges of quality factors + self.Qc_min = self.Ql / (self.n_ports*R_max) + self.Qc_max = self.Ql / (self.n_ports*R_min) + self.Qi_min = self.Ql / (1 - self.n_ports*R_min) + self.Qi_max = self.Ql / (1 - self.n_ports*R_max) + + # Handle unphysical results + if R_max >= 1./self.n_ports: + self.Qi_max = np.inf + + # Store results in dictionary + self.fitresults.update({ + "Qc_min": self.Qc_min, + "Qc_max": self.Qc_max, + "Qi_min": self.Qi_min, + "Qi_max": self.Qi_max, + "fano_b": b + }) + + def _fit_circle(self, z_data, refine_results=False): + """ + Analytical fit of a circle to the scattering data z_data. Cf. Sebastian + Probst: "Efficient and robust analysis of complex scattering data under + noise in microwave resonators" (arXiv:1410.3365v2) + """ + + # Normalize circle to deal with comparable numbers + x_norm = 0.5*(np.max(z_data.real) + np.min(z_data.real)) + y_norm = 0.5*(np.max(z_data.imag) + np.min(z_data.imag)) + z_data = z_data[:] - (x_norm + 1j*y_norm) + amp_norm = np.max(np.abs(z_data)) + z_data = z_data / amp_norm + + # Calculate matrix of moments + xi = z_data.real + xi_sqr = xi*xi + yi = z_data.imag + yi_sqr = yi*yi + zi = xi_sqr+yi_sqr + Nd = float(len(xi)) + xi_sum = xi.sum() + yi_sum = yi.sum() + zi_sum = zi.sum() + xiyi_sum = (xi*yi).sum() + xizi_sum = (xi*zi).sum() + yizi_sum = (yi*zi).sum() + M = np.array([ + [(zi*zi).sum(), xizi_sum, yizi_sum, zi_sum], + [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum], + [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum], + [zi_sum, xi_sum, yi_sum, Nd] + ]) + + # Lets skip line breaking at 80 characters for a moment :D + a0 = ((M[2][0]*M[3][2]-M[2][2]*M[3][0])*M[1][1]-M[1][2]*M[2][0]*M[3][1]-M[1][0]*M[2][1]*M[3][2]+M[1][0]*M[2][2]*M[3][1]+M[1][2]*M[2][1]*M[3][0])*M[0][3]+(M[0][2]*M[2][3]*M[3][0]-M[0][2]*M[2][0]*M[3][3]+M[0][0]*M[2][2]*M[3][3]-M[0][0]*M[2][3]*M[3][2])*M[1][1]+(M[0][1]*M[1][3]*M[3][0]-M[0][1]*M[1][0]*M[3][3]-M[0][0]*M[1][3]*M[3][1])*M[2][2]+(-M[0][1]*M[1][2]*M[2][3]-M[0][2]*M[1][3]*M[2][1])*M[3][0]+((M[2][3]*M[3][1]-M[2][1]*M[3][3])*M[1][2]+M[2][1]*M[3][2]*M[1][3])*M[0][0]+(M[1][0]*M[2][3]*M[3][2]+M[2][0]*(M[1][2]*M[3][3]-M[1][3]*M[3][2]))*M[0][1]+((M[2][1]*M[3][3]-M[2][3]*M[3][1])*M[1][0]+M[1][3]*M[2][0]*M[3][1])*M[0][2] + a1 = (((M[3][0]-2.*M[2][2])*M[1][1]-M[1][0]*M[3][1]+M[2][2]*M[3][0]+2.*M[1][2]*M[2][1]-M[2][0]*M[3][2])*M[0][3]+(2.*M[2][0]*M[3][2]-M[0][0]*M[3][3]-2.*M[2][2]*M[3][0]+2.*M[0][2]*M[2][3])*M[1][1]+(-M[0][0]*M[3][3]+2.*M[0][1]*M[1][3]+2.*M[1][0]*M[3][1])*M[2][2]+(-M[0][1]*M[1][3]+2.*M[1][2]*M[2][1]-M[0][2]*M[2][3])*M[3][0]+(M[1][3]*M[3][1]+M[2][3]*M[3][2])*M[0][0]+(M[1][0]*M[3][3]-2.*M[1][2]*M[2][3])*M[0][1]+(M[2][0]*M[3][3]-2.*M[1][3]*M[2][1])*M[0][2]-2.*M[1][2]*M[2][0]*M[3][1]-2.*M[1][0]*M[2][1]*M[3][2]) + a2 = ((2.*M[1][1]-M[3][0]+2.*M[2][2])*M[0][3]+(2.*M[3][0]-4.*M[2][2])*M[1][1]-2.*M[2][0]*M[3][2]+2.*M[2][2]*M[3][0]+M[0][0]*M[3][3]+4.*M[1][2]*M[2][1]-2.*M[0][1]*M[1][3]-2.*M[1][0]*M[3][1]-2.*M[0][2]*M[2][3]) + a3 = (-2.*M[3][0]+4.*M[1][1]+4.*M[2][2]-2.*M[0][3]) + a4 = -4. + + def char_pol(x): + return a0 + a1*x + a2*x**2 + a3*x**3 + a4*x**4 + + def d_char_pol(x): + return a1 + 2*a2*x + 3*a3*x**2 + 4*a4*x**3 + + eta = spopt.newton(char_pol, 0., fprime=d_char_pol) + + M[3][0] = M[3][0] + 2*eta + M[0][3] = M[0][3] + 2*eta + M[1][1] = M[1][1] - eta + M[2][2] = M[2][2] - eta + + U,s,Vt = np.linalg.svd(M) + A_vec = Vt[np.argmin(s),:] + + xc = -A_vec[1]/(2.*A_vec[0]) + yc = -A_vec[2]/(2.*A_vec[0]) + # The term *sqrt term corrects for the constraint, because it may be + # altered due to numerical inaccuracies during calculation + r0 = 1./(2.*np.absolute(A_vec[0]))*np.sqrt( + A_vec[1]*A_vec[1]+A_vec[2]*A_vec[2]-4.*A_vec[0]*A_vec[3] + ) + + return xc*amp_norm+x_norm, yc*amp_norm+y_norm, r0*amp_norm + + def _fit_phase(self, z_data, guesses=None): + """ + Fits the phase response of a strongly overcoupled (Qi >> Qc) resonator + in reflection which corresponds to a circle centered around the origin + (cf‌. phase_centered()). + + inputs: + - z_data: Scattering data of which the phase should be fit. Data must be + distributed around origin ("circle-like"). + - guesses (opt.): If not given, initial guesses for the fit parameters + will be determined. If given, should contain useful + guesses for fit parameters as a tuple (fr, Ql, delay) + + outputs: + - fr: Resonance frequency + - Ql: Loaded quality factor + - theta: Offset phase + - delay: Time delay between output and input signal leading to linearly + frequency dependent phase shift + """ + phase = np.unwrap(np.angle(z_data)) + + # For centered circle roll-off should be close to 2pi. If not warn user. + if np.max(phase) - np.min(phase) <= 0.8*2*np.pi: + logging.warning( + "Data does not cover a full circle (only {:.1f}".format( + np.max(phase) - np.min(phase) + ) + +" rad). Increase the frequency span around the resonance?" + ) + roll_off = np.max(phase) - np.min(phase) + else: + roll_off = 2*np.pi + + # Set useful starting parameters + if guesses is None: + # Use maximum of derivative of phase as guess for fr + phase_smooth = gaussian_filter1d(phase, 30) + phase_derivative = np.gradient(phase_smooth) + fr_guess = self.f_data[np.argmax(np.abs(phase_derivative))] + Ql_guess = 2*fr_guess / (self.f_data[-1] - self.f_data[0]) + # Estimate delay from background slope of phase (substract roll-off) + slope = phase[-1] - phase[0] + roll_off + delay_guess = -slope / (2*np.pi*(self.f_data[-1]-self.f_data[0])) + else: + fr_guess, Ql_guess, delay_guess = guesses + # This one seems stable and we do not need a manual guess for it + theta_guess = 0.5*(np.mean(phase[:5]) + np.mean(phase[-5:])) + + # Fit model with less parameters first to improve stability of fit + + def residuals_Ql(params): + Ql, = params + return residuals_full((fr_guess, Ql, theta_guess, delay_guess)) + def residuals_fr_theta(params): + fr, theta = params + return residuals_full((fr, Ql_guess, theta, delay_guess)) + def residuals_delay(params): + delay, = params + return residuals_full((fr_guess, Ql_guess, theta_guess, delay)) + def residuals_fr_Ql(params): + fr, Ql = params + return residuals_full((fr, Ql, theta_guess, delay_guess)) + def residuals_full(params): + return self._phase_dist( + phase - circuit.phase_centered(self.f_data, *params) + ) + + p_final = spopt.leastsq(residuals_Ql, [Ql_guess]) + Ql_guess, = p_final[0] + p_final = spopt.leastsq(residuals_fr_theta, [fr_guess, theta_guess]) + fr_guess, theta_guess = p_final[0] + p_final = spopt.leastsq(residuals_delay, [delay_guess]) + delay_guess, = p_final[0] + p_final = spopt.leastsq(residuals_fr_Ql, [fr_guess, Ql_guess]) + fr_guess, Ql_guess = p_final[0] + p_final = spopt.leastsq(residuals_full, [ + fr_guess, Ql_guess, theta_guess, delay_guess + ]) + + return p_final[0] + + @classmethod + def phase_centered(cls, f, fr, Ql, theta, delay=0.): + """ + Yields the phase response of a strongly overcoupled (Qi >> Qc) resonator + in reflection which corresponds to a circle centered around the origin. + Additionally, a linear background slope is accounted for if needed. + + inputs: + - fr: Resonance frequency + - Ql: Loaded quality factor (and since Qi >> Qc also Ql = Qc) + - theta: Offset phase + - delay (opt.): Time delay between output and input signal leading to + linearly frequency dependent phase shift + """ + return theta - 2*np.pi*delay*(f-fr) + 2.*np.arctan(2.*Ql*(1. - f/fr)) + + def _phase_dist(self, angle): + """ + Maps angle [-2pi, +2pi] to phase distance on circle [0, pi] + """ + return np.pi - np.abs(np.pi - np.abs(angle)) + + def _periodic_boundary(self, angle): + """ + Maps arbitrary angle to interval [-np.pi, np.pi) + """ + return (angle + np.pi) % (2*np.pi) - np.pi + + def _get_residuals(self): + """ + Calculates deviation of measured data from fit. + """ + return self.z_data_norm - self.Sij( + self.f_data, self.fr, self.Ql, self.Qc, self.phi + ) + + def _get_covariance(self): + """ + Calculates reduced chi square and covariance matrix for fit. + """ + residuals = self._get_residuals() + chi = np.abs(residuals) + # Unit vectors pointing in the correct directions for the derivative + directions = residuals / chi + # Prepare for fast construction of Jacobian + conj_directions = np.conj(directions) + + # Construct transpose of Jacobian matrix + Jt = np.array([ + np.real(self._dSij_dfr()*conj_directions), + np.real(self._dSij_dQl()*conj_directions), + np.real(self._dSij_dabsQc()*conj_directions), + np.real(self._dSij_dphi()*conj_directions) + ]) + A = np.dot(Jt, np.transpose(Jt)) + # 4 fit parameters reduce degrees of freedom for reduced chi square + chi_square = 1./float(len(self.f_data)-4) * np.sum(chi**2) + try: + cov = np.linalg.inv(A)*chi_square + except: + cov = None + return chi_square, cov + + def _dSij_dfr(self): + """ + Derivative of Sij w.r.t. fr + """ + return -4j*self.Ql**2*np.exp(1j*self.phi)*self.f_data / ( + self.n_ports * self.absQc*(self.fr+2j*self.Ql*(self.f_data-self.fr))**2 + ) + + def _dSij_dQl(self): + """ + Derivative of Sij w.r.t. Ql + """ + return -2.*np.exp(1j*self.phi) / ( + self.n_ports * self.absQc*(1.+2j*self.Ql*(self.f_data/self.fr-1))**2 + ) + + def _dSij_dabsQc(self): + """ + Derivative of Sij w.r.t. absQc + """ + return 2.*self.Ql*np.exp(1j*self.phi) / ( + self.n_ports * self.absQc**2 * (1.+2j*self.Ql*(self.f_data/self.fr-1)) + ) + + def _dSij_dphi(self): + """ + Derivative of Sij w.r.t. phi + """ + return -2j*self.Ql*np.exp(1j*self.phi) / ( + self.n_ports * self.absQc * (1.+2j*self.Ql*(self.f_data/self.fr-1)) + ) + + """ + Functions for plotting results + """ + def plotall(self): + if not plot_enable: + raise ImportError("matplotlib not found") + real = self.z_data_raw.real + imag = self.z_data_raw.imag + real2 = self.z_data_sim.real + imag2 = self.z_data_sim.imag + plt.subplot(221, aspect="equal") + plt.axvline(0, c="k", ls="--", lw=1) + plt.axhline(0, c="k", ls="--", lw=1) + plt.plot(real,imag,label='rawdata') + plt.plot(real2,imag2,label='fit') + plt.xlabel('Re(S21)') + plt.ylabel('Im(S21)') + plt.legend() + plt.subplot(222) + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_sim),label='fit') + plt.xlabel('f (GHz)') + plt.ylabel('|S21|') + plt.legend() + plt.subplot(223) + plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') + plt.plot(self.f_data*1e-9,np.angle(self.z_data_sim),label='fit') + plt.xlabel('f (GHz)') + plt.ylabel('arg(|S21|)') + plt.legend() + plt.show() + + def plotcalibrateddata(self): + if not plot_enable: + raise ImportError("matplotlib not found") + real = self.z_data_norm.real + imag = self.z_data_norm.imag + plt.subplot(221) + plt.plot(real,imag,label='rawdata') + plt.xlabel('Re(S21)') + plt.ylabel('Im(S21)') + plt.legend() + plt.subplot(222) + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_norm),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('|S21|') + plt.legend() + plt.subplot(223) + plt.plot(self.f_data*1e-9,np.angle(self.z_data_norm),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('arg(|S21|)') + plt.legend() + plt.show() + + def plotrawdata(self): + if not plot_enable: + raise ImportError("matplotlib not found") + real = self.z_data_raw.real + imag = self.z_data_raw.imag + plt.subplot(221) + plt.plot(real,imag,label='rawdata') + plt.xlabel('Re(S21)') + plt.ylabel('Im(S21)') + plt.legend() + plt.subplot(222) + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('|S21|') + plt.legend() + plt.subplot(223) + plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('arg(|S21|)') + plt.legend() + plt.show() + +class reflection_port(circuit): + """ + Circlefit class for single-port resonator probed in reflection. + """ + + # See Sij of circuit class for explanation + n_ports = 1. + +class notch_port(circuit): + """ + Circlefit class for two-port resonator probed in transmission. + """ + + # See Sij of circuit class for explanation + n_ports = 2. \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/__init__.py b/src/qkit/analysis/circle_fit/circle_fit_classic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py b/src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py new file mode 100644 index 000000000..3d81952d4 --- /dev/null +++ b/src/qkit/analysis/circle_fit/circle_fit_classic/calibration.py @@ -0,0 +1,71 @@ + +import numpy as np +from scipy import sparse +from scipy.interpolate import interp1d + +class calibration(object): + ''' + some useful tools for manual calibration + ''' + def normalize_zdata(self,z_data,cal_z_data): + return z_data/cal_z_data + + def normalize_amplitude(self,z_data,cal_ampdata): + return z_data/cal_ampdata + + def normalize_phase(self,z_data,cal_phase): + return z_data*np.exp(-1j*cal_phase) + + def normalize_by_func(self,f_data,z_data,func): + return z_data/func(f_data) + + def _baseline_als(self,y, lam, p, niter=10): + ''' + see http://zanran_storage.s3.amazonaws.com/www.science.uva.nl/ContentPages/443199618.pdf + "Asymmetric Least Squares Smoothing" by P. Eilers and H. Boelens in 2005. + http://stackoverflow.com/questions/29156532/python-baseline-correction-library + "There are two parameters: p for asymmetry and lambda for smoothness. Both have to be + tuned to the data at hand. We found that generally 0.001<=p<=0.1 is a good choice + (for a signal with positive peaks) and 10e2<=lambda<=10e9, but exceptions may occur." + ''' + L = len(y) + D = sparse.csc_matrix(np.diff(np.eye(L), 2)) + w = np.ones(L) + for i in xrange(niter): + W = sparse.spdiags(w, 0, L, L) + Z = W + lam * D.dot(D.transpose()) + z = sparse.linalg.spsolve(Z, w*y) + w = p * (y > z) + (1-p) * (y < z) + return z + + def fit_baseline_amp(self,z_data,lam,p,niter=10): + ''' + for this to work, you need to analyze a large part of the baseline + tune lam and p until you get the desired result + ''' + return self._baseline_als(np.absolute(z_data),lam,p,niter=niter) + + def baseline_func_amp(self,z_data,f_data,lam,p,niter=10): + ''' + for this to work, you need to analyze a large part of the baseline + tune lam and p until you get the desired result + returns the baseline as a function + the points in between the datapoints are computed by cubic interpolation + ''' + return interp1d(f_data, self._baseline_als(np.absolute(z_data),lam,p,niter=niter), kind='cubic') + + def baseline_func_phase(self,z_data,f_data,lam,p,niter=10): + ''' + for this to work, you need to analyze a large part of the baseline + tune lam and p until you get the desired result + returns the baseline as a function + the points in between the datapoints are computed by cubic interpolation + ''' + return interp1d(f_data, self._baseline_als(np.angle(z_data),lam,p,niter=niter), kind='cubic') + + def fit_baseline_phase(self,z_data,lam,p,niter=10): + ''' + for this to work, you need to analyze a large part of the baseline + tune lam and p until you get the desired result + ''' + return self._baseline_als(np.angle(z_data),lam,p,niter=niter) diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py b/src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py new file mode 100644 index 000000000..ca4f99799 --- /dev/null +++ b/src/qkit/analysis/circle_fit/circle_fit_classic/circlefit.py @@ -0,0 +1,396 @@ + +import numpy as np +import scipy.optimize as spopt +from scipy import stats + + +class circlefit(object): + ''' + contains all the circlefit procedures + see http://scitation.aip.org/content/aip/journal/rsi/86/2/10.1063/1.4907935 + arxiv version: http://arxiv.org/abs/1410.3365 + ''' + def _remove_cable_delay(self,f_data,z_data, delay): + return z_data/np.exp(2j*np.pi*f_data*delay) + + def _center(self,z_data,zc): + return z_data-zc + + def _dist(self,x): + np.absolute(x,x) + c = (x > np.pi).astype(np.int) + return x+c*(-2.*x+2.*np.pi) + + def _periodic_boundary(self,x,bound): + return np.fmod(x,bound)-np.trunc(x/bound)*bound + + def _phase_fit_wslope(self,f_data,z_data,theta0, Ql, fr, slope): + phase = np.angle(z_data) + def residuals(p,x,y): + theta0, Ql, fr, slope = p + err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr))-slope*x)) + return err + p0 = [theta0, Ql, fr, slope] + p_final = spopt.leastsq(residuals,p0,args=(np.array(f_data),np.array(phase))) + return p_final[0] + + def _phase_fit(self,f_data,z_data,theta0, Ql, fr): + phase = np.angle(z_data) + def residuals_1(p,x,y,Ql): + theta0, fr = p + err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) + return err + def residuals_2(p,x,y,theta0): + Ql, fr = p + err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) + return err + def residuals_3(p,x,y,theta0,Ql): + fr = p + err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) + return err + def residuals_4(p,x,y,theta0,fr): + Ql = p + err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) + return err + def residuals_5(p,x,y): + theta0, Ql, fr = p + err = self._dist(y - (theta0+2.*np.arctan(2.*Ql*(1.-x/fr)))) + return err + p0 = [theta0, fr] + p_final = spopt.leastsq(lambda a,b,c: residuals_1(a,b,c,Ql),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) + theta0, fr = p_final[0] + p0 = [Ql, fr] + p_final = spopt.leastsq(lambda a,b,c: residuals_2(a,b,c,theta0),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) + Ql, fr = p_final[0] + p0 = fr + p_final = spopt.leastsq(lambda a,b,c: residuals_3(a,b,c,theta0,Ql),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) + fr = float(p_final[0]) + p0 = Ql + p_final = spopt.leastsq(lambda a,b,c: residuals_4(a,b,c,theta0,fr),p0,args=(f_data,phase))#,ftol=1e-12,xtol=1e-12) + Ql = float(p_final[0]) + p0 = [theta0, Ql, fr] + p_final = spopt.leastsq(residuals_5,p0,args=(f_data,phase)) + return p_final[0] + + def _fit_skewed_lorentzian(self,f_data,z_data): + amplitude = np.absolute(z_data) + amplitude_sqr = amplitude**2 + A1a = np.minimum(amplitude_sqr[0],amplitude_sqr[-1]) + A3a = -np.max(amplitude_sqr) + fra = f_data[np.argmin(amplitude_sqr)] + def residuals(p,x,y): + A2, A4, Ql = p + err = y -(A1a+A2*(x-fra)+(A3a+A4*(x-fra))/(1.+4.*Ql**2*((x-fra)/fra)**2)) + return err + p0 = [0., 0., 1e3] + p_final = spopt.leastsq(residuals,p0,args=(np.array(f_data),np.array(amplitude_sqr))) + A2a, A4a, Qla = p_final[0] + + def residuals2(p,x,y): + A1, A2, A3, A4, fr, Ql = p + err = y -(A1+A2*(x-fr)+(A3+A4*(x-fr))/(1.+4.*Ql**2*((x-fr)/fr)**2)) + return err + p0 = [A1a, A2a , A3a, A4a, fra, Qla] + p_final = spopt.leastsq(residuals2,p0,args=(np.array(f_data),np.array(amplitude_sqr))) + #A1, A2, A3, A4, fr, Ql = p_final[0] + #print(p_final[0][5]) + return p_final[0] + + def _fit_circle(self,z_data, refine_results=False): + def calc_moments(z_data): + xi = z_data.real + xi_sqr = xi*xi + yi = z_data.imag + yi_sqr = yi*yi + zi = xi_sqr+yi_sqr + Nd = float(len(xi)) + xi_sum = xi.sum() + yi_sum = yi.sum() + zi_sum = zi.sum() + xiyi_sum = (xi*yi).sum() + xizi_sum = (xi*zi).sum() + yizi_sum = (yi*zi).sum() + return np.array([ [(zi*zi).sum(), xizi_sum, yizi_sum, zi_sum], \ + [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum], \ + [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum], \ + [zi_sum, xi_sum, yi_sum, Nd] ]) + + M = calc_moments(z_data) + + a0 = ((M[2][0]*M[3][2]-M[2][2]*M[3][0])*M[1][1]-M[1][2]*M[2][0]*M[3][1]-M[1][0]*M[2][1]*M[3][2]+M[1][0]*M[2][2]*M[3][1]+M[1][2]*M[2][1]*M[3][0])*M[0][3]+(M[0][2]*M[2][3]*M[3][0]-M[0][2]*M[2][0]*M[3][3]+M[0][0]*M[2][2]*M[3][3]-M[0][0]*M[2][3]*M[3][2])*M[1][1]+(M[0][1]*M[1][3]*M[3][0]-M[0][1]*M[1][0]*M[3][3]-M[0][0]*M[1][3]*M[3][1])*M[2][2]+(-M[0][1]*M[1][2]*M[2][3]-M[0][2]*M[1][3]*M[2][1])*M[3][0]+((M[2][3]*M[3][1]-M[2][1]*M[3][3])*M[1][2]+M[2][1]*M[3][2]*M[1][3])*M[0][0]+(M[1][0]*M[2][3]*M[3][2]+M[2][0]*(M[1][2]*M[3][3]-M[1][3]*M[3][2]))*M[0][1]+((M[2][1]*M[3][3]-M[2][3]*M[3][1])*M[1][0]+M[1][3]*M[2][0]*M[3][1])*M[0][2] + a1 = (((M[3][0]-2.*M[2][2])*M[1][1]-M[1][0]*M[3][1]+M[2][2]*M[3][0]+2.*M[1][2]*M[2][1]-M[2][0]*M[3][2])*M[0][3]+(2.*M[2][0]*M[3][2]-M[0][0]*M[3][3]-2.*M[2][2]*M[3][0]+2.*M[0][2]*M[2][3])*M[1][1]+(-M[0][0]*M[3][3]+2.*M[0][1]*M[1][3]+2.*M[1][0]*M[3][1])*M[2][2]+(-M[0][1]*M[1][3]+2.*M[1][2]*M[2][1]-M[0][2]*M[2][3])*M[3][0]+(M[1][3]*M[3][1]+M[2][3]*M[3][2])*M[0][0]+(M[1][0]*M[3][3]-2.*M[1][2]*M[2][3])*M[0][1]+(M[2][0]*M[3][3]-2.*M[1][3]*M[2][1])*M[0][2]-2.*M[1][2]*M[2][0]*M[3][1]-2.*M[1][0]*M[2][1]*M[3][2]) + a2 = ((2.*M[1][1]-M[3][0]+2.*M[2][2])*M[0][3]+(2.*M[3][0]-4.*M[2][2])*M[1][1]-2.*M[2][0]*M[3][2]+2.*M[2][2]*M[3][0]+M[0][0]*M[3][3]+4.*M[1][2]*M[2][1]-2.*M[0][1]*M[1][3]-2.*M[1][0]*M[3][1]-2.*M[0][2]*M[2][3]) + a3 = (-2.*M[3][0]+4.*M[1][1]+4.*M[2][2]-2.*M[0][3]) + a4 = -4. + + def func(x): + return a0+a1*x+a2*x*x+a3*x*x*x+a4*x*x*x*x + + def d_func(x): + return a1+2*a2*x+3*a3*x*x+4*a4*x*x*x + + x0 = spopt.fsolve(func, 0., fprime=d_func) + + def solve_eq_sys(val,M): + #prepare + M[3][0] = M[3][0]+2*val + M[0][3] = M[0][3]+2*val + M[1][1] = M[1][1]-val + M[2][2] = M[2][2]-val + return np.linalg.svd(M) + + U,s,Vt = solve_eq_sys(x0[0],M) + + A_vec = Vt[np.argmin(s),:] + + xc = -A_vec[1]/(2.*A_vec[0]) + yc = -A_vec[2]/(2.*A_vec[0]) + # the term *sqrt term corrects for the constraint, because it may be altered due to numerical inaccuracies during calculation + r0 = 1./(2.*np.absolute(A_vec[0]))*np.sqrt(A_vec[1]*A_vec[1]+A_vec[2]*A_vec[2]-4.*A_vec[0]*A_vec[3]) + if refine_results: + print("agebraic r0: " + str(r0)) + xc,yc,r0 = self._fit_circle_iter(z_data, xc, yc, r0) + r0 = self._fit_circle_iter_radialweight(z_data, xc, yc, r0) + print("iterative r0: " + str(r0)) + return xc, yc, r0 + + def _guess_delay(self,f_data,z_data): + phase2 = np.unwrap(np.angle(z_data)) + gradient, intercept, r_value, p_value, std_err = stats.linregress(f_data,phase2) + return gradient*(-1.)/(np.pi*2.) + + + def _fit_delay(self,f_data,z_data,delay=0.,maxiter=0): + def residuals(p,x,y): + phasedelay = p + z_data_temp = y*np.exp(1j*(2.*np.pi*phasedelay*x)) + xc,yc,r0 = self._fit_circle(z_data_temp) + err = np.sqrt((z_data_temp.real-xc)**2+(z_data_temp.imag-yc)**2)-r0 + return err + p_final = spopt.leastsq(residuals,delay,args=(f_data,z_data),maxfev=maxiter,ftol=1e-12,xtol=1e-12) + return p_final[0][0] + + def _fit_delay_alt_bigdata(self,f_data,z_data,delay=0.,maxiter=0): + def residuals(p,x,y): + phasedelay = p + z_data_temp = 1j*2.*np.pi*phasedelay*x + np.exp(z_data_temp,out=z_data_temp) + np.multiply(y,z_data_temp,out=z_data_temp) + #z_data_temp = y*np.exp(1j*(2.*np.pi*phasedelay*x)) + xc,yc,r0 = self._fit_circle(z_data_temp) + err = np.sqrt((z_data_temp.real-xc)**2+(z_data_temp.imag-yc)**2)-r0 + return err + p_final = spopt.leastsq(residuals,delay,args=(f_data,z_data),maxfev=maxiter,ftol=1e-12,xtol=1e-12) + return p_final[0][0] + + def _fit_entire_model(self,f_data,z_data,fr,absQc,Ql,phi0,delay,a=1.,alpha=0.,maxiter=0): + ''' + fits the whole model: a*exp(i*alpha)*exp(-2*pi*i*f*delay) * [ 1 - {Ql/Qc*exp(i*phi0)} / {1+2*i*Ql*(f-fr)/fr} ] + ''' + def funcsqr(p,x): + fr,absQc,Ql,phi0,delay,a,alpha = p + return np.array([np.absolute( ( a*np.exp(np.complex(0,alpha))*np.exp(np.complex(0,-2.*np.pi*delay*x[i])) * ( 1 - (Ql/absQc*np.exp(np.complex(0,phi0)))/(np.complex(1,2*Ql*(x[i]-fr)/fr)) ) ) )**2 for i in range(len(x))]) + def residuals(p,x,y): + fr,absQc,Ql,phi0,delay,a,alpha = p + err = [np.absolute( y[i] - ( a*np.exp(np.complex(0,alpha))*np.exp(np.complex(0,-2.*np.pi*delay*x[i])) * ( 1 - (Ql/absQc*np.exp(np.complex(0,phi0)))/(np.complex(1,2*Ql*(x[i]-fr)/fr)) ) ) ) for i in range(len(x))] + return err + p0 = [fr,absQc,Ql,phi0,delay,a,alpha] + (popt, params_cov, infodict, errmsg, ier) = spopt.leastsq(residuals,p0,args=(np.array(f_data),np.array(z_data)),full_output=True,maxfev=maxiter) + len_ydata = len(np.array(f_data)) + if (len_ydata > len(p0)) and params_cov is not None: #p_final[1] is cov_x data #this caculation is from scipy curve_fit routine - no idea if this works correctly... + s_sq = (funcsqr(popt, np.array(f_data))).sum()/(len_ydata-len(p0)) + params_cov = params_cov * s_sq + else: + params_cov = np.inf + return popt, params_cov, infodict, errmsg, ier + + # + + def _optimizedelay(self,f_data,z_data,Ql,fr,maxiter=4): + xc,yc,r0 = self._fit_circle(z_data) + z_data = self._center(z_data,np.complex(xc,yc)) + theta, Ql, fr, slope = self._phase_fit_wslope(f_data,z_data,0.,Ql,fr,0.) + delay = 0. + for i in range(maxiter-1): #interate to get besser phase delay term + delay = delay - slope/(2.*2.*np.pi) + z_data_corr = self._remove_cable_delay(f_data,z_data,delay) + xc, yc, r0 = self._fit_circle(z_data_corr) + z_data_corr2 = self._center(z_data_corr,np.complex(xc,yc)) + theta0, Ql, fr, slope = self._phase_fit_wslope(f_data,z_data_corr2,0.,Ql,fr,0.) + delay = delay - slope/(2.*2.*np.pi) #start final interation + return delay + + def _fit_circle_iter(self,z_data, xc, yc, rc): + ''' + this is the radial weighting procedure + it improves your fitting value for the radius = Ql/Qc + use this to improve your fit in presence of heavy noise + after having used the standard algebraic fir_circle() function + the weight here is: W=1/sqrt((xc-xi)^2+(yc-yi)^2) + this works, because the center of the circle is usually much less + corrupted by noise than the radius + ''' + xdat = z_data.real + ydat = z_data.imag + def fitfunc(x,y,xc,yc): + return np.sqrt((x-xc)**2+(y-yc)**2) + def residuals(p,x,y): + xc,yc,r = p + temp = (r-fitfunc(x,y,xc,yc)) + return temp + p0 = [xc,yc,rc] + p_final = spopt.leastsq(residuals,p0,args=(xdat,ydat)) + xc,yc,rc = p_final[0] + return xc,yc,rc + + def _fit_circle_iter_radialweight(self,z_data, xc, yc, rc): + ''' + this is the radial weighting procedure + it improves your fitting value for the radius = Ql/Qc + use this to improve your fit in presence of heavy noise + after having used the standard algebraic fir_circle() function + the weight here is: W=1/sqrt((xc-xi)^2+(yc-yi)^2) + this works, because the center of the circle is usually much less + corrupted by noise than the radius + ''' + xdat = z_data.real + ydat = z_data.imag + def fitfunc(x,y): + return np.sqrt((x-xc)**2+(y-yc)**2) + def weight(x,y): + try: + res = 1./np.sqrt((xc-x)**2+(yc-y)**2) + except: + res = 1. + return res + def residuals(p,x,y): + r = p[0] + temp = (r-fitfunc(x,y))*weight(x,y) + return temp + p0 = [rc] + p_final = spopt.leastsq(residuals,p0,args=(xdat,ydat)) + return p_final[0][0] + + def _get_errors(self,residual,xdata,ydata,fitparams): + ''' + wrapper for get_cov, only gives the errors and chisquare + ''' + chisqr, cov = self._get_cov(residual,xdata,ydata,fitparams) + if cov!=None: + errors = np.sqrt(np.diagonal(cov)) + else: + errors = None + return chisqr, errors + + def _residuals_notch_full(self,p,x,y): + fr,absQc,Ql,phi0,delay,a,alpha = p + err = np.absolute( y - ( a*np.exp(np.complex(0,alpha))*np.exp(np.complex(0,-2.*np.pi*delay*x)) * ( 1 - (Ql/absQc*np.exp(np.complex(0,phi0)))/(np.complex(1,2*Ql*(x-fr)/float(fr))) ) ) ) + return err + + def _residuals_notch_ideal(self,p,x,y): + fr,absQc,Ql,phi0 = p + #if fr == 0: print(p) + err = np.absolute( y - ( ( 1. - (Ql/float(absQc)*np.exp(1j*phi0))/(1+2j*Ql*(x-fr)/float(fr)) ) ) ) + #if np.isinf((np.complex(1,2*Ql*(x-fr)/float(fr))).imag): + # print(np.complex(1,2*Ql*(x-fr)/float(fr))) + # print("x: " + str(x)) + # print("Ql: " +str(Ql)) + #print("fr: " +str(fr)) + return err + + def _residuals_notch_ideal_complex(self,p,x,y): + fr,absQc,Ql,phi0 = p + #if fr == 0: print(p) + err = y - ( ( 1. - (Ql/float(absQc)*np.exp(1j*phi0))/(1+2j*Ql*(x-fr)/float(fr)) ) ) + #if np.isinf((np.complex(1,2*Ql*(x-fr)/float(fr))).imag): + # print(np.complex(1,2*Ql*(x-fr)/float(fr))) + # print("x: " + str(x)) + # print("Ql: " +str(Ql)) + #print("fr: " +str(fr)) + return err + + def _residuals_directrefl(self,p,x,y): + fr,Qc,Ql = p + #if fr == 0: print(p) + err = y - ( 2.*Ql/Qc - 1. + 2j*Ql*(fr-x)/fr ) / ( 1. - 2j*Ql*(fr-x)/fr ) + #if np.isinf((np.complex(1,2*Ql*(x-fr)/float(fr))).imag): + # print(np.complex(1,2*Ql*(x-fr)/float(fr))) + # print("x: " + str(x)) + # print("Ql: " +str(Ql)) + #print("fr: " +str(fr)) + return err + + def _residuals_transm_ideal(self,p,x,y): + fr,Ql = p + err = np.absolute( y - ( 1./(np.complex(1,2*Ql*(x-fr)/float(fr))) ) ) + return err + + + def _get_cov_fast_notch(self,xdata,ydata,fitparams): #enhanced by analytical derivatives + #derivatives of notch_ideal model with respect to parameters + def dS21_dQl(p,f): + fr,absQc,Ql,phi0 = p + return - (np.exp(1j*phi0) * fr**2) / (absQc * (fr+2j*Ql*f-2j*Ql*fr)**2 ) + + def dS21_dQc(p,f): + fr,absQc,Ql,phi0 = p + return (np.exp(1j*phi0) * Ql*fr) / (2j*(f-fr)*absQc**2*Ql+absQc**2*fr ) + + def dS21_dphi0(p,f): + fr,absQc,Ql,phi0 = p + return - (1j*Ql*fr*np.exp(1j*phi0) ) / (2j*(f-fr)*absQc*Ql+absQc*fr ) + + def dS21_dfr(p,f): + fr,absQc,Ql,phi0 = p + return - (2j*Ql**2*f*np.exp(1j*phi0) ) / (absQc * (fr+2j*Ql*f-2j*Ql*fr)**2 ) + + u = self._residuals_notch_ideal_complex(fitparams,xdata,ydata) + chi = np.absolute(u) + u = u/chi # unit vector pointing in the correct direction for the derivative + + aa = dS21_dfr(fitparams,xdata) + bb = dS21_dQc(fitparams,xdata) + cc = dS21_dQl(fitparams,xdata) + dd = dS21_dphi0(fitparams,xdata) + + Jt = np.array([aa.real*u.real+aa.imag*u.imag, bb.real*u.real+bb.imag*u.imag\ + , cc.real*u.real+cc.imag*u.imag, dd.real*u.real+dd.imag*u.imag ]) + A = np.dot(Jt,np.transpose(Jt)) + chisqr = 1./float(len(xdata)-len(fitparams)) * (chi**2).sum() + try: + cov = np.linalg.inv(A)*chisqr + except: + cov = None + return chisqr, cov + + def _get_cov_fast_directrefl(self,xdata,ydata,fitparams): #enhanced by analytical derivatives + #derivatives of notch_ideal model with respect to parameters + def dS21_dQl(p,f): + fr,Qc,Ql = p + return 2.*fr**2/( Qc*(2j*Ql*fr-2j*Ql*f+fr)**2 ) + + def dS21_dQc(p,f): + fr,Qc,Ql = p + return 2.*Ql*fr/(2j*Qc**2*(f-fr)*Ql-Qc**2*fr) + + def dS21_dfr(p,f): + fr,Qc,Ql = p + return - 4j*Ql**2*f/(Qc*(2j*Ql*fr-2j*Ql*f+fr)**2) + + u = self._residuals_directrefl(fitparams,xdata,ydata) + chi = np.absolute(u) + u = u/chi # unit vector pointing in the correct direction for the derivative + + aa = dS21_dfr(fitparams,xdata) + bb = dS21_dQc(fitparams,xdata) + cc = dS21_dQl(fitparams,xdata) + + Jt = np.array([aa.real*u.real+aa.imag*u.imag, bb.real*u.real+bb.imag*u.imag\ + , cc.real*u.real+cc.imag*u.imag ]) + A = np.dot(Jt,np.transpose(Jt)) + chisqr = 1./float(len(xdata)-len(fitparams)) * (chi**2).sum() + try: + cov = np.linalg.inv(A)*chisqr + except: + cov = None + return chisqr, cov \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py b/src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py new file mode 100644 index 000000000..c229bce7a --- /dev/null +++ b/src/qkit/analysis/circle_fit/circle_fit_classic/circuit.py @@ -0,0 +1,501 @@ +import warnings +import numpy as np +import scipy.optimize as spopt +from scipy.constants import hbar + +from .utilities import plotting, save_load, Watt2dBm, dBm2Watt +from .circlefit import circlefit +from .calibration import calibration + +## +## z_data_raw denotes the raw data +## z_data denotes the normalized data +## + +class reflection_port(circlefit, save_load, plotting, calibration): + ''' + normal direct port probed in reflection + ''' + def __init__(self, f_data=None, z_data_raw=None): + self.porttype = 'direct' + self.fitresults = {} + self.z_data = None + if f_data is not None: + self.f_data = np.array(f_data) + else: + self.f_data=None + if z_data_raw is not None: + self.z_data_raw = np.array(z_data_raw) + else: + self.z_data=None + + def _S11(self,f,fr,k_c,k_i): + ''' + use either frequency or angular frequency units + for all quantities + k_l=k_c+k_i: total (loaded) coupling rate + k_c: coupling rate + k_i: internal loss rate + ''' + return ((k_c-k_i)+2j*(f-fr))/((k_c+k_i)-2j*(f-fr)) + + def get_delay(self,f_data,z_data,delay=None,ignoreslope=True,guess=True): + ''' + ignoreslope option not used here + retrieves the cable delay assuming the ideal resonance has a circular shape + modifies the cable delay until the shape Im(S21) vs Re(S21) is circular + see "do_calibration" + ''' + maxval = np.max(np.absolute(z_data)) + z_data = z_data/maxval + A1, A2, A3, A4, fr, Ql = self._fit_skewed_lorentzian(f_data,z_data) + if ignoreslope==True: + A2 = 0 + else: + z_data = (np.sqrt(np.absolute(z_data)**2-A2*(f_data-fr))) * np.exp(np.angle(z_data)*1j) #usually not necessary + if delay==None: + if guess==True: + delay = self._guess_delay(f_data,z_data) + else: + delay=0. + delay = self._fit_delay(f_data,z_data,delay,maxiter=200) + params = [A1, A2, A3, A4, fr, Ql] + return delay, params + + def do_calibration(self,f_data,z_data,ignoreslope=True,guessdelay=True): + ''' + calculating parameters for normalization + ''' + delay, params = self.get_delay(f_data,z_data,ignoreslope=ignoreslope,guess=guessdelay) + z_data = np.sqrt(np.absolute(z_data)**2-params[1]*(f_data-params[4]))*np.exp(2.*1j*np.pi*delay*f_data)*np.exp(1j*np.angle(z_data)) + xc, yc, r0 = self._fit_circle(z_data) + zc = np.complex(xc,yc) + fitparams = self._phase_fit(f_data,self._center(z_data,zc),0.,np.absolute(params[5]),params[4]) + theta, Ql, fr = fitparams + beta = self._periodic_boundary(theta+np.pi,np.pi) ### + offrespoint = np.complex((xc+r0*np.cos(beta)),(yc+r0*np.sin(beta))) + alpha = self._periodic_boundary(np.angle(offrespoint)+np.pi,np.pi) + #a = np.absolute(offrespoint) + #alpha = np.angle(zc) + a = r0 + np.absolute(zc) + return delay, a, alpha, fr, Ql, params[1], params[4] + + def do_normalization(self,f_data,z_data,delay,amp_norm,alpha,A2,frcal): + ''' + transforming resonator into canonical position + ''' + return (np.sqrt(np.absolute(z_data)**2-A2*(f_data-frcal)))/amp_norm*np.exp(1j*(-alpha+2.*np.pi*delay*f_data))*np.exp(1j*np.angle(z_data)) + + def circlefit(self,f_data,z_data,fr=None,Ql=None,refine_results=False,calc_errors=True): + ''' + S11 version of the circlefit + ''' + + if fr==None: fr=f_data[np.argmin(np.absolute(z_data))] + if Ql==None: Ql=1e6 + xc, yc, r0 = self._fit_circle(z_data,refine_results=refine_results) + phi0 = -np.arcsin(yc/r0) + theta0 = self._periodic_boundary(phi0+np.pi,np.pi) + z_data_corr = self._center(z_data,np.complex(xc,yc)) + theta0, Ql, fr = self._phase_fit(f_data,z_data_corr,theta0,Ql,fr) + #print("Ql from phasefit is: " + str(Ql)) + Qi = Ql/(1.-r0) + Qc = 1./(1./Ql-1./Qi) + + results = {"Qi":Qi,"Qc":Qc,"Ql":Ql,"fr":fr,"theta0":theta0} + + #calculation of the error + p = [fr,Qc,Ql] + #chi_square, errors = rt.get_errors(rt.residuals_notch_ideal,f_data,z_data,p) + if calc_errors==True: + chi_square, cov = self._get_cov_fast_directrefl(f_data,z_data,p) + #chi_square, cov = rt.get_cov(rt.residuals_notch_ideal,f_data,z_data,p) + + if cov is not None: + errors = np.sqrt(np.diagonal(cov)) + fr_err,Qc_err,Ql_err = errors + #calc Qi with error prop (sum the squares of the variances and covariaces) + dQl = 1./((1./Ql-1./Qc)**2*Ql**2) + dQc = - 1./((1./Ql-1./Qc)**2*Qc**2) + Qi_err = np.sqrt((dQl**2*cov[2][2]) + (dQc**2*cov[1][1])+(2*dQl*dQc*cov[2][1])) #with correlations + errors = {"Ql_err":Ql_err, "Qc_err":Qc_err, "fr_err":fr_err,"chi_square":chi_square,"Qi_err":Qi_err} + results.update( errors ) + else: + print("WARNING: Error calculation failed!") + else: + #just calc chisquared: + fun2 = lambda x: self._residuals_notch_ideal(x,f_data,z_data)**2 + chi_square = 1./float(len(f_data)-len(p)) * (fun2(p)).sum() + errors = {"chi_square":chi_square} + results.update(errors) + + return results + + + def autofit(self): + ''' + automatic calibration and fitting + ''' + delay, amp_norm, alpha, fr, Ql, A2, frcal =\ + self.do_calibration(self.f_data,self.z_data_raw,ignoreslope=True,guessdelay=False) + self.z_data = self.do_normalization(self.f_data,self.z_data_raw,delay,amp_norm,alpha,A2,frcal) + self.fitresults = self.circlefit(self.f_data,self.z_data,fr,Ql,refine_results=False,calc_errors=True) + self.z_data_sim = A2*(self.f_data-frcal)+self._S11_directrefl(self.f_data,fr=self.fitresults["fr"],Ql=self.fitresults["Ql"],Qc=self.fitresults["Qc"],a=amp_norm,alpha=alpha,delay=delay) + + def _S11_directrefl(self,f,fr=10e9,Ql=900,Qc=1000.,a=1.,alpha=0.,delay=.0): + ''' + full model for notch type resonances + ''' + return a*np.exp(np.complex(0,alpha))*np.exp(-2j*np.pi*f*delay) * ( 2.*Ql/Qc - 1. + 2j*Ql*(fr-f)/fr ) / ( 1. - 2j*Ql*(fr-f)/fr ) + + def get_single_photon_limit(self,unit='dBm'): + ''' + returns the amout of power in units of W necessary + to maintain one photon on average in the cavity + unit can be 'dbm' or 'watt' + ''' + if self.fitresults!={}: + fr = self.fitresults['fr'] + k_c = fr/self.fitresults['Qc'] + k_i = fr/self.fitresults['Qi'] + if unit=='dBm': + return Watt2dBm(1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2))) + elif unit=='watt': + return 1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2)) + + else: + warnings.warn('Please perform the fit first',UserWarning) + return None + + def get_photons_in_resonator(self,power,unit='dBm'): + ''' + returns the average number of photons + for a given power (defaul unit is 'dbm') + unit can be 'dBm' or 'watt' + ''' + if self.fitresults!={}: + if unit=='dBm': + power = dBm2Watt(power) + fr = self.fitresults['fr'] + k_c = fr/self.fitresults['Qc'] + k_i = fr/self.fitresults['Qi'] + return 4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2) * power + else: + warnings.warn('Please perform the fit first',UserWarning) + return None + +class notch_port(circlefit, save_load, plotting, calibration): + ''' + notch type port probed in transmission + ''' + def __init__(self, f_data=None, z_data_raw=None): + self.porttype = 'notch' + self.fitresults = {} + self.z_data = None + if f_data is not None: + self.f_data = np.array(f_data) + else: + self.f_data=None + if z_data_raw is not None: + self.z_data_raw = np.array(z_data_raw) + else: + self.z_data_raw=None + + def get_delay(self,f_data,z_data,delay=None,ignoreslope=True,guess=True): + ''' + retrieves the cable delay assuming the ideal resonance has a circular shape + modifies the cable delay until the shape Im(S21) vs Re(S21) is circular + see "do_calibration" + ''' + maxval = np.max(np.absolute(z_data)) + z_data = z_data/maxval + A1, A2, A3, A4, fr, Ql = self._fit_skewed_lorentzian(f_data,z_data) + if ignoreslope==True: + A2 = 0 + else: + z_data = (np.absolute(z_data)-A2*(f_data-fr)) * np.exp(np.angle(z_data)*1j) #usually not necessary + if delay==None: + if guess==True: + delay = self._guess_delay(f_data,z_data) + else: + delay=0. + delay = self._fit_delay(f_data,z_data,delay,maxiter=200) + params = [A1, A2, A3, A4, fr, Ql] + return delay, params + + def do_calibration(self,f_data,z_data,ignoreslope=True,guessdelay=True): + ''' + performs an automated calibration and tries to determine the prefactors a, alpha, delay + fr, Ql, and a possible slope are extra information, which can be used as start parameters for subsequent fits + see also "do_normalization" + the calibration procedure works for transmission line resonators as well + ''' + delay, params = self.get_delay(f_data,z_data,ignoreslope=ignoreslope,guess=guessdelay) + z_data = (z_data-params[1]*(f_data-params[4]))*np.exp(2.*1j*np.pi*delay*f_data) + xc, yc, r0 = self._fit_circle(z_data) + zc = np.complex(xc,yc) + fitparams = self._phase_fit(f_data,self._center(z_data,zc),0.,np.absolute(params[5]),params[4]) + theta, Ql, fr = fitparams + beta = self._periodic_boundary(theta+np.pi,np.pi) + offrespoint = np.complex((xc+r0*np.cos(beta)),(yc+r0*np.sin(beta))) + alpha = np.angle(offrespoint) + a = np.absolute(offrespoint) + return delay, a, alpha, fr, Ql, params[1], params[4] + + def do_normalization(self,f_data,z_data,delay,amp_norm,alpha,A2,frcal): + ''' + removes the prefactors a, alpha, delay and returns the calibrated data, see also "do_calibration" + works also for transmission line resonators + ''' + return (z_data-A2*(f_data-frcal))/amp_norm*np.exp(1j*(-alpha+2.*np.pi*delay*f_data)) + + def circlefit(self,f_data,z_data,fr=None,Ql=None,refine_results=False,calc_errors=True): + ''' + performs a circle fit on a frequency vs. complex resonator scattering data set + Data has to be normalized!! + INPUT: + f_data,z_data: input data (frequency, complex S21 data) + OUTPUT: + outpus a dictionary {key:value} consisting of the fit values, errors and status information about the fit + values: {"phi0":phi0, "Ql":Ql, "absolute(Qc)":absQc, "Qi": Qi, "electronic_delay":delay, "complexQc":complQc, "resonance_freq":fr, "prefactor_a":a, "prefactor_alpha":alpha} + errors: {"phi0_err":phi0_err, "Ql_err":Ql_err, "absolute(Qc)_err":absQc_err, "Qi_err": Qi_err, "electronic_delay_err":delay_err, "resonance_freq_err":fr_err, "prefactor_a_err":a_err, "prefactor_alpha_err":alpha_err} + for details, see: + [1] (not diameter corrected) Jiansong Gao, "The Physics of Superconducting Microwave Resonators" (PhD Thesis), Appendix E, California Institute of Technology, (2008) + [2] (diameter corrected) M. S. Khalil, et. al., J. Appl. Phys. 111, 054510 (2012) + [3] (fitting techniques) N. CHERNOV AND C. LESORT, "Least Squares Fitting of Circles", Journal of Mathematical Imaging and Vision 23, 239, (2005) + [4] (further fitting techniques) P. J. Petersan, S. M. Anlage, J. Appl. Phys, 84, 3392 (1998) + the program fits the circle with the algebraic technique described in [3], the rest of the fitting is done with the scipy.optimize least square fitting toolbox + also, check out [5] S. Probst et al. "Efficient and reliable analysis of noisy complex scatterung resonator data for superconducting quantum circuits" (in preparation) + ''' + + if fr==None: fr=f_data[np.argmin(np.absolute(z_data))] + if Ql==None: Ql=1e6 + xc, yc, r0 = self._fit_circle(z_data,refine_results=refine_results) + phi0 = -np.arcsin(yc/r0) + theta0 = self._periodic_boundary(phi0+np.pi,np.pi) + z_data_corr = self._center(z_data,np.complex(xc,yc)) + theta0, Ql, fr = self._phase_fit(f_data,z_data_corr,theta0,Ql,fr) + #print("Ql from phasefit is: " + str(Ql)) + absQc = Ql/(2.*r0) + complQc = absQc*np.exp(1j*((-1.)*phi0)) + Qc = 1./(1./complQc).real # here, taking the real part of (1/complQc) from diameter correction method + Qi_dia_corr = 1./(1./Ql-1./Qc) + Qi_no_corr = 1./(1./Ql-1./absQc) + + results = {"Qi_dia_corr":Qi_dia_corr,"Qi_no_corr":Qi_no_corr,"absQc":absQc,"Qc_dia_corr":Qc,"Ql":Ql,"fr":fr,"theta0":theta0,"phi0":phi0} + + #calculation of the error + p = [fr,absQc,Ql,phi0] + #chi_square, errors = rt.get_errors(rt.residuals_notch_ideal,f_data,z_data,p) + if calc_errors==True: + chi_square, cov = self._get_cov_fast_notch(f_data,z_data,p) + #chi_square, cov = rt.get_cov(rt.residuals_notch_ideal,f_data,z_data,p) + + if cov is not None: + errors = np.sqrt(np.diagonal(cov)) + fr_err,absQc_err,Ql_err,phi0_err = errors + #calc Qi with error prop (sum the squares of the variances and covariaces) + dQl = 1./((1./Ql-1./absQc)**2*Ql**2) + dabsQc = - 1./((1./Ql-1./absQc)**2*absQc**2) + Qi_no_corr_err = np.sqrt((dQl**2*cov[2][2]) + (dabsQc**2*cov[1][1])+(2*dQl*dabsQc*cov[2][1])) #with correlations + #calc Qi dia corr with error prop + dQl = 1/((1/Ql-np.cos(phi0)/absQc)**2 *Ql**2) + dabsQc = -np.cos(phi0)/((1/Ql-np.cos(phi0)/absQc)**2 *absQc**2) + dphi0 = -np.sin(phi0)/((1/Ql-np.cos(phi0)/absQc)**2 *absQc) + ##err1 = ( (dQl*cov[2][2])**2 + (dabsQc*cov[1][1])**2 + (dphi0*cov[3][3])**2 ) + err1 = ( (dQl**2*cov[2][2]) + (dabsQc**2*cov[1][1]) + (dphi0**2*cov[3][3]) ) + err2 = ( dQl*dabsQc*cov[2][1] + dQl*dphi0*cov[2][3] + dabsQc*dphi0*cov[1][3] ) + Qi_dia_corr_err = np.sqrt(err1+2*err2) # including correlations + errors = {"phi0_err":phi0_err, "Ql_err":Ql_err, "absQc_err":absQc_err, "fr_err":fr_err,"chi_square":chi_square,"Qi_no_corr_err":Qi_no_corr_err,"Qi_dia_corr_err": Qi_dia_corr_err} + results.update( errors ) + else: + print("WARNING: Error calculation failed!") + else: + #just calc chisquared: + fun2 = lambda x: self._residuals_notch_ideal(x,f_data,z_data)**2 + chi_square = 1./float(len(f_data)-len(p)) * (fun2(p)).sum() + errors = {"chi_square":chi_square} + results.update(errors) + + return results + + def autofit(self): + ''' + automatic calibration and fitting + ''' + delay, amp_norm, alpha, fr, Ql, A2, frcal =\ + self.do_calibration(self.f_data,self.z_data_raw,ignoreslope=True,guessdelay=True) + self.z_data = self.do_normalization(self.f_data,self.z_data_raw,delay,amp_norm,alpha,A2,frcal) + self.fitresults = self.circlefit(self.f_data,self.z_data,fr,Ql,refine_results=False,calc_errors=True) + self.z_data_sim = A2*(self.f_data-frcal)+self._S21_notch(self.f_data,fr=self.fitresults["fr"],Ql=self.fitresults["Ql"],Qc=self.fitresults["absQc"],phi=self.fitresults["phi0"],a=amp_norm,alpha=alpha,delay=delay) + + def _S21_notch(self,f,fr=10e9,Ql=900,Qc=1000.,phi=0.,a=1.,alpha=0.,delay=.0): + ''' + full model for notch type resonances + ''' + return a*np.exp(np.complex(0,alpha))*np.exp(-2j*np.pi*f*delay)*(1.-Ql/Qc*np.exp(1j*phi)/(1.+2j*Ql*(f-fr)/fr)) + + def get_single_photon_limit(self,unit='dBm'): + ''' + returns the amout of power in units of W necessary + to maintain one photon on average in the cavity + unit can be 'dBm' or 'watt' + ''' + if self.fitresults!={}: + fr = self.fitresults['fr'] + k_c = fr/self.fitresults['absQc'] + k_i = fr/self.fitresults['Qi_dia_corr'] + if unit=='dBm': + return Watt2dBm(1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2))) + elif unit=='watt': + return 1./(4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2)) + else: + warnings.warn('Please perform the fit first',UserWarning) + return None + + def get_photons_in_resonator(self,power,unit='dBm'): + ''' + returns the average number of photons + for a given power in units of W + unit can be 'dBm' or 'watt' + ''' + if self.fitresults!={}: + if unit=='dBm': + power = dBm2Watt(power) + fr = self.fitresults['fr'] + k_c = fr/self.fitresults['Qc'] + k_i = fr/self.fitresults['Qi'] + return 4.*k_c/(2.*np.pi*hbar*fr*(k_c+k_i)**2) * power + else: + warnings.warn('Please perform the fit first',UserWarning) + return None + +class transmission_port(circlefit,save_load,plotting): + ''' + a class for handling transmission measurements + ''' + + def __init__(self,f_data=None,z_data_raw=None): + self.porttype = 'transm' + self.fitresults = {} + if f_data!=None: + self.f_data = np.array(f_data) + else: + self.f_data=None + if z_data_raw!=None: + self.z_data_raw = np.array(z_data_raw) + else: + self.z_data=None + + def _S21(self,f,fr,Ql,A): + return A**2/(1.+4.*Ql**2*((f-fr)/fr)**2) + + def fit(self): + self.ampsqr = (np.absolute(self.z_data_raw))**2 + p = [self.f_data[np.argmax(self.ampsqr)],1000.,np.amax(self.ampsqr)] + popt, pcov = spopt.curve_fit(self._S21, self.f_data, self.ampsqr,p) + errors = np.sqrt(np.diag(pcov)) + self.fitresults = {'fr':popt[0],'fr_err':errors[0],'Ql':popt[1],'Ql_err':errors[1],'Ampsqr':popt[2],'Ampsqr_err':errors[2]} + +class resonator(object): + ''' + Universal resonator analysis class + It can handle different kinds of ports and assymetric resonators. + ''' + def __init__(self, ports = {}, comment = None): + ''' + initializes the resonator class object + ports (dictionary {key:value}): specify the name and properties of the coupling ports + e.g. ports = {'1':'direct', '2':'notch'} + comment: add a comment + ''' + self.comment = comment + self.port = {} + self.transm = {} + if len(ports) > 0: + for key, pname in ports.iteritems(): + if pname=='direct': + self.port.update({key:reflection_port()}) + elif pname=='notch': + self.port.update({key:notch_port()}) + else: + warnings.warn("Undefined input type! Use 'direct' or 'notch'.", SyntaxWarning) + if len(self.port) == 0: warnings.warn("Resonator has no coupling ports!", UserWarning) + + def add_port(self,key,pname): + if pname=='direct': + self.port.update({key:reflection_port()}) + elif pname=='notch': + self.port.update({key:notch_port()}) + else: + warnings.warn("Undefined input type! Use 'direct' or 'notch'.", SyntaxWarning) + if len(self.port) == 0: warnings.warn("Resonator has no coupling ports!", UserWarning) + + def delete_port(self,key): + del self.port[key] + if len(self.port) == 0: warnings.warn("Resonator has no coupling ports!", UserWarning) + + def get_Qi(self): + ''' + based on the number of ports and the corresponding measurements + it calculates the internal losses + ''' + pass + + def get_single_photon_limit(self,port): + ''' + returns the amout of power necessary to maintain one photon + on average in the cavity + ''' + pass + + def get_photons_in_resonator(self,power,port): + ''' + returns the average number of photons + for a given power + ''' + pass + + def add_transm_meas(self,port1, port2): + ''' + input: port1 + output: port2 + adds a transmission measurement + connecting two direct ports S21 + ''' + key = port1 + " -> " + port2 + self.port.update({key:transm()}) + pass + + +class batch_processing(object): + ''' + A class for batch processing of resonator data as a function of another variable + Typical applications are power scans, magnetic field scans etc. + ''' + + def __init__(self,porttype): + ''' + porttype = 'notch', 'direct', 'transm' + results is an array of dictionaries containing the fitresults + ''' + self.porttype = porttype + self.results = [] + + def autofit(self,cal_dataslice = 0): + ''' + fits all data + cal_dataslice: choose scatteringdata which should be used for calibration + of the amplitude and phase, default = 0 (first) + ''' + pass + +class coupled_resonators(batch_processing): + ''' + A class for fitting a resonator coupled to a second one + ''' + + def __init__(self,porttype): + self.porttype = porttype + self.results = [] + \ No newline at end of file diff --git a/src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py b/src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py new file mode 100644 index 000000000..611218631 --- /dev/null +++ b/src/qkit/analysis/circle_fit/circle_fit_classic/utilities.py @@ -0,0 +1,187 @@ +import warnings +import numpy as np +plot_enable = False +try: + import qkit + if qkit.module_available("matplotlib"): + import matplotlib.pyplot as plt + plot_enable = True +except (ImportError, AttributeError): + try: + import matplotlib.pyplot as plt + plot_enable = True + except ImportError: + plot_enable = False + +def Watt2dBm(x): + ''' + converts from units of watts to dBm + ''' + return 10.*np.log10(x*1000.) + +def dBm2Watt(x): + ''' + converts from units of watts to dBm + ''' + return 10**(x/10.) /1000. + +class plotting(object): + ''' + some helper functions for plotting + ''' + def plotall(self): + if not plot_enable: + raise ImportError("matplotlib not found") + real = self.z_data_raw.real + imag = self.z_data_raw.imag + real2 = self.z_data_sim.real + imag2 = self.z_data_sim.imag + plt.subplot(221) + plt.plot(real,imag,label='rawdata') + plt.plot(real2,imag2,label='fit') + plt.xlabel('Re(S21)') + plt.ylabel('Im(S21)') + plt.legend() + plt.subplot(222) + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_sim),label='fit') + plt.xlabel('f (GHz)') + plt.ylabel('|S21|') + plt.legend() + plt.subplot(223) + plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') + plt.plot(self.f_data*1e-9,np.angle(self.z_data_sim),label='fit') + plt.xlabel('f (GHz)') + plt.ylabel('arg(|S21|)') + plt.legend() + plt.show() + + def plotcalibrateddata(self): + if not plot_enable: + raise ImportError("matplotlib not found") + real = self.z_data.real + imag = self.z_data.imag + plt.subplot(221) + plt.plot(real,imag,label='rawdata') + plt.xlabel('Re(S21)') + plt.ylabel('Im(S21)') + plt.legend() + plt.subplot(222) + plt.plot(self.f_data*1e-9,np.absolute(self.z_data),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('|S21|') + plt.legend() + plt.subplot(223) + plt.plot(self.f_data*1e-9,np.angle(self.z_data),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('arg(|S21|)') + plt.legend() + plt.show() + + def plotrawdata(self): + if not plot_enable: + raise ImportError("matplotlib not found") + real = self.z_data_raw.real + imag = self.z_data_raw.imag + plt.subplot(221) + plt.plot(real,imag,label='rawdata') + plt.xlabel('Re(S21)') + plt.ylabel('Im(S21)') + plt.legend() + plt.subplot(222) + plt.plot(self.f_data*1e-9,np.absolute(self.z_data_raw),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('|S21|') + plt.legend() + plt.subplot(223) + plt.plot(self.f_data*1e-9,np.angle(self.z_data_raw),label='rawdata') + plt.xlabel('f (GHz)') + plt.ylabel('arg(|S21|)') + plt.legend() + plt.show() + +class save_load(object): + ''' + procedures for loading and saving data used by other classes + ''' + def _ConvToCompl(self,x,y,dtype): + ''' + dtype = 'realimag', 'dBmagphaserad', 'linmagphaserad', 'dBmagphasedeg', 'linmagphasedeg' + ''' + if dtype=='realimag': + return x+1j*y + elif dtype=='linmagphaserad': + return x*np.exp(1j*y) + elif dtype=='dBmagphaserad': + return 10**(x/20.)*np.exp(1j*y) + elif dtype=='linmagphasedeg': + return x*np.exp(1j*y/180.*np.pi) + elif dtype=='dBmagphasedeg': + return 10**(x/20.)*np.exp(1j*y/180.*np.pi) + else: warnings.warn("Undefined input type! Use 'realimag', 'dBmagphaserad', 'linmagphaserad', 'dBmagphasedeg' or 'linmagphasedeg'.", SyntaxWarning) + + def add_data(self,f_data,z_data): + self.f_data = np.array(f_data) + self.z_data_raw = np.array(z_data) + + def cut_data(self,f1,f2): + def findpos(f_data,val): + pos = 0 + for i in range(len(f_data)): + if f_data[i]=f_min and f<=f_max in the frequency-array + the fit functions are fitted only in this area + the data in the .h5-file is NOT changed + ''' + if data.ndim == 1: + return data[(self._frequency >= self._f_min) & (self._frequency <= self._f_max)] + if data.ndim == 2: + ret_array=np.empty(shape=(data.shape[0],self._fit_frequency.shape[0]),dtype=np.float64) + for i,a in enumerate(data): + ret_array[i]=data[i][(self._frequency >= self._f_min) & (self._frequency <= self._f_max)] + return ret_array + + def _get_datasets(self): + ''' + reads out the file + ''' + if not self._hf: + logging.info('No hf file kown yet!') + return + + self._ds_amp = self._hf.get_dataset(self.ds_url_amp) + self._ds_pha = self._hf.get_dataset(self.ds_url_pha) + self._ds_type = self._ds_amp.ds_type + + self._amplitude = np.array(self._hf[self.ds_url_amp],dtype=np.float64) + self._phase = np.array(self._hf[self.ds_url_pha],dtype=np.float64) + self._frequency = np.array(self._hf[self.ds_url_freq],dtype=np.float64) + + try: + self._x_co = self._hf.get_dataset(self._ds_amp.x_ds_url) + except: + try: + self._x_co = self._hf.get_dataset(self.ds_url_power) # hardcode a std url + except: + logging.warning('Unable to open any x_coordinate. Please set manually using \'set_x_coord()\'.') + try: + self._y_co = self._hf.get_dataset(self._ds_amp.y_ds_url) + except: + try: self._y_co = self._hf.get_dataset(self.ds_url_freq) # hardcode a std url + except: + logging.warning('Unable to open any y_coordinate. Please set manually using \'set_y_coord()\'.') + self._datasets_loaded = True + + def _prepare_f_range(self,f_min,f_max): + ''' + prepares the data to be fitted: + f_min (float): lower boundary + f_max (float): upper boundary + ''' + + self._f_min = np.min(self._frequency) + self._f_max = np.max(self._frequency) + + ''' + f_min f_max do not have to be exactly an entry in the freq-array + ''' + if f_min: + for freq in self._frequency: + if freq > f_min: + self._f_min = freq + break + if f_max: + for freq in self._frequency: + if freq > f_max: + self._f_max = freq + break + + ''' + cut the data-arrays with f_min/f_max and fit_all information + ''' + self._fit_frequency = np.array(self._set_data_range(self._frequency)) + self._fit_amplitude = np.array(self._set_data_range(self._amplitude)) + self._fit_phase = np.array(self._set_data_range(self._phase)) + + self._frequency_co = self._hf.add_coordinate('frequency',folder='analysis', unit = 'Hz') + self._frequency_co.add(self._fit_frequency) + + def _update_data(self): + self._amplitude = np.array(self._hf[self.ds_url_amp],dtype=np.float64) + self._phase = np.array(self._hf[self.ds_url_pha],dtype=np.float64) + + def _get_starting_values(self): + pass + + def fit_circle(self,reflection = False, notch = False, fit_all = False, f_min = None, f_max=None): + self._fit_all = fit_all + self._circle_reflection = reflection + self._circle_notch = notch + if not reflection and not notch: + self._circle_notch = True + + if not self._datasets_loaded: + self._get_datasets() + + self._update_data() + self._prepare_f_range(f_min, f_max) + + if self._first_circle: + self._prepare_circle() + self._first_circle = False + + if self._circle_reflection: + self._circle_port = circuit.reflection_port(f_data = self._fit_frequency) + elif self._circle_notch: + self._circle_port = circuit.notch_port(f_data = self._fit_frequency) + + self._do_fit_circle() + + def _do_fit_circle(self): + ''' + Creates corresponding ports in circuit.py in the qkit/analysis folder + circle fit for amp and pha data in the f_min-f_max frequency range + fit parameter, errors, and generated amp/pha data are stored in the hdf-file + + input: + fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) + ''' + + self._get_data_circle() + trace = 0 + self.debug("circle fit:") + for z_data_raw in self._z_data_raw: + + z_data_raw.real = self._pre_filter_data(z_data_raw.real) + z_data_raw.imag = self._pre_filter_data(z_data_raw.imag) + self.debug("fitting trace: "+str(trace)) + self._circle_port.z_data_raw = z_data_raw + + try: + self._circle_port.autofit() + except: + err=np.array([np.nan for f in self._fit_frequency]) + self._circ_amp_gen.append(err) + self._circ_pha_gen.append(err) + self._circ_real_gen.append(err) + self._circ_imag_gen.append(err) + + for key in iter(self._results): + self._results[str(key)].append(np.nan) + + else: + self._circ_amp_gen.append(np.absolute(self._circle_port.z_data_sim)) + self._circ_pha_gen.append(np.angle(self._circle_port.z_data_sim)) + self._circ_real_gen.append(np.real(self._circle_port.z_data_sim)) + self._circ_imag_gen.append(np.imag(self._circle_port.z_data_sim)) + + for key in iter(self._results): + self._results[str(key)].append(float(self._circle_port.fitresults[str(key)])) + trace+=1 + + def _prepare_circle(self): + ''' + creates the datasets for the circle fit in the hdf-file + ''' + self._results = {} + circle_fit_version = qkit.cfg.get("circle_fit_version", 1) + if circle_fit_version == 1: + if self._circle_notch: + self._result_keys = ["Qi_dia_corr", "Qi_no_corr", "absQc", "Qc_dia_corr", "Ql", + "fr", "theta0", "phi0", "phi0_err", "Ql_err", "absQc_err", + "fr_err", "chi_square", "Qi_no_corr_err", "Qi_dia_corr_err"] + elif self._circle_reflection: + self._result_keys = ["Qi", "Qc", "Ql", "fr", "theta0", "Ql_err", + "Qc_err", "fr_err", "chi_square", "Qi_err"] + + elif circle_fit_version == 2: + self._result_keys = ["delay", "delay_remaining", "a", "alpha", "theta", "phi", "fr", "Ql", "Qc", + "Qc_no_dia_corr", "Qi", "Qi_no_dia_corr", "fr_err", "Ql_err", "absQc_err", + "phi_err", "Qi_err", "Qi_no_dia_corr_err", "chi_square", "Qi_min", "Qi_max", + "Qc_min", "Qc_max", "fano_b"] + else: + logging.warning("Circle fit version not properly set in configuration!") + + if self._ds_type == ds_types['vector']: # data from measure_1d + self._data_real_gen = self._hf.add_value_vector('data_real_gen', self._frequency_co, folder='analysis', + unit='') + self._data_imag_gen = self._hf.add_value_vector('data_imag_gen', self._frequency_co, folder='analysis', + unit='') + + self._circ_amp_gen = self._hf.add_value_vector('circ_amp_gen', self._frequency_co, folder='analysis', + unit='arb. unit') + self._circ_pha_gen = self._hf.add_value_vector('circ_pha_gen', self._frequency_co, folder='analysis', + unit='rad') + self._circ_real_gen = self._hf.add_value_vector('circ_real_gen', self._frequency_co, folder='analysis', + unit='') + self._circ_imag_gen = self._hf.add_value_vector('circ_imag_gen', self._frequency_co, folder='analysis', + unit='') + + for key in self._result_keys: + self._results[key] = self._hf.add_coordinate('circ_' + key, folder='analysis', unit='') + + if self._ds_type == ds_types['matrix']: # data from measure_2d + self._data_real_gen = self._hf.add_value_matrix('data_real_gen', self._x_co, self._frequency_co, + folder='analysis', unit='') + self._data_imag_gen = self._hf.add_value_matrix('data_imag_gen', self._x_co, self._frequency_co, + folder='analysis', unit='') + + self._circ_amp_gen = self._hf.add_value_matrix('circ_amp_gen', self._x_co, self._frequency_co, + folder='analysis', unit='arb. unit') + self._circ_pha_gen = self._hf.add_value_matrix('circ_pha_gen', self._x_co, self._frequency_co, + folder='analysis', unit='rad') + self._circ_real_gen = self._hf.add_value_matrix('circ_real_gen', self._x_co, self._frequency_co, + folder='analysis', unit='') + self._circ_imag_gen = self._hf.add_value_matrix('circ_imag_gen', self._x_co, self._frequency_co, + folder='analysis', unit='') + + for key in self._result_keys: + self._results[key] = self._hf.add_value_vector('circ_' + key, folder='analysis', x=self._x_co, unit='') + + circ_view_amp = self._hf.add_view('circ_amp', x=self._y_co, y=self._ds_amp) + circ_view_amp.add(x=self._frequency_co, y=self._circ_amp_gen) + circ_view_pha = self._hf.add_view('circ_pha', x=self._y_co, y=self._ds_pha) + circ_view_pha.add(x=self._frequency_co, y=self._circ_pha_gen) + circ_view_iq = self._hf.add_view('circ_IQ', x=self._data_real_gen, y=self._data_imag_gen, + view_params={'aspect': 1.0}) + circ_view_iq.add(x=self._circ_real_gen, y=self._circ_imag_gen) + + def _get_data_circle(self): + ''' + calc complex data from amp and pha + ''' + if not self._fit_all: + self._z_data_raw = np.empty((1,self._fit_frequency.shape[0]), dtype=np.complex64) + + if self._fit_amplitude.ndim == 1: + self._z_data_raw[0] = np.array(self._fit_amplitude*np.exp(1j*self._fit_phase),dtype=np.complex64) + else: + self._z_data_raw[0] = np.array(self._fit_amplitude[-1]*np.exp(1j*self._fit_phase[-1]),dtype=np.complex64) + + self._data_real_gen.append(self._z_data_raw[0].real) + self._data_imag_gen.append(self._z_data_raw[0].imag) + + if self._fit_all: + self._z_data_raw = np.empty((self._fit_amplitude.shape), dtype=np.complex64) + for i,a in enumerate(self._fit_amplitude): + self._z_data_raw[i] = self._fit_amplitude[i]*np.exp(1j*self._fit_phase[i]) + self._data_real_gen.append(self._z_data_raw[i].real) + self._data_imag_gen.append(self._z_data_raw[i].imag) + + def _get_last_amp_trace(self): + tmp_amp = np.empty((1,self._fit_frequency.shape[0])) + if self._fit_amplitude.ndim==1: + tmp_amp[0] = self._fit_amplitude + else: + tmp_amp[0] = self._fit_amplitude[-1] + self._fit_amplitude = np.empty((1,self._fit_frequency.shape[0])) + self._fit_amplitude[0] = tmp_amp[0] + + def fit_lorentzian(self,fit_all = False,f_min=None,f_max=None,pre_filter_data=None): + ''' + lorentzian fit for amp data in the f_min-f_max frequency range + squared amps are fitted at lorentzian using scipy.leastsq + fit parameter, chi2, and generated amp are stored in the hdf-file + + input: + fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) + f_min (float): lower boundary for data to be fitted (optional, default: None, results in min(frequency-array)) + f_max (float): upper boundary for data to be fitted (optional, default: None, results in max(frequency-array)) + ''' + def residuals(p,x,y): + f0,k,a,offs=p + err = y-(a/(1+4*((x-f0)/k)**2)+offs) + return err + + self._fit_all = fit_all + + if not self._datasets_loaded: + self._get_datasets() + + self._update_data() + self._prepare_f_range(f_min,f_max) + if self._first_lorentzian: + self._prepare_lorentzian() + self._first_lorentzian=False + + ''' + fit_amplitude is always 2dim np array. + for 1dim data, shape: (1, # fit frequency points) + for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) + ''' + if not self._fit_all: + self._get_last_amp_trace() + + for amplitudes in self._fit_amplitude: + amplitudes = np.absolute(amplitudes) + amplitudes_sq = amplitudes**2 + '''extract starting parameter for lorentzian from data''' + s_offs = np.mean(np.array([amplitudes_sq[:int(np.size(amplitudes_sq)*.1)], amplitudes_sq[int(np.size(amplitudes_sq)-int(np.size(amplitudes_sq)*.1)):]])) + '''offset is calculated from the first and last 10% of the data to improve fitting on tight windows''' + + if np.abs(np.max(amplitudes_sq)-np.mean(amplitudes_sq)) > np.abs(np.min(amplitudes_sq)-np.mean(amplitudes_sq)): + '''peak is expected''' + s_a = np.abs((np.max(amplitudes_sq)-np.mean(amplitudes_sq))) + s_f0 = self._fit_frequency[np.argmax(amplitudes_sq)] + else: + '''dip is expected''' + s_a = -np.abs((np.min(amplitudes_sq)-np.mean(amplitudes_sq))) + s_f0 = self._fit_frequency[np.argmin(amplitudes_sq)] + + '''estimate peak/dip width''' + mid = s_offs + .5*s_a #estimated mid region between base line and peak/dip + m = [] #mid points + for i in range(len(amplitudes_sq)-1): + if np.sign(amplitudes_sq[i]-mid) != np.sign(amplitudes_sq[i+1]-mid):#mid level crossing + m.append(i) + if len(m)>1: + s_k = self._fit_frequency[m[-1]]-self._fit_frequency[m[0]] + else: + s_k = .15*(self._fit_frequency[-1]-self._fit_frequency[0]) #try 15% of window + p0=[s_f0, s_k, s_a, s_offs] + try: + fit = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) + except: + self._lrnz_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) + self._lrnz_f0.append(np.nan) + self._lrnz_k.append(np.nan) + self._lrnz_a.append(np.nan) + self._lrnz_offs.append(np.nan) + self._lrnz_Ql.append(np.nan) + self._lrnz_chi2_fit.append(np.nan) + else: + popt=fit[0] + chi2 = self._lorentzian_fit_chi2(popt,amplitudes_sq) + self._lrnz_amp_gen.append(np.sqrt(np.array(self._lorentzian_from_fit(popt)))) + self._lrnz_f0.append(float(popt[0])) + self._lrnz_k.append(float(np.fabs(float(popt[1])))) + self._lrnz_a.append(float(popt[2])) + self._lrnz_offs.append(float(popt[3])) + self._lrnz_Ql.append(float(float(popt[0])/np.fabs(float(popt[1])))) + self._lrnz_chi2_fit.append(float(chi2)) + + def _prepare_lorentzian(self): + ''' + creates the datasets for the lorentzian fit in the hdf-file + ''' + if self._ds_type == ds_types['vector']: # data from measure_1d + self._lrnz_amp_gen = self._hf.add_value_vector('lrnz_amp_gen', folder = 'analysis', x = self._frequency_co, unit = 'arb. unit') + self._lrnz_f0 = self._hf.add_coordinate('lrnz_f0', folder = 'analysis', unit = 'Hz') + self._lrnz_k = self._hf.add_coordinate('lrnz_k', folder = 'analysis', unit = 'Hz') + self._lrnz_a = self._hf.add_coordinate('lrnz_a', folder = 'analysis', unit = '') + self._lrnz_offs = self._hf.add_coordinate('lrnz_offs', folder = 'analysis', unit = '') + self._lrnz_Ql = self._hf.add_coordinate('lrnz_ql', folder = 'analysis', unit = '') + + self._lrnz_chi2_fit = self._hf.add_coordinate('lrnz_chi2' , folder = 'analysis', unit = '') + + if self._ds_type == ds_types['matrix']: # data from measure_2d + self._lrnz_amp_gen = self._hf.add_value_matrix('lrnz_amp_gen', folder = 'analysis', x = self._x_co, y = self._frequency_co, unit = 'arb. unit') + self._lrnz_f0 = self._hf.add_value_vector('lrnz_f0', folder = 'analysis', x = self._x_co, unit = 'Hz') + self._lrnz_k = self._hf.add_value_vector('lrnz_k', folder = 'analysis', x = self._x_co, unit = 'Hz') + self._lrnz_a = self._hf.add_value_vector('lrnz_a', folder = 'analysis', x = self._x_co, unit = '') + self._lrnz_offs = self._hf.add_value_vector('lrnz_offs', folder = 'analysis', x = self._x_co, unit = '') + self._lrnz_Ql = self._hf.add_value_vector('lrnz_ql', folder = 'analysis', x = self._x_co, unit = '') + + self._lrnz_chi2_fit = self._hf.add_value_vector('lrnz_chi2' , folder = 'analysis', x = self._x_co, unit = '') + + lrnz_view = self._hf.add_view("lrnz_fit", x = self._y_co, y = self._ds_amp) + lrnz_view.add(x=self._frequency_co, y=self._lrnz_amp_gen) + + def _lorentzian_from_fit(self,fit): + return fit[2] / (1 + (4*((self._fit_frequency-fit[0])/fit[1]) ** 2)) + fit[3] + + def _lorentzian_fit_chi2(self, fit, amplitudes_sq): + chi2 = np.sum((self._lorentzian_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) + return chi2 + + def fit_skewed_lorentzian(self, fit_all = False, f_min=None, f_max=None,pre_filter_data=None): + ''' + skewed lorentzian fit for amp data in the f_min-f_max frequency range + squared amps are fitted at skewed lorentzian using scipy.leastsq + fit parameter, chi2, and generated amp are stored in the hdf-file + + input: + fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) + f_min (float): lower boundary for data to be fitted (optional, default: None, results in min(frequency-array)) + f_max (float): upper boundary for data to be fitted (optional, default: None, results in max(frequency-array)) + ''' + def residuals(p,x,y): + A2, A4, Qr = p + err = y -(A1a+A2*(x-fra)+(A3a+A4*(x-fra))/(1.+4.*Qr**2*((x-fra)/fra)**2)) + return err + def residuals2(p,x,y): + A1, A2, A3, A4, fr, Qr = p + err = y -(A1+A2*(x-fr)+(A3+A4*(x-fr))/(1.+4.*Qr**2*((x-fr)/fr)**2)) + return err + + self._fit_all = fit_all + + if not self._datasets_loaded: + self._get_datasets() + self._update_data() + + self._prepare_f_range(f_min,f_max) + if self._first_skewed_lorentzian: + self._prepare_skewed_lorentzian() + self._first_skewed_lorentzian = False + + ''' + fit_amplitude is always 2dim np array. + for 1dim data, shape: (1, # fit frequency points) + for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) + ''' + if not self._fit_all: + self._get_last_amp_trace() + + for amplitudes in self._fit_amplitude: + "fits a skewed lorenzian to reflection amplitudes of a resonator" + # prefilter the data + amplitudes = self._pre_filter_data(amplitudes) + + amplitudes = np.absolute(amplitudes) + amplitudes_sq = amplitudes**2 + + A1a = np.minimum(amplitudes_sq[0],amplitudes_sq[-1]) + A3a = -np.max(amplitudes_sq) + fra = self._fit_frequency[np.argmin(amplitudes_sq)] + + p0 = [0., 0., 1e3] + + try: + p_final = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) + A2a, A4a, Qra = p_final[0] + + p0 = [A1a, A2a , A3a, A4a, fra, Qra] + p_final = leastsq(residuals2,p0,args=(self._fit_frequency,amplitudes_sq)) + popt=p_final[0] + except: + self._skwd_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) + self._skwd_f0.append(np.nan) + self._skwd_a1.append(np.nan) + self._skwd_a2.append(np.nan) + self._skwd_a3.append(np.nan) + self._skwd_a4.append(np.nan) + self._skwd_Qr.append(np.nan) + self._skwd_chi2_fit.append(np.nan) + else: + chi2 = self._skewed_fit_chi2(popt,amplitudes_sq) + amp_gen = np.sqrt(np.array(self._skewed_from_fit(popt))) + + self._skwd_amp_gen.append(amp_gen) + self._skwd_f0.append(float(popt[4])) + self._skwd_a1.append(float(popt[0])) + self._skwd_a2.append(float(popt[1])) + self._skwd_a3.append(float(popt[2])) + self._skwd_a4.append(float(popt[3])) + self._skwd_Qr.append(float(popt[5])) + self._skwd_chi2_fit.append(float(chi2)) + + self._skwd_Qi.append(self._skewed_estimate_Qi(popt)) + + def _prepare_skewed_lorentzian(self): + ''' + creates the datasets for the skewed lorentzian fit in the hdf-file + ''' + if self._ds_type == ds_types['vector']: # data from measure_1d + self._skwd_amp_gen = self._hf.add_value_vector('sklr_amp_gen', folder = 'analysis', x = self._frequency_co, unit = 'arb. unit') + self._skwd_f0 = self._hf.add_coordinate('sklr_f0', folder = 'analysis', unit = 'Hz') + self._skwd_a1 = self._hf.add_coordinate('sklr_a1', folder = 'analysis', unit = 'Hz') + self._skwd_a2 = self._hf.add_coordinate('sklr_a2', folder = 'analysis', unit = '') + self._skwd_a3 = self._hf.add_coordinate('sklr_a3', folder = 'analysis', unit = '') + self._skwd_a4 = self._hf.add_coordinate('sklr_a4', folder = 'analysis', unit = '') + self._skwd_Qr = self._hf.add_coordinate('sklr_qr', folder = 'analysis', unit = '') + self._skwd_Qi = self._hf.add_coordinate('sklr_qi', folder = 'analysis', unit = '') + + self._skwd_chi2_fit = self._hf.add_coordinate('sklr_chi2' , folder = 'analysis', unit = '') + + if self._ds_type == ds_types['matrix']: # data from measure_2d + self._skwd_amp_gen = self._hf.add_value_matrix('sklr_amp_gen', folder = 'analysis', x = self._x_co, y = self._frequency_co, unit = 'arb. unit') + self._skwd_f0 = self._hf.add_value_vector('sklr_f0', folder = 'analysis', x = self._x_co, unit = 'Hz') + self._skwd_a1 = self._hf.add_value_vector('sklr_a1', folder = 'analysis', x = self._x_co, unit = 'Hz') + self._skwd_a2 = self._hf.add_value_vector('sklr_a2', folder = 'analysis', x = self._x_co, unit = '') + self._skwd_a3 = self._hf.add_value_vector('sklr_a3', folder = 'analysis', x = self._x_co, unit = '') + self._skwd_a4 = self._hf.add_value_vector('sklr_a4', folder = 'analysis', x = self._x_co, unit = '') + self._skwd_Qr = self._hf.add_value_vector('sklr_qr', folder = 'analysis', x = self._x_co, unit = '') + self._skwd_Qi = self._hf.add_value_vector('sklr_qi', folder = 'analysis', x = self._x_co, unit = '') + + self._skwd_chi2_fit = self._hf.add_value_vector('sklr_chi2' , folder = 'analysis', x = self._x_co, unit = '') + + skwd_view = self._hf.add_view('sklr_fit', x = self._y_co, y = self._ds_amp) + skwd_view.add(x=self._frequency_co, y=self._skwd_amp_gen) + + def _skewed_fit_chi2(self, fit, amplitudes_sq): + chi2 = np.sum((self._skewed_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) + return chi2 + + def _skewed_from_fit(self,p): + A1, A2, A3, A4, fr, Qr = p + return A1+A2*(self._fit_frequency-fr)+(A3+A4*(self._fit_frequency-fr))/(1.+4.*Qr**2*((self._fit_frequency-fr)/fr)**2) + + def _skewed_estimate_Qi(self,p): + + #this is a very clumsy numerical estimate of the Qi factor based on the +3dB method.# + A1, A2, A3, A4, fr, Qr = p + def skewed_from_fit(p,f): + A1, A2, A3, A4, fr, Qr = p + return A1+A2*(f-fr)+(A3+A4*(f-fr))/(1.+4.*Qr**2*((f-fr)/fr)**2) + + #df = fr/(Qr*10000) + fmax = fr+fr/Qr + fs = np.linspace(fr,fmax,1000,dtype=np.float64) + Amin = skewed_from_fit(p,fr) + #print("---") + #print("Amin, 2*Amin",Amin, 2*Amin) + + for f in fs: + A = skewed_from_fit(p,f) + #print(A, f) + if A>2*Amin: + break + qi = fr/(2*(f-fr)) + #print("f, A, fr/2*(f-fr)", f,A, qi) + + return float(qi) + + def _prepare_fano(self): + "create the datasets for the fano fit in the hdf-file" + if self._ds_type == ds_types['vector']: # data from measure_1d + self._fano_amp_gen = self._hf.add_value_vector('fano_amp_gen', folder = 'analysis', x = self._frequency_co, unit = 'arb. unit') + self._fano_q_fit = self._hf.add_coordinate('fano_q' , folder = 'analysis', unit = '') + self._fano_bw_fit = self._hf.add_coordinate('fano_bw', folder = 'analysis', unit = 'Hz') + self._fano_fr_fit = self._hf.add_coordinate('fano_fr', folder = 'analysis', unit = 'Hz') + self._fano_a_fit = self._hf.add_coordinate('fano_a' , folder = 'analysis', unit = '') + + self._fano_chi2_fit = self._hf.add_coordinate('fano_chi2' , folder = 'analysis', unit = '') + self._fano_Ql_fit = self._hf.add_coordinate('fano_Ql' , folder = 'analysis', unit = '') + self._fano_Q0_fit = self._hf.add_coordinate('fano_Q0' , folder = 'analysis', unit = '') + + if self._ds_type == ds_types['matrix']: # data from measure_2d + self._fano_amp_gen = self._hf.add_value_matrix('fano_amp_gen', folder = 'analysis', x = self._x_co, y = self._frequency_co, unit = 'arb. unit') + self._fano_q_fit = self._hf.add_value_vector('fano_q' , folder = 'analysis', x = self._x_co, unit = '') + self._fano_bw_fit = self._hf.add_value_vector('fano_bw', folder = 'analysis', x = self._x_co, unit = 'Hz') + self._fano_fr_fit = self._hf.add_value_vector('fano_fr', folder = 'analysis', x = self._x_co, unit = 'Hz') + self._fano_a_fit = self._hf.add_value_vector('fano_a' , folder = 'analysis', x = self._x_co, unit = '') + + self._fano_chi2_fit = self._hf.add_value_vector('fano_chi2' , folder = 'analysis', x = self._x_co, unit = '') + self._fano_Ql_fit = self._hf.add_value_vector('fano_Ql' , folder = 'analysis', x = self._x_co, unit = '') + self._fano_Q0_fit = self._hf.add_value_vector('fano_Q0' , folder = 'analysis', x = self._x_co, unit = '') + + fano_view = self._hf.add_view('fano_fit', x = self._y_co, y = self._ds_amp) + fano_view.add(x=self._frequency_co, y=self._fano_amp_gen) + + def fit_fano(self,fit_all = False, f_min=None, f_max=None,pre_filter_data=None): + ''' + fano fit for amp data in the f_min-f_max frequency range + squared amps are fitted at fano using scipy.leastsq + fit parameter, chi2, q0, and generated amp are stored in the hdf-file + + input: + fit_all (bool): True or False, default: False. Whole data (True) or only last "slice" (False) is fitted (optional) + f_min (float): lower boundary for data to be fitted (optional, default: None, results in min(frequency-array)) + f_max (float): upper boundary for data to be fitted (optional, default: None, results in max(frequency-array)) + ''' + + self._fit_all = fit_all + if not self._datasets_loaded: + self._get_datasets() + self._update_data() + self._prepare_f_range(f_min,f_max) + if self._first_fano: + self._prepare_fano() + self._first_fano = False + + ''' + fit_amplitude is always 2dim np array. + for 1dim data, shape: (1, # fit frequency points) + for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) + ''' + if not self._fit_all: + self._get_last_amp_trace() + + for amplitudes in self._fit_amplitude: + amplitude_sq = (np.absolute(amplitudes))**2 + try: + fit = self._do_fit_fano(amplitude_sq) + amplitudes_gen = self._fano_reflection_from_fit(fit) + + '''calculate the chi2 of fit and data''' + chi2 = self._fano_fit_chi2(fit, amplitude_sq) + + except: + self._fano_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) + self._fano_q_fit.append(np.nan) + self._fano_bw_fit.append(np.nan) + self._fano_fr_fit.append(np.nan) + self._fano_a_fit.append(np.nan) + self._fano_chi2_fit.append(np.nan) + self._fano_Ql_fit.append(np.nan) + self._fano_Q0_fit.append(np.nan) + + else: + ''' save the fitted data to the hdf_file''' + self._fano_amp_gen.append(np.sqrt(np.absolute(amplitudes_gen))) + self._fano_q_fit.append(float(fit[0])) + self._fano_bw_fit.append(float(fit[1])) + self._fano_fr_fit.append(float(fit[2])) + self._fano_a_fit.append(float(fit[3])) + self._fano_chi2_fit.append(float(chi2)) + self._fano_Ql_fit.append(float(fit[2])/float(fit[1])) + q0=self._fano_fit_q0(np.sqrt(np.absolute(amplitudes_gen)),float(fit[2])) + self._fano_Q0_fit.append(q0) + + def _fano_reflection(self,f,q,bw,fr,a=1,b=1): + ''' + evaluates the fano function in reflection at the + frequency f + with + resonator frequency fr + attenuation a (linear) + fano-factor q + bandwidth bw + ''' + return a*(1 - self._fano_transmission(f,q,bw,fr)) + + def _fano_transmission(self,f,q,bw,fr,a=1,b=1): + ''' + evaluates the normalized transmission fano function at the + frequency f + with + resonator frequency fr + attenuation a (linear) + fano-factor q + bandwidth bw + ''' + F = 2*(f-fr)/bw + return ( 1/(1+q**2) * (F+q)**2 / (F**2+1)) + + def _do_fit_fano(self, amplitudes_sq): + # initial guess + bw = 1e6 + q = 1 #np.sqrt(1-amplitudes_sq).min() # 1-Amp_sq = 1-1+q^2 => A_min = q + fr = self._fit_frequency[np.argmin(amplitudes_sq)] + a = amplitudes_sq.max() + + p0 = [q, bw, fr, a] + + def fano_residuals(p,frequency,amplitude_sq): + q, bw, fr, a = p + err = amplitude_sq-self._fano_reflection(frequency,q,bw,fr=fr,a=a) + return err + + p_fit = leastsq(fano_residuals,p0,args=(self._fit_frequency,np.array(amplitudes_sq))) + #print(("q:%g bw:%g fr:%g a:%g")% (p_fit[0][0],p_fit[0][1],p_fit[0][2],p_fit[0][3])) + return p_fit[0] + + def _fano_reflection_from_fit(self,fit): + return self._fano_reflection(self._fit_frequency,fit[0],fit[1],fit[2],fit[3]) + + def _fano_fit_chi2(self,fit,amplitudes_sq): + chi2 = np.sum((self._fano_reflection_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) + return chi2 + + def _fano_fit_q0(self,amp_gen,fr): + ''' + calculates q0 from 3dB bandwidth above minimum in fit function + ''' + amp_3dB=10*np.log10((np.min(amp_gen)))+3 + amp_3dB_lin=10**(amp_3dB/10) + f_3dB=[] + for i in range(len(amp_gen)-1): + if np.sign(amp_gen[i]-amp_3dB_lin) != np.sign(amp_gen[i+1]-amp_3dB_lin):#crossing@amp_3dB + f_3dB.append(self._fit_frequency[i]) + if len(f_3dB)>1: + q0 = fr/(f_3dB[1]-f_3dB[0]) + return float(q0) + else: return np.nan + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser( + description="resonator.py hdf-based simple resonator fit frontend / KIT 2015") + + parser.add_argument('-f','--file', type=str, help='hdf filename to open') + parser.add_argument('-lf','--lorentzian-fit', default=False,action='store_true', help='(optional) lorentzian fit') + parser.add_argument('-ff','--fano-fit', default=False,action='store_true', help='(optional) fano fit') + parser.add_argument('-cf','--circle-fit', default=False,action='store_true', help='(optional) circle fit') + parser.add_argument('-slf','--skewed-lorentzian-fit', default=False,action='store_true',help='(optional) skewed lorentzian fit') + parser.add_argument('-all','--fit-all', default=False,action='store_true',help='(optional) fit all entries in dataset') + parser.add_argument('-fr','--frequency-range', type=str, help='(optional) frequency range for fitting, comma separated') + parser.add_argument('-fg','--filter-gaussian', default=False, action='store_true', help='(optional) (pre-) filter data: gaussian') + parser.add_argument('-fm','--filter-median', default=False, action='store_true', help='(optional) (pre-) filter data: median') + parser.add_argument('-fp','--filter-params',type=str, help='(optional) (pre-) filter data: parameter') + parser.add_argument('-d','--debug-output', default=False, action='store_true', help='(optional) debug: more verbose') + parser.add_argument('-t','--type', type=str, help='resonator type: (r)eflection or (n)otch') + args=parser.parse_args() + #argsfile=None + if args.file: + R = Resonator(args.file) + if args.debug_output: + R._debug = True + + fit_all = args.fit_all + + if args.frequency_range: + freq_range=args.frequency_range.split(',') + f_min=int(float(freq_range[0])) + f_max=int(float(freq_range[1])) + else: + f_min=None + f_max=None + + if args.filter_median: + if args.filter_params: + filter_params = args.filter_params.split(',') + R.set_prefilter(median=True,params=[float(filter_params[0])]) + else: + R.set_prefilter(median=True) + + if args.filter_gaussian: + if args.filter_params: + filter_params = args.filter_params.split(',') + R.set_prefilter(gaussian=True,params=[float(filter_params[0])]) + else: + R.set_prefilter(gaussian=True) + + if args.circle_fit: + if args.type == 'r': + R.fit_circle(reflection = True, fit_all=fit_all, f_min=f_min,f_max=f_max) + elif args.type == 'n': + R.fit_circle(notch = True, fit_all=fit_all, f_min=f_min,f_max=f_max) + else: + R.fit_circle(notch = True, fit_all=fit_all, f_min=f_min,f_max=f_max) + if args.lorentzian_fit: + R.fit_lorentzian(fit_all=fit_all, f_min=f_min,f_max=f_max) + if args.skewed_lorentzian_fit: + R.fit_skewed_lorentzian(fit_all=fit_all, f_min=f_min,f_max=f_max) + if args.fano_fit: + R.fit_fano(fit_all=fit_all, f_min=f_min,f_max=f_max) + R.close() + else: + print("no file supplied. type -h for help") diff --git a/src/qkit/analysis/resonator_fitting.py b/src/qkit/analysis/resonator_fitting.py index 0702e57e1..a1477b5ff 100644 --- a/src/qkit/analysis/resonator_fitting.py +++ b/src/qkit/analysis/resonator_fitting.py @@ -5,6 +5,7 @@ import logging from qkit.storage.store import Data as qkitData from qkit.storage.hdf_dataset import hdf_dataset +from qkit.analysis.circle_fit.circle_fit_2019 import circuit class ResonatorFitBase(ABC): """ @@ -151,31 +152,30 @@ class CircleFit(ResonatorFitBase): def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: float = None, isolation: int = 15, guesses: list[float] = None): super().__init__() self.extract_data = { - "f_res": None, - "f_res_err": None, - "Qc": None, - #"Qc_err": None, - "Qc_no_dia_corr": None, - "Qc_no_dia_corr_err": None, - "Qc_max": None, - "Qc_min": None, - "Qi": None, - "Qi_err": None, - "Qi_no_dia_corr": None, - "Qi_no_dia_corr_err": None, - "Qi_max": None, - "Qi_min": None, - "Ql": None, - "Ql_err": None, - "a": None, - "alpha": None, - "chi_square": None, - "delay": None, - "delay_remaining": None, - "fano_b": None, - "phi": None, - "phi_err": None, - "theta": None, + 'Qc': np.nan, + 'Qc_max': np.nan, + 'Qc_min': np.nan, + 'Qc_no_dia_corr': np.nan, + 'Qi': np.nan, + 'Qi_err': np.nan, + 'Qi_max': np.nan, + 'Qi_min': np.nan, + 'Qi_no_dia_corr': np.nan, + 'Qi_no_dia_corr_err': np.nan, + 'Ql': np.nan, + 'Ql_err': np.nan, + 'a': np.nan, + 'absQc_err': np.nan, + 'alpha': np.nan, + 'chi_square': np.nan, + 'delay': np.nan, + 'delay_remaining': np.nan, + 'fano_b': np.nan, + 'fr': np.nan, + 'fr_err': np.nan, + 'phi': np.nan, + 'phi_err': np.nan, + 'theta': np.nan } # fit parameters self.n_ports = n_ports # 1: reflection port, 2: notch port @@ -185,333 +185,44 @@ def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: self.guesses = guesses # init guess (f_res, Ql, delay) for phase fit def do_fit(self, freq: np.ndarray[float], amp: np.ndarray[float], pha: np.ndarray[float]): - z = amp*np.exp(1j*pha) - # init with empty data - self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) - self.amp_fit = np.full(self.out_nop, np.nan) - self.pha_fit = np.full(self.out_nop, np.nan) - for key in self.extract_data.keys(): - self.extract_data[key] = np.nan - """ helper functions""" - _phase_centered = lambda f, fr, Ql, theta, delay=0: theta - 2*np.pi*delay*(f-fr) + 2.*np.arctan(2.*Ql*(1. - f/fr)) - _periodic_boundary = lambda angle: (angle + np.pi) % (2*np.pi) - np.pi - Sij = lambda f, fr, Ql, Qc, phi=0, a=1, alpha=0, delay=0: a*np.exp(1j*(alpha-2*np.pi*f*delay)) * (1 - 2*Ql/(Qc*np.cos(phi)*np.exp(-1j*phi)*self.n_ports*(1 + 2j*Ql*(f/fr-1)))) - def _fit_circle(z_data: np.ndarray): - """ - Analytical fit of a circle to the scattering data z_data. Cf. Sebastian - Probst: "Efficient and robust analysis of complex scattering data under - noise in microwave resonators" (arXiv:1410.3365v2) - """ - - # Normalize circle to deal with comparable numbers - x_norm = 0.5*(np.max(z_data.real) + np.min(z_data.real)) - y_norm = 0.5*(np.max(z_data.imag) + np.min(z_data.imag)) - z_data = z_data[:] - (x_norm + 1j*y_norm) - amp_norm = np.max(np.abs(z_data)) - z_data = z_data / amp_norm - - # Calculate matrix of moments - xi = z_data.real - xi_sqr = xi*xi - yi = z_data.imag - yi_sqr = yi*yi - zi = xi_sqr+yi_sqr - Nd = float(len(xi)) - xi_sum = xi.sum() - yi_sum = yi.sum() - zi_sum = zi.sum() - xiyi_sum = (xi*yi).sum() - xizi_sum = (xi*zi).sum() - yizi_sum = (yi*zi).sum() - M = np.array([ - [(zi*zi).sum(), xizi_sum, yizi_sum, zi_sum], - [xizi_sum, xi_sqr.sum(), xiyi_sum, xi_sum], - [yizi_sum, xiyi_sum, yi_sqr.sum(), yi_sum], - [zi_sum, xi_sum, yi_sum, Nd] - ]) - - # Lets skip line breaking at 80 characters for a moment :D - a0 = ((M[2][0]*M[3][2]-M[2][2]*M[3][0])*M[1][1]-M[1][2]*M[2][0]*M[3][1]-M[1][0]*M[2][1]*M[3][2]+M[1][0]*M[2][2]*M[3][1]+M[1][2]*M[2][1]*M[3][0])*M[0][3]+(M[0][2]*M[2][3]*M[3][0]-M[0][2]*M[2][0]*M[3][3]+M[0][0]*M[2][2]*M[3][3]-M[0][0]*M[2][3]*M[3][2])*M[1][1]+(M[0][1]*M[1][3]*M[3][0]-M[0][1]*M[1][0]*M[3][3]-M[0][0]*M[1][3]*M[3][1])*M[2][2]+(-M[0][1]*M[1][2]*M[2][3]-M[0][2]*M[1][3]*M[2][1])*M[3][0]+((M[2][3]*M[3][1]-M[2][1]*M[3][3])*M[1][2]+M[2][1]*M[3][2]*M[1][3])*M[0][0]+(M[1][0]*M[2][3]*M[3][2]+M[2][0]*(M[1][2]*M[3][3]-M[1][3]*M[3][2]))*M[0][1]+((M[2][1]*M[3][3]-M[2][3]*M[3][1])*M[1][0]+M[1][3]*M[2][0]*M[3][1])*M[0][2] - a1 = (((M[3][0]-2.*M[2][2])*M[1][1]-M[1][0]*M[3][1]+M[2][2]*M[3][0]+2.*M[1][2]*M[2][1]-M[2][0]*M[3][2])*M[0][3]+(2.*M[2][0]*M[3][2]-M[0][0]*M[3][3]-2.*M[2][2]*M[3][0]+2.*M[0][2]*M[2][3])*M[1][1]+(-M[0][0]*M[3][3]+2.*M[0][1]*M[1][3]+2.*M[1][0]*M[3][1])*M[2][2]+(-M[0][1]*M[1][3]+2.*M[1][2]*M[2][1]-M[0][2]*M[2][3])*M[3][0]+(M[1][3]*M[3][1]+M[2][3]*M[3][2])*M[0][0]+(M[1][0]*M[3][3]-2.*M[1][2]*M[2][3])*M[0][1]+(M[2][0]*M[3][3]-2.*M[1][3]*M[2][1])*M[0][2]-2.*M[1][2]*M[2][0]*M[3][1]-2.*M[1][0]*M[2][1]*M[3][2]) - a2 = ((2.*M[1][1]-M[3][0]+2.*M[2][2])*M[0][3]+(2.*M[3][0]-4.*M[2][2])*M[1][1]-2.*M[2][0]*M[3][2]+2.*M[2][2]*M[3][0]+M[0][0]*M[3][3]+4.*M[1][2]*M[2][1]-2.*M[0][1]*M[1][3]-2.*M[1][0]*M[3][1]-2.*M[0][2]*M[2][3]) - a3 = (-2.*M[3][0]+4.*M[1][1]+4.*M[2][2]-2.*M[0][3]) - a4 = -4. - - def char_pol(x): - return a0 + a1*x + a2*x**2 + a3*x**3 + a4*x**4 - - def d_char_pol(x): - return a1 + 2*a2*x + 3*a3*x**2 + 4*a4*x**3 - - eta = spopt.newton(char_pol, 0., fprime=d_char_pol) - - M[3][0] = M[3][0] + 2*eta - M[0][3] = M[0][3] + 2*eta - M[1][1] = M[1][1] - eta - M[2][2] = M[2][2] - eta - - U,s,Vt = np.linalg.svd(M) - A_vec = Vt[np.argmin(s),:] - - xc = -A_vec[1]/(2.*A_vec[0]) - yc = -A_vec[2]/(2.*A_vec[0]) - # The term *sqrt term corrects for the constraint, because it may be - # altered due to numerical inaccuracies during calculation - r0 = 1./(2.*np.absolute(A_vec[0]))*np.sqrt( - A_vec[1]*A_vec[1]+A_vec[2]*A_vec[2]-4.*A_vec[0]*A_vec[3] - ) - - return xc*amp_norm+x_norm, yc*amp_norm+y_norm, r0*amp_norm - def _fit_phase(z_data: np.ndarray, guesses = self.guesses): - """ - Fits the phase response of a strongly overcoupled (Qi >> Qc) resonator - in reflection which corresponds to a circle centered around the origin - (cf. phase_centered()). - - inputs: - - z_data: Scattering data of which the phase should be fit. Data must be - distributed around origin ("circle-like"). - - guesses (opt.): If not given, initial guesses for the fit parameters - will be determined. If given, should contain useful - guesses for fit parameters as a tuple (fr, Ql, delay) - - outputs: - - fr: Resonance frequency - - Ql: Loaded quality factor - - theta: Offset phase - - delay: Time delay between output and input signal leading to linearly - frequency dependent phase shift - """ - phase = np.unwrap(np.angle(z_data)) - - # For centered circle roll-off should be close to 2pi. If not warn user. - if np.max(phase) - np.min(phase) <= 2*np.pi/4: - logging.warning( - "Data does not cover a full circle (only {:.1f}".format( - np.max(phase) - np.min(phase) - ) - +" rad). Increase the frequency span around the resonance?" - ) - roll_off = np.max(phase) - np.min(phase) - elif np.max(phase) - np.min(phase) <= 2*np.pi*4/5: - logging.debug( - "Data does not cover a full circle (only {:.1f}".format( - np.max(phase) - np.min(phase) - ) - +" rad). Increase the frequency span around the resonance?" - ) - roll_off = np.max(phase) - np.min(phase) - else: - roll_off = 2*np.pi - - # Set useful starting parameters - if guesses is None: - # Use maximum of derivative of phase as guess for fr - phase_smooth = scipy.ndimage.gaussian_filter1d(phase, 30) - phase_derivative = np.gradient(phase_smooth) - fr_guess = freq[np.argmax(np.abs(phase_derivative))] - Ql_guess = 2*fr_guess / (freq[-1] - freq[0]) - # Estimate delay from background slope of phase (substract roll-off) - slope = phase[-1] - phase[0] + roll_off - delay_guess = -slope / (2*np.pi*(freq[-1]-freq[0])) - else: - fr_guess, Ql_guess, delay_guess = guesses - # This one seems stable and we do not need a manual guess for it - theta_guess = 0.5*(np.mean(phase[:5]) + np.mean(phase[-5:])) - - # Fit model with less parameters first to improve stability of fit - - def residuals_Ql(params): - Ql, = params - return residuals_full((fr_guess, Ql, theta_guess, delay_guess)) - def residuals_fr_theta(params): - fr, theta = params - return residuals_full((fr, Ql_guess, theta, delay_guess)) - def residuals_delay(params): - delay, = params - return residuals_full((fr_guess, Ql_guess, theta_guess, delay)) - def residuals_fr_Ql(params): - fr, Ql = params - return residuals_full((fr, Ql, theta_guess, delay_guess)) - def residuals_full(params): - return np.pi - np.abs(np.pi - np.abs(phase - _phase_centered(freq, *params))) + # use external circlefit + my_circuit = circuit(freq, amp*np.exp(1j*pha)) + my_circuit.n_ports = self.n_ports + my_circuit.fit_delay_max_iterations = self.fit_delay_max_iterations + my_circuit.autofit(fixed_delay=self.fixed_delay, isolation=self.isolation) - p_final = spopt.leastsq(residuals_Ql, [Ql_guess]) - Ql_guess, = p_final[0] - p_final = spopt.leastsq(residuals_fr_theta, [fr_guess, theta_guess]) - fr_guess, theta_guess = p_final[0] - p_final = spopt.leastsq(residuals_delay, [delay_guess]) - delay_guess, = p_final[0] - p_final = spopt.leastsq(residuals_fr_Ql, [fr_guess, Ql_guess]) - fr_guess, Ql_guess = p_final[0] - p_final = spopt.leastsq(residuals_full, [fr_guess, Ql_guess, theta_guess, delay_guess]) - - return p_final[0] - - """delay""" - if self.fixed_delay is not None: - delay = self.fixed_delay - else: - xc, yc, r0 = _fit_circle(z) - z_data = z - complex(xc, yc) - fr, Ql, theta, delay = _fit_phase(z_data) - delay *= 0.05 - - for i in range(self.fit_delay_max_iterations): - # Translate new best fit data to origin - z_data = z * np.exp(2j*np.pi*delay*freq) - xc, yc, r0 = _fit_circle(z_data) - z_data -= complex(xc, yc) - - # Find correction to current delay - guesses = (fr, Ql, 5e-11) - fr, Ql, theta, delay_corr = _fit_phase(z_data, guesses) - - # Stop if correction would be smaller than "measurable" - phase_fit = _phase_centered(freq, fr, Ql, theta, delay_corr) - residuals = np.unwrap(np.angle(z_data)) - phase_fit - if 2*np.pi*(np.max(freq) - np.min(freq))*delay_corr <= np.std(residuals): - break - - # Avoid overcorrection that makes procedure switch between positive - # and negative delays - if delay_corr*delay < 0: # different sign -> be careful - if abs(delay_corr) > abs(delay): - delay *= 0.5 - else: - delay += 0.1*np.sign(delay_corr)*5e-11 - else: # same direction -> can converge faster - if abs(delay_corr) >= 1e-8: - delay += min(delay_corr, delay) - elif abs(delay_corr) >= 1e-9: - delay *= 1.1 - else: - delay += delay_corr - - if 2*np.pi*(freq[-1]-freq[0])*delay_corr > np.std(residuals): - logging.debug("Delay could not be fit properly!") - - self.extract_data["delay"] = delay - - """calibrate""" - z_data = z*np.exp(2j*np.pi*delay*freq) # correct delay - xc, yc, r0 = _fit_circle(z_data) - z_data -= complex(xc, yc) - - # Find off-resonant point by fitting offset phase - # (centered circle corresponds to lossless resonator in reflection) - fr, Ql, theta, delay_remaining = _fit_phase(z_data) - theta = _periodic_boundary(theta) - beta = _periodic_boundary(theta - np.pi) - offrespoint = complex(xc, yc) + r0*np.exp(1j*beta) - a = np.absolute(offrespoint) - alpha = np.angle(offrespoint) - phi = _periodic_boundary(beta - alpha) - - r0 /= a - - # Store results in dictionary - self.extract_data["delay_remaining"] = delay_remaining - self.extract_data["a"] = a - self.extract_data["alpha"] = alpha - self.extract_data["theta"] = theta - self.extract_data["phi"] = phi - self.extract_data["f_res"] = fr - self.extract_data["Ql"] = Ql - - """normalize""" - z_norm = z/a*np.exp(1j*(-alpha + 2.*np.pi*delay*freq)) - - """extract Qs""" - absQc = Ql / (self.n_ports*r0) - # For Qc, take real part of 1/(complex Qc) (diameter correction method) - Qc = absQc / np.cos(phi) - Qi = 1/(1/Ql - 1/Qc) - Qi_no_dia_corr = 1/(1/Ql - 1/absQc) - - self.extract_data["Qc"] = Qc - self.extract_data["Qc_no_dia_corr"] = absQc - self.extract_data["Qi"] = Qi - self.extract_data["Qi_no_dia_corr"] = Qi_no_dia_corr - - """errors""" - residuals = z_norm - Sij(freq, fr, Ql, Qc, phi) - chi = np.abs(residuals) - # Unit vectors pointing in the correct directions for the derivative - directions = residuals / chi - # Prepare for fast construction of Jacobian - conj_directions = np.conj(directions) - - # Construct transpose of Jacobian matrix - Jt = np.array([ - np.real(-4j*Ql**2*np.exp(1j*phi)*freq / (self.n_ports * absQc*(fr+2j*Ql*(freq-fr))**2)*conj_directions), - np.real(-2*np.exp(1j*phi) / (self.n_ports * absQc*(1+2j*Ql*(freq/fr-1))**2)*conj_directions), - np.real(2*Ql*np.exp(1j*phi) / (self.n_ports * absQc**2 * (1+2j*Ql*(freq/fr-1)))*conj_directions), - np.real(-2j*Ql*np.exp(1j*phi) / (self.n_ports * absQc * (1.+2j*Ql*(freq/fr-1)))*conj_directions) - ]) - A = np.dot(Jt, np.transpose(Jt)) - # 4 fit parameters reduce degrees of freedom for reduced chi square - chi_square = 1/float(len(freq)-4) * np.sum(chi**2) - try: - cov = np.linalg.inv(A)*chi_square - except: - logging.warning("Error calculation failed!") - cov = None - - if cov is not None: - fr_err, Ql_err, absQc_err, phi_err = np.sqrt(np.diag(cov)) - # Calculate error of Qi with error propagation - # without diameter correction - dQl = 1/((1/Ql - 1/absQc) * Ql)**2 - dabsQc = -1/((1/Ql - 1/absQc) * absQc)**2 - Qi_no_dia_corr_err = np.sqrt(dQl**2*cov[1][1] + dabsQc**2*cov[2][2] + 2.*dQl*dabsQc*cov[1][2]) - # with diameter correction - dQl = 1/((1/Ql - 1/Qc) * Ql)**2 - dabsQc = -np.cos(phi) / ((1/Ql - 1/Qc) * absQc)**2 - dphi = -np.sin(phi) / ((1/Ql - 1/Qc)**2 * absQc) - Qi_err = np.sqrt(dQl**2*cov[1][1] + dabsQc**2*cov[2][2] + dphi**2*cov[3][3] + 2*(dQl*dabsQc*cov[1][2]+ dQl*dphi*cov[1][3]+ dabsQc*dphi*cov[2][3])) - - self.extract_data["f_res_err"] = fr_err - self.extract_data["Ql_err"] = Ql_err - self.extract_data["Qc_no_dia_corr_err"] = absQc_err - self.extract_data["phi_err"] = phi_err - self.extract_data["Qi_err"] = Qi_err - self.extract_data["Qi_no_dia_corr_err"] = Qi_no_dia_corr_err - self.extract_data["chi_square"] = chi_square - - """calc fano range""" - b = 10**(-self.isolation/20) - b = b / (1 - b) - - if np.sin(phi) > b: - logging.warning("Measurement cannot be explained with assumed Fano leakage!") - - # Calculate error on radius of circle - R_mid = r0 * np.cos(phi) - R_err = r0 * np.sqrt(b**2 - np.sin(phi)**2) - R_min = R_mid - R_err - R_max = R_mid + R_err - - # Convert to ranges of quality factors - Qc_min = Ql / (self.n_ports*R_max) - Qc_max = Ql / (self.n_ports*R_min) - Qi_min = Ql / (1 - self.n_ports*R_min) - Qi_max = Ql / (1 - self.n_ports*R_max) - - # Handle unphysical results - if R_max >= 1/self.n_ports: - Qi_max = np.nan - - self.extract_data["Qc_min"] = Qc_min - self.extract_data["Qc_max"] = Qc_max - self.extract_data["Qi_min"] = Qi_min - self.extract_data["Qi_max"] = Qi_max - self.extract_data["fano_b"] = b - - """model data""" - z_fit = Sij(self.freq_fit, fr, Ql, Qc, phi, a, alpha, delay) - self.amp_fit = np.abs(z_fit) - self.pha_fit = np.angle(z_fit) + # update to be read parameters + self.extract_data = { + 'Qc': np.nan, + 'Qc_max': np.nan, + 'Qc_min': np.nan, + 'Qc_no_dia_corr': np.nan, + 'Qi': np.nan, + 'Qi_err': np.nan, + 'Qi_max': np.nan, + 'Qi_min': np.nan, + 'Qi_no_dia_corr': np.nan, + 'Qi_no_dia_corr_err': np.nan, + 'Ql': np.nan, + 'Ql_err': np.nan, + 'a': np.nan, + 'absQc_err': np.nan, + 'alpha': np.nan, + 'chi_square': np.nan, + 'delay': np.nan, + 'delay_remaining': np.nan, + 'fano_b': np.nan, + 'fr': np.nan, + 'fr_err': np.nan, + 'phi': np.nan, + 'phi_err': np.nan, + 'theta': np.nan + } + self.extract_data.update(my_circuit.fitresults) + self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) + z_sim = self.Sij(self.freq_fit, my_circuit.fr, my_circuit.Ql, my_circuit.Qc, my_circuit.phi, my_circuit.a, my_circuit.alpha, my_circuit.delay) + self.amp_fit = np.abs(z_sim) + self.pha_fit = np.angle(z_sim) return self @@ -529,93 +240,12 @@ def __init__(self): def do_fit(self, freq, amp, pha): # TODO - logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") + logging.error("Lorentzian Fit not yet implemented. Feel free to adapt the respective maths yourself based on old resonator class") self.extract_data["f_res"] = 1 self.extract_data["f_res_err"] = 0.5 self.extract_data["Ql"] = 1 self.extract_data["Ql_err"] = 0.5 return super().do_fit(freq, amp, pha) - """ - Old implementation: - - def residuals(p,x,y): - f0,k,a,offs=p - err = y-(a/(1+4*((x-f0)/k)**2)+offs) - return err - - self._fit_all = fit_all - - if not self._datasets_loaded: - self._get_datasets() - - self._update_data() - self._prepare_f_range(f_min,f_max) - if self._first_lorentzian: - self._prepare_lorentzian() - self._first_lorentzian=False - - ''' - fit_amplitude is always 2dim np array. - for 1dim data, shape: (1, # fit frequency points) - for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) - ''' - if not self._fit_all: - self._get_last_amp_trace() - - for amplitudes in self._fit_amplitude: - amplitudes = np.absolute(amplitudes) - amplitudes_sq = amplitudes**2 - '''extract starting parameter for lorentzian from data''' - s_offs = np.mean(np.array([amplitudes_sq[:int(np.size(amplitudes_sq)*.1)], amplitudes_sq[int(np.size(amplitudes_sq)-int(np.size(amplitudes_sq)*.1)):]])) - '''offset is calculated from the first and last 10% of the data to improve fitting on tight windows''' - - if np.abs(np.max(amplitudes_sq)-np.mean(amplitudes_sq)) > np.abs(np.min(amplitudes_sq)-np.mean(amplitudes_sq)): - '''peak is expected''' - s_a = np.abs((np.max(amplitudes_sq)-np.mean(amplitudes_sq))) - s_f0 = self._fit_frequency[np.argmax(amplitudes_sq)] - else: - '''dip is expected''' - s_a = -np.abs((np.min(amplitudes_sq)-np.mean(amplitudes_sq))) - s_f0 = self._fit_frequency[np.argmin(amplitudes_sq)] - - '''estimate peak/dip width''' - mid = s_offs + .5*s_a #estimated mid region between base line and peak/dip - m = [] #mid points - for i in range(len(amplitudes_sq)-1): - if np.sign(amplitudes_sq[i]-mid) != np.sign(amplitudes_sq[i+1]-mid):#mid level crossing - m.append(i) - if len(m)>1: - s_k = self._fit_frequency[m[-1]]-self._fit_frequency[m[0]] - else: - s_k = .15*(self._fit_frequency[-1]-self._fit_frequency[0]) #try 15% of window - p0=[s_f0, s_k, s_a, s_offs] - try: - fit = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) - except: - self._lrnz_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) - self._lrnz_f0.append(np.nan) - self._lrnz_k.append(np.nan) - self._lrnz_a.append(np.nan) - self._lrnz_offs.append(np.nan) - self._lrnz_Ql.append(np.nan) - self._lrnz_chi2_fit.append(np.nan) - else: - popt=fit[0] - chi2 = self._lorentzian_fit_chi2(popt,amplitudes_sq) - self._lrnz_amp_gen.append(np.sqrt(np.array(self._lorentzian_from_fit(popt)))) - self._lrnz_f0.append(float(popt[0])) - self._lrnz_k.append(float(np.fabs(float(popt[1])))) - self._lrnz_a.append(float(popt[2])) - self._lrnz_offs.append(float(popt[3])) - self._lrnz_Ql.append(float(float(popt[0])/np.fabs(float(popt[1])))) - self._lrnz_chi2_fit.append(float(chi2)) - def _lorentzian_from_fit(self,fit): - return fit[2] / (1 + (4*((self._fit_frequency-fit[0])/fit[1]) ** 2)) + fit[3] - - def _lorentzian_fit_chi2(self, fit, amplitudes_sq): - chi2 = np.sum((self._lorentzian_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) - return chi2 - """ class SkewedLorentzianFit(ResonatorFitBase): @@ -631,123 +261,12 @@ def __init__(self): def do_fit(self, freq, amp, pha): # TODO - logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") + logging.error("Lorentzian Fit not yet implemented. Feel free to adapt the respective maths yourself based on old resonator class") self.extract_data["f_res"] = 1 self.extract_data["f_res_err"] = 0.5 self.extract_data["Qc"] = 1 self.extract_data["Qc_err"] = 0.5 return super().do_fit(freq, amp, pha) - """ - Old implementation: - - ''' - def residuals(p,x,y): - A2, A4, Qr = p - err = y -(A1a+A2*(x-fra)+(A3a+A4*(x-fra))/(1.+4.*Qr**2*((x-fra)/fra)**2)) - return err - def residuals2(p,x,y): - A1, A2, A3, A4, fr, Qr = p - err = y -(A1+A2*(x-fr)+(A3+A4*(x-fr))/(1.+4.*Qr**2*((x-fr)/fr)**2)) - return err - - self._fit_all = fit_all - - if not self._datasets_loaded: - self._get_datasets() - self._update_data() - - self._prepare_f_range(f_min,f_max) - if self._first_skewed_lorentzian: - self._prepare_skewed_lorentzian() - self._first_skewed_lorentzian = False - - ''' - fit_amplitude is always 2dim np array. - for 1dim data, shape: (1, # fit frequency points) - for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) - ''' - if not self._fit_all: - self._get_last_amp_trace() - - for amplitudes in self._fit_amplitude: - "fits a skewed lorenzian to reflection amplitudes of a resonator" - # prefilter the data - amplitudes = self._pre_filter_data(amplitudes) - - amplitudes = np.absolute(amplitudes) - amplitudes_sq = amplitudes**2 - - A1a = np.minimum(amplitudes_sq[0],amplitudes_sq[-1]) - A3a = -np.max(amplitudes_sq) - fra = self._fit_frequency[np.argmin(amplitudes_sq)] - - p0 = [0., 0., 1e3] - - try: - p_final = leastsq(residuals,p0,args=(self._fit_frequency,amplitudes_sq)) - A2a, A4a, Qra = p_final[0] - - p0 = [A1a, A2a , A3a, A4a, fra, Qra] - p_final = leastsq(residuals2,p0,args=(self._fit_frequency,amplitudes_sq)) - popt=p_final[0] - except: - self._skwd_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) - self._skwd_f0.append(np.nan) - self._skwd_a1.append(np.nan) - self._skwd_a2.append(np.nan) - self._skwd_a3.append(np.nan) - self._skwd_a4.append(np.nan) - self._skwd_Qr.append(np.nan) - self._skwd_chi2_fit.append(np.nan) - else: - chi2 = self._skewed_fit_chi2(popt,amplitudes_sq) - amp_gen = np.sqrt(np.array(self._skewed_from_fit(popt))) - - self._skwd_amp_gen.append(amp_gen) - self._skwd_f0.append(float(popt[4])) - self._skwd_a1.append(float(popt[0])) - self._skwd_a2.append(float(popt[1])) - self._skwd_a3.append(float(popt[2])) - self._skwd_a4.append(float(popt[3])) - self._skwd_Qr.append(float(popt[5])) - self._skwd_chi2_fit.append(float(chi2)) - - self._skwd_Qi.append(self._skewed_estimate_Qi(popt)) - - - def _skewed_fit_chi2(self, fit, amplitudes_sq): - chi2 = np.sum((self._skewed_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) - return chi2 - - def _skewed_from_fit(self,p): - A1, A2, A3, A4, fr, Qr = p - return A1+A2*(self._fit_frequency-fr)+(A3+A4*(self._fit_frequency-fr))/(1.+4.*Qr**2*((self._fit_frequency-fr)/fr)**2) - - def _skewed_estimate_Qi(self,p): - - #this is a very clumsy numerical estimate of the Qi factor based on the +3dB method.# - A1, A2, A3, A4, fr, Qr = p - def skewed_from_fit(p,f): - A1, A2, A3, A4, fr, Qr = p - return A1+A2*(f-fr)+(A3+A4*(f-fr))/(1.+4.*Qr**2*((f-fr)/fr)**2) - - #df = fr/(Qr*10000) - fmax = fr+fr/Qr - fs = np.linspace(fr,fmax,1000,dtype=np.float64) - Amin = skewed_from_fit(p,fr) - #print("---") - #print("Amin, 2*Amin",Amin, 2*Amin) - - for f in fs: - A = skewed_from_fit(p,f) - #print(A, f) - if A>2*Amin: - break - qi = fr/(2*(f-fr)) - #print("f, A, fr/2*(f-fr)", f,A, qi) - - return float(qi) - """ class FanoFit(ResonatorFitBase): @@ -763,129 +282,12 @@ def __init__(self): def do_fit(self, freq, amp, pha): # TODO - logging.error("Lorentzian Fit not yet implemented. Feel free to adapt it yourself based on old resonator class") + logging.error("Lorentzian Fit not yet implemented. Feel free to adapt the respective maths yourself based on old resonator class") self.extract_data["f_res"] = 1 self.extract_data["f_res_err"] = 0.5 self.extract_data["Qc"] = 1 self.extract_data["Qc_err"] = 0.5 return super().do_fit(freq, amp, pha) - """ - Old implementation: - - - self._fit_all = fit_all - if not self._datasets_loaded: - self._get_datasets() - self._update_data() - self._prepare_f_range(f_min,f_max) - if self._first_fano: - self._prepare_fano() - self._first_fano = False - - ''' - fit_amplitude is always 2dim np array. - for 1dim data, shape: (1, # fit frequency points) - for 2dim data, shape: (# amplitude slices (i.e. power values in scan), # fit frequency points) - ''' - if not self._fit_all: - self._get_last_amp_trace() - - for amplitudes in self._fit_amplitude: - amplitude_sq = (np.absolute(amplitudes))**2 - try: - fit = self._do_fit_fano(amplitude_sq) - amplitudes_gen = self._fano_reflection_from_fit(fit) - - '''calculate the chi2 of fit and data''' - chi2 = self._fano_fit_chi2(fit, amplitude_sq) - - except: - self._fano_amp_gen.append(np.array([np.nan for f in self._fit_frequency])) - self._fano_q_fit.append(np.nan) - self._fano_bw_fit.append(np.nan) - self._fano_fr_fit.append(np.nan) - self._fano_a_fit.append(np.nan) - self._fano_chi2_fit.append(np.nan) - self._fano_Ql_fit.append(np.nan) - self._fano_Q0_fit.append(np.nan) - - else: - ''' save the fitted data to the hdf_file''' - self._fano_amp_gen.append(np.sqrt(np.absolute(amplitudes_gen))) - self._fano_q_fit.append(float(fit[0])) - self._fano_bw_fit.append(float(fit[1])) - self._fano_fr_fit.append(float(fit[2])) - self._fano_a_fit.append(float(fit[3])) - self._fano_chi2_fit.append(float(chi2)) - self._fano_Ql_fit.append(float(fit[2])/float(fit[1])) - q0=self._fano_fit_q0(np.sqrt(np.absolute(amplitudes_gen)),float(fit[2])) - self._fano_Q0_fit.append(q0) - - def _fano_reflection(self,f,q,bw,fr,a=1,b=1): - ''' - evaluates the fano function in reflection at the - frequency f - with - resonator frequency fr - attenuation a (linear) - fano-factor q - bandwidth bw - ''' - return a*(1 - self._fano_transmission(f,q,bw,fr)) - - def _fano_transmission(self,f,q,bw,fr,a=1,b=1): - ''' - evaluates the normalized transmission fano function at the - frequency f - with - resonator frequency fr - attenuation a (linear) - fano-factor q - bandwidth bw - ''' - F = 2*(f-fr)/bw - return ( 1/(1+q**2) * (F+q)**2 / (F**2+1)) - - def _do_fit_fano(self, amplitudes_sq): - # initial guess - bw = 1e6 - q = 1 #np.sqrt(1-amplitudes_sq).min() # 1-Amp_sq = 1-1+q^2 => A_min = q - fr = self._fit_frequency[np.argmin(amplitudes_sq)] - a = amplitudes_sq.max() - - p0 = [q, bw, fr, a] - - def fano_residuals(p,frequency,amplitude_sq): - q, bw, fr, a = p - err = amplitude_sq-self._fano_reflection(frequency,q,bw,fr=fr,a=a) - return err - - p_fit = leastsq(fano_residuals,p0,args=(self._fit_frequency,np.array(amplitudes_sq))) - #print(("q:%g bw:%g fr:%g a:%g")% (p_fit[0][0],p_fit[0][1],p_fit[0][2],p_fit[0][3])) - return p_fit[0] - - def _fano_reflection_from_fit(self,fit): - return self._fano_reflection(self._fit_frequency,fit[0],fit[1],fit[2],fit[3]) - - def _fano_fit_chi2(self,fit,amplitudes_sq): - chi2 = np.sum((self._fano_reflection_from_fit(fit)-amplitudes_sq)**2) / (len(amplitudes_sq)-len(fit)) - return chi2 - - def _fano_fit_q0(self,amp_gen,fr): - ''' - calculates q0 from 3dB bandwidth above minimum in fit function - ''' - amp_3dB=10*np.log10((np.min(amp_gen)))+3 - amp_3dB_lin=10**(amp_3dB/10) - f_3dB=[] - for i in range(len(amp_gen)-1): - if np.sign(amp_gen[i]-amp_3dB_lin) != np.sign(amp_gen[i+1]-amp_3dB_lin):#crossing@amp_3dB - f_3dB.append(self._fit_frequency[i]) - if len(f_3dB)>1: - q0 = fr/(f_3dB[1]-f_3dB[0]) - return float(q0) - else: return np.nan - """ FitNames: dict[str, ResonatorFitBase] = { diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 764f01483..13bff4b03 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -418,7 +418,7 @@ def measure_1D(self, rescan=True, web_visible=True): """opens qviewkit to plot measurement, amp and pha are opened by default""" if self.open_qviewkit: - self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase', 'views/IQ']) + self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase'] + (['views/IQ'] if self.storeRealImag else [])) qkit.flow.start() From b3258f05bc27f5bd750710efb6bba08394f2a595 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Wed, 28 May 2025 13:06:47 +0200 Subject: [PATCH 21/43] Revert hdf changes and adjust logging coordinates access --- src/qkit/analysis/spectroscopy.py | 125 +++++++++++++++++++++++++ src/qkit/measure/logging_base.py | 5 +- src/qkit/storage/hdf_dataset.py | 13 ++- src/qkit/storage/hdf_file.py | 13 ++- src/qkit/storage/store.py | 6 +- tests/resonator_fits/resonator_fits.py | 4 +- 6 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 src/qkit/analysis/spectroscopy.py diff --git a/src/qkit/analysis/spectroscopy.py b/src/qkit/analysis/spectroscopy.py new file mode 100644 index 000000000..35a440e83 --- /dev/null +++ b/src/qkit/analysis/spectroscopy.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# spectroscopy.py analysis class for qkit spectroscopy measurement data +# Micha Wildermuth, micha.wildermuth@kit.edu 2023 + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import numpy as np +from qkit.analysis.qdata import qData +from qkit.analysis.circle_fit.circle_fit_2019 import circuit + + +class spectrum(qData): + """ + This is an analysis class for spectrum-like spectroscopy measurements taken by + `qkit.measure.spectroscopy.spectroscopy.py`. + """ + + def __init__(self): + """ + Initializes an analysis class for spectrum-like spectroscopy measurements taken by + `qkit.measure.spectroscopy.spectroscopy.py`. + + Parameters + ---------- + None + + Returns + ------- + None + + Examples + -------- + >>> import numpy as np + >>> import qkit + QKIT configuration initialized -> available as qkit.cfg[...] + >>> qkit.start() + Starting QKIT framework ... -> qkit.core.startup + Loading module ... S10_logging.py + Loading module ... S12_lockfile.py + Loading module ... S14_setup_directories.py + Loading module ... S20_check_for_updates.py + Loading module ... S25_info_service.py + Loading module ... S30_qkit_start.py + Loading module ... S65_load_RI_service.py + Loading module ... S70_load_visa.py + Loading module ... S80_load_file_service.py + Loading module ... S85_init_measurement.py + Loading module ... S98_started.py + Loading module ... S99_init_user.py + Initialized the file info database (qkit.fid) in 0.000 seconds. + + >>> from qkit.analysis.qdata import spectrum + >>> s = spectrum() + """ + super().__init__() + self.circuit = circuit + + def load(self, uuid): + """ + Loads qkit spectroscopy data with given uuid . + + Parameters + ---------- + uuid: str + Qkit identification name, that is looked for and loaded. + + Returns + ------- + None + + Examples + -------- + >>> s.load(uuid='XXXXXX') + """ + super().load(uuid=uuid) + if self.m_type != 'spectroscopy': + raise AttributeError('No spectroscopy data loaded. Use data acquired with spectroscopy measurement class or general qData class.') + self.scan_dim = self.df.data.amplitude.attrs['ds_type'] # scan dimension (1D, 2D, ...) + self._get_xy_parameter(self.df.data.amplitude) + self.circlefit = None + + def open_qviewkit(self, uuid=None, ds=None): + """ + Opens qkit measurement data with given uuid in qviewkit. + + Parameters + ---------- + uuid: str + Qkit identification name, that is looked for and opened in qviewkit. + ds: str | list(str) + Datasets that are opened instantaneously. Default for spectroscopy data is 'amplitude' and 'phase'. + + Returns + ------- + None + """ + if ds is None: + ds = [_ds if _ds in self.df.data.__dict__.keys() else None for _ds in ['amplitude', 'phase']] + super().open_qviewkit(uuid=uuid, ds=ds) + + def setup_circlefit(self, type, f_data=None, z_data_raw=None): + if f_data is None: + f_data = self.frequency + if z_data_raw is None: + if hasattr(self, 'real') and hasattr(self, 'imag'): + z_data_raw = self.real + 1j * self.imag + elif hasattr(self, 'amplitude') and hasattr(self, 'phase'): + z_data_raw = self.amplitude + np.exp(1j * self.phase) + else: + raise NameError('no S21 data available. Please load either real and imaginary data or amplitude and phase data.') + self.circlefit = {'reflection': self.circuit.reflection_port, + 'notch': self.circuit.notch_port}[type](f_data=f_data, + z_data_raw=z_data_raw) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 822070827..7e2ace666 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -41,6 +41,7 @@ def __init__(self, file_name: str, func: typing.Callable, name: str, unit: str = self.signature += "n" self.buffer1d: np.ndarray = None self.log_ds: hdf_dataset = None + def prepare_file(self): # prepare trace base coordinate if necessary if "n" in self.signature: @@ -61,9 +62,9 @@ def prepare_file(self): self.log_ds = self.file.add_value_box(self.name, self.file.get_dataset(self.x_ds_url), self.file.get_dataset(self.y_ds_url), trace_ds, self.unit, dtype=self.dtype) if "x" in self.signature: - self.x_len = self.file.get_dataset(self.x_ds_url).ds.shape[0] + self.x_len = self.file[self.x_ds_url].shape[0] if "y" in self.signature: - self.y_len = self.file.get_dataset(self.y_ds_url).ds.shape[0] + self.y_len = self.file[self.y_ds_url].shape[0] def logIfDesired(self, ix=0, iy=0): if (ix == 0 or "x" in self.signature) and (iy == 0 or "y" in self.signature): # log function call desired diff --git a/src/qkit/storage/hdf_dataset.py b/src/qkit/storage/hdf_dataset.py index fb3886432..4496da32d 100644 --- a/src/qkit/storage/hdf_dataset.py +++ b/src/qkit/storage/hdf_dataset.py @@ -69,15 +69,14 @@ def _new_ds_defaults(self, name, unit, folder, comment): # the first dataset is used to extract a few attributes self.first = True - def _read_ds_from_hdf(self, ds_url): - self.first = False # assume ds has been properly initialized if we're reading it - self.ds = self.hf[str(ds_url)] + def _read_ds_from_hdf(self,ds_url): + ds = self.hf[str(ds_url)] - for attr in self.ds.attrs.keys(): - val = self.ds.attrs.get(attr) - setattr(self, attr, val) + for attr in ds.attrs.keys(): + val = ds.attrs.get(attr) + setattr(self,attr,val) - self.ds_url = ds_url + self.ds_url = ds_url def _setup_metadata(self): ds = self.ds diff --git a/src/qkit/storage/hdf_file.py b/src/qkit/storage/hdf_file.py index aed54d193..268889189 100644 --- a/src/qkit/storage/hdf_file.py +++ b/src/qkit/storage/hdf_file.py @@ -32,14 +32,14 @@ class H5_file(object): trick of placing added data in the correct position in the dataset. """ - def __init__(self, output_file, mode, **kw): + def __init__(self,output_file, mode,**kw): """Inits the H5_file at the path 'output_file' with the access mode 'mode' """ self.create_file(output_file, mode) self.newfile = False - if self.hf.attrs.get("qt-file", None) or self.hf.attrs.get("qkit", None): + if self.hf.attrs.get("qt-file",None) or self.hf.attrs.get("qkit",None): "File existed before and was created by qkit." self.setup_required_groups() else: @@ -51,8 +51,8 @@ def __init__(self, output_file, mode, **kw): for k in kw: self.grp.attrs[k] = kw[k] - def create_file(self, output_file, mode): - self.hf = h5py.File(output_file, mode, **file_kwargs) + def create_file(self,output_file, mode): + self.hf = h5py.File(output_file, mode,**file_kwargs ) def set_base_attributes(self): "stores some attributes and creates the default data group" @@ -145,7 +145,10 @@ def create_dataset(self,name, tracelength, ds_type = ds_types['vector'], # we store text as unicode; this seems somewhat non-standard for hdf if ds_type == ds_types['txt']: - dtype = h5py.special_dtype(vlen=str) + try: + dtype = h5py.special_dtype(vlen=unicode) # python 2 + except NameError: + dtype = h5py.special_dtype(vlen=str) # python 3 #create the dataset ...; delete it first if it exists, unless it is data if name in self.grp.keys(): if folder == "data": diff --git a/src/qkit/storage/store.py b/src/qkit/storage/store.py index 864ebd6dd..7464beeba 100644 --- a/src/qkit/storage/store.py +++ b/src/qkit/storage/store.py @@ -50,7 +50,7 @@ def __init__(self, name = None, mode = 'r+', copy_file = False): logging.debug("Could not add newly generated h5 File '{}' to qkit.fid database: {}".format(name,e)) else: self._filepath = os.path.abspath(self._name) - self._folder, self._filename = os.path.split(self._filepath) + self._folder,self._filename = os.path.split(self._filepath) "setup the file" try: self.hf = H5_file(self._filepath, mode) @@ -297,8 +297,8 @@ class and in the end the entries can be sorted by all params. self.hf.agrp.attrs[param] = value - def get_dataset(self, ds_url): - return hdf_dataset(self.hf, ds_url=ds_url) + def get_dataset(self,ds_url): + return hdf_dataset(self.hf,ds_url = ds_url) def save_finished(self): pass diff --git a/tests/resonator_fits/resonator_fits.py b/tests/resonator_fits/resonator_fits.py index 2d0ad20a3..cd42e24d2 100644 --- a/tests/resonator_fits/resonator_fits.py +++ b/tests/resonator_fits/resonator_fits.py @@ -1,11 +1,11 @@ import pytest def test_circlefit(): - from qkit.analysis.resonatorV2 import CircleFit + from qkit.analysis.resonator_fitting import CircleFit from qkit.storage.store import Data # for reading file import numpy as np - datafile = Data("C:/Users/mariu/Desktop/Ordner/Studium/qkit_development/tests/resonator_fits/SVSEWX_VNA_tracedata.h5") + datafile = Data("SVSEWX_VNA_tracedata.h5") freq = np.array(datafile.data.frequency) amp = np.array(datafile.data.amplitude) pha = np.array(datafile.data.phase) From 29c531a9afc54aba18a62c97c8a42f9a4cc261be Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Wed, 28 May 2025 13:56:26 +0200 Subject: [PATCH 22/43] storeRealImag default view bugfix --- src/qkit/measure/spectroscopy/spectroscopy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 13bff4b03..8dd5a076e 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -521,7 +521,7 @@ def measure_2D(self, web_visible=True): if self._nop < 10: self._data_file.hf.hf.attrs['default_ds'] =['views/amplitude_midpoint', 'views/phase_midpoint'] else: - self._data_file.hf.hf.attrs['default_ds'] = ['amplitude', 'phase', 'views/IQ'] + self._data_file.hf.hf.attrs['default_ds'] = ['amplitude', 'phase'] + (['views/IQ'] if self.storeRealImag else []) if self.open_qviewkit: self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=list(self._data_file.hf.hf.attrs['default_ds'])) @@ -563,7 +563,7 @@ def measure_3D(self, web_visible=True): """opens qviewkit to plot measurement, amp and pha are opened by default""" """only middle point in freq array is plotted vs x and y""" if self.open_qviewkit: - self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase', "views/IQ"]) + self._qvk_process = qviewkit.plot(self._data_file.get_filepath(), datasets=['amplitude', 'phase'] + (['views/IQ'] if self.storeRealImag else [])) if self.progress_bar: if self.landscape.xylandscapes: From 66e6b9777f5a2cca3904a6bab4fdf6dd302d97f7 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Wed, 28 May 2025 18:51:21 +0200 Subject: [PATCH 23/43] Create double_vta.py --- src/qkit/drivers/double_vta.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/qkit/drivers/double_vta.py diff --git a/src/qkit/drivers/double_vta.py b/src/qkit/drivers/double_vta.py new file mode 100644 index 000000000..352b1a334 --- /dev/null +++ b/src/qkit/drivers/double_vta.py @@ -0,0 +1,19 @@ +# import instrument + + +# + +class DoubleVTA(Instrument): + def __init__(self): + # setter matrix & getter matrix for sweeping voltage difference at const. total voltage across a sample and measuring current: + # (delV) = (1 -1) . (V1) + # (avgV) (.5 .5) (V2) + # (Ieff) = (.5 -.5) . (I1) + # (Ioff) (1 1) (I2) + # + self.setter_1 = None + self.setter_2 = None + self.setter_matrix = np.array([[1, 0], [0, 1]]) + self.getter_1 = None + self.getter_2 = None + self.getter_matrix = np.array([[1, 0], [0, 1]]) From be57e44f57c46c5439009df72331d0271f1f486e Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Wed, 4 Jun 2025 16:31:37 +0200 Subject: [PATCH 24/43] transport logfunc bugfix --- src/qkit/measure/transport/transport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index 44b8f344d..33d1b1cb0 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1447,8 +1447,8 @@ def _prepare_measurement_file(self): # logging for init_tuple in self.log_init_params: - func, name, unit, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple - self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, + func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple + self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, dtype, self._hdf_x.ds_url if (self._scan_dim >= 2) and over_x else None, self._hdf_y.ds_url if (self._scan_dim == 3) and over_y else None, (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] From 0d9bf257377009b9bdb1ed4bce74af2385abdcd4 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 10 Jun 2025 19:46:55 +0200 Subject: [PATCH 25/43] VTe added --- src/qkit/drivers/Double_VTE.py | 114 +++++++++++++++++++++++++++++++++ src/qkit/drivers/double_vta.py | 19 ------ 2 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 src/qkit/drivers/Double_VTE.py delete mode 100644 src/qkit/drivers/double_vta.py diff --git a/src/qkit/drivers/Double_VTE.py b/src/qkit/drivers/Double_VTE.py new file mode 100644 index 000000000..8bacf86e8 --- /dev/null +++ b/src/qkit/drivers/Double_VTE.py @@ -0,0 +1,114 @@ +from qkit.core.instrument_base import Instrument +import numpy as np +import logging +import typing +import time + +class Double_VTE(Instrument): + def __init__(self, name): + """ + Double Virtual Tunnel Electronics + + So far only supports voltage bias, measure pseudo current for 2x transimpedance amplifier setup + """ + super().__init__(name, tags=["virtual"]) + # setter matrix & getter matrix for e.g. sweeping voltage difference at const. total voltage across a sample and measuring current: + # (VA) = (1 -1) . (V1) + # (VB) (.5 .5) (V2) + # (IA) = (.5 -.5) . (I1) + # (IB) (1 1) (I2) + self._setter_matrix = np.array([[1, 0], [0, 1]]) + self._getter_matrix = np.array([[1, 0], [0, 1]]) # technically not required for sweeping, but handled here alongside setter for consistency + + self.v_div_1 = 1 + self.v_div_2 = 1 + self.dVdA_1 = 1 + self.dVdA_2 = 1 + + self.sweep_manually = True + # for manual sweeps + self.setter_1: typing.Callable[[float], None] = None + self.setter_2: typing.Callable[[float], None] = None + self.getter_1: typing.Callable[[], float] = None + self.getter_2: typing.Callable[[], float] = None + self.rdb_set_1: typing.Callable[[], float] = None # optional but recommended to catch e.g. insufficient device resolution + self.rdb_set_2: typing.Callable[[], float] = None + self.dt = 0.001 # wait between set & get + # for automated sweeps: func(to_be_set_v1, to_be_set_v2) -> actual_set_v1*v_div_1, actual_set_v2*v_div_2, measured_i1*dVdA_1, measured_i2*dVdA_2 + self.double_sweeper: typing.Callable[[np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None + + def set_setter_matrix(self, mat: np.ndarray): + if mat.shape != (2, 2): + logging.error("Setter-matrix shape was {}, but (2, 2) is required".format(str(mat.shape))) + return + if np.linalg.det(mat) == 0: + logging.error("Setter-matrix needs to be invertible") + return + self._setter_matrix = mat + def get_setter_matrix(self): + return self._setter_matrix + + def set_getter_matrix(self, mat: np.ndarray): + if mat.shape != (2, 2): + logging.error("Getter-matrix shape was {}, but (2, 2) is required".format(str(mat.shape))) + return + if np.linalg.det(mat) == 0: + logging.error("Getter-matrix needs to be invertible") + return + self._getter_matrix = mat + def get_getter_matrix(self): + return self._getter_matrix + + def show_effective_minmax(self, v1_min, v1_max, v2_min, v2_max, fix_a = None, fix_b = None, make_plot = True): + helper = np.array([[v1_min, v1_min, v1_max, v1_max, v1_min], + [v2_min, v2_max, v2_min, v2_max, v2_min]]) + a_min = np.min(self._setter_matrix @ helper, axis=-1)[0] + a_max = np.max(self._setter_matrix @ helper, axis=-1)[0] + b_min = np.min(self._setter_matrix @ helper, axis=-1)[1] + b_max = np.max(self._setter_matrix @ helper, axis=-1)[1] + + def get_sweepdata(self, start_A, stop_A, start_B, stop_B, nop): + # (nop), (nop) = *as tuple*: (2, 2) @ (2, 2) @ (2, nop) // (1, nop), (1, nop) + sweep_1, sweep_2 = (s for s in np.array([[self.v_div_1, 0], [0, self.v_div_2]]) @ np.linalg.inv(self._setter_matrix) @ np.concatenate([np.linspace(start_A, stop_A, nop)[None,:], np.linspace(start_B, stop_B, nop)[None,:]], axis=0)) + logging.info("Sweeping Setter_1 within {}V to {}V and Setter_2 within {}V to {}V".format(np.min(sweep_1), np.max(sweep_1), np.min(sweep_2), np.max(sweep_2))) + if self.sweep_manually: + v_set_1, v_set_2, v_meas_1, v_meas_2 = ([], [], [], []) + for i in range(nop): + self.setter_1(sweep_1[i]) + self.setter_2(sweep_2[i]) + time.sleep(self.dt) + v_set_1 += [self.rdb_set_1() if not (self.rdb_set_1 is None) else sweep_1[i]] + v_set_2 += [self.rdb_set_2() if not (self.rdb_set_2 is None) else sweep_2[i]] + v_meas_1 += [self.getter_1()] + v_meas_2 += [self.getter_2()] + return np.array(v_set_1)/self.v_div_1, np.array(v_set_2)/self.v_div_2, np.array(v_meas_1)/self.dVdA_1, np.array(v_meas_2)/self.dVdA_2 + else: + v_set_1, v_set_2, v_meas_1, v_meas_2 = self.double_sweeper(sweep_1, sweep_2) + return v_set_1/self.v_div_1, v_set_2/self.v_div_2, v_meas_1/self.dVdA_1, v_meas_2/self.dVdA_2 + + # qkit setting stuffs + def get_parameters(self): + return { + "setter_matrix": None, + "getter_matrix": None, + "v_div_1": None, + "v_div_2": None, + "dVdA_1": None, + "dVdA_2": None, + } | ({ + "setter_1": None, + "setter_2": None, + "getter_1": None, + "getter_2": None, + "rdb_set_1": None, + "rdb_set_2": None, + "dt": None + } if self.sweep_manually else { + "double_sweeper": None + }) + + def get(self, param, **kwargs): + try: + return eval("self.get_{}()".format(param)) if "etter_matrix" in param else eval("self.{}".format(param)) + except: + return None \ No newline at end of file diff --git a/src/qkit/drivers/double_vta.py b/src/qkit/drivers/double_vta.py deleted file mode 100644 index 352b1a334..000000000 --- a/src/qkit/drivers/double_vta.py +++ /dev/null @@ -1,19 +0,0 @@ -# import instrument - - -# - -class DoubleVTA(Instrument): - def __init__(self): - # setter matrix & getter matrix for sweeping voltage difference at const. total voltage across a sample and measuring current: - # (delV) = (1 -1) . (V1) - # (avgV) (.5 .5) (V2) - # (Ieff) = (.5 -.5) . (I1) - # (Ioff) (1 1) (I2) - # - self.setter_1 = None - self.setter_2 = None - self.setter_matrix = np.array([[1, 0], [0, 1]]) - self.getter_1 = None - self.getter_2 = None - self.getter_matrix = np.array([[1, 0], [0, 1]]) From c13f75c55dc744598ccb921b410c199e47afa596 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Wed, 11 Jun 2025 16:45:19 +0200 Subject: [PATCH 26/43] Added effective min/max ranges calculation --- src/qkit/drivers/Double_VTE.py | 79 +++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/src/qkit/drivers/Double_VTE.py b/src/qkit/drivers/Double_VTE.py index 8bacf86e8..cf36609f4 100644 --- a/src/qkit/drivers/Double_VTE.py +++ b/src/qkit/drivers/Double_VTE.py @@ -59,16 +59,79 @@ def set_getter_matrix(self, mat: np.ndarray): def get_getter_matrix(self): return self._getter_matrix - def show_effective_minmax(self, v1_min, v1_max, v2_min, v2_max, fix_a = None, fix_b = None, make_plot = True): - helper = np.array([[v1_min, v1_min, v1_max, v1_max, v1_min], - [v2_min, v2_max, v2_min, v2_max, v2_min]]) + def show_effective_minmax(self, v1_min: float, v1_max: float, v2_min: float, v2_max: float, fix_a: float | None = None, fix_b: float | None = None, make_plot: bool = True) -> tuple[float]: + helper = np.array([[v1_min, v1_max, v1_max, v1_min, v1_min], + [v2_min, v2_min, v2_max, v2_max, v2_min]]) + # boundary box trivially as min/max over corners a_min = np.min(self._setter_matrix @ helper, axis=-1)[0] a_max = np.max(self._setter_matrix @ helper, axis=-1)[0] b_min = np.min(self._setter_matrix @ helper, axis=-1)[1] b_max = np.max(self._setter_matrix @ helper, axis=-1)[1] + # if specific fixed point is within boundary, its according range can be found as the innermost intersections between quadrilateral edges and fixed line + # draw everything in v1, v2 and va, vb space for above comment to make sense + if fix_a is None: + eff_b_min = None + eff_b_max = None + elif (fix_a < a_min) or (fix_a > a_max): + eff_b_min = None + eff_b_max = None + else: + eff_cand = [] + for i in range(4): + try: + # https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection P_y formula with p1 = setter@h_i, p2 = setter@h_i+1, p3 = (fix_a, 1), p4 = (fix_a, 0) + eff_cand += [(np.linalg.det(self._setter_matrix) * np.linalg.det(helper[:,i:i+2]) + fix_a*(self._setter_matrix @ (helper[:,i] - helper[:,i+1]))[1])/(self._setter_matrix @ (helper[:,i] - helper[:,i+1]))[0]] + except: + pass + eff_cand = np.array(eff_cand) + eff_cand = np.unique(eff_cand[(eff_cand >= b_min) & (eff_cand <= b_max)]) + if len(eff_cand) != 2: + logging.warning("Something went wrong during fix_a calculations") + eff_b_min = None + eff_b_max = None + else: + eff_b_min = np.min(eff_cand) + eff_b_max = np.max(eff_cand) + + if fix_b is None: + eff_a_min = None + eff_a_max = None + elif (fix_b < b_min) or (fix_b > b_max): + eff_a_min = None + eff_a_max = None + else: + eff_cand = [] + for i in range(4): + try: + eff_cand += [(-np.linalg.det(self._setter_matrix) * np.linalg.det(helper[:,i:i+2]) + fix_b*(self._setter_matrix @ (helper[:,i] - helper[:,i+1]))[0])/(self._setter_matrix @ (helper[:,i] - helper[:,i+1]))[1]] + except: + pass + eff_cand = np.array(eff_cand) + eff_cand = np.unique(eff_cand[(eff_cand >= a_min) & (eff_cand <= a_max)]) + if len(eff_cand) != 2: + logging.warning("Something went wrong during fix_b calculations") + eff_a_min = None + eff_a_max = None + else: + eff_a_min = np.min(eff_cand) + eff_a_max = np.max(eff_cand) + + if make_plot: + import matplotlib.pyplot as plt + plt.subplots(figsize=(12,12), dpi=360) + plt.plot(*(self._setter_matrix @ helper), "k-", zorder=0) + plt.hlines([b_min, b_max], a_min, a_max, "b", ":", zorder=1) + plt.vlines([a_min, a_max], b_min, b_max, "b", ":", zorder=1) + plt.hlines(fix_b, eff_a_min, eff_a_max, "r", "--", zorder=1) if not ((fix_b is None) or (eff_a_min is None) or (eff_a_max is None)) else None + plt.vlines(fix_a, eff_b_min, eff_b_max, "r", "--", zorder=1) if not ((fix_a is None) or (eff_b_min is None) or (eff_b_max is None)) else None + plt.scatter(*(self._setter_matrix @ helper[:,:-1]), c=range(1, helper.shape[-1]), marker="o", cmap="gist_rainbow", zorder=2) + plt.show() + + return a_min, a_max, b_min, b_max, eff_b_min, eff_b_max, eff_a_min, eff_a_max + def get_sweepdata(self, start_A, stop_A, start_B, stop_B, nop): - # (nop), (nop) = *as tuple*: (2, 2) @ (2, 2) @ (2, nop) // (1, nop), (1, nop) + # (nop), (nop) = *as tuple*: (2, 2) @ (2, 2) @ (2, nop) // (1, nop), (1, nop) sweep_1, sweep_2 = (s for s in np.array([[self.v_div_1, 0], [0, self.v_div_2]]) @ np.linalg.inv(self._setter_matrix) @ np.concatenate([np.linspace(start_A, stop_A, nop)[None,:], np.linspace(start_B, stop_B, nop)[None,:]], axis=0)) logging.info("Sweeping Setter_1 within {}V to {}V and Setter_2 within {}V to {}V".format(np.min(sweep_1), np.max(sweep_1), np.min(sweep_2), np.max(sweep_2))) if self.sweep_manually: @@ -111,4 +174,10 @@ def get(self, param, **kwargs): try: return eval("self.get_{}()".format(param)) if "etter_matrix" in param else eval("self.{}".format(param)) except: - return None \ No newline at end of file + return None + + +if __name__ == "__main__": + mydvte = Double_VTE("mydvte") + mydvte.set_setter_matrix(np.array([[1, -1], [1/3, 2/3]])) + print(mydvte.show_effective_minmax(0, 4, -4, 0, 2, 0)) \ No newline at end of file From 67c4f88c1485893ce0e07b1e0f6ea7ec0208ce70 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Wed, 18 Jun 2025 18:23:24 +0200 Subject: [PATCH 27/43] Bugfixes --- src/qkit/analysis/resonator_fitting.py | 5 ++--- src/qkit/measure/logging_base.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/qkit/analysis/resonator_fitting.py b/src/qkit/analysis/resonator_fitting.py index a1477b5ff..fe7c96c13 100644 --- a/src/qkit/analysis/resonator_fitting.py +++ b/src/qkit/analysis/resonator_fitting.py @@ -186,8 +186,7 @@ def __init__(self, n_ports: int, fit_delay_max_iterations: int = 5, fixed_delay: def do_fit(self, freq: np.ndarray[float], amp: np.ndarray[float], pha: np.ndarray[float]): # use external circlefit - my_circuit = circuit(freq, amp*np.exp(1j*pha)) - my_circuit.n_ports = self.n_ports + my_circuit = circuit.reflection_port(freq, amp*np.exp(1j*pha)) if self.n_ports == 1 else circuit.notch_port(freq, amp*np.exp(1j*pha)) my_circuit.fit_delay_max_iterations = self.fit_delay_max_iterations my_circuit.autofit(fixed_delay=self.fixed_delay, isolation=self.isolation) @@ -220,7 +219,7 @@ def do_fit(self, freq: np.ndarray[float], amp: np.ndarray[float], pha: np.ndarra } self.extract_data.update(my_circuit.fitresults) self.freq_fit = np.linspace(np.min(freq), np.max(freq), self.out_nop) - z_sim = self.Sij(self.freq_fit, my_circuit.fr, my_circuit.Ql, my_circuit.Qc, my_circuit.phi, my_circuit.a, my_circuit.alpha, my_circuit.delay) + z_sim = my_circuit.Sij(self.freq_fit, my_circuit.fr, my_circuit.Ql, my_circuit.Qc, my_circuit.phi, my_circuit.a, my_circuit.alpha, my_circuit.delay) self.amp_fit = np.abs(z_sim) self.pha_fit = np.angle(z_sim) diff --git a/src/qkit/measure/logging_base.py b/src/qkit/measure/logging_base.py index 7e2ace666..6277772a1 100644 --- a/src/qkit/measure/logging_base.py +++ b/src/qkit/measure/logging_base.py @@ -55,7 +55,7 @@ def prepare_file(self): if len(self.signature) == 0: self.log_ds = self.file.add_coordinate(self.name, self.unit) # coordinate dtype hardcoded as float elif len(self.signature) == 1: - self.log_ds = self.file.add_value_vector(self.name, {"x":self.file.get_dataset(self.x_ds_url),"y":self.file.get_dataset(self.y_ds_url),"n":trace_ds}[self.signature], self.unit, dtype=self.dtype) + self.log_ds = self.file.add_value_vector(self.name, self.file.get_dataset(self.x_ds_url) if "x" == self.signature else (self.file.get_dataset(self.y_ds_url) if "y" == self.signature else trace_ds), self.unit, dtype=self.dtype) elif len(self.signature) == 2: self.log_ds = self.file.add_value_matrix(self.name, self.file.get_dataset(self.x_ds_url) if "x" in self.signature else self.file.get_dataset(self.y_ds_url), trace_ds if "n" in self.signature else self.file.get_dataset(self.y_ds_url), self.unit, dtype=self.dtype) elif len(self.signature) == 3: From 843afe445d3ba3f08062c443ed3969cdfe0e78de Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Fri, 20 Jun 2025 17:26:20 +0200 Subject: [PATCH 28/43] Twosided measurement routine skeleton --- src/qkit/drivers/ADwinProII_SMU.py | 4 + src/qkit/drivers/Double_VTE.py | 8 +- src/qkit/measure/transport/twosided.py | 170 +++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 src/qkit/measure/transport/twosided.py diff --git a/src/qkit/drivers/ADwinProII_SMU.py b/src/qkit/drivers/ADwinProII_SMU.py index bb9c9313b..512d970fe 100644 --- a/src/qkit/drivers/ADwinProII_SMU.py +++ b/src/qkit/drivers/ADwinProII_SMU.py @@ -205,3 +205,7 @@ def set_status(self, *args, **kwargs) -> None: def reset(self): for i in range(1, 9): self.do_set_dac(0.0, i) + + +class ADwinProII_Double(Instrument): + pass \ No newline at end of file diff --git a/src/qkit/drivers/Double_VTE.py b/src/qkit/drivers/Double_VTE.py index cf36609f4..a3ee7ef5f 100644 --- a/src/qkit/drivers/Double_VTE.py +++ b/src/qkit/drivers/Double_VTE.py @@ -130,13 +130,13 @@ def show_effective_minmax(self, v1_min: float, v1_max: float, v2_min: float, v2_ return a_min, a_max, b_min, b_max, eff_b_min, eff_b_max, eff_a_min, eff_a_max - def get_sweepdata(self, start_A, stop_A, start_B, stop_B, nop): - # (nop), (nop) = *as tuple*: (2, 2) @ (2, 2) @ (2, nop) // (1, nop), (1, nop) - sweep_1, sweep_2 = (s for s in np.array([[self.v_div_1, 0], [0, self.v_div_2]]) @ np.linalg.inv(self._setter_matrix) @ np.concatenate([np.linspace(start_A, stop_A, nop)[None,:], np.linspace(start_B, stop_B, nop)[None,:]], axis=0)) + def get_sweepdata(self, a_vals: np.ndarray, b_vals: np.ndarray): + # (nop), (nop) = *as tuple*: (2, 2) @ (2, 2) @ (2, nop) // (1, nop), (1, nop) + sweep_1, sweep_2 = (s for s in np.array([[self.v_div_1, 0], [0, self.v_div_2]]) @ np.linalg.inv(self._setter_matrix) @ np.concatenate([a_vals.reshape((1, len(a_vals))), b_vals.reshape((1, len(b_vals)))], axis=0)) logging.info("Sweeping Setter_1 within {}V to {}V and Setter_2 within {}V to {}V".format(np.min(sweep_1), np.max(sweep_1), np.min(sweep_2), np.max(sweep_2))) if self.sweep_manually: v_set_1, v_set_2, v_meas_1, v_meas_2 = ([], [], [], []) - for i in range(nop): + for i in range(len(a_vals)): self.setter_1(sweep_1[i]) self.setter_2(sweep_2[i]) time.sleep(self.dt) diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py new file mode 100644 index 000000000..bbb7853ee --- /dev/null +++ b/src/qkit/measure/transport/twosided.py @@ -0,0 +1,170 @@ +import numpy as np +import qkit.measure +import qkit.measure.samples_class +from scipy import signal +import logging +import time +import sys +import threading +import typing + +import qkit +from qkit.storage import store as hdf +from qkit.gui.plot import plot as qviewkit +from qkit.gui.notebook.Progress_Bar import Progress_Bar +from qkit.measure.measurement_class import Measurement +import qkit.measure.write_additional_files as waf + + +class TransportTwoside(object): + """ + Transport measurement routine for the special cases of applying two different voltages on a sample and measuring the resulting current with trans-impedence amplifiers + """ + def __init__(self, DIVD): + """ + DIVD: Double IV-Device. Should provide + - setter/getter matrixes for converting desired effective values (e.g. voltage/current differences/averages) to respective device-side values + - get_sweepdata: effective v_a, v_b arrays in, measured v1, v2, i1, i2 out + + Possible sweep-modes: + - sweep v_a, v_b constant, arb. x/y-coordinates + - sweep v_a, v_b as x/y-coordinate, other x/y arb. + - simultaneous v_a & v_b sweep, arb. x/y-coordinates + """ + self._DIVD = DIVD + + self._measurement_object = Measurement() + self._measurement_object.measurement_type = 'transport' + + self.filename = None + self.expname = None + self.comment = None + + # TODO logging + + self._x_coordname = None + self._x_set_obj = None + self._x_vec = None + self._x_unit = None + self._x_dt = 1e-3 # in s + + self._y_coordname = None + self._y_set_obj = None + self._y_vec = [None] + self._y_unit = None + self._y_dt = 1e-3 # in s + + self._eff_vb_as_coord = None # None or "x" or "y" + + self._sweeps: list[TransportTwoside.ArbTwosideSweep] = [] + self.eff_va_name = "eff_va" + self.eff_vb_name = "eff_vb" + self.sweep_dt = 0 # in s + + self.store_effs = True + + self._derivs: list[tuple[str, str]] = [] # specifiers such as ("Ia", "Vb") for calculating dIa/dVb + self.deriv_func: typing.Callable[[np.ndarray, np.ndarray], np.ndarray] = self.savgol_deriv + + ### Measurement preparation ### + def set_sample(self, sample: qkit.measure.samples_class): + self._measurement_object.sample = sample + + def set_x_parameter(self, name: str, set_obj: typing.Callable[[float], None], vals: list[float], unit: str = "A.U.", dt: float = 1e-3): + if self._eff_vb_as_coord == "x": + self._eff_vb_as_coord = None + self._x_coordname = name + self._x_set_obj = set_obj + self._x_vec = vals + self._x_unit = unit + self._x_dt = dt + + def set_x_vb(self, b_vals: list[float]): + [logging.warn("Non-constant v_b in sweeps will be overwritten, please check") for sweep in self.sweeps if not sweep.is_b_const()] + self._eff_vb_as_coord = "x" + self._x_vec = b_vals + + def set_y_parameter(self, name: str, set_obj: typing.Callable[[float], None], vals: list[float], unit: str = "A.U.", dt: float = 1e-3): + if self._eff_vb_as_coord == "y": + self._eff_vb_as_coord = None + self._y_coordname = name + self._y_set_obj = set_obj + self._y_vec = vals + self._y_unit = unit + self._y_dt = dt + + def set_y_vb(self, b_vals: list[float]): + [logging.warn("Non-constant v_b in sweeps will be overwritten, please check") for sweep in self.sweeps if not sweep.is_b_const()] + self._eff_vb_as_coord = "y" + self._y_vec = b_vals + + def add_deriv(self, y: str, x: str): + """ + x/y: should be format "(i/v)(1/2)", e.g. "i2"; or "(i/v)/(1/2/a/b)" if store store_effs enabled. + muste be different + """ + if len(x) != 2 or len(y) != 2: + logging.error("x and y identifiers must have length 2") + return + x = x.lower() + y = y.lower() + if not (x[0] in "iv" and y[0] in "iv" and x[1] in ("12ab" if self.store_effs else "12") and y[1] in ("12ab" if self.store_effs else "12")): + logging.error("x and y should be format '(i/v)(1/2)', e.g. 'i2'; or '(i/v)/(1/2/a/b)' if store store_effs enabled") + return + if x == y: + logging.error("x and y must be different") + return + self._derivs += [(y, x)] + + def clear_deriv(self): + self._derivs = [] + + ### Main measurement routine ### + def prepare_measurement_file(self): + pass + + def measure_1D(self): + pass + + def measure_2D(self): + pass + + def measure_3D(self): + pass + + def _measure(self): + pass + + + ### Helper functions & classes ### + @staticmethod + def savgol_deriv(x_vals: np.ndarray, y_vals: np.ndarray, **savgol_args): + """ + Numerical derivative via savgol filters based qkit transport script + """ + savgol_args = {'window_length': 10, 'polyorder': 3, 'deriv': 1} | savgol_args + return signal.savgol_filter(y_vals, **savgol_args)/signal.savgol_filter(x_vals, **savgol_args) + + class ArbTwosideSweep(object): + def __init__(self, a_vals: np.ndarray, b_vals: np.ndarray): + if len(a_vals.shape) != 1 or len(b_vals.shape) != 1 or a_vals.shape != b_vals.shape: + logging.error("Sweep arrays must be 1D and of same length") + return + self.a_vals = a_vals + self.b_vals = b_vals + + def get(self): + return self.a_vals, self.b_vals + + def is_b_const(self) -> bool: + return np.all(self.b_vals == self.b_vals[0]) + + def set_b_const(self, b_val: float): + self.b_vals = np.linspace(b_val, b_val, self.a_vals.shape[0]) + + class LinearTwoside(ArbTwosideSweep): + def __init__(self, start_a, stop_a, start_b, stop_b, nop): + super.__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) + + + \ No newline at end of file From 6cf9236a23a6a3d554c60726d0788e25b3c59a83 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Thu, 26 Jun 2025 18:46:25 +0200 Subject: [PATCH 29/43] Twoside prepare_file --- src/qkit/drivers/Double_VTE.py | 4 +- src/qkit/measure/transport/twosided.py | 219 ++++++++++++++++++++----- 2 files changed, 182 insertions(+), 41 deletions(-) diff --git a/src/qkit/drivers/Double_VTE.py b/src/qkit/drivers/Double_VTE.py index a3ee7ef5f..67337c247 100644 --- a/src/qkit/drivers/Double_VTE.py +++ b/src/qkit/drivers/Double_VTE.py @@ -33,7 +33,7 @@ def __init__(self, name): self.getter_2: typing.Callable[[], float] = None self.rdb_set_1: typing.Callable[[], float] = None # optional but recommended to catch e.g. insufficient device resolution self.rdb_set_2: typing.Callable[[], float] = None - self.dt = 0.001 # wait between set & get + self.set_get_dt = 0.001 # wait between set & get # for automated sweeps: func(to_be_set_v1, to_be_set_v2) -> actual_set_v1*v_div_1, actual_set_v2*v_div_2, measured_i1*dVdA_1, measured_i2*dVdA_2 self.double_sweeper: typing.Callable[[np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None @@ -139,7 +139,7 @@ def get_sweepdata(self, a_vals: np.ndarray, b_vals: np.ndarray): for i in range(len(a_vals)): self.setter_1(sweep_1[i]) self.setter_2(sweep_2[i]) - time.sleep(self.dt) + time.sleep(self.set_get_dt) v_set_1 += [self.rdb_set_1() if not (self.rdb_set_1 is None) else sweep_1[i]] v_set_2 += [self.rdb_set_2() if not (self.rdb_set_2 is None) else sweep_2[i]] v_meas_1 += [self.getter_1()] diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index bbb7853ee..130bb0bb3 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -1,6 +1,12 @@ import numpy as np +import qkit.drivers import qkit.measure import qkit.measure.samples_class +import qkit.measure.transport +import qkit.measure.transport.transport +import qkit.storage +import qkit.storage.hdf_file +import qkit.storage.store from scipy import signal import logging import time @@ -31,7 +37,7 @@ def __init__(self, DIVD): - sweep v_a, v_b as x/y-coordinate, other x/y arb. - simultaneous v_a & v_b sweep, arb. x/y-coordinates """ - self._DIVD = DIVD + self._DIVD: qkit.drivers.Douvle_VTE.Double_VTE = DIVD self._measurement_object = Measurement() self._measurement_object.measurement_type = 'transport' @@ -43,34 +49,49 @@ def __init__(self, DIVD): # TODO logging self._x_coordname = None - self._x_set_obj = None + self._x_set_obj = lambda x: None self._x_vec = None self._x_unit = None - self._x_dt = 1e-3 # in s + self._x_dt = None self._y_coordname = None - self._y_set_obj = None + self._y_set_obj = lambda x: None self._y_vec = [None] self._y_unit = None - self._y_dt = 1e-3 # in s + self._y_dt = None self._eff_vb_as_coord = None # None or "x" or "y" - self._sweeps: list[TransportTwoside.ArbTwosideSweep] = [] + self._sweeps: list[ArbTwosideSweep] = [] self.eff_va_name = "eff_va" self.eff_vb_name = "eff_vb" - self.sweep_dt = 0 # in s + self.eff_ia_name = "eff_ia" + self.eff_ib_name = "eff_ib" + self.sweep_dt = None self.store_effs = True self._derivs: list[tuple[str, str]] = [] # specifiers such as ("Ia", "Vb") for calculating dIa/dVb self.deriv_func: typing.Callable[[np.ndarray, np.ndarray], np.ndarray] = self.savgol_deriv + self._views: list[tuple[str, str]] = [] # specifiers such as ("Ia", "Vb") + + self._msdim = None # set by resp. measure_ND() call + ### Measurement preparation ### def set_sample(self, sample: qkit.measure.samples_class): self._measurement_object.sample = sample - def set_x_parameter(self, name: str, set_obj: typing.Callable[[float], None], vals: list[float], unit: str = "A.U.", dt: float = 1e-3): + def clear_sweeps(self): + self._sweeps = [] + + def add_sweep(self, s: ArbTwosideSweep): + self._sweeps += [s] + + def set_x_parameter(self, name: str = None, set_obj: typing.Callable[[float], None] = lambda x: None, vals: list[float] = [None], unit: str = "A.U.", dt: float = None): + """ + Empty call to remove x_params + """ if self._eff_vb_as_coord == "x": self._eff_vb_as_coord = None self._x_coordname = name @@ -79,12 +100,26 @@ def set_x_parameter(self, name: str, set_obj: typing.Callable[[float], None], va self._x_unit = unit self._x_dt = dt - def set_x_vb(self, b_vals: list[float]): + def set_x_vb(self, b_vals: list[float], name: str = "eff_v_b", unit: str = "V", dt: float = None): [logging.warn("Non-constant v_b in sweeps will be overwritten, please check") for sweep in self.sweeps if not sweep.is_b_const()] + if self._eff_vb_as_coord == "y": + logging.warn("Overriding effective Vb as y-coordinate") + self._y_coordname = None + self._y_set_obj = lambda x: None + self._y_vec = [None] + self._y_unit = None + self._y_dt = None self._eff_vb_as_coord = "x" + self._x_coordname = name + self._x_set_obj = self._set_b_for_axis self._x_vec = b_vals + self._x_unit = unit + self._x_dt = dt def set_y_parameter(self, name: str, set_obj: typing.Callable[[float], None], vals: list[float], unit: str = "A.U.", dt: float = 1e-3): + """ + Empty call to remove x_params + """ if self._eff_vb_as_coord == "y": self._eff_vb_as_coord = None self._y_coordname = name @@ -93,10 +128,21 @@ def set_y_parameter(self, name: str, set_obj: typing.Callable[[float], None], va self._y_unit = unit self._y_dt = dt - def set_y_vb(self, b_vals: list[float]): + def set_y_vb(self, b_vals: list[float], name: str = "eff_v_b", unit: str = "V", dt: float = None): [logging.warn("Non-constant v_b in sweeps will be overwritten, please check") for sweep in self.sweeps if not sweep.is_b_const()] + if self._eff_vb_as_coord == "x": + logging.warn("Overriding effective Vb as y-coordinate") + self._x_coordname = None + self._x_set_obj = lambda x: None + self._x_vec = [None] + self._x_unit = None + self._x_dt = None self._eff_vb_as_coord = "y" + self._y_coordname = name + self._y_set_obj = self._set_b_for_axis self._y_vec = b_vals + self._y_unit = unit + self._y_dt = dt def add_deriv(self, y: str, x: str): """ @@ -116,27 +162,122 @@ def add_deriv(self, y: str, x: str): return self._derivs += [(y, x)] - def clear_deriv(self): + def clear_derivs(self): self._derivs = [] + def add_view(self, y: str, x: str): + """ + x/y: should be format "(i/v)(1/2)", e.g. "i2"; or "(i/v)/(1/2/a/b)" if store store_effs enabled. + muste be different + """ + if len(x) != 2 or len(y) != 2: + logging.error("x and y identifiers must have length 2") + return + x = x.lower() + y = y.lower() + if not (x[0] in "iv" and y[0] in "iv" and x[1] in ("12ab" if self.store_effs else "12") and y[1] in ("12ab" if self.store_effs else "12")): + logging.error("x and y should be format '(i/v)(1/2)', e.g. 'i2'; or '(i/v)/(1/2/a/b)' if store store_effs enabled") + return + if x == y: + logging.error("x and y must be different") + return + self._views += [(y, x)] + + def clear_views(self): + self._views = [] + ### Main measurement routine ### def prepare_measurement_file(self): - pass + self.the_file = hdf.Data(name='_'.join(list(filter(None, ('{:d}D_IV_curve'.format(self._msdim), self.filename, self.expname)))), mode='a') + # settings + self.the_file.add_textlist('settings').append(waf.get_instrument_settings(self.the_file.get_filepath())) + self._measurement_object.uuid = self.the_file._uuid + self._measurement_object.hdf_relpath = self.the_file._relpath + self._measurement_object.instruments = qkit.instruments.get_instrument_names() # qkit.instruments.get_instruments() # + self._measurement_object.save() + self.the_file.add_textlist('measurement').append(self._measurement_object.get_JSON()) + # matrices + mat_dummy_x = self.the_file.add_coordinate("_matrix_x", folder="analysis") + mat_dummy_x.add([1, 2]) + mat_dummy_y = self.the_file.add_coordinate("_matrix_y", folder="analysis") + mat_dummy_y.add([1, 2]) + setter_matrix = self.the_file.add_value_matrix("setter_matrix", mat_dummy_x, mat_dummy_y, folder="analysis") + for sm_elm in self._DIVD._setter_matrix: + setter_matrix.append(sm_elm) + getter_matrix = self.the_file.add_value_matrix("getter_matrix", mat_dummy_x, mat_dummy_y, folder="analysis") + for gm_elm in self._DIVD._getter_matrix: + getter_matrix.append(gm_elm) + # coords + target_eff_va = [self.the_file.add_coordinate("target_" + self.eff_va_name + "_{}".format(i), "V") for i in range(len(self._sweeps))] + target_eff_vb = [self.the_file.add_coordinate("target_" + self.eff_va_name + "_{}".format(i), "V") for i in range(len(self._sweeps))] + for i in range(len(self._sweeps)): + a, b = self._sweeps[i].get() + target_eff_va[i].add(a) + target_eff_vb[i].add(b) + if self._msdim >= 2: + x_coord = self.the_file.add_coordinate(self._x_coordname, self._x_unit) + x_coord.add(self._x_vec) + if self._msdim == 3: + y_coord = self.the_file.add_coordinate(self._y_coordname, self._y_unit) + y_coord.add(self._y_vec) + # data + def value_dimobj(name, base_coord, unit, folder="data"): + if self._msdim == 1: + return self.the_file.add_value_vector(name, base_coord, unit, folder=folder) + elif self._msdim == 2: + return self.the_file.add_value_matrix(name, x_coord, base_coord, unit, folder=folder) + elif self._msdim == 3: + return self.the_file.add_value_box(name, x_coord, y_coord, base_coord, unit, folder=folder) + self._v1 = [value_dimobj("v1_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] + self._v2 = [value_dimobj("v2_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] + self._i1 = [value_dimobj("i1_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] + self._i2 = [value_dimobj("i2_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] + if self.store_effs: + self._va = [value_dimobj(self.eff_va_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] + self._vb = [value_dimobj(self.eff_vb_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] + self._ia = [value_dimobj(self.eff_ia_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] + self._ib = [value_dimobj(self.eff_ib_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] + # derivs + self._deriv_store = [] + for dy, dx in self._derivs: + self._deriv_store += [ [value_dimobj("d{}_d{}_{}".format(dy, dx, i), target_eff_va[i], "V", "analysis") for i in range(len(self._sweeps))] ] + # views + for y, x in self._views: + [self.the_file.add_view(y + "_" + x, eval("self._" + x)[i], eval("self._" + y)[i]) for i in range(len(self._sweeps))] def measure_1D(self): - pass + if len(self._sweeps) == 0: + logging.error("No sweeps set, cannot measure") + return + self._msdim = 1 + self._measure() def measure_2D(self): - pass + if len(self._sweeps) == 0: + logging.error("No sweeps set, cannot measure") + return + if self._x_coordname is None: + logging.error("x-coordinate not set, cannot measure 2D") + return + self._msdim = 2 + self._measure() def measure_3D(self): - pass + if len(self._sweeps) == 0: + logging.error("No sweeps set, cannot measure") + if self._x_coordname is None: + logging.error("x-coordinate not set, cannot measure 3D") + return + if self._y_coordname is None: + logging.error("y-coordinate not set, cannot measure 3D") + return + self._msdim = 3 + self._measure() def _measure(self): pass - - ### Helper functions & classes ### + ### Helper ### @staticmethod def savgol_deriv(x_vals: np.ndarray, y_vals: np.ndarray, **savgol_args): """ @@ -145,26 +286,26 @@ def savgol_deriv(x_vals: np.ndarray, y_vals: np.ndarray, **savgol_args): savgol_args = {'window_length': 10, 'polyorder': 3, 'deriv': 1} | savgol_args return signal.savgol_filter(y_vals, **savgol_args)/signal.savgol_filter(x_vals, **savgol_args) - class ArbTwosideSweep(object): - def __init__(self, a_vals: np.ndarray, b_vals: np.ndarray): - if len(a_vals.shape) != 1 or len(b_vals.shape) != 1 or a_vals.shape != b_vals.shape: - logging.error("Sweep arrays must be 1D and of same length") - return - self.a_vals = a_vals - self.b_vals = b_vals - - def get(self): - return self.a_vals, self.b_vals - - def is_b_const(self) -> bool: - return np.all(self.b_vals == self.b_vals[0]) - - def set_b_const(self, b_val: float): - self.b_vals = np.linspace(b_val, b_val, self.a_vals.shape[0]) - - class LinearTwoside(ArbTwosideSweep): - def __init__(self, start_a, stop_a, start_b, stop_b, nop): - super.__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) - + def _set_b_for_axis(self, val: float): + for sweep in self._sweeps: + sweep.set_b_const(val) +class ArbTwosideSweep(object): + def __init__(self, a_vals: np.ndarray, b_vals: np.ndarray): + if len(a_vals.shape) != 1 or len(b_vals.shape) != 1 or a_vals.shape != b_vals.shape: + logging.error("Sweep arrays must be 1D and of same length") + return + self.a_vals = a_vals + self.b_vals = b_vals + + def get(self): + return self.a_vals, self.b_vals + + def is_b_const(self) -> bool: + return np.all(self.b_vals == self.b_vals[0]) + + def set_b_const(self, b_val: float): + self.b_vals = np.linspace(b_val, b_val, self.a_vals.shape[0]) - \ No newline at end of file +class LinearTwoside(ArbTwosideSweep): + def __init__(self, start_a, stop_a, start_b, stop_b, nop): + super.__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) From e1fb779685fe3e6ead70b43967b48ef3ef4d11ef Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Fri, 27 Jun 2025 15:36:51 +0200 Subject: [PATCH 30/43] Complete Twoside measure routine, untested --- src/qkit/measure/transport/twosided.py | 67 ++++++++++++++++++-------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index 130bb0bb3..1a817a85a 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -1,23 +1,16 @@ import numpy as np -import qkit.drivers -import qkit.measure -import qkit.measure.samples_class -import qkit.measure.transport -import qkit.measure.transport.transport -import qkit.storage -import qkit.storage.hdf_file -import qkit.storage.store from scipy import signal import logging import time -import sys -import threading import typing import qkit -from qkit.storage import store as hdf -from qkit.gui.plot import plot as qviewkit +import qkit.storage +import qkit.storage.hdf_dataset +import qkit.storage.store from qkit.gui.notebook.Progress_Bar import Progress_Bar +import qkit.measure +import qkit.measure.samples_class from qkit.measure.measurement_class import Measurement import qkit.measure.write_additional_files as waf @@ -188,7 +181,7 @@ def clear_views(self): ### Main measurement routine ### def prepare_measurement_file(self): - self.the_file = hdf.Data(name='_'.join(list(filter(None, ('{:d}D_IV_curve'.format(self._msdim), self.filename, self.expname)))), mode='a') + self.the_file = qkit.storage.store.Data(name='_'.join(list(filter(None, ('{:d}D_IV_curve'.format(self._msdim), self.filename, self.expname)))), mode='a') # settings self.the_file.add_textlist('settings').append(waf.get_instrument_settings(self.the_file.get_filepath())) self._measurement_object.uuid = self.the_file._uuid @@ -232,13 +225,13 @@ def value_dimobj(name, base_coord, unit, folder="data"): self._v2 = [value_dimobj("v2_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] self._i1 = [value_dimobj("i1_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] self._i2 = [value_dimobj("i2_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] - if self.store_effs: - self._va = [value_dimobj(self.eff_va_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] - self._vb = [value_dimobj(self.eff_vb_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] - self._ia = [value_dimobj(self.eff_ia_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] - self._ib = [value_dimobj(self.eff_ib_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] + + self._va = [value_dimobj(self.eff_va_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] if self.store_effs else [] + self._vb = [value_dimobj(self.eff_vb_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] if self.store_effs else [] + self._ia = [value_dimobj(self.eff_ia_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] if self.store_effs else [] + self._ib = [value_dimobj(self.eff_ib_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] if self.store_effs else [] # derivs - self._deriv_store = [] + self._deriv_store: list[list[qkit.storage.hdf_dataset.hdf_dataset]] = [] for dy, dx in self._derivs: self._deriv_store += [ [value_dimobj("d{}_d{}_{}".format(dy, dx, i), target_eff_va[i], "V", "analysis") for i in range(len(self._sweeps))] ] # views @@ -275,7 +268,40 @@ def measure_3D(self): self._measure() def _measure(self): - pass + self.prepare_measurement_file() + pb = Progress_Bar((1 if self._msdim < 3 else len(self._y_vec))*(1 if self._msdim < 2 else len(self._x_vec))*len(self._sweeps), self.the_file.get_filepath()) + try: + for ix, (x, x_func) in enumerate([(None, lambda x: None)] if self._msdim < 2 else [(x, self._x_set_obj) for x in self._x_vec]): + x_func(x) + time.sleep(self._x_dt) if (self._msdim >= 2 and not (self._x_dt is None)) else None + for iy, (y, y_func) in enumerate([(None, lambda y: None)] if self._msdim < 3 else [(y, self._y_set_obj) for y in self._y_vec]): + y_func(y) + time.sleep(self._y_dt) if (self._msdim == 3 and not (self._y_dt is None)) else None + # TODO logging + for i in range(len(self._sweeps)): + v1, v2, i1, i2 = self._DIVD.get_sweepdata(*self._sweeps[i].get()) + self._v1[i].append(v1) + self._v2[i].append(v2) + self._i1[i].append(i1) + self._i2[i].append(i2) + if self.store_effs: + vavb = self._DIVD._setter_matrix @ np.concatenate([v1[None,:], v2[None,:]], axis=0) + iaib = self._DIVD._getter_matrix @ np.concatenate([i1[None,:], i2[None,:]], axis=0) + self._va[i].append(vavb[0]) + self._vb[i].append(vavb[1]) + self._ia[i].append(iaib[0]) + self._ib[i].append(iaib[1]) + for j, (dy, dx) in enumerate(self._derivs): + self._deriv_store[j][i].append(self.deriv_func(eval("self._" + dy)[i], eval("self._" + dx)[i])) + pb.iterate() + + if self._msdim == 3: + for df in self._v1 + self._v2 + self._i1 + self._i2 + self._va + self._vb + self._ia + self._ib + sum(self._deriv_store, []): + df.next_matrix() + except: + self.the_file.close_file() + print('Measurement complete: {:s}'.format(self.the_file.get_filepath())) + ### Helper ### @staticmethod @@ -289,6 +315,7 @@ def savgol_deriv(x_vals: np.ndarray, y_vals: np.ndarray, **savgol_args): def _set_b_for_axis(self, val: float): for sweep in self._sweeps: sweep.set_b_const(val) + class ArbTwosideSweep(object): def __init__(self, a_vals: np.ndarray, b_vals: np.ndarray): if len(a_vals.shape) != 1 or len(b_vals.shape) != 1 or a_vals.shape != b_vals.shape: From c50649c1bf98e929e9ed8315755b99aceff47c11 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 30 Jun 2025 16:50:43 +0200 Subject: [PATCH 31/43] Refactored ADWinProII driver and added DoubleSideSweep support --- src/qkit/drivers/ADwinProII_SMU.py | 163 ++++++++++++++----------- src/qkit/measure/transport/twosided.py | 1 - 2 files changed, 94 insertions(+), 70 deletions(-) diff --git a/src/qkit/drivers/ADwinProII_SMU.py b/src/qkit/drivers/ADwinProII_SMU.py index 512d970fe..d22294e44 100644 --- a/src/qkit/drivers/ADwinProII_SMU.py +++ b/src/qkit/drivers/ADwinProII_SMU.py @@ -19,8 +19,9 @@ from qkit.core.instrument_base import Instrument import numpy as np import ADwin +from typing import Callable -class ADwinProII_SMU(Instrument): +class ADwinProII_Base(Instrument): """ The ADwin Pro II by Jäger Computergesteuerte Messtechnik GmbH provides slots for various ADC/DAC or data in/out cards as well as an accessable FPGA-like processor for control. Our @@ -41,24 +42,10 @@ class ADwinProII_SMU(Instrument): - With *adwin_install_path*/Tools/Test/ADpro/ADpro.exe one can check if communication with device is now possible & what the id-numbers of the used cards are (should be 1, 2, 3) - For usage one needs to provide a bootloader and to be run processes. The bootloader can - be found under *adwin_install_path*/ADwin12.btl for the T12 processor type. This driver - is in particular designed around 2 processes which configure the FPGA as a default SMU. - If you're still reading this your problem can't be solved any other way but with this - box, so at this point I wish you good luck with whatever is troubling you. The already - compiled processes can be found as .TCx-files alongside the other files on the exchange - under exchange/Devices/ADwinProII/ and should be provided somewhere on the measurement pc. - - SMU_SingleSetGet.TC1 enables ADC/DAC access by checking in regular intervals if a - respective action is requested. SMU_SweepLoop.TC2 is a high-priority process for sweeping - over a given array and reading back a given signal. The speed of one step is mostly limited - by the ADC/DAC access commands taking up to ~1600 CPU cycles = ~1.6e-6 s. The source code - for these processes is provided in the .bas-files. Alternatively one can program the FPGA - to do whatever one wants but then this driver obviously will no longer work with it. + be found under *adwin_install_path*/ADwin12.btl for the T12 processor type. A process + is defined in a compiled .TCx (x: process number) file. Tools and documentation for + creating, editing, testing and compiling are provided in the ADwin software package. """ - # static stuff - ADC_CARD_OFFSET = 10 - ADC_FILTER_CARD_OFFSET = 20 - DAC_CARD_OFFSET = 30 @staticmethod def _to_volt(x: np.ndarray) -> np.ndarray: return (x.astype(np.float64) - 0x8000) * 20.0 / 0x10000 @@ -67,60 +54,43 @@ def _to_reg(x: np.ndarray) -> np.ndarray: x_reg = np.round((x + 10.0)/20.0*0x10000) x_reg = np.where(x_reg == -1, 0, x_reg) return np.where(x_reg == 0x10000, 0xFFFF, x_reg) - - def __init__(self, name: str, btl_path: str = "C:\\ADwin\\ADwin12.btl", proc1_path: str = "C:\\ADwin\\SMU_SingleSetGet.TC1", proc2_path: str = "C:\\ADwin\\SMU_SweepLoop.TC2", device_num: int = 1): - """ - This is a driver for using the ADwinProII as an SMU by using the provided processes. - - Usage: - Initialize with - = qkit.instruments.create('', 'ADwinProII_SMU', **kwargs) - - Keyword arguments: - btl_path: path to bootloader file (default: "C:\\ADwin\\ADwin12.btl") - proc1_path: path to process file containing low-priority ADC/DAC access - (default: "C:\\ADwin\\SMU_SingleSetGet.TC1") - proc2_path: path to process file containing high-priority array sweep - (default: "C:\\ADwin\\SMU_SweepLoop.TC1") - device_num: number assigned to device on your pc by ADconfig (default: 1) - """ - # qkit stuff - Instrument.__init__(self, name, tags=['physical']) - self.add_parameter("adc", type = float, flags = Instrument.FLAG_GET, channels = (1, 4), minval = -10.0, maxval=10.0, units = "V") - self.add_parameter("adc_filtered", type = float, flags = Instrument.FLAG_GET, channels = (1, 8), minval = -10.0, maxval=10.0, units = "V") - self.add_parameter("dac", type = float, flags = Instrument.FLAG_SET, channels = (1, 8), minval = -10.0, maxval = 10.0, units = "V") - # Boot & Load processes + def __init__(self, name: str, btl_path: str = "C:\\ADwin\\ADwin12.btl", device_num: int = 1): + Instrument.__init__(self, name, tags=["physical"]) self.adw = ADwin.ADwin(device_num) self.adw.Boot(btl_path) logging.info("Booted ADwinProII with processor {}".format(self.adw.Processor_Type())) - self.adw.Load_Process(proc1_path) - self.adw.Start_Process(1) - logging.info("Loaded SMU process 1 for ADwinProII's FPGA") - self.adw.Load_Process(proc2_path) - logging.info("Loaded SMU process 2 for ADwinProII's FPGA") - # Sweep parameters - self.dac_channel = 1 # 1...8 - self.adc_channel = 1 # 1...4 or 1...8 depending on card - self.adc_card = 1 # 1 (normal), 2 (filtered) - self._sweep_channels = (self.dac_channel, self.adc_channel) # for qkit compability - self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. - # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always - # functionality +class ADwinProII_SingleSetGet(Instrument): + """ + To be used with the FPGA process 'SMU_SingleSetGet.TC1', enabling single set/get commands + for the ADC/DACs. + """ + # static stuff + ADC_CARD_OFFSET = 10 + ADC_FILTER_CARD_OFFSET = 20 + DAC_CARD_OFFSET = 30 + def __init__(self, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_SingleSetGet.TC1"): + Instrument.__init__(self, adw_base._name + "_SingleSetGet", tags=["virtual"]) + self.add_parameter("adc", type = float, flags = Instrument.FLAG_GET, channels = (1, 4), minval = -10.0, maxval=10.0, units = "V") + self.add_parameter("adc_filtered", type = float, flags = Instrument.FLAG_GET, channels = (1, 8), minval = -10.0, maxval=10.0, units = "V") + self.add_parameter("dac", type = float, flags = Instrument.FLAG_SET, channels = (1, 8), minval = -10.0, maxval = 10.0, units = "V") + self.adw = adw_base.adw + self.adw.Load_Process(proc_path) + self.adw.Start_Process(int(proc_path[-1])) + logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) def do_get_adc(self, channel: int) -> float: """ This is a very nice docstring """ if channel in range(1, 5): self.adw.Set_Par(channel + self.ADC_CARD_OFFSET, 1) - for i in range(5000): + for i in range(500): if self.adw.Get_Par(channel + self.ADC_CARD_OFFSET) == 0: return self.adw.Get_FPar(channel + self.ADC_CARD_OFFSET) - time.sleep(0.001) + time.sleep(0.01) raise TimeoutError("ADwin Pro II did not confirm ADC read within 5s") else: raise ValueError("Channel must be 1...4") - def do_get_adc_filtered(self, channel: int) -> float: """ This is a very nice docstring @@ -134,7 +104,6 @@ def do_get_adc_filtered(self, channel: int) -> float: raise TimeoutError("ADwin Pro II did not confirm ADC read within 5s") else: raise ValueError("Channel must be 1...8") - def do_set_dac(self, volt: float, channel: int) -> None: """ This is a very nice docstring @@ -144,7 +113,27 @@ def do_set_dac(self, volt: float, channel: int) -> None: self.adw.Set_Par(channel + self.DAC_CARD_OFFSET, 1) else: raise ValueError("Channel must be 1...8") + def reset(self): + for i in range(1, 9): + self.do_set_dac(0.0, i) +class ADwinProII_SweepLoop(Instrument): + """ + To be used with the FPGA process 'SMU_SweepLoop.TC2', enabling SMU functionality for + making qkit transport script DC sweeps. + """ + def __init__(self, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_SweepLoop.TC2"): + Instrument.__init__(self, adw_base._name + "SweepLoop", tags=["virtual"]) + self.proc_num = int(proc_path[-1]) + self.adw = adw_base.adw + self.adw.Load_Process(proc_path) + logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) + self.dac_channel = 1 # 1...8 + self.adc_channel = 1 # 1...4 or 1...8 depending on card + self.adc_card = 1 # 1 (normal), 2 (filtered) + self._sweep_channels = (self.dac_channel, self.adc_channel) # for qkit compability + self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. + # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always def set_sweep_parameters(self, sweep: np.ndarray) -> None: """ Check to be swept parameter settings and write them to the device. @@ -177,8 +166,7 @@ def set_sweep_parameters(self, sweep: np.ndarray) -> None: self.adw.Set_Par(3, self.adc_channel) self.adw.Set_Par(4, self.adc_card) self.adw.Set_Par(5, int(self.delay)) - self.adw.SetData_Long(self._to_reg(set_array), 1, 1, len(set_array)) - + self.adw.SetData_Long(ADwinProII_Base._to_reg(set_array), 1, 1, len(set_array)) def get_tracedata(self) -> tuple[np.ndarray]: """ Starts a sweep with parameters currently set on device and returns @@ -186,12 +174,11 @@ def get_tracedata(self) -> tuple[np.ndarray]: is being handled by overlaying virtual_tunnel_electronic """ # Sweep - self.adw.Start_Process(2) - while self.adw.Process_Status(2): + self.adw.Start_Process(self.proc_num) + while self.adw.Process_Status(self.proc_num): time.sleep(0.1) # Read result - return self._to_volt(self.adw.GetData_Long(1, 1, self.adw.Get_Par(1))), self._to_volt(self.adw.GetData_Long(2, 1, self.adw.Get_Par(1))) - + return ADwinProII_Base._to_volt(self.adw.GetData_Long(1, 1, self.adw.Get_Par(1))), ADwinProII_Base._to_volt(self.adw.GetData_Long(2, 1, self.adw.Get_Par(1))) # qkit SMU compability def set_sweep_mode(self, mode: int = 0): if mode != 0: @@ -202,10 +189,48 @@ def get_sweep_channels(self) -> tuple[int]: return (self.dac_channel, self.adc_channel) def set_status(self, *args, **kwargs) -> None: pass # ADC/DACs are always responsive - def reset(self): - for i in range(1, 9): - self.do_set_dac(0.0, i) +class ADwinProII_DoubleSweep(Instrument): + def __init__(self, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_DoubleSweep.TC3"): + Instrument.__init__(self, adw_base._name + "_DoubleSweep", tags=["virtual"]) + self.proc_num = int(proc_path[-1]) + self.adw = adw_base.adw + self.dac1_channel = 2 + self.dac2_channel = 3 + self.adc1_channel = 2 + self.adc2_channel = 3 + self.adc1_card = 1 # 1 (normal), 2 (filtered) + self.adc2_card = 1 # 1 (normal), 2 (filtered) + self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. + # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always + + def double_sweep(self, v1: np.ndarray, v2: np.ndarray, update_sweep_device: Callable[[], bool] = lambda: True): + if update_sweep_device(): + self.set_sweep_parameters(v1, v2) + self.adw.Start_Process(self.proc_num) + while self.adw.Process_Status(self.proc_num): + time.sleep(0.1) + nops = self.adw.Get_Par(1) + return ADwinProII_Base._to_volt(self.adw.GetData_Long(3, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(4, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(5, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(6, 1, nops)) + + def set_sweep_parameters(self, v1: np.ndarray, v2: np.ndarray): + # Skip checks here; if you use this, you'll know what you're doing because you're me + self.adw.Set_Par(41, len(v1)) + self.adw.Set_Par(42, self.dac1_channel) + self.adw.Set_Par(43, self.dac2_channel) + self.adw.Set_Par(44, self.adc1_channel) + self.adw.Set_Par(45, self.adc2_channel) + self.adw.Set_Par(46, self.adc1_card) + self.adw.Set_Par(47, self.adc2_card) + self.adw.Set_Par(48, int(self.delay)) + self.adw.SetData_Long(ADwinProII_Base._to_reg(v1), 3, 1, len(v1)) + self.adw.SetData_Long(ADwinProII_Base._to_reg(v2), 4, 1, len(v1)) -class ADwinProII_Double(Instrument): - pass \ No newline at end of file +class InitHandler(object): + def __init__(self): + self.not_called_yet = True + def __call__(self): + if self.not_called_yet: + self.not_called_yet = False + return True + return False \ No newline at end of file diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index 1a817a85a..4532bf243 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -225,7 +225,6 @@ def value_dimobj(name, base_coord, unit, folder="data"): self._v2 = [value_dimobj("v2_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] self._i1 = [value_dimobj("i1_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] self._i2 = [value_dimobj("i2_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] - self._va = [value_dimobj(self.eff_va_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] if self.store_effs else [] self._vb = [value_dimobj(self.eff_vb_name + "_{}".format(i), target_eff_va[i], "V") for i in range(len(self._sweeps))] if self.store_effs else [] self._ia = [value_dimobj(self.eff_ia_name + "_{}".format(i), target_eff_va[i], "A") for i in range(len(self._sweeps))] if self.store_effs else [] From ad53beef9670f4b413855f9b54e59a1a83ba5722 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Mon, 30 Jun 2025 17:24:36 +0200 Subject: [PATCH 32/43] Added logging to twoside measure --- src/qkit/measure/transport/twosided.py | 47 ++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index 4532bf243..7e3ed53d3 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -1,4 +1,6 @@ import numpy as np +import qkit.measure.transport +import qkit.measure.transport.twosided from scipy import signal import logging import time @@ -13,6 +15,7 @@ import qkit.measure.samples_class from qkit.measure.measurement_class import Measurement import qkit.measure.write_additional_files as waf +from qkit.measure.logging_base import logFunc class TransportTwoside(object): @@ -39,7 +42,8 @@ def __init__(self, DIVD): self.expname = None self.comment = None - # TODO logging + self.log_init_params = [] # buffer is necessary to allow adjusting x/y parameter sweeps after setting log functions + self.log_funcs: list[logFunc] = [] self._x_coordname = None self._x_set_obj = lambda x: None @@ -75,10 +79,38 @@ def __init__(self, DIVD): def set_sample(self, sample: qkit.measure.samples_class): self._measurement_object.sample = sample + def add_logger(self, func, name="log_param", unit="", dtype="f", over_x=True, over_y=True, is_trace=False, trace_base_vals=None, trace_base_name=None, trace_base_unit=None): + """ + Migration from set_log_function: + + -------- + def get_T(): + ... # returns a float + def a(): + ... # returns a float + + # obsolete + # tr.set_log_function([get_T, a], ["temp", "a_name"], ["K", "a_unit"]) + + # do instead + tr.add_logger(get_T, "temp", "K") # default migration + tr.add_logger(a, "a_name", "a_unit", over_y=False) # skip y-iteration if desired + ------ + + Alternatively more options like logging traces or skipping the x-iteration are possible now, see qkit/measure/logging_base for details. + """ + self.log_init_params += [(func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit)] # handle logger initialization in prepare_file + + def reset_log_function(self): + """ + Clear all set log functions + """ + self.log_init_params = [] + def clear_sweeps(self): self._sweeps = [] - def add_sweep(self, s: ArbTwosideSweep): + def add_sweep(self, s: qkit.measure.transport.twosided.ArbTwosideSweep): self._sweeps += [s] def set_x_parameter(self, name: str = None, set_obj: typing.Callable[[float], None] = lambda x: None, vals: list[float] = [None], unit: str = "A.U.", dt: float = None): @@ -236,6 +268,14 @@ def value_dimobj(name, base_coord, unit, folder="data"): # views for y, x in self._views: [self.the_file.add_view(y + "_" + x, eval("self._" + x)[i], eval("self._" + y)[i]) for i in range(len(self._sweeps))] + # logging + for init_tuple in self.log_init_params: + func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple + self.log_funcs += [logFunc(self.the_file.get_filepath(), func, name, unit, dtype, + x_coord.ds_url if (self._msdim >= 2) and over_x else None, + y_coord.ds_url if (self._msdim == 3) and over_y else None, + (trace_base_vals, trace_base_name, trace_base_unit) if is_trace else None)] + self.log_funcs[-1].prepare_file() def measure_1D(self): if len(self._sweeps) == 0: @@ -276,7 +316,8 @@ def _measure(self): for iy, (y, y_func) in enumerate([(None, lambda y: None)] if self._msdim < 3 else [(y, self._y_set_obj) for y in self._y_vec]): y_func(y) time.sleep(self._y_dt) if (self._msdim == 3 and not (self._y_dt is None)) else None - # TODO logging + for logger in self.log_funcs: + logger.logIfDesired(ix, iy) for i in range(len(self._sweeps)): v1, v2, i1, i2 = self._DIVD.get_sweepdata(*self._sweeps[i].get()) self._v1[i].append(v1) From 71647920cb3f82cc1247349911602d646e2c091a Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 1 Jul 2025 10:21:01 +0200 Subject: [PATCH 33/43] merges --- src/qkit/measure/transport/twosided.py | 46 ++++++++++++++------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index 7e3ed53d3..7e918946a 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -16,6 +16,29 @@ from qkit.measure.measurement_class import Measurement import qkit.measure.write_additional_files as waf from qkit.measure.logging_base import logFunc +from qkit.drivers.Double_VTE import Double_VTE + + +class ArbTwosideSweep(object): + def __init__(self, a_vals: np.ndarray, b_vals: np.ndarray): + if len(a_vals.shape) != 1 or len(b_vals.shape) != 1 or a_vals.shape != b_vals.shape: + logging.error("Sweep arrays must be 1D and of same length") + return + self.a_vals = a_vals + self.b_vals = b_vals + + def get(self): + return self.a_vals, self.b_vals + + def is_b_const(self) -> bool: + return np.all(self.b_vals == self.b_vals[0]) + + def set_b_const(self, b_val: float): + self.b_vals = np.linspace(b_val, b_val, self.a_vals.shape[0]) + +class LinearTwoside(ArbTwosideSweep): + def __init__(self, start_a, stop_a, start_b, stop_b, nop): + super.__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) class TransportTwoside(object): @@ -33,7 +56,7 @@ def __init__(self, DIVD): - sweep v_a, v_b as x/y-coordinate, other x/y arb. - simultaneous v_a & v_b sweep, arb. x/y-coordinates """ - self._DIVD: qkit.drivers.Douvle_VTE.Double_VTE = DIVD + self._DIVD: Double_VTE = DIVD self._measurement_object = Measurement() self._measurement_object.measurement_type = 'transport' @@ -355,24 +378,3 @@ def savgol_deriv(x_vals: np.ndarray, y_vals: np.ndarray, **savgol_args): def _set_b_for_axis(self, val: float): for sweep in self._sweeps: sweep.set_b_const(val) - -class ArbTwosideSweep(object): - def __init__(self, a_vals: np.ndarray, b_vals: np.ndarray): - if len(a_vals.shape) != 1 or len(b_vals.shape) != 1 or a_vals.shape != b_vals.shape: - logging.error("Sweep arrays must be 1D and of same length") - return - self.a_vals = a_vals - self.b_vals = b_vals - - def get(self): - return self.a_vals, self.b_vals - - def is_b_const(self) -> bool: - return np.all(self.b_vals == self.b_vals[0]) - - def set_b_const(self, b_val: float): - self.b_vals = np.linspace(b_val, b_val, self.a_vals.shape[0]) - -class LinearTwoside(ArbTwosideSweep): - def __init__(self, start_a, stop_a, start_b, stop_b, nop): - super.__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) From 726b06fab3d0234d535aee2d2effe7d557fc62d2 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 1 Jul 2025 13:50:53 +0200 Subject: [PATCH 34/43] Twoside Bugfixes --- src/qkit/drivers/Double_VTE.py | 15 ++------- src/qkit/measure/transport/twosided.py | 44 ++++++++++++++++---------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/qkit/drivers/Double_VTE.py b/src/qkit/drivers/Double_VTE.py index 67337c247..09d34fdb4 100644 --- a/src/qkit/drivers/Double_VTE.py +++ b/src/qkit/drivers/Double_VTE.py @@ -158,21 +158,12 @@ def get_parameters(self): "v_div_2": None, "dVdA_1": None, "dVdA_2": None, - } | ({ - "setter_1": None, - "setter_2": None, - "getter_1": None, - "getter_2": None, - "rdb_set_1": None, - "rdb_set_2": None, - "dt": None - } if self.sweep_manually else { - "double_sweeper": None - }) + "set_get_dt": None + } def get(self, param, **kwargs): try: - return eval("self.get_{}()".format(param)) if "etter_matrix" in param else eval("self.{}".format(param)) + return eval("self._{}".format(param)) if "etter_matrix" in param else eval("self.{}".format(param)) except: return None diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index 7e918946a..75f1a47fe 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -11,6 +11,7 @@ import qkit.storage.hdf_dataset import qkit.storage.store from qkit.gui.notebook.Progress_Bar import Progress_Bar +from qkit.gui.plot import plot as qviewkit import qkit.measure import qkit.measure.samples_class from qkit.measure.measurement_class import Measurement @@ -38,7 +39,7 @@ def set_b_const(self, b_val: float): class LinearTwoside(ArbTwosideSweep): def __init__(self, start_a, stop_a, start_b, stop_b, nop): - super.__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) + super().__init__(np.linspace(start_a, stop_a, nop), np.linspace(start_b, stop_b, nop)) class TransportTwoside(object): @@ -70,7 +71,7 @@ def __init__(self, DIVD): self._x_coordname = None self._x_set_obj = lambda x: None - self._x_vec = None + self._x_vec = [None] self._x_unit = None self._x_dt = None @@ -133,7 +134,7 @@ def reset_log_function(self): def clear_sweeps(self): self._sweeps = [] - def add_sweep(self, s: qkit.measure.transport.twosided.ArbTwosideSweep): + def add_sweep(self, s: ArbTwosideSweep): self._sweeps += [s] def set_x_parameter(self, name: str = None, set_obj: typing.Callable[[float], None] = lambda x: None, vals: list[float] = [None], unit: str = "A.U.", dt: float = None): @@ -241,9 +242,9 @@ def prepare_measurement_file(self): self.the_file.add_textlist('settings').append(waf.get_instrument_settings(self.the_file.get_filepath())) self._measurement_object.uuid = self.the_file._uuid self._measurement_object.hdf_relpath = self.the_file._relpath - self._measurement_object.instruments = qkit.instruments.get_instrument_names() # qkit.instruments.get_instruments() # - self._measurement_object.save() - self.the_file.add_textlist('measurement').append(self._measurement_object.get_JSON()) + self._measurement_object.instruments = qkit.instruments.get_instrument_names() + #self._measurement_object.save() # TODO fixme + #self.the_file.add_textlist('measurement').append(self._measurement_object.get_JSON()) # matrices mat_dummy_x = self.the_file.add_coordinate("_matrix_x", folder="analysis") mat_dummy_x.add([1, 2]) @@ -257,7 +258,7 @@ def prepare_measurement_file(self): getter_matrix.append(gm_elm) # coords target_eff_va = [self.the_file.add_coordinate("target_" + self.eff_va_name + "_{}".format(i), "V") for i in range(len(self._sweeps))] - target_eff_vb = [self.the_file.add_coordinate("target_" + self.eff_va_name + "_{}".format(i), "V") for i in range(len(self._sweeps))] + target_eff_vb = [self.the_file.add_coordinate("target_" + self.eff_vb_name + "_{}".format(i), "V") for i in range(len(self._sweeps))] for i in range(len(self._sweeps)): a, b = self._sweeps[i].get() target_eff_va[i].add(a) @@ -288,9 +289,14 @@ def value_dimobj(name, base_coord, unit, folder="data"): self._deriv_store: list[list[qkit.storage.hdf_dataset.hdf_dataset]] = [] for dy, dx in self._derivs: self._deriv_store += [ [value_dimobj("d{}_d{}_{}".format(dy, dx, i), target_eff_va[i], "V", "analysis") for i in range(len(self._sweeps))] ] - # views + view_buf = self.the_file.add_view("d" + dy + "_d" + dx, eval("self._" + dx)[0], self._deriv_store[-1][0], view_params={"labels": (dx[0].upper(), dy[0].upper() + "/" + dx[0].upper()), 'plot_style': 1, 'markersize': 5}) + for i in range(1, len(self._sweeps)): + view_buf.add(eval("self._" + dx)[i], self._deriv_store[-1][i]) + # extra views for y, x in self._views: - [self.the_file.add_view(y + "_" + x, eval("self._" + x)[i], eval("self._" + y)[i]) for i in range(len(self._sweeps))] + view_buf = self.the_file.add_view(y + "_" + x, eval("self._" + x)[0], eval("self._" + y)[0], view_params={"labels": (x[0].upper(), y[0].upper()), 'plot_style': 1, 'markersize': 5}) + for i in range(1, len(self._sweeps)): + view_buf.add(eval("self._" + x)[i], eval("self._" + y)[i]) # logging for init_tuple in self.log_init_params: func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple @@ -331,7 +337,8 @@ def measure_3D(self): def _measure(self): self.prepare_measurement_file() - pb = Progress_Bar((1 if self._msdim < 3 else len(self._y_vec))*(1 if self._msdim < 2 else len(self._x_vec))*len(self._sweeps), self.the_file.get_filepath()) + pb = Progress_Bar((1 if self._msdim < 3 else len(self._y_vec))*(1 if self._msdim < 2 else len(self._x_vec))*len(self._sweeps), self.the_file._uuid) + qviewkit.plot(self.the_file.get_filepath(), datasets=["views/" + y + "_" + x for y, x in self._views]) try: for ix, (x, x_func) in enumerate([(None, lambda x: None)] if self._msdim < 2 else [(x, self._x_set_obj) for x in self._x_vec]): x_func(x) @@ -343,6 +350,7 @@ def _measure(self): logger.logIfDesired(ix, iy) for i in range(len(self._sweeps)): v1, v2, i1, i2 = self._DIVD.get_sweepdata(*self._sweeps[i].get()) + time.sleep(self.sweep_dt) if not self.sweep_dt is None else None self._v1[i].append(v1) self._v2[i].append(v2) self._i1[i].append(i1) @@ -350,18 +358,22 @@ def _measure(self): if self.store_effs: vavb = self._DIVD._setter_matrix @ np.concatenate([v1[None,:], v2[None,:]], axis=0) iaib = self._DIVD._getter_matrix @ np.concatenate([i1[None,:], i2[None,:]], axis=0) - self._va[i].append(vavb[0]) - self._vb[i].append(vavb[1]) - self._ia[i].append(iaib[0]) - self._ib[i].append(iaib[1]) + va = vavb[0] + vb = vavb[1] + ia = iaib[0] + ib = iaib[1] + self._va[i].append(va) + self._vb[i].append(vb) + self._ia[i].append(ia) + self._ib[i].append(ib) for j, (dy, dx) in enumerate(self._derivs): - self._deriv_store[j][i].append(self.deriv_func(eval("self._" + dy)[i], eval("self._" + dx)[i])) + self._deriv_store[j][i].append(self.deriv_func(eval(dy), eval(dx))) pb.iterate() if self._msdim == 3: for df in self._v1 + self._v2 + self._i1 + self._i2 + self._va + self._vb + self._ia + self._ib + sum(self._deriv_store, []): df.next_matrix() - except: + finally: self.the_file.close_file() print('Measurement complete: {:s}'.format(self.the_file.get_filepath())) From 1e59db63970af0634f963861efdb763afa6a689f Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 8 Jul 2025 10:38:34 +0200 Subject: [PATCH 35/43] Keysight: 2-Channel I/V/R/P value address fix. Transport/Spectroscopy: Fix previous measurement loggers not removed --- src/qkit/drivers/Keysight_B2900.py | 6 +++--- src/qkit/measure/spectroscopy/spectroscopy.py | 1 + src/qkit/measure/transport/transport.py | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/qkit/drivers/Keysight_B2900.py b/src/qkit/drivers/Keysight_B2900.py index 21107915b..f2cd82407 100644 --- a/src/qkit/drivers/Keysight_B2900.py +++ b/src/qkit/drivers/Keysight_B2900.py @@ -1510,7 +1510,7 @@ def get_voltage(self, channel=1): logging.debug('{:s}: Get voltage value{:s}'.format(__name__, self._log_chans[self._channels][channel])) #self._write(':disp:view sing{:d}'.format(channel)) # return float(self._ask(':sour{:s}:volt?'.format(self._cmd_chans[self._channels][channel]))) - return float(self._ask(':meas:volt?').replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf')) + return float(self._ask(':meas:volt? (@{})'.format(channel)).replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf')) except Exception as e: logging.error('{!s}: Cannot get voltage value{:s}'.format(__name__, self._log_chans[self._channels][channel])) raise type(e)('{!s}: Cannot get voltage value{:s}\n{!s}'.format(__name__, self._log_chans[self._channels][channel], e)) @@ -1560,7 +1560,7 @@ def get_current(self, channel=1): logging.debug('{:s}: Get current value{:s}'.format(__name__, self._log_chans[self._channels][channel])) #self._write(':disp:view sing{:d}'.format(channel)) # return float(self._ask(':sour{:s}:curr?'.format(self._cmd_chans[self._channels][channel]))) - return float(self._ask(':meas:curr?').replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf')) + return float(self._ask(':meas:curr? (@{})'.format(channel)).replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf')) except Exception as e: logging.error('{!s}: Cannot get current value{:s}'.format(__name__, self._log_chans[self._channels][channel])) raise type(e)('{!s}: Cannot get current value{:s}\n{!s}'.format(__name__, self._log_chans[self._channels][channel], e)) @@ -1586,7 +1586,7 @@ def get_resistance(self, channel=1): logging.debug('{:s}: Get resistance value{:s}'.format(__name__, self._log_chans[self._channels][channel])) #self._write(':disp:view sing{:d}'.format(channel)) # return float(self._ask(':sour{:s}:res?'.format(self._cmd_chans[self._channels][channel]))) - return float(self._ask(':meas:res?').replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf')) + return float(self._ask(':meas:res? (@{})'.format(channel)).replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf')) except Exception as e: logging.error('{!s}: Cannot get resistance value{:s}'.format(__name__, self._log_chans[self._channels][channel])) raise type(e)('{!s}: Cannot get resistance value{:s}\n{!s}'.format(__name__, self._log_chans[self._channels][channel], e)) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 8dd5a076e..e60d2353b 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -380,6 +380,7 @@ def _prepare_measurement_file(self): self._pha_view = self._data_file.add_view("PhaseFit", self._data_freq, self._data_pha) self._pha_view.add(self._fit_freq, self._fit_pha) + self.log_funcs = [] for init_tuple in self.log_init_params: func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, dtype, diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index 33d1b1cb0..701460ed5 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1446,6 +1446,7 @@ def _prepare_measurement_file(self): self._add_views() # logging + self.log_funcs = [] for init_tuple in self.log_init_params: func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple self.log_funcs += [logFunc(self._data_file.get_filepath(), func, name, unit, dtype, From 83df4431b1c44d66d8051acb27dcc00f8bd15ae1 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Tue, 8 Jul 2025 17:48:20 +0200 Subject: [PATCH 36/43] Explicit logging-handler close-file --- src/qkit/measure/spectroscopy/spectroscopy.py | 2 ++ src/qkit/measure/transport/transport.py | 2 ++ src/qkit/measure/transport/twosided.py | 3 +++ 3 files changed, 7 insertions(+) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index e60d2353b..9978a749a 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -756,6 +756,8 @@ def _end_measurement(self): t = threading.Thread(target=qviewkit.save_plots, args=[self._data_file.get_filepath(), self._plot_comment]) t.start() self._data_file.close_file() + for lf in self.log_funcs: + lf.file.close_file() waf.close_log_file(self._log) self.dirname = None if self.averaging_start_ready: self.vna.post_measurement() diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index 701460ed5..e47250d33 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1194,6 +1194,8 @@ def _pass(arg): t = threading.Thread(target=qviewkit.save_plots, args=[self._data_file.get_filepath(), self._plot_comment]) t.start() self._data_file.close_file() + for lf in self.log_funcs: + lf.file.close_file() waf.close_log_file(self._log_file) self._set_IVD_status(False) print('Measurement complete: {:s}'.format(self._data_file.get_filepath())) diff --git a/src/qkit/measure/transport/twosided.py b/src/qkit/measure/transport/twosided.py index 75f1a47fe..3d2a9b008 100644 --- a/src/qkit/measure/transport/twosided.py +++ b/src/qkit/measure/transport/twosided.py @@ -298,6 +298,7 @@ def value_dimobj(name, base_coord, unit, folder="data"): for i in range(1, len(self._sweeps)): view_buf.add(eval("self._" + x)[i], eval("self._" + y)[i]) # logging + self.log_funcs = [] for init_tuple in self.log_init_params: func, name, unit, dtype, over_x, over_y, is_trace, trace_base_vals, trace_base_name, trace_base_unit = init_tuple self.log_funcs += [logFunc(self.the_file.get_filepath(), func, name, unit, dtype, @@ -375,6 +376,8 @@ def _measure(self): df.next_matrix() finally: self.the_file.close_file() + for lf in self.log_funcs: + lf.file.close_file() print('Measurement complete: {:s}'.format(self.the_file.get_filepath())) From e7dbf34f93607bc9f179e8092f34d48ce670aa4d Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Wed, 9 Jul 2025 19:03:36 +0200 Subject: [PATCH 37/43] ADwin DoubleSweep BugFixes --- src/qkit/drivers/ADwinProII_Base.py | 65 ++++++ src/qkit/drivers/ADwinProII_DoubleSweep.py | 69 ++++++ src/qkit/drivers/ADwinProII_SMU.py | 236 -------------------- src/qkit/drivers/ADwinProII_SingleSetGet.py | 77 +++++++ src/qkit/drivers/ADwinProII_SweepLoop.py | 94 ++++++++ 5 files changed, 305 insertions(+), 236 deletions(-) create mode 100644 src/qkit/drivers/ADwinProII_Base.py create mode 100644 src/qkit/drivers/ADwinProII_DoubleSweep.py delete mode 100644 src/qkit/drivers/ADwinProII_SMU.py create mode 100644 src/qkit/drivers/ADwinProII_SingleSetGet.py create mode 100644 src/qkit/drivers/ADwinProII_SweepLoop.py diff --git a/src/qkit/drivers/ADwinProII_Base.py b/src/qkit/drivers/ADwinProII_Base.py new file mode 100644 index 000000000..13eb24d0e --- /dev/null +++ b/src/qkit/drivers/ADwinProII_Base.py @@ -0,0 +1,65 @@ +# ADwinProII_SMU.py driver for using ADwin Pro II as an SMU +# Author: Marius Frohn (uzrfo@student.kit.edu) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging, time +from qkit.core.instrument_base import Instrument +import numpy as np +import ADwin +from typing import Callable + +class ADwinProII_Base(Instrument): + """ + The ADwin Pro II by Jäger Computergesteuerte Messtechnik GmbH provides slots for various + ADC/DAC or data in/out cards as well as an accessable FPGA-like processor for control. Our + group's device currently has a 4-channel 16-bit ADC, a 50kHz filtered 8-channel 16-bit ADC + and an 8-channel 16-bit DAC card. Getting the device running requires some steps outlined + below: + + - Selecting drivers from https://www.adwin.de/de/download/download.html. Installing the + full software package is recommended for potential debugging, analyzing timings, etc. + - 'pip install ADwin' to the qkit virtual environment. (Note: despite being present, the + driver could not correctly read out the ADwin install directory from the windows registry + on my machine. To fix this, I commented out the entire try/except block around line ~100 + in *venv*/site-packages/ADwin.py and hardcoded 'self.ADwindir = *adwin_install_path*') + - The tool *adwin_install_path*/Tools/ADconfig/ADconfig.exe finds ADwin devices in the + network and allows e.g. assigning their MAC-addresses to a fixed IP-address. This should + already be done for this device. In either case one still needs to register it as an + adwin-device on the measurement pc and assign it an adwin-device-number (default: 1) + - With *adwin_install_path*/Tools/Test/ADpro/ADpro.exe one can check if communication with + device is now possible & what the id-numbers of the used cards are (should be 1, 2, 3) + - For usage one needs to provide a bootloader and to be run processes. The bootloader can + be found under *adwin_install_path*/ADwin12.btl for the T12 processor type. A process + is defined in a compiled .TCx (x: process number) file. Tools and documentation for + creating, editing, testing and compiling are provided in the ADwin software package. + """ + @staticmethod + def _to_volt(x: np.ndarray) -> np.ndarray: + return (np.array(x, dtype=np.int32).astype(np.float64) - 0x8000) * 20.0 / 0x10000 + @staticmethod + def _to_reg(x: np.ndarray) -> np.ndarray: + x_reg = np.round((x + 10.0)/20.0*0x10000) + x_reg = np.where(x_reg == -1, 0, x_reg) + return np.where(x_reg == 0x10000, 0xFFFF, x_reg) + def __init__(self, name: str, btl_path: str = "C:\\ADwin\\ADwin12.btl", device_num: int = 1): + """ + Use via e.g. + adw_base = qkit.instruments.create("adw_base", "ADwinProII_Base", *kwargs*) + """ + Instrument.__init__(self, name, tags=["physical"]) + self.adw = ADwin.ADwin(device_num) + self.adw.Boot(btl_path) + logging.info("Booted ADwinProII with processor {}".format(self.adw.Processor_Type())) \ No newline at end of file diff --git a/src/qkit/drivers/ADwinProII_DoubleSweep.py b/src/qkit/drivers/ADwinProII_DoubleSweep.py new file mode 100644 index 000000000..6c44e11b9 --- /dev/null +++ b/src/qkit/drivers/ADwinProII_DoubleSweep.py @@ -0,0 +1,69 @@ +# ADwinProII_SMU.py driver for using ADwin Pro II as an SMU +# Author: Marius Frohn (uzrfo@student.kit.edu) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging, time +from qkit.core.instrument_base import Instrument +import numpy as np +from typing import Callable +from qkit.drivers.ADwinProII_Base import ADwinProII_Base + +class ADwinProII_DoubleSweep(Instrument): + def __init__(self, name: str, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_DoubleSweep.TC3"): + Instrument.__init__(self, name, tags=["virtual"]) + self.proc_num = int(proc_path[-1]) + self.adw = adw_base.adw + self.dac1_channel = 2 + self.dac2_channel = 3 + self.adc1_channel = 2 + self.adc2_channel = 3 + self.adc1_card = 1 # 1 (normal), 2 (filtered) + self.adc2_card = 1 # 1 (normal), 2 (filtered) + self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. + # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always + self.adw.Load_Process(proc_path) + logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) + + def double_sweep(self, v1: np.ndarray, v2: np.ndarray, update_sweep_device: Callable[[], bool] = lambda: True): + if update_sweep_device(): + self.set_sweep_parameters(v1, v2) + self.adw.Start_Process(self.proc_num) + while self.adw.Process_Status(self.proc_num): + time.sleep(0.1) + nops = self.adw.Get_Par(41) + return ADwinProII_Base._to_volt(self.adw.GetData_Long(3, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(4, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(5, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(6, 1, nops)) + + def set_sweep_parameters(self, v1: np.ndarray, v2: np.ndarray): + # Skip checks here; if you use this, you'll know what you're doing because you're me + self.adw.Set_Par(41, len(v1)) + self.adw.Set_Par(42, self.dac1_channel) + self.adw.Set_Par(43, self.dac2_channel) + self.adw.Set_Par(44, self.adc1_channel) + self.adw.Set_Par(45, self.adc1_card) + self.adw.Set_Par(46, self.adc2_channel) + self.adw.Set_Par(47, self.adc2_card) + self.adw.Set_Par(48, int(self.delay)) + self.adw.SetData_Long(ADwinProII_Base._to_reg(v1), 3, 1, len(v1)) + self.adw.SetData_Long(ADwinProII_Base._to_reg(v2), 4, 1, len(v1)) + +class InitHandler(object): + def __init__(self): + self.not_called_yet = True + def __call__(self): + if self.not_called_yet: + self.not_called_yet = False + return True + return False \ No newline at end of file diff --git a/src/qkit/drivers/ADwinProII_SMU.py b/src/qkit/drivers/ADwinProII_SMU.py deleted file mode 100644 index d22294e44..000000000 --- a/src/qkit/drivers/ADwinProII_SMU.py +++ /dev/null @@ -1,236 +0,0 @@ -# ADwinProII_SMU.py driver for using ADwin Pro II as an SMU -# Author: Marius Frohn (uzrfo@student.kit.edu) -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -import logging, time -from qkit.core.instrument_base import Instrument -import numpy as np -import ADwin -from typing import Callable - -class ADwinProII_Base(Instrument): - """ - The ADwin Pro II by Jäger Computergesteuerte Messtechnik GmbH provides slots for various - ADC/DAC or data in/out cards as well as an accessable FPGA-like processor for control. Our - group's device currently has a 4-channel 16-bit ADC, a 50kHz filtered 8-channel 16-bit ADC - and an 8-channel 16-bit DAC card. Getting the device running requires some steps outlined - below: - - - Selecting drivers from https://www.adwin.de/de/download/download.html. Installing the - full software package is recommended for potential debugging, analyzing timings, etc. - - 'pip install ADwin' to the qkit virtual environment. (Note: despite being present, the - driver could not correctly read out the ADwin install directory from the windows registry - on my machine. To fix this, I commented out the entire try/except block around line ~100 - in *venv*/site-packages/ADwin.py and hardcoded 'self.ADwindir = *adwin_install_path*') - - The tool *adwin_install_path*/Tools/ADconfig/ADconfig.exe finds ADwin devices in the - network and allows e.g. assigning their MAC-addresses to a fixed IP-address. This should - already be done for this device. In either case one still needs to register it as an - adwin-device on the measurement pc and assign it an adwin-device-number (default: 1) - - With *adwin_install_path*/Tools/Test/ADpro/ADpro.exe one can check if communication with - device is now possible & what the id-numbers of the used cards are (should be 1, 2, 3) - - For usage one needs to provide a bootloader and to be run processes. The bootloader can - be found under *adwin_install_path*/ADwin12.btl for the T12 processor type. A process - is defined in a compiled .TCx (x: process number) file. Tools and documentation for - creating, editing, testing and compiling are provided in the ADwin software package. - """ - @staticmethod - def _to_volt(x: np.ndarray) -> np.ndarray: - return (x.astype(np.float64) - 0x8000) * 20.0 / 0x10000 - @staticmethod - def _to_reg(x: np.ndarray) -> np.ndarray: - x_reg = np.round((x + 10.0)/20.0*0x10000) - x_reg = np.where(x_reg == -1, 0, x_reg) - return np.where(x_reg == 0x10000, 0xFFFF, x_reg) - def __init__(self, name: str, btl_path: str = "C:\\ADwin\\ADwin12.btl", device_num: int = 1): - Instrument.__init__(self, name, tags=["physical"]) - self.adw = ADwin.ADwin(device_num) - self.adw.Boot(btl_path) - logging.info("Booted ADwinProII with processor {}".format(self.adw.Processor_Type())) - -class ADwinProII_SingleSetGet(Instrument): - """ - To be used with the FPGA process 'SMU_SingleSetGet.TC1', enabling single set/get commands - for the ADC/DACs. - """ - # static stuff - ADC_CARD_OFFSET = 10 - ADC_FILTER_CARD_OFFSET = 20 - DAC_CARD_OFFSET = 30 - def __init__(self, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_SingleSetGet.TC1"): - Instrument.__init__(self, adw_base._name + "_SingleSetGet", tags=["virtual"]) - self.add_parameter("adc", type = float, flags = Instrument.FLAG_GET, channels = (1, 4), minval = -10.0, maxval=10.0, units = "V") - self.add_parameter("adc_filtered", type = float, flags = Instrument.FLAG_GET, channels = (1, 8), minval = -10.0, maxval=10.0, units = "V") - self.add_parameter("dac", type = float, flags = Instrument.FLAG_SET, channels = (1, 8), minval = -10.0, maxval = 10.0, units = "V") - self.adw = adw_base.adw - self.adw.Load_Process(proc_path) - self.adw.Start_Process(int(proc_path[-1])) - logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) - def do_get_adc(self, channel: int) -> float: - """ - This is a very nice docstring - """ - if channel in range(1, 5): - self.adw.Set_Par(channel + self.ADC_CARD_OFFSET, 1) - for i in range(500): - if self.adw.Get_Par(channel + self.ADC_CARD_OFFSET) == 0: - return self.adw.Get_FPar(channel + self.ADC_CARD_OFFSET) - time.sleep(0.01) - raise TimeoutError("ADwin Pro II did not confirm ADC read within 5s") - else: - raise ValueError("Channel must be 1...4") - def do_get_adc_filtered(self, channel: int) -> float: - """ - This is a very nice docstring - """ - if channel in range(1, 9): - self.adw.Set_Par(channel + self.ADC_FILTER_CARD_OFFSET, 1) - for i in range(5000): - if self.adw.Get_Par(channel + self.ADC_FILTER_CARD_OFFSET) == 0: - return self.adw.Get_FPar(channel + self.ADC_FILTER_CARD_OFFSET) - time.sleep(0.001) - raise TimeoutError("ADwin Pro II did not confirm ADC read within 5s") - else: - raise ValueError("Channel must be 1...8") - def do_set_dac(self, volt: float, channel: int) -> None: - """ - This is a very nice docstring - """ - if channel in range(1, 9): - self.adw.Set_FPar(channel + self.DAC_CARD_OFFSET, volt) - self.adw.Set_Par(channel + self.DAC_CARD_OFFSET, 1) - else: - raise ValueError("Channel must be 1...8") - def reset(self): - for i in range(1, 9): - self.do_set_dac(0.0, i) - -class ADwinProII_SweepLoop(Instrument): - """ - To be used with the FPGA process 'SMU_SweepLoop.TC2', enabling SMU functionality for - making qkit transport script DC sweeps. - """ - def __init__(self, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_SweepLoop.TC2"): - Instrument.__init__(self, adw_base._name + "SweepLoop", tags=["virtual"]) - self.proc_num = int(proc_path[-1]) - self.adw = adw_base.adw - self.adw.Load_Process(proc_path) - logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) - self.dac_channel = 1 # 1...8 - self.adc_channel = 1 # 1...4 or 1...8 depending on card - self.adc_card = 1 # 1 (normal), 2 (filtered) - self._sweep_channels = (self.dac_channel, self.adc_channel) # for qkit compability - self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. - # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always - def set_sweep_parameters(self, sweep: np.ndarray) -> None: - """ - Check to be swept parameter settings and write them to the device. - Sweep: array describing sweep values by [start, stop, step] - - Max 2^16 points due to (arbitary) memory limitation on device (More - values would not make sense anyways though since the ADC/DACs have - only 16-bit resolution) - Voltage range is limited to +-10V for both ADC/DACs - Look at device for channel number limitations - """ - # self.dac_channel, self.adc_channel = self._sweep_channels # NOTE: keep this line? potentially causes more problems with qkit defaults not fitting for this device than the feature is worth - set_array = np.arange(sweep[0], sweep[1] + sweep[2]/2, sweep[2]) - # Check values - if len(set_array) > 0x1000: - raise ValueError("Sweep size of 16 bit ADC/DACs limited to 2^16 values") - if np.any(np.abs(set_array) > 10.0): - raise ValueError("Sweep array contains values outside +-10V range") - if not self.dac_channel in range(1, 9): - raise ValueError("DAC channel must be 1...8") - if not self.adc_card in [1, 2]: - raise ValueError("ADC card must be 1 (normal), 2 (50kHz filtered)") - if self.adc_card == 1 and not self.adc_channel in range(1, 5): - raise ValueError("ADC channel must be 1...4") - if self.adc_card == 2 and not self.adc_channel in range(1, 9): - raise ValueError("Filtered ADC channel must be 1...8") - # Set values - self.adw.Set_Par(1, len(set_array)) - self.adw.Set_Par(2, self.dac_channel) - self.adw.Set_Par(3, self.adc_channel) - self.adw.Set_Par(4, self.adc_card) - self.adw.Set_Par(5, int(self.delay)) - self.adw.SetData_Long(ADwinProII_Base._to_reg(set_array), 1, 1, len(set_array)) - def get_tracedata(self) -> tuple[np.ndarray]: - """ - Starts a sweep with parameters currently set on device and returns - set_values_array, measured_values_array. Sorting by what is I and V - is being handled by overlaying virtual_tunnel_electronic - """ - # Sweep - self.adw.Start_Process(self.proc_num) - while self.adw.Process_Status(self.proc_num): - time.sleep(0.1) - # Read result - return ADwinProII_Base._to_volt(self.adw.GetData_Long(1, 1, self.adw.Get_Par(1))), ADwinProII_Base._to_volt(self.adw.GetData_Long(2, 1, self.adw.Get_Par(1))) - # qkit SMU compability - def set_sweep_mode(self, mode: int = 0): - if mode != 0: - print("ADwinProII only has voltage ADC/DACs, only VV-mode 0 supported") - def get_sweep_mode(self) -> int: - return 0 - def get_sweep_channels(self) -> tuple[int]: - return (self.dac_channel, self.adc_channel) - def set_status(self, *args, **kwargs) -> None: - pass # ADC/DACs are always responsive - -class ADwinProII_DoubleSweep(Instrument): - def __init__(self, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_DoubleSweep.TC3"): - Instrument.__init__(self, adw_base._name + "_DoubleSweep", tags=["virtual"]) - self.proc_num = int(proc_path[-1]) - self.adw = adw_base.adw - self.dac1_channel = 2 - self.dac2_channel = 3 - self.adc1_channel = 2 - self.adc2_channel = 3 - self.adc1_card = 1 # 1 (normal), 2 (filtered) - self.adc2_card = 1 # 1 (normal), 2 (filtered) - self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. - # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always - - def double_sweep(self, v1: np.ndarray, v2: np.ndarray, update_sweep_device: Callable[[], bool] = lambda: True): - if update_sweep_device(): - self.set_sweep_parameters(v1, v2) - self.adw.Start_Process(self.proc_num) - while self.adw.Process_Status(self.proc_num): - time.sleep(0.1) - nops = self.adw.Get_Par(1) - return ADwinProII_Base._to_volt(self.adw.GetData_Long(3, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(4, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(5, 1, nops)), ADwinProII_Base._to_volt(self.adw.GetData_Long(6, 1, nops)) - - def set_sweep_parameters(self, v1: np.ndarray, v2: np.ndarray): - # Skip checks here; if you use this, you'll know what you're doing because you're me - self.adw.Set_Par(41, len(v1)) - self.adw.Set_Par(42, self.dac1_channel) - self.adw.Set_Par(43, self.dac2_channel) - self.adw.Set_Par(44, self.adc1_channel) - self.adw.Set_Par(45, self.adc2_channel) - self.adw.Set_Par(46, self.adc1_card) - self.adw.Set_Par(47, self.adc2_card) - self.adw.Set_Par(48, int(self.delay)) - self.adw.SetData_Long(ADwinProII_Base._to_reg(v1), 3, 1, len(v1)) - self.adw.SetData_Long(ADwinProII_Base._to_reg(v2), 4, 1, len(v1)) - -class InitHandler(object): - def __init__(self): - self.not_called_yet = True - def __call__(self): - if self.not_called_yet: - self.not_called_yet = False - return True - return False \ No newline at end of file diff --git a/src/qkit/drivers/ADwinProII_SingleSetGet.py b/src/qkit/drivers/ADwinProII_SingleSetGet.py new file mode 100644 index 000000000..ee8080e84 --- /dev/null +++ b/src/qkit/drivers/ADwinProII_SingleSetGet.py @@ -0,0 +1,77 @@ +# ADwinProII_SMU.py driver for using ADwin Pro II as an SMU +# Author: Marius Frohn (uzrfo@student.kit.edu) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging, time +from qkit.core.instrument_base import Instrument +from qkit.drivers.ADwinProII_Base import ADwinProII_Base + +class ADwinProII_SingleSetGet(Instrument): + """ + To be used with the FPGA process 'SMU_SingleSetGet.TC1', enabling single set/get commands + for the ADC/DACs. + """ + # static stuff + ADC_CARD_OFFSET = 10 + ADC_FILTER_CARD_OFFSET = 20 + DAC_CARD_OFFSET = 30 + def __init__(self, name: str, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_SingleSetGet.TC1"): + Instrument.__init__(self, name, tags=["virtual"]) + self.add_parameter("adc", type = float, flags = Instrument.FLAG_GET, channels = (1, 4), minval = -10.0, maxval = 10.0, units = "V") + self.add_parameter("adc_filtered", type = float, flags = Instrument.FLAG_GET, channels = (1, 8), minval = -10.0, maxval = 10.0, units = "V") + self.add_parameter("dac", type = float, flags = Instrument.FLAG_SET, channels = (1, 8), minval = -10.0, maxval = 10.0, units = "V") + self.adw = adw_base.adw + self.adw.Load_Process(proc_path) + self.adw.Start_Process(int(proc_path[-1])) + logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) + def do_get_adc(self, channel: int) -> float: + """ + This is a very nice docstring + """ + if channel in range(1, 5): + self.adw.Set_Par(channel + self.ADC_CARD_OFFSET, 1) + for i in range(500): + if self.adw.Get_Par(channel + self.ADC_CARD_OFFSET) == 0: + return self.adw.Get_FPar(channel + self.ADC_CARD_OFFSET) + time.sleep(0.01) + raise TimeoutError("ADwin Pro II did not confirm ADC read within 5s") + else: + raise ValueError("Channel must be 1...4") + def do_get_adc_filtered(self, channel: int) -> float: + """ + This is a very nice docstring + """ + if channel in range(1, 9): + self.adw.Set_Par(channel + self.ADC_FILTER_CARD_OFFSET, 1) + for i in range(5000): + if self.adw.Get_Par(channel + self.ADC_FILTER_CARD_OFFSET) == 0: + return self.adw.Get_FPar(channel + self.ADC_FILTER_CARD_OFFSET) + time.sleep(0.001) + raise TimeoutError("ADwin Pro II did not confirm ADC read within 5s") + else: + raise ValueError("Channel must be 1...8") + def do_set_dac(self, volt: float, channel: int) -> None: + """ + This is a very nice docstring + """ + if channel in range(1, 9): + self.adw.Set_FPar(channel + self.DAC_CARD_OFFSET, volt) + self.adw.Set_Par(channel + self.DAC_CARD_OFFSET, 1) + else: + raise ValueError("Channel must be 1...8") + def reset(self): + for i in range(1, 9): + self.do_set_dac(0.0, i) \ No newline at end of file diff --git a/src/qkit/drivers/ADwinProII_SweepLoop.py b/src/qkit/drivers/ADwinProII_SweepLoop.py new file mode 100644 index 000000000..ac376c349 --- /dev/null +++ b/src/qkit/drivers/ADwinProII_SweepLoop.py @@ -0,0 +1,94 @@ +# ADwinProII_SMU.py driver for using ADwin Pro II as an SMU +# Author: Marius Frohn (uzrfo@student.kit.edu) +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import logging, time +from qkit.core.instrument_base import Instrument +import numpy as np +from qkit.drivers.ADwinProII_Base import ADwinProII_Base + +class ADwinProII_SweepLoop(Instrument): + """ + To be used with the FPGA process 'SMU_SweepLoop.TC2', enabling SMU functionality for + making qkit transport script DC sweeps. + """ + def __init__(self, name: str, adw_base: ADwinProII_Base, proc_path="C:\\ADwin\\SMU_SweepLoop.TC2"): + Instrument.__init__(self, name, tags=["virtual"]) + self.proc_num = int(proc_path[-1]) + self.adw = adw_base.adw + self.adw.Load_Process(proc_path) + logging.info("Loaded process '{}' for ADwinProII's FPGA".format(proc_path)) + self.dac_channel = 1 # 1...8 + self.adc_channel = 1 # 1...4 or 1...8 depending on card + self.adc_card = 1 # 1 (normal), 2 (filtered) + self._sweep_channels = (self.dac_channel, self.adc_channel) # for qkit compability + self.delay = 2000 # NOTE: int describing to be slept time inbetween dac set and adc get. + # slept time <=> self.delay * 1e-8 s, set/get commands take ~2e-6 s per point on their own always + def set_sweep_parameters(self, sweep: np.ndarray) -> None: + """ + Check to be swept parameter settings and write them to the device. + Sweep: array describing sweep values by [start, stop, step] + + Max 2^16 points due to (arbitary) memory limitation on device (More + values would not make sense anyways though since the ADC/DACs have + only 16-bit resolution) + Voltage range is limited to +-10V for both ADC/DACs + Look at device for channel number limitations + """ + # self.dac_channel, self.adc_channel = self._sweep_channels # NOTE: keep this line? potentially causes more problems with qkit defaults not fitting for this device than the feature is worth + set_array = np.arange(sweep[0], sweep[1] + sweep[2]/2, sweep[2]) + # Check values + if len(set_array) > 0x1000: + raise ValueError("Sweep size of 16 bit ADC/DACs limited to 2^16 values") + if np.any(np.abs(set_array) > 10.0): + raise ValueError("Sweep array contains values outside +-10V range") + if not self.dac_channel in range(1, 9): + raise ValueError("DAC channel must be 1...8") + if not self.adc_card in [1, 2]: + raise ValueError("ADC card must be 1 (normal), 2 (50kHz filtered)") + if self.adc_card == 1 and not self.adc_channel in range(1, 5): + raise ValueError("ADC channel must be 1...4") + if self.adc_card == 2 and not self.adc_channel in range(1, 9): + raise ValueError("Filtered ADC channel must be 1...8") + # Set values + self.adw.Set_Par(1, len(set_array)) + self.adw.Set_Par(2, self.dac_channel) + self.adw.Set_Par(3, self.adc_channel) + self.adw.Set_Par(4, self.adc_card) + self.adw.Set_Par(5, int(self.delay)) + self.adw.SetData_Long(ADwinProII_Base._to_reg(set_array), 1, 1, len(set_array)) + def get_tracedata(self) -> tuple[np.ndarray]: + """ + Starts a sweep with parameters currently set on device and returns + set_values_array, measured_values_array. Sorting by what is I and V + is being handled by overlaying virtual_tunnel_electronic + """ + # Sweep + self.adw.Start_Process(self.proc_num) + while self.adw.Process_Status(self.proc_num): + time.sleep(0.1) + # Read result + return ADwinProII_Base._to_volt(self.adw.GetData_Long(1, 1, self.adw.Get_Par(1))), ADwinProII_Base._to_volt(self.adw.GetData_Long(2, 1, self.adw.Get_Par(1))) + # qkit SMU compability + def set_sweep_mode(self, mode: int = 0): + if mode != 0: + logging.error("ADwinProII only has voltage ADC/DACs, only VV-mode 0 supported") + def get_sweep_mode(self) -> int: + return 0 + def get_sweep_channels(self) -> tuple[int]: + return (self.dac_channel, self.adc_channel) + def set_status(self, *args, **kwargs) -> None: + pass # ADC/DACs are always responsive \ No newline at end of file From 0afd721a7230881c5340a10b78c87ce3f936233b Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 30 Sep 2025 10:33:58 +0200 Subject: [PATCH 38/43] Circlefit less warnings, fix Keysight IV/VI sorting, minor spectroscopy adjustments --- src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py | 4 +++- src/qkit/drivers/Keysight_B2900.py | 2 +- src/qkit/measure/spectroscopy/spectroscopy.py | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py index 86611b216..24eda4c22 100644 --- a/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py +++ b/src/qkit/analysis/circle_fit/circle_fit_2019/circuit.py @@ -419,7 +419,7 @@ def _fit_phase(self, z_data, guesses=None): phase = np.unwrap(np.angle(z_data)) # For centered circle roll-off should be close to 2pi. If not warn user. - if np.max(phase) - np.min(phase) <= 0.8*2*np.pi: + if np.max(phase) - np.min(phase) <= 0.4*2*np.pi: logging.warning( "Data does not cover a full circle (only {:.1f}".format( np.max(phase) - np.min(phase) @@ -427,6 +427,8 @@ def _fit_phase(self, z_data, guesses=None): +" rad). Increase the frequency span around the resonance?" ) roll_off = np.max(phase) - np.min(phase) + elif np.max(phase) - np.min(phase) <= 0.8*2*np.pi: + roll_off = np.max(phase) - np.min(phase) else: roll_off = 2*np.pi diff --git a/src/qkit/drivers/Keysight_B2900.py b/src/qkit/drivers/Keysight_B2900.py index f2cd82407..b6a8dbab6 100644 --- a/src/qkit/drivers/Keysight_B2900.py +++ b/src/qkit/drivers/Keysight_B2900.py @@ -2052,7 +2052,7 @@ def get_tracedata(self): self._wait_for_transition_idle(channel=channel_bias) I_values = np.fromstring(self._ask(':fetc:arr:curr?').replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf'), sep=',', dtype=float) V_values = np.fromstring(self._ask(':fetc:arr:volt?').replace('+9.910000E+37', 'nan').replace('9.900000E+37', 'inf'), sep=',', dtype=float) - return (I_values, V_values)[::int(np.sign(.5 - self.get_sweep_bias()))] + return (I_values, V_values)#[::int(np.sign(.5 - self.get_sweep_bias()))] # IVD.get_tracedata should always yield (I,V)! correct sorting to bias/sense already handled in transport script except Exception as e: logging.error('{!s}: Cannot take sweep data of channel {!s}'.format(__name__, self._sweep_channels)) raise type(e)('{!s}: Cannot take sweep data of channel {!s}\n{!s}'.format(__name__, self._sweep_channels, e)) diff --git a/src/qkit/measure/spectroscopy/spectroscopy.py b/src/qkit/measure/spectroscopy/spectroscopy.py index 9978a749a..0d54e32e3 100644 --- a/src/qkit/measure/spectroscopy/spectroscopy.py +++ b/src/qkit/measure/spectroscopy/spectroscopy.py @@ -620,6 +620,7 @@ def _measure(self): for ix, x in enumerate(self.x_vec): self.x_set_obj(x) sleep(self.tdx) + self._sweeptime_averages = self.vna.get_sweeptime_averages() if self._scan_dim == 3: fit_extracts_helper = {} # for book-keeping current y-line @@ -632,6 +633,7 @@ def _measure(self): else: self.y_set_obj(y) sleep(self.tdy) + self._sweeptime_averages = self.vna.get_sweeptime_averages() if self.averaging_start_ready: self.vna.start_measurement() @@ -702,6 +704,8 @@ def _measure(self): self._fit_imag.next_matrix() if self.storeRealImag else None if self._scan_dim == 2: + for lf in self.log_funcs: + lf.logIfDesired(ix) if self.averaging_start_ready: self.vna.start_measurement() @@ -710,14 +714,11 @@ def _measure(self): sleep(.2) # just to make sure, the ready command does not *still* show ready while not self.vna.ready(): - sleep(min(self.vna.get_sweeptime_averages(query=False) / 11., .2)) + sleep(min(self.vna.get_sweeptime_averages(query=False) / 11., 0.2)) else: self.vna.avg_clear() sleep(self._sweeptime_averages) - for lf in self.log_funcs: - lf.logIfDesired(ix) - """ measurement """ if not self.landscape.xzlandscape_func: # normal scan data_amp, data_pha = self.vna.get_tracedata() From 2610c90dd07139b60b51987f9c26b36373fc2ace Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 30 Sep 2025 14:04:12 +0200 Subject: [PATCH 39/43] unified measure bugfixes --- src/qkit/drivers/AbstractIVDevice.py | 3 ++- src/qkit/measure/transport_measurement.py | 22 +++++++++++-------- src/qkit/measure/unified_measurements.py | 26 +++++++++++++++++++---- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/qkit/drivers/AbstractIVDevice.py b/src/qkit/drivers/AbstractIVDevice.py index 56876db77..02133d990 100644 --- a/src/qkit/drivers/AbstractIVDevice.py +++ b/src/qkit/drivers/AbstractIVDevice.py @@ -7,9 +7,10 @@ class AbstractIVDevice(ABC): @abstractmethod - def take_IV(self, sweep: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + def take_IV(self, sweep: tuple[float, float, float, float]) -> tuple[np.ndarray, np.ndarray]: """ Perform an IV sweep. Returns (bias, sense). + Sweep is (start, stop, step, sleep) """ pass diff --git a/src/qkit/measure/transport_measurement.py b/src/qkit/measure/transport_measurement.py index ead7d56c0..008c09628 100644 --- a/src/qkit/measure/transport_measurement.py +++ b/src/qkit/measure/transport_measurement.py @@ -25,16 +25,20 @@ class MeasureModes(Enum): class TransportMeasurement(MeasurementTypeAdapter): _measurement_descriptors: list[tuple[MeasurementTypeAdapter.DataDescriptor, MeasurementTypeAdapter.DataDescriptor]] + _sweep_parameters: list[tuple[float, float, float]] _iv_device: AbstractIVDevice _mode: MeasureModes _sleep: float + _extend_range: bool - def __init__(self, iv_device: AbstractIVDevice, mode: MeasureModes = MeasureModes.IV, sleep: float = 0): + def __init__(self, iv_device: AbstractIVDevice, mode: MeasureModes = MeasureModes.IV, sleep: float = 0, extend_range=False): super().__init__() self._iv_device = iv_device self._mode = mode self._measurement_descriptors = [] + self._sweep_parameters = [] self._sleep = sleep + self._extend_range = extend_range if mode == MeasureModes.IV: assert iv_device.get_sweep_mode() == 1 elif mode == MeasureModes.VI: @@ -44,9 +48,10 @@ def add_sweep(self, start: float, stop: float, step: float): axis = Axis( name=f'{self._mode.value.bias_symbol}_{len(self._measurement_descriptors)}', unit=self._mode.value.bias_unit, - range=np.arange(start, stop, step) + range=np.arange(start, stop if not self._extend_range else (stop + step/2), step) ) - self._measurement_descriptors += ( + self._sweep_parameters += [(start, stop, step)] # TODO: Refactor by lazy DataDescriptor creation based on sweep parameters + self._measurement_descriptors += [( MeasurementTypeAdapter.DataDescriptor( name=f'{self._mode.value.bias_symbol}_b_{len(self._measurement_descriptors)}', unit=self._mode.value.bias_unit, @@ -57,7 +62,7 @@ def add_sweep(self, start: float, stop: float, step: float): unit=self._mode.value.measure_unit, axes=(axis,) ) - ) + )] def add_4_quadrant_sweep(self, start: float, stop: float, step: float, offset: float = 0): """ @@ -100,8 +105,8 @@ def add_half_swing_sweep(self, amplitude: float, step: float, offset: float = 0) offset: float Offset value by which and are shifted. Default is 0. """ - self.add_sweep(amplitude + offset, -amplitude + offset, step) - self.add_sweep(-amplitude + offset, amplitude + offset, -step) + self.add_sweep(amplitude + offset, -amplitude + offset, -step) + self.add_sweep(-amplitude + offset, amplitude + offset, step) @override @property @@ -125,9 +130,8 @@ def default_views(self) -> dict[str, DataView]: @override def perform_measurement(self) -> tuple['MeasurementTypeAdapter.GeneratedData', ...]: results = [] - for (bias, measurement) in self._measurement_descriptors: - intended_bias_values = bias.axes[0].range - bias_data, measurement_data = self._iv_device.take_IV(intended_bias_values) + for ((bias, measurement), sweep_params) in zip(self._measurement_descriptors, self._sweep_parameters): + bias_data, measurement_data = self._iv_device.take_IV((*sweep_params, self._sleep)) results.append(( bias.with_data(bias_data), measurement.with_data(measurement_data) diff --git a/src/qkit/measure/unified_measurements.py b/src/qkit/measure/unified_measurements.py index 7fc279680..c0f01a494 100644 --- a/src/qkit/measure/unified_measurements.py +++ b/src/qkit/measure/unified_measurements.py @@ -170,7 +170,7 @@ def _largest_measurement_dimension(self): """ if len(self._measurements) == 0: return 0 - return max(map(lambda m: len(m.expected_structure), self._measurements)) + return max(map(lambda m: m.dimension, self._measurements)) def create_datasets(self, data_file: hdf.Data, swept_axes: list[hdf_dataset]): """ @@ -248,6 +248,7 @@ def _run_sweep(self, data_file: hdf.Data, index_list: tuple[int, ...]): sweep, size = self._generate_enumeration(data_file) try: for index, value in tqdm(sweep, desc=self._axis.name, bar_format=bar_format(), total=size, leave=False): + measurement_log.debug(f"Sweeping {self._axis.name} index: {index} value: {value}") try: self._setter(value) self._current_value = value @@ -434,6 +435,13 @@ def create_dataset(self, file: hdf.Data, axes: list[hdf_dataset]) -> hdf_dataset @property def ds_url(self): return f"/entry/data0/{self.name}" + + @property + def dimension(self): + """ + The dimensionality of the expected Data. + """ + return len(self.axes) @dataclass(frozen=True) class GeneratedData: @@ -450,7 +458,7 @@ def __post_init__(self): assert isinstance(self.data, np.ndarray), "MeasurementData must be an ndarray!" assert len(self.descriptor.axes) == len(self.data.shape), f"Data shape (d={len(self.data.shape)}) incongruent with descriptor (d={len(self.descriptor.axes)})" for (i, axis) in enumerate(self.descriptor.axes): - assert self.data.shape[i] == len(axis.range), f"Axis ({axis.name}) length and data length mismatch" + assert self.data.shape[i] == len(axis.range), f"Axis {i} ({axis.name}) length ({len(axis.range)}) and data length ({self.data.shape[i]}) mismatch" def write_data(self, file: hdf.Data, sweep_indices: tuple[int, ...]): """ @@ -587,9 +595,9 @@ def create_datasets(self, data_file: hdf.Data, swept_axes: list[hdf_dataset]): views = self.default_views assert isinstance(views, dict), "Default views must be a dict of str to DataViews!" - for name, view in views: + for name, view in views.items(): assert isinstance(name, str), "Name of view must be a string!" - assert isinstance(view, DataView), "Each view must be of type DataView!" + assert isinstance(view, DataView), f"Each view must be of type DataView! But {name} is {type(view)}" measurement_log.debug(f"Creating view {name}.") view.write(data_file, name) @@ -618,6 +626,13 @@ def with_analysis(self, analysis: AnalysisTypeAdapter) -> 'MeasurementTypeAdapte assert isinstance(analysis, AnalysisTypeAdapter), "Analysis must be of type AnalysisTypeAdapter!" self._analyses.append(analysis) return self + + @property + def dimension(self): + """ + Determine the dimensionality of the measurement as the maximum dimensionality of any expected structure. + """ + return max(map(lambda es: es.dimension, self.expected_structure)) @property @abstractmethod @@ -791,6 +806,9 @@ def run(self, open_qviewkit: bool = True, open_datasets: Optional[list["DataRefe measurement_log.info("Starting measurement") self.run_measurements(data_file, ()) self._run_child_sweep(data_file, ()) + except Exception as e: + import traceback + traceback.print_exc() finally: # Calling into existing plotting code in the background. measurement_log.info("Creating plots...") From 7ef3cc3364c071411bbd6fef1ada2c7f292979d7 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 30 Sep 2025 18:59:39 +0200 Subject: [PATCH 40/43] Transport + Deriv Bugfixes --- src/qkit/analysis/numerical_derivative.py | 24 ++++++++++++----- src/qkit/drivers/AbstractVNA.py | 2 +- src/qkit/drivers/IVVI_BiasDAC.py | 4 +-- src/qkit/measure/transport_measurement.py | 33 ++++++++++++++++++----- src/qkit/measure/unified_measurements.py | 24 +++++------------ 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/src/qkit/analysis/numerical_derivative.py b/src/qkit/analysis/numerical_derivative.py index 2ad6d33d0..698910429 100644 --- a/src/qkit/analysis/numerical_derivative.py +++ b/src/qkit/analysis/numerical_derivative.py @@ -53,12 +53,14 @@ def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDe MeasurementTypeAdapter.DataDescriptor( name=f"d{x.name}_d{y.name}", unit=f"{x.unit}_{y.unit}", - axes=x.axes + axes=x.axes, + category="analysis" ), MeasurementTypeAdapter.DataDescriptor( name=f"d{y.name}_d{x.name}", unit=f"{y.unit}_{x.unit}", - axes=x.axes + axes=x.axes, + category="analysis" ) ] return tuple(structure) @@ -67,16 +69,26 @@ def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDe def default_views(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> dict[str, DataView]: schema = self.expected_structure(parent_schema) variable_names = (schema[0].name.split('_')[0], schema[1].name.split('_')[0]) - return { - f'd{variable_names[0]}_d{variable_names[1]}': DataView( + return { # dx/dy + f'{variable_names[0]}_{variable_names[1]}': DataView( + view_params={ + "labels": (schema[0].axes[0].name, f'{variable_names[0]}_{variable_names[1]}'), + 'plot_style': 1, + 'markersize': 5 + }, view_sets=[ DataViewSet( x_path=DataReference(entry.axes[0].name,), y_path=DataReference(entry.name, category='analysis') ) for entry in schema[0::2] ] - ), - f'd{variable_names[1]}_d{variable_names[0]}': DataView( + ), # dy/dx + f'{variable_names[1]}_{variable_names[0]}': DataView( + view_params={ + "labels": (schema[1].axes[0].name, f'{variable_names[1]}_{variable_names[0]}'), + 'plot_style': 1, + 'markersize': 5 + }, view_sets=[ DataViewSet( x_path=DataReference(entry.axes[0].name), diff --git a/src/qkit/drivers/AbstractVNA.py b/src/qkit/drivers/AbstractVNA.py index f12794118..ff2f34fa0 100644 --- a/src/qkit/drivers/AbstractVNA.py +++ b/src/qkit/drivers/AbstractVNA.py @@ -36,7 +36,7 @@ def get_span(self) -> float: return freqs[-1] - freqs[0] @abstractmethod - def get_tracedata(self, RealImag = None) -> tuple((np.ndarray, np.ndarray)): + def get_tracedata(self, RealImag = None) -> tuple[np.ndarray, np.ndarray]: """ When the measurement succeeded, this method returns the resulting data. The behaviour depends on the [RealImag] keyword argument. diff --git a/src/qkit/drivers/IVVI_BiasDAC.py b/src/qkit/drivers/IVVI_BiasDAC.py index 7b2c7879f..907ae50b2 100644 --- a/src/qkit/drivers/IVVI_BiasDAC.py +++ b/src/qkit/drivers/IVVI_BiasDAC.py @@ -108,8 +108,8 @@ def __init__(self): self.v_div = None # for voltage bias def get_sweep_mode(self): # 0: V bias V measure, 1: I bias V measure, 2: V bias I measure - # technically mode 2 also possible, this class cant determine 0 vs 2 without hidden knowledge about measure function though - return 1 - self.pseudo_bias_mode + # technically mode 0 also possible, this class cant determine 0 vs 2 without hidden knowledge about measure function though + return 1 + self.pseudo_bias_mode def get_sweep_bias(self): return self.pseudo_bias_mode def get_sweep_channels(self): diff --git a/src/qkit/measure/transport_measurement.py b/src/qkit/measure/transport_measurement.py index 008c09628..a3fbe98f9 100644 --- a/src/qkit/measure/transport_measurement.py +++ b/src/qkit/measure/transport_measurement.py @@ -19,6 +19,9 @@ class _MeasureMode: measure_unit: str class MeasureModes(Enum): + """ + TODO DocString BiasSense (?) + """ IV = _MeasureMode('i', 'A', 'v', 'V') VI = _MeasureMode('v', 'V', 'i', 'A') @@ -40,20 +43,20 @@ def __init__(self, iv_device: AbstractIVDevice, mode: MeasureModes = MeasureMode self._sleep = sleep self._extend_range = extend_range if mode == MeasureModes.IV: - assert iv_device.get_sweep_mode() == 1 + assert iv_device.get_sweep_bias() == 0 elif mode == MeasureModes.VI: - assert iv_device.get_sweep_mode() == 2 + assert iv_device.get_sweep_bias() == 1 def add_sweep(self, start: float, stop: float, step: float): axis = Axis( - name=f'{self._mode.value.bias_symbol}_{len(self._measurement_descriptors)}', + name=f'{self._mode.value.bias_symbol}_b_{len(self._measurement_descriptors)}', unit=self._mode.value.bias_unit, range=np.arange(start, stop if not self._extend_range else (stop + step/2), step) ) self._sweep_parameters += [(start, stop, step)] # TODO: Refactor by lazy DataDescriptor creation based on sweep parameters self._measurement_descriptors += [( MeasurementTypeAdapter.DataDescriptor( - name=f'{self._mode.value.bias_symbol}_b_{len(self._measurement_descriptors)}', + name=f'{self._mode.value.bias_symbol}_{len(self._measurement_descriptors)}', unit=self._mode.value.bias_unit, axes=(axis,) ), @@ -118,10 +121,28 @@ def expected_structure(self) -> tuple['MeasurementTypeAdapter.DataDescriptor', . def default_views(self) -> dict[str, DataView]: return { 'IV': DataView( + view_params={ + "labels": ('V', 'I'), + 'plot_style': 1, + 'markersize': 5 + }, view_sets=list(itertools.chain( DataViewSet( - x_path= DataReference(b.name), - y_path= DataReference(m.name), + x_path = DataReference(b.name if self._mode == MeasureModes.VI else m.name), + y_path = DataReference(m.name if self._mode == MeasureModes.VI else b.name), + ) for (b, m) in self._measurement_descriptors + )) + ), + 'VI': DataView( + view_params={ + "labels": ('I', 'V'), + 'plot_style': 1, + 'markersize': 5 + }, + view_sets=list(itertools.chain( + DataViewSet( + x_path = DataReference(b.name if self._mode == MeasureModes.IV else m.name), + y_path = DataReference(m.name if self._mode == MeasureModes.IV else b.name), ) for (b, m) in self._measurement_descriptors )) ), diff --git a/src/qkit/measure/unified_measurements.py b/src/qkit/measure/unified_measurements.py index c0f01a494..7ef8d4cb9 100644 --- a/src/qkit/measure/unified_measurements.py +++ b/src/qkit/measure/unified_measurements.py @@ -367,13 +367,6 @@ class DataGenerator(ABC): Handles storing data into datasets, and provides the structures for describing the data. """ - @property - def dataset_category(self) -> Literal['data', 'analysis']: - """ - The category into which the returned data should be sorted. - """ - return 'data' - def store(self, data_file: hdf.Data, data: tuple['MeasurementTypeAdapter.GeneratedData', ...], sweep_indices: tuple[int, ...]): """ Store the generated [data] in the [data_file], while selecting based on the current [sweep_indices]. @@ -396,6 +389,7 @@ class DataDescriptor: name: str axes: tuple[Axis, ...] unit: str = 'a.u.' + category: Literal['data', 'analysis'] = "data" def __post_init__(self): assert isinstance(self.name, str), "Name must be a string!" @@ -403,6 +397,7 @@ def __post_init__(self): for axis in self.axes: assert isinstance(axis, Axis), "Axes must be a tuple of Axis objects!" assert isinstance(self.unit, str), "Unit must be a string!" + assert self.category == "data" or self.category == "analysis", "Category must bei either 'data' or 'analysis'" def with_data(self, data: Union[np.ndarray, float]) -> 'MeasurementTypeAdapter.GeneratedData': """ @@ -422,19 +417,19 @@ def create_dataset(self, file: hdf.Data, axes: list[hdf_dataset]) -> hdf_dataset # The API has different methods, depending on dimensionality, which it then unifies again to a generic case. # For political reasons, we have to live with this. if len(all_axes) == 0: - return file.add_coordinate(name=self.name, unit=self.unit) + return file.add_coordinate(name=self.name, unit=self.unit, folder=self.category) elif len(all_axes) == 1: - return file.add_value_vector(name=self.name,x = all_axes[0], unit=self.unit) + return file.add_value_vector(name=self.name,x = all_axes[0], unit=self.unit, folder=self.category) elif len(all_axes) == 2: - return file.add_value_matrix(name=self.name, x = all_axes[0], y = all_axes[1], unit=self.unit) + return file.add_value_matrix(name=self.name, x = all_axes[0], y = all_axes[1], unit=self.unit, folder=self.category) elif len(all_axes) == 3: - return file.add_value_box(name=self.name, x = all_axes[0], y = all_axes[1], z = all_axes[2], unit=self.unit) + return file.add_value_box(name=self.name, x = all_axes[0], y = all_axes[1], z = all_axes[2], unit=self.unit, folder=self.category) else: raise NotImplementedError("Qkit Store does not support more than 3 dimensions!") @property def ds_url(self): - return f"/entry/data0/{self.name}" + return f"/entry/{self.category}0/{self.name}" @property def dimension(self): @@ -538,11 +533,6 @@ def create_datasets(self, data_file: hdf.Data, parent_schema: tuple['Measurement measurement_log.debug(f"Creating View {name} for Analysis.") view.write(data_file, name) - @override - @property - def dataset_category(self) -> Literal['data', 'analysis']: - return 'analysis' - @abstractmethod def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> tuple[ 'MeasurementTypeAdapter.DataDescriptor', ...]: From 79982e09d9191e22dfb1552fba2e2fcf12c3d555 Mon Sep 17 00:00:00 2001 From: Marius Frohn Date: Thu, 16 Oct 2025 22:30:05 +0200 Subject: [PATCH 41/43] WIP --- src/qkit/analysis/crit_detection_iv.py | 43 +++++++++++++++++++ src/qkit/analysis/numerical_derivative.py | 30 ++++++------- .../{ => transport}/transport_measurement.py | 0 3 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 src/qkit/analysis/crit_detection_iv.py rename src/qkit/measure/{ => transport}/transport_measurement.py (100%) diff --git a/src/qkit/analysis/crit_detection_iv.py b/src/qkit/analysis/crit_detection_iv.py new file mode 100644 index 000000000..53ea507a1 --- /dev/null +++ b/src/qkit/analysis/crit_detection_iv.py @@ -0,0 +1,43 @@ +import itertools +from typing import override, Any, Literal +from enum import Enum +from scipy import signal + +from qkit.measure.unified_measurements import AnalysisTypeAdapter, MeasurementTypeAdapter, DataView, DataViewSet, DataReference +from qkit.analysis.numerical_derivative import SavgolNumericalDerivative + +class CritDetectionIV(AnalysisTypeAdapter): + """ + Analyzes critical points of IV/VI curves + + Analysis results are '(i/v)c_lower_j' and '(i/v)c_upper_j', with dimension lowered by 1 compared to i_j, v_j + Example: Getting Ic and Irt from a current bias 4 point measurement + -> set onWhat = "v", critVal = 5e-6 (whatever value properly cuts off random noise) + + """ + + _onWhat: Literal["i", "v", "di_dv", "dv_di"] + _critVal: float + _numderHelper: SavgolNumericalDerivative | None + + def __init__(self, onWhat: Literal["i", "v", "di_dv", "dv_di"], critVal: float, numderHelper: SavgolNumericalDerivative | None = None): + super().__init__() + self._onWhat = onWhat + self._critVal = critVal + self._numderHelper = numderHelper + if onWhat in ["di_dv", "dv_di"]: + assert isinstance(numderHelper, SavgolNumericalDerivative), "When using crit detection on derivative of data, SavgolNumericalDerivative needs to be passed aswell" + + @override + def perform_analysis(self, data: tuple['MeasurementTypeAdapter.GeneratedData', ...]) -> tuple['MeasurementTypeAdapter.GeneratedData', ...]: + data[0].descriptor.axes[:-1] + return () + + @override + def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> tuple['MeasurementTypeAdapter.DataDescriptor', ...]: + return () + + @override + def default_views(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> dict[str, DataView]: + return () + diff --git a/src/qkit/analysis/numerical_derivative.py b/src/qkit/analysis/numerical_derivative.py index 698910429..1df63ae1e 100644 --- a/src/qkit/analysis/numerical_derivative.py +++ b/src/qkit/analysis/numerical_derivative.py @@ -1,5 +1,5 @@ import itertools -from typing import override +from typing import override, Any from scipy import signal @@ -16,15 +16,15 @@ class SavgolNumericalDerivative(AnalysisTypeAdapter): Assumes that names are in the form of '[IV]_(?:b_)?_[0-9]' """ - _window_length: int - _polyorder: int - _derivative: int + savgol_kwargs: dict[str, Any] - def __init__(self, window_length: int = 15, polyorder: int = 3, derivative: int = 1): + def __init__(self, **savgol_kwargs): + """ + savgol_kwargs: + kwargs to pass to scipy.signal.savgol_filter alongside data, refer to scipy docs for more information + """ super().__init__() - self._window_length = window_length - self._polyorder = polyorder - self._derivative = derivative + self.savgol_kwargs = {"window_length": 15, "polyorder": 3, "deriv": 1} | savgol_kwargs @override def perform_analysis(self, data: tuple['MeasurementTypeAdapter.GeneratedData', ...]) -> tuple[ @@ -33,14 +33,8 @@ def perform_analysis(self, data: tuple['MeasurementTypeAdapter.GeneratedData', . output_schema = self.expected_structure(parent_schema) out = [] for ((dxdy, dydx), (x, y)) in zip(itertools.batched(output_schema, 2), itertools.batched(data, 2)): - out.append(dxdy.with_data( - signal.savgol_filter(x.data, window_length=self._window_length, polyorder=self._polyorder, deriv=self._derivative)\ - / signal.savgol_filter(y.data, window_length=self._window_length, polyorder=self._polyorder, deriv=self._derivative) - )) - out.append(dydx.with_data( - signal.savgol_filter(y.data, window_length=self._window_length, polyorder=self._polyorder, deriv=self._derivative)\ - / signal.savgol_filter(x.data, window_length=self._window_length, polyorder=self._polyorder, deriv=self._derivative) - )) + out.append(dxdy.with_data(signal.savgol_filter(x.data, **self.savgol_kwargs)/signal.savgol_filter(y.data, **self.savgol_kwargs))) + out.append(dydx.with_data(signal.savgol_filter(y.data, **self.savgol_kwargs)/signal.savgol_filter(x.data, **self.savgol_kwargs))) return tuple(out) @@ -52,13 +46,13 @@ def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDe structure += [ MeasurementTypeAdapter.DataDescriptor( name=f"d{x.name}_d{y.name}", - unit=f"{x.unit}_{y.unit}", + unit=f"{x.unit}/{y.unit}", axes=x.axes, category="analysis" ), MeasurementTypeAdapter.DataDescriptor( name=f"d{y.name}_d{x.name}", - unit=f"{y.unit}_{x.unit}", + unit=f"{y.unit}/{x.unit}", axes=x.axes, category="analysis" ) diff --git a/src/qkit/measure/transport_measurement.py b/src/qkit/measure/transport/transport_measurement.py similarity index 100% rename from src/qkit/measure/transport_measurement.py rename to src/qkit/measure/transport/transport_measurement.py From 44b5124e49d6d8cce5a9af8ca4c8eb741d544e51 Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Fri, 17 Oct 2025 15:24:17 +0200 Subject: [PATCH 42/43] WIPWIP --- src/qkit/analysis/crit_detection_iv.py | 24 +++++++++++++++++++ src/qkit/measure/transport/transport.py | 2 +- .../transport/transport_measurement.py | 7 ++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/qkit/analysis/crit_detection_iv.py b/src/qkit/analysis/crit_detection_iv.py index 53ea507a1..35aef3041 100644 --- a/src/qkit/analysis/crit_detection_iv.py +++ b/src/qkit/analysis/crit_detection_iv.py @@ -31,6 +31,7 @@ def __init__(self, onWhat: Literal["i", "v", "di_dv", "dv_di"], critVal: float, @override def perform_analysis(self, data: tuple['MeasurementTypeAdapter.GeneratedData', ...]) -> tuple['MeasurementTypeAdapter.GeneratedData', ...]: data[0].descriptor.axes[:-1] + data[0].descriptor.name return () @override @@ -40,4 +41,27 @@ def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDe @override def default_views(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> dict[str, DataView]: return () + + """ + @override + def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> tuple['MeasurementTypeAdapter.DataDescriptor', ...]: + structure = [] + for (x, y) in itertools.batched(parent_schema, 2): + assert x.axes == y.axes + structure += [ + MeasurementTypeAdapter.DataDescriptor( + name=f"d{x.name}_d{y.name}", + unit=f"{x.unit}/{y.unit}", + axes=x.axes, + category="analysis" + ), + MeasurementTypeAdapter.DataDescriptor( + name=f"d{y.name}_d{x.name}", + unit=f"{y.unit}/{x.unit}", + axes=x.axes, + category="analysis" + ) + ] + return tuple(structure) + """ diff --git a/src/qkit/measure/transport/transport.py b/src/qkit/measure/transport/transport.py index e47250d33..8ac7c59be 100644 --- a/src/qkit/measure/transport/transport.py +++ b/src/qkit/measure/transport/transport.py @@ -1318,7 +1318,7 @@ def _prepare_measurement_file(self): if self._dVdI: self._hdf_dVdI.append(self._data_file.add_value_vector('dVdI_{!s}'.format(i), x=self._hdf_bias[i], - unit='V/A', + unit='Ohm', save_timestamp=False, folder='analysis', comment=self._get_numder_comment(self._hdf_V[i].name)+ diff --git a/src/qkit/measure/transport/transport_measurement.py b/src/qkit/measure/transport/transport_measurement.py index a3fbe98f9..1caefca15 100644 --- a/src/qkit/measure/transport/transport_measurement.py +++ b/src/qkit/measure/transport/transport_measurement.py @@ -111,6 +111,13 @@ def add_half_swing_sweep(self, amplitude: float, step: float, offset: float = 0) self.add_sweep(amplitude + offset, -amplitude + offset, -step) self.add_sweep(-amplitude + offset, amplitude + offset, step) + def reset_sweeps(self): + """ + Clear currently defined sweeps + """ + self._sweep_parameters = [] + self._measurement_descriptors = [] + @override @property def expected_structure(self) -> tuple['MeasurementTypeAdapter.DataDescriptor', ...]: From f355c10804070dae09496ef275a8c6dc91595dfe Mon Sep 17 00:00:00 2001 From: Marius-Frohn Date: Tue, 21 Oct 2025 17:47:58 +0200 Subject: [PATCH 43/43] AAAA --- src/qkit/analysis/crit_detection_iv.py | 66 ++++++++++++++++++-------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/src/qkit/analysis/crit_detection_iv.py b/src/qkit/analysis/crit_detection_iv.py index 35aef3041..7d5ecf952 100644 --- a/src/qkit/analysis/crit_detection_iv.py +++ b/src/qkit/analysis/crit_detection_iv.py @@ -2,6 +2,7 @@ from typing import override, Any, Literal from enum import Enum from scipy import signal +import numpy as np from qkit.measure.unified_measurements import AnalysisTypeAdapter, MeasurementTypeAdapter, DataView, DataViewSet, DataReference from qkit.analysis.numerical_derivative import SavgolNumericalDerivative @@ -30,38 +31,61 @@ def __init__(self, onWhat: Literal["i", "v", "di_dv", "dv_di"], critVal: float, @override def perform_analysis(self, data: tuple['MeasurementTypeAdapter.GeneratedData', ...]) -> tuple['MeasurementTypeAdapter.GeneratedData', ...]: - data[0].descriptor.axes[:-1] - data[0].descriptor.name - return () + parent_schema = tuple([element.descriptor for element in data]) + output_schema = self.expected_structure(parent_schema) + out = [] + flipBiasMeas = (parent_schema[0].name == "i_0") ^ (self._onWhat in ["v", "dvdi"]) + if self._onWhat in ["di_dv", "dv_di"]: + data = self._numderHelper.perform_analysis(data) + for ((dxdy, dydx), (x, y)) in zip(itertools.batched(output_schema, 2), itertools.batched(data, 2)): + out.append(dxdy.with_data(signal.savgol_filter(x.data, **self.savgol_kwargs)/signal.savgol_filter(y.data, **self.savgol_kwargs))) + out.append(dydx.with_data(signal.savgol_filter(y.data, **self.savgol_kwargs)/signal.savgol_filter(x.data, **self.savgol_kwargs))) + return tuple(out) + + def _crit_find_thresh(x_vals: np.ndarray, y_vals: np.ndarray, thresh: float = 1e-6) -> tuple[np.ndarray]: + """ + helper function for main threshold detection on y-vals, x-vals needed for sanity checking + data should be flipped & mirrored to ideally look like + ^ x_vals (.), y_vals (x), crits (o) + | .x + | . x + | . x + | . + 0 +------oxxxxxxxxxxxxxxxo-----> #idx + | x . + | x . + | x. + | .x + v + """ + # thresh detect + upper_idxs = np.argmax(np.logical_and(y_vals > thresh, x_vals > 0), axis=-1) + upper_idxs = np.where(np.any(np.logical_and(y_vals > thresh, x_vals > 0), axis=-1), upper_idxs, x_vals.shape[-1] - 1) # default to max if no tresh found + lower_idxs = y_vals.shape[-1] - 1 - np.argmax(np.flip(np.logical_and(y_vals > thresh, x_vals < 0), axis=-1), axis=-1) # flip because argmax returns first occurence + lower_idxs = np.where(np.any(np.logical_and(y_vals > thresh, x_vals < 0), axis=-1), lower_idxs, 0) + return upper_idxs, lower_idxs - @override - def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> tuple['MeasurementTypeAdapter.DataDescriptor', ...]: - return () - @override - def default_views(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> dict[str, DataView]: - return () - - """ @override def expected_structure(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> tuple['MeasurementTypeAdapter.DataDescriptor', ...]: structure = [] - for (x, y) in itertools.batched(parent_schema, 2): - assert x.axes == y.axes + for i, bias in enumerate(parent_schema[::2]): structure += [ MeasurementTypeAdapter.DataDescriptor( - name=f"d{x.name}_d{y.name}", - unit=f"{x.unit}/{y.unit}", - axes=x.axes, + name=f"ic_upper_{i}" if self._onWhat in ["v", "dv_di"] else f"vc_upper_{i}", + unit=f"{bias.unit}", + axes=bias.axes[:-1], category="analysis" ), MeasurementTypeAdapter.DataDescriptor( - name=f"d{y.name}_d{x.name}", - unit=f"{y.unit}/{x.unit}", - axes=x.axes, + name=f"ic_lower_{i}" if self._onWhat in ["v", "dv_di"] else f"vc_lower_{i}", + unit=f"{bias.unit}", + axes=bias.axes[:-1], category="analysis" - ) + ), ] return tuple(structure) - """ + @override + def default_views(self, parent_schema: tuple['MeasurementTypeAdapter.DataDescriptor', ...]) -> dict[str, DataView]: + return ()