From bbe70fb3cba3b631c5be780d575972936ce252df Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Wed, 3 May 2023 11:24:47 +0200 Subject: [PATCH] add missing files from secop_psi - all of them have to be checked! Change-Id: I89d55ca683d0b2710222f14c2c3cd42f8fbf3a1f --- frappy_psi/adq_mr.py | 294 ++++++++++++++++ frappy_psi/attocube.py | 264 ++++++++++++++ frappy_psi/cryoltd.py | 584 +++++++++++++++++++++++++++++++ frappy_psi/cryotel.py | 144 ++++++++ frappy_psi/dg645.py | 62 ++++ frappy_psi/dilsc.py | 97 ++++++ frappy_psi/dpm.py | 125 +++++++ frappy_psi/ips_mercury.py | 329 ++++++++++++++++++ frappy_psi/iqplot.py | 102 ++++++ frappy_psi/ls240.py | 105 ++++++ frappy_psi/ls340res.py | 45 +++ frappy_psi/ls370sim.py | 75 ++++ frappy_psi/ls372.py | 329 ++++++++++++++++++ frappy_psi/magfield.py | 381 +++++++++++++++++++++ frappy_psi/phytron.py | 254 ++++++++++++++ frappy_psi/sea.py | 704 ++++++++++++++++++++++++++++++++++++++ frappy_psi/senis.py | 307 +++++++++++++++++ frappy_psi/simdpm.py | 49 +++ frappy_psi/triton.py | 320 +++++++++++++++++ frappy_psi/ultrasound.py | 261 ++++++++++++++ frappy_psi/uniax.py | 432 +++++++++++++++++++++++ frappy_psi/vector.py | 89 +++++ 22 files changed, 5352 insertions(+) create mode 100644 frappy_psi/adq_mr.py create mode 100644 frappy_psi/attocube.py create mode 100644 frappy_psi/cryoltd.py create mode 100644 frappy_psi/cryotel.py create mode 100644 frappy_psi/dg645.py create mode 100644 frappy_psi/dilsc.py create mode 100644 frappy_psi/dpm.py create mode 100644 frappy_psi/ips_mercury.py create mode 100644 frappy_psi/iqplot.py create mode 100644 frappy_psi/ls240.py create mode 100644 frappy_psi/ls340res.py create mode 100644 frappy_psi/ls370sim.py create mode 100644 frappy_psi/ls372.py create mode 100644 frappy_psi/magfield.py create mode 100644 frappy_psi/phytron.py create mode 100644 frappy_psi/sea.py create mode 100644 frappy_psi/senis.py create mode 100644 frappy_psi/simdpm.py create mode 100644 frappy_psi/triton.py create mode 100644 frappy_psi/ultrasound.py create mode 100644 frappy_psi/uniax.py create mode 100644 frappy_psi/vector.py diff --git a/frappy_psi/adq_mr.py b/frappy_psi/adq_mr.py new file mode 100644 index 0000000..d0e9fc1 --- /dev/null +++ b/frappy_psi/adq_mr.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +""" +Created on Tue Nov 26 15:42:43 2019 + +@author: tartarotti_d-adm +""" + + +import numpy as np +import ctypes as ct +import time +from numpy import sqrt, arctan2, sin, cos + +#from pylab import * + +from scipy import signal + +#ADQAPI = ct.cdll.LoadLibrary("ADQAPI.dll") +ADQAPI = ct.cdll.LoadLibrary("libadq.so.0") + +#For different trigger modes +SW_TRIG = 1 +EXT_TRIG_1 = 2 #This external trigger does not work if the level of the trigger is very close to 0.5V. Now we have it close to 3V, and it works +EXT_TRIG_2 = 7 +EXT_TRIG_3 = 8 +LVL_TRIG = 3 +INT_TRIG = 4 +LVL_FALLING = 0 +LVL_RISING = 1 + +#samples_per_record=16384 +ADQ_TRANSFER_MODE_NORMAL = 0x00 +ADQ_CHANNELS_MASK = 0x3 + +#f_LO = 40 + +def butter_lowpass(cutoff, sr, order=5): + nyq = 0.5 * sr + normal_cutoff = cutoff / nyq + b, a = signal.butter(order, normal_cutoff, btype = 'low', analog = False) + return b, a + + +class Adq(object): + max_number_of_channels = 2 + samp_freq = 2 + #ndecimate = 50 # decimation ratio (2GHz / 40 MHz) + ndecimate = 50 + + def __init__(self, number_of_records, samples_per_record, bw_cutoff): + self.number_of_records = number_of_records + self.samples_per_record = samples_per_record + self.bw_cutoff = bw_cutoff + ADQAPI.ADQAPI_GetRevision() + + # Manually set return type from some ADQAPI functions + ADQAPI.CreateADQControlUnit.restype = ct.c_void_p + ADQAPI.ADQ_GetRevision.restype = ct.c_void_p + ADQAPI.ADQ_GetPtrStream.restype = ct.POINTER(ct.c_int16) + ADQAPI.ADQControlUnit_FindDevices.argtypes = [ct.c_void_p] + # Create ADQControlUnit + self.adq_cu = ct.c_void_p(ADQAPI.CreateADQControlUnit()) + ADQAPI.ADQControlUnit_EnableErrorTrace(self.adq_cu, 3, '.') + self.adq_num = 1 + + # Find ADQ devices + ADQAPI.ADQControlUnit_FindDevices(self.adq_cu) + n_of_ADQ = ADQAPI.ADQControlUnit_NofADQ(self.adq_cu) + if n_of_ADQ != 1: + raise ValueError('number of ADQs must be 1, not %d' % n_of_ADQ) + + rev = ADQAPI.ADQ_GetRevision(self.adq_cu, self.adq_num) + revision = ct.cast(rev,ct.POINTER(ct.c_int)) + print('\nConnected to ADQ #1') + # Print revision information + print('FPGA Revision: {}'.format(revision[0])) + if (revision[1]): + print('Local copy') + else : + print('SVN Managed') + if (revision[2]): + print('Mixed Revision') + else : + print('SVN Updated') + print('') + + ADQ_CLOCK_INT_INTREF = 0 #internal clock source + ADQ_CLOCK_EXT_REF = 1 #internal clock source, external reference + ADQ_CLOCK_EXT_CLOCK = 2 #External clock source + ADQAPI.ADQ_SetClockSource(self.adq_cu, self.adq_num, ADQ_CLOCK_EXT_REF); + + ########################## + # Test pattern + #ADQAPI.ADQ_SetTestPatternMode(self.adq_cu, self.adq_num, 4) + ########################## + # Sample skip + #ADQAPI.ADQ_SetSampleSkip(self.adq_cu, self.adq_num, 1) + ########################## + + # Set trig mode + self.trigger = EXT_TRIG_1 + #trigger = LVL_TRIG + success = ADQAPI.ADQ_SetTriggerMode(self.adq_cu, self.adq_num, self.trigger) + if (success == 0): + print('ADQ_SetTriggerMode failed.') + if (self.trigger == LVL_TRIG): + success = ADQAPI.ADQ_SetLvlTrigLevel(self.adq_cu, self.adq_num, -100) + if (success == 0): + print('ADQ_SetLvlTrigLevel failed.') + success = ADQAPI.ADQ_SetTrigLevelResetValue(self.adq_cu, self.adq_num, 1000) + if (success == 0): + print('ADQ_SetTrigLevelResetValue failed.') + success = ADQAPI.ADQ_SetLvlTrigChannel(self.adq_cu, self.adq_num, 1) + if (success == 0): + print('ADQ_SetLvlTrigChannel failed.') + success = ADQAPI.ADQ_SetLvlTrigEdge(self.adq_cu, self.adq_num, LVL_RISING) + if (success == 0): + print('ADQ_SetLvlTrigEdge failed.') + elif (self.trigger == EXT_TRIG_1) : + success = ADQAPI.ADQ_SetExternTrigEdge(self.adq_cu, self.adq_num,2) + if (success == 0): + print('ADQ_SetLvlTrigEdge failed.') + # success = ADQAPI.ADQ_SetTriggerThresholdVoltage(self.adq_cu, self.adq_num, trigger, ct.c_double(0.2)) + # if (success == 0): + # print('SetTriggerThresholdVoltage failed.') + print("CHANNEL:"+str(ct.c_int(ADQAPI.ADQ_GetLvlTrigChannel(self.adq_cu, self.adq_num)))) + self.setup_target_buffers() + + def setup_target_buffers(self): + # Setup target buffers for data + self.target_buffers=(ct.POINTER(ct.c_int16 * self.samples_per_record * self.number_of_records) + * self.max_number_of_channels)() + for bufp in self.target_buffers: + bufp.contents = (ct.c_int16 * self.samples_per_record * self.number_of_records)() + + def deletecu(self): + # Only disarm trigger after data is collected + ADQAPI.ADQ_DisarmTrigger(self.adq_cu, self.adq_num) + ADQAPI.ADQ_MultiRecordClose(self.adq_cu, self.adq_num); + # Delete ADQControlunit + ADQAPI.DeleteADQControlUnit(self.adq_cu) + + def start(self): + """start datat acquisition""" + # samples_per_records = samples_per_record/number_of_records + # Change number of pulses to be acquired acording to how many records are taken + # Start acquisition + ADQAPI.ADQ_MultiRecordSetup(self.adq_cu, self.adq_num, + self.number_of_records, + self.samples_per_record) + + ADQAPI.ADQ_DisarmTrigger(self.adq_cu, self.adq_num) + ADQAPI.ADQ_ArmTrigger(self.adq_cu, self.adq_num) + + def getdata(self): + """wait for aquisition to be finished and get data""" + #start = time.time() + while(ADQAPI.ADQ_GetAcquiredAll(self.adq_cu,self.adq_num) == 0): + time.sleep(0.001) + #if (self.trigger == SW_TRIG): + # ADQAPI.ADQ_SWTrig(self.adq_cu, self.adq_num) + #mid = time.time() + status = ADQAPI.ADQ_GetData(self.adq_cu, self.adq_num, self.target_buffers, + self.samples_per_record * self.number_of_records, 2, + 0, self.number_of_records, ADQ_CHANNELS_MASK, + 0, self.samples_per_record, ADQ_TRANSFER_MODE_NORMAL); + #print(time.time()-mid,mid-start) + if not status: + raise ValueError('no succesS from ADQ_GetDATA') + # Now this is an array with all records, but the time is artificial + data = [] + for ch in range(2): + onedim = np.frombuffer(self.target_buffers[ch].contents, dtype=np.int16) + data.append(onedim.reshape(self.number_of_records, self.samples_per_record) / float(2**14)) # 14 bits ADC + return data + + def acquire(self): + self.start() + return self.getdata() + ''' + def average(self, data): + #Average over records + return [data[ch].sum(axis=0) / self.number_of_records for ch in range(2)] + + def iq(self, channel, f_LO): + newx = np.linspace(0, self.samples_per_record /2, self.samples_per_record) + s0 = channel /((2**16)/2)*0.5*np.exp(1j*2*np.pi*f_LO/(1e3)*newx) + I0 = s0.real + Q0 = s0.imag + return I0, Q0 + + + def fitting(self, data, f_LO, ti, tf): + # As long as data[0] is the pulse + si = 2*ti #Those are for fitting the pulse + sf = 2*tf + phase = np.zeros(self.number_of_records) + amplitude = np.zeros(self.number_of_records) + offset = np.zeros(self.number_of_records) + + for i in range(self.number_of_records): + phase[i], amplitude[i] = sineW(data[0][i][si:sf],f_LO*1e-9,ti,tf) + offset[i] = np.average(data[0][i][si:sf]) + return phase, amplitude, offset + + + def waveIQ(self, channel,ti,f_LO): + #channel is not the sample data + t = np.linspace(0, self.samples_per_record /2, self.samples_per_record + 1)[:-1] + si = 2*ti # Again that is where the wave pulse starts + cwi = np.zeros((self.number_of_records,self.samples_per_record)) + cwq = np.zeros((self.number_of_records,self.samples_per_record)) + iq = np.zeros((self.number_of_records,self.samples_per_record)) + q = np.zeros((self.number_of_records,self.samples_per_record)) + for i in range(self.number_of_records): + cwi[i] = np.zeros(self.samples_per_record) + cwq[i] = np.zeros(self.samples_per_record) + cwi[i] = amplitude[i]*sin(t[si:]*f_LO*1e-9*2*np.pi+phase[i]*np.pi/180)+bias[i] + cwq[i] = amplitude[i]*sin(t[si:]*f_LO*1e-9*(2*np.pi+(phase[i]+90)*np.pi/180))+bias[i] + + iq[i] = channel[i]*cwi[i] + q[i] = channel[i]*cwq[i] + + return iq,q + ''' + def sinW(self,sig,freq,ti,tf): + # sig: signal array + # freq + # ti, tf: initial and end time + si = int(ti * self.samp_freq) + nperiods = freq * (tf - ti) + n = int(round(max(2, int(nperiods)) / nperiods * (tf-ti) * self.samp_freq)) + self.nperiods = n + t = np.arange(si, len(sig)) / self.samp_freq + t = t[:n] + self.pulselen = n / self.samp_freq + sig = sig[si:si+n] + a = 2*np.sum(sig*np.cos(2*np.pi*freq*t))/len(sig) + b = 2*np.sum(sig*np.sin(2*np.pi*freq*t))/len(sig) + return a, b + + def mix(self, sigin, sigout, freq, ti, tf): + # sigin, sigout: signal array, incomping, output + # freq + # ti, tf: initial and end time if sigin + a, b = self.sinW(sigin, freq, ti, tf) + phase = arctan2(a,b) * 180 / np.pi + amp = sqrt(a**2 + b**2) + a, b = a/amp, b/amp + #si = int(ti * self.samp_freq) + t = np.arange(len(sigout)) / self.samp_freq + wave1 = sigout * (a * cos(2*np.pi*freq*t) + b * sin(2*np.pi*freq*t)) + wave2 = sigout * (a * sin(2*np.pi*freq*t) - b * cos(2*np.pi*freq*t)) + return wave1, wave2 + + def averageiq(self, data, freq, ti, tf): + '''Average over records''' + iorq = np.array([self.mix(data[0][i], data[1][i], freq, ti, tf) for i in range(self.number_of_records)]) +# iorq = np.array([self.mix(data[0][:], data[1][:], freq, ti, tf)]) + return iorq.sum(axis=0) / self.number_of_records + + def filtro(self, iorq, cutoff): + b, a = butter_lowpass(cutoff, self.samp_freq*1e9) + + #ifi = np.array(signal.filtfilt(b,a,iorq[0])) + #qf = np.array(signal.filtfilt(b,a,iorq[1])) + iqf = [signal.filtfilt(b,a,iorq[i]) for i in np.arange(len(iorq))] + + return iqf + + def box(self, iorq, ti, tf): + si = int(self.samp_freq * ti) + sf = int(self.samp_freq * tf) + bxa = [sum(iorq[i][si:sf])/(sf-si) for i in np.arange(len(iorq))] + return bxa + + def gates_and_curves(self, data, freq, pulse, roi): + """return iq values of rois and prepare plottable curves for iq""" + times = [] + times.append(('aviq', time.time())) + iq = self.averageiq(data,freq*1e-9,*pulse) + times.append(('filtro', time.time())) + iqf = self.filtro(iq,self.bw_cutoff) + m = len(iqf[0]) // self.ndecimate + times.append(('iqdec', time.time())) + iqd = np.average(np.resize(iqf, (2, m, self.ndecimate)), axis=2) + t_axis = np.arange(m) * self.ndecimate / self.samp_freq + pulsig = np.abs(data[0][0]) + times.append(('pulsig', time.time())) + pulsig = np.average(np.resize(pulsig, (m, self.ndecimate)), axis=1) + self.curves = (t_axis, iqd[0], iqd[1], pulsig) + #print(times) + return [self.box(iqf,*r) for r in roi] + diff --git a/frappy_psi/attocube.py b/frappy_psi/attocube.py new file mode 100644 index 0000000..c923fca --- /dev/null +++ b/frappy_psi/attocube.py @@ -0,0 +1,264 @@ +# ***************************************************************************** +# 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 sys +import time +from frappy.core import Drivable, Parameter, Command, Property, ERROR, WARN, BUSY, IDLE, Done, nopoll +from frappy.features import HasTargetLimits, HasSimpleOffset +from frappy.datatypes import IntRange, FloatRange, StringType, BoolType +from frappy.errors import ConfigError, BadValueError +sys.path.append('/home/l_samenv/Documents/anc350/Linux64/userlib/lib') +from PyANC350v4 import Positioner + + +DIRECTION_NAME = {1: 'forward', -1: 'backward'} + + +class FreezeStatus: + """freeze status for some time + + hardware quite often does not treat status correctly: on a target change it + may take some time to return the 'busy' status correctly. + + in classes with this mixin, within :meth:`write_target` call + + self.freeze_status(0.5, BUSY, 'changed target') + + a wrapper around read_status will take care that the status will be the given value, + for at least the given delay. This does NOT cover the case when self.status is set + directly from an other method. + """ + __freeze_status_until = 0 + + def __init_subclass__(cls): + def wrapped(self, inner=cls.read_status): + if time.time() < self.__freeze_status_until: + return Done + return inner(self) + + cls.read_status = wrapped + super().__init_subclass__() + + def freeze_status(self, delay, code=BUSY, text='changed target'): + """freezze status to the given value for the given delay""" + self.__freeze_status_until = time.time() + delay + self.status = code, text + + +class Axis(HasTargetLimits, FreezeStatus, Drivable): + axis = Property('axis number', IntRange(0, 2), 0) + value = Parameter('axis position', FloatRange(unit='deg')) + frequency = Parameter('frequency', FloatRange(1, unit='Hz'), readonly=False) + amplitude = Parameter('amplitude', FloatRange(0, unit='V'), readonly=False) + gear = Parameter('gear factor', FloatRange(), readonly=False, default=1, initwrite=True) + tolerance = Parameter('positioning tolerance', FloatRange(0, unit='$'), readonly=False, default=0.01) + output = Parameter('enable output', BoolType(), readonly=False) + info = Parameter('axis info', StringType()) + statusbits = Parameter('status bits', StringType()) + + _hw = Positioner() + _scale = 1 # scale for custom units + _move_steps = 0 # number of steps to move (used by move command) + SCALES = {'deg': 1, 'm': 1, 'mm': 1000, 'um': 1000000, 'µm': 1000000} + _direction = 1 # move direction + _idle_status = IDLE, '' + _error_state = '' # empty string: no error + _history = None + _check_sensor = False + _try_count = 0 + + def __init__(self, name, logger, opts, srv): + unit = opts.pop('unit', 'deg') + opts['value.unit'] = unit + try: + self._scale = self.SCALES[unit] * opts.get('gear', 1) + except KeyError as e: + raise ConfigError('unsupported unit: %s' % unit) + super().__init__(name, logger, opts, srv) + + def write_gear(self, value): + self._scale = self.SCALES[self.parameters['value'].datatype.unit] * self.gear + return value + + def startModule(self, start_events): + super().startModule(start_events) + start_events.queue(self.read_info) + + def check_value(self, value): + """check if value allows moving in current direction""" + if self._direction > 0: + if value > self.target_limits[1]: + raise BadValueError('above upper limit') + elif value < self.target_limits[0]: + raise BadValueError('below lower limit') + + def read_value(self): + pos = self._hw.getPosition(self.axis) * self._scale + if self.isBusy(): + try: + self.check_value(pos) + except BadValueError as e: + self._stop() + self._idle_status = ERROR, str(e) + return pos + + def read_frequency(self): + return self._hw.getFrequency(self.axis) + + def write_frequency(self, value): + self._hw.setFrequency(self.axis, value) + return self._hw.getFrequency(self.axis) + + def read_amplitude(self): + return self._hw.getAmplitude(self.axis) + + def write_amplitude(self, value): + self._hw.setAmplitude(self.axis, value) + return self._hw.getAmplitude(self.axis) + + def write_tolerance(self, value): + self._hw.setTargetRange(self.axis, value / self._scale) + return value + + def write_output(self, value): + self._hw.setAxisOutput(self.axis, enable=value, autoDisable=0) + return value + + def read_status(self): + statusbits = self._hw.getAxisStatus(self.axis) + sensor, self.output, moving, attarget, eot_fwd, eot_bwd, sensor_error = statusbits + self.statusbits = ''.join((k for k, v in zip('SOMTFBE', statusbits) if v)) + if self._move_steps: + if not (eot_fwd or eot_bwd): + return BUSY, 'moving by steps' + if not sensor: + self._error_state = 'no sensor connected' + elif sensor_error: + self._error_state = 'sensor error' + elif eot_fwd: + self._error_state = 'end of travel forward' + elif eot_bwd: + self._error_state = 'end of travel backward' + else: + if self._error_state and not DIRECTION_NAME[self._direction] in self._error_state: + self._error_state = '' + status_text = 'moving' if self._try_count == 0 else 'moving (retry %d)' % self._try_count + if moving and self._history is not None: # history None: moving by steps + self._history.append(self.value) + if len(self._history) < 5: + return BUSY, status_text + beg = self._history.pop(0) + if abs(beg - self.target) < self.tolerance: + # reset normal tolerance + self._stop() + self._idle_status = IDLE, 'in tolerance' + return self._idle_status + # self._hw.setTargetRange(self.axis, self.tolerance / self._scale) + if (self.value - beg) * self._direction > 0: + return BUSY, status_text + self._try_count += 1 + if self._try_count < 10: + self.log.warn('no progress retry %d', self._try_count) + return BUSY, status_text + self._idle_status = WARN, 'no progress' + if self._error_state: + self._try_count += 1 + if self._try_count < 10 and self._history is not None: + self.log.warn('end of travel retry %d', self._try_count) + self.write_target(self.target) + return Done + self._idle_status = WARN, self._error_state + if self.status[0] != IDLE: + self._stop() + return self._idle_status + + def write_target(self, value): + if value == self.read_value(): + return value + self.check_limits(value) + self._try_count = 0 + self._direction = 1 if value > self.value else -1 + # if self._error_state and DIRECTION_NAME[-self._direction] not in self._error_state: + # raise BadValueError('can not move (%s)' % self._error_state) + self._move_steps = 0 + self.write_output(1) + # try first with 50 % of tolerance + self._hw.setTargetRange(self.axis, self.tolerance * 0.5 / self._scale) + for itry in range(5): + try: + self._hw.setTargetPosition(self.axis, value / self._scale) + self._hw.startAutoMove(self.axis, enable=1, relative=0) + except Exception as e: + if itry == 4: + raise + self.log.warn('%r', e) + self._history = [self.value] + self._idle_status = IDLE, '' + self.freeze_status(1, BUSY, 'changed target') + self.setFastPoll(True, 1) + return value + + def doPoll(self): + if self._move_steps == 0: + super().doPoll() + return + self._hw.startSingleStep(self.axis, self._direction < 0) + self._move_steps -= self._direction + if self._move_steps % int(self.frequency) == 0: # poll value and status every second + super().doPoll() + + @nopoll + def read_info(self): + """read info from controller""" + cap = self._hw.measureCapacitance(self.axis) * 1e9 + axistype = ['linear', 'gonio', 'rotator'][self._hw.getActuatorType(self.axis)] + return '%s %s %.3gnF' % (self._hw.getActuatorName(self.axis), axistype, cap) + + def _stop(self): + self._move_steps = 0 + self._history = None + for _ in range(5): + try: + self._hw.startAutoMove(self.axis, enable=0, relative=0) + break + except Exception as e: + if itry == 4: + raise + self.log.warn('%r', e) + self._hw.setTargetRange(self.axis, self.tolerance / self._scale) + self.setFastPoll(False) + + @Command() + def stop(self): + self._idle_status = IDLE, 'stopped' if self.isBusy() else '' + self._stop() + self.status = self._idle_status + + @Command(IntRange()) + def move(self, value): + """relative move by number of steps""" + self._direction = 1 if value > 0 else -1 + self.check_value(self.value) + self._history = None + if DIRECTION_NAME[self._direction] in self._error_state: + raise BadValueError('can not move (%s)' % self._error_state) + self._move_steps = value + self._idle_status = IDLE, '' + self.read_status() + self.setFastPoll(True, 1/self.frequency) diff --git a/frappy_psi/cryoltd.py b/frappy_psi/cryoltd.py new file mode 100644 index 0000000..605cdff --- /dev/null +++ b/frappy_psi/cryoltd.py @@ -0,0 +1,584 @@ +# -*- 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 +# ***************************************************************************** + +"""flame magnet using cryogenic limited software suite + +Remarks: for reading, we use the GetAll command, which is quite fast +(3 ms response time). However, as in many such sloppy programmed software +the timing is badly handled, as after changing parameters, the readback values +are not yet up to date. We use the array block_until for this purpose: any value +changed from the client is fixed for at least 10 seconds. +""" +import re +import time +from frappy.core import HasIO, StringIO, Readable, Drivable, Parameter, Command, \ + Module, Property, Attached, Enum, IDLE, BUSY, ERROR, Done +from frappy.errors import ConfigError, BadValueError, HardwareError +from frappy.datatypes import FloatRange, StringType, EnumType, StructOf +from frappy.states import HasStates, status_code, Retry +from frappy.features import HasTargetLimits +import frappy_psi.magfield as magfield + +# floating point value followed with unit +VALUE_UNIT = re.compile(r'([-0-9.E]*\d|inf)([A-Za-z/%]*)$') + + +def as_float(value): + """converts string (with unit) to float""" + return float(VALUE_UNIT.match(value).group(1)) + + +BOOL_MAP = {'TRUE': True, 'FALSE': False} + + +def as_bool(value): + return BOOL_MAP[value] + + +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] + + +class IO(StringIO): + identification = [('Get:Remote:Remote_Ready', 'Remote_Ready RECEIVED: TRUE')] + end_of_line = (b'\n', b'\r\n') + timeout = 15 + + +class Main(HasIO, Module): + pollinterval = Parameter('main poll interval', FloatRange(0.001), default=1) + export = False + params_map = None + triggers = None + initpolls = None + ioClass = IO + + def register_module(self, obj, **params): + """register a channel + + :param obj: the remote object + :param **params: + pname= or pname=(, convert function) + the convert function is as_float by default + """ + if self.params_map is None: + self.params_map = {} + self.triggers = set() + obj.block_until = {} + if hasattr(obj, 'trigger_update'): + self.triggers.add(obj.trigger_update) + for pname, value in params.items(): + if isinstance(value, str): + key, cvt = value, as_float + else: + key, cvt = value + self.params_map[key.replace('', obj.channel)] = obj, pname, cvt + + def doPoll(self): + # format of reply: + # "key1:value1;key2:value2;" + reply = self.communicate('GetAll').split('RECEIVED ', 1)[1].split(';') + missing = None, None, None + for line in reply: + try: + key, value = line.split(':', 1) + except ValueError: # missing ':' + if line: + pass + # ignore multiline values + # if needed, we may collect here and treat with a special key + continue + obj, pname, cvt = self.params_map.get(key, missing) + if obj: + if not hasattr(obj, pname): + raise ConfigError('%s has no attribute %s' % (obj.name, pname)) + if time.time() > obj.block_until.get(pname, 0): + setattr(obj, pname, cvt(value)) + for trigger in self.triggers: # do trigger after updates + trigger() + + def communicate(self, cmd): + return self.io.communicate(cmd) + + +class Channel: + main = Attached(Main, default='main') + channel = Property('channel name', StringType()) + pollinterval = Parameter(export=False) + block_until = None + + def sendcmd(self, cmd): + cmd = cmd.replace('', self.channel) + reply = self.main.communicate(cmd) + if not reply.startswith(cmd): + self.log.warn('reply %r does not match command %r', reply, cmd) + + def block(self, pname, value=None, delay=10): + self.block_until[pname] = time.time() + delay + if value is not None: + setattr(self, pname, value) + + def doPoll(self): + """poll is done by main module""" + + +class Temperature(Channel, Readable): + channel = Property('channel name as in reply to GetAll', StringType()) + value = Parameter('T sensor value', FloatRange(0, unit='K')) + description = '' # by default take description from channel name + + KEYS = { + '1st Stage A', + '2nd Stage A', + '1st Stage B', + '2nd Stage B', + 'Inner Magnet A (Top)', + 'Inner Magnet A (Bottom)', + 'Z Shim Former', + 'XY Shim Former', + 'XY Vector Former', + 'Radiation Shield', + 'Persistent Joints', + 'Outer Magnet A', + 'Inner Magnet B (Top)', + 'Inner Magnet B (Bottom)', + 'Z Shim Former B', + 'Outer Magnet B', + 'Bore Radiation Shield', + 'XYZ Shim Plate', + 'Z Shim Switch', + 'Main Coil Switch', + } + + def initModule(self): + super().initModule() + if not self.description: + self.description = self.channel + if self.channel not in self.KEYS: + raise ConfigError('channel (%s) must be one of %r' % (self.channel, self.KEYS)) + self.main.register_module(self, value=self.channel) + + +CsMode = Enum( + PERSISTENT=1, + SEMIPERSISTENT=2, + DRIVEN=0, +) + +PersistencyMode = Enum( + DISABLED=0, + PERSISTENT=30, + SEMIPERSISTENT=31, + DRIVEN=50, +) + + +class BaseMagfield(HasStates, HasTargetLimits, Channel): + _status_text = '' + _ready_text = '' + _error_text = '' + _last_error = '' + _rate_units = '' + hw_units = Property('hardware units: A or T', EnumType(A=0, T=1)) + A_to_G = Property('A_to_G = "Gauss Value / Amps Value"', FloatRange(0)) + tolerance = Parameter('tolerance', FloatRange(0), readonly=False, default=1) + + def earlyInit(self): + super().earlyInit() + dt = self.parameters['target'].datatype + if dt.min < 1e-99: # make limits symmetric + dt.min = - dt.max + min_, max_ = self.target_limits + self.target_limits = [max(min_, dt.min), min(max_, dt.max)] + dt = self.parameters['ramp'].datatype + if self.ramp == 0: # unconfigured: take max. + self.ramp = dt.max + if not isinstance(self, magfield.Magfield): + # add unneeded attributes, as the appear in GetAll + self.switch_heater = None + self.current = None + + def doPoll(self): + super().doPoll() # does not call cycle_machine + self.cycle_machine() + + def to_gauss(self, value): + value, unit = VALUE_UNIT.match(value).groups() + if unit == 'T': + return float(value) * 10000 + if unit == 'A': + return float(value) * self.A_to_G + raise HardwareError('received unknown unit: %s' % unit) + + def to_gauss_min(self, value): + value, unit = VALUE_UNIT.match(value).groups() + if unit == 'A/s': + return float(value) * self.A_to_G * 60.0 + if unit == 'T/s': + return float(value) * 10000 * 60 + if unit == 'T/min': + return float(value) * 10000 + if unit == 'T/h': + return float(value) * 10000 / 60.0 + raise HardwareError('received unknown unit: %s' % unit) + + def initModule(self): + super().initModule() + self.main.register_module( + self, + value=('_Field', self.to_gauss), + _status_text=('_Status', str), + _ready_text=('_Ready', str), + _error_text=('_Error', self.cvt_error), + _rate_units=('_Rate Units', str), + current=('_PSU Output', self.to_gauss), + voltage='_Voltage', + workingramp=('_Ramp Rate', self.to_gauss_min), + setpoint=('_Setpoint', self.to_gauss), + switch_heater=('_Heater', self.cvt_switch_heater), + cs_mode=('_Persistent Mode', self.cvt_cs_mode), + approach_mode=('_Approach', self.cvt_approach_mode), + ) + + def cvt_error(self, text): + if text != self._last_error: + self._last_error = text + self.log.error(text) + return text + return self._error_text + + def trigger_update(self): + # called after treating result of GetAll message + if self._error_text: + if self.status[0] == ERROR: + errtxt = self._error_text + else: + errtxt = '%s while %s' % (self._error_text, self.status[1]) + # self.stop_machine((ERROR, errtxt)) + + value = Parameter('magnetic field in the coil', FloatRange(unit='G')) + + setpoint = Parameter('setpoint', FloatRange(unit='G'), default=0) + ramp = Parameter() + target = Parameter() + + def write_ramp(self, ramp): + if self._rate_units != 'A/s': + self.sendcmd('Set::ChangeRateUnits A/s') + self.sendcmd('Set::SetRate %g' % (ramp / self.A_to_G / 60.0)) + return ramp + + def write_target(self, target): + self.reset_error() + super().write_target(target) + return Done + + def start_sweep(self, target): + if self.approach_mode == self.approach_mode.OVERSHOOT: + o = self.overshoot['o'] + if (target - self.value) * o < 0: + self.write_overshoot(dict(self.overshoot, o=-o)) + self.write_ramp(self.ramp) + if self.hw_units == 'A': + self.sendcmd('Set::Sweep %gA' % (target / self.A_to_G)) + else: + self.sendcmd('Set::Sweep %gT' % (target / 10000)) + self.block('_ready_text', 'FALSE') + + def start_field_change(self, sm): + if self._ready_text == 'FALSE': + self.sendcmd('Set::Abort') + return self.wait_ready + return super().start_field_change + + def wait_ready(self, sm): + if self._ready_text == 'FALSE': + return Retry + return super().start_field_change + + def start_ramp_to_target(self, sm): + self.start_sweep(sm.target) + return self.ramp_to_target # -> stabilize_field + + def stabilize_field(self, sm): + if self._ready_text == 'FALSE': + # wait for overshoot/degauss/cycle + sm.stabilize_start = sm.now + return Retry + if sm.now - sm.stabilize_start < self.wait_stable_field: + return Retry + return self.end_stablilize + + def end_stablilize(self, sm): + return self.final_status() + + cs_mode = Parameter('persistent mode', EnumType(CsMode), readonly=False, default=0) + + @staticmethod + def cvt_cs_mode(text): + text = text.lower() + if 'off' in text: + if '0' in text: + return CsMode.PERSISTENT + return CsMode.SEMIPERSISTENT + return CsMode.DRIVEN + + def write_cs_mode(self, value): + self.sendcmd('Set::SetPM %d' % int(value)) + self.block('cs_mode') + return value + + ApproachMode = Enum( + DIRECT=0, + OVERSHOOT=1, + CYCLING=2, + DEGAUSSING=3, + ) + approach_mode = Parameter('approach mode', EnumType(ApproachMode), readonly=False, + group='approach_settings', default=0) + + def write_approach_mode(self, value): + self.sendcmd('Set::SetApproach %d' % value) + self.block('approach_mode') + return value + + @classmethod + def cvt_approach_mode(cls, text): + return cls.ApproachMode(text.upper()) + + overshoot = Parameter('overshoot [%] and hold time [s]', + StructOf(o=FloatRange(-100, 100, unit='%'), t=FloatRange(0, unit='s')), + readonly=False, default=dict(o=0, t=0), + group='approach_settings') + + def write_overshoot(self, value): + self.sendcmd('Set::SetOvershoot %g,%g' % (value['o'], value['t'])) + return value + + cycle = Parameter('start value, damping factor, final value, hold time', + StructOf(s=FloatRange(-100, 100, unit='%'), + d=FloatRange(0, 100, unit='%'), + a=FloatRange(0, 100, unit='G'), + t=FloatRange(0, unit='s')), + readonly=False, default=dict(s=0, d=0, a=0, t=0), + group='approach_settings') + + def write_cycle(self, value): + self.sendcmd('Set::SetCycling %g,%g,%g,%g' % + (value['s'], value['d'], value['a'] * 1e-4, value['t'])) + return value + + degauss = Parameter('start value [G], damping factor [%], accuracy [G], hold time [s]', + StructOf(s=FloatRange(-10000, 10000, unit='G'), + d=FloatRange(0, 100, unit='%'), + f=FloatRange(0, 10000, unit='G'), + t=FloatRange(0, unit='s')), + readonly=False, default=dict(s=0, d=0, f=0, t=0), + group='approach_settings') + + def write_degauss(self, value): + self.sendcmd('Set::SetDegaussing %g,%g,%g,%g' % + (value['s'] * 1e-4, value['d'], value['f'] * 1e-4, value['t'])) + return value + + voltage = Parameter( + 'voltage over leads', FloatRange(unit='V')) + + @staticmethod + def cvt_switch_heater(text): + return 'ON' in text + + @Command() + def stop(self): + self.sendcmd('Set::Abort') + + @Command() + def reset_quench(self): + """reset quench condition""" + self.sendcmd('Set::ResetQuench') + + @Command() + def reset_error(self): + """reset error""" + self._error_text = '' + + +class MainField(BaseMagfield, magfield.Magfield): + checked_modules = None + + def earlyInit(self): + super().earlyInit() + self.checked_modules = [] + + def check_limits(self, value): + super().check_limits(value) + self.check_combined(None, 0, value) + + # TODO: turn into a property + constraint = Parameter('product check', FloatRange(unit='G^2'), default=80000) + + def check_combined(self, obj, value, main_target): + sumvalue2 = sum(((value ** 2 if o == obj else o.value ** 2) + for o in self.checked_modules)) + if sumvalue2 * max(self.value ** 2, main_target ** 2) > self.constraint ** 2: + raise BadValueError('outside constraint (B * Bxyz > %g G^2' % self.constraint) + + def check_limits(self, value): + super().check_limits(value) + self.check_combined(None, 0, value) + + mode = Parameter(datatype=EnumType(PersistencyMode)) + + def write_mode(self, mode): + self.reset_error() + super().write_mode(mode) # updates mode + return Done + + @status_code('PREPARING') + def start_ramp_to_field(self, sm): + self.start_sweep(self.value) + return self.ramp_to_field # -> stabilize_current -> start_switch_on + + @status_code('PREPARING') + def start_switch_on(self, sm): + self.write_cs_mode(CsMode.DRIVEN) + # self.block('switch_heater', 1, 60) + self.start_sweep(self.value) + self.block('_ready_text', 'FALSE') + return self.wait_for_switch_on # -> start_ramp_to_target -> ramp_to_target -> stabilize_field + + @status_code('PREPARING') + def wait_for_switch_on(self, sm): + if self._ready_text == 'FALSE': + return Retry + self.last_target(sm.target) + return self.start_ramp_to_target + + def end_stablilize(self, sm): + return self.start_switch_off + + @status_code('FINALIZING') + def start_switch_off(self, sm): + if self.mode == PersistencyMode.DRIVEN: + return self.final_status(IDLE, 'driven') + if self.mode == PersistencyMode.SEMIPERSISTENT: + self.write_cs_mode(CsMode.SEMIPERSISTENT) + else: # PERSISTENT or DISABLED + self.write_cs_mode(CsMode.PERSISTENT) + self.start_sweep(sm.target) + self.block('_ready_text', 'FALSE') + self.block('switch_heater', 1) + return self.wait_for_switch_off # -> start_ramp_to_zero + + @status_code('PREPARING') + def wait_for_switch_off(self, sm): + if self.switch_heater: + return Retry + self.last_target(sm.target) + if self.mode == PersistencyMode.SEMIPERSISTENT: + return self.final_status(IDLE, 'semipersistent') + return self.ramp_to_zero + + @status_code('FINALIZING') + def ramp_to_zero(self, sm): + if self._ready_text == 'FALSE': + return Retry + if self.mode == PersistencyMode.DISABLED: + return self.final_status(PersistencyMode.DISABLED, 'disabled') + return self.final_status(IDLE, 'persistent') + + +class ComponentField(BaseMagfield, magfield.SimpleMagfield): + check_against = Attached(MainField) + # status = Parameter(datatype=EnumType(Drivable.Status, RAMPING=370, STABILIZING=380, FINALIZING=390)) + + def initModule(self): + super().initModule() + self.check_against.checked_modules.append(self) + + def check_limits(self, value): + super().check_limits(value) + self.check_against.check_combined(self, value, 0) + + +class Compressor(Channel, Drivable): + def initModule(self): + super().initModule() + self.main.register_module( + self, + value=('Compressor_Status', self.cvt_value), + _ready_text=('Compressor_Ready', str), + _error_text=('Compressor_Error', str), + run_time='Compressor_RunTime', + ) + # TODO: what is Compressor_Error? (without A or B) + + value = Parameter('compressor switch', EnumType(OFF=0, ON=1)) + run_time = Parameter('run time', FloatRange(0, unit='h')) + + _status_text = '' + _ready_text = '' + _error_text = '' + + def cvt_value(self, text): + self._status_text = text + value = text == 'Running' + if time.time() > self.block_until.get('target', 0): + self.target = value + return value + + def read_value(self): + return self._status_text == 'Running' + + def read_status(self): + if self.target != self.value: + return BUSY, 'switching %s' % self.target.name + # TODO: find out possible status texts + if self._ready_text == 'TRUE': + return IDLE, 'ready' + if self._error_text: + return ERROR, self._error_text + return IDLE, self._status_text + + target = Parameter('compressor switch', EnumType(OFF=0, ON=1)) + + def write_target(self, value): + if value: + self.sendcmd('Set:Compressor:Start ') + else: + self.sendcmd('Set:Compressor:Stop ') + self.block('target') + self.read_status() + return value + + @Command() + def reset_error(self): + """reset error""" + self.sendcmd('Set:Compressor:Reset ') + self._error_text = '' diff --git a/frappy_psi/cryotel.py b/frappy_psi/cryotel.py new file mode 100644 index 0000000..c979f41 --- /dev/null +++ b/frappy_psi/cryotel.py @@ -0,0 +1,144 @@ +# -*- 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 +# +# ***************************************************************************** + +"""driver for cryotel stirling cryocooler""" + +from frappy.core import Command, EnumType, FloatRange, HasIO, Parameter, Drivable, StringIO, StringType +from frappy.errors import CommunicationFailedError, HardwareError + + +class CryotelIO(StringIO): + end_of_line = ('\n', '\r') + # eol_write will be given explicitly + + @Command(StringType(), result=StringType()) + def communicate(self, command): + """send a command and receive a reply + + we receive an echo line first. + in case the command does not contain '=', the effective reply is in a second line + """ + with self._lock: + echo = super().communicate(command) + if echo.strip() != command.strip(): + raise CommunicationFailedError('missing echo') + if '=' in command: + reply = echo + else: + reply = self._conn.readline().decode() + return reply + + +class Cryo(HasIO, Drivable): + value = Parameter('current temperature', FloatRange(unit='deg')) + target = Parameter('target temperature', FloatRange(unit='deg'), readonly=False) + mode = Parameter('control mode', EnumType('mode', off=0, power=1, temperature=2), readonly=False) + power = Parameter('power', FloatRange(unit='W')) + setpower = Parameter('requested power', FloatRange(unit='W'), default=0) + cool_power = Parameter('power for cooling', FloatRange(unit='W'), default=240, readonly=False) + hold_power = Parameter('power for holding T', FloatRange(unit='W'), default=120, readonly=False) + cool_threshold = Parameter('switch to cool_power once above this value', FloatRange(unit='K'), default=100, readonly=False) + hold_threshold = Parameter('switch to hold_power once below this value', FloatRange(unit='K'), default=95, readonly=False) + + ioClass = CryotelIO + cnt_inside = 0 + + def get(self, cmd): + return float(self.communicate(cmd)) + + def set(self, cmd, value, check=False): + setcmd = '%s=%.2f' % (cmd, value) + self.communicate(setcmd) + reply = float(self.communicate(cmd)) + if check: + if value != reply: + raise HardwareError('illegal reply from %s: %g' % (cmd, reply)) + return reply + + def read_value(self): + temp = self.get('TC') + status = self.status + if self.mode == 1: # P reg + setpower = self.setpower + if temp < self.hold_threshold: + setpower = self.hold_power + status = self.Status.IDLE, 'holding' + elif temp > self.cool_threshold: + setpower = self.cool_power + status = self.Status.BUSY, 'cooling' + if abs(setpower - self.setpower) > 0.009: + self.setpower = self.set('SET PWOUT', setpower) + elif self.mode == 2: # T reg + if self.status[0] == 'BUSY': + if abs(temp - self.target) < 1: + self.cnt_inside += 1 + if self.cnt_inside >= 10: + status = self.Status.IDLE, '' + else: + status = self.Status.BUSY, 'settling' + else: + status = self.Status.BUSY, 'outside tolerance' + else: + status = self.Status.IDLE, 'off' + if status != self.status: + self.status = status + return temp + + def read_target(self): + return self.get('SET TTARGET') + + def write_target(self, value): + if abs(value - self.target) > 0.009: + self.status = self.Status.BUSY, 'changed target' + self.cnt_inside = 0 + return self.set('SET TTARGET', value) + + def read_power(self): + return self.get('P') + + def read_setpower(self): + return self.get('SET PWOUT') + + def read_mode(self): + is_stopped = self.get('SET SSTOP') + if is_stopped: + return 0 # off + pidmode = self.get('SET PID') + if pidmode == 2: + return 2 # T reg + return 1 # P reg + + def write_mode(self, value): + if value == 0: + self.set('SET SSTOP', 1, check=True) + self.status = self.Status.IDLE, 'off' + return value + is_stopped = self.get('SET SSTOP') + if is_stopped: + self.set('SET SSTOP', 0, check=True) + if value == 1: + self.set('SET PID', 0, check=True) + self.read_value() + else: + self.set('SET PID', 2, check=True) + self.write_target(self.target) + return value diff --git a/frappy_psi/dg645.py b/frappy_psi/dg645.py new file mode 100644 index 0000000..4c06a28 --- /dev/null +++ b/frappy_psi/dg645.py @@ -0,0 +1,62 @@ +#!/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 +# ***************************************************************************** +"""Delay generator stanford 645""" + +from frappy.core import FloatRange, HasIO, Module, Parameter, StringIO + + +class DG645(StringIO): + end_of_line = '\n' + + +class Delay(HasIO, Module): + + on1 = Parameter('on delay 1', FloatRange(unit='sec'), readonly=False, default=0) + off1 = Parameter('off delay 1', FloatRange(unit='sec'), readonly=False, default=60e-9) + on2 = Parameter('on delay 2', FloatRange(unit='sec'), readonly=False, default=0) + off2 = Parameter('off delay 2', FloatRange(unit='sec'), readonly=False, default=150e-9) + + ioClass = DG645 + + def read_on1(self): + return float(self.communicate('DLAY?2').split(',')[1]) + + def read_off1(self): + return float(self.communicate('DLAY?3').split(',')[1]) + + def read_on2(self): + return float(self.communicate('DLAY?4').split(',')[1]) + + def read_off2(self): + return float(self.communicate('DLAY?5').split(',')[1]) + + def write_on1(self, value): + return float(self.communicate('DLAY 2,0,%g;DLAY?2' % value).split(',')[1]) + + def write_off1(self, value): + result = self.communicate('DLAY 3,0,%g;DLAY?3' % value) + return float(result.split(',')[1]) + + def write_on2(self, value): + return float(self.communicate('DLAY 4,0,%g;DLAY?4' % value).split(',')[1]) + + def write_off2(self, value): + return float(self.communicate('DLAY 5,0,%g;DLAY?5' % value).split(',')[1]) diff --git a/frappy_psi/dilsc.py b/frappy_psi/dilsc.py new file mode 100644 index 0000000..5d49b43 --- /dev/null +++ b/frappy_psi/dilsc.py @@ -0,0 +1,97 @@ +# -*- 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 +# ***************************************************************************** +"""vector field""" + +from frappy.core import Drivable, Done, BUSY, IDLE, WARN, ERROR +from frappy.errors import BadValueError +from frappy_psi.vector import Vector + + +DECREASE = 1 +INCREASE = 2 + + +class VectorField(Vector, Drivable): + _state = None + + def doPoll(self): + """periodically called method""" + try: + if self._starting: + # first decrease components + driving = False + for target, component in zip(self.target, self.components): + if target * component.value < 0: + # change sign: drive to zero first + target = 0 + if abs(target) < abs(component.target): + if target != component.target: + component.write_target(target) + if component.isDriving(): + driving = True + if driving: + return + # now we can go to the final targets + for target, component in zip(self.target, self.components): + component.write_target(target) + self._starting = False + else: + for component in self.components: + if component.isDriving(): + return + self.setFastPoll(False) + finally: + super().doPoll() + + def merge_status(self): + names = [c.name for c in self.components if c.status[0] >= ERROR] + if names: + return ERROR, 'error in %s' % ', '.join(names) + names = [c.name for c in self.components if c.isDriving()] + if self._state: + # self.log.info('merge %r', [c.status for c in self.components]) + if names: + direction = 'down ' if self._state == DECREASE else '' + return BUSY, 'ramping %s%s' % (direction, ', '.join(names)) + if self.status[0] == BUSY: + return self.status + return BUSY, 'driving' + if names: + return WARN, 'moving %s directly' % ', '.join(names) + names = [c.name for c in self.components if c.status[0] >= WARN] + if names: + return WARN, 'warnings in %s' % ', '.join(names) + return IDLE, '' + + def write_target(self, value): + """initiate target change""" + # first make sure target is valid + for target, component in zip(self.target, self.components): + # check against limits if individual components + component.check_limits(target) + if sum(v * v for v in value) > 1: + raise BadValueError('norm of vector too high') + self.log.info('decrease') + self.setFastPoll(True) + self.target = value + self._state = DECREASE + self.doPoll() + self.log.info('done write_target %r', value) + return Done diff --git a/frappy_psi/dpm.py b/frappy_psi/dpm.py new file mode 100644 index 0000000..71d07da --- /dev/null +++ b/frappy_psi/dpm.py @@ -0,0 +1,125 @@ +# -*- 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: +# M. Zolliker +# +# ***************************************************************************** +"""transducer DPM3 read out""" + +from frappy.core import Readable, Parameter, FloatRange, StringIO,\ + HasIO, IntRange, Done + + +class DPM3IO(StringIO): + end_of_line = '\r' + timeout = 3 + identification = [('*1R135', '01')] + + +def hex2float(hexvalue, digits): + value = int(hexvalue, 16) + if value >= 0x800000: + value -= 0x1000000 + return value / (10 ** digits) + + +def float2hex(value, digits): + intvalue = int(round(value * 10 ** digits,0)) + if intvalue < 0: + intvalue += 0x1000000 + return '%06X' % intvalue + + +class DPM3(HasIO, Readable): + OFFSET = 0x8f + SCALE = 0x8c + + MAGNITUDE = {'1': 1, '2': 10, '3': 100, '4': 1e3, '5': 1e4, '6': 1e5, + '9': -1, 'A': -10, 'B': -100, 'C': -1e3, 'D': -1e4, 'E': -1e5} + + ioClass = DPM3IO + + value = Parameter(datatype=FloatRange(unit='N')) + digits = Parameter('number of digits for value', IntRange(0, 5), initwrite=True, readonly=False) + # Note: we have to treat the units properly. + # We got an output of 150 for 10N. The maximal force we want to deal with is 100N, + # thus a maximal output of 1500. 10=150/f + offset = Parameter('', FloatRange(-1e5, 1e5), readonly=False) + scale_factor = Parameter('', FloatRange(-1e5, 1e5, unit='input_units/N'), readonly=False) + + def query(self, adr, value=None): + if value is not None: + if adr == self.SCALE: + absval = abs(value) + for nibble, mag in self.MAGNITUDE.items(): + if 10000 <= round(value * mag, 0) <= 99999: + break + else: + # no suitable range found + if absval >= 99999.5: # overrange + raise ValueError('%s is out of range' % value) + # underrange: take lowest + nibble = '9' if value < 0 else '1' + mag = self.MAGNITUDE[nibble] + hex_val = nibble + '%05X' % int(round(value * mag, 0)) + if hex_val[1:] == '00000': + raise ValueError('scale factor can not be 0', value) + else: + hex_val = float2hex(value, self.digits) + cmd = '*1F3%02X%s\r' % (adr, hex_val) + else: + cmd = "" + cmd = cmd + '*1G3%02X' % adr + hexvalue = self.communicate(cmd) + if adr == self.SCALE: + mag = self.MAGNITUDE[hexvalue[0:1]] + value = int(hexvalue[1:], 16) + return value/mag + else: + return hex2float(hexvalue, self.digits) + + def write_digits(self, value): + # value defines the number of digits + back_value = self.communicate('*1F135%02X\r*1G135' % (value + 1)) + self.digits = int(back_value, 16) - 1 + # recalculate proper scale and offset + self.write_scale_factor(self.scale_factor) + self.write_offset(self.offset) + return Done + + def read_digits(self): + back_value = self.communicate('*1G135') + return int(back_value,16) - 1 + + def read_value(self): + return float(self.communicate('*1B1')) + + def read_offset(self): + reply = self.query(self.OFFSET) + return reply + + def write_offset(self, value): + return self.query(self.OFFSET, value) + + def read_scale_factor(self): + reply = self.query(self.SCALE) + return float(reply) / 10 ** self.digits + + def write_scale_factor(self, value): + reply = self.query(self.SCALE, value * 10 ** self.digits) + return float(reply) / 10 ** self.digits diff --git a/frappy_psi/ips_mercury.py b/frappy_psi/ips_mercury.py new file mode 100644 index 0000000..978bc58 --- /dev/null +++ b/frappy_psi/ips_mercury.py @@ -0,0 +1,329 @@ +#!/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 +# ***************************************************************************** +"""oxford instruments mercury IPS power supply""" + +import time +from frappy.core import Parameter, EnumType, FloatRange, BoolType, IntRange, StringType, Property, BUSY +from frappy.lib.enum import Enum +from frappy.errors import BadValueError, HardwareError +from frappy_psi.magfield import Magfield, SimpleMagfield, Status +from frappy_psi.mercury import MercuryChannel, off_on, Mapped +from frappy.states import Retry + +Action = Enum(hold=0, run_to_set=1, run_to_zero=2, clamped=3) +hold_rtoz_rtos_clmp = Mapped(HOLD=Action.hold, RTOS=Action.run_to_set, + RTOZ=Action.run_to_zero, CLMP=Action.clamped) +CURRENT_CHECK_SIZE = 2 + + +class SimpleField(MercuryChannel, SimpleMagfield): + nunits = Property('number of IPS subunits', IntRange(1, 6), default=1) + action = Parameter('action', EnumType(Action), readonly=False) + setpoint = Parameter('field setpoint', FloatRange(unit='T'), default=0) + voltage = Parameter('leads voltage', FloatRange(unit='V'), default=0) + atob = Parameter('field to amp', FloatRange(0, unit='A/T'), default=0) + working_ramp = Parameter('effective ramp', FloatRange(0, unit='T/min'), default=0) + channel_type = 'PSU' + slave_currents = None + classdict = {} + + def __new__(cls, name, logger, cfgdict, srv): + base = cls.__bases__[1] + nunits = cfgdict.get('nunits', 1) + if nunits == 1: + obj = object.__new__(cls) + return obj + classname = cls.__name__ + str(nunits) + newclass = cls.classdict.get(classname) + if not newclass: + # create individual current and voltage parameters dynamically + attrs = {} + for i in range(1, nunits + 1): + attrs['I%d' % i] = Parameter('slave %s current' % i, FloatRange(unit='A'), default=0) + attrs['V%d' % i] = Parameter('slave %s voltage' % i, FloatRange(unit='V'), default=0) + + newclass = type(classname, (cls,), attrs) + cls.classdict[classname] = newclass + obj = object.__new__(newclass) + return obj + + def initModule(self): + super().initModule() + try: + self.write_action(Action.hold) + except Exception as e: + self.log.error('can not set to hold %r', e) + + def read_value(self): + return self.query('PSU:SIG:FLD') + + def read_ramp(self): + return self.query('PSU:SIG:RFST') + + def write_ramp(self, value): + return self.change('PSU:SIG:RFST', value) + + def read_action(self): + return self.query('PSU:ACTN', hold_rtoz_rtos_clmp) + + def write_action(self, value): + return self.change('PSU:ACTN', value, hold_rtoz_rtos_clmp) + + def read_atob(self): + return self.query('PSU:ATOB') + + def read_voltage(self): + return self.query('PSU:SIG:VOLT') + + def read_working_ramp(self): + return self.query('PSU:SIG:RFLD') + + def read_setpoint(self): + return self.query('PSU:SIG:FSET') + + def set_and_go(self, value): + self.setpoint = self.change('PSU:SIG:FSET', value) + assert self.write_action(Action.hold) == Action.hold + assert self.write_action(Action.run_to_set) == Action.run_to_set + + def start_ramp_to_target(self, sm): + # if self.action != Action.hold: + # assert self.write_action(Action.hold) == Action.hold + # return Retry + self.set_and_go(sm.target) + sm.try_cnt = 5 + return self.ramp_to_target + + def ramp_to_target(self, sm): + try: + return super().ramp_to_target(sm) + except HardwareError: + sm.try_cnt -= 1 + if sm.try_cnt < 0: + raise + self.set_and_go(sm.target) + return Retry + + def final_status(self, *args, **kwds): + self.write_action(Action.hold) + return super().final_status(*args, **kwds) + + def on_restart(self, sm): + self.write_action(Action.hold) + return super().on_restart(sm) + + +class Field(SimpleField, Magfield): + persistent_field = Parameter( + 'persistent field', FloatRange(unit='$'), readonly=False) + wait_switch_on = Parameter( + 'wait time to ensure switch is on', FloatRange(0, unit='s'), readonly=True, default=60) + wait_switch_off = Parameter( + 'wait time to ensure switch is off', FloatRange(0, unit='s'), readonly=True, default=60) + forced_persistent_field = Parameter( + 'manual indication that persistent field is bad', BoolType(), readonly=False, default=False) + + _field_mismatch = None + __init = True + __switch_fixed_until = 0 + + def doPoll(self): + super().doPoll() + self.read_current() + + def startModule(self, start_events): + # on restart, assume switch is changed long time ago, if not, the mercury + # will complain and this will be handled in start_ramp_to_field + self.switch_on_time = 0 + self.switch_off_time = 0 + self.switch_heater = self.query('PSU:SIG:SWHT', off_on) + super().startModule(start_events) + + def read_value(self): + current = self.query('PSU:SIG:FLD') + pf = self.query('PSU:SIG:PFLD') + if self.__init: + self.__init = False + self.persistent_field = pf + if self.switch_heater == self.switch_heater.on or self._field_mismatch is None: + self.forced_persistent_field = False + self._field_mismatch = False + return current + self._field_mismatch = abs(self.persistent_field - pf) > self.tolerance + return pf + + def read_current(self): + if self.slave_currents is None: + self.slave_currents = [[] for _ in range(self.nunits + 1)] + if self.nunits > 1: + for i in range(1, self.nunits + 1): + curri = self.query('DEV:PSU.M%d:PSU:SIG:CURR' % i) + volti = self.query('DEV:PSU.M%d:PSU:SIG:VOLT' % i) + setattr(self, 'I%d' % i, curri) + setattr(self, 'V%d' % i, volti) + self.slave_currents[i].append(curri) + current = self.query('PSU:SIG:CURR') + self.slave_currents[0].append(current) + min_ = min(self.slave_currents[0]) / self.nunits + max_ = max(self.slave_currents[0]) / self.nunits + # keep one element more for the total current (first and last measurement is a total) + self.slave_currents[0] = self.slave_currents[0][-CURRENT_CHECK_SIZE-1:] + for i in range(1, self.nunits + 1): + min_i = min(self.slave_currents[i]) + max_i = max(self.slave_currents[i]) + if len(self.slave_currents[i]) > CURRENT_CHECK_SIZE: + self.slave_currents[i] = self.slave_currents[i][-CURRENT_CHECK_SIZE:] + if min_i - 0.1 > max_ or min_ > max_i + 0.1: # use an arbitrary 0.1 A tolerance + self.log.warning('individual currents mismatch %r', self.slave_currents) + else: + current = self.query('PSU:SIG:CURR') + if self.atob: + return current / self.atob + return 0 + + def write_persistent_field(self, value): + if self.forced_persistent_field: + self._field_mismatch = False + return value + raise BadValueError('changing persistent field needs forced_persistent_field=True') + + def write_target(self, target): + if self._field_mismatch: + self.forced_persistent_field = True + raise BadValueError('persistent field does not match - set persistent field to guessed value first') + return super().write_target(target) + + def read_switch_heater(self): + value = self.query('PSU:SIG:SWHT', off_on) + now = time.time() + if value != self.switch_heater: + if now < self.__switch_fixed_until: + self.log.debug('correct fixed switch time') + # probably switch heater was changed, but IPS reply is not yet updated + if self.switch_heater: + self.switch_on_time = time.time() + else: + self.switch_off_time = time.time() + return self.switch_heater + return value + + def read_wait_switch_on(self): + return self.query('PSU:SWONT') * 0.001 + + def read_wait_switch_off(self): + return self.query('PSU:SWOFT') * 0.001 + + def write_switch_heater(self, value): + if value == self.read_switch_heater(): + self.log.info('switch heater already %r', value) + # we do not want to restart the timer + return value + self.__switch_fixed_until = time.time() + 10 + self.log.debug('switch time fixed for 10 sec') + result = self.change('PSU:SIG:SWHT', value, off_on, n_retry=0) # no readback check + return result + + def start_ramp_to_field(self, sm): + if abs(self.current - self.persistent_field) <= self.tolerance: + self.log.info('leads %g are already at %g', self.current, self.persistent_field) + return self.ramp_to_field + try: + self.set_and_go(self.persistent_field) + except (HardwareError, AssertionError) as e: + if self.switch_heater: + self.log.warn('switch is already on!') + return self.ramp_to_field + self.log.warn('wait first for switch off current=%g pf=%g %r', self.current, self.persistent_field, e) + sm.after_wait = self.ramp_to_field + return self.wait_for_switch + return self.ramp_to_field + + def start_ramp_to_target(self, sm): + sm.try_cnt = 5 + try: + self.set_and_go(sm.target) + except (HardwareError, AssertionError) as e: + self.log.warn('switch not yet ready %r', e) + self.status = Status.PREPARING, 'wait for switch on' + sm.after_wait = self.ramp_to_target + return self.wait_for_switch + return self.ramp_to_target + + def ramp_to_field(self, sm): + try: + return super().ramp_to_field(sm) + except HardwareError: + sm.try_cnt -= 1 + if sm.try_cnt < 0: + raise + self.set_and_go(sm.persistent_field) + return Retry + + def wait_for_switch(self, sm): + if not sm.delta(10): + return Retry + try: + self.log.warn('try again') + # try again + self.set_and_go(self.persistent_field) + except (HardwareError, AssertionError) as e: + return Retry + return sm.after_wait + + def wait_for_switch_on(self, sm): + self.read_switch_heater() # trigger switch_on/off_time + if self.switch_heater == self.switch_heater.OFF: + if sm.init: # avoid too many states chained + return Retry + self.log.warning('switch turned off manually?') + return self.start_switch_on + return super().wait_for_switch_on(sm) + + def wait_for_switch_off(self, sm): + self.read_switch_heater() + if self.switch_heater == self.switch_heater.ON: + if sm.init: # avoid too many states chained + return Retry + self.log.warning('switch turned on manually?') + return self.start_switch_off + return super().wait_for_switch_off(sm) + + def start_ramp_to_zero(self, sm): + try: + assert self.write_action(Action.hold) == Action.hold + assert self.write_action(Action.run_to_zero) == Action.run_to_zero + except (HardwareError, AssertionError) as e: + self.log.warn('switch not yet ready %r', e) + self.status = Status.PREPARING, 'wait for switch off' + sm.after_wait = self.ramp_to_zero + return self.wait_for_switch + return self.ramp_to_zero + + def ramp_to_zero(self, sm): + try: + return super().ramp_to_zero(sm) + except HardwareError: + sm.try_cnt -= 1 + if sm.try_cnt < 0: + raise + assert self.write_action(Action.hold) == Action.hold + assert self.write_action(Action.run_to_zero) == Action.run_to_zero + return Retry diff --git a/frappy_psi/iqplot.py b/frappy_psi/iqplot.py new file mode 100644 index 0000000..344f062 --- /dev/null +++ b/frappy_psi/iqplot.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +""" +Created on Tue Feb 4 11:07:56 2020 + +@author: tartarotti_d-adm +""" + +import numpy as np +import matplotlib.pyplot as plt + +def rect(x1, x2, y1, y2): + return np.array([[x1,x2,x2,x1,x1],[y1,y1,y2,y2,y1]]) + +NAN = float('nan') + +def rects(intervals, y12): + result = [rect(*intervals[0], *y12)] + for x12 in intervals[1:]: + result.append([[NAN],[NAN]]) + result.append(rect(*x12, *y12)) + return np.concatenate(result, axis=1) + +class Plot: + def __init__(self, maxy): + self.lines = {} + self.yaxis = ((-2 * maxy, maxy), (-maxy, 2 * maxy)) + self.first = True + self.fig = None + + def set_line(self, iax, name, data, fmt, **kwds): + """ + plot or update a line + + when called with self.first = True: plot the line + when called with self.first = False: update the line + + iax: 0: left, 1: right yaxis + name: the name of the line. used also as label for legend, if not starting with underscore + data: data[0] and data[1] are used for x/y data respectively + fmt, other keywords: forwarded to .plot + """ + # ax: 0: left, 1: right + if self.first: + if name.startswith('_'): + label = '_nolegend_' + else: + label = name + self.lines[name], = self.ax[iax].plot(data[0], data[1], fmt, label=label, **kwds) + else: + self.lines[name].set_data(data[0:2]) + + def close(self): + if self.fig: + plt.close(self.fig) + self.fig = None + self.first = True + + def plot(self, curves, rois=None, average=None): + boxes = rects(rois[1:], self.yaxis[0]) + pbox = rect(*rois[0], *self.yaxis[1]) + rbox = rect(*rois[1], *self.yaxis[0]) + + pshift = self.yaxis[0][1] * 0.5 + pulse = curves[3] - pshift + # normalized to 0.8 * pshift: + #pulse = (curves[3] / np.max(curves[3]))* pshift * 0.8 - pshift + + try: + if self.first: + plt.ion() + self.fig, axleft = plt.subplots(figsize=(15,7)) + plt.title("I/Q", fontsize=14) + axleft.set_xlim(0, curves[0][-1]) + self.ax = [axleft, axleft.twinx()] + self.ax[0].axhline(y=0, color='#cccccc') # show x-axis line + self.ax[1].axhline(y=0, color='#cccccc') + self.ax[0].set_ylim(*self.yaxis[0]) + self.ax[1].set_ylim(*self.yaxis[1]) + + self.set_line(0, "I", curves, 'b-') # using curves [0] and [1] + self.set_line(0, "_Iaverage", average, 'b.') + + self.set_line(0, "Ampl", (curves[0],np.sqrt(curves[1]**2+curves[2]**2)), '#808080') + + self.set_line(1, "Q", (curves[0], curves[2]), 'g-') + self.set_line(1, "_Qaverage", (average[0], average[2]), 'g.') + + self.set_line(0, "pulse", (curves[0], pulse), 'c-') + + self.set_line(0, "roi's", boxes, 'm-') + self.set_line(1, "pulse reg", pbox, 'k-') + self.set_line(0, "ctrl reg", rbox, 'r-') + + if self.first: + self.fig.legend(fontsize=12) + plt.tight_layout() + finally: + self.first = False + + plt.draw() + self.fig.canvas.draw() + self.fig.canvas.flush_events() diff --git a/frappy_psi/ls240.py b/frappy_psi/ls240.py new file mode 100644 index 0000000..417acfb --- /dev/null +++ b/frappy_psi/ls240.py @@ -0,0 +1,105 @@ +#!/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 +# ***************************************************************************** +"""LakeShore 240 temperature monitor + +experimental, does not really work +""" + +import struct +from frappy.core import FloatRange, HasIodev, Readable, Parameter, BytesIO +from frappy.errors import CommunicationFailedError + +SD1= 0x10 +SD2= 0x68 +FC = 0x49 +ED = 0x16 + +STATUS_REQ = 0x49 + +def dehex(msg): + return bytes(int(b, 16) for b in msg.split()) + +def hexify(msg): + return ' '.join('%2.2X' % b for b in msg) + + +class Ls240(HasIodev, Readable): + value = Parameter('sensor reading', FloatRange(unit='Ohm')) + + iodevClass = BytesIO + + def request(self, replylen, data='', ext=None, dst_adr=3, src_adr=2): + data = dehex(data) + if ext is None: + ext = len(data) > 1 + if ext: + dst_adr |= 0x80 + src_adr |= 0x80 + if len(data) > 1: + length = len(data) + 2 + hdr = [SD2, length, length, SD2] + else: + hdr = [SD1] + mid = [dst_adr, src_adr] + list(data) + checksum = sum(mid) % 256 + msg = bytes(hdr + mid + [checksum, ED]) + for i in range(10): + try: + # print('>', hexify(msg)) + reply = self._iodev.communicate(msg, replylen) + # print('<', hexify(reply)) + except (TimeoutError, CommunicationFailedError): + continue + return reply + return None + + def read_value(self): + + # check connection + self.request(6, '49') + + # get diag + # 3C: slave diag, (what means 3E?) + reply = self.request(17, '6D 3C 3E') + assert reply[13:15] == b'\x0f\x84' # LS240 ident + + # set parameters + # 3D set param (what means 3E?) + # B0 FF FF: no watchdog, 00: min wait, 0F 84: ident, 01: group + assert b'\xe5' == self.request(1, '5D 3D 3E B0 FF FF 00 0F 84 01') + + # set config + # 3E set config (what means 2nd 3E?) + # 93: input only, 4 == 3+1 bytes + assert b'\xe5' == self.request(1, '7D 3E 3E 93') + + # get diag + # 3C: slave diag, (what means 3E?) + reply = self.request(17, '5D 3C 3E') + assert reply[13:15] == b'\x0f\x84' # LS240 ident + + # get data + # do not know what 42 24 means + reply = self.request(13, '7D 42 24', ext=0) + print('DATA', reply) + value = struct.unpack('>f', reply[7:11])[0] + print('VALUE', value) + return value diff --git a/frappy_psi/ls340res.py b/frappy_psi/ls340res.py new file mode 100644 index 0000000..a9f9c7d --- /dev/null +++ b/frappy_psi/ls340res.py @@ -0,0 +1,45 @@ +#!/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 +# ***************************************************************************** +"""very simple LakeShore Model 340 driver, resistivity only""" + +import time + +from frappy.datatypes import StringType, FloatRange +from frappy.modules import Parameter, Property, Readable +from frappy.io import HasIO, StringIO + + +class LscIO(StringIO): + identification = [('*IDN?', 'LSCI,MODEL340,.*')] + end_of_line = '\r' + wait_before = 0.05 + + +class ResChannel(HasIO, Readable): + """temperature channel on Lakeshore 340""" + + ioClass = LscIO + + value = Parameter(datatype=FloatRange(unit='Ohm')) + channel = Property('the channel A,B,C or D', StringType()) + + def read_value(self): + return float(self.communicate('SRDG?%s' % self.channel)) diff --git a/frappy_psi/ls370sim.py b/frappy_psi/ls370sim.py new file mode 100644 index 0000000..b934685 --- /dev/null +++ b/frappy_psi/ls370sim.py @@ -0,0 +1,75 @@ +#!/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 +# ***************************************************************************** +"""a very simple simulator for a LakeShore Model 370""" + +from frappy.modules import Communicator + + +class Ls370Sim(Communicator): + CHANNEL_COMMANDS = [ + ('RDGR?%d', '1.0'), + ('RDGST?%d', '0'), + ('RDGRNG?%d', '0,5,5,0,0'), + ('INSET?%d', '1,5,5,0,0'), + ('FILTER?%d', '1,5,80'), + ] + OTHER_COMMANDS = [ + ('*IDN?', 'LSCI,MODEL370,370184,05302003'), + ('SCAN?', '3,1'), + ('*OPC?', '1'), + ] + + def earlyInit(self): + super().earlyInit() + self._data = dict(self.OTHER_COMMANDS) + for fmt, v in self.CHANNEL_COMMANDS: + for chan in range(1,17): + self._data[fmt % chan] = v + + def communicate(self, command): + self.comLog('> %s' % command) + # simulation part, time independent + for channel in range(1,17): + _, _, _, _, excoff = self._data['RDGRNG?%d' % channel].split(',') + if excoff == '1': + self._data['RDGST?%d' % channel] = '6' + else: + self._data['RDGST?%d' % channel] = '0' + + chunks = command.split(';') + reply = [] + for chunk in chunks: + if '?' in chunk: + reply.append(self._data[chunk]) + else: + for nqarg in (1,0): + if nqarg == 0: + qcmd, arg = chunk.split(' ', 1) + qcmd += '?' + else: + qcmd, arg = chunk.split(',', nqarg) + qcmd = qcmd.replace(' ', '?', 1) + if qcmd in self._data: + self._data[qcmd] = arg + break + reply = ';'.join(reply) + self.comLog('< %s' % reply) + return reply diff --git a/frappy_psi/ls372.py b/frappy_psi/ls372.py new file mode 100644 index 0000000..633dcaf --- /dev/null +++ b/frappy_psi/ls372.py @@ -0,0 +1,329 @@ +#!/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 +# ***************************************************************************** +"""LakeShore Model 372 resistance channel + +implements autoscan and autorange by software. +when the autoscan or autorange button is pressed, the state is toggled, +and the hardware mode switched off again. +At startup, the configurable default mode is set, independent of +the hardware state. +""" + +import time + +import frappy.io +from frappy.datatypes import BoolType, EnumType, FloatRange, IntRange +from frappy.lib import formatStatusBits +from frappy.core import Done, Drivable, Parameter, Property, CommonReadHandler, CommonWriteHandler +from frappy.io import HasIO +from frappy_psi.channelswitcher import Channel, ChannelSwitcher + +Status = Drivable.Status + + +STATUS_BIT_LABELS = 'CS_OVL VCM_OVL VMIX_OVL VDIF_OVL R_OVER R_UNDER T_OVER T_UNDER'.split() + + +def parse1(string): + try: + return int(string) + except ValueError: + pass + try: + return float(string) + except ValueError: + return string + + +def parse(reply): + return [parse1(s) for s in reply.split(',')] + + +class StringIO(frappy.io.StringIO): + identification = [('*IDN?', 'LSCI,MODEL372,.*')] + wait_before = 0.05 + + +class Switcher(HasIO, ChannelSwitcher): + value = Parameter(datatype=IntRange(1, 16)) + target = Parameter(datatype=IntRange(1, 16)) + use_common_delays = Parameter('use switch_delay and measure_delay instead of the channels pause and dwell', + BoolType(), readonly=False, default=False) + common_pause = Parameter('pause with common delays', FloatRange(3, 200, unit='s'), readonly=False, default=3) + ioClass = StringIO + fast_poll = 1 + _measure_delay = None + _switch_delay = None + + def startModule(self, start_events): + super().startModule(start_events) + # disable unused channels + for ch in range(1, 16): + if ch not in self._channels: + self.communicate('INSET %d,0,0,0,0,0;INSET?%d' % (ch, ch)) + channelno, autoscan = parse(self.communicate('SCAN?')) + if channelno in self._channels and self._channels[channelno].enabled: + if not autoscan: + return # nothing to do + else: + channelno = self.next_channel(channelno) + if channelno is None: + self.status = 'ERROR', 'no enabled channel' + return + self.communicate('SCAN %d,0;SCAN?' % channelno) + + def doPoll(self): + """poll buttons + + and check autorange during filter time + """ + super().doPoll() + self._channels[self.target]._read_value() # check range or read + channelno, autoscan = parse(self.communicate('SCAN?')) + if autoscan: + # pressed autoscan button: switch off HW autoscan and toggle soft autoscan + self.autoscan = not self.autoscan + self.communicate('SCAN %d,0;SCAN?' % self.value) + if channelno != self.value: + # channel changed by keyboard, do not yet return new channel + self.write_target(channelno) + chan = self._channels.get(channelno) + if chan is None: + channelno = self.next_channel(channelno) + if channelno is None: + raise ValueError('no channels enabled') + self.write_target(channelno) + chan = self._channels.get(self.value) + chan.read_autorange() + chan.fix_autorange() # check for toggled autorange button + return Done + + def write_switch_delay(self, value): + self._switch_delay = value + return super().write_switch_delay(value) + + def write_measure_delay(self, value): + self._measure_delay = value + return super().write_measure_delay(value) + + def write_use_common_delays(self, value): + if value: + # use values from a previous change, instead of + # the values from the current channel + if self._measure_delay is not None: + self.measure_delay = self._measure_delay + if self._switch_delay is not None: + self.switch_delay = self._switch_delay + return value + + def set_delays(self, chan): + if self.use_common_delays: + if chan.dwell != self.measure_delay: + chan.write_dwell(self.measure_delay) + if chan.pause != self.common_pause: + chan.write_pause(self.common_pause) + filter_ = max(0, self.switch_delay - self.common_pause) + if chan.filter != filter_: + chan.write_filter(filter_) + else: + # switch_delay and measure_delay is changing with channel + self.switch_delay = chan.pause + chan.filter + self.measure_delay = chan.dwell + + def set_active_channel(self, chan): + self.communicate('SCAN %d,0;SCAN?' % chan.channel) + chan._last_range_change = time.monotonic() + self.set_delays(chan) + + +class ResChannel(Channel): + """temperature channel on Lakeshore 372""" + + RES_RANGE = {key: i+1 for i, key in list( + enumerate(mag % val for mag in ['%gmOhm', '%gOhm', '%gkOhm', '%gMOhm'] + for val in [2, 6.32, 20, 63.2, 200, 632]))[:-2]} + CUR_RANGE = {key: i + 1 for i, key in list( + enumerate(mag % val for mag in ['%gpA', '%gnA', '%guA', '%gmA'] + for val in [1, 3.16, 10, 31.6, 100, 316]))[:-2]} + VOLT_RANGE = {key: i + 1 for i, key in list( + enumerate(mag % val for mag in ['%guV', '%gmV'] + for val in [2, 6.32, 20, 63.2, 200, 632]))} + RES_SCALE = [2 * 10 ** (0.5 * i) for i in range(-7, 16)] # RES_SCALE[0] is not used + MAX_RNG = len(RES_SCALE) - 1 + + channel = Property('the Lakeshore channel', datatype=IntRange(1, 16), export=False) + + value = Parameter(datatype=FloatRange(unit='Ohm')) + pollinterval = Parameter(visibility=3, default=1) + range = Parameter('reading range', readonly=False, + datatype=EnumType(**RES_RANGE)) + minrange = Parameter('minimum range for software autorange', readonly=False, default=1, + datatype=EnumType(**RES_RANGE)) + autorange = Parameter('autorange', datatype=BoolType(), + readonly=False, default=1) + iexc = Parameter('current excitation', datatype=EnumType(off=0, **CUR_RANGE), readonly=False) + vexc = Parameter('voltage excitation', datatype=EnumType(off=0, **VOLT_RANGE), readonly=False) + enabled = Parameter('is this channel enabled?', datatype=BoolType(), readonly=False) + pause = Parameter('pause after channel change', datatype=FloatRange(3, 60, unit='s'), readonly=False) + dwell = Parameter('dwell time with autoscan', datatype=FloatRange(1, 200, unit='s'), readonly=False) + filter = Parameter('filter time', datatype=FloatRange(1, 200, unit='s'), readonly=False) + + _toggle_autorange = 'init' + _prev_rdgrng = (1, 1) # last read values for icur and exc + _last_range_change = 0 + rdgrng_params = 'range', 'iexc', 'vexc' + inset_params = 'enabled', 'pause', 'dwell' + + def communicate(self, command): + return self.switcher.communicate(command) + + def read_status(self): + if not self.enabled: + return [self.Status.DISABLED, 'disabled'] + if not self.channel == self.switcher.value == self.switcher.target: + return Done + result = int(self.communicate('RDGST?%d' % self.channel)) + result &= 0x37 # mask T_OVER and T_UNDER (change this when implementing temperatures instead of resistivities) + statustext = ' '.join(formatStatusBits(result, STATUS_BIT_LABELS)) + if statustext: + return [self.Status.ERROR, statustext] + return [self.Status.IDLE, ''] + + def _read_value(self): + """read value, without update""" + now = time.monotonic() + if now + 0.5 < max(self._last_range_change, self.switcher._start_switch) + self.pause: + return None + result = self.communicate('RDGR?%d' % self.channel) + result = float(result) + if self.autorange: + self.fix_autorange() + if now + 0.5 > self._last_range_change + self.pause: + rng = int(max(self.minrange, self.range)) # convert from enum to int + if self.status[1] == '': + if abs(result) > self.RES_SCALE[rng]: + if rng < 22: + rng += 1 + else: + lim = 0.2 + while rng > self.minrange and abs(result) < lim * self.RES_SCALE[rng]: + rng -= 1 + lim -= 0.05 # not more than 4 steps at once + # effectively: <0.16 %: 4 steps, <1%: 3 steps, <5%: 2 steps, <20%: 1 step + elif rng < self.MAX_RNG: + rng = min(self.MAX_RNG, rng + 1) + if rng != self.range: + self.write_range(rng) + self._last_range_change = now + return result + + def read_value(self): + if self.channel == self.switcher.value == self.switcher.target: + return self._read_value() + return Done # return previous value + + def is_switching(self, now, last_switch, switch_delay): + last_switch = max(last_switch, self._last_range_change) + if now + 0.5 > last_switch + self.pause: + self._read_value() # adjust range only + return super().is_switching(now, last_switch, switch_delay) + + @CommonReadHandler(rdgrng_params) + def read_rdgrng(self): + iscur, exc, rng, autorange, excoff = parse( + self.communicate('RDGRNG?%d' % self.channel)) + self._prev_rdgrng = iscur, exc + if autorange: # pressed autorange button + if not self._toggle_autorange: + self._toggle_autorange = True + iexc = 0 if excoff or not iscur else exc + vexc = 0 if excoff or iscur else exc + if (rng, iexc, vexc) != (self.range, self.iexc, self.vexc): + self._last_range_change = time.monotonic() + self.range, self.iexc, self.vexc = rng, iexc, vexc + + @CommonWriteHandler(rdgrng_params) + def write_rdgrng(self, change): + self.read_range() # make sure autorange is handled + if 'vexc' in change: # in case vext is changed, do not consider iexc + change['iexc'] = 0 + if change['iexc'] != 0: # we need '!= 0' here, as bool(enum) is always True! + iscur = 1 + exc = change['iexc'] + excoff = 0 + elif change['vexc'] != 0: # we need '!= 0' here, as bool(enum) is always True! + iscur = 0 + exc = change['vexc'] + excoff = 0 + else: + iscur, exc = self._prev_rdgrng # set to last read values + excoff = 1 + rng = change['range'] + if self.autorange: + if rng < self.minrange: + rng = self.minrange + self.communicate('RDGRNG %d,%d,%d,%d,%d,%d;*OPC?' % ( + self.channel, iscur, exc, rng, 0, excoff)) + self.read_range() + + def fix_autorange(self): + if self._toggle_autorange: + if self._toggle_autorange == 'init': + self.write_autorange(True) + else: + self.write_autorange(not self.autorange) + self._toggle_autorange = False + + @CommonReadHandler(inset_params) + def read_inset(self): + # ignore curve no and temperature coefficient + enabled, dwell, pause, _, _ = parse( + self.communicate('INSET?%d' % self.channel)) + self.enabled = enabled + self.dwell = dwell + self.pause = pause + + @CommonWriteHandler(inset_params) + def write_inset(self, change): + _, _, _, curve, tempco = parse( + self.communicate('INSET?%d' % self.channel)) + self.enabled, self.dwell, self.pause, _, _ = parse( + self.communicate('INSET %d,%d,%d,%d,%d,%d;INSET?%d' % ( + self.channel, change['enabled'], change['dwell'], change['pause'], curve, tempco, + self.channel))) + if 'enabled' in change and change['enabled']: + # switch to enabled channel + self.switcher.write_target(self.channel) + elif self.switcher.target == self.channel: + self.switcher.set_delays(self) + + def read_filter(self): + on, settle, _ = parse(self.communicate('FILTER?%d' % self.channel)) + return settle if on else 0 + + def write_filter(self, value): + on = 1 if value else 0 + value = max(1, value) + on, settle, _ = parse(self.communicate( + 'FILTER %d,%d,%g,80;FILTER?%d' % (self.channel, on, value, self.channel))) + if not on: + settle = 0 + return settle diff --git a/frappy_psi/magfield.py b/frappy_psi/magfield.py new file mode 100644 index 0000000..ae58ffc --- /dev/null +++ b/frappy_psi/magfield.py @@ -0,0 +1,381 @@ +# -*- 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 +# ***************************************************************************** +"""generic persistent magnet driver""" + +import time +from frappy.core import Drivable, Parameter, Done, IDLE, BUSY, ERROR +from frappy.datatypes import FloatRange, EnumType, ArrayOf, TupleOf, StatusType +from frappy.features import HasTargetLimits +from frappy.errors import ConfigError, ProgrammingError, HardwareError, BadValueError +from frappy.lib.enum import Enum +from frappy.states import Retry, HasStates, status_code, Start + +UNLIMITED = FloatRange() + +Mode = Enum( + DISABLED=0, + PERSISTENT=30, + DRIVEN=50, +) + +Status = Enum( + Drivable.Status, + PREPARED=150, + PREPARING=340, + RAMPING=370, + STABILIZING=380, + FINALIZING=390, +) + +OFF = 0 +ON = 1 + + +class SimpleMagfield(HasStates, HasTargetLimits, Drivable): + value = Parameter('magnetic field', datatype=FloatRange(unit='T')) + ramp = Parameter( + 'wanted ramp rate for field', FloatRange(unit='$/min'), readonly=False) + # export only when different from ramp: + workingramp = Parameter( + 'effective ramp rate for field', FloatRange(unit='$/min'), export=False) + tolerance = Parameter( + 'tolerance', FloatRange(0, unit='$'), readonly=False, default=0.0002) + trained = Parameter( + 'trained field (positive)', + TupleOf(FloatRange(-99, 0, unit='$'), FloatRange(0, unit='$')), + readonly=False, default=(0, 0)) + wait_stable_field = Parameter( + 'wait time to ensure field is stable', FloatRange(0, unit='s'), readonly=False, default=31) + ramp_tmo = Parameter( + 'timeout for field ramp progress', + FloatRange(0, unit='s'), readonly=False, default=30) + + _last_target = None + + def checkProperties(self): + dt = self.parameters['target'].datatype + max_ = dt.max + if max_ == UNLIMITED.max: + raise ConfigError('target.max not configured') + if dt.min == UNLIMITED.min: # not given: assume bipolar symmetric + dt.min = -max_ + super().checkProperties() + + def stop(self): + """keep field at current value""" + # let the state machine do the needed steps to finish + self.write_target(self.value) + + def last_target(self): + """get best known last target + + as long as the guessed last target is within tolerance + with repsect to the main value, it is used, as in general + it has better precision + """ + last = self._last_target + if last is None: + try: + last = self.setpoint # get read back from HW, if available + except Exception: + pass + if last is None or abs(last - self.value) > self.tolerance: + return self.value + return last + + def write_target(self, target): + self.check_limits(target) + self.start_machine(self.start_field_change, target=target) + return target + + def init_progress(self, sm, value): + sm.prev_point = sm.now, value + + def get_progress(self, sm, value): + """return the time passed for at least one tolerance step""" + t, v = sm.prev_point + dif = abs(v - value) + tdif = sm.now - t + if dif > self.tolerance: + sm.prev_point = sm.now, value + return tdif + + @status_code(BUSY, 'start ramp to target') + def start_field_change(self, sm): + self.setFastPoll(True, 1.0) + return self.start_ramp_to_target + + @status_code(BUSY, 'ramping field') + def ramp_to_target(self, sm): + if sm.init: + self.init_progress(sm, self.value) + # Remarks: assume there is a ramp limiting feature + if abs(self.value - sm.target) > self.tolerance: + if self.get_progress(sm, self.value) > self.ramp_tmo: + raise HardwareError('no progress') + sm.stabilize_start = None # force reset + return Retry + sm.stabilize_start = time.time() + return self.stabilize_field + + @status_code(BUSY, 'stabilizing field') + def stabilize_field(self, sm): + if sm.now - sm.stabilize_start < self.wait_stable_field: + return Retry + return self.final_status() + + def read_workingramp(self): + return self.ramp + + +class Magfield(SimpleMagfield): + status = Parameter(datatype=StatusType(Status)) + mode = Parameter( + 'persistent mode', EnumType(Mode), readonly=False, initwrite=False, default=Mode.PERSISTENT) + switch_heater = Parameter('switch heater', EnumType(off=OFF, on=ON), + readonly=False, default=0) + current = Parameter( + 'leads current (in units of field)', FloatRange(unit='$')) + # TODO: time_to_target + # profile = Parameter( + # 'ramp limit table', ArrayOf(TupleOf(FloatRange(unit='$'), FloatRange(unit='$/min'))), + # readonly=False) + # profile_training = Parameter( + # 'ramp limit table when in training', + # ArrayOf(TupleOf(FloatRange(unit='$'), FloatRange(unit='$/min'))), readonly=False) + # TODO: the following parameters should be changed into properties after tests + wait_switch_on = Parameter( + 'wait time to ensure switch is on', FloatRange(0, unit='s'), readonly=False, default=61) + wait_switch_off = Parameter( + 'wait time to ensure switch is off', FloatRange(0, unit='s'), readonly=False, default=61) + wait_stable_leads = Parameter( + 'wait time to ensure current is stable', FloatRange(0, unit='s'), readonly=False, default=6) + persistent_limit = Parameter( + 'above this limit, lead currents are not driven to 0', + FloatRange(0, unit='$'), readonly=False, default=99) + leads_ramp_tmo = Parameter( + 'timeout for leads ramp progress', + FloatRange(0, unit='s'), readonly=False, default=30) + init_persistency = True + switch_on_time = None + switch_off_time = None + + def doPoll(self): + if self.init_persistency: + if self.read_switch_heater() and self.mode != Mode.DRIVEN: + self.start_machine(self.go_persistent_soon, mode=self.mode) + self.init_persistency = False + super().doPoll() + + def initModule(self): + super().initModule() + self.registerCallbacks(self) # for update_switch_heater + + def write_mode(self, value): + self.init_persistency = False + target = self.last_target() + func = self.start_field_change + if value == Mode.DISABLED: + target = 0 + if abs(self.value) < self.tolerance: + func = self.start_switch_off + elif value == Mode.PERSISTENT: + func = self.start_switch_off + self.target = target + self.start_machine(func, target=target, mode=value) + return value + + def write_target(self, target): + self.init_persistency = False + if self.mode == Mode.DISABLED: + if target == 0: + return 0 + self.log.info('raise error %r', target) + raise BadValueError('disabled') + self.check_limits(target) + self.start_machine(self.start_field_change, target=target, mode=self.mode) + return target + + def on_error(self, sm): # sm is short for statemachine + if self.switch_heater == ON: + self.read_value() + if sm.mode != Mode.DRIVEN: + self.log.warning('turn switch heater off') + self.write_switch_heater(OFF) + return super().on_error(sm) + + @status_code(Status.WARN) + def go_persistent_soon(self, sm): + if sm.delta(60): + self.target = sm.target = self.last_target() + return self.start_field_change + return Retry + + @status_code(Status.PREPARING) + def start_field_change(self, sm): + self.setFastPoll(True, 1.0) + if (sm.target == self.last_target() and + abs(sm.target - self.value) <= self.tolerance and + abs(self.current - self.value) < self.tolerance and + (self.mode != Mode.DRIVEN or self.switch_heater == ON)): # short cut + return self.check_switch_off + if self.switch_heater == ON: + return self.start_switch_on + return self.start_ramp_to_field + + @status_code(Status.PREPARING) + def start_ramp_to_field(self, sm): + """start ramping current to persistent field + + initiate ramp to persistent field (with corresponding ramp rate) + the implementation should return ramp_to_field + """ + raise NotImplementedError + + @status_code(Status.PREPARING, 'ramp leads to match field') + def ramp_to_field(self, sm): + if sm.init: + sm.stabilize_start = 0 # in case current is already at field + self.init_progress(sm, self.current) + dif = abs(self.current - self.value) + if dif > self.tolerance: + tdif = self.get_progress(sm, self.current) + if tdif > self.leads_ramp_tmo: + raise HardwareError('no progress') + sm.stabilize_start = None # force reset + return Retry + if sm.stabilize_start is None: + sm.stabilize_start = sm.now + return self.stabilize_current + + @status_code(Status.PREPARING) + def stabilize_current(self, sm): + if sm.now - sm.stabilize_start < self.wait_stable_leads: + return Retry + return self.start_switch_on + + def update_switch_heater(self, value): + """is called whenever switch heater was changed""" + if value == 0: + if self.switch_off_time is None: + self.log.info('restart switch_off_time') + self.switch_off_time = time.time() + self.switch_on_time = None + else: + if self.switch_on_time is None: + self.log.info('restart switch_on_time') + self.switch_on_time = time.time() + self.switch_off_time = None + + @status_code(Status.PREPARING) + def start_switch_on(self, sm): + if (sm.target == self.last_target() and + abs(sm.target - self.value) <= self.tolerance): # short cut + return self.check_switch_off + if self.read_switch_heater() == OFF: + try: + self.write_switch_heater(ON) + except Exception as e: + self.log.warning('write_switch_heater %r', e) + return Retry + return self.wait_for_switch_on + + @status_code(Status.PREPARING) + def wait_for_switch_on(self, sm): + if sm.now - self.switch_on_time < self.wait_switch_on: + if sm.delta(10): + self.log.info('waited for %g sec', sm.now - self.switch_on_time) + return Retry + self._last_target = sm.target + return self.start_ramp_to_target + + @status_code(Status.RAMPING) + def start_ramp_to_target(self, sm): + """start ramping current to target field + + initiate ramp to target + the implementation should return ramp_to_target + """ + raise NotImplementedError + + @status_code(Status.RAMPING) + def ramp_to_target(self, sm): + dif = abs(self.value - sm.target) + if sm.init: + sm.stabilize_start = 0 # in case current is already at target + self.init_progress(sm, self.value) + if dif > self.tolerance: + sm.stabilize_start = sm.now + tdif = self.get_progress(sm, self.value) + if tdif > self.workingramp / self.tolerance * 60 + self.ramp_tmo: + self.log.warn('no progress') + raise HardwareError('no progress') + sm.stabilize_start = None + return Retry + if sm.stabilize_start is None: + sm.stabilize_start = sm.now + return self.stabilize_field + + @status_code(Status.STABILIZING) + def stabilize_field(self, sm): + if sm.now < sm.stabilize_start + self.wait_stable_field: + return Retry + return self.check_switch_off + + def check_switch_off(self, sm): + if sm.mode == Mode.DRIVEN: + return self.final_status(Status.PREPARED, 'driven') + return self.start_switch_off + + @status_code(Status.FINALIZING) + def start_switch_off(self, sm): + if self.switch_heater == ON: + self.write_switch_heater(OFF) + return self.wait_for_switch_off + + @status_code(Status.FINALIZING) + def wait_for_switch_off(self, sm): + if sm.now - self.switch_off_time < self.wait_switch_off: + return Retry + if abs(self.value) > self.persistent_limit: + return self.final_status(Status.IDLE, 'leads current at field, switch off') + return self.start_ramp_to_zero + + @status_code(Status.FINALIZING) + def start_ramp_to_zero(self, sm): + """start ramping current to zero + + initiate ramp to zero (with corresponding ramp rate) + the implementation should return ramp_to_zero + """ + raise NotImplementedError + + @status_code(Status.FINALIZING) + def ramp_to_zero(self, sm): + """ramp field to zero""" + if sm.init: + self.init_progress(sm, self.current) + if abs(self.current) > self.tolerance: + if self.get_progress(sm, self.value) > self.leads_ramp_tmo: + raise HardwareError('no progress') + return Retry + if sm.mode == Mode.DISABLED and abs(self.value) < self.tolerance: + return self.final_status(Status.DISABLED, 'disabled') + return self.final_status(Status.IDLE, 'persistent mode') diff --git a/frappy_psi/phytron.py b/frappy_psi/phytron.py new file mode 100644 index 0000000..fe619fc --- /dev/null +++ b/frappy_psi/phytron.py @@ -0,0 +1,254 @@ +# -*- 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 +# +# ***************************************************************************** + +"""driver for phytron motors""" + +from frappy.core import Done, Command, EnumType, FloatRange, IntRange, \ + HasIO, Parameter, Property, Drivable, PersistentMixin, PersistentParam, \ + StringIO, StringType, TupleOf +from frappy.errors import CommunicationFailedError, HardwareError, BadValueError +from frappy.lib import clamp + + +class PhytronIO(StringIO): + end_of_line = '\x03' # ETX + timeout = 0.5 + identification = [('0IVR', 'MCC Minilog .*')] + + def communicate(self, command): + ntry = 5 + warn = None + for itry in range(ntry): + try: + _, _, reply = super().communicate('\x02' + command).partition('\x02') + if reply[0] == '\x06': # ACK + break + raise CommunicationFailedError('missing ACK %r (cmd: %r)' + % (reply, command)) + except Exception as e: + if itry < ntry - 1: + warn = e + else: + raise + if warn: + self.log.warning('needed %d retries after %r', itry, warn) + return reply[1:] + + +class Motor(PersistentMixin, HasIO, Drivable): + axis = Property('motor axis X or Y', StringType(), default='X') + address = Property('address', IntRange(0, 15), default=0) + + encoder_mode = Parameter('how to treat the encoder', EnumType('encoder', NO=0, READ=1, CHECK=2), + default=1, readonly=False) + value = Parameter('angle', FloatRange(unit='deg')) + target = Parameter('target angle', FloatRange(unit='deg'), readonly=False) + speed = Parameter('', FloatRange(0, 20, unit='deg/s'), readonly=False) + accel = Parameter('', FloatRange(2, 250, unit='deg/s/s'), readonly=False) + encoder_tolerance = Parameter('', FloatRange(unit='deg'), readonly=False, default=0.01) + offset = PersistentParam('', FloatRange(unit='deg'), readonly=False, default=0) + sign = PersistentParam('', IntRange(-1,1), readonly=False, default=1) + encoder = Parameter('encoder reading', FloatRange(unit='deg')) + backlash = Parameter("""backlash compensation\n + offset for always approaching from the same side""", + FloatRange(unit='deg'), readonly=False, default=0) + abslimits = Parameter('abs limits (raw values)', default=(0, 0), + datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg'))) + userlimits = PersistentParam('user limits', readonly=False, default=(0, 0), initwrite=True, + datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg'))) + + ioClass = PhytronIO + fast_poll = 0.1 + _backlash_pending = False + _mismatch_count = 0 + _rawlimits = None + _step_size = None # degree / step + + def earlyInit(self): + super().earlyInit() + if self.abslimits == (0, 0): + self.abslimits = -9e99, 9e99 + if self.userlimits == (0, 0): + self._rawlimits = self.abslimits + self.read_userlimits() + self.loadParameters() + + def get(self, cmd): + return self.communicate('%x%s%s' % (self.address, self.axis, cmd)) + + def set(self, cmd, value): + # make sure e format is not used, max 8 characters + strvalue = '%.6g' % value + if 'e' in strvalue: + if abs(value) <= 1: # very small number + strvalue = '%.7f' % value + elif abs(value) < 99999999: # big number + strvalue = '%.0f' % value + else: + raise ValueError('number (%g) must not have more than 8 digits' % value) + self.communicate('%x%s%s%s' % (self.address, self.axis, cmd, strvalue)) + + def set_get(self, cmd, value, query): + self.set(cmd, value) + return self.get(query) + + def read_value(self): + prev_enc = self.encoder + pos = float(self.get('P20R')) * self.sign - self.offset + if self.encoder_mode != 'NO': + enc = self.read_encoder() + else: + enc = pos + status = self.communicate('%xSE' % self.address) + status = status[0:4] if self.axis == 'X' else status[4:8] + self.log.debug('run %s enc %s end %s', status[1], status[2], status[3]) + status = self.get('=H') + if status == 'N': + if self.encoder_mode == 'CHECK': + e1, e2 = sorted((prev_enc, enc)) + if e1 - self.encoder_tolerance <= pos <= e2 + self.encoder_tolerance: + self.status = self.Status.BUSY, 'driving' + else: + self.log.error('encoder lag: %g not within %g..%g', + pos, e1, e2) + self.get('S') # stop + self.status = self.Status.ERROR, 'encoder lag error' + self.setFastPoll(False) + else: + self.status = self.Status.BUSY, 'driving' + else: + if self._backlash_pending: + # drive to real target + self.set('A', self.sign * (self.target + self.offset)) + self._backlash_pending = False + return pos + if (self.encoder_mode == 'CHECK' and + abs(enc - pos) > self.encoder_tolerance): + if self._mismatch_count < 2: + self._mismatch_count += 1 + else: + self.log.error('encoder mismatch: abs(%g - %g) < %g', + enc, pos, self.encoder_tolerance) + self.status = self.Status.ERROR, 'encoder does not match pos' + else: + self._mismatch_count = 0 + self.status = self.Status.IDLE, '' + self.setFastPoll(False) + return pos + + def read_encoder(self): + if self.encoder_mode == 'NO': + return self.value + return float(self.get('P22R')) * self.sign - self.offset + + def write_sign(self, value): + self.sign = value + self.saveParameters() + return Done + + def get_step_size(self): + self._step_size = float(self.get('P03R')) + + def read_speed(self): + if self._step_size is None: + # avoid repeatedly reading step size, as this is polled and will not change + self.get_step_size() + return float(self.get('P14R')) * self._step_size + + def write_speed(self, value): + self.get_step_size() # read step size anyway, it does not harm + return float(self.set_get('P14S', round(value / self._step_size), 'P14R')) * self._step_size + + def read_accel(self): + if not self._step_size: + self.get_step_size() + return float(self.get('P15R')) * self._step_size + + def write_accel(self, value): + self.get_step_size() + return float(self.set_get('P15S', round(value / self._step_size), 'P15R')) * self._step_size + + def _check_limits(self, *values): + for name, (mn, mx) in ('user', self._rawlimits), ('abs', self.abslimits): + mn -= self.offset + mx -= self.offset + for v in values: + if not mn <= v <= mx: + raise BadValueError('%s limits violation: %g <= %g <= %g' % (name, mn, v, mx)) + v += self.offset + + def write_target(self, value): + if self.status[0] == self.Status.ERROR: + raise HardwareError('need reset') + self.status = self.Status.BUSY, 'changed target' + self._check_limits(value, value + self.backlash) + if self.backlash: + # drive first to target + backlash + # we do not optimize when already driving from the right side + self._backlash_pending = True + self.set('A', self.sign * (value + self.offset + self.backlash)) + else: + self.set('A', self.sign * (value + self.offset)) + self.setFastPoll(True, self.fast_poll) + return value + + def read_userlimits(self): + return self._rawlimits[0] - self.offset, self._rawlimits[1] - self.offset + + def write_userlimits(self, value): + self._rawlimits = [clamp(self.abslimits[0], v + self.offset, self.abslimits[1]) for v in value] + value = self.read_userlimits() + self.saveParameters() + return value + + def write_offset(self, value): + self.offset = value + self.read_userlimits() + self.saveParameters() + return Done + + def stop(self): + self.get('S') + + @Command + def reset(self): + """Reset error, set position to encoder""" + self.read_value() + if self.status[0] == self.Status.ERROR: + enc = self.encoder + self.offset + pos = self.value + self.offset + if abs(enc - pos) > self.encoder_tolerance: + if enc < 0: + # assume we have a rotation (not a linear motor) + while enc < 0: + self.offset += 360 + enc += 360 + self.set('P22S', enc * self.sign) + self.saveParameters() + self.set('P20S', enc * self.sign) # set pos to encoder + self.read_value() + # self.status = self.Status.IDLE, '' + +# TODO: +# '=E' electronics status +# '=I+' / '=I-': limit switches +# use P37 to determine if restarted diff --git a/frappy_psi/sea.py b/frappy_psi/sea.py new file mode 100644 index 0000000..c73b1be --- /dev/null +++ b/frappy_psi/sea.py @@ -0,0 +1,704 @@ +# -*- 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 +# ***************************************************************************** +"""generic SEA driver + +a object or subobject in sea may be assigned to a SECoP module + +Examples: + +SECoP SEA hipadaba path mod.obj mod.sub par.sub mod.path +------------------------------------------------------------------------------- +tt:maxwait tt /tt/maxwait tt maxwait /tt +tt:ramp tt set/ramp /tt/set/ramp tt set/ramp /tt +t1:raw tt t1/raw /tt/t1/raw tt t1 raw /tt/t1 +rx:bla rx bla /some/rx_a/bla rx bla /some/rx_a +""" + +import json +import threading +import time +import os +from os.path import expanduser, join, exists + +from frappy.client import ProxyClient +from frappy.datatypes import ArrayOf, BoolType, \ + EnumType, FloatRange, IntRange, StringType +from frappy.errors import ConfigError, HardwareError, secop_error, NoSuchModuleError, \ + CommunicationFailedError +from frappy.lib import generalConfig, mkthread, formatExtendedStack +from frappy.lib.asynconn import AsynConn, ConnectionClosed +from frappy.modules import Attached, Command, Done, Drivable, \ + Module, Parameter, Property, Readable, Writable +from frappy.protocol.dispatcher import make_update + + +CFG_HEADER = """Node( + description = '''%(nodedescr)s''', + id = %(config)s.sea.psi.ch, +) +Mod(%(seaconn)r, + 'frappy_psi.sea.SeaClient', + '%(service)s sea connection for %(config)s', + config = %(config)r, + service = %(service)r, +) +""" + +CFG_MODULE = """Mod(%(module)r, + 'frappy_psi.sea.%(modcls)s', + io = %(seaconn)r, + sea_object = %(module)r, +) +""" + +SERVICE_NAMES = { + 'config': 'main', + 'stick': 'stick', + 'addon': 'addons', +} + +SEA_DIR = expanduser('~/sea') +for confdir in generalConfig.confdir.split(os.pathsep): + seaconfdir = join(confdir, 'sea') + if exists(seaconfdir): + break +else: + seaconfdir = os.environ.get('FRAPPY_SEA_DIR') + + +def get_sea_port(instance): + for filename in ('sea_%s.tcl' % instance, 'sea.tcl'): + try: + with open(join(SEA_DIR, filename)) as f: + for line in f: + linesplit = line.split() + if len(linesplit) == 3: + _, var, value = line.split() + if var == 'serverport': + return value + except FileNotFoundError: + pass + return None + + +class SeaClient(ProxyClient, Module): + """connection to SEA""" + + uri = Parameter('hostname:portnumber', datatype=StringType(), default='localhost:5000') + timeout = Parameter('timeout', datatype=FloatRange(0), default=10) + config = Property("""needed SEA configuration, space separated + + Example: "ori4.config ori4.stick" + """, StringType(), default='') + service = Property("main/stick/addons", StringType(), default='') + visibility = 'expert' + default_json_file = {} + _connect_thread = None + _service_manager = None + _instance = None + + def __init__(self, name, log, opts, srv): + instance = srv.node_cfg['name'].rsplit('_', 1)[0] + if 'uri' not in opts: + self._instance = instance + port = get_sea_port(instance) + if port is None: + raise ConfigError('missing sea port for %s' % instance) + opts['uri'] = 'tcp://localhost:%s' % port + self.objects = set() + self.shutdown = False + self.path2param = {} + self._write_lock = threading.Lock() + config = opts.get('config') + if config: + self.default_json_file[name] = config.split()[0] + '.json' + self.syncio = None + self.asynio = None + ProxyClient.__init__(self) + Module.__init__(self, name, log, opts, srv) + + def register_obj(self, module, obj): + self.objects.add(obj) + for k, v in module.path2param.items(): + self.path2param.setdefault(k, []).extend(v) + self.register_callback(module.name, module.updateEvent) + + def startModule(self, start_events): + super().startModule(start_events) + self._connect_thread = mkthread(self._connect, start_events.get_trigger()) + + def _connect(self, started_callback): + if self._instance: + if not self._service_manager: + from servicemanager import SeaManager + self._service_manager = SeaManager() + self._service_manager.do_start(self._instance) + if '//' not in self.uri: + self.uri = 'tcp://' + self.uri + self.asynio = AsynConn(self.uri) + # print('CONNECT', self.uri, self.asynio) + # print(formatExtendedStack()) + reply = self.asynio.readline() + if reply != b'OK': + raise CommunicationFailedError('reply %r should be "OK"' % reply) + for _ in range(2): + self.asynio.writeline(b'Spy 007') + reply = self.asynio.readline() + if reply == b'Login OK': + break + else: + raise CommunicationFailedError('reply %r should be "Login OK"' % reply) + self.request('frappy_config %s %s' % (self.service, self.config)) + + # frappy_async_client switches to the json protocol (better for updates) + self.asynio.writeline(b'frappy_async_client') + self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode()) + self._connect_thread = None + mkthread(self._rxthread, started_callback) + + def request(self, command, quiet=False): + """send a request and wait for reply""" + with self._write_lock: + if not self.syncio or not self.syncio.connection: + if not self.asynio or not self.asynio.connection: + try: + self._connect_thread.join() + except AttributeError: + pass + self._connect(None) + self.syncio = AsynConn(self.uri) + # print('SYNCIO', self.uri) + assert self.syncio.readline() == b'OK' + self.syncio.writeline(b'seauser seaser') + assert self.syncio.readline() == b'Login OK' + print('connected to %s' % self.uri) + self.syncio.flush_recv() + # print('> %s' % command) + ft = 'fulltransAct' if quiet else 'fulltransact' + self.syncio.writeline(('%s %s' % (ft, command)).encode()) + result = None + deadline = time.time() + 10 + while time.time() < deadline: + try: + reply = self.syncio.readline() + if reply is None: + continue + except ConnectionClosed: + break + reply = reply.decode() + # print('< %s' % reply) + if reply.startswith('TRANSACTIONSTART'): + result = [] + continue + if reply == 'TRANSACTIONFINISHED': + if result is None: + print('missing TRANSACTIONSTART on: %s' % command) + return '' + if not result: + return '' + return '\n'.join(result) + if result is None: + print('swallow: %s' % reply) + continue + if not result: + result = [reply.split('=', 1)[-1]] + else: + result.append(reply) + raise TimeoutError('no response within 10s') + + def _rxthread(self, started_callback): + while not self.shutdown: + try: + reply = self.asynio.readline() + if reply is None: + continue + except ConnectionClosed: + break + try: + msg = json.loads(reply) + except Exception as e: + print(repr(e), reply) + continue + if isinstance(msg, str): + if msg.startswith('_E '): + try: + _, path, readerror = msg.split(None, 2) + except ValueError: + continue + else: + continue + data = {'%s.geterror' % path: readerror.replace('ERROR: ', '')} + obj = None + flag = 'hdbevent' + else: + obj = msg['object'] + flag = msg['flag'] + data = msg['data'] + if flag == 'finish' and obj == 'get_all_param': + # first updates have finished + if started_callback: + started_callback() + started_callback = None + continue + if flag != 'hdbevent': + if obj not in ('frappy_async_client', 'get_all_param'): + print('SKIP', msg) + continue + if not data: + continue + if not isinstance(data, dict): + print('what means %r' % msg) + continue + now = time.time() + for path, value in data.items(): + readerror = None + if path.endswith('.geterror'): + if value: + readerror = HardwareError(value) + path = path.rsplit('.', 1)[0] + value = None + mplist = self.path2param.get(path) + if mplist is None: + if path.startswith('/device'): + if path == '/device/changetime': + result = self.request('check_config %s %s' % (self.service, self.config)) + if result == '1': + self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode()) + else: + self.DISPATCHER.shutdown() + elif path.startswith('/device/frappy_%s' % self.service) and value == '': + self.DISPATCHER.shutdown() + else: + for module, param in mplist: + oldv, oldt, oldr = self.cache.get((module, param), [None, None, None]) + if value is None: + value = oldv + if value != oldv or str(readerror) != str(oldr) or abs(now - oldt) > 60: + # do not update unchanged values within 60 sec + self.updateValue(module, param, value, now, readerror) + + @Command(StringType(), result=StringType()) + def communicate(self, command): + """send a command to SEA""" + reply = self.request(command) + return reply + + @Command(StringType(), result=StringType()) + def query(self, cmd, quiet=False): + """a request checking for errors and accepting 0 or 1 line as result""" + errors = [] + reply = None + for line in self.request(cmd, quiet).split('\n'): + if line.strip().startswith('ERROR:'): + errors.append(line[6:].strip()) + elif reply is None: + reply = line.strip() + else: + self.log.info('SEA: superfluous reply %r to %r', reply, cmd) + if errors: + raise HardwareError('; '.join(errors)) + return reply + + +class SeaConfigCreator(SeaClient): + def startModule(self, started_callback): + """save objects (and sub-objects) description and exit""" + self._connect(None) + reply = self.request('describe_all') + reply = ''.join('' if line.startswith('WARNING') else line for line in reply.split('\n')) + description, reply = json.loads(reply) + modules = {} + modcls = {} + for filename, obj, descr in reply: + if filename not in modules: + modules[filename] = {} + if descr['params'][0].get('cmd', '').startswith('run '): + modcls[obj] = 'SeaDrivable' + elif not descr['params'][0].get('readonly', True): + modcls[obj] = 'SeaWritable' + else: + modcls[obj] = 'SeaReadable' + modules.setdefault(filename, {})[obj] = descr + + result = [] + for filename, descr in modules.items(): + stripped, _, ext = filename.rpartition('.') + service = SERVICE_NAMES[ext] + seaconn = 'sea_' + service + cfgfile = join(seaconfdir, stripped + '_cfg.py') + with open(cfgfile, 'w') as fp: + fp.write(CFG_HEADER % dict(config=filename, seaconn=seaconn, service=service, + nodedescr=description.get(filename, filename))) + for obj in descr: + fp.write(CFG_MODULE % dict(modcls=modcls[obj], module=obj, seaconn=seaconn)) + content = json.dumps(descr).replace('}, {', '},\n{').replace('[{', '[\n{').replace('}]}, ', '}]},\n\n') + result.append('%s\n' % cfgfile) + with open(join(seaconfdir, filename + '.json'), 'w') as fp: + fp.write(content + '\n') + result.append('%s: %s' % (filename, ','.join(n for n in descr))) + raise SystemExit('; '.join(result)) + + @Command(StringType(), result=StringType()) + def query(self, cmd): + """a request checking for errors and accepting 0 or 1 line as result""" + errors = [] + reply = None + for line in self.request(cmd).split('\n'): + if line.strip().startswith('ERROR:'): + errors.append(line[6:].strip()) + elif reply is None: + reply = line.strip() + else: + self.log.info('SEA: superfluous reply %r to %r', reply, cmd) + if errors: + raise HardwareError('; '.join(errors)) + return reply + + +SEA_TO_SECOPTYPE = { + 'float': FloatRange(), + 'text': StringType(), + 'int': IntRange(-1 << 63, 1 << 63 - 1), + 'bool': BoolType(), + 'none': None, + 'floatvarar': ArrayOf(FloatRange(), 0, 400), # 400 is the current limit for proper notify events in SEA +} + + +def get_datatype(paramdesc): + typ = paramdesc['type'] + result = SEA_TO_SECOPTYPE.get(typ, False) + if result is not False: # general case + return result + # special cases + if typ == 'enum': + return EnumType(paramdesc['enum']) + raise ValueError('unknown SEA type %r' % typ) + + +class SeaModule(Module): + io = Attached() + + path2param = None + sea_object = None + hdbpath = None # hdbpath for main writable + + def __new__(cls, name, logger, cfgdict, srv): + if hasattr(srv, 'extra_sea_modules'): + extra_modules = srv.extra_sea_modules + else: + extra_modules = {} + srv.extra_sea_modules = extra_modules + json_file = cfgdict.pop('json_file', None) or SeaClient.default_json_file[cfgdict['io']] + visibility_level = cfgdict.pop('visibility_level', 2) + + single_module = cfgdict.pop('single_module', None) + if single_module: + sea_object, base, paramdesc = extra_modules[single_module] + params = [paramdesc] + paramdesc['key'] = 'value' + if issubclass(cls, SeaWritable): + if paramdesc.get('readonly', True): + raise ConfigError('%s/%s is not writable' % (sea_object, paramdesc['path'])) + params.insert(0, paramdesc.copy()) # copy value + paramdesc['key'] = 'target' + paramdesc['readonly'] = False + extra_module_set = () + if 'description' not in cfgdict: + cfgdict['description'] = '%s@%s' % (single_module, json_file) + else: + sea_object = cfgdict.pop('sea_object') + rel_paths = cfgdict.pop('rel_paths', '.') + if 'description' not in cfgdict: + cfgdict['description'] = '%s@%s%s' % ( + name, json_file, '' if rel_paths == '.' else ' (rel_paths=%s)' % rel_paths) + + # with open(join(seaconfdir, json_file + '.json')) as fp: + # sea_object, descr = json.load(fp) + with open(join(seaconfdir, json_file)) as fp: + content = json.load(fp) + # print(json_file, content.keys()) + descr = content[sea_object] + if rel_paths == '*' or not rel_paths: + # take all + main = descr['params'][0] + if issubclass(cls, Readable): + # assert main['path'] == '' # TODO: check cases where this fails + main['key'] = 'value' + else: + descr['params'].pop(0) + else: + # filter by relative paths + rel_paths = rel_paths.split() + result = [] + is_running = None + for rpath in rel_paths: + include = True + for paramdesc in descr['params']: + path = paramdesc['path'] + if path.endswith('is_running') and issubclass(cls, Drivable): + # take this independent of visibility + is_running = paramdesc + continue + if paramdesc.get('visibility', 1) > visibility_level: + continue + sub = path.split('/', 1) + if rpath == '.': # take all except subpaths with readonly node at top + if len(sub) == 1: + include = paramdesc.get('kids', 0) == 0 or not paramdesc.get('readonly', True) + if include or path == '': + result.append(paramdesc) + elif sub[0] == rpath: + result.append(paramdesc) + if is_running: # take this at end + result.append(is_running) + descr['params'] = result + rel0 = '' if rel_paths[0] == '.' else rel_paths[0] + if result[0]['path'] == rel0: + if issubclass(cls, Readable): + result[0]['key'] = 'value' + else: + result.pop(0) + else: + logger.error('%s: no value found', name) + # logger.info('PARAMS %s %r', name, result) + base = descr['base'] + params = descr['params'] + if issubclass(cls, SeaWritable): + paramdesc = params[0] + assert paramdesc['key'] == 'value' + params.append(paramdesc.copy()) # copy value? + if paramdesc.get('readonly', True): + raise ConfigError('%s/%s is not writable' % (sea_object, paramdesc['path'])) + paramdesc['key'] = 'target' + paramdesc['readonly'] = False + extra_module_set = cfgdict.pop('extra_modules', ()) + if extra_module_set: + extra_module_set = set(extra_module_set.replace(',', ' ').split()) + path2param = {} + attributes = dict(sea_object=sea_object, path2param=path2param) + + # some guesses about visibility (may be overriden in *_cfg.py): + if sea_object in ('table', 'cc'): + attributes['visibility'] = 2 + elif base.count('/') > 1: + attributes['visibility'] = 2 + for paramdesc in params: + path = paramdesc['path'] + readonly = paramdesc.get('readonly', True) + dt = get_datatype(paramdesc) + #print('----', sea_object) + #print(dt, paramdesc) + kwds = dict(description=paramdesc.get('description', path), + datatype=dt, + visibility=paramdesc.get('visibility', 1), + needscfg=False, readonly=readonly) + if kwds['datatype'] is None: + kwds.update(visibility=3, default='', datatype=StringType()) + pathlist = path.split('/') if path else [] + key = paramdesc.get('key') # None, 'value' or 'target' + if key is None: + if len(pathlist) > 0: + if len(pathlist) == 1: + if issubclass(cls, Readable): + kwds['group'] = 'more' + else: + kwds['group'] = pathlist[-2] + # flatten path to parameter name + for i in reversed(range(len(pathlist))): + key = '_'.join(pathlist[i:]) + if not key in cls.accessibles: + break + if key == 'is_running': + kwds['export'] = False + if key == 'target' and kwds.get('group') == 'more': + kwds.pop('group') + if key in cls.accessibles: + if key == 'target': + kwds['readonly'] = False + prev = cls.accessibles[key] + if key == 'status': + # special case: status from sea is a string, not the status tuple + pobj = prev.copy() + else: + pobj = Parameter(**kwds) + merged_properties = prev.propertyValues.copy() + pobj.updateProperties(merged_properties) + pobj.merge(merged_properties) + else: + pobj = Parameter(**kwds) + datatype = pobj.datatype + if issubclass(cls, SeaWritable) and key == 'target': + kwds['readonly'] = False + attributes['value'] = Parameter(**kwds) + + hdbpath = '/'.join([base] + pathlist) + if key in extra_module_set: + extra_modules[name + '.' + key] = sea_object, base, paramdesc + continue # skip this parameter + path2param.setdefault(hdbpath, []).append((name, key)) + attributes[key] = pobj + # if hasattr(cls, 'read_' + key): + # print('override %s.read_%s' % (cls.__name__, key)) + + def rfunc(self, cmd='hval %s/%s' % (base, path)): + reply = self.io.query(cmd, True) + try: + reply = float(reply) + except ValueError: + pass + # an updateEvent will be handled before above returns + return reply + + rfunc.poll = False + attributes['read_' + key] = rfunc + + if not readonly: + # if hasattr(cls, 'write_' + key): + # print('override %s.write_%s' % (cls.__name__, key)) + + def wfunc(self, value, datatype=datatype, command=paramdesc['cmd']): + value = datatype.export_value(value) + if isinstance(value, bool): + value = int(value) + # TODO: check if more has to be done for valid tcl data (strings?) + cmd = "%s %s" % (command, value) + self.io.query(cmd) + return Done + + attributes['write_' + key] = wfunc + + # create standard parameters like value and status, if not yet there + for pname, pobj in cls.accessibles.items(): + if pname == 'pollinterval': + pobj.export = False + attributes[pname] = pobj + pobj.__set_name__(cls, pname) + elif pname not in attributes and isinstance(pobj, Parameter): + pobj.needscfg = False + attributes[pname] = pobj + pobj.__set_name__(cls, pname) + + classname = '%s_%s' % (cls.__name__, name) + newcls = type(classname, (cls,), attributes) + result = Module.__new__(newcls) + return result + + def updateEvent(self, module, parameter, value, timestamp, readerror): + upd = getattr(self, 'update_' + parameter, None) + if upd: + upd(value, timestamp, readerror) + return + try: + pobj = self.parameters[parameter] + except KeyError: + print(self.name, parameter) + raise + pobj.timestamp = timestamp + #if not pobj.readonly and pobj.value != value: + # print('UPDATE', module, parameter, value) + # should be done here: deal with clock differences + if not readerror: + try: + pobj.value = value # store the value even in case of a validation error + pobj.value = pobj.datatype(value) + except Exception as e: + readerror = secop_error(e) + pobj.readerror = readerror + if pobj.export: + self.DISPATCHER.broadcast_event(make_update(self.name, pobj)) + + def initModule(self): + self.io.register_obj(self, self.sea_object) + super().initModule() + + def doPoll(self): + pass + + +class SeaReadable(SeaModule, Readable): + + def update_status(self, value, timestamp, readerror): + if readerror: + value = repr(readerror) + if value == '': + self.status = (self.Status.IDLE, '') + else: + self.status = (self.Status.ERROR, value) + + def read_status(self): + return self.status + + +class SeaWritable(SeaModule, Writable): + def read_value(self): + return self.target + + def update_target(self, value, timestamp, readerror): + self.target = value + if not readerror: + self.value = value + + +class SeaDrivable(SeaModule, Drivable): + _sea_status = '' + _is_running = 0 + + def read_status(self): + return self.status + + # def read_target(self): + # return self.target + + def write_target(self, value): + self.io.query('run %s %s' % (self.sea_object, value)) + # self.status = [self.Status.BUSY, 'driving'] + return value + + def update_status(self, value, timestamp, readerror): + if not readerror: + self._sea_status = value + self.updateStatus() + + def update_is_running(self, value, timestamp, readerror): + if not readerror: + self._is_running = value + self.updateStatus() + + def updateStatus(self): + if self._sea_status: + self.status = (self.Status.ERROR, self._sea_status) + elif self._is_running: + self.status = (self.Status.BUSY, 'driving') + else: + self.status = (self.Status.IDLE, '') + + def updateTarget(self, module, parameter, value, timestamp, readerror): + if value is not None: + self.target = value + + @Command() + def stop(self): + """propagate to SEA + + - on stdsct drivables this will call the halt script + - on EaseDriv this will set the stopped state + """ + self.io.query('%s is_running 0' % self.sea_object) diff --git a/frappy_psi/senis.py b/frappy_psi/senis.py new file mode 100644 index 0000000..f129a04 --- /dev/null +++ b/frappy_psi/senis.py @@ -0,0 +1,307 @@ +#!/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 +# ***************************************************************************** +"""senis hall sensor""" + + +import threading +import time + +import numpy as np +from serial import Serial + +from frappy.core import Attached, BoolType, FloatRange, IntRange, \ + Parameter, Property, Readable, StringType, TupleOf + + +class Temperature(Readable): + enablePoll = False + + value = Parameter(datatype=FloatRange(unit='degC')) + + +class Bcomp(Readable): + enablePoll = False + + value = Parameter(datatype=FloatRange(unit='T')) + range = Parameter('working range', FloatRange(unit='T'), default=0) + + +class Raw(Readable): + enablePoll = False + + value = Parameter(datatype=FloatRange()) + + +class TeslameterBase(Readable): + """code for both models + + the protocol is somewhat weird as the read command 'B' initiates a permanent update + which has to be stopped in between the value polls and for other communication. + + the B components (and temperatures for 3MH6) are implemented as separate modules + """ + x = Attached() + y = Attached() + z = Attached() + + value = Parameter('B vector', + datatype=TupleOf(FloatRange(unit='T'), FloatRange(unit='T'), FloatRange(unit='T'))) + usb = Parameter('usb device', StringType(), readonly=False) + enabled = Parameter('enable data acq', datatype=BoolType(), readonly=False, default=True) + nsample = Parameter('number of samples for average', datatype=IntRange(1, 1000), readonly=False, default=1) + + def init_serial(self, baud): + self._conn = Serial(self.usb, baud, timeout=0.1) + self._lock = threading.Lock() + self.stop_reading() + + def write_bytes(self, msg): + with self._lock: + self._conn.write(msg) + + def read_bytes(self, cnt): + with self._lock: + return self._conn.read(cnt) + + def stop_reading(self): + self.write_bytes(b'S') + self.read_bytes(9999) # swallow bytes until timeout + + def write_enabled(self, value): + if value: + self.status = self.Status.IDLE, '' + else: + self.status = self.Status.DISABLED, 'disabled' + self._x.status = self._y.status = self._z.status = self.status + return value + + +class Teslameter3MH3(TeslameterBase): + """simpler model without temperature and auto range + + remark: no query for the sample rate is possible, therefore set always to + a default rate (therefore initwrite=True on the rate parameter) + """ + range = Property('full scale', datatype=FloatRange(), default=2) + + def earlyInit(self): + self.init_serial(115200) + self.write_bytes(b'C') # put into calibrated mode + if self.read_bytes(1) != b'!': + self.log.error('missing response to C command') + self.write_bytes(b'A\x80\r') # set to 90 SPS + self.read_bytes(1) # wait 0.1 sec as we get no reply + + def read_value(self): + if not self.enabled: + return self.value + s = self._conn + s.timeout = 0.1 + 0.02 * self.nsample + for _ in range(2): + self.write_bytes(b'B') + # t = time.time() + reply = self.read_bytes(8 * self.nsample) + s.timeout = 0.1 + self.stop_reading() + remainder = len(reply) % 8 + if remainder: + reply = reply[:-remainder] + if not reply: + continue + data = np.frombuffer(reply, dtype='i1,3f4'), ('thc', '>f4'), ('y', '>f4'), ('z', '>f4'), + ('_ted', '>i2'), ('tec', '>f4'), ('_tail', 'i2')]) + + mean = {} + for key in data.dtype.fields: + if not key.startswith('_'): + mean[key] = np.average(data[key]) + + self._x.value = mean['x'] * 0.001 + self._y.value = mean['y'] * 0.001 + self._z.value = mean['z'] * 0.001 + self._probe_temp.value = mean['thc'] + self._box_temp.value = mean['tec'] + + self.write_bytes(b'D') # put into NONcalibrated mode + if self.read_bytes(1) != b'd': + self.log.error('missing response to D command') + reply = self.get_data() + data = np.frombuffer(reply, + dtype=[('_head', 'i1'), + ('x', '>i4'), ('thc', '>i4'), ('y', '>i4'), ('z', '>i4'), + ('_ted', '>i2'), ('tec', '>i4'), ('_tail', 'i2')]) + for key in data.dtype.fields: + if not key.startswith('_'): + mean[key] = np.average(data[key]) + + self._x_direct.value = mean['x'] + self._y_direct.value = mean['y'] + self._z_direct.value = mean['z'] + self._probe_temp_direct.value = mean['thc'] + self._box_temp_direct.value = mean['tec'] * 0.01 + + self.avtime = time.time() - t0 + return self._x.value, self._y.value, self._z.value + + def get_rate_code(self, value): + for rate_code, sr in sorted(self.SAMPLING_RATES.items(), key=lambda kv: kv[1]): + if value < sr * 1.1: + break + return sr, rate_code + + def write_rate(self, value): + sr, code = self.get_rate_code(value) + for _ in range(2): + self.write_bytes(b'K%2.2x' % code) + if self.read_bytes(2) == b'k%c' % code: + break + self.stop_reading() + else: + raise ValueError('bad response from rate command') + return sr + + def read_rate(self): + self.write_bytes(b'K?') + reply = self.read_bytes(2) + if reply[0:1] != b'k': + raise ValueError('bad response from rate query') + return self.SAMPLING_RATES[reply[1]] + + def read_range(self): + self.write_bytes(b'amr?') + reply = self.read_bytes(5) + if reply == b'arng:': + ranges = [self.RANGES[c] for c in self.read_bytes(3)] + result = 0 + elif reply == b'mrng:': + ranges = [self.RANGES[self.read_bytes(1)[0]]] * 3 + result = ranges[0] + else: + raise ValueError('bad reply to range query %s' % repr(reply)) + self._x.range, self._y.range, self._z.range = ranges + return result + + def write_range(self, value): + status = None + for _ in range(2): + if status: + self.stop_reading() + try: + rng = self.read_range() + except ValueError: + status = 'can not read range' + continue + if value == rng: + return value + if value == 0: + self.write_bytes(b'T') + if self.read_bytes(3) != b'T-1': + status = 'bad reply to auto range command' + continue + return 0 + if rng == 0: + self.write_bytes(b'T') + if self.read_bytes(3) != b'T-0': + status = 'bad reply to toggle manual range command' + continue + for code, rng in sorted(self.RANGES.items()): + if value < rng * 1.1: + break + self.write_bytes(b'mr%c' % code) + reply = self.read_bytes(6) + if reply != b'mrng:%c' % code: + status = 'bad reply to manual range command %s' % repr(reply) + continue + return rng + raise ValueError(status) diff --git a/frappy_psi/simdpm.py b/frappy_psi/simdpm.py new file mode 100644 index 0000000..de00edb --- /dev/null +++ b/frappy_psi/simdpm.py @@ -0,0 +1,49 @@ +# -*- 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: +# M. Zolliker +# +# ***************************************************************************** +"""simulated transducer DPM3 read out""" + +import random +import math +from frappy.core import Readable, Parameter, FloatRange, Attached +from frappy.lib import clamp + + +class DPM3(Readable): + motor = Attached() + jitter = Parameter('simulated jitter', FloatRange(unit='N'), default=0.1, readonly=False) + hysteresis = Parameter('simulated hysteresis', FloatRange(unit='deg'), default=100, readonly=False) + friction = Parameter('friction', FloatRange(unit='N/deg'), default=2.5, readonly=False) + slope = Parameter('slope', FloatRange(unit='N/deg'), default=0.5, readonly=False) + offset = Parameter('offset', FloatRange(unit='N'), default=0, readonly=False) + + _pos = 0 + + def read_value(self): + mot = self._motor + d = self.friction * self.slope + self._pos = clamp(self._pos, mot.value - d, mot.value + d) + f = (mot.value - self._pos) / self.slope + if mot.value > 0: + f = max(f, (mot.value - self.hysteresis) / self.slope) + else: + f = min(f, (mot.value + self.hysteresis) / self.slope) + return f + self.jitter * (random.random() - random.random()) * 0.5 diff --git a/frappy_psi/triton.py b/frappy_psi/triton.py new file mode 100644 index 0000000..1feffd8 --- /dev/null +++ b/frappy_psi/triton.py @@ -0,0 +1,320 @@ +#!/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 +# ***************************************************************************** +"""oxford instruments triton (kelvinoxjt dil)""" + +from math import sqrt, log10 +from frappy.core import Writable, Parameter, Readable, Drivable, IDLE, WARN, BUSY, ERROR, \ + Done, Property +from frappy.datatypes import EnumType, FloatRange, StringType +from frappy.lib.enum import Enum +from frappy_psi.mercury import MercuryChannel, Mapped, off_on, HasInput, SELF +from frappy.lib import clamp +import frappy_psi.mercury as mercury + +actions = Enum(none=0, condense=1, circulate=2, collect=3) +open_close = Mapped(CLOSE=0, OPEN=1) +actions_map = Mapped(STOP=actions.none, COND=actions.condense, COLL=actions.collect) +actions_map.mapping['NONE'] = actions.none # when writing, STOP is used instead of NONE + + +class Action(MercuryChannel, Writable): + channel_type = 'ACTN' + cooldown_channel = Property('cool down channel', StringType(), 'T5') + mix_channel = Property('mix channel', StringType(), 'T5') + value = Parameter('running action', EnumType(actions)) + target = Parameter('action to do', EnumType(none=0, condense=1, collect=3), readonly=False) + _target = 0 + + def read_value(self): + return self.query('SYS:DR:ACTN', actions_map) + + def read_target(self): + return self._target + + def write_target(self, value): + self._target = value + self.change('SYS:DR:CHAN:COOL', self.cooldown_channel, str) + # self.change('SYS:DR:CHAN:MC', self.mix_channel, str) + # self.change('DEV:T5:TEMP:MEAS:ENAB', 'ON', str) + return self.change('SYS:DR:ACTN', value, actions_map) + + # actions: + # NONE (no action) + # COND (condense mixture) + # COLL (collect mixture) + # STOP (go to NONE) + # + # not yet used (would need a subclass of Action): + # CLDN (cool down) + # PCL (precool automation) + # PCOND (pause pre-cool (not condense?) automation) + # RCOND (resume pre-cool (not condense?) automation) + # WARM (warm-up) + # EPCL (empty pre-cool automation) + + +class Valve(MercuryChannel, Drivable): + channel_type = 'VALV' + value = Parameter('valve state', EnumType(closed=0, opened=1)) + target = Parameter('valve target', EnumType(close=0, open=1)) + + _try_count = None + + def doPoll(self): + self.read_status() + + def read_value(self): + return self.query('VALV:SIG:STATE', open_close) + + def read_status(self): + pos = self.read_value() + if self._try_count is None: # not switching + return IDLE, '' + if pos == self.target: + # success + if self._try_count: + # make sure last sent command was not opposite + self.change('VALV:SIG:STATE', self.target, open_close) + self._try_count = None + self.setFastPoll(False) + return IDLE, '' + self._try_count += 1 + if self._try_count % 4 == 0: + # send to opposite position in order to unblock + self.change('VALV:SIG:STATE', pos, open_close) + return BUSY, 'unblock' + if self._try_count > 9: + # make sure system does not toggle later + self.change('VALV:SIG:STATE', pos, open_close) + return ERROR, 'can not %s valve' % self.target.name + self.change('VALV:SIG:STATE', self.target, open_close) + return BUSY, 'waiting' + + def write_target(self, value): + if value != self.read_value(): + self._try_count = 0 + self.setFastPoll(True, 0.25) + self.change('VALV:SIG:STATE', value, open_close) + self.status = BUSY, self.target.name + return value + + +class Pump(MercuryChannel, Writable): + channel_type = 'PUMP' + value = Parameter('pump state', EnumType(off=0, on=1)) + target = Parameter('pump target', EnumType(off=0, on=1)) + + def read_value(self): + return self.query('PUMP:SIG:STATE', off_on) + + def write_target(self, value): + return self.change('PUMP:SIG:STATE', value, off_on) + + def read_status(self): + return IDLE, '' + + +class TurboPump(Pump): + power = Parameter('pump power', FloatRange(unit='W')) + freq = Parameter('pump frequency', FloatRange(unit='Hz')) + powerstage_temp = Parameter('temperature of power stage', FloatRange(unit='K')) + motor_temp = Parameter('temperature of motor', FloatRange(unit='K')) + bearing_temp = Parameter('temperature of bearing', FloatRange(unit='K')) + pumpbase_temp = Parameter('temperature of pump base', FloatRange(unit='K')) + electronics_temp = Parameter('temperature of electronics', FloatRange(unit='K')) + + def read_status(self): + status = self.query('PUMP:STATUS', str) + if status == 'OK': + return IDLE, '' + return WARN, status + + def read_power(self): + return self.query('PUMP:SIG:POWR') + + def read_freq(self): + return self.query('PUMP:SIG:SPD') + + def read_powerstage_temp(self): + return self.query('PUMP:SIG:PST') + + def read_motor_temp(self): + return self.query('PUMP:SIG:MT') + + def read_bearing_temp(self): + return self.query('PUMP:SIG:BT') + + def read_pumpbase_temp(self): + return self.query('PUMP:SIG:PBT') + + def read_electronics_temp(self): + return self.query('PUMP:SIG:ET') + + +# class PulseTubeCompressor(MercuryChannel, Writable): +# channel_type = 'PTC' +# value = Parameter('compressor state', EnumType(closed=0, opened=1)) +# target = Parameter('compressor target', EnumType(close=0, open=1)) +# water_in_temp = Parameter('temperature of water inlet', FloatRange(unit='K')) +# water_out_temp = Parameter('temperature of water outlet', FloatRange(unit='K')) +# helium_temp = Parameter('temperature of helium', FloatRange(unit='K')) +# helium_low_pressure = Parameter('helium pressure (low side)', FloatRange(unit='mbar')) +# helium_high_pressure = Parameter('helium pressure (high side)', FloatRange(unit='mbar')) +# motor_current = Parameter('motor current', FloatRange(unit='A')) +# +# def read_value(self): +# return self.query('PTC:SIG:STATE', off_on) +# +# def write_target(self, value): +# return self.change('PTC:SIG:STATE', value, off_on) +# +# def read_status(self): +# # TODO: check possible status values +# return self.WARN, self.query('PTC:SIG:STATUS') +# +# def read_water_in_temp(self): +# return self.query('PTC:SIG:WIT') +# +# def read_water_out_temp(self): +# return self.query('PTC:SIG:WOT') +# +# def read_helium_temp(self): +# return self.query('PTC:SIG:HT') +# +# def read_helium_low_pressure(self): +# return self.query('PTC:SIG:HLP') +# +# def read_helium_high_pressure(self): +# return self.query('PTC:SIG:HHP') +# +# def read_motor_current(self): +# return self.query('PTC:SIG:MCUR') + + +class FlowMeter(MercuryChannel, Readable): + channel_type = 'FLOW' + + def read_value(self): + return self.query('FLOW:SIG:FLOW') + + +class ScannerChannel: + # TODO: excitation, enable + # TODO: switch on/off filter, check + filter_time = Parameter('filter time', FloatRange(1, 200, unit='sec'), readonly=False) + dwell_time = Parameter('dwell time', FloatRange(1, 200, unit='sec'), readonly=False) + pause_time = Parameter('pause time', FloatRange(3, 200, unit='sec'), readonly=False) + + def read_filter_time(self): + return self.query('TEMP:FILT:TIME') + + def write_filter_time(self, value): + self.change('TEMP:FILT:WIN', 80) + return self.change('TEMP:FILT:TIME', value) + + def read_dwell_time(self): + return self.query('TEMP:MEAS:DWEL') + + def write_dwell_time(self, value): + self.change('TEMP:FILT:WIN', 80) + return self.change('TEMP:MEAS:DWEL', value) + + def read_pause_time(self): + return self.query('TEMP:MEAS:PAUS') + + def write_pause_time(self, value): + return self.change('TEMP:MEAS:PAUS', value) + + +class TemperatureSensor(ScannerChannel, mercury.TemperatureSensor): + pass + + +class TemperatureLoop(ScannerChannel, mercury.TemperatureLoop): + ENABLE = 'TEMP:LOOP:MODE' + ENABLE_RAMP = 'TEMP:LOOP:RAMP:ENAB' + RAMP_RATE = 'TEMP:LOOP:RAMP:RATE' + + enable_pid_table = None # remove, does not work on triton + ctrlpars = Parameter('pid (gain, integral (inv. time), differential time') + system_channel = Property('system channel name', StringType(), 'MC') + + def write_control_active(self, value): + if self.system_channel: + self.change('SYS:DR:CHAN:%s' % self.system_channel, self.slot.split(',')[0], str) + if value: + self.change('TEMP:LOOP:FILT:ENAB', 'ON', str) + if self.output_module: + limit = self.output_module.read_limit() or None # None: max. limit + self.output_module.write_limit(limit) + return super().write_control_active(value) + + +class HeaterOutput(HasInput, MercuryChannel, Writable): + """heater output""" + channel_type = 'HTR' + value = Parameter('heater output', FloatRange(unit='uW')) + target = Parameter('heater output', FloatRange(0, unit='$'), readonly=False) + resistivity = Parameter('heater resistivity', FloatRange(unit='Ohm')) + + def read_resistivity(self): + return self.query('HTR:RES') + + def read_value(self): + return round(self.query('HTR:SIG:POWR'), 3) + + def read_target(self): + if self.controlled_by != 0: + return Done + return self.value + + def write_target(self, value): + self.write_controlled_by(SELF) + if self.resistivity: + # round to the next voltage step + value = round(sqrt(value * self.resistivity)) ** 2 / self.resistivity + return round(self.change('HTR:SIG:POWR', value), 3) + + +class HeaterOutputWithRange(HeaterOutput): + """heater output with heater range""" + channel_type = 'HTR,TEMP' + + limit = Parameter('max. heater power', FloatRange(unit='uW'), readonly=False) + + def read_limit(self): + maxcur = self.query('TEMP:LOOP:RANGE') # mA + return self.read_resistivity() * maxcur ** 2 # uW + + def write_limit(self, value): + if value is None: + maxcur = 100 # max. allowed current 100mA + else: + maxcur = sqrt(value / self.read_resistivity()) + for cur in 0.0316, 0.1, 0.316, 1, 3.16, 10, 31.6, 100: + if cur > maxcur * 0.999: + maxcur = cur + break + else: + maxcur = cur + self.change('TEMP:LOOP:RANGE', maxcur) + return self.read_limit() + diff --git a/frappy_psi/ultrasound.py b/frappy_psi/ultrasound.py new file mode 100644 index 0000000..e4c9fe8 --- /dev/null +++ b/frappy_psi/ultrasound.py @@ -0,0 +1,261 @@ +#!/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 +# ***************************************************************************** +"""frappy support for ultrasound""" + +import math +#import serial +import os +import time + +import numpy as np + +import frappy_psi.iqplot as iqplot +from frappy_psi.adq_mr import Adq +from frappy.core import Attached, BoolType, Done, FloatRange, HasIO, \ + IntRange, Module, Parameter, Readable, StringIO, StringType +from frappy.properties import Property + + +def fname_from_time(t, extension): + tm = time.localtime(t) + dirname = os.path.join('..', 'data', time.strftime("%Y-%m-%d_%H", tm)) + filename = time.strftime("%Y-%m-%d_%H-%M-%S_", tm) + filename = filename + ("%.1f" % t)[-1] + if not os.path.isdir(dirname): + os.makedirs(dirname) + return os.path.join(dirname, filename) + + +class Roi(Readable): + main = Attached() + + value = Parameter('amplitude', FloatRange(), default=0) + phase = Parameter('phase', FloatRange(unit='deg'), default=0) + i = Parameter('in phase', FloatRange(), default=0) + q = Parameter('out of phase', FloatRange(), default=0) + time = Parameter('start time', FloatRange(unit='nsec'), readonly=False) + size = Parameter('interval (symmetric around time)', FloatRange(unit='nsec'), readonly=False) + enable = Parameter('calculate this roi', BoolType(), readonly=False, default=True) + #status = Parameter(export=False) + pollinterval = Parameter(export=False) + + interval = (0,0) + + def initModule(self): + super().initModule() + self.main.register_roi(self) + self.calc_interval() + + def calc_interval(self): + self.interval = (self.time - 0.5 * self.size, self.time + 0.5 * self.size) + + def write_time(self, value): + self.time = value + self.calc_interval() + return Done + + def write_size(self, value): + self.size = value + self.calc_interval() + return Done + + +class Pars(Module): + description = 'relevant parameters from SEA' + + timestamp = Parameter('unix timestamp', StringType(), default='0', readonly=False) + temperature = Parameter('T', FloatRange(unit='K'), default=0, readonly=False) + mf = Parameter('field', FloatRange(unit='T'), default=0, readonly=False) + sr = Parameter('rotaion angle', FloatRange(unit='deg'), default=0, readonly=False) + + +class FreqStringIO(StringIO): + end_of_line = '\r' + + +class Frequency(HasIO, Readable): + pars = Attached() + sr = Property('samples per record', datatype=IntRange(), default=16384) + maxy = Property('plot y scale', datatype=FloatRange(), default=0.5) + + value = Parameter('frequency@I,q', datatype=FloatRange(unit='Hz'), default=0) + basefreq = Parameter('base frequency', FloatRange(unit='Hz'), readonly=False) + nr = Parameter('number of records', datatype=IntRange(1,10000), default=500) + freq = Parameter('target frequency', FloatRange(unit='Hz'), readonly=False) + bw = Parameter('bandwidth lowpassfilter', datatype=FloatRange(unit='Hz'),default=10E6) + amp = Parameter('amplitude', FloatRange(unit='dBm'), readonly=False) + control = Parameter('control loop on?', BoolType(), readonly=False, default=True) + time = Parameter('pulse start time', FloatRange(unit='nsec'), + readonly=False) + size = Parameter('pulse length (starting from time)', FloatRange(unit='nsec'), + readonly=False) + pulselen = Parameter('adjusted pulse length (integer number of periods)', FloatRange(unit='nsec'), default=1) + maxstep = Parameter('max frequency step', FloatRange(unit='Hz'), readonly=False, + default=10000) + minstep = Parameter('min frequency step for slope calculation', FloatRange(unit='Hz'), + readonly=False, default=4000) + slope = Parameter('inphase/frequency slope', FloatRange(), readonly=False, + default=1e6) + plot = Parameter('create plot images', BoolType(), readonly=False, default=True) + save = Parameter('save data', BoolType(), readonly=False, default=True) + pollinterval = Parameter(datatype=FloatRange(0,120)) + + ioClass = FreqStringIO + + lastfreq = None + old = None + starttime = None + interval = (0,0) + + def earlyInit(self): + super().earlyInit() + self.adq = Adq(self.nr, self.sr, self.bw) + self.roilist = [] + self.write_nr(self.nr) + self.skipctrl = 0 + self.plotter = iqplot.Plot(self.maxy) + self.calc_interval() + + def calc_interval(self): + self.interval = (self.time, self.time + self.size) + + def write_time(self, value): + self.time = value + self.calc_interval() + return Done + + def write_size(self, value): + self.size = value + self.calc_interval() + return Done + + def write_nr(self, value): + # self.pollinterval = value * 0.0001 + return value + + def register_roi(self, roi): + self.roilist.append(roi) + + def set_freq(self): + freq = self.freq + self.basefreq + self.communicate('FREQ %.15g;FREQ?' % freq) + #self._iodev.readline().decode('ascii') + return freq + + def write_amp(self, amp): + reply = self.communicate('AMPR %g;AMPR?' % amp) + return float(reply) + + def read_amp(self): + reply = self.communicate('AMPR?') + return float(reply) + + def write_freq(self, value): + self.skipctrl = 2 # suppress control for the 2 next steps + return value + + def doPoll(self): + """main poll loop body""" + if self.lastfreq is None: + self.lastfreq = self.set_freq() + self.adq.start() + if self.starttime is None: + self.starttime = time.time() + times = [] + times.append(('init', time.time())) + seadata = {p: float(getattr(self.pars, p)) for p in self.pars.parameters} + data = self.adq.getdata() # this will wait, if not yet finished + #save sample + #np.save('sample.dat',data) + times.append(('wait',time.time())) + if self.control: + freq = self.lastfreq # data was acquired at this freq + else: + freq = self.set_freq() + seadata['frequency'] = freq + if self.control: + self.lastfreq = self.set_freq() + times.append(('setf',time.time())) + self.adq.start() # start next acq + times.append(('start',time.time())) + roilist = [r for r in self.roilist if r.enable] + + gates = self.adq.gates_and_curves(data, freq, self.interval, + [r.interval for r in roilist]) + if self.save: + times.append(('save',time.time())) + tdata, idata, qdata, pdata = self.adq.curves + seadata['timestep'] = tdata[1] - tdata[0] + iqdata = np.array((idata, qdata, pdata), dtype='f4') + ts = seadata['timestamp'] + if ts: + filename = fname_from_time(ts, '.npz') + seanp = np.array(list(seadata.items()), dtype=[('name', 'U16'), ('value', 'f8')]) + np.savez(filename, seadata=seanp, iqdata=iqdata) + # can be load back via + # content = np.load(filename) + # content['seadata'], content['iqdata'] + self.pulselen = self.adq.pulselen + times.append(('ana',time.time())) + if self.plot: + # get reduced interval from adq.sinW + pulseint = (self.interval[0], self.interval[0] + self.pulselen) + try: + self.plotter.plot( + self.adq.curves, + rois=[pulseint] + [r.interval for r in roilist], + average=([r.time for r in roilist], + [r.i for r in roilist], + [r.q for r in roilist])) + except Exception as e: + self.log.warning('can not plot %r' % e) + else: + self.plotter.close() + now = time.time() + times.append(('plot',now)) + # print(' '.join('%s %5.3f' % (label, t - self.starttime) for label, t in times)) + self.starttime = now + self.value = freq - self.basefreq + for i, roi in enumerate(roilist): + roi.i = a = gates[i][0] + roi.q = b = gates[i][1] + roi.value = math.sqrt(a ** 2 + b ** 2) + roi.phase = math.atan2(a,b) * 180 / math.pi + inphase = self.roilist[0].i + if self.control: + newfreq = freq + inphase * self.slope - self.basefreq + # step = sorted((-self.maxstep, inphase * self.slope, self.maxstep))[1] + if self.old: + fdif = freq - self.old[0] + idif = inphase - self.old[1] + if abs(fdif) >= self.minstep: + self.slope = - fdif / idif + else: + fdif = 0 + idif = 0 + newfreq = freq + self.minstep + self.old = (freq, inphase) + if self.skipctrl > 0: # do no control for some time after changing frequency + self.skipctrl -= 1 + elif self.control: + self.freq = sorted((self.freq - self.maxstep, newfreq, self.freq + self.maxstep))[1] + #print(times) + return Done diff --git a/frappy_psi/uniax.py b/frappy_psi/uniax.py new file mode 100644 index 0000000..64a7e51 --- /dev/null +++ b/frappy_psi/uniax.py @@ -0,0 +1,432 @@ +# -*- 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: +# M. Zolliker +# +# ***************************************************************************** +"""use transducer and motor to adjust force""" + +import time +import math +from frappy.core import Drivable, Parameter, FloatRange, Done, \ + Attached, Command, PersistentMixin, PersistentParam, BoolType +from frappy.errors import BadValueError, SECoPError +from frappy.lib.statemachine import Retry, StateMachine, Restart + +# TODO: fix with new state machine! + + +class Error(SECoPError): + pass + + +class Uniax(PersistentMixin, Drivable): + value = Parameter(unit='N') + motor = Attached() + transducer = Attached() + limit = Parameter('abs limit of force', FloatRange(0, 190, unit='N'), readonly=False, default=150) + tolerance = Parameter('force tolerance', FloatRange(0, 10, unit='N'), readonly=False, default=0.2) + slope = PersistentParam('spring constant', FloatRange(unit='deg/N'), readonly=False, + default=0.5, persistent='auto') + pid_i = PersistentParam('integral', FloatRange(), readonly=False, default=0.5, persistent='auto') + filter_interval = Parameter('filter time', FloatRange(0, 60, unit='s'), readonly=False, default=5) + current_step = Parameter('', FloatRange(unit='deg'), default=0) + force_offset = PersistentParam('transducer offset', FloatRange(unit='N'), readonly=False, default=0, + initwrite=True, persistent='auto') + hysteresis = PersistentParam('force hysteresis', FloatRange(0, 190, unit='N'), readonly=False, default=5, + persistent='auto') + adjusting = Parameter('', BoolType(), readonly=False, default=False, initwrite=True) + adjusting_current = PersistentParam('current when adjusting force', FloatRange(0, 2.8, unit='A'), readonly=False, + default=0.5, persistent='auto') + safe_step = PersistentParam('max. motor step when adjusting force', FloatRange(0, unit='deg'), readonly=False, + default=5, persistent='auto') + safe_current = PersistentParam('current when moving far', FloatRange(0, 2.8, unit='A'), readonly=False, + default=0.2, persistent='auto') + low_pos = Parameter('max. position for positive forces', FloatRange(unit='deg'), readonly=False, needscfg=False) + high_pos = Parameter('min. position for negative forces', FloatRange(unit='deg'), readonly=False, needscfg=False) + substantial_force = Parameter('min. force change expected within motor play', FloatRange(), default=1) + motor_play = Parameter('acceptable motor play within hysteresis', FloatRange(), readonly=False, default=10) + motor_max_play = Parameter('acceptable motor play outside hysteresis', FloatRange(), readonly=False, default=90) + timeout = Parameter('driving finishes when no progress within this delay', FloatRange(), readonly=False, default=300) + pollinterval = 0.1 + + _mot_target = None # for detecting manual motor manipulations + _filter_start = 0 + _cnt = 0 + _sum = 0 + _cnt_rderr = 0 + _cnt_wrerr = 0 + _zero_pos_tol = None + _state = None + _force = None # raw force + + def earlyInit(self): + super().earlyInit() + self._zero_pos_tol = {} + + def initModule(self): + super().initModule() + self._state = StateMachine(logger=self.log, threaded=False, cleanup=self.cleanup) + + def doPoll(self): + self.read_value() + self._state.cycle() + + def drive_relative(self, step, ntry=3): + """drive relative, try 3 times""" + mot = self.motor + mot.read_value() # make sure motor value is fresh + if self.adjusting and abs(step) > self.safe_step: + step = math.copysign(self.safe_step, step) + self.current_step = step + for _ in range(ntry): + try: + if abs(mot.value - mot.target) < mot.tolerance: + # make sure rounding erros do not suppress small steps + newpos = mot.target + step + else: + newpos = mot.value + step + self._mot_target = self.motor.write_target(newpos) + self._cnt_wrerr = max(0, self._cnt_wrerr - 1) + return True + except Exception as e: + self.log.warning('drive error %s', e) + self._cnt_wrerr += 1 + if self._cnt_wrerr > 5: + raise + self.log.warning('motor reset') + self.motor.reset() + return False + + def motor_busy(self): + mot = self.motor + if mot.isBusy(): + if mot.target != self._mot_target: + raise Error('control stopped - motor moved directly') + return True + return False + + def read_value(self): + try: + self._force = force = self.transducer.read_value() + self._cnt_rderr = max(0, self._cnt_rderr - 1) + except Exception as e: + self._cnt_rderr += 1 + if self._cnt_rderr > 10: + self.stop() + self.status = 'ERROR', 'too many read errors: %s' % e + self.log.error(self.status[1]) + self.read_target() + return Done + + now = time.time() + self._sum += force + self._cnt += 1 + if now < self._filter_start + self.filter_interval: + return Done + force = self._sum / self._cnt + self.reset_filter(now) + if abs(force) > self.limit + self.hysteresis: + self.motor.stop() + self.status = 'ERROR', 'above max limit' + self.log.error(self.status[1]) + self.read_target() + return Done + if self.zero_pos(force) is None and abs(force) > self.hysteresis: + self.set_zero_pos(force, self.motor.read_value()) + return force + + def reset_filter(self, now=0.0): + self._sum = self._cnt = 0 + self._filter_start = now or time.time() + + def zero_pos(self, value): + """get high_pos or low_pos, depending on sign of value + + :param value: return an estimate for a good starting position + """ + + name = 'high_pos' if value > 0 else 'low_pos' + if name not in self._zero_pos_tol: + return None + return getattr(self, name) + + def set_zero_pos(self, force, pos): + """set zero position high_pos or low_pos, depending on sign and value of force + + :param force: the force used for calculating zero_pos + :param pos: the position used for calculating zero_pos + """ + name = 'high_pos' if force > 0 else 'low_pos' + if pos is None: + self._zero_pos_tol.pop(name, None) + return None + pos -= force * self.slope + tol = (abs(force) - self.hysteresis) * self.slope * 0.2 + if name in self._zero_pos_tol: + old = self.zero_pos(force) + if abs(old - pos) < self._zero_pos_tol[name] + tol: + return + self._zero_pos_tol[name] = tol + self.log.info('set %s = %.1f +- %.1f (@%g N)' % (name, pos, tol, force)) + setattr(self, name, pos) + + def cleanup(self, state): + """in case of error, set error status""" + if state.stopped: # stop or restart + if state.stopped is Restart: + return + self.status = 'IDLE', 'stopped' + self.log.warning('stopped') + else: + self.status = 'ERROR', str(state.last_error) + if isinstance(state.last_error, Error): + self.log.error('%s', state.last_error) + else: + self.log.error('%r raised in state %r', str(state.last_error), state.status_string) + self.read_target() # make target invalid + self.motor.stop() + self.write_adjusting(False) + + def reset_progress(self, state): + state.prev_force = self.value + state.prev_pos = self.motor.value + state.prev_time = time.time() + + def check_progress(self, state): + force_step = self.target - self.value + direction = math.copysign(1, force_step) + try: + force_progress = direction * (self.value - state.prev_force) + except AttributeError: # prev_force undefined? + self.reset_progress(state) + return True + if force_progress >= self.substantial_force: + self.reset_progress(state) + else: + motor_dif = abs(self.motor.value - state.prev_pos) + if motor_dif > self.motor_play: + if motor_dif > self.motor_max_play: + raise Error('force seems not to change substantially %g %g (%g %g)' % (self.value, self.motor.value, state.prev_force, state.prev_pos)) + return False + return True + + def adjust(self, state): + """adjust force""" + if state.init: + state.phase = 0 # just initialized + state.in_since = 0 + state.direction = math.copysign(1, self.target - self.value) + state.pid_fact = 1 + if self.motor_busy(): + return Retry() + self.value = self._force + force_step = self.target - self.value + if abs(force_step) < self.tolerance: + if state.in_since == 0: + state.in_since = state.now + if state.now > state.in_since + 10: + return self.within_tolerance + else: + if force_step * state.direction < 0: + if state.pid_fact == 1: + self.log.info('overshoot -> adjust with reduced pid_i') + state.pid_fact = 0.1 + state.in_since = 0 + if state.phase == 0: + state.phase = 1 + self.reset_progress(state) + self.write_adjusting(True) + self.status = 'BUSY', 'adjusting force' + elif not self.check_progress(state): + if abs(self.value) < self.hysteresis: + if motor_dif > self.motor_play: + self.log.warning('adjusting failed - try to find zero pos') + self.set_zero_pos(self.target, None) + return self.find + elif time.time() > state.prev_time + self.timeout: + if state.phase == 1: + state.phase = 2 + self.log.warning('no substantial progress since %d sec', self.timeout) + self.status = 'IDLE', 'adjusting timeout' + self.drive_relative(force_step * self.slope * self.pid_i * min(1, state.delta()) * state.pid_fact) + return Retry() + + def within_tolerance(self, state): + """within tolerance""" + if state.init: + self.status = 'IDLE', 'within tolerance' + return Retry() + if self.motor_busy(): + return Retry() + force_step = self.target - self.value + if abs(force_step) < self.tolerance * 0.5: + self.current_step = 0 + else: + self.check_progress(state) + self.drive_relative(force_step * self.slope * self.pid_i * min(1, state.delta()) * 0.1) + if abs(force_step) > self.tolerance: + return self.out_of_tolerance + return Retry() + + def out_of_tolerance(self, state): + """out of tolerance""" + if state.init: + self.status = 'WARN', 'out of tolerance' + state.in_since = 0 + return Retry() + if self.motor_busy(): + return Retry() + force_step = self.target - self._force + if abs(force_step) < self.tolerance: + if state.in_since == 0: + state.in_since = state.now + if state.now > state.in_since + 10: + return self.within_tolerance + if abs(force_step) < self.tolerance * 0.5: + return Retry() + self.check_progress(state) + self.drive_relative(force_step * self.slope * self.pid_i * min(1, state.delta()) * 0.1) + return Retry() + + def find(self, state): + """find active (engaged) range""" + if state.init: + state.prev_direction = 0 # find not yet started + self.reset_progress(state) + direction = math.copysign(1, self.target) + self.value = self._force + abs_force = self.value * direction + if abs_force > self.hysteresis or abs_force > self.target * direction: + if self.motor_busy(): + self.log.info('motor stopped - substantial force detected: %g', self.value) + self.motor.stop() + elif state.prev_direction == 0: + return self.adjust + if abs_force > self.hysteresis: + self.set_zero_pos(self.value, self.motor.read_value()) + return self.adjust + if abs_force < -self.hysteresis: + state.force_before_free = self.value + return self.free + if self.motor_busy(): + if direction == -state.prev_direction: # target direction changed + self.motor.stop() + state.init_find = True # restart find + return Retry() + zero_pos = self.zero_pos(self.target) + if state.prev_direction: # find already started + if abs(self.motor.target - self.motor.value) > self.motor.tolerance: + # no success on last find try, try short and strong step + self.write_adjusting(True) + self.log.info('one step to %g', self.motor.value + self.safe_step) + self.drive_relative(direction * self.safe_step) + return Retry() + else: + state.prev_direction = math.copysign(1, self.target) + side_name = 'negative' if direction == -1 else 'positive' + if zero_pos is not None: + self.status = 'BUSY', 'change to %s side' % side_name + zero_pos += direction * (self.hysteresis * self.slope - self.motor.tolerance) + if (self.motor.value - zero_pos) * direction < -self.motor.tolerance: + self.write_adjusting(False) + self.log.info('change side to %g', zero_pos) + self.drive_relative(zero_pos - self.motor.value) + return Retry() + # we are already at or beyond zero_pos + return self.adjust + self.write_adjusting(False) + self.status = 'BUSY', 'find %s side' % side_name + self.log.info('one turn to %g', self.motor.value + direction * 360) + self.drive_relative(direction * 360) + return Retry() + + def free(self, state): + """free from high force at other end""" + if state.init: + state.free_way = None + self.reset_progress(state) + if self.motor_busy(): + return Retry() + self.value = self._force + if abs(self.value) > abs(state.force_before_free) + self.hysteresis: + raise Error('force increase while freeing') + if abs(self.value) < self.hysteresis: + return self.find + if state.free_way is None: + state.free_way = 0 + self.log.info('free from high force %g', self.value) + self.write_adjusting(True) + direction = math.copysign(1, self.target) + if state.free_way > abs(state.force_before_free + self.hysteresis) * self.slope + self.motor_max_play: + raise Error('freeing failed') + state.free_way += self.safe_step + self.drive_relative(direction * self.safe_step) + return Retry() + + def write_target(self, target): + if abs(target) > self.limit: + raise BadValueError('force above limit') + if abs(target - self.value) <= self.tolerance: + if not self.isBusy(): + self.status = 'IDLE', 'already at target' + self._state.start(self.within_tolerance) + return target + self.log.info('new target %g', target) + self._cnt_rderr = 0 + self._cnt_wrerr = 0 + self.status = 'BUSY', 'changed target' + self.target = target + if self.value * math.copysign(1, target) > self.hysteresis: + self._state.start(self.adjust) + else: + self._state.start(self.find) + return Done + + def read_target(self): + if self._state.state is None: + if self.status[1]: + raise Error(self.status[1]) + raise Error('inactive') + return self.target + + @Command() + def stop(self): + if self.motor.isBusy(): + self.log.info('stop motor') + self.motor.stop() + self.status = 'IDLE', 'stopped' + self._state.stop() + + def write_force_offset(self, value): + self.force_offset = value + self.transducer.write_offset(value) + return Done + + def write_adjusting(self, value): + mot = self.motor + if value: + mot_current = self.adjusting_current + mot.write_move_limit(self.safe_step) + else: + mot_current = self.safe_current + mot.write_safe_current(mot_current) + if abs(mot_current - mot.maxcurrent) > 0.01: # resolution of current: 2.8 / 250 + self.log.info('motor current %g', mot_current) + mot.write_maxcurrent(mot_current) + return value diff --git a/frappy_psi/vector.py b/frappy_psi/vector.py new file mode 100644 index 0000000..9e28386 --- /dev/null +++ b/frappy_psi/vector.py @@ -0,0 +1,89 @@ +# -*- 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 +# ***************************************************************************** +"""generic 3D vector""" + +from frappy.core import Attached, Drivable, Readable, Parameter, Done +from frappy.datatypes import FloatRange, TupleOf, StatusType, Enum + + +class VectorRd(Readable): + """generic readable vector""" + value = Parameter(datatype=TupleOf(FloatRange(), FloatRange(), FloatRange())) + x = Attached() + y = Attached() + z = Attached() + pollFuncs = None + components = None + + def initModule(self): + super().initModule() + members = [] + status_codes = {} # collect all possible status codes + components = [] + for name in 'xyz': + component = getattr(self, name) + members.append(component.parameters['value'].datatype.copy()) + components.append(component) + for code in component.status[0].enum.members: + status_codes[int(code)] = code.name + self.parameters['value'].datatype = TupleOf(*members) + self.parameters['status'].datatype = StatusType(Enum( + 'status', **{k: v for v, k in status_codes.items()})) + self.components = components + + def doPoll(self): + for component in self.components: + component.doPoll() + # update + component.pollInfo.last_main = self.pollInfo.last_main + self.value = self.merge_value() + self.status = self.merge_status() + + def merge_value(self): + return [c.value for c in self.components] + + def merge_status(self): + status = -1, '' + for c in self.components: + if c.status[0] > status[0]: + status = c.status + return status + + def read_value(self): + return tuple((c.read_value() for c in self.components)) + + def read_status(self): + [c.read_status() for c in self.components] + return self.merge_status() + + +class Vector(Drivable, VectorRd): + """generic drivable vector""" + target = Parameter(datatype=TupleOf(FloatRange(), FloatRange(), FloatRange())) + + def initModule(self): + super().initModule() + members = [] + for component in self.components: + members.append(component.parameters['target'].datatype.copy()) + self.parameters['target'].datatype = TupleOf(*members) + + def write_target(self, value): + return tuple((c.write_target(v) for v, c in zip(value, self.components))) \ No newline at end of file