Learn session forecasts from training blocks and clarify progress UI
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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}}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user