Coverage for moptipyapps / prodsched / instance.py: 79%

663 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-30 03:25 +0000

1""" 

2A production scheduling instance. 

3 

4Each production scheduling instance has a given :attr:`~Instance.name`. 

5It represents once concrete scenario of a material flow / factory production 

6scenario. 

7The factory has :attr:`~Instance.n_stations` work stations, e.g., machines 

8that perform a certain production step. 

9The factory also produces a set of :attr:`~Instance.n_products` different 

10products. 

11Each product passes through a set of work stations in a certain, pre-defined 

12order (and may even pass through the same machine multiple times). 

13The route each product takes is defined by the :attr:`~Instance.routes` 

14matrix. 

15Each product unit requires a certain time at each work station. 

16These times follow a certain random distribution. 

17 

18Ther are customer demands (:class:`~Demand`) that appear in the system at 

19certain :attr:`~Demand.arrival` times. The demand is not known to the system 

20before its :attr:`~Demand.arrival` time, so we cannot really anticipate it. 

21However, when it arrives at the :attr:`~Demand.arrival` time, it has a certain 

22:attr:`~Demand.deadline` by which it should be completed. Normally, this is 

23the same as the :attr:`~Demand.arrival` time, but it could also be later in 

24some scenarios. Each demand has a unique :attr:`~Demand.demand_id` and is 

25issued by a certain customer with a certain :attr:`~Demand.customer_id`. 

26In many MFC scenarios, the customers do not matter. 

27What always matters is the :attr:`~Demand.product_id`, though, which 

28identifies the product that the customer ordered, as well as the 

29:attr:`~Demand.amount` of that product that the customer ordered (which is 

30normally 1, but could be an arbitary positive integer). 

31 

32To summarize so far: 

33We have a factory that can produce :attr:`~Instance.n_products` different 

34products, which have IDs from `0` to `n_products-1`. 

35The factory uses :attr:`~Instance.n_stations` work stations (with IDs from 

36`0` to `n_stations-1`) for that purpose. 

37The :attr:`~Instance.routes` matrix defines which product is processed by 

38which work station and in which order. 

39The product with ID `i` passes through the work stations `routes[i]`, which 

40is tuple of work station IDs. 

41 

42The :attr:`~Instance.n_customers` customers issue :attr:`~Instance.n_demands` 

43demands. Each demand has a unique ID :attr:`~Demand.demand_id` and appears in 

44the system at the :attr:`~Demand.arrival` time. It is for 

45:attr:`~Demand.amount` units of the product with 

46ID :attr:`~Demand.product_id`. 

47It should be satisfied until :attr:`~Demand.deadline`. 

48 

49So the factory is producing the products with the goal to satisfy the demands 

50as soon as possible. 

51This leaves the last piece of the puzzle: 

52How long does it take to manufacture a unit of a given product? 

53This is regulated by the three-dimensional matrix 

54:attr:`~Instance.station_product_unit_times`. 

55If we want to produce one unit of a product with a given ID `p`, we can use 

56the :attr:`~Instance.routes` matrix (`routes[p]`) to determine through which 

57machines this unit needs to pass. 

58If it arrives at a machine with ID `m`, then we look up the 

59:class:`~numpy.ndarray` of prodcution slots and times under 

60`station_product_unit_times[m][p]`. 

61 

62This array stores (flat) pairs of time window ends and unit production times. 

63The function :func:`~compute_finish_time` then tells us when the unit of the 

64production would be finished on this machine. 

65It would pass to the next machine prescribed in `routes[p]` until it has 

66passed all machines and is finished. 

67 

68Notice that all the elements of an :class:`Instance` are **deterministic**. 

69The demands come in at specific, fixed times and are for fixed amounts of 

70fixed products. 

71They are stored in :attr:`~Instance.demands`. 

72Of course, any realistic simulation would only get to *see* them at their 

73:attr:`~Demand.arrival` times, but they are from a deterministic sequence 

74nonetheless. 

75Also, the production times are fixed and stored in the 3D array 

76:attr:`~Instance.station_product_unit_times`. 

77 

78Even the initial amount :attr:`~Instance.warehous_at_t0` of products that is 

79available in the warehouse at time step 0 is fixed. 

80Each instance also prescribes a fixed warm-up time 

81:attr:`~Instance.time_end_warmup` (that must be ignored during the performance 

82metric computation) and end time :attr:`~Instance.time_end_measure` for the 

83simulation. 

84 

85Everything is specified, deterministic, and fixed. 

86 

87Of course, that being said, you can still generate instances randomly. 

88Indeed, in :mod:`~moptipyapps.prodsched.mfc_generator`, we do exactly that. 

89So you get the best of both worlds: 

90You can generate many different instances where work times and demands follow 

91the distributions of your liking. 

92And you can run fully-reproducible simulations (using the base class 

93:class:`~moptipyapps.prodsched.simulation.Simulation`). 

94 

95Because if all events and times that would normally be "random" are hard-coded 

96in an :class:`~Instance`, then two simulations with the same "factory 

97operating system" will yield the exactly same behavior. 

98 

99Instances can be converted to a text stream that you can store in a text file 

100by using the function :func:`to_stream`. 

101You can load them from a text stream using function :func:`from_stream`. 

102If you want to store multiple instances in a directory as text files, you can 

103use :func:`store_instances`. 

104To load a set of instances from a directory, you can use 

105:func:`load_instances`. 

106The class :class:`~moptipyapps.prodsched.simulation.Simulation` in module 

107:mod:`~moptipyapps.prodsched.simulation` offers the ability to run a fully 

108reproducible simulation based on an :class:`~Instance` and to pipe out events 

109and data via a :class:`~moptipyapps.prodsched.simulation.Listener` interface. 

110 

111>>> name = "my_instance" 

112 

113The number of products be 3. 

114 

115>>> n_products = 3 

116 

117The number of customers be 5. 

118 

119>>> n_customers = 5 

120 

121The number of stations be 4. 

122 

123>>> n_stations = 4 

124 

125There will be 6 customer demands. 

126 

127>>> n_demands = 6 

128 

129The end of the warmup period. 

130 

131>>> time_end_warmup = 10 

132 

133The end of the measurement period. 

134 

135>>> time_end_measure = 10000 

136 

137Each product may take a different route through different stations. 

138 

139>>> route_p0 = [0, 3, 2] 

140>>> route_p1 = [0, 2, 1, 3] 

141>>> route_p2 = [1, 2, 3] 

142>>> routes = [route_p0, route_p1, route_p2] 

143 

144Each demand is a tuple of demand_id, customer_id, product_id, amount, 

145release time, and deadline. 

146 

147>>> d0 = [0, 0, 1, 20, 1240, 3000] 

148>>> d1 = [1, 1, 0, 10, 2300, 4000] 

149>>> d2 = [2, 2, 2, 7, 8300, 11000] 

150>>> d3 = [3, 3, 1, 12, 7300, 9000] 

151>>> d4 = [4, 4, 2, 23, 5410, 16720] 

152>>> d5 = [5, 3, 0, 19, 4234, 27080] 

153>>> demands = [d0, d1, d2, d3, d4, d5] 

154 

155There is a fixed amount of each product in the warehouse at time step 0. 

156 

157>>> warehous_at_t0 = [10, 0, 6] 

158 

159Each station requires a certain working time for each unit of each product. 

160This production time may vary over time. 

161For example, maybe station 0 needs 10 time units for 1 unit of product 0 from 

162time step 0 to time step 19, then 11 time units from time step 20 to 39, then 

1638 time units from time step 40 to 59. 

164These times are cyclic, meaning that at time step 60 to 79, it will again need 

16510 time units, and so on. 

166Of course, production times are only specified for stations that a product is 

167actually routed through. 

168 

169>>> m0_p0 = [10.0, 20.0, 11.0, 40.0, 8.0, 60.0] 

170>>> m0_p1 = [12.0, 20.0, 7.0, 40.0, 11.0, 70.0] 

171>>> m0_p2 = [] 

172>>> m1_p0 = [] 

173>>> m1_p1 = [20.0, 50.0, 30.0, 120.0, 7.0, 200.0] 

174>>> m1_p2 = [21.0, 50.0, 29.0, 130.0, 8.0, 190.0] 

175>>> m2_p0 = [ 8.0, 20.0, 9.0, 60.0] 

176>>> m2_p1 = [10.0, 90.0] 

177>>> m2_p2 = [12.0, 70.0, 30.0, 120.0] 

178>>> m3_p0 = [70.0, 200.0, 3.0, 220.0] 

179>>> m3_p1 = [60.0, 220.0, 5.0, 260.0] 

180>>> m3_p2 = [30.0, 210.0, 10.0, 300.0] 

181>>> station_product_unit_times = [[m0_p0, m0_p1, m0_p2], 

182... [m1_p0, m1_p1, m1_p2], 

183... [m2_p0, m2_p1, m2_p2], 

184... [m3_p0, m3_p1, m3_p2]] 

185 

186We can (but do not need to) provide additional information as key-value pairs. 

187 

188>>> infos = {"source": "manually created", 

189... "creation_date": "2025-11-09"} 

190 

191From all of this data, we can create the instance. 

192 

193>>> instance = Instance(name, n_products, n_customers, n_stations, n_demands, 

194... time_end_warmup, time_end_measure, 

195... routes, demands, warehous_at_t0, 

196... station_product_unit_times, infos) 

197>>> instance.name 

198'my_instance' 

199 

200>>> instance.n_customers 

2015 

202 

203>>> instance.n_stations 

2044 

205 

206>>> instance.n_demands 

2076 

208 

209>>> instance.n_products 

2103 

211 

212>>> instance.routes 

213((0, 3, 2), (0, 2, 1, 3), (1, 2, 3)) 

214 

215>>> instance.time_end_warmup 

21610.0 

217 

218>>> instance.time_end_measure 

21910000.0 

220 

221>>> instance.demands 

222(Demand(arrival=1240.0, deadline=3000.0, demand_id=0, customer_id=0,\ 

223 product_id=1, amount=20, measure=True),\ 

224 Demand(arrival=2300.0, deadline=4000.0, demand_id=1, customer_id=1,\ 

225 product_id=0, amount=10, measure=True),\ 

226 Demand(arrival=4234.0, deadline=27080.0, demand_id=5, customer_id=3,\ 

227 product_id=0, amount=19, measure=True),\ 

228 Demand(arrival=5410.0, deadline=16720.0, demand_id=4, customer_id=4,\ 

229 product_id=2, amount=23, measure=True),\ 

230 Demand(arrival=7300.0, deadline=9000.0, demand_id=3, customer_id=3,\ 

231 product_id=1, amount=12, measure=True),\ 

232 Demand(arrival=8300.0, deadline=11000.0, demand_id=2, customer_id=2,\ 

233 product_id=2, amount=7, measure=True)) 

234 

235>>> instance.warehous_at_t0 

236(10, 0, 6) 

237 

238>>> instance.station_product_unit_times 

239((array([10., 20., 11., 40., 8., 60.]), \ 

240array([12., 20., 7., 40., 11., 70.]), array([], dtype=float64)), (\ 

241array([], dtype=float64), array([ 20., 50., 30., 120., 7., 200.]), \ 

242array([ 21., 50., 29., 130., 8., 190.])), (array([ 8., 20., 9., 60.]), \ 

243array([10., 90.]), array([ 12., 70., 30., 120.])), (\ 

244array([ 70., 200., 3., 220.]), array([ 60., 220., 5., 260.]), array(\ 

245[ 30., 210., 10., 300.]))) 

246 

247>>> instance.n_measurable_demands 

2486 

249 

250>>> instance.n_measurable_demands_per_product 

251(2, 2, 2) 

252 

253>>> dict(instance.infos) 

254{'source': 'manually created', 'creation_date': '2025-11-09'} 

255 

256We can serialize instances to a stream of strings and also load them back 

257from a stream of strings. 

258Here, we store `instance` to a stream. 

259We then load the independent instance `i2` from that stream. 

260 

261>>> i2 = from_stream(to_stream(instance)) 

262>>> i2 is instance 

263False 

264>>> i2 == instance 

265True 

266 

267You can see that the loaded instance has the same data as the stored one. 

268 

269>>> i2.name == instance.name 

270True 

271>>> i2.n_customers == instance.n_customers 

272True 

273>>> i2.n_stations == instance.n_stations 

274True 

275>>> i2.n_demands == instance.n_demands 

276True 

277>>> i2.n_products == instance.n_products 

278True 

279>>> i2.routes == instance.routes 

280True 

281>>> i2.demands == instance.demands 

282True 

283>>> i2.time_end_warmup == instance.time_end_warmup 

284True 

285>>> i2.time_end_measure == instance.time_end_measure 

286True 

287>>> i2.warehous_at_t0 == instance.warehous_at_t0 

288True 

289>>> eq: bool = True 

290>>> for i in range(i2.n_stations): 

291... ma1 = i2.station_product_unit_times[i] 

292... ma2 = instance.station_product_unit_times[i] 

293... for j in range(i2.n_products): 

294... pr1 = ma1[j] 

295... pr2 = ma2[j] 

296... if not np.array_equal(pr1, pr2): 

297... eq = False 

298>>> eq 

299True 

300 

301True 

302>>> i2.infos == instance.infos 

303True 

304""" 

305 

306from dataclasses import dataclass 

307from itertools import batched 

308from math import ceil, isfinite 

309from string import ascii_letters, digits 

310from typing import ( 

311 Callable, 

312 Final, 

313 Generator, 

314 Iterable, 

315 Iterator, 

316 Mapping, 

317 cast, 

318) 

319 

320import numba # type: ignore 

321import numpy as np 

322from moptipy.api.component import Component 

323from moptipy.utils.logger import ( 

324 COMMENT_START, 

325 KEY_VALUE_SEPARATOR, 

326) 

327from moptipy.utils.strings import sanitize_name 

328from pycommons.ds.cache import repr_cache 

329from pycommons.ds.immutable_map import immutable_mapping 

330from pycommons.io.csv import CSV_SEPARATOR 

331from pycommons.io.path import Path, directory_path, write_lines 

332from pycommons.math.int_math import try_int 

333from pycommons.strings.string_conv import bool_to_str, float_to_str, num_to_str 

334from pycommons.types import check_int_range, check_to_int_range, type_error 

335 

336#: The maximum for the number of stations, products, or customers. 

337MAX_ID: Final[int] = 1_000_000_000 

338 

339#: No value bigger than this is permitted in any tuple anywhere. 

340MAX_VALUE: Final[int] = 2_147_483_647 

341 

342#: the index of the demand ID 

343DEMAND_ID: Final[int] = 0 

344#: the index of the customer ID 

345DEMAND_CUSTOMER: Final[int] = 1 

346#: the index of the product ID 

347DEMAND_PRODUCT: Final[int] = 2 

348#: the index of the demanded amount 

349DEMAND_AMOUNT: Final[int] = 3 

350#: the index of the demand release time 

351DEMAND_ARRIVAL: Final[int] = 4 

352#: the index of the demand deadline 

353DEMAND_DEADLINE: Final[int] = 5 

354 

355 

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

357class Demand(Iterable[int | float]): 

358 """ 

359 The record for demands. 

360 

361 Each demand has an :attr:`~Demand.arrival` time at which point it enters 

362 the system. It has a unique :attr:`~Demand.demand_id`. It has a 

363 :attr:`~Demand.deadline`, at which point the customer 

364 :attr:`~Demand.customer_id` who issued the demand expects to receive the 

365 :attr:`~Demand.amount` units of the product :attr:`~Demand.product_id` 

366 that they ordered. 

367 

368 Demands with the :attr:`~Demand.measure` flag set fall into the simulation 

369 time window where performance metrics are gathered. Demands where 

370 :attr:`~Demand.measure` is `False` will be irgnored during the performance 

371 evaluation, because they fall into the setup time. Their processing must 

372 still be simulated, though. 

373 

374 >>> Demand(arrival=0.6, deadline=0.8, demand_id=1, 

375 ... customer_id=2, product_id=6, amount=12, measure=True) 

376 Demand(arrival=0.6, deadline=0.8, demand_id=1, customer_id=2,\ 

377 product_id=6, amount=12, measure=True) 

378 >>> Demand(arrival=16, deadline=28, demand_id=1, 

379 ... customer_id=2, product_id=6, amount=12, measure=False) 

380 Demand(arrival=16.0, deadline=28.0, demand_id=1, customer_id=2,\ 

381 product_id=6, amount=12, measure=False) 

382 """ 

383 

384 #: the arrival time, i.e., when the demand enters the system 

385 arrival: float 

386 #: the deadline, i.e., when the customer expects the result 

387 deadline: float 

388 #: the ID of the demand 

389 demand_id: int 

390 #: the customer ID 

391 customer_id: int 

392 #: the ID of the product 

393 product_id: int 

394 #: the amount 

395 amount: int 

396 #: is this demand measurement relevant? 

397 measure: bool 

398 

399 def __init__(self, arrival: int | float, 

400 deadline: int | float, demand_id: int, 

401 customer_id: int, product_id: int, amount: int, 

402 measure: bool) -> None: 

403 """ 

404 Initialize the record. 

405 

406 :param arrival: the arrival time 

407 :param deadline: the deadline 

408 :param demand_id: the demand id 

409 :param customer_id: the customer id 

410 :param product_id: the product id 

411 :param amount: the amount 

412 :param measure: is this demand relevant for measurement? 

413 """ 

414 if isinstance(arrival, int): 

415 t: float = float(arrival) 

416 if t != arrival: 

417 raise ValueError(f"invalid arrival time {arrival}") 

418 arrival = t 

419 if not isinstance(arrival, float): 

420 raise type_error(arrival, "arrival", float) 

421 if not (isfinite(arrival) and ( 

422 0 < arrival < MAX_VALUE)): 

423 raise ValueError(f"invalid arrival={arrival}") 

424 

425 if isinstance(deadline, int): 

426 t = float(deadline) 

427 if t != deadline: 

428 raise ValueError(f"invalid deadline time {deadline}") 

429 deadline = t 

430 if not isinstance(deadline, float): 

431 raise type_error(deadline, "deadline", float) 

432 if not (isfinite(deadline) and (0 < deadline < MAX_VALUE)): 

433 raise ValueError(f"invalid deadline={deadline}") 

434 

435 if deadline < arrival: 

436 raise ValueError( 

437 f"arrival={arrival} and deadline={deadline}") 

438 object.__setattr__(self, "arrival", arrival) 

439 object.__setattr__(self, "deadline", deadline) 

440 object.__setattr__(self, "demand_id", check_int_range( 

441 demand_id, "demand_id", 0, MAX_ID)) 

442 object.__setattr__(self, "customer_id", check_int_range( 

443 customer_id, "customer_id", 0, MAX_ID)) 

444 object.__setattr__(self, "product_id", check_int_range( 

445 product_id, "product_id", 0, MAX_ID)) 

446 object.__setattr__(self, "amount", check_int_range( 

447 amount, "amount", 1, MAX_ID)) 

448 if not isinstance(measure, bool): 

449 raise type_error(measure, "measure", bool) 

450 object.__setattr__(self, "measure", measure) 

451 

452 def __str__(self) -> str: 

453 """ 

454 Get a short string representation of the demand. 

455 

456 :return: the string representation 

457 

458 >>> str(Demand(arrival=16, deadline=28.0, demand_id=1, 

459 ... customer_id=2, product_id=6, amount=12, measure=False)) 

460 'd(id: 1, p: 6, c: 2, am: 12, ar: 16, dl: 28, me: F)' 

461 """ 

462 fts: Final[Callable] = float_to_str 

463 return (f"d(id: {self.demand_id}, p: {self.product_id}, " 

464 f"c: {self.customer_id}, am: {self.amount}, " 

465 f"ar: {fts(self.arrival)}, dl: {fts(self.deadline)}, " 

466 f"me: {bool_to_str(self.measure)})") 

467 

468 def __getitem__(self, item: int) -> int | float: 

469 """ 

470 Access an element of this demand via an index. 

471 

472 :param item: the index 

473 :return: the demand value at that index 

474 

475 >>> d = Demand(arrival=16, deadline=28, demand_id=1, 

476 ... customer_id=2, product_id=6, amount=12, measure=True) 

477 >>> d[0] 

478 1 

479 >>> d[1] 

480 2 

481 >>> d[2] 

482 6 

483 >>> d[3] 

484 12 

485 >>> d[4] 

486 16 

487 >>> d[5] 

488 28 

489 """ 

490 if item == DEMAND_ID: 

491 return self.demand_id 

492 if item == DEMAND_CUSTOMER: 

493 return self.customer_id 

494 if item == DEMAND_PRODUCT: 

495 return self.product_id 

496 if item == DEMAND_AMOUNT: 

497 return self.amount 

498 if item == DEMAND_ARRIVAL: 

499 return try_int(self.arrival) 

500 if item == DEMAND_DEADLINE: 

501 return try_int(self.deadline) 

502 raise IndexError( 

503 f"index {item} out of bounds [0,{DEMAND_DEADLINE}].") 

504 

505 def __iter__(self) -> Iterator[int | float]: 

506 """ 

507 Iterate over the values in this demand. 

508 

509 :return: the demand iterable 

510 

511 >>> d = Demand(arrival=16, deadline=28, demand_id=1, 

512 ... customer_id=2, product_id=6, amount=12, measure=True) 

513 >>> list(d) 

514 [1, 2, 6, 12, 16, 28] 

515 """ 

516 yield self.demand_id # DEMAND_ID 

517 yield self.customer_id # DEMAND_CUSTOMER 

518 yield self.product_id # DEMAND_PRODUCT: 

519 yield self.amount # DEMAND_AMOUNT: 

520 yield try_int(self.arrival) # DEMAND_ARRIVAL 

521 yield try_int(self.deadline) # DEMAND_DEADLINE 

522 

523 def __len__(self) -> int: 

524 """ 

525 Get the length of the demand record. 

526 

527 :returns `6`: always 

528 

529 >>> len(Demand(arrival=16, deadline=28, demand_id=1, 

530 ... customer_id=2, product_id=6, amount=12, measure=True)) 

531 6 

532 """ 

533 return 6 

534 

535 

536def __to_tuple(source: Iterable[int | float], 

537 cache: Callable, empty_ok: bool = False, 

538 type_var: type = int) -> tuple: 

539 """ 

540 Convert an iterable to a tuple with values of a given type. 

541 

542 :param source: the data source 

543 :param cache: the cache 

544 :param empty_ok: are empty tuples OK? 

545 :param type_var: the type variable 

546 :return: the tuple 

547 

548 >>> ppl = repr_cache() 

549 >>> k1 = __to_tuple([1, 2, 3], ppl) 

550 >>> print(k1) 

551 (1, 2, 3) 

552 

553 >>> __to_tuple({2}, ppl) 

554 (2,) 

555 

556 >>> k2 = __to_tuple([1, 2, 3], ppl) 

557 >>> print(k2) 

558 (1, 2, 3) 

559 >>> k1 is k2 

560 True 

561 

562 >>> k3 = __to_tuple([3.4, 2.3, 3.1], ppl, type_var=float) 

563 >>> print(k3) 

564 (3.4, 2.3, 3.1) 

565 >>> k1 is k3 

566 False 

567 

568 >>> k4 = __to_tuple([3.4, 2.3, 3.1], ppl, type_var=float) 

569 >>> print(k4) 

570 (3.4, 2.3, 3.1) 

571 >>> k3 is k4 

572 True 

573 

574 >>> try: 

575 ... __to_tuple([], ppl) 

576 ... except Exception as e: 

577 ... print(e) 

578 row has length 0. 

579 

580 >>> __to_tuple([], ppl, empty_ok=True) 

581 () 

582 

583 >>> try: 

584 ... __to_tuple([1, 2.0], ppl) 

585 ... except Exception as e: 

586 ... print(e) 

587 row[1] should be an instance of int but is float, namely 2.0. 

588 

589 >>> try: 

590 ... __to_tuple([1.1, 2.0, 4, 3.4], ppl, type_var=float) 

591 ... except Exception as e: 

592 ... print(e) 

593 row[2] should be an instance of float but is int, namely 4. 

594 """ 

595 use_row = source if isinstance(source, tuple) else tuple(source) 

596 if (tuple.__len__(use_row) <= 0) and (not empty_ok): 

597 raise ValueError("row has length 0.") 

598 for j, v in enumerate(use_row): 

599 if not isinstance(v, type_var): 

600 raise type_error(v, f"row[{j}]", type_var) 

601 if not (isfinite(v) and (0 <= v <= MAX_VALUE)): # type: ignore 

602 raise ValueError(f"row[{j}]={v} not in 0..{MAX_VALUE}") 

603 

604 return cache(use_row) 

605 

606 

607def __to_npfloats(source: Iterable[int | float], # pylint: disable=W1113 

608 cache: Callable, empty_ok: bool = False, 

609 *_) -> np.ndarray: # pylint: disable=W1113 

610 """ 

611 Convert to numpy floats. 

612 

613 :param source: the source data 

614 :param cache: the cache 

615 :param empty_ok: are empty arrays OK? 

616 :return: the arrays 

617 

618 >>> ppl = repr_cache() 

619 >>> a = __to_npfloats([3.4, 2.3, 3.1], ppl) 

620 >>> a 

621 array([3.4, 2.3, 3.1]) 

622 >>> b = __to_npfloats([3.4, 2.3, 3.1], ppl) 

623 >>> b is a 

624 True 

625 

626 >>> c = __to_npfloats([], ppl, empty_ok=True) 

627 >>> c 

628 array([], dtype=float64) 

629 

630 >>> d = __to_npfloats([], ppl, empty_ok=True) 

631 >>> d is c 

632 True 

633 """ 

634 return cache(np.array(__to_tuple( 

635 source, cache, empty_ok, float), np.float64)) 

636 

637 

638def __to_nested_tuples(source: Iterable, 

639 cache: Callable, empty_ok: bool = False, 

640 type_var: type = int, 

641 inner: Callable = __to_tuple) -> tuple: 

642 """ 

643 Turn nested iterables of ints into nested tuples. 

644 

645 :param source: the source list 

646 :param cache: the cache 

647 :param empty_ok: are empty tuples OK? 

648 :param type_var: the type variable 

649 :param inner: the inner function 

650 :return: the tuple or array 

651 

652 >>> ppl = repr_cache() 

653 >>> k1 = __to_nested_tuples([(1, 2), [3, 2]], ppl) 

654 >>> print(k1) 

655 ((1, 2), (3, 2)) 

656 

657 >>> k2 = __to_nested_tuples([(1, 2), (1, 2, 4), (1, 2)], ppl) 

658 >>> print(k2) 

659 ((1, 2), (1, 2, 4), (1, 2)) 

660 

661 >>> k2[0] is k2[2] 

662 True 

663 

664 >>> k1[0] is k2[0] 

665 True 

666 >>> k1[0] is k2[2] 

667 True 

668 

669 >>> __to_nested_tuples([(1, 2), (1, 2, 4), (1, 2), []], ppl, True) 

670 ((1, 2), (1, 2, 4), (1, 2), ()) 

671 

672 >>> __to_nested_tuples([(), {}, []], ppl, True) 

673 () 

674 

675 >>> __to_nested_tuples([(1.0, 2.4), (1.0, 2.2)], ppl, True, float) 

676 ((1.0, 2.4), (1.0, 2.2)) 

677 """ 

678 if not isinstance(source, Iterable): 

679 raise type_error(source, "source", Iterable) 

680 dest: list = [] 

681 ins: int = 0 

682 for row in source: 

683 use_row = inner(row, cache, empty_ok, type_var) 

684 ins += len(use_row) 

685 dest.append(use_row) 

686 

687 if (ins <= 0) and empty_ok: # if all inner tuples are empty, 

688 dest.clear() # clear the tuple source 

689 

690 n_rows: Final[int] = list.__len__(dest) 

691 if (n_rows <= 0) and (not empty_ok): 

692 raise ValueError("Got empty set of rows!") 

693 

694 return cache(tuple(dest)) 

695 

696 

697def __to_tuples(source: Iterable[Iterable], 

698 cache: Callable, empty_ok: bool = False, type_var=int, 

699 inner: Callable = __to_tuple) \ 

700 -> tuple[tuple, ...]: 

701 """ 

702 Turn 2D nested iterables into 2D nested tuples. 

703 

704 :param source: the source 

705 :param cache: the cache 

706 :param empty_ok: are empty tuples OK? 

707 :param type_var: the type variable 

708 :param inner: the inner callable 

709 :return: the nested tuples 

710 

711 >>> ppl = repr_cache() 

712 >>> k1 = __to_tuples([(1, 2), [3, 2]], ppl) 

713 >>> print(k1) 

714 ((1, 2), (3, 2)) 

715 

716 >>> k2 = __to_tuples([(1, 2), (1, 2, 4), (1, 2)], ppl) 

717 >>> print(k2) 

718 ((1, 2), (1, 2, 4), (1, 2)) 

719 

720 >>> k2[0] is k2[2] 

721 True 

722 >>> k1[0] is k2[0] 

723 True 

724 >>> k1[0] is k2[2] 

725 True 

726 """ 

727 return __to_nested_tuples(source, cache, empty_ok, type_var, inner) 

728 

729 

730def __to_2d_npfloat(source: Iterable[Iterable], # pylint: disable=W1113 

731 cache: Callable, empty_ok: bool = False, 

732 *_) -> tuple[np.ndarray, ...]: # pylint: disable=W1113 

733 """ 

734 Turn 2D nested iterables into 2D nested tuples. 

735 

736 :param source: the source 

737 :param cache: the cache 

738 :param empty_ok: are empty tuples OK? 

739 :param inner: the inner callable 

740 :return: the nested tuples 

741 

742 >>> ppl = repr_cache() 

743 >>> k2 = __to_2d_npfloat([(1.0, 2.0), (1.0, 2.0, 4.0), (1.0, 0.2)], ppl) 

744 >>> print(k2) 

745 (array([1., 2.]), array([1., 2., 4.]), array([1. , 0.2])) 

746 """ 

747 return __to_nested_tuples(source, cache, empty_ok, float, __to_npfloats) 

748 

749 

750def __to_3d_npfloat(source: Iterable[Iterable[Iterable]], 

751 cache: Callable, empty_ok: bool) \ 

752 -> tuple[tuple[np.ndarray, ...], ...]: 

753 """ 

754 Turn 3D nested iterables into 3D nested tuples. 

755 

756 :param source: the source 

757 :param cache: the cache 

758 :param empty_ok: are empty tuples OK? 

759 :return: the nested tuples 

760 

761 >>> ppl = repr_cache() 

762 >>> k1 = __to_3d_npfloat([[[3.0, 2.0], [44.0, 5.0], [2.0]], 

763 ... [[2.0], [5.0, 7.0]]], ppl, False) 

764 >>> print(k1) 

765 ((array([3., 2.]), array([44., 5.]), array([2.])), \ 

766(array([2.]), array([5., 7.]))) 

767 >>> k1[0][2] is k1[1][0] 

768 True 

769 """ 

770 return __to_nested_tuples(source, cache, empty_ok, float, __to_2d_npfloat) 

771 

772 

773def _make_routes( 

774 n_products: int, n_stations: int, 

775 source: Iterable[Iterable[int]], 

776 cache: Callable) -> tuple[tuple[int, ...], ...]: 

777 """ 

778 Create the routes through stations for the products. 

779 

780 Each product passes through a set of stations. It can pass through each 

781 station at most once. It can only pass through valid stations. 

782 

783 :param n_products: the number of products 

784 :param n_stations: the number of stations 

785 :param source: the source data 

786 :param cache: the cache 

787 :return: the routes, a tuple of tuples 

788 

789 >>> ppl = repr_cache() 

790 >>> _make_routes(2, 3, ((1, 2), (1, 0)), ppl) 

791 ((1, 2), (1, 0)) 

792 

793 >>> _make_routes(3, 3, ((1, 2), (1, 0), (0, 1, 2)), ppl) 

794 ((1, 2), (1, 0), (0, 1, 2)) 

795 

796 >>> k = _make_routes(3, 3, ((1, 2), (1, 2), (0, 1, 2)), ppl) 

797 >>> k[0] is k[1] 

798 True 

799 """ 

800 check_int_range(n_products, "n_products", 1, MAX_ID) 

801 check_int_range(n_stations, "n_stations", 1, MAX_ID) 

802 dest: tuple[tuple[int, ...], ...] = __to_tuples(source, cache) 

803 

804 n_rows: Final[int] = tuple.__len__(dest) 

805 if n_rows != n_products: 

806 raise ValueError(f"{n_products} products, but {n_rows} routes.") 

807 for i, route in enumerate(dest): 

808 stations: int = tuple.__len__(route) 

809 if stations <= 0: 

810 raise ValueError( 

811 f"len(row[{i}])={stations} but n_stations={n_stations}") 

812 for j, v in enumerate(route): 

813 if not 0 <= v < n_stations: 

814 raise ValueError( 

815 f"row[{i},{j}]={v}, but n_stations={n_stations}") 

816 return dest 

817 

818 

819def __to_demand( 

820 source: Iterable[int | float], time_end_warmup: float, 

821 cache: Callable) -> Demand: 

822 """ 

823 Convert an integer source to a tuple or a demand. 

824 

825 :param source: the source 

826 :param time_end_warmup: the end of the warmup time 

827 :param cache: the cache 

828 :return: the Demand 

829 

830 >>> ppl = repr_cache() 

831 >>> d1 = __to_demand([1, 2, 3, 20, 10, 100], 10.0, ppl) 

832 >>> d1 

833 Demand(arrival=10.0, deadline=100.0, demand_id=1, \ 

834customer_id=2, product_id=3, amount=20, measure=True) 

835 >>> d2 = __to_demand([1, 2, 3, 20, 10, 100], 10.0, ppl) 

836 >>> d1 is d2 

837 True 

838 """ 

839 if isinstance(source, Demand): 

840 return cast("Demand", source) 

841 tup: tuple[int | float, ...] = tuple(source) 

842 dl: int = tuple.__len__(tup) 

843 if dl != 6: 

844 raise ValueError(f"Expected 6 values, got {dl}.") 

845 arrival: int | float = tup[DEMAND_ARRIVAL] 

846 return cache(Demand( 

847 demand_id=cast("int", tup[DEMAND_ID]), 

848 customer_id=cast("int", tup[DEMAND_CUSTOMER]), 

849 product_id=cast("int", tup[DEMAND_PRODUCT]), 

850 amount=cast("int", tup[DEMAND_AMOUNT]), 

851 arrival=arrival, deadline=tup[DEMAND_DEADLINE], 

852 measure=time_end_warmup <= arrival)) 

853 

854 

855def _make_demands(n_products: int, n_customers: int, n_demands: int, 

856 source: Iterable[Iterable[int | float]], 

857 time_end_warmup: float, 

858 time_end_measure: float, cache: Callable) \ 

859 -> tuple[Demand, ...]: 

860 """ 

861 Create the demand records, sorted by release time. 

862 

863 Each demand is a tuple of demand_id, customer_id, product_id, amount, 

864 release time, and deadline. 

865 

866 :param n_products: the number of products 

867 :param n_customers: the number of customers 

868 :param n_demands: the number of demands 

869 :param time_end_warmup: the end of the warmup time 

870 :param time_end_measure: the end of the measure time period 

871 :param source: the source data 

872 :param cache: the cache 

873 :return: the demand tuples 

874 

875 >>> ppl = repr_cache() 

876 >>> _make_demands(10, 10, 4, [[0, 2, 1, 4, 20, 21], 

877 ... [2, 5, 2, 6, 17, 27], 

878 ... [1, 6, 7, 12, 17, 21], 

879 ... [3, 7, 3, 23, 5, 21]], 10.0, 1000.0, ppl) 

880 (Demand(arrival=5.0, deadline=21.0, demand_id=3, customer_id=7,\ 

881 product_id=3, amount=23, measure=False),\ 

882 Demand(arrival=17.0, deadline=21.0, demand_id=1, customer_id=6,\ 

883 product_id=7, amount=12, measure=True),\ 

884 Demand(arrival=17.0, deadline=27.0, demand_id=2, customer_id=5,\ 

885 product_id=2, amount=6, measure=True),\ 

886 Demand(arrival=20.0, deadline=21.0, demand_id=0, customer_id=2,\ 

887 product_id=1, amount=4, measure=True)) 

888 """ 

889 check_int_range(n_products, "n_products", 1, MAX_ID) 

890 check_int_range(n_customers, "n_customers", 1, MAX_ID) 

891 check_int_range(n_demands, "n_demands", 1, MAX_ID) 

892 

893 def __make_demand(ssss: Iterable[int | float], 

894 ccc: Callable, *_) -> Demand: 

895 return __to_demand(ssss, time_end_warmup, ccc) 

896 

897 temp: tuple[Demand, ...] = __to_nested_tuples( 

898 source, cache, False, inner=__make_demand) 

899 n_dem: int = tuple.__len__(temp) 

900 if n_dem != n_demands: 

901 raise ValueError(f"Expected {n_demands} demands, got {n_dem}?") 

902 

903 used_ids: set[int] = set() 

904 min_id: int = 1000 * MAX_ID 

905 max_id: int = -1000 * MAX_ID 

906 dest: list[Demand] = [] 

907 

908 for i, demand in enumerate(temp): 

909 d_id: int = demand.demand_id 

910 if not 0 <= d_id < n_demands: 

911 raise ValueError(f"demand[{i}].id = {d_id}") 

912 if d_id in used_ids: 

913 raise ValueError(f"demand[{i}].id {d_id} appears twice!") 

914 used_ids.add(d_id) 

915 min_id = min(min_id, d_id) 

916 max_id = max(max_id, d_id) 

917 

918 c_id: int = demand.customer_id 

919 if not 0 <= c_id < n_customers: 

920 raise ValueError(f"demand[{i}].customer = {c_id}, " 

921 f"but n_customers={n_customers}") 

922 

923 p_id: int = demand.product_id 

924 if not 0 <= p_id < n_products: 

925 raise ValueError(f"demand[{i}].product = {p_id}, " 

926 f"but n_products={n_products}") 

927 

928 amount: int = demand.amount 

929 if not 0 < amount < MAX_ID: 

930 raise ValueError(f"demand[{i}].amount = {amount}.") 

931 

932 arrival: float = demand.arrival 

933 if not (isfinite(arrival) and (0 < arrival < MAX_ID)): 

934 raise ValueError(f"demand[{i}].arrival = {arrival}.") 

935 

936 deadline: float = demand.deadline 

937 if not (isfinite(deadline) and arrival <= deadline < MAX_ID): 

938 raise ValueError(f"demand[{i}].deadline = {deadline}.") 

939 

940 if arrival >= time_end_measure: 

941 raise ValueError(f"Demand[{i}]={demand!r} has arrival after " 

942 "end of measurement period.") 

943 dest.append(demand) 

944 

945 sl: int = set.__len__(used_ids) 

946 if sl != n_demands: 

947 raise ValueError(f"Got {n_demands} demands, but {sl} ids???") 

948 if ((max_id - min_id + 1) != n_demands) or (min_id != 0): 

949 raise ValueError(f"Invalid demand id range [{min_id}, {max_id}].") 

950 dest.sort() 

951 return cache(tuple(dest)) 

952 

953 

954def _make_in_warehouse(n_products: int, source: Iterable[int], 

955 cache: Callable) \ 

956 -> tuple[int, ...]: 

957 """ 

958 Make the amount of product in the warehouse at time 0. 

959 

960 :param n_products: the total number of products 

961 :param source: the data source 

962 :param cache: the tuple cache 

963 :return: the amount of products in the warehouse 

964 

965 >>> _make_in_warehouse(3, [1, 2, 3], repr_cache()) 

966 (1, 2, 3) 

967 """ 

968 ret: tuple[int, ...] = __to_tuple(source, cache) 

969 rl: Final[int] = tuple.__len__(ret) 

970 if rl != n_products: 

971 raise ValueError(f"We have {n_products} products, " 

972 f"but the warehouse list length is {rl}.") 

973 for p, v in enumerate(ret): 

974 if not 0 <= v <= MAX_ID: 

975 raise ValueError(f"Got {v} units of product {p} in warehouse?") 

976 return ret 

977 

978 

979def _make_station_product_unit_times( 

980 n_products: int, n_stations: int, 

981 routes: tuple[tuple[float, ...], ...], 

982 source: Iterable[Iterable[Iterable[float]]], 

983 cache: Callable) -> tuple[tuple[np.ndarray, ...], ...]: 

984 """ 

985 Create the structure for the work times per product unit per station. 

986 

987 Here we have for each station, for each product, a sequence of per-unit 

988 production settings. Each such "production settings" is a tuple with a 

989 per-unit production time and an end time index until which it is valid. 

990 Production times cycle, so if we produce something after the last end 

991 time index, we begin again at production time index 0. 

992 

993 :param n_products: the number of products 

994 :param n_stations: the number of stations 

995 :param routes: the routes of the products through the stations 

996 :param source: the source array 

997 :param cache: the cache 

998 :return: the station unit times 

999 

1000 >>> ppl = repr_cache() 

1001 >>> rts = _make_routes(3, 2, [[0, 1], [0], [1, 0]], ppl) 

1002 >>> print(rts) 

1003 ((0, 1), (0,), (1, 0)) 

1004 

1005 >>> mpt1 = _make_station_product_unit_times(3, 2, rts, [ 

1006 ... [[1.0, 2.0, 3.0, 5.0], [1.0, 2.0, 3.0, 5.0], 

1007 ... [1.0, 10.0, 2.0, 30.0]], 

1008 ... [[2.0, 20.0, 3.0, 40.0], [], [4.0, 56.0, 34.0, 444.0]]], ppl) 

1009 >>> print(mpt1) 

1010 ((array([1., 2., 3., 5.]), array([1., 2., 3., 5.]), \ 

1011array([ 1., 10., 2., 30.])), (array([ 2., 20., 3., 40.]), \ 

1012array([], dtype=float64), array([ 4., 56., 34., 444.]))) 

1013 >>> mpt1[0][0] is mpt1[0][1] 

1014 True 

1015 

1016 >>> mpt2 = _make_station_product_unit_times(3, 2, rts, [ 

1017 ... [[1.0, 2.0, 3.0, 5.0], [1.0, 2.0, 3.0, 5.0], 

1018 ... [1.0, 10.0, 2.0, 30.0]], 

1019 ... [[2.0, 20.0, 3.0, 40.0], [], [4.0, 56.0, 34.0, 444.0]]], ppl) 

1020 >>> print(mpt2) 

1021 ((array([1., 2., 3., 5.]), array([1., 2., 3., 5.]), \ 

1022array([ 1., 10., 2., 30.])), (array([ 2., 20., 3., 40.]), \ 

1023array([], dtype=float64), array([ 4., 56., 34., 444.]))) 

1024 >>> mpt1 is mpt2 

1025 True 

1026 """ 

1027 ret: tuple[tuple[np.ndarray, ...], ...] = __to_3d_npfloat( 

1028 source, cache, True) 

1029 

1030 if tuple.__len__(routes) != n_products: 

1031 raise ValueError("invalid routes!") 

1032 

1033 d1: int = tuple.__len__(ret) 

1034 if d1 != n_stations: 

1035 raise ValueError( 

1036 f"Got {d1} station-times, but {n_stations} stations.") 

1037 for mid, station in enumerate(ret): 

1038 d2: int = tuple.__len__(station) 

1039 if d2 <= 0: 

1040 for pid, r in enumerate(routes): 

1041 if mid in r: 

1042 raise ValueError( 

1043 f"Station {mid} in route for product {pid}, " 

1044 "but has no production time") 

1045 continue 

1046 if d2 != n_products: 

1047 raise ValueError(f"got {d2} products for station {mid}, " 

1048 f"but have {n_products} products") 

1049 for pid, product in enumerate(station): 

1050 needs_times: bool = mid in routes[pid] 

1051 d3: int = np.ndarray.__len__(product) 

1052 if (not needs_times) and (d3 > 0): 

1053 raise ValueError( 

1054 f"product {pid} does not pass through station {mid}, " 

1055 "so there must not be production times!") 

1056 if needs_times and (d3 <= 0): 

1057 raise ValueError( 

1058 f"product {pid} does pass through station {mid}, " 

1059 "so there must be production times!") 

1060 if (d3 % 2) != 0: 

1061 raise ValueError( 

1062 f"production times for {pid} does pass through station " 

1063 f"{mid}, must be of even length, but got length {d3}.") 

1064 last_end = 0 

1065 for pt, time in enumerate(batched(product, 2)): 

1066 if tuple.__len__(time) != 2: 

1067 raise ValueError(f"production times must be 2-tuples, " 

1068 f"but got {time} for product {pid} on " 

1069 f"station {mid} at position {pt}") 

1070 unit_time, end = time 

1071 if not ((unit_time > 0) and (last_end < end < MAX_ID)): 

1072 raise ValueError( 

1073 f"Invalid unit time {unit_time} and end time " 

1074 f"{end} for product {pid} on station {mid}") 

1075 last_end = end 

1076 

1077 return ret 

1078 

1079 

1080def _make_infos(source: Iterable[tuple[str, str]] | Mapping[str, str] | None)\ 

1081 -> Mapping[str, str]: 

1082 """ 

1083 Make the additional information record. 

1084 

1085 :param source: the information to represent 

1086 :return: the information record 

1087 """ 

1088 use_source: Iterable[tuple[str, str]] = () if source is None else ( 

1089 source.items() if isinstance(source, Mapping) else source) 

1090 if not isinstance(use_source, Iterable): 

1091 raise type_error(source, "infos", Iterable) 

1092 dst: dict[str, str] = {} 

1093 for i, tup in enumerate(use_source): 

1094 if tuple.__len__(tup) != 2: 

1095 raise ValueError(f"Invalid tuple {tup} at index {i} in infos.") 

1096 k: str = str.strip(tup[0]) 

1097 v: str = str.strip(tup[1]) 

1098 if (str.__len__(k) <= 0) or (str.__len__(v) <= 0): 

1099 raise ValueError(f"Invalid key/values {k!r}/{v!r} in tuple " 

1100 f"{tup} at index {i} in infos.") 

1101 if __FORBIDDEN_INFO_KEYS(str.lower(k)): 

1102 raise ValueError( 

1103 f"Info key {k!r} in tuple {tup} forbidden at index {i}.") 

1104 if not all(map(__ALLOWED_INFO_KEY_CHARS, k)): 

1105 raise ValueError( 

1106 f"Malformed info key {k!r} in tuple {tup} at index {i}.") 

1107 if k in dst: 

1108 raise ValueError(f"Duplicate key {k!r} found in tuple {tup} " 

1109 f"at index {i} in infos.") 

1110 dst[k] = v 

1111 return immutable_mapping(dst) 

1112 

1113 

1114class Instance(Component): 

1115 """An instance of the Production Scheduling Problem.""" 

1116 

1117 def __init__( 

1118 self, name: str, 

1119 n_products: int, n_customers: int, n_stations: int, 

1120 n_demands: int, 

1121 time_end_warmup: int | float, time_end_measure: int | float, 

1122 routes: Iterable[Iterable[int]], 

1123 demands: Iterable[Iterable[int | float]], 

1124 warehous_at_t0: Iterable[int], 

1125 station_product_unit_times: Iterable[Iterable[Iterable[float]]], 

1126 infos: Iterable[tuple[str, str]] | Mapping[ 

1127 str, str] | None = None) \ 

1128 -> None: 

1129 """ 

1130 Create an instance of the production scheduling time. 

1131 

1132 :param name: the instance name 

1133 :param n_products: the number of products 

1134 :param n_customers: the number of customers 

1135 :param n_stations: the number of stations 

1136 :param n_demands: the number of demand records 

1137 :param time_end_warmup: the time unit when the warmup time ends and the 

1138 actual measurement begins 

1139 :param time_end_measure: the time unit when the actual measure time 

1140 ends 

1141 :param routes: for each product, the sequence of stations that it has 

1142 to pass 

1143 :param demands: a sequences of demands of the form ( 

1144 customer_id, product_id, product_amount, release_time) OR a 

1145 sequence of :class:`Demand` records. 

1146 :param warehous_at_t0: the amount of products in the warehouse at time 

1147 0 for each product 

1148 :param station_product_unit_times: for each station and each product 

1149 the per-unit-production time schedule, in the form of 

1150 "per_unit_time, duration", where duration is the number of time 

1151 units for which the per_unit_time is value 

1152 :param station_product_unit_times: the cycling unit times for each 

1153 product on each station, each with a validity duration 

1154 :param infos: additional infos to be stored with the instance. 

1155 These are key-value pairs with keys that are not used by the 

1156 instance. They have no impact on the instance performance, but may 

1157 explain settings of an instance generator. 

1158 :raises ValueError: If the data is inconsistent or otherwise not 

1159 permissible. 

1160 """ 

1161 use_name: Final[str] = sanitize_name(name) 

1162 if name != use_name: 

1163 raise ValueError(f"Name {name!r} is not a valid name.") 

1164 if not all(map(_ALLOWED_NAME_CHARS, name)): 

1165 raise ValueError(f"Name {name!r} contains invalid characters.") 

1166 #: the name of this instance 

1167 self.name: Final[str] = name 

1168 

1169 #: the number of products in the scenario 

1170 self.n_products: Final[int] = check_int_range( 

1171 n_products, "n_products", 1, MAX_ID) 

1172 #: the number of customers in the scenario 

1173 self.n_customers: Final[int] = check_int_range( 

1174 n_customers, "n_customers", 1, MAX_ID) 

1175 #: the number of stations or workstations in the scenario 

1176 self.n_stations: Final[int] = check_int_range( 

1177 n_stations, "n_stations", 1, MAX_ID) 

1178 #: the number of demands in the scenario 

1179 self.n_demands: Final[int] = check_int_range( 

1180 n_demands, "n_demands", 1, MAX_ID) 

1181 

1182 if not isinstance(time_end_warmup, int | float): 

1183 raise type_error(time_end_warmup, "time_end_warmup", (int, float)) 

1184 time_end_warmup = float(time_end_warmup) 

1185 if not (isfinite(time_end_warmup) and ( 

1186 0 <= time_end_warmup < MAX_VALUE)): 

1187 raise ValueError(f"Invalid time_end_warmup={time_end_warmup}.") 

1188 #: the end of the warmup time 

1189 self.time_end_warmup: Final[float] = time_end_warmup 

1190 

1191 if not isinstance(time_end_measure, int | float): 

1192 raise type_error(time_end_measure, "time_end_measure", ( 

1193 int, float)) 

1194 time_end_measure = float(time_end_measure) 

1195 if not (isfinite(time_end_measure) and ( 

1196 time_end_warmup < time_end_measure < MAX_VALUE)): 

1197 raise ValueError(f"Invalid time_end_measure={time_end_measure} " 

1198 f"for time_end_warmup={time_end_warmup}.") 

1199 #: the end of the measurement time 

1200 self.time_end_measure: Final[float] = time_end_measure 

1201 

1202 cache: Final[Callable] = repr_cache() # the pool for resolving tuples 

1203 

1204 #: the product routes, i.e., the stations through which each product 

1205 #: must pass 

1206 self.routes: Final[tuple[tuple[int, ...], ...]] = _make_routes( 

1207 n_products, n_stations, routes, cache) 

1208 

1209 #: The demands: Each demand stores the :attr:`~Demand.demand_id`, 

1210 #: :attr:`~Demand.customer_id`, :attr:`~Demand.product_id`, 

1211 #: :attr:`~Demand.amount`, :attr:`~Demand.arrival` time, and 

1212 #: :attr:`~Demand.deadline`, as well as whether it should be 

1213 #: measured during the simulation (:attr:`~Demand.measure`). 

1214 #: The customer makes their order at time step 

1215 #: :attr:`~Demand.arrival`. 

1216 #: They expect to receive their product by the 

1217 #: :attr:`~Demand.deadline` . 

1218 #: The demands are sorted by :attr:`~Demand.arrival` and then 

1219 #: :attr:`~Demand.deadline` . 

1220 #: The release time is always > 0. 

1221 #: The :attr:`~Demand.arrival` is always >= 

1222 #: :attr:`~Demand.deadline` . 

1223 #: Demand ids are unique. 

1224 self.demands: Final[tuple[Demand, ...]] = _make_demands( 

1225 n_products, n_customers, n_demands, demands, time_end_warmup, 

1226 time_end_measure, cache) 

1227 

1228 # count the demands that fall in the measure time window 

1229 n_measure: int = 0 

1230 n_measures: list[int] = [0] * n_products 

1231 for d in self.demands: 

1232 if d.arrival >= self.time_end_measure: 

1233 raise ValueError(f"Invalid arrival time of demand {d!r}.") 

1234 if d.measure != (self.time_end_warmup <= d.arrival): 

1235 raise ValueError( 

1236 f"Inconsistent measure property for demand {d!r}.") 

1237 if d.measure: 

1238 n_measure += 1 

1239 n_measures[d.product_id] += 1 

1240 if n_measure <= 0: 

1241 raise ValueError("There are no measurable demands!") 

1242 for pid, npm in enumerate(n_measures): 

1243 if npm <= 0: 

1244 raise ValueError(f"No measurable demand for product {pid}!") 

1245 #: the number of demands that actually fall into the time measured 

1246 #: window 

1247 self.n_measurable_demands: Final[int] = n_measure 

1248 #: the measurable demands on a per-product basis 

1249 self.n_measurable_demands_per_product: Final[tuple[int, ...]] = tuple( 

1250 n_measures) 

1251 

1252 #: The units of product in the warehouse at time step 0. 

1253 #: For each product, we have either 0 or a positive amount of product. 

1254 self.warehous_at_t0: Final[tuple[int, ...]] = _make_in_warehouse( 

1255 n_products, warehous_at_t0, cache) 

1256 

1257 #: The per-station unit production times for each product. 

1258 #: Each station can have different production times per product. 

1259 #: Let's say that this is tuple `A`. 

1260 #: For each product, it has a tuple `B` at the index of the product 

1261 #: id. 

1262 #: If the product does not pass through the station, `B` is empty. 

1263 #: Otherwise, it holds one or multiple tuples `C`. 

1264 #: Each tuple `C` consists of two numbers: 

1265 #: A per-unit-production time for the product. 

1266 #: An end time index for this production time. 

1267 #: Once the real time surpasses the end time of the last of these 

1268 #: production specs, the production specs are recycled and begin 

1269 #: again. 

1270 self.station_product_unit_times: Final[tuple[tuple[ 

1271 np.ndarray, ...], ...]] = _make_station_product_unit_times( 

1272 n_products, n_stations, self.routes, station_product_unit_times, 

1273 cache) 

1274 

1275 #: Additional information about the nature of the instance can be 

1276 #: stored here. This has no impact on the behavior of the instance, 

1277 #: but it may explain, e.g., settings of an instance generator. 

1278 #: The module :mod:`~moptipyapps.prodsched.mfc_generator` which is 

1279 #: used to randomly generate instances, for example, makes use of this 

1280 #: data to store the random number seed as well as the distributions 

1281 #: that were used to create the instances. 

1282 self.infos: Final[Mapping[str, str]] = _make_infos(infos) 

1283 

1284 def __str__(self): 

1285 """ 

1286 Get the name of this instance. 

1287 

1288 :return: the name of this instance 

1289 """ 

1290 return self.name 

1291 

1292 def _tuple(self) -> tuple: 

1293 """ 

1294 Convert this object to a tuple. 

1295 

1296 :return: the tuple 

1297 

1298 >>> Instance(name="test1", n_products=1, n_customers=1, n_stations=2, 

1299 ... n_demands=1, time_end_warmup=12, time_end_measure=30, 

1300 ... routes=[[0, 1]], demands=[[0, 0, 0, 10, 20, 100]], 

1301 ... warehous_at_t0=[0], 

1302 ... station_product_unit_times=[[[10.0, 10000.0]], 

1303 ... [[30.0, 10000.0]]])._tuple() 

1304 ('test1', 2, 1, 1, 1, 12.0, 30.0, (Demand(arrival=20.0,\ 

1305 deadline=100.0, demand_id=0, customer_id=0, product_id=0, amount=10,\ 

1306 measure=True),), ((0, 1),), (0,), (), ((10.0, 10000.0), (30.0, 10000.0))) 

1307 """ 

1308 return (self.name, self.n_stations, self.n_products, 

1309 self.n_demands, self.n_customers, self.time_end_warmup, 

1310 self.time_end_measure, self.demands, 

1311 self.routes, self.warehous_at_t0, tuple(self.infos.items()), 

1312 tuple(tuple(float(x) for x in a2) for a1 in 

1313 self.station_product_unit_times for a2 in a1)) 

1314 

1315 def __eq__(self, other): 

1316 """ 

1317 Compare this object with another object. 

1318 

1319 :param other: the other object 

1320 :return: `NotImplemented` if the other object is not an `Instance`, 

1321 otherwise the equality comparison result. 

1322 

1323 >>> i1 = Instance(name="test1", n_products=1, n_customers=1, 

1324 ... n_stations=2, n_demands=1, 

1325 ... time_end_warmup=12, time_end_measure=30, 

1326 ... routes=[[0, 1]], 

1327 ... demands=[[0, 0, 0, 10, 20, 100]], 

1328 ... warehous_at_t0=[0], 

1329 ... station_product_unit_times=[[[10.0, 10000.0]], 

1330 ... [[30.0, 10000.0]]]) 

1331 >>> i2 = Instance(name="test1", n_products=1, n_customers=1, 

1332 ... n_stations=2, n_demands=1, 

1333 ... time_end_warmup=12, time_end_measure=30, 

1334 ... routes=[[0, 1]], 

1335 ... demands=[[0, 0, 0, 10, 20, 100]], 

1336 ... warehous_at_t0=[0], 

1337 ... station_product_unit_times=[[[10.0, 10000.0]], 

1338 ... [[30.0, 10000.0]]]) 

1339 >>> i1 == i2 

1340 True 

1341 >>> i3 = Instance(name="test1", n_products=1, n_customers=1, 

1342 ... n_stations=2, n_demands=1, 

1343 ... time_end_warmup=12, time_end_measure=30, 

1344 ... routes=[[0, 1]], 

1345 ... demands=[[0, 0, 0, 10, 20, 100]], 

1346 ... warehous_at_t0=[0], 

1347 ... station_product_unit_times=[[[10.0, 10000.1]], 

1348 ... [[30.0, 10000.0]]]) 

1349 >>> i1 == i3 

1350 False 

1351 """ 

1352 if other is None: 

1353 return False 

1354 if not isinstance(other, Instance): 

1355 return NotImplemented 

1356 return self._tuple() == cast("Instance", other)._tuple() 

1357 

1358 def __hash__(self) -> int: 

1359 """ 

1360 Get the hash code of this object. 

1361 

1362 :return: the hash code of this object 

1363 """ 

1364 return hash(self._tuple()) 

1365 

1366 

1367#: the instance name key 

1368KEY_NAME: Final[str] = "name" 

1369#: the key for the number of products 

1370KEY_N_PRODUCTS: Final[str] = "n_products" 

1371#: the key for the number of customers 

1372KEY_N_CUSTOMERS: Final[str] = "n_customers" 

1373#: the key for the number of stations 

1374KEY_N_STATIONS: Final[str] = "n_stations" 

1375#: the number of demands in the scenario 

1376KEY_N_DEMANDS: Final[str] = "n_demands" 

1377#: the end of the warmup period 

1378KEY_TIME_END_WARMUP: Final[str] = "time_end_warmup" 

1379#: the end of the measure period 

1380KEY_TIME_END_MEASURE: Final[str] = "time_end_measure" 

1381#: the start of a key index 

1382KEY_IDX_START: Final[str] = "[" 

1383#: the end of a key index 

1384KEY_IDX_END: Final[str] = "]" 

1385#: the first part of the product route key 

1386KEY_ROUTE: Final[str] = "product_route" 

1387#: the first part of the demand key 

1388KEY_DEMAND: Final[str] = "demand" 

1389#: The amount of products in the warehouse at time step 0. 

1390KEY_IN_WAREHOUSE: Final[str] = "products_in_warehouse_at_t0" 

1391#: the first part of the production time 

1392KEY_PRODUCTION_TIME: Final[str] = "production_time" 

1393 

1394#: the key value split string 

1395_KEY_VALUE_SPLIT: Final[str] = str.strip(KEY_VALUE_SEPARATOR) 

1396 

1397#: the forbidden keys 

1398__FORBIDDEN_INFO_KEYS: Final[Callable[[str], bool]] = { 

1399 KEY_NAME, KEY_N_PRODUCTS, KEY_N_CUSTOMERS, KEY_N_STATIONS, 

1400 KEY_N_DEMANDS, KEY_TIME_END_MEASURE, KEY_TIME_END_WARMUP, 

1401 KEY_ROUTE, KEY_DEMAND, KEY_IN_WAREHOUSE, KEY_PRODUCTION_TIME}.__contains__ 

1402 

1403#: the allowed information key characters 

1404__ALLOWED_INFO_KEY_CHARS: Final[Callable[[str], bool]] = set( 

1405 ascii_letters + digits + "_." + KEY_IDX_START + KEY_IDX_END).__contains__ 

1406 

1407#: the allowed characters in names 

1408_ALLOWED_NAME_CHARS: Final[Callable[[str], bool]] = set( 

1409 ascii_letters + digits + "_").__contains__ 

1410 

1411 

1412def to_stream(instance: Instance) -> Generator[str, None, None]: 

1413 """ 

1414 Convert an instance to a stream of data. 

1415 

1416 :param instance: the instance to convert to a stream 

1417 :return: the stream of data 

1418 """ 

1419 if not isinstance(instance, Instance): 

1420 raise type_error(instance, "instance", Instance) 

1421 

1422 yield f"{COMMENT_START} --- the data of instance {instance.name!r} ---" 

1423 yield COMMENT_START 

1424 yield (f"{COMMENT_START} Lines beginning with {COMMENT_START!r} are " 

1425 f"comments.") 

1426 yield COMMENT_START 

1427 yield f"{COMMENT_START} the unique identifying name of the instance" 

1428 yield f"{KEY_NAME}{KEY_VALUE_SEPARATOR}{instance.name}" 

1429 yield COMMENT_START 

1430 yield f"{COMMENT_START} the number of products in the instance, > 0" 

1431 yield f"{KEY_N_PRODUCTS}{KEY_VALUE_SEPARATOR}{instance.n_products}" 

1432 yield (f"{COMMENT_START} Valid product indices are in 0.." 

1433 f"{instance.n_products - 1}.") 

1434 yield COMMENT_START 

1435 yield f"{COMMENT_START} the number of customers in the instance, > 0" 

1436 yield f"{KEY_N_CUSTOMERS}{KEY_VALUE_SEPARATOR}{instance.n_customers}" 

1437 yield (f"{COMMENT_START} Valid customer indices are in 0.." 

1438 f"{instance.n_customers - 1}.") 

1439 yield COMMENT_START 

1440 yield f"{COMMENT_START} the number of stations in the instance, > 0" 

1441 yield f"{KEY_N_STATIONS}{KEY_VALUE_SEPARATOR}{instance.n_stations}" 

1442 yield (f"{COMMENT_START} Valid station indices are in 0.." 

1443 f"{instance.n_stations - 1}.") 

1444 yield COMMENT_START 

1445 yield (f"{COMMENT_START} the number of customer orders (demands) issued " 

1446 f"by the customers, > 0") 

1447 yield f"{KEY_N_DEMANDS}{KEY_VALUE_SEPARATOR}{instance.n_demands}" 

1448 yield (f"{COMMENT_START} Valid demand/order indices are in 0.." 

1449 f"{instance.n_demands - 1}.") 

1450 yield COMMENT_START 

1451 yield (f"{COMMENT_START} end of the warmup period in the simulations, " 

1452 f">= 0") 

1453 wm: Final[str] = float_to_str(instance.time_end_warmup) 

1454 yield f"{KEY_TIME_END_WARMUP}{KEY_VALUE_SEPARATOR}{wm}" 

1455 yield (f"{COMMENT_START} The simulation will not measure anything " 

1456 f"during the first {wm} time units.") 

1457 yield COMMENT_START 

1458 yield (f"{COMMENT_START} end of the measurement period in the " 

1459 f"simulations, > {wm}") 

1460 meas: Final[str] = float_to_str(instance.time_end_measure) 

1461 yield f"{KEY_TIME_END_MEASURE}{KEY_VALUE_SEPARATOR}{meas}" 

1462 yield (f"{COMMENT_START} The simulation will only measure things during " 

1463 f" left-closed and right-open interval [{wm},{meas}).") 

1464 

1465 yield COMMENT_START 

1466 yield (f"{COMMENT_START} For each product, we now specify the indices of " 

1467 f"the stations by which it will be processed, in the order in " 

1468 f"which it will be processed by them.") 

1469 yield (f"{COMMENT_START} {KEY_ROUTE}{KEY_IDX_START}0" 

1470 f"{KEY_IDX_END} is the production route by the first product, " 

1471 "which has index 0.") 

1472 route_0: tuple[int, ...] = instance.routes[0] 

1473 yield (f"{COMMENT_START} This product is processed by " 

1474 f"{tuple.__len__(route_0)} stations, namely first by the " 

1475 f"station with index {int(route_0[0])} and last by the station " 

1476 f"with index {int(route_0[-1])}.") 

1477 for p, route in enumerate(instance.routes): 

1478 yield (f"{KEY_ROUTE}{KEY_IDX_START}{p}{KEY_IDX_END}" 

1479 f"{KEY_VALUE_SEPARATOR}" 

1480 f"{CSV_SEPARATOR.join(map(str, route))}") 

1481 

1482 yield COMMENT_START 

1483 yield (f"{COMMENT_START} For each customer order/demand, we now " 

1484 f"specify the following values:") 

1485 yield f"{COMMENT_START} 1. the demand ID in square brackets" 

1486 yield f"{COMMENT_START} 2. the ID of the customer who made the order" 

1487 yield f"{COMMENT_START} 3. the ID of the product that the customer ordered" 

1488 yield (f"{COMMENT_START} 4. the amount of the product that the customer" 

1489 " ordered") 

1490 yield (f"{COMMENT_START} 5. the arrival time of the demand, > 0, i.e., " 

1491 f"the moment in time when the customer informed us that they want " 

1492 f"the product") 

1493 yield (f"{COMMENT_START} 6. the deadline, i.e., when the customer expects " 

1494 f"the product, >= arrival time") 

1495 srt: list[Demand] = sorted(instance.demands, key=lambda d: d.demand_id) 

1496 fd: Demand = srt[0] 

1497 yield (f"{COMMENT_START} For example, the demand with ID {fd.demand_id} " 

1498 f"was issued by the customer with ID {fd.customer_id} for " 

1499 f"{fd.amount} units of the product with ID " 

1500 f"{fd.product_id}.") 

1501 yield (f"{COMMENT_START} The order comes into the " 

1502 f"system at time unit {fd.arrival} and the customer expects " 

1503 f"the product to be ready at time unit {fd.deadline}.") 

1504 for demand in srt: 

1505 it = iter(demand) 

1506 next(it) # pylint: disable=R1708 

1507 row: str = CSV_SEPARATOR.join(map(num_to_str, it)) 

1508 yield (f"{KEY_DEMAND}{KEY_IDX_START}{demand.demand_id}{KEY_IDX_END}" 

1509 f"{KEY_VALUE_SEPARATOR}" 

1510 f"{row}") 

1511 

1512 yield COMMENT_START 

1513 yield (f"{COMMENT_START} For each product, we now specify the amount " 

1514 f"that is in the warehouse at time step 0.") 

1515 yield (f"{COMMENT_START} For example, there are " 

1516 f"{instance.warehous_at_t0[0]} units of product 0 in the " 

1517 f"warehouse at the beginning of the simulation.") 

1518 yield (f"{KEY_IN_WAREHOUSE}{KEY_VALUE_SEPARATOR}" 

1519 f"{CSV_SEPARATOR.join(map(str, instance.warehous_at_t0))}") 

1520 

1521 yield COMMENT_START 

1522 yield (f"{COMMENT_START} For each station, we now specify the production " 

1523 f"times for each product that passes through the station.") 

1524 empty_pdx: tuple[int, int] | None = None 

1525 filled_pdx: tuple[int, int, np.ndarray] | None = None 

1526 need: int = 2 

1527 for mid, station in enumerate(instance.station_product_unit_times): 

1528 for pid, product in enumerate(station): 

1529 pdl: int = np.ndarray.__len__(product) 

1530 if (pdl <= 0) and (empty_pdx is None): 

1531 empty_pdx = mid, pid 

1532 need -= 1 

1533 if need <= 0: 

1534 break 

1535 elif (pdl > 0) and (filled_pdx is None): 

1536 filled_pdx = mid, pid, product 

1537 need -= 1 

1538 if need <= 0: 

1539 break 

1540 if need <= 0: 

1541 break 

1542 if empty_pdx is not None: 

1543 yield (f"{COMMENT_START} For example, product {empty_pdx[1]} does " 

1544 f"not pass through station {empty_pdx[0]}, so it is not " 

1545 "listed here.") 

1546 if filled_pdx is not None: 

1547 yield (f"{COMMENT_START} For example, one unit of product " 

1548 f"{filled_pdx[1]} passes through station {filled_pdx[0]}.") 

1549 yield (f"{COMMENT_START} There, it needs {filled_pdx[2][0]} time " 

1550 f"units per product unit from t=0 to t={filled_pdx[2][1]}.") 

1551 if np.ndarray.__len__(filled_pdx[2]) > 2: 

1552 yield (f"{COMMENT_START} After that, it needs {filled_pdx[2][2]}" 

1553 " time units per product unit until t=" 

1554 f"{filled_pdx[2][3]}.") 

1555 

1556 cache: dict[str, str] = {} 

1557 for mid, station in enumerate(instance.station_product_unit_times): 

1558 for pid, product in enumerate(station): 

1559 if np.ndarray.__len__(product) <= 0: 

1560 continue 

1561 value: str = CSV_SEPARATOR.join(map(num_to_str, map( 

1562 try_int, map(float, product)))) 

1563 key: str = (f"{KEY_PRODUCTION_TIME}{KEY_IDX_START}{mid}" 

1564 f"{CSV_SEPARATOR}{pid}{KEY_IDX_END}") 

1565 if value in cache: 

1566 value = cache[value] 

1567 else: 

1568 cache[value] = key 

1569 yield f"{key}{KEY_VALUE_SEPARATOR}{value}" 

1570 

1571 n_infos: Final[int] = len(instance.infos) 

1572 if n_infos > 0: 

1573 yield COMMENT_START 

1574 yield (f"{COMMENT_START} The following {n_infos} key/value pairs " 

1575 "denote additional information about the instance.") 

1576 yield (f"{COMMENT_START} They have no impact whatsoever on the " 

1577 "instance behavior.") 

1578 yield (f"{COMMENT_START} A common use case is that we may have " 

1579 "used a method to randomly sample the instance.") 

1580 yield (f"{COMMENT_START} In this case, we could store the parameters " 

1581 f"of the instance generator, such as the random seed and/or " 

1582 f"the distributions used in this section.") 

1583 for k, v in instance.infos.items(): 

1584 yield f"{k}{KEY_VALUE_SEPARATOR}{v}" 

1585 

1586 

1587def __get_key_index(full_key: str) -> str: 

1588 """ 

1589 Extract the key index from a key. 

1590 

1591 :param full_key: the full key 

1592 :return: the key index 

1593 

1594 >>> __get_key_index("s[12 ]") 

1595 '12' 

1596 """ 

1597 start: int = str.index(full_key, KEY_IDX_START) 

1598 end: int = str.index(full_key, KEY_IDX_END, start) 

1599 if not 0 < start < end < str.__len__(full_key): 

1600 raise ValueError(f"Invalid key {full_key!r}.") 

1601 idx: str = str.strip(full_key[start + 1:end]) 

1602 if str.__len__(idx) <= 0: 

1603 raise ValueError(f"Invalid index in key {full_key!r}.") 

1604 return idx 

1605 

1606 

1607def __pe(message: str, oline: str, line_idx: int) -> ValueError: 

1608 """ 

1609 Create a value error to be raised inside the parser. 

1610 

1611 :param message: the message 

1612 :param oline: the original line 

1613 :param line_idx: the line index 

1614 :return: the error 

1615 """ 

1616 return ValueError(f"{message} at line {line_idx + 1} ({oline!r})") 

1617 

1618 

1619def from_stream(stream: Iterable[str]) -> Instance: 

1620 """ 

1621 Read an instance from a data stream. 

1622 

1623 :param stream: the data stream 

1624 :return: the instance 

1625 """ 

1626 if not isinstance(stream, Iterable): 

1627 raise type_error(stream, "stream", Iterable) 

1628 name: str | None = None 

1629 n_products: int | None = None 

1630 n_customers: int | None = None 

1631 n_stations: int | None = None 

1632 n_demands: int | None = None 

1633 time_end_warmup: int | float | None = None 

1634 time_end_measure: int | float | None = None 

1635 routes: list[list[int]] | None = None 

1636 demands: list[list[int | float]] | None = None 

1637 in_warehouse: list[int] | None = None 

1638 station_product_times: list[list[list[float]]] | None = None 

1639 infos: dict[str, str] = {} 

1640 

1641 for line_idx, oline in enumerate(stream): 

1642 line = str.strip(oline) 

1643 if str.startswith(line, COMMENT_START): 

1644 continue 

1645 

1646 split_idx: int = str.find(line, _KEY_VALUE_SPLIT) 

1647 if split_idx > -1: 

1648 key: str = str.lower(str.strip(line[:split_idx])) 

1649 value: str = str.strip( 

1650 line[split_idx + str.__len__(_KEY_VALUE_SPLIT):]) 

1651 if (str.__len__(key) <= 0) or (str.__len__(value) <= 0): 

1652 raise __pe(f"Invalid key/value pair {key!r}/{value!r}", 

1653 oline, line_idx) 

1654 

1655 if key == KEY_NAME: 

1656 if name is not None: 

1657 raise __pe(f"{KEY_NAME} already defined as {name!r}, " 

1658 f"cannot be set to {value!r}", oline, line_idx) 

1659 name = value 

1660 

1661 elif key == KEY_N_STATIONS: 

1662 if n_stations is not None: 

1663 raise __pe( 

1664 f"{KEY_N_STATIONS} already defined as {n_stations!r}," 

1665 f" cannot be set to {value!r}", oline, line_idx) 

1666 n_stations = check_to_int_range( 

1667 value, KEY_N_STATIONS, 1, 1_000_000) 

1668 

1669 elif key == KEY_N_PRODUCTS: 

1670 if n_products is not None: 

1671 raise __pe( 

1672 f"{KEY_N_PRODUCTS} already defined as {n_products!r}," 

1673 f" cannot be set to {value!r}", oline, line_idx) 

1674 n_products = check_to_int_range( 

1675 value, KEY_N_PRODUCTS, 1, 1_000_000) 

1676 

1677 elif key == KEY_N_CUSTOMERS: 

1678 if n_customers is not None: 

1679 raise __pe( 

1680 f"{KEY_N_CUSTOMERS} already defined as " 

1681 f"{n_customers!r}, cannot be set to {value!r}", 

1682 oline, line_idx) 

1683 n_customers = check_to_int_range( 

1684 value, KEY_N_CUSTOMERS, 1, 1_000_000) 

1685 

1686 elif key == KEY_N_DEMANDS: 

1687 if n_demands is not None: 

1688 raise __pe( 

1689 f"{KEY_N_DEMANDS} already defined as {n_demands!r}, " 

1690 f"cannot be set to {value!r}", oline, line_idx) 

1691 n_demands = check_to_int_range( 

1692 value, KEY_N_DEMANDS, 1, 1_000_000) 

1693 

1694 elif key == KEY_TIME_END_WARMUP: 

1695 if time_end_warmup is not None: 

1696 raise __pe(f"{KEY_TIME_END_WARMUP} already defined", 

1697 oline, line_idx) 

1698 time_end_warmup = float(value) 

1699 if not (isfinite(time_end_warmup) and (time_end_warmup > 0)): 

1700 raise __pe(f"time_end_warmup={time_end_warmup} invalid", 

1701 oline, line_idx) 

1702 

1703 elif key == KEY_TIME_END_MEASURE: 

1704 if time_end_measure is not None: 

1705 raise __pe(f"{KEY_TIME_END_MEASURE} already defined", 

1706 oline, line_idx) 

1707 time_end_measure = float(value) 

1708 if not (isfinite(time_end_measure) and ( 

1709 time_end_measure > 0)): 

1710 raise __pe(f"time_end_measure={time_end_measure} invalid", 

1711 oline, line_idx) 

1712 if (time_end_warmup is not None) and ( 

1713 time_end_measure <= time_end_warmup): 

1714 raise __pe(f"time_end_warmup={time_end_warmup} and " 

1715 f"time_end_measure={time_end_measure}", 

1716 oline, line_idx) 

1717 

1718 elif key == KEY_IN_WAREHOUSE: 

1719 if in_warehouse is not None: 

1720 raise __pe(f"{KEY_IN_WAREHOUSE} already defined", 

1721 oline, line_idx) 

1722 if n_products is None: 

1723 raise __pe(f"Must define {KEY_N_PRODUCTS} before " 

1724 f"{KEY_IN_WAREHOUSE}.", oline, line_idx) 

1725 in_warehouse = list(map(int, str.split( 

1726 value, CSV_SEPARATOR))) 

1727 if list.__len__(in_warehouse) != n_products: 

1728 raise __pe( 

1729 f"Expected {n_products} products in warehouse, got " 

1730 f"{in_warehouse}.", oline, line_idx) 

1731 

1732 elif str.startswith(key, KEY_ROUTE): 

1733 if n_products is None: 

1734 raise __pe(f"Must define {KEY_N_PRODUCTS} before " 

1735 f"{KEY_ROUTE}.", oline, line_idx) 

1736 if n_stations is None: 

1737 raise ValueError(f"Must define {KEY_N_STATIONS} before " 

1738 f"{KEY_ROUTE}.", oline, line_idx) 

1739 if routes is None: 

1740 routes = [[] for _ in range(n_products)] 

1741 

1742 product_id: int = check_to_int_range( 

1743 __get_key_index(key), KEY_ROUTE, 0, n_products - 1) 

1744 rlst: list[int] = routes[product_id] 

1745 if list.__len__(rlst) != 0: 

1746 raise __pe( 

1747 f"Already gave {KEY_ROUTE}{KEY_IDX_START}{product_id}" 

1748 f"{KEY_IDX_END}", oline, line_idx) 

1749 rlst.extend(map(int, str.split(value, CSV_SEPARATOR))) 

1750 if list.__len__(rlst) <= 0: 

1751 raise __pe(f"Route for product {product_id} is empty", 

1752 oline, line_idx) 

1753 

1754 elif str.startswith(key, KEY_DEMAND): 

1755 if n_customers is None: 

1756 raise __pe(f"Must define {KEY_N_CUSTOMERS} before " 

1757 f"{KEY_DEMAND}", oline, line_idx) 

1758 if n_products is None: 

1759 raise __pe(f"Must define {KEY_N_PRODUCTS} before " 

1760 f"{KEY_DEMAND}.", oline, line_idx) 

1761 if n_demands is None: 

1762 raise __pe(f"Must define {KEY_N_DEMANDS} before " 

1763 f"{KEY_DEMAND}", oline, line_idx) 

1764 if demands is None: 

1765 demands = [[i] for i in range(n_demands)] 

1766 

1767 demand_id: int = check_to_int_range( 

1768 __get_key_index(key), KEY_DEMAND, 0, n_demands - 1) 

1769 dlst: list[int | float] = demands[demand_id] 

1770 if list.__len__(dlst) != 1: 

1771 raise __pe(f"Already gave {KEY_DEMAND}{KEY_IDX_START}" 

1772 f"{demand_id}{KEY_IDX_END}", oline, line_idx) 

1773 str_lst = str.split(value, CSV_SEPARATOR) 

1774 if list.__len__(str_lst) != 5: 

1775 raise __pe( 

1776 f"Demand {demand_id} must have 5 entries, but got: " 

1777 f"{str_lst!r}", oline, line_idx) 

1778 dlst.extend((int(str_lst[0]), int(str_lst[1]), 

1779 int(str_lst[2]), float(str_lst[3]), 

1780 float(str_lst[4]))) 

1781 

1782 elif str.startswith(key, KEY_PRODUCTION_TIME): 

1783 if n_products is None: 

1784 raise __pe(f"Must define {KEY_N_PRODUCTS} before " 

1785 f"{KEY_PRODUCTION_TIME}", oline, line_idx) 

1786 if n_stations is None: 

1787 raise __pe(f"Must define {KEY_N_STATIONS} before" 

1788 f" {KEY_PRODUCTION_TIME}", oline, line_idx) 

1789 station, product = str.split( 

1790 __get_key_index(key), CSV_SEPARATOR) 

1791 station_id: int = check_to_int_range( 

1792 station, "station", 0, n_stations - 1) 

1793 product_id = check_to_int_range( 

1794 product, "product", 0, n_products - 1) 

1795 

1796 if station_product_times is None: 

1797 station_product_times = \ 

1798 [[[] for _ in range(n_products)] 

1799 for __ in range(n_stations)] 

1800 

1801 if str.startswith(value, KEY_PRODUCTION_TIME): 

1802 station, product = str.split( 

1803 __get_key_index(value), CSV_SEPARATOR) 

1804 use_station_id: int = check_to_int_range( 

1805 station, "station", 0, n_stations - 1) 

1806 use_product_id = check_to_int_range( 

1807 product, "product", 0, n_products - 1) 

1808 station_product_times[station_id][product_id] = ( 

1809 station_product_times)[use_station_id][use_product_id] 

1810 else: 

1811 mpd: list[float] = station_product_times[ 

1812 station_id][product_id] 

1813 if list.__len__(mpd) > 0: 

1814 raise __pe( 

1815 f"Already gave {KEY_PRODUCTION_TIME}" 

1816 f"{KEY_IDX_START}{station_id}{CSV_SEPARATOR}" 

1817 f"{product_id}{KEY_IDX_END}", oline, line_idx) 

1818 mpd.extend( 

1819 map(float, str.split(value, CSV_SEPARATOR))) 

1820 else: 

1821 infos[key] = value 

1822 

1823 if name is None: 

1824 raise ValueError(f"Did not specify instance name ({KEY_NAME}).") 

1825 if n_products is None: 

1826 raise ValueError("Did not specify instance n_products" 

1827 f" ({KEY_N_PRODUCTS}).") 

1828 if n_customers is None: 

1829 raise ValueError("Did not specify instance n_customers" 

1830 f" ({KEY_N_CUSTOMERS}).") 

1831 if n_stations is None: 

1832 raise ValueError("Did not specify instance n_stations" 

1833 f" ({KEY_N_STATIONS}).") 

1834 if n_demands is None: 

1835 raise ValueError("Did not specify instance n_demands" 

1836 f" ({KEY_N_DEMANDS}).") 

1837 if time_end_warmup is None: 

1838 raise ValueError("Did not specify instance time_end_warmup" 

1839 f" ({KEY_TIME_END_WARMUP}).") 

1840 if time_end_measure is None: 

1841 raise ValueError("Did not specify instance time_end_measure" 

1842 f" ({KEY_TIME_END_MEASURE}).") 

1843 if routes is None: 

1844 raise ValueError(f"Did not specify instance routes ({KEY_ROUTE}).") 

1845 if demands is None: 

1846 raise ValueError(f"Did not specify instance demands ({KEY_DEMAND}).") 

1847 if in_warehouse is None: 

1848 raise ValueError("Did not specify instance warehouse values" 

1849 f" ({KEY_IN_WAREHOUSE}).") 

1850 if station_product_times is None: 

1851 raise ValueError("Did not specify per-station product production" 

1852 f"times ({KEY_PRODUCTION_TIME}).") 

1853 

1854 return Instance(name, n_products, n_customers, n_stations, n_demands, 

1855 time_end_warmup, time_end_measure, 

1856 routes, demands, in_warehouse, station_product_times, 

1857 infos) 

1858 

1859 

1860@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) 

1861def compute_finish_time(start_time: float, amount: int, 

1862 production_times: np.ndarray) -> float: 

1863 """ 

1864 Compute the time when one job is finished. 

1865 

1866 The production times are cyclic intervals of unit production times and 

1867 interval ends. 

1868 

1869 :param start_time: the starting time of the job 

1870 :param amount: the number of units to be produced 

1871 :param production_times: the production times array 

1872 :return: the end time 

1873 

1874 Here, the production time is 10 time units / 1 product unit, valid until 

1875 end time 100. 

1876 

1877 >>> compute_finish_time(0.0, 1, np.array((10.0, 100.0), np.float64)) 

1878 10.0 

1879 

1880 Here, the production time is 10 time units / 1 product unit, valid until 

1881 end time 100. We begin producing at time unit 250. Since the production 

1882 periods are cyclic, this is OK: we would be halfway through the third 

1883 production period when the request comes in. It will consume 10 time units 

1884 and be done at time unit 260. 

1885 

1886 >>> compute_finish_time(250.0, 1, np.array((10.0, 100.0))) 

1887 260.0 

1888 

1889 Here, the end time of the production time validity is at time unit 100. 

1890 However, we begin producing 1 product unit at time step 90. This unit will 

1891 use 10 time units, meaning that its production is exactly finished when 

1892 the production time validity ends. 

1893 It will be finished at time step 100. 

1894 

1895 >>> compute_finish_time(90.0, 1, np.array((10.0, 100.0))) 

1896 100.0 

1897 

1898 Here, the end time of the production time validity is at time unit 100. 

1899 However, we begin producing 1 product unit at time step 95. This unit would 

1900 use 10 time units. It will use these units, even though this extends beyond 

1901 the end of the production time window. 

1902 

1903 >>> compute_finish_time(95.0, 1, np.array((10.0, 100.0))) 

1904 105.0 

1905 

1906 Now we have two production periods. The production begins again at time 

1907 step 95. It will use 10 time units, even though this extends into the 

1908 second period. 

1909 

1910 >>> compute_finish_time(95.0, 1, np.array((10.0, 100.0, 20.0, 200.0))) 

1911 105.0 

1912 

1913 Now things get more complex. We want to do 10 units of product. 

1914 We start in the first period, so one unit will be completed there. 

1915 This takes the starting time for the next job to 105, which is in the 

1916 second period. Here, one unit of product takes 20 time units. We can 

1917 finish producing one unit until time 125 and start the production of a 

1918 second one, taking until 145. Now the remaining three units are produced 

1919 until time 495 

1920 >>> compute_finish_time(95.0, 10, np.array(( 

1921 ... 10.0, 100.0, 20.0, 140.0, 50.0, 5000.0))) 

1922 495.0 

1923 >>> 95 + (1*10 + 2*20 + 7*50) 

1924 495 

1925 

1926 We again produce 10 product units starting at time step 95. The first one 

1927 takes 10 time units, taking us into the second production interval at time 

1928 105. Then we can again do two units here, which consume 40 time units, 

1929 taking us over the edge into the third interval at time unit 145. Here we 

1930 do two units using 50 time units. We ahen are at time 245, which wraps back 

1931 to 45. So the remaining 5 units take 10 time units each. 

1932 

1933 >>> compute_finish_time(95.0, 10, np.array(( 

1934 ... 10.0, 100.0, 20.0, 140.0, 50.0, 200.0))) 

1935 295.0 

1936 >>> 95 + (1*10 + 2*20 + 2*50 + 5*10) 

1937 295 

1938 

1939 This is the same as the last example, but this time, the last interval 

1940 (3 time units until 207) is skipped over by the long production of the 

1941 second 50-time-unit product. 

1942 

1943 >>> compute_finish_time(95.0, 10, np.array(( 

1944 ... 10.0, 100.0, 20.0, 140.0, 50.0, 200.0, 3.0, 207.0))) 

1945 295.0 

1946 >>> 95 + (1*10 + 2*20 + 2*50 + 5*10) 

1947 295 

1948 

1949 Production unit times may extend beyond the intervals. 

1950 

1951 >>> compute_finish_time(0.0, 5, np.array((1000.0, 100.0, 10.0, 110.0))) 

1952 5000.0 

1953 >>> 

1954 5 * 1000 

1955 """ 

1956 time_mod: Final[float] = production_times[-1] 

1957 low_end: Final[int] = len(production_times) 

1958 total: Final[int] = low_end // 2 

1959 

1960 # First, we need to find the segment in the production cycle 

1961 # where the production begins. We use a binary search for that. 

1962 remaining: int | float = amount 

1963 seg_start: float = start_time % time_mod 

1964 low: int = 0 

1965 high: int = total 

1966 while low < high: 

1967 mid: int = (low + high) // 2 

1968 th: float = production_times[mid * 2 + 1] 

1969 if th <= seg_start: 

1970 low = mid + 1 

1971 else: 

1972 high = mid - 1 

1973 low *= 2 

1974 

1975 # Now we can cycle through the production cycle until the product has 

1976 # been produced. 

1977 while True: 

1978 max_time = production_times[low + 1] 

1979 while max_time <= seg_start: 

1980 low += 2 

1981 if low >= low_end: 

1982 low = 0 

1983 seg_start = 0.0 

1984 max_time = production_times[low + 1] 

1985 

1986 unit_time = production_times[low] 

1987 can_do: int = ceil(min( 

1988 max_time - seg_start, remaining) / unit_time) 

1989 duration = can_do * unit_time 

1990 seg_start += duration 

1991 start_time += duration 

1992 remaining -= can_do 

1993 if remaining <= 0: 

1994 return float(start_time) 

1995 

1996 

1997def store_instances(dest: str, instances: Iterable[Instance]) -> None: 

1998 """ 

1999 Store an iterable of instances to the given directory. 

2000 

2001 :param dest: the destination directory 

2002 :param instances: the instances 

2003 """ 

2004 dest_dir: Final[Path] = Path(dest) 

2005 dest_dir.ensure_dir_exists() 

2006 

2007 if not isinstance(instances, Iterable): 

2008 raise type_error(instances, "instances", Iterable) 

2009 names: Final[set[str]] = set() 

2010 for i, instance in enumerate(instances): 

2011 if not isinstance(instance, Instance): 

2012 raise type_error(instance, f"instance[{i}]", Instance) 

2013 name: str = instance.name 

2014 if name in names: 

2015 raise ValueError( 

2016 f"Name {name!r} of instance {i} already occurred!") 

2017 dest_file = dest_dir.resolve_inside(f"{name}.txt") 

2018 if dest_file.exists(): 

2019 raise ValueError(f"File {dest_file!r} already exists, cannot " 

2020 f"store {i}th instance {name!r}.") 

2021 try: 

2022 with dest_file.open_for_write() as stream: 

2023 write_lines(to_stream(instance), stream) 

2024 except OSError as ioe: 

2025 raise ValueError(f"Error when writing instance {i} with name " 

2026 f"{name!r} to file {dest_file!r}.") from ioe 

2027 

2028 

2029def instance_sort_key(inst: Instance) -> str: 

2030 """ 

2031 Get a sort key for instances. 

2032 

2033 :param inst: the instance 

2034 :return: the sort key 

2035 """ 

2036 return inst.name 

2037 

2038 

2039def load_instances( 

2040 source: str, file_filter: Callable[[Path], bool] = lambda _: True)\ 

2041 -> tuple[Instance, ...]: 

2042 """ 

2043 Load the instances from a given irectory. 

2044 

2045 This function iterates over the files in a directory and applies 

2046 :func:`from_stream` to each text file (ends with `.txt`) that it finds 

2047 and that is accepted by the filter `file_filter`. (The default file 

2048 filter always returns `True`, i.e., accepts all files.) 

2049 

2050 :param source: the source directory 

2051 :param file_filter: a filter for files. Here you can provide a function 

2052 or lambda that returns `True` if a file should be loaded and `False` 

2053 otherwise. Notice that only `.txt` files are considered either way. 

2054 :return: the tuple of instances 

2055 

2056 >>> inst1 = Instance( 

2057 ... name="test1", n_products=1, n_customers=1, n_stations=2, 

2058 ... n_demands=1, time_end_warmup=10, time_end_measure=4000, 

2059 ... routes=[[0, 1]], 

2060 ... demands=[[0, 0, 0, 10, 20, 100]], 

2061 ... warehous_at_t0=[0], 

2062 ... station_product_unit_times=[[[10.0, 10000.0]], 

2063 ... [[30.0, 10000.0]]]) 

2064 

2065 >>> inst2 = Instance( 

2066 ... name="test2", n_products=2, n_customers=1, n_stations=2, 

2067 ... n_demands=3, time_end_warmup=21, time_end_measure=10000, 

2068 ... routes=[[0, 1], [1, 0]], 

2069 ... demands=[[0, 0, 1, 10, 20, 90], [1, 0, 0, 5, 22, 200], 

2070 ... [2, 0, 1, 7, 30, 200]], 

2071 ... warehous_at_t0=[2, 1], 

2072 ... station_product_unit_times=[[[10.0, 50.0, 15.0, 100.0], 

2073 ... [ 5.0, 20.0, 7.0, 35.0, 4.0, 50.0]], 

2074 ... [[ 5.0, 24.0, 7.0, 80.0], 

2075 ... [ 3.0, 21.0, 6.0, 50.0,]]]) 

2076 

2077 >>> from pycommons.io.temp import temp_dir 

2078 >>> with temp_dir() as td: 

2079 ... store_instances(td, [inst2, inst1]) 

2080 ... res = load_instances(td) 

2081 >>> res == (inst1, inst2) 

2082 True 

2083 """ 

2084 if not callable(file_filter): 

2085 raise type_error(file_filter, "file_filter", call=True) 

2086 src: Final[Path] = directory_path(source) 

2087 instances: Final[list[Instance]] = [] 

2088 for file in src.list_dir(files=True, directories=False): 

2089 if file.endswith(".txt") and file_filter(file): 

2090 with file.open_for_read() as stream: 

2091 instances.append(from_stream(stream)) 

2092 if list.__len__(instances) <= 0: 

2093 raise ValueError(f"Found no instances in directory {src!r}.") 

2094 instances.sort(key=instance_sort_key) 

2095 return tuple(instances)