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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user