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.

Table 19.1: Target areas for the six departments. The identifiers match the layout figures.
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.

Table 19.2: Numerical adjacency weights for the Coolblue 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.

Table 19.3: Numerical adjacency weights for the six departments. The matrix is symmetric.
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.

Listing 19.1: Combine the staging areas and load the department requirements.
Show code
from scripts.layout_tutorial import load_layout_inputs

areas, weights = load_layout_inputs()
assert len(areas) == 6
assert sum(areas.values()) == 70100
assert (weights.values >= 0).all()

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.

Table 19.4: Anchors and relative placement rules for each configuration.
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.

Listing 19.2: Define the anchors, skeletons, and envelopes for each pattern.
Show code
from scripts.layout_tutorial import layout_configurations

configurations = layout_configurations(areas, weights)
for config in configurations:
    print(config["shape"], config["building"], config["anchors"])

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.

Listing 19.3: Build the exact-area block-layout model for the I-shaped configuration.
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,
)
Listing 19.4: Solve and save the exact-area layouts.
Show code
from pathlib import Path
from scripts.layout_tutorial import solve_layouts

output = Path("tutorial-results")
output.mkdir(exist_ok=True)
solve_layouts(output, time_limit=60, seed=2026)

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.

Six labeled department rectangles inside a 325 by 217 meter envelope, with inbound on the west and shipping on the east.
Figure 19.1: I-shaped flow configuration with opposite-wall docks. The figure key gives department names, dimensions, and areas.
Six department rectangles and a hatched southeast corner void inside the enlarged envelope, with inbound at the south and shipping at the east.
Figure 19.2: L-shaped configuration with an excluded southeast corner. The hatched void is not usable department area.
Six department rectangles inside a 325 by 217 meter envelope, with both docks on the south wall and other areas north of them.
Figure 19.3: U-shaped flow configuration with both docks on the south wall. The figure key gives department names, dimensions, and areas.

Table 19.5 compares their closeness scores and footprints.

Table 19.5: Computed layouts with the same department areas and weights. The L-case has a different envelope and excluded corner.
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.

Table 19.6: Solver diagnostics. Gurobi status 2 means optimal within the requested relative tolerance of 0.0001.
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
ImportantBlock geometry and operational feasibility

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.

Table 19.7: Area requirements for the full 14-department case.
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.

python -m pip install pandas matplotlib tabulate "gurobipy>=12,<14"
python -m pip install "whlayout @ git+https://github.com/brenobeirigo/wh-block-layout.git@c2e789ce76db9aafae385f6ab2e5ce78b652cc6d"
python scripts/layout_tutorial.py --part layouts

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,440
Show 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 adjacency weights (CSV)
instances/data/coolblue/muther_legend.csv
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
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.