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
* 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<Int, MutableMap<Int, IntArray>>()
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)와 선택 결과가 파형과 같이 있어야 한다.