Skip to content

Release control and dispatching

Goal: control when jobs enter the shopfloor using release policies and apply dispatching rules for queue ordering.

See the runnable dispatching and release policy galleries to compare every rule and policy in your browser.

1) Push vs pull systems

In a push system, jobs enter the shopfloor immediately upon arrival. Simple, but can lead to high WIP (Work-in-Progress) and long queues.

In a pull system, jobs wait in a PSP (Pre-Shop Pool) and are released only when conditions are met (e.g., workload below threshold, server starving). This controls WIP and improves flow times.

Push:  Arrivals ────────────────> ShopFloor

Pull:  Arrivals ───> PSP ───> ShopFloor
                      │
              (release policy decides when)

2) The Pre-Shop Pool

The PreShopPool is a pure container with no built-in release logic. It holds jobs and provides events that external processes can monitor.

from simulatte.environment import Environment
from simulatte.psp import PreShopPool
from simulatte.shopfloor import ShopFloor

env = Environment()
shopfloor = ShopFloor(env=env)
psp = PreShopPool(env=env, shopfloor=shopfloor)

Key properties:

  • psp.empty: True if no jobs waiting
  • psp.jobs: Iterate over waiting jobs (FIFO — First-In-First-Out — order)
  • psp.new_job: Event that fires when a job is added

3) Composing release logic

Release policies are wired to the simulation through callback registration methods on ShopFloor and PreShopPool:

Method Fires when Use case
psp.on_arrival(callback) New job enters PSP Immediate decisions
shopfloor.on_processing_end(callback) Job finishes at a server Starvation avoidance

For periodic checks, a periodic_trigger process is also available (see below).

Example: compose event-driven callbacks with a periodic trigger:

from simulatte.policies.triggers import periodic_trigger

def my_release_fn(psp):
    """Release oldest job if shopfloor WIP is low."""
    if not psp.empty and len(psp.shopfloor.jobs) < 10:
        job = psp.remove()
        psp.shopfloor.add(job)

def my_starvation_fn(job, server):
    """Release a PSP job when a server might starve.

    Called after a job finishes processing at a server.
    We check if the server is now empty.
    """
    if server.empty and not psp.empty:
        # Find a job that starts at this server
        for candidate in psp.jobs_starting_at(server):
            psp.release(candidate)
            break

def my_arrival_fn(job, psp):
    """Release a job immediately if its first server is idle."""
    if job.servers[0].is_idle:
        psp.release(job)

# Register callbacks
shopfloor.on_processing_end(my_starvation_fn)
psp.on_arrival(my_arrival_fn)
env.process(periodic_trigger(psp, 5.0, my_release_fn))

The psp.release(job) method is a convenience for psp.remove(job=job) followed by shopfloor.add(job).

Advanced: process-based triggers

For cases that require full SimPy process semantics (e.g., yielding timeouts or composing with other events), lower-level trigger functions are available in simulatte.policies.triggers:

Trigger Fires when
periodic_trigger At regular intervals
on_arrival_trigger New job enters PSP
on_completion_trigger Job finishes at server

These are started as SimPy processes and run in an infinite loop:

from simulatte.policies.triggers import on_arrival_trigger, on_completion_trigger

# Process-based equivalents of the callbacks above
env.process(on_arrival_trigger(psp, my_arrival_fn))
env.process(on_completion_trigger(shopfloor, psp, my_starvation_trigger_fn))

In most cases the callback APIs (psp.on_arrival, shopfloor.on_processing_end) are simpler and preferred. Use the process-based triggers when you need to yield SimPy events inside your release logic.

4) Using builders

Simulatte provides builder functions for common configurations.

Immediate release (baseline)

Jobs bypass the PSP entirely. Useful as a baseline for comparison.

from simulatte.builders import build_immediate_release_system
from simulatte.environment import Environment

env = Environment()
_, servers, shopfloor, router, _ = build_immediate_release_system(env=env)
env.run(until=1000)

print(f"Jobs completed: {len(shopfloor.jobs_done)}")
print(f"Avg time in system: {shopfloor.average_time_in_system:.2f}")

The default Scenario (a 6-machine pure job shop at ρ=0.90) reflects a standard benchmark workload — pass a custom scenario=Scenario(...) to model other regimes.

Optional parameters:

  • priority_policies: A callable (job, server) -> float used to assign queue priorities (dispatching rules). Lower values are served first. Pass None (default) for FIFO ordering.
  • collect_workload: If True, attaches a CurrentWorkLoadCollector that records total remaining processing work over time (see ShopFloor extensibility).

A ready-made immediate release with SPT (Shortest Processing Time) dispatching rule is available:

from simulatte.builders import build_immediate_release_system
from simulatte.dispatching_rules import shortest_processing_time

_, servers, shopfloor, router, _ = build_immediate_release_system(
    env=env,
    priority_policies=shortest_processing_time,
)

LUMS COR

Lancaster University Management School corrected order release (Thürer et al., 2012 — DOI).

Jobs are released only if adding them keeps corrected WIP at or below a workload norm. Combines periodic checks with starvation avoidance.

from simulatte.builders import build_lumscor_system
from simulatte.environment import Environment

env = Environment()
psp, servers, shopfloor, router, _ = build_lumscor_system(
    env=env,
    check_timeout=10.0,      # Check every 10 time units
    wl_norm_level=5.0,       # Workload threshold per server
    allowance_factor=2,      # Buffer for due date calculation
)
env.run(until=1000)

print(f"Jobs completed: {len(shopfloor.jobs_done)}")
print(f"Avg time in PSP: {sum(j.time_in_psp for j in shopfloor.jobs_done) / len(shopfloor.jobs_done):.2f}")

Key parameters:

  • check_timeout: Time between periodic release checks
  • wl_norm_level: Maximum corrected WIP allowed per server
  • allowance_factor: Multiplier for due date slack (higher = more conservative)
  • collect_workload: If True, attaches a CurrentWorkLoadCollector (see ShopFloor extensibility)

Release triggers wired by the builder:

  1. Periodic release (periodic_trigger): every check_timeout time units, release PSP jobs (sorted by planned release date) whose corrected WIP fits within the workload norm.
  2. Starvation release (shopfloor.on_processing_end): when a server finishes a job:
    • If the server queue is empty, immediately release the PSP candidate with the earliest planned release date.
    • If exactly one job remains in the queue, schedule a postponed release — the candidate is removed from PSP immediately, but enters the shopfloor after a tiny delay so the queued job acquires the server first.
  3. Starvation avoidance (psp.on_arrival): when a new job enters the PSP and its first server is completely idle (empty queue and no job processing), the job is released immediately.

Queue ordering uses a PST (planned slack time) priority policy: jobs with lower PST are served first.

SLAR

Superfluous Load Avoidance Release (Land & Gaalman, 1998 — DOI).

Event-driven release based on planned slack time (PST). No periodic checks — releases are triggered by job completions at servers.

from simulatte.builders import build_slar_system
from simulatte.environment import Environment

env = Environment()
psp, servers, shopfloor, router, _ = build_slar_system(
    env=env,
    allowance_factor=3.0,    # Slack per operation
)
env.run(until=1000)

print(f"Jobs completed: {len(shopfloor.jobs_done)}")

Key parameters:

  • allowance_factor: Slack allowance per operation (higher = more buffer time)
  • collect_workload: If True, attaches a CurrentWorkLoadCollector (see ShopFloor extensibility)

On every job completion at a server, SLAR evaluates three branches in order:

  1. Empty-queue release: if the server queue is empty, immediately release the most urgent PSP candidate (lowest PST) to prevent idling.
  2. Urgent-job insertion: if all queued jobs are non-urgent (positive PST), release from PSP the urgent job (negative PST) with the shortest processing time, minimising disruption to the existing queue.
  3. Postponed starvation avoidance: if exactly one job remains in the queue, schedule a delayed release — the candidate is removed from PSP immediately (to avoid double-selection) but enters the shopfloor after a tiny delay so the queued job acquires the server first.

Additionally, a starvation avoidance callback is registered via psp.on_arrival: when a new job arrives whose first server is completely idle (empty queue and no job processing), it is released immediately.

Queue ordering uses a PST-based priority policy: jobs with lower PST are served first.

SLAR-Limit

SLAR with a workload-norm limit on urgent insertion (Thürer & Stevenson, 2021 — DOI).

Extends classic SLAR by gating the urgent-insertion branch with a workload-norm check: an urgent PSP candidate is released only if its corrected workload contribution PT / (i + 1) keeps every server in its routing at or below its configured norm. The idle-prevention and drain-safety-net branches are inherited unchanged from SLAR.

from simulatte.builders import build_slar_limit_system
from simulatte.environment import Environment

env = Environment()
psp, servers, shopfloor, router, _ = build_slar_limit_system(
    env=env,
    allowance_factor=3.0,   # Slack per operation
    wl_norm_level=5.0,      # Workload norm per server
)
env.run(until=1000)

print(f"Jobs completed: {len(shopfloor.jobs_done)}")

Key parameters:

  • allowance_factor: Slack allowance per operation (higher = more buffer time)
  • wl_norm_level: Workload norm applied uniformly to every server. An urgent PSP candidate is released only if adding its corrected contribution keeps every server in its routing at or below this level.
  • collect_workload: If True, attaches a CurrentWorkLoadCollector (see ShopFloor extensibility)

How it differs from SLAR: when the urgent-insertion branch fires, SLAR releases the urgent PSP candidate with the shortest processing time unconditionally. SLAR-Limit iterates urgent candidates in ascending SPT order and releases the first that fits within all server workload norms. If no urgent candidate fits, the branch returns without releasing — the drain-safety-net may still fire on the same event.

Requirements: SLAR-Limit requires CorrectedWIPStrategy on the shopfloor (set automatically by the builder).

DRACO

Dispatching, Release, and Authorization for Controlled Order flow (Kasper, Land & Teunter, 2023 — DOI).

DRACO is non-hierarchical: it merges release, authorization, and dispatching into a single per-server decision taken on every job completion. At each completion at server k, DRACO scores every candidate in Q_k ∪ P_k (jobs queued at k, plus PSP jobs whose first server is k) by a weighted total impact w^R·R + w^A·A + w^D·D and selects the maximum. The dispatching component D is the FOCUS rule (below).

from simulatte.builders import build_draco_system
from simulatte.environment import Environment

env = Environment()
psp, servers, shopfloor, router, _ = build_draco_system(
    env=env,
    wip_target=8,    # target shop WIP (job count), tau
    loop_target=4,   # target overlapping loop per server pair, epsilon
)
env.run(until=1000)

print(f"Jobs completed: {len(shopfloor.jobs_done)}")

Key parameters:

  • wip_target (τ): target shop WIP as a job count (sum of queued + in-process jobs across servers). This is independent of any WIPStrategy workload metric.
  • loop_target (ε): target overlapping loop per (k, u) server pair. Pass a scalar for a uniform target; instantiate Draco directly (passing the router — it self-wires the priority policy and hooks) with a dict[(Server, Server), int] for per-pair targets.
  • focus_weights: the five FOCUS mechanism weights used for D (default (0.25, 0.25, 0.25, 0.25, 0.0) — beta dormant; see the FOCUS section for the weight ordering).
  • total_impact_weights: (w^R, w^A, w^D), must sum to 1. Defaults to (0.25, 0.25, 0.5) — the paper's full DRACO (Table 2: W^R = W^A = 1/4, W^D = 1/2).

How it differs: classic workload control separates release (PSP → shop) from dispatching (queue ordering). DRACO makes one combined choice per completion, so a PSP job can be released and placed first at the freed server in a single decision. A _forced_at_server flag guarantees a PSP winner is dispatched before any queued job, even when the queued job has a higher queue-side priority.

Cold start: the decision fires only on completions, so build_draco_system also wires psp.on_arrival(starvation_avoidance) to release a job when an arrival's first server is idle — a liveness provision that prevents a cold-start deadlock. It bypasses the R/A/D scoring (it is not itself a DRACO decision).

Caveats: DRACO assumes capacity == 1 per server (one freed slot per completion; the force-pin/dispatch ordering relies on it — the builder enforces it). A released job routing into an idle downstream server is granted immediately by SimPy with no decide_next_job call, so that decision moment passes without R/A/D scoring — rare at the ~90% utilization studied in the paper.

Dispatching rules

Dispatching rules (priority policies) are (job, server) -> float callables that determine queue ordering (lower = more urgent). Simulatte ships a catalog of literature-standard rules in the simulatte.dispatching_rules package, split into three tiers.

Tier 1 — stateless rules are plain functions you pass directly:

Rule Function
Shortest Processing Time shortest_processing_time
Earliest Due Date earliest_due_date
Operational Due Date operational_due_date
Modified Operational Due Date modified_operational_due_date
Critical Ratio critical_ratio
First Come First Served first_come_first_served
Work In Next Queue work_in_next_queue
from simulatte.dispatching_rules import earliest_due_date

router = Router(..., priority_policies=earliest_due_date)

Tier 2 — parameterized rules are factory functions: call them with their parameter(s) — an allowance, a look-ahead, or a utilization — to build the callable.

Rule Factory
Planned Slack Time planned_slack_time(allowance=k)
Slack per Remaining Operation slack_per_remaining_operation(allowance=k)
Apparent Tardiness Cost apparent_tardiness_cost(lookahead=k)
Cost Over Time cost_over_time(lookahead=k)
Raghu & Rajendran raghu_rajendran(utilization=u)
from simulatte.dispatching_rules import planned_slack_time

# Create a PST rule with per-operation queue-time allowance k=2.0
pst = planned_slack_time(allowance=2.0)

# Use it with a Router, builders, or individual ProductionJob
router = Router(..., priority_policies=pst)

The routing-aware rules return inf for servers outside the job's routing or already exited — making them safe for priority comparisons and min() calls.

The newer Tier 1/2 rules add work-content and tardiness-cost signals. Work In Next Queue (work_in_next_queue; Blackstone, Phillips & Hogg, 1982 — DOI) orders by the work waiting at a job's next machine, feeding soon-to-starve downstream stations. Apparent Tardiness Cost (apparent_tardiness_cost; Vepsäläinen & Morton, 1987 — DOI) and Cost Over Time (cost_over_time; Carroll, 1965) weigh a job's marginal tardiness cost against its imminent processing time. Raghu & Rajendran (raghu_rajendran; 1993 — DOI) is a composite that blends processing time, due-date slack, and the next-queue load, weighted by the current machine's utilization. ATC and COVERT take a look-ahead parameter (and an optional weight); their avg_processing/utilization shop-state inputs default to live computation with an optional fixed override.

Tier 3 — system-state rules. Focus (Kasper, Land & Teunter, 2023 — DOI) is a self-establishing rule: a weighted combination of five mechanisms — SPT (pi), starvation response (omega), slack timing (psi), pacing (gamma), and WIP balancing (beta), each in [0, 1]. Unlike Tier 1/2 it is a class (it exposes per-mechanism methods and a shared build_context), adapted to the priority_policy contract by FocusPriorityRule. A ready-made push system that dispatches with FOCUS is available:

from simulatte.builders import build_focus_system

_, servers, shopfloor, router, _ = build_focus_system(
    env=env,
    focus_weights=(0.25, 0.25, 0.25, 0.25, 0.0),  # beta dormant (default)
)

The weight tuple is ordered (π, ξ, τ, δ, β)pi, omega, psi, gamma, beta — following the DRACO paper's Eq-9 order with beta appended 5th, not the FOCUS paper's Eq-12 order (π, β, ξ, τ, δ). beta is off by default because the Omega paper found WIP balancing counter-productive in their experiments. The mechanisms are scored over the order book O — every arrived, not-yet-completed order (queued and in-process, plus PSP candidates when FOCUS is used inside DRACO) — so the shop-wide normalizers (max processing time, slack, pacing) reflect the full pending workload.

To reproduce the Omega paper's per-mechanism ablations (each drops one mechanism; the rest stay at 1/4):

Ablation focus_weights
FOCUS-π (0.0, 0.25, 0.25, 0.25, 0.25)
FOCUS-ξ (0.25, 0.0, 0.25, 0.25, 0.25)
FOCUS-τ (0.25, 0.25, 0.0, 0.25, 0.25)
FOCUS-δ (0.25, 0.25, 0.25, 0.0, 0.25)
FOCUS-β (0.25, 0.25, 0.25, 0.25, 0.0) — the default

(Focus.__init__ carries the same table plus the index→mechanism→paper-symbol map.)

FOCUS is also the dispatching component of DRACO (above).

5) Comparing builder-based systems

Run the builder-based systems and compare:

from simulatte.builders import (
    build_draco_system,
    build_immediate_release_system,
    build_lumscor_system,
    build_slar_system,
    build_slar_limit_system,
)
from simulatte.environment import Environment
from simulatte.runner import Runner

def run_system(builder_fn, builder_kwargs, until=1000):
    def builder(*, env):
        return builder_fn(env, **builder_kwargs)

    def extract(system):
        psp, servers, shopfloor, router, _policy = system
        return {
            "jobs_done": len(shopfloor.jobs_done),
            "avg_time_in_system": shopfloor.average_time_in_system,
            "avg_utilization": sum(s.utilization_rate for s in servers) / len(servers),
        }

    # progress=None (default) auto-enables tqdm on TTY; set False to disable
    runner = Runner(builder=builder, seeds=range(5), parallel=False, extract_fn=extract)
    return runner.run(until=until)

# Compare
immediate = run_system(build_immediate_release_system, {})
lumscor = run_system(build_lumscor_system, {"check_timeout": 10, "wl_norm_level": 5, "allowance_factor": 2})
slar = run_system(build_slar_system, {"allowance_factor": 3})
slar_limit = run_system(build_slar_limit_system, {"allowance_factor": 3, "wl_norm_level": 5})
draco = run_system(build_draco_system, {"wip_target": 8, "loop_target": 4})

policies = [
    ("Immediate", immediate),
    ("LumsCor", lumscor),
    ("SLAR", slar),
    ("SLAR-Limit", slar_limit),
    ("DRACO", draco),
]
for name, results in policies:
    avg_tis = sum(r["avg_time_in_system"] for r in results) / len(results)
    print(f"{name}: avg time in system = {avg_tis:.2f}")

Notes

  • Multiple callbacks and triggers can coexist on the same PSP and shopfloor.
  • psp.on_arrival callbacks run synchronously during psp.add(), before the SimPy new_job event fires. Process-based listeners via on_arrival_trigger resume after.
  • shopfloor.on_processing_end callbacks run after the server is released (servers_exit_at is stamped and job.previous_server is available).
  • LUMS COR and SLAR-Limit require CorrectedWIPStrategy on the shopfloor (set automatically by the builder).
  • SLAR is purely event-driven (no periodic trigger).

6) Additional policies

Simulatte also provides two additional release policies:

ConWIP (Constant Work-In-Process)

ConWIP maintains a shop-wide WIP cap based on job count. Jobs are released from the PSP whenever the shopfloor job count drops below the cap, with selection by earliest due date (EDD).

from simulatte.builders import build_conwip_system
from simulatte.environment import Environment

env = Environment()
psp, servers, shopfloor, router, _ = build_conwip_system(env=env, wip_cap=12)
env.run(until=1000)

build_conwip_system constructs the ConWIP policy, which self-wires its release triggers (psp.on_arrival and an on-completion trigger) in __init__ — no manual wiring needed.

Reference: Spearman, M. L., Woodruff, D. L. & Hopp, W. J. (1990). CONWIP: a pull alternative to kanban. International Journal of Production Research, 28(5), 879-894 — DOI.

Inspecting and retuning the policy

Every build_*_system returns a BuiltSystem — the same 5-target tuple you have been unpacking, but also a NamedTuple whose policy field is the wired policy instance itself. Keeping the result intact (instead of discarding policy as _) lets you read a policy's configuration and, where the policy supports it, retune it mid-run.

ConWIP is the clearest example: it reads self.wip_cap live on every release decision (on each completion and each PSP arrival), so assigning a new value to system.policy.wip_cap takes effect on the very next trigger.

from simulatte.builders import build_conwip_system
from simulatte.environment import Environment

env = Environment()
system = build_conwip_system(env=env, wip_cap=8)

# `system.policy` is the same ConWIP instance the builder wired into the shop.
print(f"Configured WIP cap: {system.policy.wip_cap}")

# Phase 1 runs under the original cap.
env.run(until=500)
print(f"After phase 1 (cap={system.policy.wip_cap}): WIP={len(system.shop_floor.jobs)}")

# Retune mid-run: ConWIP reads `wip_cap` live, so raising it lets more jobs
# onto the floor from the next completion / arrival onward.
system.policy.wip_cap = 16
env.run(until=1000)
print(f"After phase 2 (cap={system.policy.wip_cap}): WIP={len(system.shop_floor.jobs)}")

Named-field access (system.policy, system.shop_floor) reads off the same BuiltSystem, so there is no second lookup or re-wiring: the object you mutate is exactly the one driving releases. The push builders (build_immediate_release_system, build_focus_system) and the plain-callback build_starvation_avoidance_system carry policy=None, since they wire no policy object to inspect. Other policies expose their own knobs — for example LumsCor, SlarLimit, and ContinuousRelease carry a wl_norm attribute you can read the same way.

Continuous Release

Continuous Release is a workload-controlled policy where jobs may be released at any moment when a server's corrected workload drops below its norm. It uses corrected aggregate load: the contribution at routing position i is PT / (i + 1). Requires CorrectedWIPStrategy on the shopfloor.

from simulatte.builders import build_continuous_release_system
from simulatte.environment import Environment

env = Environment()
psp, servers, shopfloor, router, _ = build_continuous_release_system(
    env=env,
    wl_norm_level=5.0,       # Corrected workload norm per server
    allowance_factor=2,      # Buffer for due-date planning
)
env.run(until=1000)

build_continuous_release_system constructs the ContinuousRelease policy, which sets CorrectedWIPStrategy on the shopfloor and self-wires its release triggers in __init__. A scalar wl_norm_level is applied uniformly to every server (pass a dict[Server, float] to ContinuousRelease directly for per-server norms).

Reference: Fernandes, N. O. & Carmo-Silva, S. (2011). Workload control under continuous order release. International Journal of Production Economics, 131(1), 257-262 — DOI.

7) Dynamic priorities

A job's priority comes from job.priority_policy, which simulatte calls as policy(job, server) and which returns a float (lower = more urgent). This policy is re-evaluated on every dispatch decision: every time a new job enters a server's queue and every time a job releases a server. Three patterns are supported first-class:

  • Time-dependent policies — the value depends on env.now (e.g. planned slack time, which decreases as the simulation progresses).
  • Policy reassignmentjob.priority_policy = new_fn at any time reorders the job's position in any queue it is currently waiting in.
  • Mutable external state — the policy reads from shared state owned by user code (e.g. a dispatcher's score table); updates to that state become visible at the next dispatch decision.

Contract

priority_policy(job, server) must be a deterministic function of (job, server, current simulation state): repeated calls at the same env.now with the same external state must return the same value. Do not consume RNG inside the policy and do not mutate state from inside the policy. If a policy violates this contract, the simulation still runs but queue ordering becomes unspecified.

Cost

simulatte calls priority_policy once per queued request per dispatch decision, so the per-event cost scales linearly with the queue length. Keep policies cheap.

Example

state = {"A": 10.0, "B": 20.0}

job_a = ProductionJob(
    env=env, sku="A", servers=[server], processing_times=[3.0],
    due_date=1000.0, priority_policy=lambda j, s: state["A"],
)
job_b = ProductionJob(
    env=env, sku="B", servers=[server], processing_times=[3.0],
    due_date=1000.0, priority_policy=lambda j, s: state["B"],
)

# Both queue with A ahead of B.
shopfloor.add(job_a)
shopfloor.add(job_b)

# Mutate the shared state; at the next dispatch decision the queue
# is re-sorted and B will be served before A.
state["A"] = 30.0
state["B"] = 5.0

Server.sort_queue() can also be called explicitly if you want the new order to be observable immediately (between events).

Runnable end-to-end examples live in tests/core/test_server.py::TestDynamicPriorityRefresh.

Next