feat(clinical): 정렬 파형을 간호사 판단으로 labdb 에 보낸다
파형이 이상해 보일 때 그 자리에서 개발자에게 보내는 통로가 없었다. 임상이 끝난 뒤
파일을 꺼내 보내면 회신이 하루 뒤인데, 그때는 이미 그 환자로 다시 잴 수 없다.
정렬 전용 dataType 902 를 새로 쓴다. 임상 측정(901)과 구조가 다르다 — 저쪽은 한
조건에서 20 반복이고 정렬은 여러 위치 × 20 cycle 이다. 같은 코드로 올리면 params
만으로는 구분이 안 되고 콘솔이 두 종류를 한 차트로 그린다. labdb 는 dataType 마다
records[] 스키마가 완전히 달라도 되고(labdb.md §2-3), 900~999 가 사용자 정의 구간이다.
record = 한 위치의 한 cycle
rowIndex : 위치를 가로질러 0..N 연속 (labdb 규약)
align_cm : 그 cycle 을 잰 위치
cycle_idx : 그 위치 안에서의 순번
channels : 6 × {ch, peak, peakIdx, data}
**판정 근거(align_result.json)를 params 에 통째로 넣는다.** 개발자가 답해야 할 질문이
"왜 이 위치를 골랐나"라서 파형만 오면 되묻게 된다 — 지표(nch·ch3·cap_frac)와 선택
결과, 좌우 결과가 같이 가야 한 번에 답이 나온다.
**자동이 아니다.** 간호사가 버튼을 눌러야 올라간다. 목적이 "이상하니 봐 달라"는
요청이라 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다. 무엇이 이상해 보였는지
한 줄을 받아 memo 로 보낸다 — 그게 없으면 "이게 왜 올라왔지"부터 시작한다.
params.upload_reason 에 사람이 올린 것임을 남겨 자동 업로드분과 섞이지 않게 했다.
마커는 남기지 않는다. 같은 정렬을 다시 보낼 수 있어야 한다 — 처음에 못 적은 증상을
memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면 갱신한다.
판정이 끝나기 전에도 보낼 수 있다. 첫 위치만 재고 이상하다 싶은 때가 오히려 물어볼
상황이다 — 요약이 없으면 파형만 보낸다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* AlignLabdbPayload — 부착 위치 정렬 원시 데이터 → labdb `/upload/json`.
|
||||
*/
|
||||
package com.medithings.vesiscan.services.labdb
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 정렬 한 세션(위치 여러 개)을 labdb 세션 하나로 만든다.
|
||||
*
|
||||
* ## 왜 새 dataType 인가
|
||||
* 임상 측정(`901`)과 구조가 다르다. 저쪽은 **한 조건에서 20 반복**이고, 정렬은
|
||||
* **여러 위치 × 20 cycle** 이다. 같은 dataType 에 넣으면 `params` 만으로는 구분이
|
||||
* 안 되고, 관리자 콘솔이 두 종류를 한 차트로 그리게 된다.
|
||||
*
|
||||
* labdb 는 dataType 마다 `records[]` 스키마가 완전히 달라도 된다(`labdb.md` §2-3).
|
||||
* 900~999 가 사용자 정의 구간이라 여기에 [DATA_TYPE] 을 쓴다.
|
||||
*
|
||||
* ## 언제 올리나 — 자동이 아니다
|
||||
* 간호사가 파형을 보고 **이상하다고 판단했을 때** 버튼으로만 올린다. 목적이 개발자
|
||||
* 피드백이라, 정상인 것까지 다 올리면 정작 봐야 할 것이 묻힌다.
|
||||
*
|
||||
* ## 무엇을 담나
|
||||
* 위치별 raw cycle 전부 + 판정 근거(`align_result.json`)를 함께 넣는다. 개발자가
|
||||
* 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다.
|
||||
*
|
||||
* ```
|
||||
* record = 한 위치의 한 cycle
|
||||
* rowIndex : 0..N 연속 (labdb 규약)
|
||||
* align_cm : 그 cycle 을 잰 위치
|
||||
* cycle_idx : 그 위치 안에서의 순번
|
||||
* channels : 6 × {ch, peak, peakIdx, data[~100]}
|
||||
* ```
|
||||
*/
|
||||
object AlignLabdbPayload {
|
||||
|
||||
/**
|
||||
* 정렬 전용 코드. 사용자 정의 구간(900~999).
|
||||
*
|
||||
* 임상 측정은 `901`([HospitalLabdbPayload.DATA_TYPE]). 관리자가 100~899 를
|
||||
* 배정해 주면 그 값으로 바꾼다 — 그때 이 KDoc 의 스키마를 그대로 등록 요청에 쓴다.
|
||||
*/
|
||||
const val DATA_TYPE = "902"
|
||||
|
||||
private const val PROTOCOL = "hospital_align_2026"
|
||||
|
||||
/** `align_3cm.csv` 에서 3 을 뽑는다. */
|
||||
private val NAME_RE = Regex("""^align_(-?\d+)cm\.csv$""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val isoOut: SimpleDateFormat
|
||||
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
|
||||
|
||||
/**
|
||||
* @param alignDir `HospitalRunStore.alignDir` 가 만든 폴더. `align_*cm.csv` 와
|
||||
* `align_result.json` 이 들어 있다.
|
||||
* @param memo 간호사가 남기는 한 줄. 무엇이 이상해 보였는지가 개발자에게 제일 중요하다.
|
||||
* @return 올릴 페이로드. 읽을 cycle 이 없으면 null.
|
||||
*/
|
||||
fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? {
|
||||
val files = alignDir.listFiles()
|
||||
?.filter { it.isFile && NAME_RE.matches(it.name) }
|
||||
?.sortedBy { NAME_RE.find(it.name)!!.groupValues[1].toInt() }
|
||||
?: return null
|
||||
if (files.isEmpty()) return null
|
||||
|
||||
val summary = File(alignDir, "align_result.json")
|
||||
.takeIf { it.exists() }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
|
||||
val records = JSONArray()
|
||||
var row = 0
|
||||
for (f in files) {
|
||||
val cm = NAME_RE.find(f.name)!!.groupValues[1].toInt()
|
||||
// `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles).
|
||||
val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>()
|
||||
runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line ->
|
||||
if (line.isBlank()) return@forEach
|
||||
val p = line.split(',')
|
||||
if (p.size < 3) return@forEach
|
||||
val ci = p[0].trim().toIntOrNull() ?: return@forEach
|
||||
val ch = p[1].trim().toIntOrNull() ?: return@forEach
|
||||
val data = IntArray(p.size - 2) { p[it + 2].trim().toIntOrNull() ?: 0 }
|
||||
if (data.isEmpty()) return@forEach
|
||||
byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data
|
||||
}
|
||||
for (ci in byCycle.keys.sorted()) {
|
||||
val chans = JSONArray()
|
||||
for (ch in byCycle.getValue(ci).keys.sorted()) {
|
||||
val d = byCycle.getValue(ci).getValue(ch)
|
||||
var peak = d[0]; var peakIdx = 0
|
||||
for (k in d.indices) if (d[k] > peak) { peak = d[k]; peakIdx = k }
|
||||
chans.put(JSONObject().apply {
|
||||
put("ch", ch)
|
||||
put("peak", peak)
|
||||
put("peakIdx", peakIdx)
|
||||
put("data", JSONArray(d.toList()))
|
||||
})
|
||||
}
|
||||
records.put(JSONObject().apply {
|
||||
put("rowIndex", row++)
|
||||
put("align_cm", cm)
|
||||
put("cycle_idx", ci)
|
||||
put("commandType", "MTB")
|
||||
put("channels", chans)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (records.length() == 0) return null
|
||||
|
||||
val subject = summary?.optString("patient").orEmpty()
|
||||
val saveName = summary?.optString("save_name").orEmpty()
|
||||
.ifBlank { alignDir.parentFile?.name.orEmpty() }
|
||||
val base = (saveName.ifBlank { "align" }) + "_align"
|
||||
|
||||
val params = JSONObject().apply {
|
||||
put("protocol", PROTOCOL)
|
||||
put("subject", subject)
|
||||
put("save_name", saveName)
|
||||
put("positions_measured", files.size)
|
||||
put("cycles_total", records.length())
|
||||
// 판정 근거를 통째로 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라,
|
||||
// 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다.
|
||||
summary?.let { s ->
|
||||
for (k in listOf(
|
||||
"device", "firmware_version", "hw_preset", "freq_mhz", "freq_option",
|
||||
"cycles_per_position", "probe_cycles", "avg", "delay_us", "samples",
|
||||
"ch3_hit_min", "final_offset_cm", "best_cm", "anchor_cm", "action",
|
||||
"stop_reason", "max_nch", "positions", "lateral",
|
||||
)) if (s.has(k)) put(k, s.get(k))
|
||||
}
|
||||
// 자동이 아니라 사람이 올린 것임을 데이터에 남긴다 — 정상 세션과 섞이면
|
||||
// "왜 이것만 올라와 있나"를 나중에 설명할 수 없다.
|
||||
put("upload_reason", "nurse_review")
|
||||
}
|
||||
|
||||
return JSONObject().apply {
|
||||
put("testId", testId(base))
|
||||
put("dataType", dataType)
|
||||
put("sessionName", base)
|
||||
if (memo.isNotBlank()) put("memo", memo)
|
||||
put("savedAt", isoOut.format(Date()))
|
||||
put("params", params)
|
||||
put("recordCount", records.length())
|
||||
put("records", records)
|
||||
}
|
||||
}
|
||||
|
||||
/** `^[A-Za-z0-9_-]{1,40}$`. 넘치면 앞 31자 + SHA-1 앞 8자 — 규칙은 임상 쪽과 같다. */
|
||||
fun testId(base: String): String {
|
||||
val safe = base.replace(Regex("[^A-Za-z0-9_-]"), "_")
|
||||
if (safe.length <= 40) return safe.ifEmpty { "align" }
|
||||
val sha = MessageDigest.getInstance("SHA-1")
|
||||
.digest(base.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
.take(8)
|
||||
return safe.take(31) + "_" + sha
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,29 @@ object HospitalLabdbUploader {
|
||||
return false to msg
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 세션을 올린다 — **간호사가 버튼을 눌렀을 때만**.
|
||||
*
|
||||
* 임상 측정과 달리 자동이 아니다. 목적이 "파형이 이상해 보이니 봐 달라"는 개발자
|
||||
* 피드백 요청이라, 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다.
|
||||
*
|
||||
* 마커도 남기지 않는다. 같은 정렬을 다시 올릴 수 있어야 한다 — 처음에 못 적은
|
||||
* 증상을 memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면
|
||||
* 갱신하므로 중복 세션이 쌓이지 않는다.
|
||||
*/
|
||||
suspend fun uploadAlign(alignDir: File, memo: String = ""): Pair<Boolean, String> {
|
||||
if (!LabdbCredentials.isRegistered) return false to "labdb 미등록 — 설정에서 등록하세요."
|
||||
val payload = AlignLabdbPayload.build(alignDir, memo)
|
||||
?: return false to "정렬 원시 데이터가 없습니다."
|
||||
return try {
|
||||
val r = LabdbClient.uploadJson(payload)
|
||||
true to "업로드 ${r.inserted}/${r.totalRecords} (중복 ${r.duplicates})"
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "align upload failed", e)
|
||||
false to (e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더의 미업로드 CSV 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다.
|
||||
*
|
||||
|
||||
@@ -55,6 +55,8 @@ import com.medithings.vesiscan.managers.DetachWatcher
|
||||
import com.medithings.vesiscan.managers.LateralGuide
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader
|
||||
import com.medithings.vesiscan.services.labdb.LabdbCredentials
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -64,6 +66,7 @@ import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** 정렬 측정 조건 — 병원 모드의 조합 순회와 무관하게 고정. */
|
||||
@@ -104,6 +107,17 @@ fun AnchorAlignView(appState: AppState) {
|
||||
var liveChannels by remember {
|
||||
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
|
||||
}
|
||||
// ── labdb 즉시 업로드 ────────────────────────────────────────────────────
|
||||
// 간호사가 파형을 보고 이상하다고 판단하면 그 자리에서 올린다. 개발자가 바로
|
||||
// 보고 답을 주는 흐름이라, 임상이 끝난 뒤가 아니라 **지금** 올라가야 한다.
|
||||
val uploadScope = rememberCoroutineScope()
|
||||
var uploadBusy by remember { mutableStateOf(false) }
|
||||
var uploadMsg by remember { mutableStateOf<String?>(null) }
|
||||
var uploadMemo by remember { mutableStateOf("") }
|
||||
var showUpload by remember { mutableStateOf(false) }
|
||||
/** 위치를 한 번이라도 저장했는가 — 올릴 것이 있는지 판단. */
|
||||
var hasAlignData by remember { mutableStateOf(false) }
|
||||
|
||||
// ── 채널 접촉 ────────────────────────────────────────────────────────────
|
||||
// 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표
|
||||
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
|
||||
@@ -213,6 +227,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서
|
||||
// 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다.
|
||||
val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
|
||||
hasAlignData = true
|
||||
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) {
|
||||
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
|
||||
}
|
||||
@@ -310,6 +325,53 @@ fun AnchorAlignView(appState: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// 무엇이 이상해 보였는지 한 줄 받는다. 개발자에게 제일 중요한 정보인데,
|
||||
// 파형만 오면 "이게 왜 올라왔지"부터 되묻게 된다.
|
||||
if (showUpload) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!uploadBusy) showUpload = false },
|
||||
title = { Text("정렬 데이터 보내기", fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"지금까지 잰 위치의 원시 파형과 판정 근거를 labdb 로 보냅니다. " +
|
||||
"개발자가 보고 회신합니다.",
|
||||
fontSize = 13.sp, color = MlSecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = uploadMemo,
|
||||
onValueChange = { uploadMemo = it },
|
||||
label = { Text("무엇이 이상해 보이나요?") },
|
||||
placeholder = { Text("예: CH2 만 진폭이 절반") },
|
||||
enabled = !uploadBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
uploadBusy = true
|
||||
uploadScope.launch {
|
||||
val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
|
||||
val (ok, msg) = HospitalLabdbUploader.uploadAlign(dir, uploadMemo)
|
||||
uploadMsg = if (ok) "보냈습니다 — $msg" else "실패 — $msg"
|
||||
uploadBusy = false
|
||||
if (ok) { showUpload = false; uploadMemo = "" }
|
||||
}
|
||||
},
|
||||
enabled = !uploadBusy,
|
||||
) { Text(if (uploadBusy) "보내는 중…" else "보내기") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showUpload = false }, enabled = !uploadBusy) {
|
||||
Text("취소")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState())
|
||||
) {
|
||||
@@ -454,6 +516,27 @@ fun AnchorAlignView(appState: AppState) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// ── 파형이 이상할 때 ───────────────────────────────────────────
|
||||
// 판단은 사람이 한다. 이 화면에서 파형을 보는 사람이 유일하게 환자와 프로브를
|
||||
// 같이 보고 있는 사람이라, 앱이 지표로 거르는 것보다 정확하다.
|
||||
if (hasAlignData && LabdbCredentials.isRegistered) {
|
||||
TextButton(
|
||||
onClick = { showUpload = true },
|
||||
enabled = !measuring && !lrRunning && !uploadBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (uploadBusy) "업로드 중…" else "파형이 이상합니다 — labdb 로 보내기",
|
||||
color = MlPrimary, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
uploadMsg?.let {
|
||||
Text(it, fontSize = 12.sp, color = MlSecondaryText,
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
if (liveChannels.isNotEmpty()) {
|
||||
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = MlSecondaryText)
|
||||
|
||||
Reference in New Issue
Block a user