@dgpholdings/greatoak-shared 1.2.16 → 1.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +3 -1
- package/dist/utils/scoring/calculateCalories.d.ts +67 -0
- package/dist/utils/scoring/calculateCalories.js +329 -0
- package/dist/utils/scoring/calculateMuscleFatiue.d.ts +66 -0
- package/dist/utils/scoring/calculateMuscleFatiue.js +290 -0
- package/dist/utils/scoring/calculateQualityScore.d.ts +69 -0
- package/dist/utils/scoring/calculateQualityScore.js +333 -0
- package/dist/utils/scoring/constants.d.ts +191 -0
- package/dist/utils/scoring/constants.js +227 -0
- package/dist/utils/scoring/helpers.d.ts +119 -0
- package/dist/utils/scoring/helpers.js +229 -0
- package/dist/utils/scoring/index.d.ts +28 -0
- package/dist/utils/scoring/index.js +44 -0
- package/dist/utils/scoring/parseRecords.d.ts +97 -0
- package/dist/utils/scoring/parseRecords.js +280 -0
- package/dist/utils/scoring/types.d.ts +84 -0
- package/dist/utils/scoring/types.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// constants.ts
|
|
3
|
+
/**
|
|
4
|
+
* ============================================================================
|
|
5
|
+
* FITFRIX EXERCISE SCORING SYSTEM — Constants
|
|
6
|
+
* ============================================================================
|
|
7
|
+
*
|
|
8
|
+
* All magic numbers, default values, and tuning parameters live here.
|
|
9
|
+
* When we need to adjust scoring behavior, this is the ONLY file to change.
|
|
10
|
+
*
|
|
11
|
+
* Naming convention:
|
|
12
|
+
* DEFAULT_* = user-related fallbacks (weight, height, age)
|
|
13
|
+
* FALLBACK_* = exercise timing fallbacks (only when timingGuardrails is missing)
|
|
14
|
+
* ABSOLUTE_* = hard safety bounds (catches truly broken data)
|
|
15
|
+
* QUALITY_* = quality score weights and thresholds
|
|
16
|
+
* FACTOR_* = multipliers used in formulas
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.REST_NO_DATA_SCORE = exports.REST_OUTSIDE_SCORE = exports.REST_ACCEPTABLE_BASE = exports.REST_OPTIMAL_SCORE = exports.EFFORT_NO_DATA_SCORE = exports.EFFORT_MIN_SCORE = exports.EFFORT_DISTANCE_PENALTY = exports.OPTIMAL_RPE_RANGE = exports.OPTIMAL_RIR_RANGE = exports.PROGRESSIVE_OVERLOAD_FLOOR = exports.CONSISTENCY_MIN_SCORE = exports.CONSISTENCY_CV_PENALTY = exports.QUALITY_WEIGHTS = exports.REFERENCE_MAX_EFFORT = exports.REFERENCE_MAX_REPS = exports.REFERENCE_MAX_SETS = exports.DIFFICULTY_TO_WEIGHT_FRACTION = exports.CARDIO_MUSCLE_FATIGUE_DAMPENER = exports.SET_FATIGUE_DECAY_RATE = exports.SECONDARY_MUSCLE_ALLOCATION = exports.PRIMARY_MUSCLE_ALLOCATION = exports.CARDIO_SPEED_RANGE = exports.BW_FRACTION_SCALE = exports.DURATION_CATEGORY_THRESHOLDS = exports.DEFAULT_DURATION_FACTORS = exports.WEIGHT_CATEGORY_THRESHOLDS = exports.DEFAULT_WEIGHT_FACTORS = exports.REST_MET = exports.EFFORT_FRACTION_RANGE = exports.EFFORT_FRACTION_BASE = exports.EFFORT_CURVE_EXPONENT = exports.DEFAULT_EFFORT_FRACTION = exports.ABSOLUTE_WORK_MAX = exports.ABSOLUTE_WORK_MIN = exports.ABSOLUTE_REST_MAX = exports.ABSOLUTE_REST_MIN = exports.FALLBACK_STRESS_REST_BONUS = exports.FALLBACK_FATIGUE_MULTIPLIER = exports.FALLBACK_REST_SECS = exports.FALLBACK_SET_DURATION_SECS = exports.FALLBACK_SECS_PER_REP = exports.DEFAULT_USER_AGE = exports.DEFAULT_USER_HEIGHT_CM = exports.DEFAULT_USER_WEIGHT_KG = void 0;
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// User Defaults (when TUserMetric has missing fields)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Average adult weight when user hasn't provided theirs */
|
|
24
|
+
exports.DEFAULT_USER_WEIGHT_KG = 70;
|
|
25
|
+
/** Average adult height */
|
|
26
|
+
exports.DEFAULT_USER_HEIGHT_CM = 170;
|
|
27
|
+
/** Assumed age when DOB is missing */
|
|
28
|
+
exports.DEFAULT_USER_AGE = 30;
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Timing: Last-Resort Fallbacks
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
//
|
|
33
|
+
// IMPORTANT: These are only used when timingGuardrails is COMPLETELY MISSING
|
|
34
|
+
// from the exercise. In normal operation, validation bounds and defaults come
|
|
35
|
+
// from the exercise's own timingGuardrails:
|
|
36
|
+
//
|
|
37
|
+
// Rest validation → timingGuardrails.restPeriods.minimum / maximum
|
|
38
|
+
// Rest fallback → timingGuardrails.restPeriods.typical
|
|
39
|
+
// Work duration → timingGuardrails.setDuration.min / max (duration type)
|
|
40
|
+
// → timingGuardrails.singleRep.min / max (rep-based types)
|
|
41
|
+
// Fatigue scaling → timingGuardrails.fatigueMultiplier
|
|
42
|
+
// Rest quality → timingGuardrails.stressRestBonus
|
|
43
|
+
//
|
|
44
|
+
// The constants below are emergency defaults for malformed exercise data.
|
|
45
|
+
/** Seconds per rep when timingGuardrails.singleRep is missing entirely */
|
|
46
|
+
exports.FALLBACK_SECS_PER_REP = 3;
|
|
47
|
+
/** Set duration when timingGuardrails.setDuration is missing entirely */
|
|
48
|
+
exports.FALLBACK_SET_DURATION_SECS = 30;
|
|
49
|
+
/** Rest period when timingGuardrails.restPeriods is missing entirely */
|
|
50
|
+
exports.FALLBACK_REST_SECS = 90;
|
|
51
|
+
/** Fatigue multiplier when timingGuardrails.fatigueMultiplier is missing */
|
|
52
|
+
exports.FALLBACK_FATIGUE_MULTIPLIER = 1.0;
|
|
53
|
+
/** Stress rest bonus when timingGuardrails.stressRestBonus is missing */
|
|
54
|
+
exports.FALLBACK_STRESS_REST_BONUS = 1.0;
|
|
55
|
+
/**
|
|
56
|
+
* Absolute sanity bounds — these catch truly absurd sensor/input errors
|
|
57
|
+
* that even the exercise's own guardrails wouldn't expect.
|
|
58
|
+
* These are NOT the exercise's min/max; they're a safety net.
|
|
59
|
+
*
|
|
60
|
+
* Example: a restDurationSecs of 86400 (24 hours) is clearly a bug,
|
|
61
|
+
* regardless of what the exercise's guardrails say.
|
|
62
|
+
*/
|
|
63
|
+
exports.ABSOLUTE_REST_MIN = 0;
|
|
64
|
+
exports.ABSOLUTE_REST_MAX = 3600; // 1 hour — no exercise rests longer
|
|
65
|
+
exports.ABSOLUTE_WORK_MIN = 1; // At least 1 second of work
|
|
66
|
+
exports.ABSOLUTE_WORK_MAX = 14400; // 4 hours (ultra marathon cardio)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Effort Conversion
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/**
|
|
71
|
+
* Effort fraction when neither RPE nor RIR is provided.
|
|
72
|
+
* 0.6 = moderate effort assumption.
|
|
73
|
+
*/
|
|
74
|
+
exports.DEFAULT_EFFORT_FRACTION = 0.6;
|
|
75
|
+
/**
|
|
76
|
+
* Exponent for non-linear RPE/RIR → effort conversion.
|
|
77
|
+
* Values > 1.0 make the last reps near failure disproportionately harder,
|
|
78
|
+
* which matches real physiology (RPE 9→10 is a bigger jump than 5→6).
|
|
79
|
+
*/
|
|
80
|
+
exports.EFFORT_CURVE_EXPONENT = 1.3;
|
|
81
|
+
/**
|
|
82
|
+
* Effort fraction output range.
|
|
83
|
+
* effort = BASE + RANGE × (normalized_input ^ EXPONENT)
|
|
84
|
+
*/
|
|
85
|
+
exports.EFFORT_FRACTION_BASE = 0.5;
|
|
86
|
+
exports.EFFORT_FRACTION_RANGE = 0.8;
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Calorie Calculation
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
/**
|
|
91
|
+
* MET value during rest periods.
|
|
92
|
+
* Not true resting (1.0) because the body is still elevated after a set.
|
|
93
|
+
* 1.5 accounts for elevated heart rate and recovery metabolism.
|
|
94
|
+
*/
|
|
95
|
+
exports.REST_MET = 1.5;
|
|
96
|
+
/**
|
|
97
|
+
* Default weight intensity multipliers when metabolicData.weightFactors
|
|
98
|
+
* is missing. Keyed by intensity category.
|
|
99
|
+
*/
|
|
100
|
+
exports.DEFAULT_WEIGHT_FACTORS = {
|
|
101
|
+
light: 0.8, // < 25% bodyweight
|
|
102
|
+
moderate: 1.0, // 25–50% bodyweight
|
|
103
|
+
heavy: 1.3, // > 50% bodyweight
|
|
104
|
+
};
|
|
105
|
+
/** Thresholds for weight category classification (as ratio of bodyweight) */
|
|
106
|
+
exports.WEIGHT_CATEGORY_THRESHOLDS = {
|
|
107
|
+
lightMax: 0.25,
|
|
108
|
+
moderateMax: 0.5,
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Default duration multipliers when metabolicData.durationFactors is missing.
|
|
112
|
+
*/
|
|
113
|
+
exports.DEFAULT_DURATION_FACTORS = {
|
|
114
|
+
short: 0.9, // < 30 seconds
|
|
115
|
+
medium: 1.0, // 30s – 2min
|
|
116
|
+
long: 1.2, // > 2 min
|
|
117
|
+
};
|
|
118
|
+
/** Thresholds for duration category classification (in seconds) */
|
|
119
|
+
exports.DURATION_CATEGORY_THRESHOLDS = {
|
|
120
|
+
shortMax: 30,
|
|
121
|
+
mediumMax: 120,
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* For reps-only / bodyweight exercises: how much of bodyweight is "the load".
|
|
125
|
+
* Scaled by exercise difficultyLevel (0–4):
|
|
126
|
+
* load = (difficultyLevel / 4) × BW_FRACTION_SCALE × userWeightKg
|
|
127
|
+
*
|
|
128
|
+
* difficulty 0 → 0.0 × 0.65 = 0% (basically no BW load, e.g. finger exercise)
|
|
129
|
+
* difficulty 1 → 0.25 × 0.65 = 16% (light BW, e.g. easy crunches)
|
|
130
|
+
* difficulty 2 → 0.50 × 0.65 = 33% (moderate, e.g. push-ups)
|
|
131
|
+
* difficulty 4 → 1.00 × 0.65 = 65% (heavy, e.g. pistol squats)
|
|
132
|
+
*/
|
|
133
|
+
exports.BW_FRACTION_SCALE = 0.65;
|
|
134
|
+
/**
|
|
135
|
+
* Expected min/max speed range for cardio (km/h) when paceFactors are missing.
|
|
136
|
+
* Used to normalize speed into a 0–1 fraction for MET interpolation.
|
|
137
|
+
*/
|
|
138
|
+
exports.CARDIO_SPEED_RANGE = {
|
|
139
|
+
min: 3, // slow walk
|
|
140
|
+
max: 20, // fast sprint
|
|
141
|
+
};
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Muscle Fatigue
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
/** Fraction of total fatigue stimulus allocated to primary muscles */
|
|
146
|
+
exports.PRIMARY_MUSCLE_ALLOCATION = 1.0;
|
|
147
|
+
/** Fraction of total fatigue stimulus allocated to secondary muscles */
|
|
148
|
+
exports.SECONDARY_MUSCLE_ALLOCATION = 0.35;
|
|
149
|
+
/**
|
|
150
|
+
* Diminishing returns decay rate for consecutive sets.
|
|
151
|
+
* setDecay = 1 / (1 + DECAY_RATE × setIndex)
|
|
152
|
+
*
|
|
153
|
+
* With 0.15: set0=1.00, set1=0.87, set2=0.77, set3=0.69, set4=0.63
|
|
154
|
+
*/
|
|
155
|
+
exports.SET_FATIGUE_DECAY_RATE = 0.15;
|
|
156
|
+
/**
|
|
157
|
+
* Cardio exercises create cardiovascular fatigue, not the same mechanical
|
|
158
|
+
* muscle fatigue as strength training. A 30-minute jog doesn't fatigue
|
|
159
|
+
* your quads the way 5 sets of heavy squats does.
|
|
160
|
+
*
|
|
161
|
+
* This dampener reduces the volume-load contribution of cardio exercises
|
|
162
|
+
* so their muscle fatigue scores are proportional to actual muscle damage/stress.
|
|
163
|
+
*
|
|
164
|
+
* 0.3 means cardio produces ~30% of the muscle fatigue that an equivalent
|
|
165
|
+
* "volume" of strength work would.
|
|
166
|
+
*/
|
|
167
|
+
exports.CARDIO_MUSCLE_FATIGUE_DAMPENER = 0.3;
|
|
168
|
+
/**
|
|
169
|
+
* Reference max scaling by difficulty level.
|
|
170
|
+
* Maps difficultyLevel (0–4) → expected max weight as a fraction of bodyweight.
|
|
171
|
+
*
|
|
172
|
+
* This makes fatigue normalization exercise-aware:
|
|
173
|
+
* - Bicep curl (difficulty 1) → ref weight ~0.6× BW → smaller denominator → fair score
|
|
174
|
+
* - Squat (difficulty 3) → ref weight ~1.2× BW → larger denominator → fair score
|
|
175
|
+
*/
|
|
176
|
+
const DIFFICULTY_TO_WEIGHT_FRACTION = (difficulty) => 0.3 + difficulty * 0.3;
|
|
177
|
+
exports.DIFFICULTY_TO_WEIGHT_FRACTION = DIFFICULTY_TO_WEIGHT_FRACTION;
|
|
178
|
+
/** Reference set count for "fully fatigued" benchmark */
|
|
179
|
+
exports.REFERENCE_MAX_SETS = 5;
|
|
180
|
+
/** Reference rep count for "fully fatigued" benchmark */
|
|
181
|
+
exports.REFERENCE_MAX_REPS = 12;
|
|
182
|
+
/** Reference effort multiplier for "fully fatigued" benchmark */
|
|
183
|
+
exports.REFERENCE_MAX_EFFORT = 1.3;
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Quality Score
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
/**
|
|
188
|
+
* Weights for the four quality sub-components.
|
|
189
|
+
* Must sum to 1.0.
|
|
190
|
+
*/
|
|
191
|
+
exports.QUALITY_WEIGHTS = {
|
|
192
|
+
completion: 0.2,
|
|
193
|
+
consistency: 0.35,
|
|
194
|
+
effortAdequacy: 0.3,
|
|
195
|
+
restDiscipline: 0.15,
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Consistency scoring: how aggressively CV (coefficient of variation) is penalized.
|
|
199
|
+
* consistencyScore = 100 - CV × PENALTY
|
|
200
|
+
* CV 0.0 → 100, CV 0.15 → 70, CV 0.50 → 0
|
|
201
|
+
*/
|
|
202
|
+
exports.CONSISTENCY_CV_PENALTY = 200;
|
|
203
|
+
/** Minimum consistency score (even very inconsistent sets get some credit) */
|
|
204
|
+
exports.CONSISTENCY_MIN_SCORE = 20;
|
|
205
|
+
/** Floor score for intentional progressive/descending sets (≥3 sets) */
|
|
206
|
+
exports.PROGRESSIVE_OVERLOAD_FLOOR = 75;
|
|
207
|
+
/**
|
|
208
|
+
* Optimal RIR range for effort adequacy scoring.
|
|
209
|
+
* Within this range → 100 points. Outside → penalized by distance.
|
|
210
|
+
*/
|
|
211
|
+
exports.OPTIMAL_RIR_RANGE = [1, 4];
|
|
212
|
+
/** Optimal RPE range for effort adequacy scoring. */
|
|
213
|
+
exports.OPTIMAL_RPE_RANGE = [6, 9];
|
|
214
|
+
/** Penalty per unit of distance from the optimal effort range */
|
|
215
|
+
exports.EFFORT_DISTANCE_PENALTY = 20;
|
|
216
|
+
/** Minimum effort adequacy score */
|
|
217
|
+
exports.EFFORT_MIN_SCORE = 20;
|
|
218
|
+
/** Default effort adequacy score when no RPE/RIR data is available */
|
|
219
|
+
exports.EFFORT_NO_DATA_SCORE = 70;
|
|
220
|
+
/** Score when rest is within the optimal range */
|
|
221
|
+
exports.REST_OPTIMAL_SCORE = 100;
|
|
222
|
+
/** Score when rest is within acceptable (min–max) but not optimal */
|
|
223
|
+
exports.REST_ACCEPTABLE_BASE = 60;
|
|
224
|
+
/** Score when rest is completely outside the acceptable range */
|
|
225
|
+
exports.REST_OUTSIDE_SCORE = 30;
|
|
226
|
+
/** Default rest discipline score when no valid rest data exists */
|
|
227
|
+
exports.REST_NO_DATA_SCORE = 65;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX EXERCISE SCORING SYSTEM — Helpers
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Pure utility functions with no business logic. These handle:
|
|
7
|
+
* - Safe parsing of string values from TRecord
|
|
8
|
+
* - Duration parsing ("MM:SS" → seconds)
|
|
9
|
+
* - Clamping and validation
|
|
10
|
+
* - Effort fraction conversion (RPE/RIR → 0–1)
|
|
11
|
+
* - User context extraction with fallbacks
|
|
12
|
+
*
|
|
13
|
+
* All functions are deterministic and side-effect free.
|
|
14
|
+
*/
|
|
15
|
+
import { TGender } from "../../types";
|
|
16
|
+
import type { IUserContext } from "./types";
|
|
17
|
+
/**
|
|
18
|
+
* Safely parse a string to a float.
|
|
19
|
+
* Returns `fallback` if the input is undefined, empty, NaN, or negative.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* safeParseFloat("10.5") // 10.5
|
|
23
|
+
* safeParseFloat("") // 0
|
|
24
|
+
* safeParseFloat(undefined) // 0
|
|
25
|
+
* safeParseFloat("abc", 5) // 5
|
|
26
|
+
*/
|
|
27
|
+
export declare function safeParseFloat(value: string | undefined | null, fallback?: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* Parse a "MM:SS" or "M:SS" or "HH:MM:SS" duration string into total seconds.
|
|
30
|
+
*
|
|
31
|
+
* Handles edge cases:
|
|
32
|
+
* - "04:55" → 295 seconds
|
|
33
|
+
* - "10:00" → 600 seconds
|
|
34
|
+
* - "1:30:00" → 5400 seconds
|
|
35
|
+
* - "0:45" → 45 seconds (could be MM:SS with 0 min, or just 45 sec)
|
|
36
|
+
* - "" or invalid → fallbackSecs
|
|
37
|
+
*
|
|
38
|
+
* @param duration The "MM:SS" string from TRecord.durationMmSs
|
|
39
|
+
* @param fallbackSecs Returned when parsing fails (default: 0)
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseDurationMmSs(duration: string | undefined | null, fallbackSecs?: number): number;
|
|
42
|
+
/**
|
|
43
|
+
* Clamp a value between min and max (inclusive).
|
|
44
|
+
*/
|
|
45
|
+
export declare function clamp(value: number, min: number, max: number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Calculate the arithmetic mean of an array of numbers.
|
|
48
|
+
* Returns 0 for an empty array.
|
|
49
|
+
*/
|
|
50
|
+
export declare function mean(values: number[]): number;
|
|
51
|
+
/**
|
|
52
|
+
* Calculate the standard deviation (population) of an array of numbers.
|
|
53
|
+
* Returns 0 for arrays with fewer than 2 elements.
|
|
54
|
+
*/
|
|
55
|
+
export declare function standardDeviation(values: number[]): number;
|
|
56
|
+
/**
|
|
57
|
+
* Coefficient of variation: stdDev / mean.
|
|
58
|
+
* Returns 0 if mean is 0 (avoids division by zero).
|
|
59
|
+
* A CV of 0 means perfect consistency; higher = more variable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function coefficientOfVariation(values: number[]): number;
|
|
62
|
+
/**
|
|
63
|
+
* Convert RIR (Reps In Reserve) to an effort fraction (0.0 – 1.0).
|
|
64
|
+
*
|
|
65
|
+
* RIR scale: 0 = at failure (max effort), 10 = trivial (minimal effort).
|
|
66
|
+
*
|
|
67
|
+
* Uses a non-linear curve (exponent 1.3) because the perceptual and
|
|
68
|
+
* physiological cost of the last 2–3 reps near failure is disproportionately
|
|
69
|
+
* higher than the middle reps.
|
|
70
|
+
*
|
|
71
|
+
* Output range: ~0.50 (RIR 10) → ~1.30 (RIR 0)
|
|
72
|
+
* This is an "effort multiplier" not a pure 0–1, but the naming matches
|
|
73
|
+
* its usage in calorie and fatigue calculations.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* rirToEffortFraction(0) // ~1.30 (at failure)
|
|
77
|
+
* rirToEffortFraction(3) // ~0.94
|
|
78
|
+
* rirToEffortFraction(5) // ~0.82
|
|
79
|
+
* rirToEffortFraction(10) // ~0.50
|
|
80
|
+
*/
|
|
81
|
+
export declare function rirToEffortFraction(rir: number): number;
|
|
82
|
+
/**
|
|
83
|
+
* Convert RPE (Rate of Perceived Exertion) to an effort fraction.
|
|
84
|
+
*
|
|
85
|
+
* RPE scale: 1 = very easy, 10 = maximal effort.
|
|
86
|
+
* Same non-linear curve as RIR conversion.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* rpeToEffortFraction(10) // ~1.30 (max effort)
|
|
90
|
+
* rpeToEffortFraction(7) // ~0.94
|
|
91
|
+
* rpeToEffortFraction(5) // ~0.82
|
|
92
|
+
* rpeToEffortFraction(1) // ~0.54
|
|
93
|
+
*/
|
|
94
|
+
export declare function rpeToEffortFraction(rpe: number): number;
|
|
95
|
+
/**
|
|
96
|
+
* Extract effort fraction from a raw TRecord.
|
|
97
|
+
*
|
|
98
|
+
* Priority: RIR > RPE > default (0.6).
|
|
99
|
+
* RIR is preferred because it's more objective (countable reps left)
|
|
100
|
+
* while RPE is subjective.
|
|
101
|
+
*
|
|
102
|
+
* @param record - Any TRecord (we only read the rpe/rir fields)
|
|
103
|
+
*/
|
|
104
|
+
export declare function getEffortFraction(record: {
|
|
105
|
+
rpe?: string;
|
|
106
|
+
rir?: string;
|
|
107
|
+
}): number;
|
|
108
|
+
/**
|
|
109
|
+
* Extract a validated user context from TUserMetric.
|
|
110
|
+
* Guarantees all fields have usable values (no undefined).
|
|
111
|
+
*
|
|
112
|
+
* @param user - The raw TUserMetric object
|
|
113
|
+
*/
|
|
114
|
+
export declare function extractUserContext(user: {
|
|
115
|
+
weightKg?: number;
|
|
116
|
+
heightCm?: number;
|
|
117
|
+
gender?: TGender;
|
|
118
|
+
dob?: Date | string;
|
|
119
|
+
}): IUserContext;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// helpers.ts
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.safeParseFloat = safeParseFloat;
|
|
5
|
+
exports.parseDurationMmSs = parseDurationMmSs;
|
|
6
|
+
exports.clamp = clamp;
|
|
7
|
+
exports.mean = mean;
|
|
8
|
+
exports.standardDeviation = standardDeviation;
|
|
9
|
+
exports.coefficientOfVariation = coefficientOfVariation;
|
|
10
|
+
exports.rirToEffortFraction = rirToEffortFraction;
|
|
11
|
+
exports.rpeToEffortFraction = rpeToEffortFraction;
|
|
12
|
+
exports.getEffortFraction = getEffortFraction;
|
|
13
|
+
exports.extractUserContext = extractUserContext;
|
|
14
|
+
const constants_1 = require("./constants");
|
|
15
|
+
// We reference these types but don't import them to keep this module decoupled.
|
|
16
|
+
// The caller passes the raw values; we just parse them.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Numeric Parsing
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Safely parse a string to a float.
|
|
22
|
+
* Returns `fallback` if the input is undefined, empty, NaN, or negative.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* safeParseFloat("10.5") // 10.5
|
|
26
|
+
* safeParseFloat("") // 0
|
|
27
|
+
* safeParseFloat(undefined) // 0
|
|
28
|
+
* safeParseFloat("abc", 5) // 5
|
|
29
|
+
*/
|
|
30
|
+
function safeParseFloat(value, fallback = 0) {
|
|
31
|
+
if (value === undefined || value === null || value === "")
|
|
32
|
+
return fallback;
|
|
33
|
+
const parsed = parseFloat(value);
|
|
34
|
+
if (isNaN(parsed) || parsed < 0)
|
|
35
|
+
return fallback;
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Duration Parsing
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Parse a "MM:SS" or "M:SS" or "HH:MM:SS" duration string into total seconds.
|
|
43
|
+
*
|
|
44
|
+
* Handles edge cases:
|
|
45
|
+
* - "04:55" → 295 seconds
|
|
46
|
+
* - "10:00" → 600 seconds
|
|
47
|
+
* - "1:30:00" → 5400 seconds
|
|
48
|
+
* - "0:45" → 45 seconds (could be MM:SS with 0 min, or just 45 sec)
|
|
49
|
+
* - "" or invalid → fallbackSecs
|
|
50
|
+
*
|
|
51
|
+
* @param duration The "MM:SS" string from TRecord.durationMmSs
|
|
52
|
+
* @param fallbackSecs Returned when parsing fails (default: 0)
|
|
53
|
+
*/
|
|
54
|
+
function parseDurationMmSs(duration, fallbackSecs = 0) {
|
|
55
|
+
if (!duration || typeof duration !== "string")
|
|
56
|
+
return fallbackSecs;
|
|
57
|
+
const parts = duration.split(":").map(Number);
|
|
58
|
+
// Filter out NaN parts
|
|
59
|
+
if (parts.some(isNaN))
|
|
60
|
+
return fallbackSecs;
|
|
61
|
+
if (parts.length === 3) {
|
|
62
|
+
// HH:MM:SS
|
|
63
|
+
const [hours, minutes, seconds] = parts;
|
|
64
|
+
const total = hours * 3600 + minutes * 60 + seconds;
|
|
65
|
+
return total > 0 ? total : fallbackSecs;
|
|
66
|
+
}
|
|
67
|
+
if (parts.length === 2) {
|
|
68
|
+
// MM:SS
|
|
69
|
+
const [minutes, seconds] = parts;
|
|
70
|
+
const total = minutes * 60 + seconds;
|
|
71
|
+
return total > 0 ? total : fallbackSecs;
|
|
72
|
+
}
|
|
73
|
+
if (parts.length === 1 && parts[0] > 0) {
|
|
74
|
+
// Just seconds
|
|
75
|
+
return parts[0];
|
|
76
|
+
}
|
|
77
|
+
return fallbackSecs;
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Math Utilities
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
/**
|
|
83
|
+
* Clamp a value between min and max (inclusive).
|
|
84
|
+
*/
|
|
85
|
+
function clamp(value, min, max) {
|
|
86
|
+
return Math.min(Math.max(value, min), max);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Calculate the arithmetic mean of an array of numbers.
|
|
90
|
+
* Returns 0 for an empty array.
|
|
91
|
+
*/
|
|
92
|
+
function mean(values) {
|
|
93
|
+
if (values.length === 0)
|
|
94
|
+
return 0;
|
|
95
|
+
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Calculate the standard deviation (population) of an array of numbers.
|
|
99
|
+
* Returns 0 for arrays with fewer than 2 elements.
|
|
100
|
+
*/
|
|
101
|
+
function standardDeviation(values) {
|
|
102
|
+
if (values.length < 2)
|
|
103
|
+
return 0;
|
|
104
|
+
const avg = mean(values);
|
|
105
|
+
const squaredDiffs = values.map((v) => Math.pow((v - avg), 2));
|
|
106
|
+
return Math.sqrt(mean(squaredDiffs));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Coefficient of variation: stdDev / mean.
|
|
110
|
+
* Returns 0 if mean is 0 (avoids division by zero).
|
|
111
|
+
* A CV of 0 means perfect consistency; higher = more variable.
|
|
112
|
+
*/
|
|
113
|
+
function coefficientOfVariation(values) {
|
|
114
|
+
const avg = mean(values);
|
|
115
|
+
if (avg === 0)
|
|
116
|
+
return 0;
|
|
117
|
+
return standardDeviation(values) / avg;
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Effort Conversion
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* Convert RIR (Reps In Reserve) to an effort fraction (0.0 – 1.0).
|
|
124
|
+
*
|
|
125
|
+
* RIR scale: 0 = at failure (max effort), 10 = trivial (minimal effort).
|
|
126
|
+
*
|
|
127
|
+
* Uses a non-linear curve (exponent 1.3) because the perceptual and
|
|
128
|
+
* physiological cost of the last 2–3 reps near failure is disproportionately
|
|
129
|
+
* higher than the middle reps.
|
|
130
|
+
*
|
|
131
|
+
* Output range: ~0.50 (RIR 10) → ~1.30 (RIR 0)
|
|
132
|
+
* This is an "effort multiplier" not a pure 0–1, but the naming matches
|
|
133
|
+
* its usage in calorie and fatigue calculations.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* rirToEffortFraction(0) // ~1.30 (at failure)
|
|
137
|
+
* rirToEffortFraction(3) // ~0.94
|
|
138
|
+
* rirToEffortFraction(5) // ~0.82
|
|
139
|
+
* rirToEffortFraction(10) // ~0.50
|
|
140
|
+
*/
|
|
141
|
+
function rirToEffortFraction(rir) {
|
|
142
|
+
const clampedRir = clamp(rir, 0, 10);
|
|
143
|
+
const normalized = (10 - clampedRir) / 10; // 0 at RIR 10, 1 at RIR 0
|
|
144
|
+
return (constants_1.EFFORT_FRACTION_BASE +
|
|
145
|
+
constants_1.EFFORT_FRACTION_RANGE * Math.pow(normalized, constants_1.EFFORT_CURVE_EXPONENT));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Convert RPE (Rate of Perceived Exertion) to an effort fraction.
|
|
149
|
+
*
|
|
150
|
+
* RPE scale: 1 = very easy, 10 = maximal effort.
|
|
151
|
+
* Same non-linear curve as RIR conversion.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* rpeToEffortFraction(10) // ~1.30 (max effort)
|
|
155
|
+
* rpeToEffortFraction(7) // ~0.94
|
|
156
|
+
* rpeToEffortFraction(5) // ~0.82
|
|
157
|
+
* rpeToEffortFraction(1) // ~0.54
|
|
158
|
+
*/
|
|
159
|
+
function rpeToEffortFraction(rpe) {
|
|
160
|
+
const clampedRpe = clamp(rpe, 1, 10);
|
|
161
|
+
const normalized = clampedRpe / 10; // 0.1 at RPE 1, 1.0 at RPE 10
|
|
162
|
+
return (constants_1.EFFORT_FRACTION_BASE +
|
|
163
|
+
constants_1.EFFORT_FRACTION_RANGE * Math.pow(normalized, constants_1.EFFORT_CURVE_EXPONENT));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Extract effort fraction from a raw TRecord.
|
|
167
|
+
*
|
|
168
|
+
* Priority: RIR > RPE > default (0.6).
|
|
169
|
+
* RIR is preferred because it's more objective (countable reps left)
|
|
170
|
+
* while RPE is subjective.
|
|
171
|
+
*
|
|
172
|
+
* @param record - Any TRecord (we only read the rpe/rir fields)
|
|
173
|
+
*/
|
|
174
|
+
function getEffortFraction(record) {
|
|
175
|
+
// Try RIR first (more objective)
|
|
176
|
+
if (record.rir !== undefined && record.rir !== "") {
|
|
177
|
+
const rir = safeParseFloat(record.rir, -1);
|
|
178
|
+
if (rir >= 0)
|
|
179
|
+
return rirToEffortFraction(rir);
|
|
180
|
+
}
|
|
181
|
+
// Fall back to RPE
|
|
182
|
+
if (record.rpe !== undefined && record.rpe !== "") {
|
|
183
|
+
const rpe = safeParseFloat(record.rpe, -1);
|
|
184
|
+
if (rpe >= 1)
|
|
185
|
+
return rpeToEffortFraction(rpe);
|
|
186
|
+
}
|
|
187
|
+
// No effort data available
|
|
188
|
+
return constants_1.DEFAULT_EFFORT_FRACTION;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// User Context
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
/**
|
|
194
|
+
* Extract a validated user context from TUserMetric.
|
|
195
|
+
* Guarantees all fields have usable values (no undefined).
|
|
196
|
+
*
|
|
197
|
+
* @param user - The raw TUserMetric object
|
|
198
|
+
*/
|
|
199
|
+
function extractUserContext(user) {
|
|
200
|
+
const weightKg = user.weightKg && user.weightKg > 20 && user.weightKg < 300
|
|
201
|
+
? user.weightKg
|
|
202
|
+
: constants_1.DEFAULT_USER_WEIGHT_KG;
|
|
203
|
+
const heightCm = user.heightCm && user.heightCm > 100 && user.heightCm < 250
|
|
204
|
+
? user.heightCm
|
|
205
|
+
: constants_1.DEFAULT_USER_HEIGHT_CM;
|
|
206
|
+
let age = constants_1.DEFAULT_USER_AGE;
|
|
207
|
+
if (user.dob) {
|
|
208
|
+
const dob = typeof user.dob === "string" ? new Date(user.dob) : user.dob;
|
|
209
|
+
if (!isNaN(dob.getTime())) {
|
|
210
|
+
const today = new Date();
|
|
211
|
+
age = today.getFullYear() - dob.getFullYear();
|
|
212
|
+
// Adjust if birthday hasn't happened yet this year
|
|
213
|
+
const monthDiff = today.getMonth() - dob.getMonth();
|
|
214
|
+
if (monthDiff < 0 ||
|
|
215
|
+
(monthDiff === 0 && today.getDate() < dob.getDate())) {
|
|
216
|
+
age--;
|
|
217
|
+
}
|
|
218
|
+
// Sanity check
|
|
219
|
+
if (age < 10 || age > 100)
|
|
220
|
+
age = constants_1.DEFAULT_USER_AGE;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
weightKg,
|
|
225
|
+
heightCm,
|
|
226
|
+
gender: user.gender || "unmentioned",
|
|
227
|
+
age,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* FITFRIX EXERCISE SCORING SYSTEM
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* Matches existing app signature exactly:
|
|
7
|
+
*
|
|
8
|
+
* calculateExerciseScore({ exercise, record, user }) => IScoreResult
|
|
9
|
+
*
|
|
10
|
+
* IScoreResult = { score: number, muscleScores: Record<string, number> }
|
|
11
|
+
*
|
|
12
|
+
* Internally computes calorie burn (Pillar 1), muscle fatigue (Pillar 2),
|
|
13
|
+
* and quality score (Pillar 3), but only exposes what the existing
|
|
14
|
+
* interface expects.
|
|
15
|
+
*/
|
|
16
|
+
import { TExercise, TRecord, TUserMetric } from "../../types";
|
|
17
|
+
interface IScoreResult {
|
|
18
|
+
score: number;
|
|
19
|
+
muscleScores: {
|
|
20
|
+
[muscleGroup: string]: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare const calculateExerciseScoreV2: (param: {
|
|
24
|
+
exercise: TExercise;
|
|
25
|
+
record: TRecord[];
|
|
26
|
+
user: TUserMetric;
|
|
27
|
+
}) => IScoreResult;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================================
|
|
4
|
+
* FITFRIX EXERCISE SCORING SYSTEM
|
|
5
|
+
* ============================================================================
|
|
6
|
+
*
|
|
7
|
+
* Matches existing app signature exactly:
|
|
8
|
+
*
|
|
9
|
+
* calculateExerciseScore({ exercise, record, user }) => IScoreResult
|
|
10
|
+
*
|
|
11
|
+
* IScoreResult = { score: number, muscleScores: Record<string, number> }
|
|
12
|
+
*
|
|
13
|
+
* Internally computes calorie burn (Pillar 1), muscle fatigue (Pillar 2),
|
|
14
|
+
* and quality score (Pillar 3), but only exposes what the existing
|
|
15
|
+
* interface expects.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.calculateExerciseScoreV2 = void 0;
|
|
19
|
+
const calculateMuscleFatiue_1 = require("./calculateMuscleFatiue");
|
|
20
|
+
const calculateQualityScore_1 = require("./calculateQualityScore");
|
|
21
|
+
const helpers_1 = require("./helpers");
|
|
22
|
+
const parseRecords_1 = require("./parseRecords");
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Main Function — existing signature
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const calculateExerciseScoreV2 = (param) => {
|
|
27
|
+
const { exercise, record, user } = param;
|
|
28
|
+
const userContext = (0, helpers_1.extractUserContext)(user);
|
|
29
|
+
const parsedSets = (0, parseRecords_1.parseRecords)(record, exercise.timingGuardrails);
|
|
30
|
+
// Pillar 2: Muscle Fatigue → muscleScores
|
|
31
|
+
const muscleScores = (0, calculateMuscleFatiue_1.calculateMuscleFatigue)(parsedSets, {
|
|
32
|
+
primaryMuscles: exercise.primaryMuscles,
|
|
33
|
+
secondaryMuscles: exercise.secondaryMuscles,
|
|
34
|
+
difficultyLevel: exercise.difficultyLevel,
|
|
35
|
+
metabolicData: {
|
|
36
|
+
compoundMultiplier: exercise.metabolicData.compoundMultiplier,
|
|
37
|
+
muscleGroupFactor: exercise.metabolicData.muscleGroupFactor,
|
|
38
|
+
},
|
|
39
|
+
}, userContext, exercise.timingGuardrails);
|
|
40
|
+
// Pillar 3: Quality Score → score
|
|
41
|
+
const { score } = (0, calculateQualityScore_1.calculateQualityScore)(parsedSets, record, exercise.timingGuardrails);
|
|
42
|
+
return { score, muscleScores };
|
|
43
|
+
};
|
|
44
|
+
exports.calculateExerciseScoreV2 = calculateExerciseScoreV2;
|