diff --git a/python/sports-league-scheduling/README.adoc b/python/sports-league-scheduling/README.adoc new file mode 100644 index 0000000000..c9fa4a7ede --- /dev/null +++ b/python/sports-league-scheduling/README.adoc @@ -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 + +. 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]. diff --git a/python/sports-league-scheduling/logging.conf b/python/sports-league-scheduling/logging.conf new file mode 100644 index 0000000000..b9dd947471 --- /dev/null +++ b/python/sports-league-scheduling/logging.conf @@ -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 diff --git a/python/sports-league-scheduling/pyproject.toml b/python/sports-league-scheduling/pyproject.toml new file mode 100644 index 0000000000..2671cd975d --- /dev/null +++ b/python/sports-league-scheduling/pyproject.toml @@ -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" diff --git a/python/sports-league-scheduling/sports-league-scheduling-screenshot.png b/python/sports-league-scheduling/sports-league-scheduling-screenshot.png new file mode 100644 index 0000000000..cfdfb28274 Binary files /dev/null and b/python/sports-league-scheduling/sports-league-scheduling-screenshot.png differ diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/__init__.py b/python/sports-league-scheduling/src/sports_league_scheduling/__init__.py new file mode 100644 index 0000000000..a9ff525cae --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/__init__.py @@ -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() diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/constraints.py b/python/sports-league-scheduling/src/sports_league_scheduling/constraints.py new file mode 100644 index 0000000000..7a67108d39 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/constraints.py @@ -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")) diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/demo_data.py b/python/sports-league-scheduling/src/sports_league_scheduling/demo_data.py new file mode 100644 index 0000000000..7c62b43c7c --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/demo_data.py @@ -0,0 +1,126 @@ +import json +from random import Random +from datetime import datetime, time, timedelta +from typing import List, Callable + +from .domain import * + +random = Random(0) +DISTANCE_IN_KM = [ + [0, 2163, 2163, 2160, 2156, 2156, 2163, 340, 1342, 512, 3038, 1526, 2054, 2054], + [2163, 0, 11, 50, 813, 813, 11, 1967, 842, 1661, 1139, 1037, 202, 202], + [2163, 11, 0, 50, 813, 813, 11, 1967, 842, 1661, 1139, 1037, 202, 202], + [2160, 50, 50, 0, 862, 862, 50, 1957, 831, 1655, 1180, 1068, 161, 161], + [2160, 813, 813, 862, 0, 1, 813, 2083, 1160, 1741, 910, 644, 600, 600], + [2160, 813, 813, 862, 1, 0, 813, 2083, 1160, 1741, 910, 644, 600, 600], + [2163, 11, 11, 50, 813, 813, 0, 1967, 842, 1661, 1139, 1037, 202, 202], + [340, 1967, 1967, 1957, 2083, 2083, 1967, 0, 1126, 341, 2926, 1490, 1836, 1836], + [1342, 842, 842, 831, 1160, 1160, 842, 1126, 0, 831, 1874, 820, 714, 714], + [512, 1661, 1661, 1655, 1741, 1741, 1661, 341, 831, 0, 2589, 1151, 1545, 1545], + [3038, 1139, 1139, 1180, 910, 910, 1139, 2926, 1874, 2589, 0, 1552, 1340, 1340], + [1526, 1037, 1037, 1068, 644, 644, 1037, 1490, 820, 1151, 1552, 0, 1077, 1077], + [2054, 202, 202, 161, 600, 600, 202, 1836, 714, 1545, 1340, 1077, 0, 14], + [2054, 202, 202, 161, 600, 600, 202, 1836, 714, 1545, 1340, 1077, 14, 0], +] + + +def id_generator(): + current = 0 + while True: + yield str(current) + current += 1 + + +def generate_rounds(count_rounds : int) -> List[Round]: + today = datetime.now() + rounds = [Round(index=i, weekend_or_holiday=False) for i in range(count_rounds)] + + # Mark weekend rounds as important + for round_obj in rounds: + future_date = today + timedelta(days=round_obj.index) + if future_date.weekday() in (5, 6): # Saturday or Sunday + round_obj.weekend_or_holiday = True + + return rounds + + +def generate_teams() -> List[Team]: + team_names = [ + "Cruzeiro", "Argentinos Jr.", "Boca Juniors", "Estudiantes", "Independente", + "Racing", "River Plate", "Flamengo", "Gremio", "Santos", + "Colo-Colo", "Olimpia", "Nacional", "Penharol" + ] + + teams = [Team(id=str(i + 1), name=name, distance_to_team={}) for i, name in enumerate(team_names)] + + # Assign distances + for i, team in enumerate(teams): + team.distance_to_team = { + teams[j].id: DISTANCE_IN_KM[i][j] + for j in range(len(teams)) + if i != j + } + + return teams + +def generate_matches(teams: List[Team]) -> List[Match]: + reciprocal_match = None + matches = [ + Match(id=f'{team1.id}-{team2.id}', home_team=team1, away_team=team2, classic_match=False, round=None) + for team1 in teams + for team2 in teams + if team1 != team2 + ] + + # 5% classic matches + apply_random_value( + count=int(len(matches) * 0.05), + values = matches, + filter_func = lambda match_league: not match_league.classic_match, + consumer_func = lambda match_league: setattr(match_league, 'classic_match', True) + ) + + # Ensure reciprocity for classic matches + for match in matches: + if match.classic_match: + reciprocal_match = next((m for m in matches if m.home_team == match.away_team and m.away_team == match.home_team), None) + if reciprocal_match: + reciprocal_match.classic_match = True + + return matches + + +def apply_random_value(count: int, values: List, filter_func: Callable, consumer_func: Callable) -> None: + filtered_values = [value for value in values if filter_func(value)] + size = len(filtered_values) + + for _ in range(count): + if size > 0: + selected_value = random.choice(filtered_values) + consumer_func(selected_value) + filtered_values.remove(selected_value) + size -= 1 + else: + break + + +def generate_demo_data() -> LeagueSchedule: + count_rounds = 32 + # Rounds + rounds = generate_rounds(count_rounds) + # Teams + teams = generate_teams() + # Matches + matches = generate_matches(teams) + + # Create Schedule + schedule = LeagueSchedule( + id="demo-schedule", + rounds=rounds, + teams=teams, + matches=matches, + score=None, + solver_status=None + ) + + return schedule \ No newline at end of file diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/domain.py b/python/sports-league-scheduling/src/sports_league_scheduling/domain.py new file mode 100644 index 0000000000..4ef22a45ed --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/domain.py @@ -0,0 +1,119 @@ +from timefold.solver import SolverStatus +from timefold.solver.domain import (planning_entity, planning_solution, PlanningId, PlanningVariable, + PlanningEntityCollectionProperty, + ProblemFactCollectionProperty, ValueRangeProvider, + PlanningScore) +from timefold.solver.score import HardSoftScore +from typing import Dict, List, Any, Annotated + +from .json_serialization import * + + +class Team(JsonDomainBase): + id: str + name: str + distance_to_team: Annotated[Dict[str, int], + DistanceToTeamValidator, + Field(default_factory=dict)] + + + def get_distance(self, other_team: "Team") -> int: + """ + Get the distance to another team. + """ + if not isinstance(other_team, Team): + raise TypeError(f"Expected a Team, got {type(other_team)}") + return self.distance_to_team.get(other_team.id, 0) + + 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 hash(self.id) + + def __str__(self): + return self.id + + def __repr__(self): + return f'Team({self.id}, {self.name}, {self.distance_to_team})' + + +class Round(JsonDomainBase): + index: Annotated[int, PlanningId] + # Rounds scheduled on weekends and holidays. It's common for classic matches to be scheduled on weekends or holidays. + weekend_or_holiday: Annotated[bool, Field(default=False)] + + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, Round): + return False + return self.index == other.index + + def __hash__(self): + return 31 * self.index + + def __str__(self): + return f'Round-{self.index}' + + def __repr__(self): + return f'Round({self.index}, {self.weekendOrHoliday})' + + +@planning_entity +class Match(JsonDomainBase): + id: Annotated[str, PlanningId] + home_team: Annotated[Team, + IdStrSerializer, + TeamDeserializer] + away_team: Annotated[Team, + IdStrSerializer, + TeamDeserializer] + # A classic/important match can impact aspects like revenue (e.g., derby) + classic_match: Annotated[bool, Field(default=False)] + round: Annotated[Round | None, + PlanningVariable, + IdIntSerializer, + RoundDeserializer, + Field(default=None)] + + + def __eq__(self, other): + if self is other: + return True + if not isinstance(other, Match): + return False + return self.id == other.id + + def __hash__(self): + return hash(self.id) + + def __str__(self): + return f'{self.home_team} + {self.away_team}' + + def __repr__(self): + return f'Match({self.id}, {self.home_team}, {self.away_team}, {self.classic_match})' + + +@planning_solution +class LeagueSchedule(JsonDomainBase): + id: str + rounds: Annotated[list[Round], + ProblemFactCollectionProperty, + ValueRangeProvider] + teams: Annotated[list[Team], + ProblemFactCollectionProperty, + ValueRangeProvider] + matches: Annotated[list[Match], + PlanningEntityCollectionProperty] + score: Annotated[HardSoftScore | None, + PlanningScore, + ScoreSerializer, + ScoreValidator, + Field(default=None)] + solver_status: Annotated[SolverStatus | None, Field(default=SolverStatus.NOT_SOLVING)] \ No newline at end of file diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/json_serialization.py b/python/sports-league-scheduling/src/sports_league_scheduling/json_serialization.py new file mode 100644 index 0000000000..e6784cbd96 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/json_serialization.py @@ -0,0 +1,71 @@ +from timefold.solver.score import HardSoftScore +from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, BeforeValidator, ValidationInfo +from pydantic.alias_generators import to_camel +from typing import Any, Dict + + +def make_list_item_validator(key: str): + def validator(v: Any, info: ValidationInfo) -> Any: + if v is None: + return None + + if not isinstance(v, (str, int)): + return v + + if not info.context or key not in info.context: + raise ValueError(f"Context is missing or does not contain key '{key}'.") + + context_data = info.context.get(key) + if v not in context_data: + raise ValueError(f"Value '{v}' not found in context for key '{key}'.") + + return context_data[v] + + return BeforeValidator(validator) + + +RoundDeserializer = make_list_item_validator('rounds') +TeamDeserializer = make_list_item_validator('teams') + +IdStrSerializer = PlainSerializer( + lambda item: item.id if item is not None else None, + return_type=str | None +) +IdIntSerializer = PlainSerializer( + lambda item: item.index if item is not None else None, + return_type=int | None +) +ScoreSerializer = PlainSerializer(lambda score: str(score) if score is not None else None, + return_type=str | None) + + +def validate_score(v: Any, info: ValidationInfo) -> Any: + if isinstance(v, HardSoftScore) or v is None: + return v + if isinstance(v, str): + return HardSoftScore.parse(v) + raise ValueError('"score" should be a string') + + +def validate_distance_to_team(value: Any, info: ValidationInfo) -> Dict[str, int]: + if not isinstance(value, dict): + raise ValueError("distance_to_team must be a dictionary.") + + for key, val in value.items(): + if not isinstance(key, str): + raise ValueError(f"Key {key} in distance_to_team must be a Team instance.") + if not isinstance(val, int): + raise ValueError(f"Value for {key} must be an integer.") + + return value + + +ScoreValidator = BeforeValidator(validate_score) +DistanceToTeamValidator = BeforeValidator(validate_distance_to_team) + +class JsonDomainBase(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + from_attributes=True, + ) diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/rest_api.py b/python/sports-league-scheduling/src/sports_league_scheduling/rest_api.py new file mode 100644 index 0000000000..d7cf195041 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/rest_api.py @@ -0,0 +1,111 @@ +from fastapi import FastAPI, Depends, Request +from fastapi.staticfiles import StaticFiles +from typing import Annotated, Final +from uuid import uuid4 +from datetime import datetime +from .domain import * +from .score_analysis import * +from .demo_data import generate_demo_data +from .solver import solver_manager, solution_manager + +app = FastAPI(docs_url='/q/swagger-ui') +MAX_JOBS_CACHE_SIZE: Final[int] = 2 +data_sets: Dict[str, dict] = {} + + +@app.get("/demo-data") +async def get_demo_data(): + return generate_demo_data() + + +async def setup_context(request: Request) -> LeagueSchedule: + json = await request.json() + return LeagueSchedule.model_validate(json, + context={ + 'rounds': { + match_round['index']: Round.model_validate(match_round) for + match_round in json.get('rounds', []) + }, + 'teams': { + team['id']: Team.model_validate(team) for + team in json.get('teams', []) + }, + }) + + +def clean_jobs(): + """ + The method retains only the records of the last MAX_JOBS_CACHE_SIZE completed jobs by removing the oldest ones. + """ + global data_sets + if len(data_sets) <= MAX_JOBS_CACHE_SIZE: + return + + completed_jobs = [ + (job_id, job_data) + for job_id, job_data in data_sets.items() + if job_data["schedule"] is not None + ] + + completed_jobs.sort(key=lambda job: job[1]["created_at"]) + + for job_id, _ in completed_jobs[:len(completed_jobs) - MAX_JOBS_CACHE_SIZE]: + del data_sets[job_id] + + +def update_league_schedule(problem_id: str, league_schedule: LeagueSchedule): + global data_sets + data_sets[problem_id]["schedule"] = league_schedule + + +@app.post("/schedules") +async def solve_schedule(league_schedule: Annotated[LeagueSchedule, Depends(setup_context)]) -> str: + job_id = str(uuid4()) + data_sets[job_id] = { + "schedule": league_schedule, + "created_at": datetime.now(), + "exception": None, + } + solver_manager.solve_and_listen(job_id, league_schedule, + lambda solution: update_league_schedule(job_id, solution)) + clean_jobs() + return job_id + + +@app.get("/schedules/{problem_id}") +async def get_league_schedule(problem_id: str) -> LeagueSchedule: + league_schedule = data_sets[problem_id]["schedule"] + return league_schedule.model_copy(update={ + 'solver_status': solver_manager.get_solver_status(problem_id) + }) + + +@app.get("/schedules/{job_id}/status") +async def get_schedule_status(job_id: str) -> dict: + league_schedule = data_sets[job_id]["schedule"] + return {"solver_status": league_schedule.solver_status} + + +@app.put("/schedules/analyze") +async def analyze_timetable(league_schedule: Annotated[LeagueSchedule, Depends(setup_context)]) -> dict: + return {'constraints': [ConstraintAnalysisDTO( + name=constraint.constraint_name, + weight=constraint.weight, + score=constraint.score, + matches=[ + MatchAnalysisDTO( + name=match.constraint_ref.constraint_name, + score=match.score, + justification=match.justification + ) + for match in constraint.matches + ] + ) for constraint in solution_manager.analyze(league_schedule).constraint_analyses]} + + +@app.delete("/schedules/{problem_id}") +async def stop_solving(problem_id: str) -> None: + solver_manager.terminate_early(problem_id) + + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/score_analysis.py b/python/sports-league-scheduling/src/sports_league_scheduling/score_analysis.py new file mode 100644 index 0000000000..3884952fe7 --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/score_analysis.py @@ -0,0 +1,18 @@ +from timefold.solver.score import ConstraintJustification +from dataclasses import dataclass, field + +from .json_serialization import * +from .domain import * + + +class MatchAnalysisDTO(JsonDomainBase): + name: str + score: Annotated[HardSoftScore, ScoreSerializer] + justification: object + + +class ConstraintAnalysisDTO(JsonDomainBase): + name: str + weight: Annotated[HardSoftScore, ScoreSerializer] + matches: list[MatchAnalysisDTO] + score: Annotated[HardSoftScore, ScoreSerializer] diff --git a/python/sports-league-scheduling/src/sports_league_scheduling/solver.py b/python/sports-league-scheduling/src/sports_league_scheduling/solver.py new file mode 100644 index 0000000000..96275ece1a --- /dev/null +++ b/python/sports-league-scheduling/src/sports_league_scheduling/solver.py @@ -0,0 +1,21 @@ +from timefold.solver import SolverManager, SolverFactory, SolutionManager +from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig, + TerminationConfig, Duration) + +from .domain import * +from .constraints import define_constraints + + +solver_config = SolverConfig( + solution_class=LeagueSchedule, + entity_class_list=[Match], + score_director_factory_config=ScoreDirectorFactoryConfig( + constraint_provider_function=define_constraints + ), + termination_config=TerminationConfig( + spent_limit=Duration(seconds=30) + ) +) + +solver_manager = SolverManager.create(SolverFactory.create(solver_config)) +solution_manager = SolutionManager.create(solver_manager) diff --git a/python/sports-league-scheduling/static/app.js b/python/sports-league-scheduling/static/app.js new file mode 100644 index 0000000000..fea847c161 --- /dev/null +++ b/python/sports-league-scheduling/static/app.js @@ -0,0 +1,322 @@ +let autoRefreshIntervalId = null; +const formatter = JSJoda.DateTimeFormatter.ofPattern("MM/dd/YYYY HH:mm").withLocale(JSJodaLocale.Locale.ENGLISH); + +const zoomMin = 1000 * 60 * 60 * 24 // 1 day in milliseconds +const zoomMax = 1000 * 60 * 60 * 24 * 7 * 4 // 2 weeks in milliseconds + +const byTimelineOptions = { + timeAxis: {scale: "day"}, + orientation: {axis: "top"}, + stack: false, + xss: {disabled: true}, // Items are XSS safe through JQuery + zoomMin: zoomMin, + showCurrentTime: false, +}; + +const byTeamPanel = document.getElementById("byTeamPanel"); +let byTeamGroupData = new vis.DataSet(); +let byTeamItemData = new vis.DataSet(); +let byTeamTimeline = new vis.Timeline(byTeamPanel, byTeamItemData, byTeamGroupData, byTimelineOptions); + +let scheduleId = null; +let loadedSchedule = null; +let viewType = "T"; + +$(document).ready(function () { + replaceQuickstartTimefoldAutoHeaderFooter(); + + $("#solveButton").click(function () { + solve(); + }); + $("#stopSolvingButton").click(function () { + stopSolving(); + }); + $("#analyzeButton").click(function () { + analyze(); + }); + $("#byTeamTab").click(function () { + viewType = "T"; + refreshSchedule(); + }); + + setupAjax(); + refreshSchedule(); +}); + +function setupAjax() { + $.ajaxSetup({ + headers: { + 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job + } + }); + + // Extend jQuery to support $.put() and $.delete() + jQuery.each(["put", "delete"], function (i, method) { + jQuery[method] = function (url, data, callback, type) { + if (jQuery.isFunction(data)) { + type = type || callback; + callback = data; + data = undefined; + } + return jQuery.ajax({ + url: url, type: method, dataType: type, data: data, success: callback + }); + }; + }); +} + +function refreshSchedule() { + let path = "/schedules/" + scheduleId; + if (scheduleId === null) { + path = "/demo-data"; + } + + $.getJSON(path, function (schedule) { + loadedSchedule = schedule; + $('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule)); + renderSchedule(schedule); + }) + .fail(function (xhr, ajaxOptions, thrownError) { + showError("Getting the schedule has failed.", xhr); + refreshSolvingButtons(false); + }); +} + +function renderSchedule(schedule) { + refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); + $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); + + if (viewType === "T") { + renderScheduleByTeam(schedule); + } +} + +function renderScheduleByTeam(schedule) { + const unassigned = $("#unassigned"); + unassigned.children().remove(); + byTeamGroupData.clear(); + byTeamItemData.clear(); + + const teamMap = new Map(); + $.each(schedule.teams.sort((t1, t2) => t1.name.localeCompare(t2.name)), (_, team) => { + teamMap.set(team.id, team); + let content = `
${team.name}
`; + byTeamGroupData.add({ + id: team.id, + content: content, + }); + }); + + + const currentDate = JSJoda.LocalDate.now(); + $.each(schedule.matches, (_, match) => { + const homeTeam = teamMap.get(match.homeTeam); + const awayTeam = teamMap.get(match.awayTeam); + if (match.round == null) { + const unassignedElement = $(`
`) + .append($(`
`).text(`${homeTeam.name} x ${awayTeam.name}`)); + + unassigned.append($(`
`).append($(`
`).append(unassignedElement))); + } else { + const byHomeTeamElement = $("
").append($("
").append($(`
`).text(awayTeam.name))).append($(``).append("")); + const byAwayTeamElement = $("
").append($("
").append($(`
`).text(homeTeam.name))).append($(``).append("")); + byTeamItemData.add({ + id: `${match.id}-1`, + group: homeTeam.id, + content: byHomeTeamElement.html(), + start: currentDate.plusDays(match.round).toString(), + end: currentDate.plusDays(match.round + 1).toString(), + style: `background-color: ${match.classicMatch ? '#198754CF' : '#97b0f8'}` + }); + byTeamItemData.add({ + id: `${match.id}-2`, + group: awayTeam.id, + content: byAwayTeamElement.html(), + start: currentDate.plusDays(match.round).toString(), + end: currentDate.plusDays(match.round + 1).toString(), + style: `background-color: ${match.classicMatch ? '#198754CF' : '#97b0f8'}` + }); + } + }); + + byTeamTimeline.setWindow(JSJoda.LocalDate.now().toString(), JSJoda.LocalDate.now().plusDays(7).toString()); +} + +function solve() { + $.post("/schedules", JSON.stringify(loadedSchedule), function (data) { + scheduleId = data; + refreshSolvingButtons(true); + }).fail(function (xhr, ajaxOptions, thrownError) { + showError("Start solving failed.", xhr); + refreshSolvingButtons(false); + }, "text"); +} + +function analyze() { + new bootstrap.Modal("#scoreAnalysisModal").show() + const scoreAnalysisModalContent = $("#scoreAnalysisModalContent"); + scoreAnalysisModalContent.children().remove(); + if (loadedSchedule.score == null || loadedSchedule.score.indexOf('init') != -1) { + scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button."); + } else { + $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`); + $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) { + let constraints = scoreAnalysis.constraints; + constraints.sort((a, b) => { + let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score); + if (aComponents.hard < 0 && bComponents.hard > 0) return -1; + if (aComponents.hard > 0 && bComponents.soft < 0) return 1; + if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) { + return -1; + } else { + if (aComponents.medium < 0 && bComponents.medium > 0) return -1; + if (aComponents.medium > 0 && bComponents.medium < 0) return 1; + if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) { + return -1; + } else { + if (aComponents.soft < 0 && bComponents.soft > 0) return -1; + if (aComponents.soft > 0 && bComponents.soft < 0) return 1; + + return Math.abs(bComponents.soft) - Math.abs(aComponents.soft); + } + } + }); + constraints.map((e) => { + let components = getScoreComponents(e.weight); + e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft'); + e.weight = components[e.type]; + let scores = getScoreComponents(e.score); + e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft); + }); + scoreAnalysis.constraints = constraints; + + scoreAnalysisModalContent.children().remove(); + scoreAnalysisModalContent.text(""); + + const analysisTable = $(``).css({textAlign: 'center'}); + const analysisTHead = $(``).append($(``) + .append($(``)) + .append($(``).css({textAlign: 'left'})) + .append($(``)) + .append($(``)) + .append($(``)) + .append($(``)) + .append($(``))); + analysisTable.append(analysisTHead); + const analysisTBody = $(``) + $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => { + let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '' : ''; + if (!icon) icon = constraintAnalysis.matches.length == 0 ? '' : ''; + + let row = $(``); + row.append($(`
ConstraintType# MatchesWeightScore
`).html(icon)) + .append($(``).text(constraintAnalysis.name).css({textAlign: 'left'})) + .append($(``).text(constraintAnalysis.type)) + .append($(``).html(`${constraintAnalysis.matches.length}`)) + .append($(``).text(constraintAnalysis.weight)) + .append($(``).text(constraintAnalysis.implicitScore)); + analysisTBody.append(row); + row.append($(``)); + }); + analysisTable.append(analysisTBody); + scoreAnalysisModalContent.append(analysisTable); + }).fail(function (xhr, ajaxOptions, thrownError) { + showError("Analyze failed.", xhr); + }, "text"); + } +} + +function getScoreComponents(score) { + let components = {hard: 0, medium: 0, soft: 0}; + + $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => { + components[parts[2]] = parseInt(parts[1], 10); + }); + + return components; +} + +function refreshSolvingButtons(solving) { + if (solving) { + $("#solveButton").hide(); + $("#stopSolvingButton").show(); + if (autoRefreshIntervalId == null) { + autoRefreshIntervalId = setInterval(refreshSchedule, 2000); + } + } else { + $("#solveButton").show(); + $("#stopSolvingButton").hide(); + if (autoRefreshIntervalId != null) { + clearInterval(autoRefreshIntervalId); + autoRefreshIntervalId = null; + } + } +} + +function stopSolving() { + $.delete("/schedules/" + scheduleId, function () { + refreshSolvingButtons(false); + refreshSchedule(); + }).fail(function (xhr, ajaxOptions, thrownError) { + showError("Stop solving failed.", xhr); + }); +} + +function copyTextToClipboard(id) { + const text = $("#" + id).text().trim(); + + const dummy = document.createElement("textarea"); + document.body.appendChild(dummy); + dummy.value = text; + dummy.select(); + document.execCommand("copy"); + document.body.removeChild(dummy); +} + +// TODO: move to the webjar +function replaceQuickstartTimefoldAutoHeaderFooter() { + const timefoldHeader = $("header#timefold-auto-header"); + if (timefoldHeader != null) { + timefoldHeader.addClass("bg-black") + timefoldHeader.append($(`
+ +
`)); + } + + const timefoldFooter = $("footer#timefold-auto-footer"); + if (timefoldFooter != null) { + timefoldFooter.append($(``)); + } +} diff --git a/python/sports-league-scheduling/static/index.html b/python/sports-league-scheduling/static/index.html new file mode 100644 index 0000000000..94b076615a --- /dev/null +++ b/python/sports-league-scheduling/static/index.html @@ -0,0 +1,159 @@ + + + + + + Sports League Scheduling - Timefold Solver for Python + + + + + + + + +
+ +
+
+
+
+
+
+

Sports League Scheduling Solver

+

Generate the optimal schedule for your sports league matches.

+ +
+ + + Score: ? + + +
+ +
+
+
+
+
+
+
+ +

Unassigned

+
+
+ +
+

REST API Guide

+ +

Sports League Scheduling solver integration via cURL

+ +

1. Download demo data

+
+            
+            curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data -o sample.json
+    
+ +

2. Post the sample data for solving

+

The POST operation returns a jobId that should be used in subsequent commands.

+
+            
+            curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json
+    
+ +

3. Get the current status and score

+
+            
+            curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status
+    
+ +

4. Get the complete solution

+
+            
+            curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId} -o solution.json
+    
+ +

5. Fetch the analysis of the solution

+
+            
+            curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json
+    
+ +

6. Terminate solving early

+
+            
+            curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}
+    
+
+ +
+

REST API Reference

+
+ + +
+
+
+
+ + + + + + + + + + + + diff --git a/python/sports-league-scheduling/static/webjars/timefold/css/timefold-webui.css b/python/sports-league-scheduling/static/webjars/timefold/css/timefold-webui.css new file mode 100644 index 0000000000..0d729db03d --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/css/timefold-webui.css @@ -0,0 +1,60 @@ +:root { + /* Keep in sync with .navbar height on a large screen. */ + --ts-navbar-height: 109px; + + --ts-violet-1-rgb: #3E00FF; + --ts-violet-2-rgb: #3423A6; + --ts-violet-3-rgb: #2E1760; + --ts-violet-4-rgb: #200F4F; + --ts-violet-5-rgb: #000000; /* TODO FIXME */ + --ts-violet-dark-1-rgb: #b6adfd; + --ts-violet-dark-2-rgb: #c1bbfd; + --ts-gray-rgb: #666666; + --ts-white-rgb: #FFFFFF; + --ts-light-rgb: #F2F2F2; + --ts-gray-border: #c5c5c5; + + --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */ + --bs-body-bg: var(--ts-light-rgb); /* link to html bg */ + --bs-link-color: var(--ts-violet-1-rgb); + --bs-link-hover-color: var(--ts-violet-2-rgb); + + --bs-navbar-color: var(--ts-white-rgb); + --bs-navbar-hover-color: var(--ts-white-rgb); + --bs-nav-link-font-size: 18px; + --bs-nav-link-font-weight: 400; + --bs-nav-link-color: var(--ts-white-rgb); + --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb); +} +.btn { + --bs-btn-border-radius: 1.5rem; +} +.btn-primary { + --bs-btn-bg: var(--ts-violet-1-rgb); + --bs-btn-border-color: var(--ts-violet-1-rgb); + --bs-btn-hover-bg: var(--ts-violet-2-rgb); + --bs-btn-hover-border-color: var(--ts-violet-2-rgb); + --bs-btn-active-bg: var(--ts-violet-2-rgb); + --bs-btn-active-border-bg: var(--ts-violet-2-rgb); + --bs-btn-disabled-bg: var(--ts-violet-1-rgb); + --bs-btn-disabled-border-color: var(--ts-violet-1-rgb); +} +.btn-outline-primary { + --bs-btn-color: var(--ts-violet-1-rgb); + --bs-btn-border-color: var(--ts-violet-1-rgb); + --bs-btn-hover-bg: var(--ts-violet-1-rgb); + --bs-btn-hover-border-color: var(--ts-violet-1-rgb); + --bs-btn-active-bg: var(--ts-violet-1-rgb); + --bs-btn-active-border-color: var(--ts-violet-1-rgb); + --bs-btn-disabled-color: var(--ts-violet-1-rgb); + --bs-btn-disabled-border-color: var(--ts-violet-1-rgb); +} +.navbar-dark { + --bs-link-color: var(--ts-violet-dark-1-rgb); + --bs-link-hover-color: var(--ts-violet-dark-2-rgb); + --bs-navbar-color: var(--ts-white-rgb); + --bs-navbar-hover-color: var(--ts-white-rgb); +} +.nav-pills { + --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb); +} diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-favicon.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-favicon.svg new file mode 100644 index 0000000000..f5bece2d39 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-favicon.svg @@ -0,0 +1,25 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg new file mode 100644 index 0000000000..26aa96ab2f --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-negative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg new file mode 100644 index 0000000000..12cf1da644 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-horizontal-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg new file mode 100644 index 0000000000..7c871643b2 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/img/timefold-logo-stacked-positive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/python/sports-league-scheduling/static/webjars/timefold/js/timefold-webui.js b/python/sports-league-scheduling/static/webjars/timefold/js/timefold-webui.js new file mode 100644 index 0000000000..dc8853c3f4 --- /dev/null +++ b/python/sports-league-scheduling/static/webjars/timefold/js/timefold-webui.js @@ -0,0 +1,142 @@ +function replaceTimefoldAutoHeaderFooter() { + const timefoldHeader = $("header#timefold-auto-header"); + if (timefoldHeader != null) { + timefoldHeader.addClass("bg-black") + timefoldHeader.append( + $(`
+ +
`)); + } + const timefoldFooter = $("footer#timefold-auto-footer"); + if (timefoldFooter != null) { + timefoldFooter.append( + $(``)); + + applicationInfo(); + } + +} + +function showSimpleError(title) { + const notification = $(`