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:
@@ -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 목록에 정렬 폴더가 섞이지 않는다`() {
|
||||
|
||||
Reference in New Issue
Block a user