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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.models.*
|
||||
import com.medithings.vesiscan.services.AdcCsvLogger
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader
|
||||
import com.medithings.vesiscan.services.labdb.LabdbCredentials
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
import androidx.compose.foundation.border
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -115,6 +117,16 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 채널 접촉 상태 — 파형 옆에 글자로 같이 띄운다. 파형만으로 "이 채널이 떴다"를
|
||||
// 읽으려면 눈이 익어야 하고, 20회를 다 돌린 뒤에 알면 그 단계는 다시 못 잰다
|
||||
// (방광을 비웠거나 자세가 바뀐다).
|
||||
// 업로드 버튼은 화면 수명에 묶인 스코프에서 돈다 — 측정 루프(LaunchedEffect)와
|
||||
// 달리 화면을 벗어나도 계속 돌 이유가 없다.
|
||||
val uploadScope = rememberCoroutineScope()
|
||||
|
||||
// 방금 끝난 실행 폴더 — 업로드 재시도가 이 폴더를 다시 훑는다.
|
||||
var lastRunDir by remember { mutableStateOf<java.io.File?>(null) }
|
||||
var pendingUploads by remember { mutableIntStateOf(0) }
|
||||
var uploadBusy by remember { mutableStateOf(false) }
|
||||
var uploadMsg by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() }
|
||||
var detachSnapshot by remember {
|
||||
mutableStateOf<com.medithings.vesiscan.managers.DetachWatcher.Snapshot?>(null)
|
||||
@@ -149,6 +161,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
if (!running) return@LaunchedEffect
|
||||
val startedAt = Date()
|
||||
val dir = HospitalRunStore.runDir(patient, startedAt)
|
||||
lastRunDir = dir
|
||||
val fw = bleManager.firmwareVersion.value
|
||||
val results = JSONArray()
|
||||
okCount = 0; failCount = 0; configFailed = null; restoreNote = null
|
||||
@@ -244,6 +257,16 @@ fun HospitalModeView(appState: AppState) {
|
||||
}
|
||||
delay(REPEAT_GAP_MS)
|
||||
}
|
||||
// 조합이 끝나는 즉시 올린다. 한 바퀴(6조합)를 다 돌 때까지 기다리면
|
||||
// 중간에 앱이 죽거나 자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다.
|
||||
// 실패해도 루프를 세우지 않는다 — 마커가 남아 나중에 버튼으로 올린다.
|
||||
if (LabdbCredentials.isRegistered) {
|
||||
launch {
|
||||
val (_, m) = HospitalLabdbUploader.upload(file)
|
||||
uploadMsg = "${file.name} — $m"
|
||||
lastRunDir?.let { d -> pendingUploads = HospitalLabdbUploader.pending(d).size }
|
||||
}
|
||||
}
|
||||
results.put(JSONObject().apply {
|
||||
put("freq_mhz", freq.label)
|
||||
put("freq_option", freq.freqOption)
|
||||
@@ -282,6 +305,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
put("anchor_cm", appState.anchorCm ?: JSONObject.NULL)
|
||||
put("combinations", results)
|
||||
}, startedAt)
|
||||
pendingUploads = HospitalLabdbUploader.pending(dir).size
|
||||
running = false
|
||||
}
|
||||
}
|
||||
@@ -498,6 +522,63 @@ fun HospitalModeView(appState: AppState) {
|
||||
if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.")
|
||||
else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
|
||||
|
||||
// ── labdb 업로드 ────────────────────────────────────────────
|
||||
// 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기
|
||||
// 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한
|
||||
// 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다.
|
||||
if (LabdbCredentials.isRegistered && lastRunDir != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(
|
||||
if (pendingUploads > 0) Color(0xFFFFF3E0) else MlPrimary.copy(alpha = 0.07f),
|
||||
RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
if (pendingUploads > 0) "labdb 미업로드 ${pendingUploads}건"
|
||||
else "labdb 업로드 완료",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (pendingUploads > 0) Color(0xFFE65100) else MlSuccess,
|
||||
)
|
||||
(HospitalLabdbUploader.uploadingName.value ?: uploadMsg)?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
if (pendingUploads > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val d = lastRunDir ?: return@Button
|
||||
uploadBusy = true
|
||||
uploadScope.launch {
|
||||
uploadMsg = HospitalLabdbUploader.uploadPending(d)
|
||||
pendingUploads = HospitalLabdbUploader.pending(d).size
|
||||
uploadBusy = false
|
||||
}
|
||||
},
|
||||
enabled = !uploadBusy,
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text(
|
||||
if (uploadBusy) "업로드 중…" else "지금 업로드",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"요청 사이를 띄워 보냅니다 — ${pendingUploads}건이면 약 " +
|
||||
"${(pendingUploads - 1) * 8 / 60 + 1}분 걸립니다.",
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0 || failCount > 0) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("직전 실행 — 저장 $okCount · 실패 $failCount",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.medithings.vesiscan.services.labdb
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 병원 임상 CSV → labdb 페이로드 변환.
|
||||
*
|
||||
* 2026-09-05 에 파이썬 변환기로 279건을 이미 올렸다. 앱이 만드는 모양이 그것과 달라지면
|
||||
* labdb 에 같은 프로토콜 데이터가 **두 형태로** 쌓이고, 분석하는 쪽이 두 벌을 만들게 된다.
|
||||
* 그래서 그때의 규칙을 여기에 고정한다:
|
||||
*
|
||||
* ```
|
||||
* CSV 파일 1개 = 세션 1개
|
||||
* CSV 6행(CH0~5) = record 1개 → channels[0..5].data = s0..s99
|
||||
* rowIndex = repeat_idx
|
||||
* ```
|
||||
*/
|
||||
class HospitalLabdbPayloadTest {
|
||||
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private val header: String =
|
||||
"scan_id,timestamp,patient,posture,fill_pct,freq_mhz,freq_option,cycles,repeat_idx," +
|
||||
"device,firmware_version,channel" + (0 until 100).joinToString("") { ",s$it" }
|
||||
|
||||
/** 한 행 = 1 반복 × 1 채널. [peakAt] 위치에 최대값을 심어 peakIdx 를 검증한다. */
|
||||
private fun row(rep: Int, ch: Int, peakAt: Int, ts: String = "2026-09-08 10:00:0$rep"): String {
|
||||
val samples = (0 until 100).map { if (it == peakAt) 4000 else 1000 }
|
||||
return listOf(
|
||||
"1", ts, "P001", "Supine", "40", "2.3", "0", "3", rep.toString(),
|
||||
"VBT26080001", "0.1.21", "CH$ch",
|
||||
).joinToString(",") + "," + samples.joinToString(",")
|
||||
}
|
||||
|
||||
private fun csvOf(vararg rows: String): File {
|
||||
val f = File(tmp.root, "260908_P001_Supine_f040_23f_c3.csv")
|
||||
f.writeText((listOf(header) + rows).joinToString("\n"), Charsets.UTF_8)
|
||||
return f
|
||||
}
|
||||
|
||||
private fun fullRepeat(rep: Int) = (0..5).map { ch -> row(rep, ch, peakAt = 20 + ch) }
|
||||
|
||||
@Test fun `6채널 한 묶음이 record 하나가 된다`() {
|
||||
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!!
|
||||
assertEquals(1, p.getInt("recordCount"))
|
||||
val rec = p.getJSONArray("records").getJSONObject(0)
|
||||
assertEquals(0, rec.getInt("rowIndex"))
|
||||
assertEquals("MTB", rec.getString("commandType"))
|
||||
assertEquals(6, rec.getJSONArray("channels").length())
|
||||
assertEquals(100, rec.getJSONArray("channels").getJSONObject(0)
|
||||
.getJSONArray("data").length())
|
||||
}
|
||||
|
||||
@Test fun `반복마다 record 가 하나씩 늘어난다`() {
|
||||
val rows = (0 until 3).flatMap { fullRepeat(it) }
|
||||
val p = HospitalLabdbPayload.build(csvOf(*rows.toTypedArray()))!!
|
||||
assertEquals(3, p.getInt("recordCount"))
|
||||
val recs = p.getJSONArray("records")
|
||||
// rowIndex 는 repeat_idx 그대로다 — 순서를 다시 매기면 원본 CSV 와 대조가 안 된다.
|
||||
assertEquals(listOf(0, 1, 2), (0 until 3).map { recs.getJSONObject(it).getInt("rowIndex") })
|
||||
}
|
||||
|
||||
@Test fun `peak 과 peakIdx 는 최초 최대값 위치다`() {
|
||||
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!!
|
||||
val chans = p.getJSONArray("records").getJSONObject(0).getJSONArray("channels")
|
||||
for (i in 0 until chans.length()) {
|
||||
val c = chans.getJSONObject(i)
|
||||
assertEquals(4000, c.getInt("peak"))
|
||||
assertEquals(20 + c.getInt("ch"), c.getInt("peakIdx"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `IMU 가 없는 자리는 지어내지 않는다`() {
|
||||
// 병원 CSV 에는 IMU·배터리·온도 열이 없다. 0 으로 채우면 나중에 "IMU 가 0 이었다"로 읽힌다.
|
||||
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!!
|
||||
val sensor = p.getJSONArray("records").getJSONObject(0).getJSONObject("sensor")
|
||||
assertEquals(0, sensor.length())
|
||||
}
|
||||
|
||||
@Test fun `조건이 params 에 들어간다`() {
|
||||
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!!
|
||||
val params = p.getJSONObject("params")
|
||||
assertEquals("hospital_clinical_2026", params.getString("protocol"))
|
||||
assertEquals("P001", params.getString("subject"))
|
||||
assertEquals("Supine", params.getString("posture"))
|
||||
assertEquals(40.0, params.getDouble("fill_pct"), 1e-9)
|
||||
assertEquals(2.3, params.getDouble("freq_mhz"), 1e-9)
|
||||
assertEquals(3.0, params.getDouble("cycles"), 1e-9)
|
||||
assertEquals(1, params.getInt("repeats_saved"))
|
||||
assertEquals(HospitalLabdbPayload.DATA_TYPE, p.getString("dataType"))
|
||||
}
|
||||
|
||||
@Test fun `매니페스트 값이 params 에 합쳐진다`() {
|
||||
val extra = org.json.JSONObject().apply { put("avg", 8); put("delay_us", 12) }
|
||||
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()), extra)!!
|
||||
assertEquals(8, p.getJSONObject("params").getInt("avg"))
|
||||
assertEquals(12, p.getJSONObject("params").getInt("delay_us"))
|
||||
}
|
||||
|
||||
@Test fun `빈 파일과 헤더만 있는 파일은 null`() {
|
||||
val empty = File(tmp.root, "a.csv").apply { writeText("") }
|
||||
assertNull(HospitalLabdbPayload.build(empty))
|
||||
val headerOnly = File(tmp.root, "b.csv").apply { writeText(header) }
|
||||
assertNull(HospitalLabdbPayload.build(headerOnly))
|
||||
}
|
||||
|
||||
@Test fun `짧은 버퍼는 있는 데까지만 담는다`() {
|
||||
// 연결이 끊겨 채널 버퍼가 짧게 온 회차가 실제로 생긴다. 0 으로 채우면
|
||||
// 신호가 있었던 것처럼 보인다.
|
||||
val short = listOf(
|
||||
"1", "2026-09-08 10:00:00", "P001", "Supine", "40", "2.3", "0", "3", "0",
|
||||
"VBT26080001", "0.1.21", "CH0",
|
||||
).joinToString(",") + "," + (0 until 40).joinToString(",") { "1000" } +
|
||||
(0 until 60).joinToString("") { "," }
|
||||
val p = HospitalLabdbPayload.build(csvOf(short))!!
|
||||
val data = p.getJSONArray("records").getJSONObject(0)
|
||||
.getJSONArray("channels").getJSONObject(0).getJSONArray("data")
|
||||
assertEquals(40, data.length())
|
||||
}
|
||||
|
||||
@Test fun `testId 는 40자 제한을 지키고 충돌하지 않는다`() {
|
||||
// 파일명이 그대로 들어가면 대부분 40자를 넘는다. 앞부분만 자르면 조합이 달라도
|
||||
// 같아져 labdb 가 다른 조합을 한 세션으로 합친다 — 해시를 붙여 가른다.
|
||||
val a = HospitalLabdbPayload.compactTestId(
|
||||
"P001", "260908_P001_Supine_fill040_freq23_cycle3_extra_long.csv")
|
||||
val b = HospitalLabdbPayload.compactTestId(
|
||||
"P001", "260908_P001_Supine_fill040_freq23_cycle5_extra_long.csv")
|
||||
assertTrue(a.length <= 40)
|
||||
assertTrue(b.length <= 40)
|
||||
assertFalse("서로 다른 조합이 같은 testId 가 되면 안 된다", a == b)
|
||||
assertTrue(Regex("^[A-Za-z0-9_-]{1,40}$").matches(a))
|
||||
}
|
||||
|
||||
@Test fun `짧은 파일명은 그대로 쓴다`() {
|
||||
val id = HospitalLabdbPayload.compactTestId("P001", "260908_P001_Sup.csv")
|
||||
assertEquals("260908_P001_Sup", id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user