feat(hospital): 병원 임상·정렬에 IMU 를 남긴다 — 600/601 sensor.imu

병원 경로는 `mtb?` 를 보내면서 **piezo 콜백만** 걸고 있었다. 응답에 딸려 오는
`rim:`(IMU)은 받는 곳이 없어 그대로 버려졌고, 저장된 측정에 IMU 가 하나도 없었다.
labdb 600/601 의 `sensor` 가 "항상 빈 객체"였던 이유다.

## 왜 필요한가
자세는 조작자가 고르는 실험 조건이라 판정에는 안 쓴다. 필요한 것은 다른 것이다 —
같은 조건 20회 반복에서 **어떤 회차만 값이 튈 때**, 알고리즘 문제인지 환자가 그 순간
움직인 것인지 가를 근거가 없었다. 정렬도 사람이 프로브를 옮겨 가며 재는 과정이라,
특정 위치의 파형이 이상할 때 자리 탓인지 흔들림 탓인지 알 수 없었다.

## 수집 — ImuSidecar
세 루프가 같은 패턴이라 헬퍼로 뺐다. 저장이 있는 두 곳에만 붙인다:
  · HospitalModeView  (600 · 20회 반복)
  · AnchorAlignView   (601 · 0~4cm 탐색 + 확인)
좌우 정렬 루프는 화면 안내용 스트리밍이라 저장이 없어 건드리지 않았다.

응답 순서가 `reb×6 → raa → rim` 이라 IMU 가 나중에 온다. piezo 를 받은 뒤 0.7초
기다리고, 안 오면 비운다. **IMU 가 없다고 측정을 실패로 돌리지 않는다** — 펌웨어·설정에
따라 `rim:` 이 없을 수 있고, 그때 실패로 만들면 기존에 되던 일이 안 되게 된다.

## 저장 — 형제 CSV
파형 행(meta + s0..s99)을 넓히지 않았다. 그 헤더는 이미 올라간 데이터와 파서가 함께
쓰는 규약이고, IMU 는 채널당이 아니라 **회차당** 값이라 같은 행에 넣으면 6 채널 행에
같은 IMU 를 여섯 번 복사하게 된다.

  600  <측정파일>_imu.csv              scan_id 로 파형과 잇는다
  601  align_{n}cm_imu.csv
       align_{n}cm_confirm_imu.csv     파형과 같은 confirm 분리 규칙

601 에서 confirm 을 따로 두는 이유는 파형과 같다 — 확인 측정의 IMU 가 판정에 쓰인
탐색 측정 것을 덮으면 안 된다.

## 업로드 — sensor.imu (600·601 같은 모양)
  "sensor": { "imu": [ {ax,ay,az,gx,gy,gz}, … ] }
  ax/ay/az = g · gx/gy/gz = dps

⚠ **없으면 빈 객체다. 0 으로 채우면 안 된다.** 부재가 곧 "그때는 안 쟀다" 이고,
0 으로 채우면 "무중력·완전 정지"로 정반대로 읽힌다. 이전 업로드분 전부가 여기 해당한다.

## 문서
LABDB_DATATYPES.md 의 "sensor 는 항상 빈 객체" 기술을 고치고 IMU 절을 새로 썼다.
labdb 쪽에 필요한 일(저장·뷰어 표시·없음/0 구분·마이그레이션 불필요)과 파생값
(accel_mag·gyro_mag) 계산식을 함께 적었다. 프로토콜 이름과 기존 필드는 그대로라
추가 키뿐이며 기존 파서를 깨지 않는다.

테스트 3건 추가(IMU 유/무 · cycle 분리 · confirm 이 sweep 을 덮지 않음).
601 15건 · 600 11건 전부 통과.

⚠ 실기기 미검증 — 병원 설정(2.3MHz·c3)에서도 `rim:` 이 오는지는 프로브로 확인해야 한다.
  파싱 자체는 dev 임상에서 쓰던 같은 수집기다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 17:08:26 +09:00
parent 3708ce5f95
commit 08f5a182df
8 changed files with 396 additions and 7 deletions
+90 -3
View File
@@ -125,13 +125,14 @@
| `rowIndex` | int | 반복 번호(0..N). 원본 CSV 의 `repeat_idx` 그대로 |
| `datetime` | ISO8601 | 그 반복의 캡처 시각 |
| `commandType` | string | `"MTB"` 고정 |
| `sensor` | object | **항상 빈 객체.** 병원 CSV 에 IMU·배터리·온도 열이 없습니다. 0 으로 채우지 않습니다 |
| `sensor` | object | IMU 가 있으면 `{imu: [...]}`, 없으면 `{}` (2026-09-21~). 배터리·온도는 계속 없습니다 |
| `channels` | array | 6개 (CH0~CH5) |
### 시각화 요구사항
`000`(VesiScan 초음파)과 **같은 파형 뷰**면 충분합니다. 채널 구조가 동일합니다.
`sensor` 가 비어 있으므로 배터리·온도·IMU 위젯은 숨겨 주시면 좋겠습니다.
배터리·온도 위젯은 계속 숨겨 주십시오 — 그 열은 여전히 없습니다.
IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(아래 §IMU).
조건 비교를 자주 하므로, 세션 목록에서 `params.posture` / `fill_pct` / `freq_mhz` /
`cycles` 를 열로 볼 수 있으면 유용합니다.
@@ -245,7 +246,8 @@
| `commandType` | string | `"MTB"` 고정 |
| `channels` | array | 6개 |
> `600` 과 달리 `datetime` · `sensor` 가 없습니다. 원본 정렬 파일에 시각 열이 없습니다.
> `600` 과 달리 `datetime` 이 없습니다 — 원본 정렬 파일에 시각 열이 없습니다.
> `sensor` 는 2026-09-21 부터 `600` 과 같은 모양으로 들어옵니다(아래 §IMU).
>
> ⚠️ 서버의 `records.timestamp` 는 NOT NULL 이라 **업로드 시각으로 채워집니다.** 전
> 레코드가 거의 같은 시각이 되므로 조회·시각화는 반드시 `rowIndex` 로 정렬해야 합니다.
@@ -271,3 +273,88 @@
> 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의
> 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다.
---
# IMU (600 · 601 공통) — 2026-09-21 추가
병원 임상 경로는 `mtb?` 를 보내면서 piezo 응답만 받고 IMU(`rim:`)를 버리고 있었습니다.
이제 받아서 저장하고 업로드합니다.
## 왜 넣나
자세는 조작자가 고르는 **실험 조건**이라 판정에는 안 씁니다. 필요한 것은 다른 것입니다 —
같은 조건 20회 반복에서 **어떤 회차만 값이 튈 때**, 그것이 알고리즘 문제인지 환자가
그 순간 움직인 것인지 가를 근거가 없었습니다. 정렬(601)도 사람이 프로브를 옮겨 가며
재는 과정이라, 특정 위치의 파형이 이상할 때 자리 탓인지 흔들림 탓인지 알 수 없었습니다.
## 페이로드 모양
`records[]` 의 각 레코드에 `sensor` 가 붙습니다. **600 · 601 이 같은 모양입니다.**
```jsonc
"sensor": {
"imu": [
{ "ax": 0.01, "ay": 0.02, "az": 0.98, "gx": 0.5, "gy": 0.6, "gz": 0.7 },
… // 한 회차(cycle/repeat)에서 받은 샘플 전부
]
}
```
| 필드 | 단위 | 뜻 |
|---|---|---|
| `ax` · `ay` · `az` | **g** (중력가속도) | 가속도 3축. 정지 시 합성크기 ≈ 1 |
| `gx` · `gy` · `gz` | **dps** (도/초) | 각속도 3축. 정지 시 ≈ 0 |
- 600 에서는 한 `repeat_idx` 가 한 레코드이고, 그 회차의 샘플이 배열로 들어갑니다.
- 601 에서는 한 `cycle_idx` 가 한 레코드입니다. `phase`(sweep/confirm)별로 따로 들어가며,
**확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.**
## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오
`"sensor": {}` 또는 `sensor.imu` 부재는 **"그때는 안 쟀다"** 는 뜻입니다.
0 으로 채우면 "IMU 가 0 이었다"(= 무중력·완전 정지)로 읽혀 정반대 해석이 됩니다.
빈 경우가 실제로 생깁니다:
- **2026-09-21 이전 업로드분 전부** — 그때는 수집 자체를 안 했습니다
- 펌웨어·설정에 따라 `rim:` 이 안 오는 회차
- IMU 응답이 늦어 회차 안에 못 들어온 경우 (앱이 측정을 실패로 돌리지 않고 그냥 비웁니다)
## labdb 쪽에 필요한 일
| # | 작업 | 비고 |
|---|---|---|
| 1 | `sensor.imu` 저장 | 600 · 601 모두. 스키마가 추가 키를 허용하므로 **서버 변경 없이도 보관은 됩니다** |
| 2 | 뷰어에서 IMU 표시 | 있을 때만. 없으면 위젯을 숨기는 기존 동작 유지 |
| 3 | 없음/0 구분 | 위 경고 참조. `sensor.imu` 가 없으면 **"미측정"** 으로 표시 |
| 4 | 마이그레이션 | **불필요.** 기존 레코드는 그대로 두면 됩니다(= 미측정이 사실입니다) |
보기에 쓸 만한 파생값 (서버에서 계산해도 되고 뷰어에서 해도 됩니다):
- `accel_mag = sqrt(ax²+ay²+az²)` — 정지 시 ≈ 1g. 1 에서 멀어지면 움직인 것
- `gyro_mag = sqrt(gx²+gy²+gz²)` — 정지 시 ≈ 0 dps. 회차 내 최대값이 그 회차의 흔들림
- 회차별 `gyro_mag` 최대치를 파형 옆에 띄우면 "튄 회차 = 흔들린 회차"가 한눈에 보입니다
## 앱이 저장하는 원본 파일 (참고)
업로드 전 폰에 남는 형제 CSV 입니다. 페이로드는 이걸 읽어 만듭니다.
```
600 <측정파일>_imu.csv
scan_id,timestamp,repeat_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps
→ scan_id 로 파형 CSV 와 잇습니다
601 align_{n}cm_imu.csv · align_{n}cm_confirm_imu.csv
cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps
→ 파형과 같은 파일명 규칙(confirm 분리)을 그대로 따릅니다
```
파형 행(`meta + s0..s99`)을 넓히지 않은 이유: 그 헤더는 이미 올라간 데이터와 파서가
함께 쓰는 규약이고, IMU 는 채널당이 아니라 **회차당** 값이라 같은 행에 넣으면 6 채널 행에
같은 IMU 를 여섯 번 복사하게 됩니다.
## 호환성
- `hospital_align_2026` · 600 프로토콜 이름은 **그대로**입니다
- 추가 키뿐이라 기존 파서가 깨지지 않습니다
- 기존 데이터는 의미가 바뀌지 않습니다 (없던 필드가 없는 채로 남습니다)