fix(clinical): 이름 없이 정렬해도 데이터를 잃지 않게

정렬 화면에는 환자명 입력란이 없었다(병원 임상 모드에만 있었다). 정렬부터 시작하면
이름이 빈 채로 재게 되는데, 저장이 `patient.isNotBlank()` 로 막혀 있어 20 cycle ×
위치 수를 통째로 잃었다. 임상에서 제일 아까운 것이 이미 받은 데이터다.

두 방향으로 막는다:
- 정렬 화면에 **환자명 입력란** 추가. AppState 공유라 병원 모드와 값이 오간다.
  입력 여부에 따라 저장 위치를 supportingText 로 보여 준다.
- 그래도 비어 있으면 `unnamed_HHmmss` 폴더에 **무조건 저장**한다. 세션 시작 시각
  기준이라 한 정렬의 위치별 파일이 한 폴더에 모인다.

align_result.json 에 `save_name` 과 `patient_unnamed` 를 남긴다 — 이름 없이 잰
데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다.

## 실리콘 첫 대조 (VBT2607R300 · FW VBTFW0203)
앱이 저장한 정렬 raw 를 Python `AnchorGuide(ver='r3')` 로 돌려 전 항목 일치 확인:
n_trace 11 · nch 0 · ch3 X(0%) · cap_frac 1.0 · MOVE_UP, 3위치 모두.
프리셋 자동 판별(R300)과 20 cycle → 11 trace 규약도 확인했다.
(공기 중 측정이라 nch=0 이 정상이다 — 파형이 s0 최대에서 단조 감소해 ~700
잡음바닥으로 수렴하고 세 위치가 사실상 동일하다. 없는 벽을 만들지 않는다.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:03:32 +09:00
parent 20a48ade96
commit 7d44b22e96
@@ -51,7 +51,9 @@ import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.services.HospitalRunStore
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import com.medithings.vesiscan.models.ProbeCycle
import com.medithings.vesiscan.models.ProbeFrequency
import com.medithings.vesiscan.ui.theme.*
@@ -85,6 +87,12 @@ fun AnchorAlignView(appState: AppState) {
var forceFinish by remember { mutableStateOf(false) }
// 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다.
val runStartedAt = remember { Date() }
// 저장 폴더 이름. 환자명이 비면 시각으로 대체한다(데이터를 잃지 않기 위해).
// 세션 안에서 고정돼야 위치별 파일이 한 폴더에 모인다.
val fallbackName = remember {
"unnamed_" + SimpleDateFormat("HHmmss", Locale.US).format(runStartedAt)
}
val saveName = appState.hospitalPatient.ifBlank { fallbackName }
val done = last?.done == true
@@ -149,14 +157,14 @@ fun AnchorAlignView(appState: AppState) {
delay(CYCLE_GAP_MS)
}
// 판정 **전에** raw 를 남긴다. 앱의 검출이 레퍼런스와 어긋나 있는 동안에는
// 이 파일이 정답을 되살릴 유일한 수단이다 — 판정이 실패해도 데이터는 남아야 한다.
val patient = appState.hospitalPatient
if (patient.isNotBlank()) {
val dir = HospitalRunStore.alignDir(patient, runStartedAt)
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
// 판정 **전에** raw 를 남긴다. 판정이 실패해도 데이터는 남아야 한다.
//
// 환자명이 비어 있어도 저장한다. 종전에는 이름이 없으면 그냥 안 남겼는데,
// 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서
// 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다.
val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
val step = guide.step(cycles, cm)
@@ -164,13 +172,11 @@ fun AnchorAlignView(appState: AppState) {
records = guide.records
// 결과 요약도 매 위치마다 갱신한다. 중간에 앱이 죽어도 여기까지는 남는다.
if (patient.isNotBlank()) {
HospitalRunStore.writeAlignSummary(
HospitalRunStore.alignDir(patient, runStartedAt),
buildAlignSummary(patient, deviceName, bleManager.firmwareVersion.value,
guide.records, step),
)
}
HospitalRunStore.writeAlignSummary(
dir,
buildAlignSummary(saveName, appState.hospitalPatient, deviceName,
bleManager.firmwareVersion.value, guide.records, step),
)
// 안내대로 다음 측정 위치를 미리 맞춰 둔다. 사용자는 프로브만 옮기면 된다.
when (step.action) {
AnchorAction.MOVE_UP -> cm += 1
@@ -230,6 +236,27 @@ fun AnchorAlignView(appState: AppState) {
"위치당 ${AnchorConfig.ALIGN_CYCLES}회 (고정)",
fontSize = 13.sp, color = MlSecondaryText,
)
Spacer(Modifier.height(12.dp))
// 환자명을 여기서도 받는다. 병원 임상 모드에만 있어서, 정렬부터 시작하면
// 이름 없이 재게 되고 저장 폴더가 `unnamed_*` 로 갈렸다.
// 값은 AppState 공유라 병원 모드로 돌아가도 그대로 남는다.
OutlinedTextField(
value = appState.hospitalPatient,
onValueChange = { if (!measuring) appState.hospitalPatient = it },
label = { Text("환자명") },
singleLine = true,
enabled = !measuring,
supportingText = {
Text(
if (appState.hospitalPatient.isBlank())
"비워 두면 $fallbackName 폴더에 저장됩니다."
else "저장 위치: VesiScan_Hospital/…_${appState.hospitalPatient}/align/",
fontSize = 11.sp,
)
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(10.dp))
// 검출·BV·선택 규칙이 Python 레퍼런스와 대조 완료(2026-09-03)라
// "검증 전" 경고는 걷어냈다. 다만 원시 데이터 저장은 유지한다 — 재판정·재분석
@@ -390,6 +417,9 @@ private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) {
* 나중에 Python 으로 다시 판정할 때 "앱은 무엇을 보고 그렇게 골랐나" 를 대조하는 데 쓴다.
*/
private fun buildAlignSummary(
/** 저장 폴더 이름 — 환자명이 비면 `unnamed_HHmmss`. */
saveName: String,
/** 사용자가 실제로 입력한 환자명. 비어 있으면 그대로 빈 문자열로 남긴다. */
patient: String,
deviceName: String?,
firmware: String?,
@@ -397,6 +427,9 @@ private fun buildAlignSummary(
step: AnchorStep,
): JSONObject = JSONObject().apply {
put("patient", patient)
put("save_name", saveName)
// 이름 없이 잰 데이터인지 파일만 봐도 알 수 있어야 나중에 짝을 맞출 수 있다.
put("patient_unnamed", patient.isBlank())
put("device", deviceName ?: "")
put("firmware_version", firmware ?: "")
put("hw_preset", com.medithings.vesiscan.managers.PiezoHW.activePreset.name)