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_jandv_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.
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_kgand 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_factorin the derate path so every ceiling comparison usesweight_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_headroomand, for high-overhang product, add per-side overhang tolerance (≤ 2 in) before computingvolume_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.
Related
- ABC Classification Tuning — the velocity tiers whose A-assignments must clear this feasibility gate.
- Family & Affinity Grouping — co-pick families that must fit a contiguous bin run under aggregate limits.
- Threshold Optimization for Re-slotting — the move-cost break-even that reads the headroom this layer emits.
- Location Hierarchy Mapping — the zone topology that supplies each location’s raw load and cube ratings.
- Location Assignment & ABC Classification Algorithms — the parent architecture this feasibility layer feeds.