diff --git a/db.py b/db.py index 8b9b0f9..97e2a08 100644 --- a/db.py +++ b/db.py @@ -380,7 +380,7 @@ class DataBase(): else: return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name']) - def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0): + def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0, min_date=None, max_date=None): topsets = self.execute(""" SELECT t.topset_id, @@ -394,11 +394,13 @@ class DataBase(): JOIN topset t ON e.exercise_id = t.exercise_id JOIN workout w ON t.workout_id = w.workout_id WHERE - e.exercise_id = %s AND w.person_id = %s + e.exercise_id = %s AND w.person_id = %s + AND (%s::date IS NULL OR w.start_date >= %s::date) + AND (%s::date IS NULL OR w.start_date <= %s::date) ORDER BY w.start_date DESC, t.topset_id DESC LIMIT %s OFFSET %s; - """, [exercise_id, person_id, limit, offset]) + """, [exercise_id, person_id, min_date, min_date, max_date, max_date, limit, offset]) return topsets @@ -419,7 +421,7 @@ class DataBase(): SELECT T.topset_id, E.name AS exercise_name, W.person_id, T.workout_id, T.repetitions, T.weight, - ROUND((100 * T.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * T.repetitions), 0)::NUMERIC::INTEGER AS estimated_1rm, + ROUND((100 * T.weight::NUMERIC) / (101.3 - 2.67123 * T.repetitions), 1) AS estimated_1rm, W.start_date FROM topset T @@ -433,7 +435,7 @@ class DataBase(): (%s::date IS NULL OR W.start_date >= %s::date) AND (%s::date IS NULL OR W.start_date <= %s::date) ORDER BY - W.start_date; + W.start_date, T.topset_id; """, [person_id, exercise_id, min_date, min_date, max_date, max_date]) # Return None if no topsets found @@ -442,7 +444,7 @@ class DataBase(): # Extracting values and calculating value ranges for SVG dimensions exercise_name = topsets[0]['exercise_name'] - estimated_1rm = [t['estimated_1rm'] for t in topsets] + estimated_1rm = [float(t['estimated_1rm']) for t in topsets] repetitions = [t['repetitions'] for t in topsets] weight = [t['weight'] for t in topsets] start_dates = [t['start_date'] for t in topsets] @@ -453,7 +455,19 @@ class DataBase(): latest_topset_id = latest_topset['topset_id'] latest_workout_id = latest_topset['workout_id'] - exercise_progress = get_exercise_graph_model( + # View controls affect the chart/rep map, not training. Respect the + # visible cutoff so custom historical views never train on later sets. + forecast_training_rows = None + if forecast_enabled: + forecast_training_rows = self.execute(""" + SELECT w.start_date, t.weight, t.repetitions + FROM topset t JOIN workout w USING(workout_id) + WHERE w.person_id = %s AND t.exercise_id = %s + AND w.start_date <= %s + ORDER BY w.start_date, t.topset_id + """, [person_id, exercise_id, max(start_dates)]) + + exercise_progress = get_exercise_graph_model( exercise_name, estimated_1rm, repetitions, @@ -468,7 +482,8 @@ class DataBase(): degree, forecast_enabled, forecast_weeks, - forecast_target_reps) + forecast_target_reps, + forecast_training_rows=forecast_training_rows) exercise_progress['latest_topset_id'] = latest_topset_id exercise_progress['latest_workout_id'] = latest_workout_id diff --git a/docs/forecasting.md b/docs/forecasting.md new file mode 100644 index 0000000..ac59808 --- /dev/null +++ b/docs/forecasting.md @@ -0,0 +1,125 @@ +# Exercise forecasts + +The forecast predicts joint **load × reps** outcomes for an exercise's next +sessions. E1RM is derived from those sets with fractional loads preserved. The +graph and table use the same forecast. Equivalent loads at other rep counts +are a separate, collapsed reference table, not predicted workout choices. + +## Why this model + +A small, recency-weighted transition model fits the data and the user workflow: +repeated sets, rep gains, difficult sessions, then load increases and rep resets. +It is a nonparametric learner (weighted neighbours), trained on demand on the +current person/exercise block. It needs only NumPy; no external training service, +cross-person data pooling or large ML dependency is involved. + +We compared it with repeating the last set, an anchored full-block linear E1RM +trend, and a damped recent E1RM trend. A larger neural/boosted model was not +trained: the per-exercise histories are small, and this model already improves +the held-out metrics while preserving interpretable load/rep transitions. + +The evaluation follows [rolling-origin validation](https://otexts.com/fpp3/tscv.html): +fit only on observations strictly before the held-out session. Random train/test +splits can give [optimistic results for lagged time series](https://scikit-learn.org/stable/auto_examples/applications/plot_time_series_lagged_features.html). +[Damping](https://otexts.com/fpp3/holt.html) motivates restraining long-horizon growth; +our load-transition damping is a conservative scenario assumption, not a learned +physiological law. + +## Training breaks and data handling + +- Take the best E1RM set per date, including deterministic ties. Duplicate sets + do not increase the session count. +- Split training blocks online when a gap exceeds three recent usual intervals, + with a 28-day minimum, 84-day maximum and 42-day sparse-history fallback. + The threshold uses only preceding gaps; cadence restarts after a break. +- Fit the latest block only. Require four valid sessions after a break. Older + blocks remain visible in the chart but cannot drive current forecasts. +- History-range controls change the chart and equivalent-rep map, not the + training window. A custom end date is also a strict training cutoff. +- Positive loads and integer reps 1–20 are supported. Missing, invalid and + unsupported readings do not train the model. The UI explains when a new + forecast is unavailable. This E1RM model is not appropriate for timed carries, + zero-load bodyweight work, or other metrics merely stored as reps. +- Old histories are labelled historical projections, not upcoming workouts. + +## Model details + +The last 32 current-block sessions supply joint load/rep transitions. A kernel +weights their similarity to the current reps, relative load and stall streak; +recency has an eight-session half-life. An explicit repeat-set prior keeps sparse +evidence conservative. A stall means failing to improve the rep best at the +current load, including drops/recoveries rather than counting every rebound as +new progress. + +The usual pre-increase reps and an actually observed load increment are inferred +from the recent 24 sessions. Load increases are allowed at the inferred threshold; +their reset reps come from actual transitions. No fixed 8–12 prescription is +hardcoded. If there are no observed increases, the model cannot invent them. + +The next set is the most likely **joint** outcome. For longer horizons, 512 +deterministically seeded trajectories produce scenario sets at the median load +and median reps conditional on that load. This avoids jumping between dissimilar +sets just because their E1RMs match. Each row describes a representative outcome +at that date, not one prescribed sequence. Dates use the median recent session +gap. A horizon extending between workout dates carries forward the previous +scenario without inventing another workout. + +The band starts at the observed value and uses simulated 10th/90th E1RM quantiles, +broadened to at least the 80th percentile of recent absolute one-session backtest +errors. These are plausible scenario ranges, **not calibrated 80% confidence +intervals**. Long-horizon growth probabilities are damped after 12 sessions. +Intervals and one-year scenarios have not been prospectively validated. + +## Production audit, 6 September 2026 + +Read-only SSH/SQL inspection of Peter's 3,050 recorded sets covered 132 exercises. +There were 3,015 distinct valid exercise/date observations and 53 detected breaks; +91 latest blocks had at least four usable sessions. The rest are withheld with +an explanation. Old but sufficiently populated blocks can produce historical +projections; this count is not a count of currently active exercises. + +Every eligible historical block was tested, starting with eight training sessions. +Holdouts never cross a detected break. The first four to eight post-break sessions +therefore have less validated support. All methods use the same block segmentation, +fractional loads and estimated session cadence. Linear/damped baselines keep the +latest rep target, as the previous product did. These are retrospective development +results, not independent prospective validation or a guarantee for every exercise. + +| Horizon | Holdouts | Model | Load MAE (kg) | Rep MAE | E1RM MAE (kg) | +| --- | ---: | --- | ---: | ---: | ---: | +| Next session | 1,929 | Learned transitions | 0.312 | 0.692 | 1.893 | +| Next session | 1,929 | Repeat last set | 0.506 | 0.800 | 2.395 | +| Next session | 1,929 | Damped trend | 0.374 | 0.800 | 2.133 | +| Next session | 1,929 | Linear trend | 0.422 | 0.800 | 2.118 | +| 4 sessions | 1,636 | Learned transitions | 1.022 | 1.084 | 3.243 | +| 4 sessions | 1,636 | Repeat last set | 1.884 | 1.145 | 4.159 | +| 4 sessions | 1,636 | Damped trend | 1.074 | 1.145 | 3.436 | +| 4 sessions | 1,636 | Linear trend | 1.201 | 1.145 | 3.581 | +| 8 sessions | 1,308 | Learned transitions | 1.782 | 1.174 | 4.114 | +| 8 sessions | 1,308 | Repeat last set | 3.545 | 1.158 | 5.906 | +| 8 sessions | 1,308 | Damped trend | 1.903 | 1.158 | 4.518 | +| 8 sessions | 1,308 | Linear trend | 2.056 | 1.158 | 4.845 | + +The learned model improves aggregate load and E1RM errors at all tested horizons. +At eight sessions it does **not** beat persistence on reps; difficult sessions +remain unpredictable. The UI includes exercise-specific recent error metrics. + +For 30 degrees Smith Press, the latest block has 39 sessions. The current load +block contains seven sessions at 93.5 kg, progressing 4→5→6→6→6→6→7 reps. +Three sessions did not improve the rep best. The inferred threshold is nine reps +and the load increment is 0.5 kg. The next forecast is 93.5 kg × 7, around 13 Sep +2026 at an eight-day cadence. Its E1RM is 113.2 kg; the old SQL rounded the load +to 94 kg and then the E1RM to 114 kg. + +## Reproduce + +```powershell +.venv/Scripts/python -m unittest discover -s tests -v +.venv/Scripts/python scripts/audit_forecasts.py --person Peter +.venv/Scripts/python scripts/preview_forecasts.py +``` + +The audit and local preview keep raw production records in process memory. +Neither modifies the database. Cached forecasts are bounded, keyed by the full +training rows and horizon, and copied before rendering; new records invalidate +the key naturally. diff --git a/forecasting.py b/forecasting.py new file mode 100644 index 0000000..1e0d380 --- /dev/null +++ b/forecasting.py @@ -0,0 +1,319 @@ +"""Small-data, session-based top-set forecasts; no future observations in fitting. + +The empirical transition model is a recency-weighted nearest-neighbour model. +It learns joint load/rep changes, including repeats and regressions, rather than +converting a fitted E1RM line into an alleged workout. See docs/forecasting.md. +""" +from datetime import date, datetime, timedelta +from functools import lru_cache +from collections import Counter +import math + +import numpy as np + +BASE = 101.3 +REP_FACTOR = 2.67123 + + +def e1rm(weight, reps): + return np.asarray(weight) * 100 / (BASE - REP_FACTOR * np.asarray(reps)) + + +def sessions(dates, weights, reps): + """One best set per date, with deterministic ties and fractional loads intact.""" + by_date = {} + for day, weight, repetitions in zip(dates, weights, reps): + if isinstance(day, datetime): + day = day.date() + try: + weight, repetitions = float(weight), float(repetitions) + except (TypeError, ValueError): + continue + if (not isinstance(day, date) or not math.isfinite(weight) + or not math.isfinite(repetitions) or weight <= 0 + or not repetitions.is_integer() or not 1 <= repetitions <= 20): + continue + row = (day, weight, int(repetitions)) + if day not in by_date or (float(e1rm(weight, repetitions)), weight, repetitions) > ( + float(e1rm(by_date[day][1], by_date[day][2])), *by_date[day][1:]): + by_date[day] = row + return tuple(sorted(by_date.values())) + + +def profile(rows): + """Summarise the recent training regime without counting recovery as a PR.""" + recent = rows[-24:] + weights = np.array([r[1] for r in recent]) + reps = np.array([r[2] for r in recent]) + gaps = np.array([(b[0] - a[0]).days for a, b in zip(recent, recent[1:])]) + dw = np.diff(weights) + increases = dw[(dw > 0.01) & (dw <= weights[:-1] * 0.15)] + # Use an observed increment, not an average that invents plate sizes. + increment = Counter(np.round(increases, 3)).most_common(1)[0][0] if len(increases) else 0.0 + ceiling_samples = reps[:-1][(dw > 0.01) & (dw <= weights[:-1] * 0.15)] + ceiling = int(round(float(np.median(ceiling_samples)))) if len(ceiling_samples) >= 2 else int(max(reps)) + block = [rows[-1]] + for row in reversed(rows[:-1]): + if abs(row[1] - weights[-1]) > 0.01: + break + block.insert(0, row) + best, stalls, run = block[0][2], 0, 0 + for row in block[1:]: + if row[2] > best: + best, run = row[2], 0 + else: + stalls, run = stalls + 1, run + 1 + return { + 'increment': increment, 'ceiling': ceiling, + 'cadence_days': max(1, int(round(float(np.median(gaps[-12:]))))), + 'block_sessions': len(block), 'block_stalls': stalls, 'stall_run': run, + 'block_start': block[0][0], 'block_start_reps': block[0][2], + 'block_best_reps': best, 'recent_sessions': len(recent), + 'recent_load_repeats': int(np.sum(abs(dw) < 0.01)), + 'transitions': len(dw), 'ceiling_evidence': len(ceiling_samples), + } + + +def training_blocks(rows): + """Detect layoffs online using only cadence known before each gap. + + A gap exceeds three usual intervals (at least 28, at most 84 days). + Sparse starts use 42 days. The cadence restarts with each new block. + """ + blocks = [] + gaps = [] + for row in rows: + if not blocks: + blocks.append([row]) + continue + gap = (row[0] - blocks[-1][-1][0]).days + threshold = max(28, min(84, 3 * float(np.median(gaps[-8:])))) if len(gaps) >= 3 else 42 + if gap > threshold: + blocks.append([row]) + gaps = [] + else: + blocks[-1].append(row) + gaps.append(gap) + return tuple(tuple(block) for block in blocks) + + +class TransitionModel: + """Kernel-smoothed joint transitions with an explicit repeat-set prior.""" + def __init__(self, rows): + self.rows = rows + self.profile = profile(rows) + recent = rows[-32:] + w = np.array([r[1] for r in recent]) + r = np.array([r[2] for r in recent]) + gap = np.array([(b[0] - a[0]).days for a, b in zip(recent, recent[1:])]) + # A long layoff starts a different training block, not an ordinary step. + keep = gap <= max(28, self.profile['cadence_days'] * 3) + self.source_w, self.source_r = w[:-1][keep], r[:-1][keep] + self.dw = np.diff(w)[keep] + self.dr = np.diff(r)[keep] + self.reset_r = r[1:][keep] + self.age = np.arange(len(w) - 2, -1, -1)[keep] + stall_runs, run, best, load = [], 0, int(r[0]), w[0] + for index, (weight, reps) in enumerate(zip(w[:-1], r[:-1])): + if abs(weight - load) > 0.01: + load, best, run = weight, int(reps), 0 + elif reps > best: + best, run = int(reps), 0 + elif index: + run += 1 + stall_runs.append(run) + self.stalls = np.array(stall_runs)[keep] + + def outcomes(self, weights, reps, stalls, step=1): + weights, reps, stalls = map(np.atleast_1d, (weights, reps, stalls)) + # Learn from nearby rep counts, loads and stall lengths. Recent sessions + # have greater influence; the current long plateau outweighs old PRs. + distance = (abs(reps[:, None] - self.source_r) / 2 + + abs(np.log(weights[:, None] / self.source_w)) / 0.15 + + abs(np.minimum(stalls[:, None], 5) - np.minimum(self.stalls, 5)) / 4) + probabilities = np.exp(-distance) * 0.5 ** (self.age / 8) + increase = self.dw > 0.01 + decrease = self.dw < -0.01 + probabilities[:, increase] *= (reps >= self.profile['ceiling'])[:, None] + # Calendar horizons beyond ~12 sessions are scenarios. Damp the rate of + # further load advances rather than extrapolating beginner gains forever. + probabilities[:, increase] *= 0.98 ** max(0, step - 12) + delta_w = np.where(increase, self.profile['increment'], self.dw) + delta_w = np.maximum(delta_w, -weights[:, None] * 0.15) + out_w = np.maximum(weights[:, None] + delta_w, 0.5) + out_r = np.clip(reps[:, None] + self.dr, 1, 20) + out_r[:, increase | decrease] = np.clip(self.reset_r[increase | decrease], 1, 20) + # Rep gains below a learned ceiling, not unlimited rep inflation. + out_r = np.where((self.dw == 0) & (self.dr > 0), + np.minimum(out_r, np.maximum(reps[:, None], self.profile['ceiling'])), out_r) + probabilities = np.column_stack([np.ones(len(weights)), probabilities]) + out_w = np.column_stack([weights, out_w]) + out_r = np.column_stack([reps, out_r]) + probabilities /= probabilities.sum(axis=1, keepdims=True) + return np.round(out_w, 3), out_r.astype(int), probabilities + + def next_set(self): + w, r, p = self.outcomes(self.rows[-1][1], self.rows[-1][2], self.profile['stall_run']) + # Combine identical outcomes before choosing the most likely joint set. + outcomes = {} + for weight, reps, probability in zip(w[0], r[0], p[0]): + key = (float(weight), int(reps)) + outcomes[key] = outcomes.get(key, 0) + float(probability) + ranked = sorted(outcomes.items(), key=lambda item: -item[1]) + return ranked + + def simulate(self, steps, paths=512): + rng = np.random.default_rng(20260906) + weights = np.full(paths, self.rows[-1][1], dtype=float) + reps = np.full(paths, self.rows[-1][2], dtype=int) + stalls = np.full(paths, self.profile['stall_run'], dtype=int) + best_reps = np.full(paths, self.profile['block_best_reps'], dtype=int) + result = [(weights.copy(), reps.copy())] + for step in range(1, steps + 1): + ow, oreps, p = self.outcomes(weights, reps, stalls, step) + choices = (rng.random(paths)[:, None] > np.cumsum(p, axis=1)).sum(axis=1) + choices = np.minimum(choices, p.shape[1] - 1) + nw, nr = ow[np.arange(paths), choices], oreps[np.arange(paths), choices] + changed = abs(nw - weights) > 0.01 + stalls = np.where(changed | (nr > best_reps), 0, stalls + 1) + best_reps = np.where(changed, nr, np.maximum(best_reps, nr)) + weights, reps = nw, nr + result.append((weights.copy(), reps.copy())) + return result + + +def benchmark(rows, method, steps=1): + """Forecast-only baselines: repeat last set or a damped recent E1RM trend.""" + weight, reps = rows[-1][1:] + if method == 'repeat': + return weight, reps + sample = rows if method == 'linear' else rows[-12:] + x = np.array([(r[0] - sample[0][0]).days for r in sample]) + y = e1rm([r[1] for r in sample], [r[2] for r in sample]) + slope = float(np.polyfit(x, y, 1)[0]) + days = profile(rows)['cadence_days'] + advance = steps if method == 'linear' else sum(0.9 ** i for i in range(1, steps + 1)) + value = max(0, float(y[-1]) + slope * days * advance) + load = value * (BASE - REP_FACTOR * reps) / 100 + increment = profile(rows)['increment'] or 0.5 + return max(0.5, round(load / increment) * increment), reps + + +def backtest(rows, limit=20): + """Expanding-window one-session holdouts. No random train/test split.""" + errors = {name: [] for name in ('transitions', 'repeat', 'damped', 'linear')} + for end in range(max(8, len(rows) - limit), len(rows)): + train, actual = rows[:end], rows[end] + if (actual[0] - train[-1][0]).days > max(28, profile(train)['cadence_days'] * 3): + continue + predicted = {'transitions': TransitionModel(train).next_set()[0][0]} + predicted.update({name: benchmark(train, name) for name in errors if name != 'transitions'}) + for name, (weight, reps) in predicted.items(): + errors[name].append((abs(weight - actual[1]), abs(reps - actual[2]), + abs(float(e1rm(weight, reps) - e1rm(actual[1], actual[2]))))) + return {name: {'count': len(values), + 'weight_mae': round(float(np.mean(values, axis=0)[0]), 2), + 'reps_mae': round(float(np.mean(values, axis=0)[1]), 2), + 'e1rm_mae': round(float(np.mean(values, axis=0)[2]), 2), + 'error_p80': round(float(np.quantile(np.array(values)[:, 2], .8)), 2)} + for name, values in errors.items() if values} + + +def _point(day, weight, reps, lower, upper, session_index): + return {'date': day, 'value': round(float(e1rm(weight, reps)), 1), + 'lower': round(float(lower), 1), 'upper': round(float(upper), 1), + 'projected_weight': float(weight), 'projected_repetitions': int(reps), + 'set_display': f'{weight:g} kg × {int(reps)}', 'session': session_index} + + +def representative_set(weights, reps): + """Median load, then conditional median reps at that load. + + Picking by E1RM alone can jump between distant load/rep combinations with + almost identical strength. Use the actual joint load/rep distribution. + """ + loads = np.unique(weights) + weight = float(loads[np.argmin(abs(loads - np.median(weights)))]) + conditional = reps[weights == weight] + observed_reps = np.unique(conditional) + repetitions = int(observed_reps[np.argmin(abs(observed_reps - np.median(conditional)))]) + return weight, repetitions + + +@lru_cache(maxsize=128) +def _forecast(rows, horizon_weeks): + p = profile(rows) + model = TransitionModel(rows) + cadence = p['cadence_days'] + steps = math.ceil(horizon_weeks * 7 / cadence) + simulations = model.simulate(steps) + validation = backtest(rows) + # Keep the displayed scenario band at least as broad as recent held-out + # errors. This is an empirical safeguard, not a calibrated 80% guarantee. + error_floor = validation.get('transitions', {}).get('error_p80', 0) + points = [] + for index, (weights, reps) in enumerate(simulations): + if index * cadence > horizon_weeks * 7: + break + values = e1rm(weights, reps) + lower, median, upper = np.quantile(values, [0.1, 0.5, 0.9]) + if index: + lower, upper = max(0, min(lower, median - error_floor)), max(upper, median + error_floor) + weight, repetitions = representative_set(weights, reps) + points.append(_point(rows[-1][0] + timedelta(days=index * cadence), weight, repetitions, + min(lower, float(e1rm(weight, repetitions))), + max(upper, float(e1rm(weight, repetitions))), index)) + # Extend the chart to the selected calendar horizon without inventing a + # workout between the learned session dates. + end_date = rows[-1][0] + timedelta(weeks=horizon_weeks) + chart_points = list(points) + if points[-1]['date'] != end_date: + chart_points.append(dict(points[-1], date=end_date)) + ranking = model.next_set() + (weight, repetitions), probability = ranking[0] + next_values = e1rm(simulations[1][0], simulations[1][1]) + next_point = _point(rows[-1][0] + timedelta(days=cadence), weight, repetitions, + min(np.quantile(next_values, .1), e1rm(weight, repetitions)), + max(np.quantile(next_values, .9), e1rm(weight, repetitions)), 1) + next_point['lower'] = round(max(0, min(next_point['lower'], next_point['value'] - error_floor)), 1) + next_point['upper'] = round(max(next_point['upper'], next_point['value'] + error_floor), 1) + if len(points) > 1: + points[1].update(next_point) + return { + 'available': True, 'metric': 'E1RM', 'model': 'transitions', + 'horizon_weeks': horizon_weeks, 'observed_points': len(rows), + 'last_date': rows[-1][0], 'last_value': points[0]['value'], + 'projected_value': points[-1]['value'], + 'projected_change': round(points[-1]['value'] - points[0]['value'], 1), + 'weekly_change': round((points[-1]['value'] - points[0]['value']) / horizon_weeks, 1), + 'end_date': end_date, 'points': chart_points, 'session_points': points[1:], + 'next_session': next_point, 'profile': p, 'validation': validation, + 'alternatives': [{'weight': w, 'reps': r, 'probability': round(prob * 100)} + for (w, r), prob in ranking[:3]], + 'long_range': horizon_weeks > 12, + } + + +def build_forecast(values, dates, horizon_weeks=8, metric='E1RM', weights=None, repetitions=None): + # Copy the cached result because the rendering layer adds equivalent sets. + from copy import deepcopy + try: + horizon_weeks = max(4, min(52, int(horizon_weeks))) + except (TypeError, ValueError): + horizon_weeks = 8 + rows = sessions(dates, weights if weights is not None else [], + repetitions if repetitions is not None else []) + if len(rows) < 4: + return {'available': False, 'reason': 'Add at least 4 distinct workout dates with valid sets (1–20 reps).'} + dated = [d.date() if isinstance(d, datetime) else d for d in dates if isinstance(d, date)] + if dated and max(dated) > rows[-1][0]: + return {'available': False, 'reason': 'The latest reading needs a positive load and 1–20 reps for this forecast.'} + blocks = training_blocks(rows) + rows = blocks[-1] + if len(rows) < 4: + return {'available': False, 'reason': 'Add 4 sessions after the recent training break to learn the new pattern.'} + result = deepcopy(_forecast(rows, horizon_weeks)) + result['training_blocks'] = len(blocks) + result['training_start'] = rows[0][0] + result['stale'] = (date.today() - rows[-1][0]).days > max(28, result['profile']['cadence_days'] * 3) + return result diff --git a/routes/workout.py b/routes/workout.py index 0618b7f..cdfd761 100644 --- a/routes/workout.py +++ b/routes/workout.py @@ -5,7 +5,9 @@ from flask_login import login_required, current_user from extensions import db from decorators import validate_workout, validate_topset, require_ownership, validate_person from utils import convert_str_to_date -from collections import defaultdict # Import defaultdict +from collections import defaultdict # Import defaultdict +from datetime import date +from dateutil.relativedelta import relativedelta workout_bp = Blueprint('workout', __name__) htmx = HTMX() @@ -301,6 +303,11 @@ def get_exercise_history(person_id, exercise_id): limit = request.args.get('limit', type=int, default=5) offset = request.args.get('offset', type=int, default=0) source_topset_id = request.args.get('source_topset_id', type=int) + epoch = request.args.get('epoch', default='All') + min_date = convert_str_to_date(request.args.get('min_date')) + max_date = convert_str_to_date(request.args.get('max_date')) + if epoch in ('1M', '3M', '6M'): + min_date = date.today() - relativedelta(months=int(epoch[0])) forecast_enabled = request.args.get('forecast', '').lower() in ('1', 'true', 'yes', 'on') forecast_weeks = request.args.get('forecast_weeks', type=int, default=8) forecast_weeks = max(4, min(forecast_weeks, 52)) @@ -308,7 +315,7 @@ def get_exercise_history(person_id, exercise_id): if forecast_target_reps is not None: forecast_target_reps = max(1, min(forecast_target_reps, 20)) - topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset) + topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset, min_date, max_date) title = "History" best_fit_formula = None @@ -321,7 +328,9 @@ def get_exercise_history(person_id, exercise_id): exercise_progress = db.get_exercise_progress_for_user( person_id, exercise_id, - epoch='all', + epoch=epoch, + min_date=min_date, + max_date=max_date, forecast_enabled=forecast_enabled, forecast_weeks=forecast_weeks, forecast_target_reps=forecast_target_reps) @@ -348,7 +357,8 @@ def get_exercise_history(person_id, exercise_id): forecast=forecast, forecast_enabled=forecast_enabled, forecast_weeks=forecast_weeks, - forecast_target_reps=forecast_target_reps) + forecast_target_reps=forecast_target_reps, + selected_epoch=epoch, min_date=min_date, max_date=max_date) @workout_bp.route("/person//workout/", methods=['GET']) def show_workout(person_id, workout_id): diff --git a/scripts/audit_forecasts.py b/scripts/audit_forecasts.py new file mode 100644 index 0000000..b3857a9 --- /dev/null +++ b/scripts/audit_forecasts.py @@ -0,0 +1,86 @@ +"""Read-only production audit; raw sets are kept in memory, never committed. + +Run: .venv/Scripts/python scripts/audit_forecasts.py --person Peter +Requires the existing Dokku SSH connection. SELECT/COPY only, no DB changes. +""" +import argparse +from collections import defaultdict +import csv +from datetime import date +import io +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import numpy as np +from forecasting import (TransitionModel, backtest, benchmark, build_forecast, + e1rm, sessions, training_blocks, representative_set) + + +def load_person(name): + escaped = name.replace("'", "''") + query = f"""COPY (SELECT e.exercise_id, e.name, w.start_date, t.weight, t.repetitions + FROM topset t JOIN workout w USING(workout_id) + JOIN person p USING(person_id) JOIN exercise e USING(exercise_id) + WHERE lower(p.name) = lower('{escaped}') + ORDER BY e.exercise_id,w.start_date,t.topset_id) TO STDOUT WITH CSV HEADER;""" + output = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', + 'dokku@peterstockings.com', 'postgres:connect', 'workout-db'], + input=query, text=True, capture_output=True, check=True) + lines = output.stdout.splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith('exercise_id,')) + groups = defaultdict(list) + for row in csv.DictReader(io.StringIO('\n'.join(lines[start:]))): + groups[(int(row['exercise_id']), row['name'])].append( + (date.fromisoformat(row['start_date']), float(row['weight']), int(row['repetitions']))) + return groups + + +def run(groups): + totals = {h: defaultdict(list) for h in (1, 4, 8)} + split_count, eligible, session_count = 0, 0, 0 + for (exercise_id, name), data in groups.items(): + rows = sessions(*zip(*data)) + if not rows: + continue + blocks = training_blocks(rows) + split_count += len(blocks) - 1 + session_count += len(rows) + eligible += len(blocks[-1]) >= 4 + for block in blocks: + # Evaluate multiple horizons without letting a test cross a layoff. + for end in range(8, len(block)): + train = block[:end] + model = TransitionModel(train) + simulations = model.simulate(8) + for h in (1, 4, 8): + if end + h > len(block): + continue + actual = block[end + h - 1] + if h == 1: + predicted = model.next_set()[0][0] + else: + w, r = simulations[h] + predicted = representative_set(w,r) + candidates = {'transitions': predicted} + candidates.update({m: benchmark(train, m, h) for m in ('repeat', 'damped', 'linear')}) + for method, (w, r) in candidates.items(): + totals[h][method].append((abs(w - actual[1]), abs(r - actual[2]), + abs(float(e1rm(w,r)-e1rm(actual[1],actual[2]))))) + f = build_forecast([], [r[0] for r in rows], 8, + weights=[r[1] for r in rows], repetitions=[r[2] for r in rows]) + status = f['next_session']['set_display'] if f['available'] else f['reason'] + print(f'{exercise_id:4} | {name:42.42} | {len(rows):3} sessions | {len(blocks):2} blocks | latest {len(blocks[-1]):3} | {status}') + print(f'\nTOTAL: {len(groups)} exercises; {session_count} unique valid sessions; {split_count} breaks; {eligible} current blocks with 4+ sessions') + for h, methods in totals.items(): + print(f'\n{h}-SESSION HOLDOUTS (load kg / reps / E1RM kg mean absolute error):') + for method, errors in methods.items(): + print(method, 'n=',len(errors), 'MAE=',np.mean(errors,axis=0).round(3).tolist()) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--person', default='Peter') + args = parser.parse_args() + run(load_person(args.person)) diff --git a/scripts/preview_forecasts.py b/scripts/preview_forecasts.py new file mode 100644 index 0000000..97069c5 --- /dev/null +++ b/scripts/preview_forecasts.py @@ -0,0 +1,72 @@ +"""Local, read-only UI preview using Peter's production sets in memory.""" +from pathlib import Path +import sys +from datetime import date +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from flask import Flask, Blueprint, request, render_template, render_template_string +from dateutil.relativedelta import relativedelta +from scripts.audit_forecasts import load_person +from forecasting import e1rm +from utils import get_exercise_graph_model + +root = Path(__file__).resolve().parents[1] +app = Flask(__name__, template_folder=str(root / 'templates'), static_folder=str(root / 'static')) +app.config['TEMPLATES_AUTO_RELOAD'] = True +app.jinja_env.filters['strftime'] = lambda d: d.strftime('%d %b %Y') +data = load_person('Peter') +workout = Blueprint('workout', __name__) + + +def model(exercise_id): + (key, all_rows) = next((k,r) for k,r in data.items() if k[0] == exercise_id) + epoch = request.args.get('epoch', 'All') + minimum, maximum = request.args.get('min_date'), request.args.get('max_date') + minimum = date.fromisoformat(minimum) if minimum else None + maximum = date.fromisoformat(maximum) if maximum else None + if epoch in ('1M','3M','6M'): + minimum = date.today() - relativedelta(months=int(epoch[0])) + rows = [r for r in all_rows if (not minimum or r[0] >= minimum) and (not maximum or r[0] <= maximum)] + training = [dict(start_date=d, weight=w, repetitions=r) for d,w,r in all_rows if d <= max(x[0] for x in rows)] + m = get_exercise_graph_model(key[1], [round(float(e1rm(r[1],r[2])),1) for r in rows], + [r[2] for r in rows], [r[1] for r in rows], [r[0] for r in rows], ['']*len(rows), + epoch, 1007, exercise_id, forecast_enabled=request.args.get('forecast','true')=='true', + forecast_weeks=request.args.get('forecast_weeks',8,type=int), forecast_training_rows=training) + m.update(latest_topset_id=1, latest_workout_id=1, + topsets=[dict(start_date=d,weight=w,repetitions=r,workout_id=1,topset_id=i+1) for i,(d,w,r) in enumerate(reversed(rows[-5:]))], + offset=0, limit=5, source_topset_id=1) + return m + + +@app.route('/person//exercise//sparkline') +def get_exercise_progress_for_user(person_id,exercise_id): + return render_template('partials/sparkline.html', **model(exercise_id)) + + +@workout.route('/person//exercise//history') +def get_exercise_history(person_id,exercise_id): + return render_template('partials/exercise_history.html', **model(exercise_id)) + + +@workout.route('/person//workout//topset//achievements') +def get_topset_achievements(**kwargs): + return '' + + +app.register_blueprint(workout) + + +@app.route('/') +def index(): + exercise_id = request.args.get('exercise_id',1143,type=int) + graph = render_template('partials/sparkline.html', **model(exercise_id)) + return render_template_string(''' + Workout forecast preview + + + +
+

PETER / PROGRESS

{{ graph|safe }}
''', graph=graph) + + +if __name__ == '__main__': + app.run(port=5057, debug=False) diff --git a/static/css/forecast.css b/static/css/forecast.css new file mode 100644 index 0000000..76eee2a --- /dev/null +++ b/static/css/forecast.css @@ -0,0 +1,34 @@ +.exercise-progress{color:#334155;text-align:left;min-width:0;padding:12px 4px} +.progress-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px} +.progress-title{min-width:0}.progress-title h4{font-size:16px;font-weight:650;line-height:1.4;color:#1e293b;margin:0} +.progress-actions{display:flex;gap:5px;flex-shrink:0}.progress-action,.forecast-horizon{border:1px solid #dbe3e9;border-radius:7px;background:white;color:#475569;font-size:12px;padding:6px 9px;line-height:1.3;cursor:pointer} +.progress-action:hover,.forecast-horizon:hover{background:#f1f5f9}.progress-action.is-active{background:#ecfdf5;border-color:#a7d8c5;color:#047857} +.forecast-summary{border:1px solid #cfe5db;border-radius:12px;background:linear-gradient(125deg,#f0faf5,#fafdfb);padding:14px;margin:12px 0 16px} +.forecast-summary-grid{display:grid;grid-template-columns:1.4fr 1fr;gap:18px;align-items:center} +.forecast-eyebrow{font-size:10px;text-transform:uppercase;letter-spacing:.09em;font-weight:700;color:#527466} +.forecast-primary{font-size:25px;line-height:1.25;font-weight:700;color:#123c30;margin:5px 0;font-variant-numeric:tabular-nums} +.forecast-secondary{font-size:21px;font-weight:650;color:#334155;margin:5px 0;white-space:nowrap}.forecast-secondary span{font-size:11px;font-weight:400} +.forecast-horizon-summary{border-left:1px solid #d4e8df;padding-left:18px} +.forecast-caption{font-size:11px;color:#64748b;line-height:1.6}.forecast-assumption{margin:10px 0 0} +.forecast-controls{display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:12px}.forecast-controls>span{margin-right:5px} +.progress-epochs{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin:8px 0}.progress-epochs>span{margin-right:8px} +.forecast-horizon{padding:5px 10px;font-variant-numeric:tabular-nums}.forecast-horizon.is-active{background:#16624c;color:white;border-color:#16624c} +.forecast-chart{display:block;width:100%;height:230px;outline-offset:4px;border-radius:3px;touch-action:pan-y} +.forecast-chart:focus-visible{outline:2px solid #0d9470}.forecast-hit{cursor:crosshair} +.forecast-plot{display:flex;gap:6px;margin-top:7px}.forecast-plot .forecast-chart{flex:1;min-width:0}.forecast-y-axis{width:31px;flex-shrink:0;display:flex;flex-direction:column;justify-content:space-between;font-size:10px;color:#94a3b8;text-align:right;padding:3px 0} +.forecast-axis{display:flex;justify-content:space-between;font-size:10px;color:#94a3b8;margin:7px 0 12px;font-variant-numeric:tabular-nums} +.forecast-readout{border:1px solid #e2e8f0;border-radius:10px;background:#fff;padding:10px 12px;min-height:94px} +.forecast-readout-heading{display:flex;justify-content:space-between;gap:12px;font-size:11px;color:#64748b;line-height:1.5;margin-bottom:8px} +.forecast-readout-heading time{color:#334155;font-weight:600;white-space:nowrap} +.forecast-readout-values{display:grid;grid-template-columns:1.2fr .7fr 1.2fr;gap:10px}.forecast-readout-values strong{display:block;color:#334155;font-size:12px;font-weight:600;line-height:1.5;font-variant-numeric:tabular-nums} +.forecast-chart-hint{font-size:10px;color:#94a3b8;margin:7px 0;text-align:center} +.forecast-table-wrap{overflow:auto;max-height:320px;border:1px solid #e2e8f0;border-radius:10px;margin:12px 0;background:white} +.forecast-table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap;font-variant-numeric:tabular-nums} +.forecast-table th{position:sticky;top:0;background:#f3f7f5;color:#64748b;font-size:10px;font-weight:600;padding:9px 12px;z-index:1} +.forecast-table td{padding:10px 12px;border-top:1px solid #f1f5f9;font-size:12px;color:#475569}.forecast-table td strong{color:#234d3b}.forecast-table tr:hover td{background:#f8fbf9} +.forecast-table-title{font-size:14px;font-weight:650;color:#234d3b;margin-top:16px} +.forecast-mobile-range{display:none} +@media(max-width:520px){.forecast-table .forecast-desktop-range{display:none}.forecast-mobile-range{display:block;font-size:9px;color:#94a3b8;margin-top:3px}.forecast-table-wrap .forecast-table td,.forecast-table-wrap .forecast-table th{padding:9px 6px}} +.forecast-method{margin:14px 0;border-top:1px solid #e2e8f0;padding-top:12px}.forecast-method summary{font-size:12px;color:#475569;cursor:pointer;font-weight:500}.forecast-method p{margin:8px 0;font-size:12px;line-height:1.7;color:#64748b} +.forecast-facts{display:flex;flex-wrap:wrap;gap:8px 18px;margin:10px 0;font-size:11px;color:#64748b}.forecast-facts strong{color:#334155} +@media(max-width:520px){.progress-heading{flex-wrap:wrap;gap:7px}.progress-title{flex:1 1 180px}.progress-actions{margin-left:auto}.forecast-summary{padding:12px}.forecast-summary-grid{gap:10px}.forecast-primary{font-size:21px}.forecast-horizon-summary{padding-left:10px}.forecast-secondary{font-size:18px}.forecast-secondary span{display:block}.forecast-chart{height:190px}.forecast-readout-values{grid-template-columns:1fr 1fr}.forecast-readout-values>div:last-child{grid-column:1/-1;display:flex;align-items:center;gap:8px}.forecast-readout{min-height:120px}.forecast-chart-hint{font-size:9px}.forecast-table td,.forecast-table th{padding:9px}.exercise-progress>.inline-flex{display:flex}.exercise-progress>.inline-flex>div{padding:7px 12px;font-size:12px}} diff --git a/static/js/forecast.js b/static/js/forecast.js new file mode 100644 index 0000000..009da0e --- /dev/null +++ b/static/js/forecast.js @@ -0,0 +1,39 @@ +/* Delegation survives HTMX swaps; all readout values are plain text. */ +(() => { + if (window.workoutForecastEvents) return; + window.workoutForecastEvents = true; + function inspect(hit) { + const panel = hit.closest('.exercise-progress'); + if (!panel) return; + for (const key of ['kind', 'date', 'e1rm', 'topset', 'range']) { + const field = panel.querySelector(`[data-readout="${key}"]`); + if (field) field.textContent = hit.dataset[key]; + } + const cursor = panel.querySelector('.forecast-cursor'); + if (cursor) { + cursor.setAttribute('x1', hit.dataset.position); + cursor.setAttribute('x2', hit.dataset.position); + } + panel.querySelectorAll('.forecast-hit').forEach(p => p.removeAttribute('data-selected')); + hit.dataset.selected = 'true'; + } + for (const event of ['pointerover', 'click']) { + document.addEventListener(event, e => { + const hit = e.target.closest?.('.forecast-hit'); + if (hit) inspect(hit); + }); + } + document.addEventListener('keydown', e => { + if (!e.target.matches?.('.forecast-chart') || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; + const hits = [...e.target.querySelectorAll('.forecast-hit')]; + if (!hits.length) return; + let index = hits.findIndex(p => p.dataset.selected); + if (index < 0) { + index = hits.findIndex(p => p.dataset.kind === 'Current reading'); + if (index < 0) index = hits.length - 1; + } + index = e.key === 'Home' ? 0 : e.key === 'End' ? hits.length - 1 : Math.max(0, Math.min(hits.length - 1, index + (e.key === 'ArrowRight' ? 1 : -1))); + e.preventDefault(); + inspect(hits[index]); + }); +})(); diff --git a/templates/base.html b/templates/base.html index dde9336..6a45253 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,7 +16,9 @@ - + + + @@ -309,4 +311,4 @@ - \ No newline at end of file + diff --git a/templates/partials/exercise_history.html b/templates/partials/exercise_history.html index 6a62702..b157c17 100644 --- a/templates/partials/exercise_history.html +++ b/templates/partials/exercise_history.html @@ -1,28 +1,16 @@ {% if offset == 0 %}
-
-
-

{{ title }}

-
-
-
-
- -
-
+
+
+

{{ title }}

+
+
+ +
{% if best_fit_formula and not forecast_enabled %}

{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month @@ -31,44 +19,56 @@
{% endif %} {% if forecast_enabled and forecast.available %} -
-
-
-
Projected rep-to-weight map
-
In {{ forecast.horizon_weeks }} weeks · projected E1RM {{ forecast.projected_value }}kg
-
-
- Based on {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} reps in view
- expanded by ±{{ forecast.rep_buffer }} reps -
-
-
- - - - - - - - - - - {% for top_set in forecast.top_sets %} - - - - - - +
+
Upcoming sessions
+

Approximate dates, every {{ forecast.profile.cadence_days }} days. These are scenarios based on your recent routine.

+ {% if forecast.stale %}

Historical projection from {{ forecast.last_date.strftime('%d %b %Y') }}. Log recent workouts to update.

{% endif %} +
+
RepsEst. loadModel rangeProj. E1RM
{{ top_set.repetitions }}{{ top_set.weight_display }}kg{{ top_set.lower_weight_display }}–{{ top_set.upper_weight_display }}kg{{ top_set.e1rm_display }}kg
+ + + {% for point in forecast.session_points %} + + + + {% endfor %}
AroundTop-set scenarioEst. 1RME1RM range
{{ point.date.strftime('%d %b %Y') }}{{ point.set_display }}{{ point.value }} kg{{ point.lower }}–{{ point.upper }} range{{ point.lower }}–{{ point.upper }} kg
-
Click a row to return to the graph with that rep target selected. Loads rounded to {{ forecast.target_weight_increment_display }}kg increments.
-
+ {% if not forecast.session_points %}

No session expected inside this horizon at your recent cadence.

{% endif %} +
+ Why this forecast? +
+ {{ forecast.observed_points }} sessions since {{ forecast.training_start.strftime('%d %b %Y') }} + {{ forecast.training_blocks - 1 }} earlier training blocks excluded +
+

At the current load: {{ forecast.profile.block_sessions }} sessions, from {{ forecast.profile.block_start_reps }} to a best of {{ forecast.profile.block_best_reps }} reps. {{ forecast.profile.block_stalls }} sessions did not improve that rep best.

+ {% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %} +

Your recent load increases usually follow {{ forecast.profile.ceiling }} reps, using {{ '%g'|format(forecast.profile.increment) }} kg steps. The model learns repeats, rep gains, difficult sessions and rep resets from this exercise.

+ {% else %}

There is not enough repeated loading history to infer a reliable rep threshold. Repeated sets remain a possible outcome.

{% endif %} + {% if forecast.validation %} + {% set validation = forecast.validation.transitions %} +

Checked against {{ validation.count }} past next-session outcomes, each predicted using only earlier data: average error {{ validation.weight_mae }} kg load, {{ validation.reps_mae }} reps, {{ validation.e1rm_mae }} kg E1RM. Repeating the last set scored {{ forecast.validation.repeat.e1rm_mae }} kg E1RM error.

+ {% else %}

Too few sessions for a useful accuracy check yet.

{% endif %} +

Ranges combine simulated outcomes with past forecast errors. They are not guaranteed limits. Each row is a representative outcome at that date; the rows are not a prescribed training plan. Longer horizons are less certain.

+
+
+ Equivalent loads at other rep counts · {{ forecast.end_date.strftime('%d %b %Y') }} +

These are formula equivalents of {{ forecast.projected_value }} kg E1RM, not predictions that you will choose these sets. Rep range: {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} in view, expanded by ±{{ forecast.rep_buffer }}.

+
+ + + {% for top_set in forecast.top_sets %} + + {% endfor %} +
RepsEquivalent loadLoad range
{{ top_set.repetitions }}{{ top_set.weight_display }} kg{{ top_set.lower_weight_display }}–{{ top_set.upper_weight_display }} kg
+
+

Rounded to {{ forecast.target_weight_increment_display }} kg display increments. Equivalents at rep counts far from your usual range are less reliable.

+
+ + {% elif forecast_enabled %} +

{{ forecast.reason }}

{% endif %} @@ -103,7 +103,7 @@
diff --git a/templates/partials/sparkline.html b/templates/partials/sparkline.html index 007ed33..529a79d 100644 --- a/templates/partials/sparkline.html +++ b/templates/partials/sparkline.html @@ -60,108 +60,77 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | {% set parts = [random_int()] %} {% set unique_id = parts|join('-') %} -
-
-
-

{{ title }}

-
-
-
-
- +
+
+
+

{{ title }}

+
-
- + + hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']" + hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Forecast {{ 'on' if forecast_enabled else 'off' }}
-
+

{% if best_fit_formula and not forecast_enabled %} {{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month {% endif %}

-
- {% for epoch in epochs %} -
- {% if epoch == 'Custom' %} - - - - {% else %} - {{ epoch}} - {% endif %} -
- {% endfor %} +
+ History + {% for epoch in epochs %} + + {% endfor %}
{% if forecast_enabled %} -
+
{% if forecast.available %} -
+ {% set next = forecast.next_session %} +
-
Forecast · {{ forecast.horizon_weeks }} weeks
-
{{ forecast.projected_value }}kg projected E1RM
+
{{ 'Historical forecast' if forecast.stale else 'Likely next session' }}
+
{{ next.set_display }}
+
Around {{ next.date.strftime('%d %b %Y') }} · {{ next.value }} kg est. 1RM
-
-
{% if forecast.projected_change >= 0 %}+{% endif %}{{ forecast.projected_change }}kg total
-
{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions
+
+
{{ forecast.horizon_weeks }}-week scenario
+
{{ forecast.projected_value }} kg est. 1RM
+
By {{ forecast.end_date.strftime('%d %b %Y') }}
- {% if forecast.selected_top_set %} -
- Top-set guide - {{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }} - at the selected rep target · details in History +
+ {% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %} + Your pattern: build toward {{ forecast.profile.ceiling }} reps, then +{{ '%g'|format(forecast.profile.increment) }} kg. + {% else %} + Learning from your recent repeats, rep changes and load changes. + {% endif %} + {% if forecast.stale %}Based on an older training block; log new sessions to update.{% endif %}
- {% endif %} -
- Horizon +
+ Look ahead {% for weeks, label in forecast_horizons %} - {% endfor %}
+ {% if forecast.long_range %}

Long-range scenario, assuming a similar routine. Exact sets and dates become less certain.

{% endif %} {% else %} -
Forecast unavailable
-
{{ forecast.reason }}
+
Learning your pattern
+

{{ forecast.reason }}

{% endif %} -
+
{% endif %} {% if selected_epoch == 'Custom' %}
@@ -246,76 +215,53 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
{% endif %} - + {% if forecast.available %}
Estimated 1RM · kg
{% endif %} +
+ {% if forecast.available %}{% endif %} + {% if forecast_enabled and forecast.available %} - + {{ forecast_circles(forecast_points, forecast_details, '#059669') }} {% endif %} {% for plot in plots %} - + {{ plot_line(plot.points, plot.color) }} {% endfor %} - - {% for detail in plot_details %} - {% set x = (detail.position * vb_width) - (stroke_width/2) + margin %} - {% set y = 0 %} - {% set width = stroke_width %} - {% set height = vb_height + margin %} - + + + {% for detail in hover_details %} + + {{ detail.date }} · {{ detail.top_set }} · {{ detail.e1rm }} E1RM + {% endfor %} - {% if forecast_enabled and forecast.available %} - {% for detail in forecast_details %} - {% set x = (detail.position * vb_width) - (stroke_width/2) + margin %} - - {% endfor %} - {% endif %} - - + {% if not forecast_enabled %} + + {% endif %} -