Release control
Goal: control when jobs enter the shopfloor using release policies.
1) Push vs pull systems
In a push system, jobs enter the shopfloor immediately upon arrival. Simple, but can lead to high WIP and long queues.
In a pull system, jobs wait in a Pre-Shop Pool (PSP) 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 waitingpsp.jobs: Iterate over waiting jobs (FIFO order)psp.new_job: Event that fires when a job is added
3) Composable triggers
Release policies are implemented as external SimPy processes using trigger functions from simulatte.policies.triggers:
| Trigger | Fires when | Use case |
|---|---|---|
periodic_trigger |
At regular intervals | Workload checks |
on_arrival_trigger |
New job enters PSP | Immediate decisions |
on_completion_trigger |
Job finishes at server | Starvation avoidance |
Example: compose periodic and event-driven triggers together:
from simulatte.policies.triggers import periodic_trigger, on_completion_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(triggering_job, psp):
"""Release a job when a server might starve.
The triggering_job is the job that just finished processing.
We check if its previous server is now empty.
"""
server = triggering_job.previous_server
if server is not None and server.empty and not psp.empty:
# Find a job that starts at this server
for candidate in psp.jobs:
if candidate.starts_at(server):
psp.remove(job=candidate)
psp.shopfloor.add(candidate)
break
# Register triggers
env.process(periodic_trigger(psp, 5.0, my_release_fn))
env.process(on_completion_trigger(shopfloor, psp, my_starvation_fn))
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,
n_servers=6,
arrival_rate=1.5,
service_rate=2.0,
)
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 parameters (n_servers, arrival_rate, service_rate) reflect a standard benchmark workload — pass explicit values to model other regimes.
Optional parameters:
priority_policies: A callable(job, server) -> floatused to assign queue priorities. Lower values are served first. PassNone(default) for FIFO ordering.collect_workload: IfTrue, attaches aCurrentWorkLoadCollectorthat records total remaining processing work over time (see ShopFloor extensibility).
A ready-made SPT (Shortest Processing Time) policy is available:
from simulatte.builders import build_immediate_release_system, spt_priority_policy
_, servers, shopfloor, router = build_immediate_release_system(
env,
n_servers=6,
priority_policies=spt_priority_policy,
)
LumsCor (workload-based)
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,
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 checkswl_norm_level: Maximum corrected WIP allowed per serverallowance_factor: Multiplier for due date slack (higher = more conservative)collect_workload: IfTrue, attaches aCurrentWorkLoadCollector(see ShopFloor extensibility)
Release triggers wired by the builder:
- Periodic release (
periodic_trigger): everycheck_timeouttime units, release PSP jobs (sorted by planned release date) whose corrected WIP fits within the workload norm. - Starvation release (
on_completion_trigger): 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.
- Starvation avoidance backup (
starvation_avoidance_backup): 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 slack are served first.
SLAR (slack-based)
Event-driven release based on planned slack times (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,
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: IfTrue, attaches aCurrentWorkLoadCollector(see ShopFloor extensibility)
On every job completion at a server, SLAR evaluates three branches in order:
- Empty-queue release: if the server queue is empty, immediately release the most urgent PSP candidate (lowest PST) to prevent idling.
- 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.
- 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 backup process monitors the PSP: 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 slack are served first.
5) Comparing systems
Run all three systems and compare:
from simulatte.builders import (
build_immediate_release_system,
build_lumscor_system,
build_slar_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 = 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, {"n_servers": 6, "arrival_rate": 1.5})
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})
for name, results in [("Immediate", immediate), ("LumsCor", lumscor), ("SLAR", slar)]:
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 triggers can run simultaneously on the same PSP.
- The PSP's
new_jobevent is broadcast: all waiting processes receive the job. - LumsCor requires
CorrectedWIPStrategyon the shopfloor (set automatically by the builder). - SLAR is purely event-driven (no periodic trigger).