diff --git a/docs/source/devices.rst b/docs/source/devices.rst index 1b42ec02..1e4fb666 100644 --- a/docs/source/devices.rst +++ b/docs/source/devices.rst @@ -58,6 +58,7 @@ These devices cover various frequency sources that provide either hardware-timed .. toctree:: :maxdepth: 2 + devices/AD9959DDSSweeper devices/novatechDDS9m devices/phasematrixquicksyn diff --git a/docs/source/devices/AD9959DDSSweeper.rst b/docs/source/devices/AD9959DDSSweeper.rst new file mode 100644 index 00000000..315ecd7c --- /dev/null +++ b/docs/source/devices/AD9959DDSSweeper.rst @@ -0,0 +1,158 @@ +AD9959DDSSweeper +================ + +This labscript device controls the `DDSSweeper `_, an interface to the `AD9959 eval board `_ four channel direct digital synthesizer (DDS) using the `Raspberry Pi Pico `_ platform. + +The DDS Sweeper is described in more detail in E. Huegler, J. C. Hill, and D. H. Meyer, An agile radio-frequency source using internal sweeps of a direct digital synthesizer, *Review of Scientific Instruments*, **94**, 094705 (2023) https://doi.org/10.1063/5.0163342 . + +Specifications +~~~~~~~~~~~~~~ + +The AD9959 evaluation board provides the following: + +* 4 DDS channels + + - 100 kHz - 250 MHz output frequency with 32 bit frequency resolution (~0.1 Hz) + - Up to 0 dBm output power with 10 bit amplitude resolution + - Phase control with 16 bit resolution (~48 uRad) + +The Pico interface allows the evaluation board parameters to be reprogrammed during a sequence. +At this time, stepping of frequency, amplitude, and phase parameters is supported. +Parameter ramping is possible, but not currently supported by the labscript device (if support for this is of interest, please `open an issue `). +The Pico interface provides the following: + +* For the Pico 1: 16,656 instructions distributed evenly among the configured channels; 16,656, 8,615, 5,810, and 4,383 for 1, 2, 3, 4 channels respectively. +* For the Pico 2: 34,132 instructions distributed evenly among the configured channels; 34,132, 17,654, 11,905, and 8,981 for 1, 2, 3, 4 channels respectively. +* External timing via a pseudoclock clockline. +* By default, the AD9959 system reference clock is taken from the Pi Pico. If a higher quality clock is needed, the user can provide an external system reference clock to the AD9959. For more details on clocking, see the Usage section. + +Installation +~~~~~~~~~~~~ + +- **For Pi Pico (RP2040)**: + `dds-sweeper_rp2040.uf2 `_ + +- **For Pi Pico 2 (RP2350)**: + `dds-sweeper_rp2350.uf2 `_ + +On your Raspberry Pi Pico, hold down the "bootsel" button while plugging the Pico into USB port on a PC (that must already be turned on). +The Pico should mount as a mass storage device (if it doesn't, try again or consult the Pico documentation). +Drag and drop the `.uf2` file into the mounted mass storage device. +The mass storage device should unmount after the copy completes. Your Pico is now running the DDS Sweeper firmware! + +Note that this device communicates using a virtual COM port. +The number is assigned by the controlling computer and will need to be determined in order for BLACS to connect to the PrawnDO. + + +Usage +~~~~~ + +An example connection table with the default settings of the sweeper: + +.. code-block:: python + + from labscript import start, stop, DDS, StaticDDS + from labscript_devices.PrawnBlaster.labscript_devices import PrawnBlaster + from labscript_devices.AD9959DDSSweeper.labscript_devices import AD9959DDSSweeper + + # prawnblaster for external timing + prawn = PrawnBlaster( + name='prawn', + com_port='COM7', + num_pseudoclocks=1 + ) + + AD9959 = AD9959DDSSweeper( + name='AD9959', + parent_device=prawn.clocklines[0], + com_port='COM11', + dynamic_channels=2 + ) + + + chann0 = DDS( 'chann0', AD9959, 'channel 0') + chann1 = DDS( 'chann1', AD9959, 'channel 1') + chann2 = StaticDDS( 'chann2', AD9959, 'channel 2') + chann3 = StaticDDS( 'chann3', AD9959, 'channel 3') + + + start() + + stop(1) + +An example connection table that uses the PrawnBlaster and sweeper with three +dynamic channels, an external, 100 MHz clock and pll multiplier of 5: + +.. code-block:: python + + from labscript import start, stop, DDS, StaticDDS + from labscript_devices.PrawnBlaster.labscript_devices import PrawnBlaster + from labscript_devices.AD9959DDSSweeper.labscript_devices import AD9959DDSSweeper + + # prawnblaster for external timing + prawn = PrawnBlaster( + name='prawn', + com_port='COM7', + num_pseudoclocks=1 + ) + + AD9959 = AD9959DDSSweeper( + name='AD9959', + parent_device=prawn.clocklines[0], + com_port='COM11', + dynamic_channels=3, + pico_board='pico2', + ref_clock_external=1, + ref_clock_frequency=100e6, + pll_mult=5 + ) + + + chann0 = DDS( 'chann0', AD9959, 'channel 0') + chann1 = DDS( 'chann1', AD9959, 'channel 1') + chann2 = DDS( 'chann2', AD9959, 'channel 2') + chann3 = StaticDDS( 'chann3', AD9959, 'channel 3') + + + start() + + stop(1) + +.. note:: + +**Clocking** + +If the Pi Pico is used as the AD9959 system reference clock, pin 21 of the Pi Pico should be connected to the REF CLK input (J9) of the AD9959 eval board. Otherwise, another clock source should be connected to REF CLK input and its frequency provided as the ref_clock_frequency. + +Detailed Documentation +~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: labscript_devices.AD9959DDSSweeper + :members: + :undoc-members: + :show-inheritance: + :private-members: + +.. automodule:: labscript_devices.AD9959DDSSweeper.labscript_devices + :members: + :undoc-members: + :show-inheritance: + :private-members: + +.. automodule:: labscript_devices.AD9959DDSSweeper.blacs_tabs + :members: + :undoc-members: + :show-inheritance: + :private-members: + +.. automodule:: labscript_devices.AD9959DDSSweeper.blacs_workers + :members: + :undoc-members: + :show-inheritance: + :private-members: + +.. automodule:: labscript_devices.AD9959DDSSweeper.runviewer_parsers + :members: + :undoc-members: + :show-inheritance: + :private-members: diff --git a/labscript_devices/AD9959DDSSweeper/__init__.py b/labscript_devices/AD9959DDSSweeper/__init__.py new file mode 100644 index 00000000..39fd0993 --- /dev/null +++ b/labscript_devices/AD9959DDSSweeper/__init__.py @@ -0,0 +1,12 @@ +##################################################################### +# # +# /labscript_devices/AD9959DDSSweeper/blacs_tabs.py # +# # +# Copyright 2025, Carter Turnbaugh # +# # +# This file is part of the module labscript_devices, in the # +# labscript suite (see http://labscriptsuite.org), and is # +# licensed under the Simplified BSD License. See the license.txt # +# file in the root of the project for the full license. # +# # +##################################################################### \ No newline at end of file diff --git a/labscript_devices/AD9959DDSSweeper/blacs_tabs.py b/labscript_devices/AD9959DDSSweeper/blacs_tabs.py new file mode 100644 index 00000000..b792aa76 --- /dev/null +++ b/labscript_devices/AD9959DDSSweeper/blacs_tabs.py @@ -0,0 +1,60 @@ +##################################################################### +# # +# /labscript_devices/AD9959DDSSweeper/blacs_tabs.py # +# # +# Copyright 2025, Carter Turnbaugh # +# # +# This file is part of the module labscript_devices, in the # +# labscript suite (see http://labscriptsuite.org), and is # +# licensed under the Simplified BSD License. See the license.txt # +# file in the root of the project for the full license. # +# # +##################################################################### + +from blacs.device_base_class import DeviceTab + + +class AD9959DDSSweeperTab(DeviceTab): + def initialise_GUI(self): + device = self.settings['connection_table'].find_by_name(self.device_name) + + ref_clk_freq = device.properties['ref_clock_frequency'] + pll_mult = device.properties['pll_mult'] + self.com_port = device.properties['com_port'] + + max_freq = 0.5 * (ref_clk_freq * pll_mult) + # Capabilities + self.base_units = {'freq':'Hz', 'amp':'Arb', 'phase':'Degrees'} + self.base_min = {'freq':0.0, 'amp':0, 'phase':0} + self.base_max = {'freq':max_freq, 'amp':1, 'phase':360} + self.base_step = {'freq':10**6, 'amp':1/1023., 'phase':1} + self.base_decimals = {'freq':1, 'amp':4, 'phase':3} + self.num_DDS = 4 + + dds_prop = {} + for i in range(self.num_DDS): + dds_prop['channel %d' % i] = {} + for subchnl in ['freq', 'amp', 'phase']: + dds_prop['channel %d' % i][subchnl] = {'base_unit':self.base_units[subchnl], + 'min':self.base_min[subchnl], + 'max':self.base_max[subchnl], + 'step':self.base_step[subchnl], + 'decimals':self.base_decimals[subchnl] + } + + self.create_dds_outputs(dds_prop) + dds_widgets, _, _ = self.auto_create_widgets() + self.auto_place_widgets(('DDS Outputs', dds_widgets)) + + self.supports_remote_value_check(False) + self.supports_smart_programming(True) + + def initialise_workers(self): + self.create_worker( + "main_worker", + "labscript_devices.AD9959DDSSweeper.blacs_workers.AD9959DDSSweeperWorker", + { + 'com_port': self.com_port, + }, + ) + self.primary_worker = "main_worker" diff --git a/labscript_devices/AD9959DDSSweeper/blacs_workers.py b/labscript_devices/AD9959DDSSweeper/blacs_workers.py new file mode 100644 index 00000000..a8787433 --- /dev/null +++ b/labscript_devices/AD9959DDSSweeper/blacs_workers.py @@ -0,0 +1,519 @@ +##################################################################### +# # +# /labscript_devices/AD9959DDSSweeper/blacs_workers.py # +# # +# Copyright 2025, Carter Turnbaugh # +# # +# This file is part of the module labscript_devices, in the # +# labscript suite (see http://labscriptsuite.org), and is # +# licensed under the Simplified BSD License. See the license.txt # +# file in the root of the project for the full license. # +# # +##################################################################### + +from blacs.tab_base_classes import Worker +import labscript_utils.h5_lock, h5py +import time +import numpy as np +from labscript import LabscriptError + +class AD9959DDSSweeperInterface(object): + def __init__( + self, + com_port, + pico_board, + sweep_mode, + ref_clock_external, + ref_clock_frequency, + pll_mult + ): + '''Initializes serial communication and performs initial setup. + + Initial setup consists of checking the version, board, and status. + The DDS Sweeper is then reset, after which the clock, mode, and debug mode are configured. + + Args: + com_port (str): COM port assigned to the DDS Sweeper by the OS. + On Windows, takes the form of `COMd` where `d` is an integer. + pico_board (str): The version of pico board used, 'pico1' or 'pico2'. + sweep_mode (int): + The DDS Sweeper firmware can set the DDS outputs in either fixed steps or sweeps of the amplitude, frequency, or phase. + At this time, only steps are supported, so sweep_mode must be 0. + ref_clock_external (int): Set to 0 to have Pi Pico provide the reference clock to the AD9959 eval board. Set to 1 for another source of reference clock for the AD9959 eval board. + ref_clock_frequency (float): Frequency of the reference clock. If ref_clock_external is 0, the Pi Pico system clock will be set to this frequency. If the PLL is used, ref_clock_frequency * pll_mult must be between 100 MHz and 500 MHz. If the PLL is not used, ref_clock_frequency must be less than 500 MHz. + pll_mult: the AD9959 has a PLL to multiply the reference clock frequency. Allowed values are 1 or 4-20. + ''' + global serial; import serial + + self.timeout = 0.1 + self.conn = serial.Serial(com_port, 10000000, timeout=self.timeout) + + self.min_ver = (0, 4, 0) + self.status_map = { + 0: 'STOPPED', + 1: 'TRANSITION_TO_RUNNING', + 2: 'RUNNING', + 3: 'ABORTING', + 4: 'ABORTED', + 5: 'TRANSITION_TO_STOPPED' + } + + self.sys_clk_freq = ref_clock_frequency * pll_mult + + self.tuning_words_to_SI = { + 'freq' : self.sys_clk_freq / (2**32 - 1), + 'amp' : 1/1023.0, + 'phase' : 360 / 16384.0 + } + + self.subchnls = ['freq', 'amp', 'phase'] + + version = self.get_version() + print(f'Connected to version: {version}') + + board = self.get_board() + print(f'Connected to board: {board}') + assert board.strip() == pico_board.strip(), f'firmware thinks {board} attached, labscript thinks {pico_board}' + + current_status = self.get_status() + print(f'Current status is {current_status}') + + self.conn.write(b'reset\n') + self.assert_OK() + self.conn.write(b'setclock %d %d %d\n' % (ref_clock_external, ref_clock_frequency, pll_mult)) + self.assert_OK() + self.conn.write(b'mode %d 0\n' % sweep_mode) + self.assert_OK() + self.conn.write(b'debug off\n') + self.assert_OK() + + def assert_OK(self): + '''Read a response from the DDS Sweeper, assert that that response is "ok", the standard response to a successful command.''' + resp = self.conn.readline().decode().strip() + assert resp == "ok", 'Expected "ok", received "%s"' % resp + + def get_version(self): + '''Sends 'version' command, which retrieves the Pico firmware version. + + Returns: (int, int, int): Tuple representing semantic version number.''' + self.conn.write(b'version\n') + version_str = self.conn.readline().decode() + version = tuple(int(i) for i in version_str.split('.')) + + assert version >= self.min_ver, f'Version {version} too low' + return version + + def abort(self): + '''Stops buffered execution immediately.''' + self.conn.write(b'abort\n') + self.assert_OK() + + def start(self): + '''Starts buffered execution.''' + self.conn.write(b'start\n') + self.assert_OK() + + def get_status(self): + '''Reads the status of the AD9959 DDS Sweeper. + + Returns: + (str): Status in string representation. Accepted values are: + + STOPPED: manual mode\n + TRANSITION_TO_RUNNING: transitioning to buffered execution\n + RUNNING: buffered execution\n + ABORTING: aborting buffered execution\n + ABORTED: last buffered execution was aborted\n + TRANSITION_TO_STOPPED: transitioning to manual mode''' + + self.conn.write(b'status\n') + status_str = self.conn.readline().decode() + status_int = int(status_str) + if status_int in self.status_map: + return self.status_map[status_int] + else: + raise LabscriptError(f'Invalid status, returned {status_str}') + + def get_board(self): + '''Responds with pico board version. + + Returns: + (str): Either "pico1" for a Pi Pico 1 board or "pico2" for a Pi Pico 2 board.''' + self.conn.write(b'board\n') + resp = self.conn.readline().decode() + return resp + + def get_freqs(self): + '''Responds with a dictionary containing + the current operating frequencies (in kHz) of various clocks. + + Returns: + (str): Multi-line string containing clock frequencies in kHz. + Intended to be human readable, potentially difficult to parse automatically.''' + self.conn.write(b'getfreqs\n') + freqs = {} + while True: + resp = self.conn.readline().decode() + if resp == "ok": + break + resp = resp.split('=') + freqs[resp[0].strip()] = int(resp[1].strip()[:-3]) + return freqs + + def set_output(self, channel, frequency, amplitude, phase): + '''Set frequency, phase, and amplitude of a channel + outside of the buffered sequence from floating point values. + + Args: + channel (int): channel to set the instruction for. Zero indexed. + frequency (float): + frequency of output. Floating point number in Hz (0-DDS clock/2). + Will be rounded during quantization to DDS units. + amplitude (float): + amplitude of output. Fraction of maximum output amplitude (0-1). + Will be rounded during quantization to DDS units. + phase (float): + phase of output. Floating point number in degrees (0-360). + Will be rounded during quantization to DDS units.''' + self.conn.write(b'setfreq %d %f\n' % (channel, frequency)) + self.assert_OK() + self.conn.write(b'setamp %d %f\n' % (channel, amplitude)) + self.assert_OK() + self.conn.write(b'setphase %d %f\n' % (channel, phase)) + self.assert_OK() + + def set_channels(self, channels): + '''Set number of channels to use in buffered sequence. + + Args: + channels (int): + If 1-4, sets the number of channels activated for buffered mode. + Lowest channels are always used first. + If 0, simultaneously updates all channels during buffered mode.''' + self.conn.write(b'setchannels %d\n' % channels) + self.assert_OK() + + def seti(self, channel, addr, frequency, amplitude, phase): + '''Set frequency, phase, and amplitude of a channel + for address addr in buffered sequence from integer values. + + Args: + channel (int): channel to set the instruction for. Zero indexed. + addr (int): address of the instruction to set. Zero indexed. + frequency (unsigned 32 bit int): + frequency to jump to when this instruction runs. + In DDS units: ref_clock_frequency * pll_mult / 2^32 * frequency. + amplitude (unsigned 10 bit int): + amplitude to jump to when this instruction runs. + In DDS units: amplitude / 1023 fraction of maximum output amplitude. + phase (unsigned 14 bit int): + phase to jump to when this instruction runs. + In DDS units: 360 * phase / 2^14 degrees.''' + cmd = f'seti {channel} {addr} {int(frequency)} {int(amplitude)} {int(phase)}\n' + self.conn.write(cmd.encode()) + # self.conn.write(b'seti %d %d %f %f %f\n' % (channel, addr, frequency, amplitude, phase)) + self.assert_OK() + + def set_batch(self, table): + '''Set frequency, phase, and amplitude of all channels + for many addresses in buffered sequence from integer values in a table. + + Uses binary instruction encoding in transit to improve write speeds. + :meth:`set_batch` does not send a stop instruction, so call :meth:`stop` separately. + + Args: + table (numpy array): + Table should be an array of instructions in a mode-dependent format. + The dtypes should be repeated for each channel, with channel 0s parameters + first, followed by channel1s parameters, etc. depending on the number of channels. + The formats for each channel are as follows: + Single-step mode: ('frequency', ' 0.1: + self.logger.debug(f'Changed ratio: {changed_ratio:.2%}, refreshing fully') + fresh = True + + elif self.smart_cache['dds_data'] is None: + fresh = True + + # Fresh starts use the faster binary batch mode + if fresh: + self.logger.debug('Programming a fresh set of dynamic instructions') + self.intf.set_channels(len(dyn_chans)) + self.intf.set_batch(dds_data) + self.intf.stop(len(dds_data)) + self.smart_cache['dds_data'] = dds_data.copy() + self.logger.debug('Updating dynamic final values via batch') + self._update_final_values(dds_data, dyn_chans) + self.intf.start() + + # If only a few changes, incrementally program only the differing + # instructions + else: + self.intf.set_channels(len(dyn_chans)) + self.logger.debug('Comparing changed instructions') + cache = self.smart_cache['dds_data'] + n_cache = len(cache) + + # Extend cache if necessary + if len(dds_data) > n_cache: + new_cache = np.empty(len(dds_data), dtype=dds_data.dtype) + new_cache[:n_cache] = cache + self.smart_cache['dds_data'] = new_cache + cache = new_cache + + # Boolean mask of each rows + changed_mask = np.zeros(len(dds_data), dtype=bool) + for name in dds_data.dtype.names: + + # need to check field-by-field, both vals and dtypes + diffs = np.where(cache[:len(dds_data)][name] != dds_data[name])[0] + if diffs.size > 0: + self.logger.debug(f"Field {name} differs at rows: {diffs}") + field_dtype = dds_data[name].dtype + if np.issubdtype(field_dtype, np.floating): + changed_mask |= ~np.isclose(cache[:len(dds_data)][name], dds_data[name]) + else: + changed_mask |= cache[:len(dds_data)][name] != dds_data[name] + + changed_indices = np.where(changed_mask)[0] + # Handle potential row count difference + if len(cache) != len(dds_data): + self.logger.debug(f"Length mismatch: cache has {len(cache)}, dds_data has {len(dds_data)}") + changed_indices = np.union1d(changed_indices, np.arange(len(dds_data), len(cache))) + self.logger.debug(f"Changed rows: {changed_indices}") + + # Iterate only over changed rows + for i in changed_indices: + self.logger.debug(f'Smart cache differs at index {i}') + if i >= len(dds_data): + self.logger.warning(f"Skipping seti at index {i} — beyond dds_data length") + continue + for chan in sorted(dyn_chans): + freq = dds_data[i][f'freq{chan}'] + amp = dds_data[i][f'amp{chan}'] + phase = dds_data[i][f'phase{chan}'] + self.logger.debug(f'seti {chan} {i} {freq} {amp} {phase}') + self.intf.seti(int(chan), int(i), int(freq), int(amp), int(phase)) + for name in dds_data.dtype.names: + cache[i][name] = dds_data[i][name] + + self.smart_cache['dds_data'] = cache[:len(dds_data)] + self.intf.stop(len(dds_data)) + self.logger.debug('Updating dynamic final values with smart cache') + self._update_final_values(self.smart_cache['dds_data'], dyn_chans) + self.intf.start() + + return self.final_values + + def transition_to_manual(self): + '''Handles period between programmed instructions and return to front + panel control. Device will move from RUNNING -> TRANSITION_TO_STOPPED + -> STOPPED ideally. If buffered execution takes too long, calls + :meth:`abort_buffered`''' + + i = 0 + while True: + status = self.intf.get_status() + i += 1 + if status == 'STOPPED': + self.logger.debug('Transition to manual successful') + return True + + elif i == 1000: + # program hasn't ended, probably bad triggering + # abort and raise an error + self.abort_buffered() + raise LabscriptError(f'Buffered operation did not end with status {status}. Is triggering working?') + elif status in ['ABORTING', 'ABORTED']: + raise LabscriptError(f'AD9959 returned status {status} in transition to manual') + + def abort_buffered(self): + '''Aborts currently running program, ensuring ABORTED status. + Additionally updates front panels with values before run start and + updates smart cache before return.''' + self.intf.abort() + while self.intf.get_status() != 'ABORTED': + self.logger.debug('Tried to abort buffer, waiting another half second for ABORTED STATUS') + time.sleep(0.5) + self.logger.debug('Successfully aborted buffered execution') + + # return state to initial values + values = self.initial_values # fix, and update smart cache + self.logger.debug(f'Returning to values: {values}') + self.smart_cache['static_data'] = None + self.smart_cache['dds_data'] = None + self.program_manual(values) + return True + + def abort_transition_to_buffered(self): + '''Aborts transition to buffered. + + Calls :meth:`abort_buffered`''' + return self.abort_buffered() + + def shutdown(self): + '''Calls :meth:`AD9959DDSSweeperInterface.close` to end serial connection to AD9959''' + self.intf.close() diff --git a/labscript_devices/AD9959DDSSweeper/labscript_devices.py b/labscript_devices/AD9959DDSSweeper/labscript_devices.py new file mode 100644 index 00000000..eefe2603 --- /dev/null +++ b/labscript_devices/AD9959DDSSweeper/labscript_devices.py @@ -0,0 +1,292 @@ +##################################################################### +# # +# /labscript_devices/AD9959DDSSweeper/labscript_devices.py # +# # +# Copyright 2025, Carter Turnbaugh # +# # +# This file is part of the module labscript_devices, in the # +# labscript suite (see http://labscriptsuite.org), and is # +# licensed under the Simplified BSD License. See the license.txt # +# file in the root of the project for the full license. # +# # +##################################################################### + +from labscript import DDS, StaticDDS, IntermediateDevice, set_passed_properties, LabscriptError, config +from labscript_utils.unitconversions import NovaTechDDS9mFreqConversion, NovaTechDDS9mAmpConversion + + +import numpy as np + +class AD9959DDSSweeper(IntermediateDevice): + allowed_children = [DDS, StaticDDS] + allowed_boards = ['pico1', 'pico2'] + # external timing + max_instructions_map = { + 'pico1' : + { + 'steps' : [16656, 8615, 5810, 4383], + 'sweeps' : [8614, 4382, 2938, 2210] + }, + 'pico2' : + { + 'steps' : [34132, 17654, 11905, 8981], + 'sweeps' : [17654, 8981, 6022, 4529] + } + } + + cycles_per_instruction_map = { + 'steps' : [500, 750, 1000, 1250], + 'sweeps' : [1000, 1500, 2000, 2500] + } + + @set_passed_properties( + property_names={ + 'connection_table_properties': [ + 'name', + 'com_port', + 'pico_board', + 'sweep_mode', + 'ref_clock_external', + 'ref_clock_frequency', + 'pll_mult', + ] + } + ) + + def __init__(self, name, parent_device, com_port, dynamic_channels, + pico_board='pico1', sweep_mode=0, + ref_clock_external=0, ref_clock_frequency=125e6, pll_mult=4, **kwargs): + '''Labscript device class for AD9959 eval board controlled by a Raspberry Pi Pico running the DDS Sweeper firmware (https://github.com/QTC-UMD/dds-sweeper). + + This labscript device provides up to four channels of DDS outputs. It is designed to be connected to a pseudoclock clockline. + + Args: + name (str): python variable name to assign to the AD9959DDSSweeper + parent_device (:class:`~.ClockLine`): + Pseudoclock clockline used to clock DDS parameter changes. + com_port (str): COM port assigned to the AD9959DDSSweeper by the OS. + On Windows, takes the form of `COMd` where `d` is an integer. + dynamic_channels (int): number of dynamic DDS channels that will be added. + This must be specified in the constructor so that update rates can be calculated correctly. + pico_board (str): The version of pico board used, pico1 or pico2. + sweep_mode (int): + The DDS Sweeper firmware can set the DDS outputs in either fixed steps or sweeps of the amplitude, frequency, or phase. + At this time, only steps are supported, so sweep_mode must be 0. + ref_clock_external (int): Set to 0 to have Pi Pico provide the reference clock to the AD9959 eval board. Set to 1 for another source of reference clock for the AD9959 eval board. + ref_clock_frequency (float): Frequency of the reference clock. If ref_clock_external is 0, the Pi Pico system clock will be set to this frequency. If the PLL is used, ref_clock_frequency * pll_mult must be between 100 MHz and 500 MHz. If the PLL is not used, ref_clock_frequency must be less than 500 MHz. + pll_mult: the AD9959 has a PLL to multiply the reference clock frequency. Allowed values are 1 or 4-20. + ''' + self.BLACS_connection = '%s' % com_port + + if pico_board in self.allowed_boards: + self.pico_board = pico_board + else: + raise LabscriptError(f'Pico board specified not in {self.allowed_boards}') + + # store mode data + self.sweep_mode = sweep_mode + self.ref_clock_frequency = ref_clock_frequency + # Check clocking + if ref_clock_frequency * pll_mult > 500e6: + raise ValueError('DDS system clock frequency must be less than 500 MHz') + elif pll_mult > 1 and ref_clock_frequency * pll_mult < 100e6: + raise ValueError('DDS system clock frequency must be greater than 100 MHz when using PLL') + elif not ref_clock_external and ref_clock_frequency > 133e6: + raise ValueError('ref_clock_frequency must be less than 133 MHz when clock is provided by Pi Pico') + + self.dds_clock = ref_clock_frequency * pll_mult + # define output scale factors for dynamic channels + # static channel scaling handled by firmware + self.freq_scale = 2**32 / self.dds_clock + self.amp_scale = 1023 + self.phase_scale = 16384/360.0 + + # Store number of dynamic channels + if dynamic_channels > 4: + raise ValueError('AD9959DDSSweeper only supports up to 4 total channels, dynamic channels must be 4 or less.') + self.dynamic_channels = dynamic_channels + + IntermediateDevice.__init__(self, name, parent_device, **kwargs) + + @property + def clock_limit(self): + '''Dynamically computs clock limit based off of number of dynamic + channels and reference clock frequency.''' + if self.dynamic_channels == 0: + # No clock limit + return None + + if self.sweep_mode > 0: + mode = 'sweeps' + else: + mode = 'steps' + try: + cycles_per_instruction = self.cycles_per_instruction_map[mode][self.dynamic_channels - 1] + except (KeyError, IndexError): + raise LabscriptError(f'Unsupported mode or number of channels: {mode}, {self.dynamic_channels}') + + return self.ref_clock_frequency / cycles_per_instruction + + def add_device(self, device): + """Confirms channel specified is valid before adding + + Validity checks include channel name and static/dynamic status. + Dynamic channels must be specified before static channels. + Args: + device(): Device to attach. Must be a DDS or a StaticDDS. + Allowed connections are a string of the form `channel X`. + """ + conn = device.connection + chan = int(conn.split('channel ')[-1]) + + if isinstance(device, StaticDDS): + if chan < self.dynamic_channels: + raise LabscriptError(f'Channel {chan} configured as dynamic channel, can not create StaticDDS.') + elif chan >= 4: + raise LabscriptError('AD9959DDSSweeper only supports 4 channels') + elif isinstance(device, DDS): + if chan >= self.dynamic_channels: + raise LabscriptError(f'Channel {chan} not configured as dynamic channel, can not create DDS.') + + super().add_device(device) + + def get_default_unit_conversion_classes(self, device): + """Child devices call this during their __init__ (with themselves + as the argument) to check if there are certain unit calibration + classes that they should apply to their outputs, if the user has + not otherwise specified a calibration class""" + if device.connection in ['channel 0', 'channel 1', 'channel 2', 'channel 3']: + # Default calibration classes for the non-static channels: + return NovaTechDDS9mFreqConversion, NovaTechDDS9mAmpConversion, None + else: + return None, None, None + + def quantise_freq(self, data, device): + """Provides bounds error checking and scales input values to instrument + units (0.1 Hz) before ensuring uint32 integer type.""" + if not isinstance(data, np.ndarray): + data = np.array(data) + # Ensure that frequencies are within bounds: + if np.any(data > self.dds_clock/2.) or np.any(data < 0.0): + raise LabscriptError(f'{device.description:s} {device.name:s} '+ + f'can only have frequencies between 0.0Hz and {self.dds_clock/2e6:.1f} MHz, ' + + f'the limit imposed by {self.name:s}.') + + # It's faster to add 0.5 then typecast than to round to integers first: + data = np.array((self.freq_scale*data)+0.5,dtype=' 1 ) or np.any(data < 0): + raise LabscriptError('%s %s ' % (device.description, device.name) + + 'can only have amplitudes between 0 and 1 (Volts peak to peak approx), ' + + 'the limit imposed by %s.' % self.name) + # It's faster to add 0.5 then typecast than to round to integers first: + data = np.array((self.amp_scale*data)+0.5,dtype=' 0: + mode = 'sweeps' + else: + mode = 'steps' + + for output in self.child_devices: + # Check that the instructions will fit into RAM: + max_instructions = self.max_instructions_map[self.pico_board][mode][num_channels-1] + max_instructions -= 2 # -2 to include space for dummy instructions + if isinstance(output, DDS) and len(output.frequency.raw_output) > max_instructions: + raise LabscriptError( + f'{self.name} can only support {max_instructions} instructions. \ + Please decrease the sample rates of devices on the same clock, \ + or connect {self.name} to a different pseudoclock.') + try: + _, channel = output.connection.split() + channel = int(channel) + assert channel in range(4), 'requested channel out of range' + except Exception: + raise LabscriptError('%s %s has invalid connection string: \'%s\'. ' % (output.description,output.name,str(output.connection)) + + 'Format must be \'channel n\' with n from 0 to 4.') + + # separate dynamic from static + if isinstance(output, DDS): + dyn_DDSs[channel] = output + elif isinstance(output, StaticDDS): + stat_DDSs[channel] = output + + # if no channels are being used, no need to continue + if not dyn_DDSs and not stat_DDSs: + return + + # Ensure data table is constructed in correct order + if dyn_DDSs: + dyn_DDSs = dict(sorted(dyn_DDSs.items())) + if stat_DDSs: + stat_DDSs = dict(sorted(stat_DDSs.items())) + + for connection in dyn_DDSs: + dds = dyn_DDSs[connection] + dds.frequency.raw_output = self.quantise_freq(dds.frequency.raw_output, dds) + dds.phase.raw_output = self.quantise_phase(dds.phase.raw_output, dds) + dds.amplitude.raw_output = self.quantise_amp(dds.amplitude.raw_output, dds) + + dyn_dtypes = {'names':['%s%d' % (k, i) for i in dyn_DDSs for k in ['freq', 'amp', 'phase'] ], + 'formats':[f for i in dyn_DDSs for f in ('