Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions backend/alembic/versions/77de1c773dba_create_rankings_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""create rankings table

Revision ID: 77de1c773dba
Revises: 1961954c0320
Create Date: 2024-06-29 14:13:18.278876

"""

from typing import Any

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str | None = "77de1c773dba"
down_revision: str | None = "1961954c0320"
branch_labels: str | None = None
depends_on: str | None = None


def add_missing_rankings(tournaments_without_ranking: list[Any]) -> None:
for tournament in tournaments_without_ranking:
ranking_id = (
op.get_bind()
.execute(
sa.text(
"""
INSERT INTO rankings (
tournament_id,
position,
win_points,
draw_points,
loss_points,
add_score_points
)
VALUES (:tournament_id, 0, 1, 0.5, 0, false)
RETURNING id
"""
),
tournament_id=tournament.id,
)
.scalar_one()
)

op.get_bind().execute(
sa.text(
"""
UPDATE stage_items
SET ranking_id = :ranking_id
WHERE stage_items.ranking_id IS NULL AND stage_items.stage_id IN (
SELECT id FROM stages
WHERE stages.tournament_id = :tournament_id
)
"""
),
tournament_id=tournament.id,
ranking_id=ranking_id,
)


def upgrade() -> None:
op.create_table(
"rankings",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column(
"created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("tournament_id", sa.BigInteger(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("win_points", sa.Float(), nullable=False),
sa.Column("draw_points", sa.Float(), nullable=False),
sa.Column("loss_points", sa.Float(), nullable=False),
sa.Column("add_score_points", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["tournament_id"],
["tournaments.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rankings_id"), "rankings", ["id"], unique=False)
op.create_index(op.f("ix_rankings_tournament_id"), "rankings", ["tournament_id"], unique=False)

tournaments_without_ranking = (
op.get_bind()
.execute(
"""
SELECT * FROM tournaments WHERE (
SELECT NOT EXISTS (
SELECT 1 FROM rankings WHERE rankings.tournament_id = tournaments.id
)
)
"""
)
.fetchall()
)

op.add_column("stage_items", sa.Column("ranking_id", sa.BigInteger(), nullable=True))

add_missing_rankings(tournaments_without_ranking)

op.alter_column("stage_items", "ranking_id", nullable=False)
op.create_foreign_key(
"stage_items_x_rankings_id_fkey", "stage_items", "rankings", ["ranking_id"], ["id"]
)

op.add_column(
"stage_item_inputs", sa.Column("points", sa.Float(), server_default="0", nullable=False)
)
op.add_column(
"stage_item_inputs", sa.Column("wins", sa.Integer(), server_default="0", nullable=False)
)
op.add_column(
"stage_item_inputs", sa.Column("draws", sa.Integer(), server_default="0", nullable=False)
)
op.add_column(
"stage_item_inputs", sa.Column("losses", sa.Integer(), server_default="0", nullable=False)
)


def downgrade() -> None:
op.drop_column("stage_item_inputs", "losses")
op.drop_column("stage_item_inputs", "draws")
op.drop_column("stage_item_inputs", "wins")
op.drop_column("stage_item_inputs", "points")

op.drop_constraint("stage_items_x_rankings_id_fkey", "stage_items", type_="foreignkey")
op.drop_column("stage_items", "ranking_id")
op.drop_index(op.f("ix_rankings_tournament_id"), table_name="rankings")
op.drop_index(op.f("ix_rankings_id"), table_name="rankings")
op.drop_table("rankings")
4 changes: 3 additions & 1 deletion backend/bracket/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
internals,
matches,
players,
rankings,
rounds,
stage_items,
stages,
Expand Down Expand Up @@ -58,12 +59,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:


routers = {
"Internals": internals.router,
"Auth": auth.router,
"Clubs": clubs.router,
"Courts": courts.router,
"Internals": internals.router,
"Matches": matches.router,
"Players": players.router,
"Rankings": rankings.router,
"Rounds": rounds.router,
"Stage Items": stage_items.router,
"Stages": stages.router,
Expand Down
163 changes: 70 additions & 93 deletions backend/bracket/logic/ranking/elo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from decimal import Decimal
from typing import TypeVar

from bracket.database import database
from bracket.logic.ranking.statistics import START_ELO, TeamStatistics
from bracket.models.db.match import MatchWithDetailsDefinitive
from bracket.models.db.players import START_ELO, PlayerStatistics
from bracket.models.db.ranking import Ranking
from bracket.models.db.stage_item import StageType
from bracket.models.db.util import StageItemWithRounds
from bracket.schema import players, teams
from bracket.sql.players import get_all_players_in_tournament, update_player_stats
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.teams import get_teams_in_tournament, update_team_stats
from bracket.utils.id_types import PlayerId, TeamId, TournamentId
from bracket.sql.rankings import get_ranking_for_stage_item
from bracket.sql.stage_items import get_stage_item
from bracket.sql.teams import update_team_stats
from bracket.utils.id_types import PlayerId, StageItemId, TeamId, TournamentId
from bracket.utils.types import assert_some

K = 32
Expand All @@ -21,128 +21,105 @@
TeamIdOrPlayerId = TypeVar("TeamIdOrPlayerId", bound=PlayerId | TeamId)


def set_statistics_for_player_or_team(
def set_statistics_for_team(
team_index: int,
stats: defaultdict[TeamIdOrPlayerId, PlayerStatistics],
stats: defaultdict[TeamId, TeamStatistics],
match: MatchWithDetailsDefinitive,
team_or_player_id: TeamIdOrPlayerId,
rating_team1_before: float,
rating_team2_before: float,
team_id: TeamId,
rating_team1_before: Decimal,
rating_team2_before: Decimal,
ranking: Ranking,
stage_item: StageItemWithRounds,
) -> None:
is_team1 = team_index == 0
team_score = match.team1_score if is_team1 else match.team2_score
was_draw = match.team1_score == match.team2_score
has_won = not was_draw and team_score == max(match.team1_score, match.team2_score)

# Set default for SWISS teams
if stage_item.type is StageType.SWISS and team_id not in stats:
stats[team_id].points = START_ELO

if has_won:
stats[team_or_player_id].wins += 1
swiss_score_diff = Decimal("1.00")
stats[team_id].wins += 1
swiss_score_diff = ranking.win_points
elif was_draw:
stats[team_or_player_id].draws += 1
swiss_score_diff = Decimal("0.50")
stats[team_id].draws += 1
swiss_score_diff = ranking.draw_points
else:
stats[team_or_player_id].losses += 1
swiss_score_diff = Decimal("0.00")
stats[team_id].losses += 1
swiss_score_diff = ranking.loss_points

if ranking.add_score_points:
swiss_score_diff += match.team1_score if is_team1 else match.team2_score

match stage_item.type:
case StageType.ROUND_ROBIN | StageType.SINGLE_ELIMINATION:
stats[team_id].points += swiss_score_diff

stats[team_or_player_id].swiss_score += swiss_score_diff
case StageType.SWISS:
rating_diff = (rating_team2_before - rating_team1_before) * (1 if is_team1 else -1)
expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D)))
stats[team_id].points += int(K * (swiss_score_diff - expected_score))

rating_diff = (rating_team2_before - rating_team1_before) * (1 if is_team1 else -1)
expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D)))
stats[team_or_player_id].elo_score += int(K * (swiss_score_diff - expected_score))
case _:
raise ValueError(f"Unsupported stage type: {stage_item.type}")


def determine_ranking_for_stage_items(
stage_items: list[StageItemWithRounds],
) -> tuple[defaultdict[PlayerId, PlayerStatistics], defaultdict[TeamId, PlayerStatistics]]:
player_x_stats: defaultdict[PlayerId, PlayerStatistics] = defaultdict(PlayerStatistics)
team_x_stats: defaultdict[TeamId, PlayerStatistics] = defaultdict(PlayerStatistics)
def determine_ranking_for_stage_item(
stage_item: StageItemWithRounds,
ranking: Ranking,
) -> defaultdict[TeamId, TeamStatistics]:
team_x_stats: defaultdict[TeamId, TeamStatistics] = defaultdict(TeamStatistics)
matches = [
match
for stage_item in stage_items
for round_ in stage_item.rounds
if not round_.is_draft
for match in round_.matches
if isinstance(match, MatchWithDetailsDefinitive)
if match.team1_score != 0 or match.team2_score != 0
]
for match in matches:
rating_team1_before = (
sum(player_x_stats[player_id].elo_score for player_id in match.team1.player_ids)
/ len(match.team1.player_ids)
if len(match.team1.player_ids) > 0
else START_ELO
)
rating_team2_before = (
sum(player_x_stats[player_id].elo_score for player_id in match.team2.player_ids)
/ len(match.team2.player_ids)
if len(match.team2.player_ids) > 0
else START_ELO
)

for team_index, team in enumerate(match.teams):
if team.id is not None:
set_statistics_for_player_or_team(
set_statistics_for_team(
team_index,
team_x_stats,
match,
team.id,
rating_team1_before,
rating_team2_before,
match.team1.elo_score,
match.team2.elo_score,
ranking,
stage_item,
)

for player in team.players:
set_statistics_for_player_or_team(
team_index,
player_x_stats,
match,
assert_some(player.id),
rating_team1_before,
rating_team2_before,
)

return player_x_stats, team_x_stats
return team_x_stats


def determine_team_ranking_for_stage_item(
async def determine_team_ranking_for_stage_item(
stage_item: StageItemWithRounds,
) -> list[tuple[TeamId, PlayerStatistics]]:
_, team_ranking = determine_ranking_for_stage_items([stage_item])
return sorted(team_ranking.items(), key=lambda x: x[1].elo_score, reverse=True)
ranking: Ranking,
) -> list[tuple[TeamId, TeamStatistics]]:
team_ranking = determine_ranking_for_stage_item(stage_item, ranking)
return sorted(team_ranking.items(), key=lambda x: x[1].points, reverse=True)


async def recalculate_ranking_for_tournament_id(tournament_id: TournamentId) -> None:
stages = await get_full_tournament_details(tournament_id)
stage_items = [stage_item for stage in stages for stage_item in stage.stage_items]
await recalculate_ranking_for_stage_items(tournament_id, stage_items)
async def recalculate_ranking_for_stage_item_id(
tournament_id: TournamentId,
stage_item_id: StageItemId,
) -> None:
stage_item = await get_stage_item(tournament_id, stage_item_id)
ranking = await get_ranking_for_stage_item(tournament_id, stage_item_id)
assert stage_item, "Stage item not found"
assert ranking, "Ranking not found"

team_x_stage_item_input_lookup = {
stage_item_input.team_id: assert_some(stage_item_input.id)
for stage_item_input in stage_item.inputs
if stage_item_input.team_id is not None
}

async def recalculate_ranking_for_stage_items(
tournament_id: TournamentId, stage_items: list[StageItemWithRounds]
) -> None:
elo_per_player, elo_per_team = determine_ranking_for_stage_items(stage_items)

for player_id, statistics in elo_per_player.items():
await update_player_stats(tournament_id, player_id, statistics)

for team_id, statistics in elo_per_team.items():
await update_team_stats(tournament_id, team_id, statistics)

all_players = await get_all_players_in_tournament(tournament_id)
for player in all_players:
if player.id not in elo_per_player:
await database.execute(
query=players.update().where(
(players.c.id == player.id) & (players.c.tournament_id == tournament_id)
),
values=PlayerStatistics().model_dump(),
)

all_teams = await get_teams_in_tournament(tournament_id)
for team in all_teams:
if team.id not in elo_per_team:
await database.execute(
query=teams.update().where(
(teams.c.id == team.id) & (teams.c.tournament_id == tournament_id)
),
values=PlayerStatistics().model_dump(),
)
elo_per_team = determine_ranking_for_stage_item(stage_item, ranking)

for team_id, stage_item_input_id in team_x_stage_item_input_lookup.items():
await update_team_stats(tournament_id, stage_item_input_id, elo_per_team[team_id])
Empty file.
12 changes: 12 additions & 0 deletions backend/bracket/logic/ranking/statistics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from decimal import Decimal

from pydantic import BaseModel

START_ELO = Decimal("1200")


class TeamStatistics(BaseModel):
wins: int = 0
draws: int = 0
losses: int = 0
points: Decimal = Decimal("0.00")
Loading