Optimisation with Pyomo, Part II: Whole Chairs, Real Promises and Questioning the Answer

We go back to the problem that nobody will buy 0.09 chairs.
In Part I we built a small workshop model, solved it, and got a plan with a profit of 578.18. We also learned to read the answer: which constraints bind, what an extra unit of wood is worth, and how to check that the solver actually finished.
We quietly ignored one problem: the plan asks for 9.09 chairs and 4.36 tables, which is not something a carpenter can build. You could consider this as ongoing work, but there are plenty of problems where the solution has to be a whole number.
This part is about making the model honest. We will force whole units, restructure the code so data lives outside the model, add a customer promise, sweep a resource, and then ask what the model still does not know. As in Part I, we focus on how modelling choices change the answer, without trying to cover every Pyomo feature.

Rounding Is a Guess
The obvious fix is to round the answer. Nine chairs, four tables, done.
Let us at least do it properly, by checking every whole-number neighbour of the fractional plan:
This tells us
(9, 4) feasible, profit 550
(9, 5) infeasible
(10, 4) infeasible
(10, 5) infeasibleThree of the four neighbours break a constraint. Only 9 chairs and 4 tables survives, with a profit of 550.
That feels reasonable, but as we will see, it is not the best whole-number plan. Rounding a fractional optimum is a guess, not a method. The best integer answer can sit somewhere you would never think to look.
Data In, Model Out
Before changing the model, let us tidy how we build it. In Part I, the numbers were baked into the constraints. That is fine for one model, and painful for the second.
We can keep the data in plain tables and build the model from them:
This already looks much closer to a matrix and two vectors. One each for the row respectively column count of the matrix.
Now the model becomes a function of that data:
Sets (m.P, m.R) name the things we are indexing over. An indexed variable (m.make[p]) replaces our separate chairs and tables. And the constraint decorator says: “for every resource r, here is the rule”. One function body produces as many constraints as there are resources.
Adding a product means adding a row to the profit vector and a column to the usage matrix, while adding a resource means adding a row to both the usage matrix and limit vector. In both cases without changing the model code.
We also wrap the solving step, using the safe pattern from Part I:
The + 0.0 is a small tidy-up. Floating-point arithmetic occasionally produces -0.0, which prints as a negative zero and looks alarming for no reason.
Check that nothing changed:
report(build_model(profit, usage, limits))We get out old result back
chair: 9.09, table: 4.36 | profit 578.18Same model, same answer. Anything else would have been alarming.
Whole Units
Now the one-word change. We flip integer=True:
report(build_model(profit, usage, limits, integer=True))And we obtain a very different answer than rounding something.
chair: 5.00, table: 6.00 | profit 570.00The best whole-number plan is 5 chairs and 6 tables, worth 570.
Compare that with the rounded guess of 9 chairs and 4 tables, worth 550. The true answer makes fewer than half as many chairs. It is a completely different workshop.
Why does the answer jump so far? In Part I the feasible region was a solid polygon, and the optimum sat at a corner. With integers, the feasible region is no longer a polygon. It is a grid of dots inside the polygon. The best dot is not necessarily close to the best corner, because the polygon edge that mattered most might not pass through any dot nearby.
Under the hood, the solver now does something different too. It solves the fractional version first, notices a fractional answer, and splits the problem in two: “chairs ≤ 9” and “chairs ≥ 10”. It repeats this on each branch, discarding any branch that cannot beat the best whole plan found so far. This is called branch and bound, and it is why integer problems can be dramatically harder than continuous ones.
There are two consequences. First, integer models have no shadow prices. The smooth “13.64 per square metre” idea belongs to the continuous world. We will see why in a moment.
Second, the profit dropped from 578.18 to 570. The difference of 8.18 is the price of whole units. It is the cost of the real world refusing to be fractional.
Trust, but verify
This problem is small enough to check the solver the slow way, by trying every plan:
Same answer. This works, since there are obvious, rather small, bounds on c and t to which we only have to check. Brute force lets us check a model on a toy version of the problem, before the real version gets too large to enumerate.
Making a Promise
Real workshops have customers. Suppose one of them has been promised at least 10 chairs this week.
In the data-driven model, that is one extra argument:
We already equipped our model builder above to accept minimums as an extra constraint so we get
chair: 10.00, table: 3.00 | profit 510.00The promise forces the plan back towards chairs, and the profit falls from 570 to 510. The promise costs 60 a week.
That number is more useful than the plan itself. It is the price at which the order stops being worth taking. If the customer pays 60 more than a normal price for those chairs, we are indifferent. If less, we are subsidising them.
Now a promise that cannot be kept:
Which naturally results in
No plan: infeasibleFinishing time caps us at 12 chairs. The solver does not apologise, and does not suggest 12. It simply reports that the rules contradict each other. Deciding which rule to relax is a business conversation, not a solver feature.
Changing the World
Part I ended with a warning that shadow prices only hold for small changes. Here is what that looks like.
We sweep the wood supply and solve the whole-unit model each time:
The table this generates looks as follows
wood chair table profit
0 30 10 2 440
1 35 10 3 510
2 40 5 6 570
3 45 0 9 630
4 50 0 9 630
5 55 0 9 630More wood changes what we make, not just how much. Going from 40 to 45 square metres flips the plan from 5 chairs and 6 tables to no chairs and nine tables. Anyone adjusting a plan by feel would not predict that.
The gains come in steps. From 35 to 40 the profit rises by 60, and from 40 to 45 by 60 again. That is 12 per square metre, not the 13.64 from Part I. The shadow price describes the smooth world. Whole units make the real response lumpy.
Then the gains stop. Beyond 45, extra wood is worth nothing. Nine tables use up all 18 machine hours, so machine time, the resource that was nearly idle in Part I, becomes the bottleneck. This fits with what we could easily observe in the image there, but is much harder to anticipate in large problems.
Re-running the model tells us both whether we should buy more wood and where to stop.
What the Model Does Not Know
Look at that last plan again. With plenty of wood, the model says: make nine tables and no chairs, every week.
That plan may not be sensible. We never told the model whether the market buys nine tables a week.
This is the most common failure in optimisation. The solver found the best answer to the question we wrote down, and the question was incomplete. Let us add one missing fact: demand is limited to five tables a week.
This is simply another constraint and thus we get a slightly different answer
chair: 8.00, table: 5.00 | profit 590.00One extra line, and a completely different plan: eight chairs and five tables. The profit is 590, not the 630 we believed a minute earlier. The earlier number looked better because the model was missing a constraint.
Even our improved model still assumes quite a lot:
Profit per unit is constant. No discounts, no price changes, no rush orders.
Resource use is perfectly linear. Wood offcuts and setup time do not exist.
The numbers are certain. The supplier delivers exactly 40, every week.
One week is all there is. There is no stock carried over, and no tomorrow.
The goal is profit. Not stable workloads, not happy staff, not risk.
All models simplify, so none of these assumptions is scandalous. We need to know which simplifications we made and whether any could flip the answer.
A Habit Worth Building
I have used this loop on every model I have built since:
Solve the simple version first. Fractions allowed, few constraints.
Read the binding constraints. Do they match your intuition? If not, find out why.
Add one complication at a time. Integers, then commitments, then demand. Compare each plan with the previous one.
Sweep the important number. Find where the answer changes shape.
Try to break it. Impossible orders, missing limits, absurd inputs.
Say the answer in words. If you cannot explain why the plan looks this way, you do not understand the model yet.
Adding complications one at a time tells you which change moved the answer. You need to know that if you intend to defend the model.
When NOT to Use This
We verified the solver by brute force a few sections ago. That worked because the workshop has two products. Our loop tried about two hundred plans.
With ten products, each up to 20 units, there are around ten trillion plans. That is where brute force dies and a solver earns its keep, because branch and bound skips almost all of them.
But the reverse also applies. If your problem has a handful of options, a loop is clearer than a model, and is easier to hand to a colleague. If your inputs are guesses, an exact optimum gives false confidence. And if nobody can state the objective, no amount of Pyomo will help. Optimisation does not resolve disagreements about what matters. It makes them impossible to hide.
Wrapping Up
In Part I we learned to describe a decision as variables, an objective and constraints, and to read the answer through binding constraints and shadow prices.
In this part we found that the “obvious” next step, rounding, was a guess. We moved the data out of the model, forced whole units, priced a customer promise, watched the optimal plan change shape as wood grew, and caught a model that was confidently wrong because it had never heard of demand.
An optimal answer is only as good as the question we asked. Learning Pyomo helps, but we still have to do the work of asking that question carefully.

Comments