The shop now takes 0.7 orders a second for an hour, a little more than its one packer can pack in 1.5 seconds each, so 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? It runs the hour both ways and counts every order.
What You Add
A drain at the end of the run, and the counts that show which orders each way 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. With statistics at basic or above, flows.orders.started counts the orders that entered the shop, when their first task began, before any queue, flows.orders.finished the orders that left, and flows.orders.in_system those still in the shop; from minimal, tasks.pack_order.in_progress counts the orders waiting for or being packed:
# 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 finished or still in the shop: started is finished plus in_system, so the counts say how many orders a mean wait leaves out.
Close the Doors, Then Finish
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.
A drain can also take a limit, drain: {limit: 10 min}, so a model that never empties still ends, and a stop ends the run as soon as an event or a condition happens; Time and Pacing describes both.
Result
Does a fixed horizon answer the same question as a drain? Not for every order. 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:
The mean wait barely moves, 71.70 seconds against 71.51, because the wait of an order is measured when its packing starts, and only 37 orders started late. The Builder and Low-level scripts print both runs:
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
Why It Works
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 are its 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, at the cost of running past the hour. Report the counts beside any mean, so a reader knows which orders it covers. Statistics describes each count.
# 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, 0.7 orders a second and 1.5 seconds of packing, for an hour. Everyrecord is printed to the terminal, and the script prints the counts and the meanwait with a fixed horizon and with a drain at the end:python end_conditions.py 2>/dev/null shows only those lines."""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, 0.7 orders a second and 1.5 seconds of packing, for an hour. Everyrecord is printed to the terminal, and the script prints the counts and the meanwait with a fixed horizon and with a drain at the end:python end_conditions_low_level.py 2>/dev/null shows only those lines."""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")
Next, Fit the Inputs asks which distribution fits the 40 packing times.