rr-app 1.5.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.
- rr_app/__init__.py +36 -0
- rr_app/__main__.py +11 -0
- rr_app/analysis/__init__.py +9 -0
- rr_app/analysis/emg_features.py +261 -0
- rr_app/analysis/fatigue_index.py +85 -0
- rr_app/analysis/fatigue_labels.py +85 -0
- rr_app/analysis/fatigue_model.py +212 -0
- rr_app/analysis/train_fatigue_model.py +163 -0
- rr_app/app.py +108 -0
- rr_app/ble/__init__.py +2 -0
- rr_app/ble/backend.py +426 -0
- rr_app/ble/manager.py +1007 -0
- rr_app/cli.py +110 -0
- rr_app/config/__init__.py +11 -0
- rr_app/config/akr.py +20 -0
- rr_app/config/core.py +24 -0
- rr_app/config/emgs.py +31 -0
- rr_app/config/recording.py +128 -0
- rr_app/devices/__init__.py +2 -0
- rr_app/devices/akr/__init__.py +2 -0
- rr_app/devices/akr/ble/__init__.py +2 -0
- rr_app/devices/akr/ble/ota_transfer.py +433 -0
- rr_app/devices/akr/ble/protocol.py +44 -0
- rr_app/devices/akr/ble/stream.py +629 -0
- rr_app/devices/akr/ble/utils.py +196 -0
- rr_app/devices/akr/handler.py +87 -0
- rr_app/devices/akr/manager.py +218 -0
- rr_app/devices/akr/ota_session.py +98 -0
- rr_app/devices/akr/state.py +123 -0
- rr_app/devices/core/__init__.py +21 -0
- rr_app/devices/core/base.py +39 -0
- rr_app/devices/core/contracts.py +75 -0
- rr_app/devices/core/controller.py +233 -0
- rr_app/devices/core/orchestrator.py +63 -0
- rr_app/devices/core/registry.py +31 -0
- rr_app/devices/core/session_manager.py +48 -0
- rr_app/devices/core/state.py +39 -0
- rr_app/devices/emgs/__init__.py +2 -0
- rr_app/devices/emgs/ble/__init__.py +2 -0
- rr_app/devices/emgs/ble/protocol.py +114 -0
- rr_app/devices/emgs/handler.py +106 -0
- rr_app/devices/emgs/manager.py +27 -0
- rr_app/devices/emgs/state.py +641 -0
- rr_app/gui/__init__.py +2 -0
- rr_app/gui/akr_feedback_buffers.py +13 -0
- rr_app/gui/akr_feedback_catalog.py +458 -0
- rr_app/gui/main_window.py +393 -0
- rr_app/gui/mixins/__init__.py +7 -0
- rr_app/gui/mixins/connect_actions.py +448 -0
- rr_app/gui/mixins/runtime.py +167 -0
- rr_app/gui/mixins/session_controls.py +885 -0
- rr_app/gui/mixins/settings_profiles.py +429 -0
- rr_app/gui/mixins/signals_plotting.py +1891 -0
- rr_app/gui/models.py +278 -0
- rr_app/gui/tabs/__init__.py +7 -0
- rr_app/gui/tabs/connect_tab.py +100 -0
- rr_app/gui/tabs/scenario_tab.py +266 -0
- rr_app/gui/tabs/settings_tab.py +1242 -0
- rr_app/gui/tabs/signals_tab.py +304 -0
- rr_app/gui/widgets.py +3929 -0
- rr_app/i18n.py +243 -0
- rr_app/logging_utils.py +23 -0
- rr_app/recording/__init__.py +27 -0
- rr_app/recording/akr.py +269 -0
- rr_app/recording/core.py +1557 -0
- rr_app/recording/emgs.py +150 -0
- rr_app/resources/languages.json +797 -0
- rr_app/resources/style.qss +128 -0
- rr_app/update_check.py +125 -0
- rr_app-1.5.0.dist-info/METADATA +144 -0
- rr_app-1.5.0.dist-info/RECORD +75 -0
- rr_app-1.5.0.dist-info/WHEEL +5 -0
- rr_app-1.5.0.dist-info/entry_points.txt +2 -0
- rr_app-1.5.0.dist-info/top_level.txt +2 -0
- scripts/release/build_desktop_packages.py +254 -0
rr_app/__init__.py
ADDED
|
@@ -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
|
+
"""
|
rr_app/__main__.py
ADDED
|
@@ -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
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class FatigueIndexConfig:
|
|
11
|
+
"""Unsupervised fatigue score configuration (no ground truth required)."""
|
|
12
|
+
|
|
13
|
+
baseline_s: float = 10.0 # use early windows as baseline
|
|
14
|
+
w_mdf: float = 0.7
|
|
15
|
+
w_rms: float = 0.3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _clip01(x: np.ndarray) -> np.ndarray:
|
|
19
|
+
return np.clip(np.asarray(x, dtype=float), 0.0, 1.0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fatigue_index_unsupervised(
|
|
23
|
+
t_s: np.ndarray,
|
|
24
|
+
*,
|
|
25
|
+
rms: np.ndarray,
|
|
26
|
+
mdf_hz: np.ndarray,
|
|
27
|
+
cfg: FatigueIndexConfig = FatigueIndexConfig(),
|
|
28
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
29
|
+
"""Compute (fatigue_score_0to1, confidence_0to1) from feature trends.
|
|
30
|
+
|
|
31
|
+
This is NOT a calibrated probability. It's a heuristic score that tends to increase when:
|
|
32
|
+
- MDF decreases (classic fatigue marker)
|
|
33
|
+
- RMS increases (often, but not always)
|
|
34
|
+
"""
|
|
35
|
+
t = np.asarray(t_s, dtype=float).reshape(-1)
|
|
36
|
+
rms = np.asarray(rms, dtype=float).reshape(-1)
|
|
37
|
+
mdf = np.asarray(mdf_hz, dtype=float).reshape(-1)
|
|
38
|
+
n = int(min(t.size, rms.size, mdf.size))
|
|
39
|
+
if n <= 0:
|
|
40
|
+
return np.zeros((0,), dtype=float), np.zeros((0,), dtype=float)
|
|
41
|
+
|
|
42
|
+
t = t[:n]
|
|
43
|
+
rms = rms[:n]
|
|
44
|
+
mdf = mdf[:n]
|
|
45
|
+
|
|
46
|
+
# Baseline = early segment (by time).
|
|
47
|
+
t0 = float(np.min(t))
|
|
48
|
+
t1 = t0 + float(cfg.baseline_s)
|
|
49
|
+
base_mask = t <= t1
|
|
50
|
+
if not np.any(base_mask):
|
|
51
|
+
base_mask = np.arange(n) < max(1, n // 10)
|
|
52
|
+
|
|
53
|
+
mdf0 = float(np.nanmedian(mdf[base_mask]))
|
|
54
|
+
rms0 = float(np.nanmedian(rms[base_mask]))
|
|
55
|
+
if not math.isfinite(mdf0) or mdf0 <= 0:
|
|
56
|
+
mdf0 = float(np.nanmedian(mdf)) if np.nanmedian(mdf) > 0 else 1.0
|
|
57
|
+
if not math.isfinite(rms0) or rms0 <= 0:
|
|
58
|
+
rms0 = float(np.nanmedian(rms)) if np.nanmedian(rms) > 0 else 1.0
|
|
59
|
+
|
|
60
|
+
# Components
|
|
61
|
+
mdf_drop = _clip01((mdf0 - mdf) / (mdf0 + 1e-9))
|
|
62
|
+
rms_rise = _clip01((rms - rms0) / (rms0 + 1e-9))
|
|
63
|
+
score = _clip01(float(cfg.w_mdf) * mdf_drop + float(cfg.w_rms) * rms_rise)
|
|
64
|
+
|
|
65
|
+
# Confidence heuristic: higher when MDF trend is consistently decreasing.
|
|
66
|
+
conf = np.zeros_like(score, dtype=float)
|
|
67
|
+
try:
|
|
68
|
+
from scipy.stats import spearmanr # type: ignore
|
|
69
|
+
|
|
70
|
+
# Trend over time: fatigue implies mdf decreases, so corr(t, -mdf) should be positive.
|
|
71
|
+
rho, _p = spearmanr(t, -mdf, nan_policy="omit")
|
|
72
|
+
rho = 0.0 if not math.isfinite(float(rho)) else float(rho)
|
|
73
|
+
# Map rho in [-1..1] to [0..1] with some floor.
|
|
74
|
+
trend_conf = _clip01((rho + 1.0) / 2.0)
|
|
75
|
+
conf = 0.2 + 0.8 * float(trend_conf)
|
|
76
|
+
except Exception:
|
|
77
|
+
# Fallback: use monotonicity of MDF (fraction of steps that go down).
|
|
78
|
+
if n >= 2:
|
|
79
|
+
down = float(np.mean(np.diff(mdf) < 0))
|
|
80
|
+
conf = 0.2 + 0.8 * float(_clip01(down))
|
|
81
|
+
else:
|
|
82
|
+
conf = np.ones_like(score, dtype=float) * 0.2
|
|
83
|
+
|
|
84
|
+
return score, _clip01(conf)
|
|
85
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _clip01(x: np.ndarray) -> np.ndarray:
|
|
10
|
+
return np.clip(np.asarray(x, dtype=float), 0.0, 1.0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fatigue_score_from_time_to_failure(t_s: np.ndarray, *, t_fail_s: float, t_start_s: float | None = None) -> np.ndarray:
|
|
14
|
+
"""Weak label: normalize time into a fatigue score in [0..1].
|
|
15
|
+
|
|
16
|
+
- 0.0 at t_start_s (default: min(t_s))
|
|
17
|
+
- 1.0 at t_fail_s
|
|
18
|
+
"""
|
|
19
|
+
t = np.asarray(t_s, dtype=float)
|
|
20
|
+
if t.size == 0:
|
|
21
|
+
return np.zeros((0,), dtype=float)
|
|
22
|
+
tf = float(t_fail_s)
|
|
23
|
+
if not math.isfinite(tf) or tf <= 0:
|
|
24
|
+
raise ValueError(f"Invalid t_fail_s={t_fail_s}")
|
|
25
|
+
t0 = float(np.min(t)) if t_start_s is None else float(t_start_s)
|
|
26
|
+
denom = max(1e-9, tf - t0)
|
|
27
|
+
y = (t - t0) / denom
|
|
28
|
+
return _clip01(y)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fatigue_score_from_force(force: np.ndarray, *, baseline_force: float | None = None) -> np.ndarray:
|
|
32
|
+
"""Weak label: map force drop into fatigue score in [0..1].
|
|
33
|
+
|
|
34
|
+
score(t) = (baseline - force(t)) / baseline, clipped to [0..1]
|
|
35
|
+
"""
|
|
36
|
+
f = np.asarray(force, dtype=float)
|
|
37
|
+
if f.size == 0:
|
|
38
|
+
return np.zeros((0,), dtype=float)
|
|
39
|
+
base = float(np.nanmedian(f[: max(1, min(200, f.size))])) if baseline_force is None else float(baseline_force)
|
|
40
|
+
if not math.isfinite(base) or base <= 0:
|
|
41
|
+
raise ValueError(f"Invalid baseline_force={baseline_force}")
|
|
42
|
+
y = (base - f) / base
|
|
43
|
+
y = np.nan_to_num(y, nan=0.0, posinf=1.0, neginf=0.0)
|
|
44
|
+
return _clip01(y)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fatigue_score_from_rpe(t_s: np.ndarray, rpe_times_s: np.ndarray, rpe_values: np.ndarray, *, rpe_min: float = 6.0, rpe_max: float = 20.0) -> np.ndarray:
|
|
48
|
+
"""Weak label: interpolate RPE (Borg 6–20) into a continuous fatigue score in [0..1]."""
|
|
49
|
+
t = np.asarray(t_s, dtype=float)
|
|
50
|
+
rt = np.asarray(rpe_times_s, dtype=float)
|
|
51
|
+
rv = np.asarray(rpe_values, dtype=float)
|
|
52
|
+
if t.size == 0:
|
|
53
|
+
return np.zeros((0,), dtype=float)
|
|
54
|
+
if rt.size == 0 or rv.size == 0 or rt.size != rv.size:
|
|
55
|
+
raise ValueError("rpe_times_s and rpe_values must be same nonzero length")
|
|
56
|
+
# Sort RPE points by time.
|
|
57
|
+
order = np.argsort(rt)
|
|
58
|
+
rt = rt[order]
|
|
59
|
+
rv = rv[order]
|
|
60
|
+
# Interpolate with flat extrapolation.
|
|
61
|
+
v = np.interp(t, rt, rv, left=float(rv[0]), right=float(rv[-1]))
|
|
62
|
+
denom = max(1e-9, float(rpe_max) - float(rpe_min))
|
|
63
|
+
y = (v - float(rpe_min)) / denom
|
|
64
|
+
return _clip01(y)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def bin_fatigue_score(y01: np.ndarray, *, edges: tuple[float, float] = (0.33, 0.66)) -> np.ndarray:
|
|
68
|
+
"""Convert continuous [0..1] score to 3 ordinal levels: 0/1/2."""
|
|
69
|
+
y = _clip01(y01)
|
|
70
|
+
e0, e1 = float(edges[0]), float(edges[1])
|
|
71
|
+
if not (0.0 < e0 < e1 < 1.0):
|
|
72
|
+
raise ValueError(f"Invalid edges={edges}")
|
|
73
|
+
out = np.zeros_like(y, dtype=int)
|
|
74
|
+
out[y >= e0] = 1
|
|
75
|
+
out[y >= e1] = 2
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class FatigueLabelSpec:
|
|
81
|
+
"""Configuration for weak labels."""
|
|
82
|
+
|
|
83
|
+
kind: str # "time_to_failure" | "force_drop" | "rpe" | "provided_score"
|
|
84
|
+
edges: tuple[float, float] = (0.33, 0.66)
|
|
85
|
+
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require_sklearn() -> "object":
|
|
13
|
+
try:
|
|
14
|
+
import sklearn # type: ignore
|
|
15
|
+
except ModuleNotFoundError as e: # pragma: no cover
|
|
16
|
+
raise ModuleNotFoundError(
|
|
17
|
+
"scikit-learn is required for training/calibration. Install with: pip install -r requirements.txt"
|
|
18
|
+
) from e
|
|
19
|
+
return sklearn
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TrainConfig:
|
|
24
|
+
model: str = "logreg" # "logreg" | "gbdt"
|
|
25
|
+
calibrate: str = "sigmoid" # "sigmoid" | "isotonic" | "none"
|
|
26
|
+
random_state: int = 1337
|
|
27
|
+
max_iter: int = 2000
|
|
28
|
+
# Conformal prediction set coverage (1-alpha). alpha=0.1 -> 90% sets.
|
|
29
|
+
conformal_alpha: float = 0.1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_classifier(cfg: TrainConfig) -> Any:
|
|
33
|
+
_require_sklearn()
|
|
34
|
+
from sklearn.linear_model import LogisticRegression
|
|
35
|
+
from sklearn.ensemble import HistGradientBoostingClassifier
|
|
36
|
+
from sklearn.pipeline import Pipeline
|
|
37
|
+
from sklearn.preprocessing import StandardScaler
|
|
38
|
+
|
|
39
|
+
model = str(cfg.model).lower()
|
|
40
|
+
if model == "logreg":
|
|
41
|
+
clf = LogisticRegression(
|
|
42
|
+
penalty="l2",
|
|
43
|
+
C=1.0,
|
|
44
|
+
max_iter=int(cfg.max_iter),
|
|
45
|
+
multi_class="multinomial",
|
|
46
|
+
solver="lbfgs",
|
|
47
|
+
random_state=int(cfg.random_state),
|
|
48
|
+
)
|
|
49
|
+
# Logistic regression benefits from standardization.
|
|
50
|
+
return Pipeline([("scaler", StandardScaler()), ("clf", clf)])
|
|
51
|
+
if model == "gbdt":
|
|
52
|
+
clf = HistGradientBoostingClassifier(random_state=int(cfg.random_state))
|
|
53
|
+
return Pipeline([("clf", clf)])
|
|
54
|
+
raise ValueError(f"Unknown model={cfg.model!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def maybe_calibrate(estimator: Any, *, method: str, cv: int = 3) -> Any:
|
|
58
|
+
_require_sklearn()
|
|
59
|
+
from sklearn.calibration import CalibratedClassifierCV
|
|
60
|
+
|
|
61
|
+
method = str(method).lower()
|
|
62
|
+
if method in ("none", "", "false", "0"):
|
|
63
|
+
return estimator
|
|
64
|
+
if method not in ("sigmoid", "isotonic"):
|
|
65
|
+
raise ValueError(f"Unknown calibrate method={method!r}")
|
|
66
|
+
return CalibratedClassifierCV(estimator, method=method, cv=int(cv))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def groupwise_cv_metrics(X: np.ndarray, y: np.ndarray, groups: np.ndarray, *, cfg: TrainConfig) -> dict[str, float]:
|
|
70
|
+
"""Subject-wise validation metrics (macro F1 + log loss + Brier)."""
|
|
71
|
+
_require_sklearn()
|
|
72
|
+
from sklearn.model_selection import GroupKFold
|
|
73
|
+
from sklearn.metrics import f1_score, accuracy_score, log_loss
|
|
74
|
+
|
|
75
|
+
X = np.asarray(X, dtype=float)
|
|
76
|
+
y = np.asarray(y, dtype=int)
|
|
77
|
+
g = np.asarray(groups)
|
|
78
|
+
|
|
79
|
+
uniq = np.unique(g)
|
|
80
|
+
n_groups = int(uniq.size)
|
|
81
|
+
if n_groups < 2:
|
|
82
|
+
raise ValueError("Need at least 2 groups for subject-wise validation")
|
|
83
|
+
|
|
84
|
+
n_splits = min(5, n_groups)
|
|
85
|
+
cv = GroupKFold(n_splits=n_splits)
|
|
86
|
+
|
|
87
|
+
f1s: list[float] = []
|
|
88
|
+
accs: list[float] = []
|
|
89
|
+
lls: list[float] = []
|
|
90
|
+
briers: list[float] = []
|
|
91
|
+
|
|
92
|
+
classes = np.unique(y)
|
|
93
|
+
n_classes = int(classes.size)
|
|
94
|
+
|
|
95
|
+
for tr, te in cv.split(X, y, groups=g):
|
|
96
|
+
est = build_classifier(cfg)
|
|
97
|
+
est = maybe_calibrate(est, method=cfg.calibrate, cv=3)
|
|
98
|
+
est.fit(X[tr], y[tr])
|
|
99
|
+
y_hat = est.predict(X[te])
|
|
100
|
+
f1s.append(float(f1_score(y[te], y_hat, average="macro")))
|
|
101
|
+
accs.append(float(accuracy_score(y[te], y_hat)))
|
|
102
|
+
|
|
103
|
+
# Probabilistic metrics
|
|
104
|
+
if hasattr(est, "predict_proba"):
|
|
105
|
+
p = est.predict_proba(X[te])
|
|
106
|
+
# Ensure columns correspond to class labels 0..K-1 if needed.
|
|
107
|
+
try:
|
|
108
|
+
lls.append(float(log_loss(y[te], p, labels=list(range(n_classes)))))
|
|
109
|
+
except Exception:
|
|
110
|
+
# Fallback: compute log loss with inferred labels.
|
|
111
|
+
lls.append(float(log_loss(y[te], p)))
|
|
112
|
+
# Multi-class Brier: mean sum_k (p_k - 1[y=k])^2
|
|
113
|
+
y_onehot = np.eye(n_classes, dtype=float)[y[te]]
|
|
114
|
+
briers.append(float(np.mean(np.sum((p - y_onehot) ** 2, axis=1))))
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"cv_groups": float(n_groups),
|
|
118
|
+
"cv_splits": float(n_splits),
|
|
119
|
+
"accuracy_mean": float(np.mean(accs)),
|
|
120
|
+
"f1_macro_mean": float(np.mean(f1s)),
|
|
121
|
+
"log_loss_mean": float(np.mean(lls)) if lls else float("nan"),
|
|
122
|
+
"brier_mean": float(np.mean(briers)) if briers else float("nan"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def fit_final_model(X: np.ndarray, y: np.ndarray, *, cfg: TrainConfig) -> Any:
|
|
127
|
+
est = build_classifier(cfg)
|
|
128
|
+
est = maybe_calibrate(est, method=cfg.calibrate, cv=3)
|
|
129
|
+
est.fit(np.asarray(X, dtype=float), np.asarray(y, dtype=int))
|
|
130
|
+
return est
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def predict_with_confidence(estimator: Any, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
134
|
+
"""Return (y_pred, p_pred) where p_pred is max class probability."""
|
|
135
|
+
X = np.asarray(X, dtype=float)
|
|
136
|
+
if hasattr(estimator, "predict_proba"):
|
|
137
|
+
p = estimator.predict_proba(X)
|
|
138
|
+
y = np.argmax(p, axis=1).astype(int)
|
|
139
|
+
conf = np.max(p, axis=1).astype(float)
|
|
140
|
+
return y, conf
|
|
141
|
+
# Fallback: hard predictions only
|
|
142
|
+
y = estimator.predict(X).astype(int)
|
|
143
|
+
return y, np.ones((y.size,), dtype=float)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def conformal_threshold_from_calibration_probs(p_calib: np.ndarray, y_calib: np.ndarray, *, alpha: float = 0.1) -> float:
|
|
147
|
+
"""Compute split-conformal threshold for prediction sets.
|
|
148
|
+
|
|
149
|
+
Nonconformity score s = 1 - p_true. Then choose q = quantile_{ceil((n+1)*(1-alpha))/n}.
|
|
150
|
+
A class k is included in the set if 1 - p_k <= q <=> p_k >= 1 - q.
|
|
151
|
+
"""
|
|
152
|
+
p = np.asarray(p_calib, dtype=float)
|
|
153
|
+
y = np.asarray(y_calib, dtype=int)
|
|
154
|
+
if p.ndim != 2:
|
|
155
|
+
raise ValueError("p_calib must be 2D (n_samples, n_classes)")
|
|
156
|
+
if y.size != p.shape[0]:
|
|
157
|
+
raise ValueError("y_calib length must match p_calib rows")
|
|
158
|
+
|
|
159
|
+
alpha = float(alpha)
|
|
160
|
+
if not (0.0 < alpha < 1.0):
|
|
161
|
+
raise ValueError(f"alpha must be in (0,1), got {alpha}")
|
|
162
|
+
|
|
163
|
+
p_true = p[np.arange(y.size), y]
|
|
164
|
+
s = 1.0 - np.clip(p_true, 0.0, 1.0)
|
|
165
|
+
n = float(s.size)
|
|
166
|
+
# Conformal quantile with (n+1)(1-alpha) rule.
|
|
167
|
+
q_level = math.ceil((n + 1.0) * (1.0 - alpha)) / n
|
|
168
|
+
q_level = min(1.0, max(0.0, q_level))
|
|
169
|
+
q = float(np.quantile(s, q_level, method="higher"))
|
|
170
|
+
return q
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def prediction_set_from_probs(p: np.ndarray, *, q: float) -> list[list[int]]:
|
|
174
|
+
"""Return a prediction set (list of class indices) per sample."""
|
|
175
|
+
p = np.asarray(p, dtype=float)
|
|
176
|
+
q = float(q)
|
|
177
|
+
thr = 1.0 - q
|
|
178
|
+
out: list[list[int]] = []
|
|
179
|
+
for i in range(p.shape[0]):
|
|
180
|
+
cls = [int(k) for k in range(p.shape[1]) if float(p[i, k]) >= thr]
|
|
181
|
+
if not cls:
|
|
182
|
+
cls = [int(np.argmax(p[i]))]
|
|
183
|
+
out.append(cls)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def save_model(path: str | Path, *, estimator: Any, cfg: TrainConfig, feature_names: list[str], meta: dict[str, Any] | None = None) -> None:
|
|
188
|
+
_require_sklearn()
|
|
189
|
+
import joblib # type: ignore
|
|
190
|
+
|
|
191
|
+
payload = {
|
|
192
|
+
"estimator": estimator,
|
|
193
|
+
"train_config": asdict(cfg),
|
|
194
|
+
"feature_names": list(feature_names),
|
|
195
|
+
"meta": meta or {},
|
|
196
|
+
}
|
|
197
|
+
joblib.dump(payload, str(path))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load_model(path: str | Path) -> dict[str, Any]:
|
|
201
|
+
_require_sklearn()
|
|
202
|
+
import joblib # type: ignore
|
|
203
|
+
|
|
204
|
+
payload = joblib.load(str(path))
|
|
205
|
+
if not isinstance(payload, dict) or "estimator" not in payload:
|
|
206
|
+
raise ValueError("Invalid model file format")
|
|
207
|
+
return payload
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def save_metrics_json(path: str | Path, metrics: dict[str, float]) -> None:
|
|
211
|
+
Path(path).write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
212
|
+
|