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