From 08f5a182df3a723f28450c164b03bca2f8cb9e6c Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 21 Sep 2026 17:08:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(hospital):=20=EB=B3=91=EC=9B=90=20?= =?UTF-8?q?=EC=9E=84=EC=83=81=C2=B7=EC=A0=95=EB=A0=AC=EC=97=90=20IMU=20?= =?UTF-8?q?=EB=A5=BC=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20=E2=80=94=20600/601?= =?UTF-8?q?=20sensor.imu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병원 경로는 `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 --- .../vesiscan/services/HospitalRunStore.kt | 78 ++++++++++++++++ .../services/labdb/AlignLabdbPayload.kt | 46 +++++++++ .../services/labdb/HospitalLabdbPayload.kt | 52 ++++++++++- .../ui/views/clinical/AnchorAlignView.kt | 11 +++ .../ui/views/clinical/HospitalModeView.kt | 16 +++- .../vesiscan/ui/views/clinical/ImuSidecar.kt | 62 +++++++++++++ .../services/labdb/AlignLabdbPayloadTest.kt | 45 +++++++++ docs/LABDB_DATATYPES.md | 93 ++++++++++++++++++- 8 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index 3257b3a..6049fe5 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -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, + ): 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>, 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 + } + /** * 실행 요약. 파일명에 다 적혀 있지만, **무엇을 재려 했는지**(계획)와 **실제로 몇 개가 * 남았는지**(결과)가 다를 수 있어 따로 남긴다. 중간에 연결이 끊기면 그 차이가 곧 diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt index 3786556..d4d3044 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -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>() 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 { + 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() + 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 + } + } diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt index c60b058..404a84a 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt @@ -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 = 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 { + 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() + 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 + } + } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 50a4428..b84fb26 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -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>() try { // 정렬 파라미터를 프로브에 넣고 **echo 로 확인한 뒤** 잰다. 설정이 거부돼도 // 프로브는 옛 설정으로 계속 측정하므로, 확인하지 않으면 다른 조건의 신호로 @@ -314,12 +318,15 @@ fun AnchorAlignView(appState: AppState) { val cycles = ArrayList>(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() } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index d5d07a1..134611c 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -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` 로 코루틴이 diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt new file mode 100644 index 0000000..a7484a8 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt @@ -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>(Channel.CONFLATED) + private var previous: ((List) -> 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? = + withTimeoutOrNull(graceMs) { inbox.receive() } + + fun restore() { + if (!installed) return + ble.imuCollector.onComplete = previous + previous = null + installed = false + inbox.close() + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt index 30c8049..fcfd505 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt @@ -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` 만 잡아 확인 측정이 통째로 // 빠져 있었다. 고른 자리에서 실제로 어떻게 나왔는지가 빠지면 "왜 이 위치인가"에 diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 795808d..5b8126d 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -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 프로토콜 이름은 **그대로**입니다 +- 추가 키뿐이라 기존 파서가 깨지지 않습니다 +- 기존 데이터는 의미가 바뀌지 않습니다 (없던 필드가 없는 채로 남습니다)