def _set_setpoints(self, names, values): kwargs = dict(zip(names, np.atleast_1d(values))) set_point = FieldVector() set_point.copy(self._set_point) if len(kwargs) == 3: set_point.set_vector(**kwargs) else: set_point.set_component(**kwargs) self._adjust_child_instruments(set_point.get_components("x", "y", "z")) self._set_point = set_point
def _set_target(self, coordinate: str, target: float) -> None: """ The function to set a target value for a coordinate, i.e. the set_cmd for the XXX_target parameters """ # first validate the new target valid_vec = FieldVector() valid_vec.copy(self._target_vector) valid_vec.set_component(**{coordinate: target}) components = valid_vec.get_components('x', 'y', 'z') if not self._field_limits(*components): raise ValueError(f'Cannot set {coordinate} target to {target}, ' 'that would violate the field_limits. ') # update our internal target cache self._target_vector.set_component(**{coordinate: target}) # actually assign the target on the slaves cartesian_targ = self._target_vector.get_components('x', 'y', 'z') for targ, slave in zip(cartesian_targ, self.submodules.values()): slave.field_target(targ)
class AMI430_3D(Instrument): def __init__(self, name, instrument_x, instrument_y, instrument_z, field_limit, **kwargs): super().__init__(name, **kwargs) if not isinstance(name, str): raise ValueError("Name should be a string") instruments = [instrument_x, instrument_y, instrument_z] if not all( [isinstance(instrument, AMI430) for instrument in instruments]): raise ValueError("Instruments need to be instances " "of the class AMI430") self._instrument_x = instrument_x self._instrument_y = instrument_y self._instrument_z = instrument_z if repr(field_limit).isnumeric() or isinstance(field_limit, collections.Iterable): self._field_limit = field_limit else: raise ValueError("field limit should either be" " a number or an iterable") self._set_point = FieldVector(x=self._instrument_x.field(), y=self._instrument_y.field(), z=self._instrument_z.field()) # Get-only parameters that return a measured value self.add_parameter('cartesian_measured', get_cmd=partial(self._get_measured, 'x', 'y', 'z'), unit='T') self.add_parameter('x_measured', get_cmd=partial(self._get_measured, 'x'), unit='T') self.add_parameter('y_measured', get_cmd=partial(self._get_measured, 'y'), unit='T') self.add_parameter('z_measured', get_cmd=partial(self._get_measured, 'z'), unit='T') self.add_parameter('spherical_measured', get_cmd=partial(self._get_measured, 'r', 'theta', 'phi'), unit='T') self.add_parameter('phi_measured', get_cmd=partial(self._get_measured, 'phi'), unit='deg') self.add_parameter('theta_measured', get_cmd=partial(self._get_measured, 'theta'), unit='deg') self.add_parameter('field_measured', get_cmd=partial(self._get_measured, 'r'), unit='T') self.add_parameter('cylindrical_measured', get_cmd=partial(self._get_measured, 'rho', 'phi', 'z'), unit='T') self.add_parameter('rho_measured', get_cmd=partial(self._get_measured, 'rho'), unit='T') # Get and set parameters for the set points of the coordinates self.add_parameter('cartesian', get_cmd=partial(self._get_setpoints, 'x', 'y', 'z'), set_cmd=self._set_cartesian, unit='T', vals=Anything()) self.add_parameter('x', get_cmd=partial(self._get_setpoints, 'x'), set_cmd=self._set_x, unit='T', vals=Numbers()) self.add_parameter('y', get_cmd=partial(self._get_setpoints, 'y'), set_cmd=self._set_y, unit='T', vals=Numbers()) self.add_parameter('z', get_cmd=partial(self._get_setpoints, 'z'), set_cmd=self._set_z, unit='T', vals=Numbers()) self.add_parameter('spherical', get_cmd=partial(self._get_setpoints, 'r', 'theta', 'phi'), set_cmd=self._set_spherical, unit='tuple?', vals=Anything()) self.add_parameter('phi', get_cmd=partial(self._get_setpoints, 'phi'), set_cmd=self._set_phi, unit='deg', vals=Numbers()) self.add_parameter('theta', get_cmd=partial(self._get_setpoints, 'theta'), set_cmd=self._set_theta, unit='deg', vals=Numbers()) self.add_parameter('field', get_cmd=partial(self._get_setpoints, 'r'), set_cmd=self._set_r, unit='T', vals=Numbers()) self.add_parameter('cylindrical', get_cmd=partial(self._get_setpoints, 'rho', 'phi', 'z'), set_cmd=self._set_cylindrical, unit='tuple?', vals=Anything()) self.add_parameter('rho', get_cmd=partial(self._get_setpoints, 'rho'), set_cmd=self._set_rho, unit='T', vals=Numbers()) def _verify_safe_setpoint(self, setpoint_values): if repr(self._field_limit).isnumeric(): return np.linalg.norm(setpoint_values) < self._field_limit answer = any([ limit_function(*setpoint_values) for limit_function in self._field_limit ]) return answer def _set_fields(self, values): """ Set the fields of the x/y/z magnets. This function is called whenever the field is changed and performs several safety checks to make sure no limits are exceeded. Args: values (tuple): a tuple of cartesian coordinates (x, y, z). """ log.debug("Checking whether fields can be set") # Check if exceeding the global field limit if not self._verify_safe_setpoint(values): raise ValueError("_set_fields aborted; field would exceed limit") # Check if the individual instruments are ready for name, value in zip(["x", "y", "z"], values): instrument = getattr(self, "_instrument_{}".format(name)) if instrument.ramping_state() == "ramping": msg = '_set_fields aborted; magnet {} is already ramping' raise AMI430Exception(msg.format(instrument)) # Now that we know we can proceed, call the individual instruments log.debug("Field values OK, proceeding") for operator in [np.less, np.greater]: # First ramp the coils that are decreasing in field strength. # This will ensure that we are always in a safe region as # far as the quenching of the magnets is concerned for name, value in zip(["x", "y", "z"], values): instrument = getattr(self, "_instrument_{}".format(name)) current_actual = instrument.field() # If the new set point is practically equal to the # current one then do nothing if np.isclose(value, current_actual, rtol=0, atol=1e-8): continue # evaluate if the new set point is smaller or larger # than the current value if not operator(abs(value), abs(current_actual)): continue instrument.set_field(value, perform_safety_check=False) def _request_field_change(self, instrument, value): """ This method is called by the child x/y/z magnets if they are set individually. It results in additional safety checks being performed by this 3D driver. """ if instrument is self._instrument_x: self._set_x(value) elif instrument is self._instrument_y: self._set_y(value) elif instrument is self._instrument_z: self._set_z(value) else: msg = 'This magnet doesnt belong to its specified parent {}' raise NameError(msg.format(self)) def _get_measured(self, *names): x = self._instrument_x.field() y = self._instrument_y.field() z = self._instrument_z.field() measured_values = FieldVector(x=x, y=y, z=z).get_components(*names) # Convert angles from radians to degrees d = dict(zip(names, measured_values)) # Do not do "return list(d.values())", because then there is # no guaranty that the order in which the values are returned # is the same as the original intention return_value = [d[name] for name in names] if len(names) == 1: return_value = return_value[0] return return_value def _get_setpoints(self, *names): measured_values = self._set_point.get_components(*names) # Convert angles from radians to degrees d = dict(zip(names, measured_values)) return_value = [d[name] for name in names] # Do not do "return list(d.values())", because then there is # no guarantee that the order in which the values are returned # is the same as the original intention if len(names) == 1: return_value = return_value[0] return return_value def _set_cartesian(self, values): x, y, z = values self._set_point.set_vector(x=x, y=y, z=z) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_x(self, x): self._set_point.set_component(x=x) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_y(self, y): self._set_point.set_component(y=y) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_z(self, z): self._set_point.set_component(z=z) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_spherical(self, values): r, theta, phi = values self._set_point.set_vector(r=r, theta=theta, phi=phi) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_r(self, r): self._set_point.set_component(r=r) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_theta(self, theta): self._set_point.set_component(theta=theta) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_phi(self, phi): self._set_point.set_component(phi=phi) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_cylindrical(self, values): rho, phi, z = values self._set_point.set_vector(rho=rho, phi=phi, z=z) self._set_fields(self._set_point.get_components("x", "y", "z")) def _set_rho(self, rho): self._set_point.set_component(rho=rho) self._set_fields(self._set_point.get_components("x", "y", "z"))
class MercuryiPS(VisaInstrument): """ Driver class for the QCoDeS Oxford Instruments MercuryiPS magnet power supply """ def __init__(self, name: str, address: str, visalib=None, field_limits: Optional[Callable[[float, float, float], bool]] = None, **kwargs) -> None: """ Args: name: The name to give this instrument internally in QCoDeS address: The VISA resource of the instrument. Note that a socket connection to port 7020 must be made visalib: The VISA library to use. Leave blank if not in simulation mode. field_limits: A function describing the allowed field range (T). The function shall take (x, y, z) as an input and return a boolean describing whether that field value is acceptable. """ if field_limits is not None and not (callable(field_limits)): raise ValueError('Got wrong type of field_limits. Must be a ' 'function from (x, y, z) -> Bool. Received ' f'{type(field_limits)} instead.') if visalib: visabackend = visalib.split('@')[1] else: visabackend = 'NI' # ensure that a socket is used unless we are in simulation mode if not address.endswith('SOCKET') and visabackend != 'sim': raise ValueError('Incorrect VISA resource name. Must be of type ' 'TCPIP0::XXX.XXX.XXX.XXX::7020::SOCKET.') super().__init__(name, address, terminator='\n', visalib=visalib, **kwargs) # to ensure a correct snapshot, we must wrap the get function self.IDN.get = self.IDN._wrap_get(self._idn_getter) self.firmware = self.IDN()['firmware'] # TODO: Query instrument to ensure which PSUs are actually present for grp in ['GRPX', 'GRPY', 'GRPZ']: psu_name = grp psu = MercurySlavePS(self, psu_name, grp) self.add_submodule(psu_name, psu) self._field_limits = (field_limits if field_limits else lambda x, y, z: True) self._target_vector = FieldVector(x=self.GRPX.field(), y=self.GRPY.field(), z=self.GRPZ.field()) for coord, unit in zip( ['x', 'y', 'z', 'r', 'theta', 'phi', 'rho'], ['T', 'T', 'T', 'T', 'degrees', 'degrees', 'T']): self.add_parameter(name=f'{coord}_target', label=f'{coord.upper()} target field', unit=unit, get_cmd=partial(self._get_component, coord), set_cmd=partial(self._set_target, coord)) self.add_parameter(name=f'{coord}_measured', label=f'{coord.upper()} measured field', unit=unit, get_cmd=partial(self._get_measured, [coord])) self.add_parameter(name=f'{coord}_ramp', label=f'{coord.upper()} ramp field', unit=unit, docstring='A safe ramp for each coordinate', get_cmd=partial(self._get_component, coord), set_cmd=partial(self._set_target_and_ramp, coord, 'safe')) if coord in ['r', 'theta', 'phi', 'rho']: self.add_parameter( name=f'{coord}_simulramp', label=f'{coord.upper()} ramp field', unit=unit, docstring='A simultaneous blocking ramp for a ' 'combined coordinate', get_cmd=partial(self._get_component, coord), set_cmd=partial(self._set_target_and_ramp, coord, 'simul_block')) # FieldVector-valued parameters # self.add_parameter(name="field_target", label="target field", unit="T", get_cmd=self._get_target_field, set_cmd=self._set_target_field) self.add_parameter(name="field_measured", label="measured field", unit="T", get_cmd=self._get_field) self.add_parameter(name="field_ramp_rate", label="ramp rate", unit="T/s", get_cmd=self._get_ramp_rate, set_cmd=self._set_ramp_rate) self.connect_message() def _get_component(self, coordinate: str) -> float: return self._target_vector.get_components(coordinate)[0] def _get_target_field(self) -> FieldVector: return FieldVector( **{coord: self._get_component(coord) for coord in 'xyz'}) def _get_ramp_rate(self) -> FieldVector: return FieldVector( x=self.GRPX.field_ramp_rate(), y=self.GRPY.field_ramp_rate(), z=self.GRPZ.field_ramp_rate(), ) def _set_ramp_rate(self, rate: FieldVector) -> None: self.GRPX.field_ramp_rate(rate.x) self.GRPY.field_ramp_rate(rate.y) self.GRPZ.field_ramp_rate(rate.z) def _get_measured(self, coordinates: List[str]) -> Union[float, List[float]]: """ Get the measured value of a coordinate. Measures all three fields and computes whatever coordinate we asked for. """ meas_field = FieldVector(x=self.GRPX.field(), y=self.GRPY.field(), z=self.GRPZ.field()) if len(coordinates) == 1: return meas_field.get_components(*coordinates)[0] else: return meas_field.get_components(*coordinates) def _get_field(self) -> FieldVector: return FieldVector(x=self.x_measured(), y=self.y_measured(), z=self.z_measured()) def _set_target(self, coordinate: str, target: float) -> None: """ The function to set a target value for a coordinate, i.e. the set_cmd for the XXX_target parameters """ # first validate the new target valid_vec = FieldVector() valid_vec.copy(self._target_vector) valid_vec.set_component(**{coordinate: target}) components = valid_vec.get_components('x', 'y', 'z') if not self._field_limits(*components): raise ValueError(f'Cannot set {coordinate} target to {target}, ' 'that would violate the field_limits. ') # update our internal target cache self._target_vector.set_component(**{coordinate: target}) # actually assign the target on the slaves cartesian_targ = self._target_vector.get_components('x', 'y', 'z') for targ, slave in zip(cartesian_targ, self.submodules.values()): slave.field_target(targ) def _set_target_field(self, field: FieldVector) -> None: for coord in 'xyz': self._set_target(coord, field[coord]) def _idn_getter(self) -> Dict[str, str]: """ Parse the raw non-SCPI compliant IDN string into an IDN dict Returns: The normal IDN dict """ raw_idn_string = self.ask('*IDN?') resps = raw_idn_string.split(':') idn_dict = { 'model': resps[2], 'vendor': resps[1], 'serial': resps[3], 'firmware': resps[4] } return idn_dict def _ramp_simultaneously(self) -> None: """ Ramp all three fields to their target simultaneously at their given ramp rates. NOTE: there is NO guarantee that this does not take you out of your safe region. Use with care. """ for slave in self.submodules.values(): slave.ramp_to_target() def _ramp_simultaneously_blocking(self) -> None: """ Ramp all three fields to their target simultaneously at their given ramp rates. NOTE: there is NO guarantee that this does not take you out of your safe region. Use with care. This function is BLOCKING. """ self._ramp_simultaneously() for slave in self.submodules.values(): # wait for the ramp to finish, we don't care about the order while slave.ramp_status() == 'TO SET': time.sleep(0.1) self.update_field() def _ramp_safely(self) -> None: """ Ramp all three fields to their target using the 'first-down-then-up' sequential ramping procedure. This function is BLOCKING. """ meas_vals = self._get_measured(['x', 'y', 'z']) targ_vals = self._target_vector.get_components('x', 'y', 'z') order = np.argsort(np.abs(np.array(targ_vals) - np.array(meas_vals))) for slave in np.array(list(self.submodules.values()))[order]: slave.ramp_to_target() # now just wait for the ramp to finish # (unless we are testing) if self.visabackend == 'sim': pass else: while slave.ramp_status() == 'TO SET': time.sleep(0.1) self.update_field() def update_field(self) -> None: """ Update all the field components. """ coords = ['x', 'y', 'z', 'r', 'theta', 'phi', 'rho'] meas_field = self._get_field() [getattr(self, f'{i}_measured').get() for i in coords] def is_ramping(self) -> bool: """ Returns True if any axis has a ramp status that is either 'TO SET' or 'TO ZERO' """ ramping_statuus = ['TO SET', 'TO ZERO'] is_x_ramping = self.GRPX.ramp_status() in ramping_statuus is_y_ramping = self.GRPY.ramp_status() in ramping_statuus is_z_ramping = self.GRPZ.ramp_status() in ramping_statuus return is_x_ramping or is_y_ramping or is_z_ramping def set_new_field_limits(self, limit_func: Callable) -> None: """ Assign a new field limit function to the driver Args: limit_func: must be a function mapping (Bx, By, Bz) -> True/False where True means that the field is INSIDE the allowed region """ # first check that the current target is allowed if not limit_func(*self._target_vector.get_components('x', 'y', 'z')): raise ValueError('Can not assign new limit function; present ' 'target is illegal. Please change the target ' 'and try again.') self._field_limits = limit_func def ramp(self, mode: str = "safe") -> None: """ Ramp the fields to their present target value Args: mode: how to ramp, either 'simul', 'simul-block' or 'safe'. In 'simul' and 'simul-block' mode, the fields are ramping simultaneously in a non-blocking mode and blocking mode, respectively. There is no safety check that the safe zone is not exceeded. In 'safe' mode, the fields are ramped one-by-one in a blocking way that ensures that the total field stays within the safe region (provided that this region is convex). """ if mode not in ['simul', 'safe', 'simul_block']: raise ValueError('Invalid ramp mode. Please provide either "simul"' ',"safe" or "simul_block".') meas_vals = self._get_measured(['x', 'y', 'z']) # we asked for three coordinates, so we know that we got a list meas_vals = cast(List[float], meas_vals) for cur, slave in zip(meas_vals, self.submodules.values()): if slave.field_target() != cur: if slave.field_ramp_rate() == 0: raise ValueError(f'Can not ramp {slave}; ramp rate set to' ' zero!') # then the actual ramp { 'simul': self._ramp_simultaneously, 'safe': self._ramp_safely, 'simul_block': self._ramp_simultaneously_blocking }[mode]() def _set_target_and_ramp(self, coordinate: str, mode: str, target: float) -> None: """Convenient method to combine setting target and ramping""" self._set_target(coordinate, target) self.ramp(mode) def ask(self, cmd: str) -> str: """ Since Oxford Instruments implement their own version of a SCPI-like language, we implement our own reader. Note that this command is used for getting and setting (asking and writing) alike. Args: cmd: the command to send to the instrument """ visalog.debug(f"Writing to instrument {self.name}: {cmd}") resp = self.visa_handle.query(cmd) visalog.debug(f"Got instrument response: {resp}") if 'INVALID' in resp: log.error('Invalid command. Got response: {}'.format(resp)) base_resp = resp # if the command was not invalid, it can either be a SET or a READ # SET: elif resp.endswith('VALID'): base_resp = resp.split(':')[-2] # READ: else: # For "normal" commands only (e.g. '*IDN?' is excepted): # the response of a valid command echoes back said command, # thus we remove that part base_cmd = cmd.replace('READ:', '') base_resp = resp.replace('STAT:{}'.format(base_cmd), '') return base_resp