Source code for instruments.glassman.glassmanfr

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# hpe3631a.py: Driver for the Glassman FR Series Power Supplies
#
# © 2019 Francois Drielsma (francois.drielsma@gmail.com).
#
# This file is a part of the InstrumentKit project.
# Licensed under the AGPL version 3.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Driver for the Glassman FR Series Power Supplies

Originally contributed and copyright held by Francois Drielsma
(francois.drielsma@gmail.com)

An unrestricted license has been provided to the maintainers of the Instrument
Kit project.
"""
# IMPORTS #####################################################################

from __future__ import absolute_import
from __future__ import division
from builtins import bytes, round
from struct import unpack

from enum import Enum

import quantities as pq

from instruments.abstract_instruments import (
    PowerSupply,
    PowerSupplyChannel
)
from instruments.util_fns import assume_units

# CLASSES #####################################################################


[docs]class GlassmanFR(PowerSupply, PowerSupplyChannel): """ The GlassmanFR is a single output power supply. Because it is a single channel output, this object inherits from both PowerSupply and PowerSupplyChannel. This class should work for any of the Glassman FR Series power supplies and is also likely to work for the EJ, ET, EY and FJ Series which seem to share their communication protocols. The code has only been tested by the author with an Glassman FR50R6 power supply. Before this power supply can be remotely operated, remote communication must be enabled and the HV must be on. Please refer to the manual. Example usage: >>> import instruments as ik >>> psu = ik.glassman.GlassmanFR.open_serial('/dev/ttyUSB0', 9600) >>> psu.voltage = 100 # Sets output voltage to 100V. >>> psu.voltage array(100.0) * V >>> psu.output = True # Turns on the power supply >>> psu.voltage_sense < 200 * pq.volt True This code uses default values of `voltage_max`, `current_max` and `polarity` that are only valid of the FR50R6 in its positive setting. If your power supply differs, reset those values by calling: >>> import quantities as pq >>> psu.voltage_max = 40.0 * pq.kilovolt >>> psu.current_max = 7.5 * pq.milliamp >>> psu.polarity = -1 """ def __init__(self, filelike): """ Initialize the instrument, and set the properties needed for communication. """ super(GlassmanFR, self).__init__(filelike) self.terminator = "\r" self.voltage_max = 50.0 * pq.kilovolt self.current_max = 6.0 * pq.milliamp self.polarity = +1 self._device_timeout = False self._voltage = 0. * pq.volt self._current = 0. * pq.amp # ENUMS ##
[docs] class Mode(Enum): """ Enum containing the possible modes of operations of the instrument """ #: Constant voltage mode voltage = "0" #: Constant current mode current = "1"
[docs] class ResponseCode(Enum): """ Enum containing the possible reponse codes returned by the instrument. """ #: A set command expects an acknoledge response (`A`) S = "A" #: A query command expects a response packet (`R`) Q = "R" #: A version query expects a different response packet (`B`) V = "B" #: A configure command expects an acknoledge response (`A`) C = "A"
[docs] class ErrorCode(Enum): """ Enum containing the possible error codes returned by the instrument. """ #: Undefined command received (not S, Q, V or C) undefined_command = "1" #: The checksum calculated by the instrument does not correspond to the one received checksum_error = "2" #: The command was longer than expected extra_bytes = "3" #: The digital control byte set was not one of HV On, HV Off or Power supply Reset illegal_control = "4" #: A send command was sent without a reset byte while the power supply is faulted illegal_while_fault = "5" #: Command valid, error while executing it processing_error = "6"
# PROPERTIES ## @property def channel(self): """ Return the channel (which in this case is the entire instrument, since there is only 1 channel on the GlassmanFR.) :rtype: 'tuple' of length 1 containing a reference back to the parent GlassmanFR object. """ return [self] @property def voltage(self): """ Gets/sets the output voltage setting. :units: As specified, or assumed to be :math:`\\text{V}` otherwise. :type: `float` or `~quantities.Quantity` """ return self.polarity*self._voltage @voltage.setter def voltage(self, newval): self.set_status(voltage=assume_units(newval, pq.volt)) @property def current(self): """ Gets/sets the output current setting. :units: As specified, or assumed to be :math:`\\text{A}` otherwise. :type: `float` or `~quantities.Quantity` """ return self.polarity*self._current @current.setter def current(self, newval): self.set_status(current=assume_units(newval, pq.amp)) @property def voltage_sense(self): """ Gets the output voltage as measured by the sense wires. :units: As specified, or assumed to be :math:`\\text{V}` otherwise. :type: `~quantities.Quantity` """ return self.get_status()["voltage"] @property def current_sense(self): """ Gets/sets the output current as measured by the sense wires. :units: As specified, or assumed to be :math:`\\text{A}` otherwise. :type: `~quantities.Quantity` """ return self.get_status()["current"] @property def mode(self): """ Gets/sets the mode for the specified channel. The constant-voltage/constant-current modes of the power supply are selected automatically depending on the load (resistance) connected to the power supply. If the load greater than the set V/I is connected, a voltage V is applied and the current flowing is lower than I. If the load is smaller than V/I, the set current I acts as a current limiter and the voltage is lower than V. :type: `GlassmanFR.Mode` """ return self.get_status()["mode"] @property def output(self): """ Gets/sets the output status. This is a toggle setting. True will turn on the instrument output while False will turn it off. :type: `bool` """ return self.get_status()["output"] @output.setter def output(self, newval): if not isinstance(newval, bool): raise TypeError("Ouput status mode must be a boolean.") self.set_status(output=newval) @property def fault(self): """ Gets/sets the output status. Returns True if the instrument has a fault. :type: `bool` """ return self.get_status()["fault"] @property def version(self): """ The software revision level of the power supply's data intereface via the `V` command :rtype: `str` """ return self.query("V") @property def device_timeout(self): """ Gets/sets the timeout instrument side. This is a toggle setting. ON will set the timeout to 1.5 seconds while OFF will disable it. :type: `bool` """ return self._device_timeout @device_timeout.setter def device_timeout(self, newval): if not isinstance(newval, bool): raise TypeError("Device timeout mode must be a boolean.") self.query("C{}".format(int(not newval))) # Device acknowledges self._device_timeout = newval # METHODS ##
[docs] def sendcmd(self, cmd): """ Overrides the default `setcmd` by padding the front of each command sent to the instrument with an SOH character and the back of it with a checksum. :param str cmd: The command message to send to the instrument """ checksum = self._get_checksum(cmd) self._file.sendcmd("\x01" + cmd + checksum) # Add SOH and checksum
[docs] def query(self, cmd, size=-1): """ Overrides the default `query` by padding the front of each command sent to the instrument with an SOH character and the back of it with a checksum. This implementation also automatically check that the checksum returned by the instrument is consistent with the message. If the message returned is an error, it parses it and raises. :param str cmd: The query message to send to the instrument :param int size: The number of bytes to read back from the instrument response. :return: The instrument response to the query :rtype: `str` """ self.sendcmd(cmd) result = self._file.read(size) if result[0] != getattr(self.ResponseCode, cmd[0]).value and result[0] != "E": raise ValueError("Invalid response code: {}".format(result)) if result[0] == "A": return "Acknowledged" if not self._verify_checksum(result): raise ValueError("Invalid checksum: {}".format(result)) if result[0] == "E": error_name = self.ErrorCode(result[1]).name raise ValueError("Instrument responded with error: {}".format(error_name)) return result[1:-2] # Remove SOH and checksum
[docs] def reset(self): """ Reset device to default status (HV Off, V=0.0, A=0.0) """ self.set_status(reset=True)
[docs] def set_status(self, voltage=None, current=None, output=None, reset=False): """ Sets the requested variables on the instrument. This instrument can only set all of its variables simultaneously, if some of them are omitted in this function, they will simply be kept as what they were set to previously. """ if reset: self._voltage = 0. * pq.volt self._current = 0. * pq.amp cmd = format(4, "013d") else: # The maximum value is encoded as the maximum of three hex characters (4095) cmd = '' value_max = int(0xfff) # If the voltage is not specified, keep it as is voltage = assume_units(voltage, pq.volt) if voltage is not None else self.voltage ratio = float(voltage.rescale(pq.volt)/self.voltage_max.rescale(pq.volt)) voltage_int = int(round(value_max*ratio)) self._voltage = self.voltage_max*float(voltage_int)/value_max assert 0. * pq.volt <= self._voltage <= self.voltage_max cmd += format(voltage_int, "03X") # If the current is not specified, keep it as is current = assume_units(current, pq.amp) if current is not None else self.current ratio = float(current.rescale(pq.amp)/self.current_max.rescale(pq.amp)) current_int = int(round(value_max*ratio)) self._current = self.current_max*float(current_int)/value_max assert 0. * pq.amp <= self._current <= self.current_max cmd += format(current_int, "03X") # If the output status is not specified, keep it as is output = output if output is not None else self.output control = "00{}{}".format(int(output), int(not output)) cmd += format(int(control, 2), "07X") self.query("S" + cmd) # Device acknowledges
[docs] def get_status(self): """ Gets and parses the response packet. Returns a `dict` with the following keys: ``{voltage,current,mode,fault,output}`` :rtype: `dict` """ return self._parse_response(self.query("Q"))
def _parse_response(self, response): """ Parse the response packet returned by the power supply. Returns a `dict` with the following keys: ``{voltage,current,mode,fault,output}`` :param response: Byte string to be unpacked and parsed :type: `str` :rtype: `dict` """ (voltage, current, monitors) = \ unpack("@3s3s3x1c2x", bytes(response, "utf-8")) try: voltage = self._parse_voltage(voltage) current = self._parse_current(current) mode, fault, output = self._parse_monitors(monitors) except: raise RuntimeError("Cannot parse response " "packet: {}".format(response)) return {"voltage": voltage, "current": current, "mode": mode, "fault": fault, "output": output} def _parse_voltage(self, word): """ Converts the three-bytes voltage word returned in the response packet to a single voltage quantity. :param word: Byte string to be parsed :type: `bytes` :rtype: `~quantities.quantity.Quantity` """ value = int(word.decode('utf-8'), 16) value_max = int(0x3ff) return self.polarity*self.voltage_max*float(value)/value_max def _parse_current(self, word): """ Converts the three-bytes current word returned in the response packet to a single current quantity. :param word: Byte string to be parsed :type: `bytes` :rtype: `~quantities.quantity.Quantity` """ value = int(word.decode("utf-8"), 16) value_max = int(0x3ff) return self.polarity*self.current_max*float(value)/value_max def _parse_monitors(self, word): """ Converts the monitors byte returned in the response packet to a mode, a fault boolean and an output boolean. :param word: Byte to be parsed :type: `byte` :rtype: `str, bool, bool` """ bits = format(int(word, 16), "04b") mode = self.Mode(bits[-1]) fault = bits[-2] == "1" output = bits[-3] == "1" return mode, fault, output def _verify_checksum(self, word): """ Calculates the modulo 256 checksum of a string of characters and compares it to the one returned by the instrument. Returns True if they agree, False otherwise. :param word: Byte string to be checked :type: `str` :rtype: `bool` """ data = word[1:-2] inst_checksum = word[-2:] calc_checksum = self._get_checksum(data) return inst_checksum == calc_checksum @staticmethod def _get_checksum(data): """ Calculates the modulo 256 checksum of a string of characters. This checksum, expressed in hexadecimal, is used in every communication of this instrument, as a sanity check. Returns a string corresponding to the hexademical value of the checksum, without the `0x` prefix. :param data: Byte string to be checksummed :type: `str` :rtype: `str` """ chrs = list(data) total = 0 for c in chrs: total += ord(c) return format(total % 256, "02X")