Skip to content

Commit

Permalink
feat: port sports-league-scheduling quickstart example from Java to P…
Browse files Browse the repository at this point in the history
…ython (#685)
  • Loading branch information
PatrickDiallo23 authored Jan 16, 2025
1 parent 00b200c commit 498596e
Show file tree
Hide file tree
Showing 22 changed files with 1,656 additions and 0 deletions.
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

0 comments on commit 498596e

Please sign in to comment.