Metadata-Version: 2.5
Name: senswear-heartrate
Version: 0.1.0
Summary: Real-time heart rate, SpO2, pulse waveform and body temperature monitor for SensWear devices.
Project-URL: Homepage, https://sens-wear.com
Author: Sensera Technologies
License-Expression: MIT
License-File: LICENSE
Keywords: ble,heart-rate,ppg,senswear,spo2,temperature,wearable
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
Requires-Python: >=3.10
Requires-Dist: bleak>=0.22.0
Requires-Dist: numpy>=1.26
Requires-Dist: scipy>=1.11
Requires-Dist: senswear>=0.4.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Provides-Extra: gui
Requires-Dist: pyqtgraph>=0.13.7; extra == 'gui'
Requires-Dist: pyside6>=6.6; extra == 'gui'
Description-Content-Type: text/markdown

# SensWear Heart Rate Monitor

A desktop application that shows, in real time, what the SensWear PPG and
temperature daughter boards measure:

| Panel | Content | Source |
| --- | --- | --- |
| Raw PPG | Red, infrared and green MAX30101 channels (18-bit ADC counts, about 200 Hz each) | PPG service notifications |
| Arterial pulse waveform | Inverted, band-passed pulse (percent of the DC level) with detected beats | Derived on the host |
| Heart rate | Beats per minute from peak detection, cross-checked against the spectral peak | Derived on the host |
| SpO2 estimate | Ratio-of-ratios on the red/infrared channels (uncalibrated) | Derived on the host |
| Perfusion index | Infrared AC/DC in percent | Derived on the host |
| Body temperature | MAX30208 reading via the Health Thermometer Service, with a trend | Temperature indications |

It is built only on the public [`senswear` Python SDK](https://pypi.org/project/senswear/);
neither the firmware nor the SDK is modified.

> The heart rate, SpO2 and perfusion numbers are signal-processing estimates
> from a wrist-worn optical sensor. They are not calibrated and not a medical
> measurement.

## Requirements

- Python 3.10 or newer and a Bluetooth LE adapter (Windows, macOS and Linux
  are supported by `bleak`; the application has been exercised on Windows 11).
- A SensWear device flashed with the PPG application preset
  (`senswear_nrf54l15_cpuapp_ppg`). That preset also enables the temperature
  shield because the PPG daughter board carries the MAX30208.
- `numpy`, `scipy`, `pyqtgraph` and `PySide6` (installed below).

## Installation

From PyPI, with the dashboard:

```sh
python -m pip install "senswear-heartrate[gui]"
```

The base package without the `gui` extra is enough for the terminal mode
(`--no-gui`) and CSV recording. A virtual environment keeps the Qt
dependencies isolated:

```sh
python -m venv .venv
.venv\Scripts\activate          # Windows
source .venv/bin/activate       # macOS / Linux
python -m pip install "senswear-heartrate[gui]"
```

From a source checkout, run the same command in the project directory:

```sh
python -m pip install -e ".[gui,dev]"
```

## Running

```sh
senswear-heartrate
```

`python -m senswear_heartrate` is equivalent. The dashboard connects to the
first advertising SensWear device, sets its clock, subscribes to the three PPG
channels and the temperature indications, restarts PPG acquisition so firmware
and sensor start in sync, and reconnects automatically if the link drops. On
exit it restores the PPG sampling state it found.

Try it without hardware first:

```sh
senswear-heartrate --simulate
```

To pick a specific device, scan first and pass its address or exact advertised
name:

```sh
senswear-heartrate --list-devices
senswear-heartrate --device AA:BB:CC:DD:EE:FF
senswear-heartrate --device "Sens Wear (OOB)"
```

Useful options (`--help` lists all of them):

| Option | Effect |
| --- | --- |
| `--device ID` | Connect to a specific BLE address or exact advertised name |
| `--list-devices` | Scan and print SensWear devices, then exit |
| `--no-gui` | Print one line per second in the terminal instead of the dashboard |
| `--record [PREFIX]` | Write raw samples, temperature and metrics to `PREFIX_*.csv` (default `recordings/senswear_<timestamp>`) |
| `--window 15` | Seconds of signal shown in the raw and waveform panels |
| `--hr-channel ir` | Channel used for heart rate (default `green`, the strongest pulse at the wrist) |
| `--waveform-channel green` | Channel shown as the arterial pulse waveform (default `ir`) |
| `--temperature-interval 2` | Body-temperature cadence in whole minutes (default 1; the firmware only supports whole minutes) |
| `--simulate --sim-hr 95 --sim-spo2 93 --sim-artifacts 20` | Synthetic signal with chosen targets and a motion burst every 20 s |
| `--duration 30 --screenshot shot.png` | Stop after 30 s and save a picture of the dashboard |
| `-v` / `-vv` | Verbose / debug logging |

The controls at the bottom of the window change the visible window, the
channels used for heart rate and for the waveform, pause the display (data keeps
flowing), start or stop CSV recording, and force a reconnect.

### Reading the dashboard

- **Heart rate** shows four quality dots and a status: `ok`, `settling` (less
  than three seconds of data), `tracking` (the rate jumped by more than
  25 bpm; the new value is shown once it persists for about a second),
  `no contact` (nothing pulsates), `motion` (the pulsatile part is implausibly
  large or its amplitude changes several-fold from second to second), `noisy`
  (beat and spectral estimates disagree or the beats are irregular),
  `dropouts` (notifications were lost) or `saturated` (ADC at its limits).
  While the status is not `ok` the last good value is kept for about two
  seconds and then cleared. Analysis uses the last 8 s, so after a movement
  the status stays `motion` until the burst has left that window.
- **SpO2** is only computed while the heart-rate status is `ok` and the pulse
  amplitude is steady; the tile shows the ratio of ratios `R` it came from and
  keeps its last value for a few seconds when a window is skipped.
- **Body temperature** arrives once per measurement interval (one minute by
  default), so the first value can take up to a minute. The tile shows how old
  the reading is and the device's own timestamp.
- **Device** shows the firmware version, battery level and the delivered PPG
  notification rate (about 25 Hz with the current firmware, see below).

## How it works

### Data path

The SDK delivers one notification per channel per sample (three callbacks per
sample time, matched by `timestamp_ms`). The engine (`engine.py`) runs the BLE
session on its own asyncio loop in a background thread and appends every sample
to a lock-protected ring buffer (`buffers.py`). The GUI timer (20 Hz) takes a
snapshot of the last *window* seconds for drawing and, four times per second,
hands the snapshot to the processor. Nothing in the BLE callback blocks on
drawing or disk; CSV recording goes through a queue to a writer thread.

### Signal processing (`processing.py`)

1. The sample rate is estimated from the firmware timestamps and each channel is
   linearly resampled onto a uniform grid, which also tolerates lost
   notifications (the largest gap is reported).
2. A zero-phase 4th-order Butterworth band-pass (0.5-4 Hz) separates the
   pulsatile AC part from the DC level. The signal is inverted so a beat is a
   positive peak: in reflectance PPG more blood absorbs more light.
3. **Beats** are found with a prominence-based peak detector constrained to
   40-200 bpm; the heart rate is 60 / median inter-beat interval over an 8 s
   window. A zero-padded FFT of the same window gives the **spectral** rate and
   how concentrated the band power is.
4. **Quality** (0..1) combines the agreement of the two estimates, the
   regularity of the inter-beat intervals, the spectral purity and the
   second-to-second steadiness of the pulse amplitude; it is zero without
   contact (perfusion index outside 0.05-20 %), during motion bursts or with
   saturation. A heart-rate jump above 25 bpm is only accepted once it has
   persisted for four consecutive analyses (about one second).
5. **SpO2** uses the ratio of ratios `R = (AC_red/DC_red) / (AC_ir/DC_ir)` with
   RMS amplitudes and the generic MAX3010x curve
   `SpO2 = -45.060 R^2 + 30.354 R + 94.845`, clamped to 70-100 %. The
   coefficients live in `ProcessingConfig.spo2_coefficients`; a device-specific
   calibration against a reference oximeter is required for meaningful absolute
   values.
6. The **arterial pulse waveform** is the inverted 0.4-8 Hz band-pass of the
   selected channel divided by its DC level, which keeps the dicrotic notch
   visible; beats are marked on it. This trace uses a causal filter (about
   0.1 s of lag, no edge artifact on the newest samples) whose warm-up runs
   off-screen, whereas the heart-rate analysis filters its window zero-phase.
7. Heart rate and SpO2 are smoothed with an exponential moving average.

### Firmware facts the application relies on

- PPG: MAX30101 in multi-LED mode (IR, red, green). The firmware asks for
  3200 Hz with 16-sample averaging and documents 200 samples per second per
  channel, but that rate is not allowed with three LED slots at the configured
  pulse width, so the sensor falls back to 400 Hz and delivers about **25
  samples per second per channel**, in FIFO batches of 26 roughly once per
  second (measured on firmware 0.1.0). The firmware still stamps each batch at
  5 ms spacing, so the application re-spreads every batch between the real
  interrupt times (`repair_batch_timestamps`); the processing adapts to the
  measured rate. Values are raw 18-bit counts; LED current, pulse width and
  averaging cannot be changed over BLE, and the current LED currents are low
  (about 1.6 mA red/IR, 0.6 mA green), so the signal is small.
- The out-of-the-box firmware reports PPG sampling enabled at boot, but on the
  tested device no samples were ever published until the interrupt cadence
  was switched to per-sample and back (a plain stop/start did not help). The
  engine therefore waits three seconds for the first notification and, if
  none arrives, performs that toggle automatically before warning.
- Temperature: Health Thermometer Service, indicate-only, Celsius, with the
  device timestamp and type `BODY`. The interval characteristic is in seconds
  but the firmware schedules whole minutes (the SDK rejects other values).

## Project layout

```
senswear_heartrate/
  app.py          command line entry point, headless mode
  buffers.py      thread-safe ring buffers and snapshots
  engine.py       BLE session: connect, configure, stream, reconnect
  events.py       status/event types shared by the sources and the UI
  gui.py          pyqtgraph dashboard
  processing.py   DSP: filters, beats, spectral HR, SpO2, perfusion, waveform (pure)
  recorder.py     CSV recording on a writer thread
  simulate.py     synthetic PPG/temperature source for development and tests
tests/            pytest suite, runs without hardware (fake SDK client)
```

Run the tests from a source checkout with:

```sh
python -m pytest
```

## Troubleshooting

- **No device found**: make sure the device is powered, advertising and not
  connected to another host (phone app, nRF Connect). `--list-devices` shows
  what the adapter sees; `--device` selects one explicitly.
- **"no PPG service"**: the connected firmware is not the PPG build. Flash the
  `senswear_nrf54l15_cpuapp_ppg` preset.
- **Status stays `no contact`**: the sensor must sit flat on the skin with
  light pressure; the perfusion index tile shows how much pulsation it sees.
- **Status `noisy` or `motion`**: keep the arm still; the LED currents in the
  current firmware are low, so a loose fit gives a weak pulse. Try
  `--hr-channel ir` if green is weaker on your skin.
- **"PPG: no notifications yet" stays on the device tile**: the sensor did not
  wake up even after the automatic cadence toggle. Power-cycle the device and
  reconnect; with `-v` the log shows the first notification time and the
  per-channel rate every five seconds.
- **PPG rate far below 25 Hz**: the host is dropping notifications. Move
  closer to the device and avoid other BLE traffic; the processing tolerates
  gaps but the quality score drops.
- **Temperature never updates**: the first indication arrives after one full
  interval; `-v` prints when the interval was applied and when readings come
  in. If the firmware reports interval 0 the measurements are disabled on the
  device.
