From 20b1c6965b2590068d38c3f525a9652c0a0b0de9 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 16:35:27 +0200 Subject: [PATCH 01/62] Rename example folder for ALC0315 example --- .../README.md | 0 .../_hw_control.py | 0 .../devices.toml | 6 +++--- .../limits.in | 0 .../main_loop.py | 0 .../plot/plot.py | 0 .../run_experiment.py | 2 +- 7 files changed, 4 insertions(+), 4 deletions(-) rename examples/{autonomous_reaction_optimization => reaction_optimization}/README.md (100%) rename examples/{autonomous_reaction_optimization => reaction_optimization}/_hw_control.py (100%) rename examples/{autonomous_reaction_optimization => reaction_optimization}/devices.toml (86%) rename examples/{autonomous_reaction_optimization => reaction_optimization}/limits.in (100%) rename examples/{autonomous_reaction_optimization => reaction_optimization}/main_loop.py (100%) rename examples/{autonomous_reaction_optimization => reaction_optimization}/plot/plot.py (100%) rename examples/{autonomous_reaction_optimization => reaction_optimization}/run_experiment.py (98%) diff --git a/examples/autonomous_reaction_optimization/README.md b/examples/reaction_optimization/README.md similarity index 100% rename from examples/autonomous_reaction_optimization/README.md rename to examples/reaction_optimization/README.md diff --git a/examples/autonomous_reaction_optimization/_hw_control.py b/examples/reaction_optimization/_hw_control.py similarity index 100% rename from examples/autonomous_reaction_optimization/_hw_control.py rename to examples/reaction_optimization/_hw_control.py diff --git a/examples/autonomous_reaction_optimization/devices.toml b/examples/reaction_optimization/devices.toml similarity index 86% rename from examples/autonomous_reaction_optimization/devices.toml rename to examples/reaction_optimization/devices.toml index 9711108e..8f9dabcc 100644 --- a/examples/autonomous_reaction_optimization/devices.toml +++ b/examples/reaction_optimization/devices.toml @@ -10,9 +10,9 @@ type = "AzuraCompact" ip_address = "192.168.1.119" max_pressure = "10 bar" -#[device.r4-heater] -#type = "R4Heater" -#port = "COM1" +[device.r4-heater] +type = "R4Heater" +port = "COM1" [device.flowir] type = "IcIR" diff --git a/examples/autonomous_reaction_optimization/limits.in b/examples/reaction_optimization/limits.in similarity index 100% rename from examples/autonomous_reaction_optimization/limits.in rename to examples/reaction_optimization/limits.in diff --git a/examples/autonomous_reaction_optimization/main_loop.py b/examples/reaction_optimization/main_loop.py similarity index 100% rename from examples/autonomous_reaction_optimization/main_loop.py rename to examples/reaction_optimization/main_loop.py diff --git a/examples/autonomous_reaction_optimization/plot/plot.py b/examples/reaction_optimization/plot/plot.py similarity index 100% rename from examples/autonomous_reaction_optimization/plot/plot.py rename to examples/reaction_optimization/plot/plot.py diff --git a/examples/autonomous_reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py similarity index 98% rename from examples/autonomous_reaction_optimization/run_experiment.py rename to examples/reaction_optimization/run_experiment.py index efd6f24b..4145d66a 100644 --- a/examples/autonomous_reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -2,7 +2,7 @@ import numpy as np import pandas as pd -from examples.autonomous_reaction_optimization._hw_control import ( +from examples.reaction_optimization._hw_control import ( command_session, socl2_endpoint, r4_endpoint, From 1bc24cdf63159336d999a18d6ba1a04414c09990 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 16:36:40 +0200 Subject: [PATCH 02/62] Refactor Zerconfig of devices Advertize for devices not components, skip name validation in zeroconfig but enforce before at config parsing level. --- examples/test_device/list_flowchem_devices.py | 26 +++++++++++ examples/test_device/test_config.toml | 2 + src/flowchem/server/api_server.py | 14 +++--- src/flowchem/server/configuration_parser.py | 13 ++++++ src/flowchem/server/zeroconf_server.py | 46 ++++++------------- 5 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 examples/test_device/list_flowchem_devices.py create mode 100644 examples/test_device/test_config.toml diff --git a/examples/test_device/list_flowchem_devices.py b/examples/test_device/list_flowchem_devices.py new file mode 100644 index 00000000..8f267dc6 --- /dev/null +++ b/examples/test_device/list_flowchem_devices.py @@ -0,0 +1,26 @@ +from zeroconf import ServiceBrowser, Zeroconf, ServiceListener +import socket + + +class MyListener(ServiceListener): + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + print(f"Service {name}%s removed") + + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + print(f"Service {name}%s updated") + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + info = zeroconf.get_service_info(type_, name) + if info: + # Convert IPv4 from bytes to string + device_ip = socket.inet_ntoa(info.addresses[0]) + print(f"Service {name} added, IP address: {device_ip}") + + +zeroconf = Zeroconf() +listener = MyListener() +browser = ServiceBrowser(zeroconf, "_labthing._tcp.local.", listener) +try: + input("Press enter to exit...\n\n") +finally: + zeroconf.close() diff --git a/examples/test_device/test_config.toml b/examples/test_device/test_config.toml new file mode 100644 index 00000000..a9f2ef59 --- /dev/null +++ b/examples/test_device/test_config.toml @@ -0,0 +1,2 @@ +[device.test-device] +type = "FakeDevice" diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index ed476594..5bf111fe 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -60,7 +60,7 @@ async def create_server_for_devices( dev_list = config["device"] port = config.get("port", 8000) - # FastAPI server + # HTTP server (FastAPI) app = FastAPI( title=f"Flowchem - {config.get('filename')}", description=metadata("flowchem")["Summary"], @@ -71,7 +71,8 @@ async def create_server_for_devices( }, ) - mdns = ZeroconfServer(port=port, debug=False) + # mDNS server (Zeroconfig) + mdns = ZeroconfServer(port=port) api_base_url = r"http://" + f"{host}:{port}" for seconds_delay, task_to_repeat in repeated_tasks: @@ -91,18 +92,17 @@ def home_redirect_to_docs(root_path): for device in dev_list: # Get components (some compounded devices can return multiple components) components = device.components() + device_info = device.get_metadata() logger.debug(f"Got {len(components)} components from {device.name}") + # Advertise devices (not components!) + await mdns.add_device(name=device.name, url=api_base_url, info=device_info) + for component in components: # API endpoints registration app.include_router(component.router, tags=component.router.tags) logger.debug(f"Router <{component.router.prefix}> added to app!") - # Advertise component via zeroconfig - await mdns.add_component( - name=component.router.prefix, url=api_base_url + component.router.prefix - ) - return {"api_server": app, "mdns_server": mdns, "port": port} diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 674f449c..db93f1cb 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -72,6 +72,18 @@ def instantiate_device(config: dict) -> dict: return config +def ensure_device_name_is_valid(device_name: str) -> None: + """ + Device name validator + + Uniqueness of names is ensured by their toml dict key nature,""" + if len(device_name) > 42: + # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig + raise InvalidConfiguration( + f"Name for device '{device_name}' is too long. Max 42 characters please." + ) + + def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: """ Parse device config and return a device object. @@ -79,6 +91,7 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: Exception handling to provide more specific and diagnostic messages upon errors in the configuration file. """ device_name, device_config = dev_settings + ensure_device_name_is_valid(device_name) # Get device class try: diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index 4960c468..f0910394 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -1,5 +1,4 @@ """Zeroconf (mDNS) server.""" -import hashlib import uuid from loguru import logger @@ -8,14 +7,15 @@ from zeroconf import ServiceInfo from zeroconf import Zeroconf +from flowchem.devices.flowchem_device import DeviceInfo + class ZeroconfServer: """ZeroconfServer to advertise FlowchemComponents.""" - def __init__(self, port=8000, debug=False): + def __init__(self, port=8000): # Server properties self.port = port - self.debug = debug self.server = Zeroconf(ip_version=IPVersion.V4Only) @@ -27,47 +27,27 @@ def __init__(self, port=8000, debug=False): and not ip.startswith("169.254") # Remove invalid IPs ] - @staticmethod - def _get_valid_service_name(name: str): - """Given a desired service name, returns a valid one ;)""" - candidate_name = f"{name}._labthing._tcp.local." - if len(candidate_name) < 64: - prefix = name - else: - logger.warning( - f"The device name '{name}' is too long to be used as identifier." - f"It will be trimmed to " - ) - # First 30 characters of name + 10 of hash for uniqueness (2^40 ~ 1E12 collision rate is acceptable). - # The hash is based on the (unique?) name end and limited to 64 i.e. max Blake2b key size - prefix = ( - name[:30] - + hashlib.blake2b( - key=name[-64:].encode("utf-8"), digest_size=10 - ).hexdigest() - ) - - return f"{prefix}._labthing._tcp.local." - - async def add_component(self, name, url): + async def add_device(self, name: str, url: str, device_info: DeviceInfo): """Adds device to the server.""" logger.debug(f"Adding zeroconf component {name}") - service_name = ZeroconfServer._get_valid_service_name(name) + + base_info = { + "path": url, + "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), + } + properites = base_info | device_info # LabThing service service_info = ServiceInfo( type_="_labthing._tcp.local.", - name=service_name, + name=name, port=self.port, - properties={ - "path": url, - "id": f"{service_name}:{uuid.uuid4()}".replace(" ", ""), - }, + properties=properites, parsed_addresses=self.mdns_addresses, ) await self.server.async_register_service(service_info) - logger.debug(f"Registered {service_name} on the mDNS server! [ -> {url}]") + logger.debug(f"Registered {name} on the mDNS server! [ -> {url}]") if __name__ == "__main__": From b07e34da3ca5931de8e2aa76a1d4214192eeb8b5 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 17:03:04 +0200 Subject: [PATCH 03/62] Avoid circular import loop --- src/flowchem/components/base_component.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index 70597c74..00370d70 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -8,8 +8,7 @@ from pydantic import BaseModel if TYPE_CHECKING: - from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.flowchem_device import DeviceInfo + from flowchem.devices.flowchem_device import FlowchemDevice, DeviceInfo class ComponentInfo(BaseModel): From 6d442f1eaa952722e94e13c7c1db3f403b8dc9be Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 17:03:37 +0200 Subject: [PATCH 04/62] Add test for config parser notably including max device name --- src/flowchem/server/configuration_parser.py | 43 +-------------------- tests/server/test_config_parser.py | 37 ++++++++++++++++++ 2 files changed, 38 insertions(+), 42 deletions(-) create mode 100644 tests/server/test_config_parser.py diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index db93f1cb..eaf1330e 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -4,7 +4,6 @@ import typing from io import BytesIO from pathlib import Path -from textwrap import dedent from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.list_known_device_type import autodiscover_device_classes @@ -80,7 +79,7 @@ def ensure_device_name_is_valid(device_name: str) -> None: if len(device_name) > 42: # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig raise InvalidConfiguration( - f"Name for device '{device_name}' is too long. Max 42 characters please." + f"Name for device '{device_name}' is too long ({len(device_name)} characters, max is 42)" ) @@ -158,43 +157,3 @@ def get_helpful_error_message(called_with: dict, arg_spec: inspect.FullArgSpec): logger.error( f"The following mandatory parameters were missing in the configuration: {missing_parameters}" ) - - -if __name__ == "__main__": - cfg_txt = BytesIO( - dedent( - """config_version = "1.0" - simulation = true - - [device.donor] - type = "Elite11InfuseOnly" - port = "COM11" - address = 0 - syringe_diameter = "4.6 mm" - syringe_volume = "1 ml" - - [device.activator] - type = "Elite11InfuseOnly" - port = "COM11" - address= 1 - syringe_diameter = "4.6 mm" - syringe_volume = "1 ml" - - [device.quencher] - type = "AxuraCompactPump" - mac_address = "00:80:A3:BA:C3:4A" - max_pressure = "13 bar" - - [device.sample-loop] - type = "ViciValve" - port = "COM13" - address = 0 - - [device.chiller] - type = "HubeerChiller" - port = "COM3" - """ - ).encode("utf-8") - ) - cfg = parse_config(cfg_txt) - print(cfg) diff --git a/tests/server/test_config_parser.py b/tests/server/test_config_parser.py new file mode 100644 index 00000000..6128498d --- /dev/null +++ b/tests/server/test_config_parser.py @@ -0,0 +1,37 @@ +from io import BytesIO +from textwrap import dedent + +import pytest +from flowchem_test.fakedevice import FakeDevice + +from flowchem.server.configuration_parser import parse_config +from flowchem.utils.exceptions import InvalidConfiguration + + +def test_minimal_valid_config(): + cfg_txt = BytesIO( + dedent( + """ + [device.test-device] + type = "FakeDevice" + """ + ).encode("utf-8") + ) + cfg = parse_config(cfg_txt) + assert "filename" in cfg.keys() + assert "device" in cfg.keys() + assert isinstance(cfg["device"].pop(), FakeDevice) + + +def test_name_too_long(): + cfg_txt = BytesIO( + dedent( + """ + [device.this_name_is_too_long_and_should_be_shorter] + type = "FakeDevice" + """ + ).encode("utf-8") + ) + with pytest.raises(InvalidConfiguration) as excinfo: + parse_config(cfg_txt) + assert "too long" in str(excinfo.value) From 267c59a7359760b8f0704279c944cd19885de466 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 17:14:52 +0200 Subject: [PATCH 05/62] BUGFIX --- src/flowchem/components/base_component.py | 6 +++++- src/flowchem/server/zeroconf_server.py | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index 00370d70..4b3d58f5 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -8,7 +8,8 @@ from pydantic import BaseModel if TYPE_CHECKING: - from flowchem.devices.flowchem_device import FlowchemDevice, DeviceInfo + from flowchem.devices.flowchem_device import FlowchemDevice +from flowchem.devices.flowchem_device import DeviceInfo class ComponentInfo(BaseModel): @@ -19,6 +20,9 @@ class ComponentInfo(BaseModel): hw_device: DeviceInfo +ComponentInfo.update_forward_refs() + + class FlowchemComponent: def __init__(self, name: str, hw_device: FlowchemDevice): """Initialize component.""" diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index f0910394..ffea62f5 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -27,7 +27,7 @@ def __init__(self, port=8000): and not ip.startswith("169.254") # Remove invalid IPs ] - async def add_device(self, name: str, url: str, device_info: DeviceInfo): + async def add_device(self, name: str, url: str, info: DeviceInfo): """Adds device to the server.""" logger.debug(f"Adding zeroconf component {name}") @@ -35,12 +35,12 @@ async def add_device(self, name: str, url: str, device_info: DeviceInfo): "path": url, "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), } - properites = base_info | device_info + properites = base_info | dict(info) # LabThing service service_info = ServiceInfo( type_="_labthing._tcp.local.", - name=name, + name=name + "._labthing._tcp.local.", port=self.port, properties=properites, parsed_addresses=self.mdns_addresses, From ed67c81040f356167d34f2760f7443a5fee0c606 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 17:26:42 +0200 Subject: [PATCH 06/62] list device properties in list_flowchem_devices.py --- examples/test_device/list_flowchem_devices.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/test_device/list_flowchem_devices.py b/examples/test_device/list_flowchem_devices.py index 8f267dc6..96ce5807 100644 --- a/examples/test_device/list_flowchem_devices.py +++ b/examples/test_device/list_flowchem_devices.py @@ -15,6 +15,7 @@ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: # Convert IPv4 from bytes to string device_ip = socket.inet_ntoa(info.addresses[0]) print(f"Service {name} added, IP address: {device_ip}") + print(f"Device properties are: {info.properties}") zeroconf = Zeroconf() From 1eb6fea1e43267f475c3b58866e875577b45676a Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 17:29:16 +0200 Subject: [PATCH 07/62] Do not include duplicate device info in the mDNS server but on http only They can be fetch by there, mDNS is only for discovery and points to the HTTP server anyway --- src/flowchem/server/api_server.py | 4 ++-- src/flowchem/server/zeroconf_server.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 5bf111fe..63dbd8fa 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -92,11 +92,11 @@ def home_redirect_to_docs(root_path): for device in dev_list: # Get components (some compounded devices can return multiple components) components = device.components() - device_info = device.get_metadata() + device.get_metadata() logger.debug(f"Got {len(components)} components from {device.name}") # Advertise devices (not components!) - await mdns.add_device(name=device.name, url=api_base_url, info=device_info) + await mdns.add_device(name=device.name, url=api_base_url) for component in components: # API endpoints registration diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index ffea62f5..023007a5 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -7,8 +7,6 @@ from zeroconf import ServiceInfo from zeroconf import Zeroconf -from flowchem.devices.flowchem_device import DeviceInfo - class ZeroconfServer: """ZeroconfServer to advertise FlowchemComponents.""" @@ -27,15 +25,14 @@ def __init__(self, port=8000): and not ip.startswith("169.254") # Remove invalid IPs ] - async def add_device(self, name: str, url: str, info: DeviceInfo): + async def add_device(self, name: str, url: str): """Adds device to the server.""" logger.debug(f"Adding zeroconf component {name}") - base_info = { - "path": url, + properites = { + "path": url + f"/{name}/", "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), } - properites = base_info | dict(info) # LabThing service service_info = ServiceInfo( From e14f61126a9992424be3038dce328715e99036ce Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Mon, 3 Jul 2023 17:59:13 +0200 Subject: [PATCH 08/62] Add basic per-device response page As it is linked by zeroconfig. It should list all components as well --- docs/add_device/add_to_flowchem.md | 8 +++--- src/flowchem/components/base_component.py | 15 ++--------- src/flowchem/components/component_info.py | 10 ++++++++ src/flowchem/components/device_info.py | 21 ++++++++++++++++ src/flowchem/components/pumps/syringe.py | 2 +- src/flowchem/devices/bronkhorst/el_flow.py | 2 +- src/flowchem/devices/dataapex/clarity.py | 2 +- src/flowchem/devices/flowchem_device.py | 25 +------------------ src/flowchem/devices/hamilton/ml600.py | 2 +- .../devices/harvardapparatus/elite11.py | 2 +- src/flowchem/devices/huber/chiller.py | 2 +- src/flowchem/devices/knauer/azura_compact.py | 2 +- src/flowchem/devices/knauer/dad.py | 2 +- src/flowchem/devices/knauer/knauer_valve.py | 2 +- src/flowchem/devices/magritek/spinsolve.py | 2 +- .../devices/manson/manson_power_supply.py | 2 +- src/flowchem/devices/mettlertoledo/icir.py | 2 +- .../devices/phidgets/bubble_sensor.py | 2 +- .../devices/phidgets/pressure_sensor.py | 2 +- src/flowchem/devices/vacuubrand/cvc3000.py | 2 +- src/flowchem/devices/vapourtec/r2.py | 2 +- src/flowchem/devices/vapourtec/r4_heater.py | 2 +- src/flowchem/devices/vicivalco/vici_valve.py | 2 +- src/flowchem/server/api_server.py | 12 ++++++++- src/flowchem/utils/people.py | 2 +- 25 files changed, 69 insertions(+), 60 deletions(-) create mode 100644 src/flowchem/components/component_info.py create mode 100644 src/flowchem/components/device_info.py diff --git a/docs/add_device/add_to_flowchem.md b/docs/add_device/add_to_flowchem.md index 83f43809..9ec2d475 100644 --- a/docs/add_device/add_to_flowchem.md +++ b/docs/add_device/add_to_flowchem.md @@ -155,7 +155,8 @@ commands to our device and some metadata about our device: ```python ... from flowchem.devices.weasley.extendable_ear_microphone import ExtendableEarMicrophone -from flowchem.devices.flowchem_device import FlowchemDevice, DeviceInfo, Person +from flowchem.devices.flowchem_device import FlowchemDevice +from flowchem.components.device_info import DeviceInfo, Person class ExtendableEar(FlowchemDevice): @@ -172,8 +173,9 @@ class ExtendableEar(FlowchemDevice): def send_command(self, command): print(command) # This is in place of actual communication with the device - def components(self): - return ExtendableEarMicrophone("microphone", self), + +def components(self): + return ExtendableEarMicrophone("microphone", self), ``` Now if we run `flowchem ear.toml` again the server will start successfully and show the metadata info together with the diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index 4b3d58f5..fc57e991 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -5,22 +5,11 @@ from fastapi import APIRouter from loguru import logger -from pydantic import BaseModel + +from flowchem.components.component_info import ComponentInfo if TYPE_CHECKING: from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.flowchem_device import DeviceInfo - - -class ComponentInfo(BaseModel): - """Metadata associated with flowchem components.""" - - name: str = "" - owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0000968" # 'device' - hw_device: DeviceInfo - - -ComponentInfo.update_forward_refs() class FlowchemComponent: diff --git a/src/flowchem/components/component_info.py b/src/flowchem/components/component_info.py new file mode 100644 index 00000000..3a3a4963 --- /dev/null +++ b/src/flowchem/components/component_info.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class ComponentInfo(BaseModel): + """Metadata associated with flowchem components.""" + + name: str = "" + owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0000968" # 'device' diff --git a/src/flowchem/components/device_info.py b/src/flowchem/components/device_info.py new file mode 100644 index 00000000..46ebbaf2 --- /dev/null +++ b/src/flowchem/components/device_info.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + +from flowchem import __version__ + + +class Person(BaseModel): + name: str + email: str + + +class DeviceInfo(BaseModel): + """Metadata associated with hardware devices.""" + + manufacturer: str + model: str + version: str = "" + serial_number: str | int = "unknown" + backend: str = f"flowchem v. {__version__}" + authors: "list[Person]" + maintainers: "list[Person]" + additional_info: dict = {} diff --git a/src/flowchem/components/pumps/syringe.py b/src/flowchem/components/pumps/syringe.py index 9fb087d4..8fb8ba85 100644 --- a/src/flowchem/components/pumps/syringe.py +++ b/src/flowchem/components/pumps/syringe.py @@ -1,5 +1,5 @@ """Syringe pump component, two flavours, infuse only, infuse-withdraw.""" -from flowchem.components.base_component import ComponentInfo +from flowchem.components.component_info import ComponentInfo from flowchem.components.pumps.base_pump import BasePump diff --git a/src/flowchem/devices/bronkhorst/el_flow.py b/src/flowchem/devices/bronkhorst/el_flow.py index f07a1abd..8dfb40b7 100644 --- a/src/flowchem/devices/bronkhorst/el_flow.py +++ b/src/flowchem/devices/bronkhorst/el_flow.py @@ -8,7 +8,7 @@ from flowchem import ureg from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.utils.people import jakob, dario, wei_hsin from flowchem.devices.bronkhorst.el_flow_component import MFCComponent, EPCComponent diff --git a/src/flowchem/devices/dataapex/clarity.py b/src/flowchem/devices/dataapex/clarity.py index 867ac8c2..35a21959 100644 --- a/src/flowchem/devices/dataapex/clarity.py +++ b/src/flowchem/devices/dataapex/clarity.py @@ -7,7 +7,7 @@ from loguru import logger from .clarity_hplc_control import ClarityComponent -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.utils.people import dario, jakob, wei_hsin diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index b2331bf5..c89775db 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -5,36 +5,13 @@ from typing import TYPE_CHECKING from loguru import logger -from pydantic import BaseModel -from flowchem import __version__ +from flowchem.components.device_info import DeviceInfo from flowchem.utils.exceptions import DeviceError if TYPE_CHECKING: from flowchem.components.base_component import FlowchemComponent - -class Person(BaseModel): - name: str - email: str - - -class DeviceInfo(BaseModel): - """Metadata associated with hardware devices.""" - - backend = f"flowchem v. {__version__}" - authors: "list[Person]" - maintainers: "list[Person]" - manufacturer: str - model: str - serial_number: str | int = "unknown" - version: str = "" - additional_info: dict = {} - - -DeviceInfo.update_forward_refs() - - RepeatedTaskInfo = namedtuple("RepeatedTaskInfo", ["seconds_every", "task"]) diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index ab39aec0..daec94d1 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -10,7 +10,7 @@ from loguru import logger from flowchem import ureg -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.hamilton.ml600_pump import ML600Pump from flowchem.devices.hamilton.ml600_valve import ML600Valve diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index e1892f5b..35baa339 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -12,7 +12,7 @@ from flowchem.devices.harvardapparatus._pumpio import Protocol11Command from flowchem.devices.harvardapparatus._pumpio import PumpStatus from flowchem import ureg -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.harvardapparatus.elite11_pump import Elite11PumpOnly from flowchem.devices.harvardapparatus.elite11_pump import Elite11PumpWithdraw diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index bbe3ede9..e5345e21 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -7,7 +7,7 @@ from flowchem import ureg from flowchem.components.technical.temperature import TempRange -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.huber.huber_temperature_control import HuberTemperatureControl from flowchem.devices.huber.pb_command import PBCommand diff --git a/src/flowchem/devices/knauer/azura_compact.py b/src/flowchem/devices/knauer/azura_compact.py index a6d6a221..9e932527 100644 --- a/src/flowchem/devices/knauer/azura_compact.py +++ b/src/flowchem/devices/knauer/azura_compact.py @@ -7,7 +7,7 @@ from loguru import logger from flowchem import ureg -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.knauer._common import KnauerEthernetDevice from flowchem.devices.knauer.azura_compact_pump import AzuraCompactPump diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index 63a8ae86..0058e778 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -4,7 +4,7 @@ from loguru import logger from typing import Union -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.knauer.dad_component import ( DADChannelControl, KnauerDADLampControl, diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index f755b40a..126e2b88 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -4,7 +4,7 @@ from loguru import logger -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.knauer._common import KnauerEthernetDevice from flowchem.devices.knauer.knauer_valve_component import Knauer12PortDistribution diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 61032fc2..82f7742e 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -9,7 +9,7 @@ from lxml import etree from packaging import version -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.magritek._msg_maker import create_message from flowchem.devices.magritek._msg_maker import create_protocol_message diff --git a/src/flowchem/devices/manson/manson_power_supply.py b/src/flowchem/devices/manson/manson_power_supply.py index c3a3bc45..ac69ea5a 100644 --- a/src/flowchem/devices/manson/manson_power_supply.py +++ b/src/flowchem/devices/manson/manson_power_supply.py @@ -8,7 +8,7 @@ from loguru import logger from flowchem import ureg -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.manson.manson_component import MansonPowerControl from flowchem.utils.exceptions import DeviceError diff --git a/src/flowchem/devices/mettlertoledo/icir.py b/src/flowchem/devices/mettlertoledo/icir.py index 93f1759d..90bf2d6b 100644 --- a/src/flowchem/devices/mettlertoledo/icir.py +++ b/src/flowchem/devices/mettlertoledo/icir.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from flowchem.components.analytics.ir import IRSpectrum -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.mettlertoledo.icir_control import IcIRControl from flowchem.utils.exceptions import DeviceError diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index 3ff8fc48..017941a7 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -8,7 +8,7 @@ from loguru import logger -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.phidgets.bubble_sensor_component import ( PhidgetBubbleSensorComponent, diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index 029c3cc8..adac3e86 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -4,7 +4,7 @@ import pint from loguru import logger -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.phidgets.pressure_sensor_component import ( PhidgetPressureSensorComponent, diff --git a/src/flowchem/devices/vacuubrand/cvc3000.py b/src/flowchem/devices/vacuubrand/cvc3000.py index 1f5b1fe4..be9f78f1 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000.py +++ b/src/flowchem/devices/vacuubrand/cvc3000.py @@ -5,7 +5,7 @@ import pint from loguru import logger -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vacuubrand.cvc3000_pressure_control import CVC3000PressureControl from flowchem.devices.vacuubrand.utils import ProcessStatus diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 7a7f59ea..1d69c506 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -11,7 +11,7 @@ from loguru import logger from flowchem import ureg -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vapourtec.r2_components_control import ( R2GeneralSensor, diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index 48b0df62..fa1a1003 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -10,7 +10,7 @@ from flowchem import ureg from flowchem.components.technical.temperature import TempRange -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vapourtec.r4_heater_channel_control import R4HeaterChannelControl from flowchem.utils.exceptions import InvalidConfiguration diff --git a/src/flowchem/devices/vicivalco/vici_valve.py b/src/flowchem/devices/vicivalco/vici_valve.py index fdf05573..c7d504b7 100644 --- a/src/flowchem/devices/vicivalco/vici_valve.py +++ b/src/flowchem/devices/vicivalco/vici_valve.py @@ -7,7 +7,7 @@ from loguru import logger from flowchem import ureg -from flowchem.devices.flowchem_device import DeviceInfo +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vicivalco.vici_valve_component import ViciInjectionValve from flowchem.utils.exceptions import InvalidConfiguration diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 63dbd8fa..50d933de 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -5,12 +5,13 @@ from pathlib import Path from typing import TypedDict, Iterable -from fastapi import FastAPI +from fastapi import FastAPI, APIRouter from fastapi_utils.tasks import repeat_every from loguru import logger from starlette.responses import RedirectResponse import flowchem +from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import RepeatedTaskInfo from flowchem.server.configuration_parser import parse_config from flowchem.server.zeroconf_server import ZeroconfServer @@ -97,6 +98,15 @@ def home_redirect_to_docs(root_path): # Advertise devices (not components!) await mdns.add_device(name=device.name, url=api_base_url) + # Base device endpoint + device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) + device_root.add_api_route( + "/", + device.get_metadata, # TODO: add components in the device info response! + methods=["GET"], + response_model=DeviceInfo, + ) + app.include_router(device_root) for component in components: # API endpoints registration diff --git a/src/flowchem/utils/people.py b/src/flowchem/utils/people.py index d65583fc..b65dec07 100644 --- a/src/flowchem/utils/people.py +++ b/src/flowchem/utils/people.py @@ -1,4 +1,4 @@ -from flowchem.devices.flowchem_device import Person +from flowchem.components.device_info import Person __all__ = ["dario", "jakob", "wei_hsin"] From d4501e967d4ef7538acf89cdc75bc9acae524bdb Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 4 Jul 2023 16:45:52 +0200 Subject: [PATCH 09/62] Listen on all network interfaces as default This is needed as mDNS found IP for services on local hosts isn't 127.0.0.1 --- src/flowchem/__main__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index c6edf467..24143e6a 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -20,9 +20,7 @@ @click.option( "-l", "--log", "logfile", type=click.Path(), default=None, help="Save logs to file." ) -@click.option( - "-h", "--host", "host", type=str, default="127.0.0.1", help="Server host." -) +@click.option("-h", "--host", "host", type=str, default="0.0.0.0", help="Server host.") @click.option("-d", "--debug", is_flag=True, help="Print debug info.") @click.version_option() @click.command() From 634fb0f9ff4605c19d7fd3a9034e491bd91b63cd Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 11:02:35 +0200 Subject: [PATCH 10/62] Add CI test on python 3.12 --- .github/workflows/python-app.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 7b581d55..75636b80 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -15,6 +15,10 @@ jobs: timeout-minutes: 30 strategy: matrix: + python-version: [ + "3.11", + "3.12-dev", + ] operating-system: - ubuntu-latest - windows-latest @@ -24,10 +28,10 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - name: Set up Python 3.11 + - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.11' + python-version: ${{ matrix.python-version }} - name: Install flowchem run: | @@ -36,7 +40,7 @@ jobs: - name: Run mypy run: | - mypy --check-untyped-defs --python-version 3.10 --ignore-missing-imports ./src + mypy --check-untyped-defs --python-version 3.11 --ignore-missing-imports ./src - name: Run ruff run: ruff . From 94ea64faf502ddb6e355887dbd4623d73fb343c9 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 11:03:32 +0200 Subject: [PATCH 11/62] rename autodiscover.py tp device_finder.py to avoid confusion between flowchem devices and raw devices. --- pyproject.toml | 2 +- src/flowchem/utils/{autodiscover.py => device_finder.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/flowchem/utils/{autodiscover.py => device_finder.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index 4914bad8..f430908f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ repository = "https://github.com/cambiegroup/flowchem" [project.scripts] flowchem = "flowchem.__main__:main" -flowchem-autodiscover = "flowchem.utils.autodiscover:main" +flowchem-autodiscover = "flowchem.utils.device_finder:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/flowchem/utils/autodiscover.py b/src/flowchem/utils/device_finder.py similarity index 100% rename from src/flowchem/utils/autodiscover.py rename to src/flowchem/utils/device_finder.py From f1d682ec2cbb17cbb806ab546675273b632e22f4 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 16:29:59 +0200 Subject: [PATCH 12/62] Remove useless run_create_server_from_file --- src/flowchem/__main__.py | 4 ++-- src/flowchem/server/api_server.py | 15 ++++----------- src/flowchem/server/configuration_parser.py | 2 +- tests/cli/test_autodiscover_cli.py | 2 +- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 24143e6a..05bf40e6 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -13,7 +13,7 @@ from loguru import logger from flowchem import __version__ -from flowchem.server.api_server import run_create_server_from_file +from flowchem.server.api_server import create_server_from_file @click.argument("device_config_file", type=click.Path(), required=True) @@ -51,7 +51,7 @@ def main(device_config_file, logfile, host, debug): async def main_loop(): """The loop must be shared between uvicorn and flowchem.""" - flowchem_instance = await run_create_server_from_file( + flowchem_instance = await create_server_from_file( Path(device_config_file), host=host ) config = uvicorn.Config( diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 50d933de..6ee9e194 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -23,16 +23,8 @@ class FlowchemInstance(TypedDict): port: int -async def run_create_server_from_file( - config_file: BytesIO | Path, host: str = "127.0.0.1" -) -> FlowchemInstance: - """Make create_server_from_file a sync function for CLI.""" - - return await create_server_from_file(config_file, host) - - async def create_server_from_file( - config_file: BytesIO | Path, host: str + config_file: BytesIO | Path, host: str = "0.0.0.0" ) -> FlowchemInstance: """ Based on the toml device config provided, initialize connection to devices and create API endpoints. @@ -55,7 +47,7 @@ async def create_server_from_file( async def create_server_for_devices( config: dict, repeated_tasks: Iterable[RepeatedTaskInfo] = (), - host: str = "127.0.0.1", + host: str = "0.0.0.0", ) -> FlowchemInstance: """Initialize and create API endpoints for device object provided.""" dev_list = config["device"] @@ -74,6 +66,7 @@ async def create_server_for_devices( # mDNS server (Zeroconfig) mdns = ZeroconfServer(port=port) + logger.debug(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") api_base_url = r"http://" + f"{host}:{port}" for seconds_delay, task_to_repeat in repeated_tasks: @@ -121,7 +114,7 @@ def home_redirect_to_docs(root_path): import uvicorn async def main(): - flowchem_instance = await run_create_server_from_file( + flowchem_instance = await create_server_from_file( config_file=io.BytesIO( b"""[device.test-device]\n type = "FakeDevice"\n""" diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index eaf1330e..00a63274 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -39,7 +39,7 @@ def parse_config(file_path: BytesIO | Path) -> dict: # StringIO used for testing without creating actual files if isinstance(file_path, BytesIO): config = parse_toml(file_path) - config["filename"] = "StringIO" + config["filename"] = "BytesIO" else: assert ( file_path.exists() and file_path.is_file() diff --git a/tests/cli/test_autodiscover_cli.py b/tests/cli/test_autodiscover_cli.py index 0628604a..d8616071 100644 --- a/tests/cli/test_autodiscover_cli.py +++ b/tests/cli/test_autodiscover_cli.py @@ -2,7 +2,7 @@ import os from click.testing import CliRunner -from flowchem.utils.autodiscover import main +from flowchem.utils.device_finder import main IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true" From 1e1d381a29b9c6f1869b34eb8ffadc02ca39f950 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 16:59:23 +0200 Subject: [PATCH 13/62] Basic client implementation - device discovery Flowchem device discovery over mDNS async version to be used inline in code, sync to be refactored as standalone click command for debug --- examples/test_device/list_flowchem_devices.py | 27 ------- src/flowchem/client/__init__.py | 0 src/flowchem/client/async_client.py | 76 +++++++++++++++++++ src/flowchem/client/client.py | 65 ++++++++++++++++ src/flowchem/client/common.py | 61 +++++++++++++++ src/flowchem/server/api_server.py | 3 + src/flowchem/server/configuration_parser.py | 7 +- src/flowchem/server/zeroconf_server.py | 8 +- tests/client/test_client.py | 47 ++++++++++++ 9 files changed, 261 insertions(+), 33 deletions(-) delete mode 100644 examples/test_device/list_flowchem_devices.py create mode 100644 src/flowchem/client/__init__.py create mode 100644 src/flowchem/client/async_client.py create mode 100644 src/flowchem/client/client.py create mode 100644 src/flowchem/client/common.py create mode 100644 tests/client/test_client.py diff --git a/examples/test_device/list_flowchem_devices.py b/examples/test_device/list_flowchem_devices.py deleted file mode 100644 index 96ce5807..00000000 --- a/examples/test_device/list_flowchem_devices.py +++ /dev/null @@ -1,27 +0,0 @@ -from zeroconf import ServiceBrowser, Zeroconf, ServiceListener -import socket - - -class MyListener(ServiceListener): - def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: - print(f"Service {name}%s removed") - - def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: - print(f"Service {name}%s updated") - - def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: - info = zeroconf.get_service_info(type_, name) - if info: - # Convert IPv4 from bytes to string - device_ip = socket.inet_ntoa(info.addresses[0]) - print(f"Service {name} added, IP address: {device_ip}") - print(f"Device properties are: {info.properties}") - - -zeroconf = Zeroconf() -listener = MyListener() -browser = ServiceBrowser(zeroconf, "_labthing._tcp.local.", listener) -try: - input("Press enter to exit...\n\n") -finally: - zeroconf.close() diff --git a/src/flowchem/client/__init__.py b/src/flowchem/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py new file mode 100644 index 00000000..072db2db --- /dev/null +++ b/src/flowchem/client/async_client.py @@ -0,0 +1,76 @@ +import asyncio + +from loguru import logger +from zeroconf import Zeroconf +from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo + +from flowchem.client.client import FlowchemCommonDeviceListener +from flowchem.client.common import ( + FLOWCHEM_TYPE, + URL, + device_name_to_zeroconf_name, + device_url_from_service_info, + zeroconf_name_to_device_name, +) + + +class FlowchemAsyncDeviceListener(FlowchemCommonDeviceListener): + async def _resolve_service(self, zc: Zeroconf, type_: str, name: str): + # logger.debug(f"MDNS resolving device '{name}'") + service_info = AsyncServiceInfo(type_, name) + await service_info.async_request(zc, 3000) + if service_info: + device_name = zeroconf_name_to_device_name(name) + self.flowchem_devices[device_name] = device_url_from_service_info( + service_info, device_name + ) + else: + logger.warning(f"No info for service {name}!") + + def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: + asyncio.ensure_future(self._resolve_service(zc, type_, name)) + + +async def async_get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: + """ + Given a flowchem device name, search for it via mDNS and return its URL if found. + + Args: + timeout: timout for search in ms (at least 2 seconds needed) + device_name: name of the device + + Returns: URL object, empty if not found + """ + + zc = AsyncZeroconf() + service_info = await zc.async_get_service_info( + type_=FLOWCHEM_TYPE, + name=device_name_to_zeroconf_name(device_name), + timeout=timeout, + ) + if service_info: + return device_url_from_service_info(service_info, device_name) + + +async def async_get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: + """ + Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) + """ + listener = FlowchemAsyncDeviceListener() + browser = AsyncServiceBrowser(Zeroconf(), FLOWCHEM_TYPE, listener) + await asyncio.sleep(timeout / 1000) + await browser.async_cancel() + + return listener.flowchem_devices + + +if __name__ == "__main__": + + async def main(): + url = await async_get_flowchem_device_by_name("test-device") + print(url) + + dev_info = await async_get_all_flowchem_devices() + print(dev_info) + + asyncio.run(main()) diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py new file mode 100644 index 00000000..efe60dfc --- /dev/null +++ b/src/flowchem/client/client.py @@ -0,0 +1,65 @@ +import time + +from loguru import logger +from zeroconf import ServiceBrowser, Zeroconf + +from flowchem.client.common import ( + FLOWCHEM_TYPE, + zeroconf_name_to_device_name, + device_name_to_zeroconf_name, + URL, + device_url_from_service_info, + FlowchemCommonDeviceListener, +) + + +def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: + """ + Given a flowchem device name, search for it via mDNS and return its URL if found. + + Args: + timeout: timout for search in ms (at least 2 seconds needed) + device_name: name of the device + + Returns: URL object, empty if not found + """ + + zc = Zeroconf() + service_info = zc.get_service_info( + type_=FLOWCHEM_TYPE, + name=device_name_to_zeroconf_name(device_name), + timeout=timeout, + ) + if service_info: + return device_url_from_service_info(service_info, device_name) + + +class FlowchemDeviceListener(FlowchemCommonDeviceListener): + def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: + if service_info := zc.get_service_info(type_, name): + device_name = zeroconf_name_to_device_name(name) + self.flowchem_devices[device_name] = device_url_from_service_info( + service_info, device_name + ) + else: + logger.warning(f"No info for service {name}!") + + +def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: + """ + Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) + """ + listener = FlowchemDeviceListener() + browser = ServiceBrowser(Zeroconf(), FLOWCHEM_TYPE, listener) + time.sleep(timeout / 1000) + browser.cancel() + + return listener.flowchem_devices + + +if __name__ == "__main__": + url = get_flowchem_device_by_name("test-device") + print(url) + + dev_info = get_all_flowchem_devices() + print(dev_info) diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py new file mode 100644 index 00000000..eedc1c62 --- /dev/null +++ b/src/flowchem/client/common.py @@ -0,0 +1,61 @@ +import ipaddress + +from loguru import logger +from zeroconf import ServiceListener, Zeroconf, ServiceInfo + +FLOWCHEM_SUFFIX = "._labthing._tcp.local." +FLOWCHEM_TYPE = FLOWCHEM_SUFFIX[1:] + + +class URL(str): + def __new__(cls, *value): + if value: + v0 = value[0] + if type(v0) is not str: + raise TypeError('Unexpected type for URL: "{type(v0)}"') + if not (v0.startswith("http://") or v0.startswith("https://")): + raise ValueError('Passed string value "{v0}" is not an "http*://" URL') + # else allow None to be passed. This allows an "empty" URL instance, e.g. `URL()` + # `URL()` evaluates False + + return str.__new__(cls, *value) + + +def zeroconf_name_to_device_name(zeroconf_name: str) -> str: + assert zeroconf_name.endswith(FLOWCHEM_SUFFIX) + return zeroconf_name[: -len(FLOWCHEM_SUFFIX)] + + +def device_name_to_zeroconf_name(device_name: str) -> str: + return f"{device_name}{FLOWCHEM_SUFFIX}" + + +def device_url_from_service_info(service_info: ServiceInfo, device_name: str) -> URL: + if service_info.addresses: + # Needed to convert IP from bytes to str + device_ip = ipaddress.ip_address(service_info.addresses[0]) + return URL(f"http://{device_ip}:{service_info.port}/{device_name}") + else: + logger.warning(f"No address found for {device_name}!") + return URL() + + +class FlowchemCommonDeviceListener(ServiceListener): + def __init__(self): + self.flowchem_devices: dict[str, URL] = {} + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: + logger.debug(f"Service {name} removed") + self.flowchem_devices.pop(name, None) + + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: + logger.debug(f"Service {name} updated") + self.flowchem_devices.pop(name, None) + self._save_device_info(zc, type_, name) + + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + logger.debug(f"Service {zeroconf_name_to_device_name(name)} added") + self._save_device_info(zc, type_, name) + + def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: + raise NotImplementedError() diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 6ee9e194..585a93ea 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -96,6 +96,9 @@ def home_redirect_to_docs(root_path): device_root.add_api_route( "/", device.get_metadata, # TODO: add components in the device info response! + # TODO: also, fix confusion between device get_metadata and + # components. Device get_metadata equivalent could be implemented here to + # add the components info but it would miss the owl class that is coming methods=["GET"], response_model=DeviceInfo, ) diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 00a63274..f7d35094 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -79,7 +79,12 @@ def ensure_device_name_is_valid(device_name: str) -> None: if len(device_name) > 42: # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig raise InvalidConfiguration( - f"Name for device '{device_name}' is too long ({len(device_name)} characters, max is 42)" + f"Invalid name for device '{device_name}': too long ({len(device_name)} characters, max is 42)" + ) + if "." in device_name: + # This is not strictly needed but avoids potential zeroconf problems + raise InvalidConfiguration( + f"Invalid name for device '{device_name}': '.' character not allowed" ) diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index 023007a5..8e007fbb 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -27,9 +27,7 @@ def __init__(self, port=8000): async def add_device(self, name: str, url: str): """Adds device to the server.""" - logger.debug(f"Adding zeroconf component {name}") - - properites = { + properties = { "path": url + f"/{name}/", "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), } @@ -39,12 +37,12 @@ async def add_device(self, name: str, url: str): type_="_labthing._tcp.local.", name=name + "._labthing._tcp.local.", port=self.port, - properties=properites, + properties=properties, parsed_addresses=self.mdns_addresses, ) await self.server.async_register_service(service_info) - logger.debug(f"Registered {name} on the mDNS server! [ -> {url}]") + logger.debug(f"Device {name} registered as Zeroconf service!") if __name__ == "__main__": diff --git a/tests/client/test_client.py b/tests/client/test_client.py new file mode 100644 index 00000000..8f02eb8a --- /dev/null +++ b/tests/client/test_client.py @@ -0,0 +1,47 @@ +from io import BytesIO +from textwrap import dedent + +from flowchem.client.async_client import ( + async_get_flowchem_device_by_name, + async_get_all_flowchem_devices, +) +from flowchem.server.api_server import create_server_from_file + + +async def test_get_flowchem_device_by_name(): + flowchem_instance = await create_server_from_file( + BytesIO( + bytes( + dedent( + """[device.test-device]\n + type = "FakeDevice"\n""" + ), + "utf-8", + ) + ), + "0.0.0.0", + ) + + assert flowchem_instance["mdns_server"].server.loop.is_running() + url = await async_get_flowchem_device_by_name("test-device") + assert "test-device" in url + + +async def test_get_all_flowchem_devices(): + flowchem_instance = await create_server_from_file( + BytesIO( + bytes( + dedent( + """[device.test-device2]\n + type = "FakeDevice"\n""" + ), + "utf-8", + ) + ), + "0.0.0.0", + ) + + assert flowchem_instance["mdns_server"].server.loop.is_running() + devs = await async_get_all_flowchem_devices() + print(devs) + assert "test-device2" in devs.keys() From 97128b2049cdb6644ec0c24688646e03214b284b Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 18:05:06 +0200 Subject: [PATCH 14/62] Stub for FlowchemDeviceClient To be used as client for flowchem devices --- examples/reaction_optimization/_hw_control.py | 9 -- examples/reaction_optimization/main_loop.py | 2 +- .../reaction_optimization/run_experiment.py | 52 ++++++----- pyproject.toml | 23 +++-- src/flowchem/client/client.py | 28 +++--- src/flowchem/client/common.py | 37 ++++++++ src/flowchem/components/component_info.py | 2 +- src/flowchem/server/api_server.py | 4 +- src/flowchem/utils/repeat_every.py | 87 +++++++++++++++++++ 9 files changed, 187 insertions(+), 57 deletions(-) create mode 100644 src/flowchem/utils/repeat_every.py diff --git a/examples/reaction_optimization/_hw_control.py b/examples/reaction_optimization/_hw_control.py index ee97e306..717568db 100644 --- a/examples/reaction_optimization/_hw_control.py +++ b/examples/reaction_optimization/_hw_control.py @@ -3,15 +3,6 @@ import requests from loguru import logger -HOST = "127.0.0.1" -PORT = 8000 -api_base = f"http://{HOST}:{PORT}" -# Set device names -socl2_endpoint = f"{api_base}/socl2" -hexyldecanoic_endpoint = f"{api_base}/hexyldecanoic" -r4_endpoint = f"{api_base}/r4-heater/0" -flowir_endpoint = f"{api_base}/flowir" - def check_for_errors(resp, *args, **kwargs): resp.raise_for_status() diff --git a/examples/reaction_optimization/main_loop.py b/examples/reaction_optimization/main_loop.py index 63b413e2..93350fc3 100644 --- a/examples/reaction_optimization/main_loop.py +++ b/examples/reaction_optimization/main_loop.py @@ -4,7 +4,7 @@ from loguru import logger from run_experiment import run_experiment -from examples.autonomous_reaction_optimization._hw_control import ( +from examples.reaction_optimization._hw_control import ( command_session, socl2_endpoint, r4_endpoint, diff --git a/examples/reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py index 4145d66a..e70a8cba 100644 --- a/examples/reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -2,16 +2,29 @@ import numpy as np import pandas as pd -from examples.reaction_optimization._hw_control import ( - command_session, - socl2_endpoint, - r4_endpoint, - hexyldecanoic_endpoint, - flowir_endpoint, -) +from examples.reaction_optimization._hw_control import command_session from loguru import logger from scipy import integrate +from flowchem.client.client import get_all_flowchem_devices + +HOST = "127.0.0.1" +PORT = 8000 +api_base = f"http://{HOST}:{PORT}" + +# Flowchem devices +flowchem_devices = get_all_flowchem_devices() + +# SOCl2 pump +socl2_url = flowchem_devices["socl2"] +# Hexyldecanoic pump +hexyldecanoic_url = flowchem_devices["hexyldecanoic"] +# R4 reactor heater +reactor_url = flowchem_devices["r4-heater"] +reactor_bay = 0 +# FlowIR +flowir_url = flowchem_devices["flowir"] + def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): """ @@ -34,7 +47,6 @@ def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): total_flow_rate = REACTOR_VOLUME / residence_time # ml/min - # Solving a system of 2 equations and 2 unknowns... return { "hexyldecanoic": ( a := (total_flow_rate * SOCl2) @@ -46,25 +58,23 @@ def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): def set_parameters(rates: dict, temperature: float): with command_session() as sess: + sess.put(socl2_url + "/flow-rate", params={"rate": f"{rates['socl2']} ml/min"}) sess.put( - socl2_endpoint + "/flow-rate", params={"rate": f"{rates['socl2']} ml/min"} - ) - sess.put( - hexyldecanoic_endpoint + "/flow-rate", + hexyldecanoic_url + "/flow-rate", params={"rate": f"{rates['hexyldecanoic']} ml/min"}, ) # Sets heater heater_data = {"temperature": f"{temperature:.2f} °C"} - sess.put(r4_endpoint + "/temperature", params=heater_data) + sess.put(reactor_url + "/temperature", params=heater_data) def wait_stable_temperature(): - """Wait until the ste temperature has been reached.""" + """Wait until a stable temperature has been reached.""" logger.info("Waiting for the reactor temperature to stabilize") while True: with command_session() as sess: - r = sess.get(r4_endpoint + "/target-reached") + r = sess.get(reactor_url + "/target-reached") if r.text == "true": logger.info("Stable temperature reached!") break @@ -77,23 +87,21 @@ def get_ir_once_stable(): logger.info("Waiting for the IR spectrum to be stable") with command_session() as sess: # Wait for first spectrum to be available - while int(sess.get(flowir_endpoint + "/sample-count").text) == 0: + while int(sess.get(flowir_url + "/sample-count").text) == 0: time.sleep(1) # Get spectrum previous_spectrum = pd.read_json( - sess.get(flowir_endpoint + "/sample/spectrum-treated").text + sess.get(flowir_url + "/sample/spectrum-treated").text ) previous_spectrum = previous_spectrum.set_index("wavenumber") # In case the id has changed between requests (highly unlikely) - last_sample_id = int(sess.get(flowir_endpoint + "/sample-count").text) + last_sample_id = int(sess.get(flowir_url + "/sample-count").text) while True: # Wait for a new spectrum while True: with command_session() as sess: - current_sample_id = int( - sess.get(flowir_endpoint + "/sample-count").text - ) + current_sample_id = int(sess.get(flowir_url + "/sample-count").text) if current_sample_id > last_sample_id: break else: @@ -101,7 +109,7 @@ def get_ir_once_stable(): with command_session() as sess: current_spectrum = pd.read_json( - sess.get(flowir_endpoint + "/sample/spectrum-treated").text + sess.get(flowir_url + "/sample/spectrum-treated").text ) current_spectrum = current_spectrum.set_index("wavenumber") diff --git a/pyproject.toml b/pyproject.toml index f430908f..136c5476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,22 +22,21 @@ classifiers = [ "License :: OSI Approved :: MIT License" ] dependencies = [ - "aioserial>=1.3.0", + "aioserial>=1.3.1", "anyio", - "asyncua>=0.9.92", - "bronkhorst-propar>=1.0.1", - "fastapi>=0.65.2", - "fastapi_utils>=0.2.1", - "loguru>=0.5.0", - "lxml>=4.6.4", - "packaging>=21.3", + "asyncua>=1.0.2", + "bronkhorst-propar>=1.1.0", + "fastapi>=0.100.0", + "loguru>=0.7.0", + "lxml>=4.9.2", + "packaging>=23.1", "pint>=0.16.1,!=0.21", # See hgrecco/pint#1642 - "pydantic>=1.8.2", + "pydantic>=2.0.2", "pyserial>=3", - "rich_click>=0.3.0", + "rich_click>=1.6.1", 'tomli; python_version<"3.11"', - "uvicorn>=0.13.4", - "zeroconf>=0.39.4", + "uvicorn>=0.19.0", + "zeroconf>=0.71.0", ] [project.optional-dependencies] diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index efe60dfc..972e267e 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -13,6 +13,17 @@ ) +class FlowchemDeviceListener(FlowchemCommonDeviceListener): + def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: + if service_info := zc.get_service_info(type_, name): + device_name = zeroconf_name_to_device_name(name) + self.flowchem_devices[device_name] = device_url_from_service_info( + service_info, device_name + ) + else: + logger.warning(f"No info for service {name}!") + + def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: """ Given a flowchem device name, search for it via mDNS and return its URL if found. @@ -34,17 +45,6 @@ def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: return device_url_from_service_info(service_info, device_name) -class FlowchemDeviceListener(FlowchemCommonDeviceListener): - def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: - if service_info := zc.get_service_info(type_, name): - device_name = zeroconf_name_to_device_name(name) - self.flowchem_devices[device_name] = device_url_from_service_info( - service_info, device_name - ) - else: - logger.warning(f"No info for service {name}!") - - def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) @@ -63,3 +63,9 @@ def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: dev_info = get_all_flowchem_devices() print(dev_info) + + from flowchem.client.common import FlowchemDeviceClient + + for name, url in get_all_flowchem_devices().items(): + dev = FlowchemDeviceClient(url) + print(dev) diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index eedc1c62..bd290644 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -1,8 +1,11 @@ import ipaddress +import requests from loguru import logger from zeroconf import ServiceListener, Zeroconf, ServiceInfo +from flowchem.components.device_info import DeviceInfo + FLOWCHEM_SUFFIX = "._labthing._tcp.local." FLOWCHEM_TYPE = FLOWCHEM_SUFFIX[1:] @@ -59,3 +62,37 @@ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: raise NotImplementedError() + + +class FlowchemDeviceClient: + def __init__(self, url: URL): + self.base_url = url + self._session = requests.Session() + # Log every request and always raise for status + self._session.hooks["response"] = [ + FlowchemDeviceClient.log_responses, + FlowchemDeviceClient.raise_for_status, + ] + + # Connect and get device info + self.info = DeviceInfo.model_validate_json(self.get(url).text) + + @staticmethod + def raise_for_status(resp, *args, **kwargs): + resp.raise_for_status() + + @staticmethod + def log_responses(resp, *args, **kwargs): + logger.debug(f"Reply: {resp.text} on {resp.url}") + + def get(self, url, **kwargs): + """Sends a GET request. Returns :class:`Response` object.""" + return self._session.get(url, **kwargs) + + def post(self, url, data=None, json=None, **kwargs): + """Sends a POST request. Returns :class:`Response` object.""" + return self._session.post(url, data=data, json=json, **kwargs) + + def put(self, url, data=None, **kwargs): + """Sends a PUT request. Returns :class:`Response` object.""" + return self._session.put(url, data=data, **kwargs) diff --git a/src/flowchem/components/component_info.py b/src/flowchem/components/component_info.py index 3a3a4963..b29ee83f 100644 --- a/src/flowchem/components/component_info.py +++ b/src/flowchem/components/component_info.py @@ -7,4 +7,4 @@ class ComponentInfo(BaseModel): """Metadata associated with flowchem components.""" name: str = "" - owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0000968" # 'device' + owl_subclass_of: str = "http://purl.obolibrary.org/obo/OBI_0000968" # 'device' diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 585a93ea..36874390 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -6,7 +6,9 @@ from typing import TypedDict, Iterable from fastapi import FastAPI, APIRouter -from fastapi_utils.tasks import repeat_every + +# from fastapi_utils.tasks import repeat_every +from flowchem.utils.repeat_every import repeat_every from loguru import logger from starlette.responses import RedirectResponse diff --git a/src/flowchem/utils/repeat_every.py b/src/flowchem/utils/repeat_every.py new file mode 100644 index 00000000..e2df57cf --- /dev/null +++ b/src/flowchem/utils/repeat_every.py @@ -0,0 +1,87 @@ +# Vendored from fastapi_utils due to pydantic v2 incompatibilities with the pacakge +import asyncio +import logging +from asyncio import ensure_future +from functools import wraps +from traceback import format_exception +from typing import Any, Callable, Coroutine, Optional, Union + +from starlette.concurrency import run_in_threadpool + +NoArgsNoReturnFuncT = Callable[[], None] +NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]] +NoArgsNoReturnDecorator = Callable[ + [Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]], NoArgsNoReturnAsyncFuncT +] + + +def repeat_every( + *, + seconds: float, + wait_first: bool = False, + logger: Optional[logging.Logger] = None, + raise_exceptions: bool = False, + max_repetitions: Optional[int] = None, +) -> NoArgsNoReturnDecorator: + """ + This function returns a decorator that modifies a function so it is periodically re-executed after its first call. + + The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished + by using `functools.partial` or otherwise wrapping the target function prior to decoration. + + Parameters + ---------- + seconds: float + The number of seconds to wait between repeated calls + wait_first: bool (default False) + If True, the function will wait for a single period before the first call + logger: Optional[logging.Logger] (default None) + The logger to use to log any exceptions raised by calls to the decorated function. + If not provided, exceptions will not be logged by this function (though they may be handled by the event loop). + raise_exceptions: bool (default False) + If True, errors raised by the decorated function will be raised to the event loop's exception handler. + Note that if an error is raised, the repeated execution will stop. + Otherwise, exceptions are just logged and the execution continues to repeat. + See https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.set_exception_handler for more info. + max_repetitions: Optional[int] (default None) + The maximum number of times to call the repeated function. If `None`, the function is repeated forever. + """ + + def decorator( + func: Union[NoArgsNoReturnAsyncFuncT, NoArgsNoReturnFuncT] + ) -> NoArgsNoReturnAsyncFuncT: + """ + Converts the decorated function into a repeated, periodically-called version of itself. + """ + is_coroutine = asyncio.iscoroutinefunction(func) + + @wraps(func) + async def wrapped() -> None: + repetitions = 0 + + async def loop() -> None: + nonlocal repetitions + if wait_first: + await asyncio.sleep(seconds) + while max_repetitions is None or repetitions < max_repetitions: + try: + if is_coroutine: + await func() # type: ignore + else: + await run_in_threadpool(func) + repetitions += 1 + except Exception as exc: + if logger is not None: + formatted_exception = "".join( + format_exception(type(exc), exc, exc.__traceback__) + ) + logger.error(formatted_exception) + if raise_exceptions: + raise exc + await asyncio.sleep(seconds) + + ensure_future(loop()) + + return wrapped + + return decorator From 8921e61e0aa5e42483c20dabdfe1af3da7c377b1 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 18:17:43 +0200 Subject: [PATCH 15/62] Fix mypy --- pyproject.toml | 1 + src/flowchem/client/async_client.py | 2 ++ src/flowchem/client/client.py | 2 ++ src/flowchem/components/base_component.py | 4 +--- src/flowchem/components/component_info.py | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 136c5476..a06b6beb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "anyio", "asyncua>=1.0.2", "bronkhorst-propar>=1.1.0", + "click<=8.1.3", # Temporary due to https://github.com/pallets/click/issues/2558 "fastapi>=0.100.0", "loguru>=0.7.0", "lxml>=4.9.2", diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 072db2db..33732563 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -50,6 +50,8 @@ async def async_get_flowchem_device_by_name(device_name, timeout: int = 3000) -> ) if service_info: return device_url_from_service_info(service_info, device_name) + else: + return URL() async def async_get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index 972e267e..b1195184 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -43,6 +43,8 @@ def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: ) if service_info: return device_url_from_service_info(service_info, device_name) + else: + return URL() def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index fc57e991..0f40819c 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -17,9 +17,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): """Initialize component.""" self.name = name self.hw_device = hw_device - self.metadata = ComponentInfo( - hw_device=self.hw_device.get_metadata(), name=name - ) + self.metadata = ComponentInfo(parent_device=self.hw_device.name, name=name) # Initialize router self._router = APIRouter( diff --git a/src/flowchem/components/component_info.py b/src/flowchem/components/component_info.py index b29ee83f..fd8a6959 100644 --- a/src/flowchem/components/component_info.py +++ b/src/flowchem/components/component_info.py @@ -7,4 +7,5 @@ class ComponentInfo(BaseModel): """Metadata associated with flowchem components.""" name: str = "" + parent_device: str = "" owl_subclass_of: str = "http://purl.obolibrary.org/obo/OBI_0000968" # 'device' From d0f9dd2c2b052185d32fbbe82348587f22925648 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 11 Jul 2023 18:28:06 +0200 Subject: [PATCH 16/62] Update GA depreacted options names --- .github/workflows/publish_pypi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index ab59cb58..55a841f6 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -28,8 +28,8 @@ jobs: - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - repository_url: https://test.pypi.org/legacy/ - skip_existing: true + repository-url: https://test.pypi.org/legacy/ + skip-existing: true - name: Publish distribution 📦 to PyPI if: startsWith(github.ref_name, 'v') From 86b6e34ea63136b68e0dbda53f58595bb2cde6ca Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 10:05:16 +0200 Subject: [PATCH 17/62] move vendored deps --- src/flowchem/devices/knauer/knauer_finder.py | 2 +- src/flowchem/server/api_server.py | 2 +- src/flowchem/vendor/__init__.py | 0 src/flowchem/{utils => vendor}/getmac.py | 0 src/flowchem/{utils => vendor}/repeat_every.py | 0 5 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 src/flowchem/vendor/__init__.py rename src/flowchem/{utils => vendor}/getmac.py (100%) rename src/flowchem/{utils => vendor}/repeat_every.py (100%) diff --git a/src/flowchem/devices/knauer/knauer_finder.py b/src/flowchem/devices/knauer/knauer_finder.py index 449179d7..adcd9e9b 100644 --- a/src/flowchem/devices/knauer/knauer_finder.py +++ b/src/flowchem/devices/knauer/knauer_finder.py @@ -8,7 +8,7 @@ from loguru import logger -from flowchem.utils.getmac import get_mac_address +from flowchem.vendor.getmac import get_mac_address __all__ = ["autodiscover_knauer", "knauer_finder"] diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 36874390..df1dc54c 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -8,7 +8,7 @@ from fastapi import FastAPI, APIRouter # from fastapi_utils.tasks import repeat_every -from flowchem.utils.repeat_every import repeat_every +from flowchem.vendor.repeat_every import repeat_every from loguru import logger from starlette.responses import RedirectResponse diff --git a/src/flowchem/vendor/__init__.py b/src/flowchem/vendor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/flowchem/utils/getmac.py b/src/flowchem/vendor/getmac.py similarity index 100% rename from src/flowchem/utils/getmac.py rename to src/flowchem/vendor/getmac.py diff --git a/src/flowchem/utils/repeat_every.py b/src/flowchem/vendor/repeat_every.py similarity index 100% rename from src/flowchem/utils/repeat_every.py rename to src/flowchem/vendor/repeat_every.py From 635a96d4dc763425a15dd1c30000e81b02b27fc0 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 10:06:00 +0200 Subject: [PATCH 18/62] ruff add src location only relevant for import sorting --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a06b6beb..e6e6aef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,5 +115,7 @@ markers = [ [tool.ruff] line-length = 120 +# Allow imports relative to the "src" and "test" directories. +src = ["src", "test"] [tool.ruff.per-file-ignores] "__init__.py" = ["F403"] From 1d27b80c579b202839e841e4920868e179a44a81 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 12:48:15 +0200 Subject: [PATCH 19/62] Remove maintainers from DeviceInfo Also use AnyHttpUrl instead of custom URL --- docs/add_device/add_to_flowchem.md | 1 - src/flowchem/client/async_client.py | 12 ++++++--- src/flowchem/client/client.py | 8 +++--- src/flowchem/client/common.py | 27 ++++++------------- src/flowchem/components/device_info.py | 10 +++---- src/flowchem/devices/bronkhorst/el_flow.py | 2 -- src/flowchem/devices/dataapex/clarity.py | 1 - src/flowchem/devices/flowchem_device.py | 15 +++-------- src/flowchem/devices/hamilton/ml600.py | 1 - .../devices/harvardapparatus/elite11.py | 1 - src/flowchem/devices/huber/chiller.py | 3 +-- src/flowchem/devices/knauer/azura_compact.py | 12 ++++----- src/flowchem/devices/knauer/dad.py | 1 - src/flowchem/devices/knauer/knauer_valve.py | 1 - src/flowchem/devices/magritek/spinsolve.py | 1 - .../devices/manson/manson_power_supply.py | 1 - src/flowchem/devices/mettlertoledo/icir.py | 1 - .../devices/phidgets/bubble_sensor.py | 2 -- .../devices/phidgets/pressure_sensor.py | 1 - src/flowchem/devices/vacuubrand/cvc3000.py | 1 - src/flowchem/devices/vapourtec/r2.py | 1 - src/flowchem/devices/vapourtec/r4_heater.py | 1 - src/flowchem/devices/vicivalco/vici_valve.py | 1 - src/flowchem/server/README.md | 2 +- src/flowchem/server/api_server.py | 6 ++--- 25 files changed, 38 insertions(+), 75 deletions(-) diff --git a/docs/add_device/add_to_flowchem.md b/docs/add_device/add_to_flowchem.md index 9ec2d475..afd5386b 100644 --- a/docs/add_device/add_to_flowchem.md +++ b/docs/add_device/add_to_flowchem.md @@ -163,7 +163,6 @@ class ExtendableEar(FlowchemDevice): metadata = DeviceInfo( authors=[Person(name="George Weasley", email="george.weasley@gmail.com"), Person(name="Fred Weasley", email="fred.weasley@gmail.com")], - maintainers=[Person(name="George Weasley", email="george.weasley@gmail.com")], manufacturer="Weasley & Weasley", model="Extendable Ear", ) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 33732563..3c460332 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -3,11 +3,11 @@ from loguru import logger from zeroconf import Zeroconf from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo +from pydantic import AnyHttpUrl from flowchem.client.client import FlowchemCommonDeviceListener from flowchem.client.common import ( FLOWCHEM_TYPE, - URL, device_name_to_zeroconf_name, device_url_from_service_info, zeroconf_name_to_device_name, @@ -31,7 +31,9 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: asyncio.ensure_future(self._resolve_service(zc, type_, name)) -async def async_get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: +async def async_get_flowchem_device_by_name( + device_name, timeout: int = 3000 +) -> AnyHttpUrl: """ Given a flowchem device name, search for it via mDNS and return its URL if found. @@ -51,10 +53,12 @@ async def async_get_flowchem_device_by_name(device_name, timeout: int = 3000) -> if service_info: return device_url_from_service_info(service_info, device_name) else: - return URL() + return AnyHttpUrl() -async def async_get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: +async def async_get_all_flowchem_devices( + timeout: float = 3000, +) -> dict[str, AnyHttpUrl]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) """ diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index b1195184..6d2c01ff 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -1,13 +1,13 @@ import time from loguru import logger +from pydantic import AnyHttpUrl from zeroconf import ServiceBrowser, Zeroconf from flowchem.client.common import ( FLOWCHEM_TYPE, zeroconf_name_to_device_name, device_name_to_zeroconf_name, - URL, device_url_from_service_info, FlowchemCommonDeviceListener, ) @@ -24,7 +24,7 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: logger.warning(f"No info for service {name}!") -def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: +def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> AnyHttpUrl: """ Given a flowchem device name, search for it via mDNS and return its URL if found. @@ -44,10 +44,10 @@ def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> URL: if service_info: return device_url_from_service_info(service_info, device_name) else: - return URL() + return AnyHttpUrl() -def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, URL]: +def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, AnyHttpUrl]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) """ diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index bd290644..3ed07324 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -2,6 +2,7 @@ import requests from loguru import logger +from pydantic import AnyHttpUrl from zeroconf import ServiceListener, Zeroconf, ServiceInfo from flowchem.components.device_info import DeviceInfo @@ -10,20 +11,6 @@ FLOWCHEM_TYPE = FLOWCHEM_SUFFIX[1:] -class URL(str): - def __new__(cls, *value): - if value: - v0 = value[0] - if type(v0) is not str: - raise TypeError('Unexpected type for URL: "{type(v0)}"') - if not (v0.startswith("http://") or v0.startswith("https://")): - raise ValueError('Passed string value "{v0}" is not an "http*://" URL') - # else allow None to be passed. This allows an "empty" URL instance, e.g. `URL()` - # `URL()` evaluates False - - return str.__new__(cls, *value) - - def zeroconf_name_to_device_name(zeroconf_name: str) -> str: assert zeroconf_name.endswith(FLOWCHEM_SUFFIX) return zeroconf_name[: -len(FLOWCHEM_SUFFIX)] @@ -33,19 +20,21 @@ def device_name_to_zeroconf_name(device_name: str) -> str: return f"{device_name}{FLOWCHEM_SUFFIX}" -def device_url_from_service_info(service_info: ServiceInfo, device_name: str) -> URL: +def device_url_from_service_info( + service_info: ServiceInfo, device_name: str +) -> AnyHttpUrl: if service_info.addresses: # Needed to convert IP from bytes to str device_ip = ipaddress.ip_address(service_info.addresses[0]) - return URL(f"http://{device_ip}:{service_info.port}/{device_name}") + return AnyHttpUrl(f"http://{device_ip}:{service_info.port}/{device_name}") else: logger.warning(f"No address found for {device_name}!") - return URL() + return AnyHttpUrl() class FlowchemCommonDeviceListener(ServiceListener): def __init__(self): - self.flowchem_devices: dict[str, URL] = {} + self.flowchem_devices: dict[str, AnyHttpUrl] = {} def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: logger.debug(f"Service {name} removed") @@ -65,7 +54,7 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: class FlowchemDeviceClient: - def __init__(self, url: URL): + def __init__(self, url: AnyHttpUrl): self.base_url = url self._session = requests.Session() # Log every request and always raise for status diff --git a/src/flowchem/components/device_info.py b/src/flowchem/components/device_info.py index 46ebbaf2..080df2aa 100644 --- a/src/flowchem/components/device_info.py +++ b/src/flowchem/components/device_info.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, AnyHttpUrl from flowchem import __version__ @@ -11,11 +11,11 @@ class Person(BaseModel): class DeviceInfo(BaseModel): """Metadata associated with hardware devices.""" - manufacturer: str - model: str + manufacturer: str = "" + model: str = "" version: str = "" serial_number: str | int = "unknown" + components: list[AnyHttpUrl] = [] backend: str = f"flowchem v. {__version__}" - authors: "list[Person]" - maintainers: "list[Person]" + authors: list[Person] = [] additional_info: dict = {} diff --git a/src/flowchem/devices/bronkhorst/el_flow.py b/src/flowchem/devices/bronkhorst/el_flow.py index 8dfb40b7..77582f50 100644 --- a/src/flowchem/devices/bronkhorst/el_flow.py +++ b/src/flowchem/devices/bronkhorst/el_flow.py @@ -32,7 +32,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="bronkhorst", model="EPC", ) @@ -110,7 +109,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="bronkhorst", model="MFC", ) diff --git a/src/flowchem/devices/dataapex/clarity.py b/src/flowchem/devices/dataapex/clarity.py index 35a21959..f936f1fc 100644 --- a/src/flowchem/devices/dataapex/clarity.py +++ b/src/flowchem/devices/dataapex/clarity.py @@ -15,7 +15,6 @@ class Clarity(FlowchemDevice): metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="DataApex", model="Clarity Chromatography", ) diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index c89775db..efc918ff 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -4,10 +4,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING -from loguru import logger - from flowchem.components.device_info import DeviceInfo -from flowchem.utils.exceptions import DeviceError if TYPE_CHECKING: from flowchem.components.base_component import FlowchemComponent @@ -26,6 +23,7 @@ class FlowchemDevice(ABC): def __init__(self, name): """All device have a name, which is the key in the config dict thus unique.""" self.name = name + self.device_info = DeviceInfo() async def initialize(self): """Use for setting up async connection to the device.""" @@ -35,15 +33,8 @@ def repeated_task(self) -> RepeatedTaskInfo | None: """Use for repeated background task, e.g. session keepalive.""" return None - def get_metadata(self) -> DeviceInfo: - try: - return self.metadata # type: ignore - except AttributeError as ae: - logger.error(f"Invalid device type for {self.name}!") - raise DeviceError( - f"Invalid device {self.name}!" - f"The attribute `metadata` is missing, should be a DeviceInfo variable!" - ) from ae + def get_device_info(self) -> DeviceInfo: + return self.device_info def components(self) -> Iterable["FlowchemComponent"]: return () diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index daec94d1..346148c1 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -179,7 +179,6 @@ class ML600(FlowchemDevice): metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Hamilton", model="ML600", ) diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index 35baa339..cacb47b6 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -112,7 +112,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="HarvardApparatus", model="Elite11", version="", diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index e5345e21..8101a837 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -38,9 +38,8 @@ def __init__( self._min_t: float = min_temp self._max_t: float = max_temp - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Huber", model="generic chiller", ) diff --git a/src/flowchem/devices/knauer/azura_compact.py b/src/flowchem/devices/knauer/azura_compact.py index 9e932527..8fd2b80d 100644 --- a/src/flowchem/devices/knauer/azura_compact.py +++ b/src/flowchem/devices/knauer/azura_compact.py @@ -52,13 +52,6 @@ class AzuraPumpHeads(Enum): class AzuraCompact(KnauerEthernetDevice, FlowchemDevice): """Control module for Knauer Azura Compact pumps.""" - metadata = DeviceInfo( - authors=[dario, jakob, wei_hsin], - maintainers=[dario], - manufacturer="knauer", - model="Azura Compact", - ) - def __init__( self, ip_address=None, @@ -68,6 +61,11 @@ def __init__( name="", ): super().__init__(ip_address, mac_address, name=name) + self.device_info = DeviceInfo( + authors=[dario, jakob, wei_hsin], + manufacturer="knauer", + model="Azura Compact", + ) self.eol = b"\n\r" # All the following are set upon initialize() diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index 0058e778..b6f199f6 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -57,7 +57,6 @@ def __init__( self.cmd = KnauerDADCommands() self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Knauer", model="DAD", ) diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index 126e2b88..7a8289b6 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -41,7 +41,6 @@ def __init__(self, ip_address=None, mac_address=None, **kwargs): self.eol = b"\r\n" self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Knauer", model="Valve", ) diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 82f7742e..6e5638ef 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -45,7 +45,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Magritek", model="Spinsolve", ) diff --git a/src/flowchem/devices/manson/manson_power_supply.py b/src/flowchem/devices/manson/manson_power_supply.py index ac69ea5a..5e3d149f 100644 --- a/src/flowchem/devices/manson/manson_power_supply.py +++ b/src/flowchem/devices/manson/manson_power_supply.py @@ -27,7 +27,6 @@ def __init__(self, aio: aioserial.AioSerial, name=""): self._serial = aio self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Manson", model="HCS-3***", ) diff --git a/src/flowchem/devices/mettlertoledo/icir.py b/src/flowchem/devices/mettlertoledo/icir.py index 90bf2d6b..57b1b4f2 100644 --- a/src/flowchem/devices/mettlertoledo/icir.py +++ b/src/flowchem/devices/mettlertoledo/icir.py @@ -37,7 +37,6 @@ class IcIR(FlowchemDevice): metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Mettler-Toledo", model="iCIR", version="", diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index 017941a7..ae4a1e0b 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -81,7 +81,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Phidget", model="VINT", serial_number=vint_serial_number, @@ -173,7 +172,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Phidget", model="VINT", serial_number=vint_serial_number, diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index adac3e86..4a65fda6 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -77,7 +77,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Phidget", model="VINT", serial_number=vint_serial_number, diff --git a/src/flowchem/devices/vacuubrand/cvc3000.py b/src/flowchem/devices/vacuubrand/cvc3000.py index be9f78f1..2375ae3a 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000.py +++ b/src/flowchem/devices/vacuubrand/cvc3000.py @@ -35,7 +35,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Vacuubrand", model="CVC3000", ) diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 1d69c506..3ed4a8af 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -111,7 +111,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Vapourtec", model="R2 reactor module", ) diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index fa1a1003..62888356 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -74,7 +74,6 @@ def __init__( self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Vapourtec", model="R4 reactor module", ) diff --git a/src/flowchem/devices/vicivalco/vici_valve.py b/src/flowchem/devices/vicivalco/vici_valve.py index c7d504b7..67deff10 100644 --- a/src/flowchem/devices/vicivalco/vici_valve.py +++ b/src/flowchem/devices/vicivalco/vici_valve.py @@ -142,7 +142,6 @@ def __init__( self.address = address self.metadata = DeviceInfo( authors=[dario, jakob, wei_hsin], - maintainers=[dario], manufacturer="Vici-Valco", model="Universal Valve Actuator", ) diff --git a/src/flowchem/server/README.md b/src/flowchem/server/README.md index f42433b7..e958764d 100644 --- a/src/flowchem/server/README.md +++ b/src/flowchem/server/README.md @@ -1,3 +1,3 @@ # flowchem/server -This folder contains the modules releted to the API Server and the Zeroconfig (mDNS) server for flowchem devices. +This folder contains the modules related to the API Server and the Zeroconfig (mDNS) server for flowchem devices. diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index df1dc54c..42482cb4 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -88,7 +88,7 @@ def home_redirect_to_docs(root_path): for device in dev_list: # Get components (some compounded devices can return multiple components) components = device.components() - device.get_metadata() + device.get_device_info() logger.debug(f"Got {len(components)} components from {device.name}") # Advertise devices (not components!) @@ -97,8 +97,8 @@ def home_redirect_to_docs(root_path): device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) device_root.add_api_route( "/", - device.get_metadata, # TODO: add components in the device info response! - # TODO: also, fix confusion between device get_metadata and + device.get_device_info, # TODO: add components in the device info response! + # TODO: also, fix confusion between device get_device_info and # components. Device get_metadata equivalent could be implemented here to # add the components info but it would miss the owl class that is coming methods=["GET"], From 79a74c21b79dac2ababc1ce01e00b8f8ea12dd7b Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 13:35:39 +0200 Subject: [PATCH 20/62] rename metadata to more descriptive names device info and component info respectively --- docs/add_device/add_to_flowchem.md | 12 ++--- src/flowchem/client/common.py | 50 +++++++++++++------ src/flowchem/components/analytics/hplc.py | 4 +- src/flowchem/components/analytics/ir.py | 4 +- src/flowchem/components/analytics/nmr.py | 4 +- src/flowchem/components/base_component.py | 10 ++-- src/flowchem/components/pumps/hplc.py | 4 +- src/flowchem/components/pumps/syringe.py | 8 +-- .../components/sensors/base_sensor.py | 4 +- src/flowchem/components/sensors/pressure.py | 4 +- src/flowchem/devices/bronkhorst/el_flow.py | 4 +- src/flowchem/devices/dataapex/clarity.py | 11 ++-- src/flowchem/devices/hamilton/ml600.py | 15 +++--- .../devices/harvardapparatus/elite11.py | 7 ++- src/flowchem/devices/huber/chiller.py | 8 +-- src/flowchem/devices/knauer/dad.py | 2 +- src/flowchem/devices/knauer/dad_component.py | 4 +- src/flowchem/devices/knauer/knauer_valve.py | 6 +-- src/flowchem/devices/magritek/spinsolve.py | 14 +++--- .../devices/manson/manson_power_supply.py | 8 +-- src/flowchem/devices/mettlertoledo/icir.py | 19 ++++--- .../devices/phidgets/bubble_sensor.py | 4 +- .../devices/phidgets/pressure_sensor.py | 2 +- src/flowchem/devices/vacuubrand/cvc3000.py | 8 +-- .../devices/vacuubrand/cvc3000_finder.py | 2 +- src/flowchem/devices/vapourtec/r2.py | 6 +-- src/flowchem/devices/vapourtec/r4_heater.py | 6 +-- .../devices/vapourtec/vapourtec_finder.py | 4 +- src/flowchem/devices/vicivalco/vici_valve.py | 6 +-- src/flowchem/server/api_server.py | 3 -- 30 files changed, 138 insertions(+), 105 deletions(-) diff --git a/docs/add_device/add_to_flowchem.md b/docs/add_device/add_to_flowchem.md index afd5386b..3ca5fbb1 100644 --- a/docs/add_device/add_to_flowchem.md +++ b/docs/add_device/add_to_flowchem.md @@ -160,12 +160,12 @@ from flowchem.components.device_info import DeviceInfo, Person class ExtendableEar(FlowchemDevice): - metadata = DeviceInfo( - authors=[Person(name="George Weasley", email="george.weasley@gmail.com"), - Person(name="Fred Weasley", email="fred.weasley@gmail.com")], - manufacturer="Weasley & Weasley", - model="Extendable Ear", - ) + def __init__(self, name): + super().__init__(name) + self.device_info.manufacturer = "Weasley & Weasley", + self.device_info.authors = [Person(name="George Weasley", email="george.weasley@gmail.com"), + Person(name="Fred Weasley", email="fred.weasley@gmail.com")] + self.device_info.model = "Extendable Ear" ... diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index 3ed07324..8bfa19a3 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -5,6 +5,7 @@ from pydantic import AnyHttpUrl from zeroconf import ServiceListener, Zeroconf, ServiceInfo +from flowchem.components.component_info import ComponentInfo from flowchem.components.device_info import DeviceInfo FLOWCHEM_SUFFIX = "._labthing._tcp.local." @@ -53,18 +54,47 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: raise NotImplementedError() +class FlowchemComponentClient: + def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient"): + self.url = url + # Get ComponentInfo from + logger.warning(f"CREATE COMPONENT FOR URL {url}") + self._parent = parent + self._session = self._parent._session + self.component_info = ComponentInfo.model_validate_json(self.get(url).text) + + def get(self, url, **kwargs): + """Sends a GET request. Returns :class:`Response` object.""" + return self._session.get(url, **kwargs) + + def post(self, url, data=None, json=None, **kwargs): + """Sends a POST request. Returns :class:`Response` object.""" + return self._session.post(url, data=data, json=json, **kwargs) + + def put(self, url, data=None, **kwargs): + """Sends a PUT request. Returns :class:`Response` object.""" + return self._session.put(url, data=data, **kwargs) + + class FlowchemDeviceClient: def __init__(self, url: AnyHttpUrl): - self.base_url = url - self._session = requests.Session() + self.url = url + # Log every request and always raise for status + self._session = requests.Session() self._session.hooks["response"] = [ FlowchemDeviceClient.log_responses, FlowchemDeviceClient.raise_for_status, ] - # Connect and get device info - self.info = DeviceInfo.model_validate_json(self.get(url).text) + # Connect, get device info and populate components + self.device_info = DeviceInfo.model_validate_json( + self._session.get(self.url).text + ) + self.components = [ + FlowchemComponentClient(cmp_url, parent=self) + for cmp_url in self.device_info.components + ] @staticmethod def raise_for_status(resp, *args, **kwargs): @@ -73,15 +103,3 @@ def raise_for_status(resp, *args, **kwargs): @staticmethod def log_responses(resp, *args, **kwargs): logger.debug(f"Reply: {resp.text} on {resp.url}") - - def get(self, url, **kwargs): - """Sends a GET request. Returns :class:`Response` object.""" - return self._session.get(url, **kwargs) - - def post(self, url, data=None, json=None, **kwargs): - """Sends a POST request. Returns :class:`Response` object.""" - return self._session.post(url, data=data, json=json, **kwargs) - - def put(self, url, data=None, **kwargs): - """Sends a PUT request. Returns :class:`Response` object.""" - return self._session.put(url, data=data, **kwargs) diff --git a/src/flowchem/components/analytics/hplc.py b/src/flowchem/components/analytics/hplc.py index f149ee8d..2968e7b4 100644 --- a/src/flowchem/components/analytics/hplc.py +++ b/src/flowchem/components/analytics/hplc.py @@ -17,7 +17,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/send-method", self.send_method, methods=["PUT"]) # Ontology: high performance liquid chromatography instrument - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0001057" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/OBI_0001057" + ) async def send_method(self, method_name): """Submits a method to the HPLC. diff --git a/src/flowchem/components/analytics/ir.py b/src/flowchem/components/analytics/ir.py index a187a3e4..2195f4af 100644 --- a/src/flowchem/components/analytics/ir.py +++ b/src/flowchem/components/analytics/ir.py @@ -25,7 +25,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/stop", self.stop, methods=["PUT"]) # Ontology: high performance liquid chromatography instrument - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0001057" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/OBI_0001057" + ) async def acquire_spectrum(self) -> IRSpectrum: # type: ignore """Acquire an IR spectrum.""" diff --git a/src/flowchem/components/analytics/nmr.py b/src/flowchem/components/analytics/nmr.py index c7207425..c1e1c57c 100644 --- a/src/flowchem/components/analytics/nmr.py +++ b/src/flowchem/components/analytics/nmr.py @@ -12,7 +12,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/stop", self.stop, methods=["PUT"]) # Ontology: fourier transformation NMR instrument - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0000487" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/OBI_0000487" + ) async def acquire_spectrum(self, background_tasks: BackgroundTasks): """Acquire an NMR spectrum.""" diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index 0f40819c..14d64744 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -17,7 +17,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): """Initialize component.""" self.name = name self.hw_device = hw_device - self.metadata = ComponentInfo(parent_device=self.hw_device.name, name=name) + self.component_info = ComponentInfo( + parent_device=self.hw_device.name, name=name + ) # Initialize router self._router = APIRouter( @@ -25,7 +27,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): ) self.add_api_route( "/", - self.get_metadata, + self.get_component_info, methods=["GET"], response_model=ComponentInfo, ) @@ -40,6 +42,6 @@ def add_api_route(self, path: str, endpoint: Callable, **kwargs): logger.debug(f"Adding route {path} for router of {self.name}") self._router.add_api_route(path, endpoint, **kwargs) - def get_metadata(self) -> ComponentInfo: + def get_component_info(self) -> ComponentInfo: """Return metadata.""" - return self.metadata + return self.component_info diff --git a/src/flowchem/components/pumps/hplc.py b/src/flowchem/components/pumps/hplc.py index 2e35a2ef..944ebf1e 100644 --- a/src/flowchem/components/pumps/hplc.py +++ b/src/flowchem/components/pumps/hplc.py @@ -10,7 +10,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): super().__init__(name, hw_device) # Ontology: HPLC isocratic pump - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0000556" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/OBI_0000556" + ) @staticmethod def is_withdrawing_capable() -> bool: diff --git a/src/flowchem/components/pumps/syringe.py b/src/flowchem/components/pumps/syringe.py index 8fb8ba85..2ecdb67d 100644 --- a/src/flowchem/components/pumps/syringe.py +++ b/src/flowchem/components/pumps/syringe.py @@ -4,7 +4,9 @@ class SyringePump(BasePump): - def get_metadata(self) -> ComponentInfo: + def get_component_info(self) -> ComponentInfo: # Ontology: syringe pump - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/OBI_0400100" - return super().get_metadata() + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/OBI_0400100" + ) + return super().get_component_info() diff --git a/src/flowchem/components/sensors/base_sensor.py b/src/flowchem/components/sensors/base_sensor.py index 68d14b2b..2101d8f1 100644 --- a/src/flowchem/components/sensors/base_sensor.py +++ b/src/flowchem/components/sensors/base_sensor.py @@ -13,7 +13,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/power-on", self.power_on, methods=["PUT"]) self.add_api_route("/power-off", self.power_off, methods=["PUT"]) # Ontology: HPLC isocratic pump - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/NCIT_C50166" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/NCIT_C50166" + ) async def power_on(self): """""" diff --git a/src/flowchem/components/sensors/pressure.py b/src/flowchem/components/sensors/pressure.py index 1e5c207b..bce0f681 100644 --- a/src/flowchem/components/sensors/pressure.py +++ b/src/flowchem/components/sensors/pressure.py @@ -12,7 +12,9 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/read-pressure", self.read_pressure, methods=["GET"]) # Ontology: Pressure Sensor Device - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/NCIT_C50167" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/NCIT_C50167" + ) async def read_pressure(self, units: str = "bar"): """Read from sensor, result to be expressed in units (optional).""" diff --git a/src/flowchem/devices/bronkhorst/el_flow.py b/src/flowchem/devices/bronkhorst/el_flow.py index 77582f50..4f34b76d 100644 --- a/src/flowchem/devices/bronkhorst/el_flow.py +++ b/src/flowchem/devices/bronkhorst/el_flow.py @@ -30,7 +30,7 @@ def __init__( self.max_pressure = max_pressure super().__init__(name) - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="bronkhorst", model="EPC", @@ -107,7 +107,7 @@ def __init__( self.max_flow = max_flow super().__init__(name) - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="bronkhorst", model="MFC", diff --git a/src/flowchem/devices/dataapex/clarity.py b/src/flowchem/devices/dataapex/clarity.py index f936f1fc..2cc4c867 100644 --- a/src/flowchem/devices/dataapex/clarity.py +++ b/src/flowchem/devices/dataapex/clarity.py @@ -13,12 +13,6 @@ class Clarity(FlowchemDevice): - metadata = DeviceInfo( - authors=[dario, jakob, wei_hsin], - manufacturer="DataApex", - model="Clarity Chromatography", - ) - def __init__( self, name, @@ -32,6 +26,11 @@ def __init__( cfg_file: str = "", ): super().__init__(name=name) + self.device_info = DeviceInfo( + authors=[dario, jakob, wei_hsin], + manufacturer="DataApex", + model="Clarity Chromatography", + ) # Validate executable if which(executable): diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index 346148c1..703b33a2 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -177,12 +177,6 @@ class ML600(FlowchemDevice): "default_withdraw_rate": "1 ml/min", } - metadata = DeviceInfo( - authors=[dario, jakob, wei_hsin], - manufacturer="Hamilton", - model="ML600", - ) - # This class variable is used for daisy chains (i.e. multiple pumps on the same serial connection). Details below. _io_instances: set[HamiltonPumpIO] = set() # The mutable object (a set) as class variable creates a shared state across all the instances. @@ -223,6 +217,11 @@ def __init__( name: 'cause naming stuff is important. """ super().__init__(name) + self.device_info = DeviceInfo( + authors=[dario, jakob, wei_hsin], + manufacturer="Hamilton", + model="ML600", + ) # HamiltonPumpIO self.pump_io = pump_io ML600._io_instances.add(self.pump_io) # See above for details. @@ -289,9 +288,9 @@ async def initialize(self, hw_init=False, init_speed: str = "200 sec / stroke"): await self.pump_io.initialize() # Test connectivity by querying the pump's firmware version fw_cmd = Protocol1Command(command="U", target_pump_num=self.address) - self.metadata.version = await self.pump_io.write_and_read_reply_async(fw_cmd) + self.device_info.version = await self.pump_io.write_and_read_reply_async(fw_cmd) logger.info( - f"Connected to Hamilton ML600 {self.name} - FW version: {self.metadata.version}!" + f"Connected to Hamilton ML600 {self.name} - FW version: {self.device_info.version}!" ) if hw_init: diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index cacb47b6..9ab3daf0 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -110,11 +110,10 @@ def __init__( else: raise InvalidConfiguration("Please provide the syringe volume!") - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="HarvardApparatus", model="Elite11", - version="", ) @classmethod @@ -188,8 +187,8 @@ async def initialize(self): f"Connected to '{self.name}'! [{self.pump_io._serial.name}:{self.address}]" ) version = await self.version() - self.metadata.version = version.split(" ")[-1] - self._infuse_only = "I/W" not in self.metadata.version + self.device_info.version = version.split(" ")[-1] + self._infuse_only = "I/W" not in self.device_info.version # Clear target volume eventually set to prevent pump from stopping prematurely await self.set_target_volume("0 ml") diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index 8101a837..696810d9 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -65,10 +65,12 @@ def from_config(cls, port, name=None, **serial_kwargs): async def initialize(self): """Ensure the connection w/ device is working.""" - self.metadata.serial_number = str(await self.serial_number()) - if self.metadata.serial_number == "0": + self.device_info.serial_number = str(await self.serial_number()) + if self.device_info.serial_number == "0": raise InvalidConfiguration("No reply received from Huber Chiller!") - logger.debug(f"Connected with Huber Chiller S/N {self.metadata.serial_number}") + logger.debug( + f"Connected with Huber Chiller S/N {self.device_info.serial_number}" + ) # Validate temperature limits device_limits = await self.temperature_limits() diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index b6f199f6..ad243451 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -55,7 +55,7 @@ def __init__( ) self.cmd = KnauerDADCommands() - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Knauer", model="DAD", diff --git a/src/flowchem/devices/knauer/dad_component.py b/src/flowchem/devices/knauer/dad_component.py index 01a22a29..8e0a08de 100644 --- a/src/flowchem/devices/knauer/dad_component.py +++ b/src/flowchem/devices/knauer/dad_component.py @@ -67,7 +67,9 @@ def __init__(self, name: str, hw_device: KnauerDAD, channel: int): self.add_api_route("/set-bandwidth", self.set_bandwidth, methods=["PUT"]) # Ontology: diode array detector - self.metadata.owl_subclass_of = "http://purl.obolibrary.org/obo/CHMO_0002503" + self.component_info.owl_subclass_of = ( + "http://purl.obolibrary.org/obo/CHMO_0002503" + ) async def calibrate_zero(self): """re-calibrate the sensors to their factory zero points""" diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index 7a8289b6..5f85ed19 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -39,7 +39,7 @@ class KnauerValve(KnauerEthernetDevice, FlowchemDevice): def __init__(self, ip_address=None, mac_address=None, **kwargs): super().__init__(ip_address, mac_address, **kwargs) self.eol = b"\r\n" - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Knauer", model="Valve", @@ -51,7 +51,7 @@ async def initialize(self): await super().initialize() # Detect valve type - self.metadata.additional_info["valve-type"] = await self.get_valve_type() + self.device_info.additional_info["valve-type"] = await self.get_valve_type() @staticmethod def handle_errors(reply: str): @@ -150,7 +150,7 @@ async def set_raw_position(self, position: str) -> bool: def components(self): """Create the right type of Valve components based on head type.""" - match self.metadata.additional_info["valve-type"]: # noqa + match self.device_info.additional_info["valve-type"]: # noqa case KnauerValveHeads.SIX_PORT_TWO_POSITION: return (KnauerInjectionValve("injection-valve", self),) case KnauerValveHeads.SIX_PORT_SIX_POSITION: diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 6e5638ef..820a4aa8 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -43,7 +43,7 @@ def __init__( """Control a Spinsolve instance via HTTP XML API.""" super().__init__(name) - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Magritek", model="Spinsolve", @@ -123,18 +123,20 @@ async def initialize(self): raise ConnectionError("Spectrometer not connected to Spinsolve's PC!") # If connected parse and log instrument info - self.metadata.version = hw_info.find(".//SpinsolveSoftware").text + self.device_info.version = hw_info.find(".//SpinsolveSoftware").text hardware_type = hw_info.find(".//SpinsolveType").text - self.metadata.additional_info["hardware_type"] = hardware_type - logger.debug(f"Connected to model {hardware_type}, SW: {self.metadata.version}") + self.device_info.additional_info["hardware_type"] = hardware_type + logger.debug( + f"Connected to model {hardware_type}, SW: {self.device_info.version}" + ) # Load available protocols await self.load_protocols() # Finally, check version - if version.parse(self.metadata.version) < version.parse("1.18.1.3062"): + if version.parse(self.device_info.version) < version.parse("1.18.1.3062"): warnings.warn( - f"Spinsolve v. {self.metadata.version} is not supported!" + f"Spinsolve v. {self.device_info.version} is not supported!" f"Upgrade to a more recent version! (at least 1.18.1.3062)" ) diff --git a/src/flowchem/devices/manson/manson_power_supply.py b/src/flowchem/devices/manson/manson_power_supply.py index 5e3d149f..bb6adfe9 100644 --- a/src/flowchem/devices/manson/manson_power_supply.py +++ b/src/flowchem/devices/manson/manson_power_supply.py @@ -25,7 +25,7 @@ def __init__(self, aio: aioserial.AioSerial, name=""): """Control class for Manson Power Supply.""" super().__init__(name) self._serial = aio - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Manson", model="HCS-3***", @@ -49,10 +49,10 @@ def from_config(cls, port, name="", **serial_kwargs): async def initialize(self): """Ensure the connection w/ device is working.""" - self.metadata.model = await self.get_info() - if not self.metadata.model: + self.device_info.model = await self.get_info() + if not self.device_info.model: raise DeviceError("Communication with device failed!") - if self.metadata.model not in self.MODEL_ALT_RANGE: + if self.device_info.model not in self.MODEL_ALT_RANGE: raise InvalidConfiguration( f"Device is not supported! [Supported models: {self.MODEL_ALT_RANGE}]" ) diff --git a/src/flowchem/devices/mettlertoledo/icir.py b/src/flowchem/devices/mettlertoledo/icir.py index 57b1b4f2..ca7cbebb 100644 --- a/src/flowchem/devices/mettlertoledo/icir.py +++ b/src/flowchem/devices/mettlertoledo/icir.py @@ -35,13 +35,6 @@ class ProbeInfo(BaseModel): class IcIR(FlowchemDevice): """Object to interact with the iCIR software controlling the FlowIR and ReactIR.""" - metadata = DeviceInfo( - authors=[dario, jakob, wei_hsin], - manufacturer="Mettler-Toledo", - model="iCIR", - version="", - ) - iC_OPCUA_DEFAULT_SERVER_ADDRESS = "opc.tcp://localhost:62552/iCOpcUaServer" _supported_versions = {"7.1.91.0"} SOFTWARE_VERSION = "ns=2;s=Local.iCIR.SoftwareVersion" @@ -62,6 +55,12 @@ class IcIR(FlowchemDevice): def __init__(self, template="", url="", name=""): """Initiate connection with OPC UA server.""" super().__init__(name) + self.device_info = DeviceInfo( + authors=[dario, jakob, wei_hsin], + manufacturer="Mettler-Toledo", + model="iCIR", + version="", + ) # Default (local) url if none provided if not url: @@ -82,7 +81,7 @@ async def initialize(self): ) from timeout_error # Ensure iCIR version is supported - self.metadata.version = await self.opcua.get_node( + self.device_info.version = await self.opcua.get_node( self.SOFTWARE_VERSION ).get_value() # e.g. "7.1.91.0" @@ -95,7 +94,7 @@ async def initialize(self): # Start acquisition! Ensures the device is ready when a spectrum is needed await self.start_experiment(name="Flowchem", template=self._template) probe = await self.probe_info() - self.metadata.additional_info = probe.dict() + self.device_info.additional_info = probe.dict() def is_local(self): """Return true if the server is on the same machine running the python code.""" @@ -106,7 +105,7 @@ def is_local(self): def ensure_version_is_supported(self): """Check if iCIR is installed and open and if the version is supported.""" try: - if self.metadata.version not in self._supported_versions: + if self.device_info.version not in self._supported_versions: logger.warning( f"The current version of iCIR [self.version] has not been tested!" f"Pleas use one of the supported versions: {self._supported_versions}" diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index ae4a1e0b..578d43ed 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -79,7 +79,7 @@ def __init__( logger.debug("power of tube sensor is turn on!") # self.phidget.setState(True) #setting DutyCycle to 1.0 - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Phidget", model="VINT", @@ -170,7 +170,7 @@ def __init__( logger.debug("tube sensor is turn on, default data interval is 200 ms!") self.phidget.setDataInterval(data_interval) - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Phidget", model="VINT", diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index 4a65fda6..7ee8ce5a 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -75,7 +75,7 @@ def __init__( self.phidget.setPowerSupply(PowerSupply.POWER_SUPPLY_24V) self.phidget.setDataInterval(200) # 200ms - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Phidget", model="VINT", diff --git a/src/flowchem/devices/vacuubrand/cvc3000.py b/src/flowchem/devices/vacuubrand/cvc3000.py index 2375ae3a..b3cf118a 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000.py +++ b/src/flowchem/devices/vacuubrand/cvc3000.py @@ -33,7 +33,7 @@ def __init__( self._serial = aio self._device_sn: int = None # type: ignore - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Vacuubrand", model="CVC3000", @@ -60,8 +60,8 @@ def from_config(cls, port, name=None, **serial_kwargs): async def initialize(self): """Ensure the connection w/ device is working.""" - self.metadata.version = await self.version() - if not self.metadata.version: + self.device_info.version = await self.version() + if not self.device_info.version: raise InvalidConfiguration("No reply received from CVC3000!") # Set to CVC3000 mode and save @@ -75,7 +75,7 @@ async def initialize(self): await self._send_command_and_read_reply("OUT_CFG 00001") await self.motor_speed(100) - logger.debug(f"Connected with CVC3000 version {self.metadata.version}") + logger.debug(f"Connected with CVC3000 version {self.device_info.version}") async def _send_command_and_read_reply(self, command: str) -> str: """ diff --git a/src/flowchem/devices/vacuubrand/cvc3000_finder.py b/src/flowchem/devices/vacuubrand/cvc3000_finder.py index 6bde6263..48956838 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_finder.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_finder.py @@ -24,7 +24,7 @@ def cvc3000_finder(serial_port) -> list[str]: cvc._serial.close() return [] - logger.info(f"CVC3000 {cvc.metadata.version} found on <{serial_port}>") + logger.info(f"CVC3000 {cvc.component_info.version} found on <{serial_port}>") return [ dedent( diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 3ed4a8af..6ed90bbc 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -109,7 +109,7 @@ def __init__( f"Cannot connect to the R2 on the port <{config.get('port')}>" ) from ex - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Vapourtec", model="R2 reactor module", @@ -118,8 +118,8 @@ def __init__( async def initialize(self): """Ensure connection.""" - self.metadata.version = await self.version() - logger.info(f"Connected with R2 version {self.metadata.version}") + self.device_info.version = await self.version() + logger.info(f"Connected with R2 version {self.device_info.version}") # Sets all pump to 0 ml/min await asyncio.sleep(0.1) diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index 62888356..ce8507be 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -72,7 +72,7 @@ def __init__( f"Cannot connect to the R4Heater on the port <{config.get('port')}>" ) from ex - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Vapourtec", model="R4 reactor module", @@ -80,8 +80,8 @@ def __init__( async def initialize(self): """Ensure connection.""" - self.metadata.version = await self.version() - logger.info(f"Connected with R4Heater version {self.metadata.version}") + self.device_info.version = await self.version() + logger.info(f"Connected with R4Heater version {self.device_info.version}") async def _write(self, command: str): """Writes a command to the pump""" diff --git a/src/flowchem/devices/vapourtec/vapourtec_finder.py b/src/flowchem/devices/vapourtec/vapourtec_finder.py index dc04c79a..dd89cd5b 100644 --- a/src/flowchem/devices/vapourtec/vapourtec_finder.py +++ b/src/flowchem/devices/vapourtec/vapourtec_finder.py @@ -29,8 +29,8 @@ def r4_finder(serial_port) -> list[str]: r4._serial.close() return [] - if r4.metadata.version: - logger.info(f"R4 version {r4.metadata.version} found on <{serial_port}>") + if r4.device_info.version: + logger.info(f"R4 version {r4.device_info.version} found on <{serial_port}>") # Local variable for enumeration r4_finder.counter += 1 # type: ignore cfg = f"[device.r4-heater-{r4_finder.counter}]" # type:ignore diff --git a/src/flowchem/devices/vicivalco/vici_valve.py b/src/flowchem/devices/vicivalco/vici_valve.py index 67deff10..d080d1ca 100644 --- a/src/flowchem/devices/vicivalco/vici_valve.py +++ b/src/flowchem/devices/vicivalco/vici_valve.py @@ -140,7 +140,7 @@ def __init__( super().__init__(name=name) # type: ignore self.address = address - self.metadata = DeviceInfo( + self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], manufacturer="Vici-Valco", model="Universal Valve Actuator", @@ -174,8 +174,8 @@ async def initialize(self): await self.home() # Test connectivity by querying the valve's firmware version - self.metadata.version = await self.version() - logger.info(f"Connected to {self.name} - FW ver.: {self.metadata.version}!") + self.device_info.version = await self.version() + logger.info(f"Connected to {self.name} - FW ver.: {self.device_info.version}!") async def learn_positions(self) -> None: """Initialize valve only, there is no reply -> reply_lines = 0.""" diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 42482cb4..16f50b43 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -98,9 +98,6 @@ def home_redirect_to_docs(root_path): device_root.add_api_route( "/", device.get_device_info, # TODO: add components in the device info response! - # TODO: also, fix confusion between device get_device_info and - # components. Device get_metadata equivalent could be implemented here to - # add the components info but it would miss the owl class that is coming methods=["GET"], response_model=DeviceInfo, ) From a363065d297f69f4ef8139c9d2f72181b36001d8 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 14:34:14 +0200 Subject: [PATCH 21/62] Allow multiple subclass for owl ontologies and minor cleanups --- src/flowchem/__main__.py | 14 ++-- src/flowchem/client/async_client.py | 15 +++-- src/flowchem/client/client.py | 23 +++---- src/flowchem/client/common.py | 64 +++---------------- src/flowchem/client/component_client.py | 30 +++++++++ src/flowchem/client/device_client.py | 42 ++++++++++++ src/flowchem/components/analytics/hplc.py | 3 +- src/flowchem/components/analytics/ir.py | 3 +- src/flowchem/components/analytics/nmr.py | 3 +- src/flowchem/components/base_component.py | 6 +- src/flowchem/components/component_info.py | 5 +- src/flowchem/components/pumps/base_pump.py | 1 + src/flowchem/components/pumps/hplc.py | 3 +- src/flowchem/components/pumps/syringe.py | 12 ++-- .../components/sensors/base_sensor.py | 2 +- src/flowchem/components/sensors/pressure.py | 2 +- src/flowchem/devices/knauer/dad_component.py | 2 +- src/flowchem/server/api_server.py | 29 +++++---- src/flowchem/server/configuration_parser.py | 2 +- tests/client/test_client.py | 10 ++- tests/server/test_server.py | 4 +- 21 files changed, 161 insertions(+), 114 deletions(-) create mode 100644 src/flowchem/client/component_client.py create mode 100644 src/flowchem/client/device_client.py diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 05bf40e6..72f0e6c7 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -20,7 +20,14 @@ @click.option( "-l", "--log", "logfile", type=click.Path(), default=None, help="Save logs to file." ) -@click.option("-h", "--host", "host", type=str, default="0.0.0.0", help="Server host.") +@click.option( + "-h", + "--host", + "host", + type=str, + default="0.0.0.0", + help="Server host. 0.0.0.0 is used to bind to all addresses, do not use for internet-exposed devices!", +) @click.option("-d", "--debug", is_flag=True, help="Print debug info.") @click.version_option() @click.command() @@ -34,6 +41,7 @@ def main(device_config_file, logfile, host, debug): device_config_file: Flowchem configuration file specifying device connection settings (TOML) logfile: Output file for logs. host: IP on which the server will be listening. Loopback IP as default, use LAN IP to enable remote access. + debug: Print debug info """ if sys.platform == "win32": @@ -51,9 +59,7 @@ def main(device_config_file, logfile, host, debug): async def main_loop(): """The loop must be shared between uvicorn and flowchem.""" - flowchem_instance = await create_server_from_file( - Path(device_config_file), host=host - ) + flowchem_instance = await create_server_from_file(Path(device_config_file)) config = uvicorn.Config( flowchem_instance["api_server"], host=host, diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 3c460332..0f976f9f 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -3,7 +3,6 @@ from loguru import logger from zeroconf import Zeroconf from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo -from pydantic import AnyHttpUrl from flowchem.client.client import FlowchemCommonDeviceListener from flowchem.client.common import ( @@ -11,7 +10,9 @@ device_name_to_zeroconf_name, device_url_from_service_info, zeroconf_name_to_device_name, + flowchem_devices_from_url_dict, ) +from flowchem.client.device_client import FlowchemDeviceClient class FlowchemAsyncDeviceListener(FlowchemCommonDeviceListener): @@ -33,7 +34,7 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: async def async_get_flowchem_device_by_name( device_name, timeout: int = 3000 -) -> AnyHttpUrl: +) -> FlowchemDeviceClient | None: """ Given a flowchem device name, search for it via mDNS and return its URL if found. @@ -51,14 +52,14 @@ async def async_get_flowchem_device_by_name( timeout=timeout, ) if service_info: - return device_url_from_service_info(service_info, device_name) - else: - return AnyHttpUrl() + return FlowchemDeviceClient( + device_url_from_service_info(service_info, device_name) + ) async def async_get_all_flowchem_devices( timeout: float = 3000, -) -> dict[str, AnyHttpUrl]: +) -> dict[str, FlowchemDeviceClient]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) """ @@ -67,7 +68,7 @@ async def async_get_all_flowchem_devices( await asyncio.sleep(timeout / 1000) await browser.async_cancel() - return listener.flowchem_devices + return flowchem_devices_from_url_dict(listener.flowchem_devices) if __name__ == "__main__": diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index 6d2c01ff..e2477e4e 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -1,15 +1,16 @@ import time from loguru import logger -from pydantic import AnyHttpUrl from zeroconf import ServiceBrowser, Zeroconf +from flowchem.client.device_client import FlowchemDeviceClient from flowchem.client.common import ( FLOWCHEM_TYPE, zeroconf_name_to_device_name, device_name_to_zeroconf_name, device_url_from_service_info, FlowchemCommonDeviceListener, + flowchem_devices_from_url_dict, ) @@ -24,7 +25,9 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: logger.warning(f"No info for service {name}!") -def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> AnyHttpUrl: +def get_flowchem_device_by_name( + device_name, timeout: int = 3000 +) -> FlowchemDeviceClient | None: """ Given a flowchem device name, search for it via mDNS and return its URL if found. @@ -42,12 +45,12 @@ def get_flowchem_device_by_name(device_name, timeout: int = 3000) -> AnyHttpUrl: timeout=timeout, ) if service_info: - return device_url_from_service_info(service_info, device_name) - else: - return AnyHttpUrl() + return FlowchemDeviceClient( + device_url_from_service_info(service_info, device_name) + ) -def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, AnyHttpUrl]: +def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceClient]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) """ @@ -56,7 +59,7 @@ def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, AnyHttpUrl]: time.sleep(timeout / 1000) browser.cancel() - return listener.flowchem_devices + return flowchem_devices_from_url_dict(listener.flowchem_devices) if __name__ == "__main__": @@ -65,9 +68,3 @@ def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, AnyHttpUrl]: dev_info = get_all_flowchem_devices() print(dev_info) - - from flowchem.client.common import FlowchemDeviceClient - - for name, url in get_all_flowchem_devices().items(): - dev = FlowchemDeviceClient(url) - print(dev) diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index 8bfa19a3..6d372d32 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -1,12 +1,10 @@ import ipaddress -import requests from loguru import logger from pydantic import AnyHttpUrl from zeroconf import ServiceListener, Zeroconf, ServiceInfo -from flowchem.components.component_info import ComponentInfo -from flowchem.components.device_info import DeviceInfo +from flowchem.client.device_client import FlowchemDeviceClient FLOWCHEM_SUFFIX = "._labthing._tcp.local." FLOWCHEM_TYPE = FLOWCHEM_SUFFIX[1:] @@ -21,6 +19,15 @@ def device_name_to_zeroconf_name(device_name: str) -> str: return f"{device_name}{FLOWCHEM_SUFFIX}" +def flowchem_devices_from_url_dict( + url_dict: dict[str, AnyHttpUrl] +) -> dict[str, FlowchemDeviceClient]: + dev_dict = {} + for name, url in url_dict.items(): + dev_dict[name] = FlowchemDeviceClient(url) + return dev_dict + + def device_url_from_service_info( service_info: ServiceInfo, device_name: str ) -> AnyHttpUrl: @@ -52,54 +59,3 @@ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: raise NotImplementedError() - - -class FlowchemComponentClient: - def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient"): - self.url = url - # Get ComponentInfo from - logger.warning(f"CREATE COMPONENT FOR URL {url}") - self._parent = parent - self._session = self._parent._session - self.component_info = ComponentInfo.model_validate_json(self.get(url).text) - - def get(self, url, **kwargs): - """Sends a GET request. Returns :class:`Response` object.""" - return self._session.get(url, **kwargs) - - def post(self, url, data=None, json=None, **kwargs): - """Sends a POST request. Returns :class:`Response` object.""" - return self._session.post(url, data=data, json=json, **kwargs) - - def put(self, url, data=None, **kwargs): - """Sends a PUT request. Returns :class:`Response` object.""" - return self._session.put(url, data=data, **kwargs) - - -class FlowchemDeviceClient: - def __init__(self, url: AnyHttpUrl): - self.url = url - - # Log every request and always raise for status - self._session = requests.Session() - self._session.hooks["response"] = [ - FlowchemDeviceClient.log_responses, - FlowchemDeviceClient.raise_for_status, - ] - - # Connect, get device info and populate components - self.device_info = DeviceInfo.model_validate_json( - self._session.get(self.url).text - ) - self.components = [ - FlowchemComponentClient(cmp_url, parent=self) - for cmp_url in self.device_info.components - ] - - @staticmethod - def raise_for_status(resp, *args, **kwargs): - resp.raise_for_status() - - @staticmethod - def log_responses(resp, *args, **kwargs): - logger.debug(f"Reply: {resp.text} on {resp.url}") diff --git a/src/flowchem/client/component_client.py b/src/flowchem/client/component_client.py new file mode 100644 index 00000000..9f36b997 --- /dev/null +++ b/src/flowchem/client/component_client.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING + +from loguru import logger +from pydantic import AnyHttpUrl + +if TYPE_CHECKING: + from flowchem.client.device_client import FlowchemDeviceClient +from flowchem.components.component_info import ComponentInfo + + +class FlowchemComponentClient: + def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient"): + self.url = url + # Get ComponentInfo from + logger.warning(f"CREATE COMPONENT FOR URL {url}") + self._parent = parent + self._session = self._parent._session + self.component_info = ComponentInfo.model_validate_json(self.get(url).text) + + def get(self, url, **kwargs): + """Sends a GET request. Returns :class:`Response` object.""" + return self._session.get(url, **kwargs) + + def post(self, url, data=None, json=None, **kwargs): + """Sends a POST request. Returns :class:`Response` object.""" + return self._session.post(url, data=data, json=json, **kwargs) + + def put(self, url, data=None, **kwargs): + """Sends a PUT request. Returns :class:`Response` object.""" + return self._session.put(url, data=data, **kwargs) diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py new file mode 100644 index 00000000..46e425c5 --- /dev/null +++ b/src/flowchem/client/device_client.py @@ -0,0 +1,42 @@ +import requests +from loguru import logger +from pydantic import AnyHttpUrl + +from flowchem.client.component_client import FlowchemComponentClient +from flowchem.components.device_info import DeviceInfo + + +class FlowchemDeviceClient: + def __init__(self, url: AnyHttpUrl): + self.url = url + + # Log every request and always raise for status + self._session = requests.Session() + self._session.hooks["response"] = [ + FlowchemDeviceClient.log_responses, + FlowchemDeviceClient.raise_for_status, + ] + + # Connect, get device info and populate components + try: + self.device_info = DeviceInfo.model_validate_json( + self._session.get(self.url).text + ) + except ConnectionError as ce: + raise RuntimeError( + f"Cannot connect to device at {url}!" + f"This is likely caused by the server listening only on local the interface," + f"start flowchem with the --host 0.0.0.0 option to check if that's the problem!" + ) from ce + self.components = [ + FlowchemComponentClient(cmp_url, parent=self) + for cmp_url in self.device_info.components + ] + + @staticmethod + def raise_for_status(resp, *args, **kwargs): + resp.raise_for_status() + + @staticmethod + def log_responses(resp, *args, **kwargs): + logger.debug(f"Reply: {resp.text} on {resp.url}") diff --git a/src/flowchem/components/analytics/hplc.py b/src/flowchem/components/analytics/hplc.py index 2968e7b4..574762c1 100644 --- a/src/flowchem/components/analytics/hplc.py +++ b/src/flowchem/components/analytics/hplc.py @@ -17,9 +17,10 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/send-method", self.send_method, methods=["PUT"]) # Ontology: high performance liquid chromatography instrument - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/OBI_0001057" ) + self.component_info.type = "HPLC Control" async def send_method(self, method_name): """Submits a method to the HPLC. diff --git a/src/flowchem/components/analytics/ir.py b/src/flowchem/components/analytics/ir.py index 2195f4af..4e3cf369 100644 --- a/src/flowchem/components/analytics/ir.py +++ b/src/flowchem/components/analytics/ir.py @@ -25,9 +25,10 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/stop", self.stop, methods=["PUT"]) # Ontology: high performance liquid chromatography instrument - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/OBI_0001057" ) + self.component_info.type = "IR Control" async def acquire_spectrum(self) -> IRSpectrum: # type: ignore """Acquire an IR spectrum.""" diff --git a/src/flowchem/components/analytics/nmr.py b/src/flowchem/components/analytics/nmr.py index c1e1c57c..f5257869 100644 --- a/src/flowchem/components/analytics/nmr.py +++ b/src/flowchem/components/analytics/nmr.py @@ -12,9 +12,10 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/stop", self.stop, methods=["PUT"]) # Ontology: fourier transformation NMR instrument - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/OBI_0000487" ) + self.component_info.type = "NMR Control" async def acquire_spectrum(self, background_tasks: BackgroundTasks): """Acquire an NMR spectrum.""" diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index 14d64744..84acbcdf 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -18,12 +18,14 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.name = name self.hw_device = hw_device self.component_info = ComponentInfo( - parent_device=self.hw_device.name, name=name + name=name, + parent_device=self.hw_device.name, ) # Initialize router self._router = APIRouter( - prefix=f"/{hw_device.name}/{name}", tags=[hw_device.name] + prefix=f"/{self.component_info.parent_device}/{name}", + tags=[self.component_info.parent_device], ) self.add_api_route( "/", diff --git a/src/flowchem/components/component_info.py b/src/flowchem/components/component_info.py index fd8a6959..ec45933e 100644 --- a/src/flowchem/components/component_info.py +++ b/src/flowchem/components/component_info.py @@ -8,4 +8,7 @@ class ComponentInfo(BaseModel): name: str = "" parent_device: str = "" - owl_subclass_of: str = "http://purl.obolibrary.org/obo/OBI_0000968" # 'device' + type: str = "" + owl_subclass_of: list[str] = [ + "http://purl.obolibrary.org/obo/OBI_0000968" + ] # 'device' diff --git a/src/flowchem/components/pumps/base_pump.py b/src/flowchem/components/pumps/base_pump.py index c73e1011..857853ca 100644 --- a/src/flowchem/components/pumps/base_pump.py +++ b/src/flowchem/components/pumps/base_pump.py @@ -12,6 +12,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/is-pumping", self.is_pumping, methods=["GET"]) if self.is_withdrawing_capable(): self.add_api_route("/withdraw", self.withdraw, methods=["PUT"]) + self.component_info.type = "Pump" async def infuse(self, rate: str = "", volume: str = "") -> bool: # type: ignore """Start infusion.""" diff --git a/src/flowchem/components/pumps/hplc.py b/src/flowchem/components/pumps/hplc.py index 944ebf1e..06dabe2f 100644 --- a/src/flowchem/components/pumps/hplc.py +++ b/src/flowchem/components/pumps/hplc.py @@ -10,9 +10,10 @@ def __init__(self, name: str, hw_device: FlowchemDevice): super().__init__(name, hw_device) # Ontology: HPLC isocratic pump - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/OBI_0000556" ) + self.component_info.type = "HPLC Pump" @staticmethod def is_withdrawing_capable() -> bool: diff --git a/src/flowchem/components/pumps/syringe.py b/src/flowchem/components/pumps/syringe.py index 2ecdb67d..2022dcda 100644 --- a/src/flowchem/components/pumps/syringe.py +++ b/src/flowchem/components/pumps/syringe.py @@ -1,12 +1,14 @@ """Syringe pump component, two flavours, infuse only, infuse-withdraw.""" -from flowchem.components.component_info import ComponentInfo from flowchem.components.pumps.base_pump import BasePump +from flowchem.devices.flowchem_device import FlowchemDevice class SyringePump(BasePump): - def get_component_info(self) -> ComponentInfo: - # Ontology: syringe pump - self.component_info.owl_subclass_of = ( + def __init__(self, name: str, hw_device: FlowchemDevice): + super().__init__(name, hw_device) + + # Ontology: Syringe pump + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/OBI_0400100" ) - return super().get_component_info() + self.component_info.type = "Syringe Pump" diff --git a/src/flowchem/components/sensors/base_sensor.py b/src/flowchem/components/sensors/base_sensor.py index 2101d8f1..d4ec1603 100644 --- a/src/flowchem/components/sensors/base_sensor.py +++ b/src/flowchem/components/sensors/base_sensor.py @@ -13,7 +13,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/power-on", self.power_on, methods=["PUT"]) self.add_api_route("/power-off", self.power_off, methods=["PUT"]) # Ontology: HPLC isocratic pump - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/NCIT_C50166" ) diff --git a/src/flowchem/components/sensors/pressure.py b/src/flowchem/components/sensors/pressure.py index bce0f681..dad4d814 100644 --- a/src/flowchem/components/sensors/pressure.py +++ b/src/flowchem/components/sensors/pressure.py @@ -12,7 +12,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): self.add_api_route("/read-pressure", self.read_pressure, methods=["GET"]) # Ontology: Pressure Sensor Device - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/NCIT_C50167" ) diff --git a/src/flowchem/devices/knauer/dad_component.py b/src/flowchem/devices/knauer/dad_component.py index 8e0a08de..4ee84dad 100644 --- a/src/flowchem/devices/knauer/dad_component.py +++ b/src/flowchem/devices/knauer/dad_component.py @@ -67,7 +67,7 @@ def __init__(self, name: str, hw_device: KnauerDAD, channel: int): self.add_api_route("/set-bandwidth", self.set_bandwidth, methods=["PUT"]) # Ontology: diode array detector - self.component_info.owl_subclass_of = ( + self.component_info.owl_subclass_of.append( "http://purl.obolibrary.org/obo/CHMO_0002503" ) diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 16f50b43..76bf3314 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -6,6 +6,7 @@ from typing import TypedDict, Iterable from fastapi import FastAPI, APIRouter +from zeroconf import NonUniqueNameException # from fastapi_utils.tasks import repeat_every from flowchem.vendor.repeat_every import repeat_every @@ -25,9 +26,7 @@ class FlowchemInstance(TypedDict): port: int -async def create_server_from_file( - config_file: BytesIO | Path, host: str = "0.0.0.0" -) -> FlowchemInstance: +async def create_server_from_file(config_file: BytesIO | Path) -> FlowchemInstance: """ Based on the toml device config provided, initialize connection to devices and create API endpoints. @@ -36,20 +35,19 @@ async def create_server_from_file( # Parse config (it also creates object instances for all hw dev in config["device"]) config = parse_config(config_file) - logger.info("Initializing devices...") + logger.info("Initializing device connection(s)...") # Run `initialize` method of all hw devices in parallel await asyncio.gather(*[dev.initialize() for dev in config["device"]]) # Collect background repeated tasks for each device (will need to schedule+start these) bg_tasks = [dev.repeated_task() for dev in config["device"] if dev.repeated_task()] - logger.info("Device initialization complete!") + logger.info("Device(s) connected") - return await create_server_for_devices(config, bg_tasks, host) + return await create_server_for_devices(config, bg_tasks) async def create_server_for_devices( config: dict, repeated_tasks: Iterable[RepeatedTaskInfo] = (), - host: str = "0.0.0.0", ) -> FlowchemInstance: """Initialize and create API endpoints for device object provided.""" dev_list = config["device"] @@ -68,8 +66,8 @@ async def create_server_for_devices( # mDNS server (Zeroconfig) mdns = ZeroconfServer(port=port) - logger.debug(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") - api_base_url = r"http://" + f"{host}:{port}" + logger.info(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") + api_base_url = r"http://" + f"{mdns.mdns_addresses[0]}:{port}" for seconds_delay, task_to_repeat in repeated_tasks: @@ -92,7 +90,14 @@ def home_redirect_to_docs(root_path): logger.debug(f"Got {len(components)} components from {device.name}") # Advertise devices (not components!) - await mdns.add_device(name=device.name, url=api_base_url) + try: + await mdns.add_device(name=device.name, url=api_base_url) + except NonUniqueNameException as nu: + raise RuntimeError( + f"Cannot initialize zeroconf service for {device.name}." + f"The same name is already in use: you cannot run flowchem twice!" + ) from nu + # Base device endpoint device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) device_root.add_api_route( @@ -108,6 +113,8 @@ def home_redirect_to_docs(root_path): app.include_router(component.router, tags=component.router.tags) logger.debug(f"Router <{component.router.prefix}> added to app!") + logger.info("Server component(s) loaded successfully!") + return {"api_server": app, "mdns_server": mdns, "port": port} @@ -119,7 +126,7 @@ async def main(): flowchem_instance = await create_server_from_file( config_file=io.BytesIO( b"""[device.test-device]\n - type = "FakeDevice"\n""" + type = "FakeDevice"\n""" ) ) config = uvicorn.Config( diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index f7d35094..5023b27b 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -66,7 +66,7 @@ def instantiate_device(config: dict) -> dict: parse_device(dev_settings, device_mapper) for dev_settings in config["device"].items() ] - logger.info("Configuration parsed!") + logger.info("Configuration parsed") return config diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 8f02eb8a..fe9413df 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -14,12 +14,11 @@ async def test_get_flowchem_device_by_name(): bytes( dedent( """[device.test-device]\n - type = "FakeDevice"\n""" + type = "FakeDevice"\n""" ), "utf-8", ) - ), - "0.0.0.0", + ) ) assert flowchem_instance["mdns_server"].server.loop.is_running() @@ -33,12 +32,11 @@ async def test_get_all_flowchem_devices(): bytes( dedent( """[device.test-device2]\n - type = "FakeDevice"\n""" + type = "FakeDevice"\n""" ), "utf-8", ) - ), - "0.0.0.0", + ) ) assert flowchem_instance["mdns_server"].server.loop.is_running() diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 63bab187..fc4160a4 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -20,9 +20,7 @@ def app(): type = "FakeDevice"\n""" ) ) - server = asyncio.run( - create_server_from_file(Path("test_configuration.toml"), "127.0.0.1") - ) + server = asyncio.run(create_server_from_file(Path("test_configuration.toml"))) yield server["api_server"] From 8eee42e86bfd43525223c66fa39139229074fa4d Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 14:44:48 +0200 Subject: [PATCH 22/62] Appease mypy --- src/flowchem/client/async_client.py | 11 +++++------ src/flowchem/client/client.py | 11 +++++------ src/flowchem/client/common.py | 4 ++-- src/flowchem/client/device_client.py | 2 +- src/flowchem/server/api_server.py | 3 +-- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 0f976f9f..ef0942b5 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -22,9 +22,8 @@ async def _resolve_service(self, zc: Zeroconf, type_: str, name: str): await service_info.async_request(zc, 3000) if service_info: device_name = zeroconf_name_to_device_name(name) - self.flowchem_devices[device_name] = device_url_from_service_info( - service_info, device_name - ) + if url := device_url_from_service_info(service_info, device_name): + self.flowchem_devices[device_name] = url else: logger.warning(f"No info for service {name}!") @@ -52,9 +51,9 @@ async def async_get_flowchem_device_by_name( timeout=timeout, ) if service_info: - return FlowchemDeviceClient( - device_url_from_service_info(service_info, device_name) - ) + if url := device_url_from_service_info(service_info, device_name): + return FlowchemDeviceClient(url) + return None async def async_get_all_flowchem_devices( diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index e2477e4e..71aa74ac 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -18,9 +18,8 @@ class FlowchemDeviceListener(FlowchemCommonDeviceListener): def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: if service_info := zc.get_service_info(type_, name): device_name = zeroconf_name_to_device_name(name) - self.flowchem_devices[device_name] = device_url_from_service_info( - service_info, device_name - ) + if url := device_url_from_service_info(service_info, device_name): + self.flowchem_devices[device_name] = url else: logger.warning(f"No info for service {name}!") @@ -45,9 +44,9 @@ def get_flowchem_device_by_name( timeout=timeout, ) if service_info: - return FlowchemDeviceClient( - device_url_from_service_info(service_info, device_name) - ) + if url := device_url_from_service_info(service_info, device_name): + return FlowchemDeviceClient(url) + return None def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceClient]: diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index 6d372d32..5185bcb0 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -30,14 +30,14 @@ def flowchem_devices_from_url_dict( def device_url_from_service_info( service_info: ServiceInfo, device_name: str -) -> AnyHttpUrl: +) -> AnyHttpUrl | None: if service_info.addresses: # Needed to convert IP from bytes to str device_ip = ipaddress.ip_address(service_info.addresses[0]) return AnyHttpUrl(f"http://{device_ip}:{service_info.port}/{device_name}") else: logger.warning(f"No address found for {device_name}!") - return AnyHttpUrl() + return None class FlowchemCommonDeviceListener(ServiceListener): diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index 46e425c5..652349a1 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -8,7 +8,7 @@ class FlowchemDeviceClient: def __init__(self, url: AnyHttpUrl): - self.url = url + self.url = str(url) # Log every request and always raise for status self._session = requests.Session() diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py index 76bf3314..24698c17 100644 --- a/src/flowchem/server/api_server.py +++ b/src/flowchem/server/api_server.py @@ -50,7 +50,6 @@ async def create_server_for_devices( repeated_tasks: Iterable[RepeatedTaskInfo] = (), ) -> FlowchemInstance: """Initialize and create API endpoints for device object provided.""" - dev_list = config["device"] port = config.get("port", 8000) # HTTP server (FastAPI) @@ -83,7 +82,7 @@ def home_redirect_to_docs(root_path): return RedirectResponse(url="/docs") # For each device get the relevant APIRouter(s) and add them to the app - for device in dev_list: + for device in config["device"]: # Get components (some compounded devices can return multiple components) components = device.components() device.get_device_info() From dbb848bfa19416ebc31b812fd907cc5b9aa528b6 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 15:05:53 +0200 Subject: [PATCH 23/62] pytest fixes --- src/flowchem/client/async_client.py | 45 +++++++++++++------ .../devices/dataapex/clarity_hplc_control.py | 6 +-- tests/client/test_client.py | 25 ++++------- 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index ef0942b5..0aba9678 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -31,19 +31,12 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: asyncio.ensure_future(self._resolve_service(zc, type_, name)) -async def async_get_flowchem_device_by_name( +async def _async_get_flowchem_device_url_by_name( device_name, timeout: int = 3000 ) -> FlowchemDeviceClient | None: """ - Given a flowchem device name, search for it via mDNS and return its URL if found. - - Args: - timeout: timout for search in ms (at least 2 seconds needed) - device_name: name of the device - - Returns: URL object, empty if not found + Internal function for async_get_flowchem_device_by_name() """ - zc = AsyncZeroconf() service_info = await zc.async_get_service_info( type_=FLOWCHEM_TYPE, @@ -52,22 +45,48 @@ async def async_get_flowchem_device_by_name( ) if service_info: if url := device_url_from_service_info(service_info, device_name): - return FlowchemDeviceClient(url) + return url return None -async def async_get_all_flowchem_devices( +async def async_get_flowchem_device_by_name( + device_name, timeout: int = 3000 +) -> FlowchemDeviceClient | None: + """ + Given a flowchem device name, search for it via mDNS and return its URL if found. + + Args: + timeout: timout for search in ms (at least 2 seconds needed) + device_name: name of the device + + Returns: URL object, empty if not found + """ + url = await _async_get_flowchem_device_url_by_name(device_name, timeout) + return FlowchemDeviceClient(url) if url else None + + +async def _async_get_all_flowchem_devices_url( timeout: float = 3000, ) -> dict[str, FlowchemDeviceClient]: """ - Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) + Internal function for async_get_all_flowchem_devices() """ listener = FlowchemAsyncDeviceListener() browser = AsyncServiceBrowser(Zeroconf(), FLOWCHEM_TYPE, listener) await asyncio.sleep(timeout / 1000) await browser.async_cancel() - return flowchem_devices_from_url_dict(listener.flowchem_devices) + return listener.flowchem_devices + + +async def async_get_all_flowchem_devices( + timeout: float = 3000, +) -> dict[str, FlowchemDeviceClient]: + """ + Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) + """ + url_dict = await _async_get_all_flowchem_devices_url(timeout) + return flowchem_devices_from_url_dict(url_dict) if __name__ == "__main__": diff --git a/src/flowchem/devices/dataapex/clarity_hplc_control.py b/src/flowchem/devices/dataapex/clarity_hplc_control.py index 3fb0c280..7268631e 100644 --- a/src/flowchem/devices/dataapex/clarity_hplc_control.py +++ b/src/flowchem/devices/dataapex/clarity_hplc_control.py @@ -28,7 +28,7 @@ async def send_method( method_name: str = Query( default=..., description="Name of the method file", - example="MyMethod.MET", + examples=["MyMethod.MET"], alias="method-name", ), ) -> bool: @@ -44,13 +44,13 @@ async def run_sample( sample_name: str = Query( default=..., description="Sample name", - example="JB-123-crude-2h", + examples=["JB-123-crude-2h"], alias="sample-name", ), method_name: str = Query( default=..., description="Name of the method file", - example="MyMethod.MET", + examples=["MyMethod.MET"], alias="method-name", ), ) -> bool: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index fe9413df..18e2af44 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,45 +1,38 @@ from io import BytesIO -from textwrap import dedent from flowchem.client.async_client import ( - async_get_flowchem_device_by_name, - async_get_all_flowchem_devices, + _async_get_all_flowchem_devices_url, + _async_get_flowchem_device_url_by_name, ) from flowchem.server.api_server import create_server_from_file -async def test_get_flowchem_device_by_name(): +async def test_get_flowchem_device_url_by_name(): flowchem_instance = await create_server_from_file( BytesIO( bytes( - dedent( - """[device.test-device]\n - type = "FakeDevice"\n""" - ), + """[device.test-device]\ntype = "FakeDevice"\n""", "utf-8", ) ) ) assert flowchem_instance["mdns_server"].server.loop.is_running() - url = await async_get_flowchem_device_by_name("test-device") - assert "test-device" in url + url = await _async_get_flowchem_device_url_by_name("test-device") + assert "test-device" in str(url) -async def test_get_all_flowchem_devices(): +async def test_get_all_flowchem_devices_url(): flowchem_instance = await create_server_from_file( BytesIO( bytes( - dedent( - """[device.test-device2]\n - type = "FakeDevice"\n""" - ), + """[device.test-device2]\ntype = "FakeDevice"\n""", "utf-8", ) ) ) assert flowchem_instance["mdns_server"].server.loop.is_running() - devs = await async_get_all_flowchem_devices() + devs = await _async_get_all_flowchem_devices_url() print(devs) assert "test-device2" in devs.keys() From 4f1211f6f3594886e277bf135804cbd2dfb1808d Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 12 Jul 2023 15:46:42 +0200 Subject: [PATCH 24/62] mypy --- src/flowchem/client/async_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 0aba9678..0dbe4e15 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -1,6 +1,7 @@ import asyncio from loguru import logger +from pydantic import AnyHttpUrl from zeroconf import Zeroconf from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo @@ -33,7 +34,7 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: async def _async_get_flowchem_device_url_by_name( device_name, timeout: int = 3000 -) -> FlowchemDeviceClient | None: +) -> AnyHttpUrl | None: """ Internal function for async_get_flowchem_device_by_name() """ @@ -67,7 +68,7 @@ async def async_get_flowchem_device_by_name( async def _async_get_all_flowchem_devices_url( timeout: float = 3000, -) -> dict[str, FlowchemDeviceClient]: +) -> dict[str, AnyHttpUrl]: """ Internal function for async_get_all_flowchem_devices() """ From 41f9661fcd4ee7bf0cf3d87f653c1400ae943d43 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 11:41:44 +0200 Subject: [PATCH 25/62] cleanup server part --- src/flowchem/__main__.py | 2 +- src/flowchem/devices/flowchem_device.py | 2 +- src/flowchem/server/api_server.py | 140 ------------------------ src/flowchem/server/create_server.py | 75 +++++++++++++ src/flowchem/server/fastapi_server.py | 72 ++++++++++++ src/flowchem/server/zeroconf_server.py | 19 ++-- tests/server/test_server.py | 2 +- 7 files changed, 162 insertions(+), 150 deletions(-) delete mode 100644 src/flowchem/server/api_server.py create mode 100644 src/flowchem/server/create_server.py create mode 100644 src/flowchem/server/fastapi_server.py diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 72f0e6c7..f3945828 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -61,7 +61,7 @@ async def main_loop(): """The loop must be shared between uvicorn and flowchem.""" flowchem_instance = await create_server_from_file(Path(device_config_file)) config = uvicorn.Config( - flowchem_instance["api_server"], + flowchem_instance["api_server"].app, host=host, port=flowchem_instance["port"], log_level="info", diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index efc918ff..c2b36db8 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -26,7 +26,7 @@ def __init__(self, name): self.device_info = DeviceInfo() async def initialize(self): - """Use for setting up async connection to the device.""" + """Use for setting up async connection to the device, populate components and update device_info with them.""" pass def repeated_task(self) -> RepeatedTaskInfo | None: diff --git a/src/flowchem/server/api_server.py b/src/flowchem/server/api_server.py deleted file mode 100644 index 24698c17..00000000 --- a/src/flowchem/server/api_server.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Run with `uvicorn main:app`.""" -import asyncio -from importlib.metadata import metadata -from io import BytesIO -from pathlib import Path -from typing import TypedDict, Iterable - -from fastapi import FastAPI, APIRouter -from zeroconf import NonUniqueNameException - -# from fastapi_utils.tasks import repeat_every -from flowchem.vendor.repeat_every import repeat_every -from loguru import logger -from starlette.responses import RedirectResponse - -import flowchem -from flowchem.components.device_info import DeviceInfo -from flowchem.devices.flowchem_device import RepeatedTaskInfo -from flowchem.server.configuration_parser import parse_config -from flowchem.server.zeroconf_server import ZeroconfServer - - -class FlowchemInstance(TypedDict): - api_server: FastAPI - mdns_server: ZeroconfServer - port: int - - -async def create_server_from_file(config_file: BytesIO | Path) -> FlowchemInstance: - """ - Based on the toml device config provided, initialize connection to devices and create API endpoints. - - config: Path to the toml file with the device config or dict. - """ - # Parse config (it also creates object instances for all hw dev in config["device"]) - config = parse_config(config_file) - - logger.info("Initializing device connection(s)...") - # Run `initialize` method of all hw devices in parallel - await asyncio.gather(*[dev.initialize() for dev in config["device"]]) - # Collect background repeated tasks for each device (will need to schedule+start these) - bg_tasks = [dev.repeated_task() for dev in config["device"] if dev.repeated_task()] - logger.info("Device(s) connected") - - return await create_server_for_devices(config, bg_tasks) - - -async def create_server_for_devices( - config: dict, - repeated_tasks: Iterable[RepeatedTaskInfo] = (), -) -> FlowchemInstance: - """Initialize and create API endpoints for device object provided.""" - port = config.get("port", 8000) - - # HTTP server (FastAPI) - app = FastAPI( - title=f"Flowchem - {config.get('filename')}", - description=metadata("flowchem")["Summary"], - version=flowchem.__version__, - license_info={ - "name": "MIT License", - "url": "https://opensource.org/licenses/MIT", - }, - ) - - # mDNS server (Zeroconfig) - mdns = ZeroconfServer(port=port) - logger.info(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") - api_base_url = r"http://" + f"{mdns.mdns_addresses[0]}:{port}" - - for seconds_delay, task_to_repeat in repeated_tasks: - - @app.on_event("startup") - @repeat_every(seconds=seconds_delay) - async def my_task(): - logger.debug("Running repeated task...") - await task_to_repeat() - - @app.route("/") - def home_redirect_to_docs(root_path): - """Redirect root to `/docs` to enable interaction w/ API.""" - return RedirectResponse(url="/docs") - - # For each device get the relevant APIRouter(s) and add them to the app - for device in config["device"]: - # Get components (some compounded devices can return multiple components) - components = device.components() - device.get_device_info() - logger.debug(f"Got {len(components)} components from {device.name}") - - # Advertise devices (not components!) - try: - await mdns.add_device(name=device.name, url=api_base_url) - except NonUniqueNameException as nu: - raise RuntimeError( - f"Cannot initialize zeroconf service for {device.name}." - f"The same name is already in use: you cannot run flowchem twice!" - ) from nu - - # Base device endpoint - device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) - device_root.add_api_route( - "/", - device.get_device_info, # TODO: add components in the device info response! - methods=["GET"], - response_model=DeviceInfo, - ) - app.include_router(device_root) - - for component in components: - # API endpoints registration - app.include_router(component.router, tags=component.router.tags) - logger.debug(f"Router <{component.router.prefix}> added to app!") - - logger.info("Server component(s) loaded successfully!") - - return {"api_server": app, "mdns_server": mdns, "port": port} - - -if __name__ == "__main__": - import io - import uvicorn - - async def main(): - flowchem_instance = await create_server_from_file( - config_file=io.BytesIO( - b"""[device.test-device]\n - type = "FakeDevice"\n""" - ) - ) - config = uvicorn.Config( - flowchem_instance["api_server"], - port=flowchem_instance["port"], - log_level="info", - timeout_keep_alive=3600, - ) - server = uvicorn.Server(config) - await server.serve() - - asyncio.run(main()) diff --git a/src/flowchem/server/create_server.py b/src/flowchem/server/create_server.py new file mode 100644 index 00000000..7dedd1ae --- /dev/null +++ b/src/flowchem/server/create_server.py @@ -0,0 +1,75 @@ +"""Run with `uvicorn main:app`.""" +import asyncio +from io import BytesIO +from pathlib import Path +from typing import TypedDict + +from loguru import logger + +from flowchem.server.configuration_parser import parse_config +from flowchem.server.zeroconf_server import ZeroconfServer +from flowchem.server.fastapi_server import FastAPIServer + + +class FlowchemInstance(TypedDict): + api_server: FastAPIServer + mdns_server: ZeroconfServer + port: int + + +async def create_server_from_file(config_file: BytesIO | Path) -> FlowchemInstance: + """ + Based on the toml device config provided, initialize connection to devices and create API endpoints. + + config: Path to the toml file with the device config or dict. + """ + # Parse config (it also creates object instances for all hw dev in config["device"]) + config = parse_config(config_file) + + logger.info("Initializing device connection(s)...") + # Run `initialize` method of all hw devices in parallel + await asyncio.gather(*[dev.initialize() for dev in config["device"]]) + logger.info("Device(s) connected") + + return await create_server_for_devices(config) + + +async def create_server_for_devices( + config: dict, +) -> FlowchemInstance: + """Initialize and create API endpoints for device object provided.""" + # mDNS server (Zeroconf) + mdns = ZeroconfServer(config.get("port")) + logger.info(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") + + # HTTP server (FastAPI) + http = FastAPIServer(config.get("filename")) + logger.debug("HTTP ASGI server app created") + + for device in config["device"]: + # Advertise devices as services via mDNS + await mdns.add_device(name=device.name) + # Add device API to HTTP server + http.add_device(device) + logger.info("Server component(s) loaded successfully!") + + return {"api_server": http, "mdns_server": mdns, "port": mdns.port} + + +if __name__ == "__main__": + import uvicorn + + async def main(): + flowchem_instance = await create_server_from_file( + config_file=BytesIO(b"""[device.test-device]\ntype = "FakeDevice"\n""") + ) + config = uvicorn.Config( + flowchem_instance["api_server"].app, + port=flowchem_instance["port"], + log_level="info", + timeout_keep_alive=3600, + ) + server = uvicorn.Server(config) + await server.serve() + + asyncio.run(main()) diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py new file mode 100644 index 00000000..f2558cd1 --- /dev/null +++ b/src/flowchem/server/fastapi_server.py @@ -0,0 +1,72 @@ +"""FastAPI server for devices control.""" +from typing import Iterable + +from fastapi import FastAPI, APIRouter +from importlib.metadata import metadata, version + +from loguru import logger +from starlette.responses import RedirectResponse + +from flowchem.components.device_info import DeviceInfo + +# from fastapi_utils.tasks import repeat_every + +from flowchem.vendor.repeat_every import repeat_every +from flowchem.devices.flowchem_device import RepeatedTaskInfo + + +class FastAPIServer: + def __init__(self, filename: str = ""): + # Create FastAPI app + self.app = FastAPI( + title=f"Flowchem - {filename}", + description=metadata("flowchem")["Summary"], + version=version("flowchem"), + license_info={ + "name": "MIT License", + "url": "https://opensource.org/licenses/MIT", + }, + ) + + self._add_root_redirect() + + def _add_root_redirect(self): + @self.app.route("/") + def home_redirect_to_docs(root_path): + """Redirect root to `/docs` to enable interaction w/ API.""" + return RedirectResponse(url="/docs") + + def add_background_tasks(self, repeated_tasks: Iterable[RepeatedTaskInfo]): + """Schedule repeated tasks to run upon server startup.""" + for seconds_delay, task in repeated_tasks: + + @self.app.on_event("startup") + @repeat_every(seconds=seconds_delay) + async def my_task(): + logger.debug("Running repeated task...") + await task() + + def add_device(self, device): + """Add device to server""" + # Get components (some compounded devices can return multiple components) + components = device.components() + logger.debug(f"Got {len(components)} components from {device.name}") + + # Base device endpoint + device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) + device_root.add_api_route( + "/", + device.get_device_info, # TODO: add components in the device info response! + methods=["GET"], + response_model=DeviceInfo, + ) + self.app.include_router(device_root) + + # Add repeated tasks for device if any + if tasks := device.repeated_task(): + self.add_background_tasks(tasks) + + # add device components + for component in components: + self.app.include_router(component.router, tags=component.router.tags) + logger.debug(f"Router <{component.router.prefix}> added to app!") diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index 8e007fbb..a3ca9ed7 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -2,19 +2,18 @@ import uuid from loguru import logger -from zeroconf import get_all_addresses +from zeroconf import get_all_addresses, NonUniqueNameException from zeroconf import IPVersion from zeroconf import ServiceInfo from zeroconf import Zeroconf class ZeroconfServer: - """ZeroconfServer to advertise FlowchemComponents.""" + """Server to advertise Flowchem devices via zero configuration networking.""" - def __init__(self, port=8000): + def __init__(self, port: int = 8000): # Server properties self.port = port - self.server = Zeroconf(ip_version=IPVersion.V4Only) # Get list of host addresses @@ -25,10 +24,10 @@ def __init__(self, port=8000): and not ip.startswith("169.254") # Remove invalid IPs ] - async def add_device(self, name: str, url: str): + async def add_device(self, name: str): """Adds device to the server.""" properties = { - "path": url + f"/{name}/", + "path": r"http://" + f"{self.mdns_addresses[0]}:{self.port}/{name}/", "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), } @@ -41,7 +40,13 @@ async def add_device(self, name: str, url: str): parsed_addresses=self.mdns_addresses, ) - await self.server.async_register_service(service_info) + try: + await self.server.async_register_service(service_info) + except NonUniqueNameException as nu: + raise RuntimeError( + f"Cannot initialize zeroconf service for '{name}'" + f"The same name is already in use: you cannot run flowchem twice for the same device!" + ) from nu logger.debug(f"Device {name} registered as Zeroconf service!") diff --git a/tests/server/test_server.py b/tests/server/test_server.py index fc4160a4..12955603 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -21,7 +21,7 @@ def app(): ) ) server = asyncio.run(create_server_from_file(Path("test_configuration.toml"))) - yield server["api_server"] + yield server["api_server"].app def test_read_main(app): From 6df8841432a5649edd406a432e12e51dec963702 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 11:43:05 +0200 Subject: [PATCH 26/62] missing rename from refactoring --- src/flowchem/__main__.py | 2 +- tests/client/test_client.py | 2 +- tests/server/test_server.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index f3945828..ba28096a 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -13,7 +13,7 @@ from loguru import logger from flowchem import __version__ -from flowchem.server.api_server import create_server_from_file +from flowchem.server.create_server import create_server_from_file @click.argument("device_config_file", type=click.Path(), required=True) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 18e2af44..b3aaa0dc 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -4,7 +4,7 @@ _async_get_all_flowchem_devices_url, _async_get_flowchem_device_url_by_name, ) -from flowchem.server.api_server import create_server_from_file +from flowchem.server.create_server import create_server_from_file async def test_get_flowchem_device_url_by_name(): diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 12955603..d43ddf4f 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -6,7 +6,7 @@ from click.testing import CliRunner from fastapi.testclient import TestClient -from flowchem.server.api_server import create_server_from_file +from flowchem.server.create_server import create_server_from_file @pytest.fixture(scope="function") From 458effb0711eb67829877bb7c065703427decb0a Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 13:39:59 +0200 Subject: [PATCH 27/62] simplify test for client check if it works in ga --- src/flowchem/server/create_server.py | 3 +- src/flowchem/server/zeroconf_server.py | 2 +- tests/client/test_client.py | 59 ++++++++----------- tests/conftest.py | 31 ++++++++++ tests/server/test_server.py | 2 +- .../example1.toml => tests/test_config.toml | 0 6 files changed, 57 insertions(+), 40 deletions(-) create mode 100644 tests/conftest.py rename examples/example1.toml => tests/test_config.toml (100%) diff --git a/src/flowchem/server/create_server.py b/src/flowchem/server/create_server.py index 7dedd1ae..78aad83d 100644 --- a/src/flowchem/server/create_server.py +++ b/src/flowchem/server/create_server.py @@ -39,7 +39,7 @@ async def create_server_for_devices( ) -> FlowchemInstance: """Initialize and create API endpoints for device object provided.""" # mDNS server (Zeroconf) - mdns = ZeroconfServer(config.get("port")) + mdns = ZeroconfServer(config.get("port", 8000)) logger.info(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") # HTTP server (FastAPI) @@ -52,7 +52,6 @@ async def create_server_for_devices( # Add device API to HTTP server http.add_device(device) logger.info("Server component(s) loaded successfully!") - return {"api_server": http, "mdns_server": mdns, "port": mdns.port} diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index a3ca9ed7..05b7f91d 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -11,7 +11,7 @@ class ZeroconfServer: """Server to advertise Flowchem devices via zero configuration networking.""" - def __init__(self, port: int = 8000): + def __init__(self, port: int): # Server properties self.port = port self.server = Zeroconf(ip_version=IPVersion.V4Only) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index b3aaa0dc..56e04e24 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,38 +1,25 @@ -from io import BytesIO - from flowchem.client.async_client import ( - _async_get_all_flowchem_devices_url, - _async_get_flowchem_device_url_by_name, + async_get_all_flowchem_devices, + async_get_flowchem_device_by_name, ) -from flowchem.server.create_server import create_server_from_file - - -async def test_get_flowchem_device_url_by_name(): - flowchem_instance = await create_server_from_file( - BytesIO( - bytes( - """[device.test-device]\ntype = "FakeDevice"\n""", - "utf-8", - ) - ) - ) - - assert flowchem_instance["mdns_server"].server.loop.is_running() - url = await _async_get_flowchem_device_url_by_name("test-device") - assert "test-device" in str(url) - - -async def test_get_all_flowchem_devices_url(): - flowchem_instance = await create_server_from_file( - BytesIO( - bytes( - """[device.test-device2]\ntype = "FakeDevice"\n""", - "utf-8", - ) - ) - ) - - assert flowchem_instance["mdns_server"].server.loop.is_running() - devs = await _async_get_all_flowchem_devices_url() - print(devs) - assert "test-device2" in devs.keys() +from flowchem.client.client import get_flowchem_device_by_name, get_all_flowchem_devices + + +def test_get_flowchem_device_url_by_name(flowchem_test_instance): + dev = get_flowchem_device_by_name("test-device") + assert "test-device" in str(dev.url) + + +def test_get_all_flowchem_devices(flowchem_test_instance): + dev_dict = get_all_flowchem_devices() + assert "test-device" in dev_dict.keys() + + +async def test_async_get_flowchem_device_by_name(flowchem_test_instance): + dev = await async_get_flowchem_device_by_name("test-device") + assert "test-device" in str(dev.url) + + +async def test_async_get_all_flowchem_devices(flowchem_test_instance): + dev_dict = await async_get_all_flowchem_devices() + assert "test-device" in dev_dict.keys() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..7014f053 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +import sys +import pytest + +from pathlib import Path + +from xprocess import ProcessStarter + + +@pytest.fixture(scope="module") +def flowchem_test_instance(xprocess): + config_file = Path(__file__).parent.resolve() / "test_config.toml" + main = ( + Path(__file__).parent.resolve() + / ".." + / ".." + / "src" + / "flowchem" + / "__main__.py" + ) + + class Starter(ProcessStarter): + # Process startup ends with this text in stdout + pattern = "Uvicorn running" + timeout = 10 + + # execute flowchem with current venv + args = [sys.executable, main, config_file] + + xprocess.ensure("flowchem_instance", Starter) + yield + xprocess.getinfo("flowchem_instance").terminate() diff --git a/tests/server/test_server.py b/tests/server/test_server.py index d43ddf4f..82a39148 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -24,7 +24,7 @@ def app(): yield server["api_server"].app -def test_read_main(app): +def test_read_main(flowchem_test_instance): client = TestClient(app) response = client.get("/") assert response.status_code == 200 diff --git a/examples/example1.toml b/tests/test_config.toml similarity index 100% rename from examples/example1.toml rename to tests/test_config.toml From 6c45fb0c9678309b4f0164eb3d23c2cfd648fdc5 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 13:48:04 +0200 Subject: [PATCH 28/62] mypy --- src/flowchem/server/create_server.py | 2 +- src/flowchem/server/zeroconf_server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flowchem/server/create_server.py b/src/flowchem/server/create_server.py index 78aad83d..d100792a 100644 --- a/src/flowchem/server/create_server.py +++ b/src/flowchem/server/create_server.py @@ -43,7 +43,7 @@ async def create_server_for_devices( logger.info(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") # HTTP server (FastAPI) - http = FastAPIServer(config.get("filename")) + http = FastAPIServer(config.get("filename", "")) logger.debug("HTTP ASGI server app created") for device in config["device"]: diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index 05b7f91d..a3ca9ed7 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -11,7 +11,7 @@ class ZeroconfServer: """Server to advertise Flowchem devices via zero configuration networking.""" - def __init__(self, port: int): + def __init__(self, port: int = 8000): # Server properties self.port = port self.server = Zeroconf(ip_version=IPVersion.V4Only) From 44d09f4c76297d407d96c14cdc56858b9fdd63f6 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 13:58:24 +0200 Subject: [PATCH 29/62] fix pytest --- pyproject.toml | 1 + tests/conftest.py | 9 +-------- tests/server/test_server.py | 32 ++++---------------------------- 3 files changed, 6 insertions(+), 36 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e6e6aef5..1591baa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ test = [ "pytest-asyncio", "pytest-cov", "pytest-mock", + "pytest-xprocess", "httpx", "requests", ] diff --git a/tests/conftest.py b/tests/conftest.py index 7014f053..b34db885 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,14 +9,7 @@ @pytest.fixture(scope="module") def flowchem_test_instance(xprocess): config_file = Path(__file__).parent.resolve() / "test_config.toml" - main = ( - Path(__file__).parent.resolve() - / ".." - / ".." - / "src" - / "flowchem" - / "__main__.py" - ) + main = Path(__file__).parent.resolve() / ".." / "src" / "flowchem" / "__main__.py" class Starter(ProcessStarter): # Process startup ends with this text in stdout diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 82a39148..859751b0 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1,38 +1,14 @@ -import asyncio -from pathlib import Path -from textwrap import dedent - -import pytest -from click.testing import CliRunner -from fastapi.testclient import TestClient - -from flowchem.server.create_server import create_server_from_file - - -@pytest.fixture(scope="function") -def app(): - runner = CliRunner() - with runner.isolated_filesystem(): - with open("test_configuration.toml", "w") as f: - f.write( - dedent( - """[device.test-device]\n - type = "FakeDevice"\n""" - ) - ) - server = asyncio.run(create_server_from_file(Path("test_configuration.toml"))) - yield server["api_server"].app +import requests def test_read_main(flowchem_test_instance): - client = TestClient(app) - response = client.get("/") + response = requests.get(r"http://127.0.0.1:8000/") assert response.status_code == 200 assert "Flowchem" in response.text - response = client.get("/test-device/test-component/test") + response = requests.get(r"http://127.0.0.1:8000/test-device/test-component/test") assert response.status_code == 200 assert response.text == "true" - response = client.get("/test-device2") + response = requests.get(r"http://127.0.0.1:8000/test-device2") assert response.status_code == 404 From ed0facf16c702acca52b8b36544a43fc9e545193 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 14:23:19 +0200 Subject: [PATCH 30/62] deal with flaky tests --- pyproject.toml | 1 + src/flowchem/client/async_client.py | 57 +++++++++-------------------- src/flowchem/client/client.py | 4 +- tests/client/test_client.py | 7 ++++ 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1591baa0..8e8a9abb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dev = [ ] test = [ "flowchem-test>=0.1a3", + "flaky", "pytest", "pytest-asyncio", "pytest-cov", diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 0dbe4e15..58e52160 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -1,7 +1,6 @@ import asyncio from loguru import logger -from pydantic import AnyHttpUrl from zeroconf import Zeroconf from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo @@ -32,26 +31,8 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: asyncio.ensure_future(self._resolve_service(zc, type_, name)) -async def _async_get_flowchem_device_url_by_name( - device_name, timeout: int = 3000 -) -> AnyHttpUrl | None: - """ - Internal function for async_get_flowchem_device_by_name() - """ - zc = AsyncZeroconf() - service_info = await zc.async_get_service_info( - type_=FLOWCHEM_TYPE, - name=device_name_to_zeroconf_name(device_name), - timeout=timeout, - ) - if service_info: - if url := device_url_from_service_info(service_info, device_name): - return url - return None - - async def async_get_flowchem_device_by_name( - device_name, timeout: int = 3000 + device_name, timeout: int = 10000 ) -> FlowchemDeviceClient | None: """ Given a flowchem device name, search for it via mDNS and return its URL if found. @@ -62,39 +43,37 @@ async def async_get_flowchem_device_by_name( Returns: URL object, empty if not found """ - url = await _async_get_flowchem_device_url_by_name(device_name, timeout) - return FlowchemDeviceClient(url) if url else None + zc = AsyncZeroconf() + service_info = await zc.async_get_service_info( + type_=FLOWCHEM_TYPE, + name=device_name_to_zeroconf_name(device_name), + timeout=timeout, + ) + if service_info: + if url := device_url_from_service_info(service_info, device_name): + FlowchemDeviceClient(url) + return None -async def _async_get_all_flowchem_devices_url( - timeout: float = 3000, -) -> dict[str, AnyHttpUrl]: +async def async_get_all_flowchem_devices( + timeout: float = 10000, +) -> dict[str, FlowchemDeviceClient]: """ - Internal function for async_get_all_flowchem_devices() + Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) """ listener = FlowchemAsyncDeviceListener() browser = AsyncServiceBrowser(Zeroconf(), FLOWCHEM_TYPE, listener) await asyncio.sleep(timeout / 1000) await browser.async_cancel() - return listener.flowchem_devices - - -async def async_get_all_flowchem_devices( - timeout: float = 3000, -) -> dict[str, FlowchemDeviceClient]: - """ - Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) - """ - url_dict = await _async_get_all_flowchem_devices_url(timeout) - return flowchem_devices_from_url_dict(url_dict) + return flowchem_devices_from_url_dict(listener.flowchem_devices) if __name__ == "__main__": async def main(): - url = await async_get_flowchem_device_by_name("test-device") - print(url) + dev = await async_get_flowchem_device_by_name("test-device") + print(dev) dev_info = await async_get_all_flowchem_devices() print(dev_info) diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index 71aa74ac..2791a365 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -62,8 +62,8 @@ def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceC if __name__ == "__main__": - url = get_flowchem_device_by_name("test-device") - print(url) + dev = get_flowchem_device_by_name("test-device") + print(dev) dev_info = get_all_flowchem_devices() print(dev_info) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 56e04e24..f28dde0b 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,3 +1,4 @@ +from flaky import flaky from flowchem.client.async_client import ( async_get_all_flowchem_devices, async_get_flowchem_device_by_name, @@ -5,21 +6,27 @@ from flowchem.client.client import get_flowchem_device_by_name, get_all_flowchem_devices +@flaky(max_runs=3) def test_get_flowchem_device_url_by_name(flowchem_test_instance): dev = get_flowchem_device_by_name("test-device") + assert dev is not None assert "test-device" in str(dev.url) +@flaky(max_runs=3) def test_get_all_flowchem_devices(flowchem_test_instance): dev_dict = get_all_flowchem_devices() assert "test-device" in dev_dict.keys() +@flaky(max_runs=3) async def test_async_get_flowchem_device_by_name(flowchem_test_instance): dev = await async_get_flowchem_device_by_name("test-device") + assert dev is not None assert "test-device" in str(dev.url) +@flaky(max_runs=3) async def test_async_get_all_flowchem_devices(flowchem_test_instance): dev_dict = await async_get_all_flowchem_devices() assert "test-device" in dev_dict.keys() From fde8f7e089e5a902cd5fb8c01237d284e023984d Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 16:09:13 +0200 Subject: [PATCH 31/62] Drop get_flowchem_device_by_name less robust than get_all_flowchem_devices --- pyproject.toml | 5 +++-- src/flowchem/client/async_client.py | 32 ++--------------------------- src/flowchem/client/client.py | 29 -------------------------- src/flowchem/client/common.py | 8 ++------ tests/client/test_client.py | 20 +----------------- 5 files changed, 8 insertions(+), 86 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8e8a9abb..a3fed7bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,6 @@ dev = [ ] test = [ "flowchem-test>=0.1a3", - "flaky", "pytest", "pytest-asyncio", "pytest-cov", @@ -107,7 +106,9 @@ python_version = "3.10" [tool.pytest.ini_options] testpaths = "tests" asyncio_mode = "auto" -addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" +# No cov needed for pycharm debugger +addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" +#addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" markers = [ "HApump: tests requiring a local HA Elite11 connected.", diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 58e52160..550483bf 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -2,12 +2,11 @@ from loguru import logger from zeroconf import Zeroconf -from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo from flowchem.client.client import FlowchemCommonDeviceListener from flowchem.client.common import ( FLOWCHEM_TYPE, - device_name_to_zeroconf_name, device_url_from_service_info, zeroconf_name_to_device_name, flowchem_devices_from_url_dict, @@ -31,32 +30,8 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: asyncio.ensure_future(self._resolve_service(zc, type_, name)) -async def async_get_flowchem_device_by_name( - device_name, timeout: int = 10000 -) -> FlowchemDeviceClient | None: - """ - Given a flowchem device name, search for it via mDNS and return its URL if found. - - Args: - timeout: timout for search in ms (at least 2 seconds needed) - device_name: name of the device - - Returns: URL object, empty if not found - """ - zc = AsyncZeroconf() - service_info = await zc.async_get_service_info( - type_=FLOWCHEM_TYPE, - name=device_name_to_zeroconf_name(device_name), - timeout=timeout, - ) - if service_info: - if url := device_url_from_service_info(service_info, device_name): - FlowchemDeviceClient(url) - return None - - async def async_get_all_flowchem_devices( - timeout: float = 10000, + timeout: float = 3000, ) -> dict[str, FlowchemDeviceClient]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) @@ -72,9 +47,6 @@ async def async_get_all_flowchem_devices( if __name__ == "__main__": async def main(): - dev = await async_get_flowchem_device_by_name("test-device") - print(dev) - dev_info = await async_get_all_flowchem_devices() print(dev_info) diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index 2791a365..66fe9129 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -7,7 +7,6 @@ from flowchem.client.common import ( FLOWCHEM_TYPE, zeroconf_name_to_device_name, - device_name_to_zeroconf_name, device_url_from_service_info, FlowchemCommonDeviceListener, flowchem_devices_from_url_dict, @@ -24,31 +23,6 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: logger.warning(f"No info for service {name}!") -def get_flowchem_device_by_name( - device_name, timeout: int = 3000 -) -> FlowchemDeviceClient | None: - """ - Given a flowchem device name, search for it via mDNS and return its URL if found. - - Args: - timeout: timout for search in ms (at least 2 seconds needed) - device_name: name of the device - - Returns: URL object, empty if not found - """ - - zc = Zeroconf() - service_info = zc.get_service_info( - type_=FLOWCHEM_TYPE, - name=device_name_to_zeroconf_name(device_name), - timeout=timeout, - ) - if service_info: - if url := device_url_from_service_info(service_info, device_name): - return FlowchemDeviceClient(url) - return None - - def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceClient]: """ Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) @@ -62,8 +36,5 @@ def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceC if __name__ == "__main__": - dev = get_flowchem_device_by_name("test-device") - print(dev) - dev_info = get_all_flowchem_devices() print(dev_info) diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index 5185bcb0..99ebae73 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -15,10 +15,6 @@ def zeroconf_name_to_device_name(zeroconf_name: str) -> str: return zeroconf_name[: -len(FLOWCHEM_SUFFIX)] -def device_name_to_zeroconf_name(device_name: str) -> str: - return f"{device_name}{FLOWCHEM_SUFFIX}" - - def flowchem_devices_from_url_dict( url_dict: dict[str, AnyHttpUrl] ) -> dict[str, FlowchemDeviceClient]: @@ -45,11 +41,11 @@ def __init__(self): self.flowchem_devices: dict[str, AnyHttpUrl] = {} def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: - logger.debug(f"Service {name} removed") + logger.debug(f"Service {zeroconf_name_to_device_name(name)} removed") self.flowchem_devices.pop(name, None) def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: - logger.debug(f"Service {name} updated") + logger.debug(f"Service {zeroconf_name_to_device_name(name)} updated") self.flowchem_devices.pop(name, None) self._save_device_info(zc, type_, name) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f28dde0b..3de700d2 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,32 +1,14 @@ -from flaky import flaky from flowchem.client.async_client import ( async_get_all_flowchem_devices, - async_get_flowchem_device_by_name, ) -from flowchem.client.client import get_flowchem_device_by_name, get_all_flowchem_devices +from flowchem.client.client import get_all_flowchem_devices -@flaky(max_runs=3) -def test_get_flowchem_device_url_by_name(flowchem_test_instance): - dev = get_flowchem_device_by_name("test-device") - assert dev is not None - assert "test-device" in str(dev.url) - - -@flaky(max_runs=3) def test_get_all_flowchem_devices(flowchem_test_instance): dev_dict = get_all_flowchem_devices() assert "test-device" in dev_dict.keys() -@flaky(max_runs=3) -async def test_async_get_flowchem_device_by_name(flowchem_test_instance): - dev = await async_get_flowchem_device_by_name("test-device") - assert dev is not None - assert "test-device" in str(dev.url) - - -@flaky(max_runs=3) async def test_async_get_all_flowchem_devices(flowchem_test_instance): dev_dict = await async_get_all_flowchem_devices() assert "test-device" in dev_dict.keys() From ad4e543ecc5399acbc52a71a5701f951301fe1ae Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 16:36:35 +0200 Subject: [PATCH 32/62] Minor changes --- src/flowchem/devices/flowchem_device.py | 3 ++- src/flowchem/devices/hamilton/ml600.py | 10 +++++++--- .../devices/harvardapparatus/_pumpio.py | 2 +- .../devices/harvardapparatus/elite11.py | 3 ++- src/flowchem/devices/knauer/azura_compact.py | 5 +++-- src/flowchem/devices/magritek/spinsolve.py | 4 ++-- src/flowchem/devices/phidgets/bubble_sensor.py | 8 ++++---- .../devices/phidgets/pressure_sensor.py | 4 ++-- src/flowchem/devices/vapourtec/r2.py | 2 +- src/flowchem/devices/vapourtec/r4_heater.py | 2 +- src/flowchem/server/configuration_parser.py | 2 +- tests/server/test_config_parser.py | 9 +-------- tests/server/test_server.py | 17 +++++++++++------ 13 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index c2b36db8..cb5c380e 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -1,5 +1,5 @@ """Base object for all hardware-control device classes.""" -from abc import ABC +from abc import ABC, abstractmethod from collections import namedtuple from collections.abc import Iterable from typing import TYPE_CHECKING @@ -25,6 +25,7 @@ def __init__(self, name): self.name = name self.device_info = DeviceInfo() + @abstractmethod async def initialize(self): """Use for setting up async connection to the device, populate components and update device_info with them.""" pass diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index 703b33a2..83e8b5b7 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -142,7 +142,9 @@ def parse_response(self, response: str) -> str: assert status in (self.ACKNOWLEDGE, self.NEGATIVE_ACKNOWLEDGE) if status == self.NEGATIVE_ACKNOWLEDGE: logger.warning("Negative acknowledge received") - warnings.warn("Negative acknowledge reply: check command syntax!") + warnings.warn( + "Negative acknowledge reply: check command syntax!", stacklevel=2 + ) return reply.rstrip() # removes trailing @@ -318,7 +320,8 @@ def _validate_speed(self, speed: pint.Quantity | None) -> str: warnings.warn( f"Desired speed ({speed}) is unachievable!" f"Set to {self._seconds_per_stroke_to_flowrate(speed)}" - f"Wrong units? A bigger syringe is needed?" + f"Wrong units? A bigger syringe is needed?", + stacklevel=2, ) # Target flow rate too low @@ -327,7 +330,8 @@ def _validate_speed(self, speed: pint.Quantity | None) -> str: warnings.warn( f"Desired speed ({speed}) is unachievable!" f"Set to {self._seconds_per_stroke_to_flowrate(speed)}" - f"Wrong units? A smaller syringe is needed?" + f"Wrong units? A smaller syringe is needed?", + stacklevel=2, ) return str(round(speed.m_as("sec / stroke"))) diff --git a/src/flowchem/devices/harvardapparatus/_pumpio.py b/src/flowchem/devices/harvardapparatus/_pumpio.py index 4d63e0a5..e8bbda57 100644 --- a/src/flowchem/devices/harvardapparatus/_pumpio.py +++ b/src/flowchem/devices/harvardapparatus/_pumpio.py @@ -86,7 +86,7 @@ def parse_response( ) -> tuple[list[int], list[PumpStatus], list[str]]: """Aggregate address prompt and reply body from all the reply lines and return them.""" parsed_lines = list(map(HarvardApparatusPumpIO.parse_response_line, response)) - return zip(*parsed_lines) # type: ignore + return zip(*parsed_lines, strict=True) # type: ignore @staticmethod def check_for_errors(response_line, command_sent): diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index 9ab3daf0..195f8a92 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -375,7 +375,8 @@ async def set_target_volume(self, volume: str): if "Argument error" in set_vol: warnings.warn( f"Cannot set target volume of {target_volume} with a " - f"{self.get_syringe_volume()} syringe!" + f"{self.get_syringe_volume()} syringe!", + stacklevel=2, ) async def pump_info(self) -> PumpInfo: diff --git a/src/flowchem/devices/knauer/azura_compact.py b/src/flowchem/devices/knauer/azura_compact.py index 8fd2b80d..e07f3c67 100644 --- a/src/flowchem/devices/knauer/azura_compact.py +++ b/src/flowchem/devices/knauer/azura_compact.py @@ -103,11 +103,12 @@ def error_present(reply: str) -> bool: return False if "ERROR:1" in reply: - warnings.warn("Invalid message sent to device.\n") + warnings.warn("Invalid message sent to device.\n", stacklevel=2) elif "ERROR:2" in reply: warnings.warn( - "Setpoint refused by device.\n" "Refer to manual for allowed values.\n" + "Setpoint refused by device.\n" "Refer to manual for allowed values.\n", + stacklevel=2, ) else: warnings.warn("Unspecified error detected!") diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 820a4aa8..6d05277c 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -86,10 +86,10 @@ def __init__( ) try: self.schema = etree.XMLSchema(file=str(default_schema)) - except etree.XMLSchemaParseError: # i.e. not found + except etree.XMLSchemaParseError as et: # i.e. not found raise ConnectionError( f"Cannot find RemoteControl.xsd in {default_schema}!" - ) + ) from et else: self.schema = xml_schema diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index 578d43ed..58d17893 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -69,10 +69,10 @@ def __init__( try: self.phidget.openWaitForAttachment(1000) logger.debug("power of tube sensor is connected!") - except PhidgetException: + except PhidgetException as pe: raise InvalidConfiguration( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." - ) + ) from pe # Set power supply to 5V to provide power self.phidget.setDutyCycle(1.0) @@ -160,10 +160,10 @@ def __init__( try: self.phidget.openWaitForAttachment(1000) logger.debug("tube sensor is connected!") - except PhidgetException: + except PhidgetException as pe: raise InvalidConfiguration( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." - ) + ) from pe # Set power supply to 12V to start measurement self.phidget.setPowerSupply(PowerSupply.POWER_SUPPLY_12V) diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index 7ee8ce5a..a559d500 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -66,10 +66,10 @@ def __init__( try: self.phidget.openWaitForAttachment(1000) logger.debug("Pressure sensor connected!") - except PhidgetException: + except PhidgetException as pe: raise InvalidConfiguration( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." - ) + ) from pe # Set power supply to 24V self.phidget.setPowerSupply(PowerSupply.POWER_SUPPLY_24V) diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 6ed90bbc..247588e5 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -404,7 +404,7 @@ def components(self): # Create components for reactor bays reactor_temp_limits = { ch_num: TempRange(min=ureg.Quantity(t[0]), max=ureg.Quantity(t[1])) - for ch_num, t in enumerate(zip(self._min_t, self._max_t)) + for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) } reactors = [ diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index ce8507be..da74d31e 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -163,7 +163,7 @@ def components(self): ch_num: TempRange( min=ureg.Quantity(f"{t[0]} °C"), max=ureg.Quantity(f"{t[1]} °C") ) - for ch_num, t in enumerate(zip(self._min_t, self._max_t)) + for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) } return [ R4HeaterChannelControl(f"reactor{n+1}", self, n, temp_limits[n]) diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 5023b27b..d9313879 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -110,7 +110,7 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: f"Install {needed_plugin} to add support for it!" f"e.g. `python -m pip install {needed_plugin}`" ) - raise InvalidConfiguration(f"{needed_plugin} not installed.") + raise InvalidConfiguration(f"{needed_plugin} not installed.") from error logger.exception( f"Device type `{device_config['type']}` unknown in 'device.{device_name}'!" diff --git a/tests/server/test_config_parser.py b/tests/server/test_config_parser.py index 6128498d..7762728d 100644 --- a/tests/server/test_config_parser.py +++ b/tests/server/test_config_parser.py @@ -24,14 +24,7 @@ def test_minimal_valid_config(): def test_name_too_long(): - cfg_txt = BytesIO( - dedent( - """ - [device.this_name_is_too_long_and_should_be_shorter] - type = "FakeDevice" - """ - ).encode("utf-8") - ) + cfg_txt = BytesIO(b"""[device.this_name_is_too_long_and_should_be_shorter]""") with pytest.raises(InvalidConfiguration) as excinfo: parse_config(cfg_txt) assert "too long" in str(excinfo.value) diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 859751b0..68f68c38 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1,14 +1,19 @@ import requests +OK = 200 +NOT_FOUND = 404 + def test_read_main(flowchem_test_instance): - response = requests.get(r"http://127.0.0.1:8000/") - assert response.status_code == 200 + response = requests.get(r"http://127.0.0.1:8000/", timeout=5) + assert response.status_code == OK assert "Flowchem" in response.text - response = requests.get(r"http://127.0.0.1:8000/test-device/test-component/test") - assert response.status_code == 200 + response = requests.get( + r"http://127.0.0.1:8000/test-device/test-component/test", timeout=5 + ) + assert response.status_code == OK assert response.text == "true" - response = requests.get(r"http://127.0.0.1:8000/test-device2") - assert response.status_code == 404 + response = requests.get(r"http://127.0.0.1:8000/test-device2", timeout=5) + assert response.status_code == NOT_FOUND From 5f2239e6a0a16d104fce50350113dd856a8f8793 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 17:02:08 +0200 Subject: [PATCH 33/62] Minor changes --- docs/json2yml.py | 2 +- examples/reaction_optimization/main_loop.py | 17 +++-- examples/reaction_optimization/plot/plot.py | 2 +- .../reaction_optimization/run_experiment.py | 20 ++--- src/flowchem/__init__.py | 3 +- src/flowchem/__main__.py | 17 +++-- src/flowchem/client/async_client.py | 7 +- src/flowchem/client/client.py | 10 +-- src/flowchem/client/common.py | 11 +-- src/flowchem/client/component_client.py | 2 +- src/flowchem/client/device_client.py | 4 +- src/flowchem/components/analytics/hplc.py | 4 +- src/flowchem/components/analytics/ir.py | 7 +- src/flowchem/components/analytics/nmr.py | 7 +- src/flowchem/components/base_component.py | 2 +- src/flowchem/components/component_info.py | 2 +- src/flowchem/components/device_info.py | 2 +- src/flowchem/components/pumps/base_pump.py | 2 +- src/flowchem/components/pumps/hplc.py | 4 +- src/flowchem/components/pumps/syringe.py | 4 +- .../components/sensors/base_sensor.py | 4 +- src/flowchem/components/sensors/photo.py | 7 +- src/flowchem/components/sensors/pressure.py | 7 +- src/flowchem/components/technical/photo.py | 3 +- src/flowchem/components/technical/power.py | 5 +- src/flowchem/components/technical/pressure.py | 4 +- .../components/technical/temperature.py | 11 +-- src/flowchem/components/valves/base_valve.py | 11 ++- .../components/valves/distribution_valves.py | 13 ++-- .../components/valves/injection_valves.py | 7 +- src/flowchem/devices/bronkhorst/__init__.py | 2 +- src/flowchem/devices/bronkhorst/el_flow.py | 43 ++++++----- .../devices/bronkhorst/el_flow_component.py | 22 +++--- src/flowchem/devices/dataapex/clarity.py | 5 +- .../devices/dataapex/clarity_hplc_control.py | 11 ++- src/flowchem/devices/flowchem_device.py | 6 +- src/flowchem/devices/hamilton/ml600.py | 47 ++++++------ src/flowchem/devices/hamilton/ml600_finder.py | 7 +- src/flowchem/devices/hamilton/ml600_pump.py | 4 +- .../devices/harvardapparatus/_pumpio.py | 20 ++--- .../devices/harvardapparatus/elite11.py | 76 +++++++++++-------- .../harvardapparatus/elite11_finder.py | 12 +-- src/flowchem/devices/huber/chiller.py | 18 +++-- src/flowchem/devices/huber/huber_finder.py | 4 +- src/flowchem/devices/huber/pb_command.py | 2 +- src/flowchem/devices/knauer/__init__.py | 1 - src/flowchem/devices/knauer/_common.py | 9 ++- src/flowchem/devices/knauer/azura_compact.py | 36 +++++---- .../devices/knauer/azura_compact_pump.py | 4 +- src/flowchem/devices/knauer/dad.py | 65 +++++++--------- src/flowchem/devices/knauer/dad_component.py | 31 ++++---- src/flowchem/devices/knauer/knauer_finder.py | 33 ++++---- src/flowchem/devices/knauer/knauer_valve.py | 37 ++++----- .../devices/knauer/knauer_valve_component.py | 8 +- .../devices/list_known_device_type.py | 7 +- src/flowchem/devices/magritek/_msg_maker.py | 6 +- src/flowchem/devices/magritek/_parser.py | 2 +- src/flowchem/devices/magritek/spinsolve.py | 54 +++++++------ .../devices/magritek/spinsolve_control.py | 26 +++++-- src/flowchem/devices/magritek/utils.py | 11 ++- .../devices/manson/manson_power_supply.py | 10 +-- src/flowchem/devices/mettlertoledo/icir.py | 29 +++---- .../devices/mettlertoledo/icir_control.py | 8 +- .../devices/mettlertoledo/icir_finder.py | 4 +- src/flowchem/devices/phidgets/__init__.py | 2 +- .../devices/phidgets/bubble_sensor.py | 33 ++++---- .../phidgets/bubble_sensor_component.py | 10 +-- .../devices/phidgets/pressure_sensor.py | 9 +-- .../phidgets/pressure_sensor_component.py | 2 +- src/flowchem/devices/vacuubrand/cvc3000.py | 10 +-- .../devices/vacuubrand/cvc3000_finder.py | 4 +- .../vacuubrand/cvc3000_pressure_control.py | 5 +- src/flowchem/devices/vapourtec/__init__.py | 2 +- src/flowchem/devices/vapourtec/r2.py | 76 ++++++++----------- .../vapourtec/r2_components_control.py | 51 +++++++------ src/flowchem/devices/vapourtec/r4_heater.py | 37 +++++---- .../vapourtec/r4_heater_channel_control.py | 13 ++-- .../devices/vapourtec/vapourtec_finder.py | 2 +- src/flowchem/devices/vicivalco/vici_valve.py | 34 +++++---- src/flowchem/server/configuration_parser.py | 25 +++--- src/flowchem/server/create_server.py | 7 +- src/flowchem/server/fastapi_server.py | 14 ++-- src/flowchem/server/zeroconf_server.py | 13 ++-- src/flowchem/utils/device_finder.py | 19 +++-- src/flowchem/vendor/getmac.py | 61 +++++++-------- src/flowchem/vendor/repeat_every.py | 21 +++-- tests/cli/test_autodiscover_cli.py | 4 +- tests/cli/test_flowchem_cli.py | 9 ++- tests/client/test_client.py | 4 +- tests/conftest.py | 3 +- tests/devices/analytics/test_flowir.py | 11 +-- tests/devices/analytics/test_spinsolve.py | 62 ++++++++------- tests/devices/pumps/test_azura_compact.py | 19 +++-- tests/devices/pumps/test_hw_elite11.py | 12 +-- tests/devices/technical/test_huber.py | 12 +-- tests/server/test_config_parser.py | 8 +- tests/server/test_server.py | 3 +- 97 files changed, 743 insertions(+), 715 deletions(-) diff --git a/docs/json2yml.py b/docs/json2yml.py index 7993ce22..2e03e67c 100644 --- a/docs/json2yml.py +++ b/docs/json2yml.py @@ -5,5 +5,5 @@ import yaml print( - yaml.dump(json.load(open(sys.argv[1])), default_flow_style=False, sort_keys=False) + yaml.dump(json.load(open(sys.argv[1])), default_flow_style=False, sort_keys=False), ) diff --git a/examples/reaction_optimization/main_loop.py b/examples/reaction_optimization/main_loop.py index 93350fc3..d0ec80ae 100644 --- a/examples/reaction_optimization/main_loop.py +++ b/examples/reaction_optimization/main_loop.py @@ -1,16 +1,15 @@ import time -from gryffin import Gryffin -from loguru import logger -from run_experiment import run_experiment - from examples.reaction_optimization._hw_control import ( command_session, - socl2_endpoint, - r4_endpoint, - hexyldecanoic_endpoint, flowir_endpoint, + hexyldecanoic_endpoint, + r4_endpoint, + socl2_endpoint, ) +from gryffin import Gryffin +from loguru import logger +from run_experiment import run_experiment logger.add("./xp.log", level="INFO") @@ -66,7 +65,9 @@ while time.monotonic() < (start_time + MAX_TIME): # query gryffin for new conditions_to_test, 1 exploration 1 exploitation (i.e. lambda 1 and -1) conditions_to_test = gryffin.recommend( - observations=observations, num_batches=1, sampling_strategies=[-1, 1] + observations=observations, + num_batches=1, + sampling_strategies=[-1, 1], ) # evaluate the proposed parameters! diff --git a/examples/reaction_optimization/plot/plot.py b/examples/reaction_optimization/plot/plot.py index a9d94ebe..5303ae21 100644 --- a/examples/reaction_optimization/plot/plot.py +++ b/examples/reaction_optimization/plot/plot.py @@ -18,7 +18,7 @@ "10": [6, 1, 2, 4, 8], "12.5": [8, 20, 50, 70, 90], "15": [20, 50, 70, 90, 100], - } + }, ) df.index.name = "time" df.columns.name = "temp" diff --git a/examples/reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py index e70a8cba..e2ba0f6c 100644 --- a/examples/reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -27,14 +27,14 @@ def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): - """ - Calculate pump flow rate based on target residence time and SOCl2 equivalents + """Calculate pump flow rate based on target residence time and SOCl2 equivalents. Stream A: hexyldecanoic acid ----| |----- REACTOR ---- IR ---- waste Stream B: thionyl chloride ----| Args: + ---- SOCl2_equivalent: residence_time: @@ -91,7 +91,7 @@ def get_ir_once_stable(): time.sleep(1) # Get spectrum previous_spectrum = pd.read_json( - sess.get(flowir_url + "/sample/spectrum-treated").text + sess.get(flowir_url + "/sample/spectrum-treated").text, ) previous_spectrum = previous_spectrum.set_index("wavenumber") # In case the id has changed between requests (highly unlikely) @@ -109,7 +109,7 @@ def get_ir_once_stable(): with command_session() as sess: current_spectrum = pd.read_json( - sess.get(flowir_url + "/sample/spectrum-treated").text + sess.get(flowir_url + "/sample/spectrum-treated").text, ) current_spectrum = current_spectrum.set_index("wavenumber") @@ -151,12 +151,14 @@ def integrate_peaks(ir_spectrum): def run_experiment( - SOCl2_equivalent: float, temperature: float, residence_time: float + SOCl2_equiv: float, + temperature: float, + residence_time: float, ) -> float: - """ - Runs one experiment with the provided conditions + """Runs one experiment with the provided conditions. Args: + ---- SOCl2_equivalent: SOCl2 to substrate ratio temperature: in Celsius residence_time: in minutes @@ -165,13 +167,13 @@ def run_experiment( """ logger.info( - f"Starting experiment with {SOCl2_equivalent:.2f} eq SOCl2, {temperature:.1f} degC and {residence_time:.2f} min" + f"Starting experiment with {SOCl2_equiv:.2f} eq SOCl2, {temperature:.1f} degC and {residence_time:.2f} min", ) # Set stand-by flow-rate first set_parameters({"hexyldecanoic": "0.1 ml/min", "socl2": "10 ul/min"}, temperature) wait_stable_temperature() # Set actual flow rate once the set temperature has been reached - pump_flow_rates = calculate_flow_rates(SOCl2_equivalent, residence_time) + pump_flow_rates = calculate_flow_rates(SOCl2_equiv, residence_time) set_parameters(pump_flow_rates, temperature) # Wait 1 residence time time.sleep(residence_time * 60) diff --git a/src/flowchem/__init__.py b/src/flowchem/__init__.py index 45524b6b..2d733c56 100644 --- a/src/flowchem/__init__.py +++ b/src/flowchem/__init__.py @@ -1,6 +1,5 @@ # Single-sourcing version, Option 5 in https://packaging.python.org/en/latest/guides/single-sourcing-package-version/ -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError, version try: __version__ = version(__name__) diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index ba28096a..2d0becb9 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -1,8 +1,7 @@ -""" -Entry-point module for the command line prefixer, called in case you use `python -m flowchem`. +"""Entry-point module for the command line prefixer, called in case you use `python -m flowchem`. Why does this file exist, and why `__main__`? For more info, read: - https://www.python.org/dev/peps/pep-0338/ -- https://docs.python.org/3/using/cmdline.html#cmdoption-m +- https://docs.python.org/3/using/cmdline.html#cmdoption-m. """ import asyncio import sys @@ -18,7 +17,12 @@ @click.argument("device_config_file", type=click.Path(), required=True) @click.option( - "-l", "--log", "logfile", type=click.Path(), default=None, help="Save logs to file." + "-l", + "--log", + "logfile", + type=click.Path(), + default=None, + help="Save logs to file.", ) @click.option( "-h", @@ -32,18 +36,17 @@ @click.version_option() @click.command() def main(device_config_file, logfile, host, debug): - """ - Flowchem main program. + """Flowchem main program. Parse device_config_file and starts a server exposing the devices via RESTful API. Args: + ---- device_config_file: Flowchem configuration file specifying device connection settings (TOML) logfile: Output file for logs. host: IP on which the server will be listening. Loopback IP as default, use LAN IP to enable remote access. debug: Print debug info """ - if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 550483bf..257ff8d0 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -8,15 +8,14 @@ from flowchem.client.common import ( FLOWCHEM_TYPE, device_url_from_service_info, - zeroconf_name_to_device_name, flowchem_devices_from_url_dict, + zeroconf_name_to_device_name, ) from flowchem.client.device_client import FlowchemDeviceClient class FlowchemAsyncDeviceListener(FlowchemCommonDeviceListener): async def _resolve_service(self, zc: Zeroconf, type_: str, name: str): - # logger.debug(f"MDNS resolving device '{name}'") service_info = AsyncServiceInfo(type_, name) await service_info.async_request(zc, 3000) if service_info: @@ -33,9 +32,7 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: async def async_get_all_flowchem_devices( timeout: float = 3000, ) -> dict[str, FlowchemDeviceClient]: - """ - Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) - """ + """Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address).""" listener = FlowchemAsyncDeviceListener() browser = AsyncServiceBrowser(Zeroconf(), FLOWCHEM_TYPE, listener) await asyncio.sleep(timeout / 1000) diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index 66fe9129..e18d9f32 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -3,14 +3,14 @@ from loguru import logger from zeroconf import ServiceBrowser, Zeroconf -from flowchem.client.device_client import FlowchemDeviceClient from flowchem.client.common import ( FLOWCHEM_TYPE, - zeroconf_name_to_device_name, - device_url_from_service_info, FlowchemCommonDeviceListener, + device_url_from_service_info, flowchem_devices_from_url_dict, + zeroconf_name_to_device_name, ) +from flowchem.client.device_client import FlowchemDeviceClient class FlowchemDeviceListener(FlowchemCommonDeviceListener): @@ -24,9 +24,7 @@ def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceClient]: - """ - Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address) - """ + """Search for flowchem devices and returns them in a dict (key=name, value=IPv4Address).""" listener = FlowchemDeviceListener() browser = ServiceBrowser(Zeroconf(), FLOWCHEM_TYPE, listener) time.sleep(timeout / 1000) diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index 99ebae73..40a13f64 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -2,7 +2,7 @@ from loguru import logger from pydantic import AnyHttpUrl -from zeroconf import ServiceListener, Zeroconf, ServiceInfo +from zeroconf import ServiceInfo, ServiceListener, Zeroconf from flowchem.client.device_client import FlowchemDeviceClient @@ -16,7 +16,7 @@ def zeroconf_name_to_device_name(zeroconf_name: str) -> str: def flowchem_devices_from_url_dict( - url_dict: dict[str, AnyHttpUrl] + url_dict: dict[str, AnyHttpUrl], ) -> dict[str, FlowchemDeviceClient]: dev_dict = {} for name, url in url_dict.items(): @@ -25,7 +25,8 @@ def flowchem_devices_from_url_dict( def device_url_from_service_info( - service_info: ServiceInfo, device_name: str + service_info: ServiceInfo, + device_name: str, ) -> AnyHttpUrl | None: if service_info.addresses: # Needed to convert IP from bytes to str @@ -37,7 +38,7 @@ def device_url_from_service_info( class FlowchemCommonDeviceListener(ServiceListener): - def __init__(self): + def __init__(self) -> None: self.flowchem_devices: dict[str, AnyHttpUrl] = {} def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: @@ -54,4 +55,4 @@ def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: self._save_device_info(zc, type_, name) def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: - raise NotImplementedError() + raise NotImplementedError diff --git a/src/flowchem/client/component_client.py b/src/flowchem/client/component_client.py index 9f36b997..6663f610 100644 --- a/src/flowchem/client/component_client.py +++ b/src/flowchem/client/component_client.py @@ -9,7 +9,7 @@ class FlowchemComponentClient: - def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient"): + def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient") -> None: self.url = url # Get ComponentInfo from logger.warning(f"CREATE COMPONENT FOR URL {url}") diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index 652349a1..348c98fd 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -7,7 +7,7 @@ class FlowchemDeviceClient: - def __init__(self, url: AnyHttpUrl): + def __init__(self, url: AnyHttpUrl) -> None: self.url = str(url) # Log every request and always raise for status @@ -20,7 +20,7 @@ def __init__(self, url: AnyHttpUrl): # Connect, get device info and populate components try: self.device_info = DeviceInfo.model_validate_json( - self._session.get(self.url).text + self._session.get(self.url).text, ) except ConnectionError as ce: raise RuntimeError( diff --git a/src/flowchem/components/analytics/hplc.py b/src/flowchem/components/analytics/hplc.py index 574762c1..87408a76 100644 --- a/src/flowchem/components/analytics/hplc.py +++ b/src/flowchem/components/analytics/hplc.py @@ -10,7 +10,7 @@ class HPLCControl(FlowchemComponent): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """HPLC Control component. Sends methods, starts run, do stuff.""" super().__init__(name, hw_device) self.add_api_route("/run-sample", self.run_sample, methods=["PUT"]) @@ -18,7 +18,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): # Ontology: high performance liquid chromatography instrument self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/OBI_0001057" + "http://purl.obolibrary.org/obo/OBI_0001057", ) self.component_info.type = "HPLC Control" diff --git a/src/flowchem/components/analytics/ir.py b/src/flowchem/components/analytics/ir.py index 4e3cf369..51bb3a4e 100644 --- a/src/flowchem/components/analytics/ir.py +++ b/src/flowchem/components/analytics/ir.py @@ -6,8 +6,7 @@ class IRSpectrum(BaseModel): - """ - IR spectrum class. + """IR spectrum class. Consider rampy for advance features (baseline fit, etc.) See e.g. https://github.com/charlesll/rampy/blob/master/examples/baseline_fit.ipynb @@ -18,7 +17,7 @@ class IRSpectrum(BaseModel): class IRControl(FlowchemComponent): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """HPLC Control component. Sends methods, starts run, do stuff.""" super().__init__(name, hw_device) self.add_api_route("/acquire-spectrum", self.acquire_spectrum, methods=["PUT"]) @@ -26,7 +25,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): # Ontology: high performance liquid chromatography instrument self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/OBI_0001057" + "http://purl.obolibrary.org/obo/OBI_0001057", ) self.component_info.type = "IR Control" diff --git a/src/flowchem/components/analytics/nmr.py b/src/flowchem/components/analytics/nmr.py index f5257869..2bc47249 100644 --- a/src/flowchem/components/analytics/nmr.py +++ b/src/flowchem/components/analytics/nmr.py @@ -1,11 +1,12 @@ """An NMR control component.""" +from fastapi import BackgroundTasks + from flowchem.components.base_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice -from fastapi import BackgroundTasks class NMRControl(FlowchemComponent): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """NMR Control component.""" super().__init__(name, hw_device) self.add_api_route("/acquire-spectrum", self.acquire_spectrum, methods=["PUT"]) @@ -13,7 +14,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): # Ontology: fourier transformation NMR instrument self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/OBI_0000487" + "http://purl.obolibrary.org/obo/OBI_0000487", ) self.component_info.type = "NMR Control" diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/base_component.py index 84acbcdf..84f21fa6 100644 --- a/src/flowchem/components/base_component.py +++ b/src/flowchem/components/base_component.py @@ -13,7 +13,7 @@ class FlowchemComponent: - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """Initialize component.""" self.name = name self.hw_device = hw_device diff --git a/src/flowchem/components/component_info.py b/src/flowchem/components/component_info.py index ec45933e..d205c15a 100644 --- a/src/flowchem/components/component_info.py +++ b/src/flowchem/components/component_info.py @@ -10,5 +10,5 @@ class ComponentInfo(BaseModel): parent_device: str = "" type: str = "" owl_subclass_of: list[str] = [ - "http://purl.obolibrary.org/obo/OBI_0000968" + "http://purl.obolibrary.org/obo/OBI_0000968", ] # 'device' diff --git a/src/flowchem/components/device_info.py b/src/flowchem/components/device_info.py index 080df2aa..b31af2cf 100644 --- a/src/flowchem/components/device_info.py +++ b/src/flowchem/components/device_info.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, AnyHttpUrl +from pydantic import AnyHttpUrl, BaseModel from flowchem import __version__ diff --git a/src/flowchem/components/pumps/base_pump.py b/src/flowchem/components/pumps/base_pump.py index 857853ca..c0c8572c 100644 --- a/src/flowchem/components/pumps/base_pump.py +++ b/src/flowchem/components/pumps/base_pump.py @@ -4,7 +4,7 @@ class BasePump(FlowchemComponent): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic pump.""" super().__init__(name, hw_device) self.add_api_route("/infuse", self.infuse, methods=["PUT"]) diff --git a/src/flowchem/components/pumps/hplc.py b/src/flowchem/components/pumps/hplc.py index 06dabe2f..82b46d18 100644 --- a/src/flowchem/components/pumps/hplc.py +++ b/src/flowchem/components/pumps/hplc.py @@ -5,13 +5,13 @@ class HPLCPump(BasePump): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) # Ontology: HPLC isocratic pump self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/OBI_0000556" + "http://purl.obolibrary.org/obo/OBI_0000556", ) self.component_info.type = "HPLC Pump" diff --git a/src/flowchem/components/pumps/syringe.py b/src/flowchem/components/pumps/syringe.py index 2022dcda..20e3e3c5 100644 --- a/src/flowchem/components/pumps/syringe.py +++ b/src/flowchem/components/pumps/syringe.py @@ -4,11 +4,11 @@ class SyringePump(BasePump): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: super().__init__(name, hw_device) # Ontology: Syringe pump self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/OBI_0400100" + "http://purl.obolibrary.org/obo/OBI_0400100", ) self.component_info.type = "Syringe Pump" diff --git a/src/flowchem/components/sensors/base_sensor.py b/src/flowchem/components/sensors/base_sensor.py index d4ec1603..25c3256e 100644 --- a/src/flowchem/components/sensors/base_sensor.py +++ b/src/flowchem/components/sensors/base_sensor.py @@ -8,13 +8,13 @@ class Sensor(FlowchemComponent): """A generic sensor.""" - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: super().__init__(name, hw_device) self.add_api_route("/power-on", self.power_on, methods=["PUT"]) self.add_api_route("/power-off", self.power_off, methods=["PUT"]) # Ontology: HPLC isocratic pump self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/NCIT_C50166" + "http://purl.obolibrary.org/obo/NCIT_C50166", ) async def power_on(self): diff --git a/src/flowchem/components/sensors/photo.py b/src/flowchem/components/sensors/photo.py index 37c41e72..9e3ee25a 100644 --- a/src/flowchem/components/sensors/photo.py +++ b/src/flowchem/components/sensors/photo.py @@ -1,19 +1,20 @@ """Pressure sensor.""" -from .base_sensor import Sensor from flowchem.devices.flowchem_device import FlowchemDevice +from .base_sensor import Sensor + class PhotoSensor(Sensor): """A photo sensor.""" - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/acquire-signal", self.acquire_signal, methods=["GET"]) self.add_api_route("/calibration", self.calibrate_zero, methods=["PUT"]) async def calibrate_zero(self): - """re-calibrate the sensors to their factory zero points""" + """re-calibrate the sensors to their factory zero points.""" ... async def acquire_signal(self): diff --git a/src/flowchem/components/sensors/pressure.py b/src/flowchem/components/sensors/pressure.py index dad4d814..2d170f0f 100644 --- a/src/flowchem/components/sensors/pressure.py +++ b/src/flowchem/components/sensors/pressure.py @@ -1,19 +1,20 @@ """Pressure sensor.""" -from .base_sensor import Sensor from flowchem.devices.flowchem_device import FlowchemDevice +from .base_sensor import Sensor + class PressureSensor(Sensor): """A pressure sensor.""" - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/read-pressure", self.read_pressure, methods=["GET"]) # Ontology: Pressure Sensor Device self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/NCIT_C50167" + "http://purl.obolibrary.org/obo/NCIT_C50167", ) async def read_pressure(self, units: str = "bar"): diff --git a/src/flowchem/components/technical/photo.py b/src/flowchem/components/technical/photo.py index 08d1a3bf..6abefd94 100644 --- a/src/flowchem/components/technical/photo.py +++ b/src/flowchem/components/technical/photo.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING - from flowchem.components.base_component import FlowchemComponent if TYPE_CHECKING: @@ -13,7 +12,7 @@ class Photoreactor(FlowchemComponent): """A generic photoreactor.""" - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: super().__init__(name, hw_device) self.add_api_route("/intensity", self.set_intensity, methods=["PUT"]) diff --git a/src/flowchem/components/technical/power.py b/src/flowchem/components/technical/power.py index ec9e946d..d13925ce 100644 --- a/src/flowchem/components/technical/power.py +++ b/src/flowchem/components/technical/power.py @@ -1,7 +1,6 @@ """Power control, sets both voltage and current. (Could be split in two, unnecessarty for now).""" from __future__ import annotations - from flowchem.components.base_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice @@ -13,7 +12,7 @@ def __init__( self, name: str, hw_device: FlowchemDevice, - ): + ) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device) @@ -36,7 +35,7 @@ def __init__( self, name: str, hw_device: FlowchemDevice, - ): + ) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device) diff --git a/src/flowchem/components/technical/pressure.py b/src/flowchem/components/technical/pressure.py index 2592d6bb..b2136487 100644 --- a/src/flowchem/components/technical/pressure.py +++ b/src/flowchem/components/technical/pressure.py @@ -1,4 +1,4 @@ -"""Pressure control""" +"""Pressure control.""" from __future__ import annotations from typing import TYPE_CHECKING @@ -16,7 +16,7 @@ class PressureControl(FlowchemComponent): """A generic pressure controller.""" - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device) diff --git a/src/flowchem/components/technical/temperature.py b/src/flowchem/components/technical/temperature.py index 5fdb3804..38994798 100644 --- a/src/flowchem/components/technical/temperature.py +++ b/src/flowchem/components/technical/temperature.py @@ -1,8 +1,7 @@ """Temperature control, either for heating or cooling.""" from __future__ import annotations -from typing import NamedTuple -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import pint from loguru import logger @@ -22,7 +21,9 @@ class TempRange(NamedTuple): class TemperatureControl(FlowchemComponent): """A generic temperature controller.""" - def __init__(self, name: str, hw_device: FlowchemDevice, temp_limits: TempRange): + def __init__( + self, name: str, hw_device: FlowchemDevice, temp_limits: TempRange + ) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device) @@ -48,14 +49,14 @@ async def set_temperature(self, temp: str) -> pint.Quantity: set_t = self._limits[0] logger.warning( f"Temperature requested {set_t} is out of range [{self._limits}] for {self.name}!" - f"Setting to {self._limits[0]} instead." + f"Setting to {self._limits[0]} instead.", ) if set_t > self._limits[1]: set_t = self._limits[1] logger.warning( f"Temperature requested {set_t} is out of range [{self._limits}] for {self.name}!" - f"Setting to {self._limits[1]} instead." + f"Setting to {self._limits[1]} instead.", ) return set_t diff --git a/src/flowchem/components/valves/base_valve.py b/src/flowchem/components/valves/base_valve.py index 9ef137b4..60ebeb2b 100644 --- a/src/flowchem/components/valves/base_valve.py +++ b/src/flowchem/components/valves/base_valve.py @@ -32,11 +32,11 @@ def __init__( hw_device: FlowchemDevice, positions: dict[str, list[tuple[str, str]]], ports: list[str], - ): - """ - Create a valve object. + ) -> None: + """Create a valve object. Args: + ---- name: device name, passed to FlowchemComponent. hw_device: the object that controls the hardware. positions: list of string representing the valve ports. The order in the list reflect the physical world. @@ -58,12 +58,11 @@ async def get_position(self) -> str: # type: ignore async def set_position(self, position: str) -> bool: """Set the valve to the specified position.""" - assert position in self._positions.keys() + assert position in self._positions return True def connections(self) -> ValveInfo: - """ - Get the list of all available positions for this valve. + """Get the list of all available positions for this valve. These are the human-friendly port names, and they do not necessarily match the port names used in the communication with the device. diff --git a/src/flowchem/components/valves/distribution_valves.py b/src/flowchem/components/valves/distribution_valves.py index 0e0b1e23..959fd73b 100644 --- a/src/flowchem/components/valves/distribution_valves.py +++ b/src/flowchem/components/valves/distribution_valves.py @@ -4,7 +4,7 @@ class TwoPortDistribution(BaseValve): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], "2": [("pump", "2")], @@ -13,7 +13,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): class SixPortDistribution(BaseValve): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], "2": [("pump", "2")], @@ -23,12 +23,15 @@ def __init__(self, name: str, hw_device: FlowchemDevice): "6": [("pump", "6")], } super().__init__( - name, hw_device, positions, ports=["pump", "1", "2", "3", "4", "5", "6"] + name, + hw_device, + positions, + ports=["pump", "1", "2", "3", "4", "5", "6"], ) class TwelvePortDistribution(BaseValve): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], "2": [("pump", "2")], @@ -66,7 +69,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice): class SixteenPortDistribution(BaseValve): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], "2": [("pump", "2")], diff --git a/src/flowchem/components/valves/injection_valves.py b/src/flowchem/components/valves/injection_valves.py index 4f7f3819..bac1d75e 100644 --- a/src/flowchem/components/valves/injection_valves.py +++ b/src/flowchem/components/valves/injection_valves.py @@ -4,7 +4,7 @@ class SixPortTwoPosition(BaseValve): - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: # These are hardware-port, only input and output are routable from the fixed syringe. # All three are listed as this simplifies the creation of graphs positions = { @@ -12,5 +12,8 @@ def __init__(self, name: str, hw_device: FlowchemDevice): "inject": [("6", "1"), ("2", "3"), ("4", "5")], } super().__init__( - name, hw_device, positions, ports=["1", "2", "3", "4", "5", "6"] + name, + hw_device, + positions, + ports=["1", "2", "3", "4", "5", "6"], ) diff --git a/src/flowchem/devices/bronkhorst/__init__.py b/src/flowchem/devices/bronkhorst/__init__.py index d482aab5..4f5271b5 100644 --- a/src/flowchem/devices/bronkhorst/__init__.py +++ b/src/flowchem/devices/bronkhorst/__init__.py @@ -1,3 +1,3 @@ -from .el_flow import MFC, EPC +from .el_flow import EPC, MFC __all__ = ["MFC", "EPC"] diff --git a/src/flowchem/devices/bronkhorst/el_flow.py b/src/flowchem/devices/bronkhorst/el_flow.py index 4f34b76d..47046a3a 100644 --- a/src/flowchem/devices/bronkhorst/el_flow.py +++ b/src/flowchem/devices/bronkhorst/el_flow.py @@ -1,16 +1,16 @@ +"""el-flow MFC control by python package bronkhorst-propar +https://bronkhorst-propar.readthedocs.io/en/latest/introduction.html. """ -el-flow MFC control by python package bronkhorst-propar -https://bronkhorst-propar.readthedocs.io/en/latest/introduction.html -""" -import propar import asyncio + +import propar from loguru import logger from flowchem import ureg -from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.components.device_info import DeviceInfo -from flowchem.utils.people import jakob, dario, wei_hsin -from flowchem.devices.bronkhorst.el_flow_component import MFCComponent, EPCComponent +from flowchem.devices.bronkhorst.el_flow_component import EPCComponent, MFCComponent +from flowchem.devices.flowchem_device import FlowchemDevice +from flowchem.utils.people import dario, jakob, wei_hsin class EPC(FlowchemDevice): @@ -23,7 +23,7 @@ def __init__( channel: int = 1, address: int = 0x80, max_pressure: float = 10, # bar = 100 % = 32000 - ): + ) -> None: self.port = port self.channel = channel self.address = address @@ -38,7 +38,9 @@ def __init__( try: self.el_press = propar.instrument( - self.port, address=self.address, channel=self.channel + self.port, + address=self.address, + channel=self.channel, ) self.id = self.el_press.id() logger.debug(f"Connected {self.id} to {self.port}") @@ -60,19 +62,19 @@ async def set_pressure(self, pressure: str): if set_n > 32000: self.el_press.setpoint = 32000 logger.debug( - "setting higher than maximum flow rate! set the flow rate to 100%" + "setting higher than maximum flow rate! set the flow rate to 100%", ) else: self.el_press.setpoint = set_n logger.debug(f"set the pressure to {set_n / 320}%") async def get_pressure(self) -> float: - """Get current flow rate in ml/min""" + """Get current flow rate in ml/min.""" m_num = float(self.el_press.measure) return m_num / 32000 * self.max_pressure async def get_pressure_percentage(self) -> float: - """Get current flow rate in percentage""" + """Get current flow rate in percentage.""" m_num = float(self.el_press.measure) return m_num / 320 @@ -100,7 +102,7 @@ def __init__( channel: int = 1, address: int = 0x80, max_flow: float = 9, # ml / min = 100 % = 32000 - ): + ) -> None: self.port = port self.channel = channel self.address = address @@ -115,7 +117,9 @@ def __init__( try: self.el_flow = propar.instrument( - self.port, address=self.address, channel=self.channel + self.port, + address=self.address, + channel=self.channel, ) self.id = self.el_flow.id() logger.debug(f"Connected {self.id} to {self.port}") @@ -132,26 +136,26 @@ async def set_flow_setpoint(self, flowrate: str): if flowrate.isnumeric(): flowrate = flowrate + "ml/min" logger.warning( - "No units provided to set_temperature, assuming milliliter/minutes." + "No units provided to set_temperature, assuming milliliter/minutes.", ) set_f = ureg.Quantity(flowrate) set_n = round(set_f.m_as("ml/min") * 32000 / self.max_flow) if set_n > 32000: self.el_flow.setpoint = 32000 logger.debug( - "setting higher than maximum flow rate! set the flow rate to 100%" + "setting higher than maximum flow rate! set the flow rate to 100%", ) else: self.el_flow.setpoint = set_n logger.debug(f"set the flow rate to {set_n / 320}%") async def get_flow_setpoint(self) -> float: - """Get current flow rate in ml/min""" + """Get current flow rate in ml/min.""" m_num = float(self.el_flow.measure) return m_num / 32000 * self.max_flow async def get_flow_percentage(self) -> float: - """Get current flow rate in percentage""" + """Get current flow rate in percentage.""" m_num = float(self.el_flow.measure) return m_num / 320 @@ -195,8 +199,7 @@ async def mutiple_connect(): def find_devices_info(port: str): - """ - It is also possible to only create a master. + """It is also possible to only create a master. This removes some abstraction offered by the instrument class, such as the setpoint and measure properties, the readParameter and writeParameter functions, diff --git a/src/flowchem/devices/bronkhorst/el_flow_component.py b/src/flowchem/devices/bronkhorst/el_flow_component.py index ca3f4964..176a29f1 100644 --- a/src/flowchem/devices/bronkhorst/el_flow_component.py +++ b/src/flowchem/devices/bronkhorst/el_flow_component.py @@ -8,14 +8,14 @@ from flowchem.devices.flowchem_device import FlowchemDevice if TYPE_CHECKING: - from .el_flow import MFC, EPC + from .el_flow import EPC, MFC class EPCComponent(PressureSensor): hw_device: EPC # just for typing - def __init__(self, name: str, hw_device: FlowchemDevice): - """A generic power supply""" + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: + """A generic power supply.""" super().__init__(name, hw_device) self.add_api_route("/get-pressure", self.get_pressure, methods=["GET"]) self.add_api_route("/stop", self.stop, methods=["PUT"]) @@ -27,16 +27,16 @@ async def read_pressure(self, units: str = "bar"): return p * ureg(units) async def set_pressure_setpoint(self, pressure: str) -> bool: - """Set controlled pressure to the instrument; default unit: bar""" + """Set controlled pressure to the instrument; default unit: bar.""" await self.hw_device.set_pressure(pressure) return True async def get_pressure(self) -> float: - """get current system pressure in bar""" + """Get current system pressure in bar.""" return await self.hw_device.get_pressure() async def stop(self) -> bool: - """Stop pressure controller""" + """Stop pressure controller.""" await self.hw_device.set_pressure("0 bar") return True @@ -44,23 +44,23 @@ async def stop(self) -> bool: class MFCComponent(FlowchemComponent): hw_device: MFC # just for typing - def __init__(self, name: str, hw_device: FlowchemDevice): - """A generic power supply""" + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: + """A generic power supply.""" super().__init__(name, hw_device) self.add_api_route("/get-flow-rate", self.get_flow_setpoint, methods=["GET"]) self.add_api_route("/stop", self.stop, methods=["PUT"]) self.add_api_route("/set-flow-rate", self.set_flow_setpoint, methods=["PUT"]) async def set_flow_setpoint(self, flowrate: str) -> bool: - """Set flow rate to the instrument; default unit: ml/min""" + """Set flow rate to the instrument; default unit: ml/min.""" await self.hw_device.set_flow_setpoint(flowrate) return True async def get_flow_setpoint(self) -> float: - """get current flow rate in ml/min""" + """Get current flow rate in ml/min.""" return await self.hw_device.get_flow_setpoint() async def stop(self) -> bool: - """Stop mass flow controller""" + """Stop mass flow controller.""" await self.hw_device.set_flow_setpoint("0 ml/min") return True diff --git a/src/flowchem/devices/dataapex/clarity.py b/src/flowchem/devices/dataapex/clarity.py index 2cc4c867..70ba8f67 100644 --- a/src/flowchem/devices/dataapex/clarity.py +++ b/src/flowchem/devices/dataapex/clarity.py @@ -6,11 +6,12 @@ from loguru import logger -from .clarity_hplc_control import ClarityComponent from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.utils.people import dario, jakob, wei_hsin +from .clarity_hplc_control import ClarityComponent + class Clarity(FlowchemDevice): def __init__( @@ -24,7 +25,7 @@ def __init__( user: str = "admin", password: str = "", cfg_file: str = "", - ): + ) -> None: super().__init__(name=name) self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], diff --git a/src/flowchem/devices/dataapex/clarity_hplc_control.py b/src/flowchem/devices/dataapex/clarity_hplc_control.py index 7268631e..a36ce93c 100644 --- a/src/flowchem/devices/dataapex/clarity_hplc_control.py +++ b/src/flowchem/devices/dataapex/clarity_hplc_control.py @@ -13,7 +13,7 @@ class ClarityComponent(HPLCControl): hw_device: Clarity # for typing's sake - def __init__(self, name: str, hw_device: Clarity): + def __init__(self, name: str, hw_device: Clarity) -> None: """Device-specific initialization.""" super().__init__(name, hw_device) # Clarity-specific command @@ -32,8 +32,7 @@ async def send_method( alias="method-name", ), ) -> bool: - """ - Sets the HPLC method (i.e. a file with .MET extension) to the instrument. + """Sets the HPLC method (i.e. a file with .MET extension) to the instrument. Make sure to select 'Send Method to Instrument' option in Method Sending Options dialog in System Configuration. """ @@ -54,8 +53,7 @@ async def run_sample( alias="method-name", ), ) -> bool: - """ - Run one analysis on the instrument. + """Run one analysis on the instrument. Note that it takes at least 2 sec until the run actually starts (depending on instrument configuration). While the export of the chromatogram in e.g. ASCII format can be achieved programmatically via the CLI, the best @@ -67,5 +65,6 @@ async def run_sample( if not await self.send_method(method_name): return False return await self.hw_device.execute_command( - f"run={self.hw_device.instrument}", without_instrument_num=True + f"run={self.hw_device.instrument}", + without_instrument_num=True, ) diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index cb5c380e..f9c3ea31 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -13,14 +13,13 @@ class FlowchemDevice(ABC): - """ - Base flowchem device. + """Base flowchem device. All hardware-control classes must subclass this to signal they are flowchem-device and be enabled for initializaiton during config parsing. """ - def __init__(self, name): + def __init__(self, name) -> None: """All device have a name, which is the key in the config dict thus unique.""" self.name = name self.device_info = DeviceInfo() @@ -28,7 +27,6 @@ def __init__(self, name): @abstractmethod async def initialize(self): """Use for setting up async connection to the device, populate components and update device_info with them.""" - pass def repeated_task(self) -> RepeatedTaskInfo | None: """Use for repeated background task, e.g. session keepalive.""" diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index 83e8b5b7..d1ca09d2 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -65,7 +65,7 @@ class HamiltonPumpIO: "bytesize": aioserial.SEVENBITS, } - def __init__(self, aio_port: aioserial.Serial): + def __init__(self, aio_port: aioserial.Serial) -> None: """Initialize serial port, not pumps.""" self._serial = aio_port self.num_pump_connected: int | None = ( @@ -93,8 +93,7 @@ async def initialize(self, hw_initialization: bool = True): await self._hw_init() async def _assign_pump_address(self) -> int: - """ - Auto assign pump addresses. + """Auto assign pump addresses. To be run on init, auto assign addresses to pumps based on their position in the daisy chain. A custom command syntax with no addresses is used here so read and write has been rewritten @@ -127,7 +126,7 @@ async def _hw_init(self): async def _write_async(self, command: bytes): """Write a command to the pump.""" await self._serial.write_async(command) - logger.debug(f"Command {repr(command)} sent!") + logger.debug(f"Command {command!r} sent!") async def _read_reply_async(self) -> str: """Read the pump reply from serial communication.""" @@ -143,7 +142,8 @@ def parse_response(self, response: str) -> str: if status == self.NEGATIVE_ACKNOWLEDGE: logger.warning("Negative acknowledge received") warnings.warn( - "Negative acknowledge reply: check command syntax!", stacklevel=2 + "Negative acknowledge reply: check command syntax!", + stacklevel=2, ) return reply.rstrip() # removes trailing @@ -208,11 +208,11 @@ def __init__( name: str, address: int = 1, **config, - ): - """ - Default constructor, needs an HamiltonPumpIO object. See from_config() class method for config-based init. + ) -> None: + """Default constructor, needs an HamiltonPumpIO object. See from_config() class method for config-based init. Args: + ---- pump_io: An HamiltonPumpIO w/ serial connection to the daisy chain w/ target pump. syringe_volume: Volume of the syringe used, either a Quantity or number in ml. address: number of pump in array, 1 for first one, auto-assigned on init based on position. @@ -292,7 +292,7 @@ async def initialize(self, hw_init=False, init_speed: str = "200 sec / stroke"): fw_cmd = Protocol1Command(command="U", target_pump_num=self.address) self.device_info.version = await self.pump_io.write_and_read_reply_async(fw_cmd) logger.info( - f"Connected to Hamilton ML600 {self.name} - FW version: {self.device_info.version}!" + f"Connected to Hamilton ML600 {self.name} - FW version: {self.device_info.version}!", ) if hw_init: @@ -304,8 +304,7 @@ async def send_command_and_read_reply(self, command: Protocol1Command) -> str: return await self.pump_io.write_and_read_reply_async(command) def _validate_speed(self, speed: pint.Quantity | None) -> str: - """ - Validate the speed. + """Validate the speed. Given a speed (seconds/stroke) returns a valid value for it, and a warning if out of bounds. """ @@ -337,8 +336,7 @@ def _validate_speed(self, speed: pint.Quantity | None) -> str: return str(round(speed.m_as("sec / stroke"))) async def initialize_pump(self, speed: pint.Quantity | None = None): - """ - Initialize both syringe and valve. + """Initialize both syringe and valve. speed: 2-3692 in seconds/stroke """ @@ -367,8 +365,7 @@ async def initialize_pump(self, speed: pint.Quantity | None = None): # return await self.send_command_and_read_reply(init_syringe) def flowrate_to_seconds_per_stroke(self, flowrate: pint.Quantity): - """ - Convert flow rates to steps per seconds. + """Convert flow rates to steps per seconds. To determine the volume dispensed per step the total syringe volume is divided by 48,000 steps. All Hamilton instrument syringes are designed with a 60 mm stroke @@ -391,7 +388,9 @@ def _volume_to_step_position(self, volume: pint.Quantity) -> int: return round(steps.m_as("steps")) + self._offset_steps async def _to_step_position( - self, position: int, speed: pint.Quantity | None = None + self, + position: int, + speed: pint.Quantity | None = None, ): """Absolute move to step position.""" abs_move_cmd = Protocol1Command( @@ -405,7 +404,7 @@ async def _to_step_position( async def get_current_volume(self) -> pint.Quantity: """Return current syringe position in ml.""" syringe_pos = await self.send_command_and_read_reply( - Protocol1Command(command="YQP") + Protocol1Command(command="YQP"), ) current_steps = (int(syringe_pos) - self._offset_steps) * ureg.step @@ -415,27 +414,28 @@ async def to_volume(self, target_volume: pint.Quantity, rate: pint.Quantity): """Absolute move to volume provided.""" speed = self.flowrate_to_seconds_per_stroke(rate) await self._to_step_position( - self._volume_to_step_position(target_volume), speed + self._volume_to_step_position(target_volume), + speed, ) logger.debug(f"Pump {self.name} set to volume {target_volume} at speed {speed}") async def pause(self): """Pause any running command.""" return await self.send_command_and_read_reply( - Protocol1Command(command="", execution_command="K") + Protocol1Command(command="", execution_command="K"), ) async def resume(self): """Resume any paused command.""" return await self.send_command_and_read_reply( - Protocol1Command(command="", execution_command="$") + Protocol1Command(command="", execution_command="$"), ) async def stop(self): """Stop and abort any running command.""" await self.pause() return await self.send_command_and_read_reply( - Protocol1Command(command="", execution_command="V") + Protocol1Command(command="", execution_command="V"), ) async def wait_until_idle(self): @@ -464,13 +464,12 @@ async def set_valve_position( target_position: str, wait_for_movement_end: bool = True, ): - """ - Set valve position. + """Set valve position. wait_for_movement_end is defaulted to True as it is a common mistake not to wait... """ await self.send_command_and_read_reply( - Protocol1Command(command="LP0", command_value=target_position) + Protocol1Command(command="LP0", command_value=target_position), ) logger.debug(f"{self.name} valve position set to position {target_position}") if wait_for_movement_end: diff --git a/src/flowchem/devices/hamilton/ml600_finder.py b/src/flowchem/devices/hamilton/ml600_finder.py index 0e271599..e4d53d8d 100644 --- a/src/flowchem/devices/hamilton/ml600_finder.py +++ b/src/flowchem/devices/hamilton/ml600_finder.py @@ -4,8 +4,7 @@ from loguru import logger -from flowchem.devices.hamilton.ml600 import HamiltonPumpIO -from flowchem.devices.hamilton.ml600 import InvalidConfiguration +from flowchem.devices.hamilton.ml600 import HamiltonPumpIO, InvalidConfiguration def ml600_finder(serial_port) -> set[str]: @@ -38,8 +37,8 @@ def ml600_finder(serial_port) -> set[str]: f"""type = "ML600" port = "{serial_port}" address = {count + 1} - syringe_volume = "XXX ml" # Specify syringe volume here!\n""" - ) + syringe_volume = "XXX ml" # Specify syringe volume here!\n""", + ), ) return dev_config diff --git a/src/flowchem/devices/hamilton/ml600_pump.py b/src/flowchem/devices/hamilton/ml600_pump.py index b68e2c4a..af368e0d 100644 --- a/src/flowchem/devices/hamilton/ml600_pump.py +++ b/src/flowchem/devices/hamilton/ml600_pump.py @@ -45,7 +45,7 @@ async def infuse(self, rate: str = "", volume: str = "") -> bool: if target_vol < 0: logger.error( f"Cannot infuse target volume {volume}! " - f"Only {current_volume} in the syringe!" + f"Only {current_volume} in the syringe!", ) return False @@ -70,7 +70,7 @@ async def withdraw(self, rate: str = "1 ml/min", volume: str | None = None) -> b if target_vol > self.hw_device.syringe_volume: logger.error( f"Cannot withdraw target volume {volume}! " - f"Max volume left is {self.hw_device.syringe_volume - current_volume}!" + f"Max volume left is {self.hw_device.syringe_volume - current_volume}!", ) return False diff --git a/src/flowchem/devices/harvardapparatus/_pumpio.py b/src/flowchem/devices/harvardapparatus/_pumpio.py index e8bbda57..ed82ca2f 100644 --- a/src/flowchem/devices/harvardapparatus/_pumpio.py +++ b/src/flowchem/devices/harvardapparatus/_pumpio.py @@ -5,8 +5,7 @@ import aioserial from loguru import logger -from flowchem.utils.exceptions import DeviceError -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import DeviceError, InvalidConfiguration class PumpStatus(Enum): @@ -33,7 +32,7 @@ class HarvardApparatusPumpIO: DEFAULT_CONFIG = {"timeout": 0.1, "baudrate": 115200} - def __init__(self, port: str, **kwargs): + def __init__(self, port: str, **kwargs) -> None: # Merge default settings, including serial, with provided ones. configuration = dict(HarvardApparatusPumpIO.DEFAULT_CONFIG, **kwargs) @@ -55,7 +54,7 @@ async def _write(self, command: Protocol11Command): await self._serial.write_async(command_msg.encode("ascii")) except aioserial.SerialException as serial_exception: raise InvalidConfiguration from serial_exception - logger.debug(f"Sent {repr(command_msg)}!") + logger.debug(f"Sent {command_msg!r}!") async def _read_reply(self) -> list[str]: """Read the pump reply from serial communication.""" @@ -63,7 +62,7 @@ async def _read_reply(self) -> list[str]: for line in await self._serial.readlines_async(): reply_string.append(line.decode("ascii").strip()) - logger.debug(f"Received {repr(line)}!") + logger.debug(f"Received {line!r}!") # First line is usually empty, but some prompts such as T* actually leak into this line sometimes. reply_string.pop(0) @@ -97,18 +96,19 @@ def check_for_errors(response_line, command_sent): "Argument error", "Out of range", ) - if any([e in response_line for e in error_string]): + if any(e in response_line for e in error_string): logger.error( f"Error for command {command_sent} on pump {command_sent.pump_address}!" - f"Reply: {response_line}" + f"Reply: {response_line}", ) raise DeviceError("Command error") async def write_and_read_reply( - self, command: Protocol11Command, return_parsed: bool = True + self, + command: Protocol11Command, + return_parsed: bool = True, ) -> list[str]: - """ - Send a command to the pump, read the replies and return it, optionally parsed. + """Send a command to the pump, read the replies and return it, optionally parsed. If unparsed reply is a List[str] with raw replies. If parsed reply is a List[str] w/ reply body (address and prompt removed from each line). diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index 195f8a92..62117cd7 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -8,21 +8,24 @@ from loguru import logger from pydantic import BaseModel -from flowchem.devices.harvardapparatus._pumpio import HarvardApparatusPumpIO -from flowchem.devices.harvardapparatus._pumpio import Protocol11Command -from flowchem.devices.harvardapparatus._pumpio import PumpStatus from flowchem import ureg from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.harvardapparatus.elite11_pump import Elite11PumpOnly -from flowchem.devices.harvardapparatus.elite11_pump import Elite11PumpWithdraw +from flowchem.devices.harvardapparatus._pumpio import ( + HarvardApparatusPumpIO, + Protocol11Command, + PumpStatus, +) +from flowchem.devices.harvardapparatus.elite11_pump import ( + Elite11PumpOnly, + Elite11PumpWithdraw, +) from flowchem.utils.exceptions import InvalidConfiguration from flowchem.utils.people import dario, jakob, wei_hsin class PumpInfo(BaseModel): - """ - Detailed pump info. e.g.: + """Detailed pump info. e.g.: ('Pump type Pump 11', 'Pump type string 11 ELITE I/W Single', @@ -69,8 +72,7 @@ def parse_pump_string(cls, metrics_text: list[str]): class Elite11(FlowchemDevice): - """ - Controls Harvard Apparatus Elite11 syringe pumps. + """Controls Harvard Apparatus Elite11 syringe pumps. The same protocol (Protocol11) can be used on other HA pumps, but is untested. Several pumps can be daisy-chained on the same serial connection, if so address 0 must be the first one. @@ -88,7 +90,7 @@ def __init__( address: int = 0, name: str = "", force: int = 30, - ): + ) -> None: super().__init__(name) # Create communication @@ -127,8 +129,7 @@ def from_config( force: int = 30, **serial_kwargs, ): - """ - Programmatic instantiation from configuration. + """Programmatic instantiation from configuration. Many pump can be present on the same serial port with different addresses. This shared list of PumpIO objects allow shared state in a borg-inspired way, avoiding singletons @@ -156,8 +157,7 @@ def from_config( ) async def initialize(self): - """ - Initialize Elite11. + """Initialize Elite11. Query model and version number of firmware to check if pump is connected. Responds with a load of stuff, but the last three characters @@ -184,7 +184,7 @@ async def initialize(self): await self.set_force(self._force) logger.info( - f"Connected to '{self.name}'! [{self.pump_io._serial.name}:{self.address}]" + f"Connected to '{self.name}'! [{self.pump_io._serial.name}:{self.address}]", ) version = await self.version() self.device_info.version = version.split(" ")[-1] @@ -201,7 +201,11 @@ def _parse_version(version_text: str) -> tuple[int, int, int]: return int(digits[0]), int(digits[1]), int(digits[2]) async def _send_command_and_read_reply( - self, command: str, parameter="", parse=True, multiline=False + self, + command: str, + parameter="", + parse=True, + multiline=False, ): """Send a command based on its template and return the corresponding reply as str.""" cmd = Protocol11Command( @@ -223,13 +227,15 @@ async def set_syringe_diameter(self, diameter: pint.Quantity): """Set syringe diameter. This can be set in the interval 1 mm to 33 mm.""" if not 1 * ureg.mm <= diameter <= 33 * ureg.mm: logger.warning( - f"Invalid diameter provided: {diameter}! [Valid range: 1-33 mm]" + f"Invalid diameter provided: {diameter}! [Valid range: 1-33 mm]", ) return False await self._send_command_and_read_reply( - "diameter", parameter=f"{diameter.to('mm').magnitude:.4f} mm" + "diameter", + parameter=f"{diameter.to('mm').magnitude:.4f} mm", ) + return None async def get_syringe_volume(self) -> str: """Return the syringe volume as str w/ units.""" @@ -238,12 +244,12 @@ async def get_syringe_volume(self) -> str: async def set_syringe_volume(self, volume: pint.Quantity): """Set the syringe volume in ml.""" await self._send_command_and_read_reply( - "svolume", parameter=f"{volume.m_as('ml'):.15f} m" + "svolume", + parameter=f"{volume.m_as('ml'):.15f} m", ) async def get_force(self): - """ - Pump force, in percentage. + """Pump force, in percentage. Manufacturer suggested values are: stainless steel: 100% @@ -257,12 +263,12 @@ async def get_force(self): async def set_force(self, force_percent: int): """Set the pump force, see `Elite11.get_force()` for suggested values.""" await self._send_command_and_read_reply( - "FORCE", parameter=str(int(force_percent)) + "FORCE", + parameter=str(int(force_percent)), ) async def _bound_rate_to_pump_limits(self, rate: str) -> float: - """ - Bound the rate provided to pump's limit. + """Bound the rate provided to pump's limit. These are function of the syringe diameter. NOTE: Infusion and withdraw limits are equal! @@ -286,14 +292,14 @@ async def _bound_rate_to_pump_limits(self, rate: str) -> float: if set_rate < lower_limit: logger.warning( f"The requested rate {rate} is lower than the minimum possible ({lower_limit})!" - f"Setting rate to {lower_limit} instead!" + f"Setting rate to {lower_limit} instead!", ) set_rate = lower_limit if set_rate > upper_limit: logger.warning( f"The requested rate {rate} is higher than the maximum possible ({upper_limit})!" - f"Setting rate to {upper_limit} instead!" + f"Setting rate to {upper_limit} instead!", ) set_rate = upper_limit @@ -302,7 +308,7 @@ async def _bound_rate_to_pump_limits(self, rate: str) -> float: async def version(self) -> str: """Return the current firmware version reported by the pump.""" return await self._send_command_and_read_reply( - "VER" + "VER", ) # '11 ELITE I/W Single 3.0.4 async def is_moving(self) -> bool: @@ -344,7 +350,8 @@ async def set_flow_rate(self, rate: str): """Set the infusion rate.""" set_rate = await self._bound_rate_to_pump_limits(rate=rate) await self._send_command_and_read_reply( - "irate", parameter=f"{set_rate:.10f} m/m" + "irate", + parameter=f"{set_rate:.10f} m/m", ) async def get_withdrawing_flow_rate(self) -> float: @@ -361,16 +368,15 @@ async def set_withdrawing_flow_rate(self, rate: str): async def set_target_volume(self, volume: str): """Set target volume in ml. If the volume is set to 0, the target is cleared.""" - # logger.info("Clear current infused volume!") await self._send_command_and_read_reply("civolume") target_volume = ureg.Quantity(volume) if target_volume.magnitude == 0: - # logger.info(f"send comment to clear the target volume") await self._send_command_and_read_reply("ctvolume") else: set_vol = await self._send_command_and_read_reply( - "tvolume", parameter=f"{target_volume.m_as('ml')} m" + "tvolume", + parameter=f"{target_volume.m_as('ml')} m", ) if "Argument error" in set_vol: warnings.warn( @@ -382,7 +388,8 @@ async def set_target_volume(self, volume: str): async def pump_info(self) -> PumpInfo: """Return pump info.""" parsed_multiline_response = await self._send_command_and_read_reply( - "metrics", multiline=True + "metrics", + multiline=True, ) return PumpInfo.parse_pump_string(parsed_multiline_response) @@ -396,7 +403,10 @@ def components(self): if __name__ == "__main__": pump = Elite11.from_config( - port="COM5", syringe_volume="10 ml", syringe_diameter="14.567 mm", address=3 + port="COM5", + syringe_volume="10 ml", + syringe_diameter="14.567 mm", + address=3, ) async def main(): diff --git a/src/flowchem/devices/harvardapparatus/elite11_finder.py b/src/flowchem/devices/harvardapparatus/elite11_finder.py index 976c31a0..7760b4f1 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_finder.py +++ b/src/flowchem/devices/harvardapparatus/elite11_finder.py @@ -4,14 +4,13 @@ from loguru import logger -from flowchem.devices.harvardapparatus.elite11 import Elite11 -from flowchem.devices.harvardapparatus.elite11 import HarvardApparatusPumpIO +from flowchem.devices.harvardapparatus.elite11 import Elite11, HarvardApparatusPumpIO from flowchem.utils.exceptions import InvalidConfiguration # noinspection PyProtectedMember def elite11_finder(serial_port) -> list[str]: - """Try to initialize an Elite11 on every available COM port. [Does not support daisy-chained Elite11!]""" + """Try to initialize an Elite11 on every available COM port. [Does not support daisy-chained Elite11!].""" logger.debug(f"Looking for Elite11 pumps on {serial_port}...") # Static counter for device type across different serial ports if "counter" not in elite11_finder.__dict__: @@ -32,10 +31,7 @@ def elite11_finder(serial_port) -> list[str]: # Parse status prompt pump = link._serial.readline().decode("ascii") - if pump[0:2].isdigit(): - address = int(pump[0:2]) - else: - address = 0 + address = int(pump[0:2]) if pump[0:2].isdigit() else 0 try: test_pump = Elite11( @@ -61,6 +57,6 @@ def elite11_finder(serial_port) -> list[str]: port = "{serial_port}" address = {address} syringe_diameter = "XXX mm" # Specify syringe diameter! - syringe_volume = "YYY ml" # Specify syringe volume!\n\n""" + syringe_volume = "YYY ml" # Specify syringe volume!\n\n""", ) return [cfg] diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index 696810d9..cad2e8e5 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -6,8 +6,8 @@ from loguru import logger from flowchem import ureg -from flowchem.components.technical.temperature import TempRange from flowchem.components.device_info import DeviceInfo +from flowchem.components.technical.temperature import TempRange from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.huber.huber_temperature_control import HuberTemperatureControl from flowchem.devices.huber.pb_command import PBCommand @@ -32,7 +32,7 @@ def __init__( name="", min_temp: float = -150, max_temp: float = 250, - ): + ) -> None: super().__init__(name) self._serial = aio self._min_t: float = min_temp @@ -46,8 +46,7 @@ def __init__( @classmethod def from_config(cls, port, name=None, **serial_kwargs): - """ - Create instance from config dict. Used by server to initialize obj from config. + """Create instance from config dict. Used by server to initialize obj from config. Only required parameter is 'port'. Optional 'loop' + others (see AioSerial()) """ @@ -69,7 +68,7 @@ async def initialize(self): if self.device_info.serial_number == "0": raise InvalidConfiguration("No reply received from Huber Chiller!") logger.debug( - f"Connected with Huber Chiller S/N {self.device_info.serial_number}" + f"Connected with Huber Chiller S/N {self.device_info.serial_number}", ) # Validate temperature limits @@ -77,14 +76,14 @@ async def initialize(self): if self._min_t < device_limits[0]: logger.warning( f"The device minimum temperature is higher than the specified minimum temperature!" - f"The lowest possible temperature will be {device_limits[0]} °C" + f"The lowest possible temperature will be {device_limits[0]} °C", ) self._min_t = device_limits[0] if self._max_t > device_limits[1]: logger.warning( f"The device maximum temperature is lower than the specified maximum temperature!" - f"The maximum possible temperature will be {device_limits[1]} °C" + f"The maximum possible temperature will be {device_limits[1]} °C", ) self._max_t = device_limits[1] @@ -92,9 +91,11 @@ async def _send_command_and_read_reply(self, command: str) -> str: """Send a command to the chiller and read the reply. Args: + ---- command (str): string to be transmitted Returns: + ------- str: reply received """ # Send command. Using PBCommand ensure command validation, see PBCommand.to_chiller() @@ -180,7 +181,8 @@ def _int_to_string(number: int) -> str: def components(self): """Return a TemperatureControl component.""" temperature_limits = TempRange( - min=ureg.Quantity(self._min_t), max=ureg.Quantity(self._max_t) + min=ureg.Quantity(self._min_t), + max=ureg.Quantity(self._max_t), ) return ( HuberTemperatureControl("temperature-control", self, temperature_limits), diff --git a/src/flowchem/devices/huber/huber_finder.py b/src/flowchem/devices/huber/huber_finder.py index ddc1b264..9573e6ed 100644 --- a/src/flowchem/devices/huber/huber_finder.py +++ b/src/flowchem/devices/huber/huber_finder.py @@ -31,6 +31,6 @@ def chiller_finder(serial_port) -> list[str]: f""" [device.huber-{chill._device_sn}] type = "HuberChiller" - port = "{serial_port}"\n""" - ) + port = "{serial_port}"\n""", + ), ] diff --git a/src/flowchem/devices/huber/pb_command.py b/src/flowchem/devices/huber/pb_command.py index bf2d59d0..f72d094c 100644 --- a/src/flowchem/devices/huber/pb_command.py +++ b/src/flowchem/devices/huber/pb_command.py @@ -86,7 +86,7 @@ def parse_status1(self) -> dict[str, bool]: } def parse_status2(self) -> dict[str, bool]: - """Parse response to status2 command and returns dict. See manufacturer docs for more info""" + """Parse response to status2 command and returns dict. See manufacturer docs for more info.""" bits = self.parse_bits() return { "controller_is_external": bits[0], diff --git a/src/flowchem/devices/knauer/__init__.py b/src/flowchem/devices/knauer/__init__.py index 837a35a0..a5d8539e 100644 --- a/src/flowchem/devices/knauer/__init__.py +++ b/src/flowchem/devices/knauer/__init__.py @@ -4,7 +4,6 @@ from .knauer_finder import knauer_finder from .knauer_valve import KnauerValve - __all__ = [ "knauer_finder", "AzuraCompact", diff --git a/src/flowchem/devices/knauer/_common.py b/src/flowchem/devices/knauer/_common.py index 17e37e90..79432d5f 100644 --- a/src/flowchem/devices/knauer/_common.py +++ b/src/flowchem/devices/knauer/_common.py @@ -3,9 +3,10 @@ from loguru import logger -from .knauer_finder import autodiscover_knauer from flowchem.utils.exceptions import InvalidConfiguration +from .knauer_finder import autodiscover_knauer + class KnauerEthernetDevice: """Common base class for shared logic across Knauer pumps and valves.""" @@ -14,9 +15,8 @@ class KnauerEthernetDevice: BUFFER_SIZE = 1024 _id_counter = 0 - def __init__(self, ip_address, mac_address, **kwargs): - """ - Knauer Ethernet Device - either pump or valve. + def __init__(self, ip_address, mac_address, **kwargs) -> None: + """Knauer Ethernet Device - either pump or valve. If a MAC address is given, it is used to autodiscover the IP address. Otherwise, the IP address must be given. @@ -24,6 +24,7 @@ def __init__(self, ip_address, mac_address, **kwargs): Note that for configuration files, the MAC address is preferred as it is static. Args: + ---- ip_address: device IP address (only 1 of either IP or MAC address is needed) mac_address: device MAC address (only 1 of either IP or MAC address is needed) name: name of device (optional) diff --git a/src/flowchem/devices/knauer/azura_compact.py b/src/flowchem/devices/knauer/azura_compact.py index e07f3c67..618a79f9 100644 --- a/src/flowchem/devices/knauer/azura_compact.py +++ b/src/flowchem/devices/knauer/azura_compact.py @@ -59,7 +59,7 @@ def __init__( max_pressure: str = "", min_pressure: str = "", name="", - ): + ) -> None: super().__init__(ip_address, mac_address, name=name) self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], @@ -115,8 +115,7 @@ def error_present(reply: str) -> bool: return True async def _transmit_and_parse_reply(self, message: str) -> str: - """ - Send command and receive reply. + """Send command and receive reply. Deals with all communication based stuff and checks that the valve is of expected type. :param message: @@ -146,10 +145,12 @@ async def _transmit_and_parse_reply(self, message: str) -> str: return reply async def create_and_send_command( - self, message, setpoint: int | None = None, setpoint_range: tuple | None = None + self, + message, + setpoint: int | None = None, + setpoint_range: tuple | None = None, ): - """ - Create and sends a message from the command. + """Create and sends a message from the command. If setpoint is given, then the command is appended with :value If not setpoint is given, a "?" is added for getter syntax @@ -166,13 +167,13 @@ async def create_and_send_command( if setpoint_range: if setpoint in range(*setpoint_range): return await self._transmit_and_parse_reply( - message + ":" + str(setpoint) + message + ":" + str(setpoint), ) warnings.warn( f"The setpoint provided {setpoint} is not valid for the command " f"{message}!\n Accepted range is: {setpoint_range}.\n" - f"Command ignored" + f"Command ignored", ) return "" @@ -227,6 +228,7 @@ async def set_flow_rate(self, rate: pint.Quantity): """Set flow rate. Args: + ---- rate (str): value with units """ await self.create_and_send_command( @@ -275,7 +277,9 @@ async def set_minimum_motor_current(self, setpoint=None): command = IMIN10 if self._headtype == AzuraPumpHeads.FLOWRATE_TEN_ML else IMIN50 reply = await self.create_and_send_command( - command, setpoint=setpoint, setpoint_range=(0, 101) + command, + setpoint=setpoint, + setpoint_range=(0, 101), ) logger.debug(f"Minimum motor current set to {setpoint}, returns {reply}") @@ -285,8 +289,7 @@ async def is_start_in_required(self): return not bool(int(runlevel)) async def require_start_in(self, value: bool = True): - """ - Configure START IN. If required, the pump starts only if the STARTIN pin is shortened to GND. + """Configure START IN. If required, the pump starts only if the STARTIN pin is shortened to GND. True = Pump starts the flow at short circuit contact only. (Start In <> Ground). [0] False = Pump starts the flow without a short circuit contact. (Start In <> Ground). [1] @@ -301,8 +304,7 @@ async def is_autostart_enabled(self): return bool(int(reply)) async def enable_autostart(self, value: bool = True): - """ - Set the default behaviour of the pump upon power on. + """Set the default behaviour of the pump upon power on. :param value: False: pause pump after switch on. True: start pumping with previous flow rate at startup :return: device message @@ -320,7 +322,9 @@ async def set_adjusting_factor(self, setpoint: int | None = None): """Set the adjust parameter. Not clear what it is.""" command = ADJ10 if self._headtype == AzuraPumpHeads.FLOWRATE_TEN_ML else ADJ50 reply = await self.create_and_send_command( - command, setpoint=setpoint, setpoint_range=(0, 2001) + command, + setpoint=setpoint, + setpoint_range=(0, 2001), ) logger.debug(f"Adjusting factor of set to {setpoint}, returns {reply}") @@ -333,7 +337,9 @@ async def set_correction_factor(self, setpoint=None): """Set the correction factor. Not clear what it is.""" command = CORR10 if self._headtype == AzuraPumpHeads.FLOWRATE_TEN_ML else CORR50 reply = await self.create_and_send_command( - command, setpoint=setpoint, setpoint_range=(0, 301) + command, + setpoint=setpoint, + setpoint_range=(0, 301), ) logger.debug(f"Correction factor set to {setpoint}, returns {reply}") diff --git a/src/flowchem/devices/knauer/azura_compact_pump.py b/src/flowchem/devices/knauer/azura_compact_pump.py index 4e22e88a..4b9b0506 100644 --- a/src/flowchem/devices/knauer/azura_compact_pump.py +++ b/src/flowchem/devices/knauer/azura_compact_pump.py @@ -5,7 +5,7 @@ from loguru import logger -from ... import ureg +from flowchem import ureg if TYPE_CHECKING: from .azura_compact import AzuraCompact @@ -23,7 +23,7 @@ def isfloat(rate: str) -> bool: class AzuraCompactPump(HPLCPump): hw_device: AzuraCompact # for typing's sake - def __init__(self, name: str, hw_device: AzuraCompact): + def __init__(self, name: str, hw_device: AzuraCompact) -> None: """Initialize component.""" super().__init__(name, hw_device) diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index ad243451..10a6a04c 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -1,20 +1,18 @@ """Control module for the Knauer DAD.""" import asyncio +from typing import TYPE_CHECKING from loguru import logger -from typing import Union from flowchem.components.device_info import DeviceInfo +from flowchem.devices.flowchem_device import FlowchemDevice +from flowchem.devices.knauer._common import KnauerEthernetDevice from flowchem.devices.knauer.dad_component import ( DADChannelControl, KnauerDADLampControl, ) -from flowchem.utils.people import dario, wei_hsin, jakob - -from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.knauer._common import KnauerEthernetDevice from flowchem.utils.exceptions import InvalidConfiguration -from typing import TYPE_CHECKING +from flowchem.utils.people import dario, jakob, wei_hsin if TYPE_CHECKING: from flowchem.components.base_component import FlowchemComponent @@ -38,7 +36,7 @@ def __init__( turn_on_d2: bool = True, turn_on_halogen: bool = True, display_control: bool = True, - ): + ) -> None: super().__init__(ip_address, mac_address, name=name) self.eol = b"\n\r" self._d2 = turn_on_d2 @@ -67,11 +65,7 @@ async def initialize(self): # to avoid the frequent switch the lamp on and off, # if self._d2: - # await self.lamp("d2", True) - # await asyncio.sleep(1) # if self._hal: - # await self.lamp("hal", True) - # await asyncio.sleep(15) if self._control: await self.display_control(True) @@ -83,7 +77,6 @@ async def initialize(self): await self.set_wavelength(1, 254) await self.bandwidth(8) - # await self.integration_time("75") logger.info("set channel 1 : WL = 514 nm, BW = 20nm ") async def d2(self, state: bool = True) -> str: @@ -98,8 +91,8 @@ async def hal(self, state: bool = True) -> str: self._state_hal = state return await self._send_and_receive(cmd) - async def lamp(self, lamp: str, state: Union[bool, str] = "REQUEST") -> str: - """Turn on or off the lamp, or request lamp state""" + async def lamp(self, lamp: str, state: bool | str = "REQUEST") -> str: + """Turn on or off the lamp, or request lamp state.""" if type(state) == bool: state = "ON" if state else "OFF" @@ -115,28 +108,28 @@ async def lamp(self, lamp: str, state: Union[bool, str] = "REQUEST") -> str: _reverse_lampstatus_mapping = {v: k for k, v in lampstatus_mapping.items()} cmd = self.cmd.LAMP.format( - lamp=lamp_mapping[lamp], state=lampstatus_mapping[state] + lamp=lamp_mapping[lamp], + state=lampstatus_mapping[state], ) response = await self._send_and_receive(cmd) # 'LAMP_D2:0' return response # if response.isnumeric() else _reverse_lampstatus_mapping[response[response.find(":") + 1:]] - # return response if not response.isnumeric() else _reverse_lampstatus_mapping[response] async def serial_num(self) -> str: - """Serial number""" + """Serial number.""" return await self._send_and_receive(self.cmd.SERIAL) async def identify(self) -> str: """Get the instrument information CATEGORY (=3), MANUFACTURER, MODEL_NR, SERNUM, VERSION, MODIFICATION - Example: 3,KNAUER,PDA-1,CSA094400001,2,01 + Example: 3,KNAUER,PDA-1,CSA094400001,2,01. """ return await self._send_and_receive(self.cmd.IDENTIFY) async def info(self) -> str: """Get the instrument information NUMBER OF PIXEL (256, 512, 1024), SPECTRAL RANGE(“UV”, “VIS”, “UV-VIS”), - HARDVARE VERSION, YEAR OF PRODUCTION,WEEK OF PRODUCTION,,CALIBR. A,CALIBR. B,, CALIBR. C + HARDVARE VERSION, YEAR OF PRODUCTION,WEEK OF PRODUCTION,,CALIBR. A,CALIBR. B,, CALIBR. C. """ return await self._send_and_receive(self.cmd.INFO) @@ -146,7 +139,7 @@ async def status(self): D2 Lamp (OFF = 0, ON = 1, HEAT= 2, ERROR = 3), HAL Lamp (OFF = 0, ON = 1, ERROR = 3), Shutter(OFF = 0, ON=1, FILTER=2), External Error IN, External Start IN, External Autozero IN, - Event1 OUT, Event2 OUT, Event3 OUT, Valve OUT, Error Code + Event1 OUT, Event2 OUT, Event3 OUT, Valve OUT, Error Code. """ return await self._send_and_receive(self.cmd.STATUS) @@ -170,7 +163,7 @@ async def shutter(self, shutter: str) -> str: async def signal_type(self, s_type: str = "microAU") -> str: """Set and get the type of signal shown on the display 0 = signal is Absorption Units - 1 = signal is intensity + 1 = signal is intensity. """ type_mapping = {"REQUEST": "?", "microAU": "0", "intensity": "1"} _reverse_type_mapping = {v: k for k, v in type_mapping.items()} @@ -188,33 +181,28 @@ async def get_wavelength(self, channel: int) -> int: return int(await self._send_and_receive(cmd)) async def set_wavelength(self, channel: int, wavelength: int) -> str: - """set and read wavelength""" + """Set and read wavelength.""" cmd = self.cmd.WAVELENGTH.format(channel=channel, wavelength=wavelength) return await self._send_and_receive(cmd) async def set_signal(self, channel: int, signal: int = 0): - """set signal to specific number""" + """Set signal to specific number.""" cmd = self.cmd.SIGNAL.format(channel=channel, signal=signal) return await self._send_and_receive(cmd) async def read_signal(self, channel: int) -> float: """Read signal - -9999999 to +9999999 (μAU, SIG_SRC = 0); 0 to 1000000 (INT, SIG_SRC = 1) + -9999999 to +9999999 (μAU, SIG_SRC = 0); 0 to 1000000 (INT, SIG_SRC = 1). """ cmd = self.cmd.SIGNAL.format(channel=channel, signal="?") response = await self._send_and_receive(cmd) return float(response[5:]) / 1000 # in order of running keep alive in the background, response might get 'STATUS:0,1,0,1,0,0,0,0,0,0,0,0' - # try: - # return float(response[5:]) / 1000 # mAu # # -10000000 if the lamp is not ready - # except ValueError: - # logger.warning("ValueError:the reply is not a float..") - # return None - async def integration_time(self, integ_time: Union[int | str] = "?") -> str | int: - """set and read the integration time in 10 - 2000 ms""" + async def integration_time(self, integ_time: int | str = "?") -> str | int: + """Set and read the integration time in 10 - 2000 ms.""" cmd = self.cmd.INTEGRATION_TIME.format(time=integ_time) response = await self._send_and_receive(cmd) try: @@ -222,9 +210,10 @@ async def integration_time(self, integ_time: Union[int | str] = "?") -> str | in except ValueError: return response - async def bandwidth(self, bw: Union[str | int]) -> str | int: - """set bandwidth in the range of 4 to 25 nm - read the setting of bandwidth""" + async def bandwidth(self, bw: str | int) -> str | int: + """Set bandwidth in the range of 4 to 25 nm + read the setting of bandwidth. + """ if type(bw) == int: cmd = self.cmd.BANDWIDTH.format(bandwidth=bw) return await self._send_and_receive(cmd) @@ -245,19 +234,21 @@ def components(self) -> list["FlowchemComponent"]: KnauerDADLampControl("hal", self), ] list_of_components.extend( - [DADChannelControl(f"channel{n + 1}", self, n + 1) for n in range(4)] + [DADChannelControl(f"channel{n + 1}", self, n + 1) for n in range(4)], ) return list_of_components if __name__ == "__main__": k_dad = KnauerDAD( - ip_address="192.168.1.123", turn_on_d2=False, turn_on_halogen=True + ip_address="192.168.1.123", + turn_on_d2=False, + turn_on_halogen=True, ) async def main(dad): - """test function""" + """Test function.""" await dad.initialize() lamp_d2, lamp_hal, ch1, ch2, ch3, ch4 = dad.components() bg1 = dad.bg_keep_connect() diff --git a/src/flowchem/devices/knauer/dad_component.py b/src/flowchem/devices/knauer/dad_component.py index 4ee84dad..3a0dc444 100644 --- a/src/flowchem/devices/knauer/dad_component.py +++ b/src/flowchem/devices/knauer/dad_component.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING -from ...components.sensors.photo import PhotoSensor +from flowchem.components.sensors.photo import PhotoSensor if TYPE_CHECKING: from flowchem.devices.knauer.dad import KnauerDAD @@ -13,7 +13,7 @@ class KnauerDADLampControl(PowerSwitch): hw_device: KnauerDAD - def __init__(self, name: str, hw_device: KnauerDAD): + def __init__(self, name: str, hw_device: KnauerDAD) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) self.lamp = name @@ -21,16 +21,13 @@ def __init__(self, name: str, hw_device: KnauerDAD): self.add_api_route("/status", self.get_status, methods=["GET"]) async def get_status(self) -> str: - """Get status of the instrument""" + """Get status of the instrument.""" return await self.hw_device.status() async def get_lamp(self): """Lamp status.""" return await self.hw_device.lamp(self.lamp) # return { - # "d2": self.hw_device._state_d2, - # "hal": self.hw_device._state_hal, - # } async def power_on(self): """Turn power on.""" @@ -54,7 +51,7 @@ async def set_lamp(self, state: str) -> str: class DADChannelControl(PhotoSensor): hw_device: KnauerDAD - def __init__(self, name: str, hw_device: KnauerDAD, channel: int): + def __init__(self, name: str, hw_device: KnauerDAD, channel: int) -> None: """Create a DADControl object.""" super().__init__(name, hw_device) self.channel = channel @@ -62,17 +59,19 @@ def __init__(self, name: str, hw_device: KnauerDAD, channel: int): # additional parameters self.add_api_route("/set-wavelength", self.set_wavelength, methods=["PUT"]) self.add_api_route( - "/set-integration-time", self.set_integration_time, methods=["PUT"] + "/set-integration-time", + self.set_integration_time, + methods=["PUT"], ) self.add_api_route("/set-bandwidth", self.set_bandwidth, methods=["PUT"]) # Ontology: diode array detector self.component_info.owl_subclass_of.append( - "http://purl.obolibrary.org/obo/CHMO_0002503" + "http://purl.obolibrary.org/obo/CHMO_0002503", ) async def calibrate_zero(self): - """re-calibrate the sensors to their factory zero points""" + """re-calibrate the sensors to their factory zero points.""" await self.hw_device.set_signal(self.channel) async def acquire_signal(self) -> float: @@ -80,26 +79,26 @@ async def acquire_signal(self) -> float: return await self.hw_device.read_signal(self.channel) async def set_wavelength(self, wavelength: int): - """set acquisition wavelength (nm) in the range of 0-999 nm""" + """Set acquisition wavelength (nm) in the range of 0-999 nm.""" return await self.hw_device.set_wavelength(self.channel, wavelength) async def set_integration_time(self, int_time: int): - """set integration time in the range of 10 - 2000 ms""" + """Set integration time in the range of 10 - 2000 ms.""" return await self.hw_device.integration_time(int_time) async def set_bandwidth(self, bandwidth: int): - """set bandwidth in the range of 4 to 25 nm""" + """Set bandwidth in the range of 4 to 25 nm.""" return await self.hw_device.bandwidth(bandwidth) async def set_shutter(self, status: str): - """set the shutter to "CLOSED" or "OPEN" or "FILTER".""" + """Set the shutter to "CLOSED" or "OPEN" or "FILTER".""" return await self.hw_device.shutter(status) async def power_on(self) -> str: - """check the lamp status""" + """Check the lamp status.""" return f"d2 lamp is {await self.hw_device.lamp('d2')}; halogen lamp is {await self.hw_device.lamp('hal')}" async def power_off(self) -> str: - """deactivate the measurement channel""" + """Deactivate the measurement channel.""" reply = await self.hw_device.set_wavelength(self.channel, 0) return reply diff --git a/src/flowchem/devices/knauer/knauer_finder.py b/src/flowchem/devices/knauer/knauer_finder.py index adcd9e9b..5e8f91f8 100644 --- a/src/flowchem/devices/knauer/knauer_finder.py +++ b/src/flowchem/devices/knauer/knauer_finder.py @@ -4,8 +4,8 @@ import socket import sys from textwrap import dedent -from anyio.from_thread import start_blocking_portal +from anyio.from_thread import start_blocking_portal from loguru import logger from flowchem.vendor.getmac import get_mac_address @@ -18,7 +18,7 @@ class BroadcastProtocol(asyncio.DatagramProtocol): """See `https://gist.github.com/yluthu/4f785d4546057b49b56c`.""" - def __init__(self, target: Address, response_queue: queue.Queue): + def __init__(self, target: Address, response_queue: queue.Queue) -> None: self.target = target self.loop = asyncio.get_event_loop() self._queue = response_queue @@ -99,11 +99,7 @@ def _get_local_ip() -> str: hostname = socket.gethostname() # Only accept local IP - if ( - hostname.startswith("192.168") - or hostname.startswith("192.168") - or hostname.startswith("100.") - ): + if hostname.startswith(("192.168", "192.168", "100.")): return socket.gethostbyname(hostname) else: return "" @@ -137,21 +133,24 @@ async def send_broadcast_and_receive_replies(source_ip: str): def autodiscover_knauer(source_ip: str = "") -> dict[str, str]: - """ - Automatically find Knauer ethernet device on the network and returns the IP associated to each MAC address. + """Automatically find Knauer ethernet device on the network and returns the IP associated to each MAC address. Note that the MAC is the key here as it is the parameter used in configuration files. Knauer devices only support DHCP so static IPs are not an option. + Args: + ---- source_ip: source IP for autodiscover (only relevant if multiple network interfaces are available!) + Returns: - List of tuples (IP, MAC, device_type), one per device replying to autodiscover + ------- + List of tuples (IP, MAC, device_type), one per device replying to autodiscover. """ # Define source IP resolving local hostname. if not source_ip: source_ip = _get_local_ip() if not source_ip: logger.warning("Please provide a valid source IP for broadcasting.") - return dict() + return {} logger.info(f"Starting detection from IP {source_ip}") with start_blocking_portal() as portal: @@ -194,8 +193,8 @@ def knauer_finder(source_ip=None): type = "AzuraCompact" ip_address = "{ip}" # MAC address during discovery: {mac_address} # max_pressure = "XX bar" - # min_pressure = "XX bar"\n\n""" - ) + # min_pressure = "XX bar"\n\n""", + ), ) case "KnauerValve": dev_config.add( @@ -203,8 +202,8 @@ def knauer_finder(source_ip=None): f""" [device.valve-{mac_address[-8:-6] + mac_address[-5:-3] + mac_address[-2:]}] type = "KnauerValve" - ip_address = "{ip}" # MAC address during discovery: {mac_address}\n\n""" - ) + ip_address = "{ip}" # MAC address during discovery: {mac_address}\n\n""", + ), ) case "FlowIR": dev_config.add( @@ -213,8 +212,8 @@ def knauer_finder(source_ip=None): [device.flowir] type = "IcIR" url = "opc.tcp://localhost:62552/iCOpcUaServer" # Default, replace with IP of PC with IcIR - template = "some-template.iCIRTemplate" # Replace with valid template name, see docs.\n\n""" - ) + template = "some-template.iCIRTemplate" # Replace with valid template name, see docs.\n\n""", + ), ) return dev_config diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index 5f85ed19..a0b5f901 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -7,10 +7,12 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.knauer._common import KnauerEthernetDevice -from flowchem.devices.knauer.knauer_valve_component import Knauer12PortDistribution -from flowchem.devices.knauer.knauer_valve_component import Knauer16PortDistribution -from flowchem.devices.knauer.knauer_valve_component import Knauer6PortDistribution -from flowchem.devices.knauer.knauer_valve_component import KnauerInjectionValve +from flowchem.devices.knauer.knauer_valve_component import ( + Knauer6PortDistribution, + Knauer12PortDistribution, + Knauer16PortDistribution, + KnauerInjectionValve, +) from flowchem.utils.exceptions import DeviceError from flowchem.utils.people import dario, jakob, wei_hsin @@ -25,8 +27,7 @@ class KnauerValveHeads(Enum): class KnauerValve(KnauerEthernetDevice, FlowchemDevice): - """ - Control Knauer multi position valves. + """Control Knauer multi position valves. Valve type can be 6, 12, 16, or it can be 6 ports, two positions, which will be simply 2 (two states) in this case, the response for T is LI. Load and inject can be switched by sending L or I @@ -36,7 +37,7 @@ class KnauerValve(KnauerEthernetDevice, FlowchemDevice): DIP switch for valve selection """ - def __init__(self, ip_address=None, mac_address=None, **kwargs): + def __init__(self, ip_address=None, mac_address=None, **kwargs) -> None: super().__init__(ip_address, mac_address, **kwargs) self.eol = b"\r\n" self.device_info = DeviceInfo( @@ -62,31 +63,31 @@ def handle_errors(reply: str): if "E0" in reply: DeviceError( "The valve refused to switch.\n" - "Replace the rotor seals of the valve or replace the motor drive unit." + "Replace the rotor seals of the valve or replace the motor drive unit.", ) elif "E1" in reply: DeviceError( "Skipped switch: motor current too high!\n" - "Replace the rotor seals of the valve." + "Replace the rotor seals of the valve.", ) elif "E2" in reply: DeviceError( "Change from one valve position to the next takes too long.\n" - "Replace the rotor seals of the valve." + "Replace the rotor seals of the valve.", ) elif "E3" in reply: DeviceError( "Switch position of DIP 3 and 4 are not correct.\n" - "Correct DIP switch 3 and 4." + "Correct DIP switch 3 and 4.", ) elif "E4" in reply: DeviceError( - "Valve homing position not recognized.\n" "Readjust sensor board." + "Valve homing position not recognized.\n" "Readjust sensor board.", ) elif "E5" in reply: DeviceError( "Switch position of DIP 1 and 2 are not correct.\n" - "Correct DIP switch 1 and 2." + "Correct DIP switch 1 and 2.", ) elif "E6" in reply: DeviceError("Memory error.\n" "Power-cycle valve!") @@ -94,13 +95,14 @@ def handle_errors(reply: str): DeviceError("Unspecified error detected!") async def _transmit_and_parse_reply(self, message: str) -> str: - """ - Send command, receive reply and parse it. + """Send command, receive reply and parse it. Args: + ---- message (str): command to be sent Returns: + ------- str: reply """ reply = await self._send_and_receive(message) @@ -117,8 +119,7 @@ async def _transmit_and_parse_reply(self, message: str) -> str: return reply async def get_valve_type(self): - """ - Get valve type, if returned value is not supported throws an error. + """Get valve type, if returned value is not supported throws an error. Note that this method is called during initialize(), therefore it is in line with the general philosophy of the module to 'fail early' upon init and avoiding @@ -150,7 +151,7 @@ async def set_raw_position(self, position: str) -> bool: def components(self): """Create the right type of Valve components based on head type.""" - match self.device_info.additional_info["valve-type"]: # noqa + match self.device_info.additional_info["valve-type"]: case KnauerValveHeads.SIX_PORT_TWO_POSITION: return (KnauerInjectionValve("injection-valve", self),) case KnauerValveHeads.SIX_PORT_SIX_POSITION: diff --git a/src/flowchem/devices/knauer/knauer_valve_component.py b/src/flowchem/devices/knauer/knauer_valve_component.py index 86933fdb..b61fdf7a 100644 --- a/src/flowchem/devices/knauer/knauer_valve_component.py +++ b/src/flowchem/devices/knauer/knauer_valve_component.py @@ -5,9 +5,11 @@ if TYPE_CHECKING: from .knauer_valve import KnauerValve -from flowchem.components.valves.distribution_valves import SixPortDistribution -from flowchem.components.valves.distribution_valves import SixteenPortDistribution -from flowchem.components.valves.distribution_valves import TwelvePortDistribution +from flowchem.components.valves.distribution_valves import ( + SixPortDistribution, + SixteenPortDistribution, + TwelvePortDistribution, +) from flowchem.components.valves.injection_valves import SixPortTwoPosition diff --git a/src/flowchem/devices/list_known_device_type.py b/src/flowchem/devices/list_known_device_type.py index 5917b943..3d603c18 100644 --- a/src/flowchem/devices/list_known_device_type.py +++ b/src/flowchem/devices/list_known_device_type.py @@ -12,7 +12,7 @@ def is_device_class(test_object): """Return true if the object is a subclass of FlowchemDevice.""" if getattr(test_object, "__module__", None) is None: - return + return None return ( inspect.isclass(test_object) and issubclass(test_object, FlowchemDevice) @@ -34,8 +34,7 @@ def autodiscover_first_party() -> dict[str, Any]: def autodiscover_third_party() -> dict[str, Any]: - """ - Get classes from packages with a `flowchem.devices` entrypoint. + """Get classes from packages with a `flowchem.devices` entrypoint. A plugin structure can be used to add devices from an external package via setuptools entry points. See https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-package-metadata @@ -59,5 +58,5 @@ def autodiscover_device_classes(): if __name__ == "__main__": logger.debug( - f"The following device types were found: {list(autodiscover_device_classes().keys())}" + f"The following device types were found: {list(autodiscover_device_classes().keys())}", ) diff --git a/src/flowchem/devices/magritek/_msg_maker.py b/src/flowchem/devices/magritek/_msg_maker.py index 69601d93..0a3d8864 100644 --- a/src/flowchem/devices/magritek/_msg_maker.py +++ b/src/flowchem/devices/magritek/_msg_maker.py @@ -15,8 +15,7 @@ def create_message(sub_element_name, attributes=None): def set_attribute(name, value="") -> etree._Element: - """ - Create a Set . + """Create a Set . Used for name = {Solvent | Sample} + indirectly by UserData and DataFolder. """ @@ -27,8 +26,7 @@ def set_attribute(name, value="") -> etree._Element: def get_request(name) -> etree._Element: - """ - Create a Get element. + """Create a Get element. Used for name = {Solvent | Sample | UserData} + indirectly by UserData and DataFolder. """ diff --git a/src/flowchem/devices/magritek/_parser.py b/src/flowchem/devices/magritek/_parser.py index 80a35776..35e86cfd 100644 --- a/src/flowchem/devices/magritek/_parser.py +++ b/src/flowchem/devices/magritek/_parser.py @@ -23,7 +23,7 @@ def parse_status_notification(xml_message: etree._Element): assert status_notification is not None, "a StatusNotification tree is needed" # StatusNotification child can be (w/ submsg), , or - match status_notification[0].tag, status_notification[0].get("status"): # noqa + match status_notification[0].tag, status_notification[0].get("status"): case ["State", "Running"]: status = StatusNotification.STARTED case ["State", "Ready"]: diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 6d05277c..58c5e3ca 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -11,16 +11,19 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.magritek._msg_maker import create_message -from flowchem.devices.magritek._msg_maker import create_protocol_message -from flowchem.devices.magritek._msg_maker import get_request -from flowchem.devices.magritek._msg_maker import set_attribute -from flowchem.devices.magritek._msg_maker import set_data_folder -from flowchem.devices.magritek._parser import parse_status_notification -from flowchem.devices.magritek._parser import StatusNotification +from flowchem.devices.magritek._msg_maker import ( + create_message, + create_protocol_message, + get_request, + set_attribute, + set_data_folder, +) +from flowchem.devices.magritek._parser import ( + StatusNotification, + parse_status_notification, +) from flowchem.devices.magritek.spinsolve_control import SpinsolveControl -from flowchem.devices.magritek.utils import create_folder_mapper -from flowchem.devices.magritek.utils import get_my_docs_path +from flowchem.devices.magritek.utils import create_folder_mapper, get_my_docs_path from flowchem.utils.people import dario, jakob, wei_hsin __all__ = ["Spinsolve"] @@ -39,7 +42,7 @@ def __init__( solvent: str | None = "Chloroform-d1", sample_name: str | None = "Unnamed automated experiment", remote_to_local_mapping: list[str] | None = None, - ): + ) -> None: """Control a Spinsolve instance via HTTP XML API.""" super().__init__(name) @@ -104,7 +107,8 @@ async def initialize(self): """Initiate connection with a running Spinsolve instance.""" try: self._io_reader, self._io_writer = await asyncio.open_connection( - self.host, self.port + self.host, + self.port, ) logger.debug(f"Connected to {self.host}:{self.port}") except OSError as e: @@ -114,7 +118,8 @@ async def initialize(self): # Start reader thread self.reader = asyncio.create_task( - self.connection_listener(), name="Connection listener" + self.connection_listener(), + name="Connection listener", ) # This request is used to check if the instrument is connected @@ -127,7 +132,7 @@ async def initialize(self): hardware_type = hw_info.find(".//SpinsolveType").text self.device_info.additional_info["hardware_type"] = hardware_type logger.debug( - f"Connected to model {hardware_type}, SW: {self.device_info.version}" + f"Connected to model {hardware_type}, SW: {self.device_info.version}", ) # Load available protocols @@ -137,7 +142,7 @@ async def initialize(self): if version.parse(self.device_info.version) < version.parse("1.18.1.3062"): warnings.warn( f"Spinsolve v. {self.device_info.version} is not supported!" - f"Upgrade to a more recent version! (at least 1.18.1.3062)" + f"Upgrade to a more recent version! (at least 1.18.1.3062)", ) await self.set_data_folder(self._data_folder) @@ -163,7 +168,7 @@ async def connection_listener(self): self.schema.validate(parsed_tree) except etree.XMLSyntaxError as syntax_error: warnings.warn( - f"Invalid XML received! [Validation error: {syntax_error}]" + f"Invalid XML received! [Validation error: {syntax_error}]", ) # Add to reply queue of the given tag-type @@ -217,7 +222,8 @@ async def _transmit(self, message: bytes): """Send the message to the spectrometer.""" # This assertion is here for mypy ;) assert isinstance( - self._io_writer, asyncio.StreamWriter + self._io_writer, + asyncio.StreamWriter, ), "The connection was not initialized!" self._io_writer.write(message) await self._io_writer.drain() @@ -253,10 +259,12 @@ async def load_protocols(self): } async def run_protocol( - self, name, background_tasks: BackgroundTasks, options=None + self, + name, + background_tasks: BackgroundTasks, + options=None, ) -> int: - """ - Run a protocol. + """Run a protocol. Return the ID of the protocol (needed to get results via `get_result_folder`). -1 for errors. """ @@ -265,7 +273,7 @@ async def run_protocol( if name not in self.protocols: warnings.warn( f"The protocol requested '{name}' is not available on the spectrometer!\n" - f"Valid options are: {pp.pformat(sorted(self.protocols.keys()))}" + f"Valid options are: {pp.pformat(sorted(self.protocols.keys()))}", ) return -1 @@ -354,7 +362,7 @@ def _validate_protocol_request(self, protocol_name, protocol_options) -> dict: if option_name not in valid_options: protocol_options.pop(option_name) warnings.warn( - f"Invalid option {option_name} for protocol {protocol_name} -- DROPPED!" + f"Invalid option {option_name} for protocol {protocol_name} -- DROPPED!", ) continue @@ -369,7 +377,7 @@ def _validate_protocol_request(self, protocol_name, protocol_options) -> dict: protocol_options.pop(option_name) warnings.warn( f"Invalid value {option_value} for option {option_name} in protocol {protocol_name}" - f" -- DROPPED!" + f" -- DROPPED!", ) # Returns the dict with only valid options/value pairs @@ -380,7 +388,7 @@ def shim(self): raise NotImplementedError("Use run protocol with a shimming protocol instead!") def components(self): - """Return SpinsolveControl""" + """Return SpinsolveControl.""" return (SpinsolveControl("nmr-control", self),) diff --git a/src/flowchem/devices/magritek/spinsolve_control.py b/src/flowchem/devices/magritek/spinsolve_control.py index 12a82cd6..d3dcc17e 100644 --- a/src/flowchem/devices/magritek/spinsolve_control.py +++ b/src/flowchem/devices/magritek/spinsolve_control.py @@ -13,7 +13,7 @@ class SpinsolveControl(NMRControl): hw_device: Spinsolve # for typing's sake - def __init__(self, name: str, hw_device: Spinsolve): # type:ignore + def __init__(self, name: str, hw_device: Spinsolve) -> None: # type:ignore """HPLC Control component. Sends methods, starts run, do stuff.""" super().__init__(name, hw_device) # Solvent @@ -27,25 +27,35 @@ def __init__(self, name: str, hw_device: Spinsolve): # type:ignore self.add_api_route("/user-data", self.hw_device.set_user_data, methods=["PUT"]) # Protocols self.add_api_route( - "/protocol-list", self.hw_device.list_protocols, methods=["GET"] + "/protocol-list", + self.hw_device.list_protocols, + methods=["GET"], ) self.add_api_route( - "/spectrum-folder", self.hw_device.get_result_folder, methods=["GET"] + "/spectrum-folder", + self.hw_device.get_result_folder, + methods=["GET"], ) self.add_api_route( - "/is-busy", self.hw_device.is_protocol_running, methods=["GET"] + "/is-busy", + self.hw_device.is_protocol_running, + methods=["GET"], ) async def acquire_spectrum( - self, background_tasks: BackgroundTasks, protocol="H", options=None + self, + background_tasks: BackgroundTasks, + protocol="H", + options=None, ) -> int: - """ - Acquire an NMR spectrum. + """Acquire an NMR spectrum. Return an ID to be passed to get_result_folder, it will return the result folder after acquisition end. """ return await self.hw_device.run_protocol( - name=protocol, background_tasks=background_tasks, options=options + name=protocol, + background_tasks=background_tasks, + options=options, ) async def stop(self): diff --git a/src/flowchem/devices/magritek/utils.py b/src/flowchem/devices/magritek/utils.py index 9d722ed3..c0bbcebe 100644 --- a/src/flowchem/devices/magritek/utils.py +++ b/src/flowchem/devices/magritek/utils.py @@ -20,13 +20,18 @@ def get_my_docs_path() -> Path: shgfp_type_current = 0 # Get current, not default value buf = ctypes.create_unicode_buffer(2000) ctypes.windll.shell32.SHGetFolderPathW( # type: ignore - None, csidl_personal, None, shgfp_type_current, buf + None, + csidl_personal, + None, + shgfp_type_current, + buf, ) return Path(buf.value) def create_folder_mapper( - remote_root: str | Path, local_root: str | Path + remote_root: str | Path, + local_root: str | Path, ) -> Callable[[Path | str], Path]: """Return a function that converts path relative to remote_root to their corresponding on local_root. @@ -46,7 +51,7 @@ def folder_mapper(path_to_be_translated: Path | str): # NOTE: Path.is_relative_to() is available from Py 3.9 only. NBD as this is not often used. if not path_to_be_translated.is_relative_to(remote_root): logger.exception( - f"Cannot translate remote path {path_to_be_translated} to a local path!" + f"Cannot translate remote path {path_to_be_translated} to a local path!", ) raise InvalidConfiguration( f"Cannot translate remote path {path_to_be_translated} to a local path!" diff --git a/src/flowchem/devices/manson/manson_power_supply.py b/src/flowchem/devices/manson/manson_power_supply.py index bb6adfe9..e3ea0ddc 100644 --- a/src/flowchem/devices/manson/manson_power_supply.py +++ b/src/flowchem/devices/manson/manson_power_supply.py @@ -11,8 +11,7 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.manson.manson_component import MansonPowerControl -from flowchem.utils.exceptions import DeviceError -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import DeviceError, InvalidConfiguration from flowchem.utils.people import dario, jakob, wei_hsin @@ -21,7 +20,7 @@ class MansonPowerSupply(FlowchemDevice): MODEL_ALT_RANGE = ["HCS-3102", "HCS-3014", "HCS-3204", "HCS-3202"] - def __init__(self, aio: aioserial.AioSerial, name=""): + def __init__(self, aio: aioserial.AioSerial, name="") -> None: """Control class for Manson Power Supply.""" super().__init__(name) self._serial = aio @@ -33,8 +32,7 @@ def __init__(self, aio: aioserial.AioSerial, name=""): @classmethod def from_config(cls, port, name="", **serial_kwargs): - """ - Create instance from config dict. Used by server to initialize obj from config. + """Create instance from config dict. Used by server to initialize obj from config. Only required parameter is 'port'. """ @@ -85,7 +83,7 @@ async def _send_command( reply_string = [] for line in await self._serial.readlines_async(): reply_string.append(line.decode("ascii").strip()) - logger.debug(f"Received {repr(line)}!") + logger.debug(f"Received {line!r}!") return "\n".join(reply_string) diff --git a/src/flowchem/devices/mettlertoledo/icir.py b/src/flowchem/devices/mettlertoledo/icir.py index ca7cbebb..05acbc2a 100644 --- a/src/flowchem/devices/mettlertoledo/icir.py +++ b/src/flowchem/devices/mettlertoledo/icir.py @@ -3,8 +3,7 @@ import datetime from pathlib import Path -from asyncua import Client -from asyncua import ua +from asyncua import Client, ua from loguru import logger from pydantic import BaseModel @@ -52,7 +51,7 @@ class IcIR(FlowchemDevice): counter = 0 - def __init__(self, template="", url="", name=""): + def __init__(self, template="", url="", name="") -> None: """Initiate connection with OPC UA server.""" super().__init__(name) self.device_info = DeviceInfo( @@ -66,7 +65,8 @@ def __init__(self, template="", url="", name=""): if not url: url = self.iC_OPCUA_DEFAULT_SERVER_ADDRESS self.opcua = Client( - url, timeout=5 + url, + timeout=5, ) # Call to START_EXPERIMENT can take few seconds! self._template = template @@ -82,7 +82,7 @@ async def initialize(self): # Ensure iCIR version is supported self.device_info.version = await self.opcua.get_node( - self.SOFTWARE_VERSION + self.SOFTWARE_VERSION, ).get_value() # e.g. "7.1.91.0" self.ensure_version_is_supported() @@ -108,7 +108,7 @@ def ensure_version_is_supported(self): if self.device_info.version not in self._supported_versions: logger.warning( f"The current version of iCIR [self.version] has not been tested!" - f"Pleas use one of the supported versions: {self._supported_versions}" + f"Pleas use one of the supported versions: {self._supported_versions}", ) except ua.UaStatusCodeError as error: # iCIR app closed raise DeviceError( @@ -148,8 +148,7 @@ def _normalize_template_name(template_name) -> str: @staticmethod def is_template_name_valid(template_name: str) -> bool: - r""" - Check template name validity. For the template folder location read below. + r"""Check template name validity. For the template folder location read below. From Mettler Toledo docs: You can use the Start method to create and run a new experiment in one of the iC analytical applications @@ -159,7 +158,7 @@ def is_template_name_valid(template_name: str) -> bool: This is usually C:\\ProgramData\\METTLER TOLEDO\\iC OPC UA Server\\1.2\\Templates. """ template_dir = Path( - r"C:\ProgramData\METTLER TOLEDO\iC OPC UA Server\1.2\Templates" + r"C:\ProgramData\METTLER TOLEDO\iC OPC UA Server\1.2\Templates", ) if not template_dir.exists() or not template_dir.is_dir(): logger.warning("iCIR template folder not found on the local PC!") @@ -239,15 +238,18 @@ async def last_spectrum_raw(self) -> IRSpectrum: async def last_spectrum_background(self) -> IRSpectrum: """RAW result latest scan.""" return await IcIR.spectrum_from_node( - self.opcua.get_node(self.SPECTRA_BACKGROUND) + self.opcua.get_node(self.SPECTRA_BACKGROUND), ) async def start_experiment( - self, template: str, name: str = "Unnamed flowchem exp." + self, + template: str, + name: str = "Unnamed flowchem exp.", ): r"""Start an experiment on iCIR. Args: + ---- template: name of the experiment template, should be in the Templtates folder on the PC running iCIR. That usually is C:\\ProgramData\\METTLER TOLEDO\\iC OPC UA Server\1.2\\Templates name: experiment name. @@ -261,7 +263,7 @@ async def start_experiment( if await self.probe_status() == "Running": logger.warning( "I was asked to start an experiment while a current experiment is already running!" - "I will have to stop that first! Sorry for that :)" + "I will have to stop that first! Sorry for that :)", ) # Stop running experiment and wait for the spectrometer to be ready await self.stop_experiment() @@ -282,8 +284,7 @@ async def start_experiment( return True async def stop_experiment(self): - """ - Stop the experiment currently running. + """Stop the experiment currently running. Note: the call does not make the instrument idle: you need to wait for the current scan to end! """ diff --git a/src/flowchem/devices/mettlertoledo/icir_control.py b/src/flowchem/devices/mettlertoledo/icir_control.py index 15e8db3f..9c1d0b91 100644 --- a/src/flowchem/devices/mettlertoledo/icir_control.py +++ b/src/flowchem/devices/mettlertoledo/icir_control.py @@ -2,8 +2,7 @@ from typing import TYPE_CHECKING -from flowchem.components.analytics.ir import IRControl -from flowchem.components.analytics.ir import IRSpectrum +from flowchem.components.analytics.ir import IRControl, IRSpectrum if TYPE_CHECKING: from .icir import IcIR @@ -12,14 +11,13 @@ class IcIRControl(IRControl): hw_device: IcIR # for typing's sake - def __init__(self, name: str, hw_device: IcIR): # type:ignore + def __init__(self, name: str, hw_device: IcIR) -> None: # type:ignore """HPLC Control component. Sends methods, starts run, do stuff.""" super().__init__(name, hw_device) self.add_api_route("/spectrum-count", self.spectrum_count, methods=["GET"]) async def acquire_spectrum(self, treated: bool = True) -> IRSpectrum: - """ - Acquire an IR spectrum. + """Acquire an IR spectrum. Background subtraction performed if treated=True, else a raw scan is returned. """ diff --git a/src/flowchem/devices/mettlertoledo/icir_finder.py b/src/flowchem/devices/mettlertoledo/icir_finder.py index a6d36645..ae0fb226 100644 --- a/src/flowchem/devices/mettlertoledo/icir_finder.py +++ b/src/flowchem/devices/mettlertoledo/icir_finder.py @@ -13,7 +13,7 @@ async def is_iCIR_running_locally() -> bool: - """Is iCIR available on the local machine (default URL)?""" + """Is iCIR available on the local machine (default URL)?.""" ir = IcIR() try: await ir.opcua.connect() @@ -32,7 +32,7 @@ async def generate_icir_config() -> str: [device.icir-local] type = "IcIR" template = "" # Add template name with acquisition settings! - \n\n""" + \n\n""", ) return "" diff --git a/src/flowchem/devices/phidgets/__init__.py b/src/flowchem/devices/phidgets/__init__.py index 284d2b0b..ecfb1d80 100644 --- a/src/flowchem/devices/phidgets/__init__.py +++ b/src/flowchem/devices/phidgets/__init__.py @@ -1,5 +1,5 @@ """Phidget-based devices.""" -from .pressure_sensor import PhidgetPressureSensor from .bubble_sensor import PhidgetBubbleSensor, PhidgetPowerSource5V +from .pressure_sensor import PhidgetPressureSensor __all__ = ["PhidgetPressureSensor", "PhidgetBubbleSensor", "PhidgetPowerSource5V"] diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index 58d17893..d7245b6f 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -14,11 +14,11 @@ PhidgetBubbleSensorComponent, PhidgetBubbleSensorPowerComponent, ) -from flowchem.utils.people import wei_hsin, dario, jakob +from flowchem.utils.people import dario, jakob, wei_hsin try: from Phidget22.Devices.DigitalOutput import DigitalOutput # power source - from Phidget22.Devices.VoltageInput import VoltageInput, PowerSupply # Sensor + from Phidget22.Devices.VoltageInput import PowerSupply, VoltageInput # Sensor from Phidget22.PhidgetException import PhidgetException HAS_PHIDGET = True @@ -29,7 +29,7 @@ class PhidgetPowerSource5V(FlowchemDevice): - """Use a Phidget power source to apply power to the sensor""" + """Use a Phidget power source to apply power to the sensor.""" def __init__( self, @@ -38,7 +38,7 @@ def __init__( vint_channel: int = -1, phidget_is_remote: bool = False, name: str = "", - ): + ) -> None: """Initialize BubbleSensor with the given voltage range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: @@ -77,7 +77,6 @@ def __init__( # Set power supply to 5V to provide power self.phidget.setDutyCycle(1.0) logger.debug("power of tube sensor is turn on!") - # self.phidget.setState(True) #setting DutyCycle to 1.0 self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], @@ -86,12 +85,12 @@ def __init__( serial_number=vint_serial_number, ) - def __del__(self): + def __del__(self) -> None: """Ensure connection closure upon deletion.""" self.phidget.close() def power_on(self): - """Control the power of the device""" + """Control the power of the device.""" self.phidget.setDutyCycle(1.0) # self.phidget.setState(True) logger.debug("tube sensor power is turn on!") @@ -104,7 +103,7 @@ def is_attached(self) -> bool: return bool(self.phidget.getAttached()) def is_poweron(self) -> bool: - """Wheteher the power is on""" + """Wheteher the power is on.""" return bool(self.phidget.getState()) def components(self): @@ -114,18 +113,18 @@ def components(self): class PhidgetBubbleSensor(FlowchemDevice): """Use a Phidget voltage input to translate a Tube Liquid Sensor OPB350 5 Valtage signal - to the corresponding light penetration value.""" + to the corresponding light penetration value. + """ def __init__( self, - # intensity_range: tuple[float, float] = (0, 100), vint_serial_number: int = -1, vint_hub_port: int = -1, vint_channel: int = -1, phidget_is_remote: bool = False, data_interval: int = 250, # ms name: str = "", - ): + ) -> None: """Initialize BubbleSensor with the given voltage range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: @@ -177,17 +176,17 @@ def __init__( serial_number=vint_serial_number, ) - def __del__(self): + def __del__(self) -> None: """Ensure connection closure upon deletion.""" self.phidget.close() def power_on(self): - """turn on the measurement of the bubble sensor""" + """Turn on the measurement of the bubble sensor.""" self.phidget.setPowerSupply(PowerSupply.POWER_SUPPLY_12V) logger.debug("measurement of tube sensor is turn on!") def power_off(self): - """turn off the supply to stop measurement""" + """Turn off the supply to stop measurement.""" self.phidget.setPowerSupply(PowerSupply.POWER_SUPPLY_OFF) logger.debug("measurement of tube sensor is turn off!") @@ -196,11 +195,11 @@ def is_attached(self) -> bool: return bool(self.phidget.getAttached()) def get_dataInterval(self) -> int: - """Get Data Interval form the initial setting""" + """Get Data Interval form the initial setting.""" return self.phidget.getDataInterval() def set_dataInterval(self, datainterval: int) -> None: - """Set Data Interval: 20-6000 ms""" + """Set Data Interval: 20-6000 ms.""" self.phidget.setDataInterval(datainterval) logger.debug(f"change data interval to {datainterval}!") @@ -221,7 +220,7 @@ def read_voltage(self) -> float: # type: ignore return 0 def read_intensity(self) -> float: # type: ignore - """Read intensity from voltage""" + """Read intensity from voltage.""" voltage = self.read_voltage() return self._voltage_to_intensity(voltage) diff --git a/src/flowchem/devices/phidgets/bubble_sensor_component.py b/src/flowchem/devices/phidgets/bubble_sensor_component.py index 64206bde..4079aa2b 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor_component.py +++ b/src/flowchem/devices/phidgets/bubble_sensor_component.py @@ -2,8 +2,8 @@ from typing import TYPE_CHECKING +from flowchem.components.technical.power import PowerSwitch from flowchem.devices.flowchem_device import FlowchemDevice -from ...components.technical.power import PowerSwitch if TYPE_CHECKING: from .bubble_sensor import PhidgetBubbleSensor, PhidgetPowerSource5V @@ -14,7 +14,7 @@ class PhidgetBubbleSensorComponent(Sensor): hw_device: PhidgetBubbleSensor # just for typing - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/set-data-Interval", self.power_on, methods=["PUT"]) @@ -30,15 +30,15 @@ async def power_off(self) -> bool: return True async def read_voltage(self) -> float: - """Read from sensor in Volt""" + """Read from sensor in Volt.""" return self.hw_device.read_voltage() async def acquire_signal(self) -> float: - """transform the voltage from sensor to be expressed in percentage(%)""" + """Transform the voltage from sensor to be expressed in percentage(%).""" return self.hw_device.read_intensity() async def set_dataInterval(self, datainterval: int) -> bool: - """set data interval at the range 20-60000 ms""" + """Set data interval at the range 20-60000 ms.""" self.hw_device.set_dataInterval(datainterval) return True diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index a559d500..53c6bb38 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -20,8 +20,8 @@ HAS_PHIDGET = False -from flowchem.utils.exceptions import InvalidConfiguration from flowchem import ureg +from flowchem.utils.exceptions import InvalidConfiguration class PhidgetPressureSensor(FlowchemDevice): @@ -34,7 +34,7 @@ def __init__( vint_channel: int = -1, phidget_is_remote: bool = False, name: str = "", - ): + ) -> None: """Initialize PressureSensor with the given pressure range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: @@ -82,7 +82,7 @@ def __init__( serial_number=vint_serial_number, ) - def __del__(self): + def __del__(self) -> None: """Ensure connection closure upon deletion.""" self.phidget.close() @@ -101,8 +101,7 @@ def _current_to_pressure(self, current_in_ampere: float) -> pint.Quantity: return pressure_reading def read_pressure(self) -> pint.Quantity: # type: ignore - """ - Read pressure from the sensor and returns it as pint.Quantity. + """Read pressure from the sensor and returns it as pint.Quantity. This is the main class method, and it never fails, but rather return None. Why? diff --git a/src/flowchem/devices/phidgets/pressure_sensor_component.py b/src/flowchem/devices/phidgets/pressure_sensor_component.py index cb7bd61f..4be34982 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor_component.py +++ b/src/flowchem/devices/phidgets/pressure_sensor_component.py @@ -12,7 +12,7 @@ class PhidgetPressureSensorComponent(PressureSensor): hw_device: PhidgetPressureSensor # just for typing - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) diff --git a/src/flowchem/devices/vacuubrand/cvc3000.py b/src/flowchem/devices/vacuubrand/cvc3000.py index b3cf118a..f23d7ca0 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000.py +++ b/src/flowchem/devices/vacuubrand/cvc3000.py @@ -28,7 +28,7 @@ def __init__( self, aio: aioserial.AioSerial, name="", - ): + ) -> None: super().__init__(name) self._serial = aio self._device_sn: int = None # type: ignore @@ -41,8 +41,7 @@ def __init__( @classmethod def from_config(cls, port, name=None, **serial_kwargs): - """ - Create instance from config dict. Used by server to initialize obj from config. + """Create instance from config dict. Used by server to initialize obj from config. Only required parameter is 'port'. Optional 'loop' + others (see AioSerial()) """ @@ -78,13 +77,14 @@ async def initialize(self): logger.debug(f"Connected with CVC3000 version {self.device_info.version}") async def _send_command_and_read_reply(self, command: str) -> str: - """ - Send command and read the reply. + """Send command and read the reply. Args: + ---- command (str): string to be transmitted Returns: + ------- str: reply received """ await self._serial.write_async(command.encode("ascii") + b"\r\n") diff --git a/src/flowchem/devices/vacuubrand/cvc3000_finder.py b/src/flowchem/devices/vacuubrand/cvc3000_finder.py index 48956838..569e28b5 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_finder.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_finder.py @@ -31,6 +31,6 @@ def cvc3000_finder(serial_port) -> list[str]: f""" [device.cvc-{cvc._device_sn}] type = "CVC3000" - port = "{serial_port}"\n\n""" - ) + port = "{serial_port}"\n\n""", + ), ] diff --git a/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py b/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py index 6704b6f3..16c4540a 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py @@ -5,8 +5,7 @@ from flowchem.components.technical.pressure import PressureControl from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.vacuubrand.utils import ProcessStatus -from flowchem.devices.vacuubrand.utils import PumpState +from flowchem.devices.vacuubrand.utils import ProcessStatus, PumpState if TYPE_CHECKING: from flowchem.devices.vacuubrand.cvc3000 import CVC3000 @@ -15,7 +14,7 @@ class CVC3000PressureControl(PressureControl): hw_device: CVC3000 # for typing's sake - def __init__(self, name: str, hw_device: FlowchemDevice): + def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device) diff --git a/src/flowchem/devices/vapourtec/__init__.py b/src/flowchem/devices/vapourtec/__init__.py index 2db0c129..13f6a05d 100644 --- a/src/flowchem/devices/vapourtec/__init__.py +++ b/src/flowchem/devices/vapourtec/__init__.py @@ -1,5 +1,5 @@ """Vapourtec devices.""" -from .r4_heater import R4Heater from .r2 import R2 +from .r4_heater import R4Heater __all__ = ["R4Heater", "R2"] diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 247588e5..5281fbe7 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -1,30 +1,30 @@ -""" Control module for the Vapourtec R2 """ +"""Control module for the Vapourtec R2.""" from __future__ import annotations +import asyncio from asyncio import Lock from collections import namedtuple from collections.abc import Iterable import aioserial import pint -import asyncio from loguru import logger from flowchem import ureg from flowchem.components.device_info import DeviceInfo +from flowchem.components.technical.temperature import TempRange from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vapourtec.r2_components_control import ( + R2GeneralPressureSensor, R2GeneralSensor, - UV150PhotoReactor, R2HPLCPump, R2InjectionValve, - R2TwoPortValve, - R2PumpPressureSensor, - R2GeneralPressureSensor, R2MainSwitch, + R2PumpPressureSensor, + R2TwoPortValve, R4Reactor, + UV150PhotoReactor, ) -from flowchem.components.technical.temperature import TempRange from flowchem.utils.exceptions import InvalidConfiguration from flowchem.utils.people import dario, jakob, wei_hsin @@ -77,7 +77,7 @@ def __init__( min_pressure: float = 1000, max_pressure: float = 50000, **config, - ): + ) -> None: super().__init__(name) # Set max pressure for R2 pump @@ -144,10 +144,10 @@ async def initialize(self): await self.power_on() async def _write(self, command: str): - """Writes a command to the pump""" + """Writes a command to the pump.""" cmd = command + "\r\n" await self._serial.write_async(cmd.encode("ascii")) - logger.debug(f"Sent command: {repr(command)}") + logger.debug(f"Sent command: {command!r}") async def _read_reply(self) -> str: """Reads the pump reply from serial communication.""" @@ -190,7 +190,7 @@ async def version(self): return await self.write_and_read_reply(self.cmd.VERSION) async def system_type(self): - """Get system type: system type, pressure mode""" + """Get system type: system type, pressure mode.""" return await self.write_and_read_reply(self.cmd.GET_SYSTEM_TYPE) async def get_status(self) -> AllComponentStatus: @@ -218,7 +218,7 @@ async def get_state(self) -> str: return State_dic[state.run_state] async def get_setting_Pressure_Limit(self) -> str: - """Get system pressure limit""" + """Get system pressure limit.""" state = await self.get_status() return state.presslimit @@ -228,13 +228,12 @@ async def get_setting_Temperature(self) -> str: return "Off" if state.chan3_temp == "-1000" else state.chan3_temp async def get_valve_Position(self, valve_code: int) -> str: - "Get specific valves position" + "Get specific valves position." state = await self.get_status() # Current state of all valves as bitmap bitmap = int(state.LEDs_bitmap) return list(reversed(f"{bitmap:05b}"))[valve_code] - # return f"{bitmap:05b}"[-(valve_code+1)] # return 0 or 1 # Set parameters async def set_flowrate(self, pump: str, flowrate: str): @@ -242,7 +241,7 @@ async def set_flowrate(self, pump: str, flowrate: str): if flowrate.isnumeric(): flowrate = flowrate + "ul/min" logger.warning( - "No units provided to set_temperature, assuming microliter/minutes." + "No units provided to set_temperature, assuming microliter/minutes.", ) parsed_f = ureg.Quantity(flowrate) @@ -252,15 +251,19 @@ async def set_flowrate(self, pump: str, flowrate: str): pump_num = 1 else: logger.warning(f"Invalid pump name: {pump}") - return None + return cmd = self.cmd.SET_FLOWRATE.format( - pump=pump_num, rate_in_ul_min=round(parsed_f.m_as("ul/min")) + pump=pump_num, + rate_in_ul_min=round(parsed_f.m_as("ul/min")), ) await self.write_and_read_reply(cmd) async def set_temperature( - self, channel: int, temp: pint.Quantity, ramp_rate: str | None = None + self, + channel: int, + temp: pint.Quantity, + ramp_rate: str | None = None, ): """Set temperature to R4 channel. If a UV150 is present then channel 3 range is limited to -40 to 80 °C.""" cmd = self.cmd.SET_TEMPERATURE.format( @@ -271,19 +274,19 @@ async def set_temperature( await self.write_and_read_reply(cmd) async def set_pressure_limit(self, pressure: str): - """set maximum system pressure: range 1,000 to 50,000 mbar""" + """Set maximum system pressure: range 1,000 to 50,000 mbar.""" if pressure.isnumeric(): pressure = pressure + "mbar" logger.warning("No units provided to set_temperature, assuming mbar.") set_p = ureg.Quantity(pressure) cmd = self.cmd.SET_MAX_PRESSURE.format( - max_p_in_mbar=round(set_p.m_as("mbar") / 500) * 500 + max_p_in_mbar=round(set_p.m_as("mbar") / 500) * 500, ) await self.write_and_read_reply(cmd) async def set_UV150(self, power: int, heated: bool = False): - """set intensity of the UV light: 0 or 50 to 100""" + """Set intensity of the UV light: 0 or 50 to 100.""" # Fixme: ideally the state (heated or not) of the reactor is kept as instance variable so that the light # intensity can be changed without affecting the heating state (i.e. with new default heated=None that keeps # the previous state unchanged @@ -317,7 +320,7 @@ async def get_current_temperature(self, channel=2) -> float | None: async def get_pressure_history( self, ) -> tuple[int, int, int]: - """Get pressure history and returns it as (in mbar)""" + """Get pressure history and returns it as (in mbar).""" # Get a `&` separated list of pressures for all sensors every second pressure_history = await self.write_and_read_reply(self.cmd.HISTORY_PRESSURE) if pressure_history == "OK": @@ -326,20 +329,20 @@ async def get_pressure_history( return await self.get_pressure_history() # Each pressure data point consists of four values: time and three pressures _, *pressures = pressure_history.split("&")[0].split( - "," + ",", ) # e.g. 45853,94,193,142 # Converts to mbar p_in_mbar = [int(x) * 10 for x in pressures] return p_in_mbar[1], p_in_mbar[2], p_in_mbar[0] # pumpA, pumpB, system async def get_current_pressure(self, pump_code: int = 2) -> int: - """Get current pressure (in mbar)""" + """Get current pressure (in mbar).""" press_state_list = await self.get_pressure_history() # 0: pump A, 1: pump_B, 2: system pressure return press_state_list[pump_code] async def get_current_flow(self, pump_code: str) -> float: - """Get current flow rate (in ul/min)""" + """Get current flow rate (in ul/min).""" state = await self.write_and_read_reply(self.cmd.HISTORY_FLOW) if state == "OK": logger.warning("ValueError:the reply of get flow command is OK....") @@ -352,33 +355,18 @@ async def get_current_flow(self, pump_code: str) -> float: return float(pump_flow[pump_code]) async def pooling(self) -> dict: - """extract all reaction parameters""" - AllState = dict() + """Extract all reaction parameters.""" + AllState = {} while True: state = await self.get_status() AllState["RunState_code"] = state.run_state - # AllState["ValveState_code"] = state.LEDs_bitmap - AllState["allValve"] = "{0:05b}".format(int(state.LEDs_bitmap)) - # AllState["2PortValveA"] = await self.get_valve_Position(0) - # AllState["2PortValveB"] = await self.get_valve_Position(1) - # AllState["InjValveA"] = await self.get_valve_Position(2) - # AllState["InjValveA"] = await self.get_valve_Position(3) - # AllState["2PortValveC"] = await self.get_valve_Position(4) - # AllState["sysState"] = await self.get_Run_State() + AllState["allValve"] = f"{int(state.LEDs_bitmap):05b}" ( AllState["pumpA_P"], AllState["pumpB_P"], AllState["sysP (mbar)"], ) = await self.get_pressure_history() - # AllState["sysP (mbar)"] = await self.get_current_pressure() - # AllState["pumpA_P"] = await self.get_current_pressure(pump_code = 0) - # AllState["pumpB_P"] = await self.get_current_pressure(pump_code = 1) - # AllState["pumpA_flow"] =await self.get_current_flow(pump_code=0) - # AllState["pumpB_flow"] =await self.get_current_flow(pump_code=1) AllState["Temp"] = await self.get_current_temperature() - # AllState["UVpower"] = await self.get_current_power() - # self.last_state = parse(self._serial.write_async("sdjskal")) - # time.sleep(1) return AllState def components(self): @@ -420,7 +408,7 @@ def components(self): Vapourtec_R2 = R2(port="COM4") async def main(Vapourtec_R2): - """test function""" + """Test function.""" await Vapourtec_R2.initialize() # Get valve and pump ( diff --git a/src/flowchem/devices/vapourtec/r2_components_control.py b/src/flowchem/devices/vapourtec/r2_components_control.py index fc9c20c7..65ff73fd 100644 --- a/src/flowchem/devices/vapourtec/r2_components_control.py +++ b/src/flowchem/devices/vapourtec/r2_components_control.py @@ -1,18 +1,18 @@ -""" Control module for the Vapourtec R2 valves """ +"""Control module for the Vapourtec R2 valves.""" from __future__ import annotations from typing import TYPE_CHECKING from loguru import logger -from flowchem.components.valves.injection_valves import SixPortTwoPosition -from flowchem.components.valves.distribution_valves import TwoPortDistribution from flowchem.components.pumps.hplc import HPLCPump -from flowchem.components.technical.photo import Photoreactor from flowchem.components.sensors.base_sensor import Sensor from flowchem.components.sensors.pressure import PressureSensor +from flowchem.components.technical.photo import Photoreactor from flowchem.components.technical.power import PowerSwitch from flowchem.components.technical.temperature import TemperatureControl, TempRange +from flowchem.components.valves.distribution_valves import TwoPortDistribution +from flowchem.components.valves.injection_valves import SixPortTwoPosition if TYPE_CHECKING: from .r2 import R2 @@ -30,13 +30,15 @@ class R2GeneralSensor(Sensor): hw_device: R2 # for typing's sake - def __init__(self, name: str, hw_device: R2): + def __init__(self, name: str, hw_device: R2) -> None: """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/monitor-system", self.monitor_sys, methods=["GET"]) self.add_api_route("/get-run-state", self.get_run_state, methods=["GET"]) self.add_api_route( - "/set-system-max-pressure", self.set_sys_pressure_limit, methods=["PUT"] + "/set-system-max-pressure", + self.set_sys_pressure_limit, + methods=["PUT"], ) async def monitor_sys(self) -> dict: @@ -44,11 +46,11 @@ async def monitor_sys(self) -> dict: return await self.hw_device.pooling() async def get_run_state(self) -> str: - """Get current system state""" + """Get current system state.""" return await self.hw_device.get_state() async def set_sys_pressure_limit(self, pressure: str) -> bool: - """Set maximum system pressure: range 1,000 to 50,000 mbar""" + """Set maximum system pressure: range 1,000 to 50,000 mbar.""" # TODO: change to accept different units await self.hw_device.set_pressure_limit(pressure) return True @@ -59,7 +61,9 @@ class R4Reactor(TemperatureControl): hw_device: R2 # for typing's sake - def __init__(self, name: str, hw_device: R2, channel: int, temp_limits: TempRange): + def __init__( + self, name: str, hw_device: R2, channel: int, temp_limits: TempRange + ) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device, temp_limits) self.channel = channel @@ -89,16 +93,16 @@ async def power_off(self): class UV150PhotoReactor(Photoreactor): - """R2 reactor control class""" + """R2 reactor control class.""" hw_device: R2 # for typing's sake - def __init__(self, name: str, hw_device: R2): + def __init__(self, name: str, hw_device: R2) -> None: super().__init__(name, hw_device) self._intensity = 0 # 0 set upon device init async def set_intensity(self, percent: int = 100): - """Set UV light intensity at the range 50-100%""" + """Set UV light intensity at the range 50-100%.""" self._intensity = percent await self.hw_device.set_UV150(percent) @@ -128,7 +132,7 @@ class R2InjectionValve(SixPortTwoPosition): position_mapping = {"load": "0", "inject": "1"} _reverse_position_mapping = {v: k for k, v in position_mapping.items()} - def __init__(self, name: str, hw_device: R2, valve_code: int): + def __init__(self, name: str, hw_device: R2, valve_code: int) -> None: """Create a ValveControl object.""" super().__init__(name, hw_device) self.valve_code = valve_code @@ -136,14 +140,13 @@ def __init__(self, name: str, hw_device: R2, valve_code: int): async def get_position(self) -> str: """Get current valve position.""" position = await self.hw_device.get_valve_Position(self.valve_code) - # self.hw_device.last_state.valve[self.valve_number] return "position is %s" % self._reverse_position_mapping[position] async def set_position(self, position: str) -> bool: """Set position: 'load' or 'inject'.""" target_pos = self.position_mapping[position] # load or inject await self.hw_device.trigger_key_press( - str(self.valve_code * 2 + int(target_pos)) + str(self.valve_code * 2 + int(target_pos)), ) return True @@ -157,7 +160,7 @@ class R2TwoPortValve(TwoPortDistribution): # total 3 valve (A, B, Collection) position_mapping = {"Solvent": "0", "Reagent": "1"} _reverse_position_mapping = {v: k for k, v in position_mapping.items()} - def __init__(self, name: str, hw_device: R2, valve_code: int): + def __init__(self, name: str, hw_device: R2, valve_code: int) -> None: """Create a ValveControl object.""" super().__init__(name, hw_device) self.valve_code = valve_code @@ -172,7 +175,7 @@ async def set_position(self, position: str) -> bool: """Move valve to position.""" target_pos = self.position_mapping[position] await self.hw_device.trigger_key_press( - str(self.valve_code * 2 + int(target_pos)) + str(self.valve_code * 2 + int(target_pos)), ) return True @@ -182,7 +185,7 @@ class R2HPLCPump(HPLCPump): hw_device: R2 # for typing's sake - def __init__(self, name: str, hw_device: R2, pump_code: str): + def __init__(self, name: str, hw_device: R2, pump_code: str) -> None: """Create a ValveControl object.""" super().__init__(name, hw_device) self.pump_code = pump_code @@ -192,12 +195,12 @@ async def get_current_flow(self) -> float: return await self.hw_device.get_current_flow(self.pump_code) async def set_flowrate(self, flowrate: str) -> bool: - """Set flow rate to the pump""" + """Set flow rate to the pump.""" await self.hw_device.set_flowrate(self.pump_code, flowrate) return True async def infuse(self, rate: str = "", volume: str = "") -> bool: - """set the flow rate: in ul/min and start infusion.""" + """Set the flow rate: in ul/min and start infusion.""" if volume: logger.warning(f"Volume parameter ignored: not supported by {self.name}!") @@ -206,7 +209,7 @@ async def infuse(self, rate: str = "", volume: str = "") -> bool: return True async def stop(self): - """Stop infusion""" + """Stop infusion.""" await self.hw_device.set_flowrate(pump=self.pump_code, flowrate="0 ul/min") async def is_pumping(self) -> bool: @@ -217,7 +220,7 @@ async def is_pumping(self) -> bool: class R2PumpPressureSensor(PressureSensor): hw_device: R2 # for typing's sake - def __init__(self, name: str, hw_device: R2, pump_code: int): + def __init__(self, name: str, hw_device: R2, pump_code: int) -> None: """Create a ValveControl object.""" super().__init__(name, hw_device) self.pump_code = pump_code @@ -230,12 +233,12 @@ async def read_pressure(self, units: str = "mbar") -> int | None: # mbar class R2GeneralPressureSensor(PressureSensor): hw_device: R2 # for typing's sake - def __init__(self, name: str, hw_device: R2): + def __init__(self, name: str, hw_device: R2) -> None: """Create a ValveControl object.""" super().__init__(name, hw_device) async def read_pressure(self, units: str = "mbar") -> int: - """Get system pressure""" + """Get system pressure.""" # TODO: now the output are always mbar, change it to fit the base component return await self.hw_device.get_current_pressure() diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index da74d31e..7a18938e 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -1,4 +1,4 @@ -""" Control module for the Vapourtec R4 heater """ +"""Control module for the Vapourtec R4 heater.""" from __future__ import annotations from collections import namedtuple @@ -9,8 +9,8 @@ from loguru import logger from flowchem import ureg -from flowchem.components.technical.temperature import TempRange from flowchem.components.device_info import DeviceInfo +from flowchem.components.technical.temperature import TempRange from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vapourtec.r4_heater_channel_control import R4HeaterChannelControl from flowchem.utils.exceptions import InvalidConfiguration @@ -43,7 +43,7 @@ def __init__( min_temp: float | list[float] = -100, max_temp: float | list[float] = 250, **config, - ): + ) -> None: super().__init__(name) # Set min and max temp for all 4 channels if not isinstance(min_temp, Iterable): @@ -55,11 +55,14 @@ def __init__( self._max_t = max_temp if not HAS_VAPOURTEC_COMMANDS: - raise InvalidConfiguration( - "You tried to use a Vapourtec device but the relevant commands are missing!\n" - "Unfortunately, we cannot publish those as they were provided under NDA.\n" + msg = ( + "You tried to use a Vapourtec device but the relevant commands are missing!" + "Unfortunately, we cannot publish those as they were provided under NDA." "Contact Vapourtec for further assistance." ) + raise InvalidConfiguration( + msg, + ) self.cmd = VapourtecR4Commands() @@ -68,8 +71,9 @@ def __init__( try: self._serial = aioserial.AioSerial(**configuration) except aioserial.SerialException as ex: + msg = f"Cannot connect to the R4Heater on the port <{config.get('port')}>" raise InvalidConfiguration( - f"Cannot connect to the R4Heater on the port <{config.get('port')}>" + msg, ) from ex self.device_info = DeviceInfo( @@ -84,10 +88,10 @@ async def initialize(self): logger.info(f"Connected with R4Heater version {self.device_info.version}") async def _write(self, command: str): - """Writes a command to the pump""" + """Writes a command to the pump.""" cmd = command + "\r\n" await self._serial.write_async(cmd.encode("ascii")) - logger.debug(f"Sent command: {repr(command)}") + logger.debug(f"Sent command: {command!r}") async def _read_reply(self) -> str: """Reads the pump reply from serial communication.""" @@ -103,7 +107,8 @@ async def write_and_read_reply(self, command: str) -> str: response = await self._read_reply() if not response: - raise InvalidConfiguration("No response received from heating module!") + msg = "No response received from heating module!" + raise InvalidConfiguration(msg) logger.debug(f"Reply received: {response}") return response.rstrip() @@ -115,7 +120,8 @@ async def version(self): async def set_temperature(self, channel, temperature: pint.Quantity): """Set temperature to channel.""" cmd = self.cmd.SET_TEMPERATURE.format( - channel=channel, temperature_in_C=round(temperature.m_as("°C")) + channel=channel, + temperature_in_C=round(temperature.m_as("°C")), ) await self.write_and_read_reply(cmd) # Set temperature implies channel on @@ -124,7 +130,7 @@ async def set_temperature(self, channel, temperature: pint.Quantity): status = await self.get_status(channel) if status.state == "U": logger.error( - f"TARGET CHANNEL {channel} UNPLUGGED! (Note: numbering starts at 0)" + f"TARGET CHANNEL {channel} UNPLUGGED! (Note: numbering starts at 0)", ) async def get_status(self, channel) -> ChannelStatus: @@ -134,7 +140,7 @@ async def get_status(self, channel) -> ChannelStatus: while True: try: raw_status = await self.write_and_read_reply( - self.cmd.GET_STATUS.format(channel=channel) + self.cmd.GET_STATUS.format(channel=channel), ) return R4Heater.ChannelStatus(raw_status[:1], raw_status[1:]) except InvalidConfiguration as ex: @@ -161,7 +167,8 @@ async def power_off(self, channel): def components(self): temp_limits = { ch_num: TempRange( - min=ureg.Quantity(f"{t[0]} °C"), max=ureg.Quantity(f"{t[1]} °C") + min=ureg.Quantity(f"{t[0]} °C"), + max=ureg.Quantity(f"{t[1]} °C"), ) for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) } @@ -177,7 +184,7 @@ def components(self): heat = R4Heater(port="COM1") async def main(heat): - """test function""" + """Test function.""" await heat.initialize() # Get reactors r1, r2, r3, r4 = heat.components() diff --git a/src/flowchem/devices/vapourtec/r4_heater_channel_control.py b/src/flowchem/devices/vapourtec/r4_heater_channel_control.py index ce1a0dda..782d74d7 100644 --- a/src/flowchem/devices/vapourtec/r4_heater_channel_control.py +++ b/src/flowchem/devices/vapourtec/r4_heater_channel_control.py @@ -1,10 +1,9 @@ -""" Control module for the Vapourtec R4 heater """ +"""Control module for the Vapourtec R4 heater.""" from __future__ import annotations from typing import TYPE_CHECKING -from flowchem.components.technical.temperature import TemperatureControl -from flowchem.components.technical.temperature import TempRange +from flowchem.components.technical.temperature import TemperatureControl, TempRange if TYPE_CHECKING: from .r4_heater import R4Heater @@ -16,8 +15,12 @@ class R4HeaterChannelControl(TemperatureControl): hw_device: R4Heater # for typing's sake def __init__( - self, name: str, hw_device: R4Heater, channel: int, temp_limits: TempRange - ): + self, + name: str, + hw_device: R4Heater, + channel: int, + temp_limits: TempRange, + ) -> None: """Create a TemperatureControl object.""" super().__init__(name, hw_device, temp_limits) self.channel = channel diff --git a/src/flowchem/devices/vapourtec/vapourtec_finder.py b/src/flowchem/devices/vapourtec/vapourtec_finder.py index dd89cd5b..2e6f3360 100644 --- a/src/flowchem/devices/vapourtec/vapourtec_finder.py +++ b/src/flowchem/devices/vapourtec/vapourtec_finder.py @@ -37,7 +37,7 @@ def r4_finder(serial_port) -> list[str]: cfg += dedent( f""" type = "R4Heater" - port = "{serial_port}"\n\n""" + port = "{serial_port}"\n\n""", ) else: cfg = "" diff --git a/src/flowchem/devices/vicivalco/vici_valve.py b/src/flowchem/devices/vicivalco/vici_valve.py index d080d1ca..a807d853 100644 --- a/src/flowchem/devices/vicivalco/vici_valve.py +++ b/src/flowchem/devices/vicivalco/vici_valve.py @@ -23,12 +23,12 @@ class ViciCommand: value: str = "" reply_lines: int = 1 - def __str__(self): + def __str__(self) -> str: """Provide a string representation of the command used, nice for logs.""" address = str(self.valve_id) if self.valve_id is not None else "" return f"{address} {self.command}{self.value}" - def __bytes__(self): + def __bytes__(self) -> bytes: """Byte representation of the command used for serial communication.""" return str(self).encode("ascii") @@ -44,11 +44,11 @@ class ViciValcoValveIO: "bytesize": aioserial.EIGHTBITS, } - def __init__(self, aio_port: aioserial.Serial): - """ - Initialize communication on the serial port where the valves are located and initialize them. + def __init__(self, aio_port: aioserial.Serial) -> None: + """Initialize communication on the serial port where the valves are located and initialize them. Args: + ---- aio_port: aioserial.Serial() object """ self._serial = aio_port @@ -121,13 +121,13 @@ def __init__( valve_io: ViciValcoValveIO, name: str = "", address: int | None = None, - ): - """ - Create instance from an existing ViciValcoValveIO object. This allows dependency injection. + ) -> None: + """Create instance from an existing ViciValcoValveIO object. This allows dependency injection. See from_config() class method for config-based init. Args: + ---- valve_io: An ViciValcoValveIO w/ serial connection to the daisy chain w/ target valve. address: number of valve in array, 1 for first one, auto-assigned on init based on position. name: 'cause naming stuff is important. @@ -158,10 +158,11 @@ def from_config( existing_io = [v for v in ViciValve._io_instances if v._serial.port == port] # If no existing serial object are available for the port provided, create a new one - if existing_io: - valve_io = existing_io.pop() - else: - valve_io = ViciValcoValveIO.from_config(port, **serial_kwargs) + valve_io = ( + existing_io.pop() + if existing_io + else ViciValcoValveIO.from_config(port, **serial_kwargs) + ) return cls(valve_io, address=address, name=name) @@ -203,7 +204,10 @@ async def get_raw_position(self) -> str: async def set_raw_position(self, position: str): """Set valve position.""" valve_by_name_cw = ViciCommand( - valve_id=self.address, command="GO", value=position, reply_lines=0 + valve_id=self.address, + command="GO", + value=position, + reply_lines=0, ) await self.valve_io.write_and_read_reply(valve_by_name_cw) @@ -211,7 +215,9 @@ async def timed_toggle(self, injection_time: str): """Switch valve to a position for a given time.""" delay = ureg.Quantity(injection_time).to("ms") set_delay = ViciCommand( - valve_id=self.address, command="DT", value=delay.magnitude + valve_id=self.address, + command="DT", + value=delay.magnitude, ) await self.valve_io.write_and_read_reply(set_delay) diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index d9313879..5a4db7c0 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -14,14 +14,14 @@ else: import tomli as tomllib +from loguru import logger + from flowchem.devices.known_plugins import plugin_devices from flowchem.utils.exceptions import InvalidConfiguration -from loguru import logger def parse_toml(stream: typing.BinaryIO) -> dict: - """ - Read the TOML configuration file and returns it as a dict. + """Read the TOML configuration file and returns it as a dict. Extensive exception handling due to the error-prone human editing needed in the configuration file. """ @@ -72,10 +72,10 @@ def instantiate_device(config: dict) -> dict: def ensure_device_name_is_valid(device_name: str) -> None: - """ - Device name validator + """Device name validator. - Uniqueness of names is ensured by their toml dict key nature,""" + Uniqueness of names is ensured by their toml dict key nature, + """ if len(device_name) > 42: # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig raise InvalidConfiguration( @@ -89,8 +89,7 @@ def ensure_device_name_is_valid(device_name: str) -> None: def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: - """ - Parse device config and return a device object. + """Parse device config and return a device object. Exception handling to provide more specific and diagnostic messages upon errors in the configuration file. """ @@ -108,13 +107,13 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: logger.exception( f"The device `{device_name}` of type `{device_config['type']}` needs a additional plugin" f"Install {needed_plugin} to add support for it!" - f"e.g. `python -m pip install {needed_plugin}`" + f"e.g. `python -m pip install {needed_plugin}`", ) raise InvalidConfiguration(f"{needed_plugin} not installed.") from error logger.exception( f"Device type `{device_config['type']}` unknown in 'device.{device_name}'!" - f"[Known types: {device_object_mapper.keys()}]" + f"[Known types: {device_object_mapper.keys()}]", ) raise InvalidConfiguration( f"Unknown device type `{device_config['type']}`." @@ -138,7 +137,7 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: ) from error logger.debug( - f"Created device '{device.name}' instance: {device.__class__.__name__}" + f"Created device '{device.name}' instance: {device.__class__.__name__}", ) return device @@ -151,7 +150,7 @@ def get_helpful_error_message(called_with: dict, arg_spec: inspect.FullArgSpec): invalid_parameters = list(set(called_with.keys()).difference(arg_spec.args)) if invalid_parameters: logger.error( - f"The following parameters were not recognized: {invalid_parameters}" + f"The following parameters were not recognized: {invalid_parameters}", ) # Then check if a mandatory arguments was not satisfied. [1 to skip cls/self, -n to remove args w/ default] @@ -160,5 +159,5 @@ def get_helpful_error_message(called_with: dict, arg_spec: inspect.FullArgSpec): missing_parameters = list(set(mandatory_args).difference(called_with.keys())) if missing_parameters: logger.error( - f"The following mandatory parameters were missing in the configuration: {missing_parameters}" + f"The following mandatory parameters were missing in the configuration: {missing_parameters}", ) diff --git a/src/flowchem/server/create_server.py b/src/flowchem/server/create_server.py index d100792a..399a9511 100644 --- a/src/flowchem/server/create_server.py +++ b/src/flowchem/server/create_server.py @@ -7,8 +7,8 @@ from loguru import logger from flowchem.server.configuration_parser import parse_config -from flowchem.server.zeroconf_server import ZeroconfServer from flowchem.server.fastapi_server import FastAPIServer +from flowchem.server.zeroconf_server import ZeroconfServer class FlowchemInstance(TypedDict): @@ -18,8 +18,7 @@ class FlowchemInstance(TypedDict): async def create_server_from_file(config_file: BytesIO | Path) -> FlowchemInstance: - """ - Based on the toml device config provided, initialize connection to devices and create API endpoints. + """Based on the toml device config provided, initialize connection to devices and create API endpoints. config: Path to the toml file with the device config or dict. """ @@ -60,7 +59,7 @@ async def create_server_for_devices( async def main(): flowchem_instance = await create_server_from_file( - config_file=BytesIO(b"""[device.test-device]\ntype = "FakeDevice"\n""") + config_file=BytesIO(b"""[device.test-device]\ntype = "FakeDevice"\n"""), ) config = uvicorn.Config( flowchem_instance["api_server"].app, diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index f2558cd1..4e32a24e 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -1,22 +1,18 @@ """FastAPI server for devices control.""" -from typing import Iterable - -from fastapi import FastAPI, APIRouter +from collections.abc import Iterable from importlib.metadata import metadata, version +from fastapi import APIRouter, FastAPI from loguru import logger from starlette.responses import RedirectResponse from flowchem.components.device_info import DeviceInfo - -# from fastapi_utils.tasks import repeat_every - -from flowchem.vendor.repeat_every import repeat_every from flowchem.devices.flowchem_device import RepeatedTaskInfo +from flowchem.vendor.repeat_every import repeat_every class FastAPIServer: - def __init__(self, filename: str = ""): + def __init__(self, filename: str = "") -> None: # Create FastAPI app self.app = FastAPI( title=f"Flowchem - {filename}", @@ -47,7 +43,7 @@ async def my_task(): await task() def add_device(self, device): - """Add device to server""" + """Add device to server.""" # Get components (some compounded devices can return multiple components) components = device.components() logger.debug(f"Got {len(components)} components from {device.name}") diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index a3ca9ed7..eadeb035 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -2,16 +2,19 @@ import uuid from loguru import logger -from zeroconf import get_all_addresses, NonUniqueNameException -from zeroconf import IPVersion -from zeroconf import ServiceInfo -from zeroconf import Zeroconf +from zeroconf import ( + IPVersion, + NonUniqueNameException, + ServiceInfo, + Zeroconf, + get_all_addresses, +) class ZeroconfServer: """Server to advertise Flowchem devices via zero configuration networking.""" - def __init__(self, port: int = 8000): + def __init__(self, port: int = 8000) -> None: # Server properties self.port = port self.server = Zeroconf(ip_version=IPVersion.V4Only) diff --git a/src/flowchem/utils/device_finder.py b/src/flowchem/utils/device_finder.py index f85059a2..b226513f 100644 --- a/src/flowchem/utils/device_finder.py +++ b/src/flowchem/utils/device_finder.py @@ -3,8 +3,8 @@ import aioserial import rich_click as click -import serial.tools.list_ports as list_ports from loguru import logger +from serial.tools import list_ports from flowchem.devices.hamilton.ml600_finder import ml600_finder from flowchem.devices.harvardapparatus.elite11_finder import elite11_finder @@ -27,7 +27,7 @@ def inspect_serial_ports() -> set[str]: """Search for known devices on local serial ports and generate config stubs.""" port_available = [comport.device for comport in list_ports.comports()] logger.info( - f"Found the following serial port(s) on the current device: {port_available}" + f"Found the following serial port(s) on the current device: {port_available}", ) dev_found_config: set[str] = set() @@ -102,7 +102,7 @@ def main(output, overwrite, safe, assume_yes, source_ip): # Validate output location if Path(output).exists() and not overwrite: logger.error( - f"Output file `{output}` already existing! Use `--overwrite` to replace it." + f"Output file `{output}` already existing! Use `--overwrite` to replace it.", ) return @@ -110,19 +110,18 @@ def main(output, overwrite, safe, assume_yes, source_ip): confirm = False if not safe and not assume_yes: logger.warning( - "The autodiscover include modules that involve communication over serial ports." + "The autodiscover include modules that involve communication over serial ports.", ) logger.warning("These modules are *not* guaranteed to be safe!") logger.warning( - "Unsupported devices could be placed in an unsafe state as result of the discovery process!" + "Unsupported devices could be placed in an unsafe state as result of the discovery process!", ) confirm = click.confirm("Do you want to include the search for serial devices?") # Search serial devices - if not safe and (assume_yes or confirm): - serial_config = inspect_serial_ports() - else: - serial_config = set() + serial_config = ( + inspect_serial_ports() if not safe and (assume_yes or confirm) else set() + ) # Search ethernet devices eth_config = inspect_eth(source_ip) @@ -136,7 +135,7 @@ def main(output, overwrite, safe, assume_yes, source_ip): # Print configuration configuration = "".join(serial_config) + "".join(eth_config) logger.info( - f"The following configuration will be written to `{output}:\n{configuration}" + f"The following configuration will be written to `{output}:\n{configuration}", ) # Write to file diff --git a/src/flowchem/vendor/getmac.py b/src/flowchem/vendor/getmac.py index 18781db6..7cca4264 100644 --- a/src/flowchem/vendor/getmac.py +++ b/src/flowchem/vendor/getmac.py @@ -11,7 +11,7 @@ ip_mac = get_mac_address(ip="192.168.0.1") ip6_mac = get_mac_address(ip6="::1") host_mac = get_mac_address(hostname="localhost") - updated_mac = get_mac_address(ip="10.0.0.1", network_request=True) + updated_mac = get_mac_address(ip="10.0.0.1", network_request=True). """ import ctypes import os @@ -20,16 +20,10 @@ import shlex import socket import struct -from subprocess import check_output -from subprocess import DEVNULL - -from loguru import logger - +from subprocess import DEVNULL, check_output from typing import TYPE_CHECKING, Optional # noqa -if TYPE_CHECKING: - pass - +from loguru import logger # Configure logging log = logger @@ -86,7 +80,11 @@ # noinspection PyBroadException def get_mac_address( - interface=None, ip=None, ip6=None, hostname=None, network_request=True + interface=None, + ip=None, + ip6=None, + hostname=None, + network_request=True, ): # type: (Optional[str], Optional[str], Optional[str], Optional[str], bool) -> Optional[str] """Get an Unicast IEEE 802 MAC-48 address from a local interface or remote host. @@ -94,7 +92,9 @@ def get_mac_address( are selected, the default network interface for the system will be used. Exceptions will be handled silently and returned as a None. For the time being, it assumes you are using Ethernet. - NOTES: + + Notes + ----- * You MUST provide str-typed arguments, REGARDLESS of Python version. * localhost/127.0.0.1 will always return '00:00:00:00:00:00' Args: @@ -105,7 +105,9 @@ def get_mac_address( network_request (bool): Send a UDP packet to a remote host to populate the ARP/NDP tables for IPv4/IPv6. The port this packet is sent to can be configured using the module variable `getmac.PORT`. - Returns: + + Returns + ------- Lowercase colon-separated MAC address, or None if one could not be found or there was an error. """ @@ -127,7 +129,7 @@ def get_mac_address( s.sendto(b"", (ip, PORT)) else: s.sendto(b"", (ip6, PORT)) - except Exception: # noqa: B902 + except Exception: log.error("Failed to send ARP table population packet") finally: s.close() @@ -137,7 +139,7 @@ def get_mac_address( if not socket.has_ipv6: log.error( "Cannot get the MAC address of a IPv6 host: " - "IPv6 is not supported on this system" + "IPv6 is not supported on this system", ) return None elif ":" not in ip6: @@ -172,7 +174,6 @@ def get_mac_address( to_find = "en0" mac = _hunt_for_mac(to_find, typ, network_request) - # log.debug("Raw MAC found: %s", mac) # Check and format the result to be lowercase, colon-separated if mac is not None: @@ -256,7 +257,7 @@ def _windows_ctypes_host(host): inetaddr = ctypes.windll.wsock32.inet_addr(host) # type: ignore if inetaddr in (0, -1): raise Exception - except Exception: # noqa: BLK100 + except Exception: hostip = socket.gethostbyname(host) inetaddr = ctypes.windll.wsock32.inet_addr(hostip) # type: ignore @@ -270,10 +271,7 @@ def _windows_ctypes_host(host): # Convert binary data into a string. macaddr = "" for intval in struct.unpack("BBBBBB", buffer): # type: ignore - if intval > 15: - replacestr = "0x" - else: - replacestr = "x" + replacestr = "0x" if intval > 15 else "x" macaddr = "".join([macaddr, hex(intval).replace(replacestr, "")]) return macaddr @@ -284,7 +282,6 @@ def _fcntl_iface(iface): iface = iface.encode() # type: ignore s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - # 0x8927 = SIOCGIFADDR info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack("256s", iface[:15])) # type: ignore return ":".join(["%02x" % ord(chr(char)) for char in info[18:24]]) @@ -303,7 +300,7 @@ def _uuid_ip(ip): mac2 = _uuid_convert(mac2) if mac1 == mac2: return mac1 - except Exception: # noqa: B902 + except Exception: raise finally: socket.gethostbyname = backup @@ -366,7 +363,7 @@ def _read_file(filepath): try: with open(filepath) as f: return f.read() - except OSError: # noqa: B014 - This is IOError on Python 2.7 + except OSError: # - This is IOError on Python 2.7 log.debug("Could not find file: '%s'", filepath) return None @@ -377,7 +374,7 @@ def _hunt_for_mac(to_find, type_of_thing, net_ok=True): Format of method lists: Tuple: (regex, regex index, command, command args) Command args is a list of strings to attempt to use as arguments - lambda: Function to call + lambda: Function to call. """ if to_find is None: log.warning("_hunt_for_mac() failed: to_find is None") @@ -422,7 +419,7 @@ def _hunt_for_mac(to_find, type_of_thing, net_ok=True): elif (WINDOWS or WSL) and type_of_thing in [IP4, IP6, HOSTNAME]: methods = [ # arp -a - Parsing result with a regex - (MAC_RE_DASH, 0, "arp.exe", ["-a %s" % to_find]) + (MAC_RE_DASH, 0, "arp.exe", ["-a %s" % to_find]), ] # Add methods that make network requests @@ -443,7 +440,7 @@ def _hunt_for_mac(to_find, type_of_thing, net_ok=True): 0, "arp", [to_find], - ) + ), ] elif OPENBSD and type_of_thing == INTERFACE: methods = [(r"lladdr " + MAC_RE_COLON, 0, "ifconfig", [to_find])] @@ -512,8 +509,7 @@ def _hunt_for_mac(to_find, type_of_thing, net_ok=True): def _try_methods(methods, to_find=None): # type: (list, Optional[str]) -> Optional[str] - """ - Runs the methods specified by _hunt_for_mac(). + """Runs the methods specified by _hunt_for_mac(). We try every method and see if it returned a MAC address. If it returns None or raises an exception, we continue and try the next method. @@ -528,13 +524,10 @@ def _try_methods(methods, to_find=None): if found: # Skip remaining args AND remaining methods break elif callable(m): - if to_find is not None: - found = m(to_find) - else: - found = m() + found = m(to_find) if to_find is not None else m() else: log.critical("Invalid type '%s' for method '%s'", type(m), str(m)) - except Exception as ex: # noqa: B902 + except Exception as ex: logger.debug(f"Ignore exception {ex}") if found: # Skip remaining methods break @@ -582,7 +575,7 @@ def _get_default_iface_openbsd(): lambda: _popen("route", "-nq show -inet -gateway -priority 1") .partition("127.0.0.1")[0] .strip() - .rpartition(" ")[2] + .rpartition(" ")[2], ] return _try_methods(methods) diff --git a/src/flowchem/vendor/repeat_every.py b/src/flowchem/vendor/repeat_every.py index e2df57cf..fbdd2187 100644 --- a/src/flowchem/vendor/repeat_every.py +++ b/src/flowchem/vendor/repeat_every.py @@ -2,16 +2,18 @@ import asyncio import logging from asyncio import ensure_future +from collections.abc import Callable, Coroutine from functools import wraps from traceback import format_exception -from typing import Any, Callable, Coroutine, Optional, Union +from typing import Any from starlette.concurrency import run_in_threadpool NoArgsNoReturnFuncT = Callable[[], None] NoArgsNoReturnAsyncFuncT = Callable[[], Coroutine[Any, Any, None]] NoArgsNoReturnDecorator = Callable[ - [Union[NoArgsNoReturnFuncT, NoArgsNoReturnAsyncFuncT]], NoArgsNoReturnAsyncFuncT + [NoArgsNoReturnFuncT | NoArgsNoReturnAsyncFuncT], + NoArgsNoReturnAsyncFuncT, ] @@ -19,12 +21,11 @@ def repeat_every( *, seconds: float, wait_first: bool = False, - logger: Optional[logging.Logger] = None, + logger: logging.Logger | None = None, raise_exceptions: bool = False, - max_repetitions: Optional[int] = None, + max_repetitions: int | None = None, ) -> NoArgsNoReturnDecorator: - """ - This function returns a decorator that modifies a function so it is periodically re-executed after its first call. + """Returns a decorator that modifies a function so it is periodically re-executed after its first call. The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished by using `functools.partial` or otherwise wrapping the target function prior to decoration. @@ -48,11 +49,9 @@ def repeat_every( """ def decorator( - func: Union[NoArgsNoReturnAsyncFuncT, NoArgsNoReturnFuncT] + func: NoArgsNoReturnAsyncFuncT | NoArgsNoReturnFuncT, ) -> NoArgsNoReturnAsyncFuncT: - """ - Converts the decorated function into a repeated, periodically-called version of itself. - """ + """Converts the decorated function into a repeated, periodically-called version of itself.""" is_coroutine = asyncio.iscoroutinefunction(func) @wraps(func) @@ -73,7 +72,7 @@ async def loop() -> None: except Exception as exc: if logger is not None: formatted_exception = "".join( - format_exception(type(exc), exc, exc.__traceback__) + format_exception(type(exc), exc, exc.__traceback__), ) logger.error(formatted_exception) if raise_exceptions: diff --git a/tests/cli/test_autodiscover_cli.py b/tests/cli/test_autodiscover_cli.py index d8616071..cd6ebeba 100644 --- a/tests/cli/test_autodiscover_cli.py +++ b/tests/cli/test_autodiscover_cli.py @@ -1,10 +1,10 @@ -import pytest import os + +import pytest from click.testing import CliRunner from flowchem.utils.device_finder import main - IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true" diff --git a/tests/cli/test_flowchem_cli.py b/tests/cli/test_flowchem_cli.py index 05f61b38..5d47e2a7 100644 --- a/tests/cli/test_flowchem_cli.py +++ b/tests/cli/test_flowchem_cli.py @@ -7,7 +7,7 @@ class FakeServer: - def __init__(self, config): + def __init__(self, config) -> None: pass @staticmethod @@ -26,15 +26,16 @@ def test_cli(mocker): dedent( """ [device.test-device]\n - type = "FakeDevice"\n""" - ) + type = "FakeDevice"\n""", + ), ) result = runner.invoke(main, ["test_configuration.toml"]) assert result.exit_code == 0 result = runner.invoke( - main, ["test_configuration.toml", "--log", "logfile.log"] + main, + ["test_configuration.toml", "--log", "logfile.log"], ) assert result.exit_code == 0 assert Path("logfile.log").exists() diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 3de700d2..e4c73da7 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -6,9 +6,9 @@ def test_get_all_flowchem_devices(flowchem_test_instance): dev_dict = get_all_flowchem_devices() - assert "test-device" in dev_dict.keys() + assert "test-device" in dev_dict async def test_async_get_all_flowchem_devices(flowchem_test_instance): dev_dict = await async_get_all_flowchem_devices() - assert "test-device" in dev_dict.keys() + assert "test-device" in dev_dict diff --git a/tests/conftest.py b/tests/conftest.py index b34db885..fd229766 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,7 @@ import sys -import pytest - from pathlib import Path +import pytest from xprocess import ProcessStarter diff --git a/tests/devices/analytics/test_flowir.py b/tests/devices/analytics/test_flowir.py index 1faeacb7..846a1cc4 100644 --- a/tests/devices/analytics/test_flowir.py +++ b/tests/devices/analytics/test_flowir.py @@ -1,4 +1,4 @@ -""" Test FlowIR, needs actual connection to the device :( """ +"""Test FlowIR, needs actual connection to the device :(.""" import asyncio import datetime import sys @@ -10,7 +10,7 @@ def check_pytest_asyncio_installed(): - """Utility function for pytest plugin""" + """Utility function for pytest plugin.""" import os from importlib import util @@ -21,18 +21,19 @@ def check_pytest_asyncio_installed(): @pytest.fixture() async def spectrometer(): - """Return local FlowIR object""" + """Return local FlowIR object.""" s = IcIR( template="template", url=IcIR.iC_OPCUA_DEFAULT_SERVER_ADDRESS.replace( - "localhost", "BSMC-YMEF002121" + "localhost", + "BSMC-YMEF002121", ), ) await s.initialize() return s -@pytest.mark.FlowIR +@pytest.mark.FlowIR() async def test_connected(spectrometer): assert await spectrometer.is_iCIR_connected() diff --git a/tests/devices/analytics/test_spinsolve.py b/tests/devices/analytics/test_spinsolve.py index ef75ef32..a350d191 100644 --- a/tests/devices/analytics/test_spinsolve.py +++ b/tests/devices/analytics/test_spinsolve.py @@ -1,4 +1,4 @@ -""" Test Spinsolve, needs actual connection with the device """ +"""Test Spinsolve, needs actual connection with the device.""" import asyncio import time from pathlib import Path @@ -7,7 +7,6 @@ from flowchem.devices import Spinsolve - # Change to match your environment ;) host = "BSMC-YMEF002121" @@ -42,15 +41,15 @@ def test_sample_property(nmr: Spinsolve): @pytest.mark.Spinsolve def test_user_data(nmr: Spinsolve): # Assignment is actually addition - nmr.user_data = dict(key1="value1") + nmr.user_data = {"key1": "value1"} time.sleep(0.2) # Ensures property is set. assert "key1" in nmr.user_data - nmr.user_data = dict(key2="value2") + nmr.user_data = {"key2": "value2"} time.sleep(0.2) # Ensures property is set. assert "key1" in nmr.user_data assert "key2" in nmr.user_data # Removal obtained by providing empty strings - nmr.user_data = dict(key1="", key2="") + nmr.user_data = {"key1": "", "key2": ""} time.sleep(0.2) # Ensures property is set. assert "key1" not in nmr.user_data assert "key2" not in nmr.user_data @@ -72,45 +71,52 @@ async def test_request_available_protocols(nmr: Spinsolve): @pytest.mark.Spinsolve def test_request_validation(nmr: Spinsolve): # VALID - valid_protocol = dict( - Number="8", AcquisitionTime="3.2", RepetitionTime="2", PulseAngle="45" - ) + valid_protocol = { + "Number": "8", + "AcquisitionTime": "3.2", + "RepetitionTime": "2", + "PulseAngle": "45", + } check_protocol = nmr._validate_protocol_request("1D EXTENDED+", valid_protocol) assert check_protocol == valid_protocol # INVALID NAME check_protocol = nmr._validate_protocol_request( - "NON EXISTING PROTOCOL", valid_protocol + "NON EXISTING PROTOCOL", + valid_protocol, ) assert not check_protocol # PARTLY VALID OPTIONS - partly_valid = dict( - Number="7", AcquisitionTime="3.2", RepetitionTime="2", PulseAngle="145" - ) + partly_valid = { + "Number": "7", + "AcquisitionTime": "3.2", + "RepetitionTime": "2", + "PulseAngle": "145", + } with pytest.warns(UserWarning, match="Invalid value"): check_protocol = nmr._validate_protocol_request("1D EXTENDED+", partly_valid) - assert check_protocol == dict(AcquisitionTime="3.2", RepetitionTime="2") + assert check_protocol == {"AcquisitionTime": "3.2", "RepetitionTime": "2"} # INVALID OPTIONS 1 - partly_valid = dict( - Number="7", - AcquisitionTime="43.2", - RepetitionTime="2123092183", - PulseAngle="145", - ) + partly_valid = { + "Number": "7", + "AcquisitionTime": "43.2", + "RepetitionTime": "2123092183", + "PulseAngle": "145", + } with pytest.warns(UserWarning, match="Invalid value"): check_protocol = nmr._validate_protocol_request("1D EXTENDED+", partly_valid) assert not check_protocol # INVALID OPTIONS 21 - partly_valid = dict( - Number="8", - AcquisitionTime="3.2", - RepetitionTime="2", - PulseAngle="45", - blabla="no", - ) + partly_valid = { + "Number": "8", + "AcquisitionTime": "3.2", + "RepetitionTime": "2", + "PulseAngle": "45", + "blabla": "no", + } with pytest.warns(UserWarning, match="Invalid option"): check_protocol = nmr._validate_protocol_request("1D EXTENDED+", partly_valid) assert "blabla" not in check_protocol @@ -136,7 +142,7 @@ def test_protocol(nmr: Spinsolve): time.sleep(0.1) # Run fast proton - path = asyncio.run(nmr.run_protocol("1D PROTON", dict(Scan="QuickScan"))) + path = asyncio.run(nmr.run_protocol("1D PROTON", {"Scan": "QuickScan"})) assert isinstance(path, Path) @@ -148,5 +154,5 @@ def test_invalid_protocol(nmr: Spinsolve): # Fail on plutonium with pytest.warns(UserWarning, match="not available"): - path = asyncio.run(nmr.run_protocol("1D PLUTONIUM", dict(Scan="QuickScan"))) + path = asyncio.run(nmr.run_protocol("1D PLUTONIUM", {"Scan": "QuickScan"})) assert path is None diff --git a/tests/devices/pumps/test_azura_compact.py b/tests/devices/pumps/test_azura_compact.py index 8b733e87..91031454 100644 --- a/tests/devices/pumps/test_azura_compact.py +++ b/tests/devices/pumps/test_azura_compact.py @@ -1,6 +1,5 @@ -""" -Knauer pump -Run with python -m pytest ./tests -m KPump and updates pump address below +"""Knauer pump +Run with python -m pytest ./tests -m KPump and updates pump address below. """ import asyncio import math @@ -9,8 +8,7 @@ import pint import pytest -from flowchem.devices.knauer.azura_compact import AzuraCompact -from flowchem.devices.knauer.azura_compact import AzuraPumpHeads +from flowchem.devices.knauer.azura_compact import AzuraCompact, AzuraPumpHeads if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -19,9 +17,8 @@ # noinspection PyUnusedLocal @pytest.fixture(scope="session") def event_loop(request): - """ - - Args: + """Args: + ---- request: """ loop = asyncio.get_event_loop_policy().new_event_loop() @@ -31,7 +28,7 @@ def event_loop(request): @pytest.fixture(scope="session") async def pump(): - """Change to match your hardware ;)""" + """Change to match your hardware ;).""" pump = AzuraCompact(ip_address="192.168.1.126") await pump.initialize() return pump @@ -62,7 +59,9 @@ async def test_flow_rate(pump: AzuraCompact): assert pint.Quantity(await pump.get_flow_rate()).magnitude == 1.25 await pump.set_flow_rate(f"{math.pi} ml/min") assert math.isclose( - pint.Quantity(await pump.get_flow_rate()).magnitude, math.pi, abs_tol=1e-3 + pint.Quantity(await pump.get_flow_rate()).magnitude, + math.pi, + abs_tol=1e-3, ) await pump.stop() diff --git a/tests/devices/pumps/test_hw_elite11.py b/tests/devices/pumps/test_hw_elite11.py index 1587824b..0400ebfa 100644 --- a/tests/devices/pumps/test_hw_elite11.py +++ b/tests/devices/pumps/test_hw_elite11.py @@ -1,5 +1,4 @@ -""" -HA Elite11 tests +"""HA Elite11 tests. 1. Update pump serial port and address belo 2. Run with `python -m pytest ./tests -m HApump` from the root folder @@ -10,15 +9,16 @@ import pytest from flowchem import ureg -from flowchem.devices.harvardapparatus.elite11 import Elite11 -from flowchem.devices.harvardapparatus.elite11 import PumpStatus +from flowchem.devices.harvardapparatus.elite11 import Elite11, PumpStatus @pytest.fixture(scope="session") async def pump(): - """Change to match your hardware ;)""" + """Change to match your hardware ;).""" pump = Elite11.from_config( - port="COM4", syringe_volume="5 ml", syringe_diameter="20 mm" + port="COM4", + syringe_volume="5 ml", + syringe_diameter="20 mm", ) await pump.initialize() return pump diff --git a/tests/devices/technical/test_huber.py b/tests/devices/technical/test_huber.py index 6dfc4074..20deacfb 100644 --- a/tests/devices/technical/test_huber.py +++ b/tests/devices/technical/test_huber.py @@ -1,4 +1,4 @@ -""" Test HuberChiller object. Does not require physical connection to the device. """ +"""Test HuberChiller object. Does not require physical connection to the device.""" import asyncio import aioserial @@ -67,7 +67,7 @@ class FakeSerial(aioserial.AioSerial): """Mock AioSerial.""" # noinspection PyMissingConstructor - def __init__(self): + def __init__(self) -> None: self.fixed_reply = None self.last_command = b"" self.map_reply = { @@ -87,24 +87,24 @@ def __init__(self): } async def write_async(self, text: bytes): - """Override AioSerial method""" + """Override AioSerial method.""" self.last_command = text async def readline_async(self, size: int = -1) -> bytes: - """Override AioSerial method""" + """Override AioSerial method.""" if self.last_command == b"{MFFFFFF\r\n": await asyncio.sleep(999) if self.fixed_reply: return self.fixed_reply return self.map_reply[self.last_command] - def __repr__(self): + def __repr__(self) -> str: return "FakeSerial" @pytest.fixture(scope="session") def chiller(): - """Chiller instance connected to FakeSerial""" + """Chiller instance connected to FakeSerial.""" return HuberChiller(FakeSerial()) diff --git a/tests/server/test_config_parser.py b/tests/server/test_config_parser.py index 7762728d..24a9d8e0 100644 --- a/tests/server/test_config_parser.py +++ b/tests/server/test_config_parser.py @@ -14,12 +14,12 @@ def test_minimal_valid_config(): """ [device.test-device] type = "FakeDevice" - """ - ).encode("utf-8") + """, + ).encode("utf-8"), ) cfg = parse_config(cfg_txt) - assert "filename" in cfg.keys() - assert "device" in cfg.keys() + assert "filename" in cfg + assert "device" in cfg assert isinstance(cfg["device"].pop(), FakeDevice) diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 68f68c38..b5634864 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -10,7 +10,8 @@ def test_read_main(flowchem_test_instance): assert "Flowchem" in response.text response = requests.get( - r"http://127.0.0.1:8000/test-device/test-component/test", timeout=5 + r"http://127.0.0.1:8000/test-device/test-component/test", + timeout=5, ) assert response.status_code == OK assert response.text == "true" From 98eb6e185f48630c0f33707d413b788c5e120d45 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 17:08:49 +0200 Subject: [PATCH 34/62] maybe not always an abstract method --- src/flowchem/devices/flowchem_device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index f9c3ea31..3f8e4f3e 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -1,5 +1,5 @@ """Base object for all hardware-control device classes.""" -from abc import ABC, abstractmethod +from abc import ABC from collections import namedtuple from collections.abc import Iterable from typing import TYPE_CHECKING @@ -24,9 +24,9 @@ def __init__(self, name) -> None: self.name = name self.device_info = DeviceInfo() - @abstractmethod async def initialize(self): """Use for setting up async connection to the device, populate components and update device_info with them.""" + pass def repeated_task(self) -> RepeatedTaskInfo | None: """Use for repeated background task, e.g. session keepalive.""" From 924fe0b005bd18c6f6546cc526cd3840a74d7e7e Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 17:46:06 +0200 Subject: [PATCH 35/62] Docstring improvements --- .../reaction_optimization/run_experiment.py | 4 +-- pyproject.toml | 4 +-- src/flowchem/__main__.py | 2 +- src/flowchem/client/component_client.py | 6 ++-- src/flowchem/components/analytics/hplc.py | 4 +-- src/flowchem/components/analytics/ir.py | 2 +- src/flowchem/components/analytics/nmr.py | 2 +- src/flowchem/components/pumps/hplc.py | 1 - src/flowchem/components/sensors/photo.py | 1 - src/flowchem/components/sensors/pressure.py | 1 - src/flowchem/devices/bronkhorst/el_flow.py | 6 ++-- .../devices/bronkhorst/el_flow_component.py | 1 - .../devices/dataapex/clarity_hplc_control.py | 2 +- src/flowchem/devices/hamilton/ml600.py | 20 ++++++------- src/flowchem/devices/hamilton/ml600_finder.py | 6 ++-- src/flowchem/devices/hamilton/ml600_pump.py | 4 +-- src/flowchem/devices/hamilton/ml600_valve.py | 2 +- .../devices/harvardapparatus/_pumpio.py | 10 ++++--- .../devices/harvardapparatus/elite11.py | 8 ++--- .../harvardapparatus/elite11_finder.py | 6 ++-- .../devices/harvardapparatus/elite11_pump.py | 4 +-- src/flowchem/devices/huber/chiller.py | 6 ++-- src/flowchem/devices/huber/huber_finder.py | 6 ++-- src/flowchem/devices/knauer/_common.py | 8 ++--- src/flowchem/devices/knauer/dad.py | 6 ++-- src/flowchem/devices/knauer/dad_component.py | 1 - src/flowchem/devices/knauer/knauer_finder.py | 2 +- src/flowchem/devices/knauer/knauer_valve.py | 2 +- src/flowchem/devices/magritek/utils.py | 4 +-- .../devices/manson/manson_power_supply.py | 6 ++-- .../devices/mettlertoledo/icir_finder.py | 2 +- .../devices/phidgets/bubble_sensor.py | 12 ++++---- .../phidgets/bubble_sensor_component.py | 1 - .../devices/phidgets/pressure_sensor.py | 6 ++-- .../phidgets/pressure_sensor_component.py | 1 - src/flowchem/devices/vacuubrand/cvc3000.py | 8 ++--- .../devices/vacuubrand/cvc3000_finder.py | 6 ++-- src/flowchem/devices/vapourtec/r2.py | 14 ++++----- .../vapourtec/r2_components_control.py | 1 - src/flowchem/devices/vapourtec/r4_heater.py | 16 +++++----- .../devices/vapourtec/vapourtec_finder.py | 6 ++-- src/flowchem/devices/vicivalco/vici_valve.py | 6 ++-- src/flowchem/server/configuration_parser.py | 29 ++++++++++--------- src/flowchem/server/zeroconf_server.py | 13 +++++---- src/flowchem/utils/device_finder.py | 4 +-- src/flowchem/utils/exceptions.py | 2 +- src/flowchem/vendor/getmac.py | 6 ++-- src/flowchem/vendor/repeat_every.py | 4 +-- tests/devices/analytics/test_flowir.py | 11 ------- tests/devices/technical/test_huber.py | 4 +-- tests/server/test_config_parser.py | 4 +-- 51 files changed, 140 insertions(+), 153 deletions(-) diff --git a/examples/reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py index e2ba0f6c..22eedf41 100644 --- a/examples/reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -83,7 +83,7 @@ def wait_stable_temperature(): def get_ir_once_stable(): - """Keeps acquiring IR spectra until changes are small, then returns the spectrum.""" + """Keep acquiring IR spectra until changes are small, then returns the spectrum.""" logger.info("Waiting for the IR spectrum to be stable") with command_session() as sess: # Wait for first spectrum to be available @@ -155,7 +155,7 @@ def run_experiment( temperature: float, residence_time: float, ) -> float: - """Runs one experiment with the provided conditions. + """Run one experiment with the provided conditions. Args: ---- diff --git a/pyproject.toml b/pyproject.toml index a3fed7bf..70f2c892 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,8 +107,8 @@ python_version = "3.10" testpaths = "tests" asyncio_mode = "auto" # No cov needed for pycharm debugger -addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" -#addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" +#addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" +addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" markers = [ "HApump: tests requiring a local HA Elite11 connected.", diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 2d0becb9..147a7204 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -61,7 +61,7 @@ def main(device_config_file, logfile, host, debug): logger.debug(f"Starting server with configuration file: '{device_config_file}'") async def main_loop(): - """The loop must be shared between uvicorn and flowchem.""" + """Main application loop, the event loop is shared between uvicorn and flowchem.""" flowchem_instance = await create_server_from_file(Path(device_config_file)) config = uvicorn.Config( flowchem_instance["api_server"].app, diff --git a/src/flowchem/client/component_client.py b/src/flowchem/client/component_client.py index 6663f610..facb5b2d 100644 --- a/src/flowchem/client/component_client.py +++ b/src/flowchem/client/component_client.py @@ -18,13 +18,13 @@ def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient") -> None: self.component_info = ComponentInfo.model_validate_json(self.get(url).text) def get(self, url, **kwargs): - """Sends a GET request. Returns :class:`Response` object.""" + """Send a GET request. Returns :class:`Response` object.""" return self._session.get(url, **kwargs) def post(self, url, data=None, json=None, **kwargs): - """Sends a POST request. Returns :class:`Response` object.""" + """Send a POST request. Returns :class:`Response` object.""" return self._session.post(url, data=data, json=json, **kwargs) def put(self, url, data=None, **kwargs): - """Sends a PUT request. Returns :class:`Response` object.""" + """Send a PUT request. Returns :class:`Response` object.""" return self._session.put(url, data=data, **kwargs) diff --git a/src/flowchem/components/analytics/hplc.py b/src/flowchem/components/analytics/hplc.py index 87408a76..b7b2126c 100644 --- a/src/flowchem/components/analytics/hplc.py +++ b/src/flowchem/components/analytics/hplc.py @@ -23,12 +23,12 @@ def __init__(self, name: str, hw_device: FlowchemDevice) -> None: self.component_info.type = "HPLC Control" async def send_method(self, method_name): - """Submits a method to the HPLC. + """Submit method to HPLC. This is e.g. useful when the injection is automatically triggerd when switching a valve. """ ... async def run_sample(self, sample_name: str, method_name: str): - """Runs a sample at the HPLC with the provided sample name and method.""" + """Run HPLC sample with the provided sample name and method.""" ... diff --git a/src/flowchem/components/analytics/ir.py b/src/flowchem/components/analytics/ir.py index 51bb3a4e..92f7d219 100644 --- a/src/flowchem/components/analytics/ir.py +++ b/src/flowchem/components/analytics/ir.py @@ -34,5 +34,5 @@ async def acquire_spectrum(self) -> IRSpectrum: # type: ignore ... async def stop(self): - """Stops acquisition and exit gracefully.""" + """Stop acquisition and exit gracefully.""" ... diff --git a/src/flowchem/components/analytics/nmr.py b/src/flowchem/components/analytics/nmr.py index 2bc47249..6136d6a5 100644 --- a/src/flowchem/components/analytics/nmr.py +++ b/src/flowchem/components/analytics/nmr.py @@ -23,5 +23,5 @@ async def acquire_spectrum(self, background_tasks: BackgroundTasks): ... async def stop(self): - """Stops acquisition and exit gracefully.""" + """Stop acquisition and exit gracefully.""" ... diff --git a/src/flowchem/components/pumps/hplc.py b/src/flowchem/components/pumps/hplc.py index 82b46d18..cede6a45 100644 --- a/src/flowchem/components/pumps/hplc.py +++ b/src/flowchem/components/pumps/hplc.py @@ -6,7 +6,6 @@ class HPLCPump(BasePump): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) # Ontology: HPLC isocratic pump diff --git a/src/flowchem/components/sensors/photo.py b/src/flowchem/components/sensors/photo.py index 9e3ee25a..480e0224 100644 --- a/src/flowchem/components/sensors/photo.py +++ b/src/flowchem/components/sensors/photo.py @@ -8,7 +8,6 @@ class PhotoSensor(Sensor): """A photo sensor.""" def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/acquire-signal", self.acquire_signal, methods=["GET"]) self.add_api_route("/calibration", self.calibrate_zero, methods=["PUT"]) diff --git a/src/flowchem/components/sensors/pressure.py b/src/flowchem/components/sensors/pressure.py index 2d170f0f..34e99334 100644 --- a/src/flowchem/components/sensors/pressure.py +++ b/src/flowchem/components/sensors/pressure.py @@ -8,7 +8,6 @@ class PressureSensor(Sensor): """A pressure sensor.""" def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/read-pressure", self.read_pressure, methods=["GET"]) diff --git a/src/flowchem/devices/bronkhorst/el_flow.py b/src/flowchem/devices/bronkhorst/el_flow.py index 47046a3a..b1a662d7 100644 --- a/src/flowchem/devices/bronkhorst/el_flow.py +++ b/src/flowchem/devices/bronkhorst/el_flow.py @@ -84,7 +84,7 @@ async def wink(self): self.el_press.wink() async def get_id(self): - """Reads the Serial Number (SN) of the instrument.""" + """Get instrument serial number.""" return self.el_press.id def components(self): @@ -160,12 +160,12 @@ async def get_flow_percentage(self) -> float: return m_num / 320 async def wink(self): - """Wink the LEDs on the instrument.""" + """Wink the LEDs.""" # default wink 9 time self.el_flow.wink() async def get_id(self): - """Reads the ID parameter of the instrument.""" + """Read ID parameter.""" return self.el_flow.id def components(self): diff --git a/src/flowchem/devices/bronkhorst/el_flow_component.py b/src/flowchem/devices/bronkhorst/el_flow_component.py index 176a29f1..a4b07414 100644 --- a/src/flowchem/devices/bronkhorst/el_flow_component.py +++ b/src/flowchem/devices/bronkhorst/el_flow_component.py @@ -45,7 +45,6 @@ class MFCComponent(FlowchemComponent): hw_device: MFC # just for typing def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic power supply.""" super().__init__(name, hw_device) self.add_api_route("/get-flow-rate", self.get_flow_setpoint, methods=["GET"]) self.add_api_route("/stop", self.stop, methods=["PUT"]) diff --git a/src/flowchem/devices/dataapex/clarity_hplc_control.py b/src/flowchem/devices/dataapex/clarity_hplc_control.py index a36ce93c..2ca10ab0 100644 --- a/src/flowchem/devices/dataapex/clarity_hplc_control.py +++ b/src/flowchem/devices/dataapex/clarity_hplc_control.py @@ -32,7 +32,7 @@ async def send_method( alias="method-name", ), ) -> bool: - """Sets the HPLC method (i.e. a file with .MET extension) to the instrument. + """Set HPLC method (i.e. a file with .MET extension). Make sure to select 'Send Method to Instrument' option in Method Sending Options dialog in System Configuration. """ diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index d1ca09d2..c2feb1e8 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -14,7 +14,7 @@ from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.hamilton.ml600_pump import ML600Pump from flowchem.devices.hamilton.ml600_valve import ML600Valve -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin if TYPE_CHECKING: @@ -80,7 +80,7 @@ def from_config(cls, config): try: serial_object = aioserial.AioSerial(**configuration) except aioserial.SerialException as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the pump on the port <{configuration.get('port')}>" ) from serial_exception @@ -101,11 +101,11 @@ async def _assign_pump_address(self) -> int: try: await self._write_async(b"1a\r") except aioserial.SerialException as e: - raise InvalidConfiguration from e + raise InvalidConfigurationError from e reply = await self._read_reply_async() if not reply or reply[:1] != "1": - raise InvalidConfiguration(f"No pump found on {self._serial.port}") + raise InvalidConfigurationError(f"No pump found on {self._serial.port}") # reply[1:2] should be the address of the last pump. However, this does not work reliably. # So here we enumerate the pumps explicitly instead last_pump = 0 @@ -155,7 +155,7 @@ async def write_and_read_reply_async(self, command: Protocol1Command) -> str: response = await self._read_reply_async() if not response: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"No response received from pump! " f"Maybe wrong pump address? (Set to {command.target_pump_num})" ) @@ -236,13 +236,13 @@ def __init__( self.syringe_volume = ureg.Quantity(syringe_volume) except AttributeError as attribute_error: logger.error(f"Invalid syringe volume {syringe_volume}!") - raise InvalidConfiguration( + raise InvalidConfigurationError( "Invalid syringe volume provided." "The syringe volume is a string with units! e.g. '5 ml'" ) from attribute_error if self.syringe_volume.m_as("ml") not in ML600.VALID_SYRINGE_VOLUME: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"The specified syringe volume ({syringe_volume}) is invalid!\n" f"The volume (in ml) has to be one of {ML600.VALID_SYRINGE_VOLUME}" ) @@ -256,7 +256,7 @@ def __init__( @classmethod def from_config(cls, **config): - """This class method is used to create instances via config file by the server for HTTP interface.""" + """Create instances via config file.""" # Many pump can be present on the same serial port with different addresses. # This shared list of HamiltonPumpIO objects allow shared state in a borg-inspired way, avoiding singletons # This is only relevant to programmatic instantiation, i.e. when from_config() is called per each pump from a @@ -286,7 +286,7 @@ def from_config(cls, **config): ) async def initialize(self, hw_init=False, init_speed: str = "200 sec / stroke"): - """Must be called after init before anything else.""" + """Initialize pump and its components.""" await self.pump_io.initialize() # Test connectivity by querying the pump's firmware version fw_cmd = Protocol1Command(command="U", target_pump_num=self.address) @@ -377,7 +377,7 @@ def flowrate_to_seconds_per_stroke(self, flowrate: pint.Quantity): return (1 / flowrate_in_steps_sec).to("second/stroke") def _seconds_per_stroke_to_flowrate(self, second_per_stroke) -> float: - """Converts seconds per stroke to flow rate. Only internal use.""" + """Convert seconds per stroke to flow rate.""" flowrate = 1 / (second_per_stroke * self._steps_per_ml) return flowrate.to("ml/min") diff --git a/src/flowchem/devices/hamilton/ml600_finder.py b/src/flowchem/devices/hamilton/ml600_finder.py index e4d53d8d..2655ef75 100644 --- a/src/flowchem/devices/hamilton/ml600_finder.py +++ b/src/flowchem/devices/hamilton/ml600_finder.py @@ -4,7 +4,7 @@ from loguru import logger -from flowchem.devices.hamilton.ml600 import HamiltonPumpIO, InvalidConfiguration +from flowchem.devices.hamilton.ml600 import HamiltonPumpIO, InvalidConfigurationError def ml600_finder(serial_port) -> set[str]: @@ -17,12 +17,12 @@ def ml600_finder(serial_port) -> set[str]: try: link = HamiltonPumpIO.from_config({"port": serial_port}) - except InvalidConfiguration: + except InvalidConfigurationError: return dev_config try: asyncio.run(link.initialize(hw_initialization=False)) - except InvalidConfiguration: + except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector link._serial.close() return dev_config diff --git a/src/flowchem/devices/hamilton/ml600_pump.py b/src/flowchem/devices/hamilton/ml600_pump.py index af368e0d..f11406c7 100644 --- a/src/flowchem/devices/hamilton/ml600_pump.py +++ b/src/flowchem/devices/hamilton/ml600_pump.py @@ -21,11 +21,11 @@ def is_withdrawing_capable(): return True async def is_pumping(self) -> bool: - """True if pump is moving.""" + """Check if pump is moving.""" return await self.hw_device.is_idle() is False async def stop(self): - """Stops pump.""" + """Stop pump.""" await self.hw_device.stop() async def infuse(self, rate: str = "", volume: str = "") -> bool: diff --git a/src/flowchem/devices/hamilton/ml600_valve.py b/src/flowchem/devices/hamilton/ml600_valve.py index 1d63c783..fd170999 100644 --- a/src/flowchem/devices/hamilton/ml600_valve.py +++ b/src/flowchem/devices/hamilton/ml600_valve.py @@ -28,7 +28,7 @@ async def set_position(self, position: str) -> bool: ) async def get_position(self) -> str: - """Current pump position.""" + """Get current pump position.""" pos = await self.hw_device.get_valve_position() reverse_position_mapping = { v: k for k, v in ML600Valve.position_mapping.items() diff --git a/src/flowchem/devices/harvardapparatus/_pumpio.py b/src/flowchem/devices/harvardapparatus/_pumpio.py index ed82ca2f..b9c29d12 100644 --- a/src/flowchem/devices/harvardapparatus/_pumpio.py +++ b/src/flowchem/devices/harvardapparatus/_pumpio.py @@ -5,7 +5,7 @@ import aioserial from loguru import logger -from flowchem.utils.exceptions import DeviceError, InvalidConfiguration +from flowchem.utils.exceptions import DeviceError, InvalidConfigurationError class PumpStatus(Enum): @@ -42,7 +42,7 @@ def __init__(self, port: str, **kwargs) -> None: self._serial = aioserial.AioSerial(port, **configuration) except aioserial.SerialException as serial_exception: logger.error(f"Cannot connect to the Pump on the port <{port}>") - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the Pump on the port <{port}>" ) from serial_exception @@ -53,7 +53,7 @@ async def _write(self, command: Protocol11Command): try: await self._serial.write_async(command_msg.encode("ascii")) except aioserial.SerialException as serial_exception: - raise InvalidConfiguration from serial_exception + raise InvalidConfigurationError from serial_exception logger.debug(f"Sent {command_msg!r}!") async def _read_reply(self) -> list[str]: @@ -120,7 +120,9 @@ async def write_and_read_reply( if not response: logger.error("No reply received from pump!") - raise InvalidConfiguration("No response received. Is the address right?") + raise InvalidConfigurationError( + "No response received. Is the address right?" + ) pump_address, status, parsed_response = self.parse_response(response) diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index 62117cd7..2f8677c7 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -20,7 +20,7 @@ Elite11PumpOnly, Elite11PumpWithdraw, ) -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -105,12 +105,12 @@ def __init__( if syringe_diameter: self._diameter = syringe_diameter else: - raise InvalidConfiguration("Please provide the syringe diameter!") + raise InvalidConfigurationError("Please provide the syringe diameter!") if syringe_volume: self._syringe_volume = syringe_volume else: - raise InvalidConfiguration("Please provide the syringe volume!") + raise InvalidConfigurationError("Please provide the syringe volume!") self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], @@ -174,7 +174,7 @@ async def initialize(self): try: await self.stop() except IndexError as index_e: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Check pump address! Currently {self.address=}" ) from index_e diff --git a/src/flowchem/devices/harvardapparatus/elite11_finder.py b/src/flowchem/devices/harvardapparatus/elite11_finder.py index 7760b4f1..513a2079 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_finder.py +++ b/src/flowchem/devices/harvardapparatus/elite11_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices.harvardapparatus.elite11 import Elite11, HarvardApparatusPumpIO -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -18,7 +18,7 @@ def elite11_finder(serial_port) -> list[str]: try: link = HarvardApparatusPumpIO(port=serial_port) - except InvalidConfiguration: + except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector return [] @@ -41,7 +41,7 @@ def elite11_finder(serial_port) -> list[str]: address=address, ) asyncio.run(test_pump.pump_info()) - except InvalidConfiguration: + except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector link._serial.close() return [] diff --git a/src/flowchem/devices/harvardapparatus/elite11_pump.py b/src/flowchem/devices/harvardapparatus/elite11_pump.py index d20f61c8..065aae1f 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_pump.py +++ b/src/flowchem/devices/harvardapparatus/elite11_pump.py @@ -19,11 +19,11 @@ def is_withdrawing_capable(): return False async def is_pumping(self) -> bool: - """True if pump is moving.""" + """Check if pump is moving.""" return await self.hw_device.is_moving() async def stop(self): - """Stops pump.""" + """Stop pump.""" await self.hw_device.stop() async def infuse(self, rate: str = "", volume: str = "0 ml") -> bool: diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index cad2e8e5..e7ef1e6d 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -11,7 +11,7 @@ from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.huber.huber_temperature_control import HuberTemperatureControl from flowchem.devices.huber.pb_command import PBCommand -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -56,7 +56,7 @@ def from_config(cls, port, name=None, **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **configuration) except (OSError, aioserial.SerialException) as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the HuberChiller on the port <{port}>" ) from serial_exception @@ -66,7 +66,7 @@ async def initialize(self): """Ensure the connection w/ device is working.""" self.device_info.serial_number = str(await self.serial_number()) if self.device_info.serial_number == "0": - raise InvalidConfiguration("No reply received from Huber Chiller!") + raise InvalidConfigurationError("No reply received from Huber Chiller!") logger.debug( f"Connected with Huber Chiller S/N {self.device_info.serial_number}", ) diff --git a/src/flowchem/devices/huber/huber_finder.py b/src/flowchem/devices/huber/huber_finder.py index 9573e6ed..e0550ef2 100644 --- a/src/flowchem/devices/huber/huber_finder.py +++ b/src/flowchem/devices/huber/huber_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices.huber.chiller import HuberChiller -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -15,12 +15,12 @@ def chiller_finder(serial_port) -> list[str]: try: chill = HuberChiller.from_config(port=serial_port) - except InvalidConfiguration: + except InvalidConfigurationError: return [] try: asyncio.run(chill.initialize()) - except InvalidConfiguration: + except InvalidConfigurationError: chill._serial.close() return [] diff --git a/src/flowchem/devices/knauer/_common.py b/src/flowchem/devices/knauer/_common.py index 79432d5f..e101e718 100644 --- a/src/flowchem/devices/knauer/_common.py +++ b/src/flowchem/devices/knauer/_common.py @@ -3,7 +3,7 @@ from loguru import logger -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from .knauer_finder import autodiscover_knauer @@ -51,7 +51,7 @@ def _ip_from_mac(self, mac_address: str) -> str: # IP if found, None otherwise ip_address = available_devices.get(mac_address) if ip_address is None: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"{self.__class__.__name__}:{self.name}\n" # type: ignore f"Device with MAC address={mac_address} not found!\n" f"[Available: {available_devices}]" @@ -66,12 +66,12 @@ async def initialize(self): self._reader, self._writer = await asyncio.wait_for(future, timeout=3) except OSError as connection_error: logger.exception(connection_error) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot open connection with device {self.__class__.__name__} at IP={self.ip_address}" ) from connection_error except asyncio.TimeoutError as timeout_error: logger.exception(timeout_error) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"No reply from device {self.__class__.__name__} at IP={self.ip_address}" ) from timeout_error diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index 10a6a04c..9f35f487 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -11,7 +11,7 @@ DADChannelControl, KnauerDADLampControl, ) -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin if TYPE_CHECKING: @@ -46,7 +46,7 @@ def __init__( self._control = display_control # True for Local if not HAS_DAD_COMMANDS: - raise InvalidConfiguration( + raise InvalidConfigurationError( "You tried to use a Knauer DAD device but the relevant commands are missing!\n" "Unfortunately, we cannot publish those as they were provided under NDA.\n" "Contact Knauer for further assistance." @@ -116,7 +116,7 @@ async def lamp(self, lamp: str, state: bool | str = "REQUEST") -> str: # if response.isnumeric() else _reverse_lampstatus_mapping[response[response.find(":") + 1:]] async def serial_num(self) -> str: - """Serial number.""" + """Get serial number.""" return await self._send_and_receive(self.cmd.SERIAL) async def identify(self) -> str: diff --git a/src/flowchem/devices/knauer/dad_component.py b/src/flowchem/devices/knauer/dad_component.py index 3a0dc444..f4974392 100644 --- a/src/flowchem/devices/knauer/dad_component.py +++ b/src/flowchem/devices/knauer/dad_component.py @@ -14,7 +14,6 @@ class KnauerDADLampControl(PowerSwitch): hw_device: KnauerDAD def __init__(self, name: str, hw_device: KnauerDAD) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.lamp = name self.add_api_route("/lamp_status", self.get_lamp, methods=["GET"]) diff --git a/src/flowchem/devices/knauer/knauer_finder.py b/src/flowchem/devices/knauer/knauer_finder.py index 5e8f91f8..a2aa8b0a 100644 --- a/src/flowchem/devices/knauer/knauer_finder.py +++ b/src/flowchem/devices/knauer/knauer_finder.py @@ -36,7 +36,7 @@ def datagram_received(self, data: bytes | str, addr: Address): async def get_device_type(ip_address: str) -> str: - """Detects the device type based on the reply to a test command or IP heuristic.""" + """Detect device type based on the reply to a test command or IP heuristic.""" fut = asyncio.open_connection(host=ip_address, port=10001) try: reader, writer = await asyncio.wait_for(fut, timeout=3) diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index a0b5f901..be2df65f 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -146,7 +146,7 @@ async def get_raw_position(self) -> str: return await self._transmit_and_parse_reply("P") async def set_raw_position(self, position: str) -> bool: - """Sets the valve position, following valve nomenclature.""" + """Set valve position, following valve nomenclature.""" return await self._transmit_and_parse_reply(position) != "" def components(self): diff --git a/src/flowchem/devices/magritek/utils.py b/src/flowchem/devices/magritek/utils.py index c0bbcebe..330337e9 100644 --- a/src/flowchem/devices/magritek/utils.py +++ b/src/flowchem/devices/magritek/utils.py @@ -5,7 +5,7 @@ from loguru import logger -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError def get_my_docs_path() -> Path: @@ -53,7 +53,7 @@ def folder_mapper(path_to_be_translated: Path | str): logger.exception( f"Cannot translate remote path {path_to_be_translated} to a local path!", ) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot translate remote path {path_to_be_translated} to a local path!" f"{path_to_be_translated} is not relative to {remote_root}" ) diff --git a/src/flowchem/devices/manson/manson_power_supply.py b/src/flowchem/devices/manson/manson_power_supply.py index e3ea0ddc..8555c117 100644 --- a/src/flowchem/devices/manson/manson_power_supply.py +++ b/src/flowchem/devices/manson/manson_power_supply.py @@ -11,7 +11,7 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.manson.manson_component import MansonPowerControl -from flowchem.utils.exceptions import DeviceError, InvalidConfiguration +from flowchem.utils.exceptions import DeviceError, InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -39,7 +39,7 @@ def from_config(cls, port, name="", **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **serial_kwargs) except aioserial.SerialException as error: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the MansonPowerSupply on the port <{port}>" ) from error @@ -51,7 +51,7 @@ async def initialize(self): if not self.device_info.model: raise DeviceError("Communication with device failed!") if self.device_info.model not in self.MODEL_ALT_RANGE: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Device is not supported! [Supported models: {self.MODEL_ALT_RANGE}]" ) diff --git a/src/flowchem/devices/mettlertoledo/icir_finder.py b/src/flowchem/devices/mettlertoledo/icir_finder.py index ae0fb226..445d40cd 100644 --- a/src/flowchem/devices/mettlertoledo/icir_finder.py +++ b/src/flowchem/devices/mettlertoledo/icir_finder.py @@ -24,7 +24,7 @@ async def is_iCIR_running_locally() -> bool: async def generate_icir_config() -> str: - """Generates config string if iCIR is available.""" + """Generate config string if iCIR is available.""" if await is_iCIR_running_locally(): logger.debug("Local iCIR found!") return dedent( diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index d7245b6f..682fa72e 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -25,7 +25,9 @@ except ImportError: HAS_PHIDGET = False -from flowchem.utils.exceptions import InvalidConfiguration # configuration is not valid +from flowchem.utils.exceptions import ( + InvalidConfigurationError, +) # configuration is not valid class PhidgetPowerSource5V(FlowchemDevice): @@ -42,7 +44,7 @@ def __init__( """Initialize BubbleSensor with the given voltage range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Phidget unusable: library or package not installed." ) @@ -70,7 +72,7 @@ def __init__( self.phidget.openWaitForAttachment(1000) logger.debug("power of tube sensor is connected!") except PhidgetException as pe: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." ) from pe @@ -128,7 +130,7 @@ def __init__( """Initialize BubbleSensor with the given voltage range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Phidget unusable: library or package not installed." ) @@ -160,7 +162,7 @@ def __init__( self.phidget.openWaitForAttachment(1000) logger.debug("tube sensor is connected!") except PhidgetException as pe: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." ) from pe diff --git a/src/flowchem/devices/phidgets/bubble_sensor_component.py b/src/flowchem/devices/phidgets/bubble_sensor_component.py index 4079aa2b..dd3a0607 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor_component.py +++ b/src/flowchem/devices/phidgets/bubble_sensor_component.py @@ -15,7 +15,6 @@ class PhidgetBubbleSensorComponent(Sensor): hw_device: PhidgetBubbleSensor # just for typing def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/set-data-Interval", self.power_on, methods=["PUT"]) self.add_api_route("/read-voltage", self.read_voltage, methods=["GET"]) diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index 53c6bb38..e7b28168 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -21,7 +21,7 @@ from flowchem import ureg -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError class PhidgetPressureSensor(FlowchemDevice): @@ -38,7 +38,7 @@ def __init__( """Initialize PressureSensor with the given pressure range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Phidget unusable: library or package not installed." ) @@ -67,7 +67,7 @@ def __init__( self.phidget.openWaitForAttachment(1000) logger.debug("Pressure sensor connected!") except PhidgetException as pe: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." ) from pe diff --git a/src/flowchem/devices/phidgets/pressure_sensor_component.py b/src/flowchem/devices/phidgets/pressure_sensor_component.py index 4be34982..e67f96f9 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor_component.py +++ b/src/flowchem/devices/phidgets/pressure_sensor_component.py @@ -13,7 +13,6 @@ class PhidgetPressureSensorComponent(PressureSensor): hw_device: PhidgetPressureSensor # just for typing def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) async def read_pressure(self, units: str = "bar"): diff --git a/src/flowchem/devices/vacuubrand/cvc3000.py b/src/flowchem/devices/vacuubrand/cvc3000.py index f23d7ca0..b590dba7 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000.py +++ b/src/flowchem/devices/vacuubrand/cvc3000.py @@ -9,7 +9,7 @@ from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vacuubrand.cvc3000_pressure_control import CVC3000PressureControl from flowchem.devices.vacuubrand.utils import ProcessStatus -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -51,7 +51,7 @@ def from_config(cls, port, name=None, **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **configuration) except (OSError, aioserial.SerialException) as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the CVC3000 on the port <{port}>" ) from serial_exception @@ -61,7 +61,7 @@ async def initialize(self): """Ensure the connection w/ device is working.""" self.device_info.version = await self.version() if not self.device_info.version: - raise InvalidConfiguration("No reply received from CVC3000!") + raise InvalidConfigurationError("No reply received from CVC3000!") # Set to CVC3000 mode and save await self._send_command_and_read_reply("CVC 3") @@ -121,7 +121,7 @@ async def get_pressure(self): return float(pressure_text.split()[0]) async def motor_speed(self, speed): - """Sets motor speed to target % value.""" + """Set motor speed to target % value.""" return await self._send_command_and_read_reply(f"OUT_SP_2 {speed}") async def status(self) -> ProcessStatus: diff --git a/src/flowchem/devices/vacuubrand/cvc3000_finder.py b/src/flowchem/devices/vacuubrand/cvc3000_finder.py index 569e28b5..682b5a59 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_finder.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices.vacuubrand.cvc3000 import CVC3000 -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -15,12 +15,12 @@ def cvc3000_finder(serial_port) -> list[str]: try: cvc = CVC3000.from_config(port=serial_port) - except InvalidConfiguration: + except InvalidConfigurationError: return [] try: asyncio.run(cvc.initialize()) - except InvalidConfiguration: + except InvalidConfigurationError: cvc._serial.close() return [] diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 5281fbe7..e1ed9148 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -25,7 +25,7 @@ R4Reactor, UV150PhotoReactor, ) -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin try: @@ -92,7 +92,7 @@ def __init__( self._max_t = max_temp if not HAS_VAPOURTEC_COMMANDS: - raise InvalidConfiguration( + raise InvalidConfigurationError( "You tried to use a Vapourtec device but the relevant commands are missing!\n" "Unfortunately, we cannot publish those as they were provided under NDA.\n" "Contact Vapourtec for further assistance." @@ -105,7 +105,7 @@ def __init__( try: self._serial = aioserial.AioSerial(**configuration) except aioserial.SerialException as ex: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the R2 on the port <{config.get('port')}>" ) from ex @@ -144,19 +144,19 @@ async def initialize(self): await self.power_on() async def _write(self, command: str): - """Writes a command to the pump.""" + """Write a command to the pump.""" cmd = command + "\r\n" await self._serial.write_async(cmd.encode("ascii")) logger.debug(f"Sent command: {command!r}") async def _read_reply(self) -> str: - """Reads the pump reply from serial communication.""" + """Read the pump reply from serial communication.""" reply_string = await self._serial.readline_async() logger.debug(f"Reply received: {reply_string.decode('ascii').rstrip()}") return reply_string.decode("ascii") async def write_and_read_reply(self, command: str) -> str: - """Sends a command to the pump, read the replies and returns it, optionally parsed.""" + """Send a command to the pump, read the replies and return it, optionally parsed.""" self._serial.reset_input_buffer() # Clear input buffer, discarding all that is in the buffer. async with self._serial_lock: await self._write(command) @@ -174,7 +174,7 @@ async def write_and_read_reply(self, command: str) -> str: await self._write(command) # Allows 4 failures... if failure > 3: - raise InvalidConfiguration( + raise InvalidConfigurationError( "No response received from R2 module!" ) else: diff --git a/src/flowchem/devices/vapourtec/r2_components_control.py b/src/flowchem/devices/vapourtec/r2_components_control.py index 65ff73fd..45ebc319 100644 --- a/src/flowchem/devices/vapourtec/r2_components_control.py +++ b/src/flowchem/devices/vapourtec/r2_components_control.py @@ -31,7 +31,6 @@ class R2GeneralSensor(Sensor): hw_device: R2 # for typing's sake def __init__(self, name: str, hw_device: R2) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/monitor-system", self.monitor_sys, methods=["GET"]) self.add_api_route("/get-run-state", self.get_run_state, methods=["GET"]) diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index 7a18938e..1e39af13 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -13,7 +13,7 @@ from flowchem.components.technical.temperature import TempRange from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vapourtec.r4_heater_channel_control import R4HeaterChannelControl -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin try: @@ -60,7 +60,7 @@ def __init__( "Unfortunately, we cannot publish those as they were provided under NDA." "Contact Vapourtec for further assistance." ) - raise InvalidConfiguration( + raise InvalidConfigurationError( msg, ) @@ -72,7 +72,7 @@ def __init__( self._serial = aioserial.AioSerial(**configuration) except aioserial.SerialException as ex: msg = f"Cannot connect to the R4Heater on the port <{config.get('port')}>" - raise InvalidConfiguration( + raise InvalidConfigurationError( msg, ) from ex @@ -88,19 +88,19 @@ async def initialize(self): logger.info(f"Connected with R4Heater version {self.device_info.version}") async def _write(self, command: str): - """Writes a command to the pump.""" + """Write a command to the pump.""" cmd = command + "\r\n" await self._serial.write_async(cmd.encode("ascii")) logger.debug(f"Sent command: {command!r}") async def _read_reply(self) -> str: - """Reads the pump reply from serial communication.""" + """Read the pump reply from serial communication.""" reply_string = await self._serial.readline_async() logger.debug(f"Reply received: {reply_string.decode('ascii').rstrip()}") return reply_string.decode("ascii") async def write_and_read_reply(self, command: str) -> str: - """Sends a command to the pump, read the replies and returns it, optionally parsed.""" + """Send a command to the pump, read the replies and return it, optionally parsed.""" self._serial.reset_input_buffer() await self._write(command) logger.debug(f"Command {command} sent to R4!") @@ -108,7 +108,7 @@ async def write_and_read_reply(self, command: str) -> str: if not response: msg = "No response received from heating module!" - raise InvalidConfiguration(msg) + raise InvalidConfigurationError(msg) logger.debug(f"Reply received: {response}") return response.rstrip() @@ -143,7 +143,7 @@ async def get_status(self, channel) -> ChannelStatus: self.cmd.GET_STATUS.format(channel=channel), ) return R4Heater.ChannelStatus(raw_status[:1], raw_status[1:]) - except InvalidConfiguration as ex: + except InvalidConfigurationError as ex: failure += 1 # Allows 3 failures cause the R4 is choosy at times... if failure > 3: diff --git a/src/flowchem/devices/vapourtec/vapourtec_finder.py b/src/flowchem/devices/vapourtec/vapourtec_finder.py index 2e6f3360..639a4176 100644 --- a/src/flowchem/devices/vapourtec/vapourtec_finder.py +++ b/src/flowchem/devices/vapourtec/vapourtec_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices import R4Heater -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -18,14 +18,14 @@ def r4_finder(serial_port) -> list[str]: try: r4 = R4Heater(port=serial_port) - except InvalidConfiguration as ic: + except InvalidConfigurationError as ic: logger.error("config") raise ic return [] try: asyncio.run(r4.initialize()) - except InvalidConfiguration: + except InvalidConfigurationError: r4._serial.close() return [] diff --git a/src/flowchem/devices/vicivalco/vici_valve.py b/src/flowchem/devices/vicivalco/vici_valve.py index a807d853..cfee6c09 100644 --- a/src/flowchem/devices/vicivalco/vici_valve.py +++ b/src/flowchem/devices/vicivalco/vici_valve.py @@ -10,7 +10,7 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vicivalco.vici_valve_component import ViciInjectionValve -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -62,7 +62,7 @@ def from_config(cls, port, **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **configuration) except aioserial.SerialException as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Could not open serial port {port} with configuration {configuration}" ) from serial_exception @@ -78,7 +78,7 @@ async def _read_reply(self, lines: int) -> str: if reply_string: logger.debug(f"Reply received: {reply_string}") else: - raise InvalidConfiguration( + raise InvalidConfigurationError( "No response received from valve! Check valve address?" ) diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 5a4db7c0..dff2a301 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -17,7 +17,9 @@ from loguru import logger from flowchem.devices.known_plugins import plugin_devices -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError + +DEVICE_NAME_MAX_LENGTH = 42 def parse_toml(stream: typing.BinaryIO) -> dict: @@ -29,9 +31,8 @@ def parse_toml(stream: typing.BinaryIO) -> dict: return tomllib.load(stream) except tomllib.TOMLDecodeError as parser_error: logger.exception(parser_error) - raise InvalidConfiguration( - "The configuration provided does not contain valid TOML!" - ) from parser_error + msg = "Invalid syntax in configuration!" + raise InvalidConfigurationError(msg) from parser_error def parse_config(file_path: BytesIO | Path) -> dict: @@ -41,9 +42,8 @@ def parse_config(file_path: BytesIO | Path) -> dict: config = parse_toml(file_path) config["filename"] = "BytesIO" else: - assert ( - file_path.exists() and file_path.is_file() - ), f"{file_path} is a valid file" + assert file_path.exists(), f"{file_path} exists" + assert file_path.is_file(), f"{file_path} is a file" with file_path.open("rb") as stream: config = parse_toml(stream) @@ -76,15 +76,15 @@ def ensure_device_name_is_valid(device_name: str) -> None: Uniqueness of names is ensured by their toml dict key nature, """ - if len(device_name) > 42: + if len(device_name) > DEVICE_NAME_MAX_LENGTH: # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig - raise InvalidConfiguration( - f"Invalid name for device '{device_name}': too long ({len(device_name)} characters, max is 42)" + raise InvalidConfigurationError( + f"Device name '{device_name}' is too long ({len(device_name)} characters, max is {DEVICE_NAME_MAX_LENGTH})" ) if "." in device_name: # This is not strictly needed but avoids potential zeroconf problems - raise InvalidConfiguration( - f"Invalid name for device '{device_name}': '.' character not allowed" + raise InvalidConfigurationError( + f"Invalid character '.' in device name '{device_name}'" ) @@ -109,13 +109,14 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: f"Install {needed_plugin} to add support for it!" f"e.g. `python -m pip install {needed_plugin}`", ) - raise InvalidConfiguration(f"{needed_plugin} not installed.") from error + msg = f"{needed_plugin} not installed." + raise InvalidConfigurationError(msg) from error logger.exception( f"Device type `{device_config['type']}` unknown in 'device.{device_name}'!" f"[Known types: {device_object_mapper.keys()}]", ) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Unknown device type `{device_config['type']}`." ) from error diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index eadeb035..f952744c 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -27,10 +27,10 @@ def __init__(self, port: int = 8000) -> None: and not ip.startswith("169.254") # Remove invalid IPs ] - async def add_device(self, name: str): - """Adds device to the server.""" + async def add_device(self, name: str) -> None: + """Add device to the server.""" properties = { - "path": r"http://" + f"{self.mdns_addresses[0]}:{self.port}/{name}/", + "path": rf"http://{self.mdns_addresses[0]}:{self.port}/{name}/", "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), } @@ -45,11 +45,12 @@ async def add_device(self, name: str): try: await self.server.async_register_service(service_info) - except NonUniqueNameException as nu: - raise RuntimeError( + except NonUniqueNameException as name_error: + msg = ( f"Cannot initialize zeroconf service for '{name}'" f"The same name is already in use: you cannot run flowchem twice for the same device!" - ) from nu + ) + raise RuntimeError(msg) from name_error logger.debug(f"Device {name} registered as Zeroconf service!") diff --git a/src/flowchem/utils/device_finder.py b/src/flowchem/utils/device_finder.py index b226513f..617b6750 100644 --- a/src/flowchem/utils/device_finder.py +++ b/src/flowchem/utils/device_finder.py @@ -1,4 +1,4 @@ -"""This module is used to autodiscover any supported devices connected to the PC.""" +"""Autodiscover any supported devices connected to the PC.""" from pathlib import Path import aioserial @@ -53,7 +53,7 @@ def inspect_serial_ports() -> set[str]: return dev_found_config -def inspect_eth(source_ip) -> set[str]: +def inspect_eth(source_ip: str) -> set[str]: """Search for known devices on ethernet and generate config stubs.""" logger.info("Starting ethernet detection") dev_found_config: set[str] = set() diff --git a/src/flowchem/utils/exceptions.py b/src/flowchem/utils/exceptions.py index f50b295f..cfc7a588 100644 --- a/src/flowchem/utils/exceptions.py +++ b/src/flowchem/utils/exceptions.py @@ -5,5 +5,5 @@ class DeviceError(BaseException): """Generic DeviceError.""" -class InvalidConfiguration(DeviceError): +class InvalidConfigurationError(DeviceError): """The configuration provided is not valid, e.g. no connection w/ device obtained.""" diff --git a/src/flowchem/vendor/getmac.py b/src/flowchem/vendor/getmac.py index 7cca4264..e54a99bc 100644 --- a/src/flowchem/vendor/getmac.py +++ b/src/flowchem/vendor/getmac.py @@ -370,7 +370,7 @@ def _read_file(filepath): def _hunt_for_mac(to_find, type_of_thing, net_ok=True): # type: (Optional[str], int, bool) -> Optional[str] - """Tries a variety of methods to get a MAC address. + """Try a variety of methods to get a MAC address. Format of method lists: Tuple: (regex, regex index, command, command args) Command args is a list of strings to attempt to use as arguments @@ -509,7 +509,7 @@ def _hunt_for_mac(to_find, type_of_thing, net_ok=True): def _try_methods(methods, to_find=None): # type: (list, Optional[str]) -> Optional[str] - """Runs the methods specified by _hunt_for_mac(). + """Run the methods specified by _hunt_for_mac(). We try every method and see if it returned a MAC address. If it returns None or raises an exception, we continue and try the next method. @@ -588,7 +588,7 @@ def _get_default_iface_freebsd(): def _fetch_ip_using_dns(): # type: () -> str - """Determines the IP address of the default network interface. + """Determine the IP address of the default network interface. Sends a UDP packet to Cloudflare's DNS (1.1.1.1), which should go through the default interface. This populates the source address of the socket, which we then inspect and return. diff --git a/src/flowchem/vendor/repeat_every.py b/src/flowchem/vendor/repeat_every.py index fbdd2187..5c62779a 100644 --- a/src/flowchem/vendor/repeat_every.py +++ b/src/flowchem/vendor/repeat_every.py @@ -25,7 +25,7 @@ def repeat_every( raise_exceptions: bool = False, max_repetitions: int | None = None, ) -> NoArgsNoReturnDecorator: - """Returns a decorator that modifies a function so it is periodically re-executed after its first call. + """Return a decorator that modifies a function so it is periodically re-executed after its first call. The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished by using `functools.partial` or otherwise wrapping the target function prior to decoration. @@ -51,7 +51,7 @@ def repeat_every( def decorator( func: NoArgsNoReturnAsyncFuncT | NoArgsNoReturnFuncT, ) -> NoArgsNoReturnAsyncFuncT: - """Converts the decorated function into a repeated, periodically-called version of itself.""" + """Convert the decorated function into a repeated, periodically-called version of itself.""" is_coroutine = asyncio.iscoroutinefunction(func) @wraps(func) diff --git a/tests/devices/analytics/test_flowir.py b/tests/devices/analytics/test_flowir.py index 846a1cc4..bf5193ea 100644 --- a/tests/devices/analytics/test_flowir.py +++ b/tests/devices/analytics/test_flowir.py @@ -1,7 +1,6 @@ """Test FlowIR, needs actual connection to the device :(.""" import asyncio import datetime -import sys import pytest @@ -9,16 +8,6 @@ from flowchem.devices.mettlertoledo.icir import IcIR -def check_pytest_asyncio_installed(): - """Utility function for pytest plugin.""" - import os - from importlib import util - - if not util.find_spec("pytest_asyncio"): - print("You need to install pytest-asyncio first!", file=sys.stderr) - sys.exit(os.EX_SOFTWARE) - - @pytest.fixture() async def spectrometer(): """Return local FlowIR object.""" diff --git a/tests/devices/technical/test_huber.py b/tests/devices/technical/test_huber.py index 20deacfb..6db9f287 100644 --- a/tests/devices/technical/test_huber.py +++ b/tests/devices/technical/test_huber.py @@ -8,7 +8,7 @@ from flowchem.devices.huber import HuberChiller from flowchem.devices.huber.pb_command import PBCommand -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError @pytest.fixture @@ -56,7 +56,7 @@ def test_pbcommand_parse_bool(): def test_invalid_serial_port(): - with pytest.raises(InvalidConfiguration) as execinfo: + with pytest.raises(InvalidConfigurationError) as execinfo: HuberChiller.from_config(port="COM99") assert ( str(execinfo.value) == "Cannot connect to the HuberChiller on the port " diff --git a/tests/server/test_config_parser.py b/tests/server/test_config_parser.py index 24a9d8e0..d32fc2cf 100644 --- a/tests/server/test_config_parser.py +++ b/tests/server/test_config_parser.py @@ -5,7 +5,7 @@ from flowchem_test.fakedevice import FakeDevice from flowchem.server.configuration_parser import parse_config -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError def test_minimal_valid_config(): @@ -25,6 +25,6 @@ def test_minimal_valid_config(): def test_name_too_long(): cfg_txt = BytesIO(b"""[device.this_name_is_too_long_and_should_be_shorter]""") - with pytest.raises(InvalidConfiguration) as excinfo: + with pytest.raises(InvalidConfigurationError) as excinfo: parse_config(cfg_txt) assert "too long" in str(excinfo.value) From 1ba9559bbdb0ac7af0bdcede4abcae81b86f81fe Mon Sep 17 00:00:00 2001 From: dcambie <2422614+dcambie@users.noreply.github.com> Date: Thu, 13 Jul 2023 17:24:17 +0000 Subject: [PATCH 36/62] New device syntax (#100) * Docstring improvements * components as list in device instance attribute --- .../reaction_optimization/run_experiment.py | 4 +- pyproject.toml | 4 +- src/flowchem/__main__.py | 2 +- src/flowchem/client/component_client.py | 6 +- src/flowchem/components/analytics/hplc.py | 4 +- src/flowchem/components/analytics/ir.py | 2 +- src/flowchem/components/analytics/nmr.py | 2 +- src/flowchem/components/device_info.py | 2 +- src/flowchem/components/pumps/hplc.py | 1 - src/flowchem/components/sensors/photo.py | 1 - src/flowchem/components/sensors/pressure.py | 1 - src/flowchem/devices/bronkhorst/el_flow.py | 161 +++++++----------- .../devices/bronkhorst/el_flow_component.py | 5 +- src/flowchem/devices/dataapex/clarity.py | 19 +-- .../devices/dataapex/clarity_hplc_control.py | 2 +- src/flowchem/devices/flowchem_device.py | 5 +- src/flowchem/devices/hamilton/ml600.py | 26 ++- src/flowchem/devices/hamilton/ml600_finder.py | 6 +- src/flowchem/devices/hamilton/ml600_pump.py | 4 +- src/flowchem/devices/hamilton/ml600_valve.py | 2 +- .../devices/harvardapparatus/_pumpio.py | 10 +- .../devices/harvardapparatus/elite11.py | 21 ++- .../harvardapparatus/elite11_finder.py | 6 +- .../devices/harvardapparatus/elite11_pump.py | 4 +- src/flowchem/devices/huber/chiller.py | 26 +-- src/flowchem/devices/huber/huber_finder.py | 6 +- src/flowchem/devices/knauer/_common.py | 8 +- src/flowchem/devices/knauer/azura_compact.py | 9 +- src/flowchem/devices/knauer/dad.py | 24 ++- src/flowchem/devices/knauer/dad_component.py | 1 - src/flowchem/devices/knauer/knauer_finder.py | 2 +- src/flowchem/devices/knauer/knauer_valve.py | 28 +-- src/flowchem/devices/magritek/spinsolve.py | 6 +- src/flowchem/devices/magritek/utils.py | 4 +- .../devices/manson/manson_power_supply.py | 12 +- src/flowchem/devices/mettlertoledo/icir.py | 7 +- .../devices/mettlertoledo/icir_finder.py | 2 +- .../devices/phidgets/bubble_sensor.py | 26 +-- .../phidgets/bubble_sensor_component.py | 1 - .../devices/phidgets/pressure_sensor.py | 6 +- .../phidgets/pressure_sensor_component.py | 1 - .../vacuubrand/{utils.py => constants.py} | 0 src/flowchem/devices/vacuubrand/cvc3000.py | 16 +- .../devices/vacuubrand/cvc3000_finder.py | 6 +- .../vacuubrand/cvc3000_pressure_control.py | 2 +- src/flowchem/devices/vapourtec/r2.py | 80 +++++---- .../vapourtec/r2_components_control.py | 1 - src/flowchem/devices/vapourtec/r4_heater.py | 41 +++-- .../devices/vapourtec/vapourtec_finder.py | 6 +- src/flowchem/devices/vicivalco/vici_valve.py | 13 +- src/flowchem/server/configuration_parser.py | 29 ++-- src/flowchem/server/create_server.py | 6 +- src/flowchem/server/fastapi_server.py | 19 ++- src/flowchem/server/zeroconf_server.py | 15 +- src/flowchem/utils/device_finder.py | 4 +- src/flowchem/utils/exceptions.py | 2 +- src/flowchem/vendor/getmac.py | 6 +- src/flowchem/vendor/repeat_every.py | 4 +- tests/devices/analytics/test_flowir.py | 11 -- tests/devices/technical/test_huber.py | 4 +- tests/server/test_config_parser.py | 4 +- 61 files changed, 338 insertions(+), 400 deletions(-) rename src/flowchem/devices/vacuubrand/{utils.py => constants.py} (100%) diff --git a/examples/reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py index e2ba0f6c..22eedf41 100644 --- a/examples/reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -83,7 +83,7 @@ def wait_stable_temperature(): def get_ir_once_stable(): - """Keeps acquiring IR spectra until changes are small, then returns the spectrum.""" + """Keep acquiring IR spectra until changes are small, then returns the spectrum.""" logger.info("Waiting for the IR spectrum to be stable") with command_session() as sess: # Wait for first spectrum to be available @@ -155,7 +155,7 @@ def run_experiment( temperature: float, residence_time: float, ) -> float: - """Runs one experiment with the provided conditions. + """Run one experiment with the provided conditions. Args: ---- diff --git a/pyproject.toml b/pyproject.toml index a3fed7bf..70f2c892 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,8 +107,8 @@ python_version = "3.10" testpaths = "tests" asyncio_mode = "auto" # No cov needed for pycharm debugger -addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" -#addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" +#addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" +addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" markers = [ "HApump: tests requiring a local HA Elite11 connected.", diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 2d0becb9..147a7204 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -61,7 +61,7 @@ def main(device_config_file, logfile, host, debug): logger.debug(f"Starting server with configuration file: '{device_config_file}'") async def main_loop(): - """The loop must be shared between uvicorn and flowchem.""" + """Main application loop, the event loop is shared between uvicorn and flowchem.""" flowchem_instance = await create_server_from_file(Path(device_config_file)) config = uvicorn.Config( flowchem_instance["api_server"].app, diff --git a/src/flowchem/client/component_client.py b/src/flowchem/client/component_client.py index 6663f610..facb5b2d 100644 --- a/src/flowchem/client/component_client.py +++ b/src/flowchem/client/component_client.py @@ -18,13 +18,13 @@ def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient") -> None: self.component_info = ComponentInfo.model_validate_json(self.get(url).text) def get(self, url, **kwargs): - """Sends a GET request. Returns :class:`Response` object.""" + """Send a GET request. Returns :class:`Response` object.""" return self._session.get(url, **kwargs) def post(self, url, data=None, json=None, **kwargs): - """Sends a POST request. Returns :class:`Response` object.""" + """Send a POST request. Returns :class:`Response` object.""" return self._session.post(url, data=data, json=json, **kwargs) def put(self, url, data=None, **kwargs): - """Sends a PUT request. Returns :class:`Response` object.""" + """Send a PUT request. Returns :class:`Response` object.""" return self._session.put(url, data=data, **kwargs) diff --git a/src/flowchem/components/analytics/hplc.py b/src/flowchem/components/analytics/hplc.py index 87408a76..b7b2126c 100644 --- a/src/flowchem/components/analytics/hplc.py +++ b/src/flowchem/components/analytics/hplc.py @@ -23,12 +23,12 @@ def __init__(self, name: str, hw_device: FlowchemDevice) -> None: self.component_info.type = "HPLC Control" async def send_method(self, method_name): - """Submits a method to the HPLC. + """Submit method to HPLC. This is e.g. useful when the injection is automatically triggerd when switching a valve. """ ... async def run_sample(self, sample_name: str, method_name: str): - """Runs a sample at the HPLC with the provided sample name and method.""" + """Run HPLC sample with the provided sample name and method.""" ... diff --git a/src/flowchem/components/analytics/ir.py b/src/flowchem/components/analytics/ir.py index 51bb3a4e..92f7d219 100644 --- a/src/flowchem/components/analytics/ir.py +++ b/src/flowchem/components/analytics/ir.py @@ -34,5 +34,5 @@ async def acquire_spectrum(self) -> IRSpectrum: # type: ignore ... async def stop(self): - """Stops acquisition and exit gracefully.""" + """Stop acquisition and exit gracefully.""" ... diff --git a/src/flowchem/components/analytics/nmr.py b/src/flowchem/components/analytics/nmr.py index 2bc47249..6136d6a5 100644 --- a/src/flowchem/components/analytics/nmr.py +++ b/src/flowchem/components/analytics/nmr.py @@ -23,5 +23,5 @@ async def acquire_spectrum(self, background_tasks: BackgroundTasks): ... async def stop(self): - """Stops acquisition and exit gracefully.""" + """Stop acquisition and exit gracefully.""" ... diff --git a/src/flowchem/components/device_info.py b/src/flowchem/components/device_info.py index b31af2cf..1e36fe60 100644 --- a/src/flowchem/components/device_info.py +++ b/src/flowchem/components/device_info.py @@ -15,7 +15,7 @@ class DeviceInfo(BaseModel): model: str = "" version: str = "" serial_number: str | int = "unknown" - components: list[AnyHttpUrl] = [] + components: dict[str, AnyHttpUrl] = {} backend: str = f"flowchem v. {__version__}" authors: list[Person] = [] additional_info: dict = {} diff --git a/src/flowchem/components/pumps/hplc.py b/src/flowchem/components/pumps/hplc.py index 82b46d18..cede6a45 100644 --- a/src/flowchem/components/pumps/hplc.py +++ b/src/flowchem/components/pumps/hplc.py @@ -6,7 +6,6 @@ class HPLCPump(BasePump): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) # Ontology: HPLC isocratic pump diff --git a/src/flowchem/components/sensors/photo.py b/src/flowchem/components/sensors/photo.py index 9e3ee25a..480e0224 100644 --- a/src/flowchem/components/sensors/photo.py +++ b/src/flowchem/components/sensors/photo.py @@ -8,7 +8,6 @@ class PhotoSensor(Sensor): """A photo sensor.""" def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/acquire-signal", self.acquire_signal, methods=["GET"]) self.add_api_route("/calibration", self.calibrate_zero, methods=["PUT"]) diff --git a/src/flowchem/components/sensors/pressure.py b/src/flowchem/components/sensors/pressure.py index 2d170f0f..34e99334 100644 --- a/src/flowchem/components/sensors/pressure.py +++ b/src/flowchem/components/sensors/pressure.py @@ -8,7 +8,6 @@ class PressureSensor(Sensor): """A pressure sensor.""" def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/read-pressure", self.read_pressure, methods=["GET"]) diff --git a/src/flowchem/devices/bronkhorst/el_flow.py b/src/flowchem/devices/bronkhorst/el_flow.py index 47046a3a..7bc22150 100644 --- a/src/flowchem/devices/bronkhorst/el_flow.py +++ b/src/flowchem/devices/bronkhorst/el_flow.py @@ -1,20 +1,18 @@ -"""el-flow MFC control by python package bronkhorst-propar -https://bronkhorst-propar.readthedocs.io/en/latest/introduction.html. -""" +"""Bronkhorst El-flow mass flow controller (MFC) device driver.""" import asyncio +# Manufacturer package, see https://bronkhorst-propar.readthedocs.io/en/latest/introduction.html. import propar from loguru import logger from flowchem import ureg -from flowchem.components.device_info import DeviceInfo from flowchem.devices.bronkhorst.el_flow_component import EPCComponent, MFCComponent from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.utils.people import dario, jakob, wei_hsin +from flowchem.utils.people import wei_hsin -class EPC(FlowchemDevice): - DEFAULT_CONFIG = {"channel": 1, "baudrate": 38400} # "address": 0x80 +class ProparDevice(FlowchemDevice): + """Common functions for all propar devices (i.e. EPC and MFC).""" def __init__( self, @@ -22,35 +20,44 @@ def __init__( name="", channel: int = 1, address: int = 0x80, - max_pressure: float = 10, # bar = 100 % = 32000 ) -> None: - self.port = port - self.channel = channel - self.address = address - self.max_pressure = max_pressure super().__init__(name) - self.device_info = DeviceInfo( - authors=[dario, jakob, wei_hsin], - manufacturer="bronkhorst", - model="EPC", - ) + # Metadata + self.device_info.authors = [wei_hsin] + self.device_info.manufacturer = "Bronkhorst" try: - self.el_press = propar.instrument( - self.port, - address=self.address, - channel=self.channel, - ) - self.id = self.el_press.id() - logger.debug(f"Connected {self.id} to {self.port}") - return + self.device = propar.instrument(port, address, channel) except OSError as e: - raise ConnectionError(f"Error connecting to {self.port} -- {e}") from e + raise ConnectionError(f"Error connecting to {port} -- {e}") from e + + self.id = self.device.id() + self.wink = self.device.wink + logger.debug(f"Connected to {self.id} on port {port}") + + +class EPC(ProparDevice): + """Electronic Pressure Controller (EPC), mostly used as backpressure regulator (BPR).""" + + def __init__( + self, + port: str, + name="", + channel: int = 1, + address: int = 0x80, + max_pressure: float = 10, # bar = 100 % = 32000 + ) -> None: + super().__init__(port, name, channel, address) + self.max_pressure = max_pressure + + # Metadata + self.device_info.model = "EL-PRESS" async def initialize(self): - """Ensure connection.""" + """Initialize device.""" await self.set_pressure("0 bar") + self.components.append(EPCComponent("EPC", self)) async def set_pressure(self, pressure: str): """Set the setpoint of the instrument (0-32000 = 0-max pressure = 0-100%).""" @@ -60,41 +67,26 @@ async def set_pressure(self, pressure: str): set_p = ureg.Quantity(pressure) set_n = round(set_p.m_as("bar") * 32000 / self.max_pressure) if set_n > 32000: - self.el_press.setpoint = 32000 + self.device.setpoint = 32000 logger.debug( "setting higher than maximum flow rate! set the flow rate to 100%", ) else: - self.el_press.setpoint = set_n + self.device.setpoint = set_n logger.debug(f"set the pressure to {set_n / 320}%") async def get_pressure(self) -> float: """Get current flow rate in ml/min.""" - m_num = float(self.el_press.measure) + m_num = float(self.device.measure) return m_num / 32000 * self.max_pressure async def get_pressure_percentage(self) -> float: """Get current flow rate in percentage.""" - m_num = float(self.el_press.measure) + m_num = float(self.device.measure) return m_num / 320 - async def wink(self): - """Wink the LEDs on the instrument.""" - # default wink 9 time - self.el_press.wink() - - async def get_id(self): - """Reads the Serial Number (SN) of the instrument.""" - return self.el_press.id - - def components(self): - """Return a component.""" - return (EPCComponent("el_press_EPC", self),) - - -class MFC(FlowchemDevice): - DEFAULT_CONFIG = {"channel": 1, "baudrate": 38400} # "address": 0x80 +class MFC(ProparDevice): def __init__( self, port: str, @@ -103,33 +95,18 @@ def __init__( address: int = 0x80, max_flow: float = 9, # ml / min = 100 % = 32000 ) -> None: - self.port = port - self.channel = channel - self.address = address + super().__init__(port, name, channel, address) self.max_flow = max_flow - super().__init__(name) - - self.device_info = DeviceInfo( - authors=[dario, jakob, wei_hsin], - manufacturer="bronkhorst", - model="MFC", - ) - try: - self.el_flow = propar.instrument( - self.port, - address=self.address, - channel=self.channel, - ) - self.id = self.el_flow.id() - logger.debug(f"Connected {self.id} to {self.port}") - return - except OSError as e: - raise ConnectionError(f"Error connecting to {self.port} -- {e}") from e + # Metadata + self.device_info.model = "EL-FLOW" async def initialize(self): """Ensure connection.""" await self.set_flow_setpoint("0 ul/min") + self.components.append( + MFCComponent("MFC", self), + ) async def set_flow_setpoint(self, flowrate: str): """Set the setpoint of the instrument (0-32000 = 0-max flowrate = 0-100%).""" @@ -141,37 +118,24 @@ async def set_flow_setpoint(self, flowrate: str): set_f = ureg.Quantity(flowrate) set_n = round(set_f.m_as("ml/min") * 32000 / self.max_flow) if set_n > 32000: - self.el_flow.setpoint = 32000 + self.device.setpoint = 32000 logger.debug( "setting higher than maximum flow rate! set the flow rate to 100%", ) else: - self.el_flow.setpoint = set_n + self.device.setpoint = set_n logger.debug(f"set the flow rate to {set_n / 320}%") async def get_flow_setpoint(self) -> float: """Get current flow rate in ml/min.""" - m_num = float(self.el_flow.measure) + m_num = float(self.device.measure) return m_num / 32000 * self.max_flow async def get_flow_percentage(self) -> float: """Get current flow rate in percentage.""" - m_num = float(self.el_flow.measure) + m_num = float(self.device.measure) return m_num / 320 - async def wink(self): - """Wink the LEDs on the instrument.""" - # default wink 9 time - self.el_flow.wink() - - async def get_id(self): - """Reads the ID parameter of the instrument.""" - return self.el_flow.id - - def components(self): - """Return a component.""" - return (MFCComponent("el_flow_MFC", self),) - async def gas_flow(port: str, target_flowrate: str, reaction_time: float): Oxygen_flow = MFC(port, max_flow=9) @@ -183,21 +147,10 @@ async def gas_flow(port: str, target_flowrate: str, reaction_time: float): # # # Oxygen_flow.set_flow_setpoint(target_point*32000/9.0) # # await asyncio.sleep(reaction_time*60) - O2_flow_id = await Oxygen_flow.get_id() - print(O2_flow_id) + print(Oxygen_flow.id) await Oxygen_flow.set_flow_setpoint("0 ml/min") -async def mutiple_connect(): - flow = MFC("COM7", address=1, max_flow=10) - pressure = EPC("COM7", address=2, max_pressure=10) - O2_flow = MFC("COM7", address=6, max_flow=10) - - print(await pressure.get_id()) - print(await flow.get_id()) - print(await O2_flow.get_id()) - - def find_devices_info(port: str): """It is also possible to only create a master. This removes some abstraction offered by the instrument class, @@ -220,9 +173,17 @@ def find_devices_info(port: str): if __name__ == "__main__": # find_devices_info("COM7") - # asyncio.run(gas_flow("COM7", "0.05 ml/min", 25)) - asyncio.run(mutiple_connect()) - # print(flow.wink()) + + async def multiple_connect(): + flow = MFC("COM7", address=1, max_flow=10) + pressure = EPC("COM7", address=2, max_pressure=10) + O2_flow = MFC("COM7", address=6, max_flow=10) + + print(pressure.id) + print(flow.id) + print(O2_flow.id) + + asyncio.run(multiple_connect()) db = propar.database() parameters = db.get_parameters([8, 9, 11, 142]) diff --git a/src/flowchem/devices/bronkhorst/el_flow_component.py b/src/flowchem/devices/bronkhorst/el_flow_component.py index 176a29f1..114ff8be 100644 --- a/src/flowchem/devices/bronkhorst/el_flow_component.py +++ b/src/flowchem/devices/bronkhorst/el_flow_component.py @@ -18,8 +18,8 @@ def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic power supply.""" super().__init__(name, hw_device) self.add_api_route("/get-pressure", self.get_pressure, methods=["GET"]) - self.add_api_route("/stop", self.stop, methods=["PUT"]) self.add_api_route("/set-pressure", self.set_pressure_setpoint, methods=["PUT"]) + self.add_api_route("/stop", self.stop, methods=["PUT"]) async def read_pressure(self, units: str = "bar"): """Read from sensor, result to be expressed in units.""" @@ -45,11 +45,10 @@ class MFCComponent(FlowchemComponent): hw_device: MFC # just for typing def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic power supply.""" super().__init__(name, hw_device) self.add_api_route("/get-flow-rate", self.get_flow_setpoint, methods=["GET"]) - self.add_api_route("/stop", self.stop, methods=["PUT"]) self.add_api_route("/set-flow-rate", self.set_flow_setpoint, methods=["PUT"]) + self.add_api_route("/stop", self.stop, methods=["PUT"]) async def set_flow_setpoint(self, flowrate: str) -> bool: """Set flow rate to the instrument; default unit: ml/min.""" diff --git a/src/flowchem/devices/dataapex/clarity.py b/src/flowchem/devices/dataapex/clarity.py index 70ba8f67..09c69c0b 100644 --- a/src/flowchem/devices/dataapex/clarity.py +++ b/src/flowchem/devices/dataapex/clarity.py @@ -1,14 +1,13 @@ """Controls a local ClarityChrom instance via the CLI interface.""" -# See https://www.dataapex.com/documentation/Content/Help/110-technical-specifications/110.020-command-line-parameters/110.020-command-line-parameters.htm?Highlight=command%20line +# See https://www.dataapex.com/documentation/Content/Help/110-technical-specifications/110.020-command-line-parameters/110.020-command-line-parameters.htm import asyncio from pathlib import Path from shutil import which from loguru import logger -from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.utils.people import dario, jakob, wei_hsin +from flowchem.utils.people import jakob, wei_hsin from .clarity_hplc_control import ClarityComponent @@ -27,11 +26,10 @@ def __init__( cfg_file: str = "", ) -> None: super().__init__(name=name) - self.device_info = DeviceInfo( - authors=[dario, jakob, wei_hsin], - manufacturer="DataApex", - model="Clarity Chromatography", - ) + # Metadata + self.device_info.authors = [jakob, wei_hsin] + self.device_info.manufacturer = "DataApex" + self.device_info.model = "Clarity Chromatography" # Validate executable if which(executable): @@ -58,6 +56,7 @@ async def initialize(self): await self.execute_command(self._init_command) logger.info(f"Clarity startup: waiting {self.startup_time} seconds") await asyncio.sleep(self.startup_time) + self.components.append(ClarityComponent(name="clarity", hw_device=self)) async def execute_command(self, command: str, without_instrument_num: bool = False): """Execute claritychrom.exe command.""" @@ -74,7 +73,3 @@ async def execute_command(self, command: str, without_instrument_num: bool = Fal except TimeoutError: logger.error(f"Subprocess timeout expired (timeout = {self.cmd_timeout} s)") return False - - def get_components(self): - """Return an HPLC_Control component.""" - return (ClarityComponent(name="clarity", hw_device=self),) diff --git a/src/flowchem/devices/dataapex/clarity_hplc_control.py b/src/flowchem/devices/dataapex/clarity_hplc_control.py index a36ce93c..2ca10ab0 100644 --- a/src/flowchem/devices/dataapex/clarity_hplc_control.py +++ b/src/flowchem/devices/dataapex/clarity_hplc_control.py @@ -32,7 +32,7 @@ async def send_method( alias="method-name", ), ) -> bool: - """Sets the HPLC method (i.e. a file with .MET extension) to the instrument. + """Set HPLC method (i.e. a file with .MET extension). Make sure to select 'Send Method to Instrument' option in Method Sending Options dialog in System Configuration. """ diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index 3f8e4f3e..b77c1b3d 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -1,7 +1,6 @@ """Base object for all hardware-control device classes.""" from abc import ABC from collections import namedtuple -from collections.abc import Iterable from typing import TYPE_CHECKING from flowchem.components.device_info import DeviceInfo @@ -23,6 +22,7 @@ def __init__(self, name) -> None: """All device have a name, which is the key in the config dict thus unique.""" self.name = name self.device_info = DeviceInfo() + self.components: list["FlowchemComponent"] = [] async def initialize(self): """Use for setting up async connection to the device, populate components and update device_info with them.""" @@ -34,6 +34,3 @@ def repeated_task(self) -> RepeatedTaskInfo | None: def get_device_info(self) -> DeviceInfo: return self.device_info - - def components(self) -> Iterable["FlowchemComponent"]: - return () diff --git a/src/flowchem/devices/hamilton/ml600.py b/src/flowchem/devices/hamilton/ml600.py index d1ca09d2..782dcb2d 100644 --- a/src/flowchem/devices/hamilton/ml600.py +++ b/src/flowchem/devices/hamilton/ml600.py @@ -14,7 +14,7 @@ from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.hamilton.ml600_pump import ML600Pump from flowchem.devices.hamilton.ml600_valve import ML600Valve -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin if TYPE_CHECKING: @@ -80,7 +80,7 @@ def from_config(cls, config): try: serial_object = aioserial.AioSerial(**configuration) except aioserial.SerialException as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the pump on the port <{configuration.get('port')}>" ) from serial_exception @@ -101,11 +101,11 @@ async def _assign_pump_address(self) -> int: try: await self._write_async(b"1a\r") except aioserial.SerialException as e: - raise InvalidConfiguration from e + raise InvalidConfigurationError from e reply = await self._read_reply_async() if not reply or reply[:1] != "1": - raise InvalidConfiguration(f"No pump found on {self._serial.port}") + raise InvalidConfigurationError(f"No pump found on {self._serial.port}") # reply[1:2] should be the address of the last pump. However, this does not work reliably. # So here we enumerate the pumps explicitly instead last_pump = 0 @@ -155,7 +155,7 @@ async def write_and_read_reply_async(self, command: Protocol1Command) -> str: response = await self._read_reply_async() if not response: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"No response received from pump! " f"Maybe wrong pump address? (Set to {command.target_pump_num})" ) @@ -236,13 +236,13 @@ def __init__( self.syringe_volume = ureg.Quantity(syringe_volume) except AttributeError as attribute_error: logger.error(f"Invalid syringe volume {syringe_volume}!") - raise InvalidConfiguration( + raise InvalidConfigurationError( "Invalid syringe volume provided." "The syringe volume is a string with units! e.g. '5 ml'" ) from attribute_error if self.syringe_volume.m_as("ml") not in ML600.VALID_SYRINGE_VOLUME: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"The specified syringe volume ({syringe_volume}) is invalid!\n" f"The volume (in ml) has to be one of {ML600.VALID_SYRINGE_VOLUME}" ) @@ -256,7 +256,7 @@ def __init__( @classmethod def from_config(cls, **config): - """This class method is used to create instances via config file by the server for HTTP interface.""" + """Create instances via config file.""" # Many pump can be present on the same serial port with different addresses. # This shared list of HamiltonPumpIO objects allow shared state in a borg-inspired way, avoiding singletons # This is only relevant to programmatic instantiation, i.e. when from_config() is called per each pump from a @@ -286,7 +286,7 @@ def from_config(cls, **config): ) async def initialize(self, hw_init=False, init_speed: str = "200 sec / stroke"): - """Must be called after init before anything else.""" + """Initialize pump and its components.""" await self.pump_io.initialize() # Test connectivity by querying the pump's firmware version fw_cmd = Protocol1Command(command="U", target_pump_num=self.address) @@ -297,6 +297,8 @@ async def initialize(self, hw_init=False, init_speed: str = "200 sec / stroke"): if hw_init: await self.initialize_pump(speed=ureg.Quantity(init_speed)) + # Add device components + self.components.extend([ML600Pump("pump", self), ML600Valve("valve", self)]) async def send_command_and_read_reply(self, command: Protocol1Command) -> str: """Send a command to the pump. Here we just add the right pump number.""" @@ -377,7 +379,7 @@ def flowrate_to_seconds_per_stroke(self, flowrate: pint.Quantity): return (1 / flowrate_in_steps_sec).to("second/stroke") def _seconds_per_stroke_to_flowrate(self, second_per_stroke) -> float: - """Converts seconds per stroke to flow rate. Only internal use.""" + """Convert seconds per stroke to flow rate.""" flowrate = 1 / (second_per_stroke * self._steps_per_ml) return flowrate.to("ml/min") @@ -485,10 +487,6 @@ async def set_valve_position( # target_steps = str(int(target_steps)) # return await self.send_command_and_read_reply(Protocol1Command(command="YSN", command_value=target_steps)) - def components(self): - """Return a Syringe and a Valve component.""" - return ML600Pump("pump", self), ML600Valve("valve", self) - if __name__ == "__main__": import asyncio diff --git a/src/flowchem/devices/hamilton/ml600_finder.py b/src/flowchem/devices/hamilton/ml600_finder.py index e4d53d8d..2655ef75 100644 --- a/src/flowchem/devices/hamilton/ml600_finder.py +++ b/src/flowchem/devices/hamilton/ml600_finder.py @@ -4,7 +4,7 @@ from loguru import logger -from flowchem.devices.hamilton.ml600 import HamiltonPumpIO, InvalidConfiguration +from flowchem.devices.hamilton.ml600 import HamiltonPumpIO, InvalidConfigurationError def ml600_finder(serial_port) -> set[str]: @@ -17,12 +17,12 @@ def ml600_finder(serial_port) -> set[str]: try: link = HamiltonPumpIO.from_config({"port": serial_port}) - except InvalidConfiguration: + except InvalidConfigurationError: return dev_config try: asyncio.run(link.initialize(hw_initialization=False)) - except InvalidConfiguration: + except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector link._serial.close() return dev_config diff --git a/src/flowchem/devices/hamilton/ml600_pump.py b/src/flowchem/devices/hamilton/ml600_pump.py index af368e0d..f11406c7 100644 --- a/src/flowchem/devices/hamilton/ml600_pump.py +++ b/src/flowchem/devices/hamilton/ml600_pump.py @@ -21,11 +21,11 @@ def is_withdrawing_capable(): return True async def is_pumping(self) -> bool: - """True if pump is moving.""" + """Check if pump is moving.""" return await self.hw_device.is_idle() is False async def stop(self): - """Stops pump.""" + """Stop pump.""" await self.hw_device.stop() async def infuse(self, rate: str = "", volume: str = "") -> bool: diff --git a/src/flowchem/devices/hamilton/ml600_valve.py b/src/flowchem/devices/hamilton/ml600_valve.py index 1d63c783..fd170999 100644 --- a/src/flowchem/devices/hamilton/ml600_valve.py +++ b/src/flowchem/devices/hamilton/ml600_valve.py @@ -28,7 +28,7 @@ async def set_position(self, position: str) -> bool: ) async def get_position(self) -> str: - """Current pump position.""" + """Get current pump position.""" pos = await self.hw_device.get_valve_position() reverse_position_mapping = { v: k for k, v in ML600Valve.position_mapping.items() diff --git a/src/flowchem/devices/harvardapparatus/_pumpio.py b/src/flowchem/devices/harvardapparatus/_pumpio.py index ed82ca2f..b9c29d12 100644 --- a/src/flowchem/devices/harvardapparatus/_pumpio.py +++ b/src/flowchem/devices/harvardapparatus/_pumpio.py @@ -5,7 +5,7 @@ import aioserial from loguru import logger -from flowchem.utils.exceptions import DeviceError, InvalidConfiguration +from flowchem.utils.exceptions import DeviceError, InvalidConfigurationError class PumpStatus(Enum): @@ -42,7 +42,7 @@ def __init__(self, port: str, **kwargs) -> None: self._serial = aioserial.AioSerial(port, **configuration) except aioserial.SerialException as serial_exception: logger.error(f"Cannot connect to the Pump on the port <{port}>") - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the Pump on the port <{port}>" ) from serial_exception @@ -53,7 +53,7 @@ async def _write(self, command: Protocol11Command): try: await self._serial.write_async(command_msg.encode("ascii")) except aioserial.SerialException as serial_exception: - raise InvalidConfiguration from serial_exception + raise InvalidConfigurationError from serial_exception logger.debug(f"Sent {command_msg!r}!") async def _read_reply(self) -> list[str]: @@ -120,7 +120,9 @@ async def write_and_read_reply( if not response: logger.error("No reply received from pump!") - raise InvalidConfiguration("No response received. Is the address right?") + raise InvalidConfigurationError( + "No response received. Is the address right?" + ) pump_address, status, parsed_response = self.parse_response(response) diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index 62117cd7..4358dfb2 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -20,7 +20,7 @@ Elite11PumpOnly, Elite11PumpWithdraw, ) -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -105,12 +105,12 @@ def __init__( if syringe_diameter: self._diameter = syringe_diameter else: - raise InvalidConfiguration("Please provide the syringe diameter!") + raise InvalidConfigurationError("Please provide the syringe diameter!") if syringe_volume: self._syringe_volume = syringe_volume else: - raise InvalidConfiguration("Please provide the syringe volume!") + raise InvalidConfigurationError("Please provide the syringe volume!") self.device_info = DeviceInfo( authors=[dario, jakob, wei_hsin], @@ -174,7 +174,7 @@ async def initialize(self): try: await self.stop() except IndexError as index_e: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Check pump address! Currently {self.address=}" ) from index_e @@ -193,6 +193,12 @@ async def initialize(self): # Clear target volume eventually set to prevent pump from stopping prematurely await self.set_target_volume("0 ml") + # Add components + if self._infuse_only: + self.components.append(Elite11PumpOnly("pump", self)) + else: + self.components.append(Elite11PumpWithdraw("pump", self)) + @staticmethod def _parse_version(version_text: str) -> tuple[int, int, int]: """Extract semver from Elite11 version string, e.g. '11 ELITE I/W Single 3.0.4'.""" @@ -393,13 +399,6 @@ async def pump_info(self) -> PumpInfo: ) return PumpInfo.parse_pump_string(parsed_multiline_response) - def components(self): - """Return pump component.""" - if self._infuse_only: - return (Elite11PumpOnly("pump", self),) - else: - return (Elite11PumpWithdraw("pump", self),) - if __name__ == "__main__": pump = Elite11.from_config( diff --git a/src/flowchem/devices/harvardapparatus/elite11_finder.py b/src/flowchem/devices/harvardapparatus/elite11_finder.py index 7760b4f1..513a2079 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_finder.py +++ b/src/flowchem/devices/harvardapparatus/elite11_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices.harvardapparatus.elite11 import Elite11, HarvardApparatusPumpIO -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -18,7 +18,7 @@ def elite11_finder(serial_port) -> list[str]: try: link = HarvardApparatusPumpIO(port=serial_port) - except InvalidConfiguration: + except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector return [] @@ -41,7 +41,7 @@ def elite11_finder(serial_port) -> list[str]: address=address, ) asyncio.run(test_pump.pump_info()) - except InvalidConfiguration: + except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector link._serial.close() return [] diff --git a/src/flowchem/devices/harvardapparatus/elite11_pump.py b/src/flowchem/devices/harvardapparatus/elite11_pump.py index d20f61c8..065aae1f 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_pump.py +++ b/src/flowchem/devices/harvardapparatus/elite11_pump.py @@ -19,11 +19,11 @@ def is_withdrawing_capable(): return False async def is_pumping(self) -> bool: - """True if pump is moving.""" + """Check if pump is moving.""" return await self.hw_device.is_moving() async def stop(self): - """Stops pump.""" + """Stop pump.""" await self.hw_device.stop() async def infuse(self, rate: str = "", volume: str = "0 ml") -> bool: diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index cad2e8e5..d74a6320 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -11,7 +11,7 @@ from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.huber.huber_temperature_control import HuberTemperatureControl from flowchem.devices.huber.pb_command import PBCommand -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -56,7 +56,7 @@ def from_config(cls, port, name=None, **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **configuration) except (OSError, aioserial.SerialException) as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the HuberChiller on the port <{port}>" ) from serial_exception @@ -66,7 +66,7 @@ async def initialize(self): """Ensure the connection w/ device is working.""" self.device_info.serial_number = str(await self.serial_number()) if self.device_info.serial_number == "0": - raise InvalidConfiguration("No reply received from Huber Chiller!") + raise InvalidConfigurationError("No reply received from Huber Chiller!") logger.debug( f"Connected with Huber Chiller S/N {self.device_info.serial_number}", ) @@ -87,6 +87,16 @@ async def initialize(self): ) self._max_t = device_limits[1] + temperature_range = TempRange( + min=ureg.Quantity(self._min_t), + max=ureg.Quantity(self._max_t), + ) + + # Set TemperatureControl component. + self.components.append( + HuberTemperatureControl("temperature-control", self, temperature_range) + ) + async def _send_command_and_read_reply(self, command: str) -> str: """Send a command to the chiller and read the reply. @@ -178,16 +188,6 @@ def _int_to_string(number: int) -> str: """From int to string for command. f^-1 of PCommand.parse_integer.""" return f"{number:04X}" - def components(self): - """Return a TemperatureControl component.""" - temperature_limits = TempRange( - min=ureg.Quantity(self._min_t), - max=ureg.Quantity(self._max_t), - ) - return ( - HuberTemperatureControl("temperature-control", self, temperature_limits), - ) - # async def return_temperature(self) -> float | None: # """Return the temp of the thermal fluid flowing back to the device.""" # reply = await self._send_command_and_read_reply("{M02****") diff --git a/src/flowchem/devices/huber/huber_finder.py b/src/flowchem/devices/huber/huber_finder.py index 9573e6ed..e0550ef2 100644 --- a/src/flowchem/devices/huber/huber_finder.py +++ b/src/flowchem/devices/huber/huber_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices.huber.chiller import HuberChiller -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -15,12 +15,12 @@ def chiller_finder(serial_port) -> list[str]: try: chill = HuberChiller.from_config(port=serial_port) - except InvalidConfiguration: + except InvalidConfigurationError: return [] try: asyncio.run(chill.initialize()) - except InvalidConfiguration: + except InvalidConfigurationError: chill._serial.close() return [] diff --git a/src/flowchem/devices/knauer/_common.py b/src/flowchem/devices/knauer/_common.py index 79432d5f..e101e718 100644 --- a/src/flowchem/devices/knauer/_common.py +++ b/src/flowchem/devices/knauer/_common.py @@ -3,7 +3,7 @@ from loguru import logger -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from .knauer_finder import autodiscover_knauer @@ -51,7 +51,7 @@ def _ip_from_mac(self, mac_address: str) -> str: # IP if found, None otherwise ip_address = available_devices.get(mac_address) if ip_address is None: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"{self.__class__.__name__}:{self.name}\n" # type: ignore f"Device with MAC address={mac_address} not found!\n" f"[Available: {available_devices}]" @@ -66,12 +66,12 @@ async def initialize(self): self._reader, self._writer = await asyncio.wait_for(future, timeout=3) except OSError as connection_error: logger.exception(connection_error) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot open connection with device {self.__class__.__name__} at IP={self.ip_address}" ) from connection_error except asyncio.TimeoutError as timeout_error: logger.exception(timeout_error) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"No reply from device {self.__class__.__name__} at IP={self.ip_address}" ) from timeout_error diff --git a/src/flowchem/devices/knauer/azura_compact.py b/src/flowchem/devices/knauer/azura_compact.py index 618a79f9..cdca8414 100644 --- a/src/flowchem/devices/knauer/azura_compact.py +++ b/src/flowchem/devices/knauer/azura_compact.py @@ -95,6 +95,11 @@ async def initialize(self): if self._pressure_min: await self.set_minimum_pressure(self._pressure_min) + # Set Pump and Sensor components. + self.components.extend( + [AzuraCompactPump("pump", self), AzuraCompactSensor("pressure", self)] + ) + @staticmethod def error_present(reply: str) -> bool: """Return True if there are errors, False otherwise. Warns for errors.""" @@ -408,10 +413,6 @@ async def enable_analog_control(self, value: bool): await self.create_and_send_command(EXTCONTR, setpoint=int(value)) logger.debug(f"External control set to {value}") - def components(self): - """Create a Pump and a Sensor components.""" - return AzuraCompactPump("pump", self), AzuraCompactSensor("pressure", self) - if __name__ == "__main__": # This is a bug of asyncio on Windows :| diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index 10a6a04c..237640b4 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -11,11 +11,11 @@ DADChannelControl, KnauerDADLampControl, ) -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin if TYPE_CHECKING: - from flowchem.components.base_component import FlowchemComponent + pass try: from flowchem_knauer import KnauerDADCommands @@ -46,7 +46,7 @@ def __init__( self._control = display_control # True for Local if not HAS_DAD_COMMANDS: - raise InvalidConfiguration( + raise InvalidConfigurationError( "You tried to use a Knauer DAD device but the relevant commands are missing!\n" "Unfortunately, we cannot publish those as they were provided under NDA.\n" "Contact Knauer for further assistance." @@ -79,6 +79,12 @@ async def initialize(self): await self.bandwidth(8) logger.info("set channel 1 : WL = 514 nm, BW = 20nm ") + # Set components + self.components.append(KnauerDADLampControl("d2", self)) + self.components.append(KnauerDADLampControl("hal", self)) + channels = [DADChannelControl(f"channel{n + 1}", self, n + 1) for n in range(4)] + self.components.extend(channels) + async def d2(self, state: bool = True) -> str: """Turn off or on the deuterium lamp.""" cmd = self.cmd.D2_LAMP_ON if state else self.cmd.D2_LAMP_OFF @@ -116,7 +122,7 @@ async def lamp(self, lamp: str, state: bool | str = "REQUEST") -> str: # if response.isnumeric() else _reverse_lampstatus_mapping[response[response.find(":") + 1:]] async def serial_num(self) -> str: - """Serial number.""" + """Get serial number.""" return await self._send_and_receive(self.cmd.SERIAL) async def identify(self) -> str: @@ -228,16 +234,6 @@ async def keepalive(): return 30, keepalive - def components(self) -> list["FlowchemComponent"]: - list_of_components: list[FlowchemComponent] = [ - KnauerDADLampControl("d2", self), - KnauerDADLampControl("hal", self), - ] - list_of_components.extend( - [DADChannelControl(f"channel{n + 1}", self, n + 1) for n in range(4)], - ) - return list_of_components - if __name__ == "__main__": k_dad = KnauerDAD( diff --git a/src/flowchem/devices/knauer/dad_component.py b/src/flowchem/devices/knauer/dad_component.py index 3a0dc444..f4974392 100644 --- a/src/flowchem/devices/knauer/dad_component.py +++ b/src/flowchem/devices/knauer/dad_component.py @@ -14,7 +14,6 @@ class KnauerDADLampControl(PowerSwitch): hw_device: KnauerDAD def __init__(self, name: str, hw_device: KnauerDAD) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.lamp = name self.add_api_route("/lamp_status", self.get_lamp, methods=["GET"]) diff --git a/src/flowchem/devices/knauer/knauer_finder.py b/src/flowchem/devices/knauer/knauer_finder.py index 5e8f91f8..a2aa8b0a 100644 --- a/src/flowchem/devices/knauer/knauer_finder.py +++ b/src/flowchem/devices/knauer/knauer_finder.py @@ -36,7 +36,7 @@ def datagram_received(self, data: bytes | str, addr: Address): async def get_device_type(ip_address: str) -> str: - """Detects the device type based on the reply to a test command or IP heuristic.""" + """Detect device type based on the reply to a test command or IP heuristic.""" fut = asyncio.open_connection(host=ip_address, port=10001) try: reader, writer = await asyncio.wait_for(fut, timeout=3) diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index a0b5f901..228603fd 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -54,6 +54,20 @@ async def initialize(self): # Detect valve type self.device_info.additional_info["valve-type"] = await self.get_valve_type() + # Set components + match self.device_info.additional_info["valve-type"]: + case KnauerValveHeads.SIX_PORT_TWO_POSITION: + valve_component = KnauerInjectionValve("injection-valve", self) + case KnauerValveHeads.SIX_PORT_SIX_POSITION: + valve_component = Knauer6PortDistribution("distribution-valve", self) + case KnauerValveHeads.TWELVE_PORT_TWELVE_POSITION: + valve_component = Knauer12PortDistribution("distribution-valve", self) + case KnauerValveHeads.SIXTEEN_PORT_SIXTEEN_POSITION: + valve_component = Knauer16PortDistribution("distribution-valve", self) + case _: + raise RuntimeError("Unknown valve type") + self.components.append(valve_component) + @staticmethod def handle_errors(reply: str): """Return True if there are errors, False otherwise. Warns for errors.""" @@ -146,21 +160,9 @@ async def get_raw_position(self) -> str: return await self._transmit_and_parse_reply("P") async def set_raw_position(self, position: str) -> bool: - """Sets the valve position, following valve nomenclature.""" + """Set valve position, following valve nomenclature.""" return await self._transmit_and_parse_reply(position) != "" - def components(self): - """Create the right type of Valve components based on head type.""" - match self.device_info.additional_info["valve-type"]: - case KnauerValveHeads.SIX_PORT_TWO_POSITION: - return (KnauerInjectionValve("injection-valve", self),) - case KnauerValveHeads.SIX_PORT_SIX_POSITION: - return (Knauer6PortDistribution("distribution-valve", self),) - case KnauerValveHeads.TWELVE_PORT_TWELVE_POSITION: - return (Knauer12PortDistribution("distribution-valve", self),) - case KnauerValveHeads.SIXTEEN_PORT_SIXTEEN_POSITION: - return (Knauer16PortDistribution("distribution-valve", self),) - if __name__ == "__main__": import asyncio diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 58c5e3ca..5a4c0422 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -147,6 +147,8 @@ async def initialize(self): await self.set_data_folder(self._data_folder) + self.components.append(SpinsolveControl("nmr-control", self)) + async def connection_listener(self): """Listen for replies and puts them in the queue.""" logger.debug("Spinsolve connection listener started!") @@ -387,10 +389,6 @@ def shim(self): """Shim on sample.""" raise NotImplementedError("Use run protocol with a shimming protocol instead!") - def components(self): - """Return SpinsolveControl.""" - return (SpinsolveControl("nmr-control", self),) - if __name__ == "__main__": diff --git a/src/flowchem/devices/magritek/utils.py b/src/flowchem/devices/magritek/utils.py index c0bbcebe..330337e9 100644 --- a/src/flowchem/devices/magritek/utils.py +++ b/src/flowchem/devices/magritek/utils.py @@ -5,7 +5,7 @@ from loguru import logger -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError def get_my_docs_path() -> Path: @@ -53,7 +53,7 @@ def folder_mapper(path_to_be_translated: Path | str): logger.exception( f"Cannot translate remote path {path_to_be_translated} to a local path!", ) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot translate remote path {path_to_be_translated} to a local path!" f"{path_to_be_translated} is not relative to {remote_root}" ) diff --git a/src/flowchem/devices/manson/manson_power_supply.py b/src/flowchem/devices/manson/manson_power_supply.py index e3ea0ddc..02d7d178 100644 --- a/src/flowchem/devices/manson/manson_power_supply.py +++ b/src/flowchem/devices/manson/manson_power_supply.py @@ -11,7 +11,7 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.manson.manson_component import MansonPowerControl -from flowchem.utils.exceptions import DeviceError, InvalidConfiguration +from flowchem.utils.exceptions import DeviceError, InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -39,7 +39,7 @@ def from_config(cls, port, name="", **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **serial_kwargs) except aioserial.SerialException as error: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the MansonPowerSupply on the port <{port}>" ) from error @@ -51,9 +51,11 @@ async def initialize(self): if not self.device_info.model: raise DeviceError("Communication with device failed!") if self.device_info.model not in self.MODEL_ALT_RANGE: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Device is not supported! [Supported models: {self.MODEL_ALT_RANGE}]" ) + # Set TemperatureControl component + self.components.append(MansonPowerControl("power-control", self)) @staticmethod def _format_voltage(voltage_value: str) -> str: @@ -279,10 +281,6 @@ async def set_voltage_and_current(self, voltage: str, current: str): await self.set_voltage(voltage) await self.set_current(current) - def get_components(self): - """Return an TemperatureControl component.""" - return (MansonPowerControl("power-control", self),) - # def get_router(self, prefix: str | None = None): # """Create an APIRouter for this MansonPowerSupply instance.""" # router = super().get_router() diff --git a/src/flowchem/devices/mettlertoledo/icir.py b/src/flowchem/devices/mettlertoledo/icir.py index 05acbc2a..ced28e68 100644 --- a/src/flowchem/devices/mettlertoledo/icir.py +++ b/src/flowchem/devices/mettlertoledo/icir.py @@ -96,6 +96,9 @@ async def initialize(self): probe = await self.probe_info() self.device_info.additional_info = probe.dict() + # Set IRSpectrometer component + self.components.append(IcIRControl("ir-control", self)) + def is_local(self): """Return true if the server is on the same machine running the python code.""" return any( @@ -297,10 +300,6 @@ async def wait_until_idle(self): while await self.probe_status() == "Running": await asyncio.sleep(0.2) - def components(self): - """Return an IRSpectrometer component.""" - return (IcIRControl("ir-control", self),) - if __name__ == "__main__": ... diff --git a/src/flowchem/devices/mettlertoledo/icir_finder.py b/src/flowchem/devices/mettlertoledo/icir_finder.py index ae0fb226..445d40cd 100644 --- a/src/flowchem/devices/mettlertoledo/icir_finder.py +++ b/src/flowchem/devices/mettlertoledo/icir_finder.py @@ -24,7 +24,7 @@ async def is_iCIR_running_locally() -> bool: async def generate_icir_config() -> str: - """Generates config string if iCIR is available.""" + """Generate config string if iCIR is available.""" if await is_iCIR_running_locally(): logger.debug("Local iCIR found!") return dedent( diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index d7245b6f..befc98a7 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -25,7 +25,9 @@ except ImportError: HAS_PHIDGET = False -from flowchem.utils.exceptions import InvalidConfiguration # configuration is not valid +from flowchem.utils.exceptions import ( + InvalidConfigurationError, +) # configuration is not valid class PhidgetPowerSource5V(FlowchemDevice): @@ -42,7 +44,7 @@ def __init__( """Initialize BubbleSensor with the given voltage range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Phidget unusable: library or package not installed." ) @@ -70,7 +72,7 @@ def __init__( self.phidget.openWaitForAttachment(1000) logger.debug("power of tube sensor is connected!") except PhidgetException as pe: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." ) from pe @@ -85,6 +87,9 @@ def __init__( serial_number=vint_serial_number, ) + async def initialize(self): + self.components.append(PhidgetBubbleSensorPowerComponent("5V", self)) + def __del__(self) -> None: """Ensure connection closure upon deletion.""" self.phidget.close() @@ -106,10 +111,6 @@ def is_poweron(self) -> bool: """Wheteher the power is on.""" return bool(self.phidget.getState()) - def components(self): - """Return a component.""" - return (PhidgetBubbleSensorPowerComponent("5V", self),) - class PhidgetBubbleSensor(FlowchemDevice): """Use a Phidget voltage input to translate a Tube Liquid Sensor OPB350 5 Valtage signal @@ -128,7 +129,7 @@ def __init__( """Initialize BubbleSensor with the given voltage range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Phidget unusable: library or package not installed." ) @@ -160,7 +161,7 @@ def __init__( self.phidget.openWaitForAttachment(1000) logger.debug("tube sensor is connected!") except PhidgetException as pe: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." ) from pe @@ -176,6 +177,9 @@ def __init__( serial_number=vint_serial_number, ) + async def initialize(self): + self.components.append(PhidgetBubbleSensorComponent("bubble-sensor", self)) + def __del__(self) -> None: """Ensure connection closure upon deletion.""" self.phidget.close() @@ -227,10 +231,6 @@ def read_intensity(self) -> float: # type: ignore # def getMaxVoltage(self): # https: // www.phidgets.com /?view = api - def components(self): - """Return a component.""" - return (PhidgetBubbleSensorComponent("bubble-sensor", self),) - if __name__ == "__main__": # turn on the power of the bubble tube diff --git a/src/flowchem/devices/phidgets/bubble_sensor_component.py b/src/flowchem/devices/phidgets/bubble_sensor_component.py index 4079aa2b..dd3a0607 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor_component.py +++ b/src/flowchem/devices/phidgets/bubble_sensor_component.py @@ -15,7 +15,6 @@ class PhidgetBubbleSensorComponent(Sensor): hw_device: PhidgetBubbleSensor # just for typing def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/set-data-Interval", self.power_on, methods=["PUT"]) self.add_api_route("/read-voltage", self.read_voltage, methods=["GET"]) diff --git a/src/flowchem/devices/phidgets/pressure_sensor.py b/src/flowchem/devices/phidgets/pressure_sensor.py index 53c6bb38..e7b28168 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor.py +++ b/src/flowchem/devices/phidgets/pressure_sensor.py @@ -21,7 +21,7 @@ from flowchem import ureg -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError class PhidgetPressureSensor(FlowchemDevice): @@ -38,7 +38,7 @@ def __init__( """Initialize PressureSensor with the given pressure range (sensor-specific!).""" super().__init__(name=name) if not HAS_PHIDGET: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Phidget unusable: library or package not installed." ) @@ -67,7 +67,7 @@ def __init__( self.phidget.openWaitForAttachment(1000) logger.debug("Pressure sensor connected!") except PhidgetException as pe: - raise InvalidConfiguration( + raise InvalidConfigurationError( "Cannot connect to sensor! Check it is not already opened elsewhere and settings..." ) from pe diff --git a/src/flowchem/devices/phidgets/pressure_sensor_component.py b/src/flowchem/devices/phidgets/pressure_sensor_component.py index 4be34982..e67f96f9 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor_component.py +++ b/src/flowchem/devices/phidgets/pressure_sensor_component.py @@ -13,7 +13,6 @@ class PhidgetPressureSensorComponent(PressureSensor): hw_device: PhidgetPressureSensor # just for typing def __init__(self, name: str, hw_device: FlowchemDevice) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) async def read_pressure(self, units: str = "bar"): diff --git a/src/flowchem/devices/vacuubrand/utils.py b/src/flowchem/devices/vacuubrand/constants.py similarity index 100% rename from src/flowchem/devices/vacuubrand/utils.py rename to src/flowchem/devices/vacuubrand/constants.py diff --git a/src/flowchem/devices/vacuubrand/cvc3000.py b/src/flowchem/devices/vacuubrand/cvc3000.py index f23d7ca0..6f2ef2d2 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000.py +++ b/src/flowchem/devices/vacuubrand/cvc3000.py @@ -8,8 +8,8 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vacuubrand.cvc3000_pressure_control import CVC3000PressureControl -from flowchem.devices.vacuubrand.utils import ProcessStatus -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.devices.vacuubrand.constants import ProcessStatus +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -51,7 +51,7 @@ def from_config(cls, port, name=None, **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **configuration) except (OSError, aioserial.SerialException) as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the CVC3000 on the port <{port}>" ) from serial_exception @@ -61,7 +61,7 @@ async def initialize(self): """Ensure the connection w/ device is working.""" self.device_info.version = await self.version() if not self.device_info.version: - raise InvalidConfiguration("No reply received from CVC3000!") + raise InvalidConfigurationError("No reply received from CVC3000!") # Set to CVC3000 mode and save await self._send_command_and_read_reply("CVC 3") @@ -76,6 +76,8 @@ async def initialize(self): logger.debug(f"Connected with CVC3000 version {self.device_info.version}") + self.components.append(CVC3000PressureControl("pressure-control", self)) + async def _send_command_and_read_reply(self, command: str) -> str: """Send command and read the reply. @@ -121,7 +123,7 @@ async def get_pressure(self): return float(pressure_text.split()[0]) async def motor_speed(self, speed): - """Sets motor speed to target % value.""" + """Set motor speed to target % value.""" return await self._send_command_and_read_reply(f"OUT_SP_2 {speed}") async def status(self) -> ProcessStatus: @@ -131,7 +133,3 @@ async def status(self) -> ProcessStatus: if not raw_status: raw_status = await self._send_command_and_read_reply("IN_STAT") return ProcessStatus.from_reply(raw_status) - - def components(self): - """Return a TemperatureControl component.""" - return (CVC3000PressureControl("pressure-control", self),) diff --git a/src/flowchem/devices/vacuubrand/cvc3000_finder.py b/src/flowchem/devices/vacuubrand/cvc3000_finder.py index 569e28b5..682b5a59 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_finder.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices.vacuubrand.cvc3000 import CVC3000 -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -15,12 +15,12 @@ def cvc3000_finder(serial_port) -> list[str]: try: cvc = CVC3000.from_config(port=serial_port) - except InvalidConfiguration: + except InvalidConfigurationError: return [] try: asyncio.run(cvc.initialize()) - except InvalidConfiguration: + except InvalidConfigurationError: cvc._serial.close() return [] diff --git a/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py b/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py index 16c4540a..e4135600 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_pressure_control.py @@ -5,7 +5,7 @@ from flowchem.components.technical.pressure import PressureControl from flowchem.devices.flowchem_device import FlowchemDevice -from flowchem.devices.vacuubrand.utils import ProcessStatus, PumpState +from flowchem.devices.vacuubrand.constants import ProcessStatus, PumpState if TYPE_CHECKING: from flowchem.devices.vacuubrand.cvc3000 import CVC3000 diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 5281fbe7..781880d8 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -25,7 +25,7 @@ R4Reactor, UV150PhotoReactor, ) -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin try: @@ -92,7 +92,7 @@ def __init__( self._max_t = max_temp if not HAS_VAPOURTEC_COMMANDS: - raise InvalidConfiguration( + raise InvalidConfigurationError( "You tried to use a Vapourtec device but the relevant commands are missing!\n" "Unfortunately, we cannot publish those as they were provided under NDA.\n" "Contact Vapourtec for further assistance." @@ -105,7 +105,7 @@ def __init__( try: self._serial = aioserial.AioSerial(**configuration) except aioserial.SerialException as ex: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Cannot connect to the R2 on the port <{config.get('port')}>" ) from ex @@ -143,20 +143,52 @@ async def initialize(self): await self.trigger_key_press("8") await self.power_on() + list_of_components = [ + R2MainSwitch("Power", self), + R2GeneralPressureSensor("PressureSensor", self), + R2GeneralSensor("GSensor2", self), + UV150PhotoReactor("PhotoReactor", self), + R2HPLCPump("Pump_A", self, "A"), + R2HPLCPump("Pump_B", self, "B"), + R2TwoPortValve("ReagentValve_A", self, 0), + R2TwoPortValve("ReagentValve_B", self, 1), + R2TwoPortValve("CollectionValve", self, 4), + R2InjectionValve("InjectionValve_A", self, 2), + R2InjectionValve("InjectionValve_B", self, 3), + R2PumpPressureSensor("PumpSensor_A", self, 0), + R2PumpPressureSensor("PumpSensor_B", self, 1), + ] + self.components.extend(list_of_components) + + # TODO if photoreactor -> REACTOR CHANNEL 1,3 AND 4 + UV150PhotoReactor + # if no photoreactor -> REACTOR CHANNEL 1-4 no UV150PhotoReactor + + # Create components for reactor bays + reactor_temp_limits = { + ch_num: TempRange(min=ureg.Quantity(t[0]), max=ureg.Quantity(t[1])) + for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) + } + + reactors = [ + R4Reactor(f"reactor-{n + 1}", self, n, reactor_temp_limits[n]) + for n in range(4) + ] + self.components.extend(reactors) + async def _write(self, command: str): - """Writes a command to the pump.""" + """Write a command to the pump.""" cmd = command + "\r\n" await self._serial.write_async(cmd.encode("ascii")) logger.debug(f"Sent command: {command!r}") async def _read_reply(self) -> str: - """Reads the pump reply from serial communication.""" + """Read the pump reply from serial communication.""" reply_string = await self._serial.readline_async() logger.debug(f"Reply received: {reply_string.decode('ascii').rstrip()}") return reply_string.decode("ascii") async def write_and_read_reply(self, command: str) -> str: - """Sends a command to the pump, read the replies and returns it, optionally parsed.""" + """Send a command to the pump, read the replies and return it, optionally parsed.""" self._serial.reset_input_buffer() # Clear input buffer, discarding all that is in the buffer. async with self._serial_lock: await self._write(command) @@ -174,7 +206,7 @@ async def write_and_read_reply(self, command: str) -> str: await self._write(command) # Allows 4 failures... if failure > 3: - raise InvalidConfiguration( + raise InvalidConfigurationError( "No response received from R2 module!" ) else: @@ -369,40 +401,6 @@ async def pooling(self) -> dict: AllState["Temp"] = await self.get_current_temperature() return AllState - def components(self): - list_of_components = [ - R2MainSwitch("Power", self), - R2GeneralPressureSensor("PressureSensor", self), - R2GeneralSensor("GSensor2", self), - UV150PhotoReactor("PhotoReactor", self), - R2HPLCPump("Pump_A", self, "A"), - R2HPLCPump("Pump_B", self, "B"), - R2TwoPortValve("ReagentValve_A", self, 0), - R2TwoPortValve("ReagentValve_B", self, 1), - R2TwoPortValve("CollectionValve", self, 4), - R2InjectionValve("InjectionValve_A", self, 2), - R2InjectionValve("InjectionValve_B", self, 3), - R2PumpPressureSensor("PumpSensor_A", self, 0), - R2PumpPressureSensor("PumpSensor_B", self, 1), - ] - - # TODO if photoreactor -> REACTOR CHANNEL 1,3 AND 4 + UV150PhotoReactor - # if no photoreactor -> REACTOR CHANNEL 1-4 no UV150PhotoReactor - - # Create components for reactor bays - reactor_temp_limits = { - ch_num: TempRange(min=ureg.Quantity(t[0]), max=ureg.Quantity(t[1])) - for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) - } - - reactors = [ - R4Reactor(f"reactor-{n + 1}", self, n, reactor_temp_limits[n]) - for n in range(4) - ] - list_of_components.extend(reactors) - - return list_of_components - if __name__ == "__main__": Vapourtec_R2 = R2(port="COM4") diff --git a/src/flowchem/devices/vapourtec/r2_components_control.py b/src/flowchem/devices/vapourtec/r2_components_control.py index 65ff73fd..45ebc319 100644 --- a/src/flowchem/devices/vapourtec/r2_components_control.py +++ b/src/flowchem/devices/vapourtec/r2_components_control.py @@ -31,7 +31,6 @@ class R2GeneralSensor(Sensor): hw_device: R2 # for typing's sake def __init__(self, name: str, hw_device: R2) -> None: - """A generic Syringe pump.""" super().__init__(name, hw_device) self.add_api_route("/monitor-system", self.monitor_sys, methods=["GET"]) self.add_api_route("/get-run-state", self.get_run_state, methods=["GET"]) diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index 7a18938e..b1067b8a 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -13,7 +13,7 @@ from flowchem.components.technical.temperature import TempRange from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vapourtec.r4_heater_channel_control import R4HeaterChannelControl -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin try: @@ -60,7 +60,7 @@ def __init__( "Unfortunately, we cannot publish those as they were provided under NDA." "Contact Vapourtec for further assistance." ) - raise InvalidConfiguration( + raise InvalidConfigurationError( msg, ) @@ -72,7 +72,7 @@ def __init__( self._serial = aioserial.AioSerial(**configuration) except aioserial.SerialException as ex: msg = f"Cannot connect to the R4Heater on the port <{config.get('port')}>" - raise InvalidConfiguration( + raise InvalidConfigurationError( msg, ) from ex @@ -86,21 +86,33 @@ async def initialize(self): """Ensure connection.""" self.device_info.version = await self.version() logger.info(f"Connected with R4Heater version {self.device_info.version}") + temp_limits = { + ch_num: TempRange( + min=ureg.Quantity(f"{t[0]} °C"), + max=ureg.Quantity(f"{t[1]} °C"), + ) + for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) + } + reactor_positions = [ + R4HeaterChannelControl(f"reactor{n+1}", self, n, temp_limits[n]) + for n in range(4) + ] + self.components.append(reactor_positions) async def _write(self, command: str): - """Writes a command to the pump.""" + """Write a command to the pump.""" cmd = command + "\r\n" await self._serial.write_async(cmd.encode("ascii")) logger.debug(f"Sent command: {command!r}") async def _read_reply(self) -> str: - """Reads the pump reply from serial communication.""" + """Read the pump reply from serial communication.""" reply_string = await self._serial.readline_async() logger.debug(f"Reply received: {reply_string.decode('ascii').rstrip()}") return reply_string.decode("ascii") async def write_and_read_reply(self, command: str) -> str: - """Sends a command to the pump, read the replies and returns it, optionally parsed.""" + """Send a command to the pump, read the replies and return it, optionally parsed.""" self._serial.reset_input_buffer() await self._write(command) logger.debug(f"Command {command} sent to R4!") @@ -108,7 +120,7 @@ async def write_and_read_reply(self, command: str) -> str: if not response: msg = "No response received from heating module!" - raise InvalidConfiguration(msg) + raise InvalidConfigurationError(msg) logger.debug(f"Reply received: {response}") return response.rstrip() @@ -143,7 +155,7 @@ async def get_status(self, channel) -> ChannelStatus: self.cmd.GET_STATUS.format(channel=channel), ) return R4Heater.ChannelStatus(raw_status[:1], raw_status[1:]) - except InvalidConfiguration as ex: + except InvalidConfigurationError as ex: failure += 1 # Allows 3 failures cause the R4 is choosy at times... if failure > 3: @@ -164,19 +176,6 @@ async def power_off(self, channel): """Turn off channel.""" await self.write_and_read_reply(self.cmd.POWER_OFF.format(channel=channel)) - def components(self): - temp_limits = { - ch_num: TempRange( - min=ureg.Quantity(f"{t[0]} °C"), - max=ureg.Quantity(f"{t[1]} °C"), - ) - for ch_num, t in enumerate(zip(self._min_t, self._max_t, strict=True)) - } - return [ - R4HeaterChannelControl(f"reactor{n+1}", self, n, temp_limits[n]) - for n in range(4) - ] - if __name__ == "__main__": import asyncio diff --git a/src/flowchem/devices/vapourtec/vapourtec_finder.py b/src/flowchem/devices/vapourtec/vapourtec_finder.py index 2e6f3360..639a4176 100644 --- a/src/flowchem/devices/vapourtec/vapourtec_finder.py +++ b/src/flowchem/devices/vapourtec/vapourtec_finder.py @@ -5,7 +5,7 @@ from loguru import logger from flowchem.devices import R4Heater -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError # noinspection PyProtectedMember @@ -18,14 +18,14 @@ def r4_finder(serial_port) -> list[str]: try: r4 = R4Heater(port=serial_port) - except InvalidConfiguration as ic: + except InvalidConfigurationError as ic: logger.error("config") raise ic return [] try: asyncio.run(r4.initialize()) - except InvalidConfiguration: + except InvalidConfigurationError: r4._serial.close() return [] diff --git a/src/flowchem/devices/vicivalco/vici_valve.py b/src/flowchem/devices/vicivalco/vici_valve.py index a807d853..8405a570 100644 --- a/src/flowchem/devices/vicivalco/vici_valve.py +++ b/src/flowchem/devices/vicivalco/vici_valve.py @@ -10,7 +10,7 @@ from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.vicivalco.vici_valve_component import ViciInjectionValve -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError from flowchem.utils.people import dario, jakob, wei_hsin @@ -62,7 +62,7 @@ def from_config(cls, port, **serial_kwargs): try: serial_object = aioserial.AioSerial(port, **configuration) except aioserial.SerialException as serial_exception: - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Could not open serial port {port} with configuration {configuration}" ) from serial_exception @@ -78,7 +78,7 @@ async def _read_reply(self, lines: int) -> str: if reply_string: logger.debug(f"Reply received: {reply_string}") else: - raise InvalidConfiguration( + raise InvalidConfigurationError( "No response received from valve! Check valve address?" ) @@ -178,6 +178,9 @@ async def initialize(self): self.device_info.version = await self.version() logger.info(f"Connected to {self.name} - FW ver.: {self.device_info.version}!") + # Add component + self.components.append(ViciInjectionValve("injection-valve", self)) + async def learn_positions(self) -> None: """Initialize valve only, there is no reply -> reply_lines = 0.""" learn = ViciCommand(valve_id=self.address, command="LRN") @@ -224,10 +227,6 @@ async def timed_toggle(self, injection_time: str): time_toggle = ViciCommand(valve_id=self.address, command="TT") await self.valve_io.write_and_read_reply(time_toggle) - def get_components(self): - """Return a Valve component.""" - return (ViciInjectionValve("injection-valve", self),) - if __name__ == "__main__": import asyncio diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 5a4db7c0..dff2a301 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -17,7 +17,9 @@ from loguru import logger from flowchem.devices.known_plugins import plugin_devices -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError + +DEVICE_NAME_MAX_LENGTH = 42 def parse_toml(stream: typing.BinaryIO) -> dict: @@ -29,9 +31,8 @@ def parse_toml(stream: typing.BinaryIO) -> dict: return tomllib.load(stream) except tomllib.TOMLDecodeError as parser_error: logger.exception(parser_error) - raise InvalidConfiguration( - "The configuration provided does not contain valid TOML!" - ) from parser_error + msg = "Invalid syntax in configuration!" + raise InvalidConfigurationError(msg) from parser_error def parse_config(file_path: BytesIO | Path) -> dict: @@ -41,9 +42,8 @@ def parse_config(file_path: BytesIO | Path) -> dict: config = parse_toml(file_path) config["filename"] = "BytesIO" else: - assert ( - file_path.exists() and file_path.is_file() - ), f"{file_path} is a valid file" + assert file_path.exists(), f"{file_path} exists" + assert file_path.is_file(), f"{file_path} is a file" with file_path.open("rb") as stream: config = parse_toml(stream) @@ -76,15 +76,15 @@ def ensure_device_name_is_valid(device_name: str) -> None: Uniqueness of names is ensured by their toml dict key nature, """ - if len(device_name) > 42: + if len(device_name) > DEVICE_NAME_MAX_LENGTH: # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig - raise InvalidConfiguration( - f"Invalid name for device '{device_name}': too long ({len(device_name)} characters, max is 42)" + raise InvalidConfigurationError( + f"Device name '{device_name}' is too long ({len(device_name)} characters, max is {DEVICE_NAME_MAX_LENGTH})" ) if "." in device_name: # This is not strictly needed but avoids potential zeroconf problems - raise InvalidConfiguration( - f"Invalid name for device '{device_name}': '.' character not allowed" + raise InvalidConfigurationError( + f"Invalid character '.' in device name '{device_name}'" ) @@ -109,13 +109,14 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: f"Install {needed_plugin} to add support for it!" f"e.g. `python -m pip install {needed_plugin}`", ) - raise InvalidConfiguration(f"{needed_plugin} not installed.") from error + msg = f"{needed_plugin} not installed." + raise InvalidConfigurationError(msg) from error logger.exception( f"Device type `{device_config['type']}` unknown in 'device.{device_name}'!" f"[Known types: {device_object_mapper.keys()}]", ) - raise InvalidConfiguration( + raise InvalidConfigurationError( f"Unknown device type `{device_config['type']}`." ) from error diff --git a/src/flowchem/server/create_server.py b/src/flowchem/server/create_server.py index 399a9511..1d085135 100644 --- a/src/flowchem/server/create_server.py +++ b/src/flowchem/server/create_server.py @@ -42,7 +42,11 @@ async def create_server_for_devices( logger.info(f"Zeroconf server up, broadcasting on IPs: {mdns.mdns_addresses}") # HTTP server (FastAPI) - http = FastAPIServer(config.get("filename", "")) + http = FastAPIServer( + config.get("filename", ""), + host=mdns.mdns_addresses[0], + port=config.get("port", 8000), + ) logger.debug("HTTP ASGI server app created") for device in config["device"]: diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index 4e32a24e..8a443a91 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -12,7 +12,9 @@ class FastAPIServer: - def __init__(self, filename: str = "") -> None: + def __init__( + self, filename: str = "", host: str = "127.0.0.1", port: int = 8000 + ) -> None: # Create FastAPI app self.app = FastAPI( title=f"Flowchem - {filename}", @@ -23,6 +25,8 @@ def __init__(self, filename: str = "") -> None: "url": "https://opensource.org/licenses/MIT", }, ) + self.host = host + self.port = port self._add_root_redirect() @@ -44,9 +48,13 @@ async def my_task(): def add_device(self, device): """Add device to server.""" - # Get components (some compounded devices can return multiple components) - components = device.components() - logger.debug(f"Got {len(components)} components from {device.name}") + # Add components URL to device_info + base_url = rf"http://{self.host}:{self.port}/{device.name}" + components_w_url = { + component.name: f"{base_url}/{component.name}" + for component in device.components + } + device.device_info.components = components_w_url # Base device endpoint device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) @@ -63,6 +71,7 @@ def add_device(self, device): self.add_background_tasks(tasks) # add device components - for component in components: + logger.debug(f"Device '{device.name}' has {len(device.components)} components") + for component in device.components: self.app.include_router(component.router, tags=component.router.tags) logger.debug(f"Router <{component.router.prefix}> added to app!") diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index eadeb035..9a8d337b 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -26,11 +26,13 @@ def __init__(self, port: int = 8000) -> None: if ip not in ("127.0.0.1", "0.0.0.0") and not ip.startswith("169.254") # Remove invalid IPs ] + if not self.mdns_addresses: + self.mdns_addresses.append("127.0.0.1") - async def add_device(self, name: str): - """Adds device to the server.""" + async def add_device(self, name: str) -> None: + """Add device to the server.""" properties = { - "path": r"http://" + f"{self.mdns_addresses[0]}:{self.port}/{name}/", + "path": rf"http://{self.mdns_addresses[0]}:{self.port}/{name}/", "id": f"{name}:{uuid.uuid4()}".replace(" ", ""), } @@ -45,11 +47,12 @@ async def add_device(self, name: str): try: await self.server.async_register_service(service_info) - except NonUniqueNameException as nu: - raise RuntimeError( + except NonUniqueNameException as name_error: + msg = ( f"Cannot initialize zeroconf service for '{name}'" f"The same name is already in use: you cannot run flowchem twice for the same device!" - ) from nu + ) + raise RuntimeError(msg) from name_error logger.debug(f"Device {name} registered as Zeroconf service!") diff --git a/src/flowchem/utils/device_finder.py b/src/flowchem/utils/device_finder.py index b226513f..617b6750 100644 --- a/src/flowchem/utils/device_finder.py +++ b/src/flowchem/utils/device_finder.py @@ -1,4 +1,4 @@ -"""This module is used to autodiscover any supported devices connected to the PC.""" +"""Autodiscover any supported devices connected to the PC.""" from pathlib import Path import aioserial @@ -53,7 +53,7 @@ def inspect_serial_ports() -> set[str]: return dev_found_config -def inspect_eth(source_ip) -> set[str]: +def inspect_eth(source_ip: str) -> set[str]: """Search for known devices on ethernet and generate config stubs.""" logger.info("Starting ethernet detection") dev_found_config: set[str] = set() diff --git a/src/flowchem/utils/exceptions.py b/src/flowchem/utils/exceptions.py index f50b295f..cfc7a588 100644 --- a/src/flowchem/utils/exceptions.py +++ b/src/flowchem/utils/exceptions.py @@ -5,5 +5,5 @@ class DeviceError(BaseException): """Generic DeviceError.""" -class InvalidConfiguration(DeviceError): +class InvalidConfigurationError(DeviceError): """The configuration provided is not valid, e.g. no connection w/ device obtained.""" diff --git a/src/flowchem/vendor/getmac.py b/src/flowchem/vendor/getmac.py index 7cca4264..e54a99bc 100644 --- a/src/flowchem/vendor/getmac.py +++ b/src/flowchem/vendor/getmac.py @@ -370,7 +370,7 @@ def _read_file(filepath): def _hunt_for_mac(to_find, type_of_thing, net_ok=True): # type: (Optional[str], int, bool) -> Optional[str] - """Tries a variety of methods to get a MAC address. + """Try a variety of methods to get a MAC address. Format of method lists: Tuple: (regex, regex index, command, command args) Command args is a list of strings to attempt to use as arguments @@ -509,7 +509,7 @@ def _hunt_for_mac(to_find, type_of_thing, net_ok=True): def _try_methods(methods, to_find=None): # type: (list, Optional[str]) -> Optional[str] - """Runs the methods specified by _hunt_for_mac(). + """Run the methods specified by _hunt_for_mac(). We try every method and see if it returned a MAC address. If it returns None or raises an exception, we continue and try the next method. @@ -588,7 +588,7 @@ def _get_default_iface_freebsd(): def _fetch_ip_using_dns(): # type: () -> str - """Determines the IP address of the default network interface. + """Determine the IP address of the default network interface. Sends a UDP packet to Cloudflare's DNS (1.1.1.1), which should go through the default interface. This populates the source address of the socket, which we then inspect and return. diff --git a/src/flowchem/vendor/repeat_every.py b/src/flowchem/vendor/repeat_every.py index fbdd2187..5c62779a 100644 --- a/src/flowchem/vendor/repeat_every.py +++ b/src/flowchem/vendor/repeat_every.py @@ -25,7 +25,7 @@ def repeat_every( raise_exceptions: bool = False, max_repetitions: int | None = None, ) -> NoArgsNoReturnDecorator: - """Returns a decorator that modifies a function so it is periodically re-executed after its first call. + """Return a decorator that modifies a function so it is periodically re-executed after its first call. The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished by using `functools.partial` or otherwise wrapping the target function prior to decoration. @@ -51,7 +51,7 @@ def repeat_every( def decorator( func: NoArgsNoReturnAsyncFuncT | NoArgsNoReturnFuncT, ) -> NoArgsNoReturnAsyncFuncT: - """Converts the decorated function into a repeated, periodically-called version of itself.""" + """Convert the decorated function into a repeated, periodically-called version of itself.""" is_coroutine = asyncio.iscoroutinefunction(func) @wraps(func) diff --git a/tests/devices/analytics/test_flowir.py b/tests/devices/analytics/test_flowir.py index 846a1cc4..bf5193ea 100644 --- a/tests/devices/analytics/test_flowir.py +++ b/tests/devices/analytics/test_flowir.py @@ -1,7 +1,6 @@ """Test FlowIR, needs actual connection to the device :(.""" import asyncio import datetime -import sys import pytest @@ -9,16 +8,6 @@ from flowchem.devices.mettlertoledo.icir import IcIR -def check_pytest_asyncio_installed(): - """Utility function for pytest plugin.""" - import os - from importlib import util - - if not util.find_spec("pytest_asyncio"): - print("You need to install pytest-asyncio first!", file=sys.stderr) - sys.exit(os.EX_SOFTWARE) - - @pytest.fixture() async def spectrometer(): """Return local FlowIR object.""" diff --git a/tests/devices/technical/test_huber.py b/tests/devices/technical/test_huber.py index 20deacfb..6db9f287 100644 --- a/tests/devices/technical/test_huber.py +++ b/tests/devices/technical/test_huber.py @@ -8,7 +8,7 @@ from flowchem.devices.huber import HuberChiller from flowchem.devices.huber.pb_command import PBCommand -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError @pytest.fixture @@ -56,7 +56,7 @@ def test_pbcommand_parse_bool(): def test_invalid_serial_port(): - with pytest.raises(InvalidConfiguration) as execinfo: + with pytest.raises(InvalidConfigurationError) as execinfo: HuberChiller.from_config(port="COM99") assert ( str(execinfo.value) == "Cannot connect to the HuberChiller on the port " diff --git a/tests/server/test_config_parser.py b/tests/server/test_config_parser.py index 24a9d8e0..d32fc2cf 100644 --- a/tests/server/test_config_parser.py +++ b/tests/server/test_config_parser.py @@ -5,7 +5,7 @@ from flowchem_test.fakedevice import FakeDevice from flowchem.server.configuration_parser import parse_config -from flowchem.utils.exceptions import InvalidConfiguration +from flowchem.utils.exceptions import InvalidConfigurationError def test_minimal_valid_config(): @@ -25,6 +25,6 @@ def test_minimal_valid_config(): def test_name_too_long(): cfg_txt = BytesIO(b"""[device.this_name_is_too_long_and_should_be_shorter]""") - with pytest.raises(InvalidConfiguration) as excinfo: + with pytest.raises(InvalidConfigurationError) as excinfo: parse_config(cfg_txt) assert "too long" in str(excinfo.value) From 179cf990e7f24af0e1c22a94bc49367fcb2a405b Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 19:33:09 +0200 Subject: [PATCH 37/62] mypy --- src/flowchem/client/device_client.py | 2 +- src/flowchem/devices/knauer/azura_compact.py | 4 ++-- src/flowchem/devices/knauer/knauer_valve.py | 2 ++ src/flowchem/devices/vapourtec/r4_heater.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index 348c98fd..be05852a 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -30,7 +30,7 @@ def __init__(self, url: AnyHttpUrl) -> None: ) from ce self.components = [ FlowchemComponentClient(cmp_url, parent=self) - for cmp_url in self.device_info.components + for cmp_url in self.device_info.components.values() ] @staticmethod diff --git a/src/flowchem/devices/knauer/azura_compact.py b/src/flowchem/devices/knauer/azura_compact.py index cdca8414..8135ca8c 100644 --- a/src/flowchem/devices/knauer/azura_compact.py +++ b/src/flowchem/devices/knauer/azura_compact.py @@ -426,9 +426,9 @@ async def enable_analog_control(self, value: bool): async def main(pump: AzuraCompact): """Test function.""" await pump.initialize() - c = pump.components() + c = pump.components print(c) - pc: AzuraCompactPump = c[0] + pc: AzuraCompactPump = c[0] # type:ignore print(pc) print(await pc.infuse(rate="0.1 ml/min")) await pump.set_flow_rate(ureg.Quantity("0.1 ml/min")) diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index 228603fd..741175b1 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -4,6 +4,7 @@ from loguru import logger +from flowchem.components.base_component import FlowchemComponent from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.knauer._common import KnauerEthernetDevice @@ -55,6 +56,7 @@ async def initialize(self): self.device_info.additional_info["valve-type"] = await self.get_valve_type() # Set components + valve_component: FlowchemComponent match self.device_info.additional_info["valve-type"]: case KnauerValveHeads.SIX_PORT_TWO_POSITION: valve_component = KnauerInjectionValve("injection-valve", self) diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index b1067b8a..b1763da7 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -97,7 +97,7 @@ async def initialize(self): R4HeaterChannelControl(f"reactor{n+1}", self, n, temp_limits[n]) for n in range(4) ] - self.components.append(reactor_positions) + self.components.extend(reactor_positions) async def _write(self, command: str): """Write a command to the pump.""" From 615a3e0c3da156cb55c7409240135ca24b976b58 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 19:40:33 +0200 Subject: [PATCH 38/62] drop requirement for components in device is still respected but there's a circular dependency from flowchem_test and the new changeset in flowchem... Pointless to check now that is implemented in FlowchemDevice --- tests/devices/test_device_type_finder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/devices/test_device_type_finder.py b/tests/devices/test_device_type_finder.py index 3e76e3d6..454a1bac 100644 --- a/tests/devices/test_device_type_finder.py +++ b/tests/devices/test_device_type_finder.py @@ -33,6 +33,5 @@ def test_device_finder(): if name == "KnauerDADCommands": continue # not a real device - assert hasattr(device, "components") assert hasattr(device, "initialize") assert hasattr(device, "repeated_task") From f76a73f98900442dd6692bce6dff8c7c3721520a Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 20:18:01 +0200 Subject: [PATCH 39/62] further streamlining in client use --- examples/reaction_optimization/_hw_control.py | 19 ----- examples/reaction_optimization/main_loop.py | 59 +++++++-------- .../reaction_optimization/run_experiment.py | 73 ++++++++----------- src/flowchem/client/component_client.py | 15 ++-- src/flowchem/client/device_client.py | 13 ++++ 5 files changed, 75 insertions(+), 104 deletions(-) delete mode 100644 examples/reaction_optimization/_hw_control.py diff --git a/examples/reaction_optimization/_hw_control.py b/examples/reaction_optimization/_hw_control.py deleted file mode 100644 index 717568db..00000000 --- a/examples/reaction_optimization/_hw_control.py +++ /dev/null @@ -1,19 +0,0 @@ -import contextlib - -import requests -from loguru import logger - - -def check_for_errors(resp, *args, **kwargs): - resp.raise_for_status() - - -def log_responses(resp, *args, **kwargs): - logger.debug(f"Reply: {resp.text} on {resp.url}") - - -@contextlib.contextmanager -def command_session(): - with requests.Session() as session: - session.hooks["response"] = [log_responses, check_for_errors] - yield session diff --git a/examples/reaction_optimization/main_loop.py b/examples/reaction_optimization/main_loop.py index d0ec80ae..4e5608c1 100644 --- a/examples/reaction_optimization/main_loop.py +++ b/examples/reaction_optimization/main_loop.py @@ -1,15 +1,8 @@ import time -from examples.reaction_optimization._hw_control import ( - command_session, - flowir_endpoint, - hexyldecanoic_endpoint, - r4_endpoint, - socl2_endpoint, -) from gryffin import Gryffin from loguru import logger -from run_experiment import run_experiment +from run_experiment import run_experiment, reactor, flowir, hexyldecanoic, socl2 logger.add("./xp.log", level="INFO") @@ -31,31 +24,31 @@ # Initialize hardware -with command_session() as sess: - # Heater to r.t. - sess.put(r4_endpoint + "/temperature", params={"temperature": "21"}) - sess.put(r4_endpoint + "/power-on") - - # Start pumps with low flow rate - sess.put(socl2_endpoint + "/flow-rate", params={"rate": "5 ul/min"}) - sess.put(socl2_endpoint + "/infuse") - - sess.put(hexyldecanoic_endpoint + "/flow-rate", params={"rate": "50 ul/min"}) - sess.put(hexyldecanoic_endpoint + "/infuse") - - # Ensure iCIR is running - assert ( - sess.get(flowir_endpoint + "/is-connected").text == "true" - ), "iCIR app must be open on the control PC" - # If IR is running I just reuse previous experiment. Because cleaning the probe for the BG is slow - status = sess.get(flowir_endpoint + "/probe-status") - if status == " Not running": - # Start acquisition - xp = { - "template": "30sec_2days.iCIRTemplate", - "name": "hexyldecanoic acid chlorination - automated", - } - sess.put(flowir_endpoint + "/experiment/start", params=xp) +# Heater to r.t. +reactor.put("temperature", {"temperature": "21"}) +reactor.put("power-on") + +# Start pumps with low flow rate +socl2.put("flow-rate", {"rate": "5 ul/min"}) +socl2.put("infuse") + +hexyldecanoic.put("flow-rate", {"rate": "50 ul/min"}) +hexyldecanoic.put("infuse") + +# Ensure iCIR is running +assert ( + flowir.get("is-connected").text == "true" +), "iCIR app must be open on the control PC" +# If IR is running I just reuse previous experiment. Because cleaning the probe for the BG is slow + +status = flowir.get("probe-status").text +if status == " Not running": + # Start acquisition + xp = { + "template": "30sec_2days.iCIRTemplate", + "name": "hexyldecanoic acid chlorination - automated", + } + flowir.put("experiment/start", xp) # Run optimization for MAX_TIME diff --git a/examples/reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py index 22eedf41..dba6361b 100644 --- a/examples/reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -2,28 +2,25 @@ import numpy as np import pandas as pd -from examples.reaction_optimization._hw_control import command_session + from loguru import logger from scipy import integrate from flowchem.client.client import get_all_flowchem_devices -HOST = "127.0.0.1" -PORT = 8000 -api_base = f"http://{HOST}:{PORT}" - # Flowchem devices flowchem_devices = get_all_flowchem_devices() # SOCl2 pump -socl2_url = flowchem_devices["socl2"] +socl2 = flowchem_devices["socl2"]["pump"] +# socl2 = flowchem_devices["socl2"][BasePump] # To be tested + # Hexyldecanoic pump -hexyldecanoic_url = flowchem_devices["hexyldecanoic"] +hexyldecanoic = flowchem_devices["hexyldecanoic"]["pump"] # R4 reactor heater -reactor_url = flowchem_devices["r4-heater"] -reactor_bay = 0 +reactor = flowchem_devices["r4-heater"]["reactor1"] # FlowIR -flowir_url = flowchem_devices["flowir"] +flowir = flowchem_devices["flowir"]["ir-control"] def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): @@ -57,61 +54,49 @@ def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): def set_parameters(rates: dict, temperature: float): - with command_session() as sess: - sess.put(socl2_url + "/flow-rate", params={"rate": f"{rates['socl2']} ml/min"}) - sess.put( - hexyldecanoic_url + "/flow-rate", - params={"rate": f"{rates['hexyldecanoic']} ml/min"}, - ) + # Set pumps + socl2.put("flow-rate", {"rate": f"{rates['socl2']} ml/min"}) + hexyldecanoic.put("flow-rate", {"rate": f"{rates['hexyldecanoic']} ml/min"}) - # Sets heater - heater_data = {"temperature": f"{temperature:.2f} °C"} - sess.put(reactor_url + "/temperature", params=heater_data) + # Sets heater + reactor.put("temperature", {"temperature": f"{temperature:.2f} °C"}) def wait_stable_temperature(): """Wait until a stable temperature has been reached.""" logger.info("Waiting for the reactor temperature to stabilize") while True: - with command_session() as sess: - r = sess.get(reactor_url + "/target-reached") - if r.text == "true": - logger.info("Stable temperature reached!") - break - else: - time.sleep(5) + if reactor.get("target-reached").text == "true": + logger.info("Stable temperature reached!") + break + else: + time.sleep(5) def get_ir_once_stable(): """Keep acquiring IR spectra until changes are small, then returns the spectrum.""" logger.info("Waiting for the IR spectrum to be stable") - with command_session() as sess: - # Wait for first spectrum to be available - while int(sess.get(flowir_url + "/sample-count").text) == 0: - time.sleep(1) - # Get spectrum - previous_spectrum = pd.read_json( - sess.get(flowir_url + "/sample/spectrum-treated").text, - ) - previous_spectrum = previous_spectrum.set_index("wavenumber") - # In case the id has changed between requests (highly unlikely) - last_sample_id = int(sess.get(flowir_url + "/sample-count").text) + # Wait for first spectrum to be available + while flowir.get("sample-count").text == 0: + time.sleep(1) + + # Get spectrum + previous_spectrum = pd.read_json(flowir.get("sample/spectrum-treated").text) + previous_spectrum = previous_spectrum.set_index("wavenumber") + # In case the id has changed between requests (highly unlikely) + last_sample_id = int(flowir.get("sample-count").text) while True: # Wait for a new spectrum while True: - with command_session() as sess: - current_sample_id = int(sess.get(flowir_url + "/sample-count").text) + current_sample_id = int(flowir.get("sample-count").text) if current_sample_id > last_sample_id: break else: time.sleep(2) - with command_session() as sess: - current_spectrum = pd.read_json( - sess.get(flowir_url + "/sample/spectrum-treated").text, - ) - current_spectrum = current_spectrum.set_index("wavenumber") + current_spectrum = pd.read_json(flowir.get("sample/spectrum-treated").text) + current_spectrum = current_spectrum.set_index("wavenumber") previous_peaks = integrate_peaks(previous_spectrum) current_peaks = integrate_peaks(current_spectrum) diff --git a/src/flowchem/client/component_client.py b/src/flowchem/client/component_client.py index facb5b2d..e36cf6bb 100644 --- a/src/flowchem/client/component_client.py +++ b/src/flowchem/client/component_client.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING -from loguru import logger from pydantic import AnyHttpUrl if TYPE_CHECKING: @@ -10,21 +9,21 @@ class FlowchemComponentClient: def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient") -> None: - self.url = url - # Get ComponentInfo from - logger.warning(f"CREATE COMPONENT FOR URL {url}") + self.base_url = str(url) self._parent = parent self._session = self._parent._session - self.component_info = ComponentInfo.model_validate_json(self.get(url).text) + self.component_info = ComponentInfo.model_validate_json(self.get("").text) def get(self, url, **kwargs): """Send a GET request. Returns :class:`Response` object.""" - return self._session.get(url, **kwargs) + return self._session.get(self.base_url + "/" + url, **kwargs) def post(self, url, data=None, json=None, **kwargs): """Send a POST request. Returns :class:`Response` object.""" - return self._session.post(url, data=data, json=json, **kwargs) + return self._session.post( + self.base_url + "/" + url, data=data, json=json, **kwargs + ) def put(self, url, data=None, **kwargs): """Send a PUT request. Returns :class:`Response` object.""" - return self._session.put(url, data=data, **kwargs) + return self._session.put(self.base_url + "/" + url, data=data, **kwargs) diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index be05852a..671331f1 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -33,6 +33,19 @@ def __init__(self, url: AnyHttpUrl) -> None: for cmp_url in self.device_info.components.values() ] + def __getitem__(self, item): + if isinstance(item, type): + return [ + component + for component in self.components + if isinstance(component, item) + ] + elif isinstance(item, str): + try: + FlowchemComponentClient(self.device_info.components[item]) + except IndexError: + raise KeyError(f"No component named '{item}' in {repr(self)}.") + @staticmethod def raise_for_status(resp, *args, **kwargs): resp.raise_for_status() From fde1b1b35cdc2123c293302a73bb0915687b8c61 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 20:21:51 +0200 Subject: [PATCH 40/62] Fix timeout in GA --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index fd229766..92221373 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,9 @@ def flowchem_test_instance(xprocess): main = Path(__file__).parent.resolve() / ".." / "src" / "flowchem" / "__main__.py" class Starter(ProcessStarter): - # Process startup ends with this text in stdout + # Process startup ends with this text in stdout (timeout is long cause some GA runners are slow) pattern = "Uvicorn running" - timeout = 10 + timeout = 30 # execute flowchem with current venv args = [sys.executable, main, config_file] From 5732e011b68710275348ef9012776674541170c9 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 20:22:57 +0200 Subject: [PATCH 41/62] mypy --- src/flowchem/client/device_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index 671331f1..4e55f004 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -42,7 +42,7 @@ def __getitem__(self, item): ] elif isinstance(item, str): try: - FlowchemComponentClient(self.device_info.components[item]) + FlowchemComponentClient(self.device_info.components[item], self) except IndexError: raise KeyError(f"No component named '{item}' in {repr(self)}.") From ffd5dbe235283c7a7be9164c96dadbc3c4d1c693 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 21:02:07 +0200 Subject: [PATCH 42/62] Fix R2 pressure units --- src/flowchem/devices/vapourtec/r2.py | 4 ++-- .../devices/vapourtec/r2_components_control.py | 13 +++++++------ src/flowchem/server/fastapi_server.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 781880d8..76b12d4f 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -367,11 +367,11 @@ async def get_pressure_history( p_in_mbar = [int(x) * 10 for x in pressures] return p_in_mbar[1], p_in_mbar[2], p_in_mbar[0] # pumpA, pumpB, system - async def get_current_pressure(self, pump_code: int = 2) -> int: + async def get_current_pressure(self, pump_code: int = 2) -> pint.Quantity: """Get current pressure (in mbar).""" press_state_list = await self.get_pressure_history() # 0: pump A, 1: pump_B, 2: system pressure - return press_state_list[pump_code] + return press_state_list[pump_code] * ureg.mbar async def get_current_flow(self, pump_code: str) -> float: """Get current flow rate (in ul/min).""" diff --git a/src/flowchem/devices/vapourtec/r2_components_control.py b/src/flowchem/devices/vapourtec/r2_components_control.py index 45ebc319..523e729c 100644 --- a/src/flowchem/devices/vapourtec/r2_components_control.py +++ b/src/flowchem/devices/vapourtec/r2_components_control.py @@ -150,10 +150,10 @@ async def set_position(self, position: str) -> bool: return True -class R2TwoPortValve(TwoPortDistribution): # total 3 valve (A, B, Collection) - """R2 reactor injection loop valve control class.""" +class R2TwoPortValve(TwoPortDistribution): # total 3 positions (A, B, Collection) + """R2 reactor injection loop valve control.""" - hw_device: R2 # for typing's sake + hw_device: R2 # TODO: the mapping name is not applicable position_mapping = {"Solvent": "0", "Reagent": "1"} @@ -226,7 +226,8 @@ def __init__(self, name: str, hw_device: R2, pump_code: int) -> None: async def read_pressure(self, units: str = "mbar") -> int | None: # mbar """Get current pump pressure in mbar.""" - return await self.hw_device.get_current_pressure(self.pump_code) + pressure = await self.hw_device.get_current_pressure(self.pump_code) + return pressure.m_as(units) class R2GeneralPressureSensor(PressureSensor): @@ -238,8 +239,8 @@ def __init__(self, name: str, hw_device: R2) -> None: async def read_pressure(self, units: str = "mbar") -> int: """Get system pressure.""" - # TODO: now the output are always mbar, change it to fit the base component - return await self.hw_device.get_current_pressure() + pressure = await self.hw_device.get_current_pressure() + return pressure.m_as(units) class R2MainSwitch(PowerSwitch): diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index 8a443a91..a8348343 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -60,7 +60,7 @@ def add_device(self, device): device_root = APIRouter(prefix=f"/{device.name}", tags=[device.name]) device_root.add_api_route( "/", - device.get_device_info, # TODO: add components in the device info response! + device.get_device_info, methods=["GET"], response_model=DeviceInfo, ) From c80dd5c628cbb22a3785ab8dcb726edd7529b0af Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 21:02:34 +0200 Subject: [PATCH 43/62] clean run experiment --- .../reaction_optimization/run_experiment.py | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/examples/reaction_optimization/run_experiment.py b/examples/reaction_optimization/run_experiment.py index dba6361b..2a571279 100644 --- a/examples/reaction_optimization/run_experiment.py +++ b/examples/reaction_optimization/run_experiment.py @@ -11,15 +11,9 @@ # Flowchem devices flowchem_devices = get_all_flowchem_devices() -# SOCl2 pump socl2 = flowchem_devices["socl2"]["pump"] -# socl2 = flowchem_devices["socl2"][BasePump] # To be tested - -# Hexyldecanoic pump hexyldecanoic = flowchem_devices["hexyldecanoic"]["pump"] -# R4 reactor heater reactor = flowchem_devices["r4-heater"]["reactor1"] -# FlowIR flowir = flowchem_devices["flowir"]["ir-control"] @@ -54,11 +48,9 @@ def calculate_flow_rates(SOCl2_equivalent: float, residence_time: float): def set_parameters(rates: dict, temperature: float): - # Set pumps + """Set flow rates and temperature to the reaction setup.""" socl2.put("flow-rate", {"rate": f"{rates['socl2']} ml/min"}) hexyldecanoic.put("flow-rate", {"rate": f"{rates['hexyldecanoic']} ml/min"}) - - # Sets heater reactor.put("temperature", {"temperature": f"{temperature:.2f} °C"}) @@ -73,9 +65,20 @@ def wait_stable_temperature(): time.sleep(5) +def _get_new_ir_spectrum(last_sample_id): + while True: + current_sample_id = int(flowir.get("sample-count").text) + if current_sample_id > last_sample_id: + break + else: + time.sleep(2) + return current_sample_id + + def get_ir_once_stable(): """Keep acquiring IR spectra until changes are small, then returns the spectrum.""" logger.info("Waiting for the IR spectrum to be stable") + # Wait for first spectrum to be available while flowir.get("sample-count").text == 0: time.sleep(1) @@ -83,17 +86,10 @@ def get_ir_once_stable(): # Get spectrum previous_spectrum = pd.read_json(flowir.get("sample/spectrum-treated").text) previous_spectrum = previous_spectrum.set_index("wavenumber") - # In case the id has changed between requests (highly unlikely) - last_sample_id = int(flowir.get("sample-count").text) + last_sample_id = int(flowir.get("sample-count").text) while True: - # Wait for a new spectrum - while True: - current_sample_id = int(flowir.get("sample-count").text) - if current_sample_id > last_sample_id: - break - else: - time.sleep(2) + current_sample_id = _get_new_ir_spectrum(last_sample_id) current_spectrum = pd.read_json(flowir.get("sample/spectrum-treated").text) current_spectrum = current_spectrum.set_index("wavenumber") @@ -120,7 +116,7 @@ def integrate_peaks(ir_spectrum): peaks = {} for name, start, end in peak_list: - # This is a common mistake since wavenumber are plot in reverse order + # This is a common mistake since wavenumbers are plot in reverse order if start > end: start, end = end, start @@ -131,7 +127,6 @@ def integrate_peaks(ir_spectrum): logger.debug(f"Integral of {name} between {start} and {end} is {peaks[name]}") # Normalize integrals - return {k: v / sum(peaks.values()) for k, v in peaks.items()} From 445614efaf44423f2dd57da4f87464ae1affed1a Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 22:26:41 +0200 Subject: [PATCH 44/62] Minor changes --- docs/_static/architecture_v1.svg | 1 + docs/add_device/add_to_flowchem.md | 6 +- docs/api/elite11/api.md | 2 +- docs/api/icir/api.md | 2 +- docs/api/vici_valve/api.md | 2 +- docs/contribute.md | 4 +- docs/getting_started.md | 2 +- docs/learning/tutorial.md | 6 +- pyproject.toml | 11 ++- src/flowchem/__main__.py | 2 +- src/flowchem/client/component_client.py | 2 +- src/flowchem/client/device_client.py | 2 + src/flowchem/components/README.md | 3 +- .../devices/harvardapparatus/elite11.py | 1 - .../harvardapparatus/elite11_finder.py | 10 +- src/flowchem/devices/huber/chiller.py | 4 +- src/flowchem/devices/huber/huber_finder.py | 20 ++-- src/flowchem/devices/huber/pb_command.py | 2 +- src/flowchem/devices/knauer/__init__.py | 2 +- src/flowchem/devices/magritek/__init__.py | 2 +- src/flowchem/devices/magritek/spinsolve.py | 4 +- src/flowchem/devices/magritek/utils.py | 6 +- src/flowchem/devices/mettlertoledo/icir.py | 9 +- .../devices/phidgets/bubble_sensor.py | 4 +- src/flowchem/devices/vacuubrand/constants.py | 2 +- .../devices/vacuubrand/cvc3000_finder.py | 19 ++-- src/flowchem/devices/vapourtec/r2.py | 7 +- src/flowchem/devices/vapourtec/r4_heater.py | 5 +- .../devices/vapourtec/vapourtec_finder.py | 7 +- src/flowchem/server/fastapi_server.py | 2 +- src/flowchem/vendor/getmac.py | 1 + src/flowchem/vendor/repeat_every.py | 2 +- tests/cli/test_autodiscover_cli.py | 1 + tests/cli/test_flowchem_cli.py | 3 + tests/devices/analytics/test_flowir.py | 7 +- tests/devices/analytics/test_spinsolve.py | 6 +- tests/devices/pumps/test_azura_compact.py | 7 +- tests/devices/pumps/test_hw_elite11.py | 98 ++++--------------- 38 files changed, 118 insertions(+), 158 deletions(-) diff --git a/docs/_static/architecture_v1.svg b/docs/_static/architecture_v1.svg index b48dd6c8..a0a84581 100644 --- a/docs/_static/architecture_v1.svg +++ b/docs/_static/architecture_v1.svg @@ -1,6 +1,7 @@ + **Figure 1** Schematic representation of flowchem software architecture. -An heterogeneous collection of devices is physically connected to a control PC. +A heterogeneous collection of devices is physically connected to a control PC. The configuration file in TOML format specifies the connection parameters for each device. After running flowchem with that configuration, a web server is started to control each device via a single API. ::: diff --git a/docs/learning/tutorial.md b/docs/learning/tutorial.md index a6e8fe55..4017fc2d 100644 --- a/docs/learning/tutorial.md +++ b/docs/learning/tutorial.md @@ -1,7 +1,7 @@ # Introductory tutorial -Something something on how to write config +Something on how to write config -Something something on how to run server +Something on how to run server -Something something on how to consume the API +Something on how to consume the API with the clients or OpenAPI things diff --git a/pyproject.toml b/pyproject.toml index 70f2c892..e498375f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,19 +42,20 @@ dependencies = [ [project.optional-dependencies] dev = [ + "black", + "lxml-stubs", "mypy", + "pre-commit", "ruff>=0.0.252", - "black", - "pre-commit" ] test = [ "flowchem-test>=0.1a3", + "httpx", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "pytest-xprocess", - "httpx", "requests", ] phidget = [ @@ -106,8 +107,8 @@ python_version = "3.10" [tool.pytest.ini_options] testpaths = "tests" asyncio_mode = "auto" -# No cov needed for pycharm debugger -#addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" +# pytest cov is not compatible with the pycharm debugger in tests +# addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" markers = [ diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 147a7204..4b64c588 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -38,7 +38,7 @@ def main(device_config_file, logfile, host, debug): """Flowchem main program. - Parse device_config_file and starts a server exposing the devices via RESTful API. + Parse device_config_file and starts a server exposing the devices via REST-ful API. Args: ---- diff --git a/src/flowchem/client/component_client.py b/src/flowchem/client/component_client.py index e36cf6bb..55e922b1 100644 --- a/src/flowchem/client/component_client.py +++ b/src/flowchem/client/component_client.py @@ -11,7 +11,7 @@ class FlowchemComponentClient: def __init__(self, url: AnyHttpUrl, parent: "FlowchemDeviceClient") -> None: self.base_url = str(url) self._parent = parent - self._session = self._parent._session + self._session = parent._session self.component_info = ComponentInfo.model_validate_json(self.get("").text) def get(self, url, **kwargs): diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index 4e55f004..46cbc1a9 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -46,10 +46,12 @@ def __getitem__(self, item): except IndexError: raise KeyError(f"No component named '{item}' in {repr(self)}.") + # noinspection PyUnusedLocal @staticmethod def raise_for_status(resp, *args, **kwargs): resp.raise_for_status() + # noinspection PyUnusedLocal @staticmethod def log_responses(resp, *args, **kwargs): logger.debug(f"Reply: {resp.text} on {resp.url}") diff --git a/src/flowchem/components/README.md b/src/flowchem/components/README.md index 778ae99d..62cee465 100644 --- a/src/flowchem/components/README.md +++ b/src/flowchem/components/README.md @@ -6,6 +6,7 @@ Ideally, for components of the same type (e.g. SyringePumps) it should be possib another one by simply updating the configuration file, while the public API remains unchanged. It is, however, still possible for individual devices to support additional commands, beyond the minimum set defined by -this specs. The use of such commands is discouraged as it limits the portability of any derived code. +this specification. +The use of such commands is discouraged as it limits the portability of any derived code. For a list of all components consult the documentation. diff --git a/src/flowchem/devices/harvardapparatus/elite11.py b/src/flowchem/devices/harvardapparatus/elite11.py index 4358dfb2..283727e5 100644 --- a/src/flowchem/devices/harvardapparatus/elite11.py +++ b/src/flowchem/devices/harvardapparatus/elite11.py @@ -411,7 +411,6 @@ async def pump_info(self) -> PumpInfo: async def main(): """Test function.""" await pump.initialize() - # assert await pump.get_infused_volume() == 0 # await pump.set_syringe_diameter("30 mm") # await pump.set_syringe_diameter("30 mm") await pump.set_flow_rate("0.001 ml/min") diff --git a/src/flowchem/devices/harvardapparatus/elite11_finder.py b/src/flowchem/devices/harvardapparatus/elite11_finder.py index 513a2079..1adf49c6 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_finder.py +++ b/src/flowchem/devices/harvardapparatus/elite11_finder.py @@ -9,7 +9,7 @@ # noinspection PyProtectedMember -def elite11_finder(serial_port) -> list[str]: +def elite11_finder(serial_port) -> set[str]: """Try to initialize an Elite11 on every available COM port. [Does not support daisy-chained Elite11!].""" logger.debug(f"Looking for Elite11 pumps on {serial_port}...") # Static counter for device type across different serial ports @@ -20,14 +20,14 @@ def elite11_finder(serial_port) -> list[str]: link = HarvardApparatusPumpIO(port=serial_port) except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector - return [] + return set() # Check for echo link._serial.write(b"\r\n") if link._serial.readline() != b"\n": # This is necessary only on failure to release the port for the other inspector link._serial.close() - return [] + return set() # Parse status prompt pump = link._serial.readline().decode("ascii") @@ -44,7 +44,7 @@ def elite11_finder(serial_port) -> list[str]: except InvalidConfigurationError: # This is necessary only on failure to release the port for the other inspector link._serial.close() - return [] + return set() logger.info(f"Elite11 found on <{serial_port}>") @@ -59,4 +59,4 @@ def elite11_finder(serial_port) -> list[str]: syringe_diameter = "XXX mm" # Specify syringe diameter! syringe_volume = "YYY ml" # Specify syringe volume!\n\n""", ) - return [cfg] + return set(cfg) diff --git a/src/flowchem/devices/huber/chiller.py b/src/flowchem/devices/huber/chiller.py index d74a6320..32f34474 100644 --- a/src/flowchem/devices/huber/chiller.py +++ b/src/flowchem/devices/huber/chiller.py @@ -347,10 +347,10 @@ def _int_to_string(number: int) -> str: if __name__ == "__main__": - chiller = HuberChiller(aioserial.AioSerial(port="COM8")) + device = HuberChiller(aioserial.AioSerial(port="COM8")) async def main(chiller): await chiller.initialize() print(f"S/N is {chiller.serial_number()}") - asyncio.run(main(chiller)) + asyncio.run(main(device)) diff --git a/src/flowchem/devices/huber/huber_finder.py b/src/flowchem/devices/huber/huber_finder.py index e0550ef2..c8dd6b50 100644 --- a/src/flowchem/devices/huber/huber_finder.py +++ b/src/flowchem/devices/huber/huber_finder.py @@ -9,28 +9,30 @@ # noinspection PyProtectedMember -def chiller_finder(serial_port) -> list[str]: +def chiller_finder(serial_port) -> set[str]: """Try to initialize a Huber chiller on every available COM port.""" logger.debug(f"Looking for Huber chillers on {serial_port}...") + dev_config: set[str] = set() try: chill = HuberChiller.from_config(port=serial_port) except InvalidConfigurationError: - return [] + return dev_config try: asyncio.run(chill.initialize()) except InvalidConfigurationError: chill._serial.close() - return [] + return dev_config logger.info(f"Chiller #{chill._device_sn} found on <{serial_port}>") - - return [ + dev_config.add( dedent( f""" - [device.huber-{chill._device_sn}] - type = "HuberChiller" - port = "{serial_port}"\n""", + [device.huber-{chill._device_sn}] + type = "HuberChiller" + port = "{serial_port}"\n""", ), - ] + ) + + return dev_config diff --git a/src/flowchem/devices/huber/pb_command.py b/src/flowchem/devices/huber/pb_command.py index f72d094c..9881c626 100644 --- a/src/flowchem/devices/huber/pb_command.py +++ b/src/flowchem/devices/huber/pb_command.py @@ -37,7 +37,7 @@ def data(self) -> str: return self.command[4:8] def parse_temperature(self) -> float: - """Parse a device temp from hex string to celsius float.""" + """Parse a device temp from hex string to Celsius float.""" # self.data is the two's complement 16-bit signed hex, see manual temp = ( (int(self.data, 16) - 65536) / 100 diff --git a/src/flowchem/devices/knauer/__init__.py b/src/flowchem/devices/knauer/__init__.py index a5d8539e..02f53b95 100644 --- a/src/flowchem/devices/knauer/__init__.py +++ b/src/flowchem/devices/knauer/__init__.py @@ -1,4 +1,4 @@ -"""Knauer's devices.""" +"""Knauer devices.""" from .azura_compact import AzuraCompact from .dad import KnauerDAD from .knauer_finder import knauer_finder diff --git a/src/flowchem/devices/magritek/__init__.py b/src/flowchem/devices/magritek/__init__.py index bb14dd09..40d30619 100644 --- a/src/flowchem/devices/magritek/__init__.py +++ b/src/flowchem/devices/magritek/__init__.py @@ -1,4 +1,4 @@ -"""Magritek's Spinsolve.""" +"""Magritek Spinsolve.""" from .spinsolve import Spinsolve __all__ = [ diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 5a4c0422..6e62375d 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -125,7 +125,7 @@ async def initialize(self): # This request is used to check if the instrument is connected hw_info = await self.hw_request() if hw_info.find(".//ConnectedToHardware").text != "true": - raise ConnectionError("Spectrometer not connected to Spinsolve's PC!") + raise ConnectionError("Spectrometer not connected to Spinsolve PC!") # If connected parse and log instrument info self.device_info.version = hw_info.find(".//SpinsolveSoftware").text @@ -263,7 +263,7 @@ async def load_protocols(self): async def run_protocol( self, name, - background_tasks: BackgroundTasks, + background_tasks: BackgroundTasks = None, options=None, ) -> int: """Run a protocol. diff --git a/src/flowchem/devices/magritek/utils.py b/src/flowchem/devices/magritek/utils.py index 330337e9..cab6748e 100644 --- a/src/flowchem/devices/magritek/utils.py +++ b/src/flowchem/devices/magritek/utils.py @@ -16,8 +16,10 @@ def get_my_docs_path() -> Path: """ # From https://stackoverflow.com/questions/6227590 - csidl_personal = 5 # My Documents - shgfp_type_current = 0 # Get current, not default value + csidl_personal = 5 # My Documents (csidl = constant special item ID list) + shgfp_type_current = ( + 0 # Get current, not default value (shgfp = shell get folder path) + ) buf = ctypes.create_unicode_buffer(2000) ctypes.windll.shell32.SHGetFolderPathW( # type: ignore None, diff --git a/src/flowchem/devices/mettlertoledo/icir.py b/src/flowchem/devices/mettlertoledo/icir.py index ced28e68..cd0801a2 100644 --- a/src/flowchem/devices/mettlertoledo/icir.py +++ b/src/flowchem/devices/mettlertoledo/icir.py @@ -4,6 +4,7 @@ from pathlib import Path from asyncua import Client, ua +from asyncua.ua.uaerrors import BadOutOfService, Bad from loguru import logger from pydantic import BaseModel @@ -94,7 +95,7 @@ async def initialize(self): # Start acquisition! Ensures the device is ready when a spectrum is needed await self.start_experiment(name="Flowchem", template=self._template) probe = await self.probe_info() - self.device_info.additional_info = probe.dict() + self.device_info.additional_info = probe.model_dump() # Set IRSpectrometer component self.components.append(IcIRControl("ir-control", self)) @@ -210,7 +211,7 @@ def parse_probe_info(probe_info_reply: str) -> ProbeInfo: 1 ].strip() - return ProbeInfo.parse_obj(probe_info) + return ProbeInfo.model_validate(probe_info) @staticmethod async def _wavenumber_from_spectrum_node(node) -> list[float]: @@ -227,7 +228,7 @@ async def spectrum_from_node(node) -> IRSpectrum: wavenumber = await IcIR._wavenumber_from_spectrum_node(node) return IRSpectrum(wavenumber=wavenumber, intensity=intensity) - except ua.uaerrors.BadOutOfService: + except BadOutOfService: return IRSpectrum(wavenumber=[], intensity=[]) async def last_spectrum_treated(self) -> IRSpectrum: @@ -278,7 +279,7 @@ async def start_experiment( # Collect_bg does not seem to work in automation, set to false and do not expose in start_experiment()! collect_bg = False await method_parent.call_method(start_xp_nodeid, name, template, collect_bg) - except ua.uaerrors.Bad as error: + except Bad as error: raise DeviceError( "The experiment could not be started!\n" "Check iCIR status and close any open experiment." diff --git a/src/flowchem/devices/phidgets/bubble_sensor.py b/src/flowchem/devices/phidgets/bubble_sensor.py index befc98a7..b4f90ee9 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor.py +++ b/src/flowchem/devices/phidgets/bubble_sensor.py @@ -108,12 +108,12 @@ def is_attached(self) -> bool: return bool(self.phidget.getAttached()) def is_poweron(self) -> bool: - """Wheteher the power is on.""" + """Whether the power is on.""" return bool(self.phidget.getState()) class PhidgetBubbleSensor(FlowchemDevice): - """Use a Phidget voltage input to translate a Tube Liquid Sensor OPB350 5 Valtage signal + """Use a Phidget voltage input to translate a Tube Liquid Sensor OPB350 5 Voltage signal to the corresponding light penetration value. """ diff --git a/src/flowchem/devices/vacuubrand/constants.py b/src/flowchem/devices/vacuubrand/constants.py index f8bcf190..79454705 100644 --- a/src/flowchem/devices/vacuubrand/constants.py +++ b/src/flowchem/devices/vacuubrand/constants.py @@ -37,4 +37,4 @@ def from_reply(cls, reply): "control": PumpControlMode(int(reply[4])), "state": PumpState(int(reply[5])), } - return cls.parse_obj(reply_dict) + return cls.model_validate(reply_dict) diff --git a/src/flowchem/devices/vacuubrand/cvc3000_finder.py b/src/flowchem/devices/vacuubrand/cvc3000_finder.py index 682b5a59..eec30b90 100644 --- a/src/flowchem/devices/vacuubrand/cvc3000_finder.py +++ b/src/flowchem/devices/vacuubrand/cvc3000_finder.py @@ -9,28 +9,27 @@ # noinspection PyProtectedMember -def cvc3000_finder(serial_port) -> list[str]: +def cvc3000_finder(serial_port) -> set[str]: """Try to initialize a CVC3000 on every available COM port.""" logger.debug(f"Looking for CVC3000 on {serial_port}...") try: cvc = CVC3000.from_config(port=serial_port) except InvalidConfigurationError: - return [] + return set() try: asyncio.run(cvc.initialize()) except InvalidConfigurationError: cvc._serial.close() - return [] + return set() logger.info(f"CVC3000 {cvc.component_info.version} found on <{serial_port}>") - - return [ - dedent( - f""" + dev_config = dedent( + f""" [device.cvc-{cvc._device_sn}] type = "CVC3000" - port = "{serial_port}"\n\n""", - ), - ] + port = "{serial_port}"\n\n""" + ) + + return set(dev_config) diff --git a/src/flowchem/devices/vapourtec/r2.py b/src/flowchem/devices/vapourtec/r2.py index 76b12d4f..a3d3d9c0 100644 --- a/src/flowchem/devices/vapourtec/r2.py +++ b/src/flowchem/devices/vapourtec/r2.py @@ -29,6 +29,7 @@ from flowchem.utils.people import dario, jakob, wei_hsin try: + # noinspection PyUnresolvedReferences from flowchem_vapourtec import VapourtecR2Commands HAS_VAPOURTEC_COMMANDS = True @@ -260,7 +261,7 @@ async def get_setting_Temperature(self) -> str: return "Off" if state.chan3_temp == "-1000" else state.chan3_temp async def get_valve_Position(self, valve_code: int) -> str: - "Get specific valves position." + """Get specific valves position.""" state = await self.get_status() # Current state of all valves as bitmap bitmap = int(state.LEDs_bitmap) @@ -403,7 +404,7 @@ async def pooling(self) -> dict: if __name__ == "__main__": - Vapourtec_R2 = R2(port="COM4") + R2_device = R2(port="COM4") async def main(Vapourtec_R2): """Test function.""" @@ -442,4 +443,4 @@ async def main(Vapourtec_R2): # print(f"Injection valve A {await ivA.get_position()}") await r2swich.power_off() - asyncio.run(main(Vapourtec_R2)) + asyncio.run(main(R2_device)) diff --git a/src/flowchem/devices/vapourtec/r4_heater.py b/src/flowchem/devices/vapourtec/r4_heater.py index b1763da7..dee79418 100644 --- a/src/flowchem/devices/vapourtec/r4_heater.py +++ b/src/flowchem/devices/vapourtec/r4_heater.py @@ -17,6 +17,7 @@ from flowchem.utils.people import dario, jakob, wei_hsin try: + # noinspection PyUnresolvedReferences from flowchem_vapourtec import VapourtecR4Commands HAS_VAPOURTEC_COMMANDS = True @@ -180,7 +181,7 @@ async def power_off(self, channel): if __name__ == "__main__": import asyncio - heat = R4Heater(port="COM1") + r4_device = R4Heater(port="COM1") async def main(heat): """Test function.""" @@ -191,4 +192,4 @@ async def main(heat): await r1.set_temperature("30 °C") print(f"Temperature is {await r1.get_temperature()}") - asyncio.run(main(heat)) + asyncio.run(main(r4_device)) diff --git a/src/flowchem/devices/vapourtec/vapourtec_finder.py b/src/flowchem/devices/vapourtec/vapourtec_finder.py index 639a4176..e15766c3 100644 --- a/src/flowchem/devices/vapourtec/vapourtec_finder.py +++ b/src/flowchem/devices/vapourtec/vapourtec_finder.py @@ -9,7 +9,7 @@ # noinspection PyProtectedMember -def r4_finder(serial_port) -> list[str]: +def r4_finder(serial_port) -> set[str]: """Try to initialize an R4Heater on every available COM port.""" logger.debug(f"Looking for R4Heaters on {serial_port}...") # Static counter for device type across different serial ports @@ -21,13 +21,12 @@ def r4_finder(serial_port) -> list[str]: except InvalidConfigurationError as ic: logger.error("config") raise ic - return [] try: asyncio.run(r4.initialize()) except InvalidConfigurationError: r4._serial.close() - return [] + return set() if r4.device_info.version: logger.info(f"R4 version {r4.device_info.version} found on <{serial_port}>") @@ -42,4 +41,4 @@ def r4_finder(serial_port) -> list[str]: else: cfg = "" - return [cfg] + return set(cfg) diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index a8348343..37a9a4c3 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -32,7 +32,7 @@ def __init__( def _add_root_redirect(self): @self.app.route("/") - def home_redirect_to_docs(root_path): + def home_redirect_to_docs(request): """Redirect root to `/docs` to enable interaction w/ API.""" return RedirectResponse(url="/docs") diff --git a/src/flowchem/vendor/getmac.py b/src/flowchem/vendor/getmac.py index e54a99bc..09be57d6 100644 --- a/src/flowchem/vendor/getmac.py +++ b/src/flowchem/vendor/getmac.py @@ -258,6 +258,7 @@ def _windows_ctypes_host(host): if inetaddr in (0, -1): raise Exception except Exception: + # noinspection PyTypeChecker hostip = socket.gethostbyname(host) inetaddr = ctypes.windll.wsock32.inet_addr(hostip) # type: ignore diff --git a/src/flowchem/vendor/repeat_every.py b/src/flowchem/vendor/repeat_every.py index 5c62779a..44697121 100644 --- a/src/flowchem/vendor/repeat_every.py +++ b/src/flowchem/vendor/repeat_every.py @@ -25,7 +25,7 @@ def repeat_every( raise_exceptions: bool = False, max_repetitions: int | None = None, ) -> NoArgsNoReturnDecorator: - """Return a decorator that modifies a function so it is periodically re-executed after its first call. + """Return a decorator that modifies a function, so it is periodically re-executed after its first call. The function it decorates should accept no arguments and return nothing. If necessary, this can be accomplished by using `functools.partial` or otherwise wrapping the target function prior to decoration. diff --git a/tests/cli/test_autodiscover_cli.py b/tests/cli/test_autodiscover_cli.py index cd6ebeba..2a1e322f 100644 --- a/tests/cli/test_autodiscover_cli.py +++ b/tests/cli/test_autodiscover_cli.py @@ -16,6 +16,7 @@ def test_autodiscover_cli(): runner = CliRunner() with runner.isolated_filesystem(): + # noinspection PyTypeChecker result = runner.invoke( main, ["--assume-yes", "--safe"], diff --git a/tests/cli/test_flowchem_cli.py b/tests/cli/test_flowchem_cli.py index 5d47e2a7..0d0c39f1 100644 --- a/tests/cli/test_flowchem_cli.py +++ b/tests/cli/test_flowchem_cli.py @@ -7,6 +7,7 @@ class FakeServer: + # noinspection PyUnusedLocal def __init__(self, config) -> None: pass @@ -30,9 +31,11 @@ def test_cli(mocker): ), ) + # noinspection PyTypeChecker result = runner.invoke(main, ["test_configuration.toml"]) assert result.exit_code == 0 + # noinspection PyTypeChecker result = runner.invoke( main, ["test_configuration.toml", "--log", "logfile.log"], diff --git a/tests/devices/analytics/test_flowir.py b/tests/devices/analytics/test_flowir.py index bf5193ea..d33166ca 100644 --- a/tests/devices/analytics/test_flowir.py +++ b/tests/devices/analytics/test_flowir.py @@ -8,8 +8,13 @@ from flowchem.devices.mettlertoledo.icir import IcIR +@pytest.fixture +def spectrometer() -> IcIR: + """Workaround for https://youtrack.jetbrains.com/issue/PY-30279/""" + + @pytest.fixture() -async def spectrometer(): +async def spectrometer() -> IcIR: # noqa """Return local FlowIR object.""" s = IcIR( template="template", diff --git a/tests/devices/analytics/test_spinsolve.py b/tests/devices/analytics/test_spinsolve.py index a350d191..3fba2d8e 100644 --- a/tests/devices/analytics/test_spinsolve.py +++ b/tests/devices/analytics/test_spinsolve.py @@ -142,7 +142,7 @@ def test_protocol(nmr: Spinsolve): time.sleep(0.1) # Run fast proton - path = asyncio.run(nmr.run_protocol("1D PROTON", {"Scan": "QuickScan"})) + path = asyncio.run(nmr.run_protocol("1D PROTON", options={"Scan": "QuickScan"})) assert isinstance(path, Path) @@ -154,5 +154,7 @@ def test_invalid_protocol(nmr: Spinsolve): # Fail on plutonium with pytest.warns(UserWarning, match="not available"): - path = asyncio.run(nmr.run_protocol("1D PLUTONIUM", {"Scan": "QuickScan"})) + path = asyncio.run( + nmr.run_protocol("1D PLUTONIUM", options={"Scan": "QuickScan"}) + ) assert path is None diff --git a/tests/devices/pumps/test_azura_compact.py b/tests/devices/pumps/test_azura_compact.py index 91031454..d6d9c906 100644 --- a/tests/devices/pumps/test_azura_compact.py +++ b/tests/devices/pumps/test_azura_compact.py @@ -8,6 +8,7 @@ import pint import pytest +from flowchem import ureg from flowchem.devices.knauer.azura_compact import AzuraCompact, AzuraPumpHeads if sys.platform == "win32": @@ -53,11 +54,11 @@ async def test_headtype(pump: AzuraCompact): @pytest.mark.KPump @pytest.mark.asyncio async def test_flow_rate(pump: AzuraCompact): - await pump.set_flow_rate("1.25 ml/min") + await pump.set_flow_rate(ureg.Quantity("1.25 ml/min")) await pump.infuse() # FIXME assert pint.Quantity(await pump.get_flow_rate()).magnitude == 1.25 - await pump.set_flow_rate(f"{math.pi} ml/min") + await pump.set_flow_rate(ureg.Quantity(f"{math.pi} ml/min")) assert math.isclose( pint.Quantity(await pump.get_flow_rate()).magnitude, math.pi, @@ -78,7 +79,7 @@ async def test_analog_control(pump: AzuraCompact): @pytest.mark.KPump @pytest.mark.asyncio async def test_is_running(pump: AzuraCompact): - await pump.set_flow_rate("1 ml/min") + await pump.set_flow_rate(ureg.Quantity("1 ml/min")) await pump.infuse() assert pump.is_running() is True await pump.stop() diff --git a/tests/devices/pumps/test_hw_elite11.py b/tests/devices/pumps/test_hw_elite11.py index 0400ebfa..8cb0b965 100644 --- a/tests/devices/pumps/test_hw_elite11.py +++ b/tests/devices/pumps/test_hw_elite11.py @@ -9,11 +9,16 @@ import pytest from flowchem import ureg -from flowchem.devices.harvardapparatus.elite11 import Elite11, PumpStatus +from flowchem.devices.harvardapparatus.elite11 import Elite11 + + +@pytest.fixture +def pump() -> Elite11: + """Workaround for https://youtrack.jetbrains.com/issue/PY-30279/""" @pytest.fixture(scope="session") -async def pump(): +async def pump() -> Elite11: # noqa """Change to match your hardware ;).""" pump = Elite11.from_config( port="COM4", @@ -39,38 +44,11 @@ def event_loop(request): @pytest.mark.HApump -@pytest.mark.asyncio async def test_version(pump: Elite11): assert "11 ELITE" in await pump.version() @pytest.mark.HApump -@pytest.mark.asyncio -async def test_status_idle(pump: Elite11): - await pump.stop() - assert await pump.get_status() is PumpStatus.IDLE - - -@pytest.mark.HApump -@pytest.mark.asyncio -async def test_status_infusing(pump: Elite11): - await move_infuse(pump) - assert await pump.get_status() is PumpStatus.INFUSING - await pump.stop() - - -@pytest.mark.HApump -@pytest.mark.asyncio -async def test_status_withdrawing(pump: Elite11): - await pump.set_syringe_diameter("10 mm") - await pump.set_withdrawing_flow_rate("1 ml/min") - await pump.withdraw() - assert await pump.get_status() is PumpStatus.WITHDRAWING - await pump.stop() - - -@pytest.mark.HApump -@pytest.mark.asyncio async def test_is_moving(pump: Elite11): assert await pump.is_moving() is False await move_infuse(pump) @@ -79,23 +57,23 @@ async def test_is_moving(pump: Elite11): @pytest.mark.HApump -@pytest.mark.asyncio async def test_syringe_volume(pump: Elite11): - await pump.set_syringe_volume("10 ml") + await pump.set_syringe_volume(ureg.Quantity("10 ml")) assert await pump.get_syringe_volume() == "10 ml" - await pump.set_syringe_volume(f"{math.pi} ml") + await pump.set_syringe_volume(ureg.Quantity(f"{math.pi} ml")) vol = ureg.Quantity(await pump.get_syringe_volume()).magnitude assert math.isclose(vol, math.pi, abs_tol=10e-4) - await pump.set_syringe_volume("3e-05 ml") + await pump.set_syringe_volume(ureg.Quantity("3e-05 ml")) vol = ureg.Quantity(await pump.get_syringe_volume()).magnitude assert math.isclose(vol, 3e-5) - await pump.set_syringe_volume("50 ml") # Leave it high for next tests + await pump.set_syringe_volume( + ureg.Quantity("50 ml") + ) # Leave it high for next tests @pytest.mark.HApump -@pytest.mark.asyncio async def test_infusion_rate(pump: Elite11): - await pump.set_syringe_volume("10 ml") + await pump.set_syringe_volume(ureg.Quantity("10 ml")) await pump.set_flow_rate("5 ml/min") assert await pump.get_flow_rate() with pytest.warns(UserWarning): @@ -112,33 +90,6 @@ async def test_infusion_rate(pump: Elite11): @pytest.mark.HApump -@pytest.mark.asyncio -async def test_get_infused_volume(pump: Elite11): - await pump.clear_volumes() - assert await pump.get_infused_volume() == "0 ul" - await pump.set_syringe_diameter("30 mm") - await pump.set_flow_rate("5 ml/min") - await pump.set_target_volume("0.05 ml") - await pump.infuse() - await asyncio.sleep(2) - vol = ureg.Quantity(await pump.get_infused_volume()).to("ml").magnitude - assert math.isclose(vol, 0.05, abs_tol=1e-4) - - -@pytest.mark.HApump -@pytest.mark.asyncio -async def test_get_withdrawn_volume(pump: Elite11): - await pump.clear_volumes() - await pump.set_withdrawing_flow_rate("10 ml/min") - await pump.set_target_volume("0.1 ml") - await pump.withdraw() - await asyncio.sleep(1) - vol = ureg.Quantity(await pump.get_withdrawn_volume()).to("ml").magnitude - assert math.isclose(vol, 0.1, abs_tol=1e-4) - - -@pytest.mark.HApump -@pytest.mark.asyncio async def test_force(pump: Elite11): await pump.set_force(10) assert await pump.get_force() == 10 @@ -148,29 +99,16 @@ async def test_force(pump: Elite11): @pytest.mark.HApump -@pytest.mark.asyncio async def test_diameter(pump: Elite11): - await pump.set_syringe_diameter("10 mm") + await pump.set_syringe_diameter(ureg.Quantity("10 mm")) assert await pump.get_syringe_diameter() == "10.0000 mm" with pytest.warns(UserWarning): - await pump.set_syringe_diameter("34 mm") + await pump.set_syringe_diameter(ureg.Quantity("34 mm")) with pytest.warns(UserWarning): - await pump.set_syringe_diameter("0.01 mm") + await pump.set_syringe_diameter(ureg.Quantity("0.01 mm")) - await pump.set_syringe_diameter(f"{math.pi} mm") + await pump.set_syringe_diameter(ureg.Quantity(f"{math.pi} mm")) dia = ureg.Quantity(await pump.get_syringe_diameter()).magnitude math.isclose(dia, math.pi) - - -@pytest.mark.HApump -@pytest.mark.asyncio -async def test_target_volume(pump: Elite11): - await pump.set_syringe_volume("10 ml") - await pump.set_target_volume(f"{math.pi} ml") - vol = ureg.Quantity(await pump.get_target_volume()).magnitude - assert math.isclose(vol, math.pi, abs_tol=10e-4) - await pump.set_target_volume("1e-04 ml") - vol = ureg.Quantity(await pump.get_target_volume()).magnitude - assert math.isclose(vol, 1e-4, abs_tol=10e-4) From 6b20442cbd1e3097a8289979085ba825c6c1540a Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 22:27:03 +0200 Subject: [PATCH 45/62] Minor changes --- .../devices/list_known_device_type.py | 2 +- src/flowchem/server/configuration_parser.py | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/flowchem/devices/list_known_device_type.py b/src/flowchem/devices/list_known_device_type.py index 3d603c18..ff735d07 100644 --- a/src/flowchem/devices/list_known_device_type.py +++ b/src/flowchem/devices/list_known_device_type.py @@ -46,7 +46,7 @@ def autodiscover_third_party() -> dict[str, Any]: } -def autodiscover_device_classes(): +def autodiscover_device_classes() -> dict[str, Any]: """Get all the device-controlling classes, either from `flowchem.devices` or third party packages.""" first = autodiscover_first_party() # logger.info(f"Found {len(first)} 1st-party device type! {list(first.keys())}") diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index dff2a301..7d2f2714 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -77,18 +77,16 @@ def ensure_device_name_is_valid(device_name: str) -> None: Uniqueness of names is ensured by their toml dict key nature, """ if len(device_name) > DEVICE_NAME_MAX_LENGTH: - # This is because f"{name}._labthing._tcp.local." has to be shorter than 64 in zerconfig - raise InvalidConfigurationError( - f"Device name '{device_name}' is too long ({len(device_name)} characters, max is {DEVICE_NAME_MAX_LENGTH})" - ) + # f"{name}._labthing._tcp.local." has to be shorter than 64 characters to be valid for mDNS + msg = f"Device name '{device_name}' is too long: {len(device_name)} characters, max is {DEVICE_NAME_MAX_LENGTH}" + raise InvalidConfigurationError(msg) if "." in device_name: # This is not strictly needed but avoids potential zeroconf problems - raise InvalidConfigurationError( - f"Invalid character '.' in device name '{device_name}'" - ) + msg = f"Invalid character '.' in device name '{device_name}'" + raise InvalidConfigurationError(msg) -def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: +def parse_device(dev_settings: dict, device_object_mapper: dict) -> FlowchemDevice: """Parse device config and return a device object. Exception handling to provide more specific and diagnostic messages upon errors in the configuration file. @@ -116,9 +114,8 @@ def parse_device(dev_settings, device_object_mapper) -> FlowchemDevice: f"Device type `{device_config['type']}` unknown in 'device.{device_name}'!" f"[Known types: {device_object_mapper.keys()}]", ) - raise InvalidConfigurationError( - f"Unknown device type `{device_config['type']}`." - ) from error + msg = f"Unknown device type `{device_config['type']}`." + raise InvalidConfigurationError(msg) from error # If the object has a 'from_config' method, use that for instantiation, otherwise try straight with the constructor. try: From 16d1449eaebd5794dcb46ccdc48d3fac6437680d Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 23:02:17 +0200 Subject: [PATCH 46/62] Minor changes --- src/flowchem/client/async_client.py | 6 +++++- src/flowchem/client/common.py | 4 +--- src/flowchem/client/device_client.py | 21 +++++++++++++-------- src/flowchem/server/configuration_parser.py | 12 ++++++------ src/flowchem/server/fastapi_server.py | 2 +- src/flowchem/vendor/repeat_every.py | 4 ++-- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 257ff8d0..3ab6b76a 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -15,6 +15,8 @@ class FlowchemAsyncDeviceListener(FlowchemCommonDeviceListener): + bg_tasks = set() + async def _resolve_service(self, zc: Zeroconf, type_: str, name: str): service_info = AsyncServiceInfo(type_, name) await service_info.async_request(zc, 3000) @@ -26,7 +28,9 @@ async def _resolve_service(self, zc: Zeroconf, type_: str, name: str): logger.warning(f"No info for service {name}!") def _save_device_info(self, zc: Zeroconf, type_: str, name: str) -> None: - asyncio.ensure_future(self._resolve_service(zc, type_, name)) + task = asyncio.ensure_future(self._resolve_service(zc, type_, name)) + self.bg_tasks.add(task) + task.add_done_callback(self.bg_tasks.discard) async def async_get_all_flowchem_devices( diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index 40a13f64..e75dee93 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -32,9 +32,7 @@ def device_url_from_service_info( # Needed to convert IP from bytes to str device_ip = ipaddress.ip_address(service_info.addresses[0]) return AnyHttpUrl(f"http://{device_ip}:{service_info.port}/{device_name}") - else: - logger.warning(f"No address found for {device_name}!") - return None + logger.warning(f"No address found for {device_name}!") class FlowchemCommonDeviceListener(ServiceListener): diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index 46cbc1a9..e7e0d6b6 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -23,35 +23,40 @@ def __init__(self, url: AnyHttpUrl) -> None: self._session.get(self.url).text, ) except ConnectionError as ce: - raise RuntimeError( + msg = ( f"Cannot connect to device at {url}!" f"This is likely caused by the server listening only on local the interface," f"start flowchem with the --host 0.0.0.0 option to check if that's the problem!" - ) from ce + ) + raise RuntimeError(msg) from ce self.components = [ FlowchemComponentClient(cmp_url, parent=self) for cmp_url in self.device_info.components.values() ] def __getitem__(self, item): + """Get a device component by name or type.""" if isinstance(item, type): return [ component for component in self.components if isinstance(component, item) ] - elif isinstance(item, str): + if isinstance(item, str): try: FlowchemComponentClient(self.device_info.components[item], self) - except IndexError: - raise KeyError(f"No component named '{item}' in {repr(self)}.") + except IndexError as ie: + msg = f"No component named '{item}' in {repr(self)}." + raise KeyError(msg) from ie + return None # noinspection PyUnusedLocal @staticmethod - def raise_for_status(resp, *args, **kwargs): + def raise_for_status(resp, *args, **kwargs): # noqa + """Raise errors for status codes != 200 in session requests.""" resp.raise_for_status() - # noinspection PyUnusedLocal @staticmethod - def log_responses(resp, *args, **kwargs): + def log_responses(resp, *args, **kwargs): # noqa + """Log all the requests sent.""" logger.debug(f"Reply: {resp.text} on {resp.url}") diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 7d2f2714..982bb3c6 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -128,19 +128,19 @@ def parse_device(dev_settings: dict, device_object_mapper: dict) -> FlowchemDevi except TypeError as error: logger.error(f"Wrong settings for device '{device_name}'!") get_helpful_error_message(device_config, inspect.getfullargspec(called)) - raise ConnectionError( + msg = ( f"Wrong configuration provided for device '{device_name}' of type {obj_type.__name__}!\n" f"Configuration: {device_config}\n" f"Accepted parameters: {inspect.getfullargspec(called).args}" - ) from error + ) + + raise ConnectionError(msg) from error - logger.debug( - f"Created device '{device.name}' instance: {device.__class__.__name__}", - ) + logger.debug(f"Created '{device.name}' instance: {device.__class__.__name__}") return device -def get_helpful_error_message(called_with: dict, arg_spec: inspect.FullArgSpec): +def get_helpful_error_message(called_with: dict, arg_spec: inspect.FullArgSpec) -> None: """Give helpful debugging text on configuration errors.""" # First check if we have provided an argument that is not supported. # Clearly no **kwargs should be defined in the signature otherwise all kwargs are ok diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index 37a9a4c3..08fa8b72 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -30,7 +30,7 @@ def __init__( self._add_root_redirect() - def _add_root_redirect(self): + def _add_root_redirect(self) -> None: @self.app.route("/") def home_redirect_to_docs(request): """Redirect root to `/docs` to enable interaction w/ API.""" diff --git a/src/flowchem/vendor/repeat_every.py b/src/flowchem/vendor/repeat_every.py index 44697121..522ef7c2 100644 --- a/src/flowchem/vendor/repeat_every.py +++ b/src/flowchem/vendor/repeat_every.py @@ -1,4 +1,4 @@ -# Vendored from fastapi_utils due to pydantic v2 incompatibilities with the pacakge +"""Vendored from fastapi_utils due to the package incompatibility with pydantic v2""" import asyncio import logging from asyncio import ensure_future @@ -74,7 +74,7 @@ async def loop() -> None: formatted_exception = "".join( format_exception(type(exc), exc, exc.__traceback__), ) - logger.error(formatted_exception) + logger.exception(formatted_exception) if raise_exceptions: raise exc await asyncio.sleep(seconds) From 5f64e44b7354c647ff14dbd0510c1b1fdaf7a168 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Thu, 13 Jul 2023 23:16:29 +0200 Subject: [PATCH 47/62] mypy --- src/flowchem/client/async_client.py | 3 ++- src/flowchem/client/common.py | 1 + src/flowchem/devices/magritek/spinsolve.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/flowchem/client/async_client.py b/src/flowchem/client/async_client.py index 3ab6b76a..ab8fd8b3 100644 --- a/src/flowchem/client/async_client.py +++ b/src/flowchem/client/async_client.py @@ -1,4 +1,5 @@ import asyncio +from typing import Any from loguru import logger from zeroconf import Zeroconf @@ -15,7 +16,7 @@ class FlowchemAsyncDeviceListener(FlowchemCommonDeviceListener): - bg_tasks = set() + bg_tasks: set[Any] = set() async def _resolve_service(self, zc: Zeroconf, type_: str, name: str): service_info = AsyncServiceInfo(type_, name) diff --git a/src/flowchem/client/common.py b/src/flowchem/client/common.py index e75dee93..ef0c4ae6 100644 --- a/src/flowchem/client/common.py +++ b/src/flowchem/client/common.py @@ -33,6 +33,7 @@ def device_url_from_service_info( device_ip = ipaddress.ip_address(service_info.addresses[0]) return AnyHttpUrl(f"http://{device_ip}:{service_info.port}/{device_name}") logger.warning(f"No address found for {device_name}!") + return None class FlowchemCommonDeviceListener(ServiceListener): diff --git a/src/flowchem/devices/magritek/spinsolve.py b/src/flowchem/devices/magritek/spinsolve.py index 6e62375d..49c89e0b 100644 --- a/src/flowchem/devices/magritek/spinsolve.py +++ b/src/flowchem/devices/magritek/spinsolve.py @@ -263,7 +263,7 @@ async def load_protocols(self): async def run_protocol( self, name, - background_tasks: BackgroundTasks = None, + background_tasks: BackgroundTasks, options=None, ) -> int: """Run a protocol. From 6b1c63dc9c470f8cda114baf357242b588afb603 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 14 Jul 2023 00:03:51 +0200 Subject: [PATCH 48/62] minor refactoring --- docs/add_device/add_to_flowchem.md | 2 +- examples/README.md | 3 +++ pyproject.toml | 2 +- src/flowchem/client/client.py | 4 ++-- src/flowchem/client/device_client.py | 14 +++++-------- src/flowchem/components/analytics/hplc.py | 2 +- src/flowchem/components/analytics/ir.py | 2 +- src/flowchem/components/analytics/nmr.py | 2 +- src/flowchem/components/device_info.py | 6 +----- ...ase_component.py => flowchem_component.py} | 0 .../pumps/{hplc.py => hplc_pump.py} | 4 ++-- .../pumps/{base_pump.py => pump.py} | 4 ++-- .../pumps/{syringe.py => syringe_pump.py} | 4 ++-- .../sensors/{photo.py => photo_sensor.py} | 2 +- .../{pressure.py => pressure_sensor.py} | 2 +- .../sensors/{base_sensor.py => sensor.py} | 2 +- src/flowchem/components/technical/photo.py | 2 +- src/flowchem/components/technical/power.py | 2 +- src/flowchem/components/technical/pressure.py | 2 +- .../components/technical/temperature.py | 2 +- .../components/valves/distribution_valves.py | 10 +++++----- .../components/valves/injection_valves.py | 4 ++-- .../valves/{base_valve.py => valve.py} | 4 ++-- .../devices/bronkhorst/el_flow_component.py | 4 ++-- src/flowchem/devices/flowchem_device.py | 2 +- src/flowchem/devices/hamilton/ml600_pump.py | 2 +- src/flowchem/devices/hamilton/ml600_valve.py | 4 ++-- .../devices/harvardapparatus/elite11_pump.py | 2 +- .../devices/knauer/azura_compact_pump.py | 2 +- .../devices/knauer/azura_compact_sensor.py | 2 +- src/flowchem/devices/knauer/dad.py | 2 +- src/flowchem/devices/knauer/dad_component.py | 4 ++-- src/flowchem/devices/knauer/knauer_valve.py | 20 ++++++++++++------- .../devices/knauer/knauer_valve_component.py | 16 +++++++-------- .../phidgets/bubble_sensor_component.py | 2 +- .../phidgets/pressure_sensor_component.py | 2 +- .../vapourtec/r2_components_control.py | 14 ++++++------- .../devices/vicivalco/vici_valve_component.py | 4 ++-- src/flowchem/server/README.md | 3 ++- src/flowchem/utils/README.md | 10 +++++++--- src/flowchem/utils/people.py | 10 ++++++++-- src/flowchem/vendor/README.md | 6 ++++++ tests/README.md | 8 ++++++++ tests/client/test_client.py | 12 +++++++++++ 44 files changed, 125 insertions(+), 87 deletions(-) create mode 100644 examples/README.md rename src/flowchem/components/{base_component.py => flowchem_component.py} (100%) rename src/flowchem/components/pumps/{hplc.py => hplc_pump.py} (88%) rename src/flowchem/components/pumps/{base_pump.py => pump.py} (92%) rename src/flowchem/components/pumps/{syringe.py => syringe_pump.py} (84%) rename src/flowchem/components/sensors/{photo.py => photo_sensor.py} (95%) rename src/flowchem/components/sensors/{pressure.py => pressure_sensor.py} (95%) rename src/flowchem/components/sensors/{base_sensor.py => sensor.py} (91%) rename src/flowchem/components/valves/{base_valve.py => valve.py} (96%) create mode 100644 src/flowchem/vendor/README.md create mode 100644 tests/README.md diff --git a/docs/add_device/add_to_flowchem.md b/docs/add_device/add_to_flowchem.md index 871df17a..7f9ce2bd 100644 --- a/docs/add_device/add_to_flowchem.md +++ b/docs/add_device/add_to_flowchem.md @@ -120,7 +120,7 @@ For example, we can define three methods: one to start the recording, one to sto The stop method will be responsible for returning the path of the file where the recording was saved. ```python -from flowchem.components.sensors.base_sensor import Sensor +from flowchem.components.sensors.sensor import Sensor class Microphone(Sensor): diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..f51a2ac3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,3 @@ +# Examples + +This folder collects example of the use of flowchem devices. diff --git a/pyproject.toml b/pyproject.toml index e498375f..31709f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ testpaths = "tests" asyncio_mode = "auto" # pytest cov is not compatible with the pycharm debugger in tests # addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" -addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=30" +addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=40" markers = [ "HApump: tests requiring a local HA Elite11 connected.", diff --git a/src/flowchem/client/client.py b/src/flowchem/client/client.py index e18d9f32..fbbd90fa 100644 --- a/src/flowchem/client/client.py +++ b/src/flowchem/client/client.py @@ -34,5 +34,5 @@ def get_all_flowchem_devices(timeout: float = 3000) -> dict[str, FlowchemDeviceC if __name__ == "__main__": - dev_info = get_all_flowchem_devices() - print(dev_info) + flowchem_devices: dict[str, FlowchemDeviceClient] = get_all_flowchem_devices() + print(flowchem_devices) diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index e7e0d6b6..ac482830 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -29,10 +29,10 @@ def __init__(self, url: AnyHttpUrl) -> None: f"start flowchem with the --host 0.0.0.0 option to check if that's the problem!" ) raise RuntimeError(msg) from ce - self.components = [ - FlowchemComponentClient(cmp_url, parent=self) - for cmp_url in self.device_info.components.values() - ] + self.components = { + k: FlowchemComponentClient(v, parent=self) + for k, v in self.device_info.components.items() + } def __getitem__(self, item): """Get a device component by name or type.""" @@ -43,11 +43,7 @@ def __getitem__(self, item): if isinstance(component, item) ] if isinstance(item, str): - try: - FlowchemComponentClient(self.device_info.components[item], self) - except IndexError as ie: - msg = f"No component named '{item}' in {repr(self)}." - raise KeyError(msg) from ie + return self.components.get(item, None) return None # noinspection PyUnusedLocal diff --git a/src/flowchem/components/analytics/hplc.py b/src/flowchem/components/analytics/hplc.py index b7b2126c..87bc3736 100644 --- a/src/flowchem/components/analytics/hplc.py +++ b/src/flowchem/components/analytics/hplc.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent if TYPE_CHECKING: from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/analytics/ir.py b/src/flowchem/components/analytics/ir.py index 92f7d219..5c063511 100644 --- a/src/flowchem/components/analytics/ir.py +++ b/src/flowchem/components/analytics/ir.py @@ -1,7 +1,7 @@ """An IR control component.""" from pydantic import BaseModel -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/analytics/nmr.py b/src/flowchem/components/analytics/nmr.py index 6136d6a5..c005ecad 100644 --- a/src/flowchem/components/analytics/nmr.py +++ b/src/flowchem/components/analytics/nmr.py @@ -1,7 +1,7 @@ """An NMR control component.""" from fastapi import BackgroundTasks -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/device_info.py b/src/flowchem/components/device_info.py index 1e36fe60..6e0ddb60 100644 --- a/src/flowchem/components/device_info.py +++ b/src/flowchem/components/device_info.py @@ -1,11 +1,7 @@ from pydantic import AnyHttpUrl, BaseModel from flowchem import __version__ - - -class Person(BaseModel): - name: str - email: str +from flowchem.utils.people import Person class DeviceInfo(BaseModel): diff --git a/src/flowchem/components/base_component.py b/src/flowchem/components/flowchem_component.py similarity index 100% rename from src/flowchem/components/base_component.py rename to src/flowchem/components/flowchem_component.py diff --git a/src/flowchem/components/pumps/hplc.py b/src/flowchem/components/pumps/hplc_pump.py similarity index 88% rename from src/flowchem/components/pumps/hplc.py rename to src/flowchem/components/pumps/hplc_pump.py index cede6a45..291f65ed 100644 --- a/src/flowchem/components/pumps/hplc.py +++ b/src/flowchem/components/pumps/hplc_pump.py @@ -1,10 +1,10 @@ """Syringe pump component, two flavours, infuse only, infuse-withdraw.""" -from flowchem.components.pumps.base_pump import BasePump +from flowchem.components.pumps.pump import Pump from flowchem.devices.flowchem_device import FlowchemDevice -class HPLCPump(BasePump): +class HPLCPump(Pump): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: super().__init__(name, hw_device) diff --git a/src/flowchem/components/pumps/base_pump.py b/src/flowchem/components/pumps/pump.py similarity index 92% rename from src/flowchem/components/pumps/base_pump.py rename to src/flowchem/components/pumps/pump.py index c0c8572c..d99b7e8f 100644 --- a/src/flowchem/components/pumps/base_pump.py +++ b/src/flowchem/components/pumps/pump.py @@ -1,9 +1,9 @@ """Base pump component.""" -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice -class BasePump(FlowchemComponent): +class Pump(FlowchemComponent): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: """A generic pump.""" super().__init__(name, hw_device) diff --git a/src/flowchem/components/pumps/syringe.py b/src/flowchem/components/pumps/syringe_pump.py similarity index 84% rename from src/flowchem/components/pumps/syringe.py rename to src/flowchem/components/pumps/syringe_pump.py index 20e3e3c5..6915f11f 100644 --- a/src/flowchem/components/pumps/syringe.py +++ b/src/flowchem/components/pumps/syringe_pump.py @@ -1,9 +1,9 @@ """Syringe pump component, two flavours, infuse only, infuse-withdraw.""" -from flowchem.components.pumps.base_pump import BasePump +from flowchem.components.pumps.pump import Pump from flowchem.devices.flowchem_device import FlowchemDevice -class SyringePump(BasePump): +class SyringePump(Pump): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: super().__init__(name, hw_device) diff --git a/src/flowchem/components/sensors/photo.py b/src/flowchem/components/sensors/photo_sensor.py similarity index 95% rename from src/flowchem/components/sensors/photo.py rename to src/flowchem/components/sensors/photo_sensor.py index 480e0224..9abb1c6c 100644 --- a/src/flowchem/components/sensors/photo.py +++ b/src/flowchem/components/sensors/photo_sensor.py @@ -1,7 +1,7 @@ """Pressure sensor.""" from flowchem.devices.flowchem_device import FlowchemDevice -from .base_sensor import Sensor +from .sensor import Sensor class PhotoSensor(Sensor): diff --git a/src/flowchem/components/sensors/pressure.py b/src/flowchem/components/sensors/pressure_sensor.py similarity index 95% rename from src/flowchem/components/sensors/pressure.py rename to src/flowchem/components/sensors/pressure_sensor.py index 34e99334..fcd5a745 100644 --- a/src/flowchem/components/sensors/pressure.py +++ b/src/flowchem/components/sensors/pressure_sensor.py @@ -1,7 +1,7 @@ """Pressure sensor.""" from flowchem.devices.flowchem_device import FlowchemDevice -from .base_sensor import Sensor +from .sensor import Sensor class PressureSensor(Sensor): diff --git a/src/flowchem/components/sensors/base_sensor.py b/src/flowchem/components/sensors/sensor.py similarity index 91% rename from src/flowchem/components/sensors/base_sensor.py rename to src/flowchem/components/sensors/sensor.py index 25c3256e..001bbf95 100644 --- a/src/flowchem/components/sensors/base_sensor.py +++ b/src/flowchem/components/sensors/sensor.py @@ -1,7 +1,7 @@ """Sensor device.""" from __future__ import annotations -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/technical/photo.py b/src/flowchem/components/technical/photo.py index 6abefd94..041f0d32 100644 --- a/src/flowchem/components/technical/photo.py +++ b/src/flowchem/components/technical/photo.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent if TYPE_CHECKING: from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/technical/power.py b/src/flowchem/components/technical/power.py index d13925ce..62b2e80b 100644 --- a/src/flowchem/components/technical/power.py +++ b/src/flowchem/components/technical/power.py @@ -1,7 +1,7 @@ """Power control, sets both voltage and current. (Could be split in two, unnecessarty for now).""" from __future__ import annotations -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/technical/pressure.py b/src/flowchem/components/technical/pressure.py index b2136487..957ddc0a 100644 --- a/src/flowchem/components/technical/pressure.py +++ b/src/flowchem/components/technical/pressure.py @@ -7,7 +7,7 @@ from loguru import logger from flowchem import ureg -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent if TYPE_CHECKING: from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/technical/temperature.py b/src/flowchem/components/technical/temperature.py index 38994798..c191d5ff 100644 --- a/src/flowchem/components/technical/temperature.py +++ b/src/flowchem/components/technical/temperature.py @@ -7,7 +7,7 @@ from loguru import logger from flowchem import ureg -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent if TYPE_CHECKING: from flowchem.devices.flowchem_device import FlowchemDevice diff --git a/src/flowchem/components/valves/distribution_valves.py b/src/flowchem/components/valves/distribution_valves.py index 959fd73b..7acda3a5 100644 --- a/src/flowchem/components/valves/distribution_valves.py +++ b/src/flowchem/components/valves/distribution_valves.py @@ -1,9 +1,9 @@ """Distribution valves, generally connected to syringe pumps, direct the flow from a fixed port to one of the others.""" -from flowchem.components.valves.base_valve import BaseValve +from flowchem.components.valves.valve import Valve from flowchem.devices.flowchem_device import FlowchemDevice -class TwoPortDistribution(BaseValve): +class TwoPortDistributionValve(Valve): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], @@ -12,7 +12,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice) -> None: super().__init__(name, hw_device, positions, ports=["pump", "1", "2"]) -class SixPortDistribution(BaseValve): +class SixPortDistributionValve(Valve): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], @@ -30,7 +30,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice) -> None: ) -class TwelvePortDistribution(BaseValve): +class TwelvePortDistributionValve(Valve): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], @@ -68,7 +68,7 @@ def __init__(self, name: str, hw_device: FlowchemDevice) -> None: ) -class SixteenPortDistribution(BaseValve): +class SixteenPortDistributionValve(Valve): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: positions = { "1": [("pump", "1")], diff --git a/src/flowchem/components/valves/injection_valves.py b/src/flowchem/components/valves/injection_valves.py index bac1d75e..635f74b4 100644 --- a/src/flowchem/components/valves/injection_valves.py +++ b/src/flowchem/components/valves/injection_valves.py @@ -1,9 +1,9 @@ """Injection valves are multiport, two-position valves, e.g. 6-2 commonly used w/ injection loops for HPLC injection.""" -from flowchem.components.valves.base_valve import BaseValve +from flowchem.components.valves.valve import Valve from flowchem.devices.flowchem_device import FlowchemDevice -class SixPortTwoPosition(BaseValve): +class SixPortTwoPositionValve(Valve): def __init__(self, name: str, hw_device: FlowchemDevice) -> None: # These are hardware-port, only input and output are routable from the fixed syringe. # All three are listed as this simplifies the creation of graphs diff --git a/src/flowchem/components/valves/base_valve.py b/src/flowchem/components/valves/valve.py similarity index 96% rename from src/flowchem/components/valves/base_valve.py rename to src/flowchem/components/valves/valve.py index 60ebeb2b..03577499 100644 --- a/src/flowchem/components/valves/base_valve.py +++ b/src/flowchem/components/valves/valve.py @@ -3,7 +3,7 @@ from pydantic import BaseModel -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.devices.flowchem_device import FlowchemDevice @@ -12,7 +12,7 @@ class ValveInfo(BaseModel): positions: dict[str, list[tuple[str, str]]] -class BaseValve(FlowchemComponent): +class Valve(FlowchemComponent): """An abstract class for devices of type valve. .. warning:: diff --git a/src/flowchem/devices/bronkhorst/el_flow_component.py b/src/flowchem/devices/bronkhorst/el_flow_component.py index 114ff8be..7aa04eb2 100644 --- a/src/flowchem/devices/bronkhorst/el_flow_component.py +++ b/src/flowchem/devices/bronkhorst/el_flow_component.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING from flowchem import ureg -from flowchem.components.base_component import FlowchemComponent -from flowchem.components.sensors.pressure import PressureSensor +from flowchem.components.flowchem_component import FlowchemComponent +from flowchem.components.sensors.pressure_sensor import PressureSensor from flowchem.devices.flowchem_device import FlowchemDevice if TYPE_CHECKING: diff --git a/src/flowchem/devices/flowchem_device.py b/src/flowchem/devices/flowchem_device.py index b77c1b3d..f5df9592 100644 --- a/src/flowchem/devices/flowchem_device.py +++ b/src/flowchem/devices/flowchem_device.py @@ -6,7 +6,7 @@ from flowchem.components.device_info import DeviceInfo if TYPE_CHECKING: - from flowchem.components.base_component import FlowchemComponent + from flowchem.components.flowchem_component import FlowchemComponent RepeatedTaskInfo = namedtuple("RepeatedTaskInfo", ["seconds_every", "task"]) diff --git a/src/flowchem/devices/hamilton/ml600_pump.py b/src/flowchem/devices/hamilton/ml600_pump.py index f11406c7..668a6b87 100644 --- a/src/flowchem/devices/hamilton/ml600_pump.py +++ b/src/flowchem/devices/hamilton/ml600_pump.py @@ -6,7 +6,7 @@ from loguru import logger from flowchem import ureg -from flowchem.components.pumps.syringe import SyringePump +from flowchem.components.pumps.syringe_pump import SyringePump if TYPE_CHECKING: from .ml600 import ML600 diff --git a/src/flowchem/devices/hamilton/ml600_valve.py b/src/flowchem/devices/hamilton/ml600_valve.py index fd170999..dafd0747 100644 --- a/src/flowchem/devices/hamilton/ml600_valve.py +++ b/src/flowchem/devices/hamilton/ml600_valve.py @@ -5,13 +5,13 @@ from loguru import logger -from flowchem.components.valves.distribution_valves import TwoPortDistribution +from flowchem.components.valves.distribution_valves import TwoPortDistributionValve if TYPE_CHECKING: from .ml600 import ML600 -class ML600Valve(TwoPortDistribution): +class ML600Valve(TwoPortDistributionValve): hw_device: ML600 # for typing's sake position_mapping = { diff --git a/src/flowchem/devices/harvardapparatus/elite11_pump.py b/src/flowchem/devices/harvardapparatus/elite11_pump.py index 065aae1f..23b84762 100644 --- a/src/flowchem/devices/harvardapparatus/elite11_pump.py +++ b/src/flowchem/devices/harvardapparatus/elite11_pump.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from .elite11 import Elite11 -from flowchem.components.pumps.syringe import SyringePump +from flowchem.components.pumps.syringe_pump import SyringePump class Elite11PumpOnly(SyringePump): diff --git a/src/flowchem/devices/knauer/azura_compact_pump.py b/src/flowchem/devices/knauer/azura_compact_pump.py index 4b9b0506..0f831f87 100644 --- a/src/flowchem/devices/knauer/azura_compact_pump.py +++ b/src/flowchem/devices/knauer/azura_compact_pump.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from .azura_compact import AzuraCompact -from flowchem.components.pumps.hplc import HPLCPump +from flowchem.components.pumps.hplc_pump import HPLCPump def isfloat(rate: str) -> bool: diff --git a/src/flowchem/devices/knauer/azura_compact_sensor.py b/src/flowchem/devices/knauer/azura_compact_sensor.py index ca8e46b9..9f0b9083 100644 --- a/src/flowchem/devices/knauer/azura_compact_sensor.py +++ b/src/flowchem/devices/knauer/azura_compact_sensor.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from .azura_compact import AzuraCompact -from flowchem.components.sensors.pressure import PressureSensor +from flowchem.components.sensors.pressure_sensor import PressureSensor class AzuraCompactSensor(PressureSensor): diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index 237640b4..44150968 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -135,7 +135,7 @@ async def identify(self) -> str: async def info(self) -> str: """Get the instrument information NUMBER OF PIXEL (256, 512, 1024), SPECTRAL RANGE(“UV”, “VIS”, “UV-VIS”), - HARDVARE VERSION, YEAR OF PRODUCTION,WEEK OF PRODUCTION,,CALIBR. A,CALIBR. B,, CALIBR. C. + HARDWARE VERSION, YEAR OF PRODUCTION,WEEK OF PRODUCTION,,CALIBR. A,CALIBR. B,, CALIBR. C. """ return await self._send_and_receive(self.cmd.INFO) diff --git a/src/flowchem/devices/knauer/dad_component.py b/src/flowchem/devices/knauer/dad_component.py index f4974392..be559e72 100644 --- a/src/flowchem/devices/knauer/dad_component.py +++ b/src/flowchem/devices/knauer/dad_component.py @@ -1,9 +1,9 @@ -"""Knauer dad component.""" +"""Knauer DAD component.""" from __future__ import annotations from typing import TYPE_CHECKING -from flowchem.components.sensors.photo import PhotoSensor +from flowchem.components.sensors.photo_sensor import PhotoSensor if TYPE_CHECKING: from flowchem.devices.knauer.dad import KnauerDAD diff --git a/src/flowchem/devices/knauer/knauer_valve.py b/src/flowchem/devices/knauer/knauer_valve.py index 741175b1..ac453b8f 100644 --- a/src/flowchem/devices/knauer/knauer_valve.py +++ b/src/flowchem/devices/knauer/knauer_valve.py @@ -4,14 +4,14 @@ from loguru import logger -from flowchem.components.base_component import FlowchemComponent +from flowchem.components.flowchem_component import FlowchemComponent from flowchem.components.device_info import DeviceInfo from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.devices.knauer._common import KnauerEthernetDevice from flowchem.devices.knauer.knauer_valve_component import ( - Knauer6PortDistribution, - Knauer12PortDistribution, - Knauer16PortDistribution, + Knauer6PortDistributionValve, + Knauer12PortDistributionValve, + Knauer16PortDistributionValve, KnauerInjectionValve, ) from flowchem.utils.exceptions import DeviceError @@ -61,11 +61,17 @@ async def initialize(self): case KnauerValveHeads.SIX_PORT_TWO_POSITION: valve_component = KnauerInjectionValve("injection-valve", self) case KnauerValveHeads.SIX_PORT_SIX_POSITION: - valve_component = Knauer6PortDistribution("distribution-valve", self) + valve_component = Knauer6PortDistributionValve( + "distribution-valve", self + ) case KnauerValveHeads.TWELVE_PORT_TWELVE_POSITION: - valve_component = Knauer12PortDistribution("distribution-valve", self) + valve_component = Knauer12PortDistributionValve( + "distribution-valve", self + ) case KnauerValveHeads.SIXTEEN_PORT_SIXTEEN_POSITION: - valve_component = Knauer16PortDistribution("distribution-valve", self) + valve_component = Knauer16PortDistributionValve( + "distribution-valve", self + ) case _: raise RuntimeError("Unknown valve type") self.components.append(valve_component) diff --git a/src/flowchem/devices/knauer/knauer_valve_component.py b/src/flowchem/devices/knauer/knauer_valve_component.py index b61fdf7a..abfb289b 100644 --- a/src/flowchem/devices/knauer/knauer_valve_component.py +++ b/src/flowchem/devices/knauer/knauer_valve_component.py @@ -6,14 +6,14 @@ if TYPE_CHECKING: from .knauer_valve import KnauerValve from flowchem.components.valves.distribution_valves import ( - SixPortDistribution, - SixteenPortDistribution, - TwelvePortDistribution, + SixPortDistributionValve, + SixteenPortDistributionValve, + TwelvePortDistributionValve, ) -from flowchem.components.valves.injection_valves import SixPortTwoPosition +from flowchem.components.valves.injection_valves import SixPortTwoPositionValve -class KnauerInjectionValve(SixPortTwoPosition): +class KnauerInjectionValve(SixPortTwoPositionValve): hw_device: KnauerValve # for typing's sake position_mapping = {"load": "L", "inject": "I"} _reverse_position_mapping = {v: k for k, v in position_mapping.items()} @@ -31,7 +31,7 @@ async def set_position(self, position: str): return await self.hw_device.set_raw_position(target_pos) -class Knauer6PortDistribution(SixPortDistribution): +class Knauer6PortDistributionValve(SixPortDistributionValve): """KnauerValve of type SIX_PORT_SIX_POSITION.""" hw_device: KnauerValve # for typing's sake @@ -46,7 +46,7 @@ async def set_position(self, position: str): return await self.hw_device.set_raw_position(position) -class Knauer12PortDistribution(TwelvePortDistribution): +class Knauer12PortDistributionValve(TwelvePortDistributionValve): """KnauerValve of type SIX_PORT_SIX_POSITION.""" hw_device: KnauerValve # for typing's sake @@ -61,7 +61,7 @@ async def set_position(self, position: str): return await self.hw_device.set_raw_position(position) -class Knauer16PortDistribution(SixteenPortDistribution): +class Knauer16PortDistributionValve(SixteenPortDistributionValve): """KnauerValve of type SIX_PORT_SIX_POSITION.""" hw_device: KnauerValve # for typing's sake diff --git a/src/flowchem/devices/phidgets/bubble_sensor_component.py b/src/flowchem/devices/phidgets/bubble_sensor_component.py index dd3a0607..7b0d0b31 100644 --- a/src/flowchem/devices/phidgets/bubble_sensor_component.py +++ b/src/flowchem/devices/phidgets/bubble_sensor_component.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from .bubble_sensor import PhidgetBubbleSensor, PhidgetPowerSource5V -from flowchem.components.sensors.base_sensor import Sensor +from flowchem.components.sensors.sensor import Sensor class PhidgetBubbleSensorComponent(Sensor): diff --git a/src/flowchem/devices/phidgets/pressure_sensor_component.py b/src/flowchem/devices/phidgets/pressure_sensor_component.py index e67f96f9..e90e87ac 100644 --- a/src/flowchem/devices/phidgets/pressure_sensor_component.py +++ b/src/flowchem/devices/phidgets/pressure_sensor_component.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from .pressure_sensor import PhidgetPressureSensor -from flowchem.components.sensors.pressure import PressureSensor +from flowchem.components.sensors.pressure_sensor import PressureSensor class PhidgetPressureSensorComponent(PressureSensor): diff --git a/src/flowchem/devices/vapourtec/r2_components_control.py b/src/flowchem/devices/vapourtec/r2_components_control.py index 523e729c..dcd7f0c1 100644 --- a/src/flowchem/devices/vapourtec/r2_components_control.py +++ b/src/flowchem/devices/vapourtec/r2_components_control.py @@ -5,14 +5,14 @@ from loguru import logger -from flowchem.components.pumps.hplc import HPLCPump -from flowchem.components.sensors.base_sensor import Sensor -from flowchem.components.sensors.pressure import PressureSensor +from flowchem.components.pumps.hplc_pump import HPLCPump +from flowchem.components.sensors.sensor import Sensor +from flowchem.components.sensors.pressure_sensor import PressureSensor from flowchem.components.technical.photo import Photoreactor from flowchem.components.technical.power import PowerSwitch from flowchem.components.technical.temperature import TemperatureControl, TempRange -from flowchem.components.valves.distribution_valves import TwoPortDistribution -from flowchem.components.valves.injection_valves import SixPortTwoPosition +from flowchem.components.valves.distribution_valves import TwoPortDistributionValve +from flowchem.components.valves.injection_valves import SixPortTwoPositionValve if TYPE_CHECKING: from .r2 import R2 @@ -122,7 +122,7 @@ async def power_off(self): await self.hw_device.set_UV150(0) -class R2InjectionValve(SixPortTwoPosition): +class R2InjectionValve(SixPortTwoPositionValve): """R2 reactor injection loop valve control class.""" hw_device: R2 # for typing's sake @@ -150,7 +150,7 @@ async def set_position(self, position: str) -> bool: return True -class R2TwoPortValve(TwoPortDistribution): # total 3 positions (A, B, Collection) +class R2TwoPortValve(TwoPortDistributionValve): # total 3 positions (A, B, Collection) """R2 reactor injection loop valve control.""" hw_device: R2 diff --git a/src/flowchem/devices/vicivalco/vici_valve_component.py b/src/flowchem/devices/vicivalco/vici_valve_component.py index fb478a22..9c5658b9 100644 --- a/src/flowchem/devices/vicivalco/vici_valve_component.py +++ b/src/flowchem/devices/vicivalco/vici_valve_component.py @@ -5,10 +5,10 @@ if TYPE_CHECKING: from .vici_valve import ViciValve -from flowchem.components.valves.injection_valves import SixPortTwoPosition +from flowchem.components.valves.injection_valves import SixPortTwoPositionValve -class ViciInjectionValve(SixPortTwoPosition): +class ViciInjectionValve(SixPortTwoPositionValve): hw_device: ViciValve # for typing's sake position_mapping = {"load": "1", "inject": "2"} diff --git a/src/flowchem/server/README.md b/src/flowchem/server/README.md index e958764d..62a563db 100644 --- a/src/flowchem/server/README.md +++ b/src/flowchem/server/README.md @@ -1,3 +1,4 @@ # flowchem/server -This folder contains the modules related to the API Server and the Zeroconfig (mDNS) server for flowchem devices. +This folder contains the modules related to the API Server and the Zeroconfig (mDNS) server for flowchem devices, and +the configuration parser. diff --git a/src/flowchem/utils/README.md b/src/flowchem/utils/README.md index e3753dfd..75d56ab4 100644 --- a/src/flowchem/utils/README.md +++ b/src/flowchem/utils/README.md @@ -1,4 +1,8 @@ -# flowchem/vendor +# flowchem/utils -* **GetMac** The pypi package [getmac](https://pypi.org/project/getmac/) is included in the source as there are a couple of changes -compared to upstream, mainly deprecation of Py2 support to avoid false-positive in mypy. +Some miscellaneous utilities. + +* **device_finder** a utility drafting flowchem configuration files by auto-detecting all the supported devices + connected to the PC. +* **exceptions** Flowchem-specific exceptions, namely DeviceError and InvalidConfigurationError. +* **people** a list of people that worked on flowchem, for use in the author fields of DeviceInfo. diff --git a/src/flowchem/utils/people.py b/src/flowchem/utils/people.py index b65dec07..ab378732 100644 --- a/src/flowchem/utils/people.py +++ b/src/flowchem/utils/people.py @@ -1,6 +1,12 @@ -from flowchem.components.device_info import Person +from pydantic import BaseModel + +__all__ = ["Person", "dario", "jakob", "wei_hsin"] + + +class Person(BaseModel): + name: str + email: str -__all__ = ["dario", "jakob", "wei_hsin"] dario = Person(name="Dario Cambiè", email="2422614+dcambie@users.noreply.github.com") jakob = Person(name="Jakob Wolf", email="Jakob.Wolf@mpikg.mpg.de") diff --git a/src/flowchem/vendor/README.md b/src/flowchem/vendor/README.md new file mode 100644 index 00000000..559e2998 --- /dev/null +++ b/src/flowchem/vendor/README.md @@ -0,0 +1,6 @@ +# flowchem/vendor + +* **GetMac** The pypi package [getmac](https://pypi.org/project/getmac/) (MIT license) is included in the source as + there are a couple of changes compared to upstream, mainly deprecation of Py2 support to avoid false-positive in mypy. +* **repeat_every()** From the pypi package [fastapi_utils](https://pypi.org/project/fastapi_utils/) (MIT license) is + included in the source as the upstream package is incompatible with pydantic v2 (but not this function). diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..565caad6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,8 @@ +# Tests + +The tests are ment to be run with `pytest`. + +All the test that do *not* require access to any hardware device are automatically run for every pull request against +the main branch. + +Additional device-specific test can be run with the corresponding marker (e.g. `pytest -m Spinsolve`) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index e4c73da7..fa655d9a 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -2,12 +2,24 @@ async_get_all_flowchem_devices, ) from flowchem.client.client import get_all_flowchem_devices +from flowchem.client.component_client import FlowchemComponentClient +from flowchem.client.device_client import FlowchemDeviceClient def test_get_all_flowchem_devices(flowchem_test_instance): dev_dict = get_all_flowchem_devices() assert "test-device" in dev_dict + test_device = dev_dict["test-device"] + assert isinstance(test_device, FlowchemDeviceClient) + assert len(test_device.components) == 1 + + test_component = test_device["test-component"] + assert test_component is dev_dict["test-device"]["test-component"] + assert isinstance(test_component, FlowchemComponentClient) + assert test_component.component_info.name == "test-component" + assert test_component.get("test").text == "true" + async def test_async_get_all_flowchem_devices(flowchem_test_instance): dev_dict = await async_get_all_flowchem_devices() From 15d094f29961294df50af8f80a4f3cc2a0c0cf74 Mon Sep 17 00:00:00 2001 From: dcambie <2422614+dcambie@users.noreply.github.com> Date: Thu, 13 Jul 2023 23:29:20 +0000 Subject: [PATCH 49/62] Create codeql.yml (#101) --- .github/workflows/codeql.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..2bdd31ae --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '40 3 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: 'ubuntu-latest' + timeout-minutes: 360 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: 'python' + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:python" From 7cddad66efabda360ddd78a47dfc51edb7251da5 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 14 Jul 2023 01:35:41 +0200 Subject: [PATCH 50/62] update ga actions --- .github/deadpendency.yaml | 10 ---------- .github/workflows/publish_pypi.yml | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 .github/deadpendency.yaml diff --git a/.github/deadpendency.yaml b/.github/deadpendency.yaml deleted file mode 100644 index 03111d30..00000000 --- a/.github/deadpendency.yaml +++ /dev/null @@ -1,10 +0,0 @@ -ignore-failures: - python: - - pyserial -additional-deps: - python: - # name can be included so Deadpendency can load the package details in the registry - - name: asyncua - repo: FreeOpcUa/opcua-asyncio - - name: lxml - repo: lxml/lxml diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 55a841f6..477cce2a 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: if: startsWith(github.ref_name, 'v') uses: pypa/gh-action-pypi-publish@release/v1 - build-release-gh: + build-release-github: runs-on: ubuntu-latest steps: - uses: actions/checkout@master From afc5ed3c77bef46c2a7d836c2a2752e893f4f524 Mon Sep 17 00:00:00 2001 From: dcambie <2422614+dcambie@users.noreply.github.com> Date: Tue, 29 Aug 2023 14:16:16 +0000 Subject: [PATCH 51/62] Device restructure (#103) * Use standard NameEmail instead of custom Person To enable list of str parsing for authors (e.g. in manifest files). Optional bonus is email validation. * Improve docstrings in configuration_parser.py * Add Flowchem base classe To hold the event loop and link to device registry, entities registry and event bus --- src/flowchem/__main__.py | 10 ++++++---- src/flowchem/components/device_info.py | 5 ++--- src/flowchem/server/configuration_parser.py | 22 +++++++++++---------- src/flowchem/server/fastapi_server.py | 8 ++++---- src/flowchem/server/zeroconf_server.py | 3 +++ src/flowchem/utils/people.py | 15 +++++--------- 6 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 4b64c588..11779b77 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -12,7 +12,7 @@ from loguru import logger from flowchem import __version__ -from flowchem.server.create_server import create_server_from_file +from flowchem.server.core import Flowchem @click.argument("device_config_file", type=click.Path(), required=True) @@ -62,11 +62,13 @@ def main(device_config_file, logfile, host, debug): async def main_loop(): """Main application loop, the event loop is shared between uvicorn and flowchem.""" - flowchem_instance = await create_server_from_file(Path(device_config_file)) + flowchem = Flowchem(Path(device_config_file)) + await flowchem.setup() + config = uvicorn.Config( - flowchem_instance["api_server"].app, + flowchem.http.app, host=host, - port=flowchem_instance["port"], + port=flowchem.port, log_level="info", timeout_keep_alive=3600, ) diff --git a/src/flowchem/components/device_info.py b/src/flowchem/components/device_info.py index 6e0ddb60..44fce8b9 100644 --- a/src/flowchem/components/device_info.py +++ b/src/flowchem/components/device_info.py @@ -1,7 +1,6 @@ -from pydantic import AnyHttpUrl, BaseModel +from pydantic import AnyHttpUrl, BaseModel, NameEmail from flowchem import __version__ -from flowchem.utils.people import Person class DeviceInfo(BaseModel): @@ -13,5 +12,5 @@ class DeviceInfo(BaseModel): serial_number: str | int = "unknown" components: dict[str, AnyHttpUrl] = {} backend: str = f"flowchem v. {__version__}" - authors: list[Person] = [] + authors: list[NameEmail] = [] additional_info: dict = {} diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 982bb3c6..4b39b560 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -36,24 +36,25 @@ def parse_toml(stream: typing.BinaryIO) -> dict: def parse_config(file_path: BytesIO | Path) -> dict: - """Parse a config file.""" - # StringIO used for testing without creating actual files + """Parse a config file and add a `filename` key to the resulting dict w/ its location.""" + # BytesIO used for testing without creating actual files if isinstance(file_path, BytesIO): config = parse_toml(file_path) config["filename"] = "BytesIO" - else: + elif isinstance(file_path, Path): assert file_path.exists(), f"{file_path} exists" assert file_path.is_file(), f"{file_path} is a file" with file_path.open("rb") as stream: config = parse_toml(stream) - config["filename"] = file_path.stem + else: + raise InvalidConfigurationError("Invalid configuration type.") - return instantiate_device(config) + return config -def instantiate_device(config: dict) -> dict: +def instantiate_device_from_config(config: dict) -> list[FlowchemDevice]: """Instantiate all devices defined in the provided config dict.""" assert "device" in config, "The configuration file must include a device section" @@ -62,13 +63,10 @@ def instantiate_device(config: dict) -> dict: device_mapper = autodiscover_device_classes() # Iterate on all devices, parse device-specific settings and instantiate the relevant objects - config["device"] = [ + return [ parse_device(dev_settings, device_mapper) for dev_settings in config["device"].items() ] - logger.info("Configuration parsed") - - return config def ensure_device_name_is_valid(device_name: str) -> None: @@ -89,6 +87,10 @@ def ensure_device_name_is_valid(device_name: str) -> None: def parse_device(dev_settings: dict, device_object_mapper: dict) -> FlowchemDevice: """Parse device config and return a device object. + The device type (i.e. domain) is searched in the known classes (and known plugins, even if uninstalled). + The device is instantiated via its `from_config` method or `init` if from_config is missing. + The device object is returned. + Exception handling to provide more specific and diagnostic messages upon errors in the configuration file. """ device_name, device_config = dev_settings diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index 08fa8b72..417f85b8 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -25,11 +25,12 @@ def __init__( "url": "https://opensource.org/licenses/MIT", }, ) - self.host = host - self.port = port + self.base_url = rf"http://{host}:{port}" self._add_root_redirect() + logger.debug("HTTP ASGI server app created") + def _add_root_redirect(self) -> None: @self.app.route("/") def home_redirect_to_docs(request): @@ -49,9 +50,8 @@ async def my_task(): def add_device(self, device): """Add device to server.""" # Add components URL to device_info - base_url = rf"http://{self.host}:{self.port}/{device.name}" components_w_url = { - component.name: f"{base_url}/{component.name}" + component.name: f"{self.base_url}/{component.name}" for component in device.components } device.device_info.components = components_w_url diff --git a/src/flowchem/server/zeroconf_server.py b/src/flowchem/server/zeroconf_server.py index 9a8d337b..c0cb8694 100644 --- a/src/flowchem/server/zeroconf_server.py +++ b/src/flowchem/server/zeroconf_server.py @@ -29,6 +29,8 @@ def __init__(self, port: int = 8000) -> None: if not self.mdns_addresses: self.mdns_addresses.append("127.0.0.1") + logger.info(f"Zeroconf server up, broadcasting on IPs: {self.mdns_addresses}") + async def add_device(self, name: str) -> None: """Add device to the server.""" properties = { @@ -58,4 +60,5 @@ async def add_device(self, name: str) -> None: if __name__ == "__main__": test = ZeroconfServer() + print("Press Enter to exit.") input() diff --git a/src/flowchem/utils/people.py b/src/flowchem/utils/people.py index ab378732..62825b48 100644 --- a/src/flowchem/utils/people.py +++ b/src/flowchem/utils/people.py @@ -1,13 +1,8 @@ -from pydantic import BaseModel +from pydantic import NameEmail -__all__ = ["Person", "dario", "jakob", "wei_hsin"] +__all__ = ["dario", "jakob", "wei_hsin"] -class Person(BaseModel): - name: str - email: str - - -dario = Person(name="Dario Cambiè", email="2422614+dcambie@users.noreply.github.com") -jakob = Person(name="Jakob Wolf", email="Jakob.Wolf@mpikg.mpg.de") -wei_hsin = Person(name="Wei-Hsin Hsu", email="Wei-hsin.Hsu@mpikg.mpg.de") +dario = NameEmail(name="Dario Cambiè", email="2422614+dcambie@users.noreply.github.com") +jakob = NameEmail(name="Jakob Wolf", email="Jakob.Wolf@mpikg.mpg.de") +wei_hsin = NameEmail(name="Wei-Hsin Hsu", email="Wei-hsin.Hsu@mpikg.mpg.de") From cf0845cadaac17eae451f67b9d45f7ebc5cc7733 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Tue, 29 Aug 2023 16:19:45 +0200 Subject: [PATCH 52/62] Update pydantic requirment --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 31709f78..b2ca548a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "lxml>=4.9.2", "packaging>=23.1", "pint>=0.16.1,!=0.21", # See hgrecco/pint#1642 - "pydantic>=2.0.2", + "pydantic[email]>=2.0.2", "pyserial>=3", "rich_click>=1.6.1", 'tomli; python_version<"3.11"', From abe82f609f3e4dc17b53357a66afc6860cf57446 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 30 Aug 2023 13:32:47 +0200 Subject: [PATCH 53/62] bump readthedocs py version --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 4e54bfe0..82655a6f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,7 +7,7 @@ version: 2 build: os: ubuntu-20.04 tools: - python: "3.10" + python: "3.11" sphinx: configuration: docs/conf.py From d94a4f7201ffd0a1009db4edeb4c83cd14123850 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 30 Aug 2023 13:33:05 +0200 Subject: [PATCH 54/62] -examples --- examples/nmr/devices.toml | 15 --------- examples/reaction_optimization/plot/plot.py | 34 --------------------- 2 files changed, 49 deletions(-) delete mode 100644 examples/nmr/devices.toml delete mode 100644 examples/reaction_optimization/plot/plot.py diff --git a/examples/nmr/devices.toml b/examples/nmr/devices.toml deleted file mode 100644 index b0c52c62..00000000 --- a/examples/nmr/devices.toml +++ /dev/null @@ -1,15 +0,0 @@ - - - -[device.pump-b90e33] -type = "FakeDevice" -#ip_address = "192.168.1.119" # MAC address during discovery: 00:80:a3:b9:0e:33 -# max_pressure = "XX bar" -# min_pressure = "XX bar" -# -#[device.my-benchtop-nmr] # This is the valve identifier -#type = "Spinsolve" -#host = "127.0.0.1" # IP address of the PC running Spinsolve, 127.0.0.1 for local machine. Only necessary parameter. -#port = 13000 -#sample_name = "automated-experiment" -#solvent = "chloroform-d" diff --git a/examples/reaction_optimization/plot/plot.py b/examples/reaction_optimization/plot/plot.py deleted file mode 100644 index 5303ae21..00000000 --- a/examples/reaction_optimization/plot/plot.py +++ /dev/null @@ -1,34 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd - -# Time 2.5, 5.0, 7.5, 10, 12.5, 15 -time = np.linspace(25, 150, 6) -time = time / 10 - -# Temp 50, 60, 70, 80, 90 -temp = np.linspace(50, 90, 5) - -# Data -df = pd.DataFrame.from_dict( - { - "2.5": [0, 1, 2, 4, 8], - "5": [2, 4, 8, 16, 32], - "7.5": [4, 1, 2, 4, 8], - "10": [6, 1, 2, 4, 8], - "12.5": [8, 20, 50, 70, 90], - "15": [20, 50, 70, 90, 100], - }, -) -df.index.name = "time" -df.columns.name = "temp" - -with plt.xkcd(): - fig, ax = plt.subplots() - plt.pcolormesh(time, temp, np.array(df)) - ax.set_title("Fake data :D") - ax.set_xlabel("Time (min)") - ax.set_ylabel("Temp (C)") - ax.set_xticks([2.5, 5, 7.5, 10, 12.5, 15]) - fig.tight_layout() - plt.show() From 7fda350534e9dcd84dd34ef9fc7ff8d6ab42bb30 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Wed, 30 Aug 2023 13:34:54 +0200 Subject: [PATCH 55/62] Simplify pyproject.toml --- .github/workflows/python-app.yml | 2 +- pyproject.toml | 35 +++++++++++--------------------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 75636b80..59a1f68d 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -35,7 +35,7 @@ jobs: - name: Install flowchem run: | - python -m pip install .[dev,types,test] + python -m pip install .[ci] pip freeze - name: Run mypy diff --git a/pyproject.toml b/pyproject.toml index b2ca548a..459879f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "pint>=0.16.1,!=0.21", # See hgrecco/pint#1642 "pydantic[email]>=2.0.2", "pyserial>=3", + "requests", "rich_click>=1.6.1", 'tomli; python_version<"3.11"', "uvicorn>=0.19.0", @@ -41,12 +42,18 @@ dependencies = [ ] [project.optional-dependencies] +all = ["flowchem[dev,test,phidget,docs]"] +ci = ["flowchem[dev,test,docs]"] dev = [ "black", + "data-science-types", "lxml-stubs", "mypy", "pre-commit", "ruff>=0.0.252", + "types-lxml", + "types-PyYAML", + "types-requests", ] test = [ "flowchem-test>=0.1a3", @@ -56,14 +63,10 @@ test = [ "pytest-cov", "pytest-mock", "pytest-xprocess", - "requests", ] phidget = [ "phidget22>=1.7.20211005", ] -plot = [ - "matplotlib>=3.5.0", -] docs = [ "furo", "mistune==0.8.4", # Due to sphinx-contrib/openapi#121 @@ -74,13 +77,6 @@ docs = [ "sphinx-rtd-theme", "sphinxcontrib-openapi", ] -types = [ - "data-science-types", - "types-lxml", - "types-PyYAML", - "types-requests", - -] [project.urls] homepage = "https://github.com/cambiegroup/flowchem" @@ -93,24 +89,18 @@ flowchem-autodiscover = "flowchem.utils.device_finder:main" [tool.setuptools] package-dir = {"" = "src"} - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.setuptools.package-data] -flowchem = ["py.typed"] +packages.find.where = ["src"] +package-data.flowchem = ["py.typed"] [tool.mypy] ignore_missing_imports = true -python_version = "3.10" +python_version = "3.11" [tool.pytest.ini_options] testpaths = "tests" asyncio_mode = "auto" -# pytest cov is not compatible with the pycharm debugger in tests -# addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump'" +# Note: pytest cov is not compatible with the pycharm debugger in tests addopts = "-m 'not HApump and not Spinsolve and not FlowIR and not KPump' --cov=flowchem --cov-fail-under=40" - markers = [ "HApump: tests requiring a local HA Elite11 connected.", "Spinsolve: tests requiring a connection to Spinsolve.", @@ -121,5 +111,4 @@ markers = [ line-length = 120 # Allow imports relative to the "src" and "test" directories. src = ["src", "test"] -[tool.ruff.per-file-ignores] -"__init__.py" = ["F403"] +per-file-ignores."__init__.py" = ["F403"] From 76fbf7dfd7075ba23a16724f994f76e4b9721989 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 14:57:03 +0200 Subject: [PATCH 56/62] Add missing file --- src/flowchem/server/core.py | 117 ++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/flowchem/server/core.py diff --git a/src/flowchem/server/core.py b/src/flowchem/server/core.py new file mode 100644 index 00000000..04bb6aa8 --- /dev/null +++ b/src/flowchem/server/core.py @@ -0,0 +1,117 @@ +from __future__ import annotations +import asyncio +import enum +import threading +from io import BytesIO +from pathlib import Path +from typing import Any + +from loguru import logger + +from flowchem.server.configuration_parser import ( + instantiate_device_from_config, + parse_config, +) +from flowchem.server.fastapi_server import FastAPIServer +from flowchem.server.zeroconf_server import ZeroconfServer + + +class _Flowchem(threading.local): + """Container which makes a Flowchem instance available to the event loop.""" + + fc: Flowchem | None = None + + +# Essentially a global pointer to flowchem as thread local +_fc = _Flowchem() + + +class CoreState(enum.Enum): + """Represent the current state of Flowchem.""" + + not_running = "NOT_RUNNING" + starting = "STARTING" + running = "RUNNING" + stopping = "STOPPING" + final_write = "FINAL_WRITE" + stopped = "STOPPED" + + def __str__(self) -> str: + """Return the event.""" + return self.value + + +class Flowchem: + def __new__(cls) -> Flowchem: + """Set the _fc thread local data.""" + fc = super().__new__(cls) + _fc.fc = fc + return fc + + def __init__(self): + self.loop = asyncio.get_running_loop() + self._tasks: set[asyncio.Future[Any]] = set() + self.config: dict[str, Any] = {} + self.devices: dict[str, Any] = {} + self.state: CoreState = CoreState.not_running + self.exit_code: int = 0 + # If not None, use to signal end-of-loop + self._stopped: asyncio.Event | None = None + + # mDNS server (Zeroconf) + self.mdns = ZeroconfServer(self.port) + + # HTTP server (FastAPI) + self.http = FastAPIServer( + self.config.get("filename", ""), + host=self.mdns.mdns_addresses[0], + port=self.port, + ) + + # To be implemented + # self.bus = EventBus(self) + # self.states = StateMachine(self.bus, self.loop) + + @property + def port(self): + return self.config.get("port", 8000) + + async def setup(self, config: BytesIO | Path): + self.config = parse_config(config) + self.devices = instantiate_device_from_config(self.config) + + """Initialize connection to devices and create API endpoints.""" + logger.info("Initializing device connection(s)...") + + # Run `initialize` async method of all hw devices in parallel + await asyncio.gather(*[dev.initialize() for dev in self.devices]) + logger.info("Device(s) connected") + + # Create entities for the configured devices. + for device in self.config["device"]: + # Advertise devices as services via mDNS + await self.mdns.add_device(name=device.name) + # Add device API to HTTP server + self.http.add_device(device) + logger.info("Server component(s) loaded successfully!") + + +if __name__ == "__main__": + import uvicorn + + async def main(): + flowchem = Flowchem() + await flowchem.setup( + BytesIO(b"""[device.test-device]\ntype = "FakeDevice"\n""") + ) + + config = uvicorn.Config( + flowchem.http.app, + port=flowchem.port, + log_level="info", + timeout_keep_alive=3600, + ) + server = uvicorn.Server(config) + await server.serve() + + asyncio.run(main()) From 93b49167f6f34e12f61c292d54f1cdbeddd51d34 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 14:57:53 +0200 Subject: [PATCH 57/62] minor changes --- src/flowchem/devices/knauer/dad.py | 4 ++-- src/flowchem/server/configuration_parser.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flowchem/devices/knauer/dad.py b/src/flowchem/devices/knauer/dad.py index 44150968..6e0e881c 100644 --- a/src/flowchem/devices/knauer/dad.py +++ b/src/flowchem/devices/knauer/dad.py @@ -99,7 +99,7 @@ async def hal(self, state: bool = True) -> str: async def lamp(self, lamp: str, state: bool | str = "REQUEST") -> str: """Turn on or off the lamp, or request lamp state.""" - if type(state) == bool: + if isinstance(state, bool): state = "ON" if state else "OFF" lamp_mapping = {"d2": "_D2", "hal": "_HAL"} @@ -220,7 +220,7 @@ async def bandwidth(self, bw: str | int) -> str | int: """Set bandwidth in the range of 4 to 25 nm read the setting of bandwidth. """ - if type(bw) == int: + if isinstance(bw, int): cmd = self.cmd.BANDWIDTH.format(bandwidth=bw) return await self._send_and_receive(cmd) else: diff --git a/src/flowchem/server/configuration_parser.py b/src/flowchem/server/configuration_parser.py index 4b39b560..274e061e 100644 --- a/src/flowchem/server/configuration_parser.py +++ b/src/flowchem/server/configuration_parser.py @@ -35,7 +35,7 @@ def parse_toml(stream: typing.BinaryIO) -> dict: raise InvalidConfigurationError(msg) from parser_error -def parse_config(file_path: BytesIO | Path) -> dict: +def parse_config(file_path: Path | BytesIO) -> dict: """Parse a config file and add a `filename` key to the resulting dict w/ its location.""" # BytesIO used for testing without creating actual files if isinstance(file_path, BytesIO): From 5190cfe536baf11cb2f60d0b15566e289158865e Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 15:14:30 +0200 Subject: [PATCH 58/62] types --- src/flowchem/__main__.py | 4 ++-- src/flowchem/server/core.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/flowchem/__main__.py b/src/flowchem/__main__.py index 11779b77..c292a8c2 100644 --- a/src/flowchem/__main__.py +++ b/src/flowchem/__main__.py @@ -62,8 +62,8 @@ def main(device_config_file, logfile, host, debug): async def main_loop(): """Main application loop, the event loop is shared between uvicorn and flowchem.""" - flowchem = Flowchem(Path(device_config_file)) - await flowchem.setup() + flowchem = Flowchem() + await flowchem.setup(Path(device_config_file)) config = uvicorn.Config( flowchem.http.app, diff --git a/src/flowchem/server/core.py b/src/flowchem/server/core.py index 04bb6aa8..5a7664ef 100644 --- a/src/flowchem/server/core.py +++ b/src/flowchem/server/core.py @@ -8,6 +8,7 @@ from loguru import logger +from flowchem.devices.flowchem_device import FlowchemDevice from flowchem.server.configuration_parser import ( instantiate_device_from_config, parse_config, @@ -52,7 +53,7 @@ def __init__(self): self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() self.config: dict[str, Any] = {} - self.devices: dict[str, Any] = {} + self.devices: list[FlowchemDevice] = [] self.state: CoreState = CoreState.not_running self.exit_code: int = 0 # If not None, use to signal end-of-loop From f2164e1bae72d300ce4ab26a7899b08ce84b0132 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 15:55:49 +0200 Subject: [PATCH 59/62] typo --- src/flowchem/server/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flowchem/server/core.py b/src/flowchem/server/core.py index 5a7664ef..e3f941ab 100644 --- a/src/flowchem/server/core.py +++ b/src/flowchem/server/core.py @@ -89,7 +89,7 @@ async def setup(self, config: BytesIO | Path): logger.info("Device(s) connected") # Create entities for the configured devices. - for device in self.config["device"]: + for device in self.devices: # Advertise devices as services via mDNS await self.mdns.add_device(name=device.name) # Add device API to HTTP server From 9b10fe2fe90a8281f6f11979eae90b208ee124ff Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 16:14:27 +0200 Subject: [PATCH 60/62] +ruff cache in gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d64973dc..6e2443f4 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,4 @@ dmypy.json .pyre/ /flowchem/devices/Vapourtec/commands.py /src/flowchem/devices/knauer_hplc_nda.py +.ruff_cache From 1085f144cb7b7183f789d05041b7b09cd1639791 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 16:36:03 +0200 Subject: [PATCH 61/62] Update config parser test --- tests/server/test_config_parser.py | 32 +++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/server/test_config_parser.py b/tests/server/test_config_parser.py index d32fc2cf..0d95b0db 100644 --- a/tests/server/test_config_parser.py +++ b/tests/server/test_config_parser.py @@ -4,7 +4,11 @@ import pytest from flowchem_test.fakedevice import FakeDevice -from flowchem.server.configuration_parser import parse_config +from flowchem.server.configuration_parser import ( + parse_config, + ensure_device_name_is_valid, + instantiate_device_from_config, +) from flowchem.utils.exceptions import InvalidConfigurationError @@ -20,11 +24,29 @@ def test_minimal_valid_config(): cfg = parse_config(cfg_txt) assert "filename" in cfg assert "device" in cfg - assert isinstance(cfg["device"].pop(), FakeDevice) -def test_name_too_long(): - cfg_txt = BytesIO(b"""[device.this_name_is_too_long_and_should_be_shorter]""") +def test_device_instantiation(): + cfg_txt = BytesIO( + dedent( + """ + [device.test-device] + type = "FakeDevice" + """, + ).encode("utf-8"), + ) + cfg = parse_config(cfg_txt) + devices = instantiate_device_from_config(cfg) + assert isinstance(devices.pop(), FakeDevice) + + +def test_device_name_too_long(): with pytest.raises(InvalidConfigurationError) as excinfo: - parse_config(cfg_txt) + ensure_device_name_is_valid("this_name_is_too_long_and_should_be_shorter") assert "too long" in str(excinfo.value) + + +def test_device_name_with_dot(): + with pytest.raises(InvalidConfigurationError) as excinfo: + ensure_device_name_is_valid("this.name") + assert "Invalid character" in str(excinfo.value) From 079af4cf679d9dc3234c539c932ffd00af4e70a2 Mon Sep 17 00:00:00 2001 From: Dario Cambie Date: Fri, 1 Sep 2023 16:36:48 +0200 Subject: [PATCH 62/62] Fix components url --- src/flowchem/client/device_client.py | 4 ++-- src/flowchem/server/fastapi_server.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flowchem/client/device_client.py b/src/flowchem/client/device_client.py index ac482830..de982975 100644 --- a/src/flowchem/client/device_client.py +++ b/src/flowchem/client/device_client.py @@ -13,8 +13,8 @@ def __init__(self, url: AnyHttpUrl) -> None: # Log every request and always raise for status self._session = requests.Session() self._session.hooks["response"] = [ - FlowchemDeviceClient.log_responses, FlowchemDeviceClient.raise_for_status, + FlowchemDeviceClient.log_responses, ] # Connect, get device info and populate components @@ -55,4 +55,4 @@ def raise_for_status(resp, *args, **kwargs): # noqa @staticmethod def log_responses(resp, *args, **kwargs): # noqa """Log all the requests sent.""" - logger.debug(f"Reply: {resp.text} on {resp.url}") + logger.debug(f"Reply: '{resp.text}' on {resp.url}") diff --git a/src/flowchem/server/fastapi_server.py b/src/flowchem/server/fastapi_server.py index 417f85b8..d9153d85 100644 --- a/src/flowchem/server/fastapi_server.py +++ b/src/flowchem/server/fastapi_server.py @@ -51,7 +51,7 @@ def add_device(self, device): """Add device to server.""" # Add components URL to device_info components_w_url = { - component.name: f"{self.base_url}/{component.name}" + component.name: f"{self.base_url}/{device.name}/{component.name}" for component in device.components } device.device_info.components = components_w_url