PulsePal
Python interface for the Pulse Pal open source pulse train generator.
Pulse Pal delivers precisely timed voltage pulse trains on four analog output channels, and can be triggered by TTL logic on two trigger channels or in software. This module configures and triggers the device over its USB serial port.
Everything is accessed through PulsePalDevice. Import it, connect to
the device's serial port, program parameters, and trigger, e.g.
from PulsePal import PulsePalDevice
with PulsePalDevice("COM3") as P:
P.set_output_param("phase1_voltage", 1, 5)
P.set_output_param("phase1_duration", 1, 0.001)
P.set_output_param("pulse_train_duration", 1, 2)
P.trigger(1)
Parameter arrays
Output parameters are exposed as plain Python lists on the device
object, one list per parameter. Each list has five elements so that the
list index matches the Pulse Pal channel number: index 0 is unused and
holds nan, and indices 1 to 4 hold the values for output channels
1-4. PulsePalDevice.trigger_mode follows the same convention with
three elements, for trigger channels 1 and 2.
P.phase1_voltage[2] = 7 # channel 2 only
P.inter_pulse_interval[1:5] = [0.2] * 4 # all four channels
P.sync_to_device() # push the edits to the device
Editing these lists changes only the local copy. Call
PulsePalDevice.sync_to_device to program the device, or use
PulsePalDevice.set_output_param and
PulsePalDevice.set_trigger_param, which program a single parameter
immediately and keep the local copy in step. set_output_param can also
set one parameter on several output channels at once:
P.set_output_param("inter_pulse_interval", [1, 2, 3, 4], 0.2)
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); a time exactly halfway between two cycles
rounds to the even one, as in the MATLAB and C++ classes, and voltages
round to the nearest DAC code the same way. Enumerated parameters are integers, and
their meanings are given with each attribute below.
A value the device cannot play raises PulsePalError before anything is
sent: a voltage outside [-10, 10], a time that is negative or not a
number, or an enumerated value out of range. So does a pulse phase,
inter-pulse interval or pulse train duration shorter than
DeviceInfo.min_pulse_width_us, and custom pulse times closer together
than that, so that a Pulse Pal's trigger channels can detect the shortest
pulse its output channels play.
Further reading
- Parameter guide: https://sites.google.com/site/pulsepalwiki/parameter-guide
- Serial interface and general documentation: https://sites.google.com/site/pulsepalwiki/
WavePal: alternative firmware that makes a Pulse Pal 3 a four channel waveform player, and its Python class,WavePal.WavePalDevice.
License
This file is part of the Sanworks PulsePal repository. Copyright (C) Sanworks LLC, Rochester, New York, USA
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.
This program is distributed WITHOUT ANY WARRANTY and without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/.
1""" 2Python interface for the [Pulse Pal](https://sites.google.com/site/pulsepalwiki/) 3open source pulse train generator. 4 5Pulse Pal delivers precisely timed voltage pulse trains on four analog 6output channels, and can be triggered by TTL logic on two trigger 7channels or in software. This module configures and triggers the device 8over its USB serial port. 9 10Everything is accessed through `PulsePalDevice`. Import it, connect to 11the device's serial port, program parameters, and trigger, e.g. 12 13```python 14from PulsePal import PulsePalDevice 15 16with PulsePalDevice("COM3") as P: 17 P.set_output_param("phase1_voltage", 1, 5) 18 P.set_output_param("phase1_duration", 1, 0.001) 19 P.set_output_param("pulse_train_duration", 1, 2) 20 P.trigger(1) 21``` 22 23## Parameter arrays 24 25Output parameters are exposed as plain Python lists on the device 26object, one list per parameter. Each list has five elements so that the 27list index matches the Pulse Pal channel number: index 0 is unused and 28holds `nan`, and indices 1 to 4 hold the values for output channels 291-4. `PulsePalDevice.trigger_mode` follows the same convention with 30three elements, for trigger channels 1 and 2. 31 32```python 33P.phase1_voltage[2] = 7 # channel 2 only 34P.inter_pulse_interval[1:5] = [0.2] * 4 # all four channels 35P.sync_to_device() # push the edits to the device 36``` 37 38Editing these lists changes only the local copy. Call 39`PulsePalDevice.sync_to_device` to program the device, or use 40`PulsePalDevice.set_output_param` and 41`PulsePalDevice.set_trigger_param`, which program a single parameter 42immediately and keep the local copy in step. `set_output_param` can also 43set one parameter on several output channels at once: 44 45```python 46P.set_output_param("inter_pulse_interval", [1, 2, 3, 4], 0.2) 47``` 48 49## Units 50 51Voltages are in volts in the range [-10, 10]. Times are in seconds, and 52are rounded to the nearest cycle of the device's hardware timer (see 53`DeviceInfo.cycle_frequency`); a time exactly halfway between two cycles 54rounds to the even one, as in the MATLAB and C++ classes, and voltages 55round to the nearest DAC code the same way. Enumerated parameters are integers, and 56their meanings are given with each attribute below. 57 58A value the device cannot play raises `PulsePalError` before anything is 59sent: a voltage outside [-10, 10], a time that is negative or not a 60number, or an enumerated value out of range. So does a pulse phase, 61inter-pulse interval or pulse train duration shorter than 62`DeviceInfo.min_pulse_width_us`, and custom pulse times closer together 63than that, so that a Pulse Pal's trigger channels can detect the shortest 64pulse its output channels play. 65 66## Further reading 67 68- Parameter guide: 69 https://sites.google.com/site/pulsepalwiki/parameter-guide 70- Serial interface and general documentation: 71 https://sites.google.com/site/pulsepalwiki/ 72- `WavePal`: alternative firmware that makes a Pulse Pal 3 a four 73 channel waveform player, and its Python class, `WavePal.WavePalDevice`. 74 75## License 76 77This file is part of the Sanworks PulsePal repository. 78Copyright (C) Sanworks LLC, Rochester, New York, USA 79 80This program is free software: you can redistribute it and/or modify 81it under the terms of the GNU General Public License as published by 82the Free Software Foundation, version 3. 83 84This program is distributed WITHOUT ANY WARRANTY and without even the 85implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 86See the GNU General Public License for more details. 87 88You should have received a copy of the GNU General Public License 89along with this program. If not, see <http://www.gnu.org/licenses/>. 90""" 91 92from decimal import Decimal 93from dataclasses import dataclass 94import math 95import numbers 96import struct 97import time 98 99import numpy as np 100import serial 101import serial.tools.list_ports 102 103__all__ = ["PulsePalDevice", "DeviceInfo", "PulsePalError"] 104__docformat__ = "google" 105 106 107class PulsePalError(Exception): 108 """Raised when Pulse Pal communication or configuration fails. 109 110 This covers serial reads that time out, short serial writes, missing 111 acknowledgement bytes, unknown parameter names, values that do not 112 fit the datatype expected by the device, and operations that the 113 connected firmware or hardware revision does not support. 114 """ 115 116 117@dataclass 118class DeviceInfo: 119 """Properties of the connected Pulse Pal device. 120 121 An instance is created for each connection and populated during the 122 handshake in `PulsePalDevice.__init__`. It is available as 123 `PulsePalDevice.info`. Devices running firmware v21 report only a 124 firmware version, so the remaining fields are filled in with the 125 known values for Pulse Pal hardware v2. 126 127 ```python 128 print(P.info.firmware_version) 129 ``` 130 """ 131 132 output_parameter_names: list = None 133 """Output parameter names, ordered by parameter code. 134 135 The position of a name in this list, plus 1, is the parameter code 136 the device expects. Any name here is valid as the `param_name` 137 argument of `PulsePalDevice.set_output_param`, and is also the name 138 of the matching parameter array attribute on `PulsePalDevice`. 139 """ 140 141 trigger_parameter_names: list = None 142 """Trigger parameter names, accepted by 143 `PulsePalDevice.set_trigger_param`.""" 144 145 firmware_version: int = None 146 """Firmware version running on the connected device.""" 147 148 hardware_version: int = None 149 """Hardware revision of the connected device, e.g. `2` or `3`. 150 151 Reported by the device on firmware v22 and newer; assumed to be `2` 152 on older firmware. 153 """ 154 155 max_custom_pulses: int = None 156 """Maximum number of pulses in a single custom pulse train.""" 157 158 n_custom_pulse_trains: int = None 159 """Number of custom pulse trains the device can store.""" 160 161 cycle_frequency: float = None 162 """Update frequency of the device's hardware timer, in Hz. 163 164 All time parameters are rounded to a whole number of these cycles, 165 so this sets the timing resolution of the device. 166 """ 167 168 cycle_period_us: float = None 169 """Update period of the device's hardware timer, in microseconds.""" 170 171 min_pulse_width_us: float = None 172 """Shortest pulse phase, inter-pulse interval and pulse train duration, 173 and shortest time between custom pulses, in microseconds. 174 175 Two timer cycles: a trigger channel reads its input once per cycle, so 176 a pulse must last two cycles to be detected reliably. 177 """ 178 179 180class PulsePalDevice: 181 """A class to control a Pulse Pal device on a USB serial port. 182 183 Creating an instance opens the serial port, exchanges a handshake 184 with the device, verifies that its firmware is supported, reads the 185 device properties into `PulsePalDevice.info`, and programs the 186 device with the default parameters. 187 188 ```python 189 from PulsePal import PulsePalDevice 190 191 P = PulsePalDevice("COM3") 192 P.set_output_param("phase1_voltage", 1, 5) 193 P.trigger(1) 194 P.close() 195 ``` 196 197 Here, replace "COM3" with Pulse Pal's USB serial port name. 198 To view a list of available ports, use 199 PulsePalDevice.serialportlist() 200 Pulse Pal's port may not be visible if it is connected to another 201 instance of PulsePalDevice or an external application. Use 202 PulsePalDevice.serialportlist('all') to view all ports. 203 If you see multiple available ports, disconnect Pulse Pal's USB plug 204 and re-run serialportlist(). Notice which port disappears from the list. 205 206 PulsePalDevice is also a context manager, which closes the connection on 207 exit even if an error is raised: 208 209 ```python 210 with PulsePalDevice("COM3") as P: 211 P.trigger(1) 212 ``` 213 214 The attributes below named after Pulse Pal parameters are the local 215 copy of the device's program. Each is a five element list indexed by 216 channel number, with index 0 unused. This way channels are addressed 217 by the index on the device, e.g. trigger channels 1-2 and output 218 channels 1-4. Assigning parameters does not update the device until 219 `PulsePalDevice.sync_to_device` is called; 220 `PulsePalDevice.set_output_param` programs one parameter right away. 221 """ 222 223 port: "serial.Serial" 224 """The open `serial.Serial` port connected to the device.""" 225 226 info: DeviceInfo 227 """Properties of the connected device. See `DeviceInfo`.""" 228 229 is_biphasic: list 230 """Pulse shape per channel: `0` for monophasic, `1` for biphasic. 231 232 Monophasic pulses use only the phase 1 parameters. Biphasic pulses 233 follow phase 1 with `PulsePalDevice.inter_phase_interval` and then 234 phase 2. 235 """ 236 237 phase1_voltage: list 238 """Voltage of the first phase of each pulse, in volts [-10, 10].""" 239 240 phase2_voltage: list 241 """Voltage of the second phase of each pulse, in volts [-10, 10]. 242 243 Used only when `PulsePalDevice.is_biphasic` is `1` for the channel. 244 """ 245 246 resting_voltage: list 247 """Voltage held between pulses, in volts [-10, 10]. 248 249 A new resting voltage reaches an idle channel's output at once. A 250 channel playing a pulse train keeps playing it, and moves to the new 251 resting voltage at its next transition to rest. 252 """ 253 254 phase1_duration: list 255 """Duration of the first phase of each pulse, in seconds. 256 257 At least `DeviceInfo.min_pulse_width_us`. 258 """ 259 260 inter_phase_interval: list 261 """Interval between the two phases of a biphasic pulse, in seconds. 262 263 The channel rests at `PulsePalDevice.resting_voltage` during the 264 interval. Used only when `PulsePalDevice.is_biphasic` is `1`. 265 """ 266 267 phase2_duration: list 268 """Duration of the second phase of each pulse, in seconds. 269 270 Used only when `PulsePalDevice.is_biphasic` is `1` for the channel. 271 At least `DeviceInfo.min_pulse_width_us`. 272 """ 273 274 inter_pulse_interval: list 275 """Interval from the end of one pulse to the onset of the next, in 276 seconds. At least `DeviceInfo.min_pulse_width_us`.""" 277 278 burst_duration: list 279 """Duration of each burst of pulses, in seconds. 280 281 Set to `0` to disable bursts, so that pulses continue for the whole 282 pulse train. A pulse starts only if it ends before the burst does: 283 its first phase, or for a biphasic pulse the whole pulse, so that 284 the end of a burst never cuts off a second phase. 285 """ 286 287 inter_burst_interval: list 288 """Interval between bursts of pulses, in seconds. 289 290 The channel rests at `PulsePalDevice.resting_voltage` between 291 bursts. Ignored when `PulsePalDevice.burst_duration` is `0`. 292 """ 293 294 pulse_train_duration: list 295 """Total duration of the pulse train, in seconds. 296 297 At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts 298 short a monophasic pulse still playing. A biphasic pulse starts only 299 if it can end by the end of the train, so that it keeps its second 300 phase. 301 """ 302 303 pulse_train_delay: list 304 """Delay from the trigger to the onset of the pulse train, in 305 seconds.""" 306 307 link_trigger_channel1: list 308 """Whether each output channel is linked to trigger channel 1. 309 310 `1` links the output channel to trigger channel 1, `0` unlinks it. 311 """ 312 313 link_trigger_channel2: list 314 """Whether each output channel is linked to trigger channel 2. 315 316 `1` links the output channel to trigger channel 2, `0` unlinks it. 317 """ 318 319 custom_train_id: list 320 """Custom pulse train played by each output channel. 321 322 `0` plays the parametrically defined train. `1` or higher plays the 323 matching custom train, previously loaded with 324 `PulsePalDevice.send_custom_pulse_train` or 325 `PulsePalDevice.send_custom_waveform`. 326 """ 327 328 custom_train_target: list 329 """What the timestamps of a custom train mark. 330 331 `0` if each timestamp is the onset of a pulse, `1` if each timestamp 332 is the onset of a burst of pulses. 333 """ 334 335 custom_train_loop: list 336 """Whether a custom train repeats. 337 338 `1` loops the custom train until 339 `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it 340 once. 341 """ 342 343 playback_mode: list 344 """Continuous playback mode of parametric pulse trains after being triggered 345 346 - `0` plays the pulse train once until pulse_train_duration seconds 347 - `1` plays the pulse train indefinitely, ignoring pulse_train_duration 348 349 """ 350 351 trigger_mode: list 352 """Response of each trigger channel to an incoming TTL pulse. 353 354 Three element list indexed by trigger channel, with index 0 unused. 355 Elements 1 and 2 control the respective channels on the device. 356 Their values can be: 357 358 - `0` (normal): a TTL rising edge starts the pulse train, and edges 359 during the train are ignored. 360 - `1` (toggle): same as 0 but a TTL rising edge during the train stops it. 361 - `2` (pulse gated): the train runs only while the trigger TTL is high. 362 - `3` (param sync, Pulse Pal 3 only): a TTL rising edge starts and 363 stops nothing. It loads the parameter set most recently sent by 364 `PulsePalDevice.sync_to_device`. This is how the next trial's 365 parameters are sent during the current trial and applied the 366 instant it starts. 367 368 An output channel that is idle at a param sync edge takes its new 369 parameters in the 50 us timer cycle the edge is detected. One that is 370 playing a pulse train finishes that train on the parameters it 371 started with, and takes the new ones the moment it ends, so a train 372 that runs past the end of a trial keeps one shape throughout and the 373 next trigger plays a whole train with the new parameters. A channel 374 in continuous playback mode has no train end, so it keeps its 375 parameters until something stops it. 376 377 While either trigger channel is in param sync mode, **only 378 `PulsePalDevice.sync_to_device` is held back**. 379 `PulsePalDevice.set_output_param`, `PulsePalDevice.set_trigger_param` 380 and every other parameter method still program the device 381 immediately. So leaving param sync mode means calling 382 `PulsePalDevice.set_trigger_param`; a trigger mode sent by 383 `PulsePalDevice.sync_to_device` does not take effect until a sync 384 edge arrives. 385 386 A param sync channel's links to output channels are ignored. To 387 start a pulse train on the same edge, wire the TTL to the other 388 trigger channel as well: both edges arrive in the same timer cycle, 389 and the parameters are loaded first. 390 391 Connecting a new `PulsePalDevice` takes both trigger channels out of 392 param sync mode, so that the default parameters it programs reach the 393 device instead of waiting for a TTL. 394 395 ```python 396 P.set_trigger_param("trigger_mode", 2, 3) # channel 2 does param sync 397 P.phase1_voltage[1:5] = [5] * 4 398 P.sync_to_device() # stored, not yet applied 399 # ... the next rising edge on trigger channel 2 applies it ... 400 ``` 401 """ 402 403 _CURRENT_FIRMWARE_VERSION = 22 404 405 _OP_MENU_BYTE = 213 406 _HANDSHAKE_OPCODE = 72 407 _HANDSHAKE_RESPONSE = 75 408 _WAVE_PAL_HANDSHAKE_RESPONSE = 87 # 'W': the device runs Wave Pal firmware 409 _DAC_BITMAX = 65535 410 _PARAM_MESSAGE_BYTES = 178 # Length of the parameter set sent by op 93 411 _OLDEST_FIRMWARE_SUPPORTED = 21 412 413 # Parameter names in order of their parameter codes (code = index + 1), 414 # matching _OUTPUT_PARAMETER_ATTRS and the firmware. 415 _OUTPUT_PARAMETER_NAMES = ( 416 "is_biphasic", 417 "phase1_voltage", 418 "phase2_voltage", 419 "phase1_duration", 420 "inter_phase_interval", 421 "phase2_duration", 422 "inter_pulse_interval", 423 "burst_duration", 424 "inter_burst_interval", 425 "pulse_train_duration", 426 "pulse_train_delay", 427 "link_trigger_channel1", 428 "link_trigger_channel2", 429 "custom_train_id", 430 "custom_train_target", 431 "custom_train_loop", 432 "resting_voltage", 433 "playback_mode", 434 ) 435 _TRIGGER_PARAMETER_NAMES = ("trigger_mode",) 436 # Output parameter codes whose values are 0 or 1 437 _BINARY_PARAMETER_CODES = (1, 12, 13, 15, 16, 18) 438 # Phase 1 and 2 durations, inter-pulse interval and train duration last 439 # at least this many timer cycles, as in the MATLAB and C++ classes and 440 # the joystick menu. A trigger channel reads its input once per cycle, so a 441 # one cycle pulse from another Pulse Pal could fall between two reads. 442 _MIN_PULSE_CYCLES = 2 443 _MIN_PULSE_PARAMETER_CODES = (4, 6, 7, 10) 444 445 _OUTPUT_PARAMETER_ATTRS = { 446 1: "is_biphasic", 447 2: "phase1_voltage", 448 3: "phase2_voltage", 449 4: "phase1_duration", 450 5: "inter_phase_interval", 451 6: "phase2_duration", 452 7: "inter_pulse_interval", 453 8: "burst_duration", 454 9: "inter_burst_interval", 455 10: "pulse_train_duration", 456 11: "pulse_train_delay", 457 12: "link_trigger_channel1", 458 13: "link_trigger_channel2", 459 14: "custom_train_id", 460 15: "custom_train_target", 461 16: "custom_train_loop", 462 17: "resting_voltage", 463 18: "playback_mode", 464 } 465 _ENDIANNESS = "<" 466 _STRUCT_FORMATS = { 467 "uint8": "B", 468 "int8": "b", 469 "char": "c", 470 "uint16": "H", 471 "int16": "h", 472 "uint32": "I", 473 "int32": "i", 474 "single": "f", 475 "double": "d", 476 } 477 _TYPE_RANGES = { 478 "uint8": (0, 2**8 - 1), 479 "int8": (-(2**7), 2**7 - 1), 480 "uint16": (0, 2**16 - 1), 481 "int16": (-(2**15), 2**15 - 1), 482 "uint32": (0, 2**32 - 1), 483 "int32": (-(2**31), 2**31 - 1), 484 } 485 486 def __init__(self, port_name, baud_rate=12000000, timeout=10): 487 """Open a connection to a Pulse Pal device. 488 489 Opens the serial port, exchanges the handshake, verifies the 490 firmware version, reads the device properties into 491 `PulsePalDevice.info`, and programs the device with the default 492 parameters. 493 494 Args: 495 port_name: USB serial port for the Pulse Pal device, such as 496 `COM3` on Windows or `/dev/ttyACM0` on Linux. 497 baud_rate: Serial baud rate. 498 timeout: Serial read timeout, in seconds. 499 500 Raises: 501 PulsePalError: If the device does not return the expected 502 handshake, or its firmware is older than v21, or its 503 firmware is newer than this module supports. The port is 504 closed again before any error is raised. 505 serial.SerialException: If the serial port cannot be opened. 506 """ 507 self.info = DeviceInfo() 508 self._gui = None 509 self.port = serial.Serial( 510 port_name, 511 baud_rate, 512 timeout=timeout, 513 rtscts=True, 514 ) 515 self._closed = False 516 try: 517 self._start_session(port_name) 518 except BaseException: 519 # Otherwise the port stays open until the object is garbage 520 # collected, and a second attempt, or WavePal.WavePalDevice, 521 # cannot open it. The device is in an unknown state, so it is not 522 # sent the disconnect op. 523 self.close(send_disconnect=False) 524 raise 525 526 def _start_session(self, port_name): 527 """Handshake, check the firmware, and program the defaults.""" 528 self._dac_bit_max = self._to_decimal(0) 529 self.info.firmware_version = None 530 self.info.hardware_version = None 531 self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES) 532 self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES) 533 534 self._write_serial( 535 (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE), 536 "uint8", 537 ) 538 handshake = self._read_serial(1, "uint8") 539 if handshake == self._WAVE_PAL_HANDSHAKE_RESPONSE: 540 wave_pal_version = self._read_serial(1, "uint32") 541 raise PulsePalError( 542 f"Error: the device on {port_name} runs Wave Pal firmware " 543 f"(v{wave_pal_version}), not Pulse Pal firmware. To use it as " 544 "a Pulse Pal, load Pulse Pal firmware onto it (see " 545 "/Firmware/Readme.txt). To use it as a Wave Pal, connect " 546 "with WavePal.WavePalDevice." 547 ) 548 if handshake != self._HANDSHAKE_RESPONSE: 549 raise PulsePalError( 550 "Error: incorrect handshake returned. Expected " 551 f"{self._HANDSHAKE_RESPONSE}, received {handshake}." 552 ) 553 554 firmware_version = self._read_serial(1, "uint32") 555 if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED: 556 raise PulsePalError( 557 "Error: Old firmware detected, v" 558 f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or " 559 "newer is required." 560 ) 561 if firmware_version > self._CURRENT_FIRMWARE_VERSION: 562 raise PulsePalError( 563 "Error: Future firmware detected, v" 564 f"{firmware_version}. Please update PulsePal.py or downgrade " 565 f"firmware to v{self._CURRENT_FIRMWARE_VERSION}." 566 ) 567 if firmware_version < self._CURRENT_FIRMWARE_VERSION: 568 print( 569 "Old firmware detected, v" 570 f"{firmware_version}. This firmware is supported. Update to v" 571 f"{self._CURRENT_FIRMWARE_VERSION} is available." 572 ) 573 self._dac_bit_max = self._to_decimal(self._DAC_BITMAX) 574 self.info.firmware_version = firmware_version 575 576 if self.info.firmware_version > 21: 577 self._write_serial((self._OP_MENU_BYTE, 94), "uint8") 578 self.info.hardware_version = self._read_serial(1, "uint8") 579 self.info.cycle_period_us = self._read_serial(1, "uint32") 580 self.info.cycle_frequency = 1 / ( 581 self.info.cycle_period_us / 1000000 582 ) 583 self.info.n_custom_pulse_trains = self._read_serial(1, "uint8") 584 self.info.max_custom_pulses = self._read_serial(1, "uint32") 585 else: 586 self.info.hardware_version = 2 587 self.info.cycle_period_us = 50 588 self.info.cycle_frequency = 20000 589 self.info.n_custom_pulse_trains = 2 590 self.info.max_custom_pulses = 5000 591 self.info.min_pulse_width_us = ( 592 self._MIN_PULSE_CYCLES * self.info.cycle_period_us 593 ) 594 595 # Client name op + "PYTHON" in ASCII. 596 self._write_serial( 597 (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78), 598 "uint8", 599 ) 600 601 self.set_default_params() 602 if self.info.hardware_version > 2: 603 # A device left in param sync mode by an earlier session would 604 # store the sync below instead of running it, leaving the device 605 # on its old program until a TTL arrived. set_trigger_param is 606 # not deferred that way, so it takes both trigger channels out of 607 # param sync mode first. See PulsePalDevice.trigger_mode. 608 for channel in (1, 2): 609 self.set_trigger_param("trigger_mode", channel, 0) 610 self.sync_to_device() 611 612 @staticmethod 613 def serialportlist(ports_to_list="available"): 614 """Return the names of the USB serial ports on this computer. 615 616 Called on the class, without connecting to a device, to find the 617 port name to pass to `PulsePalDevice`: 618 619 ```python 620 from PulsePal import PulsePalDevice 621 622 ports = PulsePalDevice.serialportlist() 623 P = PulsePalDevice(ports[0]) 624 ``` 625 626 Args: 627 ports_to_list: `available` to list only the ports that are 628 not already in use, or `all` to list every USB serial 629 port. Not case sensitive. 630 631 Returns: 632 Sorted list of port names, such as `["COM3", "COM7"]` on 633 Windows or `["/dev/ttyACM0"]` on Linux. 634 635 Raises: 636 PulsePalError: If `ports_to_list` is not `available` or 637 `all`. 638 """ 639 if not isinstance(ports_to_list, str): 640 raise PulsePalError( 641 "serialportlist() takes 'available' or 'all'." 642 ) 643 mode = ports_to_list.lower() 644 if mode not in ("available", "all"): 645 raise PulsePalError( 646 f"Unknown port list type: {ports_to_list}. " 647 "Use 'available' or 'all'." 648 ) 649 650 port_names = [] 651 for port_info in serial.tools.list_ports.comports(): 652 is_usb = port_info.vid is not None or "USB" in ( 653 port_info.hwid or "" 654 ).upper() 655 if not is_usb: 656 continue 657 if mode == "available" and not PulsePalDevice._port_is_free( 658 port_info.device 659 ): 660 continue 661 port_names.append(port_info.device) 662 return sorted(port_names) 663 664 @staticmethod 665 def _port_is_free(port_name): 666 """Return True if the port is not already open in another program.""" 667 port = serial.Serial() 668 port.port = port_name 669 # Leaving the control lines low avoids resetting boards that 670 # reset on DTR while the port is probed. 671 port.dtr = False 672 port.rts = False 673 try: 674 port.open() 675 except (serial.SerialException, OSError): 676 return False 677 port.close() 678 return True 679 680 def set_default_params(self): 681 """Reset the local copy of all parameters to their defaults. 682 683 The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for 684 1 second, on all four output channels, linked to trigger channel 685 1 in normal trigger mode. 686 687 This updates only the local copy. Call 688 `PulsePalDevice.sync_to_device` to program the device with them. 689 """ 690 nan = float("nan") 691 self.is_biphasic = [nan, 0, 0, 0, 0] 692 self.phase1_voltage = [nan, 5, 5, 5, 5] 693 self.phase2_voltage = [nan, -5, -5, -5, -5] 694 self.resting_voltage = [nan, 0, 0, 0, 0] 695 self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001] 696 self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001] 697 self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001] 698 self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01] 699 self.burst_duration = [nan, 0, 0, 0, 0] 700 self.inter_burst_interval = [nan, 0, 0, 0, 0] 701 self.pulse_train_duration = [nan, 1, 1, 1, 1] 702 self.pulse_train_delay = [nan, 0, 0, 0, 0] 703 self.link_trigger_channel1 = [nan, 1, 1, 1, 1] 704 self.link_trigger_channel2 = [nan, 0, 0, 0, 0] 705 self.custom_train_id = [nan, 0, 0, 0, 0] 706 self.custom_train_target = [nan, 0, 0, 0, 0] 707 self.custom_train_loop = [nan, 0, 0, 0, 0] 708 self.playback_mode = [nan, 0, 0, 0, 0] 709 self.trigger_mode = [nan, 0, 0] 710 711 def set_voltage(self, channel, voltage): 712 """Set an output channel to a fixed voltage. 713 714 The channel holds the voltage until it is set again or until a 715 pulse train is triggered on it. 716 717 Args: 718 channel: Output channel number, 1-4. 719 voltage: Voltage to set, in volts [-10, 10]. 720 721 Raises: 722 PulsePalError: If the voltage is outside [-10, 10], or the 723 device does not acknowledge the command. 724 """ 725 voltage_bits = self._volts_to_bits(voltage, "voltage") 726 self._write_serial( 727 (self._OP_MENU_BYTE, 79, channel), 728 "uint8", 729 voltage_bits, 730 "uint16", 731 ) 732 self._read_ack("set_fixed_voltage()") 733 734 def set_calibration(self, channel, voltage_offset): 735 """Calibrate the zero code of an output channel. 736 737 The offset is added to every voltage the channel produces, to 738 correct for DAC offset error. It is stored in the device's 739 EEPROM and reloaded on boot, so it only needs to be set once. 740 741 Requires Pulse Pal hardware v3 or newer. 742 743 Args: 744 channel: Output channel number, 1-4. 745 voltage_offset: Offset to apply, in volts [-0.1, 0.1]. 746 747 Raises: 748 PulsePalError: If the connected hardware is older than v3, 749 or the device does not acknowledge the command. 750 ValueError: If `channel` is not 1-4, or `voltage_offset` is 751 outside [-0.1, 0.1]. 752 """ 753 if self.info.hardware_version < 3: 754 raise PulsePalError( 755 "set_calibration() requires hardware v3 or newer." 756 ) 757 if channel not in (1, 2, 3, 4): 758 raise ValueError("channel must be 1, 2, 3 or 4") 759 if not -0.1 <= voltage_offset <= 0.1: # Also refuses NaN 760 raise ValueError( 761 "voltage_offset for zero code calibration must be in range " 762 "[-0.1, 0.1]" 763 ) 764 # To the nearest DAC code, halves to even, as the MATLAB class rounds it (int() truncated) 765 voltage_bits = int(round(voltage_offset * (1 / (20 / 65536)))) 766 self._write_serial( 767 (self._OP_MENU_BYTE, 96, channel - 1), 768 "uint8", 769 voltage_bits, 770 "int16", 771 ) 772 self._read_ack("set_calibration()") 773 774 def set_output_param(self, param_name, channel, value): 775 """Program an output channel parameter on the device. 776 777 The local copy of the parameter is updated to match, so a later 778 `PulsePalDevice.sync_to_device` will not undo the change. 779 780 ```python 781 P.set_output_param("is_biphasic", 1, 1) 782 P.set_output_param("phase1_voltage", 1, 10) 783 P.set_output_param(3, 1, -10) # same, by param code 784 P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3]) 785 P.set_output_param("phase1_duration", [2, 4], 0.002) 786 ``` 787 788 Setting all four channels in one call programs them with a single 789 command (firmware v22 or newer). Otherwise, each listed channel is 790 programmed with its own command. Channels that are not listed are 791 not changed. 792 793 Args: 794 param_name: Parameter name, as listed in 795 `DeviceInfo.output_parameter_names`, or its integer 796 parameter code. 797 channel: Output channel number, 1-4, or a list of distinct 798 output channel numbers. 799 value: Value to set. Units are volts for voltage parameters, 800 seconds for time parameters, and integers for enumerated 801 parameters. See the attributes of `PulsePalDevice` for 802 the meaning of each parameter. If `channel` is a list, 803 either one value for all listed channels, or a list with 804 one value per listed channel, in the same order. 805 806 Raises: 807 PulsePalError: If the parameter name is not recognized, the 808 channels or number of values are invalid, a value is out 809 of range for the parameter (nothing is sent), or the device 810 does not acknowledge the command. When the device refuses a 811 value, the local copy of the parameter is first read back 812 from the device, which resets a value it refuses. 813 """ 814 param_code = self._get_output_param_code(param_name) 815 816 if isinstance(channel, numbers.Integral): 817 channels = [int(channel)] 818 else: 819 channels = [int(ch) for ch in self._as_list(channel)] 820 if ( 821 not channels 822 or len(set(channels)) != len(channels) 823 or any(ch not in (1, 2, 3, 4) for ch in channels) 824 ): 825 raise PulsePalError( 826 "channel must be an output channel number, 1-4, or a list " 827 "of distinct output channel numbers." 828 ) 829 values = self._as_list(value) 830 if len(values) == 1: 831 values = values * len(channels) 832 if len(values) != len(channels): 833 raise PulsePalError( 834 f"{len(values)} values were given for {len(channels)} " 835 "channels. Give one value, or one value per channel." 836 ) 837 self._check_output_values(param_code, channels, values) 838 839 if sorted(channels) == [1, 2, 3, 4] and self.info.firmware_version > 21: 840 # One op 91 command programs this parameter on all four channels 841 values_by_channel = [values[channels.index(ch)] for ch in (1, 2, 3, 4)] 842 data, datatype = self._encode_output_param(param_code, values_by_channel) 843 self._write_serial( 844 (self._OP_MENU_BYTE, 91, param_code), 845 "uint8", 846 data, 847 datatype, 848 ) 849 self._read_ack( 850 "set_output_param()", 851 on_refusal=lambda: self._refresh_output_param(param_code, (1, 2, 3, 4)), 852 ) 853 for ch, channel_value in zip((1, 2, 3, 4), values_by_channel): 854 self._set_output_param_value(param_code, ch, channel_value) 855 else: 856 for ch, channel_value in zip(channels, values): 857 self._set_one_output_param(param_code, ch, channel_value) 858 859 def set_trigger_param(self, param_name, channel, value): 860 """Program a single trigger channel parameter on the device. 861 862 The local copy of the parameter is updated to match, so a later 863 `PulsePalDevice.sync_to_device` will not undo the change. 864 865 ```python 866 P.set_trigger_param("trigger_mode", 1, 2) # pulse gated 867 ``` 868 869 Args: 870 param_name: Parameter name, as listed in 871 `DeviceInfo.trigger_parameter_names`, or its integer 872 parameter code. 873 channel: Trigger channel number, 1-2. 874 value: Value to set. See `PulsePalDevice.trigger_mode` for 875 the trigger modes. 876 877 Raises: 878 PulsePalError: If the parameter name is not recognized, the 879 channel or value is out of range (nothing is sent), or the 880 device does not acknowledge the command. 881 """ 882 original_value = value 883 param_code = self._get_trigger_param_code(param_name) 884 if param_code == 128: 885 if not (isinstance(channel, numbers.Integral) and channel in (1, 2)): 886 raise PulsePalError( 887 f"channel must be a trigger channel number, 1 or 2. Received {channel!r}." 888 ) 889 self._check_trigger_mode(value, channel) 890 891 self._write_serial( 892 (self._OP_MENU_BYTE, 74, param_code, channel, value), 893 "uint8", 894 ) 895 self._read_ack( 896 "program_trigger_channel_param()", 897 on_refusal=lambda: self._refresh_trigger_mode(channel), 898 ) 899 900 if param_code in (1, 128): 901 self.trigger_mode[channel] = original_value 902 903 def sync_to_device(self): 904 """Program the device with the local copy of all parameters. 905 906 Call this after assigning to the parameter array attributes, to 907 send every output and trigger parameter to the device in a 908 single transaction. 909 910 ```python 911 P.phase1_voltage[1:5] = [5] * 4 912 P.sync_to_device() 913 ``` 914 915 On Pulse Pal 3, if either trigger channel is in param sync mode 916 (`PulsePalDevice.trigger_mode` 3), the device stores the 917 parameters instead of programming them, and loads them on the 918 next rising edge of that channel. The device still acknowledges 919 whether every value was in range. This is the only method whose 920 effect is deferred that way. 921 922 Raises: 923 PulsePalError: If a value is out of range for its parameter 924 (nothing is sent), or the device does not acknowledge the 925 command. 926 """ 927 self._check_program() 928 # _sync_all_params() for firmware v22+ uses the newer packed sync 929 # opcode (92). _sync_all_params_legacy() uses the less efficient 930 # legacy packed sync opcode (73). 931 if self.info.firmware_version > 21: 932 self._sync_all_params() 933 else: 934 self._sync_all_params_legacy() 935 self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync) 936 937 def sync_from_device(self): 938 """Read all parameters from the device into the local copy. 939 940 Overwrites every parameter array attribute with the program 941 currently stored on the device. Useful after the device has been 942 reprogrammed from its thumb joystick. 943 944 Requires firmware v22 or newer. 945 946 Raises: 947 PulsePalError: If the connected firmware is older than v22, 948 or the device does not return the full parameter set. 949 """ 950 self._require_firmware(22, "sync_from_device()") 951 for attr_name, values in self._read_device_params().items(): 952 setattr(self, attr_name, values) 953 954 def _read_device_params(self): 955 """Read the device's parameters (op 93). 956 957 Returns a dict of parameter lists, indexed by channel like the 958 parameter array attributes. Continuous playback mode is not part 959 of op 93, so `playback_mode` is not included. 960 """ 961 self._write_serial((self._OP_MENU_BYTE, 93), "uint8") 962 # The device sends the whole parameter set as one message, so read it in one go and 963 # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md. 964 message = self._read_raw(self._PARAM_MESSAGE_BYTES) 965 values = struct.unpack( 966 f"{self._ENDIANNESS}32I12H26B", 967 message, 968 ) 969 params = {} 970 971 for index, attr_name in enumerate(( 972 "phase1_duration", 973 "inter_phase_interval", 974 "phase2_duration", 975 "inter_pulse_interval", 976 "burst_duration", 977 "inter_burst_interval", 978 "pulse_train_duration", 979 "pulse_train_delay", 980 )): 981 cycles = values[index * 4:index * 4 + 4] 982 params[attr_name] = [float("nan")] + [self._cycles_to_seconds(x) for x in cycles] 983 984 for index, attr_name in enumerate(( 985 "phase1_voltage", 986 "phase2_voltage", 987 "resting_voltage", 988 )): 989 bits = values[32 + index * 4:32 + index * 4 + 4] 990 params[attr_name] = [float("nan")] + [self._bits_to_volts(x) for x in bits] 991 992 for index, attr_name in enumerate(( 993 "is_biphasic", 994 "custom_train_id", 995 "custom_train_target", 996 "custom_train_loop", 997 "link_trigger_channel1", 998 "link_trigger_channel2", 999 )): 1000 params[attr_name] = [float("nan")] + list(values[44 + index * 4:44 + index * 4 + 4]) 1001 params["trigger_mode"] = [float("nan")] + list(values[68:70]) 1002 return params 1003 1004 def send_custom_pulse_train( 1005 self, 1006 custom_train_id, 1007 pulse_times, 1008 pulse_voltages, 1009 ): 1010 """Load a custom pulse train onto the device. 1011 1012 A custom pulse train is an arbitrary list of pulse onset times 1013 and voltages, replacing the parametric pulse voltage and onset timing. 1014 Set `PulsePalDevice.custom_train_id` on an output channel to play 1015 the train there. 1016 1017 ```python 1018 P.send_custom_pulse_train( 1019 2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10] 1020 ) 1021 P.set_output_param("custom_train_id", 1, 2) 1022 ``` 1023 1024 Args: 1025 custom_train_id: Custom train to load, from 1 to 1026 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1027 4 on Pulse Pal 3). 1028 pulse_times: Pulse onset times, in seconds, relative to the 1029 start of the train. Accepts a list, tuple or NumPy 1030 array. 1031 pulse_voltages: Voltage of each pulse, in volts [-10, 10]. 1032 Must be the same length as `pulse_times`. 1033 1034 Raises: 1035 PulsePalError: If `custom_train_id` is out of range, 1036 `pulse_times` and `pulse_voltages` differ in length, 1037 there are more pulses than 1038 `DeviceInfo.max_custom_pulses`, a pulse time is less 1039 than `DeviceInfo.min_pulse_width_us` after the one before 1040 it (after rounding to the device's timer cycle), or the 1041 device does not acknowledge the command. 1042 """ 1043 pulse_times = self._as_list(pulse_times) 1044 pulse_voltages = self._as_list(pulse_voltages) 1045 n_pulses = len(pulse_times) 1046 if n_pulses != len(pulse_voltages): 1047 raise PulsePalError( 1048 "pulse_times and pulse_voltages must be the same length." 1049 ) 1050 1051 pulse_times_cycles = [ 1052 self._seconds_to_cycles(pulse_time, "pulse_times") 1053 for pulse_time in pulse_times 1054 ] 1055 pulse_voltage_bits = [ 1056 self._volts_to_bits(voltage, "pulse_voltages") 1057 for voltage in pulse_voltages 1058 ] 1059 1060 self._send_custom_train( 1061 custom_train_id, 1062 pulse_times_cycles, 1063 pulse_voltage_bits, 1064 "send_custom_pulse_train()", 1065 ) 1066 1067 def send_custom_waveform( 1068 self, 1069 custom_train_id, 1070 pulse_width, 1071 pulse_voltages, 1072 ): 1073 """Load an arbitrary waveform onto the device. 1074 1075 A convenience shorthand for 1076 `PulsePalDevice.send_custom_pulse_train` with evenly spaced, 1077 confluent pulses, so that `pulse_voltages` is played as a 1078 waveform sampled every `pulse_width` seconds. 1079 1080 Set the channel's `PulsePalDevice.phase1_duration` to 1081 `pulse_width` as well, so that each sample is held for the 1082 sampling period. 1083 1084 ```python 1085 import math 1086 1087 samples = [math.sin(i / 10.0) * 10 for i in range(1000)] 1088 P.send_custom_waveform(1, 0.001, samples) # 1 kHz 1089 P.set_output_param("custom_train_id", 2, 1) 1090 P.set_output_param("phase1_duration", 2, 0.001) 1091 ``` 1092 1093 Args: 1094 custom_train_id: Custom train to load, from 1 to 1095 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1096 4 on Pulse Pal 3). 1097 pulse_width: Sampling period, in seconds. Each voltage is 1098 held for this long. 1099 pulse_voltages: Waveform samples, in volts [-10, 10]. 1100 Accepts a list, tuple or NumPy array. 1101 1102 Raises: 1103 PulsePalError: If `custom_train_id` is out of range, there 1104 are more samples than `DeviceInfo.max_custom_pulses`, 1105 `pulse_width` rounds to less than 1106 `DeviceInfo.min_pulse_width_us`, or the device does not 1107 acknowledge the command. 1108 """ 1109 pulse_voltages = self._as_list(pulse_voltages) 1110 n_pulses = len(pulse_voltages) 1111 pulse_width_cycles = self._seconds_to_cycles(pulse_width, "pulse_width") 1112 pulse_times = [pulse_width_cycles * i for i in range(n_pulses)] 1113 pulse_voltage_bits = [ 1114 self._volts_to_bits(voltage, "pulse_voltages") 1115 for voltage in pulse_voltages 1116 ] 1117 1118 self._send_custom_train( 1119 custom_train_id, 1120 pulse_times, 1121 pulse_voltage_bits, 1122 "send_custom_waveform()", 1123 ) 1124 1125 def trigger( 1126 self, 1127 channel1=None, 1128 channel2=None, 1129 channel3=None, 1130 channel4=None, 1131 ): 1132 """Trigger output channels in software. 1133 1134 Triggered channels start their pulse trains together. Three 1135 calling conventions are accepted: 1136 1137 ```python 1138 P.trigger(1, 0, 1, 0) # one flag per channel 1139 P.trigger(3) # a single channel number 1140 P.trigger([1, 4]) # several channel numbers 1141 ``` 1142 1143 Channel numbers may be NumPy integers, and a list of them may be a 1144 NumPy array. A channel that is already playing a pulse train 1145 ignores the trigger, and `stop()` cancels a trigger that has not 1146 started its channel yet. 1147 1148 Args: 1149 channel1: `1` to trigger channel 1, otherwise `0`; or, when 1150 it is the only argument given, a single channel number 1151 or a list of channel numbers to trigger. 1152 channel2: `1` to trigger channel 2, otherwise `0`. 1153 channel3: `1` to trigger channel 3, otherwise `0`. 1154 channel4: `1` to trigger channel 4, otherwise `0`. 1155 1156 Raises: 1157 PulsePalError: If a channel number is not 1-4, or a flag is not 1158 0 or 1. Nothing is triggered. 1159 """ 1160 trigger_byte = 0 1161 1162 # Options 2 & 3: Only one argument was provided 1163 if channel2 is None and channel3 is None and channel4 is None: 1164 # A single channel number, or a list of them (ch1=bit0, ch2=bit1, etc.) 1165 for ch in self._output_channel_numbers(channel1, "trigger()"): 1166 trigger_byte |= (1 << (ch - 1)) 1167 1168 # Option 1: Original input scheme (logicals for each channel) 1169 else: 1170 # Fallback to 0 if an argument was omitted via kwargs 1171 flags = [0 if flag is None else flag for flag in (channel1, channel2, channel3, channel4)] 1172 for bit, flag in enumerate(flags): 1173 if not (isinstance(flag, numbers.Integral) and flag in (0, 1)): 1174 raise PulsePalError( 1175 "trigger(): with more than one argument, each is a flag for one " 1176 f"channel, 0 or 1. Received {flag!r} for channel {bit + 1}." 1177 ) 1178 trigger_byte |= int(flag) << bit 1179 1180 self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8") 1181 1182 def sd_settings(self, settings_file_name, op): 1183 """Save, load, or delete a settings file on the microSD card. 1184 1185 A settings file holds a complete Pulse Pal program, so that it 1186 can be recalled later from software or from the device's front 1187 panel. Loading a file also refreshes the local copy of the 1188 parameters, via `PulsePalDevice.sync_from_device`. 1189 1190 ```python 1191 P.sd_settings("MyProtocol.pps", "save") 1192 ``` 1193 1194 Args: 1195 settings_file_name: Settings file name, at most 15 ASCII 1196 characters including the required `.pps` extension. 1197 op: `"save"`, `"load"`, or `"delete"`. 1198 1199 Raises: 1200 PulsePalError: If the file name has no `.pps` extension or 1201 is too long, `op` is not one of the three operations, or 1202 the device does not acknowledge the command. A load that 1203 fails leaves the device on its own default parameters 1204 (not the ones `set_default_params` sets), and the local 1205 copy is read back from the device before the error is 1206 raised. 1207 """ 1208 if ".pps" not in settings_file_name: 1209 raise PulsePalError( 1210 "Error: The file name must have a valid .pps extension." 1211 ) 1212 op_byte_by_name = {"save": 1, "load": 2, "delete": 3} 1213 try: 1214 op_byte = op_byte_by_name[str(op).lower()] 1215 except KeyError as exc: 1216 raise PulsePalError( 1217 "File op must be: 'save', 'load' or 'delete'." 1218 ) from exc 1219 1220 filename_bytes = settings_file_name.encode("ascii") 1221 if len(filename_bytes) > 15: 1222 raise PulsePalError("settings_file_name is too long.") 1223 self._write_serial( 1224 (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)), 1225 "uint8", 1226 list(filename_bytes), 1227 "uint8", 1228 ) 1229 if self.info.firmware_version > 21: 1230 # Sent after the file operation has finished. A refused load leaves the device on 1231 # its default parameters, so the local copy is read back first. 1232 self._read_ack( 1233 "sd_settings()", 1234 on_refusal=self.sync_from_device if op_byte == 2 else None, 1235 ) 1236 elif op_byte == 2: 1237 time.sleep(0.1) # Firmware v21 does not acknowledge, so allow time for the load 1238 if op_byte == 2: 1239 self.sync_from_device() 1240 1241 def stop(self, channels=None): 1242 """Stop pulse trains currently playing on the device. 1243 1244 Every output channel returns to its 1245 `PulsePalDevice.resting_voltage`. 1246 1247 Args: 1248 channels (list or tuple, optional): A list of channels to stop, e.g., 1249 [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single 1250 channel number. NumPy integers and arrays are accepted. Default is 1251 None, which stops all channels. (Requires firmware v22+) 1252 1253 Raises: 1254 PulsePalError: If a channel number is not 1-4. 1255 """ 1256 bit_code = 15 # Default: 15 (binary 1111) stops all channels 1257 1258 if channels is not None: 1259 if self.info.firmware_version < 22: 1260 raise ValueError("stop() cannot address individual channels prior to firmware v22") 1261 1262 bit_code = 0 1263 for ch in self._output_channel_numbers(channels, "stop()"): 1264 # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries 1265 bit_code |= 1 << (ch - 1) 1266 1267 # Send 1268 if self.info.firmware_version < 22: 1269 self._write_serial((self._OP_MENU_BYTE, 80), "uint8") 1270 else: 1271 self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8") 1272 1273 def format_microsd(self, timeout=30): 1274 """Format the device's microSD card. 1275 1276 Erases every settings file stored on the device and resets its 1277 parameters to the defaults. The user is prompted at the console 1278 to confirm before anything is erased. 1279 1280 Requires Pulse Pal hardware v3 or newer. 1281 1282 Args: 1283 timeout: Seconds to wait for the device to report that 1284 formatting has finished. 1285 1286 Returns: 1287 `None` once the card has been formatted, or an empty string 1288 if the user declines the confirmation prompt. 1289 1290 Raises: 1291 PulsePalError: If the connected hardware is older than v3, if 1292 the device reports that formatting failed, or if it does 1293 not report a result within `timeout` seconds. 1294 """ 1295 if self.info.hardware_version < 3: 1296 raise PulsePalError( 1297 "format_microsd() requires hardware v3 or newer." 1298 ) 1299 1300 print("*** Pulse Pal microSD Formatter ***") 1301 print("This will format Pulse Pal's microSD card,") 1302 print("erase all settings files on the device") 1303 print("and reset all parameters to defaults.") 1304 1305 reply = input("Do you want to continue (y/n)") 1306 1307 if reply.strip().lower() != "y": 1308 print("Choice confirmed - microSD Card NOT formatted.") 1309 return "" 1310 1311 self._write_serial((self._OP_MENU_BYTE, 97), "uint8") 1312 1313 # The device replies with lines of status text, the last of which 1314 # contains "!", and then a confirm byte (1 if the card was formatted, 1315 # 0 if not). The confirm byte is sent after the device has reloaded 1316 # its default parameters, so it can arrive well after the text. It 1317 # must be read here: left in the buffer, it would be taken as the 1318 # reply to the next command, and every reply after that would be 1319 # read one byte late. 1320 start = time.time() 1321 message = bytearray() 1322 flag_index = -1 1323 line_end = -1 1324 1325 while time.time() - start < timeout: 1326 n_waiting = self.bytes_available() 1327 if n_waiting: 1328 message.extend(self.port.read(n_waiting)) 1329 flag_index = message.find(b"!") 1330 if flag_index >= 0: 1331 line_end = message.find(b"\n", flag_index) 1332 if line_end >= 0 and len(message) > line_end + 1: 1333 break 1334 time.sleep(0.01) 1335 if line_end < 0 or len(message) <= line_end + 1: 1336 raise PulsePalError( 1337 "Error: Pulse Pal did not report the result of formatting " 1338 f"its microSD card within {timeout} s." 1339 ) 1340 confirm = message[line_end + 1] 1341 1342 text = bytes(message[:flag_index]).decode( 1343 "ascii", errors="replace" 1344 ).rstrip() 1345 if text: 1346 print(text) 1347 1348 self.set_default_params() 1349 if confirm != 1: 1350 raise PulsePalError( 1351 "Error: Pulse Pal could not format its microSD card." 1352 ) 1353 return None 1354 1355 def gui(self, block=None, theme=None): 1356 """Open the Pulse Pal parameter GUI, or focus an open one. 1357 1358 The GUI edits its own copy of the parameters, and loads them to 1359 the device when its 'Load to Device' button is clicked. The 1360 window closes automatically when the device is closed or 1361 deleted. 1362 1363 Calling this while the GUI is already open focuses the existing 1364 window rather than opening a second one. 1365 1366 Args: 1367 block: If `True`, the call returns when the GUI is closed. If 1368 `False`, the call returns immediately, and the host 1369 application must run the Tk event loop. If `None`, the 1370 GUI blocks only when the host does not already provide a 1371 Tk event loop, e.g. when launched from a script. 1372 theme: `"light"` or `"dark"` to select the color theme, or 1373 `None` to match the desktop theme. Passing a theme to an 1374 already-open GUI recolors it in place. 1375 1376 Returns: 1377 The `PulsePalGUI.PulsePalGUI` instance driving the window. 1378 1379 Raises: 1380 ValueError: If the theme name is not recognized. 1381 """ 1382 gui = getattr(self, "_gui", None) 1383 if gui is not None and not gui.is_closed: 1384 if theme is not None: 1385 gui.set_theme(theme) 1386 gui.focus() 1387 return gui 1388 1389 try: 1390 from .PulsePalGUI import PulsePalGUI 1391 except ImportError: 1392 from PulsePalGUI import PulsePalGUI 1393 1394 gui = PulsePalGUI(self, theme=theme) 1395 self._gui = gui 1396 gui.start(block=block) 1397 return gui 1398 1399 def close(self, send_disconnect=True): 1400 """Close the connection to the device, and the GUI if open. 1401 1402 Safe to call more than once; later calls do nothing. Called 1403 automatically when leaving a `with` block and when the object is 1404 garbage collected. 1405 1406 Args: 1407 send_disconnect: If `True`, tell the device that the client 1408 is disconnecting before closing the port. Set to `False` 1409 when the device is in an unknown state, such as after a 1410 failed handshake. 1411 """ 1412 gui = getattr(self, "_gui", None) 1413 self._gui = None 1414 if gui is not None: 1415 try: 1416 gui.close() 1417 except Exception: 1418 # Cleanup must not raise; Tk may already be torn down 1419 pass 1420 1421 if getattr(self, "_closed", True): 1422 return 1423 1424 try: 1425 if send_disconnect and self.port and self.port.is_open: 1426 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 1427 finally: 1428 if self.port and self.port.is_open: 1429 self.port.close() 1430 self._closed = True 1431 1432 def bytes_available(self): 1433 """Return the number of bytes waiting in the serial read buffer. 1434 1435 Returns: 1436 Count of bytes that can be read without blocking. 1437 """ 1438 return self.port.in_waiting 1439 1440 def _send_custom_train(self, custom_train_id, pulse_times_cycles, 1441 pulse_voltage_bits, context): 1442 """Send a custom pulse train, already converted to device units. 1443 1444 Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76 1445 (trains 1 and 2 only) on firmware v21. 1446 """ 1447 n_trains = self.info.n_custom_pulse_trains 1448 try: 1449 train_id = int(custom_train_id) 1450 except (TypeError, ValueError): 1451 train_id = None 1452 if ( 1453 train_id is None 1454 or train_id != custom_train_id 1455 or not 1 <= train_id <= n_trains 1456 ): 1457 raise PulsePalError( 1458 f"{context}: custom_train_id must be an integer from 1 to " 1459 f"{n_trains}. Received {custom_train_id}." 1460 ) 1461 n_pulses = len(pulse_times_cycles) 1462 if n_pulses > self.info.max_custom_pulses: 1463 raise PulsePalError( 1464 f"{context}: {n_pulses} pulses were given. Pulse Pal can " 1465 f"store up to {self.info.max_custom_pulses} pulses per " 1466 "custom train." 1467 ) 1468 # The device plays each pulse until the next one's time, so a time 1469 # that is not later than the one before it would freeze the output 1470 # for the rest of the train, and one only a cycle later would play 1471 # a pulse too short for a trigger channel to detect (see 1472 # _MIN_PULSE_CYCLES). The check is on the times the device will 1473 # receive, after rounding to its timer cycles. 1474 for i in range(1, n_pulses): 1475 if (pulse_times_cycles[i] - pulse_times_cycles[i - 1] 1476 < self._MIN_PULSE_CYCLES): 1477 raise PulsePalError( 1478 f"{context}: pulse times must increase, by at least " 1479 f"{self._min_pulse_seconds()} s ({self._MIN_PULSE_CYCLES} " 1480 f"cycles of the device's {self.info.cycle_period_us} us " 1481 f"timer). Pulse {i + 1} is at " 1482 f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, " 1483 f"and pulse {i} is at " 1484 f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s." 1485 ) 1486 1487 if self.info.firmware_version > 21: 1488 header = (self._OP_MENU_BYTE, 95, train_id - 1) 1489 else: 1490 header = (self._OP_MENU_BYTE, 74 + train_id) 1491 self._write_serial( 1492 header, 1493 "uint8", 1494 n_pulses, 1495 "uint32", 1496 pulse_times_cycles, 1497 "uint32", 1498 pulse_voltage_bits, 1499 "uint16", 1500 ) 1501 self._read_ack(context) 1502 1503 def _get_output_param_code(self, param_name): 1504 """Resolve an output parameter name or code to its code.""" 1505 if isinstance(param_name, str): 1506 try: 1507 return self.info.output_parameter_names.index(param_name) + 1 1508 except ValueError as exc: 1509 raise PulsePalError( 1510 f"Unknown output parameter: {param_name}." 1511 ) from exc 1512 return int(param_name) 1513 1514 def _get_trigger_param_code(self, param_name): 1515 """Resolve a trigger parameter name or code to its code.""" 1516 if isinstance(param_name, str): 1517 try: 1518 index = self.info.trigger_parameter_names.index(param_name) 1519 return index + 128 1520 except ValueError as exc: 1521 raise PulsePalError( 1522 f"Unknown trigger parameter: {param_name}." 1523 ) from exc 1524 return int(param_name) 1525 1526 def _write_serial(self, *args): 1527 """Write one or more data/type pairs to the serial port.""" 1528 if len(args) % 2 != 0: 1529 raise PulsePalError( 1530 "Serial writes require data/type argument pairs." 1531 ) 1532 1533 payload = bytearray() 1534 for i in range(0, len(args), 2): 1535 payload.extend(self._pack_values(args[i], args[i + 1])) 1536 1537 bytes_written = self.port.write(bytes(payload)) 1538 if bytes_written != len(payload): 1539 raise PulsePalError( 1540 f"Error: wrote {bytes_written} byte(s), expected to write " 1541 f"{len(payload)} byte(s)." 1542 ) 1543 1544 def _read_raw(self, n_bytes): 1545 """Read exactly n_bytes from the serial port.""" 1546 message_bytes = self.port.read(n_bytes) 1547 if len(message_bytes) < n_bytes: 1548 raise PulsePalError( 1549 f"Error: serial port timed out. " 1550 f"{len(message_bytes)} byte(s) read. " 1551 f"Expected {n_bytes} byte(s)." 1552 ) 1553 return message_bytes 1554 1555 def _read_serial(self, n_values, datatype): 1556 """Read values from the serial port and unpack them with struct.""" 1557 datatype = self._normalize_datatype(datatype) 1558 fmt = self._STRUCT_FORMATS[datatype] 1559 n_values = int(n_values) 1560 n_bytes = n_values * struct.calcsize(fmt) 1561 message_bytes = self._read_raw(n_bytes) 1562 1563 values = struct.unpack( 1564 f"{self._ENDIANNESS}{n_values}{fmt}", 1565 message_bytes, 1566 ) 1567 if n_values == 1: 1568 return values[0] 1569 return list(values) 1570 1571 def _read_ack(self, context, on_refusal=None): 1572 """Read a one-byte acknowledgement from the device. 1573 1574 The device replies 1 if it executed the command, or 0 if it rejected 1575 the command because a value was out of range. On a 0, on_refusal() 1576 is called before the error is raised: the device resets a value it 1577 refuses, so callers use it to read the value back into the local copy. 1578 """ 1579 try: 1580 acknowledgement = self._read_serial(1, "uint8") 1581 except PulsePalError as exc: 1582 raise PulsePalError( 1583 "Error: Pulse Pal did not return an acknowledgement byte " 1584 f"after a call to {context}." 1585 ) from exc 1586 if acknowledgement != 1: 1587 refusal = PulsePalError( 1588 f"Error: Pulse Pal rejected the command sent by {context}. " 1589 "This usually means that a channel number, parameter code or " 1590 "value was out of range for the connected device." 1591 ) 1592 if on_refusal is not None: 1593 try: 1594 on_refusal() 1595 except PulsePalError as exc: 1596 raise refusal from exc 1597 raise refusal 1598 1599 def _pack_values(self, values, datatype): 1600 """Pack scalar, list/tuple, or NumPy array values into bytes.""" 1601 datatype = self._normalize_datatype(datatype) 1602 fmt = self._STRUCT_FORMATS[datatype] 1603 values_list = self._as_list(values) 1604 1605 if datatype == "char": 1606 values_list = self._normalize_char_values(values_list) 1607 elif datatype in self._TYPE_RANGES: 1608 values_list = self._normalize_int_values(values_list, datatype) 1609 else: 1610 values_list = [float(value) for value in values_list] 1611 1612 return struct.pack( 1613 f"{self._ENDIANNESS}{len(values_list)}{fmt}", 1614 *values_list, 1615 ) 1616 1617 def _normalize_datatype(self, datatype): 1618 """Return the datatype name, rejecting unsupported types.""" 1619 datatype = str(datatype) 1620 if datatype not in self._STRUCT_FORMATS: 1621 raise PulsePalError( 1622 f"Error: {datatype} is not a data type supported by " 1623 "PulsePalObject." 1624 ) 1625 return datatype 1626 1627 def _normalize_char_values(self, values): 1628 """Coerce str, int and bytes values to single ASCII bytes.""" 1629 normalized = [] 1630 for value in values: 1631 if isinstance(value, str): 1632 value = value.encode("ascii") 1633 if isinstance(value, int): 1634 value = bytes((value,)) 1635 if not isinstance(value, (bytes, bytearray)) or len(value) != 1: 1636 raise PulsePalError( 1637 "char values must be one-byte bytes, chars, or " 1638 "integers." 1639 ) 1640 normalized.append(bytes(value)) 1641 return normalized 1642 1643 def _normalize_int_values(self, values, datatype): 1644 """Coerce values to ints, rejecting any out of range.""" 1645 min_value, max_value = self._TYPE_RANGES[datatype] 1646 normalized = [] 1647 for value in values: 1648 value = int(value) 1649 if not min_value <= value <= max_value: 1650 raise PulsePalError( 1651 f"Value {value} is out of range for {datatype} " 1652 f"({min_value} to {max_value})." 1653 ) 1654 normalized.append(value) 1655 return normalized 1656 1657 def _as_list(self, values): 1658 """Return values as a flat list, wrapping scalars in one.""" 1659 if isinstance(values, np.ndarray): 1660 return values.ravel().tolist() 1661 if isinstance(values, (bytes, bytearray, str)): 1662 return [values] 1663 if isinstance(values, numbers.Number) or isinstance(values, Decimal): 1664 return [values] 1665 try: 1666 return list(values) 1667 except TypeError: 1668 return [values] 1669 1670 def _set_one_output_param(self, param_code, channel, value): 1671 """Program one output parameter on one channel (op 74).""" 1672 data, datatype = self._encode_output_param(param_code, value) 1673 self._write_serial( 1674 (self._OP_MENU_BYTE, 74, param_code, channel), 1675 "uint8", 1676 data, 1677 datatype, 1678 ) 1679 self._read_ack( 1680 "program_output_channel_param()", 1681 on_refusal=lambda: self._refresh_output_param(param_code, (channel,)), 1682 ) 1683 self._set_output_param_value(param_code, channel, value) 1684 1685 def _encode_output_param(self, param_code, values): 1686 """Convert output parameter values to device units. 1687 1688 Returns the converted values and the datatype the device reads for 1689 this parameter code: DAC bits for voltages, hardware timer cycles 1690 for times, and bytes for everything else. 1691 """ 1692 values = self._as_list(values) 1693 name = self._OUTPUT_PARAMETER_ATTRS.get(param_code, f"parameter {param_code}") 1694 if param_code in (2, 3, 17): 1695 return [self._volts_to_bits(v, name) for v in values], "uint16" 1696 if 4 <= param_code <= 11: 1697 return [self._seconds_to_cycles(v, name) for v in values], "uint32" 1698 return values, "uint8" 1699 1700 def _check_output_values(self, param_code, channels, values): 1701 """Raise PulsePalError, before anything is sent, for values the device cannot play. 1702 1703 The device refuses most of them too, but it also resets them (see validateOutputParams() 1704 in /Firmware/PulsePal3/USBOps.ino), so the local copy would describe a different program. 1705 """ 1706 for ch, value in zip(channels, values): 1707 self._check_output_value(param_code, value, ch) 1708 1709 def _check_program(self): 1710 """Check the whole local copy before sync_to_device() sends it. See _check_output_values().""" 1711 for param_code, attr_name in self._OUTPUT_PARAMETER_ATTRS.items(): 1712 values = getattr(self, attr_name) 1713 for ch in range(1, 5): 1714 self._check_output_value(param_code, values[ch], ch) 1715 for ch in (1, 2): 1716 self._check_trigger_mode(self.trigger_mode[ch], ch) 1717 1718 def _check_output_value(self, param_code, value, channel): 1719 """Raise PulsePalError if a value can never be played for this output parameter.""" 1720 attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code) 1721 if attr_name is None: 1722 return # The device refuses a code it does not know 1723 name = f"{attr_name} on channel {channel}" 1724 if param_code in (2, 3, 17): 1725 self._volts_to_bits(value, name) 1726 elif 4 <= param_code <= 11: 1727 cycles = self._seconds_to_cycles(value, name) 1728 # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001, 1729 # but is exactly 2 cycles of 50 us 1730 if cycles < self._MIN_PULSE_CYCLES and param_code in self._MIN_PULSE_PARAMETER_CODES: 1731 raise PulsePalError( 1732 f"{name} must be at least {self._min_pulse_seconds()} s " 1733 f"({self._MIN_PULSE_CYCLES} cycles of the device's {self.info.cycle_period_us} us " 1734 f"timer), so that trigger channels can detect the pulses. Received {value!r}." 1735 ) 1736 elif param_code == 14: 1737 self._check_whole_number(value, name, 0, self.info.n_custom_pulse_trains) 1738 elif param_code in self._BINARY_PARAMETER_CODES: 1739 self._check_whole_number(value, name, 0, 1) 1740 1741 def _min_pulse_seconds(self): 1742 """The shortest pulse phase, interval or train, in seconds. See _MIN_PULSE_CYCLES.""" 1743 return self._cycles_to_seconds(self._MIN_PULSE_CYCLES) 1744 1745 def _check_trigger_mode(self, value, channel): 1746 """Raise PulsePalError if value is not a trigger mode the device has.""" 1747 # Param sync mode (3) is on Pulse Pal 3 with firmware v22 or newer 1748 param_sync = (self.info.hardware_version or 2) > 2 and self.info.firmware_version > 21 1749 self._check_whole_number( 1750 value, f"trigger_mode on trigger channel {channel}", 0, 3 if param_sync else 2 1751 ) 1752 1753 def _check_whole_number(self, value, name, low, high): 1754 """Raise PulsePalError unless value is a whole number from low to high.""" 1755 valid = ( 1756 isinstance(value, numbers.Real) 1757 and math.isfinite(value) 1758 and value == int(value) 1759 and low <= value <= high 1760 ) 1761 if not valid: 1762 raise PulsePalError( 1763 f"{name} must be a whole number from {low} to {high}. Received {value!r}." 1764 ) 1765 1766 def _refresh_output_param(self, param_code, channels): 1767 """Read an output parameter back from the device into the local copy, for these channels. 1768 1769 Called after the device refused a value for them, which it resets. The parameter's other 1770 channels, and other parameters, keep their local values, which may hold edits not yet synced. 1771 """ 1772 attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code) 1773 if attr_name is None or self.info.firmware_version < 22: 1774 return # Nothing local to correct, or no op 93 to read it with 1775 device_values = self._read_device_params().get(attr_name) 1776 if device_values is None: 1777 return # Not in op 93 (continuous playback mode) 1778 local_values = getattr(self, attr_name) 1779 for ch in channels: 1780 local_values[ch] = device_values[ch] 1781 1782 def _refresh_trigger_mode(self, channel): 1783 """Read a trigger channel's mode back from the device, after the device refused a new one.""" 1784 if self.info.firmware_version > 21 and channel in (1, 2): 1785 self.trigger_mode[channel] = self._read_device_params()["trigger_mode"][channel] 1786 1787 def _refresh_after_refused_sync(self): 1788 """Read the parameters back after the device refused a sync_to_device() set. 1789 1790 The device programs the set with the refused values reset, so the local copy is read back. 1791 In param sync mode the device stores the set instead, and op 93 would return the parameters 1792 playing now, so the local copy is left as it is. 1793 """ 1794 if self.info.firmware_version > 21 and 3 not in self.trigger_mode[1:3]: 1795 self.sync_from_device() 1796 1797 def _output_channel_numbers(self, channels, context): 1798 """Return output channel numbers as a list of ints. 1799 1800 channels is one integer (NumPy integers included) or an iterable of them (NumPy arrays 1801 included). Raises PulsePalError for anything else, or a channel that is not 1-4. 1802 """ 1803 channel_list = self._as_list(channels) 1804 if not all(isinstance(ch, numbers.Integral) and 1 <= ch <= 4 for ch in channel_list): 1805 raise PulsePalError( 1806 f"{context}: channels must be output channel numbers 1-4, as one integer or a " 1807 f"list of them. Received {channels!r}." 1808 ) 1809 return [int(ch) for ch in channel_list] 1810 1811 def _set_output_param_value(self, param_code, channel, original_value): 1812 """Store a programmed value in its local parameter array.""" 1813 attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code) 1814 if attr_name is None: 1815 return 1816 values = getattr(self, attr_name) 1817 values[channel] = original_value 1818 1819 def _to_decimal(self, value): 1820 """Convert a value to a Decimal with PulsePal precision.""" 1821 return Decimal(value).quantize(Decimal("1.0000")) 1822 1823 def _volts_to_bits(self, value, name="voltage"): 1824 """Convert -10 V to +10 V to the corresponding DAC bit value. 1825 1826 Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do. 1827 Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would 1828 play a different voltage. 1829 """ 1830 try: 1831 volts = float(value) 1832 except (TypeError, ValueError): 1833 volts = float("nan") 1834 if not -10 <= volts <= 10: # Also refuses NaN 1835 raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.") 1836 bit_max = int(self._dac_bit_max) 1837 return int(round((volts + 10) / 20 * bit_max)) 1838 1839 def _bits_to_volts(self, value): 1840 """Convert a DAC code to volts, snapping clean values within 1 LSB.""" 1841 bit_max = int(self._dac_bit_max) 1842 raw_volts = (float(value) / bit_max * 20) - 10 1843 1844 # Calculate the voltage of 1 bit 1845 lsb_volts = 20.0 / bit_max 1846 1847 # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255) 1848 clean_volts = round(raw_volts, 3) 1849 1850 # If the raw voltage is within 1 bit of the clean voltage, snap to it 1851 if abs(raw_volts - clean_volts) <= lsb_volts: 1852 return clean_volts 1853 1854 # Otherwise, return the standard 4-decimal reading 1855 return round(raw_volts, 4) 1856 1857 def _seconds_to_cycles(self, value, name="time"): 1858 """Convert seconds to the corresponding refresh-cycle count. 1859 1860 Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do: 1861 125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is 1862 negative, not a number, or too long for the device. 1863 """ 1864 try: 1865 seconds = float(value) 1866 except (TypeError, ValueError): 1867 seconds = float("nan") 1868 if not (math.isfinite(seconds) and seconds >= 0): 1869 raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.") 1870 cycles = int(round(seconds * float(self.info.cycle_frequency))) 1871 if cycles > 2**32 - 1: 1872 raise PulsePalError( 1873 f"{name} is too long for the device ({2**32 - 1} cycles at most). Received {value!r}." 1874 ) 1875 return cycles 1876 1877 def _cycles_to_seconds(self, value): 1878 """Convert hardware timer cycle counts to seconds.""" 1879 return float(value) / float(self.info.cycle_frequency) 1880 1881 def _require_firmware(self, minimum_version, context): 1882 """Raise unless the device firmware is new enough for an op.""" 1883 if ( 1884 self.info.firmware_version is None 1885 or self.info.firmware_version < minimum_version 1886 ): 1887 raise PulsePalError( 1888 f"{context} requires firmware v{minimum_version} or newer. " 1889 f"Detected firmware is v{self.info.firmware_version}." 1890 ) 1891 1892 def _sync_all_params(self): 1893 """Send all parameters using the packed sync op (firmware v22+). 1894 1895 Values are grouped by width so that the whole program travels as 1896 one uint32 block, one uint16 block and one uint8 block. 1897 """ 1898 time_values = [] 1899 for attr_name in ( 1900 "phase1_duration", 1901 "inter_phase_interval", 1902 "phase2_duration", 1903 "inter_pulse_interval", 1904 "burst_duration", 1905 "inter_burst_interval", 1906 "pulse_train_duration", 1907 "pulse_train_delay", 1908 ): 1909 time_values.extend( 1910 self._seconds_to_cycles(getattr(self, attr_name)[channel]) 1911 for channel in range(1, 5) 1912 ) 1913 1914 voltage_values = [] 1915 for attr_name in ( 1916 "phase1_voltage", 1917 "phase2_voltage", 1918 "resting_voltage", 1919 ): 1920 voltage_values.extend( 1921 self._volts_to_bits(getattr(self, attr_name)[channel]) 1922 for channel in range(1, 5) 1923 ) 1924 1925 single_byte_values = [] 1926 for attr_name in ( 1927 "is_biphasic", 1928 "custom_train_id", 1929 "custom_train_target", 1930 "custom_train_loop", 1931 "playback_mode", 1932 ): 1933 single_byte_values.extend( 1934 int(getattr(self, attr_name)[channel]) 1935 for channel in range(1, 5) 1936 ) 1937 single_byte_values.extend( 1938 int(self.link_trigger_channel1[channel]) 1939 for channel in range(1, 5) 1940 ) 1941 single_byte_values.extend( 1942 int(self.link_trigger_channel2[channel]) 1943 for channel in range(1, 5) 1944 ) 1945 single_byte_values.extend( 1946 int(value) for value in self.trigger_mode[1:3] 1947 ) 1948 1949 self._write_serial( 1950 (self._OP_MENU_BYTE, 92), 1951 "uint8", 1952 time_values, 1953 "uint32", 1954 voltage_values, 1955 "uint16", 1956 single_byte_values, 1957 "uint8", 1958 ) 1959 1960 def _sync_all_params_legacy(self): 1961 """Send all parameters using the legacy sync op (firmware v21). 1962 1963 Equivalent to `_sync_all_params`, but lays the program out 1964 channel by channel as the older firmware expects. 1965 """ 1966 program_values_16 = [] 1967 program_values_32 = [] 1968 program_values_8 = [0] * 16 1969 1970 for channel in range(1, 5): 1971 program_values_32.extend( 1972 [ 1973 self._seconds_to_cycles( 1974 self.phase1_duration[channel]), 1975 self._seconds_to_cycles( 1976 self.inter_phase_interval[channel]), 1977 self._seconds_to_cycles( 1978 self.phase2_duration[channel]), 1979 self._seconds_to_cycles( 1980 self.inter_pulse_interval[channel]), 1981 self._seconds_to_cycles( 1982 self.burst_duration[channel]), 1983 self._seconds_to_cycles( 1984 self.inter_burst_interval[channel]), 1985 self._seconds_to_cycles( 1986 self.pulse_train_duration[channel]), 1987 self._seconds_to_cycles( 1988 self.pulse_train_delay[channel]), 1989 ] 1990 ) 1991 1992 for channel in range(1, 5): 1993 program_values_16.extend( 1994 [ 1995 self._volts_to_bits(self.phase1_voltage[channel]), 1996 self._volts_to_bits(self.phase2_voltage[channel]), 1997 self._volts_to_bits(self.resting_voltage[channel]), 1998 ] 1999 ) 2000 2001 position = 0 2002 for channel in range(1, 5): 2003 program_values_8[position] = self.is_biphasic[channel] 2004 position += 1 2005 program_values_8[position] = self.custom_train_id[channel] 2006 position += 1 2007 program_values_8[position] = self.custom_train_target[channel] 2008 position += 1 2009 program_values_8[position] = self.custom_train_loop[channel] 2010 position += 1 2011 2012 program_values_tl = [0] * 8 2013 position = 0 2014 for channel in range(1, 5): 2015 program_values_tl[position] = self.link_trigger_channel1[channel] 2016 position += 1 2017 for channel in range(1, 5): 2018 program_values_tl[position] = self.link_trigger_channel2[channel] 2019 position += 1 2020 2021 self._write_serial( 2022 (self._OP_MENU_BYTE, 73), 2023 "uint8", 2024 program_values_32, 2025 "uint32", 2026 program_values_16, 2027 "uint16", 2028 program_values_8, 2029 "uint8", 2030 program_values_tl, 2031 "uint8", 2032 self.trigger_mode[1:3], 2033 "uint8", 2034 ) 2035 2036 def __enter__(self): 2037 """Enter a `with` block, returning the connected device.""" 2038 return self 2039 2040 def __exit__(self, exc_type, exc_value, traceback): 2041 """Disconnect and close the port when leaving a `with` block. 2042 2043 Returns: 2044 `False`, so any exception raised in the block propagates. 2045 """ 2046 try: 2047 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 2048 except Exception: 2049 pass 2050 self.close() 2051 return False 2052 2053 def __del__(self): 2054 """Disconnect and close the port when the object is collected.""" 2055 try: 2056 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 2057 except Exception: 2058 pass 2059 2060 try: 2061 self.close() 2062 except Exception: 2063 # Destructors should not raise; the serial object may already be 2064 # gone during interpreter shutdown. 2065 pass
181class PulsePalDevice: 182 """A class to control a Pulse Pal device on a USB serial port. 183 184 Creating an instance opens the serial port, exchanges a handshake 185 with the device, verifies that its firmware is supported, reads the 186 device properties into `PulsePalDevice.info`, and programs the 187 device with the default parameters. 188 189 ```python 190 from PulsePal import PulsePalDevice 191 192 P = PulsePalDevice("COM3") 193 P.set_output_param("phase1_voltage", 1, 5) 194 P.trigger(1) 195 P.close() 196 ``` 197 198 Here, replace "COM3" with Pulse Pal's USB serial port name. 199 To view a list of available ports, use 200 PulsePalDevice.serialportlist() 201 Pulse Pal's port may not be visible if it is connected to another 202 instance of PulsePalDevice or an external application. Use 203 PulsePalDevice.serialportlist('all') to view all ports. 204 If you see multiple available ports, disconnect Pulse Pal's USB plug 205 and re-run serialportlist(). Notice which port disappears from the list. 206 207 PulsePalDevice is also a context manager, which closes the connection on 208 exit even if an error is raised: 209 210 ```python 211 with PulsePalDevice("COM3") as P: 212 P.trigger(1) 213 ``` 214 215 The attributes below named after Pulse Pal parameters are the local 216 copy of the device's program. Each is a five element list indexed by 217 channel number, with index 0 unused. This way channels are addressed 218 by the index on the device, e.g. trigger channels 1-2 and output 219 channels 1-4. Assigning parameters does not update the device until 220 `PulsePalDevice.sync_to_device` is called; 221 `PulsePalDevice.set_output_param` programs one parameter right away. 222 """ 223 224 port: "serial.Serial" 225 """The open `serial.Serial` port connected to the device.""" 226 227 info: DeviceInfo 228 """Properties of the connected device. See `DeviceInfo`.""" 229 230 is_biphasic: list 231 """Pulse shape per channel: `0` for monophasic, `1` for biphasic. 232 233 Monophasic pulses use only the phase 1 parameters. Biphasic pulses 234 follow phase 1 with `PulsePalDevice.inter_phase_interval` and then 235 phase 2. 236 """ 237 238 phase1_voltage: list 239 """Voltage of the first phase of each pulse, in volts [-10, 10].""" 240 241 phase2_voltage: list 242 """Voltage of the second phase of each pulse, in volts [-10, 10]. 243 244 Used only when `PulsePalDevice.is_biphasic` is `1` for the channel. 245 """ 246 247 resting_voltage: list 248 """Voltage held between pulses, in volts [-10, 10]. 249 250 A new resting voltage reaches an idle channel's output at once. A 251 channel playing a pulse train keeps playing it, and moves to the new 252 resting voltage at its next transition to rest. 253 """ 254 255 phase1_duration: list 256 """Duration of the first phase of each pulse, in seconds. 257 258 At least `DeviceInfo.min_pulse_width_us`. 259 """ 260 261 inter_phase_interval: list 262 """Interval between the two phases of a biphasic pulse, in seconds. 263 264 The channel rests at `PulsePalDevice.resting_voltage` during the 265 interval. Used only when `PulsePalDevice.is_biphasic` is `1`. 266 """ 267 268 phase2_duration: list 269 """Duration of the second phase of each pulse, in seconds. 270 271 Used only when `PulsePalDevice.is_biphasic` is `1` for the channel. 272 At least `DeviceInfo.min_pulse_width_us`. 273 """ 274 275 inter_pulse_interval: list 276 """Interval from the end of one pulse to the onset of the next, in 277 seconds. At least `DeviceInfo.min_pulse_width_us`.""" 278 279 burst_duration: list 280 """Duration of each burst of pulses, in seconds. 281 282 Set to `0` to disable bursts, so that pulses continue for the whole 283 pulse train. A pulse starts only if it ends before the burst does: 284 its first phase, or for a biphasic pulse the whole pulse, so that 285 the end of a burst never cuts off a second phase. 286 """ 287 288 inter_burst_interval: list 289 """Interval between bursts of pulses, in seconds. 290 291 The channel rests at `PulsePalDevice.resting_voltage` between 292 bursts. Ignored when `PulsePalDevice.burst_duration` is `0`. 293 """ 294 295 pulse_train_duration: list 296 """Total duration of the pulse train, in seconds. 297 298 At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts 299 short a monophasic pulse still playing. A biphasic pulse starts only 300 if it can end by the end of the train, so that it keeps its second 301 phase. 302 """ 303 304 pulse_train_delay: list 305 """Delay from the trigger to the onset of the pulse train, in 306 seconds.""" 307 308 link_trigger_channel1: list 309 """Whether each output channel is linked to trigger channel 1. 310 311 `1` links the output channel to trigger channel 1, `0` unlinks it. 312 """ 313 314 link_trigger_channel2: list 315 """Whether each output channel is linked to trigger channel 2. 316 317 `1` links the output channel to trigger channel 2, `0` unlinks it. 318 """ 319 320 custom_train_id: list 321 """Custom pulse train played by each output channel. 322 323 `0` plays the parametrically defined train. `1` or higher plays the 324 matching custom train, previously loaded with 325 `PulsePalDevice.send_custom_pulse_train` or 326 `PulsePalDevice.send_custom_waveform`. 327 """ 328 329 custom_train_target: list 330 """What the timestamps of a custom train mark. 331 332 `0` if each timestamp is the onset of a pulse, `1` if each timestamp 333 is the onset of a burst of pulses. 334 """ 335 336 custom_train_loop: list 337 """Whether a custom train repeats. 338 339 `1` loops the custom train until 340 `PulsePalDevice.pulse_train_duration` has elapsed, `0` plays it 341 once. 342 """ 343 344 playback_mode: list 345 """Continuous playback mode of parametric pulse trains after being triggered 346 347 - `0` plays the pulse train once until pulse_train_duration seconds 348 - `1` plays the pulse train indefinitely, ignoring pulse_train_duration 349 350 """ 351 352 trigger_mode: list 353 """Response of each trigger channel to an incoming TTL pulse. 354 355 Three element list indexed by trigger channel, with index 0 unused. 356 Elements 1 and 2 control the respective channels on the device. 357 Their values can be: 358 359 - `0` (normal): a TTL rising edge starts the pulse train, and edges 360 during the train are ignored. 361 - `1` (toggle): same as 0 but a TTL rising edge during the train stops it. 362 - `2` (pulse gated): the train runs only while the trigger TTL is high. 363 - `3` (param sync, Pulse Pal 3 only): a TTL rising edge starts and 364 stops nothing. It loads the parameter set most recently sent by 365 `PulsePalDevice.sync_to_device`. This is how the next trial's 366 parameters are sent during the current trial and applied the 367 instant it starts. 368 369 An output channel that is idle at a param sync edge takes its new 370 parameters in the 50 us timer cycle the edge is detected. One that is 371 playing a pulse train finishes that train on the parameters it 372 started with, and takes the new ones the moment it ends, so a train 373 that runs past the end of a trial keeps one shape throughout and the 374 next trigger plays a whole train with the new parameters. A channel 375 in continuous playback mode has no train end, so it keeps its 376 parameters until something stops it. 377 378 While either trigger channel is in param sync mode, **only 379 `PulsePalDevice.sync_to_device` is held back**. 380 `PulsePalDevice.set_output_param`, `PulsePalDevice.set_trigger_param` 381 and every other parameter method still program the device 382 immediately. So leaving param sync mode means calling 383 `PulsePalDevice.set_trigger_param`; a trigger mode sent by 384 `PulsePalDevice.sync_to_device` does not take effect until a sync 385 edge arrives. 386 387 A param sync channel's links to output channels are ignored. To 388 start a pulse train on the same edge, wire the TTL to the other 389 trigger channel as well: both edges arrive in the same timer cycle, 390 and the parameters are loaded first. 391 392 Connecting a new `PulsePalDevice` takes both trigger channels out of 393 param sync mode, so that the default parameters it programs reach the 394 device instead of waiting for a TTL. 395 396 ```python 397 P.set_trigger_param("trigger_mode", 2, 3) # channel 2 does param sync 398 P.phase1_voltage[1:5] = [5] * 4 399 P.sync_to_device() # stored, not yet applied 400 # ... the next rising edge on trigger channel 2 applies it ... 401 ``` 402 """ 403 404 _CURRENT_FIRMWARE_VERSION = 22 405 406 _OP_MENU_BYTE = 213 407 _HANDSHAKE_OPCODE = 72 408 _HANDSHAKE_RESPONSE = 75 409 _WAVE_PAL_HANDSHAKE_RESPONSE = 87 # 'W': the device runs Wave Pal firmware 410 _DAC_BITMAX = 65535 411 _PARAM_MESSAGE_BYTES = 178 # Length of the parameter set sent by op 93 412 _OLDEST_FIRMWARE_SUPPORTED = 21 413 414 # Parameter names in order of their parameter codes (code = index + 1), 415 # matching _OUTPUT_PARAMETER_ATTRS and the firmware. 416 _OUTPUT_PARAMETER_NAMES = ( 417 "is_biphasic", 418 "phase1_voltage", 419 "phase2_voltage", 420 "phase1_duration", 421 "inter_phase_interval", 422 "phase2_duration", 423 "inter_pulse_interval", 424 "burst_duration", 425 "inter_burst_interval", 426 "pulse_train_duration", 427 "pulse_train_delay", 428 "link_trigger_channel1", 429 "link_trigger_channel2", 430 "custom_train_id", 431 "custom_train_target", 432 "custom_train_loop", 433 "resting_voltage", 434 "playback_mode", 435 ) 436 _TRIGGER_PARAMETER_NAMES = ("trigger_mode",) 437 # Output parameter codes whose values are 0 or 1 438 _BINARY_PARAMETER_CODES = (1, 12, 13, 15, 16, 18) 439 # Phase 1 and 2 durations, inter-pulse interval and train duration last 440 # at least this many timer cycles, as in the MATLAB and C++ classes and 441 # the joystick menu. A trigger channel reads its input once per cycle, so a 442 # one cycle pulse from another Pulse Pal could fall between two reads. 443 _MIN_PULSE_CYCLES = 2 444 _MIN_PULSE_PARAMETER_CODES = (4, 6, 7, 10) 445 446 _OUTPUT_PARAMETER_ATTRS = { 447 1: "is_biphasic", 448 2: "phase1_voltage", 449 3: "phase2_voltage", 450 4: "phase1_duration", 451 5: "inter_phase_interval", 452 6: "phase2_duration", 453 7: "inter_pulse_interval", 454 8: "burst_duration", 455 9: "inter_burst_interval", 456 10: "pulse_train_duration", 457 11: "pulse_train_delay", 458 12: "link_trigger_channel1", 459 13: "link_trigger_channel2", 460 14: "custom_train_id", 461 15: "custom_train_target", 462 16: "custom_train_loop", 463 17: "resting_voltage", 464 18: "playback_mode", 465 } 466 _ENDIANNESS = "<" 467 _STRUCT_FORMATS = { 468 "uint8": "B", 469 "int8": "b", 470 "char": "c", 471 "uint16": "H", 472 "int16": "h", 473 "uint32": "I", 474 "int32": "i", 475 "single": "f", 476 "double": "d", 477 } 478 _TYPE_RANGES = { 479 "uint8": (0, 2**8 - 1), 480 "int8": (-(2**7), 2**7 - 1), 481 "uint16": (0, 2**16 - 1), 482 "int16": (-(2**15), 2**15 - 1), 483 "uint32": (0, 2**32 - 1), 484 "int32": (-(2**31), 2**31 - 1), 485 } 486 487 def __init__(self, port_name, baud_rate=12000000, timeout=10): 488 """Open a connection to a Pulse Pal device. 489 490 Opens the serial port, exchanges the handshake, verifies the 491 firmware version, reads the device properties into 492 `PulsePalDevice.info`, and programs the device with the default 493 parameters. 494 495 Args: 496 port_name: USB serial port for the Pulse Pal device, such as 497 `COM3` on Windows or `/dev/ttyACM0` on Linux. 498 baud_rate: Serial baud rate. 499 timeout: Serial read timeout, in seconds. 500 501 Raises: 502 PulsePalError: If the device does not return the expected 503 handshake, or its firmware is older than v21, or its 504 firmware is newer than this module supports. The port is 505 closed again before any error is raised. 506 serial.SerialException: If the serial port cannot be opened. 507 """ 508 self.info = DeviceInfo() 509 self._gui = None 510 self.port = serial.Serial( 511 port_name, 512 baud_rate, 513 timeout=timeout, 514 rtscts=True, 515 ) 516 self._closed = False 517 try: 518 self._start_session(port_name) 519 except BaseException: 520 # Otherwise the port stays open until the object is garbage 521 # collected, and a second attempt, or WavePal.WavePalDevice, 522 # cannot open it. The device is in an unknown state, so it is not 523 # sent the disconnect op. 524 self.close(send_disconnect=False) 525 raise 526 527 def _start_session(self, port_name): 528 """Handshake, check the firmware, and program the defaults.""" 529 self._dac_bit_max = self._to_decimal(0) 530 self.info.firmware_version = None 531 self.info.hardware_version = None 532 self.info.output_parameter_names = list(self._OUTPUT_PARAMETER_NAMES) 533 self.info.trigger_parameter_names = list(self._TRIGGER_PARAMETER_NAMES) 534 535 self._write_serial( 536 (self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE), 537 "uint8", 538 ) 539 handshake = self._read_serial(1, "uint8") 540 if handshake == self._WAVE_PAL_HANDSHAKE_RESPONSE: 541 wave_pal_version = self._read_serial(1, "uint32") 542 raise PulsePalError( 543 f"Error: the device on {port_name} runs Wave Pal firmware " 544 f"(v{wave_pal_version}), not Pulse Pal firmware. To use it as " 545 "a Pulse Pal, load Pulse Pal firmware onto it (see " 546 "/Firmware/Readme.txt). To use it as a Wave Pal, connect " 547 "with WavePal.WavePalDevice." 548 ) 549 if handshake != self._HANDSHAKE_RESPONSE: 550 raise PulsePalError( 551 "Error: incorrect handshake returned. Expected " 552 f"{self._HANDSHAKE_RESPONSE}, received {handshake}." 553 ) 554 555 firmware_version = self._read_serial(1, "uint32") 556 if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED: 557 raise PulsePalError( 558 "Error: Old firmware detected, v" 559 f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or " 560 "newer is required." 561 ) 562 if firmware_version > self._CURRENT_FIRMWARE_VERSION: 563 raise PulsePalError( 564 "Error: Future firmware detected, v" 565 f"{firmware_version}. Please update PulsePal.py or downgrade " 566 f"firmware to v{self._CURRENT_FIRMWARE_VERSION}." 567 ) 568 if firmware_version < self._CURRENT_FIRMWARE_VERSION: 569 print( 570 "Old firmware detected, v" 571 f"{firmware_version}. This firmware is supported. Update to v" 572 f"{self._CURRENT_FIRMWARE_VERSION} is available." 573 ) 574 self._dac_bit_max = self._to_decimal(self._DAC_BITMAX) 575 self.info.firmware_version = firmware_version 576 577 if self.info.firmware_version > 21: 578 self._write_serial((self._OP_MENU_BYTE, 94), "uint8") 579 self.info.hardware_version = self._read_serial(1, "uint8") 580 self.info.cycle_period_us = self._read_serial(1, "uint32") 581 self.info.cycle_frequency = 1 / ( 582 self.info.cycle_period_us / 1000000 583 ) 584 self.info.n_custom_pulse_trains = self._read_serial(1, "uint8") 585 self.info.max_custom_pulses = self._read_serial(1, "uint32") 586 else: 587 self.info.hardware_version = 2 588 self.info.cycle_period_us = 50 589 self.info.cycle_frequency = 20000 590 self.info.n_custom_pulse_trains = 2 591 self.info.max_custom_pulses = 5000 592 self.info.min_pulse_width_us = ( 593 self._MIN_PULSE_CYCLES * self.info.cycle_period_us 594 ) 595 596 # Client name op + "PYTHON" in ASCII. 597 self._write_serial( 598 (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78), 599 "uint8", 600 ) 601 602 self.set_default_params() 603 if self.info.hardware_version > 2: 604 # A device left in param sync mode by an earlier session would 605 # store the sync below instead of running it, leaving the device 606 # on its old program until a TTL arrived. set_trigger_param is 607 # not deferred that way, so it takes both trigger channels out of 608 # param sync mode first. See PulsePalDevice.trigger_mode. 609 for channel in (1, 2): 610 self.set_trigger_param("trigger_mode", channel, 0) 611 self.sync_to_device() 612 613 @staticmethod 614 def serialportlist(ports_to_list="available"): 615 """Return the names of the USB serial ports on this computer. 616 617 Called on the class, without connecting to a device, to find the 618 port name to pass to `PulsePalDevice`: 619 620 ```python 621 from PulsePal import PulsePalDevice 622 623 ports = PulsePalDevice.serialportlist() 624 P = PulsePalDevice(ports[0]) 625 ``` 626 627 Args: 628 ports_to_list: `available` to list only the ports that are 629 not already in use, or `all` to list every USB serial 630 port. Not case sensitive. 631 632 Returns: 633 Sorted list of port names, such as `["COM3", "COM7"]` on 634 Windows or `["/dev/ttyACM0"]` on Linux. 635 636 Raises: 637 PulsePalError: If `ports_to_list` is not `available` or 638 `all`. 639 """ 640 if not isinstance(ports_to_list, str): 641 raise PulsePalError( 642 "serialportlist() takes 'available' or 'all'." 643 ) 644 mode = ports_to_list.lower() 645 if mode not in ("available", "all"): 646 raise PulsePalError( 647 f"Unknown port list type: {ports_to_list}. " 648 "Use 'available' or 'all'." 649 ) 650 651 port_names = [] 652 for port_info in serial.tools.list_ports.comports(): 653 is_usb = port_info.vid is not None or "USB" in ( 654 port_info.hwid or "" 655 ).upper() 656 if not is_usb: 657 continue 658 if mode == "available" and not PulsePalDevice._port_is_free( 659 port_info.device 660 ): 661 continue 662 port_names.append(port_info.device) 663 return sorted(port_names) 664 665 @staticmethod 666 def _port_is_free(port_name): 667 """Return True if the port is not already open in another program.""" 668 port = serial.Serial() 669 port.port = port_name 670 # Leaving the control lines low avoids resetting boards that 671 # reset on DTR while the port is probed. 672 port.dtr = False 673 port.rts = False 674 try: 675 port.open() 676 except (serial.SerialException, OSError): 677 return False 678 port.close() 679 return True 680 681 def set_default_params(self): 682 """Reset the local copy of all parameters to their defaults. 683 684 The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for 685 1 second, on all four output channels, linked to trigger channel 686 1 in normal trigger mode. 687 688 This updates only the local copy. Call 689 `PulsePalDevice.sync_to_device` to program the device with them. 690 """ 691 nan = float("nan") 692 self.is_biphasic = [nan, 0, 0, 0, 0] 693 self.phase1_voltage = [nan, 5, 5, 5, 5] 694 self.phase2_voltage = [nan, -5, -5, -5, -5] 695 self.resting_voltage = [nan, 0, 0, 0, 0] 696 self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001] 697 self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001] 698 self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001] 699 self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01] 700 self.burst_duration = [nan, 0, 0, 0, 0] 701 self.inter_burst_interval = [nan, 0, 0, 0, 0] 702 self.pulse_train_duration = [nan, 1, 1, 1, 1] 703 self.pulse_train_delay = [nan, 0, 0, 0, 0] 704 self.link_trigger_channel1 = [nan, 1, 1, 1, 1] 705 self.link_trigger_channel2 = [nan, 0, 0, 0, 0] 706 self.custom_train_id = [nan, 0, 0, 0, 0] 707 self.custom_train_target = [nan, 0, 0, 0, 0] 708 self.custom_train_loop = [nan, 0, 0, 0, 0] 709 self.playback_mode = [nan, 0, 0, 0, 0] 710 self.trigger_mode = [nan, 0, 0] 711 712 def set_voltage(self, channel, voltage): 713 """Set an output channel to a fixed voltage. 714 715 The channel holds the voltage until it is set again or until a 716 pulse train is triggered on it. 717 718 Args: 719 channel: Output channel number, 1-4. 720 voltage: Voltage to set, in volts [-10, 10]. 721 722 Raises: 723 PulsePalError: If the voltage is outside [-10, 10], or the 724 device does not acknowledge the command. 725 """ 726 voltage_bits = self._volts_to_bits(voltage, "voltage") 727 self._write_serial( 728 (self._OP_MENU_BYTE, 79, channel), 729 "uint8", 730 voltage_bits, 731 "uint16", 732 ) 733 self._read_ack("set_fixed_voltage()") 734 735 def set_calibration(self, channel, voltage_offset): 736 """Calibrate the zero code of an output channel. 737 738 The offset is added to every voltage the channel produces, to 739 correct for DAC offset error. It is stored in the device's 740 EEPROM and reloaded on boot, so it only needs to be set once. 741 742 Requires Pulse Pal hardware v3 or newer. 743 744 Args: 745 channel: Output channel number, 1-4. 746 voltage_offset: Offset to apply, in volts [-0.1, 0.1]. 747 748 Raises: 749 PulsePalError: If the connected hardware is older than v3, 750 or the device does not acknowledge the command. 751 ValueError: If `channel` is not 1-4, or `voltage_offset` is 752 outside [-0.1, 0.1]. 753 """ 754 if self.info.hardware_version < 3: 755 raise PulsePalError( 756 "set_calibration() requires hardware v3 or newer." 757 ) 758 if channel not in (1, 2, 3, 4): 759 raise ValueError("channel must be 1, 2, 3 or 4") 760 if not -0.1 <= voltage_offset <= 0.1: # Also refuses NaN 761 raise ValueError( 762 "voltage_offset for zero code calibration must be in range " 763 "[-0.1, 0.1]" 764 ) 765 # To the nearest DAC code, halves to even, as the MATLAB class rounds it (int() truncated) 766 voltage_bits = int(round(voltage_offset * (1 / (20 / 65536)))) 767 self._write_serial( 768 (self._OP_MENU_BYTE, 96, channel - 1), 769 "uint8", 770 voltage_bits, 771 "int16", 772 ) 773 self._read_ack("set_calibration()") 774 775 def set_output_param(self, param_name, channel, value): 776 """Program an output channel parameter on the device. 777 778 The local copy of the parameter is updated to match, so a later 779 `PulsePalDevice.sync_to_device` will not undo the change. 780 781 ```python 782 P.set_output_param("is_biphasic", 1, 1) 783 P.set_output_param("phase1_voltage", 1, 10) 784 P.set_output_param(3, 1, -10) # same, by param code 785 P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3]) 786 P.set_output_param("phase1_duration", [2, 4], 0.002) 787 ``` 788 789 Setting all four channels in one call programs them with a single 790 command (firmware v22 or newer). Otherwise, each listed channel is 791 programmed with its own command. Channels that are not listed are 792 not changed. 793 794 Args: 795 param_name: Parameter name, as listed in 796 `DeviceInfo.output_parameter_names`, or its integer 797 parameter code. 798 channel: Output channel number, 1-4, or a list of distinct 799 output channel numbers. 800 value: Value to set. Units are volts for voltage parameters, 801 seconds for time parameters, and integers for enumerated 802 parameters. See the attributes of `PulsePalDevice` for 803 the meaning of each parameter. If `channel` is a list, 804 either one value for all listed channels, or a list with 805 one value per listed channel, in the same order. 806 807 Raises: 808 PulsePalError: If the parameter name is not recognized, the 809 channels or number of values are invalid, a value is out 810 of range for the parameter (nothing is sent), or the device 811 does not acknowledge the command. When the device refuses a 812 value, the local copy of the parameter is first read back 813 from the device, which resets a value it refuses. 814 """ 815 param_code = self._get_output_param_code(param_name) 816 817 if isinstance(channel, numbers.Integral): 818 channels = [int(channel)] 819 else: 820 channels = [int(ch) for ch in self._as_list(channel)] 821 if ( 822 not channels 823 or len(set(channels)) != len(channels) 824 or any(ch not in (1, 2, 3, 4) for ch in channels) 825 ): 826 raise PulsePalError( 827 "channel must be an output channel number, 1-4, or a list " 828 "of distinct output channel numbers." 829 ) 830 values = self._as_list(value) 831 if len(values) == 1: 832 values = values * len(channels) 833 if len(values) != len(channels): 834 raise PulsePalError( 835 f"{len(values)} values were given for {len(channels)} " 836 "channels. Give one value, or one value per channel." 837 ) 838 self._check_output_values(param_code, channels, values) 839 840 if sorted(channels) == [1, 2, 3, 4] and self.info.firmware_version > 21: 841 # One op 91 command programs this parameter on all four channels 842 values_by_channel = [values[channels.index(ch)] for ch in (1, 2, 3, 4)] 843 data, datatype = self._encode_output_param(param_code, values_by_channel) 844 self._write_serial( 845 (self._OP_MENU_BYTE, 91, param_code), 846 "uint8", 847 data, 848 datatype, 849 ) 850 self._read_ack( 851 "set_output_param()", 852 on_refusal=lambda: self._refresh_output_param(param_code, (1, 2, 3, 4)), 853 ) 854 for ch, channel_value in zip((1, 2, 3, 4), values_by_channel): 855 self._set_output_param_value(param_code, ch, channel_value) 856 else: 857 for ch, channel_value in zip(channels, values): 858 self._set_one_output_param(param_code, ch, channel_value) 859 860 def set_trigger_param(self, param_name, channel, value): 861 """Program a single trigger channel parameter on the device. 862 863 The local copy of the parameter is updated to match, so a later 864 `PulsePalDevice.sync_to_device` will not undo the change. 865 866 ```python 867 P.set_trigger_param("trigger_mode", 1, 2) # pulse gated 868 ``` 869 870 Args: 871 param_name: Parameter name, as listed in 872 `DeviceInfo.trigger_parameter_names`, or its integer 873 parameter code. 874 channel: Trigger channel number, 1-2. 875 value: Value to set. See `PulsePalDevice.trigger_mode` for 876 the trigger modes. 877 878 Raises: 879 PulsePalError: If the parameter name is not recognized, the 880 channel or value is out of range (nothing is sent), or the 881 device does not acknowledge the command. 882 """ 883 original_value = value 884 param_code = self._get_trigger_param_code(param_name) 885 if param_code == 128: 886 if not (isinstance(channel, numbers.Integral) and channel in (1, 2)): 887 raise PulsePalError( 888 f"channel must be a trigger channel number, 1 or 2. Received {channel!r}." 889 ) 890 self._check_trigger_mode(value, channel) 891 892 self._write_serial( 893 (self._OP_MENU_BYTE, 74, param_code, channel, value), 894 "uint8", 895 ) 896 self._read_ack( 897 "program_trigger_channel_param()", 898 on_refusal=lambda: self._refresh_trigger_mode(channel), 899 ) 900 901 if param_code in (1, 128): 902 self.trigger_mode[channel] = original_value 903 904 def sync_to_device(self): 905 """Program the device with the local copy of all parameters. 906 907 Call this after assigning to the parameter array attributes, to 908 send every output and trigger parameter to the device in a 909 single transaction. 910 911 ```python 912 P.phase1_voltage[1:5] = [5] * 4 913 P.sync_to_device() 914 ``` 915 916 On Pulse Pal 3, if either trigger channel is in param sync mode 917 (`PulsePalDevice.trigger_mode` 3), the device stores the 918 parameters instead of programming them, and loads them on the 919 next rising edge of that channel. The device still acknowledges 920 whether every value was in range. This is the only method whose 921 effect is deferred that way. 922 923 Raises: 924 PulsePalError: If a value is out of range for its parameter 925 (nothing is sent), or the device does not acknowledge the 926 command. 927 """ 928 self._check_program() 929 # _sync_all_params() for firmware v22+ uses the newer packed sync 930 # opcode (92). _sync_all_params_legacy() uses the less efficient 931 # legacy packed sync opcode (73). 932 if self.info.firmware_version > 21: 933 self._sync_all_params() 934 else: 935 self._sync_all_params_legacy() 936 self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync) 937 938 def sync_from_device(self): 939 """Read all parameters from the device into the local copy. 940 941 Overwrites every parameter array attribute with the program 942 currently stored on the device. Useful after the device has been 943 reprogrammed from its thumb joystick. 944 945 Requires firmware v22 or newer. 946 947 Raises: 948 PulsePalError: If the connected firmware is older than v22, 949 or the device does not return the full parameter set. 950 """ 951 self._require_firmware(22, "sync_from_device()") 952 for attr_name, values in self._read_device_params().items(): 953 setattr(self, attr_name, values) 954 955 def _read_device_params(self): 956 """Read the device's parameters (op 93). 957 958 Returns a dict of parameter lists, indexed by channel like the 959 parameter array attributes. Continuous playback mode is not part 960 of op 93, so `playback_mode` is not included. 961 """ 962 self._write_serial((self._OP_MENU_BYTE, 93), "uint8") 963 # The device sends the whole parameter set as one message, so read it in one go and 964 # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md. 965 message = self._read_raw(self._PARAM_MESSAGE_BYTES) 966 values = struct.unpack( 967 f"{self._ENDIANNESS}32I12H26B", 968 message, 969 ) 970 params = {} 971 972 for index, attr_name in enumerate(( 973 "phase1_duration", 974 "inter_phase_interval", 975 "phase2_duration", 976 "inter_pulse_interval", 977 "burst_duration", 978 "inter_burst_interval", 979 "pulse_train_duration", 980 "pulse_train_delay", 981 )): 982 cycles = values[index * 4:index * 4 + 4] 983 params[attr_name] = [float("nan")] + [self._cycles_to_seconds(x) for x in cycles] 984 985 for index, attr_name in enumerate(( 986 "phase1_voltage", 987 "phase2_voltage", 988 "resting_voltage", 989 )): 990 bits = values[32 + index * 4:32 + index * 4 + 4] 991 params[attr_name] = [float("nan")] + [self._bits_to_volts(x) for x in bits] 992 993 for index, attr_name in enumerate(( 994 "is_biphasic", 995 "custom_train_id", 996 "custom_train_target", 997 "custom_train_loop", 998 "link_trigger_channel1", 999 "link_trigger_channel2", 1000 )): 1001 params[attr_name] = [float("nan")] + list(values[44 + index * 4:44 + index * 4 + 4]) 1002 params["trigger_mode"] = [float("nan")] + list(values[68:70]) 1003 return params 1004 1005 def send_custom_pulse_train( 1006 self, 1007 custom_train_id, 1008 pulse_times, 1009 pulse_voltages, 1010 ): 1011 """Load a custom pulse train onto the device. 1012 1013 A custom pulse train is an arbitrary list of pulse onset times 1014 and voltages, replacing the parametric pulse voltage and onset timing. 1015 Set `PulsePalDevice.custom_train_id` on an output channel to play 1016 the train there. 1017 1018 ```python 1019 P.send_custom_pulse_train( 1020 2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10] 1021 ) 1022 P.set_output_param("custom_train_id", 1, 2) 1023 ``` 1024 1025 Args: 1026 custom_train_id: Custom train to load, from 1 to 1027 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1028 4 on Pulse Pal 3). 1029 pulse_times: Pulse onset times, in seconds, relative to the 1030 start of the train. Accepts a list, tuple or NumPy 1031 array. 1032 pulse_voltages: Voltage of each pulse, in volts [-10, 10]. 1033 Must be the same length as `pulse_times`. 1034 1035 Raises: 1036 PulsePalError: If `custom_train_id` is out of range, 1037 `pulse_times` and `pulse_voltages` differ in length, 1038 there are more pulses than 1039 `DeviceInfo.max_custom_pulses`, a pulse time is less 1040 than `DeviceInfo.min_pulse_width_us` after the one before 1041 it (after rounding to the device's timer cycle), or the 1042 device does not acknowledge the command. 1043 """ 1044 pulse_times = self._as_list(pulse_times) 1045 pulse_voltages = self._as_list(pulse_voltages) 1046 n_pulses = len(pulse_times) 1047 if n_pulses != len(pulse_voltages): 1048 raise PulsePalError( 1049 "pulse_times and pulse_voltages must be the same length." 1050 ) 1051 1052 pulse_times_cycles = [ 1053 self._seconds_to_cycles(pulse_time, "pulse_times") 1054 for pulse_time in pulse_times 1055 ] 1056 pulse_voltage_bits = [ 1057 self._volts_to_bits(voltage, "pulse_voltages") 1058 for voltage in pulse_voltages 1059 ] 1060 1061 self._send_custom_train( 1062 custom_train_id, 1063 pulse_times_cycles, 1064 pulse_voltage_bits, 1065 "send_custom_pulse_train()", 1066 ) 1067 1068 def send_custom_waveform( 1069 self, 1070 custom_train_id, 1071 pulse_width, 1072 pulse_voltages, 1073 ): 1074 """Load an arbitrary waveform onto the device. 1075 1076 A convenience shorthand for 1077 `PulsePalDevice.send_custom_pulse_train` with evenly spaced, 1078 confluent pulses, so that `pulse_voltages` is played as a 1079 waveform sampled every `pulse_width` seconds. 1080 1081 Set the channel's `PulsePalDevice.phase1_duration` to 1082 `pulse_width` as well, so that each sample is held for the 1083 sampling period. 1084 1085 ```python 1086 import math 1087 1088 samples = [math.sin(i / 10.0) * 10 for i in range(1000)] 1089 P.send_custom_waveform(1, 0.001, samples) # 1 kHz 1090 P.set_output_param("custom_train_id", 2, 1) 1091 P.set_output_param("phase1_duration", 2, 0.001) 1092 ``` 1093 1094 Args: 1095 custom_train_id: Custom train to load, from 1 to 1096 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1097 4 on Pulse Pal 3). 1098 pulse_width: Sampling period, in seconds. Each voltage is 1099 held for this long. 1100 pulse_voltages: Waveform samples, in volts [-10, 10]. 1101 Accepts a list, tuple or NumPy array. 1102 1103 Raises: 1104 PulsePalError: If `custom_train_id` is out of range, there 1105 are more samples than `DeviceInfo.max_custom_pulses`, 1106 `pulse_width` rounds to less than 1107 `DeviceInfo.min_pulse_width_us`, or the device does not 1108 acknowledge the command. 1109 """ 1110 pulse_voltages = self._as_list(pulse_voltages) 1111 n_pulses = len(pulse_voltages) 1112 pulse_width_cycles = self._seconds_to_cycles(pulse_width, "pulse_width") 1113 pulse_times = [pulse_width_cycles * i for i in range(n_pulses)] 1114 pulse_voltage_bits = [ 1115 self._volts_to_bits(voltage, "pulse_voltages") 1116 for voltage in pulse_voltages 1117 ] 1118 1119 self._send_custom_train( 1120 custom_train_id, 1121 pulse_times, 1122 pulse_voltage_bits, 1123 "send_custom_waveform()", 1124 ) 1125 1126 def trigger( 1127 self, 1128 channel1=None, 1129 channel2=None, 1130 channel3=None, 1131 channel4=None, 1132 ): 1133 """Trigger output channels in software. 1134 1135 Triggered channels start their pulse trains together. Three 1136 calling conventions are accepted: 1137 1138 ```python 1139 P.trigger(1, 0, 1, 0) # one flag per channel 1140 P.trigger(3) # a single channel number 1141 P.trigger([1, 4]) # several channel numbers 1142 ``` 1143 1144 Channel numbers may be NumPy integers, and a list of them may be a 1145 NumPy array. A channel that is already playing a pulse train 1146 ignores the trigger, and `stop()` cancels a trigger that has not 1147 started its channel yet. 1148 1149 Args: 1150 channel1: `1` to trigger channel 1, otherwise `0`; or, when 1151 it is the only argument given, a single channel number 1152 or a list of channel numbers to trigger. 1153 channel2: `1` to trigger channel 2, otherwise `0`. 1154 channel3: `1` to trigger channel 3, otherwise `0`. 1155 channel4: `1` to trigger channel 4, otherwise `0`. 1156 1157 Raises: 1158 PulsePalError: If a channel number is not 1-4, or a flag is not 1159 0 or 1. Nothing is triggered. 1160 """ 1161 trigger_byte = 0 1162 1163 # Options 2 & 3: Only one argument was provided 1164 if channel2 is None and channel3 is None and channel4 is None: 1165 # A single channel number, or a list of them (ch1=bit0, ch2=bit1, etc.) 1166 for ch in self._output_channel_numbers(channel1, "trigger()"): 1167 trigger_byte |= (1 << (ch - 1)) 1168 1169 # Option 1: Original input scheme (logicals for each channel) 1170 else: 1171 # Fallback to 0 if an argument was omitted via kwargs 1172 flags = [0 if flag is None else flag for flag in (channel1, channel2, channel3, channel4)] 1173 for bit, flag in enumerate(flags): 1174 if not (isinstance(flag, numbers.Integral) and flag in (0, 1)): 1175 raise PulsePalError( 1176 "trigger(): with more than one argument, each is a flag for one " 1177 f"channel, 0 or 1. Received {flag!r} for channel {bit + 1}." 1178 ) 1179 trigger_byte |= int(flag) << bit 1180 1181 self._write_serial((self._OP_MENU_BYTE, 77, trigger_byte), "uint8") 1182 1183 def sd_settings(self, settings_file_name, op): 1184 """Save, load, or delete a settings file on the microSD card. 1185 1186 A settings file holds a complete Pulse Pal program, so that it 1187 can be recalled later from software or from the device's front 1188 panel. Loading a file also refreshes the local copy of the 1189 parameters, via `PulsePalDevice.sync_from_device`. 1190 1191 ```python 1192 P.sd_settings("MyProtocol.pps", "save") 1193 ``` 1194 1195 Args: 1196 settings_file_name: Settings file name, at most 15 ASCII 1197 characters including the required `.pps` extension. 1198 op: `"save"`, `"load"`, or `"delete"`. 1199 1200 Raises: 1201 PulsePalError: If the file name has no `.pps` extension or 1202 is too long, `op` is not one of the three operations, or 1203 the device does not acknowledge the command. A load that 1204 fails leaves the device on its own default parameters 1205 (not the ones `set_default_params` sets), and the local 1206 copy is read back from the device before the error is 1207 raised. 1208 """ 1209 if ".pps" not in settings_file_name: 1210 raise PulsePalError( 1211 "Error: The file name must have a valid .pps extension." 1212 ) 1213 op_byte_by_name = {"save": 1, "load": 2, "delete": 3} 1214 try: 1215 op_byte = op_byte_by_name[str(op).lower()] 1216 except KeyError as exc: 1217 raise PulsePalError( 1218 "File op must be: 'save', 'load' or 'delete'." 1219 ) from exc 1220 1221 filename_bytes = settings_file_name.encode("ascii") 1222 if len(filename_bytes) > 15: 1223 raise PulsePalError("settings_file_name is too long.") 1224 self._write_serial( 1225 (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)), 1226 "uint8", 1227 list(filename_bytes), 1228 "uint8", 1229 ) 1230 if self.info.firmware_version > 21: 1231 # Sent after the file operation has finished. A refused load leaves the device on 1232 # its default parameters, so the local copy is read back first. 1233 self._read_ack( 1234 "sd_settings()", 1235 on_refusal=self.sync_from_device if op_byte == 2 else None, 1236 ) 1237 elif op_byte == 2: 1238 time.sleep(0.1) # Firmware v21 does not acknowledge, so allow time for the load 1239 if op_byte == 2: 1240 self.sync_from_device() 1241 1242 def stop(self, channels=None): 1243 """Stop pulse trains currently playing on the device. 1244 1245 Every output channel returns to its 1246 `PulsePalDevice.resting_voltage`. 1247 1248 Args: 1249 channels (list or tuple, optional): A list of channels to stop, e.g., 1250 [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single 1251 channel number. NumPy integers and arrays are accepted. Default is 1252 None, which stops all channels. (Requires firmware v22+) 1253 1254 Raises: 1255 PulsePalError: If a channel number is not 1-4. 1256 """ 1257 bit_code = 15 # Default: 15 (binary 1111) stops all channels 1258 1259 if channels is not None: 1260 if self.info.firmware_version < 22: 1261 raise ValueError("stop() cannot address individual channels prior to firmware v22") 1262 1263 bit_code = 0 1264 for ch in self._output_channel_numbers(channels, "stop()"): 1265 # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries 1266 bit_code |= 1 << (ch - 1) 1267 1268 # Send 1269 if self.info.firmware_version < 22: 1270 self._write_serial((self._OP_MENU_BYTE, 80), "uint8") 1271 else: 1272 self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8") 1273 1274 def format_microsd(self, timeout=30): 1275 """Format the device's microSD card. 1276 1277 Erases every settings file stored on the device and resets its 1278 parameters to the defaults. The user is prompted at the console 1279 to confirm before anything is erased. 1280 1281 Requires Pulse Pal hardware v3 or newer. 1282 1283 Args: 1284 timeout: Seconds to wait for the device to report that 1285 formatting has finished. 1286 1287 Returns: 1288 `None` once the card has been formatted, or an empty string 1289 if the user declines the confirmation prompt. 1290 1291 Raises: 1292 PulsePalError: If the connected hardware is older than v3, if 1293 the device reports that formatting failed, or if it does 1294 not report a result within `timeout` seconds. 1295 """ 1296 if self.info.hardware_version < 3: 1297 raise PulsePalError( 1298 "format_microsd() requires hardware v3 or newer." 1299 ) 1300 1301 print("*** Pulse Pal microSD Formatter ***") 1302 print("This will format Pulse Pal's microSD card,") 1303 print("erase all settings files on the device") 1304 print("and reset all parameters to defaults.") 1305 1306 reply = input("Do you want to continue (y/n)") 1307 1308 if reply.strip().lower() != "y": 1309 print("Choice confirmed - microSD Card NOT formatted.") 1310 return "" 1311 1312 self._write_serial((self._OP_MENU_BYTE, 97), "uint8") 1313 1314 # The device replies with lines of status text, the last of which 1315 # contains "!", and then a confirm byte (1 if the card was formatted, 1316 # 0 if not). The confirm byte is sent after the device has reloaded 1317 # its default parameters, so it can arrive well after the text. It 1318 # must be read here: left in the buffer, it would be taken as the 1319 # reply to the next command, and every reply after that would be 1320 # read one byte late. 1321 start = time.time() 1322 message = bytearray() 1323 flag_index = -1 1324 line_end = -1 1325 1326 while time.time() - start < timeout: 1327 n_waiting = self.bytes_available() 1328 if n_waiting: 1329 message.extend(self.port.read(n_waiting)) 1330 flag_index = message.find(b"!") 1331 if flag_index >= 0: 1332 line_end = message.find(b"\n", flag_index) 1333 if line_end >= 0 and len(message) > line_end + 1: 1334 break 1335 time.sleep(0.01) 1336 if line_end < 0 or len(message) <= line_end + 1: 1337 raise PulsePalError( 1338 "Error: Pulse Pal did not report the result of formatting " 1339 f"its microSD card within {timeout} s." 1340 ) 1341 confirm = message[line_end + 1] 1342 1343 text = bytes(message[:flag_index]).decode( 1344 "ascii", errors="replace" 1345 ).rstrip() 1346 if text: 1347 print(text) 1348 1349 self.set_default_params() 1350 if confirm != 1: 1351 raise PulsePalError( 1352 "Error: Pulse Pal could not format its microSD card." 1353 ) 1354 return None 1355 1356 def gui(self, block=None, theme=None): 1357 """Open the Pulse Pal parameter GUI, or focus an open one. 1358 1359 The GUI edits its own copy of the parameters, and loads them to 1360 the device when its 'Load to Device' button is clicked. The 1361 window closes automatically when the device is closed or 1362 deleted. 1363 1364 Calling this while the GUI is already open focuses the existing 1365 window rather than opening a second one. 1366 1367 Args: 1368 block: If `True`, the call returns when the GUI is closed. If 1369 `False`, the call returns immediately, and the host 1370 application must run the Tk event loop. If `None`, the 1371 GUI blocks only when the host does not already provide a 1372 Tk event loop, e.g. when launched from a script. 1373 theme: `"light"` or `"dark"` to select the color theme, or 1374 `None` to match the desktop theme. Passing a theme to an 1375 already-open GUI recolors it in place. 1376 1377 Returns: 1378 The `PulsePalGUI.PulsePalGUI` instance driving the window. 1379 1380 Raises: 1381 ValueError: If the theme name is not recognized. 1382 """ 1383 gui = getattr(self, "_gui", None) 1384 if gui is not None and not gui.is_closed: 1385 if theme is not None: 1386 gui.set_theme(theme) 1387 gui.focus() 1388 return gui 1389 1390 try: 1391 from .PulsePalGUI import PulsePalGUI 1392 except ImportError: 1393 from PulsePalGUI import PulsePalGUI 1394 1395 gui = PulsePalGUI(self, theme=theme) 1396 self._gui = gui 1397 gui.start(block=block) 1398 return gui 1399 1400 def close(self, send_disconnect=True): 1401 """Close the connection to the device, and the GUI if open. 1402 1403 Safe to call more than once; later calls do nothing. Called 1404 automatically when leaving a `with` block and when the object is 1405 garbage collected. 1406 1407 Args: 1408 send_disconnect: If `True`, tell the device that the client 1409 is disconnecting before closing the port. Set to `False` 1410 when the device is in an unknown state, such as after a 1411 failed handshake. 1412 """ 1413 gui = getattr(self, "_gui", None) 1414 self._gui = None 1415 if gui is not None: 1416 try: 1417 gui.close() 1418 except Exception: 1419 # Cleanup must not raise; Tk may already be torn down 1420 pass 1421 1422 if getattr(self, "_closed", True): 1423 return 1424 1425 try: 1426 if send_disconnect and self.port and self.port.is_open: 1427 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 1428 finally: 1429 if self.port and self.port.is_open: 1430 self.port.close() 1431 self._closed = True 1432 1433 def bytes_available(self): 1434 """Return the number of bytes waiting in the serial read buffer. 1435 1436 Returns: 1437 Count of bytes that can be read without blocking. 1438 """ 1439 return self.port.in_waiting 1440 1441 def _send_custom_train(self, custom_train_id, pulse_times_cycles, 1442 pulse_voltage_bits, context): 1443 """Send a custom pulse train, already converted to device units. 1444 1445 Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76 1446 (trains 1 and 2 only) on firmware v21. 1447 """ 1448 n_trains = self.info.n_custom_pulse_trains 1449 try: 1450 train_id = int(custom_train_id) 1451 except (TypeError, ValueError): 1452 train_id = None 1453 if ( 1454 train_id is None 1455 or train_id != custom_train_id 1456 or not 1 <= train_id <= n_trains 1457 ): 1458 raise PulsePalError( 1459 f"{context}: custom_train_id must be an integer from 1 to " 1460 f"{n_trains}. Received {custom_train_id}." 1461 ) 1462 n_pulses = len(pulse_times_cycles) 1463 if n_pulses > self.info.max_custom_pulses: 1464 raise PulsePalError( 1465 f"{context}: {n_pulses} pulses were given. Pulse Pal can " 1466 f"store up to {self.info.max_custom_pulses} pulses per " 1467 "custom train." 1468 ) 1469 # The device plays each pulse until the next one's time, so a time 1470 # that is not later than the one before it would freeze the output 1471 # for the rest of the train, and one only a cycle later would play 1472 # a pulse too short for a trigger channel to detect (see 1473 # _MIN_PULSE_CYCLES). The check is on the times the device will 1474 # receive, after rounding to its timer cycles. 1475 for i in range(1, n_pulses): 1476 if (pulse_times_cycles[i] - pulse_times_cycles[i - 1] 1477 < self._MIN_PULSE_CYCLES): 1478 raise PulsePalError( 1479 f"{context}: pulse times must increase, by at least " 1480 f"{self._min_pulse_seconds()} s ({self._MIN_PULSE_CYCLES} " 1481 f"cycles of the device's {self.info.cycle_period_us} us " 1482 f"timer). Pulse {i + 1} is at " 1483 f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, " 1484 f"and pulse {i} is at " 1485 f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s." 1486 ) 1487 1488 if self.info.firmware_version > 21: 1489 header = (self._OP_MENU_BYTE, 95, train_id - 1) 1490 else: 1491 header = (self._OP_MENU_BYTE, 74 + train_id) 1492 self._write_serial( 1493 header, 1494 "uint8", 1495 n_pulses, 1496 "uint32", 1497 pulse_times_cycles, 1498 "uint32", 1499 pulse_voltage_bits, 1500 "uint16", 1501 ) 1502 self._read_ack(context) 1503 1504 def _get_output_param_code(self, param_name): 1505 """Resolve an output parameter name or code to its code.""" 1506 if isinstance(param_name, str): 1507 try: 1508 return self.info.output_parameter_names.index(param_name) + 1 1509 except ValueError as exc: 1510 raise PulsePalError( 1511 f"Unknown output parameter: {param_name}." 1512 ) from exc 1513 return int(param_name) 1514 1515 def _get_trigger_param_code(self, param_name): 1516 """Resolve a trigger parameter name or code to its code.""" 1517 if isinstance(param_name, str): 1518 try: 1519 index = self.info.trigger_parameter_names.index(param_name) 1520 return index + 128 1521 except ValueError as exc: 1522 raise PulsePalError( 1523 f"Unknown trigger parameter: {param_name}." 1524 ) from exc 1525 return int(param_name) 1526 1527 def _write_serial(self, *args): 1528 """Write one or more data/type pairs to the serial port.""" 1529 if len(args) % 2 != 0: 1530 raise PulsePalError( 1531 "Serial writes require data/type argument pairs." 1532 ) 1533 1534 payload = bytearray() 1535 for i in range(0, len(args), 2): 1536 payload.extend(self._pack_values(args[i], args[i + 1])) 1537 1538 bytes_written = self.port.write(bytes(payload)) 1539 if bytes_written != len(payload): 1540 raise PulsePalError( 1541 f"Error: wrote {bytes_written} byte(s), expected to write " 1542 f"{len(payload)} byte(s)." 1543 ) 1544 1545 def _read_raw(self, n_bytes): 1546 """Read exactly n_bytes from the serial port.""" 1547 message_bytes = self.port.read(n_bytes) 1548 if len(message_bytes) < n_bytes: 1549 raise PulsePalError( 1550 f"Error: serial port timed out. " 1551 f"{len(message_bytes)} byte(s) read. " 1552 f"Expected {n_bytes} byte(s)." 1553 ) 1554 return message_bytes 1555 1556 def _read_serial(self, n_values, datatype): 1557 """Read values from the serial port and unpack them with struct.""" 1558 datatype = self._normalize_datatype(datatype) 1559 fmt = self._STRUCT_FORMATS[datatype] 1560 n_values = int(n_values) 1561 n_bytes = n_values * struct.calcsize(fmt) 1562 message_bytes = self._read_raw(n_bytes) 1563 1564 values = struct.unpack( 1565 f"{self._ENDIANNESS}{n_values}{fmt}", 1566 message_bytes, 1567 ) 1568 if n_values == 1: 1569 return values[0] 1570 return list(values) 1571 1572 def _read_ack(self, context, on_refusal=None): 1573 """Read a one-byte acknowledgement from the device. 1574 1575 The device replies 1 if it executed the command, or 0 if it rejected 1576 the command because a value was out of range. On a 0, on_refusal() 1577 is called before the error is raised: the device resets a value it 1578 refuses, so callers use it to read the value back into the local copy. 1579 """ 1580 try: 1581 acknowledgement = self._read_serial(1, "uint8") 1582 except PulsePalError as exc: 1583 raise PulsePalError( 1584 "Error: Pulse Pal did not return an acknowledgement byte " 1585 f"after a call to {context}." 1586 ) from exc 1587 if acknowledgement != 1: 1588 refusal = PulsePalError( 1589 f"Error: Pulse Pal rejected the command sent by {context}. " 1590 "This usually means that a channel number, parameter code or " 1591 "value was out of range for the connected device." 1592 ) 1593 if on_refusal is not None: 1594 try: 1595 on_refusal() 1596 except PulsePalError as exc: 1597 raise refusal from exc 1598 raise refusal 1599 1600 def _pack_values(self, values, datatype): 1601 """Pack scalar, list/tuple, or NumPy array values into bytes.""" 1602 datatype = self._normalize_datatype(datatype) 1603 fmt = self._STRUCT_FORMATS[datatype] 1604 values_list = self._as_list(values) 1605 1606 if datatype == "char": 1607 values_list = self._normalize_char_values(values_list) 1608 elif datatype in self._TYPE_RANGES: 1609 values_list = self._normalize_int_values(values_list, datatype) 1610 else: 1611 values_list = [float(value) for value in values_list] 1612 1613 return struct.pack( 1614 f"{self._ENDIANNESS}{len(values_list)}{fmt}", 1615 *values_list, 1616 ) 1617 1618 def _normalize_datatype(self, datatype): 1619 """Return the datatype name, rejecting unsupported types.""" 1620 datatype = str(datatype) 1621 if datatype not in self._STRUCT_FORMATS: 1622 raise PulsePalError( 1623 f"Error: {datatype} is not a data type supported by " 1624 "PulsePalObject." 1625 ) 1626 return datatype 1627 1628 def _normalize_char_values(self, values): 1629 """Coerce str, int and bytes values to single ASCII bytes.""" 1630 normalized = [] 1631 for value in values: 1632 if isinstance(value, str): 1633 value = value.encode("ascii") 1634 if isinstance(value, int): 1635 value = bytes((value,)) 1636 if not isinstance(value, (bytes, bytearray)) or len(value) != 1: 1637 raise PulsePalError( 1638 "char values must be one-byte bytes, chars, or " 1639 "integers." 1640 ) 1641 normalized.append(bytes(value)) 1642 return normalized 1643 1644 def _normalize_int_values(self, values, datatype): 1645 """Coerce values to ints, rejecting any out of range.""" 1646 min_value, max_value = self._TYPE_RANGES[datatype] 1647 normalized = [] 1648 for value in values: 1649 value = int(value) 1650 if not min_value <= value <= max_value: 1651 raise PulsePalError( 1652 f"Value {value} is out of range for {datatype} " 1653 f"({min_value} to {max_value})." 1654 ) 1655 normalized.append(value) 1656 return normalized 1657 1658 def _as_list(self, values): 1659 """Return values as a flat list, wrapping scalars in one.""" 1660 if isinstance(values, np.ndarray): 1661 return values.ravel().tolist() 1662 if isinstance(values, (bytes, bytearray, str)): 1663 return [values] 1664 if isinstance(values, numbers.Number) or isinstance(values, Decimal): 1665 return [values] 1666 try: 1667 return list(values) 1668 except TypeError: 1669 return [values] 1670 1671 def _set_one_output_param(self, param_code, channel, value): 1672 """Program one output parameter on one channel (op 74).""" 1673 data, datatype = self._encode_output_param(param_code, value) 1674 self._write_serial( 1675 (self._OP_MENU_BYTE, 74, param_code, channel), 1676 "uint8", 1677 data, 1678 datatype, 1679 ) 1680 self._read_ack( 1681 "program_output_channel_param()", 1682 on_refusal=lambda: self._refresh_output_param(param_code, (channel,)), 1683 ) 1684 self._set_output_param_value(param_code, channel, value) 1685 1686 def _encode_output_param(self, param_code, values): 1687 """Convert output parameter values to device units. 1688 1689 Returns the converted values and the datatype the device reads for 1690 this parameter code: DAC bits for voltages, hardware timer cycles 1691 for times, and bytes for everything else. 1692 """ 1693 values = self._as_list(values) 1694 name = self._OUTPUT_PARAMETER_ATTRS.get(param_code, f"parameter {param_code}") 1695 if param_code in (2, 3, 17): 1696 return [self._volts_to_bits(v, name) for v in values], "uint16" 1697 if 4 <= param_code <= 11: 1698 return [self._seconds_to_cycles(v, name) for v in values], "uint32" 1699 return values, "uint8" 1700 1701 def _check_output_values(self, param_code, channels, values): 1702 """Raise PulsePalError, before anything is sent, for values the device cannot play. 1703 1704 The device refuses most of them too, but it also resets them (see validateOutputParams() 1705 in /Firmware/PulsePal3/USBOps.ino), so the local copy would describe a different program. 1706 """ 1707 for ch, value in zip(channels, values): 1708 self._check_output_value(param_code, value, ch) 1709 1710 def _check_program(self): 1711 """Check the whole local copy before sync_to_device() sends it. See _check_output_values().""" 1712 for param_code, attr_name in self._OUTPUT_PARAMETER_ATTRS.items(): 1713 values = getattr(self, attr_name) 1714 for ch in range(1, 5): 1715 self._check_output_value(param_code, values[ch], ch) 1716 for ch in (1, 2): 1717 self._check_trigger_mode(self.trigger_mode[ch], ch) 1718 1719 def _check_output_value(self, param_code, value, channel): 1720 """Raise PulsePalError if a value can never be played for this output parameter.""" 1721 attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code) 1722 if attr_name is None: 1723 return # The device refuses a code it does not know 1724 name = f"{attr_name} on channel {channel}" 1725 if param_code in (2, 3, 17): 1726 self._volts_to_bits(value, name) 1727 elif 4 <= param_code <= 11: 1728 cycles = self._seconds_to_cycles(value, name) 1729 # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001, 1730 # but is exactly 2 cycles of 50 us 1731 if cycles < self._MIN_PULSE_CYCLES and param_code in self._MIN_PULSE_PARAMETER_CODES: 1732 raise PulsePalError( 1733 f"{name} must be at least {self._min_pulse_seconds()} s " 1734 f"({self._MIN_PULSE_CYCLES} cycles of the device's {self.info.cycle_period_us} us " 1735 f"timer), so that trigger channels can detect the pulses. Received {value!r}." 1736 ) 1737 elif param_code == 14: 1738 self._check_whole_number(value, name, 0, self.info.n_custom_pulse_trains) 1739 elif param_code in self._BINARY_PARAMETER_CODES: 1740 self._check_whole_number(value, name, 0, 1) 1741 1742 def _min_pulse_seconds(self): 1743 """The shortest pulse phase, interval or train, in seconds. See _MIN_PULSE_CYCLES.""" 1744 return self._cycles_to_seconds(self._MIN_PULSE_CYCLES) 1745 1746 def _check_trigger_mode(self, value, channel): 1747 """Raise PulsePalError if value is not a trigger mode the device has.""" 1748 # Param sync mode (3) is on Pulse Pal 3 with firmware v22 or newer 1749 param_sync = (self.info.hardware_version or 2) > 2 and self.info.firmware_version > 21 1750 self._check_whole_number( 1751 value, f"trigger_mode on trigger channel {channel}", 0, 3 if param_sync else 2 1752 ) 1753 1754 def _check_whole_number(self, value, name, low, high): 1755 """Raise PulsePalError unless value is a whole number from low to high.""" 1756 valid = ( 1757 isinstance(value, numbers.Real) 1758 and math.isfinite(value) 1759 and value == int(value) 1760 and low <= value <= high 1761 ) 1762 if not valid: 1763 raise PulsePalError( 1764 f"{name} must be a whole number from {low} to {high}. Received {value!r}." 1765 ) 1766 1767 def _refresh_output_param(self, param_code, channels): 1768 """Read an output parameter back from the device into the local copy, for these channels. 1769 1770 Called after the device refused a value for them, which it resets. The parameter's other 1771 channels, and other parameters, keep their local values, which may hold edits not yet synced. 1772 """ 1773 attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code) 1774 if attr_name is None or self.info.firmware_version < 22: 1775 return # Nothing local to correct, or no op 93 to read it with 1776 device_values = self._read_device_params().get(attr_name) 1777 if device_values is None: 1778 return # Not in op 93 (continuous playback mode) 1779 local_values = getattr(self, attr_name) 1780 for ch in channels: 1781 local_values[ch] = device_values[ch] 1782 1783 def _refresh_trigger_mode(self, channel): 1784 """Read a trigger channel's mode back from the device, after the device refused a new one.""" 1785 if self.info.firmware_version > 21 and channel in (1, 2): 1786 self.trigger_mode[channel] = self._read_device_params()["trigger_mode"][channel] 1787 1788 def _refresh_after_refused_sync(self): 1789 """Read the parameters back after the device refused a sync_to_device() set. 1790 1791 The device programs the set with the refused values reset, so the local copy is read back. 1792 In param sync mode the device stores the set instead, and op 93 would return the parameters 1793 playing now, so the local copy is left as it is. 1794 """ 1795 if self.info.firmware_version > 21 and 3 not in self.trigger_mode[1:3]: 1796 self.sync_from_device() 1797 1798 def _output_channel_numbers(self, channels, context): 1799 """Return output channel numbers as a list of ints. 1800 1801 channels is one integer (NumPy integers included) or an iterable of them (NumPy arrays 1802 included). Raises PulsePalError for anything else, or a channel that is not 1-4. 1803 """ 1804 channel_list = self._as_list(channels) 1805 if not all(isinstance(ch, numbers.Integral) and 1 <= ch <= 4 for ch in channel_list): 1806 raise PulsePalError( 1807 f"{context}: channels must be output channel numbers 1-4, as one integer or a " 1808 f"list of them. Received {channels!r}." 1809 ) 1810 return [int(ch) for ch in channel_list] 1811 1812 def _set_output_param_value(self, param_code, channel, original_value): 1813 """Store a programmed value in its local parameter array.""" 1814 attr_name = self._OUTPUT_PARAMETER_ATTRS.get(param_code) 1815 if attr_name is None: 1816 return 1817 values = getattr(self, attr_name) 1818 values[channel] = original_value 1819 1820 def _to_decimal(self, value): 1821 """Convert a value to a Decimal with PulsePal precision.""" 1822 return Decimal(value).quantize(Decimal("1.0000")) 1823 1824 def _volts_to_bits(self, value, name="voltage"): 1825 """Convert -10 V to +10 V to the corresponding DAC bit value. 1826 1827 Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do. 1828 Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would 1829 play a different voltage. 1830 """ 1831 try: 1832 volts = float(value) 1833 except (TypeError, ValueError): 1834 volts = float("nan") 1835 if not -10 <= volts <= 10: # Also refuses NaN 1836 raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.") 1837 bit_max = int(self._dac_bit_max) 1838 return int(round((volts + 10) / 20 * bit_max)) 1839 1840 def _bits_to_volts(self, value): 1841 """Convert a DAC code to volts, snapping clean values within 1 LSB.""" 1842 bit_max = int(self._dac_bit_max) 1843 raw_volts = (float(value) / bit_max * 20) - 10 1844 1845 # Calculate the voltage of 1 bit 1846 lsb_volts = 20.0 / bit_max 1847 1848 # Find the nearest clean 3-decimal number (e.g. 5.000, 4.255) 1849 clean_volts = round(raw_volts, 3) 1850 1851 # If the raw voltage is within 1 bit of the clean voltage, snap to it 1852 if abs(raw_volts - clean_volts) <= lsb_volts: 1853 return clean_volts 1854 1855 # Otherwise, return the standard 4-decimal reading 1856 return round(raw_volts, 4) 1857 1858 def _seconds_to_cycles(self, value, name="time"): 1859 """Convert seconds to the corresponding refresh-cycle count. 1860 1861 Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do: 1862 125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is 1863 negative, not a number, or too long for the device. 1864 """ 1865 try: 1866 seconds = float(value) 1867 except (TypeError, ValueError): 1868 seconds = float("nan") 1869 if not (math.isfinite(seconds) and seconds >= 0): 1870 raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.") 1871 cycles = int(round(seconds * float(self.info.cycle_frequency))) 1872 if cycles > 2**32 - 1: 1873 raise PulsePalError( 1874 f"{name} is too long for the device ({2**32 - 1} cycles at most). Received {value!r}." 1875 ) 1876 return cycles 1877 1878 def _cycles_to_seconds(self, value): 1879 """Convert hardware timer cycle counts to seconds.""" 1880 return float(value) / float(self.info.cycle_frequency) 1881 1882 def _require_firmware(self, minimum_version, context): 1883 """Raise unless the device firmware is new enough for an op.""" 1884 if ( 1885 self.info.firmware_version is None 1886 or self.info.firmware_version < minimum_version 1887 ): 1888 raise PulsePalError( 1889 f"{context} requires firmware v{minimum_version} or newer. " 1890 f"Detected firmware is v{self.info.firmware_version}." 1891 ) 1892 1893 def _sync_all_params(self): 1894 """Send all parameters using the packed sync op (firmware v22+). 1895 1896 Values are grouped by width so that the whole program travels as 1897 one uint32 block, one uint16 block and one uint8 block. 1898 """ 1899 time_values = [] 1900 for attr_name in ( 1901 "phase1_duration", 1902 "inter_phase_interval", 1903 "phase2_duration", 1904 "inter_pulse_interval", 1905 "burst_duration", 1906 "inter_burst_interval", 1907 "pulse_train_duration", 1908 "pulse_train_delay", 1909 ): 1910 time_values.extend( 1911 self._seconds_to_cycles(getattr(self, attr_name)[channel]) 1912 for channel in range(1, 5) 1913 ) 1914 1915 voltage_values = [] 1916 for attr_name in ( 1917 "phase1_voltage", 1918 "phase2_voltage", 1919 "resting_voltage", 1920 ): 1921 voltage_values.extend( 1922 self._volts_to_bits(getattr(self, attr_name)[channel]) 1923 for channel in range(1, 5) 1924 ) 1925 1926 single_byte_values = [] 1927 for attr_name in ( 1928 "is_biphasic", 1929 "custom_train_id", 1930 "custom_train_target", 1931 "custom_train_loop", 1932 "playback_mode", 1933 ): 1934 single_byte_values.extend( 1935 int(getattr(self, attr_name)[channel]) 1936 for channel in range(1, 5) 1937 ) 1938 single_byte_values.extend( 1939 int(self.link_trigger_channel1[channel]) 1940 for channel in range(1, 5) 1941 ) 1942 single_byte_values.extend( 1943 int(self.link_trigger_channel2[channel]) 1944 for channel in range(1, 5) 1945 ) 1946 single_byte_values.extend( 1947 int(value) for value in self.trigger_mode[1:3] 1948 ) 1949 1950 self._write_serial( 1951 (self._OP_MENU_BYTE, 92), 1952 "uint8", 1953 time_values, 1954 "uint32", 1955 voltage_values, 1956 "uint16", 1957 single_byte_values, 1958 "uint8", 1959 ) 1960 1961 def _sync_all_params_legacy(self): 1962 """Send all parameters using the legacy sync op (firmware v21). 1963 1964 Equivalent to `_sync_all_params`, but lays the program out 1965 channel by channel as the older firmware expects. 1966 """ 1967 program_values_16 = [] 1968 program_values_32 = [] 1969 program_values_8 = [0] * 16 1970 1971 for channel in range(1, 5): 1972 program_values_32.extend( 1973 [ 1974 self._seconds_to_cycles( 1975 self.phase1_duration[channel]), 1976 self._seconds_to_cycles( 1977 self.inter_phase_interval[channel]), 1978 self._seconds_to_cycles( 1979 self.phase2_duration[channel]), 1980 self._seconds_to_cycles( 1981 self.inter_pulse_interval[channel]), 1982 self._seconds_to_cycles( 1983 self.burst_duration[channel]), 1984 self._seconds_to_cycles( 1985 self.inter_burst_interval[channel]), 1986 self._seconds_to_cycles( 1987 self.pulse_train_duration[channel]), 1988 self._seconds_to_cycles( 1989 self.pulse_train_delay[channel]), 1990 ] 1991 ) 1992 1993 for channel in range(1, 5): 1994 program_values_16.extend( 1995 [ 1996 self._volts_to_bits(self.phase1_voltage[channel]), 1997 self._volts_to_bits(self.phase2_voltage[channel]), 1998 self._volts_to_bits(self.resting_voltage[channel]), 1999 ] 2000 ) 2001 2002 position = 0 2003 for channel in range(1, 5): 2004 program_values_8[position] = self.is_biphasic[channel] 2005 position += 1 2006 program_values_8[position] = self.custom_train_id[channel] 2007 position += 1 2008 program_values_8[position] = self.custom_train_target[channel] 2009 position += 1 2010 program_values_8[position] = self.custom_train_loop[channel] 2011 position += 1 2012 2013 program_values_tl = [0] * 8 2014 position = 0 2015 for channel in range(1, 5): 2016 program_values_tl[position] = self.link_trigger_channel1[channel] 2017 position += 1 2018 for channel in range(1, 5): 2019 program_values_tl[position] = self.link_trigger_channel2[channel] 2020 position += 1 2021 2022 self._write_serial( 2023 (self._OP_MENU_BYTE, 73), 2024 "uint8", 2025 program_values_32, 2026 "uint32", 2027 program_values_16, 2028 "uint16", 2029 program_values_8, 2030 "uint8", 2031 program_values_tl, 2032 "uint8", 2033 self.trigger_mode[1:3], 2034 "uint8", 2035 ) 2036 2037 def __enter__(self): 2038 """Enter a `with` block, returning the connected device.""" 2039 return self 2040 2041 def __exit__(self, exc_type, exc_value, traceback): 2042 """Disconnect and close the port when leaving a `with` block. 2043 2044 Returns: 2045 `False`, so any exception raised in the block propagates. 2046 """ 2047 try: 2048 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 2049 except Exception: 2050 pass 2051 self.close() 2052 return False 2053 2054 def __del__(self): 2055 """Disconnect and close the port when the object is collected.""" 2056 try: 2057 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 2058 except Exception: 2059 pass 2060 2061 try: 2062 self.close() 2063 except Exception: 2064 # Destructors should not raise; the serial object may already be 2065 # gone during interpreter shutdown. 2066 pass
A class to control a Pulse Pal device on a USB serial port.
Creating an instance opens the serial port, exchanges a handshake
with the device, verifies that its firmware is supported, reads the
device properties into PulsePalDevice.info, and programs the
device with the default parameters.
from PulsePal import PulsePalDevice
P = PulsePalDevice("COM3")
P.set_output_param("phase1_voltage", 1, 5)
P.trigger(1)
P.close()
Here, replace "COM3" with Pulse Pal's USB serial port name. To view a list of available ports, use PulsePalDevice.serialportlist() Pulse Pal's port may not be visible if it is connected to another instance of PulsePalDevice or an external application. Use PulsePalDevice.serialportlist('all') to view all ports. If you see multiple available ports, disconnect Pulse Pal's USB plug and re-run serialportlist(). Notice which port disappears from the list.
PulsePalDevice is also a context manager, which closes the connection on exit even if an error is raised:
with PulsePalDevice("COM3") as P:
P.trigger(1)
The attributes below named after Pulse Pal parameters are the local
copy of the device's program. Each is a five element list indexed by
channel number, with index 0 unused. This way channels are addressed
by the index on the device, e.g. trigger channels 1-2 and output
channels 1-4. Assigning parameters does not update the device until
PulsePalDevice.sync_to_device is called;
PulsePalDevice.set_output_param programs one parameter right away.
487 def __init__(self, port_name, baud_rate=12000000, timeout=10): 488 """Open a connection to a Pulse Pal device. 489 490 Opens the serial port, exchanges the handshake, verifies the 491 firmware version, reads the device properties into 492 `PulsePalDevice.info`, and programs the device with the default 493 parameters. 494 495 Args: 496 port_name: USB serial port for the Pulse Pal device, such as 497 `COM3` on Windows or `/dev/ttyACM0` on Linux. 498 baud_rate: Serial baud rate. 499 timeout: Serial read timeout, in seconds. 500 501 Raises: 502 PulsePalError: If the device does not return the expected 503 handshake, or its firmware is older than v21, or its 504 firmware is newer than this module supports. The port is 505 closed again before any error is raised. 506 serial.SerialException: If the serial port cannot be opened. 507 """ 508 self.info = DeviceInfo() 509 self._gui = None 510 self.port = serial.Serial( 511 port_name, 512 baud_rate, 513 timeout=timeout, 514 rtscts=True, 515 ) 516 self._closed = False 517 try: 518 self._start_session(port_name) 519 except BaseException: 520 # Otherwise the port stays open until the object is garbage 521 # collected, and a second attempt, or WavePal.WavePalDevice, 522 # cannot open it. The device is in an unknown state, so it is not 523 # sent the disconnect op. 524 self.close(send_disconnect=False) 525 raise
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. The port is closed again before any error is raised.
- 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.
Voltage held between pulses, in volts [-10, 10].
A new resting voltage reaches an idle channel's output at once. A channel playing a pulse train keeps playing it, and moves to the new resting voltage at its next transition to rest.
Duration of the first phase of each pulse, in seconds.
At least DeviceInfo.min_pulse_width_us.
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.
At least DeviceInfo.min_pulse_width_us.
Interval from the end of one pulse to the onset of the next, in
seconds. At least DeviceInfo.min_pulse_width_us.
Duration of each burst of pulses, in seconds.
Set to 0 to disable bursts, so that pulses continue for the whole
pulse train. A pulse starts only if it ends before the burst does:
its first phase, or for a biphasic pulse the whole pulse, so that
the end of a burst never cuts off a second phase.
Interval between bursts of pulses, in seconds.
The channel rests at PulsePalDevice.resting_voltage between
bursts. Ignored when PulsePalDevice.burst_duration is 0.
Total duration of the pulse train, in seconds.
At least DeviceInfo.min_pulse_width_us. The end of the train cuts
short a monophasic pulse still playing. A biphasic pulse starts only
if it can end by the end of the train, so that it keeps its second
phase.
Whether each output channel is linked to trigger channel 1.
1 links the output channel to trigger channel 1, 0 unlinks it.
Whether each output channel is linked to trigger channel 2.
1 links the output channel to trigger channel 2, 0 unlinks it.
Custom pulse train played by each output channel.
0 plays the parametrically defined train. 1 or higher plays the
matching custom train, previously loaded with
PulsePalDevice.send_custom_pulse_train or
PulsePalDevice.send_custom_waveform.
What the timestamps of a custom train mark.
0 if each timestamp is the onset of a pulse, 1 if each timestamp
is the onset of a burst of pulses.
Whether a custom train repeats.
1 loops the custom train until
PulsePalDevice.pulse_train_duration has elapsed, 0 plays it
once.
Continuous playback mode of parametric pulse trains after being triggered
0plays the pulse train once until pulse_train_duration seconds1plays the pulse train indefinitely, ignoring pulse_train_duration
Response of each trigger channel to an incoming TTL pulse.
Three element list indexed by trigger channel, with index 0 unused. Elements 1 and 2 control the respective channels on the device. Their values can be:
0(normal): a TTL rising edge starts the pulse train, and edges during the train are ignored.1(toggle): same as 0 but a TTL rising edge during the train stops it.2(pulse gated): the train runs only while the trigger TTL is high.3(param sync, Pulse Pal 3 only): a TTL rising edge starts and stops nothing. It loads the parameter set most recently sent byPulsePalDevice.sync_to_device. This is how the next trial's parameters are sent during the current trial and applied the instant it starts.
An output channel that is idle at a param sync edge takes its new parameters in the 50 us timer cycle the edge is detected. One that is playing a pulse train finishes that train on the parameters it started with, and takes the new ones the moment it ends, so a train that runs past the end of a trial keeps one shape throughout and the next trigger plays a whole train with the new parameters. A channel in continuous playback mode has no train end, so it keeps its parameters until something stops it.
While either trigger channel is in param sync mode, only
PulsePalDevice.sync_to_device is held back.
PulsePalDevice.set_output_param, PulsePalDevice.set_trigger_param
and every other parameter method still program the device
immediately. So leaving param sync mode means calling
PulsePalDevice.set_trigger_param; a trigger mode sent by
PulsePalDevice.sync_to_device does not take effect until a sync
edge arrives.
A param sync channel's links to output channels are ignored. To start a pulse train on the same edge, wire the TTL to the other trigger channel as well: both edges arrive in the same timer cycle, and the parameters are loaded first.
Connecting a new PulsePalDevice takes both trigger channels out of
param sync mode, so that the default parameters it programs reach the
device instead of waiting for a TTL.
P.set_trigger_param("trigger_mode", 2, 3) # channel 2 does param sync
P.phase1_voltage[1:5] = [5] * 4
P.sync_to_device() # stored, not yet applied
# ... the next rising edge on trigger channel 2 applies it ...
613 @staticmethod 614 def serialportlist(ports_to_list="available"): 615 """Return the names of the USB serial ports on this computer. 616 617 Called on the class, without connecting to a device, to find the 618 port name to pass to `PulsePalDevice`: 619 620 ```python 621 from PulsePal import PulsePalDevice 622 623 ports = PulsePalDevice.serialportlist() 624 P = PulsePalDevice(ports[0]) 625 ``` 626 627 Args: 628 ports_to_list: `available` to list only the ports that are 629 not already in use, or `all` to list every USB serial 630 port. Not case sensitive. 631 632 Returns: 633 Sorted list of port names, such as `["COM3", "COM7"]` on 634 Windows or `["/dev/ttyACM0"]` on Linux. 635 636 Raises: 637 PulsePalError: If `ports_to_list` is not `available` or 638 `all`. 639 """ 640 if not isinstance(ports_to_list, str): 641 raise PulsePalError( 642 "serialportlist() takes 'available' or 'all'." 643 ) 644 mode = ports_to_list.lower() 645 if mode not in ("available", "all"): 646 raise PulsePalError( 647 f"Unknown port list type: {ports_to_list}. " 648 "Use 'available' or 'all'." 649 ) 650 651 port_names = [] 652 for port_info in serial.tools.list_ports.comports(): 653 is_usb = port_info.vid is not None or "USB" in ( 654 port_info.hwid or "" 655 ).upper() 656 if not is_usb: 657 continue 658 if mode == "available" and not PulsePalDevice._port_is_free( 659 port_info.device 660 ): 661 continue 662 port_names.append(port_info.device) 663 return sorted(port_names)
Return the names of the USB serial ports on this computer.
Called on the class, without connecting to a device, to find the
port name to pass to PulsePalDevice:
from PulsePal import PulsePalDevice
ports = PulsePalDevice.serialportlist()
P = PulsePalDevice(ports[0])
Arguments:
- ports_to_list:
availableto list only the ports that are not already in use, orallto list every USB serial port. Not case sensitive.
Returns:
Sorted list of port names, such as
["COM3", "COM7"]on Windows or["/dev/ttyACM0"]on Linux.
Raises:
- PulsePalError: If
ports_to_listis notavailableorall.
681 def set_default_params(self): 682 """Reset the local copy of all parameters to their defaults. 683 684 The defaults are a 1 ms, +5 V monophasic pulse every 10 ms for 685 1 second, on all four output channels, linked to trigger channel 686 1 in normal trigger mode. 687 688 This updates only the local copy. Call 689 `PulsePalDevice.sync_to_device` to program the device with them. 690 """ 691 nan = float("nan") 692 self.is_biphasic = [nan, 0, 0, 0, 0] 693 self.phase1_voltage = [nan, 5, 5, 5, 5] 694 self.phase2_voltage = [nan, -5, -5, -5, -5] 695 self.resting_voltage = [nan, 0, 0, 0, 0] 696 self.phase1_duration = [nan, 0.001, 0.001, 0.001, 0.001] 697 self.inter_phase_interval = [nan, 0.001, 0.001, 0.001, 0.001] 698 self.phase2_duration = [nan, 0.001, 0.001, 0.001, 0.001] 699 self.inter_pulse_interval = [nan, 0.01, 0.01, 0.01, 0.01] 700 self.burst_duration = [nan, 0, 0, 0, 0] 701 self.inter_burst_interval = [nan, 0, 0, 0, 0] 702 self.pulse_train_duration = [nan, 1, 1, 1, 1] 703 self.pulse_train_delay = [nan, 0, 0, 0, 0] 704 self.link_trigger_channel1 = [nan, 1, 1, 1, 1] 705 self.link_trigger_channel2 = [nan, 0, 0, 0, 0] 706 self.custom_train_id = [nan, 0, 0, 0, 0] 707 self.custom_train_target = [nan, 0, 0, 0, 0] 708 self.custom_train_loop = [nan, 0, 0, 0, 0] 709 self.playback_mode = [nan, 0, 0, 0, 0] 710 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.
712 def set_voltage(self, channel, voltage): 713 """Set an output channel to a fixed voltage. 714 715 The channel holds the voltage until it is set again or until a 716 pulse train is triggered on it. 717 718 Args: 719 channel: Output channel number, 1-4. 720 voltage: Voltage to set, in volts [-10, 10]. 721 722 Raises: 723 PulsePalError: If the voltage is outside [-10, 10], or the 724 device does not acknowledge the command. 725 """ 726 voltage_bits = self._volts_to_bits(voltage, "voltage") 727 self._write_serial( 728 (self._OP_MENU_BYTE, 79, channel), 729 "uint8", 730 voltage_bits, 731 "uint16", 732 ) 733 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 voltage is outside [-10, 10], or the device does not acknowledge the command.
735 def set_calibration(self, channel, voltage_offset): 736 """Calibrate the zero code of an output channel. 737 738 The offset is added to every voltage the channel produces, to 739 correct for DAC offset error. It is stored in the device's 740 EEPROM and reloaded on boot, so it only needs to be set once. 741 742 Requires Pulse Pal hardware v3 or newer. 743 744 Args: 745 channel: Output channel number, 1-4. 746 voltage_offset: Offset to apply, in volts [-0.1, 0.1]. 747 748 Raises: 749 PulsePalError: If the connected hardware is older than v3, 750 or the device does not acknowledge the command. 751 ValueError: If `channel` is not 1-4, or `voltage_offset` is 752 outside [-0.1, 0.1]. 753 """ 754 if self.info.hardware_version < 3: 755 raise PulsePalError( 756 "set_calibration() requires hardware v3 or newer." 757 ) 758 if channel not in (1, 2, 3, 4): 759 raise ValueError("channel must be 1, 2, 3 or 4") 760 if not -0.1 <= voltage_offset <= 0.1: # Also refuses NaN 761 raise ValueError( 762 "voltage_offset for zero code calibration must be in range " 763 "[-0.1, 0.1]" 764 ) 765 # To the nearest DAC code, halves to even, as the MATLAB class rounds it (int() truncated) 766 voltage_bits = int(round(voltage_offset * (1 / (20 / 65536)))) 767 self._write_serial( 768 (self._OP_MENU_BYTE, 96, channel - 1), 769 "uint8", 770 voltage_bits, 771 "int16", 772 ) 773 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].
775 def set_output_param(self, param_name, channel, value): 776 """Program an output channel parameter on the device. 777 778 The local copy of the parameter is updated to match, so a later 779 `PulsePalDevice.sync_to_device` will not undo the change. 780 781 ```python 782 P.set_output_param("is_biphasic", 1, 1) 783 P.set_output_param("phase1_voltage", 1, 10) 784 P.set_output_param(3, 1, -10) # same, by param code 785 P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3]) 786 P.set_output_param("phase1_duration", [2, 4], 0.002) 787 ``` 788 789 Setting all four channels in one call programs them with a single 790 command (firmware v22 or newer). Otherwise, each listed channel is 791 programmed with its own command. Channels that are not listed are 792 not changed. 793 794 Args: 795 param_name: Parameter name, as listed in 796 `DeviceInfo.output_parameter_names`, or its integer 797 parameter code. 798 channel: Output channel number, 1-4, or a list of distinct 799 output channel numbers. 800 value: Value to set. Units are volts for voltage parameters, 801 seconds for time parameters, and integers for enumerated 802 parameters. See the attributes of `PulsePalDevice` for 803 the meaning of each parameter. If `channel` is a list, 804 either one value for all listed channels, or a list with 805 one value per listed channel, in the same order. 806 807 Raises: 808 PulsePalError: If the parameter name is not recognized, the 809 channels or number of values are invalid, a value is out 810 of range for the parameter (nothing is sent), or the device 811 does not acknowledge the command. When the device refuses a 812 value, the local copy of the parameter is first read back 813 from the device, which resets a value it refuses. 814 """ 815 param_code = self._get_output_param_code(param_name) 816 817 if isinstance(channel, numbers.Integral): 818 channels = [int(channel)] 819 else: 820 channels = [int(ch) for ch in self._as_list(channel)] 821 if ( 822 not channels 823 or len(set(channels)) != len(channels) 824 or any(ch not in (1, 2, 3, 4) for ch in channels) 825 ): 826 raise PulsePalError( 827 "channel must be an output channel number, 1-4, or a list " 828 "of distinct output channel numbers." 829 ) 830 values = self._as_list(value) 831 if len(values) == 1: 832 values = values * len(channels) 833 if len(values) != len(channels): 834 raise PulsePalError( 835 f"{len(values)} values were given for {len(channels)} " 836 "channels. Give one value, or one value per channel." 837 ) 838 self._check_output_values(param_code, channels, values) 839 840 if sorted(channels) == [1, 2, 3, 4] and self.info.firmware_version > 21: 841 # One op 91 command programs this parameter on all four channels 842 values_by_channel = [values[channels.index(ch)] for ch in (1, 2, 3, 4)] 843 data, datatype = self._encode_output_param(param_code, values_by_channel) 844 self._write_serial( 845 (self._OP_MENU_BYTE, 91, param_code), 846 "uint8", 847 data, 848 datatype, 849 ) 850 self._read_ack( 851 "set_output_param()", 852 on_refusal=lambda: self._refresh_output_param(param_code, (1, 2, 3, 4)), 853 ) 854 for ch, channel_value in zip((1, 2, 3, 4), values_by_channel): 855 self._set_output_param_value(param_code, ch, channel_value) 856 else: 857 for ch, channel_value in zip(channels, values): 858 self._set_one_output_param(param_code, ch, channel_value)
Program an 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
P.set_output_param("phase1_voltage", [1, 2, 3, 4], [5, 5, 5, 3])
P.set_output_param("phase1_duration", [2, 4], 0.002)
Setting all four channels in one call programs them with a single command (firmware v22 or newer). Otherwise, each listed channel is programmed with its own command. Channels that are not listed are not changed.
Arguments:
- param_name: Parameter name, as listed in
DeviceInfo.output_parameter_names, or its integer parameter code. - channel: Output channel number, 1-4, or a list of distinct output channel numbers.
- 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. Ifchannelis a list, either one value for all listed channels, or a list with one value per listed channel, in the same order.
Raises:
- PulsePalError: If the parameter name is not recognized, the channels or number of values are invalid, a value is out of range for the parameter (nothing is sent), or the device does not acknowledge the command. When the device refuses a value, the local copy of the parameter is first read back from the device, which resets a value it refuses.
860 def set_trigger_param(self, param_name, channel, value): 861 """Program a single trigger channel parameter on the device. 862 863 The local copy of the parameter is updated to match, so a later 864 `PulsePalDevice.sync_to_device` will not undo the change. 865 866 ```python 867 P.set_trigger_param("trigger_mode", 1, 2) # pulse gated 868 ``` 869 870 Args: 871 param_name: Parameter name, as listed in 872 `DeviceInfo.trigger_parameter_names`, or its integer 873 parameter code. 874 channel: Trigger channel number, 1-2. 875 value: Value to set. See `PulsePalDevice.trigger_mode` for 876 the trigger modes. 877 878 Raises: 879 PulsePalError: If the parameter name is not recognized, the 880 channel or value is out of range (nothing is sent), or the 881 device does not acknowledge the command. 882 """ 883 original_value = value 884 param_code = self._get_trigger_param_code(param_name) 885 if param_code == 128: 886 if not (isinstance(channel, numbers.Integral) and channel in (1, 2)): 887 raise PulsePalError( 888 f"channel must be a trigger channel number, 1 or 2. Received {channel!r}." 889 ) 890 self._check_trigger_mode(value, channel) 891 892 self._write_serial( 893 (self._OP_MENU_BYTE, 74, param_code, channel, value), 894 "uint8", 895 ) 896 self._read_ack( 897 "program_trigger_channel_param()", 898 on_refusal=lambda: self._refresh_trigger_mode(channel), 899 ) 900 901 if param_code in (1, 128): 902 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 channel or value is out of range (nothing is sent), or the device does not acknowledge the command.
904 def sync_to_device(self): 905 """Program the device with the local copy of all parameters. 906 907 Call this after assigning to the parameter array attributes, to 908 send every output and trigger parameter to the device in a 909 single transaction. 910 911 ```python 912 P.phase1_voltage[1:5] = [5] * 4 913 P.sync_to_device() 914 ``` 915 916 On Pulse Pal 3, if either trigger channel is in param sync mode 917 (`PulsePalDevice.trigger_mode` 3), the device stores the 918 parameters instead of programming them, and loads them on the 919 next rising edge of that channel. The device still acknowledges 920 whether every value was in range. This is the only method whose 921 effect is deferred that way. 922 923 Raises: 924 PulsePalError: If a value is out of range for its parameter 925 (nothing is sent), or the device does not acknowledge the 926 command. 927 """ 928 self._check_program() 929 # _sync_all_params() for firmware v22+ uses the newer packed sync 930 # opcode (92). _sync_all_params_legacy() uses the less efficient 931 # legacy packed sync opcode (73). 932 if self.info.firmware_version > 21: 933 self._sync_all_params() 934 else: 935 self._sync_all_params_legacy() 936 self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync)
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()
On Pulse Pal 3, if either trigger channel is in param sync mode
(PulsePalDevice.trigger_mode 3), the device stores the
parameters instead of programming them, and loads them on the
next rising edge of that channel. The device still acknowledges
whether every value was in range. This is the only method whose
effect is deferred that way.
Raises:
- PulsePalError: If a value is out of range for its parameter (nothing is sent), or the device does not acknowledge the command.
938 def sync_from_device(self): 939 """Read all parameters from the device into the local copy. 940 941 Overwrites every parameter array attribute with the program 942 currently stored on the device. Useful after the device has been 943 reprogrammed from its thumb joystick. 944 945 Requires firmware v22 or newer. 946 947 Raises: 948 PulsePalError: If the connected firmware is older than v22, 949 or the device does not return the full parameter set. 950 """ 951 self._require_firmware(22, "sync_from_device()") 952 for attr_name, values in self._read_device_params().items(): 953 setattr(self, attr_name, values)
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.
1005 def send_custom_pulse_train( 1006 self, 1007 custom_train_id, 1008 pulse_times, 1009 pulse_voltages, 1010 ): 1011 """Load a custom pulse train onto the device. 1012 1013 A custom pulse train is an arbitrary list of pulse onset times 1014 and voltages, replacing the parametric pulse voltage and onset timing. 1015 Set `PulsePalDevice.custom_train_id` on an output channel to play 1016 the train there. 1017 1018 ```python 1019 P.send_custom_pulse_train( 1020 2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10] 1021 ) 1022 P.set_output_param("custom_train_id", 1, 2) 1023 ``` 1024 1025 Args: 1026 custom_train_id: Custom train to load, from 1 to 1027 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1028 4 on Pulse Pal 3). 1029 pulse_times: Pulse onset times, in seconds, relative to the 1030 start of the train. Accepts a list, tuple or NumPy 1031 array. 1032 pulse_voltages: Voltage of each pulse, in volts [-10, 10]. 1033 Must be the same length as `pulse_times`. 1034 1035 Raises: 1036 PulsePalError: If `custom_train_id` is out of range, 1037 `pulse_times` and `pulse_voltages` differ in length, 1038 there are more pulses than 1039 `DeviceInfo.max_custom_pulses`, a pulse time is less 1040 than `DeviceInfo.min_pulse_width_us` after the one before 1041 it (after rounding to the device's timer cycle), or the 1042 device does not acknowledge the command. 1043 """ 1044 pulse_times = self._as_list(pulse_times) 1045 pulse_voltages = self._as_list(pulse_voltages) 1046 n_pulses = len(pulse_times) 1047 if n_pulses != len(pulse_voltages): 1048 raise PulsePalError( 1049 "pulse_times and pulse_voltages must be the same length." 1050 ) 1051 1052 pulse_times_cycles = [ 1053 self._seconds_to_cycles(pulse_time, "pulse_times") 1054 for pulse_time in pulse_times 1055 ] 1056 pulse_voltage_bits = [ 1057 self._volts_to_bits(voltage, "pulse_voltages") 1058 for voltage in pulse_voltages 1059 ] 1060 1061 self._send_custom_train( 1062 custom_train_id, 1063 pulse_times_cycles, 1064 pulse_voltage_bits, 1065 "send_custom_pulse_train()", 1066 )
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, from 1 to
DeviceInfo.n_custom_pulse_trains(2 on Pulse Pal 2, 4 on Pulse Pal 3). - 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
custom_train_idis out of range,pulse_timesandpulse_voltagesdiffer in length, there are more pulses thanDeviceInfo.max_custom_pulses, a pulse time is less thanDeviceInfo.min_pulse_width_usafter the one before it (after rounding to the device's timer cycle), or the device does not acknowledge the command.
1068 def send_custom_waveform( 1069 self, 1070 custom_train_id, 1071 pulse_width, 1072 pulse_voltages, 1073 ): 1074 """Load an arbitrary waveform onto the device. 1075 1076 A convenience shorthand for 1077 `PulsePalDevice.send_custom_pulse_train` with evenly spaced, 1078 confluent pulses, so that `pulse_voltages` is played as a 1079 waveform sampled every `pulse_width` seconds. 1080 1081 Set the channel's `PulsePalDevice.phase1_duration` to 1082 `pulse_width` as well, so that each sample is held for the 1083 sampling period. 1084 1085 ```python 1086 import math 1087 1088 samples = [math.sin(i / 10.0) * 10 for i in range(1000)] 1089 P.send_custom_waveform(1, 0.001, samples) # 1 kHz 1090 P.set_output_param("custom_train_id", 2, 1) 1091 P.set_output_param("phase1_duration", 2, 0.001) 1092 ``` 1093 1094 Args: 1095 custom_train_id: Custom train to load, from 1 to 1096 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1097 4 on Pulse Pal 3). 1098 pulse_width: Sampling period, in seconds. Each voltage is 1099 held for this long. 1100 pulse_voltages: Waveform samples, in volts [-10, 10]. 1101 Accepts a list, tuple or NumPy array. 1102 1103 Raises: 1104 PulsePalError: If `custom_train_id` is out of range, there 1105 are more samples than `DeviceInfo.max_custom_pulses`, 1106 `pulse_width` rounds to less than 1107 `DeviceInfo.min_pulse_width_us`, or the device does not 1108 acknowledge the command. 1109 """ 1110 pulse_voltages = self._as_list(pulse_voltages) 1111 n_pulses = len(pulse_voltages) 1112 pulse_width_cycles = self._seconds_to_cycles(pulse_width, "pulse_width") 1113 pulse_times = [pulse_width_cycles * i for i in range(n_pulses)] 1114 pulse_voltage_bits = [ 1115 self._volts_to_bits(voltage, "pulse_voltages") 1116 for voltage in pulse_voltages 1117 ] 1118 1119 self._send_custom_train( 1120 custom_train_id, 1121 pulse_times, 1122 pulse_voltage_bits, 1123 "send_custom_waveform()", 1124 )
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, from 1 to
DeviceInfo.n_custom_pulse_trains(2 on Pulse Pal 2, 4 on Pulse Pal 3). - 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
custom_train_idis out of range, there are more samples thanDeviceInfo.max_custom_pulses,pulse_widthrounds to less thanDeviceInfo.min_pulse_width_us, or the device does not acknowledge the command.
1126 def trigger( 1127 self, 1128 channel1=None, 1129 channel2=None, 1130 channel3=None, 1131 channel4=None, 1132 ): 1133 """Trigger output channels in software. 1134 1135 Triggered channels start their pulse trains together. Three 1136 calling conventions are accepted: 1137 1138 ```python 1139 P.trigger(1, 0, 1, 0) # one flag per channel 1140 P.trigger(3) # a single channel number 1141 P.trigger([1, 4]) # several channel numbers 1142 ``` 1143 1144 Channel numbers may be NumPy integers, and a list of them may be a 1145 NumPy array. A channel that is already playing a pulse train 1146 ignores the trigger, and `stop()` cancels a trigger that has not 1147 started its channel yet. 1148 1149 Args: 1150 channel1: `1` to trigger channel 1, otherwise `0`; or, when 1151 it is the only argument given, a single channel number 1152 or a list of channel numbers to trigger. 1153 channel2: `1` to trigger channel 2, otherwise `0`. 1154 channel3: `1` to trigger channel 3, otherwise `0`. 1155 channel4: `1` to trigger channel 4, otherwise `0`. 1156 1157 Raises: 1158 PulsePalError: If a channel number is not 1-4, or a flag is not 1159 0 or 1. Nothing is triggered. 1160 """ 1161 trigger_byte = 0 1162 1163 # Options 2 & 3: Only one argument was provided 1164 if channel2 is None and channel3 is None and channel4 is None: 1165 # A single channel number, or a list of them (ch1=bit0, ch2=bit1, etc.) 1166 for ch in self._output_channel_numbers(channel1, "trigger()"): 1167 trigger_byte |= (1 << (ch - 1)) 1168 1169 # Option 1: Original input scheme (logicals for each channel) 1170 else: 1171 # Fallback to 0 if an argument was omitted via kwargs 1172 flags = [0 if flag is None else flag for flag in (channel1, channel2, channel3, channel4)] 1173 for bit, flag in enumerate(flags): 1174 if not (isinstance(flag, numbers.Integral) and flag in (0, 1)): 1175 raise PulsePalError( 1176 "trigger(): with more than one argument, each is a flag for one " 1177 f"channel, 0 or 1. Received {flag!r} for channel {bit + 1}." 1178 ) 1179 trigger_byte |= int(flag) << bit 1180 1181 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 may be NumPy integers, and a list of them may be a
NumPy array. A channel that is already playing a pulse train
ignores the trigger, and stop() cancels a trigger that has not
started its channel yet.
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.
Raises:
- PulsePalError: If a channel number is not 1-4, or a flag is not 0 or 1. Nothing is triggered.
1183 def sd_settings(self, settings_file_name, op): 1184 """Save, load, or delete a settings file on the microSD card. 1185 1186 A settings file holds a complete Pulse Pal program, so that it 1187 can be recalled later from software or from the device's front 1188 panel. Loading a file also refreshes the local copy of the 1189 parameters, via `PulsePalDevice.sync_from_device`. 1190 1191 ```python 1192 P.sd_settings("MyProtocol.pps", "save") 1193 ``` 1194 1195 Args: 1196 settings_file_name: Settings file name, at most 15 ASCII 1197 characters including the required `.pps` extension. 1198 op: `"save"`, `"load"`, or `"delete"`. 1199 1200 Raises: 1201 PulsePalError: If the file name has no `.pps` extension or 1202 is too long, `op` is not one of the three operations, or 1203 the device does not acknowledge the command. A load that 1204 fails leaves the device on its own default parameters 1205 (not the ones `set_default_params` sets), and the local 1206 copy is read back from the device before the error is 1207 raised. 1208 """ 1209 if ".pps" not in settings_file_name: 1210 raise PulsePalError( 1211 "Error: The file name must have a valid .pps extension." 1212 ) 1213 op_byte_by_name = {"save": 1, "load": 2, "delete": 3} 1214 try: 1215 op_byte = op_byte_by_name[str(op).lower()] 1216 except KeyError as exc: 1217 raise PulsePalError( 1218 "File op must be: 'save', 'load' or 'delete'." 1219 ) from exc 1220 1221 filename_bytes = settings_file_name.encode("ascii") 1222 if len(filename_bytes) > 15: 1223 raise PulsePalError("settings_file_name is too long.") 1224 self._write_serial( 1225 (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)), 1226 "uint8", 1227 list(filename_bytes), 1228 "uint8", 1229 ) 1230 if self.info.firmware_version > 21: 1231 # Sent after the file operation has finished. A refused load leaves the device on 1232 # its default parameters, so the local copy is read back first. 1233 self._read_ack( 1234 "sd_settings()", 1235 on_refusal=self.sync_from_device if op_byte == 2 else None, 1236 ) 1237 elif op_byte == 2: 1238 time.sleep(0.1) # Firmware v21 does not acknowledge, so allow time for the load 1239 if op_byte == 2: 1240 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. A load that fails leaves the device on its own default parameters (not the onesset_default_paramssets), and the local copy is read back from the device before the error is raised.
1242 def stop(self, channels=None): 1243 """Stop pulse trains currently playing on the device. 1244 1245 Every output channel returns to its 1246 `PulsePalDevice.resting_voltage`. 1247 1248 Args: 1249 channels (list or tuple, optional): A list of channels to stop, e.g., 1250 [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single 1251 channel number. NumPy integers and arrays are accepted. Default is 1252 None, which stops all channels. (Requires firmware v22+) 1253 1254 Raises: 1255 PulsePalError: If a channel number is not 1-4. 1256 """ 1257 bit_code = 15 # Default: 15 (binary 1111) stops all channels 1258 1259 if channels is not None: 1260 if self.info.firmware_version < 22: 1261 raise ValueError("stop() cannot address individual channels prior to firmware v22") 1262 1263 bit_code = 0 1264 for ch in self._output_channel_numbers(channels, "stop()"): 1265 # Bitwise OR (|=) handles the summation, safely ignoring duplicate channel entries 1266 bit_code |= 1 << (ch - 1) 1267 1268 # Send 1269 if self.info.firmware_version < 22: 1270 self._write_serial((self._OP_MENU_BYTE, 80), "uint8") 1271 else: 1272 self._write_serial((self._OP_MENU_BYTE, 98, bit_code), "uint8")
Stop pulse trains currently playing on the device.
Every output channel returns to its
PulsePalDevice.resting_voltage.
Arguments:
- channels (list or tuple, optional): A list of channels to stop, e.g., [1, 3, 4] to stop playback on Ch1, Ch3, and Ch4, or a single channel number. NumPy integers and arrays are accepted. Default is None, which stops all channels. (Requires firmware v22+)
Raises:
- PulsePalError: If a channel number is not 1-4.
1274 def format_microsd(self, timeout=30): 1275 """Format the device's microSD card. 1276 1277 Erases every settings file stored on the device and resets its 1278 parameters to the defaults. The user is prompted at the console 1279 to confirm before anything is erased. 1280 1281 Requires Pulse Pal hardware v3 or newer. 1282 1283 Args: 1284 timeout: Seconds to wait for the device to report that 1285 formatting has finished. 1286 1287 Returns: 1288 `None` once the card has been formatted, or an empty string 1289 if the user declines the confirmation prompt. 1290 1291 Raises: 1292 PulsePalError: If the connected hardware is older than v3, if 1293 the device reports that formatting failed, or if it does 1294 not report a result within `timeout` seconds. 1295 """ 1296 if self.info.hardware_version < 3: 1297 raise PulsePalError( 1298 "format_microsd() requires hardware v3 or newer." 1299 ) 1300 1301 print("*** Pulse Pal microSD Formatter ***") 1302 print("This will format Pulse Pal's microSD card,") 1303 print("erase all settings files on the device") 1304 print("and reset all parameters to defaults.") 1305 1306 reply = input("Do you want to continue (y/n)") 1307 1308 if reply.strip().lower() != "y": 1309 print("Choice confirmed - microSD Card NOT formatted.") 1310 return "" 1311 1312 self._write_serial((self._OP_MENU_BYTE, 97), "uint8") 1313 1314 # The device replies with lines of status text, the last of which 1315 # contains "!", and then a confirm byte (1 if the card was formatted, 1316 # 0 if not). The confirm byte is sent after the device has reloaded 1317 # its default parameters, so it can arrive well after the text. It 1318 # must be read here: left in the buffer, it would be taken as the 1319 # reply to the next command, and every reply after that would be 1320 # read one byte late. 1321 start = time.time() 1322 message = bytearray() 1323 flag_index = -1 1324 line_end = -1 1325 1326 while time.time() - start < timeout: 1327 n_waiting = self.bytes_available() 1328 if n_waiting: 1329 message.extend(self.port.read(n_waiting)) 1330 flag_index = message.find(b"!") 1331 if flag_index >= 0: 1332 line_end = message.find(b"\n", flag_index) 1333 if line_end >= 0 and len(message) > line_end + 1: 1334 break 1335 time.sleep(0.01) 1336 if line_end < 0 or len(message) <= line_end + 1: 1337 raise PulsePalError( 1338 "Error: Pulse Pal did not report the result of formatting " 1339 f"its microSD card within {timeout} s." 1340 ) 1341 confirm = message[line_end + 1] 1342 1343 text = bytes(message[:flag_index]).decode( 1344 "ascii", errors="replace" 1345 ).rstrip() 1346 if text: 1347 print(text) 1348 1349 self.set_default_params() 1350 if confirm != 1: 1351 raise PulsePalError( 1352 "Error: Pulse Pal could not format its microSD card." 1353 ) 1354 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, if
the device reports that formatting failed, or if it does
not report a result within
timeoutseconds.
1356 def gui(self, block=None, theme=None): 1357 """Open the Pulse Pal parameter GUI, or focus an open one. 1358 1359 The GUI edits its own copy of the parameters, and loads them to 1360 the device when its 'Load to Device' button is clicked. The 1361 window closes automatically when the device is closed or 1362 deleted. 1363 1364 Calling this while the GUI is already open focuses the existing 1365 window rather than opening a second one. 1366 1367 Args: 1368 block: If `True`, the call returns when the GUI is closed. If 1369 `False`, the call returns immediately, and the host 1370 application must run the Tk event loop. If `None`, the 1371 GUI blocks only when the host does not already provide a 1372 Tk event loop, e.g. when launched from a script. 1373 theme: `"light"` or `"dark"` to select the color theme, or 1374 `None` to match the desktop theme. Passing a theme to an 1375 already-open GUI recolors it in place. 1376 1377 Returns: 1378 The `PulsePalGUI.PulsePalGUI` instance driving the window. 1379 1380 Raises: 1381 ValueError: If the theme name is not recognized. 1382 """ 1383 gui = getattr(self, "_gui", None) 1384 if gui is not None and not gui.is_closed: 1385 if theme is not None: 1386 gui.set_theme(theme) 1387 gui.focus() 1388 return gui 1389 1390 try: 1391 from .PulsePalGUI import PulsePalGUI 1392 except ImportError: 1393 from PulsePalGUI import PulsePalGUI 1394 1395 gui = PulsePalGUI(self, theme=theme) 1396 self._gui = gui 1397 gui.start(block=block) 1398 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.
1400 def close(self, send_disconnect=True): 1401 """Close the connection to the device, and the GUI if open. 1402 1403 Safe to call more than once; later calls do nothing. Called 1404 automatically when leaving a `with` block and when the object is 1405 garbage collected. 1406 1407 Args: 1408 send_disconnect: If `True`, tell the device that the client 1409 is disconnecting before closing the port. Set to `False` 1410 when the device is in an unknown state, such as after a 1411 failed handshake. 1412 """ 1413 gui = getattr(self, "_gui", None) 1414 self._gui = None 1415 if gui is not None: 1416 try: 1417 gui.close() 1418 except Exception: 1419 # Cleanup must not raise; Tk may already be torn down 1420 pass 1421 1422 if getattr(self, "_closed", True): 1423 return 1424 1425 try: 1426 if send_disconnect and self.port and self.port.is_open: 1427 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 1428 finally: 1429 if self.port and self.port.is_open: 1430 self.port.close() 1431 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.
1433 def bytes_available(self): 1434 """Return the number of bytes waiting in the serial read buffer. 1435 1436 Returns: 1437 Count of bytes that can be read without blocking. 1438 """ 1439 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.
118@dataclass 119class DeviceInfo: 120 """Properties of the connected Pulse Pal device. 121 122 An instance is created for each connection and populated during the 123 handshake in `PulsePalDevice.__init__`. It is available as 124 `PulsePalDevice.info`. Devices running firmware v21 report only a 125 firmware version, so the remaining fields are filled in with the 126 known values for Pulse Pal hardware v2. 127 128 ```python 129 print(P.info.firmware_version) 130 ``` 131 """ 132 133 output_parameter_names: list = None 134 """Output parameter names, ordered by parameter code. 135 136 The position of a name in this list, plus 1, is the parameter code 137 the device expects. Any name here is valid as the `param_name` 138 argument of `PulsePalDevice.set_output_param`, and is also the name 139 of the matching parameter array attribute on `PulsePalDevice`. 140 """ 141 142 trigger_parameter_names: list = None 143 """Trigger parameter names, accepted by 144 `PulsePalDevice.set_trigger_param`.""" 145 146 firmware_version: int = None 147 """Firmware version running on the connected device.""" 148 149 hardware_version: int = None 150 """Hardware revision of the connected device, e.g. `2` or `3`. 151 152 Reported by the device on firmware v22 and newer; assumed to be `2` 153 on older firmware. 154 """ 155 156 max_custom_pulses: int = None 157 """Maximum number of pulses in a single custom pulse train.""" 158 159 n_custom_pulse_trains: int = None 160 """Number of custom pulse trains the device can store.""" 161 162 cycle_frequency: float = None 163 """Update frequency of the device's hardware timer, in Hz. 164 165 All time parameters are rounded to a whole number of these cycles, 166 so this sets the timing resolution of the device. 167 """ 168 169 cycle_period_us: float = None 170 """Update period of the device's hardware timer, in microseconds.""" 171 172 min_pulse_width_us: float = None 173 """Shortest pulse phase, inter-pulse interval and pulse train duration, 174 and shortest time between custom pulses, in microseconds. 175 176 Two timer cycles: a trigger channel reads its input once per cycle, so 177 a pulse must last two cycles to be detected reliably. 178 """
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.
108class PulsePalError(Exception): 109 """Raised when Pulse Pal communication or configuration fails. 110 111 This covers serial reads that time out, short serial writes, missing 112 acknowledgement bytes, unknown parameter names, values that do not 113 fit the datatype expected by the device, and operations that the 114 connected firmware or hardware revision does not support. 115 """
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.