Transfer function¶
This notebook demonstrates how to implement a transfer function in the qruise-toolset
. To simulate this, we'll create a signal chain consisting of two local oscillators and then use a transfer function to filter out one of the frequencies.
The tutorial consists of the following:
- Motivation for using a transfer function
- Defining parameters
- Defining the transfer function coefficients
- Constructing the signal chain
- Visualising the signal
Note: The terms 'transfer function' and 'response function' are often used interchangeably. In this notebook, we have chosen to use 'transfer function'.
1. Motivation for using a transfer function¶
A transfer function is a powerful tool for modelling how a system transforms input signals into outputs, whilst accounting for physical limitations such as noise, distortion, or delays. It captures the relationship between the input and output signals, providing a way to understand and model the dynamics of a system.
In quantum computing, for example, transfer functions can model how control pulses interact with qubits, helping to mitigate distortions caused by the hardware. This ensures more accurate quantum gate operations, ultimately improving the reliability of quantum computations.
Note: Transfer functions are ideal for linear time-invariant systems but are generally unsuitable for non-linear systems except in some exceptional cases.
In the qruise-toolset
, transfer functions are represented as pseudo-devices and are implemented using the TransferFunction()
component class. Like the analogue to digital converter or rise time pseudo-devices, the transfer function pseudo-device becomes meaningful only when used alongside other, real devices in the control stack, such as an arbitrary waveform generator (AWG). By incorporating transfer functions into the control stack, the qruise-toolset
helps users account for system imperfections and improve the fidelity of quantum operations.
In this notebook, we'll show you step-by-step how to use the TransferFunction()
component to model a system's behaviour. We'll create two local oscillators with different frequencies, mix them, and then apply a high-pass Butterworth filter to simulate how the transfer function filters out one of the frequencies.
Tip: Make sure that your environment is correctly set to handle float64
precision by setting JAX_ENABLE_X64=True
or add
import jax
jax.config.update("jax_enable_x64", True)
to your script's preamble.
2. Defining parameters¶
We start by defining the time parameters for our pulse. These include the start and end times of the pulse ($t_0$ and $t_\text{final}$, respectively), and the grid size, which specifies the number of evenly spaced points used to discretise the time interval.
import jax.numpy as jnp
t0 = 0.0 # start time of pulse
tfinal = 1.0 # end (final) time of pulse
grid_size = int(1e3) # simulation grid size (number of time points)
t_span = jnp.linspace(t0, tfinal, grid_size) # time span array
Next, we define the frequencies of the local oscillators, which we'll set to 5 Hz and 30 Hz.
lo1_freq = 2 * jnp.pi * 5 # local oscillator at 5Hz
lo2_freq = 2 * jnp.pi * 30 # local oscillator at 30Hz
3. Defining the transfer function coefficients¶
For this example, we'll use a high-pass Butterworth filter as our transfer function, but you can use any transfer function you like. A Butterworth filter attenuates frequencies outside the desired range (passband) while providing a smooth and ripple-free response inside the passband. We’ll use a $10^{\rm{th}}$-order high-pass filter with a cut-off frequency at 15 Hz.
We can use scipy.signal.butter
to obtain the required coefficients that represent the numerator and denominator of the transfer function. These coefficients are essential for defining the filter mathematically, as they describe how the input signal is transformed into the output signal
from scipy import signal
num_poly, den_poly = signal.butter(
10, 2 * jnp.pi * 15, "hp", analog=True
) # 10th order, 15 Hz, high-pass
4. Constructing the signal chain¶
As mentioned above, the signal chain will consist of the two mixed local oscillators and the transfer function component. We start by storing the parameters of the three components in dictionaries (lo1_params
, lo2_params
, and transfer_func_params
).
# define parameters for the local oscillators
lo1_params = {"w": (lo1_freq, False)}
lo2_params = {"w": (lo2_freq, False)}
# define parameters for the transfer function
transfer_func_params = {
"b": (jnp.array(num_poly).squeeze(), True),
"a": (jnp.array(den_poly).squeeze(), True),
"t0": (t0, False),
"t1": (tfinal, False),
}
Note: The argument True
in the dictionaries indicates that the parameter is modifiable (e.g., for optimisation), while 'False' means the parameter is not modifiable. However, this is not relevant for this tutorial.
We define instances of the LocalOscillator()
corresponding component classes and mix them to form the modulated signal. Then we define an instance of the Transferfunc()
component class with the modulated signal used as the source.
from qruise.toolset import LocalOscillator, TransferFunc
# define instances of the local oscillators with corresponding parameters
lo1 = LocalOscillator("lo1", lo1_params)
lo2 = LocalOscillator("lo2", lo2_params)
# mix lo1 and lo2
def mixer(t, params):
return lo1(t, params) + lo2(t, params)
# define instance of transfer function with corresponding parameters and using mixer as the signal source
transfer = TransferFunc(
"tf", transfer_func_params, mixer, dsource=jax.grad(mixer, argnums=0), n_max=5000
)
We can now collect all the parameters in a parameter collection and evaluate the signal chain over the time span we defined earlier (t_span
).
from qruise.toolset import ParameterCollection
from jax import vmap
# collect parameters in collection
pc = ParameterCollection()
pc.add_dict(lo1.params | lo2.params | transfer.params)
params = pc.get_collection()
# evaluate mixer
mixer_result = mixer(t_span, params)
# evaluate full signal chain (mixer + transfer function)
full_result = vmap(transfer, (0, None))(t_span, params)
6. Visualising the signal¶
Let's now plot both signals to see the effect of the transfer function on the mixed local oscillator signal.
from qruise.toolset import PlotUtil
canvas = PlotUtil(
x_axis_label="t [a.u.]", y_axis_label="Amplitude [a.u.]", notebook=True
)
canvas.plot(t_span, mixer_result, labels=["Mixed signal"])
canvas.plot(t_span, full_result, labels=["Mixed signal + \ntransfer function"])
canvas.show_canvas()
You can see that without the transfer function we see a modulated signal consisting of the 5 Hz and 30 Hz frequencies. Once we add the transfer function (a high-pass filter) the 5 Hz signal is removed and we observe only the 30 Hz signal.
Note: Due to the finite response time of the transfer function, the signal initially takes time to reach its steady state, with the 5 Hz component gradually being filtered out at the start of the time window.
You now know how to use a transfer function to modify a signal chain! Although we used a high-pass Butterworth filter in this case, the same method applies for any transfer function you wish to use.