job-shop-lib 0.5.0__py3-none-any.whl → 1.0.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.
Files changed (93) hide show
  1. job_shop_lib/__init__.py +19 -8
  2. job_shop_lib/{base_solver.py → _base_solver.py} +1 -1
  3. job_shop_lib/{job_shop_instance.py → _job_shop_instance.py} +155 -81
  4. job_shop_lib/_operation.py +118 -0
  5. job_shop_lib/{schedule.py → _schedule.py} +102 -84
  6. job_shop_lib/{scheduled_operation.py → _scheduled_operation.py} +25 -49
  7. job_shop_lib/benchmarking/__init__.py +66 -43
  8. job_shop_lib/benchmarking/_load_benchmark.py +88 -0
  9. job_shop_lib/constraint_programming/__init__.py +13 -0
  10. job_shop_lib/{cp_sat/ortools_solver.py → constraint_programming/_ortools_solver.py} +77 -22
  11. job_shop_lib/dispatching/__init__.py +51 -42
  12. job_shop_lib/dispatching/{dispatcher.py → _dispatcher.py} +223 -130
  13. job_shop_lib/dispatching/_dispatcher_observer_config.py +67 -0
  14. job_shop_lib/dispatching/_factories.py +135 -0
  15. job_shop_lib/dispatching/{history_tracker.py → _history_observer.py} +6 -7
  16. job_shop_lib/dispatching/_optimal_operations_observer.py +113 -0
  17. job_shop_lib/dispatching/_ready_operation_filters.py +168 -0
  18. job_shop_lib/dispatching/_unscheduled_operations_observer.py +70 -0
  19. job_shop_lib/dispatching/feature_observers/__init__.py +51 -13
  20. job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py +212 -0
  21. job_shop_lib/dispatching/feature_observers/{duration_observer.py → _duration_observer.py} +20 -18
  22. job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py +289 -0
  23. job_shop_lib/dispatching/feature_observers/_factory.py +95 -0
  24. job_shop_lib/dispatching/feature_observers/_feature_observer.py +228 -0
  25. job_shop_lib/dispatching/feature_observers/_is_completed_observer.py +97 -0
  26. job_shop_lib/dispatching/feature_observers/_is_ready_observer.py +35 -0
  27. job_shop_lib/dispatching/feature_observers/{is_scheduled_observer.py → _is_scheduled_observer.py} +9 -5
  28. job_shop_lib/dispatching/feature_observers/{position_in_job_observer.py → _position_in_job_observer.py} +8 -10
  29. job_shop_lib/dispatching/feature_observers/{remaining_operations_observer.py → _remaining_operations_observer.py} +8 -26
  30. job_shop_lib/dispatching/rules/__init__.py +87 -0
  31. job_shop_lib/dispatching/rules/_dispatching_rule_factory.py +84 -0
  32. job_shop_lib/dispatching/rules/_dispatching_rule_solver.py +201 -0
  33. job_shop_lib/dispatching/{dispatching_rules.py → rules/_dispatching_rules_functions.py} +70 -16
  34. job_shop_lib/dispatching/rules/_machine_chooser_factory.py +71 -0
  35. job_shop_lib/dispatching/rules/_utils.py +128 -0
  36. job_shop_lib/exceptions.py +18 -0
  37. job_shop_lib/generation/__init__.py +19 -0
  38. job_shop_lib/generation/_general_instance_generator.py +165 -0
  39. job_shop_lib/generation/_instance_generator.py +133 -0
  40. job_shop_lib/{generators/transformations.py → generation/_transformations.py} +16 -12
  41. job_shop_lib/generation/_utils.py +124 -0
  42. job_shop_lib/graphs/__init__.py +30 -12
  43. job_shop_lib/graphs/{build_disjunctive_graph.py → _build_disjunctive_graph.py} +41 -3
  44. job_shop_lib/graphs/{build_agent_task_graph.py → _build_resource_task_graphs.py} +28 -26
  45. job_shop_lib/graphs/_constants.py +38 -0
  46. job_shop_lib/graphs/_job_shop_graph.py +320 -0
  47. job_shop_lib/graphs/_node.py +182 -0
  48. job_shop_lib/graphs/graph_updaters/__init__.py +26 -0
  49. job_shop_lib/graphs/graph_updaters/_disjunctive_graph_updater.py +108 -0
  50. job_shop_lib/graphs/graph_updaters/_graph_updater.py +57 -0
  51. job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py +155 -0
  52. job_shop_lib/graphs/graph_updaters/_utils.py +25 -0
  53. job_shop_lib/py.typed +0 -0
  54. job_shop_lib/reinforcement_learning/__init__.py +68 -0
  55. job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py +398 -0
  56. job_shop_lib/reinforcement_learning/_resource_task_graph_observation.py +329 -0
  57. job_shop_lib/reinforcement_learning/_reward_observers.py +87 -0
  58. job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py +443 -0
  59. job_shop_lib/reinforcement_learning/_types_and_constants.py +62 -0
  60. job_shop_lib/reinforcement_learning/_utils.py +199 -0
  61. job_shop_lib/visualization/__init__.py +0 -25
  62. job_shop_lib/visualization/gantt/__init__.py +48 -0
  63. job_shop_lib/visualization/gantt/_gantt_chart_creator.py +257 -0
  64. job_shop_lib/visualization/gantt/_gantt_chart_video_and_gif_creation.py +422 -0
  65. job_shop_lib/visualization/{gantt_chart.py → gantt/_plot_gantt_chart.py} +84 -21
  66. job_shop_lib/visualization/graphs/__init__.py +29 -0
  67. job_shop_lib/visualization/graphs/_plot_disjunctive_graph.py +418 -0
  68. job_shop_lib/visualization/graphs/_plot_resource_task_graph.py +389 -0
  69. {job_shop_lib-0.5.0.dist-info → job_shop_lib-1.0.0.dist-info}/METADATA +87 -55
  70. job_shop_lib-1.0.0.dist-info/RECORD +73 -0
  71. {job_shop_lib-0.5.0.dist-info → job_shop_lib-1.0.0.dist-info}/WHEEL +1 -1
  72. job_shop_lib/benchmarking/load_benchmark.py +0 -142
  73. job_shop_lib/cp_sat/__init__.py +0 -5
  74. job_shop_lib/dispatching/dispatching_rule_solver.py +0 -119
  75. job_shop_lib/dispatching/factories.py +0 -206
  76. job_shop_lib/dispatching/feature_observers/composite_feature_observer.py +0 -87
  77. job_shop_lib/dispatching/feature_observers/earliest_start_time_observer.py +0 -156
  78. job_shop_lib/dispatching/feature_observers/factory.py +0 -58
  79. job_shop_lib/dispatching/feature_observers/feature_observer.py +0 -113
  80. job_shop_lib/dispatching/feature_observers/is_completed_observer.py +0 -98
  81. job_shop_lib/dispatching/feature_observers/is_ready_observer.py +0 -40
  82. job_shop_lib/dispatching/pruning_functions.py +0 -116
  83. job_shop_lib/generators/__init__.py +0 -7
  84. job_shop_lib/generators/basic_generator.py +0 -197
  85. job_shop_lib/graphs/constants.py +0 -21
  86. job_shop_lib/graphs/job_shop_graph.py +0 -202
  87. job_shop_lib/graphs/node.py +0 -166
  88. job_shop_lib/operation.py +0 -122
  89. job_shop_lib/visualization/agent_task_graph.py +0 -257
  90. job_shop_lib/visualization/create_gif.py +0 -209
  91. job_shop_lib/visualization/disjunctive_graph.py +0 -210
  92. job_shop_lib-0.5.0.dist-info/RECORD +0 -48
  93. {job_shop_lib-0.5.0.dist-info → job_shop_lib-1.0.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,329 @@
1
+ """Contains wrappers for the environments."""
2
+
3
+ from typing import TypeVar, TypedDict, Generic, Any
4
+ from gymnasium import ObservationWrapper
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+ from job_shop_lib.reinforcement_learning import (
9
+ ObservationDict,
10
+ SingleJobShopGraphEnv,
11
+ MultiJobShopGraphEnv,
12
+ create_edge_type_dict,
13
+ map_values,
14
+ )
15
+ from job_shop_lib.graphs import NodeType
16
+ from job_shop_lib.dispatching.feature_observers import FeatureType
17
+
18
+ T = TypeVar("T", bound=np.number)
19
+ EnvType = TypeVar( # pylint: disable=invalid-name
20
+ "EnvType", bound=SingleJobShopGraphEnv | MultiJobShopGraphEnv
21
+ )
22
+
23
+ _NODE_TYPE_TO_FEATURE_TYPE = {
24
+ NodeType.OPERATION: FeatureType.OPERATIONS,
25
+ NodeType.MACHINE: FeatureType.MACHINES,
26
+ NodeType.JOB: FeatureType.JOBS,
27
+ }
28
+ _FEATURE_TYPE_STR_TO_NODE_TYPE = {
29
+ FeatureType.OPERATIONS.value: NodeType.OPERATION,
30
+ FeatureType.MACHINES.value: NodeType.MACHINE,
31
+ FeatureType.JOBS.value: NodeType.JOB,
32
+ }
33
+
34
+
35
+ class ResourceTaskGraphObservationDict(TypedDict):
36
+ """Represents a dictionary for resource task graph observations."""
37
+
38
+ edge_index_dict: dict[tuple[str, str, str], NDArray[np.int32]]
39
+ node_features_dict: dict[str, NDArray[np.float32]]
40
+ original_ids_dict: dict[str, NDArray[np.int32]]
41
+
42
+
43
+ # pylint: disable=line-too-long
44
+ class ResourceTaskGraphObservation(ObservationWrapper, Generic[EnvType]):
45
+ """Observation wrapper that converts an observation following the
46
+ :class:`ObservationDict` format to a format suitable to PyG's
47
+ [`HeteroData`](https://pytorch-geometric.readthedocs.io/en/latest/generated/torch_geometric.data.HeteroData.html).
48
+
49
+ In particular, the ``edge_index`` is converted into a ``edge_index_dict``
50
+ with keys ``(node_type_i, "to", node_type_j)``. The ``node_type_i`` and
51
+ ``node_type_j`` are the node types of the source and target nodes,
52
+ respectively.
53
+
54
+ Additionally, the node features are stored in a dictionary with keys
55
+ corresponding to the node type names under the ``node_features_dict`` key.
56
+
57
+ The node IDs are mapped to local IDs starting from 0. The
58
+ ``original_ids_dict`` contains the original node IDs before removing nodes.
59
+
60
+ Attributes:
61
+ global_to_local_id: A dictionary mapping global node IDs to local node
62
+ IDs for each node type.
63
+ type_ranges: A dictionary mapping node type names to (start, end) index
64
+ ranges.
65
+
66
+ Args:
67
+ env: The environment to wrap.
68
+ """
69
+
70
+ def __init__(self, env: EnvType):
71
+ super().__init__(env)
72
+ self.env = env # Unnecessary, but makes mypy happy
73
+ self.global_to_local_id = self._compute_id_mappings()
74
+ self.type_ranges = self._compute_node_type_ranges()
75
+ self._start_from_zero_mapping: dict[str, dict[int, int]] = {}
76
+
77
+ def step(self, action: tuple[int, int]):
78
+ """Takes a step in the environment.
79
+
80
+ Args:
81
+ action:
82
+ The action to take. The action is a tuple of two integers
83
+ (job_id, machine_id):
84
+ the job ID and the machine ID in which to schedule the
85
+ operation.
86
+
87
+ Returns:
88
+ A tuple containing the following elements:
89
+
90
+ - The observation of the environment.
91
+ - The reward obtained.
92
+ - Whether the environment is done.
93
+ - Whether the episode was truncated (always False).
94
+ - A dictionary with additional information. The dictionary
95
+ contains the following keys: "feature_names", the names of the
96
+ features in the observation; and "available_operations_with_ids",
97
+ a list of available actions in the form of (operation_id,
98
+ machine_id, job_id).
99
+ """
100
+ observation, reward, done, truncated, info = self.env.step(action)
101
+ new_observation = self.observation(observation)
102
+ new_info = self._info(info)
103
+ return new_observation, reward, done, truncated, new_info
104
+
105
+ def reset(self, *, seed: int | None = None, options: dict | None = None):
106
+ """Resets the environment.
107
+
108
+ Args:
109
+ seed:
110
+ Added to match the signature of the parent class. It is not
111
+ used in this method.
112
+ options:
113
+ Additional options to pass to the environment. Not used in
114
+ this method.
115
+
116
+ Returns:
117
+ A tuple containing the following elements:
118
+
119
+ - The observation of the environment.
120
+ - A dictionary with additional information, keys
121
+ include: "feature_names", the names of the features in the
122
+ observation; and "available_operations_with_ids", a list of
123
+ available a list of available actions in the form of
124
+ (operation_id, machine_id, job_id).
125
+ """
126
+ observation, info = self.env.reset()
127
+ new_observation = self.observation(observation)
128
+ new_info = self._info(info)
129
+ return new_observation, new_info
130
+
131
+ def _info(self, info: dict[str, Any]) -> dict[str, Any]:
132
+ """Updates the "available_operations_with_ids" key in the info
133
+ dictionary so that they start from 0 using the
134
+ `_start_from_zero_mapping` attribute.
135
+ """
136
+ new_available_operations_ids = []
137
+ for operation_id, machine_id, job_id in info[
138
+ "available_operations_with_ids"
139
+ ]:
140
+ if "operation" in self._start_from_zero_mapping:
141
+ operation_id = self._start_from_zero_mapping["operation"][
142
+ operation_id
143
+ ]
144
+ if "machine" in self._start_from_zero_mapping:
145
+ machine_id = self._start_from_zero_mapping["machine"][
146
+ machine_id
147
+ ]
148
+ if "job" in self._start_from_zero_mapping:
149
+ job_id = self._start_from_zero_mapping["job"][job_id]
150
+ new_available_operations_ids.append(
151
+ (operation_id, machine_id, job_id)
152
+ )
153
+ info["available_operations_with_ids"] = new_available_operations_ids
154
+ return info
155
+
156
+ def _compute_id_mappings(self) -> dict[int, int]:
157
+ """Computes mappings from global node IDs to type-local IDs.
158
+
159
+ Returns:
160
+ A dictionary mapping global node IDs to local node IDs for each
161
+ node type.
162
+ """
163
+ mappings = {}
164
+ for node_type in NodeType:
165
+ type_nodes = self.unwrapped.job_shop_graph.nodes_by_type[node_type]
166
+ if not type_nodes:
167
+ continue
168
+ # Create mapping from global ID to local ID
169
+ # (0 to len(type_nodes)-1)
170
+ type_mapping = {
171
+ node.node_id: local_id
172
+ for local_id, node in enumerate(type_nodes)
173
+ }
174
+ mappings.update(type_mapping)
175
+
176
+ return mappings
177
+
178
+ def _compute_node_type_ranges(self) -> dict[str, tuple[int, int]]:
179
+ """Computes index ranges for each node type.
180
+
181
+ Returns:
182
+ Dictionary mapping node type names to (start, end) index ranges
183
+ """
184
+ type_ranges = {}
185
+ for node_type in NodeType:
186
+ type_nodes = self.unwrapped.job_shop_graph.nodes_by_type[node_type]
187
+ if not type_nodes:
188
+ continue
189
+ start = min(node.node_id for node in type_nodes)
190
+ end = max(node.node_id for node in type_nodes) + 1
191
+ type_ranges[node_type.name.lower()] = (start, end)
192
+
193
+ return type_ranges
194
+
195
+ def observation(
196
+ self, observation: ObservationDict
197
+ ) -> ResourceTaskGraphObservationDict:
198
+ """Processes the observation data into the resource task graph format.
199
+
200
+ Args:
201
+ observation: The observation dictionary. It must NOT have padding.
202
+
203
+ Returns:
204
+ A dictionary containing the following keys:
205
+
206
+ - "edge_index_dict": A dictionary mapping edge types to edge index
207
+ arrays.
208
+ - "node_features_dict": A dictionary mapping node type names to
209
+ node feature arrays.
210
+ - "original_ids_dict": A dictionary mapping node type names to the
211
+ original node IDs before removing nodes.
212
+ """
213
+ edge_index_dict = create_edge_type_dict(
214
+ observation["edge_index"],
215
+ type_ranges=self.type_ranges,
216
+ relationship="to",
217
+ )
218
+ node_features_dict = self._create_node_features_dict(observation)
219
+ node_features_dict, original_ids_dict = self._remove_nodes(
220
+ node_features_dict, observation["removed_nodes"]
221
+ )
222
+
223
+ # mapping from global node ID to local node ID
224
+ for key, edge_index in edge_index_dict.items():
225
+ edge_index_dict[key] = map_values(
226
+ edge_index, self.global_to_local_id
227
+ )
228
+ # mapping so that ids start from 0 in edge index
229
+ self._start_from_zero_mapping = self._get_start_from_zero_mappings(
230
+ original_ids_dict
231
+ )
232
+ for (type_1, to, type_2), edge_index in edge_index_dict.items():
233
+ edge_index_dict[(type_1, to, type_2)][0] = map_values(
234
+ edge_index[0], self._start_from_zero_mapping[type_1]
235
+ )
236
+ edge_index_dict[(type_1, to, type_2)][1] = map_values(
237
+ edge_index[1], self._start_from_zero_mapping[type_2]
238
+ )
239
+
240
+ return {
241
+ "edge_index_dict": edge_index_dict,
242
+ "node_features_dict": node_features_dict,
243
+ "original_ids_dict": original_ids_dict,
244
+ }
245
+
246
+ @staticmethod
247
+ def _get_start_from_zero_mappings(
248
+ original_indices_dict: dict[str, NDArray[np.int32]]
249
+ ) -> dict[str, dict[int, int]]:
250
+ mappings = {}
251
+ for key, indices in original_indices_dict.items():
252
+ mappings[key] = {idx: i for i, idx in enumerate(indices)}
253
+ return mappings
254
+
255
+ def _create_node_features_dict(
256
+ self, observation: ObservationDict
257
+ ) -> dict[str, NDArray]:
258
+ """Creates a dictionary of node features for each node type.
259
+
260
+ Args:
261
+ observation: The observation dictionary.
262
+
263
+ Returns:
264
+ Dictionary mapping node type names to node features.
265
+ """
266
+
267
+ node_features_dict = {}
268
+ for node_type, feature_type in _NODE_TYPE_TO_FEATURE_TYPE.items():
269
+ if self.unwrapped.job_shop_graph.nodes_by_type[node_type]:
270
+ node_features_dict[feature_type.value] = observation[
271
+ feature_type.value
272
+ ]
273
+ continue
274
+ if feature_type != FeatureType.JOBS:
275
+ continue
276
+ assert FeatureType.OPERATIONS.value in observation
277
+ job_features = observation[
278
+ feature_type.value # type: ignore[literal-required]
279
+ ]
280
+ job_ids_of_ops = [
281
+ node.operation.job_id
282
+ for node in self.unwrapped.job_shop_graph.nodes_by_type[
283
+ NodeType.OPERATION
284
+ ]
285
+ ]
286
+ job_features_expanded = job_features[job_ids_of_ops]
287
+ operation_features = observation[FeatureType.OPERATIONS.value]
288
+ node_features_dict[FeatureType.OPERATIONS.value] = np.concatenate(
289
+ (operation_features, job_features_expanded), axis=1
290
+ )
291
+ return node_features_dict
292
+
293
+ def _remove_nodes(
294
+ self,
295
+ node_features_dict: dict[str, NDArray[T]],
296
+ removed_nodes: NDArray[np.bool_],
297
+ ) -> tuple[dict[str, NDArray[T]], dict[str, NDArray[np.int32]]]:
298
+ """Removes nodes from the node features dictionary.
299
+
300
+ Args:
301
+ node_features_dict: The node features dictionary.
302
+
303
+ Returns:
304
+ The node features dictionary with the nodes removed and a
305
+ dictionary containing the original node ids.
306
+ """
307
+ removed_nodes_dict: dict[str, NDArray[T]] = {}
308
+ original_ids_dict: dict[str, NDArray[np.int32]] = {}
309
+ for feature_type, features in node_features_dict.items():
310
+ node_type = _FEATURE_TYPE_STR_TO_NODE_TYPE[
311
+ feature_type
312
+ ].name.lower()
313
+ if node_type not in self.type_ranges:
314
+ continue
315
+ start, end = self.type_ranges[node_type]
316
+ removed_nodes_of_this_type = removed_nodes[start:end]
317
+ removed_nodes_dict[node_type] = features[
318
+ ~removed_nodes_of_this_type
319
+ ]
320
+ original_ids_dict[node_type] = np.where(
321
+ ~removed_nodes_of_this_type
322
+ )[0]
323
+
324
+ return removed_nodes_dict, original_ids_dict
325
+
326
+ @property
327
+ def unwrapped(self) -> EnvType:
328
+ """Returns the unwrapped environment."""
329
+ return self.env # type: ignore[return-value]
@@ -0,0 +1,87 @@
1
+ """Rewards functions are defined as `DispatcherObervers` and are used to
2
+ calculate the reward for a given state."""
3
+
4
+ from typing import List
5
+
6
+ from job_shop_lib.dispatching import DispatcherObserver, Dispatcher
7
+ from job_shop_lib import ScheduledOperation
8
+
9
+
10
+ class RewardObserver(DispatcherObserver):
11
+ """Base class for all reward functions.
12
+
13
+ Attributes:
14
+ rewards:
15
+ List of rewards calculated for each operation scheduled by the
16
+ dispatcher.
17
+ """
18
+
19
+ def __init__(
20
+ self, dispatcher: Dispatcher, *, subscribe: bool = True
21
+ ) -> None:
22
+ super().__init__(dispatcher, subscribe=subscribe)
23
+ self.rewards: List[float] = []
24
+
25
+ @property
26
+ def last_reward(self) -> float:
27
+ """Returns the reward of the last step or 0 if no rewards have been
28
+ calculated."""
29
+ return self.rewards[-1] if self.rewards else 0
30
+
31
+ def reset(self) -> None:
32
+ """Sets rewards attribute to a new empty list."""
33
+ self.rewards = []
34
+
35
+
36
+ class MakespanReward(RewardObserver):
37
+ """Dense reward function based on the negative makespan of the schedule.
38
+
39
+ The reward is calculated as the difference between the makespan of the
40
+ schedule before and after the last operation was scheduled. The makespan
41
+ is the time at which the last operation is completed.
42
+
43
+ Attributes:
44
+ current_makespan:
45
+ Makespan of the schedule after the last operation was scheduled.
46
+ """
47
+
48
+ def __init__(self, dispatcher: Dispatcher, *, subscribe=True) -> None:
49
+ super().__init__(dispatcher, subscribe=subscribe)
50
+ self.current_makespan = dispatcher.schedule.makespan()
51
+
52
+ def reset(self) -> None:
53
+ super().reset()
54
+ self.current_makespan = self.dispatcher.schedule.makespan()
55
+
56
+ def update(self, scheduled_operation: ScheduledOperation):
57
+ last_makespan = self.current_makespan
58
+ self.current_makespan = max(
59
+ last_makespan, scheduled_operation.end_time
60
+ )
61
+ reward = last_makespan - self.current_makespan
62
+ self.rewards.append(reward)
63
+
64
+
65
+ class IdleTimeReward(RewardObserver):
66
+ """Dense reward function based on the negative idle time of the schedule.
67
+
68
+ The reward is calculated as the difference between the idle time of the
69
+ schedule before and after the last operation was scheduled. The idle time
70
+ is the sum of the time between the end of the last operation and the start
71
+ of the next operation.
72
+ """
73
+
74
+ def update(self, scheduled_operation: ScheduledOperation):
75
+ machine_id = scheduled_operation.machine_id
76
+ machine_schedule = self.dispatcher.schedule.schedule[machine_id][:-1]
77
+
78
+ if machine_schedule:
79
+ last_operation = machine_schedule[-1]
80
+ idle_time = (
81
+ scheduled_operation.start_time - last_operation.end_time
82
+ )
83
+ else:
84
+ idle_time = scheduled_operation.start_time
85
+
86
+ reward = -idle_time
87
+ self.rewards.append(reward)