Skip to content
Planopti logoPlanopti
AboutSolutionLicenceFAQ
FR|EN
Contact us
AboutSolutionLicenceFAQContact us
FR|EN
Planopti/Blog/CP-SAT Employee Scheduling

CP-SAT Employee Scheduling: Modeling 300 Employees

Hard constraints, soft objectives, and real-world performance

  • The problem
  • Decision variables
  • Hard constraints
  • Soft objectives
  • Penalty calibration
  • Model size
  • Performance
  • Practical tips
Blog
  • Technical
  • CP-SAT Modeling
  • CP-SAT Tuning
  • Hard vs Soft Constraints
  • Fairness Modeling
  • CP-SAT vs MIP
  • Exception Handling
  • Excel to CP-SAT
  • Multi-Threaded Search
  • Business
  • Automate Rostering
  • Compliance
  • Integration
  • Excel Limits
  • Labour Costs
  • Ground Handling
  • CBA & Agreements

The problem: monthly rostering at scale

Consider a real scheduling problem: 300 employees across multiple functional groups, each holding one or more qualifications. 50 shift types with different start times, end times, and break structures. 30 days to plan. Variable daily staffing needs that depend on the day of the week and operational cycles.

Each employee has a contract type (full-time, part-time, student) that determines their monthly target hours, days off, and weekend rules. Some employees have pre-assigned absences: holidays, vacations, fixed days off. The solver must respect all of these while producing a schedule that covers every staffing need, distributes workload fairly, and complies with labour regulations.

This article describes the exact CP-SAT model we use to solve this problem. Every variable, constraint, and objective described here runs in production.

Decision variables

The model starts with three types of variables:

Assignment variables

assign[e, d, s]  # Boolean: employee e works shift s on day d

One Boolean variable for every possible employee-day-shift combination. For 300 employees, 30 days, and 50 shifts, that is up to 450,000 variables. In practice, the number is lower because employees can only be assigned to shifts matching their qualifications.

Day-off variables

is_off[e, d]  # Boolean: employee e does not work on day d

One variable per employee per day. 300 × 30 = 9,000 variables. These are linked to the assignment variables: is_off[e, d] = 1 if and only if no shift is assigned to employee e on day d.

Aggregate variables

total_minutes[e]            # Integer: total worked minutes for employee e
total_shifts_per_function[e, f]  # Integer: shifts worked by e in function f

These are derived variables that the solver computes from the assignment variables. They are used in equity objectives to measure how fairly work is distributed across the team.

Hard constraints

Hard constraints are non-negotiable. If the solver cannot satisfy all of them simultaneously, the problem is declared INFEASIBLE. No schedule is produced.

One shift per day

# For each employee e, for each day d:
sum(assign[e, d, s] for s in all_shifts) <= 1

An employee works at most one shift per day. Combined with is_off, this means each employee-day is either exactly one shift or a day off.

Minimum rest between shifts (11 hours)

Labour regulations require a minimum rest period between the end of one shift and the start of the next. The standard is 11 hours. Instead of computing time differences at solve time, we pre-compute toxic pairs: pairs of shifts (s1, s2) where shift s1 on day d followed by shift s2 on day d+1 would violate the 11-hour rule.

# For each toxic pair (s1, s2), for each employee e, for each day d:
assign[e, d, s1] + assign[e, d+1, s2] <= 1

Pre-computing toxic pairs is significantly more efficient than encoding time arithmetic in the model. For 50 shift types, the number of toxic pairs is typically 200-400, which generates a manageable number of constraints.

Qualification enforcement

# If employee e does not hold qualification for shift s:
assign[e, d, s] = 0  (for all days d)

Each shift belongs to a function (e.g. security, logistics, maintenance). Each employee holds qualifications for specific functions. The solver can only assign an employee to a shift if they are qualified. In practice, this is implemented by simply not creating the assign variable for invalid employee-shift combinations, which reduces model size.

Pre-assigned absences

# HOLIDAY, VACATION, FIXED_OFF on day d for employee e:
assign[e, d, s] = 0  (for all shifts s)
is_off[e, d] = 1

Absences are injected as fixed values before the solver runs. The solver does not decide whether an employee is on holiday. It respects the pre-assigned absence and optimises everything else around it.

Soft objectives

Soft constraints are goals the solver tries to achieve but can violate if necessary. Each violation adds a penalty to the objective function. The solver minimises the total penalty score: lower is better.

Coverage

The most critical objective. For each function, each day has a staffing need (e.g. "3 security agents on Monday"). The solver penalises every uncovered slot.

# For each function f, for each day d:
assigned_count = sum(assign[e, d, s] for e qualified for f, s in function f)
missing = max(0, need[f, d] - assigned_count)
penalty += missing * WEIGHT_COVERAGE

Days off

Each employee has a target number of days off per month (derived from contract type and working days in the month). The solver penalises missing days off.

# For each employee e:
actual_off = sum(is_off[e, d] for d in month)
missing_off = max(0, target_off[e] - actual_off)
penalty += missing_off * WEIGHT_DAYS_OFF

Weekend guarantee

Each employee should get at least one full weekend off (Saturday + Sunday) per month. Employees with contract type "student" are excluded from this constraint.

# For each employee e (non-student):
has_weekend_off = max over all weekends w of:
    (is_off[e, saturday_w] AND is_off[e, sunday_w])
penalty += (1 - has_weekend_off) * WEIGHT_WEEKEND

Workload equity (days worked)

Within each functional group, the difference in total days worked between the employee who works the most and the one who works the least should be minimal. Employees with holidays or vacations are excluded from the equity calculation to avoid penalising the solver for absences it cannot control.

# For each group g:
gap = max_days_worked[g] - min_days_worked[g]
penalty += gap * WEIGHT_WORKLOAD_EQUITY

Qualification equity (shifts per function)

Within each group, shifts should be distributed fairly across qualification types. An employee qualified for both security and logistics should not be assigned exclusively to security while others only get logistics.

# For each group g, for each function f:
gap = max_shifts[g, f] - min_shifts[g, f]
penalty += gap * WEIGHT_QUALIF_EQUITY

Consecutive working days

Working more than 6 consecutive days is penalised. This is a soft constraint rather than a hard one because in some edge cases (short-staffed periods), the solver may need to exceed 6 days to maintain coverage.

# For each employee e, for each window of 7 consecutive days:
if all 7 days are worked: penalty += WEIGHT_CONSECUTIVE

Isolated days off

A single day off surrounded by two working days is less restful than consecutive days off. The solver penalises these isolated days off to encourage clustering.

# For each employee e, for each day d (not first or last):
if is_off[e, d] AND NOT is_off[e, d-1] AND NOT is_off[e, d+1]:
    penalty += WEIGHT_ISOLATED_OFF

Penalty calibration

The penalty weights create a priority hierarchy. The solver will sacrifice lower-priority objectives to preserve higher-priority ones.

ObjectivePenalty weightPriority
Missing coverageVery highHighest
No weekend guaranteedHighHigh
Consecutive days > 6HighHigh
Missing days offMediumMedium
Isolated day offMediumMedium
Qualification equity gapLowLow
Workload equity gapVery lowLowest

The rationale: an uncovered shift (highest weight) is always worse than a missing day off (medium weight). A missing day off is worse than a slightly unfair distribution. This hierarchy ensures the solver makes the right trade-offs when it cannot satisfy everything perfectly.

A good schedule typically scores in a predictable range. A score of zero would mean every objective is perfectly met, which is rarely possible with real-world data. The penalty breakdown shows exactly where the score comes from, giving the planner clear guidance on what to adjust in the input data.

Model size in practice

Here is what the model looks like for different operation sizes:

EmployeesBoolean variablesConstraints (approx.)Soft penalty terms
100~80,000~120,000~15,000
150~130,000~200,000~25,000
200~180,000~300,000~35,000
300~280,000~500,000~55,000

These numbers assume qualification filtering (not creating variables for invalid employee-shift pairs). Without filtering, the 300-employee model would have 450,000 Boolean variables. Filtering typically removes 30-50% of them.

Performance benchmarks

Measured on a standard server (8 cores, 16 GB RAM), using CP-SAT's multi-threaded search with 8 workers:

EmployeesTime to first solutionTime to best solutionOptimality gap
100< 30 seconds2-3 minutes< 1%
150~1 minute3-5 minutes1-2%
200~2 minutes8-15 minutes2-5%
300~4 minutes15-30 minutes3-8%

"Time to first solution" is how quickly the solver finds a feasible schedule. "Time to best solution" is how long it takes to converge to the best score within the configured time limit. The "optimality gap" is the difference between the best solution found and the theoretical lower bound.

In practice, the solver finds a good solution quickly, then spends the remaining time improving it incrementally. The planner can configure the time limit based on their patience and quality requirements. CP-SAT always returns the best solution found within the allotted time.

Practical tips for scaling CP-SAT

Reduce the variable space

Do not create assign[e, d, s] for combinations that are impossible. If employee e is not qualified for shift s, or if e has a pre-assigned absence on day d, skip the variable entirely. This can reduce the model by 30-50%.

Pre-compute toxic pairs

Computing rest period violations at model build time (as a list of forbidden shift pairs) is much faster than encoding time arithmetic in CP-SAT. The solver handles Boolean implications efficiently.

Use multi-threaded search

CP-SAT runs multiple search strategies in parallel. Set num_workers to the number of available CPU cores. On an 8-core machine, this typically reduces solve time by 3-5x compared to single-threaded search.

Exclude absences from equity

Employees on holiday or vacation should be excluded from intra-group equity calculations. Otherwise, the solver penalises itself for a gap it cannot close, wasting search effort on an impossible objective.

Set a meaningful time limit

For 100-150 employees, 5 minutes is usually sufficient. For 200-300+, allow 15-30 minutes. The solver's anytime behaviour means it always has a valid solution ready, even if interrupted early. A longer time limit produces a marginally better score, not a fundamentally different schedule.

Monitor the objective breakdown

When the total penalty score is high, look at the breakdown. If 80% of the penalty comes from coverage gaps, the problem is understaffing, not solver tuning. If equity penalties dominate, the groups may be unbalanced. The penalty structure turns the solver into a diagnostic tool.

Want to see this model on your data?

Send us your employee list, shift definitions, and scheduling rules. We will run the solver and show you the result.

Contact us
Planopti

Automated employee scheduling for regulated industries. CP-SAT solver, on-premise.

[email protected]

Navigation

About Solution Licence FAQ Contact us

Tools

Technology Google OR-Tools CP-SAT Solver Scheduling Blog

Legal

Maintenance contract Privacy GDPR
© 2026 Planopti SA. All rights reserved.