From 60af5e6147700da444f40d4bf47a2c45ee8d69fd Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 6 Sep 2026 20:18:31 +0200 Subject: [PATCH] Add desktop body analytics --- CHANGELOG.md | 16 + README.md | 23 +- docs/architecture.md | 31 + docs/current_state.md | 27 +- docs/database.md | 22 + docs/roadmap.md | 19 + docs/tests.md | 29 +- docs/tui.md | 37 + tui/include/trainlog/body_analytics.h | 76 ++ tui/meson.build | 13 + tui/src/body_analytics.c | 389 +++++++++ tui/src/tui.c | 1081 ++++++++++++++++++++++++- tui/tests/test_body_analytics.c | 363 +++++++++ 13 files changed, 2118 insertions(+), 8 deletions(-) create mode 100644 tui/include/trainlog/body_analytics.h create mode 100644 tui/src/body_analytics.c create mode 100644 tui/tests/test_body_analytics.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c846b0..f49e2b7 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/README.md b/README.md index bebc3f8..3b0e839 100644 --- a/README.md +++ b/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 +``` diff --git a/docs/architecture.md b/docs/architecture.md index 1415d5c..4bce0c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/current_state.md b/docs/current_state.md index dfd0450..f231327 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/database.md b/docs/database.md index 8bdcd0e..75262b5 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index bd53fa6..051b3f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 +``` diff --git a/docs/tests.md b/docs/tests.md index 809cfbb..7cb95e7 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 ``` diff --git a/docs/tui.md b/docs/tui.md index acbe6ce..7f32e48 100644 --- a/docs/tui.md +++ b/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. diff --git a/tui/include/trainlog/body_analytics.h b/tui/include/trainlog/body_analytics.h new file mode 100644 index 0000000..03eda97 --- /dev/null +++ b/tui/include/trainlog/body_analytics.h @@ -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 + +#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 diff --git a/tui/meson.build b/tui/meson.build index ba27b51..3ac2209 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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, +) diff --git a/tui/src/body_analytics.c b/tui/src/body_analytics.c new file mode 100644 index 0000000..59cc5b0 --- /dev/null +++ b/tui/src/body_analytics.c @@ -0,0 +1,389 @@ +/** + * @file body_analytics.c + * @brief Derived body analytics implementation. + */ + +#include "trainlog/body_analytics.h" + +#include +#include + +#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; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index 6c2bbe9..bbdecb4 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include "trainlog/bodyviz.h" +#include "trainlog/body_analytics.h" #include "trainlog/catalog.h" #include "trainlog/duration.h" #include "trainlog/id.h" @@ -8733,6 +8735,1072 @@ static void screen_body_observation_detail( } } +/* TRAINLOG_BODY_ANALYTICS_TUI_V1 */ + +static bool body_analytics_profile_path( + char *output, + size_t output_size, + bool ensure_directory +) +{ + const char *config_home = + getenv( + "XDG_CONFIG_HOME" + ); + + const char *home = + getenv( + "HOME" + ); + + char base[ + PATH_MAX + 1U + ]; + + char directory[ + PATH_MAX + 1U + ]; + + int written; + + if ( + output == NULL || + output_size == 0U + ) { + return false; + } + + if ( + config_home != NULL && + config_home[0] != '\0' + ) { + written = + snprintf( + base, + sizeof(base), + "%s", + config_home + ); + } else if ( + home != NULL && + home[0] != '\0' + ) { + written = + snprintf( + base, + sizeof(base), + "%s/.config", + home + ); + + if ( + written >= 0 && + (size_t)written < + sizeof(base) && + ensure_directory + ) { + (void)mkdir( + base, + 0700 + ); + } + } else { + return false; + } + + if ( + written < 0 || + (size_t)written >= + sizeof(base) + ) { + return false; + } + + written = + snprintf( + directory, + sizeof(directory), + "%s/trainlog", + base + ); + + if ( + written < 0 || + (size_t)written >= + sizeof(directory) + ) { + return false; + } + + if (ensure_directory) { + (void)mkdir( + directory, + 0700 + ); + } + + written = + snprintf( + output, + output_size, + "%s/body_analytics.conf", + directory + ); + + return + written >= 0 && + (size_t)written < + output_size; +} + +static bool body_analytics_profile_load( + TrainlogBodyAnalyticsProfile *output +) +{ + char path[ + PATH_MAX + 1U + ]; + + char line[128]; + int version = 0; + int formula = -1; + double height_cm = 0.0; + FILE *file; + + if ( + output == NULL || + !body_analytics_profile_path( + path, + sizeof(path), + false + ) + ) { + return false; + } + + file = + fopen( + path, + "rb" + ); + + if (file == NULL) { + return false; + } + + while ( + fgets( + line, + sizeof(line), + file + ) != NULL + ) { + if ( + sscanf( + line, + "version=%d", + &version + ) == 1 + ) { + continue; + } + + if ( + strcmp( + line, + "formula=male\n" + ) == 0 || + strcmp( + line, + "formula=male" + ) == 0 + ) { + formula = + (int) + TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; + + continue; + } + + if ( + strcmp( + line, + "formula=female\n" + ) == 0 || + strcmp( + line, + "formula=female" + ) == 0 + ) { + formula = + (int) + TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE; + + continue; + } + + (void)sscanf( + line, + "height_cm=%lf", + &height_cm + ); + } + + (void)fclose( + file + ); + + if ( + version != 1 || + formula < 0 + ) { + return false; + } + + output->formula = + (TrainlogBodyAnalyticsFormula) + formula; + + output->height_cm = + height_cm; + + return + trainlog_body_analytics_profile_valid( + output + ); +} + +static bool body_analytics_profile_save( + const TrainlogBodyAnalyticsProfile *profile +) +{ + char path[ + PATH_MAX + 1U + ]; + + FILE *file; + + if ( + !trainlog_body_analytics_profile_valid( + profile + ) || + !body_analytics_profile_path( + path, + sizeof(path), + true + ) + ) { + return false; + } + + file = + fopen( + path, + "wb" + ); + + if (file == NULL) { + return false; + } + + if ( + fprintf( + file, + "version=1\n" + "formula=%s\n" + "height_cm=%.2f\n", + profile->formula == + TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + ? "female" + : "male", + profile->height_cm + ) < 0 + ) { + (void)fclose( + file + ); + + return false; + } + + return + fclose( + file + ) == 0; +} + +static bool body_analytics_profile_prompt( + TrainlogBodyAnalyticsProfile *profile +) +{ + char height_text[64]; + int formula; + double height_cm; + + if (profile == NULL) { + return false; + } + + formula = + trainlog_body_analytics_profile_valid( + profile + ) + ? ( + profile->formula == + TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + ? 2 + : 1 + ) + : 1; + + height_cm = + trainlog_body_analytics_profile_valid( + profile + ) + ? profile->height_cm + : 170.0; + + draw_shell( + "TRAINLOG — Profil d'estimation", + "Entrée valide · Échap annule" + ); + + if ( + !prompt_int_value( + 4, + "Formule 1=homme 2=femme", + 1, + 2, + formula, + &formula + ) + ) { + return false; + } + + for (;;) { + char label[128]; + + (void)snprintf( + label, + sizeof(label), + "Taille cm [%.1f] : ", + height_cm + ); + + if ( + !prompt_text( + 6, + label, + height_text, + sizeof(height_text), + true + ) + ) { + return false; + } + + if ( + height_text[0] == '\0' + ) { + break; + } + + if ( + parse_double_positive( + height_text, + &height_cm + ) && + height_cm >= 100.0 && + height_cm <= 250.0 + ) { + break; + } + + status_line( + "Taille attendue entre 100 et 250 cm.", + TRAINLOG_COLOR_ERROR + ); + + refresh(); + } + + profile->formula = + formula == 2 + ? TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + : TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; + + profile->height_cm = + height_cm; + + return + body_analytics_profile_save( + profile + ); +} + +static const TrainlogBodyObservationRecord * +body_analytics_oldest_weight( + const TrainlogBodyObservationRecord *records, + size_t count +) +{ + size_t index; + + if ( + records == NULL || + count == 0U + ) { + return NULL; + } + + for ( + index = count; + index > 0U; + --index + ) { + if ( + records[index - 1U] + .has_body_weight + ) { + return + &records[index - 1U]; + } + } + + return NULL; +} + +static const TrainlogBodyObservationRecord * +body_analytics_oldest_waist( + const TrainlogBodyObservationRecord *records, + size_t count +) +{ + size_t index; + + if ( + records == NULL || + count == 0U + ) { + return NULL; + } + + for ( + index = count; + index > 0U; + --index + ) { + if ( + records[index - 1U] + .has_waist + ) { + return + &records[index - 1U]; + } + } + + return NULL; +} + +static bool body_analytics_oldest_estimate( + const TrainlogBodyAnalyticsProfile *profile, + const TrainlogBodyObservationRecord *records, + size_t count, + TrainlogBodyAnalyticsResult *output +) +{ + size_t index; + + if ( + profile == NULL || + records == NULL || + output == NULL + ) { + return false; + } + + for ( + index = count; + index > 0U; + --index + ) { + TrainlogBodyAnalyticsResult + candidate; + + if ( + trainlog_body_analytics_calculate( + profile, + &records[index - 1U], + &candidate + ) == + TRAINLOG_STATUS_OK && + candidate + .has_body_fat_estimate + ) { + *output = + candidate; + + return true; + } + } + + return false; +} + +static void body_analytics_value( + int row, + const char *label, + bool present, + double value, + const char *suffix +) +{ + if (present) { + mvprintw( + row, + 6, + "%-25s %8.2f %s", + label, + value, + suffix != NULL + ? suffix + : "" + ); + } else { + mvprintw( + row, + 6, + "%-25s %8s", + label, + "—" + ); + } +} + +static void screen_body_analytics( + TrainlogDatabase *database +) +{ + TrainlogBodyObservationRecord + records[MAX_BODY_OBSERVATIONS]; + + size_t count = 0U; + int page = 0; + + TrainlogBodyAnalyticsProfile profile; + + bool has_profile = + body_analytics_profile_load( + &profile + ); + + if ( + database == NULL || + trainlog_database_list_body_observations( + database, + records, + MAX_BODY_OBSERVATIONS, + &count + ) != TRAINLOG_STATUS_OK + ) { + return; + } + + for (;;) { + TrainlogBodyAnalyticsResult + current; + + TrainlogBodyAnalyticsResult + oldest_estimate; + + const TrainlogBodyObservationRecord *latest = + count > 0U + ? &records[0] + : NULL; + + const TrainlogBodyObservationRecord *oldest_weight = + body_analytics_oldest_weight( + records, + count + ); + + const TrainlogBodyObservationRecord *oldest_waist = + body_analytics_oldest_waist( + records, + count + ); + + bool has_current = + latest != NULL && + trainlog_body_analytics_calculate( + has_profile + ? &profile + : NULL, + latest, + ¤t + ) == + TRAINLOG_STATUS_OK; + + bool has_oldest_estimate = + has_profile && + body_analytics_oldest_estimate( + &profile, + records, + count, + &oldest_estimate + ); + + bool decorated = + COLS >= 100 && + LINES >= 30; + + int panel_top = + decorated ? 8 : 3; + + int panel_bottom = + LINES - 4; + + int key; + + erase(); + box( + stdscr, + 0, + 0 + ); + + if (decorated) { + section_ascii_header( + ":: A N A L Y S E C O R P O R E L L E ::" + ); + + focused_panel( + panel_top, + 2, + panel_bottom, + COLS - 3, + page == 0 + ? "COMPOSITION ET TENDANCE" + : "PROPORTIONS ET SYMETRIE", + true + ); + } else { + draw_shell( + "TRAINLOG — Analyse corporelle", + "←→ page p profil b/Échap retour" + ); + + body_panel( + panel_top, + 2, + panel_bottom, + COLS - 3, + page == 0 + ? "COMPOSITION" + : "PROPORTIONS" + ); + } + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + LINES - 2, + 2, + "%.*s", + COLS - 4, + "←→ page p profil estimation b/Échap retour" + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + if (count == 0U) { + mvprintw( + panel_top + 2, + 6, + "Aucun relevé corporel." + ); + + mvprintw( + panel_top + 4, + 6, + "Ajoutez d'abord un relevé réel." + ); + } else if (page == 0) { + char date[9]; + + body_short_date( + latest->observed_at, + date + ); + + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + mvprintw( + panel_top + 2, + 6, + "Dernier relevé : %s", + date + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + + if (has_profile) { + mvprintw( + panel_top + 4, + 6, + "Profil estimation : %s · %.1f cm", + profile.formula == + TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + ? "formule femme" + : "formule homme", + profile.height_cm + ); + } else { + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + panel_top + 4, + 6, + "Profil estimation non configuré · p pour configurer." + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + } + + body_analytics_value( + panel_top + 6, + "Graisse estimée", + has_current && + current + .has_body_fat_estimate, + has_current + ? current + .body_fat_percent + : 0.0, + "%" + ); + + body_analytics_value( + panel_top + 7, + "Masse grasse estimée", + has_current && + current + .has_fat_mass_estimate, + has_current + ? current + .fat_mass_kg + : 0.0, + "kg" + ); + + body_analytics_value( + panel_top + 8, + "Masse maigre estimée", + has_current && + current + .has_lean_mass_estimate, + has_current + ? current + .lean_mass_kg + : 0.0, + "kg" + ); + + if ( + latest->has_body_weight + ) { + double delta = + oldest_weight != NULL + ? latest->body_weight_kg - + oldest_weight + ->body_weight_kg + : 0.0; + + mvprintw( + panel_top + 10, + 6, + "Poids : %.1f kg · variation depuis 1er poids : %+.1f kg", + latest->body_weight_kg, + delta + ); + } else { + mvprintw( + panel_top + 10, + 6, + "Poids : —" + ); + } + + if ( + latest->has_waist + ) { + double delta = + oldest_waist != NULL + ? latest->waist_cm - + oldest_waist + ->waist_cm + : 0.0; + + mvprintw( + panel_top + 11, + 6, + "Tour de taille : %.1f cm · variation : %+.1f cm", + latest->waist_cm, + delta + ); + } else { + mvprintw( + panel_top + 11, + 6, + "Tour de taille : —" + ); + } + + if ( + has_current && + current + .has_body_fat_estimate && + has_oldest_estimate + ) { + mvprintw( + panel_top + 12, + 6, + "Variation graisse estimée : %+.2f point(s)", + current + .body_fat_percent - + oldest_estimate + .body_fat_percent + ); + } + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + panel_top + 14, + 6, + "%.*s", + COLS - 14, + "Estimation anthropométrique : tendance utile, pas mesure directe de composition." + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + } else { + body_analytics_value( + panel_top + 3, + "Taille / hanches", + has_current && + current + .has_waist_hip_ratio, + has_current + ? current + .waist_hip_ratio + : 0.0, + "" + ); + + body_analytics_value( + panel_top + 4, + "Épaules / taille", + has_current && + current + .has_shoulder_waist_ratio, + has_current + ? current + .shoulder_waist_ratio + : 0.0, + "" + ); + + body_analytics_value( + panel_top + 5, + "Poitrine / taille", + has_current && + current + .has_chest_waist_ratio, + has_current + ? current + .chest_waist_ratio + : 0.0, + "" + ); + + mvprintw( + panel_top + 8, + 6, + "ASYMETRIE GAUCHE / DROITE" + ); + + body_analytics_value( + panel_top + 10, + "Bras", + has_current && + current + .has_arm_asymmetry, + has_current + ? current + .arm_asymmetry_percent + : 0.0, + "%" + ); + + body_analytics_value( + panel_top + 11, + "Avant-bras", + has_current && + current + .has_forearm_asymmetry, + has_current + ? current + .forearm_asymmetry_percent + : 0.0, + "%" + ); + + body_analytics_value( + panel_top + 12, + "Cuisses", + has_current && + current + .has_thigh_asymmetry, + has_current + ? current + .thigh_asymmetry_percent + : 0.0, + "%" + ); + + body_analytics_value( + panel_top + 13, + "Mollets", + has_current && + current + .has_calf_asymmetry, + has_current + ? current + .calf_asymmetry_percent + : 0.0, + "%" + ); + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + panel_top + 16, + 6, + "%.*s", + COLS - 14, + "Ratios et asymétries sont descriptifs : Trainlog ne les transforme pas en diagnostic." + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + } + + refresh(); + key = getch(); + + if ( + key == 'b' || + key == 'B' || + key == 27 + ) { + return; + } + + if ( + key == KEY_LEFT || + key == KEY_RIGHT + ) { + page = + page == 0 + ? 1 + : 0; + + continue; + } + + if ( + key == 'p' || + key == 'P' + ) { + TrainlogBodyAnalyticsProfile + edited; + + if (has_profile) { + edited = + profile; + } else { + edited.formula = + TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; + + edited.height_cm = + 170.0; + } + + if ( + body_analytics_profile_prompt( + &edited + ) + ) { + profile = + edited; + + has_profile = true; + + status_line( + "✓ Profil d'estimation enregistré localement.", + TRAINLOG_COLOR_SUCCESS + ); + + refresh(); + (void)getch(); + } + } + } +} + static void screen_body( TrainlogDatabase *database ) @@ -8839,7 +9907,7 @@ static void screen_body( 2, "%.*s", COLS - 4, - "Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter g vue globale 0/Home accueil F1-F4 direct" + "Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter v analyse g vue globale 0/Home accueil F1-F4 direct" ); attroff( @@ -8850,7 +9918,7 @@ static void screen_body( } else { draw_shell( "TRAINLOG — Corps", - "↑↓ choisir Entrée détail e Modifier a ajouter g vue globale b/Échap retour" + "↑↓ choisir Entrée détail e Modifier a ajouter v analyse g vue globale b/Échap retour" ); } @@ -9096,6 +10164,15 @@ static void screen_body( continue; } + if (key == 'v' || + key == 'V') { + screen_body_analytics( + database + ); + + continue; + } + if (key == 'g' || key == 'G') { draw_shell( diff --git a/tui/tests/test_body_analytics.c b/tui/tests/test_body_analytics.c new file mode 100644 index 0000000..c55f15b --- /dev/null +++ b/tui/tests/test_body_analytics.c @@ -0,0 +1,363 @@ +/** + * @file test_body_analytics.c + * @brief Derived body analytics regression tests. + */ + +#include +#include +#include +#include + +#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; +}