Helper code to communicate with Quantum Composer's Sapphire 9200 TTL pulse generator.
This code facilitates connections to the device, communication, error handling and system status.
pip install qcsapphire
import qcsapphire
qcsapphire.discover_devices()
Will return a list of ports and information about devices connected to those ports. For example, on *nix platforms, you may see
[['/dev/cu.BLTH', 'n/a', 'n/a'],
['/dev/cu.Bluetooth-Incoming-Port', 'n/a', 'n/a'],
['/dev/cu.usbmodem141101',
'QC-Pulse Generator',
'USB VID:PID=04D8:000A LOCATION=20-1.1']]
The device here is connected to \dev\cu.usbmodem141101
.
On Windows you may see
[['COM3',
'Intel(R) Active Management Technology - SOL (COM3)',
'PCI\\VEN_8086&DEV_43E3&SUBSYS_0A541028&REV_11\\3&11583659&1&B3'],
['COM5',
'USB Serial Device (COM5)',
'USB VID:PID=0483:A3E5 SER=206A36705430 LOCATION=1-2:x.0'],
['COM6',
'USB Serial Device (COM6)',
'USB VID:PID=04D8:000A SER= LOCATION=1-8:x.0'],
['COM7',
'USB Serial Device (COM7)',
'USB VID:PID=239A:8014 SER=3B0D07C25831555020312E341A3214FF LOCATION=1-6:x.0']]
It is certainly not obvious to which USB port the QC Sapphire is connected. However,
using the Windows Task Manager, as well as trial and error, should eventually
reveal the correct serial port to use. In this case, COM6
.
pulser = qcsapphire.Pulser('COM6')
For normal usage, all commands sent to the device should use the query()
method or
with property-like calls (see section below).
The query()
method will write a command, read the response from the device,
check for errors (raising an Exception when an error is found) and return the string
response. For example,
ret_val = pulser.query(':PULSE0:STATE?')
print(ret_val)
'0'
ret_val = pulser.query(':PULSE1:WIDTH 0.000025')
print(ret_val)
'ok'
ret_val = pulser.query(':PULSE1:WIDTH?')
print(ret_val)
'0.000025000'
It's possible to make the same calls to the pulser using a property-like call.
Instead of calling pulser.query("command1:subcommand:subsubcommand value")
,
one can call pulser.command1.subcommand.subsubcommand(value)
, which is more readable.
For example,
ret_val = pulser.pulse1.width(0.000025) #sets the width of channel A
print(ret_val) # 'ok'
width = pulser.pulse1.width() #asks for the width of channel A
print(width) # '0.000025000'
All of the SCPI commands can be built this way.
In either case, the user is responsible for sending the correct command strings by following the API. It should be pointed out there is no need to worry about string encoding and carriage returns / line feeds, as that is taken care of by the code.
Instead of using 'pulseN' to access a particular QCSapphire channel, methods have been added to facilitate more programmatic ways of control.
Two methods exist to report on global and channel settings
import pprint
pp = pprint.PrettyPrinter(indent=4)
pp.pprint(pulser.report_global_settings())
for channel in range(1,5):
pp.pprint(f'channel {channel}')
pp.pprint(pulser.report_channel_settings(channel))
The following examples demonstrate both simple and advanced usage.
The QCSapphire can be unstable at times. We have found that forcing the system to reset, multiple times, is necessary to program the pulser the the ways described below. You may or may-not have better stability if you reset the pulser before programming.
for i in range(2):
pulser.set_all_state_off()
time.sleep(1)
pulser.query('*RST')
pulser.system.mode('normal')
In CWODMR our spin system is continuously illuminated with an optical pump, while an oscillating (RF) magnetic field drives magnetic transitions between spin states. While the optical pump is continuously on, we can use the QCSapphire to control the RF magnetic signal timing. The RF signal hardware will control the frequency of the field (for NV centers, between 2.6 and 3.2 GHz, depending on the level of Zeeman splitting). The QCSapphire's TTL output may operate a switch that blocks the RF signal at known times. This allows the experimenter to acquire photo luminescence (PL) signal for periods when no RF signal is applied in order to measure a background, and to then measure PL when the RF is applied.
In this case, we wish to program the QCSapphire to output a TTL signal of fixed duration and period. The following example shows how to generate a 5 microsecond on/off TTL signal.
In the simple case, we just have a single channel ('B') that we wish to use to generate a square wave. The documentation is straight-forward here. NB: channel 'B' is demonstrated here because we use channel 'A' to control the optical pumping in other experiments (pulsed-ODMR, Rabi, etc.).
total_width = 10e-6 #in seconds
pulser.system.period(total_width)
#use channel B for RF switch and use 'normal' mode
channel = pulser.channel('B')
channel.mode('normal')
#create a 5 microsecond pulse
#round to nearest 10ns bc QCSapphire does not behave well with machine errors
channel.width(np.round(total_width/2, 8))
channel.delay(0)
pulser.system.state(1) #start the pulser
In order to take robust data, however, we need to control our data acquisition system (DAQ). Supplying an external clock and trigger signal from the QCSapphire ensures that the DAQ and pulser do not drift from each other and data is acquired at the exact moment the system is ready.
However, this changes how we must program the RF on/off to be 5 microseconds in width. The following steps through the necessary calls to produce a
- 200 ns clock period
- 5 microsecond on/off RF pulse
- single trigger pulse at the start of every 10 microsecond period
In this example, channel ('C') is programmed to act as the clock. The pulser system clock period is set to the smallest allowed value in order to set our clock tick leading edge period to 200 ns. This channel is programmed exactly as the simple case above with a smaller width.
clock_period = 200e-9
pulser.system.period(clock_period)
channel = pulser.channel('C')
channel.mode('normal')
channel.width(np.round(clock_period/2, 8)) #100 ns wide square wave
channel.delay(0)
A 5 microsecond wide pulse (positive), followed by
5 microseconds off on channel B is implemented using the duty-cycle mode.
In the duty-cycle mode, we specify the number of clock_periods
ON we wish for the
channel to generate a signal we describe. We then specify how many clock_periods
OFF until the pattern repeats. In this case channel B is programmed to output
a 5 microsecond wide pulse ONE time and then wait the appropriate number of
clock_periods
such that the start of the next 5 microsecond wide pulse occurs
10 microseconds later. Thus, the OFF duty cycle will be 10 mu*s / clock_period - 1
rf_pulse_width = 5e-6
full_pulse_sequence_period = 2 * rf_pulse_width
n_on_cycles = 1
n_off_cycles = np.round(full_cycle_width/clock_period).astype(int) - n_on_cycles
channel = pulser.channel('B')
channel.mode('dcycle')
channel.width(np.round(rf_pulse_width, 8)) #5 mu*s wide square wave
channel.delay(0)
channel.pcounter(n_on_cycles)
channel.ocounter(n_off_cycles)
When data are acquired, the first 5 microseconds will be the 'signal' where we expect a dip in PL, and the second 5 microseconds will be the 'background' where no RF signal is present and we expect full PL intensity.
We now want to produce a single square wave output on channel D at the beginning of each 10 microsecond period. This will be used to trigger our DAQ. Again, we use the duty cycle mode.
trigger_width = 100e-9
n_on_cycles = 1
n_off_cycles = np.round(full_pulse_sequence_period/clock_period).astype(int) - n_on_cycles
channel = pulser.channel('D')
channel.mode('dcycle')
channel.width(np.round(trigger_width, 8)) #100 ns wide square wave
channel.delay(0)
channel.pcounter(n_on_cycles)
channel.ocounter(n_off_cycles)
We finally set the states of the channels and system to start the sequence.
pulser.channel('B').state(1)
pulser.channel('C').state(1)
pulser.channel('D').state(1)
pulser.system.state(1)
In pulsed-ODMR, one separates the optical pumping from the application of the RF magnetic field signal. In a Rabi oscillation experiment, the width of the RF signal is varied as the PL contrast to background is measured. Both of these pulse sequences are similar to each other and both are more complicated than the example above.
The main difference between the CWODMR pulses above and Rabi/pulse-ODMR sequences is that an appropriate delay to RF pulse is added and channel A is used to control the optical pump/readout. The RF pulse must come after the initial optical pump.
The full sequence is an optical pump signal (~5 microseconds), followed by an RF signal of some duration, followed by an optical readout (~5 microseconds), followed by some period with no RF or optical pump. A description of this sequence, though done with a lock-in amplifier, is found here.
However, the duty-cycle mode is used. The RF channel (B) is programmed to generate
a delayed signal, of some width, ONE time, and then wait M clock cycles before
the next signal, where M = full_pulse_sequence_period / clock_period - 1
.
An example of this sequence is found in the QT3 lab experimental code.
Additionally, as can be seen in the code above, some extra delay after the optical pump was added because of a finite hardware response time of ~700-900 ns.
Here's an example of using an external trigger to generate an output pulse from the QCSapphire. This may be very useful when used in combination with a device that has more output channels and more sophisticated pulse sequeunce programming, such as the Spin Core Pulse Blaster. One of the Pulse Blaster's output channels could be used to trigger the external trigger channel of the QCSapphire.
One reason to do this would be to utilize QCSapphire's superior pulse width resolution. The smallest square wave from the Pulse Blaster is 50 ns, while the QCSapphire can produce a 10 ns wide pulse (according to its documentation)
### TODO -- check this code! It doesn't look right.
pulser.channel('A').mode('single')
#pulser.channel('A').cmode('single') #do we need cmode instead of mode?
pulser.channel('A').period(0.2) #200 ms system pulse
pulser.channel('A').external.mode('trigger')
pulser.channel('A').width(10e-9) #10 ns wide pulse
#pulser.channel('B').cmode('single')
#pulser.channel('B').polarity('normal')
#pulser.channel('B').width(10e-9) #10 ns wide pulse
#pulser.channel('B').state(0)
pulser.channel('A').state(0)
#trigger loop example
#wait N seconds between triggers
wait_N = 5.0
N_pulses = 50
#activate pulser and output channel
pulser.channel('A').state(1)
#pulser.channel('B').state(1)
for i in range(N_pulses):
pulser.software_trigger() #trigger the pulser
time.sleep(wait_N)
#deactivate
pulser.channel('A').state(0)
#pulser.channel('B').state(0)
If you hit an error, especially when trying to use the property-like calls,
the last string written to the Serial port is found in the
.last_write_command
attribute of the pulser object.
pulser.channle('B').width(25e-6)
print(pulser.last_write_command)
# ':PULSE1:WIDTH 2.5e-05'
Additionally, you can see the recent command history of the object (last 1000 commands)
for command in pulser.command_history():
print(command)
The Property
class of this code was taken from easy-scpi
: https://github.com/bicarlsen/easy-scpi
and modified.