diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 89c3d6f..38e6a4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.example.medilightv2android" minSdk = 24 targetSdk = 36 - versionCode = 2 - versionName = "VBTAND0101" + versionCode = 3 + versionName = "2026005-101" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 4734681..6a56153 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -127,6 +127,15 @@ class AppState(private val context: Context) { currentScreen = AppScreen.PLACEMENT_GUIDE } + /** Persist piezoSettings (called from in-session settings save). */ + fun savePiezoSettingsNow() { + // Re-assign to trigger the mutableStateOf delegate so any composables + // observing piezoSettings recompose with the latest value, then persist + // to disk so the change survives process death. + piezoSettings = piezoSettings.copy() + storage.piezoSettings = piezoSettings + } + fun placementComplete() { bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold) storage.bladderLevel = 0 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 bff5639..95aaefe 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/PiezoBVEstimator.kt @@ -1,5 +1,6 @@ package com.example.medilightv2android.managers +import com.example.medilightv2android.walldetect.core.WdConfig import kotlin.math.abs import kotlin.math.cos import kotlin.math.min @@ -95,8 +96,20 @@ object PiezoHW { val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray() val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray() - /** Acoustic calibration (런타임 조절 가능) */ - @Volatile var distancePerSample: Double = 1.968 // mm/sample + /** Acoustic calibration (런타임 조절 가능). + * Setter는 walldetect.core.WdConfig.DPS_DEFAULT 도 함께 동기화한다. + * V41Detector / Geometry / AnatomicalGate 가 WdConfig 측 상수를 사용하기 + * 때문에 한 곳만 바꾸면 BV 두 경로 (6ch estimate vs V41 dispatch / gate) 가 + * 서로 다른 dps 로 계산되어 측정이 어긋난다. */ + // `.also` 로 초기값을 walldetect.core.WdConfig 측에도 동시에 반영 — 시작 + // 시점부터 두 경로의 dps 가 일치한다. + @Volatile private var _distancePerSample: Double = 1.968.also { WdConfig.DPS_DEFAULT = it } + var distancePerSample: Double + get() = _distancePerSample + set(value) { + _distancePerSample = value + WdConfig.DPS_DEFAULT = value + } const val delayOffsetMm: Double = 6.85 /** Volume model */ diff --git a/app/src/main/java/com/example/medilightv2android/models/PiezoSettings.kt b/app/src/main/java/com/example/medilightv2android/models/PiezoSettings.kt index 5308d54..ad15430 100644 --- a/app/src/main/java/com/example/medilightv2android/models/PiezoSettings.kt +++ b/app/src/main/java/com/example/medilightv2android/models/PiezoSettings.kt @@ -14,5 +14,10 @@ enum class SensorPosition(val displayName: String, val description: String) { data class PiezoSettings( var posture: Posture = Posture.SITTING, var sensorPosition: SensorPosition = SensorPosition.LOWER_ABDOMEN, - var maxVolume: Int = 500 + var maxVolume: Int = 500, + // BV calculation tweak — anterior-wall pull-in magnitude in millimetres. + // The stored value is always non-negative; BV calculation applies it as a + // negative (probe-ward) shift on the ant index. Default 0 keeps legacy + // behaviour. + var antOffsetMm: Int = 0, ) diff --git a/app/src/main/java/com/example/medilightv2android/services/ScanLogStore.kt b/app/src/main/java/com/example/medilightv2android/services/ScanLogStore.kt new file mode 100644 index 0000000..9e874a6 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/ScanLogStore.kt @@ -0,0 +1,76 @@ +package com.example.medilightv2android.services + +import com.example.medilightv2android.ble.PiezoChannelData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * Holds the **last** Single Scan summary line so it can be inspected and + * copied to the clipboard from the developer-mode log dialog. Each new + * Single Scan replaces the previous entry. Format and clipboard interface + * mirror the xbench WallCompareViewModel mbb log so the same downstream + * parsers / spreadsheets work unchanged. + */ +object ScanLogStore { + private val tsFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + private val _scanLog = MutableStateFlow>(emptyList()) + val scanLog: StateFlow> = _scanLog.asStateFlow() + + fun clear() { + _scanLog.value = emptyList() + } + + /** + * Replaces the stored line with the latest Single Scan summary. If the + * latest successful measurement produced anterior/posterior wall indices, + * a CSV block is appended so the walls can be pasted into Excel / numpy. + * `successfulWalls[ch] = (ant, post)` or `null` if that channel failed. + */ + fun appendScanSummary( + attemptCount: Int, + spotVolumes: List, + trimmedMeanMl: Double?, + successfulWalls: List?>? = null, + successfulChannels: List? = null, + antOffsetMm: Int = 0, + ) { + val ts = tsFmt.format(System.currentTimeMillis()) + val sb = StringBuilder() + sb.append("[$ts] SUMMARY attempts=$attemptCount results=${spotVolumes.size}") + if (trimmedMeanMl != null) { + sb.append(" trimmedMean=${"%.1f".format(trimmedMeanMl)}ml") + if (spotVolumes.isNotEmpty()) { + sb.append(" values=[${spotVolumes.joinToString(", ") { "%.1f".format(it) }}]") + } + } else { + sb.append(" ✗ insufficient results") + } + if (antOffsetMm != 0) { + sb.append(" antOffset=-${antOffsetMm}mm") + } + if (successfulWalls != null) { + sb.append("\n WALLS (CSV — ch,ant,post,urine):") + successfulWalls.forEachIndexed { ch, w -> + if (w != null) { + sb.append("\n CH$ch,${w.first},${w.second},${w.second - w.first}") + } else { + sb.append("\n CH$ch,,,") + } + } + } + if (successfulChannels != null) { + sb.append("\n DATA (CSV — one row per channel, comma-separated samples):") + successfulChannels.sortedBy { it.channel }.forEach { ch -> + val s = ch.buffer + val csv = if (s.isEmpty()) "—" + else s.joinToString(",") { (it.toInt() and 0xFFFF).toString() } + sb.append("\n CH${ch.channel}[${s.size}]: $csv") + } + } + _scanLog.value = listOf(sb.toString()) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/components/ScanLogDialog.kt b/app/src/main/java/com/example/medilightv2android/ui/components/ScanLogDialog.kt new file mode 100644 index 0000000..cc7f152 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/components/ScanLogDialog.kt @@ -0,0 +1,173 @@ +/* + * Scan-log diagnostic dialog. Format and interface mirror the xbench + * MbbLogDialog (CharlesKWONsLaw / wallcompare) so the same downstream + * clipboard / spreadsheet workflow applies: each entry is one Single Scan + * measurement, newest at the bottom, monospace, long-press to select spans + * across entries. Copy puts the full log on the system clipboard. + */ +package com.example.medilightv2android.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun ScanLogDialog( + log: List, + onClear: () -> Unit, + onDismiss: () -> Unit, +) { + val ctx = LocalContext.current + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val listState = rememberLazyListState() + LaunchedEffect(log.size) { + if (log.isNotEmpty()) listState.scrollToItem(log.size - 1) + } + + Box(Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF182338), RoundedCornerShape(12.dp)) + .border(1.dp, Color(0xFF2D394F), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.0001f)) { } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.fillMaxWidth(0.7f)) { + Text( + text = "scan diagnostic log", + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${log.size} entries (newest at bottom)", + color = Color.White.copy(0.5f), + fontSize = 11.sp, + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(Color(0xFF0F1F3D), RoundedCornerShape(6.dp)) + .border(1.dp, Color(0xFF1F2C44), RoundedCornerShape(6.dp)) + .padding(8.dp), + ) { + if (log.isEmpty()) { + Text( + text = "no scan attempts yet — press Single Scan", + color = Color.White.copy(0.4f), + fontSize = 12.sp, + modifier = Modifier.align(Alignment.Center), + ) + } else { + SelectionContainer { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(log) { entry -> + val isFail = entry.contains(" ✗ ") + Text( + text = entry, + color = if (isFail) Color(0xFFA6192E) + else Color(0xFFD9D9D6), + fontSize = 10.sp, + lineHeight = 12.sp, + fontFamily = FontFamily.Monospace, + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { + val text = log.joinToString(separator = "\n────\n") + copyToClipboard(ctx, text) + Toast.makeText( + ctx, + "Copied ${log.size} entries to clipboard", + Toast.LENGTH_SHORT, + ).show() + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFF00A7B5), + ), + enabled = log.isNotEmpty(), + ) { Text("Copy") } + OutlinedButton( + onClick = onClear, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White.copy(0.7f), + ), + ) { Text("Clear") } + Button( + onClick = onDismiss, + modifier = Modifier.weight(1.2f), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073CF)), + ) { Text("Close") } + } + } + } + } +} + +private fun copyToClipboard(ctx: Context, text: String) { + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + cm?.setPrimaryClip(ClipData.newPlainText("VesiScan scan log", text)) +} 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 5911e4f..13411a4 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -49,10 +50,12 @@ import com.example.medilightv2android.managers.PiezoHW import com.example.medilightv2android.managers.estimateBladderVolume6ch import com.example.medilightv2android.models.BladderLevel import com.example.medilightv2android.models.UrgencyLevel +import com.example.medilightv2android.services.ScanLogStore import com.example.medilightv2android.ui.components.BladdyRiveView import com.example.medilightv2android.ui.components.BleDisconnectionBanner import com.example.medilightv2android.ui.components.BleReconnectionState import com.example.medilightv2android.ui.components.DonutChartView +import com.example.medilightv2android.ui.components.ScanLogDialog import com.example.medilightv2android.R import com.example.medilightv2android.ui.theme.* import androidx.compose.ui.res.stringResource @@ -75,6 +78,11 @@ fun PiezoMonitoringView(appState: AppState) { var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } var isAutoMeasuring by remember { mutableStateOf(false) } var showDevPanels by remember { mutableStateOf(false) } + var showScanLogDialog by remember { mutableStateOf(false) } + val scanLogState by ScanLogStore.scanLog.collectAsState() + // Triple-tap Bladdy to toggle Developer Mode (parity with HomeView) + var devTapCount by remember { mutableIntStateOf(0) } + var devLastTapTime by remember { mutableLongStateOf(0L) } var isPlacementTesting by remember { mutableStateOf(false) } var measureRetryCount by remember { mutableIntStateOf(0) } val maxMeasureRetries = 2 @@ -85,12 +93,21 @@ fun PiezoMonitoringView(appState: AppState) { var lastMaxUpdateMs by remember { mutableStateOf(0L) } var isSpotInProgress by remember { mutableStateOf(false) } val spotVolumes = remember { mutableListOf() } + // Last successful (ant, post) wall pair per channel captured during Single Scan + var spotSuccessfulWalls by remember { mutableStateOf?>?>(null) } + // Raw ADC channels paired with the above walls — same measurement snapshot + var spotSuccessfulChannels by remember { mutableStateOf?>(null) } val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred()) } val lastMultiChannelResult = remember { mutableStateListOf() } var lastAllWalls by remember { mutableStateOf?>>(List(6) { null }) } val analyzer = remember { PiezoEchoAnalyzer.shared } val screenWidth = LocalConfiguration.current.screenWidthDp + val screenHeight = LocalConfiguration.current.screenHeightDp + // donut/Bladdy 같은 정사각 sizing은 portrait/landscape 양쪽에서 화면 밖으로 나가지 + // 않도록 min(width, height) 를 기준 차원으로 쓴다. padding/font 등 가로 폭 기반 + // sizing은 screenWidth 를 그대로 사용. + val refDim = kotlin.math.min(screenWidth, screenHeight) val isCompact = screenWidth < 400 // 태블릿(600dp+) 글자 스케일: 폰=1.0, 태블릿=~1.6 val fontScale = (screenWidth / 360f).coerceIn(1.0f, 1.8f) @@ -203,11 +220,23 @@ fun PiezoMonitoringView(appState: AppState) { sweepSeq = 0, probe = probe, adc = adcMatrix ) val result = v41.detect(sweepInput) - for (ch in result.perChannel) { - val gated = ch.v41Diag?.gated == true - if (gated && ch.antIdx != null && ch.postIdx != null && ch.ch < 6 - && (ch.postIdx - ch.antIdx) >= 3) { - allWalls[ch.ch] = Pair(ch.antIdx, ch.postIdx) + // 사용자 의도(2026-05-12): walls를 UI 차트와 정확히 일관 유지 — V41 ant/post + // 무시하고 PiezoEchoAnalyzer 기준 사용. lateral 채널(CH4/5)에서 V41이 ant를 + // 뒤로 잡는 문제도 동시에 해결. + // • ant = analyzer.result.ant (차트의 녹색 ant marker) + // • post = analyzer.result.lowEnd + 3 (녹색 lumen 끝 + 3칸) + val analyzerForLumen = analyzer.analyzeMultiChannel(validChannels) + for (ch in 0..5) { + val v41Ch = result.perChannel.firstOrNull { it.ch == ch } + val gated = v41Ch?.v41Diag?.gated == true + val analyzerCh = analyzerForLumen.channels + .firstOrNull { it.channel == ch } + val analyzerAnt = analyzerCh?.result?.ant + val analyzerLowEnd = analyzerCh?.result?.lowEnd + val effectivePost = analyzerLowEnd?.let { it + 3 } + if (gated && analyzerAnt != null && effectivePost != null + && (effectivePost - analyzerAnt) >= 3) { + allWalls[ch] = Pair(analyzerAnt, effectivePost) } } methodCBvMl = result.summary.bvDispatch?.bvMl?.toDouble() @@ -262,9 +291,31 @@ fun PiezoMonitoringView(appState: AppState) { val rawADC = channels.sortedBy { it.channel }.map { it.buffer } val bvMethodSetting = com.example.medilightv2android.managers.GreenZoneConstants.bvMethod + // ── Ant-offset adjustment for BV calculation ─────────────────────────────── + // The user-facing setting is a non-negative magnitude in mm. BV calculation + // subtracts it from each channel's anterior wall index (probe-ward shift). + // Default 0 keeps legacy behaviour. When non-zero we skip the V41 SphereFit + // BV (it can't be re-fitted from indices alone) and route to the 6-channel + // estimator with the adjusted walls so the user sees a consistent BV. + val antOffsetMm = appState.piezoSettings.antOffsetMm + val antOffsetSamples = + if (antOffsetMm != 0) + (antOffsetMm.toDouble() / + com.example.medilightv2android.managers.PiezoHW.distancePerSample).toInt() + else 0 + val effectiveAllWalls: List?> = + if (antOffsetSamples == 0) allWalls + else allWalls.map { w -> + if (w == null) null + else Pair(maxOf(0, w.first - antOffsetSamples), w.second) + } + val effectiveCenterWalls = (0..3).count { effectiveAllWalls[it] != null } + val useV41Bv = method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C && bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41 && methodCBvMl != null + && antOffsetSamples == 0 + && effectiveCenterWalls >= 4 // center 4 모두 필요 if (useV41Bv) { rawVolumeMl = methodCBvMl!! @@ -273,8 +324,19 @@ fun PiezoMonitoringView(appState: AppState) { (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) logService.logMeasurement(methodCBvMl, channelLogs, rawADC) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl) - } else if (centerWallCount >= 1) { - val bvResult = estimateBladderVolume6ch(allWalls) + } else if (effectiveCenterWalls >= 4) { + // 검증 로그 — ant offset 비활성(0)이 아닐 때 chord 변화 출력 + if (antOffsetSamples != 0) { + val antOrig = (0..3).map { allWalls[it]?.first ?: -1 } + val antAdj = (0..3).map { effectiveAllWalls[it]?.first ?: -1 } + val chordOrig = (0..3).map { allWalls[it]?.let { w -> w.second - w.first } ?: -1 } + val chordAdj = (0..3).map { effectiveAllWalls[it]?.let { w -> w.second - w.first } ?: -1 } + Log.d("PiezoMonitor", "ANT_OFFSET verify: mm=$antOffsetMm samples=$antOffsetSamples " + + "ant_orig=$antOrig ant_adj=$antAdj " + + "chord_orig=$chordOrig chord_adj=$chordAdj " + + "chord_delta(samples)=${chordAdj.mapIndexed { i, c -> if (c >= 0 && chordOrig[i] >= 0) c - chordOrig[i] else 0 }}") + } + val bvResult = estimateBladderVolume6ch(effectiveAllWalls) if (bvResult != null) { rawVolumeMl = bvResult.volumeMl rawLrRatio = bvResult.lrRatio @@ -335,11 +397,13 @@ fun PiezoMonitoringView(appState: AppState) { } } - // Auto 연속 실패 → UI에서 처리 - // 채널 2개 이상 미수신 또는 BV 계산 불가 + // 측정 모드: 6채널 중 3개 이상 missing → 위치 이탈로 즉시 판정. + // 'allChannelsReceived' 가드로 BLE 거리/패킷 문제와 구분한다. val centerWallsFinal = (0..3).count { allWalls[it] != null } - val autoFailed = isAutoMeasuring && (centerWallsFinal < 2 || finalRawVol == null || finalRawVol <= 0) + val totalWallsFinal = allWalls.count { it != null } + val missingChannels = 6 - totalWallsFinal val autoSucceeded = isAutoMeasuring && finalRawVol != null && finalRawVol > 0 && allChannelsReceived + val placementLost = missingChannels >= 3 && allChannelsReceived // displayMaxVolumeMl 5초 간격 갱신 val nowMs = System.currentTimeMillis() @@ -354,16 +418,31 @@ fun PiezoMonitoringView(appState: AppState) { val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) } - // Auto 연속 실패 카운트 if (autoSucceeded) autoFailCount = 0 - if (autoFailed) { - autoFailCount++ - if (autoFailCount >= 5) { + + // 6채널 중 3개 이상 안 잡힘 → 즉시 realign 알림 (모드 무관). + if (placementLost && !showRealignPrompt) { + if (isAutoMeasuring) { isAutoMeasuring = false autoVolumeWindow.clear() autoFailCount = 0 - showRealignPrompt = true } + if (isSpotInProgress) { + // SingleScan 진행 중이면 spot 종료 (남은 시도 중단) + isSpotInProgress = false + spotVolumes.clear() + } + Log.w("PiezoMonitor", "POSITION_LOST — missing=$missingChannels/6 (walls=$totalWallsFinal/6, center=$centerWallsFinal/4), prompting realign") + showRealignPrompt = true + } + // Capture last walls with ≥1 detected (ant, post) pair — even when + // BV calculation failed (e.g. center channels missing). Used by + // the Single Scan dev log so the latest partial-detection sticks. + // Raw ADC channels are captured from the same snapshot so the CSV + // dump in the log aligns with the WALLS block. + if (isSpotInProgress && wallsCopy.any { it != null }) { + spotSuccessfulWalls = wallsCopy + spotSuccessfulChannels = channels.toList() } isMeasuring = false measureRetryCount = 0 @@ -846,17 +925,34 @@ fun PiezoMonitoringView(appState: AppState) { } Box(modifier = Modifier.weight(1f)) { + // BoxWithConstraints — 사용 가능한 height 를 측정해 donut/Bladdy 크기를 동적으로 + // 결정한다. 가로 모드 / 작은 화면에서도 progress + 다른 컨텐츠가 모두 들어오도록. + androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val availableH = this.maxHeight + // 비-donut 컨텐츠(status badge + 정보 카드 + 버튼 + spacing + padding) 예약치. + // 이 값 빼고 남은 공간이 donut. + val nonDonutBudget = if (appState.isDevMode) 280.dp else 360.dp + // donut max를 키워서 큰 화면(태블릿 portrait, 가로 태블릿)에서도 답답하지 않게. + val donutFitSize = (availableH - nonDonutBudget).coerceIn(120.dp, 360.dp) + // spacing 도 화면 높이에 비례 — 큰 화면에서 컨텐츠 사이 여백 넉넉히. + val nonDevSpacing = when { + availableH > 800.dp -> 36.dp + availableH > 600.dp -> 28.dp + isCompact -> 18.dp + else -> 22.dp + } Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) + // dev 모드만 scroll — 일반 사용자에겐 fit-to-screen 고정 레이아웃 + .then(if (appState.isDevMode) Modifier.verticalScroll(scrollState) else Modifier) .padding(horizontal = hPad) .padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = if (appState.isDevMode) Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp) else - Arrangement.spacedBy((screenWidth * 0.08f).dp.coerceIn(20.dp, 48.dp)) + Arrangement.spacedBy(nonDevSpacing) ) { // Status badge (항상 urgency 메시지) @@ -873,15 +969,26 @@ fun PiezoMonitoringView(appState: AppState) { .padding(horizontal = 20.dp, vertical = 8.dp) ) - // Donut chart with Bladdy — 화면 비율 기반 - val screenWidthDp = screenWidth.dp - val donutRatio = if (appState.isDevMode) 0.65f else 0.75f - val donutSize = screenWidthDp * donutRatio - val lineWidthRatio = if (appState.isDevMode) 0.07f else 0.08f - val donutLineWidth = donutSize * lineWidthRatio - val bladdyRatio = if (appState.isDevMode) 0.30f else 0.55f - val bladdySize = donutSize * bladdyRatio - val volFontSize = if (appState.isDevMode) 18.sp else (screenWidth * 0.06f).sp + // Donut + Bladdy sizing — BoxWithConstraints 의 availableH 기반. + // - dev 모드: isCompact 기반 고정 dp (scroll 가능) + // - non-dev: donutFitSize 사용 → 화면 높이에 맞춰 자동 축소 (120~240dp cap) + val baseDonutSize = if (appState.isDevMode) { + if (isCompact) 192.dp else 244.dp + } else { + donutFitSize + } + val donutSize = baseDonutSize * 0.95f + val donutLineWidth = if (appState.isDevMode) { + if (isCompact) 22.dp else 30.dp + } else { + if (isCompact) 26.dp else 34.dp + } + val bladdySize = if (appState.isDevMode) { + if (isCompact) 80.dp else 110.dp + } else { + donutSize * 0.85f // donut 내부에 항상 들어가는 크기 + } + val volFontSize = if (appState.isDevMode) 18.sp else 24.sp val maxVolumeForDonut = appState.piezoSettings.maxVolume.toDouble() val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0) @@ -908,7 +1015,31 @@ fun PiezoMonitoringView(appState: AppState) { verticalArrangement = Arrangement.Center, modifier = Modifier.matchParentSize().offset(y = -(donutSize * 0.03f)) ) { - BladdyRiveView(urgency = urgency, size = bladdySize) + Box(contentAlignment = Alignment.Center) { + BladdyRiveView(urgency = urgency, size = bladdySize) + // Transparent overlay — Rive AndroidView swallows touches, + // so we layer a clickable Box on top to catch the 3-tap + // dev-mode gesture (same trick HomeView uses). + Box( + modifier = Modifier + .size(bladdySize) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + val now = System.currentTimeMillis() + if (now - devLastTapTime > 1500) devTapCount = 0 + devLastTapTime = now + devTapCount++ + if (devTapCount >= 3) { + devTapCount = 0 + appState.isDevMode = !appState.isDevMode + val msg = if (appState.isDevMode) "Developer mode ON" else "Developer mode OFF" + android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show() + } + } + ) + } Spacer(modifier = Modifier.height(4.dp)) Text( if (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} mL" else "${appState.estimatedVolume} mL", @@ -996,6 +1127,8 @@ fun PiezoMonitoringView(appState: AppState) { isSpotInProgress = true spotCooldown = true spotVolumes.clear() + spotSuccessfulWalls = null + spotSuccessfulChannels = null spotScope.launch { val maxAttempts = 8 val targetCount = 5 @@ -1007,17 +1140,28 @@ fun PiezoMonitoringView(appState: AppState) { kotlinx.coroutines.delay(100) // throttle(800ms)이 간격 보장 } Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}") - if (spotVolumes.size >= 3) { - val result = trimmedMean(spotVolumes.toList()) - lastVolumeMl = result - maxVolumeMl = maxOf(maxVolumeMl, result) + val finalSpotVolumes = spotVolumes.toList() + val trimmedResult = if (finalSpotVolumes.size >= 3) trimmedMean(finalSpotVolumes) else null + if (trimmedResult != null) { + lastVolumeMl = trimmedResult + maxVolumeMl = maxOf(maxVolumeMl, trimmedResult) displayMaxVolumeMl = maxVolumeMl // Spot은 즉시 갱신 - Log.d("PiezoMonitor", "SPOT trimmedMean=${"%.1f".format(result)}ml from ${spotVolumes.map { "%.0f".format(it) }}") + Log.d("PiezoMonitor", "SPOT trimmedMean=${"%.1f".format(trimmedResult)}ml from ${finalSpotVolumes.map { "%.0f".format(it) }}") val maxVol = appState.piezoSettings.maxVolume.toDouble() - val pct = if (maxVol > 0) (result / maxVol).coerceAtMost(1.0) else 0.0 + val pct = if (maxVol > 0) (trimmedResult / maxVol).coerceAtMost(1.0) else 0.0 val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) } + if (appState.isDevMode) { + ScanLogStore.appendScanSummary( + attemptCount = attempts, + spotVolumes = finalSpotVolumes, + trimmedMeanMl = trimmedResult, + successfulWalls = spotSuccessfulWalls, + successfulChannels = spotSuccessfulChannels, + antOffsetMm = appState.piezoSettings.antOffsetMm, + ) + } spotVolumes.clear() isSpotInProgress = false handler.postDelayed({ spotCooldown = false }, 1000) @@ -1078,8 +1222,33 @@ fun PiezoMonitoringView(appState: AppState) { // BLE Debug Log Spacer(modifier = Modifier.height(8.dp)) BleDebugPanel(bleManager = bleManager) + + // Scan Log — Single Scan captured diagnostics (xbench mbb-log format) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { showScanLogDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073CF)) + ) { + Text( + "Scan Log (${scanLogState.size})", + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } } } // end scrollable Column + } // end BoxWithConstraints + + if (showScanLogDialog) { + ScanLogDialog( + log = scanLogState, + onClear = { ScanLogStore.clear() }, + onDismiss = { showScanLogDialog = false }, + ) + } // ── Floating Settings Overlay ── if (showSettings) { @@ -1093,11 +1262,17 @@ fun PiezoMonitoringView(appState: AppState) { colors = CardDefaults.cardColors(containerColor = MlCardBackground.copy(alpha = 0.97f)), modifier = Modifier .fillMaxWidth() + .fillMaxHeight(0.9f) // dev mode 추가 컨텐츠가 화면 밖으로 밀려 Save 버튼 안 보이는 문제 방지 .padding(horizontal = 16.dp, vertical = 12.dp) .align(Alignment.TopCenter) .shadow(12.dp, RoundedCornerShape(16.dp)) ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { // Max Volume Row(verticalAlignment = Alignment.CenterVertically) { Text("Bladder Capacity", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) @@ -1112,6 +1287,38 @@ fun PiezoMonitoringView(appState: AppState) { colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary) ) + + // Ant Offset — 개발자 모드 전용 숫자 입력. BV 계산 시 전위벽 위치를 probe 쪽으로 + // 이 mm만큼 당기는 보정값. 0~20 mm. 1 mm = 약 0.52 sample이므로 정수 truncation에 + // 유의 (2 mm마다 1 sample씩 chord가 늘어남). + if (appState.isDevMode) { + HorizontalDivider() + var antOffsetText by remember { + mutableStateOf(appState.piezoSettings.antOffsetMm.toString()) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Ant Offset", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.weight(1f)) + OutlinedTextField( + value = antOffsetText, + onValueChange = { newText -> + val filtered = newText.filter { it.isDigit() }.take(2) + antOffsetText = filtered + val parsed = filtered.toIntOrNull() ?: 0 + val clamped = parsed.coerceIn(0, 20) + appState.piezoSettings = + appState.piezoSettings.copy(antOffsetMm = clamped) + }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number + ), + singleLine = true, + suffix = { Text("mm", fontSize = 12.sp) }, + modifier = Modifier.width(110.dp), + ) + } + } + HorizontalDivider() // Catheterization Threshold @@ -1303,7 +1510,26 @@ fun PiezoMonitoringView(appState: AppState) { HorizontalDivider() Spacer(modifier = Modifier.height(8.dp)) - // Save / Cancel + // Version (BuildConfig.VERSION_NAME) — read-only + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Version", fontSize = 13.sp, color = MlSecondaryText) + Spacer(modifier = Modifier.weight(1f)) + Text( + com.example.medilightv2android.BuildConfig.VERSION_NAME, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MlPrimary, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + // Save / Cancel — Save가 piezoSettings을 영구 저장 + state 전파 (다음 측정에 반영) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -1314,7 +1540,10 @@ fun PiezoMonitoringView(appState: AppState) { } Spacer(modifier = Modifier.width(8.dp)) Button( - onClick = { showSettings = false }, + onClick = { + appState.savePiezoSettingsNow() + showSettings = false + }, shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) ) { @@ -1330,8 +1559,8 @@ fun PiezoMonitoringView(appState: AppState) { if (showRealignPrompt) { AlertDialog( onDismissRequest = { showRealignPrompt = false }, - title = { Text("Position lost") }, - text = { Text("Sensor may have shifted. Restart from alignment.") }, + title = { Text("Position Lost or Insufficient Gel") }, + text = { Text("Position lost or gel may be insufficient. Please check.") }, confirmButton = { Button(onClick = { showRealignPrompt = false 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 a720c11..66b4e69 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.platform.LocalContext @@ -252,12 +253,24 @@ fun PlacementGuideView(appState: AppState) { .then(if (appState.isDevMode) Modifier.height(400.dp) else Modifier.weight(1f)), contentAlignment = Alignment.Center ) { + // Sensor PNG → ImageBitmap (Canvas drawImage로 그려 oval 중심과 정렬) + val sensorBitmap = remember { + android.graphics.BitmapFactory.decodeResource( + context.resources, + com.example.medilightv2android.R.drawable.sensor_device, + ).asImageBitmap() + } // 하복부 실루엣 (Canvas — CKLaw body silhouette) val bodyAlpha = 0.9f // 항상 동일 (#5) Canvas(modifier = Modifier.fillMaxSize()) { val w = size.width val h = size.height val cx = w / 2f + // Cap body silhouette width by height-derived budget so the + // figure stays portrait-proportioned on wide screens / 가로 모드. + // Portrait phone: bodyW == w (no change). Tablet / landscape: + // bodyW < w, body stays centered with cx = w / 2. + val bodyW = kotlin.math.min(w, h * 0.7f) val greenZone = isGreenZone // Body silhouette palette @@ -271,9 +284,9 @@ fun PlacementGuideView(appState: AppState) { val waistY = h * 0.42f val hipY = h * 0.76f val pubisY = h * 0.96f - val chestW = w * 0.42f - val waistW = w * 0.32f - val hipW = w * 0.46f + val chestW = bodyW * 0.42f + val waistW = bodyW * 0.32f + val hipW = bodyW * 0.46f // ── Torso silhouette — hourglass via cubic bezier ── val bodyPath = Path().apply { @@ -348,6 +361,26 @@ fun PlacementGuideView(appState: AppState) { style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))) ) + // ── Sensor device (Canvas 안에서 drawImage — oval 중심에 정렬) ── + // 너비를 bladder oval 직경 × 1.15 로 묶어 화면 비율 무관하게 비례. + val sensorTargetW = (bladderRx * 2f) * 1.15f + val sensorAspect = sensorBitmap.height.toFloat() / sensorBitmap.width.toFloat() + val sensorTargetH = sensorTargetW * sensorAspect + drawImage( + image = sensorBitmap, + srcOffset = androidx.compose.ui.unit.IntOffset.Zero, + srcSize = androidx.compose.ui.unit.IntSize(sensorBitmap.width, sensorBitmap.height), + dstOffset = androidx.compose.ui.unit.IntOffset( + (cx - sensorTargetW / 2f).toInt(), + (bladderCy - sensorTargetH / 2f).toInt(), + ), + dstSize = androidx.compose.ui.unit.IntSize( + sensorTargetW.toInt(), + sensorTargetH.toInt(), + ), + alpha = 0.95f, + ) + // ── 방향 화살표 (센서/타원 바깥, 충분한 간격) ── if (!greenZone) { val arrowColor = Color(0xFFFF5722) @@ -387,27 +420,7 @@ fun PlacementGuideView(appState: AppState) { } } - // 센서 기기 이미지 (방광 타원 중앙에 정렬) - // 센서 — 화면 비율 기반 크기/위치 (태블릿 대응) - // 타원 중심 ≈ 높이 72%. BiasAlignment(0, 0.44) ≈ 72% - // 센서 너비 = 박스 너비의 35% - androidx.compose.foundation.layout.BoxWithConstraints( - modifier = Modifier.matchParentSize() - ) { - val sensorW = maxWidth * 0.35f - val sensorH = sensorW * 0.625f - androidx.compose.foundation.Image( - painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device), - contentDescription = "Sensor", - modifier = Modifier - .size(width = sensorW, height = sensorH) - .align(androidx.compose.ui.BiasAlignment(0f, 0.55f)), - contentScale = androidx.compose.ui.layout.ContentScale.Fit, - alpha = 0.95f - ) - } - - + // (sensor 이미지는 위 Canvas drawImage 로 이동 — body silhouette과 동일 좌표계) } // (힌트 텍스트는 Step 아래, 몸 위에 배치됨) @@ -785,7 +798,7 @@ fun PlacementGuideView(appState: AppState) { } } - // Start button (초기 / 재시작 대기 시) + // Start button (초기 / 재시작 대기 시). 화면 진입 직후엔 항상 클릭 가능. if (waitingForStart) { Button( onClick = { @@ -810,6 +823,7 @@ fun PlacementGuideView(appState: AppState) { currentRMm = null; bestRMm = 0.0 sagittalArgMaxCh = null; lateralOffsetMm = null }, + enabled = true, // 화면 진입 / 재진입 시 무조건 클릭 가능 보장 modifier = Modifier .fillMaxWidth() .height(96.dp), diff --git a/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt b/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt index 600c434..3b246b7 100644 --- a/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt +++ b/app/src/main/java/com/example/medilightv2android/walldetect/core/Config.kt @@ -34,7 +34,11 @@ object WdConfig { // Phantom : D(100.406 mm) / chord(52 samples) = 1.9309 mm/sp // K : c_eff / c_ref = 1.000 // 결과적으로 amode_simulator 와 동일 좌표계 → 530 mL phantom 시 R≈50.20mm 회복. - const val DPS_DEFAULT = 1.9309 + // Was `const val` — promoted to mutable so settings can override it at + // runtime (UI "DPS" field syncs PiezoHW.distancePerSample → DPS_DEFAULT + // so V41Detector's Geometry / AnatomicalGate use the same value the + // 6-channel BV estimator uses). + @Volatile @JvmField var DPS_DEFAULT: Double = 1.9309 const val DELAY_MM_DEFAULT = 6.85 const val C_EFF_DEFAULT = 1530.0 diff --git a/app/src/test/java/com/example/medilightv2android/walldetect/V41DetectorCH4Test.kt b/app/src/test/java/com/example/medilightv2android/walldetect/V41DetectorCH4Test.kt new file mode 100644 index 0000000..ca8ebda --- /dev/null +++ b/app/src/test/java/com/example/medilightv2android/walldetect/V41DetectorCH4Test.kt @@ -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, + ) + } +}