● How to use · Timetabling
School timetabling
Assign every lesson to a period and a room with no teacher, class or room conflicts, then polish for gaps, consecutive lessons and teacher wishes.
What it decides
For every weekly period of every course, the solver picks a day and a period. Special rooms are assigned afterwards by a greedy pass.
Minimal example
from optimaru import school_timetabling as st
problem = {
"calendar": {"periods_per_day": {"0": 4, "1": 4, "2": 4}},
"subjects": [
{"id": "MATH", "name": "Math", "required_room_type": None,
"preferred_periods": [1, 2], "avoided_periods": []},
{"id": "ART", "name": "Art", "required_room_type": None,
"preferred_periods": [], "avoided_periods": []},
],
"teachers": [
{"id": "T1", "name": "Kim", "subject_ids": ["MATH"], "unavailable": [{"day": 0, "period": 1}],
"max_periods_per_week": 20, "max_periods_per_day": 4, "max_consecutive_periods": 3,
"preferred_free_days": [], "homeroom_class_id": None},
{"id": "T2", "name": "Lee", "subject_ids": ["ART"], "unavailable": [],
"max_periods_per_week": 20, "max_periods_per_day": 4, "max_consecutive_periods": 3,
"preferred_free_days": [], "homeroom_class_id": None},
],
"class_groups": [{"id": "C1", "name": "Class 1", "grade": 1, "unavailable": []}],
"rooms": [],
"courses": [
{"id": "C1-MATH", "class_group_id": "C1", "subject_id": "MATH", "teacher_ids": ["T1"],
"periods_per_week": 3, "block_periods": 0, "max_periods_per_day": 1,
"fixed_slots": [], "forbidden_slots": [], "parallel_group": None},
{"id": "C1-ART", "class_group_id": "C1", "subject_id": "ART", "teacher_ids": ["T2"],
"periods_per_week": 2, "block_periods": 2, "max_periods_per_day": 2,
"fixed_slots": [], "forbidden_slots": [], "parallel_group": None},
],
}
timetable = st.solve(problem, time_limit_seconds=2.0, random_seed=42) # returns a dict
print(timetable["status"], timetable["objective_value"], timetable["penalties"])
for lesson in timetable["lessons"]:
print(lesson["course_id"], lesson["slot"], lesson["room_id"]) The same use case as a Jupyter notebook, with charts and saved outputs: examples/school-timetabling.ipynb ↗
Input parameters
problem["calendar"]
| Field | Meaning |
|---|
periods_ per_ day {"0": 7, "1": 7, ...}. 0 is Monday.problem["subjects"][i]
| Field | Meaning |
|---|
id required_ room_ type None for the class's own room.preferred_ periods avoided_ periods problem["teachers"][i]
| Field | Meaning |
|---|
id unavailable {"day": 0, "period": 1}.max_ periods_ per_ week max_ periods_ per_ day max_ consecutive_ periods preferred_ free_ days problem["class_groups"][i]
| Field | Meaning |
|---|
id unavailable problem["rooms"][i]
| Field | Meaning |
|---|
id room_ type required_room_type.problem["courses"][i]
| Field | Meaning |
|---|
id class_ group_ id subject_ id teacher_ ids periods_ per_ week block_ periods max_ periods_ per_ day fixed_ slots forbidden_ slots parallel_ group Solve parameters
What you pass to the solve call. Time limit, seed and parallel count are here.
| Parameter | Default | Meaning |
|---|---|---|
mode | 'canonical' | canonical honours every constraint below. quickstart is an approximate conversion to a smaller model, and its limits are listed in notes. |
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. |
weights | None | Soft weights by constraint name, such as {"teacher_gap_cost": 40}. Omitted names keep their default. canonical mode only, and not together with constraints=. |
constraints | None | Which constraints to score, and how. Left unset, the defaults apply. |
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. |
initial_ | None | An earlier Timetable dict to start from. |
All search options (31)
Set these on canon.SolverConfig and pass it as config=. The top-level SolverConfig is refused in the default canonical mode.
| 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. |
descent_ | 4 | Improving passes before giving up on a neighbourhood. |
descent_ | 0.0 | Share of best-improvement descent. 0 turns it off. |
full_ | False | Check every incremental score against a full recompute. Slow; for debugging. |
hot_ | 0.7 | Probability of moving a lesson that carries a hard violation. 0 disables it. |
late_ | 0 | Late-acceptance history length. 0 picks it from the instance size. |
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. |
reassign_ | 32 | Slots tried by the reassign move, used only when unassigned lessons are allowed. |
ruin_ | True | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 12 | Most entities that small ruin removes. |
ruin_ | 0.0 | Probability that a step is a small ruin and repair, judged like any other move. Negative picks it automatically where the use case supports that; 0 turns it off. |
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. |
same_ | True | Treat lessons of one class as neighbours. |
same_ | 0.5 | Probability of drawing a swap partner from those neighbours. |
smart_ | 8 | Swaps tried per such move. |
smart_ | True | Try several swaps between lessons of one class and keep the best. |
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. |
weights | ObjectiveWeights() | The six soft weights, as canon.ObjectiveWeights: teacher_gap_cost 20, teacher_workday_cost 3, teacher_free_day_cost 30, class_gap_cost 10, avoided_period_cost 5 and non_preferred_period_cost 1. |
What comes back
School timetabling takes a dict and returns a dict, so there is no model to convert. The decisions are in lessons.
timetable
| Field | Meaning |
|---|
status FEASIBLE with no hard violation left, INFEASIBLE otherwise.lessons objective_ value penalties.penalties notes INFEASIBLE result.wall_ time_ seconds best_ objective_ bound None: local search proves no bound.timetable["lessons"][i]
| Field | Meaning |
|---|
course_ id class_ group_ id subject_ id teacher_ ids slot {"day": 0, "period": 1}.room_ id None: the class's own room.Constraints
Pick them by calling their classes from optimaru.school_timetabling.constraints, such as UnassignedLessonsCost(). 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. |
Pass weights={"teacher_gap_cost": 40} to change soft weights, or constraints=Assembly()... to pick, drop or flip constraints. Assembly keeps one entry per name: add() on a name already there replaces it. A plain constraints=[...] list may pick a name several times, each pick scored on its own. Without constraints=, the solve keeps its fast path and can run workers in parallel; with it, the search is single-start.
This continues the minimal example above.
from optimaru.school_timetabling.constraints import Assembly
# start from all 17, change one, drop one
custom = st.solve(
problem, time_limit_seconds=2.0, random_seed=42,
constraints=Assembly().add("teacher_gap_cost", weight=40).drop("teacher_workday_cost"),
)
print(custom["status"], custom["penalties"]) | Name | Level | Default weight | What it does |
|---|---|---|---|
unassigned_ | Hard | 1 | One point per unassigned lesson. Demoted with level="soft", unfilled periods become a result instead of a violation; see below. |
class_ | Hard | 1 | One point per pair of same-class lessons at the same time. |
teacher_ | Hard | 1 | One point per teacher shared by a pair of lessons at the same time, team teaching included. |
disallowed_ | Hard | 1 | One point per lesson outside its allowed slots: forbidden times and teacher or class unavailability. |
room_ | Hard | 1 | Per special-room type and time, demand above the rooms there are. |
course_ | Hard | 1 | Per course and weekday, periods above the daily cap. |
block_ | Hard | 1 | How far a block's periods are from being consecutive on one day. |
teacher_ | Hard | 1 | Each teacher's excess over the daily and weekly caps. |
teacher_ | Hard | 1 | Each teacher's excess over the consecutive-lesson cap. |
teacher_ | Soft | 20 | Idle periods inside a teacher's day. |
teacher_ | Soft | 3 | One point per teacher and weekday with lessons. |
teacher_ | Soft | 30 | One point per teacher and weekday with lessons on a requested free day. |
class_ | Soft | 10 | Idle periods inside a class's day. |
avoided_ | Soft | 5 | One point per lesson in an avoided period. |
non_ | Soft | 1 | One point per lesson outside its preferred periods, when it has some. |
fixed_ | Hard Fixed level | 1 | Lessons off their course's fixed_slots. The engine pins every fixed lesson, so this is always 0, and level="soft" raises ValueError. |
parallel_ | Hard Fixed level | 1 | Sections of one parallel_group at different times. The engine merges each group into one lesson, so this is always 0, and level="soft" raises ValueError. |
Starting from an existing solution
This needs the latest release: pip install -U optimaru.
Problems here are dicts, so there is no with_initial_solution. Pass the earlier timetable to solve with initial_solution=.
This continues the minimal example above.
better = st.solve(problem, time_limit_seconds=2.0, random_seed=42, initial_solution=timetable)
print(better["status"], better["objective_value"]) - Lessons are matched by
course_id. - A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the periods 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. room_idis ignored, since it is recomputed from the result.fixed_slotsare kept, and a course's other slots must fit its free periods, so a complete solution that moves a fixed lesson is rejected.- An unknown course, a course given twice at one time, more slots than a course has free periods, or parallel sections at different times raise
ValueError. - A time a course may not use is accepted and scores as a hard violation, so an
INFEASIBLEresult can be resumed.
Exceptions and limits
- Local search cannot prove optimality or infeasibility.
INFEASIBLEmeans hard violations were left at the time limit, not that no timetable exists. - A calendar is limited to 16 periods and to weekday indices below 8. A larger one raises
ValueError. - To leave unfillable periods out instead of getting
INFEASIBLE, demote the first constraint:Assembly().add("unassigned_lessons_cost", level="soft", weight=10_000). One unassigned period costs that weight, so keep it above the sum of the other soft terms. - Do not combine that assembly with
stop_at_first_feasible: a timetable that drops every lesson is hard 0 too, and the search would stop there.