diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 344e8c12dffdfea5ea5e30a511b74e4a251a6b86..9b0dd12c36cc3d21cdfdfb6d32e4aa9aa6f11611 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,6 +9,7 @@ stages: # 2) YAML doesn't support expansion for sequences (`<<: *install_python`) tests: before_script: + # LabJack lib # Debian Docker workaround: see https://labjack.com/comment/5570#comment-5570 - apt-get update - apt-get install -y libusb-1.0-0-dev udev @@ -18,6 +19,12 @@ tests: - bash labjack_ljm_installer.run - cd ../../ - rm -rf ljm_docker_test/ + # TiePie lib + - wget -q -O - http://packages.tiepie.com/public.key | apt-key add - + - echo "deb http://packages.tiepie.com/debian stretch non-free" > /etc/apt/sources.list.d/packages.tiepie.com.list + - apt-get update + - apt-get install -y libtiepie + # Python - pip install -U pip setuptools - pip install tox script: @@ -26,6 +33,7 @@ tests: style: before_script: + # Python - pip install -U pip setuptools - pip install tox script: diff --git a/AUTHORS.rst b/AUTHORS.rst index 45a26371a7b3d281329215afadf15f0f76751cf9..f3f69a4e887400bfe64b954bc026bcbc0561f6dd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -13,3 +13,4 @@ Contributors * Henrik Menne * Alise Chachereau +* Luca Nembrini diff --git a/Makefile b/Makefile index e09bc8591e85662f2296e717a4824a3f48acbd3d..203c4d639a998820569a27bae45a10be7a568a2e 100644 --- a/Makefile +++ b/Makefile @@ -82,11 +82,11 @@ coverage: ## check code coverage quickly with the default Python coverage html $(BROWSER) htmlcov/index.html -coverage_crylas: ## check code coverage quickly for CryLas module - coverage run --source hvl_ccb -m pytest tests/test_dev_crylas.py +coverage_base_dev: ## check code coverage quickly for dev base module + coverage run --source hvl_ccb -m pytest tests/test_dev_base.py coverage report -m coverage html - $(BROWSER) htmlcov/hvl_ccb_dev_crylas_py.html + $(BROWSER) htmlcov/hvl_ccb_dev_base_py.html docs_api: ## generate Sphinx API docs rm -f docs/hvl_ccb*.rst diff --git a/README.rst b/README.rst index b04e2b2d20edd92c69c493f8db6c3da7078fee03..40b846419da15f9a3e933f94346e6bbdc24e4b90 100644 --- a/README.rst +++ b/README.rst @@ -50,7 +50,9 @@ the following devices: * a Elektro-Automatik PSI9000 DC power supply using VISA over TCP for communication * a Rhode & Schwarz RTO 1024 oscilloscope using VISA interface over :code:`TCP::INSTR` * a state-of-the-art HVL in-house Supercube device variants using an OPC UA client -* a Heinzinger Digital Interface I/II and a Heinzinger PNC power supply over a serial connection +* a Heinzinger Digital Interface I/II and a Heinzinger PNC power supply over a serial + connection +* a TiePie USB oscilloscope as a wrapper of the LibTiePie SDK Python bindings Credits diff --git a/examples/tiepie_example.py b/examples/tiepie_example.py new file mode 100644 index 0000000000000000000000000000000000000000..4a71c5dee28b8791528e792ba90acea69af68c8b --- /dev/null +++ b/examples/tiepie_example.py @@ -0,0 +1,70 @@ +import logging + +from hvl_ccb.comm import NullCommunicationProtocol +from hvl_ccb.dev.tiepie import TiePieOscilloscope + +# configure logger +logging.basicConfig(level=logging.INFO) + +# generate configuration dict +config = { + "serial_number": 33614, +} + +# create connection +comm = NullCommunicationProtocol({}) + +# create device +tp = TiePieOscilloscope({}, config) + +# start device, this also opens the port +tp.start() + +# list the attached devices (can be more than one in one single TiePie) +tp.list_devices() + +# Set all channels enabled +tp.enable_all_channels() + +# Set general oscilloscope configuration +tp_config = TiePieOscilloscope.SetScopeConfigGroup( + sample_frequency=1e7, + record_length=10000, + pre_sample_ratio=0, + range=8, + coupling=TiePieOscilloscope.ChannelCoupling.ACV, + resolution=16, +) +tp.set_scope_config(tp_config) + +# Set configuration for each channel +channel_common_config = { + "trigger_hysteresis": 0.05, + "range": 10, + "trigger_kind": TiePieOscilloscope.TriggerKind.Any, + "trigger_levels": 0.5, + "trigger_timeout": 10, + "coupling": TiePieOscilloscope.ChannelCoupling.ACV, + "probe_offset": 2, +} +channel_configs = [] +for i in range(4): + channel_configs[i] = TiePieOscilloscope.SetChannelConfigGroup( + channel=i + 1, **channel_common_config, + ) + +for channel_config in channel_configs: + tp.set_channel_config(channel_config) + +# Let the oscilloscope gather data +data = tp.measure() + +data_array = tp.data_to_array(data) + +# Write data to a csv file named "Example" +tp.write_to_csv("Example", data) + +# Write data to a xlsx file named "Example" +tp.write_to_xl( + "Example", data, scope_config=tp_config, channels_configs=channel_configs +) diff --git a/hvl_ccb/comm/__init__.py b/hvl_ccb/comm/__init__.py index 131a6d7fec6cbee16caccfe7a295de5a8da4477e..b659b3e1d2cd6b509ec40f594104b6581f9fd834 100644 --- a/hvl_ccb/comm/__init__.py +++ b/hvl_ccb/comm/__init__.py @@ -2,7 +2,10 @@ # """Communication protocols subpackage.""" -from .base import CommunicationProtocol # noqa: F401 +from .base import ( # noqa: F401 + CommunicationProtocol, + NullCommunicationProtocol, +) from .labjack_ljm import ( # noqa: F401 LJMCommunication, LJMCommunicationConfig, diff --git a/hvl_ccb/comm/base.py b/hvl_ccb/comm/base.py index c6dd89a974c78e68a666201d28a7bdde1b887c76..a18b4db62a4481251991d7c040f68b447cda2e15 100644 --- a/hvl_ccb/comm/base.py +++ b/hvl_ccb/comm/base.py @@ -4,8 +4,9 @@ Module with base classes for communication protocols. from abc import ABC, abstractmethod from threading import Lock +from typing import Type -from ..configuration import ConfigurationMixin +from ..configuration import ConfigurationMixin, EmptyConfig class CommunicationProtocol(ConfigurationMixin, ABC): @@ -19,7 +20,7 @@ class CommunicationProtocol(ConfigurationMixin, ABC): def __init__(self, config) -> None: """ Constructor for CommunicationProtocol. Takes a configuration dict or - configdataclass as single parameter. + configdataclass as the single parameter. :param config: Configdataclass or dictionary to be used with the default config dataclass. @@ -36,14 +37,12 @@ class CommunicationProtocol(ConfigurationMixin, ABC): """ Open communication protocol """ - pass # pragma: no cover @abstractmethod def close(self): """ Close the communication protocol """ - pass # pragma: no cover def __enter__(self): self.open() @@ -51,3 +50,28 @@ class CommunicationProtocol(ConfigurationMixin, ABC): def __exit__(self, exc_type, exc_val, exc_tb): self.close() + + +class NullCommunicationProtocol(CommunicationProtocol): + """ + Communication protocol that does nothing. + """ + + def open(self) -> None: + """ + Does nothing. + """ + + def close(self) -> None: + """ + Does nothing. + """ + + @staticmethod + def config_cls() -> Type[EmptyConfig]: + """ + Empty configuration + + :return: EmptyConfig + """ + return EmptyConfig diff --git a/hvl_ccb/comm/visa.py b/hvl_ccb/comm/visa.py index e339eb2228dc8ad69af5dbd2b45c95bb06c994f6..77388a522c14f894d4e91b265831539825609e0c 100644 --- a/hvl_ccb/comm/visa.py +++ b/hvl_ccb/comm/visa.py @@ -28,7 +28,6 @@ class VisaCommunicationError(Exception): """ Base class for VisaCommunication errors. """ - pass @configdataclass diff --git a/hvl_ccb/configuration.py b/hvl_ccb/configuration.py index 47ea4133ce7ded123d78236c292c67171f8968d2..882d0fc3e075cc24a6d5a87485fbae3e3f1b6917 100644 --- a/hvl_ccb/configuration.py +++ b/hvl_ccb/configuration.py @@ -21,7 +21,6 @@ def _clean_values(self): Cleans and enforces configuration values. Does nothing by default, but may be overridden to add custom configuration value checks. """ - pass _configclass_hooks = { @@ -186,7 +185,6 @@ class ConfigurationMixin(ABC): :return: a reference to the default configdataclass class """ - pass # pragma: no cover @property def config(self): @@ -243,3 +241,10 @@ class ConfigurationMixin(ABC): with open(path, 'w') as fp: json.dump(configuration, fp, indent=4) + + +@configdataclass +class EmptyConfig: + """ + Empty configuration dataclass. + """ diff --git a/hvl_ccb/dev/__init__.py b/hvl_ccb/dev/__init__.py index 17fda4fcd03ec47b2652230a22d4c1e926569dc0..a252ea9b1158f1c853167b0e46dd90266dfdee55 100644 --- a/hvl_ccb/dev/__init__.py +++ b/hvl_ccb/dev/__init__.py @@ -2,11 +2,13 @@ # """Devices subpackage.""" +import sys + from .base import ( # noqa: F401 Device, - SingleCommDevice, - DeviceSequenceMixin, DeviceExistingException, + DeviceSequenceMixin, + SingleCommDevice, ) from .ea_psi9000 import ( # noqa: F401 PSI9000, @@ -60,3 +62,12 @@ from .visa import ( # noqa: F401 VisaDevice, VisaDeviceConfig, ) + +if sys.platform == 'darwin': + import warnings + warnings.warn("libtiepie is not available for Darwin OSs") +else: + from .tiepie import ( # noqa: F401 + TiePieOscilloscope, + TiePieError, + ) diff --git a/hvl_ccb/dev/base.py b/hvl_ccb/dev/base.py index faccacb11d2b8acc7286b3de8019c26148295312..ed86497b7e45d2432ce0ca1262615b7346b757aa 100644 --- a/hvl_ccb/dev/base.py +++ b/hvl_ccb/dev/base.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from typing import Dict, Type, List, Tuple, Union from ..comm import CommunicationProtocol -from ..configuration import ConfigurationMixin, configdataclass +from ..configuration import ConfigurationMixin, EmptyConfig class DeviceExistingException(Exception): @@ -14,16 +14,6 @@ class DeviceExistingException(Exception): Exception to indicate that a device with that name already exists. """ - pass - - -@configdataclass -class EmptyConfig: - """ - Empty configuration dataclass that is the default configuration for a Device. - """ - pass - class Device(ConfigurationMixin, ABC): """ @@ -46,16 +36,12 @@ class Device(ConfigurationMixin, ABC): Start or restart this Device. To be implemented in the subclass. """ - pass # pragma: no cover - @abstractmethod def stop(self) -> None: """ Stop this Device. To be implemented in the subclass. """ - pass # pragma: no cover - def __enter__(self): self.start() return self @@ -210,7 +196,6 @@ class SingleCommDevice(Device, ABC): :return: the type of the standard communication protocol for this device """ - pass # pragma: no cover @property def com(self): diff --git a/hvl_ccb/dev/ea_psi9000.py b/hvl_ccb/dev/ea_psi9000.py index 0a6e0db22d20f12bf0af0a1ceb357b570fc10772..baf89d178f4a10de3ad153b4a991b667a7006d6f 100644 --- a/hvl_ccb/dev/ea_psi9000.py +++ b/hvl_ccb/dev/ea_psi9000.py @@ -22,7 +22,6 @@ class PSI9000Error(Exception): """ Base error class regarding problems with the PSI 9000 supply. """ - pass @configdataclass diff --git a/hvl_ccb/dev/mbw973.py b/hvl_ccb/dev/mbw973.py index bb5e4b46099ed6bb399e2e9b8167bbff5e8f0fb3..a235fc0f4f76577671424ca48b8de0e2a69b1a7a 100644 --- a/hvl_ccb/dev/mbw973.py +++ b/hvl_ccb/dev/mbw973.py @@ -19,7 +19,6 @@ class MBW973Error(Exception): """ General error with the MBW973 dew point mirror device. """ - pass class MBW973ControlRunningException(MBW973Error): @@ -27,7 +26,6 @@ class MBW973ControlRunningException(MBW973Error): Error indicating there is still a measurement running, and a new one cannot be started. """ - pass class MBW973PumpRunningException(MBW973Error): @@ -35,7 +33,6 @@ class MBW973PumpRunningException(MBW973Error): Error indicating the pump of the dew point mirror is still recovering gas, unable to start a new measurement. """ - pass @configdataclass diff --git a/hvl_ccb/dev/se_ils2t.py b/hvl_ccb/dev/se_ils2t.py index 79cd8c04fbd30ea4720307b19dfbfaf3d9148172..ac307823a6971dc0a9553b4c4f9928be6a96e679 100644 --- a/hvl_ccb/dev/se_ils2t.py +++ b/hvl_ccb/dev/se_ils2t.py @@ -28,24 +28,18 @@ class ILS2TException(Exception): Exception to indicate problems with the SE ILS2T stepper motor. """ - pass - class IoScanningModeValueError(ILS2TException): """ Exception to indicate that the selected IO scanning mode is invalid. """ - pass - class ScalingFactorValueError(ILS2TException): """ Exception to indicate that a scaling factor value is invalid. """ - pass - @configdataclass class ILS2TModbusTcpCommunicationConfig(ModbusTcpCommunicationConfig): diff --git a/hvl_ccb/dev/tiepie.py b/hvl_ccb/dev/tiepie.py new file mode 100644 index 0000000000000000000000000000000000000000..7a0a7d41a2979b1e28887fd4e14aea079aa16deb --- /dev/null +++ b/hvl_ccb/dev/tiepie.py @@ -0,0 +1,917 @@ +# Copyright (c) 2019 ETH Zurich, SIS ID and HVL D-ITET +# +""" +This module is a wrapper around libtiepie Oscilloscope devices; see +https://www.tiepie.com/en/libtiepie-sdk . + +To install libtiepie: + +1. install the libtiepie + + For Windows download the library from + https://www.tiepie.com/en/libtiepie-sdk/windows. If using the virtualenv copy the + dynamic library file `libtiepie.dll` into your project folder under + `.venv/Scripts/`, where `.venv/` is your virtual environment folder. + +2. install the Python bindings + + $ pip install python-libtiepie + +""" +import csv +import logging +import time +import warnings +from array import array +from functools import wraps +from numbers import Real +from typing import Callable, List, Union + +import libtiepie as ltp +import openpyxl +from libtiepie.exceptions import LibTiePieException +from libtiepie.oscilloscopechannel import OscilloscopeChannel +# OscilloscopeChannel = None +# class Temp: +# DeviceList = None +# Oscilloscope = None +# ltp.devicelist = Temp +# ltp.oscilloscope = Temp +from openpyxl.utils.cell import get_column_letter + +from .base import SingleCommDevice +from ..comm import NullCommunicationProtocol +from ..configuration import configdataclass +from ..utils.enum import NameEnum + + +class TiePieError(Exception): + """ + Error of the class TiePie + """ + + pass + + +def wrap_libtiepie_exception(func: Callable) -> object: + """ + Decorator wrapper for `libtiepie` methods that use + `libtiepie.library.check_last_status_raise_on_error()` calls. + + :param func: Function or method to be wrapped + :raises TiePieError: instead of `LibTiePieException` or one of its subtypes. + :return: whatever `func` returns + """ + + @wraps + def wrapped_func(*args, **kwargs): + try: + return func(*args, **kwargs) + except LibTiePieException as e: + logging.error(str(e)) + raise TiePieError from e + + return wrapped_func + + +@configdataclass +class TiePieConfig: + """ + Configuration dataclass for TiePie + """ + + serial_number: int + require_block_measurement_support: bool = True + + # class DeviceType(NameEnum): + # """ + # TiePie device type. + # """ + # ANY = ltp.DEVICETYPE_ANY + # OSCILLOSCOPE = ltp.DEVICETYPE_OSCILLOSCOPE + # I2C = ltp.DEVICETYPE_I2C + # GENERATOR = ltp.DEVICETYPE_GENERATOR + # + # device_type: (str, DeviceType) = DeviceType.ANY + + +def _is_number_in_range(x, min=None, max=None, number_type=Real) -> bool: + """ + Check if given input is a real number between `min` and `max` (inclusive), if + not `None`. + + :param x: an input object + :param min: lower limit or `None` (default), denoting no limit (negative infinity) + :param max: upper limit or `None` (default), denoting no limit (positive inifinity) + :param number_type: expected type of a number, by default `numbers.Real` + :return: `True` if the input `x` is a number of `number_type` within a given limit + from `min` to `max` (inclusive), `False` otherwise. + """ + return ( + isinstance(x, number_type) + and (min is None or x >= min) + and (max is None or x <= max) + ) + + +class TiePieOscilloscopeChannelCoupling(NameEnum): + _init_ = "value description" + DCV = ltp.CK_DCV, "DC volt" + ACV = ltp.CK_ACV, "AC volt" + DCA = ltp.CK_DCA, "DC current" + ACA = ltp.CK_ACA, "AC current" + + +TiePieOscilloscopeTriggerKind = NameEnum( + value="TiePieOscilloscopeTriggerKind", + names=( + ("Rising", ltp.TK_RISINGEDGE), + ("Falling", ltp.TK_FALLINGEDGE), + ("Any", ltp.TK_ANYEDGE), + ("Rising or Falling", ltp.TK_ANYEDGE), # just for `[]` lookup + ), +) + + +class TiePieOscilloscope(SingleCommDevice): + """ + TiePie oscilloscope device. + + Wrapper for `libtiepie.oscilloscope.Oscilloscope` device but with channel + numbered from 1 to 4 (and not from 0 to 3). + """ + + @staticmethod + def config_cls(): + return TiePieConfig + + @staticmethod + def default_com_cls(): + return NullCommunicationProtocol + + def __init__(self, com, dev_config) -> None: + """ + Constructor for a TiePie device. + """ + super().__init__(com, dev_config) + self._libtiepie_dev = None + self.serial_number = dev_config["serial_number"] + self.channels_enabled = [1, 2, 3, 4] + + def _close(self) -> None: + """ + Close the wrapped `libtiepie` device. + """ + if self._libtiepie_dev is not None: + del self._libtiepie_dev + self._libtiepie_dev = None + + @wrap_libtiepie_exception + def start(self) -> None: + """ + Start the Device. + """ + + logging.info(f"Starting {str(self)}") + super().start() + + ltp.device_list.update() + if not ltp.device_list: + msg = "No devices found to start" + logging.error(msg) + raise TiePieError(msg) + + # If a device is found + handle = ltp.device_list.get_item_by_serial_number(self.serial_number) + if not handle.can_open(ltp.DEVICETYPE_OSCILLOSCOPE): + msg = f"Can't open an oscilloscope with serial number {self.serial_number}." + logging.error(msg) + raise TiePieError(msg) + + self._libtiepie_dev = handle.open_oscilloscope() + # Check for block measurement support if required + if self.config.require_block_measurement_support and not ( + self._libtiepie_dev.measure_modes & ltp.MM_BLOCK + ): + self._close() + msg = ( + f"Oscilloscope with serial number {self.serial_number} does not " + f"have required block measurement support." + ) + logging.error(msg) + raise TiePieError(msg) + + @wrap_libtiepie_exception + def stop(self) -> None: + """ + Stop the Device. + """ + logging.info(f"Stopping {str(self)}") + self._close() + super().stop() + + @wrap_libtiepie_exception + @staticmethod + def list_devices() -> ltp.devicelist.DeviceList: + """ + List available TiePie devices. + + :return: libtiepie up to date list of devices + """ + ltp.device_list.update() + device_list = ltp.device_list + + # log devices list + if device_list: + print() + logging.info("Available devices:\n") + + for item in ltp.device_list: + logging.info(" Name: " + item.name) + logging.info(" Serial number: " + str(item.serial_number)) + logging.info(" Available types: " + ltp.device_type_str(item.types)) + + else: + logging.info("No devices found!") + + return device_list + + @wrap_libtiepie_exception + @staticmethod + def get_device_by_serial_number( + serial_number: int, + ) -> ltp.oscilloscope.Oscilloscope: + """ + Get TiePie oscilloscope with a given serial number + + :param serial_number: int serial number of the oscilloscope + :return: libtiepie Oscilloscope device + """ + ltp.device_list.update() + oscilloscope_device_list = ltp.device_list.get_item_by_serial_number( + serial_number + ) + if oscilloscope_device_list.can_open(ltp.DEVICETYPE_OSCILLOSCOPE): + scp = oscilloscope_device_list.open_oscilloscope() + else: + raise TypeError( + "The device with serial number " + + str(serial_number) + + " has no Oscilloscope function" + ) + return scp + + @wrap_libtiepie_exception + def measure(self) -> List[Union[array, None]]: + """ + Measures using set configuration. + + :return: Measurements in a `list` of `array.array`'s with float sample data + or `None` values if a channel is disabled. The returned `list` has + `self.get_record_length()` length. + """ + self._libtiepie_dev.start() + while not self._libtiepie_dev.is_data_ready: + time.sleep(0.01) # 10 ms delay to save CPU time + return self._libtiepie_dev.get_data() + + def data_to_array(self, data: List[Union[array, None]]) -> List[List[float]]: + """ + Filter data for currently enabled channels only and convert to an easier to use + format. + + :param data: Data as they come from the `self.measure()` function. + :return: Data of enabled channels in a list of lists of floats. + """ + useful_data = [] + for channel in self.channels_enabled: + useful_data.append(data[channel - 1].tolist()) + return useful_data + + def _verify_via_libtiepie( + self, verify_method_suffix: str, value: Union[Real, int] + ) -> Union[Real, int]: + """ + Generic wrapper for `verify_SOMETHING` methods of the `libtiepie` device. + Additionally to returning a value that will be actually set, gives an warning. + + :param verify_method: `libtiepie` devices verify_SOMETHING method + :param value: numeric value + :returns: Value that will be actually set instead of `value`. + :raises TiePieError: when status of underlying device gives an error + """ + verify_method = getattr(self._libtiepie_dev, f"verify_{verify_method_suffix}") + will_have_value = verify_method(value) + if will_have_value != value: + msg = ( + f"Can't set {verify_method_suffix} to " + f"{value}; instead {will_have_value} will be set." + ) + logging.warning(msg) + warnings.warn(msg) + return will_have_value + + @wrap_libtiepie_exception + def set_sample_freq(self, sample_freq: Real) -> None: + """ + Set sample frequency of the oscilloscope. + + :param sample_freq: frequency number to set + """ + act_sample_freq = self._verify_via_libtiepie("sample_frequency", sample_freq) + self._libtiepie_dev.sample_frequency = act_sample_freq + + @wrap_libtiepie_exception + def get_sample_freq(self) -> Real: + """ + Get sample frequency of the oscilloscope. + + :return: Sample frequency + """ + return self._libtiepie_dev.sample_frequency + + @wrap_libtiepie_exception + def set_record_length(self, record_length: int) -> None: + """ + Set the total number of measurements recorded. + + :param record_length: record length must be of INT type + """ + act_record_length = self._verify_via_libtiepie("record_length", record_length) + self._libtiepie_dev.record_length = act_record_length + + @wrap_libtiepie_exception + def get_record_length(self) -> int: + """ + Get the total number of measurements to record. + + :return: Total number of measurements to record. + """ + return self._libtiepie_dev.record_length + + @wrap_libtiepie_exception + def set_pre_sample_ratio(self, pre_sample_ratio: Real) -> None: + """ + Set the pre sample ratio. + + :param pre_sample_ratio: pre sample ratio numeric value. + :raise ValueError: If `pre_sample_ratio` is not a number between 0 and 1 + (inclusive). + """ + if not _is_number_in_range(pre_sample_ratio, min=0, max=1): + raise ValueError("pre sample ratio has to be a number between 0 and 1") + self._libtiepie_dev.pre_sample_ratio = pre_sample_ratio + + @wrap_libtiepie_exception + def get_pre_sample_ratio(self) -> Real: + """ + Get the pre sample ratio. + + :return: Pre sample ratio value. + """ + return self._libtiepie_dev.pre_sample_ratio + + @property + def n_channels(self): + """ + Number of channels in the oscilloscope. + + :return: Number of channels. + """ + return len(self._libtiepie_dev.channels) + + def channel_validation(self, channel: int) -> None: + """ + Validate if channel number is correct. + + :param channel: Channel number to validate. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + n_channels = self.n_channels + if not _is_number_in_range(channel, min=1, max=n_channels, number_type=int): + raise ValueError( + f"Channel value needs to be an int between 1 and {n_channels}" + ) + + def _libtiepie_dev_channel(self, channel: int) -> OscilloscopeChannel: + """ + Get channel of the wrapped `libtiepie` oscilloscope device. + + :param channel: Channel number. + :return: `libtiepie` oscilloscope channel object + """ + return self._libtiepie_dev.channels[channel - 1] + + @wrap_libtiepie_exception + def enable_channel(self, channel: int) -> None: + """ + Enable given channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).enabled = True + if channel not in self.channels_enabled: + self.channels_enabled.append(channel) + self.channels_enabled.sort(key=int) + + def enable_all_channels(self) -> None: + """ + Enable all channels. + """ + for channel in range(1, 5): + self.enable_channel(channel) + + @wrap_libtiepie_exception + def disable_channel(self, channel: int) -> None: + """ + Disable given channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).enabled = False + self.channels_enabled.remove(channel) + + def disable_all_channels(self) -> None: + """ + Disable all channels. + """ + # no need to validate, but still track single removals in case an error occurs + # meanwhile + for channel in self.channels_enabled: + self.disable_channel(channel) + + @wrap_libtiepie_exception + def enable_safeground(self, channel: int) -> None: + """ + Set safeground enabled for a specific channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).safe_ground_enabled = True + + def enable_all_safeground(self) -> None: + """ + Set safeground enabled for all channels. + """ + + for i in range(1, self.n_channels): + self.enable_safeground(i) + + @wrap_libtiepie_exception + def disable_safeground(self, channel: int) -> None: + """ + Set safeground disabled for a specific channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).safe_ground_enabled = False + + def disable_all_safeground(self) -> None: + """ + Set safeground disabled for all channels + """ + for i in range(1, self.n_channels): + self.disable_safeground(i) + + @wrap_libtiepie_exception + def set_trigger_enabled(self, channel: int) -> None: + """ + Set trigger enabled for a specific channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).trigger.enabled = True + + def set_trigger_enabled_all(self) -> None: + """ + Set trigger enabled for all channels. + """ + for i in range(1, self.n_channels): + self.set_trigger_enabled(i) + + @wrap_libtiepie_exception + def set_trigger_disabled(self, channel) -> None: + """ + Set trigger disabled for a specific channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).trigger.enabled = False + + def set_trigger_disabled_all(self) -> None: + """ + Set trigger disabled for all channels. + """ + for i in range(1, self.n_channels): + self.set_trigger_disabled(i) + + @wrap_libtiepie_exception + def set_input_range(self, input_range: Real, channel: int) -> None: + """ + Set the range for a specific channel. + + :param input_range: Channel range given in volts. + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + self._libtiepie_dev_channel(channel).range = input_range + + @wrap_libtiepie_exception + def get_input_range(self, channel: int) -> Real: + """ + Get the range for a specific channel. + + :param channel: Channel number. + :return: Channel range in volts. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + return self._libtiepie_dev_channel(channel).range + + @wrap_libtiepie_exception + def set_resolution(self, resolution: int) -> None: + """ + Set resolution of the Oscilloscope. + + :param resolution: resolution integer. + """ + self._libtiepie_dev.resolution = resolution + + @wrap_libtiepie_exception + def get_resolution(self) -> int: + """ + Get current resolution of the Oscilloscope. + + :return: resolution integer. + """ + return self._libtiepie_dev.resolution + + ChannelCoupling = TiePieOscilloscopeChannelCoupling + + @wrap_libtiepie_exception + def set_coupling(self, coupling: Union[str, ChannelCoupling], channel: int) -> None: + """ + Set coupling for specified channel. + + :param coupling: 'DCV' (DC volt), 'ACV' (AC volt), 'DCA'(DC current), + 'ACA' (AC current) + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or coupling string is not a valid coupling specification. + """ + self.channel_validation(channel) + coupling = self.ChannelCoupling(coupling) + self._libtiepie_dev_channel(channel).coupling = coupling.value + + @wrap_libtiepie_exception + def get_coupling(self, channel: int) -> TiePieOscilloscopeChannelCoupling: + """ + Get coupling for specified channel. + + :param channel: Channel number. + :return: Channel coupling specification. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or unsupported libtiepie coupling is set on the channel. + """ + self.channel_validation(channel) + return self.ChannelCoupling(self._libtiepie_dev_channel(channel).coupling) + + @wrap_libtiepie_exception + def set_trigger_time_out(self, trigger_time_out: Real, channel: int) -> None: + """ + Set trigger time-out for a specified channel. + + :param trigger_time_out: Trigger time-out value, in seconds; `0` forces + trigger to start immediately after starting a measurement. + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or trigger time-out is not a non-negative number. + """ + self.channel_validation(channel) + if _is_number_in_range(trigger_time_out, min=0): + raise ValueError("Trigger time-out must be a non-negative number.") + self._libtiepie_dev_channel(channel).trigger_time_out = trigger_time_out + + @wrap_libtiepie_exception + def get_trigger_time_out(self, channel: int) -> Real: + """ + Get trigger timeout for a specified channel. + + :param channel: Channel number. + :return: Trigger time-out for a channel, in seconds. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or trigger time-out is not a non-negative number. + """ + self.channel_validation(channel) + return self._libtiepie_dev_channel(channel).trigger_time_out + + @wrap_libtiepie_exception + def set_trigger_hysteresis( + self, trigger_hysteresis: Real, channel: int + ) -> None: # TO CHECK + """ + Set trigger hysteresis for specified channel. + + :param trigger_hysteresis: Trigger hysteresis value. + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or trigger hysteresis is not a number between 0 and 1 + (inclusive). + """ + self.channel_validation(channel) + if not _is_number_in_range(trigger_hysteresis, min=0, max=1): + raise ValueError("Trigger hysteresis has to be a number between 0 and 1") + self._libtiepie_dev_channel(channel).trigger.hystereses[0] = trigger_hysteresis + + @wrap_libtiepie_exception + def get_trigger_hysteresis(self, channel: int) -> Real: + """ + Get trigger hysteresis for specified channel. + + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + return self._libtiepie_dev_channel(channel).trigger.hystereses[0] + + TriggerKind = TiePieOscilloscopeTriggerKind + + @wrap_libtiepie_exception + def set_trigger_kind( + self, trigger_kind: Union[str, TriggerKind], channel: int + ) -> None: + """ + Set trigger kind for a specified channel. + + :param trigger_kind: `'Rising'`, `'Falling'` or `'Any'`/`'Rising or Falling'`. + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or trigger kind is not one of expected strings. + """ + self.channel_validation(channel) + # note: use `[]` lookup instead of `()` init to handle 'Rising or Falling' + trigger_kind = self.TriggerKind[trigger_kind] + self._libtiepie_dev_channel(channel).trigger.kind = trigger_kind.value + + @wrap_libtiepie_exception + def get_trigger_kind(self, channel: int) -> TiePieOscilloscopeTriggerKind: + """ + Get trigger kind for a specified channel. + + :param channel: Channel number. + :return: `TriggerKind.Rising`, `TriggerKind.Falling` or `TriggerKind.Any` + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + return self.TriggerKind(self._libtiepie_dev_channel(channel).trigger.kind) + + @wrap_libtiepie_exception + def set_trigger_levels(self, trigger_levels: Real, channel: int) -> None: + """ + Set trigger levels for a specified channel. + + :param trigger_levels: Trigger levels value. + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or trigger hysteresis is not a number between 0 and 1 + (inclusive). + """ + self.channel_validation(channel) + if not _is_number_in_range(trigger_levels, min=0, max=1): + raise ValueError("Trigger level has to be a number between 0 and 1") + self._libtiepie_dev_channel(channel).trigger.levels[0] = trigger_levels + + @wrap_libtiepie_exception + def get_trigger_levels(self, channel: int) -> Real: + """ + Get trigger levels for specified channel + + :param channel: Channel number. + :return: Trigger levels value. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + return self._libtiepie_dev_channel(channel).trigger.levels[0] + + @wrap_libtiepie_exception + def set_probe_offset(self, probe_offset: Real, channel: int) -> None: + """ + Set probe offset for the specified channel. + + :param probe_offset: Probe offset. + :param channel: Channel number. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive) or probe offset is not a number between -1e6 and 1e6 + (inclusive). + """ + self.channel_validation(channel) + # Limits taken from: + # http://api.tiepie.com/libtiepie/0.7.4/group__scp__ch__probe.html#ga17fe6e195ea2b82f93140234dd15a0b5 + if not _is_number_in_range(probe_offset, min=-1e6, max=1e6): + raise ValueError("probe offset has to be a number between -1e6 and 1e6") + self._libtiepie_dev_channel(channel).probe_offset = probe_offset + + @wrap_libtiepie_exception + def get_probe_offset(self, channel: int) -> Real: + """ + Get probe offset for the specified channel. + + :param channel: + :param channel: Channel number. + :return: Probe offset value. + :raise ValueError: If channel number is not an integer between 1 and 4 + (inclusive). + """ + self.channel_validation(channel) + return self._libtiepie_dev_channel(channel).probe_offset + + @configdataclass + class SetScopeConfigGroup: + """ + Group of oscilloscope's settings set together. + """ + + sample_frequency: Real + record_length: int + pre_sample_ratio: Real + resolution: int + + # Note: no need for `clean_values()`; validation in specific setters + + def set_scope_config(self, scope_config: Union[dict, SetScopeConfigGroup]) -> None: + """ + Set the general oscilloscope settings in bulk. + + :param scope_config: `SetScopeConfigGroup` instance or a dictionary containing + at least the following keys: `SetScopeConfigGroup.required_keys()`. + """ + + if isinstance(scope_config, dict): + # for backward compatibility, allow strings with spaces when dict used + new_scope_config = dict() + for (key, value) in scope_config.items(): + new_scope_config[key.replace(" ", "_").lower()] = value + scope_config = self.SetScopeConfigGroup(**new_scope_config) + assert isinstance(scope_config, self.SetScopeConfigGroup) + self.set_sample_freq(scope_config.sample_frequency) + self.set_record_length(scope_config.record_length) + self.set_pre_sample_ratio(scope_config.pre_sample_ratio) + self.set_resolution(scope_config.resolution) + + @configdataclass + class SetChannelConfigGroup: + channel: int + trigger_timeout: Real + trigger_hysteresis: Real + trigger_levels: Real + range: Real + coupling: (str, TiePieOscilloscopeChannelCoupling) + set_probe_offset: Real + + # Note: no need for `clean_values()`; validation in specific setters + + def set_channel_config( + self, channel_config: Union[dict, SetChannelConfigGroup] + ) -> None: + """ + Set the oscilloscope channel settings in bulk. + + :param channel_config: `SetChannelConfigGroup` instance or a dictionary + containing at least the following keys: + `SetChannelConfigGroup.required_keys()`. + """ + if isinstance(channel_config, dict): + # for backward compatibility, allow strings with spaces when dict used + new_channel_config = dict() + for (key, value) in channel_config.items(): + new_channel_config[key.replace(" ", "_").lower()] = value + channel_config = self.SetChannelConfigGroup(**new_channel_config) + + self.set_trigger_time_out( + channel_config.trigger_timeout, channel_config.channel + ) + self.set_trigger_hysteresis( + channel_config.trigger_hysteresis, channel_config.channel + ) + self.set_trigger_levels(channel_config.trigger_levels, channel_config.channel) + self.set_input_range(channel_config.range, channel_config.channel) + self.set_coupling(channel_config.coupling, channel_config.channel) + self.set_probe_offset(channel_config.probe_offset, channel_config.channel) + + def write_to_csv( + self, filename: str, data: List[Union[array, List[float]]] + ) -> None: + """ + Create a csv file with the measured data. + + :param filename: The base name of the file to write to, i.e. without extensions + :param data: Data measured by the TiePie oscilloscope as returned by + `self.measure()` or `self.data_to_array()`. + """ + with open(filename + ".csv", "w", newline="") as csv_file: + writer = csv.writer(csv_file) + writer.writerow( + ["Sample"] + [f"Ch{channel!s}" for channel in self.channels_enabled] + ) + for i in range(len(data[self.channels_enabled[0] - 1])): + writer.writerow( + [i] + [data[channel - 1][i] for channel in self.channels_enabled] + ) + logging.info("Data written to: " + csv_file.name) + + def write_to_xl( + self, + filename: str, + data: list, + scope_config: Union[dict, None] = None, + channels_configs: Union[List[dict], None] = None, + ) -> None: + """ + Write the data to an xlsx file. + + :param filename: The base name of the file to write to, i.e. without extensions. + :param data: Data measured by the TiePie oscilloscope as returned by + `self.measure()` or `self.data_to_array()`. + :param scope_config: If given, written in the excel file as the oscilloscope + settings used to collect the data. + :param channels_configs: A list of `dict` containing the configuration for each + channels used. If given, written in the excel file as the + channels settings used to collect the data. + :return: + """ + wb = openpyxl.Workbook() + + sheet = wb.active + sheet.cell(row=1, column=1, value="Sample") + k = 2 + for channel in self.channels_enabled: + sheet.cell(1, k, "CH" + str(channel)) + for j in range(len(data[self.channels_enabled[0] - 1])): + sheet.cell(2 + j, 1, j + 1) + sheet.cell(2 + j, k, data[channel - 1][j]) + k = k + 1 + nr_data_col = len(self.channels_enabled) + 2 # inclusive a white column + if scope_config is not None: + + sheet.cell(1, nr_data_col + 2, "Scope configuration:") + sheet.column_dimensions[get_column_letter(nr_data_col + 2)].width = 18.3 + i = 2 + for param in scope_config: + sheet.column_dimensions[get_column_letter(nr_data_col + 1)].width = 17 + sheet.cell(i, nr_data_col + 1, param + ":") + sheet.cell(i, nr_data_col + 2, scope_config[param]) + i = i + 1 + nr_data_col = nr_data_col + 3 + if channels_configs is not None: + for ch_config in channels_configs: + sheet.column_dimensions[get_column_letter(nr_data_col + 2)].width = 17 + sheet.column_dimensions[get_column_letter(nr_data_col + 1)].width = 17 + sheet.cell( + 1, + nr_data_col + 2, + "CH" + str(ch_config["Channel"]) + " configuration:", + ) + i = 2 + for param in ch_config: + sheet.cell(i, nr_data_col + 1, param + ":") + sheet.cell(i, nr_data_col + 2, ch_config[param]) + i = i + 1 + nr_data_col = nr_data_col + 3 + wb.save(filename + ".xlsx") + logging.info("Data written to: " + filename + ".xlsx") + + # class Generator: + # """ + # TO IMPLEMENT + # """ + # pass + # + # class I2CHost: + # """ + # TO IMPLEMENT + # """ + # pass diff --git a/hvl_ccb/dev/visa.py b/hvl_ccb/dev/visa.py index a0b8b0c1f208e274f15bd3b2a3c1114cdcb79e78..e2fbe116d44f58dc5e314fe4d61f2e64a33a9431 100644 --- a/hvl_ccb/dev/visa.py +++ b/hvl_ccb/dev/visa.py @@ -72,7 +72,6 @@ class _VisaDeviceConfigBase: # NOTE: this class is unnecessary as there are no keys here; it's coded here only # to illustrate a solution; for detailed explanations of the issue see: # https://stackoverflow.com/questions/51575931/class-inheritance-in-python-3-7-dataclasses/ - pass @configdataclass @@ -101,7 +100,6 @@ class VisaDeviceConfig(_VisaDeviceConfigDefaultsBase, _VisaDeviceConfigBase): """ Configdataclass for a VISA device. """ - pass class VisaDevice(SingleCommDevice): diff --git a/hvl_ccb/experiment_manager.py b/hvl_ccb/experiment_manager.py index ef40e732dca817c0e472f252b57ef6727f36cf64..f252ba05af44f63bdf0e9c64d9a899fbe670a781 100644 --- a/hvl_ccb/experiment_manager.py +++ b/hvl_ccb/experiment_manager.py @@ -32,8 +32,6 @@ class ExperimentError(Exception): ERROR and thus no operations can be made until reset. """ - pass - class ExperimentManager(DeviceSequenceMixin): """ diff --git a/hvl_ccb/utils/enum.py b/hvl_ccb/utils/enum.py index 65539a1dd799df8051e55878ce74fc541f4a1a4a..86f3e9310c7b0f4425cc50a2debb9b313dc8c553 100644 --- a/hvl_ccb/utils/enum.py +++ b/hvl_ccb/utils/enum.py @@ -48,15 +48,18 @@ class NameEnum(StrEnumBase): this representation. """ + # convenience: enables `[]` name-based lookup with enum instances themselves + def __hash__(self): + return hash(str(self)) + def __str__(self): return self.name -class AutoNumberNameEnum(StrEnumBase, aenum.AutoNumberEnum): +class AutoNumberNameEnum(NameEnum, aenum.AutoNumberEnum): """ Auto-numbered enum with names used as string representation, and with lookup and equality based on this representation. """ - def __str__(self): - return self.name + pass diff --git a/setup.py b/setup.py index 17db6b7073649cb3b87c4ddee8a707a4f6015c40..3c47598586df803cc92e27d127c6ab2f232e7737 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,8 @@ requirements = [ 'aenum>=2.1.2', 'opcua>=0.98.6', 'cryptography>=2.6.1', # optional dependency of the opcua package + 'python-libtiepie>=0.7', + 'openpyxl>=2.6.2', ] dependency_links = [ diff --git a/tests/test_comm_base.py b/tests/test_comm_base.py index 0f92bf69ac3508fde2d62cfca0ed5bc8261c246d..fd62a63504c3e10e22748b6eda0350e61e32dd9f 100644 --- a/tests/test_comm_base.py +++ b/tests/test_comm_base.py @@ -1,28 +1,22 @@ +# Copyright (c) 2019 ETH Zurich, SIS ID and HVL D-ITET +# + """ Tests for the base class CommunicationProtocol. """ -from hvl_ccb.comm import CommunicationProtocol -from hvl_ccb.configuration import configdataclass - - -@configdataclass -class EmptyConfig: - pass - +import pytest -class DummyCommmunicationProtocol(CommunicationProtocol): - @staticmethod - def config_cls(): - return EmptyConfig +from hvl_ccb.comm import NullCommunicationProtocol +from hvl_ccb.configuration import EmptyConfig - def open(self): - pass - def close(self): - pass +def test_instantiation(): + for arg in (EmptyConfig(), {}, None): + with NullCommunicationProtocol(arg) as com: + assert com is not None + assert isinstance(com.config, EmptyConfig) -def test_communication_protocol(): - with DummyCommmunicationProtocol({}) as c: - assert c is not None + with pytest.raises(TypeError): + NullCommunicationProtocol({'extra_key': 0}) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 5807e7ff88c7f65dad4fe8847436d88a0b0b779a..5231d30f563e026975d7f384d96213d92bf4cc39 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -7,7 +7,7 @@ from typing import Union import pytest -from hvl_ccb.configuration import ConfigurationMixin, configdataclass +from hvl_ccb.configuration import ConfigurationMixin, configdataclass, EmptyConfig @configdataclass @@ -120,6 +120,12 @@ def test_configdataclass_fromdict(): WrongDefaultConfigDataclass({'field1': 1}) +def test_empty_config(): + config = EmptyConfig() + assert not config.required_keys() + assert not config.keys() + + def test_configuration_mixin(): test_config = MyConfiguration(1) my_class = MyClassHasConfiguration(test_config) diff --git a/tests/test_dev_base.py b/tests/test_dev_base.py index 82469375d32af299a4150439c4f928084433085c..b3fb9daf739f12accf339407eb5137bd7bdd72bc 100644 --- a/tests/test_dev_base.py +++ b/tests/test_dev_base.py @@ -1,16 +1,49 @@ # Copyright (c) 2019 ETH Zurich, SIS ID and HVL D-ITET # - """ Tests for the dev.base module classes. """ + +from typing import Type + import pytest -from hvl_ccb.dev import Device, DeviceSequenceMixin, DeviceExistingException +from hvl_ccb.comm import NullCommunicationProtocol +from hvl_ccb.configuration import EmptyConfig +from hvl_ccb.dev import ( + DeviceSequenceMixin, + DeviceExistingException, + SingleCommDevice, +) + + +class NullDevice(SingleCommDevice): + def __init__(self, com=None, **kwargs): + if com is None: + com = EmptyConfig() + super().__init__(com, **kwargs) + + @staticmethod + def default_com_cls() -> Type[NullCommunicationProtocol]: + return NullCommunicationProtocol + + def start(self): + pass + + def stop(self): + pass + + +def test_null_device(): + dev_config = EmptyConfig() + for arg in (NullCommunicationProtocol({}), EmptyConfig(), {}, None): + dev = NullDevice(arg, dev_config=dev_config) + assert dev is not None + assert isinstance(dev.com.config, EmptyConfig) -# make Device instantiable -Device.__abstractmethods__ = frozenset() + with pytest.raises(TypeError): + NullDevice(None, {"extra_key": 0}) class DeviceSequence(DeviceSequenceMixin): @@ -19,13 +52,13 @@ class DeviceSequence(DeviceSequenceMixin): def test_device_sequence_access(): - dev = Device() + dev = NullDevice() - ddict = {'dev': dev} + ddict = {"dev": dev} dseq = DeviceSequence(ddict) - assert dseq.get_device('dev') is dev + assert dseq.get_device("dev") is dev for name, device in dseq.get_devices(): assert ddict[name] is device @@ -34,25 +67,25 @@ def test_device_sequence_access(): assert dseq == DeviceSequence(ddict) with pytest.raises(ValueError): - dseq.remove_device('not there') + dseq.remove_device("not there") with pytest.raises(DeviceExistingException): - dseq.add_device('dev', dev) + dseq.add_device("dev", dev) - dev2 = Device() - dseq.add_device('dev2', dev2) - assert dseq.get_device('dev2') is dev2 + dev2 = NullDevice() + dseq.add_device("dev2", dev2) + assert dseq.get_device("dev2") is dev2 assert dseq != DeviceSequence(ddict) def test_device_sequence_dot_lookup(): - dev1 = Device() - dev2 = Device() + dev1 = NullDevice() + dev2 = NullDevice() ddict = { - 'dev1': dev1, - 'dev2': dev2, + "dev1": dev1, + "dev2": dev2, } seq = DeviceSequence(ddict) @@ -62,8 +95,8 @@ def test_device_sequence_dot_lookup(): # adding device which name over-shadows attr/method with pytest.raises(ValueError): - DeviceSequence({'dev1': dev1, '_devices': dev2}) + DeviceSequence({"dev1": dev1, "_devices": dev2}) # adding single device which name over-shadows an attr/method with pytest.raises(ValueError): - seq.add_device('start', Device()) + seq.add_device("start", NullDevice()) diff --git a/tests/test_utils_enum.py b/tests/test_utils_enum.py index b9bf4f8058324148b0d01206cf60f8e1096c735f..f37672e60587fb2045788d1f490fa20f96520948 100644 --- a/tests/test_utils_enum.py +++ b/tests/test_utils_enum.py @@ -43,6 +43,12 @@ def test_valueenum(): assert str(a) == "a" assert a == E.A + assert E["A"] is a + with pytest.raises(KeyError): + E["a"] + with pytest.raises(TypeError): + E[a] + assert a != 0 assert a != 1 @@ -68,6 +74,9 @@ def test_nameenum(): assert a != 2 assert a.custom_name == 2 + assert E["a"] is a + assert E[a] is a + b = E("b") assert a != b assert a != "b" @@ -89,6 +98,9 @@ def test_autonumbernameenum(): assert a != 0 assert a != 1 + assert E["a"] is a + assert E[a] is a + b = E("b") assert a != b assert a != "b"