Learn session forecasts from training blocks and clarify progress UI

This commit is contained in:
Peter Stockings
2026-09-06 16:20:15 +10:00
parent 499d859478
commit 86fade54f1
13 changed files with 1008 additions and 343 deletions
+23 -8
View File
@@ -380,7 +380,7 @@ class DataBase():
else: else:
return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name']) 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(""" topsets = self.execute("""
SELECT SELECT
t.topset_id, t.topset_id,
@@ -394,11 +394,13 @@ class DataBase():
JOIN topset t ON e.exercise_id = t.exercise_id JOIN topset t ON e.exercise_id = t.exercise_id
JOIN workout w ON t.workout_id = w.workout_id JOIN workout w ON t.workout_id = w.workout_id
WHERE 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 ORDER BY
w.start_date DESC, t.topset_id DESC w.start_date DESC, t.topset_id DESC
LIMIT %s OFFSET %s; 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 return topsets
@@ -419,7 +421,7 @@ class DataBase():
SELECT SELECT
T.topset_id, E.name AS exercise_name, W.person_id, T.workout_id, T.topset_id, E.name AS exercise_name, W.person_id, T.workout_id,
T.repetitions, T.weight, 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 W.start_date
FROM FROM
topset T 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) AND
(%s::date IS NULL OR W.start_date <= %s::date) (%s::date IS NULL OR W.start_date <= %s::date)
ORDER BY ORDER BY
W.start_date; W.start_date, T.topset_id;
""", [person_id, exercise_id, min_date, min_date, max_date, max_date]) """, [person_id, exercise_id, min_date, min_date, max_date, max_date])
# Return None if no topsets found # Return None if no topsets found
@@ -442,7 +444,7 @@ class DataBase():
# Extracting values and calculating value ranges for SVG dimensions # Extracting values and calculating value ranges for SVG dimensions
exercise_name = topsets[0]['exercise_name'] 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] repetitions = [t['repetitions'] for t in topsets]
weight = [t['weight'] for t in topsets] weight = [t['weight'] for t in topsets]
start_dates = [t['start_date'] 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_topset_id = latest_topset['topset_id']
latest_workout_id = latest_topset['workout_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, exercise_name,
estimated_1rm, estimated_1rm,
repetitions, repetitions,
@@ -468,7 +482,8 @@ class DataBase():
degree, degree,
forecast_enabled, forecast_enabled,
forecast_weeks, 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_topset_id'] = latest_topset_id
exercise_progress['latest_workout_id'] = latest_workout_id exercise_progress['latest_workout_id'] = latest_workout_id
+125
View File
@@ -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.
+319
View File
@@ -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
+14 -4
View File
@@ -5,7 +5,9 @@ from flask_login import login_required, current_user
from extensions import db from extensions import db
from decorators import validate_workout, validate_topset, require_ownership, validate_person from decorators import validate_workout, validate_topset, require_ownership, validate_person
from utils import convert_str_to_date 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__) workout_bp = Blueprint('workout', __name__)
htmx = HTMX() htmx = HTMX()
@@ -301,6 +303,11 @@ def get_exercise_history(person_id, exercise_id):
limit = request.args.get('limit', type=int, default=5) limit = request.args.get('limit', type=int, default=5)
offset = request.args.get('offset', type=int, default=0) offset = request.args.get('offset', type=int, default=0)
source_topset_id = request.args.get('source_topset_id', type=int) 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_enabled = request.args.get('forecast', '').lower() in ('1', 'true', 'yes', 'on')
forecast_weeks = request.args.get('forecast_weeks', type=int, default=8) forecast_weeks = request.args.get('forecast_weeks', type=int, default=8)
forecast_weeks = max(4, min(forecast_weeks, 52)) 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: if forecast_target_reps is not None:
forecast_target_reps = max(1, min(forecast_target_reps, 20)) 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" title = "History"
best_fit_formula = None best_fit_formula = None
@@ -321,7 +328,9 @@ def get_exercise_history(person_id, exercise_id):
exercise_progress = db.get_exercise_progress_for_user( exercise_progress = db.get_exercise_progress_for_user(
person_id, person_id,
exercise_id, exercise_id,
epoch='all', epoch=epoch,
min_date=min_date,
max_date=max_date,
forecast_enabled=forecast_enabled, forecast_enabled=forecast_enabled,
forecast_weeks=forecast_weeks, forecast_weeks=forecast_weeks,
forecast_target_reps=forecast_target_reps) forecast_target_reps=forecast_target_reps)
@@ -348,7 +357,8 @@ def get_exercise_history(person_id, exercise_id):
forecast=forecast, forecast=forecast,
forecast_enabled=forecast_enabled, forecast_enabled=forecast_enabled,
forecast_weeks=forecast_weeks, 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/<int:person_id>/workout/<int:workout_id>", methods=['GET']) @workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>", methods=['GET'])
def show_workout(person_id, workout_id): def show_workout(person_id, workout_id):
+86
View File
@@ -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))
+72
View File
@@ -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/<int:person_id>/exercise/<int:exercise_id>/sparkline')
def get_exercise_progress_for_user(person_id,exercise_id):
return render_template('partials/sparkline.html', **model(exercise_id))
@workout.route('/person/<int:person_id>/exercise/<int:exercise_id>/history')
def get_exercise_history(person_id,exercise_id):
return render_template('partials/exercise_history.html', **model(exercise_id))
@workout.route('/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/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('''<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workout forecast preview</title><link rel="stylesheet" href="/static/css/tailwind.css">
<link rel="stylesheet" href="/static/css/forecast.css"><script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script><script src="/static/js/forecast.js" defer></script></head>
<body style="background:#f5f7f8;font-family:system-ui;margin:0;padding:16px">
<main style="max-width:680px;margin:24px auto;background:white;border:1px solid #e2e8f0;border-radius:16px;padding:16px">
<p style="font-size:11px;color:#94a3b8;margin-bottom:16px">PETER / PROGRESS</p>{{ graph|safe }}</main></body></html>''', graph=graph)
if __name__ == '__main__':
app.run(port=5057, debug=False)
+34
View File
@@ -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}}
+39
View File
@@ -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]);
});
})();
+4 -2
View File
@@ -16,7 +16,9 @@
<link href="/static/css/style.css" rel="stylesheet"> <link href="/static/css/style.css" rel="stylesheet">
<script src="/static/js/htmx.min.js" defer></script> <script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/hyperscript.min.js" defer></script> <script src="/static/js/hyperscript.min.js" defer></script>
<link rel="stylesheet" href="/static/css/forecast.css?v=2">
<script src="/static/js/forecast.js?v=2" defer></script>
</head> </head>
<body> <body>
@@ -309,4 +311,4 @@
</body> </body>
</html> </html>
+57 -57
View File
@@ -1,28 +1,16 @@
{% if offset == 0 %} {% if offset == 0 %}
<div id="exercise-history-container" <div id="exercise-history-container"
class="w-full bg-gray-50 p-4 border-t border-gray-200 shadow-inner overflow-x-auto"> class="w-full bg-gray-50 p-4 border-t border-gray-200 shadow-inner overflow-x-auto">
<div class="flex items-center justify-between sm:justify-center relative mb-1"> <div class="progress-heading">
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2"> <div class="progress-title">
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4> <h4>{{ title }}</h4>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}" <div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center"> hx-trigger="load" hx-target="this" hx-swap="innerHTML"></div>
</div> </div>
</div> <button class="progress-action" title="Show Progress Graph"
<div class="absolute left-0 z-10"> hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, min_date=min_date, max_date=max_date) }}"
<button hx-target="#exercise-history-container" hx-swap="outerHTML">Graph</button>
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100" </div>
title="Show Progress Graph"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch='All', forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-target="#exercise-history-container" hx-swap="outerHTML">
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
<span class="sr-only">Show Progress Graph</span>
</button>
</div>
</div>
{% if best_fit_formula and not forecast_enabled %} {% if best_fit_formula and not forecast_enabled %}
<h2 class="text-xs font-semibold text-blue-200 mb-4 text-center"> <h2 class="text-xs font-semibold text-blue-200 mb-4 text-center">
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month {{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
@@ -31,44 +19,56 @@
<div class="mb-4"></div> <div class="mb-4"></div>
{% endif %} {% endif %}
{% if forecast_enabled and forecast.available %} {% if forecast_enabled and forecast.available %}
<div class="mb-4 rounded-lg border border-emerald-100 bg-emerald-50/70 p-3 text-left"> <section class="exercise-progress" aria-label="Workout forecasts">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div class="forecast-table-title">Upcoming sessions</div>
<div> <p class="forecast-caption">Approximate dates, every {{ forecast.profile.cadence_days }} days. These are scenarios based on your recent routine.</p>
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">Projected rep-to-weight map</div> {% if forecast.stale %}<p class="forecast-caption">Historical projection from {{ forecast.last_date.strftime('%d %b %Y') }}. Log recent workouts to update.</p>{% endif %}
<div class="text-sm text-gray-700">In {{ forecast.horizon_weeks }} weeks · projected E1RM {{ forecast.projected_value }}kg</div> <div class="forecast-table-wrap">
</div> <table class="forecast-table">
<div class="text-xs text-gray-500 sm:text-right"> <thead><tr><th scope="col">Around</th><th scope="col">Top-set scenario</th><th scope="col">Est. 1RM</th><th class="forecast-desktop-range" scope="col">E1RM range</th></tr></thead>
Based on {{ forecast.observed_rep_min }}–{{ forecast.observed_rep_max }} reps in view<br> <tbody>
expanded by ±{{ forecast.rep_buffer }} reps {% for point in forecast.session_points %}
</div> <tr><td>{{ point.date.strftime('%d %b %Y') }}</td>
</div> <td><strong>{{ point.set_display }}</strong></td>
<div class="mt-3 overflow-x-auto rounded border border-emerald-100 bg-white"> <td>{{ point.value }} kg<span class="forecast-mobile-range">{{ point.lower }}–{{ point.upper }} range</span></td>
<table class="w-full text-left text-xs text-gray-600"> <td class="forecast-desktop-range">{{ point.lower }}–{{ point.upper }} kg</td></tr>
<thead class="bg-gray-50 text-gray-500">
<tr>
<th class="px-3 py-2 font-medium">Reps</th>
<th class="px-3 py-2 font-medium">Est. load</th>
<th class="px-3 py-2 font-medium">Model range</th>
<th class="px-3 py-2 font-medium text-right">Proj. E1RM</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for top_set in forecast.top_sets %}
<tr class="cursor-pointer {% if forecast.selected_reps == top_set.repetitions %}bg-emerald-50 text-emerald-800 font-semibold{% else %}hover:bg-gray-50{% endif %}"
title="View {{ top_set.repetitions }} reps on the graph"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch='All', forecast='true', forecast_weeks=forecast_weeks, forecast_reps=top_set.repetitions) }}"
hx-target="#exercise-history-container" hx-swap="outerHTML">
<td class="px-3 py-2">{{ top_set.repetitions }}</td>
<td class="px-3 py-2">{{ top_set.weight_display }}kg</td>
<td class="px-3 py-2">{{ top_set.lower_weight_display }}–{{ top_set.upper_weight_display }}kg</td>
<td class="px-3 py-2 text-right">{{ top_set.e1rm_display }}kg</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mt-2 text-[11px] text-gray-400">Click a row to return to the graph with that rep target selected. Loads rounded to {{ forecast.target_weight_increment_display }}kg increments.</div> {% if not forecast.session_points %}<p class="forecast-caption">No session expected inside this horizon at your recent cadence.</p>{% endif %}
</div> <details class="forecast-method">
<summary>Why this forecast?</summary>
<div class="forecast-facts">
<span><strong>{{ forecast.observed_points }}</strong> sessions since {{ forecast.training_start.strftime('%d %b %Y') }}</span>
<span><strong>{{ forecast.training_blocks - 1 }}</strong> earlier training blocks excluded</span>
</div>
<p>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.</p>
{% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %}
<p>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.</p>
{% else %}<p>There is not enough repeated loading history to infer a reliable rep threshold. Repeated sets remain a possible outcome.</p>{% endif %}
{% if forecast.validation %}
{% set validation = forecast.validation.transitions %}
<p>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.</p>
{% else %}<p>Too few sessions for a useful accuracy check yet.</p>{% endif %}
<p>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.</p>
</details>
<details class="forecast-method">
<summary>Equivalent loads at other rep counts · {{ forecast.end_date.strftime('%d %b %Y') }}</summary>
<p>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 }}.</p>
<div class="forecast-table-wrap">
<table class="forecast-table">
<thead><tr><th scope="col">Reps</th><th scope="col">Equivalent load</th><th scope="col">Load range</th></tr></thead>
<tbody>{% for top_set in forecast.top_sets %}
<tr><td>{{ top_set.repetitions }}</td><td>{{ top_set.weight_display }} kg</td><td>{{ top_set.lower_weight_display }}–{{ top_set.upper_weight_display }} kg</td></tr>
{% endfor %}</tbody>
</table>
</div>
<p>Rounded to {{ forecast.target_weight_increment_display }} kg display increments. Equivalents at rep counts far from your usual range are less reliable.</p>
</details>
</section>
{% elif forecast_enabled %}
<p class="forecast-caption">{{ forecast.reason }}</p>
{% endif %} {% endif %}
<table class="w-full text-left text-sm text-gray-500"> <table class="w-full text-left text-sm text-gray-500">
<thead class="text-xs text-gray-700 uppercase bg-gray-100"> <thead class="text-xs text-gray-700 uppercase bg-gray-100">
@@ -103,7 +103,7 @@
<td colspan="2" class="px-3 py-3 text-center"> <td colspan="2" class="px-3 py-3 text-center">
<button <button
class="text-sm text-blue-600 hover:underline font-medium px-4 py-2 border border-blue-600 rounded" class="text-sm text-blue-600 hover:underline font-medium px-4 py-2 border border-blue-600 rounded"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, limit=limit, offset=offset + limit, source_topset_id=source_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}" hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, limit=limit, offset=offset + limit, source_topset_id=source_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, epoch=selected_epoch, min_date=min_date, max_date=max_date) }}"
hx-target="#history-load-more-{{ source_topset_id }}-{{ offset + limit }}" hx-swap="outerHTML"> hx-target="#history-load-more-{{ source_topset_id }}-{{ offset + limit }}" hx-swap="outerHTML">
Load More Load More
</button> </button>
+80 -134
View File
@@ -60,108 +60,77 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
{% set parts = [random_int()] %} {% set parts = [random_int()] %}
{% set unique_id = parts|join('-') %} {% set unique_id = parts|join('-') %}
<div class="relative" id="svg-plot-{{ unique_id }}" _=" <div class="relative exercise-progress" id="svg-plot-{{ unique_id }}">
on mouseover from .pnt-{{ unique_id }} <div class="progress-heading">
put (event.target's @data-kind) into #popover-kind-{{ unique_id }} <div class="progress-title">
then put (event.target's @data-date) into #popover-date-{{ unique_id }} <h4>{{ title }}</h4>
then put (event.target's @data-e1rm) into #popover-e1rm-{{ unique_id }} <div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
then put (event.target's @data-topset) into #popover-topset-{{ unique_id }} hx-trigger="load" hx-target="this" hx-swap="innerHTML"></div>
then put (event.target's @data-range) into #popover-range-{{ unique_id }}
then remove .hidden from #popover-{{ unique_id }}
on mouseout from .pnt-{{ unique_id }}
add .hidden to #popover-{{ unique_id }}">
<div class="flex items-center justify-between sm:justify-center relative mb-1">
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
</div>
</div>
<div class="absolute left-0 z-10">
<button
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
title="Show History"
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
<span class="sr-only">Show History</span>
</button>
</div> </div>
<div class="absolute right-0 z-10"> <div class="progress-actions">
<button <button class="progress-action" title="History and forecast tables"
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg border transition-colors {% if forecast_enabled %}text-emerald-700 bg-emerald-50 border-emerald-200 hover:bg-emerald-100{% else %}text-gray-600 bg-white border-gray-300 hover:bg-gray-50{% endif %}" hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps, epoch=selected_epoch, min_date=min_date, max_date=max_date) }}"
title="{% if forecast_enabled %}Hide forecast{% else %}Show forecast{% endif %}" hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Table</button>
<button class="progress-action {% if forecast_enabled %}is-active{% endif %}"
aria-pressed="{{ 'true' if forecast_enabled else 'false' }}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='false' if forecast_enabled else 'true', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}" hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='false' if forecast_enabled else 'true', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']" 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"> hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">Forecast {{ 'on' if forecast_enabled else 'off' }}</button>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
{% if forecast_enabled %}Forecast on{% else %}Forecast{% endif %}
</button>
</div> </div>
</div> </div>
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center"> <h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
{% if best_fit_formula and not forecast_enabled %} {% if best_fit_formula and not forecast_enabled %}
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month {{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
{% endif %} {% endif %}
</h2> </h2>
<div class="inline-flex rounded-md shadow-sm w-full items-center justify-center mb-1"> <div class="progress-epochs" aria-label="Visible history">
{% for epoch in epochs %} <span class="forecast-caption">History</span>
<div {% if selected_epoch==epoch %} {% for epoch in epochs %}
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" <button class="forecast-horizon {% if selected_epoch == epoch %}is-active{% endif %}"
{% else %} aria-pressed="{{ 'true' if selected_epoch == epoch else 'false' }}"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white cursor-pointer" hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=epoch, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}"
hx-get='{{ url_for("get_exercise_progress_for_user", person_id=person_id, exercise_id=exercise_id, epoch=epoch, forecast="true" if forecast_enabled else "false", forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ epoch }}</button>
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="click" {% endif %}> {% endfor %}
{% if epoch == 'Custom' %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
<path
d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
</svg>
{% else %}
{{ epoch}}
{% endif %}
</div>
{% endfor %}
</div> </div>
{% if forecast_enabled %} {% if forecast_enabled %}
<div class="mx-2 mb-3 rounded-xl border {% if forecast.available %}border-emerald-100 bg-emerald-50/70{% else %}border-amber-100 bg-amber-50/70{% endif %} p-2.5 text-left"> <section class="forecast-summary" aria-label="Forecast summary">
{% if forecast.available %} {% if forecast.available %}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1.5"> {% set next = forecast.next_session %}
<div class="forecast-summary-grid">
<div> <div>
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800">Forecast · {{ forecast.horizon_weeks }} weeks</div> <div class="forecast-eyebrow">{{ 'Historical forecast' if forecast.stale else 'Likely next session' }}</div>
<div class="text-lg font-bold text-gray-800 mt-0.5">{{ forecast.projected_value }}kg <span class="text-xs font-normal text-gray-500">projected E1RM</span></div> <div class="forecast-primary">{{ next.set_display }}</div>
<div class="forecast-caption">Around {{ next.date.strftime('%d %b %Y') }} · {{ next.value }} kg est. 1RM</div>
</div> </div>
<div class="text-left sm:text-right"> <div class="forecast-horizon-summary">
<div class="text-sm font-semibold {% if forecast.projected_change >= 0 %}text-emerald-700{% else %}text-rose-700{% endif %}">{% if forecast.projected_change >= 0 %}+{% endif %}{{ forecast.projected_change }}kg total</div> <div class="forecast-eyebrow">{{ forecast.horizon_weeks }}-week scenario</div>
<div class="text-xs text-gray-500">{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions</div> <div class="forecast-secondary">{{ forecast.projected_value }} <span>kg est. 1RM</span></div>
<div class="forecast-caption">By {{ forecast.end_date.strftime('%d %b %Y') }}</div>
</div> </div>
</div> </div>
{% if forecast.selected_top_set %} <div class="forecast-caption forecast-assumption">
<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg bg-white/80 px-2.5 py-2 text-sm"> {% if forecast.profile.increment and forecast.profile.ceiling_evidence >= 2 %}
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Top-set guide</span> Your pattern: build toward {{ forecast.profile.ceiling }} reps, then +{{ '%g'|format(forecast.profile.increment) }} kg.
<span class="font-bold text-gray-800">{{ forecast.selected_top_set.weight_display }}kg × {{ forecast.selected_top_set.repetitions }}</span> {% else %}
<span class="text-xs text-gray-500">at the selected rep target · details in History</span> 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 %}
</div> </div>
{% endif %} <div class="forecast-controls">
<div class="flex flex-wrap items-center gap-1.5 mt-2.5"> <span class="forecast-caption">Look ahead</span>
<span class="text-xs text-gray-500 mr-1">Horizon</span>
{% for weeks, label in forecast_horizons %} {% for weeks, label in forecast_horizons %}
<button class="px-2 py-0.5 rounded-md text-xs font-medium {% if forecast_weeks == weeks %}bg-emerald-700 text-white{% else %}bg-white text-gray-600 border border-emerald-200 hover:bg-emerald-100{% endif %}" <button class="forecast-horizon {% if forecast_weeks == weeks %}is-active{% endif %}"
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='true', forecast_weeks=weeks, forecast_reps=forecast_target_reps) }}" aria-pressed="{{ 'true' if forecast_weeks == weeks else 'false' }}"
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']" hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, epoch=selected_epoch, forecast='true', forecast_weeks=weeks, forecast_reps=forecast_target_reps, min_date=min_date, max_date=max_date) }}"
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }}</button> hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">{{ label }}</button>
{% endfor %} {% endfor %}
</div> </div>
{% if forecast.long_range %}<p class="forecast-caption forecast-assumption">Long-range scenario, assuming a similar routine. Exact sets and dates become less certain.</p>{% endif %}
{% else %} {% else %}
<div class="text-sm font-medium text-amber-800">Forecast unavailable</div> <div class="forecast-eyebrow">Learning your pattern</div>
<div class="text-xs text-amber-700 mt-1">{{ forecast.reason }}</div> <p class="forecast-caption">{{ forecast.reason }}</p>
{% endif %} {% endif %}
</div> </section>
{% endif %} {% endif %}
{% if selected_epoch == 'Custom' %} {% if selected_epoch == 'Custom' %}
<div class="flex flex-col md:flex-row justify-center pb-4"> <div class="flex flex-col md:flex-row justify-center pb-4">
@@ -246,76 +215,53 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) |
</div> </div>
{% endif %} {% endif %}
<svg viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none"> {% if forecast.available %}<div class="forecast-caption">Estimated 1RM · kg</div>{% endif %}
<div class="forecast-plot">
{% if forecast.available %}<div class="forecast-y-axis" aria-hidden="true">{% for value in e1rm_scale %}<span>{{ value }}</span>{% endfor %}</div>{% endif %}
<svg class="forecast-chart" tabindex="0" role="group" aria-label="Exercise history and forecast. Use left and right arrows to inspect dates." viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none">
{% if forecast_enabled and forecast.available %} {% if forecast_enabled and forecast.available %}
<path d="{{ path_band(forecast_band_lower, forecast_band_upper, vb_height) }}" fill="#10b981" fill-opacity="0.10" stroke="none"></path> <path d="{{ path_band(forecast_band_lower, forecast_band_upper, vb_height) }}" fill="#10b981" fill-opacity="0.10" stroke="none"></path>
<path d="{{ path(forecast_points, vb_height) }}" stroke="#059669" stroke-width="2" stroke-dasharray="5,3" fill="none"></path> <path d="{{ path(forecast_points, vb_height) }}" stroke="#059669" stroke-width="2" vector-effect="non-scaling-stroke" stroke-dasharray="5,4" fill="none"></path>
{{ forecast_circles(forecast_points, forecast_details, '#059669') }} {{ forecast_circles(forecast_points, forecast_details, '#059669') }}
<line x1="{{ ((forecast_start_position * vb_width) + margin) | int }}" y1="{{ margin }}" x2="{{ ((forecast_start_position * vb_width) + margin) | int }}" y2="{{ vb_height + margin }}" stroke="#059669" stroke-width="0.8" stroke-dasharray="2,2" stroke-opacity="0.5"></line> <line x1="{{ ((forecast_start_position * vb_width) + margin) | int }}" y1="{{ margin }}" x2="{{ ((forecast_start_position * vb_width) + margin) | int }}" y2="{{ vb_height + margin }}" stroke="#059669" stroke-width="0.8" stroke-dasharray="2,2" stroke-opacity="0.5"></line>
{% endif %} {% endif %}
{% for plot in plots %} {% for plot in plots %}
<g class="{{ plot.label }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};"> <g class="{{ plot.label }} {% if forecast.available and plot.label != 'E1RM' %}hidden{% endif %}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
{{ plot_line(plot.points, plot.color) }} {{ plot_line(plot.points, plot.color) }}
</g> </g>
{% endfor %} {% endfor %}
<g style="fill-opacity: 0%"> <line class="forecast-cursor" x1="{{ default_detail.position * vb_width + margin }}" x2="{{ default_detail.position * vb_width + margin }}" y1="0" y2="{{ vb_height + margin }}" stroke="#64748b" stroke-width="0.5" stroke-dasharray="2,2" pointer-events="none"/>
{% for detail in plot_details %} <g fill="transparent">
{% set x = (detail.position * vb_width) - (stroke_width/2) + margin %} {% for detail in hover_details %}
{% set y = 0 %} <rect x="{{ detail.hit_start * vb_width + margin }}" y="0"
{% set width = stroke_width %} width="{{ (detail.hit_end - detail.hit_start) * vb_width }}" height="{{ vb_height + margin }}"
{% set height = vb_height + margin %} class="forecast-hit"
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}" data-position="{{ detail.position * vb_width + margin }}"
class="pnt-{{ unique_id }}" data-kind="{{ detail.kind }}" data-date="{{ detail.date }}"
data-kind="{{ detail.kind }}" data-e1rm="{{ detail.e1rm }}" data-topset="{{ detail.top_set }}" data-range="{{ detail.range }}">
data-date="{{ detail.date }}" <title>{{ detail.date }} · {{ detail.top_set }} · {{ detail.e1rm }} E1RM</title>
data-e1rm="{{ detail.e1rm }}" </rect>
data-topset="{{ detail.top_set }}"
data-range="{{ detail.range }}"></rect>
{% endfor %} {% endfor %}
{% if forecast_enabled and forecast.available %}
{% for detail in forecast_details %}
{% set x = (detail.position * vb_width) - (stroke_width/2) + margin %}
<rect x="{{ x | int }}" y="0" width="{{ stroke_width | int }}" height="{{ (vb_height + margin) | int }}"
class="pnt-{{ unique_id }}"
data-kind="{{ detail.kind }}"
data-date="{{ detail.date }}"
data-e1rm="{{ detail.e1rm }}"
data-topset="{{ detail.top_set }}"
data-range="{{ detail.range }}"></rect>
{% endfor %}
{% endif %}
</g> </g>
{% if not forecast_enabled %}
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" <path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" stroke-opacity="60%" pointer-events="none"/>
stroke-opacity="60%" /> {% endif %}
</svg> </svg>
<div id="popover-{{ unique_id }}" class="hidden mt-2 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm"> </div>
<div class="flex items-center justify-between gap-3 mb-1.5"> <div class="forecast-axis"><span>{{ min_date.strftime('%d %b %Y') }}</span><span>{{ (forecast.end_date if forecast.available else max_date).strftime('%d %b %Y') }}</span></div>
<div id="popover-kind-{{ unique_id }}" class="text-xs font-semibold uppercase tracking-wide text-emerald-700"></div> <div class="forecast-readout" aria-label="Selected chart reading">
<div class="text-right"> <div class="forecast-readout-heading"><span data-readout="kind">{{ default_detail.kind }}</span><time data-readout="date">{{ default_detail.date }}</time></div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Date</div> <div class="forecast-readout-values">
<div id="popover-date-{{ unique_id }}" class="text-sm font-semibold text-gray-800"></div> <div><span class="forecast-caption">Top set</span><strong data-readout="topset">{{ default_detail.top_set }}</strong></div>
</div> <div><span class="forecast-caption">Est. 1RM</span><strong data-readout="e1rm">{{ default_detail.e1rm }}</strong></div>
</div> <div><span class="forecast-caption">E1RM scenario range</span><strong data-readout="range">{{ default_detail.range }}</strong></div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="rounded bg-gray-50 px-2 py-1.5">
<div class="text-gray-400">E1RM</div>
<div id="popover-e1rm-{{ unique_id }}" class="font-semibold text-gray-800"></div>
</div>
<div class="rounded bg-gray-50 px-2 py-1.5">
<div class="text-gray-400">Top set</div>
<div id="popover-topset-{{ unique_id }}" class="font-semibold text-gray-800"></div>
</div>
<div class="rounded bg-emerald-50 px-2 py-1.5">
<div class="text-emerald-600">Range</div>
<div id="popover-range-{{ unique_id }}" class="font-semibold text-emerald-800"></div>
</div>
</div> </div>
</div> </div>
<div class="flex justify-center pt-2"> <p class="forecast-chart-hint">Hover or tap to inspect · focus chart and use ← → for dates{% if forecast.available %} · shaded area: plausible scenarios{% endif %}</p>
<div class="flex flex-wrap justify-center pt-2">
{% for plot in plots %} {% for plot in plots %}
<div class="flex items-center px-2 select-none cursor-pointer" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line <div class="flex items-center px-2 select-none cursor-pointer {% if forecast.available and plot.label != 'E1RM' %}line-through{% endif %}" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
on click toggle .hidden on my.plot_line then toggle .line-through on me"> on click toggle .hidden on my.plot_line then toggle .line-through on me">
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div> <div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
<div class="text-xs">{{ plot.label }}</div> <div class="text-xs">{{ plot.label }}</div>
+118
View File
@@ -0,0 +1,118 @@
import unittest
from datetime import date, timedelta
import numpy as np
from forecasting import (TransitionModel, build_forecast, e1rm, profile,
sessions, training_blocks)
from utils import get_exercise_graph_model, _format_kg
def rows(pairs, start=date(2026, 1, 1), interval=7):
return tuple((start + timedelta(days=i * interval), w, r) for i, (w,r) in enumerate(pairs))
def forecast(data, weeks=8):
return build_forecast([], [r[0] for r in data], weeks,
weights=[r[1] for r in data], repetitions=[r[2] for r in data])
class ForecastTests(unittest.TestCase):
def setUp(self):
self.history = rows([(70,7),(70,8),(70,9),(70.5,7),(70.5,8),(70.5,9),
(71,5),(71,6),(71,6),(71,6),(71,6),(71,7)])
def test_fractional_loads_round_trip(self):
self.assertAlmostEqual(float(e1rm(93.5,7)),113.1942,places=3)
f=forecast(self.history)
for point in f['points']:
self.assertEqual(point['value'],round(float(e1rm(point['projected_weight'], point['projected_repetitions'])),1))
self.assertEqual(_format_kg(96.75),'96.75')
def test_stalls_do_not_force_weekly_load_increases(self):
m=TransitionModel(self.history)
self.assertEqual(m.profile['block_stalls'],3)
self.assertEqual(m.profile['ceiling'],9)
self.assertEqual(m.next_set()[0][0],(71,7))
w,r,p=m.outcomes([71],[7],[0])
self.assertTrue(np.all(p[w>71]==0))
def test_loading_at_ceiling_uses_recorded_step(self):
m=TransitionModel(self.history)
w,r,p=m.outcomes([71],[9],[0])
self.assertTrue(np.any((w>71)&(p>0)))
self.assertLessEqual(float(w.max()),71.5)
def test_plateau_does_not_invent_gains(self):
f=forecast(rows([(50,8)]*16),52)
self.assertTrue(all(p['projected_weight']==50 and p['projected_repetitions']==8 for p in f['points']))
def test_months_off_excludes_old_performance(self):
older=rows([(150,10)]*12,start=date(2024,1,1))
fresh=rows([(50,6),(50,7),(50,7),(50,8)], start=date(2026,1,1))
f=forecast(older+fresh)
expected=forecast(fresh)
self.assertEqual(f['points'],expected['points'])
self.assertEqual(f['training_blocks'],2)
self.assertEqual(f['observed_points'],4)
self.assertFalse(forecast(older+fresh[:3])['available'])
def test_block_detection_is_prefix_invariant(self):
data=self.history+rows([(60,8)]*10,start=date(2027,1,1))
full=training_blocks(data)
for cutoff in range(1,len(data)+1):
trimmed=tuple(tuple(r for r in b if r[0]<=data[cutoff-1][0]) for b in full if b[0][0]<=data[cutoff-1][0])
self.assertEqual(training_blocks(data[:cutoff]),trimmed)
def test_duplicate_date_uses_one_coherent_best_set(self):
extra=(self.history[-1][0],65,5)
data=self.history+(self.history[-1],extra)
self.assertEqual(forecast(data)['points'],forecast(self.history)['points'])
def test_nonfinite_and_unsupported_data(self):
invalid=rows([(float('nan'),8),(30,40),(0,6),(float('inf'),6)])
self.assertFalse(forecast(invalid)['available'])
def test_observed_anchor_and_consistent_horizons(self):
small,large=forecast(self.history,4),forecast(self.history,52)
self.assertEqual(small['points'][0]['projected_weight'],71)
self.assertEqual(small['points'][0]['projected_repetitions'],7)
self.assertEqual(small['next_session'],large['next_session'])
for a,b in zip(small['session_points'],large['session_points']):
self.assertEqual(a,b)
def test_interval_order_and_cadence(self):
data=rows([(50,6),(50,7),(50,7),(50,8),(50,8),(50,9)],interval=14)
f=forecast(data,8)
self.assertEqual(f['profile']['cadence_days'],14)
self.assertEqual(len(f['session_points']),4)
self.assertEqual(f['next_session']['date'],data[-1][0]+timedelta(days=14))
for point in f['points']:
self.assertLessEqual(point['lower'],point['value'])
self.assertLessEqual(point['value'],point['upper'])
def test_cached_forecast_is_not_mutated_by_renderer(self):
f=forecast(self.history)
f['points'][0]['value']=-1
self.assertGreater(forecast(self.history)['points'][0]['value'],0)
def test_display_window_does_not_change_training_or_seed_reps(self):
def graph(visible, enabled=True):
return get_exercise_graph_model('Test', [round(float(e1rm(w,r)),1) for _,w,r in visible],
[r for _,_,r in visible], [w for _,w,_ in visible], [d for d,_,_ in visible], ['']*len(visible),
'All',1,1,forecast_enabled=enabled,forecast_training_rows=[dict(start_date=d,weight=w,repetitions=r) for d,w,r in self.history])
full,short=graph(self.history),graph(self.history[-3:])
# Equivalent rep tables intentionally reflect the visible rep range.
for a,b in zip(full['forecast']['points'],short['forecast']['points']):
self.assertEqual({k:v for k,v in a.items() if k!='top_sets'},
{k:v for k,v in b.items() if k!='top_sets'})
self.assertEqual(full['forecast']['target_weight_increment'],short['forecast']['target_weight_increment'])
self.assertIsNone(graph(self.history,False)['forecast_target_reps'])
hits=full['hover_details']
for a,b in zip(hits,hits[1:]):
self.assertEqual(a['hit_end'],b['hit_start'])
self.assertLess(a['hit_start'],a['hit_end'])
if __name__=='__main__':
unittest.main()
+37 -138
View File
@@ -1,7 +1,8 @@
import colorsys import colorsys
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from flask import request from flask import request
import numpy as np import numpy as np
from forecasting import build_forecast
import math import math
@@ -16,7 +17,7 @@ def _format_kg(value):
numeric_value = float(value) numeric_value = float(value)
if numeric_value.is_integer(): if numeric_value.is_integer():
return str(int(numeric_value)) return str(int(numeric_value))
return f"{numeric_value:.1f}".rstrip('0').rstrip('.') return f"{numeric_value:.2f}".rstrip('0').rstrip('.')
def convert_str_to_date(date_str, format='%Y-%m-%d'): def convert_str_to_date(date_str, format='%Y-%m-%d'):
try: try:
@@ -26,122 +27,6 @@ def convert_str_to_date(date_str, format='%Y-%m-%d'):
except TypeError: except TypeError:
return None return None
def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'):
"""Build a conservative linear forecast from dated exercise observations."""
try:
horizon_weeks = max(4, min(int(horizon_weeks), 52))
except (TypeError, ValueError):
horizon_weeks = 8
observations = []
for value, point_date in zip(values, dates):
if value is None or point_date is None:
continue
try:
numeric_value = float(value)
except (TypeError, ValueError):
continue
if isinstance(point_date, datetime):
point_date = point_date.date()
observations.append((point_date, numeric_value))
# Multiple sets on one date should not make the forecast look more certain.
# Use the best observed value for that date, which matches the E1RM use case.
best_by_date = {}
for point_date, numeric_value in observations:
best_by_date[point_date] = max(best_by_date.get(point_date, numeric_value), numeric_value)
observations = sorted(best_by_date.items())
if len(observations) < 4:
return {
'available': False,
'reason': 'Add at least 4 sessions to generate a forecast.',
'metric': metric,
'horizon_weeks': horizon_weeks,
'observed_points': len(observations)
}
observed_dates = [point_date for point_date, _ in observations]
observed_values = np.array([value for _, value in observations], dtype=float)
first_date = observed_dates[0]
last_date = observed_dates[-1]
x = np.array([(point_date - first_date).days for point_date in observed_dates], dtype=float)
if len(np.unique(x)) < 3 or x[-1] <= 0:
return {
'available': False,
'reason': 'Use at least 3 different workout dates to generate a forecast.',
'metric': metric,
'horizon_weeks': horizon_weeks,
'observed_points': len(observations)
}
try:
slope, intercept = np.polyfit(x, observed_values, 1)
except (np.linalg.LinAlgError, ValueError, TypeError):
return {
'available': False,
'reason': 'There is not enough consistent data to generate a forecast.',
'metric': metric,
'horizon_weeks': horizon_weeks,
'observed_points': len(observations)
}
fitted = intercept + slope * x
residuals = observed_values - fitted
degrees_of_freedom = max(len(observed_values) - 2, 1)
residual_std = float(np.sqrt(np.sum(residuals ** 2) / degrees_of_freedom))
mean_x = float(np.mean(x))
sum_squared_x = float(np.sum((x - mean_x) ** 2)) or 1.0
horizon_days = horizon_weeks * 7
future_x = np.arange(0, horizon_days + 1, 7, dtype=float) + x[-1]
future_dates = [last_date + timedelta(days=int(day - x[-1])) for day in future_x]
# Anchor the projection to the most recent observed value. A pure
# regression line can sit noticeably above or below the latest workout,
# which makes the forecast appear to jump when it starts.
future_values = observed_values[-1] + slope * (future_x - x[-1])
# A restrained 90% prediction band, widened for points further from the
# observations so the uncertainty remains readable on the small chart.
prediction_error = 1.645 * residual_std * np.sqrt(
1 + (1 / len(observed_values)) + ((future_x - mean_x) ** 2 / sum_squared_x)
)
lower_values = np.maximum(future_values - prediction_error, 0)
upper_values = future_values + prediction_error
total_variation = float(np.sum((observed_values - np.mean(observed_values)) ** 2))
residual_variation = float(np.sum(residuals ** 2))
r_squared = None if total_variation == 0 else max(0.0, 1 - residual_variation / total_variation)
weekly_change = float(slope * 7)
confidence = 'High' if len(observed_values) >= 8 and (r_squared is None or r_squared >= 0.6) else 'Moderate'
if len(observed_values) < 6 or (r_squared is not None and r_squared < 0.25):
confidence = 'Low'
points = []
for point_date, predicted, lower, upper in zip(future_dates, future_values, lower_values, upper_values):
points.append({
'date': point_date,
'value': round(float(predicted), 1),
'lower': round(float(lower), 1),
'upper': round(float(upper), 1)
})
return {
'available': True,
'metric': metric,
'horizon_weeks': horizon_weeks,
'observed_points': len(observations),
'last_date': last_date,
'last_value': round(float(observed_values[-1]), 1),
'projected_value': round(float(future_values[-1]), 1),
'projected_change': round(float(future_values[-1] - observed_values[-1]), 1),
'weekly_change': round(weekly_change, 1),
'r_squared': None if r_squared is None else round(r_squared, 2),
'confidence': confidence,
'end_date': future_dates[-1],
'points': points
}
def infer_weight_increment(weights): def infer_weight_increment(weights):
"""Infer a sensible display increment from the exercise's recorded weights.""" """Infer a sensible display increment from the exercise's recorded weights."""
numeric_weights = sorted({round(float(weight), 4) for weight in weights if weight is not None}) numeric_weights = sorted({round(float(weight), 4) for weight in weights if weight is not None})
@@ -180,7 +65,7 @@ def infer_rep_targets(repetitions, buffer=5):
observed_min = min(numeric_reps) observed_min = min(numeric_reps)
observed_max = max(numeric_reps) observed_max = max(numeric_reps)
target_min = max(1, observed_min - buffer) target_min = min(20, max(1, observed_min - buffer))
target_max = min(20, observed_max + buffer) target_max = min(20, observed_max + buffer)
target_reps = list(range(target_min, target_max + 1)) target_reps = list(range(target_min, target_max + 1))
# Default to the latest recorded rep target so the first forecast comparison # Default to the latest recorded rep target so the first forecast comparison
@@ -225,7 +110,7 @@ def add_top_set_forecast(forecast, historical_weights, historical_repetitions, t
except (TypeError, ValueError): except (TypeError, ValueError):
selected_reps = centered_reps selected_reps = centered_reps
selected_reps = max(target_reps_list[0], min(target_reps_list[-1], selected_reps)) selected_reps = max(target_reps_list[0], min(target_reps_list[-1], selected_reps))
increment = infer_weight_increment(historical_weights) increment = forecast.get('profile', {}).get('increment') or infer_weight_increment(historical_weights)
for point in forecast['points']: for point in forecast['points']:
point['top_sets'] = _top_set_estimates( point['top_sets'] = _top_set_estimates(
point['value'], point['lower'], point['upper'], target_reps_list, increment point['value'], point['lower'], point['upper'], target_reps_list, increment
@@ -246,11 +131,17 @@ def add_top_set_forecast(forecast, historical_weights, historical_repetitions, t
return forecast return forecast
def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_dates, messages, epoch, person_id, exercise_id, min_date=None, max_date=None, degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=None): def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_dates, messages, epoch, person_id, exercise_id, min_date=None, max_date=None, degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=None, forecast_training_rows=None):
# Precompute ranges. Forecast values are included so the projected line and # Precompute ranges. Forecast values are included so the projected line and
# confidence band have room inside the same SVG coordinate system. # confidence band have room inside the same SVG coordinate system.
observed_min_date, observed_max_date = min(start_dates), max(start_dates) observed_min_date, observed_max_date = min(start_dates), max(start_dates)
forecast = build_forecast(estimated_1rm, start_dates, forecast_weeks, metric='E1RM') if forecast_enabled else {'available': False} if forecast_training_rows is None:
forecast_training_rows = [dict(start_date=d, weight=w, repetitions=r) for d, w, r in zip(start_dates, weight, repetitions)]
forecast = build_forecast(
[], [r['start_date'] for r in forecast_training_rows], forecast_weeks,
weights=[r['weight'] for r in forecast_training_rows],
repetitions=[r['repetitions'] for r in forecast_training_rows]
) if forecast_enabled else {'available': False}
forecast = add_top_set_forecast(forecast, weight, repetitions, forecast_target_reps) forecast = add_top_set_forecast(forecast, weight, repetitions, forecast_target_reps)
forecast_horizons = [(4, '4w'), (8, '8w'), (12, '3m'), (26, '6m'), (52, '1y')] forecast_horizons = [(4, '4w'), (8, '8w'), (12, '3m'), (26, '6m'), (52, '1y')]
chart_max_date = forecast.get('end_date', observed_max_date) if forecast.get('available') else observed_max_date chart_max_date = forecast.get('end_date', observed_max_date) if forecast.get('available') else observed_max_date
@@ -356,33 +247,28 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
forecast_points.append((round(((point['value'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_points.append((round(((point['value'] - min_e1rm) / e1rm_range) * vb_height, 1), position))
forecast_band_lower.append((round(((point['lower'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_band_lower.append((round(((point['lower'] - min_e1rm) / e1rm_range) * vb_height, 1), position))
forecast_band_upper.append((round(((point['upper'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_band_upper.append((round(((point['upper'] - min_e1rm) / e1rm_range) * vb_height, 1), position))
selected_top_set = next( selected_top_set = point
(top_set for top_set in point['top_sets'] if top_set['repetitions'] == forecast['selected_reps']),
None
)
if point_index == 0: if point_index == 0:
top_set_message = f" · Recorded {_format_kg(weight[-1])}kg × {int(repetitions[-1])}" top_set_message = f" · Recorded {point['set_display']}"
forecast_details.append({ forecast_details.append({
'position': position, 'position': position,
'date': point['date'].strftime('%d %b %y'), 'date': point['date'].strftime('%d %b %y'),
'kind': 'Current reading', 'kind': 'Current reading',
'e1rm': f"{_format_kg(forecast['last_value'])}kg", 'e1rm': f"{_format_kg(forecast['last_value'])}kg",
'top_set': f"{_format_kg(weight[-1])}kg × {int(repetitions[-1])} reps", 'top_set': point['set_display'],
'range': 'Observed value' 'range': 'Observed value'
}) })
elif selected_top_set: elif selected_top_set:
top_set_message = ( top_set_message = (
f" · Est. {selected_top_set['weight_display']}kg × {selected_top_set['repetitions']} " f" · Scenario {point['set_display']}"
f"(range {selected_top_set['lower_weight_display']}–{selected_top_set['upper_weight_display']}kg)"
) )
forecast_details.append({ forecast_details.append({
'position': position, 'position': position,
'date': point['date'].strftime('%d %b %y'), 'date': point['date'].strftime('%d %b %y'),
'kind': 'Projected top set', 'kind': 'Workout scenario' if point['date'] < forecast['end_date'] else 'Horizon scenario',
'e1rm': f"{_format_kg(point['value'])}kg", 'e1rm': f"{_format_kg(point['value'])}kg",
'top_set': f"{selected_top_set['weight_display']}kg × {selected_top_set['repetitions']} reps", 'top_set': point['set_display'],
'range': f"{selected_top_set['lower_weight_display']}–{selected_top_set['upper_weight_display']}kg", 'range': f"{_format_kg(point['lower'])}–{_format_kg(point['upper'])}kg E1RM",
'confidence': forecast['confidence']
}) })
forecast_labels.append(( forecast_labels.append((
position, position,
@@ -390,11 +276,22 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
f"(range {point['lower']}–{point['upper']}kg){top_set_message}" f"(range {point['lower']}–{point['upper']}kg){top_set_message}"
)) ))
# Return exercise data with SVG dimensions and data points # Non-overlapping nearest-date targets; no late forecast rectangle can
# cover an earlier date when a year is compressed into a small chart.
hover_by_position = {}
for detail in plot_details + forecast_details:
hover_by_position[detail['position']] = detail
hover_details = sorted(hover_by_position.values(), key=lambda d: d['position'])
for i, detail in enumerate(hover_details):
detail['hit_start'] = 0 if i == 0 else (hover_details[i-1]['position'] + detail['position']) / 2
detail['hit_end'] = 1 if i == len(hover_details)-1 else (hover_details[i+1]['position'] + detail['position']) / 2
# Return exercise data with SVG dimensions and data points
return { return {
'title': title, 'title': title,
'vb_width': vb_width, 'vb_width': vb_width,
'vb_height': vb_height, 'vb_height': vb_height,
'e1rm_scale': [_format_kg(round(max_e1rm, 1)), _format_kg(round((max_e1rm + min_e1rm) / 2, 1)), _format_kg(round(min_e1rm, 1))],
'plots': [repetitions_data, weight_data, estimated_1rm_data], 'plots': [repetitions_data, weight_data, estimated_1rm_data],
'best_fit_points': best_fit_points, 'best_fit_points': best_fit_points,
'best_fit_formula': best_fit_formula, 'best_fit_formula': best_fit_formula,
@@ -410,14 +307,16 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
'forecast': forecast, 'forecast': forecast,
'forecast_enabled': forecast_enabled, 'forecast_enabled': forecast_enabled,
'forecast_weeks': forecast_weeks, 'forecast_weeks': forecast_weeks,
'forecast_target_reps': forecast.get('selected_reps', forecast_target_reps or 5), 'forecast_target_reps': forecast_target_reps,
'forecast_horizons': forecast_horizons, 'forecast_horizons': forecast_horizons,
'forecast_points': forecast_points, 'forecast_points': forecast_points,
'forecast_band_lower': forecast_band_lower, 'forecast_band_lower': forecast_band_lower,
'forecast_band_upper': forecast_band_upper, 'forecast_band_upper': forecast_band_upper,
'forecast_labels': forecast_labels, 'forecast_labels': forecast_labels,
'forecast_details': forecast_details, 'forecast_details': forecast_details,
'forecast_start_position': forecast_start_position 'forecast_start_position': forecast_start_position,
'hover_details': hover_details,
'default_detail': forecast_details[0] if forecast_details else plot_details[-1],
} }
def get_distinct_colors(n): def get_distinct_colors(n):