PulsePal

Python interface for the Pulse Pal open source pulse train generator.

Pulse Pal delivers precisely timed voltage pulse trains on four analog output channels, and can be triggered by TTL logic on two trigger channels or in software. This module configures and triggers the device over its USB serial port.

Everything is accessed through PulsePalDevice. Import it, connect to the device's serial port, program parameters, and trigger:

from PulsePal import PulsePalDevice

with PulsePalDevice("COM3") as P:       # /dev/ttyACM0 on Linux
    P.set_output_param("phase1_voltage", 1, 5)
    P.set_output_param("phase1_duration", 1, 0.001)
    P.set_output_param("pulse_train_duration", 1, 2)
    P.trigger(1)

Parameter arrays

Output parameters are exposed as plain Python lists on the device object, one list per parameter. Each list has five elements so that the list index matches the Pulse Pal channel number: index 0 is unused and holds nan, and indices 1 to 4 hold the values for output channels 1-4. PulsePalDevice.trigger_mode follows the same convention with three elements, for trigger channels 1 and 2.

P.phase1_voltage[2] = 7                  # channel 2 only
P.inter_pulse_interval[1:5] = [0.2] * 4  # all four channels
P.sync_to_device()             # push the edits to the device

Editing these lists changes only the local copy. Call PulsePalDevice.sync_to_device to program the device, or use PulsePalDevice.set_output_param and PulsePalDevice.set_trigger_param, which program a single parameter immediately and keep the local copy in step.

Units

Voltages are in volts in the range [-10, 10]. Times are in seconds, and are rounded to the nearest cycle of the device's hardware timer (see DeviceInfo.cycle_frequency). Enumerated parameters are integers, and their meanings are given with each attribute below.

Further reading

License

This file is part of the Sanworks PulsePal repository. Copyright (C) Sanworks LLC, Rochester, New York, USA

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.

This program is distributed WITHOUT ANY WARRANTY and without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/.

   1"""
   2Python interface for the [Pulse Pal](https://sites.google.com/site/pulsepalwiki/)
   3open source pulse train generator.
   4
   5Pulse Pal delivers precisely timed voltage pulse trains on four analog
   6output channels, and can be triggered by TTL logic on two trigger
   7channels or in software. This module configures and triggers the device
   8over its USB serial port.
   9
  10Everything is accessed through `PulsePalDevice`. Import it, connect to
  11the device's serial port, program parameters, and trigger:
  12
  13```python
  14from PulsePal import PulsePalDevice
  15
  16with PulsePalDevice("COM3") as P:       # /dev/ttyACM0 on Linux
  17    P.set_output_param("phase1_voltage", 1, 5)
  18    P.set_output_param("phase1_duration", 1, 0.001)
  19    P.set_output_param("pulse_train_duration", 1, 2)
  20    P.trigger(1)
  21```
  22
  23## Parameter arrays
  24
  25Output parameters are exposed as plain Python lists on the device
  26object, one list per parameter. Each list has five elements so that the
  27list index matches the Pulse Pal channel number: index 0 is unused and
  28holds `nan`, and indices 1 to 4 hold the values for output channels
  291-4. `PulsePalDevice.trigger_mode` follows the same convention with
  30three elements, for trigger channels 1 and 2.
  31
  32```python
  33P.phase1_voltage[2] = 7                  # channel 2 only
  34P.inter_pulse_interval[1:5] = [0.2] * 4  # all four channels
  35P.sync_to_device()             # push the edits to the device
  36```
  37
  38Editing these lists changes only the local copy. Call
  39`PulsePalDevice.sync_to_device` to program the device, or use
  40`PulsePalDevice.set_output_param` and
  41`PulsePalDevice.set_trigger_param`, which program a single parameter
  42immediately and keep the local copy in step.
  43
  44## Units
  45
  46Voltages are in volts in the range [-10, 10]. Times are in seconds, and
  47are rounded to the nearest cycle of the device's hardware timer (see
  48`DeviceInfo.cycle_frequency`). Enumerated parameters are integers, and
  49their meanings are given with each attribute below.
  50
  51## Further reading
  52
  53- Parameter guide:
  54  https://sites.google.com/site/pulsepalwiki/parameter-guide
  55- Serial interface and general documentation:
  56  https://sites.google.com/site/pulsepalwiki/
  57
  58## License
  59
  60This file is part of the Sanworks PulsePal repository.
  61Copyright (C) Sanworks LLC, Rochester, New York, USA
  62
  63This program is free software: you can redistribute it and/or modify
  64it under the terms of the GNU General Public License as published by
  65the Free Software Foundation, version 3.
  66
  67This program is distributed WITHOUT ANY WARRANTY and without even the
  68implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
  69See the GNU General Public License for more details.
  70
  71You should have received a copy of the GNU General Public License
  72along with this program. If not, see <http://www.gnu.org/licenses/>.
  73"""
  74
  75from decimal import Decimal
  76from dataclasses import dataclass
  77import numbers
  78import struct
  79import time
  80
  81import numpy as np
  82import serial
  83
  84__all__ = ["PulsePalDevice", "DeviceInfo", "PulsePalError"]
  85__docformat__ = "google"
  86
  87
  88class PulsePalError(Exception):
  89    """Raised when Pulse Pal communication or configuration fails.
  90
  91    This covers serial reads that time out, short serial writes, missing
  92    acknowledgement bytes, unknown parameter names, values that do not
  93    fit the datatype expected by the device, and operations that the
  94    connected firmware or hardware revision does not support.
  95    """
  96
  97
  98@dataclass
  99class DeviceInfo:
 100    """Properties of the connected Pulse Pal device.
 101
 102    An instance is created for each connection and populated during the
 103    handshake in `PulsePalDevice.__init__`. It is available as
 104    `PulsePalDevice.info`. Devices running firmware v21 report only a
 105    firmware version, so the remaining fields are filled in with the
 106    known values for Pulse Pal hardware v2.
 107
 108    ```python
 109    print(P.info.firmware_version)
 110    ```
 111    """
 112
 113    output_parameter_names: list = None
 114    """Output parameter names, ordered by parameter code.
 115
 116    The position of a name in this list, plus 1, is the parameter code
 117    the device expects. Any name here is valid as the `param_name`
 118    argument of `PulsePalDevice.set_output_param`, and is also the name
 119    of the matching parameter array attribute on `PulsePalDevice`.
 120    """
 121
 122    trigger_parameter_names: list = None
 123    """Trigger parameter names, accepted by
 124    `PulsePalDevice.set_trigger_param`."""
 125
 126    firmware_version: int = None
 127    """Firmware version running on the connected device."""
 128
 129    hardware_version: int = None
 130    """Hardware revision of the connected device, e.g. `2` or `3`.
 131
 132    Reported by the device on firmware v22 and newer; assumed to be `2`
 133    on older firmware.
 134    """
 135
 136    max_custom_pulses: int = None
 137    """Maximum number of pulses in a single custom pulse train."""
 138
 139    n_custom_pulse_trains: int = None
 140    """Number of custom pulse trains the device can store."""
 141
 142    cycle_frequency: float = None
 143    """Update frequency of the device's hardware timer, in Hz.
 144
 145    All time parameters are rounded to a whole number of these cycles,
 146    so this sets the timing resolution of the device.
 147    """
 148
 149    cycle_period_us: float = None
 150    """Update period of the device's hardware timer, in microseconds."""
 151
 152
 153class PulsePalDevice:
 154    """A class to control a Pulse Pal device on a USB serial port.
 155
 156    Creating an instance opens the serial port, exchanges a handshake
 157    with the device, verifies that its firmware is supported, reads the
 158    device properties into `PulsePalDevice.info`, and programs the
 159    device with the default parameters.
 160
 161    ```python
 162    from PulsePal import PulsePalDevice
 163
 164    P = PulsePalDevice("COM3")
 165    P.set_output_param("phase1_voltage", 1, 5)
 166    P.trigger(1)
 167    P.close()
 168    ```
 169
 170    The class is also a context manager, which closes the connection on
 171    exit even if an error is raised:
 172
 173    ```python
 174    with PulsePalDevice("COM3") as P:
 175        P.trigger(1)
 176    ```
 177
 178    The attributes below named after Pulse Pal parameters are the local
 179    copy of the device's program. Each is a five element list indexed by
 180    channel number, with index 0 unused. Assigning to them does not
 181    reach the device until `PulsePalDevice.sync_to_device` is called;
 182    `PulsePalDevice.set_output_param` programs one parameter right away.
 183    """
 184
 185    port: "serial.Serial"
 186    """The open `serial.Serial` port connected to the device."""
 187
 188    info: DeviceInfo
 189    """Properties of the connected device. See `DeviceInfo`."""
 190
 191    is_biphasic: list
 192    """Pulse shape per channel: `0` for monophasic, `1` for biphasic.
 193
 194    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 195    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 196    phase 2.
 197    """
 198
 199    phase1_voltage: list
 200    """Voltage of the first phase of each pulse, in volts [-10, 10]."""
 201
 202    phase2_voltage: list
 203    """Voltage of the second phase of each pulse, in volts [-10, 10].
 204
 205    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 206    """
 207
 208    resting_voltage: list
 209    """Voltage held between pulses, in volts [-10, 10]."""
 210
 211    phase1_duration: list
 212    """Duration of the first phase of each pulse, in seconds."""
 213
 214    inter_phase_interval: list
 215    """Interval between the two phases of a biphasic pulse, in seconds.
 216
 217    The channel rests at `PulsePalDevice.resting_voltage` during the
 218    interval. Used only when `PulsePalDevice.is_biphasic` is `1`.
 219    """
 220
 221    phase2_duration: list
 222    """Duration of the second phase of each pulse, in seconds.
 223
 224    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 225    """
 226
 227    inter_pulse_interval: list
 228    """Interval from the end of one pulse to the onset of the next, in
 229    seconds."""
 230
 231    burst_duration: list
 232    """Duration of each burst of pulses, in seconds.
 233
 234    Set to `0` to disable bursts, so that pulses continue for the whole
 235    pulse train.
 236    """
 237
 238    inter_burst_interval: list
 239    """Interval between bursts of pulses, in seconds.
 240
 241    The channel rests at `PulsePalDevice.resting_voltage` between
 242    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 243    """
 244
 245    pulse_train_duration: list
 246    """Total duration of the pulse train, in seconds."""
 247
 248    pulse_train_delay: list
 249    """Delay from the trigger to the onset of the pulse train, in
 250    seconds."""
 251
 252    link_trigger_channel1: list
 253    """Whether each output channel is linked to trigger channel 1.
 254
 255    `1` links the output channel to trigger channel 1, `0` unlinks it.
 256    """
 257
 258    link_trigger_channel2: list
 259    """Whether each output channel is linked to trigger channel 2.
 260
 261    `1` links the output channel to trigger channel 2, `0` unlinks it.
 262    """
 263
 264    custom_train_id: list
 265    """Custom pulse train played by each output channel.
 266
 267    `0` plays the parametrically defined train. `1` or higher plays the
 268    matching custom train, previously loaded with
 269    `PulsePalDevice.send_custom_pulse_train` or
 270    `PulsePalDevice.send_custom_waveform`.
 271    """
 272
 273    custom_train_target: list
 274    """What the timestamps of a custom train mark.
 275
 276    `0` if each timestamp is the onset of a pulse, `1` if each timestamp
 277    is the onset of a burst of pulses.
 278    """
 279
 280    custom_train_loop: list
 281    """Whether a custom train repeats.
 282
 283    `1` loops the custom train until
 284    `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it
 285    once.
 286    """
 287
 288    trigger_mode: list
 289    """Response of each trigger channel to an incoming TTL pulse.
 290
 291    Three element list indexed by trigger channel, with index 0 unused.
 292    Elements 1 and 2 control the respective channels on the device.
 293    Their values can be:
 294
 295    - `0` (normal): a TTL rising edge starts the pulse train, and edges
 296      during the train are ignored.
 297    - `1` (toggle): same as 0 but a TTL rising edge during the train stops it.
 298    - `2` (pulse gated): the train runs only while the trigger TTL is high.
 299    """
 300
 301    _CURRENT_FIRMWARE_VERSION = 22
 302
 303    _OP_MENU_BYTE = 213
 304    _HANDSHAKE_OPCODE = 72
 305    _HANDSHAKE_RESPONSE = 75
 306    _DAC_BITMAX = 65535
 307    _OLDEST_FIRMWARE_SUPPORTED = 21
 308
 309    _OUTPUT_PARAMETER_NAMES = (
 310        "is_biphasic",
 311        "phase1_voltage",
 312        "phase2_voltage",
 313        "phase1_duration",
 314        "inter_phase_interval",
 315        "phase2_duration",
 316        "inter_pulse_interval",
 317        "burst_duration",
 318        "inter_burst_interval",
 319        "pulse_train_duration",
 320        "pulse_train_delay",
 321        "link_trigger_channel1",
 322        "link_trigger_channel2",
 323        "custom_train_id",
 324        "custom_train_target",
 325        "custom_train_loop",
 326        "resting_voltage",
 327    )
 328    _TRIGGER_PARAMETER_NAMES = ("trigger_mode",)
 329
 330    _OUTPUT_PARAMETER_ATTRS = {
 331        1: "is_biphasic",
 332        2: "phase1_voltage",
 333        3: "phase2_voltage",
 334        4: "phase1_duration",
 335        5: "inter_phase_interval",
 336        6: "phase2_duration",
 337        7: "inter_pulse_interval",
 338        8: "burst_duration",
 339        9: "inter_burst_interval",
 340        10: "pulse_train_duration",
 341        11: "pulse_train_delay",
 342        12: "link_trigger_channel1",
 343        13: "link_trigger_channel2",
 344        14: "custom_train_id",
 345        15: "custom_train_target",
 346        16: "custom_train_loop",
 347        17: "resting_voltage",
 348    }
 349    _ENDIANNESS = "<"
 350    _STRUCT_FORMATS = {
 351        "uint8": "B",
 352        "int8": "b",
 353        "char": "c",
 354        "uint16": "H",
 355        "int16": "h",
 356        "uint32": "I",
 357        "int32": "i",
 358        "single": "f",
 359        "double": "d",
 360    }
 361    _TYPE_RANGES = {
 362        "uint8": (0, 2**8 - 1),
 363        "int8": (-(2**7), 2**7 - 1),
 364        "uint16": (0, 2**16 - 1),
 365        "int16": (-(2**15), 2**15 - 1),
 366        "uint32": (0, 2**32 - 1),
 367        "int32": (-(2**31), 2**31 - 1),
 368    }
 369
 370    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 371        """Open a connection to a Pulse Pal device.
 372
 373        Opens the serial port, exchanges the handshake, verifies the
 374        firmware version, reads the device properties into
 375        `PulsePalDevice.info`, and programs the device with the default
 376        parameters.
 377
 378        Args:
 379            port_name: USB serial port for the Pulse Pal device, such as
 380                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 381            baud_rate: Serial baud rate.
 382            timeout: Serial read timeout, in seconds.
 383
 384        Raises:
 385            PulsePalError: If the device does not return the expected
 386                handshake, or its firmware is older than v21, or its
 387                firmware is newer than this module supports.
 388            serial.SerialException: If the serial port cannot be opened.
 389        """
 390        self.info = DeviceInfo()
 391        self._gui = None
 392        self.port = serial.Serial(
 393            port_name,
 394            baud_rate,
 395            timeout=timeout,
 396            rtscts=True,
 397        )
 398        self._closed = False
 399        self._dac_bit_max = self._to_decimal(0)
 400        self.info.firmware_version = None
 401        self.info.hardware_version = None
 402        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
 403        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
 404
 405        self._write_serial(
 406            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
 407            "uint8",
 408        )
 409        handshake = self._read_serial(1, "uint8")
 410        if handshake != self._HANDSHAKE_RESPONSE:
 411            self.close(send_disconnect=False)
 412            raise PulsePalError(
 413                "Error: incorrect handshake returned. Expected "
 414                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 415            )
 416
 417        firmware_version = self._read_serial(1, "uint32")
 418        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 419            raise PulsePalError(
 420                "Error: Old firmware detected, v"
 421                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 422                "newer is required."
 423            )
 424        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 425            raise PulsePalError(
 426                "Error: Future firmware detected, v"
 427                f"{firmware_version}. Please update PulsePal.py or downgrade "
 428                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
 429            )
 430        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 431            print(
 432                "Old firmware detected, v"
 433                f"{firmware_version}. This firmware is supported. Update to v"
 434                f"{self._CURRENT_FIRMWARE_VERSION} is available."
 435            )
 436        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
 437        self.info.firmware_version = firmware_version
 438
 439        if self.info.firmware_version > 21:
 440            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 441            self.info.hardware_version = self._read_serial(1, "uint8")
 442            self.info.cycle_period_us = self._read_serial(1, "uint32")
 443            self.info.cycle_frequency = 1 / (
 444                self.info.cycle_period_us / 1000000
 445            )
 446            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 447            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 448        else:
 449            self.info.hardware_version = 2
 450            self.info.cycle_period_us = 50
 451            self.info.cycle_frequency = 20000
 452            self.info.n_custom_pulse_trains = 2
 453            self.info.max_custom_pulses = 5000
 454
 455        # Client name op + "PYTHON" in ASCII.
 456        self._write_serial(
 457            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 458            "uint8",
 459        )
 460
 461        self.set_default_params()
 462        self.sync_to_device()
 463
 464    def set_default_params(self):
 465        """Reset the local copy of all parameters to their defaults.
 466
 467        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
 468        1 second, on all four output channels, linked to trigger channel
 469        1 in normal trigger mode.
 470
 471        This updates only the local copy. Call
 472        `PulsePalDevice.sync_to_device` to program the device with them.
 473        """
 474        nan = float("nan")
 475        self.is_biphasic = [nan, 0, 0, 0, 0]
 476        self.phase1_voltage = [nan, 5, 5, 5, 5]
 477        self.phase2_voltage = [nan, -5, -5, -5, -5]
 478        self.resting_voltage = [nan, 0, 0, 0, 0]
 479        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 480        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
 481        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 482        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
 483        self.burst_duration = [nan, 0, 0, 0, 0]
 484        self.inter_burst_interval = [nan, 0, 0, 0, 0]
 485        self.pulse_train_duration = [nan, 1, 1, 1, 1]
 486        self.pulse_train_delay = [nan, 0, 0, 0, 0]
 487        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
 488        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
 489        self.custom_train_id = [nan, 0, 0, 0, 0]
 490        self.custom_train_target = [nan, 0, 0, 0, 0]
 491        self.custom_train_loop = [nan, 0, 0, 0, 0]
 492        self.trigger_mode = [nan, 0, 0]
 493
 494    def set_voltage(self, channel, voltage):
 495        """Set an output channel to a fixed voltage.
 496
 497        The channel holds the voltage until it is set again or until a
 498        pulse train is triggered on it.
 499
 500        Args:
 501            channel: Output channel number, 1-4.
 502            voltage: Voltage to set, in volts [-10, 10].
 503
 504        Raises:
 505            PulsePalError: If the device does not acknowledge the
 506                command.
 507        """
 508        voltage_bits = self._volts_to_bits(voltage)
 509        self._write_serial(
 510            (self._OP_MENU_BYTE, 79, channel),
 511            "uint8",
 512            voltage_bits,
 513            "uint16",
 514        )
 515        self._read_ack("set_fixed_voltage()")
 516
 517    def set_calibration(self, channel, voltage_offset):
 518        """Calibrate the zero code of an output channel.
 519
 520        The offset is added to every voltage the channel produces, to
 521        correct for DAC offset error. It is stored in the device's
 522        EEPROM and reloaded on boot, so it only needs to be set once.
 523
 524        Requires Pulse Pal hardware v3 or newer.
 525
 526        Args:
 527            channel: Output channel number, 1-4.
 528            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
 529
 530        Raises:
 531            PulsePalError: If the connected hardware is older than v3,
 532                or the device does not acknowledge the command.
 533            ValueError: If `channel` is not 1-4, or `voltage_offset` is
 534                outside [-0.1, 0.1].
 535        """
 536        if self.info.hardware_version < 3:
 537            raise PulsePalError(
 538                "set_calibration() requires hardware v3 or newer."
 539            )
 540        if channel not in (1, 2, 3, 4):
 541            raise ValueError("channel must be 1, 2, 3 or 4")
 542        if voltage_offset < -0.1 or voltage_offset > 0.1:
 543            raise ValueError(
 544                "voltage_offset for zero code calibration must be in range "
 545                "[-0.1, 0.1]"
 546            )
 547        voltage_bits = voltage_offset * (1 / (20 / 65536))
 548        self._write_serial(
 549            (self._OP_MENU_BYTE, 96, channel - 1),
 550            "uint8",
 551            voltage_bits,
 552            "int16",
 553        )
 554        self._read_ack("set_calibration()")
 555
 556    def set_output_param(self, param_name, channel, value):
 557        """Program a single output channel parameter on the device.
 558
 559        The local copy of the parameter is updated to match, so a later
 560        `PulsePalDevice.sync_to_device` will not undo the change.
 561
 562        ```python
 563        P.set_output_param("is_biphasic", 1, 1)
 564        P.set_output_param("phase1_voltage", 1, 10)
 565        P.set_output_param(3, 1, -10)   # same, by param code
 566        ```
 567
 568        Args:
 569            param_name: Parameter name, as listed in
 570                `DeviceInfo.output_parameter_names`, or its integer
 571                parameter code.
 572            channel: Output channel number, 1-4.
 573            value: Value to set. Units are volts for voltage parameters,
 574                seconds for time parameters, and integers for enumerated
 575                parameters. See the attributes of `PulsePalDevice` for
 576                the meaning of each parameter.
 577
 578        Raises:
 579            PulsePalError: If the parameter name is not recognized, the
 580                value does not fit the datatype the device expects, or
 581                the device does not acknowledge the command.
 582        """
 583        original_value = value
 584        param_code = self._get_output_param_code(param_name)
 585
 586        if param_code in (2, 3, 17):
 587            value = self._volts_to_bits(value)
 588            self._write_serial(
 589                (self._OP_MENU_BYTE, 74, param_code, channel),
 590                "uint8",
 591                value,
 592                "uint16",
 593            )
 594        elif 4 <= param_code <= 11:
 595            self._write_serial(
 596                (self._OP_MENU_BYTE, 74, param_code, channel),
 597                "uint8",
 598                self._seconds_to_cycles(value),
 599                "uint32",
 600            )
 601        else:
 602            self._write_serial(
 603                (self._OP_MENU_BYTE, 74, param_code, channel, value),
 604                "uint8",
 605            )
 606
 607        self._read_ack("program_output_channel_param()")
 608        self._set_output_param_value(param_code, channel, original_value)
 609
 610    def set_trigger_param(self, param_name, channel, value):
 611        """Program a single trigger channel parameter on the device.
 612
 613        The local copy of the parameter is updated to match, so a later
 614        `PulsePalDevice.sync_to_device` will not undo the change.
 615
 616        ```python
 617        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
 618        ```
 619
 620        Args:
 621            param_name: Parameter name, as listed in
 622                `DeviceInfo.trigger_parameter_names`, or its integer
 623                parameter code.
 624            channel: Trigger channel number, 1-2.
 625            value: Value to set. See `PulsePalDevice.trigger_mode` for
 626                the trigger modes.
 627
 628        Raises:
 629            PulsePalError: If the parameter name is not recognized, the
 630                value does not fit the datatype the device expects, or
 631                the device does not acknowledge the command.
 632        """
 633        original_value = value
 634        param_code = self._get_trigger_param_code(param_name)
 635
 636        self._write_serial(
 637            (self._OP_MENU_BYTE, 74, param_code, channel, value),
 638            "uint8",
 639        )
 640        self._read_ack("program_trigger_channel_param()")
 641
 642        if param_code in (1, 128):
 643            self.trigger_mode[channel] = original_value
 644
 645    def sync_to_device(self):
 646        """Program the device with the local copy of all parameters.
 647
 648        Call this after assigning to the parameter array attributes, to
 649        send every output and trigger parameter to the device in a
 650        single transaction.
 651
 652        ```python
 653        P.phase1_voltage[1:5] = [5] * 4
 654        P.sync_to_device()
 655        ```
 656
 657        Raises:
 658            PulsePalError: If the device does not acknowledge the
 659                command.
 660        """
 661        # _sync_all_params() for firmware v22+ uses the newer packed sync
 662        # opcode (92). _sync_all_params_legacy() uses the less efficient
 663        # legacy packed sync opcode (73).
 664        if self.info.firmware_version > 21:
 665            self._sync_all_params()
 666        else:
 667            self._sync_all_params_legacy()
 668        self._read_ack("sync_to_device()")
 669
 670    def sync_from_device(self):
 671        """Read all parameters from the device into the local copy.
 672
 673        Overwrites every parameter array attribute with the program
 674        currently stored on the device. Useful after the device has been
 675        reprogrammed from its thumb joystick.
 676
 677        Requires firmware v22 or newer.
 678
 679        Raises:
 680            PulsePalError: If the connected firmware is older than v22,
 681                or the device does not return the full parameter set.
 682        """
 683        self._require_firmware(22, "sync_from_device()")
 684        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
 685        for attr_name in (
 686                "phase1_duration",
 687                "inter_phase_interval",
 688                "phase2_duration",
 689                "inter_pulse_interval",
 690                "burst_duration",
 691                "inter_burst_interval",
 692                "pulse_train_duration",
 693                "pulse_train_delay",
 694        ):
 695            setattr(
 696                self,
 697                attr_name,
 698                [float("nan")]
 699                + [
 700                    self._cycles_to_seconds(x)
 701                    for x in self._read_serial(4, "uint32")
 702                ],
 703            )
 704
 705        for attr_name in (
 706                "phase1_voltage",
 707                "phase2_voltage",
 708                "resting_voltage",
 709        ):
 710            setattr(
 711                self,
 712                attr_name,
 713                [float("nan")]
 714                + [
 715                    self._bits_to_volts(x)
 716                    for x in self._read_serial(4, "uint16")
 717                ],
 718            )
 719
 720        for attr_name in (
 721                "is_biphasic",
 722                "custom_train_id",
 723                "custom_train_target",
 724                "custom_train_loop",
 725                "link_trigger_channel1",
 726                "link_trigger_channel2",
 727        ):
 728            setattr(
 729                self,
 730                attr_name,
 731                [float("nan")] + self._read_serial(4, "uint8"),
 732            )
 733        self.trigger_mode = [float("nan")] + self._read_serial(2, "uint8")
 734
 735    def send_custom_pulse_train(
 736        self,
 737        custom_train_id,
 738        pulse_times,
 739        pulse_voltages,
 740    ):
 741        """Load a custom pulse train onto the device.
 742
 743        A custom pulse train is an arbitrary list of pulse onset times
 744        and voltages, replacing the parametric pulse voltage and onset timing.
 745        Set `PulsePalDevice.custom_train_id` on an output channel to play
 746        the train there.
 747
 748        ```python
 749        P.send_custom_pulse_train(
 750            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
 751        )
 752        P.set_output_param("custom_train_id", 1, 2)
 753        ```
 754
 755        Args:
 756            custom_train_id: Custom train to load, 1-2. See
 757                `DeviceInfo.n_custom_pulse_trains`.
 758            pulse_times: Pulse onset times, in seconds, relative to the
 759                start of the train. Accepts a list, tuple or NumPy
 760                array.
 761            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
 762                Must be the same length as `pulse_times`.
 763
 764        Raises:
 765            PulsePalError: If `pulse_times` and `pulse_voltages` differ
 766                in length, or the device does not acknowledge the
 767                command.
 768        """
 769        pulse_times = self._as_list(pulse_times)
 770        pulse_voltages = self._as_list(pulse_voltages)
 771        n_pulses = len(pulse_times)
 772        if n_pulses != len(pulse_voltages):
 773            raise PulsePalError(
 774                "pulse_times and pulse_voltages must be the same length."
 775            )
 776
 777        pulse_times_cycles = [
 778            self._seconds_to_cycles(pulse_time)
 779            for pulse_time in pulse_times
 780        ]
 781        pulse_voltage_bits = [
 782            self._volts_to_bits(voltage)
 783            for voltage in pulse_voltages
 784        ]
 785
 786        op_code = int(custom_train_id) + 74
 787        self._write_serial(
 788            (self._OP_MENU_BYTE, op_code),
 789            "uint8",
 790            n_pulses,
 791            "uint32",
 792            pulse_times_cycles,
 793            "uint32",
 794            pulse_voltage_bits,
 795            "uint16",
 796        )
 797        self._read_ack("send_custom_pulse_train()")
 798
 799    def send_custom_waveform(
 800        self,
 801        custom_train_id,
 802        pulse_width,
 803        pulse_voltages,
 804    ):
 805        """Load an arbitrary waveform onto the device.
 806
 807        A convenience shorthand for
 808        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
 809        confluent pulses, so that `pulse_voltages` is played as a
 810        waveform sampled every `pulse_width` seconds.
 811
 812        Set the channel's `PulsePalDevice.phase1_duration` to
 813        `pulse_width` as well, so that each sample is held for the
 814        sampling period.
 815
 816        ```python
 817        import math
 818
 819        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
 820        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
 821        P.set_output_param("custom_train_id", 2, 1)
 822        P.set_output_param("phase1_duration", 2, 0.001)
 823        ```
 824
 825        Args:
 826            custom_train_id: Custom train to load, 1-2. See
 827                `DeviceInfo.n_custom_pulse_trains`.
 828            pulse_width: Sampling period, in seconds. Each voltage is
 829                held for this long.
 830            pulse_voltages: Waveform samples, in volts [-10, 10].
 831                Accepts a list, tuple or NumPy array.
 832
 833        Raises:
 834            PulsePalError: If the device does not acknowledge the
 835                command.
 836        """
 837        pulse_voltages = self._as_list(pulse_voltages)
 838        n_pulses = len(pulse_voltages)
 839        pulse_width_cycles = self._seconds_to_cycles(pulse_width)
 840        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
 841        pulse_voltage_bits = [
 842            self._volts_to_bits(voltage)
 843            for voltage in pulse_voltages
 844        ]
 845
 846        op_code = int(custom_train_id) + 74
 847        self._write_serial(
 848            (self._OP_MENU_BYTE, op_code),
 849            "uint8",
 850            n_pulses,
 851            "uint32",
 852            pulse_times,
 853            "uint32",
 854            pulse_voltage_bits,
 855            "uint16",
 856        )
 857        self._read_ack("send_custom_waveform()")
 858
 859    def set_continuous_loop(self, channel, state):
 860        """Set the continuous loop state of an output channel.
 861
 862        In continuous loop mode the channel repeats its pulse train
 863        indefinitely the next time it is triggered, until the mode is
 864        cleared or `PulsePalDevice.stop` is called.
 865
 866        Args:
 867            channel: Output channel number, 1-4.
 868            state: `1` for continuous loop, `0` for normal mode.
 869        """
 870        self._write_serial(
 871            (self._OP_MENU_BYTE, 82, channel, state),
 872            "uint8",
 873        )
 874
 875    def trigger(
 876        self,
 877        channel1=None,
 878        channel2=None,
 879        channel3=None,
 880        channel4=None,
 881    ):
 882        """Trigger output channels in software.
 883
 884        Triggered channels start their pulse trains together. Three
 885        calling conventions are accepted:
 886
 887        ```python
 888        P.trigger(1, 0, 1, 0)   # one flag per channel
 889        P.trigger(3)            # a single channel number
 890        P.trigger([1, 4])       # several channel numbers
 891        ```
 892
 893        Channel numbers outside 1-4 are ignored.
 894
 895        Args:
 896            channel1: `1` to trigger channel 1, otherwise `0`; or, when
 897                it is the only argument given, a single channel number
 898                or a list of channel numbers to trigger.
 899            channel2: `1` to trigger channel 2, otherwise `0`.
 900            channel3: `1` to trigger channel 3, otherwise `0`.
 901            channel4: `1` to trigger channel 4, otherwise `0`.
 902        """
 903        trigger_byte = 0
 904
 905        # Options 2 & 3: Only one argument was provided
 906        if channel2 is None and channel3 is None and channel4 is None:
 907            # Option 2: Single integer
 908            if isinstance(channel1, int):
 909                channels_to_trigger = [channel1]
 910            # Option 3: List/Tuple of integers
 911            elif isinstance(channel1, (list, tuple, set)):
 912                channels_to_trigger = channel1
 913            else:
 914                channels_to_trigger = []
 915
 916            # Use bitwise shifts to calculate the trigger byte
 917            # (ch1=bit0, ch2=bit1, etc.)
 918            for ch in channels_to_trigger:
 919                if 1 <= ch <= 4:
 920                    trigger_byte |= (1 << (ch - 1))
 921
 922        # Option 1: Original input scheme (logicals for each channel)
 923        else:
 924            # Fallback to 0 if an argument was omitted via kwargs
 925            c1 = channel1 if channel1 is not None else 0
 926            c2 = channel2 if channel2 is not None else 0
 927            c3 = channel3 if channel3 is not None else 0
 928            c4 = channel4 if channel4 is not None else 0
 929
 930            trigger_byte = (
 931                (1 * c1)
 932                + (2 * c2)
 933                + (4 * c3)
 934                + (8 * c4)
 935            )
 936
 937        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")
 938
 939    def sd_settings(self, settings_file_name, op):
 940        """Save, load, or delete a settings file on the microSD card.
 941
 942        A settings file holds a complete Pulse Pal program, so that it
 943        can be recalled later from software or from the device's front
 944        panel. Loading a file also refreshes the local copy of the
 945        parameters, via `PulsePalDevice.sync_from_device`.
 946
 947        ```python
 948        P.sd_settings("MyProtocol.pps", "save")
 949        ```
 950
 951        Args:
 952            settings_file_name: Settings file name, at most 15 ASCII
 953                characters including the required `.pps` extension.
 954            op: `"save"`, `"load"`, or `"delete"`.
 955
 956        Raises:
 957            PulsePalError: If the file name has no `.pps` extension or
 958                is too long, `op` is not one of the three operations, or
 959                the device does not acknowledge the command.
 960        """
 961        if ".pps" not in settings_file_name:
 962            raise PulsePalError(
 963                "Error: The file name must have a valid .pps extension."
 964            )
 965        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
 966        try:
 967            op_byte = op_byte_by_name[str(op).lower()]
 968        except KeyError as exc:
 969            raise PulsePalError(
 970                "File op must be: 'save', 'load' or 'delete'."
 971            ) from exc
 972
 973        filename_bytes = settings_file_name.encode("ascii")
 974        if len(filename_bytes) > 15:
 975            raise PulsePalError("settings_file_name is too long.")
 976        self._write_serial(
 977            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
 978            "uint8",
 979            list(filename_bytes),
 980            "uint8",
 981        )
 982        if self.info.firmware_version > 21:
 983            self._read_ack("sd_settings()")
 984        if op_byte == 2:
 985            time.sleep(0.1)
 986            self.sync_from_device()
 987
 988    def stop(self):
 989        """Stop all pulse trains currently playing on the device.
 990
 991        Every output channel returns to its
 992        `PulsePalDevice.resting_voltage`.
 993        """
 994        self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
 995
 996    def format_microsd(self, timeout=30):
 997        """Format the device's microSD card.
 998
 999        Erases every settings file stored on the device and resets its
1000        parameters to the defaults. The user is prompted at the console
1001        to confirm before anything is erased.
1002
1003        Requires Pulse Pal hardware v3 or newer.
1004
1005        Args:
1006            timeout: Seconds to wait for the device to report that
1007                formatting has finished.
1008
1009        Returns:
1010            `None` once the card has been formatted, or an empty string
1011            if the user declines the confirmation prompt.
1012
1013        Raises:
1014            PulsePalError: If the connected hardware is older than v3.
1015        """
1016        if self.info.hardware_version < 3:
1017            raise PulsePalError(
1018                "format_microsd() requires hardware v3 or newer."
1019            )
1020
1021        print("*** Pulse Pal microSD Formatter ***")
1022        print("This will format Pulse Pal's microSD card,")
1023        print("erase all settings files on the device")
1024        print("and reset all parameters to defaults.")
1025
1026        reply = input("Do you want to continue (y/n)")
1027
1028        if reply.strip().lower() != "y":
1029            print("Choice confirmed - microSD Card NOT formatted.")
1030            return ""
1031
1032        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1033
1034        start = time.time()
1035        message = bytearray()
1036
1037        while time.time() - start < timeout:
1038            n_waiting = self.bytes_available()
1039            if n_waiting:
1040                message.extend(self.port.read(n_waiting))
1041                if ord("!") in message:
1042                    break
1043            time.sleep(0.01)
1044        raw_message = bytes(message)
1045        flag_index = raw_message.find(b"!")
1046        if flag_index >= 0:
1047            displayed_message = raw_message[:flag_index]
1048        else:
1049            displayed_message = raw_message
1050
1051        text = displayed_message.decode("ascii", errors="replace").rstrip()
1052        if text:
1053            print(text)
1054
1055        self.set_default_params()
1056        return None
1057
1058    def gui(self, block=None, theme=None):
1059        """Open the Pulse Pal parameter GUI, or focus an open one.
1060
1061        The GUI edits its own copy of the parameters, and loads them to
1062        the device when its 'Load to Device' button is clicked. The
1063        window closes automatically when the device is closed or
1064        deleted.
1065
1066        Calling this while the GUI is already open focuses the existing
1067        window rather than opening a second one.
1068
1069        Args:
1070            block: If `True`, the call returns when the GUI is closed. If
1071                `False`, the call returns immediately, and the host
1072                application must run the Tk event loop. If `None`, the
1073                GUI blocks only when the host does not already provide a
1074                Tk event loop, e.g. when launched from a script.
1075            theme: `"light"` or `"dark"` to select the color theme, or
1076                `None` to match the desktop theme. Passing a theme to an
1077                already-open GUI recolors it in place.
1078
1079        Returns:
1080            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1081
1082        Raises:
1083            ValueError: If the theme name is not recognized.
1084        """
1085        gui = getattr(self, "_gui", None)
1086        if gui is not None and not gui.is_closed:
1087            if theme is not None:
1088                gui.set_theme(theme)
1089            gui.focus()
1090            return gui
1091
1092        try:
1093            from .PulsePalGUI import PulsePalGUI
1094        except ImportError:
1095            from PulsePalGUI import PulsePalGUI
1096
1097        gui = PulsePalGUI(self, theme=theme)
1098        self._gui = gui
1099        gui.start(block=block)
1100        return gui
1101
1102    def close(self, send_disconnect=True):
1103        """Close the connection to the device, and the GUI if open.
1104
1105        Safe to call more than once; later calls do nothing. Called
1106        automatically when leaving a `with` block and when the object is
1107        garbage collected.
1108
1109        Args:
1110            send_disconnect: If `True`, tell the device that the client
1111                is disconnecting before closing the port. Set to `False`
1112                when the device is in an unknown state, such as after a
1113                failed handshake.
1114        """
1115        gui = getattr(self, "_gui", None)
1116        self._gui = None
1117        if gui is not None:
1118            try:
1119                gui.close()
1120            except Exception:
1121                # Cleanup must not raise; Tk may already be torn down
1122                pass
1123
1124        if getattr(self, "_closed", True):
1125            return
1126
1127        try:
1128            if send_disconnect and self.port and self.port.is_open:
1129                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1130        finally:
1131            if self.port and self.port.is_open:
1132                self.port.close()
1133            self._closed = True
1134
1135    def bytes_available(self):
1136        """Return the number of bytes waiting in the serial read buffer.
1137
1138        Returns:
1139            Count of bytes that can be read without blocking.
1140        """
1141        return self.port.in_waiting
1142
1143    def _get_output_param_code(self, param_name):
1144        """Resolve an output parameter name or code to its code."""
1145        if isinstance(param_name, str):
1146            try:
1147                return self.info.output_parameter_names.index(param_name) + 1
1148            except ValueError as exc:
1149                raise PulsePalError(
1150                    f"Unknown output parameter: {param_name}."
1151                ) from exc
1152        return int(param_name)
1153
1154    def _get_trigger_param_code(self, param_name):
1155        """Resolve a trigger parameter name or code to its code."""
1156        if isinstance(param_name, str):
1157            try:
1158                index = self.info.trigger_parameter_names.index(param_name)
1159                return index + 128
1160            except ValueError as exc:
1161                raise PulsePalError(
1162                    f"Unknown trigger parameter: {param_name}."
1163                ) from exc
1164        return int(param_name)
1165
1166    def _write_serial(self, *args):
1167        """Write one or more data/type pairs to the serial port."""
1168        if len(args) % 2 != 0:
1169            raise PulsePalError(
1170                "Serial writes require data/type argument pairs."
1171            )
1172
1173        payload = bytearray()
1174        for i in range(0, len(args), 2):
1175            payload.extend(self._pack_values(args[i], args[i + 1]))
1176
1177        bytes_written = self.port.write(bytes(payload))
1178        if bytes_written != len(payload):
1179            raise PulsePalError(
1180                f"Error: wrote {bytes_written} byte(s), expected to write "
1181                f"{len(payload)} byte(s)."
1182            )
1183
1184    def _read_serial(self, n_values, datatype):
1185        """Read values from the serial port and unpack them with struct."""
1186        datatype = self._normalize_datatype(datatype)
1187        fmt = self._STRUCT_FORMATS[datatype]
1188        n_values = int(n_values)
1189        n_bytes = n_values * struct.calcsize(fmt)
1190        message_bytes = self.port.read(n_bytes)
1191        if len(message_bytes) < n_bytes:
1192            raise PulsePalError(
1193                f"Error: serial port timed out. "
1194                f"{len(message_bytes)} byte(s) read. "
1195                f"Expected {n_bytes} byte(s)."
1196            )
1197
1198        values = struct.unpack(
1199            f"{self._ENDIANNESS}{n_values}{fmt}",
1200            message_bytes,
1201        )
1202        if n_values == 1:
1203            return values[0]
1204        return list(values)
1205
1206    def _read_ack(self, context):
1207        """Read a one-byte acknowledgement from the device."""
1208        try:
1209            self._read_serial(1, "uint8")
1210        except PulsePalError as exc:
1211            raise PulsePalError(
1212                "Error: Pulse Pal did not return an acknowledgement byte "
1213                f"after a call to {context}."
1214            ) from exc
1215
1216    def _pack_values(self, values, datatype):
1217        """Pack scalar, list/tuple, or NumPy array values into bytes."""
1218        datatype = self._normalize_datatype(datatype)
1219        fmt = self._STRUCT_FORMATS[datatype]
1220        values_list = self._as_list(values)
1221
1222        if datatype == "char":
1223            values_list = self._normalize_char_values(values_list)
1224        elif datatype in self._TYPE_RANGES:
1225            values_list = self._normalize_int_values(values_list, datatype)
1226        else:
1227            values_list = [float(value) for value in values_list]
1228
1229        return struct.pack(
1230            f"{self._ENDIANNESS}{len(values_list)}{fmt}",
1231            *values_list,
1232        )
1233
1234    def _normalize_datatype(self, datatype):
1235        """Return the datatype name, rejecting unsupported types."""
1236        datatype = str(datatype)
1237        if datatype not in self._STRUCT_FORMATS:
1238            raise PulsePalError(
1239                f"Error: {datatype} is not a data type supported by "
1240                "PulsePalObject."
1241            )
1242        return datatype
1243
1244    def _normalize_char_values(self, values):
1245        """Coerce str, int and bytes values to single ASCII bytes."""
1246        normalized = []
1247        for value in values:
1248            if isinstance(value, str):
1249                value = value.encode("ascii")
1250            if isinstance(value, int):
1251                value = bytes((value,))
1252            if not isinstance(value, (bytes, bytearray)) or len(value) != 1:
1253                raise PulsePalError(
1254                    "char values must be one-byte bytes, chars, or "
1255                    "integers."
1256                )
1257            normalized.append(bytes(value))
1258        return normalized
1259
1260    def _normalize_int_values(self, values, datatype):
1261        """Coerce values to ints, rejecting any out of range."""
1262        min_value, max_value = self._TYPE_RANGES[datatype]
1263        normalized = []
1264        for value in values:
1265            value = int(value)
1266            if not min_value <= value <= max_value:
1267                raise PulsePalError(
1268                    f"Value {value} is out of range for {datatype} "
1269                    f"({min_value} to {max_value})."
1270                )
1271            normalized.append(value)
1272        return normalized
1273
1274    def _as_list(self, values):
1275        """Return values as a flat list, wrapping scalars in one."""
1276        if isinstance(values, np.ndarray):
1277            return values.ravel().tolist()
1278        if isinstance(values, (bytes, bytearray, str)):
1279            return [values]
1280        if isinstance(values, numbers.Number) or isinstance(values, Decimal):
1281            return [values]
1282        try:
1283            return list(values)
1284        except TypeError:
1285            return [values]
1286
1287    def _set_output_param_value(self, param_code, channel, original_value):
1288        """Store a programmed value in its local parameter array."""
1289        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1290        if attr_name is None:
1291            return
1292        values = getattr(self, attr_name)
1293        values[channel] = original_value
1294
1295    def _to_decimal(self, value):
1296        """Convert a value to a Decimal with PulsePal precision."""
1297        return Decimal(value).quantize(Decimal("1.0000"))
1298
1299    def _volts_to_bits(self, value):
1300        """Convert -10 V to +10 V to the corresponding DAC bit value."""
1301        normalized = (float(value) + 10) / 20
1302        bit_max = int(self._dac_bit_max)
1303        return int(min(max(round(normalized * bit_max), 0), bit_max))
1304
1305    def _bits_to_volts(self, value):
1306        """Convert a DAC code to volts, snapping clean values within 1 LSB."""
1307        bit_max = int(self._dac_bit_max)
1308        raw_volts = (float(value) / bit_max * 20) - 10
1309
1310        # Calculate the voltage of 1 bit
1311        lsb_volts = 20.0 / bit_max
1312
1313        # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255)
1314        clean_volts = round(raw_volts, 3)
1315
1316        # If the raw voltage is within 1 bit of the clean voltage, snap to it
1317        if abs(raw_volts - clean_volts) <= lsb_volts:
1318            return clean_volts
1319
1320        # Otherwise, return the standard 4-decimal reading
1321        return round(raw_volts, 4)
1322
1323    def _seconds_to_cycles(self, value):
1324        """Convert seconds to the corresponding refresh-cycle count."""
1325        return int(round(float(value) * float(self.info.cycle_frequency)))
1326
1327    def _cycles_to_seconds(self, value):
1328        """Convert hardware timer cycle counts to seconds."""
1329        return float(value) / float(self.info.cycle_frequency)
1330
1331    def _require_firmware(self, minimum_version, context):
1332        """Raise unless the device firmware is new enough for an op."""
1333        if (
1334            self.info.firmware_version is None
1335            or self.info.firmware_version < minimum_version
1336        ):
1337            raise PulsePalError(
1338                f"{context} requires firmware v{minimum_version} or newer. "
1339                f"Detected firmware is v{self.info.firmware_version}."
1340            )
1341
1342    def _sync_all_params(self):
1343        """Send all parameters using the packed sync op (firmware v22+).
1344
1345        Values are grouped by width so that the whole program travels as
1346        one uint32 block, one uint16 block and one uint8 block.
1347        """
1348        time_values = []
1349        for attr_name in (
1350            "phase1_duration",
1351            "inter_phase_interval",
1352            "phase2_duration",
1353            "inter_pulse_interval",
1354            "burst_duration",
1355            "inter_burst_interval",
1356            "pulse_train_duration",
1357            "pulse_train_delay",
1358        ):
1359            time_values.extend(
1360                self._seconds_to_cycles(getattr(self, attr_name)[channel])
1361                for channel in range(1, 5)
1362            )
1363
1364        voltage_values = []
1365        for attr_name in (
1366            "phase1_voltage",
1367            "phase2_voltage",
1368            "resting_voltage",
1369        ):
1370            voltage_values.extend(
1371                self._volts_to_bits(getattr(self, attr_name)[channel])
1372                for channel in range(1, 5)
1373            )
1374
1375        single_byte_values = []
1376        for attr_name in (
1377            "is_biphasic",
1378            "custom_train_id",
1379            "custom_train_target",
1380            "custom_train_loop",
1381        ):
1382            single_byte_values.extend(
1383                int(getattr(self, attr_name)[channel])
1384                for channel in range(1, 5)
1385            )
1386        single_byte_values.extend(
1387            int(self.link_trigger_channel1[channel])
1388            for channel in range(1, 5)
1389        )
1390        single_byte_values.extend(
1391            int(self.link_trigger_channel2[channel])
1392            for channel in range(1, 5)
1393        )
1394        single_byte_values.extend(
1395            int(value) for value in self.trigger_mode[1:3]
1396        )
1397
1398        self._write_serial(
1399            (self._OP_MENU_BYTE, 92),
1400            "uint8",
1401            time_values,
1402            "uint32",
1403            voltage_values,
1404            "uint16",
1405            single_byte_values,
1406            "uint8",
1407        )
1408
1409    def _sync_all_params_legacy(self):
1410        """Send all parameters using the legacy sync op (firmware v21).
1411
1412        Equivalent to `_sync_all_params`, but lays the program out
1413        channel by channel as the older firmware expects.
1414        """
1415        program_values_16 = []
1416        program_values_32 = []
1417        program_values_8 = [0] * 16
1418
1419        for channel in range(1, 5):
1420            program_values_32.extend(
1421                [
1422                    self._seconds_to_cycles(
1423                        self.phase1_duration[channel]),
1424                    self._seconds_to_cycles(
1425                        self.inter_phase_interval[channel]),
1426                    self._seconds_to_cycles(
1427                        self.phase2_duration[channel]),
1428                    self._seconds_to_cycles(
1429                        self.inter_pulse_interval[channel]),
1430                    self._seconds_to_cycles(
1431                        self.burst_duration[channel]),
1432                    self._seconds_to_cycles(
1433                        self.inter_burst_interval[channel]),
1434                    self._seconds_to_cycles(
1435                        self.pulse_train_duration[channel]),
1436                    self._seconds_to_cycles(
1437                        self.pulse_train_delay[channel]),
1438                ]
1439            )
1440
1441        for channel in range(1, 5):
1442            program_values_16.extend(
1443                [
1444                    self._volts_to_bits(self.phase1_voltage[channel]),
1445                    self._volts_to_bits(self.phase2_voltage[channel]),
1446                    self._volts_to_bits(self.resting_voltage[channel]),
1447                ]
1448            )
1449
1450        position = 0
1451        for channel in range(1, 5):
1452            program_values_8[position] = self.is_biphasic[channel]
1453            position += 1
1454            program_values_8[position] = self.custom_train_id[channel]
1455            position += 1
1456            program_values_8[position] = self.custom_train_target[channel]
1457            position += 1
1458            program_values_8[position] = self.custom_train_loop[channel]
1459            position += 1
1460
1461        program_values_tl = [0] * 8
1462        position = 0
1463        for channel in range(1, 5):
1464            program_values_tl[position] = self.link_trigger_channel1[channel]
1465            position += 1
1466        for channel in range(1, 5):
1467            program_values_tl[position] = self.link_trigger_channel2[channel]
1468            position += 1
1469
1470        self._write_serial(
1471            (self._OP_MENU_BYTE, 73),
1472            "uint8",
1473            program_values_32,
1474            "uint32",
1475            program_values_16,
1476            "uint16",
1477            program_values_8,
1478            "uint8",
1479            program_values_tl,
1480            "uint8",
1481            self.trigger_mode[1:3],
1482            "uint8",
1483        )
1484
1485    def __enter__(self):
1486        """Enter a `with` block, returning the connected device."""
1487        return self
1488
1489    def __exit__(self, exc_type, exc_value, traceback):
1490        """Disconnect and close the port when leaving a `with` block.
1491
1492        Returns:
1493            `False`, so any exception raised in the block propagates.
1494        """
1495        try:
1496            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1497        except Exception:
1498            pass
1499        self.close()
1500        return False
1501
1502    def __del__(self):
1503        """Disconnect and close the port when the object is collected."""
1504        try:
1505            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1506        except Exception:
1507            pass
1508
1509        try:
1510            self.close()
1511        except Exception:
1512            # Destructors should not raise; the serial object may already be
1513            # gone during interpreter shutdown.
1514            pass
class PulsePalDevice:
 154class PulsePalDevice:
 155    """A class to control a Pulse Pal device on a USB serial port.
 156
 157    Creating an instance opens the serial port, exchanges a handshake
 158    with the device, verifies that its firmware is supported, reads the
 159    device properties into `PulsePalDevice.info`, and programs the
 160    device with the default parameters.
 161
 162    ```python
 163    from PulsePal import PulsePalDevice
 164
 165    P = PulsePalDevice("COM3")
 166    P.set_output_param("phase1_voltage", 1, 5)
 167    P.trigger(1)
 168    P.close()
 169    ```
 170
 171    The class is also a context manager, which closes the connection on
 172    exit even if an error is raised:
 173
 174    ```python
 175    with PulsePalDevice("COM3") as P:
 176        P.trigger(1)
 177    ```
 178
 179    The attributes below named after Pulse Pal parameters are the local
 180    copy of the device's program. Each is a five element list indexed by
 181    channel number, with index 0 unused. Assigning to them does not
 182    reach the device until `PulsePalDevice.sync_to_device` is called;
 183    `PulsePalDevice.set_output_param` programs one parameter right away.
 184    """
 185
 186    port: "serial.Serial"
 187    """The open `serial.Serial` port connected to the device."""
 188
 189    info: DeviceInfo
 190    """Properties of the connected device. See `DeviceInfo`."""
 191
 192    is_biphasic: list
 193    """Pulse shape per channel: `0` for monophasic, `1` for biphasic.
 194
 195    Monophasic pulses use only the phase 1 parameters. Biphasic pulses
 196    follow phase 1 with `PulsePalDevice.inter_phase_interval` and then
 197    phase 2.
 198    """
 199
 200    phase1_voltage: list
 201    """Voltage of the first phase of each pulse, in volts [-10, 10]."""
 202
 203    phase2_voltage: list
 204    """Voltage of the second phase of each pulse, in volts [-10, 10].
 205
 206    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 207    """
 208
 209    resting_voltage: list
 210    """Voltage held between pulses, in volts [-10, 10]."""
 211
 212    phase1_duration: list
 213    """Duration of the first phase of each pulse, in seconds."""
 214
 215    inter_phase_interval: list
 216    """Interval between the two phases of a biphasic pulse, in seconds.
 217
 218    The channel rests at `PulsePalDevice.resting_voltage` during the
 219    interval. Used only when `PulsePalDevice.is_biphasic` is `1`.
 220    """
 221
 222    phase2_duration: list
 223    """Duration of the second phase of each pulse, in seconds.
 224
 225    Used only when `PulsePalDevice.is_biphasic` is `1` for the channel.
 226    """
 227
 228    inter_pulse_interval: list
 229    """Interval from the end of one pulse to the onset of the next, in
 230    seconds."""
 231
 232    burst_duration: list
 233    """Duration of each burst of pulses, in seconds.
 234
 235    Set to `0` to disable bursts, so that pulses continue for the whole
 236    pulse train.
 237    """
 238
 239    inter_burst_interval: list
 240    """Interval between bursts of pulses, in seconds.
 241
 242    The channel rests at `PulsePalDevice.resting_voltage` between
 243    bursts. Ignored when `PulsePalDevice.burst_duration` is `0`.
 244    """
 245
 246    pulse_train_duration: list
 247    """Total duration of the pulse train, in seconds."""
 248
 249    pulse_train_delay: list
 250    """Delay from the trigger to the onset of the pulse train, in
 251    seconds."""
 252
 253    link_trigger_channel1: list
 254    """Whether each output channel is linked to trigger channel 1.
 255
 256    `1` links the output channel to trigger channel 1, `0` unlinks it.
 257    """
 258
 259    link_trigger_channel2: list
 260    """Whether each output channel is linked to trigger channel 2.
 261
 262    `1` links the output channel to trigger channel 2, `0` unlinks it.
 263    """
 264
 265    custom_train_id: list
 266    """Custom pulse train played by each output channel.
 267
 268    `0` plays the parametrically defined train. `1` or higher plays the
 269    matching custom train, previously loaded with
 270    `PulsePalDevice.send_custom_pulse_train` or
 271    `PulsePalDevice.send_custom_waveform`.
 272    """
 273
 274    custom_train_target: list
 275    """What the timestamps of a custom train mark.
 276
 277    `0` if each timestamp is the onset of a pulse, `1` if each timestamp
 278    is the onset of a burst of pulses.
 279    """
 280
 281    custom_train_loop: list
 282    """Whether a custom train repeats.
 283
 284    `1` loops the custom train until
 285    `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it
 286    once.
 287    """
 288
 289    trigger_mode: list
 290    """Response of each trigger channel to an incoming TTL pulse.
 291
 292    Three element list indexed by trigger channel, with index 0 unused.
 293    Elements 1 and 2 control the respective channels on the device.
 294    Their values can be:
 295
 296    - `0` (normal): a TTL rising edge starts the pulse train, and edges
 297      during the train are ignored.
 298    - `1` (toggle): same as 0 but a TTL rising edge during the train stops it.
 299    - `2` (pulse gated): the train runs only while the trigger TTL is high.
 300    """
 301
 302    _CURRENT_FIRMWARE_VERSION = 22
 303
 304    _OP_MENU_BYTE = 213
 305    _HANDSHAKE_OPCODE = 72
 306    _HANDSHAKE_RESPONSE = 75
 307    _DAC_BITMAX = 65535
 308    _OLDEST_FIRMWARE_SUPPORTED = 21
 309
 310    _OUTPUT_PARAMETER_NAMES = (
 311        "is_biphasic",
 312        "phase1_voltage",
 313        "phase2_voltage",
 314        "phase1_duration",
 315        "inter_phase_interval",
 316        "phase2_duration",
 317        "inter_pulse_interval",
 318        "burst_duration",
 319        "inter_burst_interval",
 320        "pulse_train_duration",
 321        "pulse_train_delay",
 322        "link_trigger_channel1",
 323        "link_trigger_channel2",
 324        "custom_train_id",
 325        "custom_train_target",
 326        "custom_train_loop",
 327        "resting_voltage",
 328    )
 329    _TRIGGER_PARAMETER_NAMES = ("trigger_mode",)
 330
 331    _OUTPUT_PARAMETER_ATTRS = {
 332        1: "is_biphasic",
 333        2: "phase1_voltage",
 334        3: "phase2_voltage",
 335        4: "phase1_duration",
 336        5: "inter_phase_interval",
 337        6: "phase2_duration",
 338        7: "inter_pulse_interval",
 339        8: "burst_duration",
 340        9: "inter_burst_interval",
 341        10: "pulse_train_duration",
 342        11: "pulse_train_delay",
 343        12: "link_trigger_channel1",
 344        13: "link_trigger_channel2",
 345        14: "custom_train_id",
 346        15: "custom_train_target",
 347        16: "custom_train_loop",
 348        17: "resting_voltage",
 349    }
 350    _ENDIANNESS = "<"
 351    _STRUCT_FORMATS = {
 352        "uint8": "B",
 353        "int8": "b",
 354        "char": "c",
 355        "uint16": "H",
 356        "int16": "h",
 357        "uint32": "I",
 358        "int32": "i",
 359        "single": "f",
 360        "double": "d",
 361    }
 362    _TYPE_RANGES = {
 363        "uint8": (0, 2**8 - 1),
 364        "int8": (-(2**7), 2**7 - 1),
 365        "uint16": (0, 2**16 - 1),
 366        "int16": (-(2**15), 2**15 - 1),
 367        "uint32": (0, 2**32 - 1),
 368        "int32": (-(2**31), 2**31 - 1),
 369    }
 370
 371    def __init__(self, port_name, baud_rate=12000000, timeout=10):
 372        """Open a connection to a Pulse Pal device.
 373
 374        Opens the serial port, exchanges the handshake, verifies the
 375        firmware version, reads the device properties into
 376        `PulsePalDevice.info`, and programs the device with the default
 377        parameters.
 378
 379        Args:
 380            port_name: USB serial port for the Pulse Pal device, such as
 381                `COM3` on Windows or `/dev/ttyACM0` on Linux.
 382            baud_rate: Serial baud rate.
 383            timeout: Serial read timeout, in seconds.
 384
 385        Raises:
 386            PulsePalError: If the device does not return the expected
 387                handshake, or its firmware is older than v21, or its
 388                firmware is newer than this module supports.
 389            serial.SerialException: If the serial port cannot be opened.
 390        """
 391        self.info = DeviceInfo()
 392        self._gui = None
 393        self.port = serial.Serial(
 394            port_name,
 395            baud_rate,
 396            timeout=timeout,
 397            rtscts=True,
 398        )
 399        self._closed = False
 400        self._dac_bit_max = self._to_decimal(0)
 401        self.info.firmware_version = None
 402        self.info.hardware_version = None
 403        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
 404        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
 405
 406        self._write_serial(
 407            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
 408            "uint8",
 409        )
 410        handshake = self._read_serial(1, "uint8")
 411        if handshake != self._HANDSHAKE_RESPONSE:
 412            self.close(send_disconnect=False)
 413            raise PulsePalError(
 414                "Error: incorrect handshake returned. Expected "
 415                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
 416            )
 417
 418        firmware_version = self._read_serial(1, "uint32")
 419        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
 420            raise PulsePalError(
 421                "Error: Old firmware detected, v"
 422                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
 423                "newer is required."
 424            )
 425        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
 426            raise PulsePalError(
 427                "Error: Future firmware detected, v"
 428                f"{firmware_version}. Please update PulsePal.py or downgrade "
 429                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
 430            )
 431        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
 432            print(
 433                "Old firmware detected, v"
 434                f"{firmware_version}. This firmware is supported. Update to v"
 435                f"{self._CURRENT_FIRMWARE_VERSION} is available."
 436            )
 437        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
 438        self.info.firmware_version = firmware_version
 439
 440        if self.info.firmware_version > 21:
 441            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
 442            self.info.hardware_version = self._read_serial(1, "uint8")
 443            self.info.cycle_period_us = self._read_serial(1, "uint32")
 444            self.info.cycle_frequency = 1 / (
 445                self.info.cycle_period_us / 1000000
 446            )
 447            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
 448            self.info.max_custom_pulses = self._read_serial(1, "uint32")
 449        else:
 450            self.info.hardware_version = 2
 451            self.info.cycle_period_us = 50
 452            self.info.cycle_frequency = 20000
 453            self.info.n_custom_pulse_trains = 2
 454            self.info.max_custom_pulses = 5000
 455
 456        # Client name op + "PYTHON" in ASCII.
 457        self._write_serial(
 458            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
 459            "uint8",
 460        )
 461
 462        self.set_default_params()
 463        self.sync_to_device()
 464
 465    def set_default_params(self):
 466        """Reset the local copy of all parameters to their defaults.
 467
 468        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
 469        1 second, on all four output channels, linked to trigger channel
 470        1 in normal trigger mode.
 471
 472        This updates only the local copy. Call
 473        `PulsePalDevice.sync_to_device` to program the device with them.
 474        """
 475        nan = float("nan")
 476        self.is_biphasic = [nan, 0, 0, 0, 0]
 477        self.phase1_voltage = [nan, 5, 5, 5, 5]
 478        self.phase2_voltage = [nan, -5, -5, -5, -5]
 479        self.resting_voltage = [nan, 0, 0, 0, 0]
 480        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 481        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
 482        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
 483        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
 484        self.burst_duration = [nan, 0, 0, 0, 0]
 485        self.inter_burst_interval = [nan, 0, 0, 0, 0]
 486        self.pulse_train_duration = [nan, 1, 1, 1, 1]
 487        self.pulse_train_delay = [nan, 0, 0, 0, 0]
 488        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
 489        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
 490        self.custom_train_id = [nan, 0, 0, 0, 0]
 491        self.custom_train_target = [nan, 0, 0, 0, 0]
 492        self.custom_train_loop = [nan, 0, 0, 0, 0]
 493        self.trigger_mode = [nan, 0, 0]
 494
 495    def set_voltage(self, channel, voltage):
 496        """Set an output channel to a fixed voltage.
 497
 498        The channel holds the voltage until it is set again or until a
 499        pulse train is triggered on it.
 500
 501        Args:
 502            channel: Output channel number, 1-4.
 503            voltage: Voltage to set, in volts [-10, 10].
 504
 505        Raises:
 506            PulsePalError: If the device does not acknowledge the
 507                command.
 508        """
 509        voltage_bits = self._volts_to_bits(voltage)
 510        self._write_serial(
 511            (self._OP_MENU_BYTE, 79, channel),
 512            "uint8",
 513            voltage_bits,
 514            "uint16",
 515        )
 516        self._read_ack("set_fixed_voltage()")
 517
 518    def set_calibration(self, channel, voltage_offset):
 519        """Calibrate the zero code of an output channel.
 520
 521        The offset is added to every voltage the channel produces, to
 522        correct for DAC offset error. It is stored in the device's
 523        EEPROM and reloaded on boot, so it only needs to be set once.
 524
 525        Requires Pulse Pal hardware v3 or newer.
 526
 527        Args:
 528            channel: Output channel number, 1-4.
 529            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
 530
 531        Raises:
 532            PulsePalError: If the connected hardware is older than v3,
 533                or the device does not acknowledge the command.
 534            ValueError: If `channel` is not 1-4, or `voltage_offset` is
 535                outside [-0.1, 0.1].
 536        """
 537        if self.info.hardware_version < 3:
 538            raise PulsePalError(
 539                "set_calibration() requires hardware v3 or newer."
 540            )
 541        if channel not in (1, 2, 3, 4):
 542            raise ValueError("channel must be 1, 2, 3 or 4")
 543        if voltage_offset < -0.1 or voltage_offset > 0.1:
 544            raise ValueError(
 545                "voltage_offset for zero code calibration must be in range "
 546                "[-0.1, 0.1]"
 547            )
 548        voltage_bits = voltage_offset * (1 / (20 / 65536))
 549        self._write_serial(
 550            (self._OP_MENU_BYTE, 96, channel - 1),
 551            "uint8",
 552            voltage_bits,
 553            "int16",
 554        )
 555        self._read_ack("set_calibration()")
 556
 557    def set_output_param(self, param_name, channel, value):
 558        """Program a single output channel parameter on the device.
 559
 560        The local copy of the parameter is updated to match, so a later
 561        `PulsePalDevice.sync_to_device` will not undo the change.
 562
 563        ```python
 564        P.set_output_param("is_biphasic", 1, 1)
 565        P.set_output_param("phase1_voltage", 1, 10)
 566        P.set_output_param(3, 1, -10)   # same, by param code
 567        ```
 568
 569        Args:
 570            param_name: Parameter name, as listed in
 571                `DeviceInfo.output_parameter_names`, or its integer
 572                parameter code.
 573            channel: Output channel number, 1-4.
 574            value: Value to set. Units are volts for voltage parameters,
 575                seconds for time parameters, and integers for enumerated
 576                parameters. See the attributes of `PulsePalDevice` for
 577                the meaning of each parameter.
 578
 579        Raises:
 580            PulsePalError: If the parameter name is not recognized, the
 581                value does not fit the datatype the device expects, or
 582                the device does not acknowledge the command.
 583        """
 584        original_value = value
 585        param_code = self._get_output_param_code(param_name)
 586
 587        if param_code in (2, 3, 17):
 588            value = self._volts_to_bits(value)
 589            self._write_serial(
 590                (self._OP_MENU_BYTE, 74, param_code, channel),
 591                "uint8",
 592                value,
 593                "uint16",
 594            )
 595        elif 4 <= param_code <= 11:
 596            self._write_serial(
 597                (self._OP_MENU_BYTE, 74, param_code, channel),
 598                "uint8",
 599                self._seconds_to_cycles(value),
 600                "uint32",
 601            )
 602        else:
 603            self._write_serial(
 604                (self._OP_MENU_BYTE, 74, param_code, channel, value),
 605                "uint8",
 606            )
 607
 608        self._read_ack("program_output_channel_param()")
 609        self._set_output_param_value(param_code, channel, original_value)
 610
 611    def set_trigger_param(self, param_name, channel, value):
 612        """Program a single trigger channel parameter on the device.
 613
 614        The local copy of the parameter is updated to match, so a later
 615        `PulsePalDevice.sync_to_device` will not undo the change.
 616
 617        ```python
 618        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
 619        ```
 620
 621        Args:
 622            param_name: Parameter name, as listed in
 623                `DeviceInfo.trigger_parameter_names`, or its integer
 624                parameter code.
 625            channel: Trigger channel number, 1-2.
 626            value: Value to set. See `PulsePalDevice.trigger_mode` for
 627                the trigger modes.
 628
 629        Raises:
 630            PulsePalError: If the parameter name is not recognized, the
 631                value does not fit the datatype the device expects, or
 632                the device does not acknowledge the command.
 633        """
 634        original_value = value
 635        param_code = self._get_trigger_param_code(param_name)
 636
 637        self._write_serial(
 638            (self._OP_MENU_BYTE, 74, param_code, channel, value),
 639            "uint8",
 640        )
 641        self._read_ack("program_trigger_channel_param()")
 642
 643        if param_code in (1, 128):
 644            self.trigger_mode[channel] = original_value
 645
 646    def sync_to_device(self):
 647        """Program the device with the local copy of all parameters.
 648
 649        Call this after assigning to the parameter array attributes, to
 650        send every output and trigger parameter to the device in a
 651        single transaction.
 652
 653        ```python
 654        P.phase1_voltage[1:5] = [5] * 4
 655        P.sync_to_device()
 656        ```
 657
 658        Raises:
 659            PulsePalError: If the device does not acknowledge the
 660                command.
 661        """
 662        # _sync_all_params() for firmware v22+ uses the newer packed sync
 663        # opcode (92). _sync_all_params_legacy() uses the less efficient
 664        # legacy packed sync opcode (73).
 665        if self.info.firmware_version > 21:
 666            self._sync_all_params()
 667        else:
 668            self._sync_all_params_legacy()
 669        self._read_ack("sync_to_device()")
 670
 671    def sync_from_device(self):
 672        """Read all parameters from the device into the local copy.
 673
 674        Overwrites every parameter array attribute with the program
 675        currently stored on the device. Useful after the device has been
 676        reprogrammed from its thumb joystick.
 677
 678        Requires firmware v22 or newer.
 679
 680        Raises:
 681            PulsePalError: If the connected firmware is older than v22,
 682                or the device does not return the full parameter set.
 683        """
 684        self._require_firmware(22, "sync_from_device()")
 685        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
 686        for attr_name in (
 687                "phase1_duration",
 688                "inter_phase_interval",
 689                "phase2_duration",
 690                "inter_pulse_interval",
 691                "burst_duration",
 692                "inter_burst_interval",
 693                "pulse_train_duration",
 694                "pulse_train_delay",
 695        ):
 696            setattr(
 697                self,
 698                attr_name,
 699                [float("nan")]
 700                + [
 701                    self._cycles_to_seconds(x)
 702                    for x in self._read_serial(4, "uint32")
 703                ],
 704            )
 705
 706        for attr_name in (
 707                "phase1_voltage",
 708                "phase2_voltage",
 709                "resting_voltage",
 710        ):
 711            setattr(
 712                self,
 713                attr_name,
 714                [float("nan")]
 715                + [
 716                    self._bits_to_volts(x)
 717                    for x in self._read_serial(4, "uint16")
 718                ],
 719            )
 720
 721        for attr_name in (
 722                "is_biphasic",
 723                "custom_train_id",
 724                "custom_train_target",
 725                "custom_train_loop",
 726                "link_trigger_channel1",
 727                "link_trigger_channel2",
 728        ):
 729            setattr(
 730                self,
 731                attr_name,
 732                [float("nan")] + self._read_serial(4, "uint8"),
 733            )
 734        self.trigger_mode = [float("nan")] + self._read_serial(2, "uint8")
 735
 736    def send_custom_pulse_train(
 737        self,
 738        custom_train_id,
 739        pulse_times,
 740        pulse_voltages,
 741    ):
 742        """Load a custom pulse train onto the device.
 743
 744        A custom pulse train is an arbitrary list of pulse onset times
 745        and voltages, replacing the parametric pulse voltage and onset timing.
 746        Set `PulsePalDevice.custom_train_id` on an output channel to play
 747        the train there.
 748
 749        ```python
 750        P.send_custom_pulse_train(
 751            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
 752        )
 753        P.set_output_param("custom_train_id", 1, 2)
 754        ```
 755
 756        Args:
 757            custom_train_id: Custom train to load, 1-2. See
 758                `DeviceInfo.n_custom_pulse_trains`.
 759            pulse_times: Pulse onset times, in seconds, relative to the
 760                start of the train. Accepts a list, tuple or NumPy
 761                array.
 762            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
 763                Must be the same length as `pulse_times`.
 764
 765        Raises:
 766            PulsePalError: If `pulse_times` and `pulse_voltages` differ
 767                in length, or the device does not acknowledge the
 768                command.
 769        """
 770        pulse_times = self._as_list(pulse_times)
 771        pulse_voltages = self._as_list(pulse_voltages)
 772        n_pulses = len(pulse_times)
 773        if n_pulses != len(pulse_voltages):
 774            raise PulsePalError(
 775                "pulse_times and pulse_voltages must be the same length."
 776            )
 777
 778        pulse_times_cycles = [
 779            self._seconds_to_cycles(pulse_time)
 780            for pulse_time in pulse_times
 781        ]
 782        pulse_voltage_bits = [
 783            self._volts_to_bits(voltage)
 784            for voltage in pulse_voltages
 785        ]
 786
 787        op_code = int(custom_train_id) + 74
 788        self._write_serial(
 789            (self._OP_MENU_BYTE, op_code),
 790            "uint8",
 791            n_pulses,
 792            "uint32",
 793            pulse_times_cycles,
 794            "uint32",
 795            pulse_voltage_bits,
 796            "uint16",
 797        )
 798        self._read_ack("send_custom_pulse_train()")
 799
 800    def send_custom_waveform(
 801        self,
 802        custom_train_id,
 803        pulse_width,
 804        pulse_voltages,
 805    ):
 806        """Load an arbitrary waveform onto the device.
 807
 808        A convenience shorthand for
 809        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
 810        confluent pulses, so that `pulse_voltages` is played as a
 811        waveform sampled every `pulse_width` seconds.
 812
 813        Set the channel's `PulsePalDevice.phase1_duration` to
 814        `pulse_width` as well, so that each sample is held for the
 815        sampling period.
 816
 817        ```python
 818        import math
 819
 820        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
 821        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
 822        P.set_output_param("custom_train_id", 2, 1)
 823        P.set_output_param("phase1_duration", 2, 0.001)
 824        ```
 825
 826        Args:
 827            custom_train_id: Custom train to load, 1-2. See
 828                `DeviceInfo.n_custom_pulse_trains`.
 829            pulse_width: Sampling period, in seconds. Each voltage is
 830                held for this long.
 831            pulse_voltages: Waveform samples, in volts [-10, 10].
 832                Accepts a list, tuple or NumPy array.
 833
 834        Raises:
 835            PulsePalError: If the device does not acknowledge the
 836                command.
 837        """
 838        pulse_voltages = self._as_list(pulse_voltages)
 839        n_pulses = len(pulse_voltages)
 840        pulse_width_cycles = self._seconds_to_cycles(pulse_width)
 841        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
 842        pulse_voltage_bits = [
 843            self._volts_to_bits(voltage)
 844            for voltage in pulse_voltages
 845        ]
 846
 847        op_code = int(custom_train_id) + 74
 848        self._write_serial(
 849            (self._OP_MENU_BYTE, op_code),
 850            "uint8",
 851            n_pulses,
 852            "uint32",
 853            pulse_times,
 854            "uint32",
 855            pulse_voltage_bits,
 856            "uint16",
 857        )
 858        self._read_ack("send_custom_waveform()")
 859
 860    def set_continuous_loop(self, channel, state):
 861        """Set the continuous loop state of an output channel.
 862
 863        In continuous loop mode the channel repeats its pulse train
 864        indefinitely the next time it is triggered, until the mode is
 865        cleared or `PulsePalDevice.stop` is called.
 866
 867        Args:
 868            channel: Output channel number, 1-4.
 869            state: `1` for continuous loop, `0` for normal mode.
 870        """
 871        self._write_serial(
 872            (self._OP_MENU_BYTE, 82, channel, state),
 873            "uint8",
 874        )
 875
 876    def trigger(
 877        self,
 878        channel1=None,
 879        channel2=None,
 880        channel3=None,
 881        channel4=None,
 882    ):
 883        """Trigger output channels in software.
 884
 885        Triggered channels start their pulse trains together. Three
 886        calling conventions are accepted:
 887
 888        ```python
 889        P.trigger(1, 0, 1, 0)   # one flag per channel
 890        P.trigger(3)            # a single channel number
 891        P.trigger([1, 4])       # several channel numbers
 892        ```
 893
 894        Channel numbers outside 1-4 are ignored.
 895
 896        Args:
 897            channel1: `1` to trigger channel 1, otherwise `0`; or, when
 898                it is the only argument given, a single channel number
 899                or a list of channel numbers to trigger.
 900            channel2: `1` to trigger channel 2, otherwise `0`.
 901            channel3: `1` to trigger channel 3, otherwise `0`.
 902            channel4: `1` to trigger channel 4, otherwise `0`.
 903        """
 904        trigger_byte = 0
 905
 906        # Options 2 & 3: Only one argument was provided
 907        if channel2 is None and channel3 is None and channel4 is None:
 908            # Option 2: Single integer
 909            if isinstance(channel1, int):
 910                channels_to_trigger = [channel1]
 911            # Option 3: List/Tuple of integers
 912            elif isinstance(channel1, (list, tuple, set)):
 913                channels_to_trigger = channel1
 914            else:
 915                channels_to_trigger = []
 916
 917            # Use bitwise shifts to calculate the trigger byte
 918            # (ch1=bit0, ch2=bit1, etc.)
 919            for ch in channels_to_trigger:
 920                if 1 <= ch <= 4:
 921                    trigger_byte |= (1 << (ch - 1))
 922
 923        # Option 1: Original input scheme (logicals for each channel)
 924        else:
 925            # Fallback to 0 if an argument was omitted via kwargs
 926            c1 = channel1 if channel1 is not None else 0
 927            c2 = channel2 if channel2 is not None else 0
 928            c3 = channel3 if channel3 is not None else 0
 929            c4 = channel4 if channel4 is not None else 0
 930
 931            trigger_byte = (
 932                (1 * c1)
 933                + (2 * c2)
 934                + (4 * c3)
 935                + (8 * c4)
 936            )
 937
 938        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")
 939
 940    def sd_settings(self, settings_file_name, op):
 941        """Save, load, or delete a settings file on the microSD card.
 942
 943        A settings file holds a complete Pulse Pal program, so that it
 944        can be recalled later from software or from the device's front
 945        panel. Loading a file also refreshes the local copy of the
 946        parameters, via `PulsePalDevice.sync_from_device`.
 947
 948        ```python
 949        P.sd_settings("MyProtocol.pps", "save")
 950        ```
 951
 952        Args:
 953            settings_file_name: Settings file name, at most 15 ASCII
 954                characters including the required `.pps` extension.
 955            op: `"save"`, `"load"`, or `"delete"`.
 956
 957        Raises:
 958            PulsePalError: If the file name has no `.pps` extension or
 959                is too long, `op` is not one of the three operations, or
 960                the device does not acknowledge the command.
 961        """
 962        if ".pps" not in settings_file_name:
 963            raise PulsePalError(
 964                "Error: The file name must have a valid .pps extension."
 965            )
 966        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
 967        try:
 968            op_byte = op_byte_by_name[str(op).lower()]
 969        except KeyError as exc:
 970            raise PulsePalError(
 971                "File op must be: 'save', 'load' or 'delete'."
 972            ) from exc
 973
 974        filename_bytes = settings_file_name.encode("ascii")
 975        if len(filename_bytes) > 15:
 976            raise PulsePalError("settings_file_name is too long.")
 977        self._write_serial(
 978            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
 979            "uint8",
 980            list(filename_bytes),
 981            "uint8",
 982        )
 983        if self.info.firmware_version > 21:
 984            self._read_ack("sd_settings()")
 985        if op_byte == 2:
 986            time.sleep(0.1)
 987            self.sync_from_device()
 988
 989    def stop(self):
 990        """Stop all pulse trains currently playing on the device.
 991
 992        Every output channel returns to its
 993        `PulsePalDevice.resting_voltage`.
 994        """
 995        self._write_serial((self._OP_MENU_BYTE, 80), "uint8")
 996
 997    def format_microsd(self, timeout=30):
 998        """Format the device's microSD card.
 999
1000        Erases every settings file stored on the device and resets its
1001        parameters to the defaults. The user is prompted at the console
1002        to confirm before anything is erased.
1003
1004        Requires Pulse Pal hardware v3 or newer.
1005
1006        Args:
1007            timeout: Seconds to wait for the device to report that
1008                formatting has finished.
1009
1010        Returns:
1011            `None` once the card has been formatted, or an empty string
1012            if the user declines the confirmation prompt.
1013
1014        Raises:
1015            PulsePalError: If the connected hardware is older than v3.
1016        """
1017        if self.info.hardware_version < 3:
1018            raise PulsePalError(
1019                "format_microsd() requires hardware v3 or newer."
1020            )
1021
1022        print("*** Pulse Pal microSD Formatter ***")
1023        print("This will format Pulse Pal's microSD card,")
1024        print("erase all settings files on the device")
1025        print("and reset all parameters to defaults.")
1026
1027        reply = input("Do you want to continue (y/n)")
1028
1029        if reply.strip().lower() != "y":
1030            print("Choice confirmed - microSD Card NOT formatted.")
1031            return ""
1032
1033        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1034
1035        start = time.time()
1036        message = bytearray()
1037
1038        while time.time() - start < timeout:
1039            n_waiting = self.bytes_available()
1040            if n_waiting:
1041                message.extend(self.port.read(n_waiting))
1042                if ord("!") in message:
1043                    break
1044            time.sleep(0.01)
1045        raw_message = bytes(message)
1046        flag_index = raw_message.find(b"!")
1047        if flag_index >= 0:
1048            displayed_message = raw_message[:flag_index]
1049        else:
1050            displayed_message = raw_message
1051
1052        text = displayed_message.decode("ascii", errors="replace").rstrip()
1053        if text:
1054            print(text)
1055
1056        self.set_default_params()
1057        return None
1058
1059    def gui(self, block=None, theme=None):
1060        """Open the Pulse Pal parameter GUI, or focus an open one.
1061
1062        The GUI edits its own copy of the parameters, and loads them to
1063        the device when its 'Load to Device' button is clicked. The
1064        window closes automatically when the device is closed or
1065        deleted.
1066
1067        Calling this while the GUI is already open focuses the existing
1068        window rather than opening a second one.
1069
1070        Args:
1071            block: If `True`, the call returns when the GUI is closed. If
1072                `False`, the call returns immediately, and the host
1073                application must run the Tk event loop. If `None`, the
1074                GUI blocks only when the host does not already provide a
1075                Tk event loop, e.g. when launched from a script.
1076            theme: `"light"` or `"dark"` to select the color theme, or
1077                `None` to match the desktop theme. Passing a theme to an
1078                already-open GUI recolors it in place.
1079
1080        Returns:
1081            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1082
1083        Raises:
1084            ValueError: If the theme name is not recognized.
1085        """
1086        gui = getattr(self, "_gui", None)
1087        if gui is not None and not gui.is_closed:
1088            if theme is not None:
1089                gui.set_theme(theme)
1090            gui.focus()
1091            return gui
1092
1093        try:
1094            from .PulsePalGUI import PulsePalGUI
1095        except ImportError:
1096            from PulsePalGUI import PulsePalGUI
1097
1098        gui = PulsePalGUI(self, theme=theme)
1099        self._gui = gui
1100        gui.start(block=block)
1101        return gui
1102
1103    def close(self, send_disconnect=True):
1104        """Close the connection to the device, and the GUI if open.
1105
1106        Safe to call more than once; later calls do nothing. Called
1107        automatically when leaving a `with` block and when the object is
1108        garbage collected.
1109
1110        Args:
1111            send_disconnect: If `True`, tell the device that the client
1112                is disconnecting before closing the port. Set to `False`
1113                when the device is in an unknown state, such as after a
1114                failed handshake.
1115        """
1116        gui = getattr(self, "_gui", None)
1117        self._gui = None
1118        if gui is not None:
1119            try:
1120                gui.close()
1121            except Exception:
1122                # Cleanup must not raise; Tk may already be torn down
1123                pass
1124
1125        if getattr(self, "_closed", True):
1126            return
1127
1128        try:
1129            if send_disconnect and self.port and self.port.is_open:
1130                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1131        finally:
1132            if self.port and self.port.is_open:
1133                self.port.close()
1134            self._closed = True
1135
1136    def bytes_available(self):
1137        """Return the number of bytes waiting in the serial read buffer.
1138
1139        Returns:
1140            Count of bytes that can be read without blocking.
1141        """
1142        return self.port.in_waiting
1143
1144    def _get_output_param_code(self, param_name):
1145        """Resolve an output parameter name or code to its code."""
1146        if isinstance(param_name, str):
1147            try:
1148                return self.info.output_parameter_names.index(param_name) + 1
1149            except ValueError as exc:
1150                raise PulsePalError(
1151                    f"Unknown output parameter: {param_name}."
1152                ) from exc
1153        return int(param_name)
1154
1155    def _get_trigger_param_code(self, param_name):
1156        """Resolve a trigger parameter name or code to its code."""
1157        if isinstance(param_name, str):
1158            try:
1159                index = self.info.trigger_parameter_names.index(param_name)
1160                return index + 128
1161            except ValueError as exc:
1162                raise PulsePalError(
1163                    f"Unknown trigger parameter: {param_name}."
1164                ) from exc
1165        return int(param_name)
1166
1167    def _write_serial(self, *args):
1168        """Write one or more data/type pairs to the serial port."""
1169        if len(args) % 2 != 0:
1170            raise PulsePalError(
1171                "Serial writes require data/type argument pairs."
1172            )
1173
1174        payload = bytearray()
1175        for i in range(0, len(args), 2):
1176            payload.extend(self._pack_values(args[i], args[i + 1]))
1177
1178        bytes_written = self.port.write(bytes(payload))
1179        if bytes_written != len(payload):
1180            raise PulsePalError(
1181                f"Error: wrote {bytes_written} byte(s), expected to write "
1182                f"{len(payload)} byte(s)."
1183            )
1184
1185    def _read_serial(self, n_values, datatype):
1186        """Read values from the serial port and unpack them with struct."""
1187        datatype = self._normalize_datatype(datatype)
1188        fmt = self._STRUCT_FORMATS[datatype]
1189        n_values = int(n_values)
1190        n_bytes = n_values * struct.calcsize(fmt)
1191        message_bytes = self.port.read(n_bytes)
1192        if len(message_bytes) < n_bytes:
1193            raise PulsePalError(
1194                f"Error: serial port timed out. "
1195                f"{len(message_bytes)} byte(s) read. "
1196                f"Expected {n_bytes} byte(s)."
1197            )
1198
1199        values = struct.unpack(
1200            f"{self._ENDIANNESS}{n_values}{fmt}",
1201            message_bytes,
1202        )
1203        if n_values == 1:
1204            return values[0]
1205        return list(values)
1206
1207    def _read_ack(self, context):
1208        """Read a one-byte acknowledgement from the device."""
1209        try:
1210            self._read_serial(1, "uint8")
1211        except PulsePalError as exc:
1212            raise PulsePalError(
1213                "Error: Pulse Pal did not return an acknowledgement byte "
1214                f"after a call to {context}."
1215            ) from exc
1216
1217    def _pack_values(self, values, datatype):
1218        """Pack scalar, list/tuple, or NumPy array values into bytes."""
1219        datatype = self._normalize_datatype(datatype)
1220        fmt = self._STRUCT_FORMATS[datatype]
1221        values_list = self._as_list(values)
1222
1223        if datatype == "char":
1224            values_list = self._normalize_char_values(values_list)
1225        elif datatype in self._TYPE_RANGES:
1226            values_list = self._normalize_int_values(values_list, datatype)
1227        else:
1228            values_list = [float(value) for value in values_list]
1229
1230        return struct.pack(
1231            f"{self._ENDIANNESS}{len(values_list)}{fmt}",
1232            *values_list,
1233        )
1234
1235    def _normalize_datatype(self, datatype):
1236        """Return the datatype name, rejecting unsupported types."""
1237        datatype = str(datatype)
1238        if datatype not in self._STRUCT_FORMATS:
1239            raise PulsePalError(
1240                f"Error: {datatype} is not a data type supported by "
1241                "PulsePalObject."
1242            )
1243        return datatype
1244
1245    def _normalize_char_values(self, values):
1246        """Coerce str, int and bytes values to single ASCII bytes."""
1247        normalized = []
1248        for value in values:
1249            if isinstance(value, str):
1250                value = value.encode("ascii")
1251            if isinstance(value, int):
1252                value = bytes((value,))
1253            if not isinstance(value, (bytes, bytearray)) or len(value) != 1:
1254                raise PulsePalError(
1255                    "char values must be one-byte bytes, chars, or "
1256                    "integers."
1257                )
1258            normalized.append(bytes(value))
1259        return normalized
1260
1261    def _normalize_int_values(self, values, datatype):
1262        """Coerce values to ints, rejecting any out of range."""
1263        min_value, max_value = self._TYPE_RANGES[datatype]
1264        normalized = []
1265        for value in values:
1266            value = int(value)
1267            if not min_value <= value <= max_value:
1268                raise PulsePalError(
1269                    f"Value {value} is out of range for {datatype} "
1270                    f"({min_value} to {max_value})."
1271                )
1272            normalized.append(value)
1273        return normalized
1274
1275    def _as_list(self, values):
1276        """Return values as a flat list, wrapping scalars in one."""
1277        if isinstance(values, np.ndarray):
1278            return values.ravel().tolist()
1279        if isinstance(values, (bytes, bytearray, str)):
1280            return [values]
1281        if isinstance(values, numbers.Number) or isinstance(values, Decimal):
1282            return [values]
1283        try:
1284            return list(values)
1285        except TypeError:
1286            return [values]
1287
1288    def _set_output_param_value(self, param_code, channel, original_value):
1289        """Store a programmed value in its local parameter array."""
1290        attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code)
1291        if attr_name is None:
1292            return
1293        values = getattr(self, attr_name)
1294        values[channel] = original_value
1295
1296    def _to_decimal(self, value):
1297        """Convert a value to a Decimal with PulsePal precision."""
1298        return Decimal(value).quantize(Decimal("1.0000"))
1299
1300    def _volts_to_bits(self, value):
1301        """Convert -10 V to +10 V to the corresponding DAC bit value."""
1302        normalized = (float(value) + 10) / 20
1303        bit_max = int(self._dac_bit_max)
1304        return int(min(max(round(normalized * bit_max), 0), bit_max))
1305
1306    def _bits_to_volts(self, value):
1307        """Convert a DAC code to volts, snapping clean values within 1 LSB."""
1308        bit_max = int(self._dac_bit_max)
1309        raw_volts = (float(value) / bit_max * 20) - 10
1310
1311        # Calculate the voltage of 1 bit
1312        lsb_volts = 20.0 / bit_max
1313
1314        # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255)
1315        clean_volts = round(raw_volts, 3)
1316
1317        # If the raw voltage is within 1 bit of the clean voltage, snap to it
1318        if abs(raw_volts - clean_volts) <= lsb_volts:
1319            return clean_volts
1320
1321        # Otherwise, return the standard 4-decimal reading
1322        return round(raw_volts, 4)
1323
1324    def _seconds_to_cycles(self, value):
1325        """Convert seconds to the corresponding refresh-cycle count."""
1326        return int(round(float(value) * float(self.info.cycle_frequency)))
1327
1328    def _cycles_to_seconds(self, value):
1329        """Convert hardware timer cycle counts to seconds."""
1330        return float(value) / float(self.info.cycle_frequency)
1331
1332    def _require_firmware(self, minimum_version, context):
1333        """Raise unless the device firmware is new enough for an op."""
1334        if (
1335            self.info.firmware_version is None
1336            or self.info.firmware_version < minimum_version
1337        ):
1338            raise PulsePalError(
1339                f"{context} requires firmware v{minimum_version} or newer. "
1340                f"Detected firmware is v{self.info.firmware_version}."
1341            )
1342
1343    def _sync_all_params(self):
1344        """Send all parameters using the packed sync op (firmware v22+).
1345
1346        Values are grouped by width so that the whole program travels as
1347        one uint32 block, one uint16 block and one uint8 block.
1348        """
1349        time_values = []
1350        for attr_name in (
1351            "phase1_duration",
1352            "inter_phase_interval",
1353            "phase2_duration",
1354            "inter_pulse_interval",
1355            "burst_duration",
1356            "inter_burst_interval",
1357            "pulse_train_duration",
1358            "pulse_train_delay",
1359        ):
1360            time_values.extend(
1361                self._seconds_to_cycles(getattr(self, attr_name)[channel])
1362                for channel in range(1, 5)
1363            )
1364
1365        voltage_values = []
1366        for attr_name in (
1367            "phase1_voltage",
1368            "phase2_voltage",
1369            "resting_voltage",
1370        ):
1371            voltage_values.extend(
1372                self._volts_to_bits(getattr(self, attr_name)[channel])
1373                for channel in range(1, 5)
1374            )
1375
1376        single_byte_values = []
1377        for attr_name in (
1378            "is_biphasic",
1379            "custom_train_id",
1380            "custom_train_target",
1381            "custom_train_loop",
1382        ):
1383            single_byte_values.extend(
1384                int(getattr(self, attr_name)[channel])
1385                for channel in range(1, 5)
1386            )
1387        single_byte_values.extend(
1388            int(self.link_trigger_channel1[channel])
1389            for channel in range(1, 5)
1390        )
1391        single_byte_values.extend(
1392            int(self.link_trigger_channel2[channel])
1393            for channel in range(1, 5)
1394        )
1395        single_byte_values.extend(
1396            int(value) for value in self.trigger_mode[1:3]
1397        )
1398
1399        self._write_serial(
1400            (self._OP_MENU_BYTE, 92),
1401            "uint8",
1402            time_values,
1403            "uint32",
1404            voltage_values,
1405            "uint16",
1406            single_byte_values,
1407            "uint8",
1408        )
1409
1410    def _sync_all_params_legacy(self):
1411        """Send all parameters using the legacy sync op (firmware v21).
1412
1413        Equivalent to `_sync_all_params`, but lays the program out
1414        channel by channel as the older firmware expects.
1415        """
1416        program_values_16 = []
1417        program_values_32 = []
1418        program_values_8 = [0] * 16
1419
1420        for channel in range(1, 5):
1421            program_values_32.extend(
1422                [
1423                    self._seconds_to_cycles(
1424                        self.phase1_duration[channel]),
1425                    self._seconds_to_cycles(
1426                        self.inter_phase_interval[channel]),
1427                    self._seconds_to_cycles(
1428                        self.phase2_duration[channel]),
1429                    self._seconds_to_cycles(
1430                        self.inter_pulse_interval[channel]),
1431                    self._seconds_to_cycles(
1432                        self.burst_duration[channel]),
1433                    self._seconds_to_cycles(
1434                        self.inter_burst_interval[channel]),
1435                    self._seconds_to_cycles(
1436                        self.pulse_train_duration[channel]),
1437                    self._seconds_to_cycles(
1438                        self.pulse_train_delay[channel]),
1439                ]
1440            )
1441
1442        for channel in range(1, 5):
1443            program_values_16.extend(
1444                [
1445                    self._volts_to_bits(self.phase1_voltage[channel]),
1446                    self._volts_to_bits(self.phase2_voltage[channel]),
1447                    self._volts_to_bits(self.resting_voltage[channel]),
1448                ]
1449            )
1450
1451        position = 0
1452        for channel in range(1, 5):
1453            program_values_8[position] = self.is_biphasic[channel]
1454            position += 1
1455            program_values_8[position] = self.custom_train_id[channel]
1456            position += 1
1457            program_values_8[position] = self.custom_train_target[channel]
1458            position += 1
1459            program_values_8[position] = self.custom_train_loop[channel]
1460            position += 1
1461
1462        program_values_tl = [0] * 8
1463        position = 0
1464        for channel in range(1, 5):
1465            program_values_tl[position] = self.link_trigger_channel1[channel]
1466            position += 1
1467        for channel in range(1, 5):
1468            program_values_tl[position] = self.link_trigger_channel2[channel]
1469            position += 1
1470
1471        self._write_serial(
1472            (self._OP_MENU_BYTE, 73),
1473            "uint8",
1474            program_values_32,
1475            "uint32",
1476            program_values_16,
1477            "uint16",
1478            program_values_8,
1479            "uint8",
1480            program_values_tl,
1481            "uint8",
1482            self.trigger_mode[1:3],
1483            "uint8",
1484        )
1485
1486    def __enter__(self):
1487        """Enter a `with` block, returning the connected device."""
1488        return self
1489
1490    def __exit__(self, exc_type, exc_value, traceback):
1491        """Disconnect and close the port when leaving a `with` block.
1492
1493        Returns:
1494            `False`, so any exception raised in the block propagates.
1495        """
1496        try:
1497            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1498        except Exception:
1499            pass
1500        self.close()
1501        return False
1502
1503    def __del__(self):
1504        """Disconnect and close the port when the object is collected."""
1505        try:
1506            self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1507        except Exception:
1508            pass
1509
1510        try:
1511            self.close()
1512        except Exception:
1513            # Destructors should not raise; the serial object may already be
1514            # gone during interpreter shutdown.
1515            pass

A class to control a Pulse Pal device on a USB serial port.

Creating an instance opens the serial port, exchanges a handshake with the device, verifies that its firmware is supported, reads the device properties into PulsePalDevice.info, and programs the device with the default parameters.

from PulsePal import PulsePalDevice

P = PulsePalDevice("COM3")
P.set_output_param("phase1_voltage", 1, 5)
P.trigger(1)
P.close()

The class is also a context manager, which closes the connection on exit even if an error is raised:

with PulsePalDevice("COM3") as P:
    P.trigger(1)

The attributes below named after Pulse Pal parameters are the local copy of the device's program. Each is a five element list indexed by channel number, with index 0 unused. Assigning to them does not reach the device until PulsePalDevice.sync_to_device is called; PulsePalDevice.set_output_param programs one parameter right away.

PulsePalDevice(port_name, baud_rate=12000000, timeout=10)
371    def __init__(self, port_name, baud_rate=12000000, timeout=10):
372        """Open a connection to a Pulse Pal device.
373
374        Opens the serial port, exchanges the handshake, verifies the
375        firmware version, reads the device properties into
376        `PulsePalDevice.info`, and programs the device with the default
377        parameters.
378
379        Args:
380            port_name: USB serial port for the Pulse Pal device, such as
381                `COM3` on Windows or `/dev/ttyACM0` on Linux.
382            baud_rate: Serial baud rate.
383            timeout: Serial read timeout, in seconds.
384
385        Raises:
386            PulsePalError: If the device does not return the expected
387                handshake, or its firmware is older than v21, or its
388                firmware is newer than this module supports.
389            serial.SerialException: If the serial port cannot be opened.
390        """
391        self.info = DeviceInfo()
392        self._gui = None
393        self.port = serial.Serial(
394            port_name,
395            baud_rate,
396            timeout=timeout,
397            rtscts=True,
398        )
399        self._closed = False
400        self._dac_bit_max = self._to_decimal(0)
401        self.info.firmware_version = None
402        self.info.hardware_version = None
403        self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES)
404        self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES)
405
406        self._write_serial(
407            (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE),
408            "uint8",
409        )
410        handshake = self._read_serial(1, "uint8")
411        if handshake != self._HANDSHAKE_RESPONSE:
412            self.close(send_disconnect=False)
413            raise PulsePalError(
414                "Error: incorrect handshake returned. Expected "
415                f"{self._HANDSHAKE_RESPONSE}, received {handshake}."
416            )
417
418        firmware_version = self._read_serial(1, "uint32")
419        if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED:
420            raise PulsePalError(
421                "Error: Old firmware detected, v"
422                f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or "
423                "newer is required."
424            )
425        if firmware_version > self._CURRENT_FIRMWARE_VERSION:
426            raise PulsePalError(
427                "Error: Future firmware detected, v"
428                f"{firmware_version}. Please update PulsePal.py or downgrade "
429                f"firmware to v{self._CURRENT_FIRMWARE_VERSION}."
430            )
431        if firmware_version < self._CURRENT_FIRMWARE_VERSION:
432            print(
433                "Old firmware detected, v"
434                f"{firmware_version}. This firmware is supported. Update to v"
435                f"{self._CURRENT_FIRMWARE_VERSION} is available."
436            )
437        self._dac_bit_max = self._to_decimal(self._DAC_BITMAX)
438        self.info.firmware_version = firmware_version
439
440        if self.info.firmware_version > 21:
441            self._write_serial((self._OP_MENU_BYTE, 94), "uint8")
442            self.info.hardware_version = self._read_serial(1, "uint8")
443            self.info.cycle_period_us = self._read_serial(1, "uint32")
444            self.info.cycle_frequency = 1 / (
445                self.info.cycle_period_us / 1000000
446            )
447            self.info.n_custom_pulse_trains = self._read_serial(1, "uint8")
448            self.info.max_custom_pulses = self._read_serial(1, "uint32")
449        else:
450            self.info.hardware_version = 2
451            self.info.cycle_period_us = 50
452            self.info.cycle_frequency = 20000
453            self.info.n_custom_pulse_trains = 2
454            self.info.max_custom_pulses = 5000
455
456        # Client name op + "PYTHON" in ASCII.
457        self._write_serial(
458            (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78),
459            "uint8",
460        )
461
462        self.set_default_params()
463        self.sync_to_device()

Open a connection to a Pulse Pal device.

Opens the serial port, exchanges the handshake, verifies the firmware version, reads the device properties into PulsePalDevice.info, and programs the device with the default parameters.

Arguments:
  • port_name: USB serial port for the Pulse Pal device, such as COM3 on Windows or /dev/ttyACM0 on Linux.
  • baud_rate: Serial baud rate.
  • timeout: Serial read timeout, in seconds.
Raises:
  • PulsePalError: If the device does not return the expected handshake, or its firmware is older than v21, or its firmware is newer than this module supports.
  • serial.SerialException: If the serial port cannot be opened.
port: serial.serialposix.Serial

The open serial.Serial port connected to the device.

info: DeviceInfo

Properties of the connected device. See DeviceInfo.

is_biphasic: list

Pulse shape per channel: 0 for monophasic, 1 for biphasic.

Monophasic pulses use only the phase 1 parameters. Biphasic pulses follow phase 1 with PulsePalDevice.inter_phase_interval and then phase 2.

phase1_voltage: list

Voltage of the first phase of each pulse, in volts [-10, 10].

phase2_voltage: list

Voltage of the second phase of each pulse, in volts [-10, 10].

Used only when PulsePalDevice.is_biphasic is 1 for the channel.

resting_voltage: list

Voltage held between pulses, in volts [-10, 10].

phase1_duration: list

Duration of the first phase of each pulse, in seconds.

inter_phase_interval: list

Interval between the two phases of a biphasic pulse, in seconds.

The channel rests at PulsePalDevice.resting_voltage during the interval. Used only when PulsePalDevice.is_biphasic is 1.

phase2_duration: list

Duration of the second phase of each pulse, in seconds.

Used only when PulsePalDevice.is_biphasic is 1 for the channel.

inter_pulse_interval: list

Interval from the end of one pulse to the onset of the next, in seconds.

burst_duration: list

Duration of each burst of pulses, in seconds.

Set to 0 to disable bursts, so that pulses continue for the whole pulse train.

inter_burst_interval: list

Interval between bursts of pulses, in seconds.

The channel rests at PulsePalDevice.resting_voltage between bursts. Ignored when PulsePalDevice.burst_duration is 0.

pulse_train_duration: list

Total duration of the pulse train, in seconds.

pulse_train_delay: list

Delay from the trigger to the onset of the pulse train, in seconds.

custom_train_id: list

Custom pulse train played by each output channel.

0 plays the parametrically defined train. 1 or higher plays the matching custom train, previously loaded with PulsePalDevice.send_custom_pulse_train or PulsePalDevice.send_custom_waveform.

custom_train_target: list

What the timestamps of a custom train mark.

0 if each timestamp is the onset of a pulse, 1 if each timestamp is the onset of a burst of pulses.

custom_train_loop: list

Whether a custom train repeats.

1 loops the custom train until PulsePalDevice.pulse_train_duration has elapsed, 0 plays it once.

trigger_mode: list

Response of each trigger channel to an incoming TTL pulse.

Three element list indexed by trigger channel, with index 0 unused. Elements 1 and 2 control the respective channels on the device. Their values can be:

  • 0 (normal): a TTL rising edge starts the pulse train, and edges during the train are ignored.
  • 1 (toggle): same as 0 but a TTL rising edge during the train stops it.
  • 2 (pulse gated): the train runs only while the trigger TTL is high.
def set_default_params(self):
465    def set_default_params(self):
466        """Reset the local copy of all parameters to their defaults.
467
468        The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for
469        1 second, on all four output channels, linked to trigger channel
470        1 in normal trigger mode.
471
472        This updates only the local copy. Call
473        `PulsePalDevice.sync_to_device` to program the device with them.
474        """
475        nan = float("nan")
476        self.is_biphasic = [nan, 0, 0, 0, 0]
477        self.phase1_voltage = [nan, 5, 5, 5, 5]
478        self.phase2_voltage = [nan, -5, -5, -5, -5]
479        self.resting_voltage = [nan, 0, 0, 0, 0]
480        self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001]
481        self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001]
482        self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001]
483        self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01]
484        self.burst_duration = [nan, 0, 0, 0, 0]
485        self.inter_burst_interval = [nan, 0, 0, 0, 0]
486        self.pulse_train_duration = [nan, 1, 1, 1, 1]
487        self.pulse_train_delay = [nan, 0, 0, 0, 0]
488        self.link_trigger_channel1 = [nan, 1, 1, 1, 1]
489        self.link_trigger_channel2 = [nan, 0, 0, 0, 0]
490        self.custom_train_id = [nan, 0, 0, 0, 0]
491        self.custom_train_target = [nan, 0, 0, 0, 0]
492        self.custom_train_loop = [nan, 0, 0, 0, 0]
493        self.trigger_mode = [nan, 0, 0]

Reset the local copy of all parameters to their defaults.

The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for 1 second, on all four output channels, linked to trigger channel 1 in normal trigger mode.

This updates only the local copy. Call PulsePalDevice.sync_to_device to program the device with them.

def set_voltage(self, channel, voltage):
495    def set_voltage(self, channel, voltage):
496        """Set an output channel to a fixed voltage.
497
498        The channel holds the voltage until it is set again or until a
499        pulse train is triggered on it.
500
501        Args:
502            channel: Output channel number, 1-4.
503            voltage: Voltage to set, in volts [-10, 10].
504
505        Raises:
506            PulsePalError: If the device does not acknowledge the
507                command.
508        """
509        voltage_bits = self._volts_to_bits(voltage)
510        self._write_serial(
511            (self._OP_MENU_BYTE, 79, channel),
512            "uint8",
513            voltage_bits,
514            "uint16",
515        )
516        self._read_ack("set_fixed_voltage()")

Set an output channel to a fixed voltage.

The channel holds the voltage until it is set again or until a pulse train is triggered on it.

Arguments:
  • channel: Output channel number, 1-4.
  • voltage: Voltage to set, in volts [-10, 10].
Raises:
  • PulsePalError: If the device does not acknowledge the command.
def set_calibration(self, channel, voltage_offset):
518    def set_calibration(self, channel, voltage_offset):
519        """Calibrate the zero code of an output channel.
520
521        The offset is added to every voltage the channel produces, to
522        correct for DAC offset error. It is stored in the device's
523        EEPROM and reloaded on boot, so it only needs to be set once.
524
525        Requires Pulse Pal hardware v3 or newer.
526
527        Args:
528            channel: Output channel number, 1-4.
529            voltage_offset: Offset to apply, in volts [-0.1, 0.1].
530
531        Raises:
532            PulsePalError: If the connected hardware is older than v3,
533                or the device does not acknowledge the command.
534            ValueError: If `channel` is not 1-4, or `voltage_offset` is
535                outside [-0.1, 0.1].
536        """
537        if self.info.hardware_version < 3:
538            raise PulsePalError(
539                "set_calibration() requires hardware v3 or newer."
540            )
541        if channel not in (1, 2, 3, 4):
542            raise ValueError("channel must be 1, 2, 3 or 4")
543        if voltage_offset < -0.1 or voltage_offset > 0.1:
544            raise ValueError(
545                "voltage_offset for zero code calibration must be in range "
546                "[-0.1, 0.1]"
547            )
548        voltage_bits = voltage_offset * (1 / (20 / 65536))
549        self._write_serial(
550            (self._OP_MENU_BYTE, 96, channel - 1),
551            "uint8",
552            voltage_bits,
553            "int16",
554        )
555        self._read_ack("set_calibration()")

Calibrate the zero code of an output channel.

The offset is added to every voltage the channel produces, to correct for DAC offset error. It is stored in the device's EEPROM and reloaded on boot, so it only needs to be set once.

Requires Pulse Pal hardware v3 or newer.

Arguments:
  • channel: Output channel number, 1-4.
  • voltage_offset: Offset to apply, in volts [-0.1, 0.1].
Raises:
  • PulsePalError: If the connected hardware is older than v3, or the device does not acknowledge the command.
  • ValueError: If channel is not 1-4, or voltage_offset is outside [-0.1, 0.1].
def set_output_param(self, param_name, channel, value):
557    def set_output_param(self, param_name, channel, value):
558        """Program a single output channel parameter on the device.
559
560        The local copy of the parameter is updated to match, so a later
561        `PulsePalDevice.sync_to_device` will not undo the change.
562
563        ```python
564        P.set_output_param("is_biphasic", 1, 1)
565        P.set_output_param("phase1_voltage", 1, 10)
566        P.set_output_param(3, 1, -10)   # same, by param code
567        ```
568
569        Args:
570            param_name: Parameter name, as listed in
571                `DeviceInfo.output_parameter_names`, or its integer
572                parameter code.
573            channel: Output channel number, 1-4.
574            value: Value to set. Units are volts for voltage parameters,
575                seconds for time parameters, and integers for enumerated
576                parameters. See the attributes of `PulsePalDevice` for
577                the meaning of each parameter.
578
579        Raises:
580            PulsePalError: If the parameter name is not recognized, the
581                value does not fit the datatype the device expects, or
582                the device does not acknowledge the command.
583        """
584        original_value = value
585        param_code = self._get_output_param_code(param_name)
586
587        if param_code in (2, 3, 17):
588            value = self._volts_to_bits(value)
589            self._write_serial(
590                (self._OP_MENU_BYTE, 74, param_code, channel),
591                "uint8",
592                value,
593                "uint16",
594            )
595        elif 4 <= param_code <= 11:
596            self._write_serial(
597                (self._OP_MENU_BYTE, 74, param_code, channel),
598                "uint8",
599                self._seconds_to_cycles(value),
600                "uint32",
601            )
602        else:
603            self._write_serial(
604                (self._OP_MENU_BYTE, 74, param_code, channel, value),
605                "uint8",
606            )
607
608        self._read_ack("program_output_channel_param()")
609        self._set_output_param_value(param_code, channel, original_value)

Program a single output channel parameter on the device.

The local copy of the parameter is updated to match, so a later PulsePalDevice.sync_to_device will not undo the change.

P.set_output_param("is_biphasic", 1, 1)
P.set_output_param("phase1_voltage", 1, 10)
P.set_output_param(3, 1, -10)   # same, by param code
Arguments:
  • param_name: Parameter name, as listed in DeviceInfo.output_parameter_names, or its integer parameter code.
  • channel: Output channel number, 1-4.
  • value: Value to set. Units are volts for voltage parameters, seconds for time parameters, and integers for enumerated parameters. See the attributes of PulsePalDevice for the meaning of each parameter.
Raises:
  • PulsePalError: If the parameter name is not recognized, the value does not fit the datatype the device expects, or the device does not acknowledge the command.
def set_trigger_param(self, param_name, channel, value):
611    def set_trigger_param(self, param_name, channel, value):
612        """Program a single trigger channel parameter on the device.
613
614        The local copy of the parameter is updated to match, so a later
615        `PulsePalDevice.sync_to_device` will not undo the change.
616
617        ```python
618        P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
619        ```
620
621        Args:
622            param_name: Parameter name, as listed in
623                `DeviceInfo.trigger_parameter_names`, or its integer
624                parameter code.
625            channel: Trigger channel number, 1-2.
626            value: Value to set. See `PulsePalDevice.trigger_mode` for
627                the trigger modes.
628
629        Raises:
630            PulsePalError: If the parameter name is not recognized, the
631                value does not fit the datatype the device expects, or
632                the device does not acknowledge the command.
633        """
634        original_value = value
635        param_code = self._get_trigger_param_code(param_name)
636
637        self._write_serial(
638            (self._OP_MENU_BYTE, 74, param_code, channel, value),
639            "uint8",
640        )
641        self._read_ack("program_trigger_channel_param()")
642
643        if param_code in (1, 128):
644            self.trigger_mode[channel] = original_value

Program a single trigger channel parameter on the device.

The local copy of the parameter is updated to match, so a later PulsePalDevice.sync_to_device will not undo the change.

P.set_trigger_param("trigger_mode", 1, 2)  # pulse gated
Arguments:
Raises:
  • PulsePalError: If the parameter name is not recognized, the value does not fit the datatype the device expects, or the device does not acknowledge the command.
def sync_to_device(self):
646    def sync_to_device(self):
647        """Program the device with the local copy of all parameters.
648
649        Call this after assigning to the parameter array attributes, to
650        send every output and trigger parameter to the device in a
651        single transaction.
652
653        ```python
654        P.phase1_voltage[1:5] = [5] * 4
655        P.sync_to_device()
656        ```
657
658        Raises:
659            PulsePalError: If the device does not acknowledge the
660                command.
661        """
662        # _sync_all_params() for firmware v22+ uses the newer packed sync
663        # opcode (92). _sync_all_params_legacy() uses the less efficient
664        # legacy packed sync opcode (73).
665        if self.info.firmware_version > 21:
666            self._sync_all_params()
667        else:
668            self._sync_all_params_legacy()
669        self._read_ack("sync_to_device()")

Program the device with the local copy of all parameters.

Call this after assigning to the parameter array attributes, to send every output and trigger parameter to the device in a single transaction.

P.phase1_voltage[1:5] = [5] * 4
P.sync_to_device()
Raises:
  • PulsePalError: If the device does not acknowledge the command.
def sync_from_device(self):
671    def sync_from_device(self):
672        """Read all parameters from the device into the local copy.
673
674        Overwrites every parameter array attribute with the program
675        currently stored on the device. Useful after the device has been
676        reprogrammed from its thumb joystick.
677
678        Requires firmware v22 or newer.
679
680        Raises:
681            PulsePalError: If the connected firmware is older than v22,
682                or the device does not return the full parameter set.
683        """
684        self._require_firmware(22, "sync_from_device()")
685        self._write_serial((self._OP_MENU_BYTE, 93), "uint8")
686        for attr_name in (
687                "phase1_duration",
688                "inter_phase_interval",
689                "phase2_duration",
690                "inter_pulse_interval",
691                "burst_duration",
692                "inter_burst_interval",
693                "pulse_train_duration",
694                "pulse_train_delay",
695        ):
696            setattr(
697                self,
698                attr_name,
699                [float("nan")]
700                + [
701                    self._cycles_to_seconds(x)
702                    for x in self._read_serial(4, "uint32")
703                ],
704            )
705
706        for attr_name in (
707                "phase1_voltage",
708                "phase2_voltage",
709                "resting_voltage",
710        ):
711            setattr(
712                self,
713                attr_name,
714                [float("nan")]
715                + [
716                    self._bits_to_volts(x)
717                    for x in self._read_serial(4, "uint16")
718                ],
719            )
720
721        for attr_name in (
722                "is_biphasic",
723                "custom_train_id",
724                "custom_train_target",
725                "custom_train_loop",
726                "link_trigger_channel1",
727                "link_trigger_channel2",
728        ):
729            setattr(
730                self,
731                attr_name,
732                [float("nan")] + self._read_serial(4, "uint8"),
733            )
734        self.trigger_mode = [float("nan")] + self._read_serial(2, "uint8")

Read all parameters from the device into the local copy.

Overwrites every parameter array attribute with the program currently stored on the device. Useful after the device has been reprogrammed from its thumb joystick.

Requires firmware v22 or newer.

Raises:
  • PulsePalError: If the connected firmware is older than v22, or the device does not return the full parameter set.
def send_custom_pulse_train(self, custom_train_id, pulse_times, pulse_voltages):
736    def send_custom_pulse_train(
737        self,
738        custom_train_id,
739        pulse_times,
740        pulse_voltages,
741    ):
742        """Load a custom pulse train onto the device.
743
744        A custom pulse train is an arbitrary list of pulse onset times
745        and voltages, replacing the parametric pulse voltage and onset timing.
746        Set `PulsePalDevice.custom_train_id` on an output channel to play
747        the train there.
748
749        ```python
750        P.send_custom_pulse_train(
751            2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
752        )
753        P.set_output_param("custom_train_id", 1, 2)
754        ```
755
756        Args:
757            custom_train_id: Custom train to load, 1-2. See
758                `DeviceInfo.n_custom_pulse_trains`.
759            pulse_times: Pulse onset times, in seconds, relative to the
760                start of the train. Accepts a list, tuple or NumPy
761                array.
762            pulse_voltages: Voltage of each pulse, in volts [-10, 10].
763                Must be the same length as `pulse_times`.
764
765        Raises:
766            PulsePalError: If `pulse_times` and `pulse_voltages` differ
767                in length, or the device does not acknowledge the
768                command.
769        """
770        pulse_times = self._as_list(pulse_times)
771        pulse_voltages = self._as_list(pulse_voltages)
772        n_pulses = len(pulse_times)
773        if n_pulses != len(pulse_voltages):
774            raise PulsePalError(
775                "pulse_times and pulse_voltages must be the same length."
776            )
777
778        pulse_times_cycles = [
779            self._seconds_to_cycles(pulse_time)
780            for pulse_time in pulse_times
781        ]
782        pulse_voltage_bits = [
783            self._volts_to_bits(voltage)
784            for voltage in pulse_voltages
785        ]
786
787        op_code = int(custom_train_id) + 74
788        self._write_serial(
789            (self._OP_MENU_BYTE, op_code),
790            "uint8",
791            n_pulses,
792            "uint32",
793            pulse_times_cycles,
794            "uint32",
795            pulse_voltage_bits,
796            "uint16",
797        )
798        self._read_ack("send_custom_pulse_train()")

Load a custom pulse train onto the device.

A custom pulse train is an arbitrary list of pulse onset times and voltages, replacing the parametric pulse voltage and onset timing. Set PulsePalDevice.custom_train_id on an output channel to play the train there.

P.send_custom_pulse_train(
    2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
)
P.set_output_param("custom_train_id", 1, 2)
Arguments:
  • custom_train_id: Custom train to load, 1-2. See DeviceInfo.n_custom_pulse_trains.
  • pulse_times: Pulse onset times, in seconds, relative to the start of the train. Accepts a list, tuple or NumPy array.
  • pulse_voltages: Voltage of each pulse, in volts [-10, 10]. Must be the same length as pulse_times.
Raises:
  • PulsePalError: If pulse_times and pulse_voltages differ in length, or the device does not acknowledge the command.
def send_custom_waveform(self, custom_train_id, pulse_width, pulse_voltages):
800    def send_custom_waveform(
801        self,
802        custom_train_id,
803        pulse_width,
804        pulse_voltages,
805    ):
806        """Load an arbitrary waveform onto the device.
807
808        A convenience shorthand for
809        `PulsePalDevice.send_custom_pulse_train` with evenly spaced,
810        confluent pulses, so that `pulse_voltages` is played as a
811        waveform sampled every `pulse_width` seconds.
812
813        Set the channel's `PulsePalDevice.phase1_duration` to
814        `pulse_width` as well, so that each sample is held for the
815        sampling period.
816
817        ```python
818        import math
819
820        samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
821        P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
822        P.set_output_param("custom_train_id", 2, 1)
823        P.set_output_param("phase1_duration", 2, 0.001)
824        ```
825
826        Args:
827            custom_train_id: Custom train to load, 1-2. See
828                `DeviceInfo.n_custom_pulse_trains`.
829            pulse_width: Sampling period, in seconds. Each voltage is
830                held for this long.
831            pulse_voltages: Waveform samples, in volts [-10, 10].
832                Accepts a list, tuple or NumPy array.
833
834        Raises:
835            PulsePalError: If the device does not acknowledge the
836                command.
837        """
838        pulse_voltages = self._as_list(pulse_voltages)
839        n_pulses = len(pulse_voltages)
840        pulse_width_cycles = self._seconds_to_cycles(pulse_width)
841        pulse_times = [pulse_width_cycles * i for i in range(n_pulses)]
842        pulse_voltage_bits = [
843            self._volts_to_bits(voltage)
844            for voltage in pulse_voltages
845        ]
846
847        op_code = int(custom_train_id) + 74
848        self._write_serial(
849            (self._OP_MENU_BYTE, op_code),
850            "uint8",
851            n_pulses,
852            "uint32",
853            pulse_times,
854            "uint32",
855            pulse_voltage_bits,
856            "uint16",
857        )
858        self._read_ack("send_custom_waveform()")

Load an arbitrary waveform onto the device.

A convenience shorthand for PulsePalDevice.send_custom_pulse_train with evenly spaced, confluent pulses, so that pulse_voltages is played as a waveform sampled every pulse_width seconds.

Set the channel's PulsePalDevice.phase1_duration to pulse_width as well, so that each sample is held for the sampling period.

import math

samples = [math.sin(i / 10.0) * 10 for i in range(1000)]
P.send_custom_waveform(1, 0.001, samples)   # 1 kHz
P.set_output_param("custom_train_id", 2, 1)
P.set_output_param("phase1_duration", 2, 0.001)
Arguments:
  • custom_train_id: Custom train to load, 1-2. See DeviceInfo.n_custom_pulse_trains.
  • pulse_width: Sampling period, in seconds. Each voltage is held for this long.
  • pulse_voltages: Waveform samples, in volts [-10, 10]. Accepts a list, tuple or NumPy array.
Raises:
  • PulsePalError: If the device does not acknowledge the command.
def set_continuous_loop(self, channel, state):
860    def set_continuous_loop(self, channel, state):
861        """Set the continuous loop state of an output channel.
862
863        In continuous loop mode the channel repeats its pulse train
864        indefinitely the next time it is triggered, until the mode is
865        cleared or `PulsePalDevice.stop` is called.
866
867        Args:
868            channel: Output channel number, 1-4.
869            state: `1` for continuous loop, `0` for normal mode.
870        """
871        self._write_serial(
872            (self._OP_MENU_BYTE, 82, channel, state),
873            "uint8",
874        )

Set the continuous loop state of an output channel.

In continuous loop mode the channel repeats its pulse train indefinitely the next time it is triggered, until the mode is cleared or PulsePalDevice.stop is called.

Arguments:
  • channel: Output channel number, 1-4.
  • state: 1 for continuous loop, 0 for normal mode.
def trigger(self, channel1=None, channel2=None, channel3=None, channel4=None):
876    def trigger(
877        self,
878        channel1=None,
879        channel2=None,
880        channel3=None,
881        channel4=None,
882    ):
883        """Trigger output channels in software.
884
885        Triggered channels start their pulse trains together. Three
886        calling conventions are accepted:
887
888        ```python
889        P.trigger(1, 0, 1, 0)   # one flag per channel
890        P.trigger(3)            # a single channel number
891        P.trigger([1, 4])       # several channel numbers
892        ```
893
894        Channel numbers outside 1-4 are ignored.
895
896        Args:
897            channel1: `1` to trigger channel 1, otherwise `0`; or, when
898                it is the only argument given, a single channel number
899                or a list of channel numbers to trigger.
900            channel2: `1` to trigger channel 2, otherwise `0`.
901            channel3: `1` to trigger channel 3, otherwise `0`.
902            channel4: `1` to trigger channel 4, otherwise `0`.
903        """
904        trigger_byte = 0
905
906        # Options 2 & 3: Only one argument was provided
907        if channel2 is None and channel3 is None and channel4 is None:
908            # Option 2: Single integer
909            if isinstance(channel1, int):
910                channels_to_trigger = [channel1]
911            # Option 3: List/Tuple of integers
912            elif isinstance(channel1, (list, tuple, set)):
913                channels_to_trigger = channel1
914            else:
915                channels_to_trigger = []
916
917            # Use bitwise shifts to calculate the trigger byte
918            # (ch1=bit0, ch2=bit1, etc.)
919            for ch in channels_to_trigger:
920                if 1 <= ch <= 4:
921                    trigger_byte |= (1 << (ch - 1))
922
923        # Option 1: Original input scheme (logicals for each channel)
924        else:
925            # Fallback to 0 if an argument was omitted via kwargs
926            c1 = channel1 if channel1 is not None else 0
927            c2 = channel2 if channel2 is not None else 0
928            c3 = channel3 if channel3 is not None else 0
929            c4 = channel4 if channel4 is not None else 0
930
931            trigger_byte = (
932                (1 * c1)
933                + (2 * c2)
934                + (4 * c3)
935                + (8 * c4)
936            )
937
938        self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8")

Trigger output channels in software.

Triggered channels start their pulse trains together. Three calling conventions are accepted:

P.trigger(1, 0, 1, 0)   # one flag per channel
P.trigger(3)            # a single channel number
P.trigger([1, 4])       # several channel numbers

Channel numbers outside 1-4 are ignored.

Arguments:
  • channel1: 1 to trigger channel 1, otherwise 0; or, when it is the only argument given, a single channel number or a list of channel numbers to trigger.
  • channel2: 1 to trigger channel 2, otherwise 0.
  • channel3: 1 to trigger channel 3, otherwise 0.
  • channel4: 1 to trigger channel 4, otherwise 0.
def sd_settings(self, settings_file_name, op):
940    def sd_settings(self, settings_file_name, op):
941        """Save, load, or delete a settings file on the microSD card.
942
943        A settings file holds a complete Pulse Pal program, so that it
944        can be recalled later from software or from the device's front
945        panel. Loading a file also refreshes the local copy of the
946        parameters, via `PulsePalDevice.sync_from_device`.
947
948        ```python
949        P.sd_settings("MyProtocol.pps", "save")
950        ```
951
952        Args:
953            settings_file_name: Settings file name, at most 15 ASCII
954                characters including the required `.pps` extension.
955            op: `"save"`, `"load"`, or `"delete"`.
956
957        Raises:
958            PulsePalError: If the file name has no `.pps` extension or
959                is too long, `op` is not one of the three operations, or
960                the device does not acknowledge the command.
961        """
962        if ".pps" not in settings_file_name:
963            raise PulsePalError(
964                "Error: The file name must have a valid .pps extension."
965            )
966        op_byte_by_name = {"save": 1, "load": 2, "delete": 3}
967        try:
968            op_byte = op_byte_by_name[str(op).lower()]
969        except KeyError as exc:
970            raise PulsePalError(
971                "File op must be: 'save', 'load' or 'delete'."
972            ) from exc
973
974        filename_bytes = settings_file_name.encode("ascii")
975        if len(filename_bytes) > 15:
976            raise PulsePalError("settings_file_name is too long.")
977        self._write_serial(
978            (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)),
979            "uint8",
980            list(filename_bytes),
981            "uint8",
982        )
983        if self.info.firmware_version > 21:
984            self._read_ack("sd_settings()")
985        if op_byte == 2:
986            time.sleep(0.1)
987            self.sync_from_device()

Save, load, or delete a settings file on the microSD card.

A settings file holds a complete Pulse Pal program, so that it can be recalled later from software or from the device's front panel. Loading a file also refreshes the local copy of the parameters, via PulsePalDevice.sync_from_device.

P.sd_settings("MyProtocol.pps", "save")
Arguments:
  • settings_file_name: Settings file name, at most 15 ASCII characters including the required .pps extension.
  • op: "save", "load", or "delete".
Raises:
  • PulsePalError: If the file name has no .pps extension or is too long, op is not one of the three operations, or the device does not acknowledge the command.
def stop(self):
989    def stop(self):
990        """Stop all pulse trains currently playing on the device.
991
992        Every output channel returns to its
993        `PulsePalDevice.resting_voltage`.
994        """
995        self._write_serial((self._OP_MENU_BYTE, 80), "uint8")

Stop all pulse trains currently playing on the device.

Every output channel returns to its PulsePalDevice.resting_voltage.

def format_microsd(self, timeout=30):
 997    def format_microsd(self, timeout=30):
 998        """Format the device's microSD card.
 999
1000        Erases every settings file stored on the device and resets its
1001        parameters to the defaults. The user is prompted at the console
1002        to confirm before anything is erased.
1003
1004        Requires Pulse Pal hardware v3 or newer.
1005
1006        Args:
1007            timeout: Seconds to wait for the device to report that
1008                formatting has finished.
1009
1010        Returns:
1011            `None` once the card has been formatted, or an empty string
1012            if the user declines the confirmation prompt.
1013
1014        Raises:
1015            PulsePalError: If the connected hardware is older than v3.
1016        """
1017        if self.info.hardware_version < 3:
1018            raise PulsePalError(
1019                "format_microsd() requires hardware v3 or newer."
1020            )
1021
1022        print("*** Pulse Pal microSD Formatter ***")
1023        print("This will format Pulse Pal's microSD card,")
1024        print("erase all settings files on the device")
1025        print("and reset all parameters to defaults.")
1026
1027        reply = input("Do you want to continue (y/n)")
1028
1029        if reply.strip().lower() != "y":
1030            print("Choice confirmed - microSD Card NOT formatted.")
1031            return ""
1032
1033        self._write_serial((self._OP_MENU_BYTE, 97), "uint8")
1034
1035        start = time.time()
1036        message = bytearray()
1037
1038        while time.time() - start < timeout:
1039            n_waiting = self.bytes_available()
1040            if n_waiting:
1041                message.extend(self.port.read(n_waiting))
1042                if ord("!") in message:
1043                    break
1044            time.sleep(0.01)
1045        raw_message = bytes(message)
1046        flag_index = raw_message.find(b"!")
1047        if flag_index >= 0:
1048            displayed_message = raw_message[:flag_index]
1049        else:
1050            displayed_message = raw_message
1051
1052        text = displayed_message.decode("ascii", errors="replace").rstrip()
1053        if text:
1054            print(text)
1055
1056        self.set_default_params()
1057        return None

Format the device's microSD card.

Erases every settings file stored on the device and resets its parameters to the defaults. The user is prompted at the console to confirm before anything is erased.

Requires Pulse Pal hardware v3 or newer.

Arguments:
  • timeout: Seconds to wait for the device to report that formatting has finished.
Returns:

None once the card has been formatted, or an empty string if the user declines the confirmation prompt.

Raises:
  • PulsePalError: If the connected hardware is older than v3.
def gui(self, block=None, theme=None):
1059    def gui(self, block=None, theme=None):
1060        """Open the Pulse Pal parameter GUI, or focus an open one.
1061
1062        The GUI edits its own copy of the parameters, and loads them to
1063        the device when its 'Load to Device' button is clicked. The
1064        window closes automatically when the device is closed or
1065        deleted.
1066
1067        Calling this while the GUI is already open focuses the existing
1068        window rather than opening a second one.
1069
1070        Args:
1071            block: If `True`, the call returns when the GUI is closed. If
1072                `False`, the call returns immediately, and the host
1073                application must run the Tk event loop. If `None`, the
1074                GUI blocks only when the host does not already provide a
1075                Tk event loop, e.g. when launched from a script.
1076            theme: `"light"` or `"dark"` to select the color theme, or
1077                `None` to match the desktop theme. Passing a theme to an
1078                already-open GUI recolors it in place.
1079
1080        Returns:
1081            The `PulsePalGUI.PulsePalGUI` instance driving the window.
1082
1083        Raises:
1084            ValueError: If the theme name is not recognized.
1085        """
1086        gui = getattr(self, "_gui", None)
1087        if gui is not None and not gui.is_closed:
1088            if theme is not None:
1089                gui.set_theme(theme)
1090            gui.focus()
1091            return gui
1092
1093        try:
1094            from .PulsePalGUI import PulsePalGUI
1095        except ImportError:
1096            from PulsePalGUI import PulsePalGUI
1097
1098        gui = PulsePalGUI(self, theme=theme)
1099        self._gui = gui
1100        gui.start(block=block)
1101        return gui

Open the Pulse Pal parameter GUI, or focus an open one.

The GUI edits its own copy of the parameters, and loads them to the device when its 'Load to Device' button is clicked. The window closes automatically when the device is closed or deleted.

Calling this while the GUI is already open focuses the existing window rather than opening a second one.

Arguments:
  • block: If True, the call returns when the GUI is closed. If False, the call returns immediately, and the host application must run the Tk event loop. If None, the GUI blocks only when the host does not already provide a Tk event loop, e.g. when launched from a script.
  • theme: "light" or "dark" to select the color theme, or None to match the desktop theme. Passing a theme to an already-open GUI recolors it in place.
Returns:

The PulsePalGUI.PulsePalGUI instance driving the window.

Raises:
  • ValueError: If the theme name is not recognized.
def close(self, send_disconnect=True):
1103    def close(self, send_disconnect=True):
1104        """Close the connection to the device, and the GUI if open.
1105
1106        Safe to call more than once; later calls do nothing. Called
1107        automatically when leaving a `with` block and when the object is
1108        garbage collected.
1109
1110        Args:
1111            send_disconnect: If `True`, tell the device that the client
1112                is disconnecting before closing the port. Set to `False`
1113                when the device is in an unknown state, such as after a
1114                failed handshake.
1115        """
1116        gui = getattr(self, "_gui", None)
1117        self._gui = None
1118        if gui is not None:
1119            try:
1120                gui.close()
1121            except Exception:
1122                # Cleanup must not raise; Tk may already be torn down
1123                pass
1124
1125        if getattr(self, "_closed", True):
1126            return
1127
1128        try:
1129            if send_disconnect and self.port and self.port.is_open:
1130                self._write_serial((self._OP_MENU_BYTE, 81), "uint8")
1131        finally:
1132            if self.port and self.port.is_open:
1133                self.port.close()
1134            self._closed = True

Close the connection to the device, and the GUI if open.

Safe to call more than once; later calls do nothing. Called automatically when leaving a with block and when the object is garbage collected.

Arguments:
  • send_disconnect: If True, tell the device that the client is disconnecting before closing the port. Set to False when the device is in an unknown state, such as after a failed handshake.
def bytes_available(self):
1136    def bytes_available(self):
1137        """Return the number of bytes waiting in the serial read buffer.
1138
1139        Returns:
1140            Count of bytes that can be read without blocking.
1141        """
1142        return self.port.in_waiting

Return the number of bytes waiting in the serial read buffer.

Returns:

Count of bytes that can be read without blocking.

@dataclass
class DeviceInfo:
 99@dataclass
100class DeviceInfo:
101    """Properties of the connected Pulse Pal device.
102
103    An instance is created for each connection and populated during the
104    handshake in `PulsePalDevice.__init__`. It is available as
105    `PulsePalDevice.info`. Devices running firmware v21 report only a
106    firmware version, so the remaining fields are filled in with the
107    known values for Pulse Pal hardware v2.
108
109    ```python
110    print(P.info.firmware_version)
111    ```
112    """
113
114    output_parameter_names: list = None
115    """Output parameter names, ordered by parameter code.
116
117    The position of a name in this list, plus 1, is the parameter code
118    the device expects. Any name here is valid as the `param_name`
119    argument of `PulsePalDevice.set_output_param`, and is also the name
120    of the matching parameter array attribute on `PulsePalDevice`.
121    """
122
123    trigger_parameter_names: list = None
124    """Trigger parameter names, accepted by
125    `PulsePalDevice.set_trigger_param`."""
126
127    firmware_version: int = None
128    """Firmware version running on the connected device."""
129
130    hardware_version: int = None
131    """Hardware revision of the connected device, e.g. `2` or `3`.
132
133    Reported by the device on firmware v22 and newer; assumed to be `2`
134    on older firmware.
135    """
136
137    max_custom_pulses: int = None
138    """Maximum number of pulses in a single custom pulse train."""
139
140    n_custom_pulse_trains: int = None
141    """Number of custom pulse trains the device can store."""
142
143    cycle_frequency: float = None
144    """Update frequency of the device's hardware timer, in Hz.
145
146    All time parameters are rounded to a whole number of these cycles,
147    so this sets the timing resolution of the device.
148    """
149
150    cycle_period_us: float = None
151    """Update period of the device's hardware timer, in microseconds."""

Properties of the connected Pulse Pal device.

An instance is created for each connection and populated during the handshake in PulsePalDevice.__init__. It is available as PulsePalDevice.info. Devices running firmware v21 report only a firmware version, so the remaining fields are filled in with the known values for Pulse Pal hardware v2.

print(P.info.firmware_version)
DeviceInfo( output_parameter_names: list = None, trigger_parameter_names: list = None, firmware_version: int = None, hardware_version: int = None, max_custom_pulses: int = None, n_custom_pulse_trains: int = None, cycle_frequency: float = None, cycle_period_us: float = None)
output_parameter_names: list = None

Output parameter names, ordered by parameter code.

The position of a name in this list, plus 1, is the parameter code the device expects. Any name here is valid as the param_name argument of PulsePalDevice.set_output_param, and is also the name of the matching parameter array attribute on PulsePalDevice.

trigger_parameter_names: list = None

Trigger parameter names, accepted by PulsePalDevice.set_trigger_param.

firmware_version: int = None

Firmware version running on the connected device.

hardware_version: int = None

Hardware revision of the connected device, e.g. 2 or 3.

Reported by the device on firmware v22 and newer; assumed to be 2 on older firmware.

max_custom_pulses: int = None

Maximum number of pulses in a single custom pulse train.

n_custom_pulse_trains: int = None

Number of custom pulse trains the device can store.

cycle_frequency: float = None

Update frequency of the device's hardware timer, in Hz.

All time parameters are rounded to a whole number of these cycles, so this sets the timing resolution of the device.

cycle_period_us: float = None

Update period of the device's hardware timer, in microseconds.

class PulsePalError(builtins.Exception):
89class PulsePalError(Exception):
90    """Raised when Pulse Pal communication or configuration fails.
91
92    This covers serial reads that time out, short serial writes, missing
93    acknowledgement bytes, unknown parameter names, values that do not
94    fit the datatype expected by the device, and operations that the
95    connected firmware or hardware revision does not support.
96    """

Raised when Pulse Pal communication or configuration fails.

This covers serial reads that time out, short serial writes, missing acknowledgement bytes, unknown parameter names, values that do not fit the datatype expected by the device, and operations that the connected firmware or hardware revision does not support.