Congestion Modeling & Zone Routing
A travel-distance model that assumes an empty warehouse is optimistic fiction. During the midday wave, two pickers meet in a 42-inch aisle, one waits while the other clears a pallet jack, and the “shortest” path the optimizer chose is now the slowest one on the floor. Congestion is the difference between the travel cost you modeled and the travel cost your pickers actually paid, and it is invisible to any distance matrix computed against a single-picker floor. This guide is part of the Pick-Path Optimization & Space Utilization architecture, and it covers the load-and-flow layer specifically: how you measure aisle congestion from concurrent pickers and pick density, convert it into a time multiplier on travel cost, and use zone routing and wave smoothing to spread load off the hotspots before they form.
The thesis is that congestion is a dynamic multiplier, not a static attribute of a location. The same aisle is free-flowing at 09:00 and gridlocked at noon, so congestion must be modeled per aisle and per time slot, then applied on top of the base travel cost rather than baked into it.
What Congestion Modeling Is
Congestion modeling estimates how much slower travel through an aisle becomes as the number of concurrent pickers and the pick density in that aisle rise. It borrows directly from road-traffic engineering: below a free-flow threshold, pickers move at full speed and the multiplier is 1.0; past it, each additional body in the aisle adds interference — waiting to pass, restacking to reach a face, queueing at a congested pick — and travel time climbs faster than linearly. The output is a congestion multiplier in [1.0, cap] applied to the base travel cost for a specific aisle in a specific time slot.
Three ideas separate a working congestion model from a static aisle-penalty:
- Density is spatiotemporal. Congestion is not a property of aisle A-03; it is a property of aisle A-03 between 11:00 and 12:00. Modeling it per aisle-timeslot cell is what lets you see that the layout is fine and the scheduling is the problem.
- The multiplier is non-linear. One extra picker in a wide main aisle barely registers; the third picker in a narrow reserve aisle can double effective travel time. A power-law load curve captures that acceleration where a flat per-picker penalty cannot.
- Congestion multiplies, it does not add. A hotspot does not add a fixed number of seconds — it scales whatever the base travel cost already was. That is why the multiplier layers on top of the distance model rather than replacing it.
Zone routing (and its stricter sibling zone-picking) is the primary control lever. By partitioning the pick face into zones and assigning pickers to zones rather than letting them roam the whole floor, you cap the number of concurrent bodies in any one aisle by construction, trading a little more downstream consolidation for a hard ceiling on congestion. Wave smoothing is the temporal counterpart: rather than releasing every order for a hot SKU into the same wave, you spread the demand for a congested aisle across adjacent waves so no single time slot exceeds the aisle’s picker capacity.
Input Data Requirements
The model consumes confirmed pick observations bucketed to a time slot, one row per picker-aisle-slot. The critical precondition is an honest picker identity and timestamp on every pick — congestion is fundamentally a concurrency measurement, and without a picker id you can count picks but never the bodies that actually collide.
| Field | Type | Precondition |
|---|---|---|
aisle_id |
str |
Non-null; matches the aisle keys in the travel-distance model |
time_slot |
str |
Bucketed timestamp (e.g. hour label); consistent slot width across the feed |
picker_id |
str |
Non-null; distinct pickers drive the concurrency count, so anonymized ids still work |
picks |
int |
> 0; pick lines confirmed by that picker in that aisle-slot |
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PickObservation:
"""One picker's confirmed activity in one aisle during one time slot."""
aisle_id: str
time_slot: str # bucketed timestamp, e.g. "11:00"
picker_id: str
picks: int
Slot width is the setting most people get wrong. Bucket too coarse (a full shift) and two pickers who never actually met are counted as concurrent; bucket too fine (one minute) and genuine interference is scattered across empty cells. A 30–60 minute slot aligned to your wave cadence is the working default, and it must match the slot width the Wave & Batch Pick Sequencing layer schedules against, or the smoothing math will not line up.
Step-by-Step Implementation
The pipeline is three passes: fold observations into a density map keyed by aisle-timeslot, convert each cell’s load into a congestion multiplier, then route each pick task to the least-congested feasible option. Every function is pure so the multiplier map is deterministic and cacheable per wave.
1. Build a Pick-Density Map
Collapse the observation stream into one DensityCell per aisle-timeslot, carrying both total picks (throughput) and the count of distinct pickers (concurrency). Concurrency is the load variable the multiplier actually keys on; total picks is retained for hotspot ranking and for feeding demand back to the wave scheduler.
import logging
from collections import defaultdict
from dataclasses import dataclass
logger = logging.getLogger("space.congestion")
@dataclass
class DensityCell:
aisle_id: str
time_slot: str
total_picks: int
concurrent_pickers: int
def build_density_map(observations: list[PickObservation]) -> dict[tuple[str, str], DensityCell]:
"""Fold observations into one density cell per (aisle, time_slot)."""
picks: dict[tuple[str, str], int] = defaultdict(int)
pickers: dict[tuple[str, str], set[str]] = defaultdict(set)
for obs in observations:
key = (obs.aisle_id, obs.time_slot)
picks[key] += obs.picks
pickers[key].add(obs.picker_id)
cells = {
key: DensityCell(key[0], key[1], picks[key], len(pickers[key]))
for key in picks
}
logger.info("Built density map: %d aisle-timeslot cells from %d observations",
len(cells), len(observations))
return cells
2. Compute a Congestion Multiplier Per Aisle-Timeslot
The multiplier uses a BPR-style load curve — the same functional form road engineers use for link travel time. Below capacity the term is small and the multiplier hugs 1.0; as concurrent pickers exceed the aisle’s free-flow capacity, the (load / capacity)^beta term accelerates the penalty. A hard cap keeps a pathological cell from returning an absurd multiplier that would dominate the whole route cost.
def congestion_multiplier(cell: DensityCell,
capacity_pickers: int = 2,
alpha: float = 0.9,
beta: float = 2.2,
cap: float = 3.0,
flag_at: float = 1.5) -> float:
"""Map an aisle-timeslot's concurrency to a bounded travel-cost multiplier (BPR form)."""
if capacity_pickers <= 0:
return 1.0
load_ratio = cell.concurrent_pickers / capacity_pickers
multiplier = min(1.0 + alpha * (load_ratio ** beta), cap)
if multiplier >= flag_at:
logger.info("Congested %s@%s: %d pickers / cap %d -> x%.2f",
cell.aisle_id, cell.time_slot, cell.concurrent_pickers,
capacity_pickers, multiplier)
return multiplier
3. Route Around Hotspots
Routing is a minimization over the effective cost, which is base travel cost times the current multiplier for the aisle-timeslot a candidate would land in. Given several ways to satisfy a pick — an alternate cross-aisle to the same face, or the same aisle in a quieter slot — the router picks the option with the lowest effective cost, which is how a hotspot pushes traffic onto a marginally longer but far faster path.
from dataclasses import dataclass
@dataclass(frozen=True)
class RouteOption:
aisle_id: str
time_slot: str
base_cost_s: float
def choose_least_congested(options: list[RouteOption],
density_map: dict[tuple[str, str], DensityCell],
**mult_params: float) -> RouteOption:
"""Pick the option minimizing base travel cost times its congestion multiplier."""
def effective(opt: RouteOption) -> float:
cell = density_map.get((opt.aisle_id, opt.time_slot))
m = congestion_multiplier(cell, **mult_params) if cell else 1.0 # type: ignore[arg-type]
return opt.base_cost_s * m
best = min(options, key=effective)
logger.info("Routed to %s@%s (effective %.1fs) over %d options",
best.aisle_id, best.time_slot, effective(best), len(options))
return best
The temporal control lever — spreading demand so no cell exceeds capacity in the first place — is wave smoothing. The helper below flags every cell whose concurrency exceeds the zone ceiling and reports the picker overflow that must move to an adjacent wave, which is the signal the sequencing layer consumes.
def find_overflow(density_map: dict[tuple[str, str], DensityCell],
max_pickers_per_zone: int = 3) -> list[tuple[str, str, int]]:
"""Report (aisle, slot, overflow) for every cell over the zone picker ceiling."""
overflow = [
(c.aisle_id, c.time_slot, c.concurrent_pickers - max_pickers_per_zone)
for c in density_map.values()
if c.concurrent_pickers > max_pickers_per_zone
]
logger.info("Wave smoothing: %d cells over the %d-picker ceiling",
len(overflow), max_pickers_per_zone)
return overflow
Tuning & Calibration
The two parameters that move outcomes most are capacity_pickers (where the penalty starts) and beta (how sharply it accelerates). Capacity is physical: a 12-foot main aisle absorbs three or four concurrent pickers before interference; a 42-inch reserve aisle saturates at one. beta is the shape of the pain — raise it and the model punishes crowding harder, which pushes the router to spread load more aggressively. Calibrate both by regressing observed pick-cycle time against concurrency for a sample of aisles; do not import a single global capacity.
# congestion.yaml — one profile per facility, capacity overridable per aisle class
congestion:
time_slot_minutes: 60 # slot width; must match the wave cadence
capacity_pickers: 2 # default free-flow picker capacity per aisle
bpr_alpha: 0.9 # penalty scale at capacity
bpr_beta: 2.2 # penalty acceleration above capacity
multiplier_cap: 3.0 # hard ceiling so one cell cannot dominate a route
flag_at: 1.5 # multiplier above which a cell is logged as congested
zone_routing:
enable: true
max_pickers_per_zone: 3 # hard concurrency ceiling enforced by zoning
overflow_action: defer_to_next_wave
aisle_capacity_overrides:
MAIN: 4 # wide main aisles absorb more concurrency
RESERVE_NARROW: 1 # narrow reserve aisles saturate at one picker
# Equivalent Python config dict consumed by the model
CONGESTION = {
"congestion": {
"time_slot_minutes": 60,
"capacity_pickers": 2,
"bpr_alpha": 0.9,
"bpr_beta": 2.2,
"multiplier_cap": 3.0,
"flag_at": 1.5,
},
"zone_routing": {
"enable": True,
"max_pickers_per_zone": 3,
"overflow_action": "defer_to_next_wave",
},
"aisle_capacity_overrides": {"MAIN": 4, "RESERVE_NARROW": 1},
}
Parameter sensitivity is not uniform. capacity_pickers is a physical constant per aisle class — measure it once. beta and multiplier_cap are behavioural levers you tune against results: if the router thrashes tasks onto detours for marginal savings, lower beta; if a single gridlocked aisle still dominates route cost, lower the cap. Recompute the multiplier map every wave, because concurrency is a property of the wave, not the layout.
Validation & Testing
Never apply a multiplier map you have not asserted. Three invariants must hold: an empty or free-flow cell returns exactly 1.0, the multiplier is monotonically non-decreasing in concurrency and never exceeds the cap, and the router prefers a longer path when the short one is congested enough. These pytest checks run before a wave’s map is published.
def _cell(pickers: int) -> DensityCell:
return DensityCell("A-03", "11:00", total_picks=pickers * 10,
concurrent_pickers=pickers)
def test_free_flow_is_neutral() -> None:
assert congestion_multiplier(_cell(0)) == 1.0
assert congestion_multiplier(_cell(1)) < 1.3 # at/under capacity stays gentle
def test_multiplier_monotonic_and_capped() -> None:
vals = [congestion_multiplier(_cell(n), cap=3.0) for n in range(0, 12)]
assert vals == sorted(vals) # non-decreasing in concurrency
assert max(vals) <= 3.0 # never exceeds the cap
def test_router_avoids_hotspot() -> None:
dmap = build_density_map(
[PickObservation("A-03", "11:00", f"P{i}", 5) for i in range(6)] # gridlocked
)
options = [
RouteOption("A-03", "11:00", base_cost_s=40.0), # short but congested
RouteOption("A-04", "11:00", base_cost_s=52.0), # longer, empty
]
assert choose_least_congested(options, dmap).aisle_id == "A-04"
A sample healthy run logs Congested A-03@11:00: 6 pickers / cap 2 -> x3.00 and Routed to A-04@11:00 (effective 52.0s) over 2 options. The router taking the 52-second detour over a 40-second path that congestion has inflated past 90 seconds is the model working exactly as intended.
Integration Points
The congestion model is a multiplier layer — it is authoritative about slowdown, and nothing else.
- The travel-cost model. Congestion multiplies the base cost produced by Travel-Distance & Pick-Path Cost Modeling; it never replaces it. The distance model answers “how far,” the congestion model answers “how much slower right now,” and effective cost is their product. Applying congestion inside the distance matrix would freeze a wave-specific condition into a static asset — always keep them as separate factors multiplied at route time.
- Wave sequencing. The overflow that smoothing detects is the input the Wave & Batch Pick Sequencing layer acts on: it decides which orders move to an adjacent wave to keep every aisle-timeslot under its picker ceiling. The congestion model surfaces where and when the crowding is; the sequencer decides which work to defer.
Congestion is also the counterweight to space work. A consolidation pass driven by Space & Cube Utilization Scoring packs inventory into fewer, denser faces — excellent for cube, dangerous for flow if it funnels every picker into the same short aisles. Feed the post-consolidation layout back through this model to confirm you traded honeycomb void for density without minting a new hotspot. Upstream, the velocity tiers from the Location Assignment & ABC Classification Algorithms layer explain why an aisle is hot — a clustered block of A-movers concentrates picks — and spreading those A-faces across zones is the structural fix congestion routing can only paper over.
Failure Modes & Edge Cases
- Congestion baked into the distance matrix. Folding a peak-hour penalty into the base cost makes every off-peak route wrong. Remediation: keep congestion a separate multiplier applied per wave and recompute it each cycle.
- Time slots that do not match the wave cadence. A 15-minute congestion slot against hourly waves cannot inform smoothing. Remediation: align
time_slot_minutesto the wave interval so the sequencer and the model speak the same units. - One global aisle capacity. Treating a 12-foot main aisle and a 42-inch reserve aisle as equally tolerant either under-penalizes gridlock or over-penalizes free flow. Remediation: set
capacity_pickersper aisle class via overrides and calibrate against observed cycle time. - Routing that ignores the cap. Without
multiplier_cap, one pathological cell can return a multiplier so large it dominates every route and sends all traffic on absurd detours. Remediation: cap the multiplier and treat a capped cell as an alert to smooth the wave, not a routing input. - Missing picker identity. Counting picks without distinct pickers measures throughput, not concurrency, and can flag a single fast picker as a crowd. Remediation: require
picker_idon every observation; concurrency, not pick count, drives the multiplier.
FAQ
How is congestion different from travel distance?
Travel distance is a static property of the layout — how far a picker walks from one location to the next — and it is the same at 09:00 and at noon. Congestion is a dynamic property of the wave: how much slower that same walk becomes when several pickers occupy the aisle at once. The two are multiplied, not added. The distance model gives you a base cost in seconds; the congestion model gives you a multiplier that scales it for the specific aisle and time slot the picker actually travels through. Keeping them separate is what lets you re-slot for distance and re-schedule for congestion independently.
Why model congestion per time slot instead of per aisle?
Because the same aisle is free-flowing for most of the day and gridlocked for one wave. A per-aisle congestion score would either smear the noon spike across the whole shift (making calm aisles look chronically slow) or average it away entirely (hiding the spike). Keying the model on aisle-timeslot cells shows you that the layout is fine and the scheduling concentrated demand — which points you at wave smoothing rather than a costly re-slot. It also matches the granularity the wave sequencer works at, so the two layers exchange the same cells.
What does the BPR-style multiplier actually do?
It borrows the load curve traffic engineers use for road links. Below the aisle’s free-flow picker capacity the penalty term is tiny and the multiplier stays near 1.0; once concurrency exceeds capacity, the load-ratio raised to the power beta makes the penalty climb faster than linearly, reflecting how the third body in a narrow aisle hurts far more than the second. A hard cap stops one saturated cell from returning a runaway multiplier. Tuning alpha sets the penalty scale and beta sets how sharply it accelerates; calibrate both by regressing observed cycle time against concurrency.
When should I use zone routing versus wave smoothing?
Use zone routing when the crowding is structural — the same aisles are hot in almost every wave — because partitioning pickers into zones puts a hard ceiling on concurrency by construction. Use wave smoothing when the crowding is temporal — a demand spike piles one wave’s orders into a few aisles — because shifting some of that work to an adjacent wave flattens the peak without changing the layout. Most facilities need both: zoning caps steady-state concurrency, and smoothing absorbs the spikes zoning alone cannot. The model surfaces which pattern you have by showing whether hotspots are spread across slots or concentrated in one.
Can congestion modeling make things worse?
Yes, if the router chases marginal savings. Set beta too high or omit the multiplier_cap and the router will thrash tasks onto long detours to shave seconds off a mildly busy aisle, adding travel that outweighs the congestion avoided. The guardrails are the cap and a calibrated capacity: the router should only reroute when the short path is genuinely slower after inflation, and a cell pinned at the cap should trigger wave smoothing rather than an ever-longer detour. Treat persistent capping as a scheduling alert, not a routing input.
Related
- How to Detect Aisle Congestion from Pick-Density Heatmaps — the focused function that bins picks into a heatmap and flags congested cells.
- Travel-Distance & Pick-Path Cost Modeling — the base travel cost that the congestion multiplier scales at route time.
- Wave & Batch Pick Sequencing — the layer that acts on smoothing overflow to keep every aisle-timeslot under its ceiling.
- Space & Cube Utilization Scoring — the consolidation work congestion modeling keeps from minting a new hotspot.
- Pick-Path Optimization & Space Utilization — the parent architecture this flow layer feeds.