From 84296b07b455db849d1b80c973a2057dec1dfc51 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 15 Oct 2026 15:37:56 +0900 Subject: [PATCH] =?UTF-8?q?fix(labdb):=20=EC=A0=95=EB=A0=AC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=97=90=20=EA=B0=99=EC=9D=80=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EC=9D=98=20=EC=95=9E=20=EC=84=B8=EC=85=98=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=B4=20=EC=84=9E=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20=E2=80=94=20=EC=9A=94=EC=95=BD?= =?UTF-8?q?=20attempts=20=EA=B8=B0=EC=A4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정렬 폴더가 환자·날짜·자세로만 갈려서 같은 날 같은 환자를 다시 정렬하면 앞 세션의 align_{n}cm.csv 가 그대로 남고, 페이로드는 폴더의 파일을 전부 담았다. 2026-10-15 팬텀 세션에서 0cm 만 쟀는데 1~4cm 80 record 가 같은 testId 로 올라갔다 — 요약(positions· swept_to_cm)은 0cm 하나인데 records 는 5위치였다. - AlignLabdbPayload.isFromThisRun: 요약의 attempts(위치 → 이 세션에서 잰 횟수)로 가른다. 탐색·확인은 cm ∈ attempts 이고 _rK 의 K ≤ attempts[cm]. 좌우·재부착은 잰 자리 또는 anchor_cm/proceed_anchor_cm. standing 확인은 폴더 기준으로 번호를 매겨 덮어쓰지 않으므로 거르지 않는다. attempts 가 없는 요약(옛 앱)은 종전대로 전부 담는다. - params.stale_files_skipped: 걸러낸 파일 수. 0 이 정상. - 문서: 그 전 앱으로 올린 601 세션은 positions_measured 와 params.positions 길이가 다르면 혼입 의심 — align_cm ∉ positions[].align_cm 인 record 를 빼고 본다. 폰 폴더 자체가 세션을 섞는 것(새 0cm 이 옛 0cm 을 덮어씀)은 이 커밋이 다루지 않는다. Co-Authored-By: Claude Fable 5.1 --- .../services/labdb/AlignLabdbPayload.kt | 64 ++++++++++-- .../services/labdb/AlignLabdbPayloadTest.kt | 97 +++++++++++++++++++ docs/LABDB_DATATYPES.md | 4 +- 3 files changed, 155 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 29d46de..6c44206 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 @@ -35,6 +35,9 @@ import java.util.Locale * 위치별 raw cycle 전부 + 판정 근거(`align_result.json`)를 함께 넣는다. 개발자가 * 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다. * + * 단, **이 세션의 파일만**이다. 폴더가 환자·날짜·자세로만 갈려서 같은 날 다시 정렬하면 앞 + * 세션의 파일이 남아 있다 — 요약의 `attempts` 로 가른다([isFromThisRun], 2026-10-15). + * * ``` * record = 한 위치의 한 cycle * rowIndex : 0..N 연속 (labdb 규약) @@ -119,24 +122,28 @@ object AlignLabdbPayload { * @return 올릴 페이로드. 읽을 cycle 이 없으면 null. */ fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? { - // cm 오름차순, 같은 cm 이면 sweep 먼저 · confirm 나중. 받는 쪽이 파일 순서를 - // 그대로 시간 순서로 읽어도 어긋나지 않게 한다(확인 측정이 나중에 일어난다). - val files = alignDir.listFiles() + val summary = File(alignDir, "align_result.json") + .takeIf { it.exists() } + ?.let { runCatching { JSONObject(it.readText()) }.getOrNull() } + + val named = alignDir.listFiles() ?.filter { it.isFile && NAME_RE.matches(it.name) } - ?.sortedWith( + ?: return null + // 이 정렬 세션의 파일만 — 폴더에는 같은 날 앞서 한 정렬의 파일이 남아 있을 수 있다 + // ([isFromThisRun]). cm 오름차순, 같은 cm 이면 sweep 먼저 · confirm 나중. 받는 쪽이 + // 파일 순서를 그대로 시간 순서로 읽어도 어긋나지 않게 한다(확인 측정이 나중에 일어난다). + val files = named + .filter { isFromThisRun(NAME_RE.find(it.name)!!, summary) } + .sortedWith( compareBy( { NAME_RE.find(it.name)!!.groupValues[1].toInt() }, { phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) }, { attemptOf(NAME_RE.find(it.name)!!) }, ) ) - ?: return null + val staleSkipped = named.size - files.size 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) { @@ -242,6 +249,9 @@ object AlignLabdbPayload { put("reattach_frames", countPhase("reattach")) put("standing_confirm_frames", countPhase("standing_confirm")) put("cycles_total", records.length()) + // 폴더에 있었지만 이 세션 것이 아니라 뺀 파일 수(2026-10-15). 0 이 정상이다. 0 이 + // 아니면 같은 날 같은 환자를 다시 정렬한 폴더다 — 폰에 남은 raw 는 세션이 섞여 있다. + put("stale_files_skipped", staleSkipped) // 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라, // 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다. // @@ -276,6 +286,42 @@ object AlignLabdbPayload { } } + /** + * 이 파일이 `align_result.json` 이 말하는 정렬 세션의 것인가. + * + * 정렬 폴더는 환자·날짜·자세로만 갈린다([HospitalRunStore.alignDir]) — 시작 시각이 키에 없다. + * 같은 날 같은 환자를 다시 정렬하면 새 세션은 **잰 위치의 파일만** 덮어쓰고 나머지는 앞 세션 + * 것이 그대로 남는다. 2026-10-15 팬텀 세션에서 0cm 만 쟀는데 앞 세션의 1~4cm 80 record 가 + * 같은 testId 로 올라갔다 — 요약(`positions`·`swept_to_cm`)은 0cm 하나인데 records 는 5위치였다. + * + * 요약의 `attempts`(위치 → 이 세션에서 잰 횟수. 측정마다 요약을 새로 쓰고, 세션을 다시 + * 시작하면 비운다)가 이 세션이 실제로 쓴 파일의 목록이다: + * - 탐색·확인 `align_{n}cm[_confirm][_rK].csv`: n 이 attempts 에 있고 K ≤ attempts[n]. + * 앞 세션이 같은 자리를 더 많이 재서 남긴 `_r3` 은 K 가 커서 걸러진다. (같은 자리의 탐색과 + * 확인은 한 카운터를 쓴다 — 탐색 1, 확인 2 → `_confirm_r2`.) + * - 좌우·좌우 흐름·재부착: 부착 위치에서 일어나고 번호가 없다 — attempts 의 위치이거나 요약의 + * `anchor_cm`/`proceed_anchor_cm` 이면 이 세션 것으로 본다. 앞 세션과 부착 위치가 같으면 + * 가를 근거가 요약에 없어 그대로 담는다(덜 보내는 쪽이 위험). + * - standing 확인: 정렬 **앞**에 일어나고 번호를 폴더의 파일 수로 매겨 덮어쓰지 않으므로 + * ([StandingConfirmCard]) 전부 그날의 실데이터다 — 거르지 않는다. + * + * 요약에 `attempts` 가 없으면(옛 앱 폴더, 또는 이 세션이 아직 아무 위치도 안 잰 상태) 종전대로 + * 전부 담는다 — 진단용 페이로드라 근거 없이는 거르지 않는다. + */ + internal fun isFromThisRun(m: MatchResult, summary: JSONObject?): Boolean { + val attempts = summary?.optJSONObject("attempts") ?: return true + val cm = m.groupValues[1].toInt() + val key = cm.toString() + val measuredHere = attempts.has(key) + return when (phaseOf(m.groupValues[2])) { + "sweep", "confirm" -> measuredHere && attemptOf(m) <= attempts.optInt(key, 0) + "standing_confirm" -> true + else -> measuredHere || + cm == summary.optInt("anchor_cm", Int.MIN_VALUE) || + cm == summary.optInt("proceed_anchor_cm", Int.MIN_VALUE) + } + } + /** `yyyy-MM-dd_…` — [HospitalRunStore.runDir] 이 만드는 환자·날짜 폴더 이름. */ private val RUN_DIR_RE = Regex("""^\d{4}-\d{2}-\d{2}_.+""") 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 c5d17cb..28c1e75 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 @@ -379,4 +379,101 @@ class AlignLabdbPayloadTest { assertTrue(Regex("^[A-Za-z0-9_-]{1,40}$").matches(long)) assertEquals("260908_P001_align", AlignLabdbPayload.testId("260908_P001_align")) } + + // ── 같은 폴더에 남은 앞 세션 파일 (2026-10-15) ────────────────────────────── + + /** 요약에 `attempts`(이 세션이 잰 위치 → 횟수)가 있는 폴더. */ + private fun summaryWithAttempts(vararg cmToN: Pair, anchorCm: Int? = null): JSONObject = + summary().apply { + put("attempts", JSONObject().apply { cmToN.forEach { (c, n) -> put(c.toString(), n) } }) + put("anchor_cm", anchorCm ?: JSONObject.NULL) + put("started_at", "2026-10-15T14:56:05.122+09:00") + } + + @Test fun `요약의 attempts 에 없는 위치 파일은 앞 세션 것이라 올리지 않는다`() { + // 2026-10-15 팬텀: 0cm 만 쟀는데 100 record(0~4cm × 20)가 올라갔다. 폴더가 환자·날짜·자세로만 + // 갈려서 같은 날 앞서 한 정렬의 1~4cm 파일이 남아 있었고, 페이로드는 폴더의 파일을 전부 담았다. + // 요약(positions·swept_to_cm)은 0cm 하나 — 요약과 records 가 서로 모순인 세션이 됐다. + val d = tmp.newFolder("align_supine") + for (cm in 0..4) writeCycles(d, cm, cycles = 20) + File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8) + + val p = AlignLabdbPayload.build(d)!! + assertEquals(20, p.getInt("recordCount")) + val recs = p.getJSONArray("records") + assertTrue((0 until 20).all { recs.getJSONObject(it).getInt("align_cm") == 0 }) + val params = p.getJSONObject("params") + assertEquals(1, params.getInt("positions_measured")) + // 걸러낸 파일 수를 남긴다 — 폴더에 앞 세션 것이 섞여 있었다는 사실 자체가 진단 정보다. + assertEquals(4, params.getInt("stale_files_skipped")) + } + + @Test fun `attempts 횟수보다 큰 재측정 파일도 앞 세션 것이다`() { + // 앞 세션이 3cm 를 두 번 재서 _r2 가 남았고, 이 세션은 3cm 를 한 번만 쟀다(align_3cm.csv 는 덮어씀). + val d = tmp.newFolder("align_supine") + writeCycles(d, 3, cycles = 1) + File(d, "align_3cm_r2.csv").writeText((0 until 6).joinToString("") { ch -> "0,$ch,900,900,3500,900\n" }, Charsets.UTF_8) + File(d, "align_result.json").writeText(summaryWithAttempts(3 to 1).toString(), Charsets.UTF_8) + + val p = AlignLabdbPayload.build(d)!! + assertEquals(1, p.getInt("recordCount")) + assertEquals(1, p.getJSONArray("records").getJSONObject(0).getInt("attempt")) + assertEquals(1, p.getJSONObject("params").getInt("stale_files_skipped")) + + // 이 세션이 두 번 쟀으면(attempts 3→2) 둘 다 이 세션 것이다. + File(d, "align_result.json").writeText(summaryWithAttempts(3 to 2).toString(), Charsets.UTF_8) + assertEquals(2, AlignLabdbPayload.build(d)!!.getInt("recordCount")) + } + + @Test fun `요약에 attempts 가 없으면 종전대로 전부 담는다`() { + // 옛 앱이 만든 폴더(요약에 attempts 없음)와 요약 자체가 없는 폴더. 근거 없이 거르면 + // 진짜 데이터가 빠진다 — 진단용 페이로드는 덜 보내는 쪽이 위험하다. + val d = tmp.newFolder("align") + for (cm in 0..2) writeCycles(d, cm, cycles = 1) + File(d, "align_result.json").writeText(summary().toString(), Charsets.UTF_8) // attempts 없음 + val p = AlignLabdbPayload.build(d)!! + assertEquals(3, p.getInt("recordCount")) + assertEquals(0, p.getJSONObject("params").getInt("stale_files_skipped")) + + val d2 = tmp.newFolder("align-nosummary") + for (cm in 0..2) writeCycles(d2, cm, cycles = 1) + assertEquals(3, AlignLabdbPayload.build(d2)!!.getInt("recordCount")) + } + + @Test fun `좌우·재부착은 부착 위치로 가르고 standing 확인은 거르지 않는다`() { + // 좌우·재부착은 attempts 로 번호를 매기지 않는다 — 이 세션의 부착 위치(잰 자리 또는 anchor_cm)에 + // 있는 것만 이 세션 것이다. standing 확인은 정렬 앞에 일어나고 폴더 기준으로 번호를 매겨 + // 덮어쓰지 않으므로 전부 그날의 실데이터다. + val d = tmp.newFolder("align_standing") + writeCycles(d, 0, cycles = 1) + writeCycles(d, 1, cycles = 1) + writeCycles(d, 1, cycles = 2, lateral = true) // 이 세션 부착 위치 1cm + writeCycles(d, 3, cycles = 2, lateral = true) // 앞 세션 부착 위치 3cm — 옛 것 + File(d, "align_3cm_reattach.csv").writeText("0,0,900,900,3500,900\n", Charsets.UTF_8) // 옛 것 + File(d, "align_2cm_standing_confirm.csv").writeText("0,0,900,900,3500,900\n", Charsets.UTF_8) + File(d, "align_2cm_standing_confirm_r2.csv").writeText("0,0,900,900,3500,900\n", Charsets.UTF_8) + File(d, "align_result.json").writeText( + summaryWithAttempts(0 to 1, 1 to 1, anchorCm = 1).toString(), Charsets.UTF_8) + + val p = AlignLabdbPayload.build(d)!! + val recs = p.getJSONArray("records") + val got = (0 until recs.length()).map { + recs.getJSONObject(it).getInt("align_cm") to recs.getJSONObject(it).getString("phase") + } + assertEquals( + listOf(0 to "sweep", 1 to "sweep", 1 to "lateral", 1 to "lateral", + 2 to "standing_confirm", 2 to "standing_confirm"), + got, + ) + assertEquals(2, p.getJSONObject("params").getInt("stale_files_skipped")) + } + + @Test fun `모든 파일이 앞 세션 것이면 올릴 것이 없다`() { + // 측정마다 요약을 쓰므로 보통은 안 생기지만, 파일 저장이 실패한 세션에서는 생긴다 — + // 앞 세션 파일로 "성공" 업로드를 만들면 안 된다. + val d = tmp.newFolder("align_supine") + writeCycles(d, 2, cycles = 1) + File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8) + assertNull(AlignLabdbPayload.build(d)) + } } diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 7801022..39fd1e0 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -172,7 +172,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `protocol` | string | `"hospital_align_2026"` 고정 | | `subject` | string | 환자 식별자 | | `save_name` | string | 저장 폴더명 | -| `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만) | +| `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만). 2026-10-15 부터 **이 세션이 잰 위치만** — 그 전 세션은 같은 날 앞서 한 정렬의 위치가 섞여 `params.positions` 보다 클 수 있습니다 | | `confirm_positions` | int | 확인 측정한 위치 수 | | `lateral_frames` | int | 좌우 확인 완료 순간에 남긴 프레임 수 (2026-09-30~). 0 = 좌우 미확인 또는 옛 앱 | | `lateral_stream_frames` | int | 좌우 정렬 동안 흘린 프레임 전부 (2026-09-30~) | @@ -182,6 +182,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | `confirm_tilt_deg_mean` | number | 확인 측정(N cm, 20 cycle)의 IMU 평균 기울기(도, 0~180). Supine 의 값이 Sitting 요구 범위의 기준각 m 입니다 — Sitting 범위 = `35+m ~ 55+m` (2026-10-01~) | | `reattach` | object | 재부착 확인 판정 `{ok, missing[], detached[], channels[4], ref_channels[4]\|null, bv_ml, at}` — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지 | | `cycles_total` | int | = `recordCount` | +| `stale_files_skipped` | int | 폴더에 있었지만 앞 세션 것이라 뺀 파일 수 (2026-10-15~). 0 이 정상. 정렬 폴더는 환자·날짜·자세로만 갈려 같은 날 다시 정렬하면 앞 세션 파일이 남는데, 요약의 `attempts` 에 없는 위치·횟수의 파일은 담지 않습니다 | | `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 | | `device`, `firmware_version`, `hw_preset` | string | | | `freq_mhz`, `freq_option`, `probe_cycles` | number | 정렬 전용 고정 조건 (2.3MHz · cycle 3) | @@ -279,6 +280,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | 기존 적재분 | `901` 로 올렸던 **72세션 · 1,440레코드**(2026-09-04)는 서버에서 `600` 으로 마이그레이션 완료 | | `902` 정렬 1세션 | 같은 `testId` 로 `601` 재업로드하여 갱신 | | 앱 반영 | 상수 2개 변경 + 최상위 `subject` 추가 | +| 앞 세션 파일 혼입 | 2026-10-15 · 같은 날 같은 환자를 다시 정렬하면 폴더에 남은 앞 세션의 `align_{n}cm.csv` 가 새 세션에 같이 올라갔다(0cm 만 잰 팬텀 세션에 100 record). 이후 앱은 요약 `attempts` 기준으로 거른다 — **그 전 앱으로 올린 601 세션은 `positions_measured` 와 `params.positions` 길이가 다르면 혼입 의심**, 서버에서 `align_cm ∉ positions[].align_cm` 인 record 를 빼고 봐야 합니다 | > 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의 > 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다.