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:
@@ -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))
|
||||||
|
}
|
||||||
+267
-38
@@ -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
|
||||||
|
|||||||
+39
-25
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user