From 3708ce5f951af0b6e14807bfdbfddb83daf5592e Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 21 Sep 2026 11:35:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(labdb):=20=EC=A0=95=EB=A0=AC=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=B8=A1=EC=A0=95=EC=9D=B4=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EB=B9=A0=EC=A0=B8=20=EC=9E=88?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=20=C2=B7=20phase=20=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전수 탐색(0~4cm)이 끝나면 고른 위치를 한 번 더 재고, 그 결과는 `align_{n}cm_confirm.csv` 로 따로 쓰인다(판정에 쓰인 데이터를 덮지 않기 위해). 그런데 페이로드의 파일 정규식이 `^align_(-?\d+)cm\.csv$` 로 끝나 **확인 측정 파일이 하나도 잡히지 않았다** — 개발자가 "왜 이 위치인가"를 보려면 고른 자리의 실제 파형이 필요한데 그것이 서버에 없었다. 정규식에 `(_confirm)?` 를 선택 그룹으로 넣고, 레코드에 `phase`("sweep" | "confirm")를 추가했다. 같은 `align_cm` 으로 두 벌이 올라가므로 phase 가 없으면 받는 쪽이 같은 위치를 두 번 잰 것을 두 위치로 읽는다. 정렬 순서도 (cm 오름차순, 같은 cm 이면 sweep → confirm) 으로 바꿨다. 확인 측정이 실제로 나중에 일어나므로, 받는 쪽이 파일 순서를 시간 순서로 읽어도 어긋나지 않는다. 이 변경은 작업 트리에 이미 있던 것이고(2026-09-18 자 주석), 이번 세션에서 작성한 것이 아니다. `AlignLabdbPayloadTest` 12건을 재실행해 통과를 확인하고 커밋했다. Co-Authored-By: Claude Opus 5 --- .../services/labdb/AlignLabdbPayload.kt | 43 +++++++++++++++--- .../services/labdb/AlignLabdbPayloadTest.kt | 45 ++++++++++++++++++- 2 files changed, 81 insertions(+), 7 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 52649e7..3786556 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 @@ -39,9 +39,22 @@ import java.util.Locale * record = 한 위치의 한 cycle * rowIndex : 0..N 연속 (labdb 규약) * align_cm : 그 cycle 을 잰 위치 + * phase : "sweep" | "confirm" ← 2026-09-18 추가 * cycle_idx : 그 위치 안에서의 순번 * channels : 6 × {ch, peak, peakIdx, data[~100]} * ``` + * + * ## phase 를 나눠 담는 이유 + * 전수 탐색(0~4cm)이 끝나면 고른 위치를 **한 번 더 잰다**. 그 확인 측정은 + * `align_{n}cm_confirm.csv` 로 따로 쓰인다 — 같은 이름에 쓰면 판정에 쓰인 데이터가 + * 덮이기 때문이다([HospitalRunStore.writeAlignCycles]). + * + * 그런데 예전 정규식이 `align_{n}cm.csv` 만 잡아서 **확인 측정이 업로드에서 통째로 + * 빠져 있었다**(2026-09-18 발견). 개발자가 "왜 이 위치인가"를 보려면 고른 자리에서 + * 실제로 어떻게 나왔는지가 필요한데, 그 파형이 서버에 없었다. + * + * 같은 `align_cm` 으로 두 벌이 올라가므로 `phase` 없이는 구분할 수 없다. 받는 쪽이 + * 섞어 보면 같은 위치를 두 번 잰 것이 두 위치처럼 읽힌다. */ object AlignLabdbPayload { @@ -55,8 +68,14 @@ object AlignLabdbPayload { private const val PROTOCOL = "hospital_align_2026" - /** `align_3cm.csv` 에서 3 을 뽑는다. */ - private val NAME_RE = Regex("""^align_(-?\d+)cm\.csv$""", RegexOption.IGNORE_CASE) + /** + * `align_3cm.csv` → (3, sweep) · `align_3cm_confirm.csv` → (3, confirm). + * + * 2026-09-18: `_confirm` 을 선택 그룹으로 받는다. 예전에는 `cm\.csv$` 로 끝나 + * 확인 측정 파일이 하나도 안 잡혔다. + */ + private val NAME_RE = + Regex("""^align_(-?\d+)cm(_confirm)?\.csv$""", RegexOption.IGNORE_CASE) private val isoOut: SimpleDateFormat get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) @@ -68,9 +87,16 @@ object AlignLabdbPayload { * @return 올릴 페이로드. 읽을 cycle 이 없으면 null. */ fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? { + // cm 오름차순, 같은 cm 이면 sweep 먼저 · confirm 나중. 받는 쪽이 파일 순서를 + // 그대로 시간 순서로 읽어도 어긋나지 않게 한다(확인 측정이 나중에 일어난다). val files = alignDir.listFiles() ?.filter { it.isFile && NAME_RE.matches(it.name) } - ?.sortedBy { NAME_RE.find(it.name)!!.groupValues[1].toInt() } + ?.sortedWith( + compareBy( + { NAME_RE.find(it.name)!!.groupValues[1].toInt() }, + { if (NAME_RE.find(it.name)!!.groupValues[2].isEmpty()) 0 else 1 }, + ) + ) ?: return null if (files.isEmpty()) return null @@ -81,7 +107,10 @@ object AlignLabdbPayload { val records = JSONArray() var row = 0 for (f in files) { - val cm = NAME_RE.find(f.name)!!.groupValues[1].toInt() + val m = NAME_RE.find(f.name)!! + val cm = m.groupValues[1].toInt() + // 같은 align_cm 으로 두 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다. + val phase = if (m.groupValues[2].isEmpty()) "sweep" else "confirm" // `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles). val byCycle = linkedMapOf>() runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line -> @@ -110,6 +139,7 @@ object AlignLabdbPayload { records.put(JSONObject().apply { put("rowIndex", row++) put("align_cm", cm) + put("phase", phase) put("cycle_idx", ci) put("commandType", "MTB") put("channels", chans) @@ -127,7 +157,10 @@ object AlignLabdbPayload { put("protocol", PROTOCOL) put("subject", subject) put("save_name", saveName) - put("positions_measured", files.size) + // 2026-09-18: `files.size` 를 그대로 쓰면 확인 측정이 위치 하나로 더해져 + // "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다. + put("positions_measured", files.count { NAME_RE.find(it.name)!!.groupValues[2].isEmpty() }) + put("confirm_positions", files.count { NAME_RE.find(it.name)!!.groupValues[2].isNotEmpty() }) put("cycles_total", records.length()) // 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라, // 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다. 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 42ac7fe..30c8049 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 @@ -21,14 +21,19 @@ class AlignLabdbPayloadTest { @get:Rule val tmp = TemporaryFolder() /** `cycleIdx,ch,s0,s1,...` — HospitalRunStore.writeAlignCycles 형식(헤더 없음). */ - private fun writeCycles(dir: File, cm: Int, cycles: Int, chans: Int = 6, len: Int = 100) { + private fun writeCycles( + dir: File, cm: Int, cycles: Int, chans: Int = 6, len: Int = 100, + /** 고른 위치를 다시 잰 확인 측정 — `align_{cm}cm_confirm.csv` 로 따로 쓰인다. */ + confirm: Boolean = false, + ) { val sb = StringBuilder() for (ci in 0 until cycles) for (ch in 0 until chans) { sb.append(ci).append(',').append(ch) for (i in 0 until len) sb.append(',').append(if (i == 30 + ch) 3500 else 900) sb.append('\n') } - File(dir, "align_${cm}cm.csv").writeText(sb.toString(), Charsets.UTF_8) + val name = if (confirm) "align_${cm}cm_confirm.csv" else "align_${cm}cm.csv" + File(dir, name).writeText(sb.toString(), Charsets.UTF_8) } private fun summary(): JSONObject = JSONObject().apply { @@ -46,6 +51,42 @@ class AlignLabdbPayloadTest { put("lateral", JSONObject().apply { put("done", true); put("imbalance", 5) }) } + @Test fun `확인 측정도 올라가고 phase 로 구분된다`() { + // 2026-09-18: 예전 정규식이 `align_{n}cm.csv` 만 잡아 확인 측정이 통째로 + // 빠져 있었다. 고른 자리에서 실제로 어떻게 나왔는지가 빠지면 "왜 이 위치인가"에 + // 답할 수 없다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + writeCycles(d, 2, cycles = 1) + writeCycles(d, 2, cycles = 1, confirm = true) // 2cm 를 고르고 다시 쟀다 + val p = AlignLabdbPayload.build(d)!! + val recs = p.getJSONArray("records") + + assertEquals(3, p.getInt("recordCount")) + // 같은 cm 이 두 벌 — phase 없이는 두 위치처럼 읽힌다. + assertEquals(listOf(0, 2, 2), (0 until 3).map { recs.getJSONObject(it).getInt("align_cm") }) + // sweep 이 먼저, confirm 이 나중 (실제 측정 순서와 같다). + assertEquals( + listOf("sweep", "sweep", "confirm"), + (0 until 3).map { recs.getJSONObject(it).getString("phase") }, + ) + // rowIndex 는 여전히 0..N 연속이어야 한다(labdb 규약). + assertEquals((0 until 3).toList(), (0 until 3).map { recs.getJSONObject(it).getInt("rowIndex") }) + } + + @Test fun `확인 측정은 위치 수에 더해지지 않는다`() { + // files.size 를 그대로 쓰면 "3군데 쟀다"가 4가 된다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + writeCycles(d, 1, cycles = 1) + writeCycles(d, 2, cycles = 1) + writeCycles(d, 2, cycles = 1, confirm = true) + val params = AlignLabdbPayload.build(d)!!.getJSONObject("params") + + assertEquals(3, params.getInt("positions_measured")) + assertEquals(1, params.getInt("confirm_positions")) + } + @Test fun `위치별 cycle 이 record 가 된다`() { val d = tmp.newFolder("align") writeCycles(d, 0, cycles = 2)