diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index f1b8b200..4687ee39 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.kt index c47c97f2..bbfb75a6 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.kt @@ -1,196 +1,154 @@ -/* Copyright (C) 2019 olie.xdev +/* + * openScale + * Copyright (C) 2025 olie.xdev * - * 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 + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ - /** * 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 } } - diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneLib.kt index 334dd68f..cf42ee26 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneLib.kt @@ -1,253 +1,246 @@ -/* Copyright (C) 2018 olie.xdev +/* + * openScale + * Copyright (C) 2025 olie.xdev * - * 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 + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ +package com.health.openscale.core.bluetooth.libs -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 } } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLib.kt index a7ec7608..fa8cd68d 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLib.kt @@ -1,201 +1,207 @@ -package com.health.openscale.core.bluetooth.libs; +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs // 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 } - } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/SoehnleLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/SoehnleLib.kt index 08ec333a..2447d131 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/SoehnleLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/SoehnleLib.kt @@ -1,147 +1,126 @@ -/* Copyright (C) 2019 olie.xdev +/* + * openScale + * Copyright (C) 2025 olie.xdev * - * 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 + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ -package com.health.openscale.core.bluetooth.libs; +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 } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLib.kt index 818c57d5..e5e76214 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLib.kt @@ -1,77 +1,78 @@ -/* Copyright (C) 2018 Maks Verver - * 2019 olie.xdev +/* + * openScale + * Copyright (C) 2018 Maks Verver + * 2025 olie.xdev * - * 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 + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ -package com.health.openscale.core.bluetooth.libs; +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 } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.kt index a08092f6..868e1d03 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/YunmaiLib.kt @@ -15,158 +15,157 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.health.openscale.core.bluetooth.libs; +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; } } } diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/MiScaleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/MiScaleLibTest.kt new file mode 100644 index 00000000..c8c845d6 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/MiScaleLibTest.kt @@ -0,0 +1,214 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import 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 + ) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OneByoneLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OneByoneLibTest.kt new file mode 100644 index 00000000..0b352425 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OneByoneLibTest.kt @@ -0,0 +1,263 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import com.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) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLibTest.kt new file mode 100644 index 00000000..2ebf1720 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/OneByoneNewLibTest.kt @@ -0,0 +1,298 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import com.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 = 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) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/SoehnleLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/SoehnleLibTest.kt new file mode 100644 index 00000000..bfd06a52 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/SoehnleLibTest.kt @@ -0,0 +1,144 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.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 = 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) + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLibTest.kt new file mode 100644 index 00000000..da863bf5 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/TrisaBodyAnalyzeLibTest.kt @@ -0,0 +1,256 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import 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 + ) +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/YunmaiLibTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/YunmaiLibTest.kt new file mode 100644 index 00000000..19efb2c1 --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/libs/YunmaiLibTest.kt @@ -0,0 +1,194 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.libs + +import com.google.common.truth.Truth.assertThat +import com.health.openscale.core.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) + } +} diff --git a/android_app/gradle/libs.versions.toml b/android_app/gradle/libs.versions.toml index 997b0feb..598f0318 100644 --- a/android_app/gradle/libs.versions.toml +++ b/android_app/gradle/libs.versions.toml @@ -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]