Files
VesiscanClinicalAndroid/docs/LABDB_DATATYPES.md
T
dw.jang 8ef7c64ba6 feat(hospital): 재부착 확인 단계 · 좌우 스트리밍 전체 기록 · 정렬 파형을 위로 (2026-09-29 임상 후속)
임상에서 정렬 → 위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기 사이에 CH2 가 사라진 채
18조합을 다 쟀다. 좌우는 실시간 판정만 하고 아무것도 남기지 않아 "그 전에는 있었나"를
대조할 수도 없었다. 세 가지를 넣는다.

1. 재부착 확인 (병원 화면 정렬 카드 아래, ReattachCheckCard)
   · 부착 위치에서 정렬 조건(2.3MHz·c3·20 cycle)으로 재고, 정렬 확인 측정에서 잡힌
     채널(AppState.anchorRefChannels ← 확인 측정의 walls)과 CH0~3 을 채널별로 대조
     + 탈착 감시. 판정은 ReattachCheck.judge (순수 함수, 시험 6건).
   · 막지 않는다 — 측정 시작 아래 경고, 측정 중 한 줄 요약에 "재부착 ⚠", run 매니페스트
     `anchor_reattach`, align_result.json `reattach`, 601 에 phase=reattach 로 파형·IMU.
   · AppState.clearAnchor() — 부착 위치와 딸린 것(근거·기준 채널·재부착)을 한꺼번에 비운다.
2. 좌우 스트리밍 전체 기록 (HospitalRunStore.LateralStreamWriter)
   · 프레임마다 6채널 + 그 프레임의 판정(u4·u5·imbalance·action·ch3)을
     align_{n}cm_lateral_stream.csv 에 덧붙여 쓴다(앱이 죽어도 그때까지 남음).
     `attempt` 열로 [처음부터 다시 정렬] 시도를 가른다 — 앞 시도의 비동기 업로드가 파일을
     읽고 있을 수 있어 지우지 않는다. 601 에 phase=lateral_stream + `lateral{…}`.
3. 정렬 화면 파형 1차 개편 — 파형을 용적·접촉보다 위에 더 크게(170dp), 그 위에 채널별
   벽 검출 띠(CH0~3 ✓/✗, CH4·5 는 좌우용 회색). "CH2 없음"이 파형보다 먼저 보인다.

시험 88건 통과(전체 174 · skipped 3 · failures 0). 실기(프로브 필요)는 다음 임상에서.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 14:39:25 +09:00

20 KiB
Raw Blame History

labdb dataType — VesiScan 병원 임상 (600 · 601)

2026-09-09 배정 완료. 임시로 쓰던 901·902(사용자 정의 구간)에서 옮겼습니다.

코드 의미 앱 측 정의
600 병원 임상 측정 services/labdb/HospitalLabdbPayload.kt (DATA_TYPE)
601 부착 위치 정렬 services/labdb/AlignLabdbPayload.kt (DATA_TYPE)

서버에 dataType 레지스트리 테이블은 없습니다(schema.sql:47 의 data_type 은 자유 문자열). "등록"은 실제로 ① 코드 배정 ② session.html 의 VIZ_HANDLERS 분기 ③ 목록 컬럼 ④ 이 문서 네 가지입니다.


공통

  • 엔드포인트: POST /upload/json (기존과 동일)
  • 세션 1개 = 요청 1건. records[] 는 세션 안의 반복 측정.
  • 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 600 — VesiScan 병원 임상 측정

데이터 의미

병원 임상 프로토콜의 한 조합입니다. 자세 · 방광 충만도 · 주파수 · cycle 수가 하나로 고정된 상태에서 같은 조건을 20회 반복 측정합니다. 한 환자 한 세션(자세×충만도)당 6조합(주파수 2 × cycle 3)이 돌므로 세션 6개가 생깁니다.

환자 폴더 전체를 한 세션으로 묶지 않는 이유는, 그러면 1,440 record 짜리 덩어리가 되어 조건별 비교가 불가능해지기 때문입니다.

업로드 시점: 조합이 끝나는 즉시 자동. 오프라인이면 앱에 쌓아 두었다가 버튼으로 일괄 전송.

세션 필드

필드 타입 설명
testId string 조합 CSV 파일명 기반 (축약 규칙 위 참조)
dataType string "600"
sessionName string 조합 CSV 파일명(확장자 제외)
savedAt ISO8601 그 조합의 마지막 반복 시각
recordCount int = records.length
params object 아래
records array 아래

params

필드 타입 설명
protocol string "hospital_clinical_2026" 고정
subject string 환자 식별자(가명)
posture string Supine / Sitting / …
fill_pct number 방광 충만도 % (0·20·40·60·80·100)
freq_mhz number 예 2.3
freq_option number 펌웨어 주파수 코드
cycles number 버스트 cycle 수 (3·5·7)
device string 프로브 이름 (예 VBT26080001)
firmware_version string
repeats_saved int 실제로 저장된 반복 수
source_file string 원본 CSV 파일명
avg, delay_us, samples number 측정 파라미터(매니페스트에 있을 때만)
anchor_cm int / null 정렬로 정한 부착 위치(치골 위 cm)
anchor_basis string / null 그 위치를 누가 정했나 — 아래
abdomen_thickness string upto_40mm / over_40mm — 돌 조합을 이것이 정합니다
abdomen_thickness_label string 사람이 읽는 표기 (40mm 이하 / 40mm 초과)

abdomen_thickness — 조합 수가 세션마다 다릅니다

2026-09-09 부터 6조합 순회를 그만두고 복부 두께로 조합을 고릅니다. 한 단계에 20회 × 6조합 = 120회가 환자를 너무 오래 눕혀 두기 때문이고, 기존 72세션에서 cycle 을 5·7 로 늘려 얻는 것이 그 시간을 정당화하지 못했습니다.

abdomen_thickness 조합 세션 수
upto_40mm 1.8MHz · cycle 3 1
over_40mm 1.8MHz · cycle 3 + 2.3MHz · cycle 3 2

그래서 한 (자세, 충만도) 에 세션이 1개뿐인 것이 정상입니다 — 누락이 아닙니다. "왜 조합이 적은가"는 이 필드로 답이 됩니다. cycle 은 이제 항상 3 이고, cycle 5·7 데이터는 2026-09-09 이전 세션에만 있습니다.

anchor_basis — 정확도 분석 전에 반드시 보셔야 하는 값

정렬 알고리즘은 실제 인체에서 끝까지 안 맞을 수 있습니다(CH3 가 어느 위치에서도 안 잡히거나, 탐색 상한까지 가도 종료 조건이 안 걸림). 그때 진행을 막으면 환자를 눕혀 둔 채 임상이 멈추므로, 간호사가 현재 위치로 그냥 진행할 수 있게 열어 두었습니다.

그래서 부착 위치에는 세 가지 출처가 섞여 있습니다:

값 뜻 정확도 분석
algorithm 알고리즘이 best 를 확정 (action == STOP) 모집단에 포함
reattach_override 알고리즘은 재부착 권고, 그대로 진행 제외
manual 탐색이 끝나기 전에 사람이 이 위치로 결정 제외
null 정렬을 거치지 않고 측정 제외

"기존 초음파 측정기 대비 정확도" 를 낼 때 algorithm 만 추려야 합니다. 나머지를 섞으면 사람이 고른 자리의 오차가 알고리즘의 오차로 계산됩니다. 세션 목록에서 이 값을 필터로 걸어 주시면 그 실수를 막을 수 있습니다.

records[]

{
  "rowIndex": 0,
  "datetime": "2026-09-08T10:00:00.000+09:00",
  "commandType": "MTB",
  "sensor": {},
  "channels": [
    { "ch": 0, "peak": 3500, "peakIdx": 30, "data": [ 900, 901, ... ] }
  ]
}
필드 타입 설명
rowIndex int 반복 번호(0..N). 원본 CSV 의 repeat_idx 그대로
datetime ISO8601 그 반복의 캡처 시각
commandType string "MTB" 고정
sensor object IMU 가 있으면 {imu:{…}, imu_samples:[…], imu_sample_count}, 없으면 {} (아래 §IMU). 배터리·온도는 계속 없습니다
channels array 6개 (CH0~CH5)

시각화 요구사항

000(VesiScan 초음파)과 같은 파형 뷰면 충분합니다. 채널 구조가 동일합니다. 배터리·온도 위젯은 계속 숨겨 주십시오 — 그 열은 여전히 없습니다. IMU 는 2026-09-21 이후 업로드분부터 sensor.imu 로 들어옵니다(아래 §IMU).

조건 비교를 자주 하므로, 세션 목록에서 params.posture / fill_pct / freq_mhz / cycles 를 열로 볼 수 있으면 유용합니다.


2) dataType 601 — VesiScan 부착 위치 정렬

데이터 의미

임상 측정 전 단계인 부착 위치 정렬입니다. 치골 위 0cm 부터 1cm 씩 올리며 위치마다 20 cycle 을 재고, 지표를 비교해 부착 위치 하나를 고릅니다.

600 과 구조가 다릅니다 — 저쪽은 한 조건에서 20 반복, 이쪽은 여러 위치 × 20 cycle 입니다. 그래서 별도 코드가 필요합니다.

업로드 시점: 자동 아님. 간호사가 파형을 보고 이상하다고 판단했을 때만 버튼으로 보냅니다. 개발자 피드백 요청 용도입니다.

세션 필드

필드 타입 설명
testId string <날짜_환자폴더>_align_<HHmm> 기반 (2026-09-29~). 그 전에는 <환자명>_align — 날짜가 없어 같은 환자를 다른 날 다시 정렬하면 전부 중복으로 무시됐습니다
dataType string "601"
sessionName string testId 와 같은 바탕
memo string 간호사가 적은 증상. 없으면 필드 자체가 없음
savedAt ISO8601 업로드 시각
recordCount int 전체 위치의 cycle 합계
params object 아래
records array 아래

params

필드 타입 설명
protocol string "hospital_align_2026" 고정
subject string 환자 식별자
save_name string 저장 폴더명
positions_measured int 측정한 위치 수 (탐색 sweep 만)
confirm_positions int 확인 측정한 위치 수
lateral_frames int 좌우 확인 완료 순간에 남긴 프레임 수 (2026-09-30~). 0 = 좌우 미확인 또는 옛 앱
lateral_stream_frames int 좌우 정렬 동안 흘린 프레임 전부 (2026-09-30~)
reattach_frames int 재부착 확인 측정 cycle 수 (2026-09-30~). 0 = 안 했음
confirm_channels bool[4] 정렬 확인 측정에서 벽이 잡힌 채널 CH03 (2026-09-30). 재부착 확인의 기준
reattach object 재부착 확인 판정 {ok, missing[], detached[], channels[4], ref_channels[4]|null, bv_ml, at} — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지
cycles_total int = recordCount
upload_reason string "nurse_review" — 사람이 올린 세션 표시
device, firmware_version, hw_preset string
freq_mhz, freq_option, probe_cycles number 정렬 전용 고정 조건 (2.3MHz · cycle 3)
cycles_per_position int 20 고정
avg, delay_us, samples number
ch3_hit_min number CH3 검출률 하한 (0.8)
final_offset_cm int best 에 더하는 오프셋 (1)
best_cm int / null 알고리즘이 고른 최적 위치
anchor_cm int / null 실제 부착 위치 = best_cm + final_offset_cm
action string STOP / REATTACH / …
stop_reason string / null 탐색 종료 사유
max_nch int 관측된 최대 검출 채널 수
patient_unnamed bool 환자명 없이 잰 데이터인가(폴더가 unnamed_*)
selection_verified_against_reference bool 앱 판정이 Python 레퍼런스와 대조 완료인가
positions array 위치별 지표 — 아래
lateral object 좌우 정렬 결과 — 아래
anchor_basis string / null 이 위치로 임상을 진행한 근거. 600 의 같은 필드와 같은 값입니다. null = 정렬만 하고 임상으로 넘어가지 않음
proceed_anchor_cm int / null 실제로 임상에 넘긴 cm. action != STOP 인 채 진행하면 anchor_cm 은 null 인데 이 값은 들어 있습니다

params 는 align_result.json 을 통째로 옮깁니다(patient → subject, save_name 만 이름이 바뀝니다). 앱에서 요약에 필드를 추가하면 별도 작업 없이 같이 올라갑니다 — 위 표에 없는 키가 보여도 정상입니다.

params.positions[] (위치별 판정 근거):

필드 타입 설명
align_cm int 위치(치골 위 cm)
n_trace int 슬라이딩 trace 수 (20 cycle → 11)
nch int CH0~CH3 중 검출 채널 수
ch3 "O" / "X" mean-scan 기준 CH3 검출 여부
ch3_hit int CH3 가 검출된 trace 수
ch3_tot int 전체 trace 수
ch3_rate number ch3_hit / ch3_tot
cap_frac number 기하 지표 (BV 파이프라인 산출)
eligible bool 후보 자격 = ch3 == "O" AND ch3_rate >= ch3_hit_min

params.lateral (좌우 정렬):

필드 설명
done 좌우 판정값(commit)이 있는가
confirmed 간호사가 "좌우 확인 완료"를 눌렀는가. done 과 다른 사실입니다 — 좌우를 맞추는 중에 임상으로 넘어가면 done=true, confirmed=false 가 됩니다
lat_tol 허용 오차 (8)
accum_k 판정에 쓴 프레임 수 (10)
action STOP / MOVE_LEFT / MOVE_RIGHT / PROBE_LR / MOVE_UP
u4, u5 CH4(좌)·CH5(우) urine_len. null = 미검출
imbalance |u4−u5|. 한쪽이라도 미검출이면 null
ch3 판정 시점 CH3 검출 여부

records[]

{
  "rowIndex": 0,
  "align_cm": 0,
  "cycle_idx": 0,
  "commandType": "MTB",
  "channels": [
    { "ch": 0, "peak": 3500, "peakIdx": 30, "data": [ 900, ... ] }
  ]
}
필드 타입 설명
rowIndex int 위치를 가로질러 0..N 연속 (labdb 규약)
align_cm int 이 cycle 을 잰 위치(치골 위 cm)
cycle_idx int 그 위치 안에서의 순번 (0..19)
commandType string "MTB" 고정
channels array 6개

600 과 달리 datetime 이 없습니다 — 원본 정렬 파일에 시각 열이 없습니다. sensor 는 2026-09-21 부터 600 과 같은 모양으로 들어옵니다(아래 §IMU).

⚠️ 서버의 records.timestamp 는 NOT NULL 이라 업로드 시각으로 채워집니다. 전 레코드가 거의 같은 시각이 되므로 조회·시각화는 반드시 rowIndex 로 정렬해야 합니다.

시각화 요구사항

같은 세션 안에 여러 위치가 섞여 있는 것이 이 타입의 핵심입니다.

  1. align_cm 으로 그룹핑해 위치별로 파형을 나란히 볼 수 있으면 가장 유용합니다.
  2. params.positions 를 표로 띄우고 best_cm 행을 강조해 주시면, "왜 이 위치를 골랐나"를 한 화면에서 판단할 수 있습니다.
  3. memo 를 세션 목록에서 바로 보이게 해 주세요 — 간호사가 무엇을 이상하게 봤는지가 이 세션의 존재 이유입니다.

이력

항목 내용
코드 배정 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레코드입니다.


IMU (600 · 601 공통) — 2026-09-21 추가

병원 임상 경로는 mtb? 를 보내면서 piezo 응답만 받고 IMU(rim:)를 버리고 있었습니다. 이제 받아서 저장하고 업로드합니다.

왜 넣나

자세는 조작자가 고르는 실험 조건이라 판정에는 안 씁니다. 필요한 것은 다른 것입니다 — 같은 조건 20회 반복에서 어떤 회차만 값이 튈 때, 그것이 알고리즘 문제인지 환자가 그 순간 움직인 것인지 가를 근거가 없었습니다. 정렬(601)도 사람이 프로브를 옮겨 가며 재는 과정이라, 특정 위치의 파형이 이상할 때 자리 탓인지 흔들림 탓인지 알 수 없었습니다.

페이로드 모양

records[] 의 각 레코드에 sensor 가 붙습니다. 600 · 601 이 같은 모양입니다.

"sensor": {
  // 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 },
    …
  ],
  "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 가 한 레코드이고, 그 회차의 샘플이 imu_samples 에 배열로 들어갑니다.
  • 601 에서는 한 cycle_idx 가 한 레코드입니다. phase(sweep/confirm/lateral)별로 따로 들어가며, 확인 측정의 IMU 가 탐색 측정 것을 덮지 않습니다.
  • phase: "lateral" (2026-09-30~) 은 간호사가 "좌우 확인 완료"를 누른 순간의 마지막 프레임 창(판정 1회에 쓰는 10프레임)입니다. align_cm 은 부착 위치(best + 오프셋)이고, cycle_idx 는 그 창 안의 순서입니다. 좌우 정렬 판정을 나중에 대조하는 데 씁니다.
  • phase: "lateral_stream" (2026-09-30~) 은 좌우 정렬 동안 흘린 프레임 전부입니다. 레코드마다 lateral: {attempt, u4, u5, imbalance, action, ch3} 가 붙습니다 — 그 프레임까지의 판정(앱이 화면에 보인 것). attempt 는 [처음부터 다시 정렬] 마다 1씩 오릅니다.
  • phase: "reattach" (2026-09-30~) 은 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리에서 병원 화면이 20 cycle 잰 재부착 확인 측정입니다. 판정은 params.reattach 에.

⚠ 없으면 sensor 는 빈 객체입니다 — 0 으로 채우지 마십시오

"sensor": {} 또는 sensor.imu 부재는 "그때는 안 쟀다" 는 뜻입니다. 0 으로 채우면 "IMU 가 0 이었다"(= 무중력·완전 정지)로 읽혀 정반대 해석이 됩니다.

빈 경우가 실제로 생깁니다:

  • 2026-09-21 이전 업로드분 전부 — 그때는 수집 자체를 안 했습니다
  • 펌웨어·설정에 따라 rim: 이 안 오는 회차
  • IMU 응답이 늦어 회차 안에 못 들어온 경우 (앱이 측정을 실패로 돌리지 않고 그냥 비웁니다)

labdb 쪽에 필요한 일

# 작업 비고
1 sensor.imu 저장 600 · 601 모두. 스키마가 추가 키를 허용하므로 서버 변경 없이도 보관은 됩니다
2 뷰어에서 IMU 표시 있을 때만. 없으면 위젯을 숨기는 기존 동작 유지
3 없음/0 구분 위 경고 참조. sensor.imu 가 없으면 "미측정" 으로 표시
4 마이그레이션 불필요. 기존 레코드는 그대로 두면 됩니다(= 미측정이 사실입니다)
5 (선택) CSV 내보내기 2026-09-21~28 분은 imu 가 배열이라 ax..gz 가 빈칸입니다. 내보내기에서 imu 가 배열이면 마지막 원소를 쓰게 하면 그 기간도 채워집니다. 앱을 고쳤으므로 09-29 이후 분은 그대로 나옵니다

보기에 쓸 만한 파생값 (서버에서 계산해도 되고 뷰어에서 해도 됩니다):

  • accel_mag = sqrt(ax²+ay²+az²) — 정지 시 ≈ 1g. 1 에서 멀어지면 움직인 것
  • gyro_mag = sqrt(gx²+gy²+gz²) — 정지 시 ≈ 0 dps. 회차 내 최대값이 그 회차의 흔들림
  • 회차별 gyro_mag 최대치를 파형 옆에 띄우면 "튄 회차 = 흔들린 회차"가 한눈에 보입니다

앱이 저장하는 원본 파일 (참고)

업로드 전 폰에 남는 형제 CSV 입니다. 페이로드는 이걸 읽어 만듭니다.

600  <측정파일>_imu.csv
     scan_id,timestamp,repeat_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps
     → scan_id 로 파형 CSV 와 잇습니다

601  align_{n}cm_imu.csv · …_confirm_imu.csv · …_lateral_imu.csv · …_lateral_stream_imu.csv · …_reattach_imu.csv
     cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps
     → 파형과 같은 파일명 규칙(종류별 분리)을 그대로 따릅니다

파형 행(meta + s0..s99)을 넓히지 않은 이유: 그 헤더는 이미 올라간 데이터와 파서가 함께 쓰는 규약이고, IMU 는 채널당이 아니라 회차당 값이라 같은 행에 넣으면 6 채널 행에 같은 IMU 를 여섯 번 복사하게 됩니다.

호환성

  • hospital_align_2026 · 600 프로토콜 이름은 그대로입니다
  • 추가 키뿐이라 기존 파서가 깨지지 않습니다
  • 기존 데이터는 의미가 바뀌지 않습니다 (없던 필드가 없는 채로 남습니다)