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.
Files changed (80) hide show
  1. rr_app-1.5.0/PKG-INFO +144 -0
  2. rr_app-1.5.0/README.md +106 -0
  3. rr_app-1.5.0/pyproject.toml +67 -0
  4. rr_app-1.5.0/rr_app/__init__.py +36 -0
  5. rr_app-1.5.0/rr_app/__main__.py +11 -0
  6. rr_app-1.5.0/rr_app/analysis/__init__.py +9 -0
  7. rr_app-1.5.0/rr_app/analysis/emg_features.py +261 -0
  8. rr_app-1.5.0/rr_app/analysis/fatigue_index.py +85 -0
  9. rr_app-1.5.0/rr_app/analysis/fatigue_labels.py +85 -0
  10. rr_app-1.5.0/rr_app/analysis/fatigue_model.py +212 -0
  11. rr_app-1.5.0/rr_app/analysis/train_fatigue_model.py +163 -0
  12. rr_app-1.5.0/rr_app/app.py +108 -0
  13. rr_app-1.5.0/rr_app/ble/__init__.py +2 -0
  14. rr_app-1.5.0/rr_app/ble/backend.py +426 -0
  15. rr_app-1.5.0/rr_app/ble/manager.py +1007 -0
  16. rr_app-1.5.0/rr_app/cli.py +110 -0
  17. rr_app-1.5.0/rr_app/config/__init__.py +11 -0
  18. rr_app-1.5.0/rr_app/config/akr.py +20 -0
  19. rr_app-1.5.0/rr_app/config/core.py +24 -0
  20. rr_app-1.5.0/rr_app/config/emgs.py +31 -0
  21. rr_app-1.5.0/rr_app/config/recording.py +128 -0
  22. rr_app-1.5.0/rr_app/devices/__init__.py +2 -0
  23. rr_app-1.5.0/rr_app/devices/akr/__init__.py +2 -0
  24. rr_app-1.5.0/rr_app/devices/akr/ble/__init__.py +2 -0
  25. rr_app-1.5.0/rr_app/devices/akr/ble/ota_transfer.py +433 -0
  26. rr_app-1.5.0/rr_app/devices/akr/ble/protocol.py +44 -0
  27. rr_app-1.5.0/rr_app/devices/akr/ble/stream.py +629 -0
  28. rr_app-1.5.0/rr_app/devices/akr/ble/utils.py +196 -0
  29. rr_app-1.5.0/rr_app/devices/akr/handler.py +87 -0
  30. rr_app-1.5.0/rr_app/devices/akr/manager.py +218 -0
  31. rr_app-1.5.0/rr_app/devices/akr/ota_session.py +98 -0
  32. rr_app-1.5.0/rr_app/devices/akr/state.py +123 -0
  33. rr_app-1.5.0/rr_app/devices/core/__init__.py +21 -0
  34. rr_app-1.5.0/rr_app/devices/core/base.py +39 -0
  35. rr_app-1.5.0/rr_app/devices/core/contracts.py +75 -0
  36. rr_app-1.5.0/rr_app/devices/core/controller.py +233 -0
  37. rr_app-1.5.0/rr_app/devices/core/orchestrator.py +63 -0
  38. rr_app-1.5.0/rr_app/devices/core/registry.py +31 -0
  39. rr_app-1.5.0/rr_app/devices/core/session_manager.py +48 -0
  40. rr_app-1.5.0/rr_app/devices/core/state.py +39 -0
  41. rr_app-1.5.0/rr_app/devices/emgs/__init__.py +2 -0
  42. rr_app-1.5.0/rr_app/devices/emgs/ble/__init__.py +2 -0
  43. rr_app-1.5.0/rr_app/devices/emgs/ble/protocol.py +114 -0
  44. rr_app-1.5.0/rr_app/devices/emgs/handler.py +106 -0
  45. rr_app-1.5.0/rr_app/devices/emgs/manager.py +27 -0
  46. rr_app-1.5.0/rr_app/devices/emgs/state.py +641 -0
  47. rr_app-1.5.0/rr_app/gui/__init__.py +2 -0
  48. rr_app-1.5.0/rr_app/gui/akr_feedback_buffers.py +13 -0
  49. rr_app-1.5.0/rr_app/gui/akr_feedback_catalog.py +458 -0
  50. rr_app-1.5.0/rr_app/gui/main_window.py +393 -0
  51. rr_app-1.5.0/rr_app/gui/mixins/__init__.py +7 -0
  52. rr_app-1.5.0/rr_app/gui/mixins/connect_actions.py +448 -0
  53. rr_app-1.5.0/rr_app/gui/mixins/runtime.py +167 -0
  54. rr_app-1.5.0/rr_app/gui/mixins/session_controls.py +885 -0
  55. rr_app-1.5.0/rr_app/gui/mixins/settings_profiles.py +429 -0
  56. rr_app-1.5.0/rr_app/gui/mixins/signals_plotting.py +1891 -0
  57. rr_app-1.5.0/rr_app/gui/models.py +278 -0
  58. rr_app-1.5.0/rr_app/gui/tabs/__init__.py +7 -0
  59. rr_app-1.5.0/rr_app/gui/tabs/connect_tab.py +100 -0
  60. rr_app-1.5.0/rr_app/gui/tabs/scenario_tab.py +266 -0
  61. rr_app-1.5.0/rr_app/gui/tabs/settings_tab.py +1242 -0
  62. rr_app-1.5.0/rr_app/gui/tabs/signals_tab.py +304 -0
  63. rr_app-1.5.0/rr_app/gui/widgets.py +3929 -0
  64. rr_app-1.5.0/rr_app/i18n.py +243 -0
  65. rr_app-1.5.0/rr_app/logging_utils.py +23 -0
  66. rr_app-1.5.0/rr_app/recording/__init__.py +27 -0
  67. rr_app-1.5.0/rr_app/recording/akr.py +269 -0
  68. rr_app-1.5.0/rr_app/recording/core.py +1557 -0
  69. rr_app-1.5.0/rr_app/recording/emgs.py +150 -0
  70. rr_app-1.5.0/rr_app/resources/languages.json +797 -0
  71. rr_app-1.5.0/rr_app/resources/style.qss +128 -0
  72. rr_app-1.5.0/rr_app/update_check.py +125 -0
  73. rr_app-1.5.0/rr_app.egg-info/PKG-INFO +144 -0
  74. rr_app-1.5.0/rr_app.egg-info/SOURCES.txt +78 -0
  75. rr_app-1.5.0/rr_app.egg-info/dependency_links.txt +1 -0
  76. rr_app-1.5.0/rr_app.egg-info/entry_points.txt +2 -0
  77. rr_app-1.5.0/rr_app.egg-info/requires.txt +17 -0
  78. rr_app-1.5.0/rr_app.egg-info/top_level.txt +5 -0
  79. rr_app-1.5.0/scripts/release/build_desktop_packages.py +254 -0
  80. 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,11 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ if __name__ == "__main__":
5
+ try:
6
+ from .cli import main
7
+ except ImportError:
8
+ from rr_app.cli import main
9
+
10
+ raise SystemExit(main())
11
+
@@ -0,0 +1,9 @@
1
+ """Signal processing and modeling utilities (offline).
2
+
3
+ This package is intentionally GUI-agnostic so it can be used for:
4
+ - offline feature extraction / training
5
+ - lightweight runtime inference (optional)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
@@ -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
+