fix(labdb): 정렬 확인 측정이 업로드에서 빠져 있던 것 · phase 로 구분

전수 탐색(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 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 11:35:33 +09:00
parent 6c5e38080c
commit 3708ce5f95
2 changed files with 81 additions and 7 deletions
@@ -39,9 +39,22 @@ import java.util.Locale
* record = 한 위치의 한 cycle * record = 한 위치의 한 cycle
* rowIndex : 0..N 연속 (labdb 규약) * rowIndex : 0..N 연속 (labdb 규약)
* align_cm : 그 cycle 을 잰 위치 * align_cm : 그 cycle 을 잰 위치
* phase : "sweep" | "confirm" ← 2026-09-18 추가
* cycle_idx : 그 위치 안에서의 순번 * cycle_idx : 그 위치 안에서의 순번
* channels : 6 × {ch, peak, peakIdx, data[~100]} * 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 { object AlignLabdbPayload {
@@ -55,8 +68,14 @@ object AlignLabdbPayload {
private const val PROTOCOL = "hospital_align_2026" 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 private val isoOut: SimpleDateFormat
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
@@ -68,9 +87,16 @@ object AlignLabdbPayload {
* @return 올릴 페이로드. 읽을 cycle 이 없으면 null. * @return 올릴 페이로드. 읽을 cycle 이 없으면 null.
*/ */
fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? { fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? {
// cm 오름차순, 같은 cm 이면 sweep 먼저 · confirm 나중. 받는 쪽이 파일 순서를
// 그대로 시간 순서로 읽어도 어긋나지 않게 한다(확인 측정이 나중에 일어난다).
val files = alignDir.listFiles() val files = alignDir.listFiles()
?.filter { it.isFile && NAME_RE.matches(it.name) } ?.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 ?: return null
if (files.isEmpty()) return null if (files.isEmpty()) return null
@@ -81,7 +107,10 @@ object AlignLabdbPayload {
val records = JSONArray() val records = JSONArray()
var row = 0 var row = 0
for (f in files) { 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). // `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles).
val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>() val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>()
runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line -> runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line ->
@@ -110,6 +139,7 @@ object AlignLabdbPayload {
records.put(JSONObject().apply { records.put(JSONObject().apply {
put("rowIndex", row++) put("rowIndex", row++)
put("align_cm", cm) put("align_cm", cm)
put("phase", phase)
put("cycle_idx", ci) put("cycle_idx", ci)
put("commandType", "MTB") put("commandType", "MTB")
put("channels", chans) put("channels", chans)
@@ -127,7 +157,10 @@ object AlignLabdbPayload {
put("protocol", PROTOCOL) put("protocol", PROTOCOL)
put("subject", subject) put("subject", subject)
put("save_name", saveName) 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()) put("cycles_total", records.length())
// 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라, // 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라,
// 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다. // 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다.
@@ -21,14 +21,19 @@ class AlignLabdbPayloadTest {
@get:Rule val tmp = TemporaryFolder() @get:Rule val tmp = TemporaryFolder()
/** `cycleIdx,ch,s0,s1,...` — HospitalRunStore.writeAlignCycles 형식(헤더 없음). */ /** `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() val sb = StringBuilder()
for (ci in 0 until cycles) for (ch in 0 until chans) { for (ci in 0 until cycles) for (ch in 0 until chans) {
sb.append(ci).append(',').append(ch) sb.append(ci).append(',').append(ch)
for (i in 0 until len) sb.append(',').append(if (i == 30 + ch) 3500 else 900) for (i in 0 until len) sb.append(',').append(if (i == 30 + ch) 3500 else 900)
sb.append('\n') 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 { private fun summary(): JSONObject = JSONObject().apply {
@@ -46,6 +51,42 @@ class AlignLabdbPayloadTest {
put("lateral", JSONObject().apply { put("done", true); put("imbalance", 5) }) 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 가 된다`() { @Test fun `위치별 cycle 이 record 가 된다`() {
val d = tmp.newFolder("align") val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 2) writeCycles(d, 0, cycles = 2)