feat(algo): 미이식 3건 마저 이식 — post_tie_lock · sibeam · F83

레퍼런스에서 남겨 뒀던 세 가지를 옮겼다. 셋 다 레퍼런스 경로 전용이고 기존(현행
임상) 경로는 그대로다.

  · post_tie_lock  후벽 상위 두 후보의 score 가 구조적으로 붙는 구간(전 데이터
                   5773 결정 중 격차<10% 가 17.9%)에서 직전 trace 선택을 유지한다.
                   방향을 강제하지 않는다 — 깊은-peak 고정·인접 골 tie-break 은
                   전부 계통 편향을 낳았고, tie 밴드에서는 정의상 두 후보의 증거가
                   같기 때문이다. 상태기를 넘길 때만 동작하며, 레퍼런스도 streaming
                   strict pass 에서만 건다. streaming.py 이식 전까지 호출부는 없다.
  · _sibeam_cap_bounds  검출 채널이 정확히 2개일 때 미검출 이웃 빔까지의 SI 거리를
                   cap 상한으로. 2개뿐이면 끝 채널이 서로의 이웃이라 기존 top/bottom
                   clamp 가 성립하지 않아 cap 이 무제한 외삽된다. supine 은 건너뛴다.
  · F83            적도-밖 bottom cap 연속 보간(실리콘·supine). 전제인
                   mirror_bottom_cap 도 함께 옮겼다.

좌표계가 두 개라는 것을 확인했다

레퍼런스 cross_channel._z 는 sample_to_ap_depth 를 인자 없이 불러 교차채널 좌표가
언제나 모듈 기본값(1.936/6.85)으로 떨어진다. BV 는 프리셋 값을 명시로 넘긴다.
abs 는 두 값이 같아 표가 안 나지만 실리콘(1.897/7.651)에서는 갈린다 — 실측
f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로 갈려 439.88 vs 416.95 mL 이 됐다.
is_deep 판정이 34.209 > 34.042 처럼 아슬아슬한 자리라 작은 좌표 차가 선택을 뒤집는다.
의도인지 누락인지는 알고리즘팀 확인이 필요하다. 확인 전까지 레퍼런스와 같은 값을
내는 쪽을 택했고, 기존 경로는 프리셋 값을 그대로 쓴다.

missing_outside 의 null 과 빈 집합을 구분했다

null = 계산 안 함 → cap clamp 를 언제나 상한으로. 빈 집합 = 계산했고 '방광 밖'
채널이 없다 → 하한 쪽으로. ifEmpty { null } 로 뭉개면 "미검출이지만 span 은 있다"는
가장 흔한 경우에 si_floor 가 통째로 죽는다.

검증

  · 레퍼런스 대조 6종 전부 불일치 0 — tie 1568행(lock 24건) · 실데이터 BV 784행
    (nch=2 6건) · sibeam 120행(상한 102건) · 합성 BV 640행(mirror 226건) ·
    F83 blend 440행 · F83 apply 12행. 실데이터가 미러·nch=2 에 거의 안 닿아
    그 경로는 합성 입력으로 레퍼런스 함수를 직접 호출한 기대값으로 덮었다.
  · 기존 경로 무변경 — 44 cycle × CCC on/off 덤프가 HEAD 와 88행 전부 일치.
  · 전체 36개 통과 · demoDebug APK 빌드 성공.

LegacyEquivalenceDumpTest 가 PiezoHW.activePreset 을 명시로 고정하게 했다. 전역
가변 상태라 다른 테스트가 먼저 돌면 덤프가 통째로 달라진다 — 실제로 한 번 착시를
만들었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 18:09:26 +09:00
parent ccb44f0ea6
commit 441f249410
19 changed files with 4415 additions and 29 deletions
@@ -45,6 +45,10 @@ object MethodDRunner {
/** dominant-pick 보호: raw 픽 score >= 이 배수 × 차순위 → 합의 교정에서 보존. */
private const val ANT_DOMINANT_RATIO = 2.0
// 레퍼런스 경로의 교차채널 z 좌표용 전역 캘리브레이션. [zAt] 주석 참조.
private const val CCC_DISTANCE_PER_SAMPLE = 1.936
private const val CCC_DELAY_OFFSET_MM = 6.85
/** neighbor_top_validate: nb-1→nb slope 가 이 이하면 "비증가 추세". */
private const val NTV_SLOPE_EPS = 2.0
/** neighbor_top_validate: top 이 예측보다 이만큼 깊으면 위반 (FP). */
@@ -97,8 +101,9 @@ object MethodDRunner {
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
applyCross: Boolean = true,
tieLock: PostTieLock? = null,
): List<MethodDResult?> = detectMultichannelWithEvidence(
signals, params, beamAnglesDeg, applyTgc, applyCross).walls
signals, params, beamAnglesDeg, applyTgc, applyCross, tieLock).walls
fun detectMultichannelWithEvidence(
signals: List<DoubleArray>,
@@ -106,6 +111,11 @@ object MethodDRunner {
beamAnglesDeg: DoubleArray? = null,
applyTgc: Boolean = true,
applyCross: Boolean = true,
/**
* 후벽 near-tie trace lock 상태기. **측정 하나마다** 새로 만들어 trace 를 순서대로
* 넘겨야 의미가 있다. null 이면 미적용 — 기존 경로 호출부는 넘기지 않는다.
*/
tieLock: PostTieLock? = null,
): DetectionOutcome {
val angles = beamAnglesDeg ?: PiezoHW.degreeAll
// 1) per-channel SG denoise (heavy + light)
@@ -128,6 +138,11 @@ object MethodDRunner {
}
val ref = com.medithings.vesiscan.managers.AlgoMode.reference
// ── 3) 후벽 near-tie trace lock ───────────────────────────────────
// 검출 뒤·교차채널 앞. 상태기를 넘겨야 동작하므로 넘기지 않는 기존 호출부는
// 아무 영향이 없다.
tieLock?.apply(results, params)
// ── 2) 검출 실패 채널만 SG5 로 재탐색 (레퍼런스 경로 전용) ─────────────
// 이미 검출된 채널은 건드리지 않는다 — 얻는 것은 커버리지뿐이고 측정면은 불변.
if (ref && SCALE_RECOVERY && applyCross) {
@@ -136,9 +151,12 @@ object MethodDRunner {
// ── 4) 미검출 채널의 span 유무 판정 (레퍼런스 경로 전용) ───────────────
// **CCC 앞에서** 계산한다 — CCC 가 복구한 채널은 '방광 밖'이 아니다.
val missingOut: Set<Int> =
// **null 과 빈 집합은 다르다.** null = 계산 안 함 → cap clamp 를 언제나 상한으로.
// 빈 집합 = 계산했고 '방광 밖' 채널이 없다 → 미검출 채널은 전부 span 이 있다는
// 뜻이라 하한 쪽으로 간다. 뭉개면 si_floor 가 가장 흔한 경우에 통째로 죽는다.
val missingOut: Set<Int>? =
if (ref && applyCross) missingOutside(results, lightTgc, heavyTgc, angles, params)
else emptySet()
else null
// ── 5) 교차채널 보정 ───────────────────────────────────────────────
if (applyCross) {
@@ -177,7 +195,7 @@ object MethodDRunner {
*/
data class DetectionOutcome(
val walls: List<MethodDResult?>,
val missingOutside: Set<Int>,
val missingOutside: Set<Int>?,
)
/**
@@ -554,11 +572,12 @@ object MethodDRunner {
// 공통 helpers
// ─────────────────────────────────────────────────────────────────────────
/** z(i, idx) = (DELAY_OFFSET_MM + idx * DISTANCE_PER_SAMPLE) * cos(angle_i) — sample_to_ap_depth. */
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double {
val ang = if (ch < angles.size) angles[ch] else 0.0
return (PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(ang))
}
/**
* z(i, idx) — `sample_to_ap_depth`. 좌표계 선택은 [zAt] 이 한다(모드에 따라 갈린다).
* 여기서 따로 계산하면 `ant_tiebreak` 만 다른 좌표를 쓰게 된다.
*/
private fun zOf(angles: DoubleArray, ch: Int, idx: Int): Double =
zAt(if (ch < angles.size) angles[ch] else 0.0, idx)
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
private fun median(xs: List<Double>): Double {
@@ -580,9 +599,23 @@ object MethodDRunner {
// ═══ 레퍼런스 경로 전용 ([AlgoMode.reference]) ═══════════════════
/** z(angle, idx) — angle 을 직접 받는 sample_to_ap_depth. vdipDeepCandidate 재사용. */
/**
* z(angle, idx) — angle 을 직접 받는 `sample_to_ap_depth`.
*
* ⚠ 레퍼런스 경로에서는 **프리셋이 아니라 전역 캘리브레이션 상수**를 쓴다.
* `wall_correction/cross_channel._z` 가 `sample_to_ap_depth(idx, angle)` 를 인자 없이
* 불러 모듈 기본값(1.936 / 6.85)으로 떨어지기 때문이다. BV 쪽은 프리셋 값을 명시로
* 넘기므로 두 좌표계가 서로 다르다 — abs(v*)는 값이 같아 표가 안 나지만 실리콘
* (r*: 1.897/7.651)에서는 갈린다. 실측 f06/r1 단일 cycle 에서 CH0 전벽이 8 vs 14 로
* 갈려 부피가 439.88 vs 416.95 mL 이 됐다.
*
* 기존 경로는 프리셋 값을 그대로 쓴다 — 오늘 임상이 그 위에서 돌았다.
*/
private fun zAt(angleDeg: Double, idx: Int): Double =
(PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg))
if (com.medithings.vesiscan.managers.AlgoMode.reference)
(CCC_DELAY_OFFSET_MM + idx * CCC_DISTANCE_PER_SAMPLE) * cos(Math.toRadians(angleDeg))
else
(PiezoHW.delayOffsetMm + idx * PiezoHW.distancePerSample) * cos(Math.toRadians(angleDeg))
/** numpy.median 동작 매칭 — 짝수 길이면 두 가운데 값의 평균. */
/**