From 499ecdc3c2162e9b80be2ec94485f46e3e0ef3d6 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 6 Sep 2026 14:12:48 +1000 Subject: [PATCH] Add projected top set estimates --- app.py | 5 +- db.py | 5 +- templates/partials/sparkline.html | 36 ++++++++-- utils.py | 105 ++++++++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index 9df0c1f..decec10 100644 --- a/app.py +++ b/app.py @@ -244,6 +244,8 @@ def get_exercise_progress_for_user(person_id, exercise_id): 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 = max(4, min(forecast_weeks, 26)) + forecast_target_reps = request.args.get('forecast_reps', type=int, default=5) + forecast_target_reps = max(1, min(forecast_target_reps, 12)) if epoch == 'Custom' and (min_date is None or max_date is None): (min_date, max_date) = db.get_exercise_earliest_and_latest_dates(person_id, exercise_id) @@ -256,7 +258,8 @@ def get_exercise_progress_for_user(person_id, exercise_id): epoch, degree=degree, forecast_enabled=forecast_enabled, - forecast_weeks=forecast_weeks + forecast_weeks=forecast_weeks, + forecast_target_reps=forecast_target_reps ) if not exercise_progress: diff --git a/db.py b/db.py index ac70a6e..26e988e 100644 --- a/db.py +++ b/db.py @@ -405,7 +405,7 @@ class DataBase(): def get_all_exercises(self): return self.exercises.get("") - def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1, forecast_enabled=False, forecast_weeks=8): + def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1, forecast_enabled=False, forecast_weeks=8, forecast_target_reps=5): today = datetime.now() if epoch == '1M': min_date = today - relativedelta(months=1) @@ -467,7 +467,8 @@ class DataBase(): max_date, degree, forecast_enabled, - forecast_weeks) + forecast_weeks, + forecast_target_reps) exercise_progress['latest_topset_id'] = latest_topset_id exercise_progress['latest_workout_id'] = latest_workout_id diff --git a/templates/partials/sparkline.html b/templates/partials/sparkline.html index 68e813d..e0ebe94 100644 --- a/templates/partials/sparkline.html +++ b/templates/partials/sparkline.html @@ -87,7 +87,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | + {% endfor %} + +
Rounded to the nearest {{ forecast.target_weight_increment }}kg based on your recorded loads.
+ + {% endif %}
Horizon {% for weeks in [4, 8, 12, 26] %} {% endfor %} @@ -169,7 +193,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | @@ -197,7 +221,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5" name="max_date" value="{{ max_date }}" - hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}" + hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id, forecast='true' if forecast_enabled else 'false', forecast_weeks=forecast_weeks, forecast_reps=forecast_target_reps) }}" hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='degree']" hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="change"> @@ -222,7 +246,7 @@ M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | diff --git a/utils.py b/utils.py index 8469c0c..aa3d728 100644 --- a/utils.py +++ b/utils.py @@ -4,8 +4,11 @@ from flask import request import numpy as np -import math -from decimal import Decimal +import math +from decimal import Decimal + +E1RM_BASE = 101.3 +E1RM_REP_FACTOR = 2.67123 def convert_str_to_date(date_str, format='%Y-%m-%d'): try: @@ -130,11 +133,90 @@ def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'): } -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): +def infer_weight_increment(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}) + differences = [ + round(current - previous, 4) + for previous, current in zip(numeric_weights, numeric_weights[1:]) + if current > previous + ] + if not differences: + return 2.5 + + common_increments = [0.5, 1.0, 1.25, 2.0, 2.5, 5.0, 10.0] + smallest_difference = min(differences) + return min(common_increments, key=lambda increment: abs(increment - smallest_difference)) + + +def _round_weight(value, increment, direction='nearest'): + if value is None: + return None + scaled = max(float(value), 0) / increment + if direction == 'floor': + rounded = math.floor(scaled) * increment + elif direction == 'ceil': + rounded = math.ceil(scaled) * increment + else: + rounded = round(scaled) * increment + return round(rounded, 2) + + +def _top_set_estimates(e1rm, lower_e1rm, upper_e1rm, target_reps, increment): + estimates = [] + for repetitions in target_reps: + conversion = (E1RM_BASE - E1RM_REP_FACTOR * repetitions) / 100 + if conversion <= 0: + continue + expected_weight = e1rm * conversion + low_weight = lower_e1rm * conversion + high_weight = upper_e1rm * conversion + estimates.append({ + 'repetitions': repetitions, + 'weight': _round_weight(expected_weight, increment), + 'lower_weight': _round_weight(low_weight, increment, 'floor'), + 'upper_weight': _round_weight(high_weight, increment, 'ceil') + }) + return estimates + + +def add_top_set_forecast(forecast, historical_weights, target_reps=5): + """Attach projected reps x weight combinations to each forecast point.""" + if not forecast.get('available'): + return forecast + + try: + selected_reps = max(1, min(int(target_reps), 12)) + except (TypeError, ValueError): + selected_reps = 5 + target_reps_list = [3, 5, 8, 10] + if selected_reps not in target_reps_list: + target_reps_list.append(selected_reps) + target_reps_list.sort() + + increment = infer_weight_increment(historical_weights) + for point in forecast['points']: + point['top_sets'] = _top_set_estimates( + point['value'], point['lower'], point['upper'], target_reps_list, increment + ) + + forecast['target_reps'] = target_reps_list + forecast['target_weight_increment'] = increment + forecast['top_sets'] = forecast['points'][-1]['top_sets'] + forecast['selected_reps'] = selected_reps + forecast['selected_top_set'] = next( + (top_set for top_set in forecast['top_sets'] if top_set['repetitions'] == selected_reps), + None + ) + 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=5): # Precompute ranges. Forecast values are included so the projected line and # confidence band have room inside the same SVG coordinate system. 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} + forecast = add_top_set_forecast(forecast, weight, forecast_target_reps) chart_max_date = forecast.get('end_date', observed_max_date) if forecast.get('available') else observed_max_date total_span = (chart_max_date - observed_min_date).days or 1 @@ -225,10 +307,20 @@ 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_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)) + selected_top_set = next( + (top_set for top_set in point['top_sets'] if top_set['repetitions'] == forecast['selected_reps']), + None + ) + top_set_message = '' + if selected_top_set: + top_set_message = ( + f" · Est. {selected_top_set['weight']}kg × {selected_top_set['repetitions']} " + f"(range {selected_top_set['lower_weight']}–{selected_top_set['upper_weight']}kg)" + ) forecast_labels.append(( position, f"{point['date'].strftime('%d %b %y')} · Projected E1RM: {point['value']}kg " - f"(range {point['lower']}–{point['upper']}kg)" + f"(range {point['lower']}–{point['upper']}kg){top_set_message}" )) # Return exercise data with SVG dimensions and data points @@ -250,6 +342,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da 'forecast': forecast, 'forecast_enabled': forecast_enabled, 'forecast_weeks': forecast_weeks, + 'forecast_target_reps': forecast_target_reps, 'forecast_points': forecast_points, 'forecast_band_lower': forecast_band_lower, 'forecast_band_upper': forecast_band_upper, @@ -271,11 +364,11 @@ def get_distinct_colors(n): -def calculate_estimated_1rm(weight, repetitions): +def calculate_estimated_1rm(weight, repetitions): # Ensure the inputs are numeric if repetitions == 0: # Avoid division by zero return 0 - estimated_1rm = round((100 * int(weight)) / (101.3 - 2.67123 * repetitions), 0) + estimated_1rm = round((100 * int(weight)) / (E1RM_BASE - E1RM_REP_FACTOR * repetitions), 0) return int(estimated_1rm)