feat(labdb): dataType 600·601 배정 반영 · subject 를 최상위로

labdb 관리자가 코드를 배정했다(2026-09-09). 임시로 쓰던 사용자 정의 구간에서 옮긴다.

    901 → 600  병원 임상 측정
    902 → 601  부착 위치 정렬

서버 조사에서 나온 지적 셋을 함께 처리했다.

**① 적재량 오기 정정.** 문서·주석·테스트에 "279건(2026-09-05)"이라고 적혀 있었는데
틀렸다. 279 는 일반 앱의 **미업로드 세션 수**였고 병원 적재량과 무관하다 — 그걸
옮겨 적으면서 섞였다. 실제는 **72세션 · 1,440레코드**(2026-09-04)이고 서버 조회로
확인했다. 기존 적재분은 서버에서 이미 600 으로 마이그레이션됐다.

**② subject 가 서버에서 비어 있었다.** 서버는 최상위 `data.subject` 만 읽어
`sessions.subject` 컬럼에 넣는데 앱은 `params` 안에만 넣고 있었다. 그 컬럼이 export
파일명 prefix 와 목록 표시에 쓰이므로, 72세션 전부 환자 구분이 안 되는 상태였다.
두 페이로드 모두 최상위에 추가한다(params 안에도 그대로 둔다 — 분석 쪽이 이미 쓴다).
환자명이 비면 필드 자체를 넣지 않는다: 빈 문자열이 들어가면 목록에서 빈칸과
구분이 안 된다.

**③ 601 은 레코드 시각이 업로드 시각으로 채워진다.** 원본 정렬 파일에 시각 열이 없어
`datetime` 을 못 넣는데 서버의 `records.timestamp` 는 NOT NULL 이다. 동작에는 문제가
없지만 전 레코드가 거의 같은 시각이 되므로, **조회·시각화는 rowIndex 로 정렬해야 한다**
— KDoc 과 스펙 문서에 명시했다.

PC 변환기(align2labdb.py · hospital2labdb.py)도 같이 고쳤다. 기존 902 정렬 1세션은
같은 testId 로 재업로드해 601 로 갱신했다(600 72 · 601 1 로 확인).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 10:41:04 +09:00
parent e17391ca9f
commit 2770d0296a
8 changed files with 136 additions and 33 deletions
@@ -65,6 +65,16 @@ object ClinicalBv {
* 되지 못한다.
*/
val algoLabel: String,
/**
* 이 계산에 쓰인 기기 프리셋(예 `V1`, `R300`)과 확정 여부.
*
* 프리셋이 dps·delay 를 정하므로 **같은 신호가 다른 부피가 된다** — 실측에서
* 같은 데이터가 125mL 와 490mL 로 갈렸다. 값만 남기면 나중에 어느 기기 기준으로
* 계산된 것인지 알 수 없어 검증 기록이 무의미해진다.
*/
val preset: String,
/** false = 기기 이름이 규칙에 안 맞아 **기본값으로 떨어진 것**. 경고해야 한다. */
val presetConfident: Boolean,
/** 파형 마커용 (전벽, 후벽) 인덱스. 미검출 채널은 null. */
val markers: List<Pair<Int, Int>?>,
val raw: BVResult?,
@@ -101,7 +111,10 @@ object ClinicalBv {
algo: String,
): Outcome {
if (signals.size < PiezoHW.centerCh.size) {
return Outcome(null, emptyList(), 0, "채널 부족 (${signals.size}/6)", algo, emptyList(), null)
return Outcome(
null, emptyList(), 0, "채널 부족 (${signals.size}/6)", algo,
PiezoHW.activePreset.name, PiezoHW.presetConfident, emptyList(), null,
)
}
// AnchorGuide.measure 의 cap_frac 경로와 **같은 호출**이다. CCC on.
@@ -147,6 +160,8 @@ object ClinicalBv {
detectedCenter = centerN,
failure = failureOf(bv, centerN, detected.size),
algoLabel = algo,
preset = PiezoHW.activePreset.name,
presetConfident = PiezoHW.presetConfident,
markers = markers,
raw = bv,
)
@@ -53,7 +53,25 @@ object PiezoHW {
WdConfig.DPS_DEFAULT = _dpsOverride ?: presetDps
}
/**
* 프리셋을 기기 이름으로 확정했는가.
*
* false = 이름이 규칙에 안 맞아 **기본값으로 떨어진 것**이다. 이 상태로 재면
* dps·delay 가 실제 기기와 달라 같은 샘플 번호가 다른 깊이로 매핑되고, BV 가
* 통째로 어긋난다 — 실측에서 같은 데이터가 125mL 와 490mL 로 갈렸다.
*
* 조용히 넘어가면 안 되는 값이라 화면과 기록에 같이 남긴다.
*/
@Volatile var presetConfident: Boolean = false
private set
/** 프리셋 판정의 근거가 된 기기 이름. 기록용. */
@Volatile var presetSource: String = ""
private set
fun autoDetectPreset(deviceName: String) {
presetSource = deviceName
presetConfident = false
if (deviceName.startsWith("VBT") && deviceName.length >= 4) {
// 2026-08-03: R\d{3} 패턴 우선 매칭 (VBT2607R100 · R200 · R300 등).
val rMatch = Regex("""R(\d)(\d{2})$""").find(deviceName)
@@ -64,6 +82,8 @@ object PiezoHW {
"3" -> DevicePreset.R300
else -> DevicePreset.R100
}
// R1/R2/R3 만 아는 값이다. R4… 는 R100 으로 떨어지므로 확정이 아니다.
presetConfident = rMatch.groupValues[1] in listOf("1", "2", "3")
} else {
// 옛 naming: VBT...n0x → 뒤에서 3번째 글자(n) 가 각도 타입
val n = deviceName[deviceName.length - 3]
@@ -73,11 +93,17 @@ object PiezoHW {
'3' -> DevicePreset.V2
else -> DevicePreset.V1 // 알 수 없으면 V1 기본
}
presetConfident = n in listOf('0', '2', '3')
}
} else if (deviceName.startsWith("2025MEDIP")) {
activePreset = DevicePreset.LEGACY_5CH
presetConfident = true
}
android.util.Log.d("PiezoHW", "autoDetectPreset: '$deviceName' → ${activePreset.name}")
android.util.Log.d(
"PiezoHW",
"autoDetectPreset: '$deviceName' → ${activePreset.name}" +
if (presetConfident) "" else " ⚠ 이름 규칙 불일치 — 기본값 사용",
)
}
val centerCh = intArrayOf(0, 1, 2, 3)
@@ -15,12 +15,17 @@ import java.util.Locale
* 정렬 한 세션(위치 여러 개)을 labdb 세션 하나로 만든다.
*
* ## 왜 새 dataType 인가
* 임상 측정(`901`)과 구조가 다르다. 저쪽은 **한 조건에서 20 반복**이고, 정렬은
* 임상 측정(`600`)과 구조가 다르다. 저쪽은 **한 조건에서 20 반복**이고, 정렬은
* **여러 위치 × 20 cycle** 이다. 같은 dataType 에 넣으면 `params` 만으로는 구분이
* 안 되고, 관리자 콘솔이 두 종류를 한 차트로 그리게 된다.
*
* labdb 는 dataType 마다 `records[]` 스키마가 완전히 달라도 된다(`labdb.md` §2-3).
* 900~999 가 사용자 정의 구간이라 여기에 [DATA_TYPE] 을 쓴다.
* labdb 는 dataType 마다 `records[]` 스키마가 완전히 달라도 된다.
*
* ## 레코드 시각이 없다
* 원본 정렬 파일에 시각 열이 없어 `datetime` 을 넣지 못한다. 서버의
* `records.timestamp` 는 NOT NULL 이라 **업로드 시각으로 채워진다** — 전 레코드가
* 거의 같은 시각이 되므로, 조회·시각화는 반드시 `rowIndex` 로 정렬해야 한다
* (2026-09-09 서버 조사 확인).
*
* ## 언제 올리나 — 자동이 아니다
* 간호사가 파형을 보고 **이상하다고 판단했을 때** 버튼으로만 올린다. 목적이 개발자
@@ -41,12 +46,12 @@ import java.util.Locale
object AlignLabdbPayload {
/**
* 정렬 전용 코드. 사용자 정의 구간(900~999).
* 부착 위치 정렬 — labdb 관리자 배정 코드(2026-09-09).
*
* 임상 측정은 `901`([HospitalLabdbPayload.DATA_TYPE]). 관리자가 100~899 를
* 배정해 주면 그 값으로 바꾼다 — 그때 이 KDoc 의 스키마를 그대로 등록 요청에 쓴다.
* 임시로 쓰던 `902`(사용자 정의 구간)에서 옮겼다. 임상 측정은
* `600`([HospitalLabdbPayload.DATA_TYPE]).
*/
const val DATA_TYPE = "902"
const val DATA_TYPE = "601"
private const val PROTOCOL = "hospital_align_2026"
@@ -146,6 +151,10 @@ object AlignLabdbPayload {
put("testId", testId(base))
put("dataType", dataType)
put("sessionName", base)
// 서버는 **최상위** subject 만 읽어 sessions.subject 컬럼에 넣는다
// (params 안의 것은 안 본다). 그 컬럼이 export 파일명 prefix 와 목록
// 표시에 쓰이므로, 빠지면 서버에서 환자 구분이 안 된다.
if (subject.isNotBlank()) put("subject", subject)
if (memo.isNotBlank()) put("memo", memo)
put("savedAt", isoOut.format(Date()))
put("params", params)
@@ -20,10 +20,13 @@ import java.util.TimeZone
* 병원 모드는 형식이 완전히 다르다 — CSV 한 행이 **1 반복 × 1 채널**이고, 조건별로
* 파일이 갈리며, 매니페스트가 따로 있다. 그래서 기존 업로더로는 한 건도 올라가지 않는다.
*
* ## 매핑 — 2026-09-05 수동 업로드(279건)와 **같은 규칙**
* ## 매핑 — 2026-09-04 수동 업로드(72세션 · 1,440레코드)와 **같은 규칙**
* 그때 파이썬 변환기로 올린 것과 모양이 달라지면 labdb 에서 같은 프로토콜 데이터가
* 두 형태로 쌓인다. 분석하는 쪽이 두 벌을 만들어야 하므로 규칙을 그대로 옮긴다.
*
* (한때 이 주석에 "279건"이라고 적혀 있었다. 그건 일반 앱의 **미업로드 세션 수**였고
* 병원 적재량과 무관하다 — 2026-09-09 서버 조사에서 드러났다.)
*
* ```
* CSV 파일 1개 = labdb 세션 1개 (자세·충만도·주파수·cycle 이 고정된 20 반복)
* CSV 6행(CH0~5) = labdb record 1개 → channels[0..5].data = s0..s99
@@ -39,11 +42,12 @@ import java.util.TimeZone
object HospitalLabdbPayload {
/**
* labdb 는 100~899 를 관리자가 배정한다. 병원 임상 프로토콜용 코드는 **아직 없어서**
* 사용자 정의 구간(900~999)을 쓴다. 배정받으면 이 값만 바꾸면 된다 — 2026-09-05
* 수동 업로드분도 같은 값으로 올라가 있다.
* 병원 임상 측정 — labdb 관리자 배정 코드(2026-09-09).
*
* 임시로 쓰던 `901`(사용자 정의 구간)에서 옮겼다. 서버의 기존 적재분
* (72세션 · 1,440레코드)도 같은 날 600 으로 마이그레이션됐다.
*/
const val DATA_TYPE = "901"
const val DATA_TYPE = "600"
private const val PROTOCOL = "hospital_clinical_2026"
private const val SAMPLES_PER_CHANNEL = 100
@@ -144,6 +148,10 @@ object HospitalLabdbPayload {
put("testId", compactTestId(subject, csv.name))
put("dataType", dataType)
put("sessionName", base)
// 서버는 **최상위** subject 만 읽어 sessions.subject 컬럼에 넣는다
// (params 안의 것은 안 본다). 2026-09-09 서버 조사에서 기존 72세션의
// 그 컬럼이 전부 비어 있던 원인이 이것이다.
if (subject.isNotBlank()) put("subject", subject)
put("params", params)
put("recordCount", records.length())
put("records", records)
@@ -92,6 +92,15 @@ fun BvPanel(
outcome?.let {
Spacer(Modifier.height(2.dp))
Text("알고리즘: ${it.algoLabel}", fontSize = 11.sp, color = MlSecondaryText)
// 프리셋은 dps·delay 를 정한다 — 같은 신호가 다른 부피가 되므로 값과 함께
// 보여야 한다. 확정이 아니면 빨갛게 띄운다: 조용히 틀린 기준으로 재는 것이
// 이 화면에서 제일 위험한 상황이다.
Text(
"프로브 프리셋: ${it.preset}" + if (it.presetConfident) "" else " ⚠ 기본값 추정",
fontSize = 11.sp,
color = if (it.presetConfident) MlSecondaryText else MlCritical,
fontWeight = if (it.presetConfident) FontWeight.Normal else FontWeight.Bold,
)
}
outcome?.failure?.let {
@@ -10,7 +10,7 @@ import org.junit.rules.TemporaryFolder
import java.io.File
/**
* 정렬 원시 데이터 → labdb 페이로드 (dataType 902).
* 정렬 원시 데이터 → labdb 페이로드 (dataType 601).
*
* 이 세션은 **사람이 "이상하다"고 판단해서** 올린 것이라, 개발자가 받았을 때
* "왜 올라왔는지"와 "왜 이 위치를 골랐는지"가 데이터 안에서 답이 나와야 한다.
@@ -118,6 +118,19 @@ class AlignLabdbPayloadTest {
assertEquals("값", params.getString("나중에_추가될_필드"))
}
@Test fun `subject 를 최상위에도 넣는다`() {
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1)
File(d, "align_result.json").writeText(summary().toString(), Charsets.UTF_8)
val p = AlignLabdbPayload.build(d)!!
assertEquals("P001", p.getString("subject"))
// 환자명이 없으면 필드 자체를 넣지 않는다 — 빈 문자열이 들어가면 "P001 이 아닌
// 누군가"로 읽히고, 목록에서 빈칸과 구분이 안 된다.
val d2 = tmp.newFolder("noname")
writeCycles(d2, 0, cycles = 1)
assertTrue(!AlignLabdbPayload.build(d2)!!.has("subject"))
}
@Test fun `메모가 들어간다`() {
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1)
@@ -131,7 +144,7 @@ class AlignLabdbPayloadTest {
// 구조가 다른 데이터를 같은 코드로 올리면 콘솔이 한 차트로 그린다.
val d = tmp.newFolder("align")
writeCycles(d, 0, cycles = 1)
assertEquals("902", AlignLabdbPayload.build(d)!!.getString("dataType"))
assertEquals("601", AlignLabdbPayload.build(d)!!.getString("dataType"))
assertTrue(AlignLabdbPayload.DATA_TYPE != HospitalLabdbPayload.DATA_TYPE)
}
@@ -12,7 +12,7 @@ import java.io.File
/**
* 병원 임상 CSV → labdb 페이로드 변환.
*
* 2026-09-05 에 파이썬 변환기로 279건을 이미 올렸다. 앱이 만드는 모양이 그것과 달라지면
* 2026-09-04 에 파이썬 변환기로 72세션(1,440레코드)을 이미 올렸다. 앱이 만드는 모양이 그것과 달라지면
* labdb 에 같은 프로토콜 데이터가 **두 형태로** 쌓이고, 분석하는 쪽이 두 벌을 만들게 된다.
* 그래서 그때의 규칙을 여기에 고정한다:
*
@@ -95,6 +95,15 @@ class HospitalLabdbPayloadTest {
assertEquals(3.0, params.getDouble("cycles"), 1e-9)
assertEquals(1, params.getInt("repeats_saved"))
assertEquals(HospitalLabdbPayload.DATA_TYPE, p.getString("dataType"))
assertEquals("600", p.getString("dataType"))
}
@Test fun `subject 를 최상위에도 넣는다`() {
// 서버는 최상위 subject 만 읽어 sessions.subject 컬럼에 넣는다(params 안은 안 본다).
// 2026-09-09 서버 조사에서 기존 72세션의 그 컬럼이 전부 비어 있던 원인이 이것이다.
val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!!
assertEquals("P001", p.getString("subject"))
assertEquals("P001", p.getJSONObject("params").getString("subject"))
}
@Test fun `매니페스트 값이 params 에 합쳐진다`() {
+30 -16
View File
@@ -1,11 +1,15 @@
# labdb dataType 등록 요청 — VesiScan 병원 임상
# labdb dataType — VesiScan 병원 임상 (600 · 601)
> 등록 대상 2건. 현재 사용자 정의 구간(900~999)의 `901`·`902` 로 올리고 있으며,
> 관리자 배정 코드(100~899)를 받으면 앱에서 상수 한 줄만 바꿔 전환합니다.
> **2026-09-09 배정 완료.** 임시로 쓰던 `901`·`902`(사용자 정의 구간)에서 옮겼습니다.
>
> 앱 측 정의 위치
> · `901` → `services/labdb/HospitalLabdbPayload.kt` (`DATA_TYPE`)
> · `902` → `services/labdb/AlignLabdbPayload.kt` (`DATA_TYPE`)
> | 코드 | 의미 | 앱 측 정의 |
> |---|---|---|
> | `600` | 병원 임상 측정 | `services/labdb/HospitalLabdbPayload.kt` (`DATA_TYPE`) |
> | `601` | 부착 위치 정렬 | `services/labdb/AlignLabdbPayload.kt` (`DATA_TYPE`) |
>
> 서버에 `dataType` 레지스트리 테이블은 없습니다(`schema.sql:47` 의 `data_type` 은
> 자유 문자열). "등록"은 실제로 ① 코드 배정 ② `session.html` 의 `VIZ_HANDLERS` 분기
> ③ 목록 컬럼 ④ 이 문서 네 가지입니다.
---
@@ -16,10 +20,13 @@
- `testId` 는 `^[A-Za-z0-9_-]{1,40}$`. 파일명이 길면 **앞 31자 + `_` + 파일명 SHA-1 앞 8자**로 축약(충돌 방지).
- 채널 데이터는 12-bit ADC 원시값(0~4095), 채널당 최대 100 샘플. 연결이 끊긴 회차는 **짧은 배열**로 올 수 있습니다(0 패딩하지 않음).
- `peak` / `peakIdx` 는 그 채널 배열의 **최초 최대값**과 그 인덱스.
- **`subject` 는 최상위에 넣습니다.** 서버가 `params.subject` 는 읽지 않아
`sessions.subject` 컬럼이 비어 버립니다(2026-09-09 확인 — 기존 72세션이 그 상태).
그 컬럼이 export 파일명 prefix 와 목록 표시에 쓰입니다. `params` 안에도 그대로 둡니다.
---
## 1) dataType `901` — VesiScan 병원 임상 측정
## 1) dataType `600` — VesiScan 병원 임상 측정
### 데이터 의미
@@ -37,7 +44,7 @@
| 필드 | 타입 | 설명 |
|---|---|---|
| `testId` | string | 조합 CSV 파일명 기반 (축약 규칙 위 참조) |
| `dataType` | string | `"901"` |
| `dataType` | string | `"600"` |
| `sessionName` | string | 조합 CSV 파일명(확장자 제외) |
| `savedAt` | ISO8601 | 그 조합의 마지막 반복 시각 |
| `recordCount` | int | = `records.length` |
@@ -93,14 +100,14 @@
---
## 2) dataType `902` — VesiScan 부착 위치 정렬
## 2) dataType `601` — VesiScan 부착 위치 정렬
### 데이터 의미
임상 측정 **전** 단계인 부착 위치 정렬입니다. 치골 위 0cm 부터 1cm 씩 올리며 위치마다
20 cycle 을 재고, 지표를 비교해 부착 위치 하나를 고릅니다.
`901` 과 구조가 다릅니다 — 저쪽은 **한 조건에서 20 반복**, 이쪽은 **여러 위치 × 20 cycle**
`600` 과 구조가 다릅니다 — 저쪽은 **한 조건에서 20 반복**, 이쪽은 **여러 위치 × 20 cycle**
입니다. 그래서 별도 코드가 필요합니다.
업로드 시점: **자동 아님.** 간호사가 파형을 보고 이상하다고 판단했을 때만 버튼으로
@@ -111,7 +118,7 @@
| 필드 | 타입 | 설명 |
|---|---|---|
| `testId` | string | `<환자폴더>_align` 기반 |
| `dataType` | string | `"902"` |
| `dataType` | string | `"601"` |
| `sessionName` | string | `<환자폴더>_align` |
| `memo` | string | **간호사가 적은 증상.** 없으면 필드 자체가 없음 |
| `savedAt` | ISO8601 | 업로드 시각 |
@@ -197,7 +204,10 @@
| `commandType` | string | `"MTB"` 고정 |
| `channels` | array | 6개 |
> `901` 과 달리 `datetime` · `sensor` 가 없습니다. 원본 정렬 파일에 시각 열이 없습니다.
> `600` 과 달리 `datetime` · `sensor` 가 없습니다. 원본 정렬 파일에 시각 열이 없습니다.
>
> ⚠️ 서버의 `records.timestamp` 는 NOT NULL 이라 **업로드 시각으로 채워집니다.** 전
> 레코드가 거의 같은 시각이 되므로 조회·시각화는 반드시 `rowIndex` 로 정렬해야 합니다.
### 시각화 요구사항
@@ -209,10 +219,14 @@
---
## 요청 사항 정리
## 이력
| 항목 | 내용 |
|---|---|
| 코드 배정 | `901`·`902` 를 그대로 등록하거나, 100~899 구간에서 2개 배정 |
| 기존 데이터 | `901` 로 이미 **279건**(2026-09-05 수동 업로드) 적재됨. 코드 변경 시 마이그레이션 필요 |
| 앱 반영 | 배정 코드를 받으면 상수 2개만 수정 후 재배포 |
| 코드 배정 | 2026-09-09 · `600` 임상 / `601` 정렬 |
| 기존 적재분 | `901` 로 올렸던 **72세션 · 1,440레코드**(2026-09-04)는 서버에서 `600` 으로 마이그레이션 완료 |
| `902` 정렬 1세션 | 같은 `testId` 로 `601` 재업로드하여 갱신 |
| 앱 반영 | 상수 2개 변경 + 최상위 `subject` 추가 |
> 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의
> 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다.