feat(align): 좌우 확인 완료 순간의 마지막 프레임 창을 align 에 저장하고 601 로 올린다

좌우 정렬은 연속 스트리밍이라 그동안 아무것도 저장하지 않았다. "맞췄다"고 확인한 그
자리의 파형·IMU 가 파일에 없으니 나중에 좌우 판정을 대조할 길이 없었다(2026-09-30 요청).

  · 좌우 루프가 마지막 프레임 창(LateralGuide.required = 10, 판정 1회에 쓰는 수)을
    파형+IMU 짝으로 들고 있다가, [좌우 확인 완료]에서 부착 위치 cm 으로
    `align_{n}cm_lateral.csv` · `align_{n}cm_lateral_imu.csv` 를 쓴다.
    다시 시작·처음부터 다시 정렬 때는 창을 비운다.
  · HospitalRunStore: 파일 종류를 AlignFileKind(SWEEP/CONFIRM/LATERAL) 로 —
    `confirm: Boolean` 대신. 같은 cm 이라도 종류가 다르면 다른 파일이다.
  · AlignLabdbPayload: `_lateral` 을 phase=lateral 로 올린다(탐색 → 확인 → 좌우 순).
    positions_measured 는 sweep 만, confirm_positions 는 confirm 만, lateral_frames 추가.
    미업로드 대상 판정(raw = sweep)은 그대로.
  · 문서(LABDB_DATATYPES.md) 601 phase·params·IMU 파일명 갱신.

시험: HospitalRunStoreAlignFilesTest 4건 신규, 페이로드 2건·대상 판정 1건 추가 — 80건 통과.
실기(프로브 필요)는 다음 임상에서 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:01:04 +09:00
parent 9665216aad
commit c8964927ce
7 changed files with 216 additions and 37 deletions
@@ -194,11 +194,10 @@ object HospitalRunStore {
* 탐색 측정 것을 덮으면 [writeAlignCycles] 에서 막아 둔 문제가 그대로 재현된다.
*/
fun writeAlignImu(
dir: File, cm: Int, cyclesImu: List<List<ImuSample>>, confirm: Boolean = false,
dir: File, cm: Int, cyclesImu: List<List<ImuSample>>, kind: AlignFileKind = AlignFileKind.SWEEP,
): Boolean = try {
if (cyclesImu.all { it.isEmpty() }) false else {
val name = if (confirm) "align_%dcm_confirm_imu.csv".format(cm)
else "align_%dcm_imu.csv".format(cm)
val name = "align_%dcm%s_imu.csv".format(cm, kind.suffix)
File(dir, name).bufferedWriter().use { w ->
w.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps")
w.newLine()
@@ -237,6 +236,27 @@ object HospitalRunStore {
// 고를 위치와 다를 수 있다. raw 가 남아 있으면 나중에 Python 으로 다시 판정할 수 있고,
// 없으면 그 환자는 다시 부를 수밖에 없다.
/**
* 정렬 파일의 종류 = 파일명 접미사. **같은 cm 이라도 종류가 다르면 다른 파일이다.**
*
* 같은 이름에 쓰면 판정에 쓰인 데이터가 나중 것으로 덮인다 — 화면 판정은 먼저 잰 것
* 기준인데 파일은 나중 것이라, 파일로 재판정하면 앱과 답이 갈린다(2026-09-10 확인
* 측정에서 실제로 그랬다). 그래서 종류마다 파일을 가른다.
*/
enum class AlignFileKind(val suffix: String) {
/** 0~SEARCH_MAX cm 전수 탐색 — 판정에 쓰인 데이터. `align_2cm.csv` */
SWEEP(""),
/** 고른 자리를 다시 잰 확인 측정. `align_2cm_confirm.csv` */
CONFIRM("_confirm"),
/**
* 좌우 정렬 "확인 완료" 순간의 마지막 프레임들(2026-09-30 요청). 좌우는 연속
* 스트리밍이라 그동안 아무것도 남지 않았는데, "맞췄다"고 한 자리의 파형·IMU 가
* 없으면 나중에 좌우 판정을 대조할 수 없다. cm 은 부착 위치(best + 오프셋)다.
* `align_3cm_lateral.csv`
*/
LATERAL("_lateral"),
}
/** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */
fun alignDir(patient: String, startedAt: Date): File {
val dir = File(runDir(patient, startedAt), "align")
@@ -252,18 +272,9 @@ object HospitalRunStore {
*/
fun writeAlignCycles(
dir: File, cm: Int, cycles: List<List<DoubleArray>>,
/**
* 같은 위치를 다시 잰 것인가(부착 위치 확인 측정).
*
* true 면 `align_2cm_confirm.csv` 로 따로 쓴다. 같은 이름에 쓰면 **판정에 쓰인
* 데이터가 확인 측정으로 덮인다** — 화면 판정은 먼저 잰 것 기준인데 파일은
* 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 전수 측정으로 바뀐 뒤
* 이 재측정이 **항상** 일어나므로 그냥 두면 안 된다(2026-09-10).
*/
confirm: Boolean = false,
kind: AlignFileKind = AlignFileKind.SWEEP,
): Boolean = try {
val name = if (confirm) "align_%dcm_confirm.csv".format(cm)
else "align_%dcm.csv".format(cm)
val name = "align_%dcm%s.csv".format(cm, kind.suffix)
File(dir, name).bufferedWriter().use { w ->
cycles.forEachIndexed { ci, chans ->
chans.forEachIndexed { ch, buf ->
@@ -39,7 +39,7 @@ import java.util.Locale
* record = 한 위치의 한 cycle
* rowIndex : 0..N 연속 (labdb 규약)
* align_cm : 그 cycle 을 잰 위치
* phase : "sweep" | "confirm" ← 2026-09-18 추가
* phase : "sweep" | "confirm" | "lateral" ← 2026-09-18 추가 · lateral 2026-09-30
* cycle_idx : 그 위치 안에서의 순번
* channels : 6 × {ch, peak, peakIdx, data[~100]}
* sensor : {} 또는 {imu:{ax..gz}, imu_samples:[…], imu_sample_count}
@@ -84,7 +84,17 @@ object AlignLabdbPayload {
* 확인 측정 파일이 하나도 안 잡혔다.
*/
private val NAME_RE =
Regex("""^align_(-?\d+)cm(_confirm)?\.csv$""", RegexOption.IGNORE_CASE)
Regex("""^align_(-?\d+)cm(_confirm|_lateral)?\.csv$""", RegexOption.IGNORE_CASE)
/** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우)다. */
private fun phaseOf(suffix: String): String = when (suffix.lowercase()) {
"" -> "sweep"
"_confirm" -> "confirm"
else -> "lateral"
}
private fun phaseRank(suffix: String): Int = when (phaseOf(suffix)) {
"sweep" -> 0; "confirm" -> 1; else -> 2
}
private val isoOut: SimpleDateFormat
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
@@ -103,7 +113,7 @@ object AlignLabdbPayload {
?.sortedWith(
compareBy(
{ NAME_RE.find(it.name)!!.groupValues[1].toInt() },
{ if (NAME_RE.find(it.name)!!.groupValues[2].isEmpty()) 0 else 1 },
{ phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) },
)
)
?: return null
@@ -118,8 +128,9 @@ object AlignLabdbPayload {
for (f in files) {
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"
// 같은 align_cm 으로 여러 벌이 올라간다 — 이 값이 없으면 받는 쪽이 구분 못 한다.
// lateral 은 좌우 확인 완료 순간의 마지막 프레임 창(2026-09-30). cm 은 부착 위치.
val phase = phaseOf(m.groupValues[2])
// 같은 위치·같은 phase 의 IMU 형제 파일. 없으면 빈 맵.
val imuByCycle = readAlignImu(
File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv")
@@ -173,8 +184,12 @@ object AlignLabdbPayload {
put("save_name", saveName)
// 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("positions_measured", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" })
put("confirm_positions", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" })
// 좌우 확인 프레임 수(0 = 좌우를 확인하지 않았거나 옛 앱). 위치 수에는 안 더한다.
put("lateral_frames", (0 until records.length()).count {
records.getJSONObject(it).optString("phase") == "lateral"
})
put("cycles_total", records.length())
// 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라,
// 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다.
@@ -196,6 +196,12 @@ fun AnchorAlignView(appState: AppState) {
var lrCommit by remember { mutableStateOf<LateralGuide.Commit?>(null) }
var lrAccum by remember { mutableIntStateOf(0) }
var lrError by remember { mutableStateOf<String?>(null) }
/**
* 좌우 스트리밍의 **마지막 프레임 창** — [LateralGuide.required] 개(= 판정 1회에 쓰는 수).
* "좌우 확인 완료"를 누르는 순간 이것을 `align_{n}cm_lateral.csv` 로 남긴다(2026-09-30).
* 그 전에는 좌우 단계가 아무것도 저장하지 않았다. 파형과 그 프레임의 IMU 를 짝지어 둔다.
*/
val lrRecent = remember { ArrayDeque<Pair<List<DoubleArray>, List<com.medithings.vesiscan.ble.ImuSample>>>() }
/** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */
var lrConfirmed by remember { mutableStateOf(false) }
@@ -380,12 +386,13 @@ fun AnchorAlignView(appState: AppState) {
// 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정.
// 판정에 쓰인 파일을 덮지 않도록 별 이름으로 남긴다.
val isConfirm = !guide.searching && records.any { it.cm == cm }
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, confirm = isConfirm)) {
val kind = if (isConfirm) HospitalRunStore.AlignFileKind.CONFIRM else HospitalRunStore.AlignFileKind.SWEEP
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles, kind)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요."
}
// IMU 는 없을 수 있다(펌웨어·설정). 없다고 오류로 만들지 않는다 —
// 파형이 남았으면 측정은 성립한다.
HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, confirm = isConfirm)
HospitalRunStore.writeAlignImu(dir, cm, cyclesImu, kind)
// 판정과 **같은 mean-scan** 으로 BV 를 낸다. 따로 재면 그 위치의 지표와
// 용적이 다른 데이터에서 나온 값이 되어 대조가 성립하지 않는다.
@@ -457,8 +464,8 @@ fun AnchorAlignView(appState: AppState) {
val prev = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
// 좌우 정렬은 사람이 프로브를 움직이는 중이라 기울기가 제일 많이 바뀌는 때다.
// mtb 응답의 IMU 로 회차마다 카드를 갱신한다 — 저장은 하지 않는다(좌우는 원래
// 프레임을 저장하지 않는 단계다).
// mtb 응답의 IMU 로 회차마다 카드를 갱신하고, 마지막 프레임 창([lrRecent])에
// 파형과 짝지어 둔다 — "확인 완료" 순간의 것만 파일로 남는다.
val lrImu = ImuSidecar(bleManager)
lrImu.install()
try {
@@ -495,9 +502,8 @@ fun AnchorAlignView(appState: AppState) {
// 중이라 간헐적 미수신이 정상이다. 계속 돌리고 다음 프레임을 기다린다.
continue
}
lrImu.await()?.takeIf { it.isNotEmpty() }?.let {
liveTilt = ImuPostureClassifier.classify(it).pitchDeg
}
val imu = lrImu.await()?.takeIf { it.isNotEmpty() }
imu?.let { liveTilt = ImuPostureClassifier.classify(it).pitchDeg }
val sorted = got.sortedBy { it.channel }
liveChannels = sorted
detachWatcher.push(sorted.map { it.buffer })?.let { d -> detachSnapshot = d }
@@ -506,6 +512,9 @@ fun AnchorAlignView(appState: AppState) {
}
lateral.push(frame)?.let { lrCommit = it }
lrAccum = lateral.accumulated
// 판정 창과 같은 수만 남긴다 — 확인 완료 때 저장하는 것이 곧 마지막 판정의 근거다.
lrRecent.addLast(frame to (imu ?: emptyList()))
while (lrRecent.size > lateral.required) lrRecent.removeFirst()
delay(CYCLE_GAP_MS)
}
} finally {
@@ -847,12 +856,29 @@ fun AnchorAlignView(appState: AppState) {
// 다시 시작할 때는 옛 프레임을 버린다 — 이전 위치의 신호가
// 평균에 섞이면 첫 판정이 엉뚱한 방향을 가리킨다.
lateral.reset(); lrCommit = null; lrAccum = 0; lrConfirmed = false
lrRecent.clear()
lrRunning = true
}
},
onConfirm = {
lrRunning = false
lrConfirmed = true
// 확인 완료 순간의 마지막 프레임 창을 남긴다(2026-09-30 요청). 높이는
// 부착 위치(best + 오프셋)다 — 좌우는 그 높이에서 맞춘다. 화면을 떠날
// 때 정렬 업로드(601)에 phase=lateral 로 같이 올라간다.
if (lrRecent.isNotEmpty()) {
runCatching {
val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
val atCm = anchorTarget ?: cm
val frames = lrRecent.toList()
HospitalRunStore.writeAlignCycles(
dir, atCm, frames.map { it.first }, HospitalRunStore.AlignFileKind.LATERAL)
HospitalRunStore.writeAlignImu(
dir, atCm, frames.map { it.second }, HospitalRunStore.AlignFileKind.LATERAL)
hasAlignData = true
alignDirty = true
}
}
// 좌우 결과를 요약에 남긴다. 나중에 "이 세션은 좌우까지 맞춘
// 것인가"를 파일만 보고 답할 수 있어야 한다 — 재분석 때
// 좌우 미확인 세션을 가려낼 유일한 근거다.
@@ -950,7 +976,7 @@ fun AnchorAlignView(appState: AppState) {
}
runStartedAt = Date()
guide.reset(); cm = 0; last = null; records = emptyList(); error = null
lateral.reset(); lrCommit = null; lrAccum = 0
lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear()
lrConfirmed = false; lrError = null; lrRunning = false
disconnectedMidRun = false
decidedAtCm = null; sweepStopped = false; confirmDone = false
@@ -0,0 +1,66 @@
/*
* HospitalRunStoreAlignFilesTest — 정렬 파일의 종류별 이름과 형식을 잠근다.
*
* 2026-09-30: 좌우 확인 완료 순간의 프레임 창을 `_lateral` 로 남기기 시작했다. 파일명
* 규칙이 어긋나면 [AlignLabdbPayload] 가 못 잡거나(안 올라감) 다른 종류로 읽는다(phase 가
* 틀림). 여기서는 종류 → 파일명, 그리고 IMU 가 하나도 없을 때 파일을 만들지 않는 것을 본다.
*/
package com.medithings.vesiscan.services
import com.medithings.vesiscan.ble.ImuSample
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class HospitalRunStoreAlignFilesTest {
@get:Rule val tmp = TemporaryFolder()
private fun frames(n: Int): List<List<DoubleArray>> =
List(n) { List(6) { ch -> DoubleArray(4) { k -> (ch * 10 + k).toDouble() } } }
private fun sample(az: Float) = ImuSample(0.01f, 0.02f, az, 0.5f, 0.6f, 0.7f)
@Test fun `종류마다 파일명이 갈린다`() {
val d = tmp.newFolder("align")
assertTrue(HospitalRunStore.writeAlignCycles(d, 2, frames(1)))
assertTrue(HospitalRunStore.writeAlignCycles(d, 2, frames(1), HospitalRunStore.AlignFileKind.CONFIRM))
assertTrue(HospitalRunStore.writeAlignCycles(d, 3, frames(1), HospitalRunStore.AlignFileKind.LATERAL))
assertEquals(
listOf("align_2cm.csv", "align_2cm_confirm.csv", "align_3cm_lateral.csv"),
d.listFiles()!!.map { it.name }.sorted(),
)
}
@Test fun `좌우 프레임 창은 cycle,channel,s0… 으로 프레임마다 6줄이다`() {
val d = tmp.newFolder("align")
HospitalRunStore.writeAlignCycles(d, 3, frames(10), HospitalRunStore.AlignFileKind.LATERAL)
val lines = File(d, "align_3cm_lateral.csv").readLines()
assertEquals(60, lines.size)
assertEquals("0,0,0,1,2,3", lines[0])
assertEquals("9,5,50,51,52,53", lines[59])
}
@Test fun `좌우 IMU 는 _lateral_imu 로, 헤더 뒤에 프레임·샘플 순으로 간다`() {
val d = tmp.newFolder("align")
val imu = listOf(listOf(sample(0.90f), sample(0.91f)), listOf(sample(0.92f)))
assertTrue(HospitalRunStore.writeAlignImu(d, 3, imu, HospitalRunStore.AlignFileKind.LATERAL))
val lines = File(d, "align_3cm_lateral_imu.csv").readLines()
assertEquals("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps", lines[0])
assertEquals(4, lines.size)
assertTrue(lines[1].startsWith("0,0,"))
assertTrue(lines[3].startsWith("1,0,"))
}
@Test fun `IMU 가 하나도 없으면 파일을 만들지 않는다`() {
// 부재가 곧 "그때는 안 쟀다" 다 — 빈 파일이 있으면 받는 쪽이 0 샘플로 읽는다.
val d = tmp.newFolder("align")
assertFalse(HospitalRunStore.writeAlignImu(d, 3, listOf(emptyList(), emptyList()), HospitalRunStore.AlignFileKind.LATERAL))
assertFalse(File(d, "align_3cm_lateral_imu.csv").exists())
}
}
@@ -25,6 +25,8 @@ class AlignLabdbPayloadTest {
dir: File, cm: Int, cycles: Int, chans: Int = 6, len: Int = 100,
/** 고른 위치를 다시 잰 확인 측정 — `align_{cm}cm_confirm.csv` 로 따로 쓰인다. */
confirm: Boolean = false,
/** 좌우 확인 완료 순간의 프레임 창 — `align_{cm}cm_lateral.csv`. */
lateral: Boolean = false,
) {
val sb = StringBuilder()
for (ci in 0 until cycles) for (ch in 0 until chans) {
@@ -32,7 +34,11 @@ class AlignLabdbPayloadTest {
for (i in 0 until len) sb.append(',').append(if (i == 30 + ch) 3500 else 900)
sb.append('\n')
}
val name = if (confirm) "align_${cm}cm_confirm.csv" else "align_${cm}cm.csv"
val name = when {
lateral -> "align_${cm}cm_lateral.csv"
confirm -> "align_${cm}cm_confirm.csv"
else -> "align_${cm}cm.csv"
}
File(dir, name).writeText(sb.toString(), Charsets.UTF_8)
}
@@ -52,13 +58,19 @@ class AlignLabdbPayloadTest {
}
/** `cycle_idx,sample_idx,ax_g,...` — HospitalRunStore.writeAlignImu 형식. */
private fun writeImu(dir: File, cm: Int, cycles: Int, perCycle: Int = 3, confirm: Boolean = false) {
private fun writeImu(
dir: File, cm: Int, cycles: Int, perCycle: Int = 3, confirm: Boolean = false, lateral: Boolean = false,
) {
val sb = StringBuilder("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n")
// az 를 샘플마다 다르게 둔다 — "마지막 샘플" 이 진짜 마지막인지 봐야 한다.
for (ci in 0 until cycles) for (si in 0 until perCycle) {
sb.append("$ci,$si,0.01,0.02,${0.90 + 0.01 * si},0.5,0.6,0.7\n")
}
val name = if (confirm) "align_${cm}cm_confirm_imu.csv" else "align_${cm}cm_imu.csv"
val name = when {
lateral -> "align_${cm}cm_lateral_imu.csv"
confirm -> "align_${cm}cm_confirm_imu.csv"
else -> "align_${cm}cm_imu.csv"
}
File(dir, name).writeText(sb.toString(), Charsets.UTF_8)
}
@@ -123,6 +135,40 @@ class AlignLabdbPayloadTest {
assertEquals((0 until 3).toList(), (0 until 3).map { recs.getJSONObject(it).getInt("rowIndex") })
}
@Test fun `좌우 확인 프레임은 phase lateral 로 올라가고 위치 수에 더해지지 않는다`() {
// 2026-09-30: "좌우 확인 완료" 순간의 마지막 프레임 창을 부착 위치 cm 으로 남긴다.
// 좌우는 그동안 아무것도 저장하지 않아 맞췄다는 근거가 파일에 없었다.
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1)
writeCycles(d, 2, cycles = 1)
writeCycles(d, 2, cycles = 1, confirm = true)
writeCycles(d, 3, cycles = 10, lateral = true) // 부착 위치 3cm 에서 좌우 10프레임
writeImu(d, 3, cycles = 10, perCycle = 2, lateral = true)
val p = AlignLabdbPayload.build(d)!!
val recs = p.getJSONArray("records")
assertEquals(13, p.getInt("recordCount"))
assertEquals(
listOf("sweep", "sweep", "confirm") + List(10) { "lateral" },
(0 until 13).map { recs.getJSONObject(it).getString("phase") },
)
assertEquals(3, recs.getJSONObject(12).getInt("align_cm"))
assertEquals((0 until 13).toList(), (0 until 13).map { recs.getJSONObject(it).getInt("rowIndex") })
// 좌우 프레임의 IMU 도 같이 간다.
assertEquals(2, recs.getJSONObject(3).getJSONObject("sensor").getInt("imu_sample_count"))
val params = p.getJSONObject("params")
assertEquals(2, params.getInt("positions_measured"))
assertEquals(1, params.getInt("confirm_positions"))
assertEquals(10, params.getInt("lateral_frames"))
}
@Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() {
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1)
assertEquals(0, AlignLabdbPayload.build(d)!!.getJSONObject("params").getInt("lateral_frames"))
}
@Test fun `확인 측정은 위치 수에 더해지지 않는다`() {
// files.size 를 그대로 쓰면 "3군데 쟀다"가 4가 된다.
val d = tmp.newFolder("align")
@@ -127,6 +127,16 @@ class HospitalAlignPendingTest {
assertFalse(combos[0].name.endsWith("_imu.csv"))
}
/** 좌우 확인 프레임(`_lateral`)만 있는 폴더도 raw 가 아니다 — 탐색 없이 좌우만 남을 수는 없지만, 규칙은 같다. */
@Test
fun `좌우 프레임만 있으면 대상이 아니다`() {
val run = runDir("2026-09-30_lat")
val d = File(run, "align").apply { mkdirs() }
File(d, "align_3cm_lateral.csv").writeText("0,0,900\n", Charsets.UTF_8)
assertNull(HospitalLabdbUploader.pendingAlign(run))
}
/** 조합 CSV 쪽 대상 선정이 `align/` 때문에 흔들리지 않아야 한다. */
@Test
fun `조합 CSV 목록에 정렬 폴더가 섞이지 않는다`() {