fix(align V1): GREEN 게이트에 ch0/ch3 edge 대칭 검사 추가

증상: 270 mL 방광인데 220 mL 로 측정 (~50 mL 저평가).
      원인 — 센서가 살짝 아래쪽에 부착돼 ch3 가 방광 하단 가장자리
      만 보고 짧게 잡힘. ch0~ch3 CV 는 0.10 임계 안에 떨어져서
      "In position!" 통과되지만 BV 결과는 저평가.

원리: CV 는 4채널 전체 변동성 — outlier 1개를 N=4 로 희석.
      예: depths (75,78,75,65) → mean 73.25, std 4.79, cv 0.065
      → CV 0.10 통과지만 ch3 가 10mm 짧음.

수정: edge 대칭 게이트 (|d_ch0 - d_ch3| / mean(d0, d3) ≤ 0.08)
      추가. CV 와 직교 — 균일 케이스는 영향 없고, edge outlier
      케이스만 잡음.

  - DEFAULT_EDGE_ASYM_THR = 0.08 도입
  - computePlacementGuide GREEN 체크에 edgeAsym/edgePass 계산
  - isPass = cv ≤ cvThr && lrPass && rPeakOk && **edgePass**
  - hint 분기: edgeAsym > 0 → "Slide up slightly ↑ (ch3 short)"
              edgeAsym < 0 → "Slide down slightly ↓ (ch0 short)"
  - Log 에 edgeAsym/edgePass 추가

CV(0.10) 는 기존 유지. edge 만 보강.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:11:53 +09:00
parent 8c07809d58
commit c3817c9f80
@@ -1496,6 +1496,9 @@ private val DEFAULT_CV_THR: Double
else -> 0.08 else -> 0.08
} }
private const val DEFAULT_LR_DEV_THR = 0.20 private const val DEFAULT_LR_DEV_THR = 0.20
/** Edge 대칭 — |d_ch0 - d_ch3| / mean(d_ch0, d_ch3). ch3/ch0 가장자리 비대칭(센서 위치 오프셋)
* 잡는 게이트. CV(전체 균일성)는 outlier 1개를 희석시키므로 별도 검사 필요. */
private const val DEFAULT_EDGE_ASYM_THR = 0.08
/** /**
* Gradient 기반 Placement Guide * Gradient 기반 Placement Guide
@@ -1867,14 +1870,33 @@ private fun computePlacementGuide(
} }
} }
// Edge 대칭 (ch0 vs ch3) — 센서 비대칭 위치 잡기.
// 정상 정렬이면 ch0/ch3 가장자리 깊이가 비슷. 한쪽이 짧으면 센서 위치가
// 그쪽 방광 가장자리로 치우침 (BV 저평가의 주 원인).
var edgeAsym = 0.0
var edgePass = true
val w0 = if (0 < walls.size) walls[0] else null
val w3 = if (3 < walls.size) walls[3] else null
if (w0 != null && w3 != null) {
val d0 = ((offset + w0.second * dps) - (offset + w0.first * dps)) *
kotlin.math.cos(degAll[0] * Math.PI / 180.0)
val d3 = ((offset + w3.second * dps) - (offset + w3.first * dps)) *
kotlin.math.cos(degAll[3] * Math.PI / 180.0)
val edgeMean = (d0 + d3) / 2.0
if (edgeMean > 0) {
edgeAsym = (d0 - d3) / edgeMean
edgePass = kotlin.math.abs(edgeAsym) <= DEFAULT_EDGE_ASYM_THR
}
}
// R-peak gate (SWEEP 모드에서만 적용) // R-peak gate (SWEEP 모드에서만 적용)
val isSweep = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode == val isSweep = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode ==
com.example.medilightv2android.managers.PlacementGuideMode.SWEEP com.example.medilightv2android.managers.PlacementGuideMode.SWEEP
val rPeakOk = !isSweep || currentRMm == null || bestRMm <= 0.0 || val rPeakOk = !isSweep || currentRMm == null || bestRMm <= 0.0 ||
(bestRMm - currentRMm) <= 1.5 (bestRMm - currentRMm) <= 1.5
val isPass = cv <= cvThr && lrPass && rPeakOk val isPass = cv <= cvThr && lrPass && rPeakOk && edgePass
android.util.Log.d("PlacementGuide", android.util.Log.d("PlacementGuide",
"GREEN check: cv=${"%.3f".format(cv)} cvThr=${"%.3f".format(cvThr)} cvOk=${cv <= cvThr} lrPass=$lrPass lrDev=${"%.3f".format(lrDev)} rPeakOk=$rPeakOk → isPass=$isPass") "GREEN check: cv=${"%.3f".format(cv)} cvThr=${"%.3f".format(cvThr)} cvOk=${cv <= cvThr} lrPass=$lrPass lrDev=${"%.3f".format(lrDev)} edgeAsym=${"%.3f".format(edgeAsym)} edgePass=$edgePass rPeakOk=$rPeakOk → isPass=$isPass")
if (isPass) { if (isPass) {
val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70) val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70)
@@ -1911,6 +1933,14 @@ private fun computePlacementGuide(
!lrPass && lrDev < -DEFAULT_LR_DEV_THR -> { !lrPass && lrDev < -DEFAULT_LR_DEV_THR -> {
adjustHint = "Slide right →"; adjustIcon = "arrow.right" adjustHint = "Slide right →"; adjustIcon = "arrow.right"
} }
// Edge 비대칭 — ch3 짧음 (센서 너무 아래) → 위로 미세 조정
!edgePass && edgeAsym > 0 -> {
adjustHint = "Slide up slightly ↑ (ch3 short)"; adjustIcon = "arrow.up"
}
// Edge 비대칭 — ch0 짧음 (센서 너무 위) → 아래로 미세 조정
!edgePass && edgeAsym < 0 -> {
adjustHint = "Slide down slightly ↓ (ch0 short)"; adjustIcon = "arrow.down"
}
gradient > 0.3 -> { gradient > 0.3 -> {
adjustHint = "Slide down ↓"; adjustIcon = "arrow.down" adjustHint = "Slide down ↓"; adjustIcon = "arrow.down"
} }