#!/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")