Source code for instruments.fluke.fluke3000

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# fluke3000.py: Driver for the Fluke 3000 FC Industrial System
#
# © 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 Fluke 3000 FC Industrial System

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
import time
from builtins import range

from enum import Enum

import quantities as pq

from instruments.abstract_instruments import Multimeter

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


[docs]class Fluke3000(Multimeter): """The `Fluke3000` is an ecosystem of devices produced by Fluke that may be connected simultaneously to a Fluke PC3000 wireless adapter which exposes a serial port to the computer to send and receive commands. The `Fluke3000` ecosystem supports the following instruments: - Fluke 3000 FC Series Wireless Multimeter - Fluke v3000 FC Wireless AC Voltage Module - Fluke v3001 FC Wireless DC Voltage Module - Fluke t3000 FC Wireless Temperature Module `Fluke3000` is a USB instrument that communicates through a serial port via the PC3000 dongle. The commands used to communicate to the dongle do not follow the SCPI standard. When the device is reset, it searches for available wireless modules and binds them to the PC3000 dongle. At each initialization, this class checks what device has been bound and saves their module number. This class has been tested with the 3000 FC Wireless Multimeter and the t3000 FC Wireless Temperature Module. They have been operated separately and simultaneously. It does not support the Wireless AC/DC Voltage Modules as the author did not have them on hand. It is important to note that the mode of the multimeter cannot be set remotely. If must be set on the device prior to the measurement. If the measurement read back from the multimeter is not expressed in the expected units, this module will raise an error. Example usage: >>> import instruments as ik >>> mult = ik.fluke.Fluke3000.open_serial("/dev/ttyUSB0", 115200) >>> mult.measure(mult.Mode.voltage_dc) # Measures the DC voltage array(12.345) * V It is crucial not to kill this program in the process of making a measurement, as for the Fluke 3000 FC Wireless Multimeter, one has to open continuous readout, make a read and close it. If the process is killed, the read out may not be closed and the serial cache will be constantly filled with measurements that will interfere with any status query. If the multimeter is stuck in continuous trigger after a bad kill, simply do: >>> mult.reset() >>> mult.flush() >>> mult.connect() Follow the same procedure if you want to add/remove an instrument to/from the readout chain as the code will not look for new instruments if some have already been connected to the PC3000 dongle. """ def __init__(self, filelike): """ Initialize the instrument, and set the properties needed for communication. """ super(Fluke3000, self).__init__(filelike) self.timeout = 3 * pq.second self.terminator = "\r" self.positions = {} self.connect() # ENUMS ##
[docs] class Module(Enum): """ Enum containing the supported modules serial numbers. """ #: Multimeter m3000 = 46333030304643 #: Temperature module t3000 = 54333030304643
[docs] class Mode(Enum): """ Enum containing the supported mode codes. """ #: AC Voltage voltage_ac = "01" #: DC Voltage voltage_dc = "02" #: AC Current current_ac = "03" #: DC Current current_dc = "04" #: Frequency frequency = "05" #: Temperature temperature = "07" #: Resistance resistance = "0B" #: Capacitance capacitance = "0F"
# PROPERTIES ## @property def mode(self): """ Gets/sets the measurement mode for the multimeter. The measurement mode of the multimeter must be set on the device manually and cannot be set remotely. If a multimeter is bound to the PC3000, returns its measurement mode by making a measurement and checking the units bytes in response. :rtype: `Fluke3000.Mode` """ if self.Module.m3000 not in self.positions.keys(): raise KeyError("No `Fluke3000` FC multimeter is bound") port_id = self.positions[self.Module.m3000] value = self.query_lines("rfemd 0{} 1".format(port_id), 2)[-1] self.query("rfemd 0{} 2".format(port_id)) data = value.split("PH=")[-1] return self.Mode(self._parse_mode(data)) @property def trigger_mode(self): """ Gets/sets the trigger mode for the multimeter. The only supported mode is to trigger the device once when a measurement is queried. This device does support continuous triggering but it would quickly flood the serial input cache as readouts do not overwrite each other and are accumulated. :rtype: `str` """ raise AttributeError("The `Fluke3000` only supports single trigger when queried") @property def relative(self): """ Gets/sets the status of relative measuring mode for the multimeter. The `Fluke3000` FC does not support relative measurements. :rtype: `bool` """ raise AttributeError("The `Fluke3000` FC does not support relative measurements") @property def input_range(self): """ Gets/sets the current input range setting of the multimeter. The `Fluke3000` FC is an autoranging only multimeter. :rtype: `str` """ return AttributeError('The `Fluke3000` FC is an autoranging only multimeter') # METHODS #
[docs] def connect(self): """ Connect to available modules and returns a dictionary of the modules found and their port ID. """ self.scan() # Look for connected devices if not self.positions: self.reset() # Reset the PC3000 dongle timeout = self.timeout # Store default timeout self.timeout = 30 * pq.second # PC 3000 can take a while to bind with wireless devices self.query_lines("rfdis", 3) # Discover available modules and bind them self.timeout = timeout # Restore default timeout self.scan() # Look for connected devices if not self.positions: raise ValueError("No `Fluke3000` modules available")
[docs] def scan(self): """ Search for available modules and reformatturns a dictionary of the modules found and their port ID. """ # Loop over possible channels, store device locations positions = {} for port_id in range(1, 7): # Check if a device is connected to port port_id output = self.query("rfebd 0{} 0".format(port_id)) if "RFEBD" not in output: continue # If it is, identify the device self.read() output = self.query_lines("rfgus 0{}".format(port_id), 2)[-1] module_id = int(output.split("PH=")[-1]) if module_id == self.Module.m3000.value: positions[self.Module.m3000] = port_id elif module_id == self.Module.t3000.value: positions[self.Module.t3000] = port_id else: raise NotImplementedError("Module ID {} not implemented".format(module_id)) self.positions = positions
[docs] def reset(self): """ Resets the device and unbinds all modules. """ self.query_lines("ri", 3) # Resets the device self.query_lines("rfsm 1", 2) # Turns comms on
[docs] def read_lines(self, nlines=1): """ Function that keeps reading until reaches a termination character a set amount of times. This is implemented to handle the mutiline output of the PC3000. :param nlines: Number of termination characters to reach :type nlines: 'int' :return: Array of lines read out :rtype: Array of `str` """ return [self.read() for _ in range(nlines)]
[docs] def query_lines(self, cmd, nlines=1): """ Function used to send a query to the instrument while allowing for the multiline output of the PC3000. :param cmd: Command that will be sent to the instrument :param nlines: Number of termination characters to reach :type cmd: 'str' :type nlines: 'int' :return: The multiline result from the query :rtype: Array of `str` """ self.sendcmd(cmd) return self.read_lines(nlines)
[docs] def flush(self): """ Flushes the serial input cache. This device outputs a terminator after each output line. The serial input cache is flushed by repeatedly reading until a terminator is not found. """ timeout = self.timeout self.timeout = 0.1 * pq.second init_time = time.time() while time.time() - init_time < 1.: try: self.read() except OSError: break self.timeout = timeout
[docs] def measure(self, mode): """Instruct the Fluke3000 to perform a one time measurement. :param mode: Desired measurement mode. :type mode: `Fluke3000.Mode` :return: A measurement from the multimeter. :rtype: `~quantities.quantity.Quantity` """ # Check that the mode is supported if not isinstance(mode, self.Mode): raise ValueError("Mode {} is not supported".format(mode)) # Check that the module associated with this mode is available module = self._get_module(mode) if module not in self.positions.keys(): raise ValueError("Device necessary to measure {} is not available".format(mode)) # Query the module value = '' port_id = self.positions[module] init_time = time.time() while time.time() - init_time < 3.: # Read out if mode == self.Mode.temperature: # The temperature module supports single readout value = self.query_lines("rfemd 0{} 0".format(port_id), 2)[-1] else: # The multimeter does not support single readout, # have to open continuous readout, read, then close it value = self.query_lines("rfemd 0{} 1".format(port_id), 2)[-1] self.query("rfemd 0{} 2".format(port_id)) # Check that value is consistent with the request, break if "PH" in value: data = value.split("PH=")[-1] if self._parse_mode(data) != mode.value: if self.Module.m3000 in self.positions.keys(): self.query("rfemd 0{} 2".format(self.positions[self.Module.m3000])) self.flush() else: break # Parse the output value = self._parse(value, mode) # Return with the appropriate units units = UNITS[mode] return value * units
def _get_module(self, mode): """Gets the module associated with this measurement mode. :param mode: Desired measurement mode. :type mode: `Fluke3000.Mode` :return: A Fluke3000 module. :rtype: `Fluke3000.Module` """ if mode == self.Mode.temperature: return self.Module.t3000 return self.Module.m3000 def _parse(self, result, mode): """Parses the module output. :param result: Output of the query. :param mode: Desired measurement mode. :type result: `string` :type mode: `Fluke3000.Mode` :return: A measurement from the multimeter. :rtype: `Quantity` """ # Check that a value is contained if "PH" not in result: raise ValueError("Cannot parse a string that does not contain a return value") # Isolate the data string from the output data = result.split('PH=')[-1] # Check that the multimeter is in the right mode (fifth byte) if self._parse_mode(data) != mode.value: error = ("Mode {} was requested but the Fluke 3000FC Multimeter " "is in mode {} instead. Could not read the requested " "quantity.").format(mode.name, self.Mode(data[8:10]).name) raise ValueError(error) # Extract the value from the first two bytes value = self._parse_value(data) # Extract the prefactor from the fourth byte scale = self._parse_factor(data) # Combine and return return scale*value @staticmethod def _parse_mode(data): """Parses the measurement mode. :param data: Measurement output. :type data: `str` :return: A Mode string. :rtype: `str` """ # The fixth dual hex byte encodes the measurement mode return data[8:10] @staticmethod def _parse_value(data): """Parses the measurement value. :param data: Measurement output. :type data: `str` :return: A value. :rtype: `float` """ # The second dual hex byte is the most significant byte return int(data[2:4]+data[:2], 16) @staticmethod def _parse_factor(data): """Parses the measurement prefactor. :param data: Measurement output. :type data: `str` :return: A prefactor. :rtype: `float` """ # Convert the fourth dual hex byte to an 8 bits string byte = format(int(data[6:8], 16), '08b') # The first bit encodes the sign (0 positive, 1 negative) sign = 1 if byte[0] == '0' else -1 # The second to fourth bits encode the metric prefix code = int(byte[1:4], 2) if code not in PREFIXES.keys(): raise ValueError("Metric prefix not recognized: {}".format(code)) prefix = PREFIXES[code] # The sixth and seventh bit encode the decimal place scale = 10**(-int(byte[5:7], 2)) # Return the combination return sign*prefix*scale
# UNITS ####################################################################### UNITS = { None: 1, Fluke3000.Mode.voltage_ac: pq.volt, Fluke3000.Mode.voltage_dc: pq.volt, Fluke3000.Mode.current_ac: pq.amp, Fluke3000.Mode.current_dc: pq.amp, Fluke3000.Mode.frequency: pq.hertz, Fluke3000.Mode.temperature: pq.celsius, Fluke3000.Mode.resistance: pq.ohm, Fluke3000.Mode.capacitance: pq.farad } # METRIC PREFIXES ############################################################# PREFIXES = { 0: 1e0, # None 2: 1e6, # Mega 3: 1e3, # Kilo 4: 1e-3, # milli 5: 1e-6, # micro 6: 1e-9 # nano }