@aneuhold/core-ts-db-lib 4.1.13 → 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 +17 -0
- package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.d.ts +1 -1
- package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.d.ts.map +1 -1
- package/lib/ctos/workout/WorkoutMuscleGroupVolumeCTO.ts +1 -1
- package/lib/documents/workout/README.md +43 -9
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.d.ts +6 -2
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.d.ts.map +1 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.js +7 -0
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.js.map +1 -1
- package/lib/services/workout/Mesocycle/WorkoutMesocyclePlanContext.ts +13 -2
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.d.ts +136 -56
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.d.ts.map +1 -1
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.js +304 -254
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.js.map +1 -1
- package/lib/services/workout/util/VolumePlanning/WorkoutVolumePlanningService.ts +471 -330
- package/package.json +1 -1
|
@@ -7,13 +7,18 @@ import type {
|
|
|
7
7
|
import type { WorkoutSessionExercise } from '../../../../documents/workout/WorkoutSessionExercise.js';
|
|
8
8
|
import type WorkoutMesocyclePlanContext from '../../Mesocycle/WorkoutMesocyclePlanContext.js';
|
|
9
9
|
import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionExerciseService.js';
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
/** An exercise eligible to receive additional sets during SFR-based distribution. */
|
|
12
|
+
type SetAdditionCandidate = {
|
|
13
|
+
exerciseId: UUID;
|
|
14
|
+
sfr: number;
|
|
15
|
+
muscleGroupIndex: number;
|
|
16
|
+
previousSetCount: number;
|
|
17
|
+
};
|
|
11
18
|
|
|
12
19
|
/**
|
|
13
20
|
* A service for handling volume planning operations across microcycles.
|
|
14
21
|
*
|
|
15
|
-
* SCOPE: Microcycle-level volume distribution (calculating set counts per exercise)
|
|
16
|
-
*
|
|
17
22
|
* RESPONSIBILITIES:
|
|
18
23
|
* - Calculate set counts for exercises across a microcycle
|
|
19
24
|
* - Apply progressive overload rules (baseline + historical adjustments)
|
|
@@ -26,14 +31,32 @@ import WorkoutSFRService from '../SFR/WorkoutSFRService.js';
|
|
|
26
31
|
* - {@link WorkoutSessionExerciseService} - Used to calculate SFR and recovery recommendations
|
|
27
32
|
*/
|
|
28
33
|
export default class WorkoutVolumePlanningService {
|
|
34
|
+
/**
|
|
35
|
+
* Controls the volume *progression rate* across accumulation microcycles.
|
|
36
|
+
*
|
|
37
|
+
* Both modes start at estimated MEV when historical volume data exists
|
|
38
|
+
* (falling back to 2 sets per exercise when no history is available).
|
|
39
|
+
*
|
|
40
|
+
* When `false` (default): legacy progression — adds 1 set per muscle group
|
|
41
|
+
* every `progressionInterval` microcycles from the MEV starting point.
|
|
42
|
+
*
|
|
43
|
+
* When `true`: MEV-to-MRV interpolation — linearly distributes sets from
|
|
44
|
+
* estimated MEV to estimated MRV across all accumulation microcycles.
|
|
45
|
+
*
|
|
46
|
+
* This flag exists to allow toggling between the two progression algorithms
|
|
47
|
+
* while the MEV-to-MRV approach is validated in practice. Once confidence is
|
|
48
|
+
* established, the flag and legacy path should be removed.
|
|
49
|
+
*/
|
|
50
|
+
static USE_VOLUME_LANDMARK_PROGRESSION = false;
|
|
51
|
+
|
|
29
52
|
private static readonly MAX_SETS_PER_EXERCISE = 8;
|
|
30
53
|
private static readonly MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION = 10;
|
|
31
54
|
|
|
32
55
|
/** Minimum average RSM required for a mesocycle to count toward MEV estimation. */
|
|
33
56
|
private static readonly MEV_RSM_THRESHOLD = 4;
|
|
34
57
|
|
|
35
|
-
/** Default estimated MEV when no qualifying mesocycle history exists. */
|
|
36
|
-
private static readonly
|
|
58
|
+
/** Default estimated MEV when no qualifying mesocycle history exists. Per-exercise value. */
|
|
59
|
+
private static readonly DEFAULT_MEV_PER_EXERCISE = 2;
|
|
37
60
|
|
|
38
61
|
/** Minimum average performance score (or recovery presence) to count a mesocycle toward MRV estimation. */
|
|
39
62
|
private static readonly MRV_PERFORMANCE_THRESHOLD = 2.5;
|
|
@@ -41,20 +64,8 @@ export default class WorkoutVolumePlanningService {
|
|
|
41
64
|
/** Extra sets added above the historical peak when no stressed mesocycles exist, to estimate MRV. */
|
|
42
65
|
private static readonly MRV_HEADROOM = 2;
|
|
43
66
|
|
|
44
|
-
/** Default estimated MRV when no mesocycle history exists at all. */
|
|
45
|
-
private static readonly
|
|
46
|
-
|
|
47
|
-
/** RSM bracket upper bound for "below MEV" proximity (0 to this value inclusive). */
|
|
48
|
-
private static readonly MEV_PROXIMITY_BELOW_THRESHOLD = 3;
|
|
49
|
-
|
|
50
|
-
/** RSM bracket upper bound for "at MEV" proximity (above BELOW threshold up to this value inclusive). */
|
|
51
|
-
private static readonly MEV_PROXIMITY_AT_THRESHOLD = 6;
|
|
52
|
-
|
|
53
|
-
/** Recommended set adjustment when volume is below MEV. */
|
|
54
|
-
private static readonly MEV_BELOW_SET_ADJUSTMENT = 3;
|
|
55
|
-
|
|
56
|
-
/** Recommended set adjustment when volume is above MEV. */
|
|
57
|
-
private static readonly MEV_ABOVE_SET_ADJUSTMENT = -2;
|
|
67
|
+
/** Default estimated MRV when no mesocycle history exists at all. Per-exercise value. */
|
|
68
|
+
private static readonly DEFAULT_MRV_PER_EXERCISE = 8;
|
|
58
69
|
|
|
59
70
|
/** Maximum sets that can be added to a single exercise in one progression step. */
|
|
60
71
|
private static readonly MAX_SET_ADDITION_PER_EXERCISE = 2;
|
|
@@ -94,17 +105,6 @@ export default class WorkoutVolumePlanningService {
|
|
|
94
105
|
}
|
|
95
106
|
});
|
|
96
107
|
|
|
97
|
-
// Apply MEV proximity adjustment when generating the second microcycle (index 1)
|
|
98
|
-
// after the first microcycle is complete with RSM data. Only applies when volume
|
|
99
|
-
// data (volumeCTOs) was provided to the context.
|
|
100
|
-
if (
|
|
101
|
-
microcycleIndex === 1 &&
|
|
102
|
-
!isDeloadMicrocycle &&
|
|
103
|
-
context.muscleGroupToVolumeLandmarkMap.size > 0
|
|
104
|
-
) {
|
|
105
|
-
this.applyMevProximityAdjustments(context, exerciseIdToSetCount);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
108
|
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -133,7 +133,7 @@ export default class WorkoutVolumePlanningService {
|
|
|
133
133
|
} else if (mesocycleHistory.length > 0) {
|
|
134
134
|
estimatedMev = Math.min(...mesocycleHistory.map((m) => m.startingSetCount));
|
|
135
135
|
} else {
|
|
136
|
-
estimatedMev = this.
|
|
136
|
+
estimatedMev = this.DEFAULT_MEV_PER_EXERCISE;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// Estimated MRV
|
|
@@ -151,12 +151,9 @@ export default class WorkoutVolumePlanningService {
|
|
|
151
151
|
} else if (mesocycleHistory.length > 0) {
|
|
152
152
|
estimatedMrv = Math.max(...mesocycleHistory.map((m) => m.peakSetCount)) + this.MRV_HEADROOM;
|
|
153
153
|
} else {
|
|
154
|
-
estimatedMrv = this.
|
|
154
|
+
estimatedMrv = this.DEFAULT_MRV_PER_EXERCISE;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// Hard cap MRV at 10 (MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION)
|
|
158
|
-
estimatedMrv = Math.min(estimatedMrv, this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION);
|
|
159
|
-
|
|
160
157
|
// Ensure MRV > MEV
|
|
161
158
|
if (estimatedMrv <= estimatedMev) {
|
|
162
159
|
estimatedMrv = estimatedMev + 1;
|
|
@@ -167,117 +164,18 @@ export default class WorkoutVolumePlanningService {
|
|
|
167
164
|
return { estimatedMev, estimatedMrv, estimatedMav, mesocycleCount: mesocycleHistory.length };
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
/**
|
|
171
|
-
* Evaluates MEV (Minimum Effective Volume) proximity for a muscle group based on
|
|
172
|
-
* RSM scores from the first microcycle. Called when generating the second microcycle to adjust
|
|
173
|
-
* the volume baseline.
|
|
174
|
-
*
|
|
175
|
-
* Returns `null` when the first microcycle is incomplete or has no RSM data for the muscle group.
|
|
176
|
-
*
|
|
177
|
-
* @param context The mesocycle planning context containing microcycle/session/exercise data.
|
|
178
|
-
* @param muscleGroupId The muscle group to evaluate.
|
|
179
|
-
*/
|
|
180
|
-
static evaluateMevProximity(
|
|
181
|
-
context: WorkoutMesocyclePlanContext,
|
|
182
|
-
muscleGroupId: UUID
|
|
183
|
-
): {
|
|
184
|
-
/** 'below' = RSM 0-3, 'at' = RSM 4-6, 'above' = RSM 7-9 */
|
|
185
|
-
proximity: 'below' | 'at' | 'above';
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Recommended total set adjustment for this muscle group.
|
|
189
|
-
* Positive = add sets, negative = remove sets, 0 = no change.
|
|
190
|
-
* Range: -2 to +3
|
|
191
|
-
*/
|
|
192
|
-
recommendedSetAdjustment: number;
|
|
193
|
-
|
|
194
|
-
/** The average RSM across session exercises targeting this muscle group. */
|
|
195
|
-
averageRsm: number;
|
|
196
|
-
} | null {
|
|
197
|
-
const firstMicrocycle = context.microcyclesInOrder[0];
|
|
198
|
-
if (!firstMicrocycle) return null;
|
|
199
|
-
|
|
200
|
-
// Check the first microcycle is complete
|
|
201
|
-
const lastSessionId = firstMicrocycle.sessionOrder[firstMicrocycle.sessionOrder.length - 1];
|
|
202
|
-
if (!context.sessionMap.get(lastSessionId)?.complete) return null;
|
|
203
|
-
|
|
204
|
-
// Collect RSM totals from session exercises that target this muscle group
|
|
205
|
-
const rsmTotals: number[] = [];
|
|
206
|
-
for (const sessionId of firstMicrocycle.sessionOrder) {
|
|
207
|
-
const session = context.sessionMap.get(sessionId);
|
|
208
|
-
if (!session) continue;
|
|
209
|
-
for (const seId of session.sessionExerciseOrder) {
|
|
210
|
-
const se = context.sessionExerciseMap.get(seId);
|
|
211
|
-
if (!se) continue;
|
|
212
|
-
const exercise = context.exerciseMap.get(se.workoutExerciseId);
|
|
213
|
-
if (!exercise?.primaryMuscleGroups.includes(muscleGroupId)) continue;
|
|
214
|
-
|
|
215
|
-
const rsmTotal = WorkoutSFRService.getRsmTotal(se.rsm);
|
|
216
|
-
if (rsmTotal !== null) {
|
|
217
|
-
rsmTotals.push(rsmTotal);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (rsmTotals.length === 0) return null;
|
|
223
|
-
|
|
224
|
-
const averageRsm = rsmTotals.reduce((sum, val) => sum + val, 0) / rsmTotals.length;
|
|
225
|
-
const bracket = Math.floor(averageRsm);
|
|
226
|
-
|
|
227
|
-
if (bracket <= this.MEV_PROXIMITY_BELOW_THRESHOLD) {
|
|
228
|
-
return {
|
|
229
|
-
proximity: 'below',
|
|
230
|
-
recommendedSetAdjustment: this.MEV_BELOW_SET_ADJUSTMENT,
|
|
231
|
-
averageRsm
|
|
232
|
-
};
|
|
233
|
-
} else if (bracket <= this.MEV_PROXIMITY_AT_THRESHOLD) {
|
|
234
|
-
return { proximity: 'at', recommendedSetAdjustment: 0, averageRsm };
|
|
235
|
-
}
|
|
236
|
-
return {
|
|
237
|
-
proximity: 'above',
|
|
238
|
-
recommendedSetAdjustment: this.MEV_ABOVE_SET_ADJUSTMENT,
|
|
239
|
-
averageRsm
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Applies MEV proximity adjustments based on RSM data from the first microcycle.
|
|
245
|
-
* Adjusts set counts per muscle group when the first microcycle indicates volume
|
|
246
|
-
* was below or above MEV.
|
|
247
|
-
*/
|
|
248
|
-
private static applyMevProximityAdjustments(
|
|
249
|
-
context: WorkoutMesocyclePlanContext,
|
|
250
|
-
exerciseIdToSetCount: Map<UUID, number>
|
|
251
|
-
): void {
|
|
252
|
-
if (!context.muscleGroupToExerciseCTOsMap) return;
|
|
253
|
-
|
|
254
|
-
for (const [muscleGroupId, muscleGroupExerciseCTOs] of context.muscleGroupToExerciseCTOsMap) {
|
|
255
|
-
const mevResult = this.evaluateMevProximity(context, muscleGroupId);
|
|
256
|
-
if (!mevResult || mevResult.recommendedSetAdjustment === 0) continue;
|
|
257
|
-
|
|
258
|
-
// Distribute the adjustment evenly across exercises in this muscle group
|
|
259
|
-
const exerciseCount = muscleGroupExerciseCTOs.length;
|
|
260
|
-
const adjustmentPerExercise = Math.floor(mevResult.recommendedSetAdjustment / exerciseCount);
|
|
261
|
-
const adjustmentRemainder = mevResult.recommendedSetAdjustment % exerciseCount;
|
|
262
|
-
|
|
263
|
-
muscleGroupExerciseCTOs.forEach((cto, index) => {
|
|
264
|
-
const currentSets = exerciseIdToSetCount.get(cto._id) ?? 2;
|
|
265
|
-
const extra =
|
|
266
|
-
index < Math.abs(adjustmentRemainder) ? Math.sign(mevResult.recommendedSetAdjustment) : 0;
|
|
267
|
-
const newSets = Math.max(
|
|
268
|
-
1,
|
|
269
|
-
Math.min(currentSets + adjustmentPerExercise + extra, this.MAX_SETS_PER_EXERCISE)
|
|
270
|
-
);
|
|
271
|
-
exerciseIdToSetCount.set(cto._id, newSets);
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
167
|
/**
|
|
277
168
|
* Calculates the set count for each exercise in a particular muscle group for this microcycle.
|
|
278
169
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
170
|
+
* Pipeline:
|
|
171
|
+
* 1. **Volume targets** — Determine start/end volume from landmarks or defaults
|
|
172
|
+
* 2. **Baseline** — Calculate default set counts from progression rules
|
|
173
|
+
* 3. **Resolve history** — Find the most recent session exercise data for each exercise
|
|
174
|
+
* 4. **Apply history** — Override baselines with historical set counts (or MAV for recovery returns)
|
|
175
|
+
* 5. **Evaluate SFR** — Determine recovery exercises and candidates for set additions
|
|
176
|
+
* 6. **Distribute sets** — Allocate added sets to candidates by SFR quality
|
|
177
|
+
*
|
|
178
|
+
* Falls back to baseline when no previous microcycle data exists.
|
|
281
179
|
*/
|
|
282
180
|
private static calculateSetCountForEachExerciseInMuscleGroup(
|
|
283
181
|
context: WorkoutMesocyclePlanContext,
|
|
@@ -288,22 +186,38 @@ export default class WorkoutVolumePlanningService {
|
|
|
288
186
|
const exerciseIdToSetCount = new Map<UUID, number>();
|
|
289
187
|
const recoveryExerciseIds = new Set<UUID>();
|
|
290
188
|
const sessionIndexToExerciseIds = new Map<number, UUID[]>();
|
|
291
|
-
|
|
292
189
|
const { progressionInterval } = context;
|
|
293
190
|
|
|
294
|
-
// 1.
|
|
191
|
+
// 1. Look up volume landmarks and compute volume targets
|
|
192
|
+
const primaryMuscleGroupId = muscleGroupExerciseCTOs[0]?.primaryMuscleGroups[0];
|
|
193
|
+
const volumeLandmark = primaryMuscleGroupId
|
|
194
|
+
? context.muscleGroupToVolumeLandmarkMap.get(primaryMuscleGroupId)
|
|
195
|
+
: undefined;
|
|
196
|
+
|
|
197
|
+
const { startVolume, endVolume } = this.getVolumeTargetsForMuscleGroup(
|
|
198
|
+
volumeLandmark,
|
|
199
|
+
muscleGroupExerciseCTOs.length,
|
|
200
|
+
progressionInterval
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// 2. Calculate baseline set counts for all exercises in the muscle group
|
|
204
|
+
const baselineCounts = this.calculateBaselineSetCounts(
|
|
205
|
+
microcycleIndex,
|
|
206
|
+
context.accumulationMicrocycleCount,
|
|
207
|
+
muscleGroupExerciseCTOs.length,
|
|
208
|
+
startVolume,
|
|
209
|
+
endVolume,
|
|
210
|
+
isDeloadMicrocycle,
|
|
211
|
+
progressionInterval
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// 3. Assign baselines and build session-to-exercise index
|
|
295
215
|
muscleGroupExerciseCTOs.forEach((cto, index) => {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
index,
|
|
300
|
-
isDeloadMicrocycle,
|
|
301
|
-
progressionInterval
|
|
216
|
+
exerciseIdToSetCount.set(
|
|
217
|
+
cto._id,
|
|
218
|
+
Math.min(baselineCounts[index], this.MAX_SETS_PER_EXERCISE)
|
|
302
219
|
);
|
|
303
|
-
exerciseIdToSetCount.set(cto._id, Math.min(baseline, this.MAX_SETS_PER_EXERCISE));
|
|
304
220
|
|
|
305
|
-
// Build out the map for session indices to the array of exercise IDs as it pertains to this
|
|
306
|
-
// muscle group.
|
|
307
221
|
if (!context.exerciseIdToSessionIndex) return;
|
|
308
222
|
const exerciseSessionIndex = context.exerciseIdToSessionIndex.get(cto._id);
|
|
309
223
|
if (exerciseSessionIndex === undefined) return;
|
|
@@ -313,17 +227,204 @@ export default class WorkoutVolumePlanningService {
|
|
|
313
227
|
sessionIndexToExerciseIds.set(exerciseSessionIndex, existingExerciseIdsForSession);
|
|
314
228
|
});
|
|
315
229
|
|
|
316
|
-
//
|
|
317
|
-
|
|
230
|
+
// 4. Resolve historical data — returns null if no usable history exists
|
|
231
|
+
const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
|
|
232
|
+
const historicalData = this.resolveHistoricalExerciseData(
|
|
233
|
+
context,
|
|
234
|
+
microcycleIndex,
|
|
235
|
+
exerciseIds
|
|
236
|
+
);
|
|
237
|
+
if (!historicalData) return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
238
|
+
|
|
239
|
+
// 5. Apply historical set counts (overrides baselines)
|
|
240
|
+
this.applyHistoricalSetCounts(
|
|
241
|
+
muscleGroupExerciseCTOs,
|
|
242
|
+
historicalData.exerciseIdToPrevSessionExercise,
|
|
243
|
+
historicalData.exercisesThatWerePreviouslyInRecovery,
|
|
244
|
+
exerciseIdToSetCount,
|
|
245
|
+
isDeloadMicrocycle,
|
|
246
|
+
volumeLandmark
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Deload microcycles and zero-progression cycles (Resensitization) skip SFR-based set additions
|
|
250
|
+
if (isDeloadMicrocycle || progressionInterval === 0) {
|
|
251
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 6. Evaluate SFR recommendations (per-exercise SFR + recovery detection)
|
|
255
|
+
const { totalSetsToAdd, candidates } = this.evaluateSfrRecommendations(
|
|
256
|
+
context,
|
|
257
|
+
muscleGroupExerciseCTOs,
|
|
258
|
+
historicalData.exerciseIdToPrevSessionExercise,
|
|
259
|
+
historicalData.exercisesThatWerePreviouslyInRecovery,
|
|
260
|
+
exerciseIdToSetCount,
|
|
261
|
+
recoveryExerciseIds,
|
|
262
|
+
sessionIndexToExerciseIds
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
if (totalSetsToAdd === 0 || candidates.length === 0) {
|
|
266
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 7. Distribute added sets to candidates by SFR quality
|
|
270
|
+
this.distributeSetsToExercises(
|
|
271
|
+
candidates,
|
|
272
|
+
totalSetsToAdd,
|
|
273
|
+
exerciseIdToSetCount,
|
|
274
|
+
context.exerciseIdToSessionIndex,
|
|
275
|
+
sessionIndexToExerciseIds
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Determines the start and end volume targets for a muscle group based on
|
|
283
|
+
* historical volume landmarks and cycle type.
|
|
284
|
+
*
|
|
285
|
+
* @param volumeLandmark Volume landmark estimate from historical data, if available.
|
|
286
|
+
* @param exerciseCount Number of exercises in the muscle group.
|
|
287
|
+
* @param progressionInterval Cycle-type progression interval (1=MuscleGain, 2=Cut, 0=Resensitization).
|
|
288
|
+
*/
|
|
289
|
+
private static getVolumeTargetsForMuscleGroup(
|
|
290
|
+
volumeLandmark: WorkoutVolumeLandmarkEstimate | undefined,
|
|
291
|
+
exerciseCount: number,
|
|
292
|
+
progressionInterval: number
|
|
293
|
+
): { startVolume: number; endVolume: number } {
|
|
294
|
+
// Resensitization (interval=0): flat at estimated MEV (or default when no history)
|
|
295
|
+
if (progressionInterval === 0) {
|
|
296
|
+
const flatVolume =
|
|
297
|
+
volumeLandmark && volumeLandmark.mesocycleCount > 0
|
|
298
|
+
? volumeLandmark.estimatedMev
|
|
299
|
+
: this.DEFAULT_MEV_PER_EXERCISE * exerciseCount;
|
|
300
|
+
return { startVolume: flatVolume, endVolume: flatVolume };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// With historical volume landmarks
|
|
304
|
+
if (volumeLandmark && volumeLandmark.mesocycleCount > 0) {
|
|
305
|
+
const startVolume = volumeLandmark.estimatedMev;
|
|
306
|
+
// Cut: progress to MAV (midpoint), not MRV
|
|
307
|
+
let endVolume =
|
|
308
|
+
progressionInterval === 2 ? volumeLandmark.estimatedMav : volumeLandmark.estimatedMrv;
|
|
309
|
+
|
|
310
|
+
if (endVolume <= startVolume) {
|
|
311
|
+
endVolume = startVolume + 1;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { startVolume, endVolume };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// No history: use per-exercise defaults
|
|
318
|
+
const startVolume = this.DEFAULT_MEV_PER_EXERCISE * exerciseCount;
|
|
319
|
+
const mrvTotal = this.DEFAULT_MRV_PER_EXERCISE * exerciseCount;
|
|
320
|
+
|
|
321
|
+
// Cut: target MAV (midpoint), not MRV
|
|
322
|
+
const endVolume =
|
|
323
|
+
progressionInterval === 2 ? Math.ceil((startVolume + mrvTotal) / 2) : mrvTotal;
|
|
324
|
+
|
|
325
|
+
return { startVolume, endVolume };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Calculates the baseline set counts for all exercises in a muscle group for a given microcycle.
|
|
330
|
+
*
|
|
331
|
+
* When {@link USE_VOLUME_LANDMARK_PROGRESSION} is `false` (default), uses the legacy progression
|
|
332
|
+
* rate: +1 set per muscle group every `progressionInterval` microcycles from the starting volume.
|
|
333
|
+
*
|
|
334
|
+
* When `true`, linearly interpolates from `startVolume` to `endVolume` across accumulation
|
|
335
|
+
* microcycles.
|
|
336
|
+
*
|
|
337
|
+
* @param microcycleIndex The index of the current microcycle.
|
|
338
|
+
* @param accumulationMicrocycleCount Number of accumulation (non-deload) microcycles.
|
|
339
|
+
* @param exerciseCount Number of exercises in the muscle group.
|
|
340
|
+
* @param startVolume Total muscle-group volume at microcycle 0.
|
|
341
|
+
* @param endVolume Total muscle-group volume target at the last accumulation microcycle.
|
|
342
|
+
* @param isDeloadMicrocycle Whether this is a deload microcycle.
|
|
343
|
+
* @param progressionInterval Microcycles between each set addition (0 = no progression).
|
|
344
|
+
*/
|
|
345
|
+
private static calculateBaselineSetCounts(
|
|
346
|
+
microcycleIndex: number,
|
|
347
|
+
accumulationMicrocycleCount: number,
|
|
348
|
+
exerciseCount: number,
|
|
349
|
+
startVolume: number,
|
|
350
|
+
endVolume: number,
|
|
351
|
+
isDeloadMicrocycle: boolean,
|
|
352
|
+
progressionInterval: number
|
|
353
|
+
): number[] {
|
|
354
|
+
if (isDeloadMicrocycle) {
|
|
355
|
+
const lastAccumulationCounts = this.calculateBaselineSetCounts(
|
|
356
|
+
microcycleIndex - 1,
|
|
357
|
+
accumulationMicrocycleCount,
|
|
358
|
+
exerciseCount,
|
|
359
|
+
startVolume,
|
|
360
|
+
endVolume,
|
|
361
|
+
false,
|
|
362
|
+
progressionInterval
|
|
363
|
+
);
|
|
364
|
+
return lastAccumulationCounts.map((count) => Math.max(1, Math.floor(count / 2)));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let totalSets: number;
|
|
368
|
+
|
|
369
|
+
if (this.USE_VOLUME_LANDMARK_PROGRESSION) {
|
|
370
|
+
// Linear interpolation from startVolume to endVolume
|
|
371
|
+
if (accumulationMicrocycleCount <= 1) {
|
|
372
|
+
totalSets = startVolume;
|
|
373
|
+
} else {
|
|
374
|
+
totalSets = Math.round(
|
|
375
|
+
startVolume +
|
|
376
|
+
((endVolume - startVolume) * microcycleIndex) / (accumulationMicrocycleCount - 1)
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
// Legacy progression: +1 set per muscle group per progressionInterval microcycles
|
|
381
|
+
const progressionSets =
|
|
382
|
+
progressionInterval === 0 ? 0 : Math.ceil(microcycleIndex / progressionInterval);
|
|
383
|
+
totalSets = startVolume + progressionSets;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return this.distributeEvenly(totalSets, exerciseCount);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Distributes a total evenly across N slots, with remainder going to earlier slots.
|
|
391
|
+
*
|
|
392
|
+
* @param total The total to distribute.
|
|
393
|
+
* @param slots The number of slots to distribute across.
|
|
394
|
+
*/
|
|
395
|
+
private static distributeEvenly(total: number, slots: number): number[] {
|
|
396
|
+
const base = Math.floor(total / slots);
|
|
397
|
+
const remainder = total % slots;
|
|
398
|
+
return Array.from({ length: slots }, (_, i) => (i < remainder ? base + 1 : base));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Walks backward through completed microcycles to find the most recent non-recovery
|
|
403
|
+
* session exercise for each exercise in the muscle group.
|
|
404
|
+
*
|
|
405
|
+
* Returns `null` when no usable historical data exists (no previous microcycle, or
|
|
406
|
+
* previous microcycles are incomplete/have no matching exercises).
|
|
407
|
+
*
|
|
408
|
+
* @param context The mesocycle planning context.
|
|
409
|
+
* @param microcycleIndex The current microcycle index.
|
|
410
|
+
* @param exerciseIds The exercise IDs to search for.
|
|
411
|
+
*/
|
|
412
|
+
private static resolveHistoricalExerciseData(
|
|
413
|
+
context: WorkoutMesocyclePlanContext,
|
|
414
|
+
microcycleIndex: number,
|
|
415
|
+
exerciseIds: Set<UUID>
|
|
416
|
+
): {
|
|
417
|
+
exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>;
|
|
418
|
+
exercisesThatWerePreviouslyInRecovery: Set<UUID>;
|
|
419
|
+
} | null {
|
|
318
420
|
let previousMicrocycleIndex = microcycleIndex - 1;
|
|
319
421
|
let previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
|
|
320
|
-
if (!previousMicrocycle) return
|
|
422
|
+
if (!previousMicrocycle) return null;
|
|
321
423
|
|
|
322
|
-
// Map previous session exercises
|
|
323
|
-
const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
|
|
324
424
|
const exerciseIdToPrevSessionExercise = new Map<UUID, WorkoutSessionExercise>();
|
|
325
425
|
const foundExerciseIds = new Set<UUID>();
|
|
326
426
|
const exercisesThatWerePreviouslyInRecovery = new Set<UUID>();
|
|
427
|
+
|
|
327
428
|
// Loop through each previous microcycle until we find all exercises or run out of microcycles
|
|
328
429
|
while (exerciseIdToPrevSessionExercise.size < exerciseIds.size && previousMicrocycle) {
|
|
329
430
|
// Check if the previous microcycle is complete; if not, we cannot use its data
|
|
@@ -334,14 +435,12 @@ export default class WorkoutVolumePlanningService {
|
|
|
334
435
|
break;
|
|
335
436
|
}
|
|
336
437
|
|
|
337
|
-
//
|
|
438
|
+
// Scan all session exercises in this microcycle for matching non-recovery exercises
|
|
338
439
|
for (const sessionId of previousMicrocycle.sessionOrder) {
|
|
339
440
|
const session = context.sessionMap.get(sessionId);
|
|
340
441
|
if (!session) continue;
|
|
341
|
-
// Get the session exercises for this session
|
|
342
442
|
for (const sessionExerciseId of session.sessionExerciseOrder) {
|
|
343
443
|
const sessionExercise = context.sessionExerciseMap.get(sessionExerciseId);
|
|
344
|
-
// Map if in our muscle group && it isn't a recovery exercise
|
|
345
444
|
if (
|
|
346
445
|
sessionExercise &&
|
|
347
446
|
exerciseIds.has(sessionExercise.workoutExerciseId) &&
|
|
@@ -350,91 +449,104 @@ export default class WorkoutVolumePlanningService {
|
|
|
350
449
|
) {
|
|
351
450
|
exerciseIdToPrevSessionExercise.set(sessionExercise.workoutExerciseId, sessionExercise);
|
|
352
451
|
foundExerciseIds.add(sessionExercise.workoutExerciseId);
|
|
452
|
+
// If we had to go back more than one microcycle, the exercise was in recovery
|
|
353
453
|
if (previousMicrocycleIndex < microcycleIndex - 1) {
|
|
354
454
|
exercisesThatWerePreviouslyInRecovery.add(sessionExercise.workoutExerciseId);
|
|
355
455
|
}
|
|
356
456
|
}
|
|
357
457
|
}
|
|
358
458
|
}
|
|
359
|
-
// Move to earlier microcycle
|
|
360
459
|
previousMicrocycleIndex = previousMicrocycleIndex - 1;
|
|
361
460
|
previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
|
|
362
461
|
}
|
|
363
|
-
if (exerciseIdToPrevSessionExercise.size === 0)
|
|
364
|
-
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
365
462
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
463
|
+
if (exerciseIdToPrevSessionExercise.size === 0) return null;
|
|
464
|
+
return { exerciseIdToPrevSessionExercise, exercisesThatWerePreviouslyInRecovery };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Overrides baseline set counts with historical data from the previous microcycle.
|
|
469
|
+
*
|
|
470
|
+
* For exercises returning from recovery, distributes the estimated MAV from volume
|
|
471
|
+
* landmarks proportionally across exercises. For deload microcycles, halves the
|
|
472
|
+
* historical count (minimum 1 set).
|
|
473
|
+
*
|
|
474
|
+
* @param muscleGroupExerciseCTOs Exercises in this muscle group.
|
|
475
|
+
* @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
|
|
476
|
+
* @param exercisesThatWerePreviouslyInRecovery Exercises returning from recovery.
|
|
477
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated).
|
|
478
|
+
* @param isDeloadMicrocycle Whether this is a deload microcycle.
|
|
479
|
+
* @param volumeLandmark Volume landmark estimate for the primary muscle group, if available.
|
|
480
|
+
*/
|
|
481
|
+
private static applyHistoricalSetCounts(
|
|
482
|
+
muscleGroupExerciseCTOs: WorkoutExerciseCTO[],
|
|
483
|
+
exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>,
|
|
484
|
+
exercisesThatWerePreviouslyInRecovery: Set<UUID>,
|
|
485
|
+
exerciseIdToSetCount: Map<UUID, number>,
|
|
486
|
+
isDeloadMicrocycle: boolean,
|
|
487
|
+
volumeLandmark: WorkoutVolumeLandmarkEstimate | undefined
|
|
488
|
+
): void {
|
|
489
|
+
// Pre-compute per-exercise MAV distribution for exercises returning from recovery.
|
|
490
|
+
// Uses floor-based even distribution (conservative) per the source material's guidance
|
|
491
|
+
// to err on the lighter side when returning from recovery.
|
|
492
|
+
const hasExerciseReturningFromRecovery = muscleGroupExerciseCTOs.some((cto) =>
|
|
493
|
+
exercisesThatWerePreviouslyInRecovery.has(cto._id)
|
|
494
|
+
);
|
|
495
|
+
const mavDistribution =
|
|
496
|
+
hasExerciseReturningFromRecovery && volumeLandmark
|
|
497
|
+
? this.distributeEvenly(volumeLandmark.estimatedMav, muscleGroupExerciseCTOs.length)
|
|
498
|
+
: undefined;
|
|
377
499
|
|
|
378
|
-
muscleGroupExerciseCTOs.forEach((cto) => {
|
|
500
|
+
muscleGroupExerciseCTOs.forEach((cto, index) => {
|
|
379
501
|
const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
|
|
380
|
-
if (previousSessionExercise)
|
|
381
|
-
let setCount: number;
|
|
382
|
-
|
|
383
|
-
if (exercisesThatWerePreviouslyInRecovery.has(cto._id) && volumeLandmark) {
|
|
384
|
-
// Exercise is returning from recovery — resume at the estimated MAV
|
|
385
|
-
setCount = Math.min(volumeLandmark.estimatedMav, this.MAX_SETS_PER_EXERCISE);
|
|
386
|
-
} else {
|
|
387
|
-
setCount = Math.min(previousSessionExercise.setOrder.length, this.MAX_SETS_PER_EXERCISE);
|
|
388
|
-
}
|
|
502
|
+
if (!previousSessionExercise) return;
|
|
389
503
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
504
|
+
let setCount: number;
|
|
505
|
+
if (exercisesThatWerePreviouslyInRecovery.has(cto._id) && mavDistribution) {
|
|
506
|
+
// Returning from recovery — resume at conservatively distributed MAV
|
|
507
|
+
setCount = Math.min(mavDistribution[index], this.MAX_SETS_PER_EXERCISE);
|
|
508
|
+
} else {
|
|
509
|
+
// Carry forward last microcycle's set count
|
|
510
|
+
setCount = Math.min(previousSessionExercise.setOrder.length, this.MAX_SETS_PER_EXERCISE);
|
|
394
511
|
}
|
|
395
|
-
});
|
|
396
512
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Determines if the session for the given exercise is already capped for this muscle group.
|
|
404
|
-
*/
|
|
405
|
-
function sessionIsCapped(exerciseId: UUID): boolean {
|
|
406
|
-
if (!context.exerciseIdToSessionIndex) {
|
|
407
|
-
throw new Error(
|
|
408
|
-
'WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.'
|
|
409
|
-
);
|
|
513
|
+
if (isDeloadMicrocycle) {
|
|
514
|
+
// Deload: halve volume, minimum 1 set
|
|
515
|
+
setCount = Math.max(1, Math.floor(setCount / 2));
|
|
410
516
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
415
|
-
if (!exerciseIdsInSession) return false;
|
|
416
|
-
let totalSetsInSession = 0;
|
|
417
|
-
exerciseIdsInSession.forEach((id) => {
|
|
418
|
-
// Use the sets from the previous microcycle's session exercise
|
|
419
|
-
totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
|
|
420
|
-
});
|
|
421
|
-
return (
|
|
422
|
-
totalSetsInSession >= WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION
|
|
423
|
-
);
|
|
424
|
-
}
|
|
517
|
+
exerciseIdToSetCount.set(cto._id, setCount);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
425
520
|
|
|
426
|
-
|
|
521
|
+
/**
|
|
522
|
+
* Evaluates each exercise's performance feedback to determine recovery exercises,
|
|
523
|
+
* set addition recommendations, and candidates for volume increases.
|
|
524
|
+
*
|
|
525
|
+
* @param context The mesocycle planning context.
|
|
526
|
+
* @param muscleGroupExerciseCTOs Exercises in this muscle group.
|
|
527
|
+
* @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
|
|
528
|
+
* @param exercisesThatWerePreviouslyInRecovery Exercises returning from recovery.
|
|
529
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated for recovery exercises).
|
|
530
|
+
* @param recoveryExerciseIds Set of exercise IDs flagged for recovery (mutated).
|
|
531
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
532
|
+
*/
|
|
533
|
+
private static evaluateSfrRecommendations(
|
|
534
|
+
context: WorkoutMesocyclePlanContext,
|
|
535
|
+
muscleGroupExerciseCTOs: WorkoutExerciseCTO[],
|
|
536
|
+
exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>,
|
|
537
|
+
exercisesThatWerePreviouslyInRecovery: Set<UUID>,
|
|
538
|
+
exerciseIdToSetCount: Map<UUID, number>,
|
|
539
|
+
recoveryExerciseIds: Set<UUID>,
|
|
540
|
+
sessionIndexToExerciseIds: Map<number, UUID[]>
|
|
541
|
+
): { totalSetsToAdd: number; candidates: SetAdditionCandidate[] } {
|
|
427
542
|
let totalSetsToAdd = 0;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
sfr: number;
|
|
431
|
-
muscleGroupIndex: number;
|
|
432
|
-
previousSetCount: number;
|
|
433
|
-
}[] = [];
|
|
543
|
+
|
|
544
|
+
const candidates: SetAdditionCandidate[] = [];
|
|
434
545
|
muscleGroupExerciseCTOs.forEach((cto, muscleGroupIndex) => {
|
|
435
546
|
const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
|
|
436
547
|
if (!previousSessionExercise) return;
|
|
437
548
|
|
|
549
|
+
// Get SFR-based recommendation: positive = add sets, 0 = maintain, -1 = recovery needed
|
|
438
550
|
let recommendation: number | null;
|
|
439
551
|
if (!exercisesThatWerePreviouslyInRecovery.has(cto._id)) {
|
|
440
552
|
recommendation =
|
|
@@ -442,26 +554,29 @@ export default class WorkoutVolumePlanningService {
|
|
|
442
554
|
previousSessionExercise
|
|
443
555
|
);
|
|
444
556
|
} else {
|
|
445
|
-
//
|
|
446
|
-
// the only thing we are overriding. We still want to use the historical data for
|
|
447
|
-
// SFR calculations, even though that one was the one that triggered a recovery session.
|
|
448
|
-
// This should make it so that it is less likely to have sets added to it.
|
|
557
|
+
// Returning from recovery — don't add sets yet, but still use SFR for candidate ranking
|
|
449
558
|
recommendation = 0;
|
|
450
559
|
}
|
|
451
560
|
|
|
452
561
|
if (recommendation === -1) {
|
|
562
|
+
// Recovery needed: halve sets and flag as recovery exercise
|
|
453
563
|
recoveryExerciseIds.add(cto._id);
|
|
454
|
-
// Cut sets in half (rounded down, minimum 1) for recovery
|
|
455
564
|
const previousSetCount = previousSessionExercise.setOrder.length;
|
|
456
565
|
const recoverySets = Math.max(1, Math.floor(previousSetCount / 2));
|
|
457
566
|
exerciseIdToSetCount.set(cto._id, recoverySets);
|
|
458
567
|
} else if (recommendation !== null && recommendation >= 0) {
|
|
568
|
+
// Accumulate SFR-recommended additions into shared total
|
|
459
569
|
totalSetsToAdd += recommendation;
|
|
460
570
|
|
|
461
|
-
//
|
|
571
|
+
// Only exercises below per-exercise cap and in uncapped sessions are candidates
|
|
462
572
|
if (
|
|
463
573
|
previousSessionExercise.setOrder.length < this.MAX_SETS_PER_EXERCISE &&
|
|
464
|
-
!sessionIsCapped(
|
|
574
|
+
!this.sessionIsCapped(
|
|
575
|
+
cto._id,
|
|
576
|
+
context.exerciseIdToSessionIndex,
|
|
577
|
+
sessionIndexToExerciseIds,
|
|
578
|
+
exerciseIdToPrevSessionExercise
|
|
579
|
+
)
|
|
465
580
|
) {
|
|
466
581
|
candidates.push({
|
|
467
582
|
exerciseId: cto._id,
|
|
@@ -476,126 +591,152 @@ export default class WorkoutVolumePlanningService {
|
|
|
476
591
|
}
|
|
477
592
|
});
|
|
478
593
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
482
|
-
}
|
|
594
|
+
return { totalSetsToAdd, candidates };
|
|
595
|
+
}
|
|
483
596
|
|
|
484
|
-
|
|
485
|
-
|
|
597
|
+
/**
|
|
598
|
+
* Distributes added sets across candidate exercises, prioritizing higher SFR scores.
|
|
599
|
+
* Respects per-exercise, per-session, and per-addition caps.
|
|
600
|
+
*
|
|
601
|
+
* @param candidates Exercises eligible for set additions, with SFR scores.
|
|
602
|
+
* @param totalSetsToAdd Total sets recommended for addition (before capping).
|
|
603
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated).
|
|
604
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
605
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
606
|
+
*/
|
|
607
|
+
private static distributeSetsToExercises(
|
|
608
|
+
candidates: SetAdditionCandidate[],
|
|
609
|
+
totalSetsToAdd: number,
|
|
610
|
+
exerciseIdToSetCount: Map<UUID, number>,
|
|
611
|
+
exerciseIdToSessionIndex: Map<UUID, number> | undefined,
|
|
612
|
+
sessionIndexToExerciseIds: Map<number, UUID[]>
|
|
613
|
+
): void {
|
|
614
|
+
// Prioritize exercises with highest SFR (best stimulus-to-fatigue ratio)
|
|
486
615
|
candidates.sort((candidateA, candidateB) =>
|
|
487
616
|
candidateA.sfr !== candidateB.sfr
|
|
488
617
|
? candidateB.sfr - candidateA.sfr
|
|
489
618
|
: candidateA.muscleGroupIndex - candidateB.muscleGroupIndex
|
|
490
619
|
);
|
|
491
620
|
|
|
492
|
-
|
|
493
|
-
* Gets the total sets currently planned for a session.
|
|
494
|
-
*/
|
|
495
|
-
function getSessionTotal(exerciseId: UUID): number {
|
|
496
|
-
if (!context.exerciseIdToSessionIndex) return 0;
|
|
497
|
-
const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
|
|
498
|
-
if (sessionIndex === undefined) return 0;
|
|
499
|
-
|
|
500
|
-
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
501
|
-
if (!exerciseIdsInSession) return 0;
|
|
502
|
-
|
|
503
|
-
let total = 0;
|
|
504
|
-
exerciseIdsInSession.forEach((id) => {
|
|
505
|
-
total += exerciseIdToSetCount.get(id) || 0;
|
|
506
|
-
});
|
|
507
|
-
return total;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Attempts to add sets to an exercise, respecting all constraints.
|
|
512
|
-
* Returns the number of sets actually added.
|
|
513
|
-
*/
|
|
514
|
-
function addSetsToExercise(exerciseId: UUID, setsToAdd: number): number {
|
|
515
|
-
const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
|
|
516
|
-
const sessionTotal = getSessionTotal(exerciseId);
|
|
517
|
-
|
|
518
|
-
const maxDueToExerciseLimit =
|
|
519
|
-
WorkoutVolumePlanningService.MAX_SETS_PER_EXERCISE - currentSets;
|
|
520
|
-
const maxDueToSessionLimit =
|
|
521
|
-
WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
|
|
522
|
-
const maxAddable = Math.min(
|
|
523
|
-
setsToAdd,
|
|
524
|
-
maxDueToExerciseLimit,
|
|
525
|
-
maxDueToSessionLimit,
|
|
526
|
-
WorkoutVolumePlanningService.MAX_SET_ADDITION_PER_EXERCISE
|
|
527
|
-
);
|
|
528
|
-
|
|
529
|
-
if (maxAddable > 0) {
|
|
530
|
-
exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
|
|
531
|
-
}
|
|
532
|
-
return maxAddable;
|
|
533
|
-
}
|
|
534
|
-
|
|
621
|
+
// Cap total additions at MAX_TOTAL_SET_ADDITIONS regardless of raw recommendation
|
|
535
622
|
let setsRemaining =
|
|
536
|
-
totalSetsToAdd >=
|
|
537
|
-
?
|
|
623
|
+
totalSetsToAdd >= this.MAX_TOTAL_SET_ADDITIONS
|
|
624
|
+
? this.MAX_TOTAL_SET_ADDITIONS
|
|
538
625
|
: totalSetsToAdd;
|
|
626
|
+
|
|
627
|
+
// Distribute sets one candidate at a time until budget is exhausted
|
|
539
628
|
for (const candidate of candidates) {
|
|
540
|
-
const added = addSetsToExercise(
|
|
629
|
+
const added = this.addSetsToExercise(
|
|
630
|
+
candidate.exerciseId,
|
|
631
|
+
setsRemaining,
|
|
632
|
+
exerciseIdToSetCount,
|
|
633
|
+
exerciseIdToSessionIndex,
|
|
634
|
+
sessionIndexToExerciseIds
|
|
635
|
+
);
|
|
541
636
|
setsRemaining -= added;
|
|
542
637
|
if (setsRemaining === 0) break;
|
|
543
638
|
}
|
|
544
|
-
|
|
545
|
-
return { exerciseIdToSetCount, recoveryExerciseIds };
|
|
546
639
|
}
|
|
547
640
|
|
|
548
641
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* Key rule: set progression is distributed across exercises that share the same primary muscle group
|
|
552
|
-
* for the entire microcycle, regardless of which session those exercises are in.
|
|
642
|
+
* Determines if the session containing the given exercise is already at the
|
|
643
|
+
* per-muscle-group-per-session cap, based on previous microcycle set counts.
|
|
553
644
|
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* - Resensitization (interval 0): no progression (flat 2 sets per exercise)
|
|
559
|
-
*
|
|
560
|
-
* @param microcycleIndex The index of the current microcycle.
|
|
561
|
-
* @param totalExercisesInMuscleGroupForMicrocycle Total exercises in the muscle group for
|
|
562
|
-
* this microcycle.
|
|
563
|
-
* @param exerciseIndexInMuscleGroupForMicrocycle Index of the current exercise within the
|
|
564
|
-
* muscle group.
|
|
565
|
-
* @param isDeloadMicrocycle Whether this is a deload microcycle.
|
|
566
|
-
* @param progressionInterval Number of microcycles between each set addition. 0 means no
|
|
567
|
-
* progression.
|
|
645
|
+
* @param exerciseId The exercise to check.
|
|
646
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
647
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
648
|
+
* @param exerciseIdToPrevSessionExercise Map from exercise ID to its previous session exercise.
|
|
568
649
|
*/
|
|
569
|
-
private static
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const baselineSets = this.calculateBaselineSetCount(
|
|
579
|
-
microcycleIndex - 1,
|
|
580
|
-
totalExercisesInMuscleGroupForMicrocycle,
|
|
581
|
-
exerciseIndexInMuscleGroupForMicrocycle,
|
|
582
|
-
false,
|
|
583
|
-
progressionInterval
|
|
650
|
+
private static sessionIsCapped(
|
|
651
|
+
exerciseId: UUID,
|
|
652
|
+
exerciseIdToSessionIndex: Map<UUID, number> | undefined,
|
|
653
|
+
sessionIndexToExerciseIds: Map<number, UUID[]>,
|
|
654
|
+
exerciseIdToPrevSessionExercise: Map<UUID, WorkoutSessionExercise>
|
|
655
|
+
): boolean {
|
|
656
|
+
if (!exerciseIdToSessionIndex) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
'WorkoutMesocyclePlanContext.exerciseIdToSessionIndex is not initialized. This should be set during mesocycle planning.'
|
|
584
659
|
);
|
|
585
|
-
return Math.max(1, Math.floor(baselineSets / 2));
|
|
586
660
|
}
|
|
661
|
+
const sessionIndex = exerciseIdToSessionIndex.get(exerciseId);
|
|
662
|
+
if (sessionIndex === undefined) return false;
|
|
663
|
+
|
|
664
|
+
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
665
|
+
if (!exerciseIdsInSession) return false;
|
|
666
|
+
let totalSetsInSession = 0;
|
|
667
|
+
exerciseIdsInSession.forEach((id) => {
|
|
668
|
+
totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
|
|
669
|
+
});
|
|
670
|
+
return totalSetsInSession >= this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Gets the total sets currently planned for a session containing the given exercise.
|
|
675
|
+
*
|
|
676
|
+
* @param exerciseId The exercise whose session total to compute.
|
|
677
|
+
* @param exerciseIdToSetCount Current set count assignments.
|
|
678
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
679
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
680
|
+
*/
|
|
681
|
+
private static getSessionSetTotal(
|
|
682
|
+
exerciseId: UUID,
|
|
683
|
+
exerciseIdToSetCount: Map<UUID, number>,
|
|
684
|
+
exerciseIdToSessionIndex: Map<UUID, number> | undefined,
|
|
685
|
+
sessionIndexToExerciseIds: Map<number, UUID[]>
|
|
686
|
+
): number {
|
|
687
|
+
if (!exerciseIdToSessionIndex) return 0;
|
|
688
|
+
const sessionIndex = exerciseIdToSessionIndex.get(exerciseId);
|
|
689
|
+
if (sessionIndex === undefined) return 0;
|
|
587
690
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
progressionInterval === 0 ? 0 : Math.ceil(microcycleIndex / progressionInterval);
|
|
591
|
-
const totalSets = 2 * totalExercisesInMuscleGroupForMicrocycle + progressionSets;
|
|
691
|
+
const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
|
|
692
|
+
if (!exerciseIdsInSession) return 0;
|
|
592
693
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
694
|
+
let total = 0;
|
|
695
|
+
exerciseIdsInSession.forEach((id) => {
|
|
696
|
+
total += exerciseIdToSetCount.get(id) || 0;
|
|
697
|
+
});
|
|
698
|
+
return total;
|
|
699
|
+
}
|
|
596
700
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
701
|
+
/**
|
|
702
|
+
* Attempts to add sets to an exercise, respecting per-exercise, per-session, and
|
|
703
|
+
* per-addition caps. Mutates `exerciseIdToSetCount` in place.
|
|
704
|
+
*
|
|
705
|
+
* @param exerciseId The exercise to add sets to.
|
|
706
|
+
* @param setsToAdd The desired number of sets to add.
|
|
707
|
+
* @param exerciseIdToSetCount Current set count assignments (mutated).
|
|
708
|
+
* @param exerciseIdToSessionIndex Map from exercise ID to its session index.
|
|
709
|
+
* @param sessionIndexToExerciseIds Map from session index to exercise IDs in that session.
|
|
710
|
+
* @returns The number of sets actually added.
|
|
711
|
+
*/
|
|
712
|
+
private static addSetsToExercise(
|
|
713
|
+
exerciseId: UUID,
|
|
714
|
+
setsToAdd: number,
|
|
715
|
+
exerciseIdToSetCount: Map<UUID, number>,
|
|
716
|
+
exerciseIdToSessionIndex: Map<UUID, number> | undefined,
|
|
717
|
+
sessionIndexToExerciseIds: Map<number, UUID[]>
|
|
718
|
+
): number {
|
|
719
|
+
const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
|
|
720
|
+
const sessionTotal = this.getSessionSetTotal(
|
|
721
|
+
exerciseId,
|
|
722
|
+
exerciseIdToSetCount,
|
|
723
|
+
exerciseIdToSessionIndex,
|
|
724
|
+
sessionIndexToExerciseIds
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
// Respect all caps: per-exercise max, per-session max, and per-addition max
|
|
728
|
+
const maxDueToExerciseLimit = this.MAX_SETS_PER_EXERCISE - currentSets;
|
|
729
|
+
const maxDueToSessionLimit = this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
|
|
730
|
+
const maxAddable = Math.min(
|
|
731
|
+
setsToAdd,
|
|
732
|
+
maxDueToExerciseLimit,
|
|
733
|
+
maxDueToSessionLimit,
|
|
734
|
+
this.MAX_SET_ADDITION_PER_EXERCISE
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
if (maxAddable > 0) {
|
|
738
|
+
exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
|
|
739
|
+
}
|
|
740
|
+
return maxAddable;
|
|
600
741
|
}
|
|
601
742
|
}
|