fix(labdb): 정렬 업로드에 같은 폴더의 앞 세션 파일이 섞이지 않게 한다 — 요약 attempts 기준
정렬 폴더가 환자·날짜·자세로만 갈려서 같은 날 같은 환자를 다시 정렬하면 앞 세션의
align_{n}cm.csv 가 그대로 남고, 페이로드는 폴더의 파일을 전부 담았다. 2026-10-15 팬텀
세션에서 0cm 만 쟀는데 1~4cm 80 record 가 같은 testId 로 올라갔다 — 요약(positions·
swept_to_cm)은 0cm 하나인데 records 는 5위치였다.
- AlignLabdbPayload.isFromThisRun: 요약의 attempts(위치 → 이 세션에서 잰 횟수)로 가른다.
탐색·확인은 cm ∈ attempts 이고 _rK 의 K ≤ attempts[cm]. 좌우·재부착은 잰 자리 또는
anchor_cm/proceed_anchor_cm. standing 확인은 폴더 기준으로 번호를 매겨 덮어쓰지 않으므로
거르지 않는다. attempts 가 없는 요약(옛 앱)은 종전대로 전부 담는다.
- params.stale_files_skipped: 걸러낸 파일 수. 0 이 정상.
- 문서: 그 전 앱으로 올린 601 세션은 positions_measured 와 params.positions 길이가 다르면
혼입 의심 — align_cm ∉ positions[].align_cm 인 record 를 빼고 본다.
폰 폴더 자체가 세션을 섞는 것(새 0cm 이 옛 0cm 을 덮어씀)은 이 커밋이 다루지 않는다.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,9 @@ import java.util.Locale
|
||||
* 위치별 raw cycle 전부 + 판정 근거(`align_result.json`)를 함께 넣는다. 개발자가
|
||||
* 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다.
|
||||
*
|
||||
* 단, **이 세션의 파일만**이다. 폴더가 환자·날짜·자세로만 갈려서 같은 날 다시 정렬하면 앞
|
||||
* 세션의 파일이 남아 있다 — 요약의 `attempts` 로 가른다([isFromThisRun], 2026-10-15).
|
||||
*
|
||||
* ```
|
||||
* record = 한 위치의 한 cycle
|
||||
* rowIndex : 0..N 연속 (labdb 규약)
|
||||
@@ -119,24 +122,28 @@ object AlignLabdbPayload {
|
||||
* @return 올릴 페이로드. 읽을 cycle 이 없으면 null.
|
||||
*/
|
||||
fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? {
|
||||
// cm 오름차순, 같은 cm 이면 sweep 먼저 · confirm 나중. 받는 쪽이 파일 순서를
|
||||
// 그대로 시간 순서로 읽어도 어긋나지 않게 한다(확인 측정이 나중에 일어난다).
|
||||
val files = alignDir.listFiles()
|
||||
val summary = File(alignDir, "align_result.json")
|
||||
.takeIf { it.exists() }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
|
||||
val named = alignDir.listFiles()
|
||||
?.filter { it.isFile && NAME_RE.matches(it.name) }
|
||||
?.sortedWith(
|
||||
?: return null
|
||||
// 이 정렬 세션의 파일만 — 폴더에는 같은 날 앞서 한 정렬의 파일이 남아 있을 수 있다
|
||||
// ([isFromThisRun]). cm 오름차순, 같은 cm 이면 sweep 먼저 · confirm 나중. 받는 쪽이
|
||||
// 파일 순서를 그대로 시간 순서로 읽어도 어긋나지 않게 한다(확인 측정이 나중에 일어난다).
|
||||
val files = named
|
||||
.filter { isFromThisRun(NAME_RE.find(it.name)!!, summary) }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ NAME_RE.find(it.name)!!.groupValues[1].toInt() },
|
||||
{ phaseRank(NAME_RE.find(it.name)!!.groupValues[2]) },
|
||||
{ attemptOf(NAME_RE.find(it.name)!!) },
|
||||
)
|
||||
)
|
||||
?: return null
|
||||
val staleSkipped = named.size - files.size
|
||||
if (files.isEmpty()) return null
|
||||
|
||||
val summary = File(alignDir, "align_result.json")
|
||||
.takeIf { it.exists() }
|
||||
?.let { runCatching { JSONObject(it.readText()) }.getOrNull() }
|
||||
|
||||
val records = JSONArray()
|
||||
var row = 0
|
||||
for (f in files) {
|
||||
@@ -242,6 +249,9 @@ object AlignLabdbPayload {
|
||||
put("reattach_frames", countPhase("reattach"))
|
||||
put("standing_confirm_frames", countPhase("standing_confirm"))
|
||||
put("cycles_total", records.length())
|
||||
// 폴더에 있었지만 이 세션 것이 아니라 뺀 파일 수(2026-10-15). 0 이 정상이다. 0 이
|
||||
// 아니면 같은 날 같은 환자를 다시 정렬한 폴더다 — 폰에 남은 raw 는 세션이 섞여 있다.
|
||||
put("stale_files_skipped", staleSkipped)
|
||||
// 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라,
|
||||
// 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다.
|
||||
//
|
||||
@@ -276,6 +286,42 @@ object AlignLabdbPayload {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 파일이 `align_result.json` 이 말하는 정렬 세션의 것인가.
|
||||
*
|
||||
* 정렬 폴더는 환자·날짜·자세로만 갈린다([HospitalRunStore.alignDir]) — 시작 시각이 키에 없다.
|
||||
* 같은 날 같은 환자를 다시 정렬하면 새 세션은 **잰 위치의 파일만** 덮어쓰고 나머지는 앞 세션
|
||||
* 것이 그대로 남는다. 2026-10-15 팬텀 세션에서 0cm 만 쟀는데 앞 세션의 1~4cm 80 record 가
|
||||
* 같은 testId 로 올라갔다 — 요약(`positions`·`swept_to_cm`)은 0cm 하나인데 records 는 5위치였다.
|
||||
*
|
||||
* 요약의 `attempts`(위치 → 이 세션에서 잰 횟수. 측정마다 요약을 새로 쓰고, 세션을 다시
|
||||
* 시작하면 비운다)가 이 세션이 실제로 쓴 파일의 목록이다:
|
||||
* - 탐색·확인 `align_{n}cm[_confirm][_rK].csv`: n 이 attempts 에 있고 K ≤ attempts[n].
|
||||
* 앞 세션이 같은 자리를 더 많이 재서 남긴 `_r3` 은 K 가 커서 걸러진다. (같은 자리의 탐색과
|
||||
* 확인은 한 카운터를 쓴다 — 탐색 1, 확인 2 → `_confirm_r2`.)
|
||||
* - 좌우·좌우 흐름·재부착: 부착 위치에서 일어나고 번호가 없다 — attempts 의 위치이거나 요약의
|
||||
* `anchor_cm`/`proceed_anchor_cm` 이면 이 세션 것으로 본다. 앞 세션과 부착 위치가 같으면
|
||||
* 가를 근거가 요약에 없어 그대로 담는다(덜 보내는 쪽이 위험).
|
||||
* - standing 확인: 정렬 **앞**에 일어나고 번호를 폴더의 파일 수로 매겨 덮어쓰지 않으므로
|
||||
* ([StandingConfirmCard]) 전부 그날의 실데이터다 — 거르지 않는다.
|
||||
*
|
||||
* 요약에 `attempts` 가 없으면(옛 앱 폴더, 또는 이 세션이 아직 아무 위치도 안 잰 상태) 종전대로
|
||||
* 전부 담는다 — 진단용 페이로드라 근거 없이는 거르지 않는다.
|
||||
*/
|
||||
internal fun isFromThisRun(m: MatchResult, summary: JSONObject?): Boolean {
|
||||
val attempts = summary?.optJSONObject("attempts") ?: return true
|
||||
val cm = m.groupValues[1].toInt()
|
||||
val key = cm.toString()
|
||||
val measuredHere = attempts.has(key)
|
||||
return when (phaseOf(m.groupValues[2])) {
|
||||
"sweep", "confirm" -> measuredHere && attemptOf(m) <= attempts.optInt(key, 0)
|
||||
"standing_confirm" -> true
|
||||
else -> measuredHere ||
|
||||
cm == summary.optInt("anchor_cm", Int.MIN_VALUE) ||
|
||||
cm == summary.optInt("proceed_anchor_cm", Int.MIN_VALUE)
|
||||
}
|
||||
}
|
||||
|
||||
/** `yyyy-MM-dd_…` — [HospitalRunStore.runDir] 이 만드는 환자·날짜 폴더 이름. */
|
||||
private val RUN_DIR_RE = Regex("""^\d{4}-\d{2}-\d{2}_.+""")
|
||||
|
||||
|
||||
@@ -379,4 +379,101 @@ class AlignLabdbPayloadTest {
|
||||
assertTrue(Regex("^[A-Za-z0-9_-]{1,40}$").matches(long))
|
||||
assertEquals("260908_P001_align", AlignLabdbPayload.testId("260908_P001_align"))
|
||||
}
|
||||
|
||||
// ── 같은 폴더에 남은 앞 세션 파일 (2026-10-15) ──────────────────────────────
|
||||
|
||||
/** 요약에 `attempts`(이 세션이 잰 위치 → 횟수)가 있는 폴더. */
|
||||
private fun summaryWithAttempts(vararg cmToN: Pair<Int, Int>, anchorCm: Int? = null): JSONObject =
|
||||
summary().apply {
|
||||
put("attempts", JSONObject().apply { cmToN.forEach { (c, n) -> put(c.toString(), n) } })
|
||||
put("anchor_cm", anchorCm ?: JSONObject.NULL)
|
||||
put("started_at", "2026-10-15T14:56:05.122+09:00")
|
||||
}
|
||||
|
||||
@Test fun `요약의 attempts 에 없는 위치 파일은 앞 세션 것이라 올리지 않는다`() {
|
||||
// 2026-10-15 팬텀: 0cm 만 쟀는데 100 record(0~4cm × 20)가 올라갔다. 폴더가 환자·날짜·자세로만
|
||||
// 갈려서 같은 날 앞서 한 정렬의 1~4cm 파일이 남아 있었고, 페이로드는 폴더의 파일을 전부 담았다.
|
||||
// 요약(positions·swept_to_cm)은 0cm 하나 — 요약과 records 가 서로 모순인 세션이 됐다.
|
||||
val d = tmp.newFolder("align_supine")
|
||||
for (cm in 0..4) writeCycles(d, cm, cycles = 20)
|
||||
File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8)
|
||||
|
||||
val p = AlignLabdbPayload.build(d)!!
|
||||
assertEquals(20, p.getInt("recordCount"))
|
||||
val recs = p.getJSONArray("records")
|
||||
assertTrue((0 until 20).all { recs.getJSONObject(it).getInt("align_cm") == 0 })
|
||||
val params = p.getJSONObject("params")
|
||||
assertEquals(1, params.getInt("positions_measured"))
|
||||
// 걸러낸 파일 수를 남긴다 — 폴더에 앞 세션 것이 섞여 있었다는 사실 자체가 진단 정보다.
|
||||
assertEquals(4, params.getInt("stale_files_skipped"))
|
||||
}
|
||||
|
||||
@Test fun `attempts 횟수보다 큰 재측정 파일도 앞 세션 것이다`() {
|
||||
// 앞 세션이 3cm 를 두 번 재서 _r2 가 남았고, 이 세션은 3cm 를 한 번만 쟀다(align_3cm.csv 는 덮어씀).
|
||||
val d = tmp.newFolder("align_supine")
|
||||
writeCycles(d, 3, cycles = 1)
|
||||
File(d, "align_3cm_r2.csv").writeText((0 until 6).joinToString("") { ch -> "0,$ch,900,900,3500,900\n" }, Charsets.UTF_8)
|
||||
File(d, "align_result.json").writeText(summaryWithAttempts(3 to 1).toString(), Charsets.UTF_8)
|
||||
|
||||
val p = AlignLabdbPayload.build(d)!!
|
||||
assertEquals(1, p.getInt("recordCount"))
|
||||
assertEquals(1, p.getJSONArray("records").getJSONObject(0).getInt("attempt"))
|
||||
assertEquals(1, p.getJSONObject("params").getInt("stale_files_skipped"))
|
||||
|
||||
// 이 세션이 두 번 쟀으면(attempts 3→2) 둘 다 이 세션 것이다.
|
||||
File(d, "align_result.json").writeText(summaryWithAttempts(3 to 2).toString(), Charsets.UTF_8)
|
||||
assertEquals(2, AlignLabdbPayload.build(d)!!.getInt("recordCount"))
|
||||
}
|
||||
|
||||
@Test fun `요약에 attempts 가 없으면 종전대로 전부 담는다`() {
|
||||
// 옛 앱이 만든 폴더(요약에 attempts 없음)와 요약 자체가 없는 폴더. 근거 없이 거르면
|
||||
// 진짜 데이터가 빠진다 — 진단용 페이로드는 덜 보내는 쪽이 위험하다.
|
||||
val d = tmp.newFolder("align")
|
||||
for (cm in 0..2) writeCycles(d, cm, cycles = 1)
|
||||
File(d, "align_result.json").writeText(summary().toString(), Charsets.UTF_8) // attempts 없음
|
||||
val p = AlignLabdbPayload.build(d)!!
|
||||
assertEquals(3, p.getInt("recordCount"))
|
||||
assertEquals(0, p.getJSONObject("params").getInt("stale_files_skipped"))
|
||||
|
||||
val d2 = tmp.newFolder("align-nosummary")
|
||||
for (cm in 0..2) writeCycles(d2, cm, cycles = 1)
|
||||
assertEquals(3, AlignLabdbPayload.build(d2)!!.getInt("recordCount"))
|
||||
}
|
||||
|
||||
@Test fun `좌우·재부착은 부착 위치로 가르고 standing 확인은 거르지 않는다`() {
|
||||
// 좌우·재부착은 attempts 로 번호를 매기지 않는다 — 이 세션의 부착 위치(잰 자리 또는 anchor_cm)에
|
||||
// 있는 것만 이 세션 것이다. standing 확인은 정렬 앞에 일어나고 폴더 기준으로 번호를 매겨
|
||||
// 덮어쓰지 않으므로 전부 그날의 실데이터다.
|
||||
val d = tmp.newFolder("align_standing")
|
||||
writeCycles(d, 0, cycles = 1)
|
||||
writeCycles(d, 1, cycles = 1)
|
||||
writeCycles(d, 1, cycles = 2, lateral = true) // 이 세션 부착 위치 1cm
|
||||
writeCycles(d, 3, cycles = 2, lateral = true) // 앞 세션 부착 위치 3cm — 옛 것
|
||||
File(d, "align_3cm_reattach.csv").writeText("0,0,900,900,3500,900\n", Charsets.UTF_8) // 옛 것
|
||||
File(d, "align_2cm_standing_confirm.csv").writeText("0,0,900,900,3500,900\n", Charsets.UTF_8)
|
||||
File(d, "align_2cm_standing_confirm_r2.csv").writeText("0,0,900,900,3500,900\n", Charsets.UTF_8)
|
||||
File(d, "align_result.json").writeText(
|
||||
summaryWithAttempts(0 to 1, 1 to 1, anchorCm = 1).toString(), Charsets.UTF_8)
|
||||
|
||||
val p = AlignLabdbPayload.build(d)!!
|
||||
val recs = p.getJSONArray("records")
|
||||
val got = (0 until recs.length()).map {
|
||||
recs.getJSONObject(it).getInt("align_cm") to recs.getJSONObject(it).getString("phase")
|
||||
}
|
||||
assertEquals(
|
||||
listOf(0 to "sweep", 1 to "sweep", 1 to "lateral", 1 to "lateral",
|
||||
2 to "standing_confirm", 2 to "standing_confirm"),
|
||||
got,
|
||||
)
|
||||
assertEquals(2, p.getJSONObject("params").getInt("stale_files_skipped"))
|
||||
}
|
||||
|
||||
@Test fun `모든 파일이 앞 세션 것이면 올릴 것이 없다`() {
|
||||
// 측정마다 요약을 쓰므로 보통은 안 생기지만, 파일 저장이 실패한 세션에서는 생긴다 —
|
||||
// 앞 세션 파일로 "성공" 업로드를 만들면 안 된다.
|
||||
val d = tmp.newFolder("align_supine")
|
||||
writeCycles(d, 2, cycles = 1)
|
||||
File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8)
|
||||
assertNull(AlignLabdbPayload.build(d))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
|
||||
| `protocol` | string | `"hospital_align_2026"` 고정 |
|
||||
| `subject` | string | 환자 식별자 |
|
||||
| `save_name` | string | 저장 폴더명 |
|
||||
| `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만) |
|
||||
| `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만). 2026-10-15 부터 **이 세션이 잰 위치만** — 그 전 세션은 같은 날 앞서 한 정렬의 위치가 섞여 `params.positions` 보다 클 수 있습니다 |
|
||||
| `confirm_positions` | int | 확인 측정한 위치 수 |
|
||||
| `lateral_frames` | int | 좌우 확인 완료 순간에 남긴 프레임 수 (2026-09-30~). 0 = 좌우 미확인 또는 옛 앱 |
|
||||
| `lateral_stream_frames` | int | 좌우 정렬 동안 흘린 프레임 전부 (2026-09-30~) |
|
||||
@@ -182,6 +182,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
|
||||
| `confirm_tilt_deg_mean` | number | 확인 측정(N cm, 20 cycle)의 IMU 평균 기울기(도, 0~180). Supine 의 값이 Sitting 요구 범위의 기준각 m 입니다 — Sitting 범위 = `35+m ~ 55+m` (2026-10-01~) |
|
||||
| `reattach` | object | 재부착 확인 판정 `{ok, missing[], detached[], channels[4], ref_channels[4]\|null, bv_ml, at}` — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지 |
|
||||
| `cycles_total` | int | = `recordCount` |
|
||||
| `stale_files_skipped` | int | 폴더에 있었지만 앞 세션 것이라 뺀 파일 수 (2026-10-15~). 0 이 정상. 정렬 폴더는 환자·날짜·자세로만 갈려 같은 날 다시 정렬하면 앞 세션 파일이 남는데, 요약의 `attempts` 에 없는 위치·횟수의 파일은 담지 않습니다 |
|
||||
| `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 |
|
||||
| `device`, `firmware_version`, `hw_preset` | string | |
|
||||
| `freq_mhz`, `freq_option`, `probe_cycles` | number | 정렬 전용 고정 조건 (2.3MHz · cycle 3) |
|
||||
@@ -279,6 +280,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
|
||||
| 기존 적재분 | `901` 로 올렸던 **72세션 · 1,440레코드**(2026-09-04)는 서버에서 `600` 으로 마이그레이션 완료 |
|
||||
| `902` 정렬 1세션 | 같은 `testId` 로 `601` 재업로드하여 갱신 |
|
||||
| 앱 반영 | 상수 2개 변경 + 최상위 `subject` 추가 |
|
||||
| 앞 세션 파일 혼입 | 2026-10-15 · 같은 날 같은 환자를 다시 정렬하면 폴더에 남은 앞 세션의 `align_{n}cm.csv` 가 새 세션에 같이 올라갔다(0cm 만 잰 팬텀 세션에 100 record). 이후 앱은 요약 `attempts` 기준으로 거른다 — **그 전 앱으로 올린 601 세션은 `positions_measured` 와 `params.positions` 길이가 다르면 혼입 의심**, 서버에서 `align_cm ∉ positions[].align_cm` 인 record 를 빼고 봐야 합니다 |
|
||||
|
||||
> 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의
|
||||
> 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다.
|
||||
|
||||
Reference in New Issue
Block a user