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.
|
||||
|
||||
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
|
||||
BIDIRECTIONAL_SYNC_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=20/20 PASS
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
ANDROID_BUILD=PASS
|
||||
```
|
||||
|
||||
|
|
@ -207,3 +207,24 @@ MEASURED_MAX_V1=PASS
|
|||
WORKING_LOAD_PERCENTAGES=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.
|
||||
|
||||
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
|
||||
BIDIRECTIONAL_SYNC_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=20/20 PASS
|
||||
DESKTOP_TESTS=21/21 PASS
|
||||
ANDROID_BUILD=PASS
|
||||
HARDWARE_SYNC_VALIDATION=PASS
|
||||
```
|
||||
|
|
@ -120,7 +120,7 @@ No mounted Android filesystem is required.
|
|||
Desktop:
|
||||
|
||||
```text
|
||||
20/20 Meson tests PASS
|
||||
21/21 Meson tests PASS
|
||||
frozen JSON validator PASS
|
||||
import-contract validator PASS
|
||||
git diff --check PASS
|
||||
|
|
@ -156,7 +156,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
|
|||
WORKING_LOAD_PERCENTAGES=PASS
|
||||
ASSISTANCE_DIRECTION_AWARE=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
|
||||
|
|
@ -167,3 +167,24 @@ record compares max tests using the same load mode.
|
|||
|
||||
External-load working percentages are pure calculations from the current
|
||||
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.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```text
|
||||
20/20 PASS
|
||||
21/21 PASS
|
||||
```
|
||||
|
||||
Notable regression coverage:
|
||||
|
|
@ -238,5 +238,30 @@ Coverage proves:
|
|||
Current normal baseline:
|
||||
|
||||
```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
|
||||
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(
|
||||
'src/bodyviz.c',
|
||||
'src/body_analytics.c',
|
||||
'src/catalog.c',
|
||||
'src/database.c',
|
||||
'src/duration.c',
|
||||
|
|
@ -354,3 +355,15 @@ 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