pulsepal.pulse_pal
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, set parameters, and trigger, e.g.
from pulsepal import PulsePalDevice
with PulsePalDevice("COM3") as P:
P.phase1_voltage[1] = 5 # volts, output channel 1
P.phase1_duration[1] = 0.001 # seconds: 1 ms pulses,
P.inter_pulse_interval[1] = 0.049 # 49 ms apart (end of one to start of the next),
P.pulse_train_duration[1] = 2 # for 2 seconds
P.trigger(1)
Parameters
Each parameter is a list on the device object, indexed by channel
number: index 0 is unused and holds None, and indices 1 to 4 hold the
values for output channels 1-4. PulsePalDevice.trigger_mode is indexed
the same way by trigger channel, 1 or 2. Assigning an element, a slice
or the whole list programs the device at once, on the channels whose
value it changes: the others keep what the device holds, which may have
been set with its joystick. The whole list takes one value per channel:
a single value raises an error, because it does not say which channels it
is meant for.
P.phase1_voltage[2] = 7 # channel 2 only
P.inter_pulse_interval[1:5] = [0.2] * 4 # all four channels
P.phase1_voltage = [5, 5, 5, 5] # all four channels
P.trigger_mode[2] = "Toggle" # trigger channel 2
To change several parameters in one command, assign them in a
PulsePalDevice.batch block: they are sent together when it ends.
with P.batch():
P.is_biphasic[1] = True
P.phase2_voltage[1] = -5
P.inter_phase_interval[1] = 0.0005
PulsePalDevice.auto_sync set to False does the same for as long as
it stays off, until PulsePalDevice.sync_to_device is called; turning
it back on before then raises an error. That is how the next trial's
parameters wait for a TTL in param sync mode (see
PulsePalDevice.trigger_mode).
Assigning an attribute that is not a setting, such as a misspelled one
or the MATLAB class's phase1Voltage, raises AttributeError.
Units and values
Voltages are in volts in the range [-10, 10]. Times are in seconds, from
0 to MAX_TIME (9999.9999 s), and are rounded to the nearest cycle of
the device's hardware timer (DeviceInfo.cycle_period_us): 10 us on
Pulse Pal 3, and 50 us on Pulse Pal 2 and on Pulse Pal 3 with firmware
from before October 2026. The parameter then holds the time the device
plays, so 0.000123 reads back as 0.00012 (0.0001 on a 50 us timer). 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. On/off parameters are True or False,
and modes are names, such as "Gated"; names are not case sensitive,
and the integer codes of older Pulse Pal software are accepted too.
A value the device cannot play raises pulsepal.PulsePalError before
anything is sent: a voltage outside [-10, 10], a time that is negative,
longer than MAX_TIME or not a number, or an unknown name. So does a
pulse phase, inter-pulse interval or pulse train duration shorter than
DeviceInfo.min_pulse_width_us (10 us on Pulse Pal 3, 100 us on Pulse
Pal 2), so that a Pulse Pal's trigger channels can detect the shortest
pulse its output channels play. Custom pulse times are not rounded: each
must be a multiple of DeviceInfo.min_pulse_width_us.
Saving parameters
PulsePalDevice.export_params returns every parameter as a dict, to
store with your data or in a file, and PulsePalDevice.import_params
programs the device with one. The device's microSD card holds programs
too, which its joystick menu can load:
PulsePalDevice.save_program_to_microsd.
Further reading
- Parameter guide: https://sites.google.com/site/pulsepalwiki/parameter-guide
- Serial interface and general documentation: https://sites.google.com/site/pulsepalwiki/
pulsepal.wave_pal: alternative firmware that makes a Pulse Pal 3 a four channel waveform player, and its class,pulsepal.WavePalDevice.pulsepal.synth_pal: alternative firmware that makes a Pulse Pal 3 a four channel waveform synthesizer, and its class,pulsepal.SynthPalDevice.
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, set parameters, and trigger, e.g. 12 13```python 14from pulsepal import PulsePalDevice 15 16with PulsePalDevice("COM3") as P: 17 P.phase1_voltage[1] = 5 # volts, output channel 1 18 P.phase1_duration[1] = 0.001 # seconds: 1 ms pulses, 19 P.inter_pulse_interval[1] = 0.049 # 49 ms apart (end of one to start of the next), 20 P.pulse_train_duration[1] = 2 # for 2 seconds 21 P.trigger(1) 22``` 23 24## Parameters 25 26Each parameter is a list on the device object, indexed by channel 27number: index 0 is unused and holds `None`, and indices 1 to 4 hold the 28values for output channels 1-4. `PulsePalDevice.trigger_mode` is indexed 29the same way by trigger channel, 1 or 2. Assigning an element, a slice 30or the whole list programs the device at once, on the channels whose 31value it changes: the others keep what the device holds, which may have 32been set with its joystick. The whole list takes one value per channel: 33a single value raises an error, because it does not say which channels it 34is meant for. 35 36```python 37P.phase1_voltage[2] = 7 # channel 2 only 38P.inter_pulse_interval[1:5] = [0.2] * 4 # all four channels 39P.phase1_voltage = [5, 5, 5, 5] # all four channels 40P.trigger_mode[2] = "Toggle" # trigger channel 2 41``` 42 43To change several parameters in one command, assign them in a 44`PulsePalDevice.batch` block: they are sent together when it ends. 45 46```python 47with P.batch(): 48 P.is_biphasic[1] = True 49 P.phase2_voltage[1] = -5 50 P.inter_phase_interval[1] = 0.0005 51``` 52 53`PulsePalDevice.auto_sync` set to `False` does the same for as long as 54it stays off, until `PulsePalDevice.sync_to_device` is called; turning 55it back on before then raises an error. That is how the next trial's 56parameters wait for a TTL in param sync mode (see 57`PulsePalDevice.trigger_mode`). 58 59Assigning an attribute that is not a setting, such as a misspelled one 60or the MATLAB class's `phase1Voltage`, raises `AttributeError`. 61 62## Units and values 63 64Voltages are in volts in the range [-10, 10]. Times are in seconds, from 650 to `MAX_TIME` (9999.9999 s), and are rounded to the nearest cycle of 66the device's hardware timer (`DeviceInfo.cycle_period_us`): 10 us on 67Pulse Pal 3, and 50 us on Pulse Pal 2 and on Pulse Pal 3 with firmware 68from before October 2026. The parameter then holds the time the device 69plays, so 0.000123 reads back as 0.00012 (0.0001 on a 50 us timer). A 70time exactly halfway between two cycles rounds to the 71even one, as in the MATLAB and C++ classes, and voltages round to the 72nearest DAC code the same way. On/off parameters are `True` or `False`, 73and modes are names, such as `"Gated"`; names are not case sensitive, 74and the integer codes of older Pulse Pal software are accepted too. 75 76A value the device cannot play raises `pulsepal.PulsePalError` before 77anything is sent: a voltage outside [-10, 10], a time that is negative, 78longer than `MAX_TIME` or not a number, or an unknown name. So does a 79pulse phase, inter-pulse interval or pulse train duration shorter than 80`DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 100 us on Pulse 81Pal 2), so that a Pulse Pal's trigger channels can detect the shortest 82pulse its output channels play. Custom pulse times are not rounded: each 83must be a multiple of `DeviceInfo.min_pulse_width_us`. 84 85## Saving parameters 86 87`PulsePalDevice.export_params` returns every parameter as a dict, to 88store with your data or in a file, and `PulsePalDevice.import_params` 89programs the device with one. The device's microSD card holds programs 90too, which its joystick menu can load: 91`PulsePalDevice.save_program_to_microsd`. 92 93## Further reading 94 95- Parameter guide: 96 https://sites.google.com/site/pulsepalwiki/parameter-guide 97- Serial interface and general documentation: 98 https://sites.google.com/site/pulsepalwiki/ 99- `pulsepal.wave_pal`: alternative firmware that makes a Pulse Pal 3 a 100 four channel waveform player, and its class, `pulsepal.WavePalDevice`. 101- `pulsepal.synth_pal`: alternative firmware that makes a Pulse Pal 3 a 102 four channel waveform synthesizer, and its class, 103 `pulsepal.SynthPalDevice`. 104 105## License 106 107This file is part of the Sanworks PulsePal repository. 108Copyright (C) Sanworks LLC, Rochester, New York, USA 109 110This program is free software: you can redistribute it and/or modify 111it under the terms of the GNU General Public License as published by 112the Free Software Foundation, version 3. 113 114This program is distributed WITHOUT ANY WARRANTY and without even the 115implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 116See the GNU General Public License for more details. 117 118You should have received a copy of the GNU General Public License 119along with this program. If not, see <http://www.gnu.org/licenses/>. 120""" 121 122import collections.abc 123from dataclasses import dataclass 124import math 125import numbers 126import struct 127import time 128import warnings 129 130import numpy as np 131import serial 132 133from . import _common 134from ._common import ChannelSettings, PulsePalError, to_bool, to_name 135 136__all__ = ["PulsePalDevice", "DeviceInfo", "DeviceStatus", "TRIGGER_MODES", "CUSTOM_TRAIN_TARGETS", "MAX_TIME"] 137__docformat__ = "google" 138 139MAX_TIME = 9999.9999 140"""The longest time parameter or custom pulse time, in seconds: the most 141the device's joystick menu shows and edits (four digits before the 142point).""" 143 144TRIGGER_MODES = ("Normal", "Toggle", "Gated", "Param Sync") 145"""Names of the trigger modes, in order of their code on the device (0-3). 146"Param Sync" is on Pulse Pal 3 only. See `PulsePalDevice.trigger_mode`.""" 147 148CUSTOM_TRAIN_TARGETS = ("Pulses", "Bursts") 149"""Names of the custom train targets, in order of their code on the device. 150See `PulsePalDevice.custom_train_target`.""" 151 152# Output parameters by parameter code (see "Parameter codes" in /Firmware/PROTOCOL.md), with 153# the kind of value each takes. The codes are fixed: installed clients and the firmware use them. 154# "pulse_time" is a time of at least DeviceInfo.min_pulse_width_us. 155_OUTPUT_PARAMETERS = { 156 1: ("is_biphasic", "bool"), 157 2: ("phase1_voltage", "volts"), 158 3: ("phase2_voltage", "volts"), 159 4: ("phase1_duration", "pulse_time"), 160 5: ("inter_phase_interval", "time"), 161 6: ("phase2_duration", "pulse_time"), 162 7: ("inter_pulse_interval", "pulse_time"), 163 8: ("burst_duration", "time"), 164 9: ("inter_burst_interval", "time"), 165 10: ("pulse_train_duration", "pulse_time"), 166 11: ("pulse_train_delay", "time"), 167 12: ("link_trigger_channel1", "bool"), 168 13: ("link_trigger_channel2", "bool"), 169 14: ("custom_train_id", "train_id"), 170 15: ("custom_train_target", "target"), 171 16: ("custom_train_loop", "bool"), 172 17: ("resting_voltage", "volts"), 173 18: ("continuous_loop", "bool"), 174} 175_OUTPUT_PARAMETER_CODES = {name: code for code, (name, _) in _OUTPUT_PARAMETERS.items()} 176_TRIGGER_MODE_CODE = 128 # The one trigger channel parameter 177_CONTINUOUS_LOOP_CODE = 18 # Not in ops 73 and 93, or settings files 178_BURST_DURATION_CODE = 8 # These two are checked together: see PulsePalDevice._check_custom_bursts() 179_CUSTOM_TRAIN_TARGET_CODE = 15 180_FORMAT_KEY = b"WIPE" # Follows op 97, so that two stray bytes cannot format the card (FORMAT_SD_CARD_KEY in the firmware) 181# The ops whose reply _write_raw() does not track: those with no reply, and the handshakes (72 and 103), whose reply 182# read_handshake_reply() reads until the device is quiet 183_OPS_WITHOUT_TRACKED_REPLY = frozenset({72, 77, 80, 81, 89, 98, 103}) 184# The ops that can change the parameters: if the reply to one is lost, they are read back (_discard_lost_reply()) 185_PARAMETER_OPS = frozenset({73, 74, 90, 91, 92, 97}) 186# The names a script assigns: the output parameters, in parameter code order, then these 187_SETTING_NAMES = (*(name for name, _ in _OUTPUT_PARAMETERS.values()), "trigger_mode", "auto_sync") 188 189# The parameters that ops 73, 92 and 93 carry in each block, in order 190_TIME_PARAMETERS = ("phase1_duration", "inter_phase_interval", "phase2_duration", "inter_pulse_interval", 191 "burst_duration", "inter_burst_interval", "pulse_train_duration", "pulse_train_delay") 192_VOLTAGE_PARAMETERS = ("phase1_voltage", "phase2_voltage", "resting_voltage") 193 194# The default parameters: the device's own (LoadDefaultParameters() in /Firmware/PulsePal/SDSettings.ino), which it 195# loads at startup, after a comm failure and after formatting its microSD card, so that it plays the same program 196# whichever way it got its defaults. The MATLAB class has the same; the legacy clients keep their own. 197_DEFAULT_OUTPUT_VALUES = { 198 "is_biphasic": False, 199 "phase1_voltage": 5.0, 200 "phase2_voltage": -5.0, 201 "phase1_duration": 0.0001, 202 "inter_phase_interval": 0.0001, 203 "phase2_duration": 0.0001, 204 "inter_pulse_interval": 0.001, 205 "burst_duration": 0.0, 206 "inter_burst_interval": 0.0, 207 "pulse_train_duration": 1.0, 208 "pulse_train_delay": 0.0, 209 "link_trigger_channel1": False, # Until 2026-10 True, so a TTL played these pulses on every output channel 210 "link_trigger_channel2": False, 211 "custom_train_id": 0, 212 "custom_train_target": "Pulses", 213 "custom_train_loop": False, 214 "resting_voltage": 0.0, 215 "continuous_loop": False, 216} 217 218 219@dataclass 220class DeviceInfo: 221 """Properties of the connected Pulse Pal device. 222 223 An instance is created for each connection and populated during the 224 handshake in `PulsePalDevice.__init__`. It is available as 225 `PulsePalDevice.info`. Devices running firmware v21 report only a 226 firmware version, so the remaining fields are filled in with the 227 known values for Pulse Pal hardware v2. 228 229 ```python 230 print(P.info.firmware_version) 231 ``` 232 """ 233 234 output_parameter_names: tuple = tuple(name for name, _ in _OUTPUT_PARAMETERS.values()) 235 """Output parameter names, ordered by parameter code. 236 237 Each is the name of a parameter list on `PulsePalDevice`, and a key of 238 the dict `PulsePalDevice.export_params` returns. 239 """ 240 241 trigger_modes: tuple = TRIGGER_MODES 242 """Names of the trigger modes the connected device has, accepted by 243 `PulsePalDevice.trigger_mode`. Pulse Pal 2 has no "Param Sync".""" 244 245 custom_train_targets: tuple = CUSTOM_TRAIN_TARGETS 246 """Names accepted by `PulsePalDevice.custom_train_target`.""" 247 248 firmware_version: int = None 249 """Firmware version running on the connected device.""" 250 251 hardware_version: int = None 252 """Hardware version of the connected device, e.g. `2` or `3`. 253 254 Reported by the device on firmware v22 and newer; assumed to be `2` 255 on older firmware. 256 """ 257 258 circuit_board_revision: int = None 259 """Revision of the device's circuit board, 0-31. 260 261 A Pulse Pal 3 board encodes its revision on five pins, which the 262 firmware reads at startup. A board that encodes none reports `0`, as 263 does Pulse Pal 2; firmware older than v22 cannot report one, so it is 264 also `0` there. To record exactly which hardware ran an experiment, 265 copy `hardware_version` and `circuit_board_revision` to your data. 266 """ 267 268 serial_number: str = None 269 """USB serial number of the device, e.g. `"20345420"`: the Teensy's, 270 unique to each Pulse Pal 3, and the same whichever firmware it runs. 271 272 Connect by it, wherever the device is plugged in, with 273 `PulsePalDevice(serial_number="20345420")`. To record exactly which unit ran an 274 experiment, copy it to your data. Pulse Pal 2 has none, and 275 firmware older than v22 cannot report one: `""`. 276 """ 277 278 max_custom_pulses: int = None 279 """Maximum number of pulses in a single custom pulse train.""" 280 281 n_custom_pulse_trains: int = None 282 """Number of custom pulse trains the device can store.""" 283 284 cycle_frequency: float = None 285 """Update frequency of the device's hardware timer, in Hz. 286 287 All time parameters are rounded to a whole number of these cycles, 288 so this sets the timing resolution of the device. 289 """ 290 291 cycle_period_us: float = None 292 """Update period of the device's hardware timer, in microseconds.""" 293 294 min_pulse_width_us: float = None 295 """Shortest pulse phase, inter-pulse interval and pulse train duration, 296 and shortest time between custom pulses, in microseconds. 297 298 One timer cycle (10 us) on Pulse Pal 3, whose trigger channels latch 299 every edge on their inputs. Two cycles (100 us) on Pulse Pal 2, whose 300 trigger channels read their inputs once per cycle, so that a one cycle 301 pulse could fall between two reads. A Pulse Pal 2, or a Pulse Pal 3 302 with firmware from before October 2026, can miss a pulse shorter than 303 100 us from a Pulse Pal 3. Custom pulse times are multiples of it. 304 """ 305 306 max_time: float = MAX_TIME 307 """Longest time parameter or custom pulse time, in seconds. See 308 `MAX_TIME`.""" 309 310 311@dataclass 312class DeviceStatus: 313 """What the device is doing, and what has happened to it, from 314 `PulsePalDevice.status`. 315 316 The counters count from the previous call to `PulsePalDevice.status`, 317 by this or any earlier connection, or from the device's startup: each 318 call clears them, and reports what happened since the one before. 319 """ 320 321 playing: list 322 """Numbers of the output channels playing, e.g. `[1, 3]`: from a 323 channel's start, its pulse train delay included, until its train 324 ends.""" 325 326 trigger_edges: list 327 """Rising edges on each trigger channel, indexed by trigger channel 328 number (index 0 is unused), whatever its trigger mode and whether or 329 not they started an output channel.""" 330 331 channel_starts: list 332 """Times each output channel started, indexed by channel number 333 (index 0 is unused): by a trigger channel, `PulsePalDevice.trigger` 334 or the joystick menu. A trigger that a channel ignored, because it 335 was already playing, is not a start.""" 336 337 comm_failures: int 338 """Commands that reached the device only in part, after which it 339 showed COMM. FAILURE! and waited for its joystick to be clicked.""" 340 341 342def _params_to_import(params): 343 """The parameters import_params() programs, as {name: values} by this class's names. 344 345 `params` is what export_params() returns, or a whole program file as json.load() reads it, whose "params" are 346 taken; the MATLAB class's names are accepted too ("phase1Voltage"), so that its exportParams() can be imported. 347 Names that are not parameters are skipped with a warning, and if none is, PulsePalError is raised: the device 348 would otherwise be sent its program unchanged, with only a warning to say why. 349 """ 350 if not isinstance(params, collections.abc.Mapping): 351 raise PulsePalError( 352 f"import_params() takes a dict of parameters, as export_params() returns. Received {type(params).__name__}." 353 ) 354 if isinstance(params.get("params"), collections.abc.Mapping): 355 # A program file, as the GUIs and the MATLAB class's saveProgramToFile() save it: format_version, params, the 356 # text of each custom train, device_info (see "Program files" in /USING.md) 357 program = params 358 params = dict(program["params"]) 359 if "trigger_mode" not in params and "trigger_mode" in program: 360 params["trigger_mode"] = program["trigger_mode"] # Files from before format_version held it here 361 if any(program.get("custom_train_timestamps") or []) or any(program.get("custom_train_voltages") or []): 362 _common.warn("import_params() imports a program file's parameters only: open it with gui() to load its " 363 "custom trains too.") 364 keys = {} # This class's name: the key in params 365 unknown = [] 366 for key in params: 367 name = _common.snake_case(key) if isinstance(key, str) else None 368 if name not in _SETTING_NAMES or name == "auto_sync": 369 unknown.append(key) 370 elif name in keys: 371 raise PulsePalError(f"import_params(): {keys[name]!r} and {key!r} are the same parameter.") 372 else: 373 keys[name] = key 374 if not keys: 375 listed = ", ".join(map(repr, unknown)) or "nothing" 376 raise PulsePalError( 377 f"import_params() found no parameter in {listed}, so it programs nothing. It takes what export_params() " 378 "returns, e.g. {'phase1_voltage': [5, 5, 5, 5]}, or a whole program file (json.load() of one)." 379 ) 380 if unknown: 381 _common.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of PulsePalDevice " 382 "does not have.") 383 return {name: params[key] for name, key in keys.items()} 384 385 386def _channel_setting(name, doc): 387 """A property for one of PulsePalDevice's parameter lists.""" 388 389 def get(self): 390 return getattr(self, "_" + name) 391 392 def set_(self, values): 393 getattr(self, "_" + name)._assign(values) 394 395 return property(get, set_, doc=doc) 396 397 398class PulsePalDevice: 399 """A class to control a Pulse Pal device on a USB serial port. 400 401 Creating an instance opens the serial port, exchanges a handshake 402 with the device, verifies that its firmware is supported, reads the 403 device properties into `PulsePalDevice.info`, and programs the 404 device with the default parameters (see 405 `PulsePalDevice.set_default_params`). 406 407 ```python 408 from pulsepal import PulsePalDevice 409 410 P = PulsePalDevice("COM3") 411 P.phase1_voltage[1] = 5 412 P.trigger(1) 413 P.close() 414 ``` 415 416 Here, replace "COM3" with Pulse Pal's USB serial port name. 417 To view a list of available ports, use 418 `PulsePalDevice.serialportlist()`. 419 Pulse Pal's port may not be visible if it is connected to another 420 instance of PulsePalDevice or an external application. Use 421 `PulsePalDevice.serialportlist("all")` to view all ports. 422 If you see multiple available ports, disconnect Pulse Pal's USB plug 423 and run serialportlist() again. Notice which port disappears from the 424 list. 425 426 PulsePalDevice is also a context manager, which closes the connection on 427 exit even if an error is raised: 428 429 ```python 430 with PulsePalDevice("COM3") as P: 431 P.trigger(1) 432 ``` 433 434 The attributes below named after Pulse Pal parameters are lists 435 indexed by channel number, with index 0 unused (see "Parameters" 436 above). `print(P)` shows them all, and `PulsePalDevice.export_params` 437 returns them as a dict. 438 """ 439 440 port: "serial.Serial" 441 """The open `serial.Serial` port connected to the device.""" 442 443 info: DeviceInfo 444 """Properties of the connected device. See `DeviceInfo`.""" 445 446 is_biphasic = _channel_setting("is_biphasic", """Pulse shape per channel: `False` for monophasic, `True` for biphasic. 447 448 Monophasic pulses use only the phase 1 parameters. Biphasic pulses 449 follow phase 1 with `PulsePalDevice.inter_phase_interval` and then 450 phase 2. 451 """) 452 453 phase1_voltage = _channel_setting("phase1_voltage", """Voltage of the first phase of each pulse, in volts [-10, 10].""") 454 455 phase2_voltage = _channel_setting("phase2_voltage", """Voltage of the second phase of each pulse, in volts [-10, 10]. 456 457 Used only when `PulsePalDevice.is_biphasic` is `True` for the channel. 458 """) 459 460 resting_voltage = _channel_setting("resting_voltage", """Voltage held between pulses, in volts [-10, 10]. 461 462 A new resting voltage reaches an idle channel's output at once. A 463 channel playing a pulse train keeps playing it, and moves to the new 464 resting voltage at its next transition to rest. 465 """) 466 467 phase1_duration = _channel_setting("phase1_duration", """Duration of the first phase of each pulse, in seconds. 468 469 At least `DeviceInfo.min_pulse_width_us`. 470 """) 471 472 inter_phase_interval = _channel_setting("inter_phase_interval", """Interval between the two phases of a biphasic pulse, in seconds. 473 474 The channel rests at `PulsePalDevice.resting_voltage` during the 475 interval. Used only when `PulsePalDevice.is_biphasic` is `True`. 476 """) 477 478 phase2_duration = _channel_setting("phase2_duration", """Duration of the second phase of each pulse, in seconds. 479 480 Used only when `PulsePalDevice.is_biphasic` is `True` for the channel. 481 At least `DeviceInfo.min_pulse_width_us`. 482 """) 483 484 inter_pulse_interval = _channel_setting("inter_pulse_interval", """Interval from the end of one pulse to the start of the next, in 485 seconds. At least `DeviceInfo.min_pulse_width_us`. 486 487 It is not the pulse period: 1 ms pulses 49 ms apart play 20 pulses per 488 second. 489 """) 490 491 burst_duration = _channel_setting("burst_duration", """Duration of each burst of pulses, in seconds. 492 493 Set to `0` to disable bursts, so that pulses continue for the whole 494 pulse train. A pulse starts only if it ends before the burst does: 495 its first phase, or for a biphasic pulse the whole pulse, so that 496 the end of a burst never cuts off a second phase. 497 """) 498 499 inter_burst_interval = _channel_setting("inter_burst_interval", """Interval between bursts of pulses, in seconds. 500 501 The channel rests at `PulsePalDevice.resting_voltage` between 502 bursts. Ignored when `PulsePalDevice.burst_duration` is `0`. 503 """) 504 505 pulse_train_duration = _channel_setting("pulse_train_duration", """Total duration of the pulse train, in seconds. 506 507 At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts 508 short a monophasic pulse still playing. A biphasic pulse starts only 509 if it can end by the end of the train, so that it keeps its second 510 phase. A train's end is set when it starts: a new duration applies 511 from the next train. 512 """) 513 514 pulse_train_delay = _channel_setting("pulse_train_delay", """Delay from the trigger to the onset of the pulse train, in 515 seconds.""") 516 517 link_trigger_channel1 = _channel_setting("link_trigger_channel1", """Whether each output channel is triggered by trigger channel 1. 518 519 `True` links the output channel to trigger channel 1, `False` unlinks 520 it. By default no output channel is linked to either trigger channel, 521 so a TTL starts nothing until a script links it. 522 """) 523 524 link_trigger_channel2 = _channel_setting("link_trigger_channel2", """Whether each output channel is triggered by trigger channel 2. 525 526 `True` links the output channel to trigger channel 2, `False` unlinks 527 it. 528 """) 529 530 custom_train_id = _channel_setting("custom_train_id", """Custom pulse train played by each output channel. 531 532 `0` plays the parametrically defined train. `1` or higher plays the 533 matching custom train, previously loaded with 534 `PulsePalDevice.send_custom_pulse_train` or 535 `PulsePalDevice.send_custom_waveform`: 1-4 on Pulse Pal 3, 1-2 on 536 Pulse Pal 2 (`DeviceInfo.n_custom_pulse_trains`). 537 """) 538 539 custom_train_target = _channel_setting("custom_train_target", """What the times of a custom train mark. 540 541 `"Pulses"` if each time is the onset of a pulse, `"Bursts"` if each 542 time is the onset of a burst of pulses, which lasts 543 `PulsePalDevice.burst_duration`. A burst whose time comes while the 544 burst before plays is dropped, so that every burst played is on 545 time. `"Bursts"` needs a burst duration above 0: with 546 `PulsePalDevice.auto_sync` on, set the burst duration first, or set 547 both in a `PulsePalDevice.batch` block. 548 """) 549 550 custom_train_loop = _channel_setting("custom_train_loop", """Whether a custom train repeats. 551 552 `True` loops the custom train until 553 `PulsePalDevice.pulse_train_duration` has elapsed, `False` plays it 554 once. Each pass starts as the last pulse (or burst, for a train 555 targeting bursts) of the pass before ends. 556 `PulsePalDevice.continuous_loop` does not apply to custom trains. 557 """) 558 559 continuous_loop = _channel_setting("continuous_loop", """Whether each output channel plays its pulse train until stopped. 560 561 `False` plays the pulse train once per trigger, for 562 `PulsePalDevice.pulse_train_duration`. `True` plays it until the 563 channel is stopped, ignoring `PulsePalDevice.pulse_train_duration`. 564 For parametric trains only: a custom train ignores it, and plays as 565 `PulsePalDevice.custom_train_loop` says. Requires firmware v22 or 566 newer. The device does not report it, so 567 `PulsePalDevice.sync_from_device` leaves it as it is. 568 """) 569 570 trigger_mode = _channel_setting("trigger_mode", """Response of each trigger channel to an incoming TTL pulse. 571 572 Indexed by trigger channel number, 1 or 2 (index 0 is unused). The 573 modes, also listed in `DeviceInfo.trigger_modes`, are: 574 575 - `"Normal"`: a rising edge starts the pulse trains of the linked 576 output channels, and edges during a train are ignored. 577 - `"Toggle"`: as `"Normal"`, but a rising edge during a train stops 578 it. 579 - `"Gated"`: the trains play only while the trigger TTL is high. 580 - `"Param Sync"` (Pulse Pal 3 only): a TTL rising edge starts and 581 stops nothing. It loads the parameters most recently sent by 582 `PulsePalDevice.sync_to_device` (or at the end of a 583 `PulsePalDevice.batch` block), and the custom trains loaded since 584 the previous edge. This is how the next trial's parameters and 585 custom trains are sent during the current trial and applied the 586 instant it starts. 587 588 The integer codes 0-3 are accepted too. 589 590 An output channel that is idle at a param sync edge takes its new 591 parameters in the timer cycle the edge is detected. One that is 592 playing a pulse train finishes that train on the parameters it 593 started with, and takes the new ones the moment it ends, so a train 594 that runs past the end of a trial keeps one shape throughout and the 595 next trigger plays a whole train with the new parameters. A channel 596 in continuous loop mode has no train end, so it keeps its parameters 597 until something stops it. 598 599 While either trigger channel is in param sync mode, **only 600 `PulsePalDevice.sync_to_device` (and the end of a 601 `PulsePalDevice.batch` block) and the custom train loads are held 602 back**: with `PulsePalDevice.auto_sync` on, every assignment still 603 programs the device immediately. So leaving param sync mode means 604 assigning `trigger_mode` with `auto_sync` on; a trigger mode sent by 605 `PulsePalDevice.sync_to_device` does not take effect until a sync 606 edge arrives. A custom train loaded in param sync mode 607 (`PulsePalDevice.send_custom_pulse_train`) replaces the old one at 608 the edge: channels playing the old one at the edge finish their 609 trains on it. Leaving param sync mode discards parameters still 610 waiting for an edge, but makes a custom train still waiting current 611 at once. 612 613 A param sync channel's links to output channels are ignored. To 614 start a pulse train on the same edge, wire the TTL to the other 615 trigger channel as well: both edges arrive in the same timer cycle, 616 and the parameters are loaded first. 617 618 Connecting a new `PulsePalDevice`, and 619 `PulsePalDevice.set_default_params`, take both trigger channels out 620 of param sync mode, so that the default parameters reach the device 621 instead of waiting for a TTL. 622 623 ```python 624 P.trigger_mode[2] = "Param Sync" # sent at once 625 with P.batch(): 626 P.phase1_voltage[1] = 2.5 627 P.phase1_duration[1] = 0.002 628 # Stored by the device: the next rising edge on trigger channel 2 applies it 629 ``` 630 """) 631 632 _CURRENT_FIRMWARE_VERSION = 22 633 _OLDEST_FIRMWARE_SUPPORTED = 21 634 _BAUD_RATE = 12000000 # USB serial ignores the baud rate 635 636 _OP_MENU_BYTE = _common.OP_MENU_BYTE 637 _HANDSHAKE_OPCODE = _common.OP_HANDSHAKE 638 _HANDSHAKE_DEVICE_CYCLES_OPCODE = _common.OP_HANDSHAKE_DEVICE_CYCLES 639 _HANDSHAKE_DEVICE_CYCLES_TIMEOUT = 0.3 # Seconds to wait for a reply to op 103 before trying op 72 640 _HANDSHAKE_RESPONSE = 75 # 'K' 641 _DAC_BITMAX = 65535 642 _PARAM_MESSAGE_BYTES = 178 # Length of the parameter set sent by op 93 643 _STATUS_MESSAGE_BYTES = 29 # Length of the reply to op 101: playing channels, then 7 uint32 counters 644 # Phase 1 and 2 durations, inter-pulse interval and train duration last at least this many 645 # timer cycles, by hardware version, as in the MATLAB and C++ classes and the joystick menu. 646 # Pulse Pal 2's trigger channels read their inputs once per cycle, so a one cycle pulse from 647 # another Pulse Pal could fall between two reads. Pulse Pal 3's also latch every edge. 648 _MIN_PULSE_CYCLES = {2: 2, 3: 1} 649 650 _ENDIANNESS = "<" 651 _STRUCT_FORMATS = { 652 "uint8": "B", 653 "int8": "b", 654 "uint16": "H", 655 "int16": "h", 656 "uint32": "I", 657 "int32": "i", 658 } 659 _TYPE_RANGES = { 660 "uint8": (0, 2**8 - 1), 661 "int8": (-(2**7), 2**7 - 1), 662 "uint16": (0, 2**16 - 1), 663 "int16": (-(2**15), 2**15 - 1), 664 "uint32": (0, 2**32 - 1), 665 "int32": (-(2**31), 2**31 - 1), 666 } 667 668 _reply_pending = None # The op code of a command whose reply has not been read in full yet (see _write_raw()) 669 _unsent = frozenset() # The settings assigned with auto_sync off since the parameters were last sent or read 670 671 def __init__(self, port_name=None, *, serial_number=None, timeout=10): 672 """Open a connection to a Pulse Pal device. 673 674 Opens the serial port, exchanges the handshake, verifies the 675 firmware version, reads the device properties into 676 `PulsePalDevice.info`, and programs the device with the default 677 parameters. 678 679 ```python 680 P = PulsePalDevice("COM3") 681 P = PulsePalDevice(serial_number="20345420") # Wherever it is plugged in 682 ``` 683 684 Args: 685 port_name: USB serial port for the Pulse Pal device, such as 686 `COM3` on Windows or `/dev/ttyACM0` on Linux. 687 serial_number: Instead of `port_name`, the device's USB serial 688 number (`DeviceInfo.serial_number`), which finds its port. 689 Pulse Pal 3 only: Pulse Pal 2 has none. 690 timeout: Serial read timeout, in seconds. 691 692 Firmware newer than this module knows (`v22`) is used with a 693 warning: new firmware only adds commands, so everything here still 694 works with it. Update the package to use what is new. 695 696 Raises: 697 PulsePalError: If the device does not return the expected 698 handshake (for example, it runs Wave Pal or Synth Pal 699 firmware), or its firmware is older than v21, or no port 700 has the serial number given. The port is closed again 701 before any error is raised. 702 serial.SerialException: If the serial port cannot be opened, 703 e.g. because another program has it open (on Linux and 704 macOS, another session of these classes, which lock it). 705 """ 706 self._closed = True 707 self._gui = None 708 self._auto_sync = True 709 self.info = DeviceInfo() 710 self._create_settings() 711 port_name = _common.port_name_from(port_name, serial_number, "PulsePalDevice") 712 self.port = serial.Serial( 713 port_name, 714 self._BAUD_RATE, 715 timeout=timeout, 716 rtscts=True, 717 # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two 718 # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks 719 # it, such as another of these classes, gets an error instead 720 exclusive=True, 721 ) 722 self._closed = False 723 try: 724 self._start_session(port_name) 725 except BaseException: 726 # Otherwise the port stays open until the object is garbage 727 # collected, and a second attempt, or WavePalDevice, cannot open 728 # it. The device is in an unknown state, so it is not sent the 729 # disconnect op. 730 self._close(send_disconnect=False) 731 raise 732 733 def _create_settings(self): 734 """Create the parameter lists, holding the default values (nothing is sent).""" 735 for code, (name, _) in _OUTPUT_PARAMETERS.items(): 736 setattr(self, "_" + name, ChannelSettings( 737 name, [_DEFAULT_OUTPUT_VALUES[name]] * 4, self, f"_apply_{name}")) 738 self._trigger_mode = ChannelSettings( 739 "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode") 740 741 def _start_session(self, port_name): 742 """Handshake, check the firmware, and program the defaults.""" 743 # Discard anything left unread by an earlier session (one stopped mid-reply, for example): 744 # it would be read as the handshake's reply. A reply the device sends after this, to a 745 # command it had not reached yet, is skipped by read_handshake_reply(). 746 self.port.reset_input_buffer() 747 # Op 103: the session's times are in the device's own timer cycles (10 us on Pulse Pal 3). Firmware from before 748 # October 2026, Wave Pal and Synth Pal ignore it, and answer op 72, whose times are in 50 us cycles. A device that 749 # answers op 103 too late counts the session's times in 50 us cycles too, after the op 72 that follows; either 750 # way op 94 reports the session's unit, which the class then uses. 751 self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_DEVICE_CYCLES_OPCODE), "uint8") 752 reply = _common.read_handshake_reply(self.port, timeout=self._HANDSHAKE_DEVICE_CYCLES_TIMEOUT) 753 if reply is None: 754 self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE), "uint8") 755 reply = _common.read_handshake_reply(self.port) 756 if reply is None: 757 raise PulsePalError( 758 f"No reply to the handshake from the device on {port_name}. Is it a Pulse Pal? " 759 f"{_common.NO_REPLY_HINT}" 760 ) 761 handshake, firmware_version = reply 762 if handshake != self._HANDSHAKE_RESPONSE: 763 if handshake in _common.FIRMWARE_BY_HANDSHAKE_REPLY: 764 raise _common.other_firmware_error(handshake, firmware_version, port_name, "Pulse Pal") 765 raise PulsePalError( 766 "Incorrect handshake returned. Expected " 767 f"{self._HANDSHAKE_RESPONSE}, received {handshake}." 768 ) 769 770 if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED: 771 raise PulsePalError( 772 "Old firmware detected, v" 773 f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or " 774 "newer is required." 775 ) 776 if firmware_version > self._CURRENT_FIRMWARE_VERSION: 777 # New firmware only adds commands (see /Firmware/PROTOCOL.md), so this class still works with it 778 warnings.warn( 779 f"Pulse Pal firmware v{firmware_version} is newer than this pulsepal package knows " 780 f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what " 781 "is new.", stacklevel=3) 782 if firmware_version < self._CURRENT_FIRMWARE_VERSION: 783 warnings.warn( 784 f"Pulse Pal firmware v{firmware_version} is supported, but v" 785 f"{self._CURRENT_FIRMWARE_VERSION} is available: see /MATLAB/FirmwareLoader.", stacklevel=3) 786 self.info.firmware_version = firmware_version 787 788 if self.info.firmware_version > 21: 789 self._write_serial((self._OP_MENU_BYTE, 94), "uint8") 790 self.info.hardware_version = self._read_serial(1, "uint8") 791 self.info.cycle_period_us = self._read_serial(1, "uint32") # The session's time unit (see op 103 above) 792 # Not 1 / (period / 1e6): at 10 us that is 99999.99999999999, and times read back from the device would 793 # convert to 0.00010000000000000002 s instead of 0.0001 s 794 self.info.cycle_frequency = 1000000 / self.info.cycle_period_us 795 self.info.n_custom_pulse_trains = self._read_serial(1, "uint8") 796 self.info.max_custom_pulses = self._read_serial(1, "uint32") 797 self._write_serial((self._OP_MENU_BYTE, 100), "uint8") # Circuit board revision 798 revision = self.port.read(1) 799 if len(revision) < 1: 800 raise PulsePalError( 801 f"The Pulse Pal on {port_name} did not report its circuit board revision." 802 ) 803 self._reply_pending = None # Read without _read_raw() (see _write_raw()) 804 self.info.circuit_board_revision = revision[0] 805 self._write_serial((self._OP_MENU_BYTE, _common.OP_SERIAL_NUMBER), "uint8") 806 self.info.serial_number = _common.read_serial_number(self._read_raw) 807 else: 808 self.info.hardware_version = 2 809 self.info.circuit_board_revision = 0 810 self.info.serial_number = "" # v21 runs on Pulse Pal 2, which has none 811 self.info.cycle_period_us = 50 812 self.info.cycle_frequency = 20000 813 self.info.n_custom_pulse_trains = 2 814 self.info.max_custom_pulses = 5000 815 self.info.min_pulse_width_us = ( 816 self._MIN_PULSE_CYCLES[self.info.hardware_version] * self.info.cycle_period_us 817 ) 818 if not self._has_param_sync(): 819 self.info.trigger_modes = TRIGGER_MODES[:3] 820 821 # Client name op + "PYTHON" in ASCII. 822 self._write_serial( 823 (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78), 824 "uint8", 825 ) 826 827 self.set_default_params() 828 829 @staticmethod 830 def serialportlist(ports_to_list="available"): 831 """Return the names of the USB serial ports on this computer. 832 833 Called on the class, without connecting to a device, to find the 834 port name to pass to `PulsePalDevice`: 835 836 ```python 837 from pulsepal import PulsePalDevice 838 839 ports = PulsePalDevice.serialportlist() 840 P = PulsePalDevice(ports[0]) 841 ``` 842 843 Args: 844 ports_to_list: `available` to list only the ports that are 845 not already in use, or `all` to list every USB serial 846 port. Not case sensitive. 847 848 Returns: 849 Sorted list of port names, such as `["COM3", "COM7"]` on 850 Windows or `["/dev/ttyACM0"]` on Linux. 851 852 Raises: 853 PulsePalError: If `ports_to_list` is not `available` or 854 `all`. 855 """ 856 return _common.serialportlist(ports_to_list) 857 858 # ------------------------------------------------------------------ 859 # Programming the device 860 # ------------------------------------------------------------------ 861 862 @property 863 def auto_sync(self): 864 """Whether assigning a parameter programs the device at once. 865 866 `True` (the default): each assignment, such as 867 `P.phase1_voltage[1] = 5`, programs the device at once, also while 868 a trigger channel is in param sync mode. It sends the channels 869 whose value it changes, and nothing for a channel that already 870 holds the value assigned: the other channels keep what the device 871 holds, e.g. values set with its joystick (read them with 872 `PulsePalDevice.sync_from_device`). 873 874 `False`: assignments change only this object's copy of the 875 parameters, checking each value, and 876 `PulsePalDevice.sync_to_device` sends all of them in one command. 877 Turning it back on does not send changes made meanwhile, so it 878 raises `pulsepal.PulsePalError` while there are any: call 879 `PulsePalDevice.sync_to_device` first to send them, or 880 `PulsePalDevice.sync_from_device` to discard them. A 881 `PulsePalDevice.batch` block does it all for you. `close()` warns of 882 changes never sent. 883 """ 884 return self._auto_sync 885 886 @auto_sync.setter 887 def auto_sync(self, value): 888 value = to_bool(value, "auto_sync") 889 if value and self._unsent: 890 # Turned on, auto_sync would leave them unsent, and the device playing its old parameters 891 discard = "sync_from_device() to discard them" 892 if "continuous_loop" in self._unsent: 893 discard += " (all but continuous_loop, which the device does not report)" 894 raise PulsePalError( 895 f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call " 896 f"sync_to_device() to send them, or {discard}, before turning auto_sync on." 897 ) 898 self._auto_sync = value 899 900 def batch(self): 901 """Change several parameters, and send them in one command. 902 903 Returns a context manager. In its block, assignments change only 904 this object's copy of the parameters (as with 905 `PulsePalDevice.auto_sync` off), and when the block ends, 906 `PulsePalDevice.sync_to_device` sends all of them at once. If the 907 block raises an error, nothing is sent, and the parameters go 908 back to what they were. 909 910 ```python 911 with P.batch(): 912 P.phase1_voltage = [5, 5, 2.5, 2.5] 913 P.phase1_duration[3] = 0.002 914 ``` 915 916 In param sync mode, the device stores the parameters for the next 917 rising edge on the param sync channel (see 918 `PulsePalDevice.trigger_mode`). 919 """ 920 return _common.batch(self) 921 922 def sync_to_device(self): 923 """Program the device with this object's copy of all parameters. 924 925 Sends every output and trigger parameter to the device in a single 926 command. Use it after assignments made with 927 `PulsePalDevice.auto_sync` off; with it on, they have already 928 reached the device. 929 930 ```python 931 P.auto_sync = False 932 P.phase1_voltage[1:5] = [5] * 4 933 P.sync_to_device() 934 ``` 935 936 On Pulse Pal 3, if either trigger channel is in param sync mode 937 (`PulsePalDevice.trigger_mode` `"Param Sync"`), the device stores 938 the parameters instead of programming them, and loads them on the 939 next rising edge of that channel. The device still checks every 940 value. This is the only method whose effect is deferred that way. 941 942 Raises: 943 PulsePalError: If a value is out of range for its parameter 944 (nothing is sent), or the device does not acknowledge the 945 command. 946 """ 947 self._check_sync() 948 self._send_sync() 949 950 def sync_from_device(self): 951 """Read all parameters from the device into this object's copy. 952 953 Overwrites the parameter lists with the program currently on the 954 device, e.g. after it has been reprogrammed from its thumb 955 joystick. `PulsePalDevice.continuous_loop` is left as it is: the 956 device does not report it. Changes made with 957 `PulsePalDevice.auto_sync` off and not yet sent are discarded, but 958 for `PulsePalDevice.continuous_loop`. 959 960 Requires firmware v22 or newer. 961 962 Raises: 963 PulsePalError: If the connected firmware is older than v22, 964 or the device does not return the full parameter set. 965 """ 966 self._require_firmware(22, "sync_from_device()") 967 for name, values in self._read_device_params().items(): 968 getattr(self, name)._store(values) 969 self._unsent = self._unsent & {"continuous_loop"} # Not read: a change to it is still unsent 970 971 def set_default_params(self): 972 """Program the device with the default parameters. 973 974 The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart 975 (about 909 a second), for 1 second, resting at 0 V, on all four 976 output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms 977 after 0.1 ms at rest. No output channel is linked to a trigger 978 channel, so a TTL starts nothing until a script links it, and both 979 trigger channels are in normal mode. They are the device's own 980 defaults, which it also loads at startup, after a comm failure 981 and after formatting its microSD card. They are sent at once, 982 also while `PulsePalDevice.auto_sync` is off. The constructor 983 calls it. 984 """ 985 for name, value in _DEFAULT_OUTPUT_VALUES.items(): 986 getattr(self, name)._store([value] * 4) 987 self._trigger_mode._store(["Normal"] * 2) 988 if self._has_param_sync(): 989 # A device left in param sync mode would store the sync below instead of running it, 990 # leaving the device on its old program until a TTL arrived. A trigger mode sent on 991 # its own is not deferred that way, so both trigger channels leave param sync mode 992 # first. See PulsePalDevice.trigger_mode. 993 self._send_trigger_modes(["Normal"] * 2, "set_default_params()") 994 self._send_sync() 995 996 def export_params(self): 997 """Return every parameter, as a dict of plain lists. 998 999 Keyed by parameter name (`DeviceInfo.output_parameter_names`, then 1000 `"trigger_mode"`), with one value per channel and no unused index 1001 0. It holds only numbers, booleans and names, so it can be saved 1002 with `json` and logged with your data, to record exactly what the 1003 device played. `PulsePalDevice.import_params` programs it again. 1004 1005 ```python 1006 import json 1007 with open("trial_params.json", "w") as f: 1008 json.dump(P.export_params(), f) 1009 ``` 1010 """ 1011 names = [*self.info.output_parameter_names, "trigger_mode"] 1012 return {name: list(getattr(self, name))[1:] for name in names} 1013 1014 def import_params(self, params): 1015 """Program the device with parameters exported by `PulsePalDevice.export_params`. 1016 1017 All of them are checked first, and then sent in one command, as 1018 at the end of a `PulsePalDevice.batch` block: in param sync mode, 1019 the device stores them for the next sync edge. Parameters missing 1020 from `params` keep their values. A name that is not a parameter 1021 is skipped with a warning, so that parameters exported by a newer 1022 version of this package, which may have more, can still be 1023 imported; if none is, an error is raised. 1024 1025 A whole program file, as the GUIs save it, is imported too: its 1026 parameters, not its custom trains (a warning says so, if it has 1027 any). 1028 1029 ```python 1030 with open("MyProgram.json") as f: 1031 P.import_params(json.load(f)) 1032 ``` 1033 1034 Args: 1035 params: A dict of parameter name to one value per channel (a 1036 list, tuple or NumPy array), as `export_params` returns, 1037 or a program file's contents. The MATLAB class's names 1038 (`phase1Voltage`) are accepted too, so that the parameters 1039 its `exportParams()` returns, saved with `jsonencode`, can 1040 be imported. 1041 1042 Raises: 1043 PulsePalError: If a value is invalid, or no name in `params` is 1044 a parameter. Nothing is sent, and the parameters keep the 1045 values they had. 1046 """ 1047 params = _params_to_import(params) 1048 with self.batch(): 1049 for name, values in params.items(): 1050 setattr(self, name, values) 1051 1052 def set_fixed_voltage(self, channels, voltage): 1053 """Set output channels to a fixed voltage. 1054 1055 Each channel holds the voltage until it is set again or until a 1056 pulse train is triggered on it. 1057 1058 ```python 1059 P.set_fixed_voltage(4, 2.5) 1060 P.set_fixed_voltage([1, 3], -1) 1061 ``` 1062 1063 Args: 1064 channels: Output channel number, 1-4, or several as a list, 1065 tuple or NumPy array. 1066 voltage: Voltage to set on each of them, in volts [-10, 10]. 1067 1068 Raises: 1069 PulsePalError: If a channel number is not 1-4, the voltage is 1070 outside [-10, 10] (nothing is sent), or the device does not 1071 acknowledge the command. 1072 """ 1073 channels = _common.channel_numbers(channels) 1074 voltage_bits = self._volts_to_bits(voltage, "voltage") 1075 for channel in channels: 1076 self._write_serial( 1077 (self._OP_MENU_BYTE, 79, channel), 1078 "uint8", 1079 voltage_bits, 1080 "uint16", 1081 ) 1082 self._read_ack("set_fixed_voltage()") 1083 1084 def set_calibration(self, channel, voltage_offset): 1085 """Calibrate the zero code of an output channel. 1086 1087 The offset is added to every voltage the channel produces, to 1088 correct for DAC offset error. It is stored in the device's 1089 EEPROM and reloaded on boot, so it only needs to be set once. 1090 1091 Requires Pulse Pal hardware v3 or newer. 1092 1093 Args: 1094 channel: Output channel number, 1-4. 1095 voltage_offset: Offset to apply, in volts [-0.1, 0.1]. 1096 1097 Raises: 1098 PulsePalError: If the connected hardware is older than v3, 1099 `channel` is not 1-4, `voltage_offset` is outside 1100 [-0.1, 0.1], or the device does not acknowledge the 1101 command. 1102 """ 1103 if self.info.hardware_version < 3: 1104 raise PulsePalError( 1105 "set_calibration() requires hardware v3 or newer." 1106 ) 1107 if not (isinstance(channel, numbers.Integral) and channel in (1, 2, 3, 4)): 1108 raise PulsePalError("channel must be 1, 2, 3 or 4") 1109 if not (isinstance(voltage_offset, numbers.Real) and -0.1 <= voltage_offset <= 0.1): # Also refuses NaN 1110 raise PulsePalError( 1111 "voltage_offset for zero code calibration must be in range " 1112 "[-0.1, 0.1]" 1113 ) 1114 # To the nearest DAC code, halfway values to the even one, as the MATLAB class rounds it 1115 voltage_bits = int(round(voltage_offset * (1 / (20 / 65536)))) 1116 self._write_serial( 1117 (self._OP_MENU_BYTE, 96, channel - 1), 1118 "uint8", 1119 voltage_bits, 1120 "int16", 1121 ) 1122 self._read_ack("set_calibration()") 1123 1124 def set_screen_saver(self, enabled, timeout=1800): 1125 """Switch the device's screen saver on or off, and set its timeout. 1126 1127 With the screen saver on, the device dims its screen once it has 1128 been left alone for `timeout` seconds: no command from the 1129 computer, no rising edge on a trigger channel, and no joystick 1130 click or push. The next of these brings the screen back. Both 1131 settings are stored in the device's EEPROM and kept through power 1132 cycles; the screen saver can also be switched on and off from the 1133 device's joystick menu. A new device has it on, with 1800 s. 1134 1135 The device saves the settings once no channel is playing. Saving 1136 pauses its timer, usually for about 20 us but, once in about 2000 1137 changes, for tens of milliseconds, which would delay a trigger. 1138 Change the settings before an experiment rather than during one. 1139 1140 Requires firmware v22 or newer. Only Pulse Pal 3 has a screen 1141 saver: Pulse Pal 2 accepts `enabled=False` only. 1142 1143 ```python 1144 P.set_screen_saver(True, 300) # dim after 5 minutes 1145 P.set_screen_saver(False) 1146 ``` 1147 1148 Args: 1149 enabled: True (or 1) to switch the screen saver on, False (or 1150 0) to switch it off. 1151 timeout: Seconds without activity before the screen dims, a 1152 whole number from 1 to 65535. It is sent with every call, 1153 so leaving it out sets 1800 s. 1154 1155 Raises: 1156 PulsePalError: If the firmware is older than v22, the 1157 screen saver is switched on with hardware older than v3, 1158 a value is out of range, or the device does not 1159 acknowledge the command. 1160 """ 1161 self._require_firmware(22, "set_screen_saver()") 1162 enabled = to_bool(enabled, "enabled") 1163 if not ( 1164 isinstance(timeout, numbers.Real) 1165 and not isinstance(timeout, bool) 1166 and math.isfinite(timeout) 1167 and timeout == int(timeout) 1168 and 1 <= timeout <= 65535 1169 ): 1170 raise PulsePalError( 1171 "timeout must be a whole number of seconds from 1 to 65535" 1172 ) 1173 if enabled and (self.info.hardware_version or 2) < 3: 1174 raise PulsePalError( 1175 "The screen saver requires hardware v3 or newer: Pulse Pal 2 " 1176 "accepts set_screen_saver(False) only." 1177 ) 1178 self._write_serial( 1179 (self._OP_MENU_BYTE, 99, int(enabled)), 1180 "uint8", 1181 int(timeout), 1182 "uint16", 1183 ) 1184 self._read_ack("set_screen_saver()") 1185 1186 # ------------------------------------------------------------------ 1187 # Custom trains, playback and settings files 1188 # ------------------------------------------------------------------ 1189 1190 def send_custom_pulse_train( 1191 self, 1192 custom_train_id, 1193 pulse_times, 1194 voltages, 1195 ): 1196 """Load a custom pulse train onto the device. 1197 1198 A custom pulse train is an arbitrary list of pulse onset times 1199 and voltages, replacing the parametric pulse voltage and onset 1200 timing. Set `PulsePalDevice.custom_train_id` on an output channel 1201 to play the train there. Each pulse takes the channel's own phase 1202 durations; a biphasic pulse's second phase is its voltage with the 1203 sign reversed. A biphasic pulse always plays whole, and a pulse 1204 whose time comes while it plays is dropped, so that every pulse 1205 played is on time. 1206 1207 ```python 1208 P.send_custom_pulse_train( 1209 2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10] 1210 ) 1211 P.custom_train_id[1] = 2 1212 ``` 1213 1214 Loading a train stops the output channels playing it, and their 1215 next trigger plays the new train. On Pulse Pal 3, while a trigger 1216 channel is in param sync mode (see `PulsePalDevice.trigger_mode`), 1217 the device stores the train instead, as it stores the parameters 1218 `PulsePalDevice.sync_to_device` sends: channels go on playing the 1219 old train, and the next rising edge on the param sync channel 1220 makes the new one current. A channel playing the old train at that 1221 edge finishes it, and plays the new one from its next trigger. So 1222 the next trial's custom trains can be loaded during the current 1223 trial. Leaving param sync mode makes a stored train current at 1224 once. 1225 1226 Args: 1227 custom_train_id: Custom train to load, from 1 to 1228 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1229 4 on Pulse Pal 3). 1230 pulse_times: Pulse onset times, in seconds, relative to the 1231 start of the train, increasing. Each is a multiple of 1232 `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 1233 100 us on Pulse Pal 2), from 0 to 1234 `MAX_TIME`. Accepts a list, tuple or 1-D NumPy array. 1235 voltages: Voltage of each pulse, in volts [-10, 10]. Must be 1236 the same length as `pulse_times`. 1237 1238 Raises: 1239 PulsePalError: If `custom_train_id` is out of range, 1240 `pulse_times` or `voltages` is not one-dimensional (a 2-D 1241 array is refused, not flattened), they differ in length, 1242 there are no pulses, or more than 1243 `DeviceInfo.max_custom_pulses`, a pulse time is not a 1244 multiple of `DeviceInfo.min_pulse_width_us`, is out of 1245 range or is not later than the one before it, or the 1246 device does not acknowledge the command. 1247 """ 1248 pulse_times_cycles = self._custom_times_to_cycles(pulse_times, "pulse_times") 1249 pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages") 1250 if len(pulse_times_cycles) != len(pulse_voltage_bits): 1251 raise PulsePalError( 1252 "pulse_times and voltages must be the same length." 1253 ) 1254 self._send_custom_train( 1255 custom_train_id, 1256 pulse_times_cycles, 1257 pulse_voltage_bits, 1258 "send_custom_pulse_train()", 1259 ) 1260 1261 def send_custom_waveform( 1262 self, 1263 custom_train_id, 1264 sampling_period, 1265 voltages, 1266 ): 1267 """Load an arbitrary waveform onto the device. 1268 1269 A convenience shorthand for 1270 `PulsePalDevice.send_custom_pulse_train` with evenly spaced, 1271 adjoining pulses, so that `voltages` is played as a waveform 1272 sampled every `sampling_period` seconds. It is loaded as a custom 1273 train is: see `PulsePalDevice.send_custom_pulse_train` for what 1274 happens to channels playing it, and in param sync mode. 1275 1276 Set the channel's `PulsePalDevice.phase1_duration` to 1277 `sampling_period` as well, so that each sample is held for the 1278 sampling period. 1279 1280 ```python 1281 import math 1282 1283 samples = [math.sin(i / 10.0) * 10 for i in range(1000)] 1284 P.send_custom_waveform(1, 0.001, samples) # 1 kHz 1285 P.custom_train_id[2] = 1 1286 P.phase1_duration[2] = 0.001 1287 ``` 1288 1289 Args: 1290 custom_train_id: Custom train to load, from 1 to 1291 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1292 4 on Pulse Pal 3). 1293 sampling_period: In seconds: a multiple of 1294 `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 1295 100 us on Pulse Pal 2). Each voltage is 1296 held for this long. 1297 voltages: Waveform samples, in volts [-10, 10]. 1298 Accepts a list, tuple or 1-D NumPy array. 1299 1300 Raises: 1301 PulsePalError: If `custom_train_id` is out of range, there 1302 are no samples, or more than 1303 `DeviceInfo.max_custom_pulses`, `sampling_period` is not a 1304 positive multiple of `DeviceInfo.min_pulse_width_us`, the 1305 last sample would start after `MAX_TIME`, or the device 1306 does not acknowledge the command. 1307 """ 1308 pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages") 1309 period_cycles = self._custom_time_cycles(sampling_period, "sampling_period") 1310 if period_cycles == 0: 1311 raise PulsePalError( 1312 f"sampling_period must be a positive multiple of {self.info.min_pulse_width_us:g} us. " 1313 f"Received {sampling_period!r}." 1314 ) 1315 pulse_times = period_cycles * np.arange(len(pulse_voltage_bits), dtype=np.int64) 1316 self._send_custom_train( 1317 custom_train_id, 1318 pulse_times, 1319 pulse_voltage_bits, 1320 "send_custom_waveform()", 1321 ) 1322 1323 def trigger(self, channels): 1324 """Trigger output channels in software. 1325 1326 The channels start their pulse trains together, in the same timer 1327 cycle. A channel that is already playing a pulse train ignores 1328 the trigger, and `PulsePalDevice.stop` cancels a trigger that has 1329 not started its channel yet. 1330 1331 ```python 1332 P.trigger(3) # one channel 1333 P.trigger([1, 4]) # several: a list, tuple or NumPy array 1334 ``` 1335 1336 Args: 1337 channels: Output channel number, 1-4, or several as a list, 1338 tuple or NumPy array. 1339 1340 Raises: 1341 PulsePalError: If a channel number is not 1-4, or no channel 1342 is given. Nothing is triggered. 1343 """ 1344 self._write_serial((self._OP_MENU_BYTE, 77, _common.channel_bits(channels)), "uint8") 1345 1346 def stop(self, channels=None): 1347 """Stop pulse trains currently playing on the device. 1348 1349 The stopped channels return to their 1350 `PulsePalDevice.resting_voltage`. A soft trigger that has not 1351 started its channel yet is cancelled too. 1352 1353 ```python 1354 P.stop() # all output channels 1355 P.stop([1, 3, 4]) # channels 1, 3 and 4 1356 ``` 1357 1358 Args: 1359 channels: Output channel number, 1-4, or several as a list, 1360 tuple or NumPy array. `None`, the default, stops all 1361 channels. Stopping some channels requires firmware v22 1362 or newer. 1363 1364 Raises: 1365 PulsePalError: If a channel number is not 1-4, or channels are 1366 given and the firmware is older than v22. 1367 """ 1368 if self.info.firmware_version < 22: 1369 if channels is not None: 1370 raise PulsePalError("stop() cannot address individual channels before firmware v22") 1371 self._write_serial((self._OP_MENU_BYTE, 80), "uint8") # Stops all channels 1372 return 1373 bits = 0x0F if channels is None else _common.channel_bits(channels) 1374 self._write_serial((self._OP_MENU_BYTE, 98, bits), "uint8") 1375 1376 def status(self): 1377 """Read which channels are playing, and the counters since the last call. 1378 1379 The counters (see `DeviceStatus`) count from the previous call, or 1380 from the device's startup, and each call clears them. Calling it 1381 before and after a trial checks that the TTLs the trial sent 1382 arrived, and started the trains they should: 1383 1384 ```python 1385 P.status() # Clears the counters 1386 run_trial() # Your code: one TTL on trigger channel 1, linked to channels 1 and 2 1387 s = P.status() 1388 if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]: 1389 print("The trial's trains did not all start") 1390 ``` 1391 1392 Requires firmware v22 or newer. 1393 1394 Returns: 1395 A `DeviceStatus`. 1396 1397 Raises: 1398 PulsePalError: If the firmware is older than v22, or the device 1399 does not reply. 1400 """ 1401 self._require_firmware(22, "status()") 1402 self._write_serial((self._OP_MENU_BYTE, 101), "uint8") 1403 values = struct.unpack(f"{self._ENDIANNESS}B7I", self._read_raw(self._STATUS_MESSAGE_BYTES)) 1404 return DeviceStatus( 1405 playing=[ch for ch in range(1, 5) if values[0] & (1 << (ch - 1))], 1406 trigger_edges=[None, *values[1:3]], 1407 channel_starts=[None, *values[3:7]], 1408 comm_failures=values[7], 1409 ) 1410 1411 def save_program_to_microsd(self, file_name): 1412 """Save the parameters to the device's microSD card, as a program. 1413 1414 The program can be loaded later with 1415 `PulsePalDevice.load_program_from_microsd` or with the joystick 1416 menu's Load Program item. A file of the same name is replaced. To 1417 keep the parameters on this computer instead, see 1418 `PulsePalDevice.export_params`. 1419 1420 ```python 1421 P.save_program_to_microsd("MyProtocol.pps") 1422 ``` 1423 1424 Args: 1425 file_name: At most 15 ASCII characters, ending in `.pps`. 1426 1427 Raises: 1428 PulsePalError: If the file name is invalid, or the device could 1429 not write the file to its microSD card. Pulse Pal 2 also 1430 refuses while a channel is playing: its card shares a bus with 1431 the outputs, so stop playback first. 1432 """ 1433 self._settings_file_op(file_name, 1, "save_program_to_microsd()") 1434 1435 def load_program_from_microsd(self, file_name): 1436 """Load a program from the device's microSD card. 1437 1438 The device plays the program, and the parameters are read back 1439 into this object (see `PulsePalDevice.sync_from_device`). The 1440 program's trigger modes take effect too, and it is loaded at once, 1441 also in param sync mode. 1442 1443 Args: 1444 file_name: The program's file name, ending in `.pps`, as saved 1445 by `PulsePalDevice.save_program_to_microsd` or the joystick 1446 menu. 1447 1448 Raises: 1449 PulsePalError: If the file name is invalid, or the device could 1450 not load the program: there is no such file on its microSD 1451 card, or it is not a valid Pulse Pal program; or, on Pulse 1452 Pal 2, a channel is playing (see `save_program_to_microsd`). 1453 The device then keeps its parameters, and so does this object. 1454 """ 1455 self._settings_file_op(file_name, 2, "load_program_from_microsd()") 1456 1457 def delete_program_from_microsd(self, file_name): 1458 """Delete a program from the device's microSD card. 1459 1460 Args: 1461 file_name: The program's file name, ending in `.pps`. 1462 1463 Raises: 1464 PulsePalError: If the file name is invalid, or the device could 1465 not delete the file, e.g. because there is no such file, or, 1466 on Pulse Pal 2, because a channel is playing. 1467 """ 1468 self._settings_file_op(file_name, 3, "delete_program_from_microsd()") 1469 1470 def _settings_file_op(self, file_name, op_byte, context): 1471 """Save (op_byte 1), load (2) or delete (3) a settings file, with op 90.""" 1472 valid = ( 1473 isinstance(file_name, str) 1474 and all(32 <= ord(character) <= 126 for character in file_name) # Printable ASCII 1475 and file_name.lower().endswith(".pps") 1476 and 4 < len(file_name) <= 15 1477 ) 1478 if not valid: 1479 raise PulsePalError( 1480 f"{context}: the file name must be 1 to 11 ASCII characters followed by .pps, " 1481 f"e.g. 'Protocol1.pps'. Received {file_name!r}." 1482 ) 1483 filename_bytes = file_name.encode("ascii") 1484 self._write_serial( 1485 (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)), 1486 "uint8", 1487 list(filename_bytes), 1488 "uint8", 1489 ) 1490 if self.info.firmware_version > 21: 1491 # Sent after the file operation has finished. A refused load changes nothing on the device. 1492 refusal = { 1493 1: f"Pulse Pal could not save {file_name!r} to its microSD card. Is the card in place?", 1494 2: f"Pulse Pal could not load {file_name!r}: there is no such file on its microSD card, or it is not " 1495 "a valid Pulse Pal program. The device keeps its parameters.", 1496 3: f"Pulse Pal could not delete {file_name!r}: there is no such file on its microSD card.", 1497 }[op_byte] 1498 # Pulse Pal 2 replies 2 while a channel plays: its microSD card shares the SPI bus with the DAC 1499 busy = (f"Pulse Pal 2 does not use its microSD card while a channel is playing, because the card shares a " 1500 f"bus with the outputs, so {file_name!r} was not {('saved', 'loaded', 'deleted')[op_byte - 1]}. " 1501 "Stop playback first, with stop().") 1502 if op_byte == 2: 1503 busy += " The device keeps its parameters." 1504 self._read_ack(context, refusal_message=refusal, refusal_messages={2: busy}) 1505 else: 1506 self._reply_pending = None # Firmware v21 does not acknowledge op 90 (see _write_raw()) 1507 if op_byte == 2: 1508 time.sleep(0.1) # So allow time for the load 1509 if op_byte == 2: 1510 self.sync_from_device() 1511 1512 def format_microsd(self, *, confirm=True, timeout=30): 1513 """Format the device's microSD card. 1514 1515 Erases every program saved on the device and resets its 1516 parameters to the defaults. By default, asks at the console for 1517 confirmation first; a script that runs on its own, or an AI agent, 1518 passes `confirm=False`. 1519 1520 Requires Pulse Pal hardware v3 or newer. 1521 1522 Args: 1523 confirm: If `True`, ask at the console before anything is 1524 erased. If `False`, format at once. 1525 timeout: Seconds to wait for the device to report that 1526 formatting has finished. 1527 1528 Returns: 1529 `True` once the card has been formatted, or `False` if the 1530 user declines the confirmation prompt. 1531 1532 Raises: 1533 PulsePalError: If the connected hardware is older than v3, if 1534 the device reports that formatting failed, or if it does 1535 not report a result within `timeout` seconds. 1536 """ 1537 if self.info.hardware_version < 3: 1538 raise PulsePalError( 1539 "format_microsd() requires hardware v3 or newer." 1540 ) 1541 1542 if to_bool(confirm, "confirm"): 1543 print("*** Pulse Pal microSD Formatter ***") 1544 print("This will format Pulse Pal's microSD card,") 1545 print("erase all programs saved on the device") 1546 print("and reset all parameters to defaults.") 1547 reply = input("Do you want to continue (y/n)") 1548 if reply.strip().lower() != "y": 1549 print("Choice confirmed - microSD Card NOT formatted.") 1550 return False 1551 1552 self._write_serial((self._OP_MENU_BYTE, 97), "uint8", list(_FORMAT_KEY), "uint8") 1553 1554 # The device replies with lines of status text, the last of which 1555 # contains "!", and then a confirm byte (1 if the card was formatted, 1556 # 0 if not). The confirm byte is sent after the device has reloaded 1557 # its default parameters, so it can arrive well after the text. It 1558 # must be read here: left in the buffer, it would be taken as the 1559 # reply to the next command, and every reply after that would be 1560 # read one byte late. 1561 start = time.time() 1562 message = bytearray() 1563 flag_index = -1 1564 line_end = -1 1565 1566 while time.time() - start < timeout: 1567 n_waiting = self.port.in_waiting 1568 if n_waiting: 1569 message.extend(self.port.read(n_waiting)) 1570 flag_index = message.find(b"!") 1571 if flag_index >= 0: 1572 line_end = message.find(b"\n", flag_index) 1573 if line_end >= 0 and len(message) > line_end + 1: 1574 break 1575 time.sleep(0.01) 1576 if line_end < 0 or len(message) <= line_end + 1: 1577 raise PulsePalError( 1578 "Pulse Pal did not report the result of formatting " 1579 f"its microSD card within {timeout} s. {_common.NO_REPLY_HINT}" 1580 ) 1581 self._reply_pending = None # The whole reply is read (see _write_raw()) 1582 confirm_byte = message[line_end + 1] 1583 1584 text = bytes(message[:flag_index]).decode( 1585 "ascii", errors="replace" 1586 ).rstrip() 1587 if text: 1588 print(text) 1589 1590 # The device has loaded its own defaults. They are this class's, but the device keeps no record of 1591 # this object's other changes (continuous loop mode, for one), so the defaults are programmed, as the 1592 # MATLAB class does, and this object describes the device again. 1593 self.set_default_params() 1594 if confirm_byte != 1: 1595 raise PulsePalError( 1596 "Pulse Pal could not format its microSD card." 1597 ) 1598 return True 1599 1600 def gui(self, block=None, theme=None): 1601 """Open the Pulse Pal parameter GUI, or focus an open one. 1602 1603 The GUI edits its own copy of the parameters, and loads them to 1604 the device when its 'Load to Device' button is clicked. The 1605 window closes automatically when the device is closed or 1606 deleted. 1607 1608 Calling this while the GUI is already open focuses the existing 1609 window rather than opening a second one. 1610 1611 Args: 1612 block: If `True`, the call returns when the GUI is closed. If 1613 `False`, the call returns immediately, and the host 1614 application must run the Tk event loop. If `None`, the 1615 GUI blocks only when the host does not already provide a 1616 Tk event loop, e.g. when launched from a script. 1617 theme: `"light"` or `"dark"` to select the color theme, or 1618 `None` to match the desktop theme. Passing a theme to an 1619 already-open GUI recolors it in place. 1620 1621 Returns: 1622 The `pulsepal.gui.PulsePalGUI` instance driving the window. 1623 1624 Raises: 1625 ValueError: If the theme name is not recognized. 1626 """ 1627 gui = getattr(self, "_gui", None) 1628 if gui is not None and not gui.is_closed: 1629 if theme is not None: 1630 gui.set_theme(theme) 1631 gui.focus() 1632 return gui 1633 1634 from .gui import PulsePalGUI 1635 1636 gui = PulsePalGUI(self, theme=theme) 1637 self._gui = gui 1638 gui.start(block=block) 1639 return gui 1640 1641 # ------------------------------------------------------------------ 1642 # Connection 1643 # ------------------------------------------------------------------ 1644 1645 def close(self): 1646 """Close the connection to the device, and the GUI if open. 1647 1648 The device stops all output channels when the client disconnects, 1649 and shows its own name on its screen again. Safe to call more 1650 than once; later calls do nothing. Called automatically when 1651 leaving a `with` block and when the object is garbage collected, 1652 so pulse trains also stop when the last reference to the object 1653 goes, e.g. when a function that created it returns. 1654 """ 1655 self._close(send_disconnect=True) 1656 1657 def _close(self, send_disconnect): 1658 """Close the connection. send_disconnect: tell the device first (op 81). False when the device is in 1659 an unknown state, such as after a failed handshake.""" 1660 gui = getattr(self, "_gui", None) 1661 self._gui = None 1662 if gui is not None: 1663 try: 1664 gui.close() 1665 except Exception: 1666 # Cleanup must not raise; Tk may already be torn down 1667 pass 1668 1669 if getattr(self, "_closed", True): 1670 return 1671 self._closed = True 1672 if send_disconnect and self._unsent: 1673 _common.warn(f"PulsePalDevice closed with changes made with auto_sync off that were never sent to the " 1674 f"device: {self._unsent_names()}.") 1675 self._reply_pending = None # Nothing more is read, so a reply still to come needs no discarding 1676 try: 1677 if send_disconnect and self.port and self.port.is_open: 1678 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 1679 except Exception: 1680 pass # The port may already be gone, e.g. the cable was unplugged 1681 finally: 1682 if self.port and self.port.is_open: 1683 self.port.close() 1684 1685 def __enter__(self): 1686 """Enter a `with` block, returning the connected device.""" 1687 return self 1688 1689 def __exit__(self, exc_type, exc_value, traceback): 1690 """Disconnect and close the port when leaving a `with` block. 1691 1692 Returns: 1693 `False`, so any exception raised in the block propagates. 1694 """ 1695 self.close() 1696 return False 1697 1698 def __del__(self): 1699 """Disconnect and close the port when the object is collected.""" 1700 try: 1701 self.close() 1702 except Exception: 1703 # Destructors should not raise; the serial object may already be 1704 # gone during interpreter shutdown. 1705 pass 1706 1707 def __setattr__(self, name, value): 1708 """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see 1709 _common.refuse_unknown_attribute()).""" 1710 if name[0] != "_": 1711 _common.refuse_unknown_attribute(self, name, _SETTING_NAMES) 1712 super().__setattr__(name, value) 1713 1714 def __repr__(self): 1715 """Describe the device and its parameters, e.g. with `print(P)`.""" 1716 port = getattr(getattr(self, "port", None), "port", None) 1717 lines = [ 1718 f"PulsePalDevice on {port} (Pulse Pal {self.info.hardware_version}, " 1719 f"firmware v{self.info.firmware_version})", 1720 f"auto_sync: {self._auto_sync}", 1721 ] 1722 for name in self.info.output_parameter_names: 1723 lines.append(f"{name}: {list(getattr(self, name))}") 1724 lines.append(f"trigger_mode: {list(self._trigger_mode)}") 1725 return "\n".join(lines) 1726 1727 # ------------------------------------------------------------------ 1728 # Internals: parameters 1729 # ------------------------------------------------------------------ 1730 1731 def _has_param_sync(self): 1732 """Param sync mode is on Pulse Pal 3, with firmware v22 or newer.""" 1733 return (self.info.hardware_version or 2) > 2 and (self.info.firmware_version or 0) > 21 1734 1735 def _normalize_output_value(self, code, value, channel): 1736 """Check a value for an output parameter, and return it as the parameter's list holds it: 1737 a time as the device plays it, rounded to its timer cycle. 1738 1739 Raises PulsePalError, before anything is sent, for a value the device cannot play. The 1740 device refuses most of them too, but it also resets them (see validateOutputParams() in 1741 /Firmware/PulsePal/USBOps.ino), so the local copy would describe a different program. 1742 """ 1743 name, kind = _OUTPUT_PARAMETERS[code] 1744 label = f"{name} on channel {channel}" 1745 if kind == "bool": 1746 value = to_bool(value, label) 1747 if code == _CONTINUOUS_LOOP_CODE and value and self.info.firmware_version < 22: 1748 raise PulsePalError("continuous_loop requires firmware v22 or newer.") 1749 return value 1750 if kind == "volts": 1751 self._volts_to_bits(value, label) 1752 return float(value) 1753 if kind in ("time", "pulse_time"): 1754 cycles = self._seconds_to_cycles(value, label) 1755 # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001, 1756 # but is exactly 10 cycles of 10 us 1757 if kind == "pulse_time" and cycles < self._min_pulse_cycles(): 1758 raise PulsePalError( 1759 f"{label} must be at least {self._min_pulse_seconds()} s " 1760 f"({self._min_pulse_cycles_text()} of the device's {self.info.cycle_period_us} us " 1761 f"timer), so that trigger channels can detect the pulses. Received {value!r}." 1762 ) 1763 return self._cycles_to_seconds(cycles) # The time the device plays 1764 if kind == "train_id": 1765 self._check_whole_number(value, label, 0, self.info.n_custom_pulse_trains) 1766 return int(value) 1767 # "target" 1768 return to_name(value, CUSTOM_TRAIN_TARGETS, label, codes={"Pulses": 0, "Bursts": 1}) 1769 1770 def _normalize_trigger_mode(self, value): 1771 modes = self.info.trigger_modes 1772 return to_name(value, modes, "trigger_mode", codes={mode: TRIGGER_MODES.index(mode) for mode in modes}) 1773 1774 def _encode_output_param(self, code, values): 1775 """Convert output parameter values to device units. 1776 1777 Returns the converted values and the datatype the device reads for this 1778 parameter code: DAC codes for voltages, hardware timer cycles for times, 1779 and bytes for everything else. 1780 """ 1781 name, kind = _OUTPUT_PARAMETERS[code] 1782 if kind == "volts": 1783 return [self._volts_to_bits(v, name) for v in values], "uint16" 1784 if kind in ("time", "pulse_time"): 1785 return [self._seconds_to_cycles(v, name) for v in values], "uint32" 1786 if kind == "target": 1787 return [CUSTOM_TRAIN_TARGETS.index(v) for v in values], "uint8" 1788 return [int(v) for v in values], "uint8" 1789 1790 def _apply_output_param(self, code, values): 1791 """Check new values for all four channels of an output parameter, and with auto_sync on, 1792 program the channels whose value changes. Returns them as the parameter's list holds them.""" 1793 values = [self._normalize_output_value(code, v, ch) for ch, v in enumerate(values, start=1)] 1794 name = _OUTPUT_PARAMETERS[code][0] 1795 if not self._auto_sync: 1796 self._unsent = self._unsent | {name} # Until sync_to_device() sends it (see auto_sync) 1797 else: 1798 if code in (_BURST_DURATION_CODE, _CUSTOM_TRAIN_TARGET_CODE): 1799 # Sent alone, so it must suit the other parameter as the device holds it. In a batch() block, or 1800 # with auto_sync off, both are checked together by sync_to_device(). 1801 new = {name: values} 1802 self._check_custom_bursts(new.get("custom_train_target", list(self._custom_train_target)[1:]), 1803 new.get("burst_duration", list(self._burst_duration)[1:])) 1804 # Compared as the device receives them: 5 and 5.00001 V are the same DAC code 1805 old_data, _ = self._encode_output_param(code, list(getattr(self, name))[1:]) 1806 new_data, _ = self._encode_output_param(code, values) 1807 channels = [ch for ch in range(1, 5) if old_data[ch - 1] != new_data[ch - 1]] 1808 self._send_output_param(code, values, channels, f"setting {name}") 1809 return values 1810 1811 def _send_output_param(self, code, values, channels, context): 1812 """Program one output parameter on some output channels: op 91 if all four change, op 74 for each otherwise. 1813 1814 The other channels are left as the device holds them. Sending all four (op 91) for a change to one put this 1815 object's copy of the others back on the device, undoing what the joystick menu, or a program loaded with it, 1816 had set there. 1817 """ 1818 if self.info.firmware_version > 21 and len(channels) == 4: 1819 data, datatype = self._encode_output_param(code, values) 1820 self._write_serial((self._OP_MENU_BYTE, 91, code), "uint8", data, datatype) 1821 self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, channels)) 1822 return 1823 for channel in channels: 1824 # A refusal reads back every channel this assignment changes, so that the copy describes the device for 1825 # those already sent too 1826 self._send_output_param_to_channel(code, channel, values[channel - 1], context, refresh_channels=channels) 1827 1828 def _send_output_param_to_channel(self, code, channel, value, context, refresh_channels=None): 1829 """Program one output parameter on one channel (op 74).""" 1830 if code == _CONTINUOUS_LOOP_CODE and self.info.firmware_version < 22: 1831 return # Firmware v21 has no parameter 18, and _normalize_output_value() lets only False through 1832 data, datatype = self._encode_output_param(code, [value]) 1833 self._write_serial((self._OP_MENU_BYTE, 74, code, channel), "uint8", data, datatype) 1834 self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, refresh_channels or (channel,))) 1835 1836 def _apply_trigger_mode(self, values): 1837 """Check new trigger modes, and with auto_sync on, program the trigger channels whose mode changes.""" 1838 modes = [self._normalize_trigger_mode(v) for v in values] 1839 if not self._auto_sync: 1840 self._unsent = self._unsent | {"trigger_mode"} 1841 else: 1842 channels = [ch for ch in (1, 2) if modes[ch - 1] != self._trigger_mode[ch]] 1843 if len(channels) == 2: 1844 self._send_trigger_modes(modes, "setting trigger_mode") 1845 elif channels: # One: op 74, which leaves the other trigger channel as the device holds it 1846 channel = channels[0] 1847 self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel, 1848 TRIGGER_MODES.index(modes[channel - 1])), "uint8") 1849 self._read_ack("setting trigger_mode", on_refusal=self._refresh_trigger_mode) 1850 return modes 1851 1852 def _send_trigger_modes(self, modes, context): 1853 """Program both trigger channels' modes: op 91, or op 74 per channel on firmware v21.""" 1854 codes = [TRIGGER_MODES.index(mode) for mode in modes] 1855 if self.info.firmware_version > 21: 1856 self._write_serial((self._OP_MENU_BYTE, 91, _TRIGGER_MODE_CODE, *codes), "uint8") 1857 self._read_ack(context, on_refusal=self._refresh_trigger_mode) 1858 else: 1859 for channel, mode_code in enumerate(codes, start=1): 1860 self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel, mode_code), "uint8") 1861 self._read_ack(context) 1862 1863 def _snapshot(self): 1864 """All parameters, and which are unsent, for batch() to restore if its block fails.""" 1865 names = [*self.info.output_parameter_names, "trigger_mode"] 1866 return {name: list(getattr(self, name))[1:] for name in names}, self._unsent 1867 1868 def _restore(self, snapshot): 1869 params, self._unsent = snapshot 1870 for name, values in params.items(): 1871 getattr(self, name)._store(values) 1872 1873 def _unsent_names(self): 1874 """The settings assigned with auto_sync off and not yet sent, for messages, in parameter code order.""" 1875 return ", ".join(name for name in _SETTING_NAMES if name in self._unsent) 1876 1877 def _check_sync(self): 1878 """Check the whole local copy before sync_to_device() sends it.""" 1879 for code, (name, _) in _OUTPUT_PARAMETERS.items(): 1880 values = getattr(self, name) 1881 for ch in range(1, 5): 1882 self._normalize_output_value(code, values[ch], ch) 1883 for ch in (1, 2): 1884 self._normalize_trigger_mode(self._trigger_mode[ch]) 1885 self._check_custom_bursts(list(self._custom_train_target)[1:], list(self._burst_duration)[1:]) 1886 1887 @staticmethod 1888 def _check_custom_bursts(targets, burst_durations): 1889 """Refuse a custom train targeting bursts on a channel with no burst duration. 1890 1891 Its times are burst onsets, and a burst of no duration plays one timer cycle of a pulse at each: a 1892 program that cannot be what was meant. (The device once played one pulse and then left the channel 1893 "playing", ignoring triggers until it was stopped.) 1894 """ 1895 for channel, (target, burst) in enumerate(zip(targets, burst_durations), start=1): 1896 if target == "Bursts" and burst == 0: 1897 raise PulsePalError( 1898 f"Output channel {channel}: custom_train_target 'Bursts' makes the custom train's times burst " 1899 "onsets, so burst_duration must be above 0. Set burst_duration first, or set both in a batch() " 1900 "block." 1901 ) 1902 1903 def _send_sync(self): 1904 """Send every parameter in one command: op 92 on firmware v22 or newer, op 73 on v21.""" 1905 self._unsent = frozenset() # All are sent below 1906 if self.info.firmware_version > 21: 1907 self._sync_all_params() 1908 else: 1909 self._sync_all_params_legacy() 1910 self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync) 1911 1912 def _device_bytes(self, name): 1913 """The byte values of a byte parameter for channels 1-4, as the device reads them.""" 1914 values = list(getattr(self, name))[1:] 1915 if name == "custom_train_target": 1916 return [CUSTOM_TRAIN_TARGETS.index(v) for v in values] 1917 return [int(v) for v in values] 1918 1919 def _sync_all_params(self): 1920 """Send all parameters using the packed sync op (firmware v22+). 1921 1922 Values are grouped by width so that the whole program travels as 1923 one uint32 block, one uint16 block and one uint8 block. See op 92 1924 in /Firmware/PROTOCOL.md. 1925 """ 1926 time_values = [] 1927 for name in _TIME_PARAMETERS: 1928 time_values.extend(self._seconds_to_cycles(v) for v in list(getattr(self, name))[1:]) 1929 voltage_values = [] 1930 for name in _VOLTAGE_PARAMETERS: 1931 voltage_values.extend(self._volts_to_bits(v) for v in list(getattr(self, name))[1:]) 1932 single_byte_values = [] 1933 for name in ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop", 1934 "continuous_loop", "link_trigger_channel1", "link_trigger_channel2"): 1935 single_byte_values.extend(self._device_bytes(name)) 1936 single_byte_values.extend(TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:]) 1937 1938 self._write_serial( 1939 (self._OP_MENU_BYTE, 92), 1940 "uint8", 1941 time_values, 1942 "uint32", 1943 voltage_values, 1944 "uint16", 1945 single_byte_values, 1946 "uint8", 1947 ) 1948 1949 def _sync_all_params_legacy(self): 1950 """Send all parameters using the legacy sync op (firmware v21). 1951 1952 Equivalent to `_sync_all_params`, but lays the program out 1953 channel by channel as the older firmware expects. Continuous 1954 loop mode is not part of it. 1955 """ 1956 program_values_32 = [] 1957 program_values_16 = [] 1958 program_values_8 = [] 1959 for ch in range(1, 5): 1960 program_values_32.extend(self._seconds_to_cycles(getattr(self, name)[ch]) for name in _TIME_PARAMETERS) 1961 for ch in range(1, 5): 1962 program_values_16.extend(self._volts_to_bits(getattr(self, name)[ch]) for name in _VOLTAGE_PARAMETERS) 1963 byte_values = {name: self._device_bytes(name) for name in 1964 ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop")} 1965 for ch in range(4): 1966 program_values_8.extend(byte_values[name][ch] for name in byte_values) 1967 links = self._device_bytes("link_trigger_channel1") + self._device_bytes("link_trigger_channel2") 1968 modes = [TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:]] 1969 1970 self._write_serial( 1971 (self._OP_MENU_BYTE, 73), 1972 "uint8", 1973 program_values_32, 1974 "uint32", 1975 program_values_16, 1976 "uint16", 1977 program_values_8 + links + modes, 1978 "uint8", 1979 ) 1980 1981 def _read_device_params(self): 1982 """Read the device's parameters (op 93). 1983 1984 Returns a dict of parameter values for channels 1-4 (trigger 1985 channels 1-2 for trigger_mode), as the parameter lists hold them. 1986 Continuous loop mode is not part of op 93, so `continuous_loop` is 1987 not included. 1988 """ 1989 self._write_serial((self._OP_MENU_BYTE, 93), "uint8") 1990 # The device sends the whole parameter set as one message, so read it in one go and 1991 # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md. 1992 message = self._read_raw(self._PARAM_MESSAGE_BYTES) 1993 values = struct.unpack(f"{self._ENDIANNESS}32I12H26B", message) 1994 params = {} 1995 for index, name in enumerate(_TIME_PARAMETERS): 1996 params[name] = [self._cycles_to_seconds(x) for x in values[index * 4:index * 4 + 4]] 1997 for index, name in enumerate(_VOLTAGE_PARAMETERS): 1998 params[name] = [self._bits_to_volts(x) for x in values[32 + index * 4:32 + index * 4 + 4]] 1999 for index, name in enumerate(("is_biphasic", "custom_train_id", "custom_train_target", 2000 "custom_train_loop", "link_trigger_channel1", "link_trigger_channel2")): 2001 codes = values[44 + index * 4:44 + index * 4 + 4] 2002 if name == "custom_train_id": 2003 params[name] = list(codes) 2004 elif name == "custom_train_target": 2005 params[name] = [CUSTOM_TRAIN_TARGETS[min(code, 1)] for code in codes] 2006 else: 2007 params[name] = [bool(code) for code in codes] 2008 params["trigger_mode"] = [TRIGGER_MODES[min(code, 3)] for code in values[68:70]] 2009 return params 2010 2011 def _refresh_output_param(self, code, channels): 2012 """Read an output parameter back from the device into the local copy, for these channels. 2013 2014 Called after the device refused a value for them, which it resets. The parameter's other 2015 channels, and other parameters, keep their local values, which may hold edits not yet synced. 2016 """ 2017 name = _OUTPUT_PARAMETERS[code][0] 2018 if self.info.firmware_version < 22 or code == _CONTINUOUS_LOOP_CODE: 2019 return # No op 93 to read it with, or not in op 93 2020 device_values = self._read_device_params()[name] 2021 settings = getattr(self, name) 2022 local_values = list(settings)[1:] 2023 for ch in channels: 2024 local_values[ch - 1] = device_values[ch - 1] 2025 settings._store(local_values) 2026 2027 def _refresh_trigger_mode(self): 2028 """Read the trigger modes back from the device, after the device refused a new one.""" 2029 if self.info.firmware_version > 21: 2030 self._trigger_mode._store(self._read_device_params()["trigger_mode"]) 2031 2032 def _refresh_after_refused_sync(self): 2033 """Read the parameters back after the device refused a sync_to_device() set. 2034 2035 The device programs the set with the refused values reset, so the local copy is read back. 2036 In param sync mode the device stores the set instead, and op 93 would return the parameters 2037 playing now, so the local copy is left as it is. 2038 """ 2039 if self.info.firmware_version > 21 and "Param Sync" not in list(self._trigger_mode)[1:]: 2040 self.sync_from_device() 2041 2042 def _check_whole_number(self, value, name, low, high): 2043 """Raise PulsePalError unless value is a whole number from low to high.""" 2044 valid = ( 2045 isinstance(value, numbers.Real) 2046 and not isinstance(value, (bool, np.bool_)) 2047 and math.isfinite(value) 2048 and value == int(value) 2049 and low <= value <= high 2050 ) 2051 if not valid: 2052 raise PulsePalError( 2053 f"{name} must be a whole number from {low} to {high}. Received {value!r}." 2054 ) 2055 2056 def _min_pulse_cycles(self): 2057 """The shortest pulse phase, interval or train, in timer cycles: DeviceInfo.min_pulse_width_us. See 2058 _MIN_PULSE_CYCLES.""" 2059 return int(round(self.info.min_pulse_width_us / self.info.cycle_period_us)) 2060 2061 def _min_pulse_cycles_text(self): 2062 """_min_pulse_cycles() for messages: "1 cycle", "2 cycles".""" 2063 cycles = self._min_pulse_cycles() 2064 return f"{cycles} cycle" if cycles == 1 else f"{cycles} cycles" 2065 2066 def _min_pulse_seconds(self): 2067 """The shortest pulse phase, interval or train, in seconds: DeviceInfo.min_pulse_width_us.""" 2068 return self._cycles_to_seconds(self._min_pulse_cycles()) 2069 2070 def _require_firmware(self, minimum_version, context): 2071 """Raise unless the device firmware is new enough for an op.""" 2072 if ( 2073 self.info.firmware_version is None 2074 or self.info.firmware_version < minimum_version 2075 ): 2076 raise PulsePalError( 2077 f"{context} requires firmware v{minimum_version} or newer. " 2078 f"Detected firmware is v{self.info.firmware_version}." 2079 ) 2080 2081 # ------------------------------------------------------------------ 2082 # Internals: custom trains 2083 # ------------------------------------------------------------------ 2084 2085 def _send_custom_train(self, custom_train_id, pulse_times_cycles, 2086 pulse_voltage_bits, context): 2087 """Send a custom pulse train, already converted to device units (NumPy integer arrays). 2088 2089 Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76 2090 (trains 1 and 2 only) on firmware v21. 2091 """ 2092 n_trains = self.info.n_custom_pulse_trains 2093 valid_id = ( 2094 isinstance(custom_train_id, numbers.Integral) 2095 and not isinstance(custom_train_id, (bool, np.bool_)) 2096 and 1 <= custom_train_id <= n_trains 2097 ) 2098 if not valid_id: 2099 raise PulsePalError( 2100 f"{context}: custom_train_id must be an integer from 1 to " 2101 f"{n_trains}. Received {custom_train_id!r}." 2102 ) 2103 train_id = int(custom_train_id) 2104 n_pulses = len(pulse_times_cycles) 2105 if n_pulses == 0: 2106 # The device refuses an empty train too: it would hold no pulse times to play 2107 raise PulsePalError( 2108 f"{context}: a custom train needs at least one pulse. To play nothing on a channel, set its " 2109 "custom_train_id to a train that holds what it should play, or do not trigger it." 2110 ) 2111 if n_pulses > self.info.max_custom_pulses: 2112 raise PulsePalError( 2113 f"{context}: {n_pulses} pulses were given. Pulse Pal can " 2114 f"store up to {self.info.max_custom_pulses} pulses per " 2115 "custom train." 2116 ) 2117 # Times are multiples of _min_pulse_cycles() (see _custom_times_to_cycles()), so increasing times are far 2118 # enough apart for a trigger channel to detect each pulse. 2119 too_close = np.flatnonzero(np.diff(pulse_times_cycles) < self._min_pulse_cycles()) 2120 if too_close.size: 2121 i = int(too_close[0]) + 1 2122 raise PulsePalError( 2123 f"{context}: pulse times must increase, by at least " 2124 f"{self._min_pulse_seconds()} s ({self._min_pulse_cycles_text()} " 2125 f"of the device's {self.info.cycle_period_us} us " 2126 f"timer). Pulse {i + 1} is at " 2127 f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, " 2128 f"and pulse {i} is at " 2129 f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s." 2130 ) 2131 if pulse_times_cycles[-1] > self._max_time_cycles(): 2132 raise PulsePalError( 2133 f"{context}: the last pulse starts at {self._cycles_to_seconds(pulse_times_cycles[-1])} s. " 2134 f"Pulse times must be at most {self.info.max_time} s." 2135 ) 2136 2137 if self.info.firmware_version > 21: 2138 header = bytes([self._OP_MENU_BYTE, 95, train_id - 1]) 2139 else: 2140 header = bytes([self._OP_MENU_BYTE, 74 + train_id]) 2141 self._write_raw( 2142 header + struct.pack("<I", n_pulses) 2143 + np.asarray(pulse_times_cycles).astype("<u4").tobytes() 2144 + np.asarray(pulse_voltage_bits).astype("<u2").tobytes() 2145 ) 2146 self._read_ack(context) 2147 2148 def _number_array(self, values, name, description): 2149 """Return values (a scalar, a list, a tuple or a 1-D NumPy array) as a float64 array, refusing booleans, 2150 strings and anything else that is not a number, as the scalar conversions do. 2151 2152 An array of two or more dimensions, or a list of lists, is refused rather than flattened: one value per 2153 pulse is a list, and a 2-D one is a mistake, such as both rows of a stereo waveform. (The MATLAB class, which 2154 took a matrix's longest side as its pulse count and sent all of it, sent the rest to the device as commands.) 2155 """ 2156 if isinstance(values, np.ndarray): 2157 self._check_one_dimensional(values, name) 2158 if values.dtype.kind in "iuf": 2159 return values.ravel().astype(float) 2160 items = self._as_list(values) 2161 refused_types = tuple(kind for kind in {type(item) for item in items} # Each type once: fast for long lists 2162 if issubclass(kind, (bool, np.bool_, str, bytes))) 2163 if refused_types: 2164 item = next(item for item in items if isinstance(item, refused_types)) 2165 raise PulsePalError(f"{name} must be {description}. Received {item!r}.") 2166 try: 2167 array = np.asarray(items, dtype=float) 2168 except (TypeError, ValueError): 2169 for item in items: 2170 try: 2171 float(item) 2172 except (TypeError, ValueError): 2173 raise PulsePalError(f"{name} must be {description}. Received {item!r}.") from None 2174 raise 2175 self._check_one_dimensional(array, name) # A list of lists 2176 return array 2177 2178 @staticmethod 2179 def _check_one_dimensional(array, name): 2180 if array.ndim > 1: 2181 raise PulsePalError( 2182 f"{name} must be one-dimensional, one value per pulse: a list, a tuple or a 1-D NumPy array. " 2183 f"Received an array of shape {array.shape}." 2184 ) 2185 2186 @staticmethod 2187 def _first(values, refused): 2188 """The first value at a True position of the boolean array `refused`, for an error message.""" 2189 i = int(np.flatnonzero(refused)[0]) 2190 item = values.ravel()[i] if isinstance(values, np.ndarray) else PulsePalDevice._as_list(values)[i] 2191 return item.item() if isinstance(item, np.generic) else item 2192 2193 def _custom_times_to_cycles(self, values, name): 2194 """Convert custom pulse times to timer cycles, all at once: _custom_time_cycles() for each of them. 2195 2196 Each must be a time the device can play, and a whole number of DeviceInfo.min_pulse_width_us to the nearest 2197 microsecond, as in the MATLAB class. Returns an int64 array. 2198 """ 2199 seconds = self._number_array(values, name, "a time of 0 s or more") 2200 refused = ~(np.isfinite(seconds) & (seconds >= 0)) 2201 if refused.any(): 2202 raise PulsePalError(f"{name} must be a time of 0 s or more. Received {self._first(values, refused)!r}.") 2203 # The same arithmetic as _seconds_to_cycles() and _custom_time_cycles(): NumPy's round() also takes a 2204 # halfway value to the even integer 2205 refused = np.round(seconds * float(self.info.cycle_frequency)) > self._max_time_cycles() 2206 if refused.any(): 2207 raise PulsePalError( 2208 f"{name} must be at most {self.info.max_time} s, the longest time the device's screen " 2209 f"shows. Received {self._first(values, refused)!r}." 2210 ) 2211 microseconds = np.round(seconds * 1e6) 2212 step_us = int(self.info.min_pulse_width_us) 2213 refused = np.mod(microseconds, step_us) != 0 2214 if refused.any(): 2215 raise PulsePalError( 2216 f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). " 2217 f"Received {self._first(values, refused)!r}." 2218 ) 2219 return (microseconds // int(self.info.cycle_period_us)).astype(np.int64) 2220 2221 def _volts_array_to_bits(self, values, name): 2222 """Convert voltages to DAC codes, all at once: _volts_to_bits() for each of them. Returns an int64 array.""" 2223 volts = self._number_array(values, name, "in [-10, 10] V") 2224 refused = ~((volts >= -10) & (volts <= 10)) # Also refuses NaN 2225 if refused.any(): 2226 raise PulsePalError(f"{name} must be in [-10, 10] V. Received {self._first(values, refused)!r}.") 2227 return np.round((volts + 10) / 20 * self._DAC_BITMAX).astype(np.int64) 2228 2229 # ------------------------------------------------------------------ 2230 # Internals: serial port and units 2231 # ------------------------------------------------------------------ 2232 2233 def _write_serial(self, *args): 2234 """Write one or more data/type pairs to the serial port, in one write.""" 2235 if len(args) % 2 != 0: 2236 raise PulsePalError( 2237 "Serial writes require data/type argument pairs." 2238 ) 2239 2240 payload = bytearray() 2241 for i in range(0, len(args), 2): 2242 payload.extend(self._pack_values(args[i], args[i + 1])) 2243 self._write_raw(bytes(payload)) 2244 2245 def _write_raw(self, payload): 2246 """Write a command to the serial port, in one write. 2247 2248 A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves 2249 that reply, or the rest of it, to arrive later. Read as the reply to the next command, it put every reply 2250 after it out of step: status() reported channels playing that were not, and assignments the device took 2251 raised refusals. So it is discarded first (_discard_lost_reply()). Then this command's reply, if it has one, 2252 is pending until it has been read: _read_raw() marks it read, and a method that reads a reply otherwise 2253 (format_microsd()) sets _reply_pending back to None itself. 2254 """ 2255 if self._reply_pending is not None: 2256 self._discard_lost_reply() 2257 if payload[1] not in _OPS_WITHOUT_TRACKED_REPLY: 2258 self._reply_pending = payload[1] # Before the write: a Ctrl-C can come as soon as it returns 2259 bytes_written = self.port.write(payload) 2260 if bytes_written != len(payload): 2261 raise PulsePalError( 2262 f"Wrote {bytes_written} byte(s), expected to write " 2263 f"{len(payload)} byte(s)." 2264 ) 2265 2266 def _discard_lost_reply(self): 2267 """Discard what arrives of a reply that was not read in full (see _write_raw()), and bring this object back 2268 in step with the device. 2269 2270 The command may have changed the parameters, unknown to this object: an assignment interrupted after its 2271 write, for one, which the device took and this object did not. So after one of _PARAMETER_OPS, they are read 2272 back (op 93), on firmware v22 or newer and outside param sync mode, where op 93 returns the parameters playing 2273 rather than a set stored for the next sync edge. 2274 """ 2275 op = self._reply_pending 2276 self._reply_pending = None 2277 n_late = len(_common.read_until_quiet(self.port)) 2278 read_back = (op in _PARAMETER_OPS and self.info.firmware_version > 21 2279 and "Param Sync" not in list(self._trigger_mode)[1:]) 2280 _common.warn_lost_reply(f"op {op}", n_late, 2281 ", and the parameters were read back from the device" if read_back else "") 2282 if read_back: 2283 try: 2284 self.sync_from_device() 2285 except PulsePalError as exc: 2286 raise PulsePalError( 2287 f"Pulse Pal did not send its parameters, read back after the reply to an earlier command was " 2288 f"lost. {_common.NO_REPLY_HINT}" 2289 ) from exc 2290 2291 def _read_raw(self, n_bytes): 2292 """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no 2293 longer pending (see _write_raw()). Only connecting reads a reply in more than one call.""" 2294 message_bytes = self.port.read(n_bytes) 2295 if len(message_bytes) < n_bytes: 2296 raise PulsePalError( 2297 f"Pulse Pal sent {len(message_bytes)} of the {n_bytes} byte(s) of its reply within " 2298 f"{self.port.timeout} s. {_common.NO_REPLY_HINT}" 2299 ) 2300 self._reply_pending = None 2301 return message_bytes 2302 2303 def _read_serial(self, n_values, datatype): 2304 """Read values from the serial port and unpack them with struct.""" 2305 fmt = self._STRUCT_FORMATS[datatype] 2306 n_values = int(n_values) 2307 n_bytes = n_values * struct.calcsize(fmt) 2308 message_bytes = self._read_raw(n_bytes) 2309 2310 values = struct.unpack( 2311 f"{self._ENDIANNESS}{n_values}{fmt}", 2312 message_bytes, 2313 ) 2314 if n_values == 1: 2315 return values[0] 2316 return list(values) 2317 2318 def _read_ack(self, context, on_refusal=None, refusal_message=None, refusal_messages=None): 2319 """Read a one-byte acknowledgement from the device. 2320 2321 The device replies 1 if it executed the command, or 0 if it rejected 2322 the command because a value was out of range. On a 0, on_refusal() 2323 is called before the error is raised: the device resets a value it 2324 refuses, so callers use it to read the value back into the local copy. 2325 refusal_message replaces the error's usual message, for a command 2326 refused for another reason. refusal_messages maps other replies to 2327 their own message, e.g. op 90's 2. 2328 """ 2329 try: 2330 acknowledgement = self._read_serial(1, "uint8") 2331 except PulsePalError as exc: 2332 raise PulsePalError( 2333 f"Pulse Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. " 2334 f"{_common.NO_REPLY_HINT}" 2335 ) from exc 2336 if acknowledgement != 1: 2337 refusal = PulsePalError( 2338 (refusal_messages or {}).get(acknowledgement) or 2339 refusal_message or 2340 f"Pulse Pal rejected the command sent by {context}. " 2341 "This usually means that a channel number, parameter code or " 2342 "value was out of range for the connected device." 2343 ) 2344 if on_refusal is not None: 2345 try: 2346 on_refusal() 2347 except PulsePalError as exc: 2348 raise refusal from exc 2349 raise refusal 2350 2351 def _pack_values(self, values, datatype): 2352 """Pack scalar, list/tuple, or NumPy array values into bytes.""" 2353 fmt = self._STRUCT_FORMATS[datatype] 2354 values_list = self._as_list(values) 2355 min_value, max_value = self._TYPE_RANGES[datatype] 2356 normalized = [] 2357 for value in values_list: 2358 value = int(value) 2359 if not min_value <= value <= max_value: 2360 raise PulsePalError( 2361 f"Value {value} is out of range for {datatype} " 2362 f"({min_value} to {max_value})." 2363 ) 2364 normalized.append(value) 2365 return struct.pack(f"{self._ENDIANNESS}{len(normalized)}{fmt}", *normalized) 2366 2367 @staticmethod 2368 def _as_list(values): 2369 """Return values as a flat list, wrapping scalars in one.""" 2370 if isinstance(values, np.ndarray): 2371 return values.ravel().tolist() 2372 if isinstance(values, (bytes, bytearray, str)): 2373 return [values] 2374 if isinstance(values, numbers.Number): 2375 return [values] 2376 try: 2377 return list(values) 2378 except TypeError: 2379 return [values] 2380 2381 def _volts_to_bits(self, value, name="voltage"): 2382 """Convert -10 V to +10 V to the corresponding DAC code. 2383 2384 Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do. 2385 Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would 2386 play a different voltage. 2387 """ 2388 try: 2389 volts = float(value) 2390 except (TypeError, ValueError): 2391 volts = float("nan") 2392 if isinstance(value, (bool, np.bool_, str)) or not -10 <= volts <= 10: # Also refuses NaN 2393 raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.") 2394 return int(round((volts + 10) / 20 * self._DAC_BITMAX)) 2395 2396 def _bits_to_volts(self, value): 2397 """Convert a DAC code to volts, snapping to a clean value within 1 LSB.""" 2398 raw_volts = (float(value) / self._DAC_BITMAX * 20) - 10 2399 lsb_volts = 20.0 / self._DAC_BITMAX 2400 # The nearest 3 decimal number (e.g. 5.000, 4.255), if the code is within 1 LSB of it 2401 clean_volts = round(raw_volts, 3) 2402 if abs(raw_volts - clean_volts) <= lsb_volts: 2403 return clean_volts 2404 return round(raw_volts, 4) 2405 2406 def _seconds_to_cycles(self, value, name="time"): 2407 """Convert seconds to the corresponding refresh-cycle count. 2408 2409 Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do: 2410 125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is 2411 negative, not a number, or longer than DeviceInfo.max_time. 2412 """ 2413 try: 2414 seconds = float(value) 2415 except (TypeError, ValueError): 2416 seconds = float("nan") 2417 if isinstance(value, (bool, np.bool_, str)) or not (math.isfinite(seconds) and seconds >= 0): 2418 raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.") 2419 cycles = int(round(seconds * float(self.info.cycle_frequency))) 2420 if cycles > self._max_time_cycles(): 2421 raise PulsePalError( 2422 f"{name} must be at most {self.info.max_time} s, the longest time the device's screen " 2423 f"shows. Received {value!r}." 2424 ) 2425 return cycles 2426 2427 def _max_time_cycles(self): 2428 """DeviceInfo.max_time in timer cycles: 999999990 on a 10 us timer, 199999998 on a 50 us timer.""" 2429 return int(round(self.info.max_time * float(self.info.cycle_frequency))) 2430 2431 def _custom_time_cycles(self, value, name): 2432 """Convert a custom pulse time or sampling period to timer cycles. 2433 2434 It must be a whole number of DeviceInfo.min_pulse_width_us, to the nearest microsecond, 2435 as in the MATLAB class. A time between two steps is refused, not rounded 2436 as the time parameters are, so that a train plays as it was written. 2437 """ 2438 self._seconds_to_cycles(value, name) # Checks that it is a time the device can play 2439 microseconds = int(round(float(value) * 1e6)) 2440 step_us = int(self.info.min_pulse_width_us) 2441 if microseconds % step_us: 2442 raise PulsePalError( 2443 f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). Received {value!r}." 2444 ) 2445 return microseconds // int(self.info.cycle_period_us) 2446 2447 def _cycles_to_seconds(self, value): 2448 """Convert hardware timer cycle counts to seconds.""" 2449 return float(value) / float(self.info.cycle_frequency) 2450 2451 2452def _apply_method(code): 2453 def apply(self, values): 2454 return self._apply_output_param(code, values) 2455 return apply 2456 2457 2458# One _apply_<name> method per output parameter, which its ChannelSettings list calls 2459for _code, (_name, _kind) in _OUTPUT_PARAMETERS.items(): 2460 setattr(PulsePalDevice, f"_apply_{_name}", _apply_method(_code)) 2461del _code, _name, _kind
399class PulsePalDevice: 400 """A class to control a Pulse Pal device on a USB serial port. 401 402 Creating an instance opens the serial port, exchanges a handshake 403 with the device, verifies that its firmware is supported, reads the 404 device properties into `PulsePalDevice.info`, and programs the 405 device with the default parameters (see 406 `PulsePalDevice.set_default_params`). 407 408 ```python 409 from pulsepal import PulsePalDevice 410 411 P = PulsePalDevice("COM3") 412 P.phase1_voltage[1] = 5 413 P.trigger(1) 414 P.close() 415 ``` 416 417 Here, replace "COM3" with Pulse Pal's USB serial port name. 418 To view a list of available ports, use 419 `PulsePalDevice.serialportlist()`. 420 Pulse Pal's port may not be visible if it is connected to another 421 instance of PulsePalDevice or an external application. Use 422 `PulsePalDevice.serialportlist("all")` to view all ports. 423 If you see multiple available ports, disconnect Pulse Pal's USB plug 424 and run serialportlist() again. Notice which port disappears from the 425 list. 426 427 PulsePalDevice is also a context manager, which closes the connection on 428 exit even if an error is raised: 429 430 ```python 431 with PulsePalDevice("COM3") as P: 432 P.trigger(1) 433 ``` 434 435 The attributes below named after Pulse Pal parameters are lists 436 indexed by channel number, with index 0 unused (see "Parameters" 437 above). `print(P)` shows them all, and `PulsePalDevice.export_params` 438 returns them as a dict. 439 """ 440 441 port: "serial.Serial" 442 """The open `serial.Serial` port connected to the device.""" 443 444 info: DeviceInfo 445 """Properties of the connected device. See `DeviceInfo`.""" 446 447 is_biphasic = _channel_setting("is_biphasic", """Pulse shape per channel: `False` for monophasic, `True` for biphasic. 448 449 Monophasic pulses use only the phase 1 parameters. Biphasic pulses 450 follow phase 1 with `PulsePalDevice.inter_phase_interval` and then 451 phase 2. 452 """) 453 454 phase1_voltage = _channel_setting("phase1_voltage", """Voltage of the first phase of each pulse, in volts [-10, 10].""") 455 456 phase2_voltage = _channel_setting("phase2_voltage", """Voltage of the second phase of each pulse, in volts [-10, 10]. 457 458 Used only when `PulsePalDevice.is_biphasic` is `True` for the channel. 459 """) 460 461 resting_voltage = _channel_setting("resting_voltage", """Voltage held between pulses, in volts [-10, 10]. 462 463 A new resting voltage reaches an idle channel's output at once. A 464 channel playing a pulse train keeps playing it, and moves to the new 465 resting voltage at its next transition to rest. 466 """) 467 468 phase1_duration = _channel_setting("phase1_duration", """Duration of the first phase of each pulse, in seconds. 469 470 At least `DeviceInfo.min_pulse_width_us`. 471 """) 472 473 inter_phase_interval = _channel_setting("inter_phase_interval", """Interval between the two phases of a biphasic pulse, in seconds. 474 475 The channel rests at `PulsePalDevice.resting_voltage` during the 476 interval. Used only when `PulsePalDevice.is_biphasic` is `True`. 477 """) 478 479 phase2_duration = _channel_setting("phase2_duration", """Duration of the second phase of each pulse, in seconds. 480 481 Used only when `PulsePalDevice.is_biphasic` is `True` for the channel. 482 At least `DeviceInfo.min_pulse_width_us`. 483 """) 484 485 inter_pulse_interval = _channel_setting("inter_pulse_interval", """Interval from the end of one pulse to the start of the next, in 486 seconds. At least `DeviceInfo.min_pulse_width_us`. 487 488 It is not the pulse period: 1 ms pulses 49 ms apart play 20 pulses per 489 second. 490 """) 491 492 burst_duration = _channel_setting("burst_duration", """Duration of each burst of pulses, in seconds. 493 494 Set to `0` to disable bursts, so that pulses continue for the whole 495 pulse train. A pulse starts only if it ends before the burst does: 496 its first phase, or for a biphasic pulse the whole pulse, so that 497 the end of a burst never cuts off a second phase. 498 """) 499 500 inter_burst_interval = _channel_setting("inter_burst_interval", """Interval between bursts of pulses, in seconds. 501 502 The channel rests at `PulsePalDevice.resting_voltage` between 503 bursts. Ignored when `PulsePalDevice.burst_duration` is `0`. 504 """) 505 506 pulse_train_duration = _channel_setting("pulse_train_duration", """Total duration of the pulse train, in seconds. 507 508 At least `DeviceInfo.min_pulse_width_us`. The end of the train cuts 509 short a monophasic pulse still playing. A biphasic pulse starts only 510 if it can end by the end of the train, so that it keeps its second 511 phase. A train's end is set when it starts: a new duration applies 512 from the next train. 513 """) 514 515 pulse_train_delay = _channel_setting("pulse_train_delay", """Delay from the trigger to the onset of the pulse train, in 516 seconds.""") 517 518 link_trigger_channel1 = _channel_setting("link_trigger_channel1", """Whether each output channel is triggered by trigger channel 1. 519 520 `True` links the output channel to trigger channel 1, `False` unlinks 521 it. By default no output channel is linked to either trigger channel, 522 so a TTL starts nothing until a script links it. 523 """) 524 525 link_trigger_channel2 = _channel_setting("link_trigger_channel2", """Whether each output channel is triggered by trigger channel 2. 526 527 `True` links the output channel to trigger channel 2, `False` unlinks 528 it. 529 """) 530 531 custom_train_id = _channel_setting("custom_train_id", """Custom pulse train played by each output channel. 532 533 `0` plays the parametrically defined train. `1` or higher plays the 534 matching custom train, previously loaded with 535 `PulsePalDevice.send_custom_pulse_train` or 536 `PulsePalDevice.send_custom_waveform`: 1-4 on Pulse Pal 3, 1-2 on 537 Pulse Pal 2 (`DeviceInfo.n_custom_pulse_trains`). 538 """) 539 540 custom_train_target = _channel_setting("custom_train_target", """What the times of a custom train mark. 541 542 `"Pulses"` if each time is the onset of a pulse, `"Bursts"` if each 543 time is the onset of a burst of pulses, which lasts 544 `PulsePalDevice.burst_duration`. A burst whose time comes while the 545 burst before plays is dropped, so that every burst played is on 546 time. `"Bursts"` needs a burst duration above 0: with 547 `PulsePalDevice.auto_sync` on, set the burst duration first, or set 548 both in a `PulsePalDevice.batch` block. 549 """) 550 551 custom_train_loop = _channel_setting("custom_train_loop", """Whether a custom train repeats. 552 553 `True` loops the custom train until 554 `PulsePalDevice.pulse_train_duration` has elapsed, `False` plays it 555 once. Each pass starts as the last pulse (or burst, for a train 556 targeting bursts) of the pass before ends. 557 `PulsePalDevice.continuous_loop` does not apply to custom trains. 558 """) 559 560 continuous_loop = _channel_setting("continuous_loop", """Whether each output channel plays its pulse train until stopped. 561 562 `False` plays the pulse train once per trigger, for 563 `PulsePalDevice.pulse_train_duration`. `True` plays it until the 564 channel is stopped, ignoring `PulsePalDevice.pulse_train_duration`. 565 For parametric trains only: a custom train ignores it, and plays as 566 `PulsePalDevice.custom_train_loop` says. Requires firmware v22 or 567 newer. The device does not report it, so 568 `PulsePalDevice.sync_from_device` leaves it as it is. 569 """) 570 571 trigger_mode = _channel_setting("trigger_mode", """Response of each trigger channel to an incoming TTL pulse. 572 573 Indexed by trigger channel number, 1 or 2 (index 0 is unused). The 574 modes, also listed in `DeviceInfo.trigger_modes`, are: 575 576 - `"Normal"`: a rising edge starts the pulse trains of the linked 577 output channels, and edges during a train are ignored. 578 - `"Toggle"`: as `"Normal"`, but a rising edge during a train stops 579 it. 580 - `"Gated"`: the trains play only while the trigger TTL is high. 581 - `"Param Sync"` (Pulse Pal 3 only): a TTL rising edge starts and 582 stops nothing. It loads the parameters most recently sent by 583 `PulsePalDevice.sync_to_device` (or at the end of a 584 `PulsePalDevice.batch` block), and the custom trains loaded since 585 the previous edge. This is how the next trial's parameters and 586 custom trains are sent during the current trial and applied the 587 instant it starts. 588 589 The integer codes 0-3 are accepted too. 590 591 An output channel that is idle at a param sync edge takes its new 592 parameters in the timer cycle the edge is detected. One that is 593 playing a pulse train finishes that train on the parameters it 594 started with, and takes the new ones the moment it ends, so a train 595 that runs past the end of a trial keeps one shape throughout and the 596 next trigger plays a whole train with the new parameters. A channel 597 in continuous loop mode has no train end, so it keeps its parameters 598 until something stops it. 599 600 While either trigger channel is in param sync mode, **only 601 `PulsePalDevice.sync_to_device` (and the end of a 602 `PulsePalDevice.batch` block) and the custom train loads are held 603 back**: with `PulsePalDevice.auto_sync` on, every assignment still 604 programs the device immediately. So leaving param sync mode means 605 assigning `trigger_mode` with `auto_sync` on; a trigger mode sent by 606 `PulsePalDevice.sync_to_device` does not take effect until a sync 607 edge arrives. A custom train loaded in param sync mode 608 (`PulsePalDevice.send_custom_pulse_train`) replaces the old one at 609 the edge: channels playing the old one at the edge finish their 610 trains on it. Leaving param sync mode discards parameters still 611 waiting for an edge, but makes a custom train still waiting current 612 at once. 613 614 A param sync channel's links to output channels are ignored. To 615 start a pulse train on the same edge, wire the TTL to the other 616 trigger channel as well: both edges arrive in the same timer cycle, 617 and the parameters are loaded first. 618 619 Connecting a new `PulsePalDevice`, and 620 `PulsePalDevice.set_default_params`, take both trigger channels out 621 of param sync mode, so that the default parameters reach the device 622 instead of waiting for a TTL. 623 624 ```python 625 P.trigger_mode[2] = "Param Sync" # sent at once 626 with P.batch(): 627 P.phase1_voltage[1] = 2.5 628 P.phase1_duration[1] = 0.002 629 # Stored by the device: the next rising edge on trigger channel 2 applies it 630 ``` 631 """) 632 633 _CURRENT_FIRMWARE_VERSION = 22 634 _OLDEST_FIRMWARE_SUPPORTED = 21 635 _BAUD_RATE = 12000000 # USB serial ignores the baud rate 636 637 _OP_MENU_BYTE = _common.OP_MENU_BYTE 638 _HANDSHAKE_OPCODE = _common.OP_HANDSHAKE 639 _HANDSHAKE_DEVICE_CYCLES_OPCODE = _common.OP_HANDSHAKE_DEVICE_CYCLES 640 _HANDSHAKE_DEVICE_CYCLES_TIMEOUT = 0.3 # Seconds to wait for a reply to op 103 before trying op 72 641 _HANDSHAKE_RESPONSE = 75 # 'K' 642 _DAC_BITMAX = 65535 643 _PARAM_MESSAGE_BYTES = 178 # Length of the parameter set sent by op 93 644 _STATUS_MESSAGE_BYTES = 29 # Length of the reply to op 101: playing channels, then 7 uint32 counters 645 # Phase 1 and 2 durations, inter-pulse interval and train duration last at least this many 646 # timer cycles, by hardware version, as in the MATLAB and C++ classes and the joystick menu. 647 # Pulse Pal 2's trigger channels read their inputs once per cycle, so a one cycle pulse from 648 # another Pulse Pal could fall between two reads. Pulse Pal 3's also latch every edge. 649 _MIN_PULSE_CYCLES = {2: 2, 3: 1} 650 651 _ENDIANNESS = "<" 652 _STRUCT_FORMATS = { 653 "uint8": "B", 654 "int8": "b", 655 "uint16": "H", 656 "int16": "h", 657 "uint32": "I", 658 "int32": "i", 659 } 660 _TYPE_RANGES = { 661 "uint8": (0, 2**8 - 1), 662 "int8": (-(2**7), 2**7 - 1), 663 "uint16": (0, 2**16 - 1), 664 "int16": (-(2**15), 2**15 - 1), 665 "uint32": (0, 2**32 - 1), 666 "int32": (-(2**31), 2**31 - 1), 667 } 668 669 _reply_pending = None # The op code of a command whose reply has not been read in full yet (see _write_raw()) 670 _unsent = frozenset() # The settings assigned with auto_sync off since the parameters were last sent or read 671 672 def __init__(self, port_name=None, *, serial_number=None, timeout=10): 673 """Open a connection to a Pulse Pal device. 674 675 Opens the serial port, exchanges the handshake, verifies the 676 firmware version, reads the device properties into 677 `PulsePalDevice.info`, and programs the device with the default 678 parameters. 679 680 ```python 681 P = PulsePalDevice("COM3") 682 P = PulsePalDevice(serial_number="20345420") # Wherever it is plugged in 683 ``` 684 685 Args: 686 port_name: USB serial port for the Pulse Pal device, such as 687 `COM3` on Windows or `/dev/ttyACM0` on Linux. 688 serial_number: Instead of `port_name`, the device's USB serial 689 number (`DeviceInfo.serial_number`), which finds its port. 690 Pulse Pal 3 only: Pulse Pal 2 has none. 691 timeout: Serial read timeout, in seconds. 692 693 Firmware newer than this module knows (`v22`) is used with a 694 warning: new firmware only adds commands, so everything here still 695 works with it. Update the package to use what is new. 696 697 Raises: 698 PulsePalError: If the device does not return the expected 699 handshake (for example, it runs Wave Pal or Synth Pal 700 firmware), or its firmware is older than v21, or no port 701 has the serial number given. The port is closed again 702 before any error is raised. 703 serial.SerialException: If the serial port cannot be opened, 704 e.g. because another program has it open (on Linux and 705 macOS, another session of these classes, which lock it). 706 """ 707 self._closed = True 708 self._gui = None 709 self._auto_sync = True 710 self.info = DeviceInfo() 711 self._create_settings() 712 port_name = _common.port_name_from(port_name, serial_number, "PulsePalDevice") 713 self.port = serial.Serial( 714 port_name, 715 self._BAUD_RATE, 716 timeout=timeout, 717 rtscts=True, 718 # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two 719 # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks 720 # it, such as another of these classes, gets an error instead 721 exclusive=True, 722 ) 723 self._closed = False 724 try: 725 self._start_session(port_name) 726 except BaseException: 727 # Otherwise the port stays open until the object is garbage 728 # collected, and a second attempt, or WavePalDevice, cannot open 729 # it. The device is in an unknown state, so it is not sent the 730 # disconnect op. 731 self._close(send_disconnect=False) 732 raise 733 734 def _create_settings(self): 735 """Create the parameter lists, holding the default values (nothing is sent).""" 736 for code, (name, _) in _OUTPUT_PARAMETERS.items(): 737 setattr(self, "_" + name, ChannelSettings( 738 name, [_DEFAULT_OUTPUT_VALUES[name]] * 4, self, f"_apply_{name}")) 739 self._trigger_mode = ChannelSettings( 740 "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode") 741 742 def _start_session(self, port_name): 743 """Handshake, check the firmware, and program the defaults.""" 744 # Discard anything left unread by an earlier session (one stopped mid-reply, for example): 745 # it would be read as the handshake's reply. A reply the device sends after this, to a 746 # command it had not reached yet, is skipped by read_handshake_reply(). 747 self.port.reset_input_buffer() 748 # Op 103: the session's times are in the device's own timer cycles (10 us on Pulse Pal 3). Firmware from before 749 # October 2026, Wave Pal and Synth Pal ignore it, and answer op 72, whose times are in 50 us cycles. A device that 750 # answers op 103 too late counts the session's times in 50 us cycles too, after the op 72 that follows; either 751 # way op 94 reports the session's unit, which the class then uses. 752 self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_DEVICE_CYCLES_OPCODE), "uint8") 753 reply = _common.read_handshake_reply(self.port, timeout=self._HANDSHAKE_DEVICE_CYCLES_TIMEOUT) 754 if reply is None: 755 self._write_serial((self._OP_MENU_BYTE, self._HANDSHAKE_OPCODE), "uint8") 756 reply = _common.read_handshake_reply(self.port) 757 if reply is None: 758 raise PulsePalError( 759 f"No reply to the handshake from the device on {port_name}. Is it a Pulse Pal? " 760 f"{_common.NO_REPLY_HINT}" 761 ) 762 handshake, firmware_version = reply 763 if handshake != self._HANDSHAKE_RESPONSE: 764 if handshake in _common.FIRMWARE_BY_HANDSHAKE_REPLY: 765 raise _common.other_firmware_error(handshake, firmware_version, port_name, "Pulse Pal") 766 raise PulsePalError( 767 "Incorrect handshake returned. Expected " 768 f"{self._HANDSHAKE_RESPONSE}, received {handshake}." 769 ) 770 771 if firmware_version < self._OLDEST_FIRMWARE_SUPPORTED: 772 raise PulsePalError( 773 "Old firmware detected, v" 774 f"{firmware_version}. v{self._OLDEST_FIRMWARE_SUPPORTED} or " 775 "newer is required." 776 ) 777 if firmware_version > self._CURRENT_FIRMWARE_VERSION: 778 # New firmware only adds commands (see /Firmware/PROTOCOL.md), so this class still works with it 779 warnings.warn( 780 f"Pulse Pal firmware v{firmware_version} is newer than this pulsepal package knows " 781 f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what " 782 "is new.", stacklevel=3) 783 if firmware_version < self._CURRENT_FIRMWARE_VERSION: 784 warnings.warn( 785 f"Pulse Pal firmware v{firmware_version} is supported, but v" 786 f"{self._CURRENT_FIRMWARE_VERSION} is available: see /MATLAB/FirmwareLoader.", stacklevel=3) 787 self.info.firmware_version = firmware_version 788 789 if self.info.firmware_version > 21: 790 self._write_serial((self._OP_MENU_BYTE, 94), "uint8") 791 self.info.hardware_version = self._read_serial(1, "uint8") 792 self.info.cycle_period_us = self._read_serial(1, "uint32") # The session's time unit (see op 103 above) 793 # Not 1 / (period / 1e6): at 10 us that is 99999.99999999999, and times read back from the device would 794 # convert to 0.00010000000000000002 s instead of 0.0001 s 795 self.info.cycle_frequency = 1000000 / self.info.cycle_period_us 796 self.info.n_custom_pulse_trains = self._read_serial(1, "uint8") 797 self.info.max_custom_pulses = self._read_serial(1, "uint32") 798 self._write_serial((self._OP_MENU_BYTE, 100), "uint8") # Circuit board revision 799 revision = self.port.read(1) 800 if len(revision) < 1: 801 raise PulsePalError( 802 f"The Pulse Pal on {port_name} did not report its circuit board revision." 803 ) 804 self._reply_pending = None # Read without _read_raw() (see _write_raw()) 805 self.info.circuit_board_revision = revision[0] 806 self._write_serial((self._OP_MENU_BYTE, _common.OP_SERIAL_NUMBER), "uint8") 807 self.info.serial_number = _common.read_serial_number(self._read_raw) 808 else: 809 self.info.hardware_version = 2 810 self.info.circuit_board_revision = 0 811 self.info.serial_number = "" # v21 runs on Pulse Pal 2, which has none 812 self.info.cycle_period_us = 50 813 self.info.cycle_frequency = 20000 814 self.info.n_custom_pulse_trains = 2 815 self.info.max_custom_pulses = 5000 816 self.info.min_pulse_width_us = ( 817 self._MIN_PULSE_CYCLES[self.info.hardware_version] * self.info.cycle_period_us 818 ) 819 if not self._has_param_sync(): 820 self.info.trigger_modes = TRIGGER_MODES[:3] 821 822 # Client name op + "PYTHON" in ASCII. 823 self._write_serial( 824 (self._OP_MENU_BYTE, 89, 80, 89, 84, 72, 79, 78), 825 "uint8", 826 ) 827 828 self.set_default_params() 829 830 @staticmethod 831 def serialportlist(ports_to_list="available"): 832 """Return the names of the USB serial ports on this computer. 833 834 Called on the class, without connecting to a device, to find the 835 port name to pass to `PulsePalDevice`: 836 837 ```python 838 from pulsepal import PulsePalDevice 839 840 ports = PulsePalDevice.serialportlist() 841 P = PulsePalDevice(ports[0]) 842 ``` 843 844 Args: 845 ports_to_list: `available` to list only the ports that are 846 not already in use, or `all` to list every USB serial 847 port. Not case sensitive. 848 849 Returns: 850 Sorted list of port names, such as `["COM3", "COM7"]` on 851 Windows or `["/dev/ttyACM0"]` on Linux. 852 853 Raises: 854 PulsePalError: If `ports_to_list` is not `available` or 855 `all`. 856 """ 857 return _common.serialportlist(ports_to_list) 858 859 # ------------------------------------------------------------------ 860 # Programming the device 861 # ------------------------------------------------------------------ 862 863 @property 864 def auto_sync(self): 865 """Whether assigning a parameter programs the device at once. 866 867 `True` (the default): each assignment, such as 868 `P.phase1_voltage[1] = 5`, programs the device at once, also while 869 a trigger channel is in param sync mode. It sends the channels 870 whose value it changes, and nothing for a channel that already 871 holds the value assigned: the other channels keep what the device 872 holds, e.g. values set with its joystick (read them with 873 `PulsePalDevice.sync_from_device`). 874 875 `False`: assignments change only this object's copy of the 876 parameters, checking each value, and 877 `PulsePalDevice.sync_to_device` sends all of them in one command. 878 Turning it back on does not send changes made meanwhile, so it 879 raises `pulsepal.PulsePalError` while there are any: call 880 `PulsePalDevice.sync_to_device` first to send them, or 881 `PulsePalDevice.sync_from_device` to discard them. A 882 `PulsePalDevice.batch` block does it all for you. `close()` warns of 883 changes never sent. 884 """ 885 return self._auto_sync 886 887 @auto_sync.setter 888 def auto_sync(self, value): 889 value = to_bool(value, "auto_sync") 890 if value and self._unsent: 891 # Turned on, auto_sync would leave them unsent, and the device playing its old parameters 892 discard = "sync_from_device() to discard them" 893 if "continuous_loop" in self._unsent: 894 discard += " (all but continuous_loop, which the device does not report)" 895 raise PulsePalError( 896 f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call " 897 f"sync_to_device() to send them, or {discard}, before turning auto_sync on." 898 ) 899 self._auto_sync = value 900 901 def batch(self): 902 """Change several parameters, and send them in one command. 903 904 Returns a context manager. In its block, assignments change only 905 this object's copy of the parameters (as with 906 `PulsePalDevice.auto_sync` off), and when the block ends, 907 `PulsePalDevice.sync_to_device` sends all of them at once. If the 908 block raises an error, nothing is sent, and the parameters go 909 back to what they were. 910 911 ```python 912 with P.batch(): 913 P.phase1_voltage = [5, 5, 2.5, 2.5] 914 P.phase1_duration[3] = 0.002 915 ``` 916 917 In param sync mode, the device stores the parameters for the next 918 rising edge on the param sync channel (see 919 `PulsePalDevice.trigger_mode`). 920 """ 921 return _common.batch(self) 922 923 def sync_to_device(self): 924 """Program the device with this object's copy of all parameters. 925 926 Sends every output and trigger parameter to the device in a single 927 command. Use it after assignments made with 928 `PulsePalDevice.auto_sync` off; with it on, they have already 929 reached the device. 930 931 ```python 932 P.auto_sync = False 933 P.phase1_voltage[1:5] = [5] * 4 934 P.sync_to_device() 935 ``` 936 937 On Pulse Pal 3, if either trigger channel is in param sync mode 938 (`PulsePalDevice.trigger_mode` `"Param Sync"`), the device stores 939 the parameters instead of programming them, and loads them on the 940 next rising edge of that channel. The device still checks every 941 value. This is the only method whose effect is deferred that way. 942 943 Raises: 944 PulsePalError: If a value is out of range for its parameter 945 (nothing is sent), or the device does not acknowledge the 946 command. 947 """ 948 self._check_sync() 949 self._send_sync() 950 951 def sync_from_device(self): 952 """Read all parameters from the device into this object's copy. 953 954 Overwrites the parameter lists with the program currently on the 955 device, e.g. after it has been reprogrammed from its thumb 956 joystick. `PulsePalDevice.continuous_loop` is left as it is: the 957 device does not report it. Changes made with 958 `PulsePalDevice.auto_sync` off and not yet sent are discarded, but 959 for `PulsePalDevice.continuous_loop`. 960 961 Requires firmware v22 or newer. 962 963 Raises: 964 PulsePalError: If the connected firmware is older than v22, 965 or the device does not return the full parameter set. 966 """ 967 self._require_firmware(22, "sync_from_device()") 968 for name, values in self._read_device_params().items(): 969 getattr(self, name)._store(values) 970 self._unsent = self._unsent & {"continuous_loop"} # Not read: a change to it is still unsent 971 972 def set_default_params(self): 973 """Program the device with the default parameters. 974 975 The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart 976 (about 909 a second), for 1 second, resting at 0 V, on all four 977 output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms 978 after 0.1 ms at rest. No output channel is linked to a trigger 979 channel, so a TTL starts nothing until a script links it, and both 980 trigger channels are in normal mode. They are the device's own 981 defaults, which it also loads at startup, after a comm failure 982 and after formatting its microSD card. They are sent at once, 983 also while `PulsePalDevice.auto_sync` is off. The constructor 984 calls it. 985 """ 986 for name, value in _DEFAULT_OUTPUT_VALUES.items(): 987 getattr(self, name)._store([value] * 4) 988 self._trigger_mode._store(["Normal"] * 2) 989 if self._has_param_sync(): 990 # A device left in param sync mode would store the sync below instead of running it, 991 # leaving the device on its old program until a TTL arrived. A trigger mode sent on 992 # its own is not deferred that way, so both trigger channels leave param sync mode 993 # first. See PulsePalDevice.trigger_mode. 994 self._send_trigger_modes(["Normal"] * 2, "set_default_params()") 995 self._send_sync() 996 997 def export_params(self): 998 """Return every parameter, as a dict of plain lists. 999 1000 Keyed by parameter name (`DeviceInfo.output_parameter_names`, then 1001 `"trigger_mode"`), with one value per channel and no unused index 1002 0. It holds only numbers, booleans and names, so it can be saved 1003 with `json` and logged with your data, to record exactly what the 1004 device played. `PulsePalDevice.import_params` programs it again. 1005 1006 ```python 1007 import json 1008 with open("trial_params.json", "w") as f: 1009 json.dump(P.export_params(), f) 1010 ``` 1011 """ 1012 names = [*self.info.output_parameter_names, "trigger_mode"] 1013 return {name: list(getattr(self, name))[1:] for name in names} 1014 1015 def import_params(self, params): 1016 """Program the device with parameters exported by `PulsePalDevice.export_params`. 1017 1018 All of them are checked first, and then sent in one command, as 1019 at the end of a `PulsePalDevice.batch` block: in param sync mode, 1020 the device stores them for the next sync edge. Parameters missing 1021 from `params` keep their values. A name that is not a parameter 1022 is skipped with a warning, so that parameters exported by a newer 1023 version of this package, which may have more, can still be 1024 imported; if none is, an error is raised. 1025 1026 A whole program file, as the GUIs save it, is imported too: its 1027 parameters, not its custom trains (a warning says so, if it has 1028 any). 1029 1030 ```python 1031 with open("MyProgram.json") as f: 1032 P.import_params(json.load(f)) 1033 ``` 1034 1035 Args: 1036 params: A dict of parameter name to one value per channel (a 1037 list, tuple or NumPy array), as `export_params` returns, 1038 or a program file's contents. The MATLAB class's names 1039 (`phase1Voltage`) are accepted too, so that the parameters 1040 its `exportParams()` returns, saved with `jsonencode`, can 1041 be imported. 1042 1043 Raises: 1044 PulsePalError: If a value is invalid, or no name in `params` is 1045 a parameter. Nothing is sent, and the parameters keep the 1046 values they had. 1047 """ 1048 params = _params_to_import(params) 1049 with self.batch(): 1050 for name, values in params.items(): 1051 setattr(self, name, values) 1052 1053 def set_fixed_voltage(self, channels, voltage): 1054 """Set output channels to a fixed voltage. 1055 1056 Each channel holds the voltage until it is set again or until a 1057 pulse train is triggered on it. 1058 1059 ```python 1060 P.set_fixed_voltage(4, 2.5) 1061 P.set_fixed_voltage([1, 3], -1) 1062 ``` 1063 1064 Args: 1065 channels: Output channel number, 1-4, or several as a list, 1066 tuple or NumPy array. 1067 voltage: Voltage to set on each of them, in volts [-10, 10]. 1068 1069 Raises: 1070 PulsePalError: If a channel number is not 1-4, the voltage is 1071 outside [-10, 10] (nothing is sent), or the device does not 1072 acknowledge the command. 1073 """ 1074 channels = _common.channel_numbers(channels) 1075 voltage_bits = self._volts_to_bits(voltage, "voltage") 1076 for channel in channels: 1077 self._write_serial( 1078 (self._OP_MENU_BYTE, 79, channel), 1079 "uint8", 1080 voltage_bits, 1081 "uint16", 1082 ) 1083 self._read_ack("set_fixed_voltage()") 1084 1085 def set_calibration(self, channel, voltage_offset): 1086 """Calibrate the zero code of an output channel. 1087 1088 The offset is added to every voltage the channel produces, to 1089 correct for DAC offset error. It is stored in the device's 1090 EEPROM and reloaded on boot, so it only needs to be set once. 1091 1092 Requires Pulse Pal hardware v3 or newer. 1093 1094 Args: 1095 channel: Output channel number, 1-4. 1096 voltage_offset: Offset to apply, in volts [-0.1, 0.1]. 1097 1098 Raises: 1099 PulsePalError: If the connected hardware is older than v3, 1100 `channel` is not 1-4, `voltage_offset` is outside 1101 [-0.1, 0.1], or the device does not acknowledge the 1102 command. 1103 """ 1104 if self.info.hardware_version < 3: 1105 raise PulsePalError( 1106 "set_calibration() requires hardware v3 or newer." 1107 ) 1108 if not (isinstance(channel, numbers.Integral) and channel in (1, 2, 3, 4)): 1109 raise PulsePalError("channel must be 1, 2, 3 or 4") 1110 if not (isinstance(voltage_offset, numbers.Real) and -0.1 <= voltage_offset <= 0.1): # Also refuses NaN 1111 raise PulsePalError( 1112 "voltage_offset for zero code calibration must be in range " 1113 "[-0.1, 0.1]" 1114 ) 1115 # To the nearest DAC code, halfway values to the even one, as the MATLAB class rounds it 1116 voltage_bits = int(round(voltage_offset * (1 / (20 / 65536)))) 1117 self._write_serial( 1118 (self._OP_MENU_BYTE, 96, channel - 1), 1119 "uint8", 1120 voltage_bits, 1121 "int16", 1122 ) 1123 self._read_ack("set_calibration()") 1124 1125 def set_screen_saver(self, enabled, timeout=1800): 1126 """Switch the device's screen saver on or off, and set its timeout. 1127 1128 With the screen saver on, the device dims its screen once it has 1129 been left alone for `timeout` seconds: no command from the 1130 computer, no rising edge on a trigger channel, and no joystick 1131 click or push. The next of these brings the screen back. Both 1132 settings are stored in the device's EEPROM and kept through power 1133 cycles; the screen saver can also be switched on and off from the 1134 device's joystick menu. A new device has it on, with 1800 s. 1135 1136 The device saves the settings once no channel is playing. Saving 1137 pauses its timer, usually for about 20 us but, once in about 2000 1138 changes, for tens of milliseconds, which would delay a trigger. 1139 Change the settings before an experiment rather than during one. 1140 1141 Requires firmware v22 or newer. Only Pulse Pal 3 has a screen 1142 saver: Pulse Pal 2 accepts `enabled=False` only. 1143 1144 ```python 1145 P.set_screen_saver(True, 300) # dim after 5 minutes 1146 P.set_screen_saver(False) 1147 ``` 1148 1149 Args: 1150 enabled: True (or 1) to switch the screen saver on, False (or 1151 0) to switch it off. 1152 timeout: Seconds without activity before the screen dims, a 1153 whole number from 1 to 65535. It is sent with every call, 1154 so leaving it out sets 1800 s. 1155 1156 Raises: 1157 PulsePalError: If the firmware is older than v22, the 1158 screen saver is switched on with hardware older than v3, 1159 a value is out of range, or the device does not 1160 acknowledge the command. 1161 """ 1162 self._require_firmware(22, "set_screen_saver()") 1163 enabled = to_bool(enabled, "enabled") 1164 if not ( 1165 isinstance(timeout, numbers.Real) 1166 and not isinstance(timeout, bool) 1167 and math.isfinite(timeout) 1168 and timeout == int(timeout) 1169 and 1 <= timeout <= 65535 1170 ): 1171 raise PulsePalError( 1172 "timeout must be a whole number of seconds from 1 to 65535" 1173 ) 1174 if enabled and (self.info.hardware_version or 2) < 3: 1175 raise PulsePalError( 1176 "The screen saver requires hardware v3 or newer: Pulse Pal 2 " 1177 "accepts set_screen_saver(False) only." 1178 ) 1179 self._write_serial( 1180 (self._OP_MENU_BYTE, 99, int(enabled)), 1181 "uint8", 1182 int(timeout), 1183 "uint16", 1184 ) 1185 self._read_ack("set_screen_saver()") 1186 1187 # ------------------------------------------------------------------ 1188 # Custom trains, playback and settings files 1189 # ------------------------------------------------------------------ 1190 1191 def send_custom_pulse_train( 1192 self, 1193 custom_train_id, 1194 pulse_times, 1195 voltages, 1196 ): 1197 """Load a custom pulse train onto the device. 1198 1199 A custom pulse train is an arbitrary list of pulse onset times 1200 and voltages, replacing the parametric pulse voltage and onset 1201 timing. Set `PulsePalDevice.custom_train_id` on an output channel 1202 to play the train there. Each pulse takes the channel's own phase 1203 durations; a biphasic pulse's second phase is its voltage with the 1204 sign reversed. A biphasic pulse always plays whole, and a pulse 1205 whose time comes while it plays is dropped, so that every pulse 1206 played is on time. 1207 1208 ```python 1209 P.send_custom_pulse_train( 1210 2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10] 1211 ) 1212 P.custom_train_id[1] = 2 1213 ``` 1214 1215 Loading a train stops the output channels playing it, and their 1216 next trigger plays the new train. On Pulse Pal 3, while a trigger 1217 channel is in param sync mode (see `PulsePalDevice.trigger_mode`), 1218 the device stores the train instead, as it stores the parameters 1219 `PulsePalDevice.sync_to_device` sends: channels go on playing the 1220 old train, and the next rising edge on the param sync channel 1221 makes the new one current. A channel playing the old train at that 1222 edge finishes it, and plays the new one from its next trigger. So 1223 the next trial's custom trains can be loaded during the current 1224 trial. Leaving param sync mode makes a stored train current at 1225 once. 1226 1227 Args: 1228 custom_train_id: Custom train to load, from 1 to 1229 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1230 4 on Pulse Pal 3). 1231 pulse_times: Pulse onset times, in seconds, relative to the 1232 start of the train, increasing. Each is a multiple of 1233 `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 1234 100 us on Pulse Pal 2), from 0 to 1235 `MAX_TIME`. Accepts a list, tuple or 1-D NumPy array. 1236 voltages: Voltage of each pulse, in volts [-10, 10]. Must be 1237 the same length as `pulse_times`. 1238 1239 Raises: 1240 PulsePalError: If `custom_train_id` is out of range, 1241 `pulse_times` or `voltages` is not one-dimensional (a 2-D 1242 array is refused, not flattened), they differ in length, 1243 there are no pulses, or more than 1244 `DeviceInfo.max_custom_pulses`, a pulse time is not a 1245 multiple of `DeviceInfo.min_pulse_width_us`, is out of 1246 range or is not later than the one before it, or the 1247 device does not acknowledge the command. 1248 """ 1249 pulse_times_cycles = self._custom_times_to_cycles(pulse_times, "pulse_times") 1250 pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages") 1251 if len(pulse_times_cycles) != len(pulse_voltage_bits): 1252 raise PulsePalError( 1253 "pulse_times and voltages must be the same length." 1254 ) 1255 self._send_custom_train( 1256 custom_train_id, 1257 pulse_times_cycles, 1258 pulse_voltage_bits, 1259 "send_custom_pulse_train()", 1260 ) 1261 1262 def send_custom_waveform( 1263 self, 1264 custom_train_id, 1265 sampling_period, 1266 voltages, 1267 ): 1268 """Load an arbitrary waveform onto the device. 1269 1270 A convenience shorthand for 1271 `PulsePalDevice.send_custom_pulse_train` with evenly spaced, 1272 adjoining pulses, so that `voltages` is played as a waveform 1273 sampled every `sampling_period` seconds. It is loaded as a custom 1274 train is: see `PulsePalDevice.send_custom_pulse_train` for what 1275 happens to channels playing it, and in param sync mode. 1276 1277 Set the channel's `PulsePalDevice.phase1_duration` to 1278 `sampling_period` as well, so that each sample is held for the 1279 sampling period. 1280 1281 ```python 1282 import math 1283 1284 samples = [math.sin(i / 10.0) * 10 for i in range(1000)] 1285 P.send_custom_waveform(1, 0.001, samples) # 1 kHz 1286 P.custom_train_id[2] = 1 1287 P.phase1_duration[2] = 0.001 1288 ``` 1289 1290 Args: 1291 custom_train_id: Custom train to load, from 1 to 1292 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1293 4 on Pulse Pal 3). 1294 sampling_period: In seconds: a multiple of 1295 `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 1296 100 us on Pulse Pal 2). Each voltage is 1297 held for this long. 1298 voltages: Waveform samples, in volts [-10, 10]. 1299 Accepts a list, tuple or 1-D NumPy array. 1300 1301 Raises: 1302 PulsePalError: If `custom_train_id` is out of range, there 1303 are no samples, or more than 1304 `DeviceInfo.max_custom_pulses`, `sampling_period` is not a 1305 positive multiple of `DeviceInfo.min_pulse_width_us`, the 1306 last sample would start after `MAX_TIME`, or the device 1307 does not acknowledge the command. 1308 """ 1309 pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages") 1310 period_cycles = self._custom_time_cycles(sampling_period, "sampling_period") 1311 if period_cycles == 0: 1312 raise PulsePalError( 1313 f"sampling_period must be a positive multiple of {self.info.min_pulse_width_us:g} us. " 1314 f"Received {sampling_period!r}." 1315 ) 1316 pulse_times = period_cycles * np.arange(len(pulse_voltage_bits), dtype=np.int64) 1317 self._send_custom_train( 1318 custom_train_id, 1319 pulse_times, 1320 pulse_voltage_bits, 1321 "send_custom_waveform()", 1322 ) 1323 1324 def trigger(self, channels): 1325 """Trigger output channels in software. 1326 1327 The channels start their pulse trains together, in the same timer 1328 cycle. A channel that is already playing a pulse train ignores 1329 the trigger, and `PulsePalDevice.stop` cancels a trigger that has 1330 not started its channel yet. 1331 1332 ```python 1333 P.trigger(3) # one channel 1334 P.trigger([1, 4]) # several: a list, tuple or NumPy array 1335 ``` 1336 1337 Args: 1338 channels: Output channel number, 1-4, or several as a list, 1339 tuple or NumPy array. 1340 1341 Raises: 1342 PulsePalError: If a channel number is not 1-4, or no channel 1343 is given. Nothing is triggered. 1344 """ 1345 self._write_serial((self._OP_MENU_BYTE, 77, _common.channel_bits(channels)), "uint8") 1346 1347 def stop(self, channels=None): 1348 """Stop pulse trains currently playing on the device. 1349 1350 The stopped channels return to their 1351 `PulsePalDevice.resting_voltage`. A soft trigger that has not 1352 started its channel yet is cancelled too. 1353 1354 ```python 1355 P.stop() # all output channels 1356 P.stop([1, 3, 4]) # channels 1, 3 and 4 1357 ``` 1358 1359 Args: 1360 channels: Output channel number, 1-4, or several as a list, 1361 tuple or NumPy array. `None`, the default, stops all 1362 channels. Stopping some channels requires firmware v22 1363 or newer. 1364 1365 Raises: 1366 PulsePalError: If a channel number is not 1-4, or channels are 1367 given and the firmware is older than v22. 1368 """ 1369 if self.info.firmware_version < 22: 1370 if channels is not None: 1371 raise PulsePalError("stop() cannot address individual channels before firmware v22") 1372 self._write_serial((self._OP_MENU_BYTE, 80), "uint8") # Stops all channels 1373 return 1374 bits = 0x0F if channels is None else _common.channel_bits(channels) 1375 self._write_serial((self._OP_MENU_BYTE, 98, bits), "uint8") 1376 1377 def status(self): 1378 """Read which channels are playing, and the counters since the last call. 1379 1380 The counters (see `DeviceStatus`) count from the previous call, or 1381 from the device's startup, and each call clears them. Calling it 1382 before and after a trial checks that the TTLs the trial sent 1383 arrived, and started the trains they should: 1384 1385 ```python 1386 P.status() # Clears the counters 1387 run_trial() # Your code: one TTL on trigger channel 1, linked to channels 1 and 2 1388 s = P.status() 1389 if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]: 1390 print("The trial's trains did not all start") 1391 ``` 1392 1393 Requires firmware v22 or newer. 1394 1395 Returns: 1396 A `DeviceStatus`. 1397 1398 Raises: 1399 PulsePalError: If the firmware is older than v22, or the device 1400 does not reply. 1401 """ 1402 self._require_firmware(22, "status()") 1403 self._write_serial((self._OP_MENU_BYTE, 101), "uint8") 1404 values = struct.unpack(f"{self._ENDIANNESS}B7I", self._read_raw(self._STATUS_MESSAGE_BYTES)) 1405 return DeviceStatus( 1406 playing=[ch for ch in range(1, 5) if values[0] & (1 << (ch - 1))], 1407 trigger_edges=[None, *values[1:3]], 1408 channel_starts=[None, *values[3:7]], 1409 comm_failures=values[7], 1410 ) 1411 1412 def save_program_to_microsd(self, file_name): 1413 """Save the parameters to the device's microSD card, as a program. 1414 1415 The program can be loaded later with 1416 `PulsePalDevice.load_program_from_microsd` or with the joystick 1417 menu's Load Program item. A file of the same name is replaced. To 1418 keep the parameters on this computer instead, see 1419 `PulsePalDevice.export_params`. 1420 1421 ```python 1422 P.save_program_to_microsd("MyProtocol.pps") 1423 ``` 1424 1425 Args: 1426 file_name: At most 15 ASCII characters, ending in `.pps`. 1427 1428 Raises: 1429 PulsePalError: If the file name is invalid, or the device could 1430 not write the file to its microSD card. Pulse Pal 2 also 1431 refuses while a channel is playing: its card shares a bus with 1432 the outputs, so stop playback first. 1433 """ 1434 self._settings_file_op(file_name, 1, "save_program_to_microsd()") 1435 1436 def load_program_from_microsd(self, file_name): 1437 """Load a program from the device's microSD card. 1438 1439 The device plays the program, and the parameters are read back 1440 into this object (see `PulsePalDevice.sync_from_device`). The 1441 program's trigger modes take effect too, and it is loaded at once, 1442 also in param sync mode. 1443 1444 Args: 1445 file_name: The program's file name, ending in `.pps`, as saved 1446 by `PulsePalDevice.save_program_to_microsd` or the joystick 1447 menu. 1448 1449 Raises: 1450 PulsePalError: If the file name is invalid, or the device could 1451 not load the program: there is no such file on its microSD 1452 card, or it is not a valid Pulse Pal program; or, on Pulse 1453 Pal 2, a channel is playing (see `save_program_to_microsd`). 1454 The device then keeps its parameters, and so does this object. 1455 """ 1456 self._settings_file_op(file_name, 2, "load_program_from_microsd()") 1457 1458 def delete_program_from_microsd(self, file_name): 1459 """Delete a program from the device's microSD card. 1460 1461 Args: 1462 file_name: The program's file name, ending in `.pps`. 1463 1464 Raises: 1465 PulsePalError: If the file name is invalid, or the device could 1466 not delete the file, e.g. because there is no such file, or, 1467 on Pulse Pal 2, because a channel is playing. 1468 """ 1469 self._settings_file_op(file_name, 3, "delete_program_from_microsd()") 1470 1471 def _settings_file_op(self, file_name, op_byte, context): 1472 """Save (op_byte 1), load (2) or delete (3) a settings file, with op 90.""" 1473 valid = ( 1474 isinstance(file_name, str) 1475 and all(32 <= ord(character) <= 126 for character in file_name) # Printable ASCII 1476 and file_name.lower().endswith(".pps") 1477 and 4 < len(file_name) <= 15 1478 ) 1479 if not valid: 1480 raise PulsePalError( 1481 f"{context}: the file name must be 1 to 11 ASCII characters followed by .pps, " 1482 f"e.g. 'Protocol1.pps'. Received {file_name!r}." 1483 ) 1484 filename_bytes = file_name.encode("ascii") 1485 self._write_serial( 1486 (self._OP_MENU_BYTE, 90, op_byte, len(filename_bytes)), 1487 "uint8", 1488 list(filename_bytes), 1489 "uint8", 1490 ) 1491 if self.info.firmware_version > 21: 1492 # Sent after the file operation has finished. A refused load changes nothing on the device. 1493 refusal = { 1494 1: f"Pulse Pal could not save {file_name!r} to its microSD card. Is the card in place?", 1495 2: f"Pulse Pal could not load {file_name!r}: there is no such file on its microSD card, or it is not " 1496 "a valid Pulse Pal program. The device keeps its parameters.", 1497 3: f"Pulse Pal could not delete {file_name!r}: there is no such file on its microSD card.", 1498 }[op_byte] 1499 # Pulse Pal 2 replies 2 while a channel plays: its microSD card shares the SPI bus with the DAC 1500 busy = (f"Pulse Pal 2 does not use its microSD card while a channel is playing, because the card shares a " 1501 f"bus with the outputs, so {file_name!r} was not {('saved', 'loaded', 'deleted')[op_byte - 1]}. " 1502 "Stop playback first, with stop().") 1503 if op_byte == 2: 1504 busy += " The device keeps its parameters." 1505 self._read_ack(context, refusal_message=refusal, refusal_messages={2: busy}) 1506 else: 1507 self._reply_pending = None # Firmware v21 does not acknowledge op 90 (see _write_raw()) 1508 if op_byte == 2: 1509 time.sleep(0.1) # So allow time for the load 1510 if op_byte == 2: 1511 self.sync_from_device() 1512 1513 def format_microsd(self, *, confirm=True, timeout=30): 1514 """Format the device's microSD card. 1515 1516 Erases every program saved on the device and resets its 1517 parameters to the defaults. By default, asks at the console for 1518 confirmation first; a script that runs on its own, or an AI agent, 1519 passes `confirm=False`. 1520 1521 Requires Pulse Pal hardware v3 or newer. 1522 1523 Args: 1524 confirm: If `True`, ask at the console before anything is 1525 erased. If `False`, format at once. 1526 timeout: Seconds to wait for the device to report that 1527 formatting has finished. 1528 1529 Returns: 1530 `True` once the card has been formatted, or `False` if the 1531 user declines the confirmation prompt. 1532 1533 Raises: 1534 PulsePalError: If the connected hardware is older than v3, if 1535 the device reports that formatting failed, or if it does 1536 not report a result within `timeout` seconds. 1537 """ 1538 if self.info.hardware_version < 3: 1539 raise PulsePalError( 1540 "format_microsd() requires hardware v3 or newer." 1541 ) 1542 1543 if to_bool(confirm, "confirm"): 1544 print("*** Pulse Pal microSD Formatter ***") 1545 print("This will format Pulse Pal's microSD card,") 1546 print("erase all programs saved on the device") 1547 print("and reset all parameters to defaults.") 1548 reply = input("Do you want to continue (y/n)") 1549 if reply.strip().lower() != "y": 1550 print("Choice confirmed - microSD Card NOT formatted.") 1551 return False 1552 1553 self._write_serial((self._OP_MENU_BYTE, 97), "uint8", list(_FORMAT_KEY), "uint8") 1554 1555 # The device replies with lines of status text, the last of which 1556 # contains "!", and then a confirm byte (1 if the card was formatted, 1557 # 0 if not). The confirm byte is sent after the device has reloaded 1558 # its default parameters, so it can arrive well after the text. It 1559 # must be read here: left in the buffer, it would be taken as the 1560 # reply to the next command, and every reply after that would be 1561 # read one byte late. 1562 start = time.time() 1563 message = bytearray() 1564 flag_index = -1 1565 line_end = -1 1566 1567 while time.time() - start < timeout: 1568 n_waiting = self.port.in_waiting 1569 if n_waiting: 1570 message.extend(self.port.read(n_waiting)) 1571 flag_index = message.find(b"!") 1572 if flag_index >= 0: 1573 line_end = message.find(b"\n", flag_index) 1574 if line_end >= 0 and len(message) > line_end + 1: 1575 break 1576 time.sleep(0.01) 1577 if line_end < 0 or len(message) <= line_end + 1: 1578 raise PulsePalError( 1579 "Pulse Pal did not report the result of formatting " 1580 f"its microSD card within {timeout} s. {_common.NO_REPLY_HINT}" 1581 ) 1582 self._reply_pending = None # The whole reply is read (see _write_raw()) 1583 confirm_byte = message[line_end + 1] 1584 1585 text = bytes(message[:flag_index]).decode( 1586 "ascii", errors="replace" 1587 ).rstrip() 1588 if text: 1589 print(text) 1590 1591 # The device has loaded its own defaults. They are this class's, but the device keeps no record of 1592 # this object's other changes (continuous loop mode, for one), so the defaults are programmed, as the 1593 # MATLAB class does, and this object describes the device again. 1594 self.set_default_params() 1595 if confirm_byte != 1: 1596 raise PulsePalError( 1597 "Pulse Pal could not format its microSD card." 1598 ) 1599 return True 1600 1601 def gui(self, block=None, theme=None): 1602 """Open the Pulse Pal parameter GUI, or focus an open one. 1603 1604 The GUI edits its own copy of the parameters, and loads them to 1605 the device when its 'Load to Device' button is clicked. The 1606 window closes automatically when the device is closed or 1607 deleted. 1608 1609 Calling this while the GUI is already open focuses the existing 1610 window rather than opening a second one. 1611 1612 Args: 1613 block: If `True`, the call returns when the GUI is closed. If 1614 `False`, the call returns immediately, and the host 1615 application must run the Tk event loop. If `None`, the 1616 GUI blocks only when the host does not already provide a 1617 Tk event loop, e.g. when launched from a script. 1618 theme: `"light"` or `"dark"` to select the color theme, or 1619 `None` to match the desktop theme. Passing a theme to an 1620 already-open GUI recolors it in place. 1621 1622 Returns: 1623 The `pulsepal.gui.PulsePalGUI` instance driving the window. 1624 1625 Raises: 1626 ValueError: If the theme name is not recognized. 1627 """ 1628 gui = getattr(self, "_gui", None) 1629 if gui is not None and not gui.is_closed: 1630 if theme is not None: 1631 gui.set_theme(theme) 1632 gui.focus() 1633 return gui 1634 1635 from .gui import PulsePalGUI 1636 1637 gui = PulsePalGUI(self, theme=theme) 1638 self._gui = gui 1639 gui.start(block=block) 1640 return gui 1641 1642 # ------------------------------------------------------------------ 1643 # Connection 1644 # ------------------------------------------------------------------ 1645 1646 def close(self): 1647 """Close the connection to the device, and the GUI if open. 1648 1649 The device stops all output channels when the client disconnects, 1650 and shows its own name on its screen again. Safe to call more 1651 than once; later calls do nothing. Called automatically when 1652 leaving a `with` block and when the object is garbage collected, 1653 so pulse trains also stop when the last reference to the object 1654 goes, e.g. when a function that created it returns. 1655 """ 1656 self._close(send_disconnect=True) 1657 1658 def _close(self, send_disconnect): 1659 """Close the connection. send_disconnect: tell the device first (op 81). False when the device is in 1660 an unknown state, such as after a failed handshake.""" 1661 gui = getattr(self, "_gui", None) 1662 self._gui = None 1663 if gui is not None: 1664 try: 1665 gui.close() 1666 except Exception: 1667 # Cleanup must not raise; Tk may already be torn down 1668 pass 1669 1670 if getattr(self, "_closed", True): 1671 return 1672 self._closed = True 1673 if send_disconnect and self._unsent: 1674 _common.warn(f"PulsePalDevice closed with changes made with auto_sync off that were never sent to the " 1675 f"device: {self._unsent_names()}.") 1676 self._reply_pending = None # Nothing more is read, so a reply still to come needs no discarding 1677 try: 1678 if send_disconnect and self.port and self.port.is_open: 1679 self._write_serial((self._OP_MENU_BYTE, 81), "uint8") 1680 except Exception: 1681 pass # The port may already be gone, e.g. the cable was unplugged 1682 finally: 1683 if self.port and self.port.is_open: 1684 self.port.close() 1685 1686 def __enter__(self): 1687 """Enter a `with` block, returning the connected device.""" 1688 return self 1689 1690 def __exit__(self, exc_type, exc_value, traceback): 1691 """Disconnect and close the port when leaving a `with` block. 1692 1693 Returns: 1694 `False`, so any exception raised in the block propagates. 1695 """ 1696 self.close() 1697 return False 1698 1699 def __del__(self): 1700 """Disconnect and close the port when the object is collected.""" 1701 try: 1702 self.close() 1703 except Exception: 1704 # Destructors should not raise; the serial object may already be 1705 # gone during interpreter shutdown. 1706 pass 1707 1708 def __setattr__(self, name, value): 1709 """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see 1710 _common.refuse_unknown_attribute()).""" 1711 if name[0] != "_": 1712 _common.refuse_unknown_attribute(self, name, _SETTING_NAMES) 1713 super().__setattr__(name, value) 1714 1715 def __repr__(self): 1716 """Describe the device and its parameters, e.g. with `print(P)`.""" 1717 port = getattr(getattr(self, "port", None), "port", None) 1718 lines = [ 1719 f"PulsePalDevice on {port} (Pulse Pal {self.info.hardware_version}, " 1720 f"firmware v{self.info.firmware_version})", 1721 f"auto_sync: {self._auto_sync}", 1722 ] 1723 for name in self.info.output_parameter_names: 1724 lines.append(f"{name}: {list(getattr(self, name))}") 1725 lines.append(f"trigger_mode: {list(self._trigger_mode)}") 1726 return "\n".join(lines) 1727 1728 # ------------------------------------------------------------------ 1729 # Internals: parameters 1730 # ------------------------------------------------------------------ 1731 1732 def _has_param_sync(self): 1733 """Param sync mode is on Pulse Pal 3, with firmware v22 or newer.""" 1734 return (self.info.hardware_version or 2) > 2 and (self.info.firmware_version or 0) > 21 1735 1736 def _normalize_output_value(self, code, value, channel): 1737 """Check a value for an output parameter, and return it as the parameter's list holds it: 1738 a time as the device plays it, rounded to its timer cycle. 1739 1740 Raises PulsePalError, before anything is sent, for a value the device cannot play. The 1741 device refuses most of them too, but it also resets them (see validateOutputParams() in 1742 /Firmware/PulsePal/USBOps.ino), so the local copy would describe a different program. 1743 """ 1744 name, kind = _OUTPUT_PARAMETERS[code] 1745 label = f"{name} on channel {channel}" 1746 if kind == "bool": 1747 value = to_bool(value, label) 1748 if code == _CONTINUOUS_LOOP_CODE and value and self.info.firmware_version < 22: 1749 raise PulsePalError("continuous_loop requires firmware v22 or newer.") 1750 return value 1751 if kind == "volts": 1752 self._volts_to_bits(value, label) 1753 return float(value) 1754 if kind in ("time", "pulse_time"): 1755 cycles = self._seconds_to_cycles(value, label) 1756 # Checked in whole cycles, as the device receives it: 100 * 1e-6 is just under 0.0001, 1757 # but is exactly 10 cycles of 10 us 1758 if kind == "pulse_time" and cycles < self._min_pulse_cycles(): 1759 raise PulsePalError( 1760 f"{label} must be at least {self._min_pulse_seconds()} s " 1761 f"({self._min_pulse_cycles_text()} of the device's {self.info.cycle_period_us} us " 1762 f"timer), so that trigger channels can detect the pulses. Received {value!r}." 1763 ) 1764 return self._cycles_to_seconds(cycles) # The time the device plays 1765 if kind == "train_id": 1766 self._check_whole_number(value, label, 0, self.info.n_custom_pulse_trains) 1767 return int(value) 1768 # "target" 1769 return to_name(value, CUSTOM_TRAIN_TARGETS, label, codes={"Pulses": 0, "Bursts": 1}) 1770 1771 def _normalize_trigger_mode(self, value): 1772 modes = self.info.trigger_modes 1773 return to_name(value, modes, "trigger_mode", codes={mode: TRIGGER_MODES.index(mode) for mode in modes}) 1774 1775 def _encode_output_param(self, code, values): 1776 """Convert output parameter values to device units. 1777 1778 Returns the converted values and the datatype the device reads for this 1779 parameter code: DAC codes for voltages, hardware timer cycles for times, 1780 and bytes for everything else. 1781 """ 1782 name, kind = _OUTPUT_PARAMETERS[code] 1783 if kind == "volts": 1784 return [self._volts_to_bits(v, name) for v in values], "uint16" 1785 if kind in ("time", "pulse_time"): 1786 return [self._seconds_to_cycles(v, name) for v in values], "uint32" 1787 if kind == "target": 1788 return [CUSTOM_TRAIN_TARGETS.index(v) for v in values], "uint8" 1789 return [int(v) for v in values], "uint8" 1790 1791 def _apply_output_param(self, code, values): 1792 """Check new values for all four channels of an output parameter, and with auto_sync on, 1793 program the channels whose value changes. Returns them as the parameter's list holds them.""" 1794 values = [self._normalize_output_value(code, v, ch) for ch, v in enumerate(values, start=1)] 1795 name = _OUTPUT_PARAMETERS[code][0] 1796 if not self._auto_sync: 1797 self._unsent = self._unsent | {name} # Until sync_to_device() sends it (see auto_sync) 1798 else: 1799 if code in (_BURST_DURATION_CODE, _CUSTOM_TRAIN_TARGET_CODE): 1800 # Sent alone, so it must suit the other parameter as the device holds it. In a batch() block, or 1801 # with auto_sync off, both are checked together by sync_to_device(). 1802 new = {name: values} 1803 self._check_custom_bursts(new.get("custom_train_target", list(self._custom_train_target)[1:]), 1804 new.get("burst_duration", list(self._burst_duration)[1:])) 1805 # Compared as the device receives them: 5 and 5.00001 V are the same DAC code 1806 old_data, _ = self._encode_output_param(code, list(getattr(self, name))[1:]) 1807 new_data, _ = self._encode_output_param(code, values) 1808 channels = [ch for ch in range(1, 5) if old_data[ch - 1] != new_data[ch - 1]] 1809 self._send_output_param(code, values, channels, f"setting {name}") 1810 return values 1811 1812 def _send_output_param(self, code, values, channels, context): 1813 """Program one output parameter on some output channels: op 91 if all four change, op 74 for each otherwise. 1814 1815 The other channels are left as the device holds them. Sending all four (op 91) for a change to one put this 1816 object's copy of the others back on the device, undoing what the joystick menu, or a program loaded with it, 1817 had set there. 1818 """ 1819 if self.info.firmware_version > 21 and len(channels) == 4: 1820 data, datatype = self._encode_output_param(code, values) 1821 self._write_serial((self._OP_MENU_BYTE, 91, code), "uint8", data, datatype) 1822 self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, channels)) 1823 return 1824 for channel in channels: 1825 # A refusal reads back every channel this assignment changes, so that the copy describes the device for 1826 # those already sent too 1827 self._send_output_param_to_channel(code, channel, values[channel - 1], context, refresh_channels=channels) 1828 1829 def _send_output_param_to_channel(self, code, channel, value, context, refresh_channels=None): 1830 """Program one output parameter on one channel (op 74).""" 1831 if code == _CONTINUOUS_LOOP_CODE and self.info.firmware_version < 22: 1832 return # Firmware v21 has no parameter 18, and _normalize_output_value() lets only False through 1833 data, datatype = self._encode_output_param(code, [value]) 1834 self._write_serial((self._OP_MENU_BYTE, 74, code, channel), "uint8", data, datatype) 1835 self._read_ack(context, on_refusal=lambda: self._refresh_output_param(code, refresh_channels or (channel,))) 1836 1837 def _apply_trigger_mode(self, values): 1838 """Check new trigger modes, and with auto_sync on, program the trigger channels whose mode changes.""" 1839 modes = [self._normalize_trigger_mode(v) for v in values] 1840 if not self._auto_sync: 1841 self._unsent = self._unsent | {"trigger_mode"} 1842 else: 1843 channels = [ch for ch in (1, 2) if modes[ch - 1] != self._trigger_mode[ch]] 1844 if len(channels) == 2: 1845 self._send_trigger_modes(modes, "setting trigger_mode") 1846 elif channels: # One: op 74, which leaves the other trigger channel as the device holds it 1847 channel = channels[0] 1848 self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel, 1849 TRIGGER_MODES.index(modes[channel - 1])), "uint8") 1850 self._read_ack("setting trigger_mode", on_refusal=self._refresh_trigger_mode) 1851 return modes 1852 1853 def _send_trigger_modes(self, modes, context): 1854 """Program both trigger channels' modes: op 91, or op 74 per channel on firmware v21.""" 1855 codes = [TRIGGER_MODES.index(mode) for mode in modes] 1856 if self.info.firmware_version > 21: 1857 self._write_serial((self._OP_MENU_BYTE, 91, _TRIGGER_MODE_CODE, *codes), "uint8") 1858 self._read_ack(context, on_refusal=self._refresh_trigger_mode) 1859 else: 1860 for channel, mode_code in enumerate(codes, start=1): 1861 self._write_serial((self._OP_MENU_BYTE, 74, _TRIGGER_MODE_CODE, channel, mode_code), "uint8") 1862 self._read_ack(context) 1863 1864 def _snapshot(self): 1865 """All parameters, and which are unsent, for batch() to restore if its block fails.""" 1866 names = [*self.info.output_parameter_names, "trigger_mode"] 1867 return {name: list(getattr(self, name))[1:] for name in names}, self._unsent 1868 1869 def _restore(self, snapshot): 1870 params, self._unsent = snapshot 1871 for name, values in params.items(): 1872 getattr(self, name)._store(values) 1873 1874 def _unsent_names(self): 1875 """The settings assigned with auto_sync off and not yet sent, for messages, in parameter code order.""" 1876 return ", ".join(name for name in _SETTING_NAMES if name in self._unsent) 1877 1878 def _check_sync(self): 1879 """Check the whole local copy before sync_to_device() sends it.""" 1880 for code, (name, _) in _OUTPUT_PARAMETERS.items(): 1881 values = getattr(self, name) 1882 for ch in range(1, 5): 1883 self._normalize_output_value(code, values[ch], ch) 1884 for ch in (1, 2): 1885 self._normalize_trigger_mode(self._trigger_mode[ch]) 1886 self._check_custom_bursts(list(self._custom_train_target)[1:], list(self._burst_duration)[1:]) 1887 1888 @staticmethod 1889 def _check_custom_bursts(targets, burst_durations): 1890 """Refuse a custom train targeting bursts on a channel with no burst duration. 1891 1892 Its times are burst onsets, and a burst of no duration plays one timer cycle of a pulse at each: a 1893 program that cannot be what was meant. (The device once played one pulse and then left the channel 1894 "playing", ignoring triggers until it was stopped.) 1895 """ 1896 for channel, (target, burst) in enumerate(zip(targets, burst_durations), start=1): 1897 if target == "Bursts" and burst == 0: 1898 raise PulsePalError( 1899 f"Output channel {channel}: custom_train_target 'Bursts' makes the custom train's times burst " 1900 "onsets, so burst_duration must be above 0. Set burst_duration first, or set both in a batch() " 1901 "block." 1902 ) 1903 1904 def _send_sync(self): 1905 """Send every parameter in one command: op 92 on firmware v22 or newer, op 73 on v21.""" 1906 self._unsent = frozenset() # All are sent below 1907 if self.info.firmware_version > 21: 1908 self._sync_all_params() 1909 else: 1910 self._sync_all_params_legacy() 1911 self._read_ack("sync_to_device()", on_refusal=self._refresh_after_refused_sync) 1912 1913 def _device_bytes(self, name): 1914 """The byte values of a byte parameter for channels 1-4, as the device reads them.""" 1915 values = list(getattr(self, name))[1:] 1916 if name == "custom_train_target": 1917 return [CUSTOM_TRAIN_TARGETS.index(v) for v in values] 1918 return [int(v) for v in values] 1919 1920 def _sync_all_params(self): 1921 """Send all parameters using the packed sync op (firmware v22+). 1922 1923 Values are grouped by width so that the whole program travels as 1924 one uint32 block, one uint16 block and one uint8 block. See op 92 1925 in /Firmware/PROTOCOL.md. 1926 """ 1927 time_values = [] 1928 for name in _TIME_PARAMETERS: 1929 time_values.extend(self._seconds_to_cycles(v) for v in list(getattr(self, name))[1:]) 1930 voltage_values = [] 1931 for name in _VOLTAGE_PARAMETERS: 1932 voltage_values.extend(self._volts_to_bits(v) for v in list(getattr(self, name))[1:]) 1933 single_byte_values = [] 1934 for name in ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop", 1935 "continuous_loop", "link_trigger_channel1", "link_trigger_channel2"): 1936 single_byte_values.extend(self._device_bytes(name)) 1937 single_byte_values.extend(TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:]) 1938 1939 self._write_serial( 1940 (self._OP_MENU_BYTE, 92), 1941 "uint8", 1942 time_values, 1943 "uint32", 1944 voltage_values, 1945 "uint16", 1946 single_byte_values, 1947 "uint8", 1948 ) 1949 1950 def _sync_all_params_legacy(self): 1951 """Send all parameters using the legacy sync op (firmware v21). 1952 1953 Equivalent to `_sync_all_params`, but lays the program out 1954 channel by channel as the older firmware expects. Continuous 1955 loop mode is not part of it. 1956 """ 1957 program_values_32 = [] 1958 program_values_16 = [] 1959 program_values_8 = [] 1960 for ch in range(1, 5): 1961 program_values_32.extend(self._seconds_to_cycles(getattr(self, name)[ch]) for name in _TIME_PARAMETERS) 1962 for ch in range(1, 5): 1963 program_values_16.extend(self._volts_to_bits(getattr(self, name)[ch]) for name in _VOLTAGE_PARAMETERS) 1964 byte_values = {name: self._device_bytes(name) for name in 1965 ("is_biphasic", "custom_train_id", "custom_train_target", "custom_train_loop")} 1966 for ch in range(4): 1967 program_values_8.extend(byte_values[name][ch] for name in byte_values) 1968 links = self._device_bytes("link_trigger_channel1") + self._device_bytes("link_trigger_channel2") 1969 modes = [TRIGGER_MODES.index(mode) for mode in list(self._trigger_mode)[1:]] 1970 1971 self._write_serial( 1972 (self._OP_MENU_BYTE, 73), 1973 "uint8", 1974 program_values_32, 1975 "uint32", 1976 program_values_16, 1977 "uint16", 1978 program_values_8 + links + modes, 1979 "uint8", 1980 ) 1981 1982 def _read_device_params(self): 1983 """Read the device's parameters (op 93). 1984 1985 Returns a dict of parameter values for channels 1-4 (trigger 1986 channels 1-2 for trigger_mode), as the parameter lists hold them. 1987 Continuous loop mode is not part of op 93, so `continuous_loop` is 1988 not included. 1989 """ 1990 self._write_serial((self._OP_MENU_BYTE, 93), "uint8") 1991 # The device sends the whole parameter set as one message, so read it in one go and 1992 # unpack it here. See "Op codes", op 93, in /Firmware/PROTOCOL.md. 1993 message = self._read_raw(self._PARAM_MESSAGE_BYTES) 1994 values = struct.unpack(f"{self._ENDIANNESS}32I12H26B", message) 1995 params = {} 1996 for index, name in enumerate(_TIME_PARAMETERS): 1997 params[name] = [self._cycles_to_seconds(x) for x in values[index * 4:index * 4 + 4]] 1998 for index, name in enumerate(_VOLTAGE_PARAMETERS): 1999 params[name] = [self._bits_to_volts(x) for x in values[32 + index * 4:32 + index * 4 + 4]] 2000 for index, name in enumerate(("is_biphasic", "custom_train_id", "custom_train_target", 2001 "custom_train_loop", "link_trigger_channel1", "link_trigger_channel2")): 2002 codes = values[44 + index * 4:44 + index * 4 + 4] 2003 if name == "custom_train_id": 2004 params[name] = list(codes) 2005 elif name == "custom_train_target": 2006 params[name] = [CUSTOM_TRAIN_TARGETS[min(code, 1)] for code in codes] 2007 else: 2008 params[name] = [bool(code) for code in codes] 2009 params["trigger_mode"] = [TRIGGER_MODES[min(code, 3)] for code in values[68:70]] 2010 return params 2011 2012 def _refresh_output_param(self, code, channels): 2013 """Read an output parameter back from the device into the local copy, for these channels. 2014 2015 Called after the device refused a value for them, which it resets. The parameter's other 2016 channels, and other parameters, keep their local values, which may hold edits not yet synced. 2017 """ 2018 name = _OUTPUT_PARAMETERS[code][0] 2019 if self.info.firmware_version < 22 or code == _CONTINUOUS_LOOP_CODE: 2020 return # No op 93 to read it with, or not in op 93 2021 device_values = self._read_device_params()[name] 2022 settings = getattr(self, name) 2023 local_values = list(settings)[1:] 2024 for ch in channels: 2025 local_values[ch - 1] = device_values[ch - 1] 2026 settings._store(local_values) 2027 2028 def _refresh_trigger_mode(self): 2029 """Read the trigger modes back from the device, after the device refused a new one.""" 2030 if self.info.firmware_version > 21: 2031 self._trigger_mode._store(self._read_device_params()["trigger_mode"]) 2032 2033 def _refresh_after_refused_sync(self): 2034 """Read the parameters back after the device refused a sync_to_device() set. 2035 2036 The device programs the set with the refused values reset, so the local copy is read back. 2037 In param sync mode the device stores the set instead, and op 93 would return the parameters 2038 playing now, so the local copy is left as it is. 2039 """ 2040 if self.info.firmware_version > 21 and "Param Sync" not in list(self._trigger_mode)[1:]: 2041 self.sync_from_device() 2042 2043 def _check_whole_number(self, value, name, low, high): 2044 """Raise PulsePalError unless value is a whole number from low to high.""" 2045 valid = ( 2046 isinstance(value, numbers.Real) 2047 and not isinstance(value, (bool, np.bool_)) 2048 and math.isfinite(value) 2049 and value == int(value) 2050 and low <= value <= high 2051 ) 2052 if not valid: 2053 raise PulsePalError( 2054 f"{name} must be a whole number from {low} to {high}. Received {value!r}." 2055 ) 2056 2057 def _min_pulse_cycles(self): 2058 """The shortest pulse phase, interval or train, in timer cycles: DeviceInfo.min_pulse_width_us. See 2059 _MIN_PULSE_CYCLES.""" 2060 return int(round(self.info.min_pulse_width_us / self.info.cycle_period_us)) 2061 2062 def _min_pulse_cycles_text(self): 2063 """_min_pulse_cycles() for messages: "1 cycle", "2 cycles".""" 2064 cycles = self._min_pulse_cycles() 2065 return f"{cycles} cycle" if cycles == 1 else f"{cycles} cycles" 2066 2067 def _min_pulse_seconds(self): 2068 """The shortest pulse phase, interval or train, in seconds: DeviceInfo.min_pulse_width_us.""" 2069 return self._cycles_to_seconds(self._min_pulse_cycles()) 2070 2071 def _require_firmware(self, minimum_version, context): 2072 """Raise unless the device firmware is new enough for an op.""" 2073 if ( 2074 self.info.firmware_version is None 2075 or self.info.firmware_version < minimum_version 2076 ): 2077 raise PulsePalError( 2078 f"{context} requires firmware v{minimum_version} or newer. " 2079 f"Detected firmware is v{self.info.firmware_version}." 2080 ) 2081 2082 # ------------------------------------------------------------------ 2083 # Internals: custom trains 2084 # ------------------------------------------------------------------ 2085 2086 def _send_custom_train(self, custom_train_id, pulse_times_cycles, 2087 pulse_voltage_bits, context): 2088 """Send a custom pulse train, already converted to device units (NumPy integer arrays). 2089 2090 Uses op 95 on firmware v22 or newer, and the legacy ops 75 and 76 2091 (trains 1 and 2 only) on firmware v21. 2092 """ 2093 n_trains = self.info.n_custom_pulse_trains 2094 valid_id = ( 2095 isinstance(custom_train_id, numbers.Integral) 2096 and not isinstance(custom_train_id, (bool, np.bool_)) 2097 and 1 <= custom_train_id <= n_trains 2098 ) 2099 if not valid_id: 2100 raise PulsePalError( 2101 f"{context}: custom_train_id must be an integer from 1 to " 2102 f"{n_trains}. Received {custom_train_id!r}." 2103 ) 2104 train_id = int(custom_train_id) 2105 n_pulses = len(pulse_times_cycles) 2106 if n_pulses == 0: 2107 # The device refuses an empty train too: it would hold no pulse times to play 2108 raise PulsePalError( 2109 f"{context}: a custom train needs at least one pulse. To play nothing on a channel, set its " 2110 "custom_train_id to a train that holds what it should play, or do not trigger it." 2111 ) 2112 if n_pulses > self.info.max_custom_pulses: 2113 raise PulsePalError( 2114 f"{context}: {n_pulses} pulses were given. Pulse Pal can " 2115 f"store up to {self.info.max_custom_pulses} pulses per " 2116 "custom train." 2117 ) 2118 # Times are multiples of _min_pulse_cycles() (see _custom_times_to_cycles()), so increasing times are far 2119 # enough apart for a trigger channel to detect each pulse. 2120 too_close = np.flatnonzero(np.diff(pulse_times_cycles) < self._min_pulse_cycles()) 2121 if too_close.size: 2122 i = int(too_close[0]) + 1 2123 raise PulsePalError( 2124 f"{context}: pulse times must increase, by at least " 2125 f"{self._min_pulse_seconds()} s ({self._min_pulse_cycles_text()} " 2126 f"of the device's {self.info.cycle_period_us} us " 2127 f"timer). Pulse {i + 1} is at " 2128 f"{self._cycles_to_seconds(pulse_times_cycles[i])} s, " 2129 f"and pulse {i} is at " 2130 f"{self._cycles_to_seconds(pulse_times_cycles[i - 1])} s." 2131 ) 2132 if pulse_times_cycles[-1] > self._max_time_cycles(): 2133 raise PulsePalError( 2134 f"{context}: the last pulse starts at {self._cycles_to_seconds(pulse_times_cycles[-1])} s. " 2135 f"Pulse times must be at most {self.info.max_time} s." 2136 ) 2137 2138 if self.info.firmware_version > 21: 2139 header = bytes([self._OP_MENU_BYTE, 95, train_id - 1]) 2140 else: 2141 header = bytes([self._OP_MENU_BYTE, 74 + train_id]) 2142 self._write_raw( 2143 header + struct.pack("<I", n_pulses) 2144 + np.asarray(pulse_times_cycles).astype("<u4").tobytes() 2145 + np.asarray(pulse_voltage_bits).astype("<u2").tobytes() 2146 ) 2147 self._read_ack(context) 2148 2149 def _number_array(self, values, name, description): 2150 """Return values (a scalar, a list, a tuple or a 1-D NumPy array) as a float64 array, refusing booleans, 2151 strings and anything else that is not a number, as the scalar conversions do. 2152 2153 An array of two or more dimensions, or a list of lists, is refused rather than flattened: one value per 2154 pulse is a list, and a 2-D one is a mistake, such as both rows of a stereo waveform. (The MATLAB class, which 2155 took a matrix's longest side as its pulse count and sent all of it, sent the rest to the device as commands.) 2156 """ 2157 if isinstance(values, np.ndarray): 2158 self._check_one_dimensional(values, name) 2159 if values.dtype.kind in "iuf": 2160 return values.ravel().astype(float) 2161 items = self._as_list(values) 2162 refused_types = tuple(kind for kind in {type(item) for item in items} # Each type once: fast for long lists 2163 if issubclass(kind, (bool, np.bool_, str, bytes))) 2164 if refused_types: 2165 item = next(item for item in items if isinstance(item, refused_types)) 2166 raise PulsePalError(f"{name} must be {description}. Received {item!r}.") 2167 try: 2168 array = np.asarray(items, dtype=float) 2169 except (TypeError, ValueError): 2170 for item in items: 2171 try: 2172 float(item) 2173 except (TypeError, ValueError): 2174 raise PulsePalError(f"{name} must be {description}. Received {item!r}.") from None 2175 raise 2176 self._check_one_dimensional(array, name) # A list of lists 2177 return array 2178 2179 @staticmethod 2180 def _check_one_dimensional(array, name): 2181 if array.ndim > 1: 2182 raise PulsePalError( 2183 f"{name} must be one-dimensional, one value per pulse: a list, a tuple or a 1-D NumPy array. " 2184 f"Received an array of shape {array.shape}." 2185 ) 2186 2187 @staticmethod 2188 def _first(values, refused): 2189 """The first value at a True position of the boolean array `refused`, for an error message.""" 2190 i = int(np.flatnonzero(refused)[0]) 2191 item = values.ravel()[i] if isinstance(values, np.ndarray) else PulsePalDevice._as_list(values)[i] 2192 return item.item() if isinstance(item, np.generic) else item 2193 2194 def _custom_times_to_cycles(self, values, name): 2195 """Convert custom pulse times to timer cycles, all at once: _custom_time_cycles() for each of them. 2196 2197 Each must be a time the device can play, and a whole number of DeviceInfo.min_pulse_width_us to the nearest 2198 microsecond, as in the MATLAB class. Returns an int64 array. 2199 """ 2200 seconds = self._number_array(values, name, "a time of 0 s or more") 2201 refused = ~(np.isfinite(seconds) & (seconds >= 0)) 2202 if refused.any(): 2203 raise PulsePalError(f"{name} must be a time of 0 s or more. Received {self._first(values, refused)!r}.") 2204 # The same arithmetic as _seconds_to_cycles() and _custom_time_cycles(): NumPy's round() also takes a 2205 # halfway value to the even integer 2206 refused = np.round(seconds * float(self.info.cycle_frequency)) > self._max_time_cycles() 2207 if refused.any(): 2208 raise PulsePalError( 2209 f"{name} must be at most {self.info.max_time} s, the longest time the device's screen " 2210 f"shows. Received {self._first(values, refused)!r}." 2211 ) 2212 microseconds = np.round(seconds * 1e6) 2213 step_us = int(self.info.min_pulse_width_us) 2214 refused = np.mod(microseconds, step_us) != 0 2215 if refused.any(): 2216 raise PulsePalError( 2217 f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). " 2218 f"Received {self._first(values, refused)!r}." 2219 ) 2220 return (microseconds // int(self.info.cycle_period_us)).astype(np.int64) 2221 2222 def _volts_array_to_bits(self, values, name): 2223 """Convert voltages to DAC codes, all at once: _volts_to_bits() for each of them. Returns an int64 array.""" 2224 volts = self._number_array(values, name, "in [-10, 10] V") 2225 refused = ~((volts >= -10) & (volts <= 10)) # Also refuses NaN 2226 if refused.any(): 2227 raise PulsePalError(f"{name} must be in [-10, 10] V. Received {self._first(values, refused)!r}.") 2228 return np.round((volts + 10) / 20 * self._DAC_BITMAX).astype(np.int64) 2229 2230 # ------------------------------------------------------------------ 2231 # Internals: serial port and units 2232 # ------------------------------------------------------------------ 2233 2234 def _write_serial(self, *args): 2235 """Write one or more data/type pairs to the serial port, in one write.""" 2236 if len(args) % 2 != 0: 2237 raise PulsePalError( 2238 "Serial writes require data/type argument pairs." 2239 ) 2240 2241 payload = bytearray() 2242 for i in range(0, len(args), 2): 2243 payload.extend(self._pack_values(args[i], args[i + 1])) 2244 self._write_raw(bytes(payload)) 2245 2246 def _write_raw(self, payload): 2247 """Write a command to the serial port, in one write. 2248 2249 A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves 2250 that reply, or the rest of it, to arrive later. Read as the reply to the next command, it put every reply 2251 after it out of step: status() reported channels playing that were not, and assignments the device took 2252 raised refusals. So it is discarded first (_discard_lost_reply()). Then this command's reply, if it has one, 2253 is pending until it has been read: _read_raw() marks it read, and a method that reads a reply otherwise 2254 (format_microsd()) sets _reply_pending back to None itself. 2255 """ 2256 if self._reply_pending is not None: 2257 self._discard_lost_reply() 2258 if payload[1] not in _OPS_WITHOUT_TRACKED_REPLY: 2259 self._reply_pending = payload[1] # Before the write: a Ctrl-C can come as soon as it returns 2260 bytes_written = self.port.write(payload) 2261 if bytes_written != len(payload): 2262 raise PulsePalError( 2263 f"Wrote {bytes_written} byte(s), expected to write " 2264 f"{len(payload)} byte(s)." 2265 ) 2266 2267 def _discard_lost_reply(self): 2268 """Discard what arrives of a reply that was not read in full (see _write_raw()), and bring this object back 2269 in step with the device. 2270 2271 The command may have changed the parameters, unknown to this object: an assignment interrupted after its 2272 write, for one, which the device took and this object did not. So after one of _PARAMETER_OPS, they are read 2273 back (op 93), on firmware v22 or newer and outside param sync mode, where op 93 returns the parameters playing 2274 rather than a set stored for the next sync edge. 2275 """ 2276 op = self._reply_pending 2277 self._reply_pending = None 2278 n_late = len(_common.read_until_quiet(self.port)) 2279 read_back = (op in _PARAMETER_OPS and self.info.firmware_version > 21 2280 and "Param Sync" not in list(self._trigger_mode)[1:]) 2281 _common.warn_lost_reply(f"op {op}", n_late, 2282 ", and the parameters were read back from the device" if read_back else "") 2283 if read_back: 2284 try: 2285 self.sync_from_device() 2286 except PulsePalError as exc: 2287 raise PulsePalError( 2288 f"Pulse Pal did not send its parameters, read back after the reply to an earlier command was " 2289 f"lost. {_common.NO_REPLY_HINT}" 2290 ) from exc 2291 2292 def _read_raw(self, n_bytes): 2293 """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no 2294 longer pending (see _write_raw()). Only connecting reads a reply in more than one call.""" 2295 message_bytes = self.port.read(n_bytes) 2296 if len(message_bytes) < n_bytes: 2297 raise PulsePalError( 2298 f"Pulse Pal sent {len(message_bytes)} of the {n_bytes} byte(s) of its reply within " 2299 f"{self.port.timeout} s. {_common.NO_REPLY_HINT}" 2300 ) 2301 self._reply_pending = None 2302 return message_bytes 2303 2304 def _read_serial(self, n_values, datatype): 2305 """Read values from the serial port and unpack them with struct.""" 2306 fmt = self._STRUCT_FORMATS[datatype] 2307 n_values = int(n_values) 2308 n_bytes = n_values * struct.calcsize(fmt) 2309 message_bytes = self._read_raw(n_bytes) 2310 2311 values = struct.unpack( 2312 f"{self._ENDIANNESS}{n_values}{fmt}", 2313 message_bytes, 2314 ) 2315 if n_values == 1: 2316 return values[0] 2317 return list(values) 2318 2319 def _read_ack(self, context, on_refusal=None, refusal_message=None, refusal_messages=None): 2320 """Read a one-byte acknowledgement from the device. 2321 2322 The device replies 1 if it executed the command, or 0 if it rejected 2323 the command because a value was out of range. On a 0, on_refusal() 2324 is called before the error is raised: the device resets a value it 2325 refuses, so callers use it to read the value back into the local copy. 2326 refusal_message replaces the error's usual message, for a command 2327 refused for another reason. refusal_messages maps other replies to 2328 their own message, e.g. op 90's 2. 2329 """ 2330 try: 2331 acknowledgement = self._read_serial(1, "uint8") 2332 except PulsePalError as exc: 2333 raise PulsePalError( 2334 f"Pulse Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. " 2335 f"{_common.NO_REPLY_HINT}" 2336 ) from exc 2337 if acknowledgement != 1: 2338 refusal = PulsePalError( 2339 (refusal_messages or {}).get(acknowledgement) or 2340 refusal_message or 2341 f"Pulse Pal rejected the command sent by {context}. " 2342 "This usually means that a channel number, parameter code or " 2343 "value was out of range for the connected device." 2344 ) 2345 if on_refusal is not None: 2346 try: 2347 on_refusal() 2348 except PulsePalError as exc: 2349 raise refusal from exc 2350 raise refusal 2351 2352 def _pack_values(self, values, datatype): 2353 """Pack scalar, list/tuple, or NumPy array values into bytes.""" 2354 fmt = self._STRUCT_FORMATS[datatype] 2355 values_list = self._as_list(values) 2356 min_value, max_value = self._TYPE_RANGES[datatype] 2357 normalized = [] 2358 for value in values_list: 2359 value = int(value) 2360 if not min_value <= value <= max_value: 2361 raise PulsePalError( 2362 f"Value {value} is out of range for {datatype} " 2363 f"({min_value} to {max_value})." 2364 ) 2365 normalized.append(value) 2366 return struct.pack(f"{self._ENDIANNESS}{len(normalized)}{fmt}", *normalized) 2367 2368 @staticmethod 2369 def _as_list(values): 2370 """Return values as a flat list, wrapping scalars in one.""" 2371 if isinstance(values, np.ndarray): 2372 return values.ravel().tolist() 2373 if isinstance(values, (bytes, bytearray, str)): 2374 return [values] 2375 if isinstance(values, numbers.Number): 2376 return [values] 2377 try: 2378 return list(values) 2379 except TypeError: 2380 return [values] 2381 2382 def _volts_to_bits(self, value, name="voltage"): 2383 """Convert -10 V to +10 V to the corresponding DAC code. 2384 2385 Rounds to the nearest code, halves to even (round()), as the MATLAB and C++ classes do. 2386 Raises PulsePalError for a voltage outside [-10, 10] or not a number: clamping it would 2387 play a different voltage. 2388 """ 2389 try: 2390 volts = float(value) 2391 except (TypeError, ValueError): 2392 volts = float("nan") 2393 if isinstance(value, (bool, np.bool_, str)) or not -10 <= volts <= 10: # Also refuses NaN 2394 raise PulsePalError(f"{name} must be in [-10, 10] V. Received {value!r}.") 2395 return int(round((volts + 10) / 20 * self._DAC_BITMAX)) 2396 2397 def _bits_to_volts(self, value): 2398 """Convert a DAC code to volts, snapping to a clean value within 1 LSB.""" 2399 raw_volts = (float(value) / self._DAC_BITMAX * 20) - 10 2400 lsb_volts = 20.0 / self._DAC_BITMAX 2401 # The nearest 3 decimal number (e.g. 5.000, 4.255), if the code is within 1 LSB of it 2402 clean_volts = round(raw_volts, 3) 2403 if abs(raw_volts - clean_volts) <= lsb_volts: 2404 return clean_volts 2405 return round(raw_volts, 4) 2406 2407 def _seconds_to_cycles(self, value, name="time"): 2408 """Convert seconds to the corresponding refresh-cycle count. 2409 2410 Rounds to the nearest cycle, halves to even (round()), as the MATLAB and C++ classes do: 2411 125 us, 2.5 cycles, is 2 cycles in all three. Raises PulsePalError for a time that is 2412 negative, not a number, or longer than DeviceInfo.max_time. 2413 """ 2414 try: 2415 seconds = float(value) 2416 except (TypeError, ValueError): 2417 seconds = float("nan") 2418 if isinstance(value, (bool, np.bool_, str)) or not (math.isfinite(seconds) and seconds >= 0): 2419 raise PulsePalError(f"{name} must be a time of 0 s or more. Received {value!r}.") 2420 cycles = int(round(seconds * float(self.info.cycle_frequency))) 2421 if cycles > self._max_time_cycles(): 2422 raise PulsePalError( 2423 f"{name} must be at most {self.info.max_time} s, the longest time the device's screen " 2424 f"shows. Received {value!r}." 2425 ) 2426 return cycles 2427 2428 def _max_time_cycles(self): 2429 """DeviceInfo.max_time in timer cycles: 999999990 on a 10 us timer, 199999998 on a 50 us timer.""" 2430 return int(round(self.info.max_time * float(self.info.cycle_frequency))) 2431 2432 def _custom_time_cycles(self, value, name): 2433 """Convert a custom pulse time or sampling period to timer cycles. 2434 2435 It must be a whole number of DeviceInfo.min_pulse_width_us, to the nearest microsecond, 2436 as in the MATLAB class. A time between two steps is refused, not rounded 2437 as the time parameters are, so that a train plays as it was written. 2438 """ 2439 self._seconds_to_cycles(value, name) # Checks that it is a time the device can play 2440 microseconds = int(round(float(value) * 1e6)) 2441 step_us = int(self.info.min_pulse_width_us) 2442 if microseconds % step_us: 2443 raise PulsePalError( 2444 f"{name} must be multiples of {step_us} us ({step_us / 1e6:g} s). Received {value!r}." 2445 ) 2446 return microseconds // int(self.info.cycle_period_us) 2447 2448 def _cycles_to_seconds(self, value): 2449 """Convert hardware timer cycle counts to seconds.""" 2450 return float(value) / float(self.info.cycle_frequency)
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 (see
PulsePalDevice.set_default_params).
from pulsepal import PulsePalDevice
P = PulsePalDevice("COM3")
P.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 run serialportlist() again. 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 lists
indexed by channel number, with index 0 unused (see "Parameters"
above). print(P) shows them all, and PulsePalDevice.export_params
returns them as a dict.
672 def __init__(self, port_name=None, *, serial_number=None, timeout=10): 673 """Open a connection to a Pulse Pal device. 674 675 Opens the serial port, exchanges the handshake, verifies the 676 firmware version, reads the device properties into 677 `PulsePalDevice.info`, and programs the device with the default 678 parameters. 679 680 ```python 681 P = PulsePalDevice("COM3") 682 P = PulsePalDevice(serial_number="20345420") # Wherever it is plugged in 683 ``` 684 685 Args: 686 port_name: USB serial port for the Pulse Pal device, such as 687 `COM3` on Windows or `/dev/ttyACM0` on Linux. 688 serial_number: Instead of `port_name`, the device's USB serial 689 number (`DeviceInfo.serial_number`), which finds its port. 690 Pulse Pal 3 only: Pulse Pal 2 has none. 691 timeout: Serial read timeout, in seconds. 692 693 Firmware newer than this module knows (`v22`) is used with a 694 warning: new firmware only adds commands, so everything here still 695 works with it. Update the package to use what is new. 696 697 Raises: 698 PulsePalError: If the device does not return the expected 699 handshake (for example, it runs Wave Pal or Synth Pal 700 firmware), or its firmware is older than v21, or no port 701 has the serial number given. The port is closed again 702 before any error is raised. 703 serial.SerialException: If the serial port cannot be opened, 704 e.g. because another program has it open (on Linux and 705 macOS, another session of these classes, which lock it). 706 """ 707 self._closed = True 708 self._gui = None 709 self._auto_sync = True 710 self.info = DeviceInfo() 711 self._create_settings() 712 port_name = _common.port_name_from(port_name, serial_number, "PulsePalDevice") 713 self.port = serial.Serial( 714 port_name, 715 self._BAUD_RATE, 716 timeout=timeout, 717 rtscts=True, 718 # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two 719 # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks 720 # it, such as another of these classes, gets an error instead 721 exclusive=True, 722 ) 723 self._closed = False 724 try: 725 self._start_session(port_name) 726 except BaseException: 727 # Otherwise the port stays open until the object is garbage 728 # collected, and a second attempt, or WavePalDevice, cannot open 729 # it. The device is in an unknown state, so it is not sent the 730 # disconnect op. 731 self._close(send_disconnect=False) 732 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.
P = PulsePalDevice("COM3")
P = PulsePalDevice(serial_number="20345420") # Wherever it is plugged in
Arguments:
- port_name: USB serial port for the Pulse Pal device, such as
COM3on Windows or/dev/ttyACM0on Linux. - serial_number: Instead of
port_name, the device's USB serial number (DeviceInfo.serial_number), which finds its port. Pulse Pal 3 only: Pulse Pal 2 has none. - timeout: Serial read timeout, in seconds.
Firmware newer than this module knows (v22) is used with a
warning: new firmware only adds commands, so everything here still
works with it. Update the package to use what is new.
Raises:
- PulsePalError: If the device does not return the expected handshake (for example, it runs Wave Pal or Synth Pal firmware), or its firmware is older than v21, or no port has the serial number given. The port is closed again before any error is raised.
- serial.SerialException: If the serial port cannot be opened, e.g. because another program has it open (on Linux and macOS, another session of these classes, which lock it).
830 @staticmethod 831 def serialportlist(ports_to_list="available"): 832 """Return the names of the USB serial ports on this computer. 833 834 Called on the class, without connecting to a device, to find the 835 port name to pass to `PulsePalDevice`: 836 837 ```python 838 from pulsepal import PulsePalDevice 839 840 ports = PulsePalDevice.serialportlist() 841 P = PulsePalDevice(ports[0]) 842 ``` 843 844 Args: 845 ports_to_list: `available` to list only the ports that are 846 not already in use, or `all` to list every USB serial 847 port. Not case sensitive. 848 849 Returns: 850 Sorted list of port names, such as `["COM3", "COM7"]` on 851 Windows or `["/dev/ttyACM0"]` on Linux. 852 853 Raises: 854 PulsePalError: If `ports_to_list` is not `available` or 855 `all`. 856 """ 857 return _common.serialportlist(ports_to_list)
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.
863 @property 864 def auto_sync(self): 865 """Whether assigning a parameter programs the device at once. 866 867 `True` (the default): each assignment, such as 868 `P.phase1_voltage[1] = 5`, programs the device at once, also while 869 a trigger channel is in param sync mode. It sends the channels 870 whose value it changes, and nothing for a channel that already 871 holds the value assigned: the other channels keep what the device 872 holds, e.g. values set with its joystick (read them with 873 `PulsePalDevice.sync_from_device`). 874 875 `False`: assignments change only this object's copy of the 876 parameters, checking each value, and 877 `PulsePalDevice.sync_to_device` sends all of them in one command. 878 Turning it back on does not send changes made meanwhile, so it 879 raises `pulsepal.PulsePalError` while there are any: call 880 `PulsePalDevice.sync_to_device` first to send them, or 881 `PulsePalDevice.sync_from_device` to discard them. A 882 `PulsePalDevice.batch` block does it all for you. `close()` warns of 883 changes never sent. 884 """ 885 return self._auto_sync
Whether assigning a parameter programs the device at once.
True (the default): each assignment, such as
P.phase1_voltage[1] = 5, programs the device at once, also while
a trigger channel is in param sync mode. It sends the channels
whose value it changes, and nothing for a channel that already
holds the value assigned: the other channels keep what the device
holds, e.g. values set with its joystick (read them with
PulsePalDevice.sync_from_device).
False: assignments change only this object's copy of the
parameters, checking each value, and
PulsePalDevice.sync_to_device sends all of them in one command.
Turning it back on does not send changes made meanwhile, so it
raises pulsepal.PulsePalError while there are any: call
PulsePalDevice.sync_to_device first to send them, or
PulsePalDevice.sync_from_device to discard them. A
PulsePalDevice.batch block does it all for you. close() warns of
changes never sent.
901 def batch(self): 902 """Change several parameters, and send them in one command. 903 904 Returns a context manager. In its block, assignments change only 905 this object's copy of the parameters (as with 906 `PulsePalDevice.auto_sync` off), and when the block ends, 907 `PulsePalDevice.sync_to_device` sends all of them at once. If the 908 block raises an error, nothing is sent, and the parameters go 909 back to what they were. 910 911 ```python 912 with P.batch(): 913 P.phase1_voltage = [5, 5, 2.5, 2.5] 914 P.phase1_duration[3] = 0.002 915 ``` 916 917 In param sync mode, the device stores the parameters for the next 918 rising edge on the param sync channel (see 919 `PulsePalDevice.trigger_mode`). 920 """ 921 return _common.batch(self)
Change several parameters, and send them in one command.
Returns a context manager. In its block, assignments change only
this object's copy of the parameters (as with
PulsePalDevice.auto_sync off), and when the block ends,
PulsePalDevice.sync_to_device sends all of them at once. If the
block raises an error, nothing is sent, and the parameters go
back to what they were.
with P.batch():
P.phase1_voltage = [5, 5, 2.5, 2.5]
P.phase1_duration[3] = 0.002
In param sync mode, the device stores the parameters for the next
rising edge on the param sync channel (see
PulsePalDevice.trigger_mode).
923 def sync_to_device(self): 924 """Program the device with this object's copy of all parameters. 925 926 Sends every output and trigger parameter to the device in a single 927 command. Use it after assignments made with 928 `PulsePalDevice.auto_sync` off; with it on, they have already 929 reached the device. 930 931 ```python 932 P.auto_sync = False 933 P.phase1_voltage[1:5] = [5] * 4 934 P.sync_to_device() 935 ``` 936 937 On Pulse Pal 3, if either trigger channel is in param sync mode 938 (`PulsePalDevice.trigger_mode` `"Param Sync"`), the device stores 939 the parameters instead of programming them, and loads them on the 940 next rising edge of that channel. The device still checks every 941 value. This is the only method whose effect is deferred that way. 942 943 Raises: 944 PulsePalError: If a value is out of range for its parameter 945 (nothing is sent), or the device does not acknowledge the 946 command. 947 """ 948 self._check_sync() 949 self._send_sync()
Program the device with this object's copy of all parameters.
Sends every output and trigger parameter to the device in a single
command. Use it after assignments made with
PulsePalDevice.auto_sync off; with it on, they have already
reached the device.
P.auto_sync = False
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 "Param Sync"), the device stores
the parameters instead of programming them, and loads them on the
next rising edge of that channel. The device still checks every
value. 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.
951 def sync_from_device(self): 952 """Read all parameters from the device into this object's copy. 953 954 Overwrites the parameter lists with the program currently on the 955 device, e.g. after it has been reprogrammed from its thumb 956 joystick. `PulsePalDevice.continuous_loop` is left as it is: the 957 device does not report it. Changes made with 958 `PulsePalDevice.auto_sync` off and not yet sent are discarded, but 959 for `PulsePalDevice.continuous_loop`. 960 961 Requires firmware v22 or newer. 962 963 Raises: 964 PulsePalError: If the connected firmware is older than v22, 965 or the device does not return the full parameter set. 966 """ 967 self._require_firmware(22, "sync_from_device()") 968 for name, values in self._read_device_params().items(): 969 getattr(self, name)._store(values) 970 self._unsent = self._unsent & {"continuous_loop"} # Not read: a change to it is still unsent
Read all parameters from the device into this object's copy.
Overwrites the parameter lists with the program currently on the
device, e.g. after it has been reprogrammed from its thumb
joystick. PulsePalDevice.continuous_loop is left as it is: the
device does not report it. Changes made with
PulsePalDevice.auto_sync off and not yet sent are discarded, but
for PulsePalDevice.continuous_loop.
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.
972 def set_default_params(self): 973 """Program the device with the default parameters. 974 975 The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart 976 (about 909 a second), for 1 second, resting at 0 V, on all four 977 output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms 978 after 0.1 ms at rest. No output channel is linked to a trigger 979 channel, so a TTL starts nothing until a script links it, and both 980 trigger channels are in normal mode. They are the device's own 981 defaults, which it also loads at startup, after a comm failure 982 and after formatting its microSD card. They are sent at once, 983 also while `PulsePalDevice.auto_sync` is off. The constructor 984 calls it. 985 """ 986 for name, value in _DEFAULT_OUTPUT_VALUES.items(): 987 getattr(self, name)._store([value] * 4) 988 self._trigger_mode._store(["Normal"] * 2) 989 if self._has_param_sync(): 990 # A device left in param sync mode would store the sync below instead of running it, 991 # leaving the device on its old program until a TTL arrived. A trigger mode sent on 992 # its own is not deferred that way, so both trigger channels leave param sync mode 993 # first. See PulsePalDevice.trigger_mode. 994 self._send_trigger_modes(["Normal"] * 2, "set_default_params()") 995 self._send_sync()
Program the device with the default parameters.
The defaults are monophasic +5 V pulses of 0.1 ms, 1 ms apart
(about 909 a second), for 1 second, resting at 0 V, on all four
output channels; for biphasic pulses, a phase 2 of -5 V and 0.1 ms
after 0.1 ms at rest. No output channel is linked to a trigger
channel, so a TTL starts nothing until a script links it, and both
trigger channels are in normal mode. They are the device's own
defaults, which it also loads at startup, after a comm failure
and after formatting its microSD card. They are sent at once,
also while PulsePalDevice.auto_sync is off. The constructor
calls it.
997 def export_params(self): 998 """Return every parameter, as a dict of plain lists. 999 1000 Keyed by parameter name (`DeviceInfo.output_parameter_names`, then 1001 `"trigger_mode"`), with one value per channel and no unused index 1002 0. It holds only numbers, booleans and names, so it can be saved 1003 with `json` and logged with your data, to record exactly what the 1004 device played. `PulsePalDevice.import_params` programs it again. 1005 1006 ```python 1007 import json 1008 with open("trial_params.json", "w") as f: 1009 json.dump(P.export_params(), f) 1010 ``` 1011 """ 1012 names = [*self.info.output_parameter_names, "trigger_mode"] 1013 return {name: list(getattr(self, name))[1:] for name in names}
Return every parameter, as a dict of plain lists.
Keyed by parameter name (DeviceInfo.output_parameter_names, then
"trigger_mode"), with one value per channel and no unused index
- It holds only numbers, booleans and names, so it can be saved
with
jsonand logged with your data, to record exactly what the device played.PulsePalDevice.import_paramsprograms it again.
import json
with open("trial_params.json", "w") as f:
json.dump(P.export_params(), f)
1015 def import_params(self, params): 1016 """Program the device with parameters exported by `PulsePalDevice.export_params`. 1017 1018 All of them are checked first, and then sent in one command, as 1019 at the end of a `PulsePalDevice.batch` block: in param sync mode, 1020 the device stores them for the next sync edge. Parameters missing 1021 from `params` keep their values. A name that is not a parameter 1022 is skipped with a warning, so that parameters exported by a newer 1023 version of this package, which may have more, can still be 1024 imported; if none is, an error is raised. 1025 1026 A whole program file, as the GUIs save it, is imported too: its 1027 parameters, not its custom trains (a warning says so, if it has 1028 any). 1029 1030 ```python 1031 with open("MyProgram.json") as f: 1032 P.import_params(json.load(f)) 1033 ``` 1034 1035 Args: 1036 params: A dict of parameter name to one value per channel (a 1037 list, tuple or NumPy array), as `export_params` returns, 1038 or a program file's contents. The MATLAB class's names 1039 (`phase1Voltage`) are accepted too, so that the parameters 1040 its `exportParams()` returns, saved with `jsonencode`, can 1041 be imported. 1042 1043 Raises: 1044 PulsePalError: If a value is invalid, or no name in `params` is 1045 a parameter. Nothing is sent, and the parameters keep the 1046 values they had. 1047 """ 1048 params = _params_to_import(params) 1049 with self.batch(): 1050 for name, values in params.items(): 1051 setattr(self, name, values)
Program the device with parameters exported by PulsePalDevice.export_params.
All of them are checked first, and then sent in one command, as
at the end of a PulsePalDevice.batch block: in param sync mode,
the device stores them for the next sync edge. Parameters missing
from params keep their values. A name that is not a parameter
is skipped with a warning, so that parameters exported by a newer
version of this package, which may have more, can still be
imported; if none is, an error is raised.
A whole program file, as the GUIs save it, is imported too: its parameters, not its custom trains (a warning says so, if it has any).
with open("MyProgram.json") as f:
P.import_params(json.load(f))
Arguments:
- params: A dict of parameter name to one value per channel (a
list, tuple or NumPy array), as
export_paramsreturns, or a program file's contents. The MATLAB class's names (phase1Voltage) are accepted too, so that the parameters itsexportParams()returns, saved withjsonencode, can be imported.
Raises:
- PulsePalError: If a value is invalid, or no name in
paramsis a parameter. Nothing is sent, and the parameters keep the values they had.
1053 def set_fixed_voltage(self, channels, voltage): 1054 """Set output channels to a fixed voltage. 1055 1056 Each channel holds the voltage until it is set again or until a 1057 pulse train is triggered on it. 1058 1059 ```python 1060 P.set_fixed_voltage(4, 2.5) 1061 P.set_fixed_voltage([1, 3], -1) 1062 ``` 1063 1064 Args: 1065 channels: Output channel number, 1-4, or several as a list, 1066 tuple or NumPy array. 1067 voltage: Voltage to set on each of them, in volts [-10, 10]. 1068 1069 Raises: 1070 PulsePalError: If a channel number is not 1-4, the voltage is 1071 outside [-10, 10] (nothing is sent), or the device does not 1072 acknowledge the command. 1073 """ 1074 channels = _common.channel_numbers(channels) 1075 voltage_bits = self._volts_to_bits(voltage, "voltage") 1076 for channel in channels: 1077 self._write_serial( 1078 (self._OP_MENU_BYTE, 79, channel), 1079 "uint8", 1080 voltage_bits, 1081 "uint16", 1082 ) 1083 self._read_ack("set_fixed_voltage()")
Set output channels to a fixed voltage.
Each channel holds the voltage until it is set again or until a pulse train is triggered on it.
P.set_fixed_voltage(4, 2.5)
P.set_fixed_voltage([1, 3], -1)
Arguments:
- channels: Output channel number, 1-4, or several as a list, tuple or NumPy array.
- voltage: Voltage to set on each of them, in volts [-10, 10].
Raises:
- PulsePalError: If a channel number is not 1-4, the voltage is outside [-10, 10] (nothing is sent), or the device does not acknowledge the command.
1085 def set_calibration(self, channel, voltage_offset): 1086 """Calibrate the zero code of an output channel. 1087 1088 The offset is added to every voltage the channel produces, to 1089 correct for DAC offset error. It is stored in the device's 1090 EEPROM and reloaded on boot, so it only needs to be set once. 1091 1092 Requires Pulse Pal hardware v3 or newer. 1093 1094 Args: 1095 channel: Output channel number, 1-4. 1096 voltage_offset: Offset to apply, in volts [-0.1, 0.1]. 1097 1098 Raises: 1099 PulsePalError: If the connected hardware is older than v3, 1100 `channel` is not 1-4, `voltage_offset` is outside 1101 [-0.1, 0.1], or the device does not acknowledge the 1102 command. 1103 """ 1104 if self.info.hardware_version < 3: 1105 raise PulsePalError( 1106 "set_calibration() requires hardware v3 or newer." 1107 ) 1108 if not (isinstance(channel, numbers.Integral) and channel in (1, 2, 3, 4)): 1109 raise PulsePalError("channel must be 1, 2, 3 or 4") 1110 if not (isinstance(voltage_offset, numbers.Real) and -0.1 <= voltage_offset <= 0.1): # Also refuses NaN 1111 raise PulsePalError( 1112 "voltage_offset for zero code calibration must be in range " 1113 "[-0.1, 0.1]" 1114 ) 1115 # To the nearest DAC code, halfway values to the even one, as the MATLAB class rounds it 1116 voltage_bits = int(round(voltage_offset * (1 / (20 / 65536)))) 1117 self._write_serial( 1118 (self._OP_MENU_BYTE, 96, channel - 1), 1119 "uint8", 1120 voltage_bits, 1121 "int16", 1122 ) 1123 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,
channelis not 1-4,voltage_offsetis outside [-0.1, 0.1], or the device does not acknowledge the command.
1125 def set_screen_saver(self, enabled, timeout=1800): 1126 """Switch the device's screen saver on or off, and set its timeout. 1127 1128 With the screen saver on, the device dims its screen once it has 1129 been left alone for `timeout` seconds: no command from the 1130 computer, no rising edge on a trigger channel, and no joystick 1131 click or push. The next of these brings the screen back. Both 1132 settings are stored in the device's EEPROM and kept through power 1133 cycles; the screen saver can also be switched on and off from the 1134 device's joystick menu. A new device has it on, with 1800 s. 1135 1136 The device saves the settings once no channel is playing. Saving 1137 pauses its timer, usually for about 20 us but, once in about 2000 1138 changes, for tens of milliseconds, which would delay a trigger. 1139 Change the settings before an experiment rather than during one. 1140 1141 Requires firmware v22 or newer. Only Pulse Pal 3 has a screen 1142 saver: Pulse Pal 2 accepts `enabled=False` only. 1143 1144 ```python 1145 P.set_screen_saver(True, 300) # dim after 5 minutes 1146 P.set_screen_saver(False) 1147 ``` 1148 1149 Args: 1150 enabled: True (or 1) to switch the screen saver on, False (or 1151 0) to switch it off. 1152 timeout: Seconds without activity before the screen dims, a 1153 whole number from 1 to 65535. It is sent with every call, 1154 so leaving it out sets 1800 s. 1155 1156 Raises: 1157 PulsePalError: If the firmware is older than v22, the 1158 screen saver is switched on with hardware older than v3, 1159 a value is out of range, or the device does not 1160 acknowledge the command. 1161 """ 1162 self._require_firmware(22, "set_screen_saver()") 1163 enabled = to_bool(enabled, "enabled") 1164 if not ( 1165 isinstance(timeout, numbers.Real) 1166 and not isinstance(timeout, bool) 1167 and math.isfinite(timeout) 1168 and timeout == int(timeout) 1169 and 1 <= timeout <= 65535 1170 ): 1171 raise PulsePalError( 1172 "timeout must be a whole number of seconds from 1 to 65535" 1173 ) 1174 if enabled and (self.info.hardware_version or 2) < 3: 1175 raise PulsePalError( 1176 "The screen saver requires hardware v3 or newer: Pulse Pal 2 " 1177 "accepts set_screen_saver(False) only." 1178 ) 1179 self._write_serial( 1180 (self._OP_MENU_BYTE, 99, int(enabled)), 1181 "uint8", 1182 int(timeout), 1183 "uint16", 1184 ) 1185 self._read_ack("set_screen_saver()")
Switch the device's screen saver on or off, and set its timeout.
With the screen saver on, the device dims its screen once it has
been left alone for timeout seconds: no command from the
computer, no rising edge on a trigger channel, and no joystick
click or push. The next of these brings the screen back. Both
settings are stored in the device's EEPROM and kept through power
cycles; the screen saver can also be switched on and off from the
device's joystick menu. A new device has it on, with 1800 s.
The device saves the settings once no channel is playing. Saving pauses its timer, usually for about 20 us but, once in about 2000 changes, for tens of milliseconds, which would delay a trigger. Change the settings before an experiment rather than during one.
Requires firmware v22 or newer. Only Pulse Pal 3 has a screen
saver: Pulse Pal 2 accepts enabled=False only.
P.set_screen_saver(True, 300) # dim after 5 minutes
P.set_screen_saver(False)
Arguments:
- enabled: True (or 1) to switch the screen saver on, False (or 0) to switch it off.
- timeout: Seconds without activity before the screen dims, a whole number from 1 to 65535. It is sent with every call, so leaving it out sets 1800 s.
Raises:
- PulsePalError: If the firmware is older than v22, the screen saver is switched on with hardware older than v3, a value is out of range, or the device does not acknowledge the command.
1191 def send_custom_pulse_train( 1192 self, 1193 custom_train_id, 1194 pulse_times, 1195 voltages, 1196 ): 1197 """Load a custom pulse train onto the device. 1198 1199 A custom pulse train is an arbitrary list of pulse onset times 1200 and voltages, replacing the parametric pulse voltage and onset 1201 timing. Set `PulsePalDevice.custom_train_id` on an output channel 1202 to play the train there. Each pulse takes the channel's own phase 1203 durations; a biphasic pulse's second phase is its voltage with the 1204 sign reversed. A biphasic pulse always plays whole, and a pulse 1205 whose time comes while it plays is dropped, so that every pulse 1206 played is on time. 1207 1208 ```python 1209 P.send_custom_pulse_train( 1210 2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10] 1211 ) 1212 P.custom_train_id[1] = 2 1213 ``` 1214 1215 Loading a train stops the output channels playing it, and their 1216 next trigger plays the new train. On Pulse Pal 3, while a trigger 1217 channel is in param sync mode (see `PulsePalDevice.trigger_mode`), 1218 the device stores the train instead, as it stores the parameters 1219 `PulsePalDevice.sync_to_device` sends: channels go on playing the 1220 old train, and the next rising edge on the param sync channel 1221 makes the new one current. A channel playing the old train at that 1222 edge finishes it, and plays the new one from its next trigger. So 1223 the next trial's custom trains can be loaded during the current 1224 trial. Leaving param sync mode makes a stored train current at 1225 once. 1226 1227 Args: 1228 custom_train_id: Custom train to load, from 1 to 1229 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1230 4 on Pulse Pal 3). 1231 pulse_times: Pulse onset times, in seconds, relative to the 1232 start of the train, increasing. Each is a multiple of 1233 `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 1234 100 us on Pulse Pal 2), from 0 to 1235 `MAX_TIME`. Accepts a list, tuple or 1-D NumPy array. 1236 voltages: Voltage of each pulse, in volts [-10, 10]. Must be 1237 the same length as `pulse_times`. 1238 1239 Raises: 1240 PulsePalError: If `custom_train_id` is out of range, 1241 `pulse_times` or `voltages` is not one-dimensional (a 2-D 1242 array is refused, not flattened), they differ in length, 1243 there are no pulses, or more than 1244 `DeviceInfo.max_custom_pulses`, a pulse time is not a 1245 multiple of `DeviceInfo.min_pulse_width_us`, is out of 1246 range or is not later than the one before it, or the 1247 device does not acknowledge the command. 1248 """ 1249 pulse_times_cycles = self._custom_times_to_cycles(pulse_times, "pulse_times") 1250 pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages") 1251 if len(pulse_times_cycles) != len(pulse_voltage_bits): 1252 raise PulsePalError( 1253 "pulse_times and voltages must be the same length." 1254 ) 1255 self._send_custom_train( 1256 custom_train_id, 1257 pulse_times_cycles, 1258 pulse_voltage_bits, 1259 "send_custom_pulse_train()", 1260 )
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. Each pulse takes the channel's own phase
durations; a biphasic pulse's second phase is its voltage with the
sign reversed. A biphasic pulse always plays whole, and a pulse
whose time comes while it plays is dropped, so that every pulse
played is on time.
P.send_custom_pulse_train(
2, [0, 0.2, 0.5, 1], [8, 4, -3.5, -10]
)
P.custom_train_id[1] = 2
Loading a train stops the output channels playing it, and their
next trigger plays the new train. On Pulse Pal 3, while a trigger
channel is in param sync mode (see PulsePalDevice.trigger_mode),
the device stores the train instead, as it stores the parameters
PulsePalDevice.sync_to_device sends: channels go on playing the
old train, and the next rising edge on the param sync channel
makes the new one current. A channel playing the old train at that
edge finishes it, and plays the new one from its next trigger. So
the next trial's custom trains can be loaded during the current
trial. Leaving param sync mode makes a stored train current at
once.
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, increasing. Each is a multiple of
DeviceInfo.min_pulse_width_us(10 us on Pulse Pal 3, 100 us on Pulse Pal 2), from 0 toMAX_TIME. Accepts a list, tuple or 1-D NumPy array. - 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_timesorvoltagesis not one-dimensional (a 2-D array is refused, not flattened), they differ in length, there are no pulses, or more thanDeviceInfo.max_custom_pulses, a pulse time is not a multiple ofDeviceInfo.min_pulse_width_us, is out of range or is not later than the one before it, or the device does not acknowledge the command.
1262 def send_custom_waveform( 1263 self, 1264 custom_train_id, 1265 sampling_period, 1266 voltages, 1267 ): 1268 """Load an arbitrary waveform onto the device. 1269 1270 A convenience shorthand for 1271 `PulsePalDevice.send_custom_pulse_train` with evenly spaced, 1272 adjoining pulses, so that `voltages` is played as a waveform 1273 sampled every `sampling_period` seconds. It is loaded as a custom 1274 train is: see `PulsePalDevice.send_custom_pulse_train` for what 1275 happens to channels playing it, and in param sync mode. 1276 1277 Set the channel's `PulsePalDevice.phase1_duration` to 1278 `sampling_period` as well, so that each sample is held for the 1279 sampling period. 1280 1281 ```python 1282 import math 1283 1284 samples = [math.sin(i / 10.0) * 10 for i in range(1000)] 1285 P.send_custom_waveform(1, 0.001, samples) # 1 kHz 1286 P.custom_train_id[2] = 1 1287 P.phase1_duration[2] = 0.001 1288 ``` 1289 1290 Args: 1291 custom_train_id: Custom train to load, from 1 to 1292 `DeviceInfo.n_custom_pulse_trains` (2 on Pulse Pal 2, 1293 4 on Pulse Pal 3). 1294 sampling_period: In seconds: a multiple of 1295 `DeviceInfo.min_pulse_width_us` (10 us on Pulse Pal 3, 1296 100 us on Pulse Pal 2). Each voltage is 1297 held for this long. 1298 voltages: Waveform samples, in volts [-10, 10]. 1299 Accepts a list, tuple or 1-D NumPy array. 1300 1301 Raises: 1302 PulsePalError: If `custom_train_id` is out of range, there 1303 are no samples, or more than 1304 `DeviceInfo.max_custom_pulses`, `sampling_period` is not a 1305 positive multiple of `DeviceInfo.min_pulse_width_us`, the 1306 last sample would start after `MAX_TIME`, or the device 1307 does not acknowledge the command. 1308 """ 1309 pulse_voltage_bits = self._volts_array_to_bits(voltages, "voltages") 1310 period_cycles = self._custom_time_cycles(sampling_period, "sampling_period") 1311 if period_cycles == 0: 1312 raise PulsePalError( 1313 f"sampling_period must be a positive multiple of {self.info.min_pulse_width_us:g} us. " 1314 f"Received {sampling_period!r}." 1315 ) 1316 pulse_times = period_cycles * np.arange(len(pulse_voltage_bits), dtype=np.int64) 1317 self._send_custom_train( 1318 custom_train_id, 1319 pulse_times, 1320 pulse_voltage_bits, 1321 "send_custom_waveform()", 1322 )
Load an arbitrary waveform onto the device.
A convenience shorthand for
PulsePalDevice.send_custom_pulse_train with evenly spaced,
adjoining pulses, so that voltages is played as a waveform
sampled every sampling_period seconds. It is loaded as a custom
train is: see PulsePalDevice.send_custom_pulse_train for what
happens to channels playing it, and in param sync mode.
Set the channel's PulsePalDevice.phase1_duration to
sampling_period 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.custom_train_id[2] = 1
P.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). - sampling_period: In seconds: a multiple of
DeviceInfo.min_pulse_width_us(10 us on Pulse Pal 3, 100 us on Pulse Pal 2). Each voltage is held for this long. - voltages: Waveform samples, in volts [-10, 10]. Accepts a list, tuple or 1-D NumPy array.
Raises:
- PulsePalError: If
custom_train_idis out of range, there are no samples, or more thanDeviceInfo.max_custom_pulses,sampling_periodis not a positive multiple ofDeviceInfo.min_pulse_width_us, the last sample would start afterMAX_TIME, or the device does not acknowledge the command.
1324 def trigger(self, channels): 1325 """Trigger output channels in software. 1326 1327 The channels start their pulse trains together, in the same timer 1328 cycle. A channel that is already playing a pulse train ignores 1329 the trigger, and `PulsePalDevice.stop` cancels a trigger that has 1330 not started its channel yet. 1331 1332 ```python 1333 P.trigger(3) # one channel 1334 P.trigger([1, 4]) # several: a list, tuple or NumPy array 1335 ``` 1336 1337 Args: 1338 channels: Output channel number, 1-4, or several as a list, 1339 tuple or NumPy array. 1340 1341 Raises: 1342 PulsePalError: If a channel number is not 1-4, or no channel 1343 is given. Nothing is triggered. 1344 """ 1345 self._write_serial((self._OP_MENU_BYTE, 77, _common.channel_bits(channels)), "uint8")
Trigger output channels in software.
The channels start their pulse trains together, in the same timer
cycle. A channel that is already playing a pulse train ignores
the trigger, and PulsePalDevice.stop cancels a trigger that has
not started its channel yet.
P.trigger(3) # one channel
P.trigger([1, 4]) # several: a list, tuple or NumPy array
Arguments:
- channels: Output channel number, 1-4, or several as a list, tuple or NumPy array.
Raises:
- PulsePalError: If a channel number is not 1-4, or no channel is given. Nothing is triggered.
1347 def stop(self, channels=None): 1348 """Stop pulse trains currently playing on the device. 1349 1350 The stopped channels return to their 1351 `PulsePalDevice.resting_voltage`. A soft trigger that has not 1352 started its channel yet is cancelled too. 1353 1354 ```python 1355 P.stop() # all output channels 1356 P.stop([1, 3, 4]) # channels 1, 3 and 4 1357 ``` 1358 1359 Args: 1360 channels: Output channel number, 1-4, or several as a list, 1361 tuple or NumPy array. `None`, the default, stops all 1362 channels. Stopping some channels requires firmware v22 1363 or newer. 1364 1365 Raises: 1366 PulsePalError: If a channel number is not 1-4, or channels are 1367 given and the firmware is older than v22. 1368 """ 1369 if self.info.firmware_version < 22: 1370 if channels is not None: 1371 raise PulsePalError("stop() cannot address individual channels before firmware v22") 1372 self._write_serial((self._OP_MENU_BYTE, 80), "uint8") # Stops all channels 1373 return 1374 bits = 0x0F if channels is None else _common.channel_bits(channels) 1375 self._write_serial((self._OP_MENU_BYTE, 98, bits), "uint8")
Stop pulse trains currently playing on the device.
The stopped channels return to their
PulsePalDevice.resting_voltage. A soft trigger that has not
started its channel yet is cancelled too.
P.stop() # all output channels
P.stop([1, 3, 4]) # channels 1, 3 and 4
Arguments:
- channels: Output channel number, 1-4, or several as a list,
tuple or NumPy array.
None, the default, stops all channels. Stopping some channels requires firmware v22 or newer.
Raises:
- PulsePalError: If a channel number is not 1-4, or channels are given and the firmware is older than v22.
1377 def status(self): 1378 """Read which channels are playing, and the counters since the last call. 1379 1380 The counters (see `DeviceStatus`) count from the previous call, or 1381 from the device's startup, and each call clears them. Calling it 1382 before and after a trial checks that the TTLs the trial sent 1383 arrived, and started the trains they should: 1384 1385 ```python 1386 P.status() # Clears the counters 1387 run_trial() # Your code: one TTL on trigger channel 1, linked to channels 1 and 2 1388 s = P.status() 1389 if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]: 1390 print("The trial's trains did not all start") 1391 ``` 1392 1393 Requires firmware v22 or newer. 1394 1395 Returns: 1396 A `DeviceStatus`. 1397 1398 Raises: 1399 PulsePalError: If the firmware is older than v22, or the device 1400 does not reply. 1401 """ 1402 self._require_firmware(22, "status()") 1403 self._write_serial((self._OP_MENU_BYTE, 101), "uint8") 1404 values = struct.unpack(f"{self._ENDIANNESS}B7I", self._read_raw(self._STATUS_MESSAGE_BYTES)) 1405 return DeviceStatus( 1406 playing=[ch for ch in range(1, 5) if values[0] & (1 << (ch - 1))], 1407 trigger_edges=[None, *values[1:3]], 1408 channel_starts=[None, *values[3:7]], 1409 comm_failures=values[7], 1410 )
Read which channels are playing, and the counters since the last call.
The counters (see DeviceStatus) count from the previous call, or
from the device's startup, and each call clears them. Calling it
before and after a trial checks that the TTLs the trial sent
arrived, and started the trains they should:
P.status() # Clears the counters
run_trial() # Your code: one TTL on trigger channel 1, linked to channels 1 and 2
s = P.status()
if s.trigger_edges[1] != 1 or s.channel_starts[1:3] != [1, 1]:
print("The trial's trains did not all start")
Requires firmware v22 or newer.
Returns:
A
DeviceStatus.
Raises:
- PulsePalError: If the firmware is older than v22, or the device does not reply.
1412 def save_program_to_microsd(self, file_name): 1413 """Save the parameters to the device's microSD card, as a program. 1414 1415 The program can be loaded later with 1416 `PulsePalDevice.load_program_from_microsd` or with the joystick 1417 menu's Load Program item. A file of the same name is replaced. To 1418 keep the parameters on this computer instead, see 1419 `PulsePalDevice.export_params`. 1420 1421 ```python 1422 P.save_program_to_microsd("MyProtocol.pps") 1423 ``` 1424 1425 Args: 1426 file_name: At most 15 ASCII characters, ending in `.pps`. 1427 1428 Raises: 1429 PulsePalError: If the file name is invalid, or the device could 1430 not write the file to its microSD card. Pulse Pal 2 also 1431 refuses while a channel is playing: its card shares a bus with 1432 the outputs, so stop playback first. 1433 """ 1434 self._settings_file_op(file_name, 1, "save_program_to_microsd()")
Save the parameters to the device's microSD card, as a program.
The program can be loaded later with
PulsePalDevice.load_program_from_microsd or with the joystick
menu's Load Program item. A file of the same name is replaced. To
keep the parameters on this computer instead, see
PulsePalDevice.export_params.
P.save_program_to_microsd("MyProtocol.pps")
Arguments:
- file_name: At most 15 ASCII characters, ending in
.pps.
Raises:
- PulsePalError: If the file name is invalid, or the device could not write the file to its microSD card. Pulse Pal 2 also refuses while a channel is playing: its card shares a bus with the outputs, so stop playback first.
1436 def load_program_from_microsd(self, file_name): 1437 """Load a program from the device's microSD card. 1438 1439 The device plays the program, and the parameters are read back 1440 into this object (see `PulsePalDevice.sync_from_device`). The 1441 program's trigger modes take effect too, and it is loaded at once, 1442 also in param sync mode. 1443 1444 Args: 1445 file_name: The program's file name, ending in `.pps`, as saved 1446 by `PulsePalDevice.save_program_to_microsd` or the joystick 1447 menu. 1448 1449 Raises: 1450 PulsePalError: If the file name is invalid, or the device could 1451 not load the program: there is no such file on its microSD 1452 card, or it is not a valid Pulse Pal program; or, on Pulse 1453 Pal 2, a channel is playing (see `save_program_to_microsd`). 1454 The device then keeps its parameters, and so does this object. 1455 """ 1456 self._settings_file_op(file_name, 2, "load_program_from_microsd()")
Load a program from the device's microSD card.
The device plays the program, and the parameters are read back
into this object (see PulsePalDevice.sync_from_device). The
program's trigger modes take effect too, and it is loaded at once,
also in param sync mode.
Arguments:
- file_name: The program's file name, ending in
.pps, as saved byPulsePalDevice.save_program_to_microsdor the joystick menu.
Raises:
- PulsePalError: If the file name is invalid, or the device could
not load the program: there is no such file on its microSD
card, or it is not a valid Pulse Pal program; or, on Pulse
Pal 2, a channel is playing (see
save_program_to_microsd). The device then keeps its parameters, and so does this object.
1458 def delete_program_from_microsd(self, file_name): 1459 """Delete a program from the device's microSD card. 1460 1461 Args: 1462 file_name: The program's file name, ending in `.pps`. 1463 1464 Raises: 1465 PulsePalError: If the file name is invalid, or the device could 1466 not delete the file, e.g. because there is no such file, or, 1467 on Pulse Pal 2, because a channel is playing. 1468 """ 1469 self._settings_file_op(file_name, 3, "delete_program_from_microsd()")
Delete a program from the device's microSD card.
Arguments:
- file_name: The program's file name, ending in
.pps.
Raises:
- PulsePalError: If the file name is invalid, or the device could not delete the file, e.g. because there is no such file, or, on Pulse Pal 2, because a channel is playing.
1513 def format_microsd(self, *, confirm=True, timeout=30): 1514 """Format the device's microSD card. 1515 1516 Erases every program saved on the device and resets its 1517 parameters to the defaults. By default, asks at the console for 1518 confirmation first; a script that runs on its own, or an AI agent, 1519 passes `confirm=False`. 1520 1521 Requires Pulse Pal hardware v3 or newer. 1522 1523 Args: 1524 confirm: If `True`, ask at the console before anything is 1525 erased. If `False`, format at once. 1526 timeout: Seconds to wait for the device to report that 1527 formatting has finished. 1528 1529 Returns: 1530 `True` once the card has been formatted, or `False` if the 1531 user declines the confirmation prompt. 1532 1533 Raises: 1534 PulsePalError: If the connected hardware is older than v3, if 1535 the device reports that formatting failed, or if it does 1536 not report a result within `timeout` seconds. 1537 """ 1538 if self.info.hardware_version < 3: 1539 raise PulsePalError( 1540 "format_microsd() requires hardware v3 or newer." 1541 ) 1542 1543 if to_bool(confirm, "confirm"): 1544 print("*** Pulse Pal microSD Formatter ***") 1545 print("This will format Pulse Pal's microSD card,") 1546 print("erase all programs saved on the device") 1547 print("and reset all parameters to defaults.") 1548 reply = input("Do you want to continue (y/n)") 1549 if reply.strip().lower() != "y": 1550 print("Choice confirmed - microSD Card NOT formatted.") 1551 return False 1552 1553 self._write_serial((self._OP_MENU_BYTE, 97), "uint8", list(_FORMAT_KEY), "uint8") 1554 1555 # The device replies with lines of status text, the last of which 1556 # contains "!", and then a confirm byte (1 if the card was formatted, 1557 # 0 if not). The confirm byte is sent after the device has reloaded 1558 # its default parameters, so it can arrive well after the text. It 1559 # must be read here: left in the buffer, it would be taken as the 1560 # reply to the next command, and every reply after that would be 1561 # read one byte late. 1562 start = time.time() 1563 message = bytearray() 1564 flag_index = -1 1565 line_end = -1 1566 1567 while time.time() - start < timeout: 1568 n_waiting = self.port.in_waiting 1569 if n_waiting: 1570 message.extend(self.port.read(n_waiting)) 1571 flag_index = message.find(b"!") 1572 if flag_index >= 0: 1573 line_end = message.find(b"\n", flag_index) 1574 if line_end >= 0 and len(message) > line_end + 1: 1575 break 1576 time.sleep(0.01) 1577 if line_end < 0 or len(message) <= line_end + 1: 1578 raise PulsePalError( 1579 "Pulse Pal did not report the result of formatting " 1580 f"its microSD card within {timeout} s. {_common.NO_REPLY_HINT}" 1581 ) 1582 self._reply_pending = None # The whole reply is read (see _write_raw()) 1583 confirm_byte = message[line_end + 1] 1584 1585 text = bytes(message[:flag_index]).decode( 1586 "ascii", errors="replace" 1587 ).rstrip() 1588 if text: 1589 print(text) 1590 1591 # The device has loaded its own defaults. They are this class's, but the device keeps no record of 1592 # this object's other changes (continuous loop mode, for one), so the defaults are programmed, as the 1593 # MATLAB class does, and this object describes the device again. 1594 self.set_default_params() 1595 if confirm_byte != 1: 1596 raise PulsePalError( 1597 "Pulse Pal could not format its microSD card." 1598 ) 1599 return True
Format the device's microSD card.
Erases every program saved on the device and resets its
parameters to the defaults. By default, asks at the console for
confirmation first; a script that runs on its own, or an AI agent,
passes confirm=False.
Requires Pulse Pal hardware v3 or newer.
Arguments:
- confirm: If
True, ask at the console before anything is erased. IfFalse, format at once. - timeout: Seconds to wait for the device to report that formatting has finished.
Returns:
Trueonce the card has been formatted, orFalseif 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.
1601 def gui(self, block=None, theme=None): 1602 """Open the Pulse Pal parameter GUI, or focus an open one. 1603 1604 The GUI edits its own copy of the parameters, and loads them to 1605 the device when its 'Load to Device' button is clicked. The 1606 window closes automatically when the device is closed or 1607 deleted. 1608 1609 Calling this while the GUI is already open focuses the existing 1610 window rather than opening a second one. 1611 1612 Args: 1613 block: If `True`, the call returns when the GUI is closed. If 1614 `False`, the call returns immediately, and the host 1615 application must run the Tk event loop. If `None`, the 1616 GUI blocks only when the host does not already provide a 1617 Tk event loop, e.g. when launched from a script. 1618 theme: `"light"` or `"dark"` to select the color theme, or 1619 `None` to match the desktop theme. Passing a theme to an 1620 already-open GUI recolors it in place. 1621 1622 Returns: 1623 The `pulsepal.gui.PulsePalGUI` instance driving the window. 1624 1625 Raises: 1626 ValueError: If the theme name is not recognized. 1627 """ 1628 gui = getattr(self, "_gui", None) 1629 if gui is not None and not gui.is_closed: 1630 if theme is not None: 1631 gui.set_theme(theme) 1632 gui.focus() 1633 return gui 1634 1635 from .gui import PulsePalGUI 1636 1637 gui = PulsePalGUI(self, theme=theme) 1638 self._gui = gui 1639 gui.start(block=block) 1640 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
pulsepal.gui.PulsePalGUIinstance driving the window.
Raises:
- ValueError: If the theme name is not recognized.
1646 def close(self): 1647 """Close the connection to the device, and the GUI if open. 1648 1649 The device stops all output channels when the client disconnects, 1650 and shows its own name on its screen again. Safe to call more 1651 than once; later calls do nothing. Called automatically when 1652 leaving a `with` block and when the object is garbage collected, 1653 so pulse trains also stop when the last reference to the object 1654 goes, e.g. when a function that created it returns. 1655 """ 1656 self._close(send_disconnect=True)
Close the connection to the device, and the GUI if open.
The device stops all output channels when the client disconnects,
and shows its own name on its screen again. Safe to call more
than once; later calls do nothing. Called automatically when
leaving a with block and when the object is garbage collected,
so pulse trains also stop when the last reference to the object
goes, e.g. when a function that created it returns.
220@dataclass 221class DeviceInfo: 222 """Properties of the connected Pulse Pal device. 223 224 An instance is created for each connection and populated during the 225 handshake in `PulsePalDevice.__init__`. It is available as 226 `PulsePalDevice.info`. Devices running firmware v21 report only a 227 firmware version, so the remaining fields are filled in with the 228 known values for Pulse Pal hardware v2. 229 230 ```python 231 print(P.info.firmware_version) 232 ``` 233 """ 234 235 output_parameter_names: tuple = tuple(name for name, _ in _OUTPUT_PARAMETERS.values()) 236 """Output parameter names, ordered by parameter code. 237 238 Each is the name of a parameter list on `PulsePalDevice`, and a key of 239 the dict `PulsePalDevice.export_params` returns. 240 """ 241 242 trigger_modes: tuple = TRIGGER_MODES 243 """Names of the trigger modes the connected device has, accepted by 244 `PulsePalDevice.trigger_mode`. Pulse Pal 2 has no "Param Sync".""" 245 246 custom_train_targets: tuple = CUSTOM_TRAIN_TARGETS 247 """Names accepted by `PulsePalDevice.custom_train_target`.""" 248 249 firmware_version: int = None 250 """Firmware version running on the connected device.""" 251 252 hardware_version: int = None 253 """Hardware version of the connected device, e.g. `2` or `3`. 254 255 Reported by the device on firmware v22 and newer; assumed to be `2` 256 on older firmware. 257 """ 258 259 circuit_board_revision: int = None 260 """Revision of the device's circuit board, 0-31. 261 262 A Pulse Pal 3 board encodes its revision on five pins, which the 263 firmware reads at startup. A board that encodes none reports `0`, as 264 does Pulse Pal 2; firmware older than v22 cannot report one, so it is 265 also `0` there. To record exactly which hardware ran an experiment, 266 copy `hardware_version` and `circuit_board_revision` to your data. 267 """ 268 269 serial_number: str = None 270 """USB serial number of the device, e.g. `"20345420"`: the Teensy's, 271 unique to each Pulse Pal 3, and the same whichever firmware it runs. 272 273 Connect by it, wherever the device is plugged in, with 274 `PulsePalDevice(serial_number="20345420")`. To record exactly which unit ran an 275 experiment, copy it to your data. Pulse Pal 2 has none, and 276 firmware older than v22 cannot report one: `""`. 277 """ 278 279 max_custom_pulses: int = None 280 """Maximum number of pulses in a single custom pulse train.""" 281 282 n_custom_pulse_trains: int = None 283 """Number of custom pulse trains the device can store.""" 284 285 cycle_frequency: float = None 286 """Update frequency of the device's hardware timer, in Hz. 287 288 All time parameters are rounded to a whole number of these cycles, 289 so this sets the timing resolution of the device. 290 """ 291 292 cycle_period_us: float = None 293 """Update period of the device's hardware timer, in microseconds.""" 294 295 min_pulse_width_us: float = None 296 """Shortest pulse phase, inter-pulse interval and pulse train duration, 297 and shortest time between custom pulses, in microseconds. 298 299 One timer cycle (10 us) on Pulse Pal 3, whose trigger channels latch 300 every edge on their inputs. Two cycles (100 us) on Pulse Pal 2, whose 301 trigger channels read their inputs once per cycle, so that a one cycle 302 pulse could fall between two reads. A Pulse Pal 2, or a Pulse Pal 3 303 with firmware from before October 2026, can miss a pulse shorter than 304 100 us from a Pulse Pal 3. Custom pulse times are multiples of it. 305 """ 306 307 max_time: float = MAX_TIME 308 """Longest time parameter or custom pulse time, in seconds. See 309 `MAX_TIME`."""
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.
Each is the name of a parameter list on PulsePalDevice, and a key of
the dict PulsePalDevice.export_params returns.
Names of the trigger modes the connected device has, accepted by
PulsePalDevice.trigger_mode. Pulse Pal 2 has no "Param Sync".
Names accepted by PulsePalDevice.custom_train_target.
Hardware version 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.
Revision of the device's circuit board, 0-31.
A Pulse Pal 3 board encodes its revision on five pins, which the
firmware reads at startup. A board that encodes none reports 0, as
does Pulse Pal 2; firmware older than v22 cannot report one, so it is
also 0 there. To record exactly which hardware ran an experiment,
copy hardware_version and circuit_board_revision to your data.
USB serial number of the device, e.g. "20345420": the Teensy's,
unique to each Pulse Pal 3, and the same whichever firmware it runs.
Connect by it, wherever the device is plugged in, with
PulsePalDevice(serial_number="20345420"). To record exactly which unit ran an
experiment, copy it to your data. Pulse Pal 2 has none, and
firmware older than v22 cannot report one: "".
Update frequency of the device's hardware timer, in Hz.
All time parameters are rounded to a whole number of these cycles, so this sets the timing resolution of the device.
Shortest pulse phase, inter-pulse interval and pulse train duration, and shortest time between custom pulses, in microseconds.
One timer cycle (10 us) on Pulse Pal 3, whose trigger channels latch every edge on their inputs. Two cycles (100 us) on Pulse Pal 2, whose trigger channels read their inputs once per cycle, so that a one cycle pulse could fall between two reads. A Pulse Pal 2, or a Pulse Pal 3 with firmware from before October 2026, can miss a pulse shorter than 100 us from a Pulse Pal 3. Custom pulse times are multiples of it.
312@dataclass 313class DeviceStatus: 314 """What the device is doing, and what has happened to it, from 315 `PulsePalDevice.status`. 316 317 The counters count from the previous call to `PulsePalDevice.status`, 318 by this or any earlier connection, or from the device's startup: each 319 call clears them, and reports what happened since the one before. 320 """ 321 322 playing: list 323 """Numbers of the output channels playing, e.g. `[1, 3]`: from a 324 channel's start, its pulse train delay included, until its train 325 ends.""" 326 327 trigger_edges: list 328 """Rising edges on each trigger channel, indexed by trigger channel 329 number (index 0 is unused), whatever its trigger mode and whether or 330 not they started an output channel.""" 331 332 channel_starts: list 333 """Times each output channel started, indexed by channel number 334 (index 0 is unused): by a trigger channel, `PulsePalDevice.trigger` 335 or the joystick menu. A trigger that a channel ignored, because it 336 was already playing, is not a start.""" 337 338 comm_failures: int 339 """Commands that reached the device only in part, after which it 340 showed COMM. FAILURE! and waited for its joystick to be clicked."""
What the device is doing, and what has happened to it, from
PulsePalDevice.status.
The counters count from the previous call to PulsePalDevice.status,
by this or any earlier connection, or from the device's startup: each
call clears them, and reports what happened since the one before.
Numbers of the output channels playing, e.g. [1, 3]: from a
channel's start, its pulse train delay included, until its train
ends.
Rising edges on each trigger channel, indexed by trigger channel number (index 0 is unused), whatever its trigger mode and whether or not they started an output channel.
Times each output channel started, indexed by channel number
(index 0 is unused): by a trigger channel, PulsePalDevice.trigger
or the joystick menu. A trigger that a channel ignored, because it
was already playing, is not a start.
Names of the trigger modes, in order of their code on the device (0-3).
"Param Sync" is on Pulse Pal 3 only. See PulsePalDevice.trigger_mode.
Names of the custom train targets, in order of their code on the device.
See PulsePalDevice.custom_train_target.
The longest time parameter or custom pulse time, in seconds: the most the device's joystick menu shows and edits (four digits before the point).