For an hour the shop now gets an order every 1.43 seconds on average (42 a minute, rate: 0.7). Its one packer takes 1.5 seconds per order, so it packs at most 40 a minute, a little fewer than the 42 that arrive. Orders are still waiting when the hour ends. A report that counts only the orders packed in the hour leaves them out. This page asks: does a fixed horizon answer the same question as closing the doors and packing every order already in the shop?
Decision: which way of ending the run the shop's report uses.
Measures: the orders started, finished and still in the shop, the mean wait, and the time the run ends.
Runs: two runs, each with the seed 42: one stops at the end of the hour, and one stops taking orders then and runs until the last order is packed.
Assumptions: orders arrive at random, and every order takes the same time to pack.
What You Will Learn
This page shows how to:
end a run at a fixed time, or close the doors and drain the orders already in the shop;
count the orders started, finished and still in the shop;
see which orders a mean wait leaves out.
What You Add
This page adds a drain at the end of the run, which packs every order still in the shop, and the counts that show which orders each way of ending leaves out. The shop is the one from First Simulation, busier: one packer and 1.5 seconds of packing.
Count Who Is Still There
An end condition decides which entities a run's results describe. These statistics count the orders:
flows.orders.started, from the basic level, counts the orders that entered the shop. An order enters when its first task begins, before any queue.
flows.orders.finished, from basic, counts the orders that left.
flows.orders.in_system, from basic, counts the orders still in the shop.
tasks.pack_order.in_progress, from minimal, counts the orders waiting to be packed or being packed.
The model switches on typical, which also keeps the mean wait:
# Orders started, finished and still in the shop, and the mean wait, at the endenv.setup_stats("Shop","typical",interval=7200)
A low-level process reports its entities with env.stats.flow_started(arrival) and env.stats.flow_finished(arrival), as the full file below does.
Every order that started is either finished or still in the shop, so started is finished plus in_system. These counts say how many orders a mean wait leaves out.
Close the Doors, Then Finish
Save the files from Full Files in one folder and run the commands there. A fixed horizon stops the clock at until, whatever is still going on. A drain stops only the arrivals at until and keeps running until every entity in the model has left. In SimPy that is two steps: interrupt the source process, then run until every admitted process has ended. desflow writes both with drain:
run:# No new orders after one houruntil:1 h# true: pack every order already in the shop before the run endsdrain:${DRAIN:-false}
${DRAIN:-false} reads the environment variable DRAIN, so DRAIN=true desflow run end_conditions.yaml runs the drain, as desflow run end_conditions.yaml --drain does.
env.run(until=3600)# no new orders after one hourifdrain:# Stop the source, then run until every admitted order has endedsource.interrupt()env.run(until=env.all_of([oforoinordersifo.is_alive]))
env.all_of(...) is SimPy's AllOf: it happens when every order process has ended. SimPy and desflow explains waiting for all events.
source.interrupt() raises an Interrupt inside the source process, which ends its loop. SimPy and desflow explains interrupts.
env.now is the clock, which the script reads to report the end time. SimPy and desflow explains the clock.
The stop at until is an event that SimPy schedules with the URGENT priority, so it comes before the other events at that time. A low-level script that stops its own run at an event can do the same with env.schedule(event, URGENT, delay). In a blueprint, the fire action's urgent and after keys do it, as Signals and Waiting shows.
A drain can also take a limit, drain: {limit: 10 min}, so a model that never empties still ends. A stop ends the run as soon as an event or a condition happens. Run Length describes both.
What You Get and Why
Does a fixed horizon answer the same question as a drain? Not for every order. Each figure here comes from a run with the seed 42: one that stops at the hour, and one that drains after it. The fixed horizon ends at 3,600 seconds with 2,436 orders started, 2,398 finished and 38 still in the shop, one being packed and 37 waiting. The drain ends 56.4 seconds later, at 3,656.4, with all 2,436 finished:
desflow explain shows why so many orders are left, without a run:
Resources
+--------------------------------------------------------+
| resource | λ | μ | c | ρ | Wq | L | status |
|----------+-----+--------+---+------+----+---+----------|
| packers | 0.7 | 0.6667 | 1 | 1.05 | - | - | UNSTABLE |
+--------------------------------------------------------+
Not estimated:
wait at packers: unstable: the queue grows for as long as the run lasts
The packer gets 1.05 seconds of work every second, more than it can do, so the queue grows for as long as orders arrive. The longer the horizon, the more orders it leaves in the shop. A drain packs them all. In Python, context.analyze() gives the same analysis for a builder model. A low-level model has no explain, because its SimPy code makes the links between arrivals, tasks and resources as it runs, so there are none to read.
The mean wait barely moves: 71.70 seconds with the fixed horizon against 71.51 with the drain. An order's wait is measured when its packing starts, and only 37 of the 2,436 orders start packing after the hour.
Each tab prints both runs: the orders started, finished and still in the shop, and the mean wait. A record's sim_ts is the time the run ended.
# Stop at the end time: orders started, finished and still in the shop, and the mean waitdesflowrunend_conditions.yaml--stdout|grep-E'orders\.(started|finished|in_system)"|pack_order\.wait_mean"'|tail-4
# Finish the orders in the shop firstDRAIN=truedesflowrunend_conditions.yaml--stdout|grep-E'orders\.(started|finished|in_system)"|pack_order\.wait_mean"'|tail-4
horizon: ends at 3600.0 s, 2436 started, 2398 finished, 38 left, mean wait 71.70 s
drain : ends at 3656.4 s, 2436 started, 2436 finished, 0 left, mean wait 71.51 s
A fixed horizon answers a question about a period, such as how many orders the shop packs in an hour. The orders still in the shop at the end are unfinished work, and in_system says how many there are. A drain answers a question about a batch of orders, such as how long the day's orders take to clear. It serves every admitted order, but the run goes on past the hour. Report the counts beside any mean, so a reader knows which orders it covers. Output Analysis explains the two kinds of run, and Statistics describes each count.
Key Takeaways
A fixed horizon answers a question about a period. A drain answers a question about a batch of orders.
started equals finished plus in_system, so these counts show the orders a result leaves out.
Report the counts beside any mean, so a reader knows which orders it covers.
# The End Conditions tutorial: a fixed horizon against a drain.# Run it with: desflow run end_conditions.yaml# With a drain after the hour: DRAIN=true desflow run end_conditions.yamlsimulation:sim_id:Shopfactor:0random_seed:42resources:packers:{current_cap:1, max_cap:4}services:packing:{dist:constant, value:1.5 s}arrivals:orders:{dist:exponential, rate:0.7, spawn:pack_order}tasks:pack_order:service:packingresource:packerspayload:{event_type:order_packed}# Orders started, finished and still in the shop, and the mean wait, at the endstats:{level:typical, interval:2 h}egress:-type:Consolerun:# No new orders after one houruntil:1 h# true: pack every order already in the shop before the run endsdrain:${DRAIN:-false}
"""The End Conditions tutorial: a fixed horizon against a drain.One packer, an order every 1.43 seconds on average (rate 0.7) and 1.5 seconds ofpacking, for an hour. Every record is printed to the terminal, and the script prints thecounts and the mean wait with a fixed horizon and with a drain at the end. To see onlythose lines, run:python end_conditions.py 2>/dev/null"""importloggingfromdesflowimportConsoleEgress,SimulationContextlogging.basicConfig(level=logging.INFO,format="%(levelname)s [%(asctime)s] %(message)s")defshop()->SimulationContext:"""The shop of First Simulation, busier: more orders than one packer can pack."""app=(SimulationContext(sim_id="Shop",factor=0,random_seed=42).add_resource("packers",current_cap=1,max_cap=4).add_service("packing",dist="constant",value=1.5).add_arrival("orders",dist="exponential",rate=0.7)# Orders started, finished and still in the shop, and the mean wait, at the end.with_stats("typical",interval=7200).add_egress(ConsoleEgress()))@app.task(service_id="packing",resource_id="packers")defpack_order(task_id:int):return{"event_type":"order_packed"}@app.arrival_loop("orders")deforder_generator(context:SimulationContext):task_id=0whileTrue:order=yieldcontext.wait_for_arrival("orders")context.spawn(pack_order(task_id,entity=order))task_id+=1returnappdefoutcomes(drain:bool)->dict:"""Runs the hour, with or without a drain, and returns the counts and the wait."""app=shop()# No new orders after one hour; with drain, pack every order already in the shopapp.run(until="1 h",drain=drainorNone)r=app.env.stats.readings()name="Shop.stats.{}".formatreturn{"started":r[name("flows.orders.started")],"finished":r[name("flows.orders.finished")],"in_system":r[name("flows.orders.in_system")],"wait_mean":r[name("tasks.pack_order.wait_mean")],"end":app.env.now,}if__name__=="__main__":fordrainin(False,True):r=outcomes(drain)label="drain "ifdrainelse"horizon"print(f"{label}: ends at {r['end']:7.1f} s, {r['started']} started, "f"{r['finished']} finished, {r['in_system']} left, "f"mean wait {r['wait_mean']:5.2f} s")
"""The End Conditions tutorial: a fixed horizon against a drain.One packer, an order every 1.43 seconds on average (rate 0.7) and 1.5 seconds ofpacking, for an hour. Every record is printed to the terminal, and the script prints thecounts and the mean wait with a fixed horizon and with a drain at the end. To see onlythose lines, run:python end_conditions_low_level.py 2>/dev/null"""importitertoolsimportloggingimportsimpyfromdesflowimport(CapacityConfig,ConsoleEgress,DistributionConfig,DynamicRealtimeEnvironment,DynamicResource,Sampler,SimParameter,)logging.basicConfig(level=logging.INFO,format="%(levelname)s [%(asctime)s] %(message)s")PARAMS=SimParameter(sim_id="Shop",arrival={"orders":DistributionConfig(dist="exponential",rate=0.7)},service={"packing":DistributionConfig(dist="constant",value=1.5)},resources={"packers":CapacityConfig(current_cap=1,max_cap=4)},)defpack_order(env,sampler,packers,key,task_id):path_id="Shop.service.packing"defpublish(value):env.publish_event(key,value,task="pack_order",instance=task_id)env.stats.flow_started("orders")# the order enters the shoppublish({"path_id":path_id,"status":"queued"})env.stats.task_queued("pack_order",task_id)withpackers.request()asrequest:yieldrequestpublish({"path_id":path_id,"status":"started"})env.stats.task_started("pack_order",task_id)yieldenv.timeout(sampler.sample(env.registry.get_config(path_id)))publish({"event_type":"order_packed"})env.stats.task_finished("pack_order",task_id)env.stats.flow_finished("orders")deforder_generator(env,sampler,packers,orders):arrival=env.registry.get_config("Shop.arrival.orders")try:fortask_idinitertools.count():yieldenv.timeout(sampler.sample(arrival))key=f"orders-{task_id+1}"orders.append(env.process(pack_order(env,sampler,packers,key,task_id)))exceptsimpy.Interrupt:pass# no new orders after the hourdefrun(drain=False,factor=0.0):env=DynamicRealtimeEnvironment(factor=factor)env.registry.register_sim_parameter(PARAMS)env.setup_egress([ConsoleEgress()])# Orders started, finished and still in the shop, and the mean wait, at the endenv.setup_stats("Shop","typical",interval=7200)packers=DynamicResource(env,"Shop","packers")orders=[]source=env.process(order_generator(env,Sampler(seed=42),packers,orders))try:env.run(until=3600)# no new orders after one hourifdrain:# Stop the source, then run until every admitted order has endedsource.interrupt()env.run(until=env.all_of([oforoinordersifo.is_alive]))finally:env.teardown()returnenvdefoutcomes(drain:bool)->dict:"""Runs the hour, with or without a drain, and returns the counts and the wait."""env=run(drain)r=env.stats.readings()name="Shop.stats.{}".formatreturn{"started":r[name("flows.orders.started")],"finished":r[name("flows.orders.finished")],"in_system":r[name("flows.orders.in_system")],"wait_mean":r[name("tasks.pack_order.wait_mean")],"end":env.now,}if__name__=="__main__":fordrainin(False,True):r=outcomes(drain)label="drain "ifdrainelse"horizon"print(f"{label}: ends at {r['end']:7.1f} s, {r['started']} started, "f"{r['finished']} finished, {r['in_system']} left, "f"mean wait {r['wait_mean']:5.2f} s")