stimgrid 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stimgrid/__init__.py +17 -0
- stimgrid/__main__.py +5 -0
- stimgrid/cli.py +177 -0
- stimgrid/devices.py +227 -0
- stimgrid/fit.py +421 -0
- stimgrid/loader.py +129 -0
- stimgrid-0.2.0.dist-info/METADATA +317 -0
- stimgrid-0.2.0.dist-info/RECORD +12 -0
- stimgrid-0.2.0.dist-info/WHEEL +5 -0
- stimgrid-0.2.0.dist-info/entry_points.txt +2 -0
- stimgrid-0.2.0.dist-info/licenses/LICENSE +21 -0
- stimgrid-0.2.0.dist-info/top_level.txt +1 -0
stimgrid/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""stimgrid: fit stimulation pulses to what a stimulator can actually produce.
|
|
2
|
+
|
|
3
|
+
Rounds a planned pulse to a device's amplitude and timing steps, reports what is
|
|
4
|
+
really delivered, and finds the nearest exactly charge-balanced pulse.
|
|
5
|
+
For research use; check results on your own hardware.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .devices import PRESETS, Device, cortical_labs_cl, intan_rhs2116, mcs_stg4000, mcs_stg5, preset, tdt_iz2
|
|
9
|
+
from .fit import Delivered, FitReport, Issue, Pulse, RateFit, Rates, fit
|
|
10
|
+
from .loader import InputError, from_dict, load
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PRESETS", "Delivered", "Device", "FitReport", "InputError", "Issue", "Pulse", "RateFit", "Rates",
|
|
15
|
+
"cortical_labs_cl", "fit", "from_dict", "intan_rhs2116", "load", "mcs_stg4000", "mcs_stg5", "preset",
|
|
16
|
+
"tdt_iz2", "__version__",
|
|
17
|
+
]
|
stimgrid/__main__.py
ADDED
stimgrid/cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
stimgrid fit FILE|- fit the pulse in a request file (or stdin)
|
|
4
|
+
stimgrid quick ... fit a pulse given as flags
|
|
5
|
+
stimgrid devices list the built-in device presets
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 fitted with no errors (and no warnings with --strict), 1 errors
|
|
8
|
+
(or warnings with --strict), 2 bad input or usage, 3 internal error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import inspect
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .devices import PRESETS, Device, override, preset
|
|
21
|
+
from .fit import Delivered, FitReport, Pulse, Rates, fit
|
|
22
|
+
from .loader import InputError, load
|
|
23
|
+
|
|
24
|
+
EXIT_OK, EXIT_ISSUES, EXIT_INPUT, EXIT_INTERNAL = 0, 1, 2, 3
|
|
25
|
+
NOTE = "Values are what the device grid allows; check them on your own hardware. Research use only."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _f(x: Optional[float], digits: int = 6) -> str:
|
|
29
|
+
return "-" if x is None else f"{x:.{digits}g}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _row(label: str, d: Delivered) -> str:
|
|
33
|
+
second = f"{_f(d.second_amplitude_ua)} µA x {_f(d.second_width_us)} µs" if d.biphasic else "(monophasic)"
|
|
34
|
+
return (f" {label:<9} {_f(d.amplitude_ua)} µA x {_f(d.width_us)} µs | gap {_f(d.gap_us)} µs | {second}\n"
|
|
35
|
+
f" {'':<9} charge {_f(d.first_charge_nc, 5)} nC ({d.charge_change:+.2%} vs plan), "
|
|
36
|
+
f"net {_f(d.net_charge_nc, 4)} nC, imbalance {d.imbalance:.4%}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render(report: FitReport) -> str:
|
|
40
|
+
t = report.target
|
|
41
|
+
second = (f"{_f(t['second_amplitude_ua'] or t['amplitude_ua'])} µA x {_f(t['second_width_us'] or t['width_us'])} µs"
|
|
42
|
+
if t["biphasic"] else "(monophasic)")
|
|
43
|
+
dev = report.device
|
|
44
|
+
amp = "continuous" if dev["amplitude_step_ua"] is None else f"{_f(dev['amplitude_step_ua'])} µA steps"
|
|
45
|
+
lines = [
|
|
46
|
+
f"device: {dev['name']}: {_f(dev['time_step_us'])} µs time steps, {amp}"
|
|
47
|
+
+ (f", max {_f(dev['max_amplitude_ua'])} µA" if dev["max_amplitude_ua"] else ""),
|
|
48
|
+
f" plan {_f(t['amplitude_ua'])} µA x {_f(t['width_us'])} µs | gap {_f(t['gap_us'])} µs | {second}",
|
|
49
|
+
_row("rounded", report.rounded),
|
|
50
|
+
]
|
|
51
|
+
if report.balanced is not None:
|
|
52
|
+
lines.append(_row("balanced", report.balanced))
|
|
53
|
+
for i, alt in enumerate(report.alternatives, 1):
|
|
54
|
+
lines.append(_row(f"option {i}", alt))
|
|
55
|
+
for r in report.rates:
|
|
56
|
+
lines.append(f" rate {r.name} {_f(r.target_hz)} Hz -> {_f(r.delivered_hz)} Hz "
|
|
57
|
+
f"({r.period_ticks} steps per period, {r.change:+.3%})")
|
|
58
|
+
lines.append("")
|
|
59
|
+
for i in report.issues:
|
|
60
|
+
lines.append(f"{i.severity.upper():<7} {i.code} {i.message}")
|
|
61
|
+
if report.issues:
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append(f"best pulse: {json.dumps(report.best.as_pulse())}")
|
|
64
|
+
lines.append(f"result: {'OK' if report.ok else 'ERROR'}")
|
|
65
|
+
lines.append(NOTE)
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _emit(report: FitReport, fmt: str, strict: bool) -> int:
|
|
70
|
+
if fmt == "json":
|
|
71
|
+
data = report.to_dict()
|
|
72
|
+
data.update({"tool": "stimgrid", "tool_version": __version__, "note": NOTE})
|
|
73
|
+
print(json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False))
|
|
74
|
+
elif fmt == "pulse":
|
|
75
|
+
print(json.dumps(report.best.as_pulse()))
|
|
76
|
+
else:
|
|
77
|
+
print(render(report))
|
|
78
|
+
failed = (not report.ok) or (strict and bool(report.issues))
|
|
79
|
+
return EXIT_ISSUES if failed else EXIT_OK
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parser() -> argparse.ArgumentParser:
|
|
83
|
+
parser = argparse.ArgumentParser(prog="stimgrid", description="Fit stimulation pulses to what a stimulator can "
|
|
84
|
+
"actually produce, and find the nearest exactly charge-balanced pulse.")
|
|
85
|
+
parser.add_argument("--version", action="version", version=f"stimgrid {__version__}")
|
|
86
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
87
|
+
|
|
88
|
+
def output(p: argparse.ArgumentParser) -> None:
|
|
89
|
+
p.add_argument("--format", choices=("text", "json", "pulse"), default="text",
|
|
90
|
+
help="pulse prints only the best pulse as JSON")
|
|
91
|
+
p.add_argument("--strict", action="store_true", help="treat warnings as failures (exit code 1)")
|
|
92
|
+
|
|
93
|
+
p_fit = sub.add_parser("fit", help="fit the pulse in a request file ('-' reads stdin)")
|
|
94
|
+
p_fit.add_argument("file")
|
|
95
|
+
output(p_fit)
|
|
96
|
+
|
|
97
|
+
q = sub.add_parser("quick", help="fit a pulse given as flags")
|
|
98
|
+
q.add_argument("--device", choices=sorted(PRESETS), help="start from a preset")
|
|
99
|
+
q.add_argument("--step-ua", type=float, help="intan-rhs2116: current step size")
|
|
100
|
+
q.add_argument("--sample-rate-hz", type=float, help="intan-rhs2116, tdt-iz2: sampling rate")
|
|
101
|
+
q.add_argument("--range-ua", type=float, help="mcs-stg4000, mcs-stg5, tdt-iz2: current range (max µA)")
|
|
102
|
+
q.add_argument("--time-step-us", type=float)
|
|
103
|
+
q.add_argument("--amplitude-step-ua", type=float)
|
|
104
|
+
q.add_argument("--max-amplitude-ua", type=float)
|
|
105
|
+
q.add_argument("--max-charge-per-phase-nc", type=float)
|
|
106
|
+
q.add_argument("--amplitude-ua", type=float, required=True)
|
|
107
|
+
q.add_argument("--width-us", type=float, required=True)
|
|
108
|
+
q.add_argument("--second-amplitude-ua", type=float)
|
|
109
|
+
q.add_argument("--second-width-us", type=float)
|
|
110
|
+
q.add_argument("--gap-us", type=float, default=0.0)
|
|
111
|
+
q.add_argument("--monophasic", action="store_true")
|
|
112
|
+
q.add_argument("--frequency-hz", type=float)
|
|
113
|
+
q.add_argument("--intra-burst-hz", type=float)
|
|
114
|
+
q.add_argument("--burst-rate-hz", type=float)
|
|
115
|
+
q.add_argument("--max-imbalance", type=float, default=0.0)
|
|
116
|
+
q.add_argument("--keep-first", action="store_true", help="balance with the second phase only")
|
|
117
|
+
output(q)
|
|
118
|
+
|
|
119
|
+
sub.add_parser("devices", help="list the built-in device presets")
|
|
120
|
+
return parser
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _quick_device(args: argparse.Namespace) -> Device:
|
|
124
|
+
changes = dict(time_step_us=args.time_step_us, amplitude_step_ua=args.amplitude_step_ua,
|
|
125
|
+
max_amplitude_ua=args.max_amplitude_ua, max_charge_per_phase_nc=args.max_charge_per_phase_nc)
|
|
126
|
+
if args.device:
|
|
127
|
+
given = (("step_ua", args.step_ua), ("sample_rate_hz", args.sample_rate_hz), ("range_ua", args.range_ua))
|
|
128
|
+
options = {k: v for k, v in given if v is not None}
|
|
129
|
+
accepted = inspect.signature(PRESETS[args.device]).parameters
|
|
130
|
+
wrong = [k for k in options if k not in accepted]
|
|
131
|
+
if wrong:
|
|
132
|
+
flags = ", ".join("--" + k.replace("_", "-") for k in wrong)
|
|
133
|
+
raise ValueError(f"{flags} does not apply to {args.device}")
|
|
134
|
+
return override(preset(args.device, **options), **changes)
|
|
135
|
+
if args.time_step_us is None:
|
|
136
|
+
raise ValueError("give --device or at least --time-step-us")
|
|
137
|
+
return Device(name="command line", **{k: v for k, v in changes.items() if v is not None})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
141
|
+
args = _parser().parse_args(argv) # usage errors exit with 2
|
|
142
|
+
try:
|
|
143
|
+
if args.command == "devices":
|
|
144
|
+
for name in sorted(PRESETS):
|
|
145
|
+
d = preset(name)
|
|
146
|
+
amp = "continuous amplitude" if d.continuous_amplitude else f"{d.amplitude_step_ua:g} µA steps"
|
|
147
|
+
print(f"{name}: {d.time_step_us:.6g} µs time steps, {amp}\n source: {d.source}")
|
|
148
|
+
if d.note:
|
|
149
|
+
print(f" note: {d.note}")
|
|
150
|
+
return EXIT_OK
|
|
151
|
+
if args.command == "fit":
|
|
152
|
+
pulse, device, rates, options = load(args.file)
|
|
153
|
+
else:
|
|
154
|
+
try:
|
|
155
|
+
device = _quick_device(args)
|
|
156
|
+
pulse = Pulse(args.amplitude_ua, args.width_us, biphasic=not args.monophasic,
|
|
157
|
+
second_amplitude_ua=args.second_amplitude_ua, second_width_us=args.second_width_us,
|
|
158
|
+
gap_us=args.gap_us)
|
|
159
|
+
rates = Rates(args.frequency_hz, args.intra_burst_hz, args.burst_rate_hz)
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
raise InputError(str(exc)) from None
|
|
162
|
+
options = {"max_imbalance": args.max_imbalance, "keep_first": args.keep_first}
|
|
163
|
+
try:
|
|
164
|
+
report = fit(pulse, device, rates, **options)
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
raise InputError(str(exc)) from None
|
|
167
|
+
return _emit(report, args.format, args.strict)
|
|
168
|
+
except (InputError, OSError) as exc:
|
|
169
|
+
print(f"stimgrid: {exc}", file=sys.stderr)
|
|
170
|
+
return EXIT_INPUT
|
|
171
|
+
except Exception as exc: # noqa: BLE001 - report, don't crash with a traceback
|
|
172
|
+
print(f"stimgrid: internal error: {exc!r}", file=sys.stderr)
|
|
173
|
+
return EXIT_INTERNAL
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
sys.exit(main())
|
stimgrid/devices.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Stimulator resolution: the amplitudes and timings a device can actually produce.
|
|
2
|
+
|
|
3
|
+
A device has a timing grid (every phase width, gap and period is a whole number
|
|
4
|
+
of time steps) and an amplitude grid (every current is a whole number of
|
|
5
|
+
amplitude steps), plus optional ranges. Amplitude can also be continuous when a
|
|
6
|
+
device does not publish its step.
|
|
7
|
+
|
|
8
|
+
Built-in presets use only figures from the vendor's own documentation, cited in
|
|
9
|
+
`source`. Check them against your device and its settings before relying on them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
from typing import Callable, Dict, Optional
|
|
17
|
+
|
|
18
|
+
INTAN_RHS2116_STEPS_UA = (0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0)
|
|
19
|
+
INTAN_SOURCE = ("Intan Technologies RHS2116 datasheet: global current step sizes 10 nA to 10 µA, "
|
|
20
|
+
"8-bit magnitude; pulse timing set by the host's command timing")
|
|
21
|
+
CL_SOURCE = ("Cortical Labs CL API docs (docs.corticallabs.com/cl.html), StimDesign: phase width evenly "
|
|
22
|
+
"divisible by 20 µs, |current| at most 3.0 µA, charge at most 3.0 nC")
|
|
23
|
+
MCS_STG4000_RANGES = {1600.0: 0.2, 16000.0: 2.0} # max µA per channel: current step µA
|
|
24
|
+
MCS_STG4000_SOURCE = ("Multi Channel Systems STG 4002/4004/4008 datasheets (1.6 mA and 16 mA versions, 2021): "
|
|
25
|
+
"output current resolution 200 nA (1.6 mA) or 2000 nA (16 mA), time resolution 20 µs, "
|
|
26
|
+
"maximum frequency 25 kHz")
|
|
27
|
+
MCS_STG5_RANGES = {160.0: 0.1, 1600.0: 1.0, 16000.0: 10.0}
|
|
28
|
+
MCS_STG5_SOURCE = ("Multi Channel Systems STG5 brochure (2024): up to 160 µA (resolution 100 nA), 1.6 mA (1 µA) "
|
|
29
|
+
"or 16 mA (10 µA); temporal pulse resolution 5 µs, up to 100 kHz")
|
|
30
|
+
TDT_IZ2_RANGES = {300.0: "IZ2 / IZ2M", 3000.0: "IZ2H / IZ2MH"}
|
|
31
|
+
TDT_IZ2_MAX_RATE_HZ = 195312.5
|
|
32
|
+
TDT_IZ2_SOURCE = ("Tucker-Davis Technologies IZ2 manual and IZ2M/IZ2MH spec sheet: output ±300 µA (IZ2, IZ2M) "
|
|
33
|
+
"or ±3 mA (IZ2H, IZ2MH); the stimulator runs at the sampling rate of its RZ processor, "
|
|
34
|
+
"pulses as short as one sample")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Device:
|
|
39
|
+
name: str
|
|
40
|
+
time_step_us: float
|
|
41
|
+
amplitude_step_ua: Optional[float] = None # None: amplitude is continuous
|
|
42
|
+
max_amplitude_ua: Optional[float] = None
|
|
43
|
+
min_phase_us: Optional[float] = None # defaults to one time step
|
|
44
|
+
max_phase_us: Optional[float] = None
|
|
45
|
+
max_charge_per_phase_nc: Optional[float] = None
|
|
46
|
+
max_frequency_hz: Optional[float] = None
|
|
47
|
+
source: str = "user-supplied"
|
|
48
|
+
note: str = ""
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not (isinstance(self.name, str) and self.name):
|
|
52
|
+
raise ValueError("name must be a non-empty string")
|
|
53
|
+
_positive("time_step_us", self.time_step_us)
|
|
54
|
+
for field_name in ("amplitude_step_ua", "max_amplitude_ua", "min_phase_us", "max_phase_us",
|
|
55
|
+
"max_charge_per_phase_nc", "max_frequency_hz"):
|
|
56
|
+
value = getattr(self, field_name)
|
|
57
|
+
if value is not None:
|
|
58
|
+
_positive(field_name, value)
|
|
59
|
+
if (self.amplitude_step_ua is not None and self.max_amplitude_ua is not None
|
|
60
|
+
and self.max_amplitude_ua < self.amplitude_step_ua):
|
|
61
|
+
raise ValueError("max_amplitude_ua is smaller than one amplitude step")
|
|
62
|
+
if self.min_phase_us is not None and self.max_phase_us is not None and self.max_phase_us < self.min_phase_us:
|
|
63
|
+
raise ValueError("max_phase_us is smaller than min_phase_us")
|
|
64
|
+
|
|
65
|
+
# --- grid helpers ---------------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def continuous_amplitude(self) -> bool:
|
|
69
|
+
return self.amplitude_step_ua is None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def min_ticks(self) -> int:
|
|
73
|
+
if self.min_phase_us is None:
|
|
74
|
+
return 1
|
|
75
|
+
return max(1, math.ceil(self.min_phase_us / self.time_step_us - 1e-9))
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def max_ticks(self) -> Optional[int]:
|
|
79
|
+
if self.max_phase_us is None:
|
|
80
|
+
return None
|
|
81
|
+
return math.floor(self.max_phase_us / self.time_step_us + 1e-9)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def max_amplitude_steps(self) -> Optional[int]:
|
|
85
|
+
if self.continuous_amplitude or self.max_amplitude_ua is None:
|
|
86
|
+
return None
|
|
87
|
+
return math.floor(self.max_amplitude_ua / self.amplitude_step_ua + 1e-9)
|
|
88
|
+
|
|
89
|
+
def ticks(self, duration_us: float) -> int:
|
|
90
|
+
return _round_half_up(duration_us / self.time_step_us)
|
|
91
|
+
|
|
92
|
+
def us(self, ticks: int) -> float:
|
|
93
|
+
return ticks * self.time_step_us
|
|
94
|
+
|
|
95
|
+
def amp_steps(self, amplitude_ua: float) -> int:
|
|
96
|
+
return _round_half_up(amplitude_ua / self.amplitude_step_ua)
|
|
97
|
+
|
|
98
|
+
def ua(self, steps: int) -> float:
|
|
99
|
+
return steps * self.amplitude_step_ua
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- presets ----------------------------------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def intan_rhs2116(step_ua: float = 1.0, sample_rate_hz: float = 30000.0) -> Device:
|
|
105
|
+
"""Intan RHS2116 with one global current step and a host timing grid of one sample.
|
|
106
|
+
|
|
107
|
+
The chip has no pulse timers: the host sets every edge, normally once per sample,
|
|
108
|
+
so the timing grid is 1 / sample rate. Full scale is 255 steps.
|
|
109
|
+
"""
|
|
110
|
+
if not any(math.isclose(step_ua, s, rel_tol=1e-9) for s in INTAN_RHS2116_STEPS_UA):
|
|
111
|
+
allowed = ", ".join(f"{s:g}" for s in INTAN_RHS2116_STEPS_UA)
|
|
112
|
+
raise ValueError(f"the RHS2116 step size must be one of {allowed} µA")
|
|
113
|
+
_positive("sample_rate_hz", sample_rate_hz)
|
|
114
|
+
return Device(
|
|
115
|
+
name=f"intan-rhs2116 (step {step_ua:g} µA, {sample_rate_hz:g} samples/s)",
|
|
116
|
+
time_step_us=1e6 / sample_rate_hz,
|
|
117
|
+
amplitude_step_ua=step_ua,
|
|
118
|
+
max_amplitude_ua=255 * step_ua,
|
|
119
|
+
source=INTAN_SOURCE,
|
|
120
|
+
note="Timing grid assumes edges on sample boundaries; check your host software.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cortical_labs_cl() -> Device:
|
|
125
|
+
return Device(
|
|
126
|
+
name="cortical-labs-cl",
|
|
127
|
+
time_step_us=20.0,
|
|
128
|
+
amplitude_step_ua=None,
|
|
129
|
+
max_amplitude_ua=3.0,
|
|
130
|
+
max_charge_per_phase_nc=3.0,
|
|
131
|
+
source=CL_SOURCE,
|
|
132
|
+
note="The docs give no amplitude step, so amplitude is treated as continuous. They also limit "
|
|
133
|
+
"burst_hz to 200 Hz for bursts.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def mcs_stg4000(range_ua: float = 1600.0) -> Device:
|
|
138
|
+
"""MCS STG 4002/4004/4008 in current mode. The range is fixed by the model version you bought."""
|
|
139
|
+
step = _pick_range("the STG4000 version", range_ua, MCS_STG4000_RANGES)
|
|
140
|
+
return Device(
|
|
141
|
+
name=f"mcs-stg4000 ({range_ua / 1000:g} mA version)",
|
|
142
|
+
time_step_us=20.0,
|
|
143
|
+
amplitude_step_ua=step,
|
|
144
|
+
max_amplitude_ua=range_ua,
|
|
145
|
+
max_frequency_hz=25000.0,
|
|
146
|
+
source=MCS_STG4000_SOURCE,
|
|
147
|
+
note="Current mode. The 160 µA version is not included: its official datasheet could not be confirmed.",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def mcs_stg5(range_ua: float = 1600.0) -> Device:
|
|
152
|
+
"""MCS STG5 in current mode, in one of its three ranges."""
|
|
153
|
+
step = _pick_range("the STG5 range", range_ua, MCS_STG5_RANGES)
|
|
154
|
+
return Device(
|
|
155
|
+
name=f"mcs-stg5 (up to {range_ua:g} µA)",
|
|
156
|
+
time_step_us=5.0,
|
|
157
|
+
amplitude_step_ua=step,
|
|
158
|
+
max_amplitude_ua=range_ua,
|
|
159
|
+
max_frequency_hz=100000.0,
|
|
160
|
+
source=MCS_STG5_SOURCE,
|
|
161
|
+
note="Current mode. Figures are from the product brochure.",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def tdt_iz2(range_ua: float = 300.0, sample_rate_hz: float = 24414.0625) -> Device:
|
|
166
|
+
"""TDT IZ2-family stimulator; the timing grid is one sample of the RZ processor driving it."""
|
|
167
|
+
model = TDT_IZ2_RANGES.get(float(range_ua)) if _is_number(range_ua) else None
|
|
168
|
+
if model is None:
|
|
169
|
+
raise ValueError("range_ua must be 300 (IZ2, IZ2M) or 3000 (IZ2H, IZ2MH)")
|
|
170
|
+
_positive("sample_rate_hz", sample_rate_hz)
|
|
171
|
+
if sample_rate_hz > TDT_IZ2_MAX_RATE_HZ:
|
|
172
|
+
raise ValueError(f"sample_rate_hz is above the IZ2 family's maximum of {TDT_IZ2_MAX_RATE_HZ:g}")
|
|
173
|
+
return Device(
|
|
174
|
+
name=f"tdt-iz2 ({model}, {sample_rate_hz:g} samples/s)",
|
|
175
|
+
time_step_us=1e6 / sample_rate_hz,
|
|
176
|
+
amplitude_step_ua=None,
|
|
177
|
+
max_amplitude_ua=float(range_ua),
|
|
178
|
+
source=TDT_IZ2_SOURCE,
|
|
179
|
+
note="TDT publishes no current step, so amplitude is treated as continuous. Use your RZ's real "
|
|
180
|
+
"sampling rate (for example 24414.0625, 48828.125, 97656.25 or 195312.5); the larger IZ2 "
|
|
181
|
+
"models support only the lower rates.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
PRESETS: Dict[str, Callable[..., Device]] = {
|
|
186
|
+
"intan-rhs2116": intan_rhs2116,
|
|
187
|
+
"cortical-labs-cl": cortical_labs_cl,
|
|
188
|
+
"mcs-stg4000": mcs_stg4000,
|
|
189
|
+
"mcs-stg5": mcs_stg5,
|
|
190
|
+
"tdt-iz2": tdt_iz2,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def preset(name: str, **options: float) -> Device:
|
|
195
|
+
try:
|
|
196
|
+
make = PRESETS[name]
|
|
197
|
+
except KeyError:
|
|
198
|
+
raise ValueError(f"unknown device preset {name!r}; known: {', '.join(sorted(PRESETS))}") from None
|
|
199
|
+
return make(**options)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def override(device: Device, **fields) -> Device:
|
|
203
|
+
"""A copy of a device with some fields changed (None values are ignored)."""
|
|
204
|
+
changes = {k: v for k, v in fields.items() if v is not None}
|
|
205
|
+
return replace(device, **changes) if changes else device
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _round_half_up(x: float) -> int:
|
|
209
|
+
"""Round to the nearest whole number, halves up (Python's round() sends 4.5 to 4)."""
|
|
210
|
+
return int(math.floor(x + 0.5 + 1e-9))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_number(value: object) -> bool:
|
|
214
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _pick_range(what: str, range_ua: object, ranges: Dict[float, float]) -> float:
|
|
218
|
+
for top, step in ranges.items():
|
|
219
|
+
if _is_number(range_ua) and math.isclose(range_ua, top, rel_tol=1e-9):
|
|
220
|
+
return step
|
|
221
|
+
raise ValueError(f"range_ua for {what} must be one of {', '.join(f'{r:g}' for r in ranges)} µA")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _positive(name: str, value: object) -> None:
|
|
225
|
+
if not (isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
226
|
+
and value > 0):
|
|
227
|
+
raise ValueError(f"{name} must be a positive number")
|
stimgrid/fit.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Fit a planned pulse to a device's grid.
|
|
2
|
+
|
|
3
|
+
Two answers come back:
|
|
4
|
+
|
|
5
|
+
- rounded: what the device delivers if each value is simply rounded to its grid.
|
|
6
|
+
An asymmetric pulse that was charge-balanced on paper often is not after this.
|
|
7
|
+
- balanced: the nearest pulse on the grid whose two phases carry equal charge,
|
|
8
|
+
found by searching small changes to both phases (mostly the second phase's
|
|
9
|
+
width and amplitude, the usual balancing knobs).
|
|
10
|
+
|
|
11
|
+
On a gridded device charge balance is checked exactly, in whole amplitude steps
|
|
12
|
+
times whole time steps, so "balanced" means equal to the last bit, not "close".
|
|
13
|
+
Pulse rates are also rounded to whole periods and the delivered rate reported.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from dataclasses import asdict, dataclass, field
|
|
20
|
+
from typing import Iterable, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from .devices import Device, _positive, _round_half_up
|
|
23
|
+
|
|
24
|
+
MAX_SECOND_WIDTH_CANDIDATES = 4001
|
|
25
|
+
_EPS = 1e-9
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Pulse:
|
|
30
|
+
"""The pulse you planned. Units: µA and µs."""
|
|
31
|
+
|
|
32
|
+
amplitude_ua: float
|
|
33
|
+
width_us: float
|
|
34
|
+
biphasic: bool = True
|
|
35
|
+
second_amplitude_ua: Optional[float] = None # defaults to amplitude_ua
|
|
36
|
+
second_width_us: Optional[float] = None # defaults to width_us
|
|
37
|
+
gap_us: float = 0.0
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
_positive("amplitude_ua", self.amplitude_ua)
|
|
41
|
+
_positive("width_us", self.width_us)
|
|
42
|
+
if not (isinstance(self.gap_us, (int, float)) and not isinstance(self.gap_us, bool)
|
|
43
|
+
and math.isfinite(self.gap_us) and self.gap_us >= 0):
|
|
44
|
+
raise ValueError("gap_us must be zero or a positive number")
|
|
45
|
+
if self.second_amplitude_ua is not None:
|
|
46
|
+
_positive("second_amplitude_ua", self.second_amplitude_ua)
|
|
47
|
+
if self.second_width_us is not None:
|
|
48
|
+
_positive("second_width_us", self.second_width_us)
|
|
49
|
+
if not isinstance(self.biphasic, bool):
|
|
50
|
+
raise ValueError("biphasic must be true or false")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def a2(self) -> float:
|
|
54
|
+
return self.amplitude_ua if self.second_amplitude_ua is None else self.second_amplitude_ua
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def w2(self) -> float:
|
|
58
|
+
return self.width_us if self.second_width_us is None else self.second_width_us
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Rates:
|
|
63
|
+
"""Pulse rates to round to whole periods. All optional."""
|
|
64
|
+
|
|
65
|
+
frequency_hz: Optional[float] = None # uniform train
|
|
66
|
+
intra_burst_hz: Optional[float] = None # pulses inside a burst
|
|
67
|
+
burst_rate_hz: Optional[float] = None # bursts per second
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
for name in ("frequency_hz", "intra_burst_hz", "burst_rate_hz"):
|
|
71
|
+
if getattr(self, name) is not None:
|
|
72
|
+
_positive(name, getattr(self, name))
|
|
73
|
+
|
|
74
|
+
def items(self) -> Iterable[Tuple[str, float]]:
|
|
75
|
+
for name in ("frequency_hz", "intra_burst_hz", "burst_rate_hz"):
|
|
76
|
+
value = getattr(self, name)
|
|
77
|
+
if value is not None:
|
|
78
|
+
yield name, value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Delivered:
|
|
83
|
+
"""A pulse exactly as the device can produce it."""
|
|
84
|
+
|
|
85
|
+
amplitude_ua: float
|
|
86
|
+
width_us: float
|
|
87
|
+
biphasic: bool
|
|
88
|
+
second_amplitude_ua: Optional[float]
|
|
89
|
+
second_width_us: Optional[float]
|
|
90
|
+
gap_us: float
|
|
91
|
+
width_ticks: int
|
|
92
|
+
second_width_ticks: Optional[int]
|
|
93
|
+
gap_ticks: int
|
|
94
|
+
amplitude_steps: Optional[int] # None when amplitude is continuous
|
|
95
|
+
second_amplitude_steps: Optional[int]
|
|
96
|
+
first_charge_nc: float
|
|
97
|
+
second_charge_nc: float
|
|
98
|
+
net_charge_nc: float
|
|
99
|
+
imbalance: float # |Q1 - Q2| / max(Q1, Q2); 0 is exactly balanced
|
|
100
|
+
duration_us: float
|
|
101
|
+
duration_ticks: int
|
|
102
|
+
charge_change: float # first-phase charge versus the planned pulse, as a fraction
|
|
103
|
+
|
|
104
|
+
def as_pulse(self) -> dict:
|
|
105
|
+
"""The pulse in the common JSON shape (amplitude_ua, width_us, ...)."""
|
|
106
|
+
out = {"amplitude_ua": _clean(self.amplitude_ua), "width_us": _clean(self.width_us)}
|
|
107
|
+
if not self.biphasic:
|
|
108
|
+
out["biphasic"] = False
|
|
109
|
+
else:
|
|
110
|
+
out["second_amplitude_ua"] = _clean(self.second_amplitude_ua)
|
|
111
|
+
out["second_width_us"] = _clean(self.second_width_us)
|
|
112
|
+
if self.gap_ticks:
|
|
113
|
+
out["gap_us"] = _clean(self.gap_us)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class RateFit:
|
|
119
|
+
name: str
|
|
120
|
+
target_hz: float
|
|
121
|
+
period_ticks: int
|
|
122
|
+
delivered_hz: float
|
|
123
|
+
change: float # delivered versus target, as a fraction
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Issue:
|
|
128
|
+
code: str
|
|
129
|
+
severity: str # "error" or "warning"
|
|
130
|
+
message: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class FitReport:
|
|
135
|
+
device: dict
|
|
136
|
+
target: dict
|
|
137
|
+
rounded: Delivered
|
|
138
|
+
balanced: Optional[Delivered] # None for monophasic pulses
|
|
139
|
+
alternatives: List[Delivered]
|
|
140
|
+
rates: List[RateFit]
|
|
141
|
+
issues: List[Issue]
|
|
142
|
+
max_imbalance: float
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def ok(self) -> bool:
|
|
146
|
+
return not any(i.severity == "error" for i in self.issues)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def best(self) -> Delivered:
|
|
150
|
+
return self.balanced or self.rounded
|
|
151
|
+
|
|
152
|
+
def to_dict(self) -> dict:
|
|
153
|
+
data = asdict(self)
|
|
154
|
+
data["ok"] = self.ok
|
|
155
|
+
data["best_pulse"] = self.best.as_pulse()
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fit(pulse: Pulse, device: Device, rates: Optional[Rates] = None, max_imbalance: float = 0.0,
|
|
160
|
+
keep_first: bool = False, search_steps: int = 2) -> FitReport:
|
|
161
|
+
"""Round a pulse to a device's grid, and find the nearest exactly balanced version.
|
|
162
|
+
|
|
163
|
+
max_imbalance: the imbalance accepted as balanced (0 means exact).
|
|
164
|
+
keep_first: keep the first phase as rounded and balance with the second phase only.
|
|
165
|
+
search_steps: how many grid steps the first phase may move either way while searching.
|
|
166
|
+
"""
|
|
167
|
+
if not (isinstance(max_imbalance, (int, float)) and 0 <= max_imbalance < 1):
|
|
168
|
+
raise ValueError("max_imbalance must be at least 0 and below 1")
|
|
169
|
+
if not (isinstance(search_steps, int) and 0 <= search_steps <= 20):
|
|
170
|
+
raise ValueError("search_steps must be a whole number from 0 to 20")
|
|
171
|
+
rates = rates or Rates()
|
|
172
|
+
issues: List[Issue] = []
|
|
173
|
+
|
|
174
|
+
a1, a1_issue = _grid_amplitude(pulse.amplitude_ua, device, "amplitude_ua")
|
|
175
|
+
w1, w1_issue = _grid_width(pulse.width_us, device, "width_us")
|
|
176
|
+
gap = device.ticks(pulse.gap_us)
|
|
177
|
+
issues += [i for i in (a1_issue, w1_issue) if i]
|
|
178
|
+
if pulse.biphasic:
|
|
179
|
+
a2, a2_issue = _grid_amplitude(pulse.a2, device, "second_amplitude_ua")
|
|
180
|
+
w2, w2_issue = _grid_width(pulse.w2, device, "second_width_us")
|
|
181
|
+
issues += [i for i in (a2_issue, w2_issue) if i]
|
|
182
|
+
else:
|
|
183
|
+
a2 = w2 = None
|
|
184
|
+
rounded = _deliver(pulse, device, a1, w1, a2, w2, gap)
|
|
185
|
+
if pulse.gap_us and gap == 0:
|
|
186
|
+
issues.append(Issue("SG105", "warning", f"gap_us {pulse.gap_us:g} rounds to 0 on a "
|
|
187
|
+
f"{device.time_step_us:.6g} µs grid"))
|
|
188
|
+
for name, want, got in _moved(pulse, rounded):
|
|
189
|
+
issues.append(Issue("SG106", "warning", f"{name} {want:g} becomes {got:.6g} after rounding "
|
|
190
|
+
f"({(got - want) / want:+.1%})"))
|
|
191
|
+
|
|
192
|
+
balanced, alternatives = None, []
|
|
193
|
+
if pulse.biphasic:
|
|
194
|
+
if rounded.imbalance > max_imbalance + _EPS:
|
|
195
|
+
issues.append(Issue("SG201", "warning",
|
|
196
|
+
f"simple rounding leaves the pulse unbalanced (imbalance {rounded.imbalance:.4%}, "
|
|
197
|
+
f"net {rounded.net_charge_nc:.4g} nC per pulse)"))
|
|
198
|
+
ranked = _search(pulse, device, a1, w1, w2, gap, max_imbalance, keep_first, search_steps)
|
|
199
|
+
if ranked:
|
|
200
|
+
balanced, alternatives = ranked[0], ranked[1:4]
|
|
201
|
+
if balanced.imbalance > max_imbalance + _EPS:
|
|
202
|
+
issues.append(Issue("SG202", "error",
|
|
203
|
+
f"no pulse within the search is balanced to {max_imbalance:.4%}; the best "
|
|
204
|
+
f"found has imbalance {balanced.imbalance:.4%}"))
|
|
205
|
+
if abs(balanced.charge_change) > 0.05:
|
|
206
|
+
issues.append(Issue("SG203", "warning",
|
|
207
|
+
f"the balanced pulse changes charge per phase by {balanced.charge_change:+.1%}"))
|
|
208
|
+
else:
|
|
209
|
+
issues.append(Issue("SG202", "error", "no pulse on the device grid fits the search range and limits"))
|
|
210
|
+
|
|
211
|
+
best = balanced or rounded
|
|
212
|
+
if device.max_charge_per_phase_nc is not None:
|
|
213
|
+
q = max(best.first_charge_nc, best.second_charge_nc)
|
|
214
|
+
if q > device.max_charge_per_phase_nc + _EPS:
|
|
215
|
+
issues.append(Issue("SG104", "error", f"charge per phase {q:.4g} nC is above the device's "
|
|
216
|
+
f"{device.max_charge_per_phase_nc:g} nC"))
|
|
217
|
+
rate_fits = _rates(rates, device, best, issues)
|
|
218
|
+
issues.sort(key=lambda i: (i.severity != "error", i.code))
|
|
219
|
+
return FitReport(asdict(device), asdict(pulse), rounded, balanced, alternatives, rate_fits, issues,
|
|
220
|
+
max_imbalance)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- grid rounding -----------------------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def _grid_amplitude(value: float, device: Device, name: str) -> Tuple[float, Optional[Issue]]:
|
|
226
|
+
"""Amplitude as steps (int) on a gridded device, or µA (float) on a continuous one."""
|
|
227
|
+
if device.continuous_amplitude:
|
|
228
|
+
if device.max_amplitude_ua is not None and value > device.max_amplitude_ua + _EPS:
|
|
229
|
+
return device.max_amplitude_ua, Issue("SG101", "error", f"{name} {value:g} µA is above the device's "
|
|
230
|
+
f"{device.max_amplitude_ua:g} µA")
|
|
231
|
+
return value, None
|
|
232
|
+
steps = device.amp_steps(value)
|
|
233
|
+
top = device.max_amplitude_steps
|
|
234
|
+
if steps < 1:
|
|
235
|
+
return 1, Issue("SG103", "error", f"{name} {value:g} µA is below one amplitude step "
|
|
236
|
+
f"({device.amplitude_step_ua:g} µA)")
|
|
237
|
+
if top is not None and steps > top:
|
|
238
|
+
return top, Issue("SG101", "error", f"{name} {value:g} µA is above the device's "
|
|
239
|
+
f"{device.ua(top):g} µA")
|
|
240
|
+
return steps, None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _grid_width(value: float, device: Device, name: str) -> Tuple[int, Optional[Issue]]:
|
|
244
|
+
ticks = device.ticks(value)
|
|
245
|
+
if ticks < device.min_ticks:
|
|
246
|
+
return device.min_ticks, Issue("SG102", "error", f"{name} {value:g} µs is shorter than the device's "
|
|
247
|
+
f"{device.us(device.min_ticks):.6g} µs minimum")
|
|
248
|
+
top = device.max_ticks
|
|
249
|
+
if top is not None and ticks > top:
|
|
250
|
+
return top, Issue("SG102", "error", f"{name} {value:g} µs is longer than the device's "
|
|
251
|
+
f"{device.us(top):.6g} µs maximum")
|
|
252
|
+
return ticks, None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _deliver(pulse: Pulse, device: Device, a1, w1: int, a2, w2: Optional[int], gap: int) -> Delivered:
|
|
256
|
+
gridded = not device.continuous_amplitude
|
|
257
|
+
a1_ua = device.ua(a1) if gridded else a1
|
|
258
|
+
q1 = a1_ua * device.us(w1) * 1e-3
|
|
259
|
+
target_q1 = pulse.amplitude_ua * pulse.width_us * 1e-3
|
|
260
|
+
if pulse.biphasic:
|
|
261
|
+
a2_ua = device.ua(a2) if gridded else a2
|
|
262
|
+
q2 = a2_ua * device.us(w2) * 1e-3
|
|
263
|
+
if gridded: # exact: compare whole steps x whole ticks
|
|
264
|
+
p1, p2 = a1 * w1, a2 * w2
|
|
265
|
+
imbalance = abs(p1 - p2) / max(p1, p2)
|
|
266
|
+
if p1 == p2:
|
|
267
|
+
q2 = q1 # equal to the last bit, so no float noise in the net charge
|
|
268
|
+
else:
|
|
269
|
+
imbalance = abs(q1 - q2) / max(q1, q2)
|
|
270
|
+
if imbalance < 1e-12:
|
|
271
|
+
imbalance, q2 = 0.0, q1 # balanced within float precision: report a clean zero net charge
|
|
272
|
+
ticks = w1 + gap + w2
|
|
273
|
+
else:
|
|
274
|
+
a2_ua, q2, imbalance, ticks = None, 0.0, 1.0, w1
|
|
275
|
+
return Delivered(
|
|
276
|
+
amplitude_ua=a1_ua,
|
|
277
|
+
width_us=device.us(w1),
|
|
278
|
+
biphasic=pulse.biphasic,
|
|
279
|
+
second_amplitude_ua=a2_ua,
|
|
280
|
+
second_width_us=device.us(w2) if pulse.biphasic else None,
|
|
281
|
+
gap_us=device.us(gap),
|
|
282
|
+
width_ticks=w1,
|
|
283
|
+
second_width_ticks=w2 if pulse.biphasic else None,
|
|
284
|
+
gap_ticks=gap,
|
|
285
|
+
amplitude_steps=a1 if gridded else None,
|
|
286
|
+
second_amplitude_steps=a2 if (gridded and pulse.biphasic) else None,
|
|
287
|
+
first_charge_nc=q1,
|
|
288
|
+
second_charge_nc=q2,
|
|
289
|
+
net_charge_nc=q1 - q2,
|
|
290
|
+
imbalance=imbalance,
|
|
291
|
+
duration_us=device.us(ticks),
|
|
292
|
+
duration_ticks=ticks,
|
|
293
|
+
charge_change=(q1 - target_q1) / target_q1,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --- balanced search ---------------------------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def _search(pulse: Pulse, device: Device, a1, w1: int, w2: int, gap: int, max_imbalance: float,
|
|
300
|
+
keep_first: bool, search_steps: int) -> List[Delivered]:
|
|
301
|
+
gridded = not device.continuous_amplitude
|
|
302
|
+
r = 0 if keep_first else search_steps
|
|
303
|
+
top_ticks = device.max_ticks
|
|
304
|
+
top_steps = device.max_amplitude_steps
|
|
305
|
+
|
|
306
|
+
if gridded:
|
|
307
|
+
first_amps = [a for a in range(a1 - r, a1 + r + 1) if a >= 1 and (top_steps is None or a <= top_steps)]
|
|
308
|
+
else:
|
|
309
|
+
first_amps = [a1] # continuous: the first phase is kept; the second absorbs the balance
|
|
310
|
+
first_widths = [w for w in range(w1 - r, w1 + r + 1)
|
|
311
|
+
if w >= device.min_ticks and (top_ticks is None or w <= top_ticks)]
|
|
312
|
+
|
|
313
|
+
half = max(20, w2 // 2)
|
|
314
|
+
lo = max(device.min_ticks, w2 - half)
|
|
315
|
+
hi = w2 + half if top_ticks is None else min(top_ticks, w2 + half)
|
|
316
|
+
if hi - lo + 1 > MAX_SECOND_WIDTH_CANDIDATES:
|
|
317
|
+
lo, hi = max(lo, w2 - MAX_SECOND_WIDTH_CANDIDATES // 2), min(hi, w2 + MAX_SECOND_WIDTH_CANDIDATES // 2)
|
|
318
|
+
second_widths = range(lo, hi + 1)
|
|
319
|
+
|
|
320
|
+
step = device.amplitude_step_ua if gridded else 1.0
|
|
321
|
+
tick = device.time_step_us
|
|
322
|
+
qmax = device.max_charge_per_phase_nc
|
|
323
|
+
t_a1, t_w1, t_a2, t_w2 = pulse.amplitude_ua, pulse.width_us, pulse.a2, pulse.w2
|
|
324
|
+
t_q1 = t_a1 * t_w1 * 1e-3
|
|
325
|
+
|
|
326
|
+
pairs = []
|
|
327
|
+
for fw in first_widths:
|
|
328
|
+
amps = list(first_amps)
|
|
329
|
+
if not gridded: # also try the amplitude that keeps the planned charge on this width
|
|
330
|
+
keep = t_q1 * 1e3 / (fw * tick)
|
|
331
|
+
if device.max_amplitude_ua is None or keep <= device.max_amplitude_ua + _EPS:
|
|
332
|
+
amps.append(keep)
|
|
333
|
+
pairs += [(fa, fw) for fa in amps]
|
|
334
|
+
|
|
335
|
+
scored = [] # (over tolerance, cost, imbalance, candidate)
|
|
336
|
+
for fa, fw in pairs:
|
|
337
|
+
a1u = fa * step
|
|
338
|
+
w1u = fw * tick
|
|
339
|
+
q1 = a1u * w1u * 1e-3
|
|
340
|
+
if qmax is not None and q1 > qmax + _EPS:
|
|
341
|
+
continue
|
|
342
|
+
dq = abs(q1 - t_q1) / t_q1
|
|
343
|
+
base = 2 * dq + abs(a1u - t_a1) / t_a1 + abs(w1u - t_w1) / t_w1
|
|
344
|
+
for sw in second_widths:
|
|
345
|
+
w2u = sw * tick
|
|
346
|
+
for sa in _second_amplitudes(fa, fw, sw, device):
|
|
347
|
+
a2u = sa * step
|
|
348
|
+
if gridded:
|
|
349
|
+
p1, p2 = fa * fw, sa * sw
|
|
350
|
+
imb = abs(p1 - p2) / max(p1, p2)
|
|
351
|
+
else:
|
|
352
|
+
q2 = a2u * w2u * 1e-3
|
|
353
|
+
imb = abs(q1 - q2) / max(q1, q2)
|
|
354
|
+
imb = 0.0 if imb < 1e-12 else imb
|
|
355
|
+
if qmax is not None and a2u * w2u * 1e-3 > qmax + _EPS:
|
|
356
|
+
continue
|
|
357
|
+
cost = base + abs(a2u - t_a2) / t_a2 + abs(w2u - t_w2) / t_w2
|
|
358
|
+
scored.append((imb > max_imbalance + _EPS, cost, imb, (fa, fw, sa, sw)))
|
|
359
|
+
feasible = [s for s in scored if not s[0]]
|
|
360
|
+
if feasible:
|
|
361
|
+
feasible.sort(key=lambda s: (s[1], s[2], s[3]))
|
|
362
|
+
chosen = feasible
|
|
363
|
+
else:
|
|
364
|
+
scored.sort(key=lambda s: (s[2], s[1], s[3]))
|
|
365
|
+
chosen = scored
|
|
366
|
+
return [_deliver(pulse, device, fa, fw, sa, sw, gap) for _, _, _, (fa, fw, sa, sw) in chosen[:4]]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _second_amplitudes(fa, fw: int, sw: int, device: Device) -> List:
|
|
370
|
+
if device.continuous_amplitude:
|
|
371
|
+
a = fa * fw / sw # exact balance
|
|
372
|
+
if device.max_amplitude_ua is not None and a > device.max_amplitude_ua + _EPS:
|
|
373
|
+
return []
|
|
374
|
+
return [a]
|
|
375
|
+
x = fa * fw / sw
|
|
376
|
+
top = device.max_amplitude_steps
|
|
377
|
+
return [a for a in {math.floor(x), math.ceil(x)} if a >= 1 and (top is None or a <= top)]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# --- rates -------------------------------------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
def _rates(rates: Rates, device: Device, best: Delivered, issues: List[Issue]) -> List[RateFit]:
|
|
384
|
+
out = []
|
|
385
|
+
for name, hz in rates.items():
|
|
386
|
+
period = _round_half_up(1e6 / (hz * device.time_step_us))
|
|
387
|
+
if period < 1:
|
|
388
|
+
issues.append(Issue("SG302", "error", f"{name} {hz:g} Hz is faster than one time step"))
|
|
389
|
+
continue
|
|
390
|
+
delivered = 1e6 / (period * device.time_step_us)
|
|
391
|
+
change = (delivered - hz) / hz
|
|
392
|
+
out.append(RateFit(name, hz, period, delivered, change))
|
|
393
|
+
if abs(change) > 0.001:
|
|
394
|
+
issues.append(Issue("SG301", "warning", f"{name} {hz:g} Hz is delivered as {delivered:.6g} Hz "
|
|
395
|
+
f"({change:+.3%}) on a whole number of time steps"))
|
|
396
|
+
if name != "burst_rate_hz" and best.duration_ticks > period:
|
|
397
|
+
issues.append(Issue("SG303", "error", f"the pulse ({best.duration_us:.6g} µs) is longer than one "
|
|
398
|
+
f"period at {name} ({device.us(period):.6g} µs)"))
|
|
399
|
+
if name != "burst_rate_hz" and device.max_frequency_hz is not None and \
|
|
400
|
+
delivered > device.max_frequency_hz + _EPS:
|
|
401
|
+
issues.append(Issue("SG304", "error", f"{name} {delivered:.6g} Hz is above the device's "
|
|
402
|
+
f"{device.max_frequency_hz:g} Hz"))
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _moved(pulse: Pulse, d: Delivered, limit: float = 0.10) -> List[Tuple[str, float, float]]:
|
|
407
|
+
"""Planned values that rounding moves by more than limit (a fraction)."""
|
|
408
|
+
pairs = [("amplitude_ua", pulse.amplitude_ua, d.amplitude_ua), ("width_us", pulse.width_us, d.width_us),
|
|
409
|
+
("gap_us", pulse.gap_us, d.gap_us)]
|
|
410
|
+
if pulse.biphasic:
|
|
411
|
+
pairs += [("second_amplitude_ua", pulse.a2, d.second_amplitude_ua),
|
|
412
|
+
("second_width_us", pulse.w2, d.second_width_us)]
|
|
413
|
+
return [(n, w, g) for n, w, g in pairs if w > 0 and abs(g - w) / w > limit]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _clean(x: Optional[float]) -> Optional[float]:
|
|
417
|
+
"""Drop float noise such as 100.00000000000001 in exported values."""
|
|
418
|
+
if x is None:
|
|
419
|
+
return None
|
|
420
|
+
r = round(x, 9)
|
|
421
|
+
return int(r) if r == int(r) else r
|
stimgrid/loader.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Read a fit request (format version 1). Errors name the exact place, e.g. pulse.width_us.
|
|
2
|
+
|
|
3
|
+
{
|
|
4
|
+
"stimgrid": 1,
|
|
5
|
+
"device": {"preset": "intan-rhs2116", "step_ua": 0.1, "sample_rate_hz": 30000},
|
|
6
|
+
"pulse": {"amplitude_ua": 7.3, "width_us": 117, "second_amplitude_ua": 1.9, "second_width_us": 450},
|
|
7
|
+
"rates": {"frequency_hz": 40},
|
|
8
|
+
"max_imbalance": 0,
|
|
9
|
+
"keep_first": false
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
A device is a preset (with its options, plus any field to override) or a full description:
|
|
13
|
+
{"name": "my stimulator", "time_step_us": 10, "amplitude_step_ua": 0.5, "max_amplitude_ua": 100}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import fields
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, Tuple, Union
|
|
25
|
+
|
|
26
|
+
from .devices import PRESETS, Device, override, preset
|
|
27
|
+
from .fit import Pulse, Rates
|
|
28
|
+
|
|
29
|
+
FORMAT_VERSION = 1
|
|
30
|
+
DEVICE_FIELDS = tuple(f.name for f in fields(Device))
|
|
31
|
+
TOP_FIELDS = ("stimgrid", "device", "pulse", "rates", "max_imbalance", "keep_first", "search_steps")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InputError(ValueError):
|
|
35
|
+
"""The request is not valid. The message starts with where the problem is."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def from_dict(data: Any) -> Tuple[Pulse, Device, Rates, Dict[str, Any]]:
|
|
39
|
+
"""Returns the pulse, the device, the rates and the fit options."""
|
|
40
|
+
_object(data, "request")
|
|
41
|
+
_known(data, TOP_FIELDS, "request")
|
|
42
|
+
if data.get("stimgrid") != FORMAT_VERSION:
|
|
43
|
+
raise InputError(f"stimgrid: must be {FORMAT_VERSION} (the format version), got {data.get('stimgrid')!r}")
|
|
44
|
+
for key in ("device", "pulse"):
|
|
45
|
+
if key not in data:
|
|
46
|
+
raise InputError(f"request: missing '{key}'")
|
|
47
|
+
device = device_from_dict(data["device"], "device")
|
|
48
|
+
pulse = _build(Pulse, data["pulse"], "pulse")
|
|
49
|
+
rates = _build(Rates, data.get("rates", {}), "rates")
|
|
50
|
+
options = {}
|
|
51
|
+
if "max_imbalance" in data:
|
|
52
|
+
v = data["max_imbalance"]
|
|
53
|
+
if not (_number(v) and 0 <= v < 1):
|
|
54
|
+
raise InputError(f"max_imbalance: must be a number from 0 up to (not including) 1, got {json.dumps(v)}")
|
|
55
|
+
options["max_imbalance"] = float(v)
|
|
56
|
+
if "keep_first" in data:
|
|
57
|
+
if not isinstance(data["keep_first"], bool):
|
|
58
|
+
raise InputError("keep_first: must be true or false")
|
|
59
|
+
options["keep_first"] = data["keep_first"]
|
|
60
|
+
if "search_steps" in data:
|
|
61
|
+
v = data["search_steps"]
|
|
62
|
+
if not (isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 20):
|
|
63
|
+
raise InputError("search_steps: must be a whole number from 0 to 20")
|
|
64
|
+
options["search_steps"] = v
|
|
65
|
+
return pulse, device, rates, options
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def device_from_dict(spec: Any, path: str = "device") -> Device:
|
|
69
|
+
_object(spec, path)
|
|
70
|
+
if "preset" in spec:
|
|
71
|
+
name = spec["preset"]
|
|
72
|
+
if name not in PRESETS:
|
|
73
|
+
raise InputError(f"{path}.preset: unknown preset {name!r}; known: {', '.join(sorted(PRESETS))}")
|
|
74
|
+
option_names = tuple(inspect.signature(PRESETS[name]).parameters)
|
|
75
|
+
_known(spec, ("preset",) + option_names + DEVICE_FIELDS, path)
|
|
76
|
+
options = {k: spec[k] for k in option_names if k in spec}
|
|
77
|
+
changes = {k: spec[k] for k in DEVICE_FIELDS if k in spec}
|
|
78
|
+
for k, v in {**options, **changes}.items():
|
|
79
|
+
if k not in ("name", "source", "note") and not _number(v):
|
|
80
|
+
raise InputError(f"{path}.{k}: must be a number, got {json.dumps(v)}")
|
|
81
|
+
base = _wrap(path, lambda: preset(name, **options))
|
|
82
|
+
return _wrap(path, lambda: override(base, **changes))
|
|
83
|
+
_known(spec, DEVICE_FIELDS, path)
|
|
84
|
+
if "name" not in spec or "time_step_us" not in spec:
|
|
85
|
+
raise InputError(f"{path}: give a 'preset', or at least 'name' and 'time_step_us'")
|
|
86
|
+
return _build(Device, spec, path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load(source: Union[str, Path]) -> Tuple[Pulse, Device, Rates, Dict[str, Any]]:
|
|
90
|
+
"""Read a request from a file path, or from stdin when source is '-'."""
|
|
91
|
+
try:
|
|
92
|
+
if str(source) == "-":
|
|
93
|
+
data = json.load(sys.stdin)
|
|
94
|
+
else:
|
|
95
|
+
with open(source, encoding="utf-8") as fh:
|
|
96
|
+
data = json.load(fh)
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
raise InputError(f"input: not valid JSON (line {exc.lineno}, column {exc.colno}: {exc.msg})") from None
|
|
99
|
+
return from_dict(data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build(cls, spec: Any, path: str):
|
|
103
|
+
_object(spec, path)
|
|
104
|
+
_known(spec, tuple(f.name for f in fields(cls)), path)
|
|
105
|
+
return _wrap(path, lambda: cls(**spec))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _number(v: Any) -> bool:
|
|
109
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _object(value: Any, path: str) -> None:
|
|
113
|
+
if not isinstance(value, dict):
|
|
114
|
+
raise InputError(f"{path}: must be an object")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _known(spec: Dict[str, Any], allowed: Tuple[str, ...], path: str) -> None:
|
|
118
|
+
unknown = sorted(set(spec) - set(allowed))
|
|
119
|
+
if unknown:
|
|
120
|
+
raise InputError(f"{path}: unknown field(s) {', '.join(unknown)}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _wrap(path: str, make):
|
|
124
|
+
try:
|
|
125
|
+
return make()
|
|
126
|
+
except InputError:
|
|
127
|
+
raise
|
|
128
|
+
except (ValueError, TypeError) as exc:
|
|
129
|
+
raise InputError(f"{path}: {exc}") from None
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stimgrid
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Fit stimulation pulses to what a stimulator can actually produce, and find the nearest exactly charge-balanced pulse.
|
|
5
|
+
Author: Sanjeev Muthusamy
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sanjeevmuthusamy/stimgrid
|
|
8
|
+
Project-URL: Issues, https://github.com/sanjeevmuthusamy/stimgrid/issues
|
|
9
|
+
Keywords: neuroscience,electrophysiology,neurostimulation,electrical stimulation,microstimulation,charge balance,charge-balanced,biphasic pulse,asymmetric pulse,stimulation safety,multi-electrode array,MEA,microelectrode,neural interface,brain-computer interface,DAC quantization,Intan,RHS2116,Multi Channel Systems,STG4000,STG5,Tucker-Davis Technologies,IZ2,Cortical Labs
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Environment :: Console
|
|
22
|
+
Classifier: Intended Audience :: Science/Research
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# stimgrid
|
|
32
|
+
|
|
33
|
+
Your stimulator cannot produce every pulse you write down. It sets current in fixed steps and timing on
|
|
34
|
+
a fixed clock, so every value gets rounded. For an asymmetric pulse (for example a short strong first
|
|
35
|
+
phase and a long weak recharge phase) that rounding quietly breaks the charge balance you designed. A
|
|
36
|
+
rate of 130 Hz may also become 129.87 Hz.
|
|
37
|
+
|
|
38
|
+
stimgrid shows what your device will actually deliver, and finds the nearest pulse on the device's grid
|
|
39
|
+
whose two phases carry exactly equal charge. Pure Python, no dependencies.
|
|
40
|
+
|
|
41
|
+
For electrical neurostimulation and microstimulation on multi-electrode arrays (MEAs), microelectrodes
|
|
42
|
+
and neural interfaces, with presets for the Intan RHS2116, Multi Channel Systems STG4000 and STG5,
|
|
43
|
+
Tucker-Davis Technologies IZ2 and Cortical Labs CL, and support for any other current-controlled
|
|
44
|
+
stimulator from its current and timing steps.
|
|
45
|
+
|
|
46
|
+
**Research use only.** stimgrid works from the resolution you give it (or a preset's published
|
|
47
|
+
figures). Check the result on your own hardware and settings.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
Requires Python 3.9 or newer, and nothing else.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install git+https://github.com/sanjeevmuthusamy/stimgrid
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or from a downloaded copy, inside the `stimgrid` folder:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This gives you the `stimgrid` command. You can also run it without installing, from the folder, as
|
|
64
|
+
`PYTHONPATH=src python -m stimgrid ...`.
|
|
65
|
+
|
|
66
|
+
## Quick start
|
|
67
|
+
|
|
68
|
+
1. **Find your device.** `stimgrid devices` lists the built-in presets and where their figures come
|
|
69
|
+
from. If yours is not there, give its own steps instead (see [Devices](#devices)).
|
|
70
|
+
2. **Check your pulse.** Give the first phase, the second phase and the rate:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
stimgrid quick --device mcs-stg4000 --amplitude-ua 7.3 --width-us 117 \
|
|
74
|
+
--second-amplitude-ua 1.9 --second-width-us 450 --frequency-hz 130
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
3. **Read the result** (see [Reading the output](#reading-the-output)). Use the `balanced` pulse, or one
|
|
78
|
+
of the options, in your stimulation software.
|
|
79
|
+
4. **Save it for later or for a lab mate** as a [request file](#request-files), and rerun it with
|
|
80
|
+
`stimgrid fit my_pulse.json`.
|
|
81
|
+
|
|
82
|
+
Leave out `--second-amplitude-ua` and `--second-width-us` for a symmetric pulse; the second phase then
|
|
83
|
+
copies the first.
|
|
84
|
+
|
|
85
|
+
## Reading the output
|
|
86
|
+
|
|
87
|
+
For the quick start command above:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
device: mcs-stg4000 (1.6 mA version): 20 µs time steps, 0.2 µA steps, max 1600 µA
|
|
91
|
+
plan 7.3 µA x 117 µs | gap 0 µs | 1.9 µA x 450 µs
|
|
92
|
+
rounded 7.4 µA x 120 µs | gap 0 µs | 2 µA x 460 µs
|
|
93
|
+
charge 0.888 nC (+3.97% vs plan), net -0.032 nC, imbalance 3.4783%
|
|
94
|
+
balanced 7.2 µA x 120 µs | gap 0 µs | 1.8 µA x 480 µs
|
|
95
|
+
charge 0.864 nC (+1.16% vs plan), net 0 nC, imbalance 0.0000%
|
|
96
|
+
option 1 7 µA x 120 µs | gap 0 µs | 2 µA x 420 µs
|
|
97
|
+
charge 0.84 nC (-1.65% vs plan), net 0 nC, imbalance 0.0000%
|
|
98
|
+
...
|
|
99
|
+
rate frequency_hz 130 Hz -> 129.87 Hz (385 steps per period, -0.100%)
|
|
100
|
+
|
|
101
|
+
WARNING SG201 simple rounding leaves the pulse unbalanced (imbalance 3.4783%, net -0.032 nC per pulse)
|
|
102
|
+
|
|
103
|
+
best pulse: {"amplitude_ua": 7.2, "width_us": 120, "second_amplitude_ua": 1.8, "second_width_us": 480}
|
|
104
|
+
result: OK
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Line | Meaning |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `device` | The grid being used: time step, current step and maximum current |
|
|
110
|
+
| `plan` | The pulse you asked for: first phase, gap, second phase |
|
|
111
|
+
| `rounded` | What the device delivers if you type your plan in as it is |
|
|
112
|
+
| `balanced` | The closest pulse whose two phases carry exactly equal charge. **Use this one** |
|
|
113
|
+
| `option 1-3` | Other exactly balanced pulses, next closest first |
|
|
114
|
+
| `charge` | Charge in the first phase, and how far it moved from your plan |
|
|
115
|
+
| `net` | Charge left on the electrode by every pulse; 0 means balanced |
|
|
116
|
+
| `imbalance` | The difference between the two phases' charges, as a share of the larger one |
|
|
117
|
+
| `rate` | The rate you asked for, and the rate the device's clock really gives |
|
|
118
|
+
| `WARNING` / `ERROR` | Findings, explained in [Findings](#findings) |
|
|
119
|
+
| `best pulse` | The pulse to use, in a form you can paste into a file or program |
|
|
120
|
+
| `result` | `OK`, or `ERROR` if the pulse breaks a device limit or cannot be balanced |
|
|
121
|
+
|
|
122
|
+
Here the planned pulse would leave 0.032 nC on the electrode with every pulse; the balanced one leaves
|
|
123
|
+
none and keeps charge per phase within 1.2% of the plan.
|
|
124
|
+
|
|
125
|
+
## Another example: Intan RHS2116
|
|
126
|
+
|
|
127
|
+
An asymmetric pulse on an Intan RHS2116 with 0.1 µA steps, driven at 30,000 samples/s:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
stimgrid quick --device intan-rhs2116 --step-ua 0.1 --sample-rate-hz 30000 \
|
|
131
|
+
--amplitude-ua 7.3 --width-us 117 --second-amplitude-ua 1.9 --second-width-us 450 --frequency-hz 40
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
device: intan-rhs2116 (step 0.1 µA, 30000 samples/s): 33.3333 µs time steps, 0.1 µA steps, max 25.5 µA
|
|
136
|
+
plan 7.3 µA x 117 µs | gap 0 µs | 1.9 µA x 450 µs
|
|
137
|
+
rounded 7.3 µA x 133.333 µs | gap 0 µs | 1.9 µA x 466.667 µs
|
|
138
|
+
charge 0.97333 nC (+13.96% vs plan), net 0.08667 nC, imbalance 8.9041%
|
|
139
|
+
balanced 7.2 µA x 100 µs | gap 0 µs | 1.8 µA x 400 µs
|
|
140
|
+
charge 0.72 nC (-15.70% vs plan), net 0 nC, imbalance 0.0000%
|
|
141
|
+
option 1 7.2 µA x 133.333 µs | gap 0 µs | 1.8 µA x 533.333 µs
|
|
142
|
+
charge 0.96 nC (+12.40% vs plan), net 0 nC, imbalance 0.0000%
|
|
143
|
+
...
|
|
144
|
+
WARNING SG201 simple rounding leaves the pulse unbalanced (imbalance 8.9041%, net 0.08667 nC per pulse)
|
|
145
|
+
WARNING SG203 the balanced pulse changes charge per phase by -15.7%
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Simple rounding would leave 0.087 nC behind on every pulse. stimgrid lists exactly balanced pulses
|
|
149
|
+
instead, closest to your plan first, and warns when the grid forces charge per phase to move.
|
|
150
|
+
|
|
151
|
+
## How it works
|
|
152
|
+
|
|
153
|
+
1. **Rounded.** Each amplitude, phase width and gap is rounded (halves up) to the device's steps and
|
|
154
|
+
clamped to its ranges. This is what you get if you type the plan in and let the device round it.
|
|
155
|
+
2. **Balanced.** stimgrid searches small changes to both phases, mostly the second phase's width and
|
|
156
|
+
amplitude, the usual balancing knobs. On a gridded device, balance is checked exactly, as whole
|
|
157
|
+
current steps times whole time steps, so "balanced" means equal to the last bit. On a device with
|
|
158
|
+
continuous amplitude it also tries the first-phase amplitude that keeps your planned charge exactly.
|
|
159
|
+
3. **Ranking.** Exactly balanced candidates are ranked by closeness to your plan, with first-phase charge
|
|
160
|
+
counted double because it sets the effect. The best and three alternatives are shown.
|
|
161
|
+
4. **Rates.** Each rate is rounded to a whole number of time steps per period, and the delivered rate is
|
|
162
|
+
reported.
|
|
163
|
+
|
|
164
|
+
## Command options
|
|
165
|
+
|
|
166
|
+
`stimgrid quick` takes the pulse as flags. Amplitudes are in µA, times in µs, rates in Hz.
|
|
167
|
+
|
|
168
|
+
| Flag | Meaning |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `--device NAME` | A preset from `stimgrid devices` |
|
|
171
|
+
| `--step-ua`, `--sample-rate-hz`, `--range-ua` | Preset settings (see [Devices](#devices)) |
|
|
172
|
+
| `--time-step-us`, `--amplitude-step-ua` | Your own device's steps, without a preset (or to override one) |
|
|
173
|
+
| `--max-amplitude-ua`, `--max-charge-per-phase-nc` | Your own device's limits |
|
|
174
|
+
| `--amplitude-ua`, `--width-us` | First phase (required) |
|
|
175
|
+
| `--second-amplitude-ua`, `--second-width-us` | Second phase (defaults to the same as the first) |
|
|
176
|
+
| `--gap-us` | Gap between the phases |
|
|
177
|
+
| `--monophasic` | One phase only |
|
|
178
|
+
| `--frequency-hz`, `--intra-burst-hz`, `--burst-rate-hz` | Rates to check against the time grid |
|
|
179
|
+
| `--keep-first` | Keep the rounded first phase as it is; balance with the second phase only |
|
|
180
|
+
| `--max-imbalance 0.001` | Accept up to 0.1% imbalance as balanced (the default is exact) |
|
|
181
|
+
|
|
182
|
+
Both `quick` and `fit` also take:
|
|
183
|
+
|
|
184
|
+
| Flag | Meaning |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `--format text` | Readable report (the default) |
|
|
187
|
+
| `--format json` | The full report as JSON, for scripts |
|
|
188
|
+
| `--format pulse` | Only the best pulse as JSON |
|
|
189
|
+
| `--strict` | Treat warnings as failures (exit code 1), for automated checks |
|
|
190
|
+
|
|
191
|
+
`stimgrid --help` and `stimgrid quick --help` list them too.
|
|
192
|
+
|
|
193
|
+
## Devices
|
|
194
|
+
|
|
195
|
+
| Preset | Time step | Amplitude | Source |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| `intan-rhs2116` | 1 / sample rate (`--sample-rate-hz`, default 30,000) | `--step-ua` of 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5 or 10 µA, up to 255 steps | Intan RHS2116 datasheet |
|
|
198
|
+
| `cortical-labs-cl` | 20 µs | continuous (no step published), up to 3 µA, 3 nC per phase | Cortical Labs CL API docs (StimDesign) |
|
|
199
|
+
| `mcs-stg4000` | 20 µs, up to 25 kHz | `--range-ua 1600`: 0.2 µA steps; `--range-ua 16000`: 2 µA steps | MCS STG 4002/4004/4008 datasheets |
|
|
200
|
+
| `mcs-stg5` | 5 µs, up to 100 kHz | `--range-ua 160`: 0.1 µA; `1600`: 1 µA; `16000`: 10 µA steps | MCS STG5 brochure |
|
|
201
|
+
| `tdt-iz2` | 1 / RZ sample rate (`--sample-rate-hz`, default 24,414.0625) | continuous (no step published); `--range-ua 300` (IZ2, IZ2M) or `3000` (IZ2H, IZ2MH) | TDT IZ2 manual, IZ2M/IZ2MH spec sheet |
|
|
202
|
+
|
|
203
|
+
All presets are current mode. The MCS range is the model version you bought (STG4000) or the range you
|
|
204
|
+
select (STG5). The STG4000 160 µA version is left out because its official datasheet could not be
|
|
205
|
+
confirmed.
|
|
206
|
+
|
|
207
|
+
**Not included yet**, because the vendor does not publish a current step or a timing step:
|
|
208
|
+
|
|
209
|
+
| Device | Published | Missing |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| Axion Maestro Pro / Edge | ±250 µA, ±1.2 V, phase at least 100 µs | current step, time step |
|
|
212
|
+
| MaxWell MaxOne / MaxTwo | 32 stimulation units, ±1.0 V | current range and step, time step |
|
|
213
|
+
| Blackrock CereStim | phase 44–65,535 µs, 4 Hz–5 kHz, 100 µA steps (macro model) | micro model step, time step |
|
|
214
|
+
| A-M Systems 4100 | 100 mA with 1 µA resolution, pulses from 1 µs | time step |
|
|
215
|
+
|
|
216
|
+
If you have one of these, describe it with your own numbers (below), or send the values from your
|
|
217
|
+
manual and they can become a preset.
|
|
218
|
+
|
|
219
|
+
`stimgrid devices` prints each preset's source. The RHS2116 has no pulse timers; the host sets every
|
|
220
|
+
edge, so its timing grid depends on your host software. Any other stimulator can be described by its
|
|
221
|
+
own numbers:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
stimgrid quick --time-step-us 10 --amplitude-step-ua 0.5 --max-amplitude-ua 100 \
|
|
225
|
+
--amplitude-ua 20 --width-us 105 --second-amplitude-ua 4 --second-width-us 520
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Fields: `time_step_us` (required), `amplitude_step_ua` (leave out for continuous), `max_amplitude_ua`,
|
|
229
|
+
`min_phase_us`, `max_phase_us`, `max_charge_per_phase_nc`, `max_frequency_hz`.
|
|
230
|
+
|
|
231
|
+
## Request files
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"stimgrid": 1,
|
|
236
|
+
"device": {"preset": "intan-rhs2116", "step_ua": 0.1, "sample_rate_hz": 30000},
|
|
237
|
+
"pulse": {"amplitude_ua": 7.3, "width_us": 117, "second_amplitude_ua": 1.9, "second_width_us": 450},
|
|
238
|
+
"rates": {"frequency_hz": 40},
|
|
239
|
+
"max_imbalance": 0,
|
|
240
|
+
"keep_first": false
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
stimgrid fit examples/intan_asymmetric.json
|
|
246
|
+
stimgrid fit examples/intan_asymmetric.json --format json
|
|
247
|
+
cat request.json | stimgrid fit - --format pulse
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
A preset takes its options (`step_ua`, `sample_rate_hz` or `range_ua`, as in the table) and any device
|
|
251
|
+
field to override, for example `{"preset": "intan-rhs2116", "step_ua": 0.1, "max_amplitude_ua": 10}` or
|
|
252
|
+
`{"preset": "mcs-stg5", "range_ua": 160}`. Invalid input names the exact
|
|
253
|
+
place, for example `pulse: width_us must be a positive number`.
|
|
254
|
+
|
|
255
|
+
`--format pulse` prints only the best pulse, in the common shape (`amplitude_ua`, `width_us`,
|
|
256
|
+
`second_amplitude_ua`, `second_width_us`, `gap_us`), ready to paste into a protocol file or another tool.
|
|
257
|
+
|
|
258
|
+
## Findings
|
|
259
|
+
|
|
260
|
+
| Code | Level | Meaning |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| SG101 | error | An amplitude is above the device's maximum |
|
|
263
|
+
| SG102 | error | A phase width is outside the device's range |
|
|
264
|
+
| SG103 | error | An amplitude is below one current step |
|
|
265
|
+
| SG104 | error | Charge per phase is above the device's maximum |
|
|
266
|
+
| SG105 | warning | The interphase gap rounds to zero |
|
|
267
|
+
| SG106 | warning | Rounding moves a value by more than 10% |
|
|
268
|
+
| SG201 | warning | Simple rounding leaves the pulse unbalanced |
|
|
269
|
+
| SG202 | error | No balanced pulse was found within the search and the device's limits |
|
|
270
|
+
| SG203 | warning | The balanced pulse changes charge per phase by more than 5% |
|
|
271
|
+
| SG301 | warning | A rate changes by more than 0.1% on whole periods |
|
|
272
|
+
| SG303 | error | The pulse is longer than one period at that rate |
|
|
273
|
+
| SG304 | error | A rate is above the device's maximum |
|
|
274
|
+
|
|
275
|
+
## Exit codes
|
|
276
|
+
|
|
277
|
+
| Code | Meaning |
|
|
278
|
+
|---|---|
|
|
279
|
+
| 0 | Fitted with no errors (and no warnings, with `--strict`) |
|
|
280
|
+
| 1 | Errors (or warnings, with `--strict`) |
|
|
281
|
+
| 2 | Bad input or usage |
|
|
282
|
+
| 3 | Internal error |
|
|
283
|
+
|
|
284
|
+
## From Python
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from stimgrid import Pulse, Rates, fit, intan_rhs2116
|
|
288
|
+
|
|
289
|
+
report = fit(Pulse(7.3, 117, second_amplitude_ua=1.9, second_width_us=450),
|
|
290
|
+
intan_rhs2116(step_ua=0.1, sample_rate_hz=30000), Rates(frequency_hz=40))
|
|
291
|
+
print(report.balanced.as_pulse(), report.balanced.imbalance)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Limits of this tool
|
|
295
|
+
|
|
296
|
+
- It models one pulse shape: rectangular phases with an optional gap. It does not model DAC settling,
|
|
297
|
+
electrode impedance, channel-to-channel mismatch or the RHS2116's trim settings.
|
|
298
|
+
- Rates are assumed to fall on the same time grid as phase widths.
|
|
299
|
+
- Continuous amplitude means the device's real amplitude resolution is unknown; the reported amplitude
|
|
300
|
+
may need rounding again on the hardware.
|
|
301
|
+
|
|
302
|
+
## Citing
|
|
303
|
+
|
|
304
|
+
If you use stimgrid in published work, please cite it. GitHub's "Cite this repository" button gives the
|
|
305
|
+
reference from `CITATION.cff`:
|
|
306
|
+
|
|
307
|
+
> Muthusamy S. stimgrid (version 0.2.0) [Computer software]. https://github.com/sanjeevmuthusamy/stimgrid
|
|
308
|
+
|
|
309
|
+
## Tests
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
python -m unittest discover -s tests
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Licence
|
|
316
|
+
|
|
317
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
stimgrid/__init__.py,sha256=IMcTZKrrZziADveL96iDI-LVSkbTt-5Pg2FEAe00iyQ,798
|
|
2
|
+
stimgrid/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
stimgrid/cli.py,sha256=ABKI2u09bD1OUpSQRLsED3v5nzoxrqlkKAnV2UH21nc,8466
|
|
4
|
+
stimgrid/devices.py,sha256=04jwECK-ktT-3bgL5xmvbgIhj353g9bNTqmsHklUeQo,9983
|
|
5
|
+
stimgrid/fit.py,sha256=aldqvnhjTrGEjysNHkQbtrmw1CEQlCYehmYNA0gHacI,18311
|
|
6
|
+
stimgrid/loader.py,sha256=uaJFMOsyWpxlvOr3kkwGJ-w8rVr3EMoL_e-nZpvrwhA,5072
|
|
7
|
+
stimgrid-0.2.0.dist-info/licenses/LICENSE,sha256=wNrHtPRtCWvdGS6BuEZmngo6Pp7PuCcNOr9zfjZUW4w,1074
|
|
8
|
+
stimgrid-0.2.0.dist-info/METADATA,sha256=JJmHIsaOAyI_sFuwdu0IsCBabhagkioyOOut0eOay7Y,15107
|
|
9
|
+
stimgrid-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
stimgrid-0.2.0.dist-info/entry_points.txt,sha256=sjVCWjbiqavEUe8f644RYMbQKz9hXvofu-K7ENpVBBY,47
|
|
11
|
+
stimgrid-0.2.0.dist-info/top_level.txt,sha256=GB5qmuIhUG2qepLTEFUWSWx-hr-AdAl1kAKm4zSNFoc,9
|
|
12
|
+
stimgrid-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanjeev Muthusamy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stimgrid
|