diff --git a/app.py b/app.py index ff7896c..9df0c1f 100644 --- a/app.py +++ b/app.py @@ -241,11 +241,23 @@ def get_exercise_progress_for_user(person_id, exercise_id): 'max_date'), '%Y-%m-%d') epoch = request.args.get('epoch', default='All') degree = request.args.get('degree', type=int, default=1) + 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)) 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) - exercise_progress = db.get_exercise_progress_for_user(person_id, exercise_id, min_date, max_date, epoch, degree=degree) + exercise_progress = db.get_exercise_progress_for_user( + person_id, + exercise_id, + min_date, + max_date, + epoch, + degree=degree, + forecast_enabled=forecast_enabled, + forecast_weeks=forecast_weeks + ) if not exercise_progress: abort(404) diff --git a/db.py b/db.py index 5a95e3e..ac70a6e 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): + 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): today = datetime.now() if epoch == '1M': min_date = today - relativedelta(months=1) @@ -463,9 +463,11 @@ class DataBase(): epoch, person_id, exercise_id, - min_date, - max_date, - degree) + min_date, + max_date, + degree, + forecast_enabled, + forecast_weeks) 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 3c06245..6aab777 100644 --- a/templates/partials/sparkline.html +++ b/templates/partials/sparkline.html @@ -9,13 +9,20 @@ {% endfor %} {% endmacro %} -{% macro path_best_fit(best_fit_points, vb_height) %} +{% macro path_best_fit(best_fit_points, vb_height) %} {% for value, position in best_fit_points %} {% set x = (position * vb_width)+margin %} {% set y = (vb_height - value)+margin %} {% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %} {% endfor %} -{% endmacro %} +{% endmacro %} + +{% macro path_band(lower_points, upper_points, vb_height) %} +{% if lower_points and upper_points %} +M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int }} {{ (vb_height - value)+margin | int }}{% if not loop.last %} L{% endif %}{% endfor %} +{% for value, position in upper_points|reverse %} L{{ (position * vb_width)+margin | int }} {{ (vb_height - value)+margin | int }}{% endfor %} Z +{% endif %} +{% endmacro %} {% macro circles(data_points, color) %} {% for value, position in data_points %} @@ -46,14 +53,14 @@ -
+

{{ title }}

-
+
-
+ +
+
+ +

{% if best_fit_formula %} {{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month {% endif %}

-
+
{% for epoch in epochs %}
{% endfor %} -
- {% if selected_epoch == 'Custom' %} +
+ {% if forecast_enabled %} +
+ {% if forecast.available %} +
+
+
E1RM projection
+
{{ forecast.last_value }}kg now → {{ forecast.projected_value }}kg in {{ forecast.horizon_weeks }} weeks
+
+
+
{% if forecast.weekly_change >= 0 %}+{% endif %}{{ forecast.weekly_change }}kg/week
+
{{ forecast.confidence }} confidence · {{ forecast.observed_points }} sessions
+
+
+
+ Horizon + {% for weeks in [4, 8, 12, 26] %} + + {% endfor %} +
+ {% else %} +
Forecast unavailable
+
{{ forecast.reason }}
+ {% endif %} +
+ {% endif %} + {% if selected_epoch == 'Custom' %}
@@ -176,8 +222,14 @@
{% endif %} - - {% for plot in plots %} + + {% if forecast_enabled and forecast.available %} + + + {{ circles(forecast_points, '#059669') }} + + {% endif %} + {% for plot in plots %} {{ plot_line(plot.points, plot.color) }} @@ -198,13 +250,19 @@ stroke-opacity="60%" />
- {% for plot in plots %} + {% for plot in plots %}
{{ plot.label }}
- {% endfor %} -
+ {% endfor %} + {% if forecast_enabled and forecast.available %} +
+
+
Projected E1RM
+
+ {% endif %} +
-
\ No newline at end of file +
diff --git a/utils.py b/utils.py index fd45ccf..7dfef02 100644 --- a/utils.py +++ b/utils.py @@ -15,12 +15,131 @@ def convert_str_to_date(date_str, format='%Y-%m-%d'): except TypeError: return 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): - # Precompute ranges - min_date, max_date = min(start_dates), max(start_dates) - total_span = (max_date - min_date).days or 1 - - min_e1rm, max_e1rm = min(estimated_1rm), max(estimated_1rm) +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), 26)) + 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] + future_values = intercept + slope * future_x + + # A 95% prediction band, widened for points further from the observations. + prediction_error = 1.96 * 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), + '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 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): + # 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} + 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 + + e1rm_values_for_scale = list(estimated_1rm) + if forecast.get('available'): + e1rm_values_for_scale.extend([point['lower'] for point in forecast['points']]) + e1rm_values_for_scale.extend([point['upper'] for point in forecast['points']]) + + min_e1rm, max_e1rm = min(e1rm_values_for_scale), max(e1rm_values_for_scale) min_reps, max_reps = min(repetitions), max(repetitions) min_weight, max_weight = min(weight), max(weight) @@ -34,17 +153,22 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da vb_height *= 75 / vb_height # Scale to 75px height # Use NumPy arrays for efficient scaling - relative_positions = np.round(np.array([(date - min_date).days / total_span for date in start_dates]), 1) + relative_positions = np.round(np.array([(date - observed_min_date).days / total_span for date in start_dates]), 3) estimated_1rm_scaled = np.round(((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height, 1) repetitions_scaled = np.round(((np.array(repetitions) - min_reps) / reps_range) * vb_height, 1) weight_scaled = np.round(((np.array(weight) - min_weight) / weight_range) * vb_height, 1) # Calculate slope and line of best fit slope_kg_per_day = e1rm_range / total_span - best_fit_formula = { - 'kg_per_week': round(slope_kg_per_day * 7, 1), - 'kg_per_month': round(slope_kg_per_day * 30, 1) - } + best_fit_formula = { + 'kg_per_week': round(slope_kg_per_day * 7, 1), + 'kg_per_month': round(slope_kg_per_day * 30, 1) + } + if forecast.get('available'): + best_fit_formula = { + 'kg_per_week': forecast['weekly_change'], + 'kg_per_month': round(forecast['weekly_change'] * 4.345, 1) + } best_fit_points = [] try: @@ -83,7 +207,19 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da } # Prepare plot labels - plot_labels = list(zip(relative_positions.tolist(), messages)) + plot_labels = list(zip(relative_positions.tolist(), messages)) + + forecast_points = [] + forecast_band_lower = [] + forecast_band_upper = [] + forecast_start_position = None + if forecast.get('available'): + forecast_start_position = round((observed_max_date - observed_min_date).days / total_span, 3) + for point in forecast['points']: + position = round((point['date'] - observed_min_date).days / total_span, 3) + 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)) # Return exercise data with SVG dimensions and data points return { @@ -98,10 +234,17 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da 'selected_epoch': epoch, 'person_id': person_id, 'exercise_id': exercise_id, - 'min_date': min_date, - 'max_date': max_date, - 'degree': degree - } + 'min_date': observed_min_date, + 'max_date': observed_max_date, + 'degree': degree, + 'forecast': forecast, + 'forecast_enabled': forecast_enabled, + 'forecast_weeks': forecast_weeks, + 'forecast_points': forecast_points, + 'forecast_band_lower': forecast_band_lower, + 'forecast_band_upper': forecast_band_upper, + 'forecast_start_position': forecast_start_position + } def get_distinct_colors(n): colors = []