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:
@@ -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)
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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]
|
||||
|
Reference in New Issue
Block a user