protocentral-easyemg 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.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: protocentral-easyemg
3
+ Version: 0.2.0
4
+ Summary: MicroPython + Raspberry Pi driver for the Protocentral easyEMG surface EMG breakout (TLA2024-based)
5
+ Project-URL: Homepage, https://protocentral.com
6
+ Project-URL: Source, https://github.com/Protocentral/protocentral-micropython-easyemg
7
+ Author-email: Protocentral Electronics <info@protocentral.com>
8
+ License: MIT
9
+ License-File: LICENSE.md
10
+ Keywords: easyemg,emg,i2c,micropython,protocentral,qwiic,raspberry-pi,semg,tla2024
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: protocentral-tla20xx>=1.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Protocentral easyEMG MicroPython Library
16
+
17
+ [![Tests](https://github.com/Protocentral/protocentral-micropython-easyemg/actions/workflows/test.yml/badge.svg)](https://github.com/Protocentral/protocentral-micropython-easyemg/actions/workflows/test.yml)
18
+ [![Publish to PyPI](https://github.com/Protocentral/protocentral-micropython-easyemg/actions/workflows/publish.yml/badge.svg)](https://github.com/Protocentral/protocentral-micropython-easyemg/actions/workflows/publish.yml)
19
+
20
+ MicroPython library for the **Protocentral easyEMG Breakout**: single-channel surface EMG (sEMG) over I²C / Qwiic. It gives you the raw band-limited waveform and computes the RMS envelope in software. The same driver also runs on a Raspberry Pi.
21
+
22
+ ## Don't have one? [Buy it here](https://protocentral.com/product/easyemg-surface-emg-breakout-qwiic/)
23
+
24
+ ![Protocentral easyEMG](https://raw.githubusercontent.com/Protocentral/protocentral-micropython-easyemg/main/assets/easyemg.jpg)
25
+
26
+ **Wiring, electrode placement and troubleshooting are in the [easyEMG Getting Started guide](https://protocentral.com/product/easyemg-surface-emg-breakout-qwiic/docs/getting-started/).**
27
+
28
+ ## Links
29
+
30
+ | | |
31
+ |---|---|
32
+ | Product page | https://protocentral.com/product/easyemg-surface-emg-breakout-qwiic/ |
33
+ | Getting Started guide | https://protocentral.com/product/easyemg-surface-emg-breakout-qwiic/docs/getting-started/ |
34
+ | Arduino library | https://github.com/Protocentral/protocentral_easyemg_arduino |
35
+ | Hardware design files | https://github.com/Protocentral/protocentral_easyemg_hardware |
36
+
37
+ ## Features
38
+
39
+ - Raw sEMG in µV at the electrodes, on a paced 500 SPS clock with missed-sample counting
40
+ - Sliding-window RMS envelope, activation threshold and %MVC
41
+ - `MvcRecorder`, the same MVC recording step as the Arduino OpenView sketch
42
+ - Optional mains notch (fundamental or every harmonic) and digital high-pass
43
+ - A port of the [Arduino library](https://github.com/Protocentral/protocentral_easyemg_arduino) 1.1.0: the same API in snake_case, and a Python version of every Arduino example
44
+
45
+ > **Not a medical device. For research and education only.** The breakout has no patient isolation: when electrodes are on a person, power the host from a battery or connect through an [easyISO isolated Qwiic adapter](https://protocentral.com/product/easyiso-isolated-qwiic-adapter/). On a mains-powered host you get no EMG at all.
46
+
47
+ ## Installation
48
+
49
+ **MicroPython** (Raspberry Pi Pico, ESP32, …):
50
+
51
+ ```bash
52
+ mpremote mip install github:Protocentral/protocentral-micropython-easyemg
53
+ ```
54
+
55
+ This also installs the [TLA20xx driver](https://github.com/Protocentral/protocentral-micropython-tla20xx). With Thonny, copy `protocentral_easyemg.py` and `protocentral_tla20xx.py` to the board instead.
56
+
57
+ **Raspberry Pi**, with I²C enabled (`sudo raspi-config nonint do_i2c 0`):
58
+
59
+ ```bash
60
+ python3 -m venv ~/easyemg && source ~/easyemg/bin/activate
61
+ pip install protocentral-easyemg
62
+ ```
63
+
64
+ ## Quick Start
65
+
66
+ ```python
67
+ from machine import Pin, I2C
68
+ from protocentral_easyemg import EasyEMG
69
+
70
+ i2c = I2C(0, scl=Pin(5), sda=Pin(4), freq=400000) # Pico: GP5, GP4. Change for your board
71
+ emg = EasyEMG(i2c) # default address 0x48
72
+ if not emg.begin():
73
+ raise OSError("easyEMG not found")
74
+ emg.calibrate_baseline() # muscle relaxed, electrodes on
75
+
76
+ n = 0
77
+ while True:
78
+ if emg.update(): # True once per sample, 500 SPS
79
+ n += 1
80
+ if n % 50 == 0: # print 10 times a second
81
+ print(emg.last_envelope(), emg.missed_samples())
82
+ ```
83
+
84
+ On a Raspberry Pi, replace the I²C lines with `from protocentral_tla20xx_linux import I2C` and `EasyEMG(I2C(bus=1))`.
85
+
86
+ Call `update()` as often as you can; it samples only when the clock is due, and never blocks. If `missed_samples()` grows, print less or lower the rate with `set_sample_rate()`. Filters need that same rate: `set_mains_notch(50, emg.sample_rate_hz())`.
87
+
88
+ ## API Reference
89
+
90
+ | Method | Description |
91
+ |---|---|
92
+ | `EasyEMG(i2c, address=0x48)` / `begin()` | Construct / configure the ADC. `begin()` returns `False` if the board doesn't answer |
93
+ | `update()` | Non-blocking. Takes one sample when the clock is due; `True` if it did |
94
+ | `last_microvolts()` / `last_envelope()` | The latest sample and RMS envelope, in µV at the electrodes |
95
+ | `set_sample_rate(hz)` / `missed_samples()` | Paced clock, default 500 SPS / samples it couldn't take on time |
96
+ | `set_envelope_window(n)` | 1–128 samples, default 64 (128 ms at 500 SPS) |
97
+ | `set_mains_notch(hz, fs)` / `set_notch(hz, fs)` / `set_high_pass(hz, fs)` | Optional filters, off by default |
98
+ | `calibrate_baseline(n)` / `set_baseline(n)` | Measure and subtract the resting offset |
99
+ | `set_activation_threshold(uv)` / `is_active()` | Compare the envelope with a threshold |
100
+ | `set_mvc(uv)` / `read_percent_mvc()` / `MvcRecorder` | %MVC normalisation and the MVC recording step |
101
+
102
+ The full API, including the unpaced reads and low-level ADC passthroughs, is documented in [protocentral_easyemg.py](https://github.com/Protocentral/protocentral-micropython-easyemg/blob/main/protocentral_easyemg.py).
103
+
104
+ ## Examples
105
+
106
+ | # | Example | Description |
107
+ |---|---|---|
108
+ | 01 | `easyemg_simpletest.py` / `easyemg_raspberrypi.py` | Raw µV and envelope (MicroPython / Raspberry Pi) |
109
+ | 02 | `easyemg_plotter.py` | sEMG and envelope for Thonny's plotter |
110
+ | 03 | `easyemg_calibration.py` | Baseline zeroing and your resting noise floor |
111
+ | 04 | `easyemg_envelope.py` | RMS envelope, MVC recording and %MVC |
112
+ | 05 | `easyemg_activation_led.py` | Flex to light an LED, with an automatic threshold and hysteresis |
113
+ | 06 | `easyemg_openview.py` | Stream to [OpenView](https://github.com/Protocentral/protocentral_openview) 3.4+ (**easyEMG Breakout**), with the MVC step. Save it to the board as `main.py`, then close Thonny or `mpremote` before connecting OpenView |
114
+ | 07 | `easyemg_diagnostics.py` | I²C, ADC, sample-rate, noise and mains-hum checks. **Run this first if something is wrong** |
115
+
116
+ The examples default to QT Py ESP32-C3 pins: change the `I2C(...)` line for your board.
117
+
118
+ ## License
119
+
120
+ - **Software:** MIT, see [LICENSE.md](https://github.com/Protocentral/protocentral-micropython-easyemg/blob/main/LICENSE.md)
121
+ - **Hardware:** CERN-OHL-P v2, in the [hardware repo](https://github.com/Protocentral/protocentral_easyemg_hardware)
122
+
123
+ Copyright (c) 2026 Protocentral Electronics.
124
+
125
+ ## Support
126
+
127
+ Open an issue on [GitHub](https://github.com/Protocentral/protocentral-micropython-easyemg/issues) or email support@protocentral.com.
@@ -0,0 +1,5 @@
1
+ protocentral_easyemg.py,sha256=5xcddYSD__dmYqrirvD2cjePNFoBKOKtkJI0ydCEP4Y,25616
2
+ protocentral_easyemg-0.2.0.dist-info/METADATA,sha256=FFpvRQNDyod52-zyBEgzsC6C3r6Ep9197GQBKP70rKo,7160
3
+ protocentral_easyemg-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ protocentral_easyemg-0.2.0.dist-info/licenses/LICENSE.md,sha256=cgO4OFpz7oAWflQfXcqCpMv8MXBqds5RfR-423fycQw,1100
5
+ protocentral_easyemg-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashwin Whitchurch, Protocentral Electronics
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,694 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Ashwin Whitchurch, Protocentral Electronics
3
+ #
4
+ # MicroPython driver for the Protocentral easyEMG single-channel surface EMG breakout.
5
+ # An INA333 and two OPA2333 stages (total gain x51.7, roughly 20-310 Hz) feed an on-board
6
+ # TI TLA2024 12-bit ADC, read differentially against the buffered 1.65 V mid-rail. This
7
+ # library DEPENDS ON the TLA20xx driver and adds the easyEMG configuration, microvolt
8
+ # conversion, RMS envelope, mains notches, digital high-pass, activation and %MVC.
9
+ #
10
+ # Ported from the Protocentral easyEMG Arduino library 1.1.0 (single source of truth):
11
+ # https://github.com/Protocentral/protocentral_easyemg_arduino
12
+ #
13
+ # Unlike an analog "muscle sensor", easyEMG hands you the RAW band-limited waveform. The
14
+ # envelope is computed here, so you can always get back to the underlying signal.
15
+ #
16
+ # Two additions the Arduino library doesn't have, because Python needs them more:
17
+ #
18
+ # update() a paced sample clock (default 500 SPS) with missed-sample counting.
19
+ # The Arduino sketches pace their own loops; here the driver does it,
20
+ # as the easyECG driver does. The notch and high-pass are built for an
21
+ # exact rate, so a clock the host can hold beats a faster one it can't.
22
+ # MvcRecorder the MVC recording step from the Arduino 06.OpenView sketch: relax,
23
+ # contract, refuse a weak reference, auto-raise.
24
+ #
25
+ # Requires: protocentral_tla20xx (mip/pip dependency)
26
+
27
+ import math
28
+
29
+ try:
30
+ from time import ticks_us, ticks_diff, ticks_add, sleep_ms, sleep_us
31
+ except ImportError: # CPython (Raspberry Pi)
32
+ import time as _time
33
+
34
+ def ticks_us():
35
+ return int(_time.monotonic() * 1000000)
36
+
37
+ def ticks_diff(a, b):
38
+ return a - b
39
+
40
+ def ticks_add(a, b):
41
+ return a + b
42
+
43
+ def sleep_ms(ms):
44
+ _time.sleep(ms / 1000.0)
45
+
46
+ def sleep_us(us):
47
+ _time.sleep(us / 1000000.0)
48
+
49
+ from protocentral_tla20xx import (
50
+ TLA20XX, FSR_6_144V, FSR_4_096V, FSR_2_048V, FSR_1_024V, FSR_0_512V, FSR_0_256V,
51
+ DR_3300SPS, MUX_AIN0_AIN1, OP_CONTINUOUS, OP_SINGLE, _REG_CONFIG,
52
+ )
53
+
54
+ # JP1 bridged 1-2 straps ADDR to GND -> 0x48. Cut and bridge 2-3 for 0x49. An easyECG
55
+ # ships at 0x49, so the two share a bus without touching the jumper.
56
+ EASYEMG_I2C_ADDR_DEFAULT = 0x48
57
+ EASYEMG_I2C_ADDR_ALT = 0x49
58
+
59
+ # INA333 x11 (R10 = 10k) times 1 + R14/R19 = 1 + 100k/27k = x4.7. Design value; pass your
60
+ # measured gain to set_gain(). Envelope shape, %MVC and activation ratios don't depend on it.
61
+ EASYEMG_DEFAULT_GAIN = 51.7
62
+
63
+ EASYEMG_HPF_HZ = 20.0 # C6/R13 (9.1k), 1-pole
64
+ EASYEMG_LPF_HZ = 310.0 # Sallen-Key, Q = 0.5
65
+
66
+ EASYEMG_MAX_ENVELOPE_WINDOW = 128
67
+
68
+ # Most biquads the notch chain holds, as on the Arduino library's 32-bit targets. At
69
+ # 1000 SPS and 50 Hz mains, set_mains_notch() wants 9 (50-450 Hz); at the default
70
+ # 500 SPS it builds 4 (50-200 Hz).
71
+ EASYEMG_MAX_NOTCH_SECTIONS = 9
72
+
73
+ # Paced sample clock. The Arduino 06.OpenView sketch paces at 1000 SPS. Interpreted
74
+ # Python can't be relied on to hold that through the envelope, the notches and a serial
75
+ # write, so the default here is half of it. Still unmeasured on hardware: watch
76
+ # missed_samples().
77
+ EASYEMG_DEFAULT_SAMPLE_RATE = 500
78
+ EASYEMG_MAX_SAMPLE_RATE = 3300 # the ADC's own conversion rate
79
+
80
+ # MVC recording, as in the Arduino 06.OpenView sketch.
81
+ EASYEMG_MVC_SETTLE_MS = 2000 # relax: filters settle, last second = rest level
82
+ EASYEMG_MVC_RECORD_MS = 5000 # contract as hard as you can
83
+ EASYEMG_MVC_MIN_UV = 100.0 # refuse an MVC below this ...
84
+ EASYEMG_MVC_MIN_REST_RATIO = 5.0 # ... or below this many times the rest level
85
+ EASYEMG_MVC_AUTO_RAISE = True # a stronger contraction becomes the new MVC
86
+
87
+ # MVC step codes: byte 6 of the OpenView packet.
88
+ MVC_NONE = 0 # no MVC yet
89
+ MVC_RELAX = 1 # settling; the last second is the rest level
90
+ MVC_CONTRACT = 2 # LED lit: contract now
91
+ MVC_TOO_WEAK = 3 # refused; shown for 2 s while the LED blinks
92
+ MVC_SET = 4 # an MVC is in use
93
+
94
+ _FSR_VOLTS = {
95
+ FSR_6_144V: 6.144, FSR_4_096V: 4.096, FSR_2_048V: 2.048,
96
+ FSR_1_024V: 1.024, FSR_0_512V: 0.512, FSR_0_256V: 0.256,
97
+ }
98
+
99
+ # One conversion period per DR code (128 ... 3300 SPS), microseconds.
100
+ _CONV_US = (7813, 4000, 2041, 1087, 625, 417, 303)
101
+
102
+
103
+ def _round(v):
104
+ return int(v + 0.5) if v >= 0.0 else int(v - 0.5)
105
+
106
+
107
+ class EasyEMG:
108
+ """Driver for the Protocentral easyEMG breakout.
109
+
110
+ Example:
111
+ from machine import Pin, I2C
112
+ from protocentral_easyemg import EasyEMG
113
+ i2c = I2C(0, scl=Pin(6), sda=Pin(5), freq=400000) # QT Py C3: SCL=6, SDA=5
114
+ emg = EasyEMG(i2c)
115
+ emg.begin()
116
+ emg.calibrate_baseline()
117
+ while True:
118
+ if emg.update(): # one sample at 500 SPS
119
+ print(emg.last_envelope()) # uV RMS at the electrodes
120
+ """
121
+
122
+ def __init__(self, i2c, address=EASYEMG_I2C_ADDR_DEFAULT):
123
+ self.i2c = i2c
124
+ self.address = address
125
+ self.adc = TLA20XX(i2c, address)
126
+ self._fsr = FSR_0_256V
127
+ self._gain = EASYEMG_DEFAULT_GAIN
128
+ self._baseline = 0
129
+
130
+ self._env_window = 64
131
+ self._env_buf = [0] * EASYEMG_MAX_ENVELOPE_WINDOW
132
+ self._env_count = 0
133
+ self._env_index = 0
134
+ self._env_sum_sq = 0
135
+ self._last_envelope = 0.0
136
+ self._last_env_counts = 0.0
137
+ self._last_raw = 0
138
+
139
+ self._threshold = 0.0
140
+ self._mvc = 0.0
141
+
142
+ # Notch chain, one biquad per section (direct form II transposed). A notch has
143
+ # b2 = b0 and a1 = b1, so each section keeps three coefficients:
144
+ # y = b0*x + z1; z1 = b1*x - b1*y + z2; z2 = b0*x - a2*y
145
+ n = EASYEMG_MAX_NOTCH_SECTIONS
146
+ self._nb0 = [1.0] * n
147
+ self._nb1 = [0.0] * n
148
+ self._na2 = [0.0] * n
149
+ self._nz1 = [0.0] * n
150
+ self._nz2 = [0.0] * n
151
+ self._notch_count = 0
152
+
153
+ # Digital high-pass biquad.
154
+ self._hp_on = False
155
+ self._hb0 = 1.0
156
+ self._hb1 = 0.0
157
+ self._hb2 = 0.0
158
+ self._ha1 = 0.0
159
+ self._ha2 = 0.0
160
+ self._hz1 = 0.0
161
+ self._hz2 = 0.0
162
+
163
+ # Paced clock.
164
+ self._rate = EASYEMG_DEFAULT_SAMPLE_RATE
165
+ self._period_us = 1000000 // EASYEMG_DEFAULT_SAMPLE_RATE
166
+ self._next_us = 0
167
+ self._clock_running = False
168
+ self._missed = 0
169
+
170
+ # ------------------------------------------------------------- connection
171
+ def begin(self):
172
+ """AIN0/AIN1, +/-0.256 V, continuous, 3300 SPS -> CONF 0x0AC3.
173
+
174
+ The +/-0.256 V range is not optional: at the +/-2.048 V power-on default one LSB
175
+ is 19 uV at the electrodes. Returns False without touching the bus further if the
176
+ ADC doesn't answer: a write to an absent device raises OSError on real I2C.
177
+ """
178
+ if not self.is_connected():
179
+ return False
180
+ self.adc.begin()
181
+ self.adc._mux = MUX_AIN0_AIN1
182
+ self.adc._mode = OP_CONTINUOUS
183
+ self.adc._dr = DR_3300SPS
184
+ self._fsr = FSR_0_256V
185
+ self.adc.set_fsr(FSR_0_256V)
186
+ self.reset_envelope()
187
+ self._clock_running = False
188
+ return True
189
+
190
+ def is_connected(self):
191
+ return self.address in self.i2c.scan()
192
+
193
+ def read_config(self):
194
+ """CONFIG register as read back. Bit 15 reads 0 while a conversion is in progress."""
195
+ return self.adc._read16(_REG_CONFIG)
196
+
197
+ # ------------------------------------------------------------- reading
198
+ def read_raw_uncorrected(self):
199
+ """Signed 12-bit counts straight from the ADC."""
200
+ return self.adc.read_adc()
201
+
202
+ def read_raw(self):
203
+ """Signed 12-bit counts: baseline-corrected, then high-pass and notch if on."""
204
+ v = float(self.read_raw_uncorrected() - self._baseline)
205
+ if self._hp_on:
206
+ v = self._apply_high_pass(v)
207
+ if self._notch_count:
208
+ v = self._apply_notch(v)
209
+ # The filters' transients can briefly overshoot the ADC's span.
210
+ if v > 2047.0:
211
+ v = 2047.0
212
+ elif v < -2048.0:
213
+ v = -2048.0
214
+ self._last_raw = _round(v)
215
+ return self._last_raw
216
+
217
+ def read_voltage(self):
218
+ """Differential ADC voltage, volts (output-referred)."""
219
+ return self.read_raw() * (self.full_scale_volts() / 2048.0)
220
+
221
+ def read_microvolts(self):
222
+ """sEMG amplitude in uV at the electrodes: the primary unit."""
223
+ return self._counts_to_uv(self.read_raw())
224
+
225
+ def full_scale_volts(self):
226
+ return _FSR_VOLTS[self._fsr]
227
+
228
+ def microvolts_per_count(self):
229
+ """uV at the electrodes per ADC count, at the current range and gain."""
230
+ return self._counts_to_uv(1.0)
231
+
232
+ def _counts_to_uv(self, counts):
233
+ if self._gain <= 0.0:
234
+ return 0.0
235
+ return counts * (self.full_scale_volts() / 2048.0) * 1000000.0 / self._gain
236
+
237
+ # ------------------------------------------------------------- paced sampling
238
+ def set_sample_rate(self, hz):
239
+ """Rate of the update() clock, samples per second. Default 500.
240
+
241
+ Returns False and keeps the old rate outside 1..3300. Build the notch and
242
+ high-pass from this same figure: set_mains_notch(50, emg.sample_rate_hz()).
243
+ """
244
+ if hz <= 0 or hz > EASYEMG_MAX_SAMPLE_RATE:
245
+ return False
246
+ self._rate = hz
247
+ self._period_us = int(1000000 // hz)
248
+ self._clock_running = False
249
+ return True
250
+
251
+ def sample_rate_hz(self):
252
+ return self._rate
253
+
254
+ def update(self):
255
+ """Non-blocking. Call as often as you can.
256
+
257
+ When the sample clock is due, takes one sample through read_envelope_raw() and
258
+ returns True; read last_raw(), last_microvolts() and last_envelope() after it.
259
+ Otherwise returns False straight away.
260
+ """
261
+ now = ticks_us()
262
+ if not self._clock_running:
263
+ self._clock_running = True
264
+ self._next_us = now
265
+
266
+ late = ticks_diff(now, self._next_us)
267
+ if late < 0:
268
+ return False
269
+
270
+ # Advance by whole periods so the rate stays exact. Resync only if badly behind.
271
+ if late > 4 * self._period_us:
272
+ self._missed += late // self._period_us
273
+ self._next_us = now
274
+ self._next_us = ticks_add(self._next_us, self._period_us)
275
+
276
+ self.read_envelope_raw()
277
+ return True
278
+
279
+ def reset_clock(self):
280
+ """Restart the clock on the next update() without counting missed samples.
281
+
282
+ Use it after deliberately stopping (printing a report, waiting for input).
283
+ """
284
+ self._clock_running = False
285
+
286
+ def missed_samples(self):
287
+ """Samples the clock couldn't take on time. Should stay 0."""
288
+ return self._missed
289
+
290
+ # ------------------------------------------------------------- mains notch
291
+ def _set_notch_section(self, i, hz, sample_rate_hz, alpha):
292
+ # b = [1, -2cos(w0), 1] / (1 + alpha); a2 = (1 - alpha) / (1 + alpha)
293
+ w0 = 2.0 * math.pi * hz / sample_rate_hz
294
+ a0 = 1.0 + alpha
295
+ self._nb0[i] = 1.0 / a0
296
+ self._nb1[i] = (-2.0 * math.cos(w0)) / a0
297
+ self._na2[i] = (1.0 - alpha) / a0
298
+ self._nz1[i] = 0.0
299
+ self._nz2[i] = 0.0
300
+
301
+ def set_notch(self, mains_hz, sample_rate_hz, q=4.0):
302
+ """ONE notch at mains_hz, about mains_hz/q wide. OFF by default.
303
+
304
+ Pass your ACTUAL sample rate. Leaves the harmonics alone: see set_mains_notch().
305
+ Returns False and leaves the notch off if the rate is below about 2.5x the notch
306
+ frequency.
307
+ """
308
+ if mains_hz <= 0.0 or sample_rate_hz < mains_hz * 2.5 or q <= 0.0:
309
+ self.disable_notch()
310
+ return False
311
+ w0 = 2.0 * math.pi * mains_hz / sample_rate_hz
312
+ self._set_notch_section(0, mains_hz, sample_rate_hz, math.sin(w0) / (2.0 * q))
313
+ self._notch_count = 1
314
+ self.reset_envelope()
315
+ return True
316
+
317
+ def set_mains_notch(self, mains_hz, sample_rate_hz, bandwidth_hz=2.0,
318
+ fundamental_bandwidth_hz=6.0, max_harmonics=0):
319
+ """A notch on EVERY mains harmonic up to 0.45x the sample rate.
320
+
321
+ Harmonics are bandwidth_hz wide at -3 dB, the fundamental is
322
+ fundamental_bandwidth_hz wide, so a jump in 50/60 Hz pickup settles in about
323
+ 0.1 s. max_harmonics limits the chain (0 = as many as the rate allows, at most 9).
324
+ Returns the number of notches built, or 0 (notch left off) if none can be: no
325
+ harmonic below 0.45x the rate, or a bandwidth outside 0 < bw < mains_hz/2.
326
+ """
327
+ if (mains_hz <= 0.0 or sample_rate_hz <= 0.0 or
328
+ bandwidth_hz <= 0.0 or bandwidth_hz >= mains_hz * 0.5 or
329
+ fundamental_bandwidth_hz <= 0.0 or
330
+ fundamental_bandwidth_hz >= mains_hz * 0.5):
331
+ self.disable_notch()
332
+ return 0
333
+
334
+ top = 0.45 * sample_rate_hz
335
+ # alpha = tan(pi * bw / fs): exactly bw wide at -3 dB at any frequency, unlike
336
+ # the RBJ alpha, which narrows towards Nyquist.
337
+ alpha = math.tan(math.pi * bandwidth_hz / sample_rate_hz)
338
+ alpha_fund = math.tan(math.pi * fundamental_bandwidth_hz / sample_rate_hz)
339
+
340
+ n = 0
341
+ while n < EASYEMG_MAX_NOTCH_SECTIONS:
342
+ if max_harmonics and n >= max_harmonics:
343
+ break
344
+ hz = mains_hz * (n + 1)
345
+ if hz > top:
346
+ break
347
+ self._set_notch_section(n, hz, sample_rate_hz, alpha_fund if n == 0 else alpha)
348
+ n += 1
349
+
350
+ if n == 0:
351
+ self.disable_notch()
352
+ return 0
353
+ self._notch_count = n
354
+ self.reset_envelope()
355
+ return n
356
+
357
+ def disable_notch(self):
358
+ self._notch_count = 0
359
+ self._clear_notch_state()
360
+ self.reset_envelope()
361
+
362
+ def notch_enabled(self):
363
+ return self._notch_count > 0
364
+
365
+ def notch_sections(self):
366
+ """Notches in the chain: 0 off, 1 after set_notch()."""
367
+ return self._notch_count
368
+
369
+ def _clear_notch_state(self):
370
+ for i in range(EASYEMG_MAX_NOTCH_SECTIONS):
371
+ self._nz1[i] = 0.0
372
+ self._nz2[i] = 0.0
373
+
374
+ def _apply_notch(self, x):
375
+ b0 = self._nb0
376
+ b1 = self._nb1
377
+ a2 = self._na2
378
+ z1 = self._nz1
379
+ z2 = self._nz2
380
+ for i in range(self._notch_count):
381
+ y = (b0[i] * x) + z1[i]
382
+ z1[i] = (b1[i] * x) - (b1[i] * y) + z2[i]
383
+ z2[i] = (b0[i] * x) - (a2[i] * y)
384
+ x = y
385
+ return x
386
+
387
+ # ------------------------------------------------------------- digital high-pass
388
+ def set_high_pass(self, corner_hz, sample_rate_hz):
389
+ """2nd-order Butterworth high-pass, OFF by default.
390
+
391
+ Takes electrode motion artifact out of the envelope, activation and %MVC. Pass
392
+ your ACTUAL sample rate. Returns False (filter left off) below 2.5x the corner.
393
+ """
394
+ if corner_hz <= 0.0 or sample_rate_hz < corner_hz * 2.5:
395
+ self.disable_high_pass()
396
+ return False
397
+ w0 = 2.0 * math.pi * corner_hz / sample_rate_hz
398
+ cosw0 = math.cos(w0)
399
+ alpha = math.sin(w0) / (2.0 * 0.70710678)
400
+ a0 = 1.0 + alpha
401
+ self._hb0 = ((1.0 + cosw0) / 2.0) / a0
402
+ self._hb1 = -(1.0 + cosw0) / a0
403
+ self._hb2 = self._hb0
404
+ self._ha1 = (-2.0 * cosw0) / a0
405
+ self._ha2 = (1.0 - alpha) / a0
406
+ self._hz1 = 0.0
407
+ self._hz2 = 0.0
408
+ self._hp_on = True
409
+ self.reset_envelope()
410
+ return True
411
+
412
+ def disable_high_pass(self):
413
+ self._hp_on = False
414
+ self._hz1 = 0.0
415
+ self._hz2 = 0.0
416
+ self.reset_envelope()
417
+
418
+ def high_pass_enabled(self):
419
+ return self._hp_on
420
+
421
+ def _apply_high_pass(self, x):
422
+ y = (self._hb0 * x) + self._hz1
423
+ self._hz1 = (self._hb1 * x) - (self._ha1 * y) + self._hz2
424
+ self._hz2 = (self._hb2 * x) - (self._ha2 * y)
425
+ return y
426
+
427
+ # ------------------------------------------------------------- RMS envelope
428
+ def set_envelope_window(self, samples):
429
+ """Window in SAMPLES, 1..128, because the rate depends on your host."""
430
+ self._env_window = max(1, min(EASYEMG_MAX_ENVELOPE_WINDOW, int(samples)))
431
+ self.reset_envelope()
432
+
433
+ def get_envelope_window(self):
434
+ return self._env_window
435
+
436
+ def envelope_window_ms(self, sample_rate_hz):
437
+ if sample_rate_hz <= 0.0:
438
+ return 0.0
439
+ return (1000.0 * self._env_window) / sample_rate_hz
440
+
441
+ def reset_envelope(self):
442
+ self._env_count = 0
443
+ self._env_index = 0
444
+ self._env_sum_sq = 0
445
+ self._last_envelope = 0.0
446
+ self._last_env_counts = 0.0
447
+ for i in range(EASYEMG_MAX_ENVELOPE_WINDOW):
448
+ self._env_buf[i] = 0
449
+
450
+ def read_envelope_raw(self):
451
+ """RMS of the window, in ADC counts. Takes one new sample."""
452
+ sample = self.read_raw()
453
+ sq = sample * sample
454
+ if self._env_count < self._env_window:
455
+ self._env_sum_sq += sq
456
+ self._env_buf[self._env_index] = sample
457
+ self._env_count += 1
458
+ else:
459
+ old = self._env_buf[self._env_index]
460
+ self._env_sum_sq += sq - (old * old)
461
+ self._env_buf[self._env_index] = sample
462
+ self._env_index += 1
463
+ if self._env_index >= self._env_window:
464
+ self._env_index = 0
465
+ rms = math.sqrt(self._env_sum_sq / self._env_count)
466
+ self._last_env_counts = rms
467
+ self._last_envelope = self._counts_to_uv(rms)
468
+ return int(rms + 0.5)
469
+
470
+ def read_envelope(self):
471
+ """RMS of the window, in uV at the electrodes. Takes one new sample."""
472
+ self.read_envelope_raw()
473
+ return self._last_envelope
474
+
475
+ def last_envelope(self):
476
+ """Last computed envelope, uV RMS, without a new sample."""
477
+ return self._last_envelope
478
+
479
+ def last_envelope_raw(self):
480
+ """Last computed envelope, counts (rounded), without a new sample."""
481
+ return int(self._last_env_counts + 0.5)
482
+
483
+ def last_raw(self):
484
+ return self._last_raw
485
+
486
+ def last_microvolts(self):
487
+ return self._counts_to_uv(self._last_raw)
488
+
489
+ # ------------------------------------------------------------- baseline and gain
490
+ def calibrate_baseline(self, samples=256):
491
+ """Average the resting count. Muscle relaxed, electrodes attached."""
492
+ if samples <= 0:
493
+ return
494
+ total = 0
495
+ for _ in range(samples):
496
+ total += self.read_raw_uncorrected()
497
+ self._baseline = int(total / samples)
498
+ # The filters' state refers to the old baseline; start them clean.
499
+ self._clear_notch_state()
500
+ self._hz1 = 0.0
501
+ self._hz2 = 0.0
502
+ self.reset_envelope()
503
+ self._clock_running = False
504
+
505
+ def set_baseline(self, offset_counts):
506
+ self._baseline = offset_counts
507
+ self.reset_envelope()
508
+
509
+ def get_baseline(self):
510
+ return self._baseline
511
+
512
+ def set_gain(self, gain):
513
+ if gain > 0.0:
514
+ self._gain = gain
515
+
516
+ def get_gain(self):
517
+ return self._gain
518
+
519
+ # ------------------------------------------------------------- low-level passthroughs
520
+ def set_range(self, fsr):
521
+ """Change the PGA range. The counts-to-uV maths follows automatically."""
522
+ self._fsr = fsr
523
+ self.adc.set_fsr(fsr)
524
+ self.reset_envelope()
525
+
526
+ def set_data_rate(self, dr):
527
+ """ADC conversion rate, a DR_* constant. begin() sets DR_3300SPS."""
528
+ self.adc.set_dr(dr)
529
+
530
+ def set_mode(self, mode):
531
+ """OP_CONTINUOUS or OP_SINGLE. begin() sets continuous."""
532
+ self.adc.set_mode(mode)
533
+
534
+ def set_mux(self, mux):
535
+ """Input mux, a MUX_* constant. begin() sets MUX_AIN0_AIN1."""
536
+ self.adc.set_mux(mux)
537
+
538
+ def read_single_shot(self):
539
+ """Start one conversion, wait two conversion periods, return read_raw().
540
+
541
+ Leaves the ADC in single-shot mode; call begin() to go back to continuous.
542
+ """
543
+ self.adc._mode = OP_SINGLE
544
+ self.adc._write16(_REG_CONFIG, self.adc._conf_word() | 0x8000)
545
+ dr = self.adc._dr
546
+ wait = _CONV_US[dr] if 0 <= dr < len(_CONV_US) else _CONV_US[0]
547
+ sleep_us(2 * wait)
548
+ return self.read_raw()
549
+
550
+ # ------------------------------------------------------------- activation and %MVC
551
+ def set_activation_threshold(self, uv_rms):
552
+ self._threshold = uv_rms
553
+
554
+ def get_activation_threshold(self):
555
+ return self._threshold
556
+
557
+ def is_active(self):
558
+ """Uses the last computed envelope, so take a sample first."""
559
+ return self._threshold > 0.0 and self._last_envelope >= self._threshold
560
+
561
+ def set_mvc(self, uv_rms):
562
+ self._mvc = uv_rms
563
+
564
+ def get_mvc(self):
565
+ return self._mvc
566
+
567
+ def read_percent_mvc(self):
568
+ if self._mvc <= 0.0:
569
+ return 0.0
570
+ return (self._last_envelope / self._mvc) * 100.0
571
+
572
+
573
+ class MvcRecorder:
574
+ """The MVC recording step from the Arduino 06.OpenView sketch, as a state machine.
575
+
576
+ Feed it one envelope value (uV) per call at a steady update_rate_hz. OpenView feeds
577
+ the smoothed envelope it plots, once per packet.
578
+
579
+ 1. start(): relax for settle_ms. The last second is taken as the resting level.
580
+ 2. Contract as hard as you can for record_ms (led() is True).
581
+ 3. The MVC is the highest envelope seen. It's refused if below min_uv or below
582
+ min_rest_ratio x rest: step() shows MVC_TOO_WEAK for 2 s, led() blinks at 5 Hz,
583
+ and the previous MVC stays.
584
+
585
+ With auto_raise, any later envelope above both the MVC and the refusal threshold
586
+ becomes the new MVC, so %MVC stays at or below 100. Turn it off for measurements.
587
+
588
+ If emg is given, its set_mvc() follows, so emg.read_percent_mvc() agrees.
589
+ """
590
+
591
+ def __init__(self, emg, update_rate_hz, settle_ms=EASYEMG_MVC_SETTLE_MS,
592
+ record_ms=EASYEMG_MVC_RECORD_MS, min_uv=EASYEMG_MVC_MIN_UV,
593
+ min_rest_ratio=EASYEMG_MVC_MIN_REST_RATIO,
594
+ auto_raise=EASYEMG_MVC_AUTO_RAISE):
595
+ self._emg = emg
596
+ self._per_sec = int(update_rate_hz)
597
+ self._settle_n = (settle_ms * self._per_sec) // 1000
598
+ self._record_n = (record_ms * self._per_sec) // 1000
599
+ self._blink_n = max(1, self._per_sec // 10)
600
+ self._min_uv = min_uv
601
+ self._min_ratio = min_rest_ratio
602
+ self._auto_raise = auto_raise
603
+
604
+ self._state = MVC_NONE
605
+ self._left = 0
606
+ self._mvc = 0.0
607
+ self._peak = 0.0
608
+ self._rest = 0.0
609
+ self._rest_sum = 0.0
610
+ self._rest_count = 0
611
+ self._led = False
612
+
613
+ def start(self):
614
+ """Begin (or restart) the relax-then-contract step. OpenView's 'm' command."""
615
+ self._state = MVC_RELAX
616
+ self._left = self._settle_n
617
+ self._peak = 0.0
618
+ self._rest_sum = 0.0
619
+ self._rest_count = 0
620
+ self._led = False
621
+
622
+ def threshold_uv(self):
623
+ """Smallest envelope accepted as an MVC."""
624
+ t = self._min_ratio * self._rest
625
+ return t if t > self._min_uv else self._min_uv
626
+
627
+ def _set(self, uv):
628
+ self._mvc = uv
629
+ if self._emg is not None:
630
+ self._emg.set_mvc(uv)
631
+
632
+ def update(self, env_uv):
633
+ """One envelope value, uV. Returns the step code."""
634
+ s = self._state
635
+ if s == MVC_RELAX:
636
+ if self._left <= self._per_sec: # last second of the settle
637
+ self._rest_sum += env_uv
638
+ self._rest_count += 1
639
+ self._left -= 1
640
+ if self._left <= 0:
641
+ self._rest = (self._rest_sum / self._rest_count) if self._rest_count else 0.0
642
+ self._state = MVC_CONTRACT
643
+ self._left = self._record_n
644
+ self._led = True
645
+ return self._state
646
+
647
+ if s == MVC_CONTRACT:
648
+ if env_uv > self._peak:
649
+ self._peak = env_uv
650
+ self._left -= 1
651
+ if self._left <= 0:
652
+ self._led = False
653
+ if self._peak >= self.threshold_uv():
654
+ self._set(self._peak)
655
+ self._state = MVC_SET
656
+ else:
657
+ self._state = MVC_TOO_WEAK
658
+ self._left = 2 * self._per_sec
659
+ return self._state
660
+
661
+ if s == MVC_TOO_WEAK:
662
+ self._led = bool((self._left // self._blink_n) & 1)
663
+ self._left -= 1
664
+ if self._left <= 0:
665
+ self._led = False
666
+ self._state = MVC_SET if self._mvc > 0.0 else MVC_NONE
667
+
668
+ if self._auto_raise and env_uv >= self.threshold_uv() and env_uv > self._mvc:
669
+ self._set(env_uv)
670
+ if self._state == MVC_NONE:
671
+ self._state = MVC_SET
672
+ return self._state
673
+
674
+ def step(self):
675
+ """0 none, 1 relax, 2 contract, 3 too weak, 4 set."""
676
+ return self._state
677
+
678
+ def led(self):
679
+ """True while the user should contract; blinks after a refused MVC."""
680
+ return self._led
681
+
682
+ def mvc_uv(self):
683
+ """The MVC in use, uV; 0 until one is recorded."""
684
+ return self._mvc
685
+
686
+ def rest_uv(self):
687
+ """Resting level from the last step, uV."""
688
+ return self._rest
689
+
690
+ def percent(self, env_uv):
691
+ """env_uv as a rounded percentage of the MVC; 0 with no MVC."""
692
+ if self._mvc <= 0.0:
693
+ return 0
694
+ return int(env_uv / self._mvc * 100.0 + 0.5)