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