2026, Jan 08 19:00

How to Enforce Consecutive Hours in OR-Tools CP-SAT Timetables: Pairwise Ban vs. Single-Block Model

Learn how to enforce consecutive lesson blocks in OR-Tools CP-SAT timetabling. See why OnlyEnforceIf fails and fix it with AddBoolOr or a single-block model.

When you model a school timetable with Google OR-Tools (CP-SAT), a frequent requirement is to keep daily lessons of the same subject contiguous for each group. In a minimal scenario with a single subject, one teacher and two groups, it may look straightforward, yet a small modeling slip can silently disable the “consecutive hours” rule and allow scattered lessons. Below is a compact walkthrough of how that happens and two ways to fix it, including a robust single-block formulation.

Problem setup

We want to place subject slots across a weekly grid, ensuring each subject meets its weekly quota, no teacher is double-booked, teacher capacity is respected, and the subject has a daily cap per group. Additionally, if a group studies a subject more than one hour in a day, those hours should form one contiguous block.

Problematic code example

The following snippet builds the decision variables and constraints, including a non-working attempt at enforcing daily contiguity. The names are self-explanatory: keys encode group, subject, teacher, day, and hour.

from ortools.sat.python import cp_model
m = cp_model.CpModel()
# Decision variables: (group, subject_id, teacher_id, day, hour)
slot_vars = {}
for grp in groups:
    lvl = grp.split('-')[0]
    for subj in subjects:
        if subj.course == lvl:
            for tch in teachers:
                if subj in tch.subjects:
                    for d in range(day_count):
                        for h in range(hour_count):
                            k = (grp, subj.id, tch.id, d, h)
                            slot_vars[k] = m.NewBoolVar(f"g:{grp} s:{subj.id} t:{tch.name} d:{d} h:{h}")
# Each group-subject must meet its weekly required hours
for grp in groups:
    lvl = grp.split('-')[0]
    for subj in subjects:
        if subj.course == lvl:
            m.Add(
                sum(v for (g, s, _, _, _), v in slot_vars.items() if g == grp and s == subj.id) == subj.weekly_hours
            )
# A teacher cannot teach two classes at the same time
for tch in teachers:
    for d in range(day_count):
        for h in range(hour_count):
            m.AddAtMostOne(
                v for (g, s, t, dd, hh), v in slot_vars.items() if t == tch.id and dd == d and hh == h
            )
# Teachers cannot exceed their max weekly load
for tch in teachers:
    m.Add(
        sum(v for (g, s, t, _, _), v in slot_vars.items() if t == tch.id) <= tch.max_hours_week
    )
# Limit daily hours per (group, subject)
for grp in groups:
    lvl = grp.split('-')[0]
    for subj in subjects:
        if subj.course == lvl:
            for tch in teachers:
                if subj in tch.subjects:
                    for d in range(day_count):
                        day_vars = [
                            v for (g, s, t, dd, _), v in slot_vars.items()
                            if g == grp and s == subj.id and t == tch.id and dd == d
                        ]
                        m.Add(sum(day_vars) <= subj.max_hours_per_day)
# Attempt to enforce "consecutive hours" per day for (group, subject)
for grp in groups:
    lvl = grp.split('-')[0]
    for subj in subjects:
        if subj.course == lvl:
            for d in range(day_count):
                day_all_teachers = [
                    v for (g, s, _, dd, _), v in slot_vars.items()
                    if g == grp and s == subj.id and dd == d
                ]
                if len(day_all_teachers) >= 2:
                    for i in range(len(day_all_teachers)):
                        for j in range(i + 1, len(day_all_teachers)):
                            aux_not_consec = m.NewBoolVar(f"not_consec_{grp}_{subj.id}_{d}_{i}_{j}")
                            m.Add(j != i + 1).OnlyEnforceIf(aux_not_consec)
                            m.AddBoolAnd([day_all_teachers[i], day_all_teachers[j]]).OnlyEnforceIf(aux_not_consec)
                            m.Add(aux_not_consec == 0)
solver = cp_model.CpSolver()
solver.Solve(m)

Why the consecutive-hours constraint fails

The non-working part hinges on how conditional enforcement works. The constraints meant to forbid non-adjacent pairs are guarded by a Boolean, but that same Boolean is immediately fixed to false. In other words, the lines protected by OnlyEnforceIf(...) never activate because the guard is forced to zero. The model therefore has no effective restriction against placing two hours of the same subject in non-consecutive positions, and you get scattered lessons.

Two ways to fix it

There are at least two workable patterns validated by the example and discussion. The first is a minimal fix that keeps the original shape but uses a Boolean disjunction to ban non-adjacent pairs from being both true. The second is a stronger, more explicit formulation that guarantees at most one contiguous block per day by modeling starts of blocks.

The compact fix directly encodes “if two hours are selected on the same day for the same group-subject, they must be adjacent” by forbidding any non-adjacent pair to be simultaneously 1. It can be written as a conditional disjunction on variables for the day’s hour positions.

# Inside the (grp, subj, d) loop after collecting hour-wise variables:
# hour_vec must represent the chosen variables in hour order for that day
if len(hour_vec) >= 2:
    for i in range(len(hour_vec)):
        for j in range(i + 1, len(hour_vec)):
            if j != i + 1:
                m.AddBoolOr([hour_vec[i].Not(), hour_vec[j].Not()])

The robust block-formulation approach builds an aggregated per-hour view for the day (summing over teachers), then marks the start of each selected block and limits the number of starts to at most one. That ensures the chosen hours, if any, form one contiguous run. The snippet below demonstrates this approach.

# For each (grp, subj, d), build a single-hour vector z_h aggregated across teachers,
# then create "start" flags and allow at most one start.
for grp in groups:
    lvl = grp.split('-')[0]
    for subj in subjects:
        if subj.course == lvl:
            for d in range(day_count):
                z_vec = []
                for h in range(hour_count):
                    z = m.NewBoolVar(f"z_{grp}_{subj.id}_d{d}_h{h}")
                    cand = [
                        v for (g, s, t, dd, hh), v in slot_vars.items()
                        if g == grp and s == subj.id and dd == d and hh == h
                    ]
                    if cand:
                        m.Add(sum(cand) == z)  # Assumes no double-booking for the same slot
                    else:
                        m.Add(z == 0)
                    z_vec.append(z)
                starts = []
                for h in range(hour_count):
                    b = m.NewBoolVar(f"begin_{grp}_{subj.id}_d{d}_h{h}")
                    starts.append(b)
                    if h == 0:
                        m.Add(b == z_vec[0])
                    else:
                        m.Add(b >= z_vec[h] - z_vec[h - 1])
                        m.Add(b <= z_vec[h])
                        m.Add(b <= 1 - z_vec[h - 1])
                m.Add(sum(starts) <= 1)  # at most one contiguous block per day

This pattern first consolidates all teacher-dependent assignment variables into a single per-hour binary for the group and subject on that day. Then it identifies rising edges and caps their count at one. The effect is exactly one contiguous streak of selected hours (or none), which is what we want.

Why this matters

Constraint modeling with CP-SAT is all about precise semantics. A single misapplied enforcement literal can deactivate a whole block of constraints without any solver error. Consecutive-hour rules show up in many timetabling and rostering problems; expressing them either as a pairwise non-adjacency ban or as a “one contiguous block” structure is a reliable technique you’ll reuse. It also demonstrates how to aggregate across dimensions (teachers) when the policy needs a group-level view.

Takeaways

Avoid guarding key constraints with Booleans that are immediately set to false. If the goal is to disallow non-adjacent simultaneous selections, a straightforward AddBoolOr on negated variables works well. If the goal is to enforce a single daily run, detect starts with linear relations and limit their count. Both approaches fit naturally into CP-SAT and keep the timetable consistent with domain rules.