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
 | |
| }
 | |
| 
 |