-
Notifications
You must be signed in to change notification settings - Fork 87
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: port tournament-scheduling quickstart example from Java to Python
- Loading branch information
1 parent
498596e
commit 5157d22
Showing
22 changed files
with
1,564 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
16
python/tournament-scheduling/src/tournament_scheduling/__init__.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
61 changes: 61 additions & 0 deletions
61
python/tournament-scheduling/src/tournament_scheduling/constraints.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")) |
70 changes: 70 additions & 0 deletions
70
python/tournament-scheduling/src/tournament_scheduling/demo_data.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
97
python/tournament-scheduling/src/tournament_scheduling/domain.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)] |
Oops, something went wrong.