Handling Real-World Exceptions in CP-SAT
Holidays, vacations, fixed assignments, and contract-specific rules
The exception problem
A scheduling solver that only handles the "normal" case is useless. Real schedules are full of exceptions: public holidays, vacations, personal days off, contract-specific weekend rules, hour caps, qualification limits. Every month is different. Every employee has their own set of constraints.
The question is not whether the model can handle exceptions. It is whether it can absorb them without requiring code changes. If adding a new vacation means editing solver code, the system is fragile. If it means entering a row in a database, the system is robust.
This article describes how a CP-SAT scheduling model handles six types of real-world exceptions, all through data injection. The constraint types, their implementation in the model, and their impact on equity calculations and solver behaviour.
HOLIDAY: public holidays
When an employee is on a public holiday on day d, the solver must guarantee that no shift is assigned to them on that day. The employee is off, period.
Implementation
# For each employee e with HOLIDAY on day d:
for s in all_qualified_shifts(e):
model.Add(assign[e, d, s] == 0)
model.Add(is_off[e, d] == 1)
Every assignment variable for that employee on that day is fixed to 0. The day-off variable is fixed to 1. The solver does not decide anything about this day. It simply skips it and optimises everything else around it.
The employee does not count toward coverage on that day. Their target working days for the month are reduced accordingly, so the solver does not penalise them for "missing" a day they were never available for.
VACATION: contiguous absence blocks
Vacation works exactly like HOLIDAY, but for a contiguous block of days. If an employee is on vacation from day 5 to day 14, the solver fixes all assignment variables to 0 and all day-off variables to 1 for those 10 days.
Implementation
# For each employee e with VACATION from day d_start to d_end:
for d in range(d_start, d_end + 1):
for s in all_qualified_shifts(e):
model.Add(assign[e, d, s] == 0)
model.Add(is_off[e, d] == 1)
The key difference from HOLIDAY is the impact on monthly targets. A 10-day vacation reduces the employee's monthly target hours and target working days proportionally. The solver adjusts expectations automatically: it does not try to cram 22 working days into the remaining 20 available days. It recalculates the target based on the employee's actual availability.
This is critical for equity. Without target adjustment, an employee on 2 weeks vacation would appear to have far fewer working days than their colleagues, creating an artificial equity gap that the solver would waste effort trying to close.
FIXED_OFF: requested days off
FIXED_OFF is the same mechanism as HOLIDAY, applied to a specific day requested in advance by the employee. Common uses: doctor appointment, personal obligation, training day, family event.
Implementation
# For each employee e with FIXED_OFF on day d:
for s in all_qualified_shifts(e):
model.Add(assign[e, d, s] == 0)
model.Add(is_off[e, d] == 1)
From the solver's perspective, FIXED_OFF is identical to HOLIDAY. The distinction exists in the business layer: HOLIDAY comes from the public holiday calendar, FIXED_OFF comes from an employee request approved by the planner. The solver does not care about the reason. It sees a constraint and respects it.
NOT_WEEKEND: student contract rules
Student employees should not work weekends. This is a contract-level rule, not an individual request. Every employee with contract_type == "etudiant" gets this constraint applied automatically.
Implementation
# For each student employee e, for each weekend day d (Saturday/Sunday):
for s in all_qualified_shifts(e):
model.Add(assign[e, d, s] == 0)
Note that unlike HOLIDAY or FIXED_OFF, we do not force is_off[e, d] = 1 on weekend days for students. The solver is free to give them the day off or not. The constraint only says: if they work, it cannot be on a weekend day.
Students are also excluded from the weekend guarantee penalty. Since they never work weekends, penalising them for not having a weekend off would be meaningless. The penalty only applies to non-student employees.
MAX_HOURS and MAX_SHIFTS_PER_QUALIF
Two additional contract-level constraints cap the workload:
# MAX_HOURS: cap total worked minutes for employee e
model.Add(total_minutes[e] <= max_hours_value * 60)
# MAX_SHIFTS_PER_QUALIF: cap shifts in function f for employee e
model.Add(total_shifts_per_function[e, f] <= max_shifts_value)
MAX_HOURS prevents an employee from exceeding their contractual hour limit. MAX_SHIFTS_PER_QUALIF prevents over-concentration on a single function, ensuring employees maintain diverse experience across their qualifications.
Impact on equity calculations
Equity objectives measure how fairly work is distributed within each group. The solver minimises the gap between the employee who works the most and the one who works the least. But what happens when some employees are absent for part of the month?
The problem without exclusion
Consider a group of 10 employees. 8 are available all month. 2 are on vacation for 2 weeks. Without any special handling, the 2 vacation employees would have roughly half the working days of their colleagues. The equity gap would be enormous, and the solver would waste search effort trying to reduce it, potentially degrading other objectives.
The solution: exclude absent employees
# Workload equity: exclude employees with HOLIDAY or VACATION
active_employees = [e for e in group if not has_holiday_or_vacation(e)]
gap = max_days_worked[active] - min_days_worked[active]
penalty += gap * WEIGHT_WORKLOAD_EQUITY
# Qualification equity: same exclusion
active_employees = [e for e in group if not has_holiday_or_vacation(e)]
gap = max_shifts[active, f] - min_shifts[active, f]
penalty += gap * WEIGHT_QUALIF_EQUITY
Employees with HOLIDAY or VACATION constraints are excluded from both equity calculations: workload equity (days worked) and qualification equity (shifts per function). This ensures the solver only measures fairness among employees who are actually available for the full period.
The result: equity penalties reflect real imbalances that the solver can fix, not artificial gaps caused by planned absences.
Dynamic adaptation
The constraint injection is entirely data-driven. The model does not contain any hard-coded employee names, dates, or exception rules. Everything comes from the input data.
The data flow
# 1. Load constraints from database/JSON
constraints = load_constraints()
# Example: [
# {"employee_id": 42, "type": "VACATION", "start": "2026-03-10", "end": "2026-03-21"},
# {"employee_id": 17, "type": "HOLIDAY", "day": "2026-03-28"},
# {"employee_id": 55, "type": "NOT_WEEKEND"},
# {"employee_id": 8, "type": "MAX_HOURS", "value": 160},
# ]
# 2. Inject into the model at build time
for c in constraints:
if c["type"] == "HOLIDAY":
inject_holiday(model, c["employee_id"], c["day"])
elif c["type"] == "VACATION":
inject_vacation(model, c["employee_id"], c["start"], c["end"])
elif c["type"] == "FIXED_OFF":
inject_fixed_off(model, c["employee_id"], c["day"])
elif c["type"] == "NOT_WEEKEND":
inject_not_weekend(model, c["employee_id"])
elif c["type"] == "MAX_HOURS":
inject_max_hours(model, c["employee_id"], c["value"])
elif c["type"] == "MAX_SHIFTS_PER_QUALIF":
inject_max_shifts(model, c["employee_id"], c["function"], c["value"])
Adding a new vacation is a data change, not a code change. The planner enters the constraint through the dashboard, it gets stored in the database, and the next solver run picks it up automatically. No deployment, no recompilation, no risk of breaking the model.
No recoding needed
The six constraint types cover the vast majority of real-world scheduling exceptions:
| Constraint type | Scope | Effect on model | Effect on equity |
|---|---|---|---|
HOLIDAY | Single day | assign = 0, is_off = 1 | Employee excluded |
VACATION | Day range | assign = 0, is_off = 1 (each day) | Employee excluded |
FIXED_OFF | Single day | assign = 0, is_off = 1 | No exclusion |
NOT_WEEKEND | All weekends | assign = 0 on Sat/Sun | Excluded from weekend penalty |
MAX_HOURS | Whole month | total_minutes ≤ cap | No effect |
MAX_SHIFTS_PER_QUALIF | Per function | total_shifts_per_function ≤ cap | No effect |
These six types handle approximately 95% of the exceptions encountered in real ground handling operations. The planner enters them through the dashboard UI, they flow through the database into the solver as structured data, and the model absorbs them at build time.
The remaining 5%? Edge cases like shift swaps between two specific employees, or temporary qualification upgrades. These are handled outside the solver, as manual adjustments after the schedule is generated. Trying to encode every possible edge case in the model would add complexity without meaningful benefit.
The design principle is clear: the solver handles the rules, the data handles the exceptions. When the rules do not change but the exceptions do (which is every month), no code needs to change. The planner stays in control through the UI, and the solver adapts automatically.
Want to see how exceptions are handled on your data?
Send us your constraint list and employee data. We will run the solver and show you how it adapts to your specific exceptions.
Contact us