Facility¶
Problem Statement¶
Simple facility location model: given a set of plants and a set of warehouses, with transportation costs between them, this example finds the least expensive set of plants to open in order to satisfy product demand.
Full example scenario
A company currently ships its product from 5 plants to 4 warehouses, and is considering closing one or more plants to reduce cost. Which plant(s) should the company close, in order to minimize transportation and fixed costs?
The demand for the warehouses 1-4 is 15, 18, 14 and 20 products respectively (per thousand).
The capacity for the plants 1-5 is 20, 22, 17, 19 and 18 products respectively (per thousand).
The fixed costs for the plants 1-5 is 12000, 15000, 17000, 13000 and 16000 respectively.
The transportation costs (per 1000 products) can be found in
facility.csv, with the columnsWarehouse,Plant 1,Plant 2,Plant 3,Plant 4,Plant 5.
What an optimal solution looks like
Which plant ships to which warehouse.
Plant 1
20 of 20 used
Plant 2
22 of 22 used
Plant 3
closed
Plant 4
19 of 19 used
Plant 5
6 of 18 used
Warehouse 1
demand 15
Warehouse 2
demand 18
Warehouse 3
demand 14
Warehouse 4
demand 20
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same facility problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled in your industry, so you can answer with a number instead of prose.
A company currently ships its product from 5 plants to 4 warehouses. It is considering closing one or more plants to reduce cost.
The user is describing a facility location / plant closing optimization problem. Let me start by searching the knowledge base for relevant examples and ask clarifying questions one at a time. First, let me search the knowledge base for relevant information, and then ask the first clarifying question.
Querying the knowledge base.
Good, I found some relevant information about facility location problems. Now let me ask the first clarifying question to understand the business objective better.
Great, this is a classic facility location / plant closing optimization problem. I have found relevant examples in the knowledge base to guide the modelling. Let me ask you a few questions to build a complete specification.
Question 1: What is the primary business objective? For example:
Minimize total cost (shipping costs + fixed operating costs of open plants)
Minimize shipping costs only
Minimize fixed costs only
Another objective
Objective: Which plant(s) should the company close, in order to minimize transportation and fixed costs?
Constraints:
That is option 1, plus the demand, capacity and fixed cost data. Recording the objective and the input entities in the specification. Still open: the transportation cost per lane, whether demand must be met exactly, and the output structure. Asking about demand satisfaction next, since it changes the constraint rather than just the data.
Recorded. The specification now has the objective and three input entities. Three things are still missing before I can implement anything:
Question 2: Should each warehouse receive exactly its demand, or at least its demand?
Exactly the demand — no excess inventory held at the warehouse
At least the demand — overshipping is allowed
Within a tolerance band around the demand
And I want the shipment plan too, not just which plants to close.
Thinking…
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
Modeler asked for the transportation costs as a file rather than as 20 numbers
typed into the chat. Attach facility.csv to the conversation and it reads the
real shape of your data instead of guessing at it — entity names, column headers,
units and all.
Warehouse,Plant 1,Plant 2,Plant 3,Plant 4,Plant 5
1,4000,2000,3000,2500,4500
2,2500,2600,3400,3000,4000
3,1200,1800,2600,4100,3000
4,2200,2600,3100,3700,3200
facility.csv attached
Here are the transportation costs. Cost is per 1000 products.
Reading the attachment. One row per warehouse, one column per plant, so this is a 5 × 4 cost matrix in wide format — 20 lanes, matching the 5 plants and 4 warehouses already in the specification. Values are per 1000 products, the same unit as the demand and capacity figures, so no conversion is needed. Adding a “Transport lane” entity to the Input section and noting the wide layout so the implementation reads the file as given.
Got it — 20 lanes, in wide format, priced per 1000 products. That matches the units on demand and capacity, so nothing needs rescaling. I have added a Transport lane entity to the specification’s Input section and recorded that the file is keyed by warehouse with one column per plant.
That was the last open item. Total capacity is 96 against total demand of 67, so at least one plant can be closed. Shall I proceed to Step 2: Model Implementation using Python with gurobipy?
Where your files live
Everything in the conversation — the file you attached and every artifact Modeler generates from it — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The cost data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills in
a missing implementation or test suite, reconciles code that has drifted from the
specification, and runs the tests. Point it at a model written for another solver
or another language and it recovers a specification from that file, asks you to
confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
A company ships a single product from 5 plants to 4 warehouses. It is considering closing one or more plants to reduce cost. The model decides which plants to keep open and how much each open plant ships to each warehouse.
2. Objective
Minimize total cost, defined as transportation cost for everything shipped plus the fixed cost of every plant kept open.
3. Constraints
C1 — Demand is met exactly. Each warehouse receives exactly its demand. Under-delivery is not allowed, and over-delivery is not allowed either, because excess inventory at a warehouse is not wanted.
C2 — Plants ship within capacity, and only when open. An open plant ships at most its capacity across all warehouses. A closed plant ships nothing.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Warehouse |
id, demand (thousands of products) |
1: 15, 2: 18, 3: 14, 4: 20 |
Plant |
id, capacity (thousands), fixed cost |
capacities 20, 22, 17, 19, 18; fixed costs 12000, 15000, 17000, 13000, 16000 |
Transport lane |
plant id, warehouse id, cost per 1000 products |
20 pairs, supplied in |
Total capacity is 96 against total demand of 67, so at least one plant can be closed.
5. Output
The set of plants to keep open and the set to close.
The shipment plan: how much each open plant sends to each warehouse.
Cost breakdown: transportation cost, fixed cost, and total cost.
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Baseline — the data above. |
Close plant 3 only. Total cost 210,500 (transportation 154,500 + fixed 56,000). |
T2 |
Demand at warehouse 2 spikes from 18 to 25. |
The recommendation changes: plant 5 closes instead of plant 3. Total cost 232,500. |
T3 |
Plant 3 goes offline (capacity drops to 0). |
Plan and cost are unchanged from T1, since plant 3 was already being closed. |
T4 |
Plant 1 goes offline (capacity drops to 0). |
Plant 3 is forced back into the plan. Total cost 238,400. |
T5 |
Every warehouse demands 30 (total 120 against 96 of capacity). |
Reported as infeasible — no plan is returned. |
T6 |
Compare against the status quo of keeping all five plants open. |
Status quo costs 226,900, so the recommended closure saves 16,400. |
7. Out of scope
Multiple products or product-specific routing.
Multiple time periods; the model is a single-period snapshot.
One-time closure costs such as severance or decommissioning.
Reopening a closed plant, or building new capacity.
Fixed lane costs, minimum shipment quantities, and vehicle or truckload limits.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version that came out of the run-and-fix loop in the Cloud solve
tab, after Modeler ran the tests and fixed what it found.
"""Plant closure decision: which plants to keep open to serve all warehouse
demand at minimum total cost (transportation + fixed cost of the open plants).
Implements the specification in facility_modeler_spec.rst.
"""
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
# Business data from the specification (quantities in thousands of products).
DEMANDS = {1: 15, 2: 18, 3: 14, 4: 20}
CAPACITIES = {1: 20, 2: 22, 3: 17, 4: 19, 5: 18}
FIXED_COSTS = {1: 12000, 2: 15000, 3: 17000, 4: 13000, 5: 16000}
def read_transport_costs(path):
"""Read facility.csv into a {(plant, warehouse): cost} dict per 1000 units."""
frame = pd.read_csv(path).set_index("Warehouse")
return {
(int(column.removeprefix("Plant ")), int(warehouse)): float(cost)
for warehouse, row in frame.iterrows()
for column, cost in row.items()
}
def solve_facility(
demands, capacities, fixed_costs, transport_costs, time_limit=None
):
"""Solve the plant closure model.
Args:
demands: {warehouse: demand} in thousands of products.
capacities: {plant: capacity} in thousands of products. A capacity of 0
models a plant that is unavailable in this scenario.
fixed_costs: {plant: fixed cost} of keeping the plant open.
transport_costs: {(plant, warehouse): cost} per 1000 products shipped.
time_limit: optional solver time limit in seconds.
Returns:
A dict with the open/closed decision, the shipment plan and the cost
breakdown, or ``{"status": "infeasible"}`` when demand cannot be met.
"""
plants = list(capacities)
warehouses = list(demands)
with gp.Env() as env, gp.Model("facility", env=env) as model:
if time_limit is not None:
model.Params.TimeLimit = time_limit
# ship[p, w]: thousands of products sent from plant p to warehouse w.
ship = model.addVars(plants, warehouses, name="ship")
# open_plant[p]: 1 if plant p stays open, 0 if it is closed.
open_plant = model.addVars(plants, vtype=GRB.BINARY, name="open")
model.setObjective(
gp.quicksum(
transport_costs[p, w] * ship[p, w]
for p in plants
for w in warehouses
)
+ gp.quicksum(fixed_costs[p] * open_plant[p] for p in plants),
GRB.MINIMIZE,
)
# C1: every warehouse receives exactly its demand.
model.addConstrs(
(ship.sum("*", w) == demands[w] for w in warehouses), name="demand"
)
# C2: a plant ships within its capacity, and nothing at all when closed.
model.addConstrs(
(ship.sum(p, "*") <= capacities[p] * open_plant[p] for p in plants),
name="capacity",
)
model.optimize()
if model.Status == GRB.INFEASIBLE:
return {"status": "infeasible"}
if model.SolCount == 0:
return {"status": "no_solution", "gurobi_status": model.Status}
open_values = model.getAttr("X", open_plant)
ship_values = model.getAttr("X", ship)
transport_cost = sum(
transport_costs[p, w] * ship_values[p, w]
for p in plants
for w in warehouses
)
fixed_cost = sum(fixed_costs[p] for p in plants if open_values[p] > 0.5)
return {
"status": "optimal",
"open_plants": [p for p in plants if open_values[p] > 0.5],
"closed_plants": [p for p in plants if open_values[p] <= 0.5],
"shipments": {
(p, w): ship_values[p, w]
for p in plants
for w in warehouses
if ship_values[p, w] > 1e-6
},
"transport_cost": transport_cost,
"fixed_cost": fixed_cost,
"total_cost": transport_cost + fixed_cost,
}
def main():
transport_costs = read_transport_costs("facility.csv")
result = solve_facility(DEMANDS, CAPACITIES, FIXED_COSTS, transport_costs)
if result["status"] != "optimal":
print(f"No optimal plan found: {result['status']}")
return
print(f"Keep open: {result['open_plants']}")
print(f"Close: {result['closed_plants']}")
print("\nShipment plan (thousands of products):")
for (plant, warehouse), amount in sorted(result["shipments"].items()):
print(f" Plant {plant} -> Warehouse {warehouse}: {amount:6.1f}")
print(
f"\nTransport cost: {result['transport_cost']:>10,.0f}"
f"\nFixed cost: {result['fixed_cost']:>10,.0f}"
f"\nTotal cost: {result['total_cost']:>10,.0f}"
)
if __name__ == "__main__":
main()
Alongside the implementation, Modeler writes a pytest suite with one test per
scenario in the specification. Each test references its scenario identifier, and
uses the small hard-coded plant and warehouse data and a short solver time limit
so the suite runs in well under a second.
"""One test per test scenario in facility_modeler_spec.rst.
Each test uses the small hard-coded data set from the specification and a short
solver time limit, so the whole suite runs in well under a second.
"""
import pytest
from facility_modeler import CAPACITIES, DEMANDS, FIXED_COSTS, solve_facility
# Transportation cost per 1000 products, keyed by (plant, warehouse).
# Same values as facility.csv, inlined to keep the tests self-contained.
TRANSPORT_COSTS = {
(1, 1): 4000, (2, 1): 2000, (3, 1): 3000, (4, 1): 2500, (5, 1): 4500,
(1, 2): 2500, (2, 2): 2600, (3, 2): 3400, (4, 2): 3000, (5, 2): 4000,
(1, 3): 1200, (2, 3): 1800, (3, 3): 2600, (4, 3): 4100, (5, 3): 3000,
(1, 4): 2200, (2, 4): 2600, (3, 4): 3100, (4, 4): 3700, (5, 4): 3200,
}
TIME_LIMIT = 10
def run(demands=None, capacities=None, fixed_costs=None):
return solve_facility(
demands or DEMANDS,
capacities or CAPACITIES,
fixed_costs or FIXED_COSTS,
TRANSPORT_COSTS,
time_limit=TIME_LIMIT,
)
def assert_feasible(result, demands=None, capacities=None):
"""Every warehouse is served exactly, and no closed plant ships anything."""
demands = demands or DEMANDS
capacities = capacities or CAPACITIES
for warehouse, demand in demands.items():
received = sum(
amount
for (_, w), amount in result["shipments"].items()
if w == warehouse
)
assert received == pytest.approx(demand)
for plant in capacities:
shipped = sum(
amount
for (p, _), amount in result["shipments"].items()
if p == plant
)
if plant in result["closed_plants"]:
assert shipped == pytest.approx(0)
else:
assert shipped <= capacities[plant] + 1e-6
def test_t1_baseline_closes_plant_3():
"""T1: with the published data, closing plant 3 is optimal at 210,500."""
result = run()
assert result["status"] == "optimal"
assert result["closed_plants"] == [3]
assert result["open_plants"] == [1, 2, 4, 5]
assert result["total_cost"] == pytest.approx(210_500)
assert result["transport_cost"] == pytest.approx(154_500)
assert result["fixed_cost"] == pytest.approx(56_000)
assert_feasible(result)
def test_t2_demand_spike_at_warehouse_2_reopens_plant_3():
"""T2: warehouse 2 demand rises 18 -> 25, so plant 5 closes instead of 3."""
demands = {**DEMANDS, 2: 25}
result = run(demands=demands)
assert result["status"] == "optimal"
assert result["closed_plants"] == [5]
assert result["total_cost"] == pytest.approx(232_500)
# Still cheaper than the runner-up plan that keeps plant 5 and closes 3.
assert result["total_cost"] < 232_800
assert_feasible(result, demands=demands)
def test_t3_plant_3_capacity_drops_to_zero_leaves_plan_unchanged():
"""T3: plant 3 going offline costs nothing — it was already being closed."""
capacities = {**CAPACITIES, 3: 0}
result = run(capacities=capacities)
assert result["status"] == "optimal"
assert result["open_plants"] == [1, 2, 4, 5]
assert result["total_cost"] == pytest.approx(210_500)
assert_feasible(result, capacities=capacities)
def test_t4_plant_1_capacity_drops_to_zero_forces_plant_3_open():
"""T4: losing plant 1 forces the expensive plant 3 back into the plan."""
capacities = {**CAPACITIES, 1: 0}
result = run(capacities=capacities)
assert result["status"] == "optimal"
assert result["closed_plants"] == [1]
assert 3 in result["open_plants"]
assert result["total_cost"] == pytest.approx(238_400)
assert_feasible(result, capacities=capacities)
def test_t5_demand_above_total_capacity_is_infeasible():
"""T5: demand of 120 against 96 of capacity has no feasible plan."""
result = run(demands={1: 30, 2: 30, 3: 30, 4: 30})
assert result["status"] == "infeasible"
def test_t6_closing_a_plant_beats_keeping_all_five_open():
"""T6: the status quo (all five plants open) costs 226,900, so the
recommended closure saves 16,400."""
# Cheapest way to serve all demand when no plant may be closed: drop the
# fixed costs from the objective, then add all five back afterwards.
status_quo = run(fixed_costs={p: 0 for p in FIXED_COSTS})
assert status_quo["status"] == "optimal"
assert status_quo["transport_cost"] == pytest.approx(153_900)
status_quo_cost = status_quo["transport_cost"] + sum(FIXED_COSTS.values())
assert status_quo_cost == pytest.approx(226_900)
baseline = run()
assert status_quo_cost - baseline["total_cost"] == pytest.approx(16_400)
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve. It reads the output and fixes what it finds, so what you get back is a model that has been executed, not one that merely looks plausible.
The specification is now complete. One last question before moving to implementation: should the shipment plan be reported per plant-warehouse pair, or aggregated to totals per plant?
Per plant-warehouse pair works for me. Go ahead and implement and run the model.
Writing the solve_facility implementation and a pytest suite
covering scenarios T1-T6, then running the tests.
Running the test suite.
The run, and the loop
The first run here failed on the infeasible scenario:
$ pytest test_facility_modeler.py -q
....F.
================================= FAILURES =================================
________________ test_t5_demand_above_total_capacity_is_infeasible ________________
def test_t5_demand_above_total_capacity_is_infeasible():
result = run(demands={1: 30, 2: 30, 3: 30, 4: 30})
> assert result["status"] == "infeasible"
E KeyError: 'status'
1 failed, 5 passed in 0.71s
T5 fails with a KeyError rather than a wrong number, so this is not a
modeling error: the solver correctly proved infeasibility, but
solve_facility reads solution values unconditionally and never returns
a status. Scenario T5 says “reported as infeasible — no plan is returned”,
so the specification and the test agree and the implementation is the side
to fix. Adding a status check on model.Status before reading any
attribute, then re-running.
One failure, and it was in the code rather than in the model: the implementation read solution values without first checking the solve status, so scenario T5 raised instead of reporting infeasibility. I added the status check and re-ran — all six scenarios pass.
$ pytest test_facility_modeler.py -q
......
6 passed in 0.68s
Gurobi Optimizer version 12.x (linux64)
Optimize a model with 9 rows, 25 columns and 45 nonzeros
Variable types: 20 continuous, 5 integer (5 binary)
...
Explored 1 nodes in 0.01 seconds (0.00 work units)
Best objective 2.105000000000e+05, best bound 2.105000000000e+05, gap 0.0000%
All six tests pass. Here is a full summary of everything that was built:
Results
Optimal solution
Close plant 3 and keep the rest open, at a total cost of $210,500:
Plant |
Status |
Ships to |
|---|---|---|
Plant 1 |
Open |
Warehouses 2, 3 |
Plant 2 |
Open |
Warehouses 1, 3 |
Plant 3 |
Closed |
— |
Plant 4 |
Open |
Warehouse 4 |
Plant 5 |
Open |
Warehouses 1, 4 |
Cost breakdown achieved:
Component |
Amount |
|---|---|
Transportation cost |
$154,500 |
Fixed cost |
$56,000 |
Total cost |
$210,500 |
Files created
File |
Description |
|---|---|
|
Complete problem specification |
|
Input transportation cost dataset |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1–T6 |
Test results
Test |
Scenario |
Result |
|---|---|---|
T1 |
Baseline — close plant 3, total cost 210,500 |
PASSED |
T2 |
Demand spike at warehouse 2 reopens plant 3, closes plant 5 |
PASSED |
T3 |
Plant 3 offline leaves the plan unchanged |
PASSED |
T4 |
Plant 1 offline forces plant 3 back into the plan |
PASSED |
T5 |
Demand above total capacity is infeasible |
PASSED |
T6 |
Recommended closure beats the status quo by 16,400 |
PASSED |
Let me know if you would like any changes, such as adjusting capacities, adding new plants, or generating the mathematical formulation.
That log lands in the workspace as execution.log alongside the code, so the
solve is auditable rather than asserted.
The loop ends one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 25 columns and 9 rows, is nowhere near the cap — but a full-scale version of it could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full data set for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts one-time closure costs out of scope — a reasonable simplification, until finance points out that shutting a plant costs 30,000 in severance and decommissioning. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
Closing a plant is not free — there is a one-time cost of 30,000 per plant closed. Does that change the recommendation?
This is a scope change, not a data correction: one-time closure costs are
currently listed under “Out of scope”. Updating the specification first —
remove that bullet, add a closure cost field to the Plant entity, and
extend the objective to charge it for every plant that is closed. The
objective now mixes a recurring cost with a one-time cost, which the user
should be aware of, so I will flag it. Then the implementation and tests.
That moves closure costs from out of scope into the model. I have updated the specification:
closure cost field, 30,000 for all five plants.One thing to be aware of before I implement it: the objective now adds a one-time cost to a recurring one, so the totals are no longer comparable year over year. Charging the closure cost once, against a single period of transportation and fixed cost, makes closures look as expensive as they ever will. If you would rather amortize it over a planning horizon, tell me the horizon and I will change the objective instead.
Charge it once for now. Go ahead.
Specification, then code, then tests
The specification changes first. The objective in section 2 becomes:
Minimize total cost, defined as transportation cost for everything shipped,
plus the fixed cost of every plant kept open, plus the one-time closure cost
of every plant that is closed.
The implementation follows the specification, not the other way around:
model.setObjective(
gp.quicksum(
transport_costs[p, w] * ship[p, w]
for p in plants
for w in warehouses
)
- + gp.quicksum(fixed_costs[p] * open_plant[p] for p in plants),
+ + gp.quicksum(fixed_costs[p] * open_plant[p] for p in plants)
+ + gp.quicksum(
+ closure_costs[p] * (1 - open_plant[p]) for p in plants
+ ),
GRB.MINIMIZE,
)
T1 has to be re-derived — the plan it asserted is no longer optimal — and the suite gains one test for the case where a closure still pays for itself:
def test_t1_baseline_keeps_every_plant_open():
"""T1: at 30,000 per closure, no closure pays for itself."""
result = run()
assert result["status"] == "optimal"
assert result["closed_plants"] == []
assert result["total_cost"] == pytest.approx(226_900)
def test_t7_a_cheap_closure_still_wins():
"""T7: at 10,000 per closure, closing plant 3 is still optimal."""
result = run(closure_costs={p: 10_000 for p in CAPACITIES})
assert result["status"] == "optimal"
assert result["closed_plants"] == [3]
assert result["total_cost"] == pytest.approx(220_500)
Modeler re-runs the whole suite, not just the new test — the point of propagating in this order is that nothing is left stale behind the change.
Where it stops and asks
Two scenarios did not survive the update cleanly. T3 and T4 model a plant going offline by dropping its capacity to zero, which the old model treated as identical to closing it — with no closure cost in the objective, there was no difference to draw. There is now, and the specification does not say which reading is right:
Reading |
T3 (plant 3 offline) |
T4 (plant 1 offline) |
|---|---|---|
An offline plant is closed, so it is charged |
keep all open, 227,500 |
keep all open, 250,400 |
Offline is a capacity outage, not a business closure |
close plant 3, 210,500 |
close plant 1, 238,400 |
I have stopped short of updating T3 and T4. Both model a plant going offline as a capacity of zero, and until now that was indistinguishable from closing the plant. With a closure cost in the objective it no longer is, and the specification does not say which one an outage counts as. The two readings give different answers, so I do not want to pick one for you:
Which is it? I will add the distinction to the specification either way, so the next change does not run into the same ambiguity.
This is the loop ending in its second way: not a bug Modeler can fix, but a contradiction between what the specification says and what the tests now need it to say. It reports what it found and hands the decision back rather than guessing.
The answer flips
Plan |
Recurring cost |
Closure cost |
Total |
|---|---|---|---|
Keep all five plants open |
226,900 |
0 |
226,900 |
Close plant 3 |
210,500 |
30,000 |
240,500 |
Close plant 5 |
210,900 |
30,000 |
240,900 |
Closing plant 3 saves 16,400 against the status quo, so a 30,000 closure cost does not pay for itself in a single period: the recommendation becomes keep every plant open. Below 16,400 the original answer stands. That breakeven is the useful output here, and it only exists because the change went through the specification rather than straight into the code.
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(i \in P = \{1, \dots, 5\}\): plants.
\(j \in W = \{1, \dots, 4\}\): warehouses.
Parameters
\(d_j\): demand at warehouse \(j\), in thousands of products.
\(s_i\): capacity of plant \(i\), in thousands of products.
\(f_i\): fixed cost of keeping plant \(i\) open.
\(c_{ij}\): transportation cost per 1000 products from plant \(i\) to warehouse \(j\).
Decision variables
\(x_{ij} \geq 0\): thousands of products shipped from plant \(i\) to warehouse \(j\).
\(y_i \in \{0, 1\}\): 1 if plant \(i\) stays open, 0 if it is closed.
Objective function
Minimize transportation cost plus the fixed cost of the plants kept open:
Constraints
C1 — Demand is met exactly.
C2 — Plants ship within capacity, and only when open.
Complete model
Additional notes
Constraint C2 is the standard big-M linking constraint, with the plant’s own capacity \(s_i\) serving as the tightest valid \(M\). It does double duty: it caps an open plant at its capacity and forces \(x_{ij} = 0\) for every \(j\) when \(y_i = 0\).
A plant becoming unavailable is modeled by setting \(s_i = 0\) rather than by fixing \(y_i\), which keeps scenarios T3 and T4 pure data changes.
The model is feasible only when \(\sum_i s_i \geq \sum_j d_j\). Scenario T5 deliberately violates this.
Note that this is the formulation of the original model, before the closure cost added in the Changing the model tab. Gurobi’s own published facility example ships reference Python rather than a written formulation, and the equality demand constraint and capacity-linking constraint in that code match the two above.
### Sets and indices
- $i \in P = \{1, \dots, 5\}$: plants.
- $j \in W = \{1, \dots, 4\}$: warehouses.
### Parameters
- $d_j$: demand at warehouse $j$, in thousands of products.
- $s_i$: capacity of plant $i$, in thousands of products.
- $f_i$: fixed cost of keeping plant $i$ open.
- $c_{ij}$: transportation cost per 1000 products from plant $i$ to warehouse $j$.
### Decision variables
- $x_{ij} \geq 0$: thousands of products shipped from plant $i$ to warehouse $j$.
- $y_i \in \{0, 1\}$: 1 if plant $i$ stays open, 0 if it is closed.
### Objective function
Minimize transportation cost plus the fixed cost of the plants kept open:
$$
\min \; \sum_{i \in P} \sum_{j \in W} c_{ij} \, x_{ij} \; + \; \sum_{i \in P} f_i \, y_i
$$
### Constraints
**C1 — Demand is met exactly.**
$$
\sum_{i \in P} x_{ij} = d_j \qquad \forall j \in W
$$
**C2 — Plants ship within capacity, and only when open.**
$$
\sum_{j \in W} x_{ij} \leq s_i \, y_i \qquad \forall i \in P
$$
### Complete model
$$
\begin{aligned}
\min \quad & \sum_{i \in P} \sum_{j \in W} c_{ij} \, x_{ij} + \sum_{i \in P} f_i \, y_i \\
\text{s.t.} \quad & \sum_{i \in P} x_{ij} = d_j & \forall j \in W \\
& \sum_{j \in W} x_{ij} \leq s_i \, y_i & \forall i \in P \\
& x_{ij} \geq 0 & \forall i \in P, \, j \in W \\
& y_i \in \{0, 1\} & \forall i \in P
\end{aligned}
$$
### Additional notes
- Constraint C2 is the standard big-M linking constraint, with the plant's own
capacity $s_i$ serving as the tightest valid $M$. It does double duty: it
caps an open plant at its capacity and forces $x_{ij} = 0$ for every $j$
when $y_i = 0$.
- A plant becoming unavailable is modeled by setting $s_i = 0$ rather than by
fixing $y_i$, which keeps scenarios T3 and T4 pure data changes.
- The model is feasible only when $\sum_i s_i \geq \sum_j d_j$. Scenario T5
deliberately violates this.
Note that this is the formulation of the original model, before the closure
cost added in the **Changing the model** tab. Gurobi's own [published facility
example](https://docs.gurobi.com/projects/examples/en/current/examples/python/facility.html)
ships reference Python rather than a written formulation, and the equality
demand constraint and capacity-linking constraint in that code match the two
above.