Skip to content

Commit 3cb8b5d

Browse files
wujiajunhahahclaude
andcommitted
feat(stamina): import StaminaEngine v0.1.0 (status: imported)
Three-dimensional EMG stamina with temporal attention and variance-derived confidence. Pure numpy, no training, deterministic. Dimensions (each in [0, 1]): - pattern_consistency — CoV of RMS over recent windows - baseline_tension — RMS above resting baseline - sustained_capacity — MDF leftward drift (De Luca 1997) Per-window features (RMS, MDF) feed three independent dimension extractors. Dimensions are aggregated across recent windows with exponential-decay attention (closed-form alternative to the learned attention in Yan et al. PLOS One 2024 — same intuition, no training). The linear combination drives a stamina accumulator that drains in work and recovers in rest. API: - StaminaState enum (focused / fading / depleted / recovering / unknown) - FatigueDimensions dataclass with per-axis fatigue evidence - StaminaReading dataclass (stamina, state, confidence, dimensions, notes) - StaminaConfig (sample rate, window size, weights, thresholds) - StaminaEngine.update(samples, *, now) → StaminaReading - save_state / load_state / compute_offline_recovery for persistence - Standalone feature helpers (rms, median_frequency, pattern_consistency, baseline_tension, sustained_capacity) - Standalone attention helpers (exponential_decay_weights, weighted_aggregate, confidence_from_variance) 48 passing tests across features, attention, and engine. Demo: examples/demo.py — 60-second synthetic session with rising amplitude and falling MDF; exercises all three dimensions and persistence roundtrip. Status: engines/stamina → imported. Cumulative: fusion + intervention + stamina all imported. Other modules remain planned. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 08490b9 commit 3cb8b5d

13 files changed

Lines changed: 1101 additions & 23 deletions

File tree

engines/stamina/README.md

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,132 @@
11
# stamina engine
22

3-
**Status:** `planned`
3+
**Status:** `imported` · v0.1.0 · Apache-2.0
44

5-
Turns 8-channel EMG into a stamina score (0–100) and a 4-state state machine.
6-
Not a linear regression — three independent dimensions with explicit theoretical grounding.
5+
Three-dimensional EMG-based stamina estimation with **temporal attention**
6+
(exponential-decay weighting) and **variance-derived confidence**. Pure
7+
numpy, no training, fully deterministic.
78

8-
## Three dimensions
9+
## What it computes
910

10-
| Dimension | Weight | What it captures | Reference |
11-
|---|---|---|---|
12-
| Pattern Consistency | 0.40 | Coefficient of variation of RMS over a 30 s window — proxy for sustained focus ||
13-
| Baseline Tension | 0.25 | Residual muscle tone above rest baseline ||
14-
| Sustained Capacity | 0.35 | Median frequency (MDF) drift over time — physiological fatigue | De Luca, 1997 |
11+
For each analysis window (default 250 ms of EMG samples) the engine
12+
extracts:
1513

16-
## State machine
14+
- **RMS** — root mean square amplitude
15+
- **MDF** — median frequency of the power spectrum (De Luca, 1997)
1716

17+
…then reduces them into three independent fatigue dimensions:
18+
19+
| Dimension | Source | What it captures |
20+
|---|---|---|
21+
| Pattern Consistency | CoV of RMS over recent windows | Motor pattern stability |
22+
| Baseline Tension | RMS / resting-baseline RMS | Residual carried tension |
23+
| Sustained Capacity | MDF leftward drift vs. baseline | Spectral fatigue (De Luca 1997) |
24+
25+
Each dimension is normalised to [0, 1] and aggregated across recent
26+
windows using **exponential decay** (newest window weight 1.0, with a
27+
configurable half-life). The aggregate is then linearly combined into a
28+
fatigue score that drives a stamina accumulator (drains in work, recovers
29+
in rest), and finally classified into a state.
30+
31+
## What's new vs. a plain three-dimensional sum
32+
33+
Compared to a flat per-window combination, this engine adds:
34+
35+
1. **Temporal attention** via exponential decay. Recent windows weigh
36+
more than older ones — addresses the same intuition as the multilevel
37+
attention mechanism in *Yan et al., PLOS One 2024* on sEMG fatigue
38+
recognition, in closed form and without training.
39+
2. **Variance-based confidence**. Every reading carries a confidence
40+
score derived from per-window RMS variance. Sparse or extremely
41+
noisy data automatically reduces downstream trust.
42+
3. **Per-dimension audit**. Two readings can both be `stamina=55` but
43+
come from very different dimension breakdowns. Consumers (e.g. the
44+
intervention engine) can read each dimension to differentiate
45+
"motor pattern unstable, needs rest" from "baseline tension high,
46+
needs relaxation".
47+
4. **Persistence**. `save_state()` / `load_state()` / `compute_offline_recovery()`
48+
survive process restarts and resume with a sensible estimate of
49+
recovery during downtime.
50+
51+
## Install
52+
53+
```bash
54+
cd engines/stamina
55+
pip install -e .
1856
```
19-
focused (>60) → fading (30–60) → depleted (<30) → recovering
57+
58+
## Use
59+
60+
```python
61+
from focux_stamina import StaminaConfig, StaminaEngine
62+
import numpy as np
63+
import time
64+
65+
cfg = StaminaConfig(sample_rate_hz=1000.0, window_size_sec=0.25)
66+
engine = StaminaEngine(cfg)
67+
68+
# every 250 ms of EMG samples, push them through:
69+
samples = np.array([...]) # one window
70+
reading = engine.update(samples, now=time.time())
71+
72+
print(reading.stamina, reading.state.value, reading.confidence)
73+
print(reading.dimensions.to_dict())
74+
# {'pattern_consistency': 0.21,
75+
# 'baseline_tension': 0.45,
76+
# 'sustained_capacity': 0.12}
2077
```
2178

22-
## Accumulator behaviour
79+
See [`examples/demo.py`](examples/demo.py) for a full 60-second synthetic
80+
session that ramps up fatigue and exercises every dimension.
81+
82+
## Public API
83+
84+
| Name | Purpose |
85+
|---|---|
86+
| `StaminaState` | Enum: focused / fading / depleted / recovering / unknown |
87+
| `FatigueDimensions` | Per-axis breakdown (`pattern_consistency`, `baseline_tension`, `sustained_capacity`) |
88+
| `StaminaReading` | Engine output dataclass with `stamina`, `state`, `confidence`, `dimensions`, `notes` |
89+
| `StaminaConfig` | All sample/window/weight/threshold parameters |
90+
| `StaminaEngine` | `update(samples, *, now)`, `save_state()`, `load_state(snap)`, `compute_offline_recovery(elapsed_sec)`, `current_stamina` |
91+
| `rms`, `median_frequency`, `pattern_consistency`, `baseline_tension`, `sustained_capacity` | Standalone feature helpers |
92+
| `exponential_decay_weights`, `weighted_aggregate`, `confidence_from_variance` | Attention helpers |
93+
94+
## Tests
95+
96+
```bash
97+
pip install -e .[test]
98+
pytest
99+
```
23100

24-
- Working state: stamina decreases at `drain_rate × (0.5 + fatigue_multiplier)`
25-
- Resting state: recovers at `recovery_rate`
26-
- Supports persistence: save / load state, compute natural recovery while offline
101+
48 tests covering: RMS / MDF on zero / empty / sine / dominant-frequency
102+
inputs; pattern consistency, baseline tension, sustained capacity edge
103+
cases (empty / constant / drop magnitudes); exponential decay weights
104+
(half-life correctness, monotonicity, degenerate zero half-life);
105+
weighted aggregate; confidence-from-variance; engine initial state,
106+
drain in work, recover in rest, state classification at thresholds,
107+
dimensions within unit interval, variable-amplitude increases pattern
108+
dimension, persistence roundtrip, offline recovery cap at 100,
109+
confidence within unit interval.
27110

28-
## Why three dimensions instead of one number
111+
## Design decisions
29112

30-
The same stamina = 60 can come from *unstable pattern* (rest needed) or *accumulated tension*
31-
(relaxation needed) or *spectral fatigue* (stop working). Downstream policies need to differentiate.
113+
- **No neural net.** Exponential decay is the closed-form, training-free
114+
version of "attention over recent windows". For sEMG fatigue the
115+
marginal accuracy of a learned attention layer doesn't justify the
116+
audit-trail loss.
117+
- **Three dimensions, kept separate.** Same `stamina` value can mean
118+
different things; downstream policies need to differentiate them.
119+
- **Calibration is automatic.** The first few seconds of low-activity
120+
data populate per-user baselines for RMS and MDF. The engine reports
121+
`baseline_pending` in `notes` until calibration completes.
122+
- **Persistence over process boundary.** The engine is the
123+
user's continuous state record — it must survive restarts.
32124

33-
## To be imported
125+
## References
34126

35-
- `StaminaEngine` core class
36-
- `StaminaReading` dataclass + state enum
37-
- Persistence helpers (`save_state` / `load_state` / `compute_offline_recovery`)
38-
- Unit tests covering each dimension and the state transitions
127+
- De Luca, C. J. *The Use of Surface Electromyography in Biomechanics.*
128+
J Applied Biomechanics, 1997. (MDF as fatigue marker)
129+
- Yan, X. et al. *Multilevel attention mechanism for motion fatigue
130+
recognition based on sEMG and ACC signal fusion.* PLOS One, 2024.
131+
- Park, J. et al. *A Multimodal Fatigue Detection System Using sEMG
132+
and IMU Signals with a Hybrid CNN-LSTM-Attention Model.* Sensors, 2025.

engines/stamina/examples/demo.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Demo: walk a synthetic 60-second EMG session through StaminaEngine.
2+
3+
The first 5 seconds are resting baseline. The next 55 seconds simulate
4+
working activity with rising amplitude (increasing tension) and falling
5+
median frequency (spectral fatigue) — i.e. all three fatigue dimensions
6+
should trend up.
7+
8+
Run:
9+
10+
pip install -e .
11+
python examples/demo.py
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import numpy as np
17+
18+
from focux_stamina import StaminaConfig, StaminaEngine
19+
20+
21+
def main() -> None:
22+
rng = np.random.default_rng(seed=7)
23+
cfg = StaminaConfig(
24+
sample_rate_hz=1000.0,
25+
window_size_sec=0.25,
26+
drain_rate_per_sec=1.5,
27+
recovery_rate_per_sec=0.5,
28+
)
29+
eng = StaminaEngine(cfg)
30+
31+
print("StaminaEngine demo — 60s synthetic session")
32+
print(" first 5s = rest baseline; remaining 55s = working with rising fatigue\n")
33+
print(f" {'t(s)':>5} {'rms':>6} {'mdf(Hz)':>8} "
34+
f"{'stamina':>8} {'state':<10} "
35+
f"{'pc':>5} {'bt':>5} {'sc':>5} conf")
36+
print(f" {'-'*5} {'-'*6} {'-'*8} {'-'*8} {'-'*10} "
37+
f"{'-'*5} {'-'*5} {'-'*5} ----")
38+
39+
now = 0.0
40+
n_windows = int(60.0 / cfg.window_size_sec)
41+
for i in range(n_windows):
42+
t_now = i * cfg.window_size_sec
43+
if t_now < 5.0:
44+
samples = rng.normal(0, 0.01, cfg.window_samples)
45+
else:
46+
# ramping amplitude + falling MDF
47+
ramp = (t_now - 5.0) / 55.0
48+
amp = 0.2 + 0.5 * ramp + rng.normal(0, 0.05)
49+
target_freq = 120.0 - 70.0 * ramp
50+
t = np.arange(cfg.window_samples) / cfg.sample_rate_hz
51+
samples = amp * np.sin(2 * np.pi * target_freq * t) + rng.normal(
52+
0, 0.02, cfg.window_samples
53+
)
54+
now += cfg.window_size_sec
55+
r = eng.update(samples, now=now)
56+
if i % 16 == 0 or i == n_windows - 1:
57+
d = r.dimensions
58+
print(
59+
f" {t_now:5.1f} {(samples**2).mean()**0.5:6.3f} "
60+
f"{0.0:8.1f} "
61+
f"{r.stamina:8.2f} {r.state.value:<10} "
62+
f"{d.pattern_consistency:5.2f} "
63+
f"{d.baseline_tension:5.2f} "
64+
f"{d.sustained_capacity:5.2f} "
65+
f"{r.confidence:4.2f}"
66+
)
67+
68+
print()
69+
print(f"Final stamina: {eng.current_stamina:.2f}")
70+
snap = eng.save_state()
71+
print(f"Saved state snapshot keys: {sorted(snap.keys())}")
72+
eng2 = StaminaEngine(cfg)
73+
eng2.load_state(snap)
74+
eng2.compute_offline_recovery(elapsed_sec=60.0)
75+
print(f"After 60s offline recovery: {eng2.current_stamina:.2f}")
76+
77+
78+
if __name__ == "__main__":
79+
main()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""focux_stamina — three-dimensional EMG stamina engine with temporal attention."""
2+
3+
from .attention import exponential_decay_weights, weighted_aggregate
4+
from .config import StaminaConfig
5+
from .engine import StaminaEngine
6+
from .features import (
7+
baseline_tension,
8+
median_frequency,
9+
pattern_consistency,
10+
rms,
11+
sustained_capacity,
12+
)
13+
from .types import FatigueDimensions, StaminaReading, StaminaState
14+
15+
__all__ = [
16+
"StaminaConfig",
17+
"StaminaEngine",
18+
"StaminaReading",
19+
"StaminaState",
20+
"FatigueDimensions",
21+
"rms",
22+
"median_frequency",
23+
"pattern_consistency",
24+
"baseline_tension",
25+
"sustained_capacity",
26+
"exponential_decay_weights",
27+
"weighted_aggregate",
28+
]
29+
__version__ = "0.1.0"
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Lightweight temporal attention for sequential windows.
2+
3+
We do not use neural attention. Just an exponential decay over time:
4+
the most recent window gets weight 1.0, and earlier windows fade with
5+
a configurable half-life.
6+
7+
This addresses the same intuition as multilevel attention in PLOS One
8+
2024 (Yan et al.) for sEMG fatigue recognition — the network's attention
9+
heatmap also concentrates on recent windows for fatigue-onset detection.
10+
Exponential decay is the closed-form, training-free version: cheap,
11+
deterministic, and good enough when the underlying features are
12+
already strong.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import math
18+
from typing import Sequence
19+
20+
import numpy as np
21+
22+
23+
def exponential_decay_weights(
24+
n_windows: int, half_life_windows: float
25+
) -> np.ndarray:
26+
"""Return weights for `n_windows` windows ordered oldest → newest.
27+
28+
The newest window (index -1) gets weight 1.0; the window
29+
`half_life_windows` steps before it gets weight 0.5.
30+
"""
31+
if n_windows <= 0:
32+
return np.array([], dtype=float)
33+
if half_life_windows <= 0:
34+
# degenerate: only the newest window has weight
35+
w = np.zeros(n_windows, dtype=float)
36+
w[-1] = 1.0
37+
return w
38+
decay = math.log(2.0) / half_life_windows
39+
indices = np.arange(n_windows)
40+
age = (n_windows - 1) - indices # 0 for newest, n-1 for oldest
41+
return np.exp(-decay * age)
42+
43+
44+
def weighted_aggregate(
45+
values: Sequence[float], half_life_windows: float
46+
) -> float:
47+
"""Single-scalar weighted aggregate of a sequence with exponential decay.
48+
49+
Returns 0.0 for empty input.
50+
"""
51+
if not values:
52+
return 0.0
53+
arr = np.asarray(values, dtype=float)
54+
w = exponential_decay_weights(arr.size, half_life_windows)
55+
total = w.sum()
56+
if total == 0:
57+
return 0.0
58+
return float((arr * w).sum() / total)
59+
60+
61+
def confidence_from_variance(values: Sequence[float]) -> float:
62+
"""Map per-window variance into a [0, 1] confidence score.
63+
64+
Low variance → high confidence (the engine is sure about the signal).
65+
Uses a soft saturation: variance ≥ 0.25 maps to confidence 0.0;
66+
variance == 0 maps to confidence 1.0.
67+
"""
68+
if not values or len(values) < 2:
69+
return 0.0
70+
arr = np.asarray(values, dtype=float)
71+
v = float(arr.var())
72+
return float(max(0.0, 1.0 - v / 0.25))
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""StaminaEngine configuration. All thresholds and weights in one place."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
8+
@dataclass
9+
class StaminaConfig:
10+
sample_rate_hz: float = 1000.0
11+
"""EMG sample rate (Hz)."""
12+
13+
window_size_sec: float = 0.25
14+
"""Length of one analysis window in seconds."""
15+
16+
history_windows: int = 120
17+
"""How many recent windows to retain for variance / trend analysis
18+
(default 120 × 0.25s = 30 seconds)."""
19+
20+
half_life_windows: float = 8.0
21+
"""Exponential decay half-life in windows for the temporal attention
22+
aggregation. Default 8 × 0.25s = 2 seconds → recent two seconds carry
23+
half the weight, older windows fade gracefully."""
24+
25+
weight_pattern_consistency: float = 0.40
26+
weight_baseline_tension: float = 0.25
27+
weight_sustained_capacity: float = 0.35
28+
29+
drain_rate_per_sec: float = 0.05
30+
"""Rate at which stamina drains in a normal working state. The actual
31+
drain is `drain_rate × (1 + fatigue_evidence)` so high-fatigue
32+
windows deplete faster."""
33+
34+
recovery_rate_per_sec: float = 0.10
35+
"""Rate at which stamina recovers in a resting state."""
36+
37+
rest_rms_threshold: float = 0.05
38+
"""Below this RMS the user is treated as resting (recovery, not drain)."""
39+
40+
state_focused_threshold: float = 60.0
41+
state_depleted_threshold: float = 30.0
42+
43+
@property
44+
def window_samples(self) -> int:
45+
return max(1, int(self.window_size_sec * self.sample_rate_hz))

0 commit comments

Comments
 (0)