Skip to content

Commit

Permalink
chore: port tournament-scheduling quickstart example from Java to Python
Browse files Browse the repository at this point in the history
  • Loading branch information
PatrickDiallo23 committed Jan 22, 2025
1 parent 498596e commit 5157d22
Show file tree
Hide file tree
Showing 22 changed files with 1,564 additions and 0 deletions.
79 changes: 79 additions & 0 deletions python/tournament-scheduling/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
= Tournament Scheduling (Python)

Tournament Scheduling service assigning teams to tournament matches.

image::./tournament-scheduling-screenshot.png[]

* <<prerequisites,Prerequisites>>
* <<run,Run the application>>
* <<test,Test the application>>
[[prerequisites]]
== Prerequisites

. Install https://www.python.org/downloads/[Python 3.11+]

. Install JDK 17+, for example with https://sdkman.io[Sdkman]:
+
----
$ sdk install java
----

[[run]]
== Run the application

. Git clone the timefold-quickstarts repo and navigate to this directory:
+
[source, shell]
----
$ git clone https://github.com/TimefoldAI/timefold-quickstarts.git
...
$ cd timefold-quickstarts/python/tournament-scheduling
----

. Create a virtual environment
+
[source, shell]
----
$ python -m venv .venv
----

. Activate the virtual environment
+
[source, shell]
----
$ . .venv/bin/activate
----

. Install the application
+
[source, shell]
----
$ pip install -e .
----

. Run the application
+
[source, shell]
----
$ run-app
----

. Visit http://localhost:8080 in your browser.

. Click on the *Solve* button.


[[test]]
== Test the application

. Run tests
+
[source, shell]
----
$ pytest
----

== More information

Visit https://timefold.ai[timefold.ai].
30 changes: 30 additions & 0 deletions python/tournament-scheduling/logging.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[loggers]
keys=root,timefold_solver

[handlers]
keys=consoleHandler

[formatters]
keys=simpleFormatter

[logger_root]
level=INFO
handlers=consoleHandler

[logger_timefold_solver]
level=INFO
qualname=timefold.solver
handlers=consoleHandler
propagate=0

[handler_consoleHandler]
class=StreamHandler
level=INFO
formatter=simpleFormatter
args=(sys.stdout,)

[formatter_simpleFormatter]
class=uvicorn.logging.ColourizedFormatter
format={levelprefix:<8} @ {name} : {message}
style={
use_colors=True
20 changes: 20 additions & 0 deletions python/tournament-scheduling/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tournament_scheduling"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = [
'timefold == 999-dev0',
'fastapi == 0.111.0',
'pydantic == 2.7.3',
'uvicorn == 0.30.1',
'pytest == 8.2.2',
'httpx == 0.27.0',
]


[project.scripts]
run-app = "tournament_scheduling:main"
16 changes: 16 additions & 0 deletions python/tournament-scheduling/src/tournament_scheduling/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import uvicorn

from .rest_api import app


def main():
config = uvicorn.Config("tournament_scheduling:app",
port=8080,
log_config="logging.conf",
use_colors=True)
server = uvicorn.Server(config)
server.run()


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from timefold.solver.score import *
from datetime import time
from typing import Final

from .domain import *
from .score_analysis import LoadBalanceJustification


@constraint_provider
def define_constraints(constraint_factory: ConstraintFactory):
return [
one_assignment_per_date_per_team(constraint_factory),
unavailability_penalty(constraint_factory),
fair_assignment_count_per_team(constraint_factory),
evenly_confrontation_count(constraint_factory)

]


def one_assignment_per_date_per_team(constraint_factory: ConstraintFactory) -> Constraint:
return (constraint_factory
.for_each(TeamAssignment)
.join(TeamAssignment,
Joiners.equal(lambda team_assignment: team_assignment.team),
Joiners.equal(lambda team_assignment: team_assignment.day),
Joiners.less_than(lambda team_assignment: team_assignment.id))
.penalize(HardMediumSoftDecimalScore.ONE_HARD)
.as_constraint("oneAssignmentPerDatePerTeam"))


def unavailability_penalty(constraint_factory: ConstraintFactory) -> Constraint:
return (constraint_factory
.for_each(UnavailabilityPenalty)
.if_exists(TeamAssignment,
Joiners.equal(lambda unavailability_penalty: unavailability_penalty.team,
lambda team_assignment: team_assignment.team),
Joiners.equal(lambda unavailability_penalty: unavailability_penalty.day,
lambda team_assignment: team_assignment.day))
.penalize(HardMediumSoftDecimalScore.ONE_HARD)
.as_constraint("unavailabilityPenalty"))


def fair_assignment_count_per_team(constraint_factory: ConstraintFactory) -> Constraint:
return (constraint_factory
.for_each(TeamAssignment)
.group_by(ConstraintCollectors.load_balance(lambda team_assignment: team_assignment.team))
.penalize_decimal(HardMediumSoftDecimalScore.ONE_MEDIUM, lambda balance: balance.unfairness())
.justify_with(lambda balance, score: LoadBalanceJustification(balance.unfairness()))
.as_constraint("fairAssignmentCountPerTeam"))


def evenly_confrontation_count(constraint_factory: ConstraintFactory) -> Constraint:
return (constraint_factory
.for_each(TeamAssignment)
.join(TeamAssignment,
Joiners.equal(lambda team_assignment: team_assignment.day),
Joiners.less_than(lambda assignment: assignment.team.id))
.group_by(ConstraintCollectors.load_balance(lambda assignment, other_assignment: (assignment.team, other_assignment.team)))
.penalize_decimal(HardMediumSoftDecimalScore.ONE_SOFT, lambda balance: balance.unfairness())
.justify_with(lambda balance, score: LoadBalanceJustification(balance.unfairness()))
.as_constraint("evenlyConfrontationCount"))
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import json
from random import Random
from datetime import datetime, time, timedelta
from typing import List, Callable

from .domain import *

random = Random(0)


def id_generator():
current = 0
while True:
yield str(current)
current += 1


def generate_teams() -> List[Team]:
team_names = [
"Maarten", "Geoffrey", "Lukas", "Chris",
"Fred", "Radek", "Maciej"
]
teams = [Team(id=i, name=name) for i, name in enumerate(team_names)]

return teams


def generate_unavailability_penalties(count: int, teams: List[Team], days: List[Day]) -> List[UnavailabilityPenalty]:
penalties = []
while len(penalties) < count:
team = random.choice(teams)
day = random.choice(days)
if all(p.team != team or p.day != day for p in penalties):
penalties.append(UnavailabilityPenalty(team=team, day=day))
return penalties


def generate_team_assignments(count_per_day: int, days: List[Day]) -> List[TeamAssignment]:
assignments = []
count = 0
for day in days:
for i in range(count_per_day):
assignments.append(TeamAssignment(id=count, day=day, index_in_day=i, pinned=False, team=None))
count += 1
return assignments


def generate_demo_data() -> TournamentSchedule:
# Days
count_days = 18
days= [Day(date_index=i) for i in range(count_days)]
# Teams
teams = generate_teams()
# Unavailability penalties
count_unavailability_penalties = 12
unavailability_penalties = generate_unavailability_penalties(count_unavailability_penalties, teams, days)
# Team assignments
count_assignments_per_day = 4
team_assignments = generate_team_assignments(count_assignments_per_day, days)
# Create Schedule
schedule = TournamentSchedule(
teams=teams,
days=days,
unavailability_penalties=unavailability_penalties,
team_assignments=team_assignments,
score=None,
solver_status=SolverStatus.NOT_SOLVING
)

return schedule
97 changes: 97 additions & 0 deletions python/tournament-scheduling/src/tournament_scheduling/domain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from timefold.solver import SolverStatus
from timefold.solver.domain import (planning_entity, planning_solution, PlanningId, PlanningVariable,
PlanningEntityCollectionProperty,
ProblemFactCollectionProperty, ValueRangeProvider,
PlanningScore, PlanningPin)
from typing import Dict, List, Any, Annotated

from .json_serialization import *


class Day(JsonDomainBase):
date_index: int

def __eq__(self, other):
if self is other:
return True
if not isinstance(other, Day):
return False
return self.date_index == other.date_index

def __hash__(self):
return 31 * self.date_index


class Team(JsonDomainBase):
id: Annotated[int, PlanningId]
name: Annotated[str, Field(default=None)]


def __eq__(self, other):
if self is other:
return True
if not isinstance(other, Team):
return False
return self.id == other.id

def __hash__(self):
return 31 * hash(self.id)

def __str__(self):
return self.name if self.name is not None else super().__str__()

def __repr__(self):
return f'Team({self.id}, {self.name})'


class UnavailabilityPenalty(JsonDomainBase):
team: Annotated[Team | None,
IdSerializer,
TeamDeserializer,
Field(default=None)]
day: Annotated[Day | None,
IdSerializer,
DayDeserializer,
Field(default=None)]


@planning_entity
class TeamAssignment(JsonDomainBase):
id: Annotated[int, PlanningId]
day: Annotated[Day | None,
IdSerializer,
DayDeserializer,
Field(default=None)]
index_in_day: int
pinned: Annotated[bool, PlanningPin]
team: Annotated[Team | None,
PlanningVariable,
IdSerializer,
TeamDeserializer,
Field(default=None)]


def __str__(self):
return f'Round-{self.day.dateIndex}({self.index_in_day})'

def __repr__(self):
return f'TeamAssignment({self.id}, {self.day}, {self.index_in_day}, {self.pinned}, {self.team})'


@planning_solution
class TournamentSchedule(JsonDomainBase):
teams: Annotated[list[Team],
ProblemFactCollectionProperty,
ValueRangeProvider]
days: Annotated[list[Day],
ProblemFactCollectionProperty]
unavailability_penalties: Annotated[list[UnavailabilityPenalty],
ProblemFactCollectionProperty]
team_assignments: Annotated[list[TeamAssignment],
PlanningEntityCollectionProperty]
score: Annotated[HardMediumSoftDecimalScore | None,
PlanningScore,
ScoreSerializer,
ScoreValidator,
Field(default=None)]
solver_status: Annotated[SolverStatus | None, Field(default=SolverStatus.NOT_SOLVING)]
Loading

0 comments on commit 5157d22

Please sign in to comment.