feat(align): 위치 칩(0~4cm)으로 한 자리만 다시 재기 — 미부착 신호가 뜨면 그 자리에 머문다

임상(2026-10-01): 0~2cm 재고 3cm 에서 미부착 신호가 떠서 0cm 부터 2~3번 다시 쟀다. 한 자리만
다시 잴 길이 없었다. 병원 화면의 채움 정도 칩처럼 정렬도 위치마다 따로 (다시) 잰다.

  · 정렬 화면에 0~4cm 위치 칩. 잰 자리 회색 ✓(재측정이면 ×n), 미부착 신호가 뜬 자리 빨강 ⚠,
    판정된 best 초록 테두리, 지금 잴 자리 파랑. 아무 칩이나 눌러 그 자리로 간다.
  · 측정에 미부착 신호(탈착 감시)가 뜨면 자동으로 다음 위치로 넘어가지 않고
    "치골 위 N cm 다시 측정 — 미부착 신호" 를 띄운다(확정보다 우선). 버튼은 "N cm 다시
    측정하기 (2회째)".
  · 재측정 파일은 덮어쓰지 않고 `align_{n}cm_r2.csv`(+IMU) 로 따로 남긴다 — labdb 가 같은
    rowIndex 를 중복으로 무시해 옛 데이터만 남는 일을 막고, 앞 측정도 분석 대상이다.
    판정은 AnchorGuide 가 같은 cm 을 덮어 마지막 것을 쓴다.
  · 탐색 완료 기준을 "최대 cm 까지 올렸나"에서 "0~4cm 에 빈 자리가 없나"로. 확인 측정은
    재기 전 상태로 판정된 best 자리를 잴 때만. 확정 뒤 다른 자리를 다시 재면 확정을 푼다.
  · 601: 레코드 `attempt`(1 = 첫), 위치 수는 서로 다른 cm 만. 요약에 `attempts`·
    `detached_positions`. 미업로드 대상 판정(첫 파일 기준)은 그대로.

시험: 미부착 지시문 · attempt 파일 이름/파싱 · 위치 수 — 전체 195건 · failures 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 17:14:50 +09:00
parent 5373723864
commit d1fd314e05
8 changed files with 239 additions and 28 deletions
@@ -195,9 +195,10 @@ object HospitalRunStore {
*/ */
fun writeAlignImu( fun writeAlignImu(
dir: File, cm: Int, cyclesImu: List<List<ImuSample>>, kind: AlignFileKind = AlignFileKind.SWEEP, dir: File, cm: Int, cyclesImu: List<List<ImuSample>>, kind: AlignFileKind = AlignFileKind.SWEEP,
attempt: Int = 1,
): Boolean = try { ): Boolean = try {
if (cyclesImu.all { it.isEmpty() }) false else { if (cyclesImu.all { it.isEmpty() }) false else {
val name = "align_%dcm%s_imu.csv".format(cm, kind.suffix) val name = "align_%dcm%s%s_imu.csv".format(cm, kind.suffix, attemptSuffix(attempt))
File(dir, name).bufferedWriter().use { w -> File(dir, name).bufferedWriter().use { w ->
w.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps") w.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
w.newLine() w.newLine()
@@ -346,6 +347,9 @@ object HospitalRunStore {
return dir return dir
} }
/** `_r2`, `_r3`… 같은 위치의 재측정 번호. 첫 측정은 빈 문자열. */
fun attemptSuffix(attempt: Int): String = if (attempt > 1) "_r$attempt" else ""
fun alignDirName(posture: com.medithings.vesiscan.models.ClinicalPosture): String = fun alignDirName(posture: com.medithings.vesiscan.models.ClinicalPosture): String =
"align_" + posture.name.lowercase() "align_" + posture.name.lowercase()
@@ -365,8 +369,14 @@ object HospitalRunStore {
fun writeAlignCycles( fun writeAlignCycles(
dir: File, cm: Int, cycles: List<List<DoubleArray>>, dir: File, cm: Int, cycles: List<List<DoubleArray>>,
kind: AlignFileKind = AlignFileKind.SWEEP, kind: AlignFileKind = AlignFileKind.SWEEP,
/**
* 같은 위치의 몇 번째 측정인가(2026-10-01, 위치 재측정). 1 이면 접미사 없음, 2 부터
* `_r2`. 덮어쓰지 않는다 — 미부착 신호가 떠서 다시 잰 경우 앞 측정도 분석 대상이고,
* 덮어쓰면 labdb 가 같은 rowIndex 를 중복으로 무시해 옛 데이터만 남는다.
*/
attempt: Int = 1,
): Boolean = try { ): Boolean = try {
val name = "align_%dcm%s.csv".format(cm, kind.suffix) val name = "align_%dcm%s%s.csv".format(cm, kind.suffix, attemptSuffix(attempt))
File(dir, name).bufferedWriter().use { w -> File(dir, name).bufferedWriter().use { w ->
cycles.forEachIndexed { ci, chans -> cycles.forEachIndexed { ci, chans ->
chans.forEachIndexed { ch, buf -> chans.forEachIndexed { ch, buf ->
@@ -40,6 +40,7 @@ import java.util.Locale
* rowIndex : 0..N 연속 (labdb 규약) * rowIndex : 0..N 연속 (labdb 규약)
* align_cm : 그 cycle 을 잰 위치 * align_cm : 그 cycle 을 잰 위치
* phase : "sweep" | "confirm" | "lateral" | "lateral_stream" | "reattach" * phase : "sweep" | "confirm" | "lateral" | "lateral_stream" | "reattach"
* attempt : 같은 위치·phase 의 몇 번째 측정인가 (1 = 첫, `_r2` 파일 = 2 …) ← 2026-10-01
* ← 2026-09-18 추가 · lateral/lateral_stream/reattach 2026-09-30 * ← 2026-09-18 추가 · lateral/lateral_stream/reattach 2026-09-30
* lateral : (lateral_stream 만) {attempt, u4, u5, imbalance, action, ch3} — 그 프레임의 판정 * lateral : (lateral_stream 만) {attempt, u4, u5, imbalance, action, ch3} — 그 프레임의 판정
* cycle_idx : 그 위치 안에서의 순번 * cycle_idx : 그 위치 안에서의 순번
@@ -86,7 +87,10 @@ object AlignLabdbPayload {
* 확인 측정 파일이 하나도 안 잡혔다. * 확인 측정 파일이 하나도 안 잡혔다.
*/ */
private val NAME_RE = private val NAME_RE =
Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?\.csv$""", RegexOption.IGNORE_CASE) Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?(_r(\d+))?\.csv$""", RegexOption.IGNORE_CASE)
/** `_r3` → 3. 없으면 1(첫 측정). 같은 위치를 다시 잰 것(2026-10-01)은 뒤에 붙는다. */
private fun attemptOf(m: MatchResult): Int = m.groupValues[4].toIntOrNull() ?: 1
/** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */ /** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */
private fun phaseOf(suffix: String): String = when (suffix.lowercase()) { private fun phaseOf(suffix: String): String = when (suffix.lowercase()) {
@@ -118,6 +122,7 @@ object AlignLabdbPayload {
compareBy( compareBy(
{ NAME_RE.find(it.name)!!.groupValues[1].toInt() }, { NAME_RE.find(it.name)!!.groupValues[1].toInt() },
{ phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) }, { phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) },
{ attemptOf(NAME_RE.find(it.name)!!) },
) )
) )
?: return null ?: return null
@@ -135,6 +140,7 @@ object AlignLabdbPayload {
// 같은 align_cm 으로 여러 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다. // 같은 align_cm 으로 여러 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다.
// lateral 은 좌우 확인 완료 순간의 마지막 프레임 창(2026-09-30). cm 은 부착 위치. // lateral 은 좌우 확인 완료 순간의 마지막 프레임 창(2026-09-30). cm 은 부착 위치.
val phase = phaseOf(m.groupValues[2]) val phase = phaseOf(m.groupValues[2])
val attempt = attemptOf(m)
// 같은 위치·같은 phase 의 IMU 형제 파일. 없으면 빈 맵. // 같은 위치·같은 phase 의 IMU 형제 파일. 없으면 빈 맵.
val imuByCycle = readAlignImu( val imuByCycle = readAlignImu(
File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv") File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv")
@@ -194,6 +200,9 @@ object AlignLabdbPayload {
put("align_cm", cm) put("align_cm", cm)
put("phase", phase) put("phase", phase)
put("cycle_idx", ci) put("cycle_idx", ci)
// 같은 위치·같은 phase 를 다시 잰 것은 attempt 로 가른다(1 = 첫 측정).
// 판정은 마지막 시도를 썼다 — 앞 시도는 미부착 신호 등으로 버린 것.
put("attempt", attempt)
metaByCycle[ci]?.let { put("lateral", it) } metaByCycle[ci]?.let { put("lateral", it) }
put("sensor", LabdbSensor.of(imuByCycle[ci])) put("sensor", LabdbSensor.of(imuByCycle[ci]))
put("commandType", "MTB") put("commandType", "MTB")
@@ -214,8 +223,11 @@ object AlignLabdbPayload {
put("save_name", saveName) put("save_name", saveName)
// 2026-09-18: `files.size` 를 그대로 쓰면 확인 측정이 위치 하나로 더해져 // 2026-09-18: `files.size` 를 그대로 쓰면 확인 측정이 위치 하나로 더해져
// "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다. // "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다.
put("positions_measured", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" }) // 위치 수는 **서로 다른 cm** 을 센다 — 재측정(_r2…)은 같은 위치다.
put("confirm_positions", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" }) put("positions_measured", files.filter { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" }
.map { NAME_RE.find(it.name)!!.groupValues[1] }.distinct().size)
put("confirm_positions", files.filter { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" }
.map { NAME_RE.find(it.name)!!.groupValues[1] }.distinct().size)
// 좌우·재부착 프레임 수(0 = 그 단계를 안 했거나 옛 앱). 위치 수에는 안 더한다. // 좌우·재부착 프레임 수(0 = 그 단계를 안 했거나 옛 앱). 위치 수에는 안 더한다.
fun countPhase(name: String) = (0 until records.length()).count { fun countPhase(name: String) = (0 until records.length()).count {
records.getJSONObject(it).optString("phase") == name records.getJSONObject(it).optString("phase") == name
@@ -186,6 +186,16 @@ fun AnchorAlignView(appState: AppState) {
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다. // (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
val detachWatcher = remember { DetachWatcher() } val detachWatcher = remember { DetachWatcher() }
var detachSnapshot by remember { mutableStateOf<DetachWatcher.Snapshot?>(null) } var detachSnapshot by remember { mutableStateOf<DetachWatcher.Snapshot?>(null) }
/**
* 위치별 측정 횟수(2026-10-01, 위치 재측정). 1 = 한 번. 재측정 파일은 `_r2`, `_r3`…
* 로 따로 남고 판정은 [AnchorGuide] 가 마지막 것을 쓴다(같은 cm 은 덮어쓴다).
*/
val attempts = remember { mutableStateMapOf<Int, Int>() }
/**
* 마지막 측정에서 미부착 신호가 뜬 위치. 임상(2026-10-01)에서 3cm 에 미부착이 떠
* 0cm 부터 2~3번 다시 쟀다 — 그 위치만 다시 재면 된다. 다시 재서 정상이면 지운다.
*/
val detachedCms = remember { mutableStateMapOf<Int, Boolean>() }
// ── 좌우(LR) 정렬 ──────────────────────────────────────────────────────── // ── 좌우(LR) 정렬 ────────────────────────────────────────────────────────
// 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는 // 상하(anchor)가 확정된 **뒤** 도는 단계다. AnchorGuide 의 지표(nch·ch3·cap_frac)는
@@ -323,6 +333,14 @@ fun AnchorAlignView(appState: AppState) {
// 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다. // 위치를 옮겨 다시 재는 것이므로 옛 확정 상태는 무효다.
detachWatcher.reset(); detachSnapshot = null detachWatcher.reset(); detachSnapshot = null
bv = null bv = null
// 이 측정이 무엇인지는 **재기 전** 상태로 정한다. 다 잰 뒤 판정된 자리를 재면 확인
// 측정, 그 밖은 탐색(재측정 포함). 재측정은 같은 cm 의 몇 번째인지로 파일을 가른다.
val measuredBefore = guide.records.map { it.cm }.toSet()
val unmeasuredBefore = (0..AnchorConfig.SEARCH_MAX_CM).filter { it !in measuredBefore }
val sweepingAtStart = !sweepStopped && unmeasuredBefore.isNotEmpty()
val targetAtStart = if (sweepingAtStart) null
else AnchorSelection.selectBest(guide.records.filter { it.capFrac.isFinite() }).bestCm
val thisAttempt = (attempts[cm] ?: 0) + 1
val inbox = kotlinx.coroutines.channels.Channel< val inbox = kotlinx.coroutines.channels.Channel<
List<com.medithings.vesiscan.ble.PiezoChannelData>>( List<com.medithings.vesiscan.ble.PiezoChannelData>>(
@@ -402,14 +420,18 @@ fun AnchorAlignView(appState: AppState) {
alignDirty = true alignDirty = true
// 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정.
// 판정에 쓰인 파일을 덮지 않도록 별 이름으로 남긴다. // 판정에 쓰인 파일을 덮지 않도록 별 이름으로 남긴다.
val isConfirm = !guide.searching && records.any { it.cm == cm } val isConfirm = !sweepingAtStart && targetAtStart != null && cm == targetAtStart
val kind = if (isConfirm) HospitalRunStore.AlignFileKind.CONFIRM else HospitalRunStore.AlignFileKind.SWEEP val kind = if (isConfirm) HospitalRunStore.AlignFileKind.CONFIRM else HospitalRunStore.AlignFileKind.SWEEP
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, kind)) { attempts[cm] = thisAttempt
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, kind, thisAttempt)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
} }
// IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 — // IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 —
// 파형이 남았으면 측정은 성립한다. // 파형이 남았으면 측정은 성립한다.
HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind) HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind, thisAttempt)
// 미부착 신호 — 이 위치는 다시 재야 한다. 자동으로 넘어가지 않고 같은 자리에 둔다.
val detachedNow = detachSnapshot?.let { it.aggregateDetached || it.detachedChannels.isNotEmpty() } == true
if (detachedNow) detachedCms[cm] = true else detachedCms.remove(cm)
// 확인 측정의 IMU 평균 기울기 — Supine 이면 Sitting 요구 범위의 기준각 m 이 된다 // 확인 측정의 IMU 평균 기울기 — Supine 이면 Sitting 요구 범위의 기준각 m 이 된다
// (2026-10-01, PostureTilt 참조). 요약에도 남겨 앱을 다시 켜도 되살린다. // (2026-10-01, PostureTilt 참조). 요약에도 남겨 앱을 다시 켜도 되살린다.
if (isConfirm) { if (isConfirm) {
@@ -445,22 +467,32 @@ 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, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg), decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, attempts = attempts.toMap(), detachedCms = detachedCms.filterValues { it }.keys.sorted()),
) )
// ── 다음 위치 ────────────────────────────────────────────────── // ── 다음 위치 ──────────────────────────────────────────────────
// **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 // **0~SEARCH_MAX_CM 를 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 여기서
// 여기서 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다. // 보지 않는다 — 판정을 전 위치로 하므로 중간 안내가 의미가 없다. 순서는 안
// 다 재고 나면 판정된 위치로 내려가 확인 측정 한 번, 그 뒤 +1cm 에서 좌우. // 잰 위치 중 가장 아래(칩으로 다른 위치를 다시 쟀어도 빈 자리로 돌아간다).
val swept = guide.records.maxOfOrNull { it.cm } ?: cm // 다 재고 나면 판정된 위치로 가 확인 측정 한 번, 그 자리에서 좌우.
if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) { //
cm = swept + 1 // 미부착 신호가 떴으면 **넘어가지 않는다** — 같은 자리를 다시 재라고 한다.
if (detachedNow) {
// 자리 유지. 확인 측정이었으면 확정도 아니다.
} else { } else {
// 수집 끝 — 전 위치로 판정한 자리로 간다. val unmeasured = (0..AnchorConfig.SEARCH_MAX_CM)
val target = AnchorSelection.selectBest( .filter { c -> guide.records.none { it.cm == c } }
guide.records.filter { it.capFrac.isFinite() }).bestCm if (!sweepStopped && unmeasured.isNotEmpty()) {
if (target != null) { cm = unmeasured.min()
// 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다. } else {
if (cm == target) confirmDone = true else cm = target // 수집 끝 — 전 위치로 판정한 자리로 간다. 재측정으로 판정이 바뀌었을 수
// 있으므로 확정은 다시 판단한다.
val target = AnchorSelection.selectBest(
guide.records.filter { it.capFrac.isFinite() }).bestCm
if (target != null) {
// 방금 잰 자리가 곧 판정된 자리면 그 측정이 확인 측정이다.
if (cm == target && isConfirm) confirmDone = true
else { cm = target; if (!isConfirm) confirmDone = false }
}
} }
} }
} finally { } finally {
@@ -756,15 +788,18 @@ fun AnchorAlignView(appState: AppState) {
val singlePassDisagrees = finalBest != null && singlePassBest != null && val singlePassDisagrees = finalBest != null && singlePassBest != null &&
finalBest != singlePassBest finalBest != singlePassBest
// 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다. // 수집 단계인가 — 판정이 났어도 0~SEARCH_MAX_CM 에 빈 자리가 있으면 계속 잰다.
val sweptTo = records.maxOfOrNull { it.cm } ?: -1 // (2026-10-01 까지는 "최대 cm 까지 올렸나"였다. 칩으로 중간 위치를 다시 재도
val sweeping = !sweepStopped && sweptTo < AnchorConfig.SEARCH_MAX_CM // 빈 자리 기준이라야 흐름이 안 꼬인다.)
val sweeping = !sweepStopped &&
(0..AnchorConfig.SEARCH_MAX_CM).any { c -> records.none { it.cm == c } }
val instr = instructionOf( val instr = instructionOf(
last, cm, AnchorConfig.FINAL_OFFSET_CM, last, cm, AnchorConfig.FINAL_OFFSET_CM,
sweeping = sweeping, decidedAtCm = decidedAtCm, sweeping = sweeping, decidedAtCm = decidedAtCm,
confirmTarget = if (sweeping) null else finalBest, confirmTarget = if (sweeping) null else finalBest,
confirmDone = confirmDone, confirmDone = confirmDone,
needReattach = needReattach && !sweeping, needReattach = needReattach && !sweeping,
detachedHere = detachedCms[cm] == true,
) )
Box( Box(
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
@@ -844,13 +879,33 @@ fun AnchorAlignView(appState: AppState) {
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
} }
// ── 위치 칩 (2026-10-01) ──────────────────────────────────────
// 병원 화면의 채움 정도 칩처럼 0~4cm 를 늘어놓는다. 잰 자리는 회색 ✓, 미부착
// 신호가 뜬 자리는 빨강 ⚠, 지금 잴 자리는 파랑. **아무 칩이나 눌러 그 자리를
// 다시 잴 수 있다** — 3cm 에 미부착이 떴다고 0cm 부터 다시 하지 않는다.
if (!measuring && !lrRunning) {
PositionChips(
selected = cm,
measured = records.map { it.cm }.toSet(),
detached = detachedCms.filterValues { it }.keys,
attempts = attempts,
best = if (sweeping) null else finalBest,
onSelect = { c ->
cm = c
// 확정 뒤에 다른 자리를 다시 재면 판정이 바뀔 수 있다 — 확정을 푼다.
if (confirmDone && c != finalBest) confirmDone = false
},
)
Spacer(Modifier.height(10.dp))
}
// ── 동작 버튼을 파형보다 위에 둔다 ────────────────────────────── // ── 동작 버튼을 파형보다 위에 둔다 ──────────────────────────────
// 종전에는 BV·접촉·파형 아래에 있어 한 위치를 잴 때마다 스크롤을 내려야 // 종전에는 BV·접촉·파형 아래에 있어 한 위치를 잴 때마다 스크롤을 내려야
// 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한 번이 매번 든다 // 했다. 프로브를 한 손에 잡은 채 쓰는 화면이라 그 한 번이 매번 든다
// (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다. // (2026-09-10 피드백). 지시(카드) 바로 아래 누를 것을 둔다.
// //
// 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다. // 아래쪽은 **근거**다 — BV·접촉·파형은 "왜 이 값인가"를 볼 때만 내려다본다.
if (!confirmDone) { if (!confirmDone || detachedCms[cm] == true) {
Button( Button(
onClick = { measuring = true }, onClick = { measuring = true },
enabled = isConnected && !measuring, enabled = isConnected && !measuring,
@@ -860,7 +915,13 @@ fun AnchorAlignView(appState: AppState) {
// 버튼 "1cm 측정하기". 한때 버튼을 "여기서 측정"으로 두었는데 "여기"가 // 버튼 "1cm 측정하기". 한때 버튼을 "여기서 측정"으로 두었는데 "여기"가
// 어디인지 알 수 없다는 피드백이 왔다(2026-09-10). 둘 다 숫자를 쓰면 // 어디인지 알 수 없다는 피드백이 왔다(2026-09-10). 둘 다 숫자를 쓰면
// 서로를 확인해 준다 — 카드대로 붙였으면 버튼의 숫자가 지금 자리와 맞는다. // 서로를 확인해 준다 — 카드대로 붙였으면 버튼의 숫자가 지금 자리와 맞는다.
) { Text("${cm}cm 측정하기", fontSize = 16.sp, fontWeight = FontWeight.Bold) } ) {
Text(
if ((attempts[cm] ?: 0) > 0) "${cm}cm 다시 측정하기 (${(attempts[cm] ?: 0) + 1}회째)"
else "${cm}cm 측정하기",
fontSize = 16.sp, fontWeight = FontWeight.Bold,
)
}
// 수집을 중단하는 문. 두 경우를 한 버튼으로 덮는다: // 수집을 중단하는 문. 두 경우를 한 버튼으로 덮는다:
// · 아직 판정이 안 났다 → forceFinish 로 강제 확정 // · 아직 판정이 안 났다 → forceFinish 로 강제 확정
@@ -947,7 +1008,7 @@ fun AnchorAlignView(appState: AppState) {
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, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, decidedAtCm = decidedAtCm, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, attempts = attempts.toMap(), detachedCms = detachedCms.filterValues { it }.keys.sorted(),
), ),
) )
} }
@@ -996,7 +1057,7 @@ fun AnchorAlignView(appState: AppState) {
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, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, startedAt = runStartedAt, posture = appState.clinicalPosture.label, confirmTiltDeg = confirmTiltDeg, attempts = attempts.toMap(), detachedCms = detachedCms.filterValues { it }.keys.sorted(),
confirmChannels = appState.anchorRefChannels, confirmChannels = appState.anchorRefChannels,
), ),
) )
@@ -1041,6 +1102,7 @@ fun AnchorAlignView(appState: AppState) {
guide.reset(); cm = 0; last = null; records = emptyList(); error = null guide.reset(); cm = 0; last = null; records = emptyList(); error = null
lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++ lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++
lrRefChannels = null; confirmTiltDeg = null lrRefChannels = null; confirmTiltDeg = null
attempts.clear(); detachedCms.clear()
lrConfirmed = false; lrError = null; lrRunning = false lrConfirmed = false; lrError = null; lrRunning = false
disconnectedMidRun = false disconnectedMidRun = false
decidedAtCm = null; sweepStopped = false; confirmDone = false decidedAtCm = null; sweepStopped = false; confirmDone = false
@@ -1286,6 +1348,67 @@ private fun LateralSection(
} }
} }
/**
* 0~SEARCH_MAX cm 위치 칩(2026-10-01). 병원 화면의 채움 정도 칩과 같은 생김새 — 잰 자리는
* 회색 ✓(재측정이면 ×n), 미부착 신호가 뜬 자리는 빨강 ⚠, 판정된 best 는 테두리, 지금 잴
* 자리는 파랑. 아무 칩이나 눌러 그 자리로 간다.
*/
@Composable
private fun PositionChips(
selected: Int,
measured: Set<Int>,
detached: Set<Int>,
attempts: Map<Int, Int>,
best: Int?,
onSelect: (Int) -> Unit,
) {
Text("측정 위치 — 누르면 그 자리를 (다시) 잽니다", fontSize = 12.sp, color = MlSecondaryText)
Row(
Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
for (c in 0..AnchorConfig.SEARCH_MAX_CM) {
val on = c == selected
val bad = c in detached
val done = c in measured
val n = attempts[c] ?: 0
val bg = when {
on -> MlPrimary
bad -> MlCritical.copy(alpha = 0.12f)
done -> MlSecondaryText.copy(alpha = 0.16f)
else -> Color.White
}
val fg = when {
on -> Color.White
bad -> MlCritical
else -> MlSecondaryText
}
Box(
Modifier
.weight(1f)
.background(bg, RoundedCornerShape(10.dp))
.then(if (c == best && !on) Modifier.border(1.5.dp, MlSuccess, RoundedCornerShape(10.dp)) else Modifier)
.clickable { onSelect(c) }
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${c}cm", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = fg)
Text(
when {
bad -> "⚠ 미부착"
done && n > 1 -> "✓ ×$n"
done -> "✓"
else -> "–"
},
fontSize = 10.sp, color = fg,
)
}
}
}
}
}
/** /**
* 채널마다 벽이 잡혔는지 한 줄로(2026-09-30). CH0~3 이 판정 채널, CH4·5 는 좌우용이라 * 채널마다 벽이 잡혔는지 한 줄로(2026-09-30). CH0~3 이 판정 채널, CH4·5 는 좌우용이라
* 회색으로 구분한다. 잡힌 채널은 초록, 안 잡힌 판정 채널은 빨강 — "CH2 없음"이 파형을 * 회색으로 구분한다. 잡힌 채널은 초록, 안 잡힌 판정 채널은 빨강 — "CH2 없음"이 파형을
@@ -1413,11 +1536,20 @@ internal fun instructionOf(
confirmDone: Boolean = false, confirmDone: Boolean = false,
/** 후보가 하나도 없어 재부착이 필요한가. */ /** 후보가 하나도 없어 재부착이 필요한가. */
needReattach: Boolean = false, needReattach: Boolean = false,
/** 지금 자리([nextCm])의 마지막 측정에 미부착 신호가 떴는가 — 같은 자리를 다시 잰다(2026-10-01). */
detachedHere: Boolean = false,
): AlignInstruction = when { ): AlignInstruction = when {
step == null -> AlignInstruction("치골 위 0cm 에 붙이고 측정", step == null -> AlignInstruction("치골 위 0cm 에 붙이고 측정",
"젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.") "젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.")
// 확인 측정까지 끝났다 — 오프셋 자리에 붙이고 좌우로. 그 자리는 재지 않는다. // 확인 측정까지 끝났다 — 오프셋 자리에 붙이고 좌우로. 그 자리는 재지 않는다.
// 미부착 신호 — 0cm 부터가 아니라 **이 자리만** 다시. 2026-10-01 임상에서 3cm 에 떠서
// 처음부터 2~3번 다시 쟀다. 앞 측정은 파일로 남는다. 확정보다 먼저다.
detachedHere -> AlignInstruction(
"치골 위 ${nextCm}cm 다시 측정 — 미부착 신호",
"접촉이 끊긴 신호가 잡혔습니다. 젤을 보고 프로브를 다시 밀착한 뒤 같은 자리를 다시 재세요. " +
"지난 측정도 파일로 남습니다.")
confirmDone -> { confirmDone -> {
val attach = (confirmTarget ?: step.record?.cm ?: nextCm) + offsetCm val attach = (confirmTarget ?: step.record?.cm ?: nextCm) + offsetCm
// 오프셋 0(2026-10-01~): 확인 측정한 그 자리에서 바로 좌우. 옮기라는 말을 하면 안 된다. // 오프셋 0(2026-10-01~): 확인 측정한 그 자리에서 바로 좌우. 옮기라는 말을 하면 안 된다.
@@ -1519,7 +1651,13 @@ private fun buildAlignSummary(
posture: String? = null, posture: String? = null,
/** 확인 측정의 IMU 평균 기울기(도). Supine 이면 Sitting 기준각. 앱 재시작 뒤 복구에 쓴다. */ /** 확인 측정의 IMU 평균 기울기(도). Supine 이면 Sitting 기준각. 앱 재시작 뒤 복구에 쓴다. */
confirmTiltDeg: Float? = null, confirmTiltDeg: Float? = null,
/** 위치별 측정 횟수(2026-10-01). 2 이상이면 그 위치에 `_r2`… 파일이 더 있다. */
attempts: Map<Int, Int> = emptyMap(),
/** 마지막 측정에 미부착 신호가 남아 있는 위치. 비어 있어야 정상이다. */
detachedCms: List<Int> = emptyList(),
): JSONObject = JSONObject().apply { ): JSONObject = JSONObject().apply {
if (attempts.isNotEmpty()) put("attempts", JSONObject().apply { attempts.forEach { (c, n) -> put(c.toString(), n) } })
put("detached_positions", JSONArray(detachedCms))
posture?.let { put("posture", it) } posture?.let { put("posture", it) }
confirmTiltDeg?.let { put("confirm_tilt_deg_mean", it.toDouble()) } confirmTiltDeg?.let { put("confirm_tilt_deg_mean", it.toDouble()) }
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)) }
@@ -37,6 +37,19 @@ class HospitalRunStoreAlignFilesTest {
) )
} }
@Test fun `같은 위치를 다시 재면 _r2 로 따로 남고 첫 파일은 그대로다`() {
val d = tmp.newFolder("align")
assertTrue(HospitalRunStore.writeAlignCycles(d, 3, frames(1), attempt = 1))
assertTrue(HospitalRunStore.writeAlignCycles(d, 3, frames(2), attempt = 2))
assertTrue(HospitalRunStore.writeAlignImu(d, 3, listOf(listOf(sample(0.9f))), attempt = 2))
assertEquals(
listOf("align_3cm.csv", "align_3cm_r2.csv", "align_3cm_r2_imu.csv"),
d.listFiles()!!.map { it.name }.sorted(),
)
assertEquals(6, File(d, "align_3cm.csv").readLines().size)
assertEquals(12, File(d, "align_3cm_r2.csv").readLines().size)
}
@Test fun `좌우 프레임 창은 cycle,channel,s0… 으로 프레임마다 6줄이다`() { @Test fun `좌우 프레임 창은 cycle,channel,s0… 으로 프레임마다 6줄이다`() {
val d = tmp.newFolder("align") val d = tmp.newFolder("align")
HospitalRunStore.writeAlignCycles(d, 3, frames(10), HospitalRunStore.AlignFileKind.LATERAL) HospitalRunStore.writeAlignCycles(d, 3, frames(10), HospitalRunStore.AlignFileKind.LATERAL)
@@ -202,6 +202,21 @@ class AlignLabdbPayloadTest {
assertEquals(1, params.getInt("reattach_frames")) assertEquals(1, params.getInt("reattach_frames"))
} }
@Test fun `같은 위치를 다시 잰 파일은 attempt 로 가르고 위치 수에는 안 더한다`() {
// 2026-10-01: 미부착 신호로 3cm 를 다시 잼 → align_3cm.csv(첫) + align_3cm_r2.csv.
val d = tmp.newFolder("align")
writeCycles(d, 2, cycles = 1)
writeCycles(d, 3, cycles = 1)
File(d, "align_3cm_r2.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(3, p.getInt("recordCount"))
assertEquals(listOf(2, 3, 3), (0 until 3).map { recs.getJSONObject(it).getInt("align_cm") })
assertEquals(listOf(1, 1, 2), (0 until 3).map { recs.getJSONObject(it).getInt("attempt") })
assertEquals("sweep", recs.getJSONObject(2).getString("phase"))
assertEquals(2, p.getJSONObject("params").getInt("positions_measured"))
}
@Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() { @Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() {
val d = tmp.newFolder("align") val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1) writeCycles(d, 0, cycles = 1)
@@ -141,6 +141,20 @@ class AlignInstructionTest {
assertEquals("치골 위 3cm 에 붙이세요", i.headline) assertEquals("치골 위 3cm 에 붙이세요", i.headline)
} }
@Test fun `미부착 신호가 뜬 자리는 그 자리만 다시 재라고 한다`() {
// 2026-10-01 임상: 3cm 에 미부착이 떠서 0cm 부터 2~3번 다시 쟀다.
val i = instructionOf(step(3), nextCm = 3, offsetCm = 0, sweeping = true, detachedHere = true)
assertEquals("치골 위 3cm 다시 측정 — 미부착 신호", i.headline)
assertTrue(i.sub!!.contains("같은 자리"))
assertFalse(i.done)
assertNoPositionWords(i)
// 미부착은 확정보다도 먼저다 — 확인 측정에서 떠도 다시 재야 한다.
val j = instructionOf(step(2, searching = false, bestCm = 2),
nextCm = 2, offsetCm = 0, confirmTarget = 2, confirmDone = true, detachedHere = true)
assertTrue(j.headline.contains("다시 측정"))
assertFalse(j.done)
}
@Test fun `오프셋이 0 이면 그 자리에서 바로 좌우로 가라고 하고 옮기라는 말을 안 한다`() { @Test fun `오프셋이 0 이면 그 자리에서 바로 좌우로 가라고 하고 옮기라는 말을 안 한다`() {
// 2026-10-01: 모든 자세에서 정렬 → best 자리에서 바로 좌우. "붙이세요"가 나오면 // 2026-10-01: 모든 자세에서 정렬 → best 자리에서 바로 좌우. "붙이세요"가 나오면
// 간호사가 프로브를 뗀다. // 간호사가 프로브를 뗀다.
+5
View File
@@ -407,6 +407,11 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 0 (2026
[여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다. [여기까지만 재고 판정하기] 로 수집을 중단할 수 있습니다 — 그 시점까지의 위치로 판정합니다.
**위치 재측정 (2026-10-01).** 화면의 0~4cm 위치 칩을 누르면 그 자리를 다시 잽니다. 측정에
미부착 신호(탈착 감시)가 뜨면 앱이 다음 위치로 넘어가지 않고 "같은 자리 다시 측정"을 띄웁니다
— 임상에서 3cm 에 미부착이 떠 0cm 부터 2~3번 다시 쟀던 일의 후속. 재측정은 `align_{n}cm_r2.csv`
처럼 따로 남고 판정은 마지막 것을 씁니다.
#### 판정 범위가 바뀌었습니다 (2026-09-10) #### 판정 범위가 바뀌었습니다 (2026-09-10)
| | 종전 | 현재 | | | 종전 | 현재 |
+4
View File
@@ -335,6 +335,10 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
판정(앱이 화면에 보인 것). `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오릅니다. 판정(앱이 화면에 보인 것). `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오릅니다.
- `phase: "reattach"` (2026-09-30~) 은 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리에서 병원 - `phase: "reattach"` (2026-09-30~) 은 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리에서 병원
화면이 20 cycle 잰 **재부착 확인 측정**입니다. 판정은 `params.reattach` 에. 화면이 20 cycle 잰 **재부착 확인 측정**입니다. 판정은 `params.reattach` 에.
- `attempt` (2026-10-01~) 는 같은 위치·같은 phase 의 몇 번째 측정인지입니다(1 = 첫). 미부착
신호 등으로 한 위치만 다시 재면 `align_3cm_r2.csv` 가 추가되고 attempt=2 로 올라갑니다.
**판정은 마지막 시도**를 썼습니다. `params.attempts` 에 위치별 횟수, `params.detached_positions`
에 마지막 측정에서 미부착 신호가 남은 위치가 있습니다(비어 있어야 정상).
## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오 ## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오