job-shop-lib 1.0.0a5__py3-none-any.whl → 1.0.0b2__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- job_shop_lib/__init__.py +1 -1
- job_shop_lib/_job_shop_instance.py +34 -29
- job_shop_lib/_operation.py +4 -2
- job_shop_lib/_schedule.py +11 -11
- job_shop_lib/benchmarking/_load_benchmark.py +3 -3
- job_shop_lib/constraint_programming/_ortools_solver.py +6 -6
- job_shop_lib/dispatching/__init__.py +4 -3
- job_shop_lib/dispatching/_dispatcher.py +19 -19
- job_shop_lib/dispatching/_dispatcher_observer_config.py +4 -4
- job_shop_lib/dispatching/_factories.py +4 -2
- job_shop_lib/dispatching/_history_observer.py +2 -1
- job_shop_lib/dispatching/_optimal_operations_observer.py +115 -0
- job_shop_lib/dispatching/_ready_operation_filters.py +19 -18
- job_shop_lib/dispatching/_unscheduled_operations_observer.py +4 -3
- job_shop_lib/dispatching/feature_observers/_composite_feature_observer.py +7 -8
- job_shop_lib/dispatching/feature_observers/_earliest_start_time_observer.py +3 -1
- job_shop_lib/dispatching/feature_observers/_factory.py +13 -14
- job_shop_lib/dispatching/feature_observers/_feature_observer.py +9 -8
- job_shop_lib/dispatching/feature_observers/_is_completed_observer.py +2 -1
- job_shop_lib/dispatching/feature_observers/_is_ready_observer.py +4 -2
- job_shop_lib/dispatching/rules/__init__.py +37 -1
- job_shop_lib/dispatching/rules/_dispatching_rule_factory.py +4 -2
- job_shop_lib/dispatching/rules/_dispatching_rule_solver.py +50 -20
- job_shop_lib/dispatching/rules/_dispatching_rules_functions.py +9 -8
- job_shop_lib/dispatching/rules/_machine_chooser_factory.py +4 -3
- job_shop_lib/dispatching/rules/_utils.py +9 -8
- job_shop_lib/generation/__init__.py +8 -0
- job_shop_lib/generation/_general_instance_generator.py +42 -64
- job_shop_lib/generation/_instance_generator.py +11 -7
- job_shop_lib/generation/_transformations.py +5 -4
- job_shop_lib/generation/_utils.py +124 -0
- job_shop_lib/graphs/__init__.py +7 -7
- job_shop_lib/graphs/{_build_agent_task_graph.py → _build_resource_task_graphs.py} +26 -24
- job_shop_lib/graphs/_job_shop_graph.py +17 -13
- job_shop_lib/graphs/_node.py +6 -4
- job_shop_lib/graphs/graph_updaters/_residual_graph_updater.py +4 -2
- job_shop_lib/reinforcement_learning/_multi_job_shop_graph_env.py +40 -20
- job_shop_lib/reinforcement_learning/_reward_observers.py +3 -1
- job_shop_lib/reinforcement_learning/_single_job_shop_graph_env.py +89 -22
- job_shop_lib/reinforcement_learning/_types_and_constants.py +1 -1
- job_shop_lib/reinforcement_learning/_utils.py +3 -3
- job_shop_lib/visualization/__init__.py +0 -60
- job_shop_lib/visualization/gantt/__init__.py +48 -0
- job_shop_lib/visualization/{_gantt_chart_creator.py → gantt/_gantt_chart_creator.py} +12 -12
- job_shop_lib/visualization/{_gantt_chart_video_and_gif_creation.py → gantt/_gantt_chart_video_and_gif_creation.py} +22 -22
- job_shop_lib/visualization/{_plot_gantt_chart.py → gantt/_plot_gantt_chart.py} +12 -13
- job_shop_lib/visualization/graphs/__init__.py +29 -0
- job_shop_lib/visualization/{_plot_disjunctive_graph.py → graphs/_plot_disjunctive_graph.py} +18 -16
- job_shop_lib/visualization/graphs/_plot_resource_task_graph.py +389 -0
- {job_shop_lib-1.0.0a5.dist-info → job_shop_lib-1.0.0b2.dist-info}/METADATA +21 -15
- job_shop_lib-1.0.0b2.dist-info/RECORD +70 -0
- job_shop_lib/visualization/_plot_agent_task_graph.py +0 -276
- job_shop_lib-1.0.0a5.dist-info/RECORD +0 -66
- {job_shop_lib-1.0.0a5.dist-info → job_shop_lib-1.0.0b2.dist-info}/LICENSE +0 -0
- {job_shop_lib-1.0.0a5.dist-info → job_shop_lib-1.0.0b2.dist-info}/WHEEL +0 -0
@@ -1,13 +1,13 @@
|
|
1
1
|
"""Home of the `GanttChartCreator` class and its configuration types."""
|
2
2
|
|
3
|
-
from typing import TypedDict
|
3
|
+
from typing import TypedDict, Optional
|
4
4
|
import matplotlib.pyplot as plt
|
5
5
|
|
6
6
|
from job_shop_lib.dispatching import (
|
7
7
|
Dispatcher,
|
8
8
|
HistoryObserver,
|
9
9
|
)
|
10
|
-
from job_shop_lib.visualization import (
|
10
|
+
from job_shop_lib.visualization.gantt import (
|
11
11
|
create_gantt_chart_video,
|
12
12
|
get_partial_gantt_chart_plotter,
|
13
13
|
create_gantt_chart_gif,
|
@@ -24,7 +24,7 @@ class PartialGanttChartPlotterConfig(TypedDict, total=False):
|
|
24
24
|
- :func:`get_partial_gantt_chart_plotter`
|
25
25
|
"""
|
26
26
|
|
27
|
-
title: str
|
27
|
+
title: Optional[str]
|
28
28
|
"""The title of the Gantt chart."""
|
29
29
|
|
30
30
|
cmap: str
|
@@ -43,7 +43,7 @@ class GifConfig(TypedDict, total=False):
|
|
43
43
|
:func:`create_gantt_chart_gif`
|
44
44
|
"""
|
45
45
|
|
46
|
-
gif_path: str
|
46
|
+
gif_path: Optional[str]
|
47
47
|
"""The path to save the GIF. It must end with '.gif'."""
|
48
48
|
|
49
49
|
fps: int
|
@@ -52,7 +52,7 @@ class GifConfig(TypedDict, total=False):
|
|
52
52
|
remove_frames: bool
|
53
53
|
"""Whether to remove the frames after creating the GIF."""
|
54
54
|
|
55
|
-
frames_dir: str
|
55
|
+
frames_dir: Optional[str]
|
56
56
|
"""The directory to store the frames."""
|
57
57
|
|
58
58
|
plot_current_time: bool
|
@@ -68,7 +68,7 @@ class VideoConfig(TypedDict, total=False):
|
|
68
68
|
:func:`create_gantt_chart_video`
|
69
69
|
"""
|
70
70
|
|
71
|
-
video_path: str
|
71
|
+
video_path: Optional[str]
|
72
72
|
"""The path to save the video. It must end with a valid video extension
|
73
73
|
(e.g., '.mp4')."""
|
74
74
|
|
@@ -78,7 +78,7 @@ class VideoConfig(TypedDict, total=False):
|
|
78
78
|
remove_frames: bool
|
79
79
|
"""Whether to remove the frames after creating the video."""
|
80
80
|
|
81
|
-
frames_dir: str
|
81
|
+
frames_dir: Optional[str]
|
82
82
|
"""The directory to store the frames."""
|
83
83
|
|
84
84
|
plot_current_time: bool
|
@@ -164,11 +164,11 @@ class GanttChartCreator:
|
|
164
164
|
def __init__(
|
165
165
|
self,
|
166
166
|
dispatcher: Dispatcher,
|
167
|
-
partial_gantt_chart_plotter_config:
|
168
|
-
PartialGanttChartPlotterConfig
|
169
|
-
|
170
|
-
gif_config: GifConfig
|
171
|
-
video_config: VideoConfig
|
167
|
+
partial_gantt_chart_plotter_config: Optional[
|
168
|
+
PartialGanttChartPlotterConfig
|
169
|
+
] = None,
|
170
|
+
gif_config: Optional[GifConfig] = None,
|
171
|
+
video_config: Optional[VideoConfig] = None,
|
172
172
|
):
|
173
173
|
if gif_config is None:
|
174
174
|
gif_config = {}
|
@@ -3,7 +3,7 @@
|
|
3
3
|
import os
|
4
4
|
import pathlib
|
5
5
|
import shutil
|
6
|
-
from typing import Sequence, Protocol
|
6
|
+
from typing import Sequence, Protocol, Optional, List
|
7
7
|
|
8
8
|
import imageio
|
9
9
|
import matplotlib.pyplot as plt
|
@@ -22,7 +22,7 @@ from job_shop_lib.dispatching import (
|
|
22
22
|
HistoryObserver,
|
23
23
|
)
|
24
24
|
from job_shop_lib.dispatching.rules import DispatchingRuleSolver
|
25
|
-
from job_shop_lib.visualization._plot_gantt_chart import plot_gantt_chart
|
25
|
+
from job_shop_lib.visualization.gantt._plot_gantt_chart import plot_gantt_chart
|
26
26
|
|
27
27
|
|
28
28
|
# This class serves as a more meaningful type hint than simply:
|
@@ -43,9 +43,9 @@ class PartialGanttChartPlotter(Protocol):
|
|
43
43
|
def __call__(
|
44
44
|
self,
|
45
45
|
schedule: Schedule,
|
46
|
-
makespan: int
|
47
|
-
available_operations:
|
48
|
-
current_time: int
|
46
|
+
makespan: Optional[int] = None,
|
47
|
+
available_operations: Optional[List[Operation]] = None,
|
48
|
+
current_time: Optional[int] = None,
|
49
49
|
) -> Figure:
|
50
50
|
"""Plots a Gantt chart for an unfinished schedule.
|
51
51
|
|
@@ -65,7 +65,7 @@ class PartialGanttChartPlotter(Protocol):
|
|
65
65
|
|
66
66
|
|
67
67
|
def get_partial_gantt_chart_plotter(
|
68
|
-
title: str
|
68
|
+
title: Optional[str] = None,
|
69
69
|
cmap: str = "viridis",
|
70
70
|
show_available_operations: bool = False,
|
71
71
|
) -> PartialGanttChartPlotter:
|
@@ -92,9 +92,9 @@ def get_partial_gantt_chart_plotter(
|
|
92
92
|
|
93
93
|
def plot_function(
|
94
94
|
schedule: Schedule,
|
95
|
-
makespan: int
|
96
|
-
available_operations:
|
97
|
-
current_time: int
|
95
|
+
makespan: Optional[int] = None,
|
96
|
+
available_operations: Optional[List[Operation]] = None,
|
97
|
+
current_time: Optional[int] = None,
|
98
98
|
) -> Figure:
|
99
99
|
fig, ax = plot_gantt_chart(
|
100
100
|
schedule, title=title, cmap_name=cmap, xlim=makespan
|
@@ -133,14 +133,14 @@ def get_partial_gantt_chart_plotter(
|
|
133
133
|
# pylint: disable=too-many-arguments
|
134
134
|
def create_gantt_chart_gif(
|
135
135
|
instance: JobShopInstance,
|
136
|
-
gif_path: str
|
137
|
-
solver: DispatchingRuleSolver
|
138
|
-
plot_function: PartialGanttChartPlotter
|
136
|
+
gif_path: Optional[str] = None,
|
137
|
+
solver: Optional[DispatchingRuleSolver] = None,
|
138
|
+
plot_function: Optional[PartialGanttChartPlotter] = None,
|
139
139
|
fps: int = 1,
|
140
140
|
remove_frames: bool = True,
|
141
|
-
frames_dir: str
|
141
|
+
frames_dir: Optional[str] = None,
|
142
142
|
plot_current_time: bool = True,
|
143
|
-
schedule_history: Sequence[ScheduledOperation]
|
143
|
+
schedule_history: Optional[Sequence[ScheduledOperation]] = None,
|
144
144
|
) -> None:
|
145
145
|
"""Creates a GIF of the schedule being built.
|
146
146
|
|
@@ -202,14 +202,14 @@ def create_gantt_chart_gif(
|
|
202
202
|
# pylint: disable=too-many-arguments
|
203
203
|
def create_gantt_chart_video(
|
204
204
|
instance: JobShopInstance,
|
205
|
-
video_path: str
|
206
|
-
solver: DispatchingRuleSolver
|
207
|
-
plot_function: PartialGanttChartPlotter
|
205
|
+
video_path: Optional[str] = None,
|
206
|
+
solver: Optional[DispatchingRuleSolver] = None,
|
207
|
+
plot_function: Optional[PartialGanttChartPlotter] = None,
|
208
208
|
fps: int = 1,
|
209
209
|
remove_frames: bool = True,
|
210
|
-
frames_dir: str
|
210
|
+
frames_dir: Optional[str] = None,
|
211
211
|
plot_current_time: bool = True,
|
212
|
-
schedule_history: Sequence[ScheduledOperation]
|
212
|
+
schedule_history: Optional[Sequence[ScheduledOperation]] = None,
|
213
213
|
) -> None:
|
214
214
|
"""Creates a video of the schedule being built.
|
215
215
|
|
@@ -268,10 +268,10 @@ def create_gantt_chart_video(
|
|
268
268
|
def create_gantt_chart_frames(
|
269
269
|
frames_dir: str,
|
270
270
|
instance: JobShopInstance,
|
271
|
-
solver: DispatchingRuleSolver
|
271
|
+
solver: Optional[DispatchingRuleSolver],
|
272
272
|
plot_function: PartialGanttChartPlotter,
|
273
273
|
plot_current_time: bool = True,
|
274
|
-
schedule_history: Sequence[ScheduledOperation]
|
274
|
+
schedule_history: Optional[Sequence[ScheduledOperation]] = None,
|
275
275
|
) -> None:
|
276
276
|
"""Creates frames of the Gantt chart for the schedule being built.
|
277
277
|
|
@@ -411,7 +411,7 @@ def resize_image_to_macro_block(
|
|
411
411
|
return image
|
412
412
|
|
413
413
|
|
414
|
-
def _load_images(frames_dir: str) ->
|
414
|
+
def _load_images(frames_dir: str) -> List:
|
415
415
|
frames = [
|
416
416
|
os.path.join(frames_dir, frame)
|
417
417
|
for frame in sorted(os.listdir(frames_dir))
|
@@ -1,6 +1,6 @@
|
|
1
1
|
"""Module for plotting static Gantt charts for job shop schedules."""
|
2
2
|
|
3
|
-
from typing import Optional
|
3
|
+
from typing import Optional, List, Tuple, Dict
|
4
4
|
|
5
5
|
from matplotlib.figure import Figure
|
6
6
|
import matplotlib.pyplot as plt
|
@@ -9,23 +9,22 @@ from matplotlib.patches import Patch
|
|
9
9
|
|
10
10
|
from job_shop_lib import Schedule, ScheduledOperation
|
11
11
|
|
12
|
-
|
13
12
|
_BASE_Y_POSITION = 1
|
14
13
|
_Y_POSITION_INCREMENT = 10
|
15
14
|
|
16
15
|
|
17
16
|
def plot_gantt_chart(
|
18
17
|
schedule: Schedule,
|
19
|
-
title: str
|
18
|
+
title: Optional[str] = None,
|
20
19
|
cmap_name: str = "viridis",
|
21
|
-
xlim: int
|
20
|
+
xlim: Optional[int] = None,
|
22
21
|
number_of_x_ticks: int = 15,
|
23
|
-
job_labels:
|
24
|
-
machine_labels:
|
22
|
+
job_labels: Optional[List[str]] = None,
|
23
|
+
machine_labels: Optional[List[str]] = None,
|
25
24
|
legend_title: str = "",
|
26
25
|
x_label: str = "Time units",
|
27
26
|
y_label: str = "Machines",
|
28
|
-
) ->
|
27
|
+
) -> Tuple[Figure, plt.Axes]:
|
29
28
|
"""Plots a Gantt chart for the schedule.
|
30
29
|
|
31
30
|
This function generates a Gantt chart that visualizes the schedule of jobs
|
@@ -91,10 +90,10 @@ def plot_gantt_chart(
|
|
91
90
|
|
92
91
|
def _initialize_plot(
|
93
92
|
schedule: Schedule,
|
94
|
-
title: str
|
93
|
+
title: Optional[str],
|
95
94
|
x_label: str = "Time units",
|
96
95
|
y_label: str = "Machines",
|
97
|
-
) ->
|
96
|
+
) -> Tuple[Figure, plt.Axes]:
|
98
97
|
"""Initializes the plot."""
|
99
98
|
fig, ax = plt.subplots()
|
100
99
|
ax.set_xlabel(x_label)
|
@@ -111,8 +110,8 @@ def _plot_machine_schedules(
|
|
111
110
|
schedule: Schedule,
|
112
111
|
ax: plt.Axes,
|
113
112
|
cmap_name: str,
|
114
|
-
job_labels:
|
115
|
-
) ->
|
113
|
+
job_labels: Optional[List[str]],
|
114
|
+
) -> Dict[int, Patch]:
|
116
115
|
"""Plots the schedules for each machine."""
|
117
116
|
max_job_id = schedule.instance.num_jobs - 1
|
118
117
|
cmap = plt.get_cmap(cmap_name, max_job_id + 1)
|
@@ -138,7 +137,7 @@ def _plot_machine_schedules(
|
|
138
137
|
return legend_handles
|
139
138
|
|
140
139
|
|
141
|
-
def _get_job_label(job_labels:
|
140
|
+
def _get_job_label(job_labels: Optional[List[str]], job_id: int) -> str:
|
142
141
|
"""Returns the label for the job."""
|
143
142
|
if job_labels is None:
|
144
143
|
return f"Job {job_id}"
|
@@ -162,7 +161,7 @@ def _plot_scheduled_operation(
|
|
162
161
|
|
163
162
|
|
164
163
|
def _configure_legend(
|
165
|
-
ax: plt.Axes, legend_handles:
|
164
|
+
ax: plt.Axes, legend_handles: Dict[int, Patch], legend_title: str
|
166
165
|
):
|
167
166
|
"""Configures the legend for the plot."""
|
168
167
|
sorted_legend_handles = [
|
@@ -0,0 +1,29 @@
|
|
1
|
+
"""Contains functions and classes for visualizing job shop scheduling problems.
|
2
|
+
|
3
|
+
.. autosummary::
|
4
|
+
|
5
|
+
plot_disjunctive_graph
|
6
|
+
plot_resource_task_graph
|
7
|
+
three_columns_layout
|
8
|
+
duration_labeler
|
9
|
+
color_nodes_by_machine
|
10
|
+
|
11
|
+
"""
|
12
|
+
|
13
|
+
from ._plot_disjunctive_graph import (
|
14
|
+
plot_disjunctive_graph,
|
15
|
+
duration_labeler,
|
16
|
+
)
|
17
|
+
from ._plot_resource_task_graph import (
|
18
|
+
plot_resource_task_graph,
|
19
|
+
three_columns_layout,
|
20
|
+
color_nodes_by_machine,
|
21
|
+
)
|
22
|
+
|
23
|
+
__all__ = [
|
24
|
+
"plot_disjunctive_graph",
|
25
|
+
"plot_resource_task_graph",
|
26
|
+
"three_columns_layout",
|
27
|
+
"duration_labeler",
|
28
|
+
"color_nodes_by_machine",
|
29
|
+
]
|
@@ -1,7 +1,7 @@
|
|
1
1
|
"""Module for visualizing the disjunctive graph of a job shop instance."""
|
2
2
|
|
3
3
|
import functools
|
4
|
-
from typing import Any
|
4
|
+
from typing import Any, Optional, Tuple, Dict, Union
|
5
5
|
from collections.abc import Callable, Sequence, Iterable
|
6
6
|
import warnings
|
7
7
|
import copy
|
@@ -22,7 +22,7 @@ from job_shop_lib.graphs import (
|
|
22
22
|
from job_shop_lib.exceptions import ValidationError
|
23
23
|
|
24
24
|
|
25
|
-
Layout = Callable[[nx.Graph],
|
25
|
+
Layout = Callable[[nx.Graph], Dict[str, Tuple[float, float]]]
|
26
26
|
|
27
27
|
|
28
28
|
def duration_labeler(node: Node) -> str:
|
@@ -50,15 +50,15 @@ def duration_labeler(node: Node) -> str:
|
|
50
50
|
# For the "too many arguments" warning no satisfactory solution was
|
51
51
|
# found. I believe is still better than using `**kwargs` and losing the
|
52
52
|
# function signature or adding a dataclass for configuration (it would add
|
53
|
-
#
|
53
|
+
# more complexity). A TypedDict could be used too, but the default
|
54
54
|
# values would not be explicit.
|
55
55
|
# pylint: disable=too-many-arguments, too-many-locals, too-many-statements
|
56
56
|
# pylint: disable=too-many-branches, line-too-long
|
57
57
|
def plot_disjunctive_graph(
|
58
|
-
job_shop: JobShopGraph
|
58
|
+
job_shop: Union[JobShopGraph, JobShopInstance],
|
59
59
|
*,
|
60
|
-
title: str
|
61
|
-
figsize:
|
60
|
+
title: Optional[str] = None,
|
61
|
+
figsize: Tuple[float, float] = (6, 4),
|
62
62
|
node_size: int = 1600,
|
63
63
|
edge_width: int = 2,
|
64
64
|
font_size: int = 10,
|
@@ -69,21 +69,21 @@ def plot_disjunctive_graph(
|
|
69
69
|
color_map: str = "Dark2_r",
|
70
70
|
disjunctive_edge_color: str = "red",
|
71
71
|
conjunctive_edge_color: str = "black",
|
72
|
-
layout: Layout
|
73
|
-
draw_disjunctive_edges: bool
|
74
|
-
conjunctive_edges_additional_params:
|
75
|
-
disjunctive_edges_additional_params:
|
72
|
+
layout: Optional[Layout] = None,
|
73
|
+
draw_disjunctive_edges: Union[bool, str] = True,
|
74
|
+
conjunctive_edges_additional_params: Optional[Dict[str, Any]] = None,
|
75
|
+
disjunctive_edges_additional_params: Optional[Dict[str, Any]] = None,
|
76
76
|
conjunctive_patch_label: str = "Conjunctive edges",
|
77
77
|
disjunctive_patch_label: str = "Disjunctive edges",
|
78
78
|
legend_text: str = "$p_{ij}=$duration of $O_{ij}$",
|
79
79
|
show_machine_colors_in_legend: bool = True,
|
80
|
-
machine_labels: Sequence[str]
|
80
|
+
machine_labels: Optional[Sequence[str]] = None,
|
81
81
|
legend_location: str = "upper left",
|
82
|
-
legend_bbox_to_anchor:
|
82
|
+
legend_bbox_to_anchor: Tuple[float, float] = (1.01, 1),
|
83
83
|
start_node_label: str = "$S$",
|
84
84
|
end_node_label: str = "$T$",
|
85
85
|
font_family: str = "sans-serif",
|
86
|
-
) ->
|
86
|
+
) -> Tuple[plt.Figure, plt.Axes]:
|
87
87
|
r"""Plots the disjunctive graph of the given job shop instance or graph.
|
88
88
|
|
89
89
|
Args:
|
@@ -251,7 +251,7 @@ def plot_disjunctive_graph(
|
|
251
251
|
for u, v, d in job_shop_graph.graph.edges(data=True)
|
252
252
|
if d["type"] == EdgeType.CONJUNCTIVE
|
253
253
|
]
|
254
|
-
disjunctive_edges: Iterable[
|
254
|
+
disjunctive_edges: Iterable[Tuple[int, int]] = [
|
255
255
|
(u, v)
|
256
256
|
for u, v, d in job_shop_graph.graph.edges(data=True)
|
257
257
|
if d["type"] == EdgeType.DISJUNCTIVE
|
@@ -299,7 +299,7 @@ def plot_disjunctive_graph(
|
|
299
299
|
|
300
300
|
sink_node = job_shop_graph.nodes_by_type[NodeType.SINK][0]
|
301
301
|
labels[sink_node] = end_node_label
|
302
|
-
machine_colors: dict[int,
|
302
|
+
machine_colors: dict[int, Tuple[float, float, float, float]] = {}
|
303
303
|
for operation_node in operation_nodes:
|
304
304
|
if job_shop_graph.is_removed(operation_node.node_id):
|
305
305
|
continue
|
@@ -343,7 +343,9 @@ def plot_disjunctive_graph(
|
|
343
343
|
else f"Machine {machine_id}"
|
344
344
|
),
|
345
345
|
)
|
346
|
-
for machine_id, color in
|
346
|
+
for machine_id, color in sorted(
|
347
|
+
machine_colors.items(), key=lambda x: x[0]
|
348
|
+
)
|
347
349
|
]
|
348
350
|
handles.extend(machine_patches)
|
349
351
|
|