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
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.example.medilightv2android" applicationId = "com.example.medilightv2android"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 3
versionName = "VBTAND0101" versionName = "2026005-101"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -127,6 +127,15 @@ class AppState(private val context: Context) {
currentScreen = AppScreen.PLACEMENT_GUIDE 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() { fun placementComplete() {
bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold) bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold)
storage.bladderLevel = 0 storage.bladderLevel = 0
@@ -1,5 +1,6 @@
package com.example.medilightv2android.managers package com.example.medilightv2android.managers
import com.example.medilightv2android.walldetect.core.WdConfig
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.min import kotlin.math.min
@@ -95,8 +96,20 @@ object PiezoHW {
val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray() val degree: DoubleArray get() = centerCh.map { degreeAll[it] }.toDoubleArray()
val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray() val degreeLR: DoubleArray get() = centerCh.map { degreeLRAll[it] }.toDoubleArray()
/** Acoustic calibration (런타임 조절 가능) */ /** Acoustic calibration (런타임 조절 가능).
@Volatile var distancePerSample: Double = 1.968 // mm/sample * 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 const val delayOffsetMm: Double = 6.85
/** Volume model */ /** Volume model */
@@ -14,5 +14,10 @@ enum class SensorPosition(val displayName: String, val description: String) {
data class PiezoSettings( data class PiezoSettings(
var posture: Posture = Posture.SITTING, var posture: Posture = Posture.SITTING,
var sensorPosition: SensorPosition = SensorPosition.LOWER_ABDOMEN, 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,
) )
@@ -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<List<String>>(emptyList())
val scanLog: StateFlow<List<String>> = _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<Double>,
trimmedMeanMl: Double?,
successfulWalls: List<Pair<Int, Int>?>? = null,
successfulChannels: List<PiezoChannelData>? = 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())
}
}
@@ -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<String>,
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))
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState 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.managers.estimateBladderVolume6ch
import com.example.medilightv2android.models.BladderLevel import com.example.medilightv2android.models.BladderLevel
import com.example.medilightv2android.models.UrgencyLevel 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.BladdyRiveView
import com.example.medilightv2android.ui.components.BleDisconnectionBanner import com.example.medilightv2android.ui.components.BleDisconnectionBanner
import com.example.medilightv2android.ui.components.BleReconnectionState import com.example.medilightv2android.ui.components.BleReconnectionState
import com.example.medilightv2android.ui.components.DonutChartView import com.example.medilightv2android.ui.components.DonutChartView
import com.example.medilightv2android.ui.components.ScanLogDialog
import com.example.medilightv2android.R import com.example.medilightv2android.R
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -75,6 +78,11 @@ fun PiezoMonitoringView(appState: AppState) {
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
var isAutoMeasuring by remember { mutableStateOf(false) } var isAutoMeasuring by remember { mutableStateOf(false) }
var showDevPanels 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 isPlacementTesting by remember { mutableStateOf(false) }
var measureRetryCount by remember { mutableIntStateOf(0) } var measureRetryCount by remember { mutableIntStateOf(0) }
val maxMeasureRetries = 2 val maxMeasureRetries = 2
@@ -85,12 +93,21 @@ fun PiezoMonitoringView(appState: AppState) {
var lastMaxUpdateMs by remember { mutableStateOf(0L) } var lastMaxUpdateMs by remember { mutableStateOf(0L) }
var isSpotInProgress by remember { mutableStateOf(false) } var isSpotInProgress by remember { mutableStateOf(false) }
val spotVolumes = remember { mutableListOf<Double>() } val spotVolumes = remember { mutableListOf<Double>() }
// Last successful (ant, post) wall pair per channel captured during Single Scan
var spotSuccessfulWalls by remember { mutableStateOf<List<Pair<Int, Int>?>?>(null) }
// Raw ADC channels paired with the above walls — same measurement snapshot
var spotSuccessfulChannels by remember { mutableStateOf<List<PiezoChannelData>?>(null) }
val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred<Unit>()) } val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred<Unit>()) }
val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() } val lastMultiChannelResult = remember { mutableStateListOf<PiezoChannelData>() }
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) } var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
val analyzer = remember { PiezoEchoAnalyzer.shared } val analyzer = remember { PiezoEchoAnalyzer.shared }
val screenWidth = LocalConfiguration.current.screenWidthDp 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 val isCompact = screenWidth < 400
// 태블릿(600dp+) 글자 스케일: 폰=1.0, 태블릿=~1.6 // 태블릿(600dp+) 글자 스케일: 폰=1.0, 태블릿=~1.6
val fontScale = (screenWidth / 360f).coerceIn(1.0f, 1.8f) val fontScale = (screenWidth / 360f).coerceIn(1.0f, 1.8f)
@@ -203,11 +220,23 @@ fun PiezoMonitoringView(appState: AppState) {
sweepSeq = 0, probe = probe, adc = adcMatrix sweepSeq = 0, probe = probe, adc = adcMatrix
) )
val result = v41.detect(sweepInput) val result = v41.detect(sweepInput)
for (ch in result.perChannel) { // 사용자 의도(2026-05-12): walls를 UI 차트와 정확히 일관 유지 — V41 ant/post
val gated = ch.v41Diag?.gated == true // 무시하고 PiezoEchoAnalyzer 기준 사용. lateral 채널(CH4/5)에서 V41이 ant를
if (gated && ch.antIdx != null && ch.postIdx != null && ch.ch < 6 // 뒤로 잡는 문제도 동시에 해결.
&& (ch.postIdx - ch.antIdx) >= 3) { // • ant = analyzer.result.ant (차트의 녹색 ant marker)
allWalls[ch.ch] = Pair(ch.antIdx, ch.postIdx) // • 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() methodCBvMl = result.summary.bvDispatch?.bvMl?.toDouble()
@@ -262,9 +291,31 @@ fun PiezoMonitoringView(appState: AppState) {
val rawADC = channels.sortedBy { it.channel }.map { it.buffer } val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
val bvMethodSetting = com.example.medilightv2android.managers.GreenZoneConstants.bvMethod 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<Pair<Int, Int>?> =
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 val useV41Bv = method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C
&& bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41 && bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41
&& methodCBvMl != null && methodCBvMl != null
&& antOffsetSamples == 0
&& effectiveCenterWalls >= 4 // center 4 모두 필요
if (useV41Bv) { if (useV41Bv) {
rawVolumeMl = methodCBvMl!! rawVolumeMl = methodCBvMl!!
@@ -273,8 +324,19 @@ fun PiezoMonitoringView(appState: AppState) {
(0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }) (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 })
logService.logMeasurement(methodCBvMl, channelLogs, rawADC) logService.logMeasurement(methodCBvMl, channelLogs, rawADC)
com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl) com.example.medilightv2android.services.AdcCsvLogger.log(rawADC, methodCBvMl)
} else if (centerWallCount >= 1) { } else if (effectiveCenterWalls >= 4) {
val bvResult = estimateBladderVolume6ch(allWalls) // 검증 로그 — 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) { if (bvResult != null) {
rawVolumeMl = bvResult.volumeMl rawVolumeMl = bvResult.volumeMl
rawLrRatio = bvResult.lrRatio rawLrRatio = bvResult.lrRatio
@@ -335,11 +397,13 @@ fun PiezoMonitoringView(appState: AppState) {
} }
} }
// Auto 연속 실패 → UI에서 처리 // 측정 모드: 6채널 중 3개 이상 missing → 위치 이탈로 즉시 판정.
// 채널 2개 이상 미수신 또는 BV 계산 불가 // 'allChannelsReceived' 가드로 BLE 거리/패킷 문제와 구분한다.
val centerWallsFinal = (0..3).count { allWalls[it] != null } 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 autoSucceeded = isAutoMeasuring && finalRawVol != null && finalRawVol > 0 && allChannelsReceived
val placementLost = missingChannels >= 3 && allChannelsReceived
// displayMaxVolumeMl 5초 간격 갱신 // displayMaxVolumeMl 5초 간격 갱신
val nowMs = System.currentTimeMillis() val nowMs = System.currentTimeMillis()
@@ -354,16 +418,31 @@ fun PiezoMonitoringView(appState: AppState) {
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL) val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level) appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
} }
// Auto 연속 실패 카운트
if (autoSucceeded) autoFailCount = 0 if (autoSucceeded) autoFailCount = 0
if (autoFailed) {
autoFailCount++ // 6채널 중 3개 이상 안 잡힘 → 즉시 realign 알림 (모드 무관).
if (autoFailCount >= 5) { if (placementLost && !showRealignPrompt) {
if (isAutoMeasuring) {
isAutoMeasuring = false isAutoMeasuring = false
autoVolumeWindow.clear() autoVolumeWindow.clear()
autoFailCount = 0 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 isMeasuring = false
measureRetryCount = 0 measureRetryCount = 0
@@ -846,17 +925,34 @@ fun PiezoMonitoringView(appState: AppState) {
} }
Box(modifier = Modifier.weight(1f)) { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) // dev 모드만 scroll — 일반 사용자에겐 fit-to-screen 고정 레이아웃
.then(if (appState.isDevMode) Modifier.verticalScroll(scrollState) else Modifier)
.padding(horizontal = hPad) .padding(horizontal = hPad)
.padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp), .padding(top = if (isCompact) 8.dp else 16.dp, bottom = if (isCompact) 16.dp else 32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (appState.isDevMode) verticalArrangement = if (appState.isDevMode)
Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp) Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp)
else else
Arrangement.spacedBy((screenWidth * 0.08f).dp.coerceIn(20.dp, 48.dp)) Arrangement.spacedBy(nonDevSpacing)
) { ) {
// Status badge (항상 urgency 메시지) // Status badge (항상 urgency 메시지)
@@ -873,15 +969,26 @@ fun PiezoMonitoringView(appState: AppState) {
.padding(horizontal = 20.dp, vertical = 8.dp) .padding(horizontal = 20.dp, vertical = 8.dp)
) )
// Donut chart with Bladdy — 화면 비율 기반 // Donut + Bladdy sizing — BoxWithConstraints 의 availableH 기반.
val screenWidthDp = screenWidth.dp // - dev 모드: isCompact 기반 고정 dp (scroll 가능)
val donutRatio = if (appState.isDevMode) 0.65f else 0.75f // - non-dev: donutFitSize 사용 → 화면 높이에 맞춰 자동 축소 (120~240dp cap)
val donutSize = screenWidthDp * donutRatio val baseDonutSize = if (appState.isDevMode) {
val lineWidthRatio = if (appState.isDevMode) 0.07f else 0.08f if (isCompact) 192.dp else 244.dp
val donutLineWidth = donutSize * lineWidthRatio } else {
val bladdyRatio = if (appState.isDevMode) 0.30f else 0.55f donutFitSize
val bladdySize = donutSize * bladdyRatio }
val volFontSize = if (appState.isDevMode) 18.sp else (screenWidth * 0.06f).sp 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 maxVolumeForDonut = appState.piezoSettings.maxVolume.toDouble()
val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0) val donutProgress = if (maxVolumeForDonut > 0 && maxVolumeMl > 0)
@@ -908,7 +1015,31 @@ fun PiezoMonitoringView(appState: AppState) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.matchParentSize().offset(y = -(donutSize * 0.03f)) 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)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
if (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} mL" else "${appState.estimatedVolume} mL", if (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} mL" else "${appState.estimatedVolume} mL",
@@ -996,6 +1127,8 @@ fun PiezoMonitoringView(appState: AppState) {
isSpotInProgress = true isSpotInProgress = true
spotCooldown = true spotCooldown = true
spotVolumes.clear() spotVolumes.clear()
spotSuccessfulWalls = null
spotSuccessfulChannels = null
spotScope.launch { spotScope.launch {
val maxAttempts = 8 val maxAttempts = 8
val targetCount = 5 val targetCount = 5
@@ -1007,17 +1140,28 @@ fun PiezoMonitoringView(appState: AppState) {
kotlinx.coroutines.delay(100) // throttle(800ms)이 간격 보장 kotlinx.coroutines.delay(100) // throttle(800ms)이 간격 보장
} }
Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}") Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}")
if (spotVolumes.size >= 3) { val finalSpotVolumes = spotVolumes.toList()
val result = trimmedMean(spotVolumes.toList()) val trimmedResult = if (finalSpotVolumes.size >= 3) trimmedMean(finalSpotVolumes) else null
lastVolumeMl = result if (trimmedResult != null) {
maxVolumeMl = maxOf(maxVolumeMl, result) lastVolumeMl = trimmedResult
maxVolumeMl = maxOf(maxVolumeMl, trimmedResult)
displayMaxVolumeMl = maxVolumeMl // Spot은 즉시 갱신 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 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) val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = 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() spotVolumes.clear()
isSpotInProgress = false isSpotInProgress = false
handler.postDelayed({ spotCooldown = false }, 1000) handler.postDelayed({ spotCooldown = false }, 1000)
@@ -1078,8 +1222,33 @@ fun PiezoMonitoringView(appState: AppState) {
// BLE Debug Log // BLE Debug Log
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BleDebugPanel(bleManager = bleManager) 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 scrollable Column
} // end BoxWithConstraints
if (showScanLogDialog) {
ScanLogDialog(
log = scanLogState,
onClear = { ScanLogStore.clear() },
onDismiss = { showScanLogDialog = false },
)
}
// ── Floating Settings Overlay ── // ── Floating Settings Overlay ──
if (showSettings) { if (showSettings) {
@@ -1093,11 +1262,17 @@ fun PiezoMonitoringView(appState: AppState) {
colors = CardDefaults.cardColors(containerColor = MlCardBackground.copy(alpha = 0.97f)), colors = CardDefaults.cardColors(containerColor = MlCardBackground.copy(alpha = 0.97f)),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.9f) // dev mode 추가 컨텐츠가 화면 밖으로 밀려 Save 버튼 안 보이는 문제 방지
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.shadow(12.dp, RoundedCornerShape(16.dp)) .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 // Max Volume
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Bladder Capacity", fontSize = 14.sp, fontWeight = FontWeight.SemiBold) Text("Bladder Capacity", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
@@ -1112,6 +1287,38 @@ fun PiezoMonitoringView(appState: AppState) {
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary) 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() HorizontalDivider()
// Catheterization Threshold // Catheterization Threshold
@@ -1303,7 +1510,26 @@ fun PiezoMonitoringView(appState: AppState) {
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp)) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -1314,7 +1540,10 @@ fun PiezoMonitoringView(appState: AppState) {
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { showSettings = false }, onClick = {
appState.savePiezoSettingsNow()
showSettings = false
},
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) { ) {
@@ -1330,8 +1559,8 @@ fun PiezoMonitoringView(appState: AppState) {
if (showRealignPrompt) { if (showRealignPrompt) {
AlertDialog( AlertDialog(
onDismissRequest = { showRealignPrompt = false }, onDismissRequest = { showRealignPrompt = false },
title = { Text("Position lost") }, title = { Text("Position Lost or Insufficient Gel") },
text = { Text("Sensor may have shifted. Restart from alignment.") }, text = { Text("Position lost or gel may be insufficient. Please check.") },
confirmButton = { confirmButton = {
Button(onClick = { Button(onClick = {
showRealignPrompt = false showRealignPrompt = false
@@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect 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.Stroke
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.platform.LocalContext 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)), .then(if (appState.isDevMode) Modifier.height(400.dp) else Modifier.weight(1f)),
contentAlignment = Alignment.Center 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) // 하복부 실루엣 (Canvas — CKLaw body silhouette)
val bodyAlpha = 0.9f // 항상 동일 (#5) val bodyAlpha = 0.9f // 항상 동일 (#5)
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val cx = w / 2f 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 val greenZone = isGreenZone
// Body silhouette palette // Body silhouette palette
@@ -271,9 +284,9 @@ fun PlacementGuideView(appState: AppState) {
val waistY = h * 0.42f val waistY = h * 0.42f
val hipY = h * 0.76f val hipY = h * 0.76f
val pubisY = h * 0.96f val pubisY = h * 0.96f
val chestW = w * 0.42f val chestW = bodyW * 0.42f
val waistW = w * 0.32f val waistW = bodyW * 0.32f
val hipW = w * 0.46f val hipW = bodyW * 0.46f
// ── Torso silhouette — hourglass via cubic bezier ── // ── Torso silhouette — hourglass via cubic bezier ──
val bodyPath = Path().apply { val bodyPath = Path().apply {
@@ -348,6 +361,26 @@ fun PlacementGuideView(appState: AppState) {
style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))) 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) { if (!greenZone) {
val arrowColor = Color(0xFFFF5722) val arrowColor = Color(0xFFFF5722)
@@ -387,27 +420,7 @@ fun PlacementGuideView(appState: AppState) {
} }
} }
// 센서 기기 이미지 (방광 타원 중앙에 정렬) // (sensor 이미지는 위 Canvas drawImage 로 이동 — body silhouette과 동일 좌표계)
// 센서 — 화면 비율 기반 크기/위치 (태블릿 대응)
// 타원 중심 ≈ 높이 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
)
}
} }
// (힌트 텍스트는 Step 아래, 몸 위에 배치됨) // (힌트 텍스트는 Step 아래, 몸 위에 배치됨)
@@ -785,7 +798,7 @@ fun PlacementGuideView(appState: AppState) {
} }
} }
// Start button (초기 / 재시작 대기 시) // Start button (초기 / 재시작 대기 시). 화면 진입 직후엔 항상 클릭 가능.
if (waitingForStart) { if (waitingForStart) {
Button( Button(
onClick = { onClick = {
@@ -810,6 +823,7 @@ fun PlacementGuideView(appState: AppState) {
currentRMm = null; bestRMm = 0.0 currentRMm = null; bestRMm = 0.0
sagittalArgMaxCh = null; lateralOffsetMm = null sagittalArgMaxCh = null; lateralOffsetMm = null
}, },
enabled = true, // 화면 진입 / 재진입 시 무조건 클릭 가능 보장
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(96.dp), .height(96.dp),
@@ -34,7 +34,11 @@ object WdConfig {
// Phantom : D(100.406 mm) / chord(52 samples) = 1.9309 mm/sp // Phantom : D(100.406 mm) / chord(52 samples) = 1.9309 mm/sp
// K : c_eff / c_ref = 1.000 // K : c_eff / c_ref = 1.000
// 결과적으로 amode_simulator 와 동일 좌표계 → 530 mL phantom 시 R≈50.20mm 회복. // 결과적으로 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 DELAY_MM_DEFAULT = 6.85
const val C_EFF_DEFAULT = 1530.0 const val C_EFF_DEFAULT = 1530.0
@@ -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,
)
}
}