diff --git a/VesiScan_Android_Pipeline_Summary.md b/VesiScan_Android_Pipeline_Summary.md index 332a7e8..41fd4c4 100644 --- a/VesiScan_Android_Pipeline_Summary.md +++ b/VesiScan_Android_Pipeline_Summary.md @@ -2,7 +2,7 @@ ## 작성일: 2026-04-23 ## 작성자: dwjang -## 마지막 업데이트: 2026-04-24 (v2) +## 마지막 업데이트: 2026-05-07 (v3) --- @@ -286,53 +286,30 @@ UrinAI가 "소변 있다!" 하면, **정확히 어디서 어디까지**인지 --- -## 5.5 LR Ratio 계산 — 타원 단면 추정 +## 5.5 LR Ratio 계산 — x-z 타원 피팅 (2026-05-07 업데이트, appshare #21) -### iOS와 다른 점 +### 알고리즘 (x-z 평면 6점 타원 피팅) ``` -iOS: lr_ratio = 1.0 (고정, 원형 단면 가정) -Android: CH4/CH5 lateral에서 동적으로 타원 비율 계산 -``` +1. CH4/CH5 벽 좌표 (x-z 평면): + - ant/post 벽의 x좌표: sx + d × cos(α) × sin(β) + - ant/post 벽의 z좌표: d × cos(α) × cos(β) + → 최대 4개 lateral 점 -### 알고리즘 +2. Center 벽 좌표를 lateral SI 높이로 보간: + - CH1, CH2의 z_ant, z_post를 선형 보간 (x=0) + → 2개 center 점 -``` -1. Center AP 직경 계산 (CH1, CH2 기준) - - 각 이웃 채널의 (post-ant) × cos(θ) → 평균 = D_center - - D_center ≤ 0 → lr_ratio = 1.2 (invalid fallback) +3. 6개 경계점으로 x-z 축 정렬 타원 피팅: + - 정규화 후 α·x̂² + β·ẑ² + γ·x̂ + δ·ẑ = 1 + - least squares → b_lr (LR 반축), a_ap (AP 반축) + - lr_ratio = b_lr / a_ap (하한 1.0) -2. Lateral 직경 계산 (CH4, CH5 각각) - - P = cos(alpha) × cos(beta) [SI 보정] - - Q = cos(alpha) × sin(beta) [LR 성분] - - D_lateral = (dFar - dNear) × P - - y_mid = sensor_x + d_mid × Q [LR 위치] - - ratio = D_lateral / D_center - -3. LR ratio 계산 - - 2개 lateral → 타원 방정식 풀기: - u = yL × yR × (yR - yL) / denom - d = -(u × (rL² - 1) + yL²) / (2 × yL) - b² = u + d² - lr_raw = 2√u / D_center - - - 1개 lateral → 중심 가정: - b = |y_mid| / √(1 - r²) - lr_raw = b / (D_center/2) - -4. Shrinkage (Bayesian 사후확률) - - confidence = ((1 - avg_ratio) / 0.10).clamp(0, 1) +4. Shrinkage: + - confidence = ((1 - avg_chord_ratio) / 0.10).clamp(0, 1) - lr_final = lr_prior(1.2) + (lr_raw - 1.2) × confidence ``` -### Fallback 값 - -| 상황 | lr_ratio | -|------|----------| -| lateral 데이터 없음 | 1.0 (원형) | -| lateral 있지만 계산 실패 | 1.2 (해부학적 기본값) | -| 정상 계산 | 동적 값 (shrinkage 적용) | - --- ## 5.6 BV Estimation — 용적 계산 @@ -363,20 +340,24 @@ Android: estimateBladderVolume6ch(6채널) 총 부피 = 바닥 + 층1 + 층2 + 층3 + 뚜껑 ``` -### 10단계 파이프라인 +### 파이프라인 (2026-05-07 업데이트, appshare #21/#22) | Step | 뭘 하나? | 수식 | |------|---------|------| +| 0 | Center wall 보간/정리 | gap=1 보간, gap≥2 top 그룹 제거 | | 1 | Edge 채널 필터 | CH3이 CH1/CH2 대비 0.9배 미만이면 short | -| 2 | 칸 번호 → 실제 거리(mm) | d = 6.85 + 칸번호 × **1.771** | +| 2 | 칸 번호 → 실제 거리(mm) | d = 6.85 + 칸번호 × DPS | +| 2-1 | Post median outlier 제거 | median ±25% 밖 채널 제거 (≥3ch) | | 3 | 방광 직경 구하기 (각도 보정) | D = (뒷벽거리 - 앞벽거리) × cos(각도) | -| 4 | 단면적 구하기 | S = (π/4) × D² × **lr_ratio** | -| 5 | 각 층의 높이 위치 | y = 센서위치 + 중간점 × sin(각도) | +| 4 | 단면적 구하기 | S = (π/4) × D² × lr_ratio | +| 5 | 각 층의 높이 위치 + 벽 좌표 | y, z 벽 좌표 (타원 피팅용) | | 6 | 아래→위 순서로 정렬 | - | -| 7 | 포물선으로 보정 | 2차 곡선 맞추기 + 이상치 수정 | +| 7 | y-z 타원 피팅 → cap 높이 | 반복 outlier 제거 (잔차>0.5 시 worst 제거, 최소 5점) | +| 7-1 | b_si 상한 | a_ap × 1.3 (해부학적 SI/AP 제한) | +| 7-2 | Top cap 상한 | 미검출 상위 채널 빔 y좌표 기준 제한 | | 8 | 층별 부피 합산 | 절두체 공식 | -| 9 | 바닥 뚜껑 (항상 구) | V = S×h/2 + π×h³/6 | -| 10 | 윗 뚜껑 (원뿔 or 구) | 원뿔: V = S×h/3 / 구: 위와 동일 | +| 9 | 바닥 뚜껑 (spherical cap) | V = S×h/2 + π×h³/6 | +| 10 | 윗 뚜껑 (cone) | V = S×h/3 | ### 물리 상수 @@ -402,109 +383,74 @@ Android: estimateBladderVolume6ch(6채널) [CH0] 0° ← 가장 위 (배꼽쪽, y=21mm) ``` -## 방향 안내 로직 (2단계) +## 방향 안내 로직 — 3모드 (2026-05-07 업데이트) -### 1단계: 이진 판정 (4채널 미만일 때) +개발자 모드 Settings에서 전환 가능: **Gradient** (기본) / Boundary / **SWEEP** -**물리적 프로브 배치 기준:** -- CH0 = 배꼽 방향 (위) -- CH3 = 치골 방향 (아래) - -| CH0(배꼽) | CH3(치골) | CH4(왼) | CH5(오) | 안내 | -|-----------|----------|---------|---------|------| -| ✅ | ❌ | - | - | "센서를 **위로** 올리세요" (UP) | -| ❌ | ✅ | - | - | "센서를 **아래로** 내리세요" (DOWN) | -| - | - | ❌ | ✅ | "센서를 **왼쪽**으로 옮기세요" (LEFT) | -| - | - | ✅ | ❌ | "센서를 **오른쪽**으로 옮기세요" (RIGHT) | -| ❌ | ❌ | ❌ | ❌ | "방광이 안 잡힙니다. 센서 접촉을 확인하세요" | - -### 2단계: CV threshold 정밀 가이드 (center 3채널 이상 잡힌 후) - -4채널 이상 잡히더라도, 방광 **중앙**에 정확히 위치해야 측정이 정확합니다. -각 채널의 **cos-corrected depth(mm)**의 변동계수(CV)로 배치 품질을 판정합니다. - -### CV (Coefficient of Variation) 계산 +### Gradient 모드 (기본) — 신호 길이 비교 ``` -1. 각 center 채널(CH0~CH3)의 ant/post wall 위치를 mm로 변환: - depth_mm = (post_mm - ant_mm) × cos(beam_angle) +시작: 치골 위 → 위로 올림 -2. 유효 채널(≥3개)의 depth 평균과 표준편차 계산: - CV = std(depths) / mean(depths) +1단계 VERTICAL: + 0~1채널: ch0/ch1 감지→↑, ch2/ch3 감지→↓ + 2+채널: weighted center gradient + weightedCenter = Σ(ch_pos × len) / Σ(len) + gradient = weightedCenter - 1.5 + gradient > 0 → ↓ (ch3쪽 길다 = 방광 아래) + gradient < 0 → ↑ (ch0쪽 길다 = 방광 위) + |gradient| < 0.4 + lenVariation < 0.5 → 상하 OK -3. 판정: - CV ≤ 0.15 → PASS (Best Placement) - CV > 0.15 → FAIL → 방향 안내 +2단계 LATERAL: + |len4 - len5| ≤ 10 sample → 바로 GREEN + lrSymmetry ≥ 0.5 → GREEN + 아니면 len 차이 방향으로 안내 + +3단계 GREEN: + CV ≤ 0.12 (10회마다 +0.03 완화) + |lrDev| ≤ 0.35 + 둘 다 통과 → Green! + CV만 간당간당 → "Almost there..." (GREEN에 머물며 재시도) ``` -### 왜 CV인가? +### SWEEP 모드 (CKLaw) — 위에서 아래로 쓸어내리기 ``` -잘 맞은 경우 (중앙): 치우친 경우 (가장자리): -CH0: depth=45mm CH0: depth=15mm ← 짧음 -CH1: depth=52mm CH1: depth=48mm -CH2: depth=50mm CH2: depth=42mm -CH3: depth=42mm CH3: depth=10mm ← 짧음 +시작: 배꼽 근처 → 아래로 내림 -CV = 0.09 → PASS ✅ CV = 0.52 → FAIL ❌ - weakest = CH3 → "MOVE UP" +1단계 VERTICAL: + ① 0채널: "Place near navel, slide down ↓" + ② 1~3채널: "Slide down ↓" (채널이 하나씩 켜짐) + ③ 4채널: sagittalArgMaxCh로 정렬 판정 + argmax=0 → 정중앙! → LATERAL + argmax=1~3 → "Slide down" (아직 더 내려야) + ④ 4채널→줄어듦: "Past optimum — slide back up ↑" + + 좌우 차이 ≤10 → LATERAL 건너뛰고 바로 GREEN + +2단계 LATERAL: + lateralOffsetMm (mm 단위 정밀 안내) + |offset| ≤ 8mm → GREEN + +3단계 GREEN: + CV + LR dev + R-peak gate (bestR - currentR ≤ 1.5mm) ``` -중앙을 관통하면 모든 채널의 depth가 비슷 → CV 작음. -가장자리를 스치면 일부 채널이 짧음 → CV 큼. - -### SI(세로) 방향 판정 +### Boundary 모드 — 올라갔다 내려오기 ``` -deviation[i] = (depth[i] - mean) / mean - -weakest = 가장 deviation이 큰 채널 - -severity: - |dev| < 0.10 → slight - 0.10 ≤ < 0.25 → moderate - |dev| ≥ 0.25 → severe - -방향: - weakest가 CH0/CH1 (top) → MOVE DOWN - weakest가 CH2/CH3 (bottom) → MOVE UP +시작: 치골 위 → 위로 올림 +채널 수 최대→감소 감지 → "Too high, lower" → 복귀 시 LATERAL ``` -### LR(가로) 판정 +### GREEN 판정 상수 -``` -depth_CH4 = raw_depth × cos(SI_angle) × cos(LR_angle) -depth_CH5 = raw_depth × cos(SI_angle) × cos(LR_angle) - -lr_dev = (depth_CH4 - depth_CH5) / mean(depth_CH4, depth_CH5) - -|lr_dev| ≤ 0.15 → PASS -lr_dev > 0 → MOVE RIGHT (왼쪽이 더 길음) -lr_dev < 0 → MOVE LEFT (오른쪽이 더 길음) -``` - -### 최종 판정 - -``` -is_pass = CV ≤ cv_thr AND |lr_dev| ≤ lr_dev_thr - -cv_thr = 0.15 + repeatCount × 0.02 (반복 시 완화) -lr_dev_thr = 0.15 (고정) -``` - -### Threshold Relaxation (반복 완화) - -``` -Scan 흐름: - 1회차: CV_thr = 0.15 - 2회차: CV_thr = 0.17 - 3회차: CV_thr = 0.19 - ... - n회차: CV_thr = 0.15 + n × 0.02 - -→ Best Placement가 아닌 경우 반복 측정 동안 기준을 완화하여 - 사용자가 무한히 조절하지 않도록 함 -``` +| 상수 | 값 | 의미 | +|------|-----|------| +| DEFAULT_CV_THR | 0.12 | 기본 CV 임계 (10회마다 +0.03 완화) | +| DEFAULT_LR_DEV_THR | 0.35 | LR 비대칭 임계 | +| 절대 LR 차이 | ≤10 sample | LATERAL 즉시 통과 | +| R-peak tolerance | 1.5mm | SWEEP 모드 GREEN gate | +| 디바운스 | 1.5초 | phase 전환은 즉시, 같은 phase 힌트만 디바운스 | ## 연속 스캔 동작 @@ -558,13 +504,20 @@ Spot 버튼 탭 화면에 "305 ml" 표시! (도넛차트 + 레벨 업데이트) ``` -## 롱프레스 자동측정 +## Auto 모드 (2026-05-07 업데이트) -Spot 버튼을 **길게 누르면** 자동 측정 모드: -- 30초마다 자동으로 측정 -- 빨간색 "Auto" 배지 표시 -- 한 번 탭하면 중지 -- 구현: `detectTapGestures(onLongPress)` + `LaunchedEffect` 30초 루프 +별도 Auto 버튼으로 자동 반복 측정: +- 1.5초 간격 자동 측정 +- 첫 5회: "Measuring... (n/5)" 로딩 표시 +- 5회 이상: **sliding window trimmed mean** (10개 윈도우) → 안정된 값 표시 +- Stop 버튼으로 종료 + +## Spot 모드 (2026-05-07 업데이트) + +Spot 버튼 1탭 → **3회 측정 → median** (중간값 깜빡임 없음): +- 최대 6회까지 재시도 (유효값 3개 수집 목표) +- 측정 간격 300ms (BLE 겹침 방지) +- 중간 값 화면 미표시 (isSpotInProgress 플래그) ## 측정 실패 시 자동 재시도 @@ -1293,24 +1246,27 @@ ADC 파싱 시 Big/Little Endian을 자동 감지: | 항목 | 상태 | 비고 | |------|------|------| | BLE 6채널 수신 | ✅ 완료 | | -| Low-echo Detection (prominence + edge penalty) | ✅ 완료 | VALLEY_STOP=50, EDGE_DECAY=0.12 | -| Frustum BV 추정 (6ch) | ✅ 완료 | center 4ch + lateral lr_ratio(≥1.0) | -| lr_ratio 동적 계산 | ✅ 완료 | CH4/CH5 타원 추정, 하한 1.0 | -| Placement Guide 2단계 순차 | ✅ 완료 | 상하(3ch+크기)→좌우(대칭)→CV Green | -| 미부착 감지 (DetachmentDetection) | ✅ 완료 | mean/std < 100 → LED 4 | -| LED 모드 (mls?) BE 포맷 | ✅ 완료 | Placement 3단계 (4→5→6→0) | -| BLE 자동 reconnect | ✅ 완료 | 무한 3초 재시도 + watchdog | -| BLE 디버그 로거 | ✅ 완료 | TX/RX 파싱 + Downloads 내보내기 | -| BLE 명령어 Endian 통일 | ✅ 완료 | 테스터 exe와 100% 동일 (BE+공백) | -| 자동측정 (IMU 1s + 측정 10s) | ✅ 완료 | | -| 런타임 캘리브레이션 | ✅ 완료 | Threshold 슬라이더 + DPS 직접입력 + SG 토글 | -| 도넛차트 max volume 표시 | ✅ 완료 | maxVolumeMl 직접 표시 | -| 패킷 드랍 자동 retry | ✅ 완료 | 6ch 미만 수신 시 자동 재전송 (최대 2회) | -| ADC CSV 저장 | ✅ 완료 | Downloads/VesiScan_ADC/일별.csv + 설정값 포함 | -| Endian 감지 안정화 | ✅ 완료 | VBT=BE 기본, 애매한 경우 기기 기반 fallback | -| Python ↔ Android 1:1 검증 | ✅ 완료 | 전수 상수 비교 | -| 금요일 APK 배포 | 🔜 예정 | Gitea 릴리즈 | -| mbb 전체측정 전환 | 🔜 예정 | rbb: 헤더 파싱 검증 후 | -| Adaptive TGC | 🔜 예정 | 원은지님 코드 확인 필요 | -| Placement Score 캘리브레이션 | 🔜 예정 | 팬텀 실측 기반 CV threshold 조정 | +| Foreground Service (화면 잠금 BLE 유지) | ✅ 완료 | PARTIAL_WAKE_LOCK 4시간 | +| Low-echo Detection Method B | ✅ 완료 | VALLEY_STOP=50, EDGE_DECAY=0.12 | +| V4.1 CharlesKWON Method C | ✅ 완료 | 45 파일 walldetect 패키지 | +| Method A (Plateau-based) | ✅ 완료 | contrastRatio × cos(θ) | +| Frustum BV 추정 (6ch) | ✅ 완료 | y-z 타원 피팅 cap + outlier 제거 (#21/#22) | +| lr_ratio x-z 타원 피팅 | ✅ 완료 | 6점 타원 피팅 (#21) | +| Center wall repair | ✅ 완료 | gap=1 보간, gap≥2 top 제거 (#21) | +| BV 타원 피팅 outlier 제거 | ✅ 완료 | 반복 worst 제거, 잔차≤0.5, b_si≤a_ap×1.3 (#22) | +| Back Reflection 처리 | ✅ 완료 | consensus, amp_ratio=1.5, min_votes=3 | +| Placement Guide 3모드 | ✅ 완료 | Gradient / Boundary / SWEEP | +| SWEEP (BladderSphereSeek) | ✅ 완료 | Kasa circle fit + RTracker + argmax | +| Gradient placement | ✅ 완료 | weighted center + R-peak gate GREEN | +| 개발자 모드 | ✅ 완료 | 캐릭터 3탭 토글, dev-only settings/graphs/logs | +| Auto 측정 (sliding window) | ✅ 완료 | 10-sample trimmed mean, 대기 중 loading | +| Spot 측정 (재시도) | ✅ 완료 | 3회 median, 최대 6회 재시도, 깜빡임 없음 | +| 미부착 감지 | ✅ 완료 | mean/std < 30 → LED 4 | +| BLE 자동 reconnect + watchdog | ✅ 완료 | 15초 timeout, IMU heartbeat | +| BLE 디버그 로거 | ✅ 완료 | 실시간 파일 append | +| ADC CSV 저장 | ✅ 완료 | 세션별 파일 | +| Canvas 하복부 실루엣 | ✅ 완료 | PNG 제거, CKLaw Bezier torso | +| i18n (한국어/영어) | ✅ 완료 | strings.xml | +| Placement 팬텀 테스트 | 🔜 예정 | Gradient vs SWEEP 비교 | +| 5-Anchor calibration | 🔜 검토 | CKLaw 전체 포팅 여부 결정 | | 인체 임상 검증 | 🔜 예정 | 상수 재조정 | diff --git a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt index 0a5d643..3ed2972 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -504,31 +504,69 @@ fun estimateBladderVolume( val aCapS = DoubleArray(order.size) { aCap[order[it]] } val sortedCh = order.map { validChannels[it] } - // 7) y-z 평면 타원 피팅 → cap 높이 추정 (#21 merge) - val yw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] } - val zw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] } - val nPts = yw.size + // 7) y-z 평면 타원 피팅 + 반복 outlier 제거 (#22 merge) + val allYw = DoubleArray(2 * n) { i -> if (i < n) yWallAnt[i] else yWallPost[i - n] } + val allZw = DoubleArray(2 * n) { i -> if (i < n) zWallAnt[i] else zWallPost[i - n] } + val nPts = allYw.size var capKind = "fallback" var z0Ellipse: Double? = null var hCapBot = aCapS[0] var hCapTop = aCapS[n - 1] + val ellipseCostThr = 0.5 // 점당 평균 잔차 임계 - if (nPts >= 4) { - val ym = yw.average(); val ysStd = std(yw) + 1e-12 - val zm = zw.average(); val zsStd = std(zw) + 1e-12 - val yn = DoubleArray(nPts) { (yw[it] - ym) / ysStd } - val zn = DoubleArray(nPts) { (zw[it] - zm) / zsStd } + // 타원 피팅 helper: 성공 시 (y0, z0, bSi, aAp, residuals) 반환 + fun fitEllipsePts(ywF: DoubleArray, zwF: DoubleArray): Array? { + val cnt = ywF.size + val ym = ywF.average(); val ysS = std(ywF) + 1e-12 + val zm = zwF.average(); val zsS = std(zwF) + 1e-12 + val yn = DoubleArray(cnt) { (ywF[it] - ym) / ysS } + val zn = DoubleArray(cnt) { (zwF[it] - zm) / zsS } + val sol = solveEllipseLSQ(yn, zn, cnt) ?: return null + if (sol[0] <= 1e-12 || sol[1] <= 1e-12) return null + val rr = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1]) + if (rr <= 0) return null + val y0 = (-sol[2] / (2.0 * sol[0])) * ysS + ym + val z0 = (-sol[3] / (2.0 * sol[1])) * zsS + zm + val bSi = sqrt(rr / sol[0]) * ysS + val aAp = sqrt(rr / sol[1]) * zsS + val resid = DoubleArray(cnt) { + abs(((ywF[it] - y0) / bSi) * ((ywF[it] - y0) / bSi) + + ((zwF[it] - z0) / aAp) * ((zwF[it] - z0) / aAp) - 1.0) + } + return arrayOf(y0, z0, bSi, aAp, resid) + } - val sol = solveEllipseLSQ(yn, zn, nPts) - if (sol != null && sol[0] > 1e-12 && sol[1] > 1e-12) { - val y0n = -sol[2] / (2.0 * sol[0]) - val z0n = -sol[3] / (2.0 * sol[1]) - val rVal = 1.0 + sol[2] * sol[2] / (4.0 * sol[0]) + sol[3] * sol[3] / (4.0 * sol[1]) - if (rVal > 0) { - val bSiN = sqrt(rVal / sol[0]) - val y0 = y0n * ysStd + ym - z0Ellipse = z0n * zsStd + zm - val bSi = bSiN * ysStd + if (nPts >= 5) { + val keep = BooleanArray(nPts) { true } + var fit = fitEllipsePts(allYw, allZw) + + if (fit != null) { + var y0 = fit[0] as Double; var z0 = fit[1] as Double + var bSi = fit[2] as Double; var aAp = fit[3] as Double + var resid = fit[4] as DoubleArray + var meanRes = resid.average() + + // 반복 outlier 제거: worst 점 하나씩, 최소 5점 유지 + while (meanRes > ellipseCostThr && keep.count { it } > 5) { + val worstLocal = resid.indices.maxByOrNull { resid[it] } ?: break + val activeIndices = keep.indices.filter { keep[it] } + keep[activeIndices[worstLocal]] = false + val keptY = keep.indices.filter { keep[it] }.map { allYw[it] }.toDoubleArray() + val keptZ = keep.indices.filter { keep[it] }.map { allZw[it] }.toDoubleArray() + val fit2 = fitEllipsePts(keptY, keptZ) ?: break + val meanRes2 = (fit2[4] as DoubleArray).average() + if (meanRes2 < meanRes) { + y0 = fit2[0] as Double; z0 = fit2[1] as Double + bSi = fit2[2] as Double; aAp = fit2[3] as Double + resid = fit2[4] as DoubleArray; meanRes = meanRes2 + } else break + } + + // 품질 판정: 평균 잔차 ≤ threshold + if (meanRes <= ellipseCostThr) { + z0Ellipse = z0 + // b_si 상한: a_ap × 1.3 (해부학적 SI/AP 비율 제한) + if (bSi > aAp * 1.3) bSi = aAp * 1.3 hCapBot = max(0.0, yS[0] - (y0 - bSi)) hCapTop = max(0.0, (y0 + bSi) - yS[n - 1]) hCapBot = min(hCapBot, aCapS[0]) diff --git a/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt b/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt index aead786..f0cccde 100644 --- a/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt +++ b/app/src/main/java/com/example/medilightv2android/services/BleForegroundService.kt @@ -29,8 +29,8 @@ class BleForegroundService : Service() { const val ACTION_STOP = "com.example.medilightv2android.BLE_STOP" fun start(context: Context) { - // 배터리 최적화 제외 요청 (Doze 면제) - requestBatteryOptimizationExemption(context) + // 배터리 최적화 제외 요청 제거 — 다른 페이지로 이동되는 UX 문제 + // Foreground Service + PARTIAL_WAKE_LOCK만으로 Doze 대응 val intent = Intent(context, BleForegroundService::class.java).apply { action = ACTION_START diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 9c3be0e..6e967a3 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -771,17 +771,38 @@ fun PiezoMonitoringView(appState: AppState) { Column(horizontalAlignment = Alignment.CenterHorizontally) { BladdyRiveView(urgency = urgency, size = if (isCompact) 80.dp else 110.dp) Spacer(modifier = Modifier.height(4.dp)) - Text( - if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = urgency.accentColor, - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(MlCardBackground) - .shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f)) - .padding(horizontal = 14.dp, vertical = 6.dp) - ) + if (isAutoMeasuring && autoVolumeWindow.size < 5 && lastVolumeMl <= 0) { + // Auto 측정 대기 중 — trimmed mean 산출 전 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MlCardBackground) + .padding(horizontal = 14.dp, vertical = 6.dp) + ) { + CircularProgressIndicator( + color = Color(0xFFFF9800), + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Measuring... (${autoVolumeWindow.size}/5)", + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, + color = Color(0xFFFF9800)) + } + } else { + Text( + if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = urgency.accentColor, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MlCardBackground) + .shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f)) + .padding(horizontal = 14.dp, vertical = 6.dp) + ) + } } } @@ -839,11 +860,17 @@ fun PiezoMonitoringView(appState: AppState) { spotCooldown = true spotVolumes.clear() spotScope.launch { - repeat(3) { + // 최대 6회 시도, 유효값 3개 수집 목표 + val maxAttempts = 6 + val targetCount = 3 + var attempts = 0 + while (spotVolumes.size < targetCount && attempts < maxAttempts) { + attempts++ measure() while (isMeasuring) { kotlinx.coroutines.delay(100) } - kotlinx.coroutines.delay(200) + kotlinx.coroutines.delay(300) // 간격 여유 (BLE 겹침 방지) } + Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}") // 콜백에서 spotVolumes에 수집됨 (화면 갱신 없이) if (spotVolumes.size >= 2) { spotVolumes.sort() diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 85ab952..4ae7a6c 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -62,7 +62,12 @@ fun PlacementGuideView(appState: AppState) { var isContinuousMode by remember { mutableStateOf(true) } val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) } val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) } - var directionHint by remember { mutableStateOf("Place the sensor just above the pubic bone") } + val isSweepMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode == + com.example.medilightv2android.managers.PlacementGuideMode.SWEEP + var directionHint by remember { mutableStateOf( + if (isSweepMode) "Place sensor near navel, slide down ↓" + else "Place the sensor just above the pubic bone" + ) } var directionIcon by remember { mutableStateOf("") } var placementScore by remember { mutableIntStateOf(0) } var scanCount by remember { mutableIntStateOf(0) } @@ -98,7 +103,8 @@ fun PlacementGuideView(appState: AppState) { isLocked = false scanCount = 0 placementScore = 0 - directionHint = "Place the sensor just above the pubic bone" + directionHint = if (isSweepMode) "Place sensor near navel, slide down ↓" + else "Place the sensor just above the pubic bone" directionIcon = "" for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 } rTracker.reset() @@ -330,7 +336,7 @@ fun PlacementGuideView(appState: AppState) { alpha = if (isGreenZone) 0.7f else 0.95f ) - // Score + 감지수 (우측 상단) + // Score + 감지수 + R-fit (우측 상단) Column( modifier = Modifier .align(Alignment.TopEnd) @@ -347,6 +353,25 @@ fun PlacementGuideView(appState: AppState) { else -> Color.Red }) } + // SWEEP 모드: R-fit + argmax 표시 + if (isSweepMode && currentRMm != null) { + val rColor = when { + bestRMm > 0 && (bestRMm - currentRMm!!) < 1.5 -> Color(0xFF4CAF50) // PEAK + currentRMm!! > (bestRMm * 0.8) -> Color(0xFF7C3AED) // RISING + else -> Color(0xFFFF9800) // FALLING/NOISE + } + Text("R=${"%.0f".format(currentRMm)}mm", fontSize = 12.sp, + fontWeight = FontWeight.Bold, color = rColor) + if (bestRMm > 0) { + Text("best=${"%.0f".format(bestRMm)}", fontSize = 10.sp, + color = Color(0xFF4CAF50).copy(alpha = 0.7f)) + } + sagittalArgMaxCh?.let { arg -> + Text("argmax=CH$arg", fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (arg == 0) Color(0xFF4CAF50) else Color(0xFFFF9800)) + } + } } } @@ -371,33 +396,51 @@ fun PlacementGuideView(appState: AppState) { Spacer(modifier = Modifier.height(8.dp)) - // 채널 dot row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - for (i in 0 until 6) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { - Box(modifier = Modifier.size(10.dp).clip(CircleShape).background( - if (channelDetected[i]) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.35f) - )) - Text("CH$i", fontSize = 8.sp, color = MlSecondaryText) + // 채널 dot row (개발자 모드에서만 표시) + if (appState.isDevMode) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0 until 6) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background( + if (channelDetected[i]) Color(0xFF4CAF50) else Color.Red.copy(alpha = 0.35f) + )) + Text("CH$i", fontSize = 8.sp, color = MlSecondaryText) + } } } } Spacer(modifier = Modifier.height(4.dp)) - // Step indicator - Text( - text = when (placementPhase) { - PlacementPhase.VERTICAL -> "Step 1/3" - PlacementPhase.LATERAL -> "Step 2/3" - PlacementPhase.GREEN -> "Step 3/3" - }, - fontSize = 11.sp, color = MlSecondaryText - ) + // Step indicator + mode badge + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = when (placementPhase) { + PlacementPhase.VERTICAL -> "Step 1/3" + PlacementPhase.LATERAL -> "Step 2/3" + PlacementPhase.GREEN -> "Step 3/3" + }, + fontSize = 11.sp, color = MlSecondaryText + ) + if (isSweepMode) { + Text( + "SWEEP", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier + .background(Color(0xFF7C3AED), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp) + ) + } + } // Per-channel echo graphs (개발자 모드에서만 표시) if (appState.isDevMode && lastChannelData.isNotEmpty()) { @@ -554,7 +597,8 @@ fun PlacementGuideView(appState: AppState) { val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null if (detachResult != null && detachResult.isDetached) { - directionHint = "Sensor detached. Place it just above the pubic bone" + directionHint = if (isSweepMode) "Sensor detached. Place near navel" + else "Sensor detached. Place above pubic bone" directionIcon = "exclamationmark.triangle" placementScore = 0 if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 } @@ -599,11 +643,12 @@ fun PlacementGuideView(appState: AppState) { val now = System.currentTimeMillis() val elapsed = now - lastHintChangeMs - // GREEN 진입/이탈은 즉시, 일반 힌트는 디바운스 + // GREEN 진입/이탈 및 phase 전환은 즉시, 같은 phase 내 힌트만 디바운스 (1.5초) val wasGreen = isLocked val isNowGreen = guideResult.isPass val greenChanged = wasGreen != isNowGreen - if (greenChanged || (debounceCount >= 2 && elapsed >= 3000)) { + val phaseChanged = guideResult.nextPhase != placementPhase + if (greenChanged || phaseChanged || (debounceCount >= 2 && elapsed >= 1500)) { directionHint = guideResult.hint directionIcon = guideResult.icon placementScore = guideResult.score @@ -983,8 +1028,8 @@ data class PlacementGuideResult( val nextPhase: PlacementPhase = PlacementPhase.VERTICAL ) -private const val DEFAULT_CV_THR = 0.08 -private const val DEFAULT_LR_DEV_THR = 0.25 +private const val DEFAULT_CV_THR = 0.12 +private const val DEFAULT_LR_DEV_THR = 0.35 /** * Gradient 기반 Placement Guide @@ -1036,7 +1081,7 @@ private fun computePlacementGuide( val detectedCount = detected.count { it } if (detectedCount == 0) { - return PlacementGuideResult("Bladder not found. Gradually raise the sensor ↑", "arrow.up", 0, + return PlacementGuideResult("Raise the sensor ↑", "arrow.up", 0, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } @@ -1080,35 +1125,44 @@ private fun computePlacementGuide( if (centerDetected == 4) { return when (sagittalArgMaxCh) { 0 -> { - // Centred! advance to LATERAL + // Centred! 좌우 확인 val l4 = urineLens.getOrElse(4) { 0 } val l5 = urineLens.getOrElse(5) { 0 } - val lrHint = when { - l4 > 0 && l5 > 0 && l4 > l5 -> "Centred! Move right →" - l4 > 0 && l5 > 0 && l5 > l4 -> "Centred! Move left ←" - else -> "Centred! Checking lateral..." + val ch4ok = detected.getOrElse(4) { false } + val ch5ok = detected.getOrElse(5) { false } + // 좌우도 이미 균등하면 바로 GREEN + if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) { + PlacementGuideResult("Position OK! Final check...", + "checkmark.circle", 90, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) + } else { + val lrHint = when { + l4 > 0 && l5 > 0 && l4 > l5 -> "Move right →" + l4 > 0 && l5 > 0 && l5 > l4 -> "Move left ←" + else -> "Checking lateral..." + } + val lrIcon = when { + l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right" + l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left" + else -> "checkmark.circle" + } + PlacementGuideResult(lrHint, lrIcon, 90, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) } - val lrIcon = when { - l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right" - l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.left" - else -> "checkmark.circle" - } - PlacementGuideResult(lrHint, lrIcon, 90, - phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) } - 1 -> PlacementGuideResult("All 4 — Ch1 peak, slide down slightly ↓", "arrow.down", 75, + 1 -> PlacementGuideResult("Slide down slightly ↓", "arrow.down", 75, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) - 2 -> PlacementGuideResult("All 4 — Ch2 peak, slide down ↓", "arrow.down", 70, + 2 -> PlacementGuideResult("Slide down ↓", "arrow.down", 70, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) - 3 -> PlacementGuideResult("All 4 — Ch3 peak, slide further down ↓", "arrow.down", 65, + 3 -> PlacementGuideResult("Slide further down ↓", "arrow.down", 65, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) - else -> PlacementGuideResult("All 4 detected — hold still", "checkmark.circle", 80, + else -> PlacementGuideResult("Hold still", "checkmark.circle", 80, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } } // Phase 2: channels coming online (1..3) - return PlacementGuideResult("Keep sliding down ↓ ($centerDetected/4 detected)", "arrow.down", + return PlacementGuideResult("Slide down ↓", "arrow.down", 20 + centerDetected * 10, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } @@ -1120,24 +1174,14 @@ private fun computePlacementGuide( if (centerDetected <= 1) { val hint: String; val icon: String when { - // ch0만 잡힘 → 방광이 위쪽에 걸림 → 위로 올려야 - ch0det && !ch1det && !ch2det && !ch3det -> { - hint = "Signal at CH0. Raise the sensor ↑"; icon = "arrow.up" - } - // ch0 또는 ch1만 → 방광이 위쪽 → 위로 (ch0det || ch1det) && !ch2det && !ch3det -> { - hint = "Gradually raise the sensor ↑"; icon = "arrow.up" + hint = "Raise the sensor ↑"; icon = "arrow.up" } - // ch3만 잡힘 → 방광이 아래쪽에 걸림 → 아래로 내려야 - ch3det && !ch2det && !ch1det && !ch0det -> { - hint = "Signal at CH3. Lower the sensor ↓"; icon = "arrow.down" - } - // ch2 또는 ch3만 → 방광이 아래쪽 → 아래로 (ch3det || ch2det) && !ch0det && !ch1det -> { - hint = "Gradually lower the sensor ↓"; icon = "arrow.down" + hint = "Lower the sensor ↓"; icon = "arrow.down" } else -> { - hint = "Gradually raise the sensor ↑"; icon = "arrow.up" + hint = "Raise the sensor ↑"; icon = "arrow.up" } } return PlacementGuideResult(hint, icon, centerDetected * 10, @@ -1175,13 +1219,23 @@ private fun computePlacementGuide( .format(gradient, weightedCenter, lenVariation)) if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.5) { - // 상하 OK → 좌우 탐색으로 + // 상하 OK → 좌우 확인 val l4 = urineLens.getOrElse(4) { 0 } val l5 = urineLens.getOrElse(5) { 0 } + val ch4ok = detected.getOrElse(4) { false } + val ch5ok = detected.getOrElse(5) { false } + + // 좌우도 이미 균등하면 LATERAL 건너뛰고 바로 GREEN + if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 10) { + return PlacementGuideResult("Position OK! Final check...", + "checkmark.circle", 65, + phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) + } + val lrHint = when { - l4 > 0 && l5 > 0 && l4 > l5 -> "Vertical OK! Move right →" - l4 > 0 && l5 > 0 && l5 > l4 -> "Vertical OK! Move left ←" - else -> "Vertical OK! Checking lateral..." + l4 > 0 && l5 > 0 && l4 > l5 -> "Move right →" + l4 > 0 && l5 > 0 && l5 > l4 -> "Move left ←" + else -> "Checking lateral..." } val lrIcon = when { l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.right" @@ -1197,12 +1251,12 @@ private fun computePlacementGuide( // gradient 기반 방향 안내 // ch0=위, ch3=아래. gradient>0 = ch3쪽 길다 = 방광 중심이 아래 = 내려야 if (gradient > 0) { - val intensity = if (gradient > 0.8) "Lower the sensor ↓" else "Lower the sensor slightly ↓" + val intensity = if (gradient > 0.8) "Lower ↓" else "Lower slightly ↓" return PlacementGuideResult(intensity, "arrow.down", (20 + centerDetected * 5).coerceAtMost(35), phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } else { - val intensity = if (gradient < -0.8) "Raise the sensor ↑" else "Raise the sensor slightly ↑" + val intensity = if (gradient < -0.8) "Raise ↑" else "Raise slightly ↑" return PlacementGuideResult(intensity, "arrow.up", (20 + centerDetected * 5).coerceAtMost(35), phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) @@ -1218,14 +1272,22 @@ private fun computePlacementGuide( // 상하 유지 확인 — gradient 재계산 if (centerDetected < 2) { - return PlacementGuideResult("Position lost — readjust vertically", "arrow.up", 20, + return PlacementGuideResult("Readjust vertically", "arrow.up", 20, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.VERTICAL) } + // 절대 차이 10 sample 이하 → 좌우 충분히 균등, 바로 GREEN + if (ch4det && ch5det && kotlin.math.abs(len4 - len5) <= 10) { + return PlacementGuideResult( + "LR balanced. Final check...", + "checkmark.circle", 60, + phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.GREEN) + } + // SWEEP 모드 + lateralOffsetMm 있을 때: mm 기반 정밀 안내 if (lateralOffsetMm != null) { val absOff = kotlin.math.abs(lateralOffsetMm) - if (absOff <= 5.0) { + if (absOff <= 8.0) { return PlacementGuideResult( "LR centred (${"%.0f".format(absOff)}mm). Final check...", "checkmark.circle", 60, @@ -1242,15 +1304,15 @@ private fun computePlacementGuide( // 한쪽만 detect → amplitude 기반 방향 안내 if (ch4det && !ch5det) { - return PlacementGuideResult("CH4 only detected. Move right →", "arrow.right", 40, + return PlacementGuideResult("Move right →", "arrow.right", 40, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } if (ch5det && !ch4det) { - return PlacementGuideResult("CH5 only detected. Move left ←", "arrow.left", 40, + return PlacementGuideResult("Move left ←", "arrow.left", 40, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } if (!ch4det && !ch5det) { - return PlacementGuideResult("Lateral sensors not detected. Try moving left ←", "arrow.left", 35, + return PlacementGuideResult("Try moving left ←", "arrow.left", 35, phase = PlacementPhase.LATERAL, nextPhase = PlacementPhase.LATERAL) } @@ -1263,7 +1325,7 @@ private fun computePlacementGuide( "LATERAL ch4=$ch4det(len=$len4) ch5=$ch5det(len=$len5) diff=%.2f sym=%.2f" .format(lrDiff, lrSymmetry)) - if (lrSymmetry >= 0.7) { + if (lrSymmetry >= 0.5) { return PlacementGuideResult( "Symmetry OK! Final check...", "checkmark.circle", 60, @@ -1297,7 +1359,7 @@ private fun computePlacementGuide( } if (depths.size < 2) { - return PlacementGuideResult("Position lost — readjust", "exclamationmark.triangle", 30, + return PlacementGuideResult("Readjust position", "exclamationmark.triangle", 30, phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) } @@ -1342,22 +1404,28 @@ private fun computePlacementGuide( val weightedCenter = detectedLens.sumOf { (ch, len) -> ch.toDouble() * len } / totalLen val gradient = weightedCenter - 1.5 - val adjustHint = when { - !lrPass && lrDev > DEFAULT_LR_DEV_THR -> "Adjust right → (CV=${"%.2f".format(cv)})" - !lrPass && lrDev < -DEFAULT_LR_DEV_THR -> "Adjust left ← (CV=${"%.2f".format(cv)})" - gradient > 0.3 -> "Adjust downward ↓ (CV=${"%.2f".format(cv)})" - gradient < -0.3 -> "Adjust upward ↑ (CV=${"%.2f".format(cv)})" - else -> "Almost there! Hold steady (CV=${"%.2f".format(cv)})" - } - val adjustIcon = when { - !lrPass && lrDev > DEFAULT_LR_DEV_THR -> "arrow.right" - !lrPass && lrDev < -DEFAULT_LR_DEV_THR -> "arrow.left" - gradient > 0.3 -> "arrow.down" - gradient < -0.3 -> "arrow.up" - else -> "checkmark.circle" + val adjustHint: String; val adjustIcon: String; val stayInGreen: Boolean + when { + !lrPass && lrDev > DEFAULT_LR_DEV_THR -> { + adjustHint = "Move right →"; adjustIcon = "arrow.right"; stayInGreen = false + } + !lrPass && lrDev < -DEFAULT_LR_DEV_THR -> { + adjustHint = "Move left ←"; adjustIcon = "arrow.left"; stayInGreen = false + } + gradient > 0.3 -> { + adjustHint = "Lower ↓"; adjustIcon = "arrow.down"; stayInGreen = false + } + gradient < -0.3 -> { + adjustHint = "Raise ↑"; adjustIcon = "arrow.up"; stayInGreen = false + } + else -> { + // CV만 간당간당 — GREEN에 머물며 재시도 (VERTICAL로 돌아가지 않음) + adjustHint = "Almost there..."; adjustIcon = "checkmark.circle"; stayInGreen = true + } } + val nextPh = if (stayInGreen) PlacementPhase.GREEN else PlacementPhase.VERTICAL return PlacementGuideResult(adjustHint, adjustIcon, 50, cv, false, lrDev, lrPass, - phase = PlacementPhase.GREEN, nextPhase = PlacementPhase.VERTICAL) + phase = PlacementPhase.GREEN, nextPhase = nextPh) } }