439 lines
11 KiB
Go
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
|
|
}
|
|
|