feat(clinical): 수동 확인을 폰에 기록 — CSV(분석) + JSON(보관)
구분선 아래 1회/연속 측정은 화면에만 떴다가 사라졌다. 검증 항목 ③(위치 민감도 —
부착 위치에서 상·하·좌·우 1~2cm 옮겨 비교)이 바로 그 측정으로 하는 일인데, 남는 게
없으면 간호사가 종이에 받아 적어야 한다.
labdb 에는 올리지 않는다(사용자 결정). 저장 위치:
Downloads/VesiScan_Hospital/<날짜_환자>/manual/
manual_2026-09-09.csv 하루치 이어 붙임 — 행 하나 = 한 번의 확인
manual_2026-09-09_143052.json 누를 때마다 하나 — 6채널 원신호 + 채널별 벽
**하위 폴더인 것이 중요하다.** HospitalLabdbUploader.pending 과
HospitalRunStore.scanProgress 는 둘 다 폴더 최상위만 훑으므로, 여기 둔 CSV 가 600 으로
올라가지도 않고 진행 판정에 끼지도 않는다. 600 은 정확도 검증의 모집단이라, 조작자가
아무 자리에서 아무 때나 누른 값이 섞이면 "정해진 조건에서 받은 데이터"라는 전제가 깨진다.
## 자동 저장하지 않는다
연속 모드는 초당 두 번 값을 낸다. 자동으로 남기면 의미 없는 행이 수백 개 쌓여 정작
비교하려는 위치별 값이 묻힌다. 조작자가 "이 자리 값은 남길 것"이라고 판단한 순간만
[이 확인 기록] 으로 남긴다.
## 위치를 자유 문구로 받지 않는다
ManualCheckStore.Offset 9개(기준 · 위1·2 · 아래1·2 · 좌1·2 · 우1·2)로 고정하고, 메모는
그 옆에 따로 받는다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀 나중에
묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면 데이터가 아니라
메모가 된다. 3열 격자로 두어 방향이 눈에 보이게 했다(드롭다운은 잘못 고른 것도 못 본다).
## 대표값은 화면과 같은 식이어야 한다
파일에 산술평균이 들어가면 보고서를 쓰는 사람이 화면을 믿어야 할지 파일을 믿어야 할지
알 수 없다. 두 trimmedMean 을 테스트에서 직접 맞댔다.
## 저장 실패를 조용히 넘기지 않는다
save 가 null 이면 빨간 글씨로 띄운다. 조용히 넘기면 조작자는 기록됐다고 믿고 다음
위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다.
BvMeasureSection 이 supine:Boolean 대신 posture·fill·patient·anchorCm·anchorBasis 를
받는다. CSV 한 행만 보고 "어떤 상태에서 잰 값인가"에 답할 수 있어야 한다.
테스트 121개 통과(신규 7). 메모의 쉼표·따옴표·줄바꿈이 열을 밀지 않는지, 오프셋 기록
문자열이 고정인지를 잡았다 — 쉼표 하나가 그 행의 뒷열을 전부 한 칸씩 밀어 CSV 가
조용히 어긋나는 것이 제일 찾기 어렵다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* ManualCheckStore — 수동 BV 확인 기록. 폰에만 남긴다(labdb 업로드 없음).
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.managers.AnchorBasis
|
||||
import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.models.BladderFill
|
||||
import com.medithings.vesiscan.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 병원 임상 화면 아래쪽 **수동 확인**(1회/연속 측정)의 기록.
|
||||
*
|
||||
* ## 왜 프로토콜 데이터와 섞지 않는가
|
||||
* 600(프로토콜)은 정확도 검증의 **모집단**이다. 수동 확인은 조작자가 궁금해서 아무
|
||||
* 자리에서 아무 때나 누르는 것이라, 같은 곳에 쌓이면 "정해진 조건에서 받은 데이터"라는
|
||||
* 전제가 깨진다. 그래서 같은 환자·날짜 폴더 **밑의 `manual/`** 에 따로 둔다.
|
||||
*
|
||||
* 하위 폴더인 것이 중요하다. [labdb.HospitalLabdbUploader.pending] 과
|
||||
* [HospitalRunStore.scanProgress] 는 둘 다 폴더 **최상위만** 훑으므로, 여기 둔 CSV 는
|
||||
* 600 으로 올라가지도 않고 진행 판정에 끼지도 않는다.
|
||||
*
|
||||
* ## 파일이 둘인 이유
|
||||
* **CSV 는 분석용**이다 — 하루치를 한 파일에 이어 붙여, USB 로 빼서 엑셀로 열면 행끼리
|
||||
* 바로 비교된다. 검증 항목 ③(위치 민감도)이 "부착 위치에서 상·하·좌·우 1~2cm 옮겨
|
||||
* 비교"라 위치 = 행이어야 한다.
|
||||
*
|
||||
* **JSON 은 보관용**이다 — 누를 때마다 하나씩, 6채널 원신호와 채널별 벽 위치까지.
|
||||
* 숫자가 이상할 때 "그때 파형이 어땠나"로 돌아갈 수 있어야 한다. CSV 에 원신호까지
|
||||
* 넣으면 한 행이 600열을 넘어 사람이 못 읽는다.
|
||||
*
|
||||
* ## 위치를 자유 문구로 받지 않는다
|
||||
* [Offset] 으로 고정한다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀
|
||||
* 나중에 묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면
|
||||
* 데이터가 아니라 메모가 된다. 메모는 그 옆에 따로 받는다.
|
||||
*/
|
||||
object ManualCheckStore {
|
||||
|
||||
/** 부착 위치를 기준으로 한 오프셋. 항목 ③(위치 민감도)이 이 축으로 비교한다. */
|
||||
enum class Offset(val label: String, val wire: String) {
|
||||
BASE("기준", "base"),
|
||||
UP_1("위 1cm", "up_1"),
|
||||
UP_2("위 2cm", "up_2"),
|
||||
DOWN_1("아래 1cm", "down_1"),
|
||||
DOWN_2("아래 2cm", "down_2"),
|
||||
LEFT_1("좌 1cm", "left_1"),
|
||||
LEFT_2("좌 2cm", "left_2"),
|
||||
RIGHT_1("우 1cm", "right_1"),
|
||||
RIGHT_2("우 2cm", "right_2"),
|
||||
}
|
||||
|
||||
data class Saved(val csv: File, val json: File)
|
||||
|
||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val stampFmt = SimpleDateFormat("HHmmss", Locale.US)
|
||||
private val isoFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
|
||||
/** 같은 환자·날짜 폴더 밑 `manual/`. */
|
||||
fun dir(patient: String, at: Date): File =
|
||||
File(HospitalRunStore.runDir(patient, at), "manual").apply { if (!exists()) mkdirs() }
|
||||
|
||||
private val COLUMNS = listOf(
|
||||
"timestamp", "patient", "posture", "fill_pct",
|
||||
"anchor_cm", "anchor_basis", "offset", "memo",
|
||||
"mode", "freq_mhz", "freq_option", "cycles",
|
||||
"avg", "delay_us", "samples", "hw_preset", "preset_confident", "algo",
|
||||
"n", "trimmed_ml", "mean_ml", "sd_ml", "cv_pct", "min_ml", "max_ml",
|
||||
"detected_center", "failure", "snapshot_json",
|
||||
)
|
||||
|
||||
/**
|
||||
* 한 번의 확인을 남긴다. CSV 한 행 + JSON 한 개.
|
||||
*
|
||||
* @param volumes 이 확인의 BV 들. 연속이면 표시 창 그대로, 1회면 값 하나.
|
||||
* @return 실패하면 null — 호출부가 화면에 알려야 한다. 조용히 넘기면 조작자는
|
||||
* 기록됐다고 믿고 다음 위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다.
|
||||
*/
|
||||
fun save(
|
||||
patient: String,
|
||||
at: Date,
|
||||
posture: ClinicalPosture,
|
||||
fill: BladderFill,
|
||||
anchorCm: Int?,
|
||||
anchorBasis: AnchorBasis?,
|
||||
offset: Offset,
|
||||
memo: String,
|
||||
/** `"spot"` 또는 `"continuous"`. */
|
||||
mode: String,
|
||||
freq: ProbeFrequency,
|
||||
cycle: ProbeCycle,
|
||||
volumes: List<Double>,
|
||||
outcome: ClinicalBv.Outcome?,
|
||||
raw: List<PiezoChannelData>,
|
||||
): Saved? = runCatching {
|
||||
val d = dir(patient, at)
|
||||
val stamp = stampFmt.format(at)
|
||||
val jsonName = "manual_${dayFmt.format(at)}_$stamp.json"
|
||||
|
||||
val n = volumes.size
|
||||
val mean = if (n > 0) volumes.average() else Double.NaN
|
||||
val sd = if (n > 1) sqrt(volumes.sumOf { (it - mean) * (it - mean) } / n) else 0.0
|
||||
// 대표값은 화면과 **같은 식**이어야 한다. 화면은 절사평균을 보여주는데 파일에
|
||||
// 산술평균이 들어가면 둘이 다른 수가 되어, 보고서와 화면을 나란히 둘 수 없다.
|
||||
val trimmed = if (n > 0) trimmedMean(volumes) else Double.NaN
|
||||
|
||||
val row = listOf(
|
||||
isoFmt.format(at), patient, posture.label, fill.percent.toString(),
|
||||
anchorCm?.toString() ?: "", anchorBasis?.wire ?: "", offset.wire, memo,
|
||||
mode, freq.label, freq.freqOption.toString(), cycle.cycles.toString(),
|
||||
HospitalFixedParams.AVG.toString(), HospitalFixedParams.DELAY_US.toString(),
|
||||
HospitalFixedParams.SAMPLES.toString(),
|
||||
outcome?.preset ?: "", (outcome?.presetConfident ?: false).toString(),
|
||||
outcome?.algoLabel ?: "",
|
||||
n.toString(), fmt(trimmed), fmt(mean), fmt(sd),
|
||||
fmt(if (mean > 0) sd / mean * 100 else 0.0),
|
||||
fmt(volumes.minOrNull()), fmt(volumes.maxOrNull()),
|
||||
outcome?.detectedCenter?.toString() ?: "", outcome?.failure ?: "",
|
||||
jsonName,
|
||||
).map(::csvField)
|
||||
|
||||
// 하루치를 한 파일에 이어 붙인다. 헤더는 처음 만들 때만.
|
||||
val csv = File(d, "manual_${dayFmt.format(at)}.csv")
|
||||
val fresh = !csv.exists() || csv.length() == 0L
|
||||
csv.appendText(
|
||||
(if (fresh) COLUMNS.joinToString(",") + "\n" else "") + row.joinToString(",") + "\n",
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
|
||||
val json = File(d, jsonName)
|
||||
json.writeText(
|
||||
snapshot(
|
||||
at, patient, posture, fill, anchorCm, anchorBasis, offset, memo,
|
||||
mode, freq, cycle, volumes, trimmed, mean, sd, outcome, raw,
|
||||
).toString(2),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
Saved(csv, json)
|
||||
}.getOrElse {
|
||||
android.util.Log.w("ManualCheckStore", "수동 확인 저장 실패: ${it.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대·최소 하나씩 버린 평균. 화면(`BvMeasureSection.trimmedMean`)과 **같은 식**이어야
|
||||
* 한다 — 화면은 절사평균을 보여주는데 파일에 다른 수가 들어가면 보고서와 화면을
|
||||
* 나란히 둘 수 없다. `ManualCheckStoreTest` 가 두 함수를 직접 맞대어 고정한다.
|
||||
*/
|
||||
internal fun trimmedMean(values: List<Double>): Double {
|
||||
if (values.size <= 2) return values.average()
|
||||
val sorted = values.sorted()
|
||||
return sorted.subList(1, sorted.size - 1).average()
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
at: Date,
|
||||
patient: String,
|
||||
posture: ClinicalPosture,
|
||||
fill: BladderFill,
|
||||
anchorCm: Int?,
|
||||
anchorBasis: AnchorBasis?,
|
||||
offset: Offset,
|
||||
memo: String,
|
||||
mode: String,
|
||||
freq: ProbeFrequency,
|
||||
cycle: ProbeCycle,
|
||||
volumes: List<Double>,
|
||||
trimmed: Double,
|
||||
mean: Double,
|
||||
sd: Double,
|
||||
outcome: ClinicalBv.Outcome?,
|
||||
raw: List<PiezoChannelData>,
|
||||
): JSONObject = JSONObject().apply {
|
||||
put("kind", "manual_bv_check")
|
||||
put("timestamp", isoFmt.format(at))
|
||||
put("patient", patient)
|
||||
put("patient_unnamed", patient.isBlank())
|
||||
put("posture", posture.label)
|
||||
put("fill_pct", fill.percent)
|
||||
put("anchor_cm", anchorCm ?: JSONObject.NULL)
|
||||
put("anchor_basis", anchorBasis?.wire ?: JSONObject.NULL)
|
||||
put("offset", offset.wire)
|
||||
put("offset_label", offset.label)
|
||||
put("memo", memo)
|
||||
put("mode", mode)
|
||||
put("freq_mhz", freq.label)
|
||||
put("freq_option", freq.freqOption)
|
||||
put("cycles", cycle.cycles)
|
||||
put("avg", HospitalFixedParams.AVG)
|
||||
put("delay_us", HospitalFixedParams.DELAY_US)
|
||||
put("samples", HospitalFixedParams.SAMPLES)
|
||||
put("hw_preset", outcome?.preset ?: JSONObject.NULL)
|
||||
put("preset_confident", outcome?.presetConfident ?: false)
|
||||
put("algo", outcome?.algoLabel ?: JSONObject.NULL)
|
||||
put("volumes_ml", JSONArray().apply { volumes.forEach { put(it) } })
|
||||
put("trimmed_ml", if (trimmed.isFinite()) trimmed else JSONObject.NULL)
|
||||
put("mean_ml", if (mean.isFinite()) mean else JSONObject.NULL)
|
||||
put("sd_ml", sd)
|
||||
put("detected_center", outcome?.detectedCenter ?: JSONObject.NULL)
|
||||
put("failure", outcome?.failure ?: JSONObject.NULL)
|
||||
// 채널별 벽 — 숫자가 이상할 때 제일 먼저 보는 것이다.
|
||||
put("walls", JSONArray().apply {
|
||||
outcome?.walls?.forEach { w ->
|
||||
put(if (w == null) JSONObject.NULL else JSONObject().apply {
|
||||
put("ch", w.channel)
|
||||
put("ant", w.ant); put("post", w.post)
|
||||
put("ant_mm", w.antMm); put("post_mm", w.postMm)
|
||||
put("diameter_mm", w.diameterMm ?: JSONObject.NULL)
|
||||
put("used_in_bv", w.usedInBv)
|
||||
})
|
||||
}
|
||||
})
|
||||
put("channels", JSONArray().apply {
|
||||
raw.sortedBy { it.channel }.forEach { c ->
|
||||
put(JSONObject().apply {
|
||||
put("ch", c.channel)
|
||||
val buf = c.buffer
|
||||
val peak = buf.maxOrNull() ?: 0
|
||||
put("peak", peak)
|
||||
put("peakIdx", buf.indexOfFirst { it == peak })
|
||||
put("data", JSONArray().apply { buf.forEach { put(it) } })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun fmt(v: Double?): String =
|
||||
if (v == null || !v.isFinite()) "" else "%.2f".format(v)
|
||||
|
||||
/** 쉼표·따옴표·줄바꿈이 섞인 메모가 열을 밀지 않게 한다. */
|
||||
internal fun csvField(s: String): String =
|
||||
if (s.any { it == ',' || it == '"' || it == '\n' || it == '\r' })
|
||||
"\"" + s.replace("\"", "\"\"").replace("\r\n", " ").replace('\n', ' ')
|
||||
.replace('\r', ' ') + "\""
|
||||
else s
|
||||
}
|
||||
Reference in New Issue
Block a user