diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index 94a2266..c468c4d 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -56,6 +56,26 @@ class AppState(private val context: Context) { */ var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null) + /** + * 정렬 **확인 측정**에서 잡힌 채널(CH0~3). 재부착 확인([ReattachCheck])의 기준. + * null = 기준 없음 — 사람이 고른 위치이거나 확인 측정 전에 진행했다. + */ + var anchorRefChannels: List? by mutableStateOf(null) + + /** 재부착 확인 결과. null = 아직 안 했다. 부착 위치와 같이 비운다. */ + var anchorReattach: com.medithings.vesiscan.managers.ReattachCheck.Result? by mutableStateOf(null) + + /** + * 부착 위치와 딸린 것(근거·기준 채널·재부착 확인)을 **한꺼번에** 비운다. + * 하나만 남으면 그 순간부터 서로 어긋난다 — 환자가 바뀌었는데 재부착 확인이 ✓ 로 남는 식. + */ + fun clearAnchor() { + anchorCm = null + anchorBasis = null + anchorRefChannels = null + anchorReattach = null + } + /** * 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.** * diff --git a/app/src/main/java/com/medithings/vesiscan/managers/ReattachCheck.kt b/app/src/main/java/com/medithings/vesiscan/managers/ReattachCheck.kt new file mode 100644 index 0000000..8acb11b --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/managers/ReattachCheck.kt @@ -0,0 +1,96 @@ +/* + * ReattachCheck — 정렬 뒤 프로브를 뗐다가(위치 마킹 · 크래들 부착) 다시 붙인 자리가 정렬 + * 때와 같은 신호를 주는지 판정한다. + * + * 2026-09-29 임상: 정렬에서는 CH0~3 이 다 잡혔는데, 마킹하고 떼서 크래들로 다시 붙이니 + * **CH2 가 사라진 채** 18조합을 다 쟀다. 정렬과 임상 측정 사이에 손이 한 번 가는데 그 + * 사이를 확인하는 단계가 없었다. 그래서 측정 직전에 부착 위치에서 한 번(20 cycle) 재고, + * 정렬 확인 측정과 **채널별로** 대조한다(2026-09-30 결정). + * + * 판정은 둘을 합친다: + * ① 정렬 때 잡혔던 채널이 지금 안 잡히면 실패 — 그 채널이 "없음". + * ② 탈착 감시가 확정 미부착으로 본 채널이 있으면 실패. + * 정렬 기준이 없으면(사람이 고른 위치 · 옛 앱으로 정렬) ②만 본다 — 기준 없이 "정렬과 + * 같다"고 말하지 않는다. + * + * 막지 않는다 — 경고만. 결과는 align_result.json `reattach` 와 run 매니페스트 + * `anchor_reattach` 에 남아, "이 세션은 CH2 없이 잰 것"을 나중에 걸러낼 수 있다. + */ +package com.medithings.vesiscan.managers + +import org.json.JSONArray +import org.json.JSONObject + +object ReattachCheck { + + /** CH0~CH3 만 본다 — 벽 검출·판정 대상 채널. CH4·5 는 좌우 균형용이라 여기 안 든다. */ + const val CENTER_CHANNELS = 4 + + data class Result( + val ok: Boolean, + /** 정렬 때는 잡혔는데 지금 안 잡힌 채널. */ + val missing: List, + /** 탈착 감시가 확정 미부착으로 본 채널(CH0~3). */ + val detached: List, + /** 지금 잡힌 채널(CH0~3). */ + val channels: List, + /** 정렬 확인 측정 때 잡힌 채널. null = 기준 없음. */ + val refChannels: List?, + val bvMl: Double?, + val at: Long, + ) { + val hasReference: Boolean get() = refChannels != null + + /** 화면과 기록이 같은 말을 쓴다. */ + val label: String get() = when { + ok && hasReference -> "정렬과 같음 (CH0~3)" + ok -> "접촉 정상 (정렬 기준 없음)" + else -> buildString { + if (missing.isNotEmpty()) { + append("정렬 때 잡힌 ") + append(missing.joinToString(" · ") { "CH$it" }) + append(" 없음") + } + if (detached.isNotEmpty()) { + if (isNotEmpty()) append(" · ") + append(detached.joinToString(" · ") { "CH$it" }) + append(" 미부착") + } + } + } + + fun toJson(): JSONObject = JSONObject().apply { + put("ok", ok) + put("missing", JSONArray(missing)) + put("detached", JSONArray(detached)) + put("channels", JSONArray(channels)) + put("ref_channels", refChannels?.let { JSONArray(it) } ?: JSONObject.NULL) + put("bv_ml", bvMl ?: JSONObject.NULL) + put("at", at) + } + } + + /** + * @param ref 정렬 확인 측정에서 잡힌 채널(CH0~3). null 이면 기준 없음. + * @param now 지금 잡힌 채널(CH0~3 이상이면 앞 4개만 본다). + * @param detached 탈착 감시가 확정 미부착으로 본 채널 번호. + */ + fun judge( + ref: List?, + now: List, + detached: List, + bvMl: Double?, + at: Long = System.currentTimeMillis(), + ): Result { + val nowC = now.take(CENTER_CHANNELS) + val refC = ref?.take(CENTER_CHANNELS) + val missing = refC?.indices?.filter { refC[it] && nowC.getOrNull(it) != true } ?: emptyList() + val det = detached.filter { it < CENTER_CHANNELS }.distinct().sorted() + return Result( + ok = missing.isEmpty() && det.isEmpty(), + missing = missing, detached = det, + channels = nowC, refChannels = refC, + bvMl = bvMl, at = at, + ) + } +} 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 fa53ccc..a8a83c7 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -255,6 +255,84 @@ object HospitalRunStore { * `align_3cm_lateral.csv` */ LATERAL("_lateral"), + /** + * 재부착 확인 측정(2026-09-30). 정렬 뒤 뗐다(마킹·크래들) 붙인 자리에서 병원 화면이 + * 20 cycle 재고 정렬 확인 측정과 채널별로 대조한 것. cm 은 부착 위치. `align_3cm_reattach.csv` + */ + REATTACH("_reattach"), + } + + /** `align_result.json` 을 읽어 고쳐 쓴다. 없으면 빈 객체에서 시작한다(옛 앱으로 정렬한 폴더). */ + fun updateAlignSummary(dir: File, mutate: (JSONObject) -> Unit) { + runCatching { + val f = File(dir, "align_result.json") + val json = if (f.exists()) runCatching { JSONObject(f.readText(Charsets.UTF_8)) }.getOrNull() ?: JSONObject() + else JSONObject() + mutate(json) + f.writeText(json.toString(2), Charsets.UTF_8) + } + } + + /** + * 좌우 스트리밍 **전체** 기록(2026-09-30). 프레임마다 6채널 파형과 그 프레임의 판정 + * (u4 · u5 · imbalance · action · ch3)을 `align_{n}cm_lateral_stream.csv` 에, IMU 를 + * `…_lateral_stream_imu.csv` 에 **덧붙여** 쓴다. 앱이 중간에 죽어도 그때까지는 남는다. + * + * 2026-09-29 임상에서 좌우는 실시간 판정만 하고 아무것도 남기지 않아, 뗐다 붙인 뒤 + * CH2 가 사라진 것을 "그 전에는 있었나"와 대조할 수 없었다. [AlignFileKind.LATERAL] + * (확인 완료 순간의 마지막 창)과 별개로 흐름 전체를 남긴다. + * + * `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오른다 — 파일을 지우지 않고 이어 쓰므로 + * (앞 시도의 업로드가 비동기로 이 파일을 읽고 있을 수 있다) 이 열로 시도를 가른다. + * 헤더는 파일이 새로 생길 때만 쓴다. IMU 헤더는 다른 정렬 IMU 파일과 같다. + */ + class LateralStreamWriter(dir: File, cm: Int) : java.io.Closeable { + private val waveFile = File(dir, "align_%dcm_lateral_stream.csv".format(cm)) + private val imuFile = File(dir, "align_%dcm_lateral_stream_imu.csv".format(cm)) + private val wave: java.io.BufferedWriter + private val imu: java.io.BufferedWriter + + init { + val newWave = !waveFile.exists() || waveFile.length() == 0L + val newImu = !imuFile.exists() || imuFile.length() == 0L + wave = java.io.BufferedWriter(java.io.FileWriter(waveFile, true)) + imu = java.io.BufferedWriter(java.io.FileWriter(imuFile, true)) + if (newWave) { wave.write(waveHeader()); wave.newLine() } + if (newImu) { imu.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps"); imu.newLine() } + } + + /** 한 프레임. 판정이 아직 없으면(창이 덜 찼을 때) 판정 열은 빈칸이다. */ + fun frame( + attempt: Int, frameIdx: Int, frame6: List, samples: List, + u4: Int?, u5: Int?, imbalance: Int?, action: String?, ch3: Boolean?, + ) { + frame6.forEachIndexed { ch, buf -> + wave.write(buildString { + append(attempt); append(','); append(frameIdx); append(','); append(ch); append(',') + append(u4 ?: ""); append(','); append(u5 ?: ""); append(','); append(imbalance ?: ""); append(',') + append(action ?: ""); append(','); append(ch3?.let { if (it) "1" else "0" } ?: "") + for (v in buf) { append(','); append(v.toInt()) } + }) + wave.newLine() + } + samples.forEachIndexed { si, sm -> + imu.write(listOf(frameIdx, si, sm.ax, sm.ay, sm.az, sm.gx, sm.gy, sm.gz).joinToString(",")) + imu.newLine() + } + wave.flush(); imu.flush() + } + + override fun close() { + runCatching { wave.close() } + runCatching { imu.close() } + } + + companion object { + const val HEADER_PREFIX = "attempt,frame_idx,ch,u4,u5,imbalance,action,ch3" + /** 샘플 열은 프로브 설정의 샘플 수만큼 — 받는 쪽이 헤더로 읽을 수 있게. */ + fun waveHeader(samples: Int = com.medithings.vesiscan.models.HospitalFixedParams.SAMPLES): String = + HEADER_PREFIX + (0 until samples).joinToString("") { ",s$it" } + } } /** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */ 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 76adf9b..3595891 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,9 @@ import java.util.Locale * record = 한 위치의 한 cycle * rowIndex : 0..N 연속 (labdb 규약) * align_cm : 그 cycle 을 잰 위치 - * phase : "sweep" | "confirm" | "lateral" ← 2026-09-18 추가 · lateral 2026-09-30 + * phase : "sweep" | "confirm" | "lateral" | "lateral_stream" | "reattach" + * ← 2026-09-18 추가 · lateral/lateral_stream/reattach 2026-09-30 + * lateral : (lateral_stream 만) {attempt, u4, u5, imbalance, action, ch3} — 그 프레임의 판정 * cycle_idx : 그 위치 안에서의 순번 * channels : 6 × {ch, peak, peakIdx, data[~100]} * sensor : {} 또는 {imu:{ax..gz}, imu_samples:[…], imu_sample_count} @@ -84,16 +86,18 @@ object AlignLabdbPayload { * 확인 측정 파일이 하나도 안 잡혔다. */ private val NAME_RE = - Regex("""^align_(-?\d+)cm(_confirm|_lateral)?\.csv$""", RegexOption.IGNORE_CASE) + Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?\.csv$""", RegexOption.IGNORE_CASE) - /** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우)다. */ + /** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */ private fun phaseOf(suffix: String): String = when (suffix.lowercase()) { "" -> "sweep" "_confirm" -> "confirm" - else -> "lateral" + "_lateral" -> "lateral" + "_lateral_stream" -> "lateral_stream" + else -> "reattach" } private fun phaseRank(suffix: String): Int = when (phaseOf(suffix)) { - "sweep" -> 0; "confirm" -> 1; else -> 2 + "sweep" -> 0; "confirm" -> 1; "lateral" -> 2; "lateral_stream" -> 3; else -> 4 } private val isoOut: SimpleDateFormat @@ -136,16 +140,41 @@ object AlignLabdbPayload { File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv") ) // `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles). + // 좌우 흐름(`_lateral_stream`)만 헤더가 있고 앞에 판정 열이 붙는다 + // (HospitalRunStore.LateralStreamWriter): attempt,frame_idx,ch,u4,u5,imbalance,action,ch3,s0… val byCycle = linkedMapOf>() - runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line -> - if (line.isBlank()) return@forEach + val metaByCycle = linkedMapOf() + val lines = runCatching { f.readLines(Charsets.UTF_8) }.getOrNull().orEmpty() + val streamMode = lines.firstOrNull()?.startsWith( + com.medithings.vesiscan.services.HospitalRunStore.LateralStreamWriter.HEADER_PREFIX) == true + lines.forEachIndexed { li, line -> + if (line.isBlank() || (streamMode && li == 0)) return@forEachIndexed val p = line.split(',') - if (p.size < 3) return@forEach - val ci = p[0].trim().toIntOrNull() ?: return@forEach - val ch = p[1].trim().toIntOrNull() ?: return@forEach - val data = IntArray(p.size - 2) { p[it + 2].trim().toIntOrNull() ?: 0 } - if (data.isEmpty()) return@forEach - byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data + if (streamMode) { + if (p.size < 9) return@forEachIndexed + val ci = p[1].trim().toIntOrNull() ?: return@forEachIndexed + val ch = p[2].trim().toIntOrNull() ?: return@forEachIndexed + val data = IntArray(p.size - 8) { p[it + 8].trim().toIntOrNull() ?: 0 } + if (data.isEmpty()) return@forEachIndexed + byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data + metaByCycle.getOrPut(ci) { + JSONObject().apply { + put("attempt", p[0].trim().toIntOrNull() ?: JSONObject.NULL) + put("u4", p[3].trim().toIntOrNull() ?: JSONObject.NULL) + put("u5", p[4].trim().toIntOrNull() ?: JSONObject.NULL) + put("imbalance", p[5].trim().toIntOrNull() ?: JSONObject.NULL) + put("action", p[6].trim().ifBlank { null } ?: JSONObject.NULL) + put("ch3", when (p[7].trim()) { "1" -> true; "0" -> false; else -> JSONObject.NULL }) + } + } + } else { + if (p.size < 3) return@forEachIndexed + val ci = p[0].trim().toIntOrNull() ?: return@forEachIndexed + val ch = p[1].trim().toIntOrNull() ?: return@forEachIndexed + val data = IntArray(p.size - 2) { p[it + 2].trim().toIntOrNull() ?: 0 } + if (data.isEmpty()) return@forEachIndexed + byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data + } } for (ci in byCycle.keys.sorted()) { val chans = JSONArray() @@ -165,6 +194,7 @@ object AlignLabdbPayload { put("align_cm", cm) put("phase", phase) put("cycle_idx", ci) + metaByCycle[ci]?.let { put("lateral", it) } put("sensor", LabdbSensor.of(imuByCycle[ci])) put("commandType", "MTB") put("channels", chans) @@ -186,10 +216,13 @@ object AlignLabdbPayload { // "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다. 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" - }) + // 좌우·재부착 프레임 수(0 = 그 단계를 안 했거나 옛 앱). 위치 수에는 안 더한다. + fun countPhase(name: String) = (0 until records.length()).count { + records.getJSONObject(it).optString("phase") == name + } + put("lateral_frames", countPhase("lateral")) + put("lateral_stream_frames", countPhase("lateral_stream")) + put("reattach_frames", countPhase("reattach")) 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 14fc216..20e8f94 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 @@ -74,6 +74,7 @@ import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.managers.DetachWatcher import com.medithings.vesiscan.managers.ImuPostureClassifier import com.medithings.vesiscan.managers.LateralGuide +import com.medithings.vesiscan.managers.ReattachCheck import com.medithings.vesiscan.models.ClinicalPosture import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.services.HospitalRunStore @@ -202,6 +203,10 @@ fun AnchorAlignView(appState: AppState) { * 그 전에는 좌우 단계가 아무것도 저장하지 않았다. 파형과 그 프레임의 IMU 를 짝지어 둔다. */ val lrRecent = remember { ArrayDeque, List>>() } + /** 좌우 스트리밍 전체 기록의 프레임 번호 — 다시 시작해도 이어진다([HospitalRunStore.LateralStreamWriter]). */ + var lrStreamIdx by remember { mutableIntStateOf(0) } + /** [처음부터 다시 정렬] 횟수 + 1. 스트리밍 기록의 `attempt` 열. */ + var lrAttempt by remember { mutableIntStateOf(1) } /** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */ var lrConfirmed by remember { mutableStateOf(false) } @@ -468,6 +473,13 @@ fun AnchorAlignView(appState: AppState) { // 파형과 짝지어 둔다 — "확인 완료" 순간의 것만 파일로 남는다. val lrImu = ImuSidecar(bleManager) lrImu.install() + // 흐름 전체를 남긴다(2026-09-30). 열 수 없으면(저장 공간) 기록 없이 진행 — 좌우 + // 맞추기 자체를 막을 이유는 아니다. + val stream = runCatching { + HospitalRunStore.LateralStreamWriter( + HospitalRunStore.alignDir(saveName, runStartedAt), anchorTarget ?: cm) + }.getOrNull() + val streamStart = lrStreamIdx try { bleManager.piezoConfigEcho.value = null bleManager.sendPiezoConfig( @@ -515,9 +527,20 @@ fun AnchorAlignView(appState: AppState) { // 판정 창과 같은 수만 남긴다 — 확인 완료 때 저장하는 것이 곧 마지막 판정의 근거다. lrRecent.addLast(frame to (imu ?: emptyList())) while (lrRecent.size > lateral.required) lrRecent.removeFirst() + // 흐름 전체 — 이 프레임과 이 프레임까지의 판정. + val c = lrCommit + runCatching { + stream?.frame( + lrAttempt, lrStreamIdx, frame, imu ?: emptyList(), + c?.u4, c?.u5, c?.imbalance, c?.action?.name, c?.ch3, + ) + } + lrStreamIdx++ delay(CYCLE_GAP_MS) } } finally { + stream?.close() + if (lrStreamIdx > streamStart) { hasAlignData = true; alignDirty = true } bleManager.piezoCollector.onMultiChannelComplete = prev lrImu.restore() kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { @@ -598,8 +621,7 @@ fun AnchorAlignView(appState: AppState) { enabled = !measuring, onClick = { if (isConnected) { - appState.anchorCm = null - appState.anchorBasis = null + appState.clearAnchor() bleManager.disconnect() } else { appState.connectReturnScreen = AppScreen.ANCHOR_ALIGN @@ -923,6 +945,11 @@ fun AnchorAlignView(appState: AppState) { onClick = { appState.anchorCm = proceedCm appState.anchorBasis = basis + // 재부착 확인의 기준 — 확인 측정에서 잡힌 채널(CH0~3). 확인 측정 없이 + // 진행하면 기준 없음(null)이다. 새 위치이므로 지난 재부착 결과는 비운다. + appState.anchorRefChannels = + if (confirmDone) bv?.walls?.take(ReattachCheck.CENTER_CHANNELS)?.map { it != null } else null + appState.anchorReattach = null // 근거를 요약에도 박는다. 화면에만 있으면 나중에 파일을 보는 사람이 // 이 위치가 어떻게 정해졌는지 알 길이 없다. last?.let { s -> @@ -935,6 +962,7 @@ fun AnchorAlignView(appState: AppState) { bleManager.firmwareVersion.value, guide.records, s, lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm, startedAt = runStartedAt, + confirmChannels = appState.anchorRefChannels, ), ) } @@ -976,7 +1004,7 @@ fun AnchorAlignView(appState: AppState) { } runStartedAt = Date() guide.reset(); cm = 0; last = null; records = emptyList(); error = null - lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear() + lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++ lrConfirmed = false; lrError = null; lrRunning = false disconnectedMidRun = false decidedAtCm = null; sweepStopped = false; confirmDone = false @@ -990,6 +1018,28 @@ fun AnchorAlignView(appState: AppState) { // ── 채널 파형 ─────────────────────────────────────────────────── // 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는 // 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다. + // + // 파형을 **용적·접촉보다 위에, 더 크게** 둔다(2026-09-30 1차). 2026-09-29 임상에서 + // 뗐다 붙인 뒤 CH2 가 사라진 것을 화면에서 바로 알아채지 못했다 — 파형이 아래 + // 작게 있었다. 채널마다 벽이 잡혔는지 띠로 먼저 보이고, 그 아래 파형이다. + if (liveChannels.isNotEmpty()) { + Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = MlSecondaryText) + Spacer(Modifier.height(6.dp)) + ChannelWallStrip(bv?.walls) + Spacer(Modifier.height(6.dp)) + SixChannelWaveformGrid( + channels = liveChannels, + // 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가 + // 원인 분석의 출발점이다. + walls = bv?.markers ?: emptyList(), + modifier = Modifier.fillMaxWidth(), + cellHeight = 170.dp, + compact = true, + ) + Spacer(Modifier.height(16.dp)) + } + if (bv != null) { Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) @@ -1031,21 +1081,6 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(12.dp)) } - if (liveChannels.isNotEmpty()) { - Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, - color = MlSecondaryText) - Spacer(Modifier.height(6.dp)) - SixChannelWaveformGrid( - channels = liveChannels, - // 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가 - // 원인 분석의 출발점이다. - walls = bv?.markers ?: emptyList(), - modifier = Modifier.fillMaxWidth(), - cellHeight = 140.dp, - compact = true, - ) - Spacer(Modifier.height(16.dp)) - } Spacer(Modifier.height(20.dp)) @@ -1215,6 +1250,43 @@ private fun LateralSection( } } +/** + * 채널마다 벽이 잡혔는지 한 줄로(2026-09-30). CH0~3 이 판정 채널, CH4·5 는 좌우용이라 + * 회색으로 구분한다. 잡힌 채널은 초록, 안 잡힌 판정 채널은 빨강 — "CH2 없음"이 파형을 + * 읽기 전에 먼저 보여야 한다. + */ +@Composable +private fun ChannelWallStrip(walls: List?) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (ch in 0 until 6) { + val judged = ch < ReattachCheck.CENTER_CHANNELS + val hit = walls?.getOrNull(ch) != null + val c = when { + walls == null -> MlSecondaryText + !judged -> if (hit) MlSecondaryText else MlSecondaryText.copy(alpha = 0.6f) + hit -> MlSuccess + else -> MlCritical + } + Box( + Modifier.weight(1f) + .background(c.copy(alpha = 0.12f), RoundedCornerShape(8.dp)) + .padding(vertical = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "CH$ch " + when { + walls == null -> "–" + hit -> "✓" + judged -> "✗" + else -> "–" + }, + fontSize = 12.sp, fontWeight = FontWeight.Bold, color = c, + ) + } + } + } +} + @Composable private fun StatusChip(connected: Boolean, name: String?) { val bg = if (connected) MlSuccess.copy(alpha = 0.15f) else MlCritical.copy(alpha = 0.12f) @@ -1401,8 +1473,11 @@ private fun buildAlignSummary( decidedAtCm: Int? = null, /** 이 정렬 세션의 시작 시각. labdb testId 의 `_HHmm` 이 여기서 나온다([AlignLabdbPayload.sessionBase]). */ startedAt: Date? = null, + /** 확인 측정에서 잡힌 채널(CH0~3) — 재부착 확인([ReattachCheck])이 대조하는 기준. */ + confirmChannels: List? = null, ): JSONObject = JSONObject().apply { startedAt?.let { put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(it)) } + confirmChannels?.let { put("confirm_channels", JSONArray(it)) } // 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor` // 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다. val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm 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 8882f8b..61399ed 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 @@ -17,7 +17,7 @@ * 측정하는 동안 실제로 보는 것은 넷이다 — **기울기 · 채움 정도 · 채널 접촉 · 시작/중지**. * 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름만 둔다: * - * 연결 → labdb → 정렬 → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중) + * 연결 → labdb → 정렬(+재부착 확인) → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중) * * 정렬은 위에 둔다(2026-09-29 오후) — 접는 구간에 넣어 봤더니 환자마다 한 번은 반드시 * 펴야 해서 불편했다. 프로브를 붙이는 순서가 곧 화면 순서다: 연결하고, 붙일 자리를 @@ -425,6 +425,9 @@ fun HospitalModeView(appState: AppState) { // 자리로도 진행할 수 있는데, 그렇게 받은 데이터를 알고리즘 성능의 근거로 // 쓰면 안 된다 - 분석 때 모집단을 가를 유일한 단서다([AnchorBasis]). put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL) + // 재부착 확인(2026-09-30). null = 안 했다. ok=false 인 채 시작한 세션은 그 + // 채널 없이 잰 것이므로 분석에서 걸러야 한다. + put("anchor_reattach", appState.anchorReattach?.toJson() ?: JSONObject.NULL) put("combinations", results) }, startedAt) // 정렬(601)은 정렬 화면을 떠나는 순간 한 번만 올리고, 조합 CSV 와 달리 뒤에 @@ -480,8 +483,7 @@ fun HospitalModeView(appState: AppState) { // BV·파형·접촉은 여기서 비우지 않는다. 위의 // LaunchedEffect(isConnected) 가 **끊기는 모든 경우**를 // 맡는다 — 프로브가 스스로 끊길 때도 같아야 한다. - appState.anchorCm = null - appState.anchorBasis = null + appState.clearAnchor() bleManager.disconnect() } else { appState.connectReturnScreen = AppScreen.HOSPITAL_MODE @@ -510,6 +512,12 @@ fun HospitalModeView(appState: AppState) { if (appState.anchorBasis?.isAlgorithm == true) "부착 위치 ${cm}cm 확정" else "부착 위치 ${cm}cm · ${appState.anchorBasis?.label ?: "근거 불명"}" } ?: "부착 위치 미정렬" + val reattachSummary = when { + appState.anchorCm == null -> "" + appState.anchorReattach == null -> " · 재부착 미확인" + appState.anchorReattach?.ok == true -> " · 재부착 ✓" + else -> " · 재부착 ⚠" + } // 2. labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차 // 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다 @@ -519,7 +527,7 @@ fun HospitalModeView(appState: AppState) { // 진행·중지·접촉이 스크롤 없이 한 화면에 들어오게 한다 — 이 화면을 다시 짠 이유다. if (running) { Text( - "환자 ${patient.ifBlank { "(이름 없음)" }} · ${posture.label} · $anchorSummary · labdb " + + "환자 ${patient.ifBlank { "(이름 없음)" }} · ${posture.label} · $anchorSummary$reattachSummary · labdb " + (if (LabdbCredentials.isRegistered) "연결됨" else "미등록"), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlNeutral, modifier = Modifier.fillMaxWidth() @@ -539,6 +547,19 @@ fun HospitalModeView(appState: AppState) { enabled = true, onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN }, ) + // 3-1. 재부착 확인(2026-09-30) — 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리가 + // 정렬 때와 같은 채널을 주는지. 부착 위치가 있을 때만 그린다. + if (appState.anchorCm != null) { + Spacer(Modifier.height(8.dp)) + ReattachCheckCard( + appState = appState, + bleManager = bleManager, + patient = patient, + connected = isConnected, + enabled = true, + supine = posture == ClinicalPosture.SUPINE, + ) + } Spacer(Modifier.height(12.dp)) // 4. 환자명 @@ -549,8 +570,7 @@ fun HospitalModeView(appState: AppState) { // 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면 // 매니페스트에 틀린 위치가 기록된다. if (it != patient) { - appState.anchorCm = null - appState.anchorBasis = null + appState.clearAnchor() } patient = it } @@ -624,6 +644,11 @@ fun HospitalModeView(appState: AppState) { if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.") else if (patient.isBlank()) Hint("환자명을 입력해 주세요.") + // 막지 않는다 — 경고만. 그대로 시작하면 run 매니페스트에 그 사실이 남는다. + else if (appState.anchorCm != null && appState.anchorReattach == null) + Hint("재부착 확인을 아직 안 했습니다 — 위 정렬 카드의 [재부착 확인]. 그대로 시작할 수는 있습니다.") + else if (appState.anchorReattach?.ok == false) + Hint("재부착 확인 실패(${appState.anchorReattach?.label}) — 다시 붙이고 [다시 확인]. 그대로 시작하면 기록에 남습니다.") } // 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상 diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ReattachCheckCard.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ReattachCheckCard.kt new file mode 100644 index 0000000..8d66f72 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ReattachCheckCard.kt @@ -0,0 +1,308 @@ +/* + * ReattachCheckCard — 병원 임상 측정 화면의 **재부착 확인** (2026-09-30). + * + * 정렬 → 위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기 → **여기서 한 번 재고** → 측정. + * 2026-09-29 임상에서 이 사이에 CH2 가 사라졌는데 알 길이 없었다. 부착 위치에서 정렬과 + * 같은 조건(2.3MHz · c3 · 20 cycle)으로 재서 정렬 확인 측정과 채널별로 대조한다 + * ([ReattachCheck]). 막지 않는다 — 경고만. + * + * 저장: `align/align_{n}cm_reattach.csv` + IMU, `align_result.json` 의 `reattach`, 그리고 + * 정렬 세션(601)에 phase=reattach 로 같이 올라간다. run 매니페스트에는 병원 화면이 + * `anchor_reattach` 로 넣는다. + */ +package com.medithings.vesiscan.ui.views.clinical + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.medithings.vesiscan.AppState +import com.medithings.vesiscan.ble.BleManager +import com.medithings.vesiscan.ble.ImuSample +import com.medithings.vesiscan.ble.PiezoChannelData +import com.medithings.vesiscan.managers.AnchorConfig +import com.medithings.vesiscan.managers.ClinicalBv +import com.medithings.vesiscan.managers.DetachWatcher +import com.medithings.vesiscan.managers.ReattachCheck +import com.medithings.vesiscan.models.HospitalFixedParams +import com.medithings.vesiscan.models.ProbeCycle +import com.medithings.vesiscan.models.ProbeFrequency +import com.medithings.vesiscan.services.HospitalRunStore +import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader +import com.medithings.vesiscan.ui.theme.MlCritical +import com.medithings.vesiscan.ui.theme.MlPrimary +import com.medithings.vesiscan.ui.theme.MlSecondaryText +import com.medithings.vesiscan.ui.theme.MlSuccess +import com.medithings.vesiscan.ui.theme.MlWarning +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.util.Date + +/** 정렬과 같은 조건 — 다른 조건으로 재면 검출 문턱이 달라 채널 대조가 성립하지 않는다. */ +private val CHECK_FREQ = ProbeFrequency.F_2_3 +private val CHECK_CYCLE = ProbeCycle.C3 +private const val MEASURE_TIMEOUT_MS = 3_000L +private const val CONFIG_TIMEOUT_MS = 3_000L +private const val SETTLE_MS = 800L +private const val CYCLE_GAP_MS = 600L + +/** 재부착 확인 한 번의 원시 결과. 저장·판정은 호출부가 한다. */ +class ReattachMeasurement( + val cycles: List>, + val cyclesImu: List>, + val outcome: ClinicalBv.Outcome, + val detach: DetachWatcher.Snapshot?, +) + +/** + * 부착 위치에서 정렬 조건으로 [AnchorConfig.ALIGN_CYCLES] 회 잰다. 정렬 화면의 위치 측정 + * 루프와 같은 순서(설정 → echo 확인 → cycle 마다 mtb → IMU 짝짓기)다. + * + * @return 결과와 실패 이유. 실패면 결과가 null. + */ +internal suspend fun measureForReattach( + ble: BleManager, + supine: Boolean, + onProgress: (Int) -> Unit, +): Pair { + val inbox = Channel>(Channel.CONFLATED) + val guard = ProbeConfigGuard(ble) + guard.capture() + val prev = ble.piezoCollector.onMultiChannelComplete + ble.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } + val imuSide = ImuSidecar(ble) + imuSide.install() + val watcher = DetachWatcher() + try { + ble.piezoConfigEcho.value = null + ble.sendPiezoConfig( + freq = CHECK_FREQ.freqOption, + cycles = CHECK_CYCLE.cycles, + avg = HospitalFixedParams.AVG, + delayUs = HospitalFixedParams.DELAY_US, + samples = HospitalFixedParams.SAMPLES, + ) + val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) { + while (ble.piezoConfigEcho.value == null) delay(20) + ble.piezoConfigEcho.value + } + if (echo?.matches( + CHECK_FREQ.freqOption, CHECK_CYCLE.cycles, + HospitalFixedParams.AVG, HospitalFixedParams.DELAY_US, HospitalFixedParams.SAMPLES, + ) != true + ) { + return null to "프로브 설정 실패 — ${echo?.error ?: "응답 없음 또는 설정 불일치"}" + } + delay(SETTLE_MS) + + val cycles = ArrayList>(AnchorConfig.ALIGN_CYCLES) + val imus = ArrayList>(AnchorConfig.ALIGN_CYCLES) + var snap: DetachWatcher.Snapshot? = null + for (i in 1..AnchorConfig.ALIGN_CYCLES) { + while (inbox.tryReceive().isSuccess) { /* 늦게 온 직전 결과 비우기 */ } + imuSide.drain() + ble.sendMtb() + val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() } + ?: return null to "측정 시간 초과 ($i/${AnchorConfig.ALIGN_CYCLES}) — 다시 시도하세요." + val imu = imuSide.await() ?: emptyList() + val sorted = got.sortedBy { it.channel } + watcher.push(sorted.map { it.buffer })?.let { snap = it } + cycles += sorted.map { ch -> DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } } + imus += imu + onProgress(i) + delay(CYCLE_GAP_MS) + } + // 정렬과 같은 mean-scan · 같은 supine 분기 — 정렬 확인 측정의 채널 검출과 같은 식이어야 + // "정렬 때 잡혔던 채널" 과 대조가 성립한다. + val outcome = ClinicalBv.compute(ClinicalBv.meanScan(cycles), supine = supine) + return ReattachMeasurement(cycles, imus, outcome, snap ?: watcher.snapshot) to null + } finally { + ble.piezoCollector.onMultiChannelComplete = prev + imuSide.restore() + withContext(NonCancellable) { guard.restore() } + inbox.close() + } +} + +/** + * 정렬 카드 아래에 붙는 재부착 확인 카드. 부착 위치가 없으면 그리지 않는다. + * + * @param patient 저장 폴더(환자·날짜)를 정한다 — 정렬과 같은 `align/` 에 쓴다. + */ +@Composable +fun ReattachCheckCard( + appState: AppState, + bleManager: BleManager, + patient: String, + connected: Boolean, + enabled: Boolean, + supine: Boolean, +) { + val anchorCm = appState.anchorCm ?: return + var running by remember { mutableStateOf(false) } + var progress by remember { mutableIntStateOf(0) } + var error by remember { mutableStateOf(null) } + val result = appState.anchorReattach + + LaunchedEffect(running) { + if (!running) return@LaunchedEffect + error = null + progress = 0 + val (m, err) = measureForReattach(bleManager, supine) { progress = it } + if (m == null) { + error = err + running = false + return@LaunchedEffect + } + val now = m.outcome.walls.take(ReattachCheck.CENTER_CHANNELS).map { it != null } + val r = ReattachCheck.judge( + ref = appState.anchorRefChannels, + now = now, + detached = m.detach?.detachedChannels ?: emptyList(), + bvMl = m.outcome.volumeMl, + ) + appState.anchorReattach = r + // 정렬 폴더에 같이 남긴다 — 정렬 세션(601)에 phase=reattach 로 올라간다. + runCatching { + val dir = HospitalRunStore.alignDir(patient.ifBlank { "unnamed" }, Date()) + HospitalRunStore.writeAlignCycles(dir, anchorCm, m.cycles, HospitalRunStore.AlignFileKind.REATTACH) + HospitalRunStore.writeAlignImu(dir, anchorCm, m.cyclesImu, HospitalRunStore.AlignFileKind.REATTACH) + HospitalRunStore.updateAlignSummary(dir) { it.put("reattach", r.toJson()) } + HospitalLabdbUploader.uploadAlignAsync(dir) + } + running = false + } + + val tone = when { + running -> MlPrimary + result == null -> MlWarning + result.ok -> MlSuccess + else -> MlCritical + } + Column( + Modifier.fillMaxWidth() + .background(tone.copy(alpha = 0.08f), RoundedCornerShape(12.dp)) + .border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .padding(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + when { + running -> "재부착 확인 중" + result == null -> "재부착 확인 필요" + result.ok -> "✓ 재부착 확인 — ${result.label}" + else -> "⚠ 재부착 확인 — ${result.label}" + }, + fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { running = true }, enabled = connected && enabled && !running) { + Text( + if (result == null) "재부착 확인" else "다시 확인", + color = if (connected && enabled && !running) MlPrimary else Color.Unspecified, + ) + } + } + when { + running -> { + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { progress / AnchorConfig.ALIGN_CYCLES.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + "치골 위 ${anchorCm}cm 에서 측정 중 $progress/${AnchorConfig.ALIGN_CYCLES}", + fontSize = 12.sp, color = MlSecondaryText, + ) + } + result != null -> { + Spacer(Modifier.height(8.dp)) + ChannelCompareRow(result) + Spacer(Modifier.height(6.dp)) + Text( + buildString { + result.bvMl?.let { append("BV %.0f mL · ".format(it)) } + append( + if (result.hasReference) "정렬 확인 측정과 채널별 대조" + else "정렬 확인 측정이 없어 접촉 판정만 했습니다", + ) + if (!result.ok) append(" — 프로브를 다시 붙이고 [다시 확인]. 그대로 시작해도 기록에 남습니다.") + }, + fontSize = 12.sp, color = MlSecondaryText, + ) + } + else -> { + Spacer(Modifier.height(4.dp)) + Text( + "프로브를 뗐다 붙였으면(마킹 · 크래들) 측정 전에 한 번 재서 정렬 때와 같은 채널이 " + + "잡히는지 확인합니다. 치골 위 ${anchorCm}cm · ${AnchorConfig.ALIGN_CYCLES}회.", + fontSize = 12.sp, color = MlSecondaryText, + ) + } + } + error?.let { + Spacer(Modifier.height(6.dp)) + Text(it, fontSize = 12.sp, color = MlCritical) + } + } +} + +/** CH0~3 을 나란히 — 지금 잡힘/안 잡힘, 정렬 때 잡혔던 것과 다르면 빨강. */ +@Composable +private fun ChannelCompareRow(r: ReattachCheck.Result) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (ch in 0 until ReattachCheck.CENTER_CHANNELS) { + val now = r.channels.getOrNull(ch) == true + val ref = r.refChannels?.getOrNull(ch) + val bad = ch in r.missing || ch in r.detached + val c = when { + bad -> MlCritical + now -> MlSuccess + else -> MlSecondaryText + } + Box( + Modifier.weight(1f) + .background(c.copy(alpha = 0.12f), RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("CH$ch " + (if (now) "✓" else "✗"), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = c) + Text( + when (ref) { null -> "정렬 –"; true -> "정렬 ✓"; else -> "정렬 ✗" }, + fontSize = 10.sp, color = MlSecondaryText, + ) + } + } + } + Spacer(Modifier.width(0.dp)) + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/managers/ReattachCheckTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/ReattachCheckTest.kt new file mode 100644 index 0000000..52fbcca --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/ReattachCheckTest.kt @@ -0,0 +1,72 @@ +/* + * ReattachCheckTest — 재부착 확인 판정을 잠근다. + * + * 2026-09-29 임상: 정렬은 CH0~3 이 다 잡혔는데 크래들로 다시 붙이니 CH2 가 사라진 채 다 + * 쟀다. 잠그는 것은 셋이다 — 정렬 때 잡혔던 채널이 빠지면 실패, 탈착 감시가 확정 미부착으로 + * 본 채널이 있으면 실패, 정렬 기준이 없으면 "정렬과 같다"고 말하지 않는다. + */ +package com.medithings.vesiscan.managers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReattachCheckTest { + + private val all = listOf(true, true, true, true) + + @Test fun `정렬 때 잡힌 채널이 지금 없으면 실패이고 그 채널을 말한다`() { + val r = ReattachCheck.judge(ref = all, now = listOf(true, true, false, true), detached = emptyList(), bvMl = 210.0) + assertFalse(r.ok) + assertEquals(listOf(2), r.missing) + assertTrue(r.label.contains("CH2 없음")) + assertTrue(r.hasReference) + } + + @Test fun `정렬 때도 없던 채널은 지금 없어도 실패가 아니다`() { + val r = ReattachCheck.judge(ref = listOf(true, false, true, true), now = listOf(true, false, true, true), detached = emptyList(), bvMl = null) + assertTrue(r.ok) + assertEquals("정렬과 같음 (CH0~3)", r.label) + } + + @Test fun `탈착 감시가 미부착으로 본 채널이 있으면 실패다`() { + val r = ReattachCheck.judge(ref = all, now = all, detached = listOf(1), bvMl = null) + assertFalse(r.ok) + assertEquals(listOf(1), r.detached) + assertTrue(r.label.contains("CH1 미부착")) + } + + @Test fun `기준이 없으면 접촉 판정만 하고 그렇게 말한다`() { + val ok = ReattachCheck.judge(ref = null, now = listOf(true, true, false, true), detached = emptyList(), bvMl = null) + assertTrue("기준 없이 채널 소실을 실패로 잡으면 안 된다", ok.ok) + assertFalse(ok.hasReference) + assertEquals("접촉 정상 (정렬 기준 없음)", ok.label) + + val bad = ReattachCheck.judge(ref = null, now = all, detached = listOf(3), bvMl = null) + assertFalse(bad.ok) + } + + @Test fun `CH4·5 는 보지 않는다`() { + // 좌우 균형용 채널은 벽 검출 대상이 아니다 — 여섯 개를 넘겨도 앞 넷만 본다. + val r = ReattachCheck.judge( + ref = listOf(true, true, true, true, false, false), + now = listOf(true, true, true, true, false, true), + detached = listOf(5), bvMl = null, + ) + assertTrue(r.ok) + assertEquals(4, r.channels.size) + assertEquals(emptyList(), r.detached) + } + + @Test fun `기록 JSON 에 판정 근거가 다 들어간다`() { + val j = ReattachCheck.judge(ref = all, now = listOf(true, true, false, true), detached = listOf(0), bvMl = 123.4, at = 5L).toJson() + assertFalse(j.getBoolean("ok")) + assertEquals(2, j.getJSONArray("missing").getInt(0)) + assertEquals(0, j.getJSONArray("detached").getInt(0)) + assertEquals(4, j.getJSONArray("channels").length()) + assertEquals(4, j.getJSONArray("ref_channels").length()) + assertEquals(123.4, j.getDouble("bv_ml"), 1e-9) + assertEquals(5L, j.getLong("at")) + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt index 547fd00..884c4eb 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignFilesTest.kt @@ -57,6 +57,30 @@ class HospitalRunStoreAlignFilesTest { assertTrue(lines[3].startsWith("1,0,")) } + @Test fun `좌우 흐름 기록은 헤더 한 번, 프레임마다 6줄이고 이어 쓴다`() { + val d = tmp.newFolder("align") + HospitalRunStore.LateralStreamWriter(d, 3).use { w -> + w.frame(1, 0, frames(1)[0], listOf(sample(0.9f)), u4 = 12, u5 = 15, imbalance = 3, action = "MOVE_LEFT", ch3 = true) + w.frame(1, 1, frames(1)[0], emptyList(), u4 = null, u5 = null, imbalance = null, action = null, ch3 = null) + } + // 다시 열면(스트리밍 재시작) 헤더 없이 이어진다 — attempt 열로 시도를 가른다. + HospitalRunStore.LateralStreamWriter(d, 3).use { w -> + w.frame(2, 2, frames(1)[0], emptyList(), 1, 2, 1, "STOP", false) + } + val lines = File(d, "align_3cm_lateral_stream.csv").readLines() + assertEquals(1 + 18, lines.size) + assertTrue(lines[0].startsWith("attempt,frame_idx,ch,u4,u5,imbalance,action,ch3,s0")) + assertEquals("1,0,0,12,15,3,MOVE_LEFT,1,0,1,2,3", lines[1]) + assertEquals("1,1,0,,,,,,0,1,2,3", lines[7]) + assertEquals("2,2,5,1,2,1,STOP,0,50,51,52,53", lines[18]) + assertEquals(1, lines.count { it.startsWith("attempt,") }) + + val imu = File(d, "align_3cm_lateral_stream_imu.csv").readLines() + assertEquals("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps", imu[0]) + assertEquals(2, imu.size) + assertTrue(imu[1].startsWith("0,0,")) + } + @Test fun `IMU 가 하나도 없으면 파일을 만들지 않는다`() { // 부재가 곧 "그때는 안 쟀다" 다 — 빈 파일이 있으면 받는 쪽이 0 샘플로 읽는다. val d = tmp.newFolder("align") 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 a63da71..75d8dc0 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 @@ -1,7 +1,9 @@ package com.medithings.vesiscan.services.labdb +import com.medithings.vesiscan.services.HospitalRunStore import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -163,6 +165,43 @@ class AlignLabdbPayloadTest { assertEquals(10, params.getInt("lateral_frames")) } + @Test fun `좌우 흐름 전체와 재부착 확인도 phase 로 올라간다`() { + // 2026-09-30: 좌우 스트리밍 전체(프레임 + 그 프레임의 판정)와, 뗐다 붙인 뒤의 + // 재부착 확인 측정. 둘 다 부착 위치 cm 이고 위치 수에는 안 더한다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + val sb = StringBuilder(HospitalRunStore.LateralStreamWriter.waveHeader(4)).append('\n') + for (fi in 0 until 2) for (ch in 0 until 6) { + sb.append("1,$fi,$ch,12,15,3,MOVE_LEFT,1,900,900,3500,900\n") + } + File(d, "align_3cm_lateral_stream.csv").writeText(sb.toString(), Charsets.UTF_8) + File(d, "align_3cm_reattach.csv").writeText( + (0 until 6).joinToString("") { ch -> "0,$ch,900,900,3500,900\n" }, Charsets.UTF_8) + + val p = AlignLabdbPayload.build(d)!! + val recs = p.getJSONArray("records") + assertEquals(4, p.getInt("recordCount")) + assertEquals( + listOf("sweep", "lateral_stream", "lateral_stream", "reattach"), + (0 until 4).map { recs.getJSONObject(it).getString("phase") }, + ) + val lat = recs.getJSONObject(1).getJSONObject("lateral") + assertEquals(12, lat.getInt("u4")) + assertEquals(15, lat.getInt("u5")) + assertEquals(3, lat.getInt("imbalance")) + assertEquals("MOVE_LEFT", lat.getString("action")) + assertTrue(lat.getBoolean("ch3")) + assertEquals(1, lat.getInt("attempt")) + assertEquals(6, recs.getJSONObject(1).getJSONArray("channels").length()) + assertEquals(4, recs.getJSONObject(1).getJSONArray("channels").getJSONObject(0).getJSONArray("data").length()) + assertFalse(recs.getJSONObject(3).has("lateral")) + + val params = p.getJSONObject("params") + assertEquals(1, params.getInt("positions_measured")) + assertEquals(2, params.getInt("lateral_stream_frames")) + assertEquals(1, params.getInt("reattach_frames")) + } + @Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() { val d = tmp.newFolder("align") writeCycles(d, 0, cycles = 1) diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index 9faf289..4557b7d 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -385,7 +385,12 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1 ↓ 치골 위 N+1 cm 에 붙이세요 (초록 · 여기서는 측정하지 않음) ↓ -좌우 정렬 (치골 위 N+1 cm 그대로) +좌우 정렬 (치골 위 N+1 cm 그대로) ← 흐름 전체 + 확인 완료 순간의 창을 저장 + ↓ +(위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기) + ↓ +재부착 확인 — 병원 화면 정렬 카드에서 20 cycle 재서 정렬 확인 측정과 CH0~3 을 채널별로 대조 + (2026-09-29 임상: 이 사이에 CH2 가 사라졌다). 경고만, 막지 않음. 기록에 남음. ``` 지시문의 숫자는 **치골 위 절대 위치 하나**입니다(2026-09-29). "2cm 내리고" 같은 상대 diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 0a9d7c6..8b59c32 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -175,6 +175,10 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만) | | `confirm_positions` | int | 확인 측정한 위치 수 | | `lateral_frames` | int | 좌우 확인 완료 순간에 남긴 프레임 수 (2026-09-30~). 0 = 좌우 미확인 또는 옛 앱 | +| `lateral_stream_frames` | int | 좌우 정렬 동안 흘린 프레임 전부 (2026-09-30~) | +| `reattach_frames` | int | 재부착 확인 측정 cycle 수 (2026-09-30~). 0 = 안 했음 | +| `confirm_channels` | bool[4] | 정렬 확인 측정에서 벽이 잡힌 채널 CH0~3 (2026-09-30~). 재부착 확인의 기준 | +| `reattach` | object | 재부착 확인 판정 `{ok, missing[], detached[], channels[4], ref_channels[4]\|null, bv_ml, at}` — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지 | | `cycles_total` | int | = `recordCount` | | `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 | | `device`, `firmware_version`, `hw_preset` | string | | @@ -324,6 +328,11 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( - `phase: "lateral"` (2026-09-30~) 은 간호사가 "좌우 확인 완료"를 누른 순간의 **마지막 프레임 창**(판정 1회에 쓰는 10프레임)입니다. `align_cm` 은 부착 위치(best + 오프셋)이고, `cycle_idx` 는 그 창 안의 순서입니다. 좌우 정렬 판정을 나중에 대조하는 데 씁니다. +- `phase: "lateral_stream"` (2026-09-30~) 은 좌우 정렬 동안 흘린 **프레임 전부**입니다. + 레코드마다 `lateral: {attempt, u4, u5, imbalance, action, ch3}` 가 붙습니다 — 그 프레임까지의 + 판정(앱이 화면에 보인 것). `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오릅니다. +- `phase: "reattach"` (2026-09-30~) 은 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리에서 병원 + 화면이 20 cycle 잰 **재부착 확인 측정**입니다. 판정은 `params.reattach` 에. ## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오 @@ -360,9 +369,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 · align_{n}cm_lateral_imu.csv +601 align_{n}cm_imu.csv · …_confirm_imu.csv · …_lateral_imu.csv · …_lateral_stream_imu.csv · …_reattach_imu.csv cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps - → 파형과 같은 파일명 규칙(confirm/lateral 분리)을 그대로 따릅니다 + → 파형과 같은 파일명 규칙(종류별 분리)을 그대로 따릅니다 ``` 파형 행(`meta + s0..s99`)을 넓히지 않은 이유: 그 헤더는 이미 올라간 데이터와 파서가