# ***************************************************************************** # # 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 # # ***************************************************************************** """drivers for CCU4, the cryostat control unit at SINQ""" import time import math from frappy.lib.enum import Enum from frappy.lib import clamp # the most common Frappy classes can be imported from frappy.core from frappy.core import HasIO, Parameter, Command, Readable, Writable, Drivable, \ Property, StringIO, BUSY, IDLE, WARN, ERROR, DISABLED, Attached, nopoll from frappy.datatypes import BoolType, EnumType, FloatRange, StructOf, \ StatusType, IntRange, StringType, TupleOf from frappy.errors import CommunicationFailedError from frappy.states import HasStates, status_code, Retry M = Enum(idle=0, opening=1, closing=2, opened=3, closed=4, no_motor=5) A = Enum(disabled=0, manual=1, auto=2) class IO(StringIO): """communication with CCU4""" # for completeness: (not needed, as it is the default) end_of_line = '\n' # on connect, we send 'cid' and expect a reply starting with 'CCU4' identification = [('cid', r'cid=CCU4.*')] class Base(HasIO): ioClass = IO def command(self, **kwds): """send a command and get the response :param kwds: = for changing a parameter = for querying a parameter :returns: the (new) values of the parameters """ types = {} cmds = [] for key, value in kwds.items(): if isinstance(value, type): types[key] = value cmds.append(key) elif isinstance(value, str): types[key] = str cmds.append(f'{key}={value}') else: types[key] = float cmds.append(f'{key}={value:g}') reply = self.io.communicate(' '.join(cmds)).split() if len(reply) != len(types): raise CommunicationFailedError('number of reply items does not match') result = [] for (given, typ), res in zip(types.items(), reply): name, txtvalue = res.split('=') if given != name: raise CommunicationFailedError('result keys do not match given keys') result.append(typ(txtvalue)) if len(kwds) == 1: return result[0] return result class HeLevel(Base, Readable): """He Level channel of CCU4""" value = Parameter(unit='%') empty_length = Parameter('warm length when empty', FloatRange(0, 2000, unit='mm'), readonly=False) full_length = Parameter('warm length when full', FloatRange(0, 2000, unit='mm'), readonly=False) sample_rate = Parameter('sample rate', EnumType(slow=0, fast=1), readonly=False) status = Parameter(datatype=StatusType(Readable, 'DISABLED')) # conversion of the code from the CCU4 parameter 'hsf' STATUS_MAP = { 0: (IDLE, 'sensor ok'), 1: (ERROR, 'sensor warm'), 2: (ERROR, 'no sensor'), 3: (ERROR, 'timeout'), 4: (ERROR, 'not yet read'), 5: (DISABLED, 'disabled'), } def read_value(self): return self.command(h=float) def read_status(self): return self.STATUS_MAP[int(self.command(hsf=int))] def read_sample_rate(self): value, self.empty_length, self.full_length = self.command(hf=int, hem=float, hfu=float) return value def write_sample_rate(self, value): return self.command(hf=value) def write_empty_length(self, value): return self.command(hem=value) def write_full_length(self, value): return self.command(hfu=value) class Valve(Base, Writable): value = Parameter('relay state', BoolType()) target = Parameter('relay target', BoolType()) ioClass = IO STATE_MAP = {0: (0, (IDLE, 'off')), 1: (1, (IDLE, 'on')), 2: (0, (ERROR, 'no valve')), 3: (0, (WARN, 'timeout')), # timeout in filling process (takes too long to fill) 4: (0, (WARN, 'timeout1')), # timeout in filling process (takes too long to start) 5: (1, (IDLE, 'boost')), } _open_command = None _close_command = None _query_state = None def write_target(self, target): if target: self.command(**self._open_command) else: self.command(**self._close_command) def read_status(self): state = int(self.command(**self._query_state)) self.value, status = self.STATE_MAP[state] return status class HeFillValve(Valve): _open_command = {'hcd': 1, 'hf': 1} _close_command = {'hcd': 0, 'hf': 0} _query_state = {'hv': int} class N2FillValve(Valve): _open_command = {'nc': 1} _close_command = {'nc': 0} _query_state = {'nv': int} class AuxValve(Valve): channel = Property('valve number', IntRange(1, 12)) def initModule(self): self._open_command = {f'vc{self.channel}': 1} self._close_command = {f'vc{self.channel}': 0} self._query_state = {f'v{self.channel}': int} class N2TempSensor(Readable): value = Parameter('LN2 T sensor', FloatRange(unit='K'), default=0) class N2Level(Base, Readable): valve = Attached(Writable, mandatory=False) lower = Attached(Readable, mandatory=False) upper = Attached(Readable, mandatory=False) value = Parameter('vessel state', EnumType(empty=0, ok=1, full=2)) status = Parameter(datatype=StatusType(Readable, 'DISABLED', 'BUSY')) mode = Parameter('auto mode', EnumType(A), readonly=False, default=A.manual) threshold = Parameter('threshold triggering start/stop filling', FloatRange(unit='K'), readonly=False) cool_delay = Parameter('max. minutes needed to cool the lower sensor', FloatRange(unit='s'), readonly=False) fill_timeout = Parameter('max. minutes needed to fill', FloatRange(unit='s'), readonly=False) names = Property('''names of attached modules configure members as empty strings to disable the creation ''', StructOf(valve=StringType(), lower=StringType(), upper=StringType()), default={'valve': '$_valve', 'lower': '$_lower', 'upper': '$_upper'}) # conversion of the code from the CCU4 parameter 'ns' STATUS_MAP = { 0: (IDLE, 'sensor ok'), 1: (ERROR, 'no sensor'), 2: (ERROR, 'short circuit'), 3: (ERROR, 'upside down'), 4: (ERROR, 'sensor warm'), 5: (WARN, 'empty'), } def initialReads(self): self.command(nav=1) # tell CCU4 to activate LN2 sensor readings super().initialReads() def read_status(self): auto, nstate = self.command(na=int, ns=int) if not self.valve or not auto: if self.mode == A.auto: # no valve assigned self.mode = A.manual if self.mode == A.disabled: return DISABLED, '' status = self.STATUS_MAP[nstate] if status[0] // 100 != IDLE // 100: return status if self.mode == A.manual: return IDLE, '' vstatus = self.valve.status if vstatus[0] // 100 == WARN // 100: return ERROR, vstatus[1] if vstatus[0] // 100 != IDLE // 100: return vstatus if self.valve.value: return BUSY, 'filling' return IDLE, 'watching' def read_value(self): # read sensors lower, upper = self.command(nl=float, nu=float) if self.lower: self.lower.value = lower if self.upper: self.upper.value = upper if upper < self.threshold: return 'full' if lower < self.threshold: return 'ok' return 'empty' def write_mode(self, mode): if mode == A.auto: if self.isBusy(): return mode # set to watching self.command(nc=3) else: # set to off self.command(nc=2) return mode def read_threshold(self): value, self.cool_delay, self.fill_timeout = self.command(nth=float, ntc=float, ntm=float) return value def write_threshold(self, value): return self.command(nth=value) def write_cool_delay(self, value): return self.command(ntc=value) def write_fill_timeout(self, value): return self.command(ntm=value) @Command() def fill(self): """start filling""" self.mode = A.auto self.command(nc=1) @Command() def stop(self): """stop filling""" if self.mode == A.auto: # set to watching self.command(nc=3) else: # set to off self.command(nc=0) class HasFilter: __value1 = None __value = None __last = None def filter(self, filter_time, value): now = time.time() if self.__value is None: self.__last = now self.__value1 = value self.__value = value weight = (now - self.__last) / filter_time self.__value1 += weight * (value - self.__value) self.__value += weight * (self.__value1 - self.__value) self.__last = now return self.__value class Pressure(HasFilter, Base, Readable): value = Parameter(unit='mbar') mbar_offset = Parameter('offset in mbar', FloatRange(unit='mbar'), default=0.8, readonly=False) filter_time = Parameter('filter time', FloatRange(unit='sec'), readonly=False, default=3) pollinterval = Parameter(default=0.25) def read_value(self): return self.filter(self.filter_time, self.command(f=float)) - self.mbar_offset class NeedleValveFlow(HasStates, Base, Drivable): flow_sensor = Attached(Readable, mandatory=False) pressure = Attached(Pressure, mandatory=False) use_pressure = Parameter('flag (use pressure instead of flow meter)', BoolType(), readonly=False, default=False) lnm_per_mbar = Parameter('scale factor', FloatRange(unit='lnm/mbar'), readonly=False, default=0.6) value = Parameter(unit='ln/min') target = Parameter(unit='ln/min') motor_state = Parameter('motor_state', EnumType(M), default=0) tolerance = Parameter('tolerance', FloatRange(0), value=0.25, readonly=False) tolerance2 = Parameter('tolerance limit above 2 lnm', FloatRange(0), value=0.5, readonly=False) prop = Parameter('proportional term', FloatRange(unit='s/lnm'), readonly=False, default=0.001) deriv = Parameter('min progress time constant', FloatRange(unit='s'), default=30, readonly=False) settle = Parameter('time within tolerance before getting quiet', FloatRange(unit='s'), default=30, readonly=False) step_factor = Parameter('factor (no progress time) / (min step size)', FloatRange(), default=300, readonly=False) control_active = Parameter('control active flag', BoolType(), readonly=False, default=1) min_open_step = Parameter('minimal open step', FloatRange(unit='s'), readonly=False, default=0.06) min_close_step = Parameter('minimal close step', FloatRange(unit='s'), readonly=False, default=0.05) raw_open_step = Parameter('step after direction change', FloatRange(unit='s'), readonly=False, default=0.12) raw_close_step = Parameter('step after direction change', FloatRange(unit='s'), readonly=False, default=0.04) pollinterval = Parameter(default=5) _ref_time = 0 _ref_dif = 0 _dir = 0 _rawdir = 0 _step = 0 def initModule(self): if self.pressure: self.pressure.addCallback('value', self.update_from_pressure) if self.flow_sensor: self.flow_sensor.addCallback('value', self.update_from_flow) super().initModule() def update_from_flow(self, value): if not self.use_pressure: self.value = value # self.cycle_machine() def update_from_pressure(self, value): if self.use_pressure: self.value = value * self.lnm_per_mbar # self.cycle_machine() # def doPoll(self): # """only the updates should trigger the machine""" def read_value(self): p = self.pressure.read_value() * self.lnm_per_mbar f = self.flow_sensor.read_value() # self.log.info('p %g f %g +- %.2g', p, f, self.flow_sensor.stddev) self.read_motor_state() return p if self.use_pressure else f def write_tolerance(self, tolerance): if hasattr(self.pressure, 'tolerance'): self.pressure.tolerance = tolerance / self.lnm_per_mbar if hasattr(self.flow_sensor, 'tolerance'): self.flow_sensor.tolerance = tolerance def read_use_pressure(self): if self.pressure: if self.flow_sensor: return self.use_pressure return True return False def write_target(self, value): self.start_machine(self.change_target, fast_poll=1) @status_code(BUSY) def change_target(self, state): state.in_tol_time = 0 state.last_minstep = {} state.last_progress = state.now state.ref_time = 0 state.ref_dif = 0 state.prev_dif = 0 # used? state.last_close_time = 0 state.last_pulse_time = 0 state.raw_fact = 1 state.raw_step = 0 if abs(self.target - self.value) < self._tolerance(): return self.at_target return self.raw_control def start_direction(self, state): if self.target > self.value: self._dir = 1 state.minstep = self.min_open_step else: self._dir = -1 state.minstep = self.min_close_step state.prev = [] def perform_pulse(self, state): tol = self._tolerance() dif = self.target - self.value difdir = dif * self._dir state.last_pulse_time = state.now if difdir > tol: step = state.minstep + (difdir - tol) * self.prop elif difdir > 0: step = state.minstep * difdir / tol else: return self.log.info('MP %g dif=%g tol=%g', step * self._dir, dif, tol) self.command(mp=clamp(-1, step * self._dir, 1)) @status_code(BUSY) def raw_control(self, state): tol = self._tolerance() if state.init: self.start_direction(state) state.raw_step = self.raw_open_step if self._dir > 0 else -self.raw_close_step state.raw_fact = 1 # if self.read_motor_state() == M.closed: # # TODO: also check for flow near lower limit ? but only once after change_target # self.log.info('start with fast opening') # state.raw_step = 1 # self._dir = 1 difdir = (self.target - self.value) * self._dir state.prev.append(difdir) state.diflim = max(0, difdir - tol * 1) state.success = 0 self.command(mp=state.raw_step) self.log.info('first rawstep %g', state.raw_step) state.last_pulse_time = state.now state.raw_pulse_cnt = 0 state.cycle_cnt = 0 return Retry difdir = (self.target - self.value) * self._dir state.cycle_cnt += 1 state.prev.append(difdir) del state.prev[:-5] if state.prev[-1] > max(state.prev[:-1]): # TODO: use the amount of overshoot to reduce the raw_step state.cycle_cnt = 0 self.log.info('difference is increasing %s', ' '.join(f'{v:g}' for v in state.prev)) return Retry if state.cycle_cnt >= 5: state.cycle_cnt = 0 state.diflim = max(tol, min(state.prev) - tol * 0.5) state.raw_pulse_cnt += 1 self.command(mp=state.raw_step * state.raw_fact) self.log.info('rawstep %g', state.raw_step) if state.raw_pulse_cnt % 5 == 0 and state.raw_pulse_cnt > 5: state.raw_fact *= 1.25 return Retry if difdir >= state.diflim: state.success = max(0, state.success - 1) return Retry state.success += 1 if state.success <= 3: return Retry if state.raw_pulse_cnt < 3: state.raw_fact = 1 - (3 - state.raw_pulse_cnt) ** 2 * 0.05 if state.raw_fact != 1: if self._dir > 0: self.raw_open_step *= state.raw_fact self.log.info('raw_open_step %g f=%g', self.raw_open_step, state.raw_fact) self.min_open_pulse = min(self.min_open_pulse, self.raw_open_step) else: self.raw_close_step *= state.raw_fact self.log.info('raw_close_step %g f=%g', self.raw_close_step, state.raw_fact) self.min_close_pulse = min(self.min_close_pulse, self.raw_close_step) return self.controlling # @status_code(BUSY) # def raw_control(self, state): # tol = self._tolerance() # if state.init: # self.start_direction(state) # if self._dir != self._rawdir: # self._rawdir = self._dir # state.first_step = self.first_open_step if self._dir > 0 else -self.first_close_step # else: # state.first_step = 0 # state.first_fact = 1 # # if self.read_motor_state() == M.closed: # # # TODO: also check for flow near lower limit ? but only once after change_target # # self.log.info('start with fast opening') # # state.first_step = 1 # # self._dir = 1 # difdir = (self.target - self.value) * self._dir # state.prev = [difdir] # state.diflim = max(0, difdir - tol * 0.5) # state.success = 0 # if state.first_step: # self.command(mp=state.first_step) # else: # self.perform_pulse(state) # self.log.info('firststep %g', state.first_step) # state.last_pulse_time = state.now # state.raw_pulse_cnt = 0 # return Retry # difdir = (self.target - self.value) * self._dir # if state.delta(5): # state.diflim = max(0, min(state.prev) - tol * 0.1) # state.prev = [difdir] # state.raw_pulse_cnt += 1 # if state.first_step and state.raw_pulse_cnt % 10 == 0: # self.command(mp=state.first_step * state.first_fact) # self.log.info('repeat firststep %g', state.first_step * state.first_fact) # state.first_fact *= 1.25 # else: # self.perform_pulse(state) # return Retry # state.prev.append(difdir) # if difdir >= state.diflim: # state.success = max(0, state.success - 1) # return Retry # state.success += 1 # if state.success <= 5: # return Retry # if state.first_step: # if state.raw_pulse_cnt < 3: # state.first_fact = 1 - (3 - state.raw_pulse_cnt) ** 2 * 0.04 # if state.first_fact != 1: # if self._dir > 0: # self.first_open_step *= state.first_fact # self.log.info('first_open_step %g f=%g', self.first_open_step, state.first_fact) # else: # self.first_close_step *= state.first_fact # self.log.info('first_close_step %g f=%g', self.first_close_step, state.first_fact) # return self.controlling @status_code(BUSY) def controlling(self, state): dif = self.target - self.value if state.init: self.start_direction(state) state.ref_dif = abs(dif) state.ref_time = state.now state.in_tol_time = 0 difdir = dif * self._dir # negative when overshoot happend # difdif = dif - state.prev_dif # state.prev_dif = dif expected_dif = state.ref_dif * math.exp((state.ref_time - state.now) / self.deriv) tol = self._tolerance() if difdir < tol: # prev_minstep = state.last_minstep.pop(self._dir, None) # attr = 'min_open_step' if self._dir > 0 else 'min_close_step' # if prev_minstep is not None: # # increase minstep # minstep = getattr(self, attr) # setattr(self, attr, minstep * 1.1) # self.log.info('increase %s to %g', attr, minstep) if difdir > -tol: # within tolerance delta = state.delta() state.in_tol_time += delta if state.in_tol_time > self.settle: # state.last_minstep.pop(self._dir, None) self.log.info('at target %g %g', dif, tol) return self.at_target if difdir < 0: return Retry # self.log.info('minstep=0 dif=%g', dif) else: # overshoot self.log.info('overshoot %g', dif) return self.raw_control # # overshoot # prev_minstep = state.last_minstep.pop(self._dir, None) # if prev_minstep is None: # minstep = getattr(self, attr) * 0.9 # self.log.info('decrease %s to %g', attr, minstep) # setattr(self, attr, minstep) # self.start_step(state, self.target) # still approaching if difdir <= expected_dif: if difdir < expected_dif / 1.25 - tol: state.ref_time = state.now state.ref_dif = (difdir + tol) * 1.25 # self.log.info('new ref %g', state.ref_dif) state.last_progress = state.now return Retry # progressing: no pulse needed if state.now < state.last_pulse_time + 2.5: return Retry # TODO: check motor state for closed / opened ? self.perform_pulse(state) return Retry def _tolerance(self): return min(self.tolerance * min(1, self.value / 2), self.tolerance2) @status_code(IDLE) def at_target(self, state): dif = self.target - self.value if abs(dif) > self._tolerance(): state.in_tol_time = 0 self.log.info('unstable %g', dif) return self.unstable return Retry @status_code(IDLE, 'unstable') def unstable(self, state): difdir = (self.target - self.value) * self._dir if difdir < 0 or self._dir == 0: return self.raw_control return self.controlling(state) def read_motor_state(self): return self.command(fm=int) @Command def close(self): """close valve fully""" self.command(mp=-60) self.motor_state = self.command(fm=int) self.start_machine(self.closing) @status_code(BUSY) def closing(self, state): if state.init: state.start_time = state.now self.read_motor_state() if self.motor_state == M.closing: return Retry if self.motor_state == M.closed: return self.final_status(IDLE, 'closed') if state.now < state.start_time + 1: return Retry return self.final_status(IDLE, 'fixed') @Command def open(self): """open valve fully""" self.command(mp=60) self.read_motor_state() self.start_machine(self.opening) @status_code(BUSY) def opening(self, state): if state.init: state.start_time = state.now self.read_motor_state() if self.motor_state == M.opening: return Retry if self.motor_state == M.opened: return self.final_status(IDLE, 'opened') if state.now < state.start_time + 1: return Retry return self.final_status(IDLE, 'fixed') @Command(FloatRange()) def pulse(self, value): """perform a motor pulse""" self.log.info('pulse %g', value) self.command(mp=value) if value > 0: self.motor_state = M.opening return self.opening self.motor_state = M.closing return self.closing