feat(hospital): 재부착 확인 단계 · 좌우 스트리밍 전체 기록 · 정렬 파형을 위로 (2026-09-29 임상 후속)

임상에서 정렬 → 위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기 사이에 CH2 가 사라진 채
18조합을 다 쟀다. 좌우는 실시간 판정만 하고 아무것도 남기지 않아 "그 전에는 있었나"를
대조할 수도 없었다. 세 가지를 넣는다.

1. 재부착 확인 (병원 화면 정렬 카드 아래, ReattachCheckCard)
   · 부착 위치에서 정렬 조건(2.3MHz·c3·20 cycle)으로 재고, 정렬 확인 측정에서 잡힌
     채널(AppState.anchorRefChannels ← 확인 측정의 walls)과 CH0~3 을 채널별로 대조
     + 탈착 감시. 판정은 ReattachCheck.judge (순수 함수, 시험 6건).
   · 막지 않는다 — 측정 시작 아래 경고, 측정 중 한 줄 요약에 "재부착 ⚠", run 매니페스트
     `anchor_reattach`, align_result.json `reattach`, 601 에 phase=reattach 로 파형·IMU.
   · AppState.clearAnchor() — 부착 위치와 딸린 것(근거·기준 채널·재부착)을 한꺼번에 비운다.
2. 좌우 스트리밍 전체 기록 (HospitalRunStore.LateralStreamWriter)
   · 프레임마다 6채널 + 그 프레임의 판정(u4·u5·imbalance·action·ch3)을
     align_{n}cm_lateral_stream.csv 에 덧붙여 쓴다(앱이 죽어도 그때까지 남음).
     `attempt` 열로 [처음부터 다시 정렬] 시도를 가른다 — 앞 시도의 비동기 업로드가 파일을
     읽고 있을 수 있어 지우지 않는다. 601 에 phase=lateral_stream + `lateral{…}`.
3. 정렬 화면 파형 1차 개편 — 파형을 용적·접촉보다 위에 더 크게(170dp), 그 위에 채널별
   벽 검출 띠(CH0~3 ✓/✗, CH4·5 는 좌우용 회색). "CH2 없음"이 파형보다 먼저 보인다.

시험 88건 통과(전체 174 · skipped 3 · failures 0). 실기(프로브 필요)는 다음 임상에서.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 14:39:25 +09:00
parent c8964927ce
commit 8ef7c64ba6
12 changed files with 828 additions and 44 deletions
@@ -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<Boolean>? 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
}
/**
* 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.**
*
@@ -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<Int>,
/** 탈착 감시가 확정 미부착으로 본 채널(CH0~3). */
val detached: List<Int>,
/** 지금 잡힌 채널(CH0~3). */
val channels: List<Boolean>,
/** 정렬 확인 측정 때 잡힌 채널. null = 기준 없음. */
val refChannels: List<Boolean>?,
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<Boolean>?,
now: List<Boolean>,
detached: List<Int>,
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,
)
}
}
@@ -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<DoubleArray>, samples: List<ImuSample>,
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/` 에 둔다. */
@@ -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<Int, MutableMap<Int, IntArray>>()
runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line ->
if (line.isBlank()) return@forEach
val metaByCycle = linkedMapOf<Int, JSONObject>()
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
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)와 선택 결과가 파형과 같이 있어야 한다.
@@ -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<Pair<List<DoubleArray>, List<com.medithings.vesiscan.ble.ImuSample>>>() }
/** 좌우 스트리밍 전체 기록의 프레임 번호 — 다시 시작해도 이어진다([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<ClinicalBv.ChannelWall?>?) {
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<Boolean>? = 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
@@ -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}) — 다시 붙이고 [다시 확인]. 그대로 시작하면 기록에 남습니다.")
}
// 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상
@@ -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<List<DoubleArray>>,
val cyclesImu: List<List<ImuSample>>,
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<ReattachMeasurement?, String?> {
val inbox = Channel<List<PiezoChannelData>>(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<List<DoubleArray>>(AnchorConfig.ALIGN_CYCLES)
val imus = ArrayList<List<ImuSample>>(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<String?>(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))
}
}
@@ -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<Int>(), 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"))
}
}
@@ -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")
@@ -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)
+6 -1
View File
@@ -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 내리고" 같은 상대
+11 -2
View File
@@ -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`)을 넓히지 않은 이유: 그 헤더는 이미 올라간 데이터와 파서가