Slotting Architecture · 16 min read

Location Hierarchy Mapping for Velocity-Driven Slotting

Location hierarchy mapping is the coordinate system every other slotting decision is measured against: it translates the physical facility into a machine-addressable graph so that a velocity tier can be resolved to an exact bin, and a bin can be resolved back to its travel cost, capacity, and equipment requirements. This guide is part of the Core Slotting Architecture & Velocity Taxonomies system, where it sits between the scoring engine that ranks SKUs and the optimizer that commits them to slots — without a rigorous hierarchy, velocity-driven placement degrades into arbitrary assignment, inflated travel, and orphaned inventory after every layout change.

What Location Hierarchy Mapping Is

A location hierarchy is a strictly parent-child model of storage positions, encoded as a directed acyclic graph (DAG) in which each node is a physical tier and each edge is a containment relationship. The canonical schema for discrete pick-and-pack facilities is six tiers: Site → Functional Zone → Aisle → Bay → Level → Bin. Every node carries three classes of metadata: an immutable identifier (never reused, even after a bin is decommissioned), dimensional and capacity constraints (weight, cube, height clearance, temperature band), and a derived travel-cost attribute that the optimizer reads at assignment time.

The DAG framing matters because it guarantees two properties the optimizer depends on. First, every bin resolves to exactly one path back to the site root, so a composite location key is unambiguous. Second, constraints propagate downward — a temperature band declared at the zone tier is inherited by every bay, level, and bin beneath it — which lets the constraint filter reject an incompatible sub-tree in one comparison instead of walking thousands of leaf nodes.

Two industry variants deviate from the six-tier default and must be modeled explicitly rather than forced into it:

  • Cross-dock sub-hierarchies collapse the reserve tiers entirely, mapping inbound staging doors directly to outbound sortation lanes so freight flows without a put-away cycle. The graph still terminates in addressable positions, but the “bin” is a time-boxed staging slot rather than a permanent rack location.
  • Multi-temperature hierarchies introduce environmental boundaries as hard edges: ambient, chilled, and frozen sub-trees never share a parent below the zone tier, so no velocity rule can ever place a frozen SKU in an ambient bin regardless of how attractive its travel cost looks.

Input Data Requirements

The hierarchy is assembled from three feeds — CAD/rack-layout exports, the WMS location master, and any IoT or slotting-audit sensor data — normalized into one record per addressable position. Enforce these preconditions before a single node enters the graph:

Field Type Constraint / precondition
site_id str Non-null; matches the facility registry
zone_id str Non-null; resolves to a declared functional zone
aisle_id str Non-null; unique within zone
bay_id str Non-null; unique within aisle
level_id int >= 0; ground level is 0
bin_id str Non-null; unique within level
max_weight_kg float > 0; sourced from rack rating, not guessed
max_volume_m3 float > 0; usable cube after honeycombing allowance
temperature_band str | None One of ambient / chilled / frozen; inherited from zone if null
is_active bool False bins are excluded from candidate generation

Two quality gates catch the errors that silently corrupt a hierarchy: referential integrity (every child’s parent key must exist in the tier above it, or the record is an orphan) and uniqueness of the composite key (site-zone-aisle-bay-level-bin must be unique, or two physical bins will collide on one address). Feed shape and contract enforcement upstream of this stage belong to Schema Validation for Inventory Feeds; this layer assumes fields arrive typed and simply validates the topology.

The six-tier location hierarchy as a directed acyclic graph A vertical containment DAG. A single Site root, S1, branches by hard environmental edge into three functional zones: ambient (AMB), chilled (CHL) and frozen (FRZ), which never share a parent below the site. The ambient branch is fully expanded downward through Aisle A03, Bay B12, Level L2 and Bin 004, and that leaf resolves to exactly one unambiguous composite key, S1-AMB-A03-B12-L2-004. On the chilled branch a temperature band declared at the zone tier is inherited by every descendant as a hard constraint. The frozen zone collapses its reserve tiers into a leaf. SITE ZONE AISLE BAY LEVEL BIN containment edge constraint inheritance band = chilled inherited by every descendant (hard) Site S1 · immutable root Zone · Ambient AMB Zone · Chilled CHL Zone · Frozen FRZ · leaf (reserve collapsed) Aisle A03 Bay B12 Level L2 Bin · leaf 004 Aisle A07 · band ⊑ chilled Bay B04 · band ⊑ chilled one path to root → unambiguous key S1-AMB-A03-B12-L2-004 ambient / chilled / frozen are hard edges — no shared parent below the Site tier

Step-by-Step Implementation

The pipeline builds a validated registry, materializes the DAG, computes a travel-cost attribute per node, and emits a velocity-weighted candidate ranking ready for the optimizer. Each stage below is idempotent so a failed run replays against the same input window without corrupting the location ledger.

1. Validate and Normalize Location Records

Model each position with a typed schema so malformed rows are rejected — and logged — rather than propagated. A level_id that arrives as a string, or a max_weight_kg of zero, is a data defect that must never reach the optimizer.

from __future__ import annotations
import logging
from typing import Optional
import pandas as pd
from pydantic import BaseModel, Field, ValidationError

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("slotting.hierarchy")

class LocationNode(BaseModel):
    site_id: str
    zone_id: str
    aisle_id: str
    bay_id: str
    level_id: int = Field(ge=0)
    bin_id: str
    max_weight_kg: float = Field(gt=0)
    max_volume_m3: float = Field(gt=0)
    temperature_band: Optional[str] = None
    is_active: bool = True

    @property
    def composite_key(self) -> str:
        return f"{self.site_id}-{self.zone_id}-{self.aisle_id}-{self.bay_id}-{self.level_id}-{self.bin_id}"

def validate_locations(locations_df: pd.DataFrame) -> list[LocationNode]:
    """Coerce a raw location export into validated nodes, dropping and logging bad rows."""
    nodes: list[LocationNode] = []
    rejected = 0
    for _, row in locations_df.iterrows():
        try:
            nodes.append(LocationNode(**row.to_dict()))
        except ValidationError as exc:
            rejected += 1
            logger.warning("rejected location row %s: %s", row.get("bin_id", "?"), exc.error_count())
    logger.info("validated %d locations, rejected %d", len(nodes), rejected)
    return nodes

2. Assemble the Hierarchy and Enforce Referential Integrity

Before scoring anything, prove the topology is sound: every composite key is unique and every child has a live parent. Orphan detection here is what prevents “phantom bins” surviving a racking retrofit or aisle reprofile.

from collections import defaultdict

def build_hierarchy(nodes: list[LocationNode]) -> dict[str, list[LocationNode]]:
    """Group validated nodes by their aisle parent and fail fast on duplicate keys."""
    seen: set[str] = set()
    by_aisle: dict[str, list[LocationNode]] = defaultdict(list)
    for node in nodes:
        key = node.composite_key
        if key in seen:
            raise ValueError(f"duplicate composite key collides on one address: {key}")
        seen.add(key)
        aisle_key = f"{node.site_id}-{node.zone_id}-{node.aisle_id}"
        by_aisle[aisle_key].append(node)
    logger.info("assembled %d aisles across %d bins", len(by_aisle), len(seen))
    return by_aisle

3. Attach Velocity and Compute a Slotting Priority

A hierarchy earns its keep only when coupled to inventory velocity. Join the decayed velocity score produced by the SKU Velocity Taxonomy Design layer onto candidate bins, then rank each bin by the ratio of velocity to travel friction so high-velocity SKUs anchor to low-friction positions.

def rank_candidates(
    nodes: list[LocationNode],
    velocity_scores: dict[str, float],
) -> pd.DataFrame:
    """Merge velocity onto active bins and rank by velocity-per-friction (higher is better)."""
    rows = []
    aisle_codes = {a: i for i, a in enumerate(sorted({n.aisle_id for n in nodes}))}
    for node in nodes:
        if not node.is_active:
            continue
        # travel_friction: aisle depth + a level penalty (higher levels need lift equipment)
        travel_friction = aisle_codes[node.aisle_id] + (node.level_id * 2)
        velocity = velocity_scores.get(node.composite_key, 0.0)
        rows.append({
            "composite_key": node.composite_key,
            "zone_id": node.zone_id,
            "temperature_band": node.temperature_band,
            "velocity_score": velocity,
            "travel_friction": travel_friction,
            "slotting_priority": velocity / (travel_friction + 1),
        })
    ranked = pd.DataFrame(rows).sort_values("slotting_priority", ascending=False).reset_index(drop=True)
    logger.info("ranked %d active candidate bins", len(ranked))
    return ranked

4. Emit the Composite Assignment Cost

The optimizer treats placement as a constrained bin-packing problem with a distance-minimization objective. Rather than call an external solver, expose a single composite cost per candidate so the assignment engine — and the pre-slot simulation in Pick Path Modeling Frameworks — reads one comparable number. The weighted form is:

cost = α · travel_distance + β · replenishment_frequency + γ · pick_path_interference

def composite_cost(
    ranked: pd.DataFrame,
    replenishment_freq: dict[str, float],
    interference: dict[str, float],
    alpha: float = 1.0,
    beta: float = 0.4,
    gamma: float = 0.6,
) -> pd.DataFrame:
    """Blend travel, replenishment, and congestion into one cost (lower is better)."""
    df = ranked.copy()
    df["replenishment_freq"] = df["composite_key"].map(replenishment_freq).fillna(0.0)
    df["interference"] = df["composite_key"].map(interference).fillna(0.0)
    df["assignment_cost"] = (
        alpha * df["travel_friction"]
        + beta * df["replenishment_freq"]
        + gamma * df["interference"]
    )
    logger.info("computed assignment cost for %d candidates", len(df))
    return df.sort_values("assignment_cost").reset_index(drop=True)

Tuning & Calibration

The behavior of the mapping is governed by a small set of tunables: the cost weights α/β/γ, the per-level friction penalty, and the recalculation cadence that re-indexes the DAG after structural changes. Externalize them so a facility can recalibrate without a redeploy. The weights are facility-specific — a high-throughput e-commerce site with short replenishment cycles weights travel (α) hardest, while a bulk-storage site prone to congestion at cross-aisles raises interference (γ).

hierarchy:
  level_penalty: 2.0          # friction added per rack level above ground (0)
  cost_weights:
    alpha: 1.0                # travel distance weight
    beta: 0.4                 # replenishment frequency weight
    gamma: 0.6                # pick-path interference weight
  reindex_cron: "0 3 * * *"   # nightly DAG reconciliation, 03:00 local
  orphan_tolerance: 0         # abort publish if any orphan bin is detected
  golden_zone_levels: [0, 1]  # levels treated as low-friction "golden" positions
HIERARCHY_CONFIG = {
    "level_penalty": 2.0,
    "cost_weights": {"alpha": 1.0, "beta": 0.4, "gamma": 0.6},
    "reindex_cron": "0 3 * * *",
    "orphan_tolerance": 0,
    "golden_zone_levels": [0, 1],
}
Parameter Default Effect of increasing Recalibrate when
level_penalty 2.0 Pushes fast movers to lower levels; empties top racks Lift-equipment availability or ergonomics targets change
cost_weights.alpha 1.0 Prioritizes short travel over everything else Pick-walk distance dominates labor cost
cost_weights.gamma 0.6 Avoids congested aisles even at longer travel Peak-wave choke points appear in path modeling
reindex_cron nightly Fresher topology, more compute per cycle Frequent racking retrofits or aisle reprofiles
orphan_tolerance 0 Allows publishing with dangling bins (risky) Never raise above 0 in production

Weight and cube edge cases — heavy SKUs that a rack level cannot physically bear regardless of velocity — are not tuned here; they are resolved by Weight & Volume Constraint Modeling, which this layer treats as a hard pre-filter before priority ranking runs.

Validation & Testing

Topology correctness is non-negotiable, so gate the pipeline with assert-based checks that run on every build. These verify uniqueness, orphan-freedom, and that the ranking honors the friction model.

def test_composite_keys_are_unique() -> None:
    nodes = validate_locations(_sample_frame())
    keys = [n.composite_key for n in nodes]
    assert len(keys) == len(set(keys)), "composite keys must be globally unique"

def test_no_orphan_bins() -> None:
    nodes = validate_locations(_sample_frame())
    by_aisle = build_hierarchy(nodes)  # raises ValueError on duplicate/collision
    assert all(len(bins) > 0 for bins in by_aisle.values()), "every aisle must hold >=1 bin"

def test_lower_friction_bins_rank_higher() -> None:
    nodes = validate_locations(_sample_frame())
    scores = {n.composite_key: 100.0 for n in nodes}  # equal velocity isolates friction
    ranked = rank_candidates(nodes, scores)
    top, bottom = ranked.iloc[0], ranked.iloc[-1]
    assert top["travel_friction"] <= bottom["travel_friction"], "top rank must be lowest friction"

def test_inactive_bins_excluded() -> None:
    nodes = validate_locations(_sample_frame())
    ranked = rank_candidates(nodes, {})
    assert "inactive" not in ranked["composite_key"].str.lower().tolist()

Run these in CI against synthetic warehouse topologies before any change to the cost model reaches production. A regression that reorders the ranking is a slotting-thrash event waiting to happen.

Integration Points

Location hierarchy mapping is a hub the rest of the architecture reads from and writes back to:

  • Upstream — velocity tiers. The SKU Velocity Taxonomy Design layer supplies the decayed velocity_score joined onto each bin in step 3; the hierarchy contributes the travel_friction that turns a raw score into a placement priority.
  • Sibling — aisle-to-zone translation. The concrete rules for grouping physical aisles into the functional zones this DAG branches on live in Mapping Warehouse Aisles to Logical Zones, the detailed companion to this overview.
  • Downstream — path simulation. The assignment_cost from step 4 feeds directly into Pick Path Modeling Frameworks, which replays historical order profiles against a candidate layout to produce a projected travel-time delta before any move is committed.
  • Governance — who may mutate the graph. Hierarchy edits (deactivating a bin, re-rating a rack, reprofiling an aisle) are privileged operations. Security & Access Boundaries for Slotting enforces role-based access so a mistuned job cannot silently rewrite the location ledger.

Failure Modes & Edge Cases

  • Orphaned bins after a layout change. A racking retrofit deactivates a bay but leaves its child bins keyed to a now-missing parent. Remediation: run build_hierarchy with orphan_tolerance: 0 in the nightly reindex and refuse to publish candidates until the topology reconciles.
  • Composite-key collision. Two physical bins normalize to the same site-zone-aisle-bay-level-bin string after an aisle renumbering, so one silently shadows the other. Remediation: the uniqueness assert fails the build; never merge feeds without re-validating keys.
  • Stale travel friction. Aisle codes are cached from an old layout, so the friction attribute no longer matches the physical walk. Remediation: recompute aisle_codes on every reindex, not once at bootstrap, and alert when the aisle set changes.
  • Temperature-band leakage. A null temperature_band at the bin tier fails to inherit its zone’s band, letting a chilled SKU rank against an ambient slot. Remediation: resolve inheritance during validation and hard-fail any bin whose band cannot be determined.
  • Level penalty masking a hard limit. A high-velocity SKU earns a low-friction ground slot that cannot bear its weight; the friction model ranked it but never checked capacity. Remediation: apply Weight & Volume Constraint Modeling as a pre-filter so infeasible bins never enter the ranking.

FAQ

How many tiers should a location hierarchy have?

Six (Site → Zone → Aisle → Bay → Level → Bin) covers most discrete pick-and-pack facilities. Add tiers only when a real containment relationship exists — for example, a sub-bin tier for divided carton-flow lanes. Cross-dock and bulk-storage operations often use fewer, collapsing the reserve tiers; model the actual containment, never a tier that carries no constraint.

Should the hierarchy be a tree or a graph?

Model it as a directed acyclic graph that happens to be a tree for containment, so every bin has exactly one parent path. Keep valid traversal edges (aisle-to-aisle adjacency for path cost) in a separate adjacency structure — do not overload the containment DAG with routing edges, or reindexing one corrupts the other.

How often should the DAG be re-indexed?

Nightly reconciliation against WMS transaction logs is the baseline (reindex_cron: "0 3 * * *"). Trigger an immediate out-of-band reindex on any structural event — aisle reprofile, racking retrofit, bin decommission — and gate directive publishing on a clean, orphan-free topology.

How do I stop dead bins from breaking assignments after a retrofit?

Set is_active: False on decommissioned positions rather than deleting them, keep the immutable identifier reserved, and exclude inactive bins at candidate generation (step 3). During a retrofit, temporarily decouple velocity constraints from physical availability until the DAG re-indexes cleanly, then resume.