diff --git a/README.md b/README.md index 99e0884..e26ec9e 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ VesiScan **병원 임상 측정 앱** (Android · Kotlin · Compose). 정해진 ## 화면 흐름 (병원 임상) -연결 → labdb 등록 확인 → 정렬(치골 위 0~4cm 탐색 → N cm 확인 → N+1cm 좌우) → 마킹·떼기· -크래들 → **재부착 확인** → 자세·채움 정도마다 측정. 자세한 것은 +연결 → labdb 등록 확인 → 환자명 · 자세 → **그 자세의** 정렬(치골 위 0~4cm 탐색 → N cm 확인 → +N+1cm 좌우) → 마킹·떼기·크래들 → **재부착 확인** → 채움 정도마다 측정 → 다음 자세는 정렬부터 다시. 자세한 것은 [`docs/CLINICAL_ALGORITHM.md`](docs/CLINICAL_ALGORITHM.md) §3.6, labdb 적재 모양은 [`docs/LABDB_DATATYPES.md`](docs/LABDB_DATATYPES.md) (600 측정 · 601 정렬). ## 데이터 -폰 `Downloads/VesiScan_Hospital/{날짜}_{환자}/` 에 조합별 CSV + IMU, `align/` 에 정렬 -원시·요약. labdb 업로드 마커(`*.labdb.json`, `align/.labdb_align.json`)로 재시도 대상을 가린다. +폰 `Downloads/VesiScan_Hospital/{날짜}_{환자}/` 에 조합별 CSV + IMU, `align_{자세}/` 에 그 자세의 +정렬 원시·요약(2026-10-01 이전은 `align/`). labdb 업로드 마커(`*.labdb.json`, `align_*/.labdb_align.json`)로 +재시도 대상을 가린다. diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index c468c4d..14cf7b1 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -39,42 +39,57 @@ class AppState(private val context: Context) { * 연결 화면까지 엉뚱한 곳으로 보낸다. */ /** - * 정렬로 정한 부착 위치(cm). 병원 임상 모드가 표시·기록에 쓴다. + * 한 자세의 부착 위치와 딸린 것. **자세마다 따로 산다**(2026-10-01 — 임상 프로토콜이 + * 자세마다 정렬을 다시 한다). 네 값은 같이 넣고 같이 비운다 — 하나만 남으면 그 순간부터 + * 어긋난다(환자가 바뀌었는데 재부착 확인이 ✓ 로 남는 식). * - * 자세·용량이 바뀌어도 이 위치는 **고정**이라 한 환자 동안 유지된다. - * 환자가 바뀜면 다시 정렬해야 하므로 병원 모드에서 환자명을 바꿀 때 비운다. + * @property cm 정렬로 정한 부착 위치. 그 자세의 전 용량에서 고정. + * @property basis 누가 정했는지. null = 정렬을 거치지 않았다. 정렬은 실제 인체에서 끝까지 + * 안 맞을 수 있어 진행을 막지 않으므로, 알고리즘이 고른 자리와 사람이 고른 자리를 + * 나중에 가를 유일한 단서다. + * @property refChannels 정렬 좌우 확인 창(없으면 확인 측정)에서 잡힌 채널 CH0~3 — + * 재부착 확인([com.medithings.vesiscan.managers.ReattachCheck])의 기준. null = 기준 없음. + * @property reattach 재부착 확인 결과. null = 아직 안 했다. */ - var anchorCm: Int? by mutableStateOf(null) + data class AnchorState( + val cm: Int? = null, + val basis: com.medithings.vesiscan.managers.AnchorBasis? = null, + val refChannels: List? = null, + val reattach: com.medithings.vesiscan.managers.ReattachCheck.Result? = null, + ) - /** - * [anchorCm] 을 **누가** 정했는지. null = 정렬을 거치지 않았다. - * - * 정렬은 실제 인체에서 끝까지 안 맞을 수 있어 진행을 막지 않는다. 그래서 위치만 - * 들고 다니면 알고리즘이 고른 자리와 사람이 고른 자리가 한 덩어리로 쌓이고, 나중에 - * 정확도 검증의 모집단을 가려낼 수 없다. **항상 [anchorCm] 과 같이 비우고 같이 - * 넣는다** — 하나만 남으면 그 순간부터 둘이 어긋난다. - */ - var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null) + /** 자세별 부착 위치. [clinicalPosture] 가 가리키는 항목을 아래 접근자가 읽고 쓴다. */ + val anchors = androidx.compose.runtime.mutableStateMapOf< + com.medithings.vesiscan.models.ClinicalPosture, AnchorState>() - /** - * 정렬 **확인 측정**에서 잡힌 채널(CH0~3). 재부착 확인([ReattachCheck])의 기준. - * null = 기준 없음 — 사람이 고른 위치이거나 확인 측정 전에 진행했다. - */ - var anchorRefChannels: List? by mutableStateOf(null) + private fun anchorNow(): AnchorState = anchors[clinicalPosture] ?: AnchorState() + private fun updateAnchor(f: (AnchorState) -> AnchorState) { anchors[clinicalPosture] = f(anchorNow()) } - /** 재부착 확인 결과. null = 아직 안 했다. 부착 위치와 같이 비운다. */ - var anchorReattach: com.medithings.vesiscan.managers.ReattachCheck.Result? by mutableStateOf(null) + /** 지금 자세의 부착 위치(cm). 병원 임상 모드가 표시·기록에 쓴다. */ + var anchorCm: Int? + get() = anchorNow().cm + set(v) = updateAnchor { it.copy(cm = v) } - /** - * 부착 위치와 딸린 것(근거·기준 채널·재부착 확인)을 **한꺼번에** 비운다. - * 하나만 남으면 그 순간부터 서로 어긋난다 — 환자가 바뀌었는데 재부착 확인이 ✓ 로 남는 식. - */ - fun clearAnchor() { - anchorCm = null - anchorBasis = null - anchorRefChannels = null - anchorReattach = null - } + /** 지금 자세의 [AnchorState.basis]. */ + var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? + get() = anchorNow().basis + set(v) = updateAnchor { it.copy(basis = v) } + + /** 지금 자세의 [AnchorState.refChannels]. */ + var anchorRefChannels: List? + get() = anchorNow().refChannels + set(v) = updateAnchor { it.copy(refChannels = v) } + + /** 지금 자세의 [AnchorState.reattach]. */ + var anchorReattach: com.medithings.vesiscan.managers.ReattachCheck.Result? + get() = anchorNow().reattach + set(v) = updateAnchor { it.copy(reattach = v) } + + /** 지금 자세의 부착 위치와 딸린 것을 비운다. */ + fun clearAnchor() { anchors.remove(clinicalPosture) } + + /** 전 자세를 비운다 — 환자가 바뀌거나 프로브가 바뀔 때(빔 각도가 달라 정렬이 무효). */ + fun clearAllAnchors() { anchors.clear() } /** * 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.** 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 a8a83c7..62f738a 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -335,13 +335,27 @@ object HospitalRunStore { } } - /** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */ - fun alignDir(patient: String, startedAt: Date): File { - val dir = File(runDir(patient, startedAt), "align") + /** + * 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 **`align_{자세}/`** 에 둔다 + * (2026-10-01 — 자세마다 정렬을 다시 하므로 자세별로 가른다. 같은 `align/` 에 쓰면 + * Supine 정렬 원시 데이터가 Sitting 정렬로 덮인다). 2026-10-01 이전 폴더는 `align/`. + */ + fun alignDir(patient: String, startedAt: Date, posture: com.medithings.vesiscan.models.ClinicalPosture): File { + val dir = File(runDir(patient, startedAt), alignDirName(posture)) if (!dir.exists()) dir.mkdirs() return dir } + fun alignDirName(posture: com.medithings.vesiscan.models.ClinicalPosture): String = + "align_" + posture.name.lowercase() + + /** `align`(옛) 또는 `align_{자세}` — 정렬 폴더인가. 업로드 대상 탐색이 쓴다. */ + val ALIGN_DIR_RE = Regex("""^align(_[a-z]+)?$""") + + /** 폴더 이름의 자세 꼬리(`_sitting`). 옛 `align` 이면 빈 문자열. labdb 세션 이름이 쓴다. */ + fun alignPostureSuffix(alignDir: File): String = + alignDir.name.removePrefix("align").takeIf { it.startsWith("_") }.orEmpty() + /** * 한 위치의 cycle 전부를 CSV 로 저장한다. * 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 3595891..33d0ffb 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 @@ -262,7 +262,7 @@ object AlignLabdbPayload { private val RUN_DIR_RE = Regex("""^\d{4}-\d{2}-\d{2}_.+""") /** - * 세션 이름(= testId 의 바탕). **`<날짜_환자>_align[_HHmm]`.** + * 세션 이름(= testId 의 바탕). **`<날짜_환자>_align[_자세][_HHmm]`.** * * 2026-09-29 까지는 `<환자명>_align` 이었다 — 날짜가 없다. labdb 는 같은 testId 를 다시 * 받으면 rowIndex 가 같은 레코드를 **조용히 무시**한다(labdb.md "동일 testId+rowIndex → @@ -281,7 +281,10 @@ object AlignLabdbPayload { val hhmm = summary?.optString("started_at").orEmpty() .let { Regex("""T(\d{2}):(\d{2})""").find(it) } ?.let { it.groupValues[1] + it.groupValues[2] } - return root + "_align" + (hhmm?.let { "_$it" } ?: "") + // 자세별 정렬 폴더(`align_sitting`)면 그 꼬리를 붙인다 — 같은 날 같은 환자의 자세별 + // 세션이 갈려야 한다(2026-10-01). 옛 `align/` 은 꼬리 없음. + val posture = com.medithings.vesiscan.services.HospitalRunStore.alignPostureSuffix(alignDir) + return root + "_align" + posture + (hhmm?.let { "_$it" } ?: "") } /** `^[A-Za-z0-9_-]{1,40}$`. 넘치면 앞 31자 + SHA-1 앞 8자 — 규칙은 임상 쪽과 같다. */ diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt index 2d5a45a..f0c7c98 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt @@ -100,12 +100,21 @@ object HospitalLabdbUploader { * raw CSV 가 하나도 없으면 대상이 아니다 — 정렬 화면에 들어갔다 아무것도 재지 않고 나오면 * 빈 폴더만 남는데(실측: `2026-09-10_unnamed_123015`), 그걸 올리려 들면 영원히 실패한다. */ - fun pendingAlign(runDir: File): File? { - val dir = File(runDir, "align") - if (!dir.isDirectory || isAlignUploaded(dir)) return null - val hasRaw = dir.listFiles()?.any { it.isFile && ALIGN_CSV_RE.matches(it.name) } == true - return dir.takeIf { hasRaw } - } + fun pendingAlign(runDir: File): File? = pendingAligns(runDir).firstOrNull() + + /** + * 그 환자·날짜 폴더의 미업로드 정렬 폴더 **전부** — 옛 `align/` 과 자세별 `align_{자세}/` + * (2026-10-01, 자세마다 정렬). 이름순이라 `align`, `align_sitting`, `align_standing`, `align_supine`. + */ + fun pendingAligns(runDir: File): List = + runDir.listFiles() + ?.filter { it.isDirectory && com.medithings.vesiscan.services.HospitalRunStore.ALIGN_DIR_RE.matches(it.name) } + ?.sortedBy { it.name } + ?.filter { dir -> + !isAlignUploaded(dir) && + dir.listFiles()?.any { it.isFile && ALIGN_CSV_RE.matches(it.name) } == true + } + ?: emptyList() /** * 폴더에서 아직 안 올라간 조합 CSV 를 찾는다. @@ -273,7 +282,7 @@ object HospitalLabdbUploader { */ fun pendingAll(): List = com.medithings.vesiscan.services.HospitalRunStore.allRunDirs() - .flatMap { pending(it) + listOfNotNull(pendingAlign(it)) } + .flatMap { pending(it) + pendingAligns(it) } /** * 저장된 **전 폴더**의 미업로드 CSV 를 전부 올린다. @@ -296,7 +305,7 @@ object HospitalLabdbUploader { } running = true try { - val targets = dirs.flatMap { pending(it) + listOfNotNull(pendingAlign(it)) } + val targets = dirs.flatMap { pending(it) + pendingAligns(it) } pendingCount.intValue = targets.size if (targets.isEmpty()) { lastMessage.value = "올릴 것이 없습니다." 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 904a5b0..42cc2c8 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 @@ -230,6 +230,7 @@ fun AnchorAlignView(appState: AppState) { // 환자명을 나중에 치면 측정은 그 환자 폴더에 쓰였는데 업로드는 `unnamed_…` 빈 폴더를 // 보고 "원시 데이터가 없습니다"로 끝난다 — 그 정렬은 자동으로는 영영 안 올라간다. val saveNameNow = rememberUpdatedState(saveName) + val postureNow = rememberUpdatedState(appState.clinicalPosture) // ── 판정: **잰 위치 전부**로 고른다 ──────────────────────────────────── // 2026-09-10 결정. 종전에는 AnchorGuide 의 단일 pass 답(종료 시점까지의 후보)을 썼다. @@ -297,7 +298,7 @@ fun AnchorAlignView(appState: AppState) { DisposableEffect(Unit) { onDispose { if (hasAlignData && alignDirty) { - val dir = HospitalRunStore.alignDir(saveNameNow.value, runStartedAt) + val dir = HospitalRunStore.alignDir(saveNameNow.value, runStartedAt, postureNow.value) HospitalLabdbUploader.uploadAlignAsync(dir) alignDirty = false } @@ -391,7 +392,7 @@ fun AnchorAlignView(appState: AppState) { // 환자명이 비어 있어도 저장한다. 종전에는 이름이 없으면 그냥 안 남겼는데, // 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서 // 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다. - val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) hasAlignData = true alignDirty = true // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. @@ -431,7 +432,7 @@ fun AnchorAlignView(appState: AppState) { dir, buildAlignSummary(saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, step, - decidedAtCm = decidedAtCm, startedAt = runStartedAt), + decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label), ) // ── 다음 위치 ────────────────────────────────────────────────── // **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 @@ -483,7 +484,7 @@ fun AnchorAlignView(appState: AppState) { // 맞추기 자체를 막을 이유는 아니다. val stream = runCatching { HospitalRunStore.LateralStreamWriter( - HospitalRunStore.alignDir(saveName, runStartedAt), anchorTarget ?: cm) + HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture), anchorTarget ?: cm) }.getOrNull() val streamStart = lrStreamIdx try { @@ -586,7 +587,7 @@ fun AnchorAlignView(appState: AppState) { onClick = { uploadBusy = true uploadScope.launch { - val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) val (ok, msg) = HospitalLabdbUploader.uploadAlign(dir, uploadMemo) uploadMsg = if (ok) "보냈습니다 — $msg" else "실패 — $msg" uploadBusy = false @@ -627,7 +628,7 @@ fun AnchorAlignView(appState: AppState) { enabled = !measuring, onClick = { if (isConnected) { - appState.clearAnchor() + appState.clearAllAnchors() bleManager.disconnect() } else { appState.connectReturnScreen = AppScreen.ANCHOR_ALIGN @@ -653,10 +654,10 @@ fun AnchorAlignView(appState: AppState) { ) Spacer(Modifier.height(10.dp)) - // 자세 — 여기서도 고른다(2026-09-29). 병원 화면에서 정렬 카드가 자세 칩보다 위에 - // 있어(연결 → labdb → 정렬) 자세를 안 고른 채 들어오는 것이 정상 경로다. 값은 - // AppState 공유라 돌아가면 그대로다. 위 카드의 요구 범위와 BV 의 supine 분기가 - // 이 값을 본다. 측정·좌우 스캔 중에는 바꾸지 못한다 — 그 위치 데이터의 조건이다. + // 자세 — 여기서도 고른다(2026-09-29). 값은 AppState 공유라 돌아가면 그대로다. 위 + // 카드의 요구 범위와 BV 의 supine 분기가 이 값을 본다. **정렬은 자세마다 따로** + // (2026-10-01) — 저장 폴더(`align_{자세}`)·부착 위치·labdb 세션이 자세별이라, 첫 + // 위치를 재고 나면 잠근다. 바꾸려면 [처음부터 다시 정렬]. Text("자세", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) Row( Modifier.fillMaxWidth().padding(top = 6.dp), @@ -668,7 +669,7 @@ fun AnchorAlignView(appState: AppState) { Modifier .weight(1f) .background(if (on) MlPrimary else Color.White, RoundedCornerShape(10.dp)) - .clickable(enabled = !measuring && !lrRunning) { appState.clinicalPosture = p } + .clickable(enabled = !measuring && !lrRunning && !hasAlignData) { appState.clinicalPosture = p } .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { @@ -679,6 +680,11 @@ fun AnchorAlignView(appState: AppState) { } } } + Text( + if (hasAlignData) "이 정렬은 ${appState.clinicalPosture.label} 의 것입니다 — 자세를 바꾸려면 [처음부터 다시 정렬]" + else "정렬은 자세마다 따로 합니다. 지금 잴 자세를 먼저 고르세요.", + fontSize = 11.sp, color = MlSecondaryText, modifier = Modifier.padding(top = 6.dp), + ) Spacer(Modifier.height(12.dp)) Text( @@ -896,7 +902,7 @@ fun AnchorAlignView(appState: AppState) { // 때 정렬 업로드(601)에 phase=lateral 로 같이 올라간다. if (lrRecent.isNotEmpty()) { runCatching { - val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) val atCm = anchorTarget ?: cm val frames = lrRecent.toList() HospitalRunStore.writeAlignCycles( @@ -920,14 +926,14 @@ fun AnchorAlignView(appState: AppState) { // 좌우 미확인 세션을 가려낼 유일한 근거다. last?.let { s -> runCatching { - val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) HospitalRunStore.writeAlignSummary( dir, buildAlignSummary( saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lateralConfirmed = true, - decidedAtCm = decidedAtCm, startedAt = runStartedAt, + decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, ), ) } @@ -969,14 +975,14 @@ fun AnchorAlignView(appState: AppState) { // 이 위치가 어떻게 정해졌는지 알 길이 없다. last?.let { s -> runCatching { - val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) HospitalRunStore.writeAlignSummary( dir, buildAlignSummary( saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm, - startedAt = runStartedAt, + startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmChannels = appState.anchorRefChannels, ), ) @@ -1014,7 +1020,7 @@ fun AnchorAlignView(appState: AppState) { // 덮어쓰므로, 여기서 안 올리면 그 데이터는 어디에도 남지 않는다. if (hasAlignData && alignDirty) { HospitalLabdbUploader.uploadAlignAsync( - HospitalRunStore.alignDir(saveName, runStartedAt)) + HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture)) alignDirty = false } runStartedAt = Date() @@ -1491,7 +1497,10 @@ private fun buildAlignSummary( startedAt: Date? = null, /** 확인 측정에서 잡힌 채널(CH0~3) — 재부착 확인([ReattachCheck])이 대조하는 기준. */ confirmChannels: List? = null, + /** 이 정렬의 자세(라벨). 자세마다 정렬이 따로라 요약에도 박는다(2026-10-01). */ + posture: String? = null, ): JSONObject = JSONObject().apply { + posture?.let { put("posture", it) } 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` 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 960aada..c578122 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,12 +17,12 @@ * 측정하는 동안 실제로 보는 것은 넷이다 — **기울기 · 채움 정도 · 채널 접촉 · 시작/중지**. * 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름만 둔다: * - * 연결 → labdb → 정렬(+재부착 확인) → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중) + * 연결 → labdb → 환자명 → 자세 → 정렬(+재부착 확인) → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중) * - * 정렬은 위에 둔다(2026-09-29 오후) — 접는 구간에 넣어 봤더니 환자마다 한 번은 반드시 - * 펴야 해서 불편했다. 프로브를 붙이는 순서가 곧 화면 순서다: 연결하고, 붙일 자리를 - * 찾고, 그 다음이 환자·자세·측정이다. 자세는 정렬 화면에서도 고를 수 있으므로 - * (AppState.clinicalPosture 공유) 정렬이 자세보다 위에 있어도 요구 각도 띠는 거기서 뜬다. + * 정렬은 접는 구간이 아니라 위에 둔다(2026-09-29 오후) — 환자마다 한 번은 반드시 펴야 + * 해서 불편했다. **자세가 정렬보다 위다**(2026-10-01): 임상 프로토콜이 자세마다 정렬을 + * 다시 하므로 부착 위치·재부착 확인·정렬 폴더·labdb 세션이 전부 자세별이다. 자세 칩을 + * 바꾸면 정렬 카드가 그 자세의 상태(미정렬/확정/재부착)로 바뀐다. * * 세션에 한 번 만지거나 안 만지는 것은 아래 접는 구간([Collapsible])이다 — 측정 조건 * (반복·두께), 회차 상세(용적·파형), labdb 업로드·기록, 수동 확인(프로브 파라미터 · @@ -434,7 +434,7 @@ fun HospitalModeView(appState: AppState) { // 다시 시도하는 데가 없었다. 그 순간 망이 막혀 있었으면(병원 무선망) 정렬만 // 빠진 채 조합만 올라간다 — 2026-09-29 임상. 실행이 끝날 때마다 이 폴더의 // 미업로드 정렬을 한 번 더 민다. 올라간 것은 마커로 걸러져 다시 보내지 않는다. - HospitalLabdbUploader.pendingAlign(dir)?.let { HospitalLabdbUploader.uploadAlignAsync(it) } + HospitalLabdbUploader.pendingAligns(dir).forEach { HospitalLabdbUploader.uploadAlignAsync(it) } // 전 폴더 기준으로 센다 — 지난 측정이 남아 있으면 그것도 보여야 한다. pendingUploads = HospitalLabdbUploader.pendingAll().size running = false @@ -483,7 +483,7 @@ fun HospitalModeView(appState: AppState) { // BV·파형·접촉은 여기서 비우지 않는다. 위의 // LaunchedEffect(isConnected) 가 **끊기는 모든 경우**를 // 맡는다 — 프로브가 스스로 끊길 때도 같아야 한다. - appState.clearAnchor() + appState.clearAllAnchors() bleManager.disconnect() } else { appState.connectReturnScreen = AppScreen.HOSPITAL_MODE @@ -539,38 +539,15 @@ fun HospitalModeView(appState: AppState) { LabdbStatusCard(enabled = true) Spacer(Modifier.height(12.dp)) - // 3. 부착 위치 정렬 — 연결 여부로 막지 않는다(2026-09-28). 정렬 화면에 - // [기기 연결] 이 있고 측정 버튼은 거기서 연결을 본다. 들어가서 붙이면 된다. - AnchorCard( - anchorCm = appState.anchorCm, - basis = appState.anchorBasis, - 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. 환자명 + // 3. 환자명 OutlinedTextField( value = patient, onValueChange = { if (!running) { - // 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면 - // 매니페스트에 틀린 위치가 기록된다. + // 환자가 바뀌면 정렬도 무효다(전 자세). 앞 환자의 cm 을 그대로 들고 + // 가면 매니페스트에 틀린 위치가 기록된다. if (it != patient) { - appState.clearAnchor() + appState.clearAllAnchors() } patient = it } @@ -582,12 +559,37 @@ fun HospitalModeView(appState: AppState) { ) Spacer(Modifier.height(12.dp)) - // 4. 자세 — 기울기 카드·정렬보다 위. 카드의 요구 범위(Sitting 35°~55°)와 정렬의 - // BV supine 분기가 이 값을 본다. + // 4. 자세 — 정렬보다 위(2026-10-01). 정렬·부착 위치·재부착 확인이 자세별이라, + // 자세를 먼저 고르고 그 자세로 정렬한다. 기울기 카드의 요구 범위(Sitting 35°~55°)와 + // BV 의 supine 분기도 이 값을 본다. Label("자세") ChipRow(ClinicalPosture.entries, posture, { posture = it }, true, progressOf = { postureProgress(it) }) { it.label } Spacer(Modifier.height(12.dp)) + + // 5. 부착 위치 정렬 — **지금 고른 자세의** 정렬. 연결 여부로 막지 않는다 + // (2026-09-28). 정렬 화면에 [기기 연결] 이 있고 측정 버튼은 거기서 연결을 본다. + AnchorCard( + anchorCm = appState.anchorCm, + basis = appState.anchorBasis, + posture = posture, + enabled = true, + onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN }, + ) + // 5-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)) } // 5. 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 이면 카드 안에 요구 범위가 뜬다. @@ -1044,6 +1046,8 @@ private fun AnchorCard( anchorCm: Int?, /** 그 위치를 누가 정했는지. null = 정렬을 거치지 않았다. */ basis: AnchorBasis?, + /** 이 카드가 보여 주는 자세 — 정렬은 자세마다 따로다(2026-10-01). */ + posture: ClinicalPosture, enabled: Boolean, onAlign: () -> Unit, ) { @@ -1060,11 +1064,11 @@ private fun AnchorCard( Row(verticalAlignment = Alignment.CenterVertically) { Text( when { - !set -> "부착 위치 미정렬" + !set -> "${posture.label} 부착 위치 미정렬" // 정렬을 마치고 넘어온 직후다. 체크 표시가 없으면 옆의 [위치 다시 // 찾기] 버튼이 "다시 해야 한다"는 지시로 읽힌다(2026-09-10 피드백). - basis?.isAlgorithm == true -> "✓ 부착 위치 ${anchorCm}cm 확정" - else -> "✓ 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" + basis?.isAlgorithm == true -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm 확정" + else -> "✓ ${posture.label} 부착 위치 ${anchorCm}cm · ${basis?.label ?: "근거 불명"}" }, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone, modifier = Modifier.weight(1f), @@ -1085,7 +1089,7 @@ private fun AnchorCard( when { !set -> "정렬하지 않아도 측정은 되지만, 기록에 부착 위치가 남지 않습니다." basis?.isAlgorithm == true -> - "이 환자의 전 자세·전 용량에서 이 위치를 그대로 씁니다." + "이 자세의 전 용량에서 이 위치를 그대로 씁니다. 자세를 바꾸면 그 자세로 다시 정렬합니다." // 막지 않는 대신 무엇이 빠진 상태인지는 말해 준다. 이 세션은 정확도 // 검증(기존 측정기 대조)의 모집단이 아니다. else -> "이 위치를 그대로 쓰되, 알고리즘이 확정한 자리가 아니라는 사실이 " + 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 index 8d66f72..2fe9efa 100644 --- 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 @@ -191,7 +191,7 @@ fun ReattachCheckCard( appState.anchorReattach = r // 정렬 폴더에 같이 남긴다 — 정렬 세션(601)에 phase=reattach 로 올라간다. runCatching { - val dir = HospitalRunStore.alignDir(patient.ifBlank { "unnamed" }, Date()) + val dir = HospitalRunStore.alignDir(patient.ifBlank { "unnamed" }, Date(), appState.clinicalPosture) 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()) } 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 75d8dc0..a61d9f5 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 @@ -347,6 +347,9 @@ class AlignLabdbPayloadTest { val d = File(run, "align").apply { mkdirs() } val withTime = JSONObject().apply { put("started_at", "2026-09-29T14:32:10.000+09:00") } assertEquals("2026-09-29_P001_align_1432", AlignLabdbPayload.sessionBase(d, "P001", withTime)) + // 자세별 폴더(2026-10-01)는 자세 꼬리가 붙는다 — 같은 날 같은 환자의 자세별 세션이 갈린다. + val sitting = File(run, "align_sitting").apply { mkdirs() } + assertEquals("2026-09-29_P001_align_sitting_1432", AlignLabdbPayload.sessionBase(sitting, "P001", withTime)) // 옛 폴더(요약에 started_at 없음)는 시각 없이 — 그래도 날짜는 들어간다. assertEquals("2026-09-29_P001_align", AlignLabdbPayload.sessionBase(d, "P001", JSONObject())) assertEquals("2026-09-29_P001_align", AlignLabdbPayload.sessionBase(d, "P001", null)) 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 6eb0cc0..494e395 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 @@ -137,6 +137,22 @@ class HospitalAlignPendingTest { assertNull(HospitalLabdbUploader.pendingAlign(run)) } + /** 자세마다 정렬(2026-10-01) — `align_{자세}` 폴더가 전부 대상이고, 옛 `align` 도 그대로. */ + @Test + fun `자세별 정렬 폴더가 전부 대상에 들어간다`() { + val run = runDir("2026-10-01_p7") + val legacy = alignDirWith(run, 0) + val sitting = File(run, "align_sitting").apply { mkdirs() } + File(sitting, "align_2cm.csv").writeText("0,0,900\n", Charsets.UTF_8) + val supine = File(run, "align_supine").apply { mkdirs() } + File(supine, "align_1cm.csv").writeText("0,0,900\n", Charsets.UTF_8) + File(supine, ".labdb_align.json").writeText("""{"inserted":20}""", Charsets.UTF_8) // 이미 올라감 + File(run, "align_junk_dir_x").mkdirs() // 정렬 폴더 아님 + + assertEquals(listOf(legacy, sitting), HospitalLabdbUploader.pendingAligns(run)) + assertEquals(legacy, HospitalLabdbUploader.pendingAlign(run)) + } + /** 조합 CSV 쪽 대상 선정이 `align/` 때문에 흔들리지 않아야 한다. */ @Test fun `조합 CSV 목록에 정렬 폴더가 섞이지 않는다`() { diff --git a/docs/CLINICAL_ALGORITHM.md b/docs/CLINICAL_ALGORITHM.md index 4557b7d..58d4b39 100644 --- a/docs/CLINICAL_ALGORITHM.md +++ b/docs/CLINICAL_ALGORITHM.md @@ -374,6 +374,10 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1 ### 3.6 실제 진행 순서 +**자세마다 처음부터 다시 합니다**(2026-10-01). Supine 정렬 → Supine 0~100% 측정 → Sitting +정렬 → Sitting 0~100% … 부착 위치·재부착 확인·정렬 폴더(`align_{자세}/`)·labdb 세션이 +전부 자세별입니다. + ``` 치골 위 0cm 에 붙이고 측정 → 치골 위 1cm 에 붙이고 측정 → 치골 위 2cm 에 붙이고 측정 (판정이 나빠져도 멈추지 않습니다) diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 8b59c32..8073c09 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -156,7 +156,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | 필드 | 타입 | 설명 | |---|---|---| -| `testId` | string | `<날짜_환자폴더>_align_` 기반 (2026-09-29~). 그 전에는 `<환자명>_align` — 날짜가 없어 같은 환자를 다른 날 다시 정렬하면 전부 중복으로 무시됐습니다 | +| `testId` | string | `<날짜_환자폴더>_align_<자세>_` 기반 (2026-10-01~ 자세별 정렬; 09-29~30 은 자세 없이). 그 전에는 `<환자명>_align` — 날짜가 없어 같은 환자를 다른 날 다시 정렬하면 전부 중복으로 무시됐습니다 | | `dataType` | string | `"601"` | | `sessionName` | string | `testId` 와 같은 바탕 | | `memo` | string | **간호사가 적은 증상.** 없으면 필드 자체가 없음 | @@ -178,6 +178,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `lateral_stream_frames` | int | 좌우 정렬 동안 흘린 프레임 전부 (2026-09-30~) | | `reattach_frames` | int | 재부착 확인 측정 cycle 수 (2026-09-30~). 0 = 안 했음 | | `confirm_channels` | bool[4] | 정렬 확인 측정에서 벽이 잡힌 채널 CH0~3 (2026-09-30~). 재부착 확인의 기준 | +| `posture` | string | 이 정렬의 자세 `Supine`/`Sitting`/`Standing` (2026-10-01~). 정렬은 자세마다 따로 합니다 | | `reattach` | object | 재부착 확인 판정 `{ok, missing[], detached[], channels[4], ref_channels[4]\|null, bv_ml, at}` — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지 | | `cycles_total` | int | = `recordCount` | | `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 |