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
|
+
[](https://github.com/Protocentral/protocentral-micropython-easyemg/actions/workflows/test.yml)
|
|
18
|
+
[](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
|
+

|
|
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,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.
|
protocentral_easyemg.py
ADDED
|
@@ -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)
|