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.
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.
pip install -e .
python tools/generate_synthetic_site.py --out data/synthetic --seed 7Then 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:00That 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.csvOn 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.
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_PROMOTEThat 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.
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.
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 7reaches 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, orWAIT_SUPPLY_SHORTAGEnever fires, and 30% of demanded SKUs start the shift faced by nothing, orRACK_PROMOTEhas 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.
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:00src/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.
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.
MIT. See LICENSE.