Source code for FIREQ_LL_API.generator_driver

"""Low-level driver for the FIREQ generator IP."""

import numpy as np
from pynq import MMIO

from ._utils import (
    _FIREQDriver,
    _set_bit,
    _set_bits,
)

__all__ = ["GeneratorDriver"]


[docs] class GeneratorDriver(_FIREQDriver): """Driver class for the generator IP. Provides low-level methods to control the generator IP. """ bindto = ["user.org:user:axisGeneratorIP:1.0"] # Offset definition _ctrl = 0 _readout_wave_l = 1 _readout_inc_l = 5 _readout_inc_h = 6 _readout_off_l = 9 _readout_off_h = 10 _drive_inc_l = 7 _drive_inc_h = 8 _trigger_mask_l = 11 # Bit position definition - control register _man_trig_sel = 28 _source_pos = 27 _manual_trigger_pos = 31 _seed_lfsr_pos = 0 # Port name of the fabric clock fabric_clock_port = "HS_axi_clock" def __init__(self, description: dict[str, object]) -> None: """Initialize the GeneratorDriver. :param description: Dictionary containing IP parameters and configuration :type description: dict """ super().__init__(description=description) # set the axi full interface self.axi_full_initialized = False self._envelope_memory_interface = None self._common_envelope_memory_interface = None self._memory_mapped_fifo_interface = None self._wdw_memory_interface = None # address width of the envelope memory, word aligned self.sample_memory_address_width = int(description["parameters"]["SampleMemoryAddressWidth"]) # size of the envelope memory for every channel, in words self.channel_sample_memory_depth = pow(2, self.sample_memory_address_width) # width of the duration self.duration_width = int(description["parameters"]["DurationWidth"]) self.maximum_duration = pow(2, self.duration_width) # fractional precision of the interpolator self.fractional_precision = int(description["parameters"]["IncrementFractionalPrecision"]) # width of samples self.sample_size = int(description["parameters"]["SampleSize"]) # number of channels (parallelism of the generator) self.log_number_of_channels = int(description["parameters"]["LogNsamplesClock"]) self.number_of_channels = pow(2, self.log_number_of_channels) # width of the phase increment and offset self.phase_depth = int(description["parameters"]["PhaseDepth"]) # number of trigger channels self.trigger_channels = int(description["parameters"]["TriggerWordWidth"]) # axi full interface depth in bytes self.axi_full_interface_depth = pow(2, int(description["parameters"]["C_S00_AXI_ADDR_WIDTH"])) # size of axi full segments in bytes self.total_sample_memory_segment_depth = int(description["parameters"]["TotalSampleMemorySegmentDepth"]) self.wave_memory_segment_depth = int(description["parameters"]["WaveMemoryDepth"]) self.memory_mapped_fifo_segment_depth = int(description["parameters"]["MemoryMappedFifoDepth"]) # width of the lfsr seed self.seed_lfsr_width = int(description["parameters"]["MmFifoAndLfsrOutputWidth"]) # number of dac self.num_dacs = int(description["parameters"]["NumDac"]) # set debug level self.debug_level = 0 # this constant is the bit length of the addresses used in the generator dds with taylor compensation self.sample_gen_increment_width = self.sample_memory_address_width + self.fractional_precision # Bit position definition - dac_mask in wdw self.dac_mask_lsb = 2 * self.sample_gen_increment_width + self.duration_width + self.sample_size # this should never happen, but just in case if self.num_dacs < 0 or self.num_dacs > 15: raise ValueError(f"Invalid number of DACs: {self.num_dacs}")
[docs] def init_axi_full_interface(self, base_address: int, axi_depth: int) -> None: """Initialize the AXI Full interface segments and clear memory. :param base_address: Base address of the AXI Full interface :type base_address: int :param axi_depth: Depth of the AXI interface in bytes :type axi_depth: int """ def _next_segment(mmio: MMIO) -> int: return mmio.base_addr + mmio.length self._envelope_memory_interface = MMIO( base_address, self.channel_sample_memory_depth * self.number_of_channels * 4 ) self._common_envelope_memory_interface = MMIO( _next_segment(self._envelope_memory_interface), self.channel_sample_memory_depth * 4 ) self._wdw_memory_interface = MMIO( _next_segment(self._common_envelope_memory_interface), self.wave_memory_segment_depth ) self._memory_mapped_fifo_interface = MMIO( _next_segment(self._wdw_memory_interface), self.memory_mapped_fifo_segment_depth ) self.axi_full_initialized = True self.log.debug("AXI Full interface initialized") # reset envelope dictionary and memory self.clear_envelope_memory()
[docs] def reset_memory_and_registers(self) -> None: """Reset all memory and registers to 0.""" for offset in range(0, self._axi_lite_interface_mmio.length, 4): self._axi_lite_interface_mmio.write(offset, 0) for offset in range(0, self._envelope_memory_interface.length, 4): self._envelope_memory_interface.write(offset, 0) for offset in range(0, self._common_envelope_memory_interface.length, 4): self._common_envelope_memory_interface.write(offset, 0) for offset in range(0, self._wdw_memory_interface.length, 4): self._wdw_memory_interface.write(offset, 0) for offset in range(0, self._memory_mapped_fifo_interface.length, 4): self._memory_mapped_fifo_interface.write(offset, 0)
[docs] def init_axi_lite_interface(self, base_address: int, axi_depth: int) -> None: """Initialize the AXI Lite interface and delete the PYNQ-created MMIO object. :param base_address: Base address of the AXI Lite interface :type base_address: int :param axi_depth: Depth of the AXI interface in bytes :type axi_depth: int """ super().init_axi_lite_interface(base_address, axi_depth) # delete the mmio object created by PYNQ del self.mmio
[docs] def write_envelope_memory(self, start_address: int, envelope: np.ndarray, common: bool = False) -> int: """Write samples to the envelope memory starting at the given address. :param start_address: Start address in the envelope memory (word aligned) :type start_address: int :param envelope: Array of complex samples to write :type envelope: np.ndarray :param common: If True, it write the envelope in all parallelization sub-channels (envelope for interpolation) :type common: bool :return: Error code :rtype: int """ if not self.axi_full_initialized: self.log.error("AXI Full interface not initialized") return -3 if common: size = len(envelope) else: size = int(np.ceil(len(envelope) / self.number_of_channels)) if start_address < 0: self.log.error("start_address %s cannot be negative", start_address) return -3 if start_address + size > self.channel_sample_memory_depth: self.log.error("data exceeds memory bounds") return -3 to_write_array = (envelope.real.astype(np.int32) << 16) + envelope.imag.astype(np.int16) if common: # write the samples to all sub-channels, there is a specific space in the generator memory to do just that self._common_envelope_memory_interface.write(start_address * 4, to_write_array.tobytes()) else: for channel in range(self.number_of_channels): write_address_start = start_address + self.channel_sample_memory_depth * channel to_write_to_channel = to_write_array[channel :: self.number_of_channels] self._envelope_memory_interface.write(write_address_start * 4, to_write_to_channel.tobytes()) self.log.debug( "wrote %s samples to envelope memory at address %s (common: %s)", len(envelope), start_address, common ) return 0
[docs] def clear_envelope_memory(self) -> int: """Clear the envelope memory by writing all zeros to it.""" rval = self.write_envelope_memory(0, np.zeros(self.channel_sample_memory_depth, dtype=complex), common=True) if rval != 0: self.log.error("failed to clear envelope memory") return rval self.log.debug("cleared envelope memory") return 0
[docs] def trigger_manually(self) -> int: """Trigger the generator manually. :return: Error code :rtype: int """ control_register = self._axi_lite_interface_mmio.read(self._ctrl * 4) self._axi_lite_interface_mmio.write(self._ctrl * 4, _set_bit(control_register, self._manual_trigger_pos, 1)) self.log.debug("generation triggered manually") return 0
[docs] def set_trigger_channel(self, channel: int, ttype: str) -> int: """Set the channel where the generator listens for triggers. Set the channel to 0 if you want to disable external triggers. :param channel: Channel number, 1 to trigger_channels :type channel: int :param ttype: trigger type: 'drive' or 'readout' :type ttype: str :return: Error code :rtype: int """ if channel < 0 or channel > self.trigger_channels: self.log.error("channel choice %s is out of range", channel) return -3 if ttype == "drive": offset_bit = 0 elif ttype == "readout": offset_bit = self.trigger_channels else: self.log.error("trigger type %s is out of range", ttype) return -3 # write to the control register trigger_mask = (1 << channel) >> 1 trigger_reg = _set_bits( self._axi_lite_interface_mmio.read(self._trigger_mask_l * 4), offset_bit, self.trigger_channels, trigger_mask, ) self._axi_lite_interface_mmio.write(self._trigger_mask_l * 4, trigger_reg) self.log.debug("set the generation trigger channel to %s for %s", channel, ttype) return 0
[docs] def set_drive_order_source(self, source: str) -> int: """Set the source for the generator: lfsr or fifo. :param source: Source for wave, 'fifo' or 'lfsr' :type source: str :return: Error code :rtype: int """ if source == "fifo": selector = 0 elif source == "lfsr": selector = 1 else: self.log.error("source type %s is out of range", source) return -3 control_register = _set_bit( value=self._axi_lite_interface_mmio.read(self._ctrl * 4), pos=self._source_pos, setvalue=selector, ) self._axi_lite_interface_mmio.write(self._ctrl * 4, control_register) self.log.debug("set generation order source to %s(%s)", source, selector) return 0
[docs] def set_lfsr_seed(self, seed: int) -> int: """Set the seed for lfsr. :param seed: Seed for LFSR :type seed: int :return: Error code :rtype: int """ if seed < 0 or seed > (2**self.seed_lfsr_width - 1): self.log.error("seed choice %s is out of range", seed) return -3 control_register = self._axi_lite_interface_mmio.read(self._ctrl * 4) control_register = _set_bits(control_register, self._seed_lfsr_pos, self.seed_lfsr_width, seed) self._axi_lite_interface_mmio.write(self._ctrl * 4, control_register) self.log.debug("set the lfsr seed to %s", seed) return 0
[docs] def set_readout_modulation_frequency(self, frequency: float) -> int: """Set modulation carrier of readout wave. :param frequency: Frequency of the modulation signal, normalized to the DAC sampling frequency :type frequency: float :return: Error code (0 on success) :rtype: int """ normalized_frequency = frequency % 1.0 # compute the phase increment pinc = int(2**self.phase_depth * normalized_frequency) # write inc LOW self._axi_lite_interface_mmio.write(self._readout_inc_l * 4, pinc & 0xFFFFFFFF) # write inc HIGH self._axi_lite_interface_mmio.write(self._readout_inc_h * 4, pinc >> 32) # log with deferred formatting string arguments to avoid eager evaluation self.log.debug("set readout frequency to %s and phase increment to %s", frequency, pinc) return 0
[docs] def set_readout_modulation_initial_phase(self, phase: float) -> int: """Set readout modulation initial phase. :param phase: Phase offset of the modulation signal, normalized to pi :type frequency: float :return: Error code (0 on success) :rtype: int """ # TODO: check that this is ok even when moving away from the first nyquist zone. # I am unsure if I have to invert the phase or not when in the second nyquist zone. poff = int(2**self.phase_depth * phase) # write inc LOW self._axi_lite_interface_mmio.write(self._readout_off_l * 4, poff & 0xFFFFFFFF) # write inc HIGH self._axi_lite_interface_mmio.write(self._readout_off_h * 4, poff >> 32) self.log.debug("set readout initial phase to %s and phase offset to %s", phase, poff) return 0
[docs] def set_drive_modulation_frequency(self, frequency: float) -> int: """Set the modulation frequency for the drive pulses. :param frequency: Frequency of the modulation signal, normalized to the DAC sampling frequency :type frequency: float :return: Error code (0 on success) :rtype: int """ normalized_frequency = frequency % 1.0 # compute the phase increment pinc = int(2**self.phase_depth * normalized_frequency) # write inc LOW self._axi_lite_interface_mmio.write(self._drive_inc_l * 4, pinc & 0xFFFFFFFF) # write inc HIGH self._axi_lite_interface_mmio.write(self._drive_inc_h * 4, pinc >> 32) # log with deferred formatting string arguments to avoid eager evaluation self.log.debug("set drive frequency to %s and phase increment to %s", frequency, pinc) return 0
[docs] def set_manual_wave_destination(self, destination: str) -> int: """Set the destination of a manually generated wave. When manually triggered, the readout wdw is generated but can be sent to either the drive or readout output channel. This function sets the destination. :param destination: 'readout' or 'drive' :type destination: str :return: Error code :rtype: int """ if destination == "drive": selector = 0 elif destination == "readout": selector = 1 else: self.log.error("destination %s is out of range", destination) return -3 control_register = _set_bit( self._axi_lite_interface_mmio.read(self._ctrl * 4), pos=self._man_trig_sel, setvalue=selector, ) self._axi_lite_interface_mmio.write(self._ctrl * 4, control_register) self.log.debug("set manual wave destination to %s(%s)", destination, selector) return 0
[docs] def add_wave_in_wave_memory(self, wdw: int, wdw_index: int) -> int: """Add a wave definition word to the wave memory. No checks are performed on the wave definition word. The index is the address aligned to a 128 bit boundary. :param wdw: Wave definition word, low level definition of a wave :type wdw: int :param wdw_index: Wave address/index, aligned to 128 bits :type wdw_index: int :return: Error code :rtype: Literal[-3, 0] """ if wdw_index < 0 or wdw_index >= self.wave_memory_segment_depth // (128 // 8): self.log.error("index %s is out of range", wdw_index) return -3 # write to wave memory self._wdw_memory_interface.write(wdw_index * (128 // 8), wdw.to_bytes(128 // 8, "little")) self.log.debug("added wave definition %s to wave memory at index %s", hex(wdw), wdw_index) return 0
[docs] def add_wave_to_drive_wave_sequence(self, order_index: int, wdw_index: int) -> int: """Write to memory mapped FIFO the address of a wdw at a certain point in the generation order. No checks are performed ensurring that the wdw actually exists at that index. :param order_index: 0 refers to the first wdw generated. :type order_index: int :param wdw_index: Index of the wdw in the wave memory. :type wdw_index: int """ if order_index < 0 or order_index >= self.memory_mapped_fifo_segment_depth // 4: self.log.error("order index %s is out of range", order_index) return -3 if wdw_index < 0 or wdw_index >= self.wave_memory_segment_depth // (128 // 8): self.log.error("wdw index %s is out of range", wdw_index) return -3 self._memory_mapped_fifo_interface.write(order_index * 4, wdw_index) self.log.debug("added wave %s to wave sequence at index %s", wdw_index, order_index) return 0
[docs] def write_readout_wave(self, wave_definition: int) -> int | None: """Write the readout wave definition to the IP. This is the wave definition that will be used for manual and readout waves. HINT: for manual waves, you can set the output channel (readout or drive channel) with the "trigger_manuallyDestination" function. :param wave_definition: 128-bit wave defintion :type wave_definition: int :return: Error code :rtype: Literal[-3] | None """ if wave_definition < 0: self.log.error("wave definition %s is out of range", hex(wave_definition)) return -3 for i in range(4): self._axi_lite_interface_mmio.write( (self._readout_wave_l + i) * 4, (wave_definition >> i * 32) & 0xFFFFFFFF ) self.log.debug("wrote the readout wave definition to the IP %s", hex(wave_definition)) return 0
[docs] def build_envelope_specific_wdw( self, forceone: bool, for_interpolation: bool, is_symmetric: bool, i_even: bool, q_even: bool, ) -> int: """Build the envelope specific part of the wdw. :param is_symmetric: If True, the envelope is symmetric :type is_symmetric: bool :param i_even: If True, the I sample symmetry is even :type i_even: bool :param q_even: If True, the Q sample symmetry is even :type q_even: bool :param switch_iq: If True, the I and Q channels are switched :type switch_iq: bool :param forceone: If True, the envelope is forced to be one :type forceone: bool :param for_interpolation: If True, the envelope is supposed to be used with the envelope interpolation feature :type for_interpolation: bool :return: Envelope specific part of the wdw :rtype: int """ wdw = int(is_symmetric) << 127 wdw |= int(i_even) << 126 wdw |= int(q_even) << 125 wdw |= int(forceone) << 121 wdw |= int(for_interpolation) << 120 return wdw
[docs] def build_vz_wdw(self, normalized_phase: float) -> int: """Build the wave definition word for vz gates. :param normalized_phase: Phase of the vz gate normalized to 2pi :type normalized_phase: float :return: Wave definition word :rtype: int """ wdw = 1 << 119 phase = int(normalized_phase * 2 ** (self.phase_depth)) wdw |= phase & (2**self.phase_depth - 1) return wdw
[docs] def build_pulse_wdw( self, envelope_wdw: int, start_address: int, duration: int, natural_duration: int, normalized_gain: float, switch_iq: bool, keep_last: bool, dac_target_mask: int, ) -> int: """Build the wave definition word for pulses. :param envelope_wdw: Envelope specific part of the wdw :type envelope_wdw: int :param start_address: Start address of the envelope in the envelope memory :type start_address: int :param duration: Duration of the pulse in samples :type duration: int :param natural_duration: Natural duration of the pulse in samples :type natural_duration: int :param normalized_gain: Normalized gain of the pulse :type normalized_gain: float :param switch_iq: If True, the I and Q channels are switched :type switch_iq: bool :param keep_last: If True, the last sample is kept :type keep_last: bool :param dac_target_mask: Integer mask that drives the axis crossbar ip :type dac_target_mask: int :return: Wave definition word :rtype: int """ # start with the envelope wdw wdw = envelope_wdw # set the switch iq and keep last bits wdw |= int(switch_iq) << 123 wdw |= int(keep_last) << 122 # set the invert bit if gain is negative if normalized_gain < 0: wdw |= 1 << 124 gain = abs(normalized_gain) if gain >= 1: self.log.warning("Gain amplitude is out of bounds. Gain amplitude was saturated to 1") gain = 2**self.sample_size - 1 # set the gain, cap to 1 if gain was bigger than 1 gain = int(2**self.sample_size * gain) wdw = _set_bits(wdw, 2 * self.sample_gen_increment_width + self.duration_width, self.sample_size, gain) # compute a safe duration and set it dur = duration if duration > self.maximum_duration: self.log.warning("Duration %s is out of range. Saturating to maximum allowed duration", duration) dur = self.maximum_duration wdw = _set_bits(wdw, 2 * self.sample_gen_increment_width, self.duration_width, dur - 1) # compute a safe dac mask and set it dac_mask = dac_target_mask if dac_target_mask < 0 or dac_target_mask > pow(2, self.num_dacs) - 1: self.log.warning("DAC target mask %s is out of range, setting the mask to zero", dac_target_mask) dac_mask = 0 wdw = _set_bits(wdw, self.dac_mask_lsb, self.num_dacs, dac_mask) # compute a set of sample generator offsets start_offset = 0 increment = 0 # this condition checks if the input wdw has the "for_interpolation" bit set if ((wdw >> 120) & 1) != 0: # NOTE (fixed-point interpolation fix): # We compute the fractional address increment as num/den in Q(FractionalPrecision). # Using integer division (//) truncates the ideal increment, introducing a small # quantization error that accumulates along the envelope and biases the last samples. # The remainder (num % den) tells us how far we were from the ideal ratio; by adding # half of it to start_offset we "center" the quantization error, reducing the peak # error at the end without changing the hardware behavior. start_offset = start_address << self.fractional_precision num = (natural_duration - 1) << self.fractional_precision den = (dur - 1) << self.fractional_precision increment = num // den reminder = num % den # shift by half remainder to "center" the error (reduces peak error at the end) start_offset = start_offset + (reminder // 2) else: start_offset = start_address << self.fractional_precision # set the increment to 1/(number_of_channels), usually 1/16 increment = 1 << (self.fractional_precision - self.log_number_of_channels) # set the start offset and increment bits wdw = _set_bits(wdw, self.sample_gen_increment_width, self.sample_gen_increment_width, start_offset) wdw = _set_bits(wdw, 0, self.sample_gen_increment_width, increment) return wdw
[docs] def print_description(self, printer_func: callable) -> None: """Print a detailed description of the generator IP configuration parameters. :param printer_func: Function to use to print the description :type printer_func: callable """ string = ( f"sample_memory_address_width: {self.sample_memory_address_width}, address width of the envelope " "memory (word/IQSample aligned)" ) string += ( f"\nchannel_sample_memory_depth: {self.channel_sample_memory_depth}, depth of the envelope " "memory (words/IQSamples aligned)" ) string += f"\nmaximum_duration: {self.maximum_duration}, maximum duration of a wave (samples)" string += ( f"\nfractional_precision: {self.fractional_precision}, fractional precision of the interpolator (bits)" ) string += f"\nsample_size: {self.sample_size}, width of samples (bits)" string += f"\nnumber_of_channels: {self.number_of_channels}, parallelism of the generator (samples/clock cycle)" string += f"\nphase_depth: {self.phase_depth}, width of phases (bits)" string += ( f"\ntrigger_channels: {self.trigger_channels}, number of trigger channels for readout and drive (bits)" ) string += f"\naxi_full_interface_depth: {self.axi_full_interface_depth}, axi full interface depth (bytes)" string += ( f"\ntotal_sample_memory_segment_depth: {self.total_sample_memory_segment_depth}, envelope memory " "segment depth (bytes)" ) string += f"\nwave_memory_segment_depth: {self.wave_memory_segment_depth}, wave memory segment depth (bytes)" string += ( f"\nmemory_mapped_fifo_segment_depth: {self.memory_mapped_fifo_segment_depth}, memory mapped FIFO " "segment depth (bytes)" ) string += f"\nseed_lfsr_width: {self.seed_lfsr_width}, width of lsfr seed and memory mapped FIFO entries (bits)" printer_func(string)