From aece7c8903254c40d45a77bb0cae597dc79b1a2b Mon Sep 17 00:00:00 2001 From: Sean Stangl Date: Mon, 27 May 2024 12:29:56 -0600 Subject: [PATCH] feat(backend): Use fixed-point WeightKg for weights. Closes #363 This patch ports the OpenPowerlifting weight representation to Go. The new WeightKg type represents numbers like `123.45` by storing them in a fixed-point integer representation, like `12345`. The advantage of this representation is that integer comparisons and arithmetic are significantly faster than the corresponding floating-point operations, because integers do not require loads into XMM registers. --- backend/Makefile | 13 ++ backend/dbtools/dbtools_test.go | 44 ++++--- backend/dbtools/sortby.go | 2 +- backend/dbtools/sortgender.go | 62 +++++---- backend/dbtools/weightcats.go | 44 +++---- backend/lifter/search_test.go | 14 +-- backend/sinclair/sinclair.go | 38 ++++-- backend/sinclair/sinclair_test.go | 12 +- backend/structs/helpers.go | 8 +- backend/structs/helpers_test.go | 10 +- backend/structs/struct_funcs.go | 34 +++-- backend/structs/struct_funcs_test.go | 64 +++++----- backend/structs/structs.go | 48 +++---- backend/structs/weightkg.go | 181 +++++++++++++++++++++++++++ backend/structs/weightkg_test.go | 105 ++++++++++++++++ 15 files changed, 505 insertions(+), 174 deletions(-) create mode 100644 backend/Makefile create mode 100644 backend/structs/weightkg.go create mode 100644 backend/structs/weightkg_test.go diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 000000000..5f826ddbd --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,13 @@ +# Backend Makefile. + +.DEFAULT_GOAL := run + +# Compile and launch the backend server in local development mode. +.PHONY: run +run: + go run backend local + +# Execute backend unit tests. +.PHONY: test +test: + go test ./... diff --git a/backend/dbtools/dbtools_test.go b/backend/dbtools/dbtools_test.go index c98a92b32..a977a6742 100644 --- a/backend/dbtools/dbtools_test.go +++ b/backend/dbtools/dbtools_test.go @@ -57,7 +57,11 @@ func TestFilter(t *testing.T) { { name: "FilterByFederation", args: args{ - bigData: []structs.Entry{{Date: "2023-06-01", Name: "John Smith", Total: 100, Federation: "BWL", Gender: enum.Male, Bodyweight: 109.00}, {Date: "2023-06-01", Name: "Dave Smith", Total: 200, Federation: "BWL", Gender: enum.Male, Bodyweight: 109.00}, {Date: "2023-06-01", Name: "Ethan Smith", Total: 300, Federation: "BWL", Gender: enum.Male, Bodyweight: 109.00}}, + bigData: []structs.Entry{ + {Date: "2023-06-01", Name: "John Smith", Total: structs.NewWeightKg(100), Federation: "BWL", Gender: enum.Male, Bodyweight: structs.NewWeightKg(109.00)}, + {Date: "2023-06-01", Name: "Dave Smith", Total: structs.NewWeightKg(200), Federation: "BWL", Gender: enum.Male, Bodyweight: structs.NewWeightKg(109.00)}, + {Date: "2023-06-01", Name: "Ethan Smith", Total: structs.NewWeightKg(300), Federation: "BWL", Gender: enum.Male, Bodyweight: structs.NewWeightKg(109.00)}, + }, filterQuery: structs.LeaderboardPayload{ Start: 0, Stop: 10, @@ -72,7 +76,11 @@ func TestFilter(t *testing.T) { }, wantFilteredData: structs.LeaderboardResponse{ Size: 3, - Data: []structs.Entry{{Date: "2023-06-01", Name: "John Smith", Total: 100, Federation: "BWL", Gender: enum.Male, Bodyweight: 109.00}, {Date: "2023-06-01", Name: "Dave Smith", Total: 200, Federation: "BWL", Gender: enum.Male, Bodyweight: 109.00}, {Date: "2023-06-01", Name: "Ethan Smith", Total: 300, Federation: "BWL", Gender: enum.Male, Bodyweight: 109.00}}, + Data: []structs.Entry{ + {Date: "2023-06-01", Name: "John Smith", Total: structs.NewWeightKg(100), Federation: "BWL", Gender: enum.Male, Bodyweight: structs.NewWeightKg(109.00)}, + {Date: "2023-06-01", Name: "Dave Smith", Total: structs.NewWeightKg(200), Federation: "BWL", Gender: enum.Male, Bodyweight: structs.NewWeightKg(109.00)}, + {Date: "2023-06-01", Name: "Ethan Smith", Total: structs.NewWeightKg(300), Federation: "BWL", Gender: enum.Male, Bodyweight: structs.NewWeightKg(109.00)}, + }, }, }, } @@ -144,7 +152,7 @@ func TestSortLiftsBy(t *testing.T) { wantFinalData []structs.Entry }{ {name: "SortBySinclair", args: args{bigData: []structs.Entry{{Sinclair: 300}, {Sinclair: 100}, {Sinclair: 200}}, sortBy: enum.Sinclair}, wantFinalData: []structs.Entry{{Sinclair: 300}, {Sinclair: 200}, {Sinclair: 100}}}, - {name: "SortByTotal", args: args{bigData: []structs.Entry{{Total: 300}, {Total: 100}, {Total: 200}}, sortBy: enum.Total}, wantFinalData: []structs.Entry{{Total: 300}, {Total: 200}, {Total: 100}}}, + {name: "SortByTotal", args: args{bigData: []structs.Entry{{Total: structs.NewWeightKg(300)}, {Total: structs.NewWeightKg(100)}, {Total: structs.NewWeightKg(200)}}, sortBy: enum.Total}, wantFinalData: []structs.Entry{{Total: structs.NewWeightKg(300)}, {Total: structs.NewWeightKg(200)}, {Total: structs.NewWeightKg(100)}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -182,7 +190,15 @@ func TestSortTotal(t *testing.T) { name string args args }{ - {name: "NormalSort", args: args{sliceStructs: []structs.Entry{{Total: 300}, {Total: 100}, {Total: 200}}, wantedSlice: []structs.Entry{{Total: 100}, {Total: 200}, {Total: 300}}}}, + {name: "NormalSort", args: args{sliceStructs: []structs.Entry{ + {Total: structs.NewWeightKg(300)}, + {Total: structs.NewWeightKg(100)}, + {Total: structs.NewWeightKg(200)}, + }, wantedSlice: []structs.Entry{ + {Total: structs.NewWeightKg(100)}, + {Total: structs.NewWeightKg(200)}, + {Total: structs.NewWeightKg(300)}, + }}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -208,16 +224,16 @@ func Test_assignStruct(t *testing.T) { Date: "2017-10-01", Gender: "Men's Under 23 94Kg", Name: "Edmon avetisyan", - Bodyweight: 93.8, - Sn1: -146, - Sn2: 150, - Sn3: -156, - CJ1: 180, - CJ2: -190, - CJ3: -192, - BestSn: 150, - BestCJ: 180, - Total: 330, + Bodyweight: structs.NewWeightKg(93.8), + Sn1: structs.NewWeightKg(-146), + Sn2: structs.NewWeightKg(150), + Sn3: structs.NewWeightKg(-156), + CJ1: structs.NewWeightKg(180), + CJ2: structs.NewWeightKg(-190), + CJ3: structs.NewWeightKg(-192), + BestSn: structs.NewWeightKg(150), + BestCJ: structs.NewWeightKg(180), + Total: structs.NewWeightKg(330), Sinclair: 0, Federation: "BWL", Instagram: "", diff --git a/backend/dbtools/sortby.go b/backend/dbtools/sortby.go index 41ef4cc82..ec38f1272 100644 --- a/backend/dbtools/sortby.go +++ b/backend/dbtools/sortby.go @@ -90,7 +90,7 @@ func SortSinclair(sliceStructs []structs.Entry) { // SortTotal Descending order by entry total func SortTotal(sliceStructs []structs.Entry) { sort.Slice(sliceStructs, func(i, j int) bool { - return sliceStructs[i].Total > sliceStructs[j].Total + return sliceStructs[i].Total.GreaterThan(sliceStructs[j].Total) }) } diff --git a/backend/dbtools/sortgender.go b/backend/dbtools/sortgender.go index 8b0aba7e7..881985e80 100644 --- a/backend/dbtools/sortgender.go +++ b/backend/dbtools/sortgender.go @@ -4,32 +4,40 @@ import ( "backend/enum" "backend/sinclair" "backend/structs" - "backend/utilities" "log" "strings" ) // ParseData Splits results into 3 categories, male, female, and unknown. func ParseData(bigData [][]string) (allLifts structs.AllData, unknown structs.AllData) { + max_total := structs.NewWeightKg(float64(enum.MaxTotal)) + min_bodyweight := structs.NewWeightKg(float64(enum.MinimumBodyweight)) + for _, contents := range bigData { dataStruct, valid := assignStruct(contents) - if valid { - gender := getGender(&dataStruct) - switch gender { - case enum.Male: - if dataStruct.Total > 0 && dataStruct.Total < enum.MaxTotal && dataStruct.Bodyweight > enum.MinimumBodyweight { - // todo: add in error handling for CalcSinclair - sinclair.CalcSinclair(&dataStruct, true) - } - allLifts.Lifts = append(allLifts.Lifts, dataStruct) - case enum.Female: - if dataStruct.Total > 0 && dataStruct.Total < enum.MaxTotal && dataStruct.Bodyweight > enum.MinimumBodyweight { - sinclair.CalcSinclair(&dataStruct, false) - } - allLifts.Lifts = append(allLifts.Lifts, dataStruct) - case enum.Unknown: - unknown.Lifts = append(unknown.Lifts, dataStruct) + if !valid { + continue + } + + gender := getGender(&dataStruct) + switch gender { + case enum.Male: + if dataStruct.Total.IsPositive() && + dataStruct.Total.LessThan(max_total) && + dataStruct.Bodyweight.GreaterThan(min_bodyweight) { + // todo: add in error handling for CalcSinclair + sinclair.CalcSinclair(&dataStruct, true) + } + allLifts.Lifts = append(allLifts.Lifts, dataStruct) + case enum.Female: + if dataStruct.Total.IsPositive() && + dataStruct.Total.LessThan(max_total) && + dataStruct.Bodyweight.GreaterThan(min_bodyweight) { + sinclair.CalcSinclair(&dataStruct, false) } + allLifts.Lifts = append(allLifts.Lifts, dataStruct) + case enum.Unknown: + unknown.Lifts = append(unknown.Lifts, dataStruct) } } return @@ -60,16 +68,16 @@ func assignStruct(line []string) (lineStruct structs.Entry, valid bool) { Date: line[1], Gender: line[2], Name: line[3], - Bodyweight: utilities.Float(line[4]), - Sn1: utilities.Float(line[5]), - Sn2: utilities.Float(line[6]), - Sn3: utilities.Float(line[7]), - CJ1: utilities.Float(line[8]), - CJ2: utilities.Float(line[9]), - CJ3: utilities.Float(line[10]), - BestSn: utilities.Float(line[11]), - BestCJ: utilities.Float(line[12]), - Total: utilities.Float(line[13]), + Bodyweight: structs.NewWeightKgFromString(line[4]), + Sn1: structs.NewWeightKgFromString(line[5]), + Sn2: structs.NewWeightKgFromString(line[6]), + Sn3: structs.NewWeightKgFromString(line[7]), + CJ1: structs.NewWeightKgFromString(line[8]), + CJ2: structs.NewWeightKgFromString(line[9]), + CJ3: structs.NewWeightKgFromString(line[10]), + BestSn: structs.NewWeightKgFromString(line[11]), + BestCJ: structs.NewWeightKgFromString(line[12]), + Total: structs.NewWeightKgFromString(line[13]), Sinclair: 0.0, Federation: line[14], } diff --git a/backend/dbtools/weightcats.go b/backend/dbtools/weightcats.go index c914c0b7e..058165d35 100644 --- a/backend/dbtools/weightcats.go +++ b/backend/dbtools/weightcats.go @@ -6,26 +6,26 @@ import ( ) var WeightClassList = map[string]structs.WeightClass{ - "MALL": {Gender: enum.Male, Upper: enum.MaximumBodyweight, Lower: 0}, - "M55": {Gender: enum.Male, Upper: 55.00, Lower: 0}, - "M61": {Gender: enum.Male, Upper: 61.00, Lower: 55.01}, - "M67": {Gender: enum.Male, Upper: 67.00, Lower: 61.01}, - "M73": {Gender: enum.Male, Upper: 73.00, Lower: 67.01}, - "M81": {Gender: enum.Male, Upper: 81.00, Lower: 73.01}, - "M89": {Gender: enum.Male, Upper: 89.00, Lower: 81.01}, - "M96": {Gender: enum.Male, Upper: 96.00, Lower: 89.01}, - "M102": {Gender: enum.Male, Upper: 102.00, Lower: 96.01}, - "M109": {Gender: enum.Male, Upper: 109.00, Lower: 102.01}, - "M109+": {Gender: enum.Male, Upper: enum.MaximumBodyweight, Lower: 109.01}, - "FALL": {Gender: enum.Female, Upper: enum.MaximumBodyweight, Lower: 0}, - "F45": {Gender: enum.Female, Upper: 45.00, Lower: 0}, - "F49": {Gender: enum.Female, Upper: 49.00, Lower: 45.01}, - "F55": {Gender: enum.Female, Upper: 55.00, Lower: 49.01}, - "F59": {Gender: enum.Female, Upper: 59.00, Lower: 55.01}, - "F64": {Gender: enum.Female, Upper: 64.00, Lower: 59.01}, - "F71": {Gender: enum.Female, Upper: 71.00, Lower: 64.01}, - "F76": {Gender: enum.Female, Upper: 76.00, Lower: 71.01}, - "F81": {Gender: enum.Female, Upper: 81.00, Lower: 76.01}, - "F87": {Gender: enum.Female, Upper: 87.00, Lower: 81.01}, - "F87+": {Gender: enum.Female, Upper: enum.MaximumBodyweight, Lower: 87.01}, + "MALL": {Gender: enum.Male, Upper: structs.NewWeightKg(float64(enum.MaximumBodyweight)), Lower: structs.NewWeightKg(0)}, + "M55": {Gender: enum.Male, Upper: structs.NewWeightKg(55.00), Lower: structs.NewWeightKg(0)}, + "M61": {Gender: enum.Male, Upper: structs.NewWeightKg(61.00), Lower: structs.NewWeightKg(55.01)}, + "M67": {Gender: enum.Male, Upper: structs.NewWeightKg(67.00), Lower: structs.NewWeightKg(61.01)}, + "M73": {Gender: enum.Male, Upper: structs.NewWeightKg(73.00), Lower: structs.NewWeightKg(67.01)}, + "M81": {Gender: enum.Male, Upper: structs.NewWeightKg(81.00), Lower: structs.NewWeightKg(73.01)}, + "M89": {Gender: enum.Male, Upper: structs.NewWeightKg(89.00), Lower: structs.NewWeightKg(81.01)}, + "M96": {Gender: enum.Male, Upper: structs.NewWeightKg(96.00), Lower: structs.NewWeightKg(89.01)}, + "M102": {Gender: enum.Male, Upper: structs.NewWeightKg(102.00), Lower: structs.NewWeightKg(96.01)}, + "M109": {Gender: enum.Male, Upper: structs.NewWeightKg(109.00), Lower: structs.NewWeightKg(102.01)}, + "M109+": {Gender: enum.Male, Upper: structs.NewWeightKg(float64(enum.MaximumBodyweight)), Lower: structs.NewWeightKg(109.01)}, + "FALL": {Gender: enum.Female, Upper: structs.NewWeightKg(float64(enum.MaximumBodyweight)), Lower: structs.NewWeightKg(0)}, + "F45": {Gender: enum.Female, Upper: structs.NewWeightKg(45.00), Lower: structs.NewWeightKg(0)}, + "F49": {Gender: enum.Female, Upper: structs.NewWeightKg(49.00), Lower: structs.NewWeightKg(45.01)}, + "F55": {Gender: enum.Female, Upper: structs.NewWeightKg(55.00), Lower: structs.NewWeightKg(49.01)}, + "F59": {Gender: enum.Female, Upper: structs.NewWeightKg(59.00), Lower: structs.NewWeightKg(55.01)}, + "F64": {Gender: enum.Female, Upper: structs.NewWeightKg(64.00), Lower: structs.NewWeightKg(59.01)}, + "F71": {Gender: enum.Female, Upper: structs.NewWeightKg(71.00), Lower: structs.NewWeightKg(64.01)}, + "F76": {Gender: enum.Female, Upper: structs.NewWeightKg(76.00), Lower: structs.NewWeightKg(71.01)}, + "F81": {Gender: enum.Female, Upper: structs.NewWeightKg(81.00), Lower: structs.NewWeightKg(76.01)}, + "F87": {Gender: enum.Female, Upper: structs.NewWeightKg(87.00), Lower: structs.NewWeightKg(81.01)}, + "F87+": {Gender: enum.Female, Upper: structs.NewWeightKg(float64(enum.MaximumBodyweight)), Lower: structs.NewWeightKg(87.01)}, } diff --git a/backend/lifter/search_test.go b/backend/lifter/search_test.go index 810149444..1a35b09f4 100644 --- a/backend/lifter/search_test.go +++ b/backend/lifter/search_test.go @@ -9,13 +9,13 @@ import ( // todo: add more details to allow more strict testing var sampleLeaderboardData = &structs.LeaderboardData{ AllTotals: []structs.Entry{ - {Name: "John Smith", Total: 123}, - {Name: "john smith", Total: 234}, - {Name: "John smoth", Total: 345}, - {Name: "Joanne Smith", Total: 123}, - {Name: "joanne smith", Total: 234}, - {Name: "joanne smith", Total: 235}, - {Name: "joanne Smoth", Total: 345}, + {Name: "John Smith", Total: structs.NewWeightKg(123)}, + {Name: "john smith", Total: structs.NewWeightKg(234)}, + {Name: "John smoth", Total: structs.NewWeightKg(345)}, + {Name: "Joanne Smith", Total: structs.NewWeightKg(123)}, + {Name: "joanne smith", Total: structs.NewWeightKg(234)}, + {Name: "joanne smith", Total: structs.NewWeightKg(235)}, + {Name: "joanne Smoth", Total: structs.NewWeightKg(345)}, }, } diff --git a/backend/sinclair/sinclair.go b/backend/sinclair/sinclair.go index 0701e91f8..43c36da75 100644 --- a/backend/sinclair/sinclair.go +++ b/backend/sinclair/sinclair.go @@ -19,25 +19,39 @@ const ( // the Masters coefficient is absolute nonsense. You'll see there's a lot of switching between float types. // It's frustrating but it serves a purpose. func CalcSinclair(result *structs.Entry, male bool) { + // Fast path: a zero or negative total has a zero Sinclair. + if !result.Total.IsPositive() { + result.Sinclair = 0 + return + } + + // A bodyweight below the cutoff also receives a zero score. + if result.Bodyweight.LessThanOrEqual(structs.NewWeightKgFromInt32(minBW)) { + result.Sinclair = 0 + return + } + var coEffA = aMale var coEffB = bMale if !male { coEffA = aFemale coEffB = bFemale } + + total := result.Total.Float64() + bodyweight := result.Bodyweight.Float64() + // todo: add in error handling - if result.Total != 0 && result.Bodyweight > minBW { - if float64(result.Bodyweight) <= coEffB { - var X = math.Log10(float64(result.Bodyweight) / coEffB) - var expX = math.Pow(X, 2) - var coEffExp = coEffA * expX - var expSum = math.Pow(10, coEffExp) - var sinclair = float32(float64(result.Total) * expSum) - if sinclair <= naimSinclair { - result.Sinclair = sinclair - } - } else if result.Total <= naimSinclair { - result.Sinclair = result.Total + if bodyweight <= coEffB { + var X = math.Log10(bodyweight / coEffB) + var expX = math.Pow(X, 2) + var coEffExp = coEffA * expX + var expSum = math.Pow(10, coEffExp) + var sinclair = float32(total * expSum) + if sinclair <= naimSinclair { + result.Sinclair = sinclair } + } else if total <= naimSinclair { + result.Sinclair = float32(total) } } diff --git a/backend/sinclair/sinclair_test.go b/backend/sinclair/sinclair_test.go index dacd36862..19f81d6f1 100644 --- a/backend/sinclair/sinclair_test.go +++ b/backend/sinclair/sinclair_test.go @@ -17,32 +17,32 @@ func TestCalcSinclair(t *testing.T) { }{ { name: "NormalSinclairMale", - args: args{result: &structs.Entry{Bodyweight: 81, Total: 235, Sinclair: 0}, male: true}, + args: args{result: &structs.Entry{Bodyweight: structs.NewWeightKg(81), Total: structs.NewWeightKg(235), Sinclair: 0}, male: true}, expectedSinclair: 285.66986, }, { name: "Over-rangeSinclairMale", - args: args{result: &structs.Entry{Bodyweight: 160, Total: 510, Sinclair: 0}, male: true}, + args: args{result: &structs.Entry{Bodyweight: structs.NewWeightKg(160), Total: structs.NewWeightKg(510), Sinclair: 0}, male: true}, expectedSinclair: 0, }, { name: "NormalSinclairFemale", - args: args{result: &structs.Entry{Bodyweight: 81, Total: 235, Sinclair: 0}, male: false}, + args: args{result: &structs.Entry{Bodyweight: structs.NewWeightKg(81), Total: structs.NewWeightKg(235), Sinclair: 0}, male: false}, expectedSinclair: 270.17587, }, { name: "Over-rangeSinclairFemale", - args: args{result: &structs.Entry{Bodyweight: 160, Total: 510, Sinclair: 0}, male: false}, + args: args{result: &structs.Entry{Bodyweight: structs.NewWeightKg(160), Total: structs.NewWeightKg(510), Sinclair: 0}, male: false}, expectedSinclair: 0, }, { name: "SuperHeavySinclairMale", - args: args{result: &structs.Entry{Bodyweight: 200, Total: 400, Sinclair: 0}, male: true}, + args: args{result: &structs.Entry{Bodyweight: structs.NewWeightKg(200), Total: structs.NewWeightKg(400), Sinclair: 0}, male: true}, expectedSinclair: 400, }, { name: "SuperHeavySinclairFemale", - args: args{result: &structs.Entry{Bodyweight: 200, Total: 400, Sinclair: 0}, male: false}, + args: args{result: &structs.Entry{Bodyweight: structs.NewWeightKg(200), Total: structs.NewWeightKg(400), Sinclair: 0}, male: false}, expectedSinclair: 400, }, } diff --git a/backend/structs/helpers.go b/backend/structs/helpers.go index 018373dcf..84a4eb9fc 100644 --- a/backend/structs/helpers.go +++ b/backend/structs/helpers.go @@ -7,19 +7,19 @@ func IterateFloatSlice(data []Entry, item string) (floatSl []float32) { switch item { case enum.Total: for _, lift := range data { - floatSl = append(floatSl, lift.Total) + floatSl = append(floatSl, lift.Total.Float32()) } case enum.BestSnatch: for _, lift := range data { - floatSl = append(floatSl, lift.BestSn) + floatSl = append(floatSl, lift.BestSn.Float32()) } case enum.BestCJ: for _, lift := range data { - floatSl = append(floatSl, lift.BestCJ) + floatSl = append(floatSl, lift.BestCJ.Float32()) } case enum.Bodyweight: for _, lift := range data { - floatSl = append(floatSl, lift.Bodyweight) + floatSl = append(floatSl, lift.Bodyweight.Float32()) } } return diff --git a/backend/structs/helpers_test.go b/backend/structs/helpers_test.go index 48c94b14c..29993e0d3 100644 --- a/backend/structs/helpers_test.go +++ b/backend/structs/helpers_test.go @@ -16,11 +16,11 @@ func TestIterateFloatSlice(t *testing.T) { args args wantFloatSl []float32 }{ - {name: "IterateTotals", args: args{data: []Entry{{Total: 1}, {Total: 2}, {Total: 3}}, item: enum.Total}, wantFloatSl: []float32{1, 2, 3}}, - {name: "IterateBestSnatch", args: args{data: []Entry{{BestSn: 1}, {BestSn: 2}, {BestSn: 3}}, item: enum.BestSnatch}, wantFloatSl: []float32{1, 2, 3}}, - {name: "IterateBestCJ", args: args{data: []Entry{{BestCJ: 1}, {BestCJ: 2}, {BestCJ: 3}}, item: enum.BestCJ}, wantFloatSl: []float32{1, 2, 3}}, - {name: "IterateBodyweight", args: args{data: []Entry{{Bodyweight: 1}, {Bodyweight: 2}, {Bodyweight: 3}}, item: enum.Bodyweight}, wantFloatSl: []float32{1, 2, 3}}, - {name: "IterateNothing", args: args{data: []Entry{{Bodyweight: 1}, {Bodyweight: 2}, {Bodyweight: 3}}, item: ""}, wantFloatSl: nil}, + {name: "IterateTotals", args: args{data: []Entry{{Total: NewWeightKg(1)}, {Total: NewWeightKg(2)}, {Total: NewWeightKg(3)}}, item: enum.Total}, wantFloatSl: []float32{1, 2, 3}}, + {name: "IterateBestSnatch", args: args{data: []Entry{{BestSn: NewWeightKg(1)}, {BestSn: NewWeightKg(2)}, {BestSn: NewWeightKg(3)}}, item: enum.BestSnatch}, wantFloatSl: []float32{1, 2, 3}}, + {name: "IterateBestCJ", args: args{data: []Entry{{BestCJ: NewWeightKg(1)}, {BestCJ: NewWeightKg(2)}, {BestCJ: NewWeightKg(3)}}, item: enum.BestCJ}, wantFloatSl: []float32{1, 2, 3}}, + {name: "IterateBodyweight", args: args{data: []Entry{{Bodyweight: NewWeightKg(1)}, {Bodyweight: NewWeightKg(2)}, {Bodyweight: NewWeightKg(3)}}, item: enum.Bodyweight}, wantFloatSl: []float32{1, 2, 3}}, + {name: "IterateNothing", args: args{data: []Entry{{Bodyweight: NewWeightKg(1)}, {Bodyweight: NewWeightKg(2)}, {Bodyweight: NewWeightKg(3)}}, item: ""}, wantFloatSl: nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/backend/structs/struct_funcs.go b/backend/structs/struct_funcs.go index b36d789f0..b56917649 100644 --- a/backend/structs/struct_funcs.go +++ b/backend/structs/struct_funcs.go @@ -49,31 +49,31 @@ func (e LifterHistory) MakeRates(lift string) (makeRates []int) { switch lift { case enum.Snatch: for _, entry := range e.Lifts { - if entry.Sn1 > 0 { + if entry.Sn1.IsPositive() { makemiss[0]++ } - if entry.Sn2 > 0 { + if entry.Sn2.IsPositive() { makemiss[1]++ } - if entry.Sn3 > 0 { + if entry.Sn3.IsPositive() { makemiss[2]++ } - if entry.Sn1 != 0 || entry.Sn2 != 0 || entry.Sn3 != 0 { + if !entry.Sn1.IsZero() || !entry.Sn2.IsZero() || !entry.Sn3.IsZero() { numberOfLifts++ } } case enum.CleanAndJerk: for _, entry := range e.Lifts { - if entry.CJ1 > 0 { + if entry.CJ1.IsPositive() { makemiss[0]++ } - if entry.CJ2 > 0 { + if entry.CJ2.IsPositive() { makemiss[1]++ } - if entry.CJ3 > 0 { + if entry.CJ3.IsPositive() { makemiss[2]++ } - if entry.CJ1 != 0 || entry.CJ2 != 0 || entry.CJ3 != 0 { + if !entry.CJ1.IsZero() || !entry.CJ2.IsZero() || !entry.CJ3.IsZero() { numberOfLifts++ } } @@ -85,26 +85,20 @@ func (e LifterHistory) MakeRates(lift string) (makeRates []int) { return } -func (e LifterHistory) BestLift(lift string) float32 { - var bestLift float32 +func (e LifterHistory) BestLift(lift string) WeightKg { + var bestLift WeightKg switch lift { case enum.Snatch: for _, entry := range e.Lifts { - if entry.BestSn > bestLift { - bestLift = entry.BestSn - } + bestLift = bestLift.Max(entry.BestSn) } case enum.CleanAndJerk: for _, entry := range e.Lifts { - if entry.BestCJ > bestLift { - bestLift = entry.BestCJ - } + bestLift = bestLift.Max(entry.BestCJ) } case enum.Total: for _, entry := range e.Lifts { - if entry.Total > bestLift { - bestLift = entry.Total - } + bestLift = bestLift.Max(entry.Total) } } return bestLift @@ -114,7 +108,7 @@ func (e Entry) WithinWeightClass(gender string, catData WeightClass) bool { if catData.Gender == enum.ALLCATS { return true } - if catData.Gender == gender && catData.Upper >= e.Bodyweight && catData.Lower <= e.Bodyweight { + if catData.Gender == gender && catData.Upper.GreaterThanOrEqual(e.Bodyweight) && catData.Lower.LessThanOrEqual(e.Bodyweight) { return true } return false diff --git a/backend/structs/struct_funcs_test.go b/backend/structs/struct_funcs_test.go index adca9d156..323f862ca 100644 --- a/backend/structs/struct_funcs_test.go +++ b/backend/structs/struct_funcs_test.go @@ -28,7 +28,7 @@ func TestAllData_ProcessNames(t *testing.T) { func TestEntry_WithinWeightClass(t *testing.T) { sampleEntry := Entry{ Gender: enum.Male, - Bodyweight: 100, + Bodyweight: NewWeightKg(100), } type args struct { gender string @@ -43,8 +43,8 @@ func TestEntry_WithinWeightClass(t *testing.T) { gender: enum.Male, catData: WeightClass{ Gender: enum.Male, - Upper: 101, - Lower: 99, + Upper: NewWeightKg(101), + Lower: NewWeightKg(99), }}, want: true, }, @@ -52,8 +52,8 @@ func TestEntry_WithinWeightClass(t *testing.T) { gender: enum.Male, catData: WeightClass{ Gender: enum.ALLCATS, - Upper: 101, - Lower: 99, + Upper: NewWeightKg(101), + Lower: NewWeightKg(99), }}, want: true, }, @@ -61,8 +61,8 @@ func TestEntry_WithinWeightClass(t *testing.T) { gender: enum.Male, catData: WeightClass{ Gender: enum.Male, - Upper: 99, - Lower: 98, + Upper: NewWeightKg(99), + Lower: NewWeightKg(98), }}, want: false, }, @@ -218,24 +218,24 @@ func TestLifterHistory_GenerateChartData(t *testing.T) { Lifts: []Entry{ { Date: "2020-01-01", - Total: 100, - BestSn: 40, - BestCJ: 60, - Bodyweight: 50, + Total: NewWeightKg(100), + BestSn: NewWeightKg(40), + BestCJ: NewWeightKg(60), + Bodyweight: NewWeightKg(50), }, { Date: "2020-01-02", - Total: 200, - BestSn: 80, - BestCJ: 120, - Bodyweight: 100, + Total: NewWeightKg(200), + BestSn: NewWeightKg(80), + BestCJ: NewWeightKg(120), + Bodyweight: NewWeightKg(100), }, { Date: "2020-01-03", - Total: 300, - BestSn: 120, - BestCJ: 180, - Bodyweight: 150, + Total: NewWeightKg(300), + BestSn: NewWeightKg(120), + BestCJ: NewWeightKg(180), + Bodyweight: NewWeightKg(150), }, }, } @@ -277,11 +277,11 @@ func TestLifterHistory_GenerateChartData(t *testing.T) { func TestLifterHistory_MakeRates(t *testing.T) { sampleLifterHistory := LifterHistory{ Lifts: []Entry{ - {Sn1: 55, Sn2: 60, Sn3: 70, CJ1: 80, CJ2: -85, CJ3: -85, BestSn: 70, BestCJ: 80}, - {Sn1: -55, Sn2: 55, Sn3: -60, CJ1: 80, CJ2: -85, CJ3: 85, BestSn: 55, BestCJ: 85}, - {Sn1: -60, Sn2: 61, Sn3: -65, CJ1: 80, CJ2: -85, CJ3: -85, BestSn: 61, BestCJ: 80}, - {Sn1: 58, Sn2: 61, Sn3: -63, CJ1: 80, CJ2: -85, CJ3: 90, BestSn: 61, BestCJ: 90}, - {Sn1: 0, Sn2: 0, Sn3: 0, CJ1: 0, CJ2: 0, CJ3: 0, BestSn: 0, BestCJ: 0}, + {Sn1: NewWeightKg(55), Sn2: NewWeightKg(60), Sn3: NewWeightKg(70), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(-85), BestSn: NewWeightKg(70), BestCJ: NewWeightKg(80)}, + {Sn1: NewWeightKg(-55), Sn2: NewWeightKg(55), Sn3: NewWeightKg(-60), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(85), BestSn: NewWeightKg(55), BestCJ: NewWeightKg(85)}, + {Sn1: NewWeightKg(-60), Sn2: NewWeightKg(61), Sn3: NewWeightKg(-65), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(-85), BestSn: NewWeightKg(61), BestCJ: NewWeightKg(80)}, + {Sn1: NewWeightKg(58), Sn2: NewWeightKg(61), Sn3: NewWeightKg(-63), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(90), BestSn: NewWeightKg(61), BestCJ: NewWeightKg(90)}, + {Sn1: NewWeightKg(0), Sn2: NewWeightKg(0), Sn3: NewWeightKg(0), CJ1: NewWeightKg(0), CJ2: NewWeightKg(0), CJ3: NewWeightKg(0), BestSn: NewWeightKg(0), BestCJ: NewWeightKg(0)}, }, } tests := []struct { @@ -303,19 +303,19 @@ func TestLifterHistory_MakeRates(t *testing.T) { func TestLifterHistory_BestLift(t *testing.T) { sampleLifterHistory := LifterHistory{ Lifts: []Entry{ - {Sn1: 55, Sn2: 60, Sn3: 70, CJ1: 80, CJ2: -85, CJ3: -85, BestSn: 70, BestCJ: 80, Total: 150}, - {Sn1: -55, Sn2: 55, Sn3: -60, CJ1: 80, CJ2: -85, CJ3: 85, BestSn: 55, BestCJ: 85, Total: 140}, - {Sn1: -60, Sn2: 61, Sn3: -65, CJ1: 80, CJ2: -85, CJ3: -85, BestSn: 61, BestCJ: 80, Total: 141}, - {Sn1: 58, Sn2: 61, Sn3: -63, CJ1: 80, CJ2: -85, CJ3: 90, BestSn: 61, BestCJ: 90, Total: 151}, + {Sn1: NewWeightKg(55), Sn2: NewWeightKg(60), Sn3: NewWeightKg(70), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(-85), BestSn: NewWeightKg(70), BestCJ: NewWeightKg(80), Total: NewWeightKg(150)}, + {Sn1: NewWeightKg(-55), Sn2: NewWeightKg(55), Sn3: NewWeightKg(-60), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(85), BestSn: NewWeightKg(55), BestCJ: NewWeightKg(85), Total: NewWeightKg(140)}, + {Sn1: NewWeightKg(-60), Sn2: NewWeightKg(61), Sn3: NewWeightKg(-65), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(-85), BestSn: NewWeightKg(61), BestCJ: NewWeightKg(80), Total: NewWeightKg(141)}, + {Sn1: NewWeightKg(58), Sn2: NewWeightKg(61), Sn3: NewWeightKg(-63), CJ1: NewWeightKg(80), CJ2: NewWeightKg(-85), CJ3: NewWeightKg(90), BestSn: NewWeightKg(61), BestCJ: NewWeightKg(90), Total: NewWeightKg(151)}, }, } tests := []struct { name string - want float32 + want WeightKg }{ - {name: enum.Snatch, want: 70}, - {name: enum.CleanAndJerk, want: 90}, - {name: enum.Total, want: 151}, + {name: enum.Snatch, want: NewWeightKg(70)}, + {name: enum.CleanAndJerk, want: NewWeightKg(90)}, + {name: enum.Total, want: NewWeightKg(151)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/backend/structs/structs.go b/backend/structs/structs.go index 61ca0f225..5271ce031 100644 --- a/backend/structs/structs.go +++ b/backend/structs/structs.go @@ -2,8 +2,8 @@ package structs type WeightClass struct { Gender string - Upper float32 - Lower float32 + Upper WeightKg + Lower WeightKg } // swagger:response ContainerTime @@ -52,11 +52,11 @@ type LifterHistory struct { } type LifterStats struct { - BestSnatch float32 `json:"best_snatch"` - BestCJ float32 `json:"best_cj"` - BestTotal float32 `json:"best_total"` - MakeRateSnatches []int `json:"make_rate_snatches"` - MakeRateCJ []int `json:"make_rate_cj"` + BestSnatch WeightKg `json:"best_snatch"` + BestCJ WeightKg `json:"best_cj"` + BestTotal WeightKg `json:"best_total"` + MakeRateSnatches []int `json:"make_rate_snatches"` + MakeRateCJ []int `json:"make_rate_cj"` } type LeaderboardData struct { @@ -79,23 +79,23 @@ type LeaderboardPayload struct { // Entry Standard structs that we'll use for storing raw lift data // swagger:response Entry type Entry struct { - Event string `json:"event"` - Date string `json:"date"` - Gender string `json:"gender"` - Name string `json:"lifter_name"` - Bodyweight float32 `json:"bodyweight"` - Sn1 float32 `json:"snatch_1"` - Sn2 float32 `json:"snatch_2"` - Sn3 float32 `json:"snatch_3"` - CJ1 float32 `json:"cj_1"` - CJ2 float32 `json:"cj_2"` - CJ3 float32 `json:"cj_3"` - BestSn float32 `json:"best_snatch"` - BestCJ float32 `json:"best_cj"` - Total float32 `json:"total"` - Sinclair float32 `json:"sinclair"` - Federation string `json:"country"` - Instagram string `json:"instagram"` + Event string `json:"event"` + Date string `json:"date"` + Gender string `json:"gender"` + Name string `json:"lifter_name"` + Bodyweight WeightKg `json:"bodyweight"` + Sn1 WeightKg `json:"snatch_1"` + Sn2 WeightKg `json:"snatch_2"` + Sn3 WeightKg `json:"snatch_3"` + CJ1 WeightKg `json:"cj_1"` + CJ2 WeightKg `json:"cj_2"` + CJ3 WeightKg `json:"cj_3"` + BestSn WeightKg `json:"best_snatch"` + BestCJ WeightKg `json:"best_cj"` + Total WeightKg `json:"total"` + Sinclair float32 `json:"sinclair"` + Federation string `json:"country"` + Instagram string `json:"instagram"` } // swagger:response LeaderboardResponse diff --git a/backend/structs/weightkg.go b/backend/structs/weightkg.go new file mode 100644 index 000000000..3923aa5b5 --- /dev/null +++ b/backend/structs/weightkg.go @@ -0,0 +1,181 @@ +// A Go port of OpenPowerlifting's WeightKg type. + +package structs + +import ( + "fmt" + "math" + "strconv" +) + +// A weight in kilograms represented as a fixed-point integer. +// The integer representation holds two decimal places, such that +// the floating-point value "123.45" is stored as `12345`. Values +// that cannot be exactly represented round toward zero. +type WeightKg struct { + value int32 +} + +// Returns a new WeightKg from a floating-point value. +// Values that cannot be exactly represented round toward zero. +// Infinite or NaN inputs are treated as zero. +func NewWeightKg(v float64) WeightKg { + if math.IsInf(v, 0) || math.IsNaN(v) { + return WeightKg{value: 0} + } + + is_signed := v < 0 // -0 is treated identically to 0. + v = math.Floor(math.Abs(v) * 100.0) // Shift two decimal places left and truncate. + + i := int32(v) + if is_signed { + i = -i + } + return WeightKg{value: i} +} + +// Returns a new WeightKg from a string value. +// Values that cannot be parsed return zero. +func NewWeightKgFromString(s string) WeightKg { + // Explicitly allow writing the empty string instead of zero. + if len(s) == 0 { + return WeightKg{0} + } + + // Otherwise, expect a floating-point value. + float, err := strconv.ParseFloat(s, 64) + if err != nil { + return WeightKg{0} + } + return NewWeightKg(float) +} + +// Returns a new WeightKg from an integer weight. +// This is mostly useful for values that are derived from enums. +func NewWeightKgFromInt32(i int32) WeightKg { + return WeightKg{i * 100} +} + +// Returns whether both weights are equal. +func (kg WeightKg) Equal(other WeightKg) bool { + return kg.value == other.value +} + +// Returns whether kg > other. +func (kg WeightKg) GreaterThan(other WeightKg) bool { + return kg.value > other.value +} + +// Returns whether kg >= other. +func (kg WeightKg) GreaterThanOrEqual(other WeightKg) bool { + return kg.value >= other.value +} + +// Returns whether kg < other. +func (kg WeightKg) LessThan(other WeightKg) bool { + return kg.value < other.value +} + +// Returns whether kg <= other. +func (kg WeightKg) LessThanOrEqual(other WeightKg) bool { + return kg.value <= other.value +} + +// Returns -1 if negative, 0 if zero, +1 if positive. +func (kg WeightKg) Sign() int { + if kg.value > 0 { + return 1 + } + if kg.value < 0 { + return -1 + } + return 0 +} + +func (kg WeightKg) IsPositive() bool { + return kg.value > 0 +} + +func (kg WeightKg) IsNegative() bool { + return kg.value < 0 +} + +// Returns whether the weight is zero. +func (kg WeightKg) IsZero() bool { + return kg.value == 0 +} + +// Returns the minimum of the two WeightKgs. +func (kg WeightKg) Min(other WeightKg) WeightKg { + if kg.LessThan(other) { + return kg + } + return other +} + +// Returns the maximum of the two WeightKgs. +func (kg WeightKg) Max(other WeightKg) WeightKg { + if kg.GreaterThan(other) { + return kg + } + return other +} + +// Returns the nearest float32 value. +func (kg WeightKg) Float32() float32 { + return float32(kg.value) / 100 +} + +// Returns the nearest float64 value. +func (kg WeightKg) Float64() float64 { + return float64(kg.value) / 100 +} + +// Renders the WeightKg as a string, looking like a floating-point number. +// Decimal places are rendered with as few zeros as possible. +// +// Examples: +// - input 123.00 returns "123". +// - input 123.40 returns "123.4". +// - input 123.45 returns "123.45" +func (kg WeightKg) String() string { + // Fast path for the common zero value. + if kg.value == 0 { + return "0" + } + + // For purposes of the later modulo, store a non-negative representation. + non_negative := kg.value + if non_negative < 0 { + non_negative = -non_negative + } + + integer := kg.value / 100 + fraction := non_negative % 100 + + // Render the integer component, which can include a negative sign. + acc := strconv.Itoa(int(integer)) + + // Inspect the remaining fractional component. + if fraction == 0 { + return acc // No fractional component, so return the rendered integer. + } + if fraction%10 == 0 { + return acc + "." + strconv.Itoa(int(fraction/10)) // Render "50" as ".5". + } + return acc + "." + fmt.Sprintf("%02d", fraction) // Render left-padded with '0' to two places. +} + +// JSON deserialization. +func (kg *WeightKg) UnmarshalJSON(bytes []byte) error { + if string(bytes) == "null" { + return nil + } + *kg = NewWeightKgFromString(string(bytes)) + return nil +} + +// JSON serialization. +func (kg WeightKg) MarshalJSON() ([]byte, error) { + return []byte(kg.String()), nil +} diff --git a/backend/structs/weightkg_test.go b/backend/structs/weightkg_test.go new file mode 100644 index 000000000..b6bf11355 --- /dev/null +++ b/backend/structs/weightkg_test.go @@ -0,0 +1,105 @@ +package structs + +import ( + "encoding/json" + "math" + "testing" +) + +type newCase struct { + input float64 // Testcase input as a float64. + expected int32 // The expected internal representation after conversion. +} + +// Tests that WeightKgNew properly converts float64 input values. +func TestWeightKg_New(t *testing.T) { + cases := []newCase{ + // Sanity checking. + {input: -0.0, expected: 0}, + {input: +0.0, expected: 0}, + {input: -1.0, expected: -100}, + {input: +1.0, expected: 100}, + {input: +123.45, expected: 12345}, + {input: -123.45, expected: -12345}, + + // Rounding cases. + {input: 1.0 / 3.0, expected: 33}, + {input: +123.4567, expected: 12345}, + {input: -123.4567, expected: -12345}, + + // Invalid inputs that still need to be handled. + {input: math.NaN(), expected: 0}, + {input: math.Inf(1), expected: 0}, + {input: math.Inf(-1), expected: 0}, + } + for _, c := range cases { + result := NewWeightKg(c.input).value + if result != c.expected { + t.Errorf("expected %d, got %d with input %f", c.expected, result, c.input) + } + } +} + +type stringCase struct { + input int32 // The internal representation held in WeightKg.value. + expected string // The expected output of WeightKg.String(). +} + +// Tests that WeightKg.String formats strings according to spec. +func TestWeightKg_String(t *testing.T) { + cases := []stringCase{ + {input: 0, expected: "0"}, + + // Decimal formatting with positive weights. + {input: 12300, expected: "123"}, + {input: 12340, expected: "123.4"}, + {input: 12345, expected: "123.45"}, + {input: 12305, expected: "123.05"}, + {input: 12005, expected: "120.05"}, + + // Decimal formatting with negative weights. + {input: -12300, expected: "-123"}, + {input: -12340, expected: "-123.4"}, + {input: -12345, expected: "-123.45"}, + {input: -12305, expected: "-123.05"}, + {input: -12005, expected: "-120.05"}, + } + for _, c := range cases { + kg := WeightKg{c.input} + result := kg.String() + if result != c.expected { + t.Errorf("expected %s, got %s with input %d", c.expected, result, c.input) + } + } +} + +type jsonTest struct { + MyKg WeightKg `json:"mykg"` +} + +// Tests that WeightKg behaves like a float64 when serialized/deserialized to/from JSON. +func TestWeightKg_Json(t *testing.T) { + data := jsonTest{NewWeightKg(123.45)} + + // Serialize to JSON. It should serialize as a float64. + jsonData, err := json.Marshal(&data) + if err != nil { + t.Fatalf("Failed marshaling WeightKg to JSON: %v", err) + } + + expected := `{"mykg":123.45}` + if string(jsonData) != expected { + t.Errorf("Error serializing: expected %s, got %s", expected, string(jsonData)) + } + + // Deserialize back from the serialized JSON. It should match the original. + var parsed jsonTest + err = json.Unmarshal(jsonData, &parsed) + if err != nil { + t.Fatalf("Failed unmarshaling WeightKg from JSON: %v", err) + } + + if data != parsed { + t.Errorf("Error deserializing: expected %+v, got %+v", data, parsed) + } +}