Skip to content

●  How to use · Timetabling

Sports league scheduling

Assign every match to a round, limiting travel and long home or away streaks.

What it decides

For every match, the solver picks a round.

Minimal example

from optimaru import sports_league_scheduling as sl
from optimaru.sports_league_scheduling.models import (
    LeagueScheduleModel, MatchModel, RoundModel, TeamModel,
)

teams = ["Reds", "Blues", "Greens", "Golds"]
pairs = [(home, away) for home in range(4) for away in range(4) if home != away]
problem = LeagueScheduleModel(
    teams=[TeamModel(id=name) for name in teams],
    rounds=[RoundModel(index=i, weekend_or_holiday=i % 2 == 1) for i in range(12)],
    matches=[MatchModel(id=f"M{i}", home_team=home, away_team=away)
             for i, (home, away) in enumerate(pairs)],
    distance_to_team=[[0, 90, 120, 200], [90, 0, 60, 150], [120, 60, 0, 110], [200, 150, 110, 0]],
)

core = problem.to_core()
sl.solve(core, time_limit_seconds=2.0, random_seed=42)  # solves in place
solved = LeagueScheduleModel.from_core(core)
print(solved.score_hard, solved.score_soft)
print(sorted((m.round, teams[m.home_team], teams[m.away_team]) for m in solved.matches))

The same use case as a Jupyter notebook, with charts and saved outputs: examples/sports-league-scheduling.ipynb ↗

Input parameters

TeamModel

Field Meaning
id required Team id. Matches refer to teams by position in teams.

RoundModel

Field Meaning
index required Round number. Same-day, next-day, first and last checks all use it. weekend_or_holiday True if classic matches belong here.

MatchModel

Field Meaning
id required Match id. home_team required Position of the home team in teams. away_team required Position of the away team in teams. classic_match True for a derby or other classic.

LeagueScheduleModel

Field Meaning
teams required Teams. rounds required Rounds. matches required Matches to place. distance_to_team Square matrix of distances between teams, by position.

Solve parameters

What you pass to the solve call. Time limit, seed and parallel count are here.

Parameter Default Meaning
time_limit_seconds None Search budget in seconds, from 0 to 1e9. Left unset, it is the config's spent_limit_seconds, 30 by default.
random_seed None Seed for the search. Left unset, it is the config's value, 0 by default.
parallel_solver_count None Independent parallel starts. Each gets its own seed and the full budget, and the best result wins. Left unset, it is the config's value, 1 by default.
stop_at_first_feasible False Stop as soon as hard 0 is reached.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
config None A SolverConfig as the base configuration. Keyword arguments here override it for this solve only; the object you pass is not changed.
constraints None Which constraints to score, and how. Left unset, the defaults apply.
All search options (23)

Set these on a SolverConfig and pass it as config=.

Parameter Default Meaning
best_score_limit — Stop early once the best score reaches this value. By default it never fires.
change_move_weight 0.75 Relative share of change moves. 0 disables them.
costliest_match_sample 4 Random matches drawn per move; the one with the most penalty moves. 0 or 1 selects uniformly.
full_assert False Check every incremental score against a full recompute. Slow; for debugging.
late_acceptance_size 0 Late-acceptance history length. 0 picks it from the instance size.
nearby_probability 0.95 Probability of drawing move partners from nearby entities. 0 disables it.
nearby_size 0 How many neighbours each entity keeps. 0 picks it automatically or disables it, depending on the use case.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
parallel_solver_count 1 Independent parallel starts, each with its own seed and the full budget. The best one wins.
random_seed 0 Seed for the search.
ruin_recreate True Run adaptive ruin-and-recreate kicks when the search stagnates.
ruin_recreate_max_ruin_count 60 Absolute cap on entities removed per kick.
ruin_recreate_max_ruin_fraction 0.3 Largest share of the solution one kick removes.
ruin_recreate_min_ruin_fraction 0.1 Smallest share of the solution one kick removes.
ruin_recreate_operator_decay 0.8 How fast operator weights adapt.
ruin_recreate_polish_steps 5000 Hill-climbing moves that polish each kick. Not counted against step_count_limit.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] Operator rewards for a new best, a better, an accepted and a rejected result.
ruin_recreate_unimproved_step_trigger 5000 Steps without a new best before a kick.
spent_limit_seconds 30.0 Time budget in seconds, from 0 to 1e9.
step_count_limit 0 Stop after this many steps. 0 means no limit.
swap_move_weight 0.25 Relative share of swap moves. 0 disables them.
unimproved_spent_limit_seconds 0.0 Stop after this many seconds without a new best. 0 means no limit.
unimproved_step_count_limit 0 Stop after this many steps without a new best. 0 means no limit.

What comes back

solve takes the engine object, solves it in place and returns it. Convert with from_core to read the result.

MatchModel

Field Meaning
round Position in rounds, not the round's index. -1 means unassigned.

LeagueScheduleModel

Field Meaning
score_hard Hard score. 0 means feasible. score_soft Soft score: travel plus classic penalties, negated.

Constraints

Pick them by calling their classes from optimaru.sports_league_scheduling.constraints, such as StartToAwayHopCost(). Only what you pass is scored; pass nothing to keep the defaults. A list may name a constraint more than once, and each pick is scored on its own. Each one takes these parameters:

Parameter Default Meaning
weight None Multiplier on the constraint's cost, in units of the level in effect. Left unset, the default weight below applies. A constraint whose default weight is 0 is off until you give it a weight.
level None "hard" makes the constraint a must and "soft" a preference, as do Level.HARD and Level.SOFT. Left unset, it keeps the level below. This use case has no medium level, so "medium" raises ValueError. A reward refuses "hard", and a fixed level refuses any change; both raise ValueError.
exponent 1.0 Sign-preserving power on the cost, above 0, taken after offset. 2.0 squares it, so concentrated violations cost much more. It applies per unit, such as a vehicle or a day, or to the total of a constraint scored as one sum.
offset 0.0 Subtracted from the cost x before the exponent, per unit or total like the exponent. It is in the cost's own measure: seconds, minutes, a count and so on. Any finite number of either sign, keyword only. clip decides what happens below it.
clip True True makes offset a threshold: only the part beyond it counts, max(0, x - offset) ** exponent, so a cost at or below it adds nothing. False takes the literal (x - offset) ** exponent, sign kept: a unit below the offset counts negative, as a reward, even one whose cost is 0. On a level that counts toward hard, clip=False with a non-zero offset raises ValueError, since hard 0 must mean no violation. Keyword only.

This continues the minimal example above.

from optimaru.sports_league_scheduling.constraints import (
    ConsecutiveAwayMatchesCost, RepeatMatchOnNextDayCost, TeamConflictCost, get_constraints,
)

picked = get_constraints([
    TeamConflictCost(), RepeatMatchOnNextDayCost(), ConsecutiveAwayMatchesCost(weight=2),
])
custom = problem.to_core()
sl.solve(custom, time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score)
Name Level Default weight What it does
start_to_away_hop_cost Soft 1 Distance the away team travels to a match in the first round.
away_to_end_hop_cost Soft 1 Distance the away team travels home after a match in the last round.
workday_classic_matches_cost Soft 1000 A classic match in a round that is neither a weekend nor a holiday.
team_conflict_cost Hard 1 Pairs of matches in the same round sharing at least one team.
consecutive_home_matches_cost Hard 1 Teams playing at home four or more rounds in a row. The penalty is the run length.
consecutive_away_matches_cost Hard 1 Teams playing away four or more rounds in a row. The penalty is the run length.
repeat_match_on_next_day_cost Hard 1 A match whose return fixture is in the very next round.
home_to_away_hop_cost Soft 1 Distance a team travels going away in the round after a home match.
away_to_away_hop_cost Soft 1 Distance a team on two consecutive away rounds travels between the grounds.
away_to_home_hop_cost Soft 1 Distance a team travels back to play at home after an away round.

How constraints work →

Starting from an existing solution

This needs the latest release: pip install -U optimaru.

with_initial_solution(problem, solution) returns a copy of problem whose search starts from solution, a solved LeagueScheduleModel or that model as a JSON dict. solve takes the engine object, so convert the result with to_core().

This continues the minimal example above.

restart = sl.with_initial_solution(problem, solved).to_core()
sl.solve(restart, time_limit_seconds=2.0, random_seed=43)
print(restart.score)
  • Matches are matched by id.
  • A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the matches it leaves out are constructed.
  • With time_limit_seconds=0 the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this: step_count_limit = 0 means no limit.
  • round is a position in rounds, not the round's index, so a solution that lists different rounds, or the same ones in another order, raises ValueError. So do unknown ids and out-of-range values.

Exceptions and limits

  • Round adjacency follows each round's index, so with sparse indices such as 0, 1, 4 the gap counts as no round.
  • You supply the list of matches. The solver places them; it does not build the fixture list.