From 950834f629746a5eb3896a8ca8a6ea7cc9f24c39 Mon Sep 17 00:00:00 2001 From: GraysonBellamy Date: Mon, 8 Jan 2024 13:19:29 -0500 Subject: [PATCH] Add Serial framework and device module --- poetry.lock | 39 ++++- pyAlicat/example.py | 19 --- {pyAlicat => pyalicat}/__init__.py | 0 pyalicat/comm.py | 241 +++++++++++++++++++++++++++++ pyalicat/daq.py | 91 +++++++++++ pyalicat/device.py | 176 +++++++++++++++++++++ pyalicat/example.py | 28 ++++ pyalicat/util.py | 16 ++ pyproject.toml | 1 + tests/test_example/test_hello.py | 2 +- 10 files changed, 592 insertions(+), 21 deletions(-) delete mode 100644 pyAlicat/example.py rename {pyAlicat => pyalicat}/__init__.py (100%) create mode 100644 pyalicat/comm.py create mode 100644 pyalicat/daq.py create mode 100644 pyalicat/device.py create mode 100644 pyalicat/example.py create mode 100644 pyalicat/util.py diff --git a/poetry.lock b/poetry.lock index 1ab605e..f866e13 100644 --- a/poetry.lock +++ b/poetry.lock @@ -523,6 +523,43 @@ gitdb = ">=4.0.1,<5" [package.extras] test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-sugar"] +[[package]] +name = "h5py" +version = "3.10.0" +description = "Read and write HDF5 files from Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "h5py-3.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b963fb772964fc1d1563c57e4e2e874022ce11f75ddc6df1a626f42bd49ab99f"}, + {file = "h5py-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:012ab448590e3c4f5a8dd0f3533255bc57f80629bf7c5054cf4c87b30085063c"}, + {file = "h5py-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:781a24263c1270a62cd67be59f293e62b76acfcc207afa6384961762bb88ea03"}, + {file = "h5py-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f42e6c30698b520f0295d70157c4e202a9e402406f50dc08f5a7bc416b24e52d"}, + {file = "h5py-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:93dd840bd675787fc0b016f7a05fc6efe37312a08849d9dd4053fd0377b1357f"}, + {file = "h5py-3.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2381e98af081b6df7f6db300cd88f88e740649d77736e4b53db522d8874bf2dc"}, + {file = "h5py-3.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:667fe23ab33d5a8a6b77970b229e14ae3bb84e4ea3382cc08567a02e1499eedd"}, + {file = "h5py-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90286b79abd085e4e65e07c1bd7ee65a0f15818ea107f44b175d2dfe1a4674b7"}, + {file = "h5py-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c013d2e79c00f28ffd0cc24e68665ea03ae9069e167087b2adb5727d2736a52"}, + {file = "h5py-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:92273ce69ae4983dadb898fd4d3bea5eb90820df953b401282ee69ad648df684"}, + {file = "h5py-3.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c97d03f87f215e7759a354460fb4b0d0f27001450b18b23e556e7856a0b21c3"}, + {file = "h5py-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86df4c2de68257b8539a18646ceccdcf2c1ce6b1768ada16c8dcfb489eafae20"}, + {file = "h5py-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba9ab36be991119a3ff32d0c7cbe5faf9b8d2375b5278b2aea64effbeba66039"}, + {file = "h5py-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c8e4fda19eb769e9a678592e67eaec3a2f069f7570c82d2da909c077aa94339"}, + {file = "h5py-3.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:492305a074327e8d2513011fa9fffeb54ecb28a04ca4c4227d7e1e9616d35641"}, + {file = "h5py-3.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9450464b458cca2c86252b624279115dcaa7260a40d3cb1594bf2b410a2bd1a3"}, + {file = "h5py-3.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd6f6d1384a9f491732cee233b99cd4bfd6e838a8815cc86722f9d2ee64032af"}, + {file = "h5py-3.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3074ec45d3dc6e178c6f96834cf8108bf4a60ccb5ab044e16909580352010a97"}, + {file = "h5py-3.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:212bb997a91e6a895ce5e2f365ba764debeaef5d2dca5c6fb7098d66607adf99"}, + {file = "h5py-3.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5dfc65ac21fa2f630323c92453cadbe8d4f504726ec42f6a56cf80c2f90d6c52"}, + {file = "h5py-3.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4682b94fd36ab217352be438abd44c8f357c5449b8995e63886b431d260f3d3"}, + {file = "h5py-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aece0e2e1ed2aab076c41802e50a0c3e5ef8816d60ece39107d68717d4559824"}, + {file = "h5py-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43a61b2c2ad65b1fabc28802d133eed34debcc2c8b420cb213d3d4ef4d3e2229"}, + {file = "h5py-3.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae2f0201c950059676455daf92700eeb57dcf5caaf71b9e1328e6e6593601770"}, + {file = "h5py-3.10.0.tar.gz", hash = "sha256:d93adc48ceeb33347eb24a634fb787efc7ae4644e6ea4ba733d099605045c049"}, +] + +[package.dependencies] +numpy = ">=1.17.3" + [[package]] name = "hightime" version = "0.2.1" @@ -1753,4 +1790,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "f05e8b23ac83fc3a27ed23403bad40bf1dbde4c50209dd57bc5cfea76141f76d" +content-hash = "3ece6d677d6b57983773e4d2c7623969ec4f3fc24353940fe20087c7941608f5" diff --git a/pyAlicat/example.py b/pyAlicat/example.py deleted file mode 100644 index 3df5ba1..0000000 --- a/pyAlicat/example.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Example of code.""" - - -def hello(name: str) -> str: - """Just an greetings example. - - Args: - name (str): Name to greet. - - Returns: - str: greeting message - - Examples: - .. code:: python - - >>> hello("Roman") - 'Hello Roman!' - """ - return f"Hello {name}!" diff --git a/pyAlicat/__init__.py b/pyalicat/__init__.py similarity index 100% rename from pyAlicat/__init__.py rename to pyalicat/__init__.py diff --git a/pyalicat/comm.py b/pyalicat/comm.py new file mode 100644 index 0000000..13f196d --- /dev/null +++ b/pyalicat/comm.py @@ -0,0 +1,241 @@ +""" +Sets up the communication for the Alicat device. + +Author: Grayson Bellamy +Date: 2024-01-05 +""" + +import trio +from trio_serial import SerialStream +from trio_serial import Parity, StopBits +from abc import ABC, abstractmethod +from typing import Optional, ByteString + + +class CommDevice(ABC): + """ + Sets up the communication for the an Alicat device. + """ + + def __init__(self, timeout: int) -> None: + """ + Initializes the serial communication. + + Parameters + ---------- + timeout : int + The timeout of the Alicat device. + """ + + self.timeout = timeout + + @abstractmethod + async def _read(self, len: int) -> Optional[str]: + """ + Reads the serial communication. + + Returns + ------- + str + The serial communication. + """ + pass + + @abstractmethod + async def _write(self, command: str) -> None: + """ + Writes the serial communication. + + Parameters + ---------- + command : str + The serial communication. + """ + pass + + @abstractmethod + async def close(self): + """ + Closes the serial communication. + """ + pass + + @abstractmethod + async def _readline(self) -> Optional[str]: + """ + Reads the serial communication until end-of-line character reached + + Returns + ------- + str + The serial communication. + """ + pass + + @abstractmethod + async def _write_readline(self, command: str) -> Optional[str]: + """ + Writes the serial communication and reads the response until end-of-line character reached + + Parameters: + command (str): + The serial communication. + + Returns: + str: The serial communication. + """ + pass + + +class SerialDevice(CommDevice): + """ + Sets up the communication for the an Alicat device using serial protocol. + """ + + def __init__( + self, + port: str, + baudrate: int = 115200, + timeout: int = 150, + databits: int = 8, + parity: Parity = Parity.NONE, + stopbits: StopBits = StopBits.ONE, + xonxoff: bool = False, + rtscts: bool = False, + exclusive: bool = False, + ): + """ + Initializes the serial communication. + + Parameters + ---------- + port : str + The port to which the Alicat device is connected. + baudrate : int + The baudrate of the Alicat device. + timeout : int + The timeout of the Alicat device in ms. + """ + super().__init__(timeout) + + self.timeout = timeout + self.eol = b"\r" + self.serial_setup = { + "port": port, + "exclusive": exclusive, + "baudrate": baudrate, + "bytesize": databits, + "parity": parity, + "stopbits": stopbits, + "xonxoff": xonxoff, + "rtscts": rtscts, + } + self.ser_devc = SerialStream(**self.serial_setup) + + async def _read(self, len: int = 1) -> ByteString: + """ + Reads the serial communication. + + Returns + ------- + ByteString + The serial communication. + """ + return await self.ser_devc.receive_some(len) + + async def _write(self, command: str) -> None: + """ + Writes the serial communication. + + Parameters + ---------- + command : str + The serial communication. + """ + with trio.move_on_after(self.timeout / 1000): + await self.ser_devc.send_all(command.encode("ascii") + self.eol) + + async def _readline(self) -> str: + """ + Reads the serial communication until end-of-line character reached + + Returns + ------- + str + The serial communication. + """ + async with self.ser_devc: + line = bytearray() + while True: + with trio.move_on_after(self.timeout / 1000): + c = await self._read(1) + line += c + if c == self.eol: + break + return line.decode("ascii") + + async def _write_readall(self, command: str) -> list: + """ + Write command and read until timeout reached. + + Returns + ------- + str + The serial communication. + """ + async with self.ser_devc: + await self._write(command) + line = bytearray() + arr_line = [] + while True: + c = None + with trio.move_on_after(self.timeout / 1000): + c = await self._read(1) + if c == self.eol: + arr_line.append(line.decode("ascii")) + line = bytearray() + else: + line += c + if c is None: + break + return arr_line + + async def _write_readline(self, command: str) -> str: + """ + Writes the serial communication and reads the response until end-of-line character reached + + Parameters: + command (str): + The serial communication. + + Returns: + str: The serial communication. + """ + async with self.ser_devc: + await self._write(command) + line = bytearray() + while True: + with trio.move_on_after(self.timeout / 1000): + c = await self._read(1) + if c == self.eol: + break + line += c + return line.decode("ascii") + + async def _flush(self) -> None: + """ + Flushes the serial communication. + """ + await self.ser_devc.discard_input() + + async def close(self) -> None: + """ + Closes the serial communication. + """ + await self.ser_devc.aclose() + + async def open(self) -> None: + """ + Opens the serial communication. + """ + await self.ser_devc.aopen() diff --git a/pyalicat/daq.py b/pyalicat/daq.py new file mode 100644 index 0000000..838177c --- /dev/null +++ b/pyalicat/daq.py @@ -0,0 +1,91 @@ +""" +DAQ Class for managing Alicat devices. Accessible to external API and internal logging module. + +Author: Grayson Bellamy +Date: 2024-01-07 +""" + + +class DAQ: + """ + Class for managing Alicat devices. Accessible to external API and internal logging module. Wraps and allows communication with inidividual or all devices through wrapper class. + """ + + def __init__(self, config: dict) -> None: + """ + Initializes the DAQ. + + Parameters + ---------- + config : dict + The configuration dictionary. {Name : port} + """ + + pass + + def _init_devices(self) -> None: + """ + Creates and initializes the devices. + """ + + pass + + def get(self, id: str) -> str: + """ + Gets the data from the device. + + Parameters + ---------- + id : str + The ID of the device. + + Returns + ------- + str + The data from the device. + """ + + pass + + def get_all(self) -> dict: + """ + Gets the data from all devices. + + Returns + ------- + dict + The data from all devices. + """ + + pass + + def set(self, id: str, command: str) -> None: + """ + Sets the data of the device. + + Parameters + ---------- + id : str + The ID of the device. + command : str + The command to send to the device. + """ + + pass + +class DAQLogging: + """ + Class for logging the data from Alicat devices. Creates and saves file to disk with given acquisition rate. Only used for standalone logging. Use external API for use as plugin. + """ + + def __init__(self, config: dict) -> None: + """ + Initializes the Logging module. Creates and saves file to disk with given acquisition rate. + + Parameters + ---------- + config : dict + The configuration dictionary. {Name : port} + """ + + pass \ No newline at end of file diff --git a/pyalicat/device.py b/pyalicat/device.py new file mode 100644 index 0000000..fdf2740 --- /dev/null +++ b/pyalicat/device.py @@ -0,0 +1,176 @@ +import trio +from trio import run +from comm import CommDevice, SerialDevice +from typing import Any, Union +from abc import ABC +import re + + +statistics = { + "Mass Flow": 5, + "Mass Flow Setpt": 37, + "Volu Flow": 4, + "Volu Flow Setpt": 36, + "Abs Press": 2, + "Flow Temp": 3, + "Rel Hum": 25, +} + + +async def new_device(port: str, id: str = "A", **kwargs: Any): + """ + Creates a new device. Chooses appropriate device based on characteristics. + """ + if port.startswith("/dev/"): + device = SerialDevice(port, **kwargs) + dev_info = await device._write_readall(id + "??M*") + info_keys = [ + "manufacturer", + "website", + "phone", + "website", + "model", + "serial", + "manufactured", + "calibrated", + "calibrated_by", + "software", + ] + dev_info = dict( + zip(info_keys, [i[re.search(r"M\d\d", i).end() + 1 :] for i in dev_info]) + ) + for cls in all_subclasses(Device): + if cls.is_model(dev_info["model"]): + return cls(device, dev_info, id, **kwargs) + raise ValueError(f"Unknown device model: {dev_info['model']}") + + +def all_subclasses(cls): + return set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c)] + ) + + +class Device(ABC): + """ + Generic device class. + """ + + def __init__( + self, device: SerialDevice, dev_info: dict, id: str = "A", **kwargs: Any + ) -> None: + self._device = device + self._id = id + self._dev_info = dev_info + self._df_format = None + self._df_units = None + + async def get(self) -> str: + """ + Gets the current value of the device. + """ + if self._df_format is None: + await self.get_df_format() + ret = await self._device._write_readline(self._id) + df = ret.split() + for index in [idx for idx, s in enumerate(self._df_ret) if "decimal" in s]: + df[index] = float(df[index]) + return dict(zip(self._df_format, df)) + + async def get_df_format(self) -> str: + """ + Gets the format of the current dataframe format of the device + """ + resp = await self._device._write_readall(self._id + "??D*") + splits = [] + for match in re.finditer(r"\s", resp[0]): + splits.append(match.start()) + df_table = [ + [k[i + 1 : j] for i, j in zip(splits, splits[1:] + [None])] for k in resp + ] + df_format = [ + i[[idx for idx, s in enumerate(df_table[0]) if "NAME" in s][0]].strip() + for i in df_table[1:-1] + ] + df_ret = [ + i[[idx for idx, s in enumerate(df_table[0]) if "TYPE" in s][0]].strip() + for i in df_table[1:-1] + ] + df_stand = [i for i in df_format if not (i.startswith("*"))] + df_stand_ret = [i for i in df_ret[: len(df_stand)]] + self._df_format = df_format + self._df_ret = df_ret + return [df_stand, df_stand_ret] + + async def get_units(self, measurement: dict) -> dict: + """ + Gets the units of the current dataframe format of the device + """ + units = [None] * len(measurement) + for index in [idx for idx, s in enumerate(self._df_ret) if "decimal" in s]: + ret = await self._device._write_readline( + self._id + "DCU " + str(statistics[list(measurement.keys())[index]]) + ) + units[index] = ret.split()[2] + self._df_units = units + return units + + +class FlowMeter(Device): + """ + A class used to represent a flow meter. + """ + + @classmethod + def is_model(cls, model: str) -> bool: + """Checks if the flow meter is of a certain model. + + Args: + model (str): Model of flow meter. + + Returns: + bool: True if model matches. + """ + cls._models = ["M-", "MS-", "MQ-", "MW-"] + return any([bool(re.search(i, model)) for i in cls._models]) + + def __init__( + self, device: SerialDevice, dev_info: dict, id: str = "A", **kwargs: Any + ) -> None: + """Connects to the flow device. + + Args: + port (str): COM port/address of Alicat flow device. + id (str, optional): Unit ID of Alicat flow device. Defaults to "A". + """ + super().__init__(device, dev_info, id, **kwargs) + + +class FlowController(FlowMeter): + """ + A class used to represent a flow controller. Extends flow meter. + """ + + @classmethod + def is_model(cls, model: str) -> bool: + """Checks if the flow meter is of a certain model. + + Args: + model (str): Model of flow meter. + + Returns: + bool: True if model matches. + """ + cls._models = ["MC-", "MCS-", "MCQ-", "MCW-"] + return any([bool(re.search(i, model)) for i in cls._models]) + + def __init__( + sself, device: SerialDevice, dev_info: dict, id: str = "A", **kwargs: Any + ) -> None: + """Connects to the flow controller. + + Args: + port (str): COM port/address of Alicat flow controller. + id (str, optional): Unit ID of Alicat flow controller. Defaults to "A". + """ + super().__init__(device, dev_info, id, **kwargs) diff --git a/pyalicat/example.py b/pyalicat/example.py new file mode 100644 index 0000000..0cd55af --- /dev/null +++ b/pyalicat/example.py @@ -0,0 +1,28 @@ +"""Example of code.""" +import trio +import timeit +from trio import run +from trio_serial import SerialStream + +eol = b'\r' +async def main(): + async with SerialStream('/dev/ttyUSB0', baudrate=115200) as ser: + buf = ('A??M*').encode() + eol + await ser.send_all(buf) + line = bytearray() + i = 0 + while True: + c = await ser.receive_some(1) + line += c + i += 1 + if c == eol: + print(i) + break + return line + +# Time the execution of the main function +start_time = timeit.default_timer() +print(run(main)) +end_time = timeit.default_timer() + +print(f"Execution time: {(end_time - start_time)*1000} milliseconds") \ No newline at end of file diff --git a/pyalicat/util.py b/pyalicat/util.py new file mode 100644 index 0000000..b0171e7 --- /dev/null +++ b/pyalicat/util.py @@ -0,0 +1,16 @@ +""" +Utilities for manipulating data from Alicat devices. + +Author: Grayson Bellamy +Date: 2024-01-07 +""" +def gas_correction(): + """ + Calculates the gas correction factor for the Alicat device. + + Returns + ------- + float + The gas correction factor. + """ + pass \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 39db2ed..ad15e56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ pyqtgraph = "^0.13.3" pyserial = "^3.5" trio-serial = "^0.4.0" trio = "^0.23.2" +h5py = "^3.10.0" [tool.poetry.dev-dependencies] bandit = "^1.7.1" diff --git a/tests/test_example/test_hello.py b/tests/test_example/test_hello.py index 88c0cdd..74fedf0 100644 --- a/tests/test_example/test_hello.py +++ b/tests/test_example/test_hello.py @@ -2,7 +2,7 @@ import pytest -from pyAlicat.example import hello +from pyalicat.example import hello @pytest.mark.parametrize(