Add desktop body analytics
This commit is contained in:
parent
e2604131d4
commit
60af5e6147
13 changed files with 2118 additions and 8 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -100,3 +100,19 @@ Added:
|
||||||
- measured-max regression coverage.
|
- measured-max regression coverage.
|
||||||
|
|
||||||
No estimated 1RM, schema v6, or frozen Trainlog JSON v1 change was introduced.
|
No estimated 1RM, schema v6, or frozen Trainlog JSON v1 change was introduced.
|
||||||
|
|
||||||
|
### Body analytics v1
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- desktop-only body analytics view;
|
||||||
|
- local height/formula estimation profile;
|
||||||
|
- circumference-based body-fat estimate;
|
||||||
|
- estimated fat and lean mass when real weight is available;
|
||||||
|
- waist/hip, shoulder/waist, and chest/waist ratios;
|
||||||
|
- arm, forearm, thigh, and calf left/right asymmetry percentages;
|
||||||
|
- weight, waist, and estimated-body-fat trend deltas;
|
||||||
|
- body analytics regression test.
|
||||||
|
|
||||||
|
Estimated analytics remain derived display values and are never persisted as
|
||||||
|
direct measurements. Android remains capture-only for this feature.
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -29,7 +29,7 @@ ANDROID_TRIGGERED_SYNC=PASS
|
||||||
ANDROID_SYNC_RECEIPT=PASS
|
ANDROID_SYNC_RECEIPT=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=20/20 PASS
|
DESKTOP_TESTS=21/21 PASS
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -207,3 +207,24 @@ MEASURED_MAX_V1=PASS
|
||||||
WORKING_LOAD_PERCENTAGES=PASS
|
WORKING_LOAD_PERCENTAGES=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=PASS
|
ANDROID_MAX_TEST_SESSION=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Body analytics
|
||||||
|
|
||||||
|
Body analytics are desktop-only. Android remains a capture client.
|
||||||
|
|
||||||
|
The TUI derives descriptive ratios, left/right asymmetry, and an optional
|
||||||
|
circumference-based body-fat estimate from real body observations.
|
||||||
|
|
||||||
|
The estimate requires a local desktop-only analytics profile containing the
|
||||||
|
formula branch and height. Estimated fat mass and lean mass are calculated only
|
||||||
|
when a real body weight is present.
|
||||||
|
|
||||||
|
Estimated values are never persisted as direct measurements.
|
||||||
|
|
||||||
|
```text
|
||||||
|
BODY_ANALYTICS_V1=PASS
|
||||||
|
BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
|
DESKTOP_TESTS=21/21 PASS
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -285,3 +285,34 @@ persistence / MTP transport
|
||||||
Rendering does not own persistence rules.
|
Rendering does not own persistence rules.
|
||||||
|
|
||||||
Persistence and MTP code do not depend on ncurses rendering.
|
Persistence and MTP code do not depend on ncurses rendering.
|
||||||
|
|
||||||
|
## 12. Body analytics boundary
|
||||||
|
|
||||||
|
Body analytics belong to the desktop analysis layer.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Android
|
||||||
|
real measurements only
|
||||||
|
|
|
||||||
|
v
|
||||||
|
desktop body_observations
|
||||||
|
|
|
||||||
|
v
|
||||||
|
pure derived analytics
|
||||||
|
|
|
||||||
|
v
|
||||||
|
TUI display
|
||||||
|
```
|
||||||
|
|
||||||
|
The analytics layer does not modify canonical observations.
|
||||||
|
|
||||||
|
A desktop-only configuration file stores the estimation profile:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$XDG_CONFIG_HOME/trainlog/body_analytics.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
with `~/.config` fallback.
|
||||||
|
|
||||||
|
This profile is not synchronized to Android and does not require a SQLite
|
||||||
|
schema change.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ ANDROID_SYNC_RECEIPT=PASS
|
||||||
TUI_SYNC_LOG_SHOW=PASS
|
TUI_SYNC_LOG_SHOW=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=20/20 PASS
|
DESKTOP_TESTS=21/21 PASS
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=PASS
|
HARDWARE_SYNC_VALIDATION=PASS
|
||||||
```
|
```
|
||||||
|
|
@ -120,7 +120,7 @@ No mounted Android filesystem is required.
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20/20 Meson tests PASS
|
21/21 Meson tests PASS
|
||||||
frozen JSON validator PASS
|
frozen JSON validator PASS
|
||||||
import-contract validator PASS
|
import-contract validator PASS
|
||||||
git diff --check PASS
|
git diff --check PASS
|
||||||
|
|
@ -156,7 +156,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
|
||||||
WORKING_LOAD_PERCENTAGES=PASS
|
WORKING_LOAD_PERCENTAGES=PASS
|
||||||
ASSISTANCE_DIRECTION_AWARE=PASS
|
ASSISTANCE_DIRECTION_AWARE=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=PASS
|
ANDROID_MAX_TEST_SESSION=PASS
|
||||||
DESKTOP_TESTS=20/20 PASS
|
DESKTOP_TESTS=21/21 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
A measured maximum is derived only from explicit `max_test` sessions. Ordinary
|
A measured maximum is derived only from explicit `max_test` sessions. Ordinary
|
||||||
|
|
@ -167,3 +167,24 @@ record compares max tests using the same load mode.
|
||||||
|
|
||||||
External-load working percentages are pure calculations from the current
|
External-load working percentages are pure calculations from the current
|
||||||
measured load; they are not persisted and no estimated 1RM is introduced.
|
measured load; they are not persisted and no estimated 1RM is introduced.
|
||||||
|
|
||||||
|
## Body analytics v1
|
||||||
|
|
||||||
|
```text
|
||||||
|
BODY_ANALYTICS_V1=PASS
|
||||||
|
BODY_ANALYTICS_TUI_ONLY=PASS
|
||||||
|
BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
|
NO_ESTIMATE_PERSISTENCE=PASS
|
||||||
|
DESKTOP_TESTS=21/21 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Android remains capture-only for this feature.
|
||||||
|
|
||||||
|
The TUI derives analytics from canonical body observations. A local
|
||||||
|
desktop-only profile provides height and the circumference-formula branch
|
||||||
|
needed for the optional body-fat estimate.
|
||||||
|
|
||||||
|
No estimated body-fat, fat-mass, lean-mass, ratio, or asymmetry value is stored
|
||||||
|
as if it were a real measurement.
|
||||||
|
|
|
||||||
|
|
@ -311,3 +311,25 @@ The current measured result is the newest successful max-test point. The record
|
||||||
is the best max-test point using the same load mode.
|
is the best max-test point using the same load mode.
|
||||||
|
|
||||||
No extra maximum row is persisted; results are derived from canonical history.
|
No extra maximum row is persisted; results are derived from canonical history.
|
||||||
|
|
||||||
|
## 12. Body analytics persistence rule
|
||||||
|
|
||||||
|
Body analytics require no schema v6.
|
||||||
|
|
||||||
|
Canonical persistence continues to contain only measurements actually entered
|
||||||
|
by the user.
|
||||||
|
|
||||||
|
These values remain derived at display time and are not persisted:
|
||||||
|
|
||||||
|
```text
|
||||||
|
body-fat estimate
|
||||||
|
estimated fat mass
|
||||||
|
estimated lean mass
|
||||||
|
waist/hip ratio
|
||||||
|
shoulder/waist ratio
|
||||||
|
chest/waist ratio
|
||||||
|
left/right asymmetry percentages
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional estimation profile is desktop configuration, not database
|
||||||
|
history.
|
||||||
|
|
|
||||||
|
|
@ -181,3 +181,22 @@ The next product feature remains intentionally unfrozen:
|
||||||
```text
|
```text
|
||||||
NEXT_FEATURE=UNFROZEN
|
NEXT_FEATURE=UNFROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Body analytics v1
|
||||||
|
|
||||||
|
```text
|
||||||
|
BODY_ANALYTICS_V1=PASS
|
||||||
|
BODY_ANALYTICS_TUI_ONLY=PASS
|
||||||
|
BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
|
NO_ESTIMATE_PERSISTENCE=PASS
|
||||||
|
DESKTOP_TESTS=21/21 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
No Android feature, database schema migration, or frozen JSON v1 change was
|
||||||
|
required.
|
||||||
|
|
||||||
|
```text
|
||||||
|
NEXT_FEATURE=UNFROZEN
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ Current normal suite:
|
||||||
Validated checkpoint:
|
Validated checkpoint:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20/20 PASS
|
21/21 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Notable regression coverage:
|
Notable regression coverage:
|
||||||
|
|
@ -238,5 +238,30 @@ Coverage proves:
|
||||||
Current normal baseline:
|
Current normal baseline:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20/20 PASS
|
21/21 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Body analytics regression
|
||||||
|
|
||||||
|
The Meson suite adds:
|
||||||
|
|
||||||
|
```text
|
||||||
|
body_analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage includes:
|
||||||
|
|
||||||
|
- male circumference-formula branch;
|
||||||
|
- female circumference-formula branch;
|
||||||
|
- estimated fat and lean mass from real body weight;
|
||||||
|
- waist/hip, shoulder/waist, and chest/waist ratios;
|
||||||
|
- left/right asymmetry;
|
||||||
|
- profile-independent analytics without a configured estimation profile;
|
||||||
|
- missing required circumference handling;
|
||||||
|
- invalid estimation-profile rejection.
|
||||||
|
|
||||||
|
Current normal baseline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
21/21 PASS
|
||||||
```
|
```
|
||||||
|
|
|
||||||
37
docs/tui.md
37
docs/tui.md
|
|
@ -304,3 +304,40 @@ less assistance = better
|
||||||
|
|
||||||
No percentage-of-max working load is produced for assistance or no-load
|
No percentage-of-max working load is produced for assistance or no-load
|
||||||
performance.
|
performance.
|
||||||
|
|
||||||
|
## 16. Body analytics
|
||||||
|
|
||||||
|
`4 Corps` adds:
|
||||||
|
|
||||||
|
```text
|
||||||
|
v analyse corporelle
|
||||||
|
```
|
||||||
|
|
||||||
|
The analytics view has two pages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Composition et tendance
|
||||||
|
Proportions et symétrie
|
||||||
|
```
|
||||||
|
|
||||||
|
`p` configures a desktop-only estimation profile containing height and the
|
||||||
|
circumference-formula branch.
|
||||||
|
|
||||||
|
Composition can display:
|
||||||
|
|
||||||
|
- circumference-based body-fat estimate;
|
||||||
|
- estimated fat mass when body weight is present;
|
||||||
|
- estimated lean mass when body weight is present;
|
||||||
|
- weight change from the oldest available weight;
|
||||||
|
- waist change from the oldest available waist;
|
||||||
|
- body-fat estimate change when comparable observations exist.
|
||||||
|
|
||||||
|
Proportions can display:
|
||||||
|
|
||||||
|
- waist/hip ratio;
|
||||||
|
- shoulder/waist ratio;
|
||||||
|
- chest/waist ratio;
|
||||||
|
- left/right asymmetry for arms, forearms, thighs, and calves.
|
||||||
|
|
||||||
|
All estimates are explicitly labeled as estimates. No result is converted into
|
||||||
|
a medical or diagnostic classification.
|
||||||
|
|
|
||||||
76
tui/include/trainlog/body_analytics.h
Normal file
76
tui/include/trainlog/body_analytics.h
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#ifndef TRAINLOG_BODY_ANALYTICS_H
|
||||||
|
#define TRAINLOG_BODY_ANALYTICS_H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file body_analytics.h
|
||||||
|
* @brief Derived body-composition and proportion analytics.
|
||||||
|
*
|
||||||
|
* Analytics are display-time estimates derived from real body observations.
|
||||||
|
* No estimated value is persisted as if it were a direct measurement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
|
typedef enum TrainlogBodyAnalyticsFormula {
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE = 0,
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||||||
|
} TrainlogBodyAnalyticsFormula;
|
||||||
|
|
||||||
|
typedef struct TrainlogBodyAnalyticsProfile {
|
||||||
|
TrainlogBodyAnalyticsFormula formula;
|
||||||
|
double height_cm;
|
||||||
|
} TrainlogBodyAnalyticsProfile;
|
||||||
|
|
||||||
|
typedef struct TrainlogBodyAnalyticsResult {
|
||||||
|
bool has_body_fat_estimate;
|
||||||
|
double body_fat_percent;
|
||||||
|
|
||||||
|
bool has_fat_mass_estimate;
|
||||||
|
double fat_mass_kg;
|
||||||
|
|
||||||
|
bool has_lean_mass_estimate;
|
||||||
|
double lean_mass_kg;
|
||||||
|
|
||||||
|
bool has_waist_hip_ratio;
|
||||||
|
double waist_hip_ratio;
|
||||||
|
|
||||||
|
bool has_shoulder_waist_ratio;
|
||||||
|
double shoulder_waist_ratio;
|
||||||
|
|
||||||
|
bool has_chest_waist_ratio;
|
||||||
|
double chest_waist_ratio;
|
||||||
|
|
||||||
|
bool has_arm_asymmetry;
|
||||||
|
double arm_asymmetry_percent;
|
||||||
|
|
||||||
|
bool has_forearm_asymmetry;
|
||||||
|
double forearm_asymmetry_percent;
|
||||||
|
|
||||||
|
bool has_thigh_asymmetry;
|
||||||
|
double thigh_asymmetry_percent;
|
||||||
|
|
||||||
|
bool has_calf_asymmetry;
|
||||||
|
double calf_asymmetry_percent;
|
||||||
|
} TrainlogBodyAnalyticsResult;
|
||||||
|
|
||||||
|
bool trainlog_body_analytics_profile_valid(
|
||||||
|
const TrainlogBodyAnalyticsProfile *profile
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Calculate derived analytics from one real observation.
|
||||||
|
*
|
||||||
|
* If profile is NULL, profile-independent ratios and asymmetries are still
|
||||||
|
* calculated. Body-fat estimation uses the circumference-based U.S. Navy
|
||||||
|
* equations and is exposed strictly as an estimate.
|
||||||
|
*/
|
||||||
|
TrainlogStatus trainlog_body_analytics_calculate(
|
||||||
|
const TrainlogBodyAnalyticsProfile *profile,
|
||||||
|
const TrainlogBodyObservationRecord *record,
|
||||||
|
TrainlogBodyAnalyticsResult *output
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -28,6 +28,7 @@ strict_c_args = [
|
||||||
|
|
||||||
trainlog_core_sources = files(
|
trainlog_core_sources = files(
|
||||||
'src/bodyviz.c',
|
'src/bodyviz.c',
|
||||||
|
'src/body_analytics.c',
|
||||||
'src/catalog.c',
|
'src/catalog.c',
|
||||||
'src/database.c',
|
'src/database.c',
|
||||||
'src/duration.c',
|
'src/duration.c',
|
||||||
|
|
@ -354,3 +355,15 @@ test(
|
||||||
'measured_max',
|
'measured_max',
|
||||||
test_measured_max,
|
test_measured_max,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_body_analytics = executable(
|
||||||
|
'test_body_analytics',
|
||||||
|
'tests/test_body_analytics.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'body_analytics',
|
||||||
|
test_body_analytics,
|
||||||
|
)
|
||||||
|
|
|
||||||
389
tui/src/body_analytics.c
Normal file
389
tui/src/body_analytics.c
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
/**
|
||||||
|
* @file body_analytics.c
|
||||||
|
* @brief Derived body analytics implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "trainlog/body_analytics.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define CM_PER_INCH 2.54
|
||||||
|
|
||||||
|
static bool positive_finite(
|
||||||
|
double value
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
isfinite(value) &&
|
||||||
|
value > 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool body_ratio(
|
||||||
|
double numerator,
|
||||||
|
double denominator,
|
||||||
|
double *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
output == NULL ||
|
||||||
|
!positive_finite(numerator) ||
|
||||||
|
!positive_finite(denominator)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output =
|
||||||
|
numerator /
|
||||||
|
denominator;
|
||||||
|
|
||||||
|
return isfinite(*output);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool body_asymmetry(
|
||||||
|
double left,
|
||||||
|
double right,
|
||||||
|
double *output_percent
|
||||||
|
)
|
||||||
|
{
|
||||||
|
double average;
|
||||||
|
|
||||||
|
if (
|
||||||
|
output_percent == NULL ||
|
||||||
|
!positive_finite(left) ||
|
||||||
|
!positive_finite(right)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
average =
|
||||||
|
(left + right) /
|
||||||
|
2.0;
|
||||||
|
|
||||||
|
if (!positive_finite(average)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_percent =
|
||||||
|
fabs(left - right) /
|
||||||
|
average *
|
||||||
|
100.0;
|
||||||
|
|
||||||
|
return
|
||||||
|
isfinite(
|
||||||
|
*output_percent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool trainlog_body_analytics_profile_valid(
|
||||||
|
const TrainlogBodyAnalyticsProfile *profile
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (profile == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
profile->formula !=
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE &&
|
||||||
|
profile->formula !=
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
positive_finite(
|
||||||
|
profile->height_cm
|
||||||
|
) &&
|
||||||
|
profile->height_cm >= 100.0 &&
|
||||||
|
profile->height_cm <= 250.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool body_fat_estimate(
|
||||||
|
const TrainlogBodyAnalyticsProfile *profile,
|
||||||
|
const TrainlogBodyObservationRecord *record,
|
||||||
|
double *output_percent
|
||||||
|
)
|
||||||
|
{
|
||||||
|
double height_in;
|
||||||
|
double neck_in;
|
||||||
|
double waist_in;
|
||||||
|
double body_fat;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!trainlog_body_analytics_profile_valid(
|
||||||
|
profile
|
||||||
|
) ||
|
||||||
|
record == NULL ||
|
||||||
|
output_percent == NULL ||
|
||||||
|
!record->has_neck ||
|
||||||
|
!record->has_waist ||
|
||||||
|
!positive_finite(
|
||||||
|
record->neck_cm
|
||||||
|
) ||
|
||||||
|
!positive_finite(
|
||||||
|
record->waist_cm
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
height_in =
|
||||||
|
profile->height_cm /
|
||||||
|
CM_PER_INCH;
|
||||||
|
|
||||||
|
neck_in =
|
||||||
|
record->neck_cm /
|
||||||
|
CM_PER_INCH;
|
||||||
|
|
||||||
|
waist_in =
|
||||||
|
record->waist_cm /
|
||||||
|
CM_PER_INCH;
|
||||||
|
|
||||||
|
if (
|
||||||
|
profile->formula ==
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE
|
||||||
|
) {
|
||||||
|
double circumference =
|
||||||
|
waist_in -
|
||||||
|
neck_in;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!positive_finite(
|
||||||
|
circumference
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
body_fat =
|
||||||
|
86.010 *
|
||||||
|
log10(
|
||||||
|
circumference
|
||||||
|
) -
|
||||||
|
70.041 *
|
||||||
|
log10(
|
||||||
|
height_in
|
||||||
|
) +
|
||||||
|
36.76;
|
||||||
|
} else {
|
||||||
|
double hips_in;
|
||||||
|
double circumference;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!record->has_hips ||
|
||||||
|
!positive_finite(
|
||||||
|
record->hips_cm
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hips_in =
|
||||||
|
record->hips_cm /
|
||||||
|
CM_PER_INCH;
|
||||||
|
|
||||||
|
circumference =
|
||||||
|
waist_in +
|
||||||
|
hips_in -
|
||||||
|
neck_in;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!positive_finite(
|
||||||
|
circumference
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
body_fat =
|
||||||
|
163.205 *
|
||||||
|
log10(
|
||||||
|
circumference
|
||||||
|
) -
|
||||||
|
97.684 *
|
||||||
|
log10(
|
||||||
|
height_in
|
||||||
|
) -
|
||||||
|
78.387;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isfinite(body_fat) ||
|
||||||
|
body_fat <= 0.0 ||
|
||||||
|
body_fat >= 80.0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_percent =
|
||||||
|
body_fat;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_body_analytics_calculate(
|
||||||
|
const TrainlogBodyAnalyticsProfile *profile,
|
||||||
|
const TrainlogBodyObservationRecord *record,
|
||||||
|
TrainlogBodyAnalyticsResult *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
record == NULL ||
|
||||||
|
output == NULL ||
|
||||||
|
(
|
||||||
|
profile != NULL &&
|
||||||
|
!trainlog_body_analytics_profile_valid(
|
||||||
|
profile
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
output,
|
||||||
|
0,
|
||||||
|
sizeof(*output)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
profile != NULL &&
|
||||||
|
body_fat_estimate(
|
||||||
|
profile,
|
||||||
|
record,
|
||||||
|
&output->body_fat_percent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_body_fat_estimate =
|
||||||
|
true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_body_weight &&
|
||||||
|
positive_finite(
|
||||||
|
record->body_weight_kg
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->fat_mass_kg =
|
||||||
|
record->body_weight_kg *
|
||||||
|
output->body_fat_percent /
|
||||||
|
100.0;
|
||||||
|
|
||||||
|
output->lean_mass_kg =
|
||||||
|
record->body_weight_kg -
|
||||||
|
output->fat_mass_kg;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isfinite(
|
||||||
|
output->fat_mass_kg
|
||||||
|
) &&
|
||||||
|
isfinite(
|
||||||
|
output->lean_mass_kg
|
||||||
|
) &&
|
||||||
|
output->fat_mass_kg > 0.0 &&
|
||||||
|
output->lean_mass_kg > 0.0
|
||||||
|
) {
|
||||||
|
output->has_fat_mass_estimate =
|
||||||
|
true;
|
||||||
|
|
||||||
|
output->has_lean_mass_estimate =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_waist &&
|
||||||
|
record->has_hips &&
|
||||||
|
body_ratio(
|
||||||
|
record->waist_cm,
|
||||||
|
record->hips_cm,
|
||||||
|
&output->waist_hip_ratio
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_waist_hip_ratio =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_shoulders &&
|
||||||
|
record->has_waist &&
|
||||||
|
body_ratio(
|
||||||
|
record->shoulders_cm,
|
||||||
|
record->waist_cm,
|
||||||
|
&output->shoulder_waist_ratio
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_shoulder_waist_ratio =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_chest &&
|
||||||
|
record->has_waist &&
|
||||||
|
body_ratio(
|
||||||
|
record->chest_cm,
|
||||||
|
record->waist_cm,
|
||||||
|
&output->chest_waist_ratio
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_chest_waist_ratio =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_left_arm &&
|
||||||
|
record->has_right_arm &&
|
||||||
|
body_asymmetry(
|
||||||
|
record->left_arm_cm,
|
||||||
|
record->right_arm_cm,
|
||||||
|
&output->arm_asymmetry_percent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_arm_asymmetry =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_left_forearm &&
|
||||||
|
record->has_right_forearm &&
|
||||||
|
body_asymmetry(
|
||||||
|
record->left_forearm_cm,
|
||||||
|
record->right_forearm_cm,
|
||||||
|
&output->forearm_asymmetry_percent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_forearm_asymmetry =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_left_thigh &&
|
||||||
|
record->has_right_thigh &&
|
||||||
|
body_asymmetry(
|
||||||
|
record->left_thigh_cm,
|
||||||
|
record->right_thigh_cm,
|
||||||
|
&output->thigh_asymmetry_percent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_thigh_asymmetry =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record->has_left_calf &&
|
||||||
|
record->has_right_calf &&
|
||||||
|
body_asymmetry(
|
||||||
|
record->left_calf_cm,
|
||||||
|
record->right_calf_cm,
|
||||||
|
&output->calf_asymmetry_percent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
output->has_calf_asymmetry =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
1081
tui/src/tui.c
1081
tui/src/tui.c
File diff suppressed because it is too large
Load diff
363
tui/tests/test_body_analytics.c
Normal file
363
tui/tests/test_body_analytics.c
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
/**
|
||||||
|
* @file test_body_analytics.c
|
||||||
|
* @brief Derived body analytics regression tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "trainlog/body_analytics.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf( \
|
||||||
|
stderr, \
|
||||||
|
"CHECK failed at %s:%d: %s\n", \
|
||||||
|
__FILE__, \
|
||||||
|
__LINE__, \
|
||||||
|
#condition \
|
||||||
|
); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool near(
|
||||||
|
double value,
|
||||||
|
double expected,
|
||||||
|
double tolerance
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
fabs(
|
||||||
|
value -
|
||||||
|
expected
|
||||||
|
) <= tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogBodyObservationRecord
|
||||||
|
male_record(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyObservationRecord record;
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
&record,
|
||||||
|
0,
|
||||||
|
sizeof(record)
|
||||||
|
);
|
||||||
|
|
||||||
|
record.has_body_weight = true;
|
||||||
|
record.body_weight_kg = 80.0;
|
||||||
|
|
||||||
|
record.has_neck = true;
|
||||||
|
record.neck_cm = 40.0;
|
||||||
|
|
||||||
|
record.has_shoulders = true;
|
||||||
|
record.shoulders_cm = 120.0;
|
||||||
|
|
||||||
|
record.has_chest = true;
|
||||||
|
record.chest_cm = 100.0;
|
||||||
|
|
||||||
|
record.has_waist = true;
|
||||||
|
record.waist_cm = 90.0;
|
||||||
|
|
||||||
|
record.has_hips = true;
|
||||||
|
record.hips_cm = 100.0;
|
||||||
|
|
||||||
|
record.has_left_arm = true;
|
||||||
|
record.left_arm_cm = 35.0;
|
||||||
|
record.has_right_arm = true;
|
||||||
|
record.right_arm_cm = 36.0;
|
||||||
|
|
||||||
|
record.has_left_forearm = true;
|
||||||
|
record.left_forearm_cm = 29.0;
|
||||||
|
record.has_right_forearm = true;
|
||||||
|
record.right_forearm_cm = 29.5;
|
||||||
|
|
||||||
|
record.has_left_thigh = true;
|
||||||
|
record.left_thigh_cm = 58.0;
|
||||||
|
record.has_right_thigh = true;
|
||||||
|
record.right_thigh_cm = 58.0;
|
||||||
|
|
||||||
|
record.has_left_calf = true;
|
||||||
|
record.left_calf_cm = 39.0;
|
||||||
|
record.has_right_calf = true;
|
||||||
|
record.right_calf_cm = 40.0;
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_male_estimate(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyAnalyticsProfile profile;
|
||||||
|
TrainlogBodyObservationRecord record =
|
||||||
|
male_record();
|
||||||
|
|
||||||
|
TrainlogBodyAnalyticsResult result;
|
||||||
|
|
||||||
|
profile.formula =
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
|
||||||
|
|
||||||
|
profile.height_cm = 180.0;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_body_analytics_calculate(
|
||||||
|
&profile,
|
||||||
|
&record,
|
||||||
|
&result
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_body_fat_estimate
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
near(
|
||||||
|
result.body_fat_percent,
|
||||||
|
18.4621,
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_fat_mass_estimate
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
near(
|
||||||
|
result.fat_mass_kg,
|
||||||
|
14.7697,
|
||||||
|
0.02
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_lean_mass_estimate
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
near(
|
||||||
|
result.lean_mass_kg,
|
||||||
|
65.2303,
|
||||||
|
0.02
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_waist_hip_ratio &&
|
||||||
|
near(
|
||||||
|
result.waist_hip_ratio,
|
||||||
|
0.9,
|
||||||
|
0.0001
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_shoulder_waist_ratio &&
|
||||||
|
near(
|
||||||
|
result.shoulder_waist_ratio,
|
||||||
|
1.333333,
|
||||||
|
0.0001
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_chest_waist_ratio &&
|
||||||
|
near(
|
||||||
|
result.chest_waist_ratio,
|
||||||
|
1.111111,
|
||||||
|
0.0001
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_arm_asymmetry &&
|
||||||
|
near(
|
||||||
|
result.arm_asymmetry_percent,
|
||||||
|
2.8169,
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_thigh_asymmetry &&
|
||||||
|
near(
|
||||||
|
result.thigh_asymmetry_percent,
|
||||||
|
0.0,
|
||||||
|
0.0001
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_female_estimate(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyAnalyticsProfile profile;
|
||||||
|
TrainlogBodyObservationRecord record;
|
||||||
|
|
||||||
|
TrainlogBodyAnalyticsResult result;
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
&record,
|
||||||
|
0,
|
||||||
|
sizeof(record)
|
||||||
|
);
|
||||||
|
|
||||||
|
profile.formula =
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE;
|
||||||
|
|
||||||
|
profile.height_cm = 165.0;
|
||||||
|
|
||||||
|
record.has_body_weight = true;
|
||||||
|
record.body_weight_kg = 65.0;
|
||||||
|
|
||||||
|
record.has_neck = true;
|
||||||
|
record.neck_cm = 34.0;
|
||||||
|
|
||||||
|
record.has_waist = true;
|
||||||
|
record.waist_cm = 75.0;
|
||||||
|
|
||||||
|
record.has_hips = true;
|
||||||
|
record.hips_cm = 100.0;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_body_analytics_calculate(
|
||||||
|
&profile,
|
||||||
|
&record,
|
||||||
|
&result
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_body_fat_estimate
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
near(
|
||||||
|
result.body_fat_percent,
|
||||||
|
29.2385,
|
||||||
|
0.01
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_profile_independent_metrics(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyObservationRecord record =
|
||||||
|
male_record();
|
||||||
|
|
||||||
|
TrainlogBodyAnalyticsResult result;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_body_analytics_calculate(
|
||||||
|
NULL,
|
||||||
|
&record,
|
||||||
|
&result
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
!result.has_body_fat_estimate
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_waist_hip_ratio
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
result.has_arm_asymmetry
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_missing_required_measurement(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyAnalyticsProfile profile;
|
||||||
|
TrainlogBodyObservationRecord record =
|
||||||
|
male_record();
|
||||||
|
|
||||||
|
TrainlogBodyAnalyticsResult result;
|
||||||
|
|
||||||
|
profile.formula =
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
|
||||||
|
|
||||||
|
profile.height_cm = 180.0;
|
||||||
|
|
||||||
|
record.has_neck = false;
|
||||||
|
record.neck_cm = 0.0;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_body_analytics_calculate(
|
||||||
|
&profile,
|
||||||
|
&record,
|
||||||
|
&result
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
!result.has_body_fat_estimate
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_invalid_profile(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyAnalyticsProfile profile;
|
||||||
|
TrainlogBodyObservationRecord record =
|
||||||
|
male_record();
|
||||||
|
|
||||||
|
TrainlogBodyAnalyticsResult result;
|
||||||
|
|
||||||
|
profile.formula =
|
||||||
|
TRAINLOG_BODY_ANALYTICS_FORMULA_MALE;
|
||||||
|
|
||||||
|
profile.height_cm = 0.0;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_body_analytics_calculate(
|
||||||
|
&profile,
|
||||||
|
&record,
|
||||||
|
&result
|
||||||
|
) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
CHECK(
|
||||||
|
test_male_estimate()
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
test_female_estimate()
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
test_profile_independent_metrics()
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
test_missing_required_measurement()
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
test_invalid_profile()
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)printf(
|
||||||
|
"PASS body_analytics\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue