From 2a2750ad718055c44975b78762bd9bd441c8dba5 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Mon, 21 Oct 2019 13:04:30 +0200 Subject: [PATCH] added PPMS driver this is driver for the PPMS of LIN at PSI. This includes the SCoP driver and a simulation, which mimics a PPMS device including the command interface. The (small) interface to the windows system of PPMS follows in a separate patch. Change-Id: I92173b6dd83016fd1db446c710af101d436fc57b Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/21445 Tested-by: JenkinsCodeReview Reviewed-by: Markus Zolliker --- etc/ppms.cfg | 123 ++++++ secop_psi/ppms.py | 978 +++++++++++++++++++++++++++++++++++++++++++ secop_psi/ppmssim.py | 195 +++++++++ 3 files changed, 1296 insertions(+) create mode 100755 etc/ppms.cfg create mode 100755 secop_psi/ppms.py create mode 100644 secop_psi/ppmssim.py diff --git a/etc/ppms.cfg b/etc/ppms.cfg new file mode 100755 index 0000000..8a5bb85 --- /dev/null +++ b/etc/ppms.cfg @@ -0,0 +1,123 @@ +[node PPMS.psi.ch] +description = PPMS at PSI + +[interface tcp] +type = tcp +bindto = 0.0.0.0 +bindport = 5000 + +[module tt] +class = secop_psi.ppms.Temp +.description = main temperature +.iodev = ppms + +[module mf] +class = secop_psi.ppms.Field +.description = magnetic field +.iodev = ppms + +[module pos] +class = secop_psi.ppms.Position +.description = sample rotator +.iodev = ppms + +[module lev] +class = secop_psi.ppms.Level +.description = helium level +.iodev = ppms + +[module chamber] +class = secop_psi.ppms.Chamber +.description = chamber state +.iodev = ppms + +[module r1] +class = secop_psi.ppms.BridgeChannel +.description = resistivity channel 1 +.no = 1 +value.unit = Ohm +.iodev = ppms + +[module r2] +class = secop_psi.ppms.BridgeChannel +.description = resistivity channel 2 +.no = 2 +value.unit = Ohm +.iodev = ppms + +[module r3] +class = secop_psi.ppms.BridgeChannel +.description = resistivity channel 3 +.no = 3 +value.unit = Ohm +.iodev = ppms + +[module r4] +class = secop_psi.ppms.BridgeChannel +.description = resistivity channel 4 +.no = 4 +value.unit = Ohm +.iodev = ppms + +[module i1] +class = secop_psi.ppms.Channel +.description = current channel 1 +.no = 1 +value.unit = uA +.iodev = ppms + +[module i2] +class = secop_psi.ppms.Channel +.description = current channel 2 +.no = 2 +value.unit = uA +.iodev = ppms + +[module i3] +class = secop_psi.ppms.Channel +.description = current channel 3 +.no = 3 +value.unit = uA +.iodev = ppms + +[module i4] +class = secop_psi.ppms.Channel +.description = current channel 4 +.no = 4 +value.unit = uA +.iodev = ppms + +[module v1] +class = secop_psi.ppms.DriverChannel +.description = voltage channel 1 +.no = 1 +value.unit = V +.iodev = ppms + +[module v2] +class = secop_psi.ppms.DriverChannel +.description = voltage channel 2 +.no = 2 +value.unit = V +.iodev = ppms + +[module tv] +class = secop_psi.ppms.UserChannel +.description = VTI temperature +enabled = 1 +value.unit = K +.iodev = ppms + +[module ts] +class = secop_psi.ppms.UserChannel +.description = sample temperature +enabled = 1 +value.unit = K +.iodev = ppms + +[module ppms] +class = secop_psi.ppms.Main +.description = the main and poller module +.class_id = QD.MULTIVU.PPMS.1 +.visibility = 3 +pollinterval = 2 diff --git a/secop_psi/ppms.py b/secop_psi/ppms.py new file mode 100755 index 0000000..f466cc5 --- /dev/null +++ b/secop_psi/ppms.py @@ -0,0 +1,978 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# 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 2 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, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# ***************************************************************************** +"""PPMS driver + +The PPMS hardware has some special requirements: + +- the communication to the hardware happens through windows COM +- all measured data including state are handled by one request/reply pair GETDAT? +- for each channel, the settings are handled through a single request/reply pair, + needing a mechanism to treat a single parameter change correctly. + +Polling of value and status is done commonly for all modules. For each registered module +.update_value_status() is called in order to update their value and status. +Polling of module settings is using the same poller (secop.Poller is checking iodev). +Only the hidden (not exported) parameter 'settings' is polled, all the others are updated +by read_settings. The modules parameters related to the settings are updated only on change. +This allows for example for the field module to buffer ramp and approachmode until the +next target or persistent_mode change happens, because sending the common command for +settings and target would do a useless cycle of ramping up leads, heating switch etc. +""" + +import time +import threading +import json + +from secop.modules import Module, Readable, Drivable, Parameter, Override,\ + Communicator, Property +from secop.datatypes import EnumType, FloatRange, IntRange, StringType,\ + BoolType, StatusType +from secop.lib.enum import Enum +from secop.errors import HardwareError +from secop.poller import Poller + +try: + import secop_psi.ppmswindows as ppmshw +except ImportError: + import secop_psi.ppmssim as ppmshw + + +def isDriving(status): + """moving towards target""" + return 300 <= status[0] < 390 + +class Main(Communicator): + """general ppms dummy module""" + + parameters = { + 'pollinterval': Parameter('poll interval', readonly=False, + datatype=FloatRange(), default=2), + 'communicate': Override('GBIP command'), + 'data': Parameter('internal', poll=True, export=True, # export for test only + default="", readonly=True, datatype=StringType()), + } + properties = { + 'class_id': Property('Quantum Design class id', export=False, + datatype=StringType()), + } + + _channel_names = ['packed_status', 'temp', 'field', 'position', 'r1', 'i1', 'r2', 'i2', + 'r3', 'i3', 'r4', 'i4', 'v1', 'v2', 'digital', 'cur1', 'pow1', 'cur2', 'pow2', + 'p', 'u20', 'u21', 'u22', 'ts', 'u24', 'u25', 'u26', 'u27', 'u28', 'u29'] + assert len(_channel_names) == 30 + _channel_to_index = dict(((channel, i) for i, channel in enumerate(_channel_names))) + _status_bitpos = {'temp': 0, 'field': 4, 'chamber': 8, 'position': 12} + + pollerClass = Poller + + def earlyInit(self): + self.modules = {} + self._ppms_device = ppmshw.QDevice(self.class_id) + self.lock = threading.Lock() + + def register(self, other): + self.modules[other.channel] = other + + def do_communicate(self, command): + with self.lock: + reply = self._ppms_device.send(command) + self.log.debug("%s|%s", command, reply) + return reply + + def read_data(self): + mask = 1 # always get packed_status + for channelname, channel in self.modules.items(): + if channel.enabled: + mask |= 1 << self._channel_to_index.get(channelname, 0) + # send, read and convert to floats and ints + data = self.do_communicate('GETDAT? %d' % mask) + reply = data.split(',') + mask = int(reply.pop(0)) + reply.pop(0) # pop timestamp + result = {} + for bitpos, channelname in enumerate(self._channel_names): + if mask & (1 << bitpos): + result[channelname] = reply.pop(0) + if 'temp' in result: + result['tv'] = result['temp'] + if 'ts' in result: + result['temp'] = result['ts'] + packed_status = int(result['packed_status']) + for channelname, channel in self.modules.items(): + if channelname in result and channel.enabled: + channel.update_value_status(float(result.get(channelname, None)), packed_status) + return data # return data as string + + +class PpmsMixin(Module): + properties = { + 'iodev': + Property('attached communicator module', + datatype=StringType(), export=False, default=''), + } + parameters = { + 'settings': + Parameter('internal', export=False, poll=True, readonly=False, + default="", datatype=StringType()), + } + + pollerClass = Poller + enabled = True # default, if no parameter enable is defined + STATUS_MAP = {} # a mapping converting ppms status codes into SECoP status values + _settingnames = [] # names of the parameters in the settings command + _last_target_change = 0 + slow_pollfactor = 1 + + def initModule(self): + self._main = self.DISPATCHER.get_module(self.iodev) + self._main.register(self) + + def startModule(self, started_callback): + # no polls except on main module + started_callback() + + def send_cmd(self, writecmd, argdict): + self._main.do_communicate(writecmd + ' ' + + ','.join('%.7g' % argdict[key] for key in self._settingnames)) + + def get_reply(self, settings, query): + """return a dict with the values get from the reply + + if the reply has not changed, an empty dict is returned + """ + reply = self._main.do_communicate(query) + if getattr(self, settings) == reply: + return {} + setattr(self, settings, reply) + return dict(zip(self._settingnames, json.loads('[%s]' % reply))) + + def apply_reply(self, reply, pname): + """apply reply dict to the parameters + + except for reply[pname], which is returned + """ + returnvalue = getattr(self, pname) + for key, value in reply.items(): + if key == pname: + returnvalue = value + else: + setattr(self, key, value) + return returnvalue + + def make_argdict(self, pname, value): + """make a dict from the parameters self._settingnames + + but result[pname] replaced by value + """ + return {key: value if key == pname else getattr(self, key) for key in self._settingnames} + + def read_settings(self): + return self.get_settings('settings') + + def read_value(self): + """not very useful, as values are updated fast enough + + note: this will update all values, and the value of this module twice + """ + self._main.read_data() + return self.value + + def read_status(self): + """not very useful, as status is updated fast enough + + note: this will update status of all modules, and this module twice + """ + self._main.read_data() + return self.status + + def update_value_status(self, value, packed_status): + """update value and status""" + if not self.enabled: + self.status = [self.Status.DISABLED, 'disabled'] + return + if value is None: + self.status = [self.Status.ERROR, 'invalid value'] + else: + self.value = value + self.status = [self.Status.IDLE, ''] + +class Channel(PpmsMixin, Readable): + parameters = { + 'value': + Override('main value of channels', poll=False, default=0), + 'status': + Override(poll=False), + 'enabled': + Parameter('is this channel used?', readonly=False, poll=False, + datatype=BoolType(), default=False), + 'pollinterval': + Override(visibility=3), + } + properties = { + 'channel': + Property('channel name', + datatype=StringType(), export=False, default=''), + 'no': + Property('channel number', + datatype=IntRange(1, 4), export=False), + } + + def earlyInit(self): + Readable.earlyInit(self) + if not self.channel: + self.properties['channel'] = self.name + + def get_settings(self, pname): + return '' + +class UserChannel(Channel): + parameters = { + 'pollinterval': + Override(visibility=3), + } + properties = { + 'no': + Property('channel number', + datatype=IntRange(0, 0), export=False, default=0), + } + + +class DriverChannel(Channel): + parameters = { + 'current': + Parameter('driver current', readonly=False, poll=False, + datatype=FloatRange(0., 5000., unit='uA'), default=0), + 'powerlimit': + Parameter('power limit', readonly=False, poll=False, + datatype=FloatRange(0., 1000., unit='uW'), default=0), + 'pollinterval': + Override(visibility=3), + } + + _settingnames = ['no', 'current', 'powerlimit'] + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + reply = self.get_reply('settings', 'DRVOUT? %d' % self.no) + if reply: + if self.no != reply.pop('no'): + raise HardwareError('DRVOUT command: channel number in reply does not match') + return self.apply_reply(reply, pname) + + def put_settings(self, value, pname): + """write settings, combining = and current attributes + + and request updated settings + """ + self.send_cmd('DRVOUT', self.make_argdict(pname, value)) + return self.get_settings(pname) + + def read_current(self): + return self.get_settings('current') + + def read_powerlimit(self): + return self.get_settings('powerlimit') + + def write_settings(self): + return self.get_settings('settings') + + def write_current(self, value): + return self.put_settings(value, 'current') + + def write_powerlimit(self, value): + return self.put_settings(value, 'powerlimit') + + +class BridgeChannel(Channel): + # pylint: disable=invalid-name + ReadingMode = Enum('ReadingMode', standard=0, fast=1, highres=2) + parameters = { + 'excitation': + Parameter('excitation current', readonly=False, poll=False, + datatype=FloatRange(0.01, 5000., unit='uA'), default=0.01), + 'powerlimit': + Parameter('power limit', readonly=False, poll=False, + datatype=FloatRange(0.001, 1000., unit='uW'), default=0.001), + 'dcflag': + Parameter('True when excitation is DC (else AC)', readonly=False, poll=False, + datatype=BoolType(), default=False), + 'readingmode': + Parameter('reading mode', readonly=False, poll=False, + datatype=EnumType(ReadingMode), default=ReadingMode.standard), + 'voltagelimit': + Parameter('voltage limit', readonly=False, poll=False, + datatype=FloatRange(0.0001, 100., unit='mV'), default=0.0001), + 'pollinterval': + Override(visibility=3), + } + + _settingnames = ['no', 'excitation', 'powerlimit', 'dcflag', 'readingmode', 'voltagelimit'] + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + reply = self.get_reply('settings', 'BRIDGE? %d' % self.no) + if reply: + if self.no != reply['no']: + raise HardwareError('BRIDGE command: channel number in reply does not match') + reply['enabled'] = 1 + if reply['excitation'] == 0: + reply['excitation'] = self.excitation + reply['enabled'] = 0 + if reply['powerlimit'] == 0: + reply['powerlimit'] = self.powerlimit + reply['enabled'] = 0 + if reply['voltagelimit'] == 0: + reply['voltagelimit'] = self.voltagelimit + reply['enabled'] = 0 + del reply['no'] + returnvalue = self.apply_reply(reply, pname) + return returnvalue + + def put_settings(self, value, pname): + """write settings, combining = and current attributes + + and request updated settings + """ + argdict = self.make_argdict(pname, value) + enabled = value if pname == 'enabled' else self.enabled + if not enabled: + argdict['excitation'] = 0 + argdict['powerlimit'] = 0 + argdict['voltagelimit'] = 0 + self.send_cmd('BRIDGE', argdict) + returnvalue = self.get_settings(pname) + return returnvalue + + def read_enabled(self): + return self.get_settings('enabled') + + def read_excitation(self): + return self.get_settings('excitation') + + def read_powerlimit(self): + return self.get_settings('powerlimit') + + def read_dcflag(self): + return self.get_settings('dcflag') + + def read_readingmode(self): + return self.get_settings('readingmode') + + def read_voltagelimit(self): + return self.get_settings('voltagelimit') + + def write_settings(self): + return self.get_settings('settings') + + def write_enabled(self, value): + return self.put_settings(value, 'enabled') + + def write_excitation(self, value): + return self.put_settings(value, 'excitation') + + def write_powerlimit(self, value): + return self.put_settings(value, 'powerlimit') + + def write_dcflag(self, value): + return self.put_settings(value, 'dcflag') + + def write_readingmode(self, value): + return self.put_settings(value, 'readingmode') + + def write_voltagelimit(self, value): + return self.put_settings(value, 'voltagelimit') + + +class Level(PpmsMixin, Readable): + """helium level""" + + parameters = { + 'value': Override(datatype=FloatRange(unit='%'), poll=False, default=0), + 'status': Override(poll=False), + 'pollinterval': + Override(visibility=3), + } + + channel = 'level' + _settingnames = ['value', 'status'] + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + reply = self.get_reply('settings', 'LEVEL?') + if reply: + if reply['status']: + reply['status'] = [self.Status.IDLE, ''] + else: + reply['status'] = [self.Status.ERROR, 'old reading'] + return self.apply_reply(reply, pname) + + def read_value(self): + return self.get_settings('value') + + def read_status(self): + return self.get_settings('status') + + +class Chamber(PpmsMixin, Drivable): + """sample chamber handling""" + + Status = Drivable.Status + # pylint: disable=invalid-name + Operation = Enum( + 'Operation', + seal_immediately=0, + purge_and_seal=1, + vent_and_seal=2, + pump_continuously=3, + vent_continuously=4, + hi_vacuum=5, + noop=10, + ) + parameters = { + 'value': + Override(description='chamber state', poll=False, + datatype=StringType(), default='unknown'), + 'status': + Override(poll=False), + 'target': + Override(description='chamber command', poll=True, + datatype=EnumType(Operation), default=Operation.noop), + 'pollinterval': + Override(visibility=3), + } + STATUS_MAP = { + 0: [Status.ERROR, 'unknown'], + 1: [Status.IDLE, 'purged and sealed'], + 2: [Status.IDLE, 'vented and sealed'], + 3: [Status.WARN, 'sealed unknown'], + 4: [Status.BUSY, 'purge and seal'], + 5: [Status.BUSY, 'vent and seal'], + 6: [Status.BUSY, 'pumping down'], + 7: [Status.IDLE, 'at hi vacuum'], + 8: [Status.IDLE, 'pumping continuously'], + 9: [Status.IDLE, 'venting continuously'], + 15: [Status.ERROR, 'general failure'], + } + + channel = 'chamber' + _settingnames = ['target'] + + def update_value_status(self, value, packed_status): + """update value and status""" + self.status = self.STATUS_MAP[(packed_status >> 8) & 0xf] + self.value = self.status[1] + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + reply = self.get_reply('settings', 'CHAMBER?') + return self.apply_reply(reply, pname) + + def put_settings(self, value, pname): + """write settings, combining = and current attributes + + and request updated settings + """ + self.send_cmd('CHAMBER', self.make_argdict(pname, value)) + return self.get_settings(pname) + + def read_target(self): + return self.get_settings('target') + + def write_target(self, value): + if value == self.Operation.noop: + return value + return self.put_settings(value, 'target') + + +class Temp(PpmsMixin, Drivable): + """temperature""" + + Status = Enum(Drivable.Status, + RAMPING = 370, + STABILIZING = 380, + ) + # pylint: disable=invalid-name + ApproachMode = Enum('ApproachMode', fast_settle=0, no_overshoot=1) + parameters = { + 'value': + Override(datatype=FloatRange(unit='K'), poll=False, default=0), + 'status': + Override(poll=False, datatype=StatusType(Status)), + 'target': + Override(datatype=FloatRange(1.7, 402.0, unit='K'), default=295, poll=False), + 'ramp': + Parameter('ramping speed', readonly=False, poll=False, + datatype=FloatRange(0, 20, unit='K/min'), default=0.1), + 'approachmode': + Parameter('how to approach target!', readonly=False, poll=False, + datatype=EnumType(ApproachMode), default=0), + 'pollinterval': + Override(visibility=3), + 'timeout': + Parameter('drive timeout, in addition to ramp time', readonly=False, + datatype=FloatRange(0, unit='sec'), default=3600), + } + properties = { + 'general_stop': Property('respect general stop', datatype=BoolType(), + export=True, default=True) + } + # pylint: disable=invalid-name + TempStatus = Enum( + 'TempStatus', + unknown=0, + stable_at_target=1, + changing=2, + within_tolerance=5, + outside_tolerance=6, + standby=10, + control_disabled=13, + can_not_complete=14, + general_failure=15, + ) + STATUS_MAP = { + 0: [Status.ERROR, 'unknown'], + 1: [Status.IDLE, 'stable at target'], + 2: [Status.RAMPING, 'changing'], + 5: [Status.STABILIZING, 'within tolerance'], + 6: [Status.STABILIZING, 'outside tolerance'], + 10: [Status.WARN, 'standby'], + 13: [Status.WARN, 'control disabled'], + 14: [Status.ERROR, 'can not complete'], + 15: [Status.ERROR, 'general failure'], + } + + channel = 'temp' + _settingnames = ['target', 'ramp', 'approachmode'] + _stopped = False + _expected_target = None + _last_change = 0 # 0 means no target change is pending + + def earlyInit(self): + self.setProperty('general_stop', False) + super().earlyInit() + + def update_value_status(self, value, packed_status): + """update value and status""" + if value is None: + self.status = [self.Status.ERROR, 'invalid value'] + return + self.value = value + status = self.STATUS_MAP[packed_status & 0xf] + now = time.time() + if self._stopped: + # combine 'stopped' with current status text + if status[0] == self.Status.IDLE: + self._stopped = False + else: + self.status = [self.Status.IDLE, 'stopped(%s)' % status[1]] + return + if self._last_change: # there was a change, which is not yet confirmed by hw + if isDriving(status): + if now > self._last_change + 15 or status != self._status_before_change: + self._last_change = 0 + self.log.debug('time needed to change to busy: %.3g', now - self._last_change) + else: + if now < self._last_change + 15: + status = [self.Status.BUSY, 'changed target while %s' % status[1]] + else: + status = [self.Status.WARN, 'temperature status (%r) does not change to BUSY' % status] + if self._expected_target: + # handle timeout + if isDriving(status): + if now > self._expected_target + self.timeout: + self.status = [self.Status.WARN, 'timeout while %s' % status[1]] + return + else: + self._expected_target = None + self.status = status + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + return self.apply_reply(self.get_reply('settings', 'TEMP?'), pname) + + def put_settings(self, value, pname): + """write settings, combining = and current attributes + + and request updated settings + """ + self.send_cmd('TEMP', self.make_argdict(pname, value)) + return self.get_settings(pname) + + def read_target(self): + return self.get_settings('target') + + def read_ramp(self): + return self.get_settings('ramp') + + def read_approachmode(self): + return self.get_settings('approachmode') + + def write_settings(self): + return self.get_settings('settings') + + def calc_expected(self, target, ramp): + self._expected_target = time.time() + abs(target - self.value) * 60.0 / max(0.1, ramp) + + def write_target(self, target): + self._stopped = False + if abs(self.value - target) < 2e-5 and target == self.target: + return target # no action needed + self._status_before_change = self.status + self.status = [self.Status.BUSY, 'changed_target'] + self._last_change = time.time() + newtarget = self.put_settings(target, 'target') + self.calc_expected(target, self.ramp) + return newtarget + + def write_ramp(self, value): + if not isDriving(self.status)(): + # do not yet write settings, as this may change the status to busy + return value + if time.time() < self._expected_target: # recalc expected target + self.calc_expected(self.target, value) + return self.put_settings(value, 'ramp') + + def write_approachmode(self, value): + if not isDriving(self.status): + # do not yet write settings, as this may change the status to busy + return value + return self.put_settings(value, 'approachmode') + + def do_stop(self): + if not isDriving(self.status): + return + if self.status[0] == self.Status.STABLIZING: + # we are already near target + newtarget = self.target + else: + newtarget = self.value + self.log.info('stop at %s K', newtarget) + self.write_target(newtarget) + self.status = [self.Status.IDLE, 'stopped'] + self._stopped = True + + +class Field(PpmsMixin, Drivable): + """magnetic field""" + + Status = Enum(Drivable.Status, + PREPARED = 150, + PREPARING = 340, + RAMPING = 370, + FINALIZING = 390, + ) + # pylint: disable=invalid-name + PersistentMode = Enum('PersistentMode', persistent = 0, driven = 1) + ApproachMode = Enum('ApproachMode', linear=0, no_overshoot=1, oscillate=2) + + parameters = { + 'value': + Override(datatype=FloatRange(unit='T'), poll=False, default=0), + 'status': + Override(poll=False, datatype=StatusType(Status)), + 'target': + Override(datatype=FloatRange(-15,15,unit='T'), poll=False), + 'ramp': + Parameter('ramping speed', readonly=False, poll=False, + datatype=FloatRange(0.064, 1.19, unit='T/min'), default=0.064), + 'approachmode': + Parameter('how to approach target', readonly=False, poll=False, + datatype=EnumType(ApproachMode), default=0), + 'persistentmode': + Parameter('what to do after changing field', readonly=False, poll=False, + datatype=EnumType(PersistentMode), default=0), + 'pollinterval': + Override(visibility=3), + } + + STATUS_MAP = { + 0: [Status.ERROR, 'unknown'], + 1: [Status.IDLE, 'persistent mode'], + 2: [Status.PREPARING, 'switch warming'], + 3: [Status.FINALIZING, 'switch cooling'], + 4: [Status.IDLE, 'driven stable'], + 5: [Status.FINALIZING, 'driven final'], + 6: [Status.RAMPING, 'charging'], + 7: [Status.RAMPING, 'discharging'], + 8: [Status.ERROR, 'current error'], + 15: [Status.ERROR, 'general failure'], + } + + channel = 'field' + _settingnames = ['target', 'ramp', 'approachmode', 'persistentmode'] + _stopped = False + _last_target = 0 + _last_change= 0 # means no target change is pending + + def update_value_status(self, value, packed_status): + """update value and status""" + if value is None: + self.status = [self.Status.ERROR, 'invalid value'] + return + self.value = round(value * 1e-4, 7) + status_code = (packed_status >> 4) & 0xf + status = self.STATUS_MAP[status_code] + now = time.time() + if self._stopped: + # combine 'stopped' with current status text + if status[0] == self.Status.IDLE: + self._stopped = False + else: + self.status = [status[0], 'stopped (%s)' % status[1]] + return + elif self._last_change: # there was a change, which is not yet confirmed by hw + if status_code == 1: # persistent mode + # leads are ramping (ppms has no extra status code for this!) + if now < self._last_change + 30: + status = [self.Status.PREPARING, 'ramping leads'] + else: + status = [self.Status.WARN, 'timeout when ramping leads'] + elif isDriving(status): + if now > self._last_change + 5 or status != self._status_before_change: + self._last_change = 0 + self.log.debug('time needed to change to busy: %.3g', now - self._last_change) + else: + if now < self._last_change + 5: + status = [self.Status.BUSY, 'changed target while %s' % status[1]] + else: + status = [self.Status.WARN, 'field status (%r) does not change to BUSY' % status] + + + self.status = status + + def _start(self): + """common code for change target and change persistentmode""" + self._last_change = time.time() + self._status_before_change = list(self.status) + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + reply = self.get_reply('settings', 'FIELD?') + if reply: + reply['target'] *= 1e-4 + reply['ramp'] *= 6e-3 + return self.apply_reply(reply, pname) + + def put_settings(self, value, pname): + """write settings, combining = and current attributes + + and request updated settings + """ + argdict = self.make_argdict(pname, value) + argdict['target'] *= 1e+4 + argdict['ramp'] /= 6e-3 + self.send_cmd('FIELD', argdict) + return self.get_settings(pname) + + def read_target(self): + return self.get_settings('target') + + def read_ramp(self): + return self.get_settings('ramp') + + def read_approachmode(self): + return self.get_settings('approachmode') + + def read_persistentmode(self): + return self.get_settings('persistentmode') + + def write_settings(self): + return self.get_settings('settings') + + def write_target(self, target): + self._last_target = self.target # save for stop command + self._stopped = False + if abs(self.value - target) < 2e-5 and target == self.target: + return target # no action needed + self._start() + result = self.put_settings(target, 'target') + self._main.read_data() # update status + return result + + def write_ramp(self, value): + if not isDriving(self.status): + # do not yet write settings, as this will trigger a ramp up of leads current + return value + return self.put_settings(value, 'ramp') + + def write_approachmode(self, value): + if not isDriving(self.status): + # do not yet write settings, as this will trigger a ramp up of leads current + return value + return self.put_settings(value, 'approachmode') + + def write_persistentmode(self, value): + if self.persistentmode == value: + return value # no action needed + self._start() + return self.put_settings(value, 'persistentmode') + + def do_stop(self): + if not isDriving(self.status): + return + self.status = [self.Status.IDLE, '_stopped'] + self._stopped = True + if abs(self.value - self.target) > 1e-4: + # ramping is not yet at end + if abs(self.value - self._last_target) < 1e-4: + # ramping has not started yet, use more precise last target instead of current value + self.target = self.put_settings(self._last_target, 'target') + else: + self.target = self.put_settings(self.value, 'target') + + +class Position(PpmsMixin, Drivable): + """rotator position""" + + Status = Drivable.Status + parameters = { + 'value': + Override(datatype=FloatRange(unit='deg'), poll=False, default=0), + 'status': + Override(poll=False), + 'target': + Override(datatype=FloatRange(-720., 720., unit='deg'), default=0., poll=False), + 'enabled': + Parameter('is this channel used?', readonly=False, poll=False, + datatype=BoolType(), default=True), + 'speed': + Parameter('motor speed', readonly=False, poll=False, + datatype=FloatRange(0.8, 12, unit='deg/sec'), default=12.0), + 'pollinterval': + Override(visibility=3), + } + STATUS_MAP = { + 0: [Status.ERROR, 'unknown'], + 1: [Status.IDLE, 'at target'], + 5: [Status.BUSY, 'moving'], + 8: [Status.IDLE, 'at limit'], + 9: [Status.IDLE, 'at index'], + 15: [Status.ERROR, 'general failure'], + } + + channel = 'position' + _settingnames = ['target', 'mode', 'speed'] + _stopped = False + _last_target = 0 + _last_change = 0 # means no target change is pending + mode = 0 # always use normal mode + + def update_value_status(self, value, packed_status): + """update value and status""" + if not self.enabled: + self.status = [self.Status.DISABLED, 'disabled'] + return + if value is None: + self.status = [self.Status.ERROR, 'invalid value'] + return + self.value = value + status = self.STATUS_MAP[(packed_status >> 12) & 0xf] + if self._stopped: + # combine 'stopped' with current status text + if status[0] == self.Status.IDLE: + self._stopped = False + else: + status = [self.Status.IDLE, 'stopped(%s)' % status[1]] + if self._last_change: # there was a change, which is not yet confirmed by hw + now = time.time() + if isDriving(status): + if now > self._last_change + 15 or status != self._status_before_change: + self._last_change = 0 + self.log.debug('time needed to change to busy: %.3g', now - self._last_change) + else: + if now < self._last_change + 15: + status = [self.Status.BUSY, 'changed target while %s' % status[1]] + else: + status = [self.Status.WARN, 'temperature status (%r) does not change to BUSY' % status] + self.status = status + + def get_settings(self, pname): + """read settings + + return the value for and update all other parameters + """ + reply = self.get_reply('settings', 'MOVE?') + if reply: + reply['speed'] = (15 - reply['speed']) * 0.8 + reply.pop('mode', None) + return self.apply_reply(reply, pname) + + def put_settings(self, value, pname): + """write settings, combining = and current attributes + + and request updated settings + """ + argdict = self.make_argdict(pname, value) + argdict['speed'] = int(round(min(14, max(0, 15 - argdict['speed'] / 0.8)), 0)) + self.send_cmd('MOVE', argdict) + return self.get_settings(pname) + + def read_target(self): + return self.get_settings('target') + + def read_speed(self): + return self.get_settings('speed') + + def write_settings(self): + return self.get_settings('settings') + + def write_target(self, value): + self._last_target = self.target # save for stop command + self._stopped = False + self._last_change = 0 + self._status_before_change = self.status + return self.put_settings(value, 'target') + + def write_speed(self, value): + if not isDriving(self.status): + return value + return self.put_settings(value, 'speed') + + def do_stop(self): + if not isDriving(self.status): + return + self.status = [self.Status.BUSY, '_stopped'] + self._stopped = True + if abs(self.value - self.target) > 1e-2: + # moving is not yet at end + if abs(self.value - self._last_target) < 1e-2: + # moving has not started yet, use more precise last target instead of current value + self.target = self.write_target(self._last_target) + else: + self.target = self.write_target(self.value) diff --git a/secop_psi/ppmssim.py b/secop_psi/ppmssim.py new file mode 100644 index 0000000..b60c72d --- /dev/null +++ b/secop_psi/ppmssim.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# 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 2 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, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# ***************************************************************************** +import time +import json +import math + +def num(string): + return json.loads(string) + +class NamedList: + def __init__(self, keys, *args, **kwargs): + self.__keys__ = keys.split() + self.setvalues(args) + for key, val in kwargs.items(): + setattr(self, key, val) + + def setvalues(self, values): + for key, arg in zip(self.__keys__, values): + setattr(self, key, arg) + + def aslist(self): + return [getattr(self, key) for key in self.__keys__] + + def __getitem__(self, index): + return getattr(self, self.__keys__[index]) + + def __setitem__(self, index, value): + return setattr(self, self.__keys__[index], value) + + def __repr__(self): + return ",".join("%.7g" % val for val in self.aslist()) + +class PpmsSim: + CHANNELS = 'st t mf pos r1 i1 r2 i2'.split() + def __init__(self): + self.status = NamedList('t mf ch pos', 1, 1, 1, 1) + self.st = 0x1111 + self.t = 200 + self.temp = NamedList('target ramp amode', 295., 1, 0, fast=self.t, delay=10) + self.mf = 100 + self.field = NamedList('target ramp amode pmode', 0, 50, 0, 0) + self.pos = 0 + self.move = NamedList('target mode code', 0, 0, 0) + self.chamber = NamedList('target', 0) + self.level = NamedList('value code', 100.0, 1) + self.bridge1 = NamedList('no exc pow dc mode vol', 1, 333, 1000, 0, 2, 1) + self.bridge2 = NamedList('no exc pow dc mode vol', 2, 333, 1000, 0, 2, 1) + self.bridge3 = NamedList('no exc pow dc mode vol', 3, 333, 1000, 0, 2, 1) + self.bridge4 = NamedList('no exc pow dc mode vol', 4, 333, 1000, 0, 2, 1) + self.drvout1 = NamedList('no cur pow', 1, 333, 1000) + self.drvout2 = NamedList('no cur pow', 2, 333, 1000) + self.r1 = 0 + self.i1 = 0 + self.r2 = 0 + self.i2 = 0 + self.time = int(time.time()) + self.start = self.time + self.mf_start = 0 + self.changed = set() + + def progress(self): + now = time.time() + if self.time >= now: + return + while self.time < now: + self.time += 1 + if self.temp.amode: # no overshoot + dif = self.temp.target - self.temp.fast + else: + dif = self.temp.target - self.t + self.temp.fast += math.copysign(min(self.temp.ramp / 60.0, abs(dif)), dif) + self.t += (self.temp.fast - self.t) / self.temp.delay + + # handle magnetic field + if 'FIELD' in self.changed: + self.changed.remove('FIELD') + if self.field.target < 0: + self.status.mf = 15 # general error + elif self.status.mf == 1: # persistent + self.mf_start = now # indicates leads are ramping + elif self.status.mf == 3: # switch_cooling + self.mf_start = now + self.status.mf = 2 # switch_warming + else: + self.status.mf = 6 + int(self.field.target < self.mf) # charging or discharging + if self.status.mf == 1 and self.mf_start: # leads ramping + if now > self.mf_start + abs(self.field.target) / 10000 + 5: + self.mf_start = now + self.status.mf = 2 # switch_warming + elif self.status.mf == 2: # switch_warming + if now > self.mf_start + 15: + self.status.mf = 6 + int(self.field.target < self.mf) # charging or discharging + elif self.status.mf == 5: # driven_final + if now > self.mf_start + 5: + self.mf_start = now + self.status.mf = 3 # switch cooling + elif self.status.mf == 3: # switch_cooling + if now > self.mf_start + 15: + self.status.mf = 1 # persistent_mode + self.mf_start = 0 # == no leads ramping happens + elif self.status.mf in (6, 7): # charging, discharging + dif = self.field.target - self.mf + if abs(dif) < 0.01: + if self.field.pmode: + self.status.mf = 4 # driven_stable + else: + self.status.mf = 5 # driven_final + self.mf_last = now + else: + self.mf += math.copysign(min(self.field.ramp, abs(dif)), dif) + # print(self.mf, self.status.mf, self.field) + dif = self.move.target - self.pos + speed = (15 - self.move.code) * 0.8 + self.pos += math.copysign(min(speed, abs(dif)), dif) + + # omit chamber for now + + if abs(self.t - self.temp.target) < 0.01: + self.status.t = 1 + elif abs(self.t - self.temp.target) < 0.1: + self.status.t = 5 + elif abs(self.t - self.temp.target) < 1: + self.status.t = 6 + else: + self.status.t = 2 + + + if abs(self.pos - self.move.target) < 0.01: + self.status.pos = 1 + else: + self.status.pos = 5 + + self.st = sum([self.status[i] << (i * 4) for i in range(4)]) + self.r1 = self.t * 0.1 + self.i1 = self.t % 10.0 + self.r2 = 1000 / self.t + self.i2 = math.log(self.t) + self.level.value = 100 - (self.time - self.start) * 0.01 % 100 + # print('PROGRESS T=%.7g B=%.7g x=%.7g' % (self.t, self.mf, self.pos)) + + def getdat(self, mask): + mask = int(mask) & 0xff # all channels up to i2 + output = ['%d' % mask, '%.2f' % (time.time() - self.start)] + for i, chan in enumerate(self.CHANNELS): + if (1 << i) & mask: + output.append("%.7g" % getattr(self, chan)) + return ",".join(output) + +class QDevice: + def __init__(self, classid): + self.sim = PpmsSim() + + def send(self, command): + self.sim.progress() + if '?' in command: + if command.startswith('GETDAT?'): + mask = int(command[7:]) + result = self.sim.getdat(mask) + else: + name, args = command.split('?') + name += args.strip() + result = getattr(self.sim, name.lower()).aslist() + result = ",".join("%.7g" % arg for arg in result) + # print(command, '/', result) + else: + # print(command) + name, args = command.split() + args = json.loads("[%s]" % args) + if name.startswith('BRIDGE') or name.startswith('DRVOUT'): + name = name + str(int(args[0])) + getattr(self.sim, name.lower()).setvalues(args) + self.sim.changed.add(name) + result = "OK" + return result + +def shutdown(): + pass