-
Notifications
You must be signed in to change notification settings - Fork 85
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: port sports-league-scheduling quickstart example from Java to P…
…ython (#685)
- Loading branch information
1 parent
00b200c
commit 498596e
Showing
22 changed files
with
1,656 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 @@ | ||
= Sports League Scheduling (Python) | ||
|
||
Assign rounds to matches to produce a better schedule for league matches. | ||
|
||
image::./sports-league-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/sports-league-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 = "sports_league_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 = "sports_league_scheduling:main" |
Binary file added
BIN
+124 KB
python/sports-league-scheduling/sports-league-scheduling-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions
16
python/sports-league-scheduling/src/sports_league_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("sports_league_scheduling:app", | ||
port=8080, | ||
log_config="logging.conf", | ||
use_colors=True) | ||
server = uvicorn.Server(config) | ||
server.run() | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |
132 changes: 132 additions & 0 deletions
132
python/sports-league-scheduling/src/sports_league_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,132 @@ | ||
from timefold.solver.score import * | ||
from datetime import time | ||
from typing import Final | ||
|
||
from .domain import * | ||
|
||
|
||
MAX_CONSECUTIVE_MATCHES: Final[int] = 4 | ||
|
||
|
||
@constraint_provider | ||
def define_constraints(constraint_factory: ConstraintFactory): | ||
return [ | ||
matches_on_same_day(constraint_factory), | ||
multiple_consecutive_home_matches(constraint_factory), | ||
multiple_consecutive_away_matches(constraint_factory), | ||
repeat_match_on_the_next_day(constraint_factory), | ||
start_to_away_hop(constraint_factory), | ||
home_to_away_hop(constraint_factory), | ||
away_to_away_hop(constraint_factory), | ||
away_to_home_hop(constraint_factory), | ||
away_to_end_hop(constraint_factory), | ||
classic_matches(constraint_factory) | ||
] | ||
|
||
|
||
def matches_on_same_day(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each_unique_pair(Match, | ||
Joiners.equal(lambda match: match.round.index), | ||
Joiners.filtering(are_teams_overlapping)) | ||
.penalize(HardSoftScore.ONE_HARD) | ||
.as_constraint("Matches on the same day")) | ||
|
||
def are_teams_overlapping(match1 : Match, match2:Match) -> bool: | ||
return (match1.home_team == match2.home_team or match1.home_team == match2.away_team | ||
or match1.away_team == match2.home_team or match1.away_team == match2.away_team) | ||
|
||
|
||
def multiple_consecutive_home_matches(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.join(Team, | ||
Joiners.equal(lambda match: match.home_team, lambda team: team)) | ||
.group_by(lambda match, team: team, | ||
ConstraintCollectors.to_consecutive_sequences(lambda match, team: match.round, | ||
lambda match_round: match_round.index)) | ||
.flatten_last(lambda sequences: sequences.getConsecutiveSequences()) | ||
.filter(lambda team, matches: matches.getCount() >= MAX_CONSECUTIVE_MATCHES) | ||
.penalize(HardSoftScore.ONE_HARD, lambda team, matches: matches.getCount()) | ||
.as_constraint("4 or more consecutive home matches")) | ||
|
||
|
||
def multiple_consecutive_away_matches(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.join(Team, | ||
Joiners.equal(lambda match: match.away_team, lambda team: team)) | ||
.group_by(lambda match, team: team, | ||
ConstraintCollectors.to_consecutive_sequences(lambda match, team: match.round, | ||
lambda match_round: match_round.index)) | ||
.flatten_last(lambda sequences: sequences.getConsecutiveSequences()) | ||
.filter(lambda team, matches: matches.getCount() >= MAX_CONSECUTIVE_MATCHES) | ||
.penalize(HardSoftScore.ONE_HARD, lambda team, matches: matches.getCount()) | ||
.as_constraint("4 or more consecutive away matches")) | ||
|
||
|
||
def repeat_match_on_the_next_day(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.if_exists(Match, | ||
Joiners.equal(lambda match: match.home_team, lambda match: match.away_team), | ||
Joiners.equal(lambda match: match.away_team, lambda match: match.home_team), | ||
Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) | ||
.penalize(HardSoftScore.ONE_HARD) | ||
.as_constraint("Repeat match on the next day")) | ||
|
||
|
||
def start_to_away_hop(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.if_not_exists(Round, Joiners.equal(lambda match: match.round.index - 1, | ||
lambda match_round: match_round.index)) | ||
.penalize(HardSoftScore.ONE_SOFT, lambda match: match.away_team.get_distance(match.home_team)) | ||
.as_constraint("Start to away hop")) | ||
|
||
|
||
def home_to_away_hop(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.join(Match, | ||
Joiners.equal(lambda match: match.home_team, lambda match: match.away_team), | ||
Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) | ||
.penalize(HardSoftScore.ONE_SOFT, lambda match, other_match: match.home_team.get_distance(other_match.home_team)) | ||
.as_constraint("Home to away hop")) | ||
|
||
|
||
def away_to_away_hop(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.join(Match, | ||
Joiners.equal(lambda match: match.away_team, lambda match: match.away_team), | ||
Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) | ||
.penalize(HardSoftScore.ONE_SOFT, lambda match, other_match: match.home_team.get_distance(other_match.home_team)) | ||
.as_constraint("Away to away hop")) | ||
|
||
|
||
def away_to_home_hop(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.join(Match, | ||
Joiners.equal(lambda match: match.away_team, lambda match: match.home_team), | ||
Joiners.equal(lambda match: match.round.index + 1, lambda match: match.round.index)) | ||
.penalize(HardSoftScore.ONE_SOFT, lambda match, other_match: match.home_team.get_distance(match.away_team)) | ||
.as_constraint("Away to home hop")) | ||
|
||
|
||
def away_to_end_hop(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.if_not_exists(Round, Joiners.equal(lambda match: match.round.index + 1, | ||
lambda match_round: match_round.index)) | ||
.penalize(HardSoftScore.ONE_SOFT, lambda match: match.home_team.get_distance(match.away_team)) | ||
.as_constraint("Away to end hop")) | ||
|
||
|
||
def classic_matches(constraint_factory: ConstraintFactory) -> Constraint: | ||
return (constraint_factory | ||
.for_each(Match) | ||
.filter(lambda match: match.classic_match and not match.round.weekend_or_holiday) | ||
.penalize(HardSoftScore.of_soft(1000)) | ||
.as_constraint("Classic matches played on weekends or holidays")) |
Oops, something went wrong.