Commit Graph

27 Commits

Author SHA1 Message Date
dw.jang 08f5a182df feat(hospital): 병원 임상·정렬에 IMU 를 남긴다 — 600/601 sensor.imu
병원 경로는 `mtb?` 를 보내면서 **piezo 콜백만** 걸고 있었다. 응답에 딸려 오는
`rim:`(IMU)은 받는 곳이 없어 그대로 버려졌고, 저장된 측정에 IMU 가 하나도 없었다.
labdb 600/601 의 `sensor` 가 "항상 빈 객체"였던 이유다.

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

## 수집 — ImuSidecar
세 루프가 같은 패턴이라 헬퍼로 뺐다. 저장이 있는 두 곳에만 붙인다:
  · HospitalModeView  (600 · 20회 반복)
  · AnchorAlignView   (601 · 0~4cm 탐색 + 확인)
좌우 정렬 루프는 화면 안내용 스트리밍이라 저장이 없어 건드리지 않았다.

응답 순서가 `reb×6 → raa → rim` 이라 IMU 가 나중에 온다. piezo 를 받은 뒤 0.7초
기다리고, 안 오면 비운다. **IMU 가 없다고 측정을 실패로 돌리지 않는다** — 펌웨어·설정에
따라 `rim:` 이 없을 수 있고, 그때 실패로 만들면 기존에 되던 일이 안 되게 된다.

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

  600  <측정파일>_imu.csv              scan_id 로 파형과 잇는다
  601  align_{n}cm_imu.csv
       align_{n}cm_confirm_imu.csv     파형과 같은 confirm 분리 규칙

601 에서 confirm 을 따로 두는 이유는 파형과 같다 — 확인 측정의 IMU 가 판정에 쓰인
탐색 측정 것을 덮으면 안 된다.

## 업로드 — sensor.imu (600·601 같은 모양)
  "sensor": { "imu": [ {ax,ay,az,gx,gy,gz}, … ] }
  ax/ay/az = g · gx/gy/gz = dps

⚠ **없으면 빈 객체다. 0 으로 채우면 안 된다.** 부재가 곧 "그때는 안 쟀다" 이고,
0 으로 채우면 "무중력·완전 정지"로 정반대로 읽힌다. 이전 업로드분 전부가 여기 해당한다.

## 문서
LABDB_DATATYPES.md 의 "sensor 는 항상 빈 객체" 기술을 고치고 IMU 절을 새로 썼다.
labdb 쪽에 필요한 일(저장·뷰어 표시·없음/0 구분·마이그레이션 불필요)과 파생값
(accel_mag·gyro_mag) 계산식을 함께 적었다. 프로토콜 이름과 기존 필드는 그대로라
추가 키뿐이며 기존 파서를 깨지 않는다.

테스트 3건 추가(IMU 유/무 · cycle 분리 · confirm 이 sweep 을 덮지 않음).
601 15건 · 600 11건 전부 통과.

⚠ 실기기 미검증 — 병원 설정(2.3MHz·c3)에서도 `rim:` 이 오는지는 프로브로 확인해야 한다.
  파싱 자체는 dev 임상에서 쓰던 같은 수집기다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:08:26 +09:00
dw.jang 3708ce5f95 fix(labdb): 정렬 확인 측정이 업로드에서 빠져 있던 것 · phase 로 구분
전수 탐색(0~4cm)이 끝나면 고른 위치를 한 번 더 재고, 그 결과는
`align_{n}cm_confirm.csv` 로 따로 쓰인다(판정에 쓰인 데이터를 덮지 않기 위해).
그런데 페이로드의 파일 정규식이 `^align_(-?\d+)cm\.csv$` 로 끝나 **확인 측정
파일이 하나도 잡히지 않았다** — 개발자가 "왜 이 위치인가"를 보려면 고른 자리의
실제 파형이 필요한데 그것이 서버에 없었다.

정규식에 `(_confirm)?` 를 선택 그룹으로 넣고, 레코드에 `phase`("sweep" |
"confirm")를 추가했다. 같은 `align_cm` 으로 두 벌이 올라가므로 phase 가 없으면
받는 쪽이 같은 위치를 두 번 잰 것을 두 위치로 읽는다.

정렬 순서도 (cm 오름차순, 같은 cm 이면 sweep → confirm) 으로 바꿨다. 확인 측정이
실제로 나중에 일어나므로, 받는 쪽이 파일 순서를 시간 순서로 읽어도 어긋나지 않는다.

이 변경은 작업 트리에 이미 있던 것이고(2026-09-18 자 주석), 이번 세션에서 작성한
것이 아니다. `AlignLabdbPayloadTest` 12건을 재실행해 통과를 확인하고 커밋했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:35:33 +09:00
dw.jang 6be4b5a200 fix(labdb): 정렬(601)에 재시도 경로가 없어 조용히 사라지고 있었다
정렬 업로드는 정렬 화면을 떠나는 순간 한 번만 쏘고, 마커를 남기지 않고, "미업로드 전부
올리기"는 `align/` 을 보지 않았다. 세 가지가 겹쳐 **실패하면 흔적도 없이 없어졌다.**

2026-09-10 병원에서 조합 CSV 39건이 망 때문에 실패한 건 화면에 남아 나중에 올릴 수 있었지만,
같은 시각의 정렬은 올라갔는지조차 알 수 없었다. PC 로 데이터를 꺼내 labdb 와 대조해서야
31건이 빠진 것을 찾았다.

## 바꾼 것

  pendingAlign(runDir)     안 올라간 정렬 폴더를 찾는다
  pendingAll()             조합 CSV + 정렬 폴더 (디렉터리면 정렬 한 건)
  uploadAllPending()       둘을 한 버튼으로 올린다
  uploadAlign()            성공/실패 마커를 남긴다

마커는 **자동 재시도가 무엇을 건너뛸지 고를 때만** 본다. `uploadAlign` 자체는 마커를 보지
않으므로 "memo 붙여 다시 보내기"는 그대로 된다 — 원래 의도를 깨지 않으면서 빠진 것을 알 수
있게 하는 것이 목적이다.

raw CSV 가 없는 빈 정렬 폴더는 대상에서 뺀다. 정렬 화면에 들어갔다 아무것도 재지 않고 나오면
빈 폴더만 남는데(실측 `2026-09-10_unnamed_123015`), 그걸 올리려 들면 영원히 실패한다.
확인 측정(`align_Ncm_confirm.csv`)도 raw 로 세지 않는다 — 페이로드에서 빠지는 데이터라
그것만 있는 폴더는 올릴 것이 없다.

화면은 이미 pendingAll()/uploadAllPending() 만 쓰므로 따로 손대지 않았다.

## 배포 전 주의

폰에 마커가 없으면 이 변경은 **이미 올라간 것까지 전부 다시 올리려 한다.** PC 가 올린 세션은
다른 deviceId 소유라 서버가 409 SESSION_ID_CONFLICT 로 거절하므로 영구 실패로 남는다.
그래서 labdb 에 있는 것(조합 111 · 정렬 34)에 대해 폰 쪽 마커를 먼저 심었다. 마커의
`verified` 필드에 근거를 적었다 — `labdb_listing` 은 목록에서 직접 확인, `upload_conflict`
는 409 로 존재만 확인(레코드 수는 대조 못 함).

테스트 8개 추가 (전체 151, 실패 0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:03:43 +09:00
dw.jang a7d7cf469f fix(labdb): 미업로드를 전 폴더에서 올린다 · 업로드가 화면과 함께 죽지 않게
2026-09-10 병원에서 600 업로드 24건이 실패했고, 앱을 다시 켜자 **올릴 길이 없어져** PC 로
꺼내 올려야 했다(39건). 사유가 셋이었고 그중 하나는 우리 쪽 결함이다.

    12건  DNS 해석 실패            망
     6건  TLS 인증서 신뢰 실패      망 (LTE 에서는 정상 접속 — 병원 망의 검사 프록시로 보인다)
     6건  화면 이탈로 업로드 취소    **앱 결함**

## ① 화면을 떠나면 업로드가 잘렸다

측정 루프의 `launch { upload(file) }` 가 `LaunchedEffect` 스코프였다. 조합이 끝나고 화면을
떠나면 전송이 취소된다 — `The coroutine scope left the composition`. 정렬(601)은 같은 이유로
이미 오브젝트 스코프로 옮겼는데(8b3b5c0) **프로토콜(600)만 남아 있었다.**
`uploadAsync()` 를 만들어 옮겼다.

## ② [지금 업로드] 의 사정권이 한 폴더뿐이었다

`lastRunDir` 은 **이번 실행**의 폴더이고 `remember` 다. 그래서:

  · 앱을 다시 켜면 null → **카드 자체가 사라진다** ← 39건이 고립된 직접 원인
  · 폴더 하나만 훑음 → 어제 것은 6개 폴더에 흩어져 있었다

`allRunDirs()` · `pendingAll()` · `uploadAllPending()` 을 만들고, 화면은 진입할 때 전 폴더를
훑는다. 버튼도 **[미업로드 전부 올리기]**, 문구도 "미업로드 N건 (지난 측정 포함)".

매니페스트는 폴더마다 다르므로 `manifestParamsFor(csv)` 가 그 CSV 와 같은 폴더의 run json
에서 찾는다. 못 찾으면 null — CSV 헤더만으로도 페이로드는 만들어진다.

## ③ 망 오류 문구를 사람 말로

Java 예외를 그대로 흘리고 있었다(`Unable to resolve host ...`,
`CertPathValidatorException: Trust anchor ...`). 조작자가 할 일이 원인마다 다른데 알 수
없었다. **둘은 망을 바꾸면 되는 것이었다** — LTE 에서는 정상 접속된다.

    TLS 차단   "보안 연결이 차단되었습니다 — 이 네트워크가 통신을 검사하고 있을 수
                있습니다. 다른 망(LTE·테더링)으로 바꾼 뒤 [미업로드 전부 올리기] 를…
                데이터는 폰에 그대로 있습니다."
    DNS 실패   "서버 주소를 찾을 수 없습니다 — 인터넷이 끊겼거나 이 망이 외부 접속을…"
    시간 초과   "서버 응답이 없습니다 — 잠시 뒤 다시…"

**인증서 검증을 느슨하게 하는 선택은 하지 않았다.** 의료기기에서 그건 보안 후퇴다. 대신
무엇을 해야 하는지 말해 준다 — "측정은 병원에서, 업로드는 망이 되는 곳에서"가 성립하려면
②가 있어야 하고, 이제 있다.

테스트 143개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:41:00 +09:00
dw.jang b71a04374f feat(clinical): 전수 판정을 같이 계산해 기록 · 확인 측정이 판정 데이터를 덮지 않게
알고리즘 설계자가 물었다 — "0cm·1cm 만 쟀는데 0cm 가 최적으로 나왔다. 만약 3cm 가
최적이면?"

## 이건 버그가 아니라 설계 가정이다

단일 pass 는 **지표가 cm 에 대해 단봉**이라고 가정한다. 올라가다 나빠지면 봉우리를 지난
것으로 보고 멈춘다. Python 도 같다. 물리적 근거는 있다 — CH3 은 제일 기울어진 채널로
방광 아래쪽을 보므로 위로 가면 점점 벗어난다.

깨질 수 있는 경우는 **한 위치만** 일시적으로 나빠지는 것이다: 장내 가스가 CH3 을 가림,
접촉이 뜸, 호흡으로 방광이 움직임. 그러면 더 위가 진짜 좋았는데 조기 종료한다.

실측에 징후가 있다. 2026-09-09 `kai` 는 **0cm ch3=X → 1cm ch3=O 91%** 로 지표가
올라갔다. 시작 쪽은 종료 조건이 "앞에 eligible 이 있어야" 걸리게 막혀 있지만 **중간에서는
한 칸만 나빠도 끝난다.** 같은 날 `181128` 이 정확히 그 경우다 — 1cm 에서 nch 2→0,
ch3 100%→0% 로 두 조건이 동시에 걸려 best=0cm, 2cm 이후는 모른다.

## 판정은 바꾸지 않고 **측정 가능하게** 만든다

규칙을 바꾸면 레퍼런스 대조(단일 pass 기준)를 다시 해야 한다. 그건 알고리즘 팀이 숫자를
보고 정할 일이다. 그래서 전 위치로 고르면 어디가 뽑히는지 **같이 계산해 기록한다**:

    "best_cm": 0,                  앱이 쓰는 값 (단일 pass · 레퍼런스)
    "best_cm_full_sweep": 3,       잰 위치 전부로 고르면
    "sweep_disagrees": true

갈리면 화면에도 참고 문구가 뜬다 — 그 자리에서 "더 위가 좋아 보인다"를 알면 다시 정렬을
택할 수 있다. 어제 전수 측정으로 바꾼 것이 이 답을 내는 전제였다(조기 종료하면 비교할
위치가 없다).

## 확인 측정이 판정 데이터를 덮고 있었다

부착 위치로 내려가 한 번 더 재면 `align_2cm.csv` 를 **덮어썼다.** 화면 판정은 먼저 잰 것
기준인데 파일은 나중 것이라, 파일로 재판정하면 앱과 답이 갈릴 수 있다. 조기 종료 시절에는
드물었지만 전수 측정으로 바꾼 뒤에는 **항상** 일어난다.

`align_2cm_confirm.csv` 로 분리했다. 판정 근거가 파일에 그대로 남는다.

테스트 139개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 12:05:16 +09:00
dw.jang 8b3b5c0f0a feat(labdb): 정렬(601)도 자동 업로드 — 화면을 떠나는 순간
정렬 업로드만 수동이었다. 그 버튼의 원래 용도가 **"파형이 이상할 때 개발자에게 보내기"**
(메모를 받는 다이얼로그)라, 정상적으로 정렬을 끝내면 601 이 아예 안 올라갔다.

2026-09-09 실기기에서 4위치 80 record 가 폰에만 남아 있었다. 600·001 은 자동인데 601 만
빠져 있어, 같은 세션의 "어디에 붙였나"가 서버에 없는 상태가 된다 — 그 근거가 없으면
600 의 BV 를 위치와 묶을 수 없다.

## 버튼마다 넣지 않고 onDispose 한 곳에서

출구가 넷이다: 뒤로 화살표 · 시스템 뒤로 · [이대로 임상 측정 진행] · 기기 연결 화면으로
이동. 하나만 빠뜨려도 그 경로로 나간 세션은 조용히 사라진다. DisposableEffect 하나로
전부 덮는다.

## 화면 수명과 무관한 스코프가 필요했다

`rememberCoroutineScope` 로 띄우면 화면을 떠나는 순간 취소된다. 그런데 올릴 자연스러운
시점이 **바로 떠나는 순간**이라, 그 스코프로는 영원히 못 올린다.
`HospitalLabdbUploader.uploadAlignAsync` 를 오브젝트 스코프(SupervisorJob + IO)에서 돌린다.
결과는 기존 uploadingName·lastMessage 로 나가므로 다음 화면(병원 모드 카드)이 그대로 읽는다.

## 같은 폴더를 다시 올려도 안전하다

testId 가 `<저장이름>_align` 으로 고정이고 labdb 가 testId+rowIndex 로 멱등이라, 바뀐 것만
반영되고 나머지는 중복으로 센다. 그래도 dirty 깃발을 둔다 — 화면을 오가며 여러 번
들어올 수 있고, 변한 게 없는데 매번 0.3MB 를 보내면 labdb 분당 제한(10건)을 정렬 하나가
먹는다.

## 수동 버튼은 남긴다

용도가 다르다. 이쪽은 **메모를 붙여 지금 보내는** 길이다 — 파형이 이상할 때 개발자가
바로 보고 답해야 하므로 화면을 떠날 때까지 기다릴 수 없다. 주석으로 그 구분을 박았다.

테스트 129개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:14:02 +09:00
dw.jang 1c979fc26b fix(labdb): 병원 임상 화면에 등록 카드 · 임상 세션에 최상위 subject
## 왜: 실기기에서 데이터가 조용히 안 올라갔다

2026-09-09 A15 에서 임상 세션 3개(70 cycle)와 정렬 1세트(80 record)를 받았는데 labdb 에
하나도 없었다. 폴더를 열어 보니 **성공 마커도 실패 마커도 없었다** — 업로드를 시도한
기록 자체가 없다.

    ClinicalSessionStore:273   if (creds.isRegistered) uploadAsync(...)
    HospitalLabdbUploader:80   if (!isRegistered) return false to "미등록"

둘 다 isRegistered 뒤에 있어 미등록이면 아무 일도 일어나지 않는다. 그런데 등록 버튼은
임상 측정 홈(ClinicalHomeView)에만 있고, **병원 임상 모드는 홈에서 직접 들어가** 그곳을
거치지 않는다. 게다가 병원 모드의 업로드 카드가 `isRegistered && lastRunDir != null` 로
감싸져 있어, 미등록이면 화면에 **아무것도 없었다.** 조작자는 "이 화면에는 업로드 기능이
없다"로 읽는다.

## LabdbStatusCard — 측정 **전에** 보인다

환자명 위, 화면 제일 앞에 둔다. 20 cycle × 조합을 다 받은 뒤에 알면 그 데이터는 폰에만
남고 방광을 비운 뒤라 다시 잴 수 없다.

    미등록            경고색 + [labdb 등록] 버튼
    승인 대기 pending  경고색 — **"등록했다"와 "올라간다"는 다르다**
    활성 active       초록 — 조합마다 자동 업로드

승인 대기를 초록으로 두지 않은 것이 핵심이다. 버튼을 누른 것만으로 끝났다고 읽으면 같은
일이 반복된다. 진입 때 checkStatus 를 한 번 불러, 승인이 떨어졌는지·기기가 삭제됐는지
(404 면 자격이 비워진다) 그 자리에서 보인다.

업로드 카드는 isRegistered 를 떼고 lastRunDir 만 본다. 미등록일 때도 **몇 건이 못
올라갔는지**는 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로. 다만
버튼은 막는다(미등록으로 누르면 실패 마커만 쌓여 나중에 성공 여부가 헷갈린다).

## 최상위 subject

서버(server.js:650)는 **최상위 `data.subject` 만** 읽어 `sessions.subject` 컬럼에 넣는다.
앱은 `params.subject` 에만 넣고 있었다 — 서버 담당자가 지적한 "기존 72세션의 subject 가
전부 비어 있다"의 원인과 같은 버그다. 그 컬럼이 export 파일명 prefix 와 세션 목록 표시에
쓰이므로, 비면 분석하는 쪽이 testId 를 눈으로 파싱해야 한다.

params 안에도 그대로 둔다. 이미 올라간 세션들이 그 자리에 있어, 빼면 과거·신규를 같은
규칙으로 읽을 수 없다. 600/601 이 이미 같은 처리다.

테스트 121개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:30:24 +09:00
dw.jang 7926b61667 feat(clinical): 수동 확인을 폰에 기록 — CSV(분석) + JSON(보관)
구분선 아래 1회/연속 측정은 화면에만 떴다가 사라졌다. 검증 항목 ③(위치 민감도 —
부착 위치에서 상·하·좌·우 1~2cm 옮겨 비교)이 바로 그 측정으로 하는 일인데, 남는 게
없으면 간호사가 종이에 받아 적어야 한다.

labdb 에는 올리지 않는다(사용자 결정). 저장 위치:

    Downloads/VesiScan_Hospital/<날짜_환자>/manual/
      manual_2026-09-09.csv          하루치 이어 붙임 — 행 하나 = 한 번의 확인
      manual_2026-09-09_143052.json  누를 때마다 하나 — 6채널 원신호 + 채널별 벽

**하위 폴더인 것이 중요하다.** HospitalLabdbUploader.pending 과
HospitalRunStore.scanProgress 는 둘 다 폴더 최상위만 훑으므로, 여기 둔 CSV 가 600 으로
올라가지도 않고 진행 판정에 끼지도 않는다. 600 은 정확도 검증의 모집단이라, 조작자가
아무 자리에서 아무 때나 누른 값이 섞이면 "정해진 조건에서 받은 데이터"라는 전제가 깨진다.

## 자동 저장하지 않는다

연속 모드는 초당 두 번 값을 낸다. 자동으로 남기면 의미 없는 행이 수백 개 쌓여 정작
비교하려는 위치별 값이 묻힌다. 조작자가 "이 자리 값은 남길 것"이라고 판단한 순간만
[이 확인 기록] 으로 남긴다.

## 위치를 자유 문구로 받지 않는다

ManualCheckStore.Offset 9개(기준 · 위1·2 · 아래1·2 · 좌1·2 · 우1·2)로 고정하고, 메모는
그 옆에 따로 받는다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀 나중에
묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면 데이터가 아니라
메모가 된다. 3열 격자로 두어 방향이 눈에 보이게 했다(드롭다운은 잘못 고른 것도 못 본다).

## 대표값은 화면과 같은 식이어야 한다

파일에 산술평균이 들어가면 보고서를 쓰는 사람이 화면을 믿어야 할지 파일을 믿어야 할지
알 수 없다. 두 trimmedMean 을 테스트에서 직접 맞댔다.

## 저장 실패를 조용히 넘기지 않는다

save 가 null 이면 빨간 글씨로 띄운다. 조용히 넘기면 조작자는 기록됐다고 믿고 다음
위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다.

BvMeasureSection 이 supine:Boolean 대신 posture·fill·patient·anchorCm·anchorBasis 를
받는다. CSV 한 행만 보고 "어떤 상태에서 잰 값인가"에 답할 수 있어야 한다.

테스트 121개 통과(신규 7). 메모의 쉼표·따옴표·줄바꿈이 열을 밀지 않는지, 오프셋 기록
문자열이 고정인지를 잡았다 — 쉼표 하나가 그 행의 뒷열을 전부 한 칸씩 밀어 CSV 가
조용히 어긋나는 것이 제일 찾기 어렵다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:59:22 +09:00
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
dw.jang 2770d0296a 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>
2026-09-09 10:41:04 +09:00
dw.jang afeb3acc57 fix(labdb): 902 가 정렬 요약을 통째로 보내게 한다
params 를 화이트리스트로 채우고 있었는데, 그 목록에 없던 두 필드가 조용히 빠져 있었다:
  · patient_unnamed — 환자명 없이 잰 데이터인가
  · selection_verified_against_reference — 앱 판정이 레퍼런스와 대조 완료인가

요약에 필드를 추가할 때마다 여기도 고쳐야 하는데 안 고쳐도 아무 신호가 없다. 이 세션은
"파형이 이상하니 봐 달라"는 진단 요청이라 **덜 보내는 쪽이 위험**하다 — 개발자가 되물어야
하고 그 왕복이 임상에서는 하루다.

align_result.json 을 통째로 옮기고, 이미 다른 이름으로 넣은 것(patient→subject,
save_name)만 건너뛴다. 요약의 모든 키가 params 에 도달하는지 테스트로 고정했다.

문서도 함께 고쳤다. positions[] 표에서 ch3_hit·ch3_tot·eligible 이 빠져 있었고
(실제로는 올라가고 있었다), params 표에 위 두 필드를 추가했다. 표에 없는 키가 보여도
정상이라는 설명도 넣었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:12:17 +09:00
dw.jang 3d8168a0f5 feat(clinical): 정렬 파형을 간호사 판단으로 labdb 에 보낸다
파형이 이상해 보일 때 그 자리에서 개발자에게 보내는 통로가 없었다. 임상이 끝난 뒤
파일을 꺼내 보내면 회신이 하루 뒤인데, 그때는 이미 그 환자로 다시 잴 수 없다.

정렬 전용 dataType 902 를 새로 쓴다. 임상 측정(901)과 구조가 다르다 — 저쪽은 한
조건에서 20 반복이고 정렬은 여러 위치 × 20 cycle 이다. 같은 코드로 올리면 params
만으로는 구분이 안 되고 콘솔이 두 종류를 한 차트로 그린다. labdb 는 dataType 마다
records[] 스키마가 완전히 달라도 되고(labdb.md §2-3), 900~999 가 사용자 정의 구간이다.

  record = 한 위치의 한 cycle
    rowIndex  : 위치를 가로질러 0..N 연속 (labdb 규약)
    align_cm  : 그 cycle 을 잰 위치
    cycle_idx : 그 위치 안에서의 순번
    channels  : 6 × {ch, peak, peakIdx, data}

**판정 근거(align_result.json)를 params 에 통째로 넣는다.** 개발자가 답해야 할 질문이
"왜 이 위치를 골랐나"라서 파형만 오면 되묻게 된다 — 지표(nch·ch3·cap_frac)와 선택
결과, 좌우 결과가 같이 가야 한 번에 답이 나온다.

**자동이 아니다.** 간호사가 버튼을 눌러야 올라간다. 목적이 "이상하니 봐 달라"는
요청이라 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다. 무엇이 이상해 보였는지
한 줄을 받아 memo 로 보낸다 — 그게 없으면 "이게 왜 올라왔지"부터 시작한다.
params.upload_reason 에 사람이 올린 것임을 남겨 자동 업로드분과 섞이지 않게 했다.

마커는 남기지 않는다. 같은 정렬을 다시 보낼 수 있어야 한다 — 처음에 못 적은 증상을
memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면 갱신한다.

판정이 끝나기 전에도 보낼 수 있다. 첫 위치만 재고 이상하다 싶은 때가 오히려 물어볼
상황이다 — 요약이 없으면 파형만 보낸다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:11 +09:00
dw.jang 8bf7f54b72 feat(clinical): 병원 임상 측정을 labdb 로 자동 업로드
병원 임상 모드에는 labdb 업로드 경로가 **아예 없었다**. HospitalRunStore 는 Downloads 에
파일만 쓰고 업로더를 한 번도 부르지 않는다. 업로드는 ClinicalSessionStore.finalize 한
곳에만 붙어 있고 그건 병원 모드가 쓰지 않는 경로다. 2026-09-05 에 279건을 올릴 때
파이썬 변환기를 따로 만들어야 했던 이유가 이것이다.

포맷도 다르다. 기존 업로더는 measurement.json(cycles 배열)을 읽는데, 병원 모드는 CSV
한 행이 1 반복 × 1 채널이고 조건별로 파일이 갈린다. 그래서 그때의 변환 규칙을 앱으로
옮긴다 — 모양이 달라지면 labdb 에 같은 프로토콜 데이터가 두 형태로 쌓이고 분석하는
쪽이 두 벌을 만들게 된다.

  CSV 파일 1개   = 세션 1개 (자세·충만도·주파수·cycle 고정 20 반복)
  CSV 6행(CH0~5) = record 1개 → channels[0..5].data = s0..s99
  rowIndex       = repeat_idx

**조합이 끝날 때마다** 올린다. 한 바퀴를 다 돌 때까지 기다리면 중간에 앱이 죽거나
자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다. 단위도 자연스럽다 — labdb 는 요청
1건 = 세션 1개 = record N개이고, 20 반복이 곧 한 요청이다. 측정 1회마다 올리면 한
바퀴가 120건이 되어 분당 10건 제한에 걸린다.

**오프라인이 정상 경로다.** 병원 무선망이 불안정한 곳이 많아 "나중에 올리기"가 예외가
아니다. 성공/실패를 CSV 옆 마커 파일로 남겨 앱이 죽어도 디스크만 보면 무엇이 남았는지
알 수 있게 했다 — 상태를 메모리에 두면 이 기능의 요점이 사라진다. 화면에 "미업로드
N건"과 [지금 업로드] 를 두고, 밀린 것을 올릴 때는 요청 사이를 7.5초 띄운다(분당 ~8건).

한 건이 실패해도 멈추지 않는다. 망이 잠깐 끊긴 것과 그 파일이 잘못된 것을 구분할 수
없는데, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패 마커가 남아 다음에 다시 시도된다.

dataType 은 901(사용자 정의 구간)이다. 병원 프로토콜용 코드를 관리자에게 배정받으면
HospitalLabdbPayload.DATA_TYPE 만 바꾸면 된다 — 279건도 같은 값으로 올라가 있다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:50:27 +09:00
dw.jang c7cddbf9c9 fix(clinical): 조건 완료 판정을 실행 단위 → 조합 단위 합산으로
화면에서 6조합 중 일부만 골라 돌릴 수 있게 되면서(다음 커밋) 판정이 깨진다. 종전에는
실행 하나만 보고 "6조합이 다 있나"를 따졌는데, 나눠 재면 어느 실행도 6개를 못 채워
**영원히 PARTIAL** 로 남는다. 그러면 "덜 됐다"는 표시가 의미를 잃는다.

같은 자세·충만도의 여러 실행을 조합 단위로 합산한다. 조합 하나가 여러 실행에 나오면
한 번이라도 계획을 채운 실행이 있으면 그 조합은 끝난 것으로 본다 — 20회 중 3회에서
끊긴 뒤 다시 20회를 채웠다면 완료다.

판정 전부를 progressFrom(manifests) 순수 함수로 떼어 시험으로 고정했다. 이 판정이
틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고, 그 단계는 그날 다시
못 잰다. 실기기 없이도 고정해 둬야 한다.

  나눠 재기 · 재측정 · 계획 미달 · planned 없는 옛 매니페스트 · 깨진 JSON ·
  조건 간 혼선 8건.

org.json 은 안드로이드 단위테스트에서 모든 메서드가 스텁이라(isReturnDefaultValues
로도 안 된다 — 반환값이 아니라 동작이 없다) 실제 구현을 테스트 의존성으로 넣었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:50:29 +09:00
dw.jang 2126aa9361 feat(clinical): 부착 위치 정렬(AnchorGuide) — 임상 프로토콜 전 단계
piezo-phantom-test `vesiscan_test` 의 단일 pass 정렬을 이식한다. 규칙은
`alignment_selection.select_supine_anchor`(tie='cap'), 절차는
`alignment_runners.AnchorGuide`, 계약은 `porting/SPEC.md` §8.

- managers/AnchorGuide.kt  알고리즘 코어(측정·종료판정·선택 위임·안내)
- ui/.../AnchorAlignView.kt 위치별 측정 화면 + 원시 데이터 저장
- HospitalRunStore        align/ 폴더에 위치별 raw cycle + 결과 요약
- 병원 모드에 정렬 카드 · 매니페스트에 anchor_cm

기존 AlignmentAdvisorV3 는 같은 저장소의 **구 알고리즘**(2-pass CenterAligner ·
bvcv tie · 오프셋 없음)이고 호출처가 0이라 그대로 두었다. Python 쪽에서도 이미
주석 처리됐다. 제품 경로는 새 파일이다.

## 검증
- 선택 규칙: Python 무작위 200 케이스 전수 대조 **200/200 일치**
  (CH3 2단 게이트 · fallback · NaN=+inf · 동률 min cm 경계 포함)
- trace 분할: 22 cycle · win 10 → 11 trace, Python 과 일치
- 실기기: 20 cycle 수집 → 판정 → 안내 → 저장까지 완주 확인

## 알려진 불일치 — 화면에도 적었다
앱의 **벽 검출**(MethodDRunner)이 현재 Python 레퍼런스와 어긋난다. 실측
(HUMAN-kai VBT26050202, v1):
  cm=0 CH2 ant 13.913 vs 18.559 · span 24~61 vs 33~60
  cm=1 CH1 ant  4.486 vs 13.188 · CH3 는 Python 검출 / 앱 미검출
  cm=1 판정  nch=4 ch3=O  vs  nch=3 ch3=X
  cm=0 cap_frac 0.63899 vs 0.67479 · BV 410.6 vs 470.5 mL
cm=1 은 레퍼런스에서 유력 후보인데 앱에서는 후보 자격조차 없다. 정렬 선택이 이
값들 위에 서 있으므로 **앱의 추천 위치는 아직 레퍼런스의 답이 아니다.**

그래서 (1) 화면에 검증 전임을 명시하고, (2) 위치별 raw cycle 을 반드시 남긴다
(align_{n}cm.csv · Python 대조 스크립트가 바로 읽는 형식). 앱 판정이 틀려도
나중에 Python 으로 다시 고를 수 있어야 그 환자를 다시 부르지 않는다.
AnchorMeasureParityTest 는 이 불일치를 표로 기록한 채 @Ignore 로 격리했다 —
기대값을 코틀린 값으로 낮추면 대조 시험의 존재 이유가 사라진다.

BV **코어** 자체는 맞다. 벽을 고정해 넣으면 Python estimate_bv
(ellipse_cap_height=true · cap_fit='specific')와 마지막 자리까지 일치한다.

## 곁가지
- gradle.properties 에서 -Dfile.encoding=UTF-8 제거. 한글 사용자 폴더에서
  테스트 워커가 클래스패스를 못 찾아(GradleWorkerMain) **단위시험이 통째로
  실행되지 않고 있었다**. 신 저장소에서 같은 원인을 이미 확인했다.
- 환자명을 AppState 로 올렸다. 정렬 화면을 다녀오면 remember 가 초기화돼,
  이름을 다시 치는 순간 "환자가 바뀌었다"로 보여 방금 맞춘 정렬이 지워졌다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:57:17 +09:00
dw.jang 5a7e2cb918 feat(clinical): 병원 임상 모드 — 어디까지 측정했는지 칩에 표시
간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나
순서가 꼬이면 "0% 했던가?"를 기억으로 판단하게 된다는 현장 의견. 그러면 한
단계를 통째로 빠뜨리거나(방광을 다시 채워야 한다) 같은 조건을 두 번 재게 된다.
화면이 대신 기억하게 한다.

- 방광 단계 칩: 지금 고른 자세 안에서의 진행. 완료 ✓(초록) / 일부 ·(주황 테두리)
- 자세 칩: 그 자세의 6단계가 전부 끝나야 완료
- 실행이 끝날 때마다 다시 읽어 방금 잰 단계가 즉시 채워진다

판정 근거는 **파일 존재가 아니라 실행 매니페스트**다. CSV 는 첫 회차만 저장돼도
만들어지므로, 파일 유무로 보면 20회 중 2회에서 끊긴 것도 "완료"로 보인다. 그게
제일 위험하다 — 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고 그 단계는 그날
다시 못 잰다. 매니페스트의 조합별 planned/saved 로 "6조합이 전부 계획한 횟수를
채웠는가"를 본다. 중단된 실행도 매니페스트가 남으므로 PARTIAL 로 잡힌다.

완료/일부를 색뿐 아니라 기호(✓ ·)로도 구분한다 — 병실 조명·화면 반사에서
색만으로는 잘 안 보인다.

검증: 실기기(VBT26080001 연결)에서 오늘 09:40 실측 데이터로 확인.
(Supine, 0%) → 0% 칩 ✓ · Supine 칩 · (6단계 중 1단계라 일부). 선택을 20% 로
옮기면 0% 가 초록 채움으로 남는 것까지 확인. 측정 경로는 건드리지 않았다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:04:26 +09:00
dw.jang 53fd07c342 feat(clinical): 병원 임상 측정 모드 — 주파수×cycle 6조합 자동 순회
## 조작자가 하는 일은 하나다
간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
[측정 시작]을 누른다. 이후 주파수 2종 × cycle 3종 = 6조합을 앱이 자동으로 순회하며
각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.

조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 다시
만들 수 없으니, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. 사람이 개입하는
지점을 하나로 줄인 것이 이 모드의 핵심이다.

  진입: 홈에서 캐릭터 3연타 → 개발자 모드 → [병원 임상 측정]

## 프로토콜
  자세      Supine / Sitting / Standing (기존 ClinicalPosture 재사용)
  주파수    1.8 · 2.3 MHz          → mpa? 첫 인자
  cycle     3 · 5 · 7              → mpa? 둘째 인자
  방광 채움 0/20/40/60/80/100 %    → 사람이 아는 값 = 정답 라벨
  반복      화면 입력 (기본 20)

## 저장
  Downloads/VesiScan_Hospital/{날짜_환자}/
    2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
    run_HHmmss.json   ← 계획 대비 실제 저장 수

파일명에 조건을 전부 적는다. 폴더로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는데,
임상 데이터는 나중에 다른 사람이 모아서 분석한다. CSV 열 구성은 기존 AdcCsvLogger 와
맞춰(scan_id·timestamp·channel·s0~s99) 분석 스크립트를 새로 만들지 않아도 되게 했다.

기존 임상 R&D(`VesiScan_Sessions/`)와 폴더를 나눴다 — 목적도 구조도 달라서 섞이면
나중에 파일을 하나씩 열어 봐야 한다.

## ⚠ 주파수 코드값이 확인 전이다
`mpa?` 첫 인자는 정수인데 그 정수와 MHz 의 대응이 코드에도
docs/BLE_PROTOCOL_REFERENCE.md 에도 없다. 기존 코드는 늘 `freqOption = 2` 만 썼다
(PlacementGuideView · MeasurementService). 그래서 1.8→1, 2.3→2 는 **가정**이다.

확인 전에 모은 데이터가 버려지지 않도록, 실제로 보낸 정수를 파일명(`fopt1`)과 CSV 열
(`freq_option`), run json 에 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꾸면 된다.
run json 에 `freq_option_mapping_confirmed: false` 를 박아 두어 나중에 이 데이터가
어떤 상태에서 모였는지 알 수 있게 했다. 화면에도 같은 경고를 띄운다.

## 데이터 정합성
결과를 평범한 var 로 주고받으면 BLE 스레드↔코루틴 간 가시성이 보장되지 않아 Channel
을 쓴다. 그리고 **회차마다 보내기 전에 채널을 비운다** — 직전 회차가 시간초과된 뒤
뒤늦게 도착한 결과가 남아 있으면 이번 회차 데이터로 잘못 기록된다.

한 회차가 3초 안에 안 오면 실패로 세고 다음으로 넘어간다. 한 번 막혔다고 전체가
멈추면 채워 둔 방광을 버리게 된다. 실패 수는 화면과 run json 에 남는다.

## 검증
빌드 통과. **실기기 확인은 아직 못 했다** — 폰이 절전 상태로 들어가 화면을 못 띄웠고,
프로브 연결 상태의 측정 루프는 전혀 돌려보지 못했다. 내일 임상 전에 반드시 한 번
돌려봐야 한다(6조합 × 20회 = 120측정 · 약 2~3분 예상).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:53:47 +09:00
dw.jang f025bbc0a3 feat(longrun): 장시간 측정 중단 감지 + 설정에 준비 점검 항목
같은 빌드가 태블릿 34시간 / Xiaomi 40분 이었던 문제 대응.
앱 구조(Foreground Service + connectedDevice + WakeLock)는 이미 맞았고
제조사 배터리 관리자가 프로세스를 죽인 것이라, 구조 변경이 아니라
감지와 안내로 접근한다.

- LongRunGuard: 측정 시작/생존/정상종료를 기록해 비정상 종료를 감지.
  다음 실행 때 "언제부터 언제까지 몇 분" 을 알려준다. 제조사 무관하게 동작.
- BLE 로그 헤더에 기기/제한 상태 진단줄 (실패 후 원인 판별용).
- 설정 패널에 "장시간 측정 준비" — 배터리 최적화·미사용앱 제한 상태 확인 및
  해제 진입. 자동시작은 상태를 읽을 수 없어 안내만 제공.

측정 시작을 모달로 막지 않는다. 초안에서는 Auto Scan 에 사전점검을 걸었는데
OEM_AUTOSTART 를 제조사 이름만으로 판단해서(상태 조회 API 가 없음) 설정을
완벽히 해둔 기기에도 영원히 뜨는 문제가 있었다. 고칠 수도 없는 항목으로
측정을 가로막으면 경고가 무시된다. 추측으로 경고하지 않고, 실제로 중단됐을
때만 알린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:13:46 +09:00
dw.jang a86fefe321 fix(ble-log): 앱 시작~종료를 한 파일에 통으로 자동 저장
Downloads 에 VesiScan_BLE_*.log 가 여러 개 생기고 그마저 내용이 비던 문제.
원인이 셋이었다.

1. 재연결마다 파일이 갈라짐
   connected() 가 liveLogFile=null 로 핸들을 버려서 BLE 가 끊겼다 붙을 때마다
   새 파일이 생겼다. 앱 시작~첫 연결 구간도 별도 파일로 빠졌다.
   → 프로세스 1회당 파일 1개. 재연결 시에는 구분선만 남긴다.

2. 파일 쓰기에 동기화가 없음
   rx() 는 BLE 콜백 스레드, 나머지는 UI 스레드에서 호출되는데 매번 파일을
   새로 열어 쓰며 잠금이 없었다. 동시 쓰기로 줄이 섞이거나 유실됐다.
   → synchronized + 핸들 유지, 줄마다 flush (강제 종료에도 그 시점까지 보존).

3. 저장 실패가 조용히 묻힘
   Downloads 쓰기가 막히면 예외를 삼켜 아무 데도 안 남았다.
   → 내부 저장소로 폴백하고 실제 경로를 파일 헤더에 기록.

추가로 두 가지:

- Export 버튼이 메모리 링버퍼(2000줄)를 덤프해 "마지막 몇 분"만 나오던 것을
  실시간 세션 파일을 그대로 내보내도록 변경.
- disconnected() 가 비정상 종료 경로에서만 호출돼, 사용자가 직접 끊으면
  아무 기록 없이 로그가 끊겼다. 이유(user/unexpected/gatt_error)를 구분해
  항상 남기도록 수정.

임상 모드 step 별 ble.log 는 부가 사본으로 그대로 유지한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:54:38 +09:00
dw.jang 1ce19949c9 chore(cleanup): NIRS/VIVAMYO 세트 완전 제거 (Category A · 사용자 요청)
배경: NIRS 는 초기 iOS 앱에서 이식했으나 현재 완전 미사용. Piezo 만 실사용.

완전 제거 (6 파일):
  · managers/NirsManager.kt (94 L)
  · managers/NirsCalcEngine.kt (206 L)
  · models/NirsConstants.kt (49 L)
  · models/NirsData.kt (106 L · NirsMcjPacket · NirsMagResponse · NirsOxyResult)
  · ui/views/monitoring/VivamyoMonitoringView.kt (497 L)
  · ui/views/calibration/CalibrationView.kt (216 L) + calibration/ 디렉토리

부분 편집:
  · AppState.kt      — NirsManager import · NIRS_CALIBRATION/VIVAMYO_MONITORING enum ·
                       calibrationComplete() · sensor 분기 3곳 (goHome/enterDemoMode/
                       deviceConnected/backToMonitoring) 모두 PIEZO 로 단순화
  · MainActivity.kt  — CalibrationView/VivamyoMonitoringView import + Crossfade branch +
                       back-handler branch 삭제
  · BleManager.kt    — 콜백 4개 (onNirsPowerOn/SensorActivated/MagReceived/McjReceived) ·
                       sendRawWrite when-branch 4개 · NIRS Commands 섹션 5 함수 ·
                       RX branch 4개 삭제
  · MeasurementService.kt — performNirsMeasurement 제거 · performMeasurement 단순화 ·
                       stop() 콜백 정리 · SensorMode 유지 (센서 추가 대비)
  · SensorMode.kt    — NIRS enum value 삭제 · PIEZO 만 남음 · UserStorage 는
                       valueOf 실패 시 PIEZO fallback 이라 마이그레이션 불필요
  · SensorSelectView.kt — NIRS SensorCard 제거
  · strings.xml × 2  — sensor_nirs_desc · calibration_* 10개 · vivamyo_* 17개 삭제
                       (calibration_continue 는 PiezoPersonalization 사용 → 유지)

합계: 약 1,300 LOC 감소.
빌드: BUILD SUCCESSFUL 17s.
2026-08-12 15:38:59 +09:00
dw.jang 253312b4b0 fix(logs): §C+E · purge dead code 활성화 + record() 통합 관문 (cloud-mvp d8425d0 이식) 2026-08-04 16:18:07 +09:00
dw.jang 4d8ada0634 fix(logs): §D · cyclesBuffer 무한 누적 → cycles.jsonl 스트리밍 (Priority 2)
배경 (LOG_STORAGE_ANALYSIS §D):
  ClinicalSessionStore.cyclesBuffer 는 endMeasurement() 호출 전까지 모든 사이클을
  메모리 보관. MeasurementCycle 1개 ≈ 16KB · autoCapture 600ms → 시간당 ~90MB 힙.
  장시간 임상 세션 OOM 위험.

수정:
  cyclesBuffer (MutableList<MeasurementCycle>) 완전 제거 · cycleCounter (Int) 로 대체.
  addCycle / addAlignmentFrame 은 이제:
    1. MeasurementCycle 생성
    2. appendCycleToStream() → cycles.jsonl 에 즉시 append (fire-and-forget)
    3. cycleCounter++

  writeMeasurementJson (endMeasurement 시점):
    - cycles.jsonl 를 useLines 스트리밍 read → cyclesArr 조립
    - measurement.json 생성 성공 시 cycles.jsonl 삭제 (SSOT 유지)
    - 파싱 실패 line 은 skip (corrupt 방지)

  startMeasurement:
    - 이전 세션 잔여 cycles.jsonl 삭제 (같은 folderName 재사용 방지)

  discardAndDeleteFolder / endMeasurement:
    - cycleCounter = 0 리셋

효과:
  메모리 상시: cycleCounter (4B) 만 · 이전 대비 시간당 90MB 절감
  End 시점 read-back 은 필요 (measurement.json aggregate) · 그때만 잠깐 spike
  Cycle 저장 실패해도 측정 계속 (silent · fire-and-forget UC-05)

측정.json 스키마 변경 없음 · guardian app / 분석 툴 하위 호환.

검증: BUILD SUCCESSFUL 2s
2026-08-04 16:00:54 +09:00
dw.jang 1df1a4e6cd fix(logs): §A+B · scan_id 정합성 · events.jsonl 조인키 (LOG_STORAGE_ANALYSIS Priority 1)
배경 (LOG_STORAGE_ANALYSIS 2026-08-04):
  §A · ble.log 의 scan= 값이 실제 adc.csv scan_id 대비 1씩 밀림 (off-by-one)
       - 원인: measurementResult() 가 AdcCsvLogger.lastScanId 를 읽은 후에
         AdcCsvLogger.log() 가 scanCounter 를 증가시켜 실제 CSV 는 N+1 로 기록.
  §B · events.jsonl 에 scan_id 필드 부재 → 4개 로그 (adc.csv · imu.csv · ble.log ·
       events.jsonl) 간 조인 키 없음. timestamp 밀리초만으로는 autoScanIntervalMs=600ms
       에서 신뢰 불가.
  §F · BV 실패 케이스가 events.jsonl 에 아무 흔적 없음.

수정:
  AdcCsvLogger:
    - nextScanId(): Int 신규 · thread-safe counter 증가 후 id 반환
    - log(...): 새 scanId 파라미터 · 호출자가 미리 발급한 id 우선 사용
    - counterLock 으로 동시성 방어

  MeasurementLogService.logMeasurement(...):
    - scanId: Int? 파라미터 추가 · events.jsonl 에 "scan_id" 필드 기록

  BleDebugLogger.measurementResult(...):
    - scanId: Int? 파라미터 · 호출자가 발급한 id 우선 (미제공 시 legacy fallback)

  ImuCsvLogger.log(...):
    - scanId: Int? 파라미터 · 호출자가 발급한 id 우선

  PiezoMonitoringView (5 sites · onMultiChannelComplete):
    - 사이클 진입 시 val scanId = AdcCsvLogger.nextScanId() 미리 발급
    - useV41Bv · useMethodDBv · effectiveCenterWalls>=4 · totalWallCount>0 ·
      no-valid-channels 5경로 모두 scanId 전달
    - §F: BV_SKIP · BV_FAIL 케이스도 events.jsonl 에 기록 (null volume)

  ClinicalLiveView (1 site):
    - autoCapture 시 val sid = AdcCsvLogger.nextScanId() → AdcCsvLogger + ImuCsvLogger
      에 동일 id 전달

효과:
  ble.log · adc.csv · imu.csv · events.jsonl 이 scan_id 로 정합 조인 가능.
  Guardian 앱이 이벤트 재구성 시 timestamp 대신 scan_id 단일 키 사용.
  BV 실패도 audit trail 에 기록.

검증: BUILD SUCCESSFUL 8s
2026-08-04 15:58:01 +09:00
dw.jang de6e1d1931 feat(guardian): Rev2 · Bus 확장 + AlarmAggregator + NotificationSubscriber
GUARDIAN-01 §Rev2 개선 4건 (파트너 앱 개발 지원):

1. BleManager · Battery/BleConnection emit 지점 추가
   - emitBatteryEvent(pct): 임계 bucket 전이 (CRITICAL<5, LOW<20, OK≥20)
   - emitConnectionEvent(state, reason): CONNECTED / DISCONNECTED / FAILED
   - CONNECTED · STATE_DISCONNECTED · GATT_ERROR · 좀비 watchdog 4 경로 wire

2. AlarmAggregator (신규 · telemetry/) — subscriber 참조 구현
   - Bus 구독 · BvEvent/PostureEvent/GaitEvent/BleConn/Battery → AlarmEvent 재발행
   - 재귀 방지 (filterNot { it is AlarmEvent })
   - Category 매핑: BV_URGENT/BV_WARN/POSTURE_TRANSITION/GAIT_STARTED/STOPPED/
     BLE_LOST/BATTERY_CRITICAL/BATTERY_LOW
   - Guardian 앱 로직 단순화 (AlarmEvent 하나만 구독하면 됨)

3. Dev-mode overlay 확장 (PiezoMonitoringView · RSSI 옆)
   - ClinicalEventBus 최근 5개 이벤트 rolling 표시
   - Type(BV/PST/GAI/BLE/BAT/ALM) + severity color + 요약
   - LaunchedEffect + mutableStateListOf · isDevMode gate

4. NotificationSubscriber (신규 · telemetry/) — in-app 소비 참조 구현
   - AlarmEvent 구독 · Android push notification 발행
   - BATTERY_CRITICAL/LOW → device_status 채널 (신규)
   - BLE_LOST → 30초 debounce · CONNECTED 시 reset
   - BV_URGENT/WARN → AppState.updateLevelFromMeasurement 직접 호출과 중복 방지 · NO-OP
   - MainActivity.onCreate 에서 start(this) 1회 호출

NotificationService 확장:
   - device_status 채널 신규 (배터리·BLE 알람용)
   - sendBatteryNotification(level, isCritical) · bucket dedup
   - sendBleLostNotification(reason) · resetBleDebounce()

의도: Guardian 앱 (동업자 · Supabase 경유) 이 in-app subscriber 와 동일 패턴으로
소비하도록 참조. 다음 단계는 GUARDIAN-02 파트너용 스키마 문서 (cloud-mvp).
2026-08-04 09:52:15 +09:00
dw.jang db6e7e1a5c feat(labdb): demo-final 도 미업로드 세션 자동 재시도 + endMeasurement 자동 업로드
demo-final 에서도 clinical mode 테스트 중인데 매 세션마다 Upload 버튼을
눌러야 반영되는 문제. tab-navigation / cloud-mvp 브랜치의 자동 재시도
로직을 demo-final 구조에 맞춰 축소 이식.

원인:
- demo-final ClinicalSessionStore.endMeasurement 자체에 자동 업로드
  로직이 아예 없었음 (주석: "labdb 업로드는 자동 X — Upload 버튼으로
  수동 트리거").
- recentFinalizedFolders / capturedCount 히스토리 구조도 없어 단순
  cherry-pick 은 불가.

수정:
- endMeasurement: isRegistered (apiKey 존재) 면 uploadAsync 자동 호출.
- LabdbAutoRetry object 신설 (services/labdb/LabdbAutoRetry.kt):
  demo-final 은 lastFinalizedFolder 단일 값만 있으므로
  listOfNotNull(lastFinalizedFolder) 로 축소. tab-nav/cloud-mvp 는
  recentFinalizedFolders 리스트 사용 (그쪽 브랜치 코드는 그대로).
- ClinicalHomeView LaunchedEffect(Unit) 에서 자동 트리거 + 상단 배너 UI:
  * 진행 중: "자동 업로드 진행 중 · 남은 세션 N · <name>" (파랑, spinner)
  * 완료: "N uploaded, M failed" (초록/주황) + 확인 버튼

효과:
- 세션 종료 후 뒤로가기 → 자동 업로드 시도 → 실패해도 Clinical Home
  재진입 시 재시도. 사용자 개입 0.
- 여러 세션 밀린 상황도 사용자가 다음 세션 진입할 때마다 이전 세션이
  하나씩 재시도됨 (demo-final 구조 특성).
- 기존 폴더 구조 / measurement.json 포맷 100% 유지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-09 14:09:50 +09:00
dw.jang a10d4b9517 fix(stability): 코드베이스 진단 HIGH 이슈 6건 fix
병렬 진단 (BLE/thread + Compose lifecycle + null safety) 결과 HIGH
심각도 12건 중 crash 유발 가능성 높고 저비용 고효과인 것 우선 처리.

**H4 — coroutine leak**
- PiezoMonitoringView.kt BleDebugPanel: `while(true) { delay/refresh }` →
  `while(isActive)`. LaunchedEffect 취소 후에도 refreshTick++ 이 계속
  돌아 GC 방해.
- BladdyRiveView.kt 2곳: 동일 패턴 → isActive 로 통일.

**H1 — BleManager postDelayed 미취소**
- `fwFallbackTimer` (4초 mfv? fallback) + `cccdRetryTimer` (500ms CCCD
  재시도) 를 Runnable 참조로 저장. disconnect / GATT_ERROR / watchdog
  timeout 3곳에서 handler.removeCallbacks 로 명시적 취소.
- 이전에는 disconnect 후에도 4초 후 sendFirmwareVersionQuery() 가 발동
  → 이미 close 된 GATT 에 write → silent exception → state 오염.

**H2 — MeasurementService 콜백 leak**
- performNirsMeasurement/performPiezoMeasurement 는 singleton 에서
  콜백 대입만 하고 정리 안 함. Self-clearing lambda 로 응답 1회 처리
  후 자동 null. 시작 시 이전 stale 콜백도 clear.
- stop() 에서도 대기 중 콜백 취소.

**H3 — Watchdog race condition**
- watchdog thread 가 GATT disconnect/close + characteristic null 을
  binder thread 에서 직접 실행 → UI thread 의 sendRaw 와 race.
- 정리 전부를 handler.post 로 UI thread 에 위임 → single-threaded.
- fwFallback/cccdRetry timer 도 여기서 함께 취소.

**H6 — PiezoMonitoringView measure() closure leak**
- DisposableEffect 에 piezoCollector.onMultiChannelComplete 정리 추가.
  measure() 함수 안에서 이 콜백에 measureScope + channels + outer
  state 를 다수 capture → 화면 이탈 후에도 GC 방해 + 재진입 시 stale
  closure 가 새 상태 오염 위험.

**H9 — UrineCameraScreen NPE 위험**
- LaunchedEffect 안 while 루프에서 `currentDetection!!` 이 다른 recompose
  가 detection 을 null 로 만들면 NPE. Local val snapshot 으로 fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 10:40:21 +09:00
dw.jang bcbc7b440c refactor: 데모 브랜치 패키지 통일 com.example.medilightv2android → com.medithings.vesiscan
사용자앱 (feature/tab-navigation) 과 패키지 이름 일치. namespace + applicationId
둘 다 com.medithings.vesiscan 로 변경 (기존 데모 앱은 재설치 필요).

## 변경 범위
- Kotlin 148 파일: package + import 문 (714 occurrences)
- 디렉토리 이동: com/example/medilightv2android → com/medithings/vesiscan
  (main, test, androidTest 각각)
- app/build.gradle.kts: namespace, applicationId
- docs/FLAVOR_DEMO_STABLE.md: 참조 갱신
- V41DetectorCH4Test: BvDispatchResult.methodChosen → method (dto field name fix)

## 주의
- applicationId 가 바뀌므로 기존 데모 앱 (com.example.medilightv2android.demo) 은
  Android 관점에서 다른 앱으로 취급 — 재설치 시 PIN/설정 초기화됨.
- Fresh install 권장. 기존 앱 (com.example...) 은 별도로 uninstall 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 14:30:43 +09:00