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
import com.medithings.vesiscan.ble.ImuSample
import android.content.Context
import android.os.Environment
import com.medithings.vesiscan.models.BladderFill
@@ -138,6 +139,83 @@ object HospitalRunStore {
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 추가
* cycle_idx : 그 위치 안에서의 순번
* 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 를 나눠 담는 이유
* 전수 탐색(0~4cm)이 끝나면 고른 위치를 **한 번 더 잰다**. 그 확인 측정은
* `align_{n}cm_confirm.csv` 로 따로 쓰인다 — 같은 이름에 쓰면 판정에 쓰인 데이터가
@@ -111,6 +118,10 @@ object AlignLabdbPayload {
val cm = m.groupValues[1].toInt()
// 같은 align_cm 으로 두 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다.
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).
val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>()
runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line ->
@@ -141,6 +152,9 @@ object AlignLabdbPayload {
put("align_cm", cm)
put("phase", phase)
put("cycle_idx", ci)
put("sensor", JSONObject().apply {
imuByCycle[ci]?.let { put("imu", it) }
})
put("commandType", "MTB")
put("channels", chans)
})
@@ -206,4 +220,36 @@ object AlignLabdbPayload {
.take(8)
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
import com.medithings.vesiscan.services.HospitalRunStore
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -36,8 +37,16 @@ import java.util.TimeZone
* 비교가 불가능해진다.
*
* ## 없는 값을 지어내지 않는다
* 병원 CSV 에는 IMU·배터리·온도 열이 없다. `sensor` 는 빈 객체로 두어 labdb 표준
* 자리는 유지하되 0 이나 null 로 채우지 않는다 — 나중에 "IMU 가 0 이었다"로 읽힌다.
* 배터리·온도는 병원 CSV 에 열이 없다. `sensor` 에 넣지 않는다 — 0 이나 null 로 채우면
* 나중에 "배터리가 0 이었다"로 읽힌다.
*
* ## IMU (2026-09-21 추가)
* `*_imu.csv` 형제 파일이 있으면 `sensor.imu` 로 싣는다. 없으면 `sensor` 는 종전대로
* 빈 객체다 — **이 필드의 부재가 곧 "그때는 안 쟀다"** 라서, 없는 회차를 0 으로 채우면
* 안 된다. 예전 업로드분에는 애초에 파일이 없다.
*
* 같은 자세로 20회 반복하는 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지
* 알고리즘 문제인지 가르는 근거가 IMU 다.
*/
object HospitalLabdbPayload {
@@ -104,6 +113,8 @@ object HospitalLabdbPayload {
val h = head ?: return null
if (byRepeat.isEmpty()) return null
val imuByRepeat = readImu(HospitalRunStore.imuSibling(csv))
val records = JSONArray()
for (rep in byRepeat.keys.sorted()) {
val chans = JSONArray()
@@ -122,7 +133,10 @@ object HospitalLabdbPayload {
put("rowIndex", rep)
put("datetime", iso(times[rep]))
put("commandType", "MTB")
put("sensor", JSONObject()) // 병원 CSV 에 IMU·배터리·온도 열이 없다
// IMU 가 있으면 `sensor.imu`, 없으면 빈 객체(= 안 쟀다).
put("sensor", JSONObject().apply {
imuByRepeat[rep]?.let { put("imu", it) }
})
put("channels", chans)
})
}
@@ -188,4 +202,36 @@ object HospitalLabdbPayload {
/** 값에 콤마가 없는 단순 CSV — 이 파일이 만드는 형식이 그렇다. */
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
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 {
// 정렬 파라미터를 프로브에 넣고 **echo 로 확인한 뒤** 잰다. 설정이 거부돼도
// 프로브는 옛 설정으로 계속 측정하므로, 확인하지 않으면 다른 조건의 신호로
@@ -314,12 +318,15 @@ fun AnchorAlignView(appState: AppState) {
val cycles = ArrayList<List<DoubleArray>>(AnchorConfig.ALIGN_CYCLES)
for (i in 1..AnchorConfig.ALIGN_CYCLES) {
while (inbox.tryReceive().isSuccess) { /* 늦게 도착한 직전 결과 비우기 */ }
imuSide.drain()
bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) {
error = "측정 시간 초과 (${i}/${AnchorConfig.ALIGN_CYCLES}) — 다시 측정하세요."
return@LaunchedEffect
}
// IMU 는 piezo 뒤에 온다. 없으면 빈 목록 — 측정을 실패로 돌리지 않는다.
cyclesImu.add(imuSide.await() ?: emptyList())
val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터
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)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
// IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 —
// 파형이 남았으면 측정은 성립한다.
HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, confirm = isConfirm)
// 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와
// 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다.
@@ -391,6 +401,7 @@ fun AnchorAlignView(appState: AppState) {
}
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prev
imuSide.restore()
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
guard.restore()
}
@@ -221,6 +221,11 @@ fun HospitalModeView(appState: AppState) {
kotlinx.coroutines.channels.Channel.CONFLATED)
val prevOnComplete = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
// mtb 응답의 IMU 도 함께 받는다 (2026-09-21). 같은 자세로 20회 반복하는
// 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지 알고리즘 문제인지
// 가를 근거가 필요하다.
val imuSide = ImuSidecar(bleManager)
imuSide.install()
try {
// 복부 두께가 정한 조합. 순서는 [AbdomenThickness] 가 박아 두었다 — 매번
@@ -274,6 +279,7 @@ fun HospitalModeView(appState: AppState) {
// 직전 회차가 시간초과된 뒤 뒤늦게 도착한 결과가 남아 있을 수 있다.
// 그대로 두면 **이번 회차의 데이터로 잘못 기록된다** — 보내기 전에 비운다.
while (inbox.tryReceive().isSuccess) { /* drain */ }
imuSide.drain()
bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) {
@@ -291,14 +297,21 @@ fun HospitalModeView(appState: AppState) {
ClinicalBv.toSignals(raw),
supine = posture == ClinicalPosture.SUPINE,
)
// scan_id 를 먼저 발급해 파형과 IMU 가 **같은 값**을 쓰게 한다.
// 각자 발급하면 두 파일을 이을 키가 어긋난다.
val sid = AdcCsvLogger.nextScanId()
val saved = HospitalRunStore.appendMeasurement(
file = file,
scanId = AdcCsvLogger.nextScanId(),
scanId = sid,
patient = patient, posture = posture, fill = fill,
freq = freq, cycle = cycle, repeatIdx = r,
deviceName = deviceName ?: "", firmwareVersion = fw,
channels = raw,
)
// IMU 는 piezo 뒤에 온다. 없어도 측정은 성립하므로 실패로 세지 않는다.
imuSide.await()?.let { imu ->
HospitalRunStore.appendImuSamples(file, sid, r, imu)
}
if (saved) { ok++; okCount++ } else { fail++; failCount++ }
lastFile = file.name
}
@@ -329,6 +342,7 @@ fun HospitalModeView(appState: AppState) {
}
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete
imuSide.restore()
inbox.close()
// 중지·오류로 빠져나온 경우에도 되돌린다 — 오히려 그때가 더 필요하다.
// 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) })
}
/** `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 로 구분된다`() {
// 2026-09-18: 예전 정규식이 `align_{n}cm.csv` 만 잡아 확인 측정이 통째로
// 빠져 있었다. 고른 자리에서 실제로 어떻게 나왔는지가 빠지면 "왜 이 위치인가"에