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:
2026-09-02 16:53:47 +09:00
parent 5f40896bee
commit 53fd07c342
6 changed files with 580 additions and 0 deletions
@@ -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)
}
}
}