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:
2026-09-08 13:57:11 +09:00
parent 8bf7f54b72
commit 3d8168a0f5
4 changed files with 407 additions and 0 deletions
@@ -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 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다.
*