Basic pulsed tutorial¶
pulsed
mode is used to schedule, output and measure pulses with different lengths, shapes,
frequencies and amplitudes. In this tutorial, we go step by step through basic examples of how to
use the pulsed mode. You can also find these examples in the
presto-demo repository on GitHub.
Tip
To be able to run the following examples while reading through this tutorial, you should connect one output port of Presto to one of its inputs, through the low-pass filter VLFX-2500+. Keep in mind that the analog performance of each channel of Presto is customizable. Make sure to pick a port number that can output low frequencies.
Output and measure one pulse¶
Using a template¶
On each output and input, Presto has a number of resources we can access through the Python API
presto
. With this first example, we output a single pulse in order to learn what resources
are available and how to program them. We first need to import a few useful classes and define the
parameters of the pulse.
Note
To install the Presto Python API, follow the instructions at Installing and updating.
import matplotlib.pyplot as plt
import numpy as np
from presto import pulsed
from presto.hardware import AdcMode, DacMode
from presto.utils import sin2, plot_sequence
OUTPUT_PORT = 1 # 1 to 16, can be a list
INPUT_PORT = 1 # 1 to 16, can be a list
Then we create the pls
object, an instance of the Pulsed
class:
with pulsed.Pulsed(
address="192.168.20.23",
ext_ref_clk=False,
adc_mode=AdcMode.Direct,
dac_mode=DacMode.Direct,
) as pls:
This will connect to the Presto unit at the specified IP address and set up the clocks using
internal reference (ext_ref_clk=False
). We also tell the API we want to output and measure pulses
without using the digital up- and down-conversion, by setting AdcMode
and
DacMode
to Direct
. In Direct
mode we can output and measure pulses from 0 to 1 GHz.
Next, we set a few Hardware
-related parameters, which usually don’t change during the
measurement:
pls.hardware.set_dac_current(OUTPUT_PORT, 40_500) # μA, 2250 to 40500
pls.hardware.set_inv_sinc(OUTPUT_PORT, 0)
pls.hardware.set_adc_attenuation(INPUT_PORT, 0.0) # dB, 0.0 to 27.0
We set the analog output range
for the OUTPUT_PORT
to its
maximum value, disable the use of the inverse-sinc FIR filter
and
disable the input attenuation
(0 dB). These are optional
parameters, but it’s good to know that they exist.
We configure the data acquisition by specifying
that we want to store
(sample) data from the INPUT_PORT
for 2 μs each time:
pls.setup_store(INPUT_PORT, duration=2e-6)
We then define our output pulse as an arbitrary waveform with duration 200 ns, frequency 42.5 MHz, amplitude 0.5 FS and an envelope with a sine-squared shape (also known as a Hann function):
amp = 0.5 # FS
duration = 200e-9 # s
freq = 42.5e6 # Hz
ns = int(round(duration * pls.get_fs("dac")))
t = np.linspace(0, duration, ns, endpoint=False)
data = amp * sin2(ns) * np.sin(2 * np.pi * freq * t)
pulse_1 = pls.setup_template(OUTPUT_PORT, group=0, template=data)
Here, ns
is the number of samples in an output pulse with the specified duration
. With this
number, we define a time array t
and the envelope shape by using the helper function
utils.sin2()
. We create the NumPy array data
with the raw data points of the waveform, and
use setup_template()
to create the output pulse pulse_1
on output port 1 and group
0.
Each output port has enough memory for 16 templates split into two groups: 8 templates in group 0 and 8 templates in group 1. Templates in the same group share a programmable carrier generator and amplitude scaler.
Tip
You can find the functional schematics of the pulsed output at Pulsed output.
We setup the scale look-up table
(LUT) on output port 1 and group
0 to contain only one value. We just set 1.0
, because the amplitude of the pulse is already
encoded in the data points of pulse_1
.
pls.setup_scale_lut(OUTPUT_PORT, group=0, scales=1.0)
Now that all the resources are set up, we proceed to scheduling the pulse sequence:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
In this case it is a very simple sequence. At time T=0
we output a pulse
, and at the same time we open a sampling window
.
We could now just run the experiment and look at the data, but let’s first use
utils.plot_sequence()
to visualize the sequence we programmed. This is especially useful
when sequences become complex and is in general a helpful troubleshooting method.
plot_sequence(pls, period=4e-6, repeat_count=1, num_averages=1)
In our case, we see that we programmed a pulse to be output at T=0
on channel 1 group 0. At the
same time, the sampling window starts.
Tip
If you don’t have access to a Presto unit right now, you can still run this part of the code. Just
pass dry_run=True
when initializing the pls
instance of the Pulsed
class.
It’s now time to run()
the pulse sequence!
pls.run(period=4e-6, repeat_count=1, num_averages=1)
t_arr, data = pls.get_store_data()
Here, period
is the duration of the
pulse sequence. repeat_count
allows us to repeat this sequence a certain number of times, and
potentially sweep one or multiple parameters while doing so. For now we run the sequence only once.
num_averages
lets us repeat the sequence and average the sampled data. In this example we do
not average.
We request the sampled data with get_store_data()
and, finally, we can plot the
result.
fig, ax = plt.subplots(tight_layout=True, figsize=(6, 2.5))
ax.plot(1e9 * t_arr, data[0, 0, :])
ax.set_xlabel("Time [ns]")
ax.set_ylabel("Input signal [FS]")
plt.show()
We measure the pulse as expected. The delay of about 200 ns includes the latency of DAC and ADC converters in Presto, as well as the small delay introduced by the cables and low-pass filter. This delay will be constant as long as the cabling is not changed.
Using scale look-up tables¶
Sometimes it is beneficial to encode the amplitude of the pulse in the scale look-up table (LUT).
For example, we might want to output pulses with the same shape but different amplitude one after
another. Let’s modify the code to output two pulses back-to-back with amplitudes amp
and amp/2
,
respectively.
Note
We’ll use the diff syntax below to mark which lines of code should be added, removed or left unchanged. Like this:
this line should remain unchanged
- this line should be removed
+ this line should be added
We change the template to only contain the normalized pulse, with amplitude between -1 and 1:
- data = amp * sin2(ns) * np.sin(2 * np.pi * freq * t)
+ data = sin2(ns) * np.sin(2 * np.pi * freq * t)
pulse_1 = pls.setup_template(OUTPUT_PORT, group=0, template=data)
And instead we encode the amplitude into the scale LUT. Since we want to output two pulses with two different scales, we pass a list of two scales instead of one number:
- pls.setup_scale_lut(OUTPUT_PORT, group=0, scales=1.0)
+ pls.setup_scale_lut(OUTPUT_PORT, group=0, scales=[amp, amp/2])
We are ready to modify the pulse sequence:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
+ T += pulse_1.get_duration()
+ pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
+ pls.output_pulse(T, pulse_1)
We output the first pulse and start the sampling window at time T = 0
, just like before. We now
increase the time variable T
by the duration
of pulse_1
. When
the first pulse is over, we change the amplitude of the scaling block for OUTPUT_PORT
and group 0
with the method next_scale()
. We finally output the same pulse_1
again but,
because we changed the scaling factor, the second pulse will have amplitude amp/2
. At the
beginning of the experiment (T = 0
for the first repetition), the scale has the value of the
first entry in the LUT, i.e. amp
in our example.
Here’s the result of a new run:
Using frequency lookup tables¶
Similarly to scale LUTs, there are frequency LUTs to encode the frequency and phase of pulses allowing us to program sequences in a more compact way.
When we define a template, we indicate if we want to use the frequency LUT with the optional
argument envelope
. In the first example, we used the default value envelope=False
because we
encoded the frequency directly into the data points. Now, we instead encode only the envelope in
the template:
- data = sin2(ns) * np.sin(2 * np.pi * freq * t)
+ data = sin2(ns)
- pulse_1 = pls.setup_template(OUTPUT_PORT, group=0, template=data)
+ pulse_1 = pls.setup_template(OUTPUT_PORT, group=0, template=data, envelope=True)
And indicate with envelope=True
that we want to use the frequency LUT to encode the frequency and
the phase.
pls.setup_scale_lut(OUTPUT_PORT, group=0, scales=[amp, amp/2])
+ pls.setup_freq_lut(OUTPUT_PORT, group=0, frequencies=freq, phases=-np.pi/2)
The intermediate frequency (IF) generator attached to the frequency LUT produces
a cosine wave with the frequency and phase defined in the LUT. At time T=0
, it starts
and produces the wave np.cos(2 * np.pi * freq * t + phase)
that gets multiplied with all the
templates on OUTPUT_PORT
and group=0
with envelope=True
.
In this example we choose a phase of -π/2 to generate a sine instead of a cosine.
If we run with the same pulse sequence as before:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration()
pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
pls.output_pulse(T, pulse_1)
we get a similar result as before:
The subtle difference is that now the IF generator starts at time T = 0
and is free running,
while in the previous example we hard-coded the oscillation in the template. This results in a
different phase of the second pulse in the two cases: in the first example the phase resets to -π/2
with a glitch at the start of the second pulse, while in this second example the phase continues to
evolve coherently across the two pulses.
We can force the phase of the IF generator to reset, in order to achieve the same result as before.
We add a call to reset_phase()
, which effectively resets the time for the IF
generator:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration()
pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
+ pls.reset_phase(T, output_ports=OUTPUT_PORT, group=0)
pls.output_pulse(T, pulse_1)
It is now easy to change the frequency and the phase of the second pulse. We add another value for frequency and phase for the second pulse in the frequency LUT:
- pls.setup_freq_lut(OUTPUT_PORT, group=0, frequencies=freq, phases=-np.pi/2)
+ pls.setup_freq_lut(OUTPUT_PORT, group=0, frequencies=[freq, freq/2], phases=[-np.pi/2, +np.pi/2])
In the sequence, we call next_frequency()
instead of reset_phase()
to
indicate to the IF generator that we want to use the second entry in the LUT for both the frequency
and the phase:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration()
pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
- pls.reset_phase(T, output_ports=OUTPUT_PORT, group=0)
+ pls.next_frequency(T, output_ports=OUTPUT_PORT, group=0)
pls.output_pulse(T, pulse_1)
At the end of the first pulse, the IF generator instantly switches to output a cosine with
frequency freq/2
and phase π/2. The internal time of the IF generator is reset when we call
next_frequency()
. In other words, reset_phase()
is implicit in next_frequency()
.
And this is what we measure:
Flat pulses with smooth rise and fall¶
When we want to use a pulse that is longer than 1 μs, or when we want to change the duration of the
pulse during a pulse sequence, we can use setup_flat_pulse()
instead of
setup_template()
. We setup this pulse on the same output port and the same group,
meaning this pulse will be multiplied with the same scale and frequency generator (we will pass
envelope=True
) as our previous templates.
pulse_1 = pls.setup_template(OUTPUT_PORT, group=0, template=data, envelope=True)
+
+ pulse_2 = pls.setup_flat_pulse(
+ OUTPUT_PORT,
+ group=0,
+ duration=6 * duration,
+ amplitude=0.7,
+ rise_time=100e-9,
+ fall_time=100e-9,
+ envelope=True,
+ )
Now we just add this pulse to the pulse sequence. We output it right after the first pulse. For now, we don’t change the frequency or the scale to be able to see the effect of the flat_pulse more clearly.
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration()
- pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
- pls.next_frequency(T, output_ports=OUTPUT_PORT, group=0)
- pls.output_pulse(T, pulse_1)
+ pls.output_pulse(T, pulse_2)
This is what it looks like:
It’s easy to change the duration of the flat part of the pulse during the pulse sequence. This is useful e.g. when sweeping the length of the pulse. Let’s add a shorter flat pulse right after the long one:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration()
pls.output_pulse(T, pulse_2)
+ T += pulse_2.get_duration()
+ pulse_2.set_flat_duration(100e-9)
+ pls.output_pulse(T, pulse_2)
Output pulses at different times¶
We are free to choose when we output pulses. We can delay the second pulse simply by changing the time when we output it. Let’s delay the second pulse by 100 ns by changing only the definition of the pulse sequence:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
- T += pulse_1.get_duration()
+ T += pulse_1.get_duration() + 100e-9
pls.output_pulse(T, pulse_2)
- T += pulse_2.get_duration()
- pulse_2.set_flat_duration(100e-9)
- pls.output_pulse(T, pulse_2)
It is also possible to output many pulses at the same time. In this case make sure that the
combined amplitude of the pulses doesn’t exceed ±1. As an example, we output pulse_1
again 200 ns
after pulse_2
starts, somewhere in the middle of it:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration() + 100e-9
pls.output_pulse(T, pulse_2)
+ T += 200e-9
+ pls.output_pulse(T, pulse_1)
Note how we increase the time variable T
by an arbitrary amount of 200 ns, rather than by the
duration of pulse_2
. The envelope of the resulting pulse is the sum of the envelopes of pulse_1
and pulse_2
, where they overlap in time.
It is also possible to change the scale and frequency while a pulse is playing. For example, let’s change first the scale and then the frequency during the flat pulse. We only need to change the timing of the pulse sequence:
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration() + 100e-9
pls.output_pulse(T, pulse_2)
T += 200e-9
pls.output_pulse(T, pulse_1)
+ T += 300e-9
+ pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
+ T += 300e-9
+ pls.next_frequency(T, output_ports=OUTPUT_PORT, group=0)
In general, the timing of when we modify the scales, frequencies or output pulses is completely up
to us and clearly encoded in the pulse sequence. The only requirement is that all these events
happen on a time grid with a resolution of 2 ns, equal to get_clk_T()
.
Complete code¶
If you made it this far and applied all the code changes of the previous sections, congratulations! You probably have in front of you a script that looks like the one below here. Keep in mind that you can also have a look at the various example scripts in the presto-demo repository.
import matplotlib.pyplot as plt
import numpy as np
from presto import pulsed
from presto.hardware import AdcMode, DacMode
from presto.utils import sin2, plot_sequence
OUTPUT_PORT = 1 # 1 to 16, can be a list
INPUT_PORT = 1 # 1 to 16, can be a list
with pulsed.Pulsed(
address="192.168.20.23",
ext_ref_clk=False,
adc_mode=AdcMode.Direct,
dac_mode=DacMode.Direct,
) as pls:
pls.hardware.set_dac_current(OUTPUT_PORT, 40_500) # μA, 2250 to 40500
pls.hardware.set_inv_sinc(OUTPUT_PORT, 0)
pls.hardware.set_adc_attenuation(INPUT_PORT, 0.0) # dB, 0.0 to 27.0
pls.setup_store(INPUT_PORT, duration=2e-6)
amp = 0.5 # FS
duration = 200e-9 # s
freq = 42.5e6 # Hz
ns = int(round(duration * pls.get_fs("dac")))
t = np.linspace(0, duration, ns, endpoint=False)
data = sin2(ns)
pulse_1 = pls.setup_template(OUTPUT_PORT, group=0, template=data, envelope=True)
pulse_2 = pls.setup_flat_pulse(
OUTPUT_PORT,
group=0,
duration=6 * duration,
amplitude=0.7,
rise_time=100e-9,
fall_time=100e-9,
envelope=True,
)
pls.setup_scale_lut(OUTPUT_PORT, group=0, scales=[amp, amp / 2])
pls.setup_freq_lut(
OUTPUT_PORT,
group=0,
frequencies=[freq, freq / 2],
phases=[-np.pi / 2, +np.pi / 2],
)
T = 0.0
pls.store(T)
pls.output_pulse(T, pulse_1)
T += pulse_1.get_duration() + 100e-9
pls.output_pulse(T, pulse_2)
T += 200e-9
pls.output_pulse(T, pulse_1)
T += 300e-9
pls.next_scale(T, output_ports=OUTPUT_PORT, group=0)
T += 300e-9
pls.next_frequency(T, output_ports=OUTPUT_PORT, group=0)
# plot_sequence(pls, period=4e-6, repeat_count=1, num_averages=1)
pls.run(period=4e-6, repeat_count=1, num_averages=1)
t_arr, data = pls.get_store_data()
fig, ax = plt.subplots(tight_layout=True, figsize=(6, 2.5))
ax.plot(1e9 * t_arr, data[0, 0, :])
ax.set_xlabel("Time [ns]")
ax.set_ylabel("Input signal [FS]")
plt.show()