Slotting Architecture · 20 min read

Weight & Volume Constraint Modeling for Warehouse Slotting

A velocity ranking will happily send an A-class SKU to the golden zone even when the pallet weighs 900 kg and the beam is rated for 700 — and the picker discovers the mistake only when the level deflects. This guide is part of the Location Assignment & ABC Classification Algorithms system, and it covers the physical-feasibility layer specifically: how you turn racking load ratings, crush limits, and bin cube into hard mathematical boundaries that the assignment solver cannot cross, no matter how badly a fast-mover wants a prime slot. Velocity decides which SKUs deserve low-travel bins; constraint modeling decides which of those bins can actually hold them.

Every A-tier assignment produced by ABC Classification Tuning has to clear this gate before it reaches the WMS. When it is skipped, facilities pay for it in rack deflection, forklift instability, crushed cases at the bottom of a stack, and re-slotting cycles that undo last month’s optimization.

What Weight & Volume Constraint Modeling Is

Constraint modeling is the process of attaching a set of hard physical limits and soft operational preferences to every candidate SKU-to-location pair, then feeding those limits to the assignment optimizer as inequalities it must satisfy. A hard constraint is an absolute the solver may never violate: beam load rating, floor PSI, forklift lift height, bin interior cube. A soft constraint is a preference the solver is penalized — not forbidden — for breaking: ergonomic pick height, overhang tolerance, aisle congestion, mixed-family stacking.

The problem is a multi-dimensional bin-packing assignment. Each location j exposes a weight ceiling W_j and a volume ceiling V_j; each SKU i carries a mass w_i and a cube v_i. The optimizer chooses a binary assignment x_ij that minimizes travel cost subject to never exceeding any ceiling. Three variants show up in real facilities:

  • Static single-pallet slots — one SKU, one pallet position; the constraint is a simple pass/fail on w_i ≤ W_j and v_i ≤ V_j.
  • Shared pick faces — several co-located SKUs share a bay, so the ceiling applies to the sum of masses and cubes. This is the case that pairs with Family & Affinity Grouping, where a family of co-picked items must fit a contiguous run of bins as a unit.
  • Stacked / floor-block storage — vertical crush limits add a per-column constraint on the mass a bottom carton can bear.

The edge case that trips most first implementations is the difference between static storage weight and dynamic handling weight: a forklift placing or retrieving a pallet imparts a transient load 15–20% above the resting mass, and the beam rating must be evaluated against the dynamic figure, not the static one.

Weight and volume constraint gate Left to right. Each SKU-to-location candidate pair enters and splits into two lanes. The upper lane holds hard limits — beam load W_j, floor PSI, bin cube V_j, and lift height — which the solver may never violate. The lower lane holds soft penalties — ergonomic pick height, overhang tolerance, and mixed-family stacking — which are priced through penalty_weight rather than forbidden. Hard limits enter the MILP solver as the inequality system A x is at most b; soft penalties enter as an added cost term. The solver minimizes total travel cost and emits only feasible SKU-to-location assignments to the WMS. Candidate pairs x_ij (SKU × location) split HARD limits solver may never cross · reject if any fails beam load W_j floor PSI bin cube V_j lift height SOFT penalties priced via penalty_weight · not forbidden ergonomic height overhang mixed-family stacking A x ≤ b + penalty MILP solver scipy.optimize.milp min Σ travel cost feasible WMS committed slots

Input Data Requirements

Constraint evaluation is only as trustworthy as the master data behind it. Every field below must be present and unit-normalized before the solver runs — resolving units at solve time is the single most common source of silent capacity violations. The zone-level ceilings are inherited from the Location Hierarchy Mapping layer, which owns the physical topology each location belongs to.

Field Source Type Precondition
sku_id WMS item master str Unique, non-null
weight_kg Item master (gross) float Gross incl. packaging; > 0
volume_m3 Item master L×W×H float Palletized cube after overhang; > 0
handling_factor Config default float Dynamic-load multiplier, default 1.20
location_id Location master str Unique, non-null
max_weight_kg Rack engineering spec float Beam rating after safety factor
max_volume_m3 Bin geometry float Interior cube, not nominal
safety_factor Config per zone float Static-load derate, default 1.50
travel_cost Pick-path model float Cost of picking sku_id from location_id

The ingestion job that produces this frame — normalizing net vs. gross weight, converting case dimensions to pallet cube, and applying tare adjustments for pallets and slip sheets — belongs to the Velocity Data Ingestion & WMS Sync Pipelines layer. Constraint modeling consumes its output; it does not re-derive it.

Step-by-Step Implementation

The pipeline runs in four stages: derate the raw ratings into usable ceilings, build the constraint matrices, solve the MILP, and extract the assignment. The code below uses scipy.optimize.milp (SciPy ≥ 1.9), type hints, a dataclass for tunables, and structured logging so it drops directly into an automated slotting job.

1. Derate Ratings into Effective Ceilings

Raw engineering ratings are never used directly. The effective weight ceiling is the beam rating divided by the safety factor; the effective SKU mass is the gross weight multiplied by the dynamic handling factor. Doing this once, up front, keeps the solver’s inequalities in a single consistent unit system.

import logging
from dataclasses import dataclass, field

import numpy as np
import pandas as pd
from scipy.optimize import milp, LinearConstraint, Bounds
from scipy.sparse import csc_matrix

logger = logging.getLogger("slotting.constraints")


@dataclass(frozen=True)
class ConstraintConfig:
    """Facility-specific tunables for weight/volume feasibility."""
    safety_factor: float = 1.50          # static-load derate on beam ratings
    handling_factor: float = 1.20        # dynamic (forklift) load multiplier
    volume_headroom: float = 0.90        # cap usable cube at 90% of interior
    penalty_weight: float = 1.0e6        # soft-constraint penalty coefficient
    unassigned_cost: float = 1.0e9       # cost of leaving a SKU unplaced


def effective_ceilings(
    loc_df: pd.DataFrame, cfg: ConstraintConfig
) -> pd.DataFrame:
    """Derate raw ratings into the ceilings the solver will enforce."""
    out = loc_df.copy()
    out["eff_weight_kg"] = out["max_weight_kg"] / cfg.safety_factor
    out["eff_volume_m3"] = out["max_volume_m3"] * cfg.volume_headroom
    logger.info(
        "Derated %d locations (safety=%.2f, headroom=%.2f)",
        len(out), cfg.safety_factor, cfg.volume_headroom,
    )
    return out


def effective_masses(
    sku_df: pd.DataFrame, cfg: ConstraintConfig
) -> pd.DataFrame:
    """Inflate gross mass by the dynamic handling factor."""
    out = sku_df.copy()
    out["eff_weight_kg"] = out["weight_kg"] * cfg.handling_factor
    out["eff_volume_m3"] = out["volume_m3"]
    return out

2. Build the Constraint Matrices

The assignment matrix is flattened to a n_sku × n_loc vector of binary variables. Three constraint blocks stack into a single upper-bound system: at-most-one-location per SKU, aggregate weight per location, aggregate volume per location. The aggregate form is what makes shared pick faces work — several SKUs mapped to the same j sum against that location’s ceiling.

def build_constraints(
    sku_df: pd.DataFrame, loc_df: pd.DataFrame
) -> tuple[csc_matrix, np.ndarray]:
    """Assemble the stacked upper-bound constraint system A x <= b_ub."""
    n_sku, n_loc = len(sku_df), len(loc_df)
    n_vars = n_sku * n_loc

    # Block 1: each SKU assigned to at most one location.
    a_sku = np.zeros((n_sku, n_vars))
    for i in range(n_sku):
        a_sku[i, i * n_loc:(i + 1) * n_loc] = 1.0

    # Block 2 & 3: per-location aggregate weight and volume.
    w = sku_df["eff_weight_kg"].to_numpy()
    v = sku_df["eff_volume_m3"].to_numpy()
    a_weight = np.zeros((n_loc, n_vars))
    a_volume = np.zeros((n_loc, n_vars))
    for j in range(n_loc):
        cols = np.arange(n_sku) * n_loc + j
        a_weight[j, cols] = w
        a_volume[j, cols] = v

    a = np.vstack([a_sku, a_weight, a_volume])
    b_ub = np.concatenate([
        np.ones(n_sku),                        # <= 1 location each
        loc_df["eff_weight_kg"].to_numpy(),    # <= weight ceiling
        loc_df["eff_volume_m3"].to_numpy(),    # <= volume ceiling
    ])
    logger.info("Built %d constraints over %d vars", a.shape[0], n_vars)
    return csc_matrix(a), b_ub

3. Solve the MILP

The objective minimizes total travel cost. An unassigned SKU is not free — leaving Σ_j x_ij = 0 is priced at unassigned_cost so the solver only drops a SKU when no feasible bin exists, and surfaces that as a signal rather than a silent gap.

def solve_assignment(
    sku_df: pd.DataFrame,
    loc_df: pd.DataFrame,
    travel_matrix: np.ndarray,
    cfg: ConstraintConfig,
) -> dict:
    """Solve SKU->location assignment under hard weight/volume limits.

    travel_matrix: shape (n_sku, n_loc); cost of picking SKU i from loc j.
    Returns assigned location index per SKU (-1 when infeasible).
    """
    sku = effective_masses(sku_df, cfg)
    loc = effective_ceilings(loc_df, cfg)
    n_sku, n_loc = len(sku), len(loc)

    a, b_ub = build_constraints(sku, loc)
    constraints = LinearConstraint(a, ub=b_ub)
    integrality = np.ones(n_sku * n_loc)          # all vars binary
    bounds = Bounds(lb=0, ub=1)

    res = milp(
        c=travel_matrix.flatten(),
        constraints=constraints,
        integrality=integrality,
        bounds=bounds,
    )
    if not res.success:
        logger.error("MILP failed: %s", res.message)
        raise RuntimeError(f"MILP solver failed: {res.message}")

    assignment = res.x.reshape((n_sku, n_loc))
    placed = assignment.sum(axis=1) > 0.5
    assigned = np.where(placed, assignment.argmax(axis=1), -1)
    logger.info(
        "Placed %d/%d SKUs, objective=%.1f",
        int(placed.sum()), n_sku, res.fun,
    )
    return {
        "assignments": assigned,
        "objective_value": float(res.fun),
        "unplaced": sku.loc[~placed, "sku_id"].tolist(),
    }

4. Attach Structural Metadata

The raw index array is not what the WMS wants. The final stage joins each assignment back to human-readable IDs and records the remaining headroom per location, which downstream re-slotting logic reads to decide whether a bin can still absorb another SKU.

def to_assignment_frame(
    sku_df: pd.DataFrame, loc_df: pd.DataFrame, result: dict
) -> pd.DataFrame:
    """Join solver output back to IDs with per-location headroom."""
    idx = result["assignments"]
    rows = []
    for i, j in enumerate(idx):
        if j < 0:
            rows.append({"sku_id": sku_df.iloc[i]["sku_id"],
                         "location_id": None, "feasible": False})
            continue
        rows.append({
            "sku_id": sku_df.iloc[i]["sku_id"],
            "location_id": loc_df.iloc[j]["location_id"],
            "weight_remaining_kg": (
                loc_df.iloc[j]["max_weight_kg"] - sku_df.iloc[i]["weight_kg"]
            ),
            "feasible": True,
        })
    return pd.DataFrame(rows)

Tuning & Calibration

Two coefficients dominate feasibility behavior: the safety_factor that derates beam ratings and the handling_factor that inflates SKU mass for the dynamic forklift load. Set the safety factor too low and you approve assignments that fatigue the rack; set it too high and you strand usable capacity, forcing fast-movers into distant bins. The defaults below are conservative starting points — every facility should anchor them to its own rack engineering certificates and inspection history.

The volume_headroom cap exists because nominal bin dimensions overstate usable cube: pallet overhang, wire decking sag, and honeycombing all consume space that geometry alone does not capture. A 0.90 cap (10% reserve) is typical; drop it toward 0.85 for high-overhang product.

# constraints.yaml — facility feasibility tunables
safety_factor: 1.50        # divide beam rating by this (static derate)
handling_factor: 1.20      # multiply SKU mass by this (dynamic load)
volume_headroom: 0.90      # usable fraction of nominal bin cube
penalty_weight: 1000000.0  # soft-constraint violation penalty
unassigned_cost: 1000000000.0
zone_overrides:
  BULK_HEAVY:              # aged racking, wider margin
    safety_factor: 1.75
  FLOW_RACK:
    volume_headroom: 0.95

The equivalent Python dictionary consumed by ConstraintConfig:

config: dict[str, object] = {
    "safety_factor": 1.50,
    "handling_factor": 1.20,
    "volume_headroom": 0.90,
    "penalty_weight": 1_000_000.0,
    "unassigned_cost": 1_000_000_000.0,
    "zone_overrides": {
        "BULK_HEAVY": {"safety_factor": 1.75},
        "FLOW_RACK": {"volume_headroom": 0.95},
    },
}
cfg = ConstraintConfig(
    safety_factor=config["safety_factor"],
    handling_factor=config["handling_factor"],
    volume_headroom=config["volume_headroom"],
)

For heavy-pallet zones, apply a material-fatigue multiplier keyed to rack age and last inspection date, and prefer tiered weight bands that trigger automatic down-slotting when a bay’s cumulative mass approaches its yield threshold — a hard cliff at 100% invites saturation thrash, while bands at 70/85/95% give the solver room to degrade gracefully.

Validation & Testing

No constraint model touches production WMS routing without an assertion suite proving it never approves an over-capacity assignment. The checks below are the minimum bar: every placed SKU respects its location’s derated ceiling, no location is oversubscribed in aggregate, and an intentionally infeasible SKU is reported unplaced rather than silently dropped.

import numpy as np
import pandas as pd


def test_no_weight_violation():
    skus = pd.DataFrame({
        "sku_id": ["A", "B"], "weight_kg": [400.0, 300.0],
        "volume_m3": [1.0, 0.8],
    })
    locs = pd.DataFrame({
        "location_id": ["L1", "L2"],
        "max_weight_kg": [900.0, 900.0],   # /1.5 -> 600 eff
        "max_volume_m3": [2.0, 2.0],
    })
    travel = np.array([[1.0, 5.0], [5.0, 1.0]])
    cfg = ConstraintConfig()
    res = solve_assignment(skus, locs, travel, cfg)

    frame = to_assignment_frame(skus, locs, res)
    # Every placed SKU's effective mass must fit its derated ceiling.
    for _, r in frame[frame["feasible"]].iterrows():
        loc = locs.set_index("location_id").loc[r["location_id"]]
        sku = skus.set_index("sku_id").loc[r["sku_id"]]
        eff_mass = sku["weight_kg"] * cfg.handling_factor
        assert eff_mass <= loc["max_weight_kg"] / cfg.safety_factor


def test_infeasible_sku_is_reported():
    skus = pd.DataFrame({
        "sku_id": ["HEAVY"], "weight_kg": [2000.0], "volume_m3": [1.0],
    })
    locs = pd.DataFrame({
        "location_id": ["L1"], "max_weight_kg": [900.0],
        "max_volume_m3": [2.0],
    })
    travel = np.array([[1.0]])
    res = solve_assignment(skus, locs, travel, ConstraintConfig())
    assert "HEAVY" in res["unplaced"]        # never force-placed

Beyond unit assertions, run the solver in shadow mode against the last 30 days of actual putaway logs and flag any recommendation that diverges from the human decision by more than a 10% threshold for review. A structural-compliance audit should confirm no location exceeds 90% of rated capacity after the safety factor is applied.

Integration Points

Constraint modeling sits between velocity scoring and the physical move, and its output feeds three sibling processes directly:

  • A-tier gating for ABC Classification Tuning. A SKU can be A-velocity and still be barred from the golden zone by mass. Tuning proposes the tier; this layer decides whether the tier’s preferred bins are physically reachable, returning the derated headroom that tuning uses to know when a prime zone is full.
  • Family fitment for Family & Affinity Grouping. A co-picked family is an atomic placement unit, so the aggregate weight and volume constraints must be satisfied across the whole contiguous run of bins the family needs — not bin by bin.
  • Move economics for Threshold Optimization for Re-slotting. When a bin saturates, the weight_remaining_kg and volume headroom this layer emits are the inputs the break-even model reads to decide whether cascading a SKU to a secondary zone is worth the relocation labor.

Failure Modes & Edge Cases

  • Static rating used where dynamic load applies. Approving against resting mass ignores the 15–20% forklift transient and fatigues beams over time. Remediation: keep handling_factor in the derate path so every ceiling comparison uses weight_kg × handling_factor.
  • Nominal bin cube mistaken for usable cube. Geometry says the pallet fits; overhang and honeycombing say it does not. Remediation: enforce volume_headroom and, for high-overhang product, add per-side overhang tolerance (≤ 2 in) before computing volume_m3.
  • Unit drift between mass and cube systems. A kg/lb or m³/ft³ mismatch produces ceilings that are silently 2.2× or 35× off. Remediation: normalize all units in the ingestion frame and assert positive, bounded ranges before the solver ever sees the data.
  • A-class SKU stranded by an over-tight safety factor. Too-conservative derating leaves the golden zone half-empty while fast-movers sit in distant bins. Remediation: track approved-assignment count per zone; a zone accepting far below its physical count for weeks signals the factor is masking real capacity.
  • Monolithic solve on a mega-facility times out. A single MILP over 100k SKUs and 50k locations will not converge in a maintenance window. Remediation: partition by zone, pre-filter with affinity clustering to cut the candidate matrix 60–80%, and re-optimize only SKUs whose dimensional or velocity profile changed beyond a threshold delta.

FAQ

Should weight and volume be hard constraints or penalized soft constraints?

Weight is almost always hard — a beam rating is a safety limit, not a preference, and violating it risks structural failure. Volume is usually hard too, but the headroom reserve on top of it (the 10% honeycombing allowance) can be modeled soft, letting the solver dip into reserve cube in a saturated zone rather than leaving a SKU unplaced. Ergonomic pick height and overhang tolerance are the natural soft constraints: penalize them with penalty_weight so the solver avoids them when it can but does not fail the whole solve over one awkward reach.

How do I choose the safety factor for beam ratings?

Start from the rack manufacturer’s certified rating and the derate their engineering documentation specifies — commonly a 1.5× static factor. Then adjust up for aged or previously-damaged racking using inspection history: a bay with a prior repair or heavy honeycombing warrants 1.6–1.75×. Never invent a factor from scratch; it must trace to an engineering certificate, because this number is what stands between a velocity recommendation and a rack collapse.

Why does the solver leave some SKUs unassigned instead of forcing a fit?

Because forcing a fit means violating a hard limit, which is exactly the failure the model exists to prevent. Pricing the unassigned state at unassigned_cost makes the solver drop a SKU only when no feasible bin exists anywhere in the candidate set, and it surfaces that SKU in the unplaced list. An unplaced SKU is a real signal — you are out of compliant capacity for that mass or cube class — and it should route to a human or trigger a bulk-zone expansion, not be silently crammed into a bin that cannot hold it.

How does this scale past 50,000 locations?

Not as one MILP. Partition the assignment matrix by operational zone so each solve covers a discrete area, pre-filter candidate locations with affinity clustering to shrink the matrix 60–80% before optimization, and use incremental re-optimization — only re-solve SKUs whose velocity or dimensions moved beyond a delta. For the largest facilities, distribute partitioned solves across compute nodes and reconcile boundary constraints through a shared queue. The per-zone MILP stays small enough to converge inside a maintenance window.

Do I need to model stacking and crush limits separately?

Yes, whenever product is floor-blocked or stacked rather than single-pallet racked. Crush is a per-column constraint: the bottom carton must bear the summed mass of everything above it, so the ceiling belongs to the column, not the individual bin. Add it as a third aggregate constraint block keyed to the stack column, with the ceiling set by the weakest carton’s compression rating rather than the rack.