diff --git a/cfg/main/varioxb_cfg.py b/cfg/main/varioxb_cfg.py index 92767f3..fc413b6 100644 --- a/cfg/main/varioxb_cfg.py +++ b/cfg/main/varioxb_cfg.py @@ -1,17 +1,18 @@ Node('varioxb.psi.ch', - 'VarioxB - 100 mm cryostat (not tested!)', + 'VarioxB - 100 mm cryostat', + interface='tcp://5000', ) Mod('itc1', 'frappy_psi.mercury.IO', 'ITC for heat exchanger and pressures', - uri='mb11-ts:3001', + uri='linvb-ts:3001', ) Mod('itc2', 'frappy_psi.mercury.IO', 'ITC for neck and nv heaters', - uri='mb11-ts:3002', + uri='linvb-ts:3002', ) Mod('T_stat', @@ -91,15 +92,15 @@ Mod('pos_dyn', Mod('lev', 'frappy_psi.mercury.HeLevel', 'LHe level', - slot='DB1.L1', - io='ips', + slot='DB4.L1', + io='itc2', ) Mod('n2lev', 'frappy_psi.mercury.N2Level', 'LN2 level', - slot='DB1.L1', - io='ips', + slot='DB4.L1', + io='itc2', ) Mod('T_neck1', @@ -166,18 +167,18 @@ Mod('htr_nvd', io='itc2', ) -Mod('om_io', - 'frappy_psi.phytron.PhytronIO', - 'dom motor IO', - uri='mb11-ts.psi.ch:3004', -) +#Mod('om_io', +# 'frappy_psi.phytron.PhytronIO', +# 'dom motor IO', +# uri='mb11-ts.psi.ch:3004', +#) -Mod('om', - 'frappy_psi.phytron.Motor', - 'stick rotation, typically used for omega', - io='om_io', - target_min=-180, - target_max=360, - encoder_mode='NO', - target=Param(min=-180, max=360) -) +#Mod('om', +# 'frappy_psi.phytron.Motor', +# 'stick rotation, typically used for omega', +# io='om_io', +# target_min=-180, +# target_max=360, +# encoder_mode='NO', +# target=Param(min=-180, max=360) +#) diff --git a/cfg/stick/gas10ka_cfg.py b/cfg/stick/gas10ka_cfg.py new file mode 100644 index 0000000..a105e4a --- /dev/null +++ b/cfg/stick/gas10ka_cfg.py @@ -0,0 +1,43 @@ +import os + +Node('gas10ka.psi.ch', + 'gas pressure stick 10 kbar', +) + +frappy_main_port = os.environ.get('FRAPPY_MAIN_PORT', 0) + +Mod('itc1_', + 'frappy.core.Proxy', + 'itc1 on main frappy server', + remote_class = 'frappy_psi.mercury.IO', + uri = f'tcp://localhost:{frappy_main_port}', + module='itc1', + # export = False, +) + + +Mod('res_sample', + 'frappy_psi.mercury.RawSensor', + 'raw sensor', + io='itc1_', + slot='MB1.T1', +) + +Mod('htr_sample', + 'frappy_psi.mercury.HeaterUpdate', + 'sample stick heater power', + slot='MB0.H1,MB1.T1', + io='itc1_', +) + +Mod('T_sample', + 'frappy_psi.softcal.SoftPiLoop', + 'sample T', + meaning=['temperature', 20], + rawsensor='res_sample', + output_module='htr_sample', + p=1, + i=0.01, + calib='X161269', + value=Param(unit='K'), +) diff --git a/frappy/client/__init__.py b/frappy/client/__init__.py index 8027a7c..2742d22 100644 --- a/frappy/client/__init__.py +++ b/frappy/client/__init__.py @@ -260,6 +260,7 @@ class ProxyClient: cblist = self.callbacks[cbname].get(key, []) for cbfunc in list(cblist): try: + # print("CALLBACK", cbname, repr(key), *args) cbfunc(*args) except UnregisterCallback: cblist.remove(cbfunc) @@ -370,9 +371,10 @@ class SecopClient(ProxyClient): # pylint: disable=unsubscriptable-object self._init_descriptive_data(self.request(DESCRIPTIONREQUEST)[2]) self.nodename = self.properties.get('equipment_id', self.uri) - self._set_state(True, 'connected') if self.activate: + self._set_state(True, 'activating') self.request(ENABLEEVENTSREQUEST) + self._set_state(True, 'connected') break except Exception: # print(formatExtendedTraceback()) @@ -493,6 +495,8 @@ class SecopClient(ProxyClient): return if self.activate: self.log.info('try to reconnect to %s', self.uri) + if self._connthread: + print('WARN connthread is still there') self._connthread = mkthread(self._reconnect) else: self.log.warning('%s disconnected', self.uri) @@ -509,11 +513,15 @@ class SecopClient(ProxyClient): def _reconnect(self, connected_callback=None): while not self._shutdown.is_set(): try: + # print('_reconnect_connect') self.connect() + # print('_reconnect_success') if connected_callback: connected_callback() + # print('_reconnect_break') break except Exception as e: + # print('_reconnect_exc', e) txt = str(e).split('\n', 1)[0] if txt != self._last_error: self._last_error = txt diff --git a/frappy/lib/asynconn.py b/frappy/lib/asynconn.py index cc448e8..5bfe0f6 100644 --- a/frappy/lib/asynconn.py +++ b/frappy/lib/asynconn.py @@ -218,6 +218,8 @@ class AsynTcp(AsynConn): except (socket.timeout, TimeoutError): # timeout while waiting return b'' + except ConnectionResetError: + pass # treat the same as gracefully disconnected peer # note that when no data is sent on a connection, an interruption might # not be detected within a reasonable time. sending a heartbeat should # help in this case. diff --git a/frappy/mixins.py b/frappy/mixins.py index 0641816..4fadd6a 100644 --- a/frappy/mixins.py +++ b/frappy/mixins.py @@ -56,6 +56,20 @@ class HasControlledBy: for deactivate_control in self.inputCallbacks.values(): deactivate_control(self.name) + def update_target(self, module, value): + """update internal target value + + as write_target would switch to manual mode, the controlling module + has to use this method to update the value + + override and super call, if other actions are needed + """ + if self.controlled_by != module: + deactivate_control = self.inputCallbacks.get(self.controlled_by) + if deactivate_control: + deactivate_control(module) + self.target = value + class HasOutputModule: """mixin for modules having an output module diff --git a/frappy/proxy.py b/frappy/proxy.py index 7440323..e8ccbfb 100644 --- a/frappy/proxy.py +++ b/frappy/proxy.py @@ -30,6 +30,12 @@ from frappy.properties import Property from frappy.io import HasIO +DISCONNECTED = Readable.Status.ERROR, 'disconnected' +ACTIVATING = Readable.Status.IDLE, 'activating' +CONNECTED = Readable.Status.IDLE, 'connected' +CONN_STATI = {DISCONNECTED, ACTIVATING, CONNECTED} + + class ProxyModule(HasIO, Module): module = Property('remote module name', datatype=StringType(), default='') status = Parameter('connection status', Readable.status.datatype) # add status even when not a Readable @@ -108,19 +114,22 @@ class ProxyModule(HasIO, Module): # for now, the error message must be enough def nodeStateChange(self, online, state): - disconnected = Readable.Status.ERROR, 'disconnected' if online: - if not self._consistency_check_done: - self._check_descriptive_data() - self._consistency_check_done = True - if self.status == disconnected: - self.status = Readable.Status.IDLE, 'connected' + if state == 'activating': + self.status = ACTIVATING + else: + if not self._consistency_check_done: + self._check_descriptive_data() + self._consistency_check_done = True + if self.status[0] in CONN_STATI: + # avoid overriding remote status + self.status = CONNECTED else: readerror = CommunicationFailedError('disconnected') - if self.status != disconnected: + if self.status != DISCONNECTED: for pname in set(self.parameters) - set(('module', 'status')): self.announceUpdate(pname, None, readerror) - self.status = disconnected + self.status = DISCONNECTED def checkProperties(self): pass # skip diff --git a/frappy_psi/mercury.py b/frappy_psi/mercury.py index 969e3c1..c033d82 100644 --- a/frappy_psi/mercury.py +++ b/frappy_psi/mercury.py @@ -211,6 +211,14 @@ class TemperatureSensor(MercuryChannel, Readable): return self.query('DEV::TEMP:SIG:RES') +class RawSensor(MercuryChannel, Readable): + kind = 'TEMP' + value = Parameter(unit='Ohm') + + def read_value(self): + return self.query('DEV::TEMP:SIG:RES') + + class HasInput(HasControlledBy, MercuryChannel): pass @@ -313,7 +321,7 @@ class HeaterOutput(HasInput, Writable): if self._last_target is not None: if not self.true_power: self._volt_target = math.sqrt(self._last_target * self.resistivity) - self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4) + self.change('DEV::HTR:SIG:VOLT', self._volt_target, n_retry=0) return self.resistivity def read_status(self): @@ -338,7 +346,7 @@ class HeaterOutput(HasInput, Writable): self.write_resistivity(res) if self.controlled_by == 0: self._volt_target = math.sqrt(self._last_target * self.resistivity) - self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4) + self.change('DEV::HTR:SIG:VOLT', self._volt_target, n_retry=0) return volt * current def read_target(self): @@ -356,7 +364,7 @@ class HeaterOutput(HasInput, Writable): might be used by a software loop """ self._volt_target = math.sqrt(target * self.resistivity) - self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4) + self.change('DEV::HTR:SIG:VOLT', self._volt_target, n_retry=0) self._last_target = target return target @@ -365,6 +373,15 @@ class HeaterOutput(HasInput, Writable): return self.set_target(value) +class HeaterUpdate(HeaterOutput): + kind = 'HTR,TEMP' + + def update_target(self, module, value): + self.change(f'DEV::TEMP:LOOP:ENAB', False, off_on) + super().update_target(module, value) + self.set_target(value) + + class TemperatureLoop(TemperatureSensor, ConvLoop): kind = 'TEMP' output_module = Attached(HasInput, mandatory=False) diff --git a/frappy_psi/mercury.py.save b/frappy_psi/mercury.py.save new file mode 100644 index 0000000..eb815b7 --- /dev/null +++ b/frappy_psi/mercury.py.save @@ -0,0 +1,667 @@ +#!/usr/bin/env python +# ***************************************************************************** +# 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 +# ***************************************************************************** +"""oxford instruments mercury family""" + + +import math +import re +import time + +from frappy.core import Command, Drivable, HasIO, Writable, StatusType, \ + Parameter, Property, Readable, StringIO, Attached, IDLE, RAMPING, nopoll +from frappy.datatypes import EnumType, FloatRange, StringType, StructOf, BoolType, TupleOf +from frappy.errors import HardwareError, ProgrammingError, ConfigError +from frappy_psi.convergence import HasConvergence +from frappy.states import Retry, Finish +from frappy.mixins import HasOutputModule, HasControlledBy + + +VALUE_UNIT = re.compile(r'(.*\d|inf)([A-Za-z/%]*)$') +SELF = 0 + + +def as_float(value): + """converts string (with unit) to float and float to string""" + if isinstance(value, str): + return float(VALUE_UNIT.match(value).group(1)) + return f'{value:g}' + + +def as_string(value): + return value + + +class Mapped: + def __init__(self, **kwds): + self.mapping = kwds + self.mapping.update({v: k for k, v in kwds.items()}) + + def __call__(self, value): + return self.mapping[value] + + +off_on = Mapped(OFF=False, ON=True) +fast_slow = Mapped(ON=0, OFF=1) # maps OIs slow=ON/fast=OFF to sample_rate.slow=0/sample_rate.fast=1 + + +class IO(StringIO): + identification = [('*IDN?', r'IDN:OXFORD INSTRUMENTS:*')] + timeout = 5 + + +class MercuryChannel(HasIO): + slot = Property('comma separated slot id(s), e.g. DB6.T1', StringType()) + kind = '' #: used slot kind(s) + slots = () #: dict[] of + + def earlyInit(self): + super().earlyInit() + self.kinds = self.kind.split(',') + slots = self.slot.split(',') + if len(slots) != len(self.kinds): + raise ConfigError(f'slot needs {len(self.kinds)} comma separated names') + self.slots = dict(zip(self.kinds, slots)) + self.setProperty('slot', slots[0]) + + def _complete_adr(self, adr): + """insert slot to adr""" + spl = adr.split(':') + if spl[0] == 'DEV': + if spl[1] == '': + spl[1] = self.slots[spl[2]] + return ':'.join(spl) + elif spl[0] != 'SYS': + raise ProgrammingError('using old style adr?') + return adr + + def multiquery(self, adr, names=(), convert=as_float, debug=None): + """get parameter(s) in mercury syntax + + :param adr: the 'address part' of the SCPI command + READ: is added automatically, slot is inserted when adr starts with DEV:: + :param names: the SCPI names of the parameter(s), for example ['TEMP'] + :param convert: a converter function (converts replied string to value) + :return: the values as tuple + + Example (kind=PRES,AUX slot=DB5.P1,DB3.G1): + adr='DEV::AUX:SIG' + names = ('PERC',) + -> query command will be READ:DEV:DB3.G1:AUX:SIG:PERC + """ + # TODO: if the need arises: allow convert to be a list + adr = self._complete_adr(adr) + cmd = f"READ:{adr}:{':'.join(names)}" + msg = '' + for _ in range(3): + if msg: + self.log.warning('%s', msg) + reply = self.communicate(cmd) + if debug is not None: + debug.append(reply) + head = f'STAT:{adr}:' + try: + assert reply.startswith(head) + replyiter = iter(reply[len(head):].split(':')) + keys, result = zip(*zip(replyiter, replyiter)) + assert keys == tuple(names) + return tuple(convert(r) for r in result) + except (AssertionError, AttributeError, ValueError): + time.sleep(0.1) # in case this was the answer of a previous command + msg = f'invalid reply {reply!r} to cmd {cmd!r}' + raise HardwareError(msg) from None + + def multichange(self, adr, values, convert=as_float, tolerance=0, n_retry=3, lazy=False): + """set parameter(s) in mercury syntax + + :param adr: as in multiquery method. SET: is added automatically + :param values: [(name1, value1), (name2, value2) ...] + :param convert: a converter function (converts given value to string and replied string to value) + :param tolerance: tolerance for readback check + :param n_retry: number of retries or 0 for no readback check + :param lazy: check direct reply only (no additional query) + :return: the values as tuple + + Example (kind=TEMP, slot=DB6.T1: + adr='DEV::TEMP:LOOP' + values = [('P', 5), ('I', 2), ('D', 0)] + -> change command will be SET:DEV:DB6.T1:TEMP:LOOP:P:5:I:2:D:0 + """ + # TODO: if the need arises: allow convert and or tolerance to be a list + adr = self._complete_adr(adr) + params = [f'{k}:{convert(v)}' for k, v in values] + cmd = f"SET:{adr}:{':'.join(params)}" + givenkeys = tuple(v[0] for v in values) + for _ in range(max(1, n_retry)): # try n_retry times or until readback result matches + reply = self.communicate(cmd) + head = f'STAT:SET:{adr}:' + try: + assert reply.startswith(head) + replyiter = iter(reply[len(head):].split(':')) + # reshuffle reply=(k1, r1, v1, k2, r2, v1) --> keys = (k1, k2), result = (r1, r2), valid = (v1, v2) + keys, result, valid = zip(*zip(replyiter, replyiter, replyiter)) + assert keys == givenkeys + assert any(v == 'VALID' for v in valid) + result = tuple(convert(r) for r in result) + except (AssertionError, AttributeError, ValueError) as e: + time.sleep(0.1) # in case of missed replies this might help to skip garbage + raise HardwareError(f'invalid reply {reply!r} to cmd {cmd!r}') from e + if n_retry == 0: + return [v for _, v in values] + if lazy: + debug = [reply] + readback = [v for _, v in values] + else: + debug = [] + readback = list(self.multiquery(adr, givenkeys, convert, debug)) + failed = False + for i, ((k, v), r, b) in enumerate(zip(values, result, readback)): + if convert is as_float: + tol = max(abs(r) * 1e-3, abs(b) * 1e-3, tolerance) + if abs(b - v) > tol or abs(r - v) > tol: + readback[i] = None + failed = True + elif b != v or r != v: + readback[i] = None + failed = True + if not failed: + return readback + self.log.warning('sent: %s', cmd) + self.log.warning('got: %s', debug[0]) + return tuple(v[1] if b is None else b for b, v in zip(readback, values)) + + def query(self, adr, convert=as_float): + """query a single parameter + + 'adr' and 'convert' as in multiquery + """ + adr, _, name = adr.rpartition(':') + return self.multiquery(adr, [name], convert)[0] + + def change(self, adr, value, convert=as_float, tolerance=0, n_retry=3, lazy=False): + adr, _, name = adr.rpartition(':') + return self.multichange(adr, [(name, value)], convert, tolerance, n_retry, lazy)[0] + + +class TemperatureSensor(MercuryChannel, Readable): + kind = 'TEMP' + value = Parameter(unit='K') + raw = Parameter('raw value', FloatRange(unit='Ohm')) + + def read_value(self): + return self.query('DEV::TEMP:SIG:TEMP') + + def read_raw(self): + return self.query('DEV::TEMP:SIG:RES') + + +class RawSensor(MercuryChannel, Readable): + kind = 'TEMP' + value = Parameter(unit='Ohm') + + def read_value(self): + return self.query('DEV::TEMP:SIG:RES') + + +class HasInput(HasControlledBy, MercuryChannel): + pass + + +class Loop(HasOutputModule, MercuryChannel, Drivable): + """common base class for loops""" + output_module = Attached(HasInput, mandatory=False) + ctrlpars = Parameter( + 'pid (proportional band, integral time, differential time', + StructOf(p=FloatRange(0, unit='$'), i=FloatRange(0, unit='min'), d=FloatRange(0, unit='min')), + readonly=False, + ) + enable_pid_table = Parameter('', BoolType(), readonly=False) + + def set_output(self, active, source=None): + if active: + self.activate_control() + else: + self.deactivate_control(source) + + def set_target(self, target): + if not self.control_active: + self.activate_control() + self.target = target + + def read_enable_pid_table(self): + return self.query(f'DEV::{self.kinds[0]}:LOOP:PIDT', off_on) + + def write_enable_pid_table(self, value): + return self.change(f'DEV::{self.kinds[0]}:LOOP:PIDT', value, off_on) + + def read_ctrlpars(self): + # read all in one go, in order to reduce comm. traffic + pid = self.multiquery(f'DEV::{self.kinds[0]}:LOOP', ('P', 'I', 'D')) + return {k: float(v) for k, v in zip('pid', pid)} + + def write_ctrlpars(self, value): + pid = self.multichange(f'DEV::{self.kinds[0]}:LOOP', [(k, value[k.lower()]) for k in 'PID']) + return {k.lower(): v for k, v in zip('PID', pid)} + + def read_status(self): + return IDLE, '' + + @Command() + def control_off(self): + """switch control off""" + # remark: this is needed in frappy_psi.trition.TemperatureLoop, as the heater + # output is not available there. We define it here as a convenience for the user. + self.write_control_active(False) + + +class ConvLoop(HasConvergence, Loop): + def deactivate_control(self, source=None): + if self.control_active: + super().deactivate_control(source) + self.convergence_state.start(self.inactive_state) + if self.pollInfo: + self.pollInfo.trigger(True) + + def inactive_state(self, state): + self.convergence_state.status = IDLE, 'control inactive' + return Finish + + +class HeaterOutput(HasInput, Writable): + """heater output + + Remark: + The hardware calculates the power from the voltage and the configured + resistivity. As the measured heater current is available, the resistivity + will be adjusted automatically, when true_power is True. + """ + kind = 'HTR' + value = Parameter('heater output', FloatRange(unit='W'), readonly=False) + status = Parameter(update_unchanged='never') + target = Parameter('heater output', FloatRange(0, 100, unit='$'), readonly=False, update_unchanged='never') + resistivity = Parameter('heater resistivity', FloatRange(10, 1000, unit='Ohm'), + readonly=False) + true_power = Parameter('calculate power from measured current', BoolType(), readonly=False, default=True) + limit = Parameter('heater output limit', FloatRange(0, 1000, unit='W'), readonly=False) + volt = 0.0 # target voltage + _last_target = None + _volt_target = None + _resistivity = 10 + + def read_limit(self): + return self.query('DEV::HTR:VLIM') ** 2 / self.resistivity + + def write_limit(self, value): + result = self.change('DEV::HTR:VLIM', math.sqrt(value * self.resistivity)) + return result ** 2 / self.resistivity + + def read_resistivity(self): + if self.true_power: + return self.resistivity + return max(10.0, self.query('DEV::HTR:RES')) + + def write_resistivity(self, value): + self.resistivity = self.change('DEV::HTR:RES', max(10.0, value)) + if self._last_target is not None: + if not self.true_power: + self._volt_target = math.sqrt(self._last_target * self.resistivity) + self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4) + return self.resistivity + + def read_status(self): + return IDLE, ('true power' if self.true_power else 'fixed resistivity') + + def read_value(self): + if self._last_target is None: # on init + self.read_target() + if not self.true_power: + volt = self.query('DEV::HTR:SIG:VOLT') + return volt ** 2 / max(10.0, self.resistivity) + volt, current = self.multiquery('DEV::HTR:SIG', ('VOLT', 'CURR')) + if volt > 0 and current > 0.0001 and self._last_target: + res = volt / current + tol = res * max(max(0.0003, abs(volt - self._volt_target)) / volt, 0.0001 / current, 0.0001) + if abs(res - self.resistivity) > tol + 0.07 and self._last_target: + res = round(res, 1) + if self._resistivity != res and 10 <= res <= 100: + # we want twice the same value before changing + self._resistivity = res + else: + self.write_resistivity(res) + if self.controlled_by == 0: + self._volt_target = math.sqrt(self._last_target * self.resistivity) + self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4) + return volt * current + + def read_target(self): + if self.controlled_by != 0 or self._last_target is not None: + # read back only when not yet initialized + return self.target + self._volt_target = self.query('DEV::HTR:SIG:VOLT') + self.resistivity = max(10.0, self.query('DEV::HTR:RES')) + self._last_target = self._volt_target ** 2 / max(10.0, self.resistivity) + return self._last_target + + def set_target(self, target): + """set the target without switching to manual + + might be used by a software loop + """ + self._volt_target = math.sqrt(target * self.resistivity) + self.change('DEV::HTR:SIG:VOLT', self._volt_target, tolerance=2e-4) + self._last_target = target + return target + + def write_target(self, value): + self.self_controlled() + return self.set_target(value) + + +class TemperatureLoop(TemperatureSensor, ConvLoop): + kind = 'TEMP' + output_module = Attached(HasInput, mandatory=False) + ramp = Parameter('ramp rate', FloatRange(0, unit='$/min'), readonly=False) + enable_ramp = Parameter('enable ramp rate', BoolType(), readonly=False) + setpoint = Parameter('working setpoint (differs from target when ramping)', FloatRange(0, unit='$')) + status = Parameter(datatype=StatusType(Drivable, 'RAMPING')) # add ramping status + tolerance = Parameter(default=0.1) + _last_setpoint_change = None + __status = IDLE, '' + __ramping = False + # overridden in subclass frappy_psi.triton.TemperatureLoop + ENABLE = 'TEMP:LOOP:ENAB' + ENABLE_RAMP = 'TEMP:LOOP:RENA' + RAMP_RATE = 'TEMP:LOOP:RSET' + + def doPoll(self): + super().doPoll() + self.read_setpoint() + + def set_control_active(self, active): + super().set_control_active(active) + self.change(f'DEV::{self.ENABLE}', active, off_on) + + def initialReads(self): + # initialize control active from HW + active = self.query(f'DEV::{self.ENABLE}', off_on) + super().set_output(active, 'HW') + + @nopoll # polled by read_setpoint + def read_target(self): + if self.read_enable_ramp(): + return self.target + self.setpoint = self.query('DEV::TEMP:LOOP:TSET') + return self.setpoint + + def read_setpoint(self): + setpoint = self.query('DEV::TEMP:LOOP:TSET') + if self.enable_ramp: + if setpoint == self.target: + self.__ramping = False + elif setpoint == self.setpoint: + # update target when working setpoint does no longer change + if self._last_setpoint_change is not None: + unchanged_since = time.time() - self._last_setpoint_change + if unchanged_since > max(12.0, 0.06 / max(1e-4, self.ramp)): + self.__ramping = False + self.target = self.setpoint + return setpoint + self._last_setpoint_change = time.time() + else: + self.__ramping = False + self.target = setpoint + return setpoint + + def set_target(self, target): + self.change(f'DEV::{self.ENABLE}', True, off_on) + super().set_target(target) + + def deactivate_control(self, source=None): + if self.__ramping: + self.__ramping = False + # stop ramping setpoint + self.change('DEV::TEMP:LOOP:TSET', self.read_setpoint(), lazy=True) + super().deactivate_control(source) + + def ramping_state(self, state): + self.read_setpoint() + if self.__ramping: + return Retry + return self.convergence_approach + + def write_target(self, value): + target = self.change('DEV::TEMP:LOOP:TSET', value, lazy=True) + if self.enable_ramp: + self._last_setpoint_change = None + self.__ramping = True + self.set_target(value) + self.convergence_state.status = RAMPING, 'ramping' + self.read_status() + self.convergence_state.start(self.ramping_state) + else: + self.set_target(target) + self.convergence_start() + self.read_status() + return self.target + + def read_enable_ramp(self): + return self.query(f'DEV::{self.ENABLE_RAMP}', off_on) + + def write_enable_ramp(self, value): + if self.enable_ramp < value: # ramp_enable was off: start from current value + self.change('DEV::TEMP:LOOP:TSET', self.value, lazy=True) + result = self.change(f'DEV::{self.ENABLE_RAMP}', value, off_on) + if self.isDriving() and value != self.enable_ramp: + self.enable_ramp = value + self.write_target(self.target) + return result + + def read_ramp(self): + result = self.query(f'DEV::{self.RAMP_RATE}') + return min(9e99, result) + + def write_ramp(self, value): + # use 0 or a very big value for switching off ramp + if not value: + self.write_enable_ramp(0) + return 0 + if value >= 9e99: + self.change(f'DEV::{self.RAMP_RATE}', 'inf', as_string) + self.write_enable_ramp(0) + return 9e99 + self.write_enable_ramp(1) + return self.change(f'DEV::{self.RAMP_RATE}', max(1e-4, value)) + + +class PressureSensor(MercuryChannel, Readable): + kind = 'PRES' + value = Parameter(unit='mbar') + + def read_value(self): + return self.query('DEV::PRES:SIG:PRES') + + +class ValvePos(HasInput, Drivable): + kind = 'PRES,AUX' + value = Parameter('value pos', FloatRange(unit='%'), readonly=False) + target = Parameter('valve pos target', FloatRange(0, 100, unit='$'), readonly=False) + + def doPoll(self): + self.read_status() + + def read_value(self): + return self.query(f'DEV:{self.slots["AUX"]}:AUX:SIG:PERC') + + def read_status(self): + self.read_value() + if abs(self.value - self.target) < 0.01: + return 'IDLE', 'at target' + return 'BUSY', 'moving' + + def read_target(self): + return self.query('DEV::PRES:LOOP:FSET') + + def write_target(self, value): + self.self_controlled() + return self.change('DEV::PRES:LOOP:FSET', value) + + +class PressureLoop(PressureSensor, HasControlledBy, ConvLoop): + kind = 'PRES' + output_module = Attached(ValvePos, mandatory=False) + tolerance = Parameter(default=0.1) + + def set_control_active(self, active): + super().set_control_active(active) + if not active: + self.self_controlled() # switches off auto flow + return self.change('DEV::PRES:LOOP:FAUT', active, off_on) + + def initialReads(self): + # initialize control active from HW + active = self.query('DEV::PRES:LOOP:FAUT', off_on) + super().set_output(active, 'HW') + + def read_target(self): + return self.query('DEV::PRES:LOOP:PRST') + + def set_target(self, target): + """set the target without switching to manual + + might be used by a software loop + """ + self.change('DEV::PRES:LOOP:PRST', target) + super().set_target(target) + + def write_target(self, value): + self.self_controlled() + self.set_target(value) + return value + + +class HasAutoFlow: + needle_valve = Attached(PressureLoop, mandatory=False) + auto_flow = Parameter('enable auto flow', BoolType(), readonly=False, default=0) + flowpars = Parameter('Tdif(min, max), FlowSet(min, max)', + TupleOf(TupleOf(FloatRange(unit='K'), FloatRange(unit='K')), + TupleOf(FloatRange(unit='mbar'), FloatRange(unit='mbar'))), + readonly=False, default=((1, 5), (4, 20))) + + def read_value(self): + value = super().read_value() + if self.auto_flow: + (dmin, dmax), (fmin, fmax) = self.flowpars + flowset = min(dmax - dmin, max(0, value - self.target - dmin)) / (dmax - dmin) * (fmax - fmin) + fmin + self.needle_valve.set_target(flowset) + return value + + def initModule(self): + super().initModule() + if self.needle_valve: + self.needle_valve.register_input(self.name, self.auto_flow_off) + + def write_auto_flow(self, value): + if self.needle_valve: + if value: + self.needle_valve.controlled_by = self.name + else: + if self.needle_valve.control_active: + self.needle_valve.set_target(self.flowpars[1][0]) # flow min + if self.needle_valve.controlled_by != SELF: + self.needle_valve.controlled_by = SELF + return value + + def auto_flow_off(self, source=None): + if self.auto_flow: + self.log.warning(f'switched auto flow off by {source or self.name}') + self.write_auto_flow(False) + + +class TemperatureAutoFlow(HasAutoFlow, TemperatureLoop): + pass + + +class HeLevel(MercuryChannel, Readable): + """He level meter channel + + The Mercury system does not support automatic switching between fast + (when filling) and slow (when consuming). We have to handle this by software. + """ + kind = 'LVL' + value = Parameter(unit='%') + sample_rate = Parameter('_', EnumType(slow=0, fast=1), readonly=False) + hysteresis = Parameter('hysteresis for detection of increase', FloatRange(0, 100, unit='%'), + default=5, readonly=False) + fast_timeout = Parameter('time to switch to slow after last increase', FloatRange(0, unit='sec'), + default=300, readonly=False) + _min_level = 999 + _max_level = -999 + _last_increase = None # None when in slow mode, last increase time in fast mode + + def check_rate(self, sample_rate): + """check changes in rate + + :param sample_rate: (int or enum) 0: slow, 1: fast + initialize affected attributes + """ + if sample_rate != 0: # fast + if not self._last_increase: + self._last_increase = time.time() + self._max_level = -999 + elif self._last_increase: + self._last_increase = None + self._min_level = 999 + return sample_rate + + def read_sample_rate(self): + return self.check_rate(self.query('DEV::LVL:HEL:PULS:SLOW', fast_slow)) + + def write_sample_rate(self, value): + self.check_rate(value) + return self.change('DEV::LVL:HEL:PULS:SLOW', value, fast_slow) + + def read_value(self): + level = self.query('DEV::LVL:SIG:HEL:LEV') + # handle automatic switching depending on increase + now = time.time() + if self._last_increase: # fast mode + if level > self._max_level: + self._last_increase = now + self._max_level = level + elif now > self._last_increase + self.fast_timeout: + # no increase since fast timeout -> slow + self.write_sample_rate(self.sample_rate.slow) + else: + if level > self._min_level + self.hysteresis: + # substantial increase -> fast + self.write_sample_rate(self.sample_rate.fast) + else: + self._min_level = min(self._min_level, level) + return level + + +class N2Level(MercuryChannel, Readable): + kind = 'LVL' + value = Parameter(unit='%') + + def read_value(self): + return self.query('DEV::LVL:SIG:NIT:LEV') + + +class TemperatureSoftLoop( diff --git a/frappy_psi/picontrol.py b/frappy_psi/picontrol.py index f754a98..db3c1b0 100644 --- a/frappy_psi/picontrol.py +++ b/frappy_psi/picontrol.py @@ -18,53 +18,82 @@ # Jael Celia Lorenzana # ***************************************************************************** -"""PI control for furnance""" +"""soft PI control""" import time from frappy.core import Writable, Attached, Parameter, FloatRange, Readable, BoolType, ERROR, IDLE +from frappy.lib import clamp +from frappy.datatypes import LimitsType, EnumType +from frappy.mixins import HasOutputModule -class PI(Writable): - input = Attached(Readable, 'the input module') - output = Attached(Writable, 'the output module') - relais = Attached(Writable, 'the interlock relais') + +class PImixin(HasOutputModule, Writable): p = Parameter('proportional term', FloatRange(0), readonly=False) i = Parameter('integral term', FloatRange(0), readonly=False) - control_active = Parameter('control flag', BoolType(), readonly=False, default=False) - value = Parameter(unit='degC') + output_range = Parameter('min output', LimitsType(FloatRange()), default=(0, 0), readonly=False) + output_func = Parameter('output function', EnumType(lin=0, square=1), readonly=False, default=0) + value = Parameter(unit='K') _lastdiff = None _lasttime = 0 - + _clamp_limits = None + def doPoll(self): super().doPoll() + if self._clamp_limits is None: + out = self.output_module + if hasattr(out, 'max_target'): + if hasattr(self, 'min_target'): + self._clamp_limits = lambda v, o=out: clamp(v, o.read_min_target(), o.read_max_target()) + else: + self._clamp_limits = lambda v, o=out: clamp(v, 0, o.read_max_target()) + elif hasattr(out, 'limit'): # mercury.HeaterOutput + self._clamp_limits = lambda v, o=out: clamp(v, 0, o.read_limit()) + else: + self._clamp_limits = lambda v: v + self.log.info('OR %r', self.output_range) + if self.output_range == (0.0, 0.0): + self.output_range = (0, self._clamp_limits(float('inf'))) + self.log.info('OR %r', self.output_range) if not self.control_active: return - self.value = self.input.value self.status = IDLE, 'controlling' now = time.time() - deltat = min(10, now-self._lasttime) + deltat = clamp(0, now-self._lasttime, 10) self._lasttime = now diff = self.target - self.value - if self.value > 300: - self.write_control_active(False) - return if self._lastdiff is None: self._lastdiff = diff deltadiff = diff - self._lastdiff self._lastdiff = diff - output = self.output.target + out = self.output_module + output = out.target + if self.output_func == 'square': + output = match.sqrt(max(0, output)) output += self.p * deltadiff + self.i * deltat * diff - if output > 100: - output = 100 - elif output < 0: - output = 0 - self.output.write_target(output) - + if self.output_func == 'square': + output = output ** 2 + output = self._clamp_limits(output) + out.update_target(self.name, clamp(output, *self.output_range)) + def write_control_active(self, value): if not value: - self.output.write_target(0) - + self.output_module.write_target(0) + + def write_target(self, target): + if not self.control_active: + self.activate_control() + + +class PI(PImixin, Writable): + input = Attached(Readable, 'the input module') + relais = Attached(Writable, 'the interlock relais', mandatory=False) + + def read_value(self): + return self.input.value + def write_target(self, value): - self.control_active = True - self.relais.write_target(1) - - \ No newline at end of file + super().write_target(value) + if self.relais: + self.relais.write_target(1) + + diff --git a/frappy_psi/softcal.py b/frappy_psi/softcal.py index 6faabf5..94192fa 100644 --- a/frappy_psi/softcal.py +++ b/frappy_psi/softcal.py @@ -28,6 +28,8 @@ from scipy.interpolate import splev, splrep # pylint: disable=import-error from frappy.core import Attached, BoolType, Parameter, Readable, StringType, \ FloatRange, nopoll +from frappy_psi.convergence import HasConvergence +from frappy_psi.picontrol import PImixin def linear(x): @@ -232,3 +234,7 @@ class Sensor(Readable): @nopoll def read_status(self): return self._get_status(self.rawsensor.read_status()) + + +class SoftPiLoop(HasConvergence, PImixin, Sensor): + pass