Add desktop body analytics

This commit is contained in:
fy59 2026-09-06 20:18:31 +02:00
parent e2604131d4
commit 60af5e6147
13 changed files with 2118 additions and 8 deletions

View file

@ -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.

View file

@ -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
```

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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
```

View file

@ -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
``` ```

View file

@ -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.

View 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

View file

@ -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
View 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;
}

File diff suppressed because it is too large Load diff

View 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;
}