feat(clinical): 측정 조합 선택 · 파형 표시 · 프로브 설정 확인·복원

조합 선택 — 요청받은 주파수·cycle 변경이 실질적으로 불가능했다

수동 주입 패널이 `mcs?` 를 한 번 쏘기만 해서, 측정을 시작하면 첫 조합이 곧바로 덮어썼다.
루프가 6조합을 무조건 다 돌기 때문이다. 이제 돌릴 조합을 고를 수 있다.

  · 기본은 6개 전부. 프로토콜이 요구하는 것이 전부이고, 빼는 것은 "한 조합만 다시
    잰다" 같은 예외를 위한 것이다. 마지막 하나는 못 끄게 했다 — 0개로 시작을 누르면
    아무 일도 안 일어난다.
  · 선택 순서가 아니라 프로토콜 순서를 지킨다. 매번 같은 순서로 돌아야 나중에 파일을
    비교할 때 조건이 섞이지 않는다.
  · 나눠 재도 조건은 완료로 잡힌다(앞 커밋의 합산 판정).

함께 고친 것 — 부분 선택으로 틀리게 된 표시 둘

  · 진행 패널의 "1/6" 이 HOSPITAL_COMBINATIONS.size 로 박혀 있었다. 2조합만 골라도
    "1/6" 이었다. 선택 수 기준으로 바꿨다(총 측정 횟수 계산도).
  · 경고 문구의 "(2.3MHz · cycle 7)" 도 박혀 있었다. 프로브에 남는 값은 **선택한 것 중
    프로토콜 순서상 마지막** 조합이라 부분 선택이면 달라진다. 실제 값을 계산해 보여준다.

파형 — 측정·정렬 중 6채널 원신호

측정하는 사람이 신호가 제대로 잡히는지 그 자리에서 보게 한다. 20회를 다 돌린 뒤에야
파일을 열어 보면, 잘못 붙은 것을 알았을 때는 그 단계를 다시 잴 수 없다(방광을 비웠거나
자세가 바뀌었다). 정렬은 끝난 뒤에도 남긴다 — 위치를 옮겨 가며 반복하는 작업이라
판정 결과와 마지막 파형을 나란히 보고 다음을 정한다.

그리는 값은 저장·판정에 쓰는 것과 같은 데이터다. 화면용으로 따로 재지 않는다.

프로브 설정 — 확인과 복원

  · 패널에 "현재 설정" 과 읽기 버튼(`mcf?`). 임상 루프가 파라미터를 덮어쓰고 되돌리지
    않으므로, 일반 측정으로 돌아가기 전에 무엇이 들어 있는지 볼 수 있어야 한다.
  · 측정·정렬 종료 시 ProbeConfigGuard 로 측정 전 값 복원. finally 안이고
    NonCancellable 로 감쌌다 — 중지로 코루틴이 취소되면 응답 대기가 끊겨 복원 명령이
    안 나간다. 오히려 중단됐을 때가 더 필요한 자리다.
  · 복원 결과를 화면에 한 줄로 남긴다. 실패했으면 다음 일반 측정이 임상 조건으로
    돌게 되므로 눈에 띄어야 한다.

실기기(Redmi 23021RAA2Y) 확인 — 읽기 정상 동작. 테스트 44개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 15:51:05 +09:00
parent c7cddbf9c9
commit 2e9b9b204c
2 changed files with 250 additions and 14 deletions
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager
@@ -85,6 +86,19 @@ fun AnchorAlignView(appState: AppState) {
var records by remember { mutableStateOf<List<AnchorPosRecord>>(emptyList()) }
// 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish).
var forceFinish by remember { mutableStateOf(false) }
/**
* 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다.
*
* 정렬은 **어디에 붙일지**를 정하는 단계라, 신호가 제대로 잡히는지 그 자리에서
* 보이는 것이 본 측정보다 오히려 더 중요하다 — 여기서 잘못 고르면 이후 20회 ×
* 조합 전체가 엉뚱한 위치에서 측정된다.
*
* 위치를 옮겨 다시 재면 새 파형으로 덮인다. 측정이 끝나도 비우지 않아, 판정 결과와
* 마지막 파형을 나란히 보고 다음 위치로 갈지 정할 수 있다.
*/
var liveChannels by remember {
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
}
// 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다.
val runStartedAt = remember { Date() }
// 저장 폴더 이름. 환자명이 비면 시각으로 대체한다(데이터를 잃지 않기 위해).
@@ -110,6 +124,11 @@ fun AnchorAlignView(appState: AppState) {
val inbox = kotlinx.coroutines.channels.Channel<
List<com.medithings.vesiscan.ble.PiezoChannelData>>(
kotlinx.coroutines.channels.Channel.CONFLATED)
// 정렬도 자기 조건(2.3MHz·c3)을 프로브에 써 넣는다. 되돌리지 않으면 그 값이
// 남아, 뒤이어 도는 병원 측정이 "측정 전 값"으로 정렬 조건을 붙잡게 된다.
val guard = ProbeConfigGuard(bleManager)
guard.capture()
val prev = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
try {
@@ -150,7 +169,9 @@ fun AnchorAlignView(appState: AppState) {
error = "측정 시간 초과 (${i}/${AnchorConfig.ALIGN_CYCLES}) — 다시 측정하세요."
return@LaunchedEffect
}
cycles.add(got.sortedBy { it.channel }.map { ch ->
val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터
cycles.add(sorted.map { ch ->
DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() }
})
progress = i
@@ -185,6 +206,9 @@ fun AnchorAlignView(appState: AppState) {
}
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prev
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
guard.restore()
}
inbox.close()
measuring = false
}
@@ -323,6 +347,22 @@ 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,
modifier = Modifier.fillMaxWidth(),
cellHeight = 140.dp,
compact = true,
)
Spacer(Modifier.height(16.dp))
}
if (!done) {
Button(
onClick = { measuring = true },
@@ -37,6 +37,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager
@@ -79,6 +80,14 @@ fun HospitalModeView(appState: AppState) {
var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) }
var fill by remember { mutableStateOf(BladderFill.P0) }
var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) }
/**
* 이번 실행에서 돌 조합. **기본은 6개 전부** — 프로토콜이 요구하는 것이 전부이고,
* 부분 선택은 예외적인 상황(한 조합만 다시 재기, 시간이 없어 나눠 재기)을 위한 것이다.
*
* 나눠 재도 조건이 완료로 잡힌다 — `HospitalRunStore.scanProgress` 가 같은
* 자세·충만도의 여러 실행을 **조합 단위로 합산**하기 때문이다.
*/
var selectedCombos by remember { mutableStateOf(HOSPITAL_COMBINATIONS.toSet()) }
var running by remember { mutableStateOf(false) }
var comboIdx by remember { mutableIntStateOf(0) }
@@ -87,6 +96,20 @@ fun HospitalModeView(appState: AppState) {
var failCount by remember { mutableIntStateOf(0) }
var lastFile by remember { mutableStateOf<String?>(null) }
var configFailed by remember { mutableStateOf<String?>(null) }
/** 프로브 설정 복원 결과. 측정이 끝난 뒤 화면에 한 줄로 남긴다. */
var restoreNote by remember { mutableStateOf<String?>(null) }
/**
* 방금 받은 6채널 원본. 아래쪽 파형 그래프가 이걸 그린다.
*
* 저장하는 값(`raw`)과 **같은 데이터**다 — 화면용으로 따로 재지 않는다. 그래야
* 눈으로 본 파형과 파일에 들어간 파형이 어긋나지 않는다.
*
* 실행이 끝나도 비우지 않는다. 마지막 회차를 그대로 두면 "방금 뭘 쟀는지"를 다시
* 눌러 보지 않고도 확인할 수 있다.
*/
var liveChannels by remember {
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
}
val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS
val canStart = isConnected && patient.isNotBlank() && !running
@@ -119,7 +142,12 @@ fun HospitalModeView(appState: AppState) {
val dir = HospitalRunStore.runDir(patient, startedAt)
val fw = bleManager.firmwareVersion.value
val results = JSONArray()
okCount = 0; failCount = 0; configFailed = null
okCount = 0; failCount = 0; configFailed = null; restoreNote = null
// 측정 전 프로브 설정을 읽어 둔다. 이 루프는 조합마다 파라미터를 덮어쓰므로
// 끝나고 되돌리지 않으면 일반 측정이 임상 조건으로 돌게 된다.
val guard = ProbeConfigGuard(bleManager)
guard.capture()
// 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로
// 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다.
@@ -132,7 +160,10 @@ fun HospitalModeView(appState: AppState) {
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
try {
HOSPITAL_COMBINATIONS.forEachIndexed { ci, (freq, cycle) ->
// 선택 순서가 아니라 **프로토콜 순서**를 지킨다. 매번 같은 순서로 돌아야
// 나중에 파일을 비교할 때 조건이 섞이지 않는다.
val combos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos }
combos.forEachIndexed { ci, (freq, cycle) ->
if (!running) return@forEachIndexed
comboIdx = ci
val file = HospitalRunStore.combinationFile(
@@ -185,7 +216,9 @@ fun HospitalModeView(appState: AppState) {
if (got == null) {
fail++; failCount++
} else {
val raw = got.sortedBy { it.channel }.map { it.buffer }
val sorted = got.sortedBy { it.channel }
liveChannels = sorted // 화면 파형 — 저장값과 같은 데이터
val raw = sorted.map { it.buffer }
val saved = HospitalRunStore.appendMeasurement(
file = file,
scanId = AdcCsvLogger.nextScanId(),
@@ -212,6 +245,13 @@ fun HospitalModeView(appState: AppState) {
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete
inbox.close()
// 중지·오류로 빠져나온 경우에도 되돌린다 — 오히려 그때가 더 필요하다.
// withContext(NonCancellable) 로 감싸는 이유: `running=false` 로 코루틴이
// 취소되면 여기의 suspend 호출(응답 대기)이 즉시 끊겨 복원이 안 나간다.
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
guard.restore()
}
restoreNote = guard.summary()
HospitalRunStore.writeSummary(dir, JSONObject().apply {
put("patient", patient)
put("posture", posture.label)
@@ -335,15 +375,64 @@ fun HospitalModeView(appState: AppState) {
label = { Text("조합당 반복 횟수") },
singleLine = true,
enabled = !running,
supportingText = { Text("6조합 × ${repeats}회 = 총 ${6 * repeats}회 측정") },
supportingText = {
Text("${selectedCombos.size}조합 × ${repeats}회 = 총 ${selectedCombos.size * repeats}회 측정")
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
// ── 돌릴 조합 고르기 ────────────────────────────────────────────
// 기본은 6개 전부다. 프로토콜이 요구하는 것이 전부이고, 여기서 빼는 것은
// "한 조합만 다시 잰다" 같은 예외를 위한 것이다. 그래서 해제는 되지만
// 마지막 하나는 못 끄게 한다 — 0개로 시작을 누르면 아무 일도 안 일어난다.
Label("측정할 조합")
CombinationPicker(
selected = selectedCombos,
onToggle = { combo ->
if (!running) {
selectedCombos =
if (combo in selectedCombos) {
if (selectedCombos.size <= 1) selectedCombos
else selectedCombos - combo
} else selectedCombos + combo
}
},
enabled = !running,
)
if (selectedCombos.size < HOSPITAL_COMBINATIONS.size) {
Hint(
"프로토콜 6조합 중 ${selectedCombos.size}개만 돕니다. 나머지를 나중에 재도 " +
"같은 자세·충만도면 합산되어 완료로 잡힙니다."
)
}
Spacer(Modifier.height(16.dp))
// 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상
// 조건으로 돌게 되므로 눈에 띄어야 한다.
restoreNote?.let { note ->
val failed = guardRestoreFailed(note)
Text(
note,
fontSize = 12.sp,
color = if (failed) MlCritical else MlSecondaryText,
modifier = Modifier
.fillMaxWidth()
.background(
(if (failed) MlCritical else MlSuccess).copy(alpha = 0.10f),
RoundedCornerShape(8.dp),
)
.padding(10.dp),
)
Spacer(Modifier.height(12.dp))
}
if (running) {
val (f, c) = HOSPITAL_COMBINATIONS[comboIdx]
val runCombos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos }
val (f, c) = runCombos.getOrElse(comboIdx) { runCombos.firstOrNull() ?: HOSPITAL_COMBINATIONS[0] }
ProgressPanel(
comboIdx = comboIdx, repeatIdx = repeatIdx, repeats = repeats,
comboIdx = comboIdx, comboCount = runCombos.size,
repeatIdx = repeatIdx, repeats = repeats,
freqLabel = f.label, cycles = c.cycles,
ok = okCount, fail = failCount, lastFile = lastFile,
)
@@ -354,6 +443,27 @@ fun HospitalModeView(appState: AppState) {
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)),
) { Text("중지", color = Color.White, fontWeight = FontWeight.Bold) }
// ── 채널 파형 ───────────────────────────────────────────────
// 측정하는 사람이 값이 제대로 잡히는지 **그 자리에서** 보게 한다.
// 20회를 다 돌린 뒤에야 파일을 열어 보면, 잘못 붙은 것을 알았을 때는
// 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다).
//
// 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고
// 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다.
Spacer(Modifier.height(12.dp))
Label("채널 파형")
Spacer(Modifier.height(6.dp))
SixChannelWaveformGrid(
channels = liveChannels,
modifier = Modifier.fillMaxWidth(),
cellHeight = 140.dp,
compact = true,
)
if (liveChannels.isEmpty()) {
Hint("첫 회차를 받으면 파형이 나타납니다.")
}
Spacer(Modifier.height(16.dp))
} else {
Button(
onClick = { comboIdx = 0; repeatIdx = 0; running = true },
@@ -390,11 +500,16 @@ fun HospitalModeView(appState: AppState) {
)
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
// 한계를 알고 있어야 한다.
// 남는 값은 **선택한 것 중 프로토콜 순서상 마지막** 조합이다. 6개를 다 돌면
// 2.3MHz·c7 이지만 부분 선택이면 달라지므로, 박아 두지 않고 실제 값을 보여준다.
// 종전에는 "(2.3MHz · cycle 7)" 로 고정돼 있어 부분 선택 시 틀린 안내였다.
val lastCombo = HOSPITAL_COMBINATIONS.lastOrNull { it in selectedCombos }
Text(
"⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " +
"avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " +
"samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나도 마지막 조합" +
"(2.3MHz · cycle 7) 설정이 프로브에 남아 일반 측정에도 적용됩니다.",
"samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나면 마지막으로 돈 조합" +
(lastCombo?.let { "(${it.first.label}MHz · cycle ${it.second.cycles})" } ?: "") +
" 설정이 프로브에 남아 일반 측정에도 적용됩니다.",
fontSize = 11.sp, color = Color(0xFFB26A00),
modifier = Modifier.padding(top = 6.dp, bottom = 24.dp),
)
@@ -402,6 +517,64 @@ fun HospitalModeView(appState: AppState) {
}
}
/**
* 6조합 다중 선택. 2열 × 3행으로 주파수별 묶어 보여준다.
*
* 칩 한 줄(`ChipRow`)과 달리 **다중 선택**이라 따로 둔다. 진행 칩과 생김새를 맞추되,
* 선택은 "이번에 돌 것"이고 진행 칩의 초록은 "이미 잰 것"이라 의미가 다르므로 색을
* 겹치지 않게 파랑 계열만 쓴다.
*/
@Composable
private fun CombinationPicker(
selected: Set<Pair<ProbeFrequency, ProbeCycle>>,
onToggle: (Pair<ProbeFrequency, ProbeCycle>) -> Unit,
enabled: Boolean,
) {
Column(
Modifier.fillMaxWidth().padding(top = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
ProbeFrequency.entries.forEach { f ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ProbeCycle.entries.forEach { c ->
val combo = f to c
val on = combo in selected
Box(
Modifier
.weight(1f)
.background(
if (on) MlPrimary else Color.White,
RoundedCornerShape(10.dp),
)
.then(
if (!on) Modifier.border(1.dp, MlSecondaryText.copy(alpha = 0.35f),
RoundedCornerShape(10.dp))
else Modifier
)
.clickable(enabled = enabled) { onToggle(combo) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
"${f.label} · c${c.cycles}",
fontSize = 13.sp,
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
color = if (on) Color.White else MlSecondaryText,
)
}
}
}
}
}
}
/** 복원 안내가 실패를 말하는가 — 색을 가르는 데만 쓴다. */
private fun guardRestoreFailed(note: String): Boolean =
note.contains("못했습니다") || note.contains("실패")
@Composable private fun Label(text: String) =
Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
@@ -496,17 +669,17 @@ private fun <T> ChipRow(
@Composable
private fun ProgressPanel(
comboIdx: Int, repeatIdx: Int, repeats: Int,
comboIdx: Int, comboCount: Int, repeatIdx: Int, repeats: Int,
freqLabel: String, cycles: Int, ok: Int, fail: Int, lastFile: String?,
) {
val total = HOSPITAL_COMBINATIONS.size * repeats
val total = comboCount * repeats
val done = comboIdx * repeats + repeatIdx
Column(
Modifier.fillMaxWidth()
.background(Color.White, RoundedCornerShape(12.dp))
.padding(14.dp)
) {
Text("측정 중 — ${comboIdx + 1}/${HOSPITAL_COMBINATIONS.size} 조합",
Text("측정 중 — ${comboIdx + 1}/$comboCount 조합",
fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Text("$freqLabel MHz · cycle $cycles · $repeatIdx/$repeats 회",
@@ -587,14 +760,37 @@ private fun ProbeParamCard(bleManager: BleManager, enabled: Boolean) {
.background(Color.White, RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Text("프로브 파라미터 직접 설정", fontSize = 15.sp, fontWeight = FontWeight.Bold)
Text("프로브 파라미터 확인 · 설정", fontSize = 15.sp, fontWeight = FontWeight.Bold)
Text(
"고른 값을 프로브에 써 넣습니다. avg ${HospitalFixedParams.AVG} · " +
"프로브에 저장된 값을 읽거나, 고른 값을 써 넣습니다. avg ${HospitalFixedParams.AVG} · " +
"delay ${HospitalFixedParams.DELAY_US}µs · samples ${HospitalFixedParams.SAMPLES} 는 고정입니다.",
fontSize = 11.sp, color = MlSecondaryText,
modifier = Modifier.padding(top = 2.dp, bottom = 8.dp),
)
// ── 현재 프로브 설정 (mcf?) ─────────────────────────────────────────
// 임상 루프가 조합마다 파라미터를 덮어쓰고 되돌리지 않으므로, 일반 측정으로
// 돌아가기 전에 지금 무엇이 들어 있는지 볼 수 있어야 한다. `mcf?` 는 **읽기만**
// 한다 — 확인하려고 값을 바꾸는 일이 없다.
val readCfg by bleManager.piezoConfigRead
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
readCfg?.let { c ->
c.error?.let { "현재 설정: 읽기 실패 ($it)" }
?: "현재 설정: freq ${c.freq} · cycle ${c.cycles} · avg ${c.avg} · " +
"delay ${c.delayUs}µs · samples ${c.samples}"
} ?: "현재 설정: 아직 안 읽음",
fontSize = 12.sp,
color = if (readCfg?.error != null) MlCritical else MlSecondaryText,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = { bleManager.sendPiezoConfigQuery() },
enabled = connected && !busy,
) { Text("읽기") }
}
Spacer(Modifier.height(8.dp))
Label("주파수")
ChipRow(ProbeFrequency.entries, freq, { freq = it }, enabled && !busy) {
"${it.label}MHz"