144 lines
3.3 KiB
Go
144 lines
3.3 KiB
Go
// Package elo implements a practical Elo rating update for head-to-head sports
|
||
// such as table soccer. This variant has:
|
||
// - No home/away asymmetry
|
||
// - Margin of victory (MoV) factor using a robust bounded form
|
||
// - Simple burn-in: higher K for the first N games
|
||
// - No draws (inputs must produce a clear winner)
|
||
// - Rating floor
|
||
// - Per-match delta cap
|
||
// - No time weighting and no explicit uncertainty modeling
|
||
|
||
package main
|
||
|
||
import (
|
||
"errors"
|
||
"math"
|
||
)
|
||
|
||
var eloCfg = Config{
|
||
Mu0: 1500,
|
||
Scale: 400,
|
||
KNew: 48,
|
||
KStd: 24,
|
||
BurnInGames: 20,
|
||
RatingFloor: 500,
|
||
MaxPerMatchDelta: 60,
|
||
MaxGoals: 10,
|
||
}
|
||
|
||
type Config struct {
|
||
Mu0 float64
|
||
Scale float64 // KNew applies to players during burn-in (first BurnInGames matches).
|
||
KNew float64
|
||
KStd float64
|
||
BurnInGames int
|
||
RatingFloor float64
|
||
MaxPerMatchDelta float64
|
||
MaxGoals int
|
||
}
|
||
|
||
type Team struct {
|
||
Players []Player
|
||
Score int
|
||
AverageRating float64
|
||
}
|
||
|
||
// Collect players by side
|
||
type Player struct {
|
||
u *User
|
||
side string
|
||
dElo float64
|
||
}
|
||
|
||
func NewTeam(players []Player, score int) *Team {
|
||
var t1s float64 = 0
|
||
for _, player := range players {
|
||
t1s += float64(player.u.Elo)
|
||
|
||
}
|
||
t1a := t1s / float64(len(players))
|
||
|
||
return &Team{Players: players, Score: score, AverageRating: t1a}
|
||
}
|
||
|
||
func GetNewElo(teams []*Team) error {
|
||
// Update the teams
|
||
t1 := teams[0]
|
||
t2 := teams[1]
|
||
if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals {
|
||
return errors.New("goals out of allowed range")
|
||
}
|
||
if t2.Score == t1.Score {
|
||
return errors.New("draws are not supported by this variant")
|
||
}
|
||
|
||
// Expected score (Bradley–Terry with logistic base-10)
|
||
for i, t := range teams {
|
||
otherTeam := teams[1-i]
|
||
for i, p := range t.Players {
|
||
newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
t.Players[i].u.Elo = roundFloat(newRating, 1)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64, err error) {
|
||
// Observed score (no draws)
|
||
var sA float64
|
||
if goalsA > goalsB {
|
||
sA = 1.0
|
||
} else {
|
||
sA = 0.0
|
||
}
|
||
|
||
// Margin of victory factor with clamped goal difference
|
||
diff := goalsA - goalsB
|
||
if diff < 0 {
|
||
diff = -diff
|
||
}
|
||
if diff < 1 {
|
||
diff = 1
|
||
}
|
||
if diff > eloCfg.MaxGoals {
|
||
diff = eloCfg.MaxGoals
|
||
}
|
||
|
||
// Raw delta before caps
|
||
eA := expected(eloCfg.Scale, rA, rB)
|
||
mov := movFactor(rA, rB, diff)
|
||
|
||
var Keff float64
|
||
if gamesA <= eloCfg.BurnInGames {
|
||
Keff = eloCfg.KNew
|
||
} else {
|
||
Keff = eloCfg.KStd
|
||
}
|
||
|
||
delta := Keff * mov * (sA - eA)
|
||
|
||
// Apply symmetric per-match cap
|
||
if delta > eloCfg.MaxPerMatchDelta {
|
||
delta = eloCfg.MaxPerMatchDelta
|
||
} else if delta < -eloCfg.MaxPerMatchDelta {
|
||
delta = -eloCfg.MaxPerMatchDelta
|
||
}
|
||
|
||
return rA + delta, nil
|
||
}
|
||
|
||
// expected returns the win probability for player A against B
|
||
func expected(scale, rA, rB float64) float64 {
|
||
return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale))
|
||
}
|
||
|
||
// movFactor returns a bounded margin-of-victory multiplier.
|
||
func movFactor(rA, rB float64, diff int) float64 {
|
||
fd := float64(diff)
|
||
return math.Log(fd+1.0) * 2.2 / (math.Abs(rA-rB)*0.001 + 2.2)
|
||
}
|