improve mercury temperature loop

- remove appearance of Done
- add auto flow
- try up to 3 times in 'change' method if read back does not match

Change-Id: I98928307bda87190d34aed663023b157311d4495
Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/30981
Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
This commit is contained in:
zolliker 2023-05-01 11:26:52 +02:00
parent 6c02f37bbb
commit 5784aa0f5d

View File

@ -27,7 +27,7 @@ import time
from frappy.core import Drivable, HasIO, Writable, \ from frappy.core import Drivable, HasIO, Writable, \
Parameter, Property, Readable, StringIO, Attached, IDLE, nopoll Parameter, Property, Readable, StringIO, Attached, IDLE, nopoll
from frappy.datatypes import EnumType, FloatRange, StringType, StructOf, BoolType from frappy.datatypes import EnumType, FloatRange, StringType, StructOf, BoolType, TupleOf
from frappy.errors import HardwareError from frappy.errors import HardwareError
from frappy_psi.convergence import HasConvergence from frappy_psi.convergence import HasConvergence
from frappy.lib.enum import Enum from frappy.lib.enum import Enum
@ -38,6 +38,7 @@ SELF = 0
def as_float(value): def as_float(value):
"""converts string (with unit) to float and float to string"""
if isinstance(value, str): if isinstance(value, str):
return float(VALUE_UNIT.match(value).group(1)) return float(VALUE_UNIT.match(value).group(1))
return f'{value:g}' return f'{value:g}'
@ -61,7 +62,7 @@ fast_slow = Mapped(ON=0, OFF=1) # maps OIs slow=ON/fast=OFF to sample_rate.slow
class IO(StringIO): class IO(StringIO):
identification = [('*IDN?', r'IDN:OXFORD INSTRUMENTS:MERCURY*')] identification = [('*IDN?', r'IDN:OXFORD INSTRUMENTS:*')]
class MercuryChannel(HasIO): class MercuryChannel(HasIO):
@ -70,7 +71,6 @@ class MercuryChannel(HasIO):
example: DB6.T1,DB1.H1 example: DB6.T1,DB1.H1
slot ids for sensor (and control output)''', slot ids for sensor (and control output)''',
StringType()) StringType())
channel_name = Parameter('mercury nick name', StringType(), default='', update_unchanged='never')
channel_type = '' #: channel type(s) for sensor (and control) e.g. TEMP,HTR or PRES,AUX channel_type = '' #: channel type(s) for sensor (and control) e.g. TEMP,HTR or PRES,AUX
def _complete_adr(self, adr): def _complete_adr(self, adr):
@ -83,7 +83,7 @@ class MercuryChannel(HasIO):
return f'DEV:{slot}:{head}{sep}{tail}' return f'DEV:{slot}:{head}{sep}{tail}'
return adr return adr
def multiquery(self, adr, names=(), convert=as_float): def multiquery(self, adr, names=(), convert=as_float, debug=None):
"""get parameter(s) in mercury syntax """get parameter(s) in mercury syntax
:param adr: the 'address part' of the SCPI command :param adr: the 'address part' of the SCPI command
@ -100,9 +100,16 @@ class MercuryChannel(HasIO):
self.slot='DB5.P1,DB3.G1' # -> take second slot self.slot='DB5.P1,DB3.G1' # -> take second slot
-> query command will be READ:DEV:DB3.G1:PRES:SIG:PERC -> query command will be READ:DEV:DB3.G1:PRES:SIG:PERC
""" """
# TODO: if the need arises: allow convert to be a list
adr = self._complete_adr(adr) adr = self._complete_adr(adr)
cmd = f"READ:{adr}:{':'.join(names)}" cmd = f"READ:{adr}:{':'.join(names)}"
msg = ''
for _ in range(3):
if msg:
self.log.warning('%s', msg)
reply = self.communicate(cmd) reply = self.communicate(cmd)
if debug is not None:
debug.append(reply)
head = f'STAT:{adr}:' head = f'STAT:{adr}:'
try: try:
assert reply.startswith(head) assert reply.startswith(head)
@ -111,14 +118,18 @@ class MercuryChannel(HasIO):
assert keys == tuple(names) assert keys == tuple(names)
return tuple(convert(r) for r in result) return tuple(convert(r) for r in result)
except (AssertionError, AttributeError, ValueError): except (AssertionError, AttributeError, ValueError):
raise HardwareError(f'invalid reply {reply!r} to cmd {cmd!r}') from None 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): def multichange(self, adr, values, convert=as_float, tolerance=0, n_retry=3):
"""set parameter(s) in mercury syntax """set parameter(s) in mercury syntax
:param adr: as in see multiquery method :param adr: as in see multiquery method
:param values: [(name1, value1), (name2, value2) ...] :param values: [(name1, value1), (name2, value2) ...]
:param convert: a converter function (converts given value to string and replied string to value) :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
:return: the values as tuple :return: the values as tuple
Example: Example:
@ -128,21 +139,41 @@ class MercuryChannel(HasIO):
self.slot='DB6.T1,DB1.H1' # and take first slot self.slot='DB6.T1,DB1.H1' # and take first slot
-> change command will be SET:DEV:DB6.T1:TEMP:LOOP: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) adr = self._complete_adr(adr)
params = [f'{k}:{convert(v)}' for k, v in values] params = [f'{k}:{convert(v)}' for k, v in values]
cmd = f"SET:{adr}:{':'.join(params)}" cmd = f"SET:{adr}:{':'.join(params)}"
for _ in range(max(1, n_retry)): # try n_retry times or until readback result matches
reply = self.communicate(cmd) reply = self.communicate(cmd)
head = f'STAT:SET:{adr}:' head = f'STAT:SET:{adr}:'
try: try:
assert reply.startswith(head) assert reply.startswith(head)
replyiter = iter(reply[len(head):].split(':')) 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)) keys, result, valid = zip(*zip(replyiter, replyiter, replyiter))
assert keys == tuple(k for k, _ in values) assert keys == tuple(k for k, _ in values)
assert any(v == 'VALID' for v in valid) assert any(v == 'VALID' for v in valid)
return tuple(convert(r) for r in result) result = tuple(convert(r) for r in result)
except (AssertionError, AttributeError, ValueError) as e: 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 raise HardwareError(f'invalid reply {reply!r} to cmd {cmd!r}') from e
if n_retry == 0:
return [v[1] for v in values] # no readback check
keys = [v[0] for v in values]
debug = []
readback = self.multiquery(adr, keys, convert, debug)
for k, r, b in zip(keys, result, readback):
if convert is as_float:
tol = max(abs(r) * 1e-3, abs(b) * 1e-3, tolerance)
if abs(r - b) > tol:
break
elif r != b:
break
else:
return readback
self.log.warning('sent: %s', cmd)
self.log.warning('got: %s', debug[0])
return readback
def query(self, adr, convert=as_float): def query(self, adr, convert=as_float):
"""query a single parameter """query a single parameter
@ -152,14 +183,9 @@ class MercuryChannel(HasIO):
adr, _, name = adr.rpartition(':') adr, _, name = adr.rpartition(':')
return self.multiquery(adr, [name], convert)[0] return self.multiquery(adr, [name], convert)[0]
def change(self, adr, value, convert=as_float): def change(self, adr, value, convert=as_float, tolerance=0, n_retry=3):
adr, _, name = adr.rpartition(':') adr, _, name = adr.rpartition(':')
return self.multichange(adr, [(name, value)], convert)[0] return self.multichange(adr, [(name, value)], convert, tolerance, n_retry)[0]
def read_channel_name(self):
if self.channel_name:
return self.channel_name # channel name will not change
return self.query('0:NICK', as_string)
class TemperatureSensor(MercuryChannel, Readable): class TemperatureSensor(MercuryChannel, Readable):
@ -176,26 +202,29 @@ class TemperatureSensor(MercuryChannel, Readable):
class HasInput(MercuryChannel): class HasInput(MercuryChannel):
controlled_by = Parameter('source of target value', EnumType(members={'self': SELF}), default=0) controlled_by = Parameter('source of target value', EnumType(members={'self': SELF}), default=0)
target = Parameter(readonly=False) # do not know why this? target = Parameter(readonly=False)
input_modules = () input_callbacks = ()
def add_input(self, modobj): def register_input(self, name, control_off):
if not self.input_modules: """register input
self.input_modules = []
self.input_modules.append(modobj) :param name: the name of the module (for controlled_by enum)
:param control_off: a method on the input module to switch off control
"""
if not self.input_callbacks:
self.input_callbacks = []
self.input_callbacks.append(control_off)
prev_enum = self.parameters['controlled_by'].datatype._enum prev_enum = self.parameters['controlled_by'].datatype._enum
# add enum member, using autoincrement feature of Enum # add enum member, using autoincrement feature of Enum
self.parameters['controlled_by'].datatype = EnumType(Enum(prev_enum, **{modobj.name: None})) self.parameters['controlled_by'].datatype = EnumType(Enum(prev_enum, **{name: None}))
def write_controlled_by(self, value): def write_controlled_by(self, value):
if self.controlled_by == value: if self.controlled_by == value:
return value return value
self.controlled_by = value self.controlled_by = value
if value == SELF: if value == SELF:
self.log.warning('switch to manual mode') for control_off in self.input_callbacks:
for input_module in self.input_modules: control_off()
if input_module.control_active:
input_module.write_control_active(False)
return value return value
@ -213,12 +242,17 @@ class Loop(HasConvergence, MercuryChannel, Drivable):
def initModule(self): def initModule(self):
super().initModule() super().initModule()
if self.output_module: if self.output_module:
self.output_module.add_input(self) self.output_module.register_input(self.name, self.control_off)
def control_off(self):
if self.control_active:
self.log.warning('switch to manual mode')
self.write_control_active(False)
def set_output(self, active): def set_output(self, active):
if active: if active:
if self.output_module and self.output_module.controlled_by != self.name: if self.output_module and self.output_module.controlled_by != self.name:
self.output_module.controlled_by = self.name self.output_module.write_controlled_by(self.name)
else: else:
if self.output_module and self.output_module.controlled_by != SELF: if self.output_module and self.output_module.controlled_by != SELF:
self.output_module.write_controlled_by(SELF) self.output_module.write_controlled_by(SELF)
@ -312,24 +346,23 @@ class HeaterOutput(HasInput, MercuryChannel, Writable):
return volt * current return volt * current
def read_target(self): def read_target(self):
if self.controlled_by != 0 and self.target: if self.controlled_by != 0 or self._last_target is not None:
return 0 # read back only when not yet initialized
if self._last_target is not None:
return self.target return self.target
self._volt_target = self.query('HTR:SIG:VOLT') self._volt_target = self.query('HTR:SIG:VOLT')
self.resistivity = max(10, self.query('HTR:RES')) self.resistivity = max(10, self.query('HTR:RES'))
self._last_target = self._volt_target ** 2 / max(10, self.resistivity) self._last_target = self._volt_target ** 2 / max(10, self.resistivity)
return self._last_target return self._last_target
def set_target(self, value): def set_target(self, target):
"""set the target without switching to manual """set the target without switching to manual
might be used by a software loop might be used by a software loop
""" """
self._volt_target = math.sqrt(value * self.resistivity) self._volt_target = math.sqrt(target * self.resistivity)
self.change('HTR:SIG:VOLT', self._volt_target) self.change('HTR:SIG:VOLT', self._volt_target)
self._last_target = value self._last_target = target
return value return target
def write_target(self, value): def write_target(self, value):
self.write_controlled_by(SELF) self.write_controlled_by(SELF)
@ -337,26 +370,29 @@ class HeaterOutput(HasInput, MercuryChannel, Writable):
class TemperatureLoop(TemperatureSensor, Loop, Drivable): class TemperatureLoop(TemperatureSensor, Loop, Drivable):
channel_type = 'TEMP,HTR' channel_type = 'TEMP'
output_module = Attached(HeaterOutput, mandatory=False) output_module = Attached(HasInput, mandatory=False)
ramp = Parameter('ramp rate', FloatRange(0, unit='K/min'), readonly=False) ramp = Parameter('ramp rate', FloatRange(0, unit='$/min'), readonly=False)
enable_ramp = Parameter('enable ramp rate', BoolType(), readonly=False) enable_ramp = Parameter('enable ramp rate', BoolType(), readonly=False)
setpoint = Parameter('working setpoint (differs from target when ramping)', FloatRange(0, unit='$')) setpoint = Parameter('working setpoint (differs from target when ramping)', FloatRange(0, unit='$'))
auto_flow = Parameter('enable auto flow', BoolType(), readonly=False) tolerance = Parameter(default=0.1)
_last_setpoint_change = None _last_setpoint_change = None
ENABLE = 'TEMP:LOOP:ENAB'
ENABLE_RAMP = 'TEMP:LOOP:RENA'
RAMP_RATE = 'TEMP:LOOP:RSET'
def doPoll(self): def doPoll(self):
super().doPoll() super().doPoll()
self.read_setpoint() self.read_setpoint()
def read_control_active(self): def read_control_active(self):
active = self.query('TEMP:LOOP:ENAB', off_on) active = self.query(self.ENABLE, off_on)
self.set_output(active) self.set_output(active)
return active return active
def write_control_active(self, value): def write_control_active(self, value):
self.set_output(value) self.set_output(value)
return self.change('TEMP:LOOP:ENAB', value, off_on) return self.change(self.ENABLE, value, off_on)
@nopoll # polled by read_setpoint @nopoll # polled by read_setpoint
def read_target(self): def read_target(self):
@ -390,19 +426,24 @@ class TemperatureLoop(TemperatureSensor, Loop, Drivable):
return self.target return self.target
def read_enable_ramp(self): def read_enable_ramp(self):
return self.query('TEMP:LOOP:RENA', off_on) return self.query(self.ENABLE_RAMP, off_on)
def write_enable_ramp(self, value): def write_enable_ramp(self, value):
return self.change('TEMP:LOOP:RENA', value, off_on) return self.change(self.ENABLE_RAMP, value, off_on)
def read_auto_flow(self): def set_output(self, active):
return self.query('TEMP:LOOP:FAUT', off_on) if active:
if self.output_module and self.output_module.controlled_by != self.name:
def write_auto_flow(self, value): self.output_module.write_controlled_by(self.name)
return self.change('TEMP:LOOP:FAUT', value, off_on) else:
if self.output_module and self.output_module.controlled_by != SELF:
self.output_module.write_controlled_by(SELF)
status = IDLE, 'control inactive'
if self.status != status:
self.status = status
def read_ramp(self): def read_ramp(self):
result = self.query('TEMP:LOOP:RSET') result = self.query(self.RAMP_RATE)
return min(9e99, result) return min(9e99, result)
def write_ramp(self, value): def write_ramp(self, value):
@ -411,11 +452,11 @@ class TemperatureLoop(TemperatureSensor, Loop, Drivable):
self.write_enable_ramp(0) self.write_enable_ramp(0)
return 0 return 0
if value >= 9e99: if value >= 9e99:
self.change('TEMP:LOOP:RSET', 'inf', as_string) self.change(self.RAMP_RATE, 'inf', as_string)
self.write_enable_ramp(0) self.write_enable_ramp(0)
return 9e99 return 9e99
self.write_enable_ramp(1) self.write_enable_ramp(1)
return self.change('TEMP:LOOP:RSET', max(1e-4, value)) return self.change(self.RAMP_RATE, max(1e-4, value))
class PressureSensor(MercuryChannel, Readable): class PressureSensor(MercuryChannel, Readable):
@ -451,9 +492,10 @@ class ValvePos(HasInput, MercuryChannel, Drivable):
return self.change('PRES:LOOP:FSET', value) return self.change('PRES:LOOP:FSET', value)
class PressureLoop(PressureSensor, Loop, Drivable): class PressureLoop(HasInput, PressureSensor, Loop, Drivable):
channel_type = 'PRES,AUX' channel_type = 'PRES'
output_module = Attached(ValvePos, mandatory=False) output_module = Attached(ValvePos, mandatory=False)
tolerance = Parameter(default=0.1)
def read_control_active(self): def read_control_active(self):
active = self.query('PRES:LOOP:FAUT', off_on) active = self.query('PRES:LOOP:FAUT', off_on)
@ -467,10 +509,60 @@ class PressureLoop(PressureSensor, Loop, Drivable):
def read_target(self): def read_target(self):
return self.query('PRES:LOOP:PRST') return self.query('PRES:LOOP:PRST')
def set_target(self, target):
"""set the target without switching to manual
might be used by a software loop
"""
self.change('PRES:LOOP:PRST', target)
super().set_target(target)
def write_target(self, value): def write_target(self, value):
target = self.change('PRES:LOOP:PRST', value) self.write_controlled_by(SELF)
self.set_target(target) self.set_target(value)
return self.target 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 value:
if self.needle_valve and self.needle_valve.controlled_by != self.name:
self.needle_valve.write_controlled_by(self.name)
else:
if self.needle_valve and self.needle_valve.controlled_by != SELF:
self.needle_valve.write_controlled_by(SELF)
_, (fmin, _) = self.flowpars
self.needle_valve.write_target(fmin)
return value
def auto_flow_off(self):
if self.auto_flow:
self.log.warning('switch auto flow off')
self.write_auto_flow(False)
class TemperatureAutoFlow(HasAutoFlow, TemperatureLoop):
pass
class HeLevel(MercuryChannel, Readable): class HeLevel(MercuryChannel, Readable):
@ -480,6 +572,7 @@ class HeLevel(MercuryChannel, Readable):
(when filling) and slow (when consuming). We have to handle this by software. (when filling) and slow (when consuming). We have to handle this by software.
""" """
channel_type = 'LVL' channel_type = 'LVL'
value = Parameter(unit='%')
sample_rate = Parameter('_', EnumType(slow=0, fast=1), readonly=False) sample_rate = Parameter('_', EnumType(slow=0, fast=1), readonly=False)
hysteresis = Parameter('hysteresis for detection of increase', FloatRange(0, 100, unit='%'), hysteresis = Parameter('hysteresis for detection of increase', FloatRange(0, 100, unit='%'),
default=5, readonly=False) default=5, readonly=False)
@ -533,9 +626,7 @@ class HeLevel(MercuryChannel, Readable):
class N2Level(MercuryChannel, Readable): class N2Level(MercuryChannel, Readable):
channel_type = 'LVL' channel_type = 'LVL'
value = Parameter(unit='%')
def read_value(self): def read_value(self):
return self.query('LVL:SIG:NIT:LEV') return self.query('LVL:SIG:NIT:LEV')
# TODO: magnet power supply