Files
VesiscanClinicalAndroid/docs/LABDB_DATATYPES.md
T
dw.jang d401740210 feat(clinical): 병원 임상 화면 재구성 · 조합은 복부 두께로 · 레퍼런스 고정
## 화면을 두 덩어리로 나눴다

    환자명 → 부착 위치 정렬 → 자세 → 방광 채움 → 반복 횟수 → 복부 두께 →
    [측정 시작] → BV 카드
    ──────────────────────── 구분선 ────────────────────────
    수동 확인 — 저장되지 않습니다
    프로브 파라미터 주입 → [1회 측정]/[연속 측정] → BV → 마지막 측정 → 6채널

종전에는 BV 측정 구간이 [측정 시작] **위에** 있어 프로토콜 흐름 한가운데 끼어 있었다.
수동 측정 값은 저장도 업로드도 안 되는데, 한 흐름으로 붙어 있으면 조작자가 그것을
프로토콜의 한 단계로 착각한다. 구분선과 "저장되지 않습니다" 한 줄을 넣었다.

프로토콜 BV 카드는 실행이 끝난 뒤에도 남긴다(caption 이 "직전 회차"→"마지막 회차").
방광을 비우기 전에 방금 받은 값이 말이 되는지 한 번 더 볼 수 있어야 한다.

## 6조합 순회 → 복부 두께 2택

    40mm 이하   1.8MHz c3                 →  n회
    40mm 초과   1.8MHz c3 · 2.3MHz c3     →  2n회

cycle 은 3 고정. 한 단계 20회 × 6조합 = 120회가 환자를 너무 오래 눕힌다.

조작자에게 freq·cycle 을 고르게 하지 않는 것이 핵심이다. 눈으로 보고 답할 수 있는
것은 복부 두께이고, 조합은 거기서 따라 나온다(AbdomenThickness).

## 진행 판정: 개수 비교 → 집합 비교 (여기가 제일 위험했다)

`EXPECTED_COMBINATIONS = 6` 으로 **개수**를 세고 있었다. 그대로 두면 요구 조합이 1~2개인
지금 어떤 단계도 영원히 PARTIAL 로 남는다. 그런데 단순히 6을 2로 바꾸면 더 나쁘다 —
2.3MHz 를 두 번 채운 것과 1.8·2.3 을 각각 채운 것이 같은 숫자가 되어, 두꺼운 환자의
단계가 완료로 잡히고 **그 자리에서 1.8MHz 를 영원히 놓친다.**

`scanProgress(patient, day, expected)` 로 기대 집합을 받아 `containsAll` 로 본다.
expected 가 비면 완료라고 말하지 않는다 — `containsAll(emptySet)` 은 항상 true 라
가드가 없으면 아무것도 안 잰 단계까지 완료가 된다. 옛 6조합 데이터는 두 집합 모두의
상위집합이므로 계속 완료로 읽힌다(과거 데이터가 뒤집히면 간호사가 다 다시 잰다).

## 완료 칩을 초록 → 회색

초록은 "좋은 상태"로 읽혀 조작자가 거기서 멈춘다. 실제 의미는 "이미 받았으니 다음으로
가라"다. 아직 안 받은 칩이 눈에 들어와야 한다 — 남은 일이 어디인지가 그 줄의 존재
이유다. 누를 수는 있게 둔다(파형이 이상해 다시 재는 경우).

## 레퍼런스 알고리즘 스위치 제거

병원 임상의 값은 최종 보고본 하나여야 한다. 스위치가 있으면 화면을 본 사람과 기록을
읽는 사람이 서로 다른 경로의 숫자를 같은 값이라고 믿을 수 있고, 그 착오는 기록의
알고리즘 라벨을 일일이 확인해야만 드러난다. 두 경로 비교는 테스트가 맡는다
(ClinicalBvTest · AlgoPathReportTest).

## 기록

매니페스트에 `abdomen_thickness` / `abdomen_thickness_label`. 조합만 남기면 "왜 1개만
돌았나"를 나중에 답할 수 없다 — 시간이 없어 끊은 것과 얇아서 하나면 됐던 것이 구분되지
않는다. LABDB_DATATYPES.md 에 "한 조건에 세션 1개인 것이 정상"을 박았다.

테스트 114개 통과(진행 판정 12개 전면 재작성). HospitalProgressTest 가 과거 격자를
손으로 적지 않고 HOSPITAL_COMBINATIONS 를 참조한다 — 둘이 갈리면 "과거 데이터는 계속
완료로 읽힌다"는 보장이 거짓이 된다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:33:07 +09:00

13 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 항상 빈 객체. 병원 CSV 에 IMU·배터리·온도 열이 없습니다. 0 으로 채우지 않습니다
channels array 6개 (CH0~CH5)

시각화 요구사항

000(VesiScan 초음파)과 같은 파형 뷰면 충분합니다. 채널 구조가 동일합니다. sensor 가 비어 있으므로 배터리·온도·IMU 위젯은 숨겨 주시면 좋겠습니다.

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


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

데이터 의미

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

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

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

세션 필드

필드 타입 설명
testId string <환자폴더>_align 기반
dataType string "601"
sessionName string <환자폴더>_align
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 측정한 위치 수
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 가 없습니다. 원본 정렬 파일에 시각 열이 없습니다.

⚠️ 서버의 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레코드입니다.