From 31527830e5471a58271ec66b6ffab77059c1b902 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 20 Jul 2026 17:50:32 +0900 Subject: [PATCH] =?UTF-8?q?fix(bv):=20estimateBv=20+=20adaptive=5Flarge=5F?= =?UTF-8?q?bladder=5Frelax=20+=20b=5Fsi=5Ffloor=20=E2=80=94=20BV=20=CE=94?= =?UTF-8?q?=200.000=20mL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python `runners.estimate_bv` 완전 이식으로 single-trace BV 를 Python 과 **bit-perfect** 매칭. 그동안 발견되지 못한 근본 원인은: 1) `_apply_urine_inset_one` (LUMEN_INSET_FRAC=0.15) — Kotlin 호출자에서 미적용. 이전 commit db64407 에서 subsample refined 만 이식하고 inset 은 건너뜀. Python 은 fit 전에 walls 를 lumen 안쪽으로 15% 이동 후 int round. 2) `adaptive_large_bladder_relax` wrapper (runners.py:180) — Kotlin 자체가 이 wrapper 를 이식 안 함. Python 은 첫 계산 후 low_wide_endpoint 조건 (endpoint 단면이 넓지만 cap 낮음) 만족 시 `urine_inset_frac=0.05` + `b_si_floor_ratio=0.85` 로 재실행. 이 케이스에서 b_si_floor 가 발동해 b_si_ellipse 를 강제 상향 (43 → 47 mm) → cap 부피 크게 증가. 3) `b_si_floor_ratio` (bv_estimation.py:1316-1321) — ellipse fit 성공 block 에서 자유 b_si 붕괴 방지 로직. Kotlin 미이식. 주요 변경: - managers/PiezoBVEstimator.kt * `WallWithSpan` data class 신규 (Python extract_walls 4-tuple 대응) * `estimateBv(walls, hw, ..., adaptiveLargeBladderRelax=true)` 신규 — Python runners.estimate_bv 1:1. low_wide_endpoint 검사 + retry 로직. * `estimateBladderVolume6ch` Double 오버로드 : bSiFloorRatio, bSiFloorEdgeMin 파라미터 추가. * `estimateBladderVolume` : b_si_floor 로직 이식 (Python 1316-1321). * `applyLumenInsetOne` Double 오버로드 신규. - managers/AlignmentAdvisorV3.kt : buildRecord 가 estimateBv 사용. - ui/views/clinical/ClinicalLiveView.kt : BV chip METHOD_D 가 estimateBv 사용. - test/PrecisionDumpTest.kt : estimateBv 로 통일. - test/CenterAlignerValidationTest.kt : cm=0/1 tolerance 0.02 (Python 완전 일치), cm=3 는 0.10 (multi-trace edge case 잔존). 검증 (data123 cm=1 trace 0 first 10 cycles): volume_ml : py=421.028293482 == kt=421.028293482 Δ=0.000000000 ★ bottom_h_mm : py= 20.059475664 == kt= 20.059475664 Δ=0.000000000 ★ top_h_mm : py= 10.441122871 == kt= 10.441122871 Δ=0.000000000 ★ capBSiMm : py= 47.049521628 == kt= 47.049521628 Δ=0.000000000 ★ capCApMm : py= 55.352378386 == kt= 55.352378386 Δ=0.000000000 ★ V3 CenterAligner: cm=0/1 bv_cv Python 완전 일치. cm=3 만 0.048 vs 0.130 (adaptive relax multi-trace edge case, 최종 cm=3 선택은 안정). 원인 발견 과정 (총 5 iteration): db64407 - subsample refined wall 이식 (Δ 50→43 mL) d732150 - Halir-Flusser + ellipse_cap_height branch (Δ 43→13 mL) 이번 커밋 - lumen_inset + adaptive_relax + b_si_floor (Δ 13→0.000 mL) ★ --- .../vesiscan/managers/AlignmentAdvisorV3.kt | 10 +- .../vesiscan/managers/EllipseFitSpecific.kt | 15 ++- .../vesiscan/managers/PiezoBVEstimator.kt | 121 +++++++++++++++++- .../ui/views/clinical/ClinicalLiveView.kt | 10 +- .../managers/CenterAlignerValidationTest.kt | 26 ++-- .../vesiscan/managers/PrecisionDumpTest.kt | 18 ++- 6 files changed, 173 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt index acac5f1..4ba79b2 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/AlignmentAdvisorV3.kt @@ -193,12 +193,12 @@ class CenterAligner( if (trBase.size > CH3_INDEX && trBase[CH3_INDEX] != null) ch3Hit++ val trCross = MethodDRunner.detectMultichannel( tr, methodDParams, applyTgc = true, applyCross = true) - // Python `extract_walls` 는 antRefined/postRefined (subsample) 를 우선 사용. - // 정수 ant/post 만 넘기면 최대 1 sample 오차가 D_mm/cap/BV 로 누적 → ~50 mL 편차. - val allWalls: List?> = trCross.map { r -> - r?.let { Pair(it.antRefined, it.postRefined) } + // Python `runners.estimate_bv(walls, hw)` 완전 대응 — inset(0.15) + adaptive + // large bladder relax (low_wide_endpoint 시 inset=0.05 + b_si_floor=0.85 재계산). + val walls: List = trCross.map { r -> + r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } } - val bv = estimateBladderVolume6ch(allWalls) + val bv = estimateBv(walls) // Python `position_records`: `if bv is not None: bvs.append(float(bv.volume_ml))` // nan/0 필터 없이 그대로 추가. nan 발생 시 std/mean 모두 nan → bv_cv=null → // `selectCm` tie 정렬에서 +inf 로 취급되어 뒷순위로 밀림 (Python 과 동일 동작). diff --git a/app/src/main/java/com/medithings/vesiscan/managers/EllipseFitSpecific.kt b/app/src/main/java/com/medithings/vesiscan/managers/EllipseFitSpecific.kt index 4360336..71cdde4 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/EllipseFitSpecific.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/EllipseFitSpecific.kt @@ -37,6 +37,14 @@ object EllipseFitSpecific { val residuals: DoubleArray, ) + /** DEBUG: 테스트 케이스에서 Python 의 eigenvector 를 강제 주입 (원인 pinpoint 용). null 이면 정상 solver. */ + var debugForceEigenvector: DoubleArray? = null + /** DEBUG: 마지막 fit 의 상류 intermediate. Python 대조용. */ + var debugYm: Double = 0.0; var debugYsS: Double = 0.0 + var debugZm: Double = 0.0; var debugZsS: Double = 0.0 + var debugT: Array? = null + var debugA2: DoubleArray? = null + /** * (yw_f, zw_f) 두 벡터 (>= 5 점) → 타원 fit. 실패 (타원해 없음/특이) 시 null. */ @@ -51,6 +59,7 @@ object EllipseFitSpecific { val zsS = std(zwF) + 1e-12 val u = DoubleArray(n) { (ywF[it] - ym) / ysS } val v = DoubleArray(n) { (zwF[it] - zm) / zsS } + debugYm = ym; debugYsS = ysS; debugZm = zm; debugZsS = zsS // 2) D1 = [u², u·v, v²], D2 = [u, v, 1] // S1 = D1ᵀD1 (3×3), S2 = D1ᵀD2 (3×3), S3 = D2ᵀD2 (3×3) @@ -76,6 +85,7 @@ object EllipseFitSpecific { val neg = Array(3) { DoubleArray(3) } matmul3x3(s3inv, s2t, neg) val t = Array(3) { row -> DoubleArray(3) { col -> -neg[row][col] } } + debugT = t // M = C1⁻¹ · (S1 + S2·T) // C1⁻¹ = [[0, 0, 0.5], [0, -1, 0], [0.5, 0, 0]] @@ -91,7 +101,9 @@ object EllipseFitSpecific { matmul3x3(c1inv, sSum, m) // 4) M 의 3개 eigenvector 계산 (실수만 유지) - val eigenvectors = eig3Real(m) ?: return null + // DEBUG mode: Python 의 eigenvector 를 강제 주입해 downstream 만 검증. + val eigenvectors = if (debugForceEigenvector != null) listOf(debugForceEigenvector!!) + else eig3Real(m) ?: return null // 5) 조건 4·a·c − b² > 0 인 eigenvector 선택 var picked: DoubleArray? = null @@ -107,6 +119,7 @@ object EllipseFitSpecific { // 6) a1 = picked, a2 = T · a1 var a1 = picked var a2 = matvec3(t, a1) + debugA2 = a2 var a = a1[0]; var b = a1[1]; var c = a1[2] var d = a2[0]; var e = a2[1]; var f = a2[2] diff --git a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt index 050366b..21a19b6 100644 --- a/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt +++ b/app/src/main/java/com/medithings/vesiscan/managers/PiezoBVEstimator.kt @@ -479,6 +479,20 @@ fun applyLumenInsetOne( return Pair(ai, pi) } +/** Double(subsample refined) 오버로드. Python `_apply_urine_inset_one` 이 float ant + * (extract_walls 의 refined) + int ls/le → int(round(...)) 반환하는 flow 와 정확히 + * 일치. 반환은 Int (Python 매칭). frac=0 or ls/le null 시 refined→round(int) 만. */ +fun applyLumenInsetOne( + ant: Double, post: Double, ls: Int?, le: Int?, frac: Double = LUMEN_INSET_FRAC, +): Pair { + if (frac <= 0 || ls == null || le == null) { + return Pair(kotlin.math.round(ant).toInt(), kotlin.math.round(post).toInt()) + } + val ai = kotlin.math.round(ant + frac * (ls - ant)).toInt() + val pi = kotlin.math.round(post - frac * (post - le)).toInt() + return Pair(ai, pi) +} + /** Python `BOTTOM_CAP_SAGITTA_NOISE_MM` (bv_estimation.py:895). */ const val BOTTOM_CAP_SAGITTA_NOISE_MM: Double = 4.0 @@ -511,6 +525,8 @@ fun estimateBladderVolume6ch( * V3 CenterAligner 등 정밀 검증 경로에서 사용. */ fun estimateBladderVolume6ch( allWalls: List?>, + bSiFloorRatio: Double? = null, + bSiFloorEdgeMin: Double = 0.5, ): BVResult? { var centerWalls = PiezoHW.centerCh.map { if (it < allWalls.size) allWalls[it] else null } centerWalls = repairCenterWallsFor6ch(centerWalls) @@ -523,10 +539,98 @@ fun estimateBladderVolume6ch( walls = centerWalls, lrRatio = lrRatio, edgeCh = 3, - edgeRefChannels = listOf(1, 2) + edgeRefChannels = listOf(1, 2), + bSiFloorRatio = bSiFloorRatio, + bSiFloorEdgeMin = bSiFloorEdgeMin, ) } +/** Wall + span 정보 (Python `extract_walls` 반환 4-tuple 대응). */ +data class WallWithSpan(val ant: Double, val post: Double, val lowStart: Int, val lowEnd: Int) + +/** + * Python `runners.estimate_bv(walls, hw, adaptive_large_bladder_relax=True)` 1:1 이식. + * + * 표준 파라미터: lr_ratio=1.0, ellipse_cap_height=True, urine_inset_frac=0.15. + * + * `adaptive_large_bladder_relax`: 첫 계산 결과의 끝단 단면이 본체 최대 단면 대비 넓고 + * cap 이 너무 얕으면 (low_wide_endpoint) `urine_inset_frac=0.05` + `b_si_floor_ratio=0.85` + * 로 완화 재계산. 큰 방광의 cap 過소외삽 (BV 저추정) 방지. + */ +fun estimateBv( + walls: List, + lrRatio: Double = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride ?: 1.0, + urineInsetFrac: Double = LUMEN_INSET_FRAC, + bSiFloorRatio: Double? = null, + bSiFloorEdgeMin: Double = 0.5, + adaptiveLargeBladderRelax: Boolean = true, +): BVResult? { + val result = evalOnce(walls, lrRatio, urineInsetFrac, bSiFloorRatio, bSiFloorEdgeMin) + + // adaptive 조건 미충족이거나 명시 override 있으면 return + if (!adaptiveLargeBladderRelax || result == null + || urineInsetFrac != LUMEN_INSET_FRAC || bSiFloorRatio != null) { + return result + } + + // low_wide_endpoint 검사 — Python bv_estimation.py runners.py line 219-236 1:1 + if (result.validChannels.size < 4 || result.volumeMm3 <= 0) return result + val dByCh = result.validChannels.zip(result.dMm.toList()).toMap() + val dSorted = result.sortedChannels.mapNotNull { dByCh[it] } + if (dSorted.size < 2) return result + val radii = dSorted.filter { it > 0 }.map { it / 2.0 } + if (radii.size < 2) return result + + val maxR = radii.max() + val botR = dSorted[0] / 2.0 + val topR = dSorted.last() / 2.0 + if (maxR <= 0 || botR <= 0 || topR <= 0) return result + + val botEdge = botR / maxR + val topEdge = topR / maxR + val botHRel = result.bottomHMm / botR + val topHRel = result.topHMm / topR + val capFrac = (result.vBottomMm3 + result.vTopMm3) / result.volumeMm3 + val lowWideEndpoint = (capFrac < 0.20 + && minOf(botEdge, topEdge) >= 0.48 + && ((botEdge >= 0.70 && botHRel < 0.42) + || (topEdge >= 0.70 && topHRel < 0.42))) + + if (lowWideEndpoint) { + val relaxed = evalOnce(walls, lrRatio, + urineInsetFrac = 0.05, + bSiFloorRatio = 0.85, + bSiFloorEdgeMin = bSiFloorEdgeMin) + if (relaxed != null) return relaxed + } + return result +} + +/** estimateBv 내부 헬퍼 — inset 적용 후 estimateBladderVolume6ch 호출. */ +private fun evalOnce( + walls: List, + lrRatio: Double, + urineInsetFrac: Double, + bSiFloorRatio: Double?, + bSiFloorEdgeMin: Double, +): BVResult? { + val insetWalls: List?> = walls.map { w -> + w?.let { + val ins = applyLumenInsetOne(it.ant, it.post, it.lowStart, it.lowEnd, urineInsetFrac) + Pair(ins.first.toDouble(), ins.second.toDouble()) + } + } + // lrRatio 는 GreenZoneConstants override 사용하지 않고 직접 계산에 사용하도록 + // estimateBladderVolume6ch 를 우회 (호출 시 override 상수 참조 회피). + val prevOverride = com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride + com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = lrRatio + return try { + estimateBladderVolume6ch(insetWalls, bSiFloorRatio, bSiFloorEdgeMin) + } finally { + com.medithings.vesiscan.managers.GreenZoneConstants.lrRatioOverride = prevOverride + } +} + /** Int 오버로드 — legacy 호환. */ @JvmName("estimateBladderVolumeInt") fun estimateBladderVolume( @@ -563,7 +667,11 @@ fun estimateBladderVolume( lrRatio: Double = PiezoHW.defaultLrRatio, applyAngleCorrection: Boolean = true, edgeCh: Int = walls.size - 1, - edgeRefChannels: List = listOf(walls.size - 2, walls.size - 3) + edgeRefChannels: List = listOf(walls.size - 2, walls.size - 3), + /** Python `b_si_floor_ratio` (bv_estimation.py:1316). null 이면 미적용. */ + bSiFloorRatio: Double? = null, + /** Python `b_si_floor_edge_min`. edge 채널이 이 비율 이상 넓어야 floor 발동. */ + bSiFloorEdgeMin: Double = 0.5, ): BVResult? { // 1) Edge channel filter — cap 방식 결정용 플래그 @@ -739,6 +847,15 @@ fun estimateBladderVolume( bSi = aAp * 1.3 y0 = (yS[0] + yS[n - 1]) / 2.0 } + // Python `b_si_floor_ratio` (bv_estimation.py:1316-1321): + // 하단 edge 채널이 넓으면 (자유 fit b_si 붕괴 시) c_ap 대비 하한 부여. + // CLAMP_CAP_TO_ELLIPSE 가 collapsed b_si 로 하단 cap 을 죽이는 것을 방지. + if (bSiFloorRatio != null) { + val maxACap = aCapS.max() + val edgeWide = aCapS[0] / maxOf(maxACap, 1e-9) >= bSiFloorEdgeMin + val floor = minOf(bSiFloorRatio * aAp, aAp * 1.3) + if (edgeWide && bSi < floor) bSi = floor + } y0Ellipse = y0 bSiEllipse = bSi hCapBot = max(0.0, yS[0] - (y0 - bSi)) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt index 1ac872a..1f3f951 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalLiveView.kt @@ -135,10 +135,14 @@ fun ClinicalLiveView(appState: AppState) { } val mdResults = com.medithings.vesiscan.walldetect.MethodDRunner .detectMultichannel(signalsD, applyCross = true) - val mdWalls: List?> = mdResults.map { r -> - r?.let { Pair(it.antRefined, it.postRefined) } + // Python `runners.estimate_bv` 1:1 대응 (inset + adaptive_large_bladder_relax). + val mdWalls = mdResults.map { r -> + r?.let { + com.medithings.vesiscan.managers.WallWithSpan( + it.antRefined, it.postRefined, it.lowStart, it.lowEnd) + } } - bvMethodD = com.medithings.vesiscan.managers.estimateBladderVolume6ch(mdWalls) + bvMethodD = com.medithings.vesiscan.managers.estimateBv(mdWalls) ?.volumeMl?.takeIf { it.isFinite() && it > 0 } } } diff --git a/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt index 236970d..b6869c5 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/CenterAlignerValidationTest.kt @@ -98,20 +98,22 @@ class CenterAlignerValidationTest { assert(recs[3]?.ch3Hit == 10) { "cm=3 ch3_hit expected 10, got ${recs[3]?.ch3Hit}" } // bv_cv 허용 오차 : - // Halir-Flusser fit + ellipse_cap_height branch 이식 후, single-trace BV 는 - // Python 대비 Δ 3% (12 mL / 421 mL) 로 매우 근접. 하지만 per-trace BV variance 는 - // numerical noise 때문에 Kotlin 이 Python 대비 다르게 나올 수 있음 → bv_cv 는 최대 - // 0.10 편차 허용. **Rule A 최종 선택 (cm=3) 은 여전히 일치** 하므로 실용상 무해. - // 완전 numerical parity 는 numpy vs Kotlin 의 여러 세부 (Cardano 부호 처리 등) 정합 - // 추가 조사 필요 — 후속 이슈로 남김. - assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.10) { - "cm=1 bv_cv expected 0.260±0.10, got ${recs[1]?.bvCv}" + // Adaptive_large_bladder_relax + Halir-Flusser + ellipse_cap_height + lumen_inset + // 4-단계 이식 완료 → single-trace BV Python 과 완전 bit-perfect 일치 + // (data123 cm=1 trace 0 : py=421.028293482 == kt=421.028293482, Δ=0.000 mL). + // cm=0, cm=1 은 bv_cv 도 Python 완전 일치 (0.183, 0.260). + // cm=3 만 0.048 vs 0.130 — 특정 trace 의 adaptive_large_bladder_relax 조건 + // 판정에서 Kotlin/Python 이 미묘히 다른 branch 를 타는 것으로 추정 (multi-trace + // aggregation edge case). 최종 선택 (cm=3) 은 여전히 일치. + assert(recs[1]?.bvCv != null && kotlin.math.abs((recs[1]!!.bvCv!!) - 0.260) < 0.02) { + "cm=1 bv_cv expected 0.260±0.02, got ${recs[1]?.bvCv}" } + assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.02) { + "cm=0 bv_cv expected 0.183±0.02, got ${recs[0]?.bvCv}" + } + // cm=3 tolerance 완화 — adaptive relax multi-trace edge case (후속 조사) assert(recs[3]?.bvCv != null && kotlin.math.abs((recs[3]!!.bvCv!!) - 0.130) < 0.10) { - "cm=3 bv_cv expected 0.130±0.10, got ${recs[3]?.bvCv}" - } - assert(recs[0]?.bvCv != null && kotlin.math.abs((recs[0]!!.bvCv!!) - 0.183) < 0.15) { - "cm=0 bv_cv expected 0.183±0.15 (BV 발산 case), got ${recs[0]?.bvCv}" + "cm=3 bv_cv expected 0.130±0.10 (adaptive relax edge case), got ${recs[3]?.bvCv}" } // 선택 위치: Rule A → cm=3 (80% 임계 통과한 유일 위치) diff --git a/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt index 8850099..ea87a4f 100644 --- a/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/managers/PrecisionDumpTest.kt @@ -56,6 +56,7 @@ class PrecisionDumpTest { @Test fun `dump pipeline intermediates for cm=1 trace 0`() { PiezoHW.activePreset = PiezoHW.DevicePreset.V1 + EllipseFitSpecific.debugForceEigenvector = null val trace = loadFirstTrace(session, 10) val params = MethodDParams.DEFAULT val nCh = trace.size @@ -111,11 +112,11 @@ class PrecisionDumpTest { r?.let { listOf(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } } - // BV — Python `extract_walls` 는 refined ant/post 우선 사용 → subsample 정밀도 유지 - val allWalls: List?> = wallsCross.map { r -> - r?.let { Pair(it.antRefined, it.postRefined) } + // BV — Python `runners.estimate_bv` 1:1 (inset + adaptive_large_bladder_relax) + val walls: List = wallsCross.map { r -> + r?.let { WallWithSpan(it.antRefined, it.postRefined, it.lowStart, it.lowEnd) } } - val bv = estimateBladderVolume6ch(allWalls) + val bv = estimateBv(walls) out["bv"] = bv?.let { val m = LinkedHashMap() m["volume_ml"] = it.volumeMl @@ -151,6 +152,15 @@ class PrecisionDumpTest { m } + // DEBUG: Halir 상류 dump + out["halir_debug"] = mapOf( + "ym" to EllipseFitSpecific.debugYm, + "ys" to EllipseFitSpecific.debugYsS, + "zm" to EllipseFitSpecific.debugZm, + "zs" to EllipseFitSpecific.debugZsS, + "T" to EllipseFitSpecific.debugT?.map { it.toList() }, + "a2" to EllipseFitSpecific.debugA2?.toList(), + ) File(outPath).writeText(Gson().toJson(out)) println("saved: $outPath") println(" bv.volume_ml = ${bv?.volumeMl?.let { "%.4f".format(it) }}")