Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Warehouse Layer-Pick Slotting Engine

tests

Decision support for layer picking in a distribution centre: which pallet should occupy which pick face, right now, and what should move next.

A rules engine that emits advisory ADD / REMOVE / WAIT recommendations against a live floor state, a discrete-event simulator that replays a full pick day against those rules, and the slotting and wave-planning machinery underneath both.

85 modules · ~30k lines · 295 tests · runs end to end on generated data, no external services.

Synthetic data only. This engine was developed against a customer order feed that cannot be published. That feed is not in this repository in any form, at any point in its history. Everything here runs on tools/generate_synthetic_site.py, which builds a plausible site from a seed.


The problem

In a layer-pick operation a picker takes whole layers off a pallet rather than individual cases. It is far faster than case picking, but only for SKUs that are actually on a pick face when their order waves. A SKU without a face gets hand-picked instead, at a fraction of the rate.

Faces are scarce. A layout might have 100 source faces against several hundred active SKUs, so the floor is a continuous eviction problem: every face given to one SKU is denied to another, and the demand that decides who deserves it arrives in waves through the day.

Both naive policies fail:

  • Never evict: the floor fills with slow movers that were needed once, and the layer-pick rate decays as the day goes on.
  • Always evict for the next wave: pallets churn constantly and the movement cost exceeds the picking saved.

This engine is the middle: independently testable rules that each answer one narrow question about one pallet, combined into a recommendation the floor can act on or ignore.


Quick start

pip install -e .
python tools/generate_synthetic_site.py --out data/synthetic --seed 7

Then run the engine (pytest runs the suite; both work from a bare checkout):

palletsort run --data-dir data/synthetic --as-of 2026-03-02T06:00:00

That prints floor metrics, the wave ratio, and the ranked recommendations. To replay a full day hour by hour:

palletsort simulate --data-dir data/synthetic --from 2026-03-02T06:00:00 --to 2026-03-03T06:00:00 --output timeline.csv

On the seed-7 site the opening floor carries a 77% dead-slot rate (faces held by SKUs with no live demand) and layer-picks 26.3% of waved demand. Applying the recommendations takes dead slots to 10% and the waved layer-pick rate to 48.3%, and the timeline CSV shows both hour by hour.

Every rate here is weighted by the layers actually waved in each hour, not averaged across hours. 1,455 layers wave over the simulated day and they are not spread evenly: 16 of the 25 hours have no waved demand at all. Averaging hourly percentages lets those empty hours vote, which understated the throughput effect by more than half when this README first quoted it.

Those two numbers come from different rules, and only one of them is what the operation is paid on. Freeing a face is not the same as picking a layer off it. The rule that fills a freed face, RACK_PROMOTE, is disabled in default and gated a second time behind include_rack_aisle, so the default policy can evict and not much else. Open both:

palletsort simulate --data-dir data/synthetic --policy resupply-rack \
  --from 2026-03-02T06:00:00 --to 2026-03-03T06:00:00
Policy Dead slots Waved layer-pick Layers moved to layer pick
default 77% to 12% (65.3pp) 26.3% to 40.0% (+13.7pp) 367
resupply-rack 77% to 10% (67.3pp) 26.3% to 48.3% (+22.1pp) 412

Eviction earns nearly all of the dead-slot number and none of the throughput number. That split is the actual thesis: the floor is nearly always worse than it looks, and freeing a face only pays once something worth picking goes into it. Reporting the dead-slot swing alone would have hidden a version of this engine that could not pick any faster than the floor it replaced. That is exactly what an earlier revision shipped.


How the rules engine is built

Each rule is a pure function over a WorldState snapshot returning zero or more Recommendations. No rule mutates state, no rule calls another, and every one is individually disableable:

palletsort simulate --data-dir data/synthetic --policy resupply-rack \
  --from 2026-03-02T06:00:00 --to 2026-03-03T06:00:00 --without-rules RACK_PROMOTE

That structure exists for one reason: it makes attribution possible. Re-run with one rule removed and read what that rule was worth, instead of arguing about it. --without-rules subtracts from whatever policy is in force and never adds a rule that policy had switched off, so the delta measures the one change you asked for.

Dropped from resupply-rack one at a time, over the seed-7 day:

Rule dropped Dead-slot improvement Waved layer-pick Layers moved
(none) 67.3pp 48.3% (+22.1pp) 412
ZERO_DEMAND 8.0pp 48.3% (+22.1pp) 412
RACK_PROMOTE 65.3pp 40.0% (+13.7pp) 367
CROSS_AREA_HOLD 67.3pp 48.3% (+22.1pp) 412

The two load-bearing rules are orthogonal and neither substitutes for the other: ZERO_DEMAND is essentially the whole dead-slot result and contributes nothing to throughput, RACK_PROMOTE the reverse. CROSS_AREA_HOLD is a guard: it changes which pallets are legal to touch, not how many get touched on this site.

RuleConfig carries ~40 policy switches. --policy selects a named combination; palletsort simulate --help lists them.

Representative rules:

Rule Question In default?
ZERO_DEMAND Nothing waved needs this SKU. Reclaim the face, or will it be needed shortly? on
CROSS_AREA_HOLD Another fulfilment area owns this pallet's earliest order. Do not touch it. on
EXCLUDED_ITEM This SKU cannot be layer-picked at all. Never spend a face on it. on
WAIT_SUPPLY_SHORTAGE Waved demand with no supply anywhere. Flag it; the engine cannot conjure stock. on
RACK_PROMOTE Backing stock exists for a waved SKU with no face. Promote it, and evict what? off, use --policy resupply-rack
LOW_LAYERS A face is nearly empty. Resupply it, or give the slot away? off, use --policy with-low-layers

The three rules default switches off (LOW_LAYERS, BACKUP_PROMOTE, RACK_PROMOTE) each move pallets on a judgement the general engine should not make unasked. They are opt-in, not unfinished.

The engine is advisory by construction. It returns recommendations; it does not move pallets, and there is no code path that talks to a WMS.


The simulator

src/lpda/simulate/replay.py replays a pick day on a discrete clock: demand waves in, pickers draw layers off faces, faces deplete, rules fire between ticks, pallets move. It reports layer-pick percentage, hand-pick fallback, waits, shortages, and dead-slot rate per hour.

The design lesson worth stealing is a bug the project shipped and then found: for a long period the simulated floor never depleted, because the fulfilment path did not decrement what it picked. Every metric still looked plausible. The floor simply stayed full, so layer-pick rates were measured against a room that could not run out. Bar figures produced before the fix are not comparable to any after it.

The guard is now structural: tests/ asserts that layers picked equals layers removed from faces, so a floor that does not deplete fails the suite rather than quietly reporting good numbers.


The synthetic generator

tools/generate_synthetic_site.py reproduces the structural features the rules respond to, and nothing else:

  • ABC velocity skew (Zipf): under uniform demand every face is worth the same and slotting is not a decision at all.
  • Wave structure with a mid-shift peak: the binding constraint is concurrent throughput at the peak, not daily volume.
  • A visible/waved horizon split: several rules exist only to tell "needed now" from "will be needed".
  • Lumpy line sizes (geometric): the minimum-layers gate only matters when small lines exist.
  • A deliberately imperfect opening floor: slow movers are seeded onto source faces so the reclaim rules have something to do. A perfectly slotted start would make the engine look inert.
  • Backing stock behind unfaced demand: 40% of the resupply rack is reserved for SKUs that have demand but hold no source face. Drawing the whole rack from the same popularity curve that filled the faces backs SKUs that are already faced, and then the promote rules have nothing to promote: the dead-slot rate improves while the layer-pick rate does not move at all. That was the state this repository shipped in first.
  • Stock depth scaled to demand: pallets are allocated against horizon demand rather than drawn from the popularity curve a second time, to a stated coverage target (--seed 7 reaches 78.1% of horizon layer demand; the generator prints it). Drawing them gave the head SKU one partially depleted pallet whether it wanted 1 layer that day or 151, so most demand was unservable for a reason no slotting decision could fix. A share is left uncovered on purpose, or WAIT_SUPPLY_SHORTAGE never fires, and 30% of demanded SKUs start the shift faced by nothing, or RACK_PROMOTE has nothing to promote.

Seeded throughout: the same --seed gives byte-identical output anywhere, which is what makes the 77% → 10% figure above checkable rather than a claim. CI fails if --seed 7 stops reproducing the committed site, so a stale figure cannot sit here quietly.


Configuration and hard-coded values

Operational rates live in src/lpda/pick_throughput.py and are illustrative placeholders, not measurements. Every one reads an environment override, and anything derived is recomputed rather than hand-scaled:

LP_PICK_CASES_PER_HOUR=1500 palletsort simulate --data-dir data/synthetic \
  --from 2026-03-02T06:00:00 --to 2026-03-03T06:00:00

src/lpda/simulate/pilot_config.py supplies one internally consistent arrangement of the ~90 simulator switches, as a starting point to vary, not a tuned or optimal configuration. The switch names and their interactions are the reusable part.


What is deliberately not here

This engine was built during a warehousing internship on a real engagement. The general mechanism is published; anything specific to that engagement is not, and was removed rather than rewritten with invented numbers:

  • the order feed, SKU master, and site profiles
  • WMS export queries, which were written against a specific production schema
  • the labour, capacity and ROI models, and their commercial figures
  • pick rates and cost bases taken from the site's own planning documents
  • measured performance figures: the benchmark percentages the engine produced against the real feed, and the arm-by-arm results quoted against them
  • real ship dates, including the calendar of days the pilot was scored on
  • references to internal documents that exist only in the private repository

The first release of this repository got the top four right and the bottom three wrong. It replaced the site's name with the string "the customer" and stopped there, so measured percentages, a thirteen-day real ship calendar and citations to private handover documents all shipped in code comments while this section claimed otherwise. They were removed in a later pass, and the history was rewritten so the original commits do not carry them either. If you are checking this class of thing in your own work, note that a name-based search finds none of it.

What remains is the mechanism: the rules, the simulator, the slotting and wave-planning logic, and a generator that gives them something to run on. Dates in the code are synthetic, and the operating parameters are defaults to vary rather than measurements.

License

MIT. See LICENSE.

About

Rules engine and discrete-event simulator for warehouse layer-pick slotting. Advisory ADD/REMOVE/WAIT decisions over a live floor state, run entirely on seeded synthetic data.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages