# -*- coding: utf-8 -*- import types import numpy class StateBase(object): def __init__(self) -> None: pass def to_xml(self, indent: str = "") -> str: return indent + "" class StateSimple(StateBase): def __init__(self, time: float, content: object | None = None) -> None: super(StateSimple, self).__init__() if time < 0: raise AssertionError("time for state is negative!") self.time = float(time) self.content = content def to_xml(self, indent: str = "") -> str: s = indent + '\n' s += '>\n' s += indent + ' ' + str(self.content) + '\n' s += indent + '\n' return s def __repr__(self) -> str: return 'StateSimple(%s, %s)' % (self.time, repr(self.content)) class StateList(StateBase): def __init__(self) -> None: super(StateList, self).__init__() self.list: list[object] = [] def to_xml(self, indent: str = " ") -> str: s = "" for k in self.list: if hasattr(k, "to_xml"): s += k.to_xml(indent) else: s += indent + str(k) return s def append(self, val: object) -> None: self.list.append(val) class StateLoop(StateList): """Represents a loop in the state tree""" def __init__(self, repeat: int) -> None: super(StateLoop, self).__init__() self.repeat = repeat def to_xml(self, indent: str = "") -> str: s = indent + ('\n' % self.repeat) s += super(StateLoop, self).to_xml(indent + " ") s += indent + '\n' return s def __repr__(self) -> str: return 'StateLoop(repeat=%d, %s)' \ % (self.repeat, repr(self.list)) ############################################################# # # # Class: Experiment # # # # Purpose: Represents one full experiment (one program on # # the pulse-card; one file) # # # ############################################################# from . import dac class Experiment: """ Class holding the complete state tree for a single experiment. This state tree represents one program on the pulse card. It is written in a single file and picked up by the backend. :param Gating gating: gate length in s :param list rf_sources: list of rf sources ttl channels :param list rf_gates: list of rf gates ttl channels len(rf_gates) == len(rf_sources) """ job_id: int = 0 def __init__(self, gating: float | None = None, rf_sources: list[int] = [], rf_gates: list[int] = []) -> None: self.job_id: int = Experiment.job_id Experiment.job_id += 1 self.state_list: StateList = StateList() self.list_stack: list[StateList] = [] self.description: dict[str, object] = { } self.gating: float | None = gating assert type(rf_sources) == type(list()), "rf_sources needs to be a list with the channels" assert type(rf_gates) == type(list()), "rf_gates needs to be a list with the channels" assert len(rf_gates) == len(rf_sources), "rf_sources and rf_gates must have equal number of entries" self.rf_sources: list[int] = rf_sources self.rf_gates: list[int] = rf_gates #for tracking the experiment length: #because loops are possible we need to track the length for each loop level self.total_time: list[float] = [0.0] #and we need to know the number of iterations of the loops to multiply the state. self.loop_iterations: list[int] = [1] # Commands ------------------------------------------------------------------------------------- def ttl_pulse(self, length: float, channel: int | None = None, value: int | None = None) -> None: """ Creates a state with length **length** and switches requested bits of the pulse programmer to HIGH. This command generates a TTL pulse with the specified duration on a specific channel or multiple channels. Parameters: ----------- length : float Pulse length in seconds. channel : int, optional Selects a single channel (No. 1 - 24). If provided, the value is calculated as 2^channel. value : int, optional Lines to set (integer). For example, value=3 selects channels 0 and 1 (2**0 + 2**1). If both channel and value are None, the pulse is set to 0. Examples: --------- >>> e.ttl_pulse(length=5e-6, channel=1) Creates a 5 microsecond pulse on channel 1. >>> e.ttl_pulse(length=3e-6, value=3) Creates a 3 microsecond pulse on channels 0 and 1. >>> e.ttl_pulse(length=1e-6, value=0xffffff) Creates a 1 microsecond pulse on all channels. Notes: ------ Hexadecimal representation (number starts with *0x*) is convenient as the numbers are shorter. To set all channels, value would be in decimal 16777215 and in hexadecimal 0xffffff. One letter in hexadecimal represents four bits. See Also: --------- ttls : Same as ttl_pulse, but no channel keyword. """ the_value=0 if value is not None: the_value=int(value) elif channel is not None: the_value=1<' % the_value)) self.total_time[-1] += length ## Same as ttl_pulse, but no *channel* keyword def ttls(self, length: float | None = None, value: int | None = None) -> None: """ Same as ttl_pulse, but no *channel* keyword :param float length: :param int value: lines to set (integer) :return: """ assert value is not None, "ttls needs an integer value" the_value=int(value) s_content = '' % the_value if length is not None: self.state_list.append(StateSimple(length, s_content)) self.total_time[-1] += length else: self.state_list.append(s_content) def rf_pulse(self, length: float | None = None, phase: float = 0, source: int = 0) -> None: """ Make an rf pulse, including gating and phase switching. Only possible if Experiment is configured. :param float length: pulse length :param float phase: pulse phase :param int source: source id """ if not self.gating: raise SyntaxError("Can not use rf_pulse without configuration: Experiment(gating=None, rf_sources=[], rf_gates=[]") self.set_phase(phase, ttls=self.rf_gates[source]) self.ttls(length=self.gating-0.5e-6,value=self.rf_gates[source]) self.ttls(length=length, value=self.rf_gates[source]+self.rf_sources[source]) ## Beginning of a new state def state_start(self, time: float) -> None: """ Starts a state in the pulse programs with duration *time*. This must be closed with :func:`state_end`. Typically only used in very special cases. """ self.state_list.append('\n' % repr(float(time))) self.total_time[-1] += time ## End of *state_start* def state_end(self) -> None: """ Closes a previous :func:`state_start` """ self.state_list.append('\n') def wait(self, time: float, ttls: int | None = None, gating: bool = False) -> None: """ Waits for a specified amount of time without performing any actions. This command inserts a delay in the pulse sequence, allowing time for relaxation, or synchronization with other events. Parameters: ----------- time : float Time to wait in seconds. The minimum time is 90 ns, and the maximum is essentially unlimited (years). The backend driver circumvents the limit imposed by the pulse programmer by adding loops. ttls : int, optional Additional TTL lines to set (integer) during the wait period. Allows simultaneous control of TTL lines while waiting. For example, ttls=3 activates channels 0 and 1 (2^0 + 2^1). gating : bool, optional If True, reduces the wait time by the gating time (typically 2 µs). This is useful for waiting in front of an rf_pulse to account for gating delays. Default is False. Returns: -------- None Examples: --------- >>> e.wait(time=2e-3) Waits for 2 milliseconds. >>> e.wait(time=1e-6, ttls=3) Waits for 1 microsecond while activating TTL channels 0 and 1. >>> e.wait(time=5e-6, gating=True) Waits for 5 microseconds, reduced by the gating time for rf_pulse synchronization. """ if gating: assert self.gating is not None, "gating time must be set for gating=True" time -= self.gating if ttls is not None: s_content = '' % ttls self.state_list.append(StateSimple(time,s_content)) else: self.state_list.append(StateSimple(time)) self.total_time[-1] += time def record(self, samples: int, frequency: float, timelength: float | None = None, sensitivity: float | list[float] | None = None, ttls: int | None = None, channels: int = 3, offset: int | list[int] | None = None, impedance: float | list[float] | None = None) -> None: """ Records data with a given number of samples, sampling frequency, and sensitivity. This command starts data acquisition from the ADC (Analog-to-Digital Converter). Parameters: ----------- samples : int Number of samples to record. This determines the number of data points in the resulting signal. frequency : float Sampling frequency in Hz. This determines how often samples are taken from the analog signal. The maximum sampling frequency is 20 MHz. timelength : float, optional Length of this state in seconds. If not specified (None), it is calculated automatically as samples/frequency. This parameter allows overriding the automatic calculation for special timing requirements. sensitivity : float or list, optional Sensitivity in U_MAX/V. Specifies the input voltage range for the ADC. Accepted values are 0.2, 0.5, 1, 2, 5, and 10 V. Can be a single value (applied to all channels) or a list of values (one per channel). ttls : int, optional Additional TTL lines to set (integer) during recording. Allows simultaneous control of TTL lines. For example, ttls=3 activates channels 0 and 1 (2^0 + 2^1). channels : int, optional Channels to activate. Default is 3 (channels 0 and 1). Accepted values are 1 (channel 0), 3 (channels 0 and 1), 5 (channels 0, 1, and 2), and 15 (all 4 channels). offset : int or list, optional Voltage offset for the ADC input. Can be a single integer value (applied to all channels) or a list of values (one per channel). Normally not used. impedance : float or list, optional Input impedance for the ADC. Can be a single number (applied to all channels) or a list of values (one per channel). Normally set in the backend config and not changeable. Returns: -------- None Examples: --------- >>> e.record(samples=1024, frequency=2e6, sensitivity=2) Records a signal with 1024 data points and 2 MHz sampling frequency. The sensitivity will be ±2 V, providing a resolution of 0.2 mV with a 14-bit ADC card. >>> e.record(samples=4096, frequency=10e6, sensitivity=[1, 2], channels=3) Records a signal with 4096 data points and 10 MHz sampling frequency. Channel 0 uses 1 V sensitivity, and channel 1 uses 2 V sensitivity. >>> e.record(samples=2048, frequency=5e6, ttls=3) Records a signal with 2048 data points and 5 MHz sampling frequency. Simultaneously activates TTL channels 0 and 1. Notes: ------ - Multiple record statements can be in a single scan (gated sampling) or in a loop. - The onboard memory can hold 8M samples shared by all channels (depends on the board). - The sensitivity setting affects the resolution of the ADC. - The actual timing may vary slightly due to hardware limitationslike pre- and post-trigger delays. """ attributes='s="%d" f="%d"'%(samples,frequency)#%g if channels != 1 and channels != 3 and channels != 5 and channels != 15: raise ValueError("Channel definition is illegal") attributes += ' channels="%i"'%(channels) nchannels = 0 if channels == 1: nchannels = 1 elif channels == 3 or channels == 5: nchannels = 2 elif channels == 15: nchannels = 4 if sensitivity is not None: # float values are allowed and applied to all channels if isinstance(sensitivity, float) or isinstance(sensitivity, int): for i in range(nchannels): attributes +=' sensitivity%i="%f"'%(i, float(sensitivity)) else: for i in range(nchannels): attributes +=' sensitivity%i="%f"'%(i, sensitivity[i]) if offset is not None: # int values are allowed and applied to all channels if isinstance(offset, int): for i in range(nchannels): attributes +=' offset%i="%f"'%(i, offset) else: for i in range(nchannels): attributes +=' offset%i="%f"'%(i, offset[i]) if impedance is not None: # float values are allowed and applied to all channels if isinstance(impedance, float): for i in range(nchannels): attributes += ' impedance%i="%i"'%(i, impedance) else: for i in range(nchannels): attributes += ' impedance%i="%i"'%(i, impedance[i]) s_content = '' % attributes if ttls is not None: s_content+='' % ttls if timelength is None: timelength = samples / float(frequency)#*1.01 self.state_list.append(StateSimple(timelength, s_content)) self.total_time[-1] += timelength ## Create a loop on the pulse programmer. Loop contents can not change inside the loop. # @params iterations Number of loop iterations def loop_start(self, iterations: int) -> None: """ creates a loop of given number of iterations and has to be closed by loop_end(). Commands inside the loop can not change, i.e., the parameters are the same for each loop run. This loop is created on the pulse programmer, thus saving commands. Close the loop with :func:`loop_end`. :param int iterations: number of iterations to loop """ l = StateLoop(iterations) self.state_list.append(l) # (These two lines could probably be guarded by a mutex) self.list_stack.append(self.state_list) self.state_list = l self.total_time.append(0.0) self.loop_iterations.append(iterations) ## End loop state def loop_end(self) -> None: """ Closes a l:func:`loop_start` statement. """ # (This line could probably be guarded by a mutex) self.state_list = self.list_stack.pop(-1) looptime=self.total_time.pop(-1) loopiterations=self.loop_iterations.pop(-1) self.total_time[-1] += looptime*loopiterations def set_frequency(self, frequency: float, phase: float, ttls: int = 0) -> None: """ Sets the frequency and phase of the frequency source and optionally further channels. The state length is 2 µs. Stabilisation time depends on RF source. :param float frequency: frequency to set in Hz :param float phase: phase to set, normalized to the range [0, 360) :param int ttls: default=0, additional ttl lines to set (integer) """ s_content = '' % (frequency, phase % 360) if ttls != 0: s_content += '' % ttls self.state_list.append(StateSimple(2e-6, s_content)) self.total_time[-1] += 2e-6 def set_pfg(self, dac_value: int | None = None, length: float | None = None, shape: tuple[str, float] = ('rec', 0), trigger: int = 4, is_seq: bool = False) -> None: """ This sets the value for the PFG, it also sets it back to dac_value=0 automatically. If you don't wish to do so (i.e. line shapes) set is_seq=1 If you want to set a trigger, set trigger (default=4, i.e. channel 2) If you want shaped gradients: shape=(ashape, resolution), ashape can be rec, sin2, sin The default DAC ID is hardcoded (id=1)! :param int dac_value: DAC value to set :param float length: Duration of the state, minimum length is 42*90ns=3.78us (default) :param tuple shape: Tuple of (shape, resolution/seconds), shape can be one of: rec (default), sin2, sin :param bool is_seq: If set to *True*, do *NOT* set DAC to zero at the end of this state :param int trigger: default=4, lines to set (integer) """ try: form, resolution = shape except: raise SyntaxError("shape argument needs to be a tuple, i.e. ('shape',resolution), shape can be sin, sin2, rec") if length == None: # mimimum length length=42*9e-8 if resolution >= length: raise ValueError("Resolution %.3e of shaped gradients can not be longer than total length %.3e"%(resolution, length)) if resolution < 42*9e-8: raise ValueError("Resulution %.3e can not be smaller than %.3e"%(resolution, 42*9e-8)) t_steps = numpy.arange(0,length,resolution) if form == 'rec': # shape==None --> rectangular gradients s_content = '' % (trigger, dac_value) self.state_list.append(StateSimple(length, s_content)) self.total_time[-1] += length if not is_seq: s_content = '' self.state_list.append(StateSimple(42*9e-8, s_content)) self.total_time[-1] += 42*9e-8 elif form == 'sin2': # sin**2 shape for t in t_steps: dac = int (dac_value*numpy.sin(numpy.pi/length*t)**2) s_content = '' % (trigger, dac) self.state_list.append(StateSimple(resolution, s_content)) # set it back to zero s_content = '' % (trigger) self.state_list.append(StateSimple(resolution, s_content)) self.total_time[-1] += resolution*(len(t_steps)+1) elif form == 'sin': # sin shape for t in t_steps: dac = int (dac_value*numpy.sin(numpy.pi/length*t)) s_content = '' % (trigger, dac) self.state_list.append(StateSimple(resolution, s_content)) # set it back to zero s_content = '' % (trigger) self.state_list.append(StateSimple(resolution, s_content)) self.total_time[-1] += resolution*(len(t_steps)+1) else: # don't know what to do raise SyntaxError("form is unknown: %s"%form) def set_dac(self, dac_value: int, dac_id: int = 1, length: float | None = None, is_seq: bool = False, ttls: int = 0) -> None: """ Sets the value for the DAC (Digital-to-Analog Converter) and optionally additional TTL lines. This command is used to control analog output signals, such as pulsed field gradients. Parameters: ----------- dac_value : int DAC value, between -2**19-1 (-524287) and +2**19 (524288). This value determines the analog output voltage. dac_id : int, optional Specifies which DAC to control. Default is 1. This allows control of multiple DAC channels if available. length : float, optional Length of this state in seconds. Default is None, which sets the length to 42*90ns=3.78µs. If is_seq is False, the total length is doubled due to the automatic reset to zero. is_seq : bool, optional If True, the DAC value is not reset to zero after the pulse. Default is False. Use is_seq=True for creating line shapes or sequential pulses. ttls : int, optional Lines to set (integer) for TTL output. Default is 0. Allows simultaneous control of TTL lines along with the DAC. Returns: -------- None Examples: --------- >>> e.set_dac(dac_value=15040, dac_id=1, length=1e-3) Sets DAC channel 1 to value 15040 for 1 millisecond and then resets to zero. >>> e.set_dac(dac_value=10000, is_seq=True) Sets DAC to value 10000 for 3.78µs without resetting to zero. >>> e.set_dac(dac_value=20000, ttls=3) Sets DAC to value 20000 and activates TTL channel 1 (2^1 + 2^0 = 3). Notes: ------ - The minimum state length is 3.78 µs when is_seq=True. - When is_seq=False, the DAC is automatically reset to zero, adding another 3.78 µs to the total length. - The actual length may be longer than specified if additional time is required for DAC settling. """ if length==None: length=42*9e-8 s_content = '' \ % (dac_id, dac_value, ttls) self.state_list.append(StateSimple(length, s_content)) self.total_time[-1] += length if not is_seq: s_content = '' \ % (dac_id, ttls) self.state_list.append(StateSimple(42*9e-8, s_content)) self.total_time[-1] += 42*9e-8 def set_phase(self, phase: float, ttls: int = 0) -> None: """ Sets the phase of the RF source to the specified value. This command changes the phase of the frequency source. The phase is given in degrees and is relative to the phase at the beginning of the experiment. Parameters: ----------- phase : float Phase to set in degrees. This value determines the phase of the RF signal. ttls : int, optional Lines to set (integer) for TTL output. Default is 0. Allows simultaneous control of TTL lines along with setting the phase. Returns: -------- None Examples: --------- >>> e.set_phase(phase=90) Sets the receiver phase to 90 degrees. >>> e.set_phase(phase=180, ttls=3) Sets the phase to 180 degrees and activates TTL channels 0 and 1. >>> e.set_phase(phase=45) Sets the phase to 45 degrees. Notes: ------ - The state length for this command is 0.5 µs. - The stabilization time for the phase change is RF source dependent. A typical value for PTS310 is 2 µs. - The phase is relative to the initial phase set at start of the experiment. - Negative phases are normalized to the range [0, 360) before writing the job file. """ s_content = '' % (phase % 360) if ttls!=0: s_content += '' % ttls self.state_list.append(StateSimple(0.5e-6, s_content)) self.total_time[-1] += 0.5e-6 def set_description(self, key: str, value: object) -> None: """ Sets a description key-value pair in the experiment description dictionary. This command creates an entry with the specified key and value in the description dictionary. In case of data being stored in a HDF5 file, this dictionary is stored as well, allowing parameter passing between the experiment script and the result script. Parameters: ----------- key : str The key identifier for the description entry. This will be used to retrieve the value later. value : any The value to be associated with the key. Can be of any type (string, number, list, etc.). Returns: -------- None Examples: --------- >>> e.set_description(key="tau", value=2e-3) Sets the description entry with key "tau" to the value 0.002 seconds. >>> e.set_description(key="temperature", value=298.15) Sets the description entry with key "temperature" to the value 298.15 Kelvin. >>> e.set_description(key="pulse_sequence", value="CPMG") Sets the description entry with key "pulse_sequence" to the string "CPMG". Notes: ------ If the key already exists in the description dictionary, a warning message will be printed indicating that the existing value will be overwritten. """ if key in list(self.description.keys()): print('Warning: Overwriting existing description "%s" = "%s" with "%s"' % (key, self.description[key], value)) self.description[key] = value def set_pts_local(self) -> None: """ Sets the PTS310/PTS500 frequency source to local mode. State length is 2 µs. """ self.state_list.append(StateSimple(1e-6, '')) self.state_list.append(StateSimple(1e-6, '')) self.total_time[-1] += 2e-6 # / Commands ----------------------------------------------------------------------------------- # Public Methods ------------------------------------------------------------------------------- def get_job_id(self) -> int: """ Returns the job-id of the current experiment :returns: job_id """ return self.job_id def write_xml_string(self) -> str: """ Returns the current program as a string :returns: XML string """ # Standart XML-Kopf xml_string = '\n' # Experiment-Start-Tag einfügen xml_string += '\n' % self.job_id # Descriptions einfügen if len(self.description)==0: xml_string += ' \n' else: xml_string += ' \n' for key,value in self.description.items(): type_string="repr" if value is None: type_string="None" value="" elif type(value) is float or isinstance(value, numpy.floating): type_string="Float" value=repr(float(value)) elif type(value) is int or isinstance(value, numpy.integer): type_string="Int" value=repr(int(value)) elif type(value) is int: type_string="Long" value=repr(value) elif type(value) is complex or isinstance(value, numpy.complexfloating): type_string="Complex" value=repr(complex(value)) elif type(value) is bool or isinstance(value, numpy.bool_): type_string="Boolean" value=repr(bool(value)) elif type(value) in (str,): type_string="String" else: value=repr(value) xml_string += ' %s\n'%(key, type_string ,value) xml_string += " \n" # Experiment-Inhalt einfügen xml_string += self.state_list.to_xml(indent = " ") # Experiment-End-Tag xml_string += '\n' return xml_string def write_quit_job(self) -> str: """ Returns a xml quit-job :returns: XML quit-job """ return '\n' def get_length(self) -> float: timelength = 0.0 #calculate the correct timelength also for unclosed loops, if there is no unclosed loop #the timelength of this experiment is self.total_time[-1]. for i in range(len(self.total_time)): timelength += self.total_time[i] timelength *= self.loop_iterations[i] return timelength class Quit(Experiment): def write_xml_string(self) -> str: return '\n'%self.job_id # /Public Methods ------------------------------------------------------------------------------ def self_test() -> None: e = Experiment() e.set_description("key", "value") e.set_frequency(85e6, 90, ttls=16) e.wait(1e-6) #e.rf_pulse(1, 1e-6/3) # val = 1 e.ttl_pulse(1e-6/3, 1) # val = 2 e.ttl_pulse(1e-6/3, None, 7) # val = 7 if True: e.loop_start(30) e.set_pfg(dac_value=1024, length=5e-6, is_seq = True, shape=('rec', 42*9e-8)) e.loop_start(400) e.set_phase(270, ttls = 32) e.loop_end() e.ttl_pulse(5e-6, channel = 6) e.loop_end() else: l = StateLoop(3) l.append(StateSimple(5e-6, '')) e.state_list.append(l) e.set_dac(12345, dac_id=2, is_seq = True, ttls=16) e.record(1024, 20e6) try: e.wait(-1) except AssertionError: pass else: raise AssertionError("An exception should happen") e.set_pts_local() print(e.write_xml_string()) print(e.get_length()) if __name__ == '__main__': self_test()