● How to use · Scheduling
Employee scheduling
Fill every shift with a qualified person while honoring rest rules, weekly hour limits, consecutive-day limits and individual preferences.
What it decides
For every shift, the solver picks a worker, under the rules you list.
Minimal example
from optimaru import employee_scheduling as es
from optimaru.employee_scheduling.models import (
ScheduleRequest, SchedulingInput, ShiftModel, SolverSettings, WorkerModel, solve_request,
)
def shift(id, day, skill):
return ShiftModel(id=id, start=es.minutes(day, 9), end=es.minutes(day, 17),
location="Site A", required_skill=skill)
request = ScheduleRequest(
timelimit=2,
settings=SolverSettings(random_seed=42),
model_input=SchedulingInput(
workers=[
WorkerModel(id="Ana", skills=["Skill A"], unavailable_dates=[1]),
WorkerModel(id="Ben", skills=["Skill A"], undesired_dates=[2]),
WorkerModel(id="Cho", skills=["Skill B"], desired_dates=[0]),
],
shifts=[
shift("S1", 0, "Skill A"), shift("S2", 1, "Skill A"),
shift("S3", 2, "Skill A"), shift("S4", 0, "Skill B"),
],
),
)
solution = solve_request(request)
print(solution.feasible, solution.score_hard, solution.score_soft)
print({worker: [a.shift_id for a in shifts] for worker, shifts in solution.schedule.items()})
print(solution.unassigned, [(t.name, t.cost) for t in solution.rule_totals]) The same use case as a Jupyter notebook, with charts and saved outputs: examples/employee-scheduling.ipynb ↗
Input parameters
WorkerModel
| Field | Meaning |
|---|
id required skills unavailable_ dates undesired_ dates desired_ dates desired_ shifts (day, location) work preferences, read by the desired_shift_cost rule.ShiftModel
| Field | Meaning |
|---|
id required start required es.minutes(day, hour) builds one.end required location required_ skill worker pinned worker: scored, never moved. Requires worker.SchedulingInput
| Field | Meaning |
|---|
workers required shifts required rules default_rules() apply.ScheduleRequest
| Field | Meaning |
|---|
model_ input required SchedulingInput.Solve parameters
What you pass to the solve call. Time limit, seed and parallel count are here.
| Parameter | Default | Meaning |
|---|---|---|
name | None | Optional label for the request. |
timelimit | 30.0 | Search budget in seconds, from 0 to 1e9. |
num_ | 1 | Independent starts in parallel processes, each with its own seed and the full timelimit. The best is returned. If settings.multi_start is set too, the larger applies. |
settings | SolverSettings() | Seed and search options, as a SolverSettings. |
select | None | With more than one start, a function that picks the winner from the list of solutions. Ignored for a single solve. |
All search options (46)
Set these on SolverSettings and pass it as settings=.
| Parameter | Default | Meaning |
|---|---|---|
random_ | 0 | Seed for the search. |
guided_ | True | Violation-guided and skill-guided move selection. False falls back to generic change and swap moves. |
employee_ | True | Seed unassigned shifts with a day-aware greedy construction before local search. |
greedy_ | 0.0 | Share of the time budget reserved for a greedy hard repair after local search. 0 means off. |
late_ | 400 | Late-acceptance history length. |
stop_ | False | Stop at the first schedule with hard 0. |
best_ | None | Stop early once the best score reaches this value. By default it never fires. |
no_ | None | Alias of unimproved_spent_limit_seconds. |
unimproved_ | 0.0 | Stop after this many seconds without a new best. 0 means no limit. |
step_ | 0 | Stop after this many steps. 0 means no limit. |
no_ | None | Alias of unimproved_step_count_limit. |
unimproved_ | 0 | Stop after this many steps without a new best. 0 means no limit. |
ruin_ | True | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 5000 | Steps without a new best before a kick. |
ruin_ | 0.1 | Smallest share of the solution one kick removes. |
ruin_ | 0.3 | Largest share of the solution one kick removes. |
ruin_ | 60 | Absolute cap on entities removed per kick. |
ruin_ | 0.8 | How fast operator weights adapt. |
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 | Hill-climbing moves that polish each kick. Not counted against step_count_limit. |
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_ | 12 | Most entities that small ruin removes. |
descent_ | 0.02 | Share of best-improvement descent. 0 turns it off. |
descent_ | 4 | Improving passes before giving up on a neighbourhood. |
change_ | 0.75 | Relative share of change moves. 0 disables them. |
swap_ | 0.25 | Relative share of swap moves. 0 disables them. |
nearby_ | True | Bias swap partners, descent pairs and ruin clusters toward shifts starting close in time. |
nearby_ | 0.65 | Probability of drawing a swap partner from that nearby list. |
smart_ | False | Sample several employees for one shift and keep the best. Needs guided_moves. |
smart_ | 6 | Employees sampled per smart change move. |
employee_ | True | Repair one-shift-per-day violations. Needs guided_moves. |
employee_ | True | Repair overlong consecutive-work runs. Needs guided_moves. |
employee_ | 3 | Worked-day run length that triggers those repairs. |
employee_ | True | Swap two employees' assignments over a short day window. Needs guided_moves. |
employee_ | 3 | Longest day window for those moves. |
custom_ | 0.3 | Cap on how often a step may draw a use-case move. 1 means no cap. |
employee_ | True | Employee-aware ruin sets. Needs guided_moves and ruin_recreate. |
employee_ | False | Allow a move that empties an over-staffed slot, so coverage slots become optional. |
multi_ | 1 | Independent solves in parallel processes, with seeds random_seed + i. The best is kept. With num_workers, the larger of the two applies; they do not multiply. |
portfolio | [] | Per-start SolverConfig overrides, cycled across the starts. A key or value SolverConfig would refuse raises ValueError. |
select_ | 1.0 | When ranking starts, divide the shift-coverage cost by this. For callers who inflate that weight for the search. 1 means no correction. |
relocate_ | 0.5 | Generic list-variable option. Unused by employee scheduling. |
list_ | 0.3 | Generic list-variable option. Unused by employee scheduling. |
two_ | 0.2 | Generic list-variable option. Unused by employee scheduling. |
or_ | 0.0 | Generic list-variable option. Unused by employee scheduling. |
tail_ | 0.0 | Generic list-variable option. Unused by employee scheduling. |
What comes back
solve_request returns a ScheduleSolution. The decisions are in schedule and unassigned.
ScheduleSolution
| Field | Meaning |
|---|
schedule required shift_id, start, end, location and required_skill.unassigned score_ hard required score_ soft required feasible required rule_ totals name, cost, is_hard. Positive penalizes, negative rewards.Constraints
Pick them by calling their classes from optimaru.employee_scheduling.constraints, such as RequiredSkillCost(). 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. |
worker_ | None | Limit the rule to these workers, by position in workers. Per-employee rules and shift_coverage_cost only; the other rules refuse it. |
shift_ | None | Limit the rule to these shifts, by position in shifts. Per-employee rules and shift_coverage_cost only; the other rules refuse it. |
Rules go in model_input.rules, as the classes from optimaru.employee_scheduling.constraints or as their JSON form, such as {"name": "max_consecutive_days_cost", "value": 5, "weight": 2}. They are checked when you build a SchedulingInput and again when you assign its rules, as below, and stored as JSON models such as MaxConsecutiveDaysCostModel. Give rule parameters by keyword: on per-employee rules, worker_indices and shift_indices come first. Only the listed rules are scored. Most are opt-in: default_rules() holds eight, and the rest apply once you add them. A rule may be picked several times, with other parameters or knobs or with the same ones, and every pick is scored on its own, so to change a default, drop it from default_rules() and add your own. The JSON form takes the same knobs, such as "level": "hard". An empty list, or a rule with the old "hard" key, raises ValidationError.
This continues the minimal example above.
from optimaru.employee_scheduling.constraints import (
MaxConsecutiveDaysCost, MinRestCost, UndesiredDayCost,
)
from optimaru.employee_scheduling.models import default_rules
changes = [
MaxConsecutiveDaysCost(value=5, weight=2), # not a default, so it is added
MinRestCost(min_gap_minutes=12 * 60), # replaces the default min_rest_cost
UndesiredDayCost(weight=2, level="hard"), # replaces the default undesired_day_cost
]
replaced = {rule.name for rule in changes}
kept = [rule for rule in default_rules() if rule.name not in replaced]
custom = request.model_copy(deep=True)
custom.model_input.rules = kept + changes # checked on assignment too
print(solve_request(custom).score_hard) | Name | Level | Default weight | What it does |
|---|---|---|---|
required_ | Hard | 1 | A shift given to someone without the skill it needs. |
employee_ | Hard | 1 | Minutes worked on an unavailable day. |
shift_ | Hard | 1 | Minutes where two of a person's shifts overlap. |
max_ | Hard | 1 | Pairs of same-day shifts for one person. |
min_ | Hard | 1 | Missing rest minutes between two shifts. Parameters: min_gap_minutes=600 |
undesired_ | Soft | 1 | Minutes worked on a disliked day, or days with per_day. Parameters: per_day=False |
desired_ | Soft Reward | 1 | Reward for minutes worked on a wished day, or days with per_day. Parameters: per_day=False |
desired_ | Soft Reward | 1 | Reward for a shift matching a desired_shifts wish. |
shift_ | Soft | 1 | Statistical unfairness of shift counts. |
max_ | Hard | 1 | Workdays beyond value in a row. Parameters: value=6 |
min_ | Soft | 1 | How far short runs fall below value. Parameters: value=2 |
max_ | Soft | 1 | Off days beyond value, in a run bounded by work on both sides. Parameters: value=3 |
rest_ | Hard | 1 | Rest shortfall after a run of work_days or more. Parameters: work_days=6, rest_days=3 |
max_ | Hard | 1 | Minutes beyond minutes in a calendar week. week_anchor aligns the week. Parameters: minutes=2400, week_anchor=0 |
min_ | Soft | 1 | Minutes short of minutes in every week. An idle week costs the full amount. Parameters: minutes=480, week_anchor=0 |
min_ | Hard | 1 | Worked days beyond 7 - value in a week. Parameters: value=1 |
assignment_ | Soft | 1 | Money: a shift_costs entry, else per hour, else per shift. Parameters: cost_per_hour=0.0, cost_per_shift=0.0, shift_costs={} |
unassigned_ | Hard | 1 | Shifts nobody works. |
shift_ | Soft | 1 | Busiest minus least-busy shift count, beyond tolerance. Parameters: tolerance=0 |
weekly_ | Soft | 1 | Per week, busiest minus least-busy minutes beyond tolerance_minutes, in hours. Parameters: tolerance_minutes=120 |
shift_ | Soft | 1 | Per day and location, assignees holding worker_skill outside min_count to max_count. max_count below 0 means no cap. Parameters: worker_skill='', min_count=0, max_count=-1 |
location_ | Soft | 1 | Runs of consecutive days at location outside min_run to max_run. Parameters: location='', min_run=0, max_run=-1, waive_min_at_end=True |
rest_ | Hard | 1 | Worked days in the rest_days after a run at location. Parameters: location='', rest_days=1 |
location_ | Soft | 1 | Shortfall below min_gap days between two runs at location. Parameters: location='', min_gap=0 |
worker_ | Soft | 1 | Per worker holding worker_skill, location shifts outside min_count to max_count. Parameters: location='', worker_skill='', min_count=0, max_count=-1 |
day_ | Soft | 1 | Each occurrence of an ordered day pattern, in comma-separated tokens: a location, OFF, *, WORK or !x. Parameters: pattern='' |
target_ | Soft | 1 | Shortfall below min_count days off among target_days. Parameters: target_days=(), min_count=0 |
worker_ | Soft | 1 | Distinct worked days from lo to hi outside min_count to max_count. Parameters: location='', lo=0, hi=-1, min_count=0, max_count=-1 |
location_ | Soft | 1 | Fairness of a per-worker count, by ssd or gap as metric. Parameters: location='', worker_skill='', metric='ssd', tolerance=0, off_target_days=() |
worker_ | Soft | 1 | (day, location) buckets a pair of workers, anchor and paired, should share or, with penalty, avoid sharing. Parameters: anchor=-1, paired=-1, bucket_days=(), bucket_locations=(), penalty=False |
Starting from an existing solution
This needs the latest release: pip install -U optimaru.
with_initial_solution(request, solution) returns a new request whose ShiftModel.worker fields carry the solution. solution is what solve_request returned, or the dict of model_dump(mode="json"). The request you pass in is not changed.
This continues the minimal example above.
from optimaru.employee_scheduling.models import with_initial_solution
seeded = with_initial_solution(request, solution) # or solution.model_dump(mode="json")
better = solve_request(seeded)
print(better.score_hard, better.score_soft) - Workers and shifts are matched by id. An assignment is read for its
shift_idonly. - A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the shifts it leaves out are constructed.
- A shift the solution lists as
unassignedis constructed too. - With
timelimit=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. - A pinned shift keeps its worker whatever the solution says, but a solution that moves or unassigns it raises
ValueError. ValueErroris also raised for an unknown id, a shift assigned twice or both assigned and unassigned, and a malformed assignment.- A seed that breaks a rule is not an error. It is a starting point, and the score says what it costs.
Exceptions and limits
- Times are integer minutes from day 0, 00:00, and a date is a day number.
num_workersandsettings.multi_startdo not multiply: the larger applies. Each start gets the fulltimelimitand at most one process per core runs at a time, so more starts than cores take longer thantimelimit.worker_location_count_costandworker_day_count_in_range_costscore as soft by default. Setlevel="hard"if they must hold.unassigned_shifts_costis not indefault_rules(). Add it when an empty shift must count in the score, as it must withemployee_unassign_moves.- A pinned shift is settled history: per-shift rules do not re-judge it, and pattern rules treat pinned days as context.
- A run reaching the last day has an unknown future, so a shortfall rule waives it while an excess rule still counts it.