Skip to main content

Building a Basic Model

Now that we understand the Furniture Workshop problem, let's build our first real scheduling model. We'll create interval variables for each task, enforce the precedence constraints, and minimize the makespan.

Interval Variables

In OptalCP, tasks are represented by interval variables. An interval has three properties:

  • Start time: When the task begins
  • End time: When the task finishes
  • Length: How long the task takes (end - start)

Time in OptalCP is an integer. You decide what one time unit represents (seconds, minutes, hours) and what time 0 means (start of shift, midnight, project kickoff). In this tutorial, we'll use minutes.

Let's create interval variables for our workshop tasks:

import optalcp as cp

model = cp.Model()

# Create interval variables for each task
cut_desk_wood = model.interval_var(length=30, name="CutDeskWood")
cut_chair_wood = model.interval_var(length=25, name="CutChairWood")
sand_desk_parts = model.interval_var(length=20, name="SandDeskParts")
sand_chair_parts = model.interval_var(length=15, name="SandChairParts")
assemble_desk = model.interval_var(length=45, name="AssembleDesk")
assemble_chair = model.interval_var(length=35, name="AssembleChair")
stain_desk = model.interval_var(length=20, name="StainDesk")
stain_chair = model.interval_var(length=15, name="StainChair")
apply_finish = model.interval_var(length=30, name="ApplyFinish")
final_inspect = model.interval_var(length=10, name="FinalInspect")

Each interval has a fixed length (duration in minutes) and a descriptive name. The start and end times are decision variables—the solver will choose them to satisfy our constraints and optimize our objective.

Precedence Constraints

Tasks must happen in a specific order. For example, we can't sand the desk parts until we've cut the wood. These are called precedence constraints.

Here are the dependencies we need to model:

Task dependencies

OptalCP has convenient functions to express precedence. The most common is end_before_start, which ensures one task finishes before another begins:

# Desk production chain
cut_desk_wood.end_before_start(sand_desk_parts)
sand_desk_parts.end_before_start(assemble_desk)
assemble_desk.end_before_start(stain_desk)
stain_desk.end_before_start(apply_finish)

# Chair production chain
cut_chair_wood.end_before_start(sand_chair_parts)
sand_chair_parts.end_before_start(assemble_chair)
assemble_chair.end_before_start(stain_chair)
stain_chair.end_before_start(apply_finish)

# Final inspection after finish is applied
apply_finish.end_before_start(final_inspect)

Three Styles for Precedence

You can express precedence in three equivalent ways:

# On the interval variable
task1.end_before_start(task2)

# On the model
model.end_before_start(task1, task2)

# Boolean expression with enforce
model.enforce(task1.end() <= task2.start())

All three are treated exactly the same by the solver—choose whichever you find more readable.

OptalCP has 8 precedence functions with an optional delay parameter. See Modeling / Constraints for the full list.

Minimizing Makespan

Our goal is to finish the entire order as quickly as possible. This means minimizing the makespan: the time when the last task finishes.

The last task is final_inspect, so we minimize its end time:

model.minimize(final_inspect.end())

The end() function returns an integer expression representing the task's end time. By minimizing it, we tell the solver to find the schedule that completes everything earliest.

Solving the Model

Now we're ready to solve:

result = model.solve()

if result.solution:
print(f"Makespan: {result.objective} minutes")
print(f"Optimal: {result.proof}")
print()

# Print the schedule for each task
for task in [cut_desk_wood, cut_chair_wood, sand_desk_parts,
sand_chair_parts, assemble_desk, assemble_chair,
stain_desk, stain_chair, apply_finish, final_inspect]:
start = result.solution.get_start(task)
end = result.solution.get_end(task)
print(f"{task.name}: {start} - {end}")
else:
print("No solution found")

Understanding the Output

The solver returns a SolveResult object with:

  • solution: The best schedule found (or None/null if none exists)
  • objective: The objective value (makespan in this case)
  • proof: Whether optimality is proven (True if optimal, False if solution is feasible but might not be optimal)

When you run the code, the solver automatically prints a log showing its progress. After the solver finishes, our print statements output the schedule. Here's the complete output:

────────────────────────────────────────────────────────────────────────────────
                              ScheduleOpt OptalCP
                           Version 2025.12.1 (Linux)
                   CPU: AMD Ryzen 9 5950X (16 physical cores)
────────────────────────────────────────────────────────────────────────────────
Input parse time: 00:00
   nbWorkers = 16                      (auto: 16 physical cores)
   preset = Default                    (auto: < 100,000 variables)
   noOverlapPropagationLevel = 4       (preset: Default)
   cumulPropagationLevel = 3           (preset: Default)
     Workers 0-7: searchType = LNS     (preset: Default)
    Workers 8-13: searchType = FDS     (preset: Default)
   Workers 14-15: searchType = FDSDual (preset: Default)
Input:
   0 integer variables, 10 interval variables, 9 constraints, 11.2kB
   00:00 Presolving..
Presolved:
   0 integer variables, 10 interval variables, 9 constraints, 11kB
   00:00 Presolve complete, starting search.
────────────────────────────────────────────────────────────────────────────────
   00:00  Lower bound 155 Worker 5
   00:00 ↓ Solution 155 [Verified] Worker 5: LNS
   00:00 The current best solution is optimal.
────────────────────────────────────────────────────────────────────────────────
   Objective value: 155 (optimal)
       Lower bound: 155
         Solutions: 1
         LNS steps: 11 (1026.08 per second)
          Restarts: 0 (0.00 per second)
          Branches: 93 (8675.05 per second)
             Fails: 9 (839.52 per second)
    Total duration: 00:00.01
            Memory: 102MB
────────────────────────────────────────────────────────────────────────────────
Makespan: 155 minutes
Optimal: true

CutDeskWood: 0 - 30
CutChairWood: 0 - 25
SandDeskParts: 30 - 50
SandChairParts: 25 - 40
AssembleDesk: 50 - 95
AssembleChair: 40 - 75
StainDesk: 95 - 115
StainChair: 75 - 90
ApplyFinish: 115 - 145
FinalInspect: 145 - 155

Understanding the Solver Log

The log has three main parts:

Parameters — At the top, the solver shows which parameters it's using. The right column explains why each parameter has its value:

  • auto — auto-detected from your system (e.g., nbWorkers from CPU cores)
  • preset — derived from the selected preset (Default for typical problems)
  • No reason shown — explicitly set by the user

Key parameters include:

  • nbWorkers: parallel CPU threads
  • preset: configuration bundle (automatically chosen based on problem size)
  • searchType: which search strategy each worker uses—the solver runs multiple strategies in parallel (LNS, FDS, FDSDual)
  • Propagation levels: how aggressively constraints are processed

Search progress — During solving, you'll see:

  • ↑ Lower bound 155 — proves no solution can be better than 155
  • ↓ Solution 155 — found a solution with makespan 155
  • When lower bound equals the solution, optimality is proven

Summary — Statistics about the search: objective value, solution count, branches, and duration.

Interpreting the Schedule

Both cutting tasks start at time 0—this schedule is intentionally unrealistic because we haven't added resource constraints yet. Without a no-overlap constraint on the saw, the solver assumes both cutting tasks can run simultaneously. The desk chain is longer, forming the critical path that determines makespan. We'll add resource constraints in the following chapters to make the model realistic.

Preview Edition

If you're using the Preview edition, you will see the correct objective value (155), but solution values (start/end times) will be None/null. See Editions for details.

Complete Code

Here's the complete model:

import optalcp as cp

model = cp.Model()

# Create interval variables
cut_desk_wood = model.interval_var(length=30, name="CutDeskWood")
cut_chair_wood = model.interval_var(length=25, name="CutChairWood")
sand_desk_parts = model.interval_var(length=20, name="SandDeskParts")
sand_chair_parts = model.interval_var(length=15, name="SandChairParts")
assemble_desk = model.interval_var(length=45, name="AssembleDesk")
assemble_chair = model.interval_var(length=35, name="AssembleChair")
stain_desk = model.interval_var(length=20, name="StainDesk")
stain_chair = model.interval_var(length=15, name="StainChair")
apply_finish = model.interval_var(length=30, name="ApplyFinish")
final_inspect = model.interval_var(length=10, name="FinalInspect")

# Precedence constraints - Desk
cut_desk_wood.end_before_start(sand_desk_parts)
sand_desk_parts.end_before_start(assemble_desk)
assemble_desk.end_before_start(stain_desk)
stain_desk.end_before_start(apply_finish)

# Precedence constraints - Chair
cut_chair_wood.end_before_start(sand_chair_parts)
sand_chair_parts.end_before_start(assemble_chair)
assemble_chair.end_before_start(stain_chair)
stain_chair.end_before_start(apply_finish)

# Final inspection
apply_finish.end_before_start(final_inspect)

# Minimize makespan
model.minimize(final_inspect.end())

# Solve
result = model.solve()

if result.solution:
print(f"Makespan: {result.objective} minutes")
print(f"Optimal: {result.proof}")

What We Learned

In this chapter, we:

  • Created interval variables to represent tasks with durations
  • Used precedence constraints to enforce task ordering
  • Set an objective to minimize makespan
  • Solved the model and interpreted the results

Our model captures the logical dependencies, but it's still unrealistic: it assumes we have unlimited resources. In reality, we have one saw, limited workers, and one spray booth.

See Also

Next Steps

In the next chapter, we'll add our first resource constraint: the saw can only cut one piece of wood at a time.

Continue to: No-Overlap Constraints →