@aneuhold/core-ts-db-lib 4.1.12 → 4.1.14
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/CHANGELOG.md +38 -0
- package/lib/browser.d.ts +11 -8
- package/lib/browser.d.ts.map +1 -1
- package/lib/browser.js +6 -4
- package/lib/browser.js.map +1 -1
- package/lib/browser.ts +23 -9
- package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.d.ts +14 -0
- package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.d.ts.map +1 -1
- package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.ts +18 -0
- package/lib/documents/workout/README.md +43 -9
- package/lib/documents/workout/WorkoutSet.d.ts +8 -0
- package/lib/documents/workout/WorkoutSet.d.ts.map +1 -1
- package/lib/documents/workout/WorkoutSet.ts +9 -0
- package/lib/services/workout/Exercise/WorkoutExerciseService.d.ts +40 -0
- package/lib/services/workout/Exercise/WorkoutExerciseService.d.ts.map +1 -1
- package/lib/services/workout/Exercise/WorkoutExerciseService.js +134 -1
- package/lib/services/workout/Exercise/WorkoutExerciseService.js.map +1 -1
- package/lib/services/workout/Exercise/WorkoutExerciseService.ts +204 -1
- package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.d.ts +15 -0
- package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.d.ts.map +1 -1
- package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.js +18 -1
- package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.js.map +1 -1
- package/lib/services/workout/ExerciseCalibration/WorkoutExerciseCalibrationService.ts +19 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.d.ts +30 -4
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.d.ts.map +1 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.js +58 -4
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.js.map +1 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.ts +69 -3
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.d.ts +45 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.d.ts.map +1 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.js +201 -11
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.js.map +1 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.ts +285 -9
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.d.ts +33 -0
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.d.ts.map +1 -0
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.js +24 -0
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.js.map +1 -0
- package/lib/services/workout/Mesocycle/WorkoutMesocycleService.types.ts +36 -0
- package/lib/services/workout/Session/WorkoutSessionService.d.ts.map +1 -1
- package/lib/services/workout/Session/WorkoutSessionService.js +1 -11
- package/lib/services/workout/Session/WorkoutSessionService.js.map +1 -1
- package/lib/services/workout/Session/WorkoutSessionService.ts +1 -17
- package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.d.ts +14 -2
- package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.d.ts.map +1 -1
- package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.js +17 -3
- package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.js.map +1 -1
- package/lib/services/workout/SessionExercise/WorkoutSessionExerciseService.ts +28 -3
- package/lib/services/workout/Set/WorkoutSetService.d.ts +17 -5
- package/lib/services/workout/Set/WorkoutSetService.d.ts.map +1 -1
- package/lib/services/workout/Set/WorkoutSetService.js +83 -16
- package/lib/services/workout/Set/WorkoutSetService.js.map +1 -1
- package/lib/services/workout/Set/WorkoutSetService.ts +107 -24
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.d.ts +161 -11
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.d.ts.map +1 -1
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.js +364 -127
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.js.map +1 -1
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.ts +551 -160
- package/package.json +1 -1
|
@@ -2,8 +2,6 @@ import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionE
|
|
|
2
2
|
/**
|
|
3
3
|
* A service for handling volume planning operations across microcycles.
|
|
4
4
|
*
|
|
5
|
-
* SCOPE: Microcycle-level volume distribution (calculating set counts per exercise)
|
|
6
|
-
*
|
|
7
5
|
* RESPONSIBILITIES:
|
|
8
6
|
* - Calculate set counts for exercises across a microcycle
|
|
9
7
|
* - Apply progressive overload rules (baseline + historical adjustments)
|
|
@@ -16,8 +14,39 @@ import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionE
|
|
|
16
14
|
* - {@link WorkoutSessionExerciseService} - Used to calculate SFR and recovery recommendations
|
|
17
15
|
*/
|
|
18
16
|
export default class WorkoutVolumePlanningService {
|
|
17
|
+
/**
|
|
18
|
+
* Controls the volume *progression rate* across accumulation microcycles.
|
|
19
|
+
*
|
|
20
|
+
* Both modes start at estimated MEV when historical volume data exists
|
|
21
|
+
* (falling back to 2 sets per exercise when no history is available).
|
|
22
|
+
*
|
|
23
|
+
* When `false` (default): legacy progression — adds 1 set per muscle group
|
|
24
|
+
* every `progressionInterval` microcycles from the MEV starting point.
|
|
25
|
+
*
|
|
26
|
+
* When `true`: MEV-to-MRV interpolation — linearly distributes sets from
|
|
27
|
+
* estimated MEV to estimated MRV across all accumulation microcycles.
|
|
28
|
+
*
|
|
29
|
+
* This flag exists to allow toggling between the two progression algorithms
|
|
30
|
+
* while the MEV-to-MRV approach is validated in practice. Once confidence is
|
|
31
|
+
* established, the flag and legacy path should be removed.
|
|
32
|
+
*/
|
|
33
|
+
static USE_VOLUME_LANDMARK_PROGRESSION = false;
|
|
19
34
|
static MAX_SETS_PER_EXERCISE = 8;
|
|
20
35
|
static MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION = 10;
|
|
36
|
+
/** Minimum average RSM required for a mesocycle to count toward MEV estimation. */
|
|
37
|
+
static MEV_RSM_THRESHOLD = 4;
|
|
38
|
+
/** Default estimated MEV when no qualifying mesocycle history exists. Per-exercise value. */
|
|
39
|
+
static DEFAULT_MEV_PER_EXERCISE = 2;
|
|
40
|
+
/** Minimum average performance score (or recovery presence) to count a mesocycle toward MRV estimation. */
|
|
41
|
+
static MRV_PERFORMANCE_THRESHOLD = 2.5;
|
|
42
|
+
/** Extra sets added above the historical peak when no stressed mesocycles exist, to estimate MRV. */
|
|
43
|
+
static MRV_HEADROOM = 2;
|
|
44
|
+
/** Default estimated MRV when no mesocycle history exists at all. Per-exercise value. */
|
|
45
|
+
static DEFAULT_MRV_PER_EXERCISE = 8;
|
|
46
|
+
/** Maximum sets that can be added to a single exercise in one progression step. */
|
|
47
|
+
static MAX_SET_ADDITION_PER_EXERCISE = 2;
|
|
48
|
+
/** Maximum total sets to distribute across a muscle group in one progression step. */
|
|
49
|
+
static MAX_TOTAL_SET_ADDITIONS = 3;
|
|
21
50
|
/**
|
|
22
51
|
* Calculates the set plan for an entire microcycle.
|
|
23
52
|
*/
|
|
@@ -38,22 +67,82 @@ export default class WorkoutVolumePlanningService {
|
|
|
38
67
|
});
|
|
39
68
|
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
40
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Estimates MEV, MRV, and MAV for a muscle group based on historical data
|
|
72
|
+
* across completed mesocycles.
|
|
73
|
+
*
|
|
74
|
+
* @param volumeCTO The WorkoutMuscleGroupVolumeCTO containing mesocycle
|
|
75
|
+
* history for this muscle group.
|
|
76
|
+
*/
|
|
77
|
+
static estimateVolumeLandmarks(volumeCTO) {
|
|
78
|
+
const { mesocycleHistory } = volumeCTO;
|
|
79
|
+
// Estimated MEV
|
|
80
|
+
let estimatedMev;
|
|
81
|
+
const effectiveMesocycles = mesocycleHistory.filter((m) => m.avgRsm !== null && m.avgRsm >= this.MEV_RSM_THRESHOLD);
|
|
82
|
+
if (effectiveMesocycles.length > 0) {
|
|
83
|
+
estimatedMev =
|
|
84
|
+
effectiveMesocycles.reduce((sum, m) => sum + m.startingSetCount, 0) /
|
|
85
|
+
effectiveMesocycles.length;
|
|
86
|
+
estimatedMev = Math.round(estimatedMev);
|
|
87
|
+
}
|
|
88
|
+
else if (mesocycleHistory.length > 0) {
|
|
89
|
+
estimatedMev = Math.min(...mesocycleHistory.map((m) => m.startingSetCount));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
estimatedMev = this.DEFAULT_MEV_PER_EXERCISE;
|
|
93
|
+
}
|
|
94
|
+
// Estimated MRV
|
|
95
|
+
let estimatedMrv;
|
|
96
|
+
const stressedMesocycles = mesocycleHistory.filter((m) => (m.avgPerformanceScore !== null &&
|
|
97
|
+
m.avgPerformanceScore >= this.MRV_PERFORMANCE_THRESHOLD) ||
|
|
98
|
+
m.recoverySessionCount > 0);
|
|
99
|
+
if (stressedMesocycles.length > 0) {
|
|
100
|
+
estimatedMrv =
|
|
101
|
+
stressedMesocycles.reduce((sum, m) => sum + m.peakSetCount, 0) / stressedMesocycles.length;
|
|
102
|
+
estimatedMrv = Math.round(estimatedMrv);
|
|
103
|
+
}
|
|
104
|
+
else if (mesocycleHistory.length > 0) {
|
|
105
|
+
estimatedMrv = Math.max(...mesocycleHistory.map((m) => m.peakSetCount)) + this.MRV_HEADROOM;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
estimatedMrv = this.DEFAULT_MRV_PER_EXERCISE;
|
|
109
|
+
}
|
|
110
|
+
// Ensure MRV > MEV
|
|
111
|
+
if (estimatedMrv <= estimatedMev) {
|
|
112
|
+
estimatedMrv = estimatedMev + 1;
|
|
113
|
+
}
|
|
114
|
+
const estimatedMav = Math.ceil((estimatedMev + estimatedMrv) / 2);
|
|
115
|
+
return { estimatedMev, estimatedMrv, estimatedMav, mesocycleCount: mesocycleHistory.length };
|
|
116
|
+
}
|
|
41
117
|
/**
|
|
42
118
|
* Calculates the set count for each exercise in a particular muscle group for this microcycle.
|
|
43
119
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
120
|
+
* Pipeline:
|
|
121
|
+
* 1. **Volume targets** — Determine start/end volume from landmarks or defaults
|
|
122
|
+
* 2. **Baseline** — Calculate default set counts from progression rules
|
|
123
|
+
* 3. **Resolve history** — Find the most recent session exercise data for each exercise
|
|
124
|
+
* 4. **Apply history** — Override baselines with historical set counts (or MAV for recovery returns)
|
|
125
|
+
* 5. **Evaluate SFR** — Determine recovery exercises and candidates for set additions
|
|
126
|
+
* 6. **Distribute sets** — Allocate added sets to candidates by SFR quality
|
|
127
|
+
*
|
|
128
|
+
* Falls back to baseline when no previous microcycle data exists.
|
|
46
129
|
*/
|
|
47
130
|
static calculateSetCountForEachExerciseInMuscleGroup(context, microcycleIndex, muscleGroupExerciseCTOs, isDeloadMicrocycle) {
|
|
48
131
|
const exerciseIdToSetCount = new Map();
|
|
49
132
|
const recoveryExerciseIds = new Set();
|
|
50
133
|
const sessionIndexToExerciseIds = new Map();
|
|
51
|
-
|
|
134
|
+
const { progressionInterval } = context;
|
|
135
|
+
// 1. Look up volume landmarks and compute volume targets
|
|
136
|
+
const primaryMuscleGroupId = muscleGroupExerciseCTOs[0]?.primaryMuscleGroups[0];
|
|
137
|
+
const volumeLandmark = primaryMuscleGroupId
|
|
138
|
+
? context.muscleGroupToVolumeLandmarkMap.get(primaryMuscleGroupId)
|
|
139
|
+
: undefined;
|
|
140
|
+
const { startVolume, endVolume } = this.getVolumeTargetsForMuscleGroup(volumeLandmark, muscleGroupExerciseCTOs.length, progressionInterval);
|
|
141
|
+
// 2. Calculate baseline set counts for all exercises in the muscle group
|
|
142
|
+
const baselineCounts = this.calculateBaselineSetCounts(microcycleIndex, context.accumulationMicrocycleCount, muscleGroupExerciseCTOs.length, startVolume, endVolume, isDeloadMicrocycle, progressionInterval);
|
|
143
|
+
// 3. Assign baselines and build session-to-exercise index
|
|
52
144
|
muscleGroupExerciseCTOs.forEach((cto, index) => {
|
|
53
|
-
|
|
54
|
-
exerciseIdToSetCount.set(cto._id, Math.min(baseline, this.MAX_SETS_PER_EXERCISE));
|
|
55
|
-
// Build out the map for session indices to the array of exercise IDs as it pertains to this
|
|
56
|
-
// muscle group.
|
|
145
|
+
exerciseIdToSetCount.set(cto._id, Math.min(baselineCounts[index], this.MAX_SETS_PER_EXERCISE));
|
|
57
146
|
if (!context.exerciseIdToSessionIndex)
|
|
58
147
|
return;
|
|
59
148
|
const exerciseSessionIndex = context.exerciseIdToSessionIndex.get(cto._id);
|
|
@@ -63,14 +152,126 @@ export default class WorkoutVolumePlanningService {
|
|
|
63
152
|
existingExerciseIdsForSession.push(cto._id);
|
|
64
153
|
sessionIndexToExerciseIds.set(exerciseSessionIndex, existingExerciseIdsForSession);
|
|
65
154
|
});
|
|
66
|
-
//
|
|
67
|
-
|
|
155
|
+
// 4. Resolve historical data — returns null if no usable history exists
|
|
156
|
+
const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
|
|
157
|
+
const historicalData = this.resolveHistoricalExerciseData(context, microcycleIndex, exerciseIds);
|
|
158
|
+
if (!historicalData)
|
|
159
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
160
|
+
// 5. Apply historical set counts (overrides baselines)
|
|
161
|
+
this.applyHistoricalSetCounts(muscleGroupExerciseCTOs, historicalData.exerciseIdToPrevSessionExercise, historicalData.exercisesThatWerePreviouslyInRecovery, exerciseIdToSetCount, isDeloadMicrocycle, volumeLandmark);
|
|
162
|
+
// Deload microcycles and zero-progression cycles (Resensitization) skip SFR-based set additions
|
|
163
|
+
if (isDeloadMicrocycle || progressionInterval === 0) {
|
|
164
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
165
|
+
}
|
|
166
|
+
// 6. Evaluate SFR recommendations (per-exercise SFR + recovery detection)
|
|
167
|
+
const { totalSetsToAdd, candidates } = this.evaluateSfrRecommendations(context, muscleGroupExerciseCTOs, historicalData.exerciseIdToPrevSessionExercise, historicalData.exercisesThatWerePreviouslyInRecovery, exerciseIdToSetCount, recoveryExerciseIds, sessionIndexToExerciseIds);
|
|
168
|
+
if (totalSetsToAdd === 0 || candidates.length === 0) {
|
|
169
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
170
|
+
}
|
|
171
|
+
// 7. Distribute added sets to candidates by SFR quality
|
|
172
|
+
this.distributeSetsToExercises(candidates, totalSetsToAdd, exerciseIdToSetCount, context.exerciseIdToSessionIndex, sessionIndexToExerciseIds);
|
|
173
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Determines the start and end volume targets for a muscle group based on
|
|
177
|
+
* historical volume landmarks and cycle type.
|
|
178
|
+
*
|
|
179
|
+
* @param volumeLandmark Volume landmark estimate from historical data, if available.
|
|
180
|
+
* @param exerciseCount Number of exercises in the muscle group.
|
|
181
|
+
* @param progressionInterval Cycle-type progression interval (1=MuscleGain, 2=Cut, 0=Resensitization).
|
|
182
|
+
*/
|
|
183
|
+
static getVolumeTargetsForMuscleGroup(volumeLandmark, exerciseCount, progressionInterval) {
|
|
184
|
+
// Resensitization (interval=0): flat at estimated MEV (or default when no history)
|
|
185
|
+
if (progressionInterval === 0) {
|
|
186
|
+
const flatVolume = volumeLandmark && volumeLandmark.mesocycleCount > 0
|
|
187
|
+
? volumeLandmark.estimatedMev
|
|
188
|
+
: this.DEFAULT_MEV_PER_EXERCISE * exerciseCount;
|
|
189
|
+
return { startVolume: flatVolume, endVolume: flatVolume };
|
|
190
|
+
}
|
|
191
|
+
// With historical volume landmarks
|
|
192
|
+
if (volumeLandmark && volumeLandmark.mesocycleCount > 0) {
|
|
193
|
+
const startVolume = volumeLandmark.estimatedMev;
|
|
194
|
+
// Cut: progress to MAV (midpoint), not MRV
|
|
195
|
+
let endVolume = progressionInterval === 2 ? volumeLandmark.estimatedMav : volumeLandmark.estimatedMrv;
|
|
196
|
+
if (endVolume <= startVolume) {
|
|
197
|
+
endVolume = startVolume + 1;
|
|
198
|
+
}
|
|
199
|
+
return { startVolume, endVolume };
|
|
200
|
+
}
|
|
201
|
+
// No history: use per-exercise defaults
|
|
202
|
+
const startVolume = this.DEFAULT_MEV_PER_EXERCISE * exerciseCount;
|
|
203
|
+
const mrvTotal = this.DEFAULT_MRV_PER_EXERCISE * exerciseCount;
|
|
204
|
+
// Cut: target MAV (midpoint), not MRV
|
|
205
|
+
const endVolume = progressionInterval === 2 ? Math.ceil((startVolume + mrvTotal) / 2) : mrvTotal;
|
|
206
|
+
return { startVolume, endVolume };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Calculates the baseline set counts for all exercises in a muscle group for a given microcycle.
|
|
210
|
+
*
|
|
211
|
+
* When {@link USE_VOLUME_LANDMARK_PROGRESSION} is `false` (default), uses the legacy progression
|
|
212
|
+
* rate: +1 set per muscle group every `progressionInterval` microcycles from the starting volume.
|
|
213
|
+
*
|
|
214
|
+
* When `true`, linearly interpolates from `startVolume` to `endVolume` across accumulation
|
|
215
|
+
* microcycles.
|
|
216
|
+
*
|
|
217
|
+
* @param microcycleIndex The index of the current microcycle.
|
|
218
|
+
* @param accumulationMicrocycleCount Number of accumulation (non-deload) microcycles.
|
|
219
|
+
* @param exerciseCount Number of exercises in the muscle group.
|
|
220
|
+
* @param startVolume Total muscle-group volume at microcycle 0.
|
|
221
|
+
* @param endVolume Total muscle-group volume target at the last accumulation microcycle.
|
|
222
|
+
* @param isDeloadMicrocycle Whether this is a deload microcycle.
|
|
223
|
+
* @param progressionInterval Microcycles between each set addition (0 = no progression).
|
|
224
|
+
*/
|
|
225
|
+
static calculateBaselineSetCounts(microcycleIndex, accumulationMicrocycleCount, exerciseCount, startVolume, endVolume, isDeloadMicrocycle, progressionInterval) {
|
|
226
|
+
if (isDeloadMicrocycle) {
|
|
227
|
+
const lastAccumulationCounts = this.calculateBaselineSetCounts(microcycleIndex - 1, accumulationMicrocycleCount, exerciseCount, startVolume, endVolume, false, progressionInterval);
|
|
228
|
+
return lastAccumulationCounts.map((count) => Math.max(1, Math.floor(count / 2)));
|
|
229
|
+
}
|
|
230
|
+
let totalSets;
|
|
231
|
+
if (this.USE_VOLUME_LANDMARK_PROGRESSION) {
|
|
232
|
+
// Linear interpolation from startVolume to endVolume
|
|
233
|
+
if (accumulationMicrocycleCount <= 1) {
|
|
234
|
+
totalSets = startVolume;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
totalSets = Math.round(startVolume +
|
|
238
|
+
((endVolume - startVolume) * microcycleIndex) / (accumulationMicrocycleCount - 1));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// Legacy progression: +1 set per muscle group per progressionInterval microcycles
|
|
243
|
+
const progressionSets = progressionInterval === 0 ? 0 : Math.ceil(microcycleIndex / progressionInterval);
|
|
244
|
+
totalSets = startVolume + progressionSets;
|
|
245
|
+
}
|
|
246
|
+
return this.distributeEvenly(totalSets, exerciseCount);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Distributes a total evenly across N slots, with remainder going to earlier slots.
|
|
250
|
+
*
|
|
251
|
+
* @param total The total to distribute.
|
|
252
|
+
* @param slots The number of slots to distribute across.
|
|
253
|
+
*/
|
|
254
|
+
static distributeEvenly(total, slots) {
|
|
255
|
+
const base = Math.floor(total / slots);
|
|
256
|
+
const remainder = total % slots;
|
|
257
|
+
return Array.from({ length: slots }, (_, i) => (i < remainder ? base + 1 : base));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Walks backward through completed microcycles to find the most recent non-recovery
|
|
261
|
+
* session exercise for each exercise in the muscle group.
|
|
262
|
+
*
|
|
263
|
+
* Returns `null` when no usable historical data exists (no previous microcycle, or
|
|
264
|
+
* previous microcycles are incomplete/have no matching exercises).
|
|
265
|
+
*
|
|
266
|
+
* @param context The mesocycle planning context.
|
|
267
|
+
* @param microcycleIndex The current microcycle index.
|
|
268
|
+
* @param exerciseIds The exercise IDs to search for.
|
|
269
|
+
*/
|
|
270
|
+
static resolveHistoricalExerciseData(context, microcycleIndex, exerciseIds) {
|
|
68
271
|
let previousMicrocycleIndex = microcycleIndex - 1;
|
|
69
272
|
let previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
|
|
70
273
|
if (!previousMicrocycle)
|
|
71
|
-
return
|
|
72
|
-
// Map previous session exercises
|
|
73
|
-
const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
|
|
274
|
+
return null;
|
|
74
275
|
const exerciseIdToPrevSessionExercise = new Map();
|
|
75
276
|
const foundExerciseIds = new Set();
|
|
76
277
|
const exercisesThatWerePreviouslyInRecovery = new Set();
|
|
@@ -82,102 +283,117 @@ export default class WorkoutVolumePlanningService {
|
|
|
82
283
|
if (!microcycleIsComplete) {
|
|
83
284
|
break;
|
|
84
285
|
}
|
|
85
|
-
//
|
|
286
|
+
// Scan all session exercises in this microcycle for matching non-recovery exercises
|
|
86
287
|
for (const sessionId of previousMicrocycle.sessionOrder) {
|
|
87
288
|
const session = context.sessionMap.get(sessionId);
|
|
88
289
|
if (!session)
|
|
89
290
|
continue;
|
|
90
|
-
// Get the session exercises for this session
|
|
91
291
|
for (const sessionExerciseId of session.sessionExerciseOrder) {
|
|
92
292
|
const sessionExercise = context.sessionExerciseMap.get(sessionExerciseId);
|
|
93
|
-
// Map if in our muscle group && it isn't a recovery exercise
|
|
94
293
|
if (sessionExercise &&
|
|
95
294
|
exerciseIds.has(sessionExercise.workoutExerciseId) &&
|
|
96
295
|
!foundExerciseIds.has(sessionExercise.workoutExerciseId) &&
|
|
97
296
|
!sessionExercise.isRecoveryExercise) {
|
|
98
297
|
exerciseIdToPrevSessionExercise.set(sessionExercise.workoutExerciseId, sessionExercise);
|
|
99
298
|
foundExerciseIds.add(sessionExercise.workoutExerciseId);
|
|
299
|
+
// If we had to go back more than one microcycle, the exercise was in recovery
|
|
100
300
|
if (previousMicrocycleIndex < microcycleIndex - 1) {
|
|
101
301
|
exercisesThatWerePreviouslyInRecovery.add(sessionExercise.workoutExerciseId);
|
|
102
302
|
}
|
|
103
303
|
}
|
|
104
304
|
}
|
|
105
305
|
}
|
|
106
|
-
// Move to earlier microcycle
|
|
107
306
|
previousMicrocycleIndex = previousMicrocycleIndex - 1;
|
|
108
307
|
previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
|
|
109
308
|
}
|
|
110
309
|
if (exerciseIdToPrevSessionExercise.size === 0)
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
310
|
+
return null;
|
|
311
|
+
return { exerciseIdToPrevSessionExercise, exercisesThatWerePreviouslyInRecovery };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Overrides baseline set counts with historical data from the previous microcycle.
|
|
315
|
+
*
|
|
316
|
+
* For exercises returning from recovery, distributes the estimated MAV from volume
|
|
317
|
+
* landmarks proportionally across exercises. For deload microcycles, halves the
|
|
318
|
+
* historical count (minimum 1 set).
|
|
319
|
+
*
|
|
320
|
+
* @param muscleGroupExerciseCTOs Exercises in this muscle group.
|
|
321
|
+
* @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
|
|
322
|
+
* @param exercisesThatWerePreviouslyInRecovery Exercises returning from recovery.
|
|
323
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated).
|
|
324
|
+
* @param isDeloadMicrocycle Whether this is a deload microcycle.
|
|
325
|
+
* @param volumeLandmark Volume landmark estimate for the primary muscle group, if available.
|
|
326
|
+
*/
|
|
327
|
+
static applyHistoricalSetCounts(muscleGroupExerciseCTOs, exerciseIdToPrevSessionExercise, exercisesThatWerePreviouslyInRecovery, exerciseIdToSetCount, isDeloadMicrocycle, volumeLandmark) {
|
|
328
|
+
// Pre-compute per-exercise MAV distribution for exercises returning from recovery.
|
|
329
|
+
// Uses floor-based even distribution (conservative) per the source material's guidance
|
|
330
|
+
// to err on the lighter side when returning from recovery.
|
|
331
|
+
const hasExerciseReturningFromRecovery = muscleGroupExerciseCTOs.some((cto) => exercisesThatWerePreviouslyInRecovery.has(cto._id));
|
|
332
|
+
const mavDistribution = hasExerciseReturningFromRecovery && volumeLandmark
|
|
333
|
+
? this.distributeEvenly(volumeLandmark.estimatedMav, muscleGroupExerciseCTOs.length)
|
|
334
|
+
: undefined;
|
|
335
|
+
muscleGroupExerciseCTOs.forEach((cto, index) => {
|
|
117
336
|
const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
|
|
118
|
-
if (previousSessionExercise)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
337
|
+
if (!previousSessionExercise)
|
|
338
|
+
return;
|
|
339
|
+
let setCount;
|
|
340
|
+
if (exercisesThatWerePreviouslyInRecovery.has(cto._id) && mavDistribution) {
|
|
341
|
+
// Returning from recovery — resume at conservatively distributed MAV
|
|
342
|
+
setCount = Math.min(mavDistribution[index], this.MAX_SETS_PER_EXERCISE);
|
|
124
343
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Determines if the session for the given exercise is already capped for this muscle group.
|
|
132
|
-
*/
|
|
133
|
-
function sessionIsCapped(exerciseId) {
|
|
134
|
-
if (!context.exerciseIdToSessionIndex) {
|
|
135
|
-
throw new Error('WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.');
|
|
344
|
+
else {
|
|
345
|
+
// Carry forward last microcycle's set count
|
|
346
|
+
setCount = Math.min(previousSessionExercise.setOrder.length, this.MAX_SETS_PER_EXERCISE);
|
|
136
347
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
348
|
+
if (isDeloadMicrocycle) {
|
|
349
|
+
// Deload: halve volume, minimum 1 set
|
|
350
|
+
setCount = Math.max(1, Math.floor(setCount / 2));
|
|
351
|
+
}
|
|
352
|
+
exerciseIdToSetCount.set(cto._id, setCount);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Evaluates each exercise's performance feedback to determine recovery exercises,
|
|
357
|
+
* set addition recommendations, and candidates for volume increases.
|
|
358
|
+
*
|
|
359
|
+
* @param context The mesocycle planning context.
|
|
360
|
+
* @param muscleGroupExerciseCTOs Exercises in this muscle group.
|
|
361
|
+
* @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
|
|
362
|
+
* @param exercisesThatWerePreviouslyInRecovery Exercises returning from recovery.
|
|
363
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated for recovery exercises).
|
|
364
|
+
* @param recoveryExerciseIds Set of exercise IDs flagged for recovery (mutated).
|
|
365
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
366
|
+
*/
|
|
367
|
+
static evaluateSfrRecommendations(context, muscleGroupExerciseCTOs, exerciseIdToPrevSessionExercise, exercisesThatWerePreviouslyInRecovery, exerciseIdToSetCount, recoveryExerciseIds, sessionIndexToExerciseIds) {
|
|
151
368
|
let totalSetsToAdd = 0;
|
|
152
369
|
const candidates = [];
|
|
153
370
|
muscleGroupExerciseCTOs.forEach((cto, muscleGroupIndex) => {
|
|
154
371
|
const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
|
|
155
372
|
if (!previousSessionExercise)
|
|
156
373
|
return;
|
|
374
|
+
// Get SFR-based recommendation: positive = add sets, 0 = maintain, -1 = recovery needed
|
|
157
375
|
let recommendation;
|
|
158
376
|
if (!exercisesThatWerePreviouslyInRecovery.has(cto._id)) {
|
|
159
377
|
recommendation =
|
|
160
378
|
WorkoutSessionExerciseService.getRecommendedSetAdditionsOrRecovery(previousSessionExercise);
|
|
161
379
|
}
|
|
162
380
|
else {
|
|
163
|
-
//
|
|
164
|
-
// the only thing we are overriding. We still want to use the historical data for
|
|
165
|
-
// SFR calculations, even though that one was the one that triggered a recovery session.
|
|
166
|
-
// This should make it so that it is less likely to have sets added to it.
|
|
381
|
+
// Returning from recovery — don't add sets yet, but still use SFR for candidate ranking
|
|
167
382
|
recommendation = 0;
|
|
168
383
|
}
|
|
169
384
|
if (recommendation === -1) {
|
|
385
|
+
// Recovery needed: halve sets and flag as recovery exercise
|
|
170
386
|
recoveryExerciseIds.add(cto._id);
|
|
171
|
-
// Cut sets in half (rounded down, minimum 1) for recovery
|
|
172
387
|
const previousSetCount = previousSessionExercise.setOrder.length;
|
|
173
388
|
const recoverySets = Math.max(1, Math.floor(previousSetCount / 2));
|
|
174
389
|
exerciseIdToSetCount.set(cto._id, recoverySets);
|
|
175
390
|
}
|
|
176
|
-
else if (recommendation
|
|
391
|
+
else if (recommendation !== null && recommendation >= 0) {
|
|
392
|
+
// Accumulate SFR-recommended additions into shared total
|
|
177
393
|
totalSetsToAdd += recommendation;
|
|
178
|
-
//
|
|
394
|
+
// Only exercises below per-exercise cap and in uncapped sessions are candidates
|
|
179
395
|
if (previousSessionExercise.setOrder.length < this.MAX_SETS_PER_EXERCISE &&
|
|
180
|
-
!sessionIsCapped(cto._id)) {
|
|
396
|
+
!this.sessionIsCapped(cto._id, context.exerciseIdToSessionIndex, sessionIndexToExerciseIds, exerciseIdToPrevSessionExercise)) {
|
|
181
397
|
candidates.push({
|
|
182
398
|
exerciseId: cto._id,
|
|
183
399
|
// Don't error if SFR is null for now, just treat as very low
|
|
@@ -189,84 +405,105 @@ export default class WorkoutVolumePlanningService {
|
|
|
189
405
|
}
|
|
190
406
|
}
|
|
191
407
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
408
|
+
return { totalSetsToAdd, candidates };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Distributes added sets across candidate exercises, prioritizing higher SFR scores.
|
|
412
|
+
* Respects per-exercise, per-session, and per-addition caps.
|
|
413
|
+
*
|
|
414
|
+
* @param candidates Exercises eligible for set additions, with SFR scores.
|
|
415
|
+
* @param totalSetsToAdd Total sets recommended for addition (before capping).
|
|
416
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated).
|
|
417
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
418
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
419
|
+
*/
|
|
420
|
+
static distributeSetsToExercises(candidates, totalSetsToAdd, exerciseIdToSetCount, exerciseIdToSessionIndex, sessionIndexToExerciseIds) {
|
|
421
|
+
// Prioritize exercises with highest SFR (best stimulus-to-fatigue ratio)
|
|
198
422
|
candidates.sort((candidateA, candidateB) => candidateA.sfr !== candidateB.sfr
|
|
199
423
|
? candidateB.sfr - candidateA.sfr
|
|
200
424
|
: candidateA.muscleGroupIndex - candidateB.muscleGroupIndex);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return 0;
|
|
207
|
-
const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
|
|
208
|
-
if (sessionIndex === undefined)
|
|
209
|
-
return 0;
|
|
210
|
-
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
211
|
-
if (!exerciseIdsInSession)
|
|
212
|
-
return 0;
|
|
213
|
-
let total = 0;
|
|
214
|
-
exerciseIdsInSession.forEach((id) => {
|
|
215
|
-
total += exerciseIdToSetCount.get(id) || 0;
|
|
216
|
-
});
|
|
217
|
-
return total;
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* Attempts to add sets to an exercise, respecting all constraints.
|
|
221
|
-
* Returns the number of sets actually added.
|
|
222
|
-
*/
|
|
223
|
-
function addSetsToExercise(exerciseId, setsToAdd) {
|
|
224
|
-
const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
|
|
225
|
-
const sessionTotal = getSessionTotal(exerciseId);
|
|
226
|
-
const maxDueToExerciseLimit = WorkoutVolumePlanningService.MAX_SETS_PER_EXERCISE - currentSets;
|
|
227
|
-
const maxDueToSessionLimit = WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
|
|
228
|
-
// Hard limit of 2 to add to a particular exercise at once
|
|
229
|
-
const maxAddable = Math.min(setsToAdd, maxDueToExerciseLimit, maxDueToSessionLimit, 2);
|
|
230
|
-
if (maxAddable > 0) {
|
|
231
|
-
exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
|
|
232
|
-
}
|
|
233
|
-
return maxAddable;
|
|
234
|
-
}
|
|
235
|
-
// Cap the actual sets to add to 3 total
|
|
236
|
-
let setsRemaining = totalSetsToAdd >= 3 ? 3 : totalSetsToAdd;
|
|
425
|
+
// Cap total additions at MAX_TOTAL_SET_ADDITIONS regardless of raw recommendation
|
|
426
|
+
let setsRemaining = totalSetsToAdd >= this.MAX_TOTAL_SET_ADDITIONS
|
|
427
|
+
? this.MAX_TOTAL_SET_ADDITIONS
|
|
428
|
+
: totalSetsToAdd;
|
|
429
|
+
// Distribute sets one candidate at a time until budget is exhausted
|
|
237
430
|
for (const candidate of candidates) {
|
|
238
|
-
const added = addSetsToExercise(candidate.exerciseId, setsRemaining);
|
|
431
|
+
const added = this.addSetsToExercise(candidate.exerciseId, setsRemaining, exerciseIdToSetCount, exerciseIdToSessionIndex, sessionIndexToExerciseIds);
|
|
239
432
|
setsRemaining -= added;
|
|
240
433
|
if (setsRemaining === 0)
|
|
241
434
|
break;
|
|
242
435
|
}
|
|
243
|
-
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
244
436
|
}
|
|
245
437
|
/**
|
|
246
|
-
*
|
|
438
|
+
* Determines if the session containing the given exercise is already at the
|
|
439
|
+
* per-muscle-group-per-session cap, based on previous microcycle set counts.
|
|
440
|
+
*
|
|
441
|
+
* @param exerciseId The exercise to check.
|
|
442
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
443
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
444
|
+
* @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
|
|
445
|
+
*/
|
|
446
|
+
static sessionIsCapped(exerciseId, exerciseIdToSessionIndex, sessionIndexToExerciseIds, exerciseIdToPrevSessionExercise) {
|
|
447
|
+
if (!exerciseIdToSessionIndex) {
|
|
448
|
+
throw new Error('WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.');
|
|
449
|
+
}
|
|
450
|
+
const sessionIndex = exerciseIdToSessionIndex.get(exerciseId);
|
|
451
|
+
if (sessionIndex === undefined)
|
|
452
|
+
return false;
|
|
453
|
+
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
454
|
+
if (!exerciseIdsInSession)
|
|
455
|
+
return false;
|
|
456
|
+
let totalSetsInSession = 0;
|
|
457
|
+
exerciseIdsInSession.forEach((id) => {
|
|
458
|
+
totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
|
|
459
|
+
});
|
|
460
|
+
return totalSetsInSession >= this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Gets the total sets currently planned for a session containing the given exercise.
|
|
247
464
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
465
|
+
* @param exerciseId The exercise whose session total to compute.
|
|
466
|
+
* @param exerciseIdToSetCount Current set count assignments.
|
|
467
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
468
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
469
|
+
*/
|
|
470
|
+
static getSessionSetTotal(exerciseId, exerciseIdToSetCount, exerciseIdToSessionIndex, sessionIndexToExerciseIds) {
|
|
471
|
+
if (!exerciseIdToSessionIndex)
|
|
472
|
+
return 0;
|
|
473
|
+
const sessionIndex = exerciseIdToSessionIndex.get(exerciseId);
|
|
474
|
+
if (sessionIndex === undefined)
|
|
475
|
+
return 0;
|
|
476
|
+
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
477
|
+
if (!exerciseIdsInSession)
|
|
478
|
+
return 0;
|
|
479
|
+
let total = 0;
|
|
480
|
+
exerciseIdsInSession.forEach((id) => {
|
|
481
|
+
total += exerciseIdToSetCount.get(id) || 0;
|
|
482
|
+
});
|
|
483
|
+
return total;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Attempts to add sets to an exercise, respecting per-exercise, per-session, and
|
|
487
|
+
* per-addition caps. Mutates `exerciseIdToSetCount` in place.
|
|
250
488
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
489
|
+
* @param exerciseId The exercise to add sets to.
|
|
490
|
+
* @param setsToAdd The desired number of sets to add.
|
|
491
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated).
|
|
492
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
493
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
494
|
+
* @returns The number of sets actually added.
|
|
254
495
|
*/
|
|
255
|
-
static
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
496
|
+
static addSetsToExercise(exerciseId, setsToAdd, exerciseIdToSetCount, exerciseIdToSessionIndex, sessionIndexToExerciseIds) {
|
|
497
|
+
const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
|
|
498
|
+
const sessionTotal = this.getSessionSetTotal(exerciseId, exerciseIdToSetCount, exerciseIdToSessionIndex, sessionIndexToExerciseIds);
|
|
499
|
+
// Respect all caps: per-exercise max, per-session max, and per-addition max
|
|
500
|
+
const maxDueToExerciseLimit = this.MAX_SETS_PER_EXERCISE - currentSets;
|
|
501
|
+
const maxDueToSessionLimit = this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
|
|
502
|
+
const maxAddable = Math.min(setsToAdd, maxDueToExerciseLimit, maxDueToSessionLimit, this.MAX_SET_ADDITION_PER_EXERCISE);
|
|
503
|
+
if (maxAddable > 0) {
|
|
504
|
+
exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
|
|
260
505
|
}
|
|
261
|
-
|
|
262
|
-
// For now, add exactly +1 total set per microcycle per muscle group.
|
|
263
|
-
const totalSets = 2 * totalExercisesInMuscleGroupForMicrocycle + microcycleIndex;
|
|
264
|
-
// Distribute sets evenly, with earlier exercises getting extra sets from the remainder.
|
|
265
|
-
const baseSetsPerExercise = Math.floor(totalSets / totalExercisesInMuscleGroupForMicrocycle);
|
|
266
|
-
const remainder = totalSets % totalExercisesInMuscleGroupForMicrocycle;
|
|
267
|
-
return exerciseIndexInMuscleGroupForMicrocycle < remainder
|
|
268
|
-
? baseSetsPerExercise + 1
|
|
269
|
-
: baseSetsPerExercise;
|
|
506
|
+
return maxAddable;
|
|
270
507
|
}
|
|
271
508
|
}
|
|
272
509
|
//# sourceMappingURL=WorkoutVolumePlanningService.js.map
|