fix(labdb): 600·601 의 sensor.imu 를 labdb 표준 모양(샘플 하나)으로 — CSV 내보내기에 IMU 가 비어 있었다

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 09:26:41 +09:00
parent 93b5726f21
commit 4fc40219f2
8 changed files with 155 additions and 28 deletions
@@ -42,12 +42,14 @@ import java.util.Locale
* phase : "sweep" | "confirm" ← 2026-09-18 추가 * phase : "sweep" | "confirm" ← 2026-09-18 추가
* cycle_idx : 그 위치 안에서의 순번 * cycle_idx : 그 위치 안에서의 순번
* channels : 6 × {ch, peak, peakIdx, data[~100]} * 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 * ## IMU
* `align_{n}cm[_confirm]_imu.csv` 가 있으면 cycle 별로 `sensor.imu` 에 싣는다. 없으면 * `align_{n}cm[_confirm]_imu.csv` 가 있으면 cycle 별로 `sensor` 에 싣는다([LabdbSensor.of]
* `sensor` 는 빈 객체다 — **부재가 곧 "그때는 안 쟀다"** 이므로 0 으로 채우지 않는다. * — `imu` 는 마지막 샘플 하나, `imu_samples` 는 전부). 없으면 `sensor` 는 빈 객체다 —
* **부재가 곧 "그때는 안 쟀다"** 이므로 0 으로 채우지 않는다.
* 정렬은 사람이 프로브를 옮겨 가며 재는 과정이라, 어느 위치의 파형이 이상할 때 그것이 * 정렬은 사람이 프로브를 옮겨 가며 재는 과정이라, 어느 위치의 파형이 이상할 때 그것이
* 자리 탓인지 그 순간 흔들린 탓인지 IMU 없이는 가를 수 없다. * 자리 탓인지 그 순간 흔들린 탓인지 IMU 없이는 가를 수 없다.
* *
@@ -152,9 +154,7 @@ object AlignLabdbPayload {
put("align_cm", cm) put("align_cm", cm)
put("phase", phase) put("phase", phase)
put("cycle_idx", ci) put("cycle_idx", ci)
put("sensor", JSONObject().apply { put("sensor", LabdbSensor.of(imuByCycle[ci]))
imuByCycle[ci]?.let { put("imu", it) }
})
put("commandType", "MTB") put("commandType", "MTB")
put("channels", chans) put("channels", chans)
}) })
@@ -40,10 +40,14 @@ import java.util.TimeZone
* 배터리·온도는 병원 CSV 에 열이 없다. `sensor` 에 넣지 않는다 — 0 이나 null 로 채우면 * 배터리·온도는 병원 CSV 에 열이 없다. `sensor` 에 넣지 않는다 — 0 이나 null 로 채우면
* 나중에 "배터리가 0 이었다"로 읽힌다. * 나중에 "배터리가 0 이었다"로 읽힌다.
* *
* ## IMU (2026-09-21 추가) * ## IMU (2026-09-21 추가 · 2026-09-29 표준 모양)
* `*_imu.csv` 형제 파일이 있으면 `sensor.imu` 로 싣는다. 없으면 `sensor` 는 종전대로 * `*_imu.csv` 형제 파일이 있으면 회차별로 `sensor` 에 싣는다([LabdbSensor.of] — `imu` 는
* 빈 객체다 — **이 필드의 부재가 곧 "그때는 안 쟀다"** 라서, 없는 회차를 0 으로 채우면 * 마지막 샘플 하나, `imu_samples` 는 전부). 없으면 `sensor` 는 종전대로 빈 객체다 —
* 안 된다. 예전 업로드분에는 애초에 파일이 없다. * **이 필드의 부재가 곧 "그때는 안 쟀다"** 라서, 없는 회차를 0 으로 채우면 안 된다.
* 예전 업로드분에는 애초에 파일이 없다.
*
* 09-21~09-28 업로드분은 `sensor.imu` 가 **배열**이었다. labdb 표준은 샘플 하나라 CSV
* 내보내기의 `ax..gz` 가 비어 나왔다 — raw JSON 에는 다 있다.
* *
* 같은 자세로 20회 반복하는 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지 * 같은 자세로 20회 반복하는 프로토콜이라, 어떤 회차만 값이 튈 때 환자가 움직인 것인지
* 알고리즘 문제인지 가르는 근거가 IMU 다. * 알고리즘 문제인지 가르는 근거가 IMU 다.
@@ -133,10 +137,8 @@ object HospitalLabdbPayload {
put("rowIndex", rep) put("rowIndex", rep)
put("datetime", iso(times[rep])) put("datetime", iso(times[rep]))
put("commandType", "MTB") put("commandType", "MTB")
// IMU 가 있으면 `sensor.imu`, 없으면 빈 객체(= 안 쟀다). // IMU 가 있으면 표준 모양(imu · imu_samples), 없으면 빈 객체(= 안 쟀다).
put("sensor", JSONObject().apply { put("sensor", LabdbSensor.of(imuByRepeat[rep]))
imuByRepeat[rep]?.let { put("imu", it) }
})
put("channels", chans) put("channels", chans)
}) })
} }
@@ -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
}
}
@@ -54,24 +54,28 @@ class AlignLabdbPayloadTest {
/** `cycle_idx,sample_idx,ax_g,...` — HospitalRunStore.writeAlignImu 형식. */ /** `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) {
val sb = StringBuilder("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n") 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) { 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" 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) 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") val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 2) writeCycles(d, 0, cycles = 2)
writeImu(d, 0, cycles = 2, perCycle = 3) writeImu(d, 0, cycles = 2, perCycle = 3)
val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records") val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records")
val imu0 = recs.getJSONObject(0).getJSONObject("sensor").getJSONArray("imu") val s0 = recs.getJSONObject(0).getJSONObject("sensor")
assertEquals(3, imu0.length()) assertEquals(0.92, s0.getJSONObject("imu").getDouble("az"), 1e-6) // 마지막(si=2) 샘플
assertEquals(0.98, imu0.getJSONObject(0).getDouble("az"), 1e-6) assertEquals(3, s0.getJSONArray("imu_samples").length())
assertEquals(3, s0.getInt("imu_sample_count"))
// cycle 별로 갈라져야 한다 — 한 위치의 모든 샘플이 한 회차에 몰리면 안 된다. // 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 는 빈 객체다`() { @Test fun `IMU 가 없으면 sensor 는 빈 객체다`() {
@@ -91,9 +95,9 @@ class AlignLabdbPayloadTest {
val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records") val recs = AlignLabdbPayload.build(d)!!.getJSONArray("records")
assertEquals("sweep", recs.getJSONObject(0).getString("phase")) 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("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 로 구분된다`() { @Test fun `확인 측정도 올라가고 phase 로 구분된다`() {
@@ -1,5 +1,6 @@
package com.medithings.vesiscan.services.labdb package com.medithings.vesiscan.services.labdb
import com.medithings.vesiscan.services.HospitalRunStore
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull 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 가 없는 자리는 지어내지 않는다`() { @Test fun `IMU 가 없는 자리는 지어내지 않는다`() {
// 병원 CSV 에는 IMU·배터리·온도 열이 없다. 0 으로 채우면 나중에 "IMU 가 0 이었다"로 읽힌다. // 병원 CSV 에는 IMU·배터리·온도 열이 없다. 0 으로 채우면 나중에 "IMU 가 0 이었다"로 읽힌다.
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!! val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!!
@@ -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"))
}
}
+16 -5
View File
@@ -125,7 +125,7 @@
| `rowIndex` | int | 반복 번호(0..N). 원본 CSV 의 `repeat_idx` 그대로 | | `rowIndex` | int | 반복 번호(0..N). 원본 CSV 의 `repeat_idx` 그대로 |
| `datetime` | ISO8601 | 그 반복의 캡처 시각 | | `datetime` | ISO8601 | 그 반복의 캡처 시각 |
| `commandType` | string | `"MTB"` 고정 | | `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) | | `channels` | array | 6개 (CH0~CH5) |
### 시각화 요구사항 ### 시각화 요구사항
@@ -294,19 +294,29 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
```jsonc ```jsonc
"sensor": { "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 }, { "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 | | `ax` · `ay` · `az` | **g** (중력가속도) | 가속도 3축. 정지 시 합성크기 ≈ 1 |
| `gx` · `gy` · `gz` | **dps** (도/초) | 각속도 3축. 정지 시 ≈ 0 | | `gx` · `gy` · `gz` | **dps** (도/초) | 각속도 3축. 정지 시 ≈ 0 |
- 600 에서는 한 `repeat_idx` 가 한 레코드이고, 그 회차의 샘플이 배열로 들어갑니다. - 600 에서는 한 `repeat_idx` 가 한 레코드이고, 그 회차의 샘플이 `imu_samples` 에 배열로 들어갑니다.
- 601 에서는 한 `cycle_idx` 가 한 레코드입니다. `phase`(sweep/confirm)별로 따로 들어가며, - 601 에서는 한 `cycle_idx` 가 한 레코드입니다. `phase`(sweep/confirm)별로 따로 들어가며,
**확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.** **확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.**
@@ -328,6 +338,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
| 2 | 뷰어에서 IMU 표시 | 있을 때만. 없으면 위젯을 숨기는 기존 동작 유지 | | 2 | 뷰어에서 IMU 표시 | 있을 때만. 없으면 위젯을 숨기는 기존 동작 유지 |
| 3 | 없음/0 구분 | 위 경고 참조. `sensor.imu` 가 없으면 **"미측정"** 으로 표시 | | 3 | 없음/0 구분 | 위 경고 참조. `sensor.imu` 가 없으면 **"미측정"** 으로 표시 |
| 4 | 마이그레이션 | **불필요.** 기존 레코드는 그대로 두면 됩니다(= 미측정이 사실입니다) | | 4 | 마이그레이션 | **불필요.** 기존 레코드는 그대로 두면 됩니다(= 미측정이 사실입니다) |
| 5 | (선택) CSV 내보내기 | 2026-09-21~28 분은 `imu` 가 배열이라 `ax..gz` 가 빈칸입니다. 내보내기에서 **`imu` 가 배열이면 마지막 원소를 쓰게** 하면 그 기간도 채워집니다. 앱을 고쳤으므로 09-29 이후 분은 그대로 나옵니다 |
보기에 쓸 만한 파생값 (서버에서 계산해도 되고 뷰어에서 해도 됩니다): 보기에 쓸 만한 파생값 (서버에서 계산해도 되고 뷰어에서 해도 됩니다):
+3 -1
View File
@@ -46,7 +46,9 @@ def load_records(path):
'timestamp': r.get('timestamp'), 'timestamp': r.get('timestamp'),
'command_type': r.get('command_type'), 'command_type': r.get('command_type'),
'sigs': sigs, '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 return session, out