Coverage for moptipyapps / prodsched / simulation.py: 94%
237 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 04:40 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 04:40 +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 :class:`~moptipyapps.prodsched.instance.Instance`.
10Simulations have three groups of methods:
12- Methods starting with `ctrl_*` are for starting and resetting the
13 simulation so that it can be started again. You may override them if you
14 have additional need for initialization or clean-up.
15- Methods that start with `event_*` are methods that are invoked by the
16 simulator to notify you about an event in the simulation. You can overwrite
17 these methods to implement the logic of your production scheduling method.
18- Methods that start with `act_*` are actions that you can invoke inside the
19 `event_*` methods. The tell the simulator or stations what to do.
21An example of such specialized simulations is the
22:class:`~moptipyapps.prodsched.rop_simulation.ROPSimulation`,
23which simulates the behavior of a system that uses re-order points (ROPs) to
24decide what to produce and when.
25In such a simulation, the `event_*`-methods are overwritten to invoke the
26`act_*`-methods according to their needs.
27Here, in the base class :class:`~Simulation`, they are implemented such to
28order the production of product units directly upon the arrival of customer
29demands. In the :class:`~moptipyapps.prodsched.rop_simulation.ROPSimulation`
30on the other hand, products are produced base on re-order points.
32## We have the following `ctrl_*` methods:**
34- :meth:`~Simulation.ctrl_run` runs the simulation.
35- :meth:`~Simulation.ctrl_reset` resets the simulator so that we can start it
36 again. If you want to re-use a simulation, you need to first invoke
37 :meth:`~Simulation.ctrl_reset` to clear the internal state.
39## We have the following `event_*` methods:**
41- :meth:`~Simulation.event_product` is invoked by the simulation if one of the
42 following three things happened:
44 1. An amount of a product has been produced (`amount > 0`).
45 2. An amount of a product has been made available at the start of the
46 simulation to form the initial amount in the warehouse
47 (`amount > 0`).
48 3. A customer demand for the product has appeared in the system.
50 In this method, you can store product into the warehouse, remove product
51 from the warehouse, and/or mark a demand as completed.
54## Examples
56Here we have a very easy production scheduling instance.
57There is 1 product that passes through 2 stations.
58First it passes through station 0, then through station 1.
59The per-unit production time is always 10 time units on station 0 and 30 time
60units on station 2.
61There is one customer demand, for 10 units of this product, which enters the
62system at time unit 20.
63The warehouse is initially empty.
65>>> instance = Instance(
66... name="test1", n_products=1, n_customers=1, n_stations=2, n_demands=1,
67... time_end_warmup=10, time_end_measure=4000,
68... routes=[[0, 1]],
69... demands=[[0, 0, 0, 10, 20, 100]],
70... warehous_at_t0=[0],
71... station_product_unit_times=[[[10.0, 10000.0]],
72... [[30.0, 10000.0]]])
74The simulation will see that the customer demand for 10 units of product 0
75appears at time unit 20.
76It will issue a production order for these 10 units at station 0.
77Since station 0 is not occupied, it can immediately begin with the production.
78It will finish the production after 10*10 time units, i.e., at time unit 120.
79The product is then routed to station 1, which is also idle and can
80immediately begin producing.
81It needs 10*30 time units, meaning that it finishes after 300 time units.
82The demanded product amount is completed after 420 time units and the demand 0
83can be fulfilled.
85>>> simulation = Simulation(instance, PrintingListener(print_time=False))
86>>> simulation.ctrl_run()
87start
88T=0.0: product=0, amount=0, in_warehouse=0, in_production=0, 0 pending demands
89T=20.0! product=0, amount=0, in_warehouse=0, in_production=0,\
90 1 pending demands
91T=20.0! station=0, 1 jobs queued
92T=20.0! start j(id: 0, p: 0, am: 10, ar: 20, me: T, c: F, st: 20, sp: 0)\
93 at station 0
94T=120.0! finished j(id: 0, p: 0, am: 10, ar: 20, me: T, c: F, st: 20, sp: 0)\
95 at station 0
96T=120.0! station=1, 1 jobs queued
97T=120.0! start j(id: 0, p: 0, am: 10, ar: 20, me: T, c: F, st: 120, sp: 1)\
98 at station 1
99T=420.0! finished j(id: 0, p: 0, am: 10, ar: 20, me: T, c: T, st: 120, sp: 1)\
100 at station 1
101T=420.0! product=0, amount=10, in_warehouse=0, in_production=0,\
102 1 pending demands
103T=420.0! d(id: 0, p: 0, c: 0, am: 10, ar: 20, dl: 100, me: T) statisfied
104T=420.0 -- finished
106>>> instance = Instance(
107... name="test2", n_products=2, n_customers=1, n_stations=2, n_demands=3,
108... time_end_warmup=21, time_end_measure=10000,
109... routes=[[0, 1], [1, 0]],
110... demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200],
111... [2, 0, 1, 7, 30, 200]],
112... warehous_at_t0=[2, 1],
113... station_product_unit_times=[[[10.0, 50.0, 15.0, 100.0],
114... [ 5.0, 20.0, 7.0, 35.0, 4.0, 50.0]],
115... [[ 5.0, 24.0, 7.0, 80.0],
116... [ 3.0, 21.0, 6.0, 50.0,]]])
118>>> instance.name
119'test2'
121>>> simulation = Simulation(instance, PrintingListener(print_time=False))
122>>> simulation.ctrl_run()
123start
124T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
125T=0.0: 2 units of product 0 in warehouse
126T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
127T=0.0: 1 units of product 1 in warehouse
128T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
129 1 pending demands
130T=20.0: station=1, 1 jobs queued
131T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
132 at station 1
133T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
134 1 pending demands
135T=22.0! station=0, 1 jobs queued
136T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
137 at station 0
138T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
139 2 pending demands
140T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
141 at station 0
142T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
143 at station 1
144T=62.0! station=1, 2 jobs queued
145T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
146 at station 1
147T=62.0! station=0, 1 jobs queued
148T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
149 at station 0
150T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
151 at station 1
152T=95.0! station=1, 1 jobs queued
153T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
154 at station 1
155T=107.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: T, st: 62, sp: 1)\
156 at station 0
157T=107.0! station=0, 1 jobs queued
158T=107.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 95, sp: 1)\
159 at station 0
160T=107.0! product=1, amount=9, in_warehouse=1, in_production=7,\
161 2 pending demands
162T=107.0: d(id: 0, p: 1, c: 0, am: 10, ar: 20, dl: 90, me: F) statisfied
163T=107.0! 0 units of product 1 in warehouse
164T=112.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: T, st: 52, sp: 1)\
165 at station 1
166T=112.0! product=0, amount=3, in_warehouse=2, in_production=0,\
167 1 pending demands
168T=112.0! d(id: 1, p: 0, c: 0, am: 5, ar: 22, dl: 200, me: T) statisfied
169T=112.0! 0 units of product 0 in warehouse
170T=144.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: T, st: 95, sp: 1)\
171 at station 0
172T=144.0! product=1, amount=7, in_warehouse=0, in_production=0,\
173 1 pending demands
174T=144.0! d(id: 2, p: 1, c: 0, am: 7, ar: 30, dl: 200, me: T) statisfied
175T=144.0 -- finished
178>>> simulation.ctrl_reset()
179>>> simulation.ctrl_run()
180start
181T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
182T=0.0: 2 units of product 0 in warehouse
183T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
184T=0.0: 1 units of product 1 in warehouse
185T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
186 1 pending demands
187T=20.0: station=1, 1 jobs queued
188T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
189 at station 1
190T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
191 1 pending demands
192T=22.0! station=0, 1 jobs queued
193T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
194 at station 0
195T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
196 2 pending demands
197T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
198 at station 0
199T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
200 at station 1
201T=62.0! station=1, 2 jobs queued
202T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
203 at station 1
204T=62.0! station=0, 1 jobs queued
205T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
206 at station 0
207T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
208 at station 1
209T=95.0! station=1, 1 jobs queued
210T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
211 at station 1
212T=107.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: T, st: 62, sp: 1)\
213 at station 0
214T=107.0! station=0, 1 jobs queued
215T=107.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 95, sp: 1)\
216 at station 0
217T=107.0! product=1, amount=9, in_warehouse=1, in_production=7,\
218 2 pending demands
219T=107.0: d(id: 0, p: 1, c: 0, am: 10, ar: 20, dl: 90, me: F) statisfied
220T=107.0! 0 units of product 1 in warehouse
221T=112.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: T, st: 52, sp: 1)\
222 at station 1
223T=112.0! product=0, amount=3, in_warehouse=2, in_production=0,\
224 1 pending demands
225T=112.0! d(id: 1, p: 0, c: 0, am: 5, ar: 22, dl: 200, me: T) statisfied
226T=112.0! 0 units of product 0 in warehouse
227T=144.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: T, st: 95, sp: 1)\
228 at station 0
229T=144.0! product=1, amount=7, in_warehouse=0, in_production=0,\
230 1 pending demands
231T=144.0! d(id: 2, p: 1, c: 0, am: 7, ar: 30, dl: 200, me: T) statisfied
232T=144.0 -- finished
235Now we want to stop the simulation measurement period before the last
236job completes. Notice that the last production jobs after time unit
23781.xxx are no longer performed, because their end falls outside of the
238measurement period.
240>>> instance = Instance(
241... name="test3", n_products=2, n_customers=1, n_stations=2, n_demands=3,
242... time_end_warmup=21, time_end_measure=100,
243... routes=[[0, 1], [1, 0]],
244... demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200],
245... [2, 0, 1, 7, 30, 200]],
246... warehous_at_t0=[2, 1],
247... station_product_unit_times=[[[10.0, 50.0, 15.0, 100.0],
248... [ 5.0, 20.0, 7.0, 35.0, 4.0, 50.0]],
249... [[ 5.0, 24.0, 7.0, 80.0],
250... [ 3.0, 21.0, 6.0, 50.0,]]])
252>>> instance.name
253'test3'
255>>> simulation = Simulation(instance, PrintingListener(print_time=False))
256>>> simulation.ctrl_run()
257start
258T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
259T=0.0: 2 units of product 0 in warehouse
260T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
261T=0.0: 1 units of product 1 in warehouse
262T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
263 1 pending demands
264T=20.0: station=1, 1 jobs queued
265T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
266 at station 1
267T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
268 1 pending demands
269T=22.0! station=0, 1 jobs queued
270T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
271 at station 0
272T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
273 2 pending demands
274T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
275 at station 0
276T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
277 at station 1
278T=62.0! station=1, 2 jobs queued
279T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
280 at station 1
281T=62.0! station=0, 1 jobs queued
282T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
283 at station 0
284T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
285 at station 1
286T=95.0! station=1, 1 jobs queued
287T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
288 at station 1
289T=95.0 -- finished
291>>> simulation.ctrl_reset()
292>>> simulation.ctrl_run()
293start
294T=0.0: product=0, amount=2, in_warehouse=0, in_production=0, 0 pending demands
295T=0.0: 2 units of product 0 in warehouse
296T=0.0: product=1, amount=1, in_warehouse=0, in_production=0, 0 pending demands
297T=0.0: 1 units of product 1 in warehouse
298T=20.0: product=1, amount=0, in_warehouse=1, in_production=0,\
299 1 pending demands
300T=20.0: station=1, 1 jobs queued
301T=20.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
302 at station 1
303T=22.0! product=0, amount=0, in_warehouse=2, in_production=0,\
304 1 pending demands
305T=22.0! station=0, 1 jobs queued
306T=22.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
307 at station 0
308T=30.0! product=1, amount=0, in_warehouse=1, in_production=9,\
309 2 pending demands
310T=52.0! finished j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 22, sp: 0)\
311 at station 0
312T=62.0: finished j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 20, sp: 0)\
313 at station 1
314T=62.0! station=1, 2 jobs queued
315T=62.0! start j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
316 at station 1
317T=62.0! station=0, 1 jobs queued
318T=62.0: start j(id: 0, p: 1, am: 9, ar: 20, me: F, c: F, st: 62, sp: 1)\
319 at station 0
320T=95.0! finished j(id: 2, p: 1, am: 7, ar: 30, me: T, c: F, st: 30, sp: 0)\
321 at station 1
322T=95.0! station=1, 1 jobs queued
323T=95.0! start j(id: 1, p: 0, am: 3, ar: 22, me: T, c: F, st: 52, sp: 1)\
324 at station 1
325T=95.0 -- finished
326"""
328from dataclasses import dataclass, field
329from heapq import heappop, heappush
330from time import time_ns
331from typing import Any, Callable, Final
333import numpy as np
334from pycommons.strings.string_conv import bool_to_str, float_to_str
335from pycommons.types import type_error
337from moptipyapps.prodsched.instance import (
338 Demand,
339 Instance,
340 compute_finish_time,
341)
344@dataclass(order=True, frozen=True)
345class _Event:
346 """The internal record for events in the simulation."""
348 #: When does the event happen?
349 when: float
350 #: Which function to call?
351 call: Callable = field(compare=False)
352 #: The arguments to pass to the function
353 args: tuple = field(compare=False)
356@dataclass(order=True, frozen=True)
357class Job:
358 """The record for a production job."""
360 #: the unique job id
361 job_id: int
362 #: the ID of the product to be produced.
363 product_id: int
364 #: the amount to produce
365 amount: int
366 #: the time when the job was issued
367 arrival: float
368 #: should the job be considered during measurement?
369 measure: bool
370 #: is the job completed?
371 completed: bool = False
372 #: the time when the job arrived at the queue of the current station.
373 station_time: float = -1.0
374 #: the current job step, starts at 0.
375 step: int = -1
377 def __str__(self) -> str:
378 """
379 Get a string representation of this job.
381 :return: the string representation
383 >>> str(Job(0, 1, 10, 0.5, True, False, 1.0, 0))
384 'j(id: 0, p: 1, am: 10, ar: 0.5, me: T, c: F, st: 1, sp: 0)'
385 """
386 fts: Final[Callable] = float_to_str
387 return (f"j(id: {self.job_id}, p: {self.product_id}, "
388 f"am: {self.amount}, ar: {fts(self.arrival)}, "
389 f"me: {bool_to_str(self.measure)}, "
390 f"c: {bool_to_str(self.completed)}, "
391 f"st: {fts(self.station_time)}, sp: {self.step})")
394class Listener:
395 """A listener for simulation events."""
397 def start(self) -> None:
398 """Get notification that the simulation is starting."""
400 def product_in_warehouse(
401 self, time: float, product_id: int, amount: int,
402 is_in_measure_period: bool) -> None:
403 """
404 Report a change of the amount of products in the warehouse.
406 :param time: the current time
407 :param product_id: the product ID
408 :param amount: the new absolute total amount of that product in the
409 warehouse
410 :param is_in_measure_period: is this event inside the measurement
411 period?
412 """
414 def produce_at_begin(
415 self, time: float, station_id: int, job: Job) -> None:
416 """
417 Report the start of the production of a certain product at a station.
419 :param time: the current time
420 :param station_id: the station ID
421 :param job: the production job
422 """
424 def produce_at_end(
425 self, time: float, station_id: int, job: Job) -> None:
426 """
427 Report the completion of the production of a product at a station.
429 :param time: the current time
430 :param station_id: the station ID
431 :param job: the production job
432 """
434 def demand_satisfied(
435 self, time: float, demand: Demand) -> None:
436 """
437 Report that a given demand has been satisfied.
439 :param time: the time index when the demand was satisfied
440 :param demand: the demand that was satisfied
441 """
443 def event_product(self, time: float, # pylint: disable=R0913,R0917
444 product_id: int, amount: int,
445 in_warehouse: int, in_production: int,
446 pending_demands: tuple[Demand, ...],
447 is_in_measure_period: bool) -> None:
448 """
449 Get notified right before :meth:`Simulation.event_product`.
451 :param time: the current system time
452 :param product_id: the id of the product
453 :param amount: the amount of the product that appears
454 :param in_warehouse: the amount of the product currently in the
455 warehouse
456 :param in_production: the amounf of product currently under production
457 :param pending_demands: the pending orders for the product
458 :param is_in_measure_period: is this event inside the measurement
459 period?
460 """
462 def event_station(self, time: float, station_id: int,
463 queue: tuple[Job, ...],
464 is_in_measure_period: bool) -> None:
465 """
466 Get notified right before :meth:`Simulation.event_station`.
468 If this event happens, the station is not busy. It could process a job
469 and there is at least one job that it could process. You can now
470 select the job to be executed from the `queue` and pass it to
471 :meth:`~Simulation.act_exec_job`.
473 :param time: the current time
474 :param station_id: the station ID
475 :param queue: the job queue for this station
476 :param is_in_measure_period: is this event inside the measurement
477 period?
478 """
480 def finished(self, time: float) -> None:
481 """
482 Be notified that the simulation has been finished.
484 :param time: the time when we are finished
485 """
488class Simulation: # pylint: disable=R0902
489 """A simulator for production scheduling."""
491 def __init__(self, instance: Instance, listener: Listener) -> None:
492 """
493 Initialize the simulator.
495 :param instance: the instance
496 :param listener: the listener
497 """
498 if not isinstance(instance, Instance):
499 raise type_error(instance, "instance", Instance)
500 if not isinstance(listener, Listener):
501 raise type_error(listener, "listener", Listener)
503 #: the instance whose data is simulated
504 self.instance: Final[Instance] = instance
505 #: the product routes
506 self.__routes: Final[tuple[tuple[int, ...], ...]] = instance.routes
507 #: the station-product-unit-times
508 self.__mput: Final[tuple[tuple[np.ndarray, ...], ...]] = (
509 instance.station_product_unit_times)
510 #: the end of the warmup period
511 self.__warmup: Final[float] = instance.time_end_warmup
512 #: the end of the measurement period
513 self.__measure: Final[float] = instance.time_end_measure
515 #: the start event function
516 self.__l_start: Final[Callable[[], None]] = listener.start
517 #: the product-level-in-warehouse-changed event function
518 self.__l_product_in_warehouse: Final[Callable[[
519 float, int, int, bool], None]] = listener.product_in_warehouse
520 #: the demand satisfied event function
521 self.__l_demand_satisfied: Final[Callable[[
522 float, Demand], None]] \
523 = listener.demand_satisfied
524 #: the listener to be notified if the production of a certain
525 #: product begins at a certain station.
526 self.__l_produce_at_begin: Final[Callable[[
527 float, int, Job], None]] = listener.produce_at_begin
528 #: the listener to be notified if the production of a certain
529 #: product end at a certain station.
530 self.__l_produce_at_end: Final[Callable[[
531 float, int, Job], None]] = listener.produce_at_end
532 #: the listener to notify about simulation end
533 self.__l_finished: Final[Callable[[float], None]] = listener.finished
534 #: the listener to notify about product events
535 self.__l_event_product: Final[Callable[[
536 float, int, int, int, int, tuple[Demand, ...], bool], None]] = \
537 listener.event_product
538 #: the listener to notify about station events
539 self.__l_event_station: Final[Callable[[
540 float, int, tuple[Job, ...], bool], None]] = \
541 listener.event_station
543 #: the current time
544 self.__time: float = 0.0
545 #: the internal event queue
546 self.__queue: Final[list[_Event]] = []
547 #: the internal list of pending demands
548 self.__pending_demands: Final[list[list[Demand]]] = [
549 [] for _ in range(instance.n_products)]
550 #: the internal list of the amount of product currently in production
551 self.__in_production: Final[list[int]] = [
552 0 for _ in range(instance.n_products)]
553 #: the internal warehouse
554 self.__warehouse: Final[list[int]] = [0] * instance.n_products
555 #: the station queues.
556 self.__mq: Final[list[list[Job]]] = [
557 [] for _ in range(instance.n_stations)]
558 #: whether the stations are busy
559 self.__mbusy: Final[list[bool]] = [False] * instance.n_stations
560 #: the job ID counter
561 self.__job_id: int = 0
563 def ctrl_reset(self) -> None:
564 """
565 Reset the simulation.
567 This function sets the time to 0, clears the event queue, clears
568 the pending orders list, clears the warehouse.
569 """
570 self.__time = 0.0
571 self.__queue.clear()
572 for i in range(self.instance.n_products):
573 self.__warehouse[i] = 0
574 self.__pending_demands[i].clear()
575 self.__in_production[i] = 0
576 for mq in self.__mq:
577 mq.clear()
578 for i in range(self.instance.n_stations):
579 self.__mbusy[i] = False
580 self.__job_id = 0
582 def ctrl_run(self) -> None:
583 """
584 Run the simulation.
586 This function executes the main loop of the simulation. It runs the
587 central event pump, which is a priority queue. It processes the
588 simulation events one by one.
589 """
590 self.__l_start()
591 queue: Final[list[_Event]] = self.__queue
593 #: fill the warehouse at time index 0
594 for product_id, amount in enumerate(self.instance.warehous_at_t0):
595 heappush(queue, _Event(0.0, self.__product_available, (
596 product_id, amount)))
597 #: fill in the customer demands/orders
598 for demand in self.instance.demands:
599 heappush(queue, _Event(
600 demand.arrival, self.__demand_issued, (demand, )))
602 while list.__len__(queue):
603 event: _Event = heappop(queue)
604 time: float = event.when
605 if time < self.__time:
606 raise ValueError(f"Event for {time} at time {self.__time}?")
607 self.__time = time
608 event.call(*event.args)
610 self.__l_finished(self.__time)
612 def act_demand_satisfied(self, demand: Demand) -> None:
613 """
614 Notify the system that a given demand has been satisfied.
616 :param demand: the demand that was satisfied
617 """
618 self.__pending_demands[demand.product_id].remove(demand)
619 self.__l_demand_satisfied(self.__time, demand)
621 def event_product(self, time: float, # pylint: disable=W0613,R0913,R0917
622 product_id: int, amount: int,
623 in_warehouse: int,
624 in_production: int, # pylint: disable=W0613
625 pending_demands: tuple[Demand, ...]) -> None:
626 """
627 Take actions when an event regarding a product or demand occurred.
629 The following events may have occurred:
631 1. An amount of a product has been produced (`amount > 0`).
632 2. An amount of a product has been made available at the start of the
633 simulation to form the initial amount in the warehouse
634 (`amount > 0`).
635 3. A customer demand for the product has appeared in the system. If
636 there is any demand to be fulfilled, then `pending_demands` is not
637 empty.
639 You can choose to execute one or multiple of the following actions:
641 1. :meth:`~Simulation.act_store_in_warehouse` to store a positive
642 amount of product in the warehouse.
643 2. :meth:`~Simulation.act_take_from_warehouse` to take a positive
644 amount of product out of the warehouse (must be `<= in_warehouse`.
645 3. :meth:`~Simulation.act_produce` to order the production of a
646 positive amount of the product.
647 4. :meth:`~Simulation.act_demand_satisfied` to mark one of the demands
648 from `queue` as satisfied. Notice that in this case, you must make
649 sure to remove the corresponding amount of product units from the
650 system. If sufficient units are in `amount`, you would simply not
651 store these in the warehouse. You could also simply take some units
652 out of the warehouse with :meth:`~act_take_from_warehouse`.
654 :param time: the current system time
655 :param product_id: the id of the product
656 :param amount: the amount of the product that appears
657 :param in_warehouse: the amount of the product currently in the
658 warehouse
659 :param in_production: the amounf of product currently under production
660 :param pending_demands: the pending orders for the product
661 """
662 dem_len: int = tuple.__len__(pending_demands)
663 if dem_len <= 0 < amount: # no demands + positive amount?
664 self.act_store_in_warehouse(product_id, amount) # store
665 return # ... and we are done
667 # Go through the list of demands and satisfy them on a first-come-
668 # first-serve basis.
669 total: int = in_warehouse + amount # The available units.
670 product_needed: int = 0 # The amount needed to satisfy the demands.
671 for demand in pending_demands:
672 demand_needed: int = demand.amount
673 if demand_needed <= total:
674 total -= demand_needed
675 self.act_demand_satisfied(demand)
676 continue
677 product_needed += demand_needed
679 #: Update the warehouse.
680 if total > in_warehouse:
681 self.act_store_in_warehouse(product_id, total - in_warehouse)
682 elif total < in_warehouse:
683 self.act_take_from_warehouse(product_id, in_warehouse - total)
685 # Order the production of the product units required to satisfy all
686 # demands.
687 product_needed -= total + in_production
688 if product_needed > 0:
689 self.act_produce(product_id, product_needed)
691 def event_station(self,
692 time: float, # pylint: disable=W0613
693 station_id: int, # pylint: disable=W0613
694 queue: tuple[Job, ...]) -> None:
695 """
696 Process an event for a given station.
698 If this event happens, the station is not busy. It could process a job
699 and there is at least one job that it could process. You can now
700 select the job to be executed from the `queue` and pass it to
701 :meth:`~Simulation.act_exec_job`.
703 :param time: the current time
704 :param station_id: the station ID
705 :param queue: the job queue for this station
706 """
707 self.act_exec_job(queue[0])
709 def act_exec_job(self, job: Job) -> None:
710 """
711 Execute the job on its current station.
713 :param job: the job to be executed
714 """
715 product_id: Final[int] = job.product_id
716 station_id: Final[int] = self.__routes[product_id][job.step]
717 time: Final[float] = self.__time
718 self.__mq[station_id].remove(job) # exception if job is not there
720 if self.__mbusy[station_id]:
721 raise ValueError("Cannot execute job on busy station.")
723 self.__mbusy[station_id] = True
724 self.__l_produce_at_begin(time, station_id, job)
726 end_time: float = compute_finish_time(
727 time, job.amount, self.__mput[station_id][product_id])
728 if end_time < self.__measure: # only simulate if within time window
729 heappush(self.__queue, _Event(end_time, self.__job_step, (job, )))
731 def act_store_in_warehouse(self, product_id: int, amount: int) -> None:
732 """
733 Add a certain amount of product to the warehouse.
735 :param product_id: the product ID
736 :param amount: the amount
737 """
738 if amount <= 0:
739 raise ValueError(
740 f"Cannot add amount {amount} of product {product_id}!")
741 wh: Final[int] = self.__warehouse[product_id] + amount
742 self.__warehouse[product_id] = wh
743 time: Final[float] = self.__time
744 self.__l_product_in_warehouse(
745 time, product_id, wh, self.__warmup <= time)
747 def act_take_from_warehouse(self, product_id: int, amount: int) -> None:
748 """
749 Remove a certain amount of product to the warehouse.
751 :param product_id: the product ID
752 :param amount: the amount
753 """
754 if amount <= 0:
755 raise ValueError(
756 f"Cannot remove amount {amount} of product {product_id}!")
757 wh: Final[int] = self.__warehouse[product_id] - amount
758 if wh < 0:
759 raise ValueError(
760 f"Cannot remove {amount} of product {product_id} from "
761 "warehouse if there are only "
762 f"{self.__warehouse[product_id]} units in it.")
763 self.__warehouse[product_id] = wh
764 time: Final[float] = self.__time
765 self.__l_product_in_warehouse(
766 time, product_id, wh, self.__warmup <= time)
768 def act_produce(self, product_id: int, amount: int) -> None:
769 """
770 Order the production of `amount` units of product.
772 :param product_id: the product ID
773 :param amount: the amount that needs to be produced
774 """
775 if amount <= 0:
776 raise ValueError(
777 f"Cannot produce {amount} units of product {product_id}.")
778 time: Final[float] = self.__time
779 jid: Final[int] = self.__job_id
780 self.__job_id = jid + 1
781 self.__job_step(Job(jid, product_id, amount, time,
782 self.__warmup <= time))
784 def __product_available(
785 self, product_id: int, amount: int) -> None:
786 """
787 Process that an amount of a product enters the warehouse.
789 :param time: the time when it enters the warehouse
790 :param product_id: the product ID
791 :param amount: the amount of the product that enters the warehouse
792 """
793 lst: Final[list[Demand]] = self.__pending_demands[product_id]
794 tp: Final[tuple] = tuple(lst) if list.__len__(lst) > 0 else ()
795 wh: Final[int] = self.__warehouse[product_id]
796 ip: Final[int] = self.__in_production[product_id]
797 time: Final[float] = self.__time
798 self.__l_event_product(time, product_id, amount, wh, ip, tp,
799 self.__warmup <= time)
800 self.event_product(time, product_id, amount, wh, ip, tp)
802 def __demand_issued(self, demand: Demand) -> None:
803 """
804 Process that a demand was issued by a customer.
806 :param demand: the demand record
807 """
808 time: float = self.__time
809 if demand.arrival != time:
810 raise ValueError(
811 f"Demand time {demand.arrival} != system time {time}")
812 product_id: int = demand.product_id
813 lst: list[Demand] = self.__pending_demands[product_id]
814 lst.append(demand)
815 tp: Final[tuple[Demand, ...]] = tuple(lst)
816 ip: Final[int] = self.__in_production[product_id]
817 iw: Final[int] = self.__warehouse[product_id]
818 self.__l_event_product(time, product_id, 0, iw, ip, tp,
819 self.__warmup <= time)
820 self.event_product(time, product_id, 0, iw, ip, tp)
822 def __job_step(self, job: Job) -> None:
823 """
824 Move a job a step forward.
826 If this job just enters the system, it gets enqueued at its first
827 station. If it was already running on a station, then that station
828 becomes idle and can process the next job. Our job now either moves to
829 the next station and enters the queue of that station OR, if it has
830 been completed, its produced product amount can enter the warehouse.
832 :param job: the job
833 """
834 product_id: Final[int] = job.product_id
835 routes: Final[tuple[int, ...]] = self.__routes[product_id]
836 time: Final[float] = self.__time
838 # WARNING: We only track jobs with issue time within the measurement
839 # period!
840 warm: Final[float] = self.__warmup
841 time_in_meas: Final[bool] = warm <= time
843 job_step: Final[int] = job.step
844 next_step: Final[int] = job_step + 1
845 completed: Final[bool] = next_step >= tuple.__len__(routes)
847 if job_step >= 0: # The job was running on a station.
848 old_station_id: Final[int] = routes[job_step]
849 if completed:
850 object.__setattr__(job, "completed", True)
851 self.__l_produce_at_end(time, old_station_id, job)
852 self.__mbusy[old_station_id] = False
853 old_mq: Final[list[Job]] = self.__mq[old_station_id]
854 if list.__len__(old_mq) > 0:
855 tupo: Final[tuple[Job, ...]] = tuple(old_mq)
856 self.__l_event_station(time, old_station_id, tupo,
857 time_in_meas)
858 self.event_station(time, old_station_id, tupo)
859 else:
860 self.__in_production[product_id] += job.amount
862 if completed:
863 self.__in_production[product_id] -= job.amount
864 self.__product_available(product_id, job.amount)
865 return
867 object.__setattr__(job, "step", next_step)
868 object.__setattr__(job, "station_time", time)
870 new_station_id: Final[int] = routes[next_step]
871 queue: list[Job] = self.__mq[new_station_id]
872 queue.append(job)
873 if not self.__mbusy[new_station_id]:
874 tupq: Final[tuple[Job, ...]] = tuple(queue)
875 self.__l_event_station(
876 time, new_station_id, tupq, time_in_meas)
877 self.event_station(time, new_station_id, tupq)
880class PrintingListener(Listener):
881 """A listener that just prints simulation events."""
883 def __init__(self, output: Callable[[str], Any] = print,
884 print_time: bool = True) -> None:
885 """
886 Initialize the printing listener.
888 :param output: the output callable
889 :param print_time: shall we print the time?
890 """
891 if not callable(output):
892 raise type_error(output, "output", call=True)
893 if not isinstance(print_time, bool):
894 raise type_error(print_time, "print_time", bool)
895 #: the output callable
896 self.__output: Final[Callable[[str], Any]] = output
897 #: shall we print the time at the end?
898 self.__print_time: Final[bool] = print_time
899 #: the internal start time
900 self.__start_time_ns: int | None = None
902 def start(self) -> None:
903 """Print that the simulation begins."""
904 self.__start_time_ns = time_ns()
905 self.__output("start")
907 def product_in_warehouse(
908 self, time: float, product_id: int, amount: int,
909 is_in_measure_period: bool) -> None:
910 """Print the product amount in the warehouse."""
911 self.__output(f"T={time}{'!' if is_in_measure_period else ':'} "
912 f"{amount} units of product {product_id} in warehouse")
914 def produce_at_begin(
915 self, time: float, station_id: int, job: Job) -> None:
916 """Print that the production at a given station begun."""
917 self.__output(f"T={time}{'!' if job.measure else ':'} "
918 f"start {job} at station {station_id}")
920 def produce_at_end(self, time: float, station_id: int, job: Job) -> None:
921 """Print that the production at a given station ended."""
922 self.__output(f"T={time}{'!' if job.measure else ':'} "
923 f"finished {job} at station {station_id}")
925 def demand_satisfied(self, time: float, demand: Demand) -> None:
926 """Print that a demand was satisfied."""
927 self.__output(
928 f"T={time}{'!' if demand.measure else ':'} {demand} statisfied")
930 def event_product(self, time: float, # pylint: disable=R0913,R0917
931 product_id: int, amount: int,
932 in_warehouse: int, in_production: int,
933 pending_demands: tuple[Demand, ...],
934 is_in_measure_period: bool) -> None:
935 """Print the prouct event."""
936 self.__output(f"T={time}{'!' if is_in_measure_period else ':'} "
937 f"product={product_id}, amount={amount}"
938 f", in_warehouse={in_warehouse}, in_production="
939 f"{in_production}, {tuple.__len__(pending_demands)} "
940 "pending demands")
942 def event_station(self, time: float, station_id: int,
943 queue: tuple[Job, ...],
944 is_in_measure_period: bool) -> None:
945 """Print the station event."""
946 self.__output(
947 f"T={time}{'!' if is_in_measure_period else ':'} "
948 f"station={station_id}, {tuple.__len__(queue)} jobs queued")
950 def finished(self, time: float) -> None:
951 """Print that the simulation has finished."""
952 end: Final[int] = time_ns()
953 self.__output(f"T={time} -- finished")
954 if self.__print_time and self.__start_time_ns is not None:
955 required: float = (end - self.__start_time_ns) / 1_000_000_000
956 self.__output(f"Simulation time: {required}s")
959def warmup() -> None:
960 """
961 Perform a warm-up for our simulator.
963 The simulator uses some code implemented in numba etc., which may need to
964 be jitted before the actual execution.
966 >>> warmup()
967 """
968 instance = Instance(
969 name="warmup", n_products=2, n_customers=1, n_stations=2, n_demands=2,
970 time_end_warmup=10, time_end_measure=10000,
971 routes=[[0, 1], [1, 0]],
972 demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200]],
973 warehous_at_t0=[2, 1],
974 station_product_unit_times=[
975 [[10.0, 50.0, 15.0, 100.0], [5.0, 20.0, 7.0, 35.0, 4.0, 50.0]],
976 [[5.0, 24.0, 7.0, 80.0], [3.0, 21.0, 6.0, 50.0]]])
977 Simulation(instance, Listener()).ctrl_run()