Coverage for moptipyapps / prodsched / simulation.py: 94%
237 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-30 03:25 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-30 03:25 +0000
1"""
2A simulator for production scheduling.
4For simulation a production system, we can build on the class
5:class:`~Simulation`. This base class offers the support to implement almost
6arbitrarily complex production system scheduling logic.
7The simulations here a fully deterministic and execute a given MFC scenario
8given as an :mod:`~moptipyapps.prodsched.instance`, i.e., an object of type
9:class:`~moptipyapps.prodsched.instance.Instance`.
11The :class:`~Simulation` class offers a core backbone of a priority-queue
12based discrete event simulation. These events are strictly related to the
13production scheduling task, which allows for an efficient implementation.
14The :class:`~Simulation` is driven by the data of an
15:mod:`~moptipyapps.prodsched.instance`. This instance prescribes when new
16demands (:class:`~moptipyapps.prodsched.instance.Demand`) enter the system,
17how long certain production steps take, and which route each product type
18takes through the system, i.e., by which work stations it is processed in
19which order.
20This is the core logic that drives the simulation.
22A simulation is executed by invoking the method :meth:`~Simulation.ctrl_run`.
23Then, the event loop begins and it invokes the `event_*` methods as need be.
24For example, when a customer demand for a certain product comes in or if some
25units of a given product become available, the method
26:meth:`~Simulation.event_product` is invoked.
27You can overwrite this method to decide what to do in such cases.
28For example, you could invoke :meth:`~Simulation.act_demand_satisfied` to mark
29a :class:`~moptipyapps.prodsched.instance.Demand` as satisfied, you could
30invoke :meth:`~Simulation.act_produce` to tell the factory to produce some
31units of a given product, you could invoke
32:meth:`~Simulation.act_store_in_warehouse` to store some units of a product in
33the warehouse or use :meth:`~Simulation.act_take_from_warehouse` to take some
34out.
35In other words, in the `event_*` methods, you implement the logic, the
36operating system for your factory.
37Their default implementations in this class just produce product units on
38demand and do not really perform predictive production.
40Each :class:`~Simulation` also needs an instance of :class:`~Listener`.
41The :class:`~Listener` is informed about what happens and sees all the
42production events. It is used to gather statistics and information --
43independently of how you implement the `event_*` methods. This allows us
44to compare factories that run on very different logic.
46Simulations have three groups of methods:
48- Methods starting with `ctrl_*` are for starting and resetting the
49 simulation so that it can be started again. You may override them if you
50 have additional need for initialization or clean-up.
51- Methods that start with `event_*` are methods that are invoked by the
52 simulator to notify you about an event in the simulation. You can overwrite
53 these methods to implement the logic of your production scheduling method.
54- Methods that start with `act_*` are actions that you can invoke inside the
55 `event_*` methods. The tell the simulator or stations what to do.
57An example of such specialized simulations is the
58:class:`~moptipyapps.prodsched.rop_simulation.ROPSimulation`,
59which simulates the behavior of a system that uses re-order points (ROPs) to
60decide what to produce and when.
61In such a simulation, the `event_*`-methods are overwritten to invoke the
62`act_*`-methods according to their needs.
63Here, in the base class :class:`~Simulation`, they are implemented such to
64order the production of product units directly upon the arrival of customer
65demands. In the :class:`~moptipyapps.prodsched.rop_simulation.ROPSimulation`
66on the other hand, products are produced base on re-order points.
69**`ctrl_*` Methods:**
71We have the following `ctrl_*` methods, which are invoked from outside to
72start, stop, or reset the simulation.
74- :meth:`~Simulation.ctrl_run` runs the simulation.
75- :meth:`~Simulation.ctrl_reset` resets the simulator so that we can start it
76 again. If you want to re-use a simulation, you need to first invoke
77 :meth:`~Simulation.ctrl_reset` to clear the internal state.
80**`event_*` Methods:**
82We have the following `event_*` methods, which implement the core logic of
83the factory. They can be overwritten to ralize different production
84scenarios.
86- :meth:`~Simulation.event_product` is invoked by the simulation if one of the
87 following three things happened:
89 1. An amount of a product has been produced (`amount > 0`).
90 2. An amount of a product has been made available at the start of the
91 simulation to form the initial amount in the warehouse
92 (`amount > 0`).
93 3. A customer demand for the product has appeared in the system.
95 In this method, you can store product into the warehouse, remove product
96 from the warehouse, and/or mark a demand as completed.
98- :meth:`~Simulation.event_station` is invoked by the simulation if a work
99 station became idle *and* at least one production :class:`~Job` is queued
100 at the station.
101 Now you can decide which of the queued to jobs to execute next by invoking
102 :meth:`~Simulation.act_exec_job`. The job you pass into this method will
103 then immediately begin production at the work station.
106**`act_*` Methods:**
108The `act_*` methods are invoked from inside the `event_*` methods.
109They cause the production system to perform certain actions.
110The following `act_*` methods exist:
112- :meth:`~Simulation.act_store_in_warehouse` can be invoked from inside
113 :meth:`~Simulation.event_product`. It tells the system to *add* a certain
114 amount of units of a given product to the warehouse.
115 Notice that these units of product must have come from somewhere.
116 They could be the result of a completed production job or could have
117 occurred at the simulation startup as initial warehouse contents.
118 You cannot just "make up" new product units without violating the integrity
119 of the simulation.
121- :meth:`~Simulation.act_take_from_warehouse` can be invoked from inside
122 :meth:`~Simulation.event_product`. It tells the system to take a certain
123 amount of units *out* of the warehouse. You cannot take out more units from
124 the warehouse than currently stored inside it nor can you take out a
125 negative amount of units.
127- :meth:`~Simulation.act_demand_satisfied` can be invoked from inside
128 :meth:`~Simulation.event_product`. It tells the system that a certain
129 :class:`~moptipyapps.prodsched.instance.Demand` has been fulfilled.
130 If you do that, you must make sure to remove the corresponding amount of
131 product units from the system. They could have just been produced or they
132 could have been taken from the warehouse. However, demands can only be
133 satisfied using actually existing units of product. If you just "make up"
134 product units, you will destroy the integrity of the simulation.
136- :meth:`~Simulation.act_produce` can be invoked from inside
137 :meth:`~Simulation.event_product`. This instructs the system to begin
138 producing a certain amount of a given product. This will lead to the
139 creation of a :class:`~Job` record. This record will enter the queue of
140 the first work station that the product should pass through. It will appear
141 in :meth:`~Simulation.event_station` once this work station gets idle (or
142 right away, if it currently is idle).
144- :meth:`~Simulation.act_exec_job` is invoked from inside
145 :meth:`~Simulation.event_station`. Basically,
146 :meth:`~Simulation.event_station` gets called if there are one or multiple
147 production :class:`~Job` records queued at a work station and the work
148 station is idle (or becomes idle). Then, you can decide which of the jobs to
149 begin working on next on that work station. You will pass the corresponding
150 :class:`~Job` record to :meth:`~Simulation.act_exec_job`.
151 Once that job is completed on the current work station, it will re-appear in
152 the :meth:`~Simulation.event_station` invocation for the *next* work station
153 it needs to pass through. Once it completes at its last work station, its
154 produced :attr:`~Job.amount` of product :attr:`~Job.product_id` will appear
155 in a :meth:`~Simulation.event_product` invocation for the corresponding
156 product.
158Through this simple interface, we can control a relatively complex material
159flow simulation.
160In :mod:`~moptipyapps.prodsched.rop_simulation`, we extend this simulation
161class by implementing a re-order point based approach.
162Matter of fact, we can extend this basic simulation using all kinds of
163production logic to drive our simulated factory.
165The question then is: If all these approaches overwrite the `event_*` methods
166in different ways, how can we get a clear picture of their performance?
167No problem:
168For this purpose, the class :class:`~Listener` exists.
169Its methods are automatically invoked by the simulation and allow us to track
170exactly what happens and when, independently of the actual simulation logic.
171The class :class:`~PrintingListener` implements these methods to print the
172events to the standard output.
173In :mod:`~moptipyapps.prodsched.statistics_collector`, we implement the class
174:class:`~moptipyapps.prodsched.statistics_collector.StatisticsCollector` which
175instead uses the events to fill a
176:class:`~moptipyapps.prodsched.statistics.Statistics` record (see module
177:mod:`~moptipyapps.prodsched.statistics`).
178This record collects all the performance data that is relevant for judging the
179efficiency of a production scheduling approach.
181**Examples:**
183Let's now look at some basic examples of the production scheduling / material
184flow control simulation.
186Here we have a very easy production scheduling instance.
187There is 1 product that passes through 2 stations.
188First it passes through station 0, then through station 1.
189The per-unit production time is always 10 time units on station 0 and 30 time
190units on station 2.
191There is one customer demand, for 10 units of this product, which enters the
192system at time unit 20.
193The warehouse is initially empty.
195>>> instance = Instance(
196... name="test1", n_products=1, n_customers=1, n_stations=2, n_demands=1,
197... time_end_warmup=10, time_end_measure=4000,
198... routes=[[0, 1]],
199... demands=[[0, 0, 0, 10, 20, 100]],
200... warehous_at_t0=[0],
201... station_product_unit_times=[[[10.0, 10000.0]],
202... [[30.0, 10000.0]]])
204The simulation will see that the customer demand for 10 units of product 0
205appears at time unit 20.
206It will issue a production order for these 10 units at station 0.
207Since station 0 is not occupied, it can immediately begin with the production.
208It will finish the production after 10*10 time units, i.e., at time unit 120.
209The product is then routed to station 1, which is also idle and can
210immediately begin producing.
211It needs 10*30 time units, meaning that it finishes after 300 time units.
212The demanded product amount is completed after 420 time units and the demand 0
213can be fulfilled.
215>>> simulation = Simulation(instance, PrintingListener(print_time=False))
216>>> simulation.ctrl_run()
217start
218T=0.0: product=0, amount=0, in_warehouse=0, in_production=0, 0 pending demands
219T=20.0! product=0, amount=0, in_warehouse=0, in_production=0,\
220 1 pending demands
221T=20.0! station=0, 1 jobs queued
222T=20.0! start j(id: 0, p: 0, am: 10, ar: 20, me: T, c: F, st: 20, sp: 0)\
223 at station 0
224T=120.0! finished j(id: 0, p: 0, am: 10, ar: 20, me: T, c: F, st: 20, sp: 0)\
225 at station 0
226T=120.0! station=1, 1 jobs queued
227T=120.0! start j(id: 0, p: 0, am: 10, ar: 20, me: T, c: F, st: 120, sp: 1)\
228 at station 1
229T=420.0! finished j(id: 0, p: 0, am: 10, ar: 20, me: T, c: T, st: 120, sp: 1)\
230 at station 1
231T=420.0! product=0, amount=10, in_warehouse=0, in_production=0,\
232 1 pending demands
233T=420.0! d(id: 0, p: 0, c: 0, am: 10, ar: 20, dl: 100, me: T) statisfied
234T=420.0 -- finished
236>>> instance = Instance(
237... name="test2", n_products=2, n_customers=1, n_stations=2, n_demands=3,
238... time_end_warmup=21, time_end_measure=10000,
239... routes=[[0, 1], [1, 0]],
240... demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200],
241... [2, 0, 1, 7, 30, 200]],
242... warehous_at_t0=[2, 1],
243... station_product_unit_times=[[[10.0, 50.0, 15.0, 100.0],
244... [ 5.0, 20.0, 7.0, 35.0, 4.0, 50.0]],
245... [[ 5.0, 24.0, 7.0, 80.0],
246... [ 3.0, 21.0, 6.0, 50.0,]]])
248>>> instance.name
249'test2'
251>>> simulation = Simulation(instance, PrintingListener(print_time=False))
252>>> simulation.ctrl_run()
253start
254T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
255T=0.0: 2 units of product 0 in warehouse
256T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
257T=0.0: 1 units of product 1 in warehouse
258T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
259 1 pending demands
260T=20.0: station=1, 1 jobs queued
261T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
262 at station 1
263T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
264 1 pending demands
265T=22.0! station=0, 1 jobs queued
266T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
267 at station 0
268T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
269 2 pending demands
270T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
271 at station 0
272T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
273 at station 1
274T=62.0! station=1, 2 jobs queued
275T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
276 at station 1
277T=62.0! station=0, 1 jobs queued
278T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
279 at station 0
280T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
281 at station 1
282T=95.0! station=1, 1 jobs queued
283T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
284 at station 1
285T=107.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: T, st: 62, sp: 1)\
286 at station 0
287T=107.0! station=0, 1 jobs queued
288T=107.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 95, sp: 1)\
289 at station 0
290T=107.0! product=1, amount=9, in_warehouse=1, in_production=7,\
291 2 pending demands
292T=107.0: d(id: 0, p: 1, c: 0, am: 10, ar: 20, dl: 90, me: F) statisfied
293T=107.0! 0 units of product 1 in warehouse
294T=112.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: T, st: 52, sp: 1)\
295 at station 1
296T=112.0! product=0, amount=3, in_warehouse=2, in_production=0,\
297 1 pending demands
298T=112.0! d(id: 1, p: 0, c: 0, am: 5, ar: 22, dl: 200, me: T) statisfied
299T=112.0! 0 units of product 0 in warehouse
300T=144.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: T, st: 95, sp: 1)\
301 at station 0
302T=144.0! product=1, amount=7, in_warehouse=0, in_production=0,\
303 1 pending demands
304T=144.0! d(id: 2, p: 1, c: 0, am: 7, ar: 30, dl: 200, me: T) statisfied
305T=144.0 -- finished
308>>> simulation.ctrl_reset()
309>>> simulation.ctrl_run()
310start
311T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
312T=0.0: 2 units of product 0 in warehouse
313T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
314T=0.0: 1 units of product 1 in warehouse
315T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
316 1 pending demands
317T=20.0: station=1, 1 jobs queued
318T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
319 at station 1
320T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
321 1 pending demands
322T=22.0! station=0, 1 jobs queued
323T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
324 at station 0
325T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
326 2 pending demands
327T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
328 at station 0
329T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
330 at station 1
331T=62.0! station=1, 2 jobs queued
332T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
333 at station 1
334T=62.0! station=0, 1 jobs queued
335T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
336 at station 0
337T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
338 at station 1
339T=95.0! station=1, 1 jobs queued
340T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
341 at station 1
342T=107.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: T, st: 62, sp: 1)\
343 at station 0
344T=107.0! station=0, 1 jobs queued
345T=107.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 95, sp: 1)\
346 at station 0
347T=107.0! product=1, amount=9, in_warehouse=1, in_production=7,\
348 2 pending demands
349T=107.0: d(id: 0, p: 1, c: 0, am: 10, ar: 20, dl: 90, me: F) statisfied
350T=107.0! 0 units of product 1 in warehouse
351T=112.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: T, st: 52, sp: 1)\
352 at station 1
353T=112.0! product=0, amount=3, in_warehouse=2, in_production=0,\
354 1 pending demands
355T=112.0! d(id: 1, p: 0, c: 0, am: 5, ar: 22, dl: 200, me: T) statisfied
356T=112.0! 0 units of product 0 in warehouse
357T=144.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: T, st: 95, sp: 1)\
358 at station 0
359T=144.0! product=1, amount=7, in_warehouse=0, in_production=0,\
360 1 pending demands
361T=144.0! d(id: 2, p: 1, c: 0, am: 7, ar: 30, dl: 200, me: T) statisfied
362T=144.0 -- finished
365Now we want to stop the simulation measurement period before the last
366job completes. Notice that the last production jobs after time unit
36781 are no longer performed, because their end falls outside of the
368measurement period.
370>>> instance = Instance(
371... name="test3", n_products=2, n_customers=1, n_stations=2, n_demands=3,
372... time_end_warmup=21, time_end_measure=100,
373... routes=[[0, 1], [1, 0]],
374... demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200],
375... [2, 0, 1, 7, 30, 200]],
376... warehous_at_t0=[2, 1],
377... station_product_unit_times=[[[10.0, 50.0, 15.0, 100.0],
378... [ 5.0, 20.0, 7.0, 35.0, 4.0, 50.0]],
379... [[ 5.0, 24.0, 7.0, 80.0],
380... [ 3.0, 21.0, 6.0, 50.0,]]])
382>>> instance.name
383'test3'
385>>> simulation = Simulation(instance, PrintingListener(print_time=False))
386>>> simulation.ctrl_run()
387start
388T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
389T=0.0: 2 units of product 0 in warehouse
390T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
391T=0.0: 1 units of product 1 in warehouse
392T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
393 1 pending demands
394T=20.0: station=1, 1 jobs queued
395T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
396 at station 1
397T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
398 1 pending demands
399T=22.0! station=0, 1 jobs queued
400T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
401 at station 0
402T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
403 2 pending demands
404T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
405 at station 0
406T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
407 at station 1
408T=62.0! station=1, 2 jobs queued
409T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
410 at station 1
411T=62.0! station=0, 1 jobs queued
412T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
413 at station 0
414T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
415 at station 1
416T=95.0! station=1, 1 jobs queued
417T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
418 at station 1
419T=95.0 -- finished
421>>> simulation.ctrl_reset()
422>>> simulation.ctrl_run()
423start
424T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
425T=0.0: 2 units of product 0 in warehouse
426T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
427T=0.0: 1 units of product 1 in warehouse
428T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
429 1 pending demands
430T=20.0: station=1, 1 jobs queued
431T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
432 at station 1
433T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
434 1 pending demands
435T=22.0! station=0, 1 jobs queued
436T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
437 at station 0
438T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
439 2 pending demands
440T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
441 at station 0
442T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
443 at station 1
444T=62.0! station=1, 2 jobs queued
445T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
446 at station 1
447T=62.0! station=0, 1 jobs queued
448T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
449 at station 0
450T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
451 at station 1
452T=95.0! station=1, 1 jobs queued
453T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
454 at station 1
455T=95.0 -- finished
456"""
458from dataclasses import dataclass, field
459from heapq import heappop, heappush
460from time import time_ns
461from typing import Any, Callable, Final
463import numpy as np
464from pycommons.strings.string_conv import bool_to_str, float_to_str
465from pycommons.types import type_error
467from moptipyapps.prodsched.instance import (
468 Demand,
469 Instance,
470 compute_finish_time,
471)
474@dataclass(order=True, frozen=True)
475class _Event:
476 """The internal record for events in the simulation."""
478 #: When does the event happen?
479 when: float
480 #: Which function to call?
481 call: Callable = field(compare=False)
482 #: The arguments to pass to the function
483 args: tuple = field(compare=False)
486@dataclass(order=True, frozen=True)
487class Job:
488 """The record for a production job."""
490 #: the unique job id
491 job_id: int
492 #: the ID of the product to be produced.
493 product_id: int
494 #: the amount to produce
495 amount: int
496 #: the time when the job was issued
497 arrival: float
498 #: should the job be considered during measurement?
499 measure: bool
500 #: is the job completed?
501 completed: bool = False
502 #: the time when the job arrived at the queue of the current station.
503 station_time: float = -1.0
504 #: the current job step, starts at 0.
505 step: int = -1
507 def __str__(self) -> str:
508 """
509 Get a string representation of this job.
511 :return: the string representation
513 >>> str(Job(0, 1, 10, 0.5, True, False, 1.0, 0))
514 'j(id: 0, p: 1, am: 10, ar: 0.5, me: T, c: F, st: 1, sp: 0)'
515 """
516 fts: Final[Callable] = float_to_str
517 return (f"j(id: {self.job_id}, p: {self.product_id}, "
518 f"am: {self.amount}, ar: {fts(self.arrival)}, "
519 f"me: {bool_to_str(self.measure)}, "
520 f"c: {bool_to_str(self.completed)}, "
521 f"st: {fts(self.station_time)}, sp: {self.step})")
524class Listener:
525 """A listener for simulation events."""
527 def start(self) -> None:
528 """Get notification that the simulation is starting."""
530 def product_in_warehouse(
531 self, time: float, product_id: int, amount: int,
532 is_in_measure_period: bool) -> None:
533 """
534 Report a change of the amount of products in the warehouse.
536 :param time: the current time
537 :param product_id: the product ID
538 :param amount: the new absolute total amount of that product in the
539 warehouse
540 :param is_in_measure_period: is this event inside the measurement
541 period?
542 """
544 def produce_at_begin(
545 self, time: float, station_id: int, job: Job) -> None:
546 """
547 Report the start of the production of a certain product at a station.
549 :param time: the current time
550 :param station_id: the station ID
551 :param job: the production job
552 """
554 def produce_at_end(
555 self, time: float, station_id: int, job: Job) -> None:
556 """
557 Report the completion of the production of a product at a station.
559 :param time: the current time
560 :param station_id: the station ID
561 :param job: the production job
562 """
564 def demand_satisfied(
565 self, time: float, demand: Demand) -> None:
566 """
567 Report that a given demand has been satisfied.
569 :param time: the time index when the demand was satisfied
570 :param demand: the demand that was satisfied
571 """
573 def event_product(self, time: float, # pylint: disable=R0913,R0917
574 product_id: int, amount: int,
575 in_warehouse: int, in_production: int,
576 pending_demands: tuple[Demand, ...],
577 is_in_measure_period: bool) -> None:
578 """
579 Get notified right before :meth:`Simulation.event_product`.
581 :param time: the current system time
582 :param product_id: the id of the product
583 :param amount: the amount of the product that appears
584 :param in_warehouse: the amount of the product currently in the
585 warehouse
586 :param in_production: the amounf of product currently under production
587 :param pending_demands: the pending orders for the product
588 :param is_in_measure_period: is this event inside the measurement
589 period?
590 """
592 def event_station(self, time: float, station_id: int,
593 queue: tuple[Job, ...],
594 is_in_measure_period: bool) -> None:
595 """
596 Get notified right before :meth:`Simulation.event_station`.
598 If this event happens, the station is not busy. It could process a job
599 and there is at least one job that it could process. You can now
600 select the job to be executed from the `queue` and pass it to
601 :meth:`~Simulation.act_exec_job`.
603 :param time: the current time
604 :param station_id: the station ID
605 :param queue: the job queue for this station
606 :param is_in_measure_period: is this event inside the measurement
607 period?
608 """
610 def finished(self, time: float) -> None:
611 """
612 Be notified that the simulation has been finished.
614 :param time: the time when we are finished
615 """
618class Simulation: # pylint: disable=R0902
619 """A simulator for production scheduling."""
621 def __init__(self, instance: Instance, listener: Listener) -> None:
622 """
623 Initialize the simulator.
625 :param instance: the instance
626 :param listener: the listener
627 """
628 if not isinstance(instance, Instance):
629 raise type_error(instance, "instance", Instance)
630 if not isinstance(listener, Listener):
631 raise type_error(listener, "listener", Listener)
633 #: the instance whose data is simulated
634 self.instance: Final[Instance] = instance
635 #: the product routes
636 self.__routes: Final[tuple[tuple[int, ...], ...]] = instance.routes
637 #: the station-product-unit-times
638 self.__mput: Final[tuple[tuple[np.ndarray, ...], ...]] = (
639 instance.station_product_unit_times)
640 #: the end of the warmup period
641 self.__warmup: Final[float] = instance.time_end_warmup
642 #: the end of the measurement period
643 self.__measure: Final[float] = instance.time_end_measure
645 #: the start event function
646 self.__l_start: Final[Callable[[], None]] = listener.start
647 #: the product-level-in-warehouse-changed event function
648 self.__l_product_in_warehouse: Final[Callable[[
649 float, int, int, bool], None]] = listener.product_in_warehouse
650 #: the demand satisfied event function
651 self.__l_demand_satisfied: Final[Callable[[
652 float, Demand], None]] \
653 = listener.demand_satisfied
654 #: the listener to be notified if the production of a certain
655 #: product begins at a certain station.
656 self.__l_produce_at_begin: Final[Callable[[
657 float, int, Job], None]] = listener.produce_at_begin
658 #: the listener to be notified if the production of a certain
659 #: product end at a certain station.
660 self.__l_produce_at_end: Final[Callable[[
661 float, int, Job], None]] = listener.produce_at_end
662 #: the listener to notify about simulation end
663 self.__l_finished: Final[Callable[[float], None]] = listener.finished
664 #: the listener to notify about product events
665 self.__l_event_product: Final[Callable[[
666 float, int, int, int, int, tuple[Demand, ...], bool], None]] = \
667 listener.event_product
668 #: the listener to notify about station events
669 self.__l_event_station: Final[Callable[[
670 float, int, tuple[Job, ...], bool], None]] = \
671 listener.event_station
673 #: the current time
674 self.__time: float = 0.0
675 #: the internal event queue
676 self.__queue: Final[list[_Event]] = []
677 #: the internal list of pending demands
678 self.__pending_demands: Final[list[list[Demand]]] = [
679 [] for _ in range(instance.n_products)]
680 #: the internal list of the amount of product currently in production
681 self.__in_production: Final[list[int]] = [
682 0 for _ in range(instance.n_products)]
683 #: the internal warehouse
684 self.__warehouse: Final[list[int]] = [0] * instance.n_products
685 #: the station queues.
686 self.__mq: Final[list[list[Job]]] = [
687 [] for _ in range(instance.n_stations)]
688 #: whether the stations are busy
689 self.__mbusy: Final[list[bool]] = [False] * instance.n_stations
690 #: the job ID counter
691 self.__job_id: int = 0
693 def ctrl_reset(self) -> None:
694 """
695 Reset the simulation.
697 This function sets the time to 0, clears the event queue, clears
698 the pending orders list, clears the warehouse.
699 """
700 self.__time = 0.0
701 self.__queue.clear()
702 for i in range(self.instance.n_products):
703 self.__warehouse[i] = 0
704 self.__pending_demands[i].clear()
705 self.__in_production[i] = 0
706 for mq in self.__mq:
707 mq.clear()
708 for i in range(self.instance.n_stations):
709 self.__mbusy[i] = False
710 self.__job_id = 0
712 def ctrl_run(self) -> None:
713 """
714 Run the simulation.
716 This function executes the main loop of the simulation. It runs the
717 central event pump, which is a priority queue. It processes the
718 simulation events one by one.
719 """
720 self.__l_start()
721 queue: Final[list[_Event]] = self.__queue
723 #: fill the warehouse at time index 0
724 for product_id, amount in enumerate(self.instance.warehous_at_t0):
725 heappush(queue, _Event(0.0, self.__product_available, (
726 product_id, amount)))
727 #: fill in the customer demands/orders
728 for demand in self.instance.demands:
729 heappush(queue, _Event(
730 demand.arrival, self.__demand_issued, (demand, )))
732 while list.__len__(queue):
733 event: _Event = heappop(queue)
734 time: float = event.when
735 if time < self.__time:
736 raise ValueError(f"Event for {time} at time {self.__time}?")
737 self.__time = time
738 event.call(*event.args)
740 self.__l_finished(self.__time)
742 def act_demand_satisfied(self, demand: Demand) -> None:
743 """
744 Notify the system that a given demand has been satisfied.
746 :param demand: the demand that was satisfied
747 """
748 self.__pending_demands[demand.product_id].remove(demand)
749 self.__l_demand_satisfied(self.__time, demand)
751 def event_product(self, time: float, # pylint: disable=W0613,R0913,R0917
752 product_id: int, amount: int,
753 in_warehouse: int,
754 in_production: int, # pylint: disable=W0613
755 pending_demands: tuple[Demand, ...]) -> None:
756 """
757 Take actions when an event regarding a product or demand occurred.
759 The following events may have occurred:
761 1. An amount of a product has been produced (`amount > 0`).
762 2. An amount of a product has been made available at the start of the
763 simulation to form the initial amount in the warehouse
764 (`amount > 0`).
765 3. A customer demand for the product has appeared in the system. If
766 there is any demand to be fulfilled, then `pending_demands` is not
767 empty.
769 You can choose to execute one or multiple of the following actions:
771 1. :meth:`~Simulation.act_store_in_warehouse` to store a positive
772 amount of product in the warehouse.
773 2. :meth:`~Simulation.act_take_from_warehouse` to take a positive
774 amount of product out of the warehouse (must be `<= in_warehouse`.
775 3. :meth:`~Simulation.act_produce` to order the production of a
776 positive amount of the product.
777 4. :meth:`~Simulation.act_demand_satisfied` to mark one of the demands
778 from `queue` as satisfied. Notice that in this case, you must make
779 sure to remove the corresponding amount of product units from the
780 system. If sufficient units are in `amount`, you would simply not
781 store these in the warehouse. You could also simply take some units
782 out of the warehouse with :meth:`~act_take_from_warehouse`.
784 :param time: the current system time
785 :param product_id: the id of the product
786 :param amount: the amount of the product that appears
787 :param in_warehouse: the amount of the product currently in the
788 warehouse
789 :param in_production: the amounf of product currently under production
790 :param pending_demands: the pending orders for the product
791 """
792 dem_len: int = tuple.__len__(pending_demands)
793 if dem_len <= 0 < amount: # no demands + positive amount?
794 self.act_store_in_warehouse(product_id, amount) # store
795 return # ... and we are done
797 # Go through the list of demands and satisfy them on a first-come-
798 # first-serve basis.
799 total: int = in_warehouse + amount # The available units.
800 product_needed: int = 0 # The amount needed to satisfy the demands.
801 for demand in pending_demands:
802 demand_needed: int = demand.amount
803 if demand_needed <= total:
804 total -= demand_needed
805 self.act_demand_satisfied(demand)
806 continue
807 product_needed += demand_needed
809 #: Update the warehouse.
810 if total > in_warehouse:
811 self.act_store_in_warehouse(product_id, total - in_warehouse)
812 elif total < in_warehouse:
813 self.act_take_from_warehouse(product_id, in_warehouse - total)
815 # Order the production of the product units required to satisfy all
816 # demands.
817 product_needed -= total + in_production
818 if product_needed > 0:
819 self.act_produce(product_id, product_needed)
821 def event_station(self,
822 time: float, # pylint: disable=W0613
823 station_id: int, # pylint: disable=W0613
824 queue: tuple[Job, ...]) -> None:
825 """
826 Process an event for a given station.
828 If this event happens, the station is not busy. It could process a job
829 and there is at least one job that it could process. You can now
830 select the job to be executed from the `queue` and pass it to
831 :meth:`~Simulation.act_exec_job`.
833 :param time: the current time
834 :param station_id: the station ID
835 :param queue: the job queue for this station
836 """
837 self.act_exec_job(queue[0])
839 def act_exec_job(self, job: Job) -> None:
840 """
841 Execute the job on its current station.
843 :param job: the job to be executed
844 """
845 product_id: Final[int] = job.product_id
846 station_id: Final[int] = self.__routes[product_id][job.step]
847 time: Final[float] = self.__time
848 self.__mq[station_id].remove(job) # exception if job is not there
850 if self.__mbusy[station_id]:
851 raise ValueError("Cannot execute job on busy station.")
853 self.__mbusy[station_id] = True
854 self.__l_produce_at_begin(time, station_id, job)
856 end_time: float = compute_finish_time(
857 time, job.amount, self.__mput[station_id][product_id])
858 if end_time < self.__measure: # only simulate if within time window
859 heappush(self.__queue, _Event(end_time, self.__job_step, (job, )))
861 def act_store_in_warehouse(self, product_id: int, amount: int) -> None:
862 """
863 Add a certain amount of product to the warehouse.
865 :param product_id: the product ID
866 :param amount: the amount
867 """
868 if amount <= 0:
869 raise ValueError(
870 f"Cannot add amount {amount} of product {product_id}!")
871 wh: Final[int] = self.__warehouse[product_id] + amount
872 self.__warehouse[product_id] = wh
873 time: Final[float] = self.__time
874 self.__l_product_in_warehouse(
875 time, product_id, wh, self.__warmup <= time)
877 def act_take_from_warehouse(self, product_id: int, amount: int) -> None:
878 """
879 Remove a certain amount of product to the warehouse.
881 :param product_id: the product ID
882 :param amount: the amount
883 """
884 if amount <= 0:
885 raise ValueError(
886 f"Cannot remove amount {amount} of product {product_id}!")
887 wh: Final[int] = self.__warehouse[product_id] - amount
888 if wh < 0:
889 raise ValueError(
890 f"Cannot remove {amount} of product {product_id} from "
891 "warehouse if there are only "
892 f"{self.__warehouse[product_id]} units in it.")
893 self.__warehouse[product_id] = wh
894 time: Final[float] = self.__time
895 self.__l_product_in_warehouse(
896 time, product_id, wh, self.__warmup <= time)
898 def act_produce(self, product_id: int, amount: int) -> None:
899 """
900 Order the production of `amount` units of product.
902 :param product_id: the product ID
903 :param amount: the amount that needs to be produced
904 """
905 if amount <= 0:
906 raise ValueError(
907 f"Cannot produce {amount} units of product {product_id}.")
908 time: Final[float] = self.__time
909 jid: Final[int] = self.__job_id
910 self.__job_id = jid + 1
911 self.__job_step(Job(jid, product_id, amount, time,
912 self.__warmup <= time))
914 def __product_available(
915 self, product_id: int, amount: int) -> None:
916 """
917 Process that an amount of a product enters the warehouse.
919 :param time: the time when it enters the warehouse
920 :param product_id: the product ID
921 :param amount: the amount of the product that enters the warehouse
922 """
923 lst: Final[list[Demand]] = self.__pending_demands[product_id]
924 tp: Final[tuple] = tuple(lst) if list.__len__(lst) > 0 else ()
925 wh: Final[int] = self.__warehouse[product_id]
926 ip: Final[int] = self.__in_production[product_id]
927 time: Final[float] = self.__time
928 self.__l_event_product(time, product_id, amount, wh, ip, tp,
929 self.__warmup <= time)
930 self.event_product(time, product_id, amount, wh, ip, tp)
932 def __demand_issued(self, demand: Demand) -> None:
933 """
934 Process that a demand was issued by a customer.
936 :param demand: the demand record
937 """
938 time: float = self.__time
939 if demand.arrival != time:
940 raise ValueError(
941 f"Demand time {demand.arrival} != system time {time}")
942 product_id: int = demand.product_id
943 lst: list[Demand] = self.__pending_demands[product_id]
944 lst.append(demand)
945 tp: Final[tuple[Demand, ...]] = tuple(lst)
946 ip: Final[int] = self.__in_production[product_id]
947 iw: Final[int] = self.__warehouse[product_id]
948 self.__l_event_product(time, product_id, 0, iw, ip, tp,
949 self.__warmup <= time)
950 self.event_product(time, product_id, 0, iw, ip, tp)
952 def __job_step(self, job: Job) -> None:
953 """
954 Move a job a step forward.
956 If this job just enters the system, it gets enqueued at its first
957 station. If it was already running on a station, then that station
958 becomes idle and can process the next job. Our job now either moves to
959 the next station and enters the queue of that station OR, if it has
960 been completed, its produced product amount can enter the warehouse.
962 :param job: the job
963 """
964 product_id: Final[int] = job.product_id
965 routes: Final[tuple[int, ...]] = self.__routes[product_id]
966 time: Final[float] = self.__time
968 # WARNING: We only track jobs with issue time within the measurement
969 # period!
970 warm: Final[float] = self.__warmup
971 time_in_meas: Final[bool] = warm <= time
973 job_step: Final[int] = job.step
974 next_step: Final[int] = job_step + 1
975 completed: Final[bool] = next_step >= tuple.__len__(routes)
977 if job_step >= 0: # The job was running on a station.
978 old_station_id: Final[int] = routes[job_step]
979 if completed:
980 object.__setattr__(job, "completed", True)
981 self.__l_produce_at_end(time, old_station_id, job)
982 self.__mbusy[old_station_id] = False
983 old_mq: Final[list[Job]] = self.__mq[old_station_id]
984 if list.__len__(old_mq) > 0:
985 tupo: Final[tuple[Job, ...]] = tuple(old_mq)
986 self.__l_event_station(time, old_station_id, tupo,
987 time_in_meas)
988 self.event_station(time, old_station_id, tupo)
989 else:
990 self.__in_production[product_id] += job.amount
992 if completed:
993 self.__in_production[product_id] -= job.amount
994 self.__product_available(product_id, job.amount)
995 return
997 object.__setattr__(job, "step", next_step)
998 object.__setattr__(job, "station_time", time)
1000 new_station_id: Final[int] = routes[next_step]
1001 queue: list[Job] = self.__mq[new_station_id]
1002 queue.append(job)
1003 if not self.__mbusy[new_station_id]:
1004 tupq: Final[tuple[Job, ...]] = tuple(queue)
1005 self.__l_event_station(
1006 time, new_station_id, tupq, time_in_meas)
1007 self.event_station(time, new_station_id, tupq)
1010class PrintingListener(Listener):
1011 """A listener that just prints simulation events."""
1013 def __init__(self, output: Callable[[str], Any] = print,
1014 print_time: bool = True) -> None:
1015 """
1016 Initialize the printing listener.
1018 :param output: the output callable
1019 :param print_time: shall we print the time?
1020 """
1021 if not callable(output):
1022 raise type_error(output, "output", call=True)
1023 if not isinstance(print_time, bool):
1024 raise type_error(print_time, "print_time", bool)
1025 #: the output callable
1026 self.__output: Final[Callable[[str], Any]] = output
1027 #: shall we print the time at the end?
1028 self.__print_time: Final[bool] = print_time
1029 #: the internal start time
1030 self.__start_time_ns: int | None = None
1032 def start(self) -> None:
1033 """Print that the simulation begins."""
1034 self.__start_time_ns = time_ns()
1035 self.__output("start")
1037 def product_in_warehouse(
1038 self, time: float, product_id: int, amount: int,
1039 is_in_measure_period: bool) -> None:
1040 """Print the product amount in the warehouse."""
1041 self.__output(f"T={time}{'!' if is_in_measure_period else ':'} "
1042 f"{amount} units of product {product_id} in warehouse")
1044 def produce_at_begin(
1045 self, time: float, station_id: int, job: Job) -> None:
1046 """Print that the production at a given station begun."""
1047 self.__output(f"T={time}{'!' if job.measure else ':'} "
1048 f"start {job} at station {station_id}")
1050 def produce_at_end(self, time: float, station_id: int, job: Job) -> None:
1051 """Print that the production at a given station ended."""
1052 self.__output(f"T={time}{'!' if job.measure else ':'} "
1053 f"finished {job} at station {station_id}")
1055 def demand_satisfied(self, time: float, demand: Demand) -> None:
1056 """Print that a demand was satisfied."""
1057 self.__output(
1058 f"T={time}{'!' if demand.measure else ':'} {demand} statisfied")
1060 def event_product(self, time: float, # pylint: disable=R0913,R0917
1061 product_id: int, amount: int,
1062 in_warehouse: int, in_production: int,
1063 pending_demands: tuple[Demand, ...],
1064 is_in_measure_period: bool) -> None:
1065 """Print the prouct event."""
1066 self.__output(f"T={time}{'!' if is_in_measure_period else ':'} "
1067 f"product={product_id}, amount={amount}"
1068 f", in_warehouse={in_warehouse}, in_production="
1069 f"{in_production}, {tuple.__len__(pending_demands)} "
1070 "pending demands")
1072 def event_station(self, time: float, station_id: int,
1073 queue: tuple[Job, ...],
1074 is_in_measure_period: bool) -> None:
1075 """Print the station event."""
1076 self.__output(
1077 f"T={time}{'!' if is_in_measure_period else ':'} "
1078 f"station={station_id}, {tuple.__len__(queue)} jobs queued")
1080 def finished(self, time: float) -> None:
1081 """Print that the simulation has finished."""
1082 end: Final[int] = time_ns()
1083 self.__output(f"T={time} -- finished")
1084 if self.__print_time and self.__start_time_ns is not None:
1085 required: float = (end - self.__start_time_ns) / 1_000_000_000
1086 self.__output(f"Simulation time: {required}s")
1089def warmup() -> None:
1090 """
1091 Perform a warm-up for our simulator.
1093 The simulator uses some code implemented in numba etc., which may need to
1094 be jitted before the actual execution.
1096 >>> warmup()
1097 """
1098 instance = Instance(
1099 name="warmup", n_products=2, n_customers=1, n_stations=2, n_demands=2,
1100 time_end_warmup=10, time_end_measure=10000,
1101 routes=[[0, 1], [1, 0]],
1102 demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200]],
1103 warehous_at_t0=[2, 1],
1104 station_product_unit_times=[
1105 [[10.0, 50.0, 15.0, 100.0], [5.0, 20.0, 7.0, 35.0, 4.0, 50.0]],
1106 [[5.0, 24.0, 7.0, 80.0], [3.0, 21.0, 6.0, 50.0]]])
1107 Simulation(instance, Listener()).ctrl_run()