Skip to content

●  How to use · Scheduling

Meeting scheduling

Place meetings in time and rooms so that required attendees can come, rooms are big enough and nothing overlaps.

What it decides

For every meeting, the solver picks a starting time grain and a room. A meeting then occupies duration_in_grains consecutive grains.

Minimal example

from optimaru import meeting_scheduling as ms
from optimaru.meeting_scheduling.models import (
    MeetingAssignmentModel, MeetingModel, MeetingScheduleModel, PersonModel, RoomModel, TimeGrainModel,
)

problem = MeetingScheduleModel(
    people=[PersonModel(id=f"P{i}") for i in range(4)],
    time_grains=[  # 15-minute grains, 09:00 to 11:00 on one day
        TimeGrainModel(id=f"G{i}", grain_index=i, day_of_year=200, starting_minute_of_day=540 + 15 * i)
        for i in range(8)
    ],
    rooms=[RoomModel(id="R1", capacity=4), RoomModel(id="R2", capacity=2)],
    meetings=[
        MeetingModel(id="M1", topic="Roadmap", duration_in_grains=2,
                     required_attendees=[0, 1], preferred_attendees=[2]),
        MeetingModel(id="M2", topic="Budget", duration_in_grains=3, required_attendees=[1, 3]),
    ],
    meeting_assignments=[
        MeetingAssignmentModel(id="A1", meeting=0),
        MeetingAssignmentModel(id="A2", meeting=1),
    ],
)

result = ms.solve(problem, time_limit_seconds=2.0, random_seed=42)
print(result.score_hard, result.score_soft)
for a in result.meeting_assignments:
    grain, room = divmod(a.grain_room, len(result.rooms))
    print(result.meetings[a.meeting].topic, result.time_grains[grain].starting_minute_of_day, result.rooms[room].id)

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

Input parameters

PersonModel

Field Meaning
id required Person id. People are referred to by their position in people.

TimeGrainModel

Field Meaning
id required Grain id. grain_index required Index used for duration and overlap. day_of_year required Day the grain belongs to. starting_minute_of_day required Start, in minutes from midnight.

RoomModel

Field Meaning
id required Room id. capacity Seats.

MeetingModel

Field Meaning
id required Meeting id. topic Meeting topic. duration_in_grains Length in 15-minute grains. required_attendees Positions in people of those who must attend. preferred_attendees Positions in people of those who should attend.

MeetingAssignmentModel

Field Meaning
id required Assignment id. meeting required Position of the meeting in meetings. Meetings and assignments are 1:1. pinned Keep this assignment's value.

MeetingScheduleModel

Field Meaning
people required People. time_grains required The calendar. rooms required Rooms. meetings required Meetings. meeting_assignments One entry per meeting.

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.
day_fit_value_probability 0.8 Probability that a change move draws a start whose whole meeting fits in one day. 0 disables it.
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.
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_as_move_max_ruin 12 Most entities that small ruin removes.
ruin_recreate_as_move_weight 0.0002 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_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 model and returns the solved model.

MeetingAssignmentModel

Field Meaning
grain_room Composite value, time_grain_pos * room_count + room_index. None means unassigned.

MeetingScheduleModel

Field Meaning
score_hard Hard score. 0 means feasible. score_soft Medium and soft folded together, as -(medium * 1e6 + soft).

Constraints

Pick them by calling their classes from optimaru.meeting_scheduling.constraints, such as OvertimeCost(). 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", "medium" or "soft" moves the constraint onto that level, as do Level.HARD, Level.MEDIUM and Level.SOFT. Left unset, it keeps the level below. This use case has a medium level, and any constraint may move onto it.
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.

A medium constraint weighs 1 in medium units by default. The search compares solutions on medium * 1e6 + soft, so medium outranks soft while the soft total stays below 1e6. With level, any constraint moves onto any of the three levels: level="medium" puts it on medium, and "hard" or "soft" takes a medium one off it. A weight counts in units of the level in effect.

This continues the minimal example above.

from optimaru.meeting_scheduling.constraints import (
    ConsecutiveMeetingsCost, RequiredAttendanceConflictCost, RoomCapacityCost, RoomConflictCost,
    get_constraints,
)

picked = get_constraints([
    RoomConflictCost(), RequiredAttendanceConflictCost(), RoomCapacityCost(),
    ConsecutiveMeetingsCost(weight=500),
])
custom = ms.solve(problem, time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score_hard, custom.score_soft)
Name Level Default weight What it does
overtime_cost Hard 1 A meeting running past the end of the calendar.
room_capacity_cost Hard 1 Seats missing in the assigned room for the attendees.
day_crossing_cost Hard 1 A meeting whose last grain falls on a different day from its first.
meeting_end_time_cost Soft 1 Each meeting's last grain index, which pulls the schedule toward the front of the calendar.
larger_room_capacity_cost Soft 1 For every room larger than the assigned one, the capacity difference, which fills big rooms first.
room_conflict_cost Hard 1 Grains in which two meetings use the same room.
required_attendance_conflict_cost Hard 1 Overlapping grains of a person required in both meetings.
required_and_preferred_attendance_conflict_cost Medium 1 Overlapping grains of a person required in one meeting and preferred in the other.
preferred_attendance_conflict_cost Medium 1 Overlapping grains of a person preferred in both meetings.
consecutive_meetings_cost Soft 100 Two meetings back to back without one grain between them. The heaviest soft term.
meeting_overlap_cost Soft 10 Grains in which any two meetings run at once, regardless of room or attendees.
room_change_cost Soft 1 Shared attendees whose next meeting starts within two grains in a different room.

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, the model a solve returned or that model as a JSON dict.

This continues the minimal example above.

seeded = ms.with_initial_solution(problem, result.model_dump(mode="json"))
again = ms.solve(seeded, time_limit_seconds=2.0, random_seed=43)
print(again.score_hard, again.score_soft)
  • Assignments 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 assignments 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.
  • Pinned assignments keep their value on problem, whatever the solution says.
  • grain_room is a composite over the positions in time_grains and rooms, so a solution that lists different ones, or the same ones in another order, raises ValueError. So do unknown ids and out-of-range values.

Exceptions and limits

  • A grain is 15 minutes.
  • Meetings and assignments must be 1:1, or ValueError is raised.
  • score_soft folds medium into soft with a 1e6 offset, so medium always dominates while raw soft stays below 1e6.