Warehouse block-layout design
Aim
Use mathematical models to design warehouse block layouts. Design experience informs the assumptions. Optimization makes the trade-offs and constraints explicit.
Learning objectives
After completing this tutorial, you will be able to:
- formulate a block-layout model with department-area requirements and fixed interfaces;
- design and compare I-shaped, L-shaped, and U-shaped layouts; and
- interpret the results and check the conditions needed to implement a design.
Coolblue distribution center
Coolblue intends to deploy a new unit-load (white goods) distribution center in the Netherlands to serve the Benelux. Based on historical and projected demand, and on operational requirements (e.g., space, equipment, staffing, technology, product characteristics), the departments, their areas, and their adjacency relationships are defined for the case.
Coolblue is evaluating three warehouse layout patterns:
- I-shaped: Long rectangular block with inbound and outbound docks at opposite ends.
- L-shaped: Two orthogonal wings with inbound and outbound docks at the ends of each wing.
- U-shaped: Inbound and outbound docks located on the same side, creating a defined flow path.
Each layout must accommodate the departments with their target areas and respect the fixed locations for inbound and outbound docks. The objective minimizes the closeness weighted Manhattan distance between department centers.
Department areas and adjacency
We solve the block-layout problem for a reduced set of 6 departments using the exact area formulation. The reduced instance keeps the quadratic model computationally manageable. Table 19.1 gives the area requirements. The department identifiers also appear in the layout figures.
The two Outbound Staging departments relate similarly to all other departments. Both have the same ratings with respect to the other departments in the reduced instance. We can simply choose one of them to represent the combined Outbound Staging department in the adjacency matrix. Their target areas are added together.
| ID | Department | Target area (m²) |
|---|---|---|
| D1 | Inbound Dock | 2,640 |
| D2 | Outbound Staging | 8,800 |
| D3 | Packing / Wrap / Banding | 3,520 |
| D4 | Pallet Reserve Storage (Bulk) | 46,340 |
| D5 | Receiving/Staging | 5,280 |
| D6 | Shipping Dock | 3,520 |
The departments carry out the following activities.
- Inbound Dock (D1)
- Trailer apron and doors for receipts; check-in, seal verification, unload to receiving lanes.
- Outbound Staging (D2)
- Lane accumulation by parcel carrier/route. Lane accumulation for bulky deliveries and van loading.
- Packing / Wrap / Banding (D3)
- Strapping, corner boards, stretch-wrap; labeling and ship docs.
- Pallet Reserve Storage (Bulk) (D4)
- Primary pallet storage for appliances; high cube, stable replenishment.
- Receiving/Staging (D5)
- Counting, labeling, exception handling; short buffer before QA/putaway.
- Shipping Dock (D6)
- Parcel and linehaul doors; marshal, load sequence, seal.
Adjacency ratings describe how important it is for two departments to be close. Table 19.2 gives the numerical weights used in this case.
| Code | Meaning | Weight |
|---|---|---|
| E | Especially important to be adjacent | 4 |
| A | Absolutely necessary to be near | 3 |
| I | Important to be near | 2 |
| O | Ordinary closeness acceptable | 1 |
| U | Unimportant | 0 |
| X | Undesirable; keep apart | -4 |
The supplied weights express the case’s adjacency preferences. The weights are specific to this example and have not been measured as unit-load flows. When applying the model to another warehouse, justify the numerical conversion of its adjacency ratings. All relationships in the reduced instance are nonnegative. The distance formulation below requires nonnegative objective weights. Use an explicit separation constraint for a relationship that requires departments to stay apart.
Table 19.3 gives the weights for the six departments. For example, inbound and receiving have weight 4, while receiving and reserve storage have weight 2. A zero means that proximity does not contribute to the objective. The diagonal is zero because a department’s distance to itself is zero.
| ID | D1 | D2 | D3 | D4 | D5 | D6 |
|---|---|---|---|---|---|---|
| D1 | 0 | 0 | 0 | 0 | 4 | 0 |
| D2 | 0 | 0 | 4 | 0 | 0 | 4 |
| D3 | 0 | 4 | 0 | 4 | 0 | 0 |
| D4 | 0 | 0 | 4 | 0 | 2 | 0 |
| D5 | 4 | 0 | 0 | 2 | 0 | 0 |
| D6 | 0 | 4 | 0 | 0 | 0 | 0 |
The code reads the area targets, adjacency ratings, and numerical weights from CSV files in instances/data/coolblue/. It combines the two outbound staging areas and selects the six departments used here. The complete source and CSV contents are shown under Run the Python examples at the end of this tutorial.
Model formulation
Each department \(i\) is a rectangle with west/east coordinates \(x_i^-,x_i^+\), south/north coordinates \(y_i^-,y_i^+\), width \(b_i\), height \(h_i\), and centroid \((c_i^x,c_i^y)\). For a building envelope \(B_x\times B_y\):
\[ \begin{aligned} b_i&=x_i^+-x_i^-, & h_i&=y_i^+-y_i^-,\\ c_i^x&=(x_i^-+x_i^+)/2, & c_i^y&=(y_i^-+y_i^+)/2,\\ 0&\le x_i^-\le x_i^+\le B_x, & 0&\le y_i^-\le y_i^+\le B_y,\\ b_i h_i&=A_i. \end{aligned} \tag{19.1}\]
The exact-area equality is bilinear, giving a mixed-integer quadratically constrained program (MIQCP). For every pair, binary indicators enforce at least one separation relation: \(i\) is west, east, north, or south of \(j\). Anchors fix selected department edges to building walls. A design skeleton adds directional relations appropriate to each pattern.
For each unordered department pair, nonnegative variables \(d_{ij}^x,d_{ij}^y\) satisfy:
\[ d_{ij}^x\ge c_i^x-c_j^x,\quad d_{ij}^x\ge c_j^x-c_i^x,\qquad d_{ij}^y\ge c_i^y-c_j^y,\quad d_{ij}^y\ge c_j^y-c_i^y. \]
Minimize the closeness weighted Manhattan distance between department centers:
\[ \min\sum_{i<j}W_{ij}(d_{ij}^x+d_{ij}^y). \tag{19.2}\]
Positive weights make the distance variables equal the corresponding absolute centroid differences at an optimum. Zero-weight pairs do not affect the objective. Recompute their actual distances from the coordinates when evaluating a layout. With these qualitative weights, the objective is a closeness score in weighted meters. It does not directly predict picker travel, cost, congestion, or throughput.
Design skeletons
We assume a building slack of 1.0 and an aspect ratio of 1.5. The unrounded building area equals the sum of department areas, and the building is 1.5 times wider than it is deep. Rounding the side lengths upward gives a 325 m × 217 m envelope for I and U.
Each skeleton specifies the relative positions of departments based on anchor points. Table 19.4 gives the anchors and placement rules.
| Pattern | Anchors and skeleton |
|---|---|
| I | Inbound on the west wall, shipping on the east wall; remaining departments between them |
| U | Inbound and shipping on the south wall; remaining departments north of both docks |
| L | Inbound on the south wall, shipping on the east wall; a southeast corner is excluded, with other departments north of inbound and west of shipping |
For the U-shaped layout, we anchor both the Inbound Dock and Shipping Dock to the south wall. All other departments are placed north of these anchors.
For the I-shaped layout, we anchor the Inbound Dock to the left wall and the Shipping Dock to the right wall. All other departments are placed between these two anchors.
To create the L, we create a rectangle called L-Corner anchored to the southeast corner of the building. All other departments are placed north of the Inbound Dock and west of the Shipping Dock. The L-case introduces a zero-weight L-Corner rectangle of 17,631.25 m², calculated as 25% of the original I/U envelope. Including the excluded corner increases the outer envelope to 363 m × 242 m. The excluded corner is therefore about 20% of the enlarged envelope. It is a structural void, not a warehouse department or usable storage area.
Model solution
Once we have defined the design skeletons for the U-shaped, I-shaped, and L-shaped layouts, we can build and solve the optimization model for each layout pattern. The example calls build_block_layout_model with area_calculation="exact". It uses 1 thread, seed 2026, a 60-second time limit per layout, and relative gap tolerance 0.0001. After solving, check actual rectangle areas, building bounds, nonoverlap, anchors, and skeleton relations. Recompute the objective from the centroids.
Show code
from whlayout.model import build_block_layout_model
config = configurations[0] # I-shaped configuration
bx, by = config["building"]
block_model, variables = build_block_layout_model(
dept_list=list(config["areas"]), areas_m2=config["areas"],
weights=config["weights"], building_x=bx, building_y=by,
anchors=config["anchors"], design_skeleton=config["skeleton"],
area_calculation="exact", mip_gap=0.0001, time_limit=60,
log_to_console=False, write_lp=None,
)You can also explore the linear shape-surrogate mode, area_calculation="envelope". It does not enforce \(b_i h_i=A_i\), so check each rectangle’s actual area before claiming that capacity requirements are met. The results here use exact-area constraints.
Results
Figure 19.1, Figure 19.2, and Figure 19.3 show the solved configurations. The blocks are labeled with department identifiers. Each figure’s key gives the names, dimensions, and target areas.
Table 19.5 compares their closeness scores and footprints.
| Shape | Closeness score (weighted m) | Department area (m²) | Envelope (m²) | Void (m²) | Relative gap |
|---|---|---|---|---|---|
| I | 1,001.43 | 70,100.00 | 70,525 | 0.00 | 0.000000% |
| L | 1,384.96 | 70,100.00 | 87,846 | 17,631.25 | 0.000000% |
| U | 1,333.33 | 70,100.00 | 70,525 | 0.00 | 0.000793% |
I has the lowest computed closeness score, followed by U and L, for these inputs and placement rules. Every case accommodates the same 70,100 m² of productive department area. The L-case has a different envelope and a structural void. Both changes affect the score comparison, alongside dock orientation. Keep the footprint and usable area alongside the score when interpreting it.
| Shape | Status | Runtime (s) | Bound | Relative gap | Max. area error (%) |
|---|---|---|---|---|---|
| I | 2 | 0.01 | 1,001.43 | 0.000000% | 0.00 |
| L | 2 | 0.17 | 1,384.96 | 0.000000% | 0.00 |
| U | 2 | 0.14 | 1,333.32 | 0.000793% | 0.00 |
The example requires zero clearance between blocks and leaves little spare area. Before implementing an arrangement, check aisle widths, equipment access, replenishment routes, staging peaks, emergency access, and congestion. The model does not certify those conditions.
Optional extension
Repeat the analysis with all 14 departments in the supplied case. Table 19.7 gives their target areas. Pallet reserve storage occupies 46,340 m², more than half of the listed department area.
| Department | Target area (m²) |
|---|---|
| Inbound Dock | 2,640 |
| Receiving/Staging | 5,280 |
| QA & Technical Test | 1,760 |
| Cross-Dock | 3,520 |
| Pallet Reserve Storage (Bulk) | 46,340 |
| Oversize/Non-Standard Storage | 2,640 |
| Packing / Wrap / Banding | 3,520 |
| Outbound Staging - Parcel | 3,520 |
| Outbound Staging - 2-Man Delivery | 5,280 |
| Shipping Dock | 3,520 |
| Empty Pallets & Dunnage | 880 |
| Maintenance & Battery Charge | 1,320 |
| Returns & WEEE | 2,640 |
| Spare Parts & Accessories Cage | 440 |
Report runtime, incumbent objective, bound, gap, and actual area errors if you compare exact and shape-surrogate formulations. Keep input areas, relationships, solver budgets, and footprint assumptions explicit. The 14-department case is optional practice.
Applying the model
Area sizing determines how much space each activity receives. Block layout determines each activity’s position and its distance to other activities. Use the area requirements for one consistent warehouse scope, with space for the supporting activities and access routes.
Fixed interfaces, replenishment routes, and material-handling equipment constrain the feasible arrangements. Compare the current and proposed layouts with the same area requirements, workload, units, and evaluation assumptions. When weights describe qualitative adjacency preferences, report the result as a closeness score. Measured travel or handling costs require corresponding flow and cost data.
Save the numerical outputs and complete model settings with the code.
Use Python 3.11 or later. Save the source and input files below using the paths shown above each code block. Run the snippets in order from the directory containing scripts/ and instances/. The models use whlayout with Gurobi. You need a Gurobi license suitable for the model size; use your academic setup where applicable.
The script solves the three layouts and writes their coordinates, figures, scores, and solver diagnostics to tutorial-results/. Check each layout’s area, bounds, nonoverlap, fixed interfaces, and objective using those outputs.
Show complete Python source
scripts/layout_tutorial.py
"""Reproduce the worked storage allocation and warehouse layout examples.
Install pandas, matplotlib, tabulate, pulp, gurobipy and the pinned whlayout
package listed in ../tutorial-requirements.txt. --verify needs only pandas.
"""
from __future__ import annotations
import argparse
import hashlib
import itertools
import json
import math
import time
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
AREA_NAMES = ("CD", "R", "F")
FLOW_NAMES = {1: "CD", 2: "R", 3: "RF", 4: "F"}
WHLAYOUT_REVISION = "c2e789ce76db9aafae385f6ab2e5ce78b652cc6d"
def source_digest(path):
"""Hash text with LF newlines so Windows and Linux checkouts agree."""
return hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest()
def load_allocation_inputs():
"""Read the six-product historical case, with stable product/flow IDs."""
source = ROOT / "tutorial-inputs"
products = pd.read_csv(source / "products.csv", index_col="Product")
handling = pd.read_csv(source / "handling.csv", index_col="Product")
storage = pd.read_csv(source / "storage.csv", index_col="Product")
bounds = pd.read_csv(source / "area-bounds.csv", index_col="Area")
assert products.index.equals(handling.index)
assert products.index.equals(storage.index)
products["EOQ"] = (
2 * products.Demand * products.OrderCost
/ (products.Price * products.CarryingRate)
) ** 0.5
products["AverageInventory"] = products.EOQ / 2
products["DwellYears"] = products.AverageInventory / products.Demand
return products, handling, storage, bounds
def allocation_coefficients(products, handling, storage, rf_operations=2):
"""Historical q=1, one level, average-inventory allocation model."""
costs, occupancy = {}, {}
for i, row in products.iterrows():
for f in FLOW_NAMES:
operations = rf_operations if f == 3 else 2
costs[i, f] = (
operations * row.Demand * handling.loc[i, str(f)]
+ row.AverageInventory * storage.loc[i, str(f)]
)
base = row.AverageInventory * row.SpacePerLoad
occupancy[i, f] = {
"CD": base if f == 1 else 0.0,
"R": base * (1 if f == 2 else row.ReserveShare
if f == 3 else 0),
"F": base * (1 if f == 4 else 1 - row.ReserveShare
if f == 3 else 0),
}
return costs, occupancy
def validate_allocation(assignments, areas, bounds, total=100000.0):
"""Verify independent capacity and accounting conditions."""
assert len(assignments) == 6
assert assignments.Product.nunique() == 6
assert assignments.FlowID.isin(FLOW_NAMES).all()
assert abs(areas.Allocated.sum() - total) < 1e-5
for row in areas.itertuples():
assert bounds.loc[row.Area, "Lower"] - 1e-5 <= row.Allocated
assert row.Allocated <= bounds.loc[row.Area, "Upper"] + 1e-5
assert row.Occupied <= row.Allocated + 1e-5
assert row.Occupied >= 0
def solve_allocation(output, rf_operations=2):
import pulp as pl
products, handling, storage, bounds = load_allocation_inputs()
costs, occupancy = allocation_coefficients(
products, handling, storage, rf_operations
)
model = pl.LpProblem("HistoricalAllocationSizing", pl.LpMinimize)
x = pl.LpVariable.dicts("x", costs, cat=pl.LpBinary)
area = {
a: pl.LpVariable(f"area_{a}", bounds.loc[a, "Lower"],
bounds.loc[a, "Upper"])
for a in AREA_NAMES
}
model += pl.lpSum(costs[key] * x[key] for key in costs)
for i in products.index:
model += pl.lpSum(x[i, f] for f in FLOW_NAMES) == 1
used = {
a: pl.lpSum(occupancy[key][a] * x[key] for key in costs)
for a in AREA_NAMES
}
for a in AREA_NAMES:
model += used[a] <= area[a]
model += pl.lpSum(area.values()) == 100000.0
started = time.perf_counter()
model.solve(pl.PULP_CBC_CMD(msg=False, threads=1, timeLimit=30))
status = pl.LpStatus[model.status]
metadata = {
"status": status, "runtime_seconds": time.perf_counter() - started,
"solver": "PuLP/CBC", "rf_handling_operations": rf_operations,
"total_area_m2": 100000.0, "break_bulk_factor": 1,
"vertical_levels": 1, "inventory_basis": "EOQ/2 average inventory",
}
(output / "allocation-run.json").write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
if status != "Optimal":
raise RuntimeError(f"Allocation solve returned {status}")
rows = []
for i in products.index:
f = next(f for f in FLOW_NAMES if pl.value(x[i, f]) > 0.5)
operations = rf_operations if f == 3 else 2
rows.append({
"Product": i, "FlowID": f, "Flow": FLOW_NAMES[f],
"HandlingCost": operations * products.loc[i, "Demand"]
* handling.loc[i, str(f)],
"StorageCost": products.loc[i, "AverageInventory"]
* storage.loc[i, str(f)],
})
assignments = pd.DataFrame(rows)
assignments["TotalCost"] = (
assignments.HandlingCost + assignments.StorageCost
)
areas = pd.DataFrame([
{"Area": a, "Occupied": pl.value(used[a]),
"Allocated": pl.value(area[a]),
"UtilizationPct": 100 * pl.value(used[a]) / pl.value(area[a])
if pl.value(area[a]) > 0 else 0.0}
for a in AREA_NAMES
])
validate_allocation(assignments, areas, bounds)
assert math.isclose(
assignments.TotalCost.sum(), pl.value(model.objective), abs_tol=1e-5
)
# Independent enumeration of the 4^6 assignments validates this tiny MILP.
valid_costs = []
for flows in itertools.product(FLOW_NAMES, repeat=len(products)):
allocation = dict(zip(products.index, flows))
occupied = {
a: sum(occupancy[i, f][a] for i, f in allocation.items())
for a in AREA_NAMES
}
if all(occupied[a] <= bounds.loc[a, "Upper"] for a in AREA_NAMES):
required = sum(max(occupied[a], bounds.loc[a, "Lower"])
for a in AREA_NAMES)
if required <= 100000 <= bounds.Upper.sum():
valid_costs.append(sum(costs[i, f]
for i, f in allocation.items()))
assert math.isclose(min(valid_costs), pl.value(model.objective),
abs_tol=1e-5)
products.to_csv(output / "inventory.csv")
assignments.to_csv(output / "assignments.csv", index=False)
areas.to_csv(output / "areas.csv", index=False)
metadata.update(objective=pl.value(model.objective),
enumeration_check=True, checks_passed=True)
(output / "allocation-run.json").write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
def load_layout_inputs():
"""Use the original case legend and its identical staging relationships."""
source = ROOT / "instances/data/coolblue"
raw = pd.read_csv(source / "adjacency_matrix.csv", index_col=0)
legend = pd.read_csv(source / "muther_legend.csv", index_col=0)
areas = pd.read_csv(source / "space_program_abs.csv", index_col=0).Sq_m
staging = ["Outbound Staging - 2-Man Delivery",
"Outbound Staging - Parcel"]
selected = ["Inbound Dock", "Receiving/Staging",
"Pallet Reserve Storage (Bulk)", "Packing / Wrap / Banding",
"Shipping Dock"]
assert raw.loc[staging[0], selected].equals(
raw.loc[staging[1], selected]
), "The historical aggregation requires identical staging relationships."
weights = raw.replace("-", "U").apply(
lambda column: column.map(legend.Weight.to_dict())
).astype(float)
weights = weights.loc[selected + [staging[0]], selected + [staging[0]]]
weights = weights.rename(index={staging[0]: "Outbound Staging"},
columns={staging[0]: "Outbound Staging"})
areas = areas.loc[selected].to_dict() | {
"Outbound Staging": float(areas.loc[staging].sum())
}
assert (weights.values >= 0).all()
assert (weights.values == weights.values.T).all()
for d in weights.index:
weights.loc[d, d] = 0
return areas, weights
def layout_configurations(areas, weights):
from whlayout.layoututils import propose_building_sides
bx, by = propose_building_sides(areas, slack=1.0, aspect_ratio=1.5)
configurations = []
for shape in ("I", "L", "U"):
current_areas = dict(areas)
current_weights = weights.copy()
if shape == "L":
current_areas["L-Corner"] = 0.25 * bx * by
current_weights["L-Corner"] = 0.0
current_weights.loc["L-Corner"] = 0.0
size = propose_building_sides(
current_areas, slack=1.0, aspect_ratio=1.5
)
anchors = {
"Inbound Dock": [{"side": "south", "offset": 0}],
"Shipping Dock": [{"side": "east", "offset": 0}],
"L-Corner": [{"side": "east", "offset": 0},
{"side": "south", "offset": 0}],
}
elif shape == "I":
size = (bx, by)
anchors = {
"Inbound Dock": [{"side": "west", "offset": 0}],
"Shipping Dock": [{"side": "east", "offset": 0}],
}
else:
size = (bx, by)
anchors = {
"Inbound Dock": [{"side": "south", "offset": 0}],
"Shipping Dock": [{"side": "south", "offset": 0}],
}
skeleton = []
for d in areas:
if d in anchors:
continue
if shape == "I":
skeleton.extend([(d, "east", "Inbound Dock"),
(d, "west", "Shipping Dock")])
elif shape == "L":
skeleton.extend([(d, "north", "Inbound Dock"),
(d, "west", "Shipping Dock")])
else:
skeleton.extend([(d, "north", "Inbound Dock"),
(d, "north", "Shipping Dock")])
configurations.append({
"shape": shape, "areas": current_areas,
"weights": current_weights, "building": size,
"anchors": anchors, "skeleton": skeleton,
})
return configurations
def validate_geometry(table, bx, by, anchors, skeleton, tol=1e-3):
"""Validate exported geometry independently of solver helper variables."""
assert table.index.is_unique
assert table[["width", "height"]].gt(0).all().all()
assert (table.x_west >= -tol).all()
assert (table.y_south >= -tol).all()
assert (table.x_east <= bx + tol).all()
assert (table.y_north <= by + tol).all()
assert ((table.width - (table.x_east - table.x_west)).abs() <= tol).all()
assert ((table.height - (table.y_north - table.y_south)).abs() <= tol).all()
assert ((table.center_x - (table.x_east + table.x_west) / 2)
.abs() <= tol).all()
assert ((table.center_y - (table.y_north + table.y_south) / 2)
.abs() <= tol).all()
area = table.width * table.height
assert ((area - table.area_target).abs()
<= tol + 1e-6 * table.area_target).all()
for a, b in itertools.combinations(table.index, 2):
p, q = table.loc[a], table.loc[b]
assert (p.x_east <= q.x_west + tol
or q.x_east <= p.x_west + tol
or p.y_north <= q.y_south + tol
or q.y_north <= p.y_south + tol)
for d, specifications in anchors.items():
for specification in specifications:
side = specification["side"]
coordinate, target = {
"west": ("x_west", 0), "east": ("x_east", bx),
"south": ("y_south", 0), "north": ("y_north", by),
}[side]
assert abs(table.loc[d, coordinate] - target) <= tol
for a, direction, b in skeleton:
p, q = table.loc[a], table.loc[b]
assert {
"east": p.x_west >= q.x_east - tol,
"west": p.x_east <= q.x_west + tol,
"north": p.y_south >= q.y_north - tol,
"south": p.y_north <= q.y_south + tol,
}[direction]
def centroid_score(table, weights):
return sum(
weights.loc[a, b] * (
abs(table.loc[a, "center_x"] - table.loc[b, "center_x"])
+ abs(table.loc[a, "center_y"] - table.loc[b, "center_y"])
) for a, b in itertools.combinations(table.index, 2)
)
def draw_layout(table, bx, by, shape, output):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import textwrap
fig, ax = plt.subplots(figsize=(10, 8), layout="constrained")
colors = plt.get_cmap("Pastel1")
labels = []
for index, (department, row) in enumerate(table.iterrows()):
corner = department == "L-Corner"
ax.add_patch(Rectangle(
(row.x_west, row.y_south), row.width, row.height,
facecolor="#d9dde3" if corner else colors(index % 9),
edgecolor="#24344b", linewidth=1.2,
hatch="//" if corner else None,
))
label = "Void" if corner else f"D{index + 1}"
ax.text(row.center_x, row.center_y, label, ha="center",
va="center", fontsize=12, weight="bold")
labels.append(f"{label}: {department} ({row.width:.1f} × "
f"{row.height:.1f} m; {row.area_target:,.0f} m²)")
ax.set(xlim=(0, bx), ylim=(0, by), xlabel="x (m)", ylabel="y (m)",
title=f"{shape}-shaped flow pattern: Coolblue case")
ax.set_aspect("equal")
caption = "\n".join(textwrap.fill(line, 90) for line in labels)
fig.text(0.08, -0.01, caption, fontsize=10, va="top")
fig.savefig(output / f"layout-{shape.lower()}.svg", bbox_inches="tight")
plt.close(fig)
def solve_layouts(output, time_limit, seed):
# Reuse the historical package, including its exact-area formulation.
from whlayout.model import build_block_layout_model
from whlayout.utils import extract_solution_table
areas, weights = load_layout_inputs()
pd.Series(areas, name="Area_m2").rename_axis("Department").to_csv(
output / "departments.csv"
)
weights.to_csv(output / "weights.csv")
runs = []
for config in layout_configurations(areas, weights):
shape = config["shape"]
bx, by = config["building"]
model, variables = build_block_layout_model(
dept_list=list(config["areas"]), areas_m2=config["areas"],
weights=config["weights"], building_x=bx, building_y=by,
anchors=config["anchors"], design_skeleton=config["skeleton"],
area_calculation="exact", mip_gap=0.0001,
time_limit=time_limit, log_to_console=False,
write_lp=str(output / f"layout-{shape.lower()}.lp"),
)
model.Params.Threads = 1
model.Params.Seed = seed
model.optimize()
run = {
"Shape": shape, "Status": int(model.Status),
"SolutionCount": int(model.SolCount), "Runtime": model.Runtime,
"BuildingX": bx, "BuildingY": by,
"GrossEnvelope": bx * by,
"ProductiveArea": sum(areas.values()),
"CornerVoid": config["areas"].get("L-Corner", 0),
"TimeLimit": time_limit, "Seed": seed, "Threads": 1,
"RequestedGap": 0.0001, "AreaMethod": "exact",
"WhlayoutRevision": WHLAYOUT_REVISION,
"Anchors": config["anchors"], "Skeleton": config["skeleton"],
}
runs.append(run)
(output / "layout-runs.json").write_text(
json.dumps(runs, indent=2), encoding="utf-8"
)
if model.SolCount == 0:
model.dispose()
raise RuntimeError(f"{shape}: no feasible incumbent; run retained")
table = extract_solution_table(
list(config["areas"]), bx, by, variables,
areas_m2=config["areas"],
)
validate_geometry(table, bx, by, config["anchors"], config["skeleton"])
score = centroid_score(table, config["weights"])
assert math.isclose(score, model.ObjVal, rel_tol=1e-6, abs_tol=1e-3)
run.update(Objective=score, Bound=model.ObjBound, Gap=model.MIPGap,
MaxAreaErrorPct=table.area_gap_pct.abs().max(),
ChecksPassed=True)
table.to_csv(output / f"layout-{shape.lower()}.csv")
draw_layout(table, bx, by, shape, output)
(output / "layout-runs.json").write_text(
json.dumps(runs, indent=2), encoding="utf-8"
)
model.dispose()
pd.DataFrame(runs).to_csv(output / "layout-comparison.csv", index=False)
def verify_saved(output, require_layouts=True):
products, handling, storage, bounds = load_allocation_inputs()
assignments = pd.read_csv(output / "assignments.csv")
areas = pd.read_csv(output / "areas.csv")
run = json.loads((output / "allocation-run.json").read_text())
costs, occupancy = allocation_coefficients(
products, handling, storage, run["rf_handling_operations"]
)
validate_allocation(assignments, areas, bounds)
assert run["status"] == "Optimal" and run["checks_passed"]
selected = dict(zip(assignments.Product, assignments.FlowID))
for row in assignments.itertuples():
assert row.Flow == FLOW_NAMES[row.FlowID]
operations = run["rf_handling_operations"] if row.FlowID == 3 else 2
expected_handling = (operations * products.loc[row.Product, "Demand"]
* handling.loc[row.Product, str(row.FlowID)])
expected_storage = (products.loc[row.Product, "AverageInventory"]
* storage.loc[row.Product, str(row.FlowID)])
assert math.isclose(row.HandlingCost, expected_handling)
assert math.isclose(row.StorageCost, expected_storage)
assert math.isclose(row.TotalCost, expected_handling + expected_storage)
assert math.isclose(sum(costs[key] for key in selected.items()),
run["objective"], abs_tol=1e-5)
for row in areas.itertuples():
assert math.isclose(
sum(occupancy[key][row.Area] for key in selected.items()),
row.Occupied, abs_tol=1e-5
)
expected_utilization = (100 * row.Occupied / row.Allocated
if row.Allocated else 0)
assert math.isclose(row.UtilizationPct, expected_utilization)
inventory = pd.read_csv(output / "inventory.csv", index_col="Product")
for column in ("EOQ", "AverageInventory", "DwellYears"):
assert (abs(inventory[column] - products[column]) < 1e-8).all()
layout_file = output / "layout-runs.json"
if require_layouts:
assert layout_file.exists(), "The published tutorial needs all layouts."
if layout_file.exists():
runs = json.loads(layout_file.read_text())
assert {run["Shape"] for run in runs} == {"I", "L", "U"}
areas, weights = load_layout_inputs()
comparison = pd.read_csv(output / "layout-comparison.csv")
for run in runs:
shape = run["Shape"]
current_weights = weights.copy()
current_areas = dict(areas)
if shape == "L":
current_weights["L-Corner"] = 0.0
current_weights.loc["L-Corner"] = 0.0
current_areas["L-Corner"] = run["CornerVoid"]
table = pd.read_csv(output / f"layout-{shape.lower()}.csv",
index_col="Department")
assert set(table.index) == set(current_areas)
for d, target in current_areas.items():
assert math.isclose(table.loc[d, "area_target"], target)
validate_geometry(table, run["BuildingX"], run["BuildingY"],
run["Anchors"], run["Skeleton"])
assert math.isclose(centroid_score(table, current_weights),
run["Objective"], abs_tol=1e-3)
reported = comparison.loc[comparison.Shape == shape].iloc[0]
assert math.isclose(reported.Objective, run["Objective"])
assert math.isclose(reported.ProductiveArea, sum(areas.values()))
assert run["ChecksPassed"] and run["SolutionCount"] > 0
hashes_file = output / "source-hashes.json"
if hashes_file.exists():
for name, digest in json.loads(hashes_file.read_text()).items():
assert source_digest(ROOT / name) == digest
print("Saved allocation and layout evidence passed independent checks.")
def write_tables(output):
"""Build the publication tables from the saved evidence, without a solve."""
directory = output / "_tables"
directory.mkdir(exist_ok=True)
def save(name, table, caption):
table = table.copy()
for column in table.columns:
if pd.api.types.is_integer_dtype(table[column]):
table[column] = table[column].map(lambda v: f"{v:,d}")
elif pd.api.types.is_float_dtype(table[column]):
precision = 4 if name == "handling" else 6 if (
column == "Dwell time (years)"
) else 2
table[column] = table[column].map(
lambda v: f"{v:,.{precision}f}"
)
text = table.to_markdown(index=False, disable_numparse=True)
text = text.replace("$", "\\$")
(directory / f"{name}.md").write_text(
text + f"\n\n: {caption} {{#tbl-tut-{name} .striped}}\n",
encoding="utf-8",
)
products, handling, storage, bounds = load_allocation_inputs()
save("products", products.reset_index()[[
"Product", "Demand", "OrderCost", "Price", "SpacePerLoad",
"ReserveShare"
]].rename(columns={
"Demand": "Loads/year", "OrderCost": "Order cost ($)",
"Price": "Price ($/load)", "SpacePerLoad": "m²/load",
"ReserveShare": "Reserve share in RF",
}), "Six-product example inputs. Carrying rate is 10% per year.")
for name, table in (("handling", handling), ("storage", storage)):
save(name, table.rename(columns={str(k): v
for k, v in FLOW_NAMES.items()})
.reset_index(), name.capitalize() + " costs " + (
"($/load/transaction)." if name == "handling"
else "($/average load/year)."
))
save("bounds", bounds.reset_index().rename(columns={
"Lower": "Minimum (m²)", "Upper": "Maximum (m²)"
}), "Functional-area bounds. Total allocated floor area is 100,000 m².")
save("inventory", products.reset_index()[[
"Product", "EOQ", "AverageInventory", "DwellYears"
]].rename(columns={"EOQ": "EOQ (loads)",
"AverageInventory": "Average inventory (loads)",
"DwellYears": "Dwell time (years)"}),
"Calculated order quantities, average inventory, and dwell times.")
assignments = pd.read_csv(output / "assignments.csv")
cost_table = assignments.drop(columns="FlowID")
cost_table.loc[len(cost_table)] = {
"Product": "Total", "Flow": "All",
**{column: assignments[column].sum() for column in
("HandlingCost", "StorageCost", "TotalCost")},
}
save("assignments", cost_table.rename(columns={
"HandlingCost": "Handling ($/year)",
"StorageCost": "Storage ($/year)", "TotalCost": "Total ($/year)"
}), "Optimal product flows and annual cost components for the "
"two-transaction convention.")
save("areas", pd.read_csv(output / "areas.csv").rename(columns={
"Occupied": "Occupied (m²)", "Allocated": "Allocated (m²)",
"UtilizationPct": "Utilization (%)"
}), "One cost-optimal area allocation and its average-inventory use.")
if (output / "departments.csv").exists():
save("departments", pd.read_csv(output / "departments.csv")
.rename(columns={"Area_m2": "Target area (m²)"}),
"Six departments in the reduced Coolblue case.")
runs = pd.read_csv(output / "layout-comparison.csv")
save("layouts", runs[[
"Shape", "Objective", "ProductiveArea", "GrossEnvelope",
"CornerVoid", "Gap"
]].assign(Gap=lambda t: t.Gap.map(lambda g: f"{100*g:.6f}%"))
.rename(columns={
"Objective": "Closeness score (weighted m)",
"ProductiveArea": "Department area (m²)",
"GrossEnvelope": "Envelope (m²)", "CornerVoid": "Void (m²)",
"Gap": "Relative gap"
}), "Computed layouts with the same department areas and weights. "
"The L-case has a different envelope and excluded corner.")
save("diagnostics", runs[[
"Shape", "Status", "Runtime", "Bound", "Gap",
"MaxAreaErrorPct"
]].assign(Gap=lambda t: t.Gap.map(lambda g: f"{100*g:.6f}%"))
.rename(columns={"Runtime": "Runtime (s)",
"Gap": "Relative gap",
"MaxAreaErrorPct": "Max. area error (%)"}),
"Solver diagnostics. Gurobi status 2 means optimal within the "
"requested relative tolerance of 0.0001.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--part", choices=["all", "allocation", "layouts"],
default="all")
parser.add_argument("--output", type=Path,
default=ROOT / "tutorial-results")
parser.add_argument("--time-limit", type=int, default=60)
parser.add_argument("--seed", type=int, default=2026)
parser.add_argument("--rf-operations", type=int, choices=[2, 3], default=2)
parser.add_argument("--verify", action="store_true")
args = parser.parse_args()
if args.verify:
verify_saved(args.output, require_layouts=args.part != "allocation")
return
args.output.mkdir(parents=True, exist_ok=True)
if args.part in {"all", "allocation"}:
solve_allocation(args.output, args.rf_operations)
if args.part in {"all", "layouts"}:
solve_layouts(args.output, args.time_limit, args.seed)
if (args.output / "assignments.csv").exists():
write_tables(args.output)
inputs = list((ROOT / "tutorial-inputs").glob("*.csv"))
inputs += list((ROOT / "instances/data/coolblue").glob("*.csv"))
inputs.append(Path(__file__).resolve())
(args.output / "source-hashes.json").write_text(json.dumps({
path.relative_to(ROOT).as_posix():
source_digest(path)
for path in inputs
}, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()Show department areas (CSV)
instances/data/coolblue/space_program_abs.csv
Department,Sq_m
Cross-Dock,3520
Empty Pallets & Dunnage,880
Inbound Dock,2640
Maintenance & Battery Charge,1320
Outbound Staging - 2-Man Delivery,5280
Outbound Staging - Parcel,3520
Oversize/Non-Standard Storage,2640
Packing / Wrap / Banding,3520
Pallet Reserve Storage (Bulk),46340
QA & Technical Test,1760
Receiving/Staging,5280
Returns & WEEE,2640
Shipping Dock,3520
Spare Parts & Accessories Cage,440Show adjacency ratings (CSV)
instances/data/coolblue/adjacency_matrix.csv
From/To,Inbound Dock,Receiving/Staging,QA & Technical Test,Cross-Dock,Pallet Reserve Storage (Bulk),Oversize/Non-Standard Storage,Packing / Wrap / Banding,Outbound Staging - Parcel,Outbound Staging - 2-Man Delivery,Shipping Dock,Empty Pallets & Dunnage,Maintenance & Battery Charge,Returns & WEEE,Spare Parts & Accessories Cage
Inbound Dock,-,E,U,U,U,U,U,U,U,U,U,U,U,U
Receiving/Staging,E,-,A,A,I,U,U,U,U,U,I,U,U,U
QA & Technical Test,U,A,-,U,U,U,U,U,U,U,U,U,I,U
Cross-Dock,U,A,U,-,U,U,U,A,A,A,U,U,U,U
Pallet Reserve Storage (Bulk),U,I,U,U,-,U,E,U,U,U,U,O,U,U
Oversize/Non-Standard Storage,U,U,U,U,U,-,I,U,U,U,U,U,U,U
Packing / Wrap / Banding,U,U,U,U,E,I,-,E,E,U,O,U,O,U
Outbound Staging - Parcel,U,U,U,A,U,U,E,-,U,E,U,U,U,U
Outbound Staging - 2-Man Delivery,U,U,U,A,U,U,E,U,-,E,U,U,U,U
Shipping Dock,U,U,U,A,U,U,U,E,E,-,U,U,U,U
Empty Pallets & Dunnage,U,I,U,U,U,U,O,U,U,U,-,U,U,U
Maintenance & Battery Charge,U,U,U,U,O,U,U,U,U,U,U,-,U,U
Returns & WEEE,U,U,I,U,U,U,O,U,U,U,U,U,-,U
Spare Parts & Accessories Cage,U,U,U,U,U,U,U,U,U,U,U,U,U,-Show department descriptions (CSV)
instances/data/coolblue/area_descriptions.csv
Department,Description
Inbound Dock,"Trailer apron and doors for receipts; check-in, seal verification, unload to receiving lanes."
Receiving/Staging,"Counting, labeling, exception handling; short buffer before QA/putaway."
QA & Technical Test,"Visual/functional checks for white goods (power-on, cosmetic damage, accessories)."
Cross-Dock,Flow-through for urgent/vendor-prepped units; direct transfer to outbound lanes.
Pallet Reserve Storage (Bulk),"Primary pallet storage for appliances; high cube, stable replenishment."
Oversize/Non-Standard Storage,Storage for odd-dimension or high-CG units with special handling.
Packing / Wrap / Banding,"Strapping, corner boards, stretch-wrap; labeling and ship docs."
Outbound Staging - Parcel,Lane accumulation by parcel carrier/route.
Outbound Staging - 2-Man Delivery,Lane accumulation for bulky deliveries and van loading.
Shipping Dock,"Parcel and linehaul doors; marshal, load sequence, seal."
Empty Pallets & Dunnage,"Pallet stacks, corner boards, wrap, foam; near receiving and pack."
Maintenance & Battery Charge,MHE charging/maintenance with ventilation and clear aisles.
Returns & WEEE,Bulky returns triage; compliant WEEE handling for refurb or recycling.
Spare Parts & Accessories Cage,Small parts and accessories storage to support VAS and repairs.