feat(hospital): 재부착 확인 단계 · 좌우 스트리밍 전체 기록 · 정렬 파형을 위로 (2026-09-29 임상 후속)
임상에서 정렬 → 위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기 사이에 CH2 가 사라진 채
18조합을 다 쟀다. 좌우는 실시간 판정만 하고 아무것도 남기지 않아 "그 전에는 있었나"를
대조할 수도 없었다. 세 가지를 넣는다.
1. 재부착 확인 (병원 화면 정렬 카드 아래, ReattachCheckCard)
· 부착 위치에서 정렬 조건(2.3MHz·c3·20 cycle)으로 재고, 정렬 확인 측정에서 잡힌
채널(AppState.anchorRefChannels ← 확인 측정의 walls)과 CH0~3 을 채널별로 대조
+ 탈착 감시. 판정은 ReattachCheck.judge (순수 함수, 시험 6건).
· 막지 않는다 — 측정 시작 아래 경고, 측정 중 한 줄 요약에 "재부착 ⚠", run 매니페스트
`anchor_reattach`, align_result.json `reattach`, 601 에 phase=reattach 로 파형·IMU.
· AppState.clearAnchor() — 부착 위치와 딸린 것(근거·기준 채널·재부착)을 한꺼번에 비운다.
2. 좌우 스트리밍 전체 기록 (HospitalRunStore.LateralStreamWriter)
· 프레임마다 6채널 + 그 프레임의 판정(u4·u5·imbalance·action·ch3)을
align_{n}cm_lateral_stream.csv 에 덧붙여 쓴다(앱이 죽어도 그때까지 남음).
`attempt` 열로 [처음부터 다시 정렬] 시도를 가른다 — 앞 시도의 비동기 업로드가 파일을
읽고 있을 수 있어 지우지 않는다. 601 에 phase=lateral_stream + `lateral{…}`.
3. 정렬 화면 파형 1차 개편 — 파형을 용적·접촉보다 위에 더 크게(170dp), 그 위에 채널별
벽 검출 띠(CH0~3 ✓/✗, CH4·5 는 좌우용 회색). "CH2 없음"이 파형보다 먼저 보인다.
시험 88건 통과(전체 174 · skipped 3 · failures 0). 실기(프로브 필요)는 다음 임상에서.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,26 @@ class AppState(private val context: Context) {
|
||||
*/
|
||||
var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* 정렬 **확인 측정**에서 잡힌 채널(CH0~3). 재부착 확인([ReattachCheck])의 기준.
|
||||
* null = 기준 없음 — 사람이 고른 위치이거나 확인 측정 전에 진행했다.
|
||||
*/
|
||||
var anchorRefChannels: List<Boolean>? by mutableStateOf(null)
|
||||
|
||||
/** 재부착 확인 결과. null = 아직 안 했다. 부착 위치와 같이 비운다. */
|
||||
var anchorReattach: com.medithings.vesiscan.managers.ReattachCheck.Result? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* 부착 위치와 딸린 것(근거·기준 채널·재부착 확인)을 **한꺼번에** 비운다.
|
||||
* 하나만 남으면 그 순간부터 서로 어긋난다 — 환자가 바뀌었는데 재부착 확인이 ✓ 로 남는 식.
|
||||
*/
|
||||
fun clearAnchor() {
|
||||
anchorCm = null
|
||||
anchorBasis = null
|
||||
anchorRefChannels = null
|
||||
anchorReattach = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.**
|
||||
*
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* ReattachCheck — 정렬 뒤 프로브를 뗐다가(위치 마킹 · 크래들 부착) 다시 붙인 자리가 정렬
|
||||
* 때와 같은 신호를 주는지 판정한다.
|
||||
*
|
||||
* 2026-09-29 임상: 정렬에서는 CH0~3 이 다 잡혔는데, 마킹하고 떼서 크래들로 다시 붙이니
|
||||
* **CH2 가 사라진 채** 18조합을 다 쟀다. 정렬과 임상 측정 사이에 손이 한 번 가는데 그
|
||||
* 사이를 확인하는 단계가 없었다. 그래서 측정 직전에 부착 위치에서 한 번(20 cycle) 재고,
|
||||
* 정렬 확인 측정과 **채널별로** 대조한다(2026-09-30 결정).
|
||||
*
|
||||
* 판정은 둘을 합친다:
|
||||
* ① 정렬 때 잡혔던 채널이 지금 안 잡히면 실패 — 그 채널이 "없음".
|
||||
* ② 탈착 감시가 확정 미부착으로 본 채널이 있으면 실패.
|
||||
* 정렬 기준이 없으면(사람이 고른 위치 · 옛 앱으로 정렬) ②만 본다 — 기준 없이 "정렬과
|
||||
* 같다"고 말하지 않는다.
|
||||
*
|
||||
* 막지 않는다 — 경고만. 결과는 align_result.json `reattach` 와 run 매니페스트
|
||||
* `anchor_reattach` 에 남아, "이 세션은 CH2 없이 잰 것"을 나중에 걸러낼 수 있다.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object ReattachCheck {
|
||||
|
||||
/** CH0~CH3 만 본다 — 벽 검출·판정 대상 채널. CH4·5 는 좌우 균형용이라 여기 안 든다. */
|
||||
const val CENTER_CHANNELS = 4
|
||||
|
||||
data class Result(
|
||||
val ok: Boolean,
|
||||
/** 정렬 때는 잡혔는데 지금 안 잡힌 채널. */
|
||||
val missing: List<Int>,
|
||||
/** 탈착 감시가 확정 미부착으로 본 채널(CH0~3). */
|
||||
val detached: List<Int>,
|
||||
/** 지금 잡힌 채널(CH0~3). */
|
||||
val channels: List<Boolean>,
|
||||
/** 정렬 확인 측정 때 잡힌 채널. null = 기준 없음. */
|
||||
val refChannels: List<Boolean>?,
|
||||
val bvMl: Double?,
|
||||
val at: Long,
|
||||
) {
|
||||
val hasReference: Boolean get() = refChannels != null
|
||||
|
||||
/** 화면과 기록이 같은 말을 쓴다. */
|
||||
val label: String get() = when {
|
||||
ok && hasReference -> "정렬과 같음 (CH0~3)"
|
||||
ok -> "접촉 정상 (정렬 기준 없음)"
|
||||
else -> buildString {
|
||||
if (missing.isNotEmpty()) {
|
||||
append("정렬 때 잡힌 ")
|
||||
append(missing.joinToString(" · ") { "CH$it" })
|
||||
append(" 없음")
|
||||
}
|
||||
if (detached.isNotEmpty()) {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append(detached.joinToString(" · ") { "CH$it" })
|
||||
append(" 미부착")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("ok", ok)
|
||||
put("missing", JSONArray(missing))
|
||||
put("detached", JSONArray(detached))
|
||||
put("channels", JSONArray(channels))
|
||||
put("ref_channels", refChannels?.let { JSONArray(it) } ?: JSONObject.NULL)
|
||||
put("bv_ml", bvMl ?: JSONObject.NULL)
|
||||
put("at", at)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ref 정렬 확인 측정에서 잡힌 채널(CH0~3). null 이면 기준 없음.
|
||||
* @param now 지금 잡힌 채널(CH0~3 이상이면 앞 4개만 본다).
|
||||
* @param detached 탈착 감시가 확정 미부착으로 본 채널 번호.
|
||||
*/
|
||||
fun judge(
|
||||
ref: List<Boolean>?,
|
||||
now: List<Boolean>,
|
||||
detached: List<Int>,
|
||||
bvMl: Double?,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
): Result {
|
||||
val nowC = now.take(CENTER_CHANNELS)
|
||||
val refC = ref?.take(CENTER_CHANNELS)
|
||||
val missing = refC?.indices?.filter { refC[it] && nowC.getOrNull(it) != true } ?: emptyList()
|
||||
val det = detached.filter { it < CENTER_CHANNELS }.distinct().sorted()
|
||||
return Result(
|
||||
ok = missing.isEmpty() && det.isEmpty(),
|
||||
missing = missing, detached = det,
|
||||
channels = nowC, refChannels = refC,
|
||||
bvMl = bvMl, at = at,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,84 @@ object HospitalRunStore {
|
||||
* `align_3cm_lateral.csv`
|
||||
*/
|
||||
LATERAL("_lateral"),
|
||||
/**
|
||||
* 재부착 확인 측정(2026-09-30). 정렬 뒤 뗐다(마킹·크래들) 붙인 자리에서 병원 화면이
|
||||
* 20 cycle 재고 정렬 확인 측정과 채널별로 대조한 것. cm 은 부착 위치. `align_3cm_reattach.csv`
|
||||
*/
|
||||
REATTACH("_reattach"),
|
||||
}
|
||||
|
||||
/** `align_result.json` 을 읽어 고쳐 쓴다. 없으면 빈 객체에서 시작한다(옛 앱으로 정렬한 폴더). */
|
||||
fun updateAlignSummary(dir: File, mutate: (JSONObject) -> Unit) {
|
||||
runCatching {
|
||||
val f = File(dir, "align_result.json")
|
||||
val json = if (f.exists()) runCatching { JSONObject(f.readText(Charsets.UTF_8)) }.getOrNull() ?: JSONObject()
|
||||
else JSONObject()
|
||||
mutate(json)
|
||||
f.writeText(json.toString(2), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌우 스트리밍 **전체** 기록(2026-09-30). 프레임마다 6채널 파형과 그 프레임의 판정
|
||||
* (u4 · u5 · imbalance · action · ch3)을 `align_{n}cm_lateral_stream.csv` 에, IMU 를
|
||||
* `…_lateral_stream_imu.csv` 에 **덧붙여** 쓴다. 앱이 중간에 죽어도 그때까지는 남는다.
|
||||
*
|
||||
* 2026-09-29 임상에서 좌우는 실시간 판정만 하고 아무것도 남기지 않아, 뗐다 붙인 뒤
|
||||
* CH2 가 사라진 것을 "그 전에는 있었나"와 대조할 수 없었다. [AlignFileKind.LATERAL]
|
||||
* (확인 완료 순간의 마지막 창)과 별개로 흐름 전체를 남긴다.
|
||||
*
|
||||
* `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오른다 — 파일을 지우지 않고 이어 쓰므로
|
||||
* (앞 시도의 업로드가 비동기로 이 파일을 읽고 있을 수 있다) 이 열로 시도를 가른다.
|
||||
* 헤더는 파일이 새로 생길 때만 쓴다. IMU 헤더는 다른 정렬 IMU 파일과 같다.
|
||||
*/
|
||||
class LateralStreamWriter(dir: File, cm: Int) : java.io.Closeable {
|
||||
private val waveFile = File(dir, "align_%dcm_lateral_stream.csv".format(cm))
|
||||
private val imuFile = File(dir, "align_%dcm_lateral_stream_imu.csv".format(cm))
|
||||
private val wave: java.io.BufferedWriter
|
||||
private val imu: java.io.BufferedWriter
|
||||
|
||||
init {
|
||||
val newWave = !waveFile.exists() || waveFile.length() == 0L
|
||||
val newImu = !imuFile.exists() || imuFile.length() == 0L
|
||||
wave = java.io.BufferedWriter(java.io.FileWriter(waveFile, true))
|
||||
imu = java.io.BufferedWriter(java.io.FileWriter(imuFile, true))
|
||||
if (newWave) { wave.write(waveHeader()); wave.newLine() }
|
||||
if (newImu) { imu.write("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps"); imu.newLine() }
|
||||
}
|
||||
|
||||
/** 한 프레임. 판정이 아직 없으면(창이 덜 찼을 때) 판정 열은 빈칸이다. */
|
||||
fun frame(
|
||||
attempt: Int, frameIdx: Int, frame6: List<DoubleArray>, samples: List<ImuSample>,
|
||||
u4: Int?, u5: Int?, imbalance: Int?, action: String?, ch3: Boolean?,
|
||||
) {
|
||||
frame6.forEachIndexed { ch, buf ->
|
||||
wave.write(buildString {
|
||||
append(attempt); append(','); append(frameIdx); append(','); append(ch); append(',')
|
||||
append(u4 ?: ""); append(','); append(u5 ?: ""); append(','); append(imbalance ?: ""); append(',')
|
||||
append(action ?: ""); append(','); append(ch3?.let { if (it) "1" else "0" } ?: "")
|
||||
for (v in buf) { append(','); append(v.toInt()) }
|
||||
})
|
||||
wave.newLine()
|
||||
}
|
||||
samples.forEachIndexed { si, sm ->
|
||||
imu.write(listOf(frameIdx, si, sm.ax, sm.ay, sm.az, sm.gx, sm.gy, sm.gz).joinToString(","))
|
||||
imu.newLine()
|
||||
}
|
||||
wave.flush(); imu.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { wave.close() }
|
||||
runCatching { imu.close() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val HEADER_PREFIX = "attempt,frame_idx,ch,u4,u5,imbalance,action,ch3"
|
||||
/** 샘플 열은 프로브 설정의 샘플 수만큼 — 받는 쪽이 헤더로 읽을 수 있게. */
|
||||
fun waveHeader(samples: Int = com.medithings.vesiscan.models.HospitalFixedParams.SAMPLES): String =
|
||||
HEADER_PREFIX + (0 until samples).joinToString("") { ",s$it" }
|
||||
}
|
||||
}
|
||||
|
||||
/** 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 `align/` 에 둔다. */
|
||||
|
||||
@@ -39,7 +39,9 @@ import java.util.Locale
|
||||
* record = 한 위치의 한 cycle
|
||||
* rowIndex : 0..N 연속 (labdb 규약)
|
||||
* align_cm : 그 cycle 을 잰 위치
|
||||
* phase : "sweep" | "confirm" | "lateral" ← 2026-09-18 추가 · lateral 2026-09-30
|
||||
* phase : "sweep" | "confirm" | "lateral" | "lateral_stream" | "reattach"
|
||||
* ← 2026-09-18 추가 · lateral/lateral_stream/reattach 2026-09-30
|
||||
* lateral : (lateral_stream 만) {attempt, u4, u5, imbalance, action, ch3} — 그 프레임의 판정
|
||||
* cycle_idx : 그 위치 안에서의 순번
|
||||
* channels : 6 × {ch, peak, peakIdx, data[~100]}
|
||||
* sensor : {} 또는 {imu:{ax..gz}, imu_samples:[…], imu_sample_count}
|
||||
@@ -84,16 +86,18 @@ object AlignLabdbPayload {
|
||||
* 확인 측정 파일이 하나도 안 잡혔다.
|
||||
*/
|
||||
private val NAME_RE =
|
||||
Regex("""^align_(-?\d+)cm(_confirm|_lateral)?\.csv$""", RegexOption.IGNORE_CASE)
|
||||
Regex("""^align_(-?\d+)cm(_confirm|_lateral_stream|_lateral|_reattach)?\.csv$""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우)다. */
|
||||
/** 파일명 접미사 → phase. 순서는 실제 측정 순서(탐색 → 확인 → 좌우 → 좌우 흐름 → 재부착)다. */
|
||||
private fun phaseOf(suffix: String): String = when (suffix.lowercase()) {
|
||||
"" -> "sweep"
|
||||
"_confirm" -> "confirm"
|
||||
else -> "lateral"
|
||||
"_lateral" -> "lateral"
|
||||
"_lateral_stream" -> "lateral_stream"
|
||||
else -> "reattach"
|
||||
}
|
||||
private fun phaseRank(suffix: String): Int = when (phaseOf(suffix)) {
|
||||
"sweep" -> 0; "confirm" -> 1; else -> 2
|
||||
"sweep" -> 0; "confirm" -> 1; "lateral" -> 2; "lateral_stream" -> 3; else -> 4
|
||||
}
|
||||
|
||||
private val isoOut: SimpleDateFormat
|
||||
@@ -136,16 +140,41 @@ object AlignLabdbPayload {
|
||||
File(f.parentFile, f.name.removeSuffix(".csv") + "_imu.csv")
|
||||
)
|
||||
// `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles).
|
||||
// 좌우 흐름(`_lateral_stream`)만 헤더가 있고 앞에 판정 열이 붙는다
|
||||
// (HospitalRunStore.LateralStreamWriter): attempt,frame_idx,ch,u4,u5,imbalance,action,ch3,s0…
|
||||
val byCycle = linkedMapOf<Int, MutableMap<Int, IntArray>>()
|
||||
runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line ->
|
||||
if (line.isBlank()) return@forEach
|
||||
val metaByCycle = linkedMapOf<Int, JSONObject>()
|
||||
val lines = runCatching { f.readLines(Charsets.UTF_8) }.getOrNull().orEmpty()
|
||||
val streamMode = lines.firstOrNull()?.startsWith(
|
||||
com.medithings.vesiscan.services.HospitalRunStore.LateralStreamWriter.HEADER_PREFIX) == true
|
||||
lines.forEachIndexed { li, line ->
|
||||
if (line.isBlank() || (streamMode && li == 0)) return@forEachIndexed
|
||||
val p = line.split(',')
|
||||
if (p.size < 3) return@forEach
|
||||
val ci = p[0].trim().toIntOrNull() ?: return@forEach
|
||||
val ch = p[1].trim().toIntOrNull() ?: return@forEach
|
||||
val data = IntArray(p.size - 2) { p[it + 2].trim().toIntOrNull() ?: 0 }
|
||||
if (data.isEmpty()) return@forEach
|
||||
if (streamMode) {
|
||||
if (p.size < 9) return@forEachIndexed
|
||||
val ci = p[1].trim().toIntOrNull() ?: return@forEachIndexed
|
||||
val ch = p[2].trim().toIntOrNull() ?: return@forEachIndexed
|
||||
val data = IntArray(p.size - 8) { p[it + 8].trim().toIntOrNull() ?: 0 }
|
||||
if (data.isEmpty()) return@forEachIndexed
|
||||
byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data
|
||||
metaByCycle.getOrPut(ci) {
|
||||
JSONObject().apply {
|
||||
put("attempt", p[0].trim().toIntOrNull() ?: JSONObject.NULL)
|
||||
put("u4", p[3].trim().toIntOrNull() ?: JSONObject.NULL)
|
||||
put("u5", p[4].trim().toIntOrNull() ?: JSONObject.NULL)
|
||||
put("imbalance", p[5].trim().toIntOrNull() ?: JSONObject.NULL)
|
||||
put("action", p[6].trim().ifBlank { null } ?: JSONObject.NULL)
|
||||
put("ch3", when (p[7].trim()) { "1" -> true; "0" -> false; else -> JSONObject.NULL })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (p.size < 3) return@forEachIndexed
|
||||
val ci = p[0].trim().toIntOrNull() ?: return@forEachIndexed
|
||||
val ch = p[1].trim().toIntOrNull() ?: return@forEachIndexed
|
||||
val data = IntArray(p.size - 2) { p[it + 2].trim().toIntOrNull() ?: 0 }
|
||||
if (data.isEmpty()) return@forEachIndexed
|
||||
byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data
|
||||
}
|
||||
}
|
||||
for (ci in byCycle.keys.sorted()) {
|
||||
val chans = JSONArray()
|
||||
@@ -165,6 +194,7 @@ object AlignLabdbPayload {
|
||||
put("align_cm", cm)
|
||||
put("phase", phase)
|
||||
put("cycle_idx", ci)
|
||||
metaByCycle[ci]?.let { put("lateral", it) }
|
||||
put("sensor", LabdbSensor.of(imuByCycle[ci]))
|
||||
put("commandType", "MTB")
|
||||
put("channels", chans)
|
||||
@@ -186,10 +216,13 @@ object AlignLabdbPayload {
|
||||
// "5군데 쟀다"가 6이 된다. 실제로 **서로 다른 위치**를 센다.
|
||||
put("positions_measured", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "sweep" })
|
||||
put("confirm_positions", files.count { phaseOf(NAME_RE.find(it.name)!!.groupValues[2]) == "confirm" })
|
||||
// 좌우 확인 프레임 수(0 = 좌우를 확인하지 않았거나 옛 앱). 위치 수에는 안 더한다.
|
||||
put("lateral_frames", (0 until records.length()).count {
|
||||
records.getJSONObject(it).optString("phase") == "lateral"
|
||||
})
|
||||
// 좌우·재부착 프레임 수(0 = 그 단계를 안 했거나 옛 앱). 위치 수에는 안 더한다.
|
||||
fun countPhase(name: String) = (0 until records.length()).count {
|
||||
records.getJSONObject(it).optString("phase") == name
|
||||
}
|
||||
put("lateral_frames", countPhase("lateral"))
|
||||
put("lateral_stream_frames", countPhase("lateral_stream"))
|
||||
put("reattach_frames", countPhase("reattach"))
|
||||
put("cycles_total", records.length())
|
||||
// 판정 근거를 **통째로** 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라,
|
||||
// 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다.
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.managers.DetachWatcher
|
||||
import com.medithings.vesiscan.managers.ImuPostureClassifier
|
||||
import com.medithings.vesiscan.managers.LateralGuide
|
||||
import com.medithings.vesiscan.managers.ReattachCheck
|
||||
import com.medithings.vesiscan.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
@@ -202,6 +203,10 @@ fun AnchorAlignView(appState: AppState) {
|
||||
* 그 전에는 좌우 단계가 아무것도 저장하지 않았다. 파형과 그 프레임의 IMU 를 짝지어 둔다.
|
||||
*/
|
||||
val lrRecent = remember { ArrayDeque<Pair<List<DoubleArray>, List<com.medithings.vesiscan.ble.ImuSample>>>() }
|
||||
/** 좌우 스트리밍 전체 기록의 프레임 번호 — 다시 시작해도 이어진다([HospitalRunStore.LateralStreamWriter]). */
|
||||
var lrStreamIdx by remember { mutableIntStateOf(0) }
|
||||
/** [처음부터 다시 정렬] 횟수 + 1. 스트리밍 기록의 `attempt` 열. */
|
||||
var lrAttempt by remember { mutableIntStateOf(1) }
|
||||
/** 간호사가 "좌우 확인 완료"를 누른 적이 있는가. 저장·다음 단계 안내에 쓴다. */
|
||||
var lrConfirmed by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -468,6 +473,13 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// 파형과 짝지어 둔다 — "확인 완료" 순간의 것만 파일로 남는다.
|
||||
val lrImu = ImuSidecar(bleManager)
|
||||
lrImu.install()
|
||||
// 흐름 전체를 남긴다(2026-09-30). 열 수 없으면(저장 공간) 기록 없이 진행 — 좌우
|
||||
// 맞추기 자체를 막을 이유는 아니다.
|
||||
val stream = runCatching {
|
||||
HospitalRunStore.LateralStreamWriter(
|
||||
HospitalRunStore.alignDir(saveName, runStartedAt), anchorTarget ?: cm)
|
||||
}.getOrNull()
|
||||
val streamStart = lrStreamIdx
|
||||
try {
|
||||
bleManager.piezoConfigEcho.value = null
|
||||
bleManager.sendPiezoConfig(
|
||||
@@ -515,9 +527,20 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// 판정 창과 같은 수만 남긴다 — 확인 완료 때 저장하는 것이 곧 마지막 판정의 근거다.
|
||||
lrRecent.addLast(frame to (imu ?: emptyList()))
|
||||
while (lrRecent.size > lateral.required) lrRecent.removeFirst()
|
||||
// 흐름 전체 — 이 프레임과 이 프레임까지의 판정.
|
||||
val c = lrCommit
|
||||
runCatching {
|
||||
stream?.frame(
|
||||
lrAttempt, lrStreamIdx, frame, imu ?: emptyList(),
|
||||
c?.u4, c?.u5, c?.imbalance, c?.action?.name, c?.ch3,
|
||||
)
|
||||
}
|
||||
lrStreamIdx++
|
||||
delay(CYCLE_GAP_MS)
|
||||
}
|
||||
} finally {
|
||||
stream?.close()
|
||||
if (lrStreamIdx > streamStart) { hasAlignData = true; alignDirty = true }
|
||||
bleManager.piezoCollector.onMultiChannelComplete = prev
|
||||
lrImu.restore()
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) {
|
||||
@@ -598,8 +621,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
enabled = !measuring,
|
||||
onClick = {
|
||||
if (isConnected) {
|
||||
appState.anchorCm = null
|
||||
appState.anchorBasis = null
|
||||
appState.clearAnchor()
|
||||
bleManager.disconnect()
|
||||
} else {
|
||||
appState.connectReturnScreen = AppScreen.ANCHOR_ALIGN
|
||||
@@ -923,6 +945,11 @@ fun AnchorAlignView(appState: AppState) {
|
||||
onClick = {
|
||||
appState.anchorCm = proceedCm
|
||||
appState.anchorBasis = basis
|
||||
// 재부착 확인의 기준 — 확인 측정에서 잡힌 채널(CH0~3). 확인 측정 없이
|
||||
// 진행하면 기준 없음(null)이다. 새 위치이므로 지난 재부착 결과는 비운다.
|
||||
appState.anchorRefChannels =
|
||||
if (confirmDone) bv?.walls?.take(ReattachCheck.CENTER_CHANNELS)?.map { it != null } else null
|
||||
appState.anchorReattach = null
|
||||
// 근거를 요약에도 박는다. 화면에만 있으면 나중에 파일을 보는 사람이
|
||||
// 이 위치가 어떻게 정해졌는지 알 길이 없다.
|
||||
last?.let { s ->
|
||||
@@ -935,6 +962,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
bleManager.firmwareVersion.value, guide.records, s,
|
||||
lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm,
|
||||
startedAt = runStartedAt,
|
||||
confirmChannels = appState.anchorRefChannels,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -976,7 +1004,7 @@ fun AnchorAlignView(appState: AppState) {
|
||||
}
|
||||
runStartedAt = Date()
|
||||
guide.reset(); cm = 0; last = null; records = emptyList(); error = null
|
||||
lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear()
|
||||
lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++
|
||||
lrConfirmed = false; lrError = null; lrRunning = false
|
||||
disconnectedMidRun = false
|
||||
decidedAtCm = null; sweepStopped = false; confirmDone = false
|
||||
@@ -990,6 +1018,28 @@ fun AnchorAlignView(appState: AppState) {
|
||||
// ── 채널 파형 ───────────────────────────────────────────────────
|
||||
// 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는
|
||||
// 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다.
|
||||
//
|
||||
// 파형을 **용적·접촉보다 위에, 더 크게** 둔다(2026-09-30 1차). 2026-09-29 임상에서
|
||||
// 뗐다 붙인 뒤 CH2 가 사라진 것을 화면에서 바로 알아채지 못했다 — 파형이 아래
|
||||
// 작게 있었다. 채널마다 벽이 잡혔는지 띠로 먼저 보이고, 그 아래 파형이다.
|
||||
if (liveChannels.isNotEmpty()) {
|
||||
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = MlSecondaryText)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
ChannelWallStrip(bv?.walls)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
SixChannelWaveformGrid(
|
||||
channels = liveChannels,
|
||||
// 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가
|
||||
// 원인 분석의 출발점이다.
|
||||
walls = bv?.markers ?: emptyList(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cellHeight = 170.dp,
|
||||
compact = true,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (bv != null) {
|
||||
Text("이 위치의 방광 용적", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = MlSecondaryText)
|
||||
@@ -1031,21 +1081,6 @@ fun AnchorAlignView(appState: AppState) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
if (liveChannels.isNotEmpty()) {
|
||||
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = MlSecondaryText)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
SixChannelWaveformGrid(
|
||||
channels = liveChannels,
|
||||
// 전벽·후벽 마커. BV 가 안 나올 때 어느 채널의 벽이 어디로 잡혔는지가
|
||||
// 원인 분석의 출발점이다.
|
||||
walls = bv?.markers ?: emptyList(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cellHeight = 140.dp,
|
||||
compact = true,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
@@ -1215,6 +1250,43 @@ private fun LateralSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널마다 벽이 잡혔는지 한 줄로(2026-09-30). CH0~3 이 판정 채널, CH4·5 는 좌우용이라
|
||||
* 회색으로 구분한다. 잡힌 채널은 초록, 안 잡힌 판정 채널은 빨강 — "CH2 없음"이 파형을
|
||||
* 읽기 전에 먼저 보여야 한다.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelWallStrip(walls: List<ClinicalBv.ChannelWall?>?) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for (ch in 0 until 6) {
|
||||
val judged = ch < ReattachCheck.CENTER_CHANNELS
|
||||
val hit = walls?.getOrNull(ch) != null
|
||||
val c = when {
|
||||
walls == null -> MlSecondaryText
|
||||
!judged -> if (hit) MlSecondaryText else MlSecondaryText.copy(alpha = 0.6f)
|
||||
hit -> MlSuccess
|
||||
else -> MlCritical
|
||||
}
|
||||
Box(
|
||||
Modifier.weight(1f)
|
||||
.background(c.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 5.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"CH$ch " + when {
|
||||
walls == null -> "–"
|
||||
hit -> "✓"
|
||||
judged -> "✗"
|
||||
else -> "–"
|
||||
},
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Bold, color = c,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(connected: Boolean, name: String?) {
|
||||
val bg = if (connected) MlSuccess.copy(alpha = 0.15f) else MlCritical.copy(alpha = 0.12f)
|
||||
@@ -1401,8 +1473,11 @@ private fun buildAlignSummary(
|
||||
decidedAtCm: Int? = null,
|
||||
/** 이 정렬 세션의 시작 시각. labdb testId 의 `_HHmm` 이 여기서 나온다([AlignLabdbPayload.sessionBase]). */
|
||||
startedAt: Date? = null,
|
||||
/** 확인 측정에서 잡힌 채널(CH0~3) — 재부착 확인([ReattachCheck])이 대조하는 기준. */
|
||||
confirmChannels: List<Boolean>? = null,
|
||||
): JSONObject = JSONObject().apply {
|
||||
startedAt?.let { put("started_at", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(it)) }
|
||||
confirmChannels?.let { put("confirm_channels", JSONArray(it)) }
|
||||
// 앱이 쓴 값 = **잰 위치 전부**로 고른 것. 선택 규칙은 Python `select_supine_anchor`
|
||||
// 그대로이고(AnchorSelectionParityTest 200케이스), 바뀐 것은 후보 집합뿐이다.
|
||||
val fullBest = AnchorSelection.selectBest(records.filter { it.capFrac.isFinite() }).bestCm
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* 측정하는 동안 실제로 보는 것은 넷이다 — **기울기 · 채움 정도 · 채널 접촉 · 시작/중지**.
|
||||
* 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름만 둔다:
|
||||
*
|
||||
* 연결 → labdb → 정렬 → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중)
|
||||
* 연결 → labdb → 정렬(+재부착 확인) → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중)
|
||||
*
|
||||
* 정렬은 위에 둔다(2026-09-29 오후) — 접는 구간에 넣어 봤더니 환자마다 한 번은 반드시
|
||||
* 펴야 해서 불편했다. 프로브를 붙이는 순서가 곧 화면 순서다: 연결하고, 붙일 자리를
|
||||
@@ -425,6 +425,9 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 자리로도 진행할 수 있는데, 그렇게 받은 데이터를 알고리즘 성능의 근거로
|
||||
// 쓰면 안 된다 - 분석 때 모집단을 가를 유일한 단서다([AnchorBasis]).
|
||||
put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL)
|
||||
// 재부착 확인(2026-09-30). null = 안 했다. ok=false 인 채 시작한 세션은 그
|
||||
// 채널 없이 잰 것이므로 분석에서 걸러야 한다.
|
||||
put("anchor_reattach", appState.anchorReattach?.toJson() ?: JSONObject.NULL)
|
||||
put("combinations", results)
|
||||
}, startedAt)
|
||||
// 정렬(601)은 정렬 화면을 떠나는 순간 한 번만 올리고, 조합 CSV 와 달리 뒤에
|
||||
@@ -480,8 +483,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
// BV·파형·접촉은 여기서 비우지 않는다. 위의
|
||||
// LaunchedEffect(isConnected) 가 **끊기는 모든 경우**를
|
||||
// 맡는다 — 프로브가 스스로 끊길 때도 같아야 한다.
|
||||
appState.anchorCm = null
|
||||
appState.anchorBasis = null
|
||||
appState.clearAnchor()
|
||||
bleManager.disconnect()
|
||||
} else {
|
||||
appState.connectReturnScreen = AppScreen.HOSPITAL_MODE
|
||||
@@ -510,6 +512,12 @@ fun HospitalModeView(appState: AppState) {
|
||||
if (appState.anchorBasis?.isAlgorithm == true) "부착 위치 ${cm}cm 확정"
|
||||
else "부착 위치 ${cm}cm · ${appState.anchorBasis?.label ?: "근거 불명"}"
|
||||
} ?: "부착 위치 미정렬"
|
||||
val reattachSummary = when {
|
||||
appState.anchorCm == null -> ""
|
||||
appState.anchorReattach == null -> " · 재부착 미확인"
|
||||
appState.anchorReattach?.ok == true -> " · 재부착 ✓"
|
||||
else -> " · 재부착 ⚠"
|
||||
}
|
||||
|
||||
// 2. labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차
|
||||
// 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다
|
||||
@@ -519,7 +527,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 진행·중지·접촉이 스크롤 없이 한 화면에 들어오게 한다 — 이 화면을 다시 짠 이유다.
|
||||
if (running) {
|
||||
Text(
|
||||
"환자 ${patient.ifBlank { "(이름 없음)" }} · ${posture.label} · $anchorSummary · labdb " +
|
||||
"환자 ${patient.ifBlank { "(이름 없음)" }} · ${posture.label} · $anchorSummary$reattachSummary · labdb " +
|
||||
(if (LabdbCredentials.isRegistered) "연결됨" else "미등록"),
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlNeutral,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -539,6 +547,19 @@ fun HospitalModeView(appState: AppState) {
|
||||
enabled = true,
|
||||
onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN },
|
||||
)
|
||||
// 3-1. 재부착 확인(2026-09-30) — 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리가
|
||||
// 정렬 때와 같은 채널을 주는지. 부착 위치가 있을 때만 그린다.
|
||||
if (appState.anchorCm != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReattachCheckCard(
|
||||
appState = appState,
|
||||
bleManager = bleManager,
|
||||
patient = patient,
|
||||
connected = isConnected,
|
||||
enabled = true,
|
||||
supine = posture == ClinicalPosture.SUPINE,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 4. 환자명
|
||||
@@ -549,8 +570,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 환자가 바뀌면 정렬도 무효다. 앞 환자의 cm 을 그대로 들고 가면
|
||||
// 매니페스트에 틀린 위치가 기록된다.
|
||||
if (it != patient) {
|
||||
appState.anchorCm = null
|
||||
appState.anchorBasis = null
|
||||
appState.clearAnchor()
|
||||
}
|
||||
patient = it
|
||||
}
|
||||
@@ -624,6 +644,11 @@ fun HospitalModeView(appState: AppState) {
|
||||
|
||||
if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.")
|
||||
else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
|
||||
// 막지 않는다 — 경고만. 그대로 시작하면 run 매니페스트에 그 사실이 남는다.
|
||||
else if (appState.anchorCm != null && appState.anchorReattach == null)
|
||||
Hint("재부착 확인을 아직 안 했습니다 — 위 정렬 카드의 [재부착 확인]. 그대로 시작할 수는 있습니다.")
|
||||
else if (appState.anchorReattach?.ok == false)
|
||||
Hint("재부착 확인 실패(${appState.anchorReattach?.label}) — 다시 붙이고 [다시 확인]. 그대로 시작하면 기록에 남습니다.")
|
||||
}
|
||||
|
||||
// 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* ReattachCheckCard — 병원 임상 측정 화면의 **재부착 확인** (2026-09-30).
|
||||
*
|
||||
* 정렬 → 위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기 → **여기서 한 번 재고** → 측정.
|
||||
* 2026-09-29 임상에서 이 사이에 CH2 가 사라졌는데 알 길이 없었다. 부착 위치에서 정렬과
|
||||
* 같은 조건(2.3MHz · c3 · 20 cycle)으로 재서 정렬 확인 측정과 채널별로 대조한다
|
||||
* ([ReattachCheck]). 막지 않는다 — 경고만.
|
||||
*
|
||||
* 저장: `align/align_{n}cm_reattach.csv` + IMU, `align_result.json` 의 `reattach`, 그리고
|
||||
* 정렬 세션(601)에 phase=reattach 로 같이 올라간다. run 매니페스트에는 병원 화면이
|
||||
* `anchor_reattach` 로 넣는다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.AppState
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.managers.AnchorConfig
|
||||
import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.managers.DetachWatcher
|
||||
import com.medithings.vesiscan.managers.ReattachCheck
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader
|
||||
import com.medithings.vesiscan.ui.theme.MlCritical
|
||||
import com.medithings.vesiscan.ui.theme.MlPrimary
|
||||
import com.medithings.vesiscan.ui.theme.MlSecondaryText
|
||||
import com.medithings.vesiscan.ui.theme.MlSuccess
|
||||
import com.medithings.vesiscan.ui.theme.MlWarning
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.Date
|
||||
|
||||
/** 정렬과 같은 조건 — 다른 조건으로 재면 검출 문턱이 달라 채널 대조가 성립하지 않는다. */
|
||||
private val CHECK_FREQ = ProbeFrequency.F_2_3
|
||||
private val CHECK_CYCLE = ProbeCycle.C3
|
||||
private const val MEASURE_TIMEOUT_MS = 3_000L
|
||||
private const val CONFIG_TIMEOUT_MS = 3_000L
|
||||
private const val SETTLE_MS = 800L
|
||||
private const val CYCLE_GAP_MS = 600L
|
||||
|
||||
/** 재부착 확인 한 번의 원시 결과. 저장·판정은 호출부가 한다. */
|
||||
class ReattachMeasurement(
|
||||
val cycles: List<List<DoubleArray>>,
|
||||
val cyclesImu: List<List<ImuSample>>,
|
||||
val outcome: ClinicalBv.Outcome,
|
||||
val detach: DetachWatcher.Snapshot?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 부착 위치에서 정렬 조건으로 [AnchorConfig.ALIGN_CYCLES] 회 잰다. 정렬 화면의 위치 측정
|
||||
* 루프와 같은 순서(설정 → echo 확인 → cycle 마다 mtb → IMU 짝짓기)다.
|
||||
*
|
||||
* @return 결과와 실패 이유. 실패면 결과가 null.
|
||||
*/
|
||||
internal suspend fun measureForReattach(
|
||||
ble: BleManager,
|
||||
supine: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
): Pair<ReattachMeasurement?, String?> {
|
||||
val inbox = Channel<List<PiezoChannelData>>(Channel.CONFLATED)
|
||||
val guard = ProbeConfigGuard(ble)
|
||||
guard.capture()
|
||||
val prev = ble.piezoCollector.onMultiChannelComplete
|
||||
ble.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
|
||||
val imuSide = ImuSidecar(ble)
|
||||
imuSide.install()
|
||||
val watcher = DetachWatcher()
|
||||
try {
|
||||
ble.piezoConfigEcho.value = null
|
||||
ble.sendPiezoConfig(
|
||||
freq = CHECK_FREQ.freqOption,
|
||||
cycles = CHECK_CYCLE.cycles,
|
||||
avg = HospitalFixedParams.AVG,
|
||||
delayUs = HospitalFixedParams.DELAY_US,
|
||||
samples = HospitalFixedParams.SAMPLES,
|
||||
)
|
||||
val echo = withTimeoutOrNull(CONFIG_TIMEOUT_MS) {
|
||||
while (ble.piezoConfigEcho.value == null) delay(20)
|
||||
ble.piezoConfigEcho.value
|
||||
}
|
||||
if (echo?.matches(
|
||||
CHECK_FREQ.freqOption, CHECK_CYCLE.cycles,
|
||||
HospitalFixedParams.AVG, HospitalFixedParams.DELAY_US, HospitalFixedParams.SAMPLES,
|
||||
) != true
|
||||
) {
|
||||
return null to "프로브 설정 실패 — ${echo?.error ?: "응답 없음 또는 설정 불일치"}"
|
||||
}
|
||||
delay(SETTLE_MS)
|
||||
|
||||
val cycles = ArrayList<List<DoubleArray>>(AnchorConfig.ALIGN_CYCLES)
|
||||
val imus = ArrayList<List<ImuSample>>(AnchorConfig.ALIGN_CYCLES)
|
||||
var snap: DetachWatcher.Snapshot? = null
|
||||
for (i in 1..AnchorConfig.ALIGN_CYCLES) {
|
||||
while (inbox.tryReceive().isSuccess) { /* 늦게 온 직전 결과 비우기 */ }
|
||||
imuSide.drain()
|
||||
ble.sendMtb()
|
||||
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
|
||||
?: return null to "측정 시간 초과 ($i/${AnchorConfig.ALIGN_CYCLES}) — 다시 시도하세요."
|
||||
val imu = imuSide.await() ?: emptyList()
|
||||
val sorted = got.sortedBy { it.channel }
|
||||
watcher.push(sorted.map { it.buffer })?.let { snap = it }
|
||||
cycles += sorted.map { ch -> DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } }
|
||||
imus += imu
|
||||
onProgress(i)
|
||||
delay(CYCLE_GAP_MS)
|
||||
}
|
||||
// 정렬과 같은 mean-scan · 같은 supine 분기 — 정렬 확인 측정의 채널 검출과 같은 식이어야
|
||||
// "정렬 때 잡혔던 채널" 과 대조가 성립한다.
|
||||
val outcome = ClinicalBv.compute(ClinicalBv.meanScan(cycles), supine = supine)
|
||||
return ReattachMeasurement(cycles, imus, outcome, snap ?: watcher.snapshot) to null
|
||||
} finally {
|
||||
ble.piezoCollector.onMultiChannelComplete = prev
|
||||
imuSide.restore()
|
||||
withContext(NonCancellable) { guard.restore() }
|
||||
inbox.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 카드 아래에 붙는 재부착 확인 카드. 부착 위치가 없으면 그리지 않는다.
|
||||
*
|
||||
* @param patient 저장 폴더(환자·날짜)를 정한다 — 정렬과 같은 `align/` 에 쓴다.
|
||||
*/
|
||||
@Composable
|
||||
fun ReattachCheckCard(
|
||||
appState: AppState,
|
||||
bleManager: BleManager,
|
||||
patient: String,
|
||||
connected: Boolean,
|
||||
enabled: Boolean,
|
||||
supine: Boolean,
|
||||
) {
|
||||
val anchorCm = appState.anchorCm ?: return
|
||||
var running by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val result = appState.anchorReattach
|
||||
|
||||
LaunchedEffect(running) {
|
||||
if (!running) return@LaunchedEffect
|
||||
error = null
|
||||
progress = 0
|
||||
val (m, err) = measureForReattach(bleManager, supine) { progress = it }
|
||||
if (m == null) {
|
||||
error = err
|
||||
running = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val now = m.outcome.walls.take(ReattachCheck.CENTER_CHANNELS).map { it != null }
|
||||
val r = ReattachCheck.judge(
|
||||
ref = appState.anchorRefChannels,
|
||||
now = now,
|
||||
detached = m.detach?.detachedChannels ?: emptyList(),
|
||||
bvMl = m.outcome.volumeMl,
|
||||
)
|
||||
appState.anchorReattach = r
|
||||
// 정렬 폴더에 같이 남긴다 — 정렬 세션(601)에 phase=reattach 로 올라간다.
|
||||
runCatching {
|
||||
val dir = HospitalRunStore.alignDir(patient.ifBlank { "unnamed" }, Date())
|
||||
HospitalRunStore.writeAlignCycles(dir, anchorCm, m.cycles, HospitalRunStore.AlignFileKind.REATTACH)
|
||||
HospitalRunStore.writeAlignImu(dir, anchorCm, m.cyclesImu, HospitalRunStore.AlignFileKind.REATTACH)
|
||||
HospitalRunStore.updateAlignSummary(dir) { it.put("reattach", r.toJson()) }
|
||||
HospitalLabdbUploader.uploadAlignAsync(dir)
|
||||
}
|
||||
running = false
|
||||
}
|
||||
|
||||
val tone = when {
|
||||
running -> MlPrimary
|
||||
result == null -> MlWarning
|
||||
result.ok -> MlSuccess
|
||||
else -> MlCritical
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(tone.copy(alpha = 0.08f), RoundedCornerShape(12.dp))
|
||||
.border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
when {
|
||||
running -> "재부착 확인 중"
|
||||
result == null -> "재부착 확인 필요"
|
||||
result.ok -> "✓ 재부착 확인 — ${result.label}"
|
||||
else -> "⚠ 재부착 확인 — ${result.label}"
|
||||
},
|
||||
fontSize = 15.sp, fontWeight = FontWeight.Bold, color = tone,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { running = true }, enabled = connected && enabled && !running) {
|
||||
Text(
|
||||
if (result == null) "재부착 확인" else "다시 확인",
|
||||
color = if (connected && enabled && !running) MlPrimary else Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
running -> {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / AnchorConfig.ALIGN_CYCLES.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"치골 위 ${anchorCm}cm 에서 측정 중 $progress/${AnchorConfig.ALIGN_CYCLES}",
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
result != null -> {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ChannelCompareRow(result)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
buildString {
|
||||
result.bvMl?.let { append("BV %.0f mL · ".format(it)) }
|
||||
append(
|
||||
if (result.hasReference) "정렬 확인 측정과 채널별 대조"
|
||||
else "정렬 확인 측정이 없어 접촉 판정만 했습니다",
|
||||
)
|
||||
if (!result.ok) append(" — 프로브를 다시 붙이고 [다시 확인]. 그대로 시작해도 기록에 남습니다.")
|
||||
},
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"프로브를 뗐다 붙였으면(마킹 · 크래들) 측정 전에 한 번 재서 정렬 때와 같은 채널이 " +
|
||||
"잡히는지 확인합니다. 치골 위 ${anchorCm}cm · ${AnchorConfig.ALIGN_CYCLES}회.",
|
||||
fontSize = 12.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(it, fontSize = 12.sp, color = MlCritical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CH0~3 을 나란히 — 지금 잡힘/안 잡힘, 정렬 때 잡혔던 것과 다르면 빨강. */
|
||||
@Composable
|
||||
private fun ChannelCompareRow(r: ReattachCheck.Result) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for (ch in 0 until ReattachCheck.CENTER_CHANNELS) {
|
||||
val now = r.channels.getOrNull(ch) == true
|
||||
val ref = r.refChannels?.getOrNull(ch)
|
||||
val bad = ch in r.missing || ch in r.detached
|
||||
val c = when {
|
||||
bad -> MlCritical
|
||||
now -> MlSuccess
|
||||
else -> MlSecondaryText
|
||||
}
|
||||
Box(
|
||||
Modifier.weight(1f)
|
||||
.background(c.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("CH$ch " + (if (now) "✓" else "✗"), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = c)
|
||||
Text(
|
||||
when (ref) { null -> "정렬 –"; true -> "정렬 ✓"; else -> "정렬 ✗" },
|
||||
fontSize = 10.sp, color = MlSecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(0.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* ReattachCheckTest — 재부착 확인 판정을 잠근다.
|
||||
*
|
||||
* 2026-09-29 임상: 정렬은 CH0~3 이 다 잡혔는데 크래들로 다시 붙이니 CH2 가 사라진 채 다
|
||||
* 쟀다. 잠그는 것은 셋이다 — 정렬 때 잡혔던 채널이 빠지면 실패, 탈착 감시가 확정 미부착으로
|
||||
* 본 채널이 있으면 실패, 정렬 기준이 없으면 "정렬과 같다"고 말하지 않는다.
|
||||
*/
|
||||
package com.medithings.vesiscan.managers
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReattachCheckTest {
|
||||
|
||||
private val all = listOf(true, true, true, true)
|
||||
|
||||
@Test fun `정렬 때 잡힌 채널이 지금 없으면 실패이고 그 채널을 말한다`() {
|
||||
val r = ReattachCheck.judge(ref = all, now = listOf(true, true, false, true), detached = emptyList(), bvMl = 210.0)
|
||||
assertFalse(r.ok)
|
||||
assertEquals(listOf(2), r.missing)
|
||||
assertTrue(r.label.contains("CH2 없음"))
|
||||
assertTrue(r.hasReference)
|
||||
}
|
||||
|
||||
@Test fun `정렬 때도 없던 채널은 지금 없어도 실패가 아니다`() {
|
||||
val r = ReattachCheck.judge(ref = listOf(true, false, true, true), now = listOf(true, false, true, true), detached = emptyList(), bvMl = null)
|
||||
assertTrue(r.ok)
|
||||
assertEquals("정렬과 같음 (CH0~3)", r.label)
|
||||
}
|
||||
|
||||
@Test fun `탈착 감시가 미부착으로 본 채널이 있으면 실패다`() {
|
||||
val r = ReattachCheck.judge(ref = all, now = all, detached = listOf(1), bvMl = null)
|
||||
assertFalse(r.ok)
|
||||
assertEquals(listOf(1), r.detached)
|
||||
assertTrue(r.label.contains("CH1 미부착"))
|
||||
}
|
||||
|
||||
@Test fun `기준이 없으면 접촉 판정만 하고 그렇게 말한다`() {
|
||||
val ok = ReattachCheck.judge(ref = null, now = listOf(true, true, false, true), detached = emptyList(), bvMl = null)
|
||||
assertTrue("기준 없이 채널 소실을 실패로 잡으면 안 된다", ok.ok)
|
||||
assertFalse(ok.hasReference)
|
||||
assertEquals("접촉 정상 (정렬 기준 없음)", ok.label)
|
||||
|
||||
val bad = ReattachCheck.judge(ref = null, now = all, detached = listOf(3), bvMl = null)
|
||||
assertFalse(bad.ok)
|
||||
}
|
||||
|
||||
@Test fun `CH4·5 는 보지 않는다`() {
|
||||
// 좌우 균형용 채널은 벽 검출 대상이 아니다 — 여섯 개를 넘겨도 앞 넷만 본다.
|
||||
val r = ReattachCheck.judge(
|
||||
ref = listOf(true, true, true, true, false, false),
|
||||
now = listOf(true, true, true, true, false, true),
|
||||
detached = listOf(5), bvMl = null,
|
||||
)
|
||||
assertTrue(r.ok)
|
||||
assertEquals(4, r.channels.size)
|
||||
assertEquals(emptyList<Int>(), r.detached)
|
||||
}
|
||||
|
||||
@Test fun `기록 JSON 에 판정 근거가 다 들어간다`() {
|
||||
val j = ReattachCheck.judge(ref = all, now = listOf(true, true, false, true), detached = listOf(0), bvMl = 123.4, at = 5L).toJson()
|
||||
assertFalse(j.getBoolean("ok"))
|
||||
assertEquals(2, j.getJSONArray("missing").getInt(0))
|
||||
assertEquals(0, j.getJSONArray("detached").getInt(0))
|
||||
assertEquals(4, j.getJSONArray("channels").length())
|
||||
assertEquals(4, j.getJSONArray("ref_channels").length())
|
||||
assertEquals(123.4, j.getDouble("bv_ml"), 1e-9)
|
||||
assertEquals(5L, j.getLong("at"))
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,30 @@ class HospitalRunStoreAlignFilesTest {
|
||||
assertTrue(lines[3].startsWith("1,0,"))
|
||||
}
|
||||
|
||||
@Test fun `좌우 흐름 기록은 헤더 한 번, 프레임마다 6줄이고 이어 쓴다`() {
|
||||
val d = tmp.newFolder("align")
|
||||
HospitalRunStore.LateralStreamWriter(d, 3).use { w ->
|
||||
w.frame(1, 0, frames(1)[0], listOf(sample(0.9f)), u4 = 12, u5 = 15, imbalance = 3, action = "MOVE_LEFT", ch3 = true)
|
||||
w.frame(1, 1, frames(1)[0], emptyList(), u4 = null, u5 = null, imbalance = null, action = null, ch3 = null)
|
||||
}
|
||||
// 다시 열면(스트리밍 재시작) 헤더 없이 이어진다 — attempt 열로 시도를 가른다.
|
||||
HospitalRunStore.LateralStreamWriter(d, 3).use { w ->
|
||||
w.frame(2, 2, frames(1)[0], emptyList(), 1, 2, 1, "STOP", false)
|
||||
}
|
||||
val lines = File(d, "align_3cm_lateral_stream.csv").readLines()
|
||||
assertEquals(1 + 18, lines.size)
|
||||
assertTrue(lines[0].startsWith("attempt,frame_idx,ch,u4,u5,imbalance,action,ch3,s0"))
|
||||
assertEquals("1,0,0,12,15,3,MOVE_LEFT,1,0,1,2,3", lines[1])
|
||||
assertEquals("1,1,0,,,,,,0,1,2,3", lines[7])
|
||||
assertEquals("2,2,5,1,2,1,STOP,0,50,51,52,53", lines[18])
|
||||
assertEquals(1, lines.count { it.startsWith("attempt,") })
|
||||
|
||||
val imu = File(d, "align_3cm_lateral_stream_imu.csv").readLines()
|
||||
assertEquals("cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps", imu[0])
|
||||
assertEquals(2, imu.size)
|
||||
assertTrue(imu[1].startsWith("0,0,"))
|
||||
}
|
||||
|
||||
@Test fun `IMU 가 하나도 없으면 파일을 만들지 않는다`() {
|
||||
// 부재가 곧 "그때는 안 쟀다" 다 — 빈 파일이 있으면 받는 쪽이 0 샘플로 읽는다.
|
||||
val d = tmp.newFolder("align")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.medithings.vesiscan.services.labdb
|
||||
|
||||
import com.medithings.vesiscan.services.HospitalRunStore
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
@@ -163,6 +165,43 @@ class AlignLabdbPayloadTest {
|
||||
assertEquals(10, params.getInt("lateral_frames"))
|
||||
}
|
||||
|
||||
@Test fun `좌우 흐름 전체와 재부착 확인도 phase 로 올라간다`() {
|
||||
// 2026-09-30: 좌우 스트리밍 전체(프레임 + 그 프레임의 판정)와, 뗐다 붙인 뒤의
|
||||
// 재부착 확인 측정. 둘 다 부착 위치 cm 이고 위치 수에는 안 더한다.
|
||||
val d = tmp.newFolder("align")
|
||||
writeCycles(d, 0, cycles = 1)
|
||||
val sb = StringBuilder(HospitalRunStore.LateralStreamWriter.waveHeader(4)).append('\n')
|
||||
for (fi in 0 until 2) for (ch in 0 until 6) {
|
||||
sb.append("1,$fi,$ch,12,15,3,MOVE_LEFT,1,900,900,3500,900\n")
|
||||
}
|
||||
File(d, "align_3cm_lateral_stream.csv").writeText(sb.toString(), Charsets.UTF_8)
|
||||
File(d, "align_3cm_reattach.csv").writeText(
|
||||
(0 until 6).joinToString("") { ch -> "0,$ch,900,900,3500,900\n" }, Charsets.UTF_8)
|
||||
|
||||
val p = AlignLabdbPayload.build(d)!!
|
||||
val recs = p.getJSONArray("records")
|
||||
assertEquals(4, p.getInt("recordCount"))
|
||||
assertEquals(
|
||||
listOf("sweep", "lateral_stream", "lateral_stream", "reattach"),
|
||||
(0 until 4).map { recs.getJSONObject(it).getString("phase") },
|
||||
)
|
||||
val lat = recs.getJSONObject(1).getJSONObject("lateral")
|
||||
assertEquals(12, lat.getInt("u4"))
|
||||
assertEquals(15, lat.getInt("u5"))
|
||||
assertEquals(3, lat.getInt("imbalance"))
|
||||
assertEquals("MOVE_LEFT", lat.getString("action"))
|
||||
assertTrue(lat.getBoolean("ch3"))
|
||||
assertEquals(1, lat.getInt("attempt"))
|
||||
assertEquals(6, recs.getJSONObject(1).getJSONArray("channels").length())
|
||||
assertEquals(4, recs.getJSONObject(1).getJSONArray("channels").getJSONObject(0).getJSONArray("data").length())
|
||||
assertFalse(recs.getJSONObject(3).has("lateral"))
|
||||
|
||||
val params = p.getJSONObject("params")
|
||||
assertEquals(1, params.getInt("positions_measured"))
|
||||
assertEquals(2, params.getInt("lateral_stream_frames"))
|
||||
assertEquals(1, params.getInt("reattach_frames"))
|
||||
}
|
||||
|
||||
@Test fun `좌우 프레임이 없으면 lateral_frames 는 0 이다`() {
|
||||
val d = tmp.newFolder("align")
|
||||
writeCycles(d, 0, cycles = 1)
|
||||
|
||||
@@ -385,7 +385,12 @@ val anchorCm: Int? get() = bestCm?.let { it + offsetCm } // offsetCm = 1
|
||||
↓
|
||||
치골 위 N+1 cm 에 붙이세요 (초록 · 여기서는 측정하지 않음)
|
||||
↓
|
||||
좌우 정렬 (치골 위 N+1 cm 그대로)
|
||||
좌우 정렬 (치골 위 N+1 cm 그대로) ← 흐름 전체 + 확인 완료 순간의 창을 저장
|
||||
↓
|
||||
(위치 마킹 → 프로브 떼기 → 크래들로 다시 붙이기)
|
||||
↓
|
||||
재부착 확인 — 병원 화면 정렬 카드에서 20 cycle 재서 정렬 확인 측정과 CH0~3 을 채널별로 대조
|
||||
(2026-09-29 임상: 이 사이에 CH2 가 사라졌다). 경고만, 막지 않음. 기록에 남음.
|
||||
```
|
||||
|
||||
지시문의 숫자는 **치골 위 절대 위치 하나**입니다(2026-09-29). "2cm 내리고" 같은 상대
|
||||
|
||||
+11
-2
@@ -175,6 +175,10 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
|
||||
| `positions_measured` | int | 측정한 위치 수 (탐색 `sweep` 만) |
|
||||
| `confirm_positions` | int | 확인 측정한 위치 수 |
|
||||
| `lateral_frames` | int | 좌우 확인 완료 순간에 남긴 프레임 수 (2026-09-30~). 0 = 좌우 미확인 또는 옛 앱 |
|
||||
| `lateral_stream_frames` | int | 좌우 정렬 동안 흘린 프레임 전부 (2026-09-30~) |
|
||||
| `reattach_frames` | int | 재부착 확인 측정 cycle 수 (2026-09-30~). 0 = 안 했음 |
|
||||
| `confirm_channels` | bool[4] | 정렬 확인 측정에서 벽이 잡힌 채널 CH0~3 (2026-09-30~). 재부착 확인의 기준 |
|
||||
| `reattach` | object | 재부착 확인 판정 `{ok, missing[], detached[], channels[4], ref_channels[4]\|null, bv_ml, at}` — 정렬 뒤 뗐다 붙인 자리에서 정렬 때 잡힌 채널이 빠졌는지 |
|
||||
| `cycles_total` | int | = `recordCount` |
|
||||
| `upload_reason` | string | `"nurse_review"` — 사람이 올린 세션 표시 |
|
||||
| `device`, `firmware_version`, `hw_preset` | string | |
|
||||
@@ -324,6 +328,11 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
|
||||
- `phase: "lateral"` (2026-09-30~) 은 간호사가 "좌우 확인 완료"를 누른 순간의 **마지막
|
||||
프레임 창**(판정 1회에 쓰는 10프레임)입니다. `align_cm` 은 부착 위치(best + 오프셋)이고,
|
||||
`cycle_idx` 는 그 창 안의 순서입니다. 좌우 정렬 판정을 나중에 대조하는 데 씁니다.
|
||||
- `phase: "lateral_stream"` (2026-09-30~) 은 좌우 정렬 동안 흘린 **프레임 전부**입니다.
|
||||
레코드마다 `lateral: {attempt, u4, u5, imbalance, action, ch3}` 가 붙습니다 — 그 프레임까지의
|
||||
판정(앱이 화면에 보인 것). `attempt` 는 [처음부터 다시 정렬] 마다 1씩 오릅니다.
|
||||
- `phase: "reattach"` (2026-09-30~) 은 정렬 뒤 마킹·떼기·크래들 부착을 거친 자리에서 병원
|
||||
화면이 20 cycle 잰 **재부착 확인 측정**입니다. 판정은 `params.reattach` 에.
|
||||
|
||||
## ⚠ 없으면 `sensor` 는 빈 객체입니다 — 0 으로 채우지 마십시오
|
||||
|
||||
@@ -360,9 +369,9 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
|
||||
scan_id,timestamp,repeat_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps
|
||||
→ scan_id 로 파형 CSV 와 잇습니다
|
||||
|
||||
601 align_{n}cm_imu.csv · align_{n}cm_confirm_imu.csv · align_{n}cm_lateral_imu.csv
|
||||
601 align_{n}cm_imu.csv · …_confirm_imu.csv · …_lateral_imu.csv · …_lateral_stream_imu.csv · …_reattach_imu.csv
|
||||
cycle_idx,sample_idx,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps
|
||||
→ 파형과 같은 파일명 규칙(confirm/lateral 분리)을 그대로 따릅니다
|
||||
→ 파형과 같은 파일명 규칙(종류별 분리)을 그대로 따릅니다
|
||||
```
|
||||
|
||||
파형 행(`meta + s0..s99`)을 넓히지 않은 이유: 그 헤더는 이미 올라간 데이터와 파서가
|
||||
|
||||
Reference in New Issue
Block a user