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.
- job_shop_lib/__init__.py +19 -8
- job_shop_lib/{base_solver.py → _base_solver.py} +1 -1
- job_shop_lib/{job_shop_instance.py → _job_shop_instance.py} +155 -81
- job_shop_lib/_operation.py +118 -0
- job_shop_lib/{schedule.py → _schedule.py} +102 -84
- job_shop_lib/{scheduled_operation.py → _scheduled_operation.py} +25 -49
- job_shop_lib/benchmarking/__init__.py +66 -43
- job_shop_lib/benchmarking/_load_benchmark.py +88 -0
- job_shop_lib/constraint_programming/__init__.py +13 -0
- job_shop_lib/{cp_sat/ortools_solver.py → constraint_programming/_ortools_solver.py} +77 -22
- job_shop_lib/dispatching/__init__.py +51 -42
- job_shop_lib/dispatching/{dispatcher.py → _dispatcher.py} +223 -130
- job_shop_lib/dispatching/_dispatcher_observer_config.py +67 -0
- job_shop_lib/dispatching/_factories.py +135 -0
- job_shop_lib/dispatching/{history_tracker.py → _history_observer.py} +6 -7
- job_shop_lib/dispatching/_optimal_operations_observer.py +113 -0
- job_shop_lib/dispatching/_ready_operation_filters.py +168 -0
- job_shop_lib/dispatching/_unscheduled_operations_observer.py +70 -0
- job_shop_lib/dispatching/feature_observers/__init__.py +51 -13
- job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py +212 -0
- job_shop_lib/dispatching/feature_observers/{duration_observer.py → _duration_observer.py} +20 -18
- job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py +289 -0
- job_shop_lib/dispatching/feature_observers/_factory.py +95 -0
- job_shop_lib/dispatching/feature_observers/_feature_observer.py +228 -0
- job_shop_lib/dispatching/feature_observers/_is_completed_observer.py +97 -0
- job_shop_lib/dispatching/feature_observers/_is_ready_observer.py +35 -0
- job_shop_lib/dispatching/feature_observers/{is_scheduled_observer.py → _is_scheduled_observer.py} +9 -5
- job_shop_lib/dispatching/feature_observers/{position_in_job_observer.py → _position_in_job_observer.py} +8 -10
- job_shop_lib/dispatching/feature_observers/{remaining_operations_observer.py → _remaining_operations_observer.py} +8 -26
- job_shop_lib/dispatching/rules/__init__.py +87 -0
- job_shop_lib/dispatching/rules/_dispatching_rule_factory.py +84 -0
- job_shop_lib/dispatching/rules/_dispatching_rule_solver.py +201 -0
- job_shop_lib/dispatching/{dispatching_rules.py → rules/_dispatching_rules_functions.py} +70 -16
- job_shop_lib/dispatching/rules/_machine_chooser_factory.py +71 -0
- job_shop_lib/dispatching/rules/_utils.py +128 -0
- job_shop_lib/exceptions.py +18 -0
- job_shop_lib/generation/__init__.py +19 -0
- job_shop_lib/generation/_general_instance_generator.py +165 -0
- job_shop_lib/generation/_instance_generator.py +133 -0
- job_shop_lib/{generators/transformations.py → generation/_transformations.py} +16 -12
- job_shop_lib/generation/_utils.py +124 -0
- job_shop_lib/graphs/__init__.py +30 -12
- job_shop_lib/graphs/{build_disjunctive_graph.py → _build_disjunctive_graph.py} +41 -3
- job_shop_lib/graphs/{build_agent_task_graph.py → _build_resource_task_graphs.py} +28 -26
- job_shop_lib/graphs/_constants.py +38 -0
- job_shop_lib/graphs/_job_shop_graph.py +320 -0
- job_shop_lib/graphs/_node.py +182 -0
- job_shop_lib/graphs/graph_updaters/__init__.py +26 -0
- job_shop_lib/graphs/graph_updaters/_disjunctive_graph_updater.py +108 -0
- job_shop_lib/graphs/graph_updaters/_graph_updater.py +57 -0
- job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py +155 -0
- job_shop_lib/graphs/graph_updaters/_utils.py +25 -0
- job_shop_lib/py.typed +0 -0
- job_shop_lib/reinforcement_learning/__init__.py +68 -0
- job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py +398 -0
- job_shop_lib/reinforcement_learning/_resource_task_graph_observation.py +329 -0
- job_shop_lib/reinforcement_learning/_reward_observers.py +87 -0
- job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py +443 -0
- job_shop_lib/reinforcement_learning/_types_and_constants.py +62 -0
- job_shop_lib/reinforcement_learning/_utils.py +199 -0
- job_shop_lib/visualization/__init__.py +0 -25
- job_shop_lib/visualization/gantt/__init__.py +48 -0
- job_shop_lib/visualization/gantt/_gantt_chart_creator.py +257 -0
- job_shop_lib/visualization/gantt/_gantt_chart_video_and_gif_creation.py +422 -0
- job_shop_lib/visualization/{gantt_chart.py → gantt/_plot_gantt_chart.py} +84 -21
- job_shop_lib/visualization/graphs/__init__.py +29 -0
- job_shop_lib/visualization/graphs/_plot_disjunctive_graph.py +418 -0
- job_shop_lib/visualization/graphs/_plot_resource_task_graph.py +389 -0
- {job_shop_lib-0.5.0.dist-info → job_shop_lib-1.0.0.dist-info}/METADATA +87 -55
- job_shop_lib-1.0.0.dist-info/RECORD +73 -0
- {job_shop_lib-0.5.0.dist-info → job_shop_lib-1.0.0.dist-info}/WHEEL +1 -1
- job_shop_lib/benchmarking/load_benchmark.py +0 -142
- job_shop_lib/cp_sat/__init__.py +0 -5
- job_shop_lib/dispatching/dispatching_rule_solver.py +0 -119
- job_shop_lib/dispatching/factories.py +0 -206
- job_shop_lib/dispatching/feature_observers/composite_feature_observer.py +0 -87
- job_shop_lib/dispatching/feature_observers/earliest_start_time_observer.py +0 -156
- job_shop_lib/dispatching/feature_observers/factory.py +0 -58
- job_shop_lib/dispatching/feature_observers/feature_observer.py +0 -113
- job_shop_lib/dispatching/feature_observers/is_completed_observer.py +0 -98
- job_shop_lib/dispatching/feature_observers/is_ready_observer.py +0 -40
- job_shop_lib/dispatching/pruning_functions.py +0 -116
- job_shop_lib/generators/__init__.py +0 -7
- job_shop_lib/generators/basic_generator.py +0 -197
- job_shop_lib/graphs/constants.py +0 -21
- job_shop_lib/graphs/job_shop_graph.py +0 -202
- job_shop_lib/graphs/node.py +0 -166
- job_shop_lib/operation.py +0 -122
- job_shop_lib/visualization/agent_task_graph.py +0 -257
- job_shop_lib/visualization/create_gif.py +0 -209
- job_shop_lib/visualization/disjunctive_graph.py +0 -210
- job_shop_lib-0.5.0.dist-info/RECORD +0 -48
- {job_shop_lib-0.5.0.dist-info → job_shop_lib-1.0.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,443 @@
|
|
1
|
+
"""Home of the `SingleJobShopGraphEnv` class."""
|
2
|
+
|
3
|
+
from copy import deepcopy
|
4
|
+
from collections.abc import Callable, Sequence
|
5
|
+
from typing import Any, Dict, Tuple, List, Optional, Type, Union
|
6
|
+
|
7
|
+
import matplotlib.pyplot as plt
|
8
|
+
import gymnasium as gym
|
9
|
+
import numpy as np
|
10
|
+
from numpy.typing import NDArray
|
11
|
+
|
12
|
+
from job_shop_lib import JobShopInstance, Operation
|
13
|
+
from job_shop_lib.graphs import JobShopGraph
|
14
|
+
from job_shop_lib.graphs.graph_updaters import (
|
15
|
+
GraphUpdater,
|
16
|
+
ResidualGraphUpdater,
|
17
|
+
)
|
18
|
+
from job_shop_lib.exceptions import ValidationError
|
19
|
+
from job_shop_lib.dispatching import (
|
20
|
+
Dispatcher,
|
21
|
+
filter_dominated_operations,
|
22
|
+
DispatcherObserverConfig,
|
23
|
+
)
|
24
|
+
from job_shop_lib.dispatching.feature_observers import (
|
25
|
+
FeatureObserverConfig,
|
26
|
+
CompositeFeatureObserver,
|
27
|
+
FeatureObserver,
|
28
|
+
FeatureObserverType,
|
29
|
+
)
|
30
|
+
from job_shop_lib.visualization.gantt import GanttChartCreator
|
31
|
+
from job_shop_lib.reinforcement_learning import (
|
32
|
+
RewardObserver,
|
33
|
+
MakespanReward,
|
34
|
+
add_padding,
|
35
|
+
RenderConfig,
|
36
|
+
ObservationSpaceKey,
|
37
|
+
ObservationDict,
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
class SingleJobShopGraphEnv(gym.Env):
|
42
|
+
"""A Gymnasium environment for solving a specific instance of the Job Shop
|
43
|
+
Scheduling Problem represented as a graph.
|
44
|
+
|
45
|
+
This environment manages the scheduling process for a single Job Shop
|
46
|
+
instance, using a graph representation and various observers to track the
|
47
|
+
state and compute rewards.
|
48
|
+
|
49
|
+
Observation Space:
|
50
|
+
A dictionary with the following keys:
|
51
|
+
|
52
|
+
- "removed_nodes": Binary vector indicating removed graph nodes.
|
53
|
+
- "edge_list": Matrix of graph edges in COO format.
|
54
|
+
- Feature matrices: Keys corresponding to the composite observer
|
55
|
+
features (e.g., "operations", "jobs", "machines").
|
56
|
+
|
57
|
+
Action Space:
|
58
|
+
MultiDiscrete space representing (job_id, machine_id) pairs.
|
59
|
+
|
60
|
+
Render Modes:
|
61
|
+
|
62
|
+
- "human": Displays the current Gantt chart.
|
63
|
+
- "save_video": Saves a video of the complete Gantt chart.
|
64
|
+
- "save_gif": Saves a GIF of the complete Gantt chart.
|
65
|
+
|
66
|
+
Attributes:
|
67
|
+
dispatcher:
|
68
|
+
Manages the scheduling process. See
|
69
|
+
:class:`~job_shop_lib.dispatching.Dispatcher`.
|
70
|
+
|
71
|
+
composite_observer:
|
72
|
+
A :class:`~job_shop_lib.dispatching.feature_observers.
|
73
|
+
CompositeFeatureObserver` which aggregates features from multiple
|
74
|
+
observers.
|
75
|
+
|
76
|
+
graph_updater:
|
77
|
+
Updates the graph representation after each action. See
|
78
|
+
:class:`~job_shop_lib.graphs.GraphUpdater`.
|
79
|
+
|
80
|
+
reward_function:
|
81
|
+
Computes rewards for actions taken. See
|
82
|
+
:class:`~job_shop_lib.reinforcement_learning.RewardObserver`.
|
83
|
+
|
84
|
+
action_space:
|
85
|
+
Defines the action space. The action is a tuple of two integers
|
86
|
+
(job_id, machine_id). The machine_id can be -1 if the selected
|
87
|
+
operation can only be scheduled in one machine.
|
88
|
+
|
89
|
+
observation_space:
|
90
|
+
Defines the observation space. The observation is a dictionary
|
91
|
+
with the following keys:
|
92
|
+
|
93
|
+
- "removed_nodes": Binary vector indicating removed graph nodes.
|
94
|
+
- "edge_list": Matrix of graph edges in COO format.
|
95
|
+
- Feature matrices: Keys corresponding to the composite observer
|
96
|
+
features (e.g., "operations", "jobs", "machines").
|
97
|
+
|
98
|
+
render_mode:
|
99
|
+
The mode for rendering the environment ("human", "save_video",
|
100
|
+
"save_gif").
|
101
|
+
|
102
|
+
gantt_chart_creator:
|
103
|
+
Creates Gantt chart visualizations. See
|
104
|
+
:class:`~job_shop_lib.visualization.GanttChartCreator`.
|
105
|
+
|
106
|
+
use_padding:
|
107
|
+
Whether to use padding in observations. Padding maintains the
|
108
|
+
observation space shape when the number of nodes changes.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
job_shop_graph:
|
112
|
+
The JobShopGraph instance representing the job shop problem.
|
113
|
+
feature_observer_configs:
|
114
|
+
A list of FeatureObserverConfig instances for the feature
|
115
|
+
observers.
|
116
|
+
reward_function_config:
|
117
|
+
The configuration for the reward function.
|
118
|
+
graph_updater_config:
|
119
|
+
The configuration for the graph updater.
|
120
|
+
ready_operations_filter:
|
121
|
+
The function to use for pruning dominated operations.
|
122
|
+
render_mode:
|
123
|
+
The mode for rendering the environment ("human", "save_video",
|
124
|
+
"save_gif").
|
125
|
+
render_config:
|
126
|
+
Configuration for rendering (e.g., paths for saving videos
|
127
|
+
or GIFs). See :class:`~job_shop_lib.visualization.RenderConfig`.
|
128
|
+
use_padding:
|
129
|
+
Whether to use padding in observations. Padding maintains the
|
130
|
+
observation space shape when the number of nodes changes.
|
131
|
+
"""
|
132
|
+
|
133
|
+
metadata = {"render_modes": ["human", "save_video", "save_gif"]}
|
134
|
+
|
135
|
+
# I think the class is easier to use this way. We could initiliaze the
|
136
|
+
# class from Dispatcher or an already initialized RewardFunction. However,
|
137
|
+
# it would be impossible to add good default values.
|
138
|
+
# pylint: disable=too-many-arguments
|
139
|
+
def __init__(
|
140
|
+
self,
|
141
|
+
job_shop_graph: JobShopGraph,
|
142
|
+
feature_observer_configs: Sequence[
|
143
|
+
Union[
|
144
|
+
str,
|
145
|
+
FeatureObserverType,
|
146
|
+
Type[FeatureObserver],
|
147
|
+
FeatureObserverConfig,
|
148
|
+
],
|
149
|
+
],
|
150
|
+
reward_function_config: DispatcherObserverConfig[
|
151
|
+
Type[RewardObserver]
|
152
|
+
] = DispatcherObserverConfig(class_type=MakespanReward),
|
153
|
+
graph_updater_config: DispatcherObserverConfig[
|
154
|
+
Type[GraphUpdater]
|
155
|
+
] = DispatcherObserverConfig(class_type=ResidualGraphUpdater),
|
156
|
+
ready_operations_filter: Optional[
|
157
|
+
Callable[[Dispatcher, List[Operation]], List[Operation]]
|
158
|
+
] = filter_dominated_operations,
|
159
|
+
render_mode: Optional[str] = None,
|
160
|
+
render_config: Optional[RenderConfig] = None,
|
161
|
+
use_padding: bool = True,
|
162
|
+
) -> None:
|
163
|
+
super().__init__()
|
164
|
+
# Used for resetting the environment
|
165
|
+
self.initial_job_shop_graph = deepcopy(job_shop_graph)
|
166
|
+
|
167
|
+
self.dispatcher = Dispatcher(
|
168
|
+
job_shop_graph.instance,
|
169
|
+
ready_operations_filter=ready_operations_filter,
|
170
|
+
)
|
171
|
+
|
172
|
+
# Observers added to track the environment state
|
173
|
+
self.composite_observer = (
|
174
|
+
CompositeFeatureObserver.from_feature_observer_configs(
|
175
|
+
self.dispatcher, feature_observer_configs
|
176
|
+
)
|
177
|
+
)
|
178
|
+
self.graph_updater = graph_updater_config.class_type(
|
179
|
+
dispatcher=self.dispatcher,
|
180
|
+
job_shop_graph=job_shop_graph,
|
181
|
+
**graph_updater_config.kwargs,
|
182
|
+
)
|
183
|
+
self.reward_function = reward_function_config.class_type(
|
184
|
+
dispatcher=self.dispatcher, **reward_function_config.kwargs
|
185
|
+
)
|
186
|
+
self.action_space = gym.spaces.MultiDiscrete(
|
187
|
+
[self.instance.num_jobs, self.instance.num_machines], start=[0, -1]
|
188
|
+
)
|
189
|
+
self.observation_space: gym.spaces.Dict = self._get_observation_space()
|
190
|
+
self.render_mode = render_mode
|
191
|
+
if render_config is None:
|
192
|
+
render_config = {}
|
193
|
+
self.gantt_chart_creator = GanttChartCreator(
|
194
|
+
dispatcher=self.dispatcher, **render_config
|
195
|
+
)
|
196
|
+
self.use_padding = use_padding
|
197
|
+
|
198
|
+
@property
|
199
|
+
def instance(self) -> JobShopInstance:
|
200
|
+
"""Returns the instance the environment is working on."""
|
201
|
+
return self.job_shop_graph.instance
|
202
|
+
|
203
|
+
@property
|
204
|
+
def job_shop_graph(self) -> JobShopGraph:
|
205
|
+
"""Returns the job shop graph."""
|
206
|
+
return self.graph_updater.job_shop_graph
|
207
|
+
|
208
|
+
def current_makespan(self) -> int:
|
209
|
+
"""Returns current makespan of partial schedule."""
|
210
|
+
return self.dispatcher.schedule.makespan()
|
211
|
+
|
212
|
+
def machine_utilization(self) -> NDArray[np.float32]:
|
213
|
+
"""Returns utilization percentage for each machine."""
|
214
|
+
total_time = max(1, self.current_makespan()) # Avoid division by zero
|
215
|
+
machine_busy_time = np.zeros(self.instance.num_machines)
|
216
|
+
|
217
|
+
for m_id, m_schedule in enumerate(self.dispatcher.schedule.schedule):
|
218
|
+
machine_busy_time[m_id] = sum(
|
219
|
+
op.operation.duration for op in m_schedule
|
220
|
+
)
|
221
|
+
|
222
|
+
return machine_busy_time / total_time
|
223
|
+
|
224
|
+
def _get_observation_space(self) -> gym.spaces.Dict:
|
225
|
+
"""Returns the observation space dictionary."""
|
226
|
+
num_edges = self.job_shop_graph.num_edges
|
227
|
+
dict_space: Dict[str, gym.Space] = {
|
228
|
+
ObservationSpaceKey.REMOVED_NODES.value: gym.spaces.MultiBinary(
|
229
|
+
len(self.job_shop_graph.nodes)
|
230
|
+
),
|
231
|
+
ObservationSpaceKey.EDGE_INDEX.value: gym.spaces.MultiDiscrete(
|
232
|
+
np.full(
|
233
|
+
(2, num_edges),
|
234
|
+
fill_value=len(self.job_shop_graph.nodes) + 1,
|
235
|
+
dtype=np.int32,
|
236
|
+
),
|
237
|
+
start=np.full(
|
238
|
+
(2, num_edges),
|
239
|
+
fill_value=-1, # -1 is used for padding
|
240
|
+
dtype=np.int32,
|
241
|
+
),
|
242
|
+
),
|
243
|
+
}
|
244
|
+
for feature_type, matrix in self.composite_observer.features.items():
|
245
|
+
dict_space[feature_type.value] = gym.spaces.Box(
|
246
|
+
low=-np.inf, high=np.inf, shape=matrix.shape
|
247
|
+
)
|
248
|
+
return gym.spaces.Dict(dict_space)
|
249
|
+
|
250
|
+
def reset(
|
251
|
+
self,
|
252
|
+
*,
|
253
|
+
seed: Optional[int] = None,
|
254
|
+
options: Optional[Dict[str, Any]] = None,
|
255
|
+
) -> Tuple[ObservationDict, dict[str, Any]]:
|
256
|
+
"""Resets the environment.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
seed:
|
260
|
+
Added to match the signature of the parent class. It is not
|
261
|
+
used in this method.
|
262
|
+
options:
|
263
|
+
Additional options to pass to the environment. Not used in
|
264
|
+
this method.
|
265
|
+
|
266
|
+
Returns:
|
267
|
+
A tuple containing the following elements:
|
268
|
+
|
269
|
+
- The observation of the environment.
|
270
|
+
- A dictionary with additional information, keys
|
271
|
+
include: "feature_names", the names of the features in the
|
272
|
+
observation; and "available_operations_with_ids", a list of
|
273
|
+
available a list of available actions in the form of
|
274
|
+
(operation_id, machine_id, job_id).
|
275
|
+
"""
|
276
|
+
super().reset(seed=seed, options=options)
|
277
|
+
self.dispatcher.reset()
|
278
|
+
obs = self.get_observation()
|
279
|
+
return obs, {
|
280
|
+
"feature_names": self.composite_observer.column_names,
|
281
|
+
"available_operations_with_ids": (
|
282
|
+
self.get_available_actions_with_ids()
|
283
|
+
),
|
284
|
+
}
|
285
|
+
|
286
|
+
def step(
|
287
|
+
self, action: Tuple[int, int]
|
288
|
+
) -> Tuple[ObservationDict, float, bool, bool, Dict[str, Any]]:
|
289
|
+
"""Takes a step in the environment.
|
290
|
+
|
291
|
+
Args:
|
292
|
+
action:
|
293
|
+
The action to take. The action is a tuple of two integers
|
294
|
+
(job_id, machine_id):
|
295
|
+
the job ID and the machine ID in which to schedule the
|
296
|
+
operation.
|
297
|
+
|
298
|
+
Returns:
|
299
|
+
A tuple containing the following elements:
|
300
|
+
|
301
|
+
- The observation of the environment.
|
302
|
+
- The reward obtained.
|
303
|
+
- Whether the environment is done.
|
304
|
+
- Whether the episode was truncated (always False).
|
305
|
+
- A dictionary with additional information. The dictionary
|
306
|
+
contains the following keys: "feature_names", the names of the
|
307
|
+
features in the observation; and "available_operations_with_ids",
|
308
|
+
a list of available actions in the form of (operation_id,
|
309
|
+
machine_id, job_id).
|
310
|
+
"""
|
311
|
+
job_id, machine_id = action
|
312
|
+
operation = self.dispatcher.next_operation(job_id)
|
313
|
+
if machine_id == -1:
|
314
|
+
machine_id = operation.machine_id
|
315
|
+
|
316
|
+
self.dispatcher.dispatch(operation, machine_id)
|
317
|
+
|
318
|
+
obs = self.get_observation()
|
319
|
+
reward = self.reward_function.last_reward
|
320
|
+
done = self.dispatcher.schedule.is_complete()
|
321
|
+
truncated = False
|
322
|
+
info: Dict[str, Any] = {
|
323
|
+
"feature_names": self.composite_observer.column_names,
|
324
|
+
"available_operations_with_ids": (
|
325
|
+
self.get_available_actions_with_ids()
|
326
|
+
),
|
327
|
+
}
|
328
|
+
return obs, reward, done, truncated, info
|
329
|
+
|
330
|
+
def get_observation(self) -> ObservationDict:
|
331
|
+
"""Returns the current observation of the environment."""
|
332
|
+
observation: ObservationDict = {
|
333
|
+
ObservationSpaceKey.REMOVED_NODES.value: np.array(
|
334
|
+
self.job_shop_graph.removed_nodes, dtype=bool
|
335
|
+
),
|
336
|
+
ObservationSpaceKey.EDGE_INDEX.value: self._get_edge_index(),
|
337
|
+
}
|
338
|
+
for feature_type, matrix in self.composite_observer.features.items():
|
339
|
+
observation[feature_type.value] = matrix
|
340
|
+
return observation
|
341
|
+
|
342
|
+
def _get_edge_index(self) -> NDArray[np.int32]:
|
343
|
+
"""Returns the edge index matrix."""
|
344
|
+
edge_index = np.array(
|
345
|
+
self.job_shop_graph.graph.edges(), dtype=np.int32
|
346
|
+
).T
|
347
|
+
|
348
|
+
if self.use_padding:
|
349
|
+
output_shape = self.observation_space[
|
350
|
+
ObservationSpaceKey.EDGE_INDEX.value
|
351
|
+
].shape
|
352
|
+
assert output_shape is not None # For the type checker
|
353
|
+
edge_index = add_padding(
|
354
|
+
edge_index, output_shape=output_shape, dtype=np.int32
|
355
|
+
)
|
356
|
+
return edge_index
|
357
|
+
|
358
|
+
def render(self):
|
359
|
+
"""Renders the environment.
|
360
|
+
|
361
|
+
The rendering mode is set by the `render_mode` attribute:
|
362
|
+
|
363
|
+
- human: Renders the current Gannt chart.
|
364
|
+
- save_video: Saves a video of the Gantt chart. Used only if the
|
365
|
+
schedule is completed.
|
366
|
+
- save_gif: Saves a GIF of the Gantt chart. Used only if the schedule
|
367
|
+
is completed.
|
368
|
+
"""
|
369
|
+
if self.render_mode == "human":
|
370
|
+
self.gantt_chart_creator.plot_gantt_chart()
|
371
|
+
plt.show(block=False)
|
372
|
+
elif self.render_mode == "save_video":
|
373
|
+
self.gantt_chart_creator.create_video()
|
374
|
+
elif self.render_mode == "save_gif":
|
375
|
+
self.gantt_chart_creator.create_gif()
|
376
|
+
|
377
|
+
def get_available_actions_with_ids(self) -> List[Tuple[int, int, int]]:
|
378
|
+
"""Returns a list of available actions in the form of
|
379
|
+
(operation_id, machine_id, job_id)."""
|
380
|
+
available_operations = self.dispatcher.available_operations()
|
381
|
+
available_operations_with_ids = []
|
382
|
+
for operation in available_operations:
|
383
|
+
job_id = operation.job_id
|
384
|
+
operation_id = operation.operation_id
|
385
|
+
for machine_id in operation.machines:
|
386
|
+
available_operations_with_ids.append(
|
387
|
+
(operation_id, machine_id, job_id)
|
388
|
+
)
|
389
|
+
return available_operations_with_ids
|
390
|
+
|
391
|
+
def validate_action(self, action: Tuple[int, int]) -> None:
|
392
|
+
"""Validates that the action is legal in the current state.
|
393
|
+
|
394
|
+
Args:
|
395
|
+
action:
|
396
|
+
The action to validate. The action is a tuple of two integers
|
397
|
+
(job_id, machine_id).
|
398
|
+
|
399
|
+
Raises:
|
400
|
+
ValidationError: If the action is invalid.
|
401
|
+
"""
|
402
|
+
job_id, machine_id = action
|
403
|
+
if not 0 <= job_id < self.instance.num_jobs:
|
404
|
+
raise ValidationError(f"Invalid job_id {job_id}")
|
405
|
+
|
406
|
+
if not -1 <= machine_id < self.instance.num_machines:
|
407
|
+
raise ValidationError(f"Invalid machine_id {machine_id}")
|
408
|
+
|
409
|
+
# Check if job has operations left
|
410
|
+
job = self.instance.jobs[job_id]
|
411
|
+
if self.dispatcher.job_next_operation_index[job_id] >= len(job):
|
412
|
+
raise ValidationError(f"Job {job_id} has no operations left")
|
413
|
+
|
414
|
+
next_operation = self.dispatcher.next_operation(job_id)
|
415
|
+
if machine_id == -1 and len(next_operation.machines) > 1:
|
416
|
+
raise ValidationError(
|
417
|
+
f"Operation {next_operation} requires a machine_id"
|
418
|
+
)
|
419
|
+
|
420
|
+
|
421
|
+
if __name__ == "__main__":
|
422
|
+
from job_shop_lib.dispatching.feature_observers import (
|
423
|
+
FeatureObserverType,
|
424
|
+
FeatureType,
|
425
|
+
)
|
426
|
+
from job_shop_lib.graphs import build_disjunctive_graph
|
427
|
+
from job_shop_lib.benchmarking import load_benchmark_instance
|
428
|
+
|
429
|
+
instance = load_benchmark_instance("ft06")
|
430
|
+
job_shop_graph_ = build_disjunctive_graph(instance)
|
431
|
+
feature_observer_configs_: List[DispatcherObserverConfig] = [
|
432
|
+
DispatcherObserverConfig(
|
433
|
+
FeatureObserverType.IS_READY,
|
434
|
+
kwargs={"feature_types": [FeatureType.JOBS]},
|
435
|
+
)
|
436
|
+
]
|
437
|
+
|
438
|
+
env = SingleJobShopGraphEnv(
|
439
|
+
job_shop_graph=job_shop_graph_,
|
440
|
+
feature_observer_configs=feature_observer_configs_,
|
441
|
+
render_mode="save_video",
|
442
|
+
render_config={"video_config": {"fps": 4}},
|
443
|
+
)
|
@@ -0,0 +1,62 @@
|
|
1
|
+
"""Contains types and enumerations used in the reinforcement learning
|
2
|
+
module."""
|
3
|
+
|
4
|
+
from enum import Enum
|
5
|
+
from typing import TypedDict
|
6
|
+
|
7
|
+
import numpy as np
|
8
|
+
from numpy.typing import NDArray
|
9
|
+
|
10
|
+
from job_shop_lib.dispatching.feature_observers import FeatureType
|
11
|
+
from job_shop_lib.visualization.gantt import (
|
12
|
+
PartialGanttChartPlotterConfig,
|
13
|
+
GifConfig,
|
14
|
+
VideoConfig,
|
15
|
+
)
|
16
|
+
|
17
|
+
|
18
|
+
class RenderConfig(TypedDict, total=False):
|
19
|
+
"""Configuration needed to initialize the `GanttChartCreator` class."""
|
20
|
+
|
21
|
+
partial_gantt_chart_plotter_config: PartialGanttChartPlotterConfig
|
22
|
+
video_config: VideoConfig
|
23
|
+
gif_config: GifConfig
|
24
|
+
|
25
|
+
|
26
|
+
class ObservationSpaceKey(str, Enum):
|
27
|
+
"""Enumeration of the keys for the observation space dictionary."""
|
28
|
+
|
29
|
+
REMOVED_NODES = "removed_nodes"
|
30
|
+
EDGE_INDEX = "edge_index"
|
31
|
+
OPERATIONS = FeatureType.OPERATIONS.value
|
32
|
+
JOBS = FeatureType.JOBS.value
|
33
|
+
MACHINES = FeatureType.MACHINES.value
|
34
|
+
|
35
|
+
|
36
|
+
class _ObservationDictRequired(TypedDict):
|
37
|
+
"""Required fields for the observation dictionary."""
|
38
|
+
|
39
|
+
removed_nodes: NDArray[np.bool_]
|
40
|
+
edge_index: NDArray[np.int32]
|
41
|
+
|
42
|
+
|
43
|
+
class _ObservationDictOptional(TypedDict, total=False):
|
44
|
+
"""Optional fields for the observation dictionary."""
|
45
|
+
|
46
|
+
operations: NDArray[np.float32]
|
47
|
+
jobs: NDArray[np.float32]
|
48
|
+
machines: NDArray[np.float32]
|
49
|
+
|
50
|
+
|
51
|
+
class ObservationDict(_ObservationDictRequired, _ObservationDictOptional):
|
52
|
+
"""A dictionary containing the observation of the environment.
|
53
|
+
|
54
|
+
Required fields:
|
55
|
+
removed_nodes: Binary vector indicating removed nodes.
|
56
|
+
edge_index: Edge list in COO format.
|
57
|
+
|
58
|
+
Optional fields:
|
59
|
+
operations: Matrix of operation features.
|
60
|
+
jobs: Matrix of job features.
|
61
|
+
machines: Matrix of machine features.
|
62
|
+
"""
|
@@ -0,0 +1,199 @@
|
|
1
|
+
"""Utility functions for reinforcement learning."""
|
2
|
+
|
3
|
+
from typing import TypeVar, Any, Type
|
4
|
+
|
5
|
+
import numpy as np
|
6
|
+
from numpy.typing import NDArray
|
7
|
+
|
8
|
+
from job_shop_lib.exceptions import ValidationError
|
9
|
+
from job_shop_lib.dispatching import OptimalOperationsObserver
|
10
|
+
|
11
|
+
T = TypeVar("T", bound=np.number)
|
12
|
+
|
13
|
+
|
14
|
+
def add_padding(
|
15
|
+
array: NDArray[Any],
|
16
|
+
output_shape: tuple[int, ...],
|
17
|
+
padding_value: float = -1,
|
18
|
+
dtype: Type[T] | None = None,
|
19
|
+
) -> NDArray[T]:
|
20
|
+
"""Adds padding to the array.
|
21
|
+
|
22
|
+
Pads the input array to the specified output shape with a given padding
|
23
|
+
value. If the ``dtype`` is not specified, the ``dtype`` of the input array
|
24
|
+
is used.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
array:
|
28
|
+
The input array to be padded.
|
29
|
+
output_shape:
|
30
|
+
The desired shape of the output array.
|
31
|
+
padding_value:
|
32
|
+
The value to use for padding. Defaults to -1.
|
33
|
+
dtype:
|
34
|
+
The data type for the output array. Defaults to ``None``, in which
|
35
|
+
case the dtype of the input array is used.
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
The padded array with the specified output shape.
|
39
|
+
|
40
|
+
Raises:
|
41
|
+
ValidationError:
|
42
|
+
If the output shape is smaller than the input shape.
|
43
|
+
|
44
|
+
Examples:
|
45
|
+
|
46
|
+
.. doctest::
|
47
|
+
|
48
|
+
>>> array = np.array([[1, 2], [3, 4]])
|
49
|
+
>>> add_padding(array, (3, 3))
|
50
|
+
array([[ 1, 2, -1],
|
51
|
+
[ 3, 4, -1],
|
52
|
+
[-1, -1, -1]])
|
53
|
+
|
54
|
+
>>> add_padding(array, (3, 3), padding_value=0)
|
55
|
+
array([[1, 2, 0],
|
56
|
+
[3, 4, 0],
|
57
|
+
[0, 0, 0]])
|
58
|
+
|
59
|
+
>>> bool_array = np.array([[True, False], [False, True]])
|
60
|
+
>>> add_padding(bool_array, (3, 3), padding_value=False, dtype=int)
|
61
|
+
array([[1, 0, 0],
|
62
|
+
[0, 1, 0],
|
63
|
+
[0, 0, 0]])
|
64
|
+
|
65
|
+
>>> add_padding(bool_array, (3, 3), dtype=int)
|
66
|
+
array([[ 1, 0, -1],
|
67
|
+
[ 0, 1, -1],
|
68
|
+
[-1, -1, -1]])
|
69
|
+
"""
|
70
|
+
|
71
|
+
if np.any(np.less(output_shape, array.shape)):
|
72
|
+
raise ValidationError(
|
73
|
+
"Output shape must be greater than the input shape. "
|
74
|
+
f"Got output shape: {output_shape}, input shape: {array.shape}."
|
75
|
+
)
|
76
|
+
|
77
|
+
if dtype is None:
|
78
|
+
dtype = array.dtype.type
|
79
|
+
|
80
|
+
padded_array = np.full(
|
81
|
+
output_shape,
|
82
|
+
fill_value=padding_value,
|
83
|
+
dtype=dtype,
|
84
|
+
)
|
85
|
+
|
86
|
+
if array.size == 0:
|
87
|
+
return padded_array
|
88
|
+
|
89
|
+
slices = tuple(slice(0, dim) for dim in array.shape)
|
90
|
+
padded_array[slices] = array
|
91
|
+
return padded_array
|
92
|
+
|
93
|
+
|
94
|
+
def create_edge_type_dict(
|
95
|
+
edge_index: NDArray[T],
|
96
|
+
type_ranges: dict[str, tuple[int, int]],
|
97
|
+
relationship: str = "to",
|
98
|
+
) -> dict[tuple[str, str, str], NDArray[T]]:
|
99
|
+
"""Organizes edges based on node types.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
edge_index:
|
103
|
+
numpy array of shape (2, E) where E is number of edges
|
104
|
+
type_ranges: dict[str, tuple[int, int]]
|
105
|
+
Dictionary mapping type names to their corresponding index ranges
|
106
|
+
[start, end) in the ``edge_index`` array.
|
107
|
+
relationship:
|
108
|
+
A string representing the relationship type between nodes.
|
109
|
+
|
110
|
+
Returns:
|
111
|
+
A dictionary with keys (type_i, relationship, type_j) and values as
|
112
|
+
edge indices
|
113
|
+
"""
|
114
|
+
edge_index_dict: dict[tuple[str, str, str], NDArray] = {}
|
115
|
+
for type_name_i, (start_i, end_i) in type_ranges.items():
|
116
|
+
for type_name_j, (start_j, end_j) in type_ranges.items():
|
117
|
+
key: tuple[str, str, str] = (
|
118
|
+
type_name_i,
|
119
|
+
relationship,
|
120
|
+
type_name_j,
|
121
|
+
)
|
122
|
+
# Find edges where source is in type_i and target is in type_j
|
123
|
+
mask = (
|
124
|
+
(edge_index[0] >= start_i)
|
125
|
+
& (edge_index[0] < end_i)
|
126
|
+
& (edge_index[1] >= start_j)
|
127
|
+
& (edge_index[1] < end_j)
|
128
|
+
)
|
129
|
+
edge_index_dict[key] = edge_index[:, mask]
|
130
|
+
|
131
|
+
return edge_index_dict
|
132
|
+
|
133
|
+
|
134
|
+
def map_values(array: NDArray[T], mapping: dict[int, int]) -> NDArray[T]:
|
135
|
+
"""Maps values in an array using a mapping.
|
136
|
+
|
137
|
+
Args:
|
138
|
+
array:
|
139
|
+
An NumPy array.
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
A NumPy array where each element has been replaced by its
|
143
|
+
corresponding value from the mapping.
|
144
|
+
|
145
|
+
Raises:
|
146
|
+
ValidationError:
|
147
|
+
If the array contains values that are not in the mapping.
|
148
|
+
|
149
|
+
Examples:
|
150
|
+
>>> map_values(np.array([1, 2, 3]), {1: 10, 2: 20, 3: 30})
|
151
|
+
array([10, 20, 30])
|
152
|
+
|
153
|
+
>>> map_values(np.array([1, 2]), {1: 10, 2: 10, 3: 30})
|
154
|
+
array([10, 10])
|
155
|
+
|
156
|
+
"""
|
157
|
+
if array.size == 0:
|
158
|
+
return array
|
159
|
+
try:
|
160
|
+
vectorized_mapping = np.vectorize(mapping.get)
|
161
|
+
return vectorized_mapping(array)
|
162
|
+
except TypeError as e:
|
163
|
+
raise ValidationError(
|
164
|
+
"The array contains values that are not in the mapping."
|
165
|
+
) from e
|
166
|
+
|
167
|
+
|
168
|
+
def get_optimal_actions(
|
169
|
+
optimal_ops_observer: OptimalOperationsObserver,
|
170
|
+
available_operations_with_ids: list[tuple[int, int, int]],
|
171
|
+
) -> dict[tuple[int, int, int], int]:
|
172
|
+
"""Indicates if each action is optimal according to a
|
173
|
+
:class:`~job_shop_lib.dispatching.OptimalOperationsObserver` instance.
|
174
|
+
|
175
|
+
Args:
|
176
|
+
optimal_ops_observer: The observer that provides optimal operations.
|
177
|
+
available_operations_with_ids: List of available operations with their
|
178
|
+
IDs (operation_id, machine_id, job_id).
|
179
|
+
|
180
|
+
Returns:
|
181
|
+
A dictionary mapping each tuple
|
182
|
+
(operation_id, machine_id, job_id) in the available actions to a binary
|
183
|
+
indicator (1 if optimal, 0 otherwise).
|
184
|
+
"""
|
185
|
+
optimal_actions = {}
|
186
|
+
optimal_ops = optimal_ops_observer.optimal_available
|
187
|
+
optimal_ops_ids = [
|
188
|
+
(op.operation_id, op.machine_id, op.job_id) for op in optimal_ops
|
189
|
+
]
|
190
|
+
for operation_id, machine_id, job_id in available_operations_with_ids:
|
191
|
+
is_optimal = (operation_id, machine_id, job_id) in optimal_ops_ids
|
192
|
+
optimal_actions[(operation_id, machine_id, job_id)] = int(is_optimal)
|
193
|
+
return optimal_actions
|
194
|
+
|
195
|
+
|
196
|
+
if __name__ == "__main__":
|
197
|
+
import doctest
|
198
|
+
|
199
|
+
doctest.testmod()
|