From 22a4c40401cbe2044293ba4d340cf8d45f2a3516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Mon, 26 Jan 2026 21:16:16 +0000 Subject: [PATCH] Add distinct VO2 Max ranges per age / gender --- .../activities/charts/VO2MaxFragment.java | 27 ++----- .../activities/charts/VO2MaxRanges.kt | 78 +++++++++++++++++++ .../AbstractDashboardVO2MaxWidget.java | 42 +++------- .../gadgetbridge/model/ActivityUser.java | 6 +- 4 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxRanges.kt diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java index ce48a29fd2..e4d6be332b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java @@ -41,6 +41,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Calendar; @@ -58,6 +60,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; @@ -180,25 +183,26 @@ public class VO2MaxFragment extends AbstractChartFragment lineDataSets = new ArrayList<>(); if (supportsVO2MultiSport(device)) { // Running VO2MaxRecord latestRunningRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.RUNNING); - float runningVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestRunningRecord != null ? latestRunningRecord.value : 0); + float runningVO2MaxValue = VO2MaxRanges.INSTANCE.calculateVO2MaxPercentile(latestRunningRecord != null ? latestRunningRecord.value : 0, age, activityUser.getGender()); vo2MaxRunningValue.setText(String.valueOf(latestRunningRecord != null ? Math.round(latestRunningRecord.value) : "-")); gaugeDrawer.drawSegmentedGauge(vo2MaxRunningGauge, colors, segments, runningVO2MaxValue, false, true); lineDataSets.add(createDataSet(runningEntries, getResources().getColor(R.color.vo2max_running_char_line_color), getString(R.string.vo2max_running))); // Cycling VO2MaxRecord latestCyclingRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.CYCLING); - float cyclingVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestCyclingRecord != null ? latestCyclingRecord.value : 0); + float cyclingVO2MaxValue = VO2MaxRanges.INSTANCE.calculateVO2MaxPercentile(latestCyclingRecord != null ? latestCyclingRecord.value : 0, age, activityUser.getGender()); gaugeDrawer.drawSegmentedGauge(vo2MaxCyclingGauge, colors, segments, cyclingVO2MaxValue, false, true); vo2MaxCyclingValue.setText(String.valueOf(latestCyclingRecord != null ? Math.round(latestCyclingRecord.value) : "-")); lineDataSets.add(createDataSet(cyclingEntries, getResources().getColor(R.color.vo2max_cycling_char_line_color), getString(R.string.vo2max_cycling))); } else { VO2MaxRecord latestRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.ANY); - float vO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestRecord != null ? latestRecord.value : 0); + float vO2MaxValue = VO2MaxRanges.INSTANCE.calculateVO2MaxPercentile(latestRecord != null ? latestRecord.value : 0, age, activityUser.getGender()); gaugeDrawer.drawSegmentedGauge(vo2MaxGauge, colors, segments, vO2MaxValue, false, true); vo2MaxValue.setText(String.valueOf(latestRecord != null ? Math.round(latestRecord.value) : "-")); lineDataSets.add(createDataSet(allEntries, getResources().getColor(R.color.vo2max_running_char_line_color), getString(R.string.menuitem_vo2_max))); @@ -220,21 +224,6 @@ public class VO2MaxFragment extends AbstractChartFragment 0) { - float rangeValue = i - 1 >= 0 ? vo2MaxRanges[i-1] : 60F; - float rangeDiff = rangeValue - vo2MaxRanges[i]; - float valueDiff = vo2MaxValue - vo2MaxRanges[i]; - float multiplayer = valueDiff / rangeDiff; - value = (4 - i) * 0.2F + 0.2F * (multiplayer > 1 ? 1 : multiplayer) ; - break; - } - } - return value; - } - protected LineDataSet createDataSet(final List values, int color, String label) { final LineDataSet lineDataSet = new LineDataSet(values, label); lineDataSet.setColor(color); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxRanges.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxRanges.kt new file mode 100644 index 0000000000..b1e93b20e5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxRanges.kt @@ -0,0 +1,78 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.charts + +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser + +object VO2MaxRanges { + // Percentile boundaries: superior (95-100), excellent (80-95), good (60-80), fair (40-60), poor (0-40) + val PERCENTILES = floatArrayOf(0.95f, 0.80f, 0.60f, 0.40f, 0.0f) + + fun calculateVO2MaxPercentile(vo2MaxValue: Float, age: Int, gender: Int): Float { + val ranges = getVO2MaxRanges(age, gender) + + // Determine which band the value falls into (0=superior, 1=excellent, 2=good, 3=fair, 4=poor) + val bandIndex = when { + vo2MaxValue >= ranges[0] -> 0 + vo2MaxValue >= ranges[1] -> 1 + vo2MaxValue >= ranges[2] -> 2 + vo2MaxValue >= ranges[3] -> 3 + else -> 4 + } + + // Get the upper and lower bounds for this band + val upperBound = if (bandIndex == 0) { + // Superior band. Highest recorded VO2 Max + 97.5f + } else { + ranges[bandIndex - 1] + } + val lowerBound = ranges[bandIndex] + + // Scale the percentile within the band + val positionInBand = if (upperBound > lowerBound) { + ((vo2MaxValue - lowerBound) / (upperBound - lowerBound)).coerceIn(0f, 1f) + } else { + 0.5f + } + + return PERCENTILES[bandIndex] + (positionInBand * (PERCENTILES[bandIndex - 1] - PERCENTILES[bandIndex])) + } + + /** + * Returns a 5-element float array with VO2 max lower bounds (inclusive) for each level. The levels are ordered from highest to + * lowest: superior, excellent, good, fair, poor. + */ + fun getVO2MaxRanges(age: Int, gender: Int): FloatArray { + return when (gender) { + ActivityUser.GENDER_MALE -> when { + age <= 29 -> floatArrayOf(55.4f, 51.1f, 45.4f, 41.7f, 0f) + age in 30..39 -> floatArrayOf(54f, 48.3f, 44f, 40.5f, 0f) + age in 40..49 -> floatArrayOf(52.5f, 46.4f, 42.4f, 38.5f, 0f) + age in 50..59 -> floatArrayOf(48.9f, 43.4f, 39.2f, 35.6f, 0f) + age in 60..69 -> floatArrayOf(45.7f, 39.5f, 35.5f, 32.3f, 0f) + else -> floatArrayOf(42.1f, 36.7f, 32.3f, 29.4f, 0f) + } + + ActivityUser.GENDER_FEMALE -> when { + age <= 29 -> floatArrayOf(49.6f, 43.9f, 39.5f, 36.1f, 0f) + age in 30..39 -> floatArrayOf(47.4f, 42.4f, 37.8f, 34.4f, 0f) + age in 40..49 -> floatArrayOf(45.3f, 39.7f, 36.3f, 33f, 0f) + age in 50..59 -> floatArrayOf(41.1f, 36.7f, 33f, 30.1f, 0f) + age in 60..69 -> floatArrayOf(37.8f, 33f, 30f, 27.5f, 0f) + else -> floatArrayOf(36.7f, 30.9f, 28.1f, 25.9f, 0f) + } + + else -> { + // Average them (?) + val rangesMale = getVO2MaxRanges(age, ActivityUser.GENDER_MALE) + val rangesFemale = getVO2MaxRanges(age, ActivityUser.GENDER_FEMALE) + floatArrayOf( + (rangesMale[0] + rangesFemale[0]) / 2f, + (rangesMale[1] + rangesFemale[1]) / 2f, + (rangesMale[2] + rangesFemale[2]) / 2f, + (rangesMale[3] + rangesFemale[3]) / 2f, + (rangesMale[4] + rangesFemale[4]) / 2f, + ) + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java index d8b6ea7484..60542094fc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardVO2MaxWidget.java @@ -7,14 +7,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Serializable; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment; +import nodomain.freeyourgadget.gadgetbridge.activities.charts.VO2MaxRanges; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; public abstract class AbstractDashboardVO2MaxWidget extends AbstractGaugeWidget implements DashboardVO2MaxWidgetInterface { @@ -62,22 +67,13 @@ public abstract class AbstractDashboardVO2MaxWidget extends AbstractGaugeWidget } public static float[] getSegments() { + // Should match the percentiles in VO2MaxRanges return new float[] { + 0.40F, 0.20F, 0.20F, - 0.20F, - 0.20F, - 0.20F, - }; - } - - public static float[] getVO2MaxRanges() { - return new float[] { - 55.4F, - 51.1F, - 45.4F, - 41.7F, - 0.0F, + 0.15F, + 0.05F, }; } @@ -91,8 +87,9 @@ public abstract class AbstractDashboardVO2MaxWidget extends AbstractGaugeWidget final int[] colors = getColors(); final float[] segments = getSegments(); - final float[] vo2MaxRanges = getVO2MaxRanges(); - float vo2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, vo2MaxData.value != -1 ? vo2MaxData.value : 0); + final ActivityUser activityUser = new ActivityUser(); + final int age = activityUser.getAgeAt(LocalDate.ofInstant(Instant.ofEpochSecond(dashboardData.timeTo), ZoneId.systemDefault())); + float vo2MaxValue = VO2MaxRanges.INSTANCE.calculateVO2MaxPercentile(vo2MaxData.value != -1 ? vo2MaxData.value : 0, age, activityUser.getGender()); setText(String.valueOf(vo2MaxData.value != -1 ? Math.round(vo2MaxData.value) : "-")); drawSegmentedGauge( colors, @@ -103,21 +100,6 @@ public abstract class AbstractDashboardVO2MaxWidget extends AbstractGaugeWidget ); } - private float calculateVO2maxGaugeValue(float[] vo2MaxRanges, float vo2MaxValue) { - float value = -1; - for (int i = 0; i < vo2MaxRanges.length; i++) { - if (vo2MaxValue - vo2MaxRanges[i] > 0) { - float rangeValue = i - 1 >= 0 ? vo2MaxRanges[i-1] : 60F; - float rangeDiff = rangeValue - vo2MaxRanges[i]; - float valueDiff = vo2MaxValue - vo2MaxRanges[i]; - float multiplayer = valueDiff / rangeDiff; - value = (4 - i) * 0.2F + 0.2F * (multiplayer > 1 ? 1 : multiplayer) ; - break; - } - } - return value; - } - private static class VO2MaxData implements Serializable { private float value = -1; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java index 5ef0b7f5aa..6b6da25daf 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityUser.java @@ -147,7 +147,11 @@ public class ActivityUser { } public int getAge() { - return Period.between(getDateOfBirth(), LocalDate.now()).getYears(); + return getAgeAt(LocalDate.now()); + } + + public int getAgeAt(final LocalDate when) { + return Period.between(getDateOfBirth(), when).getYears(); } private void fetchPreferences() {