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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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