From 4fc40219f2ca6ed4569c257b673bf772bc288e00 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 29 Sep 2026 09:26:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(labdb):=20600=C2=B7601=20=EC=9D=98=20sensor?= =?UTF-8?q?.imu=20=EB=A5=BC=20labdb=20=ED=91=9C=EC=A4=80=20=EB=AA=A8?= =?UTF-8?q?=EC=96=91(=EC=83=98=ED=94=8C=20=ED=95=98=EB=82=98)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20CSV=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20IMU=20=EA=B0=80=20=EB=B9=84=EC=96=B4=20?= =?UTF-8?q?=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit labdb 에서 CSV 로 받으면 ax..gz 열이 빈칸이고 raw JSON 에는 IMU 가 있다(2026-09-29 보고). 사이트의 파싱 문제가 아니다 — labdb 표준(labdb.md "000" 레코드)은 `sensor.imu` 가 샘플 하나 `{ax,ay,az,gx,gy,gz}` 이고 CSV 내보내기는 그 여섯 값을 편다. 병원 임상(600)·정렬(601)은 2026-09-21 부터 거기에 회차 샘플 전부(배열)를 넣고 있었다. 사내 임상(001)은 처음부터 표준대로 보내고 있었다. · LabdbSensor.of(samples): imu = 마지막 샘플 하나(표준·CSV 에 나옴), imu_samples = 전부(FIFO 순), imu_sample_count. 없으면 빈 객체(0 으로 안 채움). 001 의 LabdbUploader·tools/labdb_upload.py 와 같은 모양. · HospitalLabdbPayload·AlignLabdbPayload 가 이것을 쓴다. · tools/align_analyze.py 는 imu_samples 를 우선 읽고, 09-21~28 분(imu 가 배열)도 그대로 받는다. · docs/LABDB_DATATYPES.md §IMU 를 새 모양으로. 09-21~28 업로드분은 CSV 에서 IMU 가 비는 이유와, 내보내기에서 배열이면 마지막 원소를 쓰는 선택 사항을 적었다. 시험: LabdbSensorTest 3건 신규, Align/Hospital 페이로드 시험을 새 모양으로 (services.labdb 38건 통과). 전체 168건 중 실패 3건은 옛 세션 임시 경로에 쓰는 덤프 시험(Cm3PerTraceDump·GoldenDump·PrecisionDump)으로 이 변경과 무관하다. 병원 폰(SM-A155N)에 설치 완료. Co-Authored-By: Claude Fable 5.1 --- .../services/labdb/AlignLabdbPayload.kt | 12 ++--- .../services/labdb/HospitalLabdbPayload.kt | 18 ++++---- .../vesiscan/services/labdb/LabdbSensor.kt | 46 +++++++++++++++++++ .../services/labdb/AlignLabdbPayloadTest.kt | 20 ++++---- .../labdb/HospitalLabdbPayloadTest.kt | 17 +++++++ .../services/labdb/LabdbSensorTest.kt | 45 ++++++++++++++++++ docs/LABDB_DATATYPES.md | 21 +++++++-- tools/align_analyze.py | 4 +- 8 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbSensor.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/services/labdb/LabdbSensorTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt index d4d3044..91f326d 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -42,12 +42,14 @@ import java.util.Locale * phase : "sweep" | "confirm" ← 2026-09-18 추가 * cycle_idx : 그 위치 안에서의 순번 * channels : 6 × {ch, peak, peakIdx, data[~100]} - * sensor : {} 또는 {imu: [{ax,ay,az,gx,gy,gz}, …]} ← 2026-09-21 추가 + * sensor : {} 또는 {imu:{ax..gz}, imu_samples:[…], imu_sample_count} + * ← 2026-09-21 추가 · 2026-09-29 표준 모양([LabdbSensor]) * ``` * * ## IMU - * `align_{n}cm[_confirm]_imu.csv` 가 있으면 cycle 별로 `sensor.imu` 에 싣는다. 없으면 - * `sensor` 는 빈 객체다 — **부재가 곧 "그때는 안 쟀다"** 이므로 0 으로 채우지 않는다. + * `align_{n}cm[_confirm]_imu.csv` 가 있으면 cycle 별로 `sensor` 에 싣는다([LabdbSensor.of] + * — `imu` 는 마지막 샘플 하나, `imu_samples` 는 전부). 없으면 `sensor` 는 빈 객체다 — + * **부재가 곧 "그때는 안 쟀다"** 이므로 0 으로 채우지 않는다. * 정렬은 사람이 프로브를 옮겨 가며 재는 과정이라, 어느 위치의 파형이 이상할 때 그것이 * 자리 탓인지 그 순간 흔들린 탓인지 IMU 없이는 가를 수 없다. * @@ -152,9 +154,7 @@ object AlignLabdbPayload { put("align_cm", cm) put("phase", phase) put("cycle_idx", ci) - put("sensor", JSONObject().apply { - imuByCycle[ci]?.let { put("imu", it) } - }) + put("sensor", LabdbSensor.of(imuByCycle[ci])) put("commandType", "MTB") put("channels", chans) }) diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt index 404a84a..2eb104c 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt @@ -40,10 +40,14 @@ import java.util.TimeZone * 배터리·온도는 병원 CSV 에 열이 없다. `sensor` 에 넣지 않는다 — 0 이나 null 로 채우면 * 나중에 "배터리가 0 이었다"로 읽힌다. * - * ## IMU (2026-09-21 추가) - * `*_imu.csv` 형제 파일이 있으면 `sensor.imu` 로 싣는다. 없으면 `sensor` 는 종전대로 - * 빈 객체다 — **이 필드의 부재가 곧 "그때는 안 쟀다"** 라서, 없는 회차를 0 으로 채우면 - * 안 된다. 예전 업로드분에는 애초에 파일이 없다. + * ## IMU (2026-09-21 추가 · 2026-09-29 표준 모양) + * `*_imu.csv` 형제 파일이 있으면 회차별로 `sensor` 에 싣는다([LabdbSensor.of] — `imu` 는 + * 마지막 샘플 하나, `imu_samples` 는 전부). 없으면 `sensor` 는 종전대로 빈 객체다 — + * **이 필드의 부재가 곧 "그때는 안 쟀다"** 라서, 없는 회차를 0 으로 채우면 안 된다. + * 예전 업로드분에는 애초에 파일이 없다. + * + * 09-21~09-28 업로드분은 `sensor.imu` 가 **배열**이었다. labdb 표준은 샘플 하나라 CSV + * 내보내기의 `ax..gz` 가 비어 나왔다 — raw JSON 에는 다 있다. * * 같은 자세로 20회 반복하는 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지 * 알고리즘 문제인지 가르는 근거가 IMU 다. @@ -133,10 +137,8 @@ object HospitalLabdbPayload { put("rowIndex", rep) put("datetime", iso(times[rep])) put("commandType", "MTB") - // IMU 가 있으면 `sensor.imu`, 없으면 빈 객체(= 안 쟀다). - put("sensor", JSONObject().apply { - imuByRepeat[rep]?.let { put("imu", it) } - }) + // IMU 가 있으면 표준 모양(imu · imu_samples), 없으면 빈 객체(= 안 쟀다). + put("sensor", LabdbSensor.of(imuByRepeat[rep])) put("channels", chans) }) } diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbSensor.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbSensor.kt new file mode 100644 index 0000000..698ce2d --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbSensor.kt @@ -0,0 +1,46 @@ +/* + * LabdbSensor — labdb 레코드의 `sensor` 필드를 **표준 모양**으로 만든다. + * + * labdb 표준(labdb.md `"000"` 레코드 · §5-6 CSV 내보내기)은 `sensor.imu` 가 **샘플 하나** + * `{ax,ay,az,gx,gy,gz}` 다. CSV 내보내기는 그 여섯 값을 `ax..gz` 열로 편다 — 레코드당 + * 값 하나라 배열은 펼 수 없다. + * + * 병원 임상(600)·정렬(601)은 2026-09-21 부터 `sensor.imu` 에 **회차 샘플 전부(배열)** 를 + * 넣고 있었다. 서버는 JSON 을 그대로 저장하니 raw 내보내기에는 다 보였지만, CSV 의 + * `ax..gz` 는 빈칸이었다(2026-09-29 발견 — 분석팀이 CSV 로 받으면 IMU 가 없다). + * 사이트의 파싱 잘못이 아니라 우리가 표준을 어긴 것이다. + * + * 그래서 사내 임상(001, [LabdbUploader.cycleToRecord]·`tools/labdb_upload.py`)이 처음부터 + * 쓰던 모양으로 셋을 맞춘다: + * + * imu : 마지막(가장 최근 = piezo 캡처 직전) 샘플 하나 — 표준. CSV 에 나온다. + * imu_samples : 회차 샘플 전부(FIFO 순). raw JSON 에서 그대로. 기울기 재계산은 이것으로. + * imu_sample_count : 그 개수. + * + * 샘플이 없으면 **빈 객체** — 부재가 곧 "그때는 안 쟀다" 다. 0 으로 채우지 않는다. + */ +package com.medithings.vesiscan.services.labdb + +import org.json.JSONArray +import org.json.JSONObject + +object LabdbSensor { + + private val AXES = listOf("ax", "ay", "az", "gx", "gy", "gz") + + /** + * @param samples `[{ax,ay,az,gx,gy,gz}, …]` FIFO 순(마지막이 최신). null·빈 배열이면 빈 객체. + */ + fun of(samples: JSONArray?): JSONObject { + val out = JSONObject() + if (samples == null || samples.length() == 0) return out + val latest = samples.optJSONObject(samples.length() - 1) ?: return out + out.put("imu", JSONObject().apply { + // 없는 축은 빼 둔다 — 0 을 넣으면 "0 이었다"가 된다. + for (k in AXES) if (latest.has(k)) put(k, latest.getDouble(k)) + }) + out.put("imu_samples", samples) + out.put("imu_sample_count", samples.length()) + return out + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt index fcfd505..1e8e112 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt @@ -54,24 +54,28 @@ 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) { 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.98,0.5,0.6,0.7\n") + 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" File(dir, name).writeText(sb.toString(), Charsets.UTF_8) } - @Test fun `IMU 형제 파일이 있으면 sensor imu 로 실린다`() { + @Test fun `IMU 형제 파일이 있으면 sensor 에 표준 모양으로 실린다`() { + // 2026-09-29: labdb 표준은 `imu` 가 샘플 하나다(CSV 내보내기가 그것을 ax..gz 로 편다). + // 배열을 넣던 동안 CSV 의 IMU 열이 비어 있었다. 전체는 `imu_samples` 에. val d = tmp.newFolder("align") writeCycles(d, 0, cycles = 2) writeImu(d, 0, cycles = 2, perCycle = 3) val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records") - val imu0 = recs.getJSONObject(0).getJSONObject("sensor").getJSONArray("imu") - assertEquals(3, imu0.length()) - assertEquals(0.98, imu0.getJSONObject(0).getDouble("az"), 1e-6) + val s0 = recs.getJSONObject(0).getJSONObject("sensor") + assertEquals(0.92, s0.getJSONObject("imu").getDouble("az"), 1e-6) // 마지막(si=2) 샘플 + assertEquals(3, s0.getJSONArray("imu_samples").length()) + assertEquals(3, s0.getInt("imu_sample_count")) // cycle 별로 갈라져야 한다 — 한 위치의 모든 샘플이 한 회차에 몰리면 안 된다. - assertEquals(3, recs.getJSONObject(1).getJSONObject("sensor").getJSONArray("imu").length()) + assertEquals(3, recs.getJSONObject(1).getJSONObject("sensor").getJSONArray("imu_samples").length()) } @Test fun `IMU 가 없으면 sensor 는 빈 객체다`() { @@ -91,9 +95,9 @@ class AlignLabdbPayloadTest { val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records") assertEquals("sweep", recs.getJSONObject(0).getString("phase")) - assertEquals(2, recs.getJSONObject(0).getJSONObject("sensor").getJSONArray("imu").length()) + assertEquals(2, recs.getJSONObject(0).getJSONObject("sensor").getInt("imu_sample_count")) assertEquals("confirm", recs.getJSONObject(1).getString("phase")) - assertEquals(5, recs.getJSONObject(1).getJSONObject("sensor").getJSONArray("imu").length()) + assertEquals(5, recs.getJSONObject(1).getJSONObject("sensor").getInt("imu_sample_count")) } @Test fun `확인 측정도 올라가고 phase 로 구분된다`() { diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt index e1d41d2..3f7547c 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt @@ -1,5 +1,6 @@ package com.medithings.vesiscan.services.labdb +import com.medithings.vesiscan.services.HospitalRunStore import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -77,6 +78,22 @@ class HospitalLabdbPayloadTest { } } + @Test fun `IMU 형제 파일이 있으면 sensor 에 표준 모양으로 실린다`() { + // 2026-09-29: `imu` 는 마지막 샘플 하나(labdb CSV 내보내기가 ax..gz 로 편다), + // 전체는 `imu_samples`. 배열을 `imu` 에 넣던 동안 CSV 의 IMU 열이 비어 있었다. + val csv = csvOf(*(fullRepeat(0) + fullRepeat(1)).toTypedArray()) + val sb = StringBuilder("scan_id,timestamp,repeat_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") + for (rep in 0..1) for (si in 0 until 3) sb.append("1,t,$rep,$si,0.01,0.02,${0.90 + 0.01 * si},0.5,0.6,0.7\n") + HospitalRunStore.imuSibling(csv).writeText(sb.toString(), Charsets.UTF_8) + + val recs = HospitalLabdbPayload.build(csv)!!.getJSONArray("records") + val s0 = recs.getJSONObject(0).getJSONObject("sensor") + assertEquals(0.92, s0.getJSONObject("imu").getDouble("az"), 1e-6) // 마지막(si=2) 샘플 + assertEquals(3, s0.getJSONArray("imu_samples").length()) + assertEquals(3, s0.getInt("imu_sample_count")) + assertEquals(3, recs.getJSONObject(1).getJSONObject("sensor").getInt("imu_sample_count")) + } + @Test fun `IMU 가 없는 자리는 지어내지 않는다`() { // 병원 CSV 에는 IMU·배터리·온도 열이 없다. 0 으로 채우면 나중에 "IMU 가 0 이었다"로 읽힌다. val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!! diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/LabdbSensorTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/LabdbSensorTest.kt new file mode 100644 index 0000000..8461719 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/LabdbSensorTest.kt @@ -0,0 +1,45 @@ +/* + * LabdbSensorTest — `sensor` 가 labdb 표준 모양인지 잠근다. + * + * 2026-09-29: 600·601 이 `sensor.imu` 에 배열을 넣어 labdb CSV 내보내기의 `ax..gz` 가 + * 비어 있었다. 여기서 잠그는 것은 셋이다 — `imu` 는 **마지막** 샘플 하나, 전체는 + * `imu_samples`, 없으면 빈 객체(0 으로 채우지 않는다). + */ +package com.medithings.vesiscan.services.labdb + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class LabdbSensorTest { + + private fun sample(az: Double) = JSONObject().apply { + put("ax", 0.01); put("ay", 0.02); put("az", az) + put("gx", 0.5); put("gy", 0.6); put("gz", 0.7) + } + + @Test fun `imu 는 마지막 샘플 하나이고 전체는 imu_samples 다`() { + val s = LabdbSensor.of(JSONArray().put(sample(0.90)).put(sample(0.91)).put(sample(0.92))) + val imu = s.getJSONObject("imu") + assertEquals(0.92, imu.getDouble("az"), 1e-9) + assertEquals(0.01, imu.getDouble("ax"), 1e-9) + assertEquals(0.7, imu.getDouble("gz"), 1e-9) + assertEquals(6, imu.length()) + assertEquals(3, s.getJSONArray("imu_samples").length()) + assertEquals(3, s.getInt("imu_sample_count")) + } + + @Test fun `샘플이 없으면 빈 객체다`() { + assertEquals(0, LabdbSensor.of(null).length()) + assertEquals(0, LabdbSensor.of(JSONArray()).length()) + } + + @Test fun `없는 축은 0 으로 채우지 않는다`() { + val partial = JSONObject().apply { put("ax", 0.1); put("ay", 0.2); put("az", 0.9) } + val imu = LabdbSensor.of(JSONArray().put(partial)).getJSONObject("imu") + assertEquals(3, imu.length()) + assertFalse(imu.has("gx")) + } +} diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 5b8126d..29dedde 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -125,7 +125,7 @@ | `rowIndex` | int | 반복 번호(0..N). 원본 CSV 의 `repeat_idx` 그대로 | | `datetime` | ISO8601 | 그 반복의 캡처 시각 | | `commandType` | string | `"MTB"` 고정 | -| `sensor` | object | IMU 가 있으면 `{imu: [...]}`, 없으면 `{}` (2026-09-21~). 배터리·온도는 계속 없습니다 | +| `sensor` | object | IMU 가 있으면 `{imu:{…}, imu_samples:[…], imu_sample_count}`, 없으면 `{}` (아래 §IMU). 배터리·온도는 계속 없습니다 | | `channels` | array | 6개 (CH0~CH5) | ### 시각화 요구사항 @@ -294,19 +294,29 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( ```jsonc "sensor": { - "imu": [ + // labdb 표준(000 과 같음): 샘플 하나. CSV 내보내기의 ax..gz 열은 여기서 나옵니다. + // 회차의 마지막(가장 최근 = piezo 캡처 직전) 샘플입니다. + "imu": { "ax": 0.01, "ay": 0.02, "az": 0.98, "gx": 0.5, "gy": 0.6, "gz": 0.7 }, + // 한 회차(cycle/repeat)에서 받은 샘플 전부, FIFO 순(마지막이 최신). 15개 × 50Hz ≈ 300ms. + "imu_samples": [ { "ax": 0.01, "ay": 0.02, "az": 0.98, "gx": 0.5, "gy": 0.6, "gz": 0.7 }, - … // 한 회차(cycle/repeat)에서 받은 샘플 전부 - ] + … + ], + "imu_sample_count": 15 } ``` +> **2026-09-29 모양 변경.** 2026-09-21~28 업로드분은 `imu` 가 **배열**(샘플 전부)이었고 +> `imu_samples` 가 없었습니다. labdb 표준은 `imu` 가 샘플 하나라, 그 기간 세션은 CSV +> 내보내기의 `ax..gz` 열이 **빈칸**으로 나옵니다 — raw JSON 내보내기에는 다 있습니다. +> 사내 임상(`001`)이 처음부터 쓰던 모양으로 맞춘 것이고, 그쪽은 영향 없습니다. + | 필드 | 단위 | 뜻 | |---|---|---| | `ax` · `ay` · `az` | **g** (중력가속도) | 가속도 3축. 정지 시 합성크기 ≈ 1 | | `gx` · `gy` · `gz` | **dps** (도/초) | 각속도 3축. 정지 시 ≈ 0 | -- 600 에서는 한 `repeat_idx` 가 한 레코드이고, 그 회차의 샘플이 배열로 들어갑니다. +- 600 에서는 한 `repeat_idx` 가 한 레코드이고, 그 회차의 샘플이 `imu_samples` 에 배열로 들어갑니다. - 601 에서는 한 `cycle_idx` 가 한 레코드입니다. `phase`(sweep/confirm)별로 따로 들어가며, **확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.** @@ -328,6 +338,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | 2 | 뷰어에서 IMU 표시 | 있을 때만. 없으면 위젯을 숨기는 기존 동작 유지 | | 3 | 없음/0 구분 | 위 경고 참조. `sensor.imu` 가 없으면 **"미측정"** 으로 표시 | | 4 | 마이그레이션 | **불필요.** 기존 레코드는 그대로 두면 됩니다(= 미측정이 사실입니다) | +| 5 | (선택) CSV 내보내기 | 2026-09-21~28 분은 `imu` 가 배열이라 `ax..gz` 가 빈칸입니다. 내보내기에서 **`imu` 가 배열이면 마지막 원소를 쓰게** 하면 그 기간도 채워집니다. 앱을 고쳤으므로 09-29 이후 분은 그대로 나옵니다 | 보기에 쓸 만한 파생값 (서버에서 계산해도 되고 뷰어에서 해도 됩니다): diff --git a/tools/align_analyze.py b/tools/align_analyze.py index f88e06c..e9159b8 100644 --- a/tools/align_analyze.py +++ b/tools/align_analyze.py @@ -46,7 +46,9 @@ def load_records(path): 'timestamp': r.get('timestamp'), 'command_type': r.get('command_type'), 'sigs': sigs, - 'imu': r.get('sensor', {}).get('imu'), + # 2026-09-29 부터 sensor.imu 는 labdb 표준(샘플 하나)이고 전체는 imu_samples. + # 그 전(09-21~28) 업로드분은 imu 자체가 배열이다 — 둘 다 "샘플 목록"으로 받는다. + 'imu': (r.get('sensor') or {}).get('imu_samples', (r.get('sensor') or {}).get('imu')), }) return session, out