The problem
A beverage producer schedules production on its packaging lines. Planners schedule ahead and refine each week's plan as it approaches. The unit of work is a batch: a product and a quantity. For every batch, the plan decides which line runs it, in what order, and when.
A plan has to respect all of these inputs at once:
- Each line runs each product at its own actual and nominal rate, and some lines cannot run some products.
- Switching products costs setup time, which each line defines in its own changeover matrix.
- Downtime, shift windows, and start-up calendars remove time from each line.
- Some batches have a minimum lot size, so a line cannot run a small piece of them.
- Stock and daily forecasts set how soon each product is needed.
The client measures lines with an efficiency KPI in the family of overall equipment effectiveness (OEE): theoretical work time divided by effective work time. Theoretical time is good output divided by nominal speed, so a slower line has a higher effective time and a lower score. If a line runs units at an actual rate against a nominal rate :
Effective time counts everything the line spends on the plan: batch time, changeovers, idle gaps between batches, and the delay before the first batch starts. Efficiency goes up when each product runs on the line closest to its nominal speed and batches are ordered to keep changeovers short.
The existing optimizer used simulated annealing, built its problem from a mix of SQL queries, T-SQL stored procedures, and .NET code, and ran on the application server. I led the replacement from the initial client requirements through delivery.
Why the search space explodes
Leave time aside and count only which line runs each batch and in what order. With batches and lines, a schedule splits the batches into ordered sequences, and some sequences can be empty. Assignment alone allows up to options. Ordering each line multiplies that:
Take a small hypothetical case of 20 batches on 4 lines. It has about assignments and ordered schedules. Checking a billion schedules per second would take about 136,000 years. Start times, shift windows, and partial runs make the real space larger.
CP-SAT cuts the space down with constraint propagation, clause learning, and linear relaxations. It runs several search strategies in parallel and returns the best plan found when the time limit ends.
Three model versions
The model changed as the client added rules.
| Version | What changed |
|---|---|
| v1 (2021) | One integer model assigns and sequences batches against each line's capacity in hours. |
| v2 (2023) | CP-SAT with time intervals. Downtime, shift windows, and calendars become constraints. A second stage cuts batches into shift windows, and stock coverage enters the objective. |
| v3 (2024) | Partial and left-out batches carry penalties, and efficiency becomes a linear objective. Minimum lot applies per batch, the cutter runs on every line that has shift windows, and stock shortages become alerts. v3 went into production. |
System design
I moved optimization off the application server into separate services. A .NET Core and Entity Framework API owns the jobs in SQL Server, and solver workers take jobs from RabbitMQ. The application and the optimizer deploy independently, and the workers first ran on AWS.
Each worker sets a prefetch of 1, so it holds one job at a time and gives the solver all of its configured cores. Adding capacity means adding worker instances.
The worker acknowledges a message only after it posts the result. If a worker stops mid-solve, RabbitMQ delivers the job again. The API also writes each incoming job as JSON before queueing it, so any plan can be solved again from the same input.
Preparing the model input
Before building a model, the worker normalizes time. Overlapping downtime on a line merges into one event. Downtime is subtracted from shift windows, and a window that loses its middle splits in two. Each line's available time is the sum of what remains, and day boundaries start at the first midnight in the planning range.
CP-SAT works with integers. Batch lengths rounded to whole minutes lost output, so the model counts time in hundredths of a minute, with a scale factor . With rates in units per hour, the length of batch on line is:
Each job carries these settings:
| Parameter | Meaning |
|---|---|
| Model | Assignment only, sequencing for one line, or the full model. |
| Objective | Maximize efficiency, minimize production time, or minimize total span. |
| Time limit | A deadline per job, with a worker default. |
| Search workers | CP-SAT parallel search workers, set from the host's configured cores. |
| Time scale | integer units per minute. |
| Stock targets | The level each product should hold in each period. |
| Flags | Include downtime and apply minimum lot. |
Stage 1: assign and sequence
One CP-SAT model covers every line.
Sets and parameters. is the set of lines, the set of batches, the batches of product , and the downtime intervals on line . Batch has quantity and minimum lot . Line runs batch at actual rate and nominal rate . Changing from batch to batch on line takes minutes. is the line's horizon, based on its available time.
Decision variables. A variable exists only when , so an ineligible line never appears in the model.
- is 1 when line runs batch .
- are its start and end. They form an optional interval , at most long, that exists only when .
- is 1 when batch is left out.
- is 1 when batch directly follows batch on line . It controls an optional changeover interval of length that ends when batch starts.
- is the idle gap before that changeover, and is the delay before a first batch.
An interval can be shorter than , so a batch can run partially. The objective charges for that.
Assignment. Each batch runs on exactly one line or is left out:
Timing and no overlap. An unassigned batch has zero start and end. On each line, batches, downtime, and changeovers cannot overlap:
Sequencing. The order on a line is a Hamiltonian path. CP-SAT's circuit constraint expresses it with a dummy depot node 0. An arc from the depot marks the first batch, and an arc back to it marks the last. A self-loop on node means the line skips that batch. The depot has no self-loop, so every line in the model runs at least one batch.
Each arc is tied to the timeline. A chosen arc forces its changeover time, and the model records the idle gap and the delay before the first batch:
Minimum lot. If a batch runs at all, it runs long enough to make its minimum lot:
Objective. The v3 prototype first stated efficiency as a ratio minus penalties. CP-SAT needs a linear objective, and the revision minimizes the difference between effective and theoretical time. Theoretical time uses , so the other terms are also multiplied by to stay on the same scale:
Batch time and the partial-run penalty add up to . Every assigned batch pays for its full length and earns back for each unit of time it actually runs. A batch left out costs its longest possible run time, so the solver drops it only when that lowers the total.
Stock coverage
A schedule can look efficient and still let a product run out. The model projects stock for each product over each period of the horizon. Production placed in the period adds to stock, and forecast demand draws it down. A shortage is how far stock plus production falls below a target , and the target already accounts for the period's demand:
counts what the batches of product produce during the part of their intervals that falls in period . The shortage is a soft constraint: v2 adds to the stage 1 objective, with weights that put it on the same scale as time and make early shortages cost more. v3 drops the term from the objective and reports shortages as alerts.
Stage 2: cutting batches into shift windows
Stage 1 places each batch as one block, but a line with shift windows can only run inside them, so a long batch has to be split. The cutter is a fractional knapsack across several windows, written in CP-SAT and run per line. Its bins are shift windows with downtime removed, and its items are the batches stage 1 put on that line.
Parameters and variables. Window has capacity . Batch has length , the time stage 1 assigned to it, and a minimum lot that takes time units. is the line's changeover time from batch to batch .
- is the time of batch placed in window .
- is 1 when part of batch is in window .
- is an arc literal for a circuit that orders batches inside window .
The constraints keep pieces consistent with their batch and their window:
The pieces of one batch also have to sit in consecutive windows, and empty windows form a single block at the start or end of the horizon.
Objective. The cutter minimizes unused capacity, extra batches per window, changeovers, and time left unassigned. Each batch in a window beyond the first costs a fixed fraction of that window's capacity:
Unused capacity and unassigned time both shrink as more of each batch is placed, so together they push the cutter to fill windows. An empty window pays its full capacity as unused time and gets back from the count term.
When the cutter splits a batch, the worker creates the new batches through the API. It posts an alert for each batch that ran partially or not at all, and for each shift window left unfilled.
A test case produced the empty-window rule. The first version filled capacity but alternated used and unused days. Planners expected the unused shifts grouped together, because those groups define the available ranges. The next version added the block constraint.
Trade-offs
Which objective to optimize
In v1 we compared two objectives on the same test week. Maximizing efficiency gave a higher KPI than minimizing production time, but it scheduled more production hours and took longer to solve. Efficiency was the client's KPI, so it became the default, and v3 uses only efficiency.
Capacity buckets or intervals
v1 treated each line as a bucket of hours. That was enough to assign and sequence batches. It could not place a batch around downtime or a shift, and it could not simulate individual days. v2 moved to time intervals, which made calendars, shift windows, and daily stock possible.
One model or stages
Cutting runs as its own stage after assignment and sequencing. Stage 1 plans against each line's total available time and ignores shift boundaries, which keeps the whole-plant model smaller. Stage 2 then solves one small model per line. In exchange, each batch stays on the line stage 1 chose: the cutter can reorder batches inside a window but cannot move one to another line. If it finds no valid split, the job reports the plan as infeasible.
Hard or soft assignment
Early models required every batch to run: . When demand exceeded available time, the model was infeasible and planners got no plan. The rule was relaxed to , and v3 then priced partial and missing batches in the objective. A batch that does not fit now shows up in an alert.
Simulations and testing
Planners call a scenario run a simulation, and each simulation is one job.
Unit tests run the cutter on small hand-built cases with known window capacities and changeover matrices. Some cases have more than one optimal answer: a batch that needed two of three windows came back in the first two in one run and in the last two in another.
When a plan looked wrong, we saved the job JSON under a name that described the behavior and replayed it against new builds. Those files included a shift window that ended past the horizon and a minimum lot that dropped a small piece.
Each job runs under a time limit, and a job can set its own deadline. Full-plant test runs showed where the default limit was too short.
During rollout, planners reviewed scenarios with us every week. We compared actual weeks with the optimizer's plan and checked changeover and rate data against recent history.
Delivery
Optimizer v1 improved the efficiency KPI by up to 2% over the previous optimizer. The optimizer also helped planners extend their planning horizon. The distributed system replaced the existing optimizer at the client's plants, and v3 later went into production.
What I brought to it
I have enjoyed optimization problems since college, and I like them most when the model has to answer to a real business. On this project I worked across the system, the model, and the business: I moved optimization onto queue-driven workers, designed the CP-SAT models, and turned the plant's efficiency KPI into a linear objective the solver could use.
I ran the client meetings and took the work past the original scope. The first version had two objectives, and on our own initiative we added a third that reduced the time and resources each optimization run needs.

Architecture
