feat(clinical): 병원 임상 측정 모드 — 주파수×cycle 6조합 자동 순회
## 조작자가 하는 일은 하나다
간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
[측정 시작]을 누른다. 이후 주파수 2종 × cycle 3종 = 6조합을 앱이 자동으로 순회하며
각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.
조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 다시
만들 수 없으니, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. 사람이 개입하는
지점을 하나로 줄인 것이 이 모드의 핵심이다.
진입: 홈에서 캐릭터 3연타 → 개발자 모드 → [병원 임상 측정]
## 프로토콜
자세 Supine / Sitting / Standing (기존 ClinicalPosture 재사용)
주파수 1.8 · 2.3 MHz → mpa? 첫 인자
cycle 3 · 5 · 7 → mpa? 둘째 인자
방광 채움 0/20/40/60/80/100 % → 사람이 아는 값 = 정답 라벨
반복 화면 입력 (기본 20)
## 저장
Downloads/VesiScan_Hospital/{날짜_환자}/
2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
run_HHmmss.json ← 계획 대비 실제 저장 수
파일명에 조건을 전부 적는다. 폴더로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는데,
임상 데이터는 나중에 다른 사람이 모아서 분석한다. CSV 열 구성은 기존 AdcCsvLogger 와
맞춰(scan_id·timestamp·channel·s0~s99) 분석 스크립트를 새로 만들지 않아도 되게 했다.
기존 임상 R&D(`VesiScan_Sessions/`)와 폴더를 나눴다 — 목적도 구조도 달라서 섞이면
나중에 파일을 하나씩 열어 봐야 한다.
## ⚠ 주파수 코드값이 확인 전이다
`mpa?` 첫 인자는 정수인데 그 정수와 MHz 의 대응이 코드에도
docs/BLE_PROTOCOL_REFERENCE.md 에도 없다. 기존 코드는 늘 `freqOption = 2` 만 썼다
(PlacementGuideView · MeasurementService). 그래서 1.8→1, 2.3→2 는 **가정**이다.
확인 전에 모은 데이터가 버려지지 않도록, 실제로 보낸 정수를 파일명(`fopt1`)과 CSV 열
(`freq_option`), run json 에 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꾸면 된다.
run json 에 `freq_option_mapping_confirmed: false` 를 박아 두어 나중에 이 데이터가
어떤 상태에서 모였는지 알 수 있게 했다. 화면에도 같은 경고를 띄운다.
## 데이터 정합성
결과를 평범한 var 로 주고받으면 BLE 스레드↔코루틴 간 가시성이 보장되지 않아 Channel
을 쓴다. 그리고 **회차마다 보내기 전에 채널을 비운다** — 직전 회차가 시간초과된 뒤
뒤늦게 도착한 결과가 남아 있으면 이번 회차 데이터로 잘못 기록된다.
한 회차가 3초 안에 안 오면 실패로 세고 다음으로 넘어간다. 한 번 막혔다고 전체가
멈추면 채워 둔 방광을 버리게 된다. 실패 수는 화면과 run json 에 남는다.
## 검증
빌드 통과. **실기기 확인은 아직 못 했다** — 폰이 절전 상태로 들어가 화면을 못 띄웠고,
프로브 연결 상태의 측정 루프는 전혀 돌려보지 못했다. 내일 임상 전에 반드시 한 번
돌려봐야 한다(6조합 × 20회 = 120측정 · 약 2~3분 예상).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ enum class AppScreen {
|
||||
VOIDING_DIARY, REMINDER_SETTINGS,
|
||||
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||
HOSPITAL_MODE, // ★ Dev 모드 한정 — 병원 임상 측정 (주파수×cycle 자동 순회)
|
||||
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
onBack = { appState.backToMonitoring() }
|
||||
)
|
||||
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
|
||||
AppScreen.HOSPITAL_MODE -> com.medithings.vesiscan.ui.views.clinical.HospitalModeView(appState)
|
||||
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
|
||||
AppScreen.FIRMWARE_UPDATE -> com.medithings.vesiscan.firmware.ui.FirmwareUpdateScreen(
|
||||
onBack = { appState.currentScreen = AppScreen.HOME }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* HospitalProtocol — 병원 임상 측정 프로토콜 정의.
|
||||
*
|
||||
* ## 무엇을 하는 모드인가
|
||||
* 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도 하나만** 고르고
|
||||
* [측정 시작]을 누른다. 그 뒤로는 앱이 주파수 2종 × cycle 3종 = **6조합을 자동으로
|
||||
* 순회**하며 각 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다.
|
||||
*
|
||||
* 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면
|
||||
* 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다.
|
||||
*
|
||||
* ## ⚠ 주파수 코드값이 아직 확인되지 않았다 (2026-09-02)
|
||||
* 펌웨어에 보내는 `mpa?` 명령의 첫 인자는 **정수**인데, 그 정수와 실제 MHz 의 대응이
|
||||
* 코드에도 `docs/BLE_PROTOCOL_REFERENCE.md` 에도 적혀 있지 않다. 기존 코드는 항상
|
||||
* `freqOption = 2` 만 쓰고 있었다(PlacementGuideView · MeasurementService).
|
||||
*
|
||||
* 그래서 [ProbeFrequency.freqOption] 값은 **가정**이다. 펌웨어팀 확인 후 이 표 한 줄만
|
||||
* 고치면 된다.
|
||||
*
|
||||
* 확인 전에 모은 데이터가 버려지지 않도록, 저장 파일명과 meta 에 **실제로 보낸 정수**를
|
||||
* `fopt{N}` 으로 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꿔 되살릴 수 있다.
|
||||
*/
|
||||
package com.medithings.vesiscan.models
|
||||
|
||||
/** 프로브 송신 주파수. [freqOption] 이 `mpa?` 의 첫 인자로 나간다. */
|
||||
enum class ProbeFrequency(val label: String, val freqOption: Int) {
|
||||
// ⚠ freqOption 값은 펌웨어팀 확인 전의 가정이다. 위 KDoc 참고.
|
||||
F_1_8("1.8", 1),
|
||||
F_2_3("2.3", 2), // 기존 코드가 늘 쓰던 값이 2 다.
|
||||
;
|
||||
|
||||
/** 파일명에 쓸 표기 — MHz 와 실제 보낸 정수를 함께 남긴다. */
|
||||
val fileTag: String get() = "${label}MHz-fopt$freqOption"
|
||||
}
|
||||
|
||||
/** 버스트 cycle 수. `mpa?` 의 둘째 인자로 그대로 나간다. */
|
||||
enum class ProbeCycle(val cycles: Int) {
|
||||
C3(3), C5(5), C7(7);
|
||||
|
||||
val fileTag: String get() = "c$cycles"
|
||||
}
|
||||
|
||||
/**
|
||||
* 방광 채움 정도 — 간호사가 채워 둔 값을 조작자가 고른다.
|
||||
*
|
||||
* 앱이 재는 값이 아니라 **사람이 아는 값**이다. 이 모드의 목적이 "채움 정도가 알려진
|
||||
* 상태에서 6채널 원신호를 모으는 것"이라, 이 값이 곧 정답 라벨이 된다.
|
||||
*/
|
||||
enum class BladderFill(val percent: Int) {
|
||||
P0(0), P20(20), P40(40), P60(60), P80(80), P100(100);
|
||||
|
||||
val label: String get() = "$percent%"
|
||||
val fileTag: String get() = "%03dpct".format(percent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 번의 [측정 시작]이 도는 조합 목록 — 주파수 2 × cycle 3 = 6.
|
||||
*
|
||||
* 순서를 고정해 둔다. 매번 같은 순서로 돌아야 나중에 파일을 비교할 때 조건이 섞이지
|
||||
* 않는다. 주파수를 바깥 고리에 둔 이유는 주파수 전환이 cycle 전환보다 프로브에 더 큰
|
||||
* 변화라, 한 주파수 안에서 cycle 셋을 몰아 끝내는 편이 안정적이기 때문이다.
|
||||
*/
|
||||
val HOSPITAL_COMBINATIONS: List<Pair<ProbeFrequency, ProbeCycle>> =
|
||||
ProbeFrequency.entries.flatMap { f -> ProbeCycle.entries.map { c -> f to c } }
|
||||
|
||||
/** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */
|
||||
const val HOSPITAL_DEFAULT_REPEATS = 20
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* HospitalRunStore — 병원 임상 모드의 측정 파일 저장.
|
||||
*
|
||||
* ## 저장 위치
|
||||
* Downloads/VesiScan_Hospital/{날짜_환자}/{조합}.csv
|
||||
*
|
||||
* 기존 임상 R&D 모드(`VesiScan_Sessions/`)와 폴더를 나눈다. 둘은 목적도 파일 구조도
|
||||
* 다르고, 병원에서 받은 데이터를 R&D 세션과 섞어 두면 나중에 어느 쪽인지 구분하려고
|
||||
* 파일을 하나씩 열어 봐야 한다.
|
||||
*
|
||||
* ## 파일명에 조건을 전부 적는다
|
||||
* 2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
|
||||
*
|
||||
* 폴더 구조로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는다. 임상 데이터는 나중에
|
||||
* 모아서 다른 사람이 분석하므로, **파일 하나만 봐도 조건을 알 수 있어야 한다.**
|
||||
* `fopt1` 은 그때 실제로 펌웨어에 보낸 정수다 — 주파수 대응이 나중에 바뀌어도
|
||||
* 이 값이 있으면 라벨을 되살릴 수 있다(HospitalProtocol KDoc 참고).
|
||||
*
|
||||
* ## CSV 형식
|
||||
* 기존 [AdcCsvLogger] 와 같은 열 구성을 쓴다(scan_id · timestamp · 채널 · s0~s99).
|
||||
* 분석 스크립트를 새로 만들지 않아도 되도록 맞춘 것이고, 앞쪽에 조건 열을 더했다.
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.medithings.vesiscan.models.BladderFill
|
||||
import com.medithings.vesiscan.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object HospitalRunStore {
|
||||
|
||||
private const val ROOT = "VesiScan_Hospital"
|
||||
|
||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val stampFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
/** 파일명에 못 쓰는 글자를 지운다. 환자명이 자유 입력이라 반드시 거친다. */
|
||||
private fun safe(raw: String): String =
|
||||
raw.trim().replace(Regex("""[^\p{L}\p{N}_-]"""), "-").take(40).ifBlank { "unknown" }
|
||||
|
||||
private fun downloads(): File =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
/** 한 환자·하루 단위 폴더. 같은 날 같은 환자를 여러 번 재면 파일이 이 안에 쌓인다. */
|
||||
fun runDir(patient: String, startedAt: Date): File {
|
||||
val dir = File(File(downloads(), ROOT), "${dayFmt.format(startedAt)}_${safe(patient)}")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 조합(주파수·cycle)의 파일. 이미 있으면 이어 쓴다 —
|
||||
* 같은 조건을 다시 재는 것은 정상적인 재측정이라 덮어쓰지 않는다.
|
||||
*/
|
||||
fun combinationFile(
|
||||
dir: File,
|
||||
patient: String,
|
||||
posture: ClinicalPosture,
|
||||
fill: BladderFill,
|
||||
freq: ProbeFrequency,
|
||||
cycle: ProbeCycle,
|
||||
startedAt: Date,
|
||||
): File = File(
|
||||
dir,
|
||||
listOf(
|
||||
dayFmt.format(startedAt),
|
||||
safe(patient),
|
||||
posture.label,
|
||||
fill.fileTag,
|
||||
freq.fileTag,
|
||||
cycle.fileTag,
|
||||
).joinToString("_") + ".csv"
|
||||
)
|
||||
|
||||
private const val HEADER_PREFIX =
|
||||
"scan_id,timestamp,patient,posture,fill_pct,freq_mhz,freq_option,cycles,repeat_idx," +
|
||||
"device,firmware_version,channel"
|
||||
|
||||
/**
|
||||
* 6채널 한 번을 기록한다. [channels] 는 채널 순으로 정렬된 100 샘플 버퍼 6개.
|
||||
*
|
||||
* 실패해도 예외를 던지지 않는다 — 측정 도중 파일 오류로 루프 전체가 멈추면 이미
|
||||
* 채워 둔 방광을 버리게 된다. 대신 false 를 돌려주어 화면이 실패 횟수를 셀 수 있다.
|
||||
*/
|
||||
fun appendMeasurement(
|
||||
file: File,
|
||||
scanId: Int,
|
||||
patient: String,
|
||||
posture: ClinicalPosture,
|
||||
fill: BladderFill,
|
||||
freq: ProbeFrequency,
|
||||
cycle: ProbeCycle,
|
||||
repeatIdx: Int,
|
||||
deviceName: String,
|
||||
firmwareVersion: String,
|
||||
channels: List<List<UShort>>,
|
||||
): Boolean = try {
|
||||
val isNew = !file.exists() || file.length() == 0L
|
||||
java.io.FileWriter(file, !isNew).buffered().use { w ->
|
||||
if (isNew) {
|
||||
w.write(buildString {
|
||||
append(HEADER_PREFIX)
|
||||
for (i in 0 until 100) append(",s$i")
|
||||
})
|
||||
w.newLine()
|
||||
}
|
||||
val meta = listOf(
|
||||
scanId.toString(),
|
||||
stampFmt.format(Date()),
|
||||
safe(patient),
|
||||
posture.label,
|
||||
fill.percent.toString(),
|
||||
freq.label,
|
||||
freq.freqOption.toString(),
|
||||
cycle.cycles.toString(),
|
||||
repeatIdx.toString(),
|
||||
deviceName,
|
||||
firmwareVersion,
|
||||
).joinToString(",")
|
||||
for ((chIdx, buffer) in channels.withIndex()) {
|
||||
w.write(buildString {
|
||||
append(meta); append(",CH"); append(chIdx)
|
||||
for (s in buffer) { append(','); append(s.toInt()) }
|
||||
for (i in buffer.size until 100) append(',')
|
||||
})
|
||||
w.newLine()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 요약. 파일명에 다 적혀 있지만, **무엇을 재려 했는지**(계획)와 **실제로 몇 개가
|
||||
* 남았는지**(결과)가 다를 수 있어 따로 남긴다. 중간에 연결이 끊기면 그 차이가 곧
|
||||
* 데이터 신뢰도다.
|
||||
*/
|
||||
fun writeSummary(dir: File, json: JSONObject, startedAt: Date) {
|
||||
runCatching {
|
||||
File(dir, "run_${SimpleDateFormat("HHmmss", Locale.US).format(startedAt)}.json")
|
||||
.writeText(json.toString(2), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/*
|
||||
* HospitalModeView — 병원 임상 측정 모드.
|
||||
*
|
||||
* ## 조작자가 하는 일은 하나다
|
||||
* 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
|
||||
* [측정 시작]을 누른다. 그 뒤로 주파수 2 × cycle 3 = 6조합을 앱이 자동으로 순회하며
|
||||
* 각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.
|
||||
*
|
||||
* 조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은
|
||||
* 다시 만들 수 없으므로, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다.
|
||||
*
|
||||
* ## 측정 한 번의 흐름
|
||||
* sendPiezoPowerOn(freqOption, cycles) ← 조합 진입 시 1회
|
||||
* sendMtb() → piezoCollector.onComplete ← n회 반복
|
||||
*
|
||||
* `sendMtb` 는 6채널+IMU 를 한 번에 받는 명령이고, 완료는 콜백으로 온다. 그래서 루프는
|
||||
* "보내고 기다린다"가 아니라 **콜백이 올 때마다 다음 것을 보내는** 구조다. 응답이
|
||||
* 안 오면 [MEASURE_TIMEOUT_MS] 뒤에 그 회차를 실패로 세고 다음으로 넘어간다 —
|
||||
* 한 번 막혔다고 전체가 멈추면 채워 둔 방광을 버리게 된다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.AppScreen
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.medithings.vesiscan.services.AdcCsvLogger
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
|
||||
/** 한 회차 응답을 이만큼 기다린다. 넘으면 실패로 세고 다음 회차로 넘어간다. */
|
||||
private const val MEASURE_TIMEOUT_MS = 3_000L
|
||||
|
||||
/** 조합을 바꾼 뒤 프로브가 새 설정으로 안정될 때까지. */
|
||||
private const val COMBINATION_SETTLE_MS = 800L
|
||||
|
||||
/** 회차 사이 간격 — 기존 임상 라이브(600ms)와 같은 값. */
|
||||
private const val REPEAT_GAP_MS = 600L
|
||||
|
||||
@Composable
|
||||
fun HospitalModeView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val isConnected by bleManager.isConnected
|
||||
val deviceName = bleManager.connectedDeviceName.value
|
||||
|
||||
var patient by remember { mutableStateOf("") }
|
||||
var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) }
|
||||
var fill by remember { mutableStateOf(BladderFill.P0) }
|
||||
var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) }
|
||||
|
||||
var running by remember { mutableStateOf(false) }
|
||||
var comboIdx by remember { mutableIntStateOf(0) }
|
||||
var repeatIdx by remember { mutableIntStateOf(0) }
|
||||
var okCount by remember { mutableIntStateOf(0) }
|
||||
var failCount by remember { mutableIntStateOf(0) }
|
||||
var lastFile by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS
|
||||
val canStart = isConnected && patient.isNotBlank() && !running
|
||||
|
||||
// ── 측정 루프 ────────────────────────────────────────────────────────
|
||||
LaunchedEffect(running) {
|
||||
if (!running) return@LaunchedEffect
|
||||
val startedAt = Date()
|
||||
val dir = HospitalRunStore.runDir(patient, startedAt)
|
||||
val fw = bleManager.firmwareVersion.value
|
||||
val results = JSONArray()
|
||||
okCount = 0; failCount = 0
|
||||
|
||||
// 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로
|
||||
// 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다.
|
||||
//
|
||||
// `onComplete` 는 **채널 하나**마다 오고, 6채널이 다 모이면
|
||||
// `onMultiChannelComplete` 가 한 번 온다 — 후자를 써야 한 회차가 된다.
|
||||
val inbox = kotlinx.coroutines.channels.Channel<List<com.medithings.vesiscan.ble.PiezoChannelData>>(
|
||||
kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
val prevOnComplete = bleManager.piezoCollector.onMultiChannelComplete
|
||||
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
|
||||
|
||||
try {
|
||||
HOSPITAL_COMBINATIONS.forEachIndexed { ci, (freq, cycle) ->
|
||||
if (!running) return@forEachIndexed
|
||||
comboIdx = ci
|
||||
val file = HospitalRunStore.combinationFile(
|
||||
dir, patient, posture, fill, freq, cycle, startedAt)
|
||||
|
||||
// 조합 진입 — 프로브에 주파수·cycle 을 실어 보낸다.
|
||||
bleManager.sendPiezoPowerOn(freqOption = freq.freqOption, cycles = cycle.cycles)
|
||||
delay(COMBINATION_SETTLE_MS)
|
||||
|
||||
var ok = 0; var fail = 0
|
||||
for (r in 1..repeats) {
|
||||
if (!running) break
|
||||
repeatIdx = r
|
||||
// 직전 회차가 시간초과된 뒤 뒤늦게 도착한 결과가 남아 있을 수 있다.
|
||||
// 그대로 두면 **이번 회차의 데이터로 잘못 기록된다** — 보내기 전에 비운다.
|
||||
while (inbox.tryReceive().isSuccess) { /* drain */ }
|
||||
bleManager.sendMtb()
|
||||
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
|
||||
if (got == null) {
|
||||
fail++; failCount++
|
||||
} else {
|
||||
val raw = got.sortedBy { it.channel }.map { it.buffer }
|
||||
val saved = HospitalRunStore.appendMeasurement(
|
||||
file = file,
|
||||
scanId = AdcCsvLogger.nextScanId(),
|
||||
patient = patient, posture = posture, fill = fill,
|
||||
freq = freq, cycle = cycle, repeatIdx = r,
|
||||
deviceName = deviceName ?: "", firmwareVersion = fw,
|
||||
channels = raw,
|
||||
)
|
||||
if (saved) { ok++; okCount++ } else { fail++; failCount++ }
|
||||
lastFile = file.name
|
||||
}
|
||||
delay(REPEAT_GAP_MS)
|
||||
}
|
||||
results.put(JSONObject().apply {
|
||||
put("freq_mhz", freq.label)
|
||||
put("freq_option", freq.freqOption)
|
||||
put("cycles", cycle.cycles)
|
||||
put("planned", repeats)
|
||||
put("saved", ok)
|
||||
put("failed", fail)
|
||||
put("file", file.name)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete
|
||||
inbox.close()
|
||||
HospitalRunStore.writeSummary(dir, JSONObject().apply {
|
||||
put("patient", patient)
|
||||
put("posture", posture.label)
|
||||
put("fill_pct", fill.percent)
|
||||
put("repeats", repeats)
|
||||
put("device", deviceName ?: "")
|
||||
put("firmware_version", fw)
|
||||
// ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다.
|
||||
put("freq_option_mapping_confirmed", false)
|
||||
put("combinations", results)
|
||||
}, startedAt)
|
||||
running = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(MlBackground)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (!running) appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||
}
|
||||
Text("병원 임상 측정", fontSize = 18.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Column(Modifier.padding(horizontal = 16.dp)) {
|
||||
|
||||
StatusRow(isConnected, deviceName)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = patient,
|
||||
onValueChange = { if (!running) patient = it },
|
||||
label = { Text("환자명") },
|
||||
singleLine = true,
|
||||
enabled = !running,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Label("자세")
|
||||
ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running) { it.label }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Label("방광 채움 정도 — 간호사가 채워 둔 값")
|
||||
ChipRow(BladderFill.entries, fill, { fill = it }, !running) { it.label }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = repeatsText,
|
||||
onValueChange = { if (!running) repeatsText = it.filter { c -> c.isDigit() }.take(3) },
|
||||
label = { Text("조합당 반복 횟수") },
|
||||
singleLine = true,
|
||||
enabled = !running,
|
||||
supportingText = { Text("6조합 × ${repeats}회 = 총 ${6 * repeats}회 측정") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (running) {
|
||||
val (f, c) = HOSPITAL_COMBINATIONS[comboIdx]
|
||||
ProgressPanel(
|
||||
comboIdx = comboIdx, repeatIdx = repeatIdx, repeats = repeats,
|
||||
freqLabel = f.label, cycles = c.cycles,
|
||||
ok = okCount, fail = failCount, lastFile = lastFile,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = { running = false },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)),
|
||||
) { Text("중지", color = Color.White, fontWeight = FontWeight.Bold) }
|
||||
} else {
|
||||
Button(
|
||||
onClick = { comboIdx = 0; repeatIdx = 0; running = true },
|
||||
enabled = canStart,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) { Text("측정 시작", fontSize = 17.sp, fontWeight = FontWeight.Bold) }
|
||||
|
||||
if (!isConnected) Hint("기기를 먼저 연결해 주세요.")
|
||||
else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
|
||||
|
||||
if (okCount > 0 || failCount > 0) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("직전 실행 — 저장 $okCount · 실패 $failCount",
|
||||
fontSize = 13.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"저장 위치: Downloads/VesiScan_Hospital/",
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
|
||||
// 한계를 알고 있어야 한다.
|
||||
Text(
|
||||
"⚠ 주파수 코드값(mpa 첫 인자)은 펌웨어팀 확인 전입니다. " +
|
||||
"파일에 실제 전송값(fopt)을 함께 기록하므로 나중에 라벨을 고칠 수 있습니다.",
|
||||
fontSize = 11.sp, color = Color(0xFFB26A00),
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun Label(text: String) =
|
||||
Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||
|
||||
@Composable private fun Hint(text: String) =
|
||||
Text(text, fontSize = 12.sp, color = MlSecondaryText, modifier = Modifier.padding(top = 6.dp))
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(connected: Boolean, deviceName: String?) {
|
||||
val color = if (connected) Color(0xFF4CAF50) else Color(0xFFD32F2F)
|
||||
Text(
|
||||
if (connected) "연결됨 · ${deviceName ?: ""}" else "연결 안 됨",
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color,
|
||||
modifier = Modifier
|
||||
.background(color.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
items: List<T>, selected: T, onSelect: (T) -> Unit, enabled: Boolean, label: (T) -> String,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
val on = item == selected
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.background(
|
||||
if (on) MlPrimary else Color.White,
|
||||
RoundedCornerShape(10.dp),
|
||||
)
|
||||
.clickable(enabled = enabled) { onSelect(item) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label(item), fontSize = 13.sp,
|
||||
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (on) Color.White else MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressPanel(
|
||||
comboIdx: Int, repeatIdx: Int, repeats: Int,
|
||||
freqLabel: String, cycles: Int, ok: Int, fail: Int, lastFile: String?,
|
||||
) {
|
||||
val total = HOSPITAL_COMBINATIONS.size * repeats
|
||||
val done = comboIdx * repeats + repeatIdx
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(Color.White, RoundedCornerShape(12.dp))
|
||||
.padding(14.dp)
|
||||
) {
|
||||
Text("측정 중 — ${comboIdx + 1}/${HOSPITAL_COMBINATIONS.size} 조합",
|
||||
fontSize = 15.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("$freqLabel MHz · cycle $cycles · $repeatIdx/$repeats 회",
|
||||
fontSize = 13.sp, color = MlSecondaryText)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { if (total > 0) done.toFloat() / total else 0f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("저장 $ok · 실패 $fail", fontSize = 12.sp, color = MlSecondaryText)
|
||||
lastFile?.let {
|
||||
Text(it, fontSize = 10.sp, color = MlSecondaryText.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,20 @@ fun HomeView(appState: AppState) {
|
||||
) {
|
||||
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
// 병원 임상 측정 — R&D 임상 모드와 목적이 다르다. 저쪽은 알고리즘 개발용
|
||||
// (정렬 단계·자유 라벨), 이쪽은 정해진 프로토콜을 빠짐없이 도는 것이 목적이다.
|
||||
Button(
|
||||
onClick = { appState.currentScreen = com.medithings.vesiscan.AppScreen.HOSPITAL_MODE },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0B7D6B))
|
||||
) {
|
||||
Text("병원 임상 측정", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
// 2026-08-03 (cloud-mvp 672e679 이식): 프로브 firmware 업데이트 진입점.
|
||||
TextButton(
|
||||
|
||||
Reference in New Issue
Block a user