87 lines
4.1 KiB
Python
87 lines
4.1 KiB
Python
"""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))
|