From 878e76ad0c66040c61c00737b0a6f070619a1d6d Mon Sep 17 00:00:00 2001 From: Daniel Kukula Date: Tue, 21 Jul 2026 21:08:43 +0200 Subject: [PATCH 1/4] Add selectable Scientific body-composition algorithm to Mi Scale Mi Scale v1/v2 previously derived body composition only from the reverse-engineered Mi Fit algorithm (MiScaleLib). This adds a per-scale setting letting users pick between that "Xiaomi (original app)" path and a new "Scientific" estimator. - BodyMiScaleLib: mono-frequency estimator ported from the bodymiscale Home Assistant integration (GPL-3.0). SCIENCE mode chains a hardware-calibrated LBM with peer-reviewed formulas (Siri, Pace, Wang, Schofield) and additionally reports protein and BMR. Locked to byte-for-byte parity with the reference project via a regression test. - MiScaleHandler: radio picker to choose the algorithm; new measurements use the selected path. - ScaleDeviceHandler: extract a reusable SettingRadioGroup composable (settings-backed radio group) for device configuration UIs. - CREDITS: add bodymiscale attribution. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Daniel Kukula --- CREDITS | 6 + .../core/bluetooth/libs/BodyMiScaleLib.kt | 191 ++++++++++++++++++ .../core/bluetooth/scales/MiScaleHandler.kt | 83 +++++++- .../bluetooth/scales/ScaleDeviceHandler.kt | 56 +++++ .../app/src/main/res/values/strings.xml | 6 + .../core/bluetooth/libs/BodyMiScaleLibTest.kt | 77 +++++++ 6 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt diff --git a/CREDITS b/CREDITS index 358456cca..5e6951693 100644 --- a/CREDITS +++ b/CREDITS @@ -42,6 +42,12 @@ GNU Lesser General Public License 2.1 or any later version License: https://www.gnu.org/licenses/ Website: https://github.com/PaulStoffregen/Time +Project: bodymiscale +Copyright (c) dckiller51 and contributors +GNU General Public License v3.0 +License: https://www.gnu.org/licenses/gpl-3.0 +Website: https://github.com/dckiller51/bodymiscale + Used icons are by flaticon.com (http://www.flaticon.com) Attribution 3.0 Unported (CC BY 3.0) License: http://creativecommons.org/licenses/by/3.0/ diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt new file mode 100644 index 000000000..8aac3e5ea --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt @@ -0,0 +1,191 @@ +/* + * openScale + * Copyright (C) 2026 olie.xdev + * + * Portions derived from bodymiscale (C) dckiller51 and contributors, GPL-3.0 + * (https://github.com/dckiller51/bodymiscale). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.health.openscale.core.data.GenderType + +/** + * Port of the body-composition algorithms from the Home Assistant integration + * **bodymiscale** by dckiller51 (GPL-3.0): + * https://github.com/dckiller51/bodymiscale + * custom_components/bodymiscale/metrics/{impedance,weight}.py + util.py + * + * Two mono-frequency (standard impedance) modes are supported: + * + * - [Mode.XIAOMI] : the reverse-engineered Zepp Life / Mi Fit algorithm. This is the + * same family as [MiScaleLib]; it barely reacts to impedance and is + * dominated by height/weight. Kept for parity with the Xiaomi app. + * + * - [Mode.SCIENCE] : a hardware-calibrated LBM (identical baseline to Xiaomi) combined + * with peer-reviewed downstream formulas — body fat via the Siri (1956) + * 2-compartment model, water via the Pace & Rathbun (1945) constant, + * protein via Wang (1999), and BMR via the Schofield (WHO) equation. + * + * All metrics are chained and internally consistent: fat is derived from LBM, water and + * protein from fat/LBM, and muscle from fat and bone. Compute [getLbm] first and feed its + * result into the other methods (as the callers in bodymiscale do) to reproduce its output + * exactly. + * + * The S400 dual-frequency mode of bodymiscale is intentionally not ported here — openScale + * has its own dual-frequency path in [S400BodyComposition]. + */ +class BodyMiScaleLib( + private val gender: GenderType, + private val age: Int, + private val heightCm: Float, +) { + enum class Mode { XIAOMI, SCIENCE } + + private val isMale = gender == GenderType.MALE + + /** + * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, shared by + * both modes and capped at 98% of body weight. Everything downstream depends on this. + */ + fun getLbm(weightKg: Float, impedance: Float): Float { + val lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + + weightKg * 0.32f + 12.226f - impedance * 0.0068f - age * 0.0542f + return minOf(lbm, weightKg * 0.98f) + } + + /** Body fat percentage. Pass the [getLbm] result as [lbm]. */ + fun getFat(mode: Mode, weightKg: Float, lbm: Float): Float { + val fat = when (mode) { + Mode.SCIENCE -> (weightKg - lbm) / weightKg * 100f // Siri 1956, 2-compartment + Mode.XIAOMI -> { + val adjust: Float + var coeff: Float + if (isMale) { + adjust = 0.8f + coeff = if (weightKg < 61f) 0.98f else 1.0f + } else { + adjust = if (age <= 49) 9.25f else 7.25f + coeff = 1.0f + if (weightKg > 60f) coeff = 0.96f * (if (heightCm > 160f) 1.03f else 1.0f) + else if (weightKg < 50f) coeff = 1.02f * (if (heightCm > 160f) 1.03f else 1.0f) + } + (1.0f - ((lbm - adjust) * coeff / weightKg)) * 100f + } + } + return fat.coerceIn(5f, 75f) + } + + /** + * Water percentage of body weight. SCIENCE uses the Pace & Rathbun 0.73 constant; + * XIAOMI uses the Zepp Life 0.7 factor with a low/high correction. + */ + fun getWater(mode: Mode, fatPercent: Float): Float { + return when (mode) { + Mode.SCIENCE -> ((100f - fatPercent) * 0.73f).coerceIn(35f, 73f) + Mode.XIAOMI -> { + var water = (100f - fatPercent) * 0.7f + water *= if (water <= 50f) 1.02f else 0.98f + water.coerceIn(35f, 75f) + } + } + } + + /** + * Protein percentage. SCIENCE: Wang (1999), protein ≈ 19.5% of LBM. XIAOMI: the legacy + * subtraction (muscle% − water%), matching the Zepp app. + */ + fun getProtein(mode: Mode, weightKg: Float, lbm: Float, muscleMassKg: Float, waterPercent: Float): Float { + val protein = when (mode) { + Mode.SCIENCE -> lbm * 0.195f / weightKg * 100f + Mode.XIAOMI -> muscleMassKg / weightKg * 100f - waterPercent + } + return protein.coerceIn(5f, 32f) + } + + /** Bone mass in kg — empirical formula shared by all modes, driven by [getLbm]. */ + fun getBoneMass(lbm: Float): Float { + val base = if (isMale) 0.18016894f else 0.245691014f + var bone = (base - lbm * 0.05158f) * -1f + bone = if (bone > 2.2f) bone + 0.1f else bone - 0.1f + if ((isMale && bone > 5.2f) || (!isMale && bone > 5.1f)) bone = 8.0f + return bone.coerceIn(0.5f, 8f) + } + + /** + * Total muscle mass in kg: weight − fat mass − bone mass. Matches bodymiscale's + * "muscle_mass" sensor (the "Mięśnie" / Masa mięśniowa value), not skeletal muscle. + */ + fun getMuscleMass(weightKg: Float, fatPercent: Float, boneMassKg: Float): Float { + val muscle = weightKg - (fatPercent * 0.01f * weightKg) - boneMassKg + return muscle.coerceIn(10f, 120f) + } + + /** Basal metabolic rate in kcal/day. SCIENCE: Schofield (WHO). XIAOMI: Zepp Life. */ + fun getBmr(mode: Mode, weightKg: Float): Float { + val bmr = when (mode) { + Mode.SCIENCE -> schofieldBmr(weightKg) + Mode.XIAOMI -> if (isMale) + 877.8f + weightKg * 14.916f - heightCm * 0.726f - age * 8.976f + else + 864.6f + weightKg * 10.2036f - heightCm * 0.39336f - age * 6.204f + } + return bmr.coerceIn(500f, 5000f) + } + + /** Schofield BMR by age bracket (WHO standard). */ + private fun schofieldBmr(weightKg: Float): Float { + val coeffs = if (isMale) MALE_SCHOFIELD else FEMALE_SCHOFIELD + val (slope, constant) = when { + age < 3 -> coeffs[0] + age < 10 -> coeffs[1] + age < 18 -> coeffs[2] + age < 30 -> coeffs[3] + age < 60 -> coeffs[4] + else -> coeffs[5] + } + return slope * weightKg + constant + } + + /** Visceral fat rating (Zepp Life formula, shared by all modes). */ + fun getVisceralFat(weightKg: Float): Float { + val h = heightCm + val w = weightKg + val vfal = if (isMale) { + if (h < w * 1.6f + 63.0f) + age * 0.15f + ((w * 305.0f) / ((h * 0.0826f * h - h * 0.4f) + 48.0f) - 2.9f) + else + age * 0.15f + (w * (h * -0.0015f + 0.765f) - h * 0.143f) - 5.0f + } else { + if (w <= h * 0.5f - 13.0f) + age * 0.07f + (w * (h * -0.0024f + 0.691f) - h * 0.027f) - 10.5f + else + age * 0.07f + ((w * 500.0f) / ((h * 1.45f + h * 0.1158f * h) - 120.0f) - 6.0f) + } + return vfal.coerceIn(1f, 50f) + } + + private companion object { + // Schofield (slope, constant) by bracket: 0-3, 3-10, 10-18, 18-30, 30-60, 60+ + val MALE_SCHOFIELD = arrayOf( + 59.512f to -30.4f, 22.706f to 504.3f, 17.686f to 658.2f, + 15.057f to 692.2f, 11.472f to 873.1f, 11.711f to 587.7f, + ) + val FEMALE_SCHOFIELD = arrayOf( + 58.317f to -31.1f, 20.315f to 485.9f, 13.384f to 692.6f, + 14.818f to 486.6f, 8.126f to 845.6f, 9.082f to 658.5f, + ) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index bba807cc7..80b6928dd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -17,9 +17,11 @@ */ package com.health.openscale.core.bluetooth.scales +import androidx.compose.runtime.Composable import com.health.openscale.R import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.libs.BodyMiScaleLib import com.health.openscale.core.bluetooth.libs.MiScaleLib import com.health.openscale.core.data.GenderType import com.health.openscale.core.service.ScannedDeviceInfo @@ -81,6 +83,36 @@ class MiScaleHandler : ScaleDeviceHandler() { // Timers private var historyFallbackJob: Job? = null + // ----- Body-composition algorithm selection (per-scale setting) ----- + + private val SETTINGS_KEY_ALGORITHM = "body_comp_algorithm" + + /** + * Which library derives body composition from this scale's mono-frequency impedance. + * [XIAOMI] is openScale's reverse-engineered Mi Fit port ([MiScaleLib]); [BODYMISCALE_SCIENCE] + * is the peer-reviewed estimator ported from the bodymiscale Home Assistant integration + * ([BodyMiScaleLib]). + */ + private enum class BodyCompAlgorithm { XIAOMI, BODYMISCALE_SCIENCE } + + private fun readBodyCompAlgorithm(): BodyCompAlgorithm = + runCatching { BodyCompAlgorithm.valueOf(settingsGetString(SETTINGS_KEY_ALGORITHM) ?: "") } + .getOrDefault(BodyCompAlgorithm.XIAOMI) + + @Composable + override fun DeviceConfigurationUi() { + SettingRadioGroup( + titleRes = R.string.mi_body_comp_algorithm_label, + key = SETTINGS_KEY_ALGORITHM, + options = listOf( + BodyCompAlgorithm.XIAOMI.name to R.string.mi_algorithm_xiaomi, + BodyCompAlgorithm.BODYMISCALE_SCIENCE.name to R.string.mi_algorithm_bodymiscale_science, + ), + defaultValue = BodyCompAlgorithm.XIAOMI.name, + descriptionRes = R.string.mi_algorithm_description, + ) + } + // ----- Capability & detection ----- override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { @@ -350,14 +382,10 @@ class MiScaleHandler : ScaleDeviceHandler() { if (imp > 0) { // Store the raw impedance so body composition can be recomputed later. m.impedance = imp.toDouble() - val sex = if (user.gender == GenderType.MALE) 1 else 0 - val lib = MiScaleLib(sex, user.age, user.bodyHeight) - m.water = lib.getWater(m.weight, imp.toFloat()) - m.visceralFat = lib.getVisceralFat(m.weight) - m.fat = lib.getBodyFat(m.weight, imp.toFloat()) - m.muscle = lib.getMuscle(m.weight, imp.toFloat()) - m.lbm = lib.getLBM(m.weight, imp.toFloat()) - m.bone = lib.getBoneMass(m.weight, imp.toFloat()) + when (readBodyCompAlgorithm()) { + BodyCompAlgorithm.XIAOMI -> applyXiaomiComposition(m, imp.toFloat(), user) + BodyCompAlgorithm.BODYMISCALE_SCIENCE -> applyBodyMiScaleComposition(m, imp.toFloat(), user) + } } } @@ -367,6 +395,45 @@ class MiScaleHandler : ScaleDeviceHandler() { return true } + /** openScale's reverse-engineered Mi Fit algorithm (the original-app parity path). */ + private fun applyXiaomiComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { + val sex = if (user.gender == GenderType.MALE) 1 else 0 + val lib = MiScaleLib(sex, user.age, user.bodyHeight) + m.water = lib.getWater(m.weight, impedance) + m.visceralFat = lib.getVisceralFat(m.weight) + m.fat = lib.getBodyFat(m.weight, impedance) + m.muscle = lib.getMuscle(m.weight, impedance) + m.lbm = lib.getLBM(m.weight, impedance) + m.bone = lib.getBoneMass(m.weight, impedance) + } + + /** + * Scientific estimator; see [BodyMiScaleLib] for attribution and formula sources. + * + * Fields follow the same units as [applyXiaomiComposition]: fat/water/muscle/protein are + * percentages of body weight, bone/lbm are kg, bmr is kcal/day. Muscle mass (kg) is + * converted to a percentage to match the schema and the Xiaomi path. + */ + private fun applyBodyMiScaleComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { + val lib = BodyMiScaleLib(user.gender, user.age, user.bodyHeight) + val mode = BodyMiScaleLib.Mode.SCIENCE + + val lbmKg = lib.getLbm(m.weight, impedance) + val fatPct = lib.getFat(mode, m.weight, lbmKg) + val boneKg = lib.getBoneMass(lbmKg) + val muscleKg = lib.getMuscleMass(m.weight, fatPct, boneKg) + val waterPct = lib.getWater(mode, fatPct) + + m.fat = fatPct + m.water = waterPct + m.muscle = if (m.weight > 0f) muscleKg / m.weight * 100f else 0f + m.lbm = lbmKg + m.bone = boneKg + m.protein = lib.getProtein(mode, m.weight, lbmKg, muscleKg, waterPct) + m.bmr = lib.getBmr(mode, m.weight) + m.visceralFat = lib.getVisceralFat(m.weight) + } + /** History record (10 bytes): [status][weightLE(2)][yearLE(2)][mon][day][h][m][s] */ private fun parseHistory10(d: ByteArray, user: ScaleUser): Boolean { if (d.size != 10) return false diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 7c8c755bf..6a17f6854 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -19,8 +19,12 @@ package com.health.openscale.core.bluetooth.scales import android.bluetooth.le.ScanResult import androidx.annotation.StringRes +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.fillMaxWidth +import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoGraph import androidx.compose.material.icons.filled.FitnessCenter @@ -30,12 +34,18 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.outlined.BatteryStd import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement @@ -139,6 +149,52 @@ abstract class ScaleDeviceHandler { } } + /** + * A titled radio-button group backed by a string setting. Reads the persisted value for + * [key] (falling back to [defaultValue]) and writes the selection back on each change. + * Each option is a stored value paired with its label string resource. + */ + @Composable + protected fun SettingRadioGroup( + @StringRes titleRes: Int, + key: String, + options: List>, + defaultValue: String, + @StringRes descriptionRes: Int? = null, + ) { + val persisted = settingsGetString(key) ?: defaultValue + var selected by remember(persisted) { mutableStateOf(persisted) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleSmall, + ) + options.forEach { (value, labelRes) -> + val onSelect = { + selected = value + settingsPutString(key, value) + } + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected == value, onClick = onSelect), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected == value, onClick = onSelect) + Text(stringResource(labelRes)) + } + } + if (descriptionRes != null) { + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + // --- Lifecycle entry points called by the adapter ------------------------- internal fun attachSettings(settings: DriverSettings) { diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 1fe8a5c50..7adea8155 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -394,6 +394,12 @@ Scale Configuration No additional special configuration available for this device. + + Body composition algorithm + Xiaomi (original app) + Scientific + Choose how body composition is derived from impedance. \"Xiaomi\" reproduces the original Mi Fit / Zepp Life app. \"Scientific\" uses peer-reviewed formulas (Siri, Pace, Wang, Schofield) and also reports protein and BMR. Applies to new measurements from this scale. + BLE Bind Key 32-character hex key diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt new file mode 100644 index 000000000..45360134c --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt @@ -0,0 +1,77 @@ +/* + * openScale + * Copyright (C) 2026 olie.xdev + * + * Portions derived from bodymiscale (C) dckiller51 and contributors, GPL-3.0 + * (https://github.com/dckiller51/bodymiscale). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.bluetooth.libs.BodyMiScaleLib.Mode +import com.health.openscale.core.data.GenderType +import org.junit.Test + +/** + * Unit tests for [BodyMiScaleLib]. + * + * The SCIENCE-mode regression fixture is a real measurement captured from the upstream + * bodymiscale Home Assistant integration (male, 46y, 168cm, 86.10kg, impedance 421), + * so this test locks openScale's port to byte-for-byte parity with the reference project. + */ +class BodyMiScaleLibTest { + private val EPS = 0.05f + + private val lib = BodyMiScaleLib(GenderType.MALE, age = 46, heightCm = 168f) + private val weight = 86.10f + private val impedance = 421f + + @Test + fun science_mode_matches_bodymiscale_reference() { + val lbm = lib.getLbm(weight, impedance) + val fat = lib.getFat(Mode.SCIENCE, weight, lbm) + val water = lib.getWater(Mode.SCIENCE, fat) + val bone = lib.getBoneMass(lbm) + val muscle = lib.getMuscleMass(weight, fat, bone) + val protein = lib.getProtein(Mode.SCIENCE, weight, lbm, muscle, water) + + assertThat(lbm).isWithin(EPS).of(60.0f) + assertThat(fat).isWithin(EPS).of(30.33f) + assertThat(water).isWithin(EPS).of(50.86f) + assertThat(protein).isWithin(EPS).of(13.59f) + assertThat(bone).isWithin(EPS).of(3.01f) + assertThat(muscle).isWithin(EPS).of(56.97f) + assertThat(lib.getBmr(Mode.SCIENCE, weight)).isWithin(1f).of(1861f) + assertThat(lib.getVisceralFat(weight)).isWithin(EPS).of(15.36f) + } + + @Test + fun science_fat_reacts_to_impedance_only_weakly() { + // Xiaomi-calibrated LBM barely moves with impedance; verify direction is sane. + val fatLow = lib.getFat(Mode.SCIENCE, weight, lib.getLbm(weight, 400f)) + val fatHigh = lib.getFat(Mode.SCIENCE, weight, lib.getLbm(weight, 600f)) + assertThat(fatHigh).isGreaterThan(fatLow) + } + + @Test + fun xiaomi_mode_stays_close_to_miscalelib() { + // bodymiscale XIAOMI mode should track the existing MiScaleLib within ~1pp. + val lbm = lib.getLbm(weight, impedance) + val bmXiaomi = lib.getFat(Mode.XIAOMI, weight, lbm) + val miScale = MiScaleLib(1, 46, 168f).getBodyFat(weight, impedance) + assertThat(bmXiaomi).isWithin(1.0f).of(miScale) + } +} From ad847ff8e1d0e8c20a4fd542a5bb32dd50864ec9 Mon Sep 17 00:00:00 2001 From: Daniel Kukula Date: Tue, 21 Jul 2026 21:22:13 +0200 Subject: [PATCH 2/4] Simplify BodyMiScaleLib to a scientific-only estimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mode enum threaded through every method was a vestigial artifact of the upstream bodymiscale port. openScale already selects the algorithm at the handler (MiScaleLib vs BodyMiScaleLib), so BodyMiScaleLib.Mode.XIAOMI was never used in production — only by a test comparing it to MiScaleLib. Drop Mode and its XIAOMI branches so the class is purely the scientific path. Method signatures shrink accordingly (getProtein no longer needs muscle/water, which existed only for the Xiaomi formula). Output is unchanged; the regression fixture still matches byte-for-byte. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/bluetooth/libs/BodyMiScaleLib.kt | 92 +++++-------------- .../core/bluetooth/scales/MiScaleHandler.kt | 10 +- .../core/bluetooth/libs/BodyMiScaleLibTest.kt | 32 +++---- 3 files changed, 36 insertions(+), 98 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt index 8aac3e5ea..d21dfcc01 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt @@ -23,37 +23,30 @@ package com.health.openscale.core.bluetooth.libs import com.health.openscale.core.data.GenderType /** - * Port of the body-composition algorithms from the Home Assistant integration - * **bodymiscale** by dckiller51 (GPL-3.0): + * Scientific mono-frequency (standard impedance) body-composition estimator, ported from the + * Home Assistant integration **bodymiscale** by dckiller51 (GPL-3.0): * https://github.com/dckiller51/bodymiscale * custom_components/bodymiscale/metrics/{impedance,weight}.py + util.py * - * Two mono-frequency (standard impedance) modes are supported: - * - * - [Mode.XIAOMI] : the reverse-engineered Zepp Life / Mi Fit algorithm. This is the - * same family as [MiScaleLib]; it barely reacts to impedance and is - * dominated by height/weight. Kept for parity with the Xiaomi app. - * - * - [Mode.SCIENCE] : a hardware-calibrated LBM (identical baseline to Xiaomi) combined - * with peer-reviewed downstream formulas — body fat via the Siri (1956) - * 2-compartment model, water via the Pace & Rathbun (1945) constant, - * protein via Wang (1999), and BMR via the Schofield (WHO) equation. + * A hardware-calibrated LBM (the same baseline Xiaomi uses) is combined with peer-reviewed + * downstream formulas — body fat via the Siri (1956) 2-compartment model, water via the + * Pace & Rathbun (1945) constant, protein via Wang (1999), and BMR via the Schofield (WHO) + * equation. * * All metrics are chained and internally consistent: fat is derived from LBM, water and * protein from fat/LBM, and muscle from fat and bone. Compute [getLbm] first and feed its * result into the other methods (as the callers in bodymiscale do) to reproduce its output * exactly. * - * The S400 dual-frequency mode of bodymiscale is intentionally not ported here — openScale - * has its own dual-frequency path in [S400BodyComposition]. + * The reverse-engineered Zepp Life / Mi Fit algorithm is not reproduced here — openScale + * selects [MiScaleLib] for that path. The S400 dual-frequency mode of bodymiscale is likewise + * out of scope; openScale has its own dual-frequency path in [S400BodyComposition]. */ class BodyMiScaleLib( private val gender: GenderType, private val age: Int, private val heightCm: Float, ) { - enum class Mode { XIAOMI, SCIENCE } - private val isMale = gender == GenderType.MALE /** @@ -66,54 +59,19 @@ class BodyMiScaleLib( return minOf(lbm, weightKg * 0.98f) } - /** Body fat percentage. Pass the [getLbm] result as [lbm]. */ - fun getFat(mode: Mode, weightKg: Float, lbm: Float): Float { - val fat = when (mode) { - Mode.SCIENCE -> (weightKg - lbm) / weightKg * 100f // Siri 1956, 2-compartment - Mode.XIAOMI -> { - val adjust: Float - var coeff: Float - if (isMale) { - adjust = 0.8f - coeff = if (weightKg < 61f) 0.98f else 1.0f - } else { - adjust = if (age <= 49) 9.25f else 7.25f - coeff = 1.0f - if (weightKg > 60f) coeff = 0.96f * (if (heightCm > 160f) 1.03f else 1.0f) - else if (weightKg < 50f) coeff = 1.02f * (if (heightCm > 160f) 1.03f else 1.0f) - } - (1.0f - ((lbm - adjust) * coeff / weightKg)) * 100f - } - } + /** Body fat percentage via the Siri (1956) 2-compartment model. Pass the [getLbm] result as [lbm]. */ + fun getFat(weightKg: Float, lbm: Float): Float { + val fat = (weightKg - lbm) / weightKg * 100f return fat.coerceIn(5f, 75f) } - /** - * Water percentage of body weight. SCIENCE uses the Pace & Rathbun 0.73 constant; - * XIAOMI uses the Zepp Life 0.7 factor with a low/high correction. - */ - fun getWater(mode: Mode, fatPercent: Float): Float { - return when (mode) { - Mode.SCIENCE -> ((100f - fatPercent) * 0.73f).coerceIn(35f, 73f) - Mode.XIAOMI -> { - var water = (100f - fatPercent) * 0.7f - water *= if (water <= 50f) 1.02f else 0.98f - water.coerceIn(35f, 75f) - } - } - } + /** Water percentage of body weight, via the Pace & Rathbun (1945) 0.73 constant. */ + fun getWater(fatPercent: Float): Float = + ((100f - fatPercent) * 0.73f).coerceIn(35f, 73f) - /** - * Protein percentage. SCIENCE: Wang (1999), protein ≈ 19.5% of LBM. XIAOMI: the legacy - * subtraction (muscle% − water%), matching the Zepp app. - */ - fun getProtein(mode: Mode, weightKg: Float, lbm: Float, muscleMassKg: Float, waterPercent: Float): Float { - val protein = when (mode) { - Mode.SCIENCE -> lbm * 0.195f / weightKg * 100f - Mode.XIAOMI -> muscleMassKg / weightKg * 100f - waterPercent - } - return protein.coerceIn(5f, 32f) - } + /** Protein percentage via Wang (1999): protein ≈ 19.5% of LBM. */ + fun getProtein(weightKg: Float, lbm: Float): Float = + (lbm * 0.195f / weightKg * 100f).coerceIn(5f, 32f) /** Bone mass in kg — empirical formula shared by all modes, driven by [getLbm]. */ fun getBoneMass(lbm: Float): Float { @@ -133,17 +91,9 @@ class BodyMiScaleLib( return muscle.coerceIn(10f, 120f) } - /** Basal metabolic rate in kcal/day. SCIENCE: Schofield (WHO). XIAOMI: Zepp Life. */ - fun getBmr(mode: Mode, weightKg: Float): Float { - val bmr = when (mode) { - Mode.SCIENCE -> schofieldBmr(weightKg) - Mode.XIAOMI -> if (isMale) - 877.8f + weightKg * 14.916f - heightCm * 0.726f - age * 8.976f - else - 864.6f + weightKg * 10.2036f - heightCm * 0.39336f - age * 6.204f - } - return bmr.coerceIn(500f, 5000f) - } + /** Basal metabolic rate in kcal/day via the Schofield (WHO) equation. */ + fun getBmr(weightKg: Float): Float = + schofieldBmr(weightKg).coerceIn(500f, 5000f) /** Schofield BMR by age bracket (WHO standard). */ private fun schofieldBmr(weightKg: Float): Float { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt index 80b6928dd..90ba7749a 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/MiScaleHandler.kt @@ -416,21 +416,19 @@ class MiScaleHandler : ScaleDeviceHandler() { */ private fun applyBodyMiScaleComposition(m: ScaleMeasurement, impedance: Float, user: ScaleUser) { val lib = BodyMiScaleLib(user.gender, user.age, user.bodyHeight) - val mode = BodyMiScaleLib.Mode.SCIENCE val lbmKg = lib.getLbm(m.weight, impedance) - val fatPct = lib.getFat(mode, m.weight, lbmKg) + val fatPct = lib.getFat(m.weight, lbmKg) val boneKg = lib.getBoneMass(lbmKg) val muscleKg = lib.getMuscleMass(m.weight, fatPct, boneKg) - val waterPct = lib.getWater(mode, fatPct) m.fat = fatPct - m.water = waterPct + m.water = lib.getWater(fatPct) m.muscle = if (m.weight > 0f) muscleKg / m.weight * 100f else 0f m.lbm = lbmKg m.bone = boneKg - m.protein = lib.getProtein(mode, m.weight, lbmKg, muscleKg, waterPct) - m.bmr = lib.getBmr(mode, m.weight) + m.protein = lib.getProtein(m.weight, lbmKg) + m.bmr = lib.getBmr(m.weight) m.visceralFat = lib.getVisceralFat(m.weight) } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt index 45360134c..fe5f8b4ef 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt @@ -21,16 +21,15 @@ package com.health.openscale.core.bluetooth.libs import com.google.common.truth.Truth.assertThat -import com.health.openscale.core.bluetooth.libs.BodyMiScaleLib.Mode import com.health.openscale.core.data.GenderType import org.junit.Test /** * Unit tests for [BodyMiScaleLib]. * - * The SCIENCE-mode regression fixture is a real measurement captured from the upstream - * bodymiscale Home Assistant integration (male, 46y, 168cm, 86.10kg, impedance 421), - * so this test locks openScale's port to byte-for-byte parity with the reference project. + * The regression fixture is a real measurement captured from the upstream bodymiscale + * Home Assistant integration (male, 46y, 168cm, 86.10kg, impedance 421), so this test + * locks openScale's port to byte-for-byte parity with the reference project. */ class BodyMiScaleLibTest { private val EPS = 0.05f @@ -40,13 +39,13 @@ class BodyMiScaleLibTest { private val impedance = 421f @Test - fun science_mode_matches_bodymiscale_reference() { + fun matches_bodymiscale_reference() { val lbm = lib.getLbm(weight, impedance) - val fat = lib.getFat(Mode.SCIENCE, weight, lbm) - val water = lib.getWater(Mode.SCIENCE, fat) + val fat = lib.getFat(weight, lbm) + val water = lib.getWater(fat) val bone = lib.getBoneMass(lbm) val muscle = lib.getMuscleMass(weight, fat, bone) - val protein = lib.getProtein(Mode.SCIENCE, weight, lbm, muscle, water) + val protein = lib.getProtein(weight, lbm) assertThat(lbm).isWithin(EPS).of(60.0f) assertThat(fat).isWithin(EPS).of(30.33f) @@ -54,24 +53,15 @@ class BodyMiScaleLibTest { assertThat(protein).isWithin(EPS).of(13.59f) assertThat(bone).isWithin(EPS).of(3.01f) assertThat(muscle).isWithin(EPS).of(56.97f) - assertThat(lib.getBmr(Mode.SCIENCE, weight)).isWithin(1f).of(1861f) + assertThat(lib.getBmr(weight)).isWithin(1f).of(1861f) assertThat(lib.getVisceralFat(weight)).isWithin(EPS).of(15.36f) } @Test - fun science_fat_reacts_to_impedance_only_weakly() { + fun fat_reacts_to_impedance_only_weakly() { // Xiaomi-calibrated LBM barely moves with impedance; verify direction is sane. - val fatLow = lib.getFat(Mode.SCIENCE, weight, lib.getLbm(weight, 400f)) - val fatHigh = lib.getFat(Mode.SCIENCE, weight, lib.getLbm(weight, 600f)) + val fatLow = lib.getFat(weight, lib.getLbm(weight, 400f)) + val fatHigh = lib.getFat(weight, lib.getLbm(weight, 600f)) assertThat(fatHigh).isGreaterThan(fatLow) } - - @Test - fun xiaomi_mode_stays_close_to_miscalelib() { - // bodymiscale XIAOMI mode should track the existing MiScaleLib within ~1pp. - val lbm = lib.getLbm(weight, impedance) - val bmXiaomi = lib.getFat(Mode.XIAOMI, weight, lbm) - val miScale = MiScaleLib(1, 46, 168f).getBodyFat(weight, impedance) - assertThat(bmXiaomi).isWithin(1.0f).of(miScale) - } } From a33ea22579a359d6ac50a3f555190f710e05c971 Mon Sep 17 00:00:00 2001 From: OliE Date: Fri, 24 Jul 2026 13:22:56 +0200 Subject: [PATCH 3/4] Update CREDITS --- CREDITS | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CREDITS b/CREDITS index 5e6951693..358456cca 100644 --- a/CREDITS +++ b/CREDITS @@ -42,12 +42,6 @@ GNU Lesser General Public License 2.1 or any later version License: https://www.gnu.org/licenses/ Website: https://github.com/PaulStoffregen/Time -Project: bodymiscale -Copyright (c) dckiller51 and contributors -GNU General Public License v3.0 -License: https://www.gnu.org/licenses/gpl-3.0 -Website: https://github.com/dckiller51/bodymiscale - Used icons are by flaticon.com (http://www.flaticon.com) Attribution 3.0 Unported (CC BY 3.0) License: http://creativecommons.org/licenses/by/3.0/ From 4dedb5fb12a8abae6838fb110cefa35f1a52d95c Mon Sep 17 00:00:00 2001 From: Daniel Kukula Date: Tue, 25 Aug 2026 21:04:09 +0200 Subject: [PATCH 4/4] Correct female body composition estimates --- .../core/bluetooth/libs/BodyMiScaleLib.kt | 17 +++++++++++++---- .../core/bluetooth/libs/BodyMiScaleLibTest.kt | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt index d21dfcc01..35c1742ab 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLib.kt @@ -50,19 +50,26 @@ class BodyMiScaleLib( private val isMale = gender == GenderType.MALE /** - * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, shared by - * both modes and capped at 98% of body weight. Everything downstream depends on this. + * Lean / fat-free body mass in kg — the Xiaomi hardware-calibrated formula, corrected + * for female profiles because the base regression has no sex term, and capped at 98% + * of body weight. Everything downstream depends on this. */ fun getLbm(weightKg: Float, impedance: Float): Float { - val lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + + var lbm = (heightCm * 9.058f / 100f) * (heightCm / 100f) + weightKg * 0.32f + 12.226f - impedance * 0.0068f - age * 0.0542f + + // bodymiscale 2026.8.0: its hardware regression lacks a sex term and overestimates + // female LBM by about 16%, skewing every metric derived from it. + if (!isMale) lbm *= FEMALE_LBM_CORRECTION + return minOf(lbm, weightKg * 0.98f) } /** Body fat percentage via the Siri (1956) 2-compartment model. Pass the [getLbm] result as [lbm]. */ fun getFat(weightKg: Float, lbm: Float): Float { val fat = (weightKg - lbm) / weightKg * 100f - return fat.coerceIn(5f, 75f) + val minimumFat = if (isMale) 5f else 10f + return fat.coerceIn(minimumFat, 75f) } /** Water percentage of body weight, via the Pace & Rathbun (1945) 0.73 constant. */ @@ -128,6 +135,8 @@ class BodyMiScaleLib( } private companion object { + const val FEMALE_LBM_CORRECTION = 0.84f + // Schofield (slope, constant) by bracket: 0-3, 3-10, 10-18, 18-30, 30-60, 60+ val MALE_SCHOFIELD = arrayOf( 59.512f to -30.4f, 22.706f to 504.3f, 17.686f to 658.2f, diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt index fe5f8b4ef..4787cec47 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/BodyMiScaleLibTest.kt @@ -64,4 +64,23 @@ class BodyMiScaleLibTest { val fatHigh = lib.getFat(weight, lib.getLbm(weight, 600f)) assertThat(fatHigh).isGreaterThan(fatLow) } + + @Test + fun female_profile_applies_bodymiscale_lbm_correction() { + val femaleLib = BodyMiScaleLib(GenderType.FEMALE, age = 46, heightCm = 168f) + + val lbm = femaleLib.getLbm(weight, impedance) + val fat = femaleLib.getFat(weight, lbm) + + assertThat(lbm).isWithin(EPS).of(50.40f) + assertThat(fat).isWithin(EPS).of(41.46f) + } + + @Test + fun fat_uses_sex_specific_biological_floor() { + val femaleLib = BodyMiScaleLib(GenderType.FEMALE, age = 46, heightCm = 168f) + + assertThat(lib.getFat(100f, 99f)).isEqualTo(5f) + assertThat(femaleLib.getFat(100f, 99f)).isEqualTo(10f) + } }