Source code for qucat.core

import sympy as sp
from sympy.utilities.lambdify import lambdify
import numpy as np
from numpy.polynomial.polynomial import Polynomial as npPoly
from sympy.core.mul import Mul, Pow, Add
from copy import deepcopy
from numbers import Number
from math import floor, factorial
import os
from subprocess import run
import inspect
import matplotlib.pyplot as plt
import time
from warnings import warn
try:
    from ._constants import *
    from ._utility import *
    from .plotting_settings import plotting_parameters_show,plotting_parameters_normal_modes
except ImportError:
    # When running from source without pip installation
    from _constants import *
    from _utility import *
    from plotting_settings import plotting_parameters_show,plotting_parameters_normal_modes

PROFILING = False

def timeit(method):
    '''
    Decorator which prints the time 
    a function took to execute.
    Only works the global variable PROFILING is set to True.
    '''
    def timed(*args, **kw):
        ts = time.time()
        result = method(*args, **kw)
        te = time.time()
        if PROFILING:
            print('calling %r took %2.2f ms' % \
                    (method.__name__, (te - ts) * 1000))
        return result
    return timed

def string_to_component(s, *arg, **kwarg):
    '''
    Allows the creation of a Component object using a string.

    Parameters
    ----------
    s : string
        One of 'W', 'R', 'L', 'J', 'C', 'G', dicatates the type 
        of component to create
    args, kwargs : 
        Arguments needed for the component creation

    Returns
    -------
    A component of type ``s``
    '''
    if s == 'W':
        return W(*arg, **kwarg)
    elif s == 'R':
        return R(*arg, **kwarg)
    elif s == 'L':
        return L(*arg, **kwarg)
    elif s == 'J':
        return J(*arg, **kwarg)
    elif s == 'C':
        return C(*arg, **kwarg)
    elif s == 'G':
        return G(*arg, **kwarg)

class Qcircuit(object):
    """A class representing a quantum circuit.

    Attributes:
        components (dict): Dictionary of components having a label, such that a component 
            with label 'L_1' can be obtained by ``Qcircuit.components['L_1']``
        Q_min (float): Modes with have a quality factor below Q_min will not ignored
        inductors (list): List of inductor objects present in the circuit
        resistors (list): List of inductor objects present in the circuit
        junctions (list): List of junction objects present in the circuit
        capacitors (list): List of capacitor objects present in the circuit
        netlist (list): List of all components present in the circuit
        ref_elt (J or L): list of junction or inductor component used as a reference for the calculation 
                        of zero-point fluctations, each index of the list corresponds to a different mode
    """

    def __init__(self, netlist):
        self.Q_min = 1 # Modes with have a quality factor below Q_min will not ignored

        self.warn_discarded_mode = True # If this is set to True, the user will be notified when a mode is discarded.
        
        # After an initial estimation of the complex eigenfrequenceis using a diaglinalization
        # of the companion matrix, the frequencies are polishd to a tolerence
        # self.root_relative_tolerance using a gradient based root finder, with a maximum number of iterations self.root_max_iterations
        self.root_max_iterations = 1e4 
        self.root_relative_tolerance = 1e-12

        self._plotting_normal_mode = False # Used to keep track of which imported plotting_settings to use 
                                            # only set to true when show_normal_mode is called
        self.plotting_parameters_normal_modes = plotting_parameters_normal_modes
        self.plotting_parameters_show = plotting_parameters_show

        self.netlist = netlist # List of all components present in the circuit

        self._network = _Network(netlist) # Converts the list of components into a network object
                # The Network object has methods to compute of the admittance between two nodes
                # or the tranfer function between two nodes and two others
        
        # We construct (enpty) lists of all the different type of 
        # components that could be present in the circuit
        self.inductors = []
        self.capacitors = []
        self.junctions = []
        self.resistors = []
        self._wire = []
        self._grounds = []

        # Initialize a dictionary of components having a label, such that a component 
        # with label 'L_1' can be obtained by ``Qcircuit.components['L_1']``
        self.components = {}

        # Initialize a list which will contain the labels of the componentns which have
        # no value (these will have to be specified in most methods as a kwarg)
        self._no_value_components = []

        # For each component of the circuit....
        for elt in netlist:
            # ...tell the component what circuit it belongs to
            elt._circuit = self
            # ...and populate the empty lists/dictionaries initialized above with the element if appropriate
            elt._set_component_lists()


        # Check that there is at least one inductive element in the circuit
        if len(self.junctions) == 0 and len(self.inductors) == 0:
            raise ValueError(
                "There should be at least one junction or inductor in the circuit")

        # Check that there is at least one capacitive element in the circuit
        if len(self.capacitors) == 0:
            raise ValueError(
                "There should be at least one capacitor in the circuit")

                
        # define the function which returns the inverse of dY
        # where Y is the admittance at the nodes of an inductive element
        for inductive_element in self.inductors+self.junctions:
            inductive_element._compute_flux_zpf_r()

        # Initialize the flux transformation dictionary,
        # where _flux_transformation_dict[ref_node_minus,ref_node_plus,node_minus,node_plus] 
        # will be populated with a function which gives
        # the voltage transfer function between the nodes surrounding
        # the reference element and (node_plus,node_minus)
        # this function takes as an argument an angular frequency
        # and keyword arguments if component values need to be specified
        self._flux_transformation_dict = {}

        # define the functions which returns the components of the characteristic polynomial
        # (the roots of which are the eigen-frequencies)
        self._char_poly_coeffs = [lambdify(self._no_value_components, c, 'numpy') 
            for c in self._network.compute_char_poly_coeffs(is_lossy = (len(self.resistors)>0))]

    @property
    def _pp(self):
        '''
        Returns the plotting parameters used 
            * in the Qcircuit.show method (if self._plotting_normal_mode is False)
            * in the Qcircuit.show_normal_modes() method (if self._plotting_normal_mode is True)
        '''
        if self._plotting_normal_mode:
            return self.plotting_parameters_normal_modes
        else:
            return self.plotting_parameters_show

    def _parse_kwargs(self, **kwargs):
        '''
        Raises a ValueError 
        * if one of the kwargs is not the label of a circuit element
        * if a component without a value has not had its value specified in the kwargs 

        Called in all functions accepting keyword arguments (for un-specified circuit components).
        
        Parameters
        ----------
        kwargs:     
                    Values for un-specified circuit components, 
                    ex: ``L=1e-9``.
        '''
        for key in kwargs:
            if key in self._no_value_components:
                kwargs[key]  = kwargs[key]
            else:
                if key in self.components:
                    raise ValueError(
                        f"A value for the component labelled '{key}' " 
                        "has already been specified when building the circuit. "
                        "To be able to change it, you should re-build the circuit "
                        "without specifying its value." )
                else:
                    raise ValueError(
                        '%s is not the label of a circuit element' % key)

        for label in self._no_value_components:
            try:
                kwargs[label]
            except Exception:
                raise ValueError(
                    'The value of %s should be specified with the keyword argument %s=... ' % (label, label))

    @timeit
    def _set_zeta(self, **kwargs):
        '''
        Sets the Qcircuit.zeta to the circuit eigenfrequencies
        (including the imaginary part due to losses).

        Parameters
        ----------
        kwargs:     
                    Values for un-specified circuit components, 
                    ex: ``L=1e-9``.
        '''

        # Check if the kwargs provided are correct
        self._parse_kwargs(**kwargs)

        try:
            if kwargs == self._kwargs_previous:
                # Avoid doing the same thing two
                # times in a row
                return
        except AttributeError:
            pass
        self._kwargs_previous = kwargs

        if len(self.resistors) == 0:

            # Compute the coefficients of the characteristic polynomial.
            # The roots of this polynomial will provide the complex eigenfrequencies
            char_poly = npPoly([np.real(coeff(**kwargs)) for coeff in self._char_poly_coeffs])
            
            # char_poly = remove_multiplicity(char_poly)
        
            # In this case, the variable of the characteristic polynomial is \omega^2
            # And we can safely take the real part of the solution as there are no
            # resistors in the circuit.
            w2 = np.real(char_poly.roots())
            w2 = polish_roots(char_poly,w2, self.root_max_iterations,np.sqrt(self.root_relative_tolerance))

            # Sometimes, when the circuits has vastly different
            # values for its circuit components or modes are too
            # decoupled, the symbolic 
            # calculations can yield an incorrect char_poly
            # We can easily discard some of these casese by throwing away
            # negative solutions
            for w2_single in w2:
                if np.real(w2_single) < 0 and self.warn_discarded_mode:
                    error_message = "Imaginary frequency mode f = 1j %f Hz mode found (and discarded).\n"%(np.sqrt(-w2_single)/2/np.pi)
                    warn(error_message)
            w2 = w2[np.nonzero(w2 >= 0.)]

            # Take the square root to get to the eigenfrequencies
            zeta = np.sqrt(w2)

            # Sort solutions with increasing frequency
            order = np.argsort(np.real(zeta))
            zeta = zeta[order]

        else:

            # Compute the coefficients of the characteristic polynomial.
            # The roots of this polynomial will provide the complex eigenfrequencies
            char_poly = npPoly([complex(coeff(**kwargs)) for coeff in self._char_poly_coeffs])
            # char_poly = remove_multiplicity(char_poly)

            zeta = char_poly.roots()
            zeta = polish_roots(char_poly,zeta, self.root_max_iterations,self.root_relative_tolerance)

            # Sort solutions with increasing frequency
            order = np.argsort(np.real(zeta))
            zeta = zeta[order]

            # Negative modes are discarded
            zeta = zeta[np.nonzero(np.real(zeta) >= 0.)]

            # For each solution, its complex conjugate
            # is also a solution, we want to discard the negative
            # imaginary part solutions which correspond to unphysical
            # negative dissipation modes
            zeta = zeta[np.nonzero(np.imag(zeta) >= 0.)]

        # zero frequency modes are discarded
        zeta = zeta[np.nonzero(np.logical_not(np.isclose(np.real(zeta),0*zeta, rtol = self.root_relative_tolerance)))]

        # Only consider modes with Q>self.Q_min (=1 by default)
        # The reason for this measure is that
        # 0-frequency solutions (with real parts close to 0)
        # tend to have frequencies which oscillate between positive and
        # negative values.
        # The negative values are discarded which changes the number of modes
        # and makes parameter sweeps difficult 
        for w in zeta:
            if np.real(w) < self.Q_min*2*np.imag(w) and self.warn_discarded_mode:
                error_message = "Discarding f = %f Hz mode "%(np.real(w/2/np.pi))
                error_message += "since it has a too low quality factor Q = %f < %f"%(np.real(w)/2/np.imag(w),self.Q_min)
                warn(error_message)
        zeta = zeta[np.nonzero(np.real(zeta) >= self.Q_min*2*np.imag(zeta))]

        # Choose reference elements for each mode which 
        # maximize the inverse of dY: we want the reference 
        # element to the element where zero-point fluctuations
        # in flux are most localized.
        inductive_elements = self.junctions+self.inductors
        ref_elt = []
        zeta_copy = deepcopy(zeta)
        zeta = []
        
        for w in zeta_copy:
            largest_dYm1 = 0
            ref_elt_index = None
            for ind_index,ind in enumerate(inductive_elements):
                try:
                    dYm1 = ind._flux_zpf_r(w,**kwargs)
                except Exception:
                    # Computation of dYm1 failed for some reason
                    dYm1 = -1
                    
                if dYm1>largest_dYm1:
                    ref_elt_index = ind_index
                    largest_dYm1 = dYm1

            if ref_elt_index is None and self.warn_discarded_mode:
                # Sometimes, when the circuits has vastly different
                # values for its circuit components or modes are too
                # decoupled, the symbolic 
                # calculations can yield an incorrect char_poly
                # We can easily discard some of these cases by throwing away
                # any solutions with a complex impedance (ImY'<0)
                error_message = "Discarding f = %f Hz mode.\n"%(np.real(w/2/np.pi))
                error_message += "since the calculation of zero-point-fluctuations was unsuccesful.\n"
                warn(error_message)
            else:
                zeta.append(w)
                ref_elt.append(inductive_elements[ref_elt_index])
        zeta = np.array(zeta)

        self.zeta = zeta
        self.ref_elt = ref_elt

    def _anharmonicities_per_junction(self, **kwargs):
        '''
        Returns the contribution of each junction to the anharmonicity of each mode.
        For more details, see the documentation of J.anharmonicity.

        Anharmonicities are given in units of Hz (not angular frequency).

        Parameters
        ----------
        kwargs:     
                    Values for un-specified circuit components, 
                    ex: ``L=1e-9``.

        Returns
        -------
        anh_per_jun: ndarray
            where ``anh_per_jun[j,m]`` corresponds to the contribution of junction ``j``
            to the anharmonicity of mode ``m``
        '''
        self._set_zeta(**kwargs)
        return [[j.anharmonicity(mode, **kwargs) for mode in range(len(self.zeta))] for j in self.junctions]
    
[docs] @vectorize_kwargs def eigenfrequencies(self, **kwargs): '''Returns the normal mode frequencies of the circuit. Frequencies are provided in units of Hertz, not in angular frequency. Parameters ---------- kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- numpy.array Normal mode frequencies of the circuit, ordered from lowest to highest frequency, given in Hertz. Notes ----- These eigen-frequencies :math:`f_m` correspond to the real parts of the complex frequencies which make the conductance matrix singular, or equivalently the real parts of the poles of the impedance calculated between the nodes of an inductor or josephson junction. The Hamiltonian of the circuit is :math:`\hat{H} = \sum_m hf_m\hat{a}_m^\dagger\hat{a}_m + \hat{U}`, where :math:`h` is Plancks constant, :math:`\hat{a}_m` is the annihilation operator of the m-th normal mode of the circuit and :math:`f_m` is the frequency of the m-th normal mode. The frequencies :math:`f_m` would be the resonance frequencies of the circuit if all junctions were replaced with linear inductors. In that case the non-linear part of the Hamiltonian :math:`\hat{U}`, originating in the junction non-linearity, would be 0. For more information on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf. ''' self._set_zeta(**kwargs) return np.real(self.zeta)/2./pi
[docs] @vectorize_kwargs def loss_rates(self, **kwargs): '''Returns the loss rates of the circuit normal modes. The array is ordered ordered with increasing normal mode frequencies such that the first element of the array corresponds to the loss rate of the lowest frequency mode. Losses are provided in units of Hertz, **not in angular frequency**. Parameters ---------- kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- numpy.array Normal mode losses of the circuit Notes ----- These loss rates :math:`\kappa_m` correspond to twice the imaginary parts of the complex frequencies which make the conductance matrix singular, or equivalently twice the imaginary parts of the poles of the impedance calculated between the nodes of an inductor or josephon junction. For further details on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf. The dynamics of the circuit can be studied in QuTiP by considering collapse operators for the m-th mode :math:`\sqrt{2\pi\kappa_m(n_{th,m}+1)}\hat{a}_m` and :math:`\sqrt{2\pi\kappa_m(n_{th,m})}\hat{a}_m^\dagger` where :math:`n_{th,m}` is the average thermal occupation of mode :math:`m` and :math:`\hat{a}_m` is the annihilation operator of the m-th normal mode of the circuit. Note that dissipation rates that are obtained from this function have to be converted to angular frequencies through the factor :math:`2\pi`. If you are also using a hamiltonian generated from qucat, then it too should be converted to angular frequencies by multiplying the entire Hamiltonian by :math:`2\pi` when performing time-dependant simulations. ''' self._set_zeta(**kwargs) return 2*np.imag(self.zeta)/2./pi
[docs] @vectorize_kwargs def anharmonicities(self, **kwargs): r'''Returns the anharmonicity of the circuit normal modes. The array is ordered ordered with increasing normal mode frequencies such that the first element of the array corresponds to the anharmonicity of the lowest frequency mode. Anharmonicities are provided in units of Hertz, not in angular frequency. Parameters ---------- kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- numpy.array Normal mode anharmonicities Notes ----- The Hamiltonian of a circuit in first order perturbation theory is given by :math:`\hat{H} = \sum_m\sum_{n\ne m} (\hbar\omega_m-A_m-\frac{\chi_{mn}}{2})\hat{a}_m^\dagger\hat{a}_m -\frac{A_m}{2}\hat{a}_m^\dagger\hat{a}_m^\dagger\hat{a}_m\hat{a}_m -\chi_{mn}\hat{a}_m^\dagger\hat{a}_m\hat{a}_n^\dagger\hat{a}_n`, valid for weak anharmonicity :math:`\chi_{mn},A_m\ll \omega_m`. Here * :math:`\omega_m` are the frequencies of the normal modes of the circuit where all junctions have been replaced with inductors characterized by their Josephson inductance * :math:`A_m` is the anharmonicity of mode :math:`m` , the difference in frequency of the first two transitions of the mode * :math:`\chi_{mn}` is the shift in mode :math:`m` that incurs if an excitation is created in mode :math:`n` This function returns the values of :math:`A_m`. For more information on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf. ''' Ks = self.kerr(**kwargs) return np.array([Ks[i, i] for i in range(Ks.shape[0])])
[docs] @vectorize_kwargs def kerr(self, **kwargs): r'''Returns the Kerr parameters for the circuit normal modes. The diagonal component ``K[m,m]`` of the returned matrix correspond to the anharmonicity (or self-Kerr) of mode ``m``. An off-diagonal component ``K[m,n]`` corresponds to the cross-Kerr coupling between modes ``m`` and ``n``. The modes are indexed with increasing normal mode frequencies, for example ``K[0,1]`` corresponds to the cross-Kerr interaction between the lowest frequency mode and next highest frequency mode. Kerr parameters are provided in units of Hertz, not in angular frequency. Parameters ---------- kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- numpy.array of dimension 2 Kerr parameters Notes ----- The Hamiltonian of a circuit in first order perturbation theory is given by :math:`\hat{H} = \sum_m\sum_{n\ne m} (\hbar\omega_m-A_m-\frac{\chi_{mn}}{2})\hat{a}_m^\dagger\hat{a}_m -\frac{A_m}{2}\hat{a}_m^\dagger\hat{a}_m^\dagger\hat{a}_m\hat{a}_m -\chi_{mn}\hat{a}_m^\dagger\hat{a}_m\hat{a}_n^\dagger\hat{a}_n`, valid for weak anharmonicity :math:`\chi_{mn},A_m\ll \omega_m`. Here * :math:`\omega_m` are the frequencies of the normal modes of the circuit where all junctions have been replaced with inductors characterized by their Josephson inductance * :math:`A_m` is the anharmonicity of mode :math:`m` , the difference in frequency of the first two transitions of the mode * :math:`\chi_{mn}` is the shift in mode :math:`m` that incurs if an excitation is created in mode :math:`n` This function returns the values of :math:`A_m` and :math:`\chi_{mn}` . For more information on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf. ''' # Compute anharmonicity per junction ``As`` # where ``As[j,m]`` corresponds to the contribution of junction ``j`` # to the anharmonicity of mode ``m`` As = self._anharmonicities_per_junction(**kwargs) # Number of modes in the circuit N_modes = len(self.zeta) # Number of junctions in the circuit N_junctions = len(self.junctions) # initialize the vector of Kerr coefficients Ks = np.zeros((N_modes, N_modes)) for i in range(N_modes): for j in range(N_modes): for k in range(N_junctions): if i == j: # Add contribution to self-Kerr Ks[i, i] += np.real(As[k][i]) else: # Add contribution to cross-Kerr # Note that taking the square root here is fine # since Ks[i, j]~phi_ki^2*phi_kj^2 is necessarily a positive real # since phi_ki,phi_kj are real numbers Ks[i, j] += 2. * np.sqrt(As[k][i]*As[k][j]) return Ks
[docs] def f_k_A_chi(self, pretty_print=False, **kwargs): r'''Returns the eigenfrequency, loss-rates, anharmonicity, and Kerr parameters of the circuit. Returns these quantities in the form ``[[f_0,f_1,..],[k_0,k_1,..],[A_0,A_1,..],[[A_0,chi_01,..],[chi_10,A_1,..]..]]`` Each quantity is returned as a numpy arrays, where each index corresponds to a normal mode, ordered with increasing normal mode frequency. All quantities are provided in units of Hertz, not in angular frequency. This method is equivalent to calling ``[_.eigenfrequencies(**kwargs),_.loss_rates(**kwargs), _.anharmonicities(**kwargs),_.kerr(**kwargs)]`` For more details, refer to the functions :meth:`qucat.Qcircuit.eigenfrequencies` :meth:`qucat.Qcircuit.loss_rates` :meth:`qucat.Qcircuit.anharmonicities` :meth:`qucat.Qcircuit.kerr` Parameters ---------- pretty_print: Boolean, optional If set to True, this method will print a summary of the system parameters as a table. kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- List of numpy arrays ``[[f_0,f_1,..],[k_0,k_1,..],[A_0,A_1,..],[[A_0,chi_01,..],[chi_10,A_1,..]..]]`` ''' # Quantity to be returned: # eigenfrequency, loss-rates, anharmonicity, and Kerr parameters of the circuit to_return = self.eigenfrequencies(**kwargs),\ self.loss_rates(**kwargs),\ self.anharmonicities(**kwargs),\ self.kerr(**kwargs) if pretty_print: # Number of modes in the circuit N_modes = len(to_return[0]) # Setup a template for the mode/frequency/dissipation/anharmonicity # table row in the form `` 7 spaces | 7 spaces | 7 spaces | 7 spaces |`` table_line = "" for i in range(4): table_line += " %12s |" table_line += "\n" # Top row for content of columns to_print = table_line % ( "mode", " freq. ", " diss. ", " anha. ") # add all the other rows (each row is a mode) for i, w in enumerate(to_return[0]): to_print += table_line % tuple([str(i)]+[pretty_value( to_return[j][i], use_unicode=False)+'Hz' for j in range(3)]) to_print += "\nKerr coefficients (diagonal = Kerr, off-diagonal = cross-Kerr)\n" # Setup template for the rows of the Kerr coefficients table row # in the form `` 7 spaces | 7 spaces | ...`` table_line = "" for i in range(N_modes+1): table_line += " %12s |" table_line += "\n" # Top row indexing each column as a mode to_print += table_line % tuple(['mode'] + [str(i)+' ' for i in range(N_modes)]) # Add other rows for i in range(N_modes): line_elements = [str(i)] for j in range(N_modes): if i >= j: line_elements.append(pretty_value( to_return[3][i][j], use_unicode=False)+'Hz') else: line_elements.append("") to_print += table_line % tuple(line_elements) # Print the two tables print(to_print) return to_return
[docs] @refuse_vectorize_kwargs(exclude = ['modes','taylor','excitations','return_ops']) def hamiltonian(self, modes='all', taylor=4, excitations=6, return_ops = False, **kwargs): r'''Returns the circuits Hamiltonian for further analysis with QuTiP. The Hamiltonian is provided in units of frequency (not angular frequency), such that :math:`h=1`. Parameters ---------- modes: array of integers, optional List of modes to consider, where the modes are ordered with increasing frequency, such that ``modes = [0,1]`` would lead to considering only the two lowest frequency modes of the circuit. By default all modes are considered. taylor: integer, optional Order to which the potential of all josephson junctions should be taylor-expanded. Default is `4`. excitations:integer or array of integers, optional Number of energy levels considered for each junction. If one number is given, all modes have the same number of levels, if an array is given, its length should match the number of modes considered. For example if ``modes = [0,1]`` and ``excitations = [5,10]``, then we will consider 5 excitation levels for mode 0 and 10 for mode 1. return_ops: Boolean, optional If set to True, a list of the annihilation operators will be returned along with the hamiltonian in the form ``<Hamiltonian>, <list of operators>``. The form of the return is then ``H,[a_0,a_1,..]`` where ``a_i`` is the annihilation operator of the i-th considered mode, a QuTiP Qobj kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- qutip.qobj Hamiltonian of the circuit Notes ----- The Hamiltonian of the circuit, with the non-linearity of the Josephson junctions Taylor-expanded, is given in the limit of low dissipation by :math:`\hat{H} = \sum_{m\in\text{modes}} \hbar \omega_m\hat{a}_m^\dagger\hat{a}_m + \sum_j\sum_{2n\le\text{taylor}}E_j\frac{(-1)^{n+1}}{(2n)!}\left[\frac{\phi_{zpf,m,j}}{\phi_0}(\hat{a}_m^\dagger+\hat{a}_m)\right]^{2n}`, where :math:`\hat{a}_m` is the annihilation operator of the m-th normal mode of the circuit, :math:`\omega_m` is the frequency of the m-th normal mode, :math:`E_j` is the Josephson energy of the j-th junction and :math:`\phi_0 = \hbar/2e` and :math:`\phi_{zpf,m,j}` is the zero point fluctuation of mode :math:`m` through junction :math:`j`. In the expression above, ``modes`` and ``taylor`` are arguments of the ``hamiltonian`` function. For more details on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf ''' from qutip import destroy, qeye, tensor self.hamiltonian_modes = modes self.hamiltonian_taylor = taylor self.hamiltonian_excitations = excitations fs = self.eigenfrequencies(**kwargs) if modes == 'all': modes = range(len(fs)) for m in modes: try: fs[m] except IndexError: error_message ="There are only %d modes in the circuit, and you specified mode index %d "%(len(fs),m) error_message +="corresponding to the %d-th mode."%(m+1) # error_message +="\nNote that the numer of modes may change as one sweeps a parameter" # error_message +=" for example if a 0 frequency, spurious mode becomes negative due to " # error_message +="numerical imprecision. Adding a resistance to the circuit may help with this." raise ValueError(error_message) if not isinstance(excitations,list): excitations = [int(excitations) for i in modes] else: if len(excitations)!=len(modes): raise ValueError("excitations and modes should have the same length") H = 0 operators = [] phi = [0 for junction in self.junctions] qeye_list = [qeye(n) for n in excitations] for index,mode in enumerate(modes): a_to_tensor = deepcopy(qeye_list) a_to_tensor[index] = destroy(excitations[index]) a = tensor(a_to_tensor) operators.append(a) H += fs[mode]*a.dag()*a for j, junction in enumerate(self.junctions): # Note that zpf returns the flux in units of phi_0 = hbar/2./e phi[j] += np.real(junction.zpf(quantity='flux',mode=mode, **kwargs))*(a+a.dag()) # a = x+iy => -i*(a-a^) = -i(iy+iy) = --1 phi[j] += -1j*np.imag(junction.zpf(quantity='flux',mode=mode, **kwargs))*(a-a.dag()) for j, junction in enumerate(self.junctions): n = 2 EJ = (hbar/2./e)**2/(junction._get_value(**kwargs)*h) while 2*n <= taylor: H += (-1)**(n+1)*EJ/factorial(2*n)*phi[j]**(2*n) n += 1 if return_ops: return H, operators return H
[docs] @refuse_vectorize_kwargs(exclude = ['plot','return_fig_ax']) def show(self, plot=True, return_fig_ax=False, **kwargs): '''Plots the circuit. Only works if the circuit was created using the GUI. Parameters ---------- plot: Boolean, optional If set to True (default), the function will call plt.show() to display the circuit return_fig_ax: Boolean, optional If set to True (default is False), the function will return figure and axis for further processing using matplotlib. ''' pp = self._pp if isinstance(self,Network): #TODO recognize if the network is of series/parallel type # in which case the circuit can be constructed anyway raise TypeError(''' Plotting functions not available if the circuit was not constructed using the GUI. ''') xs = [] ys = [] line_type = [] for elt in self.netlist: x, y, lt = elt._draw() xs += x ys += y line_type += lt x_min = min([np.amin(x) for x in xs]) x_max = max([np.amax(x) for x in xs]) y_min = min([np.amin(x) for x in ys]) y_max = max([np.amax(x) for x in ys]) x_margin = pp['x_fig_margin'] # ensures that any text labels are not cutoff y_margin = pp['y_fig_margin'] fig = plt.figure(figsize=( ((x_max-x_min)+2.*x_margin)*pp["figsize_scaling"], ((y_max-y_min)+2.*y_margin)*pp["figsize_scaling"])) ax = fig.add_subplot(111) plt.subplots_adjust(left=0., right=1., top=1., bottom=0.) for i, _ in enumerate(xs): if line_type[i] == "node": ax.scatter(xs[i], ys[i], color=pp["color"], s=pp['node']['diameter']) else: ax.plot(xs[i], ys[i], color=pp["color"], lw=pp[line_type[i]]['lw']) for elt in self.netlist: elt._draw_label(ax) ax.set_axis_off() ax.set_xlim(x_min-x_margin, x_max+x_margin) ax.set_ylim(y_min-y_margin, y_max+y_margin) plt.margins(x=0., y=0.) if return_fig_ax: return fig, ax if plot: plt.show() plt.close()
[docs] @refuse_vectorize_kwargs(exclude = ['quantity']) def show_normal_mode(self, mode, quantity='current', plot=True, return_fig_ax=False, add_title = True, add_legend = True, **kwargs): r'''Plots a visual representation of a normal mode. Only works if the circuit was created using the GUI. Plots a schematic of the circuit overlayed with arrows representing the complex amplitude of a certain quantity :math:`X` which can be flux, current, charge or voltage. More specifically, the complex amplitude of :math:`X` if a single-photon amplitude coherent state were populating a given mode ``mode``. Current is shown in units of Ampere, voltage in Volts, charge in electron charge, and flux in units of the reduced flux quantum (defined as :math:`\hbar/2e`) The direction of the arrows show what we are defining as positive current for that component. Parameters ---------- mode: integer Determine what mode to plot, where 0 designates the lowest frequency mode, and the others are arranged in order of increasing frequency quantity: string One of 'current' (default), 'flux','charge','voltage' Determines what quantity the arrows should represent. plot: Boolean, optional If set to True (default), the function will call plt.show() to display the circuit return_fig_ax: Boolean, optional If set to True (default is False), the function will return figure and axis for further processing using matplotlib. add_title: Boolean, optional If set to True (default), the function will add a title detailing the modes frequency, anharmonicity and dissipation rate add_legend: Boolean, optional If set to True (default), the function will add a legend detailing the definition of arrow size and arrow direction Notes ----- This annotated quantity, called a phasor, is calculated by multiplying the voltage transfer function :math:`T_{rc}` (between a reference component :math:`r` and the annotated component :math:`c` ), with :math:`X_{zpf,m,r}`, the zero-point fluctuations of :math:`\hat{X}` at the reference component. Note that resistors make the transfer function :math:`T_{rc}`, and hence the phasors complex. Since this is plotted for a single-photon amplitude coherent state, the absolute value of the annotation is equal to the contribution of a mode to the zero-point fluctuations accross this component. For more detail on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf. ''' # This changes the default plotting settings # to those defined in plotting_settings.py # under plotting_parameters_normal_modes self._plotting_normal_mode = True # This will set pp to plotting_parameters_normal_modes # (see the definition of the Qcircuit._pp propoerty function) pp = self._pp # Make sure this has been called on a # Qcircuit defined with the GUI if isinstance(self,Network): raise TypeError(''' Plotting functions not available if the circuit was not constructed using the GUI. ''') def pretty(v, quantity): # Utility function to print a pretty # value for the phasor if quantity == 'flux': return pretty_value(v, is_complex = True)+u"\u03A6_0" elif quantity == 'charge': return pretty_value(v, is_complex = True, use_power_10=True)+'e' elif quantity == 'voltage': return pretty_value(v, is_complex = True)+'V' elif quantity == 'current': return pretty_value(v, is_complex = True)+'A' # Plot the circuit and return the # figure and axis for further # editing below fig, ax = self.show( plot=False, return_fig_ax=True) # Determine smallest and largest arrow size # Based on the absolute value of the # phasor through all components all_values = [] for el in self.netlist: if not isinstance(el,W): all_values.append(el.zpf(mode = mode, quantity = quantity, **kwargs)) all_values = np.absolute(all_values) max_value = np.amax(all_values) min_value = np.amin(all_values) def value_to_01_range(value): # Returns a number between 0 and 1 # where 0 corresponds to the smallest # phasor and 1 to the largest if pretty(np.absolute(max_value), quantity) == pretty(np.absolute(min_value), quantity): # Case where all the components have # the same phasor magnitude return 1. else: return (np.absolute(value)-min_value)/(max_value-min_value) def arrow_width(value=None,value_01 = None): # Converts value between 0 and 1 # to an arrow width where 0 will # get the ``min_width`` and # 1 will get the ``max_width``` if value_01 is None: value_01 = value_to_01_range(value) # part of the plotting parameters # which concern the arrow ppnm = pp['normal_mode_arrow'] return np.absolute(ppnm['min_width']+value_01*(ppnm['max_width']-ppnm['min_width'])) def arrow_kwargs(value=None,value_01 = None): # Constructs the keyword arguments to be passed # in the construction of an arrow based on the # zpf value if value_01 is None: value_01 = value_to_01_range(value) # part of the plotting parameters # which concern the arrow ppnm = pp['normal_mode_arrow'] # linewidth lw = ppnm['min_lw']+value_01*(ppnm['max_lw']-ppnm['min_lw']) # head size head = ppnm['min_head']+value_01 * \ (ppnm['max_head']-ppnm['min_head']) return {'lw': lw, 'head_width': head, 'head_length': head, 'clip_on': False} # For each element in the circuit, if it # isn't a ground, add an arrow and a label for el in self.netlist: if not isinstance(el,W): # phasor for the quantity and for the current value = el.zpf(mode = mode, quantity = quantity, **kwargs) # location of the element center x = el.x_plot_center y = el.y_plot_center if el.angle==EAST or el.angle==WEST: # Case of horizontal element # Text position x_text = x+pp["normal_mode_label"]["text_position_horizontal"][0] y_text = y+pp["normal_mode_label"]["text_position_horizontal"][1] # text alignment ha = 'center' va = 'top' # Arrow position in y y_arrow = y+pp["normal_mode_label"]["y_arrow"] dy_arrow = 0. # Arrow position in x if el.angle == EAST: # Define the direction for positive values # and the positive node on the east x_arrow = x-arrow_width(value)/2. dx_arrow = arrow_width(value) elif el.angle == WEST: # Define the direction for positive values # and the positive node on the west x_arrow = x+arrow_width(value)/2. dx_arrow = -arrow_width(value) if el.angle==NORTH or el.angle==SOUTH: # Case of vertical element # Text position x_text = x+pp["normal_mode_label"]["text_position_vertical"][0] y_text = y+pp["normal_mode_label"]["text_position_vertical"][1] # Text alignment ha = 'right' va = 'center' # Arrow x position x_arrow = x-pp["normal_mode_label"]["y_arrow"] dx_arrow = 0. # Arrow position in y if el.angle==NORTH: # Define the direction for positive values # and the positive node on the north y_arrow = y-arrow_width(value)/2. dy_arrow = arrow_width(value) elif el.angle==SOUTH: # Define the direction for positive values # and the positive node on the south y_arrow = y+arrow_width(value)/2. dy_arrow = -arrow_width(value) # Add the arrow if np.real(value)+np.imag(value)<0: # If the dominating part of the complex number is negative # Flip the arrow and the value arrow_coords = [x_arrow+dx_arrow, y_arrow+dy_arrow, -dx_arrow, -dy_arrow] value = -value else: arrow_coords = [x_arrow, y_arrow, dx_arrow, dy_arrow] ax.arrow(*arrow_coords, fc=pp['normal_mode_arrow']['color'], ec=pp['normal_mode_arrow']['color'], **arrow_kwargs(value)) # Add the annotation ax.text(x_text, y_text, pretty(value, quantity), fontsize=pp["normal_mode_label"]["fontsize"], ha=ha, va=va, weight='normal',color =pp["normal_mode_label"]["color"] ) # Add the title if add_title: w,k,A,chi = self.f_k_A_chi(**kwargs) ax.annotate(r'Mode %d, f=%sHz, k=%sHz, A=%sHz,'% (mode, pretty_value(w[mode]), pretty_value(k[mode]), pretty_value(A[mode])), xy=(0.05, 0.97), horizontalalignment='left', verticalalignment='top', xycoords='axes fraction', fontsize=12, weight='bold') ax.annotate('populated by single-photon amplitude coherent state', xy=(0.05, 0.97-0.045), horizontalalignment='left', verticalalignment='top', xycoords='axes fraction', fontsize=12) if add_legend: if quantity == 'current': value_text= "|I|" elif quantity == 'voltage': value_text= u"|V|" if quantity == 'flux': value_text= u"|\u03A6|" elif quantity == 'charge': value_text= "|Q|" value_text += u"exp(i\u03B8)" x_legend = ax.get_xlim()[0]+0.4 y_legend = ax.get_ylim()[0]+0.25 legend_text_kwargs = { 'ha':'center', 'va':'center', 'fontsize':12, 'weight':'normal' } ax.text(x_legend, y_legend, value_text, **legend_text_kwargs) v01 = 0.7 ax.arrow(x_legend-arrow_width(value_01 = v01)/2, y_legend-0.15, arrow_width(value_01 = v01), 0, fc=pp['normal_mode_arrow']['color'], ec=pp['normal_mode_arrow']['color'], **arrow_kwargs(value_01 =v01)) if plot == True: plt.show() plt.close() self._plotting_normal_mode = False if return_fig_ax: return fig, ax
[docs]class Network(Qcircuit): r'''Constructs a Qcircuit object from a list of components without resorting to a graphical user interface. The list can be composed of instances of the :class:`qucat.L`, :class:`qucat.C`, :class:`qucat.R` or :class:`qucat.J` classes for inductors, capacitors, resistors or junctions respectively. This Qcircuit construction method offers the advantage of programmatically constructing the circuit of the GUI class. On could, for example, construct an array of LC-resonators using a python ``for`` loop, which would be tedious using a graphical user interface. The disadvantage is that one cannot use the plotting tools :meth:`show` or :meth:`show_normal_modes` to visualize the circuit or its innerworkings. Parameters ---------- netlist: list of :class:`qucat.Component` See examples Returns ------- qucat.Qcircuit A Qcircuit object, see qucat.Qcircuit Examples -------- Here we construct a parallel combination of a capacitor, inductor and junction, grounded on one end and connected at the other end through a capacitor to a 50 Ohm resistor to ground. .. image:: Network_example_circuit.png Import the Network class, and the components we will need >>> from qucat import Network, R,L,C,J Note that the components (R,L,C,J) accept node indexes as their two first arguments, here we will use the node ``0`` to designate ground. The last arguments should be a label (``str``) or a value (``float``) or both, the order in which these arguments are provided are unimportant. >>> circuit = Network([ ... L(0,1,'L',1e-9), # Add the inductor between ground and node 1 ... C(0,1,100e-15,'C'), # Add the capacitor ... J(0,1,'L_J'), # Add the junction ... C(1,2,1e-15), # Add a capacitor which will connect to a resistor ... R(2,0,50) # Add a 50 Ohm resistance to ground ... ]) The junction was parametrized only by a string ``L_J`` , this is the best way to proceed if one wants to sweep the value of a component. Indeed, the most computationally expensive part of the analysis is performed upon initializing the Network, subsequently changing the value of a component and re-calculating a quantity such as the frequency or anharmonicity can be performed much faster. For example, we can compute the eigenfrequency, loss-rates, anharmonicity, and Kerr parameters of the circuit for a specific junction inductance. >>> circuit.f_k_A_chi(L_J = 1e-9) ''' def __init__(self, netlist): super(Network, self).__init__(netlist)
[docs]class GUI(Qcircuit): r'''Opens a graphical user interface to constructs a circuit. Parameters ---------- filename: string Path to a file which will store all the information about the graphically constructed circuit. edit: Boolean If True (default), the graphical user interface will be opened. One can set this argument to False to import the circuit without opening the graphical user interface plot: Boolean If True (default), the circuit will be plotted using matplotlib. print_network: Boolean If True (default), a text description of the constructed network will be printed. Returns ------- qucat.Qcircuit A Qcircuit object, see qucat.Qcircuit Notes ----- All the necessary information about the circuit generated by the graphical user interface application is stored in a human-readable format at the specified path. Each line of this text file is in the format: ``type`` ; ``x_minus`` , ``y_minus`` ; ``x_plus`` , ``y_plus`` ; ``value`` ; ``label`` and represents a circuit component, wire or ground element. ``type`` can take the values ``L``, ``C``, ``R``, ``J``, ``W`` or ``G`` for inductor, capacitor, resistor, junction, wire or ground respectively. ``value`` will be a float representing the value of the component or will be empty ``label`` will be a string corresponding to the label of the component or will be empty ``x/y_minus`` (``x/y_plus``) represents the horizontal/vertical location of the minus (plus) node of the component. Negative value are allowed and components have a length of 1 unit. For example, the circuit below, is described by the following text file :: L;3,-10;3,-11;1.000000e-09;L C;4,-10;4,-11;1.000000e-13;C J;5,-10;5,-11;;L_J C;5,-12;6,-12;1.000000e-15; R;7,-11;7,-12;5.000000e+01; G;7,-10;7,-11;; .. image:: Network_example_circuit.png ''' def __init__(self, filename, edit=True, plot=True, print_network=False,_unittesting=False): # Note: this will also give a valid path if filename was specified using # an absolute path filename = os.path.join(os.getcwd(),filename) try: with open(filename, 'r') as f: pass except FileNotFoundError: # if file does not exist force the gui to open edit = True # and create the folder os.makedirs(os.path.dirname(filename), exist_ok=True) # ... and file with open(filename, "w") as f: pass if edit: run([sys.executable, os.path.join(os.path.dirname(__file__),"_gui.py"), filename]) netlist = [] with open(filename, 'r') as f: for el in f: el = el.replace('\n', '') el = el.split(";") if el[3] == '': v = None else: v = float(el[3]) if el[4] == '': l = None else: l = el[4] netlist.append( string_to_component(el[0], el[1], el[2], v, l)) super(GUI, self).__init__(netlist) for el in self.netlist: el._set_plot_coordinates() if plot: self.show() if print_network: for el in [el for el in self.netlist if not isinstance(el,W)]: print("%s %d %d %s"%( el.__class__.__name__, min(el.node_minus,el.node_plus), max(el.node_minus,el.node_plus), el._to_string(use_unicode = False))) print('\n')
class _Network(object): """ The _Network class parses network arrays generated by the GUI or written manually by the user. It allows the computation of the R, L and C matrices, the admittance Y of the network between two nodes as well as the voltage transfer function of the network between two ports (4 nodes). Parameters ---------- netlist : list list of Component objects """ @timeit def __init__(self, netlist): self.netlist = netlist self.parse_netlist() if len(self.net_dict) == 0: raise ValueError("There are no components in the circuit") if not self.is_connected(): raise ValueError("There are two sub-circuits which are not connected") if self.has_shorts(): raise ValueError("Your circuit appears to be open or shorted making the analysis impossible") if self.has_opens(): raise ValueError("Your circuit appears to be open or shorted making the analysis impossible") @timeit def is_connected(self, nodes_encountered = None, start_node = None): ''' Determines if a nework is connected (graph theory term). Starting at "start_node", the algorithm will go from neighbouring node to neighbouring node, adding all encountered nodes to the encountered_nodes list. At then end we check if all the nodes of the network were encountered by checking the length of this list with respect to the total number of nodes ''' encountered_nodes = [] start_node = list(self.net_dict)[0] # Starting point of the algo def add_neighboors_to_encountered_nodes(node): if node not in encountered_nodes: encountered_nodes.append(node) for neighboor in self.net_dict[node]: add_neighboors_to_encountered_nodes(neighboor) add_neighboors_to_encountered_nodes(start_node) if len(encountered_nodes) != len(self.net_dict): return False else: return True def has_shorts(self): ''' Determines if there is an short circuit. For each node, we construct the network where that node has been removed. If the removal of that node leads to two distinct, non-connected circuits, then that node was a point at which the circuit was being shorted. ''' for node_to_remove in range(len(self.net_dict)): # create a copy of the network partial_network = deepcopy(self) # remove the node_to_remove from the network for other_node in partial_network.net_dict[node_to_remove]: del partial_network.net_dict[other_node][node_to_remove] del partial_network.net_dict[node_to_remove] # check if that network is connected if not partial_network.is_connected(): return True # No shorts were found return False def has_opens(self): ''' Determines if there is an open connection in the circuit. If there are only two nodes in the circuit, it cannot be open. This is because through another check we are imposing that the circuit has at least two types of components, one inductive, one capacitive. So a two node circuit will at minimum be an LC or JC circuit. If there are more than two nodes, and one of the nodes is connected to only one other, the circuit is open. ''' for _, connections in self.net_dict.items(): if len(connections) == 1 and len(self.net_dict)>2: return True return False def parse_netlist(self): def merge_chains(chains, i, j): '''Merges two chains (two arrays)''' to_add = chains[j] del chains[j] chains[i] = chains[i]+to_add return chains # ``chains``` is a list of node-lists (node-list = a chain) # each chain lists nodes which are connected one to another # either through wires or through ground chains = [] # We start by grouping all nodes which are connected by wires # into chains of nodes indexed by a integer which is to become # the new node names for future calculations for el in self.netlist: # Go through all the wires (note: grounds are instances of wires) if isinstance(el,W): # If this element is a ground, call it's negative node '__ground' np = el.node_plus if type(el) is G: nm = '__ground' else: nm = el.node_minus # added tells us if both nodes have already # been added to a same chain added = False # go through all chains to see if it # contains the nodes of this wire for i, ch in enumerate(chains): # If both node of the wire have already been # added to a same chain, don't do anyting if (nm in ch) and (np in ch): added = True # If minus node was added to chain 'ch'... elif (nm in ch): for j, ch2 in enumerate(chains): # ...and plus node to chain 'ch2' if np in ch2: # merge the two chains chains = merge_chains(chains, i, j) added = True if added == False: # otherwise add plus node to chain 'ch' ch.append(np) added = True # same check for the plus node elif (np in ch): for j, ch2 in enumerate(chains): if nm in ch2: chains = merge_chains(chains, i, j) added = True if added == False: ch.append(nm) added = True # if none of the nodes were present in chains, # create a new chain linking the two nodes of the wire if added == False: chains.append([nm, np]) def plot_node_to_new_node(plot_node): ''' Transforms the node ``plot_node``` to a new node. Parameters ---------- plot_node: typically a string or an integer, but could be any hashable object For GUI generated networks, this is a string 'x,y' that determines the position of the node when plotting it. Returns ------- i: integer, a unique number corresponding to all nodes connected via a wire or ground. ``i`` is one of [0,..,N-1] where N is the number of nodes in the circuit stripped of all wires. ''' i = 0 # if plot_node is already in a chain, # return the index of that chain in ``chains`` for ch in chains: if plot_node in ch: return i i+=1 # other wise append ``chains`` and # return the the last index of ``chains`` chains.append([plot_node]) return i # replace plotting nodes with new nodes # for all non-wire elements # and make a list of all nodes self.nodes = [] for el in self.netlist: el._node_minus_plot = el.node_minus el._node_plus_plot = el.node_plus if not isinstance(el,W): el.node_minus = plot_node_to_new_node(el.node_minus) el.node_plus = plot_node_to_new_node(el.node_plus) if el.node_minus == el.node_plus: raise ValueError("Your circuit appears to be shorted, making the analysis impossible") for n in [el.node_minus, el.node_plus]: if n not in self.nodes: self.nodes.append(n) # build ``net_dict``, a dictionary such that # ``net_dict[node_A][node_B]`` gives the non-wire circuit # component connecting ``node_A`` and ``node_B``. # If ``node_A`` and ``node_B`` are not connected, # calling ``net_dict[node_A][node_B]`` will raise a KeyError self.net_dict = {} for n in self.nodes: self.net_dict[n] = {} for el in self.netlist: if not isinstance(el,W): self.connect(el, el.node_minus, el.node_plus) @timeit def compute_char_poly_coeffs(self, is_lossy = True): @timeit def determinant(matrix): return matrix.berkowitz_det() self.is_lossy = is_lossy ntr = deepcopy(self) # ntr stands for Network To Reduce # compute conductance matrix ntr.compute_RLC_matrices() if self.is_lossy: w = sp.Symbol('w') char_poly = determinant((ntr.RLC_matrices['L']+1j*w*ntr.RLC_matrices['R']-w**2*ntr.RLC_matrices['C'])) char_poly = sp.collect(sp.expand(char_poly), w) self.char_poly_order = sp.polys.polytools.degree( char_poly, gen=w) # Order of the polynomial # Get polynomial coefficients, index 0 = lowest order term self.char_poly_coeffs_analytical =\ [char_poly.coeff(w, n) for n in range(self.char_poly_order+1)] else: w2 = sp.Symbol('w2') char_poly = determinant((-ntr.RLC_matrices['L']+w2*ntr.RLC_matrices['C'])) char_poly = sp.collect(sp.expand(char_poly), w2) self.char_poly_order = sp.polys.polytools.degree(char_poly, gen=w2) # Order of the polynomial # Get polynomial coefficients, index 0 = lowest order term self.char_poly_coeffs_analytical =\ [char_poly.coeff(w2, n) for n in range(self.char_poly_order+1)] # Divide by w if possible n=0 while self.char_poly_coeffs_analytical[n]==0: n+=1 self.char_poly_coeffs_analytical = self.char_poly_coeffs_analytical[n:] return self.char_poly_coeffs_analytical def compute_RLC_matrices(self): N_nodes = len(self.net_dict) self.RLC_matrices = { 'R':sp.zeros(N_nodes), 'L':sp.zeros(N_nodes), 'C':sp.zeros(N_nodes) } for i in range(N_nodes): for j, el in self.net_dict[i].items(): if j>i: RLC_matrix_components = el._get_RLC_matrix_components() for k in self.RLC_matrices: self.RLC_matrices[k][i,j] = -RLC_matrix_components[k] self.RLC_matrices[k][j,i] = -RLC_matrix_components[k] self.RLC_matrices[k][i,i] += RLC_matrix_components[k] self.RLC_matrices[k][j,j] += RLC_matrix_components[k] # count the number of coefficients in each row number_coefficients = np.zeros((N_nodes)) for k in self.RLC_matrices: for i in range(N_nodes): for j in range(N_nodes): if self.RLC_matrices[k][i,j] != 0: number_coefficients[i]+=1 # set a ground ground_node = np.argmax(number_coefficients) for k in self.RLC_matrices: self.RLC_matrices[k].row_del(ground_node) self.RLC_matrices[k].col_del(ground_node) def connect(self, element, node_minus, node_plus): ''' Modifies the ``net_dict`` variable such that ``node_minus`` and ``node_plus`` are marked as connected in future calculations. ``net_dict`` is a dictionary such that ``net_dict[node_A][node_B]`` gives the non-wire circuit component connecting ``node_A`` and ``node_B``. If ``node_A`` and ``node_B`` are not connected, calling ``net_dict[node_A][node_B]`` will raise a KeyError Parameters ---------- element: a ``Circuit`` object which is not a Wire or a Ground node_minus: integer negative node of the element node_plus: integer positive node of the element ''' # Connect node minus to node plus try: # If the nodes are already connected, add this element in parallel self.net_dict[node_minus][node_plus] = self.net_dict[node_minus][node_plus] | element except KeyError: # Case where the nodes are not connected self.net_dict[node_minus][node_plus] = element # Connect node plus to node minus try: self.net_dict[node_plus][node_minus] = self.net_dict[node_plus][node_minus] | element except KeyError: self.net_dict[node_plus][node_minus] = element def remove_node(self, node_to_remove): ''' Makes use of the star-mesh transform to remove the ``node_to_remove`` from the network. A node N=``node_to_remove`` connected to nodes A,B,C,.. through impedances Z_A,Z_B,... (the star) can be eliminated if we interconnect nodes A,B,C,.. with impedances Z_{AB},Z_{AC},Z_{BC},... given by Z_{XY} = Z_XZ_Y\sum_M1/Z_M. The resulting network is called the mesh. Parameters ---------- node: integer, node to be removed of the network stored in ``net_dict`` ''' # List of (connecting_nodes, connecting_components) connecting the # node_to_remove to (nearest neighbour) connecting_nodes connections = [x for x in self.net_dict[node_to_remove].items()] # Sum of admittances of connecting_components sum_Y = sum([elt._admittance() for _, elt in connections]) # Go through all pairs of connecting nodes # and calculate the admittance Y_XY that will connect them # in the mesh mesh_to_add = [] for i, (node_A, elt_A) in enumerate(connections): for node_B, elt_B in connections[i+1:]: Y = elt_A._admittance()*elt_B._admittance()/sum_Y mesh_to_add.append([Admittance(node_A, node_B, Y), node_A, node_B]) # Remove the node_to_remove from the net_dict, along with all # the connecting components for other_node in self.net_dict[node_to_remove]: del self.net_dict[other_node][node_to_remove] del self.net_dict[node_to_remove] # Add admittances Y_XY connecting nodes X,Y directly adjascent to # the removed node for mesh_branch in mesh_to_add: self.connect(*mesh_branch) @timeit def admittance(self, node_minus, node_plus): ''' Compute the admittance of the network between two nodes ``node_plus`` and ``node_minus`` by removing all other nodes through star-mesh transformations. Parameters ---------- node_minus: integer node_plus: integer ''' if node_minus == node_plus: raise ValueError('node_minus == node_plus') # Create a temporary copy of the network which will be reduced ntr = deepcopy(self) # ntr stands for Network To Reduce # # order nodes from the node with the least amount of connections # # to the one with the most nodes = [key for key in ntr.net_dict] nodes_order = np.argsort([len(ntr.net_dict[key]) for key in nodes]) nodes_sorted = [nodes[i] for i in nodes_order] # Remove all nodes except from node_minus, node_plus # through star-mesh transforms with the remove_node function for node in nodes_sorted: if node not in [node_minus, node_plus]: ntr.remove_node(node) # Compute the admittance between the two remaining nodes: # node_minus and node_plus Y = ntr.net_dict[node_minus][node_plus]._admittance() return Y def branch_admittance(self, node_1, node_2): ''' Returns the admittance in the branch connecting ``node_1`` and ``node_2``. If they are not directly connected through a single Component, this function returns 0 (i.e. the admittnce of an open circuit). This function is written to avoid calling try/except clauses repetitivly to verify if ``self.net_dict[node_1][node_2]`` is an existing key Parameters ---------- node_1: integer node_2: integer ''' if node_1 == node_2: raise ValueError('node_1 == node_2') try: return self.net_dict[node_1][node_2]._admittance() except KeyError: return 0. def transfer(self, node_left_minus, node_left_plus, node_right_minus, node_right_plus): ''' Returns the transfer function V_right/V_left relating the voltage on a port 'right' defined by ``node_right_minus`` and ``node_right_plus`` and a port 'left' defined by ``node_left_minus`` and ``node_left_plus`` We proceed by constructing an ABCD matrix and returning V_right/V_left = 1/A Parameters ---------- node_left_minus: integer node_left_plus: integer node_right_minus: integer node_right_plus: integer ''' if node_left_minus == node_left_plus: raise ValueError('node_left_minus == node_left_plus') elif node_right_minus == node_right_plus: raise ValueError('node_right_minus == node_right_plus') # Case where the left an right port are identical if (node_left_minus == node_right_minus)\ and (node_left_plus == node_right_plus): return 1. # Case where the left an right port are identical, but inverted elif (node_left_plus == node_right_minus)\ and (node_left_minus == node_right_plus): return -1. # If the ports are not identical, reduce the network such that only # the nodes provided as arguments remain # Create a temporary copy of the network which will be reduced ntr = deepcopy(self) # ntr stands for Network To Reduce # # order nodes from the node with the least amount of connections # # to the one with the most nodes = [key for key in ntr.net_dict] nodes_order = np.argsort([len(ntr.net_dict[key]) for key in nodes]) nodes_sorted = [nodes[i] for i in nodes_order] # Remove nodes using the star-mesh relation for node in nodes_sorted: if node not in [node_left_minus, node_left_plus, node_right_minus, node_right_plus]: ntr.remove_node(node) if (node_left_minus in [node_right_plus, node_right_minus]) or\ (node_left_plus in [node_right_plus, node_right_minus]): # Case where there are two of the nodes provided as arguments # are identical. # The circuit has then three distinct nodes connected by # three components. # For this case, the ABCD matrix can be constructed following # the before last case of Table 4.1 in Microwave Engineering (Pozar) # Indeed, all these cases are equivelant to the network below: # # p1 --------- Y_3 ---------- p2 # | | # Y_1 Y_2 # | | # gr ------------------------ gr # Note that the transfer function is independant of Y_1, this in essence # a voltage divider (see https://en.wikipedia.org/wiki/Voltage_divider) # where the voltage at p2 is entirely determined by Y_2,Y_3 and the voltage at p1 if node_left_minus == node_right_minus: p1 = node_left_plus p2 = node_right_plus gr = node_left_minus #= node_right_minus # Y_1 = ntr.branch_admittance(p1,gr) Y_2 = ntr.branch_admittance(p2,gr) Y_3 = ntr.branch_admittance(p1,p2) # A component of the ABCD matrix A = 1+Y_2/Y_3 # node Y_3 cannot be 0 in theory return 1/A elif node_left_plus == node_right_plus: # Ports 1 and 2 are the wrong way round, minuses cancel out p1 = node_left_minus p2 = node_right_minus gr = node_left_plus #= node_right_plus # Y_1 = ntr.branch_admittance(p1,gr) Y_2 = ntr.branch_admittance(p2,gr) Y_3 = ntr.branch_admittance(p1,p2) # A component of the ABCD matrix A = 1+Y_2/Y_3 # node Y_3 cannot be 0 in theory return 1/A elif node_left_minus == node_right_plus: # Port 2 is the wrong way round, transfer function gets a minus p1 = node_left_plus p2 = node_right_minus gr = node_left_minus #= node_right_plus # Y_1 = ntr.branch_admittance(p1,gr) Y_2 = ntr.branch_admittance(p2,gr) Y_3 = ntr.branch_admittance(p1,p2) # A component of the ABCD matrix A = 1+Y_2/Y_3 # node Y_3 cannot be 0 in theory return -1/A elif node_left_plus == node_right_minus: # Port 2 is the wrong way round, transfer function gets a minus p1 = node_left_minus p2 = node_right_plus gr = node_left_plus #= node_right_minus # Y_1 = ntr.branch_admittance(p1,gr) Y_2 = ntr.branch_admittance(p2,gr) Y_3 = ntr.branch_admittance(p1,p2) # A component of the ABCD matrix A = 1+Y_2/Y_3 # node Y_3 cannot be 0 in theory return -1/A else: # Most complex case (discussed in the paper) # First, compute the impence matrix of the lattice network following notations in # https://www.globalspec.com/reference/71734/203279/10-11-lattice-networks # excerpt of Network Analysis & Circuit (By M. Arshad) section 10.11: LATTICE NETWORKS Ya = ntr.branch_admittance(node_left_plus, node_right_plus) Yb = ntr.branch_admittance(node_left_minus, node_right_plus) Yc = ntr.branch_admittance(node_left_plus, node_right_minus) Yd = ntr.branch_admittance(node_left_minus, node_right_minus) # The book provides formulas with impedance: # sum_Z = Za + Zb + Zc + Zd # Z11 is V1/I1 (with V2=0) # Z11 = (Za+Zb)*(Zd+Zc)/sum_Z # Z21 is V1/I2 (with V2=0) # Z21 = (Zb*Zc-Za*Zd)/sum_Z # Z22 is V2/I2 (with V1=0) # Z22 = (Za+Zc)*(Zd+Zb)/sum_Z # From Pozar, we obtain the A and B components of the ABCD matrix of the lattice # The C and D components play no role in determining the transfer function # A_lattice = Z11/Z21 # B_lattice = Z11*Z22/Z21-Z21 # We will work with admittances, to deal in an easier # way with Yx = 0 (otherwise we would have to # distinguish many more cases) # Using Mathematica, we compute simplify the expressions for # A_lattice and B_lattice to: A_lattice = (Ya + Yb)*(Yd + Yc)/(Ya*Yd-Yb*Yc) B_lattice = (Ya + Yb + Yc + Yd)/(Ya*Yd-Yb*Yc) # The admittance accross the left port plays no role # The admittance accross the right port comes into play to yield the A component # of the total ABCD matrix: A = A_lattice + B_lattice*ntr.branch_admittance(node_right_minus,node_right_plus) return 1/A class Circuit(object): """docstring for Circuit""" def __init__(self, node_minus, node_plus): self.node_minus = node_minus self.node_plus = node_plus self._circuit = None def __or__(self, other_circuit): return Parallel(self, other_circuit) def _set_plot_coordinates(self): self.x_plot_node_minus = float(self._node_minus_plot.split(',')[0]) self.x_plot_node_plus = float(self._node_plus_plot.split(',')[0]) self.y_plot_node_minus = -float(self._node_minus_plot.split(',')[1]) self.y_plot_node_plus = -float(self._node_plus_plot.split(',')[1]) self.x_plot_center = (self.x_plot_node_minus + self.x_plot_node_plus)/2. self.y_plot_center = (self.y_plot_node_minus + self.y_plot_node_plus)/2. if self.x_plot_node_minus == self.x_plot_node_plus: # increasing y = SOUTH in tkinter if self.y_plot_node_minus < self.y_plot_node_plus: self.angle = SOUTH else: self.angle = NORTH else: if self.x_plot_node_minus < self.x_plot_node_plus: self.angle = WEST else: self.angle = EAST def _draw_label(self, ax): pp = self._circuit._pp if self.angle%180. == 0.: x = self.x_plot_center+pp['label']['text_position_horizontal'][0] y = self.y_plot_center+pp['label']['text_position_horizontal'][1] ha = 'center' va = 'top' else: x = self.x_plot_center+pp['label']['text_position_vertical'][0] y = self.y_plot_center+pp['label']['text_position_vertical'][1] ha = 'left' va = 'center' ax.text(x, y, to_string(self.unit, self.label, self.value), fontsize=pp['label']['fontsize'], ha=ha, va=va) class Parallel(Circuit): def __init__(self, left, right): super(Parallel, self).__init__(node_minus=None, node_plus=None) # sets the two children circuit elements self.left = left self.right = right def _admittance(self): return Add( self.left._admittance(), self.right._admittance()) def _get_RLC_matrix_components(self): RLC_matrix_components = { 'R':0, 'L':0, 'C':0 } for el in [self.left,self.right]: values_to_add = el._get_RLC_matrix_components() for k in RLC_matrix_components: RLC_matrix_components[k] += values_to_add[k] return RLC_matrix_components class Component(Circuit): def __init__(self, node_minus, node_plus, *args): super(Component, self).__init__(node_minus, node_plus) self.label = None self.value = None self.__flux = None if len(args)==0: raise ValueError("Specify either a value or a label") for a in args: if a is None: pass elif type(a) is str: self.label = a else: self.value = a # Check its not too big, too small, or negative # Note that values above max(min)_float would then # be interpreted as infinity (or zero) if self.value>max_float: raise ValueError("Maximum allowed value is %.2e"%max_float) elif self.value<0: raise ValueError("Value should be a positive float") elif 0<=self.value<min_float: raise ValueError("Minimum allowed value is %.2e"%min_float) def __hash__(self): if self.label is None: return hash(str(self.value)+self.unit) else: if self.value is None: return hash(self.label+self.unit) else: return hash(str(self.value)+self.label+self.unit) def _get_value(self, **kwargs): if self.value is not None: return self.value elif self.value is None and kwargs is not None: if self.label in [k for k in kwargs]: return kwargs[self.label] return sp.Symbol(self.label) def _set_component_lists(self): if self.label not in ['', ' ', 'None', None]: self._circuit.components[self.label] = self if self.value is None and self.label not in ['', ' ', 'None', None]: if self.label in self._circuit._no_value_components: # raise ValueError( # "Two components may not have the same name %s" % self.label) pass else: self._circuit._no_value_components.append(self.label) def _flux_zpf(self, mode, **kwargs): self._circuit._set_zeta(**kwargs) w = self._circuit.zeta[mode] try: tr = self._circuit._flux_transformation_dict[ self._circuit.ref_elt[mode].node_minus, self._circuit.ref_elt[mode].node_plus, self.node_minus, self.node_plus] except KeyError: tr_analytical = self._circuit._network.transfer( self._circuit.ref_elt[mode].node_minus, self._circuit.ref_elt[mode].node_plus, self.node_minus, self.node_plus) tr = lambdify(['w']+self._circuit._no_value_components,tr_analytical, "numpy") def tr_minus(w,**kwargs): return -tr(w,**kwargs) self._circuit._flux_transformation_dict[ self._circuit.ref_elt[mode].node_minus, self._circuit.ref_elt[mode].node_plus, self.node_minus,self.node_plus] = tr self._circuit._flux_transformation_dict[ self._circuit.ref_elt[mode].node_minus, self._circuit.ref_elt[mode].node_plus, self.node_plus,self.node_minus] = tr_minus # Following Black-box quantization, # we assume the losses to be neglegible by # removing the imaginary part of the eigenfrequency # w = np.real(w) # Calculation of phi_zpf of the reference junction/inductor # = sqrt(hbar/w/ImdY[w]) # The minus is there since 1/Im(Y) = -Im(1/Y) phi_zpf_r = self._circuit.ref_elt[mode]._flux_zpf_r(w,**kwargs) # Note that the flux defined here phi = tr(w,**kwargs)*phi_zpf_r # is complex. return phi def _to_string(self, use_unicode=True): return to_string(self.unit, self.label, self.value, use_unicode=use_unicode) @vectorize_kwargs(exclude = ['quantity','mode']) def zpf(self, mode, quantity, **kwargs): r'''Returns contribution of a mode to the zero-point fluctuations of a quantity for this component. The quantity can be current (in units of Ampere), voltage (in Volts), charge (in electron charge), or flux (in units of the reduced flux quantum, :math:`\hbar/2e`). Parameters ---------- mode: integer Determine what mode to consider, where 0 designates the lowest frequency mode, and the others are arranged in order of increasing frequency quantity: string One of 'current', 'flux', 'charge', 'voltage' kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. Returns ------- float contribution of the ``mode`` to the zero-point fluctuations of the ``quantity`` Notes ----- This quantity is calculated by multiplying the voltage transfer function :math:`T_{rc}` (between a reference component :math:`r` and the annotated component :math:`c` ), with :math:`X_{zpf,m,r}`, the zero-point fluctuations of :math:`\hat{X}` at the reference component. Note that resistors make the transfer function :math:`T_{rc}`, and hence this quantity, complex. For more detail on the underlying theory, see https://arxiv.org/pdf/1908.10342.pdf. ''' if quantity == 'flux': phi_0 = hbar/2./e return self._flux_zpf(mode,**kwargs)/phi_0 if quantity == 'voltage': phi_zpf = self._flux_zpf(mode,**kwargs) # The above will set the eigenfrequencies w=np.real(self._circuit.zeta)[mode] return phi_zpf*1j*w if quantity == 'current': Vzpf = self.zpf(mode,'voltage',**kwargs) # The above will set the eigenfrequencies kwargs_with_w = deepcopy(kwargs) kwargs_with_w['w'] = np.real(self._circuit.zeta)[mode] Y = self._admittance() if isinstance(Y, Number): pass else: Y = Y.evalf(subs=kwargs_with_w) return complex(Vzpf*Y) if quantity == 'charge': Izpf = self.zpf(mode,'current', **kwargs) # The above will set the eigenfrequencies w = np.real(self._circuit.zeta)[mode] return Izpf/1j/w/e class W(Component): """docstring for Wire""" def __init__(self, node_minus, node_plus, *args): super(W, self).__init__(node_minus, node_plus, '') self.unit = None self.label = None self.value = None def _to_string(*args, **kwargs): return ' ' def _set_component_lists(self): super(W, self)._set_component_lists() self._circuit._wire.append(self) def _draw(self): pp = self._circuit._pp x = [np.array([self.x_plot_node_minus, self.x_plot_node_plus]), np.array([self.x_plot_node_minus]), np.array([self.x_plot_node_plus])] y = [np.array([self.y_plot_node_minus, self.y_plot_node_plus]), np.array([self.y_plot_node_minus]), np.array([self.y_plot_node_plus])] line_type = ['W'] line_type += ['node'] line_type += ['node'] return x, y, line_type class G(W): ''' From a network perspective, a ground element is a wire that connects node_plus to a node called '__ground' ''' def __init__(self, node_minus, node_plus, *args): super(G, self).__init__(node_minus, node_plus) def _set_component_lists(self): super(G, self)._set_component_lists() self._circuit._grounds.append(self) def _draw(self): pp = self._circuit._pp # Defined for EAST line_type = [] x = [ np.array([0.5, 0.3]), np.array([0.3, 0.3]), np.array([0.23, 0.23]), np.array([0.16, 0.16]), ] y = [ np.array([0., 0.]), np.array([-1., 1.])*5./30., np.array([-1., 1.])*3./30., np.array([-1., 1.])*1./30., ] line_type.append('W') line_type.append('W') line_type.append('W') line_type.append('W') if self.angle == WEST: return shift(x, self.x_plot_center), shift(y, self.y_plot_center), line_type elif self.angle == NORTH: return shift(y, self.x_plot_center), shift([-xx for xx in x], self.y_plot_center), line_type elif self.angle == EAST: return shift([-xx for xx in x], self.x_plot_center), shift(y, self.y_plot_center), line_type elif self.angle == SOUTH: return shift(y, self.x_plot_center), shift(x, self.y_plot_center), line_type
[docs]class L(Component): """A class representing an inductor Parameters ---------- node_minus: integer Index corresponding to one node of inductor node_minus: integer Index corresponding to the other node of the inductor args: <float> or <str> or <float>,<str> Other arguments should be a float corresponding to the inductance, a string corresponding to the name of that value (ex: `"L"`), or both. If only a label is provided, a value for should be passed as a keyword argument in subsequent function calls (ex: `L = 1e-9`) This is the best way to proceed if one wants to sweep the value of this inductor. Indeed, the most computationally expensive part of the analysis is performed upon initializing the circuit, subsequently changing the value of a component and re-calculating a quantity such as the frequency or anharmonicity can be performed much faster. """ def __init__(self, node_minus, node_plus, *args): super(L, self).__init__(node_minus, node_plus, *args) self.unit = 'H' def _admittance(self): return -sp.I*Mul(1/sp.Symbol('w'), 1/self._get_value()) def _set_component_lists(self): super(L, self)._set_component_lists() self._circuit.inductors.append(self) def _draw(self): pp = self._circuit._pp x = np.linspace(0.5, float( pp['L']['N_turns']) + 1., pp['L']['N_points']) y = -np.sin(2.*np.pi*x) x = np.cos(2.*np.pi*x)+2.*x line_type = [] line_type.append('L') # reset leftmost point to 0 x_min = x[0] x -= x_min # set width inductor width x_max = x[-1] x *= pp['L']['width']/x_max # set leftmost point to the length of # the side connection wires x += (1.-pp['L']['width'])/2. # add side wire connections x_min = x[0] x_max = x[-1] x_list = [x] x_list += [np.array([0., x_min])] x_list += [np.array([x_max, 1.])] line_type.append('W') line_type.append('W') # center in x x_list = shift(x_list, -1./2.) # set height of inductor y *= pp['L']['height']/2. # add side wire connections y_list = [y] y_list += [np.array([0., 0.])] y_list += [np.array([0., 0.])] if self.angle%180. == 0.: return shift(x_list, self.x_plot_center), shift(y_list, self.y_plot_center), line_type if self.angle%180. == 90.: return shift(y_list, self.x_plot_center), shift(x_list, self.y_plot_center), line_type def _get_RLC_matrix_components(self): return { 'R':0, 'L':1/self._get_value(), 'C':0 } @timeit def _compute_flux_zpf_r(self): ''' Generate the L._flux_zpf_r function which takes as an argument an angular frequency (and keyword arguments if component values need to be specified) and returns the derivative of the admittance evaluated at the nodes of the inductor, which is effective capacitance at that frequency. ''' # Compute a sympy expression for the admittance # at the nodes of the reference element Y = self._circuit._network.admittance(self.node_minus, self.node_plus) # Write the expression as a single fraction # with the numerator and denomenator as polynomials # (it combines but also "de-nests") Y_together = sp.together(Y) # Extract numerator and denominator u = sp.numer(Y_together) v = sp.denom(Y_together) # Symbol representing angular frequency w = sp.Symbol('w') # Calculate derivatives derivatives = [] for P in [u]:#[u,v]: # Write as polynomial in 'w' P = sp.collect(sp.expand(P), w) # Obtain the order of the polynomial P_order = sp.polys.polytools.degree(P, gen=w) # Compute list of coefficients P_coeffs_analytical = [P.coeff(w, n) for n in range(P_order+1)[::-1]] # Express the derivative of the polynomial dP = sum([(P_order-n)*a*w**(P_order-n-1) for n, a in enumerate(P_coeffs_analytical)]) derivatives.append(dP) du = derivatives[0] # dv = derivatives[1] # Convert the sympy expression for v/du to a function # Note the function arguments are the angular frequency # and component values that need to be specified dY = lambdify(['w']+self._circuit._no_value_components,du/v, "numpy") def _flux_zpf_r(z,**kwargs): return np.sqrt(hbar/np.real(z)/np.imag(dY(z,**kwargs))) self._flux_zpf_r = _flux_zpf_r
[docs]class J(L): """A class representing a junction Parameters ---------- node_minus: integer Index corresponding to one node of junction node_minus: integer Index corresponding to the other node of the junction args: <float> or <str> or <float>,<str> Other arguments should be a float which by default corresponds to the Josephson inductance of the junction, a string corresponding to the name of that value (ex: `"L_J"`), or both. If only a label is provided, a value for this junction should be passed as a keyword argument in subsequent function calls (ex: `L_J = 10e-9`). This is the best way to proceed if one wants to sweep the value of this junction. Indeed, the most computationally expensive part of the analysis is performed upon initializing the circuit, subsequently changing the value of a component and re-calculating a quantity such as the frequency or anharmonicity can be performed much faster. use_E: Boolean If set to True, the junction will be parametrized by its Josephson energy, given in units of Hertz, rather than its Josephson inductance use_I: Boolean If set to True, the junction will be parametrized by its critical current, given in units of Ampere, rather than its Josephson inductance """ def __init__(self, node_minus, node_plus, *args, use_E=False, use_I=False): super(J, self).__init__(node_minus, node_plus, *args) self.use_E = use_E self.use_I = use_I if self.use_E: self.unit = 'Hz' elif self.use_I: self.unit = 'A' else: self.unit = 'H' def _get_value(self, **kwargs): # Returns the Josephson inductance value = super(J, self)._get_value(**kwargs) if (self.use_E == False) and (self.use_I == False): return value elif (self.use_E == True) and (self.use_I == False): L = (hbar/2./e)**2/(value*h) # E is assumed to be provided in Hz return L elif (use_E == False) and (use_I == True): L = (hbar/2./e)/value return L else: raise ValueError("Cannot set both use_E and use_I to True") def _get_Ej(self, **kwargs): return (hbar/2./e)**2/(self._get_value(**kwargs)*h) def _set_component_lists(self): super(L, self)._set_component_lists() self._circuit.junctions.append(self)
[docs] @vectorize_kwargs(exclude = ['mode']) def anharmonicity(self, mode, **kwargs): r'''Returns the contribution of this junction to the anharmonicity of a given normal mode. Returned in units of Hertz, not angular frequency. Parameters ---------- kwargs: Values for un-specified circuit components, ex: ``L=1e-9``. mode: integer where 0 designates the lowest frequency mode, and the others are arranged in order of increasing frequency Returns ------- float contribution of this junction to the anharmonicity of a given normal mode Notes ----- The quantity returned is the anharmonicity of the mode ``m`` if this junction were the only junction present in the circuit (i.e. if all the others were replaced by linear inductors). The total anharmonicity of a mode (in first order perturbation theory) is obtained by summing these contribution over all modes. For more details, see https://arxiv.org/pdf/1908.10342.pdf ''' return self._get_Ej(**kwargs)/2*np.absolute(self.zpf(mode,quantity='flux',**kwargs))**4
def _draw(self): pp = self._circuit._pp line_type = [] x = [ np.array([0., 1.]), np.array([(1.-pp['J']['width'])/2., (1.+pp['J']['width'])/2.]), np.array([(1.-pp['J']['width'])/2., (1.+pp['J']['width'])/2.]) ] y = [ np.array([0., 0.]), np.array([-1., 1.])*pp['J']['width']/2., np.array([1., -1.])*pp['J']['width']/2. ] line_type.append('W') line_type.append('J') line_type.append('J') # center in x and y x = shift(x, -1./2.) if self.angle%180. == 0.: return shift(x, self.x_plot_center), shift(y, self.y_plot_center), line_type if self.angle%180. == 90.: return shift(y, self.x_plot_center), shift(x, self.y_plot_center), line_type
[docs]class R(Component): """A class representing a resistor Parameters ---------- node_minus: integer Index corresponding to one node of resistor node_minus: integer Index corresponding to the other node of the resistor args: <float> or <str> or <float>,<str> Other arguments should be a float corresponding to the resistance, a string corresponding to the name of that value (ex: `"R"`), or both. If only a label is provided, a value for should be passed as a keyword argument in subsequent function calls (ex: `R = 1e-9`) This is the best way to proceed if one wants to sweep the value of this resistor. Indeed, the most computationally expensive part of the analysis is performed upon initializing the circuit, subsequently changing the value of a component and re-calculating a quantity such as the dissipation rate can be performed much faster. """ def __init__(self, node_minus, node_plus, *args): super(R, self).__init__(node_minus, node_plus, *args) self.unit = u"\u03A9" def _admittance(self): return 1/self._get_value() def _set_component_lists(self): super(R, self)._set_component_lists() self._circuit.resistors.append(self) def _get_RLC_matrix_components(self): return { 'R':1/self._get_value(), 'L':0, 'C':0 } def _draw(self): pp = self._circuit._pp x = np.linspace(-0.25, 0.25 +float(pp['R']['N_ridges']), pp['R']['N_points']) height = 1. period = 1. a = height*2.*(-1.+2.*np.mod(np.floor(2.*x/period), 2.)) b = -height*2.*np.mod(np.floor(2.*x/period), 2.) y = (2.*x/period - np.floor(2.*x/period))*a+b+height line_type = [] line_type.append('R') # reset leftmost point to 0 x_min = x[0] x -= x_min # set width inductor width x_max = x[-1] x *= pp['R']['width']/x_max # set leftmost point to the length of # the side connection wires x += (1.-pp['R']['width'])/2. # add side wire connections x_min = x[0] x_max = x[-1] x_list = [x] x_list += [np.array([0., x_min])] x_list += [np.array([x_max, 1.])] line_type.append('W') line_type.append('W') # center in x x_list = shift(x_list, -1./2.) # set height of inductor y *= pp['R']['height']/2. # add side wire connections y_list = [y] y_list += [np.array([0., 0.])] y_list += [np.array([0., 0.])] if self.angle%180. == 0.: return shift(x_list, self.x_plot_center), shift(y_list, self.y_plot_center), line_type if self.angle%180. == 90.: return shift(y_list, self.x_plot_center), shift(x_list, self.y_plot_center), line_type
[docs]class C(Component): """A class representing a capacitor Parameters ---------- node_minus: integer Index corresponding to one node of capacitor node_minus: integer Index corresponding to the other node of the capacitor args: <float> or <str> or <float>,<str> Other arguments should be a float corresponding to the capacitance, a string corresponding to the name of that value (ex: `"C"`), or both. If only a label is provided, a value for should be passed as a keyword argument in subsequent function calls (ex: `C = 1e-9`) This is the best way to proceed if one wants to sweep the value of this capacitor. Indeed, the most computationally expensive part of the analysis is performed upon initializing the circuit, subsequently changing the value of a component and re-calculating a quantity such as the anharmonicity can be performed much faster. """ def __init__(self, node_minus, node_plus, *args): super(C, self).__init__(node_minus, node_plus, *args) self.unit = 'F' def _admittance(self): return sp.I*Mul(sp.Symbol('w'), self._get_value()) def _set_component_lists(self): super(C, self)._set_component_lists() self._circuit.capacitors.append(self) def _draw(self): pp = self._circuit._pp line_type = [] x = [ np.array([0., (1.-pp['C']['gap'])/2.]), np.array([(1.+pp['C']['gap']) / 2., 1.]), np.array([(1.-pp['C']['gap'])/2., (1.-pp['C']['gap'])/2.]), np.array([(1.+pp['C']['gap'])/2., (1.+pp['C']['gap'])/2.]), ] y = [ np.array([0., 0.]), np.array([0., 0.]), np.array([-pp['C']['height']/2., pp['C']['height']/2.]), np.array([-pp['C']['height']/2., pp['C']['height']/2.]), ] line_type.append('W') line_type.append('W') line_type.append('C') line_type.append('C') # center in x and y x = shift(x, -1./2.) if self.angle%180. == 0.: return shift(x, self.x_plot_center), shift(y, self.y_plot_center), line_type if self.angle%180. == 90.: return shift(y, self.x_plot_center), shift(x, self.y_plot_center), line_type def _get_RLC_matrix_components(self): return { 'R':0, 'L':0, 'C':self._get_value() }
class Admittance(Component): def __init__(self, node_minus, node_plus, Y): self.node_minus = node_minus self.node_plus = node_plus self.Y = Y def _admittance(self): return self.Y