feat(clinical): 병원 임상 측정을 labdb 로 자동 업로드
병원 임상 모드에는 labdb 업로드 경로가 **아예 없었다**. HospitalRunStore 는 Downloads 에 파일만 쓰고 업로더를 한 번도 부르지 않는다. 업로드는 ClinicalSessionStore.finalize 한 곳에만 붙어 있고 그건 병원 모드가 쓰지 않는 경로다. 2026-09-05 에 279건을 올릴 때 파이썬 변환기를 따로 만들어야 했던 이유가 이것이다. 포맷도 다르다. 기존 업로더는 measurement.json(cycles 배열)을 읽는데, 병원 모드는 CSV 한 행이 1 반복 × 1 채널이고 조건별로 파일이 갈린다. 그래서 그때의 변환 규칙을 앱으로 옮긴다 — 모양이 달라지면 labdb 에 같은 프로토콜 데이터가 두 형태로 쌓이고 분석하는 쪽이 두 벌을 만들게 된다. CSV 파일 1개 = 세션 1개 (자세·충만도·주파수·cycle 고정 20 반복) CSV 6행(CH0~5) = record 1개 → channels[0..5].data = s0..s99 rowIndex = repeat_idx **조합이 끝날 때마다** 올린다. 한 바퀴를 다 돌 때까지 기다리면 중간에 앱이 죽거나 자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다. 단위도 자연스럽다 — labdb 는 요청 1건 = 세션 1개 = record N개이고, 20 반복이 곧 한 요청이다. 측정 1회마다 올리면 한 바퀴가 120건이 되어 분당 10건 제한에 걸린다. **오프라인이 정상 경로다.** 병원 무선망이 불안정한 곳이 많아 "나중에 올리기"가 예외가 아니다. 성공/실패를 CSV 옆 마커 파일로 남겨 앱이 죽어도 디스크만 보면 무엇이 남았는지 알 수 있게 했다 — 상태를 메모리에 두면 이 기능의 요점이 사라진다. 화면에 "미업로드 N건"과 [지금 업로드] 를 두고, 밀린 것을 올릴 때는 요청 사이를 7.5초 띄운다(분당 ~8건). 한 건이 실패해도 멈추지 않는다. 망이 잠깐 끊긴 것과 그 파일이 잘못된 것을 구분할 수 없는데, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패 마커가 남아 다음에 다시 시도된다. dataType 은 901(사용자 정의 구간)이다. 병원 프로토콜용 코드를 관리자에게 배정받으면 HospitalLabdbPayload.DATA_TYPE 만 바꾸면 된다 — 279건도 같은 값으로 올라가 있다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* HospitalLabdbPayload — 병원 임상 CSV → 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
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* 병원 임상 모드의 조합 CSV 하나를 labdb 세션 하나로 바꾼다.
|
||||
*
|
||||
* ## 왜 [LabdbUploader] 를 못 쓰나
|
||||
* 그쪽은 `VesiScan_Sessions` 의 `measurement_<folder>.json`(cycles 배열)을 읽는다.
|
||||
* 병원 모드는 형식이 완전히 다르다 — CSV 한 행이 **1 반복 × 1 채널**이고, 조건별로
|
||||
* 파일이 갈리며, 매니페스트가 따로 있다. 그래서 기존 업로더로는 한 건도 올라가지 않는다.
|
||||
*
|
||||
* ## 매핑 — 2026-09-05 수동 업로드(279건)와 **같은 규칙**
|
||||
* 그때 파이썬 변환기로 올린 것과 모양이 달라지면 labdb 에서 같은 프로토콜 데이터가
|
||||
* 두 형태로 쌓인다. 분석하는 쪽이 두 벌을 만들어야 하므로 규칙을 그대로 옮긴다.
|
||||
*
|
||||
* ```
|
||||
* CSV 파일 1개 = labdb 세션 1개 (자세·충만도·주파수·cycle 이 고정된 20 반복)
|
||||
* CSV 6행(CH0~5) = labdb record 1개 → channels[0..5].data = s0..s99
|
||||
* ```
|
||||
*
|
||||
* 환자 폴더 전체를 세션 하나로 묶지 않는다 — 1,440 record 짜리 덩어리가 되어 조건별
|
||||
* 비교가 불가능해진다.
|
||||
*
|
||||
* ## 없는 값을 지어내지 않는다
|
||||
* 병원 CSV 에는 IMU·배터리·온도 열이 없다. `sensor` 는 빈 객체로 두어 labdb 표준
|
||||
* 자리는 유지하되 0 이나 null 로 채우지 않는다 — 나중에 "IMU 가 0 이었다"로 읽힌다.
|
||||
*/
|
||||
object HospitalLabdbPayload {
|
||||
|
||||
/**
|
||||
* labdb 는 100~899 를 관리자가 배정한다. 병원 임상 프로토콜용 코드는 **아직 없어서**
|
||||
* 사용자 정의 구간(900~999)을 쓴다. 배정받으면 이 값만 바꾸면 된다 — 2026-09-05
|
||||
* 수동 업로드분도 같은 값으로 올라가 있다.
|
||||
*/
|
||||
const val DATA_TYPE = "901"
|
||||
|
||||
private const val PROTOCOL = "hospital_clinical_2026"
|
||||
private const val SAMPLES_PER_CHANNEL = 100
|
||||
|
||||
private val isoOut: SimpleDateFormat
|
||||
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
|
||||
|
||||
/** CSV `timestamp` 열 포맷. HospitalRunStore.stampFmt 와 같아야 한다. */
|
||||
private val stampIn: SimpleDateFormat
|
||||
get() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
|
||||
/**
|
||||
* @param csv 조합 CSV. [HospitalRunStore.combinationFile] 가 만든 것.
|
||||
* @param manifestParams 매니페스트에만 있는 값(avg·delay_us·samples 등). 없으면 생략한다.
|
||||
* @return 업로드 가능한 페이로드. 유효 record 가 없으면 null.
|
||||
*/
|
||||
fun build(
|
||||
csv: File,
|
||||
manifestParams: JSONObject? = null,
|
||||
dataType: String = DATA_TYPE,
|
||||
): JSONObject? {
|
||||
val lines = runCatching { csv.readLines(Charsets.UTF_8) }.getOrNull() ?: return null
|
||||
if (lines.size < 2) return null
|
||||
|
||||
val header = splitCsv(lines[0].removePrefix(""))
|
||||
val idx = header.withIndex().associate { (i, name) -> name.trim() to i }
|
||||
fun col(row: List<String>, name: String): String =
|
||||
idx[name]?.let { row.getOrNull(it) }?.trim().orEmpty()
|
||||
|
||||
// 반복(repeat_idx) 단위로 묶는다. 한 반복 = 6채널 = record 1개.
|
||||
val byRepeat = linkedMapOf<Int, MutableMap<Int, IntArray>>()
|
||||
val times = HashMap<Int, String>()
|
||||
var head: List<String>? = null
|
||||
|
||||
for (i in 1 until lines.size) {
|
||||
val row = splitCsv(lines[i])
|
||||
if (row.size < header.size - SAMPLES_PER_CHANNEL) continue
|
||||
if (head == null) head = row
|
||||
val rep = col(row, "repeat_idx").toIntOrNull() ?: continue
|
||||
val ch = col(row, "channel").removePrefix("CH").toIntOrNull() ?: continue
|
||||
|
||||
val data = ArrayList<Int>(SAMPLES_PER_CHANNEL)
|
||||
for (s in 0 until SAMPLES_PER_CHANNEL) {
|
||||
val v = idx["s$s"]?.let { row.getOrNull(it) }?.trim()
|
||||
if (v.isNullOrEmpty()) break // 짧은 버퍼 — 거기까지만 쓴다
|
||||
val n = v.toDoubleOrNull()?.toInt() ?: break
|
||||
data.add(n)
|
||||
}
|
||||
if (data.isEmpty()) continue
|
||||
byRepeat.getOrPut(rep) { linkedMapOf() }[ch] = data.toIntArray()
|
||||
// 한 반복의 6채널은 같은 캡처라 시각이 사실상 같다. 첫 채널 것을 쓴다.
|
||||
times.getOrPut(rep) { col(row, "timestamp") }
|
||||
}
|
||||
val h = head ?: return null
|
||||
if (byRepeat.isEmpty()) return null
|
||||
|
||||
val records = JSONArray()
|
||||
for (rep in byRepeat.keys.sorted()) {
|
||||
val chans = JSONArray()
|
||||
for (ch in byRepeat.getValue(rep).keys.sorted()) {
|
||||
val d = byRepeat.getValue(rep).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", rep)
|
||||
put("datetime", iso(times[rep]))
|
||||
put("commandType", "MTB")
|
||||
put("sensor", JSONObject()) // 병원 CSV 에 IMU·배터리·온도 열이 없다
|
||||
put("channels", chans)
|
||||
})
|
||||
}
|
||||
|
||||
val subject = col(h, "patient")
|
||||
val base = csv.nameWithoutExtension
|
||||
val params = JSONObject().apply {
|
||||
put("protocol", PROTOCOL)
|
||||
put("subject", subject)
|
||||
put("posture", col(h, "posture"))
|
||||
num(col(h, "fill_pct"))?.let { put("fill_pct", it) }
|
||||
num(col(h, "freq_mhz"))?.let { put("freq_mhz", it) }
|
||||
num(col(h, "freq_option"))?.let { put("freq_option", it) }
|
||||
num(col(h, "cycles"))?.let { put("cycles", it) }
|
||||
put("device", col(h, "device"))
|
||||
put("firmware_version", col(h, "firmware_version"))
|
||||
put("repeats_saved", records.length())
|
||||
put("source_file", csv.name)
|
||||
manifestParams?.let { m -> m.keys().forEach { k -> put(k, m.get(k)) } }
|
||||
}
|
||||
|
||||
return JSONObject().apply {
|
||||
put("testId", compactTestId(subject, csv.name))
|
||||
put("dataType", dataType)
|
||||
put("sessionName", base)
|
||||
put("params", params)
|
||||
put("recordCount", records.length())
|
||||
put("records", records)
|
||||
times.keys.maxOrNull()?.let { last -> iso(times[last])?.let { put("savedAt", it) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `^[A-Za-z0-9_-]{1,40}$` 제한에 맞춘 testId.
|
||||
*
|
||||
* 파일명이 그대로 들어가면 40자를 넘는 경우가 대부분이라(날짜·환자·자세·충만도·
|
||||
* 주파수·cycle 이 다 붙는다), 넘치면 **앞 31자 + `_` + 파일명 SHA-1 앞 8자**로 줄인다.
|
||||
* 해시를 붙이는 이유는 조합이 달라도 앞부분이 같아 충돌하기 때문이다 — 충돌하면
|
||||
* labdb 가 다른 조합을 같은 세션으로 합친다.
|
||||
*/
|
||||
fun compactTestId(subject: String, fileName: String): String {
|
||||
val raw = fileName.substringBeforeLast('.')
|
||||
val safe = raw.replace(Regex("[^A-Za-z0-9_-]"), "_")
|
||||
if (safe.length <= 40) return safe.ifEmpty { "session" }
|
||||
val sha = MessageDigest.getInstance("SHA-1")
|
||||
.digest(fileName.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
.take(8)
|
||||
return safe.take(31) + "_" + sha
|
||||
}
|
||||
|
||||
private fun iso(stamp: String?): String? {
|
||||
if (stamp.isNullOrBlank()) return null
|
||||
val d = runCatching { stampIn.parse(stamp) }.getOrNull() ?: return null
|
||||
return isoOut.apply { timeZone = TimeZone.getDefault() }.format(d)
|
||||
}
|
||||
|
||||
private fun num(s: String): Double? = s.takeIf { it.isNotBlank() }?.toDoubleOrNull()
|
||||
|
||||
/** 값에 콤마가 없는 단순 CSV — 이 파일이 만드는 형식이 그렇다. */
|
||||
private fun splitCsv(line: String): List<String> = line.split(',')
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* HospitalLabdbUploader — 병원 임상 조합 CSV 를 labdb 로 올린다.
|
||||
*/
|
||||
package com.medithings.vesiscan.services.labdb
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.delay
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 조합 하나(=CSV 하나)를 세션 하나로 올린다.
|
||||
*
|
||||
* ## 왜 조합 단위인가
|
||||
* labdb 는 요청 1건 = 세션 1개 = record N개다. 한 조합이 20 반복이므로 **조합이 끝날
|
||||
* 때마다 1건**이면 자연스럽게 맞고, 6조합 한 바퀴가 6건이라 분당 10건 제한에도 여유가
|
||||
* 있다. 측정 1회마다 올리면 한 바퀴에 120건이 되어 제한에 걸린다.
|
||||
*
|
||||
* ## 마커로 상태를 남긴다
|
||||
* 성공하면 `<csv이름>.labdb.json`, 실패하면 `<csv이름>.labdb_error.json` 을 CSV 옆에
|
||||
* 둔다. 앱이 죽어도 디스크만 보면 무엇이 남았는지 알 수 있다 — 인터넷이 끊긴 병원에서
|
||||
* 측정을 마치고 나중에 올리는 것이 이 기능의 요점이라, 상태를 메모리에 두면 안 된다.
|
||||
*
|
||||
* 실패 마커는 다음 시도에서 지운다. 남겨 두면 "실패한 적 있음"과 "지금 실패 상태"를
|
||||
* 구분할 수 없다.
|
||||
*/
|
||||
object HospitalLabdbUploader {
|
||||
|
||||
private const val TAG = "HospitalLabdb"
|
||||
|
||||
/** 성공 마커. 이게 있으면 다시 올리지 않는다. */
|
||||
private const val OK_SUFFIX = ".labdb.json"
|
||||
private const val ERR_SUFFIX = ".labdb_error.json"
|
||||
|
||||
/**
|
||||
* 연속 업로드 사이 간격.
|
||||
*
|
||||
* labdb 는 분당 10건 제한이다. 밀린 것을 한꺼번에 올릴 때 그 제한에 걸리면 남은
|
||||
* 것이 전부 실패로 기록되므로, 처음부터 여유 있게 띄운다(분당 ~8건).
|
||||
*/
|
||||
private const val GAP_MS = 7_500L
|
||||
|
||||
// ── 화면이 관찰하는 상태 ──
|
||||
/** 이 실행에서 아직 못 올린 건수. */
|
||||
val pendingCount = mutableIntStateOf(0)
|
||||
/** 지금 올리는 중인 파일 이름. null = idle. */
|
||||
val uploadingName = mutableStateOf<String?>(null)
|
||||
/** 마지막 배치 결과 한 줄. */
|
||||
val lastMessage = mutableStateOf<String?>(null)
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
fun isUploaded(csv: File): Boolean = markerOk(csv).exists()
|
||||
fun lastError(csv: File): String? = markerErr(csv).takeIf { it.exists() }
|
||||
?.let { runCatching { JSONObject(it.readText()).optString("message") }.getOrNull() }
|
||||
|
||||
private fun markerOk(csv: File) = File(csv.parentFile, csv.name + OK_SUFFIX)
|
||||
private fun markerErr(csv: File) = File(csv.parentFile, csv.name + ERR_SUFFIX)
|
||||
|
||||
/**
|
||||
* 폴더에서 아직 안 올라간 조합 CSV 를 찾는다.
|
||||
*
|
||||
* `align/` 밑의 정렬 원시 데이터는 대상이 아니다 — 형식이 다르고(위치별 cycle),
|
||||
* 올릴지 여부도 별도 판단이다.
|
||||
*/
|
||||
fun pending(runDir: File): List<File> =
|
||||
runDir.listFiles()
|
||||
?.filter { it.isFile && it.extension.equals("csv", true) && !isUploaded(it) }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
/**
|
||||
* CSV 하나를 올린다. 성공/실패 모두 마커를 남긴다.
|
||||
*
|
||||
* @return 성공 여부와 사람이 읽을 메시지.
|
||||
*/
|
||||
suspend fun upload(csv: File, manifestParams: JSONObject? = null): Pair<Boolean, String> {
|
||||
if (!LabdbCredentials.isRegistered) return false to "labdb 미등록 — 설정에서 등록하세요."
|
||||
if (isUploaded(csv)) return true to "이미 업로드됨"
|
||||
|
||||
val payload = HospitalLabdbPayload.build(csv, manifestParams)
|
||||
?: return fail(csv, "변환 실패 — 유효한 반복이 없습니다.")
|
||||
|
||||
return try {
|
||||
val r = LabdbClient.uploadJson(payload)
|
||||
markerErr(csv).delete()
|
||||
markerOk(csv).writeText(
|
||||
JSONObject().apply {
|
||||
put("sessionId", r.sessionId)
|
||||
put("created", r.created)
|
||||
put("totalRecords", r.totalRecords)
|
||||
put("inserted", r.inserted)
|
||||
put("duplicates", r.duplicates)
|
||||
put("errors", r.errors)
|
||||
put("uploadedAt", System.currentTimeMillis())
|
||||
}.toString(2),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
true to "업로드 ${r.inserted}/${r.totalRecords} (중복 ${r.duplicates})"
|
||||
} catch (e: Exception) {
|
||||
fail(csv, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(csv: File, msg: String): Pair<Boolean, String> {
|
||||
runCatching {
|
||||
markerErr(csv).writeText(
|
||||
JSONObject().apply {
|
||||
put("message", msg)
|
||||
put("failedAt", System.currentTimeMillis())
|
||||
}.toString(2),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
}
|
||||
Log.w(TAG, "upload failed: ${csv.name} — $msg")
|
||||
return false to msg
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더의 미업로드 CSV 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다.
|
||||
*
|
||||
* 한 건이 실패해도 멈추지 않는다 — 인터넷이 잠깐 끊긴 것과 그 파일이 잘못된 것을
|
||||
* 구분할 수 없고, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패한 것은 마커가 남아
|
||||
* 다음 호출에서 다시 시도된다.
|
||||
*/
|
||||
suspend fun uploadPending(runDir: File, manifestParams: JSONObject? = null): String {
|
||||
if (running) return "이미 업로드 중입니다."
|
||||
running = true
|
||||
try {
|
||||
val targets = pending(runDir)
|
||||
pendingCount.intValue = targets.size
|
||||
if (targets.isEmpty()) {
|
||||
lastMessage.value = "올릴 것이 없습니다."
|
||||
return lastMessage.value!!
|
||||
}
|
||||
var ok = 0
|
||||
var failed = 0
|
||||
targets.forEachIndexed { i, csv ->
|
||||
uploadingName.value = csv.name
|
||||
val (success, _) = upload(csv, manifestParams)
|
||||
if (success) ok++ else failed++
|
||||
pendingCount.intValue = targets.size - (i + 1)
|
||||
if (i < targets.lastIndex) delay(GAP_MS) // 분당 제한 회피
|
||||
}
|
||||
lastMessage.value = "업로드 완료 $ok · 실패 $failed"
|
||||
return lastMessage.value!!
|
||||
} finally {
|
||||
uploadingName.value = null
|
||||
running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user