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