From f35ec8f0dc3228691c3fe52c34bd03f603a57481 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 29 Sep 2026 16:53:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(labdb):=20=EC=A0=95=EB=A0=AC(601)=EB=A7=8C?= =?UTF-8?q?=20=EC=95=88=20=EC=98=AC=EB=9D=BC=EA=B0=80=EB=8D=98=20=EA=B8=B8?= =?UTF-8?q?=20=EB=84=B7=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4=20=E2=80=94?= =?UTF-8?q?=20testId=20=EC=97=90=20=EB=82=A0=EC=A7=9C=C2=B7=EC=8B=9C?= =?UTF-8?q?=EA=B0=81,=20IMU=20=ED=98=95=EC=A0=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8,=20=EC=9E=AC=EC=8B=9C=EB=8F=84,=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=ED=8F=B4=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-29 임상: 조합 CSV(600)는 올라갔는데 정렬(601)이 안 올라갔다. 폰 없이 코드에서 정렬만 다르게 취급되는 지점을 찾았다. 1. 정렬 testId 에 날짜가 없었다 — `<환자명>_align`. labdb 는 같은 testId+rowIndex 를 조용히 무시하므로(labdb.md "자동 무시"), 같은 환자·같은 시험 이름을 다른 날 다시 정렬하면 그날 정렬은 한 건도 안 들어가는데 응답은 200 이라 앱은 성공 마커를 남긴다. 다른 기기가 먼저 쓴 이름이면 409. 조합 CSV 는 파일명(날짜·자세·충만도·주파수)이 testId 라 이 문제가 없었다. → `<날짜_환자폴더>_align_`. 시각은 정렬 화면의 시작 시각을 align_result.json `started_at` 으로 남겨 쓴다. [처음부터 다시 정렬] 은 앞 시도를 먼저 올리고 새 시각을 받는다 — 같은 날 두 번 정렬해도 세션이 갈린다. 2. `*_imu.csv` 가 미업로드 대상에 들어갔다 — 조합 CSV 의 IMU 형제 파일인데 세션으로 올리려다 변환 실패 마커만 남기고 "미업로드 N건"을 부풀렸다(A34 실측 2건). 제외. 3. 정렬은 정렬 화면을 떠나는 순간 한 번만 올리고 재시도가 없었다. 그 순간 망이 막혀 있으면 정렬만 빠진다. → 임상 실행이 끝날 때마다 그 폴더의 미업로드 정렬을 한 번 더 민다. 4. onDispose 가 첫 컴포지션의 saveName 을 붙들었다 — 정렬 화면에서 환자명을 나중에 치면 측정은 환자 폴더에, 업로드는 `unnamed_…` 빈 폴더를 보고 끝났다. → rememberUpdatedState. 시험: sessionBase 4경우 · IMU 형제 제외. services.labdb 40건 통과. Co-Authored-By: Claude Fable 5.1 --- .../services/labdb/AlignLabdbPayload.kt | 28 ++++++++++++++++++- .../services/labdb/HospitalLabdbUploader.kt | 14 ++++++++-- .../ui/views/clinical/AnchorAlignView.kt | 26 ++++++++++++++--- .../ui/views/clinical/HospitalModeView.kt | 5 ++++ .../services/labdb/AlignLabdbPayloadTest.kt | 15 ++++++++++ .../labdb/HospitalAlignPendingTest.kt | 17 +++++++++++ docs/LABDB_DATATYPES.md | 4 +-- 7 files changed, 99 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt index 91f326d..fd9520e 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -165,7 +165,7 @@ object AlignLabdbPayload { 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 base = sessionBase(alignDir, saveName, summary) val params = JSONObject().apply { put("protocol", PROTOCOL) @@ -210,6 +210,32 @@ object AlignLabdbPayload { } } + /** `yyyy-MM-dd_…` — [HospitalRunStore.runDir] 이 만드는 환자·날짜 폴더 이름. */ + private val RUN_DIR_RE = Regex("""^\d{4}-\d{2}-\d{2}_.+""") + + /** + * 세션 이름(= testId 의 바탕). **`<날짜_환자>_align[_HHmm]`.** + * + * 2026-09-29 까지는 `<환자명>_align` 이었다 — 날짜가 없다. labdb 는 같은 testId 를 다시 + * 받으면 rowIndex 가 같은 레코드를 **조용히 무시**한다(labdb.md "동일 testId+rowIndex → + * 자동 무시"). 그래서 같은 환자(또는 같은 시험 이름)를 다른 날 다시 정렬하면 그날의 + * 정렬은 한 건도 안 들어가고, 앱은 응답 200 을 받아 **성공 마커를 남긴다** — 화면에는 + * 올라간 것으로 보이는데 서버엔 옛 데이터뿐이다. 다른 기기가 먼저 쓴 testId 면 409 다. + * 조합 CSV(600)는 파일명에 날짜·자세·충만도·주파수가 다 들어가 이 문제가 없었다. + * + * 그래서 조합 CSV 처럼 **환자·날짜 폴더 이름**을 바탕으로 쓰고, 같은 날 두 번 정렬한 + * 경우를 위해 정렬 시작 시각(`started_at`, 있을 때만)을 HHmm 으로 붙인다. 폴더가 그 + * 모양이 아니면(단위시험 임시 폴더) 종전대로 `save_name` 을 쓴다. + */ + internal fun sessionBase(alignDir: File, saveName: String, summary: JSONObject?): String { + val dirName = alignDir.parentFile?.name.orEmpty() + val root = if (RUN_DIR_RE.matches(dirName)) dirName else saveName.ifBlank { "align" } + val hhmm = summary?.optString("started_at").orEmpty() + .let { Regex("""T(\d{2}):(\d{2})""").find(it) } + ?.let { it.groupValues[1] + it.groupValues[2] } + return root + "_align" + (hhmm?.let { "_$it" } ?: "") + } + /** `^[A-Za-z0-9_-]{1,40}$`. 넘치면 앞 31자 + SHA-1 앞 8자 — 규칙은 임상 쪽과 같다. */ fun testId(base: String): String { val safe = base.replace(Regex("[^A-Za-z0-9_-]"), "_") diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt index 5851209..2d5a45a 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt @@ -112,10 +112,17 @@ object HospitalLabdbUploader { * * `align/` 밑의 정렬 원시 데이터는 대상이 아니다 — 형식이 다르고(위치별 cycle), * 올릴지 여부도 별도 판단이다. + * + * `*_imu.csv`(조합 CSV 의 IMU 형제, 2026-09-21~)도 대상이 아니다 — 그 내용은 조합 CSV 를 + * 올릴 때 `sensor` 로 같이 간다. 2026-09-29 까지는 이것도 세어서 "미업로드 N건"이 부풀고, + * 올리려 들면 변환 실패 마커만 남기며 분당 10건 한도를 갉아먹었다(A34 실측 2건). */ fun pending(runDir: File): List = runDir.listFiles() - ?.filter { it.isFile && it.extension.equals("csv", true) && !isUploaded(it) } + ?.filter { + it.isFile && it.extension.equals("csv", true) && + !it.name.endsWith("_imu.csv", ignoreCase = true) && !isUploaded(it) + } ?.sortedBy { it.name } ?: emptyList() @@ -183,8 +190,9 @@ object HospitalLabdbUploader { * 정렬을 올릴 자연스러운 시점이 바로 **떠나는 순간**(임상 측정으로 넘어가거나 뒤로 * 나갈 때)이라, 그 스코프로는 영원히 못 올린다. * - * 같은 폴더를 다시 올려도 안전하다. testId 가 `<저장이름>_align` 으로 고정이고 - * labdb 가 testId+rowIndex 로 멱등이라, 바뀐 것만 반영되고 나머지는 중복으로 센다. + * 같은 폴더를 다시 올려도 안전하다. testId 가 폴더·시작 시각으로 고정이고 + * ([AlignLabdbPayload.sessionBase]) labdb 가 testId+rowIndex 로 멱등이라, 새 것만 + * 들어가고 나머지는 중복으로 센다. * * 결과는 [lastMessage] 와 [uploadingName] 으로 나가므로 다음 화면이 그대로 읽는다. */ diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index b60a470..ed282bc 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -197,13 +197,19 @@ fun AnchorAlignView(appState: AppState) { var lrConfirmed by remember { mutableStateOf(false) } // 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다. - val runStartedAt = remember { Date() } + // [처음부터 다시 정렬] 이 새 값을 준다 — labdb 세션 이름의 `_HHmm` 이 여기서 나오므로 + // 다시 한 정렬이 앞 시도와 같은 testId 로 가서 전부 중복 처리되는 일을 막는다. + var runStartedAt by remember { mutableStateOf(Date()) } // 저장 폴더 이름. 환자명이 비면 시각으로 대체한다(데이터를 잃지 않기 위해). // 세션 안에서 고정돼야 위치별 파일이 한 폴더에 모인다. val fallbackName = remember { "unnamed_" + SimpleDateFormat("HHmmss", Locale.US).format(runStartedAt) } val saveName = appState.hospitalPatient.ifBlank { fallbackName } + // 아래 DisposableEffect(Unit) 의 onDispose 는 **첫 컴포지션의** 값을 붙든다. 정렬 화면에서 + // 환자명을 나중에 치면 측정은 그 환자 폴더에 쓰였는데 업로드는 `unnamed_…` 빈 폴더를 + // 보고 "원시 데이터가 없습니다"로 끝난다 — 그 정렬은 자동으로는 영영 안 올라간다. + val saveNameNow = rememberUpdatedState(saveName) // ── 판정: **잰 위치 전부**로 고른다 ──────────────────────────────────── // 2026-09-10 결정. 종전에는 AnchorGuide 의 단일 pass 답(종료 시점까지의 후보)을 썼다. @@ -271,7 +277,7 @@ fun AnchorAlignView(appState: AppState) { DisposableEffect(Unit) { onDispose { if (hasAlignData && alignDirty) { - val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val dir = HospitalRunStore.alignDir(saveNameNow.value, runStartedAt) HospitalLabdbUploader.uploadAlignAsync(dir) alignDirty = false } @@ -404,7 +410,7 @@ fun AnchorAlignView(appState: AppState) { dir, buildAlignSummary(saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, step, - decidedAtCm = decidedAtCm), + decidedAtCm = decidedAtCm, startedAt = runStartedAt), ) // ── 다음 위치 ────────────────────────────────────────────────── // **0~SEARCH_MAX_CM 를 무조건 다 잰다.** guide 의 안내(MOVE_UP/MOVE_DOWN)는 @@ -842,7 +848,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lateralConfirmed = true, - decidedAtCm = decidedAtCm, + decidedAtCm = decidedAtCm, startedAt = runStartedAt, ), ) } @@ -885,6 +891,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm, + startedAt = runStartedAt, ), ) } @@ -917,6 +924,14 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(8.dp)) TextButton( onClick = { + // 앞 시도는 지금 올려 둔다 — 새 시도가 같은 폴더의 파일을 위치마다 + // 덮어쓰므로, 여기서 안 올리면 그 데이터는 어디에도 남지 않는다. + if (hasAlignData && alignDirty) { + HospitalLabdbUploader.uploadAlignAsync( + HospitalRunStore.alignDir(saveName, runStartedAt)) + alignDirty = false + } + runStartedAt = Date() guide.reset(); cm = 0; last = null; records = emptyList(); error = null lateral.reset(); lrCommit = null; lrAccum = 0 lrConfirmed = false; lrError = null; lrRunning = false @@ -1340,7 +1355,10 @@ private fun buildAlignSummary( * 앱이 틀린 것으로 읽는다. */ decidedAtCm: Int? = null, + /** 이 정렬 세션의 시작 시각. labdb testId 의 `_HHmm` 이 여기서 나온다([AlignLabdbPayload.sessionBase]). */ + startedAt: Date? = null, ): JSONObject = JSONObject().apply { + startedAt?.let { put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(it)) } // 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor` // 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다. val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 421fa9f..02f5904 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -423,6 +423,11 @@ fun HospitalModeView(appState: AppState) { put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL) put("combinations", results) }, startedAt) + // 정렬(601)은 정렬 화면을 떠나는 순간 한 번만 올리고, 조합 CSV 와 달리 뒤에 + // 다시 시도하는 데가 없었다. 그 순간 망이 막혀 있었으면(병원 무선망) 정렬만 + // 빠진 채 조합만 올라간다 — 2026-09-29 임상. 실행이 끝날 때마다 이 폴더의 + // 미업로드 정렬을 한 번 더 민다. 올라간 것은 마커로 걸러져 다시 보내지 않는다. + HospitalLabdbUploader.pendingAlign(dir)?.let { HospitalLabdbUploader.uploadAlignAsync(it) } // 전 폴더 기준으로 센다 — 지난 측정이 남아 있으면 그것도 보여야 한다. pendingUploads = HospitalLabdbUploader.pendingAll().size running = false diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt index 1e8e112..011d4ff 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt @@ -255,6 +255,21 @@ class AlignLabdbPayloadTest { assertNull(AlignLabdbPayload.build(d)) } + @Test fun `세션 이름은 환자·날짜 폴더와 시작 시각으로 만든다`() { + // 2026-09-29: `<환자명>_align` 에는 날짜가 없어 같은 환자를 다른 날 다시 정렬하면 + // labdb 가 전부 중복으로 무시했다(응답은 200 — 앱은 성공 마커를 남겼다). + val run = tmp.newFolder("2026-09-29_P001") + val d = File(run, "align").apply { mkdirs() } + val withTime = JSONObject().apply { put("started_at", "2026-09-29T14:32:10.000+09:00") } + assertEquals("2026-09-29_P001_align_1432", AlignLabdbPayload.sessionBase(d, "P001", withTime)) + // 옛 폴더(요약에 started_at 없음)는 시각 없이 — 그래도 날짜는 들어간다. + assertEquals("2026-09-29_P001_align", AlignLabdbPayload.sessionBase(d, "P001", JSONObject())) + assertEquals("2026-09-29_P001_align", AlignLabdbPayload.sessionBase(d, "P001", null)) + // 환자·날짜 폴더 모양이 아니면(임시 폴더) 종전대로 save_name. + val odd = File(tmp.newFolder("junk"), "align").apply { mkdirs() } + assertEquals("260908_P001_align", AlignLabdbPayload.sessionBase(odd, "260908_P001", null)) + } + @Test fun `testId 는 40자 제한을 지킨다`() { val long = AlignLabdbPayload.testId("260908_P001_아주긴환자폴더이름_align_extra_long_name") assertTrue(long.length <= 40) diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt index cb4fc2e..68d1489 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt @@ -110,6 +110,23 @@ class HospitalAlignPendingTest { assertNull(HospitalLabdbUploader.pendingAlign(run)) } + /** + * `*_imu.csv` 는 조합 CSV 의 IMU 형제 파일이지 세션이 아니다. 대상에 넣으면 변환 실패 + * 마커만 남기고 "미업로드 N건"이 부푼다 — A34 실측(2026-09-29) 2건이 그것이었다. + */ + @Test + fun `IMU 형제 파일은 조합 CSV 목록에 들어가지 않는다`() { + val run = runDir("2026-09-29_p9") + File(run, "2026-09-29_p9_Supine_000pct_2.3MHz-f5_c3.csv") + .writeText("ch,peak\n0,3500\n", Charsets.UTF_8) + File(run, "2026-09-29_p9_Supine_000pct_2.3MHz-f5_c3_imu.csv") + .writeText("scan_id,timestamp,repeat_idx\n1,t,0\n", Charsets.UTF_8) + + val combos = HospitalLabdbUploader.pending(run) + assertEquals(1, combos.size) + assertFalse(combos[0].name.endsWith("_imu.csv")) + } + /** 조합 CSV 쪽 대상 선정이 `align/` 때문에 흔들리지 않아야 한다. */ @Test fun `조합 CSV 목록에 정렬 폴더가 섞이지 않는다`() { diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 29dedde..798e5d6 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -156,9 +156,9 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | 필드 | 타입 | 설명 | |---|---|---| -| `testId` | string | `<환자폴더>_align` 기반 | +| `testId` | string | `<날짜_환자폴더>_align_` 기반 (2026-09-29~). 그 전에는 `<환자명>_align` — 날짜가 없어 같은 환자를 다른 날 다시 정렬하면 전부 중복으로 무시됐습니다 | | `dataType` | string | `"601"` | -| `sessionName` | string | `<환자폴더>_align` | +| `sessionName` | string | `testId` 와 같은 바탕 | | `memo` | string | **간호사가 적은 증상.** 없으면 필드 자체가 없음 | | `savedAt` | ISO8601 | 업로드 시각 | | `recordCount` | int | 전체 위치의 cycle 합계 |