feat(hospital): 정렬을 자세마다 — 부착 위치·재부착 확인·정렬 폴더·labdb 세션을 자세별로

임상 프로토콜은 자세마다 정렬을 처음부터 다시 한다(2026-10-01 확인): Supine 정렬 →
Supine 0~100% → Sitting 정렬 → Sitting 0~100% …. 앱은 환자당 한 번이었다 — Sitting 에서
다시 정렬하면 같은 `align/` 의 원시 파일이 덮이고 labdb 세션 ID 도 같아 섞였다.

  · AppState: 부착 위치·근거·기준 채널·재부착 결과를 자세별 맵(AnchorState)으로. 기존
    접근자(anchorCm 등)는 지금 자세의 항목을 읽고 쓴다. 환자·프로브가 바뀌면 전 자세를
    비운다(clearAllAnchors).
  · 저장: `align/` → `align_{자세}/`. 업로드 대상 탐색은 옛 `align` 과 `align_*` 전부.
    labdb 601 세션 이름에 자세 꼬리(`…_align_sitting_HHmm`), 요약에 `posture`.
  · 정렬 화면: 자세 칩은 첫 위치를 재고 나면 잠근다(폴더·세션의 조건이다). 바꾸려면
    [처음부터 다시 정렬].
  · 병원 화면: 환자명 → 자세 → 정렬 카드 순(자세가 정렬보다 위). 카드가 그 자세의 상태를
    보인다 — "Sitting 부착 위치 미정렬" / "✓ Sitting 부착 위치 3cm 확정".

시험: 자세별 폴더 대상 탐색 · 세션 이름 자세 꼬리. 전체 190건 · failures 0.

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