rr-app 1.5.0__tar.gz
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.
- rr_app-1.5.0/PKG-INFO +144 -0
- rr_app-1.5.0/README.md +106 -0
- rr_app-1.5.0/pyproject.toml +67 -0
- rr_app-1.5.0/rr_app/__init__.py +36 -0
- rr_app-1.5.0/rr_app/__main__.py +11 -0
- rr_app-1.5.0/rr_app/analysis/__init__.py +9 -0
- rr_app-1.5.0/rr_app/analysis/emg_features.py +261 -0
- rr_app-1.5.0/rr_app/analysis/fatigue_index.py +85 -0
- rr_app-1.5.0/rr_app/analysis/fatigue_labels.py +85 -0
- rr_app-1.5.0/rr_app/analysis/fatigue_model.py +212 -0
- rr_app-1.5.0/rr_app/analysis/train_fatigue_model.py +163 -0
- rr_app-1.5.0/rr_app/app.py +108 -0
- rr_app-1.5.0/rr_app/ble/__init__.py +2 -0
- rr_app-1.5.0/rr_app/ble/backend.py +426 -0
- rr_app-1.5.0/rr_app/ble/manager.py +1007 -0
- rr_app-1.5.0/rr_app/cli.py +110 -0
- rr_app-1.5.0/rr_app/config/__init__.py +11 -0
- rr_app-1.5.0/rr_app/config/akr.py +20 -0
- rr_app-1.5.0/rr_app/config/core.py +24 -0
- rr_app-1.5.0/rr_app/config/emgs.py +31 -0
- rr_app-1.5.0/rr_app/config/recording.py +128 -0
- rr_app-1.5.0/rr_app/devices/__init__.py +2 -0
- rr_app-1.5.0/rr_app/devices/akr/__init__.py +2 -0
- rr_app-1.5.0/rr_app/devices/akr/ble/__init__.py +2 -0
- rr_app-1.5.0/rr_app/devices/akr/ble/ota_transfer.py +433 -0
- rr_app-1.5.0/rr_app/devices/akr/ble/protocol.py +44 -0
- rr_app-1.5.0/rr_app/devices/akr/ble/stream.py +629 -0
- rr_app-1.5.0/rr_app/devices/akr/ble/utils.py +196 -0
- rr_app-1.5.0/rr_app/devices/akr/handler.py +87 -0
- rr_app-1.5.0/rr_app/devices/akr/manager.py +218 -0
- rr_app-1.5.0/rr_app/devices/akr/ota_session.py +98 -0
- rr_app-1.5.0/rr_app/devices/akr/state.py +123 -0
- rr_app-1.5.0/rr_app/devices/core/__init__.py +21 -0
- rr_app-1.5.0/rr_app/devices/core/base.py +39 -0
- rr_app-1.5.0/rr_app/devices/core/contracts.py +75 -0
- rr_app-1.5.0/rr_app/devices/core/controller.py +233 -0
- rr_app-1.5.0/rr_app/devices/core/orchestrator.py +63 -0
- rr_app-1.5.0/rr_app/devices/core/registry.py +31 -0
- rr_app-1.5.0/rr_app/devices/core/session_manager.py +48 -0
- rr_app-1.5.0/rr_app/devices/core/state.py +39 -0
- rr_app-1.5.0/rr_app/devices/emgs/__init__.py +2 -0
- rr_app-1.5.0/rr_app/devices/emgs/ble/__init__.py +2 -0
- rr_app-1.5.0/rr_app/devices/emgs/ble/protocol.py +114 -0
- rr_app-1.5.0/rr_app/devices/emgs/handler.py +106 -0
- rr_app-1.5.0/rr_app/devices/emgs/manager.py +27 -0
- rr_app-1.5.0/rr_app/devices/emgs/state.py +641 -0
- rr_app-1.5.0/rr_app/gui/__init__.py +2 -0
- rr_app-1.5.0/rr_app/gui/akr_feedback_buffers.py +13 -0
- rr_app-1.5.0/rr_app/gui/akr_feedback_catalog.py +458 -0
- rr_app-1.5.0/rr_app/gui/main_window.py +393 -0
- rr_app-1.5.0/rr_app/gui/mixins/__init__.py +7 -0
- rr_app-1.5.0/rr_app/gui/mixins/connect_actions.py +448 -0
- rr_app-1.5.0/rr_app/gui/mixins/runtime.py +167 -0
- rr_app-1.5.0/rr_app/gui/mixins/session_controls.py +885 -0
- rr_app-1.5.0/rr_app/gui/mixins/settings_profiles.py +429 -0
- rr_app-1.5.0/rr_app/gui/mixins/signals_plotting.py +1891 -0
- rr_app-1.5.0/rr_app/gui/models.py +278 -0
- rr_app-1.5.0/rr_app/gui/tabs/__init__.py +7 -0
- rr_app-1.5.0/rr_app/gui/tabs/connect_tab.py +100 -0
- rr_app-1.5.0/rr_app/gui/tabs/scenario_tab.py +266 -0
- rr_app-1.5.0/rr_app/gui/tabs/settings_tab.py +1242 -0
- rr_app-1.5.0/rr_app/gui/tabs/signals_tab.py +304 -0
- rr_app-1.5.0/rr_app/gui/widgets.py +3929 -0
- rr_app-1.5.0/rr_app/i18n.py +243 -0
- rr_app-1.5.0/rr_app/logging_utils.py +23 -0
- rr_app-1.5.0/rr_app/recording/__init__.py +27 -0
- rr_app-1.5.0/rr_app/recording/akr.py +269 -0
- rr_app-1.5.0/rr_app/recording/core.py +1557 -0
- rr_app-1.5.0/rr_app/recording/emgs.py +150 -0
- rr_app-1.5.0/rr_app/resources/languages.json +797 -0
- rr_app-1.5.0/rr_app/resources/style.qss +128 -0
- rr_app-1.5.0/rr_app/update_check.py +125 -0
- rr_app-1.5.0/rr_app.egg-info/PKG-INFO +144 -0
- rr_app-1.5.0/rr_app.egg-info/SOURCES.txt +78 -0
- rr_app-1.5.0/rr_app.egg-info/dependency_links.txt +1 -0
- rr_app-1.5.0/rr_app.egg-info/entry_points.txt +2 -0
- rr_app-1.5.0/rr_app.egg-info/requires.txt +17 -0
- rr_app-1.5.0/rr_app.egg-info/top_level.txt +5 -0
- rr_app-1.5.0/scripts/release/build_desktop_packages.py +254 -0
- rr_app-1.5.0/setup.cfg +4 -0
rr_app-1.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rr-app
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: EMGS PyQt6 desktop application (BLE + asyncio).
|
|
5
|
+
Author: rr_emg contributors
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/o0fung/RR_EMG
|
|
8
|
+
Project-URL: Repository, https://github.com/o0fung/RR_EMG
|
|
9
|
+
Project-URL: Issues, https://github.com/o0fung/RR_EMG/issues
|
|
10
|
+
Keywords: emg,ble,pyqt6,imu,sensor
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
21
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
22
|
+
Requires-Python: <3.14,>=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: PyQt6==6.9.1
|
|
25
|
+
Requires-Dist: qasync==0.27.1
|
|
26
|
+
Requires-Dist: bleak==0.22.3
|
|
27
|
+
Requires-Dist: pyqtgraph==0.13.7
|
|
28
|
+
Requires-Dist: numpy==2.3.1
|
|
29
|
+
Requires-Dist: rich==14.1.0
|
|
30
|
+
Requires-Dist: pyobjc-core==10.3.2; platform_system == "Darwin"
|
|
31
|
+
Requires-Dist: pyobjc-framework-Cocoa==10.3.2; platform_system == "Darwin"
|
|
32
|
+
Requires-Dist: pyobjc-framework-CoreBluetooth==10.3.2; platform_system == "Darwin"
|
|
33
|
+
Requires-Dist: pyobjc-framework-libdispatch==10.3.2; platform_system == "Darwin"
|
|
34
|
+
Provides-Extra: ml
|
|
35
|
+
Requires-Dist: scipy; extra == "ml"
|
|
36
|
+
Requires-Dist: scikit-learn; extra == "ml"
|
|
37
|
+
Requires-Dist: joblib; extra == "ml"
|
|
38
|
+
|
|
39
|
+
# RR App (`rr_emg`)
|
|
40
|
+
|
|
41
|
+
Language: **English** | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
|
42
|
+
|
|
43
|
+
## Overview
|
|
44
|
+
|
|
45
|
+
`rr_emg` is a desktop GUI for Bluetooth monitoring and control.
|
|
46
|
+
|
|
47
|
+
It supports:
|
|
48
|
+
- **EMGS devices** (EMG + IMU streaming)
|
|
49
|
+
- **AKR-compatible devices** (including `rr_akr2` protocol behavior and other compatible devices such as EMGS-family products)
|
|
50
|
+
|
|
51
|
+
Typical use:
|
|
52
|
+
- scan and connect to devices,
|
|
53
|
+
- monitor live data,
|
|
54
|
+
- send control/configuration commands,
|
|
55
|
+
- record sessions for analysis.
|
|
56
|
+
|
|
57
|
+
## Audience
|
|
58
|
+
|
|
59
|
+
- Operators and clinicians
|
|
60
|
+
- R&D teams
|
|
61
|
+
- Integrators and developers
|
|
62
|
+
|
|
63
|
+
For operator-focused instructions, see `IFU.md`.
|
|
64
|
+
|
|
65
|
+
## Requirements
|
|
66
|
+
|
|
67
|
+
- Python `>=3.10,<3.14`
|
|
68
|
+
- Bluetooth-capable host (macOS / Linux / Windows)
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
### Option 1: Release packages (recommended for most operators)
|
|
73
|
+
|
|
74
|
+
Download from [GitHub Releases](https://github.com/o0fung/RR_EMG/releases/latest).
|
|
75
|
+
|
|
76
|
+
- Windows: `rr-app-<version>-setup.exe`
|
|
77
|
+
- macOS: `rr-app-<version>-macos.dmg`
|
|
78
|
+
|
|
79
|
+
On macOS, if Gatekeeper warns on unsigned builds, right-click the app and choose **Open**.
|
|
80
|
+
|
|
81
|
+
### Option 2: PyPI / pipx
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install rr-app
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
or
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pipx install rr-app
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Option 3: From source (developer path)
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m venv .venv
|
|
97
|
+
source .venv/bin/activate
|
|
98
|
+
pip install -e .
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Quick Start
|
|
102
|
+
|
|
103
|
+
1. Turn on host Bluetooth.
|
|
104
|
+
2. Power on target device(s).
|
|
105
|
+
3. Launch:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
rr
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
4. Scan and connect in the app.
|
|
112
|
+
5. Use **Signals** tab for live monitoring.
|
|
113
|
+
6. Use settings/control actions as needed.
|
|
114
|
+
7. Start recording when session export is required.
|
|
115
|
+
|
|
116
|
+
Simulation mode (no hardware):
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
RR_BACKEND=sim rr
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Device Compatibility Notes
|
|
123
|
+
|
|
124
|
+
- EMGS discovery prefix: `EMGS`
|
|
125
|
+
- AKR discovery prefixes: `AKR` and optional legacy `BT`
|
|
126
|
+
- AKR BLE profile fallback is built-in (new profile first, legacy profile fallback)
|
|
127
|
+
|
|
128
|
+
## Protocol and OTA Docs
|
|
129
|
+
|
|
130
|
+
- AKR/BT CLI + telemetry: `doc/BT_AKR_DEVICE_PROTOCOL.md`
|
|
131
|
+
- OTA CLI session protocol: `doc/OTA_PROTOCOL_CLI_SESSION.md`
|
|
132
|
+
|
|
133
|
+
## Data and Logs
|
|
134
|
+
|
|
135
|
+
- Startup logs:
|
|
136
|
+
- macOS: `~/Library/Logs/rr-app`
|
|
137
|
+
- Linux/Windows: `~/.rr-app/logs`
|
|
138
|
+
- Default recording folder: `~/rr_data`
|
|
139
|
+
|
|
140
|
+
## Project Links
|
|
141
|
+
|
|
142
|
+
- Homepage: [RR_EMG](https://github.com/o0fung/RR_EMG)
|
|
143
|
+
- Issues: [GitHub Issues](https://github.com/o0fung/RR_EMG/issues)
|
|
144
|
+
|
rr_app-1.5.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# RR App (`rr_emg`)
|
|
2
|
+
|
|
3
|
+
Language: **English** | [繁體中文](README.zh-TW.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`rr_emg` is a desktop GUI for Bluetooth monitoring and control.
|
|
8
|
+
|
|
9
|
+
It supports:
|
|
10
|
+
- **EMGS devices** (EMG + IMU streaming)
|
|
11
|
+
- **AKR-compatible devices** (including `rr_akr2` protocol behavior and other compatible devices such as EMGS-family products)
|
|
12
|
+
|
|
13
|
+
Typical use:
|
|
14
|
+
- scan and connect to devices,
|
|
15
|
+
- monitor live data,
|
|
16
|
+
- send control/configuration commands,
|
|
17
|
+
- record sessions for analysis.
|
|
18
|
+
|
|
19
|
+
## Audience
|
|
20
|
+
|
|
21
|
+
- Operators and clinicians
|
|
22
|
+
- R&D teams
|
|
23
|
+
- Integrators and developers
|
|
24
|
+
|
|
25
|
+
For operator-focused instructions, see `IFU.md`.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python `>=3.10,<3.14`
|
|
30
|
+
- Bluetooth-capable host (macOS / Linux / Windows)
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Option 1: Release packages (recommended for most operators)
|
|
35
|
+
|
|
36
|
+
Download from [GitHub Releases](https://github.com/o0fung/RR_EMG/releases/latest).
|
|
37
|
+
|
|
38
|
+
- Windows: `rr-app-<version>-setup.exe`
|
|
39
|
+
- macOS: `rr-app-<version>-macos.dmg`
|
|
40
|
+
|
|
41
|
+
On macOS, if Gatekeeper warns on unsigned builds, right-click the app and choose **Open**.
|
|
42
|
+
|
|
43
|
+
### Option 2: PyPI / pipx
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install rr-app
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
or
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx install rr-app
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Option 3: From source (developer path)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m venv .venv
|
|
59
|
+
source .venv/bin/activate
|
|
60
|
+
pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
1. Turn on host Bluetooth.
|
|
66
|
+
2. Power on target device(s).
|
|
67
|
+
3. Launch:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
rr
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
4. Scan and connect in the app.
|
|
74
|
+
5. Use **Signals** tab for live monitoring.
|
|
75
|
+
6. Use settings/control actions as needed.
|
|
76
|
+
7. Start recording when session export is required.
|
|
77
|
+
|
|
78
|
+
Simulation mode (no hardware):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
RR_BACKEND=sim rr
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Device Compatibility Notes
|
|
85
|
+
|
|
86
|
+
- EMGS discovery prefix: `EMGS`
|
|
87
|
+
- AKR discovery prefixes: `AKR` and optional legacy `BT`
|
|
88
|
+
- AKR BLE profile fallback is built-in (new profile first, legacy profile fallback)
|
|
89
|
+
|
|
90
|
+
## Protocol and OTA Docs
|
|
91
|
+
|
|
92
|
+
- AKR/BT CLI + telemetry: `doc/BT_AKR_DEVICE_PROTOCOL.md`
|
|
93
|
+
- OTA CLI session protocol: `doc/OTA_PROTOCOL_CLI_SESSION.md`
|
|
94
|
+
|
|
95
|
+
## Data and Logs
|
|
96
|
+
|
|
97
|
+
- Startup logs:
|
|
98
|
+
- macOS: `~/Library/Logs/rr-app`
|
|
99
|
+
- Linux/Windows: `~/.rr-app/logs`
|
|
100
|
+
- Default recording folder: `~/rr_data`
|
|
101
|
+
|
|
102
|
+
## Project Links
|
|
103
|
+
|
|
104
|
+
- Homepage: [RR_EMG](https://github.com/o0fung/RR_EMG)
|
|
105
|
+
- Issues: [GitHub Issues](https://github.com/o0fung/RR_EMG/issues)
|
|
106
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=70", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rr-app"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "EMGS PyQt6 desktop application (BLE + asyncio)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.14"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "rr_emg contributors" }]
|
|
13
|
+
keywords = ["emg", "ble", "pyqt6", "imu", "sensor"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Operating System :: MacOS",
|
|
24
|
+
"Operating System :: POSIX :: Linux",
|
|
25
|
+
"Operating System :: Microsoft :: Windows",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Keep the default install as the GUI app + BLE stack.
|
|
29
|
+
dependencies = [
|
|
30
|
+
"PyQt6==6.9.1",
|
|
31
|
+
"qasync==0.27.1",
|
|
32
|
+
"bleak==0.22.3",
|
|
33
|
+
"pyqtgraph==0.13.7",
|
|
34
|
+
"numpy==2.3.1",
|
|
35
|
+
"rich==14.1.0",
|
|
36
|
+
# macOS BLE stack dependencies (only on Darwin)
|
|
37
|
+
"pyobjc-core==10.3.2; platform_system=='Darwin'",
|
|
38
|
+
"pyobjc-framework-Cocoa==10.3.2; platform_system=='Darwin'",
|
|
39
|
+
"pyobjc-framework-CoreBluetooth==10.3.2; platform_system=='Darwin'",
|
|
40
|
+
"pyobjc-framework-libdispatch==10.3.2; platform_system=='Darwin'",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/o0fung/RR_EMG"
|
|
45
|
+
Repository = "https://github.com/o0fung/RR_EMG"
|
|
46
|
+
Issues = "https://github.com/o0fung/RR_EMG/issues"
|
|
47
|
+
|
|
48
|
+
[project.optional-dependencies]
|
|
49
|
+
# Offline analysis / ML (fatigue detection)
|
|
50
|
+
ml = ["scipy", "scikit-learn", "joblib"]
|
|
51
|
+
|
|
52
|
+
[project.scripts]
|
|
53
|
+
rr = "rr_app.cli:main"
|
|
54
|
+
|
|
55
|
+
[tool.setuptools]
|
|
56
|
+
include-package-data = true
|
|
57
|
+
|
|
58
|
+
[tool.setuptools.packages.find]
|
|
59
|
+
where = ["."]
|
|
60
|
+
exclude = ["backup*", "doc*"]
|
|
61
|
+
|
|
62
|
+
[tool.setuptools.dynamic]
|
|
63
|
+
version = { attr = "rr_app.__version__" }
|
|
64
|
+
|
|
65
|
+
[tool.setuptools.package-data]
|
|
66
|
+
rr_app = ["resources/*.qss", "resources/languages.json"]
|
|
67
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""RR App PyQt6 application (clean rewrite).
|
|
2
|
+
|
|
3
|
+
Run with:
|
|
4
|
+
python -m rr_app
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
|
12
|
+
|
|
13
|
+
# Safety: never write __pycache__/bytecode into the checked-out repo / installed package.
|
|
14
|
+
# This avoids "accidental updates" to a git checkout when running `rr` or `python -m rr_app`.
|
|
15
|
+
sys.dont_write_bytecode = True
|
|
16
|
+
|
|
17
|
+
__version__ = "1.5.0"
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
v1.4.0:
|
|
21
|
+
- Added Signals preset profile (save/load)
|
|
22
|
+
- Updated Settings prompt button grid
|
|
23
|
+
|
|
24
|
+
v1.4.1:
|
|
25
|
+
- Added mutlilingual translation selector support
|
|
26
|
+
|
|
27
|
+
v1.4.3:
|
|
28
|
+
- Fixed data recording CSV format
|
|
29
|
+
|
|
30
|
+
v1.4.9:
|
|
31
|
+
- Can read new CPM telemetry (for remaining time)
|
|
32
|
+
|
|
33
|
+
v1.5.0:
|
|
34
|
+
- Fixed CPM target_deg to _rad; refactored the rr_app
|
|
35
|
+
|
|
36
|
+
"""
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable, Iterator
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _require_scipy() -> "object":
|
|
11
|
+
try:
|
|
12
|
+
import scipy.signal # type: ignore
|
|
13
|
+
except ModuleNotFoundError as e: # pragma: no cover
|
|
14
|
+
raise ModuleNotFoundError(
|
|
15
|
+
"scipy is required for EMG filtering/PSD. Install with: pip install -r requirements.txt"
|
|
16
|
+
) from e
|
|
17
|
+
return scipy.signal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class EmgPreprocessConfig:
|
|
22
|
+
fs_hz: float = 1000.0
|
|
23
|
+
bandpass_hz: tuple[float, float] | None = (20.0, 450.0)
|
|
24
|
+
notch_hz: float | None = None # e.g. 50 or 60
|
|
25
|
+
notch_q: float = 30.0
|
|
26
|
+
detrend: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def preprocess_emg(x: np.ndarray, cfg: EmgPreprocessConfig) -> np.ndarray:
|
|
30
|
+
"""Basic EMG preprocessing (typical surface EMG defaults).
|
|
31
|
+
|
|
32
|
+
Notes:
|
|
33
|
+
- Keep this conservative: the goal is repeatable features, not maximal denoising.
|
|
34
|
+
- If you already have a clean pipeline, you can skip this and only use feature extraction.
|
|
35
|
+
"""
|
|
36
|
+
scipy_signal = _require_scipy()
|
|
37
|
+
|
|
38
|
+
x = np.asarray(x, dtype=float).copy()
|
|
39
|
+
if x.ndim != 1:
|
|
40
|
+
raise ValueError(f"preprocess_emg expects 1D signal, got shape={x.shape}")
|
|
41
|
+
|
|
42
|
+
if cfg.detrend:
|
|
43
|
+
x = x - float(np.mean(x))
|
|
44
|
+
|
|
45
|
+
fs = float(cfg.fs_hz)
|
|
46
|
+
|
|
47
|
+
if cfg.notch_hz is not None:
|
|
48
|
+
w0 = float(cfg.notch_hz) / (fs / 2.0)
|
|
49
|
+
if 0.0 < w0 < 1.0:
|
|
50
|
+
b, a = scipy_signal.iirnotch(w0=w0, Q=float(cfg.notch_q))
|
|
51
|
+
x = scipy_signal.filtfilt(b, a, x, method="pad")
|
|
52
|
+
|
|
53
|
+
if cfg.bandpass_hz is not None:
|
|
54
|
+
lo, hi = cfg.bandpass_hz
|
|
55
|
+
lo = float(lo)
|
|
56
|
+
hi = float(hi)
|
|
57
|
+
if lo <= 0.0 or hi <= 0.0 or hi <= lo:
|
|
58
|
+
raise ValueError(f"Invalid bandpass_hz={cfg.bandpass_hz}")
|
|
59
|
+
wn = (lo / (fs / 2.0), hi / (fs / 2.0))
|
|
60
|
+
b, a = scipy_signal.butter(4, wn, btype="bandpass")
|
|
61
|
+
x = scipy_signal.filtfilt(b, a, x, method="pad")
|
|
62
|
+
|
|
63
|
+
return x
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class WindowConfig:
|
|
68
|
+
fs_hz: float = 1000.0
|
|
69
|
+
window_s: float = 0.250
|
|
70
|
+
hop_s: float = 0.050
|
|
71
|
+
|
|
72
|
+
def window_n(self) -> int:
|
|
73
|
+
return max(8, int(round(float(self.window_s) * float(self.fs_hz))))
|
|
74
|
+
|
|
75
|
+
def hop_n(self) -> int:
|
|
76
|
+
return max(1, int(round(float(self.hop_s) * float(self.fs_hz))))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def iterate_windows(x: np.ndarray, cfg: WindowConfig) -> Iterator[tuple[int, int, np.ndarray]]:
|
|
80
|
+
"""Yield (start_idx, end_idx, window_signal)."""
|
|
81
|
+
x = np.asarray(x, dtype=float)
|
|
82
|
+
if x.ndim != 1:
|
|
83
|
+
raise ValueError(f"iterate_windows expects 1D signal, got shape={x.shape}")
|
|
84
|
+
|
|
85
|
+
n = int(x.size)
|
|
86
|
+
w = int(cfg.window_n())
|
|
87
|
+
hop = int(cfg.hop_n())
|
|
88
|
+
if n < w:
|
|
89
|
+
return
|
|
90
|
+
for start in range(0, n - w + 1, hop):
|
|
91
|
+
end = start + w
|
|
92
|
+
yield start, end, x[start:end]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _zc(x: np.ndarray, *, threshold: float = 0.0) -> float:
|
|
96
|
+
"""Zero crossings count with dead-zone threshold."""
|
|
97
|
+
x = np.asarray(x, dtype=float)
|
|
98
|
+
if x.size < 2:
|
|
99
|
+
return 0.0
|
|
100
|
+
# Apply dead-zone
|
|
101
|
+
x1 = np.where(np.abs(x) < float(threshold), 0.0, x)
|
|
102
|
+
s = np.sign(x1)
|
|
103
|
+
return float(np.sum((s[1:] * s[:-1]) < 0))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _wamp(x: np.ndarray, *, threshold: float) -> float:
|
|
107
|
+
"""Willison amplitude: count of |x[n]-x[n-1]| >= threshold."""
|
|
108
|
+
x = np.asarray(x, dtype=float)
|
|
109
|
+
if x.size < 2:
|
|
110
|
+
return 0.0
|
|
111
|
+
d = np.abs(np.diff(x))
|
|
112
|
+
return float(np.sum(d >= float(threshold)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _waveform_length(x: np.ndarray) -> float:
|
|
116
|
+
x = np.asarray(x, dtype=float)
|
|
117
|
+
if x.size < 2:
|
|
118
|
+
return 0.0
|
|
119
|
+
return float(np.sum(np.abs(np.diff(x))))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class WelchConfig:
|
|
124
|
+
fs_hz: float = 1000.0
|
|
125
|
+
nperseg: int = 256
|
|
126
|
+
noverlap: int = 128
|
|
127
|
+
window: str = "hann"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def welch_psd(x: np.ndarray, cfg: WelchConfig) -> tuple[np.ndarray, np.ndarray]:
|
|
131
|
+
"""Welch PSD estimate (nonnegative) in units of power/Hz."""
|
|
132
|
+
scipy_signal = _require_scipy()
|
|
133
|
+
x = np.asarray(x, dtype=float)
|
|
134
|
+
if x.ndim != 1:
|
|
135
|
+
raise ValueError(f"welch_psd expects 1D signal, got shape={x.shape}")
|
|
136
|
+
f, pxx = scipy_signal.welch(
|
|
137
|
+
x,
|
|
138
|
+
fs=float(cfg.fs_hz),
|
|
139
|
+
window=str(cfg.window),
|
|
140
|
+
nperseg=int(cfg.nperseg),
|
|
141
|
+
noverlap=int(cfg.noverlap),
|
|
142
|
+
detrend=False,
|
|
143
|
+
scaling="density",
|
|
144
|
+
return_onesided=True,
|
|
145
|
+
)
|
|
146
|
+
return np.asarray(f, dtype=float), np.asarray(pxx, dtype=float)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def spectral_features_from_psd(f_hz: np.ndarray, pxx: np.ndarray) -> dict[str, float]:
|
|
150
|
+
"""Compute common fatigue-related spectral features from a PSD."""
|
|
151
|
+
f = np.asarray(f_hz, dtype=float)
|
|
152
|
+
p = np.asarray(pxx, dtype=float)
|
|
153
|
+
if f.size == 0 or p.size == 0 or f.size != p.size:
|
|
154
|
+
return {"mnf_hz": 0.0, "mdf_hz": 0.0, "tot_power": 0.0}
|
|
155
|
+
|
|
156
|
+
# Drop DC for fatigue features (EMG often has DC/artifact content).
|
|
157
|
+
if f.size > 1:
|
|
158
|
+
f = f[1:]
|
|
159
|
+
p = p[1:]
|
|
160
|
+
|
|
161
|
+
p = np.maximum(p, 0.0)
|
|
162
|
+
tot = float(np.trapezoid(p, f))
|
|
163
|
+
if not math.isfinite(tot) or tot <= 0.0:
|
|
164
|
+
return {"mnf_hz": 0.0, "mdf_hz": 0.0, "tot_power": 0.0}
|
|
165
|
+
|
|
166
|
+
# Mean frequency (MNF): first moment / total power.
|
|
167
|
+
mnf = float(np.trapezoid(f * p, f) / tot)
|
|
168
|
+
|
|
169
|
+
# Median frequency (MDF): frequency where cumulative power reaches 50%.
|
|
170
|
+
# Use cumulative trapezoid integration for robustness.
|
|
171
|
+
c = np.cumsum((p[1:] + p[:-1]) * (f[1:] - f[:-1]) * 0.5)
|
|
172
|
+
if c.size == 0:
|
|
173
|
+
mdf = float(f[0])
|
|
174
|
+
else:
|
|
175
|
+
idx = int(np.searchsorted(c, 0.5 * float(c[-1]), side="left"))
|
|
176
|
+
idx = max(0, min(idx, int(f.size - 2)))
|
|
177
|
+
mdf = float(f[idx + 1])
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"mnf_hz": float(mnf),
|
|
181
|
+
"mdf_hz": float(mdf),
|
|
182
|
+
"tot_power": float(tot),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def bandpower(f_hz: np.ndarray, pxx: np.ndarray, band_hz: tuple[float, float]) -> float:
|
|
187
|
+
f = np.asarray(f_hz, dtype=float)
|
|
188
|
+
p = np.asarray(pxx, dtype=float)
|
|
189
|
+
lo, hi = float(band_hz[0]), float(band_hz[1])
|
|
190
|
+
if f.size == 0 or p.size == 0:
|
|
191
|
+
return 0.0
|
|
192
|
+
m = (f >= lo) & (f <= hi)
|
|
193
|
+
if not np.any(m):
|
|
194
|
+
return 0.0
|
|
195
|
+
return float(np.trapezoid(np.maximum(p[m], 0.0), f[m]))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def time_features(x: np.ndarray, *, wamp_thresh: float | None = None, zc_thresh: float = 0.0) -> dict[str, float]:
|
|
199
|
+
x = np.asarray(x, dtype=float)
|
|
200
|
+
if x.size == 0:
|
|
201
|
+
return {"rms": 0.0, "iemg": 0.0, "wl": 0.0, "zc": 0.0, "wamp": 0.0}
|
|
202
|
+
|
|
203
|
+
rms = float(math.sqrt(max(0.0, float(np.mean(x * x)))))
|
|
204
|
+
iemg = float(np.sum(np.abs(x)))
|
|
205
|
+
wl = _waveform_length(x)
|
|
206
|
+
zc = _zc(x, threshold=float(zc_thresh))
|
|
207
|
+
|
|
208
|
+
if wamp_thresh is None:
|
|
209
|
+
# Common heuristic: threshold relative to signal scale (10% of RMS).
|
|
210
|
+
wamp_thresh = 0.1 * rms
|
|
211
|
+
wamp = _wamp(x, threshold=float(wamp_thresh))
|
|
212
|
+
|
|
213
|
+
return {"rms": rms, "iemg": iemg, "wl": wl, "zc": zc, "wamp": wamp}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def extract_feature_rows(
|
|
217
|
+
x: np.ndarray,
|
|
218
|
+
*,
|
|
219
|
+
preprocess: EmgPreprocessConfig | None = None,
|
|
220
|
+
window: WindowConfig,
|
|
221
|
+
welch: WelchConfig | None = None,
|
|
222
|
+
bandpowers_hz: Iterable[tuple[str, tuple[float, float]]] = (("bp_20_60", (20.0, 60.0)), ("bp_60_150", (60.0, 150.0)), ("bp_150_300", (150.0, 300.0))),
|
|
223
|
+
) -> tuple[np.ndarray, list[str], np.ndarray]:
|
|
224
|
+
"""Return (X, feature_names, t_center_s) for a single recording.
|
|
225
|
+
|
|
226
|
+
X rows correspond to windows. t_center_s is window center time in seconds.
|
|
227
|
+
"""
|
|
228
|
+
x_in = np.asarray(x, dtype=float)
|
|
229
|
+
if x_in.ndim != 1:
|
|
230
|
+
raise ValueError(f"extract_feature_rows expects 1D signal, got shape={x_in.shape}")
|
|
231
|
+
|
|
232
|
+
cfg_p = preprocess or EmgPreprocessConfig(fs_hz=float(window.fs_hz))
|
|
233
|
+
x0 = preprocess_emg(x_in, cfg_p) if preprocess is not None else x_in.astype(float)
|
|
234
|
+
|
|
235
|
+
cfg_welch = welch or WelchConfig(fs_hz=float(window.fs_hz), nperseg=min(256, window.window_n()), noverlap=min(128, max(0, window.window_n() // 2)))
|
|
236
|
+
names: list[str] = []
|
|
237
|
+
rows: list[list[float]] = []
|
|
238
|
+
t_centers: list[float] = []
|
|
239
|
+
|
|
240
|
+
for start, end, seg in iterate_windows(x0, window):
|
|
241
|
+
tf = time_features(seg)
|
|
242
|
+
f, pxx = welch_psd(seg, cfg_welch)
|
|
243
|
+
sf = spectral_features_from_psd(f, pxx)
|
|
244
|
+
bp: dict[str, float] = {name: bandpower(f, pxx, band) for name, band in bandpowers_hz}
|
|
245
|
+
|
|
246
|
+
feat: dict[str, float] = {}
|
|
247
|
+
feat.update(tf)
|
|
248
|
+
feat.update(sf)
|
|
249
|
+
feat.update(bp)
|
|
250
|
+
|
|
251
|
+
if not names:
|
|
252
|
+
names = list(feat.keys())
|
|
253
|
+
rows.append([float(feat[k]) for k in names])
|
|
254
|
+
|
|
255
|
+
t_center = (float(start) + float(end)) * 0.5 / float(window.fs_hz)
|
|
256
|
+
t_centers.append(float(t_center))
|
|
257
|
+
|
|
258
|
+
if not rows:
|
|
259
|
+
return np.zeros((0, 0), dtype=float), [], np.zeros((0,), dtype=float)
|
|
260
|
+
return np.asarray(rows, dtype=float), names, np.asarray(t_centers, dtype=float)
|
|
261
|
+
|