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,120 @@
1
+ """Home of the `Operation` class."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Operation:
7
+ """Stores machine and duration information for a job operation.
8
+
9
+ Note:
10
+ To increase performance, some solvers such as the CP-SAT solver use
11
+ only integers to represent the operation's attributes. Should a
12
+ problem involve operations with non-integer durations, it would be
13
+ necessary to multiply all durations by a sufficiently large integer so
14
+ that every duration is an integer.
15
+
16
+ Attributes:
17
+ machines: A list of machine ids that can perform the operation.
18
+ duration: The time it takes to perform the operation.
19
+ """
20
+
21
+ __slots__ = (
22
+ "machines",
23
+ "duration",
24
+ "_job_id",
25
+ "_position_in_job",
26
+ "_operation_id",
27
+ )
28
+
29
+ def __init__(self, machines: int | list[int], duration: int):
30
+ """Initializes the object with the given machines and duration.
31
+
32
+ Args:
33
+ machines: A list of machine ids that can perform the operation. If
34
+ only one machine can perform the operation, it can be passed as
35
+ an integer.
36
+ duration: The time it takes to perform the operation.
37
+ """
38
+ self.machines = [machines] if isinstance(machines, int) else machines
39
+ self.duration = duration
40
+
41
+ # Defined outside the class by the JobShopInstance class:
42
+ self._job_id: int | None = None
43
+ self._position_in_job: int | None = None
44
+ self._operation_id: int | None = None
45
+
46
+ @property
47
+ def machine_id(self) -> int:
48
+ """Returns the id of the machine associated with the operation.
49
+
50
+ Raises:
51
+ ValueError: If the operation has multiple machines in its list.
52
+ """
53
+ if len(self.machines) > 1:
54
+ raise ValueError("Operation has multiple machines.")
55
+ return self.machines[0]
56
+
57
+ @property
58
+ def job_id(self) -> int:
59
+ """Returns the id of the job that the operation belongs to."""
60
+ if self._job_id is None:
61
+ raise ValueError("Operation has no job_id.")
62
+ return self._job_id
63
+
64
+ @job_id.setter
65
+ def job_id(self, value: int) -> None:
66
+ self._job_id = value
67
+
68
+ @property
69
+ def position_in_job(self) -> int:
70
+ """Returns the position (starting at zero) of the operation in the
71
+ job.
72
+
73
+ Raises:
74
+ ValueError: If the operation has no position_in_job.
75
+ """
76
+ if self._position_in_job is None:
77
+ raise ValueError("Operation has no position_in_job.")
78
+ return self._position_in_job
79
+
80
+ @position_in_job.setter
81
+ def position_in_job(self, value: int) -> None:
82
+ self._position_in_job = value
83
+
84
+ @property
85
+ def operation_id(self) -> int:
86
+ """Returns the id of the operation.
87
+
88
+ The operation id is unique within a job shop instance and should
89
+ be set by the JobShopInstance class.
90
+
91
+ It starts at 0 and is incremented by 1 for each operation in the
92
+ instance.
93
+
94
+ Raises:
95
+ ValueError: If the operation has no id.
96
+ """
97
+ if self._operation_id is None:
98
+ raise ValueError("Operation has no id.")
99
+ return self._operation_id
100
+
101
+ @operation_id.setter
102
+ def operation_id(self, value: int) -> None:
103
+ self._operation_id = value
104
+
105
+ def __hash__(self) -> int:
106
+ return hash(self.operation_id)
107
+
108
+ def __eq__(self, __value: object) -> bool:
109
+ if isinstance(__value, Operation):
110
+ return self.operation_id == __value.operation_id
111
+ return False
112
+
113
+ def __repr__(self) -> str:
114
+ machines = (
115
+ self.machines[0] if len(self.machines) == 1 else self.machines
116
+ )
117
+ return (
118
+ f"O(m={machines}, d={self.duration}, "
119
+ f"j={self.job_id}, p={self.position_in_job})"
120
+ )
@@ -0,0 +1,180 @@
1
+ """Home of the `Schedule` class."""
2
+
3
+ from job_shop_lib import ScheduledOperation, JobShopInstance
4
+
5
+
6
+ class Schedule:
7
+ """Data structure to store a schedule for a `JobShopInstance` object.
8
+
9
+ Attributes:
10
+ instance:
11
+ The `JobShopInstance` object that the schedule is for.
12
+ schedule:
13
+ A list of lists of `ScheduledOperation` objects. Each list of
14
+ `ScheduledOperation` objects represents the order of operations
15
+ on a machine.
16
+ metadata:
17
+ A dictionary with additional information about the schedule. It
18
+ can be used to store information about the algorithm that generated
19
+ the schedule, for example.
20
+ """
21
+
22
+ __slots__ = (
23
+ "instance",
24
+ "_schedule",
25
+ "metadata",
26
+ )
27
+
28
+ def __init__(
29
+ self,
30
+ instance: JobShopInstance,
31
+ schedule: list[list[ScheduledOperation]] | None = None,
32
+ **metadata,
33
+ ):
34
+ """Initializes the object with the given instance and schedule.
35
+
36
+ Args:
37
+ instance:
38
+ The `JobShopInstance` object that the schedule is for.
39
+ schedule:
40
+ A list of lists of `ScheduledOperation` objects. Each list of
41
+ `ScheduledOperation` objects represents the order of operations
42
+ on a machine. If not provided, the schedule is initialized as
43
+ an empty schedule.
44
+ **metadata:
45
+ Additional information about the schedule.
46
+ """
47
+ if schedule is None:
48
+ schedule = [[] for _ in range(instance.num_machines)]
49
+
50
+ Schedule.check_schedule(schedule)
51
+
52
+ self.instance = instance
53
+ self._schedule = schedule
54
+ self.metadata = metadata
55
+
56
+ def __repr__(self) -> str:
57
+ return str(self.schedule)
58
+
59
+ @property
60
+ def schedule(self) -> list[list[ScheduledOperation]]:
61
+ """Returns the schedule attribute."""
62
+ return self._schedule
63
+
64
+ @schedule.setter
65
+ def schedule(self, new_schedule: list[list[ScheduledOperation]]):
66
+ Schedule.check_schedule(new_schedule)
67
+ self._schedule = new_schedule
68
+
69
+ @property
70
+ def num_scheduled_operations(self) -> int:
71
+ """Returns the number of operations that have been scheduled."""
72
+ return sum(len(machine_schedule) for machine_schedule in self.schedule)
73
+
74
+ def reset(self):
75
+ """Resets the schedule to an empty state."""
76
+ self.schedule = [[] for _ in range(self.instance.num_machines)]
77
+
78
+ def makespan(self) -> int:
79
+ """Returns the makespan of the schedule.
80
+
81
+ The makespan is the time at which all operations are completed.
82
+ """
83
+ max_end_time = 0
84
+ for machine_schedule in self.schedule:
85
+ if machine_schedule:
86
+ max_end_time = max(max_end_time, machine_schedule[-1].end_time)
87
+ return max_end_time
88
+
89
+ def is_complete(self) -> bool:
90
+ """Returns True if all operations have been scheduled."""
91
+ return self.num_scheduled_operations == self.instance.num_operations
92
+
93
+ def add(self, scheduled_operation: ScheduledOperation):
94
+ """Adds a new `ScheduledOperation` to the schedule.
95
+
96
+ Args:
97
+ scheduled_operation:
98
+ The `ScheduledOperation` to add to the schedule.
99
+
100
+ Raises:
101
+ ValueError: If the start time of the new operation is before the
102
+ end time of the last operation on the same machine. In favor of
103
+ performance, this method does not checks precedence
104
+ constraints.
105
+ """
106
+ self._check_start_time_of_new_operation(scheduled_operation)
107
+ self.schedule[scheduled_operation.machine_id].append(
108
+ scheduled_operation
109
+ )
110
+
111
+ def _check_start_time_of_new_operation(
112
+ self,
113
+ new_operation: ScheduledOperation,
114
+ ):
115
+ is_first_operation = not self.schedule[new_operation.machine_id]
116
+ if is_first_operation:
117
+ return
118
+
119
+ last_operation = self.schedule[new_operation.machine_id][-1]
120
+ self._check_start_time(new_operation, last_operation)
121
+
122
+ @staticmethod
123
+ def _check_start_time(
124
+ scheduled_operation: ScheduledOperation,
125
+ previous_operation: ScheduledOperation,
126
+ ):
127
+ """Raises a ValueError if the start time of the new operation is before
128
+ the end time of the last operation on the same machine."""
129
+
130
+ if previous_operation.end_time <= scheduled_operation.start_time:
131
+ return
132
+
133
+ raise ValueError(
134
+ "Operation cannot be scheduled before the last operation on "
135
+ "the same machine: end time of last operation "
136
+ f"({previous_operation.end_time}) > start time of new operation "
137
+ f"({scheduled_operation.start_time})."
138
+ )
139
+
140
+ @staticmethod
141
+ def check_schedule(schedule: list[list[ScheduledOperation]]):
142
+ """Checks if a schedule is valid and raises a ValueError if it is not.
143
+
144
+ A schedule is considered invalid if:
145
+ - A `ScheduledOperation` has a machine id that does not match the
146
+ machine id of the machine schedule (the list of
147
+ `ScheduledOperation` objects) that it belongs to.
148
+ - The start time of a `ScheduledOperation` is before the end time
149
+ of the last operation on the same machine.
150
+
151
+ Args:
152
+ schedule:
153
+ The schedule (a list of lists of `ScheduledOperation` objects)
154
+ to check.
155
+
156
+ Raises:
157
+ ValueError: If the schedule is invalid.
158
+ """
159
+ for machine_id, scheduled_operations in enumerate(schedule):
160
+ for i, scheduled_operation in enumerate(scheduled_operations):
161
+ if scheduled_operation.machine_id != machine_id:
162
+ raise ValueError(
163
+ "The machine id of the scheduled operation "
164
+ f"({ScheduledOperation.machine_id}) does not match "
165
+ f"the machine id of the machine schedule ({machine_id}"
166
+ f"). Index of the operation: [{machine_id}][{i}]."
167
+ )
168
+
169
+ if i == 0:
170
+ continue
171
+
172
+ Schedule._check_start_time(
173
+ scheduled_operation, scheduled_operations[i - 1]
174
+ )
175
+
176
+ def __eq__(self, value: object) -> bool:
177
+ if not isinstance(value, Schedule):
178
+ return False
179
+
180
+ return self.schedule == value.schedule
@@ -0,0 +1,97 @@
1
+ """Home of the `ScheduledOperation` class."""
2
+
3
+ from job_shop_lib import Operation
4
+
5
+
6
+ class ScheduledOperation:
7
+ """Data structure to store a scheduled operation.
8
+
9
+ Attributes:
10
+ operation:
11
+ The `Operation` object that is scheduled.
12
+ start_time:
13
+ The time at which the operation is scheduled to start.
14
+ machine_id:
15
+ The id of the machine on which the operation is scheduled.
16
+ """
17
+
18
+ __slots__ = ("operation", "start_time", "_machine_id")
19
+
20
+ def __init__(self, operation: Operation, start_time: int, machine_id: int):
21
+ """Initializes the object with the given operation, start time, and
22
+ machine id.
23
+
24
+ Args:
25
+ operation:
26
+ The `Operation` object that is scheduled.
27
+ start_time:
28
+ The time at which the operation is scheduled to start.
29
+ machine_id:
30
+ The id of the machine on which the operation is scheduled.
31
+
32
+ Raises:
33
+ ValueError:
34
+ If the machine_id is not valid for the operation.
35
+ """
36
+ self.operation = operation
37
+ self.start_time = start_time
38
+ self._machine_id = machine_id
39
+ self.machine_id = machine_id # Validate machine_id
40
+
41
+ @property
42
+ def machine_id(self) -> int:
43
+ """Returns the id of the machine on which the operation has been
44
+ scheduled."""
45
+ return self._machine_id
46
+
47
+ @machine_id.setter
48
+ def machine_id(self, value: int):
49
+ if value not in self.operation.machines:
50
+ raise ValueError(
51
+ f"Operation cannot be scheduled on machine {value}. "
52
+ f"Valid machines are {self.operation.machines}."
53
+ )
54
+ self._machine_id = value
55
+
56
+ @property
57
+ def job_id(self) -> int:
58
+ """Returns the id of the job that the operation belongs to.
59
+
60
+ Raises:
61
+ ValueError: If the operation has no job_id.
62
+ """
63
+
64
+ if self.operation.job_id is None:
65
+ raise ValueError("Operation has no job_id.")
66
+ return self.operation.job_id
67
+
68
+ @property
69
+ def position(self) -> int:
70
+ """Returns the position (starting at zero) of the operation in the job.
71
+
72
+ Raises:
73
+ ValueError: If the operation has no position_in_job.
74
+ """
75
+ if self.operation.position_in_job is None:
76
+ raise ValueError("Operation has no position.")
77
+ return self.operation.position_in_job
78
+
79
+ @property
80
+ def end_time(self) -> int:
81
+ """Returns the time at which the operation is scheduled to end."""
82
+ return self.start_time + self.operation.duration
83
+
84
+ def __repr__(self) -> str:
85
+ return (
86
+ f"S-Op(operation={self.operation}, "
87
+ f"start_time={self.start_time}, machine_id={self.machine_id})"
88
+ )
89
+
90
+ def __eq__(self, value: object) -> bool:
91
+ if not isinstance(value, ScheduledOperation):
92
+ return False
93
+ return (
94
+ self.operation is value.operation
95
+ and self.start_time == value.start_time
96
+ and self.machine_id == value.machine_id
97
+ )
@@ -0,0 +1,25 @@
1
+ """Package for visualization."""
2
+
3
+ from job_shop_lib.visualization.gantt_chart import plot_gantt_chart
4
+ from job_shop_lib.visualization.create_gif import (
5
+ create_gif,
6
+ create_gantt_chart_frames,
7
+ plot_gantt_chart_wrapper,
8
+ create_gif_from_frames,
9
+ )
10
+ from job_shop_lib.visualization.disjunctive_graph import plot_disjunctive_graph
11
+ from job_shop_lib.visualization.agent_task_graph import (
12
+ plot_agent_task_graph,
13
+ three_columns_layout,
14
+ )
15
+
16
+ __all__ = [
17
+ "plot_gantt_chart",
18
+ "create_gif",
19
+ "create_gantt_chart_frames",
20
+ "plot_gantt_chart_wrapper",
21
+ "create_gif_from_frames",
22
+ "plot_disjunctive_graph",
23
+ "plot_agent_task_graph",
24
+ "three_columns_layout",
25
+ ]
@@ -0,0 +1,257 @@
1
+ """Contains functions to plot the agent-task graph of a job shop instance.
2
+
3
+ The agent-task graph was introduced by Junyoung Park et al. (2021).
4
+ In contrast to the disjunctive graph, instead of connecting operations that
5
+ share the same resources directly by disjunctive edges, operation nodes are
6
+ connected with machine ones. All machine nodes are connected between them, and
7
+ all operation nodes from the same job are connected by non-directed edges too.
8
+
9
+ See: job_shop_lib.graphs.build_agent_task_graph module for more information.
10
+ """
11
+
12
+ from typing import Optional
13
+
14
+ import matplotlib.pyplot as plt
15
+ import networkx as nx
16
+
17
+ from job_shop_lib.graphs import NodeType, JobShopGraph, Node
18
+
19
+
20
+ def plot_agent_task_graph(
21
+ job_shop_graph: JobShopGraph,
22
+ title: Optional[str] = None,
23
+ figsize: tuple[int, int] = (10, 10),
24
+ layout: Optional[dict[Node, tuple[float, float]]] = None,
25
+ color_map_name: str = "tab10",
26
+ node_size: int = 1000,
27
+ alpha: float = 0.95,
28
+ add_legend: bool = False,
29
+ ) -> plt.Figure:
30
+ """Returns a plot of the agent-task graph of the instance.
31
+
32
+ Machine and job nodes are represented by squares, and the operation nodes
33
+ are represented by circles.
34
+
35
+ Args:
36
+ job_shop_graph:
37
+ The job shop graph instance. It should be already initialized with
38
+ the instance with a valid agent-task graph representation.
39
+
40
+ Returns:
41
+ The figure of the plot. This figure can be used to save the plot to a
42
+ file or to show it in a Jupyter notebook.
43
+ """
44
+ if title is None:
45
+ title = (
46
+ f"Agent-Task Graph Visualization: {job_shop_graph.instance.name}"
47
+ )
48
+ # Create a new figure and axis
49
+ fig, ax = plt.subplots(figsize=figsize)
50
+ fig.suptitle(title)
51
+
52
+ # Create the networkx graph
53
+ graph = job_shop_graph.graph
54
+
55
+ # Create the layout if it was not provided
56
+ if layout is None:
57
+ layout = three_columns_layout(job_shop_graph)
58
+
59
+ # Define colors and shapes
60
+ color_map = plt.get_cmap(color_map_name)
61
+ machine_colors = {
62
+ machine.machine_id: color_map(i)
63
+ for i, machine in enumerate(
64
+ job_shop_graph.nodes_by_type[NodeType.MACHINE]
65
+ )
66
+ }
67
+ node_colors = [
68
+ _get_node_color(node, machine_colors) for node in job_shop_graph.nodes
69
+ ]
70
+ node_shapes = {"machine": "s", "job": "d", "operation": "o", "global": "o"}
71
+
72
+ # Draw nodes with different shapes based on their type
73
+ for node_type, shape in node_shapes.items():
74
+ current_nodes = [
75
+ node.node_id
76
+ for node in job_shop_graph.nodes
77
+ if node.node_type.name.lower() == node_type
78
+ ]
79
+ nx.draw_networkx_nodes(
80
+ graph,
81
+ layout,
82
+ nodelist=current_nodes,
83
+ node_color=[node_colors[i] for i in current_nodes],
84
+ node_shape=shape,
85
+ ax=ax,
86
+ node_size=node_size,
87
+ alpha=alpha,
88
+ )
89
+
90
+ # Draw edges
91
+ nx.draw_networkx_edges(graph, layout, ax=ax)
92
+
93
+ node_labels = {
94
+ node.node_id: _get_node_label(node) for node in job_shop_graph.nodes
95
+ }
96
+ nx.draw_networkx_labels(graph, layout, node_labels, ax=ax)
97
+
98
+ ax.set_axis_off()
99
+
100
+ plt.tight_layout()
101
+
102
+ # Add to the legend the meaning of m and d
103
+ if add_legend:
104
+ plt.figtext(0, 0.95, "d = duration", wrap=True, fontsize=12)
105
+ return fig
106
+
107
+
108
+ def _get_node_color(
109
+ node: Node, machine_colors: dict[int, tuple[float, float, float, float]]
110
+ ) -> tuple[float, float, float, float] | str:
111
+ if node.node_type == NodeType.OPERATION:
112
+ return machine_colors[node.operation.machine_id]
113
+ if node.node_type == NodeType.MACHINE:
114
+ return machine_colors[node.machine_id]
115
+
116
+ return "lightblue"
117
+
118
+
119
+ def _get_node_label(node: Node) -> str:
120
+ if node.node_type == NodeType.OPERATION:
121
+ return f"d={node.operation.duration}"
122
+ if node.node_type == NodeType.MACHINE:
123
+ return f"M{node.machine_id}"
124
+ if node.node_type == NodeType.JOB:
125
+ return f"J{node.job_id}"
126
+ if node.node_type == NodeType.GLOBAL:
127
+ return "G"
128
+
129
+ raise ValueError(f"Invalid node type: {node.node_type}")
130
+
131
+
132
+ def three_columns_layout(
133
+ job_shop_graph: JobShopGraph,
134
+ *,
135
+ leftmost_position: float = 0.1,
136
+ rightmost_position: float = 0.9,
137
+ topmost_position: float = 1.0,
138
+ bottommost_position: float = 0.0,
139
+ ) -> dict[Node, tuple[float, float]]:
140
+ """Returns the layout of the agent-task graph.
141
+
142
+ The layout is organized in a grid manner. For example, for a JobShopGraph
143
+ representing a job shop instance with 2 machines and 3 jobs, the layout
144
+ would be:
145
+
146
+ 0: - O_11 -
147
+ 1: - O_12 J1
148
+ 2: - O_13 -
149
+ 3: M1 O_21 -
150
+ 4: - O_22 J2
151
+ 5: - O_23 -
152
+ 6: M2 O_31 -
153
+ 7: - O_32 J3
154
+ 8: - O_33 -
155
+ 9: - - -
156
+ 10: - G -
157
+ Where M1 and M2 are the machine nodes, J1, J2, and J3 are the job
158
+ nodes, O_ij are the operation nodes, and G is the global node.
159
+
160
+ Args:
161
+ job_shop_graph:
162
+ The job shop graph instance. It should be already initialized with
163
+ the instance with a valid agent-task graph representation.
164
+ leftmost_position:
165
+ The center position of the leftmost column of the layout. It should
166
+ be a float between 0 and 1. The default is 0.1.
167
+ rightmost_position:
168
+ The center position of the rightmost column of the layout. It
169
+ should be a float between 0 and 1. The default is 0.9.
170
+ topmost_position:
171
+ The center position of the topmost node of the layout. It should be
172
+ a float between 0 and 1. The default is 0.9.
173
+ bottommost_position:
174
+ The center position of the bottommost node of the layout. It should
175
+ be a float between 0 and 1. The default is 0.1.
176
+
177
+ Returns:
178
+ A dictionary with the position of each node in the graph. The keys are
179
+ the node ids, and the values are tuples with the x and y coordinates.
180
+ """
181
+
182
+ x_positions = _get_x_positions(leftmost_position, rightmost_position)
183
+
184
+ operation_nodes = job_shop_graph.nodes_by_type[NodeType.OPERATION]
185
+ machine_nodes = job_shop_graph.nodes_by_type[NodeType.MACHINE]
186
+ job_nodes = job_shop_graph.nodes_by_type[NodeType.JOB]
187
+ global_nodes = job_shop_graph.nodes_by_type[NodeType.GLOBAL]
188
+
189
+ total_positions = len(operation_nodes) + len(global_nodes) * 2
190
+ y_spacing = (topmost_position - bottommost_position) / total_positions
191
+
192
+ layout: dict[Node, tuple[float, float]] = {}
193
+
194
+ machines_spacing_multiplier = len(operation_nodes) // len(machine_nodes)
195
+ layout.update(
196
+ _assign_positions_from_top(
197
+ machine_nodes,
198
+ x_positions["machine"],
199
+ topmost_position,
200
+ y_spacing * machines_spacing_multiplier,
201
+ )
202
+ )
203
+ layout.update(
204
+ (
205
+ _assign_positions_from_top(
206
+ operation_nodes,
207
+ x_positions["operation"],
208
+ topmost_position,
209
+ y_spacing,
210
+ )
211
+ )
212
+ )
213
+
214
+ if global_nodes:
215
+ layout[global_nodes[0]] = (
216
+ x_positions["operation"],
217
+ bottommost_position,
218
+ )
219
+
220
+ if job_nodes:
221
+ job_multiplier = len(operation_nodes) // len(job_nodes)
222
+ layout.update(
223
+ _assign_positions_from_top(
224
+ job_nodes,
225
+ x_positions["job"],
226
+ topmost_position,
227
+ y_spacing * job_multiplier,
228
+ )
229
+ )
230
+ return layout
231
+
232
+
233
+ def _get_x_positions(
234
+ leftmost_position: float, rightmost_position: float
235
+ ) -> dict[str, float]:
236
+ center_position = (
237
+ leftmost_position + (rightmost_position - leftmost_position) / 2
238
+ )
239
+ return {
240
+ "machine": leftmost_position,
241
+ "operation": center_position,
242
+ "job": rightmost_position,
243
+ }
244
+
245
+
246
+ def _assign_positions_from_top(
247
+ nodes: list[Node],
248
+ x: float,
249
+ top: float,
250
+ y_spacing: float,
251
+ ) -> dict[Node, tuple[float, float]]:
252
+ layout: dict[Node, tuple[float, float]] = {}
253
+ for i, node in enumerate(nodes):
254
+ y = top - (i + 1) * y_spacing
255
+ layout[node] = (x, y)
256
+
257
+ return layout