protocentral-easyecg 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,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: protocentral-easyecg
3
+ Version: 0.2.0
4
+ Summary: MicroPython + Raspberry Pi driver for the Protocentral easyECG single-lead ECG breakout (TLA2024-based)
5
+ Project-URL: Homepage, https://protocentral.com
6
+ Project-URL: Source, https://github.com/Protocentral/protocentral-micropython-easyecg
7
+ Author-email: Protocentral Electronics <info@protocentral.com>
8
+ License: MIT
9
+ License-File: LICENSE.md
10
+ Keywords: easyecg,ecg,i2c,micropython,protocentral,qwiic,raspberry-pi,tla2024
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: protocentral-tla20xx>=1.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Protocentral easyECG MicroPython Library
16
+
17
+ [![Tests](https://github.com/Protocentral/protocentral-micropython-easyecg/actions/workflows/test.yml/badge.svg)](https://github.com/Protocentral/protocentral-micropython-easyecg/actions/workflows/test.yml)
18
+ [![Publish to PyPI](https://github.com/Protocentral/protocentral-micropython-easyecg/actions/workflows/publish.yml/badge.svg)](https://github.com/Protocentral/protocentral-micropython-easyecg/actions/workflows/publish.yml)
19
+
20
+ MicroPython library for the **Protocentral easyECG Breakout**: single-lead (Lead I) ECG over I²C / Qwiic. It handles the sampling, filtering and heart-rate detection. The same driver also runs on a Raspberry Pi.
21
+
22
+ ## Don't have one? [Buy it here](https://protocentral.com/product/easyecg-single-lead-ecg-breakout-qwiic/)
23
+
24
+ ![Protocentral easyECG](https://raw.githubusercontent.com/Protocentral/protocentral-micropython-easyecg/main/assets/easyecg.jpg)
25
+
26
+ **Wiring, electrode placement and troubleshooting are in the [easyECG Getting Started guide](https://protocentral.com/product/easyecg-single-lead-ecg-breakout-qwiic/docs/getting-started/).**
27
+
28
+ ## Links
29
+
30
+ | | |
31
+ |---|---|
32
+ | Product page | https://protocentral.com/product/easyecg-single-lead-ecg-breakout-qwiic/ |
33
+ | Getting Started guide | https://protocentral.com/product/easyecg-single-lead-ecg-breakout-qwiic/docs/getting-started/ |
34
+ | Arduino library | https://github.com/Protocentral/protocentral_easyecg_arduino |
35
+ | Hardware design files | https://github.com/Protocentral/protocentral_easyecg_hardware |
36
+
37
+ ## Features
38
+
39
+ - Paced sampling with missed-sample counting: 250 SPS in, 125 or 250 Hz out
40
+ - 50/60 Hz notch and 40 Hz low-pass in software; no high-pass by default, to keep the ST segment
41
+ - Heart rate from a simplified Pan-Tompkins R-peak detector
42
+ - Saturation, mains-pickup and a provisional lead-off flag
43
+ - Readings in µV at the electrodes
44
+ - A port of the [Arduino library](https://github.com/Protocentral/protocentral_easyecg_arduino) 1.0.0: the same API in snake_case, and a Python version of every Arduino example
45
+
46
+ > **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 a flat line, and `is_saturated()` reports it.
47
+
48
+ ## Installation
49
+
50
+ **MicroPython** (Raspberry Pi Pico, ESP32, …):
51
+
52
+ ```bash
53
+ mpremote mip install github:Protocentral/protocentral-micropython-easyecg
54
+ ```
55
+
56
+ This also installs the [TLA20xx driver](https://github.com/Protocentral/protocentral-micropython-tla20xx). With Thonny, copy `protocentral_easyecg.py` and `protocentral_tla20xx.py` to the board instead.
57
+
58
+ **Raspberry Pi**, with I²C enabled (`sudo raspi-config nonint do_i2c 0`):
59
+
60
+ ```bash
61
+ python3 -m venv ~/easyecg && source ~/easyecg/bin/activate
62
+ pip install protocentral-easyecg
63
+ ```
64
+
65
+ ## Quick Start
66
+
67
+ ```python
68
+ from machine import Pin, I2C
69
+ from protocentral_easyecg import EasyECG
70
+
71
+ i2c = I2C(0, scl=Pin(5), sda=Pin(4), freq=400000) # Pico: GP5, GP4. Change for your board
72
+ ecg = EasyECG(i2c) # default address 0x49
73
+ if not ecg.begin():
74
+ raise OSError("easyECG not found")
75
+
76
+ while True:
77
+ if ecg.update(): # True at the output rate, 125 Hz
78
+ print(ecg.ecg_microvolts(), ecg.heart_rate())
79
+ ```
80
+
81
+ On a Raspberry Pi, replace the I²C lines with `from protocentral_tla20xx_linux import I2C` and `EasyECG(I2C(bus=1))`.
82
+
83
+ Call `update()` as often as you can; it samples only when the clock is due, and never blocks. If `missed_samples()` grows, your loop is too slow.
84
+
85
+ ## API Reference
86
+
87
+ | Method | Description |
88
+ |---|---|
89
+ | `EasyECG(i2c, address=0x49)` / `begin()` | Construct / configure the ADC. `begin()` returns `False` if the board doesn't answer |
90
+ | `update()` | Non-blocking. `True` when a new output sample is ready |
91
+ | `ecg_microvolts()` / `ecg_filtered()` | Filtered output, in µV at the electrodes / in counts |
92
+ | `ecg_unfiltered_microvolts()` / `ecg_unfiltered()` | Plain ADC average |
93
+ | `heart_rate()` / `beat_detected()` / `last_rr_millis()` | bpm, 0 until established / true once per beat / last R-R interval |
94
+ | `is_saturated()` / `is_lead_off()` / `mains_microvolts()` | Signal-quality flags; lead-off is a provisional heuristic |
95
+ | `set_sampling(adc, out)` / `missed_samples()` | Sample clock (default 250, 125) / samples the loop couldn't take on time |
96
+ | `set_notch(hz)` / `set_low_pass(hz)` / `set_high_pass(hz)` | Filters: 50 Hz, 40 Hz, off by default. 0 disables |
97
+ | `calibrate_baseline(ms)` / `set_baseline(n)` | Measure and subtract the resting offset |
98
+
99
+ The full API, including the low-level ADC passthroughs, is documented in [protocentral_easyecg.py](https://github.com/Protocentral/protocentral-micropython-easyecg/blob/main/protocentral_easyecg.py).
100
+
101
+ ## Examples
102
+
103
+ | # | Example | Description |
104
+ |---|---|---|
105
+ | 01 | `easyecg_simpletest.py` / `easyecg_raspberrypi.py` | Filtered ECG in counts and µV (MicroPython / Raspberry Pi) |
106
+ | 02 | `easyecg_plotter.py` | Filtered and unfiltered traces for Thonny's plotter |
107
+ | 03 | `easyecg_heartrate.py` | Heart rate on every beat, with saturation and lead-off messages |
108
+ | 04 | `easyecg_openview.py` | Stream to [OpenView](https://github.com/Protocentral/protocentral_openview) 3.4+ (**easyECG Breakout**). Save it to the board as `main.py`, then close Thonny or `mpremote` before connecting OpenView |
109
+ | 05 | `easyecg_diagnostics.py` | I²C, ADC, noise, mains and sample-rate checks. **Run this first if something is wrong** |
110
+
111
+ The examples default to QT Py ESP32-C3 pins: change the `I2C(...)` line for your board.
112
+
113
+ ## License
114
+
115
+ - **Software:** MIT, see [LICENSE.md](https://github.com/Protocentral/protocentral-micropython-easyecg/blob/main/LICENSE.md)
116
+ - **Hardware:** CERN-OHL-P v2, in the [hardware repo](https://github.com/Protocentral/protocentral_easyecg_hardware)
117
+
118
+ Copyright (c) 2026 Protocentral Electronics.
119
+
120
+ ## Support
121
+
122
+ Open an issue on [GitHub](https://github.com/Protocentral/protocentral-micropython-easyecg/issues) or email support@protocentral.com.
@@ -0,0 +1,5 @@
1
+ protocentral_easyecg.py,sha256=EGVcQ7QIMBwAb4uVwEFtg8DdluoEH9geDs9Lp2z0nmU,22435
2
+ protocentral_easyecg-0.2.0.dist-info/METADATA,sha256=aR-t07hWoAJhTpHcBiqr1b0mh4zA9fM3Ge66gjOorBU,6766
3
+ protocentral_easyecg-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ protocentral_easyecg-0.2.0.dist-info/licenses/LICENSE.md,sha256=cgO4OFpz7oAWflQfXcqCpMv8MXBqds5RfR-423fycQw,1100
5
+ protocentral_easyecg-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,633 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: Copyright (c) 2026 Ashwin Whitchurch, Protocentral Electronics
3
+ #
4
+ # MicroPython driver for the Protocentral easyECG single-lead ECG breakout. An INA333
5
+ # and two OPA2333 stages (total gain x535, roughly 0.5-31 Hz) feed an on-board TI
6
+ # 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 easyECG configuration, the paced
8
+ # filter chain and the heart-rate detector.
9
+ #
10
+ # Ported from the Protocentral easyECG Arduino library v1.0.0 (single source of truth):
11
+ # https://github.com/Protocentral/protocentral_easyecg_arduino
12
+ # Same public surface, in snake_case: begin() ... read_single_shot(). Python-only extras
13
+ # are set_gain() / get_gain() and the .adc attribute.
14
+ #
15
+ # Same chain as the Arduino library:
16
+ #
17
+ # ADC -> 50/60 Hz notch -> 4th-order Butterworth low-pass (40 Hz)
18
+ # -> decimate to the output rate -> optional high-pass (OFF by default)
19
+ # -> R-peak detector -> heart rate
20
+ #
21
+ # One deliberate difference: the default ADC rate is 250 SPS, not 1000. Interpreted
22
+ # Python can't run these filters at 1 kSPS on most boards. The filters are built for an
23
+ # exact rate, so a clock the host can actually hold beats a faster one it can't. At
24
+ # 250 SPS the 50/60 Hz notch and the 40 Hz low-pass are still well inside Nyquist, and
25
+ # both output rates the Arduino examples use (125 and 250 Hz) are reachable.
26
+ #
27
+ # Requires: protocentral_tla20xx (mip/pip dependency)
28
+
29
+ import math
30
+
31
+ __version__ = "0.2.0"
32
+
33
+ try:
34
+ from time import ticks_us, ticks_diff, ticks_add, sleep_ms, sleep_us
35
+ except ImportError: # CPython (Raspberry Pi)
36
+ import time as _time
37
+
38
+ def ticks_us():
39
+ return int(_time.monotonic() * 1000000)
40
+
41
+ def ticks_diff(a, b):
42
+ return a - b
43
+
44
+ def ticks_add(a, b):
45
+ return a + b
46
+
47
+ def sleep_ms(ms):
48
+ _time.sleep(ms / 1000.0)
49
+
50
+ def sleep_us(us):
51
+ _time.sleep(us / 1000000.0)
52
+
53
+ from protocentral_tla20xx import (
54
+ TLA20XX, FSR_6_144V, FSR_4_096V, FSR_2_048V, FSR_1_024V, FSR_0_512V, FSR_0_256V,
55
+ DR_128SPS, DR_250SPS, DR_490SPS, DR_920SPS, DR_1600SPS, DR_2400SPS, DR_3300SPS,
56
+ MUX_AIN0_AIN1, MUX_AIN0_AIN3, MUX_AIN1_AIN3, MUX_AIN2_AIN3,
57
+ MUX_AIN0_GND, MUX_AIN1_GND, MUX_AIN2_GND, MUX_AIN3_GND,
58
+ OP_CONTINUOUS, OP_SINGLE, _REG_CONFIG,
59
+ )
60
+
61
+ # Arduino's easyECG::Mode names for the TLA20xx OP_* values.
62
+ MODE_CONTINUOUS = OP_CONTINUOUS
63
+ MODE_SINGLE_SHOT = OP_SINGLE
64
+
65
+ # JP1 bridged 1-2 straps ADDR to 3.3 V -> 0x49. Cut and bridge 2-3 for 0x48. An easyEMG
66
+ # ships at 0x48, so the two share a bus without touching the jumper.
67
+ EASYECG_I2C_ADDR_DEFAULT = 0x49
68
+ EASYECG_I2C_ADDR_ALT = 0x48
69
+
70
+ # INA333 x11 (R10 = 10k) times 1 + R14/R19 = 1 + 100k/2.1k = x48.6. Design value; pass
71
+ # your measured gain to set_gain(). Only absolute microvolts depend on it.
72
+ EASYECG_DEFAULT_GAIN = 535.0
73
+ EASYECG_GAIN = EASYECG_DEFAULT_GAIN # Arduino name
74
+
75
+ EASYECG_HPF_HZ = 0.49 # C6 1 uF / R13 324k, 1-pole
76
+ EASYECG_LPF_HZ = 31.0 # Sallen-Key, 33k / 100n, Q = 0.5
77
+
78
+ # The OPA2333 swings to about +/-1.6 V around mid-rail; 1500 counts leaves some margin.
79
+ EASYECG_SATURATION_COUNTS = 1500
80
+
81
+ EASYECG_CONV_REG = 0x00
82
+ EASYECG_CONF_REG = 0x01
83
+
84
+ # Arduino defaults to 1000 SPS (500 on AVR). See the note at the top of this file.
85
+ EASYECG_DEFAULT_ADC_RATE = 250
86
+ EASYECG_DEFAULT_OUTPUT_RATE = 125
87
+ EASYECG_MAX_DECIMATION = 16
88
+ EASYECG_MWI_MAX = 40
89
+ EASYECG_RR_HISTORY = 8
90
+
91
+ # PROVISIONAL: not yet validated on the bench per electrode.
92
+ _DEFAULT_LEADOFF_UV = 200.0
93
+
94
+ _BW4_Q1 = 0.54119610
95
+ _BW4_Q2 = 1.30656296
96
+
97
+ # TLA2024 conversion time per DR code, microseconds (datasheet minimum rates).
98
+ _CONV_US = (7813, 4000, 2041, 1087, 625, 417, 303)
99
+
100
+ _FSR_VOLTS = {
101
+ FSR_6_144V: 6.144, FSR_4_096V: 4.096, FSR_2_048V: 2.048,
102
+ FSR_1_024V: 1.024, FSR_0_512V: 0.512, FSR_0_256V: 0.256,
103
+ }
104
+
105
+
106
+ class _Biquad:
107
+ """RBJ cookbook biquad, direct form II transposed."""
108
+
109
+ def __init__(self, b0=1.0, b1=0.0, b2=0.0, a1=0.0, a2=0.0):
110
+ self.b0, self.b1, self.b2, self.a1, self.a2 = b0, b1, b2, a1, a2
111
+ self.z1 = 0.0
112
+ self.z2 = 0.0
113
+
114
+ def clear(self):
115
+ self.z1 = 0.0
116
+ self.z2 = 0.0
117
+
118
+ def run(self, x):
119
+ y = (self.b0 * x) + self.z1
120
+ self.z1 = (self.b1 * x) - (self.a1 * y) + self.z2
121
+ self.z2 = (self.b2 * x) - (self.a2 * y)
122
+ return y
123
+
124
+
125
+ def _low_pass(fc, fs, q):
126
+ w0 = 2.0 * math.pi * fc / fs
127
+ cosw0 = math.cos(w0)
128
+ alpha = math.sin(w0) / (2.0 * q)
129
+ a0 = 1.0 + alpha
130
+ b0 = ((1.0 - cosw0) * 0.5) / a0
131
+ return _Biquad(b0, (1.0 - cosw0) / a0, b0, (-2.0 * cosw0) / a0, (1.0 - alpha) / a0)
132
+
133
+
134
+ def _notch(f0, fs, q):
135
+ w0 = 2.0 * math.pi * f0 / fs
136
+ cosw0 = math.cos(w0)
137
+ alpha = math.sin(w0) / (2.0 * q)
138
+ a0 = 1.0 + alpha
139
+ b0 = 1.0 / a0
140
+ b1 = (-2.0 * cosw0) / a0
141
+ return _Biquad(b0, b1, b0, b1, (1.0 - alpha) / a0)
142
+
143
+
144
+ def _clamp_counts(v):
145
+ # Arduino clampCounts(): saturate to int16, round half away from zero.
146
+ if v > 32767.0:
147
+ v = 32767.0
148
+ elif v < -32768.0:
149
+ v = -32768.0
150
+ return int(v + 0.5) if v >= 0.0 else int(v - 0.5)
151
+
152
+
153
+ class EasyECG:
154
+ """Driver for the Protocentral easyECG breakout.
155
+
156
+ Example:
157
+ from machine import Pin, I2C
158
+ from protocentral_easyecg import EasyECG
159
+ i2c = I2C(0, scl=Pin(6), sda=Pin(5), freq=400000) # QT Py C3: SCL=6, SDA=5
160
+ ecg = EasyECG(i2c)
161
+ ecg.begin()
162
+ while True:
163
+ if ecg.update():
164
+ print(ecg.ecg_microvolts(), ecg.heart_rate())
165
+ """
166
+
167
+ def __init__(self, i2c, address=EASYECG_I2C_ADDR_DEFAULT):
168
+ self.i2c = i2c
169
+ self.address = address
170
+ self.adc = TLA20XX(i2c, address)
171
+ self._fsr = FSR_2_048V
172
+ self._gain = EASYECG_DEFAULT_GAIN
173
+ self._baseline = 0
174
+
175
+ self._adc_rate = EASYECG_DEFAULT_ADC_RATE
176
+ self._out_rate = EASYECG_DEFAULT_OUTPUT_RATE
177
+ self._decim = EASYECG_DEFAULT_ADC_RATE // EASYECG_DEFAULT_OUTPUT_RATE
178
+ self._period_us = 1000000 // EASYECG_DEFAULT_ADC_RATE
179
+ self._next_us = 0
180
+ self._clock_running = False
181
+ self._missed = 0
182
+
183
+ self._notch_on = True
184
+ self._notch_hz = 50.0
185
+ self._notch_q = 4.0
186
+ self._lpf_hz = 40.0
187
+ self._hpf_hz = 0.0
188
+ self._lead_off_uv = _DEFAULT_LEADOFF_UV
189
+
190
+ self._rebuild_filters()
191
+
192
+ # ------------------------------------------------------------- connection
193
+ def begin(self):
194
+ """AIN0/AIN1, +/-2.048 V, continuous, 3300 SPS -> CONF 0x04C3.
195
+
196
+ Returns False without touching the bus further if the ADC doesn't answer. A write
197
+ to an absent device raises OSError on real I2C, so check first.
198
+ """
199
+ if not self.is_connected():
200
+ return False
201
+ self.adc.begin()
202
+ self.adc._mux = MUX_AIN0_AIN1
203
+ self.adc._mode = OP_CONTINUOUS
204
+ self.adc._dr = DR_3300SPS
205
+ self._fsr = FSR_2_048V
206
+ self.adc.set_fsr(FSR_2_048V)
207
+ self.reset_filters()
208
+ return True
209
+
210
+ def is_connected(self):
211
+ return self.address in self.i2c.scan()
212
+
213
+ def read_config(self):
214
+ """CONFIG register as read back. Bit 15 reads 0 while a conversion is in progress."""
215
+ return self.adc._read16(_REG_CONFIG)
216
+
217
+ # ------------------------------------------------------------- single samples
218
+ def read_raw_uncorrected(self):
219
+ """Signed 12-bit counts straight from the ADC."""
220
+ return self.adc.read_adc()
221
+
222
+ def read_raw(self):
223
+ """Signed 12-bit counts, baseline-corrected."""
224
+ return self.read_raw_uncorrected() - self._baseline
225
+
226
+ def read_voltage(self):
227
+ """Differential ADC voltage, volts (output-referred)."""
228
+ return self.read_raw() * (self.full_scale_volts() / 2048.0)
229
+
230
+ def read_microvolts(self):
231
+ """uV at the electrodes, unfiltered."""
232
+ return self._counts_to_uv(self.read_raw())
233
+
234
+ def full_scale_volts(self):
235
+ # Codes 6 and 7 aren't in the table; fall back to +/-2.048 V as Arduino does.
236
+ return _FSR_VOLTS.get(self._fsr, 2.048)
237
+
238
+ def _counts_to_uv(self, counts):
239
+ if self._gain <= 0.0:
240
+ return 0.0
241
+ return counts * (self.full_scale_volts() / 2048.0) * 1000000.0 / self._gain
242
+
243
+ # ------------------------------------------------------------- filter set-up
244
+ def _rebuild_filters(self):
245
+ fs = float(self._adc_rate)
246
+
247
+ # Always designed: its residue is the mains measurement.
248
+ nf = self._notch_hz if self._notch_hz > 0.0 else 50.0
249
+ self._notch = _notch(nf, fs, self._notch_q)
250
+
251
+ if self._lpf_hz > 0.0:
252
+ self._lpf1 = _low_pass(self._lpf_hz, fs, _BW4_Q1)
253
+ self._lpf2 = _low_pass(self._lpf_hz, fs, _BW4_Q2)
254
+
255
+ if self._hpf_hz > 0.0:
256
+ rc = 1.0 / (2.0 * math.pi * self._hpf_hz)
257
+ dt = 1.0 / self._out_rate
258
+ self._hpf_a = rc / (rc + dt)
259
+ else:
260
+ self._hpf_a = 1.0
261
+
262
+ self._sat_alpha = 1.0 / (0.25 * fs)
263
+ self._mains_alpha = 1.0 / (0.5 * fs)
264
+
265
+ self._mwi_len = max(1, min(EASYECG_MWI_MAX, int(0.15 * self._out_rate + 0.5)))
266
+ self.reset_filters()
267
+
268
+ def reset_filters(self):
269
+ """Clears filter, detector and status state, e.g. after re-attaching electrodes."""
270
+ self._notch.clear()
271
+ if self._lpf_hz > 0.0:
272
+ self._lpf1.clear()
273
+ self._lpf2.clear()
274
+ self._hpf_prev_x = 0.0
275
+ self._hpf_prev_y = 0.0
276
+
277
+ self._decim_count = 0
278
+ self._raw_accum = 0.0
279
+ self._out_ready = False
280
+ self._out_filtered = 0.0
281
+ self._out_unfiltered = 0.0
282
+
283
+ self._sat_level = 0.0
284
+ self._mains_sq = 0.0
285
+
286
+ self._hr_x = [0.0] * 5
287
+ self._mwi_buf = [0.0] * EASYECG_MWI_MAX
288
+ self._mwi_idx = 0
289
+ self._mwi_sum = 0.0
290
+ self._sig_level = 0.0
291
+ self._noise_level = 0.0
292
+ self._hr_count = 0
293
+ self._last_beat = 0
294
+ self._have_beat = False
295
+ self._in_peak = False
296
+ self._peak_val = 0.0
297
+ self._rr = [0] * EASYECG_RR_HISTORY
298
+ self._rr_count = 0
299
+ self._rr_idx = 0
300
+ self._last_rr = 0
301
+ self._bpm = 0.0
302
+ self._beat_flag = False
303
+
304
+ self._clock_running = False
305
+
306
+ def set_sampling(self, adc_rate_hz, output_rate_hz):
307
+ """adc_rate_hz must be a multiple of output_rate_hz, at most 16 times it.
308
+
309
+ Returns False and keeps the old setting if the pair is invalid.
310
+ """
311
+ if adc_rate_hz <= 0 or output_rate_hz <= 0 or adc_rate_hz % output_rate_hz:
312
+ return False
313
+ d = adc_rate_hz // output_rate_hz
314
+ if d < 1 or d > EASYECG_MAX_DECIMATION:
315
+ return False
316
+ if self._lpf_hz > 0.0 and self._lpf_hz > 0.45 * adc_rate_hz:
317
+ return False
318
+ if self._notch_hz > 0.0 and adc_rate_hz < self._notch_hz * 2.5:
319
+ return False
320
+ self._adc_rate = adc_rate_hz
321
+ self._out_rate = output_rate_hz
322
+ self._decim = d
323
+ self._period_us = 1000000 // adc_rate_hz
324
+ self._rebuild_filters()
325
+ return True
326
+
327
+ def adc_rate_hz(self):
328
+ return self._adc_rate
329
+
330
+ def output_rate_hz(self):
331
+ return self._out_rate
332
+
333
+ def set_notch(self, mains_hz, q=4.0):
334
+ """Mains notch, 50 or 60 Hz. On by default at 50 Hz. Pass 0 to disable."""
335
+ if mains_hz <= 0.0:
336
+ self._notch_on = False
337
+ self._notch_hz = 0.0
338
+ self._rebuild_filters()
339
+ return True
340
+ if self._adc_rate < mains_hz * 2.5 or q <= 0.0:
341
+ return False
342
+ self._notch_on = True
343
+ self._notch_hz = mains_hz
344
+ self._notch_q = q
345
+ self._rebuild_filters()
346
+ return True
347
+
348
+ def notch_enabled(self):
349
+ return self._notch_on
350
+
351
+ def set_low_pass(self, corner_hz):
352
+ """Digital low-pass corner, Hz. Default 40. Pass 0 to disable."""
353
+ if corner_hz < 0.0 or corner_hz > 0.45 * self._adc_rate:
354
+ return False
355
+ self._lpf_hz = corner_hz
356
+ self._rebuild_filters()
357
+ return True
358
+
359
+ def set_high_pass(self, corner_hz):
360
+ """Digital high-pass corner, Hz. Default 0 = OFF.
361
+
362
+ Off on purpose: the board is already AC-coupled at about 0.5 Hz, and every extra
363
+ high-pass pole distorts the ST segment.
364
+ """
365
+ self._hpf_hz = corner_hz if corner_hz > 0.0 else 0.0
366
+ self._rebuild_filters()
367
+
368
+ # ------------------------------------------------------------- paced pipeline
369
+ def update(self):
370
+ """Non-blocking. Call as often as you can.
371
+
372
+ Takes one ADC sample when the sample clock is due and returns True when a new
373
+ output sample is ready.
374
+ """
375
+ now = ticks_us()
376
+ if not self._clock_running:
377
+ self._clock_running = True
378
+ self._next_us = now
379
+
380
+ late = ticks_diff(now, self._next_us)
381
+ if late < 0:
382
+ return False
383
+
384
+ # Advance by whole periods so the rate stays exact. Resync only if badly behind.
385
+ if late > 4 * self._period_us:
386
+ self._missed += late // self._period_us
387
+ self._next_us = now
388
+ self._next_us = ticks_add(self._next_us, self._period_us)
389
+
390
+ self._out_ready = False
391
+ self._process_adc_sample(self.read_raw_uncorrected())
392
+ if self._out_ready:
393
+ self._out_ready = False
394
+ return True
395
+ return False
396
+
397
+ def _process_adc_sample(self, uncorrected):
398
+ ind = 1.0 if abs(uncorrected) >= EASYECG_SATURATION_COUNTS else 0.0
399
+ self._sat_level += self._sat_alpha * (ind - self._sat_level)
400
+
401
+ x = float(uncorrected - self._baseline)
402
+
403
+ n = self._notch.run(x)
404
+ resid = x - n
405
+ self._mains_sq += self._mains_alpha * ((resid * resid) - self._mains_sq)
406
+
407
+ y = n if self._notch_on else x
408
+ if self._lpf_hz > 0.0:
409
+ y = self._lpf2.run(self._lpf1.run(y))
410
+
411
+ self._raw_accum += x
412
+ self._decim_count += 1
413
+ if self._decim_count >= self._decim:
414
+ self._out_unfiltered = self._raw_accum / self._decim
415
+ self._raw_accum = 0.0
416
+ self._decim_count = 0
417
+ self._process_output_sample(y)
418
+
419
+ def _process_output_sample(self, x):
420
+ if self._hpf_hz > 0.0:
421
+ y = self._hpf_a * (self._hpf_prev_y + x - self._hpf_prev_x)
422
+ self._hpf_prev_x = x
423
+ self._hpf_prev_y = y
424
+ x = y
425
+ self._out_filtered = x
426
+ self._out_ready = True
427
+ self._detect_beat(x)
428
+
429
+ def ecg_filtered(self):
430
+ return _clamp_counts(self._out_filtered)
431
+
432
+ def ecg_microvolts(self):
433
+ return self._counts_to_uv(self._out_filtered)
434
+
435
+ def ecg_unfiltered(self):
436
+ return _clamp_counts(self._out_unfiltered)
437
+
438
+ def ecg_unfiltered_microvolts(self):
439
+ return self._counts_to_uv(self._out_unfiltered)
440
+
441
+ def missed_samples(self):
442
+ return self._missed
443
+
444
+ # ------------------------------------------------------------- heart rate
445
+ def _detect_beat(self, x):
446
+ # Simplified Pan-Tompkins: 5-point derivative -> square -> 150 ms moving-window
447
+ # integral -> adaptive threshold, 200 ms refractory, T-wave rejection inside 360 ms.
448
+ h = self._hr_x
449
+ h[4] = h[3] # one at a time: a 5-way swap builds a tuple
450
+ h[3] = h[2]
451
+ h[2] = h[1]
452
+ h[1] = h[0]
453
+ h[0] = x
454
+ d = ((2.0 * h[0]) + h[1] - h[3] - (2.0 * h[4])) * 0.125
455
+ sq = d * d
456
+
457
+ self._mwi_sum -= self._mwi_buf[self._mwi_idx]
458
+ self._mwi_buf[self._mwi_idx] = sq
459
+ self._mwi_sum += sq
460
+ self._mwi_idx += 1
461
+ if self._mwi_idx >= self._mwi_len:
462
+ self._mwi_idx = 0
463
+ if self._mwi_sum < 0.0:
464
+ self._mwi_sum = 0.0
465
+ mwi = self._mwi_sum / self._mwi_len
466
+
467
+ self._hr_count += 1
468
+ if self._hr_count <= 2 * self._out_rate: # learning phase
469
+ if mwi > self._sig_level:
470
+ self._sig_level = mwi
471
+ self._noise_level += (mwi - self._noise_level) / self._hr_count
472
+ return
473
+
474
+ threshold = self._noise_level + 0.25 * (self._sig_level - self._noise_level)
475
+ since = self._hr_count - self._last_beat
476
+ refractory = int(0.2 * self._out_rate)
477
+
478
+ if not self._in_peak:
479
+ if mwi > threshold and (not self._have_beat or since > refractory):
480
+ self._in_peak = True
481
+ self._peak_val = mwi
482
+ else:
483
+ self._noise_level += 0.01 * (mwi - self._noise_level)
484
+ else:
485
+ if mwi > self._peak_val:
486
+ self._peak_val = mwi
487
+ elif mwi < 0.5 * self._peak_val:
488
+ self._in_peak = False
489
+ rr_ms = (since * 1000) // self._out_rate
490
+
491
+ # A small peak soon after a beat is a T-wave, not a QRS.
492
+ if self._have_beat and rr_ms < 360 and self._peak_val < 0.5 * self._sig_level:
493
+ self._noise_level += 0.125 * (self._peak_val - self._noise_level)
494
+ return
495
+
496
+ self._sig_level = 0.125 * self._peak_val + 0.875 * self._sig_level
497
+
498
+ if self._have_beat and 270 <= rr_ms <= 2000: # 30-220 bpm
499
+ self._last_rr = rr_ms
500
+ self._rr[self._rr_idx] = rr_ms
501
+ self._rr_idx = (self._rr_idx + 1) % EASYECG_RR_HISTORY
502
+ if self._rr_count < EASYECG_RR_HISTORY:
503
+ self._rr_count += 1
504
+ total = 0 # no slice: keep allocations out
505
+ rr = self._rr
506
+ for i in range(self._rr_count):
507
+ total += rr[i]
508
+ self._bpm = 60000.0 / (total / self._rr_count)
509
+
510
+ self._have_beat = True
511
+ self._last_beat = self._hr_count
512
+ self._beat_flag = True
513
+
514
+ # No beat for 3 s: let a stale signal level decay. After 5 s, stop reporting a rate.
515
+ if self._have_beat and since > 3 * self._out_rate and not self._in_peak:
516
+ self._sig_level *= 0.98
517
+ if self._sig_level < self._noise_level:
518
+ self._sig_level = self._noise_level
519
+ if self._have_beat and since > 5 * self._out_rate:
520
+ self._bpm = 0.0
521
+ self._rr_count = 0
522
+ self._rr_idx = 0
523
+
524
+ def heart_rate(self):
525
+ """Beats per minute, 0 until established (about 2 s to learn, then a few beats)."""
526
+ return self._bpm
527
+
528
+ def beat_detected(self):
529
+ """True once per detected beat; clears when read."""
530
+ b = self._beat_flag
531
+ self._beat_flag = False
532
+ return b
533
+
534
+ def last_rr_millis(self):
535
+ """Most recent R-R interval, ms (Arduino lastRRMillis())."""
536
+ return self._last_rr
537
+
538
+ last_rr_ms = last_rr_millis # 0.1.0 name, kept for compatibility
539
+
540
+ # ------------------------------------------------------------- signal quality
541
+ def is_saturated(self):
542
+ """Pinned near a rail for most of the last ~250 ms.
543
+
544
+ On a mains-powered host this is the driven-right-leg failure: use a battery or an
545
+ easyISO. It is also what a floating electrode looks like.
546
+ """
547
+ return self._sat_level > 0.5
548
+
549
+ def mains_microvolts(self):
550
+ """Mains pickup, uV RMS at the electrodes: what the notch is removing."""
551
+ return self._counts_to_uv(math.sqrt(self._mains_sq))
552
+
553
+ def is_lead_off(self):
554
+ """PROVISIONAL heuristic: saturated, or mains pickup above the threshold."""
555
+ return self.is_saturated() or (
556
+ self._lead_off_uv > 0.0 and self.mains_microvolts() > self._lead_off_uv)
557
+
558
+ def set_lead_off_threshold(self, mains_uv_rms):
559
+ self._lead_off_uv = mains_uv_rms
560
+
561
+ def get_lead_off_threshold(self):
562
+ return self._lead_off_uv
563
+
564
+ # ------------------------------------------------------------- baseline and gain
565
+ def calibrate_baseline(self, duration_ms=1000):
566
+ """Average the uncorrected reading over whole heartbeats. Electrodes on, sit still."""
567
+ if duration_ms <= 0:
568
+ return
569
+ # Average the UNCORRECTED count so repeated calls converge instead of stacking.
570
+ total = 0
571
+ n = 0
572
+ t0 = ticks_us()
573
+ while ticks_diff(ticks_us(), t0) < duration_ms * 1000:
574
+ total += self.read_raw_uncorrected()
575
+ n += 1
576
+ sleep_us(500)
577
+ if n:
578
+ self._baseline = int(total / n) # truncates toward zero, as in C
579
+ self.reset_filters()
580
+
581
+ def set_baseline(self, offset_counts):
582
+ self._baseline = offset_counts
583
+ self.reset_filters()
584
+
585
+ def get_baseline(self):
586
+ return self._baseline
587
+
588
+ def set_gain(self, gain):
589
+ """Python-only. Arduino fixes the gain at EASYECG_GAIN (set by R10, R14, R19).
590
+
591
+ Pass a measured gain to correct absolute microvolts. Ignored unless > 0.
592
+ """
593
+ if gain > 0.0:
594
+ self._gain = gain
595
+
596
+ def get_gain(self):
597
+ return self._gain
598
+
599
+ # ------------------------------------------------------------- low-level passthroughs
600
+ def set_data_rate(self, rate):
601
+ """ADC conversion rate, a DR_* code. begin() sets DR_3300SPS.
602
+
603
+ Leave it there for the paced pipeline: the conversion must outrun the ADC rate
604
+ set with set_sampling(), or update() reads the same conversion twice.
605
+ """
606
+ self.adc.set_dr(rate)
607
+
608
+ def set_range(self, fsr):
609
+ """Change the PGA range, an FSR_* code. +/-2.048 V is the ECG range; see the README."""
610
+ self._fsr = fsr
611
+ self.adc.set_fsr(fsr)
612
+ self.reset_filters()
613
+
614
+ def set_mode(self, mode):
615
+ """MODE_CONTINUOUS (begin() default) or MODE_SINGLE_SHOT."""
616
+ self.adc.set_mode(mode)
617
+
618
+ def set_mux(self, mux):
619
+ """Input mux, a MUX_* code. begin() sets MUX_AIN0_AIN1 (signal vs mid-rail)."""
620
+ self.adc.set_mux(mux)
621
+
622
+ def read_single_shot(self):
623
+ """Start one conversion, wait for it, return baseline-corrected counts.
624
+
625
+ As in Arduino, this leaves the ADC in single-shot mode: call
626
+ set_mode(MODE_CONTINUOUS) or begin() before using update() again.
627
+ """
628
+ adc = self.adc
629
+ adc._mode = OP_SINGLE
630
+ adc._write16(_REG_CONFIG, adc._conf_word() | 0x8000)
631
+ wait = _CONV_US[adc._dr] if 0 <= adc._dr < len(_CONV_US) else _CONV_US[0]
632
+ sleep_us(2 * wait)
633
+ return self.read_raw()