Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: port sports-league-scheduling quickstart example from Java to Python #685

Open
wants to merge 2 commits into
base: development
Choose a base branch
from
Open
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
79 changes: 79 additions & 0 deletions python/sports-league-scheduling/README.adoc
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].
30 changes: 30 additions & 0 deletions python/sports-league-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/sports-league-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 = "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"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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()
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"))
Loading