diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index b62df1e..b61a31e 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -7,3 +7,4 @@ # Modules in this directory from .add_auth_spec import * +from .muxer_service import * diff --git a/dripline/extensions/cca_pid_loop.py b/dripline/extensions/cca_pid_loop.py new file mode 100644 index 0000000..79bca37 --- /dev/null +++ b/dripline/extensions/cca_pid_loop.py @@ -0,0 +1,223 @@ +''' +Implementation of a PID control loop +''' + +from __future__ import print_function +__all__ = [] + +import time +import datetime + +from dripline.core import AlertConsumer, constants, exceptions, fancy_doc, ThrowReply + +import logging +logger = logging.getLogger(__name__) + +__all__.append('PidController') +@fancy_doc +class PidController(AlertConsumer): + ''' + Implementation of a PID control loop with constant offset. That is, the PID equation + is used to compute the **change** to the value of some channel and not the value + itself. In the case of temperature control, this makes sense if the loop is working + against some fixed load (such as a cryocooler). + + The input sensor can be anything which broadcasts regular values on the alerts + exchange (using the standard sensor_value. routing key format). Usually + this would be a temperature sensor, but it could be anything. Similarly, the + output is anything that can be set to a float value, though a current output + is probably most common. After setting the new value of current, this value is checked + to be within a range around the desired value. + + **NOTE** + The "exchange" and "keys" arguments list below come from the Service class but + are not valid for this class. Any value provided will be ignored + ''' + + def __init__(self, + input_channel, + output_channel, + check_channel, + status_channel, + payload_field='value_cal', + tolerance = 0.01, + target_value=110, + proportional=0.0, integral=0.0, differential=0.0, + maximum_out=1.0, minimum_out=1.0, delta_out_min= 0.001, + enable_offset_term=True, + minimum_elapsed_time=0, + **kwargs + ): + ''' + input_channel (str): name of the logging sensor to use as input to PID (this will override any provided values for keys) + output_channel (str): name of the endpoint to be set() based on PID + check_channel (str): name of the endpoint to be checked() after a set() + status_channel (str): name of the endpoint which controls the status of the heater (enabled/disabled output) + payload_field (str): name of the field in the payload when the sensor logs (default is 'value_cal' and 'value_raw' is the only other expected value) + target_value (float): numerical value to which the loop will try to lock the input_channel + proportional (float): coefficient for the P term in the PID equation + integral (float): coefficient for the I term in the PID equation + differential (float): coefficient for the D term in the PID equation + maximum_out (float): max value to which the output_channel may be set; if the PID equation gives a larger value this value is used instead + delta_out_min (float): minimum value by which to change the output_channel; if the PID equation gives a smaller change, the value is left unchanged (no set is attempted) + tolerance (float): acceptable difference between the set and get values (default: 0.01) + minimum_elapsed_time (float): minimum time interval to perform PID calculation over + ''' + kwargs.update({'keys':['sensor_value.'+input_channel]}) + AlertConsumer.__init__(self, **kwargs) + + self._set_channel = output_channel + self._check_channel = check_channel + self._status_channel = status_channel + self.payload_field = payload_field + self.tolerance = tolerance + + self._last_data = {'value':None, 'time':datetime.datetime.utcnow()} + self.target_value = target_value + + self.Kproportional = proportional + self.Kintegral = integral + self.Kdifferential = differential + + self._integral= 0 + + self.max_current = maximum_out + self.min_current = minimum_out + self.min_current_change = delta_out_min + + self.enable_offset_term = enable_offset_term + self.minimum_elapsed_time = minimum_elapsed_time + + self.__validate_status() + self._old_current = self.__get_current() + logger.info('starting current is: {}'.format(self._old_current)) + + def __get_current(self): + #value = self.provider.get(self._check_channel)[self.payload_field] + alue = self.service.get(self._check_channel)[self.payload_field] + logger.info('current get is {}'.format(value)) + + try: + value = float(value) + except (TypeError, ValueError): + raise ThrowReply('DriplineValueError','value get ({}) is not floatable'.format(value)) + return value + + def __validate_status(self): + # value = self.provider.get(self._status_channel)[self.payload_field] + value = self.service.get(self._status_channel)[self.payload_field] + if value == 'enabled': + logger.debug("{} returns {}".format(self._status_channel,value)) + else: + logger.critical("Invalid status of {} for PID control by {}".format(self._status_channel,self.name)) + raise ThrowReply('DriplineHardwareError',"{} returns {}".format(self._status_channel,value)) + + def this_consume(self, message, method): + logger.info('consuming message') + this_value = message.payload[self.payload_field] + if this_value is None: + logger.info('value is None') + return + + this_time = datetime.datetime.strptime(message['timestamp'], constants.TIME_FORMAT) + if (this_time - self._last_data['time']).total_seconds() < self.minimum_elapsed_time: + # handle self._force_reprocess from @target_value.setter + if not self._force_reprocess: + logger.info("not enough time has elasped: {}[{}]".format((this_time - self._last_data['time']).total_seconds(),self.minimum_elapsed_time)) + return + logger.info("Forcing process due to changed target_value") + self._force_reprocess = False + + self.process_new_value(timestamp=this_time, value=float(this_value)) + + @property + def target_value(self): + return self._target_value + @target_value.setter + def target_value(self, value): + self._target_value = value + self._integral = 0 + self._force_reprocess = True + + def set_current(self, value): + logger.info('going to set new current to: {}'.format(value)) + #reply = self.provider.set(self._set_channel, value) + reply = self.service.set(self._set_channel, value) + logger.info('set response was: {}'.format(reply)) + + def process_new_value(self, value, timestamp): + + delta = self.target_value - value + logger.info('value is <{}>; delta is <{}>'.format(value, delta)) + + self._integral += delta * (timestamp - self._last_data['time']).total_seconds() + if (timestamp - self._last_data['time']).total_seconds() < 2*self.minimum_elapsed_time: + try: + derivative = (self._last_data['value'] - value) / (timestamp - self._last_data['time']).total_seconds() + except TypeError: + derivative = 0 + else: + logger.warning("invalid time for calculating derivative") + derivative = 0. + self._last_data = {'value': value, 'time': timestamp} + + logger.info("proportional <{}>; integral <{}>; differential <{}>".format\ + (self.Kproportional*delta, self.Kintegral*self._integral, self.Kdifferential*derivative)) + change_to_current = (self.Kproportional * delta + + self.Kintegral * self._integral + + self.Kdifferential * derivative + ) + new_current = (self._old_current or 0)*self.enable_offset_term + change_to_current + + if abs(change_to_current) < self.min_current_change: + logger.info("current change less than min delta") + logger.info("old[new] are: {}[{}]".format(self._old_current,new_current)) + return + logger.info('computed new current to be: {}'.format(new_current)) + if new_current > self.max_current: + logger.info("new current above max") + new_current = self.max_current + if new_current < self.min_current: + logger.info("new current below min") + new_current = self.min_current + if new_current < 0.: + logger.info("new current < 0") + new_current = 0. + + self.set_current(new_current) + logger.debug("allow settling time and checking the current value") + # FIXME: remove sleep when set_and_check handled properly + time.sleep(1) + current_get = self.__get_current() + if abs(current_get-new_current) < self.tolerance: + logger.debug("current set is equal to current get") + else: + self.__validate_status() + raise ThrowReply('DriplineValueError',"set value ({}) is not equal to checked value ({})".format(new_current,current_get)) + + logger.info("current set is: {}".format(new_current)) + self._old_current = new_current + +__all__.append('ServiceAttributeEntity') +#changed things like self.provider to self.service, idk if this is the move tho +class ServiceAttributeEntity(Entity): + ''' + Entity allowing communication with spime property. + ''' + + def __init__(self, + attribute_name, + disable_set=False, + **kwargs): + Entity.__init__(self, **kwargs) + self._attribute_name = attribute_name + self._disable_set = disable_set + + @calibrate() + def on_get(self): + return getattr(self.service, self._attribute_name) + + def on_set(self, value): + if self._disable_set: + raise ThrowReply('DriplineMethodNotSupportedError','setting not available for {}'.format(self.name)) + setattr(self.service, self._attribute_name, value) diff --git a/dripline/extensions/muxer_service.py b/dripline/extensions/muxer_service.py new file mode 100644 index 0000000..7dce046 --- /dev/null +++ b/dripline/extensions/muxer_service.py @@ -0,0 +1,155 @@ +''' +A class to interface with the multiplexer aka muxer instrument +''' + +from dripline.core import ThrowReply, Entity, calibrate +from dripline.implementations import EthernetSCPIService, FormatEntity + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] +__all__.append('MuxerService') + +class MuxerService(EthernetSCPIService): + ''' + Provider to interface with muxer + ''' + + def __init__(self, scan_interval=0,**kwargs): + ''' + scan_interval (int): time between scans in seconds + ''' + EthernetSCPIService.__init__(self,**kwargs) + if scan_interval <= 0: + raise ThrowReply('service_error_invalid_value', 'scan interval must be > 0') + self.scan_interval = scan_interval + self.configure_scan() + + def configure_scan(self, *args, **kwargs): + ''' + loops over the provider's internal list of endpoints and attempts to configure each, then configures and begins scan + ''' + self.send_to_device(['ABOR;*CLS;*OPC?']) + + ch_scan_list = list() + for childname, child in self.sync_children.items(): + + if not isinstance(child, MuxerGetEntity): + continue + error_data = self.send_to_device([child.conf_str+';*OPC?','SYST:ERR?']) + if error_data != '1;+0,"No error"': + logger.critical('Error detected; cannot configure muxer') + raise ThrowReply('resource_error', + f'{error_data} when attempting to configure endpoint <{childname}>') + + ch_scan_list.append(str(child.ch_number)) + child.log_interval = self.scan_interval + + scan_list_cmd = 'ROUT:SCAN (@{})'.format(','.join(ch_scan_list)) + self.send_to_device([scan_list_cmd+';*OPC?',\ + 'TRIG:SOUR TIM;*OPC?',\ + 'TRIG:COUN INF;*OPC?',\ + 'TRIG:TIM {};*OPC?'.format(self.scan_interval),\ + 'INIT;*ESE?']) + + +__all__.append('MuxerGetEntity') +class MuxerGetEntity(Entity): + ''' + Entity for communication with muxer endpoints. No set functionality. + ''' + + def __init__(self, + ch_number, + conf_str=None, + **kwargs): + ''' + ch_number (int): channel number for endpoint + conf_str (str): used by MuxerService to configure endpoint scan + ''' + Entity.__init__(self, **kwargs) + if conf_str is None: + raise ThrowReply('service_error_invalid_value', + f' required for MuxerGetEntity {self.name}') + self.get_str = "DATA:LAST? (@{})".format(ch_number) + self.ch_number = ch_number + self.conf_str = conf_str.format(ch_number) + + @calibrate() + def on_get(self): + result = self.service.send_to_device([self.get_str.format(self.ch_number)]) + logger.debug('very raw is: {}'.format(result)) + return result.split()[0] + + def on_set(self, value): + raise ThrowReply('message_error_invalid_method', + f'endpoint {self.name} does not support set') + + + +__all__.append('MuxerRelay') +class MuxerRelay(FormatEntity): + ''' + Entity to communicate with relay cards in muxer, + ''' + def __init__(self, + ch_number, + relay_type=None, + **kwargs): + ''' + ch_number (int): channel number for endpoint + relay_type (None,'relay','polarity','switch'): automatically configure set_value_map and calibration dictionaries (overwriteable) + ''' + + # default get/set strings + if 'get_str' not in kwargs: + if relay_type=='relay' or relay_type=='polarity': + kwargs.update( {'get_str':':ROUTE:OPEN? (@{})'.format(ch_number)} ) + elif relay_type=='switch': + kwargs.update( {'get_str':':ROUTE:CLOSE? (@{})'.format(ch_number)} ) + if 'set_str' not in kwargs: + kwargs.update( {'set_str':':ROUTE:{{}} (@{});{}'.format(ch_number,kwargs['get_str'])} ) + # Default kwargs for get_on_set and set_value_lowercase + if 'get_on_set' not in kwargs: + kwargs.update( {'get_on_set':True} ) + if 'set_value_lowercase' not in kwargs: + kwargs.update( {'set_value_lowercase' :True} ) + # Default set_value_map and calibration for known relay types (relay, polarity, switch) + if relay_type == 'relay': + if 'set_value_map' not in kwargs: + kwargs.update( { 'set_value_map' : {1: 'OPEN', + 0: 'CLOSE', + 'on': 'OPEN', + 'off': 'CLOSE', + 'enable': 'OPEN', + 'disable': 'CLOSE'} } ) + if 'calibration' not in kwargs: + kwargs.update( { 'calibration' : {'1': 'enabled', + '0': 'disabled'} } ) + elif relay_type == 'polarity': + if 'set_value_map' not in kwargs: + kwargs.update( { 'set_value_map' : {1: 'OPEN', + 0: 'CLOSE', + 'positive': 'OPEN', + 'negative': 'CLOSE'} } ) + if 'calibration' not in kwargs: + kwargs.update( { 'calibration' : {'1': 'positive', + '0': 'negative'} } ) + elif relay_type == 'switch': + if 'set_value_map' not in kwargs: + kwargs.update( { 'set_value_map' : {0: 'OPEN', + 1: 'CLOSE', + 'off': 'OPEN', + 'on': 'CLOSE', + 'disable': 'OPEN', + 'enable': 'CLOSE'} } ) + if 'calibration' not in kwargs: + kwargs.update( { 'calibration' : {'0': 'disabled', + '1': 'enabled'} } ) + elif relay_type is not None: + raise ThrowReply("message_error_invalid_method", + f"endpoint {self.name} expect 'relay'or 'polarity'") + + FormatEntity.__init__(self, **kwargs) + diff --git a/muxer-test.yaml b/muxer-test.yaml new file mode 100644 index 0000000..ed8c3a2 --- /dev/null +++ b/muxer-test.yaml @@ -0,0 +1,31 @@ +version: "3" +services: + + # The broker for the mesh + rabbit-broker: + image: rabbitmq:3-management + ports: + - "15672:15672" + environment: + - RABBITMQ_DEFAULT_USER=dripline + - RABBITMQ_DEFAULT_PASS=dripline + healthcheck: + test: ["CMD-SHELL", "curl -u dripline:dripline http://rabbit-broker:15672/api/overview &> /dev/null || exit 1"] + + muxer-service: + image: ghcr.io/project8/dragonfly:muxer_test + depends_on: + rabbit-broker: + condition: service_healthy + volumes: + - ./muxer.yaml:/root/muxer.yaml + environment: + - DRIPLINE_USER=dripline + - DRIPLINE_PASSWORD=dripline + command: + - dl-serve + - -c + - /root/muxer.yaml + - -vv + - -b + - rabbit-broker diff --git a/muxer.yaml b/muxer.yaml new file mode 100644 index 0000000..dc22721 --- /dev/null +++ b/muxer.yaml @@ -0,0 +1,65 @@ +name: muxer +module: MuxerService +socket_info: ('glenlivet.p8', 5024) +cmd_at_reconnect: + - + - "" + - "SYST:ERR?" + - "TRIG:DEL:AUTO?" +command_terminator: "\r\n" +response_terminator: "\r\n34980A> " +reply_echo_cmd: True +scan_interval: 30 +endpoints: +##################### Cable B #################### + # PT 100 1/12 + - name: pt100_1_12 + module: MuxerGetEntity + ch_number: 1011 + conf_str: 'CONF:FRES AUTO,DEF,(@{})' + calibration: 'pt100_calibration({})' + # PT 100 2/12 + - name: pt100_2_12 + module: MuxerGetEntity + ch_number: 1012 + conf_str: 'CONF:FRES AUTO,DEF,(@{})' + calibration: 'pt100_calibration({})' +##################### Cable C #################### + # PT 100 3/12 + - name: pt100_3_12 + module: MuxerGetEntity + ch_number: 1004 + conf_str: 'CONF:FRES AUTO,DEF,(@{})' + calibration: 'pt100_calibration({})' + # PT 100 4/12 + - name: pt100_4_12 + module: MuxerGetEntity + ch_number: 1005 + conf_str: 'CONF:FRES AUTO,DEF,(@{})' + calibration: 'pt100_calibration({})' + # PT 100 5/12 + - name: pt100_5_12 + module: MuxerGetEntity + ch_number: 1006 + conf_str: 'CONF:FRES AUTO,DEF,(@{})' + calibration: 'pt100_calibration({})' + # PT 100 6/12 + - name: pt100_6_12 + module: MuxerGetEntity + ch_number: 1007 + conf_str: 'CONF:FRES AUTO,DEF,(@{})' + calibration: 'pt100_calibration({})' + # PT 100 7/12 +## - name: pt_100_7_12 + ## modeule: MuxerGetEntity + ## ch_number: 1013 + ## conf_str: 'CONF:FRES AUTO,DEF,(@{})' + ## calibration: 'pt100_calibration({})' + + # this is not set up but wanted to keep the syntax available as an example + - name: hall_probe_field + module: MuxerGetEntity + ch_number: 1029 + conf_str: 'CONF:VOLT:DC 10,(@{})' + calibration: "(0.9991/0.847)*(1000*{}+0.007)" +