● 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 teams.RoundModel
| Field | Meaning |
|---|
index required weekend_ or_ holiday MatchModel
| Field | Meaning |
|---|
id required home_ team required teams.away_ team required teams.classic_ match LeagueScheduleModel
| Field | Meaning |
|---|
teams required rounds required matches required distance_ to_ team Solve parameters
What you pass to the solve call. Time limit, seed and parallel count are here.
| Parameter | Default | Meaning |
|---|---|---|
time_ | None | Search budget in seconds, from 0 to 1e9. Left unset, it is the config's spent_limit_seconds, 30 by default. |
random_ | None | Seed for the search. Left unset, it is the config's value, 0 by default. |
parallel_ | 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_ | False | Stop as soon as hard 0 is reached. |
on_ | 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_ | — | Stop early once the best score reaches this value. By default it never fires. |
change_ | 0.75 | Relative share of change moves. 0 disables them. |
costliest_ | 4 | Random matches drawn per move; the one with the most penalty moves. 0 or 1 selects uniformly. |
full_ | False | Check every incremental score against a full recompute. Slow; for debugging. |
late_ | 0 | Late-acceptance history length. 0 picks it from the instance size. |
nearby_ | 0.95 | Probability of drawing move partners from nearby entities. 0 disables it. |
nearby_ | 0 | How many neighbours each entity keeps. 0 picks it automatically or disables it, depending on the use case. |
on_ | None | Callback run on each new best solution. If it raises, the solve stops and raises the same exception. |
parallel_ | 1 | Independent parallel starts, each with its own seed and the full budget. The best one wins. |
random_ | 0 | Seed for the search. |
ruin_ | True | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 60 | Absolute cap on entities removed per kick. |
ruin_ | 0.3 | Largest share of the solution one kick removes. |
ruin_ | 0.1 | Smallest share of the solution one kick removes. |
ruin_ | 0.8 | How fast operator weights adapt. |
ruin_ | 5000 | Hill-climbing moves that polish each kick. Not counted against step_count_limit. |
ruin_ | [33.0, 9.0, 3.0, 0.0] | Operator rewards for a new best, a better, an accepted and a rejected result. |
ruin_ | 5000 | Steps without a new best before a kick. |
spent_ | 30.0 | Time budget in seconds, from 0 to 1e9. |
step_ | 0 | Stop after this many steps. 0 means no limit. |
swap_ | 0.25 | Relative share of swap moves. 0 disables them. |
unimproved_ | 0.0 | Stop after this many seconds without a new best. 0 means no limit. |
unimproved_ | 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 rounds, not the round's index. -1 means unassigned.LeagueScheduleModel
| Field | Meaning |
|---|
score_ hard score_ soft 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_ | Soft | 1 | Distance the away team travels to a match in the first round. |
away_ | Soft | 1 | Distance the away team travels home after a match in the last round. |
workday_ | Soft | 1000 | A classic match in a round that is neither a weekend nor a holiday. |
team_ | Hard | 1 | Pairs of matches in the same round sharing at least one team. |
consecutive_ | Hard | 1 | Teams playing at home four or more rounds in a row. The penalty is the run length. |
consecutive_ | Hard | 1 | Teams playing away four or more rounds in a row. The penalty is the run length. |
repeat_ | Hard | 1 | A match whose return fixture is in the very next round. |
home_ | Soft | 1 | Distance a team travels going away in the round after a home match. |
away_ | Soft | 1 | Distance a team on two consecutive away rounds travels between the grounds. |
away_ | Soft | 1 | Distance a team travels back to play at home after an away round. |
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=0the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this:step_count_limit = 0means no limit. roundis a position inrounds, not the round'sindex, so a solution that lists different rounds, or the same ones in another order, raisesValueError. 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.