Learn session forecasts from training blocks and clarify progress UI

This commit is contained in:
Peter Stockings
2026-09-06 16:20:15 +10:00
parent 499d859478
commit 86fade54f1
13 changed files with 1008 additions and 343 deletions
+118
View File
@@ -0,0 +1,118 @@
import unittest
from datetime import date, timedelta
import numpy as np
from forecasting import (TransitionModel, build_forecast, e1rm, profile,
sessions, training_blocks)
from utils import get_exercise_graph_model, _format_kg
def rows(pairs, start=date(2026, 1, 1), interval=7):
return tuple((start + timedelta(days=i * interval), w, r) for i, (w,r) in enumerate(pairs))
def forecast(data, weeks=8):
return build_forecast([], [r[0] for r in data], weeks,
weights=[r[1] for r in data], repetitions=[r[2] for r in data])
class ForecastTests(unittest.TestCase):
def setUp(self):
self.history = rows([(70,7),(70,8),(70,9),(70.5,7),(70.5,8),(70.5,9),
(71,5),(71,6),(71,6),(71,6),(71,6),(71,7)])
def test_fractional_loads_round_trip(self):
self.assertAlmostEqual(float(e1rm(93.5,7)),113.1942,places=3)
f=forecast(self.history)
for point in f['points']:
self.assertEqual(point['value'],round(float(e1rm(point['projected_weight'], point['projected_repetitions'])),1))
self.assertEqual(_format_kg(96.75),'96.75')
def test_stalls_do_not_force_weekly_load_increases(self):
m=TransitionModel(self.history)
self.assertEqual(m.profile['block_stalls'],3)
self.assertEqual(m.profile['ceiling'],9)
self.assertEqual(m.next_set()[0][0],(71,7))
w,r,p=m.outcomes([71],[7],[0])
self.assertTrue(np.all(p[w>71]==0))
def test_loading_at_ceiling_uses_recorded_step(self):
m=TransitionModel(self.history)
w,r,p=m.outcomes([71],[9],[0])
self.assertTrue(np.any((w>71)&(p>0)))
self.assertLessEqual(float(w.max()),71.5)
def test_plateau_does_not_invent_gains(self):
f=forecast(rows([(50,8)]*16),52)
self.assertTrue(all(p['projected_weight']==50 and p['projected_repetitions']==8 for p in f['points']))
def test_months_off_excludes_old_performance(self):
older=rows([(150,10)]*12,start=date(2024,1,1))
fresh=rows([(50,6),(50,7),(50,7),(50,8)], start=date(2026,1,1))
f=forecast(older+fresh)
expected=forecast(fresh)
self.assertEqual(f['points'],expected['points'])
self.assertEqual(f['training_blocks'],2)
self.assertEqual(f['observed_points'],4)
self.assertFalse(forecast(older+fresh[:3])['available'])
def test_block_detection_is_prefix_invariant(self):
data=self.history+rows([(60,8)]*10,start=date(2027,1,1))
full=training_blocks(data)
for cutoff in range(1,len(data)+1):
trimmed=tuple(tuple(r for r in b if r[0]<=data[cutoff-1][0]) for b in full if b[0][0]<=data[cutoff-1][0])
self.assertEqual(training_blocks(data[:cutoff]),trimmed)
def test_duplicate_date_uses_one_coherent_best_set(self):
extra=(self.history[-1][0],65,5)
data=self.history+(self.history[-1],extra)
self.assertEqual(forecast(data)['points'],forecast(self.history)['points'])
def test_nonfinite_and_unsupported_data(self):
invalid=rows([(float('nan'),8),(30,40),(0,6),(float('inf'),6)])
self.assertFalse(forecast(invalid)['available'])
def test_observed_anchor_and_consistent_horizons(self):
small,large=forecast(self.history,4),forecast(self.history,52)
self.assertEqual(small['points'][0]['projected_weight'],71)
self.assertEqual(small['points'][0]['projected_repetitions'],7)
self.assertEqual(small['next_session'],large['next_session'])
for a,b in zip(small['session_points'],large['session_points']):
self.assertEqual(a,b)
def test_interval_order_and_cadence(self):
data=rows([(50,6),(50,7),(50,7),(50,8),(50,8),(50,9)],interval=14)
f=forecast(data,8)
self.assertEqual(f['profile']['cadence_days'],14)
self.assertEqual(len(f['session_points']),4)
self.assertEqual(f['next_session']['date'],data[-1][0]+timedelta(days=14))
for point in f['points']:
self.assertLessEqual(point['lower'],point['value'])
self.assertLessEqual(point['value'],point['upper'])
def test_cached_forecast_is_not_mutated_by_renderer(self):
f=forecast(self.history)
f['points'][0]['value']=-1
self.assertGreater(forecast(self.history)['points'][0]['value'],0)
def test_display_window_does_not_change_training_or_seed_reps(self):
def graph(visible, enabled=True):
return get_exercise_graph_model('Test', [round(float(e1rm(w,r)),1) for _,w,r in visible],
[r for _,_,r in visible], [w for _,w,_ in visible], [d for d,_,_ in visible], ['']*len(visible),
'All',1,1,forecast_enabled=enabled,forecast_training_rows=[dict(start_date=d,weight=w,repetitions=r) for d,w,r in self.history])
full,short=graph(self.history),graph(self.history[-3:])
# Equivalent rep tables intentionally reflect the visible rep range.
for a,b in zip(full['forecast']['points'],short['forecast']['points']):
self.assertEqual({k:v for k,v in a.items() if k!='top_sets'},
{k:v for k,v in b.items() if k!='top_sets'})
self.assertEqual(full['forecast']['target_weight_increment'],short['forecast']['target_weight_increment'])
self.assertIsNone(graph(self.history,False)['forecast_target_reps'])
hits=full['hover_details']
for a,b in zip(hits,hits[1:]):
self.assertEqual(a['hit_end'],b['hit_start'])
self.assertLess(a['hit_start'],a['hit_end'])
if __name__=='__main__':
unittest.main()