# Basic pulsed tutorial {mod}`.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](https://github.com/intermod-pro/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. ::: ```{contents} Contents :class: this-will-duplicate-information-and-it-is-still-useful-here ``` ## 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 {mod}`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](project:/setup.md#python-api). ::: ```python 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 {class}`.Pulsed` class: ```python 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 {class}`.AdcMode` and {class}`.DacMode` to `Direct`. In `Direct` mode we can output and measure pulses from 0 to 1 GHz. --- Next, we set a few {class}`.Hardware`-related parameters, which usually don't change during the measurement: ```python 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 {meth}`analog output range <.Hardware.set_dac_current>` for the `OUTPUT_PORT` to its maximum value, disable the use of the {meth}`inverse-sinc FIR filter <.Hardware.set_inv_sinc>` and disable the {meth}`input attenuation <.Hardware.set_adc_attenuation>` (0 dB). These are optional parameters, but it's good to know that they exist. --- We configure the data acquisition by {meth}`specifying <.Pulsed.setup_store>` that we want to store (sample) data from the `INPUT_PORT` for 2 μs each time: ```python 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](https://en.wikipedia.org/wiki/Hann_function)): ```python 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 {func}`.utils.sin2`. We create the NumPy array `data` with the raw data points of the waveform, and use {meth}`~.Pulsed.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](project:/man.md#pulsed-output). ::: --- We {meth}`setup the scale look-up table <.Pulsed.setup_scale_lut>` (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`. ```python 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: ```python 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 {meth}`output a pulse <.Pulsed.output_pulse>`, and at the same time we {meth}`open a sampling window <.Pulsed.store>`. --- We could now just run the experiment and look at the data, but let's first use {func}`.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. ```python plot_sequence(pls, period=4e-6, repeat_count=1, num_averages=1) ``` ```{image} images/pulsed_demo_1_sequence_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_1_sequence_dark.svg :align: center :class: only-dark ``` 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 {class}`.Pulsed` class. ::: --- It's now time to {meth}`~.Pulsed.run` the pulse sequence! ```python 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 {meth}`~.Pulsed.get_store_data` and, finally, we can plot the result. ```python 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() ``` ```{image} images/pulsed_demo_1a_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_1a_dark.svg :align: center :class: only-dark ``` 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: ```diff 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: ```diff - 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: ```diff - 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: ```diff 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 {meth}`duration <.Template.get_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 {meth}`~.Pulsed.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: ```{image} images/pulsed_demo_2a_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_2a_dark.svg :align: center :class: only-dark ``` ### 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: ```diff - 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. ```diff 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: ```python 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: ```{image} images/pulsed_demo_3a_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_3a_dark.svg :align: center :class: only-dark ``` 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 {meth}`~.Pulsed.reset_phase`, which effectively resets the time for the IF generator: ```diff 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) ``` ```{image} images/pulsed_demo_3b_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_3b_dark.svg :align: center :class: only-dark ``` --- 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: ```diff - 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 {meth}`~.Pulsed.next_frequency` instead of {meth}`~.Pulsed.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: ```diff 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: ```{image} images/pulsed_demo_3c_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_3c_dark.svg :align: center :class: only-dark ``` ### 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 {meth}`~.Pulsed.setup_flat_pulse` instead of {meth}`~.Pulsed.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. ```diff 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. ```diff 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: ```{image} images/pulsed_demo_4a_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_4a_dark.svg :align: center :class: only-dark ``` --- 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: ```diff 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) ``` ```{image} images/pulsed_demo_4b_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_4b_dark.svg :align: center :class: only-dark ``` ### 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: ```diff 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) ``` ```{image} images/pulsed_demo_5a_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_5a_dark.svg :align: center :class: only-dark ``` --- 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: ```diff 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) ``` ```{image} images/pulsed_demo_5b_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_5b_dark.svg :align: center :class: only-dark ``` 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: ```diff 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) ``` ```{image} images/pulsed_demo_5c_light.svg :align: center :class: only-light ``` ```{image} images/pulsed_demo_5c_dark.svg :align: center :class: only-dark ``` --- 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 {meth}`~.Pulsed.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](https://github.com/intermod-pro/presto-demo) repository. ```python 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() ```