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
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-30 03:25 +0000
1"""
2A production scheduling instance.
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.
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).
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.
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`.
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]`.
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.
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`.
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.
85Everything is specified, deterministic, and fixed.
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`).
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.
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.
111>>> name = "my_instance"
113The number of products be 3.
115>>> n_products = 3
117The number of customers be 5.
119>>> n_customers = 5
121The number of stations be 4.
123>>> n_stations = 4
125There will be 6 customer demands.
127>>> n_demands = 6
129The end of the warmup period.
131>>> time_end_warmup = 10
133The end of the measurement period.
135>>> time_end_measure = 10000
137Each product may take a different route through different stations.
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]
144Each demand is a tuple of demand_id, customer_id, product_id, amount,
145release time, and deadline.
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]
155There is a fixed amount of each product in the warehouse at time step 0.
157>>> warehous_at_t0 = [10, 0, 6]
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.
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]]
186We can (but do not need to) provide additional information as key-value pairs.
188>>> infos = {"source": "manually created",
189... "creation_date": "2025-11-09"}
191From all of this data, we can create the instance.
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'
200>>> instance.n_customers
2015
203>>> instance.n_stations
2044
206>>> instance.n_demands
2076
209>>> instance.n_products
2103
212>>> instance.routes
213((0, 3, 2), (0, 2, 1, 3), (1, 2, 3))
215>>> instance.time_end_warmup
21610.0
218>>> instance.time_end_measure
21910000.0
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))
235>>> instance.warehous_at_t0
236(10, 0, 6)
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.])))
247>>> instance.n_measurable_demands
2486
250>>> instance.n_measurable_demands_per_product
251(2, 2, 2)
253>>> dict(instance.infos)
254{'source': 'manually created', 'creation_date': '2025-11-09'}
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.
261>>> i2 = from_stream(to_stream(instance))
262>>> i2 is instance
263False
264>>> i2 == instance
265True
267You can see that the loaded instance has the same data as the stored one.
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
301True
302>>> i2.infos == instance.infos
303True
304"""
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)
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
336#: The maximum for the number of stations, products, or customers.
337MAX_ID: Final[int] = 1_000_000_000
339#: No value bigger than this is permitted in any tuple anywhere.
340MAX_VALUE: Final[int] = 2_147_483_647
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
356@dataclass(order=True, frozen=True)
357class Demand(Iterable[int | float]):
358 """
359 The record for demands.
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.
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.
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 """
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
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.
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}")
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}")
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)
452 def __str__(self) -> str:
453 """
454 Get a short string representation of the demand.
456 :return: the string representation
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)})")
468 def __getitem__(self, item: int) -> int | float:
469 """
470 Access an element of this demand via an index.
472 :param item: the index
473 :return: the demand value at that index
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}].")
505 def __iter__(self) -> Iterator[int | float]:
506 """
507 Iterate over the values in this demand.
509 :return: the demand iterable
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
523 def __len__(self) -> int:
524 """
525 Get the length of the demand record.
527 :returns `6`: always
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
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.
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
548 >>> ppl = repr_cache()
549 >>> k1 = __to_tuple([1, 2, 3], ppl)
550 >>> print(k1)
551 (1, 2, 3)
553 >>> __to_tuple({2}, ppl)
554 (2,)
556 >>> k2 = __to_tuple([1, 2, 3], ppl)
557 >>> print(k2)
558 (1, 2, 3)
559 >>> k1 is k2
560 True
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
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
574 >>> try:
575 ... __to_tuple([], ppl)
576 ... except Exception as e:
577 ... print(e)
578 row has length 0.
580 >>> __to_tuple([], ppl, empty_ok=True)
581 ()
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.
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}")
604 return cache(use_row)
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.
613 :param source: the source data
614 :param cache: the cache
615 :param empty_ok: are empty arrays OK?
616 :return: the arrays
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
626 >>> c = __to_npfloats([], ppl, empty_ok=True)
627 >>> c
628 array([], dtype=float64)
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))
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.
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
652 >>> ppl = repr_cache()
653 >>> k1 = __to_nested_tuples([(1, 2), [3, 2]], ppl)
654 >>> print(k1)
655 ((1, 2), (3, 2))
657 >>> k2 = __to_nested_tuples([(1, 2), (1, 2, 4), (1, 2)], ppl)
658 >>> print(k2)
659 ((1, 2), (1, 2, 4), (1, 2))
661 >>> k2[0] is k2[2]
662 True
664 >>> k1[0] is k2[0]
665 True
666 >>> k1[0] is k2[2]
667 True
669 >>> __to_nested_tuples([(1, 2), (1, 2, 4), (1, 2), []], ppl, True)
670 ((1, 2), (1, 2, 4), (1, 2), ())
672 >>> __to_nested_tuples([(), {}, []], ppl, True)
673 ()
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)
687 if (ins <= 0) and empty_ok: # if all inner tuples are empty,
688 dest.clear() # clear the tuple source
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!")
694 return cache(tuple(dest))
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.
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
711 >>> ppl = repr_cache()
712 >>> k1 = __to_tuples([(1, 2), [3, 2]], ppl)
713 >>> print(k1)
714 ((1, 2), (3, 2))
716 >>> k2 = __to_tuples([(1, 2), (1, 2, 4), (1, 2)], ppl)
717 >>> print(k2)
718 ((1, 2), (1, 2, 4), (1, 2))
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)
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.
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
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)
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.
756 :param source: the source
757 :param cache: the cache
758 :param empty_ok: are empty tuples OK?
759 :return: the nested tuples
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)
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.
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.
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
789 >>> ppl = repr_cache()
790 >>> _make_routes(2, 3, ((1, 2), (1, 0)), ppl)
791 ((1, 2), (1, 0))
793 >>> _make_routes(3, 3, ((1, 2), (1, 0), (0, 1, 2)), ppl)
794 ((1, 2), (1, 0), (0, 1, 2))
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)
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
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.
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
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))
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.
863 Each demand is a tuple of demand_id, customer_id, product_id, amount,
864 release time, and deadline.
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
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)
893 def __make_demand(ssss: Iterable[int | float],
894 ccc: Callable, *_) -> Demand:
895 return __to_demand(ssss, time_end_warmup, ccc)
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}?")
903 used_ids: set[int] = set()
904 min_id: int = 1000 * MAX_ID
905 max_id: int = -1000 * MAX_ID
906 dest: list[Demand] = []
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)
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}")
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}")
928 amount: int = demand.amount
929 if not 0 < amount < MAX_ID:
930 raise ValueError(f"demand[{i}].amount = {amount}.")
932 arrival: float = demand.arrival
933 if not (isfinite(arrival) and (0 < arrival < MAX_ID)):
934 raise ValueError(f"demand[{i}].arrival = {arrival}.")
936 deadline: float = demand.deadline
937 if not (isfinite(deadline) and arrival <= deadline < MAX_ID):
938 raise ValueError(f"demand[{i}].deadline = {deadline}.")
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)
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))
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.
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
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
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.
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.
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
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))
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
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)
1030 if tuple.__len__(routes) != n_products:
1031 raise ValueError("invalid routes!")
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
1077 return ret
1080def _make_infos(source: Iterable[tuple[str, str]] | Mapping[str, str] | None)\
1081 -> Mapping[str, str]:
1082 """
1083 Make the additional information record.
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)
1114class Instance(Component):
1115 """An instance of the Production Scheduling Problem."""
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.
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
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)
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
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
1202 cache: Final[Callable] = repr_cache() # the pool for resolving tuples
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)
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)
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)
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)
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)
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)
1284 def __str__(self):
1285 """
1286 Get the name of this instance.
1288 :return: the name of this instance
1289 """
1290 return self.name
1292 def _tuple(self) -> tuple:
1293 """
1294 Convert this object to a tuple.
1296 :return: the tuple
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))
1315 def __eq__(self, other):
1316 """
1317 Compare this object with another object.
1319 :param other: the other object
1320 :return: `NotImplemented` if the other object is not an `Instance`,
1321 otherwise the equality comparison result.
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()
1358 def __hash__(self) -> int:
1359 """
1360 Get the hash code of this object.
1362 :return: the hash code of this object
1363 """
1364 return hash(self._tuple())
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"
1394#: the key value split string
1395_KEY_VALUE_SPLIT: Final[str] = str.strip(KEY_VALUE_SEPARATOR)
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__
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__
1407#: the allowed characters in names
1408_ALLOWED_NAME_CHARS: Final[Callable[[str], bool]] = set(
1409 ascii_letters + digits + "_").__contains__
1412def to_stream(instance: Instance) -> Generator[str, None, None]:
1413 """
1414 Convert an instance to a stream of data.
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)
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}).")
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))}")
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}")
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))}")
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]}.")
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}"
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}"
1587def __get_key_index(full_key: str) -> str:
1588 """
1589 Extract the key index from a key.
1591 :param full_key: the full key
1592 :return: the key index
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
1607def __pe(message: str, oline: str, line_idx: int) -> ValueError:
1608 """
1609 Create a value error to be raised inside the parser.
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})")
1619def from_stream(stream: Iterable[str]) -> Instance:
1620 """
1621 Read an instance from a data stream.
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] = {}
1641 for line_idx, oline in enumerate(stream):
1642 line = str.strip(oline)
1643 if str.startswith(line, COMMENT_START):
1644 continue
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)
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
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)
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)
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)
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)
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)
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)
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)
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)]
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)
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)]
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])))
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)
1796 if station_product_times is None:
1797 station_product_times = \
1798 [[[] for _ in range(n_products)]
1799 for __ in range(n_stations)]
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
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}).")
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)
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.
1866 The production times are cyclic intervals of unit production times and
1867 interval ends.
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
1874 Here, the production time is 10 time units / 1 product unit, valid until
1875 end time 100.
1877 >>> compute_finish_time(0.0, 1, np.array((10.0, 100.0), np.float64))
1878 10.0
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.
1886 >>> compute_finish_time(250.0, 1, np.array((10.0, 100.0)))
1887 260.0
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.
1895 >>> compute_finish_time(90.0, 1, np.array((10.0, 100.0)))
1896 100.0
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.
1903 >>> compute_finish_time(95.0, 1, np.array((10.0, 100.0)))
1904 105.0
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.
1910 >>> compute_finish_time(95.0, 1, np.array((10.0, 100.0, 20.0, 200.0)))
1911 105.0
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
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.
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
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.
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
1949 Production unit times may extend beyond the intervals.
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
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
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]
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)
1997def store_instances(dest: str, instances: Iterable[Instance]) -> None:
1998 """
1999 Store an iterable of instances to the given directory.
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()
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
2029def instance_sort_key(inst: Instance) -> str:
2030 """
2031 Get a sort key for instances.
2033 :param inst: the instance
2034 :return: the sort key
2035 """
2036 return inst.name
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.
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.)
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
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]]])
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,]]])
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)