logbook/review/main.go
2025-05-09 14:37:57 -04:00

439 lines
11 KiB
Go

package review
import (
"errors"
"fmt"
"git.wow.st/gmp/logbook"
"git.wow.st/gmp/logbook/parser"
"sort"
"strings"
"time"
)
// Performance represents one or more actual sets performed together.
type Performance struct {
Exercise string
Type string
Weights []float64 // one weight per set
Reps []int // one reps count per set
Times []time.Time // one timestamp per set
Note string
Bodyweight float64 // bodyweight at or near these times
RIR []int
}
func (p Performance) String() string {
if len(p.Weights) == 0 {
return ""
}
var result []string
result = append(result, p.Times[0].UTC().Format("2 Jan 2006") +
": " +
p.Exercise)
if p.Type != "" {
result = append(result, "(" + p.Type + ")")
}
result = append(result, fmt.Sprintf("%0.0f", p.Weights[0]))
currentWeight := p.Weights[0]
currentReps := p.Reps[0]
count := 1
firstMax := 0 // pointer to the first entry with a zero RIR
if p.RIR[0] != 0 {
firstMax = -1
}
for i := 1; i < len(p.Weights); i++ {
if p.Weights[i] == currentWeight && p.Reps[i] == currentReps {
count++
} else {
result = append(result, fmt.Sprintf("%dx%d", count, currentReps))
count = 1
if p.Weights[i] != currentWeight {
result = append(result, fmt.Sprintf("%0.0f", p.Weights[i]))
currentWeight = p.Weights[i]
}
currentReps = p.Reps[i]
}
if firstMax == -1 && p.RIR[i] == 0 {
firstMax = i
}
}
result = append(result, fmt.Sprintf("%dx%d", count, currentReps))
if firstMax != -1 {
result = append(result, fmt.Sprintf("(1RM: %0.0f)", Estimated1RM(p)))
}
return strings.Join(result, " ")
}
func (p Performance) String2() string {
var b strings.Builder
b.WriteString(p.Times[0].UTC().Format("2 Jan 2006") + ": ")
b.WriteString(p.Exercise)
if p.Type != "" {
b.WriteString(" (" + p.Type + ")")
}
wt := p.Weights[0]
b.WriteString(" " + fmt.Sprintf("%0.0f", wt))
b.WriteString(" " + fmt.Sprintf("%d", p.Reps[0]))
if len(p.Reps) > 1 {
for i,r := range(p.Reps[1:]) {
b.WriteString("-")
if p.Weights[i+1] != wt {
b.WriteString(fmt.Sprintf("%0.0fx",p.Weights[i+1]))
}
b.WriteString(fmt.Sprintf("%d", r))
}
}
b.WriteString(fmt.Sprintf(" (%0.0f)", Estimated1RM(p)))
return b.String()
}
type Performances []Performance
// PerformanceRepository defines interface to retrieve performances.
type PerformanceRepository interface {
// FindPerformances returns performances matching the filter, sorted oldest to newest.
FindPerformances(filter func(Performance) bool) (Performances, error)
}
// performanceRepo implements PerformanceRepository using WorkoutRepository and MeasurementRepository.
type performanceRepo struct {
workoutRepo *logbook.WorkoutRepository
measurementRepo *logbook.MeasurementRepository
}
// NewPerformanceRepository creates a PerformanceRepository backed by the given repositories.
func NewPerformanceRepository(wr *logbook.WorkoutRepository, mr *logbook.MeasurementRepository) PerformanceRepository {
return &performanceRepo{
workoutRepo: wr,
measurementRepo: mr,
}
}
func (r *performanceRepo) FindPerformances(filter func(Performance) bool) (Performances, error) {
// If filter is nil, accept all SetGroups
if filter == nil {
filter = func(p Performance) bool { return true }
}
// We create a SetGroup-level filter that accepts any SetGroup
// whose Exercise and Type might possibly match the Performance filter.
// Because filter works on Performance (which includes weights, reps, etc),
// we conservatively accept all SetGroups and filter later.
// But if filter is composed of simple Exercise/Type filters, we can optimize.
// Attempt to extract Exercise and Type filters from the given filter func
// (This requires filter composition knowledge; otherwise fallback to accept all)
// For simplicity, here we pass all SetGroups and filter in Go:
setGroups, err := r.workoutRepo.FindSetGroups(func(sg *parser.SetGroup) bool {
// Construct a minimal Performance with only Exercise and Type to test filter
p := Performance{
Exercise: sg.Exercise,
Type: sg.Type,
}
return filter(p)
})
if err != nil {
return nil, err
}
var performances []Performance
for _, sg := range setGroups {
if len(sg.ActualSets) == 0 {
continue
}
weights := make([]float64, 0, len(sg.ActualSets))
reps := make([]int, 0, len(sg.ActualSets))
times := make([]time.Time, 0, len(sg.ActualSets))
RIR := make([]int, 0, len(sg.ActualSets))
for _, set := range sg.ActualSets {
weights = append(weights, set.Weight)
reps = append(reps, set.Reps)
times = append(times, set.Time)
RIR = append(RIR, set.RIR)
}
earliestTime := times[0]
bodyweight, err := r.measurementRepo.GetOrPredict("bodyweight", earliestTime)
if err != nil {
bodyweight = 0
}
p := Performance{
Exercise: sg.Exercise,
Type: sg.Type,
Weights: weights,
Reps: reps,
Times: times,
Note: sg.Note,
Bodyweight: bodyweight,
RIR: RIR,
}
if filter(p) {
performances = append(performances, p)
}
}
return Performances(performances), nil
}
func (ps *Performances) FindPerformances(filter func(Performance) bool) ([]Performance, error) {
var performances Performances
for _, p := range (*ps) {
if filter(p) {
performances = append(performances, p)
}
}
return performances, nil
}
//////////////////////////
// Filtering Utilities //
//////////////////////////
// FilterByExerciseType returns a filter for a given exercise and type.
func FilterByExerciseType(exercise, typ string) func(Performance) bool {
return func(p Performance) bool {
return p.Exercise == exercise && p.Type == typ
}
}
// FilterByWeightRange returns a filter that matches any set weight within [min, max].
func FilterByWeightRange(min, max float64) func(Performance) bool {
return func(p Performance) bool {
for _, w := range p.Weights {
if w >= min && w <= max {
return true
}
}
return false
}
}
// FilterByDateRange returns a filter that matches any set time within [start, end].
func FilterByDateRange(start, end time.Time) func(Performance) bool {
return func(p Performance) bool {
for _, t := range p.Times {
if !t.Before(start) && !t.After(end) {
return true
}
}
return false
}
}
// And composes multiple filters with logical AND.
func And(filters ...func(Performance) bool) func(Performance) bool {
return func(p Performance) bool {
for _, f := range filters {
if !f(p) {
return false
}
}
return true
}
}
// Or composes multiple filters with logical OR.
func Or(filters ...func(Performance) bool) func(Performance) bool {
return func(p Performance) bool {
for _, f := range filters {
if f(p) {
return true
}
}
return false
}
}
//////////////////////////
// Aggregation Functions //
//////////////////////////
// RecentPerformances returns up to n most recent performances matching filter, sorted newest first.
func RecentPerformances(repo PerformanceRepository, filter func(Performance) bool, n int) ([]Performance, error) {
perfs, err := repo.FindPerformances(filter)
if err != nil {
return nil, err
}
// Sort by latest set time descending
sort.Slice(perfs, func(i, j int) bool {
return latestTime(perfs[i]).After(latestTime(perfs[j]))
})
if n > 0 && len(perfs) > n {
perfs = perfs[:n]
}
return perfs, nil
}
// PersonalBest returns the performance with the highest max estimated 1RM for the given exercise, type, and weight.
func PersonalBest(repo PerformanceRepository, exercise, typ string, weight float64) (Performance, error) {
filter := And(
FilterByExerciseType(exercise, typ),
FilterByWeightRange(weight, weight),
)
perfs, err := repo.FindPerformances(filter)
if err != nil {
return Performance{}, err
}
if len(perfs) == 0 {
return Performance{}, errors.New("no performances found")
}
best := perfs[0]
bestMax1RM := Estimated1RM(best)
for _, p := range perfs[1:] {
if m := Estimated1RM(p); m > bestMax1RM {
best = p
bestMax1RM = m
}
}
return best, nil
}
// RepsDistribution returns a map of reps count to frequency for all sets matching exercise, type, and weight.
func RepsDistribution(repo PerformanceRepository, exercise, typ string, weight float64) (map[int]int, error) {
filter := And(
FilterByExerciseType(exercise, typ),
FilterByWeightRange(weight, weight),
)
perfs, err := repo.FindPerformances(filter)
if err != nil {
return nil, err
}
dist := make(map[int]int)
for _, p := range perfs {
for i, w := range p.Weights {
if w == weight {
dist[p.Reps[i]]++
}
}
}
return dist, nil
}
//////////////////////////
// Strength Estimation //
//////////////////////////
// Epley1RM estimates one-rep max using Epley formula: 1RM = weight * (1 + reps/30)
func Epley1RM(weight float64, reps int) float64 {
if reps <= 0 {
return 0
}
return weight * (1 + float64(reps-1)/30.0)
}
// EstimateRepsAtWeight estimates how many reps can be performed at targetWeight given 1RM.
func EstimateRepsAtWeight(oneRM, targetWeight float64) int {
if targetWeight <= 0 || oneRM <= 0 {
return 0
}
reps := 30 * (oneRM/targetWeight - 1)
if reps < 0 {
return 0
}
return int(reps + 0.5) // round to nearest int
}
// EstimateWeightAtReps estimates max weight for given reps and 1RM.
func EstimateWeightAtReps(oneRM float64, reps int) float64 {
if reps <= 0 {
return 0
}
return oneRM / (1 + float64(reps)/30.0)
}
//////////////////////////
// Sorting and Comparison //
//////////////////////////
// LessFunc defines a comparison function for sorting Performances.
type LessFunc func(a, b Performance) bool
// ByMaxEstimated1RM compares by max estimated 1RM (default), tiebreak by lower bodyweight.
func ByMaxEstimated1RM(a, b Performance) bool {
max1RM_a := Estimated1RM(a)
max1RM_b := Estimated1RM(b)
if max1RM_a != max1RM_b {
return max1RM_a < max1RM_b
}
// tie-breaker: lower bodyweight ranks higher
return a.Bodyweight > b.Bodyweight
}
// ByTotalVolume compares by total volume (sum weight*reps), tiebreak by lower bodyweight.
func ByTotalVolume(a, b Performance) bool {
volA := totalVolume(a)
volB := totalVolume(b)
if volA != volB {
return volA < volB
}
return a.Bodyweight > b.Bodyweight
}
// SortPerformances sorts performances in-place using the provided LessFunc.
// If less is nil, uses ByMaxEstimated1RM as default.
func SortPerformances(perfs []Performance, less LessFunc) {
if less == nil {
less = ByMaxEstimated1RM
}
sort.Slice(perfs, func(i, j int) bool {
return less(perfs[i], perfs[j])
})
}
//////////////////////////
// Internal helpers //
//////////////////////////
func Estimated1RM(p Performance) float64 {
isbw := IsBodyweight(p.Exercise, "")
Estimate1RM := GetEstimate1RM(p.Exercise, p.Type)
var bw float64
if isbw {
bw = p.Bodyweight
}
max1RM := bw
for i := range p.Weights {
oneRM := Estimate1RM(p.Weights[i] + bw, p.Reps[i])
if oneRM > max1RM {
max1RM = oneRM
}
}
return max1RM - bw
}
func totalVolume(p Performance) float64 {
vol := 0.0
for i := range p.Weights {
vol += p.Weights[i] * float64(p.Reps[i])
}
return vol
}
func latestTime(p Performance) time.Time {
latest := p.Times[0]
for _, t := range p.Times[1:] {
if t.After(latest) {
latest = t
}
}
return latest
}