feat(football): implement World Cup 2026 simulator and Empirical-Poisson Mixture Model
Build and Push Docker Image / build (push) Successful in 3m38s
Build and Push Docker Image / build (push) Successful in 3m38s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# Binaries
|
||||
build/
|
||||
toolbox
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
+17
-12
@@ -13,6 +13,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"toolbox/pkg/base"
|
||||
_ "toolbox/pkg/football"
|
||||
_ "toolbox/pkg/learnnumber"
|
||||
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
||||
|
||||
@@ -89,18 +90,22 @@ func main() {
|
||||
keywords := "个人工具箱, 汉字字帖生成, 书法练习, 数字学习, Own-Tools"
|
||||
baseURL := "https://toolbox.pengzhan.dev"
|
||||
canonical := baseURL + path
|
||||
// 针对不同路径设置 SEO 标签
|
||||
switch path {
|
||||
case "/zitie":
|
||||
title = "汉字字帖生成器 - 教学方格与步进式分解"
|
||||
desc = "在线生成 2x3 教学方格字帖和 9 列步进式笔顺分解字帖,支持多种书法字体和古风排版。"
|
||||
keywords = "字帖生成, 笔顺分解, 汉字教学, 书法字帖, 练字"
|
||||
case "/learn-number":
|
||||
title = "趣味数字学习工具"
|
||||
desc = "为儿童设计的数字学习与计数练习工具,生动活泼,寓教于乐。"
|
||||
keywords = "数字学习, 儿童计数, 幼小衔接"
|
||||
}
|
||||
|
||||
// 针对不同路径设置 SEO 标签
|
||||
switch path {
|
||||
case "/zitie":
|
||||
title = "汉字字帖生成器 - 教学方格与步进式分解"
|
||||
desc = "在线生成 2x3 教学方格字帖和 9 列步进式笔顺分解字帖,支持多种书法字体和古风排版。"
|
||||
keywords = "字帖生成, 笔顺分解, 汉字教学, 书法字帖, 练字"
|
||||
case "/learn-number":
|
||||
title = "趣味数字学习工具"
|
||||
desc = "为儿童设计的数字学习与计数练习工具,生动活泼,寓教于乐。"
|
||||
keywords = "数字学习, 儿童计数, 幼小衔接"
|
||||
case "/football":
|
||||
title = "2026世界杯小组出线模拟器 - 蒙特卡洛预测"
|
||||
desc = "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。"
|
||||
keywords = "2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测"
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"Title": title,
|
||||
"Description": desc,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- Tools Panels Containers -->
|
||||
{{template "zitie" .}}
|
||||
{{template "learn_number" .}}
|
||||
{{template "football" .}}
|
||||
|
||||
<script>
|
||||
// 首页 Tile 渲染
|
||||
|
||||
@@ -157,8 +157,13 @@
|
||||
},
|
||||
'learn-number': {
|
||||
title: '趣味数字学习工具',
|
||||
desc: '为儿童设计的数字学习与计数练习工具,生动活泼,寓教于乐。',
|
||||
desc: '为儿童设计的数字学习与计数练习工具,生动活泼,寓教力乐。',
|
||||
keywords: '数字学习, 儿童计数, 幼小衔接'
|
||||
},
|
||||
'football': {
|
||||
title: '2026世界杯小组出线模拟器',
|
||||
desc: '使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。',
|
||||
keywords: '2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package logic
|
||||
|
||||
// Team represents a football team.
|
||||
type Team struct {
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group"` // e.g., "A", "B", ..., "L"
|
||||
}
|
||||
|
||||
// Match represents a group stage match between two teams.
|
||||
type Match struct {
|
||||
TeamA string `json:"team_a"`
|
||||
TeamB string `json:"team_b"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
ScoreA int `json:"score_a"`
|
||||
ScoreB int `json:"score_b"`
|
||||
}
|
||||
|
||||
// ScoreOutcome represents a possible match score and its probability.
|
||||
type ScoreOutcome struct {
|
||||
ScoreA int `json:"score_a"`
|
||||
ScoreB int `json:"score_b"`
|
||||
Probability float64 `json:"probability"`
|
||||
}
|
||||
|
||||
// TeamStats holds the metrics used to rank teams within a group or across third-place teams.
|
||||
type TeamStats struct {
|
||||
TeamName string
|
||||
Points int
|
||||
GoalDifference int
|
||||
GoalsScored int
|
||||
// Fields used for tie-breaking comparison within the group
|
||||
H2HPoints int
|
||||
H2HGD int
|
||||
H2HGS int
|
||||
}
|
||||
|
||||
// TeamProbability represents the calculated qualification probabilities for a single team.
|
||||
type TeamProbability struct {
|
||||
TeamName string `json:"team_name"`
|
||||
Group string `json:"group"`
|
||||
DirectQualProb float64 `json:"direct_qual_prob"`
|
||||
ThirdQualProb float64 `json:"third_qual_prob"`
|
||||
TotalQualProb float64 `json:"total_qual_prob"`
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RawScoreRecord represents the raw statistics for a scoreline in football history.
|
||||
type RawScoreRecord struct {
|
||||
GoalsA int `json:"goals_a"`
|
||||
GoalsB int `json:"goals_b"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// WorldCupRawScores represents the actual historical frequencies of scorelines
|
||||
// in the FIFA World Cup history (964 matches from 1930 to 2022).
|
||||
var WorldCupRawScores = []RawScoreRecord{
|
||||
{GoalsA: 1, GoalsB: 0, Count: 182},
|
||||
{GoalsA: 2, GoalsB: 1, Count: 152},
|
||||
{GoalsA: 2, GoalsB: 0, Count: 111},
|
||||
{GoalsA: 1, GoalsB: 1, Count: 92},
|
||||
{GoalsA: 0, GoalsB: 0, Count: 78},
|
||||
{GoalsA: 3, GoalsB: 1, Count: 68},
|
||||
{GoalsA: 3, GoalsB: 0, Count: 57},
|
||||
{GoalsA: 3, GoalsB: 2, Count: 43},
|
||||
{GoalsA: 2, GoalsB: 2, Count: 35},
|
||||
{GoalsA: 4, GoalsB: 1, Count: 31},
|
||||
{GoalsA: 4, GoalsB: 0, Count: 24},
|
||||
{GoalsA: 4, GoalsB: 2, Count: 17},
|
||||
{GoalsA: 6, GoalsB: 1, Count: 11},
|
||||
{GoalsA: 5, GoalsB: 2, Count: 9},
|
||||
{GoalsA: 5, GoalsB: 0, Count: 7},
|
||||
{GoalsA: 3, GoalsB: 3, Count: 7},
|
||||
{GoalsA: 5, GoalsB: 1, Count: 7},
|
||||
{GoalsA: 6, GoalsB: 0, Count: 5},
|
||||
{GoalsA: 7, GoalsB: 0, Count: 5},
|
||||
{GoalsA: 4, GoalsB: 3, Count: 3},
|
||||
{GoalsA: 7, GoalsB: 1, Count: 3},
|
||||
{GoalsA: 8, GoalsB: 1, Count: 3},
|
||||
{GoalsA: 4, GoalsB: 4, Count: 2},
|
||||
{GoalsA: 6, GoalsB: 3, Count: 2},
|
||||
{GoalsA: 9, GoalsB: 0, Count: 2},
|
||||
{GoalsA: 5, GoalsB: 3, Count: 1},
|
||||
{GoalsA: 6, GoalsB: 2, Count: 1},
|
||||
{GoalsA: 7, GoalsB: 2, Count: 1},
|
||||
{GoalsA: 7, GoalsB: 3, Count: 1},
|
||||
{GoalsA: 6, GoalsB: 5, Count: 1},
|
||||
{GoalsA: 8, GoalsB: 3, Count: 1},
|
||||
{GoalsA: 10, GoalsB: 1, Count: 1},
|
||||
{GoalsA: 7, GoalsB: 5, Count: 1},
|
||||
}
|
||||
|
||||
// poissonProbability computes the Poisson probability P(k; lambda).
|
||||
func poissonProbability(lambda float64, k int) float64 {
|
||||
factorial := 1.0
|
||||
for i := 1; i <= k; i++ {
|
||||
factorial *= float64(i)
|
||||
}
|
||||
return (math.Pow(lambda, float64(k)) * math.Exp(-lambda)) / factorial
|
||||
}
|
||||
|
||||
// DefaultScoreOutcomes returns the symmetric default probability distribution based on WorldCupRawScores.
|
||||
func DefaultScoreOutcomes() []ScoreOutcome {
|
||||
var totalGoals int
|
||||
var totalMatches int
|
||||
for _, r := range WorldCupRawScores {
|
||||
totalGoals += (r.GoalsA + r.GoalsB) * r.Count
|
||||
totalMatches += r.Count
|
||||
}
|
||||
|
||||
if totalMatches == 0 {
|
||||
return []ScoreOutcome{{ScoreA: 1, ScoreB: 1, Probability: 1.0}}
|
||||
}
|
||||
|
||||
// 1. Calculate expected goals per team (lambda)
|
||||
lambda := float64(totalGoals) / (2.0 * float64(totalMatches))
|
||||
|
||||
// 2. Compute Poisson 1D and 2D probabilities (up to MaxGoals = 10)
|
||||
maxGoals := 10
|
||||
poisson1D := make([]float64, maxGoals+1)
|
||||
var sumP float64
|
||||
for g := 0; g <= maxGoals; g++ {
|
||||
p := poissonProbability(lambda, g)
|
||||
poisson1D[g] = p
|
||||
sumP += p
|
||||
}
|
||||
for g := 0; g <= maxGoals; g++ {
|
||||
poisson1D[g] /= sumP
|
||||
}
|
||||
|
||||
// 3. Build Empirical 2D map
|
||||
type scoreKey struct {
|
||||
goalsA int
|
||||
goalsB int
|
||||
}
|
||||
empirical := make(map[scoreKey]float64)
|
||||
for _, r := range WorldCupRawScores {
|
||||
if r.GoalsA <= maxGoals && r.GoalsB <= maxGoals {
|
||||
prob := float64(r.Count) / float64(totalMatches)
|
||||
if r.GoalsA == r.GoalsB {
|
||||
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob
|
||||
} else {
|
||||
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob / 2.0
|
||||
empirical[scoreKey{r.GoalsB, r.GoalsA}] = prob / 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Combine into Mixture Model (alpha = 0.99)
|
||||
alpha := 0.99
|
||||
var outcomes []ScoreOutcome
|
||||
for ga := 0; ga <= maxGoals; ga++ {
|
||||
for gb := 0; gb <= maxGoals; gb++ {
|
||||
pPoisson := poisson1D[ga] * poisson1D[gb]
|
||||
pEmpirical := empirical[scoreKey{ga, gb}]
|
||||
pMix := alpha*pEmpirical + (1.0-alpha)*pPoisson
|
||||
|
||||
outcomes = append(outcomes, ScoreOutcome{
|
||||
ScoreA: ga,
|
||||
ScoreB: gb,
|
||||
Probability: pMix,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize just in case of float64 precision drift
|
||||
var sumMix float64
|
||||
for _, o := range outcomes {
|
||||
sumMix += o.Probability
|
||||
}
|
||||
for i := range outcomes {
|
||||
outcomes[i].Probability /= sumMix
|
||||
}
|
||||
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// ScoreModel holds the probability distribution used to sample random scores.
|
||||
type ScoreModel struct {
|
||||
Outcomes []ScoreOutcome
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewScoreModel creates a ScoreModel from custom score outcomes.
|
||||
// If customOutcomes is empty, it uses DefaultScoreOutcomes().
|
||||
func NewScoreModel(customOutcomes []ScoreOutcome) *ScoreModel {
|
||||
sm := &ScoreModel{}
|
||||
if len(customOutcomes) > 0 {
|
||||
// Normalize custom outcomes to ensure they sum to 1.0
|
||||
var sum float64
|
||||
for _, o := range customOutcomes {
|
||||
sum += o.Probability
|
||||
}
|
||||
if sum > 0 {
|
||||
normalized := make([]ScoreOutcome, len(customOutcomes))
|
||||
for i, o := range customOutcomes {
|
||||
normalized[i] = ScoreOutcome{
|
||||
ScoreA: o.ScoreA,
|
||||
ScoreB: o.ScoreB,
|
||||
Probability: o.Probability / sum,
|
||||
}
|
||||
}
|
||||
sm.Outcomes = normalized
|
||||
return sm
|
||||
}
|
||||
}
|
||||
sm.Outcomes = DefaultScoreOutcomes()
|
||||
return sm
|
||||
}
|
||||
|
||||
// RandomScore samples a random score outcome based on the probability distribution.
|
||||
func (sm *ScoreModel) RandomScore(r *rand.Rand) ScoreOutcome {
|
||||
sm.mu.RLock()
|
||||
outcomes := sm.Outcomes
|
||||
sm.mu.RUnlock()
|
||||
|
||||
if len(outcomes) == 0 {
|
||||
return ScoreOutcome{ScoreA: 0, ScoreB: 0, Probability: 1.0}
|
||||
}
|
||||
|
||||
p := r.Float64()
|
||||
var cumulative float64
|
||||
for _, outcome := range outcomes {
|
||||
cumulative += outcome.Probability
|
||||
if p <= cumulative {
|
||||
return outcome
|
||||
}
|
||||
}
|
||||
return outcomes[len(outcomes)-1]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunMonteCarlo runs a Monte Carlo simulation of the remaining matches.
|
||||
// Returns a slice of TeamProbability sorted by Group and then by TotalQualProb descending.
|
||||
func RunMonteCarlo(teams []Team, matches []Match, scoreOutcomes []ScoreOutcome, numSimulations int) []TeamProbability {
|
||||
if numSimulations <= 0 {
|
||||
numSimulations = 50000
|
||||
}
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
sm := NewScoreModel(scoreOutcomes)
|
||||
|
||||
// Map teams to groups
|
||||
teamGroups := make(map[string]string)
|
||||
groupsTeams := make(map[string][]string)
|
||||
for _, t := range teams {
|
||||
teamGroups[t.Name] = t.Group
|
||||
groupsTeams[t.Group] = append(groupsTeams[t.Group], t.Name)
|
||||
}
|
||||
|
||||
directCounts := make(map[string]int)
|
||||
thirdCounts := make(map[string]int)
|
||||
|
||||
// Separate completed and uncompleted matches
|
||||
var completed []Match
|
||||
var uncompleted []Match
|
||||
for _, m := range matches {
|
||||
if m.IsCompleted {
|
||||
completed = append(completed, m)
|
||||
} else {
|
||||
uncompleted = append(uncompleted, m)
|
||||
}
|
||||
}
|
||||
|
||||
for sim := 0; sim < numSimulations; sim++ {
|
||||
// Clone completed matches and simulate uncompleted ones
|
||||
simMatches := make([]Match, len(completed)+len(uncompleted))
|
||||
copy(simMatches, completed)
|
||||
|
||||
for i, m := range uncompleted {
|
||||
outcome := sm.RandomScore(r)
|
||||
simMatches[len(completed)+i] = Match{
|
||||
TeamA: m.TeamA,
|
||||
TeamB: m.TeamB,
|
||||
IsCompleted: true,
|
||||
ScoreA: outcome.ScoreA,
|
||||
ScoreB: outcome.ScoreB,
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate standings for each group
|
||||
thirdPlaced := make([]*TeamStats, 0, 12)
|
||||
for grp, grpTeams := range groupsTeams {
|
||||
var grpMatches []Match
|
||||
for _, m := range simMatches {
|
||||
if teamGroups[m.TeamA] == grp && teamGroups[m.TeamB] == grp {
|
||||
grpMatches = append(grpMatches, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle group teams to randomize tie-breaks in SortGroupStandings
|
||||
shuffledGrpTeams := make([]string, len(grpTeams))
|
||||
copy(shuffledGrpTeams, grpTeams)
|
||||
r.Shuffle(len(shuffledGrpTeams), func(i, j int) {
|
||||
shuffledGrpTeams[i], shuffledGrpTeams[j] = shuffledGrpTeams[j], shuffledGrpTeams[i]
|
||||
})
|
||||
|
||||
standings := CalculateStandings(shuffledGrpTeams, grpMatches)
|
||||
// Top 2 qualify directly
|
||||
if len(standings) > 0 {
|
||||
directCounts[standings[0].TeamName]++
|
||||
}
|
||||
if len(standings) > 1 {
|
||||
directCounts[standings[1].TeamName]++
|
||||
}
|
||||
// 3rd placed team goes to the pool
|
||||
if len(standings) > 2 {
|
||||
thirdPlaced = append(thirdPlaced, standings[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle third-placed list to randomize cross-group tie-breaks in CompareThirdPlaced
|
||||
r.Shuffle(len(thirdPlaced), func(i, j int) {
|
||||
thirdPlaced[i], thirdPlaced[j] = thirdPlaced[j], thirdPlaced[i]
|
||||
})
|
||||
|
||||
CompareThirdPlaced(thirdPlaced)
|
||||
limit := 8
|
||||
if len(thirdPlaced) < limit {
|
||||
limit = len(thirdPlaced)
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
thirdCounts[thirdPlaced[i].TeamName]++
|
||||
}
|
||||
}
|
||||
|
||||
// Build result slice
|
||||
res := make([]TeamProbability, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
dQual := float64(directCounts[t.Name]) / float64(numSimulations)
|
||||
tQual := float64(thirdCounts[t.Name]) / float64(numSimulations)
|
||||
res = append(res, TeamProbability{
|
||||
TeamName: t.Name,
|
||||
Group: t.Group,
|
||||
DirectQualProb: dQual,
|
||||
ThirdQualProb: tQual,
|
||||
TotalQualProb: dQual + tQual,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort results by Group name first, then by TotalQualProb descending
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
if res[i].Group != res[j].Group {
|
||||
return res[i].Group < res[j].Group
|
||||
}
|
||||
return res[i].TotalQualProb > res[j].TotalQualProb
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateStandings(t *testing.T) {
|
||||
teams := []string{"MEX", "RSA", "KOR", "CZE"}
|
||||
matches := []Match{
|
||||
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // MEX: 3 pts, RSA: 0 pts
|
||||
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // KOR: 3 pts, CZE: 0 pts
|
||||
}
|
||||
|
||||
standings := CalculateStandings(teams, matches)
|
||||
if len(standings) != 4 {
|
||||
t.Fatalf("expected 4 teams, got %d", len(standings))
|
||||
}
|
||||
|
||||
// MEX and KOR should be top 2
|
||||
if standings[0].TeamName != "MEX" && standings[0].TeamName != "KOR" {
|
||||
t.Errorf("expected MEX or KOR as first, got %s", standings[0].TeamName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMonteCarlo(t *testing.T) {
|
||||
teams := []Team{
|
||||
{Name: "MEX", Group: "A"},
|
||||
{Name: "RSA", Group: "A"},
|
||||
{Name: "KOR", Group: "A"},
|
||||
{Name: "CZE", Group: "A"},
|
||||
}
|
||||
matches := []Match{
|
||||
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0},
|
||||
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1},
|
||||
{TeamA: "CZE", TeamB: "RSA", IsCompleted: false},
|
||||
{TeamA: "MEX", TeamB: "KOR", IsCompleted: false},
|
||||
{TeamA: "MEX", TeamB: "CZE", IsCompleted: false},
|
||||
{TeamA: "KOR", TeamB: "RSA", IsCompleted: false},
|
||||
}
|
||||
|
||||
// We only simulate Group A here (4 teams, 6 matches total).
|
||||
// Note: 3rd place comparison won't succeed in putting any team to top 8 of third placed
|
||||
// unless there are other groups, but it shouldn't crash.
|
||||
probs := RunMonteCarlo(teams, matches, nil, 100)
|
||||
if len(probs) != 4 {
|
||||
t.Fatalf("expected probabilities for 4 teams, got %d", len(probs))
|
||||
}
|
||||
|
||||
for _, p := range probs {
|
||||
if p.TotalQualProb < 0 || p.TotalQualProb > 1.0 {
|
||||
t.Errorf("invalid total qual probability for %s: %f", p.TeamName, p.TotalQualProb)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// CalculateStandings computes the stats for each team in a group given the match results.
|
||||
func CalculateStandings(teams []string, matches []Match) []*TeamStats {
|
||||
statsMap := make(map[string]*TeamStats)
|
||||
for _, t := range teams {
|
||||
statsMap[t] = &TeamStats{TeamName: t}
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
if !m.IsCompleted {
|
||||
continue
|
||||
}
|
||||
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
|
||||
if sa == nil || sb == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sa.GoalsScored += m.ScoreA
|
||||
sa.GoalDifference += m.ScoreA - m.ScoreB
|
||||
sb.GoalsScored += m.ScoreB
|
||||
sb.GoalDifference += m.ScoreB - m.ScoreA
|
||||
|
||||
if m.ScoreA > m.ScoreB {
|
||||
sa.Points += 3
|
||||
} else if m.ScoreA < m.ScoreB {
|
||||
sb.Points += 3
|
||||
} else {
|
||||
sa.Points += 1
|
||||
sb.Points += 1
|
||||
}
|
||||
}
|
||||
|
||||
res := make([]*TeamStats, 0, len(teams))
|
||||
for _, s := range statsMap {
|
||||
res = append(res, s)
|
||||
}
|
||||
|
||||
// Sort the standings
|
||||
SortGroupStandings(res, matches)
|
||||
return res
|
||||
}
|
||||
|
||||
// SortGroupStandings sorts the teams in a group based on the tie-breaking rules.
|
||||
func SortGroupStandings(teams []*TeamStats, matches []Match) {
|
||||
sort.Slice(teams, func(i, j int) bool {
|
||||
ti, tj := teams[i], teams[j]
|
||||
|
||||
// 1. Points
|
||||
if ti.Points != tj.Points {
|
||||
return ti.Points > tj.Points
|
||||
}
|
||||
|
||||
// 2. Goal Difference
|
||||
if ti.GoalDifference != tj.GoalDifference {
|
||||
return ti.GoalDifference > tj.GoalDifference
|
||||
}
|
||||
|
||||
// 3. Goals Scored
|
||||
if ti.GoalsScored != tj.GoalsScored {
|
||||
return ti.GoalsScored > tj.GoalsScored
|
||||
}
|
||||
|
||||
// 4-6. Head-to-Head (H2H)
|
||||
// We dynamically compute H2H stats for all teams that are tied on Points, GD, and GS.
|
||||
// Since we are sorting, we can check the relationship between ti and tj.
|
||||
// First find all teams tied with ti and tj.
|
||||
tiedTeams := []string{}
|
||||
for _, t := range teams {
|
||||
if t.Points == ti.Points && t.GoalDifference == ti.GoalDifference && t.GoalsScored == ti.GoalsScored {
|
||||
tiedTeams = append(tiedTeams, t.TeamName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tiedTeams) > 1 {
|
||||
h2hStats := computeH2H(tiedTeams, matches)
|
||||
h2hi := h2hStats[ti.TeamName]
|
||||
h2hj := h2hStats[tj.TeamName]
|
||||
|
||||
if h2hi != nil && h2hj != nil {
|
||||
// H2H Points
|
||||
if h2hi.Points != h2hj.Points {
|
||||
return h2hi.Points > h2hj.Points
|
||||
}
|
||||
// H2H GD
|
||||
if h2hi.GoalDifference != h2hj.GoalDifference {
|
||||
return h2hi.GoalDifference > h2hj.GoalDifference
|
||||
}
|
||||
// H2H GS
|
||||
if h2hi.GoalsScored != h2hj.GoalsScored {
|
||||
return h2hi.GoalsScored > h2hj.GoalsScored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// computeH2H calculates Points, GD, and GS only considering matches between the specified tied teams.
|
||||
func computeH2H(tiedTeams []string, matches []Match) map[string]*TeamStats {
|
||||
statsMap := make(map[string]*TeamStats)
|
||||
isTied := make(map[string]bool)
|
||||
for _, t := range tiedTeams {
|
||||
statsMap[t] = &TeamStats{TeamName: t}
|
||||
isTied[t] = true
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
if !m.IsCompleted {
|
||||
continue
|
||||
}
|
||||
if isTied[m.TeamA] && isTied[m.TeamB] {
|
||||
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
|
||||
sa.GoalsScored += m.ScoreA
|
||||
sa.GoalDifference += m.ScoreA - m.ScoreB
|
||||
sb.GoalsScored += m.ScoreB
|
||||
sb.GoalDifference += m.ScoreB - m.ScoreA
|
||||
|
||||
if m.ScoreA > m.ScoreB {
|
||||
sa.Points += 3
|
||||
} else if m.ScoreA < m.ScoreB {
|
||||
sb.Points += 3
|
||||
} else {
|
||||
sa.Points += 1
|
||||
sb.Points += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return statsMap
|
||||
}
|
||||
|
||||
// CompareThirdPlaced ranks the third-placed teams across groups.
|
||||
func CompareThirdPlaced(thirdTeams []*TeamStats) {
|
||||
sort.Slice(thirdTeams, func(i, j int) bool {
|
||||
ti, tj := thirdTeams[i], thirdTeams[j]
|
||||
|
||||
// 1. Points
|
||||
if ti.Points != tj.Points {
|
||||
return ti.Points > tj.Points
|
||||
}
|
||||
|
||||
// 2. Goal Difference
|
||||
if ti.GoalDifference != tj.GoalDifference {
|
||||
return ti.GoalDifference > tj.GoalDifference
|
||||
}
|
||||
|
||||
// 3. Goals Scored
|
||||
if ti.GoalsScored != tj.GoalsScored {
|
||||
return ti.GoalsScored > tj.GoalsScored
|
||||
}
|
||||
|
||||
// 4. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package football
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"toolbox/pkg/base"
|
||||
"toolbox/pkg/football/logic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type footballTool struct{}
|
||||
|
||||
func init() {
|
||||
base.Register(&footballTool{})
|
||||
}
|
||||
|
||||
func (t *footballTool) ID() string { return "football" }
|
||||
func (t *footballTool) Name() string { return "足球概率模拟" }
|
||||
func (t *footballTool) Description() string {
|
||||
return "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛阶段的出线概率"
|
||||
}
|
||||
func (t *footballTool) Emoji() string { return "⚽" }
|
||||
|
||||
func (t *footballTool) Init() error {
|
||||
fmt.Println("Initializing Football Tool...")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *footballTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.GET("/defaults", t.handleDefaults)
|
||||
r.POST("/simulate", t.handleSimulate)
|
||||
}
|
||||
|
||||
type SimulateRequest struct {
|
||||
Groups map[string][]string `json:"groups"`
|
||||
CompletedMatches []logic.Match `json:"completed_matches"`
|
||||
ScoreOutcomes []logic.ScoreOutcome `json:"score_outcomes"`
|
||||
Simulations int `json:"simulations"`
|
||||
}
|
||||
|
||||
func (t *footballTool) handleDefaults(c *gin.Context) {
|
||||
defaultGroups := map[string][]string{
|
||||
"A": {"MEX", "RSA", "KOR", "CZE"},
|
||||
"B": {"CAN", "QAT", "SUI", "BIH"},
|
||||
"C": {"BRA", "MAR", "HAI", "SCO"},
|
||||
"D": {"USA", "PAR", "AUS", "TUR"},
|
||||
"E": {"GER", "CUW", "CIV", "ECU"},
|
||||
"F": {"NED", "JPN", "TUN", "SWE"},
|
||||
"G": {"BEL", "EGY", "IRN", "NZL"},
|
||||
"H": {"ESP", "KSA", "URU", "CPV"},
|
||||
"I": {"FRA", "SEN", "IRQ", "NOR"},
|
||||
"J": {"ARG", "ALG", "AUT", "JOR"},
|
||||
"K": {"POR", "COL", "UZB", "COD"},
|
||||
"L": {"ENG", "CRO", "GHA", "PAN"},
|
||||
}
|
||||
|
||||
defaultMatches := []logic.Match{
|
||||
// === ROUND 1 ===
|
||||
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group A
|
||||
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // Group A
|
||||
{TeamA: "CAN", TeamB: "BIH", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group B
|
||||
{TeamA: "USA", TeamB: "PAR", IsCompleted: true, ScoreA: 4, ScoreB: 1}, // Group D
|
||||
{TeamA: "QAT", TeamB: "SUI", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group B
|
||||
{TeamA: "BRA", TeamB: "MAR", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group C
|
||||
{TeamA: "SCO", TeamB: "HAI", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group C
|
||||
{TeamA: "AUS", TeamB: "TUR", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group D
|
||||
{TeamA: "GER", TeamB: "CUW", IsCompleted: true, ScoreA: 7, ScoreB: 1}, // Group E
|
||||
{TeamA: "NED", TeamB: "JPN", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group F
|
||||
{TeamA: "CIV", TeamB: "ECU", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group E
|
||||
{TeamA: "SWE", TeamB: "TUN", IsCompleted: true, ScoreA: 5, ScoreB: 1}, // Group F
|
||||
{TeamA: "ESP", TeamB: "CPV", IsCompleted: true, ScoreA: 0, ScoreB: 0}, // Group H
|
||||
{TeamA: "BEL", TeamB: "EGY", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group G
|
||||
{TeamA: "KSA", TeamB: "URU", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group H
|
||||
{TeamA: "IRN", TeamB: "NZL", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group G
|
||||
{TeamA: "FRA", TeamB: "SEN", IsCompleted: true, ScoreA: 3, ScoreB: 1}, // Group I
|
||||
{TeamA: "IRQ", TeamB: "NOR", IsCompleted: true, ScoreA: 1, ScoreB: 4}, // Group I
|
||||
{TeamA: "ARG", TeamB: "ALG", IsCompleted: true, ScoreA: 3, ScoreB: 0}, // Group J
|
||||
{TeamA: "AUT", TeamB: "JOR", IsCompleted: true, ScoreA: 3, ScoreB: 1}, // Group J
|
||||
{TeamA: "POR", TeamB: "COD", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group K
|
||||
{TeamA: "ENG", TeamB: "CRO", IsCompleted: true, ScoreA: 4, ScoreB: 2}, // Group L
|
||||
{TeamA: "GHA", TeamB: "PAN", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group L
|
||||
{TeamA: "UZB", TeamB: "COL", IsCompleted: true, ScoreA: 1, ScoreB: 3}, // Group K
|
||||
|
||||
// === ROUND 2 ===
|
||||
{TeamA: "CZE", TeamB: "RSA", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group A
|
||||
{TeamA: "SUI", TeamB: "BIH", IsCompleted: true, ScoreA: 4, ScoreB: 1}, // Group B
|
||||
{TeamA: "CAN", TeamB: "QAT", IsCompleted: true, ScoreA: 6, ScoreB: 0}, // Group B
|
||||
{TeamA: "MEX", TeamB: "KOR", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group A
|
||||
{TeamA: "USA", TeamB: "AUS", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group D
|
||||
{TeamA: "SCO", TeamB: "MAR", IsCompleted: false, ScoreA: 0, ScoreB: 1}, // Group C
|
||||
{TeamA: "BRA", TeamB: "HAI", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
|
||||
{TeamA: "TUR", TeamB: "PAR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
|
||||
{TeamA: "NED", TeamB: "SWE", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
|
||||
{TeamA: "GER", TeamB: "CIV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
|
||||
{TeamA: "ECU", TeamB: "CUW", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
|
||||
{TeamA: "TUN", TeamB: "JPN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
|
||||
{TeamA: "ESP", TeamB: "KSA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
|
||||
{TeamA: "BEL", TeamB: "IRN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
|
||||
{TeamA: "URU", TeamB: "CPV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
|
||||
{TeamA: "NZL", TeamB: "EGY", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
|
||||
{TeamA: "ARG", TeamB: "AUT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
|
||||
{TeamA: "FRA", TeamB: "IRQ", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
|
||||
{TeamA: "NOR", TeamB: "SEN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
|
||||
{TeamA: "JOR", TeamB: "ALG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
|
||||
{TeamA: "POR", TeamB: "UZB", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
|
||||
{TeamA: "ENG", TeamB: "GHA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
|
||||
{TeamA: "PAN", TeamB: "CRO", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
|
||||
{TeamA: "COL", TeamB: "COD", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
|
||||
|
||||
// === ROUND 3 ===
|
||||
{TeamA: "SUI", TeamB: "CAN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group B
|
||||
{TeamA: "BIH", TeamB: "QAT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group B
|
||||
{TeamA: "SCO", TeamB: "BRA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
|
||||
{TeamA: "MAR", TeamB: "HAI", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
|
||||
{TeamA: "CZE", TeamB: "MEX", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group A
|
||||
{TeamA: "RSA", TeamB: "KOR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group A
|
||||
{TeamA: "CUW", TeamB: "CIV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
|
||||
{TeamA: "ECU", TeamB: "GER", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
|
||||
{TeamA: "JPN", TeamB: "SWE", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
|
||||
{TeamA: "TUN", TeamB: "NED", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
|
||||
{TeamA: "TUR", TeamB: "USA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
|
||||
{TeamA: "PAR", TeamB: "AUS", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
|
||||
{TeamA: "NOR", TeamB: "FRA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
|
||||
{TeamA: "SEN", TeamB: "IRQ", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
|
||||
{TeamA: "CPV", TeamB: "KSA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
|
||||
{TeamA: "URU", TeamB: "ESP", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
|
||||
{TeamA: "EGY", TeamB: "IRN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
|
||||
{TeamA: "NZL", TeamB: "BEL", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
|
||||
{TeamA: "PAN", TeamB: "ENG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
|
||||
{TeamA: "CRO", TeamB: "GHA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
|
||||
{TeamA: "COL", TeamB: "POR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
|
||||
{TeamA: "COD", TeamB: "UZB", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
|
||||
{TeamA: "ALG", TeamB: "AUT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
|
||||
{TeamA: "JOR", TeamB: "ARG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"groups": defaultGroups,
|
||||
"completed_matches": defaultMatches,
|
||||
"score_outcomes": logic.DefaultScoreOutcomes(),
|
||||
})
|
||||
}
|
||||
|
||||
func (t *footballTool) handleSimulate(c *gin.Context) {
|
||||
var req SimulateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Groups) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Groups map cannot be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reconstruct Teams
|
||||
var teams []logic.Team
|
||||
for grp, grpTeams := range req.Groups {
|
||||
for _, teamName := range grpTeams {
|
||||
teams = append(teams, logic.Team{Name: teamName, Group: grp})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all group matches (completed + uncompleted)
|
||||
var allMatches []logic.Match
|
||||
for _, grpTeams := range req.Groups {
|
||||
for i := 0; i < len(grpTeams); i++ {
|
||||
for j := i + 1; j < len(grpTeams); j++ {
|
||||
tA := grpTeams[i]
|
||||
tB := grpTeams[j]
|
||||
|
||||
// Check if this match is completed in input
|
||||
isCompleted := false
|
||||
scoreA, scoreB := 0, 0
|
||||
for _, cm := range req.CompletedMatches {
|
||||
if !cm.IsCompleted {
|
||||
continue
|
||||
}
|
||||
if cm.TeamA == tA && cm.TeamB == tB {
|
||||
isCompleted = true
|
||||
scoreA = cm.ScoreA
|
||||
scoreB = cm.ScoreB
|
||||
break
|
||||
} else if cm.TeamA == tB && cm.TeamB == tA {
|
||||
isCompleted = true
|
||||
scoreA = cm.ScoreB
|
||||
scoreB = cm.ScoreA
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
allMatches = append(allMatches, logic.Match{
|
||||
TeamA: tA,
|
||||
TeamB: tB,
|
||||
IsCompleted: isCompleted,
|
||||
ScoreA: scoreA,
|
||||
ScoreB: scoreB,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sims := req.Simulations
|
||||
if sims <= 0 {
|
||||
sims = 50000
|
||||
}
|
||||
|
||||
results := logic.RunMonteCarlo(teams, allMatches, req.ScoreOutcomes, sims)
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package football
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"toolbox/pkg/football/logic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestFootballToolRoutes(t *testing.T) {
|
||||
// Set Gin to test mode
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create test router and register routes
|
||||
r := gin.New()
|
||||
api := r.Group("/api/football")
|
||||
tool := &footballTool{}
|
||||
tool.RegisterRoutes(api)
|
||||
|
||||
// 1. Test /api/football/defaults
|
||||
reqDefaults, _ := http.NewRequest(http.MethodGet, "/api/football/defaults", nil)
|
||||
respDefaults := httptest.NewRecorder()
|
||||
r.ServeHTTP(respDefaults, reqDefaults)
|
||||
|
||||
if respDefaults.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 OK for defaults, got %d", respDefaults.Code)
|
||||
}
|
||||
|
||||
var defaults struct {
|
||||
Groups map[string][]string `json:"groups"`
|
||||
CompletedMatches []logic.Match `json:"completed_matches"`
|
||||
ScoreOutcomes []logic.ScoreOutcome `json:"score_outcomes"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respDefaults.Body.Bytes(), &defaults); err != nil {
|
||||
t.Fatalf("failed to parse defaults JSON response: %v", err)
|
||||
}
|
||||
|
||||
if len(defaults.Groups) != 12 {
|
||||
t.Errorf("expected 12 groups, got %d", len(defaults.Groups))
|
||||
}
|
||||
if len(defaults.CompletedMatches) == 0 {
|
||||
t.Errorf("expected completed matches in default configuration")
|
||||
}
|
||||
|
||||
// 2. Test /api/football/simulate
|
||||
simReq := SimulateRequest{
|
||||
Groups: defaults.Groups,
|
||||
CompletedMatches: defaults.CompletedMatches,
|
||||
ScoreOutcomes: defaults.ScoreOutcomes,
|
||||
Simulations: 100, // run a small number for fast test
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(simReq)
|
||||
reqSim, _ := http.NewRequest(http.MethodPost, "/api/football/simulate", bytes.NewBuffer(reqBody))
|
||||
reqSim.Header.Set("Content-Type", "application/json")
|
||||
respSim := httptest.NewRecorder()
|
||||
r.ServeHTTP(respSim, reqSim)
|
||||
|
||||
if respSim.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 OK for simulate, got %d. Body: %s", respSim.Code, respSim.Body.String())
|
||||
}
|
||||
|
||||
var results []logic.TeamProbability
|
||||
if err := json.Unmarshal(respSim.Body.Bytes(), &results); err != nil {
|
||||
t.Fatalf("failed to parse simulate JSON response: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 48 {
|
||||
t.Errorf("expected 48 team probabilities, got %d", len(results))
|
||||
}
|
||||
|
||||
// Sum of TotalQualProb across all 48 teams should be exactly 32.0
|
||||
var sum float64
|
||||
for _, r := range results {
|
||||
sum += r.TotalQualProb
|
||||
}
|
||||
|
||||
// Because of floating point representation it should be extremely close to 32.0
|
||||
if sum < 31.9 || sum > 32.1 {
|
||||
t.Errorf("expected sum of probabilities to be 32.0, got %f", sum)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user