@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.
@@ -1,10 +1,7 @@
1
1
  import WorkoutSessionExerciseService from '../../SessionExercise/WorkoutSessionExerciseService.js';
2
- import WorkoutSFRService from '../SFR/WorkoutSFRService.js';
3
2
  /**
4
3
  * A service for handling volume planning operations across microcycles.
5
4
  *
6
- * SCOPE: Microcycle-level volume distribution (calculating set counts per exercise)
7
- *
8
5
  * RESPONSIBILITIES:
9
6
  * - Calculate set counts for exercises across a microcycle
10
7
  * - Apply progressive overload rules (baseline + historical adjustments)
@@ -17,26 +14,35 @@ import WorkoutSFRService from '../SFR/WorkoutSFRService.js';
17
14
  * - {@link WorkoutSessionExerciseService} - Used to calculate SFR and recovery recommendations
18
15
  */
19
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;
20
34
  static MAX_SETS_PER_EXERCISE = 8;
21
35
  static MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION = 10;
22
36
  /** Minimum average RSM required for a mesocycle to count toward MEV estimation. */
23
37
  static MEV_RSM_THRESHOLD = 4;
24
- /** Default estimated MEV when no qualifying mesocycle history exists. */
25
- static DEFAULT_MEV = 2;
38
+ /** Default estimated MEV when no qualifying mesocycle history exists. Per-exercise value. */
39
+ static DEFAULT_MEV_PER_EXERCISE = 2;
26
40
  /** Minimum average performance score (or recovery presence) to count a mesocycle toward MRV estimation. */
27
41
  static MRV_PERFORMANCE_THRESHOLD = 2.5;
28
42
  /** Extra sets added above the historical peak when no stressed mesocycles exist, to estimate MRV. */
29
43
  static MRV_HEADROOM = 2;
30
- /** Default estimated MRV when no mesocycle history exists at all. */
31
- static DEFAULT_MRV = 8;
32
- /** RSM bracket upper bound for "below MEV" proximity (0 to this value inclusive). */
33
- static MEV_PROXIMITY_BELOW_THRESHOLD = 3;
34
- /** RSM bracket upper bound for "at MEV" proximity (above BELOW threshold up to this value inclusive). */
35
- static MEV_PROXIMITY_AT_THRESHOLD = 6;
36
- /** Recommended set adjustment when volume is below MEV. */
37
- static MEV_BELOW_SET_ADJUSTMENT = 3;
38
- /** Recommended set adjustment when volume is above MEV. */
39
- static MEV_ABOVE_SET_ADJUSTMENT = -2;
44
+ /** Default estimated MRV when no mesocycle history exists at all. Per-exercise value. */
45
+ static DEFAULT_MRV_PER_EXERCISE = 8;
40
46
  /** Maximum sets that can be added to a single exercise in one progression step. */
41
47
  static MAX_SET_ADDITION_PER_EXERCISE = 2;
42
48
  /** Maximum total sets to distribute across a muscle group in one progression step. */
@@ -59,14 +65,6 @@ export default class WorkoutVolumePlanningService {
59
65
  recoveryExerciseIds.add(recoveryExerciseId);
60
66
  }
61
67
  });
62
- // Apply MEV proximity adjustment when generating the second microcycle (index 1)
63
- // after the first microcycle is complete with RSM data. Only applies when volume
64
- // data (volumeCTOs) was provided to the context.
65
- if (microcycleIndex === 1 &&
66
- !isDeloadMicrocycle &&
67
- context.muscleGroupToVolumeLandmarkMap.size > 0) {
68
- this.applyMevProximityAdjustments(context, exerciseIdToSetCount);
69
- }
70
68
  return { exerciseIdToSetCount, recoveryExerciseIds };
71
69
  }
72
70
  /**
@@ -91,7 +89,7 @@ export default class WorkoutVolumePlanningService {
91
89
  estimatedMev = Math.min(...mesocycleHistory.map((m) => m.startingSetCount));
92
90
  }
93
91
  else {
94
- estimatedMev = this.DEFAULT_MEV;
92
+ estimatedMev = this.DEFAULT_MEV_PER_EXERCISE;
95
93
  }
96
94
  // Estimated MRV
97
95
  let estimatedMrv;
@@ -107,10 +105,8 @@ export default class WorkoutVolumePlanningService {
107
105
  estimatedMrv = Math.max(...mesocycleHistory.map((m) => m.peakSetCount)) + this.MRV_HEADROOM;
108
106
  }
109
107
  else {
110
- estimatedMrv = this.DEFAULT_MRV;
108
+ estimatedMrv = this.DEFAULT_MRV_PER_EXERCISE;
111
109
  }
112
- // Hard cap MRV at 10 (MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION)
113
- estimatedMrv = Math.min(estimatedMrv, this.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION);
114
110
  // Ensure MRV > MEV
115
111
  if (estimatedMrv <= estimatedMev) {
116
112
  estimatedMrv = estimatedMev + 1;
@@ -118,104 +114,35 @@ export default class WorkoutVolumePlanningService {
118
114
  const estimatedMav = Math.ceil((estimatedMev + estimatedMrv) / 2);
119
115
  return { estimatedMev, estimatedMrv, estimatedMav, mesocycleCount: mesocycleHistory.length };
120
116
  }
121
- /**
122
- * Evaluates MEV (Minimum Effective Volume) proximity for a muscle group based on
123
- * RSM scores from the first microcycle. Called when generating the second microcycle to adjust
124
- * the volume baseline.
125
- *
126
- * Returns `null` when the first microcycle is incomplete or has no RSM data for the muscle group.
127
- *
128
- * @param context The mesocycle planning context containing microcycle/session/exercise data.
129
- * @param muscleGroupId The muscle group to evaluate.
130
- */
131
- static evaluateMevProximity(context, muscleGroupId) {
132
- const firstMicrocycle = context.microcyclesInOrder[0];
133
- if (!firstMicrocycle)
134
- return null;
135
- // Check the first microcycle is complete
136
- const lastSessionId = firstMicrocycle.sessionOrder[firstMicrocycle.sessionOrder.length - 1];
137
- if (!context.sessionMap.get(lastSessionId)?.complete)
138
- return null;
139
- // Collect RSM totals from session exercises that target this muscle group
140
- const rsmTotals = [];
141
- for (const sessionId of firstMicrocycle.sessionOrder) {
142
- const session = context.sessionMap.get(sessionId);
143
- if (!session)
144
- continue;
145
- for (const seId of session.sessionExerciseOrder) {
146
- const se = context.sessionExerciseMap.get(seId);
147
- if (!se)
148
- continue;
149
- const exercise = context.exerciseMap.get(se.workoutExerciseId);
150
- if (!exercise?.primaryMuscleGroups.includes(muscleGroupId))
151
- continue;
152
- const rsmTotal = WorkoutSFRService.getRsmTotal(se.rsm);
153
- if (rsmTotal !== null) {
154
- rsmTotals.push(rsmTotal);
155
- }
156
- }
157
- }
158
- if (rsmTotals.length === 0)
159
- return null;
160
- const averageRsm = rsmTotals.reduce((sum, val) => sum + val, 0) / rsmTotals.length;
161
- const bracket = Math.floor(averageRsm);
162
- if (bracket <= this.MEV_PROXIMITY_BELOW_THRESHOLD) {
163
- return {
164
- proximity: 'below',
165
- recommendedSetAdjustment: this.MEV_BELOW_SET_ADJUSTMENT,
166
- averageRsm
167
- };
168
- }
169
- else if (bracket <= this.MEV_PROXIMITY_AT_THRESHOLD) {
170
- return { proximity: 'at', recommendedSetAdjustment: 0, averageRsm };
171
- }
172
- return {
173
- proximity: 'above',
174
- recommendedSetAdjustment: this.MEV_ABOVE_SET_ADJUSTMENT,
175
- averageRsm
176
- };
177
- }
178
- /**
179
- * Applies MEV proximity adjustments based on RSM data from the first microcycle.
180
- * Adjusts set counts per muscle group when the first microcycle indicates volume
181
- * was below or above MEV.
182
- */
183
- static applyMevProximityAdjustments(context, exerciseIdToSetCount) {
184
- if (!context.muscleGroupToExerciseCTOsMap)
185
- return;
186
- for (const [muscleGroupId, muscleGroupExerciseCTOs] of context.muscleGroupToExerciseCTOsMap) {
187
- const mevResult = this.evaluateMevProximity(context, muscleGroupId);
188
- if (!mevResult || mevResult.recommendedSetAdjustment === 0)
189
- continue;
190
- // Distribute the adjustment evenly across exercises in this muscle group
191
- const exerciseCount = muscleGroupExerciseCTOs.length;
192
- const adjustmentPerExercise = Math.floor(mevResult.recommendedSetAdjustment / exerciseCount);
193
- const adjustmentRemainder = mevResult.recommendedSetAdjustment % exerciseCount;
194
- muscleGroupExerciseCTOs.forEach((cto, index) => {
195
- const currentSets = exerciseIdToSetCount.get(cto._id) ?? 2;
196
- const extra = index < Math.abs(adjustmentRemainder) ? Math.sign(mevResult.recommendedSetAdjustment) : 0;
197
- const newSets = Math.max(1, Math.min(currentSets + adjustmentPerExercise + extra, this.MAX_SETS_PER_EXERCISE));
198
- exerciseIdToSetCount.set(cto._id, newSets);
199
- });
200
- }
201
- }
202
117
  /**
203
118
  * Calculates the set count for each exercise in a particular muscle group for this microcycle.
204
119
  *
205
- * If there is no previous microcycle data for the muscle group, this falls back to
206
- * the baseline progression rules.
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.
207
129
  */
208
130
  static calculateSetCountForEachExerciseInMuscleGroup(context, microcycleIndex, muscleGroupExerciseCTOs, isDeloadMicrocycle) {
209
131
  const exerciseIdToSetCount = new Map();
210
132
  const recoveryExerciseIds = new Set();
211
133
  const sessionIndexToExerciseIds = new Map();
212
134
  const { progressionInterval } = context;
213
- // 1. Calculate baselines for all exercises in muscle group
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
214
144
  muscleGroupExerciseCTOs.forEach((cto, index) => {
215
- const baseline = this.calculateBaselineSetCount(microcycleIndex, muscleGroupExerciseCTOs.length, index, isDeloadMicrocycle, progressionInterval);
216
- exerciseIdToSetCount.set(cto._id, Math.min(baseline, this.MAX_SETS_PER_EXERCISE));
217
- // Build out the map for session indices to the array of exercise IDs as it pertains to this
218
- // muscle group.
145
+ exerciseIdToSetCount.set(cto._id, Math.min(baselineCounts[index], this.MAX_SETS_PER_EXERCISE));
219
146
  if (!context.exerciseIdToSessionIndex)
220
147
  return;
221
148
  const exerciseSessionIndex = context.exerciseIdToSessionIndex.get(cto._id);
@@ -225,14 +152,126 @@ export default class WorkoutVolumePlanningService {
225
152
  existingExerciseIdsForSession.push(cto._id);
226
153
  sessionIndexToExerciseIds.set(exerciseSessionIndex, existingExerciseIdsForSession);
227
154
  });
228
- // 2. Resolve historical performance data
229
- // Return if no previous microcycle
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) {
230
271
  let previousMicrocycleIndex = microcycleIndex - 1;
231
272
  let previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
232
273
  if (!previousMicrocycle)
233
- return { exerciseIdToSetCount, recoveryExerciseIds };
234
- // Map previous session exercises
235
- const exerciseIds = new Set(muscleGroupExerciseCTOs.map((cto) => cto._id));
274
+ return null;
236
275
  const exerciseIdToPrevSessionExercise = new Map();
237
276
  const foundExerciseIds = new Set();
238
277
  const exercisesThatWerePreviouslyInRecovery = new Set();
@@ -244,116 +283,117 @@ export default class WorkoutVolumePlanningService {
244
283
  if (!microcycleIsComplete) {
245
284
  break;
246
285
  }
247
- // Start with session order
286
+ // Scan all session exercises in this microcycle for matching non-recovery exercises
248
287
  for (const sessionId of previousMicrocycle.sessionOrder) {
249
288
  const session = context.sessionMap.get(sessionId);
250
289
  if (!session)
251
290
  continue;
252
- // Get the session exercises for this session
253
291
  for (const sessionExerciseId of session.sessionExerciseOrder) {
254
292
  const sessionExercise = context.sessionExerciseMap.get(sessionExerciseId);
255
- // Map if in our muscle group && it isn't a recovery exercise
256
293
  if (sessionExercise &&
257
294
  exerciseIds.has(sessionExercise.workoutExerciseId) &&
258
295
  !foundExerciseIds.has(sessionExercise.workoutExerciseId) &&
259
296
  !sessionExercise.isRecoveryExercise) {
260
297
  exerciseIdToPrevSessionExercise.set(sessionExercise.workoutExerciseId, sessionExercise);
261
298
  foundExerciseIds.add(sessionExercise.workoutExerciseId);
299
+ // If we had to go back more than one microcycle, the exercise was in recovery
262
300
  if (previousMicrocycleIndex < microcycleIndex - 1) {
263
301
  exercisesThatWerePreviouslyInRecovery.add(sessionExercise.workoutExerciseId);
264
302
  }
265
303
  }
266
304
  }
267
305
  }
268
- // Move to earlier microcycle
269
306
  previousMicrocycleIndex = previousMicrocycleIndex - 1;
270
307
  previousMicrocycle = context.microcyclesInOrder[previousMicrocycleIndex];
271
308
  }
272
309
  if (exerciseIdToPrevSessionExercise.size === 0)
273
- return { exerciseIdToSetCount, recoveryExerciseIds };
274
- // Update baseline with historical set counts when available.
275
- // For deload microcycles, halve the historical count (minimum 1 set). We don't use baseline
276
- // because the user may have adjusted over the mesocycle in a way that the baseline is actually
277
- // a higher set count than what would be calculated by halving the previous microcycle's sets.
278
- //
279
- // For exercises returning from recovery, use the estimated MAV from volume landmarks
280
- // when available, instead of the pre-recovery historical count.
281
- const primaryMuscleGroupId = muscleGroupExerciseCTOs[0]?.primaryMuscleGroups[0];
282
- const volumeLandmark = primaryMuscleGroupId
283
- ? context.muscleGroupToVolumeLandmarkMap.get(primaryMuscleGroupId)
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)
284
334
  : undefined;
285
- muscleGroupExerciseCTOs.forEach((cto) => {
335
+ muscleGroupExerciseCTOs.forEach((cto, index) => {
286
336
  const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
287
- if (previousSessionExercise) {
288
- let setCount;
289
- if (exercisesThatWerePreviouslyInRecovery.has(cto._id) && volumeLandmark) {
290
- // Exercise is returning from recovery — resume at the estimated MAV
291
- setCount = Math.min(volumeLandmark.estimatedMav, this.MAX_SETS_PER_EXERCISE);
292
- }
293
- else {
294
- setCount = Math.min(previousSessionExercise.setOrder.length, this.MAX_SETS_PER_EXERCISE);
295
- }
296
- if (isDeloadMicrocycle) {
297
- setCount = Math.max(1, Math.floor(setCount / 2));
298
- }
299
- exerciseIdToSetCount.set(cto._id, setCount);
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);
300
343
  }
301
- });
302
- // Deload microcycles and zero-progression cycles (Resensitization) skip SFR-based set additions
303
- if (isDeloadMicrocycle || progressionInterval === 0) {
304
- return { exerciseIdToSetCount, recoveryExerciseIds };
305
- }
306
- /**
307
- * Determines if the session for the given exercise is already capped for this muscle group.
308
- */
309
- function sessionIsCapped(exerciseId) {
310
- if (!context.exerciseIdToSessionIndex) {
311
- 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);
312
347
  }
313
- const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
314
- if (sessionIndex === undefined)
315
- return false;
316
- const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
317
- if (!exerciseIdsInSession)
318
- return false;
319
- let totalSetsInSession = 0;
320
- exerciseIdsInSession.forEach((id) => {
321
- // Use the sets from the previous microcycle's session exercise
322
- totalSetsInSession += exerciseIdToPrevSessionExercise.get(id)?.setOrder.length || 0;
323
- });
324
- return (totalSetsInSession >= WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION);
325
- }
326
- // 3. Determine sets to add and valid candidates
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) {
327
368
  let totalSetsToAdd = 0;
328
369
  const candidates = [];
329
370
  muscleGroupExerciseCTOs.forEach((cto, muscleGroupIndex) => {
330
371
  const previousSessionExercise = exerciseIdToPrevSessionExercise.get(cto._id);
331
372
  if (!previousSessionExercise)
332
373
  return;
374
+ // Get SFR-based recommendation: positive = add sets, 0 = maintain, -1 = recovery needed
333
375
  let recommendation;
334
376
  if (!exercisesThatWerePreviouslyInRecovery.has(cto._id)) {
335
377
  recommendation =
336
378
  WorkoutSessionExerciseService.getRecommendedSetAdditionsOrRecovery(previousSessionExercise);
337
379
  }
338
380
  else {
339
- // If previously in recovery, do not recommend adding sets this microcycle. Also, this is
340
- // the only thing we are overriding. We still want to use the historical data for
341
- // SFR calculations, even though that one was the one that triggered a recovery session.
342
- // 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
343
382
  recommendation = 0;
344
383
  }
345
384
  if (recommendation === -1) {
385
+ // Recovery needed: halve sets and flag as recovery exercise
346
386
  recoveryExerciseIds.add(cto._id);
347
- // Cut sets in half (rounded down, minimum 1) for recovery
348
387
  const previousSetCount = previousSessionExercise.setOrder.length;
349
388
  const recoverySets = Math.max(1, Math.floor(previousSetCount / 2));
350
389
  exerciseIdToSetCount.set(cto._id, recoverySets);
351
390
  }
352
391
  else if (recommendation !== null && recommendation >= 0) {
392
+ // Accumulate SFR-recommended additions into shared total
353
393
  totalSetsToAdd += recommendation;
354
- // Consider as candidate if session is not already capped
394
+ // Only exercises below per-exercise cap and in uncapped sessions are candidates
355
395
  if (previousSessionExercise.setOrder.length < this.MAX_SETS_PER_EXERCISE &&
356
- !sessionIsCapped(cto._id)) {
396
+ !this.sessionIsCapped(cto._id, context.exerciseIdToSessionIndex, sessionIndexToExerciseIds, exerciseIdToPrevSessionExercise)) {
357
397
  candidates.push({
358
398
  exerciseId: cto._id,
359
399
  // Don't error if SFR is null for now, just treat as very low
@@ -365,95 +405,105 @@ export default class WorkoutVolumePlanningService {
365
405
  }
366
406
  }
367
407
  });
368
- // Return if nothing to add or no candidates
369
- if (totalSetsToAdd === 0 || candidates.length === 0) {
370
- return { exerciseIdToSetCount, recoveryExerciseIds };
371
- }
372
- // 4. Distribute added sets based on SFR quality
373
- // Sort by SFR descending, then by muscleGroupIndex ascending (as tie-breaker)
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)
374
422
  candidates.sort((candidateA, candidateB) => candidateA.sfr !== candidateB.sfr
375
423
  ? candidateB.sfr - candidateA.sfr
376
424
  : candidateA.muscleGroupIndex - candidateB.muscleGroupIndex);
377
- /**
378
- * Gets the total sets currently planned for a session.
379
- */
380
- function getSessionTotal(exerciseId) {
381
- if (!context.exerciseIdToSessionIndex)
382
- return 0;
383
- const sessionIndex = context.exerciseIdToSessionIndex.get(exerciseId);
384
- if (sessionIndex === undefined)
385
- return 0;
386
- const exerciseIdsInSession = sessionIndexToExerciseIds.get(sessionIndex);
387
- if (!exerciseIdsInSession)
388
- return 0;
389
- let total = 0;
390
- exerciseIdsInSession.forEach((id) => {
391
- total += exerciseIdToSetCount.get(id) || 0;
392
- });
393
- return total;
394
- }
395
- /**
396
- * Attempts to add sets to an exercise, respecting all constraints.
397
- * Returns the number of sets actually added.
398
- */
399
- function addSetsToExercise(exerciseId, setsToAdd) {
400
- const currentSets = exerciseIdToSetCount.get(exerciseId) || 0;
401
- const sessionTotal = getSessionTotal(exerciseId);
402
- const maxDueToExerciseLimit = WorkoutVolumePlanningService.MAX_SETS_PER_EXERCISE - currentSets;
403
- const maxDueToSessionLimit = WorkoutVolumePlanningService.MAX_SETS_PER_MUSCLE_GROUP_PER_SESSION - sessionTotal;
404
- const maxAddable = Math.min(setsToAdd, maxDueToExerciseLimit, maxDueToSessionLimit, WorkoutVolumePlanningService.MAX_SET_ADDITION_PER_EXERCISE);
405
- if (maxAddable > 0) {
406
- exerciseIdToSetCount.set(exerciseId, currentSets + maxAddable);
407
- }
408
- return maxAddable;
409
- }
410
- let setsRemaining = totalSetsToAdd >= WorkoutVolumePlanningService.MAX_TOTAL_SET_ADDITIONS
411
- ? WorkoutVolumePlanningService.MAX_TOTAL_SET_ADDITIONS
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
412
428
  : totalSetsToAdd;
429
+ // Distribute sets one candidate at a time until budget is exhausted
413
430
  for (const candidate of candidates) {
414
- const added = addSetsToExercise(candidate.exerciseId, setsRemaining);
431
+ const added = this.addSetsToExercise(candidate.exerciseId, setsRemaining, exerciseIdToSetCount, exerciseIdToSessionIndex, sessionIndexToExerciseIds);
415
432
  setsRemaining -= added;
416
433
  if (setsRemaining === 0)
417
434
  break;
418
435
  }
419
- return { exerciseIdToSetCount, recoveryExerciseIds };
420
436
  }
421
437
  /**
422
- * Calculates the default number of sets for an exercise based on microcycle progression.
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.
423
440
  *
424
- * Key rule: set progression is distributed across exercises that share the same primary muscle group
425
- * for the entire microcycle, regardless of which session those exercises are in.
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.
426
464
  *
427
- * Baseline: 2 sets per exercise in the muscle group.
428
- * Progression: add 1 set per muscle group every `progressionInterval` microcycles.
429
- * - MuscleGain (interval 1): every microcycle
430
- * - Cut (interval 2): every other microcycle
431
- * - Resensitization (interval 0): no progression (flat 2 sets per exercise)
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.
432
488
  *
433
- * @param microcycleIndex The index of the current microcycle.
434
- * @param totalExercisesInMuscleGroupForMicrocycle Total exercises in the muscle group for
435
- * this microcycle.
436
- * @param exerciseIndexInMuscleGroupForMicrocycle Index of the current exercise within the
437
- * muscle group.
438
- * @param isDeloadMicrocycle Whether this is a deload microcycle.
439
- * @param progressionInterval Number of microcycles between each set addition. 0 means no
440
- * progression.
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.
441
495
  */
442
- static calculateBaselineSetCount(microcycleIndex, totalExercisesInMuscleGroupForMicrocycle, exerciseIndexInMuscleGroupForMicrocycle, isDeloadMicrocycle, progressionInterval = 1) {
443
- // Deload microcycle: half the sets from the previous microcycle, minimum 1 set.
444
- if (isDeloadMicrocycle) {
445
- const baselineSets = this.calculateBaselineSetCount(microcycleIndex - 1, totalExercisesInMuscleGroupForMicrocycle, exerciseIndexInMuscleGroupForMicrocycle, false, progressionInterval);
446
- return Math.max(1, Math.floor(baselineSets / 2));
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);
447
505
  }
448
- // Total sets to distribute for this muscle group in this microcycle.
449
- const progressionSets = progressionInterval === 0 ? 0 : Math.ceil(microcycleIndex / progressionInterval);
450
- const totalSets = 2 * totalExercisesInMuscleGroupForMicrocycle + progressionSets;
451
- // Distribute sets evenly, with earlier exercises getting extra sets from the remainder.
452
- const baseSetsPerExercise = Math.floor(totalSets / totalExercisesInMuscleGroupForMicrocycle);
453
- const remainder = totalSets % totalExercisesInMuscleGroupForMicrocycle;
454
- return exerciseIndexInMuscleGroupForMicrocycle < remainder
455
- ? baseSetsPerExercise + 1
456
- : baseSetsPerExercise;
506
+ return maxAddable;
457
507
  }
458
508
  }
459
509
  //# sourceMappingURL=WorkoutVolumePlanningService.js.map