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

1""" 

2A simulator for production scheduling. 

3 

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`. 

9 

10Simulations have three groups of methods: 

11 

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. 

20 

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. 

31 

32## We have the following `ctrl_*` methods:** 

33 

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. 

38 

39## We have the following `event_*` methods:** 

40 

41- :meth:`~Simulation.event_product` is invoked by the simulation if one of the 

42 following three things happened: 

43 

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. 

49 

50 In this method, you can store product into the warehouse, remove product 

51 from the warehouse, and/or mark a demand as completed. 

52 

53 

54## Examples 

55 

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. 

64 

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]]]) 

73 

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. 

84 

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 

105 

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,]]]) 

117 

118>>> instance.name 

119'test2' 

120 

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 

176 

177 

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 

233 

234 

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. 

239 

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,]]]) 

251 

252>>> instance.name 

253'test3' 

254 

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 

290 

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""" 

327 

328from dataclasses import dataclass, field 

329from heapq import heappop, heappush 

330from time import time_ns 

331from typing import Any, Callable, Final 

332 

333import numpy as np 

334from pycommons.strings.string_conv import bool_to_str, float_to_str 

335from pycommons.types import type_error 

336 

337from moptipyapps.prodsched.instance import ( 

338 Demand, 

339 Instance, 

340 compute_finish_time, 

341) 

342 

343 

344@dataclass(order=True, frozen=True) 

345class _Event: 

346 """The internal record for events in the simulation.""" 

347 

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) 

354 

355 

356@dataclass(order=True, frozen=True) 

357class Job: 

358 """The record for a production job.""" 

359 

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 

376 

377 def __str__(self) -> str: 

378 """ 

379 Get a string representation of this job. 

380 

381 :return: the string representation 

382 

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})") 

392 

393 

394class Listener: 

395 """A listener for simulation events.""" 

396 

397 def start(self) -> None: 

398 """Get notification that the simulation is starting.""" 

399 

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. 

405 

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 """ 

413 

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. 

418 

419 :param time: the current time 

420 :param station_id: the station ID 

421 :param job: the production job 

422 """ 

423 

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. 

428 

429 :param time: the current time 

430 :param station_id: the station ID 

431 :param job: the production job 

432 """ 

433 

434 def demand_satisfied( 

435 self, time: float, demand: Demand) -> None: 

436 """ 

437 Report that a given demand has been satisfied. 

438 

439 :param time: the time index when the demand was satisfied 

440 :param demand: the demand that was satisfied 

441 """ 

442 

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`. 

450 

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 """ 

461 

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`. 

467 

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`. 

472 

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 """ 

479 

480 def finished(self, time: float) -> None: 

481 """ 

482 Be notified that the simulation has been finished. 

483 

484 :param time: the time when we are finished 

485 """ 

486 

487 

488class Simulation: # pylint: disable=R0902 

489 """A simulator for production scheduling.""" 

490 

491 def __init__(self, instance: Instance, listener: Listener) -> None: 

492 """ 

493 Initialize the simulator. 

494 

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) 

502 

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 

514 

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 

542 

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 

562 

563 def ctrl_reset(self) -> None: 

564 """ 

565 Reset the simulation. 

566 

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 

581 

582 def ctrl_run(self) -> None: 

583 """ 

584 Run the simulation. 

585 

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 

592 

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, ))) 

601 

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) 

609 

610 self.__l_finished(self.__time) 

611 

612 def act_demand_satisfied(self, demand: Demand) -> None: 

613 """ 

614 Notify the system that a given demand has been satisfied. 

615 

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) 

620 

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. 

628 

629 The following events may have occurred: 

630 

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. 

638 

639 You can choose to execute one or multiple of the following actions: 

640 

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`. 

653 

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 

666 

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 

678 

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) 

684 

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) 

690 

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. 

697 

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`. 

702 

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]) 

708 

709 def act_exec_job(self, job: Job) -> None: 

710 """ 

711 Execute the job on its current station. 

712 

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 

719 

720 if self.__mbusy[station_id]: 

721 raise ValueError("Cannot execute job on busy station.") 

722 

723 self.__mbusy[station_id] = True 

724 self.__l_produce_at_begin(time, station_id, job) 

725 

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, ))) 

730 

731 def act_store_in_warehouse(self, product_id: int, amount: int) -> None: 

732 """ 

733 Add a certain amount of product to the warehouse. 

734 

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) 

746 

747 def act_take_from_warehouse(self, product_id: int, amount: int) -> None: 

748 """ 

749 Remove a certain amount of product to the warehouse. 

750 

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) 

767 

768 def act_produce(self, product_id: int, amount: int) -> None: 

769 """ 

770 Order the production of `amount` units of product. 

771 

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)) 

783 

784 def __product_available( 

785 self, product_id: int, amount: int) -> None: 

786 """ 

787 Process that an amount of a product enters the warehouse. 

788 

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) 

801 

802 def __demand_issued(self, demand: Demand) -> None: 

803 """ 

804 Process that a demand was issued by a customer. 

805 

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) 

821 

822 def __job_step(self, job: Job) -> None: 

823 """ 

824 Move a job a step forward. 

825 

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. 

831 

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 

837 

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 

842 

843 job_step: Final[int] = job.step 

844 next_step: Final[int] = job_step + 1 

845 completed: Final[bool] = next_step >= tuple.__len__(routes) 

846 

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 

861 

862 if completed: 

863 self.__in_production[product_id] -= job.amount 

864 self.__product_available(product_id, job.amount) 

865 return 

866 

867 object.__setattr__(job, "step", next_step) 

868 object.__setattr__(job, "station_time", time) 

869 

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) 

878 

879 

880class PrintingListener(Listener): 

881 """A listener that just prints simulation events.""" 

882 

883 def __init__(self, output: Callable[[str], Any] = print, 

884 print_time: bool = True) -> None: 

885 """ 

886 Initialize the printing listener. 

887 

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 

901 

902 def start(self) -> None: 

903 """Print that the simulation begins.""" 

904 self.__start_time_ns = time_ns() 

905 self.__output("start") 

906 

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") 

913 

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}") 

919 

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}") 

924 

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") 

929 

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") 

941 

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") 

949 

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") 

957 

958 

959def warmup() -> None: 

960 """ 

961 Perform a warm-up for our simulator. 

962 

963 The simulator uses some code implemented in numba etc., which may need to 

964 be jitted before the actual execution. 

965 

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()