kaiserlift 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kaiserlift-0.1.0/LICENSE +21 -0
- kaiserlift-0.1.0/PKG-INFO +11 -0
- kaiserlift-0.1.0/README.md +36 -0
- kaiserlift-0.1.0/kaiserlift/__init__.py +19 -0
- kaiserlift-0.1.0/kaiserlift/helpers.py +226 -0
- kaiserlift-0.1.0/kaiserlift/main.py +224 -0
- kaiserlift-0.1.0/kaiserlift.egg-info/PKG-INFO +11 -0
- kaiserlift-0.1.0/kaiserlift.egg-info/SOURCES.txt +13 -0
- kaiserlift-0.1.0/kaiserlift.egg-info/dependency_links.txt +1 -0
- kaiserlift-0.1.0/kaiserlift.egg-info/entry_points.txt +2 -0
- kaiserlift-0.1.0/kaiserlift.egg-info/top_level.txt +1 -0
- kaiserlift-0.1.0/pyproject.toml +3 -0
- kaiserlift-0.1.0/setup.cfg +4 -0
- kaiserlift-0.1.0/setup.py +16 -0
- kaiserlift-0.1.0/tests/tests.py +179 -0
kaiserlift-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Douglas Kaiser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kaiserlift
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Data-driven progressive overload
|
|
5
|
+
Author: Douglas Kaiser
|
|
6
|
+
Author-email: douglastkaiser@gmail.com
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Dynamic: author
|
|
9
|
+
Dynamic: author-email
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# [kaiserlift](https://www.douglastkaiser.com/projects/#workoutPlanner)
|
|
2
|
+
A smarter way to choose your next workout: data-driven progressive overload
|
|
3
|
+
|
|
4
|
+
## Why keep doing 10 rep sets? Are you pushing in a smart way?
|
|
5
|
+
|
|
6
|
+
The core idea I’m exploring is simple: I want a science-based system for determining what’s the best workout to do next if your goal is muscle growth. The foundation of this is progressive overload—the principle that muscles grow when you consistently challenge them beyond what they’re used to.
|
|
7
|
+
|
|
8
|
+
One of the most effective ways to apply progressive overload is by taking sets to failure. But doing that intelligently requires knowing what you’ve done before. If you have a record of your best sets on a given exercise, you can deliberately aim to push past those PRs. That history becomes your benchmark.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
Now, there’s another dimension to this: rep range adaptation. If you always train for, say, 10 reps, your muscles can get used to that rep range—even if you’re still pushing for PRs. Switching things up and going for, say, 20-rep maxes (even with lighter weight) can stimulate growth by forcing the muscle to adapt to new challenges. Then, when you go back to 10 reps, you might find you’ve blown through a plateau.
|
|
13
|
+
|
|
14
|
+
To make this system more precise, I propose using a one-rep max (1RM) equivalence formula—a way of mapping rep and weight combinations onto a single curve. It gives you a way to compare different PRs across rep ranges. Using that, you can identify which rep range you’re weakest in—meaning, which PR has the lowest 1RM equivalent. That’s where your next opportunity lies.
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
Here's how I operationalize it:
|
|
19
|
+
|
|
20
|
+
1. Collect your full workout history for a given exercise—every weight and rep combo you’ve done.
|
|
21
|
+
2. Calculate the Pareto front of that data. This is the set of “non-dominated” performances: the heaviest weights at each rep range that can’t be beaten in both weight and reps at the same time.
|
|
22
|
+
3. For each point on the Pareto front, compute the 1RM equivalent using a decay-style formula.
|
|
23
|
+
4. Identify the Pareto front point with the lowest 1RM equivalent—that’s your weakest spot.
|
|
24
|
+
5. Now, generate a “next step” PR target: a new set that just barely beats that weakest point, by the smallest reasonable margin (e.g., +1 rep or +5 lbs). That becomes your next workout goal.
|
|
25
|
+
|
|
26
|
+
This method gives you a structured, data-driven way to chase the easiest possible PR—which is still a PR. That keeps you progressing without burning out.
|
|
27
|
+
|
|
28
|
+
You can extend this concept across exercises too. Let’s say it’s biceps day. Instead of defaulting to the same curl variation you always do, you can rotate in a bicep exercise you haven’t done recently in order to assure a new PR. This introduces variability, which is another powerful way to drive adaptation while still targeting the same muscle group.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
The end goal here is simple: use your data to intelligently apply progressive overload, break through plateaus, and train more effectively—with zero guesswork.
|
|
33
|
+
|
|
34
|
+
To this end I also made an HTML page that can organize these in a text searchable way and can be accessed from my phone at the gym. This table of taget sets can be ordered by 1RPM and so easilly parsed.
|
|
35
|
+
|
|
36
|
+

|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .helper import (
|
|
2
|
+
calculate_1rm,
|
|
3
|
+
highest_weight_per_rep,
|
|
4
|
+
estimate_weight_from_1rm,
|
|
5
|
+
add_1rm_column,
|
|
6
|
+
dougs_next_pareto,
|
|
7
|
+
get_closest_exercise,
|
|
8
|
+
plot_df
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"calculate_1rm",
|
|
13
|
+
"highest_weight_per_rep",
|
|
14
|
+
"estimate_weight_from_1rm",
|
|
15
|
+
"add_1rm_column",
|
|
16
|
+
"dougs_next_pareto",
|
|
17
|
+
"get_closest_exercise",
|
|
18
|
+
"plot_df"
|
|
19
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from difflib import get_close_matches
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
|
|
7
|
+
def calculate_1rm(weight: float, reps: int) -> float:
|
|
8
|
+
# Input validation
|
|
9
|
+
if weight is None or reps is None or \
|
|
10
|
+
math.isnan(weight) or math.isnan(reps) or \
|
|
11
|
+
(reps <= 0) or (weight <= 0):
|
|
12
|
+
# Allow weight == 0 (e.g. bodyweight exercises recorded as 0)
|
|
13
|
+
# but 1RM is meaningless if weight is negative or reps <= 0
|
|
14
|
+
if (weight == 0) and (reps > 0):
|
|
15
|
+
return 0.0 # 1RM for 0 weight is 0
|
|
16
|
+
return np.nan # Invalid input for calculation
|
|
17
|
+
|
|
18
|
+
# If reps is 1, the 1RM is simply the weight lifted.
|
|
19
|
+
if reps == 1:
|
|
20
|
+
return float(weight)
|
|
21
|
+
|
|
22
|
+
# Apply the Epley formula
|
|
23
|
+
estimated_1rm = weight * (1 + reps / 30.0)
|
|
24
|
+
|
|
25
|
+
return estimated_1rm
|
|
26
|
+
|
|
27
|
+
def highest_weight_per_rep(df: pd.DataFrame) -> pd.DataFrame:
|
|
28
|
+
# Ensure required columns exist
|
|
29
|
+
required_cols = ['Exercise', 'Weight', 'Reps']
|
|
30
|
+
assert all(col in df.columns for col in required_cols)
|
|
31
|
+
|
|
32
|
+
# --- Make it simple: Basic Checks and Copy ---
|
|
33
|
+
# Work on a copy to avoid modifying the original DataFrame
|
|
34
|
+
df_copy = df.copy()
|
|
35
|
+
|
|
36
|
+
# Convert Weight and Reps to numeric, coercing errors to NaN
|
|
37
|
+
df_copy['Weight'] = pd.to_numeric(df_copy['Weight'], errors='coerce')
|
|
38
|
+
df_copy['Reps'] = pd.to_numeric(df_copy['Reps'], errors='coerce')
|
|
39
|
+
|
|
40
|
+
# Drop rows where Weight or Reps are invalid/missing for this calculation
|
|
41
|
+
df_copy = df_copy.dropna(subset=['Exercise', 'Weight', 'Reps'])
|
|
42
|
+
|
|
43
|
+
# Ensure Reps are integers if possible (or handle floats if necessary)
|
|
44
|
+
df_copy['Reps'] = df_copy['Reps'].astype(int)
|
|
45
|
+
|
|
46
|
+
if df_copy.empty:
|
|
47
|
+
# Return an empty DataFrame with the original columns structure
|
|
48
|
+
return pd.DataFrame(columns=df.columns)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Core Logic ---
|
|
52
|
+
|
|
53
|
+
# Step 1: Find the row index corresponding to the max weight for each (Exercise, Reps) pair.
|
|
54
|
+
# This handles cases where the same max weight/rep was achieved multiple times,
|
|
55
|
+
# picking one instance (the first one encountered in the original df order by default).
|
|
56
|
+
# Using idxmax ensures we keep the entire row data associated with that max weight.
|
|
57
|
+
idx = df_copy.groupby(['Exercise', 'Reps'])['Weight'].idxmax()
|
|
58
|
+
max_weight_sets = df_copy.loc[idx].copy() # Create df of potential PRs
|
|
59
|
+
|
|
60
|
+
# Step 2: Filter based on the superseding rule.
|
|
61
|
+
# A record (W, R) is superseded if there exists another record (W', R') for the same exercise
|
|
62
|
+
# such that R' > R and W' >= W.
|
|
63
|
+
|
|
64
|
+
# Define a function to check if a row is superseded within its group
|
|
65
|
+
def is_superseded(row, group_df):
|
|
66
|
+
# Check for rows in the same exercise group
|
|
67
|
+
# with strictly more reps AND greater than or equal weight
|
|
68
|
+
superseding_rows = group_df[
|
|
69
|
+
(group_df['Reps'] > row['Reps']) &
|
|
70
|
+
(group_df['Weight'] >= row['Weight'])
|
|
71
|
+
]
|
|
72
|
+
# Return True if any such row exists (meaning the current row IS superseded)
|
|
73
|
+
return not superseding_rows.empty
|
|
74
|
+
|
|
75
|
+
# Apply this check group-wise
|
|
76
|
+
final_record_indices = []
|
|
77
|
+
# Group the potential PRs by exercise
|
|
78
|
+
for exercise_name, group in max_weight_sets.groupby('Exercise'):
|
|
79
|
+
# Apply the check function to each row (axis=1) within the group
|
|
80
|
+
# We want rows where is_superseded is False
|
|
81
|
+
rows_to_keep = group[~group.apply(lambda row: is_superseded(row, group), axis=1)]
|
|
82
|
+
final_record_indices.extend(rows_to_keep.index.tolist())
|
|
83
|
+
|
|
84
|
+
# Filter the max_weight_sets DataFrame using the collected indices
|
|
85
|
+
final_df = max_weight_sets.loc[final_record_indices]
|
|
86
|
+
|
|
87
|
+
return final_df
|
|
88
|
+
|
|
89
|
+
def estimate_weight_from_1rm(one_rm: float, reps: int) -> float:
|
|
90
|
+
# Input validation
|
|
91
|
+
if one_rm is None or reps is None or \
|
|
92
|
+
math.isnan(one_rm) or math.isnan(reps) or \
|
|
93
|
+
(reps <= 0) or (one_rm < 0):
|
|
94
|
+
if (one_rm == 0) and (reps > 0):
|
|
95
|
+
return 0.0
|
|
96
|
+
return np.nan
|
|
97
|
+
|
|
98
|
+
if reps == 1:
|
|
99
|
+
return float(one_rm)
|
|
100
|
+
|
|
101
|
+
estimated_weight = one_rm / (1 + reps / 30.0)
|
|
102
|
+
|
|
103
|
+
return estimated_weight
|
|
104
|
+
|
|
105
|
+
def add_1rm_column(df: pd.DataFrame) -> pd.DataFrame:
|
|
106
|
+
# Check if necessary columns exist
|
|
107
|
+
required_cols = ['Weight', 'Reps']
|
|
108
|
+
assert all(col in df.columns for col in required_cols), \
|
|
109
|
+
f"Warning: DataFrame must contain columns: {required_cols} to calculate 1RM. Returning original DataFrame."
|
|
110
|
+
|
|
111
|
+
# Work on a copy to avoid modifying the original DataFrame
|
|
112
|
+
df_copy = df.copy()
|
|
113
|
+
|
|
114
|
+
# Ensure 'Weight' and 'Reps' are numeric, coercing errors to NaN
|
|
115
|
+
# This prepares the columns for the calculation function
|
|
116
|
+
df_copy['Weight'] = pd.to_numeric(df_copy['Weight'], errors='coerce')
|
|
117
|
+
df_copy['Reps'] = pd.to_numeric(df_copy['Reps'], errors='coerce')
|
|
118
|
+
|
|
119
|
+
# Apply the calculate_1rm function row-wise
|
|
120
|
+
# It iterates through rows and passes 'Weight' and 'Reps' to calculate_1rm
|
|
121
|
+
# The result of apply is a Series which becomes the new '1RM' column
|
|
122
|
+
df_copy['1RM'] = df_copy.apply(
|
|
123
|
+
lambda row: calculate_1rm(row['Weight'], row['Reps']),
|
|
124
|
+
axis=1 # Apply function row-wise
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Optional: Round the 1RM column for cleaner presentation
|
|
128
|
+
# df_copy['1RM'] = df_copy['1RM'].round(2)
|
|
129
|
+
|
|
130
|
+
return df_copy
|
|
131
|
+
|
|
132
|
+
def dougs_next_pareto(df_records):
|
|
133
|
+
rows = []
|
|
134
|
+
for ex in df_records['Exercise'].unique():
|
|
135
|
+
ed = df_records[df_records['Exercise']==ex].sort_values('Reps')
|
|
136
|
+
ws = ed['Weight'].tolist()
|
|
137
|
+
rs = ed['Reps'].tolist()
|
|
138
|
+
|
|
139
|
+
# first‐rep side
|
|
140
|
+
rows.append((ex, ws[0]+5, 1))
|
|
141
|
+
|
|
142
|
+
# gaps in the middle
|
|
143
|
+
for i in range(len(rs)-1):
|
|
144
|
+
if rs[i+1] > rs[i] + 1:
|
|
145
|
+
nr = rs[i] + 1
|
|
146
|
+
# two candidates: step rep on low, or step weight on high
|
|
147
|
+
c1_w = ws[i]
|
|
148
|
+
c2_w = ws[i+1] + 5
|
|
149
|
+
rows.append((ex, min(c1_w, c2_w), nr))
|
|
150
|
+
|
|
151
|
+
# high‐rep end
|
|
152
|
+
rows.append((ex, ws[-1], rs[-1]+1))
|
|
153
|
+
|
|
154
|
+
return add_1rm_column(pd.DataFrame(rows, columns=['Exercise','Weight','Reps']))
|
|
155
|
+
|
|
156
|
+
def get_closest_exercise(df, Exercise):
|
|
157
|
+
all_exercises = df['Exercise'].unique()
|
|
158
|
+
matches = get_close_matches(Exercise, all_exercises, n=1, cutoff=0.6)
|
|
159
|
+
if matches:
|
|
160
|
+
return matches[0]
|
|
161
|
+
else:
|
|
162
|
+
raise ValueError(f"No close match found for '{Exercise}'.")
|
|
163
|
+
|
|
164
|
+
def plot_df(df, df_pareto=None, df_targets=None, Exercise: str = None):
|
|
165
|
+
df = df[df['Reps'] != 0]
|
|
166
|
+
|
|
167
|
+
if Exercise is None:
|
|
168
|
+
exercises = df['Exercise'].unique()
|
|
169
|
+
fig, ax = plt.subplots()
|
|
170
|
+
for exercise in exercises:
|
|
171
|
+
exercise_df = df[df['Exercise'] == exercise]
|
|
172
|
+
ax.scatter(exercise_df['Reps']/max(exercise_df['Reps']), exercise_df['Weight']/max(exercise_df['Weight']), label=exercise)
|
|
173
|
+
ax.set_title("Weight vs. Reps for All Exercises")
|
|
174
|
+
ax.set_xlabel("Reps")
|
|
175
|
+
ax.set_ylabel("Weight")
|
|
176
|
+
return fig
|
|
177
|
+
|
|
178
|
+
closest_match = get_closest_exercise(df, Exercise)
|
|
179
|
+
df = df[df['Exercise'] == closest_match]
|
|
180
|
+
if df_pareto is not None:
|
|
181
|
+
df_pareto = df_pareto[df_pareto['Exercise'] == closest_match]
|
|
182
|
+
if df_targets is not None:
|
|
183
|
+
df_targets = df_targets[df_targets['Exercise'] == closest_match]
|
|
184
|
+
|
|
185
|
+
fig, ax = plt.subplots()
|
|
186
|
+
|
|
187
|
+
if df_pareto is not None:
|
|
188
|
+
pareto_points = list(zip(df_pareto['Reps'], df_pareto['Weight']))
|
|
189
|
+
pareto_reps, pareto_weights = zip(*sorted(pareto_points, key=lambda x: x[0]))
|
|
190
|
+
|
|
191
|
+
# Compute best 1RM from Pareto front
|
|
192
|
+
one_rms = [calculate_1rm(w, r) for w, r in zip(pareto_weights, pareto_reps)]
|
|
193
|
+
max_1rm = max(one_rms)
|
|
194
|
+
|
|
195
|
+
# Generate dotted Epley decay line
|
|
196
|
+
x_vals = np.linspace(min(df['Reps']), max(df['Reps']), 10)
|
|
197
|
+
y_vals = [estimate_weight_from_1rm(max_1rm, r) for r in x_vals]
|
|
198
|
+
ax.plot(x_vals, y_vals, 'k--', label='Max Achieved 1RM', alpha=0.7)
|
|
199
|
+
|
|
200
|
+
ax.step(pareto_reps, pareto_weights, color='red', marker='o', label='Pareto Front')
|
|
201
|
+
|
|
202
|
+
if df_targets is not None:
|
|
203
|
+
target_points = list(zip(df_targets['Reps'], df_targets['Weight']))
|
|
204
|
+
target_reps, target_weights = zip(*sorted(target_points, key=lambda x: x[0]))
|
|
205
|
+
|
|
206
|
+
# Compute best 1RM from Pareto front
|
|
207
|
+
one_rms = [calculate_1rm(w, r) for w, r in zip(target_weights, target_reps)]
|
|
208
|
+
min_1rm = min(one_rms)
|
|
209
|
+
|
|
210
|
+
# Generate dotted Epley decay line
|
|
211
|
+
x_vals = np.linspace(min(df['Reps']), max(df['Reps']), 10)
|
|
212
|
+
y_vals = [estimate_weight_from_1rm(min_1rm, r) for r in x_vals]
|
|
213
|
+
ax.plot(x_vals, y_vals, 'g-.', label='Min Target 1RM', alpha=0.7)
|
|
214
|
+
|
|
215
|
+
ax.scatter(df_targets['Reps'], df_targets['Weight'], color='green', marker='x', label='Targets')
|
|
216
|
+
|
|
217
|
+
# Plotting
|
|
218
|
+
ax.scatter(df['Reps'], df['Weight'], label='Data Points')
|
|
219
|
+
|
|
220
|
+
ax.set_title(f"Weight vs. Reps for {closest_match}")
|
|
221
|
+
ax.set_xlabel("Reps")
|
|
222
|
+
ax.set_xlim(left=0)
|
|
223
|
+
ax.set_ylabel("Weight")
|
|
224
|
+
ax.legend()
|
|
225
|
+
|
|
226
|
+
return fig
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import os
|
|
3
|
+
import glob
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import base64
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from IPython.display import display, HTML
|
|
8
|
+
|
|
9
|
+
from helpers import dougs_next_pareto, highest_weight_per_rep, plot_df
|
|
10
|
+
|
|
11
|
+
# Get a list of all CSV files in the current directory
|
|
12
|
+
working_dir = '/content/drive/MyDrive/Personal Work/Workout Analysis (90%)/'
|
|
13
|
+
csv_files = glob.glob(working_dir+'FitNotes_Export_*.csv')
|
|
14
|
+
|
|
15
|
+
# Find the file with the most recent timestamp
|
|
16
|
+
latest_file = max(csv_files, key=os.path.getctime)
|
|
17
|
+
print(f"Using {latest_file}")
|
|
18
|
+
|
|
19
|
+
# Load the latest file into a pandas DataFrame
|
|
20
|
+
df = pd.read_csv(latest_file)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
test_df = pd.DataFrame({
|
|
24
|
+
'Exercise':['Bench Press']*6 + ['Flat Bench Press']*4,
|
|
25
|
+
'Weight':[40, 100, 105, 85, 55, 15] + [95, 75, 45, 10],
|
|
26
|
+
'Reps': [ 4, 1, 1, 3, 6, 11] + [ 1, 3, 5, 11],
|
|
27
|
+
})
|
|
28
|
+
df_records = highest_weight_per_rep(test_df)
|
|
29
|
+
df_targets = dougs_next_pareto(df_records)
|
|
30
|
+
fig = plot_df(test_df, df_pareto=df_records, df_targets=df_targets, Exercise='Bench Press')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Assuming 'Date' column exists and is in datetime format.
|
|
34
|
+
# If not, convert it first:
|
|
35
|
+
df['Date'] = pd.to_datetime(df['Date'], format='%Y-%m-%d') # Example format, adjust as needed.
|
|
36
|
+
|
|
37
|
+
# Sort the DataFrame by 'Date' in ascending order (least recent to most recent)
|
|
38
|
+
df_sorted = df.sort_values(by='Date', ascending=True)
|
|
39
|
+
|
|
40
|
+
# Turn Distance into Weight?
|
|
41
|
+
df_sorted['Weight'] = df_sorted['Weight'].combine_first(df_sorted['Distance'])
|
|
42
|
+
df_sorted['Time'] = pd.to_timedelta(df_sorted['Time']).dt.total_seconds()/60
|
|
43
|
+
df_sorted['Reps'] = df_sorted['Reps'].combine_first(df_sorted['Time'])
|
|
44
|
+
|
|
45
|
+
# drop what we don't care about
|
|
46
|
+
df_sorted = df_sorted.drop(['Distance', 'Weight Unit', 'Distance Unit', 'Comment', 'Time'], axis=1)
|
|
47
|
+
df_sorted = df_sorted[df_sorted['Category'] != 'Cardio']
|
|
48
|
+
df_sorted = df_sorted[df_sorted['Exercise'] != 'Climbing']
|
|
49
|
+
df_sorted = df_sorted[df_sorted['Exercise'] != 'Tricep Push Ul']
|
|
50
|
+
|
|
51
|
+
df_records = highest_weight_per_rep(df_sorted)
|
|
52
|
+
df_targets = dougs_next_pareto(df_records)
|
|
53
|
+
|
|
54
|
+
fig = plot_df(df_sorted, Exercise='Curl Pulldown Bicep')
|
|
55
|
+
fig = plot_df(df_sorted, df_pareto=df_records, Exercise='Curl Pulldown Bicep')
|
|
56
|
+
fig = plot_df(df_sorted, df_records, df_targets, Exercise='Curl Pulldown Bicep')
|
|
57
|
+
|
|
58
|
+
fig = plot_df(df_sorted, df_records, df_targets, Exercise='Straight-Arm Cable Pushdown')
|
|
59
|
+
|
|
60
|
+
N_CAT = 2
|
|
61
|
+
N_EXERCISES_PER_CAT = 2
|
|
62
|
+
N_TARGET_SETS_PER_EXERCISES = 2
|
|
63
|
+
|
|
64
|
+
# Find the most recent date for each category
|
|
65
|
+
category_most_recent = df_sorted.groupby('Category')['Date'].max()
|
|
66
|
+
|
|
67
|
+
# Sort categories by their most recent date (oldest first)
|
|
68
|
+
sorted_categories = category_most_recent.sort_values().index
|
|
69
|
+
output_lines = []
|
|
70
|
+
|
|
71
|
+
for category in sorted_categories[:N_CAT]: # Take the category with oldest most recent date
|
|
72
|
+
print(f"{category=}")
|
|
73
|
+
output_lines.append(f"Category: {category}\n")
|
|
74
|
+
|
|
75
|
+
# Filter to this category
|
|
76
|
+
category_df = df_sorted[df_sorted['Category'] == category]
|
|
77
|
+
|
|
78
|
+
# Find the oldest exercises in this category
|
|
79
|
+
exercise_oldest_dates = category_df.groupby('Exercise')['Date'].max()
|
|
80
|
+
oldest_exercises = exercise_oldest_dates.nsmallest(N_EXERCISES_PER_CAT)
|
|
81
|
+
|
|
82
|
+
for exercise, oldest_date in oldest_exercises.items():
|
|
83
|
+
print(f" {exercise=}, date={oldest_date}")
|
|
84
|
+
output_lines.append(f" Exercise: {exercise}, Last Done: {oldest_date}\n")
|
|
85
|
+
|
|
86
|
+
# Find the lowest 3 sets to target
|
|
87
|
+
sorted_exercise_targets = df_targets[df_targets['Exercise'] == exercise].nsmallest(n=N_TARGET_SETS_PER_EXERCISES, columns='1RM')
|
|
88
|
+
for index, row in sorted_exercise_targets.iterrows():
|
|
89
|
+
print(f" {row['Weight']} for {row['Reps']} reps ({row['1RM']:.2f} 1rm)")
|
|
90
|
+
output_lines.append(f" {row['Weight']} lbs for {row['Reps']} reps ({row['1RM']:.2f} 1RM)\n")
|
|
91
|
+
|
|
92
|
+
print(' ')
|
|
93
|
+
output_lines.append("\n") # Add a blank line between categories
|
|
94
|
+
|
|
95
|
+
# Save to file
|
|
96
|
+
output_file = working_dir+"workout_summary.txt"
|
|
97
|
+
with open(output_file, "w") as f:
|
|
98
|
+
f.writelines(output_lines)
|
|
99
|
+
|
|
100
|
+
print(f"Saved to {output_file}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Create a dictionary: { exercise_name: base64_image_string }
|
|
104
|
+
figures_html = {}
|
|
105
|
+
errors = ""
|
|
106
|
+
for exercise in df['Exercise'].unique():
|
|
107
|
+
try:
|
|
108
|
+
fig = plot_df(df_sorted, df_records, df_targets, Exercise=exercise)
|
|
109
|
+
buf = BytesIO()
|
|
110
|
+
fig.savefig(buf, format="png", bbox_inches='tight')
|
|
111
|
+
buf.seek(0)
|
|
112
|
+
base64_img = base64.b64encode(buf.read()).decode('utf-8')
|
|
113
|
+
img_html = f'<img src="data:image/png;base64,{base64_img}" id="fig-{exercise}" class="exercise-figure" style="display:none; max-width:100%; height:auto;">'
|
|
114
|
+
figures_html[exercise] = img_html
|
|
115
|
+
plt.close(fig)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
errors += f"{e}"
|
|
118
|
+
|
|
119
|
+
all_figures_html = "\n".join(figures_html.values())
|
|
120
|
+
|
|
121
|
+
# Basic setup
|
|
122
|
+
exercise_column = "Exercise" # Adjust if needed
|
|
123
|
+
exercise_options = sorted(df_targets[exercise_column].dropna().unique())
|
|
124
|
+
|
|
125
|
+
# Build dropdown
|
|
126
|
+
dropdown_html = f"""
|
|
127
|
+
<label for="exerciseDropdown">Filter by Exercise:</label>
|
|
128
|
+
<select id="exerciseDropdown">
|
|
129
|
+
<option value="">All</option>
|
|
130
|
+
{''.join(f'<option value="{x}">{x}</option>' for x in exercise_options)}
|
|
131
|
+
</select>
|
|
132
|
+
<br><br>
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# Convert DataFrame to HTML table
|
|
136
|
+
table_html = df_targets.to_html(classes="display compact cell-border", table_id="exerciseTable", index=False)
|
|
137
|
+
|
|
138
|
+
# JS and CSS for DataTables + filtering
|
|
139
|
+
# JS, CSS, and styling improvements
|
|
140
|
+
js_and_css = """
|
|
141
|
+
<!-- DataTables -->
|
|
142
|
+
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css"/>
|
|
143
|
+
<script src="https://code.jquery.com/jquery-3.5.1.js"></script>
|
|
144
|
+
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
|
145
|
+
|
|
146
|
+
<!-- Select2 for searchable dropdown -->
|
|
147
|
+
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
|
148
|
+
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
|
149
|
+
|
|
150
|
+
<!-- Custom Styling for Mobile -->
|
|
151
|
+
<style>
|
|
152
|
+
body {
|
|
153
|
+
font-family: Arial, sans-serif;
|
|
154
|
+
font-size: 34px;
|
|
155
|
+
padding: 28px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
table.dataTable {
|
|
159
|
+
font-size: 32px;
|
|
160
|
+
width: 100% !important;
|
|
161
|
+
word-wrap: break-word;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
label, select {
|
|
165
|
+
font-size: 34px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#exerciseDropdown {
|
|
169
|
+
width: 100%;
|
|
170
|
+
max-width: 400px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@media only screen and (max-width: 600px) {
|
|
174
|
+
table, thead, tbody, th, td, tr {
|
|
175
|
+
display: block;
|
|
176
|
+
}
|
|
177
|
+
th {
|
|
178
|
+
text-align: left;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
182
|
+
|
|
183
|
+
<script>
|
|
184
|
+
$(document).ready(function() {
|
|
185
|
+
// Initialize DataTable
|
|
186
|
+
var table = $('#exerciseTable').DataTable({
|
|
187
|
+
responsive: true
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Initialize Select2 for searchable dropdown
|
|
191
|
+
$('#exerciseDropdown').select2({
|
|
192
|
+
placeholder: "Filter by Exercise",
|
|
193
|
+
allowClear: true
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Filter by selected exercise
|
|
197
|
+
$('#exerciseDropdown').on('change', function() {
|
|
198
|
+
var val = $.fn.dataTable.util.escapeRegex($(this).val());
|
|
199
|
+
table.column(0).search(val ? '^' + val + '$' : '', true, false).draw(); // assumes Exercise is col 0
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
$('#exerciseDropdown').on('change', function() {
|
|
203
|
+
var val = $.fn.dataTable.util.escapeRegex($(this).val());
|
|
204
|
+
table.column(0).search(val ? '^' + val + '$' : '', true, false).draw();
|
|
205
|
+
|
|
206
|
+
// Hide all figures
|
|
207
|
+
$('.exercise-figure').hide();
|
|
208
|
+
|
|
209
|
+
// Show the matching figure
|
|
210
|
+
if (this.value) {
|
|
211
|
+
$('#fig-' + this.value).show();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
</script>
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
# Final combo
|
|
219
|
+
full_html = js_and_css + dropdown_html + table_html + all_figures_html
|
|
220
|
+
display(HTML(full_html))
|
|
221
|
+
|
|
222
|
+
# --- Save the HTML to a file ---
|
|
223
|
+
with open(working_dir+"interactive_table.html", 'w', encoding='utf-8') as f:
|
|
224
|
+
f.write(full_html)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kaiserlift
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Data-driven progressive overload
|
|
5
|
+
Author: Douglas Kaiser
|
|
6
|
+
Author-email: douglastkaiser@gmail.com
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Dynamic: author
|
|
9
|
+
Dynamic: author-email
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
kaiserlift/__init__.py
|
|
6
|
+
kaiserlift/helpers.py
|
|
7
|
+
kaiserlift/main.py
|
|
8
|
+
kaiserlift.egg-info/PKG-INFO
|
|
9
|
+
kaiserlift.egg-info/SOURCES.txt
|
|
10
|
+
kaiserlift.egg-info/dependency_links.txt
|
|
11
|
+
kaiserlift.egg-info/entry_points.txt
|
|
12
|
+
kaiserlift.egg-info/top_level.txt
|
|
13
|
+
tests/tests.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kaiserlift
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name='kaiserlift',
|
|
5
|
+
version='0.1.0',
|
|
6
|
+
description='Data-driven progressive overload',
|
|
7
|
+
author='Douglas Kaiser',
|
|
8
|
+
author_email='douglastkaiser@gmail.com',
|
|
9
|
+
packages=find_packages(),
|
|
10
|
+
install_requires=[],
|
|
11
|
+
entry_points={
|
|
12
|
+
'console_scripts': [
|
|
13
|
+
'kaiserlift-cli = kaiserlift.main:main',
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
|
|
2
|
+
# Use pypi installed version of kaiserlift, not local version.
|
|
3
|
+
from kaiserlift import calculate_1rm, estimate_weight_from_1rm, \
|
|
4
|
+
add_1rm_column, dougs_next_pareto, highest_weight_per_rep
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
def assert_frame_equal(df1, df2):
|
|
8
|
+
assert df1.shape == df2.shape, "DataFrames have different shapes." + \
|
|
9
|
+
f"\n{df1=}\n{df2=}"
|
|
10
|
+
assert sorted(df1.columns) == sorted(df2.columns), "DataFrames have different column names." + \
|
|
11
|
+
f"\n{df1=}\n{df2=}"
|
|
12
|
+
# Ensure column order is the same before sorting rows
|
|
13
|
+
df1_reordered_cols = df1.sort_index(axis=1)
|
|
14
|
+
df2_reordered_cols = df2.sort_index(axis=1)
|
|
15
|
+
# Reset index, sort by all columns, reset index again
|
|
16
|
+
df1_processed = df1_reordered_cols.reset_index(drop=True)\
|
|
17
|
+
.sort_values(by=df1_reordered_cols.columns.tolist())\
|
|
18
|
+
.reset_index(drop=True)
|
|
19
|
+
df2_processed = df2_reordered_cols.reset_index(drop=True)\
|
|
20
|
+
.sort_values(by=df2_reordered_cols.columns.tolist())\
|
|
21
|
+
.reset_index(drop=True)
|
|
22
|
+
assert df1_processed.equals(df2_processed), f'\n{df1_processed}\nnot equal to\n{df2_processed}'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_assert_frame_equal():
|
|
26
|
+
assert_frame_equal(
|
|
27
|
+
pd.DataFrame({'Weight':[100], 'Reps':[1]}),
|
|
28
|
+
pd.DataFrame({'Weight':[100], 'Reps':[1]})
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
assert_frame_equal(
|
|
33
|
+
pd.DataFrame({'Weight':[100], 'Reps':[1]}),
|
|
34
|
+
pd.DataFrame({'Weight':[100], 'Reps':[2]})
|
|
35
|
+
)
|
|
36
|
+
except AssertionError as e:
|
|
37
|
+
assert "not equal to" in f"{e}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_calculate_1rm():
|
|
41
|
+
assert calculate_1rm(100, 1) == 100.0
|
|
42
|
+
assert calculate_1rm(1, 15) == 1.5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_highest_weight_per_rep():
|
|
46
|
+
# Should be the same for 100x1 only.
|
|
47
|
+
assert_frame_equal(
|
|
48
|
+
highest_weight_per_rep(pd.DataFrame({
|
|
49
|
+
'Exercise':'Bench Press',
|
|
50
|
+
'Weight':[100],
|
|
51
|
+
'Reps':[1],
|
|
52
|
+
})),
|
|
53
|
+
pd.DataFrame({
|
|
54
|
+
'Exercise':'Bench Press',
|
|
55
|
+
'Weight':[100],
|
|
56
|
+
'Reps':[1],
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Fake out, 90x5 should be dropped in favor of 100x10
|
|
61
|
+
assert_frame_equal(
|
|
62
|
+
highest_weight_per_rep(pd.DataFrame({
|
|
63
|
+
'Exercise':'Bench Press',
|
|
64
|
+
'Weight':[100, 90],
|
|
65
|
+
'Reps':[10, 5],
|
|
66
|
+
})),
|
|
67
|
+
pd.DataFrame({
|
|
68
|
+
'Exercise':'Bench Press',
|
|
69
|
+
'Weight':[100],
|
|
70
|
+
'Reps':[10],
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Real now, 90x15, 110x1, and 100x10 will be kept, all others dropped.
|
|
75
|
+
assert_frame_equal(
|
|
76
|
+
highest_weight_per_rep(pd.DataFrame({
|
|
77
|
+
'Exercise':'Bench Press',
|
|
78
|
+
'Weight':[100, 90, 100, 95, 110],
|
|
79
|
+
'Reps': [10, 15, 5, 3, 1],
|
|
80
|
+
})),
|
|
81
|
+
pd.DataFrame({
|
|
82
|
+
'Exercise':'Bench Press',
|
|
83
|
+
'Weight':[100, 90, 110],
|
|
84
|
+
'Reps':[10, 15, 1],
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def test_estimate_weight_from_1rm():
|
|
89
|
+
assert estimate_weight_from_1rm(200, 1) == 200.0
|
|
90
|
+
assert estimate_weight_from_1rm(200, 4) == 176.47058823529412
|
|
91
|
+
|
|
92
|
+
def test_calculate_1rm_from_estimate():
|
|
93
|
+
assert calculate_1rm(estimate_weight_from_1rm(200, 4), 4) == 200.0
|
|
94
|
+
|
|
95
|
+
def test_add_1rm_column():
|
|
96
|
+
# Should be the same for 100x1 only.
|
|
97
|
+
assert_frame_equal(
|
|
98
|
+
add_1rm_column(pd.DataFrame({
|
|
99
|
+
'Weight':[100],
|
|
100
|
+
'Reps':[1],
|
|
101
|
+
})),
|
|
102
|
+
pd.DataFrame({
|
|
103
|
+
'Weight':[100],
|
|
104
|
+
'Reps':[1],
|
|
105
|
+
'1RM':[100.0],
|
|
106
|
+
})
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# More real values. w*(1+r/30.0)
|
|
110
|
+
assert_frame_equal(
|
|
111
|
+
add_1rm_column(pd.DataFrame({
|
|
112
|
+
'Weight':[100, 1, 13],
|
|
113
|
+
'Reps':[30, 15, 1],
|
|
114
|
+
})),
|
|
115
|
+
pd.DataFrame({
|
|
116
|
+
'Weight':[100, 1, 13],
|
|
117
|
+
'Reps':[30, 15, 1],
|
|
118
|
+
'1RM':[200.0, 1.5, 13.0],
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def test_dougs_next_pareto():
|
|
123
|
+
# Simple case for 100x1; 105x1 and 100x2
|
|
124
|
+
assert_frame_equal(
|
|
125
|
+
dougs_next_pareto(pd.DataFrame({
|
|
126
|
+
'Exercise':'Bench Press',
|
|
127
|
+
'Weight':[100],
|
|
128
|
+
'Reps':[1],
|
|
129
|
+
})),
|
|
130
|
+
add_1rm_column(pd.DataFrame({
|
|
131
|
+
'Exercise':'Bench Press',
|
|
132
|
+
'Weight':[105, 100],
|
|
133
|
+
'Reps':[1, 2],
|
|
134
|
+
}))
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
assert_frame_equal(
|
|
138
|
+
dougs_next_pareto(pd.DataFrame({
|
|
139
|
+
'Exercise':'Bench Press',
|
|
140
|
+
'Weight':[100],
|
|
141
|
+
'Reps':[5],
|
|
142
|
+
})),
|
|
143
|
+
add_1rm_column(pd.DataFrame({
|
|
144
|
+
'Exercise':'Bench Press',
|
|
145
|
+
'Weight':[105, 100],
|
|
146
|
+
'Reps':[1, 6],
|
|
147
|
+
}))
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Two excersice test
|
|
151
|
+
assert_frame_equal(
|
|
152
|
+
dougs_next_pareto(pd.DataFrame({
|
|
153
|
+
'Exercise':['Bench Press'] + ['Incline Bench Press']*2,
|
|
154
|
+
'Weight':[100] + [80, 50],
|
|
155
|
+
'Reps':[5] + [1, 10],
|
|
156
|
+
})),
|
|
157
|
+
add_1rm_column(pd.DataFrame({
|
|
158
|
+
'Exercise':['Bench Press']*2 + ['Incline Bench Press']*3,
|
|
159
|
+
'Weight':[105, 100] + [85, 55, 50],
|
|
160
|
+
'Reps':[1, 6] + [1, 2, 11],
|
|
161
|
+
}))
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Okay the big case now. Lowest next set on the pareto front.
|
|
165
|
+
# First entry not on rep 1
|
|
166
|
+
# Gaps in both rep and weight
|
|
167
|
+
# Single increment off example (80x5 and 75x6)
|
|
168
|
+
assert_frame_equal(
|
|
169
|
+
dougs_next_pareto(pd.DataFrame({
|
|
170
|
+
'Exercise':'Bench Press',
|
|
171
|
+
'Weight':[100, 80, 75, 50],
|
|
172
|
+
'Reps':[2, 5, 6, 10],
|
|
173
|
+
})),
|
|
174
|
+
add_1rm_column(pd.DataFrame({
|
|
175
|
+
'Exercise':'Bench Press',
|
|
176
|
+
'Weight':[105, 85, 55, 50],
|
|
177
|
+
'Reps': [ 1, 3, 7, 11],
|
|
178
|
+
}))
|
|
179
|
+
)
|