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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 형식. */
|
||||
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 로 구분된다`() {
|
||||
|
||||
@@ -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()))!!
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user