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