# VBT Metrics Data Flow — MCU vs App

## 한 줄 요약

MCU가 50ms마다 position → speed(1차 미분) → accel(2차 미분)까지 계산해서 보낸다.
앱은 **원칙적으로 MCU 값을 그대로 사용**하고, **평균 concentric 속도(MCV) 하나만
position에서 재계산**한다 (T-Force gold standard, 샘플링 지터 면역).

## 왜 하이브리드인가

두 축의 트레이드오프:

| 지표 성격 | 선택 |
|---|---|
| Instantaneous peak (순간 최대) | MCU 값 신뢰 — MCU의 실시간 저역통과가 이미 적용된 값 |
| Phase mean (구간 평균) | 앱에서 displacement / duration 으로 재계산 — 샘플 주기(50ms)가 정확히 떨어지지 않을 때 평균화 오차 방지 |

MCV가 VBT의 핵심 지표(load-velocity profile, velocity-loss fatigue detection의
입력값)인 만큼 **가장 안정적인 계산 방식**을 쓰되, 나머지 순간 값은 MCU의
이미 필터링된 값을 재미분 없이 사용한다.

## Data flow (end-to-end)

```
MCU (펌웨어)
  50ms 주기로 계산:
    position        ← 엔코더 원시값
    speed           ← position 1차 미분 + LPF
    accel           ← speed 1차 미분
    fLoad           ← icmd 에서 kg 역산 (Iq × torque constant)
    icmd / ifb      ← PI 제어기 명령 / 피드백
   ↓
  0x41 report (36 bytes) 발신
   ↓
BLE → flutter_blue_plus → roomfit_ble.BleTransport
   ↓
roomfit_ble → ReportResponse (packets/roomfit_protocol/lib/src/responses/)
   ↓
roomfit_device.MotionState   (L/R 묶은 DTO)
   ↓
apps/{b2c,b2b}/lib/features/exercise/data/motion_sample_mapper.dart
  motionStateToSample()
  * 순수 passthrough — 재미분 / 필터 없음
  * MCU 원시 scale 변환만 (fLoad × 0.01 = kg 등)
   ↓
roomfit_exercise.MotionSample → SideSample (side split)
   ↓
StandardVbtMetricsCalculator   ← 여기서 최종 VBT 지표 산출
   ↓
CompletedRep → DB reps row
```

핵심: mapper 레이어는 스케일 변환만 하고 **어떤 지표도 계산하지 않는다**.
모든 VBT 계산은 `StandardVbtMetricsCalculator` 한 곳에 모여 있어서 감사/디버깅이
쉽다.

## 지표별 출처 매트릭스

packages/roomfit_exercise/lib/src/metrics/calculators/standard_vbt_calculator.dart 기준.

| 지표 | 데이터 소스 | 연산 | 라인 |
|---|---|---|---|
| ROM (concentric/eccentric/total) | MCU position | max − min | 171–197 |
| **Mean concentric velocity (MCV)** | **앱 재계산** | \|Δposition\| / Δtime (T-Force) | 202–209 |
| **Peak concentric velocity (PCV)** | MCU speed 직접 | max(abs(speedMmps)) | 212–220 |
| Mean eccentric velocity (MEV) | 앱 재계산 | 동일 displacement 방식 | 202–209 |
| Peak eccentric velocity | MCU speed 직접 | abs(min(speedMmps)) | 96 |
| **MPV (Mean Propulsive Velocity)** | **MCU accel + position** | accel > −g 인 구간만 골라 displacement 평균 | 256–273 |
| Mean/peak concentric force | MCU fLoad 직접 | sum/max fLoadKg | 222–231 |
| Mean/peak eccentric force | MCU fLoad 직접 | 동일 | 222–231 |
| Instantaneous power | MCU speed × MCU fLoad | fLoadKg × 9.81 × (speedMmps / 1000) | side_sample.dart:70 |
| Mean/peak concentric power | 위 instantaneous 집계 | mean / max | 233–243 |
| Work (J) | MCU fLoad × Δposition | Σ fLoadKg·g · \|Δs\| (left-Riemann) | 278–287 |
| Impulse (N·s) | MCU fLoad × Δt | Σ fLoadKg·g · Δt | 290–299 |
| RFD initial 100ms | MCU fLoad 차분 | (F@100ms − F_start) / 0.1s | 303–319 |
| TUT (load-weighted) | MCU fLoad × Δt | Σ fLoadKg · Δt (kg·ms) | 338–354 |
| icmd/ifb RMS error | MCU icmd, ifb | √(mean((icmd − ifb)²)) | 322–336 |

## 왜 MCV만 재계산하는가 (상세)

MCU speed field 는 50ms tick 에서의 instantaneous 값. 샘플 주기가 **정확히
50.0ms**가 아니라 49.7 / 50.3 / 51.1 ms 처럼 지터가 있다면:

- 단순 평균 `mean(speedMmps)` 은 실제 평균 속도와 **누적 오차** 발생
- 거리/시간 비율 `|Δposition| / Δtime` 은 지터에 자동 면역 (두 끝점만 보니까)

후자가 T-Force (VBT 학계 표준) 방식.

인용 — `standard_vbt_calculator.dart:77–81`:
> Mean velocity uses displacement / duration (T-Force gold standard): total
> cable travel divided by total phase time. This is more robust than
> averaging per-sample instantaneous speeds because it's immune to uneven
> sample intervals. Peak velocity still uses the per-sample maximum.

## Weight mode별 예외 처리

| WeightMode | MCV/PCV/Power 노출 여부 | 이유 |
|---|---|---|
| normal (0) | ✅ 전부 | 사용자 effort 가 직접 반영 |
| eccentric (1) | ✅ 전부 | concentric 은 정상, eccentric force 만 추가됨 |
| elastic (2) | ✅ 전부 | position 기반 point force — 속도는 여전히 유저 의도 |
| **isokinetic (3)** | ❌ velocity/power NULL | PI 컨트롤러가 속도를 Vtarget에 클램프 |
| **hydraulic (4)** | ❌ velocity/power NULL | 저항 자체가 `F=base×(v/Vref)^n` — 속도가 controller에 의해 왜곡 |
| **vibration (5)** | ❌ velocity/power NULL | 진동 성분 때문에 mean velocity 노이즈 |

`WeightMode.velocityMeaningful` 플래그로 한 줄에 판정
(`standard_vbt_calculator.dart:82, 108, 117`). Force / Work / Impulse / RFD 는
**모든 모드에서 유효** — fLoad 는 항상 실제 저항이니까.

## 변경 가이드

### 지표를 추가할 때

1. `packages/roomfit_exercise/lib/src/metrics/` 에만 로직 추가.
   Mapper / MotionSample / SideSample 은 건드리지 않는다.
2. MCU 원시값으로 커버 가능한지 먼저 확인. 재계산이 필요한 이유가 있다면
   해당 지표 옆에 `//` 주석으로 명시.
3. 새 지표가 `WeightMode.velocityMeaningful == false` 에서도 의미 있는지
   판정 — 불확실하면 null 반환.
4. `StandardVbtMetricsCalculator` 가 아닌 Roomfit 고유 확장(예: icmd 기반)은
   `RoomfitExtendedMetricsCalculator` 에 추가.

### MCU 펌웨어가 새 파생값을 보내면

1. `packages/roomfit_protocol/lib/src/responses/report_response.dart` 에
   필드 추가 → 36B payload 레이아웃 문서도 함께 업데이트
   (`CLAUDE.md` → "ReportResponse 포맷" 섹션).
2. MotionState / MotionSample / SideSample 에 필드 passthrough.
3. Calculator에서 **재계산하지 말고 MCU 값을 사용**하는 걸 기본값으로.
   재계산해야 할 이유는 주석으로 남긴다.

### MCU 값을 불신하고 재계산하기로 한다면

주석에 이유 명시가 필수:

```dart
/// MCV: displacement / duration (T-Force method). MCU speed field is
/// unreliable here because our 50ms tick jitters by ±1ms under BLE
/// contention, and averaging biased instantaneous samples accumulates
/// error across a 3-second rep.
double? _displacementMeanVelocity(List<SideSample> samples) { ... }
```

주석 없이 재미분이 추가되면 PR review 에서 blocker — "왜 MCU 값을
안 믿는가?"에 답할 수 없기 때문.

## 검증 지표

MCU 계산과 앱 재계산이 일치하는지 주기적으로 샘플링:

```dart
// tests/vbt_drift_test.dart (suggested)
test('MCU mean speed ≈ displacement-based MCV on clean signal', () {
  final samples = generateSyntheticConcentric(
    peakV: 800, // mm/s
    durationMs: 1200,
    jitter: Duration.zero, // perfect 50ms intervals
  );
  final mcuMean = samples.map((s) => s.speedMmps).average;
  final displacementMean = _displacementMeanVelocity(samples);
  expect((mcuMean - displacementMean!).abs(), lessThan(5.0));
});
```

드리프트가 5 mm/s 초과면 MCU 필터 상수가 바뀐 것 — 회귀 감지.

## 관련 문서

- `docs/reference/rep-detection-research.md` — VBT 지표 정의 + 업계 표준
  (T-Force, GymAware, Tendo, Enode)
- `docs/reference/mcu-protocol.md` — 0x41 report 바이트 레이아웃
- `CLAUDE.md` → "물리 모델" 섹션 — position/speed/accel/icmd 의 물리 의미
- `packages/roomfit_protocol/lib/src/responses/report_response.dart` — 36B
  payload parser (dev 포맷, 유일한 정식 수신 응답)
