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
@@ -22,6 +22,7 @@
*/ */
package com.medithings.vesiscan.services package com.medithings.vesiscan.services
import com.medithings.vesiscan.ble.ImuSample
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment
import com.medithings.vesiscan.models.BladderFill import com.medithings.vesiscan.models.BladderFill
@@ -138,6 +139,83 @@ object HospitalRunStore {
false false
} }
/**
* 병원 측정(600)의 IMU — 파형 CSV 옆에 **형제 파일**로 쓴다.
*
* 기존 행 형식(meta + s0..s99)을 넓히지 않는 이유: 그 헤더는 이미 올라간 데이터와
* 파서가 함께 쓰는 규약이고, IMU 는 **채널당이 아니라 회차당** 값이라 같은 행에
* 넣을 자리가 없다(6 채널 행에 같은 IMU 를 여섯 번 복사하게 된다).
*
* `scan_id` 로 파형과 잇는다 — dev 임상의 `adc.csv`/`imu.csv` 와 같은 규약이다.
*
* @param measurementFile [appendMeasurement] 가 쓴 파형 파일. 옆에 `*_imu.csv` 를 만든다.
*/
fun appendImuSamples(
measurementFile: File,
scanId: Int,
repeatIdx: Int,
samples: List<ImuSample>,
): Boolean = try {
if (samples.isEmpty()) false else {
val file = imuSibling(measurementFile)
val isNew = !file.exists() || file.length() == 0L
java.io.FileWriter(file, !isNew).buffered().use { w ->
if (isNew) {
w.write("scan_id,timestamp,repeat_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
w.newLine()
}
val stamp = stampFmt.format(Date())
for ((idx, sm) in samples.withIndex()) {
w.write(
listOf(
scanId, stamp, repeatIdx, idx,
sm.ax, sm.ay, sm.az, sm.gx, sm.gy, sm.gz,
).joinToString(",")
)
w.newLine()
}
}
true
}
} catch (e: Exception) {
android.util.Log.w("HospitalRunStore", "IMU 저장 실패 scan=$scanId: ${e.message}")
false
}
/** `..._2p3MHz_c3.csv` → `..._2p3MHz_c3_imu.csv`. */
fun imuSibling(measurementFile: File): File =
File(measurementFile.parentFile, measurementFile.name.removeSuffix(".csv") + "_imu.csv")
/**
* 정렬(601)의 IMU — 위치별 파형 CSV 옆에 형제 파일로 쓴다.
*
* 파형은 `cycleIdx,ch,s0..` 인데 IMU 는 채널 축이 없어 같은 파일에 섞을 수 없다.
* `confirm` 여부는 파형과 **같은 규칙**으로 파일명에 담는다 — 확인 측정의 IMU 가
* 탐색 측정 것을 덮으면 [writeAlignCycles] 에서 막아 둔 문제가 그대로 재현된다.
*/
fun writeAlignImu(
dir: File, cm: Int, cyclesImu: List<List<ImuSample>>, confirm: Boolean = false,
): Boolean = try {
if (cyclesImu.all { it.isEmpty() }) false else {
val name = if (confirm) "align_%dcm_confirm_imu.csv".format(cm)
else "align_%dcm_imu.csv".format(cm)
File(dir, name).bufferedWriter().use { w ->
w.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
w.newLine()
cyclesImu.forEachIndexed { ci, samples ->
samples.forEachIndexed { si, sm ->
w.write(listOf(ci, si, sm.ax, sm.ay, sm.az, sm.gx, sm.gy, sm.gz).joinToString(","))
w.newLine()
}
}
}
true
}
} catch (e: Exception) {
android.util.Log.w("HospitalRunStore", "align IMU 저장 실패 cm=$cm: ${e.message}")
false
}
/** /**
* 실행 요약. 파일명에 다 적혀 있지만, **무엇을 재려 했는지**(계획)와 **실제로 몇 개가 * 실행 요약. 파일명에 다 적혀 있지만, **무엇을 재려 했는지**(계획)와 **실제로 몇 개가
* 남았는지**(결과)가 다를 수 있어 따로 남긴다. 중간에 연결이 끊기면 그 차이가 곧 * 남았는지**(결과)가 다를 수 있어 따로 남긴다. 중간에 연결이 끊기면 그 차이가 곧
@@ -42,8 +42,15 @@ import java.util.Locale
* phase : "sweep" | "confirm" ← 2026-09-18 추가 * phase : "sweep" | "confirm" ← 2026-09-18 추가
* cycle_idx : 그 위치 안에서의 순번 * cycle_idx : 그 위치 안에서의 순번
* channels : 6 × {ch, peak, peakIdx, data[~100]} * channels : 6 × {ch, peak, peakIdx, data[~100]}
* sensor : {} 또는 {imu: [{ax,ay,az,gx,gy,gz}, …]} ← 2026-09-21 추가
* ``` * ```
* *
* ## IMU
* `align_{n}cm[_confirm]_imu.csv` 가 있으면 cycle 별로 `sensor.imu` 에 싣는다. 없으면
* `sensor` 는 빈 객체다 — **부재가 곧 "그때는 안 쟀다"** 이므로 0 으로 채우지 않는다.
* 정렬은 사람이 프로브를 옮겨 가며 재는 과정이라, 어느 위치의 파형이 이상할 때 그것이
* 자리 탓인지 그 순간 흔들린 탓인지 IMU 없이는 가를 수 없다.
*
* ## phase 를 나눠 담는 이유 * ## phase 를 나눠 담는 이유
* 전수 탐색(0~4cm)이 끝나면 고른 위치를 **한 번 더 잰다**. 그 확인 측정은 * 전수 탐색(0~4cm)이 끝나면 고른 위치를 **한 번 더 잰다**. 그 확인 측정은
* `align_{n}cm_confirm.csv` 로 따로 쓰인다 — 같은 이름에 쓰면 판정에 쓰인 데이터가 * `align_{n}cm_confirm.csv` 로 따로 쓰인다 — 같은 이름에 쓰면 판정에 쓰인 데이터가
@@ -111,6 +118,10 @@ object AlignLabdbPayload {
val cm = m.groupValues[1].toInt() val cm = m.groupValues[1].toInt()
// 같은 align_cm 으로 두 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다. // 같은 align_cm 으로 두 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다.
val phase = if (m.groupValues[2].isEmpty()) "sweep" else "confirm" val phase = if (m.groupValues[2].isEmpty()) "sweep" else "confirm"
// 같은 위치·같은 phase 의 IMU 형제 파일. 없으면 빈 맵.
val imuByCycle = readAlignImu(
File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv")
)
// `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles). // `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles).
val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>() val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>()
runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line -> runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line ->
@@ -141,6 +152,9 @@ object AlignLabdbPayload {
put("align_cm", cm) put("align_cm", cm)
put("phase", phase) put("phase", phase)
put("cycle_idx", ci) put("cycle_idx", ci)
put("sensor", JSONObject().apply {
imuByCycle[ci]?.let { put("imu", it) }
})
put("commandType", "MTB") put("commandType", "MTB")
put("channels", chans) put("channels", chans)
}) })
@@ -206,4 +220,36 @@ object AlignLabdbPayload {
.take(8) .take(8)
return safe.take(31) + "_" + sha return safe.take(31) + "_" + sha
} }
/**
* `align_{n}cm[_confirm]_imu.csv` → cycle_idx 별 샘플 배열.
*
* 헤더: `cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps`
* ([HospitalRunStore.writeAlignImu]). 파일이 없으면 빈 맵 — IMU 가 없다고
* 업로드를 막지 않는다. 기존 세션에는 애초에 이 파일이 없다.
*/
private fun readAlignImu(file: File): Map<Int, JSONArray> {
if (!file.exists()) return emptyMap()
val lines = runCatching { file.readLines(Charsets.UTF_8) }.getOrNull() ?: return emptyMap()
if (lines.size < 2) return emptyMap()
val head = lines[0].split(',').map { it.trim() }
fun at(name: String) = head.indexOf(name).takeIf { it >= 0 }
val iCycle = at("cycle_idx") ?: return emptyMap()
val cols = listOf("ax_g", "ay_g", "az_g", "gx_dps", "gy_dps", "gz_dps").map { at(it) }
if (cols.any { it == null }) return emptyMap()
val out = linkedMapOf<Int, JSONArray>()
for (i in 1 until lines.size) {
if (lines[i].isBlank()) continue
val r = lines[i].split(',')
val ci = r.getOrNull(iCycle)?.trim()?.toIntOrNull() ?: continue
val vals = cols.map { r.getOrNull(it!!)?.trim()?.toFloatOrNull() }
if (vals.any { it == null }) continue
out.getOrPut(ci) { JSONArray() }.put(JSONObject().apply {
put("ax", vals[0]); put("ay", vals[1]); put("az", vals[2])
put("gx", vals[3]); put("gy", vals[4]); put("gz", vals[5])
})
}
return out
}
} }
@@ -3,6 +3,7 @@
*/ */
package com.medithings.vesiscan.services.labdb package com.medithings.vesiscan.services.labdb
import com.medithings.vesiscan.services.HospitalRunStore
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
@@ -36,8 +37,16 @@ import java.util.TimeZone
* 비교가 불가능해진다. * 비교가 불가능해진다.
* *
* ## 없는 값을 지어내지 않는다 * ## 없는 값을 지어내지 않는다
* 병원 CSV 에는 IMU·배터리·온도 열이 없다. `sensor` 는 빈 객체로 두어 labdb 표준 * 배터리·온도는 병원 CSV 에 열이 없다. `sensor` 에 넣지 않는다 — 0 이나 null 로 채우면
* 자리는 유지하되 0 이나 null 로 채우지 않는다 — 나중에 "IMU 가 0 이었다"로 읽힌다. * 나중에 "배터리가 0 이었다"로 읽힌다.
*
* ## IMU (2026-09-21 추가)
* `*_imu.csv` 형제 파일이 있으면 `sensor.imu` 로 싣는다. 없으면 `sensor` 는 종전대로
* 빈 객체다 — **이 필드의 부재가 곧 "그때는 안 쟀다"** 라서, 없는 회차를 0 으로 채우면
* 안 된다. 예전 업로드분에는 애초에 파일이 없다.
*
* 같은 자세로 20회 반복하는 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지
* 알고리즘 문제인지 가르는 근거가 IMU 다.
*/ */
object HospitalLabdbPayload { object HospitalLabdbPayload {
@@ -104,6 +113,8 @@ object HospitalLabdbPayload {
val h = head ?: return null val h = head ?: return null
if (byRepeat.isEmpty()) return null if (byRepeat.isEmpty()) return null
val imuByRepeat = readImu(HospitalRunStore.imuSibling(csv))
val records = JSONArray() val records = JSONArray()
for (rep in byRepeat.keys.sorted()) { for (rep in byRepeat.keys.sorted()) {
val chans = JSONArray() val chans = JSONArray()
@@ -122,7 +133,10 @@ object HospitalLabdbPayload {
put("rowIndex", rep) put("rowIndex", rep)
put("datetime", iso(times[rep])) put("datetime", iso(times[rep]))
put("commandType", "MTB") put("commandType", "MTB")
put("sensor", JSONObject()) // 병원 CSV 에 IMU·배터리·온도 열이 없다 // IMU 가 있으면 `sensor.imu`, 없으면 빈 객체(= 안 쟀다).
put("sensor", JSONObject().apply {
imuByRepeat[rep]?.let { put("imu", it) }
})
put("channels", chans) put("channels", chans)
}) })
} }
@@ -188,4 +202,36 @@ object HospitalLabdbPayload {
/** 값에 콤마가 없는 단순 CSV — 이 파일이 만드는 형식이 그렇다. */ /** 값에 콤마가 없는 단순 CSV — 이 파일이 만드는 형식이 그렇다. */
private fun splitCsv(line: String): List<String> = line.split(',') private fun splitCsv(line: String): List<String> = line.split(',')
/**
* `*_imu.csv` → repeat_idx 별 샘플 배열.
*
* 헤더: `scan_id,timestamp,repeat_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps`
* ([HospitalRunStore.appendImuSamples]). 파일이 없거나 읽히지 않으면 빈 맵 —
* IMU 가 없다고 업로드 자체를 막지 않는다.
*/
private fun readImu(file: File): Map<Int, JSONArray> {
if (!file.exists()) return emptyMap()
val lines = runCatching { file.readLines(Charsets.UTF_8) }.getOrNull() ?: return emptyMap()
if (lines.size < 2) return emptyMap()
val head = splitCsv(lines[0]).map { it.trim() }
fun at(name: String) = head.indexOf(name).takeIf { it >= 0 }
val iRep = at("repeat_idx") ?: return emptyMap()
val cols = listOf("ax_g", "ay_g", "az_g", "gx_dps", "gy_dps", "gz_dps").map { at(it) }
if (cols.any { it == null }) return emptyMap()
val out = linkedMapOf<Int, JSONArray>()
for (i in 1 until lines.size) {
if (lines[i].isBlank()) continue
val r = splitCsv(lines[i])
val rep = r.getOrNull(iRep)?.trim()?.toIntOrNull() ?: continue
val vals = cols.map { r.getOrNull(it!!)?.trim()?.toFloatOrNull() }
if (vals.any { it == null }) continue
out.getOrPut(rep) { JSONArray() }.put(JSONObject().apply {
put("ax", vals[0]); put("ay", vals[1]); put("az", vals[2])
put("gx", vals[3]); put("gy", vals[4]); put("gz", vals[5])
})
}
return out
}
} }
@@ -282,6 +282,10 @@ fun AnchorAlignView(appState: AppState) {
val prev = bleManager.piezoCollector.onMultiChannelComplete val prev = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
// mtb 응답의 IMU 부분도 받는다 (2026-09-21). 회차마다 piezo 뒤에 온다.
val imuSide = ImuSidecar(bleManager)
imuSide.install()
val cyclesImu = mutableListOf<List<com.medithings.vesiscan.ble.ImuSample>>()
try { try {
// 정렬 파라미터를 프로브에 넣고 **echo 로 확인한 뒤** 잰다. 설정이 거부돼도 // 정렬 파라미터를 프로브에 넣고 **echo 로 확인한 뒤** 잰다. 설정이 거부돼도
// 프로브는 옛 설정으로 계속 측정하므로, 확인하지 않으면 다른 조건의 신호로 // 프로브는 옛 설정으로 계속 측정하므로, 확인하지 않으면 다른 조건의 신호로
@@ -314,12 +318,15 @@ fun AnchorAlignView(appState: AppState) {
val cycles = ArrayList<List<DoubleArray>>(AnchorConfig.ALIGN_CYCLES) val cycles = ArrayList<List<DoubleArray>>(AnchorConfig.ALIGN_CYCLES)
for (i in 1..AnchorConfig.ALIGN_CYCLES) { for (i in 1..AnchorConfig.ALIGN_CYCLES) {
while (inbox.tryReceive().isSuccess) { /* 늦게 도착한 직전 결과 비우기 */ } while (inbox.tryReceive().isSuccess) { /* 늦게 도착한 직전 결과 비우기 */ }
imuSide.drain()
bleManager.sendMtb() bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() } val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) { if (got == null) {
error = "측정 시간 초과 (${i}/${AnchorConfig.ALIGN_CYCLES}) — 다시 측정하세요." error = "측정 시간 초과 (${i}/${AnchorConfig.ALIGN_CYCLES}) — 다시 측정하세요."
return@LaunchedEffect return@LaunchedEffect
} }
// IMU 는 piezo 뒤에 온다. 없으면 빈 목록 — 측정을 실패로 돌리지 않는다.
cyclesImu.add(imuSide.await() ?: emptyList())
val sorted = got.sortedBy { it.channel } val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터 liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터
detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d } detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d }
@@ -344,6 +351,9 @@ fun AnchorAlignView(appState: AppState) {
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, confirm = isConfirm)) { if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, confirm = isConfirm)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
} }
// IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 —
// 파형이 남았으면 측정은 성립한다.
HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, confirm = isConfirm)
// 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와 // 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와
// 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다. // 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다.
@@ -391,6 +401,7 @@ fun AnchorAlignView(appState: AppState) {
} }
} finally { } finally {
bleManager.piezoCollector.onMultiChannelComplete = prev bleManager.piezoCollector.onMultiChannelComplete = prev
imuSide.restore()
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
guard.restore() guard.restore()
} }
@@ -221,6 +221,11 @@ fun HospitalModeView(appState: AppState) {
kotlinx.coroutines.channels.Channel.CONFLATED) kotlinx.coroutines.channels.Channel.CONFLATED)
val prevOnComplete = bleManager.piezoCollector.onMultiChannelComplete val prevOnComplete = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
// mtb 응답의 IMU 도 함께 받는다 (2026-09-21). 같은 자세로 20회 반복하는
// 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지 알고리즘 문제인지
// 가를 근거가 필요하다.
val imuSide = ImuSidecar(bleManager)
imuSide.install()
try { try {
// 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번 // 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번
@@ -274,6 +279,7 @@ fun HospitalModeView(appState: AppState) {
// 직전 회차가 시간초과된 뒤 뒤늦게 도착한 결과가 남아 있을 수 있다. // 직전 회차가 시간초과된 뒤 뒤늦게 도착한 결과가 남아 있을 수 있다.
// 그대로 두면 **이번 회차의 데이터로 잘못 기록된다** — 보내기 전에 비운다. // 그대로 두면 **이번 회차의 데이터로 잘못 기록된다** — 보내기 전에 비운다.
while (inbox.tryReceive().isSuccess) { /* drain */ } while (inbox.tryReceive().isSuccess) { /* drain */ }
imuSide.drain()
bleManager.sendMtb() bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() } val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) { if (got == null) {
@@ -291,14 +297,21 @@ fun HospitalModeView(appState: AppState) {
ClinicalBv.toSignals(raw), ClinicalBv.toSignals(raw),
supine = posture == ClinicalPosture.SUPINE, supine = posture == ClinicalPosture.SUPINE,
) )
// scan_id 를 먼저 발급해 파형과 IMU 가 **같은 값**을 쓰게 한다.
// 각자 발급하면 두 파일을 이을 키가 어긋난다.
val sid = AdcCsvLogger.nextScanId()
val saved = HospitalRunStore.appendMeasurement( val saved = HospitalRunStore.appendMeasurement(
file = file, file = file,
scanId = AdcCsvLogger.nextScanId(), scanId = sid,
patient = patient, posture = posture, fill = fill, patient = patient, posture = posture, fill = fill,
freq = freq, cycle = cycle, repeatIdx = r, freq = freq, cycle = cycle, repeatIdx = r,
deviceName = deviceName ?: "", firmwareVersion = fw, deviceName = deviceName ?: "", firmwareVersion = fw,
channels = raw, channels = raw,
) )
// IMU 는 piezo 뒤에 온다. 없어도 측정은 성립하므로 실패로 세지 않는다.
imuSide.await()?.let { imu ->
HospitalRunStore.appendImuSamples(file, sid, r, imu)
}
if (saved) { ok++; okCount++ } else { fail++; failCount++ } if (saved) { ok++; okCount++ } else { fail++; failCount++ }
lastFile = file.name lastFile = file.name
} }
@@ -329,6 +342,7 @@ fun HospitalModeView(appState: AppState) {
} }
} finally { } finally {
bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete
imuSide.restore()
inbox.close() inbox.close()
// 중지·오류로 빠져나온 경우에도 되돌린다 — 오히려 그때가 더 필요하다. // 중지·오류로 빠져나온 경우에도 되돌린다 — 오히려 그때가 더 필요하다.
// withContext(NonCancellable) 로 감싸는 이유: `running=false` 로 코루틴이 // withContext(NonCancellable) 로 감싸는 이유: `running=false` 로 코루틴이
@@ -0,0 +1,62 @@
/*
* ImuSidecar — 병원 경로에서 mtb 응답의 IMU 부분을 함께 받아 두는 보조 수집기.
*
* ## 왜 필요한가 (2026-09-21)
* 병원 임상·정렬 화면은 `mtb?` 를 보내면서 **piezo 콜백만** 걸고 있었다. `mtb` 응답에는
* `rim:`(IMU)이 딸려 오는데 받는 곳이 없어 그대로 버려졌고, 저장된 측정에 IMU 가
* 하나도 남지 않았다(labdb 600/601 의 `sensor` 가 항상 빈 객체였던 이유).
*
* 자세는 조작자가 고르는 실험 조건이라 **판정에는 필요 없지만**, 그 자세가 실제로
* 유지됐는지 · 측정 중 환자가 움직였는지는 파형을 해석할 때 필요한 근거다. 같은 조건
* 20회 반복에서 어떤 회차만 값이 튀면, 그게 알고리즘 문제인지 환자가 움직인 것인지
* IMU 없이는 가를 수 없다.
*
* ## 짝짓기 규약
* 응답 순서는 `reb:×6 → raa:`(piezo 완료) `→ rim:`(IMU 완료)다. 즉 **IMU 가 나중에
* 온다.** 그래서 호출부는 piezo 를 받은 뒤 [await] 로 잠깐 더 기다린다.
*
* IMU 가 안 와도 측정은 성립한다 — 펌웨어·설정에 따라 `rim:` 이 없을 수 있고, 그때
* 측정을 실패로 돌리면 기존에 되던 일이 안 되게 된다. [await] 는 null 을 돌려주고
* 호출부는 IMU 없이 저장한다.
*/
package com.medithings.vesiscan.ui.views.clinical
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ble.ImuSample
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
/** piezo 를 받은 뒤 IMU 를 기다리는 시간. 같은 응답의 뒷부분이라 길 필요가 없다. */
const val IMU_GRACE_MS = 700L
class ImuSidecar(private val ble: BleManager) {
private val inbox = Channel<List<ImuSample>>(Channel.CONFLATED)
private var previous: ((List<ImuSample>) -> Unit)? = null
private var installed = false
/** 콜백을 가로챈다. 원래 콜백은 [restore] 에서 되돌린다. */
fun install() {
if (installed) return
previous = ble.imuCollector.onComplete
ble.imuCollector.onComplete = { samples -> inbox.trySend(samples) }
installed = true
}
/** 직전 회차에서 늦게 도착한 것을 버린다. piezo 쪽 비우기와 같은 자리에서 부른다. */
fun drain() {
while (inbox.tryReceive().isSuccess) { /* 비우기 */ }
}
/** piezo 를 받은 뒤 부른다. 시간 안에 안 오면 null — 호출부는 IMU 없이 진행한다. */
suspend fun await(graceMs: Long = IMU_GRACE_MS): List<ImuSample>? =
withTimeoutOrNull(graceMs) { inbox.receive() }
fun restore() {
if (!installed) return
ble.imuCollector.onComplete = previous
previous = null
installed = false
inbox.close()
}
}
@@ -51,6 +51,51 @@ class AlignLabdbPayloadTest {
put("lateral", JSONObject().apply { put("done", true); put("imbalance", 5) }) put("lateral", JSONObject().apply { put("done", true); put("imbalance", 5) })
} }
/** `cycle_idx,sample_idx,ax_g,...` — HospitalRunStore.writeAlignImu 형식. */
private fun writeImu(dir: File, cm: Int, cycles: Int, perCycle: Int = 3, confirm: Boolean = false) {
val sb = StringBuilder("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n")
for (ci in 0 until cycles) for (si in 0 until perCycle) {
sb.append("$ci,$si,0.01,0.02,0.98,0.5,0.6,0.7\n")
}
val name = if (confirm) "align_${cm}cm_confirm_imu.csv" else "align_${cm}cm_imu.csv"
File(dir, name).writeText(sb.toString(), Charsets.UTF_8)
}
@Test fun `IMU 형제 파일이 있으면 sensor imu 로 실린다`() {
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 2)
writeImu(d, 0, cycles = 2, perCycle = 3)
val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records")
val imu0 = recs.getJSONObject(0).getJSONObject("sensor").getJSONArray("imu")
assertEquals(3, imu0.length())
assertEquals(0.98, imu0.getJSONObject(0).getDouble("az"), 1e-6)
// cycle 별로 갈라져야 한다 — 한 위치의 모든 샘플이 한 회차에 몰리면 안 된다.
assertEquals(3, recs.getJSONObject(1).getJSONObject("sensor").getJSONArray("imu").length())
}
@Test fun `IMU 가 없으면 sensor 는 빈 객체다`() {
// 없는 것을 0 으로 채우면 "IMU 가 0 이었다"로 읽힌다 — 부재가 곧 정보다.
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1)
val rec = AlignLabdbPayload.build(d)!!.getJSONArray("records").getJSONObject(0)
assertEquals(0, rec.getJSONObject("sensor").length())
}
@Test fun `확인 측정의 IMU 가 탐색 측정 것을 덮지 않는다`() {
val d = tmp.newFolder("align")
writeCycles(d, 2, cycles = 1)
writeImu(d, 2, cycles = 1, perCycle = 2)
writeCycles(d, 2, cycles = 1, confirm = true)
writeImu(d, 2, cycles = 1, perCycle = 5, confirm = true)
val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records")
assertEquals("sweep", recs.getJSONObject(0).getString("phase"))
assertEquals(2, recs.getJSONObject(0).getJSONObject("sensor").getJSONArray("imu").length())
assertEquals("confirm", recs.getJSONObject(1).getString("phase"))
assertEquals(5, recs.getJSONObject(1).getJSONObject("sensor").getJSONArray("imu").length())
}
@Test fun `확인 측정도 올라가고 phase 로 구분된다`() { @Test fun `확인 측정도 올라가고 phase 로 구분된다`() {
// 2026-09-18: 예전 정규식이 `align_{n}cm.csv` 만 잡아 확인 측정이 통째로 // 2026-09-18: 예전 정규식이 `align_{n}cm.csv` 만 잡아 확인 측정이 통째로
// 빠져 있었다. 고른 자리에서 실제로 어떻게 나왔는지가 빠지면 "왜 이 위치인가"에 // 빠져 있었다. 고른 자리에서 실제로 어떻게 나왔는지가 빠지면 "왜 이 위치인가"에
+90 -3
View File
@@ -125,13 +125,14 @@
| `rowIndex` | int | 반복 번호(0..N). 원본 CSV 의 `repeat_idx` 그대로 | | `rowIndex` | int | 반복 번호(0..N). 원본 CSV 의 `repeat_idx` 그대로 |
| `datetime` | ISO8601 | 그 반복의 캡처 시각 | | `datetime` | ISO8601 | 그 반복의 캡처 시각 |
| `commandType` | string | `"MTB"` 고정 | | `commandType` | string | `"MTB"` 고정 |
| `sensor` | object | **항상 빈 객체.** 병원 CSV 에 IMU·배터리·온도 열이 없습니다. 0 으로 채우지 않습니다 | | `sensor` | object | IMU 가 있으면 `{imu: [...]}`, 없으면 `{}` (2026-09-21~). 배터리·온도는 계속 없습니다 |
| `channels` | array | 6개 (CH0~CH5) | | `channels` | array | 6개 (CH0~CH5) |
### 시각화 요구사항 ### 시각화 요구사항
`000`(VesiScan 초음파)과 **같은 파형 뷰**면 충분합니다. 채널 구조가 동일합니다. `000`(VesiScan 초음파)과 **같은 파형 뷰**면 충분합니다. 채널 구조가 동일합니다.
`sensor` 가 비어 있으므로 배터리·온도·IMU 위젯은 숨겨 주시면 좋겠습니다. 배터리·온도 위젯은 계속 숨겨 주십시오 — 그 열은 여전히 없습니다.
IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(아래 §IMU).
조건 비교를 자주 하므로, 세션 목록에서 `params.posture` / `fill_pct` / `freq_mhz` / 조건 비교를 자주 하므로, 세션 목록에서 `params.posture` / `fill_pct` / `freq_mhz` /
`cycles` 를 열로 볼 수 있으면 유용합니다. `cycles` 를 열로 볼 수 있으면 유용합니다.
@@ -245,7 +246,8 @@
| `commandType` | string | `"MTB"` 고정 | | `commandType` | string | `"MTB"` 고정 |
| `channels` | array | 6개 | | `channels` | array | 6개 |
> `600` 과 달리 `datetime` · `sensor` 가 없습니다. 원본 정렬 파일에 시각 열이 없습니다. > `600` 과 달리 `datetime` 이 없습니다 — 원본 정렬 파일에 시각 열이 없습니다.
> `sensor` 는 2026-09-21 부터 `600` 과 같은 모양으로 들어옵니다(아래 §IMU).
> >
> ⚠️ 서버의 `records.timestamp` 는 NOT NULL 이라 **업로드 시각으로 채워집니다.** 전 > ⚠️ 서버의 `records.timestamp` 는 NOT NULL 이라 **업로드 시각으로 채워집니다.** 전
> 레코드가 거의 같은 시각이 되므로 조회·시각화는 반드시 `rowIndex` 로 정렬해야 합니다. > 레코드가 거의 같은 시각이 되므로 조회·시각화는 반드시 `rowIndex` 로 정렬해야 합니다.
@@ -271,3 +273,88 @@
> 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의 > 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의
> 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다. > 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 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 프로토콜 이름은 **그대로**입니다
- 추가 키뿐이라 기존 파서가 깨지지 않습니다
- 기존 데이터는 의미가 바뀌지 않습니다 (없던 필드가 없는 채로 남습니다)