Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand All @@ -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) ----------
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down