From c8964927ce0dfea53e238515cfa6001098fd145a Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 30 Sep 2026 11:01:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(align):=20=EC=A2=8C=EC=9A=B0=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=99=84=EB=A3=8C=20=EC=88=9C=EA=B0=84=EC=9D=98=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EC=B0=BD=EC=9D=84=20align=20=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20601=20=EB=A1=9C=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 좌우 정렬은 연속 스트리밍이라 그동안 아무것도 저장하지 않았다. "맞췄다"고 확인한 그 자리의 파형·IMU 가 파일에 없으니 나중에 좌우 판정을 대조할 길이 없었다(2026-09-30 요청). · 좌우 루프가 마지막 프레임 창(LateralGuide.required = 10, 판정 1회에 쓰는 수)을 파형+IMU 짝으로 들고 있다가, [좌우 확인 완료]에서 부착 위치 cm 으로 `align_{n}cm_lateral.csv` · `align_{n}cm_lateral_imu.csv` 를 쓴다. 다시 시작·처음부터 다시 정렬 때는 창을 비운다. · HospitalRunStore: 파일 종류를 AlignFileKind(SWEEP/CONFIRM/LATERAL) 로 — `confirm: Boolean` 대신. 같은 cm 이라도 종류가 다르면 다른 파일이다. · AlignLabdbPayload: `_lateral` 을 phase=lateral 로 올린다(탐색 → 확인 → 좌우 순). positions_measured 는 sweep 만, confirm_positions 는 confirm 만, lateral_frames 추가. 미업로드 대상 판정(raw = sweep)은 그대로. · 문서(LABDB_DATATYPES.md) 601 phase·params·IMU 파일명 갱신. 시험: HospitalRunStoreAlignFilesTest 4건 신규, 페이로드 2건·대상 판정 1건 추가 — 80건 통과. 실기(프로브 필요)는 다음 임상에서 확인. Co-Authored-By: Claude Fable 5.1 --- .../vesiscan/services/HospitalRunStore.kt | 39 +++++++---- .../services/labdb/AlignLabdbPayload.kt | 29 ++++++-- .../ui/views/clinical/AnchorAlignView.kt | 42 +++++++++--- .../HospitalRunStoreAlignFilesTest.kt | 66 +++++++++++++++++++ .../services/labdb/AlignLabdbPayloadTest.kt | 52 ++++++++++++++- .../labdb/HospitalAlignPendingTest.kt | 10 +++ docs/LABDB_DATATYPES.md | 15 +++-- 7 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.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 6049fe5..fa53ccc 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -194,11 +194,10 @@ object HospitalRunStore { * 탐색 측정 것을 덮으면 [writeAlignCycles] 에서 막아 둔 문제가 그대로 재현된다. */ fun writeAlignImu( - dir: File, cm: Int, cyclesImu: List>, confirm: Boolean = false, + dir: File, cm: Int, cyclesImu: List>, kind: AlignFileKind = AlignFileKind.SWEEP, ): 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) + val name = "align_%dcm%s_imu.csv".format(cm, kind.suffix) 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() @@ -237,6 +236,27 @@ object HospitalRunStore { // 고를 위치와 다를 수 있다. raw 가 남아 있으면 나중에 Python 으로 다시 판정할 수 있고, // 없으면 그 환자는 다시 부를 수밖에 없다. + /** + * 정렬 파일의 종류 = 파일명 접미사. **같은 cm 이라도 종류가 다르면 다른 파일이다.** + * + * 같은 이름에 쓰면 판정에 쓰인 데이터가 나중 것으로 덮인다 — 화면 판정은 먼저 잰 것 + * 기준인데 파일은 나중 것이라, 파일로 재판정하면 앱과 답이 갈린다(2026-09-10 확인 + * 측정에서 실제로 그랬다). 그래서 종류마다 파일을 가른다. + */ + enum class AlignFileKind(val suffix: String) { + /** 0~SEARCH_MAX cm 전수 탐색 — 판정에 쓰인 데이터. `align_2cm.csv` */ + SWEEP(""), + /** 고른 자리를 다시 잰 확인 측정. `align_2cm_confirm.csv` */ + CONFIRM("_confirm"), + /** + * 좌우 정렬 "확인 완료" 순간의 마지막 프레임들(2026-09-30 요청). 좌우는 연속 + * 스트리밍이라 그동안 아무것도 남지 않았는데, "맞췄다"고 한 자리의 파형·IMU 가 + * 없으면 나중에 좌우 판정을 대조할 수 없다. cm 은 부착 위치(best + 오프셋)다. + * `align_3cm_lateral.csv` + */ + LATERAL("_lateral"), + } + /** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */ fun alignDir(patient: String, startedAt: Date): File { val dir = File(runDir(patient, startedAt), "align") @@ -252,18 +272,9 @@ object HospitalRunStore { */ fun writeAlignCycles( dir: File, cm: Int, cycles: List>, - /** - * 같은 위치를 다시 잰 것인가(부착 위치 확인 측정). - * - * true 면 `align_2cm_confirm.csv` 로 따로 쓴다. 같은 이름에 쓰면 **판정에 쓰인 - * 데이터가 확인 측정으로 덮인다** — 화면 판정은 먼저 잰 것 기준인데 파일은 - * 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 전수 측정으로 바뀐 뒤 - * 이 재측정이 **항상** 일어나므로 그냥 두면 안 된다(2026-09-10). - */ - confirm: Boolean = false, + kind: AlignFileKind = AlignFileKind.SWEEP, ): Boolean = try { - val name = if (confirm) "align_%dcm_confirm.csv".format(cm) - else "align_%dcm.csv".format(cm) + val name = "align_%dcm%s.csv".format(cm, kind.suffix) File(dir, name).bufferedWriter().use { w -> cycles.forEachIndexed { ci, chans -> chans.forEachIndexed { ch, buf -> 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 fd9520e..76adf9b 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 @@ -39,7 +39,7 @@ import java.util.Locale * record = 한 위치의 한 cycle * rowIndex : 0..N 연속 (labdb 규약) * align_cm : 그 cycle 을 잰 위치 - * phase : "sweep" | "confirm" ← 2026-09-18 추가 + * phase : "sweep" | "confirm" | "lateral" ← 2026-09-18 추가 · lateral 2026-09-30 * cycle_idx : 그 위치 안에서의 순번 * channels : 6 × {ch, peak, peakIdx, data[~100]} * sensor : {} 또는 {imu:{ax..gz}, imu_samples:[…], imu_sample_count} @@ -84,7 +84,17 @@ object AlignLabdbPayload { * 확인 측정 파일이 하나도 안 잡혔다. */ private val NAME_RE = - Regex("""^align_(-?\d+)cm(_confirm)?\.csv$""", RegexOption.IGNORE_CASE) + Regex("""^align_(-?\d+)cm(_confirm|_lateral)?\.csv$""", RegexOption.IGNORE_CASE) + + /** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우)다. */ + private fun phaseOf(suffix: String): String = when (suffix.lowercase()) { + "" -> "sweep" + "_confirm" -> "confirm" + else -> "lateral" + } + private fun phaseRank(suffix: String): Int = when (phaseOf(suffix)) { + "sweep" -> 0; "confirm" -> 1; else -> 2 + } private val isoOut: SimpleDateFormat get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) @@ -103,7 +113,7 @@ object AlignLabdbPayload { ?.sortedWith( compareBy( { NAME_RE.find(it.name)!!.groupValues[1].toInt() }, - { if (NAME_RE.find(it.name)!!.groupValues[2].isEmpty()) 0 else 1 }, + { phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) }, ) ) ?: return null @@ -118,8 +128,9 @@ object AlignLabdbPayload { for (f in files) { val m = NAME_RE.find(f.name)!! val cm = m.groupValues[1].toInt() - // 같은 align_cm 으로 두 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다. - val phase = if (m.groupValues[2].isEmpty()) "sweep" else "confirm" + // 같은 align_cm 으로 여러 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다. + // lateral 은 좌우 확인 완료 순간의 마지막 프레임 창(2026-09-30). cm 은 부착 위치. + val phase = phaseOf(m.groupValues[2]) // 같은 위치·같은 phase 의 IMU 형제 파일. 없으면 빈 맵. val imuByCycle = readAlignImu( File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv") @@ -173,8 +184,12 @@ object AlignLabdbPayload { put("save_name", saveName) // 2026-09-18: `files.size` 를 그대로 쓰면 확인 측정이 위치 하나로 더해져 // "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다. - put("positions_measured", files.count { NAME_RE.find(it.name)!!.groupValues[2].isEmpty() }) - put("confirm_positions", files.count { NAME_RE.find(it.name)!!.groupValues[2].isNotEmpty() }) + put("positions_measured", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" }) + put("confirm_positions", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" }) + // 좌우 확인 프레임 수(0 = 좌우를 확인하지 않았거나 옛 앱). 위치 수에는 안 더한다. + put("lateral_frames", (0 until records.length()).count { + records.getJSONObject(it).optString("phase") == "lateral" + }) put("cycles_total", records.length()) // 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라, // 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다. 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 37dfb5c..14fc216 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 @@ -196,6 +196,12 @@ fun AnchorAlignView(appState: AppState) { var lrCommit by remember { mutableStateOf(null) } var lrAccum by remember { mutableIntStateOf(0) } var lrError by remember { mutableStateOf(null) } + /** + * 좌우 스트리밍의 **마지막 프레임 창** — [LateralGuide.required] 개(= 판정 1회에 쓰는 수). + * "좌우 확인 완료"를 누르는 순간 이것을 `align_{n}cm_lateral.csv` 로 남긴다(2026-09-30). + * 그 전에는 좌우 단계가 아무것도 저장하지 않았다. 파형과 그 프레임의 IMU 를 짝지어 둔다. + */ + val lrRecent = remember { ArrayDeque, List>>() } /** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */ var lrConfirmed by remember { mutableStateOf(false) } @@ -380,12 +386,13 @@ fun AnchorAlignView(appState: AppState) { // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. // 판정에 쓰인 파일을 덮지 않도록 별 이름으로 남긴다. val isConfirm = !guide.searching && records.any { it.cm == cm } - if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, confirm = isConfirm)) { + val kind = if (isConfirm) HospitalRunStore.AlignFileKind.CONFIRM else HospitalRunStore.AlignFileKind.SWEEP + if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, kind)) { error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." } // IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 — // 파형이 남았으면 측정은 성립한다. - HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, confirm = isConfirm) + HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind) // 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와 // 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다. @@ -457,8 +464,8 @@ fun AnchorAlignView(appState: AppState) { val prev = bleManager.piezoCollector.onMultiChannelComplete bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } // 좌우 정렬은 사람이 프로브를 움직이는 중이라 기울기가 제일 많이 바뀌는 때다. - // mtb 응답의 IMU 로 회차마다 카드를 갱신한다 — 저장은 하지 않는다(좌우는 원래 - // 프레임을 저장하지 않는 단계다). + // mtb 응답의 IMU 로 회차마다 카드를 갱신하고, 마지막 프레임 창([lrRecent])에 + // 파형과 짝지어 둔다 — "확인 완료" 순간의 것만 파일로 남는다. val lrImu = ImuSidecar(bleManager) lrImu.install() try { @@ -495,9 +502,8 @@ fun AnchorAlignView(appState: AppState) { // 중이라 간헐적 미수신이 정상이다. 계속 돌리고 다음 프레임을 기다린다. continue } - lrImu.await()?.takeIf { it.isNotEmpty() }?.let { - liveTilt = ImuPostureClassifier.classify(it).pitchDeg - } + val imu = lrImu.await()?.takeIf { it.isNotEmpty() } + imu?.let { liveTilt = ImuPostureClassifier.classify(it).pitchDeg } val sorted = got.sortedBy { it.channel } liveChannels = sorted detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d } @@ -506,6 +512,9 @@ fun AnchorAlignView(appState: AppState) { } lateral.push(frame)?.let { lrCommit = it } lrAccum = lateral.accumulated + // 판정 창과 같은 수만 남긴다 — 확인 완료 때 저장하는 것이 곧 마지막 판정의 근거다. + lrRecent.addLast(frame to (imu ?: emptyList())) + while (lrRecent.size > lateral.required) lrRecent.removeFirst() delay(CYCLE_GAP_MS) } } finally { @@ -847,12 +856,29 @@ fun AnchorAlignView(appState: AppState) { // 다시 시작할 때는 옛 프레임을 버린다 — 이전 위치의 신호가 // 평균에 섞이면 첫 판정이 엉뚱한 방향을 가리킨다. lateral.reset(); lrCommit = null; lrAccum = 0; lrConfirmed = false + lrRecent.clear() lrRunning = true } }, onConfirm = { lrRunning = false lrConfirmed = true + // 확인 완료 순간의 마지막 프레임 창을 남긴다(2026-09-30 요청). 높이는 + // 부착 위치(best + 오프셋)다 — 좌우는 그 높이에서 맞춘다. 화면을 떠날 + // 때 정렬 업로드(601)에 phase=lateral 로 같이 올라간다. + if (lrRecent.isNotEmpty()) { + runCatching { + val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val atCm = anchorTarget ?: cm + val frames = lrRecent.toList() + HospitalRunStore.writeAlignCycles( + dir, atCm, frames.map { it.first }, HospitalRunStore.AlignFileKind.LATERAL) + HospitalRunStore.writeAlignImu( + dir, atCm, frames.map { it.second }, HospitalRunStore.AlignFileKind.LATERAL) + hasAlignData = true + alignDirty = true + } + } // 좌우 결과를 요약에 남긴다. 나중에 "이 세션은 좌우까지 맞춘 // 것인가"를 파일만 보고 답할 수 있어야 한다 — 재분석 때 // 좌우 미확인 세션을 가려낼 유일한 근거다. @@ -950,7 +976,7 @@ fun AnchorAlignView(appState: AppState) { } runStartedAt = Date() guide.reset(); cm = 0; last = null; records = emptyList(); error = null - lateral.reset(); lrCommit = null; lrAccum = 0 + lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear() lrConfirmed = false; lrError = null; lrRunning = false disconnectedMidRun = false decidedAtCm = null; sweepStopped = false; confirmDone = false diff --git a/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt new file mode 100644 index 0000000..547fd00 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt @@ -0,0 +1,66 @@ +/* + * HospitalRunStoreAlignFilesTest — 정렬 파일의 종류별 이름과 형식을 잠근다. + * + * 2026-09-30: 좌우 확인 완료 순간의 프레임 창을 `_lateral` 로 남기기 시작했다. 파일명 + * 규칙이 어긋나면 [AlignLabdbPayload] 가 못 잡거나(안 올라감) 다른 종류로 읽는다(phase 가 + * 틀림). 여기서는 종류 → 파일명, 그리고 IMU 가 하나도 없을 때 파일을 만들지 않는 것을 본다. + */ +package com.medithings.vesiscan.services + +import com.medithings.vesiscan.ble.ImuSample +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class HospitalRunStoreAlignFilesTest { + + @get:Rule val tmp = TemporaryFolder() + + private fun frames(n: Int): List> = + List(n) { List(6) { ch -> DoubleArray(4) { k -> (ch * 10 + k).toDouble() } } } + + private fun sample(az: Float) = ImuSample(0.01f, 0.02f, az, 0.5f, 0.6f, 0.7f) + + @Test fun `종류마다 파일명이 갈린다`() { + val d = tmp.newFolder("align") + assertTrue(HospitalRunStore.writeAlignCycles(d, 2, frames(1))) + assertTrue(HospitalRunStore.writeAlignCycles(d, 2, frames(1), HospitalRunStore.AlignFileKind.CONFIRM)) + assertTrue(HospitalRunStore.writeAlignCycles(d, 3, frames(1), HospitalRunStore.AlignFileKind.LATERAL)) + + assertEquals( + listOf("align_2cm.csv", "align_2cm_confirm.csv", "align_3cm_lateral.csv"), + d.listFiles()!!.map { it.name }.sorted(), + ) + } + + @Test fun `좌우 프레임 창은 cycle,channel,s0… 으로 프레임마다 6줄이다`() { + val d = tmp.newFolder("align") + HospitalRunStore.writeAlignCycles(d, 3, frames(10), HospitalRunStore.AlignFileKind.LATERAL) + val lines = File(d, "align_3cm_lateral.csv").readLines() + assertEquals(60, lines.size) + assertEquals("0,0,0,1,2,3", lines[0]) + assertEquals("9,5,50,51,52,53", lines[59]) + } + + @Test fun `좌우 IMU 는 _lateral_imu 로, 헤더 뒤에 프레임·샘플 순으로 간다`() { + val d = tmp.newFolder("align") + val imu = listOf(listOf(sample(0.90f), sample(0.91f)), listOf(sample(0.92f))) + assertTrue(HospitalRunStore.writeAlignImu(d, 3, imu, HospitalRunStore.AlignFileKind.LATERAL)) + val lines = File(d, "align_3cm_lateral_imu.csv").readLines() + assertEquals("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps", lines[0]) + assertEquals(4, lines.size) + assertTrue(lines[1].startsWith("0,0,")) + assertTrue(lines[3].startsWith("1,0,")) + } + + @Test fun `IMU 가 하나도 없으면 파일을 만들지 않는다`() { + // 부재가 곧 "그때는 안 쟀다" 다 — 빈 파일이 있으면 받는 쪽이 0 샘플로 읽는다. + val d = tmp.newFolder("align") + assertFalse(HospitalRunStore.writeAlignImu(d, 3, listOf(emptyList(), emptyList()), HospitalRunStore.AlignFileKind.LATERAL)) + assertFalse(File(d, "align_3cm_lateral_imu.csv").exists()) + } +} 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 011d4ff..a63da71 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 @@ -25,6 +25,8 @@ class AlignLabdbPayloadTest { dir: File, cm: Int, cycles: Int, chans: Int = 6, len: Int = 100, /** 고른 위치를 다시 잰 확인 측정 — `align_{cm}cm_confirm.csv` 로 따로 쓰인다. */ confirm: Boolean = false, + /** 좌우 확인 완료 순간의 프레임 창 — `align_{cm}cm_lateral.csv`. */ + lateral: Boolean = false, ) { val sb = StringBuilder() for (ci in 0 until cycles) for (ch in 0 until chans) { @@ -32,7 +34,11 @@ class AlignLabdbPayloadTest { for (i in 0 until len) sb.append(',').append(if (i == 30 + ch) 3500 else 900) sb.append('\n') } - val name = if (confirm) "align_${cm}cm_confirm.csv" else "align_${cm}cm.csv" + val name = when { + lateral -> "align_${cm}cm_lateral.csv" + confirm -> "align_${cm}cm_confirm.csv" + else -> "align_${cm}cm.csv" + } File(dir, name).writeText(sb.toString(), Charsets.UTF_8) } @@ -52,13 +58,19 @@ class AlignLabdbPayloadTest { } /** `cycle_idx,sample_idx,ax_g,...` — HospitalRunStore.writeAlignImu 형식. */ - private fun writeImu(dir: File, cm: Int, cycles: Int, perCycle: Int = 3, confirm: Boolean = false) { + private fun writeImu( + dir: File, cm: Int, cycles: Int, perCycle: Int = 3, confirm: Boolean = false, lateral: Boolean = false, + ) { val sb = StringBuilder("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") // az 를 샘플마다 다르게 둔다 — "마지막 샘플" 이 진짜 마지막인지 봐야 한다. for (ci in 0 until cycles) for (si in 0 until perCycle) { sb.append("$ci,$si,0.01,0.02,${0.90 + 0.01 * si},0.5,0.6,0.7\n") } - val name = if (confirm) "align_${cm}cm_confirm_imu.csv" else "align_${cm}cm_imu.csv" + val name = when { + lateral -> "align_${cm}cm_lateral_imu.csv" + confirm -> "align_${cm}cm_confirm_imu.csv" + else -> "align_${cm}cm_imu.csv" + } File(dir, name).writeText(sb.toString(), Charsets.UTF_8) } @@ -123,6 +135,40 @@ class AlignLabdbPayloadTest { assertEquals((0 until 3).toList(), (0 until 3).map { recs.getJSONObject(it).getInt("rowIndex") }) } + @Test fun `좌우 확인 프레임은 phase lateral 로 올라가고 위치 수에 더해지지 않는다`() { + // 2026-09-30: "좌우 확인 완료" 순간의 마지막 프레임 창을 부착 위치 cm 으로 남긴다. + // 좌우는 그동안 아무것도 저장하지 않아 맞췄다는 근거가 파일에 없었다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + writeCycles(d, 2, cycles = 1) + writeCycles(d, 2, cycles = 1, confirm = true) + writeCycles(d, 3, cycles = 10, lateral = true) // 부착 위치 3cm 에서 좌우 10프레임 + writeImu(d, 3, cycles = 10, perCycle = 2, lateral = true) + val p = AlignLabdbPayload.build(d)!! + val recs = p.getJSONArray("records") + + assertEquals(13, p.getInt("recordCount")) + assertEquals( + listOf("sweep", "sweep", "confirm") + List(10) { "lateral" }, + (0 until 13).map { recs.getJSONObject(it).getString("phase") }, + ) + assertEquals(3, recs.getJSONObject(12).getInt("align_cm")) + assertEquals((0 until 13).toList(), (0 until 13).map { recs.getJSONObject(it).getInt("rowIndex") }) + // 좌우 프레임의 IMU 도 같이 간다. + assertEquals(2, recs.getJSONObject(3).getJSONObject("sensor").getInt("imu_sample_count")) + + val params = p.getJSONObject("params") + assertEquals(2, params.getInt("positions_measured")) + assertEquals(1, params.getInt("confirm_positions")) + assertEquals(10, params.getInt("lateral_frames")) + } + + @Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() { + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + assertEquals(0, AlignLabdbPayload.build(d)!!.getJSONObject("params").getInt("lateral_frames")) + } + @Test fun `확인 측정은 위치 수에 더해지지 않는다`() { // files.size 를 그대로 쓰면 "3군데 쟀다"가 4가 된다. val d = tmp.newFolder("align") diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt index 68d1489..6eb0cc0 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt @@ -127,6 +127,16 @@ class HospitalAlignPendingTest { assertFalse(combos[0].name.endsWith("_imu.csv")) } + /** 좌우 확인 프레임(`_lateral`)만 있는 폴더도 raw 가 아니다 — 탐색 없이 좌우만 남을 수는 없지만, 규칙은 같다. */ + @Test + fun `좌우 프레임만 있으면 대상이 아니다`() { + val run = runDir("2026-09-30_lat") + val d = File(run, "align").apply { mkdirs() } + File(d, "align_3cm_lateral.csv").writeText("0,0,900\n", Charsets.UTF_8) + + assertNull(HospitalLabdbUploader.pendingAlign(run)) + } + /** 조합 CSV 쪽 대상 선정이 `align/` 때문에 흔들리지 않아야 한다. */ @Test fun `조합 CSV 목록에 정렬 폴더가 섞이지 않는다`() { diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 798e5d6..0a9d7c6 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -172,7 +172,9 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `protocol` | string | `"hospital_align_2026"` 고정 | | `subject` | string | 환자 식별자 | | `save_name` | string | 저장 폴더명 | -| `positions_measured` | int | 측정한 위치 수 | +| `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만) | +| `confirm_positions` | int | 확인 측정한 위치 수 | +| `lateral_frames` | int | 좌우 확인 완료 순간에 남긴 프레임 수 (2026-09-30~). 0 = 좌우 미확인 또는 옛 앱 | | `cycles_total` | int | = `recordCount` | | `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 | | `device`, `firmware_version`, `hw_preset` | string | | @@ -317,8 +319,11 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `gx` · `gy` · `gz` | **dps** (도/초) | 각속도 3축. 정지 시 ≈ 0 | - 600 에서는 한 `repeat_idx` 가 한 레코드이고, 그 회차의 샘플이 `imu_samples` 에 배열로 들어갑니다. -- 601 에서는 한 `cycle_idx` 가 한 레코드입니다. `phase`(sweep/confirm)별로 따로 들어가며, - **확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.** +- 601 에서는 한 `cycle_idx` 가 한 레코드입니다. `phase`(sweep/confirm/lateral)별로 따로 + 들어가며, **확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.** +- `phase: "lateral"` (2026-09-30~) 은 간호사가 "좌우 확인 완료"를 누른 순간의 **마지막 + 프레임 창**(판정 1회에 쓰는 10프레임)입니다. `align_cm` 은 부착 위치(best + 오프셋)이고, + `cycle_idx` 는 그 창 안의 순서입니다. 좌우 정렬 판정을 나중에 대조하는 데 씁니다. ## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오 @@ -355,9 +360,9 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( 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 +601 align_{n}cm_imu.csv · align_{n}cm_confirm_imu.csv · align_{n}cm_lateral_imu.csv cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps - → 파형과 같은 파일명 규칙(confirm 분리)을 그대로 따릅니다 + → 파형과 같은 파일명 규칙(confirm/lateral 분리)을 그대로 따릅니다 ``` 파형 행(`meta + s0..s99`)을 넓히지 않은 이유: 그 헤더는 이미 올라간 데이터와 파서가