From 335fc2599c0883d2fa06df1c3179b6f52114e3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20N=C3=A4her?= Date: Fri, 11 Oct 2024 15:03:31 +0200 Subject: [PATCH] added new functionality to ds8r class --- ds8r/ds8r.py | 510 +++++++++++++++++++++++++++++---------------------- 1 file changed, 289 insertions(+), 221 deletions(-) diff --git a/ds8r/ds8r.py b/ds8r/ds8r.py index 7d12eb6..5f06660 100644 --- a/ds8r/ds8r.py +++ b/ds8r/ds8r.py @@ -1,292 +1,360 @@ -""" -Define python class 'DS8R' and its method 'run()' used to control DS8R device. -""" - +import ctypes import os -__all__ = ['DS8R'] - -api_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'DS8R_API') - - class DS8R: """ - A Python controller for DS8R device. + A Python controller for the DS8R device. + + The DS8R class provides methods to configure and control the DS8R device, + which is used for delivering electrical pulses in research and clinical settings. + This class interfaces with the device's DLL to send commands and retrieve the current state. Attributes ---------- - mode : {1, 2}, optional - "Mode" indicates pulse mode. - It can be either 1 (mono-phasic) or 2 (bi-phasic) (default: 1). + mode : {DS8R.MODE_MONOPHASIC, DS8R.MODE_BIPHASIC}, optional + Indicates pulse mode. Default is DS8R.MODE_MONOPHASIC. - - In **mono-phasic** mode, only positive or negative currents are generated. - - In **bi-phasic** mode, positive and negative currents alternate. - One serves as a stimulus phase and the other serves as a recovery phase. + - **MODE_MONOPHASIC** (1): Only positive or negative currents are generated. + - **MODE_BIPHASIC** (2): Positive and negative currents alternate. One serves as a stimulus phase, and the other serves as a recovery phase. - polarity : {1, 2, 3}, optional - "Polarity" indicates pulse polarity. - It can be either 1 (positive), 2 (negative), or 3 (alternating) (default: 1). + polarity : {DS8R.POLARITY_POSITIVE, DS8R.POLARITY_NEGATIVE, DS8R.POLARITY_ALTERNATING}, optional + Indicates pulse polarity. Default is DS8R.POLARITY_POSITIVE. - - **Positive** is the standard stimulation mode. - - **Negative** reverses the polarity of all pulses. - - In **alternating** mode, each successive trigger results in a polarity reversal. + - **POLARITY_POSITIVE** (1): Standard stimulation mode. + - **POLARITY_NEGATIVE** (2): Reverses the polarity of all pulses. + - **POLARITY_ALTERNATING** (3): Each successive trigger results in a polarity reversal. - source : {1, 2}, optional - "Source" indicates the source of pulse amplitude control. - It can be either 1 (internal) or 2 (external) (default: 1). + source : {DS8R.SOURCE_INTERNAL, DS8R.SOURCE_EXTERNAL}, optional + Indicates the source of pulse amplitude control. Default is DS8R.SOURCE_INTERNAL. - - **Internal** is the front panel control (including software). - - **External** is the external analogue voltage control. + - **SOURCE_INTERNAL** (1): Front panel control (including software). + - **SOURCE_EXTERNAL** (2): External analogue voltage control. demand : int, optional - "Demand" indicates current output. - It can have a value between 1 and 150 (default: 20). + Current output demand. Value between 1 and 150 (default: 20). - The value of 1 indicates 0.1mA - (e.g. the value of 24 indicates 2.4mA). - Due to safety issues, the current output is limited to 150 (15.0mA). - Values from 1 to 19 (0.1 ~ 1.9mA) may not be correctly implemented - due to the limitations of the device. + - The value of 1 indicates 0.1 mA (e.g., 24 indicates 2.4 mA). + - Due to safety issues, the current output is limited to 150 (15.0 mA). + - Values from 1 to 19 (0.1 ~ 1.9 mA) may not be correctly implemented due to device limitations. pulse_width : int, optional - "Pulse_width" indicates pulse duration. - It can have a value between 50 and 2000, - which have to be a multiple of 10 (default: 100) + Pulse duration in microseconds. Value between 50 and 2000, must be a multiple of 10 (default: 100). - The value of 1 indicates 1 microsecond - (e.g. the value of 100 indicates 100 microseconds). - Since pulse duration increments by 10 microsecond steps, - the "pulse_width" value must be a multiple of 10. + - The value directly represents microseconds (e.g., 100 indicates 100 µs). + - Since pulse duration increments by 10 µs steps, the value must be a multiple of 10. dwell : int, optional - "Dwell" indicates interphase interval in biphasic mode. - It can have a value between 1 and 990 (default: 1). + Interphase interval in biphasic mode. Value between 1 and 990 (default: 1). - Interphase interval is the interval between - the stimulus phase and the recovery phase. - The value of 1 indicates 1 microsecond - (e.g. the value of 100 indicates 100 microseconds). + - Interval between the stimulus phase and the recovery phase. + - The value directly represents microseconds (e.g., 100 indicates 100 µs). recovery : int, optional - "Recovery" indicates the recovery phase ratio in biphasic mode. - It can have a value between 10 and 100 (default: 100) + Recovery phase ratio in biphasic mode. Value between 10 and 100 (default: 100). - At 100%, stimulus and recovery phases are the same in duration and amplitude. - As the ratio is reduced from 100% the amplitude of the recovery phase decreases, - and its duration increases to preserve charge balancing. + - At 100%, stimulus and recovery phases are the same in duration and amplitude. + - As the ratio is reduced from 100%, the amplitude of the recovery phase decreases, and its duration increases to preserve charge balancing. enabled : {0, 1}, optional - "Enabled" indicates output status. - It can have 0 (disabled) or 1 (enabled) (default: 1). + Output status. 0 (disabled) or 1 (enabled) (default: 1). - - In **Disabled** state, the current output will not be triggered. - - In **Enabled** state, the current output will be triggered. + - **0**: The current output will not be triggered. + - **1**: The current output will be triggered. + Methods + ------- + upload_parameters() + Uploads the configured parameters to the DS8R device without triggering a pulse. - Examples - -------- - First, you must make an DS8R object with parameters as arguments. - If you don't pass any argument, the object will use the default values. - These parameters are not applied to the setting of the DS8R device yet. - They will be changed when you use the method `run()`. + trigger_pulse() + Triggers a pulse on the DS8R device using the current settings. - >>> c = DS8R() + set_enabled(enabled) + Sets the enabled state of the device without changing other parameters. - If you want to change a parameter value of an existing DS8R object, - you can do it just by assigning a new value to the property. + get_state(verbose=False) + Retrieves the current state of the DS8R device. - >>> c.demand = 20 + run(force=False) + Uploads the parameters and triggers a pulse. - Finally, by running `run()` method as below, - you can apply the parameters to the DS8R device and trigger a current output. + Examples + -------- + First, create a DS8R object with desired parameters. If no arguments are passed, default values are used. + These parameters are not applied to the DS8R device until you call `run()`. - >>> c.run() - """ + >>> device = DS8R() - def __init__(self, - mode: int = 1, - polarity: int = 1, - source: int = 1, - demand: int = 20, - pulse_width: int = 100, - dwell: int = 1, - recovery: int = 100, - enabled: int = 1): - self.mode = mode - self.polarity = polarity - self.source = source - self.demand = demand - self.pulse_width = pulse_width - self.dwell = dwell - self.recovery = recovery - self.enabled = enabled + To change a parameter value of an existing DS8R object: - @property - def mode(self) -> int: - return self.__mode + >>> device.demand = 20 - @mode.setter - def mode(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + Use constants for clarity: - if obj == 1 or obj == 2: - self.__mode = obj - else: - raise ValueError( - 'The parameter "mode" must be either 1 (Monophasic) or 2 (Biphasic).') + >>> device.mode = DS8R.MODE_BIPHASIC + >>> device.polarity = DS8R.POLARITY_ALTERNATING + >>> device.source = DS8R.SOURCE_INTERNAL - @property - def polarity(self) -> int: - return self.__polarity + Finally, apply the parameters to the DS8R device and trigger a current output: - @polarity.setter - def polarity(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + >>> device.run() - if obj in [1, 2, 3]: - self.__polarity = obj - else: - raise ValueError( - 'The parameter "polarity" must be either 1 (Positive), 2 (Negative), or 3 (Alternating).') + To apply a demand value exceeding the safe limit (12.4 mA), use `force=True`: - @property - def source(self) -> int: - return self.__source + >>> device.demand = 130 + >>> device.run(force=True) + """ - @source.setter - def source(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + _dll_loaded = False + _dll = None - if obj in [1, 2]: - self.__source = obj - else: - raise ValueError( - 'The parameter "source" must be either 1 (internal) or 2 (External).') + # Constants for Mode, Polarity, Source + MODE_MONOPHASIC = 1 + MODE_BIPHASIC = 2 - @property - def demand(self) -> int: - return self.__demand + POLARITY_POSITIVE = 1 + POLARITY_NEGATIVE = 2 + POLARITY_ALTERNATING = 3 - @demand.setter - def demand(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + SOURCE_INTERNAL = 1 + SOURCE_EXTERNAL = 2 + + def __init__(self, mode=MODE_MONOPHASIC, polarity=POLARITY_POSITIVE, source=SOURCE_INTERNAL, + demand=20, pulse_width=100, dwell=1, recovery=100, enabled=1, dll_path=None): + self.mode = mode + self.polarity = polarity + self.source = source + self.demand = demand + self.pulse_width = pulse_width + self.dwell = dwell + self.recovery = recovery + self.enabled = enabled - if 1 <= obj <= 150: - self.__demand = obj + # Load the DLL only once + if not DS8R._dll_loaded: + if dll_path is None: + # Use the directory of the current script + dll_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'D128RProxy.dll') + if not os.path.exists(dll_path): + raise FileNotFoundError(f"DLL not found at {dll_path}") + try: + DS8R._dll = ctypes.WinDLL(dll_path) + DS8R._dll_loaded = True + self._define_functions() + except OSError as e: + raise OSError(f"Failed to load DLL: {e}") + + def _define_functions(self): + # DGD128_Set function + DS8R._dll.DGD128_Set.argtypes = [ + ctypes.c_long, # Mode + ctypes.c_long, # Polarity + ctypes.c_long, # Source + ctypes.c_long, # Demand + ctypes.c_long, # PulseWidth + ctypes.c_long, # Dwell + ctypes.c_long, # Recovery + ctypes.c_long # Enabled + ] + DS8R._dll.DGD128_Set.restype = ctypes.c_int + + # DGD128_Get function + DS8R._dll.DGD128_Get.argtypes = [ + ctypes.POINTER(ctypes.c_long), # Mode + ctypes.POINTER(ctypes.c_long), # Polarity + ctypes.POINTER(ctypes.c_long), # Source + ctypes.POINTER(ctypes.c_long), # Demand + ctypes.POINTER(ctypes.c_long), # PulseWidth + ctypes.POINTER(ctypes.c_long), # Dwell + ctypes.POINTER(ctypes.c_long), # Recovery + ctypes.POINTER(ctypes.c_long) # Enabled + ] + DS8R._dll.DGD128_Get.restype = ctypes.c_int + + # DGD128_Trigger function + DS8R._dll.DGD128_Trigger.argtypes = [] + DS8R._dll.DGD128_Trigger.restype = ctypes.c_int + + def upload_parameters(self): + """ + Uploads the configured parameters to the DS8R device without triggering a pulse. - if 1 <= obj <= 19: - print('"demand" values from 1 to 19 may not be correctly implemented ' - 'due to the limitations of the device.') - else: - raise ValueError( - 'The parameter "demand" must be in the range of 1 to 150.') + This method sends the current configuration parameters to the DS8R device. + It does not trigger a pulse; it only updates the device settings. + """ + try: + # Map parameters to ctypes, ensuring they are integers + mode = ctypes.c_long(int(self.mode)) + polarity = ctypes.c_long(int(self.polarity)) + source = ctypes.c_long(int(self.source)) + demand = ctypes.c_long(int(self.demand)) + pulse_width = ctypes.c_long(int(self.pulse_width)) + dwell = ctypes.c_long(int(self.dwell)) + recovery = ctypes.c_long(int(self.recovery)) + enabled = ctypes.c_long(int(self.enabled)) + + # Call the DGD128_Set function to upload parameters + result = DS8R._dll.DGD128_Set( + mode, + polarity, + source, + demand, + pulse_width, + dwell, + recovery, + enabled + ) + + # Log the return value for debugging + print(f"DGD128_Set returned: {result}") + + # Do not raise an exception based on return value + print("Parameters uploaded successfully.") + except Exception as e: + print(f"Error in upload_parameters: {e}") + raise + + def trigger_pulse(self): + """ + Triggers a pulse on the DS8R device using the current settings. - @property - def pulse_width(self) -> int: - return self.__pulse_width + This method commands the DS8R device to emit a pulse based on the + parameters previously uploaded via `upload_parameters`. + """ + try: + result = DS8R._dll.DGD128_Trigger() - @pulse_width.setter - def pulse_width(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + # Log the return value for debugging + print(f"DGD128_Trigger returned: {result}") - if 50 <= obj <= 2000 and obj % 10 == 0: - self.__pulse_width = obj - else: - raise ValueError( - 'The parameter "pulse_width" must be in the range of 50 to 2000, ' - 'and the input value must be a multiple of 10.') + # Do not raise an exception based on return value + print("Pulse triggered successfully.") + except Exception as e: + print(f"Error in trigger_pulse: {e}") + raise - @property - def dwell(self) -> int: - return self.__dwell + def set_enabled(self, enabled): + """ + Sets the enabled state of the device without changing other parameters. - @dwell.setter - def dwell(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + Parameters + ---------- + enabled : bool + True to enable the device, False to disable. - if 1 <= obj <= 990: - self.__dwell = obj - else: - raise ValueError( - 'The parameter "dwell" must be in the range of 1 to 990') + This method updates the 'enabled' parameter and uploads it to the device. + """ + try: + # Update the 'enabled' parameter + self.enabled = 1 if enabled else 0 - @property - def recovery(self) -> int: - return self.__recovery + # Call upload_parameters to update the device + self.upload_parameters() - @recovery.setter - def recovery(self, obj: int): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + state_str = 'enabled' if enabled else 'disabled' + print(f"Device {state_str} successfully.") + except Exception as e: + print(f"Error in set_enabled: {e}") + raise - if 10 <= obj <= 100: - self.__recovery = obj - else: - raise ValueError( - 'The parameter "recovery" must be in the range of 10 to 100') + def get_state(self, verbose=False): + """ + Retrieves the current state of the DS8R device. - @property - def enabled(self) -> int: - return self.__enabled + Parameters + ---------- + verbose : bool, optional + If True, prints the retrieved parameters. Default is False. - @enabled.setter - def enabled(self, obj): - if not isinstance(obj, int): - raise TypeError( - 'Invalid value. Every parameter value must be an integer.') + Returns + ------- + dict + A dictionary containing the current device parameters. - if obj == 0 or obj == 1: - self.__enabled = obj - else: - raise ValueError( - 'The parameter "enabled" must be either 0 (disabled) or 1 (enabled).') + This method queries the DS8R device for its current settings and updates + the instance variables accordingly. + """ + try: + # Prepare ctypes variables to receive the parameters + mode = ctypes.c_long() + polarity = ctypes.c_long() + source = ctypes.c_long() + demand = ctypes.c_long() + pulse_width = ctypes.c_long() + dwell = ctypes.c_long() + recovery = ctypes.c_long() + enabled = ctypes.c_long() + + # Call the DGD128_Get function + result = DS8R._dll.DGD128_Get( + ctypes.byref(mode), + ctypes.byref(polarity), + ctypes.byref(source), + ctypes.byref(demand), + ctypes.byref(pulse_width), + ctypes.byref(dwell), + ctypes.byref(recovery), + ctypes.byref(enabled) + ) + + # Create a dictionary with the parameters + state = { + 'mode': mode.value, + 'polarity': polarity.value, + 'source': source.value, + 'demand': demand.value, # Use demand as is + 'pulse_width': pulse_width.value, + 'dwell': dwell.value, + 'recovery': recovery.value, + 'enabled': enabled.value + } + + # Update the instance variables + self.mode = state['mode'] + self.polarity = state['polarity'] + self.source = state['source'] + self.demand = state['demand'] + self.pulse_width = state['pulse_width'] + self.dwell = state['dwell'] + self.recovery = state['recovery'] + self.enabled = state['enabled'] + + # Print the retrieved parameters if verbose is True + if verbose: + print(f"DGD128_Get returned: {result}") + print("Current device parameters:") + for key, value in state.items(): + print(f"{key.capitalize()}: {value}") + + return state + except Exception as e: + print(f"Error in get_state: {e}") + raise def run(self, force=False): - """Change the settings of the DS8R device and trigger an output. + """ + Uploads the parameters and triggers a pulse. Parameters - --------- - force : bool - ``True`` allows applying a current greater than 15.0mA, - which can be dangerous. The default value is ``False``. + ---------- + force : bool, optional + If True, allows applying a current demand exceeding the safe limit (12.4 mA). + Default is False. Raises ------ ValueError - If the current output is greater than 15.0mA and the 'force' value is ``False``. + If the demand value exceeds the safe limit and force is not True. + + This method first uploads the parameters to the device and then triggers a pulse. + It includes safety logic to prevent applying a high current demand unintentionally. """ - command = ('"{filename}" {mode} {polarity} {source} {demand} ' - '{pulse_width} {dwell} {recovery} {enabled}')\ - .format(filename=api_path, - mode=self.mode, - polarity=self.polarity, - source=self.source, - demand=self.demand, - pulse_width=self.pulse_width, - dwell=self.dwell, - recovery=self.recovery, - enabled=self.enabled) - if self.demand <= 500: - os.system(command) - elif force: - os.system(command) + safe_limit = 10 # Safe maximum demand without forcing + if self.demand <= safe_limit or force: + self.upload_parameters() + self.trigger_pulse() else: raise ValueError( - 'You should be careful when applying "demand" values greater than 124 (12.4mA). ' - 'To apply a current greater than 12.4mA, ' - 'use "c.run(force=True)".') + f'Demand value {self.demand} exceeds safe limit of {safe_limit} (10.0 mA). ' + 'To apply a current greater than 10.0 mA, use "run(force=True)".' + )