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:
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user