1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-09-02 21:02:48 +02:00

Convert legacy Bluetooth scale algorithm classes to Kotlin and unit tests have been added for each of these Kotlin classes to verify the correctness of the ported algorithms against snapshot values and behavioral properties.

This commit is contained in:
oliexdev
2025-08-30 15:22:49 +02:00
parent 84161c8089
commit baaae69c66
14 changed files with 1933 additions and 621 deletions

View File

@@ -161,11 +161,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.worker)
implementation(libs.androidx.documentfile)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
@@ -206,4 +201,13 @@ dependencies {
// Blessed Kotlin
// implementation(libs.blessed.kotlin)
implementation(libs.blessed.java)
// Test dependencies
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
testImplementation(libs.junit)
testImplementation(libs.truth)
}

View File

@@ -1,196 +1,154 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 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.
* 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 <http://www.gnu.org/licenses/>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* based on https://github.com/prototux/MIBCS-reverse-engineering by prototux
*/
package com.health.openscale.core.bluetooth.libs
package com.health.openscale.core.bluetooth.libs;
class MiScaleLib(
// male = 1; female = 0
private val sex: Int,
private val age: Int,
private val height: Float // cm
) {
public class MiScaleLib {
private int sex; // male = 1; female = 0
private int age;
private float height;
public MiScaleLib(int sex, int age, float height) {
this.sex = sex;
this.age = age;
this.height = height;
private fun getLBMCoefficient(weight: Float, impedance: Float): Float {
var lbm = (height * 9.058f / 100.0f) * (height / 100.0f)
lbm += weight * 0.32f + 12.226f
lbm -= impedance * 0.0068f
lbm -= age * 0.0542f
return lbm
}
private float getLBMCoefficient(float weight, float impedance) {
float lbm = (height * 9.058f / 100.0f) * (height / 100.0f);
lbm += weight * 0.32f + 12.226f;
lbm -= impedance * 0.0068f;
lbm -= age * 0.0542f;
return lbm;
fun getBMI(weight: Float): Float {
// weight [kg], height [cm]
// BMI = kg / (m^2)
return weight / (((height * height) / 100.0f) / 100.0f)
}
public float getBMI(float weight) {
return weight / (((height * height) / 100.0f) / 100.0f);
}
public float getLBM(float weight, float impedance) {
float leanBodyMass = weight - ((getBodyFat(weight, impedance) * 0.01f) * weight) - getBoneMass(weight, impedance);
fun getLBM(weight: Float, impedance: Float): Float {
var leanBodyMass =
weight - ((getBodyFat(weight, impedance) * 0.01f) * weight) - getBoneMass(weight, impedance)
if (sex == 0 && leanBodyMass >= 84.0f) {
leanBodyMass = 120.0f;
}
else if (sex == 1 && leanBodyMass >= 93.5f) {
leanBodyMass = 120.0f;
leanBodyMass = 120.0f
} else if (sex == 1 && leanBodyMass >= 93.5f) {
leanBodyMass = 120.0f
}
return leanBodyMass;
return leanBodyMass
}
/**
* Skeletal Muscle Mass (kg) using Janssen et al. BIA equation.
* Skeletal Muscle Mass (%) derived from Janssen et al. BIA equation (kg) -> percent of body weight.
* If impedance is non-positive, falls back to LBM * ratio.
*/
public float getMuscle(float weight, float impedance) {
if (weight <= 0f) return 0f;
fun getMuscle(weight: Float, impedance: Float): Float {
if (weight <= 0f) return 0f
float smm;
if (impedance > 0f) {
// Janssen et al. BIA equation for Skeletal Muscle Mass (kg)
float h2_over_r = (height * height) / impedance;
smm = 0.401f * h2_over_r + 3.825f * sex - 0.071f * age + 5.102f;
val smmKg: Float = if (impedance > 0f) {
// Janssen et al.: SMM(kg) = 0.401*(H^2/R) + 3.825*sex - 0.071*age + 5.102
val h2OverR = (height * height) / impedance
0.401f * h2OverR + 3.825f * sex - 0.071f * age + 5.102f
} else {
// Fallback: approximate as fraction of LBM
float lbm = getLBM(weight, impedance);
float ratio = (sex == 1) ? 0.52f : 0.46f;
smm = lbm * ratio;
val lbm = getLBM(weight, impedance)
val ratio = if (sex == 1) 0.52f else 0.46f
lbm * ratio
}
float percent = (smm / weight) * 100f;
return clamp(percent, 10f, 60f); // clamp to plausible %
val percent = (smmKg / weight) * 100f
return percent.coerceIn(10f, 60f)
}
public float getWater(float weight, float impedance) {
float coeff;
float water = (100.0f - getBodyFat(weight, impedance)) * 0.7f;
if (water < 50) {
coeff = 1.02f;
} else {
coeff = 0.98f;
}
return coeff * water;
fun getWater(weight: Float, impedance: Float): Float {
val water = (100.0f - getBodyFat(weight, impedance)) * 0.7f
val coeff = if (water < 50f) 1.02f else 0.98f
return coeff * water
}
public float getBoneMass(float weight, float impedance) {
float boneMass;
float base;
fun getBoneMass(weight: Float, impedance: Float): Float {
val base = if (sex == 0) 0.245691014f else 0.18016894f
var boneMass = (base - (getLBMCoefficient(weight, impedance) * 0.05158f)) * -1.0f
if (sex == 0) {
base = 0.245691014f;
}
else {
base = 0.18016894f;
}
boneMass = (base - (getLBMCoefficient(weight, impedance) * 0.05158f)) * -1.0f;
if (boneMass > 2.2f) {
boneMass += 0.1f;
}
else {
boneMass -= 0.1f;
}
boneMass = if (boneMass > 2.2f) boneMass + 0.1f else boneMass - 0.1f
if (sex == 0 && boneMass > 5.1f) {
boneMass = 8.0f;
}
else if (sex == 1 && boneMass > 5.2f) {
boneMass = 8.0f;
boneMass = 8.0f
} else if (sex == 1 && boneMass > 5.2f) {
boneMass = 8.0f
}
return boneMass;
return boneMass
}
public float getVisceralFat(float weight) {
float visceralFat = 0.0f;
fun getVisceralFat(weight: Float): Float {
var visceralFat = 0.0f
if (sex == 0) {
if (weight > (13.0f - (height * 0.5f)) * -1.0f) {
float subsubcalc = ((height * 1.45f) + (height * 0.1158f) * height) - 120.0f;
float subcalc = weight * 500.0f / subsubcalc;
visceralFat = (subcalc - 6.0f) + (age * 0.07f);
val subsubcalc = ((height * 1.45f) + (height * 0.1158f) * height) - 120.0f
val subcalc = weight * 500.0f / subsubcalc
visceralFat = (subcalc - 6.0f) + (age * 0.07f)
} else {
val subcalc = 0.691f + (height * -0.0024f) + (height * -0.0024f)
visceralFat = (((height * 0.027f) - (subcalc * weight)) * -1.0f) + (age * 0.07f) - age
}
else {
float subcalc = 0.691f + (height * -0.0024f) + (height * -0.0024f);
visceralFat = (((height * 0.027f) - (subcalc * weight)) * -1.0f) + (age * 0.07f) - age;
}
}
else {
} else {
if (height < weight * 1.6f) {
float subcalc = ((height * 0.4f) - (height * (height * 0.0826f))) * -1.0f;
visceralFat = ((weight * 305.0f) / (subcalc + 48.0f)) - 2.9f + (age * 0.15f);
}
else {
float subcalc = 0.765f + height * -0.0015f;
visceralFat = (((height * 0.143f) - (weight * subcalc)) * -1.0f) + (age * 0.15f) - 5.0f;
val subcalc = ((height * 0.4f) - (height * (height * 0.0826f))) * -1.0f
visceralFat = ((weight * 305.0f) / (subcalc + 48.0f)) - 2.9f + (age * 0.15f)
} else {
val subcalc = 0.765f + height * -0.0015f
visceralFat = (((height * 0.143f) - (weight * subcalc)) * -1.0f) + (age * 0.15f) - 5.0f
}
}
return visceralFat;
return visceralFat
}
public float getBodyFat(float weight, float impedance) {
float bodyFat = 0.0f;
float lbmSub = 0.8f;
fun getBodyFat(weight: Float, impedance: Float): Float {
var lbmSub = 0.8f
if (sex == 0 && age <= 49) {
lbmSub = 9.25f;
lbmSub = 9.25f
} else if (sex == 0 && age > 49) {
lbmSub = 7.25f;
lbmSub = 7.25f
}
float lbmCoeff = getLBMCoefficient(weight, impedance);
float coeff = 1.0f;
val lbmCoeff = getLBMCoefficient(weight, impedance)
var coeff = 1.0f
if (sex == 1 && weight < 61.0f) {
coeff = 0.98f;
}
else if (sex == 0 && weight > 60.0f) {
coeff = 0.96f;
coeff = 0.98f
} else if (sex == 0 && weight > 60.0f) {
coeff = 0.96f
if (height > 160.0f) {
coeff *= 1.03f;
coeff *= 1.03f
}
} else if (sex == 0 && weight < 50.0f) {
coeff = 1.02f;
coeff = 1.02f
if (height > 160.0f) {
coeff *= 1.03f;
coeff *= 1.03f
}
}
bodyFat = (1.0f - (((lbmCoeff - lbmSub) * coeff) / weight)) * 100.0f;
var bodyFat = (1.0f - (((lbmCoeff - lbmSub) * coeff) / weight)) * 100.0f
if (bodyFat > 63.0f) {
bodyFat = 75.0f;
bodyFat = 75.0f
}
return bodyFat;
}
private static float clamp(float v, float lo, float hi) {
return Math.max(lo, Math.min(hi, v));
return bodyFat
}
}

View File

@@ -1,253 +1,246 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 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.
* 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 <http://www.gnu.org/licenses/>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
package com.health.openscale.core.bluetooth.libs;
public class OneByoneLib {
private int sex; // male = 1; female = 0
private int age;
private float height;
private int peopleType; // low activity = 0; medium activity = 1; high activity = 2
public OneByoneLib(int sex, int age, float height, int peopleType) {
this.sex = sex;
this.age = age;
this.height = height;
this.peopleType = peopleType;
class OneByoneLib(// male = 1; female = 0
private val sex: Int,
private val age: Int,
private val height: Float, // low activity = 0; medium activity = 1; high activity = 2
private val peopleType: Int
) {
fun getBMI(weight: Float): Float {
return weight / (((height * height) / 100.0f) / 100.0f)
}
public float getBMI(float weight) {
return weight / (((height * height) / 100.0f) / 100.0f);
fun getLBM(weight: Float, bodyFat: Float): Float {
return weight - (bodyFat / 100.0f * weight)
}
public float getLBM(float weight, float bodyFat) {
return weight - (bodyFat / 100.0f * weight);
fun getMuscle(weight: Float, impedanceValue: Float): Float {
return ((height * height / impedanceValue * 0.401) + (sex * 3.825) - (age * 0.071) + 5.102).toFloat() / weight * 100.0f
}
public float getMuscle(float weight, float impedanceValue){
return (float)((height * height / impedanceValue * 0.401) + (sex * 3.825) - (age * 0.071) + 5.102) / weight * 100.0f;
}
public float getWater(float bodyFat) {
float coeff;
float water = (100.0f - bodyFat) * 0.7f;
fun getWater(bodyFat: Float): Float {
val coeff: Float
val water = (100.0f - bodyFat) * 0.7f
if (water < 50) {
coeff = 1.02f;
coeff = 1.02f
} else {
coeff = 0.98f;
coeff = 0.98f
}
return coeff * water;
return coeff * water
}
public float getBoneMass(float weight, float impedanceValue) {
float boneMass, sexConst , peopleCoeff = 0.0f;
fun getBoneMass(weight: Float, impedanceValue: Float): Float {
var boneMass: Float
val sexConst: Float
var peopleCoeff = 0.0f
switch (peopleType) {
case 0:
peopleCoeff = 1.0f;
break;
case 1:
peopleCoeff = 1.0427f;
break;
case 2:
peopleCoeff = 1.0958f;
break;
when (peopleType) {
0 -> peopleCoeff = 1.0f
1 -> peopleCoeff = 1.0427f
2 -> peopleCoeff = 1.0958f
}
boneMass = (9.058f * (height / 100.0f) * (height / 100.0f) + 12.226f + (0.32f * weight)) - (0.0068f * impedanceValue);
boneMass =
(9.058f * (height / 100.0f) * (height / 100.0f) + 12.226f + (0.32f * weight)) - (0.0068f * impedanceValue)
if (sex == 1) { // male
sexConst = 3.49305f;
sexConst = 3.49305f
} else {
sexConst = 4.76325f;
sexConst = 4.76325f
}
boneMass = boneMass - sexConst - (age * 0.0542f) * peopleCoeff;
boneMass = boneMass - sexConst - (age * 0.0542f) * peopleCoeff
if (boneMass <= 2.2f) {
boneMass = boneMass - 0.1f;
boneMass = boneMass - 0.1f
} else {
boneMass = boneMass + 0.1f;
boneMass = boneMass + 0.1f
}
boneMass = boneMass * 0.05158f;
boneMass = boneMass * 0.05158f
if (0.5f > boneMass) {
return 0.5f;
return 0.5f
} else if (boneMass > 8.0f) {
return 8.0f;
return 8.0f
}
return boneMass;
return boneMass
}
public float getVisceralFat(float weight) {
float visceralFat;
fun getVisceralFat(weight: Float): Float {
val visceralFat: Float
if (sex == 1) {
if (height < ((1.6f * weight) + 63.0f)) {
visceralFat = (((weight * 305.0f) / (0.0826f * height * height - (0.4f * height) + 48.0f)) - 2.9f) + ((float)age * 0.15f);
visceralFat =
(((weight * 305.0f) / (0.0826f * height * height - (0.4f * height) + 48.0f)) - 2.9f) + (age.toFloat() * 0.15f)
if (peopleType == 0) {
return visceralFat;
return visceralFat
} else {
return subVisceralFat_A(visceralFat);
return subVisceralFat_A(visceralFat)
}
} else {
visceralFat = (((float)age * 0.15f) + ((weight * (-0.0015f * height + 0.765f)) - height * 0.143f)) - 5.0f;
visceralFat =
((age.toFloat() * 0.15f) + ((weight * (-0.0015f * height + 0.765f)) - height * 0.143f)) - 5.0f
if (peopleType == 0) {
return visceralFat;
return visceralFat
} else {
return subVisceralFat_A(visceralFat);
return subVisceralFat_A(visceralFat)
}
}
} else {
if (((0.5f * height) - 13.0f) > weight) {
visceralFat = (((float)age * 0.07f) + ((weight * (-0.0024f * height + 0.691f)) - (height * 0.027f))) - 10.5f;
visceralFat =
((age.toFloat() * 0.07f) + ((weight * (-0.0024f * height + 0.691f)) - (height * 0.027f))) - 10.5f
if (peopleType != 0) {
return subVisceralFat_A(visceralFat);
return subVisceralFat_A(visceralFat)
} else {
return visceralFat;
return visceralFat
}
} else {
visceralFat = (weight * 500.0f) / (((1.45f * height) + 0.1158f * height * height) - 120.0f) - 6.0f + ((float)age * 0.07f);
visceralFat =
(weight * 500.0f) / (((1.45f * height) + 0.1158f * height * height) - 120.0f) - 6.0f + (age.toFloat() * 0.07f)
if (peopleType == 0) {
return visceralFat;
return visceralFat
} else {
return subVisceralFat_A(visceralFat);
return subVisceralFat_A(visceralFat)
}
}
}
}
private float subVisceralFat_A(float visceralFat) {
private fun subVisceralFat_A(visceralFat: Float): Float {
var visceralFat = visceralFat
if (peopleType != 0) {
if (10.0f <= visceralFat) {
return subVisceralFat_B(visceralFat);
return subVisceralFat_B(visceralFat)
} else {
visceralFat = visceralFat - 4.0f;
return visceralFat;
visceralFat = visceralFat - 4.0f
return visceralFat
}
} else {
if (10.0f > visceralFat) {
visceralFat = visceralFat - 2.0f;
return visceralFat;
visceralFat = visceralFat - 2.0f
return visceralFat
} else {
return subVisceralFat_B(visceralFat);
return subVisceralFat_B(visceralFat)
}
}
}
private float subVisceralFat_B(float visceralFat) {
private fun subVisceralFat_B(visceralFat: Float): Float {
var visceralFat = visceralFat
if (visceralFat < 10.0f) {
visceralFat = visceralFat * 0.85f;
return visceralFat;
visceralFat = visceralFat * 0.85f
return visceralFat
} else {
if (20.0f < visceralFat) {
visceralFat = visceralFat * 0.85f;
return visceralFat;
visceralFat = visceralFat * 0.85f
return visceralFat
} else {
visceralFat = visceralFat * 0.8f;
return visceralFat;
visceralFat = visceralFat * 0.8f
return visceralFat
}
}
}
public float getBodyFat(float weight, float impedanceValue) {
float bodyFatConst=0;
fun getBodyFat(weight: Float, impedanceValue: Float): Float {
var bodyFatConst = 0f
if (impedanceValue >= 1200.0f) bodyFatConst = 8.16f;
else if (impedanceValue >= 200.0f) bodyFatConst = 0.0068f * impedanceValue;
else if (impedanceValue >= 50.0f) bodyFatConst = 1.36f;
if (impedanceValue >= 1200.0f) bodyFatConst = 8.16f
else if (impedanceValue >= 200.0f) bodyFatConst = 0.0068f * impedanceValue
else if (impedanceValue >= 50.0f) bodyFatConst = 1.36f
float peopleTypeCoeff, bodyVar, bodyFat;
val peopleTypeCoeff: Float
var bodyVar: Float
val bodyFat: Float
if (peopleType == 0) {
peopleTypeCoeff = 1.0f;
peopleTypeCoeff = 1.0f
} else {
if (peopleType == 1) {
peopleTypeCoeff = 1.0427f;
peopleTypeCoeff = 1.0427f
} else {
peopleTypeCoeff = 1.0958f;
peopleTypeCoeff = 1.0958f
}
}
bodyVar = (9.058f * height) / 100.0f;
bodyVar = bodyVar * height;
bodyVar = bodyVar / 100.0f + 12.226f;
bodyVar = bodyVar + 0.32f * weight;
bodyVar = bodyVar - bodyFatConst;
bodyVar = (9.058f * height) / 100.0f
bodyVar = bodyVar * height
bodyVar = bodyVar / 100.0f + 12.226f
bodyVar = bodyVar + 0.32f * weight
bodyVar = bodyVar - bodyFatConst
if (age > 0x31) {
bodyFatConst = 7.25f;
bodyFatConst = 7.25f
if (sex == 1) {
bodyFatConst = 0.8f;
bodyFatConst = 0.8f
}
} else {
bodyFatConst = 9.25f;
bodyFatConst = 9.25f
if (sex == 1) {
bodyFatConst = 0.8f;
bodyFatConst = 0.8f
}
}
bodyVar = bodyVar - bodyFatConst;
bodyVar = bodyVar - (age * 0.0542f);
bodyVar = bodyVar * peopleTypeCoeff;
bodyVar = bodyVar - bodyFatConst
bodyVar = bodyVar - (age * 0.0542f)
bodyVar = bodyVar * peopleTypeCoeff
if (sex != 0) {
if (61.0f > weight) {
bodyVar *= 0.98f;
bodyVar *= 0.98f
}
} else {
if (50.0f > weight) {
bodyVar *= 1.02f;
bodyVar *= 1.02f
}
if (weight > 60.0f) {
bodyVar *= 0.96f;
bodyVar *= 0.96f
}
if (height > 160.0f) {
bodyVar *= 1.03f;
bodyVar *= 1.03f
}
}
bodyVar = bodyVar / weight;
bodyFat = 100.0f * (1.0f - bodyVar);
bodyVar = bodyVar / weight
bodyFat = 100.0f * (1.0f - bodyVar)
if (1.0f > bodyFat) {
return 1.0f;
return 1.0f
} else {
if (bodyFat > 45.0f) {
return 45.0f;
return 45.0f
} else {
return bodyFat;
return bodyFat
}
}
}

View File

@@ -1,201 +1,207 @@
package com.health.openscale.core.bluetooth.libs;
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
// This class is similar to OneByoneLib, but the way measures are computer are slightly different
public class OneByoneNewLib {
private int sex;
private int age;
private float height;
private int peopleType; // low activity = 0; medium activity = 1; high activity = 2
public OneByoneNewLib(int sex, int age, float height, int peopleType) {
this.sex = sex;
this.age = age;
this.height = height;
this.peopleType = peopleType;
class OneByoneNewLib(
private val sex: Int,
private val age: Int,
private val height: Float, // low activity = 0; medium activity = 1; high activity = 2
private val peopleType: Int
) {
fun getBMI(weight: Float): Float {
val bmi = weight / (((height * height) / 100.0f) / 100.0f)
return getBounded(bmi, 10f, 90f)
}
public float getBMI(float weight) {
float bmi = weight / (((height * height) / 100.0f) / 100.0f);
return getBounded(bmi, 10, 90);
}
public float getLBM(float weight, int impedance) {
float lbmCoeff = height / 100 * height / 100 * 9.058F;
lbmCoeff += 12.226;
lbmCoeff += weight * 0.32;
lbmCoeff -= impedance * 0.0068;
lbmCoeff -= age * 0.0542;
return lbmCoeff;
fun getLBM(weight: Float, impedance: Int): Float {
var lbmCoeff = height / 100 * height / 100 * 9.058f
lbmCoeff += 12.226.toFloat()
lbmCoeff += (weight * 0.32).toFloat()
lbmCoeff -= (impedance * 0.0068).toFloat()
lbmCoeff -= (age * 0.0542).toFloat()
return lbmCoeff
}
public float getBMMRCoeff(float weight){
int bmmrCoeff = 20;
if(sex == 1){
bmmrCoeff = 21;
if(age < 0xd){
bmmrCoeff = 36;
} else if(age < 0x10){
bmmrCoeff = 30;
} else if(age < 0x12){
bmmrCoeff = 26;
} else if(age < 0x1e){
bmmrCoeff = 23;
} else if (age >= 0x32){
bmmrCoeff = 20;
fun getBMMRCoeff(weight: Float): Float {
var bmmrCoeff = 20
if (sex == 1) {
bmmrCoeff = 21
if (age < 0xd) {
bmmrCoeff = 36
} else if (age < 0x10) {
bmmrCoeff = 30
} else if (age < 0x12) {
bmmrCoeff = 26
} else if (age < 0x1e) {
bmmrCoeff = 23
} else if (age >= 0x32) {
bmmrCoeff = 20
}
} else {
if(age < 0xd){
bmmrCoeff = 34;
} else if(age < 0x10){
bmmrCoeff = 29;
} else if(age < 0x12){
bmmrCoeff = 24;
} else if(age < 0x1e){
bmmrCoeff = 22;
} else if (age >= 0x32){
bmmrCoeff = 19;
if (age < 0xd) {
bmmrCoeff = 34
} else if (age < 0x10) {
bmmrCoeff = 29
} else if (age < 0x12) {
bmmrCoeff = 24
} else if (age < 0x1e) {
bmmrCoeff = 22
} else if (age >= 0x32) {
bmmrCoeff = 19
}
}
return bmmrCoeff;
return bmmrCoeff.toFloat()
}
public float getBMMR(float weight){
float bmmr;
if(sex == 1){
bmmr = (weight * 14.916F + 877.8F) - height * 0.726F;
bmmr -= age * 8.976;
fun getBMMR(weight: Float): Float {
var bmmr: Float
if (sex == 1) {
bmmr = (weight * 14.916f + 877.8f) - height * 0.726f
bmmr -= (age * 8.976).toFloat()
} else {
bmmr = (weight * 10.2036F + 864.6F) - height * 0.39336F;
bmmr -= age * 6.204;
bmmr = (weight * 10.2036f + 864.6f) - height * 0.39336f
bmmr -= (age * 6.204).toFloat()
}
return getBounded(bmmr, 500, 1000);
return getBounded(bmmr, 500f, 1000f)
}
public float getBodyFatPercentage(float weight, int impedance) {
float bodyFat = getLBM(weight, impedance);
fun getBodyFatPercentage(weight: Float, impedance: Int): Float {
var bodyFat = getLBM(weight, impedance)
float bodyFatConst;
val bodyFatConst: Float
if (sex == 0) {
if (age < 0x32) {
bodyFatConst = 9.25F;
bodyFatConst = 9.25f
} else {
bodyFatConst = 7.25F;
bodyFatConst = 7.25f
}
} else {
bodyFatConst = 0.8F;
bodyFatConst = 0.8f
}
bodyFat -= bodyFatConst;
bodyFat -= bodyFatConst
if (sex == 0){
if (weight < 50){
bodyFat *= 1.02;
} else if(weight > 60){
bodyFat *= 0.96;
if (sex == 0) {
if (weight < 50) {
bodyFat *= 1.02.toFloat()
} else if (weight > 60) {
bodyFat *= 0.96.toFloat()
}
if(height > 160){
bodyFat *= 1.03;
if (height > 160) {
bodyFat *= 1.03.toFloat()
}
} else {
if (weight < 61){
bodyFat *= 0.98;
if (weight < 61) {
bodyFat *= 0.98.toFloat()
}
}
return 100 * (1 - bodyFat / weight);
return 100 * (1 - bodyFat / weight)
}
public float getBoneMass(float weight, int impedance){
float lbmCoeff = getLBM(weight, impedance);
fun getBoneMass(weight: Float, impedance: Int): Float {
val lbmCoeff = getLBM(weight, impedance)
float boneMassConst;
if(sex == 1){
boneMassConst = 0.18016894F;
var boneMassConst: Float
if (sex == 1) {
boneMassConst = 0.18016894f
} else {
boneMassConst = 0.245691014F;
boneMassConst = 0.245691014f
}
boneMassConst = lbmCoeff * 0.05158F - boneMassConst;
float boneMass;
if(boneMassConst <= 2.2){
boneMass = boneMassConst - 0.1F;
boneMassConst = lbmCoeff * 0.05158f - boneMassConst
val boneMass: Float
if (boneMassConst <= 2.2) {
boneMass = boneMassConst - 0.1f
} else {
boneMass = boneMassConst + 0.1F;
boneMass = boneMassConst + 0.1f
}
return getBounded(boneMass, 0.5F, 8);
return getBounded(boneMass, 0.5f, 8f)
}
public float getMuscleMass(float weight, int impedance){
float muscleMass = weight - getBodyFatPercentage(weight, impedance) * 0.01F * weight;
muscleMass -= getBoneMass(weight, impedance);
return getBounded(muscleMass, 10, 120);
fun getMuscleMass(weight: Float, impedance: Int): Float {
var muscleMass = weight - getBodyFatPercentage(weight, impedance) * 0.01f * weight
muscleMass -= getBoneMass(weight, impedance)
return getBounded(muscleMass, 10f, 120f)
}
public float getSkeletonMusclePercentage(float weight, int impedance){
float skeletonMuscleMass = getWaterPercentage(weight, impedance);
skeletonMuscleMass *= weight;
skeletonMuscleMass *= 0.8422F * 0.01F;
skeletonMuscleMass -= 2.9903;
skeletonMuscleMass /= weight;
return skeletonMuscleMass * 100;
fun getSkeletonMusclePercentage(weight: Float, impedance: Int): Float {
var skeletonMuscleMass = getWaterPercentage(weight, impedance)
skeletonMuscleMass *= weight
skeletonMuscleMass *= 0.8422f * 0.01f
skeletonMuscleMass -= 2.9903.toFloat()
skeletonMuscleMass /= weight
return skeletonMuscleMass * 100
}
public float getVisceralFat(float weight){
float visceralFat;
fun getVisceralFat(weight: Float): Float {
val visceralFat: Float
if (sex == 1) {
if (height < weight * 1.6 + 63.0) {
visceralFat =
age * 0.15F + ((weight * 305.0F) /((height * 0.0826F * height - height * 0.4F) + 48.0F) - 2.9F);
age * 0.15f + ((weight * 305.0f) / ((height * 0.0826f * height - height * 0.4f) + 48.0f) - 2.9f)
} else {
visceralFat =
age * 0.15f + (weight * (height * -0.0015f + 0.765f) - height * 0.143f) - 5.0f
}
else {
visceralFat = age * 0.15F + (weight * (height * -0.0015F + 0.765F) - height * 0.143F) - 5.0F;
}
}
else {
if (weight <= height * 0.5 - 13.0) {
visceralFat = age * 0.07F + (weight * (height * -0.0024F + 0.691F) - height * 0.027F) - 10.5F;
}
else {
visceralFat = age * 0.07F + ((weight * 500.0F) / ((height * 1.45F + height * 0.1158F * height) - 120.0F) - 6.0F);
}
}
return getBounded(visceralFat, 1, 50);
}
public float getWaterPercentage(float weight, int impedance){
float waterPercentage = (100 - getBodyFatPercentage(weight, impedance)) * 0.7F;
if (waterPercentage > 50){
waterPercentage *= 0.98;
} else {
waterPercentage *= 1.02;
if (weight <= height * 0.5 - 13.0) {
visceralFat =
age * 0.07f + (weight * (height * -0.0024f + 0.691f) - height * 0.027f) - 10.5f
} else {
visceralFat =
age * 0.07f + ((weight * 500.0f) / ((height * 1.45f + height * 0.1158f * height) - 120.0f) - 6.0f)
}
}
return getBounded(waterPercentage, 35, 75);
return getBounded(visceralFat, 1f, 50f)
}
public float getProteinPercentage(float weight, int impedance){
return (
(100.0F - getBodyFatPercentage(weight, impedance))
- getWaterPercentage(weight, impedance) * 1.08F
fun getWaterPercentage(weight: Float, impedance: Int): Float {
var waterPercentage = (100 - getBodyFatPercentage(weight, impedance)) * 0.7f
if (waterPercentage > 50) {
waterPercentage *= 0.98.toFloat()
} else {
waterPercentage *= 1.02.toFloat()
}
return getBounded(waterPercentage, 35f, 75f)
}
fun getProteinPercentage(weight: Float, impedance: Int): Float {
return (((100.0f - getBodyFatPercentage(weight, impedance))
- getWaterPercentage(weight, impedance) * 1.08f
)
- (getBoneMass(weight, impedance) / weight) * 100.0F;
- (getBoneMass(weight, impedance) / weight) * 100.0f)
}
private float getBounded(float value, float lowerBound, float upperBound){
if(value < lowerBound){
return lowerBound;
} else if (value > upperBound){
return upperBound;
private fun getBounded(value: Float, lowerBound: Float, upperBound: Float): Float {
if (value < lowerBound) {
return lowerBound
} else if (value > upperBound) {
return upperBound
}
return value;
return value
}
}

View File

@@ -1,147 +1,126 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 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.
* 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 <http://www.gnu.org/licenses/>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs;
package com.health.openscale.core.bluetooth.libs
public class SoehnleLib {
private boolean isMale; // male = 1; female = 0
private int age;
private float height;
private int activityLevel;
class SoehnleLib(// male = 1; female = 0
private val isMale: Boolean,
private val age: Int,
private val height: Float,
private val activityLevel: Int
) {
fun getFat(weight: Float, imp50: Float): Float { // in %
var activityCorrFac = 0.0f
public SoehnleLib(boolean isMale, int age, float height, int activityLevel) {
this.isMale = isMale;
this.age = age;
this.height = height;
this.activityLevel = activityLevel;
}
public float getFat(final float weight, final float imp50) { // in %
float activityCorrFac = 0.0f;
switch (activityLevel) {
case 4: {
when (activityLevel) {
4 -> {
if (isMale) {
activityCorrFac = 2.5f;
activityCorrFac = 2.5f
} else {
activityCorrFac = 2.3f
}
else {
activityCorrFac = 2.3f;
}
break;
}
case 5: {
5 -> {
if (isMale) {
activityCorrFac = 4.3f;
activityCorrFac = 4.3f
} else {
activityCorrFac = 4.1f
}
else {
activityCorrFac = 4.1f;
}
break;
}
}
float sexCorrFac;
float activitySexDiv;
val sexCorrFac: Float
val activitySexDiv: Float
if (isMale) {
sexCorrFac = 0.250f;
activitySexDiv = 65.5f;
}
else {
sexCorrFac = 0.214f;
activitySexDiv = 55.1f;
sexCorrFac = 0.250f
activitySexDiv = 65.5f
} else {
sexCorrFac = 0.214f
activitySexDiv = 55.1f
}
return 1.847f * weight * 10000.0f / (height * height) + sexCorrFac * age + 0.062f * imp50 - (activitySexDiv - activityCorrFac);
return 1.847f * weight * 10000.0f / (height * height) + sexCorrFac * age + 0.062f * imp50 - (activitySexDiv - activityCorrFac)
}
public float computeBodyMassIndex(final float weight) {
return 10000.0f * weight / (height * height);
fun computeBodyMassIndex(weight: Float): Float {
return 10000.0f * weight / (height * height)
}
public float getWater(final float weight, final float imp50) { // in %
float activityCorrFac = 0.0f;
fun getWater(weight: Float, imp50: Float): Float { // in %
var activityCorrFac = 0.0f
switch (activityLevel) {
case 1:
case 2:
case 3: {
when (activityLevel) {
1, 2, 3 -> {
if (isMale) {
activityCorrFac = 2.83f;
activityCorrFac = 2.83f
} else {
activityCorrFac = 0.0f
}
else {
activityCorrFac = 0.0f;
}
break;
}
case 4: {
4 -> {
if (isMale) {
activityCorrFac = 3.93f;
activityCorrFac = 3.93f
} else {
activityCorrFac = 0.4f
}
else {
activityCorrFac = 0.4f;
}
break;
}
case 5: {
5 -> {
if (isMale) {
activityCorrFac = 5.33f;
activityCorrFac = 5.33f
} else {
activityCorrFac = 1.4f
}
else {
activityCorrFac = 1.4f;
}
break;
}
}
return (0.3674f * height * height / imp50 + 0.17530f * weight - 0.11f * age + (6.53f + activityCorrFac)) / weight * 100.0f;
return (0.3674f * height * height / imp50 + 0.17530f * weight - 0.11f * age + (6.53f + activityCorrFac)) / weight * 100.0f
}
public float getMuscle(final float weight, final float imp50, final float imp5) { // in %
float activityCorrFac = 0.0f;
fun getMuscle(weight: Float, imp50: Float, imp5: Float): Float { // in %
var activityCorrFac = 0.0f
switch (activityLevel) {
case 1:
case 2:
case 3: {
when (activityLevel) {
1, 2, 3 -> {
if (isMale) {
activityCorrFac = 3.6224f;
activityCorrFac = 3.6224f
} else {
activityCorrFac = 0.0f
}
else {
activityCorrFac = 0.0f;
}
break;
}
case 4: {
4 -> {
if (isMale) {
activityCorrFac = 4.3904f;
activityCorrFac = 4.3904f
} else {
activityCorrFac = 0.0f
}
else {
activityCorrFac = 0.0f;
}
break;
}
case 5: {
5 -> {
if (isMale) {
activityCorrFac = 5.4144f;
activityCorrFac = 5.4144f
} else {
activityCorrFac = 1.664f
}
else {
activityCorrFac = 1.664f;
}
break;
}
}
return ((0.47027f / imp50 - 0.24196f / imp5) * height * height + 0.13796f * weight - 0.1152f * age + (5.12f + activityCorrFac)) / weight * 100.0f;
return ((0.47027f / imp50 - 0.24196f / imp5) * height * height + 0.13796f * weight - 0.1152f * age + (5.12f + activityCorrFac)) / weight * 100.0f
}
}

View File

@@ -1,77 +1,78 @@
/* Copyright (C) 2018 Maks Verver <maks@verver.ch>
* 2019 olie.xdev <olie.xdev@googlemail.com>
/*
* openScale
* Copyright (C) 2018 Maks Verver <maks@verver.ch>
* 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 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.
* 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 <http://www.gnu.org/licenses/>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs;
package com.health.openscale.core.bluetooth.libs
/**
* Class with static helper methods. This is a separate class for testing purposes.
*/
public class TrisaBodyAnalyzeLib {
class TrisaBodyAnalyzeLib(sex: Int, private val ageYears: Int, private val heightCm: Float) {
private val isMale: Boolean
private boolean isMale;
private int ageYears;
private float heightCm;
public TrisaBodyAnalyzeLib(int sex, int age, float height) {
isMale = sex == 1 ? true : false; // male = 1; female = 0
ageYears = age;
heightCm = height;
init {
isMale = if (sex == 1) true else false // male = 1; female = 0
}
public float getBMI(float weightKg) {
return weightKg * 1e4f / (heightCm * heightCm);
fun getBMI(weightKg: Float): Float {
return weightKg * 1e4f / (heightCm * heightCm)
}
public float getWater(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
fun getWater(weightKg: Float, impedance: Float): Float {
val bmi = getBMI(weightKg)
float water = isMale
? 87.51f + (-1.162f * bmi - 0.00813f * impedance + 0.07594f * ageYears)
: 77.721f + (-1.148f * bmi - 0.00573f * impedance + 0.06448f * ageYears);
val water = if (isMale)
87.51f + (-1.162f * bmi - 0.00813f * impedance + 0.07594f * ageYears)
else
77.721f + (-1.148f * bmi - 0.00573f * impedance + 0.06448f * ageYears)
return water;
return water
}
public float getFat(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
fun getFat(weightKg: Float, impedance: Float): Float {
val bmi = getBMI(weightKg)
float fat = isMale
? bmi * (1.479f + 4.4e-4f * impedance) + 0.1f * ageYears - 21.764f
: bmi * (1.506f + 3.908e-4f * impedance) + 0.1f * ageYears - 12.834f;
val fat = if (isMale)
bmi * (1.479f + 4.4e-4f * impedance) + 0.1f * ageYears - 21.764f
else
bmi * (1.506f + 3.908e-4f * impedance) + 0.1f * ageYears - 12.834f
return fat;
return fat
}
public float getMuscle(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
fun getMuscle(weightKg: Float, impedance: Float): Float {
val bmi = getBMI(weightKg)
float muscle = isMale
? 74.627f + (-0.811f * bmi - 0.00565f * impedance - 0.367f * ageYears)
: 57.0f + (-0.694f * bmi - 0.00344f * impedance - 0.255f * ageYears);
val muscle = if (isMale)
74.627f + (-0.811f * bmi - 0.00565f * impedance - 0.367f * ageYears)
else
57.0f + (-0.694f * bmi - 0.00344f * impedance - 0.255f * ageYears)
return muscle;
return muscle
}
public float getBone(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
fun getBone(weightKg: Float, impedance: Float): Float {
val bmi = getBMI(weightKg)
float bone = isMale
? 7.829f + (-0.0855f * bmi - 5.92e-4f * impedance - 0.0389f * ageYears)
: 7.98f + (-0.0973f * bmi - 4.84e-4f * impedance - 0.036f * ageYears);
val bone = if (isMale)
7.829f + (-0.0855f * bmi - 5.92e-4f * impedance - 0.0389f * ageYears)
else
7.98f + (-0.0973f * bmi - 4.84e-4f * impedance - 0.036f * ageYears)
return bone;
return bone
}
}

View File

@@ -15,158 +15,157 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs;
package com.health.openscale.core.bluetooth.libs
import com.health.openscale.core.data.ActivityLevel
import kotlin.math.sqrt
import com.health.openscale.core.data.ActivityLevel;
class YunmaiLib(// male = 1; female = 0
private val sex: Int, private val height: Float, activityLevel: ActivityLevel
) {
private val fitnessBodyType: Boolean
public class YunmaiLib {
private int sex; // male = 1; female = 0
private float height;
private boolean fitnessBodyType;
static public int toYunmaiActivityLevel(ActivityLevel activityLevel) {
switch (activityLevel) {
case HEAVY:
case EXTREME:
return 1;
default:
return 0;
}
init {
this.fitnessBodyType = toYunmaiActivityLevel(activityLevel) == 1
}
public YunmaiLib(int sex, float height, ActivityLevel activityLevel) {
this.sex = sex;
this.height = height;
this.fitnessBodyType = YunmaiLib.toYunmaiActivityLevel(activityLevel) == 1;
fun getWater(bodyFat: Float): Float {
return ((100.0f - bodyFat) * 0.726f * 100.0f + 0.5f) / 100.0f
}
public float getWater(float bodyFat) {
return ((100.0f - bodyFat) * 0.726f * 100.0f + 0.5f) / 100.0f;
}
public float getFat(int age, float weight, int resistance) {
fun getFat(age: Int, weight: Float, resistance: Int): Float {
// for < 0x1e version devices
float fat;
var fat: Float
float r = (resistance - 100.0f) / 100.0f;
float h = height / 100.0f;
var r = (resistance - 100.0f) / 100.0f
val h = height / 100.0f
if (r >= 1) {
r = (float)Math.sqrt(r);
r = sqrt(r.toDouble()).toFloat()
}
fat = (weight * 1.5f / h / h) + (age * 0.08f);
fat = (weight * 1.5f / h / h) + (age * 0.08f)
if (this.sex == 1) {
fat -= 10.8f;
fat -= 10.8f
}
fat = (fat - 7.4f) + r;
fat = (fat - 7.4f) + r
if (fat < 5.0f || fat > 75.0f) {
fat = 0.0f;
fat = 0.0f
}
return fat;
return fat
}
public float getMuscle(float bodyFat) {
float muscle;
muscle = (100.0f - bodyFat) * 0.67f;
fun getMuscle(bodyFat: Float): Float {
var muscle: Float
muscle = (100.0f - bodyFat) * 0.67f
if (this.fitnessBodyType) {
muscle = (100.0f - bodyFat) * 0.7f;
muscle = (100.0f - bodyFat) * 0.7f
}
muscle = ((muscle * 100.0f) + 0.5f) / 100.0f;
muscle = ((muscle * 100.0f) + 0.5f) / 100.0f
return muscle;
return muscle
}
public float getSkeletalMuscle(float bodyFat) {
float muscle;
fun getSkeletalMuscle(bodyFat: Float): Float {
var muscle: Float
muscle = (100.0f - bodyFat) * 0.53f;
muscle = (100.0f - bodyFat) * 0.53f
if (this.fitnessBodyType) {
muscle = (100.0f - bodyFat) * 0.6f;
muscle = (100.0f - bodyFat) * 0.6f
}
muscle = ((muscle * 100.0f) + 0.5f) / 100.0f;
muscle = ((muscle * 100.0f) + 0.5f) / 100.0f
return muscle;
return muscle
}
public float getBoneMass(float muscle, float weight) {
float boneMass;
fun getBoneMass(muscle: Float, weight: Float): Float {
var boneMass: Float
float h = height - 170.0f;
val h = height - 170.0f
if (sex == 1) {
boneMass = ((weight * (muscle / 100.0f) * 4.0f) / 7.0f * 0.22f * 0.6f) + (h / 100.0f);
boneMass = ((weight * (muscle / 100.0f) * 4.0f) / 7.0f * 0.22f * 0.6f) + (h / 100.0f)
} else {
boneMass = ((weight * (muscle / 100.0f) * 4.0f) / 7.0f * 0.34f * 0.45f) + (h / 100.0f);
boneMass = ((weight * (muscle / 100.0f) * 4.0f) / 7.0f * 0.34f * 0.45f) + (h / 100.0f)
}
boneMass = ((boneMass * 10.0f) + 0.5f) / 10.0f;
boneMass = ((boneMass * 10.0f) + 0.5f) / 10.0f
return boneMass;
return boneMass
}
public float getLeanBodyMass(float weight, float bodyFat) {
return weight * (100.0f - bodyFat) / 100.0f;
fun getLeanBodyMass(weight: Float, bodyFat: Float): Float {
return weight * (100.0f - bodyFat) / 100.0f
}
public float getVisceralFat(float bodyFat, int age) {
float f = bodyFat;
int a = (age < 18 || age > 120) ? 18 : age;
fun getVisceralFat(bodyFat: Float, age: Int): Float {
var f = bodyFat
val a = if (age < 18 || age > 120) 18 else age
float vf;
val vf: Float
if (!fitnessBodyType) {
if (sex == 1) {
if (a < 40) {
f -= 21.0f;
f -= 21.0f
} else if (a < 60) {
f -= 22.0f;
f -= 22.0f
} else {
f -= 24.0f;
f -= 24.0f
}
} else {
if (a < 40) {
f -= 34.0f;
f -= 34.0f
} else if (a < 60) {
f -= 35.0f;
f -= 35.0f
} else {
f -= 36.0f;
f -= 36.0f
}
}
float d = sex == 1 ? 1.4f : 1.8f;
var d = if (sex == 1) 1.4f else 1.8f
if (f > 0.0f) {
d = 1.1f;
d = 1.1f
}
vf = (f / d) + 9.5f;
vf = (f / d) + 9.5f
if (vf < 1.0f) {
return 1.0f;
return 1.0f
}
if (vf > 30.0f) {
return 30.0f;
return 30.0f
}
return vf;
return vf
} else {
if (bodyFat > 15.0f) {
vf = (bodyFat - 15.0f) / 1.1f + 12.0f;
vf = (bodyFat - 15.0f) / 1.1f + 12.0f
} else {
vf = -1 * (15.0f - bodyFat) / 1.4f + 12.0f;
vf = -1 * (15.0f - bodyFat) / 1.4f + 12.0f
}
if (vf < 1.0f) {
return 1.0f;
return 1.0f
}
if (vf > 9.0f) {
return 9.0f;
return 9.0f
}
return vf
}
}
companion object {
@JvmStatic
fun toYunmaiActivityLevel(activityLevel: ActivityLevel): Int {
when (activityLevel) {
ActivityLevel.HEAVY, ActivityLevel.EXTREME -> return 1
else -> return 0
}
return vf;
}
}
}

View File

@@ -0,0 +1,214 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
import com.google.common.truth.Truth.assertThat
import org.junit.Test
/**
* Unit tests for [MiScaleLib] (current implementation).
*
* - Three regression fixtures use the exact outputs printed from the current code
* to guard against accidental changes.
* - Behavioral tests verify important branches without brittle hard-coded numbers.
*/
class MiScaleLibTest {
private val EPS = 1e-3f // general float tolerance
// --- Simple BMI checks ----------------------------------------------------
@Test
fun bmi_isComputedCorrectly_forTypicalMale() {
// Given
val lib = MiScaleLib(/* sex=male */ 1, /* age */ 30, /* height cm */ 180f)
val weight = 80f
// When
val bmi = lib.getBMI(weight)
// Then: BMI = weight / (height_m^2) = 80 / (1.8 * 1.8) = 24.691...
assertThat(bmi).isWithin(EPS).of(24.691358f)
}
@Test
fun bmi_monotonicity_weightUp_heightSame_increases() {
val lib = MiScaleLib(0, 28, 165f)
val bmi1 = lib.getBMI(60f)
val bmi2 = lib.getBMI(65f)
assertThat(bmi2).isGreaterThan(bmi1)
}
@Test
fun bmi_monotonicity_heightUp_weightSame_decreases() {
val libShort = MiScaleLib(1, 35, 170f)
val libTall = MiScaleLib(1, 35, 185f)
val weight = 80f
assertThat(libTall.getBMI(weight)).isLessThan(libShort.getBMI(weight))
}
// --- Regression values for full model (from your dumps) ------------------
@Test
fun regression_male_30y_180cm_80kg_imp500() {
val lib = MiScaleLib(1, 30, 180f)
val weight = 80f
val r = Fixture(
bmi = 24.691359f,
bodyFat = 23.315107f,
bone = 3.1254203f,
lbm = 58.222496f,
musclePct = 40.977253f,
waterPct = 52.605835f,
visceralFat = 13.359997f
)
assertThat(lib.getBMI(weight)).isWithin(EPS).of(r.bmi)
assertThat(lib.getBodyFat(weight, 500f)).isWithin(1e-3f).of(r.bodyFat)
assertThat(lib.getBoneMass(weight, 500f)).isWithin(1e-3f).of(r.bone)
assertThat(lib.getLBM(weight, 500f)).isWithin(1e-3f).of(r.lbm)
assertThat(lib.getMuscle(weight, 500f)).isWithin(1e-3f).of(r.musclePct)
assertThat(lib.getWater(weight, 500f)).isWithin(1e-3f).of(r.waterPct)
assertThat(lib.getVisceralFat(weight)).isWithin(1e-3f).of(r.visceralFat)
}
@Test
fun regression_female_28y_165cm_60kg_imp520() {
val lib = MiScaleLib(0, 28, 165f)
val weight = 60f
val r = Fixture(
bmi = 22.038567f,
bodyFat = 30.361998f,
bone = 2.4865808f,
lbm = 39.29622f,
musclePct = 40.181103f,
waterPct = 49.72153f,
visceralFat = -36.555004f
)
assertThat(lib.getBMI(weight)).isWithin(EPS).of(r.bmi)
assertThat(lib.getBodyFat(weight, 520f)).isWithin(1e-3f).of(r.bodyFat)
assertThat(lib.getBoneMass(weight, 520f)).isWithin(1e-3f).of(r.bone)
assertThat(lib.getLBM(weight, 520f)).isWithin(1e-3f).of(r.lbm)
assertThat(lib.getMuscle(weight, 520f)).isWithin(1e-3f).of(r.musclePct)
assertThat(lib.getWater(weight, 520f)).isWithin(1e-3f).of(r.waterPct)
assertThat(lib.getVisceralFat(weight)).isWithin(1e-3f).of(r.visceralFat)
}
@Test
fun regression_male_45y_175cm_95kg_imp430() {
val lib = MiScaleLib(1, 45, 175f)
val weight = 95f
val r = Fixture(
bmi = 31.020409f,
bodyFat = 32.41778f,
bone = 3.2726917f,
lbm = 60.93042f,
musclePct = 36.096416f,
waterPct = 48.2537f,
visceralFat = 24.462498f
)
assertThat(lib.getBMI(weight)).isWithin(EPS).of(r.bmi)
assertThat(lib.getBodyFat(weight, 430f)).isWithin(1e-3f).of(r.bodyFat)
assertThat(lib.getBoneMass(weight, 430f)).isWithin(1e-3f).of(r.bone)
assertThat(lib.getLBM(weight, 430f)).isWithin(1e-3f).of(r.lbm)
assertThat(lib.getMuscle(weight, 430f)).isWithin(1e-3f).of(r.musclePct)
assertThat(lib.getWater(weight, 430f)).isWithin(1e-3f).of(r.waterPct)
assertThat(lib.getVisceralFat(weight)).isWithin(1e-3f).of(r.visceralFat)
}
// --- Special paths & edge behavior --------------------------------------
@Test
fun muscle_fallback_whenImpedanceZero_usesLbmRatio_andIsClamped() {
// Female, impedance=0 triggers fallback path (LBM * 0.46) → % of weight → clamp 10..60
val lib = MiScaleLib(0, 52, 160f)
val weight = 48f
// Compute expected via the same path the code uses (behavioral property, not magic number)
val lbm = lib.getLBM(weight, /* impedance */ 0f)
val expectedPct = (lbm * 0.46f) / weight * 100f
val expectedClamped = expectedPct.coerceIn(10f, 60f)
val actual = lib.getMuscle(weight, /* impedance */ 0f)
assertThat(actual).isWithin(1e-3f).of(expectedClamped)
assertThat(actual).isAtLeast(10f)
assertThat(actual).isAtMost(60f)
}
@Test
fun muscle_percentage_isClampedAt60_whenExtremelyHigh() {
// Construct params that blow up SMM/weight; expect clamp to 60%
val lib = MiScaleLib(1, 20, 190f)
val clamped = lib.getMuscle(/* weight */ 40f, /* very low impedance */ 50f)
assertThat(clamped).isWithin(EPS).of(60f)
}
@Test
fun water_derivesFromBodyFat_andUsesCoeffBranch() {
// Check: water = ((100 - BF) * 0.7) * coeff, coeff = 1.02 if <50 else 0.98
val lib = MiScaleLib(0, 50, 150f)
val weight = 100f
val imp = 700f
val bf = lib.getBodyFat(weight, imp)
val raw = (100f - bf) * 0.7f
val coeff = if (raw < 50f) 1.02f else 0.98f
val expected = raw * coeff
val water = lib.getWater(weight, imp)
assertThat(water).isWithin(1e-3f).of(expected)
if (raw < 50f) {
assertThat(water).isLessThan(50f)
} else {
assertThat(water).isGreaterThan(50f)
}
}
@Test
fun outputs_areFinite_forTypicalInputs() {
val lib = MiScaleLib(1, 30, 180f)
val weight = 80f
val imp = 500f
val nums = listOf(
lib.getBMI(weight),
lib.getBodyFat(weight, imp),
lib.getBoneMass(weight, imp),
lib.getLBM(weight, imp),
lib.getMuscle(weight, imp),
lib.getWater(weight, imp),
lib.getVisceralFat(weight)
)
nums.forEach { v ->
assertThat(v.isNaN()).isFalse()
assertThat(v.isInfinite()).isFalse()
}
}
private data class Fixture(
val bmi: Float,
val bodyFat: Float,
val bone: Float,
val lbm: Float,
val musclePct: Float,
val waterPct: Float,
val visceralFat: Float
)
}

View File

@@ -0,0 +1,263 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.Test
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* Unit tests for [OneByoneLib].
*
* Strategy
* 1) Snapshot tests with frozen numbers (from current Java impl) to guard a Kotlin port.
* 2) Property tests (monotonicity, clamping, finite outputs, boundary behavior).
*
* NOTE: We do NOT re-implement formulas here. Snapshots are the source of truth.
*/
class OneByoneLibTest {
private val EPS = 1e-3f
// ---------- Snapshots (pre-recorded from current Java implementation) ----------
private data class Snap(
val sex: Int,
val age: Int,
val h: Float,
val w: Float,
val imp: Float,
val pt: Int,
val bmi: Float,
val bf: Float,
val lbm: Float,
val muscle: Float,
val water: Float,
val bone: Float,
val vf: Float
)
private val FIXTURES = mapOf(
"male_mid" to Snap(
sex = 1, age = 30, h = 180f, w = 80f, imp = 500f, pt = 0,
bmi = 24.691359f, bf = 23.315102f, lbm = 61.34792f, muscle = 40.97725f,
water = 52.60584f, bone = 3.030576f, vf = 10.79977f
),
"female_mid" to Snap(
sex = 0, age = 28, h = 165f, w = 60f, imp = 520f, pt = 1,
bmi = 22.038567f, bf = 25.210106f, lbm = 44.873936f, muscle = 40.181107f,
water = 51.305866f, bone = 2.3883991f, vf = 0.70499706f
),
"male_high" to Snap(
sex = 1, age = 52, h = 175f, w = 95f, imp = 430f, pt = 2,
bmi = 31.020409f, bf = 26.381027f, lbm = 69.93803f, muscle = 35.573257f,
water = 50.502613f, bone = 3.1443515f, vf = 13.163806f
),
"imp_low" to Snap(
sex = 1, age = 25, h = 178f, w = 72f, imp = 80f, pt = 0,
bmi = 22.724403f, bf = 16.04116f, lbm = 60.450363f, muscle = 230.51118f,
water = 57.595764f, bone = 3.0263696f, vf = 9.316022f
),
"imp_mid" to Snap(
sex = 0, age = 35, h = 170f, w = 68f, imp = 300f, pt = 2,
bmi = 23.529411f, bf = 25.14642f, lbm = 50.900436f, muscle = 60.656864f,
water = 51.349552f, bone = 2.650265f, vf = 2.6039982f
),
"imp_high" to Snap(
sex = 1, age = 45, h = 182f, w = 90f, imp = 1300f, pt = 1,
bmi = 27.170633f, bf = 30.914497f, lbm = 62.176952f, muscle = 17.721643f,
water = 49.32705f, bone = 2.901557f, vf = 11.179609f
)
)
@Test
fun snapshots_match_expected_outputs() {
require(FIXTURES.isNotEmpty()) { "No snapshots defined." }
FIXTURES.forEach { (name, s) ->
val lib = OneByoneLib(s.sex, s.age, s.h, s.pt)
val bf = lib.getBodyFat(s.w, s.imp)
assertWithMessage("$name:bmi").that(lib.getBMI(s.w)).isWithin(EPS).of(s.bmi)
assertWithMessage("$name:bf").that(bf).isWithin(EPS).of(s.bf)
assertWithMessage("$name:lbm").that(lib.getLBM(s.w, bf)).isWithin(EPS).of(s.lbm)
assertWithMessage("$name:muscle").that(lib.getMuscle(s.w, s.imp)).isWithin(EPS).of(s.muscle)
assertWithMessage("$name:water").that(lib.getWater(bf)).isWithin(EPS).of(s.water)
assertWithMessage("$name:bone").that(lib.getBoneMass(s.w, s.imp)).isWithin(EPS).of(s.bone)
assertWithMessage("$name:vf").that(lib.getVisceralFat(s.w)).isWithin(EPS).of(s.vf)
}
}
// ---------------- Generic / property-based tests ----------------
@Test
fun bmi_monotonicity_weightUp_increases_heightConstant() {
val lib = OneByoneLib(1, 30, 180f, 0)
val w1 = 70f
val w2 = 85f
assertThat(lib.getBMI(w2)).isGreaterThan(lib.getBMI(w1))
}
@Test
fun bmi_monotonicity_heightUp_decreases_weightConstant() {
val libShort = OneByoneLib(1, 30, 170f, 0)
val libTall = OneByoneLib(1, 30, 190f, 0)
val w = 80f
assertThat(libTall.getBMI(w)).isLessThan(libShort.getBMI(w))
}
@Test
fun water_switch_coeff_below_and_above_50() {
val lib = OneByoneLib(0, 40, 165f, 1)
val bfHigh = 35f // → (100-35)*0.7 = 45.5 < 50 → *1.02
val bfLow = 20f // → (100-20)*0.7 = 56 > 50 → *0.98
val wHigh = lib.getWater(bfHigh)
val wLow = lib.getWater(bfLow)
assertThat(wHigh).isLessThan(50f)
assertThat(wLow).isGreaterThan(50f)
}
@Test
fun boneMass_isReasonablyClamped_between_0_5_and_8_0() {
val lib = OneByoneLib(0, 55, 170f, 2)
// Explore some extreme ranges
val candidates = listOf(
40f to 1400f,
150f to 200f,
55f to 600f,
95f to 300f,
)
candidates.forEach { (w, imp) ->
val bone = lib.getBoneMass(w, imp)
assertThat(bone).isAtLeast(0.5f)
assertThat(bone).isAtMost(8.0f)
}
}
@Test
fun muscle_reacts_to_impedance_reasonably() {
val sex = 1; val age = 30; val h = 180f; val w = 80f
val lib = OneByoneLib(sex, age, h, 0)
val impHigh = 1300f
val impMid = 400f
val impLow = 80f
val mHigh = lib.getMuscle(w, impHigh)
val mMid = lib.getMuscle(w, impMid)
val mLow = lib.getMuscle(w, impLow)
// Lower impedance tends to increase SMM estimate (classic BIA behavior)
assertThat(mLow).isGreaterThan(mMid)
assertThat(mMid).isGreaterThan(mHigh)
}
@Test
fun bodyFat_stays_within_reasonable_bounds() {
val lib = OneByoneLib(1, 35, 180f, 1)
val weights = listOf(50f, 70f, 90f, 110f)
val imps = listOf(80f, 300f, 600f, 1200f)
for (w in weights) for (imp in imps) {
val bf = lib.getBodyFat(w, imp)
// Implementation clamps to [1, 45]; allow small epsilon
assertThat(bf).isAtLeast(1f - 1e-3f)
assertThat(bf).isAtMost(45f + 1e-3f)
}
}
@Test
fun peopleType_influences_outputs() {
val base = OneByoneLib(1, 40, 175f, 0)
val mid = OneByoneLib(1, 40, 175f, 1)
val high = OneByoneLib(1, 40, 175f, 2)
val w = 85f; val imp = 450f
val boneBase = base.getBoneMass(w, imp)
val boneMid = mid.getBoneMass(w, imp)
val boneHigh = high.getBoneMass(w, imp)
// Different activity types should yield distinct (but not crazy) values.
assertThat(abs(boneBase - boneMid)).isGreaterThan(0.0f)
assertThat(abs(boneMid - boneHigh)).isGreaterThan(0.0f)
// Guard against wild divergence
val minV = min(boneBase, min(boneMid, boneHigh))
val maxV = max(boneBase, max(boneMid, boneHigh))
assertThat(maxV - minV).isLessThan(2.0f) // heuristic guard
}
@Test
fun sex_flag_affects_outputs() {
val male = OneByoneLib(1, 32, 178f, 1)
val female = OneByoneLib(0, 32, 178f, 1)
val w = 75f; val imp = 420f
val bfM = male.getBodyFat(w, imp)
val bfF = female.getBodyFat(w, imp)
// Expect some difference between sexes
assertThat(abs(bfM - bfF)).isGreaterThan(0.1f)
}
@Test
fun outputs_are_finite_for_typical_ranges() {
val lib = OneByoneLib(1, 30, 180f, 0)
val w = 80f; val imp = 500f
val bf = lib.getBodyFat(w, imp)
val values = listOf(
lib.getBMI(w),
bf,
lib.getLBM(w, bf),
lib.getMuscle(w, imp),
lib.getWater(bf),
lib.getBoneMass(w, imp),
lib.getVisceralFat(w)
)
values.forEach {
assertThat(it.isNaN()).isFalse()
assertThat(it.isInfinite()).isFalse()
}
}
// ---------- Helper to (re)generate snapshot values if formulas change ----------
@Test
fun print_current_outputs_for_fixtures() {
fun dump(sex: Int, age: Int, h: Float, w: Float, imp: Float, pt: Int) {
val lib = OneByoneLib(sex, age, h, pt)
val bf = lib.getBodyFat(w, imp)
println(
"SNAP -> sex=$sex age=$age h=$h w=$w imp=$imp pt=$pt | " +
"bmi=${lib.getBMI(w)}; bf=$bf; " +
"lbm=${lib.getLBM(w, bf)}; muscle=${lib.getMuscle(w, imp)}; " +
"water=${lib.getWater(bf)}; bone=${lib.getBoneMass(w, imp)}; vf=${lib.getVisceralFat(w)}"
)
}
// Re-run if you intentionally modify formulas; then paste outputs into FIXTURES above.
dump(1, 30, 180f, 80f, 500f, 0)
dump(0, 28, 165f, 60f, 520f, 1)
dump(1, 52, 175f, 95f, 430f, 2)
dump(1, 25, 178f, 72f, 80f, 0)
dump(0, 35, 170f, 68f, 300f, 2)
dump(1, 45, 182f, 90f, 1300f, 1)
}
}

View File

@@ -0,0 +1,298 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.Test
import kotlin.math.abs
/**
* Unit tests for [OneByoneNewLib].
*
* - Snapshot tests (FIXTURES) sichern, dass sich die Outputs nach Refactor/Port nicht verändern.
* - Property-Tests prüfen Monotonie, Clamping, Finite-Werte etc.
*/
class OneByoneNewLibTest {
private val EPS = 1e-3f
// ---------- Snapshots (aus deiner Ausgabe eingefügt) ----------
private data class Snap(
val sex: Int,
val age: Int,
val h: Float,
val w: Float,
val imp: Int,
val pt: Int,
val bmi: Float,
val lbm: Float,
val bmmrCoeff: Float,
val bmmr: Float,
val bodyFatPct: Float,
val boneMass: Float,
val muscleMass: Float,
val skelMusclePct: Float,
val visceralFat: Float,
val waterPct: Float,
val proteinPct: Float
)
private val FIXTURES: Map<String, Snap> = mapOf(
"male_mid" to Snap(
sex = 1, age = 30, h = 180f, w = 80f, imp = 500, pt = 0,
bmi = 24.691359f, lbm = 62.14792f, bmmrCoeff = 21.0f, bmmr = 1000.0f,
bodyFatPct = 23.315102f, boneMass = 3.1254208f, muscleMass = 58.2225f,
skelMusclePct = 40.566765f, visceralFat = 10.79977f, waterPct = 52.60584f,
proteinPct = 15.963814f
),
"female_mid" to Snap(
sex = 0, age = 28, h = 165f, w = 60f, imp = 520, pt = 1,
bmi = 22.038567f, lbm = 51.032806f, bmmrCoeff = 22.0f, bmmr = 1000.0f,
bodyFatPct = 28.27285f, boneMass = 2.486581f, muscleMass = 40.549713f,
skelMusclePct = 36.45647f, visceralFat = 4.704997f, waterPct = 49.204823f,
proteinPct = 14.44164f
),
"imp_low" to Snap(
sex = 1, age = 25, h = 178f, w = 72f, imp = 80, pt = 0,
bmi = 22.724403f, lbm = 62.06637f, bmmrCoeff = 23.0f, bmmr = 1000.0f,
bodyFatPct = 14.907819f, boneMass = 3.1212144f, muscleMass = 58.145157f,
skelMusclePct = 45.008743f, visceralFat = 9.316022f, waterPct = 58.373234f,
proteinPct = 17.714064f
),
"imp_mid" to Snap(
sex = 0, age = 35, h = 170f, w = 68f, imp = 300, pt = 2,
bmi = 23.529411f, lbm = 56.22662f, bmmrCoeff = 20.0f, bmmr = 1000.0f,
bodyFatPct = 31.690466f, boneMass = 2.754478f, muscleMass = 43.696007f,
skelMusclePct = 36.679123f, visceralFat = 6.603998f, waterPct = 48.773006f,
proteinPct = 11.583979f
),
"imp_high" to Snap(
sex = 1, age = 45, h = 182f, w = 90f, imp = 1300, pt = 1,
bmi = 27.170633f, lbm = 59.750725f, bmmrCoeff = 21.0f, bmmr = 1000.0f,
bodyFatPct = 34.49919f, boneMass = 3.0017734f, muscleMass = 55.948956f,
skelMusclePct = 36.065098f, visceralFat = 13.974511f, waterPct = 46.76758f,
proteinPct = 11.656517f
),
)
@Test
fun snapshots_match_expected_outputs() {
FIXTURES.forEach { (name, s) ->
val lib = OneByoneNewLib(s.sex, s.age, s.h, s.pt)
val bmi = lib.getBMI(s.w)
val lbm = lib.getLBM(s.w, s.imp)
val coeff = lib.getBMMRCoeff(s.w)
val bmmr = lib.getBMMR(s.w)
val bf = lib.getBodyFatPercentage(s.w, s.imp)
val bone = lib.getBoneMass(s.w, s.imp)
val mm = lib.getMuscleMass(s.w, s.imp)
val skm = lib.getSkeletonMusclePercentage(s.w, s.imp)
val vf = lib.getVisceralFat(s.w)
val water = lib.getWaterPercentage(s.w, s.imp)
val prot = lib.getProteinPercentage(s.w, s.imp)
assertWithMessage("$name:bmi").that(bmi).isWithin(EPS).of(s.bmi)
assertWithMessage("$name:lbm").that(lbm).isWithin(EPS).of(s.lbm)
assertWithMessage("$name:bmmrCoeff").that(coeff).isWithin(EPS).of(s.bmmrCoeff)
assertWithMessage("$name:bmmr").that(bmmr).isWithin(EPS).of(s.bmmr)
assertWithMessage("$name:bf%").that(bf).isWithin(EPS).of(s.bodyFatPct)
assertWithMessage("$name:bone").that(bone).isWithin(EPS).of(s.boneMass)
assertWithMessage("$name:muscleMass").that(mm).isWithin(EPS).of(s.muscleMass)
assertWithMessage("$name:skelMuscle%").that(skm).isWithin(EPS).of(s.skelMusclePct)
assertWithMessage("$name:visceralFat").that(vf).isWithin(EPS).of(s.visceralFat)
assertWithMessage("$name:water%").that(water).isWithin(EPS).of(s.waterPct)
assertWithMessage("$name:protein%").that(prot).isWithin(EPS).of(s.proteinPct)
}
}
// ---------- Behavior / Property tests ----------
@Test
fun bmi_is_bounded_and_monotonic_with_weight() {
val lib = OneByoneNewLib(1, 30, 180f, 0)
val b1 = lib.getBMI(60f)
val b2 = lib.getBMI(80f)
val b3 = lib.getBMI(100f)
listOf(b1, b2, b3).forEach {
assertThat(it).isAtLeast(10f - EPS)
assertThat(it).isAtMost(90f + EPS)
}
assertThat(b2).isGreaterThan(b1)
assertThat(b3).isGreaterThan(b2)
}
@Test
fun lbm_varies_with_impedance_and_weight() {
val lib = OneByoneNewLib(0, 28, 165f, 1)
val w = 60f
val lbmHighImp = lib.getLBM(w, 1200)
val lbmLowImp = lib.getLBM(w, 200)
assertThat(lbmLowImp).isGreaterThan(lbmHighImp)
val lbmHeavier = lib.getLBM(75f, 300)
val lbmLighter = lib.getLBM(55f, 300)
assertThat(lbmHeavier).isGreaterThan(lbmLighter)
}
@Test
fun bmmrCoeff_follows_age_bands_and_sex() {
fun coeff(sex:Int, age:Int): Float =
OneByoneNewLib(sex, age, 170f, 0).getBMMRCoeff(70f)
// male bands
assertThat(coeff(1, 10)).isWithin(EPS).of(36f)
assertThat(coeff(1, 14)).isWithin(EPS).of(30f)
assertThat(coeff(1, 17)).isWithin(EPS).of(26f)
assertThat(coeff(1, 25)).isWithin(EPS).of(23f)
assertThat(coeff(1, 50)).isWithin(EPS).of(20f)
// female bands
assertThat(coeff(0, 10)).isWithin(EPS).of(34f)
assertThat(coeff(0, 15)).isWithin(EPS).of(29f)
assertThat(coeff(0, 17)).isWithin(EPS).of(24f)
assertThat(coeff(0, 25)).isWithin(EPS).of(22f)
assertThat(coeff(0, 50)).isWithin(EPS).of(19f)
}
@Test
fun bmmr_is_bounded_and_differs_by_sex() {
val h = 180f
val age = 30
val male = OneByoneNewLib(1, age, h, 0).getBMMR(22f) // ~806 (ungeclamped)
val female = OneByoneNewLib(0, age, h, 0).getBMMR(19f) // ~802 (ungeclamped)
assertThat(male).isAtLeast(500f - 1e-3f)
assertThat(male).isAtMost(1000f + 1e-3f)
assertThat(female).isAtLeast(500f - 1e-3f)
assertThat(female).isAtMost(1000f + 1e-3f)
assertThat(abs(male - female)).isGreaterThan(0.1f)
}
@Test
fun water_switches_coeff_around_50_and_is_bounded() {
val lib = OneByoneNewLib(1, 35, 175f, 1)
val w = 80f
val waterLow = lib.getWaterPercentage(w, 1200) // typ. <50
val waterHigh = lib.getWaterPercentage(w, 200) // typ. >50
listOf(waterLow, waterHigh).forEach {
assertThat(it).isAtLeast(35f - EPS)
assertThat(it).isAtMost(75f + EPS)
}
// lose heuristics; just ensure they are not identical and near the “switch” region across scenarios
assertThat(abs(waterHigh - waterLow)).isGreaterThan(0.5f)
}
@Test
fun boneMass_is_bounded_and_varies_with_impedance() {
val lib = OneByoneNewLib(0, 50, 168f, 0)
val w = 70f
val boneHighImp = lib.getBoneMass(w, 1200)
val boneLowImp = lib.getBoneMass(w, 200)
listOf(boneHighImp, boneLowImp).forEach {
assertThat(it).isAtLeast(0.5f - 1e-3f)
assertThat(it).isAtMost(8.0f + 1e-3f)
}
assertThat(abs(boneLowImp - boneHighImp)).isGreaterThan(0.0f)
assertThat(boneLowImp).isGreaterThan(boneHighImp)
}
@Test
fun muscleMass_is_bounded_and_correlates_with_impedance() {
val lib = OneByoneNewLib(1, 28, 180f, 0)
val w = 82f
val mmHighImp = lib.getMuscleMass(w, 1200)
val mmLowImp = lib.getMuscleMass(w, 200)
listOf(mmHighImp, mmLowImp).forEach {
assertThat(it).isAtLeast(10f - EPS)
assertThat(it).isAtMost(120f + EPS)
}
assertThat(mmLowImp).isGreaterThan(mmHighImp)
}
@Test
fun skeletonMuscle_is_finite_and_reasonable_range() {
val lib = OneByoneNewLib(0, 33, 165f, 2)
val sm = lib.getSkeletonMusclePercentage(58f, 400)
assertThat(sm.isNaN()).isFalse()
assertThat(sm.isInfinite()).isFalse()
assertThat(sm).isGreaterThan(-20f)
assertThat(sm).isLessThan(120f)
}
@Test
fun bodyFat_and_protein_are_finite() {
val lib = OneByoneNewLib(1, 40, 182f, 1)
val w = 90f
val imp = 500
val bf = lib.getBodyFatPercentage(w, imp)
val prot = lib.getProteinPercentage(w, imp)
assertThat(bf.isNaN()).isFalse()
assertThat(prot.isNaN()).isFalse()
assertThat(bf.isInfinite()).isFalse()
assertThat(prot.isInfinite()).isFalse()
}
// ---------- Helper: re-print current values if du neue Fixtures brauchst ----------
@Test
fun print_current_outputs_for_fixtures() {
fun dump(tag:String, sex:Int, age:Int, h:Float, w:Float, imp:Int, pt:Int) {
val lib = OneByoneNewLib(sex, age, h, pt)
val bmi = lib.getBMI(w)
val lbm = lib.getLBM(w, imp)
val coeff = lib.getBMMRCoeff(w)
val bmmr = lib.getBMMR(w)
val bf = lib.getBodyFatPercentage(w, imp)
val bone = lib.getBoneMass(w, imp)
val mm = lib.getMuscleMass(w, imp)
val skm = lib.getSkeletonMusclePercentage(w, imp)
val vf = lib.getVisceralFat(w)
val water = lib.getWaterPercentage(w, imp)
val prot = lib.getProteinPercentage(w, imp)
println(
"SNAP $tag -> sex=$sex age=$age h=$h w=$w imp=$imp pt=$pt | " +
"bmi=$bmi; lbm=$lbm; bmmrCoeff=$coeff; bmmr=$bmmr; " +
"bf%=$bf; bone=$bone; muscleMass=$mm; skelMuscle%=$skm; " +
"visceralFat=$vf; water%=$water; protein%=$prot"
)
}
dump("male_mid", 1, 30, 180f, 80f, 500, 0)
dump("female_mid", 0, 28, 165f, 60f, 520, 1)
dump("imp_low", 1, 25, 178f, 72f, 80, 0)
dump("imp_mid", 0, 35, 170f, 68f, 300, 2)
dump("imp_high", 1, 45, 182f, 90f,1300, 1)
}
}

View File

@@ -0,0 +1,144 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
import com.google.common.truth.Truth.assertWithMessage
import org.junit.Test
import kotlin.math.abs
class SoehnleLibTest {
private val EPS = 1e-3f
// -------- Snapshot-Struktur --------
private data class Snap(
val isMale: Boolean,
val age: Int,
val h: Float, // height (cm)
val w: Float, // weight (kg)
val imp50: Float, // 50 kHz impedance
val imp5: Float, // 5 kHz impedance
val activity: Int, // activityLevel (1..5)
val bmi: Float,
val fat: Float,
val water: Float,
val muscle: Float
)
// -------- Fixe Snapshots (deine Werte) --------
private val FIXTURES: Map<String, Snap> = mapOf(
"male_mid" to Snap(
isMale = true, age = 30, h = 180f, w = 80f,
imp50 = 500f, imp5 = 200f, activity = 3,
bmi = 24.691359f, fat = 18.604935f, water = 54.8644f, muscle = 9.49897f
),
"female_mid" to Snap(
isMale = false, age = 28, h = 165f, w = 60f,
imp50 = 520f, imp5 = 210f, activity = 4,
bmi = 22.038567f, fat = 26.137234f, water = 56.005848f, muscle = 5.708269f
),
"male_active5" to Snap(
isMale = true, age = 35, h = 178f, w = 85f,
imp50 = 480f, imp5 = 190f, activity = 5,
bmi = 26.827421f, fat = 26.860249f, water = 55.484665f, muscle = 10.4964695f
),
"female_low" to Snap(
isMale = false, age = 45, h = 160f, w = 70f,
imp50 = 700f, imp5 = 250f, activity = 1,
bmi = 27.34375f, fat = 48.433907f, water = 38.981922f, muscle = 2.8784876f
),
)
// -------- Snapshot-Check --------
@Test
fun snapshots_match_expected_outputs() {
require(FIXTURES.isNotEmpty()) { "No snapshots defined." }
FIXTURES.forEach { (name, s) ->
val lib = SoehnleLib(s.isMale, s.age, s.h, s.activity)
assertWithMessage("$name:bmi")
.that(lib.computeBodyMassIndex(s.w))
.isWithin(EPS).of(s.bmi)
assertWithMessage("$name:fat%")
.that(lib.getFat(s.w, s.imp50))
.isWithin(EPS).of(s.fat)
assertWithMessage("$name:water%")
.that(lib.getWater(s.w, s.imp50))
.isWithin(EPS).of(s.water)
assertWithMessage("$name:muscle%")
.that(lib.getMuscle(s.w, s.imp50, s.imp5))
.isWithin(EPS).of(s.muscle)
}
}
// -------- Property-Tests --------
@Test
fun bmi_monotonic_with_weight() {
val lib = SoehnleLib(true, 30, 180f, 3)
val bmi1 = lib.computeBodyMassIndex(70f)
val bmi2 = lib.computeBodyMassIndex(85f)
assertWithMessage("BMI should increase when weight increases")
.that(bmi2).isGreaterThan(bmi1)
}
@Test
fun fat_increases_with_impedance50() {
val lib = SoehnleLib(true, 35, 178f, 3)
val w = 82f
val low = lib.getFat(w, 300f)
val high = lib.getFat(w, 600f)
assertWithMessage("Fat% should increase with imp50 for same person/weight")
.that(high).isGreaterThan(low)
}
@Test
fun water_in_reasonable_range() {
val lib = SoehnleLib( false, 29, 165f, 4)
val water = lib.getWater(60f, 520f)
assertInRange("Water%", water, 30f, 75f)
}
@Test
fun muscle_in_reasonable_range() {
val lib = SoehnleLib( true, 40, 182f, 5)
val muscle = lib.getMuscle(90f, 500f, 220f)
assertInRange("Muscle%", muscle, 0f, 70f)
}
@Test
fun male_vs_female_fat_differs_same_inputs() {
val age = 30; val h = 178f; val act = 3; val w = 75f; val imp50 = 500f
val m = SoehnleLib(true, age, h, act).getFat(w, imp50)
val f = SoehnleLib(false, age, h, act).getFat(w, imp50)
assertWithMessage("Male vs Female fat% should differ")
.that(abs(m - f)).isGreaterThan(0.1f)
}
// -------- Helper --------
private fun assertInRange(label: String, value: Float, lo: Float, hi: Float) {
assertWithMessage("$label lower bound ($lo)")
.that(value).isAtLeast(lo)
assertWithMessage("$label upper bound ($hi)")
.that(value).isAtMost(hi)
}
}

View File

@@ -0,0 +1,256 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
import com.google.common.truth.Truth.assertThat
import org.junit.Test
/**
* Unit tests for [TrisaBodyAnalyzeLib].
*
* - Regression fixtures use outputs computed from the current formulas
* to guard against accidental changes.
* - Behavioral tests verify key monotonicity/branch properties.
*/
class TrisaBodyAnalyzeLibTest {
private val EPS = 1e-3f // general float tolerance
// --- Simple BMI checks ----------------------------------------------------
@Test
fun bmi_isComputedCorrectly_forTypicalMale() {
val lib = TrisaBodyAnalyzeLib(1, 30, 180f)
val weight = 80f
val bmi = lib.getBMI(weight)
assertThat(bmi).isWithin(EPS).of(24.691358f)
}
@Test
fun bmi_monotonicity_weightUp_heightSame_increases() {
val lib = TrisaBodyAnalyzeLib(0, 28, 165f)
val bmi1 = lib.getBMI(60f)
val bmi2 = lib.getBMI(65f)
assertThat(bmi2).isGreaterThan(bmi1)
}
@Test
fun bmi_monotonicity_heightUp_weightSame_decreases() {
val shorty = TrisaBodyAnalyzeLib(1, 35, 170f)
val tall = TrisaBodyAnalyzeLib(1, 35, 185f)
val weight = 80f
assertThat(tall.getBMI(weight)).isLessThan(shorty.getBMI(weight))
}
// --- Behavioral properties -----------------------------------------------
@Test
fun impedance_effects_haveExpectedDirections() {
val male = TrisaBodyAnalyzeLib(1, 30, 180f)
val female = TrisaBodyAnalyzeLib(0, 30, 165f)
val w = 70f
val impLow = 300f
val impHigh = 700f
assertThat(male.getWater(w, impHigh)).isLessThan(male.getWater(w, impLow))
assertThat(male.getMuscle(w, impHigh)).isLessThan(male.getMuscle(w, impLow))
assertThat(male.getBone(w, impHigh)).isLessThan(male.getBone(w, impLow))
assertThat(male.getFat(w, impHigh)).isGreaterThan(male.getFat(w, impLow))
assertThat(female.getWater(w, impHigh)).isLessThan(female.getWater(w, impLow))
assertThat(female.getMuscle(w, impHigh)).isLessThan(female.getMuscle(w, impLow))
assertThat(female.getBone(w, impHigh)).isLessThan(female.getBone(w, impLow))
assertThat(female.getFat(w, impHigh)).isGreaterThan(female.getFat(w, impLow))
}
@Test
fun sex_flag_changes_branch_outputs() {
val male = TrisaBodyAnalyzeLib(1, 30, 175f)
val female = TrisaBodyAnalyzeLib(0, 30, 175f)
val w = 70f
val imp = 500f
assertThat(male.getWater(w, imp)).isNotEqualTo(female.getWater(w, imp))
assertThat(male.getFat(w, imp)).isNotEqualTo(female.getFat(w, imp))
assertThat(male.getMuscle(w, imp)).isNotEqualTo(female.getMuscle(w, imp))
assertThat(male.getBone(w, imp)).isNotEqualTo(female.getBone(w, imp))
}
@Test
fun outputs_areFinite_forTypicalInputs() {
val lib = TrisaBodyAnalyzeLib(1, 30, 180f)
val w = 80f
val imp = 500f
val nums = listOf(
lib.getBMI(w),
lib.getWater(w, imp),
lib.getFat(w, imp),
lib.getMuscle(w, imp),
lib.getBone(w, imp)
)
nums.forEach { v ->
assertThat(v.isNaN()).isFalse()
assertThat(v.isInfinite()).isFalse()
}
}
// --- Regression fixtures -------------------------------------------------
@Test
fun regression_male_30y_180cm_80kg_imp500() {
val lib = TrisaBodyAnalyzeLib(1, 30, 180f)
val w = 80f
val imp = 500f
val r = Fixture(
bmi = 24.691359f,
water = 57.031845f,
fat = 23.186619f,
muscle = 40.767307f,
bone = 4.254889f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_female_28y_165cm_60kg_imp520() {
val lib = TrisaBodyAnalyzeLib(0, 28, 165f)
val w = 60f
val imp = 520f
val r = Fixture(
bmi = 22.038567f,
water = 51.246567f,
fat = 27.63467f,
muscle = 32.776436f,
bone = 4.575968f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_male_45y_175cm_95kg_imp430() {
val lib = TrisaBodyAnalyzeLib(1, 45, 175f)
val w = 95f
val imp = 430f
val r = Fixture(
bmi = 31.020409f,
water = 51.385693f,
fat = 34.484245f,
muscle = 30.524948f,
bone = 3.1716952f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_female_55y_160cm_50kg_imp600() {
val lib = TrisaBodyAnalyzeLib(0, 55, 160f)
val w = 50f
val imp = 600f
val r = Fixture(
bmi = 19.53125f,
water = 55.407524f,
fat = 26.659752f,
muscle = 27.356312f,
bone = 3.8092093f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_male_20y_190cm_65kg_imp480() {
val lib = TrisaBodyAnalyzeLib(1, 20, 190f)
val w = 65f
val imp = 480f
val r = Fixture(
bmi = 18.00554f,
water = 64.203964f,
fat = 10.668964f,
muscle = 49.972504f,
bone = 5.2273664f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_female_22y_155cm_55kg_imp510() {
val lib = TrisaBodyAnalyzeLib(0, 22, 155f)
val w = 55f
val imp = 510f
val r = Fixture(
bmi = 22.89282f,
water = 49.936302f,
fat = 28.405312f,
muscle = 33.747982f,
bone = 4.713689f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_male_35y_175cm_85kg_imp200() {
val lib = TrisaBodyAnalyzeLib(1, 35, 175f)
val w = 85f
val imp = 200f
val r = Fixture(
bmi = 27.755102f,
water = 56.290474f,
fat = 25.228241f,
muscle = 38.142612f,
bone = 3.9760387f
)
checkFixture(lib, w, imp, r)
}
@Test
fun regression_female_40y_170cm_70kg_imp800() {
val lib = TrisaBodyAnalyzeLib(0, 40, 170f)
val w = 70f
val imp = 800f
val r = Fixture(
bmi = 24.221453f,
water = 47.909973f,
fat = 35.216103f,
muscle = 27.238312f,
bone = 3.7960525f
)
checkFixture(lib, w, imp, r)
}
// --- Helper --------------------------------------------------------------
private fun checkFixture(lib: TrisaBodyAnalyzeLib, w: Float, imp: Float, r: Fixture) {
assertThat(lib.getBMI(w)).isWithin(EPS).of(r.bmi)
assertThat(lib.getWater(w, imp)).isWithin(EPS).of(r.water)
assertThat(lib.getFat(w, imp)).isWithin(EPS).of(r.fat)
assertThat(lib.getMuscle(w, imp)).isWithin(EPS).of(r.muscle)
assertThat(lib.getBone(w, imp)).isWithin(EPS).of(r.bone)
}
private data class Fixture(
val bmi: Float,
val water: Float,
val fat: Float,
val muscle: Float,
val bone: Float
)
}

View File

@@ -0,0 +1,194 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs
import com.google.common.truth.Truth.assertThat
import com.health.openscale.core.data.ActivityLevel
import org.junit.Test
class YunmaiLibTest {
private val EPS = 1e-3f
// --- Behavior (kept from earlier) ---------------------------------------
@Test
fun toYunmaiActivityLevel_mapsCorrectly() {
assertThat(YunmaiLib.toYunmaiActivityLevel(ActivityLevel.HEAVY)).isEqualTo(1)
assertThat(YunmaiLib.toYunmaiActivityLevel(ActivityLevel.EXTREME)).isEqualTo(1)
assertThat(YunmaiLib.toYunmaiActivityLevel(ActivityLevel.SEDENTARY)).isEqualTo(0)
assertThat(YunmaiLib.toYunmaiActivityLevel(ActivityLevel.MILD)).isEqualTo(0)
assertThat(YunmaiLib.toYunmaiActivityLevel(ActivityLevel.MODERATE)).isEqualTo(0)
}
@Test
fun constructor_setsFitnessFlag_indirectlyVisibleInMuscleValues() {
val fit = YunmaiLib(1, 180f, ActivityLevel.EXTREME)
val normal = YunmaiLib(1, 180f, ActivityLevel.MILD)
val bf = 20f
assertThat(fit.getMuscle(bf)).isGreaterThan(normal.getMuscle(bf))
assertThat(fit.getSkeletalMuscle(bf)).isGreaterThan(normal.getSkeletalMuscle(bf))
}
// --- Regression fixtures -------------------------------------------------
@Test
fun regression_male_mod_30y_180cm_80kg_res500_bf23() {
val lib = YunmaiLib(1, 180f, ActivityLevel.MODERATE)
val age = 30; val w = 80f; val res = 500; val bf = 23f
val r = Fx(55.907001f, 23.237043f, 51.595001f, 40.814999f, 3.263390f, 61.599998f, 11.318182f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_female_mild_28y_165cm_60kg_res520_bf28() {
val lib = YunmaiLib(0, 165f, ActivityLevel.MILD)
val age = 28; val w = 60f; val res = 520; val bf = 28f
val r = Fx(52.276997f, 29.947247f, 48.244999f, 38.164993f, 2.530795f, 43.200001f, 6.166667f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_male_sedentary_55y_175cm_95kg_res430_bf32() {
val lib = YunmaiLib(1, 175f, ActivityLevel.SEDENTARY)
val age = 55; val w = 95f; val res = 430; val bf = 32f
val r = Fx(49.372997f, 34.547203f, 45.564999f, 36.044998f, 3.365057f, 64.599998f, 18.590908f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_female_sedentary_55y_160cm_50kg_res600_bf27() {
val lib = YunmaiLib(0, 160f, ActivityLevel.SEDENTARY)
val age = 55; val w = 50f; val res = 600; val bf = 27f
val r = Fx(53.003002f, 28.532946f, 48.915001f, 38.694996f, 2.088284f, 36.500000f, 5.055555f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_male_heavy_20y_190cm_72kg_res480_bf14() {
val lib = YunmaiLib(1, 190f, ActivityLevel.HEAVY)
val age = 20; val w = 72f; val res = 480; val bf = 14f
val r = Fx(62.441002f, 15.266259f, 60.205002f, 51.605000f, 3.519648f, 61.919998f, 9.000000f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_female_mod_22y_155cm_55kg_res510_bf29() {
val lib = YunmaiLib(0, 155f, ActivityLevel.MODERATE)
val age = 22; val w = 55f; val res = 510; val bf = 29f
val r = Fx(51.551003f, 30.724077f, 47.575001f, 37.634998f, 2.187678f, 39.049999f, 6.722222f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_male_mild_35y_175cm_85kg_res200_bf25() {
val lib = YunmaiLib(1, 175f, ActivityLevel.MILD)
val age = 35; val w = 85f; val res = 200; val bf = 25f
val r = Fx(54.455002f, 27.232653f, 50.255001f, 39.754993f, 3.322063f, 63.750000f, 13.136364f)
checkAll(lib, age, w, res, bf, r)
}
@Test
fun regression_female_sedentary_40y_170cm_70kg_res800_bf36() {
val lib = YunmaiLib(0, 170f, ActivityLevel.SEDENTARY)
val age = 40; val w = 70f; val res = 800; val bf = 36f
val r = Fx(46.468998f, 34.777931f, 42.884998f, 33.924999f, 2.674562f, 44.799999f, 10.409091f)
checkAll(lib, age, w, res, bf, r)
}
// --- Fixture dump helper (run manually) ----------------------------------
private data class DumpIn(
val sex: Int,
val heightCm: Float,
val activity: ActivityLevel,
val age: Int,
val weightKg: Float,
val resistance: Int,
val bodyFatPct: Float
)
private fun dump(fi: DumpIn) {
val lib = YunmaiLib(fi.sex, fi.heightCm, fi.activity)
val water = lib.getWater(fi.bodyFatPct)
val fat = lib.getFat(fi.age, fi.weightKg, fi.resistance)
val muscle = lib.getMuscle(fi.bodyFatPct)
val skeletal = lib.getSkeletalMuscle(fi.bodyFatPct)
val bone = lib.getBoneMass(muscle, fi.weightKg)
val lbm = lib.getLeanBodyMass(fi.weightKg, fi.bodyFatPct)
val vfat = lib.getVisceralFat(fi.bodyFatPct, fi.age)
println(
"""
// sex=${fi.sex}, height=${fi.heightCm}cm, act=${fi.activity}, age=${fi.age}, weight=${fi.weightKg}kg, res=${fi.resistance}, bf=${fi.bodyFatPct}%
Fixture(
water = ${"%.6f".format(water)}f,
fat = ${"%.6f".format(fat)}f,
muscle = ${"%.6f".format(muscle)}f,
skeletal = ${"%.6f".format(skeletal)}f,
bone = ${"%.6f".format(bone)}f,
lbm = ${"%.6f".format(lbm)}f,
visceralFat = ${"%.6f".format(vfat)}f
)
""".trimIndent()
)
}
@Test
fun dump_allFixtures() {
listOf(
DumpIn(1, 180f, ActivityLevel.MODERATE, 30, 80f, 500, 23f),
DumpIn(0, 165f, ActivityLevel.MILD, 28, 60f, 520, 28f),
DumpIn(1, 175f, ActivityLevel.SEDENTARY, 55, 95f, 430, 32f),
DumpIn(0, 160f, ActivityLevel.SEDENTARY, 55, 50f, 600, 27f),
DumpIn(1, 190f, ActivityLevel.HEAVY, 20, 72f, 480, 14f),
DumpIn(0, 155f, ActivityLevel.MODERATE, 22, 55f, 510, 29f),
DumpIn(1, 175f, ActivityLevel.MILD, 35, 85f, 200, 25f),
DumpIn(0, 170f, ActivityLevel.SEDENTARY, 40, 70f, 800, 36f)
).forEach(::dump)
}
// --- Helpers -------------------------------------------------------------
private data class Fx(
val water: Float,
val fat: Float,
val muscle: Float,
val skeletal: Float,
val bone: Float,
val lbm: Float,
val visceralFat: Float
)
private fun checkAll(
lib: YunmaiLib,
age: Int,
w: Float,
res: Int,
bf: Float,
r: Fx
) {
assertThat(lib.getWater(bf)).isWithin(EPS).of(r.water)
assertThat(lib.getFat(age, w, res)).isWithin(EPS).of(r.fat)
assertThat(lib.getMuscle(bf)).isWithin(EPS).of(r.muscle)
assertThat(lib.getSkeletalMuscle(bf)).isWithin(EPS).of(r.skeletal)
assertThat(lib.getBoneMass(r.muscle, w)).isWithin(EPS).of(r.bone)
assertThat(lib.getLeanBodyMass(w, bf)).isWithin(EPS).of(r.lbm)
assertThat(lib.getVisceralFat(bf, age)).isWithin(EPS).of(r.visceralFat)
}
}

View File

@@ -23,8 +23,10 @@ compose-material = "1.7.8"
constraintlayout-compose = "1.1.1"
glance = "1.1.1"
kotlinCsv = "1.10.0"
blessedKotlin = "3.0.9"
blessedKotlin = "3.0.10"
blessedJava = "2.5.2"
truth = "1.4.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -66,6 +68,7 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material
kotlin-csv-jvm = { group = "com.github.doyaaaaaken", name = "kotlin-csv-jvm", version.ref = "kotlinCsv" }
blessed-kotlin = { group = "com.github.weliem", name = "blessed-kotlin", version.ref = "blessedKotlin" }
blessed-java = { group = "com.github.weliem", name = "blessed-android", version.ref = "blessedJava" }
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
[plugins]