From 6aaa4129857445faf1c58a5a08032908a20d10b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Pater?= Date: Thu, 3 Sep 2026 07:17:00 +0200 Subject: [PATCH] Xiaomi S400: use 50 kHz for estimation equations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S400 is dual-frequency (50 / 250 kHz). Sun 2003 (TBW) and Janssen 2000 (SMM) were both derived on 50 kHz single-frequency BIA, but S400BodyComposition.compute() fed them the high-frequency band — the smaller of the two magnitudes after the Cole-Cole sort. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PzAUUMVb6dWAshRJ9U6RY5 --- .../bluetooth/libs/S400BodyComposition.kt | 26 +++--- .../bluetooth/libs/S400BodyCompositionTest.kt | 87 +++++++++++-------- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400BodyComposition.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400BodyComposition.kt index 16bc57f5d..29c20a827 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400BodyComposition.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/S400BodyComposition.kt @@ -38,6 +38,10 @@ private fun cbrt(x: Double): Double = x.pow(1.0 / 3.0) * `rLowRaw` (Ω, ~50 kHz). Heart rate, if present, is **not** an input to any * body-composition equation — pass through to the UI unmodified. * + * The single-frequency prediction equations (Sun 2003, Janssen 2000) were + * derived on 50 kHz BIA, so they are fed the corrected **low-frequency** band. + * The high-frequency band only informs the §2.1 Cole-Cole checks. + * * ## Validation (§1.1) — reject entire computation * `age 18-120` (Janssen/Cunningham not validated <18), `height 100-230`, * `weight 20-250`, `R_high/R_low 200-1500`, `BMI 12-60`. These are not hard @@ -54,20 +58,20 @@ private fun cbrt(x: Double): Double = x.pow(1.0 / 3.0) * - **§2.2 Foot-to-foot correction.** The S400 measures only the lower body * (foot↔foot), but every published BIA equation was derived for * wrist-to-ankle BIA. Foot-to-foot R is ~10 % lower because the path omits - * the arm segment (Organ 1994, Bracco 1996, Demura 2004). Both R values are - * multiplied by [FOOT_TO_FOOT_CORRECTION] before entering any prediction + * the arm segment (Organ 1994, Bracco 1996, Demura 2004). The low-frequency band + * is multiplied by [FOOT_TO_FOOT_CORRECTION] before entering a prediction * equation. Raw (un-corrected) values are kept for the §3.7 empirical bone * formula and §3.8 VFI, which were fit against raw foot-to-foot data and * would double-correct. * * ## Computation order (§3) — sources - * - §3.1 TBW — Sun 2003 race-combined, sex-specific + * - §3.1 TBW — Sun 2003 race-combined, sex-specific (50 kHz → corrected R_low) * - §3.2 ECW — De Lorenzo 1997 / Matthie 2005 Hanai mixture theory * - §3.3 ICW = TBW − ECW * - §3.4 FFM = TBW / 0.732 — Pace & Rathbun 1945 hydration constant * - §3.5 BF = W − FFM * - §3.6 SMM — Janssen 2000 (MRI-validated, single-frequency 50 kHz; uses - * corrected R_H even though nominally a dual-freq model) + * corrected R_low) * - §3.7 Bone — see [BoneFormula] * - §3.8 VFI — empirical anthropometric regression (no impedance input) * - §3.9 BMR — see [BmrFormula]; Mifflin-St Jeor fallback when FFM suppressed @@ -214,16 +218,16 @@ object S400BodyComposition { val rHighRawAfterSwap = rHigh // §3.7 Option A needs RAW (un-corrected) R_high. val unreliableContact = abs(rLow - rHigh) / rHigh < 0.01f - // §2.2 foot-to-foot correction (applied to both R values for the main pipeline). - val rH = rHigh * footToFootCorrection + // §2.2 foot-to-foot correction. The single-frequency equations take + // the corrected low-frequency band. val rL = rLow * footToFootCorrection - // §3.1 TBW (Sun 2003, race-combined, sex-specific). + // §3.1 TBW (Sun 2003, race-combined, sex-specific; 50 kHz). val sexM = if (inputs.sexMale) 1f else 0f val tbwRaw = if (inputs.sexMale) { - 1.20f + 0.45f * (h * h / rH) + 0.18f * w + 1.20f + 0.45f * (h * h / rL) + 0.18f * w } else { - 3.75f + 0.45f * (h * h / rH) + 0.11f * w + 3.75f + 0.45f * (h * h / rL) + 0.11f * w } val tbwOk = tbwRaw in (0.30f * w)..(0.75f * w) val tbw = if (tbwOk) tbwRaw else null @@ -251,8 +255,8 @@ object S400BodyComposition { val bfPct = if (bfPctOk) bfPctRaw else null val bfKg = if (bfPct != null) bf else null - // §3.6 SMM (Janssen 2000), uses corrected R_H. - val smmRaw = 0.401f * (h * h / rH) + 3.825f * sexM - 0.071f * inputs.age + 5.102f + // §3.6 SMM (Janssen 2000; 50 kHz → rL). + val smmRaw = 0.401f * (h * h / rL) + 3.825f * sexM - 0.071f * inputs.age + 5.102f val smm = smmRaw.coerceIn(8f, 75f) // §3.7 Bone mineral mass — two options. diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400BodyCompositionTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400BodyCompositionTest.kt index 501702a0d..ab9b92952 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400BodyCompositionTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/S400BodyCompositionTest.kt @@ -52,8 +52,8 @@ class S400BodyCompositionTest { val r = S400BodyComposition.compute(swapped) assertThat(r.labelSwapApplied).isTrue() // After swap, result must equal §7.1 Subject A. - assertThat(r.tbwKg!!).isWithin(tolKg).of(48.13f) - assertThat(r.ffmKg!!).isWithin(tolKg).of(65.75f) + assertThat(r.tbwKg!!).isWithin(tolKg).of(45.08f) + assertThat(r.ffmKg!!).isWithin(tolKg).of(61.58f) } @Test @@ -94,21 +94,21 @@ class S400BodyCompositionTest { fun subjectA_defaultOptions_matchesSpec() { val r = subjectA() assertThat(r.reliability).isAnyOf(Reliability.OK, Reliability.APPROXIMATE) - assertThat(r.tbwKg!!).isWithin(tolKg).of(48.13f) - assertThat(r.tbwPct!!).isWithin(tolPct).of(62.9f) + assertThat(r.tbwKg!!).isWithin(tolKg).of(45.08f) + assertThat(r.tbwPct!!).isWithin(tolPct).of(58.92f) assertThat(r.ecwKg!!).isWithin(tolKg).of(21.47f) - assertThat(r.ecwPct!!).isWithin(tolPct).of(28.1f) - assertThat(r.icwKg!!).isWithin(tolKg).of(26.66f) - assertThat(r.ecwTbwRatio!!).isWithin(0.005f).of(0.446f) - assertThat(r.ffmKg!!).isWithin(tolKg).of(65.75f) - assertThat(r.bfKg!!).isWithin(tolKg).of(10.75f) - assertThat(r.bfPct!!).isWithin(tolPct).of(14.1f) - assertThat(r.smmKg!!).isWithin(tolKg).of(36.56f) - assertThat(r.smmPct!!).isWithin(tolPct).of(47.8f) + assertThat(r.ecwPct!!).isWithin(tolPct).of(28.06f) + assertThat(r.icwKg!!).isWithin(tolKg).of(23.61f) + assertThat(r.ecwTbwRatio!!).isWithin(0.005f).of(0.476f) + assertThat(r.ffmKg!!).isWithin(tolKg).of(61.58f) + assertThat(r.bfKg!!).isWithin(tolKg).of(14.92f) + assertThat(r.bfPct!!).isWithin(tolPct).of(19.50f) + assertThat(r.smmKg!!).isWithin(tolKg).of(33.84f) + assertThat(r.smmPct!!).isWithin(tolPct).of(44.23f) assertThat(r.boneKg!!).isWithin(tolKg).of(2.99f) // Option A default - assertThat(r.vfi!!).isWithin(0.1f).of(13.2f) - assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1790f) // Cun91 default - assertThat(r.bcmKg!!).isWithin(tolKg).of(38.09f) + assertThat(r.vfi!!).isWithin(0.1f).of(13.24f) + assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1700f) // Cun91 default + assertThat(r.bcmKg!!).isWithin(tolKg).of(33.73f) assertThat(r.phaseAngleDeg).isNull() } @@ -121,7 +121,7 @@ class S400BodyCompositionTest { @Test fun subjectA_bmrCunningham1980_matchesSpec() { val r = subjectA(bmrFormula = BmrFormula.CUNNINGHAM_1980) - assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1946f) + assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1855f) } // ---------- §7.2 — Subject B (middle-aged female) ---------- @@ -132,18 +132,18 @@ class S400BodyCompositionTest { age = 55, sexMale = false, heightCm = 162f, weightKg = 68f, rHighRaw = 600f, rLowRaw = 690f, )) - assertThat(r.tbwKg!!).isWithin(tolKg).of(29.12f) - assertThat(r.tbwPct!!).isWithin(tolPct).of(42.8f) + assertThat(r.tbwKg!!).isWithin(tolKg).of(26.79f) + assertThat(r.tbwPct!!).isWithin(tolPct).of(39.40f) assertThat(r.ecwKg!!).isWithin(tolKg).of(12.96f) - assertThat(r.icwKg!!).isWithin(tolKg).of(16.16f) - assertThat(r.ecwTbwRatio!!).isWithin(0.005f).of(0.445f) - assertThat(r.ffmKg!!).isWithin(tolKg).of(39.79f) - assertThat(r.bfKg!!).isWithin(tolKg).of(28.21f) - assertThat(r.bfPct!!).isWithin(tolPct).of(41.5f) - assertThat(r.smmKg!!).isWithin(tolKg).of(17.14f) + assertThat(r.icwKg!!).isWithin(tolKg).of(13.83f) + assertThat(r.ecwTbwRatio!!).isWithin(0.005f).of(0.484f) + assertThat(r.ffmKg!!).isWithin(tolKg).of(36.60f) + assertThat(r.bfKg!!).isWithin(tolKg).of(31.40f) + assertThat(r.bfPct!!).isWithin(tolPct).of(46.18f) + assertThat(r.smmKg!!).isWithin(tolKg).of(15.06f) assertThat(r.boneKg!!).isWithin(tolKg).of(2.47f) - assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1229f) - assertThat(r.bcmKg!!).isWithin(tolKg).of(23.09f) + assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1161f) + assertThat(r.bcmKg!!).isWithin(tolKg).of(19.75f) } @Test @@ -163,18 +163,18 @@ class S400BodyCompositionTest { age = 70, sexMale = true, heightCm = 170f, weightKg = 80f, rHighRaw = 520f, rLowRaw = 610f, )) - assertThat(r.tbwKg!!).isWithin(tolKg).of(38.34f) - assertThat(r.tbwPct!!).isWithin(tolPct).of(47.9f) + assertThat(r.tbwKg!!).isWithin(tolKg).of(34.98f) + assertThat(r.tbwPct!!).isWithin(tolPct).of(43.73f) assertThat(r.ecwKg!!).isWithin(tolKg).of(16.24f) - assertThat(r.icwKg!!).isWithin(tolKg).of(22.09f) - assertThat(r.ecwTbwRatio!!).isWithin(0.005f).of(0.424f) - assertThat(r.ffmKg!!).isWithin(tolKg).of(52.37f) - assertThat(r.bfKg!!).isWithin(tolKg).of(27.63f) - assertThat(r.bfPct!!).isWithin(tolPct).of(34.5f) - assertThat(r.smmKg!!).isWithin(tolKg).of(24.22f) + assertThat(r.icwKg!!).isWithin(tolKg).of(18.74f) + assertThat(r.ecwTbwRatio!!).isWithin(0.005f).of(0.464f) + assertThat(r.ffmKg!!).isWithin(tolKg).of(47.79f) + assertThat(r.bfKg!!).isWithin(tolKg).of(32.21f) + assertThat(r.bfPct!!).isWithin(tolPct).of(40.26f) + assertThat(r.smmKg!!).isWithin(tolKg).of(21.23f) assertThat(r.boneKg!!).isWithin(tolKg).of(2.84f) - assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1501f) - assertThat(r.bcmKg!!).isWithin(tolKg).of(31.56f) + assertThat(r.bmrKcal!!).isWithin(tolKcal).of(1402f) + assertThat(r.bcmKg!!).isWithin(tolKg).of(26.77f) } @Test @@ -209,6 +209,21 @@ class S400BodyCompositionTest { assertThat(r.reliability).isEqualTo(Reliability.NOT_AVAILABLE) } + @Test + fun usesLowFrequencyForPrediction() { + // Previously this used rHigh, causing the TBW/FFM predictions to land out of range. + val r = S400BodyComposition.compute( + S400Inputs( + age = 45, sexMale = true, heightCm = 196f, weightKg = 112.5f, + rHighRaw = 226.0f, rLowRaw = 305.0f, + ), + ) + assertThat(r.bfPct).isNotNull() + assertThat(r.bfPct!!).isGreaterThan(3f) + assertThat(r.bfPct!!).isLessThan(40f) + assertThat(r.ffmKg!!).isLessThan(112.5f) + } + // ---------- foot-to-foot correction injection ---------- @Test