job-shop-lib 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ """Contains factory functions for creating node feature encoders."""
2
+
3
+ from enum import Enum
4
+
5
+ from job_shop_lib.dispatching.feature_observers import (
6
+ IsReadyObserver,
7
+ EarliestStartTimeObserver,
8
+ FeatureObserver,
9
+ DurationObserver,
10
+ IsScheduledObserver,
11
+ PositionInJobObserver,
12
+ RemainingOperationsObserver,
13
+ IsCompletedObserver,
14
+ )
15
+
16
+
17
+ class FeatureObserverType(str, Enum):
18
+ """Enumeration of node feature creator types for the job shop scheduling
19
+ problem."""
20
+
21
+ IS_READY = "is_ready"
22
+ EARLIEST_START_TIME = "earliest_start_time"
23
+ DURATION = "duration"
24
+ IS_SCHEDULED = "is_scheduled"
25
+ POSITION_IN_JOB = "position_in_job"
26
+ REMAINING_OPERATIONS = "remaining_operations"
27
+ IS_COMPLETED = "is_completed"
28
+ COMPOSITE = "composite"
29
+
30
+
31
+ def feature_observer_factory(
32
+ node_feature_creator_type: str | FeatureObserverType,
33
+ **kwargs,
34
+ ) -> FeatureObserver:
35
+ """Creates and returns a node feature creator based on the specified
36
+ node feature creator type.
37
+
38
+ Args:
39
+ node_feature_creator_type:
40
+ The type of node feature creator to create.
41
+ **kwargs:
42
+ Additional keyword arguments to pass to the node
43
+ feature creator constructor.
44
+
45
+ Returns:
46
+ A node feature creator instance.
47
+ """
48
+ mapping: dict[FeatureObserverType, type[FeatureObserver]] = {
49
+ FeatureObserverType.IS_READY: IsReadyObserver,
50
+ FeatureObserverType.EARLIEST_START_TIME: EarliestStartTimeObserver,
51
+ FeatureObserverType.DURATION: DurationObserver,
52
+ FeatureObserverType.IS_SCHEDULED: IsScheduledObserver,
53
+ FeatureObserverType.POSITION_IN_JOB: PositionInJobObserver,
54
+ FeatureObserverType.REMAINING_OPERATIONS: RemainingOperationsObserver,
55
+ FeatureObserverType.IS_COMPLETED: IsCompletedObserver,
56
+ }
57
+ feature_creator = mapping[node_feature_creator_type] # type: ignore[index]
58
+ return feature_creator(**kwargs)
@@ -0,0 +1,113 @@
1
+ """Home of the `FeatureObserver` class and `FeatureType` enum."""
2
+
3
+ import enum
4
+
5
+ import numpy as np
6
+ from job_shop_lib import ScheduledOperation
7
+ from job_shop_lib.dispatching import Dispatcher, DispatcherObserver
8
+
9
+
10
+ class FeatureType(str, enum.Enum):
11
+ """Types of features that can be extracted."""
12
+
13
+ OPERATIONS = "operations"
14
+ MACHINES = "machines"
15
+ JOBS = "jobs"
16
+
17
+
18
+ class FeatureObserver(DispatcherObserver):
19
+ """Base class for feature observers."""
20
+
21
+ def __init__(
22
+ self,
23
+ dispatcher: Dispatcher,
24
+ feature_types: list[FeatureType] | FeatureType | None = None,
25
+ feature_size: dict[FeatureType, int] | int = 1,
26
+ is_singleton: bool = True,
27
+ subscribe: bool = True,
28
+ ):
29
+ feature_types = self.get_feature_types_list(feature_types)
30
+ if isinstance(feature_size, int):
31
+ feature_size = {
32
+ feature_type: feature_size for feature_type in feature_types
33
+ }
34
+ super().__init__(dispatcher, is_singleton, subscribe)
35
+
36
+ number_of_entities = {
37
+ FeatureType.OPERATIONS: dispatcher.instance.num_operations,
38
+ FeatureType.MACHINES: dispatcher.instance.num_machines,
39
+ FeatureType.JOBS: dispatcher.instance.num_jobs,
40
+ }
41
+ self.feature_dimensions = {
42
+ feature_type: (
43
+ number_of_entities[feature_type],
44
+ feature_size[feature_type],
45
+ )
46
+ for feature_type in feature_types
47
+ }
48
+ self.features = {
49
+ feature_type: np.zeros(
50
+ self.feature_dimensions[feature_type],
51
+ dtype=np.float32,
52
+ )
53
+ for feature_type in feature_types
54
+ }
55
+ self.initialize_features()
56
+
57
+ def initialize_features(self):
58
+ """Initializes the features based on the current state of the
59
+ dispatcher."""
60
+
61
+ def update(self, scheduled_operation: ScheduledOperation):
62
+ """Updates the features based on the scheduled operation.
63
+
64
+ By default, this method just calls `initialize_features`.
65
+
66
+ Args:
67
+ scheduled_operation:
68
+ The operation that has been scheduled.
69
+ """
70
+ self.initialize_features()
71
+
72
+ def reset(self):
73
+ """Sets features to zero and calls to `initialize_features`."""
74
+ self.set_features_to_zero()
75
+ self.initialize_features()
76
+
77
+ def set_features_to_zero(
78
+ self, exclude: FeatureType | list[FeatureType] | None = None
79
+ ):
80
+ """Sets features to zero."""
81
+ if exclude is None:
82
+ exclude = []
83
+ if isinstance(exclude, FeatureType):
84
+ exclude = [exclude]
85
+
86
+ for feature_type in self.features:
87
+ if feature_type in exclude:
88
+ continue
89
+ self.features[feature_type][:] = 0.0
90
+
91
+ @staticmethod
92
+ def get_feature_types_list(
93
+ feature_types: list[FeatureType] | FeatureType | None,
94
+ ) -> list[FeatureType]:
95
+ """Returns a list of feature types.
96
+
97
+ Args:
98
+ feature_types:
99
+ A list of feature types or a single feature type. If `None`,
100
+ all feature types are returned.
101
+ """
102
+ if isinstance(feature_types, FeatureType):
103
+ feature_types = [feature_types]
104
+ if feature_types is None:
105
+ feature_types = list(FeatureType)
106
+ return feature_types
107
+
108
+ def __str__(self):
109
+ out = [self.__class__.__name__, ":\n"]
110
+ out.append("-" * len(out[0]))
111
+ for feature_type, feature in self.features.items():
112
+ out.append(f"\n{feature_type.value}:\n{feature}")
113
+ return "".join(out)
@@ -0,0 +1,98 @@
1
+ """Home of the `IsCompletedObserver` class."""
2
+
3
+ from typing import Iterable
4
+
5
+ import numpy as np
6
+
7
+ from job_shop_lib import ScheduledOperation
8
+ from job_shop_lib.dispatching import Dispatcher
9
+ from job_shop_lib.dispatching.feature_observers import (
10
+ FeatureObserver,
11
+ FeatureType,
12
+ RemainingOperationsObserver,
13
+ )
14
+
15
+
16
+ class IsCompletedObserver(FeatureObserver):
17
+ """Observer that adds a binary feature indicating whether each operation,
18
+ machine, or job has been completed."""
19
+
20
+ def __init__(
21
+ self,
22
+ dispatcher: Dispatcher,
23
+ feature_types: list[FeatureType] | FeatureType | None = None,
24
+ ):
25
+ feature_types = self.get_feature_types_list(feature_types)
26
+ self.remaining_ops_per_machine = np.zeros(
27
+ (dispatcher.instance.num_machines, 1), dtype=int
28
+ )
29
+ self.remaining_ops_per_job = np.zeros(
30
+ (dispatcher.instance.num_jobs, 1), dtype=int
31
+ )
32
+ super().__init__(dispatcher, feature_types, feature_size=1)
33
+
34
+ def initialize_features(self):
35
+ self._initialize_remaining_operations()
36
+
37
+ def update(self, scheduled_operation: ScheduledOperation):
38
+ if FeatureType.OPERATIONS in self.features:
39
+ # operation_id = scheduled_operation.operation.operation_id
40
+ # self.features[FeatureType.OPERATIONS][operation_id, 0] = 1
41
+ completed_operations = [
42
+ op.operation_id
43
+ for op in self.dispatcher.completed_operations()
44
+ ]
45
+ self.features[FeatureType.OPERATIONS][completed_operations, 0] = 1
46
+ if FeatureType.MACHINES in self.features:
47
+ machine_id = scheduled_operation.machine_id
48
+ self.remaining_ops_per_machine[machine_id, 0] -= 1
49
+ is_completed = self.remaining_ops_per_machine[machine_id, 0] == 0
50
+ self.features[FeatureType.MACHINES][machine_id, 0] = is_completed
51
+ if FeatureType.JOBS in self.features:
52
+ job_id = scheduled_operation.job_id
53
+ self.remaining_ops_per_job[job_id, 0] -= 1
54
+ is_completed = self.remaining_ops_per_job[job_id, 0] == 0
55
+ self.features[FeatureType.JOBS][job_id, 0] = is_completed
56
+
57
+ def _initialize_remaining_operations(self):
58
+ remaining_ops_observer = self._get_remaining_operations_observer(
59
+ self.dispatcher, self.features
60
+ )
61
+ if remaining_ops_observer is not None:
62
+ if FeatureType.JOBS in self.features:
63
+ self.remaining_ops_per_job = remaining_ops_observer.features[
64
+ FeatureType.JOBS
65
+ ].copy()
66
+ if FeatureType.MACHINES in self.features:
67
+ self.remaining_ops_per_machine = (
68
+ remaining_ops_observer.features[
69
+ FeatureType.MACHINES
70
+ ].copy()
71
+ )
72
+ return
73
+
74
+ # If there is no remaining operations observer, we need to
75
+ # compute the remaining operations ourselves.
76
+ # We iterate over all operations using scheduled_operations
77
+ # instead of uncompleted_operations, because in this case
78
+ # they will output the same operations, and the former is slightly
79
+ # more efficient.
80
+ for operation in self.dispatcher.unscheduled_operations():
81
+ if FeatureType.JOBS in self.features:
82
+ self.remaining_ops_per_job[operation.job_id, 0] += 1
83
+ if FeatureType.MACHINES in self.features:
84
+ self.remaining_ops_per_machine[operation.machine_id, 0] += 1
85
+
86
+ def _get_remaining_operations_observer(
87
+ self, dispatcher: Dispatcher, feature_types: Iterable[FeatureType]
88
+ ) -> RemainingOperationsObserver | None:
89
+ for observer in dispatcher.subscribers:
90
+ if not isinstance(observer, RemainingOperationsObserver):
91
+ continue
92
+ has_same_features = all(
93
+ feature_type in observer.features
94
+ for feature_type in feature_types
95
+ )
96
+ if has_same_features:
97
+ return observer
98
+ return None
@@ -0,0 +1,40 @@
1
+ """Home of the `IsReadyObserver` class."""
2
+
3
+ from job_shop_lib.dispatching import Dispatcher
4
+ from job_shop_lib.dispatching.feature_observers import (
5
+ FeatureObserver,
6
+ FeatureType,
7
+ )
8
+
9
+
10
+ class IsReadyObserver(FeatureObserver):
11
+ """Feature creator that adds a binary feature indicating if the operation
12
+ is ready to be dispatched."""
13
+
14
+ def __init__(
15
+ self,
16
+ dispatcher: Dispatcher,
17
+ feature_types: list[FeatureType] | FeatureType | None = None,
18
+ subscribe: bool = True,
19
+ ):
20
+ super().__init__(
21
+ dispatcher, feature_types, feature_size=1, subscribe=subscribe
22
+ )
23
+
24
+ def initialize_features(self):
25
+ self.set_features_to_zero()
26
+ for feature_type, feature in self.features.items():
27
+ node_ids = self._get_ready_nodes(feature_type)
28
+ feature[node_ids, 0] = 1.0
29
+
30
+ def _get_ready_nodes(self, feature_type: FeatureType) -> list[int]:
31
+ mapping = {
32
+ FeatureType.OPERATIONS: self._get_ready_operation_nodes,
33
+ FeatureType.MACHINES: self.dispatcher.available_machines,
34
+ FeatureType.JOBS: self.dispatcher.available_jobs,
35
+ }
36
+ return mapping[feature_type]()
37
+
38
+ def _get_ready_operation_nodes(self) -> list[int]:
39
+ available_operations = self.dispatcher.available_operations()
40
+ return [operation.operation_id for operation in available_operations]
@@ -0,0 +1,34 @@
1
+ """Home of the `IsScheduledObserver` class."""
2
+
3
+ from job_shop_lib import ScheduledOperation
4
+ from job_shop_lib.dispatching.feature_observers import (
5
+ FeatureObserver,
6
+ FeatureType,
7
+ )
8
+
9
+
10
+ class IsScheduledObserver(FeatureObserver):
11
+ """Observer that updates features based on scheduling operations.
12
+
13
+ This observer tracks which operations have been scheduled and updates
14
+ feature matrices accordingly. It updates a feature in the
15
+ `FeatureType.OPERATIONS` matrix to indicate that an operation has been
16
+ scheduled. Additionally, it counts the number of uncompleted but
17
+ scheduled operations for each machine and job, updating the respective
18
+ `FeatureType.MACHINES` and `FeatureType.JOBS` feature matrices.
19
+ """
20
+
21
+ def update(self, scheduled_operation: ScheduledOperation):
22
+ if FeatureType.OPERATIONS in self.features:
23
+ self.features[FeatureType.OPERATIONS][
24
+ scheduled_operation.operation.operation_id, 0
25
+ ] = 1.0
26
+
27
+ ongoing_operations = self.dispatcher.ongoing_operations()
28
+ self.set_features_to_zero(exclude=FeatureType.OPERATIONS)
29
+ for scheduled_op in ongoing_operations:
30
+ if FeatureType.MACHINES in self.features:
31
+ machine_id = scheduled_op.machine_id
32
+ self.features[FeatureType.MACHINES][machine_id, 0] += 1.0
33
+ if FeatureType.JOBS in self.features:
34
+ self.features[FeatureType.JOBS][scheduled_op.job_id, 0] += 1.0
@@ -0,0 +1,39 @@
1
+ """Home of the `PositionInJobObserver` class."""
2
+
3
+ from job_shop_lib.dispatching import Dispatcher
4
+ from job_shop_lib import ScheduledOperation
5
+ from job_shop_lib.dispatching.feature_observers import (
6
+ FeatureObserver,
7
+ FeatureType,
8
+ )
9
+
10
+
11
+ class PositionInJobObserver(FeatureObserver):
12
+ """Observer that adds a feature indicating the position of
13
+ operations in their respective jobs.
14
+
15
+ Positions are adjusted dynamically as operations are scheduled.
16
+ """
17
+
18
+ def __init__(self, dispatcher: Dispatcher, subscribe: bool = True):
19
+ super().__init__(
20
+ dispatcher,
21
+ feature_types=[FeatureType.OPERATIONS],
22
+ feature_size=1,
23
+ subscribe=subscribe,
24
+ )
25
+
26
+ def initialize_features(self):
27
+ for operation in self.dispatcher.unscheduled_operations():
28
+ self.features[FeatureType.OPERATIONS][
29
+ operation.operation_id, 0
30
+ ] = operation.position_in_job
31
+
32
+ def update(self, scheduled_operation: ScheduledOperation):
33
+ job = self.dispatcher.instance.jobs[scheduled_operation.job_id]
34
+ for new_position_in_job, operation in enumerate(
35
+ job[scheduled_operation.position_in_job + 1 :]
36
+ ):
37
+ self.features[FeatureType.OPERATIONS][
38
+ operation.operation_id, 0
39
+ ] = new_position_in_job
@@ -0,0 +1,54 @@
1
+ """Home of the `RemainingOperationsObserver` class."""
2
+
3
+ from job_shop_lib import ScheduledOperation
4
+ from job_shop_lib.dispatching import Dispatcher
5
+ from job_shop_lib.dispatching.feature_observers import (
6
+ FeatureObserver,
7
+ FeatureType,
8
+ )
9
+
10
+
11
+ class RemainingOperationsObserver(FeatureObserver):
12
+ """Adds a feature indicating the number of remaining operations for each
13
+ job and machine.
14
+
15
+ It does not support FeatureType.OPERATIONS.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ dispatcher: Dispatcher,
21
+ feature_types: list[FeatureType] | FeatureType | None = None,
22
+ subscribe: bool = True,
23
+ ):
24
+ if feature_types is None:
25
+ feature_types = [FeatureType.MACHINES, FeatureType.JOBS]
26
+
27
+ if (
28
+ feature_types == FeatureType.OPERATIONS
29
+ or FeatureType.OPERATIONS in feature_types
30
+ ):
31
+ raise ValueError("FeatureType.OPERATIONS is not supported.")
32
+ super().__init__(
33
+ dispatcher,
34
+ feature_types=feature_types,
35
+ feature_size=1,
36
+ subscribe=subscribe,
37
+ )
38
+
39
+ def initialize_features(self):
40
+ for operation in self.dispatcher.unscheduled_operations():
41
+ if FeatureType.JOBS in self.features:
42
+ self.features[FeatureType.JOBS][operation.job_id, 0] += 1
43
+ if FeatureType.MACHINES in self.features:
44
+ self.features[FeatureType.MACHINES][
45
+ operation.machine_id, 0
46
+ ] += 1
47
+
48
+ def update(self, scheduled_operation: ScheduledOperation):
49
+ if FeatureType.JOBS in self.features:
50
+ job_id = scheduled_operation.job_id
51
+ self.features[FeatureType.JOBS][job_id, 0] -= 1
52
+ if FeatureType.MACHINES in self.features:
53
+ machine_id = scheduled_operation.machine_id
54
+ self.features[FeatureType.MACHINES][machine_id, 0] -= 1
@@ -23,6 +23,26 @@ from job_shop_lib.graphs import JobShopGraph, EdgeType, NodeType, Node
23
23
 
24
24
 
25
25
  def build_disjunctive_graph(instance: JobShopInstance) -> JobShopGraph:
26
+ """Builds and returns a disjunctive graph for the given job shop instance.
27
+
28
+ This function creates a complete disjunctive graph from a JobShopInstance.
29
+ It starts by initializing a JobShopGraph object and proceeds by adding
30
+ disjunctive edges between operations using the same machine, conjunctive
31
+ edges between successive operations in the same job, and finally, special
32
+ source and sink nodes with their respective edges to and from all other
33
+ operations.
34
+
35
+ Edges have a "type" attribute indicating whether they are disjunctive or
36
+ conjunctive.
37
+
38
+ Args:
39
+ instance (JobShopInstance): The job shop instance for which to build
40
+ the graph.
41
+
42
+ Returns:
43
+ JobShopGraph: A JobShopGraph object representing the disjunctive graph
44
+ of the job shop scheduling problem.
45
+ """
26
46
  graph = JobShopGraph(instance)
27
47
  add_disjunctive_edges(graph)
28
48
  add_conjunctive_edges(graph)
@@ -6,6 +6,8 @@ import os
6
6
  import functools
7
7
  from typing import Any
8
8
 
9
+ import numpy as np
10
+
9
11
  from job_shop_lib import Operation
10
12
 
11
13
 
@@ -264,6 +266,58 @@ class JobShopInstance:
264
266
  [operation.machine_id for operation in job] for job in self.jobs
265
267
  ]
266
268
 
269
+ @functools.cached_property
270
+ def durations_matrix_array(self) -> np.ndarray:
271
+ """Returns the duration matrix of the instance as a numpy array.
272
+
273
+ The returned array has shape (num_jobs, max_num_operations_per_job).
274
+ Non-existing operations are filled with np.nan.
275
+
276
+ Example:
277
+ >>> jobs = [[Operation(0, 2), Operation(1, 3)], [Operation(0, 4)]]
278
+ >>> instance = JobShopInstance(jobs)
279
+ >>> instance.durations_matrix_array
280
+ array([[ 2., 2.],
281
+ [ 4., nan]], dtype=float32)
282
+ """
283
+ duration_matrix = self.durations_matrix
284
+ return self._fill_matrix_with_nans_2d(duration_matrix)
285
+
286
+ @functools.cached_property
287
+ def machines_matrix_array(self) -> np.ndarray:
288
+ """Returns the machines matrix of the instance as a numpy array.
289
+
290
+ The returned array has shape (num_jobs, max_num_operations_per_job,
291
+ max_num_machines_per_operation). Non-existing machines are filled with
292
+ np.nan.
293
+
294
+ Example:
295
+ >>> jobs = [
296
+ ... [Operation(machines=[0, 1], 2), Operation(machines=1, 3)],
297
+ ... [Operation(machines=0, 6)],
298
+ ... ]
299
+ >>> instance = JobShopInstance(jobs)
300
+ >>> instance.machines_matrix_array
301
+ array([[[ 0., 1.],
302
+ [ 1., nan]],
303
+ [[ 0., nan],
304
+ [nan, nan]]], dtype=float32)
305
+ """
306
+
307
+ machines_matrix = self.machines_matrix
308
+ if self.is_flexible:
309
+ # False positive from mypy, the type of machines_matrix is
310
+ # list[list[list[int]]] here
311
+ return self._fill_matrix_with_nans_3d(
312
+ machines_matrix # type: ignore[arg-type]
313
+ )
314
+
315
+ # False positive from mypy, the type of machines_matrix is
316
+ # list[list[int]] here
317
+ return self._fill_matrix_with_nans_2d(
318
+ machines_matrix # type: ignore[arg-type]
319
+ )
320
+
267
321
  @functools.cached_property
268
322
  def operations_by_machine(self) -> list[list[Operation]]:
269
323
  """Returns a list of lists of operations.
@@ -353,3 +407,50 @@ class JobShopInstance:
353
407
  def total_duration(self) -> int:
354
408
  """Returns the sum of the durations of all operations in all jobs."""
355
409
  return sum(self.job_durations)
410
+
411
+ @staticmethod
412
+ def _fill_matrix_with_nans_2d(matrix: list[list[int]]) -> np.ndarray:
413
+ """Fills a matrix with np.nan values.
414
+
415
+ Args:
416
+ matrix:
417
+ A list of lists of integers.
418
+
419
+ Returns:
420
+ A numpy array with the same shape as the input matrix, filled with
421
+ np.nan values.
422
+ """
423
+ max_length = max(len(row) for row in matrix)
424
+ squared_matrix = np.full(
425
+ (len(matrix), max_length), np.nan, dtype=np.float32
426
+ )
427
+ for i, row in enumerate(matrix):
428
+ squared_matrix[i, : len(row)] = row
429
+ return squared_matrix
430
+
431
+ @staticmethod
432
+ def _fill_matrix_with_nans_3d(matrix: list[list[list[int]]]) -> np.ndarray:
433
+ """Fills a 3D matrix with np.nan values.
434
+
435
+ Args:
436
+ matrix:
437
+ A list of lists of lists of integers.
438
+
439
+ Returns:
440
+ A numpy array with the same shape as the input matrix, filled with
441
+ np.nan values.
442
+ """
443
+ max_length = max(len(row) for row in matrix)
444
+ max_inner_length = len(matrix[0][0])
445
+ for row in matrix:
446
+ for inner_row in row:
447
+ max_inner_length = max(max_inner_length, len(inner_row))
448
+ squared_matrix = np.full(
449
+ (len(matrix), max_length, max_inner_length),
450
+ np.nan,
451
+ dtype=np.float32,
452
+ )
453
+ for i, row in enumerate(matrix):
454
+ for j, inner_row in enumerate(row):
455
+ squared_matrix[i, j, : len(inner_row)] = inner_row
456
+ return squared_matrix