diff --git a/lewis/devices/Lksh218/__init__.py b/lewis/devices/Lksh218/__init__.py new file mode 100644 index 00000000..d821ea5f --- /dev/null +++ b/lewis/devices/Lksh218/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedLakeshore218 + +__all__ = ["SimulatedLakeshore218"] diff --git a/lewis/devices/Lksh218/device.py b/lewis/devices/Lksh218/device.py new file mode 100644 index 00000000..85828c05 --- /dev/null +++ b/lewis/devices/Lksh218/device.py @@ -0,0 +1,86 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + +NUMBER_OF_TEMP_CHANNELS = 8 +NUMBER_OF_SENSOR_CHANNELS = 8 + + +class SimulatedLakeshore218(StateMachineDevice): + """Simulated Lakeshore 218 + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self._temps = [1.0] * NUMBER_OF_TEMP_CHANNELS + self._sensors = [0.5] * NUMBER_OF_SENSOR_CHANNELS + self.temp_all = "" + self.sensor_all = "" + self.connected = True + + @staticmethod + def _get_state_handlers(): + """Returns: States and their names. + """ + return {DefaultState.NAME: DefaultState()} + + @staticmethod + def _get_initial_state(): + """Returns: The name of the initial state. + """ + return DefaultState.NAME + + @staticmethod + def _get_transition_handlers(): + """Returns: The state transitions. + """ + return OrderedDict() + + def get_temp(self, number): + """Gets the temperature of a specific temperature sensor. + + Args: + number: Integer between 1 and 8. + + Returns: + float: Temperature value at position (number - 1) in temps. + """ + return self._temps[number - 1] + + def set_temp(self, number, temperature): + """Sets the (number - 1) temp pv to temperature. + + Args: + number: Integer between 1 and 8. + temperature: Temperature reading to set. + + Returns: + None + """ + self._temps[number - 1] = temperature + + def get_sensor(self, number): + """Gets the sensor reading of a specific sensor. + + Args: + number: Integer between 1 and 8. + + Returns: + float: Value of sensor at position (number - 1) in sensors. + """ + return self._sensors[number - 1] + + def set_sensor(self, number, value): + """Sets the (number - 1) sensor pv to value. + + Args: + number: Integer between 1 and 8. + value: Sensor reading to set. + + Returns: + None + """ + self._sensors[number - 1] = value diff --git a/lewis/devices/Lksh218/interfaces/__init__.py b/lewis/devices/Lksh218/interfaces/__init__.py new file mode 100644 index 00000000..57ac3f9a --- /dev/null +++ b/lewis/devices/Lksh218/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Lakeshore218StreamInterface + +__all__ = ["Lakeshore218StreamInterface"] diff --git a/lewis/devices/Lksh218/interfaces/stream_interface.py b/lewis/devices/Lksh218/interfaces/stream_interface.py new file mode 100644 index 00000000..81b484b2 --- /dev/null +++ b/lewis/devices/Lksh218/interfaces/stream_interface.py @@ -0,0 +1,84 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + + +def if_connected(f): + """Decorator that executes f if the device is connected and returns None otherwise. + + Args: + f: function to be executed if the device is connected. + + Returns: + The value of f(*args) if the device is connected and None otherwise. + """ + + def wrapper(*args): + connected = getattr(args[0], "_device").connected + if connected: + result = f(*args) + else: + result = None + return result + + return wrapper + + +class Lakeshore218StreamInterface(StreamInterface): + """Stream interface for the serial port + """ + + in_terminator = "\r\n" + out_terminator = "\r\n" + + commands = { + CmdBuilder("get_temp").escape("KRDG? ").arg("[1-8]").build(), + CmdBuilder("get_sensor").escape("SRDG? ").arg("[1-8]").build(), + CmdBuilder("get_temp_all").escape("KRDG? 0").build(), + CmdBuilder("get_sensor_all").escape("SRDG? 0").build(), + } + + @if_connected + def get_temp(self, number): + """Returns the temperature of a TEMP pv. + + Args: + number: integer between 1 and 8 + + Returns: + float: temperature + """ + number = int(number) + temperature = self._device.get_temp(number) + return temperature + + @if_connected + def get_sensor(self, number): + """Returns the temperature of a SENSOR pv. + + Args: + number: integer between 1 and 8 + + Returns: + float: sensor_reading + """ + number = int(number) + sensor_reading = self._device.get_sensor(number) + return sensor_reading + + @if_connected + def get_temp_all(self): + """Returns a string from TEMPALL pv. + + Returns: + string: value of TEMPALL pv. + """ + return self._device.temp_all + + @if_connected + def get_sensor_all(self): + """Returns a string from SENSORALL pv. + + Returns: + string: value of SENSORALL pv. + """ + return self._device.sensor_all diff --git a/lewis/devices/Lksh218/states.py b/lewis/devices/Lksh218/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/Lksh218/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/ag33220a/__init__.py b/lewis/devices/ag33220a/__init__.py new file mode 100644 index 00000000..05b4a3be --- /dev/null +++ b/lewis/devices/ag33220a/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedAG33220A + +__all__ = ["SimulatedAG33220A"] diff --git a/lewis/devices/ag33220a/device.py b/lewis/devices/ag33220a/device.py new file mode 100644 index 00000000..e59399da --- /dev/null +++ b/lewis/devices/ag33220a/device.py @@ -0,0 +1,169 @@ +from lewis.devices import Device + + +class SimulatedAG33220A(Device): + """Simulated AG33220A + """ + + connected = True + + # Constants + AMP_MIN = 0.01 + AMP_MAX = 10 + OFF_MAX = 4.995 + VOLT_MAX = 5 + VOLT_MIN = -5 + VOLT_LOW_MAX = 4.99 + VOLT_HIGH_MIN = -4.99 + VOLT_PRECISION = 0.01 + FREQ_MINS = { + "SIN": 10**-6, + "SQU": 10**-6, + "RAMP": 10**-6, + "PULS": 5 * 10**-4, + "NOIS": 10**-6, + "USER": 10**-6, + } + FREQ_MAXS = { + "SIN": 2 * 10**7, + "SQU": 2 * 10**7, + "RAMP": 2 * 10**5, + "PULS": 5 * 10**6, + "NOIS": 2 * 10**7, + "USER": 6 * 10**6, + } + + # Device variables + idn = "Agilent Technologies,33220A-MY44033103,2.02-2.02-22-2" + amplitude = 0.1 + frequency = 1000 + offset = 0 + units = "VPP" + function = "SIN" + output = "ON" + voltage_high = 0.05 + voltage_low = -0.05 + range_auto = "OFF" + + def limit(self, value, minimum, maximum): + """Limits an input number between two given numbers or sets the value to the maximum or minimum. + + :param value: the value to be limited + :param minimum: the smallest that the value can be + :param maximum: the largest that the value can be + + :return: the value after it has been limited + """ + if type(value) is str: + try: + value = float(value) + except ValueError: + return {"MIN": minimum, "MAX": maximum}[value] + + return max(min(value, maximum), minimum) + + def set_new_amplitude(self, new_amplitude): + """Changing the amplitude to the new amplitude whilst also changing the offset if voltage high or low is + outside the boundary. The volt high and low are then updated. + + :param new_amplitude: the amplitude to set the devices amplitude to + """ + new_amplitude = self.limit(new_amplitude, self.AMP_MIN, self.AMP_MAX) + + peak_amp = 0.5 * new_amplitude + if self.offset + peak_amp > self.VOLT_MAX: + self.offset = self.VOLT_MAX - peak_amp + elif self.offset - peak_amp < self.VOLT_MIN: + self.offset = self.VOLT_MIN + peak_amp + + self.amplitude = new_amplitude + + self._update_volt_high_and_low(self.amplitude, self.offset) + + def set_new_frequency(self, new_frequency): + """Sets the frequency within limits between upper and lower bound (depends on the function). + + :param new_frequency: the frequency to set to + """ + self.frequency = self.limit( + new_frequency, self.FREQ_MINS[self.function], self.FREQ_MAXS[self.function] + ) + + def set_new_voltage_high(self, new_voltage_high): + """Sets a new voltage high which then changes the voltage low to keep it lower. + The voltage offset and amplitude are then updated. + + :param new_voltage_high: the value of voltage high to set to + """ + new_voltage_high = self.limit(new_voltage_high, self.VOLT_HIGH_MIN, self.VOLT_MAX) + if new_voltage_high <= self.voltage_low: + self.voltage_low = self.limit( + new_voltage_high - self.VOLT_PRECISION, self.VOLT_MIN, new_voltage_high + ) + self._update_volt_and_offs(self.voltage_low, new_voltage_high) + + def set_new_voltage_low(self, new_voltage_low): + """Sets a new voltage high which then changes the voltage low to keep it higher. + The voltage offset and amplitude are then updated. + + :param new_voltage_low: the value of voltage low which is to be set + """ + new_voltage_low = self.limit(new_voltage_low, self.VOLT_MIN, self.VOLT_LOW_MAX) + if new_voltage_low >= self.voltage_high: + self.voltage_high = self.limit( + new_voltage_low + self.VOLT_PRECISION, new_voltage_low, self.VOLT_MAX + ) + self._update_volt_and_offs(new_voltage_low, self.voltage_high) + + def _update_volt_and_offs(self, new_low, new_high): + """Updates the value of amplitude and offset if there is a change in voltage low or voltage high. + + :param new_low: the value of voltage low + :param new_high: the value of voltage high + """ + self.voltage_high = new_high + self.voltage_low = new_low + self.amplitude = self.voltage_high - self.voltage_low + self.offset = (self.voltage_high + self.voltage_low) / 2 + + def set_offs_and_update_voltage(self, new_offset): + """Sets the value of offset and updates the amplitude, voltage low and voltage high for a new value of the offset. + + :param new_offset: the new offset to be set + """ + new_offset = self.limit(new_offset, -self.OFF_MAX, self.OFF_MAX) + if new_offset + self.voltage_high > self.VOLT_MAX: + self.amplitude = 2 * (self.VOLT_MAX - new_offset) + self.voltage_high = self.VOLT_MAX + self.voltage_low = self.VOLT_MAX - self.amplitude + elif new_offset + self.voltage_low < self.VOLT_MIN: + self.amplitude = 2 * (self.VOLT_MIN - new_offset) + self.voltage_low = self.VOLT_MIN + self.voltage_high = self.VOLT_MIN + self.amplitude + else: + self._update_volt_high_and_low(self.amplitude, new_offset) + self.offset = new_offset + + def _update_volt_high_and_low(self, new_volt, new_offs): + """Updates the value of voltage high and low for a given value of amplitude and offset. + + :param new_volt: the value of the amplitude + :param new_offs: the value of the offset + """ + self.offset = new_offs + self.amplitude = new_volt + self.voltage_high = new_offs + new_volt / 2 + self.voltage_low = new_offs - new_volt / 2 + + def get_output(self): + return ["OFF", "ON"].index(self.output) + + def get_range_auto(self): + possible_ranges = ["OFF", "ON", "ONCE"] + return possible_ranges.index(self.range_auto) + + def set_function(self, new_function): + self.function = new_function + self.frequency = self.limit( + self.frequency, self.FREQ_MINS[new_function], self.FREQ_MAXS[new_function] + ) diff --git a/lewis/devices/ag33220a/interfaces/__init__.py b/lewis/devices/ag33220a/interfaces/__init__.py new file mode 100644 index 00000000..962025b4 --- /dev/null +++ b/lewis/devices/ag33220a/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import AG33220AStreamInterface + +__all__ = ["AG33220AStreamInterface"] diff --git a/lewis/devices/ag33220a/interfaces/stream_interface.py b/lewis/devices/ag33220a/interfaces/stream_interface.py new file mode 100644 index 00000000..4704788d --- /dev/null +++ b/lewis/devices/ag33220a/interfaces/stream_interface.py @@ -0,0 +1,128 @@ +import traceback + +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.utils.command_builder import string_arg +from lewis.utils.replies import conditional_reply + +NUM_MIN_MAX = "([\-0-9.]+|MAX|MIN)" + +if_connected = conditional_reply("connected") + + +class AG33220AStreamInterface(StreamInterface): + commands = { + Cmd("get_amplitude", "^VOLT\?$"), + Cmd("set_amplitude", "^VOLT " + NUM_MIN_MAX, argument_mappings=[string_arg]), + Cmd("get_frequency", "^FREQ\?$"), + Cmd("set_frequency", "^FREQ " + NUM_MIN_MAX, argument_mappings=[string_arg]), + Cmd("get_offset", "^VOLT:OFFS\?$"), + Cmd("set_offset", "^VOLT:OFFS " + NUM_MIN_MAX, argument_mappings=[string_arg]), + Cmd("get_units", "^VOLT:UNIT\?$"), + Cmd("set_units", "^VOLT:UNIT (VPP|VRMS|DBM)$", argument_mappings=[string_arg]), + Cmd("get_function", "^FUNC\?$"), + Cmd( + "set_function", + "^FUNC (SIN|SQU|RAMP|PULS|NOIS|DC|USER)$", + argument_mappings=[string_arg], + ), + Cmd("get_output", "^OUTP\?$"), + Cmd("set_output", "^OUTP (ON|OFF)$", argument_mappings=[string_arg]), + Cmd("get_idn", "^\*IDN\?$"), + Cmd("get_voltage_high", "^VOLT:HIGH\?$"), + Cmd("set_voltage_high", "^VOLT:HIGH " + NUM_MIN_MAX, argument_mappings=[string_arg]), + Cmd("get_voltage_low", "^VOLT:LOW\?$"), + Cmd("set_voltage_low", "^VOLT:LOW " + NUM_MIN_MAX, argument_mappings=[string_arg]), + Cmd("get_voltage_range_auto", "^VOLT:RANG:AUTO\?$"), + Cmd( + "set_voltage_range_auto", + "^VOLT:RANG:AUTO (OFF|ON|ONCE)$", + argument_mappings=[string_arg], + ), + } + + in_terminator = "\n" + out_terminator = "\n" + + # Takes in a value and returns a value in the form of x.xxx0000000000Eyy + def float_output(self, value): + value = float("%s" % float("%.4g" % float(value))) + return "{:+.13E}".format(value) + + @if_connected + def get_amplitude(self): + return self.float_output(self._device.amplitude) + + @if_connected + def set_amplitude(self, new_amplitude): + self._device.set_new_amplitude(new_amplitude) + + @if_connected + def get_frequency(self): + return self.float_output(self._device.frequency) + + @if_connected + def set_frequency(self, new_frequency): + self._device.set_new_frequency(new_frequency) + + @if_connected + def get_offset(self): + return self.float_output(self._device.offset) + + @if_connected + def set_offset(self, new_offset): + self._device.set_offs_and_update_voltage(new_offset) + + @if_connected + def get_units(self): + return self._device.units + + @if_connected + def set_units(self, new_units): + self._device.units = new_units + + @if_connected + def get_function(self): + return self._device.function + + @if_connected + def set_function(self, new_function): + self._device.set_function(new_function) + + @if_connected + def get_output(self): + return self._device.get_output() + + @if_connected + def set_output(self, new_output): + self._device.output = new_output + + @if_connected + def get_idn(self): + return self._device.idn + + @if_connected + def get_voltage_high(self): + return self.float_output(self._device.voltage_high) + + @if_connected + def set_voltage_high(self, new_voltage_high): + self._device.set_new_voltage_high(new_voltage_high) + + @if_connected + def get_voltage_low(self): + return self.float_output(self._device.voltage_low) + + @if_connected + def set_voltage_low(self, new_voltage_low): + self._device.set_new_voltage_low(new_voltage_low) + + @if_connected + def get_voltage_range_auto(self): + return self._device.get_range_auto() + + @if_connected + def set_voltage_range_auto(self, range_auto): + self._device.range_auto = range_auto + + def handle_error(self, request, error): + print(traceback.format_exc()) diff --git a/lewis/devices/amint2l/__init__.py b/lewis/devices/amint2l/__init__.py new file mode 100644 index 00000000..4cf97a01 --- /dev/null +++ b/lewis/devices/amint2l/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedAmint2l + +__all__ = ["SimulatedAmint2l"] diff --git a/lewis/devices/amint2l/device.py b/lewis/devices/amint2l/device.py new file mode 100644 index 00000000..48006cec --- /dev/null +++ b/lewis/devices/amint2l/device.py @@ -0,0 +1,32 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedAmint2l(StateMachineDevice): + """Simulated AM Int2-L pressure transducer. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.connected = True + self.pressure = 2.0 + self.address = "AB" + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() diff --git a/lewis/devices/amint2l/interfaces/__init__.py b/lewis/devices/amint2l/interfaces/__init__.py new file mode 100644 index 00000000..3eae8e19 --- /dev/null +++ b/lewis/devices/amint2l/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Amint2lStreamInterface + +__all__ = ["Amint2lStreamInterface"] diff --git a/lewis/devices/amint2l/interfaces/stream_interface.py b/lewis/devices/amint2l/interfaces/stream_interface.py new file mode 100644 index 00000000..2aeeea81 --- /dev/null +++ b/lewis/devices/amint2l/interfaces/stream_interface.py @@ -0,0 +1,56 @@ +"""Stream device for amint2l +""" + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +@has_log +class Amint2lStreamInterface(StreamInterface): + """Stream interface for the serial port + """ + + in_terminator = chr(3) + out_terminator = chr(3) + + def __init__(self): + super(Amint2lStreamInterface, self).__init__() + self.commands = { + CmdBuilder(self.get_pressure).stx().arg("[A-Fa-f0-9]+").escape("r").build() + } + + @if_connected + def handle_error(self, request, error): + """If command is not recognised print and error + + Args: + request: requested string + error: problem + + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @if_connected + def get_pressure(self, address): + """Gets the current pressure + + :param address: address of request + Returns: pressure in correct format if pressure has a value; if None returns None as if it is disconnected + + """ + if address.upper() != self._device.address.upper(): + self.log.error("unknown address {0}".format(address)) + return None + self.log.info("Pressure: {0}".format(self._device.pressure)) + if self._device.pressure is None: + return None + else: + try: + return "{stx}{pressure:+8.3f}".format(stx=chr(2), pressure=self._device.pressure) + except ValueError: + # pressure contains string probably OR (over range) or UR (under range) + return "{stx}{pressure:8s}".format(stx=chr(2), pressure=self._device.pressure) diff --git a/lewis/devices/amint2l/states.py b/lewis/devices/amint2l/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/amint2l/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/attocube_anc350/__init__.py b/lewis/devices/attocube_anc350/__init__.py new file mode 100644 index 00000000..cfbc3d3c --- /dev/null +++ b/lewis/devices/attocube_anc350/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedAttocubeANC350 + +__all__ = ["SimulatedAttocubeANC350"] diff --git a/lewis/devices/attocube_anc350/device.py b/lewis/devices/attocube_anc350/device.py new file mode 100644 index 00000000..fae6d21d --- /dev/null +++ b/lewis/devices/attocube_anc350/device.py @@ -0,0 +1,47 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState, MovingState + + +class SimulatedAttocubeANC350(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.position = 0 + self.position_setpoint = 0 + self.speed = 10 + self.start_move = False + self.amplitude = 30000 + self.axis_on = True + + def set_amplitude(self, amplitude): + self.amplitude = amplitude + + def move(self): + self.start_move = True + + def set_position_setpoint(self, position): + self.position_setpoint = position + + def set_axis_on(self, on_state): + self.axis_on = on_state == 1 + + def _get_state_handlers(self): + return {DefaultState.NAME: DefaultState(), MovingState.NAME: MovingState()} + + def _get_initial_state(self): + return DefaultState.NAME + + def _get_transition_handlers(self): + return OrderedDict( + [ + ((DefaultState.NAME, MovingState.NAME), lambda: self.start_move and self.axis_on), + ( + (MovingState.NAME, DefaultState.NAME), + lambda: self.position_setpoint == self.position, + ), + ] + ) diff --git a/lewis/devices/attocube_anc350/interfaces/__init__.py b/lewis/devices/attocube_anc350/interfaces/__init__.py new file mode 100644 index 00000000..3d3c171f --- /dev/null +++ b/lewis/devices/attocube_anc350/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import AttocubeANC350StreamInterface + +__all__ = ["AttocubeANC350StreamInterface"] diff --git a/lewis/devices/attocube_anc350/interfaces/stream_interface.py b/lewis/devices/attocube_anc350/interfaces/stream_interface.py new file mode 100644 index 00000000..8e8f11cd --- /dev/null +++ b/lewis/devices/attocube_anc350/interfaces/stream_interface.py @@ -0,0 +1,191 @@ +from functools import partial + +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.byte_conversions import int_to_raw_bytes, raw_bytes_to_int + +BYTES_IN_INT = 4 +HEADER_LENGTH = 4 * BYTES_IN_INT + +convert_to_response = partial(int_to_raw_bytes, length=BYTES_IN_INT, low_byte_first=True) + +# NB, all variables used from here onwards are named the same in the C driver +# 'hump' in the controller refers to hardware limits +UC_SET = 0 # Set command +UC_GET = 1 # Get command +UC_ACK = 3 # Ack command +UC_TELL = 4 # Event command + +UC_REASON_OK = 0 # All ok +UC_REASON_ADDR = 1 # Invalid address +UC_REASON_RANGE = 2 # Value out of range +UC_REASON_IGNORED = 3 # Telegram was ignored +UC_REASON_VERIFY = 4 # Verify of data failed +UC_REASON_TYPE = 5 # Wrong type of data +UC_REASON_UNKNW = 99 # unknown error + +# Memory addresses +# Read +ID_ANC_AMPL = 0x400 +ID_ANC_FAST_FREQ = 0x0401 +ID_ANC_STATUS = 0x0404 +ID_ANC_REFCOUNTER = 0x0407 # The position of home +ID_ANC_COUNTER = 0x0415 +ID_ANC_UNIT = 0x041D +ID_ANC_SENSOR_VOLT = 0x0526 +ID_ANC_REGSPD_SETP = 0x0542 # Speed of the controller +ID_ANC_REGSPD_SETPS = 0x0549 # Step width of the controller +ID_ANC_MAX_AMP = 0x054F +ID_ANC_CAP_VALUE = 0x0569 + +# Write +ID_ANC_STOP_EN = 0x0450 # Enables 'hump' detection +ID_ANC_REGSPD_SELSP = 0x054A # Type of setpoint for the speed +ID_ANC_TARGET = 0x0408 # The target to move to +ID_ANC_RUN_TARGET = 0x040D # Actually start the move to target +ID_ANC_AXIS_ON = 0x3030 # Turn the axis on (on power cycle the axis is turned off + +# Status bitmask +ANC_STATUS_RUNNING = 0x0001 +ANC_STATUS_HUMP = 0x0002 +ANC_STATUS_SENS_ERR = 0x0100 +ANC_STATUS_DISCONN = 0x0400 +ANC_STATUS_REF_VALID = 0x0800 +ANC_STATUS_ENABLE = 0x1000 + + +def convert_to_ints(command, start, end): + """Converts an incoming set of bytes into a list of ints. Assuming there are BYTES_IN_INT bytes in an int. + + Args: + command: The incoming bytes. + start: The index at which to start + end: The index at which to end + + Returns: A list of integers converted from the command. + """ + return [ + raw_bytes_to_int(command[x : x + BYTES_IN_INT]) for x in range(start, end, BYTES_IN_INT) + ] + + +def generate_response(address, index, correlation_num, data=None): + """Creates a response of the format: + * Length (the length of the response) + * Opcode (always ACK in this case) + * Address (where the driver had read/written to) + * Index (the axis the driver had read/written from) + * Correlation Number (the ID of the message we're responding to) + * Reason (whether the request was successful, always SUCCESS currently) + + Args: + address: The memory address where the driver had read/written to + index: The axis the driver had read/written from + correlation_num: The ID of the message we're responding to + data (optional): The data we want to send back to the driver (only valid on a get command) + + Returns: The raw bytes to send back to the driver. + """ + int_responses = [UC_ACK, address, index, correlation_num, UC_REASON_OK] + if data is not None: + int_responses.append(data) + response = bytearray() + for int_response in int_responses: + response += convert_to_response(int_response) + return convert_to_response(len(response)) + response + + +@has_log +class AttocubeANC350StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation. Match anything! + commands = { + Cmd("any_command", r"^([\s\S]*)$", return_mapping=lambda x: x), + } + + in_terminator = "" + out_terminator = b"" + + # Due to poll rate of the driver this will get individual commands + readtimeout = 10 + + def handle_error(self, request, error): + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + def any_command(self, command): + response = "" + + if not self.device.connected: + # Used rather than conditional_reply decorator to improve error message + raise ValueError("Device simulating disconnection") + + while command: + # Length doesn't include itself + length = raw_bytes_to_int(command[:BYTES_IN_INT]) + BYTES_IN_INT + + response += self.handle_single_command(command[:length]) + + command = command[length:] + + return response + + def handle_single_command(self, command): + length = raw_bytes_to_int(command[:BYTES_IN_INT]) + + opcode, address, index, correlation_num = convert_to_ints( + command, BYTES_IN_INT, HEADER_LENGTH + 1 + ) + + if length > HEADER_LENGTH: + # This is probably a set command + data = convert_to_ints(command, HEADER_LENGTH + BYTES_IN_INT, len(command)) + + # Length should describe command minus itself + if len(command) - BYTES_IN_INT != length: + raise ValueError( + "Told I would receive {} bytes but received {}".format( + length, len(command) - BYTES_IN_INT + ) + ) + + if opcode == UC_GET: + return self.get(address, index, correlation_num) + elif opcode == UC_SET: + return self.set(address, index, correlation_num, data) + else: + raise ValueError("Unrecognised opcode {}".format(opcode)) + + def set(self, address, index, correlation_num, data): + self.log.info("Setting address {} with data {}".format(address, data[0])) + command_mapping = { + ID_ANC_TARGET: partial(self.device.set_position_setpoint, position=data[0]), + ID_ANC_RUN_TARGET: self.device.move, + ID_ANC_AMPL: partial(self.device.set_amplitude, data[0]), + ID_ANC_AXIS_ON: partial(self.device.set_axis_on, data[0]), + } + + try: + command_mapping[address]() + print("Device amp is {}".format(self.device.amplitude)) + except KeyError: + pass # Ignore unimplemented commands for now + return generate_response(address, index, correlation_num) + + def get(self, address, index, correlation_num): + self.log.info("Getting address {}".format(address)) + command_mapping = { + ID_ANC_COUNTER: int(self.device.position), + ID_ANC_REFCOUNTER: 0, + ID_ANC_STATUS: ANC_STATUS_REF_VALID + ANC_STATUS_ENABLE, + ID_ANC_UNIT: 0x00, + ID_ANC_REGSPD_SETP: self.device.speed, + ID_ANC_SENSOR_VOLT: 2000, + ID_ANC_MAX_AMP: 60000, + ID_ANC_AMPL: self.device.amplitude, + ID_ANC_FAST_FREQ: 1000, + } + try: + data = command_mapping[address] + except KeyError: + data = 0 # Just return 0 for now + return generate_response(address, index, correlation_num, data) diff --git a/lewis/devices/attocube_anc350/states.py b/lewis/devices/attocube_anc350/states.py new file mode 100644 index 00000000..2f377ec8 --- /dev/null +++ b/lewis/devices/attocube_anc350/states.py @@ -0,0 +1,22 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class MovingState(State): + """Device is in moving state. + """ + + NAME = "Moving" + + def in_state(self, dt): + device = self._context + device.position = approaches.linear( + device.position, device.position_setpoint, device.speed, dt + ) + + +class DefaultState(State): + NAME = "Default" + + def on_entry(self, dt): + self._context.start_move = False diff --git a/lewis/devices/chtobisr/__init__.py b/lewis/devices/chtobisr/__init__.py new file mode 100644 index 00000000..c4454893 --- /dev/null +++ b/lewis/devices/chtobisr/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedChtobisr + +__all__ = ["SimulatedChtobisr"] diff --git a/lewis/devices/chtobisr/device.py b/lewis/devices/chtobisr/device.py new file mode 100644 index 00000000..335fc301 --- /dev/null +++ b/lewis/devices/chtobisr/device.py @@ -0,0 +1,155 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +def build_code(codes_dict): + """Builds a code based on a codes dictionary + :param codes_dict: A dictionary with the code and whether it's flagged or not. + :return: The full code + """ + code = 0x00000000 + + for value in codes_dict.values(): + if value[0]: + code += value[1] + + return code + + +class SimulatedChtobisr(StateMachineDevice): + """Class to simulate Coherent OBIS Laser Remote + """ + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.id = "Coherent OBIS Laser Remote - EMULATOR" + self.interlock = "OFF" # "OFF" -> OPEN, "ON" -> CLOSED + + # Dictionary of form: + # {status_name: [whether_in_status, return_code]} + self.status = { + # Laser specific status bits + "laser_fault": [False, 0x00000001], + "laser_emission": [False, 0x00000002], + "laser_ready": [False, 0x00000004], + "laser_standby": [False, 0x00000008], + "cdrh_delay": [False, 0x00000010], + "laser_hardware_fault": [False, 0x00000020], + "laser_error": [False, 0x00000040], + "laser_power_calibration": [False, 0x00000080], + "laser_warm_up": [False, 0x00000100], + "laser_noise": [False, 0x00000200], + "external_operating_mode": [False, 0x00000400], + "field_calibration": [False, 0x00000800], + "laser_power_voltage": [False, 0x00001000], + # Controller specific status bits + "controller_standby": [False, 0x02000000], + "controller_interlock": [False, 0x04000000], + "controller_enumeration": [False, 0x08000000], + "controller_error": [False, 0x10000000], + "controller_fault": [False, 0x20000000], + "remote_active": [False, 0x40000000], + "controller_indicator": [False, 0x80000000], + } + + self.faults = { + # Laser specific fault bits + "base_plate_temp_fault": [False, 0x00000001], + "diode_temp_fault": [False, 0x00000002], + "internal_temp_fault": [False, 0x00000004], + "laser_power_supply_fault": [False, 0x00000008], + "i2c_error": [False, 0x00000010], + "over_current": [False, 0x00000020], + "laser_checksum_error": [False, 0x00000040], + "checksum_recovery": [False, 0x00000080], + "buffer_overflow": [False, 0x00000100], + "warm_up_limit_fault": [False, 0x00000200], + "tec_driver_error": [False, 0x00000400], + "ccb_error": [False, 0x00000800], + "diode_temp_limit_error": [False, 0x00001000], + "laser_ready_fault": [False, 0x00002000], + "photodiode_fault": [False, 0x00004000], + "fatal_fault": [False, 0x00008000], + "startup_fault": [False, 0x00010000], + "watchdog_timer_reset": [False, 0x00020000], + "field_calibration": [False, 0x00040000], + # ... + "over_power": [False, 0x00100000], + # Controller specific fault bits + # ... + "controller_checksum": [False, 0x40000000], + "controller_status": [False, 0x80000000], + } + + @has_log + def backdoor_set_interlock(self, value): + """Sets interlock via backdoor + :param value: "ON" or "OFF" + :return: none + """ + if value not in ["ON", "OFF"]: + self.log.error("Interlock can only be set to ON or OFF") + else: + self.interlock = value + + def reset(self): + """Resets all parameters by calling initialize function + """ + self._initialize_data() + + def build_status_code(self): + """ " + Builds the device status code + + :return: status code + """ + return build_code(self.status) + + def build_fault_code(self): + """ " + Builds the device fault code + + :return: fault code + """ + return build_code(self.faults) + + @has_log + def backdoor_set_status(self, statusname, value): + """Sets status code via backdoor + :param statusname: name of status attribute + :param value: true or false + :return: none + """ + try: + self.status[statusname][0] = value + except KeyError: + self.log.error("An error occurred: " + KeyError.message) + + @has_log + def backdoor_set_fault(self, faultname, value): + """Sets fault code via backdoor + :param faultname: name of fault attribute + :param value: true or false + :return: none + """ + try: + self.faults[faultname][0] = value + except KeyError: + self.log.error("An error occurred: " + KeyError.message) + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/chtobisr/interfaces/__init__.py b/lewis/devices/chtobisr/interfaces/__init__.py new file mode 100644 index 00000000..b70e2f9c --- /dev/null +++ b/lewis/devices/chtobisr/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import ChtobisrStreamInterface + +__all__ = ["ChtobisrStreamInterface"] diff --git a/lewis/devices/chtobisr/interfaces/stream_interface.py b/lewis/devices/chtobisr/interfaces/stream_interface.py new file mode 100644 index 00000000..c379e74a --- /dev/null +++ b/lewis/devices/chtobisr/interfaces/stream_interface.py @@ -0,0 +1,68 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + + +@has_log +class ChtobisrStreamInterface(StreamInterface): + """Stream interface for the Coherent OBIS Laser Remote + """ + + commands = { + CmdBuilder("get_id").escape("*IDN?").build(), + CmdBuilder("set_reset").escape("*RST").build(), + CmdBuilder("get_interlock").escape("SYSTEM:LOCK?").build(), + CmdBuilder("get_status").escape("SYSTEM:STATUS?").build(), + CmdBuilder("get_faults").escape("SYSTEM:FAULT?").build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + """If command is not recognised, print and error + + Args: + request: requested string + error: problem + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @conditional_reply("connected") + def get_id(self): + """Gets the device Identification string + + :return: Device ID string + """ + return "{}".format(self._device.id) + + @conditional_reply("connected") + def set_reset(self): + """Resets the device + + :return: none + """ + self._device.reset() + + @conditional_reply("connected") + def get_interlock(self): + """Gets the device interlock status + + :return: Interlock status + """ + return "{}".format(self._device.interlock) + + @conditional_reply("connected") + def get_status(self): + """Returns status code + :return: Formatted status code + """ + return "{:08X}".format(self._device.build_status_code()) + + @conditional_reply("connected") + def get_faults(self): + """Returns faults code + :return: Formatted fault code + """ + return "{:08X}".format(self._device.build_fault_code()) diff --git a/lewis/devices/chtobisr/states.py b/lewis/devices/chtobisr/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/chtobisr/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/cryogenic_sms/__init__.py b/lewis/devices/cryogenic_sms/__init__.py new file mode 100644 index 00000000..f4405407 --- /dev/null +++ b/lewis/devices/cryogenic_sms/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedCRYOSMS + +__all__ = ["SimulatedCRYOSMS"] diff --git a/lewis/devices/cryogenic_sms/device.py b/lewis/devices/cryogenic_sms/device.py new file mode 100644 index 00000000..eb0ca667 --- /dev/null +++ b/lewis/devices/cryogenic_sms/device.py @@ -0,0 +1,122 @@ +from collections import OrderedDict +from datetime import datetime +from typing import Callable + +from lewis.core.statemachine import State +from lewis.devices import StateMachineDevice + +from .states import DefaultInitState, HoldingState, RampingState, TrippedState +from .utils import RampDirection, RampTarget + + +class SimulatedCRYOSMS(StateMachineDevice): + def _initialize_data(self) -> None: + self.connected = True + + # field constant (load line gradient) + self.constant = 0.029 + + # targets + self.max_target = 10.0 + self.mid_target = 0.0 + self.prev_target = 0.0 + self.zero_target = 0.0 + self.at_target = False + + # ramp + self.ramp_target = RampTarget.ZERO + self.ramp_rate = 0.5 + + # paused + self.is_paused = False + + # output + self.output = 0.0 + self.is_output_mode_tesla = False + self.direction = RampDirection.POSITIVE + self.output_voltage = 0.0 + self.output_persist = 0.0 + + # heater + self.is_heater_on = True + self.heater_value = 0.0 + + # quenched + self.is_quenched = False + + # external trip + self.is_xtripped = False + + # PSU voltage limit + self.limit = 5.0 + + # log message + self.log_message = "this is the initial log message" + self.error_message = "" + + def _get_state_handlers(self) -> dict[str, State]: + return { + "init": DefaultInitState(), + "holding": HoldingState(), + "tripped": TrippedState(), + "ramping": RampingState(), + } + + def _get_initial_state(self) -> str: + return "init" + + def _get_transition_handlers(self) -> dict[tuple[str, str], Callable[[], bool]]: + return OrderedDict( + [ + (("init", "ramping"), lambda: not self.at_target and not self.is_paused), + (("ramping", "holding"), lambda: self.is_paused or self.at_target), + (("ramping", "tripped"), lambda: self.is_quenched or self.is_xtripped), + (("holding", "ramping"), lambda: not self.at_target and not self.is_paused), + ] + ) + + # Utilities + + def timestamp_str(self) -> str: + return datetime.now().strftime("%H:%M:%S") + + def switch_mode(self, mode: str) -> None: + if mode == "TESLA" and not self.is_output_mode_tesla: + # going from A to T + self.output *= self.constant + self.max_target *= self.constant + self.mid_target *= self.constant + self.heater_value *= self.constant + self.is_output_mode_tesla = True + elif mode == "AMPS" and self.is_output_mode_tesla: + # going from T to A + self.output /= self.constant + self.max_target /= self.constant + self.mid_target /= self.constant + self.heater_value /= self.constant + self.is_output_mode_tesla = False + + def check_is_at_target(self) -> bool: + self.at_target = self.output == self.ramp_target_value() + return self.at_target + + def ramp_target_value(self) -> float: + if self.ramp_target.name == "MID": + return self.mid_target + elif self.ramp_target.name == "MAX": + return self.max_target + elif self.ramp_target.name == "ZERO": + return self.zero_target + raise ValueError(f"Unknown ramp target {self.ramp_target.name}") + + def switch_direction(self, dir: int) -> None: + """ + :param dir: output direction, can be -1, 0 or 1. If not one of these values, nothing happens + :return: + """ + if dir == 0: + self.direction = RampDirection.ZERO + elif dir == 1: + self.direction = RampDirection.POSITIVE + elif dir == -1: + self.direction = RampDirection.NEGATIVE diff --git a/lewis/devices/cryogenic_sms/interfaces/__init__.py b/lewis/devices/cryogenic_sms/interfaces/__init__.py new file mode 100644 index 00000000..54739b17 --- /dev/null +++ b/lewis/devices/cryogenic_sms/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import CRYOSMSStreamInterface + +__all__ = ["CRYOSMSStreamInterface"] diff --git a/lewis/devices/cryogenic_sms/interfaces/stream_interface.py b/lewis/devices/cryogenic_sms/interfaces/stream_interface.py new file mode 100644 index 00000000..5434f0eb --- /dev/null +++ b/lewis/devices/cryogenic_sms/interfaces/stream_interface.py @@ -0,0 +1,404 @@ +import logging +from datetime import datetime +from typing import TYPE_CHECKING, Literal + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from ..utils import RampDirection, RampTarget + +if TYPE_CHECKING: + from ..device import SimulatedCRYOSMS + +ON_STATES = ["ON", "1"] +OFF_STATES = ["OFF", "0"] + + +if_connected = conditional_reply("connected") + + +@has_log +class CRYOSMSStreamInterface(StreamInterface): + in_terminator = "\r\n" + out_terminator = "" + + def __init__(self) -> None: + self.log: logging.Logger + self._device: SimulatedCRYOSMS + + # Set regex for shorthand or longhand with 0 or more leading and trailing spaces + re_set = " *S(?:ET)* *" + re_get = " *G(?:ET)* *" # Get regex + self.commands = { + # Get commands + CmdBuilder(self.read_direction).regex(re_get).regex("S(?:IGN)*").spaces().eos().build(), + CmdBuilder(self.read_output_mode).spaces().regex("T(?:ESLA)*").spaces().eos().build(), + CmdBuilder(self.read_output).regex(re_get).regex("O(?:UTPUT)*").spaces().eos().build(), + CmdBuilder(self.read_ramp_status) + .spaces() + .regex("R(?:AMP)*") + .spaces() + .regex("S(?:TATUS)*") + .spaces() + .eos() + .build(), + CmdBuilder(self.read_heater_status) + .spaces() + .regex("H(?:EATER)*") + .spaces() + .eos() + .build(), + CmdBuilder(self.read_max_target) + .regex(re_get) + .regex("(?:MAX|!)*") + .spaces() + .eos() + .build(), + CmdBuilder(self.read_mid_target) + .regex(re_get) + .regex("(?:MID|%)*") + .spaces() + .eos() + .build(), + CmdBuilder(self.read_ramp_rate).regex(re_get).regex("R(?:ATE)*").spaces().eos().build(), + CmdBuilder(self.read_limit).regex(re_get).regex("V(?:L)*").spaces().eos().build(), + CmdBuilder(self.read_pause).spaces().regex("P(?:AUSE)*").spaces().eos().build(), + CmdBuilder(self.read_heater_value) + .regex(re_get) + .regex("H(?:V)*") + .spaces() + .eos() + .build(), + CmdBuilder(self.read_constant).regex(re_get).regex("T(?:PA)").spaces().eos().build(), + # Set commands + CmdBuilder(self.write_direction) + .spaces() + .regex("D(?:IRECTION)*") + .spaces() + .arg(r"0|-|\+") + .spaces() + .eos() + .build(), + CmdBuilder(self.write_output_mode) + .spaces() + .regex("T(?:ESLA)*") + .spaces() + .arg("OFF|ON|0|1") + .spaces() + .eos() + .build(), + CmdBuilder(self.write_ramp_target) + .spaces() + .regex("R(?:AMP)*") + .spaces() + .arg("ZERO|MID|MAX|0|%|!") + .spaces() + .eos() + .build(), + CmdBuilder(self.write_heater_status) + .spaces() + .regex("H(?:EATER)*") + .spaces() + .arg("OFF|ON|1|0") + .spaces() + .eos() + .build(), + CmdBuilder(self.write_pause) + .spaces() + .regex("P(?:AUSE)*") + .spaces() + .arg("OFF|ON|0|1") + .spaces() + .eos() + .build(), + CmdBuilder(self.write_heater_value) + .regex(re_set) + .regex("H(?:EATER)*") + .spaces() + .float() + .spaces() + .eos() + .build(), + CmdBuilder(self.write_max_target) + .regex(re_set) + .regex("(?:MAX|!)") + .spaces() + .float() + .spaces() + .eos() + .build(), + CmdBuilder(self.write_mid_target) + .regex(re_set) + .regex("(?:MID|%)") + .spaces() + .float() + .spaces() + .eos() + .build(), + CmdBuilder(self.write_ramp_rate) + .regex(re_set) + .regex("R(?:AMP)*") + .spaces() + .float() + .spaces() + .eos() + .build(), + CmdBuilder(self.write_limit) + .regex(re_set) + .regex("L(?:IMIT)*") + .spaces() + .float() + .spaces() + .eos() + .build(), + CmdBuilder(self.write_constant) + .regex(re_set) + .regex("T(?:PA)*") + .spaces() + .float() + .spaces() + .eos() + .build(), + } + + def _out_message(self, message: str, terminator: str = "\r\n\x13") -> str: + return "........ {}{}".format(message, terminator) + + def _timestamp(self) -> str: + return datetime.now().strftime("%H:%M:%S") + + def _create_log_message(self, pv: str, value: float | int | str, suffix: str = "") -> None: + current_time = self._timestamp() + self._device.log_message = "{} {}: {}{}".format(current_time, pv, value, suffix) + + def handle_error(self, request: str, error: str | BaseException) -> None: + self.log.error("Error occurred at {}: {}".format(request, error)) + + def _get_output_mode_string(self) -> str: + return "TESLA" if self._device.is_output_mode_tesla else "AMPS" + + def _get_paused_state_str(self) -> str: + return "ON" if self._device.is_paused else "OFF" + + def _get_ramp_target_value(self) -> float: + if self._device.ramp_target.name == "MID": + return self._device.mid_target + elif self._device.ramp_target.name == "MAX": + return self._device.max_target + elif self._device.ramp_target.name == "ZERO": + return self._device.zero_target + raise RuntimeError("Unknown ramp target {}".format(self._device.ramp_target.name)) + + @if_connected + def read_direction(self) -> str: + return "........ CURRENT DIRECTION: {}\r\n\x13".format(self._device.direction.name) + + @if_connected + def write_direction(self, direction: Literal["+"] | Literal["-"] | Literal["0"]) -> str: + if direction == "+": + self._device.direction = RampDirection.POSITIVE + if direction == "-": + self._device.direction = RampDirection.NEGATIVE + if direction == "0": + self._device.direction = RampDirection.ZERO + return "\x13" + + @if_connected + def read_output_mode(self) -> str: + return self._out_message("UNITS: {}\r\n\x13".format(self._get_output_mode_string())) + + @if_connected + def read_output(self) -> str: + sign = -1 if self._device.direction == RampDirection.NEGATIVE else 1 + return "........ OUTPUT: {} {} AT {} VOLTS \r\n\x13".format( + self._device.output * sign, self._get_output_mode_string(), self._device.output_voltage + ) + + @if_connected + def write_output_mode(self, output_mode: str) -> str: + # Convert values if output mode is changing between amps(OFF) / tesla(ON) + constant = self._device.constant + if output_mode in ON_STATES: + self._create_log_message("UNITS", "TESLA") + if not self._device.is_output_mode_tesla: + self._device.switch_mode("TESLA") + elif output_mode in OFF_STATES: + self._create_log_message("UNITS", "AMPS") + if constant == 0: + self._device.error_message = "------> No field constant has been entered" + else: + if self._device.is_output_mode_tesla: + self._device.switch_mode("AMPS") + else: + raise ValueError("Invalid arguments sent") + return f"{self._device.log_message}\r\n\x13" + + @if_connected + def read_ramp_target(self) -> str: + return self._out_message("RAMP TARGET: {}".format(self._device.ramp_target.name)) + + @if_connected + def read_ramp_status(self) -> str: + output = self._device.output + status_message = "RAMP STATUS: " + if self._device.is_paused: + status_message += "HOLDING ON PAUSE AT {} {}".format( + output, self._get_output_mode_string() + ) + elif self._device.at_target: + status_message += "HOLDING ON TARGET AT {} {}".format( + output, self._get_output_mode_string() + ) + elif self._device.is_quenched: + status_message += "QUENCH TRIP AT {} {}".format(output, self._get_output_mode_string()) + elif self._device.is_xtripped: + status_message += "EXTERNAL TRIP AT {} {}".format( + output, self._get_output_mode_string() + ) + elif not self._device.at_target and not self._device.is_paused: + status_message += "RAMPING FROM {} TO {} {} AT {:07.5f} A/SEC".format( + self._device.prev_target, + self._device.mid_target, + self._get_output_mode_string(), + self._device.ramp_rate, + ) + else: + raise ValueError("Didn't match any of the expected conditions") + return self._out_message(status_message) + + @if_connected + def write_ramp_target(self, ramp_target_str: str) -> None: + if ramp_target_str in ["0", "ZERO"]: + ramp_target = RampTarget.ZERO + elif ramp_target_str in ["%", "MID"]: + ramp_target = RampTarget.MID + elif ramp_target_str in ["!", "MAX"]: + ramp_target = RampTarget.MAX + else: + raise ValueError("Invalid arguments sent") + self._device.ramp_target = ramp_target + self._device.is_paused = False + + @if_connected + def read_ramp_rate(self) -> str: + return self._out_message("RAMP RATE: {} A/SEC".format(self._device.ramp_rate)) + + @if_connected + def write_ramp_rate(self, ramp_rate: int | float | str) -> str: + self._device.ramp_rate = float(ramp_rate) + self._create_log_message("RAMP RATE", ramp_rate, suffix=" A/SEC") + return f"{self._device.log_message}\r\n\x13" + + @if_connected + def read_heater_status(self) -> str: + heater_value = "ON" if self._device.is_heater_on else "OFF" + if self._device.output_persist != 0.0 and heater_value == "OFF": + return self._out_message( + "HEATER STATUS: SWITCHED OFF AT {} {}".format( + self._device.output_persist, self._get_output_mode_string() + ) + ) + else: + return self._out_message("HEATER STATUS: {}".format(heater_value)) + + @if_connected + def write_heater_status(self, heater_status: str) -> str: + if heater_status in ON_STATES: + self._device.output_persist = 0.0 + self._device.is_heater_on = True + elif heater_status in OFF_STATES: + self._device.output_persist = self._device.output + self._device.is_heater_on = False + else: + raise ValueError("Invalid arguments sent") + self._create_log_message("HEATER STATUS", heater_status) + return self._out_message(f"HEATER STATUS: {heater_status}") + + @if_connected + def read_pause(self) -> str: + return self._out_message("PAUSE STATUS: {}".format(self._get_paused_state_str())) + + @if_connected + def write_pause(self, paused: str) -> str: + mode = self._get_output_mode_string() + target = self._get_ramp_target_value() + rate = self._device.ramp_rate + output = "HOLDING ON PAUSE AT {} {}".format(self._device.output, mode) + if paused in ON_STATES: + self._device.is_paused = True + self._create_log_message("PAUSE STATUS", output) + elif paused in OFF_STATES: + self._device.is_paused = False + if self._device.check_is_at_target(): + self._create_log_message("RAMP STATUS", output) + else: + output = ( + f"RAMPING FROM {self._device.output:.6f} " + f"TO {target:.6f} {mode} AT {rate:.6f} A/SEC" + ) + self._create_log_message("RAMP STATUS", output) + else: + raise ValueError("Invalid arguments sent") + + return self._out_message("PAUSE STATUS: {}".format(paused)) + + @if_connected + def read_heater_value(self) -> str: + return self._out_message("HEATER OUTPUT: {} VOLTS".format(self._device.heater_value)) + + @if_connected + def write_heater_value(self, heater_value: float) -> str: + self._device.heater_value = heater_value + self._create_log_message("HEATER OUTPUT", heater_value, suffix=" VOLTS") + return f"{self._device.log_message}\r\n\x13" + + @if_connected + def read_max_target(self) -> str: + mode = self._get_output_mode_string() + return self._out_message( + "MAX SETTING: {:.4} {}".format(float(self._device.max_target), mode), terminator="\r\n" + ) + + @if_connected + def write_max_target(self, max_target: int | float | str) -> str: + self._device.max_target = abs(float(max_target)) # abs because PSU ignores sign + units = self._get_output_mode_string() + self._create_log_message("MAX SETTING", max_target, suffix=" {}".format(units)) + return f"{self._device.log_message}\r\n\x13" + + @if_connected + def read_mid_target(self) -> str: + mode = self._get_output_mode_string() + return self._out_message( + "MID SETTING: {:.4} {}".format(float(self._device.mid_target), mode), + terminator="\r\n\x13", + ) + + @if_connected + def write_mid_target(self, mid_target: int | float | str) -> str: + self._device.mid_target = abs(float(mid_target)) # abs because PSU ignores sign + units = self._get_output_mode_string() + self._create_log_message("MID SETTING", mid_target, suffix=" {}".format(units)) + return f"{self._device.log_message}\r\n\x13" + + @if_connected + def read_limit(self) -> str: + return self._out_message("VOLTAGE LIMIT: {} VOLTS".format(self._device.limit)) + + @if_connected + def write_limit(self, limit: float) -> str: + self._device.limit = limit + self._create_log_message("VOLTAGE LIMIT", limit, suffix=" VOLTS") + return f"{self._device.log_message}\r\n\x13" + + @if_connected + def read_constant(self) -> str: + return self._out_message("FIELD CONSTANT: {:.7} T/A".format(self._device.constant)) + + @if_connected + def write_constant(self, constant: int | float | str) -> str: + self._device.constant = float(constant) + self._create_log_message("FIELD CONSTANT", constant, suffix=" T/A") + return f"{self._device.log_message}\r\n\x13" diff --git a/lewis/devices/cryogenic_sms/states.py b/lewis/devices/cryogenic_sms/states.py new file mode 100644 index 00000000..17c3b0b1 --- /dev/null +++ b/lewis/devices/cryogenic_sms/states.py @@ -0,0 +1,56 @@ +import logging +import typing + +from lewis.core import approaches +from lewis.core.logging import has_log +from lewis.core.statemachine import State + +if typing.TYPE_CHECKING: + from .device import SimulatedCRYOSMS + + +def get_target_value(device: "SimulatedCRYOSMS") -> float: + if device.ramp_target == "MID": + target_value = device.mid_target + elif device.ramp_target == "MAX": + target_value = device.max_target + else: + target_value = 0 + + return target_value + + +class DefaultInitState(State): + def in_state(self, dt: float) -> None: + device = typing.cast("SimulatedCRYOSMS", self._context) + device.check_is_at_target() + + +@has_log +class HoldingState(State): + def on_entry(self, dt: float) -> None: + self.log: logging.Logger + self.log.info("*********** ENTERED HOLD STATE") + + def in_state(self, dt: float) -> None: + device = typing.cast("SimulatedCRYOSMS", self._context) + device.check_is_at_target() + + +class TrippedState(State): + def in_state(self, dt: float) -> None: + pass + + +class RampingState(State): + def in_state(self, dt: float) -> None: + device = typing.cast("SimulatedCRYOSMS", self._context) + # to avoid tests taking forever, ignoring actual rate in + # favour of value that ramps between boundaries in roughly 8 seconds + rate = 0.05 + target = device.ramp_target_value() + constant = device.constant + if device.is_output_mode_tesla: + rate = rate * constant + device.output = approaches.linear(device.output, target, rate, dt) + device.check_is_at_target() diff --git a/lewis/devices/cryogenic_sms/utils.py b/lewis/devices/cryogenic_sms/utils.py new file mode 100644 index 00000000..31a963df --- /dev/null +++ b/lewis/devices/cryogenic_sms/utils.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class RampTarget(Enum): + ZERO = 0 + MID = 1 + MAX = 2 + + +class RampDirection(Enum): + NEGATIVE = 0 + ZERO = 1 + POSITIVE = 2 diff --git a/lewis/devices/cybaman/__init__.py b/lewis/devices/cybaman/__init__.py new file mode 100644 index 00000000..7473c973 --- /dev/null +++ b/lewis/devices/cybaman/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedCybaman + +__all__ = ["SimulatedCybaman"] diff --git a/lewis/devices/cybaman/device.py b/lewis/devices/cybaman/device.py new file mode 100644 index 00000000..a87ebbab --- /dev/null +++ b/lewis/devices/cybaman/device.py @@ -0,0 +1,75 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import InitializedState, MovingState, UninitializedState + + +class SimulatedCybaman(StateMachineDevice): + """Simulated cyber man. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.connected = True + + self.a_setpoint = 0 + self.b_setpoint = 0 + self.c_setpoint = 0 + + self.a = self.a_setpoint + self.b = self.b_setpoint + self.c = self.c_setpoint + + self.home_position_axis_a = 66 + self.home_position_axis_b = 77 + self.home_position_axis_c = 88 + + self.initialized = False + + def _get_state_handlers(self): + """Returns: states and their names + """ + return { + InitializedState.NAME: InitializedState(), + UninitializedState.NAME: UninitializedState(), + MovingState.NAME: MovingState(), + } + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return UninitializedState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict( + [ + ((UninitializedState.NAME, InitializedState.NAME), lambda: self.initialized), + ((InitializedState.NAME, UninitializedState.NAME), lambda: not self.initialized), + ((MovingState.NAME, UninitializedState.NAME), lambda: not self.initialized), + ( + (InitializedState.NAME, MovingState.NAME), + lambda: self.a != self.a_setpoint + or self.b != self.b_setpoint + or self.c != self.c_setpoint, + ), + ( + (MovingState.NAME, InitializedState.NAME), + lambda: self.a == self.a_setpoint + and self.b == self.b_setpoint + and self.c == self.c_setpoint, + ), + ] + ) + + def home_axis_a(self): + self.a_setpoint = self.home_position_axis_a + + def home_axis_b(self): + self.b_setpoint = self.home_position_axis_b + + def home_axis_c(self): + self.c_setpoint = self.home_position_axis_c diff --git a/lewis/devices/cybaman/interfaces/__init__.py b/lewis/devices/cybaman/interfaces/__init__.py new file mode 100644 index 00000000..48af746f --- /dev/null +++ b/lewis/devices/cybaman/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import CybamanStreamInterface + +__all__ = ["CybamanStreamInterface"] diff --git a/lewis/devices/cybaman/interfaces/stream_interface.py b/lewis/devices/cybaman/interfaces/stream_interface.py new file mode 100644 index 00000000..dc01f5e8 --- /dev/null +++ b/lewis/devices/cybaman/interfaces/stream_interface.py @@ -0,0 +1,112 @@ +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +class CybamanStreamInterface(StreamInterface): + """Stream interface for the serial port + """ + + FLOAT = "([-+]?[0-9]*\.?[0-9]*)" + + commands = { + Cmd("initialize", "^A$"), + Cmd("get_a", "^M101$"), + Cmd("get_b", "^M201$"), + Cmd("get_c", "^M301$"), + Cmd( + "set_all", + "^OPEN PROG 10 CLEAR\nG1 A " + FLOAT + " B " + FLOAT + " C " + FLOAT + " TM([0-9]*)$", + ), + Cmd("ignore", "^CLOSE$"), + Cmd("ignore", "^B10R$"), + Cmd("reset", "^\$\$\$$"), + Cmd("home_a", "^B9001R$"), + Cmd("home_b", "^B9002R$"), + Cmd("home_c", "^B9003R$"), + Cmd("stop", "^{}$".format(chr(0x01))), + } + + in_terminator = "\r" + + # ACK character + out_terminator = chr(0x06) + + @has_log + def handle_error(self, request, error): + """If command is not recognised print and error. + + :param request: requested string + :param error: problem + :return: + """ + error = "An error occurred at request " + repr(request) + ": " + repr(error) + print(error) + self.log.debug(error) + return error + + @if_connected + def ignore(self): + return "" + + @if_connected + def initialize(self): + self._device.initialized = True + return "" + + @if_connected + def stop(self): + self._device.initialized = False + + @if_connected + def reset(self): + self._device._initialize_data() + return "" + + @if_connected + def get_a(self): + return "{}\r".format(self._device.a * 3577) + + @if_connected + def get_b(self): + return "{}\r".format(self._device.b * 3663) + + @if_connected + def get_c(self): + return "{}\r".format(self._device.c * 3663) + + @if_connected + def set_all(self, a, b, c, tm): + self._verify_tm(a, b, c, tm) + + self._device.a_setpoint = float(a) + self._device.b_setpoint = float(b) + self._device.c_setpoint = float(c) + return "" + + def _verify_tm(self, a, b, c, tm): + tm = int(tm) + old_position = (self._device.a, self._device.b, self._device.c) + new_position = (float(a), float(b), float(c)) + + max_difference = max([abs(a - b) for a, b in zip(old_position, new_position)]) + expected_tm = max([int(round(max_difference / 5.0)) * 1000, 4000]) + + # Allow a difference of 1000 for rounding errors / differences between labview and epics + # (error would get multiplied by 1000) + if abs(tm - expected_tm) > 1000: + assert False, "Wrong TM value! Expected {} but got {}".format(expected_tm, tm) + + def home_a(self): + self._device.home_axis_a() + return "" + + def home_b(self): + self._device.home_axis_b() + return "" + + def home_c(self): + self._device.home_axis_c() + return "" diff --git a/lewis/devices/cybaman/states.py b/lewis/devices/cybaman/states.py new file mode 100644 index 00000000..de5e342f --- /dev/null +++ b/lewis/devices/cybaman/states.py @@ -0,0 +1,30 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class UninitializedState(State): + NAME = "UninitializedState" + + def on_entry(self, dt): + print("Entering uninitialized state") + + +class InitializedState(State): + NAME = "InitializedState" + + def on_entry(self, dt): + print("Entering initialized state") + + +class MovingState(State): + NAME = "MovingState" + + def in_state(self, dt): + device = self._context + + device.a = approaches.linear(device.a, device.a_setpoint, 10, dt) + device.b = approaches.linear(device.b, device.b_setpoint, 10, dt) + device.c = approaches.linear(device.c, device.c_setpoint, 10, dt) + + def on_entry(self, dt): + print("Entering moving state") diff --git a/lewis/devices/danfysik/__init__.py b/lewis/devices/danfysik/__init__.py new file mode 100644 index 00000000..d47c28ea --- /dev/null +++ b/lewis/devices/danfysik/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedDanfysik + +__all__ = ["SimulatedDanfysik"] diff --git a/lewis/devices/danfysik/device.py b/lewis/devices/danfysik/device.py new file mode 100644 index 00000000..7e75558d --- /dev/null +++ b/lewis/devices/danfysik/device.py @@ -0,0 +1,159 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class FieldUnits(object): + """Field units. + """ + + OERSTED = object() + GAUSS = object() + TESLA = object() + + +class SimulatedDanfysik(StateMachineDevice): + """Simulated Danfysik. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.comms_initialized = False + self.connected = True + + self.field = 0 + self.field_sp = 0 + + self.absolute_current = 0 + self.voltage = 0 + self.voltage_read_factor = 1 + self.current_read_factor = 1 + self.current_write_factor = 1 + + # DAC 1, DAC 2, DAC 1 absolute slew rates + self.slew_rate = [0, 0, 0] + + self.field_units = FieldUnits.GAUSS + self.negative_polarity = False + self.power = True + + # Use a list of active interlocks because each danfysik has different sets of interlocks which can be enabled. + self.active_interlocks = [] + + self.currently_addressed_psu = 0 + self.address = 75 + + def enable_interlock(self, name): + """Adds an interlock to the list of enabled interlock + Args: + name: the name of the interlock to enable. + """ + if name not in self.active_interlocks: + self.active_interlocks.append(name) + + def disable_interlock(self, name): + """Removes an interlock from the list of enabled interlocks + Args: + name: the name of the interlock to disable. + """ + if name in self.active_interlocks: + self.active_interlocks.remove(name) + + def set_address(self, value): + """Changes the currently addressed PSU + + Args: + value: int, the address to set the PSU to. + """ + self.currently_addressed_psu = value + + self.log.info("Address set to, {}".format(value)) + + if self.address != self.currently_addressed_psu: + self.comms_initialized = False + self.log.info("Device down") + else: + self.comms_initialized = True + self.log.info("Device up") + + def reset(self): + """Reset the device to the standard off configuration. + """ + self.absolute_current = 0 + self.voltage = 0 + self.power = False + + def reinitialise(self): + """Reinitialise the device state (this is mainly used via the backdoor to clean up between tests) + """ + self._initialize_data() + + def set_current_read_factor(self, factor): + """Set the scale factor between current and raw when reading a value. + + Args: + factor: The scale factor to apply. + """ + self.current_read_factor = factor + + def set_current_write_factor(self, factor): + """Set the scale factor between current and raw when writing a value. + + Args: + factor: The scale factor to apply. + """ + self.current_write_factor = factor + + def get_current(self): + """Return: + The readback value of current as raw value (parts per 100,000) + """ + raw_rbv = self.absolute_current / self.current_read_factor + return raw_rbv + + def get_last_setpoint(self): + """Return: + The setpoint readback value of current as raw value (parts per 1,000,000) + """ + raw_sp_rbv = self.absolute_current * self.current_write_factor + return raw_sp_rbv + + def set_current(self, raw_sp): + """Set a new value for current. + + Args: + raw_sp: The new value in raw (parts per 1,000,000) + """ + current = raw_sp / self.current_write_factor + self.absolute_current = abs(current) + self.negative_polarity = current < 0 + + def get_voltage(self): + """Return: + The readback value of voltage scaled by the custom scale factor + """ + return self.voltage * self.voltage_read_factor + + def set_slew_rate(self, dac_num, value): + self.slew_rate[dac_num - 1] = value + + def get_slew_rate(self, dac_num): + return self.slew_rate[dac_num - 1] + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() diff --git a/lewis/devices/danfysik/interfaces/__init__.py b/lewis/devices/danfysik/interfaces/__init__.py new file mode 100644 index 00000000..dcbc59e7 --- /dev/null +++ b/lewis/devices/danfysik/interfaces/__init__.py @@ -0,0 +1,10 @@ +from .dfkps_9X00 import Danfysik9X00StreamInterface +from .dfkps_8000 import Danfysik8000StreamInterface +from .dfkps_8800 import Danfysik8800StreamInterface +from .dfkps_base import CommonStreamInterface + +__all__ = [ + "Danfysik8000StreamInterface", + "Danfysik8800StreamInterface", + "Danfysik9X00StreamInterface", +] diff --git a/lewis/devices/danfysik/interfaces/dfkps_8000.py b/lewis/devices/danfysik/interfaces/dfkps_8000.py new file mode 100644 index 00000000..18f65413 --- /dev/null +++ b/lewis/devices/danfysik/interfaces/dfkps_8000.py @@ -0,0 +1,64 @@ +"""Stream device for danfysik 8000 +""" + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from .dfkps_base import CommonStreamInterface + +__all__ = ["Danfysik8000StreamInterface"] + + +@has_log +class Danfysik8000StreamInterface(CommonStreamInterface, StreamInterface): + """Stream interface for a Danfysik model 8000. + """ + + protocol = "model8000" + + commands = CommonStreamInterface.commands + [ + CmdBuilder("set_current").escape("DA 0 ").int().eos().build(), + CmdBuilder("get_current").escape("AD 8").eos().build(), + CmdBuilder("init_comms").escape("UNLOCK").build(), + ] + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_status(self): + """Respond to the get_status command (S1) + """ + response = ( + "{power_off}{pol_normal}{pol_reversed}{reg_transformer}{dac16}{dac17}{is_percent}{spare}" + "{transistor_fault}{sum_interlock}{dc_overcurrent}{dc_overload}{reg_mod_fail}{prereg_fail}" + "{phase_fail}{mps_waterflow_fail}{earth_leak_fail}{thermal_fail}{mps_overtemperature}" + "{door_switch}{mag_waterflow_fail}{mag_overtemp}{mps_not_ready}{spare}".format( + spare=self.bit(False), + power_off=self.bit(not self.device.power), + pol_normal=self.bit(not self.device.negative_polarity), + pol_reversed=self.bit(self.device.negative_polarity), + reg_transformer=self.bit(False), + dac16=self.bit(False), + dac17=self.bit(False), + is_percent=self.bit(False), + transistor_fault=self.interlock("transistor_fault"), + sum_interlock=self.bit(len(self.device.active_interlocks) > 0), + dc_overcurrent=self.interlock("dc_overcurrent"), + dc_overload=self.interlock("dc_overload"), + reg_mod_fail=self.interlock("reg_mod_fail"), + prereg_fail=self.interlock("prereg_fail"), + phase_fail=self.interlock("phase_fail"), + mps_waterflow_fail=self.interlock("mps_waterflow_fail"), + earth_leak_fail=self.interlock("earth_leak_fail"), + thermal_fail=self.interlock("thermal_fail"), + mps_overtemperature=self.interlock("mps_overtemperature"), + door_switch=self.interlock("door_switch"), + mag_waterflow_fail=self.interlock("mag_waterflow_fail"), + mag_overtemp=self.interlock("mag_overtemp"), + mps_not_ready=self.bit(not self.device.power), + ) + ) + + assert len(response) == 24, "length should have been 24 but was {}".format(len(response)) + return response diff --git a/lewis/devices/danfysik/interfaces/dfkps_8500.py b/lewis/devices/danfysik/interfaces/dfkps_8500.py new file mode 100644 index 00000000..2ca4fbc9 --- /dev/null +++ b/lewis/devices/danfysik/interfaces/dfkps_8500.py @@ -0,0 +1,99 @@ +"""Stream device for danfysik 8500""" + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from .dfkps_base import CommonStreamInterface + +__all__ = ["Danfysik8500StreamInterface"] + + +@has_log +class Danfysik8500StreamInterface(CommonStreamInterface, StreamInterface): + """Stream interface for a Danfysik model 8500.""" + + in_terminator = "\r" + out_terminator = "\n\r" + + protocol = "model8500" + + # This is the address of the LOQ danfysik 8500 + PSU_ADDRESS = 75 + + commands = CommonStreamInterface.commands + [ + # See https://github.com/ISISComputingGroup/IBEX/issues/8502 for justification about why + # we are using WA over DA 0 + CmdBuilder("set_current").escape("WA ").int().eos().build(), + CmdBuilder("get_current").escape("AD 8").eos().build(), + CmdBuilder("set_address").escape("ADR ").int().eos().build(), + CmdBuilder("get_address").escape("ADR").eos().build(), + CmdBuilder("init_comms").escape("REM").eos().build(), + CmdBuilder("init_comms").escape("UNLOCK").eos().build(), + CmdBuilder("get_slew_rate").escape("R").arg(r"[1-3]", argument_mapping=int).eos().build(), + CmdBuilder("set_slew_rate") + .escape("W") + .arg(r"[1-3]", argument_mapping=int) + .spaces() + .int() + .eos() + .build(), + ] + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_status(self) -> str: + """Respond to the get_status command (S1)""" + response = ( + "{power_off}{pol_normal}{pol_reversed}{reg_transformer}{dac16}{dac17}{is_percent}{spare}" + "{transistor_fault}{sum_interlock}{dc_overcurrent}{dc_overload}{reg_mod_fail}{prereg_fail}" + "{phase_fail}{mps_waterflow_fail}{earth_leak_fail}{thermal_fail}{mps_overtemperature}" + "{door_switch}{mag_waterflow_fail}{mag_overtemp}{mps_not_ready}{spare}".format( + spare=self.bit(False), + power_off=self.bit(not self.device.power), + pol_normal=self.bit(not self.device.negative_polarity), + pol_reversed=self.bit(self.device.negative_polarity), + reg_transformer=self.bit(False), + dac16=self.bit(False), + dac17=self.bit(False), + is_percent=self.bit(False), + transistor_fault=self.interlock("transistor_fault"), + sum_interlock=self.bit(len(self.device.active_interlocks) > 0), + dc_overcurrent=self.interlock("dc_overcurrent"), + dc_overload=self.interlock("dc_overload"), + reg_mod_fail=self.interlock("reg_mod_fail"), + prereg_fail=self.interlock("prereg_fail"), + phase_fail=self.interlock("phase_fail"), + mps_waterflow_fail=self.interlock("mps_waterflow_fail"), + earth_leak_fail=self.interlock("earth_leak_fail"), + thermal_fail=self.interlock("thermal_fail"), + mps_overtemperature=self.interlock("mps_overtemperature"), + door_switch=self.interlock("door_switch"), + mag_waterflow_fail=self.interlock("mag_waterflow_fail"), + mag_overtemp=self.interlock("mag_overtemp"), + mps_not_ready=self.bit(not self.device.power), + ) + ) + + assert len(response) == 24, "length should have been 24 but was {}".format(len(response)) + + return response + + def set_address(self, value: int) -> None: + self.device.set_address(value) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_address(self) -> str: + return "{:03d}".format(self.address) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_slew_rate(self, dac_num: int) -> float: + return self.device.get_slew_rate(dac_num) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def set_slew_rate(self, dac_num: int, slew_rate_value: float) -> None: + self.device.set_slew_rate(dac_num, slew_rate_value) diff --git a/lewis/devices/danfysik/interfaces/dfkps_8800.py b/lewis/devices/danfysik/interfaces/dfkps_8800.py new file mode 100644 index 00000000..1b708c49 --- /dev/null +++ b/lewis/devices/danfysik/interfaces/dfkps_8800.py @@ -0,0 +1,67 @@ +"""Stream device for danfysik 8800 +""" + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from .dfkps_base import CommonStreamInterface + +__all__ = ["Danfysik8800StreamInterface"] + + +@has_log +class Danfysik8800StreamInterface(CommonStreamInterface, StreamInterface): + """Stream interface for a Danfysik model 8800. + """ + + protocol = "model8800" + + commands = CommonStreamInterface.commands + [ + CmdBuilder("set_current").escape("WA ").int().eos().build(), + CmdBuilder("get_current").escape("ADCV").eos().build(), + CmdBuilder("init_comms").escape("ADR 000").build(), + ] + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_status(self): + """Respond to the get_status command (S1) + """ + response = ( + "{spare}{user1}{user2}{user3}{user4}{user5}{user6}{fw_diode_overtemp}{low_water_flow}{door_open}" + "{pol_normal}{pol_reversed}{spare}{spare}{spare}{spare}{diode_heatsink}{chassis_overtemp}" + "{igbt_heatsink_overtemp}{hf_diode_overtemp}{switch_reg_ddct_fail}{switch_reg_supply_fail}" + "{igbt_driver_fail}{spare}{spare}{ac_undervolt}{spare}{ground_ripple}{ground_leak}" + "{overcurrent}{power_on}{ready}".format( + spare=self.bit(False), + user1=self.interlock("user1"), + user2=self.interlock("user2"), + user3=self.interlock("user3"), + user4=self.interlock("user4"), + user5=self.interlock("user5"), + user6=self.interlock("user6"), + pol_normal=self.bit(not self.device.negative_polarity), + pol_reversed=self.bit(self.device.negative_polarity), + fw_diode_overtemp=self.interlock("fw_diode_overtemp"), + low_water_flow=self.interlock("low_water_flow"), + door_open=self.interlock("door_open"), + diode_heatsink=self.interlock("diode_heatsink"), + chassis_overtemp=self.interlock("chassis_overtemp"), + igbt_heatsink_overtemp=self.interlock("igbt_heatsink_overtemp"), + hf_diode_overtemp=self.interlock("hf_diode_overtemp"), + switch_reg_ddct_fail=self.interlock("switch_reg_ddct_fail"), + switch_reg_supply_fail=self.interlock("switch_reg_supply_fail"), + igbt_driver_fail=self.interlock("igbt_driver_fail"), + ac_undervolt=self.interlock("ac_undervolt"), + ground_ripple=self.interlock("ground_ripple"), + ground_leak=self.interlock("ground_leak"), + overcurrent=self.interlock("overcurrent"), + power_on=self.bit(not self.device.power), + ready=self.bit(self.device.power), + ) + ) + + assert len(response) == 32 + return response diff --git a/lewis/devices/danfysik/interfaces/dfkps_9X00.py b/lewis/devices/danfysik/interfaces/dfkps_9X00.py new file mode 100644 index 00000000..2ee265ee --- /dev/null +++ b/lewis/devices/danfysik/interfaces/dfkps_9X00.py @@ -0,0 +1,97 @@ +"""Stream device for danfysik 9X00 +""" + + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from .dfkps_base import CommonStreamInterface + +__all__ = ["Danfysik9X00StreamInterface"] + + +@has_log +class Danfysik9X00StreamInterface(CommonStreamInterface, StreamInterface): + """Stream interface for a Danfysik model 9100. + """ + + in_terminator = "\r" + out_terminator = "\n\r" + + protocol = "model9X00" + + # This is the address of the LOQ danfysik 8500 + PSU_ADDRESS = 75 + + commands = CommonStreamInterface.commands + [ + CmdBuilder("set_current").escape("DA 0 ").int().eos().build(), + CmdBuilder("get_current").escape("AD 8").eos().build(), + CmdBuilder("set_address").escape("ADR ").int().eos().build(), + CmdBuilder("get_address").escape("ADR").eos().build(), + CmdBuilder("init_comms").escape("REM").eos().build(), + CmdBuilder("init_comms").escape("UNLOCK").eos().build(), + CmdBuilder("get_slew_rate").escape("R").arg(r"[1-3]", argument_mapping=int).eos().build(), + CmdBuilder("set_slew_rate") + .escape("W") + .arg(r"[1-3]", argument_mapping=int) + .spaces() + .int() + .eos() + .build(), + ] + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_status(self): + """Respond to the get_status command (S1) + """ + response = ( + "{power_off}{pol_normal}{pol_reversed}{spare}{crowbar}{imode}{is_percent}{external_interlock_0}" + "{spare}{sum_interlock}{over_voltage}{dc_overcurrent}{dc_undervoltage}{spare}" + "{phase_fail}{spare}{earth_leak_fail}{fan}{mps_overtemperature}" + "{external_interlock_1}{external_interlock_2}{external_interlock_3}{mps_not_ready}{spare}".format( + spare=self.bit(False), + power_off=self.bit(not self.device.power), + pol_normal=self.bit(not self.device.negative_polarity), + pol_reversed=self.bit(self.device.negative_polarity), + crowbar=self.bit(False), + imode=self.bit(False), + is_percent=self.bit(False), + external_interlock_0=self.interlock("external_interlock_0"), + sum_interlock=self.bit(len(self.device.active_interlocks) > 0), + dc_overcurrent=self.interlock("dc_overcurrent"), + over_voltage=self.interlock("over_voltage"), + dc_undervoltage=self.interlock("dc_undervoltage"), + phase_fail=self.interlock("phase_fail"), + earth_leak_fail=self.interlock("earth_leak_fail"), + fan=self.interlock("fan"), + mps_overtemperature=self.interlock("mps_overtemperature"), + external_interlock_1=self.interlock("external_interlock_1"), + external_interlock_2=self.interlock("external_interlock_2"), + external_interlock_3=self.interlock("external_interlock_3"), + mps_not_ready=self.bit(not self.device.power), + ) + ) + + assert len(response) == 24, "length should have been 24 but was {}".format(len(response)) + return response + + def set_address(self, value): + self.device.set_address(value) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_address(self): + return "{:03d}".format(self.address) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_slew_rate(self, dac_num): + return self.device.get_slew_rate(dac_num) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def set_slew_rate(self, dac_num, slew_rate_value): + self.device.set_slew_rate(dac_num, slew_rate_value) diff --git a/lewis/devices/danfysik/interfaces/dfkps_RIKEN.py b/lewis/devices/danfysik/interfaces/dfkps_RIKEN.py new file mode 100644 index 00000000..d0ffc873 --- /dev/null +++ b/lewis/devices/danfysik/interfaces/dfkps_RIKEN.py @@ -0,0 +1,46 @@ +"""Stream device for danfysik 8500-like PSU on RIKEN (RB2) +""" + +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from .dfkps_8500 import Danfysik8500StreamInterface +from .dfkps_base import CommonStreamInterface + +__all__ = ["DanfysikRIKENStreamInterface"] + + +@has_log +class DanfysikRIKENStreamInterface(Danfysik8500StreamInterface): + """Stream interface for a Danfysik-like PSU on RIKEN (RB2). Inherited from Danfysik 8500. + """ + + # use modified protocol file for RB2 PSU + protocol = "RIKEN" + + commands = CommonStreamInterface.commands + [ + CmdBuilder("set_current") + .escape("WA ") + .int() + .eos() + .build(), # ** only difference from 8500 ** + CmdBuilder("get_current").escape("AD 8").eos().build(), + CmdBuilder("set_address").escape("ADR ").int().eos().build(), + CmdBuilder("get_address").escape("ADR").eos().build(), + CmdBuilder("init_comms").escape("REM").eos().build(), + CmdBuilder("init_comms").escape("UNLOCK").eos().build(), + CmdBuilder("get_slew_rate").escape("R").arg(r"[1-3]", argument_mapping=int).eos().build(), + CmdBuilder("set_slew_rate") + .escape("W") + .arg(r"[1-3]", argument_mapping=int) + .spaces() + .int() + .eos() + .build(), + ] + + +# Remove instance of 8500 stream interface (imported above), +# so that only one interface for RIKEN is exported from this module. (LeWIS was reading both originally) + +del Danfysik8500StreamInterface diff --git a/lewis/devices/danfysik/interfaces/dfkps_base.py b/lewis/devices/danfysik/interfaces/dfkps_base.py new file mode 100644 index 00000000..56e34a40 --- /dev/null +++ b/lewis/devices/danfysik/interfaces/dfkps_base.py @@ -0,0 +1,106 @@ +"""Stream device for danfysik +""" + +import abc + +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + + +@has_log +class CommonStreamInterface(object, metaclass=abc.ABCMeta): + """Common part of the stream interface for a Danfysik. + """ + + in_terminator = "\r" + out_terminator = "" + + commands = [ + CmdBuilder("get_voltage").escape("AD 2").eos().build(), + CmdBuilder("set_polarity").escape("PO ").arg(r"\+|-").eos().build(), + CmdBuilder("get_polarity").escape("PO").eos().build(), + CmdBuilder("set_power_off").escape("F").eos().build(), + CmdBuilder("set_power_on").escape("N").eos().build(), + CmdBuilder("get_status").escape("S1").eos().build(), + CmdBuilder("get_last_setpoint").escape("RA").eos().build(), + CmdBuilder("reset").escape("RS").eos().build(), + ] + + def handle_error(self, request, error): + """If command is not recognised print and error + + Args: + request: requested string + error: problem + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_current(self): + return int(round(self.device.get_current())) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def set_current(self, value): + self.device.set_current(value) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_last_setpoint(self): + return int(round(self.device.get_last_setpoint())) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_voltage(self): + return int(round(self.device.get_voltage())) + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def unlock(self): + """Unlock the device. Implementation could be put in in future. + """ + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def get_polarity(self): + return "-" if self.device.negative_polarity else "+" + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def set_polarity(self, polarity): + assert polarity in ["+", "-"] + self.device.negative_polarity = polarity == "-" + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def set_power_off(self): + self.device.power = False + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def set_power_on(self): + self.device.power = True + + @conditional_reply("connected") + @conditional_reply("comms_initialized") + def reset_device(self): + self.device.reset() + + @abc.abstractmethod + def get_status(self): + """Respond to the get_status command. + """ + + @conditional_reply("connected") + def init_comms(self): + """Initialize comms of device + """ + self.device.comms_initialized = True + + def bit(self, condition): + return "!" if condition else "." + + def interlock(self, name): + return self.bit(name in self.device.active_interlocks) diff --git a/lewis/devices/danfysik/states.py b/lewis/devices/danfysik/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/danfysik/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/dh2000/__init__.py b/lewis/devices/dh2000/__init__.py new file mode 100644 index 00000000..c317b9b3 --- /dev/null +++ b/lewis/devices/dh2000/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedDh2000 + +__all__ = ["SimulatedDh2000"] diff --git a/lewis/devices/dh2000/device.py b/lewis/devices/dh2000/device.py new file mode 100644 index 00000000..dd77e76a --- /dev/null +++ b/lewis/devices/dh2000/device.py @@ -0,0 +1,66 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedDh2000(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._shutter_is_open = False + self._interlock_triggered = False + self.is_connected = True + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + @property + def shutter_is_open(self): + """Returns whether the shutter is open or closed + + Returns: + shutter_is_open: Bool, True if the shutter is open + + """ + return self._shutter_is_open + + @shutter_is_open.setter + def shutter_is_open(self, value): + """Sets whether the shutter is open or closed + Args: + value: Boolean, set to True to open the shutter + + Returns: + None + """ + self._shutter_is_open = value + + @property + def interlock_is_triggered(self): + """Returns whether the interlock has been triggered + + Returns: + interlock_is_triggered: Bool, True if the interlock has been triggered (forcing shutter closed) + + """ + return self._interlock_triggered + + @interlock_is_triggered.setter + def interlock_is_triggered(self, value): + """Sets the interlock triggered status + + Returns: + None + + """ + self._interlock_triggered = value diff --git a/lewis/devices/dh2000/interfaces/__init__.py b/lewis/devices/dh2000/interfaces/__init__.py new file mode 100644 index 00000000..bd0ece43 --- /dev/null +++ b/lewis/devices/dh2000/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Dh2000StreamInterface + +__all__ = ["Dh2000StreamInterface"] diff --git a/lewis/devices/dh2000/interfaces/stream_interface.py b/lewis/devices/dh2000/interfaces/stream_interface.py new file mode 100644 index 00000000..aa3b0805 --- /dev/null +++ b/lewis/devices/dh2000/interfaces/stream_interface.py @@ -0,0 +1,60 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + + +@has_log +class Dh2000StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_status").escape("&STS!").eos().build(), + CmdBuilder("close_shutter").escape("&CLOSEA!").eos().build(), + CmdBuilder("open_shutter").escape("&OPENA!").eos().build(), + CmdBuilder("invalid_command").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + ACK = "&ACK!" + out_terminator + + @staticmethod + def handle_error(request, error): + """Prints an error message if a command is not recognised. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + None. + """ + print("An error occurred at request {}: {}".format(request, error)) + + @conditional_reply("is_connected") + def invalid_command(self): + return "&NAC!{}&TYPERR!".format(self.out_terminator) + + @conditional_reply("is_connected") + def close_shutter(self): + self._device.shutter_is_open = False + + return self.ACK + + @conditional_reply("is_connected") + def open_shutter(self): + self._device.shutter_is_open = True + + return self.ACK + + @conditional_reply("is_connected") + def get_status(self): + shutter = self._device.shutter_is_open + interlock = self._device.interlock_is_triggered + + status_string = "{ACK}\n&A{shutter},B0,I{intlock}!".format( + ACK=self.ACK, shutter=int(shutter), intlock=int(interlock) + ) + + return status_string diff --git a/lewis/devices/dh2000/states.py b/lewis/devices/dh2000/states.py new file mode 100644 index 00000000..62cd6384 --- /dev/null +++ b/lewis/devices/dh2000/states.py @@ -0,0 +1,12 @@ +from lewis.core.logging import has_log +from lewis.core.statemachine import State + + +@has_log +class DefaultState(State): + def in_state(self, dt): + device = self._context + + if device.interlock_is_triggered and device.shutter_is_open: + self.log.info("INTERLOCK close shutter") + device.shutter_is_open = False diff --git a/lewis/devices/dma4500m/__init__.py b/lewis/devices/dma4500m/__init__.py new file mode 100644 index 00000000..b128578c --- /dev/null +++ b/lewis/devices/dma4500m/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedDMA4500M + +__all__ = ["SimulatedDMA4500M"] diff --git a/lewis/devices/dma4500m/device.py b/lewis/devices/dma4500m/device.py new file mode 100644 index 00000000..a7f0dccb --- /dev/null +++ b/lewis/devices/dma4500m/device.py @@ -0,0 +1,97 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DoneState, MeasuringState, ReadyState + + +class SimulatedDMA4500M(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.measurement_time = 0 + + self.sample_id = 0 + self.target_temperature = 0.0 + self.actual_temperature = 0.0 + self.density = 0.0 + self.condition = "valid" + + self.data_buffer = "" + self.status = "" + + self.measuring = False + self.last_measurement_successful = False + self.setting_temperature = False + + def reset(self): + self._initialize_data() + + def start(self): + if self.measuring: + return "measurement already started" + else: + self.sample_id += 1 + self.measuring = True + return "measurement started" + + def abort(self): + if not self.measuring: + return "measurement not started" + else: + self.measuring = False + return "measurement aborted" + + def finished(self): + return self.status + + def set_temperature(self, temperature): + if self.measuring: + return "not allowed during measurement" + else: + self.target_temperature = temperature + self.setting_temperature = True + return "accepted" + + def get_data(self): + if not self.data_buffer: + return "no new data" + else: + data = self.data_buffer + self.data_buffer = "" + return data + + def get_raw_data(self): + sample_id = self.sample_id if self.sample_id else "NaN" + return "{0:.6f};{1:.2f};{2:.2f};{3}".format( + self.density, self.actual_temperature, self.target_temperature, sample_id + ) + + def _get_state_handlers(self): + return { + "ready": ReadyState(), + "measuring": MeasuringState(), + "done": DoneState(), + } + + def _get_initial_state(self): + return "ready" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("ready", "measuring"), lambda: self.measuring is True), + ( + ("measuring", "ready"), + lambda: self.measuring is False and not self.last_measurement_successful, + ), + ( + ("measuring", "done"), + lambda: self.measuring is False and self.last_measurement_successful, + ), + (("done", "measuring"), lambda: self.measuring is True), + (("done", "ready"), lambda: self.setting_temperature is True), + (("ready", "ready"), lambda: self.setting_temperature is True), + ] + ) diff --git a/lewis/devices/dma4500m/interfaces/__init__.py b/lewis/devices/dma4500m/interfaces/__init__.py new file mode 100644 index 00000000..a4d68464 --- /dev/null +++ b/lewis/devices/dma4500m/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import DMA4500MStreamInterface + +__all__ = ["DMA4500MStreamInterface"] diff --git a/lewis/devices/dma4500m/interfaces/stream_interface.py b/lewis/devices/dma4500m/interfaces/stream_interface.py new file mode 100644 index 00000000..b95ab1dc --- /dev/null +++ b/lewis/devices/dma4500m/interfaces/stream_interface.py @@ -0,0 +1,74 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +@has_log +class DMA4500MStreamInterface(StreamInterface): + in_terminator = "\r" + out_terminator = "\r" + + # Commands that we expect via serial during normal operation + def __init__(self): + super(DMA4500MStreamInterface, self).__init__() + self.commands = { + CmdBuilder(self.start).escape("start").eos().build(), + CmdBuilder(self.abort).escape("abort").eos().build(), + CmdBuilder(self.finished).escape("finished").eos().build(), + CmdBuilder(self.set_temperature) + .escape("set") + .optional(" ") + .escape("temperature ") + .arg(".+") + .eos() + .build(), + CmdBuilder(self.get_data).escape("get").optional(" ").escape("data").eos().build(), + CmdBuilder(self.get_raw_data) + .escape("get") + .optional(" ") + .escape("raw") + .optional(" ") + .escape("data") + .eos() + .build(), + } + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + @if_connected + def start(self): + return self._device.start() + + @if_connected + def abort(self): + return self._device.abort() + + @if_connected + def finished(self): + return self._device.finished() + + @if_connected + def set_temperature(self, temperature_arg): + try: + temperature = float(temperature_arg) + except ValueError: + return "the given temperature could not be parsed" + + return self._device.set_temperature(temperature) + + @if_connected + def get_data(self): + return self._device.get_data() + + @if_connected + def get_raw_data(self): + return self._device.get_raw_data() diff --git a/lewis/devices/dma4500m/states.py b/lewis/devices/dma4500m/states.py new file mode 100644 index 00000000..976e6208 --- /dev/null +++ b/lewis/devices/dma4500m/states.py @@ -0,0 +1,38 @@ +from lewis.core.statemachine import State + + +class ReadyState(State): + def on_entry(self, dt): + self._context.status = "measurement not started" + self._context.actual_temperature = self._context.target_temperature + self._context.setting_temperature = False + + +class MeasuringState(State): + time_elapsed = 0.0 + + def on_entry(self, dt): + self._context.status = "measurement not finished" + self._context.last_measurement_successful = False + self.time_elapsed = 0.0 + + def in_state(self, dt): + self.time_elapsed += dt + if self.time_elapsed > self._context.measurement_time: + self._context.last_measurement_successful = True + self._context.measuring = False + + def on_exit(self, dt): + if not self._context.last_measurement_successful: + self._context.condition = "canceled" + self._context.data_buffer = "data: ---;---;canceled" + else: + self._context.condition = "valid" + self._context.data_buffer = "data: {0:.5f};{1:.2f};{2}".format( + self._context.density, self._context.actual_temperature, self._context.condition + ) + + +class DoneState(State): + def on_entry(self, dt): + self._context.status = "measurement finished" diff --git a/lewis/devices/edwardstic/__init__.py b/lewis/devices/edwardstic/__init__.py new file mode 100644 index 00000000..08201a01 --- /dev/null +++ b/lewis/devices/edwardstic/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedEdwardsTIC + +__all__ = ["SimulatedEdwardsTIC", "AlertStates", "PriorityStates", "PumpStates"] diff --git a/lewis/devices/edwardstic/device.py b/lewis/devices/edwardstic/device.py new file mode 100644 index 00000000..7add70c1 --- /dev/null +++ b/lewis/devices/edwardstic/device.py @@ -0,0 +1,264 @@ +from collections import OrderedDict +from enum import Enum + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class OnOffStates(Enum): + """Holds whether a device function is on or off + """ + + on = 4 + off = 0 + + +class PumpStates(object): + """The pump states + """ + + stopped = object() + starting_delay = object() + accelerating = object() + running = object() + stopping_short_delay = object() + stopping_normal_delay = object() + fault_braking = object() + braking = object() + + +class GaugeStates(object): + """Possible gauge states + """ + + not_connected = object() + connected = object() + new_id = object() + change = object() + alert = object() + off = object() + striking = object() + initialising = object() + calibrating = object() + zeroing = object() + degassing = object() + on = object() + inhibited = object() + + +class PriorityStates(object): + """Priority values + """ + + OK = object() + Warning = object() + Alarm = object() + + +class GaugeUnits(object): + """Units the gauges can measure in + """ + + Pa = object() + V = object() + percent = object() + + +class SimulatedEdwardsTIC(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._turbo_pump = PumpStates.stopped + self._turbo_priority = PriorityStates.OK + self._turbo_alert = 0 + self._turbo_in_standby = False + + self._gauge_state = GaugeStates.on + self._gauge_priority = PriorityStates.OK + self._gauge_alert = 0 + self._gauge_pressure = 0.0 + self._gauge_units = GaugeUnits.Pa + + self.connected = True + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + @property + def turbo_in_standby(self): + """Gets whether the turbo is in standby mode + + Returns: + _turbo_standby: Bool, True if the turbo is in standby mode + """ + return self._turbo_in_standby + + @turbo_in_standby.setter + def turbo_in_standby(self, value): + """Sets the turbo standby mode + + Args: + value: Bool, True to set standby mode. False to unset standby mode + """ + self._turbo_in_standby = value + + def turbo_set_standby(self, value): + """Sets / unsets turbo standby mode + + Args: + value, int: 1 to set standby mode, 0 to unset standby. + """ + if value == 1: + self.log.info("Entering turbo standby mode") + self._turbo_in_standby = True + elif value == 0: + self.log.info("Leaving turbo standby mode") + self._turbo_in_standby = False + else: + raise ValueError("Invalid standby argument provided ({} not 0 or 1)".format(value)) + + @property + def turbo_pump(self): + """Gets the running state of the turbo pump + """ + return self._turbo_pump + + def set_turbo_pump_state(self, state): + """Sets the state of the turbo pump. + This function doesn't exist on the real device and is only called through the back door. + + Args: + state: String, Matches an attribute of the PumpStates class + """ + pump_state = getattr(PumpStates, state) + + self._turbo_pump = pump_state + + def turbo_start_stop(self, value): + """Sets the turbo pump running/stopping + + Args: + value: int, 1 if starting the pump 0 to stop the pump + """ + self.log.info("Starting or stopping turbo {}".format(value)) + + if value == 1: + self.log.info("Starting turbo") + self._turbo_pump = PumpStates.running + elif value == 0: + self.log.info("Stopping turbo") + self._turbo_pump = PumpStates.stopped + else: + raise ValueError("Invalid start/stop switch ({} not 0 or 1)".format(value)) + + @property + def turbo_priority(self): + """Gets the priority state of the turbo pump + """ + self.log.info("Getting priority {}".format(self._turbo_priority)) + + return self._turbo_priority + + def set_turbo_priority(self, state): + """Sets the priority state of the turbo pump. + This function doesn't exist on the real device and is only called through the back door. + + Args: + state: object, an attribute of the PumpStates class + """ + priority_state = getattr(PriorityStates, state) + + self._turbo_priority = priority_state + + @property + def turbo_alert(self): + """Gets the alert state of the turbo pump + """ + return self._turbo_alert + + # This setter doesn't exist on the 'real' device + def set_turbo_alert(self, state): + """Sets the alert state of the turbo pump + + Args: + state: Int, the alert value + """ + self._turbo_alert = state + + @property + def gauge_pressure(self): + """Gets the gauge pressure + """ + return self._gauge_pressure + + @gauge_pressure.setter + def gauge_pressure(self, value): + """Sets the gauge pressure. + This function is not present on the real device and can only be accessed through the backdoor. + + Args: + value: float, The value to set the gauge pressure to. + """ + self._gauge_pressure = value + + @property + def gauge_state(self): + """Gets the running state of the gauges + """ + return self._gauge_state + + def set_gauge_state(self, state): + """Sets the state of the gauges + This function doesn't exist on the real device and is only called through the back door. + + Args: + state: String, Matches an attribute of the GaugeStates class + """ + gauge_state = getattr(GaugeStates, state) + + self._gauge_state = gauge_state + + @property + def gauge_alert(self): + return self._gauge_alert + + def set_gauge_alert(self, state): + """Sets the alert state of the gauges. + This is only accessed through the back door + + Args: + state: Int, the alert value + """ + self._gauge_alert = state + + @property + def gauge_priority(self): + """Gets the priority state of the gauges + """ + return self._gauge_priority + + def set_gauge_priority(self, state): + """Sets the priority state of the gauges. + This function doesn't exist on the real device and is only called through the back door. + + Args: + state: object, an attribute of the PumpStates class + """ + priority_state = getattr(PriorityStates, state) + + self._gauge_priority = priority_state + + @property + def gauge_units(self): + """Getter for the gauge units + """ + return self._gauge_units diff --git a/lewis/devices/edwardstic/interfaces/__init__.py b/lewis/devices/edwardstic/interfaces/__init__.py new file mode 100644 index 00000000..6b49f44f --- /dev/null +++ b/lewis/devices/edwardstic/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import EdwardsTICStreamInterface + +__all__ = ["EdwardsTICStreamInterface"] diff --git a/lewis/devices/edwardstic/interfaces/stream_interface.py b/lewis/devices/edwardstic/interfaces/stream_interface.py new file mode 100644 index 00000000..47a7206b --- /dev/null +++ b/lewis/devices/edwardstic/interfaces/stream_interface.py @@ -0,0 +1,184 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from ..device import GaugeStates, GaugeUnits, PriorityStates, PumpStates + +PUMPSTATES_MAP = { + 0: PumpStates.stopped, + 1: PumpStates.starting_delay, + 5: PumpStates.accelerating, + 4: PumpStates.running, + 2: PumpStates.stopping_short_delay, + 3: PumpStates.stopping_normal_delay, + 6: PumpStates.fault_braking, + 7: PumpStates.braking, +} + +GAUGESTATES_MAP = { + 0: GaugeStates.not_connected, + 1: GaugeStates.connected, + 2: GaugeStates.new_id, + 3: GaugeStates.change, + 4: GaugeStates.alert, + 5: GaugeStates.off, + 6: GaugeStates.striking, + 7: GaugeStates.initialising, + 8: GaugeStates.calibrating, + 9: GaugeStates.zeroing, + 10: GaugeStates.degassing, + 11: GaugeStates.on, + 12: GaugeStates.inhibited, +} + +GAUGEUNITS_MAP = {GaugeUnits.Pa: 59, GaugeUnits.V: 66, GaugeUnits.percent: 81} + +PRIORITYSTATES_MAP = {PriorityStates.OK: 0, PriorityStates.Warning: 1, PriorityStates.Alarm: 3} + + +def reverse_dict_lookup(dictionary, value_to_find): + """Looks up the key for the supplied value in dictionary dict. + + Args: + dictionary: dictionary, the dictionary to do the reverse lookup + value_to_find: the value to find in the dictionary + + Raises: + KeyError if value does not exist in the dictionary + """ + for key, value in dictionary.items(): + if value == value_to_find: + return key + else: + raise KeyError("Could not find {} in map".format(value_to_find)) + + +@has_log +class EdwardsTICStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("turbo_start_stop").escape("!C904 ").int().eos().build(), + CmdBuilder("get_turbo_state").escape("?V904").eos().build(), + CmdBuilder("turbo_get_speed").escape("?V905").eos().build(), + CmdBuilder("turbo_get_sft").escape("?S905").eos().build(), + CmdBuilder("turbo_get_power").escape("?V906").eos().build(), + CmdBuilder("turbo_get_norm").escape("?V907").eos().build(), + CmdBuilder("turbo_set_standby").escape("!C908 ").int().eos().build(), + CmdBuilder("turbo_get_standby").escape("?V908").eos().build(), + CmdBuilder("turbo_get_cycle").escape("?V909").eos().build(), + CmdBuilder("backing_get_status").escape("?V910").eos().build(), + CmdBuilder("backing_start_stop").escape("!C910 ").int().eos().build(), + CmdBuilder("backing_get_speed").escape("?V911").eos().build(), + CmdBuilder("backing_get_power").escape("?V912").eos().build(), + CmdBuilder("get_gauge").escape("?V91").arg("3|4|5").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + ACK = "&ACK!" + out_terminator + + def handle_error(self, request, error): + """Prints an error message if a command is not recognised. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + None. + """ + self.log.info("An error occurred at request {}: {}".format(request, error)) + + @conditional_reply("connected") + def turbo_set_standby(self, switch): + self._device.turbo_set_standby(switch) + + return "*C908 0" + + @conditional_reply("connected") + def turbo_get_standby(self): + return_string = "=V908 {stdby_state};0;0" + + standby_state = 4 if self._device.turbo_in_standby else 0 + + self.log.info(return_string.format(stdby_state=standby_state)) + + return return_string.format(stdby_state=standby_state) + + @conditional_reply("connected") + def turbo_start_stop(self, switch): + self.log.info("turbo start stop command received") + self._device.turbo_start_stop(switch) + + return "*C904 0" + + @conditional_reply("connected") + def get_turbo_state(self): + state_string = "=V904 {turbo_state};{alert};{priority}" + + return state_string.format( + turbo_state=reverse_dict_lookup(PUMPSTATES_MAP, self._device.turbo_pump), + alert=self._device.turbo_alert, + priority=PRIORITYSTATES_MAP[self._device.turbo_priority], + ) + + @conditional_reply("connected") + def get_turbo_status(self): + output_string = "*C904 {state};{alert};{priority}" + + state = self._device.turbo_state + alert = self._device.turbo_alert + priority = self._device.turbo_priority + + return output_string.format(state=state, alert=alert, priority=priority) + + @conditional_reply("connected") + def turbo_get_speed(self): + return "=V905 1;0;0" + + @conditional_reply("connected") + def turbo_get_sft(self): + return "=S905 1;0" + + @conditional_reply("connected") + def turbo_get_power(self): + return "=V906 1;0;0" + + @conditional_reply("connected") + def turbo_get_norm(self): + return "=V907 4;0;0" + + @conditional_reply("connected") + def turbo_get_cycle(self): + return "=V909 1;0;0;0" + + @conditional_reply("connected") + def backing_get_status(self): + return "=V910 1;0;0" + + @conditional_reply("connected") + def backing_start_stop(self, switch): + return "*C910 0" + + @conditional_reply("connected") + def backing_get_speed(self): + return "=V911 1;0;0" + + @conditional_reply("connected") + def backing_get_power(self): + return "=V912 1;0;0" + + @conditional_reply("connected") + def get_gauge(self, gauge_id): + state_string = "=V91{gauge_id} {pressure};{units};{gauge_state};{alert};{priority}" + + return state_string.format( + gauge_id=gauge_id, + pressure=self._device.gauge_pressure, + units=GAUGEUNITS_MAP[self._device.gauge_units], + gauge_state=reverse_dict_lookup(GAUGESTATES_MAP, self._device.gauge_state), + alert=self._device.gauge_alert, + priority=PRIORITYSTATES_MAP[self._device.gauge_priority], + ) diff --git a/lewis/devices/edwardstic/states.py b/lewis/devices/edwardstic/states.py new file mode 100644 index 00000000..7e384073 --- /dev/null +++ b/lewis/devices/edwardstic/states.py @@ -0,0 +1,13 @@ +from lewis.core.logging import has_log +from lewis.core.statemachine import State + + +@has_log +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" + + def in_state(self, dt): + pass diff --git a/lewis/devices/eurotherm/__init__.py b/lewis/devices/eurotherm/__init__.py new file mode 100644 index 00000000..383c5a86 --- /dev/null +++ b/lewis/devices/eurotherm/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedEurotherm + +__all__ = ["SimulatedEurotherm"] diff --git a/lewis/devices/eurotherm/device.py b/lewis/devices/eurotherm/device.py new file mode 100644 index 00000000..d8b2bb6f --- /dev/null +++ b/lewis/devices/eurotherm/device.py @@ -0,0 +1,829 @@ +import logging +from collections import OrderedDict +from time import sleep + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +@has_log +class SimulatedEurotherm(StateMachineDevice): + """ + Simulated Eurotherm device. + """ + + def _initialize_data(self) -> None: + """ + Sets the initial state of the device. + """ + self.log: logging.Logger + self._connected = True + self.delay_time = None + self.sensors = { + "01": SimulatedEurotherm.EurothermSensor(), + "02": SimulatedEurotherm.EurothermSensor(), + "03": SimulatedEurotherm.EurothermSensor(), + "04": SimulatedEurotherm.EurothermSensor(), + "05": SimulatedEurotherm.EurothermSensor(), + "06": SimulatedEurotherm.EurothermSensor(), + } + + def _get_state_handlers(self) -> dict[str, DefaultState]: + """ + Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self) -> str: + """ + Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self) -> OrderedDict: + """ + Returns: the state transitions + """ + return OrderedDict() + + def _delay(self) -> None: + """ + Simulate a delay. + """ + if self.delay_time is not None: + sleep(self.delay_time) + + def set_delay_time(self, value: float) -> None: + """ + Set a simulated delay time + """ + value = float(value) + self.delay_time = value + + def setpoint_temperature(self, addr: str) -> float: + """ + Get current temperature of the device. + + Returns: the current temperature in K. + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.setpoint_temperature + + def set_setpoint_temperature(self, addr: str, value: float) -> None: + """ + Set the current temperature of the device. + + Args: + temp: the current temperature of the device in K. + + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.current_temperature = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.setpoint_temperature = value + + def current_temperature(self, addr: str) -> float: + """ + Get current temperature of the device. + + Returns: the current temperature in K. + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.current_temperature + + def set_current_temperature(self, addr: str, value: float) -> None: + """ + Set the current temperature of the device. + + Args: + temp: the current temperature of the device in K. + + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.current_temperature = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.current_temperature = value + + def address(self, addr: str) -> str: + """ + Get the address of the device. + + Returns: the address of the device e.g. "A01" + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.address + + def set_address(self, addr: str) -> None: + """ + Sets the address of the device. + + Args: + addr (str): the address of this device e.g. "A01". + + """ + euro = self.sensors[addr] + if addr is None: + euro.address = "01" + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.address = addr + + def ramping_on(self, addr: str) -> bool: + """ + Gets whether the device is currently ramping. + + Returns: bool indicating if the device is ramping. + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.ramping_on + + def set_ramping_on(self, addr: str, value: bool) -> None: + """ + Sets whether the device is currently ramping. + + Args: + value - toggle (bool): turn ramping on or off. + + """ + value = bool(value) + if addr is None: + for euro in self.sensors.values(): + euro.ramping_on = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.ramping_on = value + + def ramp_rate(self, addr: str) -> float: + """ + Get the current ramp rate. + + Returns: the current ramp rate in K/min + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError # not sure if this is right + return euro.ramp_rate + + def set_ramp_rate(self, addr: str, value: float) -> None: + """ + Set the ramp rate. + + Args: + value - ramp_rate (float): set the current ramp rate in K/min. + + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.ramp_rate = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.ramp_rate = value + + def ramp_setpoint_temperature(self, addr: str) -> float: + """ + Get the set point temperature. + + Returns: the current value of the setpoint temperature in K. + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.ramp_setpoint_temperature + + def set_ramp_setpoint_temperature(self, addr: str, value: float) -> None: + """ + Set the set point temperature. + + Args: + value - temp (float): the current value of the set point temperature in K. + + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.ramp_setpoint_temperature = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.ramp_setpoint_temperature = value + + def needlevalve_flow(self, addr: str) -> float: + """ + Get the flow readback from the transducer + + Returns: the current value of the flow rate in L/min + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_flow + + def set_needlevalve_flow(self, addr: str, value: float) -> None: + """ + Sets the flow readback from the transducer + + Args: + value - flow (double) the current value of the flow rate in L/min + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_flow = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_flow = value + + def needlevalve_manual_flow(self, addr: str) -> float: + """ + Get the manual flow setpoint + + Returns: the current value of the manual flow setpoint + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_manual_flow + + def set_needlevalve_manual_flow(self, addr: str, value: float) -> None: + """ + Sets the manual flow setpoint + + Args: + value - flow_val (float): set the manual flow setpoint in L/min + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_manual_flow = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_manual_flow = value + + def needlevalve_flow_low_lim(self, addr: str) -> float: + """ + Get the low setpoint limit for flow control + + Returns: the current value of the manual flow setpoint + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_flow_low_lim + + def set_needlevalve_flow_low_lim(self, addr: str, value: float) -> None: + """ + Sets the low setpoint limit for flow control + + Args: + value - low_lim (float): set the low setpoint limit in L/min + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_flow_low_lim = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_flow_low_lim = value + + def needlevalve_flow_high_lim(self, addr: str) -> float: + """ + Get the low setpoint limit for flow control + + Returns: the current value of the manual flow setpoint + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_flow_high_lim + + def set_needlevalve_flow_high_lim(self, addr: str, value: float) -> None: + """ + Sets the high setpoint limit for flow control + + Args: + value - high_lim (float): set the high setpoint limit in L/min + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_flow_high_lim = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_flow_high_lim = value + + def needlevalve_auto_flow_scale(self, addr: str) -> float: + """ + Get the auto_flow_scale + + Returns: the current value of the manual flow setpoint + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_auto_flow_scale + + def set_needlevalve_auto_flow_scale(self, addr: str, value: float) -> None: + """ + Sets the auto_flow_scale + + Args: + value (float): set the high setpoint limit in L/min + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_auto_flow_scale = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_auto_flow_scale = value + + def needlevalve_min_auto_flow_bl_temp(self, addr: str) -> float: + """ + Get min_auto_flow_bl_tempw setpoint + + Returns: current mode of the fmin_auto_flow_bl_temp + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_min_auto_flow_bl_temp + + def set_needlevalve_min_auto_flow_bl_temp(self, addr: str, value: float) -> None: + """ + Sets the min_auto_flow_bl_temp setpoint + + Args: + value (float) + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_min_auto_flow_bl_temp = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_min_auto_flow_bl_temp = value + + def needlevalve_flow_sp_mode(self, addr: str) -> int: + """ + Get the mode of the flow setpoint + + Returns: current mode of the flow setpoint (AUTO/MANUAL) + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_flow_sp_mode + + def set_needlevalve_flow_sp_mode(self, addr: str, value: int) -> None: + """ + Sets the mode of the flow setpoint + + Args: + value - mode (int) + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_flow_sp_mode = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_flow_sp_mode = value + + def needlevalve_direction(self, addr: str) -> int: + """ + Get the direction of the valve + + Returns: current direction of the valve (OPENING/CLOSING) + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_direction + + def set_needlevalve_direction(self, addr: str, value: int) -> None: + """ + Sets the direction of the valve + + Args: + value - dir (int) current direction of the valve (OPENING/CLOSING) + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_direction = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_direction = value + + def needlevalve_stop(self, addr: str) -> int: + """ + Gets the control mode of Loop 2 + + Returns: current control mode of Loop 2 (STOPPED/NOT STOPPED) + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.needlevalve_stop + + def set_needlevalve_stop(self, addr: str, value: int) -> None: + """ + Sets the control mode of Loop 2 + + Args: + value - stop_val (int) + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.needlevalve_stop = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.needlevalve_stop = value + + def high_lim(self, addr: str) -> float: + """ + Gets the high limit + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.high_lim + + def set_high_lim(self, addr: str, value: float) -> None: + """ + Sets the high limit + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.high_lim = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.high_lim = value + + def low_lim(self, addr: str) -> float: + """ + Gets the low limit + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.low_lim + + def set_low_lim(self, addr: str, value: float) -> None: + """ + Sets the low limit + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.low_lim = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.low_lim = value + + def output(self, addr: str) -> float: + """ + Gets the output value + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.output + + def set_output(self, addr: str, value: float) -> None: + """ + Sets the output value + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.output = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.output = value + + def max_output(self, addr: str) -> float: + """ + Gets the max output value + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.max_output + + def set_max_output(self, addr: str, value: float) -> None: + """ + Sets the max_output value + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.max_output = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.max_output = value + + def output_rate(self, addr: str) -> float: + """ + Get the set point output rate. + """ + self._delay() + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.output_rate + + def set_output_rate(self, addr: str, value: float) -> None: + """ + Set the set point output rate. + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.output_rate = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.output_rate = value + + def error(self, addr: str) -> int: + """ + Gets the error status + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.error + + def set_error(self, addr: str, error: int) -> None: + """ + Sets the error status + """ + error = int(error) + if addr is None: + for euro in self.sensors.values(): + euro.error = error + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.error = error + + def scaling(self, addr: str) -> float: + """ + Gets the scaling factor + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.scaling + + def set_scaling(self, addr: str, value: float) -> None: + """ + Sets the scaling factor + """ + value = float(value) + if addr is None: + for euro in self.sensors.values(): + euro.scaling = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.scaling = value + + def autotune(self, addr: str) -> int: + """ + Gets the autotune value + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.autotune + + def set_autotune(self, addr: str, value: int) -> None: + """ + Sets the autotune value + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.autotune = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.autotune = value + + def p(self, addr: str) -> int: + """ + Gets the p value + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.p + + def set_p(self, addr: str, value: int) -> None: + """ + Sets the p value + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.p = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.p = value + + def i(self, addr: str) -> int: + """ + Gets the i value + """ + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.i + + def set_i(self, addr: str, value: int) -> None: + """ + Sets the i value + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.i = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.i = value + + def d(self, addr: str) -> int: + """ + Gets the d value + """ + + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + return euro.d + + def set_d(self, addr: str, value: int) -> None: + """ + Sets the d value + """ + value = int(value) + if addr is None: + for euro in self.sensors.values(): + euro.d = value + else: + euro = self.sensors[addr] + if not euro.connected: + raise ValueError + euro.d = value + + def connected(self, addr: str) -> bool: + """ + Gets connected status + """ + euro = self.sensors[addr] + return euro.connected + + def set_connected(self, addr: str, connected: bool) -> None: + """ + Sets connected status + """ + self.log.info(f"Addr: {addr}, Connected: {connected}") + self.log.info(f"{addr.__class__.__name__}") + connected = bool(connected) + if addr is None: + for euro in self.sensors.values(): + euro.connected = connected + else: + euro = self.sensors[addr] + euro.connected = connected + + class EurothermSensor: + """ + Eurotherm temperature sensor method + """ + + def __init__(self) -> None: + self.log: logging.Logger + self.connected = True + self.current_temperature = 0.0 + self.setpoint_temperature = 0.0 + self.address = "" + self.p = 0 + self.i = 0 + self.d = 0 + self.autotune = 0 + self.output = 0.0 + self.output_rate = 0.0 + self.max_output = 0.0 + self.high_lim = 0.0 + self.low_lim = 0.0 + self.error = 0 + self.scaling = 1.0 + self.ramping_on = False + self.ramp_rate = 1.0 + self.ramp_setpoint_temperature = 0.0 + self.needlevalve_flow = 0.0 + self.needlevalve_manual_flow = 0.0 + self.needlevalve_flow_low_lim = 0.0 + self.needlevalve_flow_high_lim = 0.0 + self.needlevalve_auto_flow_scale = 0.0 + self.needlevalve_min_auto_flow_bl_temp = 0.0 + self.needlevalve_flow_sp_mode = 0 + self.needlevalve_direction = 0 + self.needlevalve_stop = 0 diff --git a/lewis/devices/eurotherm/interfaces/__init__.py b/lewis/devices/eurotherm/interfaces/__init__.py new file mode 100644 index 00000000..ba227c0b --- /dev/null +++ b/lewis/devices/eurotherm/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import EurothermStreamInterface + +__all__ = ["EurothermStreamInterface"] diff --git a/lewis/devices/eurotherm/interfaces/modbus_interface.py b/lewis/devices/eurotherm/interfaces/modbus_interface.py new file mode 100644 index 00000000..27198627 --- /dev/null +++ b/lewis/devices/eurotherm/interfaces/modbus_interface.py @@ -0,0 +1,277 @@ +import logging +from typing import Callable, Concatenate, ParamSpec, Protocol, TypeVar + +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.byte_conversions import BYTE, int_to_raw_bytes +from lewis.utils.replies import conditional_reply + +from lewis_emulators.eurotherm import SimulatedEurotherm + +sensor = "01" + + +class HasLog(Protocol): + log: logging.Logger + + +P = ParamSpec("P") +T = TypeVar("T") +T_self = TypeVar("T_self", bound=HasLog) + + +def log_replies(f: Callable[Concatenate[T_self, P], T]) -> Callable[Concatenate[T_self, P], T]: + def _wrapper(self: T_self, *args: P.args, **kwargs: P.kwargs) -> T: + result = f(self, *args, **kwargs) + self.log.info(f"Reply in {f.__name__}: {result}") + return result + + return _wrapper + + +def bytes_to_int(bytes: bytes) -> int: + return int.from_bytes(bytes, byteorder="big", signed=True) + + +def crc16(data: bytes) -> bytes: + """ + CRC algorithm - translated from section 3-5 of eurotherm manual. + :param data: the data to checksum + :return: the checksum + """ + crc = 0xFFFF + + for b in data: + crc ^= b + for _ in range(8): + if crc & 1: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + + crc %= BYTE**2 + + return int_to_raw_bytes(crc, 2, low_byte_first=True) + + +@has_log +class EurothermModbusInterface(StreamInterface): + """ + This implements the modbus stream interface for a eurotherm. + + Note: Eurotherm uses modbus RTU, not TCP, so cannot use lewis' normal modbus + implementation here. + """ + + commands = { + Cmd("any_command", r"^([\s\S]*)$", return_mapping=lambda x: x), + } + + def __init__(self) -> None: + super().__init__() + self.device: SimulatedEurotherm + self.log: logging.Logger + # Modbus addresses for the needle valve were obtained from Jamie, + # full info can be found on the manuals share + self.read_commands = { + 1: self.get_temperature, + 2: self.get_temperature_sp, + 6: self.get_p, + 8: self.get_i, + 9: self.get_d, + 111: self.get_high_lim, + 112: self.get_low_lim, + 270: self.get_autotune, + 30: self.get_max_output, + 37: self.get_output_rate, + 3: self.get_output, + 1025: self.get_nv_flow, + 1509: self.get_nv_manual_flow, + 1300: self.get_nv_flow_sp_mode, + 4827: self.get_nv_direction, + 1135: self.get_nv_flow_high_lim, + 1136: self.get_nv_flow_low_lim, + 4963: self.get_nv_min_auto_flow_bl_temp, + 4965: self.get_nv_auto_flow_scale, + 1292: self.get_nv_stop, + } + + self.write_commands = { + 2: self.set_temperature_sp, + 6: self.set_p, + 8: self.set_i, + 9: self.set_d, + 30: self.set_max_output, + 37: self.set_output_rate, + 270: self.set_autotune, + 1509: self.set_nv_manual_flow, + 1300: self.set_nv_flow_sp_mode, + 1135: self.set_nv_flow_high_lim, + 1136: self.set_nv_flow_low_lim, + 4963: self.set_nv_min_auto_flow_bl_temp, + 4965: self.set_nv_auto_flow_scale, + 1292: self.set_nv_stop, + } + + in_terminator = "" + out_terminator = "" + readtimeout = 10 + + protocol = "eurotherm_modbus" + + def handle_error(self, request: bytes, error: BaseException | str) -> None: + error_message = "An error occurred at request " + repr(request) + ": " + repr(error) + print(error_message) + self.log.error(error_message) + + @log_replies + @conditional_reply("connected") + def any_command(self, command: bytes) -> bytes | None: + self.log.info(command) + comms_address = command[0] + function_code = int(command[1]) + data = command[2:-2] + + assert crc16(command) == b"\x00\x00", "Invalid checksum from IOC" + + if len(data) != 4: + raise ValueError(f"Invalid message length {len(data)}") + + if function_code == 3: + return self.handle_read(comms_address, data) + elif function_code == 6: + return self.handle_write(data, command) + else: + raise ValueError(f"Unknown modbus function code: {function_code}") + + def handle_read(self, comms_address: int, data: bytes) -> bytes: + mem_address = bytes_to_int(data[0:2]) + words_to_read = bytes_to_int(data[2:4]) + self.log.info(f"Attempting to read {words_to_read} words from mem address: {mem_address}") + reply_data = self.read_commands[mem_address]() + + self.log.info(f"reply_data = {reply_data}") + assert -0x8000 <= reply_data <= 0x7FFF, f"reply {reply_data} was outside modbus range, bug?" + + reply = ( + comms_address.to_bytes(1, byteorder="big", signed=True) + + b"\x03\x02" + + reply_data.to_bytes(2, byteorder="big", signed=True) + ) + + return reply + crc16(reply) + + def handle_write(self, data: bytes, command: bytes) -> bytes | None: + mem_address = bytes_to_int(data[0:2]) + value = bytes_to_int(data[2:4]) + self.log.info(f"Attempting to write {value} to mem address: {mem_address}") + try: + self.write_commands[mem_address](value) + except Exception as e: + self.log.error(e) + return None + # On write, device echos command back to IOC + return command + + def get_temperature(self) -> int: + return int(self.device.current_temperature(sensor) * self.device.scaling(sensor)) + + def get_temperature_sp(self) -> int: + return int(self.device.ramp_setpoint_temperature(sensor) * self.device.scaling(sensor)) + + def set_temperature_sp(self, value: int) -> None: + self.device.set_ramp_setpoint_temperature(sensor, (value / self.device.scaling(sensor))) + + def get_p(self) -> int: + return int(self.device.p(sensor)) + + def get_i(self) -> int: + return int(self.device.i(sensor)) + + def get_d(self) -> int: + return int(self.device.d(sensor)) + + def set_p(self, value: int) -> None: + self.device.set_p(sensor, value) + + def set_i(self, value: int) -> None: + self.device.set_i(sensor, value) + + def set_d(self, value: int) -> None: + self.device.set_d(sensor, value) + + def get_high_lim(self) -> int: + return int(self.device.high_lim(sensor) * self.device.scaling(sensor)) + + def get_low_lim(self) -> int: + return int(self.device.low_lim(sensor) * self.device.scaling(sensor)) + + def get_autotune(self) -> int: + return int(self.device.autotune(sensor)) + + def set_autotune(self, value: int) -> None: + self.device.set_autotune(sensor, value) + + def get_max_output(self) -> int: + return int(self.device.max_output(sensor) * self.device.scaling(sensor)) + + def set_max_output(self, value: int) -> None: + self.device.set_max_output(sensor, (value / self.device.scaling(sensor))) + + def get_output_rate(self) -> int: + return int(self.device.output_rate(sensor)) + + def set_output_rate(self, value: int) -> None: + self.device.set_output_rate(sensor, value) + + def get_output(self) -> int: + return int(self.device.output(sensor) * self.device.scaling(sensor)) + + def get_nv_flow(self) -> int: + return int(self.device.needlevalve_flow(sensor)) + + def get_nv_manual_flow(self) -> int: + return int(self.device.needlevalve_manual_flow(sensor)) + + def set_nv_manual_flow(self, value: int) -> None: + self.device.set_needlevalve_manual_flow(sensor, value) + + def get_nv_flow_low_lim(self) -> int: + return int(self.device.needlevalve_flow_low_lim(sensor)) + + def set_nv_flow_low_lim(self, value: int) -> None: + self.device.set_needlevalve_flow_low_lim(sensor, value) + + def get_nv_flow_high_lim(self) -> int: + return int(self.device.needlevalve_flow_high_lim(sensor)) + + def set_nv_flow_high_lim(self, value: int) -> None: + self.device.set_needlevalve_flow_high_lim(sensor, value) + + def get_nv_min_auto_flow_bl_temp(self) -> int: + return int(self.device.needlevalve_min_auto_flow_bl_temp(sensor)) + + def set_nv_min_auto_flow_bl_temp(self, value: int) -> None: + self.device.set_needlevalve_min_auto_flow_bl_temp(sensor, value) + + def get_nv_auto_flow_scale(self) -> int: + return int(self.device.needlevalve_auto_flow_scale(sensor)) + + def set_nv_auto_flow_scale(self, value: int) -> None: + self.device.set_needlevalve_auto_flow_scale(sensor, value) + + def get_nv_flow_sp_mode(self) -> int: + return int(self.device.needlevalve_flow_sp_mode(sensor)) + + def set_nv_flow_sp_mode(self, value: int) -> None: + self.device.set_needlevalve_flow_sp_mode(sensor, value) + + def get_nv_direction(self) -> int: + return int(self.device.needlevalve_direction(sensor)) + + def set_nv_stop(self, value: int) -> None: + self.device.set_needlevalve_stop(sensor, value) + + def get_nv_stop(self) -> int: + return int(self.device.needlevalve_stop(sensor)) diff --git a/lewis/devices/eurotherm/interfaces/stream_interface.py b/lewis/devices/eurotherm/interfaces/stream_interface.py new file mode 100644 index 00000000..201c1027 --- /dev/null +++ b/lewis/devices/eurotherm/interfaces/stream_interface.py @@ -0,0 +1,325 @@ +import logging +from typing import Callable, ClassVar, Concatenate, ParamSpec, TypeVar + +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from lewis_emulators.eurotherm import SimulatedEurotherm + +if_connected = conditional_reply("connected") + +P = ParamSpec("P") +T = TypeVar("T") +T_self = TypeVar("T_self") + + +def translate_adddress( + f: Callable[Concatenate[T_self, str, P], T], +) -> Callable[Concatenate[T_self, str, P], T]: + """Translate the eurotherm address from GAD,GAD,LAD,LAD to GAD,LAD.""" + + def wrapper(self: T_self, addr: str, *args: P.args, **kwargs: P.kwargs) -> T: + addr = str(addr) + assert len(addr) == 4 + gad = addr[0] + assert addr[1] == gad + lad = addr[2] + assert addr[3] == lad + + address = gad + lad + + return f(self, address, *args, **kwargs) + + return wrapper + + +class EurothermStreamInterface(StreamInterface): + """Stream interface for the serial port.""" + + def __init__(self) -> None: + """Create stream interface for serial port Eurotherm sensor.""" + self.device: SimulatedEurotherm + self.log: logging.Logger + + commands: ClassVar = { + CmdBuilder("get_current_temperature").eot().arg("[0-9]{4}").escape("PV").enq().build(), + CmdBuilder("get_setpoint").eot().arg("[0-9]{4}").escape("SL").enq().build(), + CmdBuilder("get_ramp_setpoint").eot().arg("[0-9]{4}").escape("SP").enq().build(), + CmdBuilder("get_output").eot().arg("[0-9]{4}").escape("OP").enq().build(), + CmdBuilder("get_max_output").eot().arg("[0-9]{4}").escape("HO").enq().build(), + CmdBuilder("get_output_rate").eot().arg("[0-9]{4}").escape("OR").enq().build(), + CmdBuilder("get_autotune").eot().arg("[0-9]{4}").escape("AT").enq().build(), + CmdBuilder("get_proportional").eot().arg("[0-9]{4}").escape("XP").enq().build(), + CmdBuilder("get_derivative").eot().arg("[0-9]{4}").escape("TD").enq().build(), + CmdBuilder("get_integral").eot().arg("[0-9]{4}").escape("TI").enq().build(), + CmdBuilder("get_highlim").eot().arg("[0-9]{4}").escape("HS").enq().build(), + CmdBuilder("get_lowlim").eot().arg("[0-9]{4}").escape("LS").enq().build(), + CmdBuilder("get_error").eot().arg("[0-9]{4}").escape("EE").enq().build(), + CmdBuilder("get_address").eot().arg("[0-9]{4}").escape("").enq().build(), + CmdBuilder("set_ramp_setpoint", arg_sep="") + .eot() + .arg("[0-9]{4}") + .stx() + .escape("SL") + .float() + .etx() + .any() + .build(), + CmdBuilder("set_output_rate", arg_sep="") + .eot() + .arg("[0-9]{4}") + .stx() + .escape("OR") + .float() + .etx() + .any() + .build(), + } + + # Add terminating characters manually for each command, + # as write and read commands use different formatting for their 'in' commands. + in_terminator = "" + out_terminator = "" + readtimeout = 1 + + # calculate a eurotherm xor checksum character from a data string + def make_checksum(self, chars: str) -> str: + """Make a checksum to send after a read or write command. + + Args: + chars: a string holding the read or write command. + + Returns: A unicode string of one value, the checksum of the read + or write command, in chr type. + + """ + checksum = 0 + for c in chars: + checksum ^= ord(c) + return chr(checksum) + + def make_read_reply(self, command: str, value: str | float | int) -> str: + """Make a read reply to send to Eurotherm sensor. + + Args: + command: a string which holds the read command to send. + value: a string/float/int which holds the value one wants to read. + + Returns: A string holding the read reply. + + """ + reply = f"\x02{command}{value!s}\x03" + # checksum calculated on characters after \x02 but up to and including \x03 + return f"{reply}{self.make_checksum(reply[1:])}" + + def handle_error(self, request: str, error: Exception | str) -> None: + """Print an error if a command is not recognised. + + Args: + request: requested string + error: problem + + Returns: None + + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @if_connected + @translate_adddress + def get_address(self, addr: str) -> str: + """Get the address of the specific Eurotherm sensor, i.e. A01 or 0011. + + Returns: the address as a string. + """ + return self.make_read_reply("", self.device.address(addr)) + + @if_connected + @translate_adddress + def get_setpoint(self, addr: str) -> str | None: + """Get the setpoint of the Eurotherm sensor. + + Returns: the setpoint value formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("SL", self.device.setpoint_temperature(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_proportional(self, addr: str) -> str | None: + """Get the proportional of the Eurotherm sensor. + + Returns: the proportional value formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("XP", self.device.p(addr)) + except Exception as e: + print(e) + return None + + @if_connected + @translate_adddress + def get_integral(self, addr: str) -> str | None: + """Get the integral of the Eurotherm sensor. + + Returns: the integral value formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("TI", self.device.i(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_derivative(self, addr: str) -> str | None: + """Get the derivative of the Eurotherm sensor. + + Returns: the derivative value formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("TD", self.device.d(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_output(self, addr: str) -> str | None: + """Get the output of the Eurotherm sensor. + + Returns: the output value formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("OP", self.device.output(addr)) + except Exception as e: + print(e) + return None + + @if_connected + @translate_adddress + def get_highlim(self, addr: str) -> str | None: + """Get the high limit of the Eurotherm sensor. + + Returns: the high limit formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("HS", self.device.high_lim(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_lowlim(self, addr: str) -> str | None: + """Get the low limit of the Eurotherm sensor. + + Returns: the low limit formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("LS", self.device.low_lim(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_max_output(self, addr: str) -> str | None: + """Get the max output value of the Eurotherm sensor. + + Returns: the max output value formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("HO", self.device.max_output(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_autotune(self, addr: str) -> str | None: + """Get the autotune value of the Eurotherm sensor. + + Returns: the autotune formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("AT", self.device.autotune(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_current_temperature(self, addr: str) -> str | None: + """Get the current temperature of the device. + + Returns: the current temperature formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("PV", self.device.current_temperature(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def get_output_rate(self, addr: str) -> str | None: + """Get output rate of Eurotherm sensor. + + Returns: the output rate formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("OR", self.device.output_rate(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def set_output_rate(self, addr: str, output_rate: int, _: str) -> str | None: + """Set the output rate. + + Args: + output_rate: output rate to set as int. + _: argument captured by the command. + addr: address of the Eurotherm sensor. + + """ + try: + self.device.set_output_rate(addr, output_rate) + return "\x06" + except Exception: + return None + + @if_connected + @translate_adddress + def get_ramp_setpoint(self, addr: str) -> str | None: + """Get the set point temperature. + + Returns: the current set point temperature formatted like the Eurotherm protocol. + """ + try: + return self.make_read_reply("SP", self.device.ramp_setpoint_temperature(addr)) + except Exception: + return None + + @if_connected + @translate_adddress + def set_ramp_setpoint(self, addr: str, temperature: float, _: str) -> str | None: + """Set the set point temperature. + + Args: + temperature: the temperature to set the setpoint to. + _: argument captured by the command. + addr: address of the Eurotherm sensor. + + """ + try: + self.device.set_ramp_setpoint_temperature(addr, temperature) + return "\x06" + except Exception: + return None + + @if_connected + @translate_adddress + def get_error(self, addr: str) -> str: + """Get the error. + + Returns: the current error code in HEX. + """ + reply = "\x02EE>0x{}\x03".format(self.device.error(addr)) + return f"{reply}{self.make_checksum(reply[1:])}" diff --git a/lewis/devices/eurotherm/states.py b/lewis/devices/eurotherm/states.py new file mode 100644 index 00000000..0de86c45 --- /dev/null +++ b/lewis/devices/eurotherm/states.py @@ -0,0 +1,7 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state.""" + + NAME = "Default" diff --git a/lewis/devices/fermichopper/__init__.py b/lewis/devices/fermichopper/__init__.py new file mode 100644 index 00000000..227254f1 --- /dev/null +++ b/lewis/devices/fermichopper/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedFermichopper + +__all__ = ["SimulatedFermichopper"] diff --git a/lewis/devices/fermichopper/device.py b/lewis/devices/fermichopper/device.py new file mode 100644 index 00000000..90795175 --- /dev/null +++ b/lewis/devices/fermichopper/device.py @@ -0,0 +1,167 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState, GoingState, StoppedState, StoppingState + + +class SimulatedFermichopper(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.last_command = "0000" + self.speed = 0 + self.speed_setpoint = 0 + self._allowed_speed_setpoints = list(50 * i for i in range(1, 13)) + + self.delay_highword = 0 + self.delay_lowword = 0 + self.delay = 0 + + self.gatewidth = 0 + + self.electronics_temp = 30.0 + self.motor_temp = 30.0 + + self.voltage = 0 + self.current = 0 + + self.autozero_1_lower = 0 + self.autozero_2_lower = 0 + self.autozero_1_upper = 0 + self.autozero_2_upper = 0 + + self.drive = False + self.runmode = False + self.magneticbearing = False + + self.parameters = None + + self.is_lying_about_delay_sp_rbv = False + self.is_lying_about_gatewidth = False + self.is_broken = False + + def reset(self): + self._initialize_data() + + def _get_state_handlers(self): + return { + "default": DefaultState(), + "stopping": StoppingState(), + "going": GoingState(), + "stopped": StoppedState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("default", "stopped"), lambda: not self.runmode), + (("default", "going"), lambda: self.runmode), + (("stopped", "going"), lambda: self.runmode), + (("going", "stopping"), lambda: self.runmode is False), + (("stopping", "stopped"), lambda: self.speed == 0), + ] + ) + + def do_command(self, command): + valid_commands = ["0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"] + assert command in valid_commands, "Invalid command." + + self.last_command = command + + if command == "0001": + self.drive = True + self.runmode = False + elif command == "0002": + self.drive = False + elif command == "0003": + if self.drive and self.speed_setpoint == 600: + self.is_broken = True + self.drive = True + self.runmode = True + elif command == "0004": + self.magneticbearing = True + elif command == "0005": + self.magneticbearing = False + elif command == "0006": + self.parameters = ChopperParameters.MERLIN_LARGE + elif command == "0007": + self.parameters = ChopperParameters.HET_MARI + elif command == "0008": + self.parameters = ChopperParameters.MERLIN_SMALL + + def get_last_command(self): + return self.last_command + + def set_speed_setpoint(self, value): + assert value in self._allowed_speed_setpoints, "Speed setpoint {} not allowed".format(value) + + if value == 600 and self.speed_setpoint == 600 and self.speed == 600: + self.is_broken = True + + self.speed_setpoint = value + + def get_speed_setpoint(self): + return self.speed_setpoint + + def set_true_speed(self, value): + self.speed = value + + def get_true_speed(self): + return self.speed + + def set_delay_highword(self, value): + self.delay_highword = value + self.update_delay() + + def set_delay_lowword(self, value): + self.delay_lowword = value + self.update_delay() + + def update_delay(self): + self.delay = self.delay_highword * 65536 + self.delay_lowword + self.is_lying_about_delay_sp_rbv = ( + False # Resending the setpoint causes the device to no longer be confused + ) + + def set_gate_width(self, value): + self.gatewidth = value + self.is_lying_about_gatewidth = ( + False # Resending the setpoint causes the device to no longer be confused + ) + + def get_gate_width(self): + if self.is_lying_about_gatewidth: + return self.gatewidth + 123 + else: + return self.gatewidth + + def get_electronics_temp(self): + return self.electronics_temp + + def get_motor_temp(self): + return self.motor_temp + + def get_voltage(self): + return self.voltage + + def get_current(self): + return self.current + + def get_nominal_delay(self): + if self.is_lying_about_delay_sp_rbv: + return self.delay + 123 + else: + return self.delay + + def get_actual_delay(self): + return self.delay + + +class ChopperParameters(object): + MERLIN_SMALL = 1 + MERLIN_LARGE = 2 + HET_MARI = 3 diff --git a/lewis/devices/fermichopper/interfaces/__init__.py b/lewis/devices/fermichopper/interfaces/__init__.py new file mode 100644 index 00000000..ba63f63d --- /dev/null +++ b/lewis/devices/fermichopper/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface_merlin import FermichopperStreamInterface + +__all__ = ["FermichopperStreamInterface"] diff --git a/lewis/devices/fermichopper/interfaces/common_interface_utils.py b/lewis/devices/fermichopper/interfaces/common_interface_utils.py new file mode 100644 index 00000000..a3ba604b --- /dev/null +++ b/lewis/devices/fermichopper/interfaces/common_interface_utils.py @@ -0,0 +1,13 @@ +from lewis.utils.command_builder import CmdBuilder + +HEX_LEN_2 = "[0-9A-F]{2}" +HEX_LEN_4 = "[0-9A-F]{4}" + +COMMANDS = { + CmdBuilder("get_all_data").escape("#00000").arg(HEX_LEN_2).build(), + CmdBuilder("execute_command").escape("#1").arg(HEX_LEN_4).arg(HEX_LEN_2).build(), + CmdBuilder("set_speed").escape("#3").arg(HEX_LEN_4).arg(HEX_LEN_2).build(), + CmdBuilder("set_delay_highword").escape("#6").arg(HEX_LEN_4).arg(HEX_LEN_2).build(), + CmdBuilder("set_delay_lowword").escape("#5").arg(HEX_LEN_4).arg(HEX_LEN_2).build(), + CmdBuilder("set_gate_width").escape("#9").arg(HEX_LEN_4).arg(HEX_LEN_2).build(), +} diff --git a/lewis/devices/fermichopper/interfaces/stream_interface_maps.py b/lewis/devices/fermichopper/interfaces/stream_interface_maps.py new file mode 100644 index 00000000..eafbeeb5 --- /dev/null +++ b/lewis/devices/fermichopper/interfaces/stream_interface_maps.py @@ -0,0 +1,175 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log + +from ..device import ChopperParameters +from .common_interface_utils import COMMANDS + +TIMING_FREQ_MHZ = 18.0 + + +class JulichChecksum(object): + @staticmethod + def _calculate(alldata): + """Calculates the Julich checksum of the given data + :param alldata: the input data (list of chars, length 5) + :return: the Julich checksum of the given input data + """ + assert all( + i in list("#0123456789ABCDEFGH") for i in alldata + ), "Invalid character can't calculate checksum" + return ( + "00" + if all(x in ["0", "#"] for x in alldata) + else hex(sum(ord(i) for i in alldata[1:])).upper()[-2:] + ) + + @staticmethod + def verify(header, data, actual_checksum): + """Verifies that the checksum of received data is correct. + :param header: The leading # and the first byte (str, length 2) + :param data: The data bytes (str, length 4) + :param actual_checksum: The transmitted checksum (str, length 2) + :return: Nothing + :raises: AssertionError: If the checksum didn't match or the inputs were invalid + """ + assert len(header) == 2, "Header should have length 2" + assert len(data) == 4, "Data should have length 4" + assert len(actual_checksum) == 2, "Actual checksum should have length 2" + assert JulichChecksum._calculate(header + data) == actual_checksum, "Checksum did not match" + + @staticmethod + def append(data): + """Utility method for appending the Julich checksum to the input data + :param data: the input data + :return: the input data with it's checksum appended + """ + assert len(data) == 6, "Unexpected data length." + return data + JulichChecksum._calculate(data) + + +@has_log +class FermichopperStreamInterface(StreamInterface): + protocol = "fermi_maps" + + commands = COMMANDS + + in_terminator = "$" + out_terminator = "" + + def build_status_code(self): + status = 0 + + if True: # Microcontroller OK? + status += 1 + if self._device.get_true_speed() == self._device.get_speed_setpoint(): + status += 2 + if self._device.magneticbearing: + status += 8 + if self._device.get_voltage() > 0: + status += 16 + if self._device.drive: + status += 32 + if self._device.parameters == ChopperParameters.MERLIN_LARGE: + status += 64 + if False: # Interlock open? + status += 128 + if self._device.parameters == ChopperParameters.HET_MARI: + status += 256 + if self._device.parameters == ChopperParameters.MERLIN_SMALL: + status += 512 + if self._device.speed > 600: + status += 1024 + if self._device.speed > 10 and not self._device.magneticbearing: + status += 2048 + if any( + abs(voltage) > 3 + for voltage in [ + self._device.autozero_1_lower, + self._device.autozero_2_lower, + self._device.autozero_1_upper, + self._device.autozero_2_upper, + ] + ): + status += 4096 + + return status + + def handle_error(self, request, error): + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + def get_all_data(self, checksum): + JulichChecksum.verify("#0", "0000", checksum) + + def autozero_calibrate(value): + return (value + 7.0) / 0.0137 + + return ( + JulichChecksum.append("#1" + self._device.get_last_command()) + + JulichChecksum.append("#2{:04X}".format(self.build_status_code())) + + JulichChecksum.append( + "#3{:04X}".format(int(round(self._device.get_speed_setpoint() * 60))) + ) + + JulichChecksum.append( + "#4{:04X}".format(int(round(self._device.get_true_speed() * 60))) + ) + + JulichChecksum.append( + "#5{:04X}".format( + int(round((self._device.get_nominal_delay() * TIMING_FREQ_MHZ) % 65536)) + ) + ) + + JulichChecksum.append( + "#6{:04X}".format( + int(round((self._device.get_nominal_delay() * TIMING_FREQ_MHZ) / 65536)) + ) + ) + + JulichChecksum.append( + "#7{:04X}".format( + int(round((self._device.get_actual_delay() * TIMING_FREQ_MHZ) % 65536)) + ) + ) + + JulichChecksum.append( + "#8{:04X}".format( + int(round((self._device.get_actual_delay() * TIMING_FREQ_MHZ) / 65536)) + ) + ) + + JulichChecksum.append( + "#9{:04X}".format(int(round(self._device.get_gate_width() * TIMING_FREQ_MHZ))) + ) + + JulichChecksum.append( + "#A{:04X}".format(int(round(self._device.get_current() / 0.00684))) + ) + + JulichChecksum.append( + "#B{:04X}".format(int(round(autozero_calibrate(self._device.autozero_1_upper)))) + ) + + JulichChecksum.append( + "#C{:04X}".format(int(round(autozero_calibrate(self._device.autozero_2_upper)))) + ) + + JulichChecksum.append( + "#D{:04X}".format(int(round(autozero_calibrate(self._device.autozero_1_lower)))) + ) + + JulichChecksum.append( + "#E{:04X}".format(int(round(autozero_calibrate(self._device.autozero_2_lower)))) + ) + + "$" + ) + + def execute_command(self, command, checksum): + JulichChecksum.verify("#1", command, checksum) + self._device.do_command(command) + + def set_speed(self, command, checksum): + JulichChecksum.verify("#3", command, checksum) + self._device.set_speed_setpoint(int(command, 16) / 60) + + def set_delay_lowword(self, command, checksum): + JulichChecksum.verify("#5", command, checksum) + self._device.set_delay_lowword(int(command, 16) / TIMING_FREQ_MHZ) + + def set_delay_highword(self, command, checksum): + JulichChecksum.verify("#6", command, checksum) + self._device.set_delay_highword(int(command, 16) / TIMING_FREQ_MHZ) + + def set_gate_width(self, command, checksum): + JulichChecksum.verify("#9", command, checksum) + self._device.set_gate_width(int(command, 16) / TIMING_FREQ_MHZ) diff --git a/lewis/devices/fermichopper/interfaces/stream_interface_merlin.py b/lewis/devices/fermichopper/interfaces/stream_interface_merlin.py new file mode 100644 index 00000000..23474733 --- /dev/null +++ b/lewis/devices/fermichopper/interfaces/stream_interface_merlin.py @@ -0,0 +1,188 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log + +from ..device import ChopperParameters +from .common_interface_utils import COMMANDS + +TIMING_FREQ_MHZ = 50.4 + + +class JulichChecksum(object): + @staticmethod + def _calculate(alldata): + """Calculates the Julich checksum of the given data + :param alldata: the input data (list of chars, length 5) + :return: the Julich checksum of the given input data + """ + assert all( + i in list("#0123456789ABCDEFGH") for i in alldata + ), "Invalid character can't calculate checksum" + return ( + "00" + if all(x in ["0", "#"] for x in alldata) + else hex(sum(ord(i) for i in alldata)).upper()[-2:] + ) + + @staticmethod + def verify(header, data, actual_checksum): + """Verifies that the checksum of received data is correct. + :param header: The leading # and the first byte (str, length 2) + :param data: The data bytes (str, length 4) + :param actual_checksum: The transmitted checksum (str, length 2) + :return: Nothing + :raises: AssertionError: If the checksum didn't match or the inputs were invalid + """ + assert len(header) == 2, "Header should have length 2" + assert len(data) == 4, "Data should have length 4" + assert len(actual_checksum) == 2, "Actual checksum should have length 2" + assert ( + JulichChecksum._calculate(list(header) + list(data)) == actual_checksum + ), "Checksum did not match" + + @staticmethod + def append(data): + """Utility method for appending the Julich checksum to the input data + :param data: the input data + :return: the input data with it's checksum appended + """ + assert len(data) == 6, "Unexpected data length." + return data + JulichChecksum._calculate(data) + + +@has_log +class FermichopperStreamInterface(StreamInterface): + protocol = "fermi_merlin" + + commands = COMMANDS + + in_terminator = "$\n" + out_terminator = "\n" + + def build_status_code(self): + status = 0 + + if True: # Microcontroller OK? + status += 1 + if self._device.get_true_speed() == self._device.get_speed_setpoint(): + status += 2 + if self._device.magneticbearing: + status += 8 + if self._device.get_voltage() > 0: + status += 16 + if self._device.drive: + status += 32 + if self._device.parameters == ChopperParameters.MERLIN_LARGE: + status += 64 + if False: # Interlock open? + status += 128 + if self._device.parameters == ChopperParameters.HET_MARI: + status += 256 + if self._device.parameters == ChopperParameters.MERLIN_SMALL: + status += 512 + if self._device.speed > 600: + status += 1024 + if self._device.speed > 10 and not self._device.magneticbearing: + status += 2048 + if any( + abs(voltage) > 3 + for voltage in [ + self._device.autozero_1_lower, + self._device.autozero_2_lower, + self._device.autozero_1_upper, + self._device.autozero_2_upper, + ] + ): + status += 4096 + + return status + + def handle_error(self, request, error): + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + def get_all_data(self, checksum): + JulichChecksum.verify("#0", "0000", checksum) + + def autozero_calibrate(value): + return (value + 22.86647) / 0.04486 + + return ( + JulichChecksum.append("#1" + self._device.get_last_command()) + + JulichChecksum.append("#2{:04X}".format(self.build_status_code())) + + JulichChecksum.append( + "#3000{:01X}".format(int(round(12 - (self._device.get_speed_setpoint() / 50)))) + ) + + JulichChecksum.append( + "#4{:04X}".format(int(round(self._device.get_true_speed() * 60))) + ) + + JulichChecksum.append( + "#5{:04X}".format( + int(round((self._device.get_nominal_delay() * TIMING_FREQ_MHZ) % 65536)) + ) + ) + + JulichChecksum.append( + "#6{:04X}".format( + int(round((self._device.get_nominal_delay() * TIMING_FREQ_MHZ) / 65536)) + ) + ) + + JulichChecksum.append( + "#7{:04X}".format( + int(round((self._device.get_actual_delay() * TIMING_FREQ_MHZ) % 65536)) + ) + ) + + JulichChecksum.append( + "#8{:04X}".format( + int(round((self._device.get_actual_delay() * TIMING_FREQ_MHZ) / 65536)) + ) + ) + + JulichChecksum.append( + "#9{:04X}".format(int(round(self._device.get_gate_width() * TIMING_FREQ_MHZ))) + ) + + JulichChecksum.append( + "#A{:04X}".format(int(round(self._device.get_current() / 0.002016))) + ) + + JulichChecksum.append( + "#B{:04X}".format(int(round(autozero_calibrate(self._device.autozero_1_upper)))) + ) + + JulichChecksum.append( + "#C{:04X}".format(int(round(autozero_calibrate(self._device.autozero_2_upper)))) + ) + + JulichChecksum.append( + "#D{:04X}".format(int(round(autozero_calibrate(self._device.autozero_1_lower)))) + ) + + JulichChecksum.append( + "#E{:04X}".format(int(round(autozero_calibrate(self._device.autozero_2_lower)))) + ) + + JulichChecksum.append( + "#F{:04X}".format(int(round(self._device.get_voltage() / 0.4274))) + ) + + JulichChecksum.append( + "#G{:04X}".format( + int(round((self._device.get_electronics_temp() + 25.0) / 0.14663)) + ) + ) + + JulichChecksum.append( + "#H{:04X}".format(int(round((self._device.get_motor_temp() + 12.124) / 0.1263))) + ) + + "$" + ) + + def execute_command(self, command, checksum): + JulichChecksum.verify("#1", command, checksum) + self._device.do_command(command) + + def set_speed(self, command, checksum): + JulichChecksum.verify("#3", command, checksum) + self._device.set_speed_setpoint(int((12 - int(command, 16)) * 50)) + + def set_delay_lowword(self, command, checksum): + JulichChecksum.verify("#5", command, checksum) + self._device.set_delay_lowword(int(command, 16) / TIMING_FREQ_MHZ) + + def set_delay_highword(self, command, checksum): + JulichChecksum.verify("#6", command, checksum) + self._device.set_delay_highword(int(command, 16) / TIMING_FREQ_MHZ) + + def set_gate_width(self, command, checksum): + JulichChecksum.verify("#9", command, checksum) + self._device.set_gate_width(int(command, 16) / TIMING_FREQ_MHZ) diff --git a/lewis/devices/fermichopper/states.py b/lewis/devices/fermichopper/states.py new file mode 100644 index 00000000..0458b7ee --- /dev/null +++ b/lewis/devices/fermichopper/states.py @@ -0,0 +1,54 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +def check_speed(device): + if device.get_true_speed() > 10 and not device.magneticbearing: + device.is_broken = True + + +class DefaultState(State): + def in_state(self, dt): + check_speed(self._context) + + +class StoppingState(State): + def in_state(self, dt): + device = self._context + + rate = 0 + + if not device.magneticbearing: + rate += 1 + if device.drive: + rate += 50 + + device.set_true_speed(approaches.linear(device.get_true_speed(), 0, rate, dt)) + + check_speed(device) + + +class GoingState(State): + def in_state(self, dt): + device = self._context + + rate = 0 + + if device.drive: + rate += 50 + if not device.magneticbearing: + rate -= 1 + + device.set_true_speed( + approaches.linear(device.get_true_speed(), device.get_speed_setpoint(), rate, dt) + ) + + check_speed(device) + + +class StoppedState(State): + def in_state(self, dt): + check_speed(self._context) + + def on_entry(self, dt): + self._context.drive = False diff --git a/lewis/devices/fins/__init__.py b/lewis/devices/fins/__init__.py new file mode 100644 index 00000000..a79a73d5 --- /dev/null +++ b/lewis/devices/fins/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedFinsPLC + +__all__ = ["SimulatedFinsPLC"] diff --git a/lewis/devices/fins/device.py b/lewis/devices/fins/device.py new file mode 100644 index 00000000..15870714 --- /dev/null +++ b/lewis/devices/fins/device.py @@ -0,0 +1,352 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedFinsPLC(StateMachineDevice): + """Class represented a simulated Helium Recovery FINS PLC. + """ + + HELIUM_RECOVERY_NODE = 58 + + # a dictionary representing the mapping between pv names, and the memory addresses in the helium recovery FINS PLC + # that store the data corresponding to each PV. + PV_NAME_MEMORY_MAPPING = { + # pv names for memory locations that store 16 bit integers, in the order they appear in the substitutions file + # (except the heartbeat, which appears in the header template) + "HEARTBEAT": 19500, + "MCP:BANK1:TS2": 19501, + "MCP:BANK1:TS1": 19502, + "MCP1:BANK2:IMPURE_HE": 19503, + "MCP2:BANK2:IMPURE_HE": 19504, + "MCP1:BANK3:MAIN_HE_STORAGE": 19505, + "MCP2:BANK3:MAIN_HE_STORAGE": 19506, + "MCP1:BANK4:DLS_HE_STORAGE": 19507, + "MCP2:BANK4:DLS_HE_STORAGE": 19508, + "MCP1:BANK5:SPARE_STORAGE": 19509, + "MCP2:BANK5:SPARE_STORAGE": 19510, + "MCP1:BANK6:SPARE_STORAGE": 19511, + "MCP2:BANK6:SPARE_STORAGE": 19512, + "MCP1:BANK7:SPARE_STORAGE": 19513, + "MCP2:BANK7:SPARE_STORAGE": 19514, + "MCP1:BANK8:SPARE_STORAGE": 19515, + "MCP2:BANK8:SPARE_STORAGE": 19516, + "MCP:INLET:PRESSURE": 19517, + "MCP:EXTERNAL_TEMP": 19518, + "GAS_LIQUEFACTION:MASS_FLOW": 19521, + "HE_FILLS:MASS_FLOW": 19522, + "CMPRSSR:INTERNAL_TEMP": 19523, + "COLDBOX:HE_TEMP": 19524, + "COLDBOX:HE_TEMP:LIMIT": 19525, + "TRANSPORT_DEWAR:PRESSURE": 19526, + "HE_PURITY": 19533, + "DEW_POINT": 19534, + "FLOW_METER:TS2:EAST": 19652, + "TS2:EAST:O2": 19653, + "FLOW_METER:TS2:WEST": 19662, + "TS2:WEST:O2": 19663, + "TS1:NORTH:O2": 19668, + "TS1:SOUTH:O2": 19669, + "FLOW_METER:TS1:WINDOW": 19697, + "FLOW_METER:TS1:SHUTTER": 19698, + "FLOW_METER:TS1:VOID": 19699, + "BANK1:TS2:RSPPL:AVG_PURITY": 19929, + "BANK1:TS1:RSPPL:AVG_PURITY": 19930, + "BANK2:IMPURE_HE:AVG_PURITY": 19931, + "BANK3:MAIN_STRG:AVG_PURITY": 19933, + "BANK4:DLS_STRG:AVG_PURITY": 19935, + "BANK5:SPR_STRG:AVG_PURITY": 19937, + "BANK6:SPR_STRG:AVG_PURITY": 19939, + "BANK7:SPR_STRG:AVG_PURITY": 19941, + "BANK8:SPR_STRG:AVG_PURITY": 19943, + "COLDBOX:TURBINE_100:SPEED": 19945, + "COLDBOX:TURBINE_101:SPEED": 19946, + "COLDBOX:T106:TEMP": 19947, + "COLDBOX:TT111:TEMP": 19948, + "COLDBOX:PT102:PRESSURE": 19949, + "BUFFER:PT203:PRESSURE": 19950, + "PURIFIER:TT104:TEMP": 19951, + "PURIFIER:TT102:TEMP": 19952, + "COLDBOX:TT108:TEMP": 19953, + "COLDBOX:PT112:PRESSURE": 19954, + "COLDBOX:CNTRL_VALVE_103": 19955, + "COLDBOX:CNTRL_VALVE_111": 19956, + "COLDBOX:CNTRL_VALVE_112": 19957, + "MOTHER_DEWAR:HE_LEVEL": 19958, + "PURIFIER:LEVEL": 19961, + "IMPURE_HE_SUPPLY:PRESSURE": 19962, + "CMPRSSR:LOW_CNTRL_PRESSURE": 19963, + "CMPRSSR:HIGH_CNTRL_PRESSURE": 19964, + "CNTRL_VALVE_2250": 19972, + "CNTRL_VALVE_2150": 19974, + "CNTRL_VALVE_2160": 19975, + "LIQUEFIER:_ALARM1": 19982, + "LIQUEFIER:_ALARM2": 19983, + "MCP:LIQUID_HE_INVENTORY": 19996, + # pv names for memory locations storing 32 bit integers, in the order they appear in the substitutions file + "GC:R108:U40": 19700, + "GC:R108:DEWAR_FARM": 19702, + "GC:R55:TOTAL": 19704, + "GC:R55:NORTH": 19706, + "GC:R55:SOUTH": 19708, + "GC:MICE_HALL": 19710, + "GC:MUON": 19712, + "GC:PEARL_HRPD_MARI_ENGINX": 19714, + "GC:SXD_AND_MERLIN": 19720, + "GC:CRYO_LAB": 19724, + "GC:MAPS_AND_VESUVIO": 19726, + "GC:SANDALS": 19728, + "GC:CRISP_AND_LOQ": 19730, + "GC:IRIS_AND_OSIRIS": 19734, + "GC:INES_AND_TOSCA": 19736, + "GC:RIKEN": 19738, + "GC:R80:TOTAL": 19746, + "GC:R53": 19748, + "GC:R80:EAST": 19750, + "GC:WISH": 19752, + "GC:WISH:DEWAR_FARM": 19754, + "GC:LARMOR_AND_OFFSPEC": 19756, + "GC:ZOOM_SANS2D_AND_POLREF": 19758, + "GC:MAGNET_LAB": 19762, + "GC:IMAT": 19766, + "GC:LET_AND_NIMROD": 19768, + "GC:R80:WEST": 19772, + # pv names for bi records for automatic/manual modes, in the order they appear in the substitutions file + "LIQUID_NITROGEN:STATUS": 19979, + "CNTRL_VALVE_120:MODE": 19967, + "CNTRL_VALVE_121:MODE": 19969, + "LOW_PRESSURE:MODE": 19971, + "HIGH_PRESSURE:MODE": 19973, + "TIC106:MODE": 19976, + "PIC112:MODE": 19977, + # pv names for various other mbbi records, in the order they appear in the header template + "CNTRL_VALVE_120:POSITION": 19968, + "CNTRL_VALVE_121:POSITION": 19970, + "PURIFIER:STATUS": 19978, + "CMPRSSR:STATUS": 19980, + "COLDBOX:STATUS": 19981, + # pv names for mbbi records that store the status of valves, in the order they appear in the substitutions file + "MOTORISED_VALVE_108:STATUS": 19875, + "CNTRL_VALVE_112:STATUS": 19871, + "CNTRL_VALVE_2150:STATUS": 19872, + "CNTRL_VALVE_2160:STATUS": 19873, + "CNTRL_VALVE_2250:STATUS": 19874, + "MOTORISED_VALVE_110:STATUS": 19984, + "MOTORISED_VALVE_160:STATUS": 19985, + "MOTORISED_VALVE_163:STATUS": 19986, + "MOTORISED_VALVE_167:STATUS": 19987, + "MOTORISED_VALVE_172:STATUS": 19988, + "MOTORISED_VALVE_174:STATUS": 19989, + "MOTORISED_VALVE_175:STATUS": 19990, + "MOTORISED_VALVE_176:STATUS": 19991, + "MOTORISED_VALVE_177:STATUS": 19992, + "MOTORISED_VALVE_178:STATUS": 19993, + "CNTRL_VALVE_103:STATUS": 19994, + "CNTRL_VALVE_111:STATUS": 19995, + # pv names for memory locations storing floating point numbers, in the order they appear in the substitutions + # file + "MASS_FLOW:HE_RSPPL:TS2:EAST": 19876, # TS2 mass flow total helium resupply east + "MASS_FLOW:HE_RSPPL:TS2:WEST": 19878, # TS2 mass flow total helium resupply west + "MASS_FLOW:HE_RSPPL:TS1:VOID": 19880, # TS1 mass flow target group helium resupply void + "MASS_FLOW:HE_RSPPL:TS1:WNDW": 19882, # TS1 mass flow target group helium resupply window + "MASS_FLOW:HE_RSPPL:TS1:SHTR": 19884, # TS1 mass flow target group helium resupply shutter + } + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.network_address = 0x00 + self.unit_address = 0x00 + + self.connected = True + + # represents the part of the plc memory that stores 16 bit ints. + self.int16_memory = { + # memory locations in the order they appear in the substitutions file (except the heartbeat) + 19500: 0, # heartbeat + 19501: 0, # mcp bank 1 TS2 helium gas resupply + 19502: 0, # mcp bank 1 TS1 helium gas resupply + 19503: 0, # mcp 1 bank 2 impure helium + 19504: 0, # mcp 2 bank 2 impure helium + 19505: 0, # mcp 1 bank 3 main helium storage + 19506: 0, # mcp 2 bank 3 main helium storage + 19507: 0, # mcp 1 bank 4 dls helium storage + 19508: 0, # mcp 2 bank 4 dls helium storage + 19509: 0, # mcp 1 bank 5 spare storage + 19510: 0, # mcp 2 bank 5 spare storage + 19511: 0, # mcp 1 bank 6 spare storage + 19512: 0, # mcp 2 bank 6 spare storage + 19513: 0, # mcp 1 bank 7 spare storage + 19514: 0, # mcp 2 bank 7 spare storage + 19515: 0, # mcp 1 bank 8 spare storage + 19516: 0, # mcp 2 bank 8 spare storage + 19517: 0, # mcp manifold inlet pressure from compressors + 19518: 0, # mcp external temperature + 19521: 0, # mass flow meter for gas flow liquefaction + 19522: 0, # mass flow meter for helium fills, + 19523: 0, # Kaiser compressor container internal temperature + 19524: 0, # Coldbox Helium temperature + 19525: 0, # Coldbox Helium temperature limit + 19526: 0, # Transport dewar flash pressure + 19533: 0, # helium purity + 19534: 0, # dew point + 19652: 0, # TS2 east flow meter + 19653: 0, # O2 level TS2 east + 19662: 0, # TS2 west flow meter + 19663: 0, # TS2 west O2 level + 19668: 0, # TS1 north O2 level + 19669: 0, # TS1 south OS level + 19697: 0, # TS1 window flow meter + 19698: 0, # TS1 shutter flow meter + 19699: 0, # TS1 void flow meter, + 19929: 0, # bank 1 TS2 helium gas resupply average purity + 19930: 0, # bank 1 TS1 helium gas resupply average purity + 19931: 0, # bank 2 impure helium average purity + 19933: 0, # bank 3 ISIS main helium purity average + 19935: 0, # bank 4 DLS main helium storage purity average + 19937: 0, # bank 5 ISIS helium spare storage purity average + 19939: 0, # bank 6 ISIS helium spare storage purity average + 19941: 0, # bank 7 ISIS helium spare storage purity average + 19943: 0, # bank 8 ISIS helium spare storage purity average + 19945: 0, # coldbox turbine 100 speed + 19946: 0, # coldbox turbine 101 speed + 19947: 0, # coldbox tempereture T106 + 19948: 0, # coldbox temperature transducer 111 + 19949: 0, # coldbox pressure transducer 102 + 19950: 0, # buffer pressure transducer 203 + 19951: 0, # purifier temperature transducer 104 + 19952: 0, # purifier temperature transducer 102 + 19953: 0, # coldbox temperature transducer 108 + 19954: 0, # coldbox pressure transducer 112 + 19955: 0, # liquefier coldbox control valve 103 % + 19956: 0, # liquefier coldbox control valve 111 % + 19957: 0, # liquefier coldbox control valve 112 % + 19958: 0, # helium mother dewar level + 19961: 0, # purifier level % + 19962: 0, # impure helium supply pressure + 19963: 0, # compressor low pressure control pressure + 19964: 0, # compressor high pressure control pressure + 19966: 0, # liquefier coldbox cv103 % + 19972: 0, # control valve 2250 % + 19974: 0, # control valve 2150 % + 19975: 0, # control valve 2160 % + 19982: 0, # liquefier alarm 1 + 19983: 0, # liquefier alarm 2 + 19996: 0, # mcp liquid helium inventory + # memory locations corresponding to bi records for automatic/manual mode + 19967: 0, # control valve 120 automatic/manual mode + 19969: 0, # control valve 121 automatic/manual mode + 19971: 0, # low pressure automatic/manual + 19973: 0, # high pressure automatic/manual + 19976: 0, # TIC106 automatic/manual + 19977: 0, # PIC112 automatic/manual + # memory locations corresponding to various mbbi records + 19979: 0, # liquid nitrogen status + 19968: 0, # control valve 120 position + 19970: 0, # control valve 121 position + 19978: 0, # purifier status + 19980: 0, # compressor status + 19981: 0, # coldbox status + # the part of the plc memory storing valve statuses, in the order they appear in the memory map + 19875: 0, # liquefier coldbox motorised valve 108 status + 19871: 0, # control valve 112 status + 19872: 0, # liquefier compressor control valve 2150 status + 19873: 0, # liquefier compressor control valve 2160 status + 19874: 0, # liquefier compressor control valve 2250 status + 19984: 0, # motorised valve 110 status + 19985: 0, # motorised valve 160 status + 19986: 0, # motorised valve 163 status + 19987: 0, # motorised valve 167 status + 19988: 0, # motorised valve 172 status + 19989: 0, # motorised valve 174 status + 19990: 0, # motorised valve 175 status + 19991: 0, # motorised valve 176 status + 19992: 0, # motorised valve 177 status + 19993: 0, # motorised valve 178 status + 19994: 0, # control valve 103 status + 19995: 0, # control valve 111 status + } + + # represents the part of the plc memory that stores 32 bit ints, in the order they appear in the memory map + self.int32_memory = { + 19700: 0, # R108 U40 gas counter + 19702: 0, # R108 dewar farm gas counter + 19704: 0, # gas counter R55 total + 19706: 0, # gas counter R55 north + 19708: 0, # gas counter R55 south + 19710: 0, # gas counter mice hall + 19712: 0, # gas counter muon + 19714: 0, # gas counter PEARL, HRPD, ENGIN-X, GEM and MARI + 19720: 0, # gas counter SXD and MERLIN + 19724: 0, # gas counter Cryo Lab + 19726: 0, # gas counter MAPS and VESUVIO + 19728: 0, # gas counter SANDALS + 19730: 0, # gas counter CRISP and LOQ + 19734: 0, # gas counter IRIS and OSIRIS + 19736: 0, # gas counter INES and TOSCA + 19738: 0, # gas counter RIKEN + 19746: 0, # gas counter R80 total + 19748: 0, # gas counter R53 + 19750: 0, # gas counter R80 east + 19752: 0, # gas counter WISH + 19754: 0, # gas counter WISH dewar farm + 19756: 0, # gas counter LARMOR and OFFSPEC + 19758: 0, # gas counter ZOOM, SANS2D and POLREF + 19762: 0, # gas counter magnet lab + 19766: 0, # gas counter IMAT + 19768: 0, # gas counter LET and NIMROD + 19772: 0, # gas counter R80 west + } + + # represents the part of the plc memory that stores floating point numbers, in the order they appear in the + # memory map. Comments explaining what each memory location is are in the name to address mappings above. + self.float_memory = {address: 0 for address in range(19876, 19886, 2)} + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def reset(self): + """Public method that re-initializes the device's fields. + + Returns: + Nothing. + """ + self._initialize_data() + + def set_memory(self, pv_name, data): + """Sets a location in the plc emulator's memory to the given data. + + Args: + pv_name (string): The pv name that the test wants to set. Each PV name corresponds to only one memory + location in the emulator. + data: The data to be put in the plc memory. + + Returns: + None. + + Raises: + ValueError: if the given pv name is in the name to memory address mapping, but the associated memory + location is not in the memory of the emulator. + """ + memory_location = SimulatedFinsPLC.PV_NAME_MEMORY_MAPPING[pv_name] + + if memory_location in self.int16_memory.keys(): + self.int16_memory[memory_location] = data + elif memory_location in self.int32_memory.keys(): + self.int32_memory[memory_location] = data + elif memory_location in self.float_memory.keys(): + self.float_memory[memory_location] = data + else: + raise ValueError( + "the pv name maps to a memory address that is not recognized by the emulator memory." + ) diff --git a/lewis/devices/fins/interfaces/__init__.py b/lewis/devices/fins/interfaces/__init__.py new file mode 100644 index 00000000..1d4aee21 --- /dev/null +++ b/lewis/devices/fins/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import FinsPLCStreamInterface + +__all__ = ["FinsPLCStreamInterface"] diff --git a/lewis/devices/fins/interfaces/response_utilities.py b/lewis/devices/fins/interfaces/response_utilities.py new file mode 100644 index 00000000..62d78b9e --- /dev/null +++ b/lewis/devices/fins/interfaces/response_utilities.py @@ -0,0 +1,229 @@ +from lewis.utils.byte_conversions import float_to_raw_bytes, int_to_raw_bytes, raw_bytes_to_int + +from ..device import SimulatedFinsPLC + + +def check_is_byte(character): + """Checks if the given character can represent a byte. Raises an error of it can not, otherwise returns nothing. + + Args: + character (string|byte): A one character string. + + Returns: + None. + """ + try: + number = ord(character) + except TypeError: + number = int(character) + if 0 > number > 255: + raise ValueError("the character in the string must represent a byte value") + + +def dm_memory_area_read_response_fins_frame( + device, + client_network_address, + client_node_address, + client_unit_address, + service_id, + memory_start_address, + number_of_words_to_read, + is_float, +): + """Returns a response to a DM memory area read command. + + Response structure is: + 10 bytes FINS frame header. + 2 bytes (integer): Command code, for memory area read in this case. + 2 bytes (integer): End code. Shows errors. + 2 bytes for every word read. + + Args: + device (device.SimulatedFinsPLC): The Lewis device. + client_network_address (int): The FINS network address of the client. + client_node_address (int): The FINS node of the client. + client_unit_address (int): The FINS unit address of the client. + service_id (int): The service ID of the original command. + memory_start_address (int): The memory address from where reading starts. + number_of_words_to_read (int): The number of words to be read, starting from the start address, inclusive. + is_float: data is a float + + Returns: + bytes: the response. + """ + # The length argument asks for number of bytes, and each word has two bytes + fins_reply = ( + FinsResponseBuilder() + .add_fins_frame_header( + device.network_address, + device.unit_address, + client_network_address, + client_node_address, + client_unit_address, + service_id, + ) + .add_fins_command_and_error_codes() + ) + + # The plc has 2 byte words. The command asks for 1 word if the memory address stores a 16 bit integer, or 2 words + # if it stores a 32 bit integer, or a real number. + if number_of_words_to_read == 1: + fins_reply = fins_reply.add_int(device.int16_memory[memory_start_address], 2) + + # The FINS driver does not recognise 32 bit ints. Instead, it represents 32 bit ints as an array of two 16 bit + # ints. Although the 16 bit ints are in big endian, in the array the first int is the least significant int, and + # the second one is the most significant one. + elif is_float: + data = _convert_32bit_float_to_int16_array(device.float_memory[memory_start_address]) + fins_reply = fins_reply.add_int(data[0], 2).add_int(data[1], 2) + elif number_of_words_to_read == 2: + # convert 32 bit int to array of two ints + data = _convert_32bit_int_to_int16_array(device.int32_memory[memory_start_address]) + fins_reply = fins_reply.add_int(data[0], 2).add_int(data[1], 2) + # The asyn device support for ai records makes the IOC ask for 4 words, even though the real numbers are only 2 + # words long + + return fins_reply.build() + + +def _convert_32bit_int_to_int16_array(number): + """Converts a 32 bit integer into an array of two 16 bit integers. The first 16 bit integer is the least significant + one, and the second is the most significant. The order of the 16 bit integers in the array is little endian. + + Args: + number (int): The number to be converted. But the individual 16 bit ints are encoded in big endian. + + Returns: + int list: a list, with the first element being the least significant byte of the given number, and the second + element being the most significant byte. + """ + if type(number) != int: + raise TypeError("number argument must always be an integer!") + + raw_bytes_representation = int_to_raw_bytes(number, 4, False) + + least_significant_byte = raw_bytes_to_int(raw_bytes_representation[2:4], low_bytes_first=False) + most_significant_byte = raw_bytes_to_int(raw_bytes_representation[:2], low_bytes_first=False) + + return [least_significant_byte, most_significant_byte] + + +def _convert_32bit_float_to_int16_array(number): + """Converts a 32 bit real number into an array of two 16 bit integers. The first 16 bit integer is the least + significant one, and the second is the most significant. The order of the 16 bit integers in the array is little + endian. + + Args: + number (float): The number to be converted. But the individual 16 bit ints are encoded in big endian. + + Returns: + int list: a list, with the first element being the least significant byte of the given number, and the second + element being the most significant byte. + """ + if type(number) != int and type(number) != float: + raise TypeError("number argument must always be a real number! {}".format(type(number))) + + raw_bytes_representation = float_to_raw_bytes(number, False) + + least_significant_byte = raw_bytes_to_int(raw_bytes_representation[2:4], low_bytes_first=False) + most_significant_byte = raw_bytes_to_int(raw_bytes_representation[:2], low_bytes_first=False) + + return [least_significant_byte, most_significant_byte] + + +class FinsResponseBuilder(object): + """Response builder which formats the responses as bytes. + """ + + def __init__(self): + self.response = bytearray() + + def add_int(self, value, length): + """Adds an integer to the builder. + + Args: + value (integer): The integer to add. + length (integer): How many bytes should the integer be represented as. + + Returns: + FinsResponseBuilder: The builder. + """ + self.response += int_to_raw_bytes(value, length, False) + return self + + def add_float(self, value): + """Adds an float to the builder (4 bytes, IEEE single-precision). + + Args: + value (double): The real number to add. + + Returns: + response_utilities.FinsResponseBuilder: The builder. + """ + self.response += float_to_raw_bytes(value, False) + return self + + def add_fins_frame_header( + self, + emulator_network_address, + emulator_unit_address, + client_network_address, + client_node, + client_unit_address, + service_id, + ): + """Makes a FINS frame header with the given data for a response to a client's command. + + The header bytes are as follows: + 1 byte (unsigned int): Information Control Field. It is always 0xC1 for a response. + 1 byte (unsigned int): Reserved byte. Always 0x00. + 1 byte (unsigned int): Gate count. Always 0x02 for our purposes. + 1 byte (unsigned int): Destination network address. For a response, it is the client's address. + 1 byte (unsigned int): Destination node address. For a response, it is the client's node. + 1 byte (unsigned int): Destination unit address. For a response, it is the client's unit. + 1 byte (unsigned int): Source network address. For a response, it is the emulator's address. + 1 byte (unsigned int): Source node address. For a response, it is the emulator's node. + 1 byte (unsigned int): Source unit address. For a response, it is the emulator's unit. + 1 byte (unsigned int): Service ID. It is a number showing what process generated the command sent by the + client. + + Args: + emulator_network_address (int): The FINS network address of the emulator. + emulator_unit_address (int): The FINS unit address of the emulator. + client_network_address (int): The FINS network address of the client. + client_node (int): The FINS node of the client. + client_unit_address (int): The FINS unit address of the client. + service_id (int): The service ID of the original command. + + Returns: + FinsResponseBuilder: The builder with the FINS frame header bytes. + """ + return ( + self.add_int(0xC1, 1) + .add_int(0x00, 1) + .add_int(0x02, 1) + .add_int(client_network_address, 1) + .add_int(client_node, 1) + .add_int(client_unit_address, 1) + .add_int(emulator_network_address, 1) + .add_int(SimulatedFinsPLC.HELIUM_RECOVERY_NODE, 1) + .add_int(emulator_unit_address, 1) + .add_int(service_id, 1) + ) + + def add_fins_command_and_error_codes(self): + """Adds the code for the FINS memory area read command and a default error code to the builder. + + Returns: + FinsResponseBuilder: The builder with the command and error codes now added. + """ + # The memory area read command code is 0101, and the 0000 is the No error code. + return self.add_int(0x0101, 2).add_int(0x0000, 2) + + def build(self): + """Gets the response from the builder. + + Returns: + FinsResponseBuilder: The response builder. + """ + return bytes(self.response) diff --git a/lewis/devices/fins/interfaces/stream_interface.py b/lewis/devices/fins/interfaces/stream_interface.py new file mode 100644 index 00000000..48cf5226 --- /dev/null +++ b/lewis/devices/fins/interfaces/stream_interface.py @@ -0,0 +1,201 @@ +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.byte_conversions import raw_bytes_to_int + +from ..device import SimulatedFinsPLC +from .response_utilities import check_is_byte, dm_memory_area_read_response_fins_frame + + +@has_log +class FinsPLCStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation. Match anything! + commands = { + Cmd("any_command", r"^([\s\S]*)$", return_mapping=lambda x: x), + } + + in_terminator = "" + out_terminator = b"" + + do_log = True + + def handle_error(self, request, error): + error_message = "An error occurred at request " + repr(request) + ": " + repr(error) + self.log.error(error_message) + print(error_message) + return str(error) + + def any_command(self, command): + """Handles all command sent to this emulator. It checks the validity of the command, and raises an error if it + finds something invalid. If the command is valid, then it returns a string representing the response to the + command. + + Args: + command (bytes): A string where every character represents a byte from the received FINS command frame. + + Returns: + bytes: a string where each character represents a byte from the FINS response frame. + """ + self._log_fins_frame(command, False) + + self._check_fins_frame_header_validity(command[:10]) + + # We extract information necessary for building the FINS response header + self.device.network_address = command[3] + self.device.unit_address = command[5] + + client_network_address = command[6] + client_node_address = command[7] + client_unit_address = command[8] + + service_id = command[9] + + if command[10] != 0x01 or command[11] != 0x01: + raise ValueError("The command code should be 0x0101, for memory area read command!") + + if command[12] != 0x82: + raise ValueError( + "The emulator only supports reading words from the DM area, for which the code is 82." + ) + + # the address of the starting word from where reading is done. Addresses are stored in two bytes. + memory_start_address = raw_bytes_to_int(command[13:15], low_bytes_first=False) + + # The FINS PLC supports reading either a certain number of words, or can also read individual bits in a word. + # The helium recovery memory map implies that that PLC uses word designated reading. When bit designated + # reading is not used, the 16th byte of the command is 0x00. + if command[15] != 0x00: + raise ValueError( + "The emulator only supports word designated memory reading. The bit address must " + "be 0x00." + ) + + number_of_words_to_read = raw_bytes_to_int(command[16:18], low_bytes_first=False) + + # The helium recovery PLC memory map has addresses that store types that take up either one word (16 bits) or + # two. Most take up one word, so if the number of words to read is two we check that the client wants to read + # from a memory location from where a 32 bit value starts. + if number_of_words_to_read == 2 and ( + memory_start_address not in self.device.int32_memory.keys() + and memory_start_address not in self.device.float_memory.keys() + ): + raise ValueError( + "The memory start address {} corresponds to a single word in the memory map, " + "not two.".format(memory_start_address) + ) + # The PLC also stores 32 bit floating point numbers, but the asyn device support makes the IOC ask for 4 bytes + # instead of two. + elif number_of_words_to_read > 2: + raise ValueError( + "The memory map only specifies data types for which commands should ask for one or two at most." + ) + is_float = True if memory_start_address in self.device.float_memory.keys() else False + self._log_command_contents( + client_network_address, + client_node_address, + client_unit_address, + service_id, + memory_start_address, + number_of_words_to_read, + ) + + reply = dm_memory_area_read_response_fins_frame( + self.device, + client_network_address, + client_node_address, + client_unit_address, + service_id, + memory_start_address, + number_of_words_to_read, + is_float, + ) + + self._log_fins_frame(reply, True) + + return reply + + def _log_fins_frame(self, fins_frame, is_reply): + """Nicely displays every byte in the command as a hexadecimal number in the emulator log. + + Args: + fins_frame (bytes): The fins frame we want to log. + is_reply (bool): Whether we want to log the reply or the command. + + Returns: + None. + """ + if self.do_log: + hex_command = [hex(character) for character in fins_frame] + + if not is_reply: + self.log.info("command is {}".format(hex_command)) + else: + self.log.info("reply is{}".format(hex_command)) + + def _log_command_contents( + self, + client_network_address, + client_node_address, + client_unit_address, + service_id, + memory_start_address, + number_of_words_to_read, + ): + """Nicely displays the bits of information in the FINS command that will be used for building the reply as numbers. + + Args: + client_network_address (int): The FINS network address of the client. + client_node_address (int): The FINS node of the client. + client_unit_address (int): The FINS unit address of the client. + service_id (int): The service ID of the original command. + memory_start_address (int): The memory address from where reading starts. + number_of_words_to_read (int): The number of words to be read, starting from the start address, inclusive. + + Returns: + None. + """ + if self.do_log: + self.log.info("Server network address: {}".format(self.device.network_address)) + self.log.info("Server Unit address: {}".format(self.device.unit_address)) + self.log.info("Client network address: {}".format(client_network_address)) + self.log.info("Client node address: {}".format(client_node_address)) + self.log.info("Client unit address: {}".format(client_unit_address)) + self.log.info("Service id: {}".format(service_id)) + self.log.info("Memory start address: {}".format(memory_start_address)) + self.log.info("Number of words to read: {}".format(number_of_words_to_read)) + + @staticmethod + def _check_fins_frame_header_validity(fins_frame_header): + """Checks that the FINS frame header part of the command is valid for a command sent from a client to a server + (PLC). + + Args: + fins_frame_header (bytes): A string where every character represents a byte from the received FINS frame + header. + + Returns: + None. + """ + # ICF means Information Control Field, it gives information about if the frame is for a command or a response, + # and if a response is needed or not. + icf = fins_frame_header[0] + if icf != 0x80: + raise ValueError("ICF value should always be 0x80 for a command sent to the emulator") + + # Reserved byte. Should always be 0x00 + if fins_frame_header[1] != 0x00: + raise ValueError("Reserved byte should always be 0x00.") + + if fins_frame_header[2] != 0x02: + raise ValueError("Gate count should always be 0x02.") + + check_is_byte(fins_frame_header[3]) + + if fins_frame_header[4] != SimulatedFinsPLC.HELIUM_RECOVERY_NODE: + raise ValueError( + "The node address of the FINS helium recovery PLC should be {}!".format( + SimulatedFinsPLC.HELIUM_RECOVERY_NODE + ) + ) + + for i in range(5, 10): + check_is_byte(fins_frame_header[i]) diff --git a/lewis/devices/fins/states.py b/lewis/devices/fins/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/fins/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/flipprps/__init__.py b/lewis/devices/flipprps/__init__.py new file mode 100644 index 00000000..0b758abd --- /dev/null +++ b/lewis/devices/flipprps/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedFlipprps + +__all__ = ["SimulatedFlipprps"] diff --git a/lewis/devices/flipprps/device.py b/lewis/devices/flipprps/device.py new file mode 100644 index 00000000..9dd57031 --- /dev/null +++ b/lewis/devices/flipprps/device.py @@ -0,0 +1,24 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedFlipprps(StateMachineDevice): + def _initialize_data(self): + DOWN = 0 + self.polarity = DOWN + self.id = "Flipper" + self.connected = True + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/flipprps/interfaces/__init__.py b/lewis/devices/flipprps/interfaces/__init__.py new file mode 100644 index 00000000..c0d6365e --- /dev/null +++ b/lewis/devices/flipprps/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import FlipprpsStreamInterface + +__all__ = ["FlipprpsStreamInterface"] diff --git a/lewis/devices/flipprps/interfaces/stream_interface.py b/lewis/devices/flipprps/interfaces/stream_interface.py new file mode 100644 index 00000000..0084c43f --- /dev/null +++ b/lewis/devices/flipprps/interfaces/stream_interface.py @@ -0,0 +1,33 @@ +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +class FlipprpsStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + Cmd("set_polarity_down", "^dn$"), + Cmd("set_polarity_up", "^up$"), + Cmd("get_id", "^id$"), + } + + in_terminator = "\r\n" + out_terminator = in_terminator + + @if_connected + def get_id(self): + return self._device.id + + @if_connected + def set_polarity_down(self): + self._device.polarity = 0 + return "OK" + + @if_connected + def set_polarity_up(self): + self._device.polarity = 1 + return "OK" + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) diff --git a/lewis/devices/flipprps/states.py b/lewis/devices/flipprps/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/flipprps/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/fzj_dd_fermi_chopper/__init__.py b/lewis/devices/fzj_dd_fermi_chopper/__init__.py new file mode 100644 index 00000000..c68a40df --- /dev/null +++ b/lewis/devices/fzj_dd_fermi_chopper/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedFZJDDFCH + +__all__ = ["SimulatedFZJDDFCH"] diff --git a/lewis/devices/fzj_dd_fermi_chopper/device.py b/lewis/devices/fzj_dd_fermi_chopper/device.py new file mode 100644 index 00000000..62167b0c --- /dev/null +++ b/lewis/devices/fzj_dd_fermi_chopper/device.py @@ -0,0 +1,75 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import StartedState, StoppedState + + +class SimulatedFZJDDFCH(StateMachineDevice): + """Simulated FZJ Digital Drive Fermi Chopper Controller. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.chopper_name = "C01" # only one chopper in this case + self.frequency_reference = 50 # reference frequency set to 50Hz to match actual device + self.frequency_setpoint = 0 + self.frequency = 0 + self.phase_setpoint = 0 + self.phase = 0 + self.phase_status_is_ok = False + self.magnetic_bearing_is_on = False + self.magnetic_bearing_status_is_ok = False + self.drive_is_on = False + self.drive_mode_is_start = False + self.drive_l1_current = 0 + self.drive_l2_current = 0 + self.drive_l3_current = 0 + self.drive_direction_is_cw = False + self.drive_temperature = 0 + self.phase_outage = 0 + self.master_chopper = "C1" + self.logging_is_on = False + self.dsp_status_is_ok = False + self.interlock_er_status_is_ok = False + self.interlock_vacuum_status_is_ok = False + self.interlock_frequency_monitoring_status_is_ok = False + self.interlock_magnetic_bearing_amplifier_temperature_status_is_ok = False + self.interlock_magnetic_bearing_amplifier_current_status_is_ok = False + self.interlock_drive_amplifier_temperature_status_is_ok = False + self.interlock_drive_amplifier_current_status_is_ok = False + self.interlock_ups_status_is_ok = False + + self.error_on_set_frequency = None + self.error_on_set_phase = None + self.error_on_set_magnetic_bearing = None + self.error_on_set_drive_mode = None + + self.connected = True + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {StartedState.NAME: StartedState(), StoppedState.NAME: StoppedState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return StoppedState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict( + [ + ((StoppedState.NAME, StartedState.NAME), lambda: self.drive_mode_is_start), + ((StartedState.NAME, StoppedState.NAME), lambda: not self.drive_mode_is_start), + ] + ) + + def reset(self): + """Reset device to defaults + :return: + """ + self._initialize_data() diff --git a/lewis/devices/fzj_dd_fermi_chopper/interfaces/__init__.py b/lewis/devices/fzj_dd_fermi_chopper/interfaces/__init__.py new file mode 100644 index 00000000..58294826 --- /dev/null +++ b/lewis/devices/fzj_dd_fermi_chopper/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import FZJDDFCHStreamInterface + +__all__ = ["FZJDDFCHStreamInterface"] diff --git a/lewis/devices/fzj_dd_fermi_chopper/interfaces/stream_interface.py b/lewis/devices/fzj_dd_fermi_chopper/interfaces/stream_interface.py new file mode 100644 index 00000000..fc390e3f --- /dev/null +++ b/lewis/devices/fzj_dd_fermi_chopper/interfaces/stream_interface.py @@ -0,0 +1,189 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +# Dictionaries for parameter states (strings required to build reply to "all status" command) +OK_NOK = {True: "OK", False: "NOK"} +ON_OFF = {True: "ON", False: "OFF"} +START_STOP = {True: "START", False: "STOP"} +CW_CCW = {True: "CLOCK", False: "ANTICLOCK"} + + +@has_log +class FZJDDFCHStreamInterface(StreamInterface): + """Stream interface for the Ethernet port + """ + + commands = { + CmdBuilder("get_magnetic_bearing_status").arg(".{3}").escape("?;MBON?").build(), + CmdBuilder("get_all_status").arg(".{3}").escape("?;ASTA?").build(), + CmdBuilder("set_frequency", arg_sep="").arg(".{3}").escape("!;FACT!;").int().build(), + CmdBuilder("set_phase", arg_sep="").arg(".{3}").escape("!;PHAS!;").float().build(), + CmdBuilder("set_magnetic_bearing", arg_sep="").arg(".{3}").escape("!;MAGB!;").any().build(), + CmdBuilder("set_drive_mode", arg_sep="").arg(".{3}").escape("!;DRIV!;").any().build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + """If command is not recognised, print and error + + Args: + request: requested string + error: problem + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @conditional_reply("connected") + def set_frequency(self, chopper_name, frequency): + """Sets the frequency setpoint by multiplying input value by reference frequency + + Args: + chopper_name: Chopper name (C01, C02, C2B, C03) + frequency: Frequency setpoint multiple (1, 2, 3, ... 12) + + Returns: OK or error + """ + if self._device.error_on_set_frequency is None: + self._device.frequency_setpoint = frequency * self._device.frequency_reference + reply = "{chopper_name}OK".format(chopper_name=chopper_name) + else: + reply = "ERROR;{}".format(self._device.error_on_set_frequency) + + self.log.info(reply) + return reply + + @conditional_reply("connected") + def set_phase(self, chopper_name, phase): + """Sets the phase setpoint + + Args: + chopper_name: Chopper name (C01, C02, C2B, C03) + phase: Phase setpoint (0.01 ... 359.99) + + Returns: OK or error + """ + if self._device.error_on_set_phase is None: + self._device.phase_setpoint = phase + reply = "{chopper_name}OK".format(chopper_name=chopper_name) + else: + reply = "ERROR;{}".format(self._device.error_on_set_phase) + + self.log.info(reply) + return reply + + @conditional_reply("connected") + def set_magnetic_bearing(self, chopper_name, magnetic_bearing): + """Sets the state of the magnetic bearings + + Args: + chopper_name: Chopper name (C01, C02, C2B, C03) + magnetic_bearing: boolean value to set magnetic bearings on or off ("ON", "OFF") + + Returns: OK or error + """ + if self._device.error_on_set_magnetic_bearing is None: + # Lookup the bool representation of the string + inverted_on_off_dict = {str_val: bool_val for (bool_val, str_val) in ON_OFF.items()} + self._device.magnetic_bearing_is_on = inverted_on_off_dict[magnetic_bearing] + reply = "{chopper_name}OK".format(chopper_name=chopper_name) + else: + reply = "ERROR;{}".format(self._device.error_on_set_magnetic_bearing) + + self.log.info(reply) + return reply + + @conditional_reply("connected") + def set_drive_mode(self, chopper_name, drive_mode): + """Sets the drive mode + + Args: + chopper_name: Chopper name (C01, C02, C2B, C03) + drive_mode: boolean value to set drive ("START", "STOP") + + Returns: OK or error + """ + if self._device.error_on_set_drive_mode is None: + # Lookup the bool representation of the string + inverted_start_stop_dict = { + str_val: bool_val for (bool_val, str_val) in START_STOP.items() + } + self._device.drive_mode_is_start = inverted_start_stop_dict[drive_mode] + reply = "{chopper_name}OK".format(chopper_name=chopper_name) + else: + reply = "ERROR;{}".format(self._device.error_on_set_drive_mode) + + self.log.info(reply) + return reply + + @conditional_reply("connected") + def get_magnetic_bearing_status(self, chopper_name): + """Gets the magnetic bearing status + + Args: + chopper_name: Chopper name (e.g. C01, C02, C2B, C03) + + Returns: magnetic bearing status + """ + device = self._device + return "{0:3s};MBON?;{}".format(device.chopper_name, ) + + @conditional_reply("connected") + def get_all_status(self, chopper_name): + """Gets the status as a single string + + Args: + chopper_name: Chopper name (e.g. C01, C02, C2B, C03) + + Returns: string containing values for all parameters + """ + device = self._device + if chopper_name != device.chopper_name: + return None + + values = [ + "{0:3s}".format(device.chopper_name), + "ASTA?", # device echoes command + "{0:3s}".format(device.chopper_name), + "{0:2d}".format( + device.frequency_setpoint // device.frequency_reference + ), # multiplier of reference frequency + "{0:.2f}".format(device.frequency_setpoint), + "{0:.2f}".format(device.frequency), + "{0:.1f}".format(device.phase_setpoint), + "{0:.1f}".format(device.phase), + "{0:s}".format(OK_NOK[device.phase_status_is_ok]), + "{0:s}".format(ON_OFF[device.magnetic_bearing_is_on]), + "{0:s}".format(OK_NOK[device.magnetic_bearing_status_is_ok]), + "{0:s}".format(ON_OFF[device.drive_is_on]), + "{0:s}".format(START_STOP[device.drive_mode_is_start]), + "{0:.2f}".format(device.drive_l1_current), + "{0:.2f}".format(device.drive_l2_current), + "{0:.2f}".format(device.drive_l3_current), + "{0:s}".format(CW_CCW[device.drive_direction_is_cw]), + "{0:.2f}".format(device.drive_temperature), + "{0:.2f}".format(device.phase_outage), + "{0:2s}".format(device.master_chopper), + "{0:s}".format(ON_OFF[device.logging_is_on]), + "{0:s}".format( + OK_NOK[False] + ), # Device always responds with "NOK" - constant defined in server code + "{0:s}".format(OK_NOK[device.dsp_status_is_ok]), + "{0:s}".format(OK_NOK[device.interlock_er_status_is_ok]), + "{0:s}".format(OK_NOK[device.interlock_vacuum_status_is_ok]), + "{0:s}".format(OK_NOK[device.interlock_frequency_monitoring_status_is_ok]), + "{0:s}".format( + OK_NOK[device.interlock_magnetic_bearing_amplifier_temperature_status_is_ok] + ), + "{0:s}".format( + OK_NOK[device.interlock_magnetic_bearing_amplifier_current_status_is_ok] + ), + "{0:s}".format(OK_NOK[device.interlock_drive_amplifier_temperature_status_is_ok]), + "{0:s}".format(OK_NOK[device.interlock_drive_amplifier_current_status_is_ok]), + "{0:s}".format(OK_NOK[device.interlock_ups_status_is_ok]), + ] + + status_string = ";".join(values) + return status_string diff --git a/lewis/devices/fzj_dd_fermi_chopper/states.py b/lewis/devices/fzj_dd_fermi_chopper/states.py new file mode 100644 index 00000000..c43b9892 --- /dev/null +++ b/lewis/devices/fzj_dd_fermi_chopper/states.py @@ -0,0 +1,28 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + +# FZJ Digital Drive Fermi Chopper Controller + + +class StartedState(State): + """Device is in started state. + """ + + NAME = "Started" + + def in_state(self, dt): + device = self._context + device.frequency = approaches.linear(device.frequency, device.frequency_setpoint, 1, dt) + device.phase = approaches.linear(device.phase, device.phase_setpoint, 1, dt) + + +class StoppedState(State): + """Device is in stopped state. + """ + + NAME = "Stopped" + + def in_state(self, dt): + device = self._context + device.frequency = approaches.linear(device.frequency, 0, 1, dt) + device.phase = approaches.linear(device.phase, 0, 1, dt) diff --git a/lewis/devices/gamry/__init__.py b/lewis/devices/gamry/__init__.py new file mode 100644 index 00000000..1317c07d --- /dev/null +++ b/lewis/devices/gamry/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedGamry + +__all__ = ["SimulatedGamry"] diff --git a/lewis/devices/gamry/device.py b/lewis/devices/gamry/device.py new file mode 100644 index 00000000..18db5e9c --- /dev/null +++ b/lewis/devices/gamry/device.py @@ -0,0 +1,8 @@ +from lewis.devices import Device + + +class SimulatedGamry(Device): + address = "a" + setpoint = 0.00 + units = "" + setpoint_mode = 1 diff --git a/lewis/devices/gamry/interfaces/__init__.py b/lewis/devices/gamry/interfaces/__init__.py new file mode 100644 index 00000000..0b0c65c6 --- /dev/null +++ b/lewis/devices/gamry/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import GamryStreamInterface + +__all__ = ["GamryStreamInterface"] diff --git a/lewis/devices/gamry/interfaces/stream_interface.py b/lewis/devices/gamry/interfaces/stream_interface.py new file mode 100644 index 00000000..342c12e7 --- /dev/null +++ b/lewis/devices/gamry/interfaces/stream_interface.py @@ -0,0 +1,39 @@ +from threading import Timer + +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log + +START_COMM = "START01" +BEAMON_COMM = "BEAMON" +BEAMOFF_COMM = "BEAMOFF" + + +@has_log +class GamryStreamInterface(StreamInterface): + commands = { + Cmd("start_charging", "^" + START_COMM + "$"), + Cmd("beam_on", "^" + BEAMON_COMM + "$"), + Cmd("beam_off", "^" + BEAMOFF_COMM + "$"), + } + + in_terminator = "\r" + out_terminator = "\r" + charging_time = 10.0 + + def beam_on(self): + return "BEAMON" + + def beam_off(self): + return "BEAMOFF" + + def charged(self): + self.handler.unsolicited_reply("STOPPED") + + def start_charging(self): + t = Timer(self.charging_time, self.charged) + t.start() + return "STARTED" + + def handle_error(self, request, error): + self.log.info("An error occurred at request " + repr(request) + ": " + repr(error)) + return "NAC" diff --git a/lewis/devices/group3hallprobe/__init__.py b/lewis/devices/group3hallprobe/__init__.py new file mode 100644 index 00000000..27d68994 --- /dev/null +++ b/lewis/devices/group3hallprobe/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedGroup3HallProbe + +__all__ = ["SimulatedGroup3HallProbe"] diff --git a/lewis/devices/group3hallprobe/device.py b/lewis/devices/group3hallprobe/device.py new file mode 100644 index 00000000..45e4a70e --- /dev/null +++ b/lewis/devices/group3hallprobe/device.py @@ -0,0 +1,91 @@ +from collections import OrderedDict +from enum import Enum +from typing import Callable, assert_never + +from lewis.devices import StateMachineDevice + +from .states import DefaultState, State + +GAUSS_PER_TESLA = 10_000.0 + + +class Ranges(Enum): + """ + Expresses a measurement range that a Probe can be in. + + Values and indices from device manual. + """ + + R0 = 0 # 0.3 Tesla range + R1 = 1 # 0.6 Tesla range + R2 = 2 # 1.2 Tesla range + R3 = 3 # 3.0 Tesla range + + +def range_to_max_gauss(r: Ranges) -> float: + match r: + case Ranges.R0: + return 0.3 * GAUSS_PER_TESLA + case Ranges.R1: + return 0.6 * GAUSS_PER_TESLA + case Ranges.R2: + return 1.2 * GAUSS_PER_TESLA + case Ranges.R3: + return 3.0 * GAUSS_PER_TESLA + + assert_never(r) + + +class Probe: + """ + A single probe. + """ + + def __init__(self) -> None: + self.field = 0.0 + self.temperature = 0.0 + self.sensor_range = Ranges.R3 + self.initialized = True + + def is_over_range(self) -> bool: + return abs(self.field) > range_to_max_gauss(self.sensor_range) + + def initialize(self) -> None: + self.sensor_range = Ranges.R3 + self.initialized = True + + +class SimulatedGroup3HallProbe(StateMachineDevice): + def _initialize_data(self) -> None: + """ + Initialize all of the device's attributes. + """ + self.connected = True + self.probes = { + 0: Probe(), + 1: Probe(), + 2: Probe(), + } + + def reset(self) -> None: + self._initialize_data() + + def backdoor_set_field(self, probe_id: int, field: float) -> None: + self.probes[probe_id].field = field + + def backdoor_set_temperature(self, probe_id: int, temperature: float) -> None: + self.probes[probe_id].temperature = temperature + + def backdoor_set_initialized(self, probe_id: int, initialized: bool) -> None: + self.probes[probe_id].initialized = initialized + + def _get_state_handlers(self) -> dict[str, State]: + return { + "default": DefaultState(), + } + + def _get_initial_state(self) -> str: + return "default" + + def _get_transition_handlers(self) -> dict[tuple[str, str], Callable[[], bool]]: + return OrderedDict([]) diff --git a/lewis/devices/group3hallprobe/interfaces/__init__.py b/lewis/devices/group3hallprobe/interfaces/__init__.py new file mode 100644 index 00000000..f0fff2af --- /dev/null +++ b/lewis/devices/group3hallprobe/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Group3HallProbeStreamInterface + +__all__ = ["Group3HallProbeStreamInterface"] diff --git a/lewis/devices/group3hallprobe/interfaces/stream_interface.py b/lewis/devices/group3hallprobe/interfaces/stream_interface.py new file mode 100644 index 00000000..4ef03588 --- /dev/null +++ b/lewis/devices/group3hallprobe/interfaces/stream_interface.py @@ -0,0 +1,66 @@ +import logging + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply +from lewis_emulators.group3hallprobe.device import Ranges, SimulatedGroup3HallProbe + +if_connected = conditional_reply("connected") + + +@has_log +class Group3HallProbeStreamInterface(StreamInterface): + in_terminator = "\n\r" # Yes, really LF-CR not CR-LF + out_terminator = "\n\r" + + def __init__(self) -> None: + super(Group3HallProbeStreamInterface, self).__init__() + + self.log: logging.Logger + self.device: SimulatedGroup3HallProbe + + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.initialize).escape("A").int().escape(" SE0GDR3GCNNUFG").eos().build(), + CmdBuilder(self.get_field).escape("A").int().escape(" F").eos().build(), + CmdBuilder(self.get_temperature).escape("A").int().escape(" T").eos().build(), + CmdBuilder(self.set_range).escape("A").int().escape(" R").int().eos().build(), + } + + def handle_error(self, request: str, error: str | Exception) -> None: + """ + If command is not recognised print and error + + Args: + request: requested string + error: problem + + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @if_connected + def initialize(self, probe_id: int) -> str: + self.device.probes[probe_id].initialize() + return f"A{probe_id} SE0GDR3GCNNUFG" + + @if_connected + def get_field(self, probe_id: int) -> str: + probe = self.device.probes[probe_id] + if not probe.initialized: + return f"A{probe_id} F\n\runinitialized_bad_data" + if probe.is_over_range(): + return f"A{probe_id} F\n\rOVER RANGE" + return f"A{probe_id} F\n\r{probe.field}" + + @if_connected + def get_temperature(self, probe_id: int) -> str: + probe = self.device.probes[probe_id] + if not probe.initialized: + return f"A{probe_id} T\n\runinitialized_bad_data" + return f"A{probe_id} T\n\r{probe.temperature}C" + + @if_connected + def set_range(self, probe_id: int, range_id: int) -> str: + self.device.probes[probe_id].sensor_range = Ranges(range_id) + return f"A{probe_id} R{range_id}" diff --git a/lewis/devices/group3hallprobe/states.py b/lewis/devices/group3hallprobe/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/group3hallprobe/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/heliox/__init__.py b/lewis/devices/heliox/__init__.py new file mode 100644 index 00000000..a0ae7e5b --- /dev/null +++ b/lewis/devices/heliox/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedHeliox + +__all__ = ["SimulatedHeliox"] diff --git a/lewis/devices/heliox/device.py b/lewis/devices/heliox/device.py new file mode 100644 index 00000000..73f83893 --- /dev/null +++ b/lewis/devices/heliox/device.py @@ -0,0 +1,79 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import He3PotEmptyState, TemperatureControlState + + +class TemperatureChannel(object): + """Class to represent an individual temperature channel on a Heliox fridge. e.g. He3Sorb or He4Pot channels. + """ + + def __init__(self): + self.temperature = 0 + self.temperature_sp = 0 + self.stable = True + self.heater_auto = True + self.heater_percent = 0 + + +@has_log +class SimulatedHeliox(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.temperature = 0 + self.temperature_sp = 0 + + self.temperature_stable = True + + self.temperature_channels = { + "HE3SORB": TemperatureChannel(), + "HE4POT": TemperatureChannel(), + "HELOW": TemperatureChannel(), + "HEHIGH": TemperatureChannel(), + } + + self.status = "Low Temp" + + self.connected = True + + self.helium_3_pot_empty = False + self.drift_towards = 1.5 # Drift to 1.5K ~= temperature of 1K pot. + self.drift_rate = 1 + + def reset(self): + self._initialize_data() + + def _get_state_handlers(self): + return { + "temperature_control": TemperatureControlState(), + "helium_3_empty": He3PotEmptyState(), + } + + def _get_initial_state(self): + return "temperature_control" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("temperature_control", "helium_3_empty"), lambda: self.helium_3_pot_empty), + (("helium_3_empty", "temperature_control"), lambda: not self.helium_3_pot_empty), + ] + ) + + def backdoor_set_channel_temperature(self, channel, temperature): + self.temperature_channels[channel].temperature = temperature + + def backdoor_set_channel_temperature_sp(self, channel, temperature_sp): + self.temperature_channels[channel].temperature_sp = temperature_sp + + def backdoor_set_channel_stability(self, channel, stability): + self.temperature_channels[channel].stable = stability + + def backdoor_set_channel_heater_auto(self, channel, heater_auto): + self.temperature_channels[channel].heater_auto = heater_auto + + def backdoor_set_channel_heater_percent(self, channel, percent): + self.temperature_channels[channel].heater_percent = percent diff --git a/lewis/devices/heliox/interfaces/__init__.py b/lewis/devices/heliox/interfaces/__init__.py new file mode 100644 index 00000000..70eeea66 --- /dev/null +++ b/lewis/devices/heliox/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import HelioxStreamInterface + +__all__ = ["HelioxStreamInterface"] diff --git a/lewis/devices/heliox/interfaces/stream_interface.py b/lewis/devices/heliox/interfaces/stream_interface.py new file mode 100644 index 00000000..f6521363 --- /dev/null +++ b/lewis/devices/heliox/interfaces/stream_interface.py @@ -0,0 +1,282 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + +ISOBUS_PREFIX = "@1" +PRIMARY_DEVICE_NAME = "HelioxX" + + +class HelioxStreamInterface(StreamInterface): + commands = { + CmdBuilder("get_catalog").optional(ISOBUS_PREFIX).escape("READ:SYS:CAT").eos().build(), + CmdBuilder("get_all_heliox_status") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .escape(PRIMARY_DEVICE_NAME) + .escape(":HEL") + .eos() + .build(), + CmdBuilder("get_heliox_temp") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .escape(PRIMARY_DEVICE_NAME) + .escape(":HEL:SIG:TEMP") + .eos() + .build(), + CmdBuilder("get_heliox_temp_sp_rbv") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .escape(PRIMARY_DEVICE_NAME) + .escape(":HEL:SIG:TSET") + .eos() + .build(), + CmdBuilder("get_heliox_stable") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .escape(PRIMARY_DEVICE_NAME) + .escape(":HEL:SIG:H3PS") + .eos() + .build(), + CmdBuilder("get_heliox_status") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .escape(PRIMARY_DEVICE_NAME) + .escape(":HEL:SIG:STAT") + .eos() + .build(), + CmdBuilder("set_heliox_setpoint") + .optional(ISOBUS_PREFIX) + .escape("SET:DEV:{}:HEL:SIG:TSET:".format(PRIMARY_DEVICE_NAME)) + .float() + .escape("K") + .eos() + .build(), + CmdBuilder("get_nickname") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .any() + .escape(":NICK") + .eos() + .build(), + CmdBuilder("get_channel_status") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .arg(r"[^:]*") + .escape(":TEMP") + .eos() + .build(), + CmdBuilder("get_channel_temp") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .arg(r"[^:]*") + .escape(":TEMP:SIG:TEMP") + .eos() + .build(), + CmdBuilder("get_channel_temp_sp") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .arg(r"[^:]*") + .escape(":TEMP:LOOP:TSET") + .eos() + .build(), + CmdBuilder("get_channel_heater_auto") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .arg(r"[^:]*") + .escape(":TEMP:LOOP:ENAB") + .eos() + .build(), + CmdBuilder("get_channel_heater_percentage") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:") + .arg(r"[^:]*") + .escape(":TEMP:LOOP:HSET") + .eos() + .build(), + CmdBuilder("get_he3_sorb_stable") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:{}:HEL:SIG:SRBS".format(PRIMARY_DEVICE_NAME)) + .eos() + .build(), + CmdBuilder("get_he4_pot_stable") + .optional(ISOBUS_PREFIX) + .escape("READ:DEV:{}:HEL:SIG:H4PS".format(PRIMARY_DEVICE_NAME)) + .eos() + .build(), + } + + in_terminator = "\n" + out_terminator = "\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + @if_connected + def get_all_heliox_status(self): + """This function is used by the labview VI. In EPICS it is more convenient to ask for the parameters individually, + so we don't use this large function which generates all of the possible status information. + """ + return ( + "STAT:DEV:{}".format(PRIMARY_DEVICE_NAME) + + ":HEL:LOWT:2.5000K" + ":BT:0.0000K" + ":NVCN:17.000mB" + ":RCTD:10.000K" + ":RCST:20.000K" + ":NVHT:17.000mB" + ":RCTE:10.000K" + ":SRBH:15.000K" + ":PE:3.5000K" + ":RGNA:1.0000K" + ":PCT:2.0000K" + ":SIG:H4PS:{}".format( + "Stable" if self.device.temperature_channels["HE4POT"].stable else "Unstable" + ) + + ":STAT:{}".format(self.device.status) + + ":TEMP:{:.4f}K".format(self.device.temperature) + + ":TSET:{:.4f}K".format(self.device.temperature_sp) + + ":H3PS:{}".format("Stable" if self.device.temperature_stable else "Unstable") + + ":SRBS:{}".format( + "Stable" if self.device.temperature_channels["HE3SORB"].stable else "Unstable" + ) + + ":SRBR:32.000K" + ":SCT:3.0000K" + ":NVLT:10.000mB" + ) + + @if_connected + def get_catalog(self): + """This is only needed by the LabVIEW driver - it is not used by EPICS. + """ + return ( + "STAT:SYS:CAT" + ":DEV:HelioxX:HEL" + ":DEV:He3Sorb:TEMP" + ":DEV:He4Pot:TEMP" + ":DEV:HeLow:TEMP" + ":DEV:HeHigh:TEMP" + ) + + @if_connected + def get_nickname(self, arg): + """Returns a fake nickname. This is only implemented to allow this emulator to be used with the existing + labview driver, and the labview driver actually ignores the results (but not implementing the function causes + an error). + """ + return "STAT:DEV:{}:NICK:{}".format(arg, "FAKENICKNAME") + + @if_connected + def set_heliox_setpoint(self, new_setpoint): + self.device.temperature_sp = new_setpoint + return "STAT:SET:DEV:HelioxX:HEL:SIG:TSET:{:.4f}K:VALID".format(new_setpoint) + + @if_connected + def get_heliox_temp(self): + return "STAT:DEV:HelioxX:HEL:SIG:TEMP:{:.4f}K".format(self.device.temperature) + + @if_connected + def get_heliox_temp_sp_rbv(self): + return "STAT:DEV:HelioxX:HEL:SIG:TSET:{:.4f}K".format(self.device.temperature_sp) + + @if_connected + def get_heliox_stable(self): + return "STAT:DEV:{}:HEL:SIG:H3PS:{}".format( + PRIMARY_DEVICE_NAME, "Stable" if self.device.temperature_stable else "Unstable" + ) + + @if_connected + def get_heliox_status(self): + return "STAT:DEV:{}:HEL:SIG:STAT:{}".format(PRIMARY_DEVICE_NAME, self.device.status) + + @if_connected + def get_channel_status(self, channel): + temperature_channel = self.device.temperature_channels[channel.upper()] + return ( + "STAT:DEV:{name}:TEMP" + ":EXCT:TYPE:UNIP:MAG:0" + ":STAT:40000000" + ":NICK:MB1.T1" + ":LOOP:AUX:None" + ":D:1.0" + ":HTR:None" + ":I:1.0" + ":THTF:None" + ":HSET:{heater_percent:.4f}" + ":PIDT:OFF" + ":ENAB:{heater_auto}" + ":SWFL:None" + ":FAUT:OFF" + ":FSET:0" + ":PIDF:None" + ":P:1.0" + ":SWMD:FIX" + ":TSET:{tset:.4f}K" + ":MAN:HVER:1" + ":FVER:1.12" + ":SERL:111450078" + ":CAL:OFFS:0" + ":COLDL:999.00K" + ":INT:LIN:SCAL:1" + ":FILE:None" + ":HOTL:999.00K" + ":TYPE:TCE" + ":SIG:VOLT:-0.0038mV" + ":CURR:-0.0000A" + ":TEMP:{temp:.4f}K" + ":POWR:0.0000W" + ":RES:0.0000O" + ":SLOP:0.0000O/K".format( + name=channel, + tset=temperature_channel.temperature_sp, + temp=temperature_channel.temperature, + heater_auto="ON" if temperature_channel.heater_auto else "OFF", + heater_percent=temperature_channel.heater_percent, + ) + ) + + @if_connected + def get_channel_temp(self, chan): + return "STAT:DEV:{}:TEMP:SIG:TEMP:{:.4f}K".format( + chan, self.device.temperature_channels[chan.upper()].temperature + ) + + @if_connected + def get_channel_temp_sp(self, chan): + return "STAT:DEV:{}:TEMP:LOOP:TSET:{:.4f}K".format( + chan, self.device.temperature_channels[chan.upper()].temperature_sp + ) + + @if_connected + def get_channel_heater_auto(self, chan): + return "STAT:DEV:{}:TEMP:LOOP:ENAB:{}".format( + chan, "ON" if self.device.temperature_channels[chan.upper()].heater_auto else "OFF" + ) + + @if_connected + def get_channel_heater_percentage(self, chan): + return "STAT:DEV:{}:TEMP:LOOP:HSET:{:.4f}".format( + chan, self.device.temperature_channels[chan.upper()].heater_percent + ) + + # Individual channel stabilities + + @if_connected + def get_he3_sorb_stable(self): + return "STAT:DEV:{}:HEL:SIG:SRBS:{}".format( + PRIMARY_DEVICE_NAME, + "Stable" if self.device.temperature_channels["HE3SORB"].stable else "Unstable", + ) + + @if_connected + def get_he4_pot_stable(self): + return "STAT:DEV:{}:HEL:SIG:H4PS:{}".format( + PRIMARY_DEVICE_NAME, + "Stable" if self.device.temperature_channels["HE4POT"].stable else "Unstable", + ) diff --git a/lewis/devices/heliox/states.py b/lewis/devices/heliox/states.py new file mode 100644 index 00000000..76d204e6 --- /dev/null +++ b/lewis/devices/heliox/states.py @@ -0,0 +1,22 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class TemperatureControlState(State): + def in_state(self, dt): + device = self._context + + rate = 10 + + device.temperature = approaches.linear(device.temperature, device.temperature_sp, rate, dt) + + +class He3PotEmptyState(State): + DRIFT_TOWARDS = 1.5 # When the 3He pot is empty, this is the temperature it will drift towards + + def in_state(self, dt): + device = self._context + + device.temperature = approaches.linear( + device.temperature, device.drift_towards, device.drift_rate, dt + ) diff --git a/lewis/devices/hlg/__init__.py b/lewis/devices/hlg/__init__.py new file mode 100644 index 00000000..4d20cc40 --- /dev/null +++ b/lewis/devices/hlg/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedHgl + +__all__ = ["SimulatedHgl"] diff --git a/lewis/devices/hlg/device.py b/lewis/devices/hlg/device.py new file mode 100644 index 00000000..70467f35 --- /dev/null +++ b/lewis/devices/hlg/device.py @@ -0,0 +1,32 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedHgl(StateMachineDevice): + """Simulated AM Int2-L pressure transducer. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.level = 2.0 + self.verbosity = 0 + self.prefix = 1 + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() diff --git a/lewis/devices/hlg/interfaces/__init__.py b/lewis/devices/hlg/interfaces/__init__.py new file mode 100644 index 00000000..4080c78e --- /dev/null +++ b/lewis/devices/hlg/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import HlgStreamInterface + +__all__ = ["HlgStreamInterface"] diff --git a/lewis/devices/hlg/interfaces/stream_interface.py b/lewis/devices/hlg/interfaces/stream_interface.py new file mode 100644 index 00000000..4f5c1231 --- /dev/null +++ b/lewis/devices/hlg/interfaces/stream_interface.py @@ -0,0 +1,100 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +PREFIXES = [ + "", # 0 + "\r\n", # 1 + "\r\n ", # 2 + "\r\n01:23:45 ", # 3 + "\r\n -------> ", # 4 + ", ", # 5 +] + + +@has_log +class HlgStreamInterface(StreamInterface): + """Stream interface for the serial port + """ + + commands = { + CmdBuilder("get_level").escape("PM").build(), + CmdBuilder("set_verbosity").escape("CV").int().build(), + CmdBuilder("set_prefix").escape("CP").int().build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + """If command is not recognised print and error + + Args: + request: requested string + error: problem + + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + def set_verbosity(self, verbosity): + """Set the verbosity of the output from the device + + Args: + verbosity: 0 normal, 1 labview style (more verbose) + + Returns: confirmation message + + """ + if verbosity not in [0, 1]: + raise AssertionError("Verbosity must be 0 or 1 was '{}'".format(verbosity)) + self._device.verbosity = verbosity + if verbosity == 0: + out_verbose = "Normal" + else: + out_verbose = "labVIEW" + return self._format_output("CV{0}".format(verbosity), "Verbose=", out_verbose) + + def set_prefix(self, prefix): + """Set the prefix the device returns + Args: + prefix: prefix id 0-5 see PREFIXES for details + + Returns: confirmation message + + """ + if not 0 <= prefix < len(PREFIXES): + raise AssertionError( + "Prefix must be between 0 and {1} '{0}'".format(prefix, len(PREFIXES)) + ) + self._device.prefix = prefix + return self._format_output("CP{0}".format(prefix), "Verbose=", str(prefix)) + + def get_level(self): + """Gets the current level + + Returns: level in correct units or None if no level is set + + """ + if self._device.level is None: + return None + else: + return self._format_output( + "PM", "Probe value=", "{level:.3f} mm".format(level=self._device.level) + ) + + def _format_output(self, echo, verbose_prefix, data): + """Format the output of a command depending on verbosity and prefix settings of device + Args: + echo: string to echo back to user + verbose_prefix: prefix for value in normal verbose mode + data: data to output + + Returns: formatted output from command + + """ + output_string = echo + output_string += PREFIXES[self._device.prefix] + if self._device.verbosity == 0: + output_string += verbose_prefix + output_string += data + return output_string diff --git a/lewis/devices/hlg/states.py b/lewis/devices/hlg/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/hlg/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/hlx503/__init__.py b/lewis/devices/hlx503/__init__.py new file mode 100644 index 00000000..c46ab651 --- /dev/null +++ b/lewis/devices/hlx503/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedItc503 + +__all__ = ["SimulatedItc503"] diff --git a/lewis/devices/hlx503/device.py b/lewis/devices/hlx503/device.py new file mode 100644 index 00000000..0f3376c0 --- /dev/null +++ b/lewis/devices/hlx503/device.py @@ -0,0 +1,106 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import He3PotEmptyState, RegeneratingState, TemperatureControlState + + +@has_log +class SimulatedItc503(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.control_channel = 1 + self.p, self.i, self.d = 0, 0, 0 + self.control = 0 + self.autopid = False + self.sweeping = False + self.temperature_sp = 0 + self.autoheat = False + self.heater_voltage = 0 + self.he3pot_low_plugged_in = True + self.sorb_temp = 1.5 + self.he3pot_temp = 0 + self.onekpot_temp = 1.5 + + self.helium_3_pot_empty = False + self.drift_towards = 1.5 # Drift to 1.5K ~= temperature of 1K pot. + self.drift_rate = 1 + + # Set by tests, affects the response format of the device. Slightly different models of ITC will respond + # differently + self.report_sweep_state_with_leading_zero = False + + def _get_state_handlers(self): + return { + "temperature_control": TemperatureControlState(), + "helium_3_empty": He3PotEmptyState(), + "regenerating": RegeneratingState(), + } + + def _get_initial_state(self): + return "temperature_control" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("temperature_control", "helium_3_empty"), lambda: self.helium_3_pot_empty), + ( + ("helium_3_empty", "regenerating"), + lambda: self.control_channel == 1 and self.temperature_sp >= 30, + ), + ( + ("temperature_control", "regenerating"), + lambda: self.control_channel == 1 and self.temperature_sp >= 30, + ), + ( + ("regenerating", "temperature_control"), + lambda: self.sorb_temp >= 30 and not self.helium_3_pot_empty, + ), + ] + ) + + @property + def temperature_1(self): + return self.sorb_temp + + @property + def temperature_2(self): + if self.he3pot_low_plugged_in: + return self.he3pot_temp + else: + return self.onekpot_temp + + @property + def temperature_3(self): + return self.he3pot_temp + + @property + def temperature(self): + if self.control_channel == 1: + return self.temperature_1 + elif self.control_channel == 2: + return self.temperature_2 + elif self.control_channel == 3: + return self.temperature_3 + else: + raise ValueError("Control channel incorrect") + + @property + def mode(self): + return int(self.autoheat) + + @mode.setter + def mode(self, new_mode): + self.autoheat = new_mode % 2 != 0 + + @property + def heater_percent(self): + return self.heater_voltage + + def backdoor_plug_in_onekpot(self): + self.he3pot_low_plugged_in = False + + def backdoor_plug_in_he3potlow(self): + self.he3pot_low_plugged_in = True diff --git a/lewis/devices/hlx503/interfaces/__init__.py b/lewis/devices/hlx503/interfaces/__init__.py new file mode 100644 index 00000000..2db67108 --- /dev/null +++ b/lewis/devices/hlx503/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Itc503StreamInterface + +__all__ = ["Itc503StreamInterface"] diff --git a/lewis/devices/hlx503/interfaces/stream_interface.py b/lewis/devices/hlx503/interfaces/stream_interface.py new file mode 100644 index 00000000..a3d0d8c6 --- /dev/null +++ b/lewis/devices/hlx503/interfaces/stream_interface.py @@ -0,0 +1,131 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + + +class Itc503StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("set_p").escape("P").float().eos().build(), + CmdBuilder("set_i").escape("I").float().eos().build(), + CmdBuilder("set_d").escape("D").float().eos().build(), + CmdBuilder("get_p").escape("R8").eos().build(), + CmdBuilder("get_i").escape("R9").eos().build(), + CmdBuilder("get_d").escape("R10").eos().build(), + CmdBuilder("get_temp_1").escape("R1").eos().build(), + CmdBuilder("get_temp_2").escape("R2").eos().build(), + CmdBuilder("get_temp_3").escape("R3").eos().build(), + CmdBuilder("get_temp_sp").escape("R0").eos().build(), + CmdBuilder("set_temp").escape("T").float().eos().build(), + CmdBuilder("get_status").escape("X").eos().build(), + CmdBuilder("set_ctrl").escape("C").int().eos().build(), + CmdBuilder("set_mode").escape("A").int().eos().build(), + CmdBuilder("set_ctrl_chan").escape("H").int().eos().build(), + CmdBuilder("set_autopid_on").escape("L1").eos().build(), + CmdBuilder("set_autopid_off").escape("L0").eos().build(), + CmdBuilder("set_heater_maxv").escape("M").float().eos().build(), + # No readback for max heater output + CmdBuilder("set_heater_voltage").escape("O").float().eos().build(), + CmdBuilder("get_heater_voltage").escape("R6").eos().build(), + CmdBuilder("get_heater_percent").escape("R5").eos().build(), + CmdBuilder("get_temp_error").escape("R4").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def set_p(self, p): + self.device.p = float(p) + return "P" + + def get_p(self): + return "R{:.1f}".format(self.device.p) + + def set_i(self, i): + self.device.i = float(i) + return "I" + + def get_i(self): + return "R{:.1f}".format(self.device.i) + + def set_d(self, d): + self.device.d = float(d) + return "D" + + def get_d(self): + return "R{:.1f}".format(self.device.d) + + def get_temp_1(self): + return "R{:.1f}".format(self.device.temperature_1) + + def get_temp_2(self): + return "R{:.1f}".format(self.device.temperature_2) + + def get_temp_3(self): + return "R{:.1f}".format(self.device.temperature_3) + + def set_temp(self, temp): + self.device.temperature_sp = float(temp) + return "T" + + def get_temp_sp(self): + return "R{:.1f}".format(self.device.temperature_sp) + + def get_status(self): + if self.device.report_sweep_state_with_leading_zero: + format_string = "X0A{mode}C{ctrl}S{sweeping:02d}H{control_channel}L{autopid}" + else: + format_string = "X0A{mode}C{ctrl}S{sweeping:01d}H{control_channel}L{autopid}" + + return format_string.format( + mode=self.device.mode, + ctrl=self.device.control, + sweeping=1 if self.device.sweeping else 0, + control_channel=self.device.control_channel, + autopid=1 if self.device.autopid else 0, + ) + + def set_ctrl(self, ctrl): + self.device.control = int(ctrl) + return "C" + + def set_mode(self, mode): + self.device.mode = int(mode) + return "A" + + def set_ctrl_chan(self, chan): + if not 1 <= int(chan) <= 3: + raise ValueError("Invalid channel") + self.device.control_channel = int(chan) + return "H" + + def set_autopid_on(self): + self.device.autopid = True + return "L" + + def set_autopid_off(self): + self.device.autopid = False + return "L" + + def set_heater_voltage(self, manv): + self.device.heater_voltage = float(manv) + return "O" + + def get_heater_voltage(self): + return "R{:.1f}".format(self.device.heater_voltage) + + def get_heater_percent(self): + return "R{:.1f}".format(self.device.heater_percent) + + def set_heater_maxv(self, volts): + raise ValueError("At ISIS, do not use this command!") + + def get_temp_error(self): + return "R{:.1f}".format(abs(self.device.temperature_sp - self.device.temperature)) diff --git a/lewis/devices/hlx503/states.py b/lewis/devices/hlx503/states.py new file mode 100644 index 00000000..0dfc66fc --- /dev/null +++ b/lewis/devices/hlx503/states.py @@ -0,0 +1,32 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class TemperatureControlState(State): + def in_state(self, dt): + device = self._context + + rate = 10 + + device.he3pot_temp = approaches.linear(device.he3pot_temp, device.temperature_sp, rate, dt) + + +class He3PotEmptyState(State): + def in_state(self, dt): + device = self._context + + device.he3pot_temp = approaches.linear( + device.he3pot_temp, device.drift_towards, device.drift_rate, dt + ) + + +class RegeneratingState(State): + def in_state(self, dt): + device = self._context + + device.sorb_temp = approaches.linear( + device.sorb_temp, device.temperature_sp, device.heater_voltage, dt + ) + device.he3pot_temp = approaches.linear( + device.he3pot_temp, device.drift_towards, device.drift_rate, dt + ) diff --git a/lewis/devices/ieg/__init__.py b/lewis/devices/ieg/__init__.py new file mode 100644 index 00000000..d3a47c29 --- /dev/null +++ b/lewis/devices/ieg/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedIeg + +__all__ = ["SimulatedIeg"] diff --git a/lewis/devices/ieg/device.py b/lewis/devices/ieg/device.py new file mode 100644 index 00000000..7c7f2e9e --- /dev/null +++ b/lewis/devices/ieg/device.py @@ -0,0 +1,70 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedIeg(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.unique_id = 123 + + self.gas_valve_open = False + self.buffer_valve_open = False + self.pump_valve_open = False + + self.operatingmode = 0 + + self.sample_pressure_high_limit = 100 + self.sample_pressure_low_limit = 10 + self.sample_pressure = 0 + + self.error = 0 + + self.buffer_pressure_high = True + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def is_sample_pressure_high(self): + return self.sample_pressure > self.sample_pressure_high_limit + + def is_sample_pressure_low(self): + return self.sample_pressure < self.sample_pressure_low_limit + + def get_id(self): + return self.unique_id + + def get_pressure(self): + return self.sample_pressure + + def get_error(self): + return self.error + + def is_pump_valve_open(self): + return self.pump_valve_open + + def is_buffer_valve_open(self): + return self.buffer_valve_open + + def is_gas_valve_open(self): + return self.gas_valve_open + + def get_operating_mode(self): + return self.operatingmode + + def is_buffer_pressure_high(self): + return self.buffer_pressure_high + + def set_operating_mode(self, mode): + self.operatingmode = mode diff --git a/lewis/devices/ieg/interfaces/__init__.py b/lewis/devices/ieg/interfaces/__init__.py new file mode 100644 index 00000000..3472ad44 --- /dev/null +++ b/lewis/devices/ieg/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import IegStreamInterface + +__all__ = ["IegStreamInterface"] diff --git a/lewis/devices/ieg/interfaces/stream_interface.py b/lewis/devices/ieg/interfaces/stream_interface.py new file mode 100644 index 00000000..a732917d --- /dev/null +++ b/lewis/devices/ieg/interfaces/stream_interface.py @@ -0,0 +1,110 @@ +from lewis.adapters.stream import Cmd, StreamInterface + + +class IegStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + Cmd("get_status", "^&STS0$"), + Cmd("change_operating_mode", "^&OPM([1-4])$"), + Cmd("abort", "^&KILL$"), + } + + in_terminator = "!" + + # Out terminator is defined in ResponseBuilder instead as we need to add it to two messages. + out_terminator = "" + + def _build_valve_state(self): + val = 0 + val += 1 if self._device.is_pump_valve_open() else 0 + val += 2 if self._device.is_buffer_valve_open() else 0 + val += 4 if self._device.is_gas_valve_open() else 0 + return val + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + def get_status(self): + return ( + ResponseBuilder() + .add_data_block("IEG", self._device.get_id()) + .add_data_block("OPM", self._device.get_operating_mode()) + .add_data_block("VST", self._build_valve_state()) + .add_data_block("ERR", self._device.get_error()) + .add_data_block("BPH", 0 if self._device.is_buffer_pressure_high() else 1) + .add_data_block("SPL", 1 if self._device.is_sample_pressure_low() else 0) + .add_data_block("SPH", 1 if self._device.is_sample_pressure_high() else 0) + .add_data_block("SPR", int(self._device.get_pressure())) + .build() + ) + + def change_operating_mode(self, mode): + self._device.set_operating_mode(int(mode)) + return ( + ResponseBuilder() + .add_data_block("IEG", self._device.get_id()) + .add_data_block("OPM", self._device.get_operating_mode()) + .build() + ) + + def abort(self): + self._device.operatingmode = 0 + return ( + ResponseBuilder() + .add_data_block("IEG", self._device.get_id()) + .add_data_block("KILL") + .build() + ) + + +class ResponseBuilder(object): + """Response builder for the IEG. + + Outputs: + - An ACK packet before the response, properly terminated. + - A "start of data block" character + - Any number of data blocks added by add_data_block() + - An "end of data block" character + """ + + packet_start = "&" + packet_end = "!\r\n" + data_block_sep = "," + + def __init__(self): + """Initialize a new response. + """ + self.response = "{pack_start}ACK{pack_end}{pack_start}".format( + pack_start=self.packet_start, pack_end=self.packet_end + ) + + # Not yet in a valid state - set to true once at least one data block is added + self.valid = False + + def add_data_block(self, *data): + """Adds a data block. + The elements are converted to strings and added to the response in order. + If the preceding character is not already a separator nor the start of the data block a separator is added first + :param data: data to add to the response + :return: ResponseBuilder + """ + if ( + not self.response[-1:] == self.data_block_sep + and not self.response[-1:] == self.packet_start + ): + self.response += self.data_block_sep + + for item in data: + self.response += "{}".format(item) + + # At least one data block has now been added so this is a valid message + self.valid = True + return self + + def build(self): + """Extract the response from the builder + :return: (str) response + """ + assert self.valid, "At least one data block must be added before calling build" + return "{response}{packet_end}".format(response=self.response, packet_end=self.packet_end) diff --git a/lewis/devices/ieg/states.py b/lewis/devices/ieg/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/ieg/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/indfurn/__init__.py b/lewis/devices/indfurn/__init__.py new file mode 100644 index 00000000..926e72aa --- /dev/null +++ b/lewis/devices/indfurn/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedIndfurn + +__all__ = ["SimulatedIndfurn"] diff --git a/lewis/devices/indfurn/device.py b/lewis/devices/indfurn/device.py new file mode 100644 index 00000000..bb15edd8 --- /dev/null +++ b/lewis/devices/indfurn/device.py @@ -0,0 +1,61 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SampleHolderMaterials(object): + ALUMINIUM = 0 + GLASSY_CARBON = 1 + GRAPHITE = 2 + QUARTZ = 3 + SINGLE_CRYSTAL_SAPPHIRE = 4 + STEEL = 5 + VANADIUM = 6 + + +class SimulatedIndfurn(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.setpoint = 20 + self.pipe_temperature = 25.1 + self.capacitor_bank_temperature = 30.3 + self.fet_temperature = 35.8 + + self.p, self.i, self.d = 0, 0, 0 + self.sample_time = 100 + + self.direction_heating = True + + self.pid_lower_limit, self.pid_upper_limit = 0, 0 + + self.pid_mode_automatic = True + self.running = True + + self.psu_voltage, self.psu_current, self.output = 0, 0, 0 + + self.remote_mode = True + self.power_supply_on = True + self.sample_area_led_on = True + self.hf_on = False + + self.psu_overtemp, self.psu_overvolt = False, False + self.cooling_water_flow = 100 + + self.sample_holder_material = SampleHolderMaterials.ALUMINIUM + + self.thermocouple_1_fault, self.thermocouple_2_fault = 0, 0 + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def is_cooling_water_flow_ok(self): + return self.cooling_water_flow >= 100 diff --git a/lewis/devices/indfurn/interfaces/__init__.py b/lewis/devices/indfurn/interfaces/__init__.py new file mode 100644 index 00000000..af7f8e1c --- /dev/null +++ b/lewis/devices/indfurn/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import IndfurnStreamInterface + +__all__ = ["IndfurnStreamInterface"] diff --git a/lewis/devices/indfurn/interfaces/stream_interface.py b/lewis/devices/indfurn/interfaces/stream_interface.py new file mode 100644 index 00000000..970fd605 --- /dev/null +++ b/lewis/devices/indfurn/interfaces/stream_interface.py @@ -0,0 +1,280 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from lewis_emulators.indfurn.device import SampleHolderMaterials + +SAMPLE_HOLDER_MATERIALS = { + "aluminium": SampleHolderMaterials.ALUMINIUM, + "glassy_carbon": SampleHolderMaterials.GLASSY_CARBON, + "graphite": SampleHolderMaterials.GRAPHITE, + "quartz": SampleHolderMaterials.QUARTZ, + "single_crystal_sapphire": SampleHolderMaterials.SINGLE_CRYSTAL_SAPPHIRE, + "steel": SampleHolderMaterials.STEEL, + "vanadium": SampleHolderMaterials.VANADIUM, +} + + +@has_log +class IndfurnStreamInterface(StreamInterface): + commands = { + # ID + CmdBuilder("get_version").escape("?ver").eos().build(), + CmdBuilder("get_setpoint").escape("?pidSP").eos().build(), + CmdBuilder("set_setpoint").escape(">pidSP ").float().eos().build(), + CmdBuilder("get_psu_voltage").escape("?powV").eos().build(), + CmdBuilder("set_psu_voltage").escape(">powV ").float().eos().build(), + CmdBuilder("get_psu_current").escape("?powI").eos().build(), + CmdBuilder("set_psu_current").escape(">powI ").float().eos().build(), + CmdBuilder("get_output").escape("?pidOUTM").eos().build(), + CmdBuilder("set_output").escape(">pidOUTM ").float().eos().build(), + CmdBuilder("get_thermocouple_temperature").escape("?tempTC").eos().build(), + CmdBuilder("get_thermocouple2_temperature").escape("?tmpTC2").eos().build(), + CmdBuilder("get_pipe_temperature").escape("?tempP").eos().build(), + CmdBuilder("get_capacitor_bank_temperature").escape("?tempC").eos().build(), + CmdBuilder("get_fet_temperature").escape("?tempS").eos().build(), + CmdBuilder("get_pid_params").escape("?pidTu").eos().build(), + CmdBuilder("set_pid_params") + .escape(">pidTu ") + .float() + .escape(" ") + .float() + .escape(" ") + .float() + .eos() + .build(), + CmdBuilder("get_sample_time").escape("?pidSt").eos().build(), + CmdBuilder("set_sample_time").escape(">pidSt ").int().eos().build(), + CmdBuilder("get_psu_direction").escape("?pidDir").eos().build(), + CmdBuilder("set_psu_direction").escape(">pidDir ").any().eos().build(), + CmdBuilder("get_pid_mode").escape("?pidMODE").eos().build(), + CmdBuilder("set_pid_mode").escape(">pidMODE ").char().eos().build(), + CmdBuilder("set_psu_remote").escape(">powR").eos().build(), + CmdBuilder("set_psu_local").escape(">powL").eos().build(), + CmdBuilder("get_psu_control_mode").escape("?powRL").eos().build(), + CmdBuilder("set_psu_on").escape(">powON").eos().build(), + CmdBuilder("set_psu_off").escape(">powOFF").eos().build(), + CmdBuilder("get_psu_power").escape("?powOnOff").eos().build(), + CmdBuilder("set_led_on").escape(">ledON").eos().build(), + CmdBuilder("set_led_off").escape(">ledOFF").eos().build(), + CmdBuilder("get_led").escape("?ledOnOff").eos().build(), + CmdBuilder("set_hf_on").escape(">oscON").eos().build(), + CmdBuilder("set_hf_off").escape(">oscOFF").eos().build(), + CmdBuilder("get_hf_power").escape("?oscOnOff").eos().build(), + CmdBuilder("get_pid_limits").escape("?pidOUTL").eos().build(), + CmdBuilder("set_pid_limits").escape(">pidOUTL ").float().escape(" ").float().eos().build(), + CmdBuilder("get_psu_overtemp").escape("?alarmh").eos().build(), + CmdBuilder("get_psu_overvolt").escape("?alarmv").eos().build(), + CmdBuilder("get_cooling_water_flow_status").escape("?flowSt").eos().build(), + CmdBuilder("get_cooling_water_flow").escape("?flowCw").eos().build(), + CmdBuilder("reset_alarms").escape(">ackAlarm").eos().build(), + CmdBuilder("set_runmode_on").escape(">pidRUN").eos().build(), + CmdBuilder("set_runmode_off").escape(">pidSTP").eos().build(), + CmdBuilder("get_runmode").escape("?pidRUN").eos().build(), + CmdBuilder("get_sample_holder_material").escape("?sHold").eos().build(), + CmdBuilder("set_sample_holder_material").escape(">sHold ").string().eos().build(), + CmdBuilder("get_tc_fault").escape("?faultTC").eos().build(), + CmdBuilder("get_tc2_fault").escape("?fltTC2").eos().build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return "=. The counter won't trip if it already exceeds max_counts + if self.counts == self.max_counts: + self.state = QCEDStates.TRIPPED + + def off(self): + self.state = QCEDStates.OFF + # When the QCED is turned off it does not automatically reset it's count + # So don't do self.counts = 0 here. + + def cycles(self, fractional=True): + if fractional: + return self.counts / 4.0 + else: + return self.counts // 4 diff --git a/lewis/devices/instron_stress_rig/quarter_cycle_event_detector_states.py b/lewis/devices/instron_stress_rig/quarter_cycle_event_detector_states.py new file mode 100644 index 00000000..723bc96b --- /dev/null +++ b/lewis/devices/instron_stress_rig/quarter_cycle_event_detector_states.py @@ -0,0 +1,8 @@ +class QuarterCycleEventDetectorStates(object): + OFF = 0 + PREPARED = 1 + ARMED = 2 + TRIPPED = 4 + PREPARED_INHIBITED = 6 + ARMED_INHIBITED = 7 + TRIPPED_INHIBITED = 9 diff --git a/lewis/devices/instron_stress_rig/states.py b/lewis/devices/instron_stress_rig/states.py new file mode 100644 index 00000000..afd3d1a8 --- /dev/null +++ b/lewis/devices/instron_stress_rig/states.py @@ -0,0 +1,50 @@ +import time + +from lewis.core import approaches +from lewis.core.statemachine import State + + +class DefaultState(State): + def in_state(self, dt): + device = self._context + device.set_current_time() + + if device.watchdog_refresh_time + 3 < time.time() and device.get_control_mode() != 0: + print("Watchdog time expired, going back to front panel control mode") + device.set_control_mode(0) + + device.stop_waveform_generation_if_requested() + + +class GoingToSetpointState(DefaultState): + def in_state(self, dt): + super(GoingToSetpointState, self).in_state(dt) + device = self._context + device.channels[device.control_channel].value = approaches.linear( + device.channels[device.control_channel].value, + device.channels[device.control_channel].ramp_amplitude_setpoint, + 0.001, + dt, + ) + + def on_exit(self, dt): + device = self._context + device.movement_type = 0 + + +class GeneratingWaveformState(DefaultState): + TIME_SINCE_LAST_QUART_COUNT = 0 + + @staticmethod + def increment_cycle(device, dt): + GeneratingWaveformState.TIME_SINCE_LAST_QUART_COUNT += dt + if GeneratingWaveformState.TIME_SINCE_LAST_QUART_COUNT > 1.0: + GeneratingWaveformState.TIME_SINCE_LAST_QUART_COUNT = 0.0 + device.quarter_cycle_event() + + def in_state(self, dt): + super(GeneratingWaveformState, self).in_state(dt) + device = self._context + + GeneratingWaveformState.increment_cycle(device, dt) + device.channels[device.control_channel].value = device.get_waveform_value() diff --git a/lewis/devices/instron_stress_rig/waveform_generator.py b/lewis/devices/instron_stress_rig/waveform_generator.py new file mode 100644 index 00000000..5c2b08f8 --- /dev/null +++ b/lewis/devices/instron_stress_rig/waveform_generator.py @@ -0,0 +1,102 @@ +import math +from datetime import datetime, timedelta + +from .quarter_cycle_event_detector import QuarterCycleEventDetector as QCED +from .waveform_generator_states import WaveformGeneratorStates as GenStates +from .waveform_types import WaveformTypes + + +class WaveformGenerator(object): + STOP_DELAY = timedelta(seconds=3) + + def __init__(self): + self.state = GenStates.STOPPED + self.amplitude = {i + 1: 0.0 for i in range(3)} + self.frequency = {i + 1: 1.0 for i in range(3)} + self.type = {i + 1: WaveformTypes.SINE for i in range(3)} + self.stop_requested_at_time = None + self.quart_counter = QCED() + + def abort(self): + if self.active(): + self.state = GenStates.ABORTED + self.stop_requested_at_time = None + self.quart_counter.off() + + def finish(self): + if self.active(): + self.stop_requested_at_time = datetime.now() + self.state = GenStates.FINISHING + + def time_to_stop(self): + return ( + self.stop_requested_at_time is not None + and (datetime.now() - self.stop_requested_at_time) > WaveformGenerator.STOP_DELAY + ) + + def stop(self): + self.stop_requested_at_time = None + self.state = GenStates.STOPPED + self.quart_counter.off() + + def start(self): + self.state = GenStates.RUNNING + self.stop_requested_at_time = None + + def hold(self): + if self.active(): + self.state = GenStates.HOLDING + + def maintain_log(self): + # Does nothing in current emulator + pass + + def active(self): + return self.state in [GenStates.RUNNING, GenStates.HOLDING] + + def get_value(self, channel): + def sin(a, x, f): + return a * math.sin(math.pi * x * f) + + def square(a, x, f): + return math.copysign(a, sin(a, x, f)) + + def sawtooth(a, x, f): + return a * (x % (1.0 / f)) + + def triangle(a, x, f): + return a * (1 - 2 * abs((f * x - 0.5) % 2 - 1)) + + def haversine(a, x, f): + return a / 2.0 * (1.0 - math.cos(math.pi * f * x)) + + def havertriangle(a, x, f): + return a * (1 - abs(f * x % 2 - 1)) + + def haversquare(a, x, f): + return a / 2.0 * (1.0 + math.copysign(1.0, haversine(a, x, f) - 0.5)) + + if self.active(): + amp = self.amplitude[channel] + freq = max(self.frequency[channel], 1.0e-20) + wave_type = self.type[channel] + val = self.quart_counter.counts + + if not self.active(): + return 0.0 + elif wave_type == WaveformTypes.TRIANGLE: + return triangle(amp, val, freq) + elif wave_type == WaveformTypes.SAWTOOTH: + return sawtooth(amp, val, freq) + elif wave_type == WaveformTypes.SQUARE: + return square(amp, val, freq) + elif wave_type == WaveformTypes.HAVERSINE: + return haversine(amp, val, freq) + elif wave_type == WaveformTypes.HAVERSQUARE: + return haversquare(amp, val, freq) + elif wave_type == WaveformTypes.HAVERTRIANGLE: + return havertriangle(amp, val, freq) + else: + return sin(amp, val, freq) + + return 0.0 diff --git a/lewis/devices/instron_stress_rig/waveform_generator_states.py b/lewis/devices/instron_stress_rig/waveform_generator_states.py new file mode 100644 index 00000000..b624703d --- /dev/null +++ b/lewis/devices/instron_stress_rig/waveform_generator_states.py @@ -0,0 +1,11 @@ +class WaveformGeneratorStates(object): + STOPPED = 0 + RUNNING = 1 + HOLDING = 2 + FINISHING = 3 + ABORTED = 4 + DISABLED = 5 + SLAVED = 6 + SLAVE_LOCKED = 7 + SWEEPING = 8 + DWELLING = 9 diff --git a/lewis/devices/instron_stress_rig/waveform_types.py b/lewis/devices/instron_stress_rig/waveform_types.py new file mode 100644 index 00000000..2bed34c9 --- /dev/null +++ b/lewis/devices/instron_stress_rig/waveform_types.py @@ -0,0 +1,10 @@ +class WaveformTypes(object): + SINE = 0 + TRIANGLE = 1 + SQUARE = 2 + HAVERSINE = 3 + HAVERTRIANGLE = 4 + HAVERSQUARE = 5 + EXTERNAL_SENSOR = 6 + EXTERNAL_AUX = 7 + SAWTOOTH = 8 diff --git a/lewis/devices/ips/__init__.py b/lewis/devices/ips/__init__.py new file mode 100644 index 00000000..3a22213d --- /dev/null +++ b/lewis/devices/ips/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedIps + +__all__ = ["SimulatedIps"] diff --git a/lewis/devices/ips/device.py b/lewis/devices/ips/device.py new file mode 100644 index 00000000..c59ed495 --- /dev/null +++ b/lewis/devices/ips/device.py @@ -0,0 +1,151 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from lewis_emulators.ips.modes import Activity, Control, Mode, SweepMode + +from .states import HeaterOffState, HeaterOnState, MagnetQuenchedState + +# As long as no magnetic saturation effects are present, there is a linear relationship between Teslas and Amps. +# +# This is called the load line. For more detailed (technical) discussion about the load line see: +# - http://aries.ucsd.edu/LIB/REPORT/SPPS/FINAL/chap4.pdf (section 4.3.3) +# - http://www.prizz.fi/sites/default/files/tiedostot/linkki1ID346.pdf (slide 11) +LOAD_LINE_GRADIENT = 0.01 + + +def amps_to_tesla(amps): + return amps * LOAD_LINE_GRADIENT + + +def tesla_to_amps(tesla): + return tesla / LOAD_LINE_GRADIENT + + +@has_log +class SimulatedIps(StateMachineDevice): + # Currents that correspond to the switch heater being on and off + HEATER_OFF_CURRENT, HEATER_ON_CURRENT = 0, 10 + + # If there is a difference in current of more than this between the magnet and the power supply, and the switch is + # resistive, then the magnet will quench. + # No idea what this number should be for a physically realistic system so just guess. + QUENCH_CURRENT_DELTA = 0.1 + + # Maximum rate at which the magnet can safely ramp without quenching. + MAGNET_RAMP_RATE = 1000 + + # Fixed rate at which switch heater can ramp up or down + HEATER_RAMP_RATE = 5 + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.reset() + + def reset(self): + # Within the cryostat, there is a wire that is made superconducting because it is in the cryostat. The wire has + # a heater which can be used to make the wire go back to a non-superconducting state. + # + # When the heater is ON, the wire has a high resistance and the magnet is powered directly by the power supply. + # + # When the heater is OFF, the wire is superconducting, which means that the power supply can be ramped down and + # the magnet will stay active (this is "persistent" mode) + self.heater_on = False + self.heater_current = 0 + + # "Leads" are the non-superconducting wires between the superconducting magnet and the power supply. + # Not sure what a realistic value is for these leads, so I've guessed. + self.lead_resistance = 50 + + # Current = what the power supply is providing. + self.current = 0 + self.current_setpoint = 0 + + # Current for the magnet. May be different from the power supply current if the magnet is in persistent mode. + self.magnet_current = 0 + + # Measured current may be different from what the PSU is attempting to provide + self.measured_current = 0 + + # If the device trips, store the last current which caused a trip in here. + # This could be used for diagnostics e.g. finding maximum field which magnet is capable of in a certain config. + self.trip_current = 0 + + # Ramp rate == sweep rate + self.current_ramp_rate = 1 / LOAD_LINE_GRADIENT + + # Set to true if the magnet is quenched - this will cause lewis to enter the quenched state + self.quenched = False + + # Mode of the magnet e.g. HOLD, TO SET POINT, TO ZERO, CLAMP + self.activity = Activity.TO_SETPOINT + + # No idea what a sensible value is. Hard-code this here for now - can't be changed on real device. + self.inductance = 0.005 + + # No idea what sensible values are here. Also not clear what the behaviour is of the controller when these + # limits are hit. + self.neg_current_limit, self.pos_current_limit = -(10**6), 10**6 + + # Local and locked is the zeroth mode of the control command + self.control = Control.LOCAL_LOCKED + + # The only sweep mode we are interested in is tesla fast + self.sweep_mode = SweepMode.TESLA_FAST + + # Not sure what is the sensible value here + self.mode = Mode.SLOW + + def _get_state_handlers(self): + return { + "heater_off": HeaterOffState(), + "heater_on": HeaterOnState(), + "quenched": MagnetQuenchedState(), + } + + def _get_initial_state(self): + return "heater_off" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("heater_off", "heater_on"), lambda: self.heater_on), + (("heater_on", "heater_off"), lambda: not self.heater_on), + (("heater_on", "quenched"), lambda: self.quenched), + (("heater_off", "quenched"), lambda: self.quenched), + # Only triggered when device is reset or similar + (("quenched", "heater_off"), lambda: not self.quenched and not self.heater_on), + (("quenched", "heater_on"), lambda: not self.quenched and self.heater_on), + ] + ) + + def quench(self, reason): + self.log.info("Magnet quenching at current={} because: {}".format(self.current, reason)) + self.trip_current = self.current + self.magnet_current = 0 + self.current = 0 + self.measured_current = 0 + self.quenched = True # Causes LeWiS to enter Quenched state + + def unquench(self): + self.quenched = False + + def get_voltage(self): + """Gets the voltage of the PSU. + + Everything except the leads is superconducting, we use Ohm's law here with the PSU current and the lead + resistance. + + In reality would also need to account for inductance effects from the magnet but I don't think that + extra complexity is necessary for this emulator. + """ + return self.current * self.lead_resistance + + def set_heater_status(self, new_status): + if new_status and abs(self.current - self.magnet_current) > self.QUENCH_CURRENT_DELTA: + raise ValueError( + "Can't set the heater to on while the magnet current and PSU current are mismatched" + ) + self.heater_on = new_status diff --git a/lewis/devices/ips/interfaces/__init__.py b/lewis/devices/ips/interfaces/__init__.py new file mode 100644 index 00000000..3e54fafa --- /dev/null +++ b/lewis/devices/ips/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import IpsStreamInterface + +__all__ = ["IpsStreamInterface"] diff --git a/lewis/devices/ips/interfaces/stream_interface.py b/lewis/devices/ips/interfaces/stream_interface.py new file mode 100644 index 00000000..9b7c80c0 --- /dev/null +++ b/lewis/devices/ips/interfaces/stream_interface.py @@ -0,0 +1,208 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from lewis_emulators.ips.modes import Activity, Control + +from ..device import amps_to_tesla, tesla_to_amps + +MODE_MAPPING = { + 0: Activity.HOLD, + 1: Activity.TO_SETPOINT, + 2: Activity.TO_ZERO, + 4: Activity.CLAMP, +} + +CONTROL_MODE_MAPPING = { + 0: Control.LOCAL_LOCKED, + 1: Control.REMOTE_LOCKED, + 2: Control.LOCAL_UNLOCKED, + 3: Control.REMOTE_UNLOCKED, +} + + +@has_log +class IpsStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_version").escape("V").eos().build(), + CmdBuilder("set_comms_mode").escape("Q4").eos().build(), + CmdBuilder("get_status").escape("X").eos().build(), + CmdBuilder("get_current").escape("R0").eos().build(), + CmdBuilder("get_supply_voltage").escape("R1").eos().build(), + CmdBuilder("get_measured_current").escape("R2").eos().build(), + CmdBuilder("get_current_setpoint").escape("R5").eos().build(), + CmdBuilder("get_current_sweep_rate").escape("R6").eos().build(), + CmdBuilder("get_field").escape("R7").eos().build(), + CmdBuilder("get_field_setpoint").escape("R8").eos().build(), + CmdBuilder("get_field_sweep_rate").escape("R9").eos().build(), + CmdBuilder("get_software_voltage_limit").escape("R15").eos().build(), + CmdBuilder("get_persistent_magnet_current").escape("R16").eos().build(), + CmdBuilder("get_trip_current").escape("R17").eos().build(), + CmdBuilder("get_persistent_magnet_field").escape("R18").eos().build(), + CmdBuilder("get_trip_field").escape("R19").eos().build(), + CmdBuilder("get_heater_current").escape("R20").eos().build(), + CmdBuilder("get_neg_current_limit").escape("R21").eos().build(), + CmdBuilder("get_pos_current_limit").escape("R22").eos().build(), + CmdBuilder("get_lead_resistance").escape("R23").eos().build(), + CmdBuilder("get_magnet_inductance").escape("R24").eos().build(), + CmdBuilder("set_control_mode") + .escape("C") + .arg("0|1|2|3", argument_mapping=int) + .eos() + .build(), + CmdBuilder("set_mode").escape("A").int().eos().build(), + CmdBuilder("set_current").escape("I").float().eos().build(), + CmdBuilder("set_field").escape("J").float().eos().build(), + CmdBuilder("set_field_sweep_rate").escape("T").float().eos().build(), + CmdBuilder("set_sweep_mode").escape("M").int().eos().build(), + CmdBuilder("set_heater_on").escape("H1").eos().build(), + CmdBuilder("set_heater_off").escape("H0").eos().build(), + CmdBuilder("set_heater_off").escape("H2").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def get_version(self): + return "Simulated IPS" + + def set_comms_mode(self): + """This sets the terminator that the device wants, not implemented in emulator. Command does not reply. + """ + + def set_control_mode(self, mode): + self.device.control = CONTROL_MODE_MAPPING[mode] + return "C" + + def set_mode(self, mode): + mode = int(mode) + try: + self.device.activity = MODE_MAPPING[mode] + except KeyError: + raise ValueError("Invalid mode specified") + return "A" + + def get_status(self): + resp = "X{x1}{x2}A{a}C{c}H{h}M{m1}{m2}P{p1}{p2}" + + def translate_activity(): + for k, v in MODE_MAPPING.items(): + if v == self.device.activity: + return k + else: + raise ValueError("Device was in invalid mode, can't construct status") + + def get_heater_status_number(): + if self.device.heater_on: + return 1 + else: + return 0 if self.device.magnet_current == 0 else 2 + + def is_sweeping(): + if self.device.activity == Activity.TO_SETPOINT: + return self.device.current != self.device.current_setpoint + elif self.device.activity == Activity.TO_ZERO: + return self.device.current != 0 + else: + return False + + statuses = { + "x1": 1 if self.device.quenched else 0, + "x2": 0, + "a": translate_activity(), + "c": 4 if self.device.quenched else 3, + "h": get_heater_status_number(), + "m1": self.device.sweep_mode, + "m2": 1 if is_sweeping() else 0, + "p1": 0, + "p2": 0, + } + + return resp.format(**statuses) + + def get_current_setpoint(self): + return "R{}".format(self.device.current_setpoint) + + def get_supply_voltage(self): + return "R{}".format(self.device.get_voltage()) + + def get_measured_current(self): + return "R{}".format(self.device.measured_current) + + def get_current(self): + return "R{}".format(self.device.current) + + def get_current_sweep_rate(self): + return "R{}".format(self.device.current_ramp_rate) + + def get_field(self): + return "R{}".format(amps_to_tesla(self.device.current)) + + def get_field_setpoint(self): + return "R{}".format(amps_to_tesla(self.device.current_setpoint)) + + def get_field_sweep_rate(self): + return "R{}".format(amps_to_tesla(self.device.current_ramp_rate)) + + def get_software_voltage_limit(self): + return "R0" + + def get_persistent_magnet_current(self): + return "R{}".format(self.device.magnet_current) + + def get_trip_current(self): + return "R{}".format(self.device.trip_current) + + def get_persistent_magnet_field(self): + return "R{}".format(amps_to_tesla(self.device.magnet_current)) + + def get_trip_field(self): + return "R{}".format(amps_to_tesla(self.device.trip_current)) + + def get_heater_current(self): + return "R{}".format(self.device.heater_current) + + def get_neg_current_limit(self): + return "R{}".format(self.device.neg_current_limit) + + def get_pos_current_limit(self): + return "R{}".format(self.device.pos_current_limit) + + def get_lead_resistance(self): + return "R{}".format(self.device.lead_resistance) + + def get_magnet_inductance(self): + return "R{}".format(self.device.inductance) + + def set_current(self, current): + self.device.current_setpoint = float(current) + return "I" + + def set_field(self, current): + self.device.current_setpoint = tesla_to_amps(float(current)) + return "J" + + def set_heater_on(self): + self.device.set_heater_status(True) + return "H" + + def set_heater_off(self): + self.device.set_heater_status(False) + return "H" + + def set_field_sweep_rate(self, tesla): + self.device.current_ramp_rate = tesla_to_amps(float(tesla)) + return "T" + + def set_sweep_mode(self, mode): + self.device.sweep_mode = int(mode) + return "M" diff --git a/lewis/devices/ips/modes.py b/lewis/devices/ips/modes.py new file mode 100644 index 00000000..c86bb5e2 --- /dev/null +++ b/lewis/devices/ips/modes.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class Activity(Enum): + HOLD = "Hold" + TO_SETPOINT = "To Setpoint" + TO_ZERO = "To Zero" + CLAMP = "Clamped" + + +class Control(Enum): + LOCAL_LOCKED = "Local & Locked" + REMOTE_LOCKED = "Remote & Unlocked" + LOCAL_UNLOCKED = "Local & Unlocked" + REMOTE_UNLOCKED = "Remote & Unlocked" + AUTO_RUNDOWN = "Auto-Run-Down" + + +class SweepMode(Enum): + TESLA_FAST = "Tesla Fast" + + +class Mode(Enum): + FAST = "Fast" + SLOW = "Slow" diff --git a/lewis/devices/ips/states.py b/lewis/devices/ips/states.py new file mode 100644 index 00000000..f6bc351e --- /dev/null +++ b/lewis/devices/ips/states.py @@ -0,0 +1,63 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + +from lewis_emulators.ips.modes import Activity + +SECS_PER_MIN = 60 + + +class HeaterOnState(State): + def in_state(self, dt): + device = self._context + + device.heater_current = approaches.linear( + device.heater_current, device.HEATER_ON_CURRENT, device.HEATER_RAMP_RATE, dt + ) + + # The magnet can only be ramped at a certain rate. The PSUs ramp rate can be varied. + # If the PSU attempts to ramp too fast for the magnet, then get a quench + curr_ramp_rate = device.current_ramp_rate / SECS_PER_MIN + + if curr_ramp_rate > device.MAGNET_RAMP_RATE: + device.quench("PSU ramp rate is too high") + elif abs(device.current - device.magnet_current) > device.QUENCH_CURRENT_DELTA * dt: + device.quench( + "Difference between PSU current ({}) and magnet current ({}) is higher than allowed ({})".format( + device.current, device.magnet_current, device.QUENCH_CURRENT_DELTA * dt + ) + ) + + elif device.activity == Activity.TO_SETPOINT: + device.current = approaches.linear( + device.current, device.current_setpoint, curr_ramp_rate, dt + ) + device.magnet_current = approaches.linear( + device.magnet_current, device.current_setpoint, curr_ramp_rate, dt + ) + + elif device.activity == Activity.TO_ZERO: + device.current = approaches.linear(device.current, 0, curr_ramp_rate, dt) + device.magnet_current = approaches.linear(device.magnet_current, 0, curr_ramp_rate, dt) + + +class HeaterOffState(State): + def in_state(self, dt): + device = self._context + + device.heater_current = approaches.linear( + device.heater_current, device.HEATER_OFF_CURRENT, device.HEATER_RAMP_RATE, dt + ) + + curr_ramp_rate = device.current_ramp_rate / SECS_PER_MIN + + # In this state, the magnet current is totally unaffected by whatever the PSU decides to do. + if device.activity == Activity.TO_SETPOINT: + device.current = approaches.linear( + device.current, device.current_setpoint, curr_ramp_rate, dt + ) + elif device.activity == Activity.TO_ZERO: + device.current = approaches.linear(device.current, 0, curr_ramp_rate, dt) + + +class MagnetQuenchedState(State): + pass diff --git a/lewis/devices/iris_cryo_valve/__init__.py b/lewis/devices/iris_cryo_valve/__init__.py new file mode 100644 index 00000000..82b519de --- /dev/null +++ b/lewis/devices/iris_cryo_valve/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedIrisCryoValve + +__all__ = ["SimulatedIrisCryoValve"] diff --git a/lewis/devices/iris_cryo_valve/device.py b/lewis/devices/iris_cryo_valve/device.py new file mode 100644 index 00000000..8562e617 --- /dev/null +++ b/lewis/devices/iris_cryo_valve/device.py @@ -0,0 +1,5 @@ +from lewis.devices import Device + + +class SimulatedIrisCryoValve(Device): + is_open = False diff --git a/lewis/devices/iris_cryo_valve/interfaces/__init__.py b/lewis/devices/iris_cryo_valve/interfaces/__init__.py new file mode 100644 index 00000000..d341c05c --- /dev/null +++ b/lewis/devices/iris_cryo_valve/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import IrisCryoValveStreamInterface + +__all__ = ["IrisCryoValveStreamInterface"] diff --git a/lewis/devices/iris_cryo_valve/interfaces/stream_interface.py b/lewis/devices/iris_cryo_valve/interfaces/stream_interface.py new file mode 100644 index 00000000..445cd62f --- /dev/null +++ b/lewis/devices/iris_cryo_valve/interfaces/stream_interface.py @@ -0,0 +1,27 @@ +from lewis.adapters.stream import Cmd, StreamInterface + + +class IrisCryoValveStreamInterface(StreamInterface): + commands = { + Cmd("get_status", "^\?$"), + Cmd("set_open", "^OPEN$"), + Cmd("set_closed", "^CLOSE$"), + } + + in_terminator = "\r" + out_terminator = "\r" + + def get_status(self): + status = "OPEN" if self._device.is_open else "CLOSED" + return "SOLENOID " + status + + def set_open(self): + self._device.is_open = True + return "" + + def set_closed(self): + self._device.is_open = False + return "" + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) diff --git a/lewis/devices/itc503/__init__.py b/lewis/devices/itc503/__init__.py new file mode 100644 index 00000000..c46ab651 --- /dev/null +++ b/lewis/devices/itc503/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedItc503 + +__all__ = ["SimulatedItc503"] diff --git a/lewis/devices/itc503/device.py b/lewis/devices/itc503/device.py new file mode 100644 index 00000000..89a6a61e --- /dev/null +++ b/lewis/devices/itc503/device.py @@ -0,0 +1,37 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +@has_log +class SimulatedItc503(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.p, self.i, self.d = 0, 0, 0 + self.gas_flow = 0 + self.temperature = 0 + self.temperature_sp = 0 + self.mode = 0 + self.control = 0 + self.sweeping = False + self.control_channel = 1 + self.autopid = False + + self.heater_v = 0 + + # Set by tests, affects the response format of the device. Slightly different models of ITC will respond + # differently + self.report_sweep_state_with_leading_zero = False + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/itc503/interfaces/__init__.py b/lewis/devices/itc503/interfaces/__init__.py new file mode 100644 index 00000000..2db67108 --- /dev/null +++ b/lewis/devices/itc503/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Itc503StreamInterface + +__all__ = ["Itc503StreamInterface"] diff --git a/lewis/devices/itc503/interfaces/stream_interface.py b/lewis/devices/itc503/interfaces/stream_interface.py new file mode 100644 index 00000000..6b360550 --- /dev/null +++ b/lewis/devices/itc503/interfaces/stream_interface.py @@ -0,0 +1,135 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + + +class Itc503StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("set_p").escape("P").float().eos().build(), + CmdBuilder("set_i").escape("I").float().eos().build(), + CmdBuilder("set_d").escape("D").float().eos().build(), + CmdBuilder("get_p").escape("R8").eos().build(), + CmdBuilder("get_i").escape("R9").eos().build(), + CmdBuilder("get_d").escape("R10").eos().build(), + CmdBuilder("get_gas_flow").escape("R7").eos().build(), + CmdBuilder("set_gas_flow").escape("G").float().eos().build(), + CmdBuilder("get_temp").escape("R1").eos().build(), + CmdBuilder("get_temp").escape("R2").eos().build(), + CmdBuilder("get_temp").escape("R3").eos().build(), + CmdBuilder("get_temp_sp").escape("R0").eos().build(), + CmdBuilder("set_temp").escape("T").float().eos().build(), + CmdBuilder("get_status").escape("X").eos().build(), + CmdBuilder("set_ctrl").escape("C").int().eos().build(), + CmdBuilder("set_mode").escape("A").int().eos().build(), + CmdBuilder("set_ctrl_chan").escape("H").int().eos().build(), + CmdBuilder("set_autopid_on").escape("L1").eos().build(), + CmdBuilder("set_autopid_off").escape("L0").eos().build(), + CmdBuilder("set_heater_maxv").escape("M").float().eos().build(), + # No readback for max heater output + CmdBuilder("set_heater_v").escape("O").float().eos().build(), + CmdBuilder("get_heater_v").escape("R6").eos().build(), + CmdBuilder("get_heater_p").escape("R5").eos().build(), + CmdBuilder("get_temp_error").escape("R4").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def set_p(self, p): + self.device.p = float(p) + return "P" + + def get_p(self): + return "R{:.1f}".format(self.device.p) + + def set_i(self, i): + self.device.i = float(i) + return "I" + + def get_i(self): + return "R{:.1f}".format(self.device.i) + + def set_d(self, d): + self.device.d = float(d) + return "D" + + def get_d(self): + return "R{:.1f}".format(self.device.d) + + def set_gas_flow(self, flow): + self.device.gas_flow = float(flow) + return "G" + + def get_gas_flow(self): + return "R{:.1f}".format(self.device.gas_flow) + + def get_temp(self): + return "R{:.1f}".format(self.device.temperature) + + def set_temp(self, temp): + self.device.temperature_sp = float(temp) + return "T" + + def get_temp_sp(self): + return "R{:.1f}".format(self.device.temperature_sp) + + def get_status(self): + if self.device.report_sweep_state_with_leading_zero: + format_string = "X0A{mode}C{ctrl}S{sweeping:02d}H{control_channel}L{autopid}" + else: + format_string = "X0A{mode}C{ctrl}S{sweeping:01d}H{control_channel}L{autopid}" + + return format_string.format( + mode=self.device.mode, + ctrl=self.device.control, + sweeping=1 if self.device.sweeping else 0, + control_channel=self.device.control_channel, + autopid=1 if self.device.autopid else 0, + ) + + def set_ctrl(self, ctrl): + self.device.control = int(ctrl) + return "C" + + def set_mode(self, mode): + self.device.mode = int(mode) + return "A" + + def set_ctrl_chan(self, chan): + if not 1 <= int(chan) <= 3: + raise ValueError("Invalid channel") + self.device.control_channel = int(chan) + return "H" + + def set_autopid_on(self): + self.device.autopid = True + return "L" + + def set_autopid_off(self): + self.device.autopid = False + return "L" + + def set_heater_v(self, manv): + self.device.heater_v = float(manv) + return "O" + + def get_heater_v(self): + return "R{:.1f}".format(self.device.heater_v) + + def get_heater_p(self): + # Return heater voltage number as a substitute for percentage. + return "R{:.1f}".format(self.device.heater_v) + + def set_heater_maxv(self, volts): + raise ValueError("At ISIS, do not use this command!") + + def get_temp_error(self): + return "R{:.1f}".format(abs(self.device.temperature_sp - self.device.temperature)) diff --git a/lewis/devices/itc503/states.py b/lewis/devices/itc503/states.py new file mode 100644 index 00000000..04bab6cf --- /dev/null +++ b/lewis/devices/itc503/states.py @@ -0,0 +1,11 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class DefaultState(State): + def in_state(self, dt): + device = self._context + + rate = 10 + + device.temperature = approaches.linear(device.temperature, device.temperature_sp, rate, dt) diff --git a/lewis/devices/jsco4180/__init__.py b/lewis/devices/jsco4180/__init__.py new file mode 100644 index 00000000..b633e912 --- /dev/null +++ b/lewis/devices/jsco4180/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedJsco4180 + +__all__ = ["SimulatedJsco4180"] diff --git a/lewis/devices/jsco4180/device.py b/lewis/devices/jsco4180/device.py new file mode 100644 index 00000000..8bce66ec --- /dev/null +++ b/lewis/devices/jsco4180/device.py @@ -0,0 +1,84 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import PumpOff, PumpOn, PumpProgram, PumpProgramReset + +states = OrderedDict( + [ + ("pump_off", PumpOff()), + ("pump_on", PumpOn()), + ("pump_program", PumpProgram()), + ("pump_program_reset", PumpProgramReset()), + ] +) + + +class SimulatedJsco4180(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.input_correct = True + self.single_channel_mode = False + self.status = "pump_off" + + self.flowrate_sp = 0.1 + self.flowrate_rbv = 0.1 + self.flowrate = 0.0 + + self.pressure = 0 + self.pressure_max = 400 + self.pressure_min = 1 + + # Composition components A, B, C, D + self.component_A = 100.0 + self.component_B = 0.0 + self.component_C = 0.0 + self.component_D = 0.0 + + self.program_runtime = 0 + self.file_number = 0 + self.file_open = False + self.error = 0 + + @property + def state(self): + return self._csm.state + + def crash_pump(self): + self.connected = False + + def simulate_pumping(self): + self.flowrate = self.flowrate_rbv + self.pressure = (self.pressure_max - self.pressure_min) // 2 + + def _get_state_handlers(self): + return states + + def _get_initial_state(self): + return "pump_off" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("pump_off", "pump_on"), lambda: self.status == "on"), + (("pump_off", "pump_program"), lambda: self.status == "pump_program"), + (("pump_off", "pump_program_reset"), lambda: self.status == "pump_program_reset"), + (("pump_on", "pump_off"), lambda: self.status == "pump_off"), + (("pump_on", "pump_program"), lambda: self.status == "pump_program"), + (("pump_on", "pump_program_reset"), lambda: self.status == "pump_program_reset"), + (("pump_program", "pump_off"), lambda: self.status == "pump_off"), + (("pump_program", "pump_on"), lambda: self.status == "pump_on"), + ( + ("pump_program", "pump_program_reset"), + lambda: self.status == "pump_program_reset", + ), + (("pump_program_reset", "pump_off"), lambda: self.status == "pump_off"), + (("pump_program_reset", "pump_on"), lambda: self.status == "pump_on"), + (("pump_program_reset", "pump_program"), lambda: self.status == "pump_program"), + ] + ) + + def reset(self): + self._initialize_data() diff --git a/lewis/devices/jsco4180/interfaces/__init__.py b/lewis/devices/jsco4180/interfaces/__init__.py new file mode 100644 index 00000000..26a2e67f --- /dev/null +++ b/lewis/devices/jsco4180/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Jsco4180StreamInterface + +__all__ = ["Jsco4180StreamInterface"] diff --git a/lewis/devices/jsco4180/interfaces/stream_interface.py b/lewis/devices/jsco4180/interfaces/stream_interface.py new file mode 100644 index 00000000..8a62ea5a --- /dev/null +++ b/lewis/devices/jsco4180/interfaces/stream_interface.py @@ -0,0 +1,183 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply, timed_reply + +if_connected = conditional_reply("connected") +if_input_error = conditional_reply("input_correct", "%%[Error:stack underflow]%%") +if_valid_input_delay = timed_reply(action="crash_pump", minimum_time_delay=100) + + +def combined_checks(func): + """Combine all conditional reply checks so we have a single decorator + """ + return if_valid_input_delay(if_connected(if_input_error(func))) + + +class Jsco4180StreamInterface(StreamInterface): + in_terminator = "\r" + out_terminator = "\r\n" + + def __init__(self): + super(Jsco4180StreamInterface, self).__init__() + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.set_flowrate).float().escape(" flowrate set").eos().build(), + CmdBuilder(self.get_flowrate_rbv).escape("flowrate load p").eos().build(), + CmdBuilder(self.get_flowrate).escape("a_flow load p").eos().build(), + CmdBuilder(self.get_pressure).escape("a_press1 load p").eos().build(), + CmdBuilder(self.set_pressure_max).int().escape(" pmax set").build(), + CmdBuilder(self.get_pressure_max).escape("a_pmax load p").eos().build(), + CmdBuilder(self.set_pressure_min).int().escape(" pmin set").build(), + CmdBuilder(self.get_pressure_min).escape("a_pmin load p").eos().build(), + CmdBuilder(self.get_program_runtime).escape("current_time load p").eos().build(), + CmdBuilder(self.get_component_a).escape("compa load p").eos().build(), + CmdBuilder(self.get_component_b).escape("compb load p").eos().build(), + CmdBuilder(self.get_component_c).escape("compc load p").eos().build(), + CmdBuilder(self.get_component_d).escape("compd load p").eos().build(), + CmdBuilder(self.set_composition) + .float() + .escape(" ") + .float() + .escape(" ") + .float() + .escape(" ") + .float() + .escape(" comp set") + .eos() + .build(), + CmdBuilder(self.get_error).escape("trouble load p").eos().build(), + CmdBuilder(self.set_error).escape("0 trouble set").build(), + CmdBuilder(self.set_pump).int().escape(" pump set").eos().build(), + CmdBuilder(self.get_status).escape("status load p").eos().build(), + CmdBuilder(self.set_file_number).int().escape(" fileno set").eos().build(), + CmdBuilder(self.set_file_open).int().escape(" openfile").eos().build(), + CmdBuilder(self.set_file_closed).int().escape(" closefile").eos().build(), + } + + def catch_all(self): + pass + + @combined_checks + def set_file_open(self, _): + self.device.file_open = True + + @combined_checks + def set_file_closed(self, _): + self.device.file_open = False + + @combined_checks + def set_file_number(self, file_number): + state = self.device.state + if state != "pump_off": + return "%%[Program is Busy]%%" + self.out_terminator + else: + self.device.file_number = file_number + self.device.single_channel_mode = False + + @combined_checks + def get_status(self): + if self.device.status == "pump_off": + return 0 + elif self.device.status == "pump_on": + return 33 # Running program but time halted + elif self.device.status == "pump_program": + return 49 # Running program with time + elif self.device.status == "pump_program_reset": + return 49 # Running program with reset timer + + @combined_checks + def set_pump(self, mode): + if mode == 0: + # Pump on + self.device.status = "pump_on" + elif mode == 1: + # Pump off + self.device.status = "pump_off" + return self.out_terminator + elif mode == 6: + # Pump program (time increment halted) + self.device.status = "pump_program" + return self.out_terminator + elif mode == 8: + # Pump reset and rerun program + self.device.status = "pump_program_reset" + self.device.program_runtime = 0 + return self.out_terminator + + @combined_checks + def set_flowrate(self, flowrate): + self.device.flowrate_rbv = flowrate + return self.out_terminator + + @combined_checks + def get_flowrate(self): + return self.device.flowrate + + @combined_checks + def get_flowrate_rbv(self): + return self.device.flowrate_rbv + + @combined_checks + def get_current_flowrate(self): + return self.device.flowrate + + @combined_checks + def get_pressure(self): + return int(self.device.pressure) + + @combined_checks + def set_pressure_max(self, pressure_max): + self.device.pressure_max = pressure_max + return self.out_terminator + + @combined_checks + def get_pressure_max(self): + return self.device.pressure_max + + @combined_checks + def set_pressure_min(self, pressure_min): + self.device.pressure_min = pressure_min + return self.out_terminator + + @combined_checks + def get_pressure_min(self): + return self.device.pressure_min + + @combined_checks + def get_program_runtime(self): + if self.device.status == "pump_program_reset": + self.device.program_runtime += 1 + return int(self.device.program_runtime) + + @combined_checks + def get_component_a(self): + return 100 if self.device.single_channel_mode else self.device.component_A + + @combined_checks + def get_component_b(self): + return 0 if self.device.single_channel_mode else self.device.component_B + + @combined_checks + def get_component_c(self): + return 0 if self.device.single_channel_mode else self.device.component_C + + @combined_checks + def get_component_d(self): + return 0 if self.device.single_channel_mode else self.device.component_D + + @combined_checks + def set_composition(self, ramptime, a, b, c): + self.device.component_A = a + self.device.component_B = b + self.device.component_C = c + self.device.component_D = 100 - (a + b + c) + return self.out_terminator + + @combined_checks + def get_error(self): + return self.device.error + + @combined_checks + def set_error(self): + self.device.error = 0 + return self.out_terminator diff --git a/lewis/devices/jsco4180/states.py b/lewis/devices/jsco4180/states.py new file mode 100644 index 00000000..9e6d227a --- /dev/null +++ b/lewis/devices/jsco4180/states.py @@ -0,0 +1,26 @@ +from lewis.core.statemachine import State + + +class PumpOff(State): + def on_entry(self, dt): + device = self._context + device.flowrate = 0.0 + device.pressure = 0.0 + + +class PumpOn(State): + def on_entry(self, dt): + device = self._context + device.simulate_pumping() + + +class PumpProgram(State): + def on_entry(self, dt): + device = self._context + device.simulate_pumping() + + +class PumpProgramReset(State): + def on_entry(self, dt): + device = self._context + device.simulate_pumping() diff --git a/lewis/devices/julabo/__init__.py b/lewis/devices/julabo/__init__.py index d731034b..e69de29b 100644 --- a/lewis/devices/julabo/__init__.py +++ b/lewis/devices/julabo/__init__.py @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* diff --git a/lewis/devices/julabo/devices/__init__.py b/lewis/devices/julabo/devices/__init__.py index d731034b..e69de29b 100644 --- a/lewis/devices/julabo/devices/__init__.py +++ b/lewis/devices/julabo/devices/__init__.py @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* diff --git a/lewis/devices/julabo/devices/device.py b/lewis/devices/julabo/devices/device.py index 3cac4d06..67d198c7 100644 --- a/lewis/devices/julabo/devices/device.py +++ b/lewis/devices/julabo/devices/device.py @@ -1,22 +1,3 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* - from collections import OrderedDict from lewis.core.utils import check_limits @@ -25,6 +6,11 @@ from . import states +class ControlModes(object): + INTERNAL = 0 + EXTERNAL = 1 + + class SimulatedJulabo(StateMachineDevice): internal_p = 0.1 # The proportional internal_i = 3 # The integral @@ -43,10 +29,10 @@ class SimulatedJulabo(StateMachineDevice): external_temperature = 26.0 # External temperature in C circulate_commanded = False temperature_ramp_rate = 5.0 # Guessed value in C/min + control_mode = ControlModes.EXTERNAL - def _initialize_data(self) -> None: - """ - This method is called once on construction. After that, it may be + def _initialize_data(self): + """This method is called once on construction. After that, it may be manually called again to reset the device to its default state. After the first call during construction, the class is frozen. @@ -63,7 +49,7 @@ def _get_state_handlers(self): "not_circulate": states.DefaultNotCirculatingState(), } - def _get_initial_state(self) -> str: + def _get_initial_state(self): return "not_circulate" def _get_transition_handlers(self): @@ -74,9 +60,8 @@ def _get_transition_handlers(self): ] ) - def set_set_point(self, param) -> str: - """ - Sets the target temperature. + def set_set_point(self, param): + """Sets the target temperature. :param param: The new temperature in C. Must be positive. :return: Empty string. @@ -85,9 +70,8 @@ def set_set_point(self, param) -> str: self.set_point_temperature = param return "" - def set_circulating(self, param) -> str: - """ - Sets whether to circulate - in effect whether the heater is on. + def set_circulating(self, param): + """Sets whether to circulate - in effect whether the heater is on. :param param: The mode to set, must be 0 or 1. :return: Empty string. @@ -101,9 +85,8 @@ def set_circulating(self, param) -> str: return "" @check_limits(0.1, 99.9) - def set_internal_p(self, param) -> str: - """ - Sets the internal proportional. + def set_internal_p(self, param): + """Sets the internal proportional. Xp in Julabo speak. :param param: The value to set, must be between 0.1 and 99.9 @@ -113,9 +96,8 @@ def set_internal_p(self, param) -> str: return "" @check_limits(3, 9999) - def set_internal_i(self, param) -> str: - """ - Sets the internal integral. + def set_internal_i(self, param): + """Sets the internal integral. Tn in Julabo speak. :param param: The value to set, must be an integer between 3 and 9999 @@ -125,9 +107,8 @@ def set_internal_i(self, param) -> str: return "" @check_limits(0, 999) - def set_internal_d(self, param) -> str: - """ - Sets the internal derivative. + def set_internal_d(self, param): + """Sets the internal derivative. Tv in Julabo speak. :param param: The value to set, must be an integer between 0 and 999 @@ -137,9 +118,8 @@ def set_internal_d(self, param) -> str: return "" @check_limits(0.1, 99.9) - def set_external_p(self, param) -> str: - """ - Sets the external proportional. + def set_external_p(self, param): + """Sets the external proportional. Xp in Julabo speak. :param param: The value to set, must be between 0.1 and 99.9 @@ -149,9 +129,8 @@ def set_external_p(self, param) -> str: return "" @check_limits(3, 9999) - def set_external_i(self, param) -> str: - """ - Sets the external integral. + def set_external_i(self, param): + """Sets the external integral. Tn in Julabo speak. :param param: The value to set, must be an integer between 3 and 9999 @@ -161,9 +140,8 @@ def set_external_i(self, param) -> str: return "" @check_limits(0, 999) - def set_external_d(self, param) -> str: - """ - Sets the external derivative. + def set_external_d(self, param): + """Sets the external derivative. Tv in Julabo speak. :param param: The value to set, must be an integer between 0 and 999 @@ -171,3 +149,12 @@ def set_external_d(self, param) -> str: """ self.external_d = param return "" + + @check_limits(0, 1) + def set_control_mode(self, control_mode): + """Sets the control mode of the julabo. + :param control_mode: (int) 1 for external control, 0 for internal control + :return: Empty string + """ + self.control_mode = ControlModes.EXTERNAL if control_mode == 1 else ControlModes.INTERNAL + return "" diff --git a/lewis/devices/julabo/devices/states.py b/lewis/devices/julabo/devices/states.py index b6536051..33c147e0 100644 --- a/lewis/devices/julabo/devices/states.py +++ b/lewis/devices/julabo/devices/states.py @@ -1,22 +1,3 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* - from lewis.core import approaches from lewis.core.statemachine import State @@ -26,7 +7,7 @@ class DefaultNotCirculatingState(State): class DefaultCirculatingState(State): - def in_state(self, dt) -> None: + def in_state(self, dt): # Approach target temperature at a set rate self._context.temperature = approaches.linear( self._context.temperature, diff --git a/lewis/devices/julabo/interfaces/__init__.py b/lewis/devices/julabo/interfaces/__init__.py index d731034b..e69de29b 100644 --- a/lewis/devices/julabo/interfaces/__init__.py +++ b/lewis/devices/julabo/interfaces/__init__.py @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* diff --git a/lewis/devices/julabo/interfaces/julabo_stream_interface_1.py b/lewis/devices/julabo/interfaces/julabo_stream_interface_1.py index f64eb833..e7247d07 100644 --- a/lewis/devices/julabo/interfaces/julabo_stream_interface_1.py +++ b/lewis/devices/julabo/interfaces/julabo_stream_interface_1.py @@ -1,22 +1,3 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* - from lewis.adapters.stream import Cmd, StreamInterface, Var @@ -30,22 +11,10 @@ class JulaboStreamInterfaceV1(StreamInterface): commands = { Var("temperature", read_pattern="^IN_PV_00$", doc="The bath temperature."), - Var( - "external_temperature", - read_pattern="^IN_PV_01$", - doc="The external temperature.", - ), + Var("external_temperature", read_pattern="^IN_PV_01$", doc="The external temperature."), Var("heating_power", read_pattern="^IN_PV_02$", doc="The heating power."), - Var( - "set_point_temperature", - read_pattern="^IN_SP_00$", - doc="The temperature setpoint.", - ), - Cmd( - "set_set_point", - r"^OUT_SP_00 ([0-9]*\.?[0-9]+)$", - argument_mappings=(float,), - ), + Var("set_point_temperature", read_pattern="^IN_SP_00$", doc="The temperature setpoint."), + Cmd("set_set_point", "^OUT_SP_00 ([0-9]*\.?[0-9]+)$", argument_mappings=(float,)), Var( "temperature_high_limit", read_pattern="^IN_SP_01$", @@ -58,32 +27,22 @@ class JulaboStreamInterfaceV1(StreamInterface): ), Var("version", read_pattern="^VERSION$", doc="The Julabo version."), Var("status", read_pattern="^STATUS$", doc="The Julabo status."), - Var( - "is_circulating", - read_pattern="^IN_MODE_05$", - doc="Whether it is circulating.", - ), + Var("is_circulating", read_pattern="^IN_MODE_05$", doc="Whether it is circulating."), Cmd("set_circulating", "^OUT_MODE_05 (0|1)$", argument_mappings=(int,)), Var("internal_p", read_pattern="^IN_PAR_06$", doc="The internal proportional."), - Cmd( - "set_internal_p", - r"^OUT_PAR_06 ([0-9]*\.?[0-9]+)$", - argument_mappings=(float,), - ), + Cmd("set_internal_p", "^OUT_PAR_06 ([0-9]*\.?[0-9]+)$", argument_mappings=(float,)), Var("internal_i", read_pattern="^IN_PAR_07$", doc="The internal integral."), Cmd("set_internal_i", "^OUT_PAR_07 ([0-9]*)$", argument_mappings=(int,)), Var("internal_d", read_pattern="^IN_PAR_08$", doc="The internal derivative."), Cmd("set_internal_d", "^OUT_PAR_08 ([0-9]*)$", argument_mappings=(int,)), Var("external_p", read_pattern="^IN_PAR_09$", doc="The external proportional."), - Cmd( - "set_external_p", - r"^OUT_PAR_09 ([0-9]*\.?[0-9]+)$", - argument_mappings=(float,), - ), + Cmd("set_external_p", "^OUT_PAR_09 ([0-9]*\.?[0-9]+)$", argument_mappings=(float,)), Var("external_i", read_pattern="^IN_PAR_11$", doc="The external integral."), Cmd("set_external_i", "^OUT_PAR_11 ([0-9]*)$", argument_mappings=(int,)), Var("external_d", read_pattern="^IN_PAR_12$", doc="The external derivative."), Cmd("set_external_d", "^OUT_PAR_12 ([0-9]*)$", argument_mappings=(int,)), + Var("control_mode", read_pattern="^IN_MODE_04", doc="The control mode internal/external"), + Cmd("set_control_mode", "^OUT_MODE_04 (0|1)$", argument_mappings=(int,)), } in_terminator = "\r" diff --git a/lewis/devices/julabo/interfaces/julabo_stream_interface_2.py b/lewis/devices/julabo/interfaces/julabo_stream_interface_2.py index e479f398..d3416fd4 100644 --- a/lewis/devices/julabo/interfaces/julabo_stream_interface_2.py +++ b/lewis/devices/julabo/interfaces/julabo_stream_interface_2.py @@ -1,22 +1,3 @@ -# -*- coding: utf-8 -*- -# ********************************************************************* -# lewis - a library for creating hardware device simulators -# Copyright (C) 2016-2021 European Spallation Source ERIC -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# ********************************************************************* - from lewis.adapters.stream import Cmd, StreamInterface, Var @@ -30,22 +11,10 @@ class JulaboStreamInterfaceV2(StreamInterface): commands = { Var("temperature", read_pattern="^IN_PV_00$", doc="The bath temperature."), - Var( - "external_temperature", - read_pattern="^IN_PV_01$", - doc="The external temperature.", - ), + Var("external_temperature", read_pattern="^IN_PV_01$", doc="The external temperature."), Var("heating_power", read_pattern="^IN_PV_02$", doc="The heating power."), - Var( - "set_point_temperature", - read_pattern="^IN_SP_00$", - doc="The temperature setpoint.", - ), - Cmd( - "set_set_point", - r"^OUT_SP_00 ([0-9]*\.?[0-9]+)$", - argument_mappings=(float,), - ), + Var("set_point_temperature", read_pattern="^IN_SP_00$", doc="The temperature setpoint."), + Cmd("set_set_point", "^OUT_SP_00 ([0-9]*\.?[0-9]+)$", argument_mappings=(float,)), # Read pattern for high limit is different from version 1 Var( "temperature_high_limit", @@ -60,28 +29,16 @@ class JulaboStreamInterfaceV2(StreamInterface): ), Var("version", read_pattern="^VERSION$", doc="The Julabo version."), Var("status", read_pattern="^STATUS$", doc="The Julabo status."), - Var( - "is_circulating", - read_pattern="^IN_MODE_05$", - doc="Whether it is circulating.", - ), + Var("is_circulating", read_pattern="^IN_MODE_05$", doc="Whether it is circulating."), Cmd("set_circulating", "^OUT_MODE_05 (0|1)$", argument_mappings=(int,)), Var("internal_p", read_pattern="^IN_PAR_06$", doc="The internal proportional."), - Cmd( - "set_internal_p", - r"^OUT_PAR_06 ([0-9]*\.?[0-9]+)$", - argument_mappings=(float,), - ), + Cmd("set_internal_p", "^OUT_PAR_06 ([0-9]*\.?[0-9]+)$", argument_mappings=(float,)), Var("internal_i", read_pattern="^IN_PAR_07$", doc="The internal integral."), Cmd("set_internal_i", "^OUT_PAR_07 ([0-9]*)$", argument_mappings=(int,)), Var("internal_d", read_pattern="^IN_PAR_08$", doc="The internal derivative."), Cmd("set_internal_d", "^OUT_PAR_08 ([0-9]*)$", argument_mappings=(int,)), Var("external_p", read_pattern="^IN_PAR_09$", doc="The external proportional."), - Cmd( - "set_external_p", - r"^OUT_PAR_09 ([0-9]*\.?[0-9]+)$", - argument_mappings=(float,), - ), + Cmd("set_external_p", "^OUT_PAR_09 ([0-9]*\.?[0-9]+)$", argument_mappings=(float,)), Var("external_i", read_pattern="^IN_PAR_11$", doc="The external integral."), Cmd("set_external_i", "^OUT_PAR_11 ([0-9]*)$", argument_mappings=(int,)), Var("external_d", read_pattern="^IN_PAR_12$", doc="The external derivative."), diff --git a/lewis/devices/keithley_2001/__init__.py b/lewis/devices/keithley_2001/__init__.py new file mode 100644 index 00000000..4ed3a775 --- /dev/null +++ b/lewis/devices/keithley_2001/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedKeithley2001 + +__all__ = ["SimulatedKeithley2001"] diff --git a/lewis/devices/keithley_2001/buffer.py b/lewis/devices/keithley_2001/buffer.py new file mode 100644 index 00000000..a81523e6 --- /dev/null +++ b/lewis/devices/keithley_2001/buffer.py @@ -0,0 +1,78 @@ +from enum import Enum + + +class Buffer(object): + def __init__(self): + self.buffer = [] + self._size = 100 + self._source = Source.NONE + self._mode = Mode.NEV + self.number_of_times_buffer_cleared = 0 + self._egroup = Egroup.FULL + self.scan_channels = None + + def clear_buffer(self): + self.buffer = [] + self.number_of_times_buffer_cleared += 1 + + @property + def source(self): + return self._source.name + + @source.setter + def source(self, source): + try: + self._source = Source[source] + except KeyError: + raise ValueError("{} is not a valid buffer source.".format(source)) + + @property + def mode(self): + return self._mode.name + + @mode.setter + def mode(self, mode): + try: + self._mode = Mode[mode] + except KeyError: + raise ValueError("{} is not a valid buffer mode.".format(mode)) + + @property + def size(self): + return self._size + + @size.setter + def size(self, size): + if 2 <= size <= 404: + self._size = size + else: + raise ValueError("{} is not a valid buffer size.".format(size)) + + @property + def egroup(self): + return self._egroup.name + + @egroup.setter + def egroup(self, egroup): + try: + self._egroup = Egroup[egroup] + except KeyError: + raise ValueError("{} is not a valid buffer element group.".format(egroup)) + + +class Source(Enum): + NONE = 0 + SENS1 = 1 + CALC1 = 2 + + +class Mode(Enum): + NEV = 0 + NEXT = 1 + ALW = 2 + PRET = 3 + + +class Egroup(Enum): + FULL = 0 + COMP = 1 diff --git a/lewis/devices/keithley_2001/device.py b/lewis/devices/keithley_2001/device.py new file mode 100644 index 00000000..9b8128ce --- /dev/null +++ b/lewis/devices/keithley_2001/device.py @@ -0,0 +1,241 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .buffer import Buffer +from .states import DefaultState +from .utils import Channel, ScanTrigger, StatusRegister + + +class SimulatedKeithley2001(StateMachineDevice): + """Simulated Keithley2700 Multimeter + """ + + number_of_times_device_has_been_reset = 0 + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connect() + self.idn = "KEITHLEY INSTRUMENTS INC.,MODEL 2001,4301578,B17 /A02 " + self.elements = { + "READ": False, + "CHAN": False, + "RNUM": False, + "UNIT": False, + "TIME": False, + "STAT": False, + } + self._channel_readback_format = None + + self.buffer = Buffer() + self.status_register = StatusRegister() + + self.scan_count = 0 + self._scan_trigger_type = ScanTrigger.IMM + self.measurement_scan_count = 0 + + self.continuous_initialisation_status = False + self._channels = { + 1: Channel(1), + 2: Channel(2), + 3: Channel(3), + 4: Channel(4), + 5: Channel(5), + 6: Channel(6), + 7: Channel(7), + 8: Channel(8), + 9: Channel(9), + 10: Channel(10), + } + self.closed_channel = None + self._error = [0, "No error"] + self.number_of_times_ioc_has_been_reset = 0 + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def reset_device(self): + """Resets device to initialized state. + + This does not reset the buffer or status register. + """ + for element in self.elements: + self.elements[element] = False + + self.continuous_initialisation_status = False + + self._channels = { + 1: Channel(1), + 2: Channel(2), + 3: Channel(3), + 4: Channel(4), + 5: Channel(5), + 6: Channel(6), + 7: Channel(7), + 8: Channel(8), + 9: Channel(9), + 10: Channel(10), + } + self.closed_channel = None + self._scan_trigger_type = ScanTrigger.IMM + self._clear_error() + + SimulatedKeithley2001.number_of_times_device_has_been_reset += 1 + + def close_channel(self, channel): + """Closes channel to read from and opens the previously closed channel. + + Args: + channel (int): Channel number to close. + Valid channels are 1,2,3,4,5,6,7,8,9,10. + + Raises: + ValueError if channel is not a valid channel. + """ + channel = int(channel) + try: + if self.closed_channel != channel: + if self.closed_channel is not None: + self._channels[self.closed_channel].close = False + self._channels[channel].close = True + self.closed_channel = channel + except KeyError: + raise ValueError("Channel {} is not a valid channel".format(channel)) + + def take_single_reading(self): + """Takes a single reading from the closed channel. + + Returns: + dict: closed channel reading data containing + READ: channel reading + CHAN: Channel number + UNIT: channel reading unit + """ + channel = self._channels[self.closed_channel] + return { + "READ": channel.reading, + "CHAN": channel.channel, + "READ_UNIT": channel.reading_units, + } + + @property + def scan_trigger_type(self): + """Returns name of the scan trigger type. + """ + return self._scan_trigger_type.name + + def scan_channels(self): + """Generates buffer of readings. + + Each element of the buffer is a dictinoary of values + generated from the channel. + + """ + for channel_to_scan in self.buffer.scan_channels: + channel = self._channels[int(channel_to_scan)] + self.buffer.buffer.append( + { + "READ": channel.reading, + "CHAN": channel.channel, + "READ_UNIT": channel.reading_units, + } + ) + + @property + def error(self): + """Returns the current error status. + + Returns: + list [int, string]: list of integer error code and error message. + """ + return self._error + + def clear_error(self): + """Clears any error + """ + self._error = [0, "No error"] + + def connect(self): + """Connects the device. + """ + self._connected = True + + def disconnect(self): + """Disconnects the device. + """ + self._connected = False + + # Backdoor functions + def get_number_of_times_buffer_has_been_cleared_via_the_backdoor(self): + """Gets the number of times the buffer has been cleared. + Only called via the backdoor. + + Returns: + int: Number of times the buffer has been cleared. + """ + return self.buffer.number_of_times_buffer_cleared + + def get_number_of_times_status_register_has_been_reset_and_cleared_via_the_backdoor(self): + """Gets the number of times the status register has been reset and cleared. + + Only called via the backdoor. + + Returns: + int: Number of times the status register has been reset and cleared. + """ + return self.status_register.number_of_times_reset_and_cleared + + def set_number_of_times_status_register_has_been_reset_and_cleared_via_the_backdoor( + self, value + ): + """Sets the number of times the status register has been reset and cleared. + + Only called via the backdoor. + """ + self.status_register.number_of_times_reset_and_cleared = int(value) + + def set_channel_value_via_the_backdoor(self, channel, value, reading_unit): + """Sets a channel value using Lewis backdoor. + + rgs: + channel (int): Channel number 1,2,3,4,6,7,8, or 9. + value (float): Value to set the channel to + reading_unit (string): Reading unit to set. + """ + self._channels[channel].reading = value + self._channels[channel].reading_units = reading_unit + + def set_error_via_the_backdoor(self, error_code, error_message): + """Sets an error via the using Lewis backdoor. + + Args: + error_code (string): error code number + error_message (string): error message + """ + self._error = [int(error_code), error_message] + + def get_how_many_times_ioc_has_been_reset_via_the_backdoor(self): + """Gets the number of times the ioc has been reset. + + Only called via the backdoor. + + Returns: + int: Number of times the ioc has been reset + """ + return self.number_of_times_ioc_has_been_reset + + def set_how_many_times_ioc_has_been_reset_via_the_backdoor(self, value): + """Sets the number of times the ioc has been reset. + + Only called via the backdoor. + """ + self.number_of_times_ioc_has_been_reset = int(value) diff --git a/lewis/devices/keithley_2001/interfaces/__init__.py b/lewis/devices/keithley_2001/interfaces/__init__.py new file mode 100644 index 00000000..b9854a20 --- /dev/null +++ b/lewis/devices/keithley_2001/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Keithley2001StreamInterface + +__all__ = ["Keithley2001StreamInterface"] diff --git a/lewis/devices/keithley_2001/interfaces/stream_interface.py b/lewis/devices/keithley_2001/interfaces/stream_interface.py new file mode 100644 index 00000000..f4c9dff2 --- /dev/null +++ b/lewis/devices/keithley_2001/interfaces/stream_interface.py @@ -0,0 +1,394 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + + +class Keithley2001StreamInterface(StreamInterface): + in_terminator = "\r\n" + out_terminator = "\n" + + _channel_readback_format = None + + commands = { + # Commands used on setup + CmdBuilder("get_idn").escape("*IDN?").eos().build(), + CmdBuilder("reset_device").escape("*RST").eos().build(), + CmdBuilder("set_buffer_source").escape(":DATA:FEED ").arg("NONE|SENS1|CALC1").eos().build(), + CmdBuilder("get_buffer_source").escape(":DATA:FEED?").eos().build(), + CmdBuilder("set_buffer_egroup").escape(":DATA:EGR ").arg("FULL|COMP").eos().build(), + CmdBuilder("get_buffer_egroup").escape(":DATA:EGR?").eos().build(), + CmdBuilder("set_continuous_initialization") + .escape(":INIT:CONT ") + .arg("OFF|ON") + .eos() + .build(), + CmdBuilder("get_continuous_initialization_status").escape(":INIT:CONT?").eos().build(), + CmdBuilder("get_elements").escape(":FORM:ELEM?").eos().build(), + CmdBuilder("set_elements").escape(":FORM:ELEM ").string().eos().build(), + CmdBuilder("get_measurement_status").escape(":STAT:MEAS:ENAB?").eos().build(), + CmdBuilder("set_buffer_full_status").escape(":STAT:MEAS:ENAB 512").eos().build(), + CmdBuilder("get_service_request_status").escape("*SRE?").eos().build(), + CmdBuilder("set_measure_summary_status").escape("*SRE 1").eos().build(), + CmdBuilder("reset_and_clear_status_registers").escape(":STAT:PRES; *CLS").eos().build(), + CmdBuilder("set_scan_count").escape(":ARM:LAY2:COUN ").int().eos().build(), + CmdBuilder("get_scan_count").escape(":ARM:LAY2:COUN?").eos().build(), + CmdBuilder("get_scan_trigger").escape(":ARM:LAY2:SOUR?").eos().build(), + # Reading a single channel + CmdBuilder("set_read_channel").escape(":ROUT:CLOS (@").int().escape(")").eos().build(), + CmdBuilder("read_single_channel").escape(":READ?").eos().build(), + # Reading from the buffer + CmdBuilder("set_buffer_mode") + .escape(":DATA:FEED:CONT ") + .arg("NEV|NEXT|ALW|PRET") + .eos() + .build(), + CmdBuilder("get_buffer_mode").escape(":DATA:FEED:CONT?").eos().build(), + CmdBuilder("clear_buffer").escape(":DATA:CLE").eos().build(), + CmdBuilder("scan_channels").escape(":INIT").eos().build(), + CmdBuilder("get_buffer_date").escape(":DATA:DATA?").eos().build(), + # Setting up a scan + CmdBuilder("set_measurement_scan_count").escape(":TRIG:COUN ").int().eos().build(), + CmdBuilder("get_measurement_scan_count").escape(":TRIG:COUN?").eos().build(), + CmdBuilder("set_buffer_size").escape(":DATA:POIN ").int().eos().build(), + CmdBuilder("get_buffer_size").escape(":DATA:POIN?").eos().build(), + CmdBuilder("set_scan_channels") + .escape(":ROUT:SCAN (@") + .arg("[0-9,]+") + .escape(")") + .eos() + .build(), + CmdBuilder("get_scan_channels").escape(":ROUT:SCAN?").eos().build(), + # Error handling + CmdBuilder("get_error").escape(":SYST:ERR?").eos().build(), + } + + def handle_error(self, request, error): + self.log.error("An error occurred at request {}: {}".format(repr(request), repr(error))) + print("An error occurred at request {}: {}".format(repr(request), repr(error))) + + # Commands used on setup + @conditional_reply("_connected") + def get_idn(self): + """Returns the devices IDN string. + + Returns: + string: The device's IDN. + """ + idn = self._device.idn + return idn + + @conditional_reply("_connected") + def get_elements(self): + """Returns the lists of elements of a reading in alphabetical order from the device. + + """ + elements = [element for element, value in self._device.elements.items() if value] + return ", ".join(elements) + + @conditional_reply("_connected") + def set_elements(self, string): + """Sets the elements a reading has. + + Args: + string: String of comma separated elements of a reading. Valid elements are: + READ, CHAN, RNUM, UNIT, TIME, STAT. + """ + elements = {element.strip().upper() for element in string.split(",")} + + for element in elements: + try: + self._device.elements[element] = True + except LookupError: + self.log.error( + "Tried to set {} which is not a valid reading element.".format(element) + ) + print("Tried to set {} which is not a valid reading element.".format(element)) + + self._generate_readback_format() + + @conditional_reply("_connected") + def _generate_readback_format(self): + """Generates the readback format for buffer readings. + """ + readback_elements = [] + + if self._device.elements["READ"]: + readback_elements.append("{:.7E}") + if self._device.elements["UNIT"]: + readback_elements.append("{}") + + if self._device.elements["CHAN"]: + readback_elements.append(",{:02d}") + if self._device.elements["UNIT"]: + readback_elements.append("INTCHAN") + + self._channel_readback_format = "".join(readback_elements) + + @conditional_reply("_connected") + def reset_device(self): + """Resets device. + """ + self._device.reset_device() + + @conditional_reply("_connected") + def set_buffer_source(self, source): + """Sets the buffer source. + """ + self._device.buffer.source = source + + @conditional_reply("_connected") + def get_buffer_source(self): + """Gets the buffer source. + """ + return self._device.buffer.source + + @conditional_reply("_connected") + def set_buffer_egroup(self, egroup): + """Sets the buffer element group. + """ + self._device.number_of_times_ioc_has_been_reset += 1 + + self._device.buffer.egroup = egroup + + @conditional_reply("_connected") + def get_buffer_egroup(self): + """Gets the buffer element group. + """ + return self._device.buffer.egroup + + @conditional_reply("_connected") + def set_continuous_initialization(self, value): + """Sets continuous scanning status to ON or OFF. + + Thus is called continuous initialization mode in the Keithley 2001 manual. + + Args: + value (string): ON or OFF. + """ + if value.upper() == "ON": + self._device.continuous_initialisation_status = True + elif value.upper() == "OFF": + self._device.continuous_initialisation_status = False + else: + raise ValueError("Not a valid continuous initialisation mode") + + @conditional_reply("_connected") + def get_continuous_initialization_status(self): + """Gets the continuous scanning status. + + Thus is the continuous initialization mode in the Keithley 2001 manual. + """ + status = "OFF" + + if self._device.continuous_initialisation_status: + status = "ON" + + return status + + @conditional_reply("_connected") + def set_buffer_full_status(self): + """Sets the buffer full status of the status register to true. + """ + self._device.status_register.buffer_full = True + + @conditional_reply("_connected") + def set_measure_summary_status(self): + """Sets the measurement summary status of the status register to true. + """ + self._device.status_register.measurement_summary_status = True + + @conditional_reply("_connected") + def get_measurement_status(self): + """Returns the measurement status of the device. + + Returns: + string: integer which represents the measurement status register status in bits. + """ + status = 0 + if self._device.status_register.buffer_full: + status += 512 + + return str(status) + + @conditional_reply("_connected") + def get_service_request_status(self): + """Returns the measurement status of the device. + + Returns: + string: integer which represents the service register status in bits. + """ + status = 0 + if self._device.status_register.measurement_summary_status: + status += 1 + + return str(status) + + @conditional_reply("_connected") + def reset_and_clear_status_registers(self): + """Resets and clears the status registers of the device. + """ + self._device.status_register.reset_and_clear() + + @conditional_reply("_connected") + def set_scan_count(self, value): + """Sets the scan count. + + Args: + value (int): Number of times to scan. + """ + self._device.scan_count = int(value) + + @conditional_reply("_connected") + def get_scan_count(self): + """Returns the number of times the device is set to scan. + + Returns: + string: Number of times the device is set to scan. + """ + return str(self._device.scan_count) + + @conditional_reply("_connected") + def get_scan_trigger(self): + """Returns the scan trigger type. + + Returns: + string: Scan trigger mode. One of IMM, HOLD, MAN, BUS, TLINK, EXT, TIM. + """ + return self._device.scan_trigger_type + + # Reading a single channel + @conditional_reply("_connected") + def set_read_channel(self, channel): + """Sets the channels to read from in single read mode. + + Args: + channel string): String representation of a channel number between 1 and 10. + """ + self._device.close_channel(int(channel)) + + @conditional_reply("_connected") + def read_single_channel(self): + """Takes a single reading from the closed channel on the device. + + Returns: + string: Formatted string of channel data. + """ + channel_data = self._device.take_single_reading() + + return "".join(self._format_buffer_readings(channel_data)) + + # Setting up for a scan + @conditional_reply("_connected") + def set_buffer_mode(self, mode): + """Sets the buffer mode. + """ + self._device.buffer.mode = mode + + @conditional_reply("_connected") + def get_buffer_mode(self): + """Gets the buffer mode. + """ + return self._device.buffer.mode + + @conditional_reply("_connected") + def set_buffer_size(self, size): + """Sets the buffer mode. + """ + self._device.buffer.size = int(size) + + @conditional_reply("_connected") + def get_buffer_size(self): + """Gets the buffer mode. + """ + return self._device.buffer.size + + @conditional_reply("_connected") + def clear_buffer(self): + """Clears the buffer. + """ + self._device.buffer.clear_buffer() + + @conditional_reply("_connected") + def set_scan_channels(self, channels): + """Sets the channels to scan. + + Args: + channels (string): Comma separated list of channel number to read from. + """ + channels = channels.split(",") + self._device.buffer.scan_channels = channels + + @conditional_reply("_connected") + def get_scan_channels(self): + """Returns the channels set to scan. + + Returns: + string: comman separated list of channels set to scan + """ + return "(@" + ",".join(self._device.buffer.scan_channels) + ")" + + @conditional_reply("_connected") + def set_measurement_scan_count(self, value): + """Sets the measurement scan count. + + Args: + value (int): Number of times to trigger measurements. + """ + self._device.measurement_scan_count = int(value) + + @conditional_reply("_connected") + def get_measurement_scan_count(self): + """Gets the measurement scan count. + + Returns: + value (int): Number of times to trigger measurements + """ + return str(self._device.measurement_scan_count) + + @conditional_reply("_connected") + def scan_channels(self): + """Sets the device to scan. + + """ + self._device.scan_channels() + + @conditional_reply("_connected") + def get_buffer_date(self): + """Returns the buffer data. + + Returns: + list of strings: List of readings from channels. + """ + return ",".join(map(self._format_buffer_readings, self._device.buffer.buffer)) + + def _format_buffer_readings(self, reading): + """Formats a reading. + + Args: + reading: dictionary with keys + "READ", "READ_UNIT", "CHAN" + + Returns: + string: Buffer reading formatted depending on elements + """ + formatted_buffer_reading = [] + + if self._device.elements["READ"]: + formatted_buffer_reading.append(reading["READ"]) + if self._device.elements["UNIT"]: + formatted_buffer_reading.append(reading["READ_UNIT"]) + + if self._device.elements["CHAN"]: + formatted_buffer_reading.append(reading["CHAN"]) + + return self._channel_readback_format.format(*formatted_buffer_reading) + + @conditional_reply("_connected") + def get_error(self): + """Returns the error code and message. + + Returns: + (string): Returns the error string formed of + error code, error message. + """ + return ",".join(["{}".format(self._device.error[0]), self._device.error[1]]) diff --git a/lewis/devices/keithley_2001/states.py b/lewis/devices/keithley_2001/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/keithley_2001/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/keithley_2001/utils.py b/lewis/devices/keithley_2001/utils.py new file mode 100644 index 00000000..3806dbc5 --- /dev/null +++ b/lewis/devices/keithley_2001/utils.py @@ -0,0 +1,36 @@ +from enum import Enum + + +class Channel(object): + def __init__(self, channel): + self.channel = channel + self.reading = 0 + self.reading_units = "VDC" + self.close = False + + +class StatusRegister(object): + def __init__(self): + self.buffer_full = False + self.measurement_summary_status = False + self.number_of_times_reset_and_cleared = 0 + + def reset_and_clear(self): + self.buffer_full = False + self.measurement_summary_status = False + self.number_of_times_reset_and_cleared += 1 + + +class ScanTrigger(Enum): + IMM = 0 + HOLD = 1 + MAN = 2 + BUS = 3 + TLIN = 4 + EXT = 5 + TIM = 6 + + +class ReadStatus(Enum): + SINGLE = 0 + MULIT = 1 diff --git a/lewis/devices/keithley_2400/__init__.py b/lewis/devices/keithley_2400/__init__.py new file mode 100644 index 00000000..8042bea1 --- /dev/null +++ b/lewis/devices/keithley_2400/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedKeithley2400 + +__all__ = ["SimulatedKeithley2400"] diff --git a/lewis/devices/keithley_2400/control_modes.py b/lewis/devices/keithley_2400/control_modes.py new file mode 100644 index 00000000..ba70fd59 --- /dev/null +++ b/lewis/devices/keithley_2400/control_modes.py @@ -0,0 +1,53 @@ +"""Effectively enumerators for each of the various modes supported by the device. Allows checking for invalid mode +strings and helps avoid typos. +""" + + +class Mode(object): + MODES = [] + + +class ResistanceRangeMode(Mode): + AUTO = "1" + MANUAL = "0" + MODES = [AUTO, MANUAL] + + +class AutorangeMode(Mode): + AUTO = "1" + MANUAL = "0" + MODES = [AUTO, MANUAL] + + +class SourceMode(Mode): + CURRENT = "CURR" + VOLTAGE = "VOLT" + MODES = [CURRENT, VOLTAGE] + + +class AutoMode(Mode): + AUTO = "AUTO" + MANUAL = "MAN" + MODES = [AUTO, MANUAL] + + +class ResistanceMode(AutoMode): + pass + + +class OnOffMode(Mode): + ON = "1" + OFF = "0" + MODES = [ON, OFF] + + +class RemoteSensingMode(OnOffMode): + pass + + +class OffsetCompensationMode(OnOffMode): + pass + + +class OutputMode(OnOffMode): + pass diff --git a/lewis/devices/keithley_2400/device.py b/lewis/devices/keithley_2400/device.py new file mode 100644 index 00000000..c04fea0e --- /dev/null +++ b/lewis/devices/keithley_2400/device.py @@ -0,0 +1,290 @@ +from __future__ import division + +from collections import OrderedDict +from random import uniform + +from lewis.devices import StateMachineDevice + +from .control_modes import * +from .states import DefaultRunningState, StaticRunningState +from .utilities import format_value + + +class SimulatedKeithley2400(StateMachineDevice): + INITIAL_CURRENT = 0.1 + INITIAL_CURRENT_COMPLIANCE = INITIAL_CURRENT + INITIAL_VOLTAGE = 10.0 + INITIAL_VOLTAGE_COMPLIANCE = INITIAL_VOLTAGE + MINIMUM_CURRENT = 1.0e-20 + RESISTANCE_RANGE_MULTIPLIER = 2.1 + + INITIAL_SOURCE_CURRENT = 1.0e-4 + INITIAL_SOURCE_VOLTAGE = 0.8 + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.random_output = True + + # Power properties + self.current = SimulatedKeithley2400.INITIAL_CURRENT + self.voltage = SimulatedKeithley2400.INITIAL_VOLTAGE + + self._source_current = SimulatedKeithley2400.INITIAL_SOURCE_CURRENT + self._source_voltage = SimulatedKeithley2400.INITIAL_SOURCE_VOLTAGE + + # Modes + self._output_mode = OutputMode.OFF + self._offset_compensation_mode = OffsetCompensationMode.OFF + self._resistance_mode = ResistanceMode.AUTO + self._remote_sensing_mode = RemoteSensingMode.OFF + self._resistance_range_mode = ResistanceRangeMode.AUTO + + self._source_mode = SourceMode.CURRENT + + # Ranges + + self._source_current_autorange_mode = AutorangeMode.AUTO + self._source_voltage_autorange_mode = AutorangeMode.AUTO + + self._source_current_range = SimulatedKeithley2400.INITIAL_CURRENT + self._source_voltage_range = SimulatedKeithley2400.INITIAL_VOLTAGE + + self._measured_current_autorange_mode = AutorangeMode.AUTO + self._measured_voltage_autorange_mode = AutorangeMode.AUTO + + self._measured_current_range = SimulatedKeithley2400.INITIAL_CURRENT + self._measured_voltage_range = SimulatedKeithley2400.INITIAL_VOLTAGE + + # Mode settings + self._resistance_range = SimulatedKeithley2400.RESISTANCE_RANGE_MULTIPLIER + self._current_compliance = SimulatedKeithley2400.INITIAL_CURRENT_COMPLIANCE + self._voltage_compliance = SimulatedKeithley2400.INITIAL_VOLTAGE_COMPLIANCE + + def _get_state_handlers(self): + return { + "running": DefaultRunningState(), + "static": StaticRunningState(), + } + + def _get_initial_state(self): + return "static" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("static", "running"), lambda: self.random_output), + (("running", "static"), lambda: not self.random_output), + ] + ) + + def _resistance(self): + # The device only tracks current and voltage. Resistance is calculated as a dependent variable + r = self.voltage / self.current + return ( + min(r, self._resistance_range) + if self._resistance_range_mode == ResistanceRangeMode.MANUAL + else r + ) + + def _format_power_output(self, value, as_string, offset=0.0): + """Some properties like output mode and offset compensation affect the output without affecting the underlying + model. Those adjustments are applied here. + """ + output_value = value + if self._offset_compensation_mode == OffsetCompensationMode.ON: + output_value -= offset + return format_value(output_value, as_string) + + def set_voltage(self, value): + self.voltage = value + + def set_current(self, value): + self.current = value + + def get_voltage(self, as_string=False): + return self._format_power_output( + self.voltage, as_string, SimulatedKeithley2400.INITIAL_VOLTAGE + ) + + def get_current(self, as_string=False): + return self._format_power_output( + self.current, as_string, SimulatedKeithley2400.INITIAL_CURRENT + ) + + def get_resistance(self, as_string=False): + return self._format_power_output(self._resistance(), as_string) + + def update(self, dt): + """Update the current and voltage values based on the current mode and time elapsed. + """ + + def update_value(value): + return abs(value + uniform(-1, 1) * dt) + + new_current = max(update_value(self.current), SimulatedKeithley2400.MINIMUM_CURRENT) + new_voltage = update_value(self.voltage) + + if self._resistance_mode == ResistanceMode.MANUAL: + # Restrict the current if we're in current compliance mode. Similarly for voltage + if new_current < self.current_compliance or self._source_mode == SourceMode.VOLTAGE: + self.current = new_current + if new_voltage < self._voltage_compliance or self._source_mode == SourceMode.CURRENT: + self.voltage = new_voltage + elif self._resistance_mode == ResistanceMode.AUTO: + self.current = new_current + self.voltage = new_voltage + + def reset(self): + """Set all the attributes back to their initial values. + """ + self._initialize_data() + + @staticmethod + def _check_mode(mode, mode_class): + """Make sure the mode requested exists in the related class. + """ + if mode in mode_class.MODES: + return True + else: + print("Invalid mode, {}, received for: {}".format(mode, mode_class.__name__)) + return False + + def set_output_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, OutputMode): + self._output_mode = mode + + def get_output_mode(self): + return self._output_mode + + def set_offset_compensation_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, OffsetCompensationMode): + self._offset_compensation_mode = mode + + def get_offset_compensation_mode(self): + return self._offset_compensation_mode + + def set_resistance_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, ResistanceMode): + self._resistance_mode = mode + + def get_resistance_mode(self): + return self._resistance_mode + + def set_remote_sensing_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, RemoteSensingMode): + self._remote_sensing_mode = mode + # Output switched off when remote sensing mode changed + self._output_mode = OutputMode.OFF + + def get_remote_sensing_mode(self): + return self._remote_sensing_mode + + def set_resistance_range_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, ResistanceRangeMode): + self._resistance_range_mode = mode + + def get_resistance_range_mode(self): + return self._resistance_range_mode + + def set_source_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, SourceMode): + self._source_mode = mode + + def get_source_mode(self): + return self._source_mode + + def set_resistance_range(self, value): + self.log.info("Setting resistance range to {}".format(value)) + from math import pow + + # Set the resistance range to the smallest value of 2.1En the requested + # value exceeds + self._resistance_range = SimulatedKeithley2400.RESISTANCE_RANGE_MULTIPLIER + for r in [ + SimulatedKeithley2400.RESISTANCE_RANGE_MULTIPLIER * pow(10, i) for i in range(1, 8) + ]: + if value < r: + self._resistance_range = r / 10 + break + # Resistance range mode set to manual when range set + self._resistance_range_mode = ResistanceRangeMode.MANUAL + + def get_resistance_range(self): + return self._resistance_range + + def set_current_compliance(self, value): + self._current_compliance = value + + def get_current_compliance(self): + return self._current_compliance + + def set_voltage_compliance(self, value): + self._voltage_compliance = value + + def get_voltage_compliance(self): + return self._voltage_compliance + + def get_source_voltage(self): + return self._source_voltage + + def set_source_voltage(self, value): + self._source_voltage = value + + def get_source_current(self): + return self._source_current + + def set_source_current(self, value): + self._source_current = value + + def get_source_current_autorange_mode(self): + return self._source_current_autorange_mode + + def set_source_current_autorange_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, AutorangeMode): + self._source_current_autorange_mode = mode + + def get_source_voltage_autorange_mode(self): + return self._source_voltage_autorange_mode + + def set_source_voltage_autorange_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, AutorangeMode): + self._source_voltage_autorange_mode = mode + + def get_source_current_range(self): + return self._source_current_range + + def set_source_current_range(self, value): + self._source_current_range = value + + def get_source_voltage_range(self): + return self._source_voltage_range + + def set_source_voltage_range(self, value): + self._source_voltage_range = value + + def set_measured_voltage_autorange_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, AutorangeMode): + self._measured_voltage_autorange_mode = mode + + def get_measured_voltage_autorange_mode(self): + return self._measured_voltage_autorange_mode + + def set_measured_current_autorange_mode(self, mode): + if SimulatedKeithley2400._check_mode(mode, AutorangeMode): + self._measured_current_autorange_mode = mode + + def get_measured_current_autorange_mode(self): + return self._measured_current_autorange_mode + + def get_measured_current_range(self): + return self._measured_current_range + + def set_measured_current_range(self, value): + self._measured_current_range = value + + def get_measured_voltage_range(self): + return self._measured_voltage_range + + def set_measured_voltage_range(self, value): + self._measured_voltage_range = value diff --git a/lewis/devices/keithley_2400/interfaces/__init__.py b/lewis/devices/keithley_2400/interfaces/__init__.py new file mode 100644 index 00000000..b2ecf659 --- /dev/null +++ b/lewis/devices/keithley_2400/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Keithley2400StreamInterface + +__all__ = ["Keithley2400StreamInterface"] diff --git a/lewis/devices/keithley_2400/interfaces/stream_interface.py b/lewis/devices/keithley_2400/interfaces/stream_interface.py new file mode 100644 index 00000000..4a28b7a9 --- /dev/null +++ b/lewis/devices/keithley_2400/interfaces/stream_interface.py @@ -0,0 +1,260 @@ +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from ..control_modes import OutputMode + +SCI_NOTATION_REGEX = r"[-+]?[0-9]*\.?[0-9]*e?[-+]?[0-9]+" + + +@has_log +class Keithley2400StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + serial_commands = { + CmdBuilder("get_values").escape(":READ?").build(), + CmdBuilder("get_values").escape(":MEAS:VOLT?").build(), + CmdBuilder("get_values").escape(":MEAS:CURR?").build(), + CmdBuilder("get_values").escape(":MEAS:RES?").build(), + CmdBuilder("reset").escape("*RST").build(), + CmdBuilder("identify").escape("*IDN?").build(), + CmdBuilder("get_output_mode").escape(":OUTP?").build(), + CmdBuilder("set_output_mode").escape(":OUTP ").enum("0", "1").build(), + CmdBuilder("get_offset_compensation_mode").escape(":SENS:RES:OCOM?").build(), + CmdBuilder("set_offset_compensation_mode").escape(":SENS:RES:OCOM ").enum("0", "1").build(), + CmdBuilder("get_resistance_mode").escape(":SENS:RES:MODE?").build(), + CmdBuilder("set_resistance_mode").escape(":SENS:RES:MODE ").enum("AUTO", "MAN").build(), + CmdBuilder("get_remote_sensing_mode").escape(":SYST:RSEN?").build(), + CmdBuilder("set_remote_sensing_mode").escape(":SYST:RSEN ").enum("0", "1").build(), + CmdBuilder("get_resistance_range_mode").escape(":SENS:RES:RANG:AUTO?").build(), + CmdBuilder("set_resistance_range_mode") + .escape(":SENS:RES:RANG:AUTO ") + .enum("0", "1") + .build(), + CmdBuilder("get_resistance_range").escape(":SENS:RES:RANG?").build(), + CmdBuilder("set_resistance_range") + .escape(":SENS:RES:RANG ") + .arg(SCI_NOTATION_REGEX) + .build(), + CmdBuilder("get_source_mode").escape(":SOUR:FUNC?").build(), + CmdBuilder("set_source_mode").escape(":SOUR:FUNC ").enum("CURR", "VOLT").build(), + CmdBuilder("get_current_compliance").escape(":SENS:CURR:PROT?").build(), + CmdBuilder("set_current_compliance") + .escape(":SENS:CURR:PROT ") + .arg(SCI_NOTATION_REGEX) + .build(), + CmdBuilder("get_voltage_compliance").escape(":SENS:VOLT:PROT?").build(), + CmdBuilder("set_voltage_compliance") + .escape(":SENS:VOLT:PROT ") + .arg(SCI_NOTATION_REGEX) + .build(), + CmdBuilder("get_source_voltage").escape(":SOUR:VOLT:LEV?").build(), + CmdBuilder("set_source_voltage").escape(":SOUR:VOLT:LEV ").arg(SCI_NOTATION_REGEX).build(), + CmdBuilder("get_source_current").escape(":SOUR:CURR:LEV?").build(), + CmdBuilder("set_source_current").escape(":SOUR:CURR:LEV ").arg(SCI_NOTATION_REGEX).build(), + CmdBuilder("get_source_current_autorange_mode").escape(":SOUR:CURR:RANG:AUTO?").build(), + CmdBuilder("set_source_current_autorange_mode") + .escape(":SOUR:CURR:RANG:AUTO ") + .enum("0", "1") + .build(), + CmdBuilder("get_source_voltage_autorange_mode").escape(":SOUR:VOLT:RANG:AUTO?").build(), + CmdBuilder("set_source_voltage_autorange_mode") + .escape(":SOUR:VOLT:RANG:AUTO ") + .enum("0", "1") + .build(), + CmdBuilder("get_source_current_range").escape(":SOUR:CURR:RANG?").build(), + CmdBuilder("set_source_current_range") + .escape(":SOUR:CURR:RANG ") + .arg(SCI_NOTATION_REGEX) + .build(), + CmdBuilder("get_source_voltage_range").escape(":SOUR:VOLT:RANG?").build(), + CmdBuilder("set_source_voltage_range") + .escape(":SOUR:VOLT:RANG ") + .arg(SCI_NOTATION_REGEX) + .build(), + CmdBuilder("get_measured_voltage_autorange_mode").escape(":SENS:VOLT:RANG:AUTO?").build(), + CmdBuilder("set_measured_voltage_autorange_mode") + .escape(":SENS:VOLT:RANG:AUTO ") + .enum("0", "1") + .build(), + CmdBuilder("get_measured_current_autorange_mode").escape(":SENS:CURR:RANG:AUTO?").build(), + CmdBuilder("set_measured_current_autorange_mode") + .escape(":SENS:CURR:RANG:AUTO ") + .enum("0", "1") + .build(), + CmdBuilder("get_measured_current_range").escape(":SENS:CURR:RANG?").build(), + CmdBuilder("set_measured_current_range") + .escape(":SENS:CURR:RANG ") + .arg(SCI_NOTATION_REGEX) + .build(), + CmdBuilder("get_measured_voltage_range").escape(":SENS:VOLT:RANG?").build(), + CmdBuilder("set_measured_voltage_range") + .escape(":SENS:VOLT:RANG ") + .arg(SCI_NOTATION_REGEX) + .build(), + } + + # Private control commands that can be used as an alternative to the lewis backdoor + control_commands = { + Cmd("set_voltage", "^\:_CTRL:VOLT\s([-+]?[0-9]*\.?[0-9]+)$"), + Cmd("set_current", "^\:_CTRL:CURR\s([-+]?[0-9]*\.?[0-9]+)$"), + } + + commands = set.union(serial_commands, control_commands) + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def get_values(self): + """Get the current, voltage and resistance readings + + :return: A string of 3 doubles: voltage, current, resistance. In that order + """ + return ( + ", ".join( + [ + self._device.get_voltage(as_string=True), + self._device.get_current(as_string=True), + self._device.get_resistance(as_string=True), + ] + ) + if self._device.get_output_mode() == OutputMode.ON + else None + ) + + def reset(self): + """Resets the device. + """ + self._device.reset() + return "*RST" + + def identify(self): + """Replies with the device's identity. + """ + return "Keithley 2400 Source Meter emulator" + + def set_current(self, value): + self._device.set_current(float(value)) + return "Current set to: " + str(value) + + def set_voltage(self, value): + self._device.set_voltage(float(value)) + return "Voltage set to: " + str(value) + + def _set_mode(self, set_method, mode, command): + """The generic form of how mode sets are executed and responded to. + """ + set_method(mode) + + return command + " " + mode + + def set_output_mode(self, new_mode): + return self._set_mode(self._device.set_output_mode, new_mode, "OUTP:") + + def get_output_mode(self): + return self._device.get_output_mode() + + def set_offset_compensation_mode(self, new_mode): + return self._set_mode(self._device.set_offset_compensation_mode, new_mode, ":SENS:RES:OCOM") + + def get_offset_compensation_mode(self): + return self._device.get_offset_compensation_mode() + + def set_resistance_mode(self, new_mode): + return self._set_mode(self._device.set_resistance_mode, new_mode, ":SENS:RES:MODE") + + def get_resistance_mode(self): + return self._device.get_resistance_mode() + + def set_remote_sensing_mode(self, new_mode): + return self._set_mode(self._device.set_remote_sensing_mode, new_mode, ":SYST:RSEN") + + def get_remote_sensing_mode(self): + return self._device.get_remote_sensing_mode() + + def set_resistance_range_mode(self, new_mode): + return self._set_mode( + self._device.set_resistance_range_mode, new_mode, ":SENS:RES:RANG:AUTO" + ) + + def get_resistance_range_mode(self): + return self._device.get_resistance_range_mode() + + def set_resistance_range(self, value): + return self._device.set_resistance_range(float(value)) + + def get_resistance_range(self): + return self._device.get_resistance_range() + + def set_source_mode(self, new_mode): + return self._set_mode(self._device.set_source_mode, new_mode, ":SOUR:FUNC") + + def get_source_mode(self): + return self._device.get_source_mode() + + def set_current_compliance(self, value): + return self._device.set_current_compliance(float(value)) + + def get_current_compliance(self): + return self._device.get_current_compliance() + + def set_voltage_compliance(self, value): + return self._device.set_voltage_compliance(float(value)) + + def get_voltage_compliance(self): + return self._device.get_voltage_compliance() + + def set_source_voltage(self, value): + return self._device.set_source_voltage(float(value)) + + def get_source_voltage(self): + return self._device.get_source_voltage() + + def set_source_current(self, value): + return self._device.set_source_current(float(value)) + + def get_source_current(self): + return self._device.get_source_current() + + def get_source_current_autorange_mode(self): + return self._device.get_source_current_autorange_mode() + + def set_source_current_autorange_mode(self, value): + return self._device.set_source_current_autorange_mode(value) + + def get_source_voltage_autorange_mode(self): + return self._device.get_source_voltage_autorange_mode() + + def set_source_voltage_autorange_mode(self, value): + return self._device.set_source_voltage_autorange_mode(value) + + @has_log + def handle_error(self, request, error): + err = "An error occurred at request {}: {}".format(str(request), str(error)) + print(err) + self.log.info(err) + return str(err) + + def set_source_current_range(self, value): + return self._device.set_source_current_range(float(value)) + + def get_source_current_range(self): + return self._device.get_source_current_range() + + def set_source_voltage_range(self, value): + return self._device.set_source_voltage_range(float(value)) + + def get_source_voltage_range(self): + return self._device.get_source_voltage_range() + + def get_measured_voltage_autorange_mode(self): + return self._device.get_measured_voltage_autorange_mode() + + def set_measured_voltage_autorange_mode(self, value): + return self._device.set_measured_voltage_autorange_mode(value) + + def get_measured_current_autorange_mode(self): + return self._device.get_measured_current_autorange_mode() + + def set_measured_current_autorange_mode(self, value): + val = self._device.set_measured_current_autorange_mode(value) + return val diff --git a/lewis/devices/keithley_2400/states.py b/lewis/devices/keithley_2400/states.py new file mode 100644 index 00000000..bb93820d --- /dev/null +++ b/lewis/devices/keithley_2400/states.py @@ -0,0 +1,17 @@ +from lewis.core.statemachine import State + + +class StaticRunningState(State): + """This state does not emulate a randomly changing output value + """ + + def in_state(self, dt): + pass + + +class DefaultRunningState(State): + """The current and voltage measurements while in this state randomly fluctuate + """ + + def in_state(self, dt): + self._context.update(dt) diff --git a/lewis/devices/keithley_2400/utilities.py b/lewis/devices/keithley_2400/utilities.py new file mode 100644 index 00000000..6f9c2517 --- /dev/null +++ b/lewis/devices/keithley_2400/utilities.py @@ -0,0 +1,4 @@ +def format_value(f, as_string): + """Format a floating point value into either a string or return it as is. + """ + return "{0:.3f}".format(f) if as_string else f diff --git a/lewis/devices/kepco/__init__.py b/lewis/devices/kepco/__init__.py new file mode 100644 index 00000000..1bae5390 --- /dev/null +++ b/lewis/devices/kepco/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedKepco + +__all__ = ["SimulatedKepco"] diff --git a/lewis/devices/kepco/device.py b/lewis/devices/kepco/device.py new file mode 100644 index 00000000..b4d23197 --- /dev/null +++ b/lewis/devices/kepco/device.py @@ -0,0 +1,197 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedKepco(StateMachineDevice): + """Simulated Kepco + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.reset_count = 0 + self._idn_no_firmware = "KEPCO,BOP 50-20,E1234," + self._firmware = 2.6 + self._init_data() + + def _init_data(self): + """Initialise device data. + """ + self.voltage_set_count = 0 + self.current_set_count = 0 + self._voltage = 10.0 + self._current = 10.0 + self._setpoint_voltage = 10.0 + self._setpoint_current = 10.0 + self.output_mode_set_count = 0 + self.output_status_set_count = 0 + self._output_mode = 0 + self._output_status = 0 + self.connected = True + self.auto_voltage_range = 1 + self.auto_current_range = 1 + self._voltage_range = 1 + self._current_range = 1 + + self.remote_comms_enabled = True + + def reset(self): + """Reset the device, reinitialising the data. + :return: + """ + self.reset_count += 1 + self._init_data() + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() + + @property + def idn(self): + """:return: IDN- Identification String + """ + return self._idn_no_firmware + str(self._firmware) + + @property + def idn_no_firmware(self): + """:return: IDN- Identification String + """ + return self._idn_no_firmware + + @idn_no_firmware.setter + def idn_no_firmware(self, idn_no_firmware): + """:param idn_no_firmware: + :return: sets IDN without the firmware- Identification String + """ + self._idn_no_firmware = idn_no_firmware + + @property + def firmware(self): + """:return: IDN- Identification String + """ + return self._firmware + + @firmware.setter + def firmware(self, firmware): + """:param firmware: + :return: sets the firmware of the device (part of the IDN) + """ + self._firmware = firmware + + @property + def voltage(self): + """Returns: the Voltage + """ + return self._voltage + + @voltage.setter + def voltage(self, voltage): + """:param voltage: Write the Voltage + """ + self._voltage = voltage + + @property + def current(self): + """:return: get the Current + """ + return self._current + + @current.setter + def current(self, current): + """:param write the current: + """ + self._current = current + + @property + def setpoint_voltage(self): + """Returns: the Setpoint Voltage + """ + return self._setpoint_voltage + + @setpoint_voltage.setter + def setpoint_voltage(self, setpoint_voltage): + """:param setpoint_voltage: set the Setpoint Voltage + :return: + """ + self.voltage_set_count += 1 + self._setpoint_voltage = setpoint_voltage + + @property + def setpoint_current(self): + """Returns: the Setpoint Current + """ + return self._setpoint_current + + @setpoint_current.setter + def setpoint_current(self, setpoint_current): + """:param setpoint_current: set the setpoint current + :return: + """ + self.current_set_count += 1 + self._setpoint_current = setpoint_current + + @property + def output_mode(self): + """:return: Returns the output mode + """ + return self._output_mode + + @output_mode.setter + def output_mode(self, mode): + """:param mode: Set output mode + """ + self.output_mode_set_count += 1 + self._output_mode = mode + + @property + def output_status(self): + """:return: Output status + """ + return self._output_status + + @output_status.setter + def output_status(self, status): + """:param status: set Output status + """ + self.output_status_set_count += 1 + self._output_status = status + + @property + def voltage_range(self): + """Returns: the Voltage range + """ + return self._voltage_range + + @voltage_range.setter + def voltage_range(self, range): + """:param range: the Voltage range + """ + self._voltage_range = range + self.auto_voltage_range = 0 + + @property + def current_range(self): + """Returns: the Currrent range + """ + return self._current_range + + @current_range.setter + def current_range(self, range): + """:param range: the Current range + """ + self._current_range = range + self.auto_current_range = 0 diff --git a/lewis/devices/kepco/interfaces/__init__.py b/lewis/devices/kepco/interfaces/__init__.py new file mode 100644 index 00000000..fd4a64e0 --- /dev/null +++ b/lewis/devices/kepco/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .kepco import KepcoStreamInterface + +__all__ = ["KepcoStreamInterface"] diff --git a/lewis/devices/kepco/interfaces/kepco.py b/lewis/devices/kepco/interfaces/kepco.py new file mode 100644 index 00000000..212dc58a --- /dev/null +++ b/lewis/devices/kepco/interfaces/kepco.py @@ -0,0 +1,149 @@ +from functools import wraps + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +def needs_remote_mode(func): + wraps(func) + + def _wrapper(self, *args, **kwargs): + if not self._device.remote_comms_enabled: + raise ValueError("Not in remote mode") + return func(self, *args, **kwargs) + + return _wrapper + + +@has_log +class KepcoStreamInterface(StreamInterface): + in_terminator = "\n" + out_terminator = "\r\n" + + commands = { + CmdBuilder("write_voltage").escape("VOLT ").float().build(), + CmdBuilder("read_actual_voltage").escape("MEAS:VOLT?").build(), + CmdBuilder("read_actual_current").escape("MEAS:CURR?").build(), + CmdBuilder("write_current").escape("CURR ").float().build(), + CmdBuilder("read_setpoint_voltage").escape("VOLT?").build(), + CmdBuilder("read_setpoint_current").escape("CURR?").build(), + CmdBuilder("set_output_mode").escape("FUNC:MODE ").arg("VOLT|CURR").build(), + CmdBuilder("read_output_mode").escape("FUNC:MODE?").build(), + CmdBuilder("read_output_status").escape("OUTP?").build(), + CmdBuilder("set_output_status").escape("OUTP ").arg("0|1").build(), + CmdBuilder("get_IDN").escape("*IDN?").build(), + CmdBuilder("set_control_mode").escape("SYST:REM ").arg("0|1").build(), + CmdBuilder("reset").escape("*RST").build(), + CmdBuilder("get_current_range").escape("CURR:RANG?").build(), + CmdBuilder("get_voltage_range").escape("VOLT:RANG?").build(), + CmdBuilder("set_current_range").escape("CURR:RANG ").int().build(), + CmdBuilder("set_voltage_range").escape("VOLT:RANG ").int().build(), + CmdBuilder("set_auto_current_range").escape("CURR:RANG:AUTO ").int().build(), + CmdBuilder("set_auto_voltage_range").escape("VOLT:RANG:AUTO ").int().build(), + } + + def handle_error(self, request, error): + self.log.error("An error occurred at request" + repr(request) + ": " + repr(error)) + print("An error occurred at request" + repr(request) + ": " + repr(error)) + + @if_connected + def read_actual_voltage(self): + return "{0}".format(self._device.voltage) + + @if_connected + def read_actual_current(self): + return "{0}".format(self._device.current) + + @if_connected + @needs_remote_mode + def write_voltage(self, voltage): + self._device.setpoint_voltage = voltage + + @if_connected + @needs_remote_mode + def write_current(self, current): + self._device.setpoint_current = current + + @if_connected + def read_setpoint_voltage(self): + return "{0}".format(self._device.setpoint_voltage) + + @if_connected + def read_setpoint_current(self): + return "{0}".format(self._device.setpoint_current) + + @if_connected + @needs_remote_mode + def set_output_mode(self, mode): + self._device.output_mode = 0 if mode.startswith("VOLT") else 1 + + @if_connected + def read_output_mode(self): + return "{0}".format(self._device.output_mode) + + @if_connected + @needs_remote_mode + def set_output_status(self, status): + self._device.output_status = status + + @if_connected + def read_output_status(self): + return "{0}".format(self._device.output_status) + + @if_connected + def get_IDN(self): + return "{0}".format(self._device.idn) + + @if_connected + def set_control_mode(self, mode): + if self._device.firmware <= 2.0: + raise ValueError("No SYST:REM command available") + else: + mode = int(mode) + if mode not in [0, 1]: + raise ValueError("Invalid mode in set_control_mode: {}".format(mode)) + self._device.remote_comms_enabled = mode == 1 + + @if_connected + def reset(self): + self._device.reset() + + @if_connected + def get_current_range(self): + return f"{self._device.current_range}" + + @if_connected + def get_voltage_range(self): + return f"{self._device.voltage_range}" + + @if_connected + def set_current_range(self, range): + if range == 1 or range == 4: + self._device.current_range = range + else: + raise ValueError(f"Invalid current range {range}") + + @if_connected + def set_voltage_range(self, range): + if range == 1 or range == 4: + self._device.voltage_range = range + else: + raise ValueError(f"Invalid voltage range {range}") + + @if_connected + def set_auto_current_range(self, range): + if range == 0 or range == 1: + self._device.auto_current_range = range + else: + raise ValueError(f"Invalid auto current range {range}") + + @if_connected + def set_auto_voltage_range(self, range): + if range == 0 or range == 1: + self._device.auto_voltage_range = range + else: + raise ValueError(f"Invalid auto voltage range {range}") diff --git a/lewis/devices/kepco/states.py b/lewis/devices/kepco/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/kepco/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/keylkg/__init__.py b/lewis/devices/keylkg/__init__.py new file mode 100644 index 00000000..b6abddcd --- /dev/null +++ b/lewis/devices/keylkg/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedKeylkg + +__all__ = ["SimulatedKeylkg"] diff --git a/lewis/devices/keylkg/device.py b/lewis/devices/keylkg/device.py new file mode 100644 index 00000000..0b0d8bf3 --- /dev/null +++ b/lewis/devices/keylkg/device.py @@ -0,0 +1,38 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .interfaces.stream_interface import Modes +from .states import DefaultState + + +class SimulatedKeylkg(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.input_correct = True + + self.mode = Modes.MEASURE + + self.detector_1_offset = 0.0 + self.detector_2_offset = 0.0 + self.detector_1_raw_value = 0.0 + self.detector_2_raw_value = 0.0 + + self.detector_1_measurement_mode = 0 + self.detector_2_measurement_mode = 0 + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def reset(self): + self._initialize_data() diff --git a/lewis/devices/keylkg/interfaces/__init__.py b/lewis/devices/keylkg/interfaces/__init__.py new file mode 100644 index 00000000..cc2fddbb --- /dev/null +++ b/lewis/devices/keylkg/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import KeylkgStreamInterface + +__all__ = ["KeylkgStreamInterface"] diff --git a/lewis/devices/keylkg/interfaces/stream_interface.py b/lewis/devices/keylkg/interfaces/stream_interface.py new file mode 100644 index 00000000..497afd80 --- /dev/null +++ b/lewis/devices/keylkg/interfaces/stream_interface.py @@ -0,0 +1,141 @@ +from enum import Enum + +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") +if_input_error = conditional_reply("input_correct", "ER,OF,00") + + +class Modes(Enum): + """Device Modes + """ + + MEASURE = "R0" # Read measured values + SET_UP = "Q0" # Configure device parameters + + +class KeylkgStreamInterface(StreamInterface): + terminator = "\r" + + def __init__(self): + super(KeylkgStreamInterface, self).__init__() + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.set_mode).arg("Q0|R0").eos().build(), + CmdBuilder(self.set_measurement_offset) + .escape("SW,OF,") + .int() + .escape(",") + .float() + .eos() + .build(), + CmdBuilder(self.get_measurement_offset).escape("SR,OF,").float().eos().build(), + CmdBuilder(self.get_measurement_mode).escape("SR,HB,").int().eos().build(), + CmdBuilder(self.set_measurement_mode) + .escape("SW,HB,") + .int() + .escape(",") + .int() + .eos() + .build(), + CmdBuilder(self.get_measurement_value).escape("M").int().eos().build(), + CmdBuilder(self.reset_measurement).escape("VR,").int().eos().build(), + } + + @if_connected + def reset_measurement(self, measurement_head): + if self.device.mode == "Q0": + return "ER,VR,01" + else: + if measurement_head == 0: + self.device.detector_1_raw_value = 0.0000 + self.device.detector_2_raw_value = 0.0000 + elif measurement_head == 1: + self.device.detector_1_raw_value = 0.0000 + elif measurement_head == 2: + self.device.detector_2_raw_value = 0.0000 + return "VR" + + @if_connected + def get_measurement_value(self, measurement_head): + detector_1_value = self.device.detector_1_raw_value - self.device.detector_1_offset + detector_2_value = self.device.detector_2_raw_value - self.device.detector_2_offset + + if measurement_head == 0: + return ( + "ER,M0,01" + if self.device.mode == Modes.SET_UP + else "M0,{0:+08.4f},{1:+08.4f}".format(detector_1_value, detector_2_value) + ) + elif measurement_head == 1: + return ( + "ER,M1,01" + if self.device.mode == Modes.SET_UP + else "M1,{:+08.4f}".format(detector_1_value) + ) + elif measurement_head == 2: + return ( + "ER,M2,01" + if self.device.mode == Modes.SET_UP + else "M2,{:+08.4f}".format(detector_2_value) + ) + + @if_connected + def set_measurement_mode(self, measurement_head, function): + if self.device.mode == "R0": + return "ER,HB,01" + else: + if measurement_head == 1: + self.device.detector_1_measurement_mode = function + elif measurement_head == 2: + self.device.detector_2_measurement_mode = function + return "SW,HB" + + @if_connected + def get_measurement_mode(self, measurement_head): + if self.device.mode == "R0": + return "ER,HB,01" + else: + if measurement_head == 1: + return "SR,HB,1,{0}".format(self.device.detector_1_measurement_mode) + elif measurement_head == 2: + return "SR,HB,2,{0}".format(self.device.detector_2_measurement_mode) + + @if_connected + @if_input_error + def set_measurement_offset(self, measurement_head, offset_value): + # The device requires a +07d formatted input that is converted to within the + # (-99.999, 99.000) limits. We convert this so that our read back will be correct. + converted_value = offset_value * 10e-5 + + if self.device.mode == "R0": + return "ER,OF,01" + else: + if measurement_head == 0: + self.device.detector_1_offset = converted_value + self.device.detector_2_offset = converted_value + elif measurement_head == 1: + self.device.detector_1_offset = converted_value + elif measurement_head == 2: + self.device.detector_2_offset = converted_value + return "SW,OF" + + @if_connected + def get_measurement_offset(self, measurement_head): + if self.device.mode == "R0": + return "ER,OF,01" + else: + if measurement_head == 1: + return "SR,OF,1,{:+08.4f}".format(self.device.detector_1_offset) + elif measurement_head == 2: + return "SR,OF,2,{:+08.4f}".format(self.device.detector_2_offset) + + @if_connected + def set_mode(self, new_mode): + if self.device.mode == new_mode: + return "ER,{mode},01".format(mode=new_mode) + else: + self.device.mode = Modes(new_mode) + return new_mode diff --git a/lewis/devices/keylkg/states.py b/lewis/devices/keylkg/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/keylkg/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/knr1050/__init__.py b/lewis/devices/knr1050/__init__.py new file mode 100644 index 00000000..b986f290 --- /dev/null +++ b/lewis/devices/knr1050/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedKnr1050 + +__all__ = ["SimulatedKnr1050"] diff --git a/lewis/devices/knr1050/device.py b/lewis/devices/knr1050/device.py new file mode 100644 index 00000000..e129b304 --- /dev/null +++ b/lewis/devices/knr1050/device.py @@ -0,0 +1,87 @@ +import time +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import ( + HoldState, + IdleState, + InitializingState, + OffState, + PurgeState, + RunState, + StandbyState, +) + +states = OrderedDict( + [ + ("INITIALIZING", InitializingState()), + ("OFF", OffState()), + ("IDLE", IdleState()), + ("RUN", RunState()), + ("HOLD", HoldState()), + ("PURGE", PurgeState()), + ("STANDBY", StandbyState()), + ] +) + + +class SimulatedKnr1050(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.input_correct = True + + self.pump_on = False + self.keep_last_values = False + self.hold = False + self.standby = False + self.initializing = False + self.remote = False + + self.pressure = 0 + self.pressure_limit_low = 0 + self.pressure_limit_high = 100 + self.flow_rate = 0.01 + self.current_flow_rate = 0.0 + + self.concentrations = [100, 0, 0, 0] + + self.curr_program_run_time = False + self.error_string = "" + + def reset(self): + self._initialize_data() + + @property + def time_stamp(self): + """Returns: + (int) current time in ms + """ + return int(round(time.time() * 1000)) + + @property + def state_num(self): + return list(states.keys()).index(self.state) + + @property + def state(self): + return self._csm.state + + def _get_state_handlers(self): + return states + + def _get_initial_state(self): + return "OFF" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("INITIALIZING", "IDLE"), lambda: self.initializing is False), + (("IDLE", "OFF"), lambda: self.pump_on is False), + (("OFF", "IDLE"), lambda: self.pump_on is True), + (("RUN", "HOLD"), lambda: self.hold is True), + (("RUN", "STANDBY"), lambda: self.standby is True), + ] + ) diff --git a/lewis/devices/knr1050/interfaces/__init__.py b/lewis/devices/knr1050/interfaces/__init__.py new file mode 100644 index 00000000..51b1ec93 --- /dev/null +++ b/lewis/devices/knr1050/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Knr1050StreamInterface + +__all__ = ["Knr1050StreamInterface"] diff --git a/lewis/devices/knr1050/interfaces/stream_interface.py b/lewis/devices/knr1050/interfaces/stream_interface.py new file mode 100644 index 00000000..15fd14db --- /dev/null +++ b/lewis/devices/knr1050/interfaces/stream_interface.py @@ -0,0 +1,141 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") +if_input_error = conditional_reply("input_correct", "ERROR:20,Instrument in standalone mode") + + +@has_log +class Knr1050StreamInterface(StreamInterface): + in_terminator = "\r" + out_terminator = "\r" + + def __init__(self): + super(Knr1050StreamInterface, self).__init__() + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.get_status).escape("STATUS?").eos().build(), + CmdBuilder(self.start_pump) + .escape("RAMP:0,") + .int() + .escape(",") + .int() + .escape(",") + .int() + .escape(",") + .int() + .escape(",") + .int() + .eos() + .build(), + CmdBuilder(self.stop_pump).escape("STOP:1,0").eos().build(), + CmdBuilder(self.stop_klv).escape("STOP:2").eos().build(), + CmdBuilder(self.get_pressure_limits).escape("PLIM?").eos().build(), + CmdBuilder(self.set_pressure_limits) + .escape("PLIM:") + .int() + .escape(",") + .int() + .eos() + .build(), + CmdBuilder(self.get_remote_mode).escape("REMOTE?").eos().build(), + CmdBuilder(self.set_remote_mode).escape("REMOTE").eos().build(), + CmdBuilder(self.set_local_mode).escape("LOCAL").eos().build(), + } + + def handle_error(self, request, error): + """If command is not recognised print and error + Args: + request: requested string + error: problem + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + @if_connected + def start_pump(self, flow_rate, a, b, c, d): + """Executes ramp starting from current execution time. + + Args: + flow_rate (float): the flow rate in ul/min + a (int): concentration A + b (int): concentration B + c (int): concentration C + d (int): concentration D + """ + self.device.flow_rate = flow_rate + self.device.current_flow_rate = flow_rate + # crude pressure simulation + self.device.pressure = ( + int(self.device.pressure_limit_high) - int(self.device.pressure_limit_low) // 2 + ) + self.device.concentrations = [int(a), int(b), int(c), int(d)] + + self.device.pump_on = True + + self.stop_klv() + return "OK" + + @if_connected + def stop_pump(self): + """Stop mode: Stop time table and data acquisition. + """ + self.device.pump_on = False + self.device.keep_last_values = False + + self.device.current_flow_rate = 0.0 + self.device.pressure = 0 + return "OK" + + @if_connected + def stop_klv(self): + """Stop mode: Keep last values. + """ + self.device.keep_last_values = True + return "OK" + + @if_connected + @if_input_error + def get_pressure_limits(self): + return "PLIM:{},{}".format(self.device.pressure_limit_low, self.device.pressure_limit_high) + + @if_connected + def set_pressure_limits(self, low, high): + """Set the pressure limits on the device + Args: + low (int): The lower bound + high (int): The upper bound + + Returns: + 'OK' (str) : Device confirmation + """ + self.device.pressure_limit_low = int(low) + self.device.pressure_limit_high = int(high) + return "OK" + + @if_connected + def get_status(self): + return_params = [ + self.device.time_stamp, + self.device.state_num, + 1 if self.device.curr_program_run_time else "", + self.device.current_flow_rate, + ] + return_params.extend(self.device.concentrations) + return_params.append(self.device.pressure) + return "STATUS:{},{},0,{},{},{},{},{},{},0,0,0,0,0,0,0,0,{},0,0".format(*return_params) + + @if_connected + def get_remote_mode(self): + return "REMOTE:{}".format(1 if self.device.remote else 0) + + @if_connected + def set_remote_mode(self): + self.device.remote = True + return "OK" + + @if_connected + def set_local_mode(self): + self.device.remote = False + return "OK" diff --git a/lewis/devices/knr1050/states.py b/lewis/devices/knr1050/states.py new file mode 100644 index 00000000..a74198f3 --- /dev/null +++ b/lewis/devices/knr1050/states.py @@ -0,0 +1,29 @@ +from lewis.core.statemachine import State + + +class InitializingState(State): + pass + + +class OffState(State): + pass + + +class IdleState(State): + pass + + +class RunState(State): + pass + + +class HoldState(State): + pass + + +class PurgeState(State): + pass + + +class StandbyState(State): + pass diff --git a/lewis/devices/kynctm3k/__init__.py b/lewis/devices/kynctm3k/__init__.py new file mode 100644 index 00000000..88446112 --- /dev/null +++ b/lewis/devices/kynctm3k/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedKynctm3K + +__all__ = ["SimulatedKynctm3K"] diff --git a/lewis/devices/kynctm3k/device.py b/lewis/devices/kynctm3k/device.py new file mode 100644 index 00000000..c75e126f --- /dev/null +++ b/lewis/devices/kynctm3k/device.py @@ -0,0 +1,169 @@ +import random +from collections import OrderedDict +from functools import wraps + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +def truncate_if_set(f): + """Truncates the decorated function's string output if truncated_output is True + + """ + + @wraps(f) + def wrapper(self, *args, **kwargs): + output = f(self, *args, **kwargs) + + if self.truncated_output: + output = output[: int(round(len(output) / 2.0))] + + return output + + return wrapper + + +@has_log +def fake_auto_send(f): + """Changes the decorated functions's string output to a simulate a device in auto-send mode + + """ + + @wraps(f) + def wrapper(self, *args, **kwargs): + output = f(self, *args, **kwargs) + + if self.auto_send: + output = "TG,{:02d},+FFFFFFF".format(random.randint(1, 4)) + + return output + + return wrapper + + +@has_log +class SimulatedKynctm3K(StateMachineDevice): + INPUT_MODES = ("R0", "R1", "Q0") + + def _initialize_data(self): + """Initialize all of the device's attributes. + + OUT_values contains the measurement values to be returned. A False value is considered to not + be in the program, and will not be returned. + """ + self.OUT_values = None + self.truncated_output = False + self.auto_send = False + self.input_mode = "R0" + + pass + + def reset_device(self): + """Resets the device to a known state. This can be confirmed when the OUT channels equal -256.0 + + Returns: None + + """ + self._initialize_data() + self.OUT_values = ["off"] * 16 + + return None + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def set_autosend_status(self, new_state): + """Sets the autosend status between True (autosend on) and False (autosend off) + + Args: + new_state: Boolean, the new autosend state + + Returns: + A string acknowledging the status change, or an error if the device is in the wrong state to change the setting + + """ + try: + assert isinstance(new_state, int) + except AssertionError: + return "ER,SW,08" + + if self.input_mode in ("R0", "R1"): + # Cannot change the autosend in this input mode + return "ER,SW,01" + + else: + self.auto_send = bool(new_state) + return "SW,EA" + + def set_input_mode(self, new_state): + """Changes the state of the device to a measurment screen (R0/R1) or a RS232C comms mode (Q0) + + Args: + new_state: String, denoting measurement screen (R0/R1) or RS232C mode (Q0) + + Returns: + new_state: String. Either the name of the new state, or an error code if the new state was not recognised + + """ + if new_state in self.INPUT_MODES: + self.input_mode = new_state + return new_state + else: + return "ER,{:.2},01".format(new_state) + + def parse_status(self, output_setting): + """Converts the status for one OUT channel to a formatted string + Args: + output_setting: String or float. If float, then output is on. If off or out_of_bounds, then a formatted string will be returned + + Returns: + OUT_string: String. Contains the measurement value if on, or XXXXXXXX/FFFFFFF as appropriate if off or out of range + + """ + out_of_range_return = "FFFFFFF" + off_return = "XXXXXXXX" + + if output_setting == "off": + return off_return + + elif output_setting == "out_of_range": + # Add a random sign to the out of range string + sign = random.sample(("+", "-"), 1)[0] + return sign + out_of_range_return + + elif type(output_setting) is float: + return "{:+08.3f}".format(output_setting) + + else: + return off_return + + @fake_auto_send + @truncate_if_set + def format_output_data(self): + """Recalls and formats the measurement values + + Returns: + A string containing the measurement values for the current program, formatted as per the user manual + + """ + if self.OUT_values is None: + return None + else: + channel_strings = [ + "MM,1111111111111111", + ] + for channel, output_value in enumerate(self.OUT_values): + # Only return output if the OUT value is in the program + channel_strings.append(self.parse_status(output_value)) + + return ",".join(channel_strings) diff --git a/lewis/devices/kynctm3k/interfaces/__init__.py b/lewis/devices/kynctm3k/interfaces/__init__.py new file mode 100644 index 00000000..47232f57 --- /dev/null +++ b/lewis/devices/kynctm3k/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Kynctm3KStreamInterface + +__all__ = ["Kynctm3KStreamInterface"] diff --git a/lewis/devices/kynctm3k/interfaces/stream_interface.py b/lewis/devices/kynctm3k/interfaces/stream_interface.py new file mode 100644 index 00000000..f77fb33d --- /dev/null +++ b/lewis/devices/kynctm3k/interfaces/stream_interface.py @@ -0,0 +1,32 @@ +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + + +@has_log +class Kynctm3KStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + Cmd("return_data", "MM,1111111111111111$"), + CmdBuilder("change_input_mode").enum("Q0", "R0", "R1").build(), + CmdBuilder("toggle_autosend").escape("SW,EA,").enum("1", "0").build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def return_data(self): + return_data = self._device.format_output_data() + self.log.info("Returning {}".format(return_data)) + return return_data + + def change_input_mode(self, new_state): + return self._device.set_input_mode(new_state) + + def toggle_autosend(self, new_state): + return self._device.set_autosend_status(int(new_state)) + + def handle_error(self, request, error): + err = "An error occurred at request {}: {}".format(request, error) + self.log.error(err) + return str(err) diff --git a/lewis/devices/kynctm3k/states.py b/lewis/devices/kynctm3k/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/kynctm3k/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/lakeshore340/__init__.py b/lewis/devices/lakeshore340/__init__.py new file mode 100644 index 00000000..5c5ce388 --- /dev/null +++ b/lewis/devices/lakeshore340/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedLakeshore340 + +__all__ = ["SimulatedLakeshore340"] diff --git a/lewis/devices/lakeshore340/device.py b/lewis/devices/lakeshore340/device.py new file mode 100644 index 00000000..a3eff7d8 --- /dev/null +++ b/lewis/devices/lakeshore340/device.py @@ -0,0 +1,40 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedLakeshore340(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.temp_a = 0 + self.temp_b = 0 + self.temp_c = 0 + self.temp_d = 0 + self.measurement_a = 0 + self.measurement_b = 0 + self.measurement_c = 0 + self.measurement_d = 0 + + self.tset = 0 + + self.p, self.i, self.d = 0, 0, 0 + + self.pid_mode = 1 + self.loop_on = True + + self.max_temp = 0 + self.heater_output = 0 + self.heater_range = 0 + self.excitationa = 0 + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/lakeshore340/interfaces/__init__.py b/lewis/devices/lakeshore340/interfaces/__init__.py new file mode 100644 index 00000000..7b70bea8 --- /dev/null +++ b/lewis/devices/lakeshore340/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Lakeshore340StreamInterface + +__all__ = ["Lakeshore340StreamInterface"] diff --git a/lewis/devices/lakeshore340/interfaces/stream_interface.py b/lewis/devices/lakeshore340/interfaces/stream_interface.py new file mode 100644 index 00000000..0d816c40 --- /dev/null +++ b/lewis/devices/lakeshore340/interfaces/stream_interface.py @@ -0,0 +1,156 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +_CONTROL_CHANNEL, _CONTROL_CHANNEL_INDEX = "B", 1 +_SENSOR_UNITS = 1 +_POWERUPENABLE = 1 + + +@has_log +class Lakeshore340StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_temperature_a").escape("KRDG? 0").eos().build(), + CmdBuilder("get_temperature_b").escape("KRDG? 1").eos().build(), + CmdBuilder("get_temperature_c").escape("KRDG? 2").eos().build(), + CmdBuilder("get_temperature_d").escape("KRDG? 3").eos().build(), + CmdBuilder("get_measurement_a").escape("SRDG? 0").eos().build(), + CmdBuilder("get_measurement_b").escape("SRDG? 1").eos().build(), + CmdBuilder("get_measurement_c").escape("SRDG? 2").eos().build(), + CmdBuilder("get_measurement_d").escape("SRDG? 3").eos().build(), + CmdBuilder("set_tset") + .escape("SETP {},".format(_CONTROL_CHANNEL_INDEX)) + .float() + .eos() + .build(), + CmdBuilder("get_tset").escape("SETP? {}".format(_CONTROL_CHANNEL_INDEX)).eos().build(), + CmdBuilder("set_pid") + .escape("PID {},".format(_CONTROL_CHANNEL_INDEX)) + .float() + .escape(",") + .float() + .escape(",") + .int() + .eos() + .build(), + CmdBuilder("get_pid").escape("PID? {}".format(_CONTROL_CHANNEL_INDEX)).eos().build(), + CmdBuilder("set_pid_mode") + .escape("CMODE {},".format(_CONTROL_CHANNEL_INDEX)) + .int() + .eos() + .build(), + CmdBuilder("get_pid_mode").escape("CMODE? {}".format(_CONTROL_CHANNEL_INDEX)).eos().build(), + CmdBuilder("set_control_mode") + .escape("CSET {},{},{},".format(_CONTROL_CHANNEL_INDEX, _CONTROL_CHANNEL, _SENSOR_UNITS)) + .int() + .escape(",{}".format(_POWERUPENABLE)) + .eos() + .build(), + CmdBuilder("get_control_mode") + .escape("CSET? {}".format(_CONTROL_CHANNEL_INDEX)) + .eos() + .build(), + CmdBuilder("set_temp_limit") + .escape("CLIMIT {},".format(_CONTROL_CHANNEL_INDEX)) + .float() + .eos() + .build(), + CmdBuilder("get_temp_limit") + .escape("CLIMIT? {}".format(_CONTROL_CHANNEL_INDEX)) + .eos() + .build(), + CmdBuilder("get_heater_output").escape("HTR?").eos().build(), + CmdBuilder("set_heater_range").escape("RANGE ").int().eos().build(), + CmdBuilder("get_heater_range").escape("RANGE?").eos().build(), + CmdBuilder("get_excitation_a").escape("INTYPE? A").eos().build(), + CmdBuilder("set_excitation_a").escape("INTYPE A, 1, , , , ").int().eos().build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def get_temperature_a(self): + return self._device.temp_a + + def get_temperature_b(self): + return self._device.temp_b + + def get_temperature_c(self): + return self._device.temp_c + + def get_temperature_d(self): + return self._device.temp_d + + def get_measurement_a(self): + return self._device.measurement_a + + def get_measurement_b(self): + return self._device.measurement_b + + def get_measurement_c(self): + return self._device.measurement_c + + def get_measurement_d(self): + return self._device.measurement_d + + def set_tset(self, val): + self._device.tset = float(val) + + def get_tset(self): + return self._device.tset + + def set_pid(self, p, i, d): + self._device.p, self._device.i, self._device.d = p, i, d + + def get_pid(self): + return "{},{},{}".format(self._device.p, self._device.i, self._device.d) + + def get_pid_mode(self): + return self._device.pid_mode + + def set_pid_mode(self, mode): + if not 1 <= mode <= 6: + raise ValueError("Mode must be 1-6") + self._device.pid_mode = mode + + def get_control_mode(self): + return "{},{},{},{}".format( + _CONTROL_CHANNEL, _SENSOR_UNITS, 1 if self._device.loop_on else 0, _POWERUPENABLE + ) + + def set_control_mode(self, val): + self._device.loop_on = bool(val) + + def set_temp_limit(self, val): + self._device.max_temp = val + + def get_temp_limit(self): + return "{},0,0,0,0".format(self._device.max_temp) + + def get_heater_output(self): + return "{:.2f}".format(self._device.heater_output) + + def get_heater_range(self): + return self._device.heater_range + + def set_heater_range(self, val): + if not 0 <= val <= 5: + raise ValueError("Heater range must be 0-5") + self._device.heater_range = val + + def get_excitation_a(self): + return self._device.excitationa + + def set_excitation_a(self, val): + if not 0 <= val <= 12: + raise ValueError("Excitations range must be 0-12") + self._device.excitationa = val diff --git a/lewis/devices/lakeshore340/states.py b/lewis/devices/lakeshore340/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/lakeshore340/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/lakeshore372/__init__.py b/lewis/devices/lakeshore372/__init__.py new file mode 100644 index 00000000..91bb1b0b --- /dev/null +++ b/lewis/devices/lakeshore372/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedLakeshore372 + +__all__ = ["SimulatedLakeshore372"] diff --git a/lewis/devices/lakeshore372/device.py b/lewis/devices/lakeshore372/device.py new file mode 100644 index 00000000..b8772450 --- /dev/null +++ b/lewis/devices/lakeshore372/device.py @@ -0,0 +1,31 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedLakeshore372(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.temperature = 0 + self.heater_range = 0 + self.heater_power = 0 + self.sensor_resistance = 0 + self.control_mode = 0 + + self.p = 0 + self.i = 0 + self.d = 0 + + self.connected = True + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/lakeshore372/interfaces/__init__.py b/lewis/devices/lakeshore372/interfaces/__init__.py new file mode 100644 index 00000000..7854b7f8 --- /dev/null +++ b/lewis/devices/lakeshore372/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Lakeshore372StreamInterface + +__all__ = ["Lakeshore372StreamInterface"] diff --git a/lewis/devices/lakeshore372/interfaces/stream_interface.py b/lewis/devices/lakeshore372/interfaces/stream_interface.py new file mode 100644 index 00000000..55d34d4f --- /dev/null +++ b/lewis/devices/lakeshore372/interfaces/stream_interface.py @@ -0,0 +1,121 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +OUTMODE_INPUT = 1 +OUTMODE_POWERUPENABLE = 2 +OUTMODE_POLARITY = 3 +OUTMODE_FILTER = 4 +OUTMODE_DELAY = 5 + + +@has_log +class Lakeshore372StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_tset").escape("SETP? 0").eos().build(), + CmdBuilder("set_tset").escape("SETP 0 ").float().eos().build(), + CmdBuilder("get_temperature").escape("RDGK? A").eos().build(), + CmdBuilder("get_resistance").escape("RDGR? A").eos().build(), + CmdBuilder("get_heater_range").escape("RANGE? 0").eos().build(), + CmdBuilder("set_heater_range").escape("RANGE 0,").int().eos().build(), + CmdBuilder("get_heater_power").escape("HTR?").eos().build(), + CmdBuilder("get_pid").escape("PID? ").optional("0").eos().build(), + CmdBuilder("set_pid") + .escape("PID ") + .float() + .escape(",") + .float() + .escape(",") + .float() + .eos() + .build(), + CmdBuilder("get_outmode").escape("OUTMODE? 0").eos().build(), + CmdBuilder("set_outmode") + .escape("OUTMODE 0,") + .int() + .escape(",") + .int() + .escape(",") + .int() + .escape(",") + .int() + .escape(",") + .int() + .escape(",") + .int() + .eos() + .build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def set_tset(self, temperature): + self._device.temperature = temperature + + @if_connected + def get_tset(self): + return "{:.3f}".format(self._device.temperature) + + @if_connected + def get_temperature(self): + return "{:.3f}".format(self._device.temperature) + + @if_connected + def get_resistance(self): + return "{:.6f}".format(self._device.sensor_resistance) + + def set_heater_range(self, heater_range): + self._device.heater_range = heater_range + + @if_connected + def get_heater_range(self): + return "{:d}".format(self._device.heater_range) + + @if_connected + def get_heater_power(self): + return "{:.3f}".format(self._device.heater_power) + + @if_connected + def get_pid(self): + return "{:.6f},{:d},{:d}".format(self._device.p, self._device.i, self._device.d) + + def set_pid(self, p, i, d): + self._device.p = p + self._device.i = int(round(i)) + self._device.d = int(round(d)) + + @if_connected + def get_outmode(self): + return "{:d},{:d},{:d},{:d},{:d},{:d}".format( + self._device.control_mode, + OUTMODE_INPUT, + OUTMODE_POWERUPENABLE, + OUTMODE_POLARITY, + OUTMODE_FILTER, + OUTMODE_DELAY, + ) + + def set_outmode(self, control_mode, inp, powerup_enable, polarity, filt, delay): + if ( + inp != OUTMODE_INPUT + or powerup_enable != OUTMODE_POWERUPENABLE + or polarity != OUTMODE_POLARITY + or filt != OUTMODE_FILTER + or delay != OUTMODE_DELAY + ): + raise ValueError("Invalid parameters sent to set_outmode") + self._device.control_mode = control_mode diff --git a/lewis/devices/lakeshore372/states.py b/lewis/devices/lakeshore372/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/lakeshore372/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/lakeshore460/__init__.py b/lewis/devices/lakeshore460/__init__.py new file mode 100644 index 00000000..61d03bac --- /dev/null +++ b/lewis/devices/lakeshore460/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedLakeshore460 + +__all__ = ["SimulatedLakeshore460"] diff --git a/lewis/devices/lakeshore460/device.py b/lewis/devices/lakeshore460/device.py new file mode 100644 index 00000000..7211a748 --- /dev/null +++ b/lewis/devices/lakeshore460/device.py @@ -0,0 +1,170 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class Channel(object): + def __init__(self): + self.field_reading = 1.234 + self.field_multiplier = " " + self.max_hold_reading = 0 + self.max_hold_multiplier = " " + self.rel_mode_reading = 0.5645 + self.rel_mode_multiplier = " " + self.mode = 0 + self.prms = 0 + self.filter_status = 0 + self.rel_mode_status = 0 + self.auto_mode_status = 0 + self.max_hold_status = 0 + self.channel_status = 0 + self.filter_windows = 5 + self.filter_points = 32 + self.manual_range = 1 + self.relative_setpoint = 1.123 + self.relative_setpoint_multiplier = "u" + + +class SimulatedLakeshore460(StateMachineDevice): + """Simulated Lakeshore 460 + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.idn = "LSCI,MODEL460,0,22323" + self.source = 1 + self.channels = {"X": Channel(), "Y": Channel(), "Z": Channel(), "V": Channel()} + self.channel = "X" + self.unit = "T" + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() + + # This is a workaround for https://github.com/DMSC-Instrument-Data/lewis/issues/248 + def set_channel_param(self, channel, param, value): + setattr(self.channels[str(channel)], str(param), value) + + # This is a workaround for https://github.com/DMSC-Instrument-Data/lewis/issues/248 + def get_channel_param(self, channel, param): + return getattr(self.channels[str(channel)], str(param)) + + def update_reading_and_multiplier(self, reading, multiplier): + """Args: + reading: A reading from the device. + multiplier: The current multiplier for the reading. + + Returns: + new_reading: Updated reading value, based on more appropriate multiplier + new_multiplier: updated multiplier for the value + """ + stripped_reading = self.strip_multiplier(reading, multiplier) + new_multiplier = self.calculate_multiplier(stripped_reading) + new_reading = self.apply_multiplier(stripped_reading, new_multiplier) + return new_reading, new_multiplier + + def strip_multiplier(self, reading, multiplier): + """Args: + reading: A reading from the device with multiplier applied. + multiplier: The current multiplier for the reading. + + Returns: + The raw reading. + """ + if multiplier == "u": + return reading * 0.000001 + if multiplier == "m": + return reading * 0.001 + if multiplier == "k": + return reading * 1000 + else: + return reading + + def apply_multiplier(self, reading, multiplier): + """Args: + reading: A raw reading from the device. + multiplier: The multiplier to be applied. + + Returns: + The reading with the multiplier applied. + """ + if multiplier == "u": + return reading / 0.000001 + if multiplier == "m": + return reading / 0.001 + if multiplier == "k": + return reading / 1000 + else: + return reading + + def convert_units(self, convert_value): + """Converts between Tesla and Gauss (applies conversion of *10000 or *0.0001) + Then updates reading values according to the more appropriate multiplier + + Args: + convert_value: 10000 (converting to gauss) or 0.0001 (to Tesla). + + Returns: + None. + """ + channels = ["X", "Y", "Z", "V"] + for c in channels: + self.channel = c + self.channels[c].field_reading *= convert_value + self.channels[c].field_reading, self.channels[c].field_multiplier = ( + self.update_reading_and_multiplier( + self.channels[c].field_reading, self.channels[c].field_multiplier + ) + ) + self.channels[c].max_hold_reading *= convert_value + self.channels[c].max_hold_reading, self.channels[c].max_hold_multiplier = ( + self.update_reading_and_multiplier( + self.channels[c].max_hold_reading, self.channels[c].max_hold_multiplier + ) + ) + self.channels[c].rel_mode_reading *= convert_value + self.channels[c].rel_mode_reading, self.channels[c].rel_mode_multiplier = ( + self.update_reading_and_multiplier( + self.channels[c].rel_mode_reading, self.channels[c].rel_mode_multiplier + ) + ) + self.channels[c].relative_setpoint *= convert_value + self.channels[c].relative_setpoint, self.channels[c].relative_setpoint_multiplier = ( + self.update_reading_and_multiplier( + self.channels[c].relative_setpoint, + self.channels[c].relative_setpoint_multiplier, + ) + ) + + def calculate_multiplier(self, reading): + """Calculates the most appropriate multiplier for a given value. + + Args: + reading: A raw reading from the device. + + Returns: + The most appropriate multiplier value for the given raw reading. + + """ + if reading <= 0.001: + return "u" + if 0.001 < reading <= 0: + return "m" + if 0 < reading < 1000: + return " " + else: + return "k" diff --git a/lewis/devices/lakeshore460/interfaces/__init__.py b/lewis/devices/lakeshore460/interfaces/__init__.py new file mode 100644 index 00000000..a568a8db --- /dev/null +++ b/lewis/devices/lakeshore460/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Lakeshore460StreamInterface + +__all__ = ["Lakeshore460StreamInterface"] diff --git a/lewis/devices/lakeshore460/interfaces/stream_interface.py b/lewis/devices/lakeshore460/interfaces/stream_interface.py new file mode 100644 index 00000000..7400f30d --- /dev/null +++ b/lewis/devices/lakeshore460/interfaces/stream_interface.py @@ -0,0 +1,200 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + + +@has_log +class Lakeshore460StreamInterface(StreamInterface): + """Stream interface for the serial port + """ + + in_terminator = "\r\n" + out_terminator = "\r\n" + + commands = { + # get_multicmds splits commands by ';' if multiple command strings are received + CmdBuilder("get_multicmds", arg_sep="").arg("[^;]+").escape(";").arg(".*").build(), + CmdBuilder("get_idn").escape("*IDN?").build(), + CmdBuilder("get_source").escape("VSRC?").build(), + CmdBuilder("set_source").escape("VSRC ").digit().build(), + CmdBuilder("get_channel").escape("CHNL?").build(), + CmdBuilder("set_channel").escape("CHNL ").arg("X|Y|Z|V").eos().build(), + CmdBuilder("get_magnetic_field_reading").escape("FIELD?").build(), + CmdBuilder("get_magnetic_field_reading_multiplier").escape("FIELDM?").build(), + CmdBuilder("get_max_hold_reading").escape("MAXR?").build(), + CmdBuilder("get_max_hold_reading_multiplier").escape("MAXRM?").build(), + CmdBuilder("get_relative_mode_reading").escape("RELR?").build(), + CmdBuilder("get_relative_mode_multiplier").escape("RELRM?").build(), + CmdBuilder("get_unit").escape("UNIT?").build(), + CmdBuilder("set_unit").escape("UNIT ").arg("G|T").build(), + CmdBuilder("get_ac_dc_mode").escape("ACDC?").build(), + CmdBuilder("set_ac_dc_mode").escape("ACDC ").arg("0|1").build(), + CmdBuilder("get_prms_reading").escape("PRMS?").build(), + CmdBuilder("set_prms_reading").escape("PRMS ").arg("0|1").build(), + CmdBuilder("set_filter_status").escape("FILT ").arg("0|1").build(), + CmdBuilder("get_filter_status").escape("FILT?").build(), + CmdBuilder("set_relative_mode_status").escape("REL ").arg("0|1").build(), + CmdBuilder("get_relative_mode_status").escape("REL?").build(), + CmdBuilder("get_relative_setpoint").escape("RELS?").build(), + CmdBuilder("set_relative_setpoint").escape("RELS ").float().build(), + CmdBuilder("get_relative_setpoint_multiplier").escape("RELSM?").build(), + CmdBuilder("set_auto_mode_status").escape("AUTO ").arg("0|1").build(), + CmdBuilder("get_auto_mode_status").escape("AUTO?").build(), + CmdBuilder("set_max_hold_status").escape("MAX ").arg("0|1").build(), + CmdBuilder("get_max_hold_status").escape("MAX?").build(), + CmdBuilder("get_channel_status").escape("ONOFF?").build(), + CmdBuilder("set_channel_status").escape("ONOFF ").arg("0|1").build(), + CmdBuilder("get_filter_windows").escape("FWIN?").build(), + CmdBuilder("set_filter_windows").escape("FWIN ").int().build(), + CmdBuilder("set_filter_points").escape("FNUM ").int().build(), + CmdBuilder("get_filter_points").escape("FNUM?").build(), + CmdBuilder("get_manual_range").escape("RANGE?").build(), + CmdBuilder("set_manual_range").escape("RANGE ").int().build(), + } + + def handle_error(self, request, error): + self.log.error("An error occurred at request" + repr(request) + ": " + repr(error)) + print("An error occurred at request" + repr(request) + ": " + repr(error)) + + def get_idn(self): + return "{0}".format(self._device.idn) + + def set_source(self, source): + self._device.source = source + + def get_source(self): + return "{0}".format(self._device.source) + + def set_channel(self, channel): + self._device.channel = channel + + def get_channel(self): + return "{0}".format(self._device.channel) + + def get_magnetic_field_reading(self): + field_reading = self._device.channels[self.get_channel()].field_reading + multiplier = self._device.channels[self.get_channel()].field_multiplier + + # Update max_hold_reading if field_reading is larger + if field_reading > self._device.channels[self.get_channel()].max_hold_reading: + self._device.channels[self.get_channel()].max_hold_reading = field_reading + self._device.channels[self.get_channel()].max_hold_reading_multiplier = multiplier + + return "{0}".format(self._device.channels[self.get_channel()].field_reading) + + def get_magnetic_field_reading_multiplier(self): + return "{0}".format(self._device.channels[self.get_channel()].field_multiplier) + + def get_max_hold_reading(self): + return "{0}".format(self._device.channels[self.get_channel()].max_hold_reading) + + def get_max_hold_reading_multiplier(self): + return "{0}".format(self._device.channels[self.get_channel()].max_hold_multiplier) + + def get_relative_mode_reading(self): + return "{0}".format(self._device.channels[self.get_channel()].rel_mode_reading) + + def get_relative_mode_multiplier(self): + return "{0}".format(self._device.channels[self.get_channel()].rel_mode_multiplier) + + def get_unit(self): + return "{0}".format(self._device.unit) + + def set_unit(self, unit): + # Convert values if required + if self._device.unit == "T": + if unit == "G": + self._device.convert_units(10000) + if self._device.unit == "G": + if unit == "T": + self._device.convert_units(0.0001) + self._device.unit = unit + + def get_ac_dc_mode(self): + return "{0}".format(self._device.channels[self.get_channel()].mode) + + def set_ac_dc_mode(self, mode): + self._device.channels[self.get_channel()].mode = mode + + def get_prms_reading(self): + return "{0}".format(self._device.channels[self.get_channel()].prms) + + def set_prms_reading(self, prms): + self._device.channels[self.get_channel()].prms = prms + + def get_filter_status(self): + return "{0}".format(self._device.channels[self.get_channel()].filter_status) + + def set_filter_status(self, filter): + self._device.channels[self.get_channel()].filter_status = filter + + def set_relative_mode_status(self, rel_mode): + self._device.channels[self.get_channel()].rel_mode_status = rel_mode + + def get_relative_mode_status(self): + return_val = "{0}".format(self._device.channels[self.get_channel()].rel_mode_status) + return return_val + + def set_auto_mode_status(self, auto_mode_status): + self._device.channels[self.get_channel()].auto_mode_status = auto_mode_status + + def get_auto_mode_status(self): + return "{0}".format(self._device.channels[self.get_channel()].auto_mode_status) + + def set_max_hold_status(self, max_hold_status): + self._device.channels[self.get_channel()].max_hold_status = max_hold_status + + def get_max_hold_status(self): + return "{0}".format(self._device.channels[self.get_channel()].max_hold_status) + + def get_channel_status(self): + return "{0}".format(self._device.channels[self.get_channel()].channel_status) + + def set_channel_status(self, status): + self._device.channels[self.get_channel()].channel_status = status + + def get_filter_windows(self): + return "{0}".format(self._device.channels[self._device.channel].filter_windows) + + def set_filter_windows(self, percentage): + self._device.channels[self.get_channel()].filter_windows = percentage + + def get_filter_points(self): + return "{0}".format(self._device.channels[self.get_channel()].filter_points) + + def set_filter_points(self, points): + self._device.channels[self.get_channel()].filter_points = points + + def set_manual_range(self, range): + self._device.channels[self.get_channel()].manual_range = range + + def get_manual_range(self): + return "{0}".format(self._device.channels[self.get_channel()].manual_range) + + def get_relative_setpoint(self): + return self._device.channels[self.get_channel()].relative_setpoint + + def set_relative_setpoint(self, rel_setpoint): + self._device.channels[self.get_channel()].relative_setpoint = rel_setpoint + + def get_relative_setpoint_multiplier(self): + return self._device.channels[self.get_channel()].relative_setpoint_multiplier + + def get_multicmds(self, command, other_commands): + """As the protocol file sends multiple commands (set Channel; request channel PV), + these methods split up the commands and process both. + """ + replies = [] + for cmd_to_find in [command, other_commands]: + cmd_to_find = bytes(cmd_to_find, "utf-8") + self.log.info("Processing {} from combined command".format(cmd_to_find)) + reply = self._process_part_command(cmd_to_find) + if reply is not None: + replies.append(self._process_part_command(cmd_to_find)) + return self.out_terminator.join(replies) + + def _process_part_command(self, cmd_to_find): + for cmd in self.bound_commands: + if cmd.can_process(cmd_to_find): + return cmd.process_request(cmd_to_find) + self.log.info("Error, unable to find command: {}".format(cmd_to_find)) diff --git a/lewis/devices/lakeshore460/states.py b/lewis/devices/lakeshore460/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/lakeshore460/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/linmot/__init__.py b/lewis/devices/linmot/__init__.py new file mode 100644 index 00000000..4c14e5ef --- /dev/null +++ b/lewis/devices/linmot/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedLinmot + +__all__ = ["SimulatedLinmot"] diff --git a/lewis/devices/linmot/device.py b/lewis/devices/linmot/device.py new file mode 100644 index 00000000..f11037c0 --- /dev/null +++ b/lewis/devices/linmot/device.py @@ -0,0 +1,99 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import ErrorStateCode, MovingState, StoppedState, WarnStateCode + +HARD_LIMIT_MINIMUM = 0.0 +HARD_LIMIT_MAXIMUM = 5000.0 + +# Defaults taken from device +DEVICE_DEFAULT_VELO = 52 +DEVICE_DEFAULT_MAX_ACCEL = 10 +DEVICE_DEFAULT_SPEED_RES = 190735 + +states = OrderedDict([("Stopped", StoppedState()), ("Moving", MovingState())]) + + +class SimulatedLinmot(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.position = 0 + self.target_position = 0 + self.inside_hard_limits = True + + self.velocity = DEVICE_DEFAULT_VELO + self.maximal_acceleration = DEVICE_DEFAULT_MAX_ACCEL + self.speed_resolution = DEVICE_DEFAULT_SPEED_RES + + self.motor_warn_status = WarnStateCode.STATIONARY + self.motor_error_status = ErrorStateCode.NONE + + self.new_action = False + self.position_reached = False + self.tolerance = 0.01 + + def _get_transition_handlers(self): + return OrderedDict( + [ + ( + ("Stopped", "Moving"), + lambda: self.new_action is True and self.position_reached is False, + ), + (("Moving", "Stopped"), lambda: self.position_reached is True), + ] + ) + + @property + def state(self): + return self._csm.state + + @property + def device_error(self): + """Is the device errored due to being outside of the hard limits + + Return(s): + (bool): True if device in errored state + """ + return not self.within_hard_limits() + + @property + def motor_warn_status_int(self): + """Return the integer value of the warn status enum + + The state machine attempts to replicate the devices warn status codes. This is done via + an enum, WarnStateCode, in the states.py: The enum is used for readability but the device + only ever returns these integer codes. + + Return(s): + (int): int value of the motor_warn_status + """ + return self.motor_warn_status.value + + def move_to_target(self, target_position): + """Demand the motor to drive to a target position. + + Argument(s): + target_position (int): the desire axis target position + """ + self.new_action = True + self.position_reached = False + self.target_position = target_position + + def within_hard_limits(self): + """Determine if the axis position is within the physics limits of the devices capability. + + The axis has a range of moment, however if taken beyond these then it will put the controller into an + error state. + """ + return HARD_LIMIT_MINIMUM <= self.position <= HARD_LIMIT_MAXIMUM + + def _get_state_handlers(self): + return states + + def _get_initial_state(self): + return "Stopped" + + def reset(self): + self._initialize_data() diff --git a/lewis/devices/linmot/interfaces/__init__.py b/lewis/devices/linmot/interfaces/__init__.py new file mode 100644 index 00000000..af1dc77e --- /dev/null +++ b/lewis/devices/linmot/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import LinmotStreamInterface + +__all__ = ["LinmotStreamInterface"] diff --git a/lewis/devices/linmot/interfaces/stream_interface.py b/lewis/devices/linmot/interfaces/stream_interface.py new file mode 100644 index 00000000..7327ebef --- /dev/null +++ b/lewis/devices/linmot/interfaces/stream_interface.py @@ -0,0 +1,52 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + + +class LinmotStreamInterface(StreamInterface): + in_terminator = "\r\n" + out_terminator = "\r" + + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("set_position").escape("!SP").int().escape("A").eos().build(), + CmdBuilder("get_position").escape("!GPA").eos().build(), + CmdBuilder("get_actual_speed_resolution").escape("!VIA").eos().build(), + CmdBuilder("get_motor_warn_status").escape("!EWA").eos().build(), + CmdBuilder("get_motor_error_status").escape("!EEA").eos().build(), + CmdBuilder("set_maximal_speed").escape("!SV").int().escape("A").eos().build(), + CmdBuilder("set_maximal_acceleration").escape("!SA").int().escape("A").eos().build(), + } + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def set_position(self, target_position): + self.device.move_to_target(target_position) + return "#" + + def get_position(self): + return "#{position}".format(position=self.device.position) + + def get_actual_speed_resolution(self): + return "#{speed_resolution}".format(speed_resolution=self.device.speed_resolution) + + def get_motor_warn_status(self): + return "#{motor_warn_status}".format(motor_warn_status=self.device.motor_warn_status.value) + + def get_motor_error_status(self): + return "#{motor_error_status}".format( + motor_error_status=self.device.motor_error_status.value + ) + + def set_maximal_speed(self, speed): + self.device.velocity = int(speed) + return "#" + + def set_maximal_acceleration(self, acceleration): + self.device.maximal_acceleration = int(acceleration) + return "#" diff --git a/lewis/devices/linmot/states.py b/lewis/devices/linmot/states.py new file mode 100644 index 00000000..bf1c6b6b --- /dev/null +++ b/lewis/devices/linmot/states.py @@ -0,0 +1,46 @@ +from enum import Enum + +from lewis.core import approaches +from lewis.core.statemachine import State + + +class WarnStateCode(Enum): + STATIONARY = 256 + MOVING = 512 + UNDEFINED_POSITION = 768 + + +class ErrorStateCode(Enum): + NONE = 0 + ERROR = 1 + + +class StoppedState(State): + def on_entry(self, dt): + device = self._context + self.log.info("Entering STOPPED state") + device.motor_warn_status = WarnStateCode.STATIONARY + + def in_state(self, dt): + device = self._context + if not device.within_hard_limits(): + device.motor_warn_status = WarnStateCode.UNDEFINED_POSITION + + +class MovingState(State): + def on_entry(self, dt): + device = self._context + self.log.info("Entering MOVING state") + device.motor_warn_status = WarnStateCode.MOVING + + def in_state(self, dt): + device = self._context + device.position = approaches.linear( + device.position, device.target_position, device.velocity, dt + ) + if ( + not device.within_hard_limits() + ): # If outside of limits device controller faults and must be re-initialised + device.motor_warn_status = WarnStateCode.UNDEFINED_POSITION + if abs(device.target_position - device.position) <= device.tolerance: + device.position_reached = True diff --git a/lewis/devices/mclennan/__init__.py b/lewis/devices/mclennan/__init__.py new file mode 100644 index 00000000..79773d01 --- /dev/null +++ b/lewis/devices/mclennan/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedMclennan + +__all__ = ["SimulatedMclennan"] diff --git a/lewis/devices/mclennan/device.py b/lewis/devices/mclennan/device.py new file mode 100644 index 00000000..c419868e --- /dev/null +++ b/lewis/devices/mclennan/device.py @@ -0,0 +1,111 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import HomingState, JoggingState, MovingState, StoppedState + +states = OrderedDict( + [ + ("Stopped", StoppedState()), + ("Moving", MovingState()), + ("Homing", HomingState()), + ("Jogging", JoggingState()), + ] +) + + +class SimulatedMclennan(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.is_jogging = False + self.is_moving = False + self.is_homing = False + self.is_pm304 = False + self.has_sent_BA = False + self.is_idle = True + self.jog_velocity = 0 + self.position = 0 + self.target_position = 0 + self.current_op = "Idle" + self.creep_speed1 = 700 + self.creep_speed2 = 700 + self.creep_speed3 = 700 + self.creep_speedz = 700 + + self.velocity = {} + self.creep_speed = {} + self.accl = {} + self.decl = {} + self.mode = {} + self.encoder_ratio = {} + self.window = {} + self.timeout = {} + self.tracking_window = {} + self.enable_limits = {} + self.backoff = {} + self.creep_steps = {} + self.settle_time = {} + self.abort_mode = {} + self.datum_mode = {} + self.home_pos = {} + for i in range(1, 10): + self.velocity[i] = 0 + self.creep_speed[i] = 700 + self.accl[i] = 1000 + self.decl[i] = 1000 + self.mode[i] = 1 + self.encoder_ratio[i] = 1.0 + self.window[i] = 10 + self.timeout[i] = 400 + self.tracking_window[i] = 300 + self.enable_limits[i] = False + self.backoff[i] = 0 + self.creep_steps[i] = 10 + self.settle_time[i] = 1000 + self.abort_mode[i] = "00000000" + self.datum_mode[i] = "00000000" + self.home_pos[i] = 0 + + def jog(self, velocity): + self.jog_velocity = velocity + self.is_jogging = True + + def home(self): + self.is_homing = True + + def moveAbs(self, controller, pos): + self.target_position = pos + self.jog_velocity = self.velocity[controller] + self.is_moving = True + + def moveRel(self, controller, pos): + self.target_position = self.position + pos + self.jog_velocity = self.velocity[controller] + self.is_moving = True + + def stop(self): + self.is_jogging = False + self.is_moving = False + self.is_homing = False + self.is_idle = True + self.current_op = "Idle" + + def _get_state_handlers(self): + return states + + def _get_initial_state(self): + return "Stopped" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("Stopped", "Jogging"), lambda: self.is_jogging), + (("Jogging", "Stopped"), lambda: not self.is_jogging), + (("Stopped", "Moving"), lambda: self.is_moving), + (("Moving", "Stopped"), lambda: not self.is_moving), + (("Stopped", "Homing"), lambda: self.is_homing), + (("Homing", "Stopped"), lambda: not self.is_homing), + ] + ) diff --git a/lewis/devices/mclennan/interfaces/__init__.py b/lewis/devices/mclennan/interfaces/__init__.py new file mode 100644 index 00000000..ed219192 --- /dev/null +++ b/lewis/devices/mclennan/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import MclennanStreamInterface + +__all__ = ["MclennanStreamInterface"] diff --git a/lewis/devices/mclennan/interfaces/stream_interface.py b/lewis/devices/mclennan/interfaces/stream_interface.py new file mode 100644 index 00000000..c6a930bd --- /dev/null +++ b/lewis/devices/mclennan/interfaces/stream_interface.py @@ -0,0 +1,263 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +@has_log +class MclennanStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("stop").int().escape("ST").eos().build(), + CmdBuilder("identify").int().escape("ID").eos().build(), + CmdBuilder("status").int().escape("OS").eos().build(), + CmdBuilder("get_actual_position").int().escape("OA").eos().build(), + CmdBuilder("get_command_position").int().escape("OC").eos().build(), + CmdBuilder("set_accel").int().escape("SA").int().eos().build(), + CmdBuilder("set_decel").int().escape("SD").int().eos().build(), + CmdBuilder("set_creep_speed").int().escape("SC").int().eos().build(), + CmdBuilder("reset").int().escape("RS").eos().build(), + CmdBuilder("jog").int().escape("CV").int().eos().build(), + CmdBuilder("query_speeds").int().escape("QS").eos().build(), + CmdBuilder("set_mode").int().escape("CM").int().eos().build(), + CmdBuilder("set_encoder_ratio").int().escape("ER").int().escape("/").int().eos().build(), + CmdBuilder("set_window").int().escape("WI").int().eos().build(), + CmdBuilder("set_timeout").int().escape("TO").int().eos().build(), + CmdBuilder("set_tracking_window").int().escape("TR").int().eos().build(), + CmdBuilder("enable_soft_limits").int().escape("SL").int().eos().build(), + CmdBuilder("set_backoff").int().escape("BO").int().eos().build(), + CmdBuilder("set_creep_steps").int().escape("CR").int().eos().build(), + CmdBuilder("set_settle_time").int().escape("SE").int().eos().build(), + CmdBuilder("set_abort_mode").int().escape("AM").arg("[0-1]{8}").eos().build(), + CmdBuilder("set_datum_mode").int().escape("DM").arg("[0-1]{8}").eos().build(), + CmdBuilder("set_home_pos").int().escape("SH").int().eos().build(), + CmdBuilder("move_relative").int().escape("MR").int().eos().build(), + CmdBuilder("move_absolute").int().escape("MA").int().eos().build(), + CmdBuilder("set_velocity").int().escape("SV").int().eos().build(), + CmdBuilder("home").int().escape("HD").int().eos().build(), + CmdBuilder("set_ba").int().escape("BA").eos().build(), + CmdBuilder("clear_datum").int().escape("CD").eos().build(), + CmdBuilder("define_command_position").int().escape("CP").int().eos().build(), + CmdBuilder("define_actual_position").int().escape("AP").int().eos().build(), + CmdBuilder("query_mode").int().escape("QM").eos().build(), + CmdBuilder("query_current_op").int().escape("CO").eos().build(), + CmdBuilder("query_all").int().escape("QA").eos().build(), + CmdBuilder("query_position").int().escape("QP").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + @if_connected + def stop(self, controller): + self.device.stop() + return "OK" + + @if_connected + def identify(self, controller): + return ( + f"{controller:02}: PM600 Ver 3.02" + if not self.device.is_pm304 + else "1:Mclennan Servo Supplies Ltd. PM304 V6.15" + ) + + @if_connected + def query_speeds(self, controller): + return ( + f"SV={self.device.velocity[controller]},SC={self.device.creep_speed[controller]},SA={self.device.accl[controller]},SD={self.device.decl[controller]}" + if self.device.is_pm304 + else f"{controller:02}:SC = {self.device.creep_speed[controller]} SV = {self.device.velocity[controller]} SA = {self.device.accl[controller]} SD = {self.device.decl[controller]} LD = 200000" + ) + + @if_connected + def set_creep_speed(self, controller, creep_speed): + self.device.creep_speed[controller] = creep_speed + if controller == 1: + self.device.creep_speed1 = creep_speed + if controller == 2: + self.device.creep_speed2 = creep_speed + if controller == 3: + self.device.creep_speed3 = creep_speed + return "OK" + + @if_connected + def status(self, controller): + return f"{controller:02}:{int(self.device.is_idle)}000{int(self.device.is_jogging)}000" + + @if_connected + def get_actual_position(self, controller): + return f"{controller:02}:{int(self.device.position)}" + + def get_command_position(self, controller): + return f"{controller:02}:{int(self.device.position)}" + + @if_connected + def set_accel(self, controller, acceleration): + self.device.accl[controller] = acceleration + return "OK" + + @if_connected + def set_decel(self, controller, deceleration): + self.device.decl[controller] = deceleration + return "OK" + + @if_connected + def reset(self, controller): + return "!RESET" + + @if_connected + def jog(self, controller, velocity): + self.device.jog(velocity) + return "OK" + + @if_connected + def set_mode(self, controller, mode): + self.device.mode[controller] = mode + return "OK" + + @if_connected + def set_encoder_ratio(self, controller, er_num, er_denom): + self.device.encoder_ratio[controller] = float(er_num) / float(er_denom) + return "OK" + + @if_connected + def set_window(self, controller, window): + self.device.window[controller] = window + return "OK" + + @if_connected + def set_timeout(self, controller, timeout): + self.device.timeout[controller] = timeout + return "OK" + + @if_connected + def set_tracking_window(self, controller, window): + self.device.tracking_window[controller] = window + return "OK" + + @if_connected + def enable_soft_limits(self, controller, enable): + self.device.enable_limits[controller] = enable + return "OK" + + @if_connected + def set_backoff(self, controller, value): + self.device.backoff[controller] = value + return "OK" + + @if_connected + def set_creep_steps(self, controller, value): + self.device.creep_steps[controller] = value + return "OK" + + @if_connected + def set_settle_time(self, controller, value): + self.device.settle_time[controller] = value + return "OK" + + @if_connected + def set_abort_mode(self, controller, value): + self.device.abort_mode[controller] = value + return "OK" + + @if_connected + def set_datum_mode(self, controller, value): + self.device.datum_mode[controller] = value + return "OK" + + @if_connected + def set_home_pos(self, controller, value): + self.device.home_pos[controller] = value + return "OK" + + @if_connected + def move_relative(self, controller, pos): + self.device.moveRel(controller, pos) + return "OK" + + @if_connected + def move_absolute(self, controller, pos): + self.device.moveAbs(controller, pos) + return "OK" + + @if_connected + def set_velocity(self, controller, value): + self.device.velocity[controller] = value + return "OK" + + @if_connected + def home(self, controller, dir): + self.device.home() + return "OK" + + @if_connected + def set_ba(self, controller): + self.device.has_sent_BA = True + return "OK" + + @if_connected + def clear_datum(self, controller): + return "OK" + + @if_connected + def define_command_position(self, controller, value): + self.device.target_position = value + return "OK" + + @if_connected + def define_actual_position(self, controller, value): + self.device.position = value + return "OK" + + @if_connected + def query_mode(self, controller): + return f"{controller:02}:CM = {self.device.mode[controller]} AM = {self.device.abort_mode[controller]} DM = {self.device.datum_mode[controller]} JM = 11000000" + + @if_connected + def query_current_op(self, controller): + return f"{controller:02}:{self.device.current_op}" + + # all replies should contain the original command string first and \r, the emulator does not do this in general but it + # only matters for multiline replies which this is the only such command + @if_connected + def query_all(self, controller): + lines = [ + "Mclennan Digiloop Motor Controller V2.10a(1.2)", + f"Address = {controller}", + "Privilege level = 4", + "Mode = Aborted", + "Kf = 1000 Kp = 500 Ks = 2000 Kv = 1000 Kx = 0", + "Slew speed = 100000", + "Acceleration = 200000 Deceleration = 400000", + "Creep speed = 400 Creep steps = 0", + "Jog speed = 100 Joystick speed = 10000", + "Settling time = 200", + "Window = 4 Threshold = 2000", + "Tracking = 4000", + "Lower soft limit = -2147483647 Upper soft limit = 2147483647", + "Soft limits enabled", + "Lower hard limit on Upper hard limit on", + "Jog enabled Joystick disabled", + "Gbox num = 1 Gbox den = 1", + "Command pos = 0 Motor pos = 1", + "Pos error = -1 Input pos = 0", + "Valid sequences: none Autoexec disabled", + "Valid cams: none", + "Valid profiles: none Profile time = 1000 ms", + "Read port: %00000000 Last write: %00000000", + ] + return f"{controller:02}QA\r" + "\r\n".join(lines) + + @if_connected + def query_position(self, controller): + return f"{controller:02}:CP = {self.device.target_position} AP = {self.device.position} IP = 1050 TP = 0 OD = -2050" diff --git a/lewis/devices/mclennan/states.py b/lewis/devices/mclennan/states.py new file mode 100644 index 00000000..21c9d319 --- /dev/null +++ b/lewis/devices/mclennan/states.py @@ -0,0 +1,70 @@ +from lewis.core.statemachine import State + + +class StoppedState(State): + def on_entry(self, dt): + device = self._context + self.log.info("Entering STOPPED state") + device.current_op = "Stopping" + device.current_op = "Idle" + device.is_idle = True + + +class JoggingState(State): + def on_entry(self, dt): + device = self._context + self.log.info("Entering JOGGING state") + device.current_op = "Constant velocity" + device.is_idle = False + + def in_state(self, dt): + device = self._context + device.is_idle = False + device.position += device.jog_velocity * dt + + +class MovingState(State): + def __init__(self): + self.incr = 0 + + def on_entry(self, dt): + device = self._context + self.log.info("Entering MOVING state") + device.current_op = "Move" + self.incr = (device.target_position - device.position) / 5.0 + device.is_idle = False + + def in_state(self, dt): + device = self._context + device.position += self.incr + device.is_idle = False + if device.position == device.target_position: + device.is_moving = False + + +# device.position += device.jog_velocity * dt + + +class HomingState(State): + def __init__(self): + self.incr = 0 + + def on_entry(self, dt): + device = self._context + self.log.info("Entering HOMING state") + device.current_op = "Home to datum" + self.incr = 0 + device.is_idle = False + + def in_state(self, dt): + device = self._context + self.incr += 1 + device.is_idle = False + if self.incr < 5: + device.position += 10 + else: + device.position = 0 + device.is_homing = False + + +# device.position += device.jog_velocity * dt diff --git a/lewis/devices/mecfrf/__init__.py b/lewis/devices/mecfrf/__init__.py new file mode 100644 index 00000000..e9b6f9a8 --- /dev/null +++ b/lewis/devices/mecfrf/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedMecfrf + +__all__ = ["SimulatedMecfrf"] diff --git a/lewis/devices/mecfrf/device.py b/lewis/devices/mecfrf/device.py new file mode 100644 index 00000000..9a7ab7c2 --- /dev/null +++ b/lewis/devices/mecfrf/device.py @@ -0,0 +1,30 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedMecfrf(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.sensor1 = 123 + self.sensor2 = 456 + + self.corrupted_messages = False + self.connected = True + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def reset(self): + self._initialize_data() + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/mecfrf/interfaces/__init__.py b/lewis/devices/mecfrf/interfaces/__init__.py new file mode 100644 index 00000000..a40f09ce --- /dev/null +++ b/lewis/devices/mecfrf/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import MecfrfStreamInterface + +__all__ = ["MecfrfStreamInterface"] diff --git a/lewis/devices/mecfrf/interfaces/stream_interface.py b/lewis/devices/mecfrf/interfaces/stream_interface.py new file mode 100644 index 00000000..e64526a3 --- /dev/null +++ b/lewis/devices/mecfrf/interfaces/stream_interface.py @@ -0,0 +1,74 @@ +import struct +import threading + +from lewis.adapters.stream import StreamInterface + +EXPECTED_MESSAGE_LENGTH = 188 + + +class MecfrfStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation (No commands - the device always sends a stream of + # information to the IOC without being polled) + commands = {} + + in_terminator = "" + out_terminator = b"" + + def __init__(self): + super(MecfrfStreamInterface, self).__init__() + self._queue_next_unsolicited_message() + + def _queue_next_unsolicited_message(self): + timer = threading.Timer(1.0, self.get_data_unsolicited) + timer.daemon = True + timer.start() + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + def get_data_unsolicited(self): + self._queue_next_unsolicited_message() + + if not self.device.connected: + return + + try: + handler = self.handler + except AttributeError: + # Happens if no client is currently connected. + return + else: + if self.device.corrupted_messages: + # Nonsense message which should cause alarms in the IOC. + handler.unsolicited_reply(b"A" * EXPECTED_MESSAGE_LENGTH) + else: + handler.unsolicited_reply(self._construct_status_message()) + + def _construct_status_message(self): + # Fixed message "preamble" + msg = b"DATA" + + # There are 6 integer header fields which we ignore. They are: + # - Order number + # - Serial number + # - Length of measurement data + # - Length of video data + # - Frame number + # - Counter + for _ in range(6): + msg += struct.pack("L", 0) + + # Two little-endian signed integers corresponding to the data for sensors 1 and 2 respectively. + msg += struct.pack(" MAX_TEMPERATURE + + def chopper_overspeed(self): + return self._true_frequency > self._type.get_frequency() + + def phase_delay_error(self): + return self._phase_delay_error + + def phase_delay_correction_error(self): + tolerance = 0.001 * self._demanded_phase_delay + return abs(self._true_phase_delay - self._demanded_phase_delay) > tolerance + + def phase_accuracy_window_error(self): + return ( + abs(self._true_phase_delay - self._demanded_phase_delay) + > self._demanded_phase_error_window + ) + + def set_demanded_frequency(self, new_frequency_int): + self._demanded_frequency = self._type.get_closest_valid_frequency(new_frequency_int) + self._max_phase_delay = self._type.get_max_phase_for_closest_frequency(new_frequency_int) + + def set_demanded_phase_delay(self, new_phase_delay): + self._demanded_phase_delay = min(new_phase_delay, self._max_phase_delay) + self._phase_delay_error = self._demanded_phase_delay != new_phase_delay + + def set_demanded_phase_error_window(self, new_phase_window): + self._demanded_phase_error_window = new_phase_window + + def set_true_frequency(self, new_frequency): + self._true_frequency = new_frequency + + def set_true_phase_delay(self, new_delay): + self._true_phase_delay = new_delay + + def set_chopper_type(self, frequency, manufacturer): + self._type = ChopperType(frequency, manufacturer) + # Do this in case the current demanded frequency is invalid for the new type + self.set_demanded_frequency(self._demanded_frequency) + + def set_temperature(self, temperature): + self._temperature = temperature + + def start(self): + self._started = True + + def stop(self): + self._started = False diff --git a/lewis/devices/mk2_chopper/interfaces/__init__.py b/lewis/devices/mk2_chopper/interfaces/__init__.py new file mode 100644 index 00000000..e20257ab --- /dev/null +++ b/lewis/devices/mk2_chopper/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Mk2ChopperStreamInterface + +__all__ = ["Mk2ChopperStreamInterface"] diff --git a/lewis/devices/mk2_chopper/interfaces/stream_interface.py b/lewis/devices/mk2_chopper/interfaces/stream_interface.py new file mode 100644 index 00000000..5d2eb74c --- /dev/null +++ b/lewis/devices/mk2_chopper/interfaces/stream_interface.py @@ -0,0 +1,145 @@ +from lewis.adapters.stream import Cmd, StreamInterface + +from ..chopper_type import ChopperType + + +def filled_int(val, length): + """Takes a value and returns a zero padded representation of the integer component. + + :param val: The original value. + :param length: Minimum length of the returned string + :return: Zero-padded integer representation (if possible) of string. Original string used if integer conversion + fails + """ + try: + converted_val = int(val) + except ValueError: + converted_val = val + return str(converted_val).zfill(length) + + +class Mk2ChopperStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + Cmd("get_true_frequency", "^RF$"), + Cmd("get_demanded_frequency", "^RG$"), + Cmd("get_true_phase_delay", "^RP$"), + Cmd("get_demanded_phase_delay", "^RQ$"), + Cmd("get_true_phase_error", "^RE$"), + Cmd("get_demanded_phase_error_window", "^RW$"), + Cmd("get_chopper_interlocks", "^RC$"), + Cmd("get_spectral_interlocks", "^RS$"), + Cmd("get_error_flags", "^RX$"), + Cmd("read_all", "^RA$"), + Cmd("set_chopper_started", "^WS([0-9]+)$"), + Cmd("set_demanded_frequency", "^WM([0-9]+)$"), + Cmd("set_demanded_phase_delay", "^WP([0-9]+)$"), + Cmd("set_demanded_phase_error_window", "^WR([0-9]+)$"), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + def get_demanded_frequency(self): + return "RG{0}".format(filled_int(self._device.get_demanded_frequency(), 3)) + + def get_true_frequency(self): + return "RF{0}".format(filled_int(self._device.get_true_frequency(), 3)) + + def get_demanded_phase_delay(self): + return "RQ{0}".format(filled_int(self._device.get_demanded_phase_delay(), 5)) + + def get_true_phase_delay(self): + return "RP{0}".format(filled_int(self._device.get_true_phase_delay(), 5)) + + def get_demanded_phase_error_window(self): + return "RW{0}".format(filled_int(self._device.get_demanded_phase_error_window(), 3)) + + def get_true_phase_error(self): + return "RE{0}".format(filled_int(self._device.get_true_phase_error(), 3)) + + def get_spectral_interlocks(self): + bits = [0] * 8 + if self._device.get_manufacturer() == ChopperType.CORTINA: + bits[0] = 1 if self._device.inverter_ready() else 0 + bits[1] = 1 if self._device.motor_running() else 0 + bits[2] = 1 if self._device.in_sync() else 0 + elif self._device.get_manufacturer() == ChopperType.INDRAMAT: + bits[0] = 1 if self._device.motor_running() else 0 + bits[1] = 1 if self._device.reg_mode() else 0 + bits[2] = 1 if self._device.in_sync() else 0 + elif self._device.get_manufacturer() == ChopperType.SPECTRAL: + bits[2] = 1 if self._device.external_fault() else 0 + return "RS{0:8s}".format(Mk2ChopperStreamInterface._string_from_bits(bits)) + + def get_chopper_interlocks(self): + bits = [0] * 8 + bits[0] = 1 if self._device.get_system_frequency() == 50 else 0 + bits[1] = 1 if self._device.clock_loss() else 0 + bits[2] = 1 if self._device.bearing_1_overheat() else 0 + bits[3] = 1 if self._device.bearing_2_overheat() else 0 + bits[4] = 1 if self._device.motor_overheat() else 0 + bits[5] = 1 if self._device.chopper_overspeed() else 0 + return "RC{0:8s}".format(Mk2ChopperStreamInterface._string_from_bits(bits)) + + def get_error_flags(self): + bits = [0] * 8 + bits[0] = 1 if self._device.phase_delay_error() else 0 + bits[1] = 1 if self._device.phase_delay_correction_error() else 0 + bits[2] = 1 if self._device.phase_accuracy_window_error() else 0 + return "RX{0:8s}".format(Mk2ChopperStreamInterface._string_from_bits(bits)) + + def get_manufacturer(self): + return self._type.get_manufacturer() + + def set_chopper_started(self, start_flag_raw): + try: + start_flag = int(start_flag_raw) + except ValueError: + pass + else: + if start_flag == 1: + self._device.start() + elif start_flag == 2: + self._device.stop() + return + + def set_demanded_frequency(self, new_frequency_raw): + return Mk2ChopperStreamInterface._set( + new_frequency_raw, self.get_demanded_frequency, self._device.set_demanded_frequency + ) + + def set_demanded_phase_delay(self, new_phase_delay_raw): + return Mk2ChopperStreamInterface._set( + new_phase_delay_raw, + self.get_demanded_phase_delay, + self._device.set_demanded_phase_delay, + ) + + def set_demanded_phase_error_window(self, new_phase_error_window_raw): + return Mk2ChopperStreamInterface._set( + new_phase_error_window_raw, + self.get_demanded_phase_error_window, + self._device.set_demanded_phase_error_window, + ) + + def read_all(self): + return "RA:Don't use, it causes the driver to lock up" + + @staticmethod + def _set(raw, device_get, device_set): + try: + int_value = int(raw) + except ValueError: + pass + else: + device_set(int_value) + return device_get() + + @staticmethod + def _string_from_bits(bits): + return "".join(str(n) for n in reversed(bits)) diff --git a/lewis/devices/mk2_chopper/states.py b/lewis/devices/mk2_chopper/states.py new file mode 100644 index 00000000..f5cf48fd --- /dev/null +++ b/lewis/devices/mk2_chopper/states.py @@ -0,0 +1,55 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + +# Would rather this were in device but causes Lewis to fail +MAX_TEMPERATURE = 1 + + +def output_current_state(device, state_name): + print( + "{0}: Freq {1:.2f}, Phase {2:.2f}, Error {3:.2f}, Temperature {4:.2f}".format( + state_name.upper(), + device.get_true_frequency(), + device.get_true_phase_delay(), + device.get_true_phase_error(), + device.get_temperature(), + ) + ) + + +class DefaultInitState(State): + pass + + +class DefaultStoppedState(State): + def in_state(self, dt): + device = self._context + output_current_state(self._context, "stopped") + device.set_true_frequency(approaches.linear(device.get_true_frequency(), 0, 1, dt)) + device.set_temperature(approaches.linear(device.get_temperature(), 0, 0.1, dt)) + device.set_true_phase_delay(approaches.linear(device.get_true_phase_delay(), 0, 1, dt)) + + +class DefaultStartedState(State): + def in_state(self, dt): + device = self._context + output_current_state(self._context, "started") + device.set_true_frequency( + approaches.linear(device.get_true_frequency(), device.get_demanded_frequency(), 1, dt) + ) + equilibrium_frequency_temperature = ( + 2 * MAX_TEMPERATURE * device.get_true_frequency() / device.get_system_frequency() + ) + device.set_temperature( + approaches.linear( + device.get_temperature(), + equilibrium_frequency_temperature, + device.get_true_frequency() * 0.001, + dt, + ) + ) + device.set_true_phase_delay( + approaches.linear( + device.get_true_phase_delay(), device.get_demanded_phase_delay(), 1, dt + ) + ) diff --git a/lewis/devices/mkspr4kb/__init__.py b/lewis/devices/mkspr4kb/__init__.py new file mode 100644 index 00000000..eec2f914 --- /dev/null +++ b/lewis/devices/mkspr4kb/__init__.py @@ -0,0 +1,3 @@ +from .device import Simulated_MKS_PR4000B + +__all__ = ["Simulated_MKS_PR4000B"] diff --git a/lewis/devices/mkspr4kb/device.py b/lewis/devices/mkspr4kb/device.py new file mode 100644 index 00000000..c394c233 --- /dev/null +++ b/lewis/devices/mkspr4kb/device.py @@ -0,0 +1,65 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class Sensor(object): + def __init__(self): + self.setpoint = 0 + + self.valve_enabled = False + self.relay_enabled = False + + self.gain = 0 + self.offset = 0 + self.rtd_offset = 0 + self.input_range = 0 + self.output_range = 0 + self.ext_input_range = 0 + self.ext_output_range = 0 + self.scale = 0 + self.upper_limit = 0 + self.lower_limit = 0 + + self.signalmode = 0 + self.limitmode = 0 + + self.formula_relay = "formula+1" + + self.external_input = 0 + + self.range = 0 + self.range_units = 0 + + +@has_log +class Simulated_MKS_PR4000B(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.channels = { + 1: Sensor(), + 2: Sensor(), + } + self.connected = True + self.remote_mode = True + + def reset(self): + self._initialize_data() + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def backdoor_set_channel_property(self, channel, property, value): + setattr(self.channels[int(channel)], str(property), float(value)) diff --git a/lewis/devices/mkspr4kb/interfaces/__init__.py b/lewis/devices/mkspr4kb/interfaces/__init__.py new file mode 100644 index 00000000..2b2d24a5 --- /dev/null +++ b/lewis/devices/mkspr4kb/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import MKS_PR4000B_StreamInterface + +__all__ = ["MKS_PR4000B_StreamInterface"] diff --git a/lewis/devices/mkspr4kb/interfaces/stream_interface.py b/lewis/devices/mkspr4kb/interfaces/stream_interface.py new file mode 100644 index 00000000..1351588e --- /dev/null +++ b/lewis/devices/mkspr4kb/interfaces/stream_interface.py @@ -0,0 +1,193 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +class MKS_PR4000B_StreamInterface(StreamInterface): + def __init__(self): + super(MKS_PR4000B_StreamInterface, self).__init__() + + self.commands = { + CmdBuilder("get_value").escape("AV").int().eos().build(), + CmdBuilder("get_valve_status").escape("?VL").int().eos().build(), + CmdBuilder("set_valve_status") + .escape("VL") + .int() + .escape(",") + .enum("ON", "OFF") + .eos() + .build(), + CmdBuilder("get_relay_status").escape("?RL").int().eos().build(), + CmdBuilder("set_relay_status") + .escape("RL") + .int() + .escape(",") + .enum("ON", "OFF") + .eos() + .build(), + CmdBuilder("get_formula_relay").escape("?FR").int().eos().build(), + CmdBuilder("set_formula_relay").escape("FR").int().escape(",").any().eos().build(), + CmdBuilder("get_remote_mode").escape("?RT").eos().build(), + CmdBuilder("set_remote_mode").escape("RT").escape(",").enum("ON", "OFF").eos().build(), + CmdBuilder("get_external_input").escape("EX").int().eos().build(), + CmdBuilder("get_status").escape("ST").eos().build(), + CmdBuilder("set_range") + .escape("RG") + .int() + .escape(",") + .float() + .escape(",") + .int() + .eos() + .build(), + CmdBuilder("get_range").escape("?RG").int().eos().build(), + CmdBuilder("get_id").escape("?ID").eos().build(), + } + + # These get appended to the list of commands above - just map command syntax against emulator property + numeric_get_and_set_commands = { + "SP": "setpoint", + "GN": "gain", + "OF": "offset", + "RO": "rtd_offset", + "IN": "input_range", + "OT": "output_range", + "EI": "ext_input_range", + "EO": "ext_output_range", + "SC": "scale", + "UL": "upper_limit", + "LL": "lower_limit", + "SM": "signalmode", # As far as the emulator is concerned, this is an int. IOC treats is as enum. + "LM": "limitmode", # As far as the emulator is concerned, this is an int. IOC treats is as enum. + } + + getter_factory, setter_factory = self._get_getter_and_setter_factories() + + for command_name, emulator_name in numeric_get_and_set_commands.items(): + self.commands.update( + { + CmdBuilder(setter_factory(emulator_name)) + .escape(command_name) + .int() + .escape(",") + .float() + .eos() + .build(), + CmdBuilder(getter_factory(emulator_name)) + .escape("?{}".format(command_name)) + .int() + .eos() + .build(), + } + ) + + def _get_getter_and_setter_factories(self): + """Returns a pair of functions (getter_factory, setter_factory) which can generate appropriate attribute getters + and setters for a given property name. + + For example: + >>> getter_factory("foo") + will generate the getter accessing + >>> self.device.channels[chan].foo + where + >>> chan + is one of the captured arguments to the getter. + + Factory methods are used to force the functions to bind correctly. + """ + + def getter_factory(name): + def getter(chan): + if not self.device.connected: + return None + return "{:.2f}".format(getattr(self.device.channels[chan], name)) + + return getter + + def setter_factory(name): + def setter(chan, value): + if not self.device.connected: + return None + setattr(self.device.channels[chan], name, value) + return "" + + return setter + + return getter_factory, setter_factory + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + @if_connected + def get_value(self, chan): + return "{:.2f}".format(self.device.channels[chan].setpoint) + + @if_connected + def get_valve_status(self, chan): + return "ON" if self.device.channels[chan].valve_enabled else "OFF" + + @if_connected + def set_valve_status(self, chan, status): + self.device.channels[chan].valve_enabled = status == "ON" + return "" + + @if_connected + def get_relay_status(self, chan): + return "ON" if self.device.channels[chan].relay_enabled else "OFF" + + @if_connected + def set_relay_status(self, chan, status): + self.device.channels[chan].relay_enabled = status == "ON" + return "" + + @if_connected + def get_formula_relay(self, chan): + return self.device.channels[chan].formula_relay + + @if_connected + def set_formula_relay(self, chan, formula): + self.device.channels[chan].formula_relay = formula + return "" + + @if_connected + def get_remote_mode(self): + return "ON" if self.device.remote_mode else "OFF" + + @if_connected + def set_remote_mode(self, mode): + self.device.remote_mode = mode == "ON" + return "" + + @if_connected + def get_external_input(self, chan): + return "{:.2f}".format(self.device.channels[chan].external_input) + + @if_connected + def get_status(self): + return "{:05d}".format(0) # Return a constant here, just to keep IOC happy. + + @if_connected + def get_range(self, chan): + return "{:.2f},{:02d}".format( + self.device.channels[chan].range, self.device.channels[chan].range_units + ) + + @if_connected + def set_range(self, chan, range, units): + self.device.channels[chan].range = range + self.device.channels[chan].range_units = units + return "" + + @if_connected + def get_id(self): + return "emulated_mks_pr4000" diff --git a/lewis/devices/mkspr4kb/states.py b/lewis/devices/mkspr4kb/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/mkspr4kb/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/moxa12xx/__init__.py b/lewis/devices/moxa12xx/__init__.py new file mode 100644 index 00000000..1ff41787 --- /dev/null +++ b/lewis/devices/moxa12xx/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedMoxa1210 + +__all__ = ["SimulatedMoxa1210"] diff --git a/lewis/devices/moxa12xx/device.py b/lewis/devices/moxa12xx/device.py new file mode 100644 index 00000000..a4d2940f --- /dev/null +++ b/lewis/devices/moxa12xx/device.py @@ -0,0 +1,130 @@ +import struct +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +@has_log +class SimulatedMoxa1210(StateMachineDevice): + """Simulated Moxa ioLogik E1210 Remote I/O device. + """ + + def _initialize_data(self): + """Sets the initial state of the device + """ + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() + + def get_di(self, addr, count): + """Gets values from a register on the modbus interface + + Args: + addr: Integer, the starting address of the values to be retrieved + count: Integer, the number of contiguous values to get from the modbus register + + Returns: + Array of values with length count + + """ + return self.interface.di.get(addr, count) + + def set_di(self, addr, value): + """Sets values in the register on the modbus interface + + Args: + addr: Integer, starting address of the values to be set + value: List, Containing the data to be written to the register + + Returns: + None + """ + self.interface.di.set(addr, value) + + def set_ir(self, addr, value): + """Set value(s) on the input registers. + + Args: + addr: Integer, starting address of the values to be set + value: List, containing the values to be written to the register + + Returns: + None + + """ + self.interface.ir.set(addr, value) + + def get_ir(self, addr, count): + """Get value(s) from the input registers. + + Args: + addr: Integer, starting address of the values to be set + count: Integer, the number of contiguous values to get from the modbus register + + Returns: + None + + """ + self.interface.ir.get(addr, count) + + def set_1240_voltage(self, addr, value): + """Writes to an input register data a voltage encoded like a moxa e1240 voltage/current logger + + The voltage range for an e1240 is a 0 - 10 V. This is linearly encoded in a 16-bit int, so 0=0V and 2**16=10V + + Args: + addr: Integer, The address to write the value to + value: Float, The desired voltage to be written to the input register + + Returns: + + """ + max_16_bit_value = 2**16 - 1 + max_voltage_range = 10.0 + min_voltage_range = 0.0 + + if value > max_voltage_range: + raw_val = max_16_bit_value + elif value < min_voltage_range: + raw_val = 0 + else: + raw_val = int(float(value) * max_16_bit_value / max_voltage_range) + + self.interface.ir.set(addr, (raw_val,)) + + def set_1262_temperature(self, addr, value): + """Encodes the requested temperature as two 16-bit integer words and writes to input registers like a moxa e1262 + + Follows these stack overflow answers: https://stackoverflow.com/a/35603706, https://stackoverflow.com/a/45354944 + + Args: + addr: The input register to write the first word of the temperature in. The second word will be written to addr+1 + value: Float, the desired temperature to be written to the input registers + + Returns: + None + + """ + # Convert floating point number to binary representation string + binary_representation = struct.pack(" 2;4;3; + Example QOUT?2; produces -> 3;5; + + :param output_number: The output number being queries; 1 HEATER, 2 Analogue + :return: configuration as a string; sensor source;control;heater_range + """ + device = self._device + try: + output_index = int(output_number) - 1 + + output_config = "{sensor_source};{control}".format( + sensor_source=device.sensor_source[output_index], + control=device.control[output_index], + ) + + if output_index == HEATER_INDEX: + output_config += ";{heater_range}".format(heater_range=device.heater_range) + + return output_config + + except (IndexError, ValueError, TypeError): + print("Error: invalid output number, '{0}'".format(output_number)) + device.error = NeoceraDeviceErrors(NeoceraDeviceErrors.BAD_PARAMETER) + return "" + + def set_heater_control(self, control_type_number): + """Set the heater output control. + + :param control_type_number: control type to set the heater to + :return: None + """ + self._set_output_control(HEATER_INDEX, control_type_number) + + def set_analog_control(self, control_type_number): + """Set the analog output control. + + :param control_type_number: control type to set the heater to + :return: None + """ + self._set_output_control(ANALOG_INDEX, control_type_number) + + def _set_output_control(self, output_index, control_type_number): + """Set the output control for either the heater or the analog output. + + :param output_index: output index + :param control_type_number: control type to set + :return: None + """ + device = self._device + try: + control_type = int(control_type_number) + + if ( + control_type < CONTROL_TYPE_MIN[output_index] + or control_type > CONTROL_TYPE_MAX[output_index] + ): + raise ValueError("Bad control type number") + + self._device.control[output_index] = control_type + + except (IndexError, ValueError, TypeError): + print( + "Error: invalid control type number for output {output}, '{0}'".format( + control_type_number, output=output_index + ) + ) + device.error = NeoceraDeviceErrors(NeoceraDeviceErrors.BAD_PARAMETER) + + def get_heater(self): + """:return: Heater output + """ + return "{0:5.1f}".format(self._device.heater) + + def get_pid(self, output_number): + """Get the PID and other info of the output. + + Information is: + P, I, D, fixed power settting, + for heater: power limit + for analog: gain and offset + + Examples: + QPID?1; -> 24.999;32.;8.;0.0;100.; + QPID?2; -> 99.999;10.;0.0;0.0;1.;0.0; + + :param output_number: output number; + :return: various info as a string + """ + device = self._device + try: + output_index = int(output_number) - 1 + + pid_output = "{P:f};{I:f};{D:f};{fixed_power:f}".format(**device.pid[output_index]) + + if output_index == HEATER_INDEX: + return "{pid_output};{limit:f}".format( + pid_output=pid_output, **device.pid[output_index] + ) + else: + return "{pid_output};{gain:f};{offset:f}".format( + pid_output=pid_output, **device.pid[output_index] + ) + + except (IndexError, ValueError, TypeError): + print("Error: invalid output number, '{output}'".format(output=output_number)) + device.error = NeoceraDeviceErrors(NeoceraDeviceErrors.BAD_PARAMETER) + + def set_pid_heater(self, p, i, d, fixed_power, limit): + """Set the pid settings for the heater. + + :param p: p + :param i: i + :param d: d + :param fixed_power: fixed power + :param limit: limit of the heater + :returns: None + """ + pid_settings = self._device.pid[HEATER_INDEX] + try: + self._set_pid(p, i, d, fixed_power, pid_settings) + limit_as_float = float(limit) + if limit_as_float < 0.0 or limit_as_float > 100.0: + raise ValueError("Outside allowed heater range") + pid_settings["limit"] = limit_as_float + + except (IndexError, ValueError, TypeError): + print("Error: in pid settings for heater") + self._device.error = NeoceraDeviceErrors(NeoceraDeviceErrors.BAD_PARAMETER) + + def set_pid_analog(self, p, i, d, fixed_power, gain, offset): + """Set the pid settings for the analog output. + + :param p: p + :param i: i + :param d: d + :param fixed_power: fixed power + :param gain: gain of the output + :param offset: offset for the output + :return: None + """ + pid_settings = self._device.pid[ANALOG_INDEX] + try: + self._set_pid(p, i, d, fixed_power, pid_settings) + pid_settings["gain"] = float(gain) + pid_settings["offset"] = float(offset) + + except (IndexError, ValueError, TypeError): + print("Error: in pid settings for analog") + self._device.error = NeoceraDeviceErrors(NeoceraDeviceErrors.BAD_PARAMETER) + + def _set_pid(self, p, i, d, fixed_power, pid_settings): + """Common function to set p,i,d and power. + + :param p: p + :param i: i + :param d: d + :param fixed_power: fixed power + :param pid_settings: in which to set them + + :return: None + """ + pid_settings["P"] = float(p) + pid_settings["I"] = float(i) + pid_settings["D"] = float(d) + pid_settings["fixed_power"] = float(fixed_power) + + def handle_error(self, request, error): + """Handles errors. + + :param request: + :param error: + """ + print("An error occurred at request " + repr(request) + ": " + repr(error)) diff --git a/lewis/devices/neocera_ltc21/states.py b/lewis/devices/neocera_ltc21/states.py new file mode 100644 index 00000000..0a6c68e7 --- /dev/null +++ b/lewis/devices/neocera_ltc21/states.py @@ -0,0 +1,56 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + +from .constants import HEATER_INDEX + + +class OffState(State): + """Device is in off state. + + It does not display the temperature on the front it is not monitoring or controlling it. + """ + + NAME = "off" + + +class MonitorState(State): + """Temperature is being monitored but heater is switched off. + """ + + NAME = "monitor" + + def in_state(self, dt): + # heater is off because we are in monitor mode + self._context.heater = 0 + + +class ControlState(State): + """Temperature is being controlled and monitored. The device will try to use the heater to make + the temperature the same as the set point. + """ + + NAME = "control" + + def in_state(self, dt): + device = self._context + for output_index in range(device.sensor_count): + sensor_source = device.sensor_source[output_index] - 1 # sensor source is 1 indexed + try: + temp = device.temperatures[sensor_source] + setpoint = device.setpoints[output_index] + device.temperatures[sensor_source] = approaches.linear(temp, setpoint, 0.1, dt) + except IndexError: + # sensor source is out of range (probably 3) + pass + + try: + heater_sensor_source = device.sensor_source[HEATER_INDEX] - 1 + # set heater between 0 and 100% proportional to diff in temp * 10 + temp = device.temperatures[heater_sensor_source] + setpoint = device.setpoints[HEATER_INDEX] + diff_in_temp = setpoint - temp + heater_limit = device.pid[HEATER_INDEX]["limit"] + device.heater = max(0, min(diff_in_temp * 10.0, heater_limit)) + except IndexError: + # heater is not connected to a sensor so it is off + device.heater = 0 diff --git a/lewis/devices/ngpspsu/__init__.py b/lewis/devices/ngpspsu/__init__.py new file mode 100644 index 00000000..f5969074 --- /dev/null +++ b/lewis/devices/ngpspsu/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedNgpspsu + +__all__ = ["SimulatedNgpspsu"] diff --git a/lewis/devices/ngpspsu/device.py b/lewis/devices/ngpspsu/device.py new file mode 100644 index 00000000..9c6215f4 --- /dev/null +++ b/lewis/devices/ngpspsu/device.py @@ -0,0 +1,180 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedNgpspsu(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._model_no_and_firmware = "NGPS 100-50:0.9.01" + self._voltage = 0.0 + self._voltage_setpoint = 0.0 + self._current = 0.0 + self._current_setpoint = 0.0 + self._connected = True + self._status = { + "ON/OFF": False, + "Fault condition": False, + "Control mode": "Remote", + "Regulation mode": False, + "Update mode": "Normal", + "Ramping": False, + "Waveform": False, + "OVT": False, + "Mains fault": False, + "Earth leakage": False, + "Earth fuse": False, + "Regulation fault": False, + "Ext. interlock #1": False, + "Ext. interlock #2": False, + "Ext. interlock #3": False, + "Ext. interlock #4": False, + "DCCT fault": False, + "OVP": False, + } + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + @property + def model_number_and_firmware(self): + """Returns the model number and firmware version.""" + return self._model_no_and_firmware + + @property + def status(self): + """Returns the status of the device.""" + return self._status + + @property + def voltage(self): + """Returns voltage to 6 decimal places.""" + return "{0:.6f}".format(self._voltage) + + @property + def voltage_setpoint(self): + """Returns last voltage setpoint to 6 decimal places.""" + return "{0:.6f}".format(self._voltage_setpoint) + + def try_setting_voltage_setpoint(self, value): + """Sets the voltage setpoint. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code.). + """ + if not self._status["ON/OFF"]: + return "#NAK:13" + else: + value = float(value) + self._voltage_setpoint = value + self._voltage = value + return "#AK" + + @property + def current(self): + """Returns current to 6 decimal places.""" + return "{0:.6f}".format(self._current) + + @property + def current_setpoint(self): + """Returns current setpoint to 6 decimal places.""" + return "{0:.6f}".format(self._current_setpoint) + + def try_setting_current_setpoint(self, value): + """Sets the current setpoint. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code). + """ + if not self._status["ON/OFF"]: + return "#NAK:13" + else: + value = float(value) + self._current_setpoint = value + self._current = value + return "#AK" + + def start_device(self): + """Starts the device. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code). + """ + if self._status["ON/OFF"]: + return "#NAK:09" + else: + self._status["ON/OFF"] = True + return "#AK" + + def stop_device(self): + """Stops the device. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code). + """ + if not self._status["ON/OFF"]: + return "#NAK:13" + else: + self._status["ON/OFF"] = False + self._voltage = 0.00000 + self._current = 0.00000 + return "#AK" + + def reset_device(self): + """Resets the device. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code). + """ + for key in self._status: + if key == "Control mode": + self._status[key] = "Remote" + elif key == "Update mode": + self._status[key] = "Normal" + else: + self._status[key] = False + + self._voltage = 0 + self._voltage_setpoint = 0 + self._current = 0 + self._current_setpoint = 0 + return "#AK" + + @property + def connected(self): + """Connected status of the device. + + Returns: + True if the device is connected. False otherwise. + """ + return self._connected + + def connect(self): + """Connects the device.""" + self._connected = True + + def disconnect(self): + """Disconnects the device.""" + self._connected = False + + def fault(self, fault_name): + """Sets the status depending on the fault. Set only via the backdoor. + + Raises: + ValueError if fault_name is not a recognised fault. + """ + if fault_name in self._status: + self._status[fault_name] = True + else: + raise ValueError("Could not find {}".format(fault_name)) diff --git a/lewis/devices/ngpspsu/interfaces/__init__.py b/lewis/devices/ngpspsu/interfaces/__init__.py new file mode 100644 index 00000000..de04bd90 --- /dev/null +++ b/lewis/devices/ngpspsu/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import NgpspsuStreamInterface + +__all__ = ["NgpspsuStreamInterface"] diff --git a/lewis/devices/ngpspsu/interfaces/device_status.py b/lewis/devices/ngpspsu/interfaces/device_status.py new file mode 100644 index 00000000..91325699 --- /dev/null +++ b/lewis/devices/ngpspsu/interfaces/device_status.py @@ -0,0 +1,94 @@ +# Class and function to help convert status into 8 hexadecimal characters + +NUMBER_OF_BITS = 32 +NUMBER_OF_HEXADECIMAL_CHARACTERS = 8 + + +class DeviceStatus(object): + """Converts the device's status to a list of 8 hexadecimal characters. + """ + + _REFERENCE = { + "0": "ON/OFF", + "1": "Fault condition", + "2": "Control mode", + "5": "Regulation mode", + "6": "Update mode", + "12": "Ramping", + "13": "Waveform", + "20": "OVT", + "21": "Mains fault", + "22": "Earth leakage", + "23": "Earth fuse", + "24": "Regulation fault", + "26": "Ext. interlock #1", + "27": "Ext. interlock #2", + "28": "Ext. interlock #3", + "29": "Ext. interlock #4", + "30": "DCCT fault", + "31": "OVP", + } + + _CONTROL_MODE = {"Remote": [False, False], "Local": [False, True]} + + _UPDATE_MODE = { + "Normal": [False, False], + "Waveform": [False, True], + "Triggered FIFO": [True, False], + "Analog Input": [True, True], + } + + def __init__(self, status): + self._status = status + self._bits = [False for _ in range(0, NUMBER_OF_BITS)] + + def in_hexadecimal(self): + """Returns the status of the device as a string of hexadecimal values. + + Returns: + string: 8 hexadecimal values 0-F. + """ + self._convert_status_to_bits() + hexadecimals = self._get_hexadecimals(NUMBER_OF_HEXADECIMAL_CHARACTERS) + return "".join(hexadecimals) + + def _convert_status_to_bits(self): + for key, value in self._REFERENCE.items(): + if value == "Control mode": + self._bits[2:4] = self._CONTROL_MODE[self._status["Control mode"]] + elif value == "Update mode": + self._bits[6:8] = self._UPDATE_MODE[self._status["Update mode"]] + else: + self._bits[int(key)] = self._status[value] + + def _get_hexadecimals(self, number_of_digits): + bits = list(reversed(self._bits)) + + return convert_to_hexadecimal(bits, number_of_digits) + + +def convert_to_hexadecimal(bits, padding): + """Converts bits to a hexadecimal character with padding. + + E.g. + Converts [False, False, False, True], 0 to "1". + Converts [True, False, False, False], 2 to "08" + + Args: + bits: List of boolean values. + padding: Integer of number of 0 padded places. + + Returns: + string: Zero padded hexadecimal number. + """ + bits_as_strings = ["1" if bit else "0" for bit in bits] + + bits_base_2 = int("".join(bits_as_strings), 2) + + zero_padded_eight_digit_hexadecimal_with_prefix = "{0:#0{1}x}".format(bits_base_2, padding + 2) + + zero_padded_eight_digit_hexadecimal_without_prefix = ( + zero_padded_eight_digit_hexadecimal_with_prefix[2:] + ) + + return zero_padded_eight_digit_hexadecimal_without_prefix.upper() diff --git a/lewis/devices/ngpspsu/interfaces/stream_interface.py b/lewis/devices/ngpspsu/interfaces/stream_interface.py new file mode 100644 index 00000000..463cf65f --- /dev/null +++ b/lewis/devices/ngpspsu/interfaces/stream_interface.py @@ -0,0 +1,148 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +from lewis_emulators.ngpspsu.interfaces.device_status import DeviceStatus + +if_connected = conditional_reply("connected") + + +class NgpspsuStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_version").escape("VER").eos().build(), + CmdBuilder("start").escape("MON").eos().build(), + CmdBuilder("stop").escape("MOFF").eos().build(), + CmdBuilder("read_status").escape("MST").eos().build(), + CmdBuilder("reset").escape("MRESET").eos().build(), + CmdBuilder("read_voltage").escape("MRV").build(), + CmdBuilder("set_voltage_setpoint").escape("MWV:").float().eos().build(), + CmdBuilder("read_voltage_setpoint").escape("MWV:?").eos().build(), + CmdBuilder("read_current").escape("MRI").eos().build(), + CmdBuilder("set_current_setpoint").escape("MWI:").float().eos().build(), + CmdBuilder("read_current_setpoint").escape("MWI:?").eos().build(), + } + + out_terminator = "\r\n" + in_terminator = "\r" + + def handle_error(self, request, error): + """Prints an error message if a command is not recognised. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + None. + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + print("An error occurred at request {}: {}".format(request, error)) + + @if_connected + def get_version(self): + """Returns the model number and firmware of the device + + E.g. "#VER:NGPS 100-50:0.9.01" where "NGPS 100-50" is the model + number and "0.9.01" is the firmware number. + """ + return "#VER:{}".format(self._device.model_number_and_firmware) + + @if_connected + def start(self): + """Starts the device. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code.). + """ + return self._device.start_device() + + @if_connected + def stop(self): + """Stops the device. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code.). + """ + return self._device.stop_device() + + @if_connected + def read_status(self): + """Reads the status of the device. + + Returns: + string: The status of the device as a string of 8 hexadecimal digits. + """ + device_status = DeviceStatus(self._device.status) + hexadecimal_status = device_status.in_hexadecimal() + return "#MST:{}".format(hexadecimal_status) + + @if_connected + def reset(self): + """Resets the device. + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code.). + """ + return self._device.reset_device() + + @if_connected + def read_voltage(self): + """Reads the voltage. + + Returns: + string: "#MRV:%f" where %f is the voltage of the device to 6 decimal places. + """ + return "#MRV:{}".format(self._device.voltage) + + @if_connected + def set_voltage_setpoint(self, value): + """Sets the voltage setpoint. + + Args: + value: string of a decimal to 6 decimal places + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code.). + """ + return self._device.try_setting_voltage_setpoint(value) + + @if_connected + def read_voltage_setpoint(self): + """Reads the voltage setpoint. + + Returns: + string: #MWV:%f" where %f is the voltage setpoint value. + """ + return "#MWV:{}".format(self._device.voltage_setpoint) + + @if_connected + def read_current(self): + """Reads the current. + + Returns: + string: "#MRI:%f" where %f is the current of the device to 6 decimal places. + """ + return "#MRI:{}".format(self._device.current) + + @if_connected + def set_current_setpoint(self, value): + """Sets the current setpoint. + + Args: + value: string of a decimal to 6 decimal places + + Returns: + string: "#AK" if successful, #NK:%i if not (%i is an error code.). + """ + return self._device.try_setting_current_setpoint(value) + + @if_connected + def read_current_setpoint(self): + """Reads the current setpoint. + + Returns: + string: #MWV:%f" where %f is the current setpoint value. + """ + return "#MWI:{}".format(self._device.current_setpoint) diff --git a/lewis/devices/ngpspsu/states.py b/lewis/devices/ngpspsu/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/ngpspsu/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/ngpspsu/tests/__init__.py b/lewis/devices/ngpspsu/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lewis/devices/ngpspsu/tests/test_device_status.py b/lewis/devices/ngpspsu/tests/test_device_status.py new file mode 100644 index 00000000..aeecb1fb --- /dev/null +++ b/lewis/devices/ngpspsu/tests/test_device_status.py @@ -0,0 +1,179 @@ +import unittest + +from hamcrest import assert_that, equal_to, is_ + +from lewis_emulators.ngpspsu.interfaces.device_status import DeviceStatus, convert_to_hexadecimal + + +class DeviceStatusTests(unittest.TestCase): + """Tests that the device status is correctly converted. + """ + + def test_that_GIVEN_a_blank_device_status_THEN_all_hex_characters_zero(self): + # Given: + status = { + "ON/OFF": False, + "Fault condition": False, + "Control mode": "Remote", + "Regulation mode": False, + "Update mode": "Normal", + "Ramping": False, + "Waveform": False, + "OVT": False, + "Mains fault": False, + "Earth leakage": False, + "Earth fuse": False, + "Regulation fault": False, + "Ext. interlock #1": False, + "Ext. interlock #2": False, + "Ext. interlock #3": False, + "Ext. interlock #4": False, + "DCCT fault": False, + "OVP": False, + } + device_status = DeviceStatus(status) + + # When: + result = device_status.in_hexadecimal() + + # Then: + expected = "0" * 8 + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_a_on_device_status_THEN_00000001_is_returned(self): + # Given: + status = { + "ON/OFF": True, + "Fault condition": False, + "Control mode": "Remote", + "Regulation mode": False, + "Update mode": "Normal", + "Ramping": False, + "Waveform": False, + "OVT": False, + "Mains fault": False, + "Earth leakage": False, + "Earth fuse": False, + "Regulation fault": False, + "Ext. interlock #1": False, + "Ext. interlock #2": False, + "Ext. interlock #3": False, + "Ext. interlock #4": False, + "DCCT fault": False, + "OVP": False, + } + device_status = DeviceStatus(status) + + # When: + result = device_status.in_hexadecimal() + + # Then: + expected = "0" * 7 + "1" + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_a_fault_condition_on_an_on_device_THEN_00000003_is_returned(self): + # Given: + status = { + "ON/OFF": True, + "Fault condition": True, + "Control mode": "Remote", + "Regulation mode": False, + "Update mode": "Normal", + "Ramping": False, + "Waveform": False, + "OVT": False, + "Mains fault": False, + "Earth leakage": False, + "Earth fuse": False, + "Regulation fault": False, + "Ext. interlock #1": False, + "Ext. interlock #2": False, + "Ext. interlock #3": False, + "Ext. interlock #4": False, + "DCCT fault": False, + "OVP": False, + } + device_status = DeviceStatus(status) + + # When: + result = device_status.in_hexadecimal() + + # Then: + expected = "0" * 7 + "3" + assert_that(result, is_(equal_to(expected))) + + +class ConvertBitsToHexTests(unittest.TestCase): + """Tests for converting a word of 4 bits to the + corresponding hexadecimal character. + """ + + def test_that_GIVEN_4_off_bits_THEN_0_is_returned(self): + # Given: + word = [False, False, False, False] + + # When: + result = convert_to_hexadecimal(word, 1) + + # Then: + expected = "0" + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_the_last_bit_on_THEN_one_is_returned(self): + # Given: + word = [False, False, False, True] + + # When: + result = convert_to_hexadecimal(word, 2) + + # Then: + expected = "01" + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_a_byte_and_padding_of_5_THEN_a_5_padded_two_digit_hex_character_is_returned( + self, + ): + # Given: + word = [True, False, False, False, True, True, False, False] + + # When: + result = convert_to_hexadecimal(word, 5) + + # Then: + expected = "0" * 3 + "8C" + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_32_bits_THEN_a_zero_padded_8_digit_hex_number_is_returned(self): + # Given: + bits = [False for _ in range(0, 32)] + + # When: + result = convert_to_hexadecimal(bits, 8) + + # Then: + expected = "0" * 8 + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_32_bits_with_zeroth_one_on_THEN_00000001_is_returned(self): + # Given: + bits = [False for _ in range(0, 32)] + bits[-1] = True + + # When: + result = convert_to_hexadecimal(bits, 8) + + # Then: + expected = "0" * 7 + "1" + assert_that(result, is_(equal_to(expected))) + + def test_that_GIVEN_32_bits_with_5th_bit_on_THEN_00000010_is_returned(self): + # Given: + bits = [False for _ in range(0, 32)] + bits[-5] = True + + # When: + result = convert_to_hexadecimal(bits, 8) + + # Then: + expected = "0" * 6 + "10" + assert_that(result, is_(equal_to(expected))) diff --git a/lewis/devices/oercone/__init__.py b/lewis/devices/oercone/__init__.py new file mode 100644 index 00000000..b664af3f --- /dev/null +++ b/lewis/devices/oercone/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedOercone + +__all__ = ["SimulatedOercone"] diff --git a/lewis/devices/oercone/device.py b/lewis/devices/oercone/device.py new file mode 100644 index 00000000..76995fa0 --- /dev/null +++ b/lewis/devices/oercone/device.py @@ -0,0 +1,114 @@ +from collections import OrderedDict +from enum import Enum, unique + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +@unique +class Units(Enum): + mbar = 0 + Torr = 1 + Pascal = 2 + Micron = 3 + + +@unique +class ReadState(Enum): + PR1 = 0 + UNI = 1 + + +class SimulatedOercone(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._pressure = 0 + self._measurement_unit = Units.mbar + self._read_state = None + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + @property + def pressure(self): + """Returns the value of the pressure sensor. + + Returns: + float: Pressure value. + """ + return self._pressure + + @pressure.setter + def pressure(self, pressure): + """Sets the pressure sensor. + + Args: + pressure: Value to set pressure sensor to. + + Returns: + None + """ + self._pressure = pressure + + @property + def measurement_unit(self): + """Returns the value of the pressure sensor. + + Returns: + Units: The enum unit currently in use e.g. Units.Micron. + """ + return self._measurement_unit + + @measurement_unit.setter + def measurement_unit(self, units): + """Sets the curent units. + + Args: + units (Units member): Enum value to set the units to. + + Returns: + None + """ + self._measurement_unit = units + + def backdoor_set_units(self, unit): + """Sets unit on device. Called only via the backdoor using lewis. + + Args: + unit: integer 0, 1, 2, or 3 + + Returns: + None + """ + self.measurement_unit = Units(int(unit)) + + @property + def read_state(self): + """Returns the readstate for the device + + Returns: + Enum: Readstate of the device. + """ + return self._read_state + + @read_state.setter + def read_state(self, state): + """Sets the readstate of the device + + Args: + state: Enum readstate of the device to be set + + Returns: + None + """ + self._read_state = state diff --git a/lewis/devices/oercone/interfaces/__init__.py b/lewis/devices/oercone/interfaces/__init__.py new file mode 100644 index 00000000..c3ef8db6 --- /dev/null +++ b/lewis/devices/oercone/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import OerconeStreamInterface + +__all__ = ["OerconeStreamInterface"] diff --git a/lewis/devices/oercone/interfaces/stream_interface.py b/lewis/devices/oercone/interfaces/stream_interface.py new file mode 100644 index 00000000..ace8ae9e --- /dev/null +++ b/lewis/devices/oercone/interfaces/stream_interface.py @@ -0,0 +1,127 @@ +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.constants import ACK, ENQ + +from ..device import ReadState, Units + + +@has_log +class OerconeStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("handle_enquiry").escape(ENQ).build(), + CmdBuilder("acknowledge_pressure").escape("PR1").eos().build(), + CmdBuilder("acknowledge_measurement_unit").escape("UNI").eos().build(), + CmdBuilder("acknowledge_set_measurement_unit") + .escape("UNI,") + .arg("0|1|2|3", argument_mapping=int) + .eos() + .build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + """Prints and logs an error message if a command is not recognised. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + String: The error string. + """ + err_string = 'command was: "{}", error was: {}: {}\n'.format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def handle_enquiry(self): + """Handles an enquiry using the last command sent. + + Returns: + String: Channel pressure if last command was in channels. + String: Returns the devices current units if last command is 'UNI'. + None: Sets the devices units to 1,2, or 3 if last command is 'UNI{}' where {} is 1, 2 or 3 + respectively. + None: Last command unknown. + """ + self.log.info("Mode: {}".format(self._device._read_state.name)) + + if self._device._read_state.name == "PR1": + return self.get_pressure() + elif self._device._read_state.name == "UNI": + return self.get_measurement_unit() + else: + self.log.info( + "Last command was unknown. Current readstate is {}.".format( + self._device._read_state + ) + ) + print( + "Last command was unknown. Current readstate is {}.".format( + self._device._read_state + ) + ) + + def acknowledge_pressure(self): + """Acknowledges a request to get the pressure and stores the request. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device._read_state = ReadState["PR1"] + return ACK + + def acknowledge_measurement_unit(self): + """Acknowledge that the request to get the units was received. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device._read_state = ReadState["UNI"] + return ACK + + def acknowledge_set_measurement_unit(self, units): + """Acknowledge that the request to set the units was received. + + Args: + units (integer): Takes the value 1, 2 or 3. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device._read_state = ReadState["UNI"] + self._device.measurement_unit = Units(units) + return ACK + + def get_pressure(self): + """Gets the pressure for the device. + + Returns: + String: Pressure from the channel. + """ + return "0,{}".format(self._device.pressure) + + def get_measurement_unit(self): + """Gets the units of the device. + + Returns: + Name of the units. + """ + return "{}".format(self._device.measurement_unit.value) + + def set_measurement_unit(self, units): + """Sets the units on the device. + + Args: + units (Units member): Units to be set + + Returns: + None + """ + self._device.measurement_unit = Units(units) diff --git a/lewis/devices/oercone/states.py b/lewis/devices/oercone/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/oercone/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/pt2025/__init__.py b/lewis/devices/pt2025/__init__.py new file mode 100644 index 00000000..13339a45 --- /dev/null +++ b/lewis/devices/pt2025/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedPt2025 + +__all__ = ["SimulatedPt2025"] diff --git a/lewis/devices/pt2025/device.py b/lewis/devices/pt2025/device.py new file mode 100644 index 00000000..a6cb541d --- /dev/null +++ b/lewis/devices/pt2025/device.py @@ -0,0 +1,31 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedPt2025(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.status = "" + self.data = "" + + def reset_values(self): + """Public method that re-initializes the device's fields. + :return: Nothing. + """ + self._initialize_data() + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/pt2025/interfaces/__init__.py b/lewis/devices/pt2025/interfaces/__init__.py new file mode 100644 index 00000000..42a7c99c --- /dev/null +++ b/lewis/devices/pt2025/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Pt2025StreamInterface + +__all__ = ["Pt2025StreamInterface"] diff --git a/lewis/devices/pt2025/interfaces/stream_interface.py b/lewis/devices/pt2025/interfaces/stream_interface.py new file mode 100644 index 00000000..7ed78335 --- /dev/null +++ b/lewis/devices/pt2025/interfaces/stream_interface.py @@ -0,0 +1,41 @@ + +import threading + +from lewis.adapters.stream import StreamInterface +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") +DATA = "L12.123456T" +NUMBER_OF_MESSAGES = 10 + + +class Pt2025StreamInterface(StreamInterface): + commands = {} + in_terminator = "\r\n" + out_terminator = "\r\n" + + def __init__(self): + super(Pt2025StreamInterface, self).__init__() + self._queue_next_unsolicited_message() + + def _queue_next_unsolicited_message(self): + timer = threading.Timer(1.0, self.get_data_unsolicited) + timer.daemon = True + timer.start() + + def get_data_unsolicited(self): + self._queue_next_unsolicited_message() + + if not self.device.connected: + return + + try: + handler = self.handler + except AttributeError: + # Happens if no client is currently connected. + return + else: + handler.unsolicited_reply(str(self.device.data)) + + def handle_error(self, request, error): + pass diff --git a/lewis/devices/pt2025/states.py b/lewis/devices/pt2025/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/pt2025/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/rkndio/__init__.py b/lewis/devices/rkndio/__init__.py new file mode 100644 index 00000000..5e3c22fc --- /dev/null +++ b/lewis/devices/rkndio/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedRkndio + +__all__ = ["SimulatedRkndio"] diff --git a/lewis/devices/rkndio/device.py b/lewis/devices/rkndio/device.py new file mode 100644 index 00000000..d9a677f7 --- /dev/null +++ b/lewis/devices/rkndio/device.py @@ -0,0 +1,129 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + +NUMBER_OF_D_Is = 6 +NUMBER_OF_D_Os = 6 + + +class SimulatedRkndio(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._idn = "RIKENFE Prototype v2.0" + self._connected = True + self.reset_error() + self._input_states = ["FALSE"] * NUMBER_OF_D_Is + self._output_states = ["FALSE"] * NUMBER_OF_D_Os + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + @property + def idn(self): + """Returns the IDN of the device. + + Returns: + (string): The IDN of the device. + """ + return self._idn + + @property + def connected(self): + return self._connected + + def get_input_state(self, pin): + """Gets the state + + Args: + pin: Pin number of DO + + Returns: + State pin value - True or False. + Error: if pin not in range. + """ + pin = int(pin) + if pin in range(2, 8): + return self._input_states[pin - 2] + self.reset_error() + else: + self.error = "The pin is not readable" + self.status = "The pin is not readable" + return "ERROR" + + def set_input_state_via_the_backdoor(self, pin, state): + """Sets the read state of a pin. Called only via the backdoor. + + Args: + pin: pin number (int 2-7) + state: True or False + + Returns: + None + """ + self._input_states[pin - 2] = state + + def set_output_state(self, pin, state): + """Gets the state + + Args: + pin: Pin number of DI + state (string): TRUE or FALSE + + Returns: + None + """ + pin = int(pin) + if pin not in range(8, 14): + self._device.error = "The pin is not writeable" + self._device.status = "The pin is not writeable" + return "ERROR" + elif state in ["TRUE", "FALSE"]: + self._output_states[pin - 8] = state + self.reset_error() + return "OK" + else: + self._device.error = "Cannot set pin {} to {}".format(pin, state) + self._device.status = "Cannot set pin {} to {}".format(pin, state) + return "ERROR" + + def get_output_state_via_the_backdoor(self, pin): + """Gets the set state of a pin. Called only via the backdoor. + + Args: + pin: pin number (int 8-13) + + Returns: + (string): True or False + """ + return self._output_states[pin - 8] + + def connect(self): + """Connects the device. + + Returns: + None + """ + self._connected = True + + def disconnect(self): + """Disconnects the device. + + Returns: + Nome + """ + self._connected = False + + def reset_error(self): + self.error = "No error" + self.status = "No error" diff --git a/lewis/devices/rkndio/interfaces/__init__.py b/lewis/devices/rkndio/interfaces/__init__.py new file mode 100644 index 00000000..2c2d0910 --- /dev/null +++ b/lewis/devices/rkndio/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import RkndioStreamInterface + +__all__ = ["RkndioStreamInterface"] diff --git a/lewis/devices/rkndio/interfaces/stream_interface.py b/lewis/devices/rkndio/interfaces/stream_interface.py new file mode 100644 index 00000000..a9669b48 --- /dev/null +++ b/lewis/devices/rkndio/interfaces/stream_interface.py @@ -0,0 +1,57 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + + +class RkndioStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_idn").escape("*IDN?").eos().build(), + CmdBuilder("get_status").escape("STATUS").eos().build(), + CmdBuilder("get_error").escape("ERR").eos().build(), + CmdBuilder("get_d_i_state").escape("READ ").arg("2|3|4|5|6|7").eos().build(), + CmdBuilder("set_d_o_state") + .escape("WRITE ") + .arg("8|9|10|11|12|13") + .escape(" ") + .arg("FALSE|TRUE") + .eos() + .build(), + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + """Prints an error message if a command is not recognised. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + None. + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + print("An error occurred at request {}: {}".format(request, error)) + + @conditional_reply("connected") + def get_idn(self): + return self._device.idn + + @conditional_reply("connected") + def get_status(self): + return self._device.status + + @conditional_reply("connected") + def get_error(self): + return self._device.error + + @conditional_reply("connected") + def get_d_i_state(self, pin): + return self._device.get_input_state(pin) + + @conditional_reply("connected") + def set_d_o_state(self, pin, state): + return self._device.set_output_state(pin, state) diff --git a/lewis/devices/rkndio/states.py b/lewis/devices/rkndio/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/rkndio/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/rotating_sample_changer/__init__.py b/lewis/devices/rotating_sample_changer/__init__.py new file mode 100644 index 00000000..4ec5e729 --- /dev/null +++ b/lewis/devices/rotating_sample_changer/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedSampleChanger + +__all__ = ["SimulatedSampleChanger"] diff --git a/lewis/devices/rotating_sample_changer/device.py b/lewis/devices/rotating_sample_changer/device.py new file mode 100644 index 00000000..d851e2a8 --- /dev/null +++ b/lewis/devices/rotating_sample_changer/device.py @@ -0,0 +1,141 @@ +from collections import OrderedDict + +from lewis.core.logging import has_log +from lewis.core.statemachine import State +from lewis.devices import StateMachineDevice + +from .states import Errors, MovingState, SampleDroppedState + + +@has_log +class SimulatedSampleChanger(StateMachineDevice): + MIN_CAROUSEL = 1 + MAX_CAROUSEL = 20 + + # ARM_SPEED = 1.0/25.0 Arm takes 25s to raise/lower (measured on HRPD) + # ARM_SPEED = 1.0/100.0 # Arm takes 100s to raise/lower (measured on POLARIS) + CAR_SPEED = 1.0 / 6.0 # Carousel takes 6 seconds per position (measured on actual device) + + def _initialize_data(self): + self.uninitialise() + + def uninitialise(self): + self.reset_from_dropped_sample() + self.car_pos = -1 + self.car_target = -1 + self.arm_lowered = False + + def reset_from_dropped_sample(self): + self.current_err = Errors.NO_ERR + self._position_to_drop_sample = None + self._sample_retrieved = False + self.drop_persistently = False + self.arm_lowered = True + + def _get_state_handlers(self): + return { + "init": State(), + "initialising": MovingState(), + "idle": State(), + "car_moving": MovingState(), + "sample_dropped": SampleDroppedState(), + } + + def _get_initial_state(self): + return "init" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("init", "initialising"), lambda: self.car_target > 0), + (("initialising", "idle"), lambda: self.car_pos == 1), + (("idle", "car_moving"), lambda: self.car_target != self.car_pos), + ( + ("car_moving", "sample_dropped"), + lambda: self._position_to_drop_sample != None + and (self._position_to_drop_sample - self.car_pos) < 0.5, + ), + (("car_moving", "idle"), lambda: self.car_pos == self.car_target), + (("sample_dropped", "idle"), lambda: self.car_target != self.car_pos), + ] + ) + + def is_car_at_one(self): + return self.car_pos == self.MIN_CAROUSEL + + def is_moving(self): + return self._csm.state == "car_moving" + + def _check_can_move(self): + if self._csm.state == "init": + return Errors.ERR_NOT_INITIALISED + if self.arm_lowered: + return Errors.ERR_CANT_ROT_IF_NOT_UP + return Errors.NO_ERR + + def go_forward(self): + err_state = self._check_can_move() + if err_state: + return err_state + self.car_target += 1 + if self.car_target > self.MAX_CAROUSEL: + self.car_target = self.MIN_CAROUSEL + return Errors.NO_ERR + + def go_backward(self): + err_state = self._check_can_move() + if err_state: + return err_state + self.car_target -= 1 + if self.car_target < self.MIN_CAROUSEL: + self.car_target = self.MAX_CAROUSEL + return Errors.NO_ERR + + def move_to(self, position, lower_arm): + if self._csm.state == "init": + return Errors.ERR_NOT_INITIALISED + if (position < self.MIN_CAROUSEL) or (position > self.MAX_CAROUSEL): + return Errors.ERR_INV_DEST + else: + self.car_target = position + self.arm_lowered = lower_arm + return Errors.NO_ERR + + def set_arm(self, lowered): + if self._csm.state == "init": + return Errors.ERR_NOT_INITIALISED + if lowered == self.arm_lowered: + if lowered: + return Errors.ERR_ARM_DROPPED + else: + return Errors.ERR_ARM_UP + self.arm_lowered = lowered + return Errors.NO_ERR + + def get_arm_lowered(self): + return self.arm_lowered + + def init(self): + self.arm_lowered = False + self.car_target = self.MIN_CAROUSEL + self.current_err = Errors.NO_ERR + return Errors.NO_ERR + + @property + def sample_retrieved(self): + return self._sample_retrieved + + @sample_retrieved.setter + def sample_retrieved(self, val): + if not self.drop_persistently: + self._sample_retrieved = val + self.position_to_drop_sample = None + self.arm_lowered = True + + @property + def position_to_drop_sample(self): + return self._position_to_drop_sample + + @position_to_drop_sample.setter + def position_to_drop_sample(self, value): + self._position_to_drop_sample = value diff --git a/lewis/devices/rotating_sample_changer/interfaces/HRPD_stream_interface.py b/lewis/devices/rotating_sample_changer/interfaces/HRPD_stream_interface.py new file mode 100644 index 00000000..40bcdeeb --- /dev/null +++ b/lewis/devices/rotating_sample_changer/interfaces/HRPD_stream_interface.py @@ -0,0 +1,100 @@ +from lewis.adapters.stream import Cmd, StreamInterface + +from ..states import Errors + + +class HRPDSampleChangerStreamInterface(StreamInterface): + protocol = "HRPD" + + commands = { + Cmd("get_id", "^id$"), + Cmd("get_position", "^po$"), + Cmd("get_status", "^st$"), + Cmd("go_back", "^bk$"), + Cmd("go_fwd", "^fw$"), + Cmd("halt", "^ht$"), + Cmd("initialise", "^in$"), + Cmd("lower_arm", "^lo$"), + Cmd("move_to", "^ma([0-9]{2})$", argument_mappings=[int]), + Cmd("move_to_without_lowering", "^mn([0-9]{2})$", argument_mappings=[int]), + Cmd("raise_arm", "^ra$"), + Cmd("read_variable", "^vr([0-9]{4})$", argument_mappings=[int]), + Cmd("retrieve_sample", "^rt$"), + } + + error_codes = { + Errors.NO_ERR: 0, + Errors.ERR_INV_DEST: 5, + Errors.ERR_NOT_INITIALISED: 6, + Errors.ERR_ARM_DROPPED: 7, + Errors.ERR_ARM_UP: 8, + Errors.ERR_CANT_ROT_IF_NOT_UP: 7, + } + + in_terminator = "\r" + out_terminator = "\r\n" + + def _check_error_code(self, code): + if code == Errors.NO_ERR: + return "ok" + else: + self._device.current_err = code + return "rf-%02d" % code + + def get_id(self): + return "0001 0001 ISIS HRPD Sample Changer V1.00" + + def get_position(self): + return "Position = {:2d}".format(int(self._device.car_pos)) + + def get_status(self): + lowered = self._device.get_arm_lowered() + + # Based on testing with actual device, appears to be different than doc + return_string = "01000{0:b}01{1:b}{2:b}{3:b}00000" + return_string = return_string.format( + not lowered, self._device.is_car_at_one(), not lowered, lowered + ) + + return_string += " 0 {:b}".format(self._device.is_moving()) + + return_error = int(self.error_codes[self._device.current_err]) + + return_string += " {:2d}".format(return_error) + return_string += " {:2d}".format(int(self._device.car_pos)) + + return return_string + + def go_back(self): + return self._check_error_code(self._device.go_backward()) + + def go_fwd(self): + return self._check_error_code(self._device.go_forward()) + + def read_variable(self, variable): + return "- VR " + str(variable) + " = 17 hx 11" + + def halt(self): + return "ok" + + def initialise(self): + return self._check_error_code(self._device.init()) + + def move_to(self, position): + return self._check_error_code(self._device.move_to(position, True)) + + def move_to_without_lowering(self, position): + return self._check_error_code(self._device.move_to(position, False)) + + def lower_arm(self): + return self._check_error_code(self._device.set_arm(True)) + + def raise_arm(self): + return self._check_error_code(self._device.set_arm(False)) + + def retrieve_sample(self): + self._device.sample_retrieved = True + return "ok" + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) diff --git a/lewis/devices/rotating_sample_changer/interfaces/POLARIS_stream_interface.py b/lewis/devices/rotating_sample_changer/interfaces/POLARIS_stream_interface.py new file mode 100644 index 00000000..df06dacc --- /dev/null +++ b/lewis/devices/rotating_sample_changer/interfaces/POLARIS_stream_interface.py @@ -0,0 +1,89 @@ +from lewis.adapters.stream import Cmd, StreamInterface + +from ..states import Errors + + +class POLARISSampleChangerStreamInterface(StreamInterface): + protocol = "POLARIS" + + commands = { + Cmd("get_id", "^id$"), + Cmd("get_position", "^po$"), + Cmd("get_status", "^st$"), + Cmd("go_back", "^bk$"), + Cmd("go_fwd", "^fw$"), + Cmd("halt", "^ht$"), + Cmd("initialise", "^in$"), + Cmd("lower_arm", "^lo$"), + Cmd("move_to", "^ma(0[1-9]|[1][0-9]|20)$", argument_mappings=[int]), + Cmd("move_to_without_lowering", "^mn(0[1-9]|[1][0-9]|20)$", argument_mappings=[int]), + Cmd("raise_arm", "^ra$"), + Cmd("retrieve_sample", "^rt$"), + } + + error_codes = { + Errors.NO_ERR: 0, + Errors.ERR_INV_DEST: 5, + Errors.ERR_NOT_INITIALISED: 6, + Errors.ERR_ARM_DROPPED: 7, + Errors.ERR_ARM_UP: 8, + Errors.ERR_CANT_ROT_IF_NOT_UP: 10, + } + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def get_id(self): + return "0001 0001 ISIS Polaris Sample Changer V" + + def get_position(self): + return "Position = {:2d}".format(int(self._device.car_pos)) + + def get_status(self): + lowered = self._device.get_arm_lowered() + + # Based on testing with actual device, appears to be different than doc + return_string = "{0:b}01{1:b}{2:b}{3:b}0{4:b}" + return_string = return_string.format( + not lowered, + self._device.is_car_at_one(), + not lowered, + lowered, + self._device.is_moving(), + ) + + return_string += "{:1d}".format(int(self._device.current_err)) + return_string += " {:2d}".format(int(self._device.car_pos)) + + return return_string + + def go_back(self): + self._device.go_backward() + + def go_fwd(self): + self._device.go_forward() + + def halt(self): + return "" + + def initialise(self): + self._device.init() + + def move_to(self, position): + self._device.move_to(position, True) + + def move_to_without_lowering(self, position): + self._device.move_to(position, False) + + def lower_arm(self): + self._device.set_arm(True) + + def raise_arm(self): + self._device.set_arm(False) + + def retrieve_sample(self): + self._device.sample_retrieved = True + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) + return "??" diff --git a/lewis/devices/rotating_sample_changer/interfaces/__init__.py b/lewis/devices/rotating_sample_changer/interfaces/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lewis/devices/rotating_sample_changer/states.py b/lewis/devices/rotating_sample_changer/states.py new file mode 100644 index 00000000..11f4ca95 --- /dev/null +++ b/lewis/devices/rotating_sample_changer/states.py @@ -0,0 +1,36 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class Errors(object): + NO_ERR = 0 + ERR_INV_DEST = 5 + ERR_NOT_INITIALISED = 6 + ERR_ARM_DROPPED = 7 + ERR_ARM_FAILED_TO_LOWER = 8 + ERR_ARM_UP = 8 + ERR_CANT_ROT_IF_NOT_UP = 10 + + +class MovingState(State): + def on_entry(self, dt): + self._context.arm_lowered = False + + def in_state(self, dt): + self._context.car_pos = approaches.linear( + self._context.car_pos, self._context.car_target, self._context.CAR_SPEED, dt + ) + + def on_exit(self, dt): + self._context.arm_lowered = True + + +class SampleDroppedState(State): + def on_entry(self, dt): + self._context.log.info("Entered sample dropped state.") + self._context.current_err = Errors.ERR_ARM_DROPPED + self._context.car_target = self._context.car_pos + + def on_exit(self, dt): + self._context.current_err = Errors.NO_ERR + self._context.log.info("Exited sample dropped state.") diff --git a/lewis/devices/skf_chopper/__init__.py b/lewis/devices/skf_chopper/__init__.py new file mode 100644 index 00000000..ca312cb6 --- /dev/null +++ b/lewis/devices/skf_chopper/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedSKFChopper + +__all__ = ["SimulatedSKFChopper"] diff --git a/lewis/devices/skf_chopper/device.py b/lewis/devices/skf_chopper/device.py new file mode 100644 index 00000000..f59c1787 --- /dev/null +++ b/lewis/devices/skf_chopper/device.py @@ -0,0 +1,45 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedSKFChopper(StateMachineDevice): + """Simulated SKF chopper. Very basic and only provides frequency as a parameter for now + to perform a basic check of modbus comms. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.connected = True + + self.freq = 40 + self.send_ok_transid = True + + self.v13_norm = 0 + self.w13_norm = 0 + self.v24_norm = 0 + self.w24_norm = 0 + self.z12_norm = 0 + self.v13_fsv = 0 + self.w13_fsv = 0 + self.v24_fsv = 0 + self.w24_fsv = 0 + self.z12_fsv = 0 + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() diff --git a/lewis/devices/skf_chopper/interfaces/__init__.py b/lewis/devices/skf_chopper/interfaces/__init__.py new file mode 100644 index 00000000..8f6927ac --- /dev/null +++ b/lewis/devices/skf_chopper/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .modbus_interface import SKFChopperModbusInterface + +__all__ = ["SKFChopperModbusInterface"] diff --git a/lewis/devices/skf_chopper/interfaces/modbus_interface.py b/lewis/devices/skf_chopper/interfaces/modbus_interface.py new file mode 100644 index 00000000..8e3674a4 --- /dev/null +++ b/lewis/devices/skf_chopper/interfaces/modbus_interface.py @@ -0,0 +1,178 @@ +from os import urandom + +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.byte_conversions import float_to_raw_bytes, int_to_raw_bytes, raw_bytes_to_int +from lewis.utils.replies import conditional_reply + + +def log_replies(f): + def _wrapper(self, *args, **kwargs): + result = f(self, *args, **kwargs) + self.log.info(f"Reply in {f.__name__}: {result}") + return result + + return _wrapper + + +@has_log +class SKFChopperModbusInterface(StreamInterface): + """This implements the modbus stream interface for an skf chopper. + This is not a full implementation of the device and just handles frequency for now to check + that modbus comms work OK. + """ + + commands = { + Cmd("any_command", r"^([\s\S]*)$", return_mapping=lambda x: x), + } + + def __init__(self): + super().__init__() + self.read_commands = { + 353: self.get_freq, # RBV + 345: self.get_freq, # SP:RBV + 905: self.get_v13_norm, + 906: self.get_w13_norm, + 907: self.get_v24_norm, + 908: self.get_w24_norm, + 909: self.get_z12_norm, + 910: self.get_v13_fsv, + 911: self.get_w13_fsv, + 912: self.get_v24_fsv, + 913: self.get_w24_fsv, + 914: self.get_z12_fsv, + } + + self.write_commands = { + 345: self.set_freq, # SP + } + + in_terminator = "" + out_terminator = b"" + protocol = "stream" + + def handle_error(self, request, error): + error_message = "An error occurred at request " + repr(request) + ": " + repr(error) + print(error_message) + self.log.error(error_message) + return str(error).encode("utf-8") + + @log_replies + @conditional_reply("connected") + def any_command(self, command): + self.log.info(command) + transaction_id = command[0:2] if self.device.send_ok_transid else urandom(2) + protocol_id = command[2:4] + length = int.from_bytes(command[4:6], "big") + unit = command[6] + function_code = int(command[7]) + data = command[8:] + + if len(command[6:]) != length: + raise ValueError(f"Invalid message length, expected {length} but got {len(data)}") + + if function_code == 3: + return self.handle_read(transaction_id, protocol_id, unit, function_code, data) + elif function_code == 16: + return self.handle_write(command, data) + else: + raise ValueError(f"Unknown modbus function code: {function_code}") + + def handle_read(self, transaction_id, protocol_id, unit, function_code, data): + mem_address = raw_bytes_to_int(data[0:2], False) + words_to_read = raw_bytes_to_int(data[2:4], False) + self.log.info(f"Attempting to read {words_to_read} words from mem address: {mem_address}") + if mem_address in self.read_commands.keys(): + reply_data = self.read_commands[mem_address]() + else: + reply_data = 0 + + self.log.info(f"reply_data = {reply_data}") + + if isinstance(reply_data, float) and words_to_read == 2: + data_length = 4 + littleendian_bytes = bytearray(float_to_raw_bytes(reply_data, low_byte_first=True)) + # split up in 2-byte words, then swap endianness respectively to big endian. + # The device represents float32s in 2 words, little endian, but each of these words respectively are big endian. + + # Get the first 2 bytes, then flip endianness + first_word = littleendian_bytes[:2][::-1] + + # Do the same for the remainder + second_word = littleendian_bytes[2:][::-1] + + # Concatenate the two bytes/words + reply_data_bytes = first_word + second_word + elif isinstance(reply_data, int): + if words_to_read == 2: + data_length = 4 + littleendian_bytes = bytearray( + int_to_raw_bytes(reply_data, data_length, low_byte_first=True) + ) + first_word = littleendian_bytes[:2][::-1] + second_word = littleendian_bytes[2:][::-1] + reply_data_bytes = first_word + second_word + else: + data_length = 2 + reply_data_bytes = int_to_raw_bytes(reply_data, data_length, low_byte_first=False) + else: + raise ValueError("Unknown data type or data length") + + function_code_bytes = function_code.to_bytes(1, byteorder="big") + unit_bytes = unit.to_bytes(1, byteorder="big") + data_length_bytes = data_length.to_bytes(1, byteorder="big") + length = int(3 + data_length).to_bytes(2, byteorder="big") + + reply = ( + transaction_id + + protocol_id + + length + + unit_bytes + + function_code_bytes + + data_length_bytes + + reply_data_bytes + ) + + return reply + + def handle_write(self, command, data): + mem_address = raw_bytes_to_int(data[0:2], False) + value = raw_bytes_to_int(data[2:4], False) + self.write_commands[mem_address](value) + return command + + def get_freq(self): + return float(self.device.freq) + + def get_v13_norm(self): + return self.device.v13_norm + + def get_w13_norm(self): + return self.device.w13_norm + + def get_v24_norm(self): + return self.device.v24_norm + + def get_w24_norm(self): + return self.device.w24_norm + + def get_z12_norm(self): + return self.device.z12_norm + + def get_v13_fsv(self): + return self.device.v13_fsv + + def get_w13_fsv(self): + return self.device.w13_fsv + + def get_v24_fsv(self): + return self.device.v24_fsv + + def get_w24_fsv(self): + return self.device.w24_fsv + + def get_z12_fsv(self): + return self.device.z12_fsv + + def set_freq(self, value): + self.device.freq = value diff --git a/lewis/devices/skf_chopper/states.py b/lewis/devices/skf_chopper/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/skf_chopper/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/skf_mb350_chopper/__init__.py b/lewis/devices/skf_mb350_chopper/__init__.py new file mode 100644 index 00000000..9b02301a --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedSkfMb350Chopper + +__all__ = ["SimulatedSkfMb350Chopper"] diff --git a/lewis/devices/skf_mb350_chopper/device.py b/lewis/devices/skf_mb350_chopper/device.py new file mode 100644 index 00000000..4d3f15dd --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/device.py @@ -0,0 +1,125 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState, GoingState, StoppingState + + +class SimulatedSkfMb350Chopper(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._started = False + self.phase = 0 + self.frequency = 0 + self.frequency_setpoint = 0 + self.phase_percent_ok = 100.0 + self.phase_repeatability = 100.0 + + self.interlocks = OrderedDict( + [ + ("DSP_WD_FAIL", False), + ("OSCILLATOR_FAIL", False), + ("POSITION_SHUTDOWN", False), + ("EMERGENCY_STOP", False), + ("UPS_FAIL", False), + ("EXTERNAL_FAULT", False), + ("CC_WD_FAIL", False), + ("OVERSPEED_TRIP", False), + ("VACUUM_FAIL", False), + ("MOTOR_OVER_TEMP", False), + ("REFERENCE_SIGNAL_LOSS", False), + ("SPEED_SENSOR_LOSS", False), + ("COOLING_LOSS", False), + ("DSP_SUMMARY_SHUTDOWN", False), + ("CC_SHUTDOWN_REQ", False), + ("TEST_MODE", False), + ] + ) + + self.rotator_angle = 90 + + def set_interlock_state(self, item, value): + self.interlocks[item] = value + + def get_interlocks(self): + return self.interlocks + + def _get_state_handlers(self): + return { + "default": DefaultState(), + "going": GoingState(), + "stopping": StoppingState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict( + [ + ( + ("default", "going"), + lambda: self.frequency_setpoint != self.frequency and self._started, + ), + (("going", "stopping"), lambda: not self._started), + (("stopping", "default"), lambda: self.frequency == 0 and not self._started), + ] + ) + + def set_frequency(self, frequency): + self.frequency_setpoint = frequency + + def get_frequency(self): + return self.frequency + + def set_nominal_phase(self, phase): + self.phase = phase + + def start(self): + self._started = True + + def stop(self): + self._started = False + + def is_controller_ok(self): + return True + + def is_up_to_speed(self): + return self.frequency == self.frequency_setpoint and self._started + + def is_able_to_run(self): + return True + + def is_shutting_down(self): + return False + + def is_levitation_complete(self): + return self.is_up_to_speed() # Not really the correct condition but close enough + + def is_phase_locked(self): + return self.is_up_to_speed() # Not really the correct condition but close enough + + def get_motor_direction(self): + return 1 # Not clear if this can be set externally or only from front panel of physical device. + + def is_avc_on(self): + return True # Don't know what this is/represents. Manual doesn't help. + + def get_phase(self): + return self.phase + + def get_phase_percent_ok(self): + return self.phase_percent_ok + + def get_phase_repeatability(self): + return self.phase_repeatability + + def set_phase_repeatability(self, value): + self.phase_repeatability = value + + def get_rotator_angle(self): + return self.rotator_angle + + def set_rotator_angle(self, angle): + self.rotator_angle = angle diff --git a/lewis/devices/skf_mb350_chopper/interfaces/__init__.py b/lewis/devices/skf_mb350_chopper/interfaces/__init__.py new file mode 100644 index 00000000..b11732ba --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import SkfMb350ChopperStreamInterface + +__all__ = ["SkfMb350ChopperStreamInterface"] diff --git a/lewis/devices/skf_mb350_chopper/interfaces/crc16.py b/lewis/devices/skf_mb350_chopper/interfaces/crc16.py new file mode 100644 index 00000000..8665f4db --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/interfaces/crc16.py @@ -0,0 +1,30 @@ +from lewis.utils.byte_conversions import BYTE, int_to_raw_bytes + + +def crc16_matches(data, expected): + """:param data: The input, an iterable of characters + :param expected: The expected checksum, an iterable of two characters + :return: true if the checksum of 'input' is equal to 'expected', false otherwise. + """ + return crc16(data) == expected + + +def crc16(data): + """CRC algorithm, translated to python from the C code in appendix A of the manual. + :param data: the data to checksum + :return: the checksum + """ + crc = 0xFFFF + + for b in data: + crc ^= b + for _ in range(8): + if crc & 1: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + + crc %= BYTE**2 + + return int_to_raw_bytes(crc, 2, low_byte_first=True) diff --git a/lewis/devices/skf_mb350_chopper/interfaces/response_utilities.py b/lewis/devices/skf_mb350_chopper/interfaces/response_utilities.py new file mode 100644 index 00000000..2b20c425 --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/interfaces/response_utilities.py @@ -0,0 +1,177 @@ +from lewis.utils.byte_conversions import float_to_raw_bytes, int_to_raw_bytes + +from .crc16 import crc16 + + +def build_interlock_status(device): + """Builds an integer representation of the interlock bit-field. + :param device: the lewis device + :return: int representation of the bit field + """ + interlocks = device.get_interlocks() + + bit = 1 + result = 0 + for ilk in interlocks.values(): + result += bit if ilk else 0 + bit *= 2 + + return result + + +def build_device_status(device): + """Builds an integer representation of the device status bit-field. + :param device: the lewis device + :return: int representation of the bit field + """ + status_bits = [ + device.is_controller_ok(), + device.is_up_to_speed(), + device.is_able_to_run(), + device.is_shutting_down(), + device.is_levitation_complete(), + device.is_phase_locked(), + device.get_motor_direction() > 0, + device.is_avc_on(), + ] + + bit = 1 + result = 0 + for stat in status_bits: + result += bit if stat else 0 + bit *= 2 + return result + + +def general_status_response_packet(address, device, command): + """Returns the general response packet, the default response to any command that doesn't have a more specific response. + + Response structure is: + 8 bytes common header (see ResponseBuilder.add_common_header) + + :param address: The address of this device + :param device: The lewis device + :param command: The command number that this is a reply to + :return: The response + """ + return ResponseBuilder().add_common_header(address, command, device).build() + + +def phase_information_response_packet(address, device): + """Returns the response to the "get_phase_information" command. + + Response structure is: + 8 bytes common header (see ResponseBuilder.add_common_header) + 4 bytes (IEEE single-precision float): The current phase + 4 bytes (IEEE single-precision float): Phase repeatability + 4 bytes (IEEE single-precision float): Phase percent OK + + :param address: The address of this device + :param device: The lewis device + :return: The response + """ + return ( + ResponseBuilder() + .add_common_header(address, 0xC0, device) + .add_float(device.get_phase()) + .add_float(device.get_phase_repeatability()) + .add_float(device.get_phase_percent_ok()) + .build() + ) + + +def rotator_angle_response_packet(address, device): + """Returns the response to the "get_rotator_angle" command. + + Response structure is: + 8 bytes common header (see ResponseBuilder.add_common_header) + 4 bytes (unsigned int): The current rotator angle + + :param address: The address of this device + :param device: The lewis device + :return: The response + """ + return ( + ResponseBuilder() + .add_common_header(address, 0x81, device) + .add_int(int(device.get_rotator_angle() * 10), 4) + .build() + ) + + +def phase_time_response_packet(address, device): + """Returns the response to the "get_phase_information" command. + + Response structure is: + 8 bytes common header (see ResponseBuilder.add_common_header) + 4 bytes (unsigned int): The current rotator angle + + :param address: The address of this device + :param device: The lewis device + :return: The response + """ + return ( + ResponseBuilder() + .add_common_header(address, 0x85, device) + .add_float(device.get_phase() / 1000.0) + .build() + ) + + +class ResponseBuilder(object): + """Response builder which formats the responses as bytes. + """ + + def __init__(self): + self.response = bytearray() + + def add_int(self, value, length, low_byte_first=True): + """Adds an integer to the builder + :param value: The integer to add + :param length: How many bytes should the integer be represented as + :param low_byte_first: If true (default), put the least significant byte first. + If false, put the most significant byte first. + :return: The builder + """ + self.response += int_to_raw_bytes(value, length, low_byte_first) + return self + + def add_float(self, value): + """Adds an float to the builder (4 bytes, IEEE single-precision) + :param value: The float to add + :return: The builder + """ + self.response += float_to_raw_bytes(value) + return self + + def add_common_header(self, address, command_number, device): + """Adds the common header. + + The header bytes are as follows: + 1 byte (unsigned int): Device address + 1 byte (unsigned int): Command number + 1 byte (unsigned int): Error status (always zero in the emulator) + 1 byte (bit field): Device status bit-field + 2 bytes (bit field): Device interlock status bit-field + 2 bytes (unsigned int): Current frequency of the chopper in rpm. + + :param address: The address of this device + :param command_number: The command number that this is a reply to + :param device: The lewis device + :return: (ResponseBuilder) the builder with the common header bytes. + """ + return ( + self.add_int(address, 1) + .add_int(command_number, 1) + .add_int(0x00, 1) + .add_int(build_device_status(device), 1) + .add_int(build_interlock_status(device), 2, low_byte_first=False) + .add_int(int(device.get_frequency()), 2) + ) + + def build(self): + """Gets the response from the builder + :return: the response + """ + self.response += crc16(self.response) + return self.response diff --git a/lewis/devices/skf_mb350_chopper/interfaces/stream_interface.py b/lewis/devices/skf_mb350_chopper/interfaces/stream_interface.py new file mode 100644 index 00000000..d61b9920 --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/interfaces/stream_interface.py @@ -0,0 +1,116 @@ +from lewis.adapters.stream import Cmd, StreamInterface +from lewis.core.logging import has_log +from lewis.utils.byte_conversions import raw_bytes_to_int + +from .crc16 import crc16, crc16_matches +from .response_utilities import ( + general_status_response_packet, + phase_information_response_packet, + phase_time_response_packet, + rotator_angle_response_packet, +) + + +@has_log +class SkfMb350ChopperStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation. Match anything! + commands = { + Cmd("any_command", r"^([\s\S]*)$", return_mapping=lambda x: x), + } + + in_terminator = "\r\n" + out_terminator = b"" + + def handle_error(self, request, error): + error_message = "An error occurred at request " + repr(request) + ": " + repr(error) + print(error_message) + self.log.error(error_message) + return str(error) + + def any_command(self, command): + command_mapping = { + 0x20: self.start, + 0x30: self.stop, + 0x60: self.set_rotational_speed, + 0x81: self.get_rotator_angle, + 0x82: self.set_rotator_angle, + 0x85: self.get_phase_delay, + 0x8E: self.set_gate_width, + 0x90: self.set_nominal_phase, + 0xC0: self.get_phase_info, + } + + address = command[0] + if not 0 <= address < 16: + raise ValueError("Address should be in range 0-15") + + # Constant function code. Should always be 0x80 + if command[1] != 0x80: + raise ValueError("Function code should always be 0x80") + + command_number = command[2] + if command_number not in command_mapping.keys(): + raise ValueError("Command number should be in map") + + command_data = [c for c in command[3:-2]] + + if not crc16_matches(command[:-2], command[-2:]): + raise ValueError( + "CRC Checksum didn't match. Expected {} but got {}".format( + crc16(command[:-2]), command[-2:] + ) + ) + + return command_mapping[command_number](address, command_data) + + def start(self, address, data): + self._device.start() + return general_status_response_packet(address, self.device, 0x20) + + def stop(self, address, data): + self._device.stop() + return general_status_response_packet(address, self.device, 0x30) + + def set_nominal_phase(self, address, data): + self.log.info("Setting phase") + self.log.info("Data = {}".format(data)) + nominal_phase = raw_bytes_to_int(data) / 1000.0 + self.log.info("Setting nominal phase to {}".format(nominal_phase)) + self._device.set_nominal_phase(nominal_phase) + return general_status_response_packet(address, self.device, 0x90) + + def set_gate_width(self, address, data): + self.log.info("Setting gate width") + self.log.info("Data = {}".format(data)) + width = raw_bytes_to_int(data) + self.log.info("Setting gate width to {}".format(width)) + self._device.set_phase_repeatability(width / 10.0) + return general_status_response_packet(address, self.device, 0x8E) + + def set_rotational_speed(self, address, data): + self.log.info("Setting frequency") + self.log.info("Data = {}".format(data)) + freq = raw_bytes_to_int(data) + self.log.info("Setting frequency to {}".format(freq)) + self._device.set_frequency(freq) + return general_status_response_packet(address, self.device, 0x60) + + def set_rotator_angle(self, address, data): + self.log.info("Setting rotator angle") + self.log.info("Data = {}".format(data)) + angle_times_ten = raw_bytes_to_int(data) + self.log.info("Setting rotator angle to {}".format(angle_times_ten / 10.0)) + self._device.set_rotator_angle(angle_times_ten / 10.0) + return general_status_response_packet(address, self.device, 0x82) + + def get_phase_info(self, address, data): + self.log.info("Getting phase info") + return phase_information_response_packet(address, self._device) + + def get_rotator_angle(self, address, data): + self.log.info("Getting rotator angle") + return rotator_angle_response_packet(address, self._device) + + def get_phase_delay(self, address, data): + self.log.info("Getting phase time") + return phase_time_response_packet(address, self._device) diff --git a/lewis/devices/skf_mb350_chopper/states.py b/lewis/devices/skf_mb350_chopper/states.py new file mode 100644 index 00000000..ecd050ec --- /dev/null +++ b/lewis/devices/skf_mb350_chopper/states.py @@ -0,0 +1,18 @@ +from lewis.core import approaches +from lewis.core.statemachine import State + + +class DefaultState(State): + pass + + +class StoppingState(State): + def in_state(self, dt): + device = self._context + device.frequency = approaches.linear(device.frequency, 0, 50, dt) + + +class GoingState(State): + def in_state(self, dt): + device = self._context + device.frequency = approaches.linear(device.frequency, device.frequency_setpoint, 50, dt) diff --git a/lewis/devices/smrtmon/__init__.py b/lewis/devices/smrtmon/__init__.py new file mode 100644 index 00000000..bb51eec4 --- /dev/null +++ b/lewis/devices/smrtmon/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedSmrtmon + +__all__ = ["SimulatedSmrtmon"] diff --git a/lewis/devices/smrtmon/device.py b/lewis/devices/smrtmon/device.py new file mode 100644 index 00000000..947c0a96 --- /dev/null +++ b/lewis/devices/smrtmon/device.py @@ -0,0 +1,38 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedSmrtmon(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.connected = True + self.stat = [0.0] * 11 + self.oplm = [1.0] * 9 + self.lims = [2.0] * 9 + + def reset_values(self): + self._initialize_data() + + def set_stat(self, num_stat, stat_value): + self.stat[num_stat] = stat_value + + def set_oplm(self, num_oplm, oplm_value): + self.oplm[num_oplm] = oplm_value + + def set_lims(self, num_lims, lims_value): + self.lims[num_lims] = lims_value + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/smrtmon/interfaces/__init__.py b/lewis/devices/smrtmon/interfaces/__init__.py new file mode 100644 index 00000000..511a01b9 --- /dev/null +++ b/lewis/devices/smrtmon/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import SmrtmonStreamInterface + +__all__ = ["SmrtmonStreamInterface"] diff --git a/lewis/devices/smrtmon/interfaces/stream_interface.py b/lewis/devices/smrtmon/interfaces/stream_interface.py new file mode 100644 index 00000000..7a039051 --- /dev/null +++ b/lewis/devices/smrtmon/interfaces/stream_interface.py @@ -0,0 +1,33 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if_connected = conditional_reply("connected") + + +class SmrtmonStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_stat").escape("STAT").build(), + CmdBuilder("get_oplm").escape("OPLM").build(), + CmdBuilder("get_lims").escape("LIMS").build(), + } + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + print("An error occurred at request " + repr(request) + ": " + repr(error)) + return str(error) + + @if_connected + def get_stat(self): + # Actual device responds with the device's current time as second-to-last item + return "{},{},{},{},{},{},{},{},{},{},23:59:59,{}".format(*self._device.stat) + + @if_connected + def get_oplm(self): + return "{},{},{},{},{},{},{},{},{}".format(*self._device.oplm) + + @if_connected + def get_lims(self): + return "{},{},{},{},{},{},{},{},{}".format(*self._device.lims) diff --git a/lewis/devices/smrtmon/states.py b/lewis/devices/smrtmon/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/smrtmon/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/sp2xx/__init__.py b/lewis/devices/sp2xx/__init__.py new file mode 100644 index 00000000..66440651 --- /dev/null +++ b/lewis/devices/sp2xx/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedSp2XX + +__all__ = ["SimulatedSp2XX"] diff --git a/lewis/devices/sp2xx/device.py b/lewis/devices/sp2xx/device.py new file mode 100644 index 00000000..468cb241 --- /dev/null +++ b/lewis/devices/sp2xx/device.py @@ -0,0 +1,210 @@ +"""Items associated with WPI SP2XX syringe pump +""" + +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState +from .util_classes import ErrorType, RunStatus +from .util_constants import DIRECTIONS, MODES, NO_ERROR + + +class SimulatedSp2XX(StateMachineDevice): + """Simulation of the WPI SP2XX syringe pump + """ + + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self._running_status = RunStatus.Stopped + self._direction = DIRECTIONS["I"] + self._running = False + self._last_error = NO_ERROR + self._mode = MODES["i"] + self._diameter = 0.0 + self.connect() + self.infusion_volume_units = "ml" + self.infusion_volume = 12.0 + self.withdrawal_volume_units = "ml" + self.withdrawal_volume = 12.0 + self.infusion_rate_units = "ml/m" + self.infusion_rate = 12.0 + self.withdrawal_rate_units = "ml/m" + self.withdrawal_rate = 12.0 + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + @property + def running(self): + """Returns True if the device is running and False otherwise. + """ + return self._running + + @property + def direction(self): + """Returns the direction the pump is set to. + """ + return self._direction + + def set_direction_via_the_backdoor(self, direction_symbol): + """Sets the direction via the backdoor. Only called using lewis via the backdoor. + + Args: + direction_symbol: Infusion or Withdrawal. + + Returns: + None + """ + self._direction = DIRECTIONS[direction_symbol] + + def reverse_direction(self): + """Reverses the direction of the device and change mode and status accordingly. But only if it is in + Infusion or withdrawal mode and running. Other reverse can not be sent. + + Returns: + True if direction was reversed; False otherwise + """ + # it is not clear whether the actual device sets the mode on reverse or not; and it it not important + # enough to find out + if self.mode.response == "I" and self._running: + self.set_mode("w") + self._running_status = RunStatus.Withdrawal + did_reverse = True + elif self.mode.response == "W" and self._running: + self.set_mode("i") + self._running_status = RunStatus.Infusion + did_reverse = True + else: + did_reverse = False + return did_reverse + + def start_device(self): + """Starts the device running to present settings. + + Returns: + None + """ + self._running = True + self._running_status = RunStatus[self._direction.name] + + def stop_device(self): + """Stops the device running. + + Returns: + None + """ + self._running = False + self._running_status = RunStatus.Stopped + + @property + def running_status(self): + """Returns the running status of the device. + """ + return self._running_status + + @property + def mode(self): + """Returns the mode the device is in. + + Returns: + _mode: Mode class that the device is in. + """ + return self._mode + + def set_mode(self, mode_symbol): + """Sets the mode of the device. + + Args: + mode_symbol: one of i, w, w/i, i/w, con. + + Returns: + None + """ + if mode_symbol in ["i", "i/w", "con"]: + self._direction = DIRECTIONS["I"] + elif mode_symbol in ["w", "w/i"]: + self._direction = DIRECTIONS["W"] + else: + print("Could not set direction.") + + self._mode = MODES[mode_symbol] + + @property + def last_error(self): + """Returns the last error type. + + """ + return self._last_error + + def throw_error_via_the_backdoor(self, error_name, error_value, error_alarm_severity): + """Throws an error of type error_type. Set only via the backdoor + + Args: + error_name: Name of the error to throw. + error_value: Integer value of error. + error_alarm_severity: Alarm severity. + + Returns: + "\r\nE": Device error prompt. + """ + new_error = ErrorType(error_name, error_value, error_alarm_severity) + self._last_error = new_error + + def clear_last_error(self): + """Clears the last error. + + Returns: + None. + """ + self._last_error = NO_ERROR + + @property + def diameter(self): + """Returns: the diameter of the syringe set on the device + """ + return self._diameter + + def successfully_set_diameter(self, value): + """Sets the diameter after checking the input value. Must be in the form nn.nn. + + Returns: + True if the diameter has been set and False otherwise. + """ + if value >= 100 or value < 0.01: + return False + else: + value = round(value, 2) + self._diameter = value + return True + + @property + def connected(self): + """Returns True if the device is connected and False if disconnected. + """ + return self._connected + + def connect(self): + """Connects the device. + + Returns: + None + """ + self._connected = True + + def discconnect(self): + """Disconnects the device. + + Returns: + None + """ + self._connected = False diff --git a/lewis/devices/sp2xx/interfaces/__init__.py b/lewis/devices/sp2xx/interfaces/__init__.py new file mode 100644 index 00000000..4acea141 --- /dev/null +++ b/lewis/devices/sp2xx/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Sp2XXStreamInterface + +__all__ = ["Sp2XXStreamInterface"] diff --git a/lewis/devices/sp2xx/interfaces/stream_interface.py b/lewis/devices/sp2xx/interfaces/stream_interface.py new file mode 100644 index 00000000..9af26a5c --- /dev/null +++ b/lewis/devices/sp2xx/interfaces/stream_interface.py @@ -0,0 +1,325 @@ +"""Stream interface for the SP2xx device. +""" + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder, string_arg +from lewis.utils.replies import conditional_reply + +from ..util_classes import RunStatus +from ..util_constants import DIRECTIONS + +ALLOWED_VOLUME_UNITS = ["ul", "ml"] +ALLOWED_RATE_UNITS = ["ul/m", "ml/m", "ul/h", "ml/h"] + +if_connected = conditional_reply("connected") + + +def if_error(f): + """Decorator that executes f if the device has no errors on it and returns the error prompt otherwise. + + Args: + f: function to be executed if the device has no error. + + Returns: + The value of f(*args) if the device has no error and "\r\nE" otherwise. + """ + + def _wrapper(*args): + error = getattr(args[0], "_device").last_error.value + if error == 0: + result = f(*args) + else: + result = "\r\nE" + return result + + return _wrapper + + +@has_log +class Sp2XXStreamInterface(StreamInterface): + """Stream interface for the serial port. + """ + + def __init__(self): + super(Sp2XXStreamInterface, self).__init__() + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.start).escape("run").eos().build(), + CmdBuilder(self.stop).escape("stop").eos().build(), + CmdBuilder(self.get_run_status).escape("run?").eos().build(), + CmdBuilder(self.get_error_status).escape("error?").eos().build(), + CmdBuilder(self.set_mode).escape("mode ").arg("i/w|w/i|i|w|con").eos().build(), + CmdBuilder(self.get_mode).escape("mode?").eos().build(), + CmdBuilder(self.get_direction).escape("dir?").eos().build(), + CmdBuilder(self.reverse_direction).escape("dir rev").eos().build(), + CmdBuilder(self.get_diameter).escape("dia?").eos().build(), + CmdBuilder(self.set_diameter).escape("dia ").float().eos().build(), + CmdBuilder(self.set_volume_or_rate) + .arg("vol|rate") + .char() + .escape(" ") + .float(string_arg) + .escape(" ") + .string() + .eos() + .build(), + CmdBuilder(self.get_volume_or_rate).arg("vol|rate").char().escape("?").eos().build(), + } + + out_terminator = "" + in_terminator = "\r" + + _return = "\r\n" + Infusion = "\r\n>" + Withdrawal = "\r\n<" + Stopped = "\r\n:" + + def handle_error(self, request, error): + """Prints an error message if a command is not recognised. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + None. + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + print("An error occurred at request {}: {}".format(request, error)) + + @if_error + @if_connected + def start(self): + """Starts the device running to present settings if it is not running. + + Returns: + + """ + if self._device.running is False: + if self._device.direction == DIRECTIONS["I"]: + self._device.start_device() + return self.Infusion + elif self._device.direction == DIRECTIONS["W"]: + self._device.start_device() + return self.Withdrawal + else: + print( + "An error occurred when trying to run the device. The device's running state is \ + is {}.".format(self._device.running_direction) + ) + + @if_error + @if_connected + def get_run_status(self): + """Gets the run status of the pump. + + Returns: + "\r\n:" : If the device is not running. + "\r\n>" : Prompt saying the device's run direction is infusing. + "\r\n<" : Prompt saying the device's run direction is withdrawing. + """ + if self._device.running_status == RunStatus.Infusion: + return self.Infusion + elif self._device.running_status == RunStatus.Withdrawal: + return self.Withdrawal + elif self._device.running_status == RunStatus.Stopped: + return self.Stopped + else: + print("An error occurred when trying to run the device.") + + @if_error + @if_connected + def stop(self): + """Stops the device running. + + Returns: + "\r\n:" : stopped prompt + """ + self._device.stop_device() + return self.Stopped + + @if_connected + def get_error_status(self): + """Gets the error status from the device and returns the error value and run status. + + Returns: + \r\n%i\r{} where %i is the error_type value and {} the run status. + """ + last_error = self._device.last_error + current_status = None + + if self._device.running_status == RunStatus.Infusion: + current_status = self.Infusion + elif self._device.running_status == RunStatus.Withdrawal: + current_status = self.Withdrawal + elif self._device.running_status == RunStatus.Stopped: + current_status = self.Stopped + + return "{}{}{}".format(self._return, last_error.value, current_status) + + @if_error + @if_connected + def set_mode(self, mode_symbol): + """Sets the mode of the device. + + Args: + mode_symbol: symbol to change the mode setting + + Returns: + run_status + """ + self._device.set_mode(mode_symbol) + return self.stop() + + @if_error + @if_connected + def get_mode(self): + """Gets the mode of the device + + Returns: + The mode the device is in and the run status. + E.g. \r\nI\r\n: if the device is in infusion mode and stopped. + """ + mode_response = self._device.mode.response + run_status = self.get_run_status() + return "{}{}{}".format(self._return, mode_response, run_status) + + @if_error + @if_connected + def get_direction(self): + """Gets the direction of the device + + Returns: + The direction the device is in and the run status. + E.g. \r\nI\r: if the device in the infusion direction and stopped. + """ + run_status = self.get_run_status() + return "{}{}{}".format(self._return, self._device.direction.symbol, run_status) + + @if_error + @if_connected + def reverse_direction(self): + """Attempt to reverse the direction of a running device. + + Returns: + Run status if the device is running and in infusion or withdrawal mode. + NA otherwise. + """ + if self._device.reverse_direction(): + return self.get_run_status() + else: + return "{}NA{}".format(self._return, self.get_run_status()) + + @if_error + @if_connected + def get_diameter(self): + """Gets the diameter that the syringe is set to. + + Returns: + float: Diameter of the syringe. + """ + run_status = self.get_run_status() + return "{}{}{}".format(self._return, self._device.diameter, run_status) + + def set_diameter(self, value): + """Sets the diameter of the + Returns: + \r\nNA{}: If value is + """ + if self._device.successfully_set_diameter(value): + return "{}".format(self.Stopped) + else: + run_status = self.get_run_status() + return "{}NA{}".format(self._return, run_status) + + @if_error + @if_connected + def set_volume_or_rate(self, vol_or_rate, vol_or_rate_type, value, units): + """Set a volume or rate. + + Args: + vol_or_rate: vol to set a volume, rate to set a rate, anything else is an error + vol_or_rate_type: the type of the volume or rate, i for infusion, w for withdrawal, anything else + is an error + value: value to set it too; of form nnnnn (0-9 and. won't accept number larger than 9999) + units: units + Returns: + return string + """ + run_status = self.get_run_status() + if len(value) > 5: + self.log.error("Value is too long: '{}' ".format(value)) + return "{}NA{}".format(self._return, run_status) + actual_volume = float(value) + + if vol_or_rate == "vol": + allowed_units = ALLOWED_VOLUME_UNITS + else: + allowed_units = ALLOWED_RATE_UNITS + if units not in allowed_units: + self.log.error("Units are unknown: '{}' ".format(units)) + return "{}NA{}".format(self._return, run_status) + + if vol_or_rate == "vol" and vol_or_rate_type == "i": + self._device.infusion_volume_units = units + self._device.infusion_volume = actual_volume + elif vol_or_rate == "vol" and vol_or_rate_type == "w": + self._device.withdrawal_volume_units = units + self._device.withdrawal_volume = actual_volume + elif vol_or_rate == "rate" and vol_or_rate_type == "i": + self._device.infusion_rate_units = units + self._device.infusion_rate = actual_volume + elif vol_or_rate == "rate" and vol_or_rate_type == "w": + self._device.withdrawal_rate_units = units + self._device.withdrawal_rate = actual_volume + else: + self.log.error("command is not know: '{}{}' ".format(vol_or_rate, vol_or_rate_type)) + return "{}NA{}".format(self._return, run_status) + + return "{}".format(self.Stopped) + + @if_error + @if_connected + def get_volume_or_rate(self, vol_or_rate, vol_or_rate_type): + """Get a volume or rate. + + Args: + vol_or_rate: vol to set a volume, rate to set a rate, anything else is an error + vol_or_rate_type: the type of the volume or rate, i for infusion, w for withdrawal, anything else + is an error + Returns: + return string + """ + run_status = self.get_run_status() + + if vol_or_rate == "vol" and vol_or_rate_type == "i": + value = self._device.infusion_volume + units = self._device.infusion_volume_units + + elif vol_or_rate == "vol" and vol_or_rate_type == "w": + value = self._device.withdrawal_volume + units = self._device.withdrawal_volume_units + elif vol_or_rate == "rate" and vol_or_rate_type == "i": + value = self._device.infusion_rate + units = self._device.infusion_rate_units + elif vol_or_rate == "rate" and vol_or_rate_type == "w": + value = self._device.withdrawal_rate + units = self._device.withdrawal_rate_units + else: + self.log.error("Command is not know: '{}{}?' ".format(vol_or_rate, vol_or_rate_type)) + return "{}NA{}".format(self._return, run_status) + + if value < 10.0: + format_string = "{:5.3f} {}" + elif value < 100.0: + format_string = "{:5.2f} {}" + elif value < 1000.0: + format_string = "{:5.1f} {}" + else: + format_string = "{:5f} {}" + + volume_as_string = format_string.format(value, units) + + return "{}{}{}".format(self._return, volume_as_string, self.get_run_status()) diff --git a/lewis/devices/sp2xx/states.py b/lewis/devices/sp2xx/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/sp2xx/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/sp2xx/util_classes.py b/lewis/devices/sp2xx/util_classes.py new file mode 100644 index 00000000..49671f48 --- /dev/null +++ b/lewis/devices/sp2xx/util_classes.py @@ -0,0 +1,48 @@ +from enum import Enum + + +class RunStatus(Enum): + Stopped = 0 + Infusion = 1 + Withdrawal = 2 + + +class Direction(object): + """Attributes: + symbol: Respinse symbol, I or W. + name: Name of the direction. Infusion of withdrawal. + """ + + def __init__(self, symbol, name): + self.symbol = symbol + self.name = name + + +class Mode(object): + """Operation mode for the device. + + Attributes: + set_symbol (string): Symbol for setting the mode + response (string): Response to a query for the mode. + name: Description of the mode. + """ + + def __init__(self, symbol, response, name): + self.symbol = symbol + self.response = response + self.name = name + + +class ErrorType(object): + """Error Type. + + Attributes: + name: String name of the error + value: integer value of the error + alarm_severity: Alarm severity of the error + """ + + def __init__(self, name, value, alarm_severity): + self.name = name + self.value = value + self.alarm_severity = alarm_severity diff --git a/lewis/devices/sp2xx/util_constants.py b/lewis/devices/sp2xx/util_constants.py new file mode 100644 index 00000000..e00fc8f6 --- /dev/null +++ b/lewis/devices/sp2xx/util_constants.py @@ -0,0 +1,22 @@ +from .util_classes import Direction, ErrorType, Mode + +infusion_mode = Mode("i", "I", "Infusion") +withdrawal_mode = Mode("w", "W", "Withdrawal") +infusion_withdrawal_mode = Mode("i/w", "IW", "Infusion/Withdrawal") +withdrawal_infusion_mode = Mode("w/i", "WI", "Withdrawal/Infusion") +continuous = Mode("con", "CON", "Continuous") + +MODES = { + "i": infusion_mode, + "w": withdrawal_mode, + "i/w": infusion_withdrawal_mode, + "w/i": withdrawal_infusion_mode, + "con": continuous, +} + +infusion_direction = Direction("I", "Infusion") +withdrawal_direction = Direction("W", "Withdrawal") + +DIRECTIONS = {"I": infusion_direction, "W": withdrawal_direction} + +NO_ERROR = ErrorType("No error", 0, "NO_ALARM") diff --git a/lewis/devices/tdk_lambda_genesys/__init__.py b/lewis/devices/tdk_lambda_genesys/__init__.py new file mode 100644 index 00000000..4f29c48c --- /dev/null +++ b/lewis/devices/tdk_lambda_genesys/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTDKLambdaGenesys + +__all__ = ["SimulatedTDKLambdaGenesys"] diff --git a/lewis/devices/tdk_lambda_genesys/device.py b/lewis/devices/tdk_lambda_genesys/device.py new file mode 100644 index 00000000..016f2f22 --- /dev/null +++ b/lewis/devices/tdk_lambda_genesys/device.py @@ -0,0 +1,66 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedTDKLambdaGenesys(StateMachineDevice): + def _initialize_data(self): + self._voltage = 10.0 + self._current = 2.0 + self._setpoint_voltage = 10.0 + self._setpoint_current = 2.0 + self._powerstate = "OFF" + self.comms_initialized = False + + def _get_state_handlers(self): + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + return DefaultState.NAME + + def _get_transition_handlers(self): + return OrderedDict() + + @property + def voltage(self): + # Return current actual voltage + return self._voltage + + @property + def setpoint_voltage(self): + # Return the set voltage + return self._setpoint_voltage + + @property + def current(self): + return self._current + + @property + def setpoint_current(self): + return self._setpoint_current + + @property + def powerstate(self): + return self._powerstate + + @voltage.setter + def voltage(self, voltage): + self._voltage = voltage + + @setpoint_voltage.setter + def setpoint_voltage(self, spv): + self._setpoint_voltage = spv + + @current.setter + def current(self, c): + self._current = c + + @setpoint_current.setter + def setpoint_current(self, c): + self._setpoint_current = c + + @powerstate.setter + def powerstate(self, p): + self._powerstate = p diff --git a/lewis/devices/tdk_lambda_genesys/interfaces/__init__.py b/lewis/devices/tdk_lambda_genesys/interfaces/__init__.py new file mode 100644 index 00000000..7dd4c398 --- /dev/null +++ b/lewis/devices/tdk_lambda_genesys/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import TDKLambdaGenesysStreamInterface + +__all__ = ["TDKLambdaGenesysStreamInterface"] diff --git a/lewis/devices/tdk_lambda_genesys/interfaces/stream_interface.py b/lewis/devices/tdk_lambda_genesys/interfaces/stream_interface.py new file mode 100644 index 00000000..6447c93b --- /dev/null +++ b/lewis/devices/tdk_lambda_genesys/interfaces/stream_interface.py @@ -0,0 +1,69 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + + +class TDKLambdaGenesysStreamInterface(StreamInterface): + commands = { + CmdBuilder("write_voltage").escape("PV ").float().build(), + CmdBuilder("read_setpoint_voltage").escape("PV?").build(), + CmdBuilder("read_voltage").escape("MV?").build(), + CmdBuilder("write_current").escape("PC ").float().build(), + CmdBuilder("read_setpoint_current").escape("PC?").build(), + CmdBuilder("read_current").escape("MC?").build(), + CmdBuilder("remote").escape("RMT 1").build(), + CmdBuilder("write_power").escape("OUT ").arg("[OFF|ON]").build(), + CmdBuilder("read_power").escape("OUT?").build(), + CmdBuilder("initialize_comms").escape("ADR ").int().eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + self.log.error("Beep boop. Error occurred at " + repr(request) + ": " + repr(error)) + print("Beep boop. Error occurred at " + repr(request) + ": " + repr(error)) + + @conditional_reply("comms_initialized") + def read_voltage(self): + return self._device.voltage + + @conditional_reply("comms_initialized") + def read_setpoint_voltage(self): + return self._device.setpoint_voltage + + @conditional_reply("comms_initialized") + def write_voltage(self, v): + self._device.setpoint_voltage = v + return "VOLTAGE SET TO: {}".format(v) + + @conditional_reply("comms_initialized") + def read_current(self): + return self._device.current + + @conditional_reply("comms_initialized") + def read_setpoint_current(self): + return self._device.setpoint_current + + @conditional_reply("comms_initialized") + def write_current(self, c): + self._device.setpoint_current = c + return "VOLTAGE SET TO: {}".format(c) + + @conditional_reply("comms_initialized") + def read_power(self): + return self._device.powerstate + + @conditional_reply("comms_initialized") + def write_power(self, p): + self._device.powerstate = p + return "POWER SET TO {}".format(p) + + @conditional_reply("comms_initialized") + def remote(self): + # We can ignore this command + pass + + # This is the only command the device recognises when uninitialized + def initialize_comms(self, addr): + self.device.comms_initialized = True diff --git a/lewis/devices/tdk_lambda_genesys/states.py b/lewis/devices/tdk_lambda_genesys/states.py new file mode 100644 index 00000000..6a56faf4 --- /dev/null +++ b/lewis/devices/tdk_lambda_genesys/states.py @@ -0,0 +1,6 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + # Default state + NAME = "Default" diff --git a/lewis/devices/tekafg3XXX/__init__.py b/lewis/devices/tekafg3XXX/__init__.py new file mode 100644 index 00000000..1a77c8ba --- /dev/null +++ b/lewis/devices/tekafg3XXX/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTekafg3XXX + +__all__ = ["SimulatedTekafg3XXX"] diff --git a/lewis/devices/tekafg3XXX/device.py b/lewis/devices/tekafg3XXX/device.py new file mode 100644 index 00000000..2a6649e3 --- /dev/null +++ b/lewis/devices/tekafg3XXX/device.py @@ -0,0 +1,56 @@ +from collections import OrderedDict +from typing import Any + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SourceChannel: + def __init__(self) -> None: + self.status = 0 + self.function = "SIN" + self.polarity = "NORM" + self.impedance = 0.0 + self.voltage = 0.0 + self.voltage_units = "VPP" + self.voltage_low_limit = 0.0 + self.voltage_low_level = 0.0 + self.voltage_high_limit = 0.0 + self.voltage_high_level = 0.0 + self.voltage_offset = 0.0 + self.frequency = 0.0 + self.frequency_mode = "FIX" + self.phase = 0.0 + self.burst_status = "OFF" + self.burst_mode = "TRIG" + self.burst_num_cycles = 0 + self.burst_time_delay = 0.0 + self.sweep_span = 0.0 + self.sweep_start = 0.0 + self.sweep_stop = 0.0 + self.sweep_hold_time = 0.0 + self.sweep_mode = "AUTO" + self.sweep_return_time = 0.0 + self.sweep_spacing = "LIN" + self.sweep_time = 0.0 + self.ramp_symmetry = 0.0 + + +class SimulatedTekafg3XXX(StateMachineDevice): + def _initialize_data(self) -> None: + """Initialize all of the device's attributes.""" + self.connected = True + self.channels = {1: SourceChannel(), 2: SourceChannel()} + self.triggered = False + + def _get_state_handlers(self) -> dict[str, Any]: + return { + "default": DefaultState(), + } + + def _get_initial_state(self) -> str: + return "default" + + def _get_transition_handlers(self) -> OrderedDict[Any, Any]: + return OrderedDict([]) diff --git a/lewis/devices/tekafg3XXX/interfaces/__init__.py b/lewis/devices/tekafg3XXX/interfaces/__init__.py new file mode 100644 index 00000000..a9cd127c --- /dev/null +++ b/lewis/devices/tekafg3XXX/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Tekafg3XXXStreamInterface + +__all__ = ["Tekafg3XXXStreamInterface"] diff --git a/lewis/devices/tekafg3XXX/interfaces/stream_interface.py b/lewis/devices/tekafg3XXX/interfaces/stream_interface.py new file mode 100644 index 00000000..1330945a --- /dev/null +++ b/lewis/devices/tekafg3XXX/interfaces/stream_interface.py @@ -0,0 +1,414 @@ +import typing + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.replies import conditional_reply + +if typing.TYPE_CHECKING: + from lewis_emulators.tekafg3XXX.device import SimulatedTekafg3XXX, SourceChannel + +if_connected = conditional_reply("connected") + + +@has_log +class Tekafg3XXXStreamInterface(StreamInterface): + in_terminator = "\n" + out_terminator = "\n" + + def __init__(self) -> None: + super(Tekafg3XXXStreamInterface, self).__init__() + self.device: "SimulatedTekafg3XXX" + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.identity).escape("*IDN?").eos().build(), + CmdBuilder(self.trigger).escape("*TRG").eos().build(), + CmdBuilder(self.get_status).escape("OUTP").int().escape(":STAT?").build(), + CmdBuilder(self.set_status).escape("OUTP").int().escape(":STAT ").int().build(), + CmdBuilder(self.get_function).escape("SOUR").int().escape(":FUNC:SHAP?").build(), + CmdBuilder(self.set_function) + .escape("SOUR") + .int() + .escape(":FUNC:SHAP ") + .arg("SIN|SQU|PULS|RAMP|PRN|DC|SINC|GAUS|LOR|ERIS|EDEC|HAV") + .build(), + CmdBuilder(self.get_polarity).escape("OUTP").int().escape(":POL?").build(), + CmdBuilder(self.set_polarity) + .escape("OUTP") + .int() + .escape(":POL ") + .arg("NORM|INV") + .build(), + CmdBuilder(self.get_impedance).escape("OUTP").int().escape(":IMP?").build(), + CmdBuilder(self.set_impedance).escape("OUTP").int().escape(":IMP ").float().build(), + CmdBuilder(self.get_voltage).escape("SOUR").int().escape(":VOLT?").build(), + CmdBuilder(self.set_voltage).escape("SOUR").int().escape(":VOLT ").float().build(), + CmdBuilder(self.get_voltage_units).escape("SOUR").int().escape(":VOLT:UNIT?").build(), + CmdBuilder(self.set_voltage_units) + .escape("SOUR") + .int() + .escape(":VOLT:UNIT ") + .arg("VPP|VRMS|DBM") + .build(), + CmdBuilder(self.get_voltage_low_level) + .escape("SOUR") + .int() + .escape(":VOLT:LEV:IMM:LOW?") + .build(), + CmdBuilder(self.set_voltage_low_level) + .escape("SOUR") + .int() + .escape(":VOLT:LEV:IMM:LOW ") + .float() + .build(), + CmdBuilder(self.get_voltage_high_level) + .escape("SOUR") + .int() + .escape(":VOLT:LEV:IMM:HIGH?") + .build(), + CmdBuilder(self.set_voltage_high_level) + .escape("SOUR") + .int() + .escape(":VOLT:LEV:IMM:HIGH ") + .float() + .build(), + CmdBuilder(self.get_voltage_low_limit) + .escape("SOUR") + .int() + .escape(":VOLT:LIM:LOW?") + .build(), + CmdBuilder(self.set_voltage_low_limit) + .escape("SOUR") + .int() + .escape(":VOLT:LIM:LOW ") + .float() + .build(), + CmdBuilder(self.get_voltage_high_limit) + .escape("SOUR") + .int() + .escape(":VOLT:LIM:HIGH?") + .build(), + CmdBuilder(self.set_voltage_high_limit) + .escape("SOUR") + .int() + .escape(":VOLT:LIM:HIGH ") + .float() + .build(), + CmdBuilder(self.get_voltage_offset) + .escape("SOUR") + .int() + .escape(":VOLT:LEV:IMM:OFFS?") + .build(), + CmdBuilder(self.set_voltage_offset) + .escape("SOUR") + .int() + .escape(":VOLT:LEV:IMM:OFFS ") + .float() + .build(), + CmdBuilder(self.get_frequency).escape("SOUR").int().escape(":FREQ:FIX?").build(), + CmdBuilder(self.set_frequency) + .escape("SOUR") + .int() + .escape(":FREQ:FIX ") + .float() + .build(), + CmdBuilder(self.get_phase).escape("SOUR").int().escape(":PHASE:ADJ?").build(), + CmdBuilder(self.set_phase).escape("SOUR").int().escape(":PHASE:ADJ ").float().build(), + CmdBuilder(self.get_burst_status).escape("SOUR").int().escape(":BURS:STAT?").build(), + CmdBuilder(self.set_burst_status) + .escape("SOUR") + .int() + .escape(":BURS:STAT ") + .arg("ON|OFF|1|0") + .build(), + CmdBuilder(self.get_burst_mode).escape("SOUR").int().escape(":BURS:MODE?").build(), + CmdBuilder(self.set_burst_mode) + .escape("SOUR") + .int() + .escape(":BURS:MODE ") + .arg("TRIG|GAT") + .build(), + CmdBuilder(self.get_burst_num_cycles) + .escape("SOUR") + .int() + .escape(":BURS:NCYC?") + .build(), + CmdBuilder(self.set_burst_num_cycles) + .escape("SOUR") + .int() + .escape(":BURS:NCYC ") + .float() + .build(), + CmdBuilder(self.get_burst_time_delay) + .escape("SOUR") + .int() + .escape(":BURS:TDEL?") + .build(), + CmdBuilder(self.set_burst_time_delay) + .escape("SOUR") + .int() + .escape(":BURS:TDEL ") + .float() + .build(), + CmdBuilder(self.get_frequency_mode).escape("SOUR").int().escape(":FREQ:MODE?").build(), + CmdBuilder(self.set_frequency_mode) + .escape("SOUR") + .int() + .escape(":FREQ:MODE ") + .arg("CW|FIX|SWE") + .build(), + CmdBuilder(self.get_sweep_span).escape("SOUR").int().escape(":FREQ:SPAN?").build(), + CmdBuilder(self.set_sweep_span) + .escape("SOUR") + .int() + .escape(":FREQ:SPAN ") + .float() + .build(), + CmdBuilder(self.get_sweep_start).escape("SOUR").int().escape(":FREQ:STAR?").build(), + CmdBuilder(self.set_sweep_start) + .escape("SOUR") + .int() + .escape(":FREQ:STAR ") + .float() + .build(), + CmdBuilder(self.get_sweep_stop).escape("SOUR").int().escape(":FREQ:STOP?").build(), + CmdBuilder(self.set_sweep_stop) + .escape("SOUR") + .int() + .escape(":FREQ:STOP ") + .float() + .build(), + CmdBuilder(self.get_sweep_hold_time).escape("SOUR").int().escape(":SWE:HTIM?").build(), + CmdBuilder(self.set_sweep_hold_time) + .escape("SOUR") + .int() + .escape(":SWE:HTIM ") + .float() + .build(), + CmdBuilder(self.get_sweep_mode).escape("SOUR").int().escape(":SWE:MODE?").build(), + CmdBuilder(self.set_sweep_mode) + .escape("SOUR") + .int() + .escape(":SWE:MODE ") + .arg("AUTO|MAN") + .build(), + CmdBuilder(self.get_sweep_return_time) + .escape("SOUR") + .int() + .escape(":SWE:RTIM?") + .build(), + CmdBuilder(self.set_sweep_return_time) + .escape("SOUR") + .int() + .escape(":SWE:RTIM ") + .float() + .build(), + CmdBuilder(self.get_sweep_spacing).escape("SOUR").int().escape(":SWE:SPAC?").build(), + CmdBuilder(self.set_sweep_spacing) + .escape("SOUR") + .int() + .escape(":SWE:SPAC ") + .arg("LIN|LOG") + .build(), + CmdBuilder(self.get_sweep_time).escape("SOUR").int().escape(":SWE:TIME?").build(), + CmdBuilder(self.set_sweep_time) + .escape("SOUR") + .int() + .escape(":SWE:TIME ") + .float() + .build(), + CmdBuilder(self.get_ramp_symmetry) + .escape("SOUR") + .int() + .escape(":FUNC:RAMP:SYMM?") + .build(), + CmdBuilder(self.set_ramp_symmetry) + .escape("SOUR") + .int() + .escape(":FUNC:RAMP:SYMM ") + .float() + .build(), + } + + def handle_error(self, request: str, error: str | Exception) -> None: + """If command is not recognised print and error + + Args: + request: requested string + error: problem + + """ + self.log.error("An error occurred at request " + repr(request) + ": " + repr(error)) + + def identity(self) -> str: + """:return: identity of the device""" + return "TEKTRONIX,AFG3021,C100101,SCPI:99.0 FV:1.0" + + def trigger(self) -> None: + self.device.triggered = True + + def _channel(self, channel_num: int) -> "SourceChannel": + """Helper method to get a channel object from the device according to number""" + return self.device.channels[channel_num] + + def get_status(self, channel: int) -> int: + return self._channel(channel).status + + def set_status(self, channel: int, new_status: int) -> None: + self._channel(channel).status = new_status + + def get_function(self, channel: int) -> str: + return self._channel(channel).function + + def set_function(self, channel: int, new_function: str) -> None: + self._channel(channel).function = new_function + + def get_polarity(self, channel: int) -> str: + return self._channel(channel).polarity + + def set_polarity(self, channel: int, new_polarity: str) -> None: + self._channel(channel).polarity = new_polarity + + def get_impedance(self, channel: int) -> float: + return self._channel(channel).impedance + + def set_impedance(self, channel: int, new_impedance: float) -> None: + self._channel(channel).impedance = new_impedance + + def get_voltage(self, channel: int) -> int: + return self._channel(channel).voltage + + def set_voltage(self, channel: int, new_voltage: float) -> None: + self._channel(channel).voltage = new_voltage + + def get_voltage_units(self, channel: int) -> str: + return self._channel(channel).voltage_units + + def set_voltage_units(self, channel: int, new_voltage_units: str) -> None: + self._channel(channel).voltage_units = new_voltage_units + + def get_voltage_low_limit(self, channel: int) -> float: + return self._channel(channel).voltage_low_limit + + def set_voltage_low_limit(self, channel: int, new_voltage_low_limit: float) -> None: + self._channel(channel).voltage_low_limit = new_voltage_low_limit + + def get_voltage_low_level(self, channel: int) -> float: + return self._channel(channel).voltage_low_level + + def set_voltage_low_level(self, channel: int, new_voltage_low_level: float) -> None: + self._channel(channel).voltage_low_level = new_voltage_low_level + + def get_voltage_high_limit(self, channel: int) -> float: + return self._channel(channel).voltage_high_limit + + def set_voltage_high_limit(self, channel: int, new_voltage_high_limit: float) -> None: + self._channel(channel).voltage_high_limit = new_voltage_high_limit + + def get_voltage_high_level(self, channel: int) -> float: + return self._channel(channel).voltage_high_level + + def set_voltage_high_level(self, channel: int, new_voltage_high_level: float) -> None: + self._channel(channel).voltage_high_level = new_voltage_high_level + + def get_voltage_offset(self, channel: int) -> float: + return self._channel(channel).voltage_offset + + def set_voltage_offset(self, channel: int, new_voltage_offset: float) -> None: + self._channel(channel).voltage_offset = new_voltage_offset + + def get_frequency(self, channel: int) -> float: + return self._channel(channel).frequency + + def set_frequency(self, channel: int, new_frequency: float) -> None: + self._channel(channel).frequency = new_frequency + + def get_frequency_mode(self, channel: int) -> str: + return self._channel(channel).frequency_mode + + def set_frequency_mode(self, channel: int, new_frequency_mode: str) -> None: + self._channel(channel).frequency_mode = new_frequency_mode + + def get_phase(self, channel: int) -> float: + return self._channel(channel).phase + + def set_phase(self, channel: int, new_phase: float) -> None: + self._channel(channel).phase = new_phase + + def get_burst_status(self, channel: int) -> str: + return self._channel(channel).burst_status + + def set_burst_status(self, channel: int, new_burst_status: str) -> None: + self._channel(channel).burst_status = "ON" if new_burst_status in ["ON", "1"] else "OFF" + + def get_burst_mode(self, channel: int) -> str: + return self._channel(channel).burst_mode + + def set_burst_mode(self, channel: int, new_burst_mode: str) -> None: + self._channel(channel).burst_mode = new_burst_mode + + def get_burst_num_cycles(self, channel: int) -> int: + return self._channel(channel).burst_num_cycles + + def set_burst_num_cycles(self, channel: int, new_burst_num_cycles: int) -> None: + self._channel(channel).burst_num_cycles = new_burst_num_cycles + + def get_burst_time_delay(self, channel: int) -> int: + return self._channel(channel).burst_time_delay + + def set_burst_time_delay(self, channel: int, new_burst_time_delay: int) -> None: + self._channel(channel).burst_time_delay = new_burst_time_delay + + def get_sweep_span(self, channel: int) -> int: + return self._channel(channel).sweep_span + + def set_sweep_span(self, channel: int, new_sweep_span: int) -> None: + self._channel(channel).sweep_span = new_sweep_span + + def get_sweep_start(self, channel: int) -> int: + return self._channel(channel).sweep_start + + def set_sweep_start(self, channel: int, new_sweep_start: int) -> None: + self._channel(channel).sweep_start = new_sweep_start + + def get_sweep_stop(self, channel: int) -> int: + return self._channel(channel).sweep_stop + + def set_sweep_stop(self, channel: int, new_sweep_stop: int) -> None: + self._channel(channel).sweep_stop = new_sweep_stop + + def get_sweep_hold_time(self, channel: int) -> int: + return self._channel(channel).sweep_hold_time + + def set_sweep_hold_time(self, channel: int, new_sweep_hold_time: int) -> None: + self._channel(channel).sweep_hold_time = new_sweep_hold_time + + def get_sweep_mode(self, channel: int) -> str: + return self._channel(channel).sweep_mode + + def set_sweep_mode(self, channel: int, new_sweep_mode: str) -> None: + self._channel(channel).sweep_mode = new_sweep_mode + + def get_sweep_return_time(self, channel: int) -> int: + return self._channel(channel).sweep_return_time + + def set_sweep_return_time(self, channel: int, new_sweep_return_time: int) -> None: + self._channel(channel).sweep_return_time = new_sweep_return_time + + def get_sweep_spacing(self, channel: int) -> str: + return self._channel(channel).sweep_spacing + + def set_sweep_spacing(self, channel: int, new_sweep_spacing: str) -> None: + self._channel(channel).sweep_spacing = new_sweep_spacing + + def get_sweep_time(self, channel: int) -> int: + return self._channel(channel).sweep_time + + def set_sweep_time(self, channel: int, new_sweep_time: int) -> None: + self._channel(channel).sweep_time = new_sweep_time + + def get_ramp_symmetry(self, channel: int) -> float: + return self._channel(channel).ramp_symmetry + + def set_ramp_symmetry(self, channel: int, new_ramp_symmetry: float) -> None: + self._channel(channel).ramp_symmetry = new_ramp_symmetry diff --git a/lewis/devices/tekafg3XXX/states.py b/lewis/devices/tekafg3XXX/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/tekafg3XXX/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/tpgx00/__init__.py b/lewis/devices/tpgx00/__init__.py new file mode 100644 index 00000000..e271a0af --- /dev/null +++ b/lewis/devices/tpgx00/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTpgx00 + +__all__ = ["SimulatedTpgx00"] diff --git a/lewis/devices/tpgx00/device.py b/lewis/devices/tpgx00/device.py new file mode 100644 index 00000000..ebb779b8 --- /dev/null +++ b/lewis/devices/tpgx00/device.py @@ -0,0 +1,562 @@ +from collections import OrderedDict +from enum import Enum, unique + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +@unique +class Units(Enum): + hPascal = object() + mbar = object() + Torr = object() + Pa = object() + Micron = object() + Volt = object() + Ampere = object() + + +@unique +class ChannelStatus(Enum): + DATA_OK = object() + UNDERRANGE = object() + OVERRANGE = object() + POINT_ERROR = object() + POINT_OFF = object() + NO_HARDWARE = object() + + +@unique +class SFAssignment(Enum): + OFF = object() + A1 = object() + A2 = object() + B1 = object() + B2 = object() + A1_SELF_MON = object() + A2_SELF_MON = object() + B1_SELF_MON = object() + B2_SELF_MON = object() + ON = object() + + +@unique +class SFStatus(Enum): + OFF = object() + ON = object() + + +@unique +class ErrorStatus(Enum): + NO_ERROR = object() + DEVICE_ERROR = object() + NO_HARDWARE = object() + INVALID_PARAM = object() + SYNTAX_ERROR = object() + + +@unique +class ReadState(Enum): + A1 = object() + A2 = object() + B1 = object() + B2 = object() + UNI = object() + UNI0 = object() + UNI1 = object() + UNI2 = object() + UNI3 = object() + UNI4 = object() + UNI5 = object() + UNI6 = object() + F1 = object() + F2 = object() + F3 = object() + F4 = object() + FA = object() + FB = object() + FS1 = object() + FS2 = object() + FS3 = object() + FS4 = object() + FSA = object() + FSB = object() + SPS = object() + ERR = object() + + +class CircuitAssignment: + """This object represents settings for a circuit in the device. + these settings are: high_threshold(float), high_exponent(int), + low_threshold(float), low_exponent(int), circuit_assignment(SFAssignment enum member) + """ + + def __init__( + self, + high_threshold=0.0, + high_exponent=0, + low_threshold=0.0, + low_exponent=0, + circuit_assignment="A1", + ): + """Default constructor. + """ + self.high_threshold = high_threshold + self.high_exponent = high_exponent + self.low_threshold = low_threshold + self.low_exponent = low_exponent + self.circuit_assignment = SFAssignment[circuit_assignment] + + +class SimulatedTpgx00(StateMachineDevice): + """Simulated device for both the TPG300 and TPG500. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self.__pressure_a1 = 0.0 + self.__pressure_a2 = 0.0 + self.__pressure_b1 = 0.0 + self.__pressure_b2 = 0.0 + self.__pressure_status_a1 = ChannelStatus["DATA_OK"] + self.__pressure_status_a2 = ChannelStatus["DATA_OK"] + self.__pressure_status_b1 = ChannelStatus["DATA_OK"] + self.__pressure_status_b2 = ChannelStatus["DATA_OK"] + self.__units = Units["mbar"] + self.__connected = None + self.__readstate = None + self.__switching_functions = { + "1": CircuitAssignment(), + "2": CircuitAssignment(), + "3": CircuitAssignment(), + "4": CircuitAssignment(), + "A": CircuitAssignment(), + "B": CircuitAssignment(), + } + self.__switching_function_to_set = CircuitAssignment() + self.__switching_functions_status = { + "1": SFStatus["OFF"], + "2": SFStatus["OFF"], + "3": SFStatus["OFF"], + "4": SFStatus["OFF"], + "A": SFStatus["OFF"], + "B": SFStatus["OFF"], + } + self.__switching_function_assignment = { + "1": SFAssignment["OFF"], + "2": SFAssignment["OFF"], + "3": SFAssignment["OFF"], + "4": SFAssignment["OFF"], + "A": SFAssignment["OFF"], + "B": SFAssignment["OFF"], + } + self.__on_timer = 0 + self.__error_status = ErrorStatus["NO_ERROR"] + self.connect() + + @staticmethod + def _get_state_handlers(): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + @staticmethod + def _get_initial_state(): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + @staticmethod + def _get_transition_handlers(): + """Returns: the state transitions + """ + return OrderedDict() + + @property + def pressure_a1(self): + """Returns the value of the A1 pressure sensor. + + Returns: + float: Pressure A1 value. + """ + return self.__pressure_a1 + + @pressure_a1.setter + def pressure_a1(self, value): + """Sets the A1 pressure sensor. + + Args: + value: Value to set A1 pressure sensor to. + + Returns: + None + """ + self.__pressure_a1 = value + + @property + def pressure_a2(self): + """Returns the value of the A2 pressure sensor. + + Returns: + float: Pressure A1 value. + """ + return self.__pressure_a2 + + @pressure_a2.setter + def pressure_a2(self, value): + """Sets the B1 pressure sensor. + + Args: + value: Value to set B1 pressure sensor to. + + Returns: + None + """ + self.__pressure_a2 = value + + @property + def pressure_b1(self): + """Returns the value of the A2 pressure sensor. + + Returns: + float: Pressure A1 value. + """ + return self.__pressure_b1 + + @pressure_b1.setter + def pressure_b1(self, value): + """Sets the B1 pressure sensor. + + Args: + value: Value to set B1 pressure sensor to. + + Returns: + None + """ + self.__pressure_b1 = value + + @property + def pressure_b2(self): + """Returns the value of the B2 pressure sensor. + + Returns: + float: Pressure B2 value. + """ + return self.__pressure_b2 + + @pressure_b2.setter + def pressure_b2(self, value): + """Sets the B2 pressure sensor. + + Args: + value: Value to set B2 pressure sensor to. + + Returns: + None + """ + self.__pressure_b2 = value + + @property + def pressure_status_a1(self): + """Returns the status of the A1 pressure sensor + + Returns: + Enum memeber: A1 pressure sensor status + """ + return self.__pressure_status_a1 + + @pressure_status_a1.setter + def pressure_status_a1(self, value): + """Sets the status of the A1 pressure sensor + (Only used via backdoor) + + Args: + value: Enum member to be set as the status + Returns: + None + """ + self.__pressure_status_a1 = value + + @property + def pressure_status_a2(self): + """Returns the status of the A2 pressure sensor + + Returns: + Enum memeber: A2 pressure sensor status + """ + return self.__pressure_status_a2 + + @pressure_status_a2.setter + def pressure_status_a2(self, value): + """Sets the status of the A2 pressure sensor + (Only used via backdoor) + + Args: + value: Enum member to be set as the status + Returns: + None + """ + self.__pressure_status_a2 = value + + @property + def pressure_status_b1(self): + """Returns the status of the B1 pressure sensor + + Returns: + Enum memeber: B1 pressure sensor status + """ + return self.__pressure_status_b1 + + @pressure_status_b1.setter + def pressure_status_b1(self, value): + """Sets the status of the B1 pressure sensor + (Only used via backdoor) + + Args: + value: Enum member to be set as the status + Returns: + None + """ + self.__pressure_status_b1 = value + + @property + def pressure_status_b2(self): + """Returns the status of the B2 pressure sensor + + Returns: + Enum memeber: B2 pressure sensor status + """ + return self.__pressure_status_b2 + + @pressure_status_b2.setter + def pressure_status_b2(self, value): + """Sets the status of the B2 pressure sensor + (Only used via backdoor) + + Args: + value: Enum member to be set as the status + Returns: + None + """ + self.__pressure_status_b2 = value + + @property + def units(self): + """Returns units currently set of the device. + + Returns: + unit (Enum member): Enum member of Units Enum. + """ + return self.__units + + @units.setter + def units(self, units): + """Sets the devices units. + + Args: + units: Enum member of Units. + + Returns: + None + """ + self.__units = units + + @property + def switching_functions_status(self): + """Returns status of the switching functions. + + Returns: + a dictionary of 6 Enum members which can be SFStatus.OFF (off) or SFStatus.ON (on) + """ + return self.__switching_functions_status + + @switching_functions_status.setter + def switching_functions_status(self, statuses): + """Sets the status of the switching functions. + + Args: + status: list of 6 values which can be 'OFF' or 'ON' + Returns: + None + """ + for key, status in zip(self.__switching_functions_status.keys(), statuses): + self.__switching_functions_status[key] = SFStatus[status] + + @property + def switching_functions(self): + """Returns the settings of a switching function + + Returns: + list of 6 CircuitAssignment instances + """ + return self.__switching_functions + + @switching_functions.setter + def switching_functions(self, function_list): + """Sets the status of the switching functions. + + Args: + function_list: list of 6 CircuitAssignment instances + Returns: + None + """ + self.__switching_functions = function_list + + @property + def switching_function_to_set(self): + """Returns the thresholds of the switching function that will be saved upon receiving ENQ signal. + + Returns: + CircuitAssignment instance + """ + return self.__switching_function_to_set + + @switching_function_to_set.setter + def switching_function_to_set(self, function): + """Sets the thresholds of the switching function that will be saved upon receiving ENQ signal. + + Args: + function: CircuitAssignment instance + Returns: + None + """ + self.__switching_function_to_set = function + + @property + def switching_function_assignment(self, function): + """Returns the assignment of the current switching function + + Args: + function: (string) the switching function to retrieve the switching function assignment for. + """ + return self.__switching_function_assignment[function] + + @property + def on_timer(self): + """Returns the ON-Timer property + + Returns: + int: (0-100) ON-Timer value + """ + return self.__on_timer + + @property + def error_status(self): + """Returns the error status of the device + + Returns: + Enum: ErrorStatus code of the device + """ + return self.__error_status + + @error_status.setter + def error_status(self, error): + """Sets the error status of the device. Called only via the backdoor using lewis. + + Args: + string: the enum name of the error status to be set + + Returns: + None + """ + self.__error_status = ErrorStatus[error] + + @property + def connected(self): + """Returns the current connected state. + + Returns: + bool: Current connected state. + """ + return self.__connected + + def connect(self): + """Connects the device. + + Returns: + None + """ + self.__connected = True + + def disconnect(self): + """Disconnects the device. + + Returns: + None + """ + self.__connected = False + + @property + def readstate(self): + """Returns the readstate for the device + + Returns: + Enum: Readstate of the device. + """ + return self.__readstate + + @readstate.setter + def readstate(self, state): + """Sets the readstate of the device + + Args: + state: (string) readstate name of the device to be set + + Returns: + None + """ + self.__readstate = ReadState[state] + + def backdoor_get_unit(self): + """Gets unit on device. Called only via the backdoor using lewis. + + Returns: + unit (string): Unit enum name + """ + return self.units.name + + def backdoor_get_switching_fn(self): + """Gets the current switching function in use on the device. + Called only via the backdoor using lewis. + + Returns: + string: SFAssignment member name + """ + return self.switching_function_to_set.circuit_assignment.name + + def backdoor_set_switching_function_status(self, statuses): + """Sets status of switching functions. Called only via the backdoor using lewis. + + Args: + status: list of 6 values (strings) which can be 'OFF' or 'ON' + + Returns: + None + """ + self.switching_functions_status = statuses + + def backdoor_set_pressure_status(self, channel, status): + """Sets the pressure status of the specified channel. Called only via the backdoor using lewis. + + Args: + channel (string): the pressure channel to set to + status (string): the name of the pressure status Enum to be set + + Returns: + None + """ + status_suffix = "pressure_status_{}".format(channel.lower()) + setattr(self, status_suffix, ChannelStatus[status]) + + def backdoor_set_error_status(self, error): + """Sets the current error status on the device. Called only via the backdoor using lewis. + + Args: + status (string): the enum name of the error status to be set. + + Returns: + None + """ + self.error_status = error diff --git a/lewis/devices/tpgx00/interfaces/__init__.py b/lewis/devices/tpgx00/interfaces/__init__.py new file mode 100644 index 00000000..743e01b2 --- /dev/null +++ b/lewis/devices/tpgx00/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Tpgx00StreamInterfaceBase + +__all__ = ["Tpgx00StreamInterfaceBase"] diff --git a/lewis/devices/tpgx00/interfaces/stream_interface.py b/lewis/devices/tpgx00/interfaces/stream_interface.py new file mode 100644 index 00000000..2d1642dd --- /dev/null +++ b/lewis/devices/tpgx00/interfaces/stream_interface.py @@ -0,0 +1,552 @@ +from enum import Enum + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder +from lewis.utils.constants import ACK +from lewis.utils.replies import conditional_reply + +from ..device import CircuitAssignment + + +@has_log +class Tpgx00StreamInterfaceBase(object): + """Stream interface for the serial port for either a TPG300 or TPG500. + """ + + ack_terminator = "\r\n" # Acknowledged commands are terminated by this + + commands = { + CmdBuilder("acknowledge_pressure") + .escape("P") + .arg("A1|A2|B1|B2") + .escape(ack_terminator) + .eos() + .build(), + CmdBuilder("acknowledge_units").escape("UNI").escape(ack_terminator).eos().build(), + CmdBuilder("acknowledge_set_units") + .escape("UNI") + .escape(",") + .arg("0|1|2|3|4|5|6") + .escape(ack_terminator) + .eos() + .build(), + CmdBuilder("acknowledge_function") + .escape("SP") + .arg("1|2|3|4|A|B") + .escape(ack_terminator) + .eos() + .build(), + CmdBuilder("acknowledge_set_function") + .escape("SP") + .arg("1|2|3|4|A|B") + .escape(",") + .arg(r"[+-]?\d+.\d+", float) + .escape("E") + .arg(r"(?:-|\+)(?:[1-9]+\d*|0)", int) + .escape(",") + .arg(r"[+-]?\d+.\d+", float) + .escape("E") + .arg(r"(?:-|\+)(?:[1-9]+\d*|0)", int) + .escape(",") + .int() + .escape(ack_terminator) + .eos() + .build(), + CmdBuilder("acknowledge_function_status") + .escape("SPS") + .escape(ack_terminator) + .eos() + .build(), + CmdBuilder("acknowledge_error").escape("ERR").escape(ack_terminator).eos().build(), + CmdBuilder("handle_enquiry") + .enq() + .build(), # IMPORTANT: is not terminated with usual terminator + } + + # Override StreamInterface attributes: + in_terminator = "" # Override the default terminator + out_terminator = "\r\n" + readtimeout = 1 + + def handle_error(self, request, error): + """Prints an error message if a command is not recognised, and sets the device + error status accordingly. + + Args: + request : Request. + error: The error that has occurred. + + Returns: + None. + """ + self._device.error_status = "SYNTAX_ERROR" + print("An error occurred at request {}: {}".format(request, error)) + + @conditional_reply("connected") + def acknowledge_pressure(self, channel): + """Acknowledges a request to get the pressure and stores the request. + + Args: + channel: (string) Pressure channel to read from. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = channel + return ACK + + @conditional_reply("connected") + def acknowledge_units(self): + """Acknowledge that the request for current units was received. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = "UNI" + return ACK + + @conditional_reply("connected") + def acknowledge_set_units(self, units): + """Acknowledge that the request to set the units was received. + + Args: + units (integer): Takes the value 1, 2 or 3. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = "UNI" + str(units) + return ACK + + @conditional_reply("connected") + def acknowledge_function(self, function): + """Acknowledge that the request for function thresholds was received. + + Args: + function (string): Takes the value 1, 2, 3, 4, A or B. This it the switching + function's settings that we want to read. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = "F" + function + return ACK + + @conditional_reply("connected") + def acknowledge_set_function(self, function, low_thr, low_exp, high_thr, high_exp, assign): + """Acknowledge that the request to set the function thresholds was received. + + Args: + function (string): Takes the value 1, 2, 3, 4, A or B. This is the switching + function's settings that we want to set. + + low_thr (float): Lower threshold of the switching function. + + low_exp (int): Exponent of the lower threshold. + + high_thr (float): Upper threshold of the switching function. + + high_exp (int): Exponent of the upper threshold. + + assign (int): Circuit to be assigned to this switching function. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = "FS" + function + self._device.switching_function_to_set = CircuitAssignment( + low_thr, low_exp, high_thr, high_exp, self.get_sf_assignment_name(assign) + ) + return ACK + + @conditional_reply("connected") + def acknowledge_function_status(self): + """Acknowledge that the request to check switching functions status was received + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = "SPS" + return ACK + + @conditional_reply("connected") + def acknowledge_error(self): + """Acknowledge that the request to check the device error status was received. + + Returns: + ASCII acknowledgement character (0x6). + """ + self._device.readstate = "ERR" + return ACK + + def handle_enquiry(self): + """Handles an enquiry using the last command sent. + + Returns: + String: Channel pressure and status if last command was in channels. + String: Returns the devices current units if last command is 'UNI'. + None: Sets the devices units to 1,2, or 3 if last command is 'UNI{}' where {} is 1, 2 or 3 + respectively. + None: Last command unknown. + """ + self.log.info(self._device.readstate) + + channels = ("A1", "A2", "B1", "B2") + switching_functions_read = ("F1", "F2", "F3", "F4", "FA", "FB") + switching_functions_set = ("FS1", "FS2", "FS3", "FS4", "FSA", "FSB") + units_flags = ("UNI0", "UNI1", "UNI2", "UNI3", "UNI4", "UNI5", "UNI6") + + if self._device.readstate.name in channels: + return self.get_pressure(self._device.readstate) + + elif self._device.readstate.name == "UNI": + return self.get_units() + + elif self._device.readstate.name in units_flags: + unit_num = int(self._device.readstate.name[-1]) + self.set_units(self.get_units_enum(unit_num)) + return self.get_units() + + elif self._device.readstate.name in switching_functions_read: + return self.get_thresholds_readstate(self._device.readstate) + + elif self._device.readstate.name in switching_functions_set: + self.set_threshold(self._device.readstate) + readstate = self.get_readstate_val(self._device.readstate).replace("S", "", 1) + return self.get_thresholds_readstate(self.get_readstate_enum(readstate)) + + elif self._device.readstate.name == "SPS": + status = self.get_switching_functions_status() + return ",".join(status) + + elif self._device.readstate.name == "ERR": + return self.get_error_status() + + else: + self.log.info( + "Last command was unknown. Current readstate is {}.".format(self._device.readstate) + ) + + def get_units(self): + """Gets the units of the device. + + Returns: + Name of the units. + """ + return self.get_units_val(self._device.units) + + def set_units(self, units): + """Sets the units on the device. + + Args: + units (Units member): Units to be set + + Returns: + None. + """ + self._device.units = units + + def get_threshold(self, function): + """Gets the settings of a switching function. + + Args: + function: (string) the switching function to be set + Returns: + tuple containing a sequence of: high_threshold (float), high_exponent(int), + low_threshold (float), low_exponent (int), circuit_assignment (SFAssignment enum member) + """ + switching_function = function[-1] + return self._device.switching_functions[switching_function] + + def set_threshold(self, function): + """Sets the settings of a switching function. + + Args: + function: (ReadState member) the switching function to be set + + Returns: + None. + """ + switching_function = self.get_readstate_val(function)[-1] + self._device.switching_functions[switching_function] = ( + self._device.switching_function_to_set + ) + + def get_thresholds_readstate(self, readstate): + """Helper method for getting thresholds of a function all in one string based on current readstate. + + Args: + readstate: (ReadState member) the current read state + Returns: + a string containing the lower and higher threshold and the switching f-n assignment + """ + function = self.get_threshold(self.get_readstate_val(readstate)) + return ( + str(function.high_threshold) + + "E" + + str(function.high_exponent) + + "," + + str(function.low_threshold) + + "E" + + str(function.low_exponent) + + "," + + str(self.get_sf_assignment_val(function.circuit_assignment)) + ) + + def get_switching_functions_status(self): + """Returns statuses of switching functions + + Returns: + a dictionary of 6 Enum members (SFStatus.ON/SFStatus.OFF) corresponding to each switching function + """ + return self.get_sf_status_val(self._device.switching_functions_status) + + def get_pressure(self, channel): + """Gets the pressure for a channel. + + Args: + channel (Enum member): Enum readstate pressure channel. E.g. Readstate.A1. + + Returns: + String: Device status and pressure from the channel. + """ + pressure_suffix = "pressure_{}".format(self.get_readstate_val(channel).lower()) + status_suffix = "pressure_status_{}".format(self.get_readstate_val(channel).lower()) + pressure = getattr(self._device, pressure_suffix) + status = getattr(self._device, status_suffix) + return "{},{}".format(self.get_channel_status_val(status), pressure) + + def get_error_status(self): + """Gets the device error status. + + Returns: + String: (0000|1000|0100|0010|0001) four-character error status code + """ + return self.get_error_status_val(self.device.error_status) + + +class Tpg300StreamInterface(Tpgx00StreamInterfaceBase, StreamInterface): + protocol = "tpg300" + + class SFStatus300(Enum): + OFF = 0 + ON = 1 + + class Units300(Enum): + hPascal = "Invalid unit" + mbar = 1 + Torr = 2 + Pa = 3 + Micron = "Invalid unit" + Volt = "Invalid unit" + Ampere = "Invalid unit" + + class ChannelStatus300(Enum): + DATA_OK = 0 + UNDERRANGE = 1 + OVERRANGE = 2 + POINT_ERROR = 3 + POINT_OFF = 4 + NO_HARDWARE = 5 + + class SFAssignment300(Enum): + OFF = 0 + A1 = 1 + A2 = 2 + B1 = 3 + B2 = 4 + A1_SELF_MON = 5 + A2_SELF_MON = 6 + B1_SELF_MON = 7 + B2_SELF_MON = 8 + ON = "Invalid assignment" + + class ErrorStatus300(Enum): + NO_ERROR = "0000" + DEVICE_ERROR = "1000" + NO_HARDWARE = "0100" + INVALID_PARAM = "0010" + SYNTAX_ERROR = "0001" + + class ReadState300(Enum): + A1 = "A1" + A2 = "A2" + B1 = "B1" + B2 = "B2" + UNI = "UNI" + UNI0 = "Invalid command" + UNI1 = "UNI1" + UNI2 = "UNI2" + UNI3 = "UNI3" + UNI4 = "Invalid command" + UNI5 = "Invalid command" + UNI6 = "Invalid command" + F1 = "F1" + F2 = "F2" + F3 = "F3" + F4 = "F4" + FA = "FA" + FB = "FB" + FS1 = "FS1" + FS2 = "FS2" + FS3 = "FS3" + FS4 = "FS4" + FSA = "FSA" + FSB = "FSB" + SPS = "SPS" + + def get_sf_status_val(self, status_enums): + translated_vals = [ + str(self.SFStatus300[status.name].value) for status in status_enums.values() + ] + return translated_vals + + def get_units_val(self, unit_enum): + return self.Units300[unit_enum.name].value + + def get_units_enum(self, unit_num): + return self.Units300(unit_num) + + def get_channel_status_val(self, status_enum): + return self.ChannelStatus300[status_enum.name].value + + def get_sf_assignment_name(self, assignment_num): + return self.SFAssignment300(assignment_num).name + + def get_sf_assignment_val(self, assignment_enum): + return self.SFAssignment300[assignment_enum.name].value + + def get_error_status_val(self, error_enum): + return self.ErrorStatus300[error_enum.name].value + + def get_readstate_enum(self, state_str): + return self.ReadState300(state_str) + + def get_readstate_val(self, readstate_enum): + return self.ReadState300[readstate_enum.name].value + + +class Tpg500StreamInterface(Tpgx00StreamInterfaceBase, StreamInterface): + protocol = "tpg500" + + class SFStatus500(Enum): + OFF = 0 + ON = 1 + + class Units500(Enum): + hPascal = 0 + mbar = 1 + Torr = 2 + Pa = 3 + Micron = 4 + Volt = 5 + Ampere = 6 + + class ChannelStatus500(Enum): + DATA_OK = 0 + UNDERRANGE = 1 + OVERRANGE = 2 + POINT_ERROR = 3 + POINT_OFF = 4 + NO_HARDWARE = 5 + + class SFAssignment500(Enum): + OFF = 0 + A1 = 1 + A2 = 2 + B1 = 3 + B2 = 4 + A1_SELF_MON = "Invalid assignment" + A2_SELF_MON = "Invalid assignment" + B1_SELF_MON = "Invalid assignment" + B2_SELF_MON = "Invalid assignment" + ON = 5 + + class ErrorStatus500(Enum): + NO_ERROR = "0000" + DEVICE_ERROR = "1000" + NO_HARDWARE = "0100" + INVALID_PARAM = "0010" + SYNTAX_ERROR = "0001" + + class ReadState500(Enum): + A1 = "a1" + A2 = "a2" + B1 = "b1" + B2 = "b2" + UNI = "UNI" + UNI0 = "UNI0" + UNI1 = "UNI1" + UNI2 = "UNI2" + UNI3 = "UNI3" + UNI4 = "UNI4" + UNI5 = "UNI5" + UNI6 = "UNI6" + F1 = "F1" + F2 = "F2" + F3 = "F3" + F4 = "F4" + FA = "Invalid command" + FB = "Invalid command" + FS1 = "FS1" + FS2 = "FS2" + FS3 = "FS3" + FS4 = "FS4" + FSA = "Invalid command" + FSB = "Invalid command" + SPS = "SPS" + + def get_sf_status_val(self, status_enums): + translated_vals = [ + str(self.SFStatus500[status.name].value) for status in status_enums.values() + ] + return translated_vals + + def get_units_val(self, unit_enum): + return self.Units500[unit_enum.name].value + + def get_units_enum(self, unit_num): + return self.Units500(unit_num) + + def get_channel_status_val(self, status_enum): + return self.ChannelStatus500[status_enum.name].value + + def get_sf_assignment_name(self, assignment_num): + return self.SFAssignment500(assignment_num).name + + def get_sf_assignment_val(self, assignment_enum): + return self.SFAssignment500[assignment_enum.name].value + + def get_error_status_val(self, error_enum): + return self.ErrorStatus500[error_enum.name].value + + def get_readstate_enum(self, state_str): + return self.ReadState500(state_str) + + def get_readstate_val(self, readstate_enum): + return self.ReadState500[readstate_enum.name].value + + def get_thresholds_readstate(self, readstate): + """Helper method for getting thresholds of a function all in one string based on current readstate. + + Args: + readstate: (ReadState member) the current read state + Returns: + a string containing the lower and higher threshold, the switching f-n assignment and the + ON-timer value. + """ + function = self.get_threshold(self.get_readstate_val(readstate)) + return ( + str(function.high_threshold) + + "E" + + str(function.high_exponent) + + "," + + str(function.low_threshold) + + "E" + + str(function.low_exponent) + + "," + + str(self.get_sf_assignment_val(function.circuit_assignment)) + + "," + + str(self._device.on_timer) + ) diff --git a/lewis/devices/tpgx00/states.py b/lewis/devices/tpgx00/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/tpgx00/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/tpgx6x/__init__.py b/lewis/devices/tpgx6x/__init__.py new file mode 100644 index 00000000..90a5da56 --- /dev/null +++ b/lewis/devices/tpgx6x/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTpgx6x + +__all__ = ["SimulatedTpgx6x"] diff --git a/lewis/devices/tpgx6x/device.py b/lewis/devices/tpgx6x/device.py new file mode 100644 index 00000000..e94ee4e3 --- /dev/null +++ b/lewis/devices/tpgx6x/device.py @@ -0,0 +1,104 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedTpgx6x(StateMachineDevice): + """Simulated device for both the TPG26x and TPG36x. + """ + + def _initialize_data(self): + """Sets the initial state of the device. + """ + self._pressure1 = 2.0 + self._pressure2 = 3.0 + self._error1 = 0 + self._error2 = 0 + self._units = 0 + + def _get_state_handlers(self): + """Returns: states and their names + """ + return {DefaultState.NAME: DefaultState()} + + def _get_initial_state(self): + """Returns: the name of the initial state + """ + return DefaultState.NAME + + def _get_transition_handlers(self): + """Returns: the state transitions + """ + return OrderedDict() + + @property + def pressure1(self): + """Returns: the first pressure + """ + return self._pressure1 + + @pressure1.setter + def pressure1(self, pressure): + """Set the pressure for pressure 1. + + :param pressure: the pressure value to set the first pressure to + """ + self._pressure1 = pressure + + @property + def pressure2(self): + """Returns: the second pressure. + """ + return self._pressure2 + + @pressure2.setter + def pressure2(self, pressure): + """Set the pressure for pressure 2. + + :param pressure: the pressure value to set the second pressure to + """ + self._pressure2 = pressure + + @property + def units(self): + """Returns: the units of the TPG26x + """ + return self._units + + @units.setter + def units(self, units): + """Set the units for the TPG26x. + + :param units: the units to set the device to as a string + """ + self._units = units + + @property + def error1(self): + """Returns: the error state for pressure 1 + """ + return self._error1 + + @error1.setter + def error1(self, state): + """Set the error state for pressure 1. + + :param state: the error code state for pressure 1 + """ + self._error1 = state + + @property + def error2(self): + """Returns: the error state for pressure 2 + """ + return self._error2 + + @error2.setter + def error2(self, state): + """Set the error state for pressure 2. + + :param state: the error code state for pressure 2 + """ + self._error2 = state diff --git a/lewis/devices/tpgx6x/interfaces/__init__.py b/lewis/devices/tpgx6x/interfaces/__init__.py new file mode 100644 index 00000000..eafbbe6c --- /dev/null +++ b/lewis/devices/tpgx6x/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Tpg26xStreamInterface, Tpg36xStreamInterface + +__all__ = ["Tpg26xStreamInterface", "Tpg36xStreamInterface"] diff --git a/lewis/devices/tpgx6x/interfaces/stream_interface.py b/lewis/devices/tpgx6x/interfaces/stream_interface.py new file mode 100644 index 00000000..603f40c6 --- /dev/null +++ b/lewis/devices/tpgx6x/interfaces/stream_interface.py @@ -0,0 +1,134 @@ +import abc + +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + +ACK = chr(6) + + +class TpgStreamInterfaceBase(object, metaclass=abc.ABCMeta): + """Stream interface for the serial port for either a TPG26x or TPG36x. + """ + + _last_command = None + + @abc.abstractmethod + def acknowledgement(self): + """Returns a string which is the device's "acknowledgement" message. + """ + + @abc.abstractmethod + def output_terminator(self): + """A terminator to add to every reply except acknowledgement messages. + """ + + commands = { + CmdBuilder("acknowledge_pressure").escape("PRX").build(), + CmdBuilder("acknowledge_units").escape("UNI").build(), + CmdBuilder("set_units").escape("UNI").arg("{0|1|2}").build(), + CmdBuilder("handle_enquiry").enq().build(), + } + + def handle_error(self, request, error): + """If command is not recognised print and error. + + :param request: requested string + :param error: problem + :return: + """ + print("An error occurred at request " + repr(request) + ": " + repr(error)) + + def acknowledge_pressure(self): + """Acknowledge that the request for current pressure was received. + + :return: ASCII acknowledgement character (0x6) + """ + self._last_command = "PRX" + return self.acknowledgement() + + def acknowledge_units(self): + """Acknowledge that the request for current units was received. + + :return: ASCII acknowledgement character (0x6) + """ + self._last_command = "UNI" + return self.acknowledgement() + + def handle_enquiry(self): + """Handle an enquiry using the last command sent. + + :return: + """ + if self._last_command == "PRX": + return "{}{}".format(self.get_pressure(), self.output_terminator()) + elif self._last_command == "UNI": + return "{}{}".format(self.get_units(), self.output_terminator()) + else: + print("Last command was unknown: " + repr(self._last_command)) + + def get_pressure(self): + """Get the current pressure of the TPG26x. + + Returns: a string with pressure and error codes + """ + return "{},{},{},{}{}".format( + self._device.error1, + self._device.pressure1, + self._device.error2, + self._device.pressure2, + self.output_terminator(), + ) + + def get_units(self): + """Get the current units of the TPG26x. + + Returns: a string representing the units + """ + return self._device.units + + def set_units(self, units): + """Set the units of the TPG26x. + + :param units: the unit flag to change the units to + """ + if self._last_command is None: + self._last_command = "UNI" + return self.acknowledgement() + + self._device.units = units + self._last_command = None + + +class Tpg36xStreamInterface(TpgStreamInterfaceBase, StreamInterface): + protocol = "tpg36x" + in_terminator = "" + out_terminator = "" + + def acknowledgement(self): + return "{}\r\n".format(ACK) + + def output_terminator(self): + return "\r" + + +class Tpg361StreamInterface(Tpg36xStreamInterface, StreamInterface): + protocol = "tpg361" + + def get_pressure(self): + return "{},{}{}".format( + self._device.error1, self._device.pressure1, self.output_terminator() + ) + + +class Tpg26xStreamInterface(TpgStreamInterfaceBase, StreamInterface): + protocol = "tpg26x" + + in_terminator = "\r\n" + out_terminator = "\r\n" + + def acknowledgement(self): + return "{}".format(ACK) + + # No "additional" terminator (just uses the lewis one defined above). + def output_terminator(self): + return "" diff --git a/lewis/devices/tpgx6x/states.py b/lewis/devices/tpgx6x/states.py new file mode 100644 index 00000000..d489cbdc --- /dev/null +++ b/lewis/devices/tpgx6x/states.py @@ -0,0 +1,8 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + """Device is in default state. + """ + + NAME = "Default" diff --git a/lewis/devices/triton/__init__.py b/lewis/devices/triton/__init__.py new file mode 100644 index 00000000..e0780925 --- /dev/null +++ b/lewis/devices/triton/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTriton + +__all__ = ["SimulatedTriton"] diff --git a/lewis/devices/triton/device.py b/lewis/devices/triton/device.py new file mode 100644 index 00000000..f228aae0 --- /dev/null +++ b/lewis/devices/triton/device.py @@ -0,0 +1,151 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + +HEATER_NAME = "H1" + + +class TemperatureStage(object): + """Class representing a temperature stage. + """ + + def __init__(self, name): + self.name = name + self.temperature = 1 + self.enabled = True + + self.resistance = 0 + + self.excitation_type = "VOLT" + self.excitation = 10 + + self.pause = 10 + self.dwell = 3 + + +class PressureSensor(object): + """Class to represent a pressure sensor. + + Having this as a class makes it more extensible in future, as the triton driver is still in flux. + """ + + def __init__(self): + self.pressure = 0 + + +class SimulatedTriton(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.heater_range = 0 + self.heater_power = 1 + self.heater_resistance = 0 + + self.temperature_setpoint = 0 + self.p = 0 + self.i = 0 + self.d = 0 + self.closed_loop = False + + self.status = "This is a device status message." + self.automation = "This is the automation status" + + self.pressure_sensors = {"P{}".format(i): PressureSensor() for i in range(1, 6)} + + self.temperature_stages = { + "T1": TemperatureStage("STIL"), + "T2": TemperatureStage("PT1"), + "T3": TemperatureStage("PT2"), + "T4": TemperatureStage("SORB"), + "T5": TemperatureStage("MC"), + "T6": TemperatureStage("unknown"), + } + + self.sample_channel = "T5" + assert self.sample_channel in self.temperature_stages + + def find_temperature_channel(self, name): + for k, v in self.temperature_stages.items(): + if v.name == name: + return k + else: + raise KeyError("{} not found".format(name)) + + def set_temperature_backdoor(self, stage_name, new_temp): + self.temperature_stages[self.find_temperature_channel(stage_name)].temperature = new_temp + + def set_pressure_backdoor(self, sensor, newpressure): + self.pressure_sensors["P{}".format(sensor)].pressure = float(newpressure) + + def set_sensor_property_backdoor(self, sensor, property, value): + # In older versions of the software, there was an off-by-one error here in the OI software. + # This has now been fixed by O.I. so no longer need to adjust by one here. + setattr(self.temperature_stages["T{}".format(sensor)], property, value) + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def get_closed_loop_mode(self): + return self.closed_loop + + def set_closed_loop_mode(self, mode): + self.closed_loop = mode + + def set_p(self, value): + self.p = value + + def set_i(self, value): + self.i = value + + def set_d(self, value): + self.d = value + + def get_p(self): + return self.p + + def get_i(self): + return self.i + + def get_d(self): + return self.d + + def get_temperature_setpoint(self): + return self.temperature_setpoint + + def set_temperature_setpoint(self, value): + self.temperature_setpoint = value + + def get_heater_range(self): + return self.heater_range + + def set_heater_range(self, value): + self.heater_range = value + + def is_channel_enabled(self, chan): + try: + return self.temperature_stages[chan].enabled + except KeyError: + return False + + def set_channel_enabled(self, chan, newstate): + self.temperature_stages[chan].enabled = newstate + + def get_status(self): + return self.status + + def get_automation(self): + return self.automation + + def get_pressure(self, sensor): + return self.pressure_sensors[sensor].pressure + + def get_temp(self, stage): + return self.temperature_stages[stage].temperature diff --git a/lewis/devices/triton/interfaces/__init__.py b/lewis/devices/triton/interfaces/__init__.py new file mode 100644 index 00000000..bd323f81 --- /dev/null +++ b/lewis/devices/triton/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import TritonStreamInterface + +__all__ = ["TritonStreamInterface"] diff --git a/lewis/devices/triton/interfaces/stream_interface.py b/lewis/devices/triton/interfaces/stream_interface.py new file mode 100644 index 00000000..865ff971 --- /dev/null +++ b/lewis/devices/triton/interfaces/stream_interface.py @@ -0,0 +1,303 @@ +from datetime import datetime + +from lewis.adapters.stream import StreamInterface +from lewis.core.logging import has_log +from lewis.utils.command_builder import CmdBuilder + +from lewis_emulators.triton.device import HEATER_NAME + + +@has_log +class TritonStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + # ID + CmdBuilder("get_idn").escape("*IDN?").eos().build(), + # UIDs + CmdBuilder("get_uid").escape("READ:SYS:DR:CHAN:").arg("[A-Z0-9]+").eos().build(), + # PID setpoints + CmdBuilder("set_p") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:P:") + .float() + .eos() + .build(), + CmdBuilder("set_i") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:I:") + .float() + .eos() + .build(), + CmdBuilder("set_d") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:D:") + .float() + .eos() + .build(), + # PID readbacks + CmdBuilder("get_p").escape("READ:DEV:").arg("T[0-9]+").escape(":TEMP:LOOP:P").eos().build(), + CmdBuilder("get_i").escape("READ:DEV:").arg("T[0-9]+").escape(":TEMP:LOOP:I").eos().build(), + CmdBuilder("get_d").escape("READ:DEV:").arg("T[0-9]+").escape(":TEMP:LOOP:D").eos().build(), + # Setpoint temperature + CmdBuilder("set_temperature_setpoint") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:TSET:") + .float() + .eos() + .build(), + CmdBuilder("get_temperature_setpoint") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:TSET") + .eos() + .build(), + # Temperature + CmdBuilder("get_temp") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:SIG:TEMP") + .eos() + .build(), + # Heater range + CmdBuilder("set_heater_range") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:RANGE:") + .float() + .eos() + .build(), + CmdBuilder("get_heater_range") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:RANGE") + .eos() + .build(), + # Heater type + CmdBuilder("get_heater_type") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:HTR") + .eos() + .build(), + # Get heater power + CmdBuilder("get_heater_power") + .escape("READ:DEV:{}:HTR:SIG:POWR".format(HEATER_NAME)) + .eos() + .build(), + # Get heater resistance + CmdBuilder("get_heater_resistance") + .escape("READ:DEV:{}:HTR:RES".format(HEATER_NAME)) + .eos() + .build(), + # Heater control sensor + CmdBuilder("get_heater_control_sensor") + .escape("READ:DEV:{}:HTR:LOOP".format(HEATER_NAME)) + .eos() + .build(), + # Loop mode + CmdBuilder("get_closed_loop_mode") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:MODE") + .eos() + .build(), + CmdBuilder("set_closed_loop_mode") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:LOOP:MODE:") + .any() + .eos() + .build(), + # Channel enablement + CmdBuilder("get_channel_enabled") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:MEAS:ENAB") + .eos() + .build(), + CmdBuilder("set_channel_enabled") + .escape("SET:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:MEAS:ENAB:") + .any() + .eos() + .build(), + # Status + CmdBuilder("get_status").escape("READ:SYS:DR:STATUS").eos().build(), + CmdBuilder("get_automation").escape("READ:SYS:DR:ACTN").eos().build(), + # Pressures + CmdBuilder("get_pressure") + .escape("READ:DEV:") + .arg("P[0-9]+") + .escape(":PRES:SIG:PRES") + .eos() + .build(), + # System + CmdBuilder("get_time").escape("READ:SYS:TIME").eos().build(), + # Sensor info + CmdBuilder("get_sig").escape("READ:DEV:").arg("T[0-9]+").escape(":TEMP:SIG").eos().build(), + CmdBuilder("get_excitation") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:EXCT") + .eos() + .build(), + CmdBuilder("get_meas") + .escape("READ:DEV:") + .arg("T[0-9]+") + .escape(":TEMP:MEAS") + .eos() + .build(), + } + + in_terminator = "\r\n" + out_terminator = "\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def raise_if_channel_is_not_sample_channel(self, chan): + if str(chan) != self.device.sample_channel: + raise ValueError("Channel should have been sample channel") + + def get_idn(self): + return "This is the IDN of this device" + + def get_uid(self, chan): + return "STAT:SYS:DR:CHAN:{}:{}".format(chan, self.device.find_temperature_channel(chan)) + + def set_p(self, stage, value): + self.raise_if_channel_is_not_sample_channel(stage) + self.device.set_p(float(value)) + return "ok" + + def set_i(self, stage, value): + self.raise_if_channel_is_not_sample_channel(stage) + self.device.set_i(float(value)) + return "ok" + + def set_d(self, stage, value): + self.raise_if_channel_is_not_sample_channel(stage) + self.device.set_d(float(value)) + return "ok" + + def get_p(self, stage): + self.raise_if_channel_is_not_sample_channel(stage) + return "STAT:DEV:{}:TEMP:LOOP:P:{}".format(stage, self.device.get_p()) + + def get_i(self, stage): + self.raise_if_channel_is_not_sample_channel(stage) + return "STAT:DEV:{}:TEMP:LOOP:I:{}".format(stage, self.device.get_i()) + + def get_d(self, stage): + self.raise_if_channel_is_not_sample_channel(stage) + return "STAT:DEV:{}:TEMP:LOOP:D:{}".format(stage, self.device.get_d()) + + def set_temperature_setpoint(self, chan, value): + self.raise_if_channel_is_not_sample_channel(chan) + self.device.set_temperature_setpoint(float(value)) + return "ok" + + def get_temperature_setpoint(self, chan): + self.raise_if_channel_is_not_sample_channel(chan) + return "STAT:DEV:{}:TEMP:LOOP:TSET:{}K".format(chan, self.device.get_temperature_setpoint()) + + def set_heater_range(self, chan, value): + self.raise_if_channel_is_not_sample_channel(chan) + self.device.set_heater_range(float(value)) + return "ok" + + def get_heater_range(self, chan): + self.raise_if_channel_is_not_sample_channel(chan) + return "STAT:DEV:{}:TEMP:LOOP:RANGE:{}mA".format(chan, self.device.get_heater_range()) + + def get_heater_type(self, chan): + self.raise_if_channel_is_not_sample_channel(chan) + return "STAT:DEV:{}:TEMP:LOOP:HTR:{}".format(chan, HEATER_NAME) + + def get_heater_power(self): + return "STAT:DEV:{}:HTR:SIG:POWR:{}uW".format(HEATER_NAME, self.device.heater_power) + + def get_heater_resistance(self): + return "STAT:DEV:{}:HTR:RES:{}Ohm".format(HEATER_NAME, self.device.heater_resistance) + + def get_heater_current(self): + return "STAT:DEV:{}:HTR:SIG:CURR:{}mA".format(HEATER_NAME, self.device.heater_current) + + def get_closed_loop_mode(self, chan): + self.raise_if_channel_is_not_sample_channel(chan) + return "STAT:DEV:{}:TEMP:LOOP:MODE:{}".format( + chan, "ON" if self.device.get_closed_loop_mode() else "OFF" + ) + + def set_closed_loop_mode(self, chan, mode): + self.raise_if_channel_is_not_sample_channel(chan) + + if mode not in ["ON", "OFF"]: + raise ValueError("Invalid mode") + + self.device.set_closed_loop_mode(mode == "ON") + return "STAT:SET:DEV:{}:TEMP:LOOP:MODE:{}:VALID".format(chan, mode) + + def get_channel_enabled(self, channel): + return "STAT:DEV:{}:TEMP:MEAS:ENAB:{}".format( + channel, "ON" if self.device.is_channel_enabled(channel) else "OFF" + ) + + def set_channel_enabled(self, channel, newstate): + newstate = str(newstate) + + if newstate not in ["ON", "OFF"]: + raise ValueError("New state '{}' not valid.".format(newstate)) + + self.device.set_channel_enabled(channel, newstate == "ON") + return "ok" + + def get_status(self): + return "STAT:SYS:DR:STATUS:{}".format(self.device.get_status()) + + def get_automation(self): + return "STAT:SYS:DR:ACTN:{}".format(self.device.get_automation()) + + def get_temp(self, stage): + return "STAT:DEV:{}:TEMP:SIG:TEMP:{}K".format(stage, self.device.get_temp(str(stage))) + + def get_pressure(self, sensor): + return "STAT:DEV:{}:PRES:SIG:PRES:{}mB".format(sensor, self.device.get_pressure(sensor)) + + def get_time(self): + return datetime.now().strftime("STAT:SYS:TIME:%H:%M:%S") + + def get_heater_control_sensor(self): + # Always assume heater controls sample. This is true so far at ISIS + return "STAT:DEV:{}:HTR:LOOP:SENS:{}".format(HEATER_NAME, self.device.sample_channel) + + def get_sig(self, chan): + return "STAT:DEV:{}:TEMP:SIG:TEMP:{}K:RES:{}Ohm".format( + chan, + self.device.temperature_stages[chan].temperature, + self.device.temperature_stages[chan].resistance, + ) + + def get_excitation(self, chan): + return "STAT:DEV:{}:TEMP:EXCT:TYPE:{}:MAG:{}V".format( + chan, + self.device.temperature_stages[chan].excitation_type, + self.device.temperature_stages[chan].excitation, + ) + + def get_meas(self, chan): + return "STAT:DEV:{}:TEMP:MEAS:PAUS:{}s:DWEL:{}s:ENAB:ON".format( + chan, + self.device.temperature_stages[chan].pause, + self.device.temperature_stages[chan].dwell, + ) diff --git a/lewis/devices/triton/states.py b/lewis/devices/triton/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/triton/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/tti355/__init__.py b/lewis/devices/tti355/__init__.py new file mode 100644 index 00000000..5197080e --- /dev/null +++ b/lewis/devices/tti355/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTti355 + +__all__ = ["SimulatedTti355"] diff --git a/lewis/devices/tti355/device.py b/lewis/devices/tti355/device.py new file mode 100644 index 00000000..6aaca8c8 --- /dev/null +++ b/lewis/devices/tti355/device.py @@ -0,0 +1,108 @@ +from collections import OrderedDict +from random import random + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedTti355(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.identity = "Thurlby Thandar,EL302P,0,v1.14" + self.voltage = 0.00 + self.voltage_sp = 1.00 + self.current = 0.00 + self.current_limit_sp = 1.00 + self.output_status = "OUT OFF" + self.output_mode = "M CV" + self.error = "ERR 0" + self._max_voltage = 35.0 + self._max_current = 5.0 + self.load_resistance = 10 + + def reset(self): + self._initialize_data() + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def calculate_potential_current(self, voltage): + return voltage / self.load_resistance + + def calculate_actual_voltage(self): + return self.get_current() * self.load_resistance + + def voltage_within_limits(self, voltage): + return voltage <= self._max_voltage + + def get_voltage(self): + if self.output_status == "OUT ON": + if self.output_mode == "M CI": + self.voltage = self.calculate_actual_voltage() + else: + self.voltage = self.voltage_sp + ((random() - 0.5) / 1000) + else: + self.voltage = (random() - 0.5) / 1000 + return self.voltage + + def set_voltage_sp(self, voltage): + voltage = round(float(voltage), 2) + if not self.voltage_within_limits(voltage): + self.error = "ERR 2" + else: + self.voltage_sp = voltage + if ( + self.calculate_potential_current(voltage) > self.current_limit_sp + and self.output_status == "OUT ON" + ): + self.output_mode = "M CI" + + def current_within_limits(self, current): + return current <= self._max_current + + def get_current(self): + if self.output_status == "OUT ON" and self.output_mode == "M CI": + self.current = self.current_limit_sp + ((random() - 0.5) / 1000) + else: + self.current = (random() - 0.5) / 1000 + return self.current + + def set_current_limit_sp(self, current): + current = round(float(current), 2) + if not self.current_within_limits(current): + self.error = "ERR 2" + else: + self.current_limit_sp = current + + def set_output_status(self, status): + if status == "ON": + self.output_status = "OUT ON" + elif status == "OFF": + self.output_status = "OUT OFF" + self.reset() + + def get_output_status(self): + return self.output_status + + def get_output_mode(self): + return self.output_mode + + def get_error_status(self): + if self.error == "ERR 1": + self.error = "ERR 0" + return "ERR 1" + elif self.error == "ERR 2": + self.error = "ERR 0" + return "ERR 2" + else: + return self.error diff --git a/lewis/devices/tti355/interfaces/__init__.py b/lewis/devices/tti355/interfaces/__init__.py new file mode 100644 index 00000000..ff06d550 --- /dev/null +++ b/lewis/devices/tti355/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Tti355StreamInterface + +__all__ = ["Tti355StreamInterface"] diff --git a/lewis/devices/tti355/interfaces/stream_interface.py b/lewis/devices/tti355/interfaces/stream_interface.py new file mode 100644 index 00000000..a5cb4954 --- /dev/null +++ b/lewis/devices/tti355/interfaces/stream_interface.py @@ -0,0 +1,81 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + + +class Tti355StreamInterface(StreamInterface): + in_terminator = "\n" + out_terminator = "\r\n" + + def __init__(self): + super(Tti355StreamInterface, self).__init__() + + # Commands that we expect via serial during normal operation + self.commands = { + CmdBuilder(self.get_identity).escape("*IDN?").eos().build(), + CmdBuilder(self.reset).escape("*RST").eos().build(), + CmdBuilder(self.get_voltage_sp).escape("V?").eos().build(), + CmdBuilder(self.set_voltage_sp).escape("V ").float().eos().build(), + CmdBuilder(self.get_voltage).escape("VO?").eos().build(), + CmdBuilder(self.get_current_sp).escape("I?").eos().build(), + CmdBuilder(self.set_current_limit_sp).escape("I ").float().eos().build(), + CmdBuilder(self.get_current).escape("IO?").eos().build(), + CmdBuilder(self.get_outputstatus).escape("OUT?").eos().build(), + CmdBuilder(self.set_outputstatus_on).escape("ON").eos().build(), + CmdBuilder(self.set_outputstatus_off).escape("OFF").eos().build(), + CmdBuilder(self.get_output_mode).escape("M?").eos().build(), + CmdBuilder(self.get_error_status).escape("ERR?").eos().build(), + } + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def reset(self): + self.device.reset() + return self.out_terminator + + def get_voltage(self): + volt = self.device.get_voltage() + return "V{:.2f}".format(volt) + + def get_voltage_sp(self): + return "V{:.2f}".format(self.device.voltage_sp) + + def set_voltage_sp(self, voltage_sp): + self.device.set_voltage_sp(float(voltage_sp)) + return self.out_terminator + + def get_current(self): + current = self.device.get_current() + return "I{:.2f}".format(current) + + def get_current_sp(self): + return "I{:.2f}".format(self.device.current_limit_sp) + + def set_current_limit_sp(self, current_limit_sp): + self.device.set_current_limit_sp(float(current_limit_sp)) + return self.out_terminator + + def get_outputstatus(self): + return self.device.get_output_status() + + def set_outputstatus_on(self): + self.device.set_output_status("ON") + return self.out_terminator + + def set_outputstatus_off(self): + self.device.set_output_status("OFF") + return self.out_terminator + + def get_output_mode(self): + return self.device.get_output_mode() + + def get_error_status(self): + return self.device.get_error_status() + + def get_identity(self): + return self.device.identity diff --git a/lewis/devices/tti355/states.py b/lewis/devices/tti355/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/tti355/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/ttiex355p/__init__.py b/lewis/devices/ttiex355p/__init__.py new file mode 100644 index 00000000..228a5904 --- /dev/null +++ b/lewis/devices/ttiex355p/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTTIEX355P + +__all__ = ["SimulatedTTIEX355P"] diff --git a/lewis/devices/ttiex355p/device.py b/lewis/devices/ttiex355p/device.py new file mode 100644 index 00000000..e598ae3a --- /dev/null +++ b/lewis/devices/ttiex355p/device.py @@ -0,0 +1,20 @@ +from lewis.core.logging import has_log + +from lewis_emulators.tti355.device import SimulatedTti355 + + +@has_log +class SimulatedTTIEX355P(SimulatedTti355): + def _initialize_data(self): + super()._initialize_data() + self.min_voltage = 0.0 + self.min_current = 0.01 + + def reset_ttiex355p(self): + self._initialize_data() + + def voltage_within_limits(self, voltage): + return self.min_voltage <= voltage and super().voltage_within_limits(voltage) + + def currrent_within_limits(self, current): + return self.min_current <= current and super().current_within_limits(current) diff --git a/lewis/devices/ttiex355p/interfaces/__init__.py b/lewis/devices/ttiex355p/interfaces/__init__.py new file mode 100644 index 00000000..928c1882 --- /dev/null +++ b/lewis/devices/ttiex355p/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Ttiex355pStreamInterface + +__all__ = ["Ttiex355pStreamInterface"] diff --git a/lewis/devices/ttiex355p/interfaces/stream_interface.py b/lewis/devices/ttiex355p/interfaces/stream_interface.py new file mode 100644 index 00000000..522c6d0c --- /dev/null +++ b/lewis/devices/ttiex355p/interfaces/stream_interface.py @@ -0,0 +1,7 @@ +from lewis_emulators.tti355.interfaces.stream_interface import Tti355StreamInterface + +__all__ = ["Ttiex355pStreamInterface"] + + +class Ttiex355pStreamInterface(Tti355StreamInterface): + protocol = "ttiex355p" diff --git a/lewis/devices/ttiplp/__init__.py b/lewis/devices/ttiplp/__init__.py new file mode 100644 index 00000000..199caaff --- /dev/null +++ b/lewis/devices/ttiplp/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedTtiplp + +__all__ = ["SimulatedTtiplp"] diff --git a/lewis/devices/ttiplp/device.py b/lewis/devices/ttiplp/device.py new file mode 100644 index 00000000..73401af8 --- /dev/null +++ b/lewis/devices/ttiplp/device.py @@ -0,0 +1,108 @@ +from collections import OrderedDict +from random import random as rnd + +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class SimulatedTtiplp(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.ident = "THURLBY THANDAR, PL303-P, 490296, 3.02-4.06" + self.volt = 0 + self.volt_sp = 0 + self.curr = 0 + self.curr_sp = 0 + self.output = 0 + self.overvolt = 0 + self.overcurr = 0 + self.ocp_tripped = False + self.ovp_tripped = False + self.hardware_tripped = False + self.current_limited = False + self.voltage_limited = False + + def reset(self): + self._initialize_data() + + def _get_state_handlers(self): + return { + "default": DefaultState(), + } + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) + + def get_volt(self): + if self.output == 1: + self.volt = self.volt_sp + ((rnd() - 0.5) / 1000) + else: + self.volt = (rnd() - 0.5) / 1000 + return self.volt + + def get_curr(self): + if self.output == 1: + self.curr = self.curr_sp + ((rnd() - 0.5) / 1000) + else: + self.curr = (rnd() - 0.5) / 1000 + return self.curr + + def set_volt_sp(self, volt_sp): + self.volt_sp = float(volt_sp) + self._check_trip() + + def set_curr_sp(self, curr_sp): + self.curr_sp = float(curr_sp) + self._check_trip() + + def set_overvolt(self, overvolt): + self.overvolt = float(overvolt) + self._check_trip() + + def set_overcurr(self, overcurr): + self.overcurr = float(overcurr) + self._check_trip() + + def set_output(self, output): + self.output = output + self._check_trip() + + def is_overcurrent_tripped(self): + return self.ocp_tripped + + def is_overvolt_tripped(self): + return self.ovp_tripped + + def is_hardware_tripped(self): + return self.hardware_tripped + + def reset_trip(self): + self.ovp_tripped = False + self.ocp_tripped = False + + def is_voltage_limited(self): + return self.voltage_limited + + def is_current_limited(self): + return self.current_limited + + def _check_trip(self): + if self.output == 1: + # Trip bits + if self.volt_sp > self.overvolt: + self.output = 0 + self.ovp_tripped = True + if self.curr_sp > self.overcurr: + self.output = 0 + self.ocp_tripped = True + + # Limit bits + if abs(self.volt_sp - self.volt) < 0.01: + self.voltage_limited = True + if abs(self.curr_sp - self.curr) < 0.01: + self.current_limited = True diff --git a/lewis/devices/ttiplp/interfaces/__init__.py b/lewis/devices/ttiplp/interfaces/__init__.py new file mode 100644 index 00000000..6be7b753 --- /dev/null +++ b/lewis/devices/ttiplp/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import TtiplpStreamInterface + +__all__ = ["TtiplpStreamInterface"] diff --git a/lewis/devices/ttiplp/interfaces/stream_interface.py b/lewis/devices/ttiplp/interfaces/stream_interface.py new file mode 100644 index 00000000..954aff0c --- /dev/null +++ b/lewis/devices/ttiplp/interfaces/stream_interface.py @@ -0,0 +1,92 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + + +class TtiplpStreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("ident").escape("*IDN?").eos().build(), + CmdBuilder("get_volt_sp").escape("V").int().escape("?").eos().build(), + CmdBuilder("set_volt_sp").escape("V").int().escape(" ").float().eos().build(), + CmdBuilder("get_volt").escape("V").int().escape("O?").eos().build(), + CmdBuilder("get_curr_sp").escape("I").int().escape("?").eos().build(), + CmdBuilder("set_curr_sp").escape("I").int().escape(" ").float().eos().build(), + CmdBuilder("get_curr").escape("I").int().escape("O?").eos().build(), + CmdBuilder("get_output").escape("OP").int().escape("?").eos().build(), + CmdBuilder("set_output").escape("OP").int().escape(" ").float().eos().build(), + CmdBuilder("set_overvolt").escape("OVP").int().escape(" ").float().eos().build(), + CmdBuilder("get_overvolt").escape("OVP").int().escape("?").eos().build(), + CmdBuilder("set_overcurr").escape("OCP").int().escape(" ").float().eos().build(), + CmdBuilder("get_overcurr").escape("OCP").int().escape("?").eos().build(), + CmdBuilder("get_event_stat_reg").escape("LSR").int().escape("?").eos().build(), + CmdBuilder("reset_trip").escape("TRIPRST").eos().build(), + } + + in_terminator = "\n" + out_terminator = "\r\n" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def ident(self): + return self.device.ident + + def get_volt(self, _): + volt = self.device.get_volt() + return "{:.4f}V".format(volt) + + def get_curr(self, _): + curr = self.device.get_curr() + return "{:.4f}A".format(curr) + + def set_volt_sp(self, _, volt_sp): + self.device.set_volt_sp(float(volt_sp)) + + def get_volt_sp(self, _): + return "V1 {:.3f}".format(self.device.volt_sp) + + def set_curr_sp(self, _, curr_sp): + self.device.set_curr_sp(float(curr_sp)) + + def get_curr_sp(self, _): + return "I1 {:.4f}".format(self.device.curr_sp) + + def set_output(self, _, output): + self.device.set_output(output) + + def get_output(self, _): + return "{:.0f}".format(self.device.output) + + def set_overvolt(self, _, overvolt): + self.device.set_overvolt(float(overvolt)) + + def get_overvolt(self, _): + return "VP1 {:.3f}".format(self.device.overvolt) + + def set_overcurr(self, _, overcurr): + self.device.set_overcurr(float(overcurr)) + + def get_overcurr(self, _): + return "CP1 {:.4f}".format(self.device.overcurr) + + def get_event_stat_reg(self, _): + ret = 0 + if self.device.is_voltage_limited(): # Bit 0 + ret += 1 + if self.device.is_current_limited(): # Bit 1 + ret += 2 + if self.device.is_overvolt_tripped(): # Bit 2 + ret += 4 + if self.device.is_overcurrent_tripped(): # Bit 3 + ret += 8 + if self.device.is_hardware_tripped(): # Bit 6 + ret += 64 + return f"{ret}" + + def reset_trip(self): + self.device.reset_trip() diff --git a/lewis/devices/ttiplp/states.py b/lewis/devices/ttiplp/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/ttiplp/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass diff --git a/lewis/devices/volumetric_rig/__init__.py b/lewis/devices/volumetric_rig/__init__.py new file mode 100644 index 00000000..a54ff26d --- /dev/null +++ b/lewis/devices/volumetric_rig/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedVolumetricRig + +__all__ = ["SimulatedVolumetricRig"] diff --git a/lewis/devices/volumetric_rig/buffer.py b/lewis/devices/volumetric_rig/buffer.py new file mode 100644 index 00000000..fc1301af --- /dev/null +++ b/lewis/devices/volumetric_rig/buffer.py @@ -0,0 +1,72 @@ +from .two_gas_mixer import TwoGasMixer +from .utilities import format_int +from .valve import Valve + + +class Buffer(object): + """A buffer contains a gas and is connected to a supply of a specific system gas via a valve. The system gas can be + changed and the buffer fills from the system gas it is connected to. The valve can only be opened if mixing of + the system and buffer gas are permitted + """ + + def __init__(self, index, buffer_gas, system_gas): + assert buffer_gas is not None + assert system_gas is not None + self._buffer_gas = buffer_gas + self._system_gas = system_gas + self._index = index + self._valve = Valve() + + def _disable_valve(self): + self._valve.disable() + + def _enable_valve(self): + self._valve.enable() + + def index(self, as_string=False, length=1): + return format_int(self._index, as_string, length) + + def open_valve(self, mixer): + """Try to open the valve between the buffer and system. Nothing will happen if the buffer and system gases are not + allowed to mix. + + :param mixer: The details of which gases can be mixed + """ + assert isinstance(mixer, TwoGasMixer) + if mixer.can_mix(self._buffer_gas, self._system_gas): + self._valve.open() + + def close_valve(self): + self._valve.close() + + def enable_valve(self): + self._valve.enable() + + def disable_valve(self): + """Disable the valve. If the valve is open when this is requested then it will be automatically closed + """ + self._valve.close() + self._valve.disable() + + def valve_is_open(self): + return self._valve.is_open() + + def valve_is_enabled(self): + return self._valve.is_enabled() + + def valve_status(self): + return self._valve.status() + + def buffer_gas(self): + return self._buffer_gas + + def system_gas(self): + return self._system_gas + + def set_system_gas(self, gas): + """Set a new system gas. This is only possible if the valve is closed. + + :param gas: The new system gas + """ + if not self._valve.is_open(): + self._system_gas = gas diff --git a/lewis/devices/volumetric_rig/device.py b/lewis/devices/volumetric_rig/device.py new file mode 100644 index 00000000..f4c2c1e0 --- /dev/null +++ b/lewis/devices/volumetric_rig/device.py @@ -0,0 +1,284 @@ +from collections import OrderedDict + +from lewis.devices import StateMachineDevice + +from .buffer import Buffer +from .error_states import ErrorStates +from .ethernet_device import EthernetDevice +from .gas import Gas +from .hmi_device import HmiDevice +from .pressure_sensor import PressureSensor +from .seed_gas_data import SeedGasData +from .sensor import Sensor +from .states import DefaultInitState, DefaultRunningState +from .system_gases import SystemGases +from .two_gas_mixer import TwoGasMixer +from .utilities import format_float, format_int +from .valve import Valve + + +class SimulatedVolumetricRig(StateMachineDevice): + HALTED_MESSAGE = "Rejected only allowed when running" + + def _initialize_data(self): + # Device modes + self.serial_command_mode = True + self._cycle_pressures = False + + # Set up all available gases + self._system_gases = SystemGases( + [Gas(i, SeedGasData.names[i]) for i in range(len(SeedGasData.names))] + ) + + # Set mixable gases + self._mixer = TwoGasMixer() + for name1, name2 in SeedGasData.mixable_gas_names(): + self._mixer.add_mixable( + self._system_gases.gas_by_name(name1), self._system_gases.gas_by_name(name2) + ) + + # Set buffers + buffer_gases = [ + (self._system_gases.gas_by_name(name1), self._system_gases.gas_by_name(name2)) + for name1, name2 in SeedGasData.buffer_gas_names() + ] + self._buffers = [ + Buffer(i + 1, buffer_gases[i][0], buffer_gases[i][1]) for i in range(len(buffer_gases)) + ] + + # Set ethernet devices + self._plc = EthernetDevice("192.168.0.1") + self._hmi = HmiDevice("192.168.0.2") + + # Target pressure: We can't set this via serial + self._target_pressure = 100.00 + + # Set up sensors + self._temperature_sensors = [Sensor() for _ in range(9)] + self._pressure_sensors = [PressureSensor() for _ in range(5)] + + # Set up special valves + self._supply_valve = Valve() + self._vacuum_extract_valve = Valve() + self._cell_valve = Valve() + + # Misc system state variables + self._halted = False + self._status_code = 2 + self._errors = ErrorStates() + + def _get_state_handlers(self): + return { + "init": DefaultInitState(), + "running": DefaultRunningState(), + } + + def _get_initial_state(self): + return "init" + + def _get_transition_handlers(self): + return OrderedDict( + [ + (("init", "running"), lambda: self.serial_command_mode), + ] + ) + + def identify(self): + return "ISIS Volumetric Gas Handing Panel" + + def buffer_count(self): + return len(self._buffers) + + def buffer(self, i): + try: + return next(b for b in self._buffers if b.index() == i) + except StopIteration: + return None + + def memory_location(self, location, as_string, length): + # Currently returns the value at the location as the location itself. + return format_int(location, as_string, length) + + def plc(self): + return self._plc + + def hmi(self): + return self._hmi + + def halted(self): + return self._halted + + def halt(self): + self._halted = True + + def pressure_sensors(self, reverse=False): + return self._pressure_sensors if not reverse else list(reversed(self._pressure_sensors)) + + def temperature_sensors(self, reverse=False): + return ( + self._temperature_sensors if not reverse else list(reversed(self._temperature_sensors)) + ) + + def target_pressure(self, as_string): + return format_float(self._target_pressure, as_string) + + def status_code(self, as_string=False, length=None): + # We don't currently do any logic for the system status, it always returns 2 + return format_int(2, as_string, length) + + def errors(self): + return self._errors + + def valve_count(self): + return len(self.valves_status()) + + def valves_status(self): + # The valve order goes: supply, vacuum, cell, buffer(n), ... , buffer(1) + return [ + self._supply_valve.status(), + self._vacuum_extract_valve.status(), + self._cell_valve.status(), + ] + [b.valve_status() for b in list(reversed(self._buffers))] + + def buffer_valve_is_open(self, buffer_number): + buff = self.buffer(buffer_number) + return buff.valve_is_open() if buff is not None else False + + def vacuum_valve_is_open(self): + return self._vacuum_extract_valve.is_open() + + def cell_valve_is_open(self): + return self._cell_valve.is_open() + + def buffer_valve_is_enabled(self, buffer_number): + buff = self.buffer(buffer_number) + return buff.valve_is_enabled() if buff is not None else False + + def vacuum_valve_is_enabled(self): + return self._vacuum_extract_valve.is_enabled() + + def cell_valve_is_enabled(self): + return self._cell_valve.is_enabled() + + def open_buffer_valve(self, buffer_number): + if not self._halted: + buff = self.buffer(buffer_number) + # The buffer must exist and the system gas connected to it must be mixable with all the other current + # buffer gases + if buff is not None and all( + self._mixer.can_mix(buff.system_gas(), b.buffer_gas()) for b in self._buffers + ): + buff.open_valve(self._mixer) + + def open_cell_valve(self): + if not self._halted: + self._cell_valve.open() + + def open_vacuum_valve(self): + if not self._halted: + # We can't open the vacuum valve if any of the buffer valves are open + if not any([b.valve_is_open() for b in self._buffers]): + self._vacuum_extract_valve.open() + + def close_buffer_valve(self, buffer_number): + if not self._halted: + buff = self.buffer(buffer_number) + if buff is not None: + buff.close_valve() + + def close_cell_valve(self): + if not self._halted: + self._cell_valve.close() + + def close_vacuum_valve(self): + if not self._halted: + self._vacuum_extract_valve.close() + + def enable_cell_valve(self): + if not self._halted: + self._cell_valve.enable() + + def enable_vacuum_valve(self): + if not self._halted: + self._vacuum_extract_valve.enable() + + def enable_buffer_valve(self, buffer_number): + if not self._halted: + buff = self.buffer(buffer_number) + if buff is not None: + buff.enable_valve() + + def disable_cell_valve(self): + if not self._halted: + self._cell_valve.disable() + + def disable_vacuum_valve(self): + if not self._halted: + self._vacuum_extract_valve.disable() + + def disable_buffer_valve(self, buffer_number): + if not self._halted: + buff = self.buffer(buffer_number) + if buff is not None: + buff.disable_valve() + + def buffers(self): + return self._buffers + + def mixer(self): + return self._mixer + + def update_pressures(self, dt): + # This is a custom behaviour designed to cycle through various valve behaviours. It will ramp up the pressure + # to the maximum, close and disable all valves, then let the pressure drop and enable and subsequently reopen + # the valves + if self._cycle_pressures: + number_of_open_buffers = sum(1 for b in self._buffers if b.valve_is_open()) + for p in self._pressure_sensors: + base_rate = 10.0 + if number_of_open_buffers > 0: + # Approach a pressure above target pressure so we intentionally go over the limit + from random import random + + p.approach_value( + dt, + 1.1 * self._target_pressure, + base_rate * float(number_of_open_buffers) / self.buffer_count() * random(), + ) + else: + p.approach_value(dt, 0.0, base_rate) + if self._overall_pressure() < 0.5 * self._target_pressure: + for b in self._buffers: + b.enable_valve() + if self._overall_pressure() < 0.1 * self._target_pressure: + b.open_valve(self._mixer) + + # Check if system pressure is over the maximum and disable valves if necessary + self._check_pressure() + + def _overall_pressure(self): + # This calculates the pressure based on the 5 readings from the pressure sensor. At the moment this is done in + # an ad hoc fashion. The actual behaviour hasn't been set on the real device, and it is likely the output from + # the PMV command could change in the future to give the actual reference pressure. + return max(s.value() for s in self._pressure_sensors) + + def _check_pressure(self): + # Disable the buffer valves if the pressure exceeds the limit + if self._overall_pressure() > self._target_pressure: + for b in self._buffers: + b.disable_valve() + + def cycle_pressures(self, on): + # Switch on/off pressure cycling + self._cycle_pressures = on + + def set_pressures(self, value): + # Sets all pressure sensors to have the same value + for p in self._pressure_sensors: + p.set_value(value, self._target_pressure) + + def set_pressure_target(self, value): + self._target_pressure = value + + def system_gases(self): + return self._system_gases diff --git a/lewis/devices/volumetric_rig/error_states.py b/lewis/devices/volumetric_rig/error_states.py new file mode 100644 index 00000000..3fd9153e --- /dev/null +++ b/lewis/devices/volumetric_rig/error_states.py @@ -0,0 +1,10 @@ +class ErrorStates(object): + """The possible error states the device can be in. + """ + + def __init__(self): + self.run = False + self.hmi = False + self.gauges = False + self.comms = False + self.estop = False diff --git a/lewis/devices/volumetric_rig/ethernet_device.py b/lewis/devices/volumetric_rig/ethernet_device.py new file mode 100644 index 00000000..562cc99e --- /dev/null +++ b/lewis/devices/volumetric_rig/ethernet_device.py @@ -0,0 +1,10 @@ +class EthernetDevice(object): + """An ethernet device that the rig communicates with. + """ + + def __init__(self, ip): + assert type(ip) is str + self._ip = ip + + def ip(self): + return self._ip diff --git a/lewis/devices/volumetric_rig/gas.py b/lewis/devices/volumetric_rig/gas.py new file mode 100644 index 00000000..546cc681 --- /dev/null +++ b/lewis/devices/volumetric_rig/gas.py @@ -0,0 +1,17 @@ +from .utilities import format_int, pad_string + + +class Gas(object): + """A gas within the system, identified by either its name or an integer index. + """ + + def __init__(self, index, name): + assert type(index) is int and type(name) is str + self._index = index + self._name = name + + def name(self, length=None, padding_character=" "): + return pad_string(self._name, length, padding_character) + + def index(self, as_string=False, length=2): + return format_int(self._index, as_string, length) diff --git a/lewis/devices/volumetric_rig/hmi_device.py b/lewis/devices/volumetric_rig/hmi_device.py new file mode 100644 index 00000000..a1bab936 --- /dev/null +++ b/lewis/devices/volumetric_rig/hmi_device.py @@ -0,0 +1,49 @@ +from .ethernet_device import EthernetDevice +from .utilities import format_int + + +class HmiDevice(EthernetDevice): + OK_STATUS = "OK" + + def __init__(self, ip): + self._status = HmiDevice.OK_STATUS + self._base_page = 34 + self._sub_page = 2 + self._count_cycles = [ + "999", + "006", + "002", + "002", + "002", + "002", + "002", + "002", + "001", + "001", + "310", + ] + self._count = 0 + self._max_grabbed = 38 + self._limit = 20 + super(HmiDevice, self).__init__(ip) + + def base_page(self, as_string, length): + return format_int(self._base_page, as_string, length) + + def sub_page(self, as_string, length): + return format_int(self._sub_page, as_string, length) + + def count_cycles(self): + return list(self._count_cycles) + + def max_grabbed(self, as_string, length): + return format_int(self._max_grabbed, as_string, length) + + def limit(self, as_string, length): + return format_int(self._limit, as_string, length) + + def count(self, as_string, length): + return format_int(self._count, as_string, length) + + def status(self): + return self._status diff --git a/lewis/devices/volumetric_rig/interfaces/__init__.py b/lewis/devices/volumetric_rig/interfaces/__init__.py new file mode 100644 index 00000000..85fd72a4 --- /dev/null +++ b/lewis/devices/volumetric_rig/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import VolumetricRigStreamInterface + +__all__ = ["VolumetricRigStreamInterface"] diff --git a/lewis/devices/volumetric_rig/interfaces/stream_interface.py b/lewis/devices/volumetric_rig/interfaces/stream_interface.py new file mode 100644 index 00000000..7ed5ef6c --- /dev/null +++ b/lewis/devices/volumetric_rig/interfaces/stream_interface.py @@ -0,0 +1,624 @@ +from lewis.adapters.stream import Cmd, StreamInterface + +from ..sensor_status import SensorStatus +from ..utilities import convert_raw_to_bool, convert_raw_to_float, convert_raw_to_int, format_int +from ..valve_status import ValveStatus + + +class VolumetricRigStreamInterface(StreamInterface): + # The rig typically splits a command by whitespace and then uses the arguments it needs and then ignores the rest + # so "IDN" will respond as "IDN BLAH BLAH BLAH" and "BCS 01" would be the same as "BCS 01 02 03". + # Some commands that take input will respond with default (often invalid) parameters if not present. For example + # "BCS" is the same as "BCS 00" and also "BCS AA". + serial_commands = { + Cmd("purge", "^(.*)\!$"), + Cmd("get_identity", "^IDN(?:\s.*)?$"), + Cmd("get_identity", "^\?(?:\s.*)?$"), + Cmd("get_buffer_control_and_status", "^BCS(?:\s(\S*))?.*$"), + Cmd("get_ethernet_and_hmi_status", "^ETN(?:\s.*)?$"), + Cmd("get_gas_control_and_status", "^GCS(?:\s.*)?$"), + Cmd("get_gas_mix_matrix", "^GMM(?:\s.*)?$"), + Cmd("gas_mix_check", "^GMC(?:\s(\S*))?(?:\s(\S*))?.*$"), + Cmd("get_gas_number_available", "^GNA(?:\s.*)?$"), + Cmd("get_hmi_status", "^HMI(?:\s.*)?$"), + Cmd("get_hmi_count_cycles", "^HMC(?:\s.*)?$"), + Cmd("get_memory_location", "^RDM(?:\s(\S*))?.*"), + Cmd("get_pressure_and_temperature_status", "^PTS(?:\s.*)?$"), + Cmd("get_pressures", "^PMV(?:\s.*)?$"), + Cmd("get_temperatures", "^TMV(?:\s.*)?$"), + Cmd("get_ports_and_relays_hex", "^PTR(?:\s.*)?$"), + Cmd("get_ports_output", "^POT(?:\s.*)?$"), + Cmd("get_ports_input", "^PIN(?:\s.*)?$"), + Cmd("get_ports_relays", "^PRY(?:\s.*)?$"), + Cmd("get_system_status", "^STS(?:\s.*)?$"), + Cmd("get_com_activity", "^COM(?:\s.*)?$"), + Cmd("get_valve_status", "^VST(?:\s.*)?$"), + Cmd("open_valve", "^OPV(?:\s(\S*))?.*$"), + Cmd("close_valve", "^CLV(?:\s(\S*))?.*$"), + Cmd("halt", "^HLT(?:\s.*)?$"), + } + + # These commands are intended solely as a control mechanism for the emulator. As an alternative, the Lewis + # backdoor can be used to modify the device state. + control_commands = { + Cmd("set_buffer_system_gas", "^_SBG(?:\s(\S*))?(?:\s(\S*))?.*$"), + Cmd("set_pressure_cycling", "^_PCY(?:\s(\S*)).*$"), + Cmd("set_pressures", "^_SPR(?:\s(\S*)).*$"), + Cmd("set_pressure_target", "^_SPT(?:\s(\S*)).*$"), + Cmd("enable_valve", "^_ENV(?:\s(\S*)).*$"), + Cmd("disable_valve", "^_DIV(?:\s(\S*)).*$"), + } + + commands = set.union(serial_commands, control_commands) + + # You may need to change these to \r\n if using Telnet" + in_terminator = "\r" + out_terminator = "\r" + + # Lots of formatted output for the volumetric rig is based on fixed length strings + output_length = 20 + + def purge(self, chars): + """Responds any current input to the screen without executing it. + + :param chars: Whatever characters are left over in the buffer + :return: Purge message including ignored input + """ + return " ".join( + ["PRG,00,Purge", format_int(len(chars) + 1, True, 5), "Characters", chars + "!"] + ) + + def get_identity(self): + """Responds with the devices identity. + + :return: Device identity + """ + return "IDN,00," + self._device.identify() + + def _build_buffer_control_and_status_string(self, buffer_number): + """Get information about a specific buffer, its valve state and the gases connected to it. + + :param buffer_number : The index of the buffer + :return: Information about the requested buffer + """ + buff = self._device.buffer(buffer_number) + assert buff is not None + return " ".join( + [ + "", + buff.index(as_string=True), + buff.buffer_gas().index(as_string=True), + buff.buffer_gas().name(VolumetricRigStreamInterface.output_length, " "), + "E" if buff.valve_is_enabled() else "d", + "O" if buff.valve_is_open() else "c", + buff.system_gas().index(as_string=True), + buff.system_gas().name(), + ] + ) + + def get_buffer_control_and_status(self, buffer_number_raw): + """Get information about a specific buffer, its valve state and the gases connected to it. + + :param buffer_number_raw : The buffer "number" entered by a user. Although a number is expected, the command + will accept other types of input + :return: Information about the requested buffer + """ + buffer_number = convert_raw_to_int(buffer_number_raw) + message_prefix = "BCS" + num_length = 3 + error_message_prefix = " ".join( + [message_prefix, "Buffer", str(buffer_number)[:num_length].zfill(num_length)] + ) + buffer_too_low = " ".join([error_message_prefix, "Too Low"]) + buffer_too_high = " ".join([error_message_prefix, "Too High"]) + + if buffer_number <= 0: + return buffer_too_low + elif buffer_number > len(self._device.buffers()): + return buffer_too_high + else: + return "BCS " + self._build_buffer_control_and_status_string(buffer_number) + + def get_ethernet_and_hmi_status(self): + """Get information about the rig's hmi and plc ethernet devices. + + :return: Information about the ethernet devices status. The syntax of the return string is odd: the + separators are not consistent + """ + return " ".join( + [ + "ETN:PLC", + self._device.plc().ip() + ",HMI", + self._device.hmi().status(), + "," + self._device.hmi().ip(), + ] + ) + + def get_gas_control_and_status(self): + """Get a list of information about all the buffers, their associated gases and valve statuses. + + :return: Buffer information. One line per buffer with a header + """ + return "\r\n".join( + ["No No Buffer E O No System"] + + [ + self._build_buffer_control_and_status_string(b.index()) + for b in self._device.buffers() + ] + + ["GCS"] + ) + + def get_gas_mix_matrix(self): + """Get information about which gases can be mixed together. + + :return: A 2D matrix representation of the ability to mix different gases with column and row titles + """ + system_gases = self._device.system_gases().gases() + column_headers = [ + gas.name(VolumetricRigStreamInterface.output_length, "|") for gas in system_gases + ] + row_titles = [ + " ".join( + [ + gas.index(as_string=True), + gas.name(VolumetricRigStreamInterface.output_length, " "), + ] + ) + for gas in system_gases + ] + mixable_chars = [ + ["<" if self._device.mixer().can_mix(g1, g2) else "." for g1 in system_gases] + for g2 in system_gases + ] + + # Put data in output format + lines = list() + # Add column headers + for i in range(len(max(column_headers, key=len))): + words = list() + # For the top-left block of white space + words.append((len(max(row_titles, key=len)) - 1) * " ") + # Vertically aligned gas names + for j in range(len(column_headers)): + words.append(column_headers[j][i]) + lines.append(" ".join(words)) + # Add rows + assert len(row_titles) == len(mixable_chars) + for i in range(len(row_titles)): + words = list() + words.append(row_titles[i]) + words.append(" ".join(mixable_chars[i])) + lines.append("".join(words)) + # Add footer + lines.append("GMM allowance limit: " + str(self._device.system_gases().gas_count())) + + return "\r\n".join(lines) + + def gas_mix_check(self, gas1_index_raw, gas2_index_raw): + """Query whether two gases can be mixed. + + :param gas1_index_raw : The index of the first gas. Although a number is expected, the command will + accept other types of input + :param gas2_index_raw : As above for the 2nd gas + + :return: An echo of the name and index of the requested gases as well as an ok/NO indicating whether the + gases can be mixed + """ + gas1 = self._device.system_gases().gas_by_index(convert_raw_to_int(gas1_index_raw)) + gas2 = self._device.system_gases().gas_by_index(convert_raw_to_int(gas2_index_raw)) + if gas1 is None: + gas1 = self._device.system_gases().gas_by_index(0) + if gas2 is None: + gas2 = self._device.system_gases().gas_by_index(0) + + return " ".join( + [ + "GMC", + gas1.index(as_string=True), + gas1.name(VolumetricRigStreamInterface.output_length, "."), + gas2.index(as_string=True), + gas2.name(VolumetricRigStreamInterface.output_length, "."), + "ok" if self._device.mixer().can_mix(gas1, gas2) else "NO", + ] + ) + + def get_gas_number_available(self): + """Get the number of available gases. + + :return: The number of available gases + """ + return self._device.system_gases().gas_count() + + def get_hmi_status(self): + """Get the current status of the HMI. + + :return: Information about the HMI + """ + hmi = self._device.hmi() + return ",".join( + [ + "HMI " + hmi.status() + " ", + hmi.ip(), + "B", + hmi.base_page(as_string=True, length=3), + "S", + hmi.sub_page(as_string=True, length=3), + "C", + hmi.count(as_string=True, length=4), + "L", + hmi.limit(as_string=True, length=4), + "M", + hmi.max_grabbed(as_string=True, length=4), + ] + ) + + def get_hmi_count_cycles(self): + """Get information about how frequently the HMI is disconnected. + + :return: A list of integers indicating the number of occurrences of a disconnected count cycle of a specific + length + """ + return " ".join(["HMC"] + self._device.hmi().count_cycles()) + + def get_memory_location(self, location_raw): + """Get the value stored in a particular location in memory. + + :param location_raw : The memory location to read. Although a number is expected, the command will accept other + types of input + + :return: The memory location and the value stored there + """ + location = convert_raw_to_int(location_raw) + return " ".join( + [ + "RDM", + format_int(location, as_string=True, length=4), + self._device.memory_location(location, as_string=True, length=6), + ] + ) + + def get_pressure_and_temperature_status(self): + """Get the status of the temperature and pressure sensors. + + :return: A letter for each sensor indicating its status. Refer to the spec for the meaning and sensor order + """ + status_codes = { + SensorStatus.DISABLED: "D", + SensorStatus.NO_REPLY: "X", + SensorStatus.VALUE_IN_RANGE: "O", + SensorStatus.VALUE_TOO_LOW: "L", + SensorStatus.VALUE_TOO_HIGH: "H", + SensorStatus.UNKNOWN: "?", + } + + return "PTS " + "".join( + [ + status_codes[s.status()] + for s in self._device.pressure_sensors(reverse=True) + + self._device.temperature_sensors(reverse=True) + ] + ) + + def get_pressures(self): + """Get the current pressure sensor readings, and target pressure. + + :return: The pressure readings from each of the pressure sensors and the target pressure which, if exceeded, + will cause all buffer valves to close and disable + """ + return " ".join( + ["PMV"] + + [p.value(as_string=True) for p in self._device.pressure_sensors(reverse=True)] + + ["T", self._device.target_pressure(as_string=True)] + ) + + def get_temperatures(self): + """Get the current temperature reading. + + :return: The current temperature for each of the temperature sensors + """ + return " ".join( + ["TMV"] + + [t.value(as_string=True) for t in self._device.temperature_sensors(reverse=True)] + ) + + def get_valve_status(self): + """Get the status of the buffer and system valves. + + :return: The status of each of the system valves represented by a letter. Refer to the specification for the + exact meaning and order + """ + status_codes = { + ValveStatus.OPEN_AND_ENABLED: "O", + ValveStatus.CLOSED_AND_ENABLED: "E", + ValveStatus.CLOSED_AND_DISABLED: "D", + ValveStatus.OPEN_AND_DISABLED: "!", + } + return "VST Valve Status " + "".join( + [status_codes[v] for v in self._device.valves_status()] + ) + + @staticmethod + def _convert_raw_valve_to_int(raw): + """Get the valve number from its identifier. + + :param raw: The raw valve identifier + :return: An integer indicating the valve number + """ + if str(raw).lower() == "c": + n = 7 + elif str(raw).lower() == "v": + n = 8 + else: + n = convert_raw_to_int(raw) + return n + + def _set_valve_status(self, valve_identifier_raw, set_to_open=None, set_to_enabled=None): + """Change the valve status. + + :param valve_identifier_raw: A raw value that identifies the valve + :param set_to_open: Whether to set the valve to open(True)/closed(False)/do noting(None) + :param set_to_enabled: Whether to set the valve to enabled(True)/disabled(False)/do noting(None) + :return: Indicates the valve number, previous state, and new state + """ + valve_number = VolumetricRigStreamInterface._convert_raw_valve_to_int(valve_identifier_raw) + + # We should have exactly one of these arguments + if set_to_open is not None: + command = "OPV" if set_to_open else "CLV" + elif set_to_enabled is not None: + command = "_ENV" if set_to_enabled else "_DIV" + else: + assert False + + # The command and valve number are always included + message_prefix = " ".join([command, "Value", str(valve_number)]) + + # Select an action based on the input parameters. + args = list() + enabled = lambda *args: None + if self._device.halted(): + return command + " Rejected only allowed when running" + elif valve_number <= 0: + return message_prefix + " Too Low" + elif valve_number <= self._device.buffer_count(): + if set_to_open is not None: + action = ( + self._device.open_buffer_valve + if set_to_open + else self._device.close_buffer_valve + ) + enabled = self._device.buffer_valve_is_enabled + current_state = self._device.buffer_valve_is_open + else: + action = ( + self._device.enable_buffer_valve + if set_to_enabled + else self._device.disable_buffer_valve + ) + current_state = self._device.buffer_valve_is_enabled + args.append(valve_number) + elif valve_number == self._device.buffer_count() + 1: + if set_to_open is not None: + action = ( + self._device.open_cell_valve if set_to_open else self._device.close_cell_valve + ) + enabled = self._device.cell_valve_is_enabled + current_state = self._device.cell_valve_is_open + else: + action = ( + self._device.enable_cell_valve + if set_to_enabled + else self._device.disable_cell_valve + ) + current_state = self._device.cell_valve_is_enabled + elif valve_number == self._device.buffer_count() + 2: + if set_to_open is not None: + action = ( + self._device.open_vacuum_valve + if set_to_open + else self._device.close_vacuum_valve + ) + enabled = self._device.vacuum_valve_is_enabled + current_state = self._device.vacuum_valve_is_open + else: + action = ( + self._device.enable_vacuum_valve + if set_to_enabled + else self._device.disable_vacuum_valve + ) + current_state = self._device.vacuum_valve_is_enabled + else: + return message_prefix + " Too High" + + if set_to_open is not None: + if not enabled(*args): + return " ".join( + [command, "Rejected not enabled", format_int(valve_number, True, 1)] + ) + status_codes = {True: "open", False: "closed"} + else: + status_codes = {True: "enabled", False: "disabled"} + + # Execute the action and get the status before and after + original_status = current_state(*args) + action(*args) + new_status = current_state(*args) + return " ".join( + [ + command, + "Valve Buffer", + str(valve_number), + status_codes[new_status], + "was", + status_codes[original_status], + ] + ) + + def close_valve(self, valve_number_raw): + """Close a valve. + + :param valve_number_raw: The number of the valve to close. The first n valves correspond to the buffers where n + is the number of buffers. The n+1th valve is the cell valve, the n+2nd valve is for the vacuum. The supply + valve cannot be controlled via serial. Although a number is expected, the command will accept other types + of input + :return: Indicates the valve number, previous state, and new state + """ + return self._set_valve_status(valve_number_raw, set_to_open=False) + + def open_valve(self, valve_number_raw): + """Open a valve. + + :param valve_number_raw : The number of the valve to close. The first n valves correspond to the buffers where n + is the number of buffers. The n+1th valve is the cell valve, the n+2nd valve is for the vacuum. The supply + valve cannot be controlled via serial. Although a number is expected, the command will accept other types + of input + :return: Indicates the valve number, previous state, and new state + """ + return self._set_valve_status(valve_number_raw, set_to_open=True) + + def enable_valve(self, valve_number_raw): + """Enable a valve. + + :param valve_number_raw: The number of the valve to close. The first n valves correspond to the buffers where n + is the number of buffers. The n+1th valve is the cell valve, the n+2nd valve is for the vacuum. The supply + valve cannot be controlled via serial. Although a number is expected, the command will accept other types + of input + :return: Indicates the valve number, previous state, and new state + """ + return self._set_valve_status(convert_raw_to_int(valve_number_raw), set_to_enabled=True) + + def disable_valve(self, valve_number_raw): + """Disable a valve. + + :param valve_number_raw: The number of the valve to close. The first n valves correspond to the buffers where n + is the number of buffers. The n+1th valve is the cell valve, the n+2nd valve is for the vacuum. The supply + valve cannot be controlled via serial. Although a number is expected, the command will accept other types + of input + :return: Indicates the valve number, previous state, and new state + """ + return self._set_valve_status(convert_raw_to_int(valve_number_raw), set_to_enabled=False) + + def halt(self): + """Halts the device. No further valve commands will be accepted. + + :return: Indicates that the system has been, or was already halted + """ + if self._device.halted(): + message = "SYSTEM ALREADY HALTED" + else: + self._device.halt() + assert self._device.halted() + message = "SYSTEM NOW HALTED" + return "HLT *** " + message + " ***" + + def get_system_status(self): + """Get information about the current system state. + + :return: Information about the system. Capitalisation of a particular word indicates an error has occurred + in that subsystem. Refer to the specification for the meaning of system codes + """ + return " ".join( + [ + "STS", + self._device.status_code(as_string=True, length=2), + "STOP" if self._device.errors().run else "run", + "HMI" if self._device.errors().hmi else "hmi", + # Spelling error duplicated as on device + "GUAGES" if self._device.errors().gauges else "guages", + "COMMS" if self._device.errors().comms else "comms", + "HLT" if self._device.halted() else "halted", + "E-STOP" if self._device.errors().estop else "estop", + ] + ) + + def get_ports_and_relays_hex(self): + """:return: Information about the ports and relays + """ + return "PTR I:00 0000 0000 R:0000 0200 0000 O:00 0000 4400" + + def get_ports_output(self): + """:return: Information about the port output + """ + return "POT qwertyus vsbbbbbbzyxwvuts aBhecSssvsbbbbbb" + + def get_ports_input(self): + """:return: Information about the port input + """ + return "PIN qwertyui zyxwvutsrqponmlk abcdefghijklmneb" + + def get_ports_relays(self): + """:return: Information about the port relays. + """ + return "PRY qwertyuiopasdfgh zyxwhmLsrqponmlk abcdefghihlbhace" + + def get_com_activity(self): + """:return: Information about activity over the COM port + """ + return "COM ok 0113/0000" + + def set_buffer_system_gas(self, buffer_index_raw, gas_index_raw): + """Changes the system gas associated with a particular buffer. + + :param buffer_index_raw: The index of the buffer to update + :param gas_index_raw: The index of the gas to update + :return: Indicates the buffer changed, the previous system gas and the new system gas + """ + gas = self._device.system_gases().gas_by_index(convert_raw_to_int(gas_index_raw)) + buff = self._device.buffer(convert_raw_to_int(buffer_index_raw)) + if gas is not None and buff is not None: + original_gas = buff.system_gas() + buff.set_system_gas(gas) + new_gas = buff.system_gas() + return " ".join( + [ + "SBG Buffer", + buff.index(as_string=True), + "system gas was", + original_gas.name(), + "now", + new_gas.name(), + ] + ) + else: + return "SBG Lookup failed" + + def set_pressure_cycling(self, on_int_raw): + """Starts a sequence of pressure cycling. The pressure is increased until the target is met. This disables all + buffer valves. The system pressure is decreased and the valves are renabled and reopened when the pressure + falls below set limits. When the pressure reaches a minimum, the cycle is restarted. This allows simulation + of various valve conditions. + + :param on_int_raw: Whether to switch cycling on(1)/off(other) + :return: Indicates whether cycling is enabled + """ + cycle = convert_raw_to_bool(on_int_raw) + self._device.cycle_pressures(cycle) + return "_PCY " + str(cycle) + + def set_pressures(self, value_raw): + """Set the reading for all pressure sensors to a fixed value. + + :param value_raw: The value to apply to the pressure sensors + :return: Echo the new pressure + """ + value = convert_raw_to_float(value_raw) + self._device.set_pressures(value) + return "SPR Pressures set to " + str(value) + + def set_pressure_target(self, value_raw): + """Set the target (limit) pressure for the system. + + :param value_raw: The new pressure target + :return: Echo the new target + """ + value = convert_raw_to_float(value_raw) + self._device.set_pressure_target(value) + return "SPT Pressure target set to " + str(value) + + def handle_error(self, request, error): + """Handle errors during execution. May be an unrecognised command or emulator failure. + """ + if str(error) == "None of the device's commands matched.": + return "URC,04,Unrecognised Command," + str(request) + else: + print("An error occurred at request " + repr(request) + ": " + repr(error)) diff --git a/lewis/devices/volumetric_rig/pressure_sensor.py b/lewis/devices/volumetric_rig/pressure_sensor.py new file mode 100644 index 00000000..b4ffb788 --- /dev/null +++ b/lewis/devices/volumetric_rig/pressure_sensor.py @@ -0,0 +1,24 @@ +from .sensor import Sensor +from .sensor_status import SensorStatus + + +class PressureSensor(Sensor): + """A sensor that reads the pressure. + """ + + def __init__(self): + super(PressureSensor, self).__init__() + + def set_value(self, v, target): + """Updates the pressure reading with a new value along with the sensor's status. + + :param v: The new value + :param target: The target/limit value + """ + super(PressureSensor, self).set_value(v) + if self._value < 0.0: + self._status = SensorStatus.VALUE_TOO_LOW + elif self._value > target: + self._status = SensorStatus.VALUE_TOO_HIGH + else: + self._status = SensorStatus.VALUE_IN_RANGE diff --git a/lewis/devices/volumetric_rig/seed_gas_data.py b/lewis/devices/volumetric_rig/seed_gas_data.py new file mode 100644 index 00000000..145fe519 --- /dev/null +++ b/lewis/devices/volumetric_rig/seed_gas_data.py @@ -0,0 +1,97 @@ +class SeedGasData(object): + """Contains information about gases and their mixing properties used to set up the initial device state. + """ + + # Gas names + unknown = "UNKNOWN" + empty = "EMPTY" + vacuum_extract = "VACUUM EXTRACT" + argon = "ARGON" + nitrogen = "NITROGEN" + neon = "NEON" + carbon_dioxide = "CARBON DIOXIDE" + carbon_monoxide = "CARBON MONOXIDE" + helium = "HELIUM" + gravy = "GRAVY" + liver = "LIVER" + hydrogen = "HYDROGEN" + oxygen = "OXYGEN" + curried_rat = "CURRIED RAT" + fresh_coffee = "FRESH COFFEE" + bacon = "BACON" + onion = "ONION" + chips = "CHIPS" + garlic = "GARLIC" + brown_sauce = "BROWN SAUCE" + + names = [ + unknown, + empty, + vacuum_extract, + argon, + nitrogen, + neon, + carbon_dioxide, + carbon_monoxide, + helium, + gravy, + liver, + hydrogen, + oxygen, + curried_rat, + fresh_coffee, + bacon, + onion, + chips, + garlic, + brown_sauce, + ] + + @staticmethod + def mixable_gas_names(): + sgd = SeedGasData + mixable_names = set() + for g in sgd.names: + if g not in {sgd.unknown, sgd.liver}: + mixable_names.add((g, g)) + mixable_names.add((sgd.empty, g)) + mixable_names.add((sgd.vacuum_extract, g)) + mixable_names.add((sgd.argon, g)) + if g != sgd.nitrogen: + mixable_names.add((sgd.neon, g)) + mixable_names.add((sgd.carbon_dioxide, g)) + if g != sgd.carbon_monoxide: + mixable_names.add((sgd.helium, g)) + for g in {sgd.hydrogen, sgd.oxygen, sgd.onion, sgd.garlic, sgd.brown_sauce}: + mixable_names.add((sgd.gravy, g)) + import itertools + + for pair in list( + itertools.combinations( + { + sgd.oxygen, + sgd.curried_rat, + sgd.fresh_coffee, + sgd.bacon, + sgd.onion, + sgd.chips, + sgd.garlic, + sgd.brown_sauce, + }, + 2, + ) + ): + mixable_names.add((pair[0], pair[1])) + return mixable_names + + @staticmethod + def buffer_gas_names(): + sgd = SeedGasData + return [ + (sgd.argon, sgd.argon), + (sgd.nitrogen, sgd.empty), + (sgd.neon, sgd.empty), + (sgd.carbon_dioxide, sgd.empty), + (sgd.helium, sgd.helium), + (sgd.hydrogen, sgd.empty), + ] diff --git a/lewis/devices/volumetric_rig/sensor.py b/lewis/devices/volumetric_rig/sensor.py new file mode 100644 index 00000000..0c5d33d4 --- /dev/null +++ b/lewis/devices/volumetric_rig/sensor.py @@ -0,0 +1,23 @@ +from .sensor_status import SensorStatus +from .utilities import format_float + + +class Sensor(object): + """A basic sensor which monitors a value and keeps track of its own status. + """ + + def __init__(self): + self._status = SensorStatus.NO_REPLY + self._value = 0.0 + + def set_status(self, status): + self._status = status + + def status(self): + return self._status + + def set_value(self, v): + self._value = v + + def value(self, as_string=False): + return format_float(self._value, as_string) diff --git a/lewis/devices/volumetric_rig/sensor_status.py b/lewis/devices/volumetric_rig/sensor_status.py new file mode 100644 index 00000000..eeee0003 --- /dev/null +++ b/lewis/devices/volumetric_rig/sensor_status.py @@ -0,0 +1,7 @@ +class SensorStatus(object): + """An enumeration of possible sensor states. + """ + + UNKNOWN, DISABLED, NO_REPLY, VALUE_IN_RANGE, VALUE_TOO_LOW, VALUE_TOO_HIGH = ( + i for i in range(6) + ) diff --git a/lewis/devices/volumetric_rig/states.py b/lewis/devices/volumetric_rig/states.py new file mode 100644 index 00000000..a3c0498d --- /dev/null +++ b/lewis/devices/volumetric_rig/states.py @@ -0,0 +1,10 @@ +from lewis.core.statemachine import State + + +class DefaultInitState(State): + pass + + +class DefaultRunningState(State): + def in_state(self, dt): + self._context.update_pressures(dt) diff --git a/lewis/devices/volumetric_rig/system_gases.py b/lewis/devices/volumetric_rig/system_gases.py new file mode 100644 index 00000000..f00998ef --- /dev/null +++ b/lewis/devices/volumetric_rig/system_gases.py @@ -0,0 +1,31 @@ +from .gas import Gas + + +class SystemGases(object): + """The collection of gases that are available within the system. + """ + + def __init__(self, gases=list()): + self._gases = set() + self._add_gases(gases) + + def gas_by_index(self, index): + return self._get_by_method(index, "index") + + def gas_by_name(self, name): + return self._get_by_method(name, "name") + + def _get_by_method(self, value, method): + try: + return next(g for g in self._gases if getattr(g, method)() == value) + except StopIteration: + return None + + def _add_gases(self, iterable): + self._gases = set.union(self._gases, {g for g in iterable if isinstance(g, Gas)}) + + def gases(self): + return sorted(list(self._gases), key=lambda g: g.index()) + + def gas_count(self): + return len(self._gases) diff --git a/lewis/devices/volumetric_rig/two_gas_mixer.py b/lewis/devices/volumetric_rig/two_gas_mixer.py new file mode 100644 index 00000000..06551b13 --- /dev/null +++ b/lewis/devices/volumetric_rig/two_gas_mixer.py @@ -0,0 +1,12 @@ +class TwoGasMixer(object): + """Keeps a record of whether pairs of gases can be mixed. + """ + + def __init__(self): + self.mixable = set() + + def add_mixable(self, gas1, gas2): + self.mixable.add(frozenset([gas1, gas2])) + + def can_mix(self, gas1, gas2): + return frozenset([gas1, gas2]) in self.mixable diff --git a/lewis/devices/volumetric_rig/utilities.py b/lewis/devices/volumetric_rig/utilities.py new file mode 100644 index 00000000..d5488d07 --- /dev/null +++ b/lewis/devices/volumetric_rig/utilities.py @@ -0,0 +1,33 @@ +def format_int(i, as_string, length): + if as_string: + return str(i) if length is None else str(i)[:length].zfill(length) + else: + return i + + +def format_float(f, as_string): + return "{0:.2f}".format(f).zfill(5) if as_string else f + + +def pad_string(s, length, padding_character): + return s if length is None else s[:length] + (length - len(s)) * padding_character + + +def convert_raw_to_int(raw): + if type(raw) == int: + return raw + elif type(raw) == str: + return int(raw.zfill(1)) + else: + return 0 + + +def convert_raw_to_float(raw): + try: + return float(raw) + except: + return 0.0 + + +def convert_raw_to_bool(raw): + return bool(convert_raw_to_int(raw)) diff --git a/lewis/devices/volumetric_rig/valve.py b/lewis/devices/volumetric_rig/valve.py new file mode 100644 index 00000000..7a33f624 --- /dev/null +++ b/lewis/devices/volumetric_rig/valve.py @@ -0,0 +1,44 @@ +from .valve_status import ValveStatus + + +class Valve(object): + """Valves can either be enabled/disabled and open/closed. A valve should never be open and disabled. + """ + + def __init__(self): + self._is_enabled = True + self._is_open = False + + def open(self): + if self._is_enabled: + self._is_open = True + + def close(self): + if self._is_enabled: + self._is_open = False + + def is_open(self): + return self._is_open + + def is_enabled(self): + return self._is_enabled + + def status(self): + if self._is_open: + return ( + ValveStatus.OPEN_AND_ENABLED if self._is_enabled else ValveStatus.OPEN_AND_DISABLED + ) + else: + return ( + ValveStatus.CLOSED_AND_ENABLED + if self._is_enabled + else ValveStatus.CLOSED_AND_DISABLED + ) + + def disable(self): + # Can't disable an open valve + if not self._is_open: + self._is_enabled = False + + def enable(self): + self._is_enabled = True diff --git a/lewis/devices/volumetric_rig/valve_status.py b/lewis/devices/volumetric_rig/valve_status.py new file mode 100644 index 00000000..71dcf810 --- /dev/null +++ b/lewis/devices/volumetric_rig/valve_status.py @@ -0,0 +1,7 @@ +class ValveStatus(object): + """An enumeration of possible valve states. OPEN_AND_DISABLED should never happen. + """ + + OPEN_AND_ENABLED, CLOSED_AND_ENABLED, OPEN_AND_DISABLED, CLOSED_AND_DISABLED = ( + i for i in range(4) + ) diff --git a/lewis/devices/wm323/__init__.py b/lewis/devices/wm323/__init__.py new file mode 100644 index 00000000..7cf64788 --- /dev/null +++ b/lewis/devices/wm323/__init__.py @@ -0,0 +1,3 @@ +from .device import SimulatedWm323 + +__all__ = ["SimulatedWm323"] diff --git a/lewis/devices/wm323/device.py b/lewis/devices/wm323/device.py new file mode 100644 index 00000000..377588da --- /dev/null +++ b/lewis/devices/wm323/device.py @@ -0,0 +1,32 @@ +from collections import OrderedDict +from enum import Enum + +from lewis.core.logging import has_log +from lewis.devices import StateMachineDevice + +from .states import DefaultState + + +class Direction(Enum): + CCW = 0 + CW = 1 + + +@has_log +class SimulatedWm323(StateMachineDevice): + def _initialize_data(self): + """Initialize all of the device's attributes. + """ + self.speed = 0 + self.direction = Direction.CCW + self.running = False + self.type = "323Du" + + def _get_state_handlers(self): + return {"default": DefaultState()} + + def _get_initial_state(self): + return "default" + + def _get_transition_handlers(self): + return OrderedDict([]) diff --git a/lewis/devices/wm323/interfaces/__init__.py b/lewis/devices/wm323/interfaces/__init__.py new file mode 100644 index 00000000..e22601a9 --- /dev/null +++ b/lewis/devices/wm323/interfaces/__init__.py @@ -0,0 +1,3 @@ +from .stream_interface import Wm323StreamInterface + +__all__ = ["Wm323StreamInterface"] diff --git a/lewis/devices/wm323/interfaces/stream_interface.py b/lewis/devices/wm323/interfaces/stream_interface.py new file mode 100644 index 00000000..4897c894 --- /dev/null +++ b/lewis/devices/wm323/interfaces/stream_interface.py @@ -0,0 +1,50 @@ +from lewis.adapters.stream import StreamInterface +from lewis.utils.command_builder import CmdBuilder + +from ..device import Direction + + +class Wm323StreamInterface(StreamInterface): + # Commands that we expect via serial during normal operation + commands = { + CmdBuilder("get_status").escape("1RS").eos().build(), + CmdBuilder("set_speed").escape("1SP ").float().eos().build(), + CmdBuilder("set_rotation_cw").escape("1RR").eos().build(), + CmdBuilder("set_rotation_ccw").escape("1RL").eos().build(), + CmdBuilder("set_running_start").escape("1GO").eos().build(), + CmdBuilder("set_running_stop").escape("1ST").eos().build(), + } + + in_terminator = "\r" + out_terminator = "\r" + + def handle_error(self, request, error): + err_string = "command was: {}, error was: {}: {}\n".format( + request, error.__class__.__name__, error + ) + print(err_string) + self.log.error(err_string) + return err_string + + def get_status(self): + running_int = 0 + if self.device.running: + running_int = 1 + return "{} {} {} {} !".format( + self.device.type, self.device.speed, self.device.direction.name, running_int + ) + + def set_speed(self, speed): + self.device.speed = speed + + def set_rotation_cw(self): + self.device.direction = Direction.CW + + def set_rotation_ccw(self): + self.device.direction = Direction.CCW + + def set_running_start(self): + self.device.running = True + + def set_running_stop(self): + self.device.running = False diff --git a/lewis/devices/wm323/states.py b/lewis/devices/wm323/states.py new file mode 100644 index 00000000..e4ca48e8 --- /dev/null +++ b/lewis/devices/wm323/states.py @@ -0,0 +1,5 @@ +from lewis.core.statemachine import State + + +class DefaultState(State): + pass