job-shop-lib 0.1.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. job_shop_lib/__init__.py +20 -0
  2. job_shop_lib/base_solver.py +37 -0
  3. job_shop_lib/benchmarking/__init__.py +78 -0
  4. job_shop_lib/benchmarking/benchmark_instances.json +1 -0
  5. job_shop_lib/benchmarking/load_benchmark.py +142 -0
  6. job_shop_lib/cp_sat/__init__.py +5 -0
  7. job_shop_lib/cp_sat/ortools_solver.py +201 -0
  8. job_shop_lib/dispatching/__init__.py +49 -0
  9. job_shop_lib/dispatching/dispatcher.py +269 -0
  10. job_shop_lib/dispatching/dispatching_rule_solver.py +111 -0
  11. job_shop_lib/dispatching/dispatching_rules.py +160 -0
  12. job_shop_lib/dispatching/factories.py +206 -0
  13. job_shop_lib/dispatching/pruning_functions.py +116 -0
  14. job_shop_lib/exceptions.py +26 -0
  15. job_shop_lib/generators/__init__.py +7 -0
  16. job_shop_lib/generators/basic_generator.py +197 -0
  17. job_shop_lib/graphs/__init__.py +52 -0
  18. job_shop_lib/graphs/build_agent_task_graph.py +209 -0
  19. job_shop_lib/graphs/build_disjunctive_graph.py +78 -0
  20. job_shop_lib/graphs/constants.py +21 -0
  21. job_shop_lib/graphs/job_shop_graph.py +159 -0
  22. job_shop_lib/graphs/node.py +147 -0
  23. job_shop_lib/job_shop_instance.py +355 -0
  24. job_shop_lib/operation.py +120 -0
  25. job_shop_lib/schedule.py +180 -0
  26. job_shop_lib/scheduled_operation.py +97 -0
  27. job_shop_lib/visualization/__init__.py +25 -0
  28. job_shop_lib/visualization/agent_task_graph.py +257 -0
  29. job_shop_lib/visualization/create_gif.py +191 -0
  30. job_shop_lib/visualization/disjunctive_graph.py +206 -0
  31. job_shop_lib/visualization/gantt_chart.py +147 -0
  32. job_shop_lib-0.1.0.dist-info/LICENSE +21 -0
  33. job_shop_lib-0.1.0.dist-info/METADATA +363 -0
  34. job_shop_lib-0.1.0.dist-info/RECORD +35 -0
  35. job_shop_lib-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,191 @@
1
+ """Module for creating a GIF of the schedule being built by a
2
+ dispatching rule solver."""
3
+
4
+ import os
5
+ import pathlib
6
+ import shutil
7
+ from typing import Callable
8
+
9
+ import imageio
10
+ import matplotlib.pyplot as plt
11
+ from matplotlib.figure import Figure
12
+
13
+ from job_shop_lib import JobShopInstance, Schedule, Operation
14
+ from job_shop_lib.dispatching import DispatchingRuleSolver, Dispatcher
15
+ from job_shop_lib.visualization.gantt_chart import plot_gantt_chart
16
+
17
+
18
+ # Most of the arguments are optional with default values. There is no way to
19
+ # reduce the number of arguments without losing functionality.
20
+ # pylint: disable=too-many-arguments
21
+ def create_gif(
22
+ gif_path: str,
23
+ instance: JobShopInstance,
24
+ solver: DispatchingRuleSolver,
25
+ plot_function: (
26
+ Callable[[Schedule, int, list[Operation] | None], Figure] | None
27
+ ) = None,
28
+ fps: int = 1,
29
+ remove_frames: bool = True,
30
+ frames_dir: str | None = None,
31
+ plot_current_time: bool = True,
32
+ ) -> None:
33
+ """Creates a GIF of the schedule being built by the given solver.
34
+
35
+ Args:
36
+ gif_path:
37
+ The path to save the GIF file. It should end with ".gif".
38
+ instance:
39
+ The instance of the job shop problem to be scheduled.
40
+ solver:
41
+ The dispatching rule solver to use.
42
+ plot_function:
43
+ A function that plots a Gantt chart for a schedule. It
44
+ should take a `Schedule` object and the makespan of the schedule as
45
+ input and return a `Figure` object. If not provided, a default
46
+ function is used.
47
+ fps:
48
+ The number of frames per second in the GIF.
49
+ remove_frames:
50
+ Whether to remove the frames after creating the GIF.
51
+ frames_dir:
52
+ The directory to save the frames in. If not provided,
53
+ `gif_path.replace(".gif", "") + "_frames"` is used.
54
+ plot_current_time:
55
+ Whether to plot a vertical line at the current time.
56
+ """
57
+ if plot_function is None:
58
+ plot_function = plot_gantt_chart_wrapper()
59
+
60
+ if frames_dir is None:
61
+ # Use the name of the GIF file as the directory name
62
+ frames_dir = gif_path.replace(".gif", "") + "_frames"
63
+ path = pathlib.Path(frames_dir)
64
+ path.mkdir(exist_ok=True)
65
+ frames_dir = str(path)
66
+ create_gantt_chart_frames(
67
+ frames_dir, instance, solver, plot_function, plot_current_time
68
+ )
69
+ create_gif_from_frames(frames_dir, gif_path, fps)
70
+
71
+ if remove_frames:
72
+ shutil.rmtree(frames_dir)
73
+
74
+
75
+ def plot_gantt_chart_wrapper(
76
+ title: str | None = None,
77
+ cmap: str = "viridis",
78
+ show_available_operations: bool = False,
79
+ ) -> Callable[[Schedule, int, list[Operation] | None], Figure]:
80
+ """Returns a function that plots a Gantt chart for an unfinished schedule.
81
+
82
+ Args:
83
+ title: The title of the Gantt chart.
84
+ cmap: The name of the colormap to use.
85
+
86
+ Returns:
87
+ A function that plots a Gantt chart for a schedule. The function takes
88
+ a `Schedule` object and the makespan of the schedule as input and
89
+ returns a `Figure` object.
90
+ """
91
+
92
+ def plot_function(
93
+ schedule: Schedule,
94
+ makespan: int,
95
+ available_operations: list | None = None,
96
+ ) -> Figure:
97
+ fig, ax = plot_gantt_chart(
98
+ schedule, title=title, cmap_name=cmap, xlim=makespan
99
+ )
100
+
101
+ if not show_available_operations or available_operations is None:
102
+ return fig
103
+
104
+ operations_text = "\n".join(
105
+ str(operation) for operation in available_operations
106
+ )
107
+ text = f"Available operations:\n{operations_text}"
108
+ # Print the available operations at the bottom right corner
109
+ # of the Gantt chart
110
+ fig.text(
111
+ 1.25,
112
+ 0.05,
113
+ text,
114
+ ha="right",
115
+ va="bottom",
116
+ transform=ax.transAxes,
117
+ bbox=dict(facecolor="white", alpha=0.5, boxstyle="round,pad=0.5"),
118
+ )
119
+ return fig
120
+
121
+ return plot_function
122
+
123
+
124
+ def create_gantt_chart_frames(
125
+ frames_dir: str,
126
+ instance: JobShopInstance,
127
+ solver: DispatchingRuleSolver,
128
+ plot_function: Callable[[Schedule, int, list[Operation] | None], Figure],
129
+ plot_current_time: bool = True,
130
+ ) -> None:
131
+ """Creates frames of the Gantt chart for the schedule being built.
132
+
133
+ Args:
134
+ frames_dir:
135
+ The directory to save the frames in.
136
+ instance:
137
+ The instance of the job shop problem to be scheduled.
138
+ solver:
139
+ The dispatching rule solver to use.
140
+ plot_function:
141
+ A function that plots a Gantt chart for a schedule. It
142
+ should take a `Schedule` object and the makespan of the schedule as
143
+ input and return a `Figure` object.
144
+ plot_current_time:
145
+ Whether to plot a vertical line at the current time."""
146
+ dispatcher = Dispatcher(instance, pruning_function=solver.pruning_function)
147
+ schedule = dispatcher.schedule
148
+ makespan = solver(instance).makespan()
149
+ iteration = 0
150
+
151
+ while not schedule.is_complete():
152
+ solver.step(dispatcher)
153
+ iteration += 1
154
+ fig = plot_function(
155
+ schedule,
156
+ makespan,
157
+ dispatcher.available_operations(),
158
+ )
159
+ current_time = (
160
+ None if not plot_current_time else dispatcher.current_time()
161
+ )
162
+ _save_frame(fig, frames_dir, iteration, current_time)
163
+
164
+
165
+ def _save_frame(
166
+ figure: Figure, frames_dir: str, number: int, current_time: int | None
167
+ ) -> None:
168
+ if current_time is not None:
169
+ figure.gca().axvline(current_time, color="red", linestyle="--")
170
+
171
+ figure.savefig(f"{frames_dir}/frame_{number:02d}.png", bbox_inches="tight")
172
+ plt.close(figure)
173
+
174
+
175
+ def create_gif_from_frames(frames_dir: str, gif_path: str, fps: int) -> None:
176
+ """Creates a GIF from the frames in the given directory.
177
+
178
+ Args:
179
+ frames_dir:
180
+ The directory containing the frames to be used in the GIF.
181
+ gif_path:
182
+ The path to save the GIF file. It should end with ".gif".
183
+ fps:
184
+ The number of frames per second in the GIF.
185
+ """
186
+ frames = [
187
+ os.path.join(frames_dir, frame)
188
+ for frame in sorted(os.listdir(frames_dir))
189
+ ]
190
+ images = [imageio.imread(frame) for frame in frames]
191
+ imageio.mimsave(gif_path, images, fps=fps, loop=0)
@@ -0,0 +1,206 @@
1
+ """Module for visualizing the disjunctive graph of a job shop instance."""
2
+
3
+ import functools
4
+ from typing import Optional, Callable
5
+ import warnings
6
+ import copy
7
+
8
+ import matplotlib
9
+ import matplotlib.pyplot as plt
10
+ import networkx as nx
11
+
12
+ from job_shop_lib import JobShopInstance
13
+ from job_shop_lib.graphs import (
14
+ JobShopGraph,
15
+ EdgeType,
16
+ NodeType,
17
+ Node,
18
+ build_disjunctive_graph,
19
+ )
20
+
21
+
22
+ Layout = Callable[[nx.Graph], dict[str, tuple[float, float]]]
23
+
24
+
25
+ # This function could be improved by a function extraction refactoring
26
+ # (see `plot_gantt_chart`
27
+ # function as a reference in how to do it). That would solve the
28
+ # "too many locals" warning. However, this refactoring is not a priority at
29
+ # the moment. To compensate, sections are separated by comments.
30
+ # For the "too many arguments" warning no satisfactory solution was
31
+ # found. I believe is still better than using `**kwargs` and losing the
32
+ # function signature or adding a dataclass for configuration (it would add
33
+ # unnecessary complexity).
34
+ # pylint: disable=too-many-arguments, too-many-locals
35
+ def plot_disjunctive_graph(
36
+ job_shop: JobShopGraph | JobShopInstance,
37
+ figsize: tuple[float, float] = (6, 4),
38
+ node_size: int = 1600,
39
+ title: Optional[str] = None,
40
+ layout: Optional[Layout] = None,
41
+ edge_width: int = 2,
42
+ font_size: int = 10,
43
+ arrow_size: int = 35,
44
+ alpha=0.95,
45
+ node_font_color: str = "white",
46
+ color_map: str = "Dark2_r",
47
+ draw_disjunctive_edges: bool = True,
48
+ ) -> plt.Figure:
49
+ """Returns a plot of the disjunctive graph of the instance."""
50
+
51
+ if isinstance(job_shop, JobShopInstance):
52
+ job_shop_graph = build_disjunctive_graph(job_shop)
53
+ else:
54
+ job_shop_graph = job_shop
55
+
56
+ # Set up the plot
57
+ # ----------------
58
+ plt.figure(figsize=figsize)
59
+ if title is None:
60
+ title = (
61
+ f"Disjunctive Graph Visualization: {job_shop_graph.instance.name}"
62
+ )
63
+ plt.title(title)
64
+
65
+ # Set up the layout
66
+ # -----------------
67
+ if layout is None:
68
+ try:
69
+ from networkx.drawing.nx_agraph import (
70
+ graphviz_layout,
71
+ )
72
+
73
+ layout = functools.partial(
74
+ graphviz_layout, prog="dot", args="-Grankdir=LR"
75
+ )
76
+ except ImportError:
77
+ warnings.warn(
78
+ "Could not import graphviz_layout. "
79
+ + "Using spring_layout instead."
80
+ )
81
+ layout = nx.spring_layout
82
+
83
+ temp_graph = copy.deepcopy(job_shop_graph.graph)
84
+ # Remove disjunctive edges to get a better layout
85
+ temp_graph.remove_edges_from(
86
+ [
87
+ (u, v)
88
+ for u, v, d in job_shop_graph.graph.edges(data=True)
89
+ if d["type"] == EdgeType.DISJUNCTIVE
90
+ ]
91
+ )
92
+ pos = layout(temp_graph) # type: ignore
93
+
94
+ # Draw nodes
95
+ # ----------
96
+ node_colors = [_get_node_color(node) for node in job_shop_graph.nodes]
97
+
98
+ nx.draw_networkx_nodes(
99
+ job_shop_graph.graph,
100
+ pos,
101
+ node_size=node_size,
102
+ node_color=node_colors,
103
+ alpha=alpha,
104
+ cmap=matplotlib.colormaps.get_cmap(color_map),
105
+ )
106
+
107
+ # Draw edges
108
+ # ----------
109
+ conjunctive_edges = [
110
+ (u, v)
111
+ for u, v, d in job_shop_graph.graph.edges(data=True)
112
+ if d["type"] == EdgeType.CONJUNCTIVE
113
+ ]
114
+ disjunctive_edges = [
115
+ (u, v)
116
+ for u, v, d in job_shop_graph.graph.edges(data=True)
117
+ if d["type"] == EdgeType.DISJUNCTIVE
118
+ ]
119
+
120
+ nx.draw_networkx_edges(
121
+ job_shop_graph.graph,
122
+ pos,
123
+ edgelist=conjunctive_edges,
124
+ width=edge_width,
125
+ edge_color="black",
126
+ arrowsize=arrow_size,
127
+ )
128
+
129
+ if draw_disjunctive_edges:
130
+ nx.draw_networkx_edges(
131
+ job_shop_graph.graph,
132
+ pos,
133
+ edgelist=disjunctive_edges,
134
+ width=edge_width,
135
+ edge_color="red",
136
+ arrowsize=arrow_size,
137
+ )
138
+
139
+ # Draw node labels
140
+ # ----------------
141
+ operation_nodes = job_shop_graph.nodes_by_type[NodeType.OPERATION]
142
+
143
+ labels = {}
144
+ source_node = job_shop_graph.nodes_by_type[NodeType.SOURCE][0]
145
+ labels[source_node] = "S"
146
+
147
+ sink_node = job_shop_graph.nodes_by_type[NodeType.SINK][0]
148
+ labels[sink_node] = "T"
149
+ for operation_node in operation_nodes:
150
+ labels[operation_node] = (
151
+ f"m={operation_node.operation.machine_id}\n"
152
+ f"d={operation_node.operation.duration}"
153
+ )
154
+
155
+ nx.draw_networkx_labels(
156
+ job_shop_graph.graph,
157
+ pos,
158
+ labels=labels,
159
+ font_color=node_font_color,
160
+ font_size=font_size,
161
+ font_family="sans-serif",
162
+ )
163
+
164
+ # Final touches
165
+ # -------------
166
+ plt.axis("off")
167
+ plt.tight_layout()
168
+ # Create a legend to indicate the meaning of the edge colors
169
+ conjunctive_patch = matplotlib.patches.Patch(
170
+ color="black", label="conjunctive edges"
171
+ )
172
+ disjunctive_patch = matplotlib.patches.Patch(
173
+ color="red", label="disjunctive edges"
174
+ )
175
+
176
+ # Add to the legend the meaning of m and d
177
+ text = "m = machine_id\nd = duration"
178
+ extra = matplotlib.patches.Rectangle(
179
+ (0, 0),
180
+ 1,
181
+ 1,
182
+ fc="w",
183
+ fill=False,
184
+ edgecolor="none",
185
+ linewidth=0,
186
+ label=text,
187
+ )
188
+ plt.legend(
189
+ handles=[conjunctive_patch, disjunctive_patch, extra],
190
+ loc="upper left",
191
+ bbox_to_anchor=(1.05, 1),
192
+ borderaxespad=0.0,
193
+ )
194
+ return plt.gcf()
195
+
196
+
197
+ def _get_node_color(node: Node) -> int:
198
+ """Returns the color of the node."""
199
+ if node.node_type == NodeType.SOURCE:
200
+ return -1
201
+ if node.node_type == NodeType.SINK:
202
+ return -1
203
+ if node.node_type == NodeType.OPERATION:
204
+ return node.operation.machine_id
205
+
206
+ raise ValueError("Invalid node type.")
@@ -0,0 +1,147 @@
1
+ """Module for plotting static Gantt charts for job shop schedules."""
2
+
3
+ from typing import Optional
4
+
5
+ from matplotlib.figure import Figure
6
+ import matplotlib.pyplot as plt
7
+ from matplotlib.colors import Normalize
8
+ from matplotlib.patches import Patch
9
+
10
+ from job_shop_lib import Schedule, ScheduledOperation
11
+
12
+
13
+ _BASE_Y_POSITION = 1
14
+ _Y_POSITION_INCREMENT = 10
15
+
16
+
17
+ def plot_gantt_chart(
18
+ schedule: Schedule,
19
+ title: str | None = None,
20
+ cmap_name: str = "viridis",
21
+ xlim: int | None = None,
22
+ number_of_x_ticks: int = 15,
23
+ ) -> tuple[Figure, plt.Axes]:
24
+ """Plots a Gantt chart for the schedule.
25
+
26
+ Args:
27
+ schedule:
28
+ The schedule to plot.
29
+ title:
30
+ The title of the plot. If not provided, the title:
31
+ `f"Gantt Chart for {schedule.instance.name} instance"`
32
+ is used.
33
+ cmap_name:
34
+ The name of the colormap to use. Default is "viridis".
35
+ xlim:
36
+ The maximum value for the x-axis. If not provided, the makespan of
37
+ the schedule is used.
38
+ number_of_x_ticks:
39
+ The number of ticks to use in the x-axis.
40
+ """
41
+ fig, ax = _initialize_plot(schedule, title)
42
+ legend_handles = _plot_machine_schedules(schedule, ax, cmap_name)
43
+ _configure_legend(ax, legend_handles)
44
+ _configure_axes(schedule, ax, xlim, number_of_x_ticks)
45
+ return fig, ax
46
+
47
+
48
+ def _initialize_plot(
49
+ schedule: Schedule, title: str | None
50
+ ) -> tuple[Figure, plt.Axes]:
51
+ """Initializes the plot."""
52
+ fig, ax = plt.subplots()
53
+ ax.set_xlabel("Time units")
54
+ ax.set_ylabel("Machines")
55
+ ax.grid(True, which="both", axis="x", linestyle="--", linewidth=0.5)
56
+ ax.yaxis.grid(False)
57
+ if title is None:
58
+ title = f"Gantt Chart for {schedule.instance.name} instance"
59
+ plt.title(title)
60
+ return fig, ax
61
+
62
+
63
+ def _plot_machine_schedules(
64
+ schedule: Schedule, ax: plt.Axes, cmap_name: str
65
+ ) -> dict[int, Patch]:
66
+ """Plots the schedules for each machine."""
67
+ max_job_id = schedule.instance.num_jobs - 1
68
+ cmap = plt.cm.get_cmap(cmap_name, max_job_id + 1)
69
+ norm = Normalize(vmin=0, vmax=max_job_id)
70
+ legend_handles = {}
71
+
72
+ for machine_index, machine_schedule in enumerate(schedule.schedule):
73
+ y_position_for_machines = (
74
+ _BASE_Y_POSITION + _Y_POSITION_INCREMENT * machine_index
75
+ )
76
+
77
+ for scheduled_op in machine_schedule:
78
+ color = cmap(norm(scheduled_op.job_id))
79
+ _plot_scheduled_operation(
80
+ ax, scheduled_op, y_position_for_machines, color
81
+ )
82
+ if scheduled_op.job_id not in legend_handles:
83
+ legend_handles[scheduled_op.job_id] = Patch(
84
+ facecolor=color, label=f"Job {scheduled_op.job_id}"
85
+ )
86
+
87
+ return legend_handles
88
+
89
+
90
+ def _plot_scheduled_operation(
91
+ ax: plt.Axes,
92
+ scheduled_op: ScheduledOperation,
93
+ y_position_for_machines: int,
94
+ color,
95
+ ):
96
+ """Plots a single scheduled operation."""
97
+ start_time, end_time = scheduled_op.start_time, scheduled_op.end_time
98
+ duration = end_time - start_time
99
+ ax.broken_barh(
100
+ [(start_time, duration)],
101
+ (y_position_for_machines, 9),
102
+ facecolors=color,
103
+ )
104
+
105
+
106
+ def _configure_legend(ax: plt.Axes, legend_handles: dict[int, Patch]):
107
+ """Configures the legend for the plot."""
108
+ sorted_legend_handles = [
109
+ legend_handles[job_id] for job_id in sorted(legend_handles)
110
+ ]
111
+ ax.legend(
112
+ handles=sorted_legend_handles,
113
+ loc="upper left",
114
+ bbox_to_anchor=(1.01, 1),
115
+ )
116
+
117
+
118
+ def _configure_axes(
119
+ schedule: Schedule,
120
+ ax: plt.Axes,
121
+ xlim: Optional[int],
122
+ number_of_x_ticks: int,
123
+ ):
124
+ """Sets the limits and labels for the axes."""
125
+ num_machines = len(schedule.schedule)
126
+ ax.set_ylim(0, _BASE_Y_POSITION + _Y_POSITION_INCREMENT * num_machines)
127
+ ax.set_yticks(
128
+ [
129
+ _BASE_Y_POSITION
130
+ + _Y_POSITION_INCREMENT // 2
131
+ + _Y_POSITION_INCREMENT * i
132
+ for i in range(num_machines)
133
+ ]
134
+ )
135
+ ax.set_yticklabels([str(i) for i in range(num_machines)])
136
+ makespan = schedule.makespan()
137
+ xlim = xlim if xlim is not None else makespan
138
+ ax.set_xlim(0, xlim)
139
+
140
+ tick_interval = max(1, xlim // number_of_x_ticks)
141
+ xticks = list(range(0, xlim + 1, tick_interval))
142
+
143
+ if xticks[-1] != xlim:
144
+ xticks.pop()
145
+ xticks.append(xlim)
146
+
147
+ ax.set_xticks(xticks)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Pablo Ariño
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.