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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user