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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stimgrid = stimgrid.cli:main
@@ -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