feat: 측정 가로모드 대응 + Ant Offset + Scan Log + 다중 sanity check

## 측정 화면 (PiezoMonitoringView)
- BoxWithConstraints + donutFitSize: 가로/작은 화면에서도 donut+Bladdy+buttons
  모두 들어오도록 height-aware sizing (cap 120~360dp)
- nonDevSpacing: available height 기반 단계 (18/22/28/36) — 큰 화면 답답함 해소
- center 4 중 1개라도 missing → 즉시 realign prompt (이전: AutoMeasure 5회 누적)
- 6채널 중 3개 이상 missing 시 위치 이탈 알림 (모드 무관)
- 다이얼로그 문구: "Position Lost or Insufficient Gel"
- V41 결과 후처리 — walls를 PiezoEchoAnalyzer 기준으로 강제 (UI 차트와 일관)
  · ant = analyzer.result.ant
  · post = analyzer.result.lowEnd + 3
- CH3 secondary echo 잡힘 / CH4·CH5 lateral ant too-deep 문제 해결
- Bladdy 3-tap → Developer Mode toggle (측정 화면에서도)

## Placement 가이드 (PlacementGuideView)
- 가로 모드 대응: bodyW = min(w, h*0.7f) — 태블릿 가로에서 portrait 비율 유지
- Sensor 이미지를 Canvas drawImage 로 이동 — bladder oval 좌표계와 정확히 정렬
- Start Alignment 버튼: enabled = true 명시

## Settings overlay
- "Ant Offset" 숫자 입력 필드 (0~20mm, dev mode 전용)
  · BV 계산 시 ant 를 입력값만큼 probe 쪽으로 당김 (chord 증가 → BV 증가)
  · mm→samples 변환은 PiezoHW.distancePerSample
  · V41 SphereFit BV 는 offset≠0 일 때 우회 → 6ch estimate
- "Version" row 추가 (BuildConfig.VERSION_NAME)
- Save 버튼: 실제 UserStorage 영구 저장 + state 재전파
  · AppState.savePiezoSettingsNow() 추가
- 전체 overlay 에 verticalScroll + Card maxHeight 90% — dev 컨텐츠 길어도 Save 도달

## DPS 동기화 (긴급 수정)
설정의 DPS 변경이 V41Detector(Geometry/AnatomicalGate)에 반영 안 되던 문제:
- WdConfig.DPS_DEFAULT: const val → @Volatile @JvmField var
- PiezoHW.distancePerSample setter 가 WdConfig.DPS_DEFAULT 자동 동기화
- 시작 시점 1.968.also { WdConfig.DPS_DEFAULT = it } 로 두 값 일치

## 버전
- versionName: VBTAND0101 → 2026005-101
- versionCode: 2 → 3

## 신규 모듈
- services/ScanLogStore.kt — dev mode 진단 로그 (마지막 Single Scan summary)
  · WALLS CSV (ch,ant,post,urine), DATA CSV (raw ADC), antOffsetMm
- ui/components/ScanLogDialog.kt — 클립보드 복사 다이얼로그
  · xbench (CharlesKWONsLaw) MbbLogDialog 패턴 차용
  · SelectionContainer + monospace + Copy/Clear/Close
- 측정 화면 dev panel 에 "Scan Log (N)" 버튼 추가

## 데이터 모델
- PiezoSettings.antOffsetMm: Int = 0 필드 추가

## 단위 테스트
- V41DetectorCH4Test.kt — 2026-05-06 floating-signal 캡쳐 6채널 raw 를
  V41Detector.detect 에 통과시켜 결과 출력 (CH4 가짜 wall 진단용)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Charles KWON
2026-05-12 18:01:33 +09:00
parent a1042b808c
commit 833c110d2f
10 changed files with 755 additions and 69 deletions
@@ -0,0 +1,163 @@
package com.example.medilightv2android.walldetect
import com.example.medilightv2android.walldetect.dto.ProbeProfileDto
import com.example.medilightv2android.walldetect.dto.SweepInput
import org.junit.Test
/**
* Diagnostic run for the 2026-05-06 floating-signal capture (charleskwon mail
* dated 5/6). The 100-sample stream for each of the 6 channels is fed straight
* through `V41Detector.detect(...)` so the team can see exactly which channels
* the production algorithm keeps, which it gates out, and on what score / tier.
*
* No assertions — output is meant to be read off the JUnit console. Run with
* ./gradlew :app:testDebugUnitTest --tests "*V41DetectorCH4Test*"
* or use the green-arrow gutter in Android Studio.
*/
class V41DetectorCH4Test {
@Test
fun runFloatingSignalCaptureThroughV41() {
val sweep = SweepInput(
requestId = "ch4-floating-2026-05-06",
timestampMs = 0L,
deviceId = "ckl-mail",
sweepSeq = 0,
probe = ProbeProfileDto(
name = "V1",
rMm = 50.2,
anteriorAnchorMm = 32.0,
fsHz = 537500L,
cMmPerUs = 1.54,
// V1 housing → Snell-refracted angles (informational; runtime uses WdProbe)
anglesDeg = listOf(6.89, 0.0, -6.89, -13.66, -6.87, -6.87),
),
adc = listOf(CH0, CH1, CH2, CH3, CH4, CH5),
)
val result = V41Detector().detect(sweep)
println()
println("════════════════════════════════════════════════════════════════════════")
println(" V4.1 result — floating-signal capture (CH0..CH5)")
println("════════════════════════════════════════════════════════════════════════")
println(" algorithm : ${result.algorithm} v=${result.algorithmVersion}")
println(" processingMs : ${"%.2f".format(result.processingMs)}")
println(" gatedCount : ${result.summary.gatedCount}/6")
println(" matchCount : ${result.summary.matchCount}/6")
println(" tier(sweep) : ${result.summary.tier} scoreMean=${result.summary.scoreMean}")
println(" chordMmMean : ${result.summary.chordMmMean} std=${result.summary.chordMmStd}")
println(" v41Sphere : ${result.summary.v41Sphere?.let { "R=${it.radiusMm}mm BV=${it.bvMl}mL n=${it.nPoints}" } ?: "—(gated<4)"}")
println(" bvDispatch : ${result.summary.bvDispatch?.let { "method=${it.methodChosen} bv=${it.bvMl}mL" } ?: "—"}")
println()
println(" CH | ant post | gated | score tier | sContrast tier | antMm postMm chord")
println(" ---+-----------+-------+--------------+---------------------+----------------------")
result.perChannel.forEach { cr ->
val d = cr.v41Diag
println(
" %1d | %4s %4s | %5s | %.3f %5s | %.3f %-9s | %6s %6s %6s".format(
cr.ch,
cr.antIdx?.toString() ?: "—",
cr.postIdx?.toString() ?: "—",
d?.gated?.toString() ?: "—",
d?.score ?: 0f,
d?.tier ?: "—",
d?.sContrast ?: 0f,
d?.sContrastTier ?: "—",
cr.antMm?.let { "%.1f".format(it) } ?: "—",
cr.postMm?.let { "%.1f".format(it) } ?: "—",
cr.chordMm?.let { "%.1f".format(it) } ?: "—",
)
)
}
println()
println(" Per-channel lumen + peak count")
println(" CH | lumen[start,end] | peaks(OS-CFAR pass)")
println(" ---+------------------+--------------------")
result.perChannel.forEach { cr ->
val d = cr.v41Diag
val lumen = if (cr.lumenStart != null && cr.lumenEnd != null)
"[${cr.lumenStart}, ${cr.lumenEnd}] (n=${cr.lumenEnd - cr.lumenStart})"
else "—"
val peaks = d?.peaksAll?.size ?: 0
println(" %1d | %-16s | %d peak(s): %s".format(cr.ch, lumen, peaks, d?.peaksAll?.joinToString(",") ?: ""))
}
println("════════════════════════════════════════════════════════════════════════")
}
companion object {
// 6 × 100 ADC samples — copied verbatim from the 2026-05-06 mbb log.
private val CH0 = listOf(
1840, 1686, 1626, 1471, 1653, 1621, 1558, 1563, 1510, 1743,
1498, 1405, 1092, 1382, 961, 892, 845, 848, 816, 839,
839, 829, 825, 850, 836, 831, 834, 846, 808, 826,
836, 831, 834, 835, 841, 831, 840, 816, 824, 827,
814, 837, 825, 823, 828, 841, 830, 823, 867, 1109,
1232, 1070, 1096, 1035, 1004, 951, 958, 1079, 1146, 1214,
984, 1008, 1052, 1100, 975, 1010, 1226, 922, 1305, 2221,
2003, 1867, 1439, 1199, 1119, 1322, 1081, 1053, 1135, 1136,
984, 1024, 1030, 1070, 895, 920, 990, 974, 878, 937,
841, 835, 846, 863, 863, 972, 932, 883, 877, 878,
)
private val CH1 = listOf(
1954, 1570, 1595, 1592, 1449, 1422, 1476, 1551, 1598, 1498,
1478, 1457, 1642, 1369, 1161, 890, 880, 905, 899, 888,
887, 893, 897, 897, 908, 882, 901, 876, 869, 894,
909, 894, 899, 894, 910, 886, 905, 894, 892, 882,
910, 877, 890, 886, 899, 915, 890, 888, 885, 874,
1111, 1355, 1041, 1048, 1199, 1155, 1144, 1064, 1293, 1089,
1215, 1052, 1047, 961, 987, 1192, 1256, 1187, 1596, 1831,
1648, 1426, 981, 1056, 1057, 1100, 1041, 1247, 1119, 1223,
1124, 972, 976, 943, 936, 892, 880, 905, 936, 906,
919, 890, 943, 1126, 1222, 1049, 971, 1002, 1000, 911,
)
private val CH2 = listOf(
1810, 1750, 1698, 1626, 1664, 1188, 1538, 1456, 1231, 1261,
1514, 1616, 1112, 1459, 1039, 867, 858, 835, 872, 834,
829, 847, 850, 818, 841, 822, 844, 834, 829, 862,
870, 831, 864, 882, 844, 846, 896, 867, 862, 890,
830, 876, 868, 833, 867, 874, 1115, 974, 1036, 1234,
1271, 1094, 1363, 1140, 1061, 1189, 910, 1106, 1213, 964,
1070, 1033, 1155, 1091, 1097, 965, 1203, 949, 1413, 1373,
1198, 1211, 1043, 946, 891, 1309, 1298, 1364, 1071, 1009,
990, 902, 905, 893, 901, 868, 869, 865, 862, 872,
873, 923, 911, 1369, 1089, 1132, 960, 918, 1006, 985,
)
private val CH3 = listOf(
1812, 1796, 1701, 1585, 1414, 1619, 1607, 1394, 1536, 1502,
1157, 1469, 1327, 1413, 1340, 1480, 1440, 1111, 986, 894,
916, 855, 851, 848, 856, 823, 848, 828, 844, 851,
870, 854, 853, 859, 898, 888, 946, 1093, 1185, 1081,
1210, 1089, 1217, 1145, 1007, 1112, 1278, 1155, 1099, 1205,
1037, 991, 1129, 1041, 936, 1113, 1060, 1091, 996, 918,
1073, 1154, 892, 938, 945, 1144, 1051, 1000, 1250, 1247,
999, 1144, 1176, 1184, 1940, 1890, 1593, 1155, 1150, 1012,
878, 1029, 893, 857, 852, 873, 857, 869, 842, 867,
847, 858, 858, 1119, 972, 1007, 873, 1003, 864, 879,
)
private val CH4 = listOf(
1604, 1585, 1608, 1549, 1708, 1451, 1548, 1618, 1712, 1616,
1230, 1422, 1687, 1440, 1244, 844, 895, 850, 824, 838,
871, 839, 839, 826, 818, 848, 815, 825, 849, 850,
846, 825, 834, 879, 831, 838, 818, 834, 854, 827,
848, 838, 839, 861, 830, 826, 844, 844, 856, 933,
1052, 1253, 1035, 1304, 1143, 1003, 1252, 1217, 1196, 979,
1184, 937, 1242, 1047, 1056, 961, 1150, 907, 1465, 2028,
1973, 1388, 1143, 985, 1230, 1048, 997, 1228, 1344, 1049,
913, 893, 1005, 914, 977, 880, 869, 1014, 1015, 910,
876, 905, 933, 881, 967, 950, 933, 915, 921, 915,
)
private val CH5 = listOf(
1796, 1725, 1694, 1605, 1543, 1588, 1589, 1562, 1527, 1769,
1785, 1760, 1614, 1474, 1326, 899, 872, 863, 870, 967,
887, 866, 828, 842, 852, 847, 864, 831, 845, 837,
853, 850, 867, 837, 833, 826, 820, 837, 829, 861,
843, 829, 843, 837, 826, 832, 845, 951, 1277, 1234,
1429, 1257, 1253, 1286, 1180, 1189, 1249, 1162, 1110, 972,
963, 1126, 1310, 1129, 1137, 917, 1171, 1287, 1199, 1610,
1782, 1355, 1257, 1341, 1135, 1088, 1012, 995, 955, 1041,
1182, 945, 909, 980, 956, 941, 928, 866, 880, 908,
846, 868, 900, 959, 1032, 921, 963, 898, 872, 855,
)
}
}