Skip to content

●  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 Worker id. skills Skills the worker holds. unavailable_dates Day numbers the worker cannot work. undesired_dates Day numbers the worker would rather not work. desired_dates Day numbers the worker would like to work. desired_shifts (day, location) work preferences, read by the desired_shift_cost rule.

ShiftModel

Field Meaning
id required Shift id. start required Start, in minutes from day 0, 00:00. es.minutes(day, hour) builds one. end required End, in minutes from day 0, 00:00. location Free-form string. Location rules read it, so it can hold a site, a team or a shift type. required_skill Skill the shift needs. worker Pre-assigned worker id. A starting point, not a pin: local search may still move it. pinned Fix the assignment to worker: scored, never moved. Requires worker.

SchedulingInput

Field Meaning
workers required Workers. shifts required Shifts to cover. rules The rules that apply: constraint classes or their JSON form. Left out, the eight rules of default_rules() apply.

ScheduleRequest

Field Meaning
model_input required The 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_workers 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_seed 0 Seed for the search.
guided_moves True Violation-guided and skill-guided move selection. False falls back to generic change and swap moves.
employee_greedy_construction True Seed unassigned shifts with a day-aware greedy construction before local search.
greedy_repair_budget_fraction 0.0 Share of the time budget reserved for a greedy hard repair after local search. 0 means off.
late_acceptance_size 400 Late-acceptance history length.
stop_at_first_feasible False Stop at the first schedule with hard 0.
best_score_limit None Stop early once the best score reaches this value. By default it never fires.
no_improvement_limit_seconds None Alias of unimproved_spent_limit_seconds.
unimproved_spent_limit_seconds 0.0 Stop after this many seconds without a new best. 0 means no limit.
step_count_limit 0 Stop after this many steps. 0 means no limit.
no_improvement_step_count_limit None Alias of unimproved_step_count_limit.
unimproved_step_count_limit 0 Stop after this many steps without a new best. 0 means no limit.
ruin_recreate True Run adaptive ruin-and-recreate kicks when the search stagnates.
ruin_recreate_unimproved_step_trigger 5000 Steps without a new best before a kick.
ruin_recreate_min_ruin_fraction 0.1 Smallest share of the solution one kick removes.
ruin_recreate_max_ruin_fraction 0.3 Largest share of the solution one kick removes.
ruin_recreate_max_ruin_count 60 Absolute cap on entities removed per kick.
ruin_recreate_operator_decay 0.8 How fast operator weights adapt.
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_polish_steps 5000 Hill-climbing moves that polish each kick. Not counted against step_count_limit.
ruin_recreate_as_move_weight 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_recreate_as_move_max_ruin 12 Most entities that small ruin removes.
descent_weight 0.02 Share of best-improvement descent. 0 turns it off.
descent_max_sweeps 4 Improving passes before giving up on a neighbourhood.
change_move_weight 0.75 Relative share of change moves. 0 disables them.
swap_move_weight 0.25 Relative share of swap moves. 0 disables them.
nearby_shifts True Bias swap partners, descent pairs and ruin clusters toward shifts starting close in time.
nearby_shifts_probability 0.65 Probability of drawing a swap partner from that nearby list.
smart_change_moves False Sample several employees for one shift and keep the best. Needs guided_moves.
smart_change_candidate_count 6 Employees sampled per smart change move.
employee_day_repair_moves True Repair one-shift-per-day violations. Needs guided_moves.
employee_run_repair_moves True Repair overlong consecutive-work runs. Needs guided_moves.
employee_run_repair_max_days 3 Worked-day run length that triggers those repairs.
employee_window_moves True Swap two employees' assignments over a short day window. Needs guided_moves.
employee_window_max_days 3 Longest day window for those moves.
custom_move_probability 0.3 Cap on how often a step may draw a use-case move. 1 means no cap.
employee_ruin_moves True Employee-aware ruin sets. Needs guided_moves and ruin_recreate.
employee_unassign_moves False Allow a move that empties an over-staffed slot, so coverage slots become optional.
multi_start 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_coverage_divisor 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_move_weight 0.5 Generic list-variable option. Unused by employee scheduling.
list_swap_move_weight 0.3 Generic list-variable option. Unused by employee scheduling.
two_opt_move_weight 0.2 Generic list-variable option. Unused by employee scheduling.
or_opt_move_weight 0.0 Generic list-variable option. Unused by employee scheduling.
tail_swap_move_weight 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 Worker id to the shifts assigned, each with shift_id, start, end, location and required_skill. unassigned Ids of shifts nobody works. score_hard required Hard score. 0 means no hard rule is broken. score_soft required Soft score. Rewards can make it positive. feasible required True when no hard rule is broken. rule_totals Weighted cost of each rule: 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_indices 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_indices 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_skill_cost Hard 1 A shift given to someone without the skill it needs.
employee_unavailability_cost Hard 1 Minutes worked on an unavailable day.
shift_overlap_cost Hard 1 Minutes where two of a person's shifts overlap.
max_one_shift_per_day_cost Hard 1 Pairs of same-day shifts for one person.
min_rest_cost Hard 1 Missing rest minutes between two shifts. Parameters: min_gap_minutes=600
undesired_day_cost Soft 1 Minutes worked on a disliked day, or days with per_day. Parameters: per_day=False
desired_day_cost Soft Reward 1 Reward for minutes worked on a wished day, or days with per_day. Parameters: per_day=False
desired_shift_cost Soft Reward 1 Reward for a shift matching a desired_shifts wish.
shift_count_imbalance_cost Soft 1 Statistical unfairness of shift counts.
max_consecutive_days_cost Hard 1 Workdays beyond value in a row. Parameters: value=6
min_consecutive_days_cost Soft 1 How far short runs fall below value. Parameters: value=2
max_consecutive_days_off_cost Soft 1 Off days beyond value, in a run bounded by work on both sides. Parameters: value=3
rest_after_consecutive_work_cost Hard 1 Rest shortfall after a run of work_days or more. Parameters: work_days=6, rest_days=3
max_weekly_minutes_cost Hard 1 Minutes beyond minutes in a calendar week. week_anchor aligns the week. Parameters: minutes=2400, week_anchor=0
min_weekly_minutes_cost Soft 1 Minutes short of minutes in every week. An idle week costs the full amount. Parameters: minutes=480, week_anchor=0
min_weekly_rest_days_cost Hard 1 Worked days beyond 7 - value in a week. Parameters: value=1
assignment_cost 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_shifts_cost Hard 1 Shifts nobody works.
shift_count_gap_cost Soft 1 Busiest minus least-busy shift count, beyond tolerance. Parameters: tolerance=0
weekly_minutes_gap_cost Soft 1 Per week, busiest minus least-busy minutes beyond tolerance_minutes, in hours. Parameters: tolerance_minutes=120
shift_coverage_cost 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_run_length_cost 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_after_location_run_cost Hard 1 Worked days in the rest_days after a run at location. Parameters: location='', rest_days=1
location_run_gap_cost Soft 1 Shortfall below min_gap days between two runs at location. Parameters: location='', min_gap=0
worker_location_count_cost 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_pattern_cost Soft 1 Each occurrence of an ordered day pattern, in comma-separated tokens: a location, OFF, *, WORK or !x. Parameters: pattern=''
target_days_off_cost Soft 1 Shortfall below min_count days off among target_days. Parameters: target_days=(), min_count=0
worker_day_count_in_range_cost 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_imbalance_cost 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_pairing_cost 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

How constraints work →

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_id only.
  • 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 unassigned is constructed too.
  • With timelimit=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.
  • A pinned shift keeps its worker whatever the solution says, but a solution that moves or unassigns it raises ValueError.
  • ValueError is 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_workers and settings.multi_start do not multiply: the larger applies. Each start gets the full timelimit and at most one process per core runs at a time, so more starts than cores take longer than timelimit.
  • worker_location_count_cost and worker_day_count_in_range_cost score as soft by default. Set level="hard" if they must hold.
  • unassigned_shifts_cost is not in default_rules(). Add it when an empty shift must count in the score, as it must with employee_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.