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.
@@ -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
+ ![Rawdata Curl Pulldown](images/RawDataCurlPulldownBicep.png "Rawdata Curl Pulldown")
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
+ ![Curl Pulldown with Pareto](images/CurlPulldownwithPareto.png "Curl Pulldown with Pareto")
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
+ ![Curl Pulldown with Targets](images/CurlPulldownwithTargets.png "Curl Pulldown with Targets")
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
+ ![Curl Pulldown Example - HTML](images/curlpulldown_html.png "Curl Pulldown Example - HTML")
@@ -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,2 @@
1
+ [console_scripts]
2
+ kaiserlift-cli = kaiserlift.main:main
@@ -0,0 +1 @@
1
+ kaiserlift
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )