From ee538a0b74f92abd0a9296644677da8be6a27411 Mon Sep 17 00:00:00 2001 From: Michael Randall <59715680+mjrand@users.noreply.github.com> Date: Mon, 26 Feb 2024 07:14:11 -0800 Subject: [PATCH] Adding Hi6200 Agent for reading LN2 on SATp (#555) * Adding Hi6200 Agent for reading LN2 on SATp * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Corrected some whitespace and style issues * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixed my fight with the pre-commit bot * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Added in edits from Brian's PR comments. Added docs. (Attempted) removal of all swap files * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Attempting to fix disconnect error catching * Added catch in monitor weight for AttributeError. Cleaned up code as per Brian's comments. * Actually fixed Brian's comments regarding pacemaker, privatizing decoding function, etc. * Remove layer of nesting in main process * Add job names to lock * Wrap agent code to 80 characters * Add agent to docs index * Add driver code to docs page * Format driver docstrings and wrap code to 80 characters --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Brian Koopman --- docs/agents/hi6200.rst | 87 +++++++++++++++++++ docs/index.rst | 1 + socs/agents/hi6200/agent.py | 159 ++++++++++++++++++++++++++++++++++ socs/agents/hi6200/drivers.py | 82 ++++++++++++++++++ socs/plugin.py | 1 + 5 files changed, 330 insertions(+) create mode 100644 docs/agents/hi6200.rst create mode 100644 socs/agents/hi6200/agent.py create mode 100644 socs/agents/hi6200/drivers.py diff --git a/docs/agents/hi6200.rst b/docs/agents/hi6200.rst new file mode 100644 index 000000000..75c74ad19 --- /dev/null +++ b/docs/agents/hi6200.rst @@ -0,0 +1,87 @@ +.. highlight:: rst + +.. _Hi6200: + +============== +Hi6200 Agent +============== + +This agent uses Modbus TCP to communicate with the Hi6200 Weight Sensor. +This agent uses ModbusClient from pyModbusTCP to facilitate the communication. +The agent is able to communicate over ethernet to read and monitor the net and +gross weights of the scale. + +.. argparse:: + :filename: ../socs/agents/hi6200/agent.py + :func: make_parser + :prog: python3 agent.py + + +Configuration File Examples +--------------------------- +Below are configuration examples for the ocs config file and for running the +Agent in a docker container. + +OCS Site Config +``````````````` + +To configure the Hi6200 Agent we need to add a block to our ocs +configuration file. Here is an example configuration block using all of +the available arguments:: + + {'agent-class': 'Hi6200Agent', + 'instance-id': 'hi6200', + 'arguments': [ + ['--ip-address', '192.168.11.43'], + ['--tcp-port', '502'] + ]}, + +The Hi6200 Agent requires the IP address and ModbusTCP port of the Hi6200 +in order to connect to the Hi6200. The default ModbusTCP port on the Hi6200 +is 502. + +Docker Compose +`````````````` + +The SCPI PSU Agent should be configured to run in a Docker container. +An example docker-compose service configuration is shown here:: + + ocs-hi6200: + image: simonsobs/socs:latest + hostname: ocs-docker + network_mode: "host" + environment: + - INSTANCE_ID=hi6200 + volumes: + - ${OCS_CONFIG_DIR}:/config:ro + +Agent API +--------- + +.. autoclass:: socs.agents.hi6200.agent.Hi6200Agent + :members: + +Example Clients +--------------- + +Below is an example client demonstrating full agent functionality.:: + + from ocs.ocs_client import OCSClient + + # Initialize the power supply + scale = OCSClient('hi6200') + scale.init.start() + scale.init.wait() + + # Begin Monitoring Weight + scale.monitor_weight.start() + + #Stop Monitoring Weight + scale.stop_monitoring.start() + +Supporting APIs +--------------- + +.. autoclass:: socs.agents.hi6200.drivers.Hi6200Interface + :members: + :noindex: diff --git a/docs/index.rst b/docs/index.rst index e75cae33f..7d8e746dd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,6 +43,7 @@ API Reference Full API documentation for core parts of the SOCS library. agents/cryomech_cpa agents/fts_agent agents/generator + agents/hi6200 agents/hwp_encoder agents/hwp_gripper agents/hwp_pcu diff --git a/socs/agents/hi6200/agent.py b/socs/agents/hi6200/agent.py new file mode 100644 index 000000000..e7986ac91 --- /dev/null +++ b/socs/agents/hi6200/agent.py @@ -0,0 +1,159 @@ +import argparse +import time + +from ocs import ocs_agent, site_config +from ocs.ocs_twisted import Pacemaker, TimeoutLock + +from socs.agents.hi6200.drivers import Hi6200Interface + + +class Hi6200Agent: + """ + Agent to connect to the Hi6200 weight controller that measures the weight + of the LN2 dewar on the SAT platform. + + Parameters: + ip_address (string): IP address set on the Hi6200 + tcp_port (int): Modbus TCP port of the Hi6200. + Default set on the device is 502. + scale (Hi6200Interface): A driver object that allows + for communication with the scale. + """ + + def __init__(self, agent, ip_address, tcp_port): + self.agent = agent + self.log = agent.log + self.lock = TimeoutLock() + + self.ip_address = ip_address + self.tcp_port = tcp_port + self.scale = None + + self.monitor = False + + # Registers Scale Output + agg_params = { + 'frame_length': 10 * 60, + } + self.agent.register_feed('scale_output', + record=True, + agg_params=agg_params, + buffer_time=0) + + @ocs_agent.param('_') + def init(self, session, params=None): + """init() + + **Task** - Initialize connection to the Hi 6200 Weight Sensor. + + """ + with self.lock.acquire_timeout(0, job='init') as acquired: + if not acquired: + return False, "Could not acquire lock" + + self.scale = Hi6200Interface(self.ip_address, self.tcp_port) + + self.log.info("Connected to scale.") + + return True, 'Initialized Scale.' + + @ocs_agent.param('wait', type=float, default=1) + def monitor_weight(self, session, params=None): + """monitor_weight(wait=1) + + **Process** - Continuously monitor scale gross and net weights. + + Parameters: + wait (float, optional): Time to wait between measurements + [seconds]. + + """ + session.set_status('running') + self.monitor = True + + pm = Pacemaker(1, quantize=True) + while self.monitor: + + pm.sleep() + with self.lock.acquire_timeout(1, job='monitor_weight') as acquired: + if not acquired: + self.log.warn("Could not start monitor_weight because " + + f"{self.lock.job} is already running") + return False, "Could not acquire lock." + + data = { + 'timestamp': time.time(), + 'block_name': 'weight', + 'data': {} + } + + try: + # Grab the gross and net weights from the scale. + gross_weight = self.scale.read_scale_gross_weight() + net_weight = self.scale.read_scale_net_weight() + + # The above functions return None when an Attribute error + # is thrown. If they did not return None and threw no + # errors, the data is good. + if (gross_weight is not None) and (net_weight is not None): + data['data']["Gross"] = gross_weight + data['data']["Net"] = net_weight + self.agent.publish_to_feed('scale_output', data) + + # Occurs when the scale disconnects. + except AttributeError as e: + self.log.error("Connection with scale failed. Check that " + + f"the scale is connected: {e}") + return False, "Monitoring weight failed" + + except ValueError as e: + self.log.error("Scale responded with an anomolous number, " + + f"ignorning: {e}") + + except TypeError as e: + self.log.error("Scale responded with 'None' and broke the " + + f"hex decoding, trying again: {e}") + + return True, "Finished monitoring weight" + + def stop_monitoring(self, session, params=None): + self.monitor = False + return True, "Stopping current monitor" + + +def make_parser(parser=None): + """Build the argument parser for the Agent. Allows sphinx to automatically + build documentation based on this function. + + """ + if parser is None: + parser = argparse.ArgumentParser() + + # Add options specific to this agent. + pgroup = parser.add_argument_group('Agent Options') + pgroup.add_argument('--ip-address') + pgroup.add_argument('--tcp-port') + + return parser + + +def main(args=None): + + parser = make_parser() + args = site_config.parse_args(agent_class='Hi6200Agent', + parser=parser, + args=args) + + agent, runner = ocs_agent.init_site_agent(args) + + p = Hi6200Agent(agent, args.ip_address, int(args.tcp_port)) + + agent.register_task('init', p.init) + + agent.register_process('monitor_weight', p.monitor_weight, p.stop_monitoring) + + runner.run(agent, auto_reconnect=True) + + +if __name__ == '__main__': + main() diff --git a/socs/agents/hi6200/drivers.py b/socs/agents/hi6200/drivers.py new file mode 100644 index 000000000..12283a8ba --- /dev/null +++ b/socs/agents/hi6200/drivers.py @@ -0,0 +1,82 @@ +import struct + +from pyModbusTCP.client import ModbusClient + + +class Hi6200Interface(): + """ + Connects to the Hi6200 weight sensor using a TCP ModbusClient with pyModbusTCP. + The Modbus Client uses a socket connection to facillitate Modbus communications. + ModbusClient requires an IP address and port to connect. + + The Gross and Net weight sensors are always available to read on the 8,9 + and 6,7 registers respectively. + + ModbusClient will not throw errors upon incorrect ip_address! + + ModbusClient auto-reconnects upon socket failure if auto_open is True. + + """ + + def __init__(self, ip_address, tcp_port, verbose=False, **kwargs): + self.scale = ModbusClient(host=ip_address, + port=tcp_port, + auto_open=True, + auto_close=False) + + def _decode_scale_weight_registers(self, register_a, register_b): + """ + Decodes the scales weight registers and returns a single weight value + (float). + + The scale holds both the net and gross weights in permanent holding + registers. Each weight is held across 2 registers in 4 hex bits (2 hex + bits/4 bits per register, 8 bits total). The hex bits must be + concatenated and converted to a float. + + """ + # Strip the '0x' hex bit + # We must have 8 total bits to convert, so we zfill until each register + # value is 4 bits + hex_a = hex(register_b)[2:].zfill(4) + hex_b = hex(register_b)[2:].zfill(4) + + # Concatenate the hex bits in cdab order. + hex_weight = hex_b + hex_a + + # This struct function converts the concatenated hex bits to a float. + return struct.unpack('!f', bytes.fromhex(hex_weight))[0] + + def read_scale_gross_weight(self): + """ + Returns: + float: The current gross weight reading of the scale in the sensors + chosen unit (kg). + + """ + try: + # The gross weight is always available on the 8,9 registers. + # Reading these registers will return an int. + a, b = self.scale.read_holding_registers(8, 2) + + return self._decode_scale_weight_registers(a, b) + + except AttributeError: + return None + + def read_scale_net_weight(self): + """ + Returns: + float: The current net weight reading of the scale in the sensors + chosen unit (kg). + + """ + try: + # The gross weight is always available on the 6,7 registers. + # Reading these registers will return an int. + a, b = self.scale.read_holding_registers(6, 2) + + return self._decode_scale_weight_registers(a, b) + + except AttributeError: + return None diff --git a/socs/plugin.py b/socs/plugin.py index 675104824..91b5296d1 100644 --- a/socs/plugin.py +++ b/socs/plugin.py @@ -9,6 +9,7 @@ 'FlowmeterAgent': {'module': 'socs.agents.ifm_sbn246_flowmeter.agent', 'entry_point': 'main'}, 'FTSAerotechAgent': {'module': 'socs.agents.fts_aerotech.agent', 'entry_point': 'main'}, 'GeneratorAgent': {'module': 'socs.agents.generator.agent', 'entry_point': 'main'}, + 'Hi6200Agent': {'module': 'socs.agents.hi6200.agent', 'entry_point': 'main'}, 'HWPBBBAgent': {'module': 'socs.agents.hwp_encoder.agent', 'entry_point': 'main'}, 'HWPGripperAgent': {'module': 'socs.agents.hwp_gripper.agent', 'entry_point': 'main'}, 'HWPPCUAgent': {'module': 'socs.agents.hwp_pcu.agent', 'entry_point': 'main'},