Skip to content

initial muxer implementation #219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3097b60
initial muxer implementation
Feb 6, 2025
5a36087
two files to connect muxer to dripline service
miniwynne Mar 19, 2025
37d26bd
added RelayService by copying old spime_endpoints.py
miniwynne Apr 2, 2025
23e118e
closed the comment that was commenting out all of RelayService
miniwynne Apr 30, 2025
d0eb97c
commenting out the error lines
miniwynne May 1, 2025
32b43e2
added an 'append.MuxerRelay' above the relay class and fixed a commen…
miniwynne May 5, 2025
52e578c
added an 'Enitity.__init__(self,**kwargs) and hopefully fixed the ind…
miniwynne May 5, 2025
b30672a
changed the tabs around using 'expand -t 4'
miniwynne May 5, 2025
1f9d282
changed 'Entity.__init__(self,**kwargs)' to be at the end of Relay
miniwynne May 6, 2025
4722d3b
trying to address the get_str eror using pop
miniwynne May 6, 2025
d21d26b
added self. in front of any kwargs, hoping I didnt break spacing
miniwynne May 6, 2025
cf1000b
did away with adding self. to all objects, ended up doing the kwargs.…
miniwynne May 6, 2025
8ae9e0b
changing the input to MuxerRelay from Entity to FormatEntity
miniwynne May 7, 2025
3985355
importing FormatEntity from implementations
miniwynne May 7, 2025
854ae0b
removed ' ' from get_str to see if that is the issue
miniwynne May 7, 2025
62e4524
putting self. in front get_str
miniwynne May 7, 2025
97cfa8b
adding the kwargs.pop thing again to get_str
miniwynne May 7, 2025
c01ccbe
Fixing super __init__ for Relay
May 7, 2025
da79744
indentation issue at line 147 w calibration
miniwynne May 7, 2025
21518e0
putting get_str back into a string
miniwynne May 7, 2025
18f2eed
Pid loop from phase 2 changed certain syntax, mostly updated throw re…
miniwynne Jul 16, 2025
18dccc0
added ServiceAttributeEntity, which is the module in the config file …
miniwynne Jul 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dripline/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

# Modules in this directory
from .add_auth_spec import *
from .muxer_service import *
223 changes: 223 additions & 0 deletions dripline/extensions/cca_pid_loop.py
Original file line number Diff line number Diff line change
@@ -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.<name> 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)
155 changes: 155 additions & 0 deletions dripline/extensions/muxer_service.py
Original file line number Diff line number Diff line change
@@ -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'<conf_str> 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)

Loading