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,159 @@
1
+ """Home of the `JobShopGraph` class."""
2
+
3
+ import collections
4
+ import networkx as nx
5
+
6
+ from job_shop_lib import JobShopInstance, Operation
7
+ from job_shop_lib.graphs import Node, NodeType
8
+
9
+
10
+ NODE_ATTR = "node"
11
+
12
+
13
+ class JobShopGraph:
14
+ """Data structure to represent a `JobShopInstance` as a graph.
15
+
16
+ Provides a comprehensive graph-based representation of a job shop
17
+ scheduling problem, utilizing the `networkx` library to model the complex
18
+ relationships between jobs, operations, and machines. This class transforms
19
+ the abstract scheduling problem into a directed graph, where various
20
+ entities (jobs, machines, and operations) are nodes, and the dependencies
21
+ (such as operation order within a job or machine assignment) are edges.
22
+
23
+ This transformation allows for the application of graph algorithms
24
+ to analyze and solve scheduling problems.
25
+
26
+ Attributes:
27
+ instance:
28
+ The job shop instance encapsulated by this graph.
29
+ graph:
30
+ The directed graph representing the job shop, where nodes are
31
+ operations, machines, jobs, or abstract concepts like global,
32
+ source, and sink, with edges indicating dependencies.
33
+ nodes:
34
+ A list of all nodes, encapsulated by the `Node` class, in the
35
+ graph.
36
+ nodes_by_type:
37
+ A dictionary categorizing nodes by their `NodeType`,
38
+ facilitating access to nodes of a particular type.
39
+ nodes_by_machine:
40
+ A nested list mapping each machine to its associated
41
+ operation nodes, aiding in machine-specific analysis.
42
+ nodes_by_job:
43
+ Similar to `nodes_by_machine`, but maps jobs to their
44
+ operation nodes, useful for job-specific traversal.
45
+ """
46
+
47
+ __slots__ = (
48
+ "instance",
49
+ "graph",
50
+ "nodes",
51
+ "nodes_by_type",
52
+ "nodes_by_machine",
53
+ "nodes_by_job",
54
+ "_next_node_id",
55
+ )
56
+
57
+ def __init__(self, instance: JobShopInstance):
58
+ """Initializes the graph with the given instance.
59
+
60
+ Nodes of type `OPERATION` are added to the graph based on the
61
+ operations of the instance.
62
+
63
+ Args:
64
+ instance:
65
+ The job shop instance that the graph represents.
66
+ """
67
+ self.graph = nx.DiGraph()
68
+ self.instance = instance
69
+
70
+ self.nodes: list[Node] = []
71
+
72
+ self.nodes_by_type: dict[NodeType, list[Node]] = (
73
+ collections.defaultdict(list)
74
+ )
75
+
76
+ self.nodes_by_machine: list[list[Node]] = [
77
+ [] for _ in range(instance.num_machines)
78
+ ]
79
+
80
+ self.nodes_by_job: list[list[Node]] = [
81
+ [] for _ in range(instance.num_jobs)
82
+ ]
83
+
84
+ self._next_node_id = 0
85
+
86
+ self._add_operation_nodes()
87
+
88
+ def _add_operation_nodes(self) -> None:
89
+ """Adds operation nodes to the graph."""
90
+ for job in self.instance.jobs:
91
+ for operation in job:
92
+ node = Node(node_type=NodeType.OPERATION, operation=operation)
93
+ self.add_node(node)
94
+
95
+ def get_operation_from_id(self, operation_id: int) -> Operation:
96
+ """Returns the operation with the given id."""
97
+ return self.nodes[operation_id].operation
98
+
99
+ def add_node(self, node_for_adding: Node) -> None:
100
+ """Adds a node to the graph and updates relevant class attributes.
101
+
102
+ This method assigns a unique identifier to the node, adds it to the
103
+ graph, and updates the nodes list and the nodes_by_type dictionary. If
104
+ the node is of type `OPERATION`, it also updates `nodes_by_job` and
105
+ `nodes_by_machine` based on the operation's job_id and machine_ids.
106
+
107
+ Args:
108
+ node_for_adding (Node): The node to be added to the graph.
109
+
110
+ Raises:
111
+ ValueError: If the node type is unsupported or if required
112
+ attributes for the node type are missing.
113
+
114
+ Note:
115
+ This method directly modifies the graph attribute as well as
116
+ several other class attributes. Thus, adding nodes to the graph
117
+ should be done exclusively through this method to avoid
118
+ inconsistencies.
119
+ """
120
+ node_for_adding.node_id = self._next_node_id
121
+ self.graph.add_node(
122
+ node_for_adding.node_id, **{NODE_ATTR: node_for_adding}
123
+ )
124
+ self.nodes_by_type[node_for_adding.node_type].append(node_for_adding)
125
+ self.nodes.append(node_for_adding)
126
+ self._next_node_id += 1
127
+
128
+ if node_for_adding.node_type == NodeType.OPERATION:
129
+ operation = node_for_adding.operation
130
+ self.nodes_by_job[operation.job_id].append(node_for_adding)
131
+ for machine_id in operation.machines:
132
+ self.nodes_by_machine[machine_id].append(node_for_adding)
133
+
134
+ def add_edge(
135
+ self, u_of_edge: Node | int, v_of_edge: Node | int, **attr
136
+ ) -> None:
137
+ """Adds an edge to the graph.
138
+
139
+ Args:
140
+ u_of_edge: The source node of the edge. If it is a `Node`, its
141
+ `node_id` is used as the source. Otherwise, it is assumed to be
142
+ the node_id of the source.
143
+ v_of_edge: The destination node of the edge. If it is a `Node`, its
144
+ `node_id` is used as the destination. Otherwise, it is assumed
145
+ to be the node_id of the destination.
146
+ **attr: Additional attributes to be added to the edge.
147
+
148
+ Raises:
149
+ ValueError: If `u_of_edge` or `v_of_edge` are not in the graph.
150
+ """
151
+ if isinstance(u_of_edge, Node):
152
+ u_of_edge = u_of_edge.node_id
153
+ if isinstance(v_of_edge, Node):
154
+ v_of_edge = v_of_edge.node_id
155
+ if u_of_edge not in self.graph or v_of_edge not in self.graph:
156
+ raise ValueError(
157
+ "`u_of_edge` and `v_of_edge` must be in the graph."
158
+ )
159
+ self.graph.add_edge(u_of_edge, v_of_edge, **attr)
@@ -0,0 +1,147 @@
1
+ """Home of the `Node` class."""
2
+
3
+ from job_shop_lib import Operation
4
+ from job_shop_lib.graphs.constants import NodeType
5
+
6
+
7
+ class Node:
8
+ """Data structure to represent a node in the `JobShopGraph`.
9
+
10
+ A node is hashable by its id. The id is assigned when the node is added to
11
+ the graph. The id must be unique for each node in the graph, and should be
12
+ used to identify the node in the networkx graph.
13
+
14
+ Depending on the type of the node, it can have different attributes. The
15
+ following table shows the attributes of each type of node:
16
+
17
+ Node Type | Required Attribute
18
+ ----------------|---------------------
19
+ OPERATION | `operation`
20
+ MACHINE | `machine_id`
21
+ JOB | `job_id`
22
+
23
+ In terms of equality, two nodes are equal if they have the same id.
24
+ Additionally, one node is equal to an integer if the integer is equal to
25
+ its id. It is also hashable by its id.
26
+
27
+ This allows for using the node as a key in a dictionary, at the same time
28
+ we can use its id to index that dictionary. Example:
29
+
30
+ ```python
31
+ node = Node(NodeType.SOURCE)
32
+ node.node_id = 1
33
+ graph = {node: "some value"}
34
+ print(graph[node]) # "some value"
35
+ print(graph[1]) # "some value"
36
+ ```
37
+
38
+ Args:
39
+ node_type:
40
+ The type of the node. It can be one of the following:
41
+ - NodeType.OPERATION
42
+ - NodeType.MACHINE
43
+ - NodeType.JOB
44
+ - NodeType.GLOBAL
45
+ ...
46
+ operation:
47
+ The operation of the node. It should be provided if the node_type
48
+ is NodeType.OPERATION.
49
+ machine_id:
50
+ The id of the machine of the node. It should be provided if the
51
+ node_type is NodeType.MACHINE.
52
+ job_id:
53
+ The id of the job of the node. It should be provided if the
54
+ node_type is NodeType.JOB.
55
+ """
56
+
57
+ __slots__ = "node_type", "_node_id", "_operation", "_machine_id", "_job_id"
58
+
59
+ def __init__(
60
+ self,
61
+ node_type: NodeType,
62
+ operation: Operation | None = None,
63
+ machine_id: int | None = None,
64
+ job_id: int | None = None,
65
+ ):
66
+ if node_type == NodeType.OPERATION and operation is None:
67
+ raise ValueError("Operation node must have an operation.")
68
+
69
+ if node_type == NodeType.MACHINE and machine_id is None:
70
+ raise ValueError("Machine node must have a machine_id.")
71
+
72
+ if node_type == NodeType.JOB and job_id is None:
73
+ raise ValueError("Job node must have a job_id.")
74
+
75
+ self.node_type = node_type
76
+ self._node_id: int | None = None
77
+
78
+ self._operation = operation
79
+ self._machine_id = machine_id
80
+ self._job_id = job_id
81
+
82
+ @property
83
+ def node_id(self) -> int:
84
+ """Returns a unique identifier for the node."""
85
+ if self._node_id is None:
86
+ raise ValueError("Node has not been assigned an id.")
87
+ return self._node_id
88
+
89
+ @node_id.setter
90
+ def node_id(self, value: int) -> None:
91
+ self._node_id = value
92
+
93
+ @property
94
+ def operation(self) -> Operation:
95
+ """Returns the operation of the node.
96
+
97
+ This property is mandatory for nodes of type `OPERATION`.
98
+ """
99
+ if self._operation is None:
100
+ raise ValueError("Node has no operation.")
101
+ return self._operation
102
+
103
+ @property
104
+ def machine_id(self) -> int:
105
+ """Returns the `machine_id` of the node.
106
+
107
+ This property is mandatory for nodes of type `MACHINE`.
108
+ """
109
+ if self._machine_id is None:
110
+ raise ValueError("Node has no `machine_id`.")
111
+ return self._machine_id
112
+
113
+ @property
114
+ def job_id(self) -> int:
115
+ """Returns the `job_id` of the node.
116
+
117
+ This property is mandatory for nodes of type `JOB`.
118
+ """
119
+ if self._job_id is None:
120
+ raise ValueError("Node has no `job_id`.")
121
+ return self._job_id
122
+
123
+ def __hash__(self) -> int:
124
+ return self.node_id
125
+
126
+ def __eq__(self, __value: object) -> bool:
127
+ if isinstance(__value, Node):
128
+ __value = __value.node_id
129
+ return self.node_id == __value
130
+
131
+ def __repr__(self) -> str:
132
+ if self.node_type == NodeType.OPERATION:
133
+ return (
134
+ f"Node(node_type={self.node_type.name}, id={self._node_id}, "
135
+ f"operation={self.operation})"
136
+ )
137
+ if self.node_type == NodeType.MACHINE:
138
+ return (
139
+ f"Node(node_type={self.node_type.name}, id={self._node_id}, "
140
+ f"machine_id={self._machine_id})"
141
+ )
142
+ if self.node_type == NodeType.JOB:
143
+ return (
144
+ f"Node(node_type={self.node_type.name}, id={self._node_id}, "
145
+ f"job_id={self._job_id})"
146
+ )
147
+ return f"Node(node_type={self.node_type.name}, id={self._node_id})"
@@ -0,0 +1,355 @@
1
+ """Contains the `JobShopInstance` class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import functools
7
+ from typing import Any
8
+
9
+ from job_shop_lib import Operation
10
+
11
+
12
+ class JobShopInstance:
13
+ """Data structure to store a Job Shop Scheduling Problem instance.
14
+
15
+ Additional attributes such as `num_jobs` or `num_machines` can be computed
16
+ from the instance and are cached for performance if they require expensive
17
+ computations.
18
+
19
+ Attributes:
20
+ jobs:
21
+ A list of lists of operations. Each list of operations represents
22
+ a job, and the operations are ordered by their position in the job.
23
+ The `job_id`, `position_in_job`, and `operation_id` attributes of
24
+ the operations are set when the instance is created.
25
+ name:
26
+ A string with the name of the instance.
27
+ metadata:
28
+ A dictionary with additional information about the instance.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ jobs: list[list[Operation]],
34
+ name: str = "JobShopInstance",
35
+ **metadata: Any,
36
+ ):
37
+ """Initializes the instance based on a list of lists of operations.
38
+
39
+ Args:
40
+ jobs:
41
+ A list of lists of operations. Each list of operations
42
+ represents a job, and the operations are ordered by their
43
+ position in the job. The `job_id`, `position_in_job`, and
44
+ `operation_id` attributes of the operations are set when the
45
+ instance is created.
46
+ name:
47
+ A string with the name of the instance.
48
+ **metadata:
49
+ Additional information about the instance.
50
+ """
51
+ self.jobs = jobs
52
+ self.set_operation_attributes()
53
+ self.name = name
54
+ self.metadata = metadata
55
+
56
+ def set_operation_attributes(self):
57
+ """Sets the job_id and position of each operation."""
58
+ operation_id = 0
59
+ for job_id, job in enumerate(self.jobs):
60
+ for position, operation in enumerate(job):
61
+ operation.job_id = job_id
62
+ operation.position_in_job = position
63
+ operation.operation_id = operation_id
64
+ operation_id += 1
65
+
66
+ @classmethod
67
+ def from_taillard_file(
68
+ cls,
69
+ file_path: os.PathLike | str | bytes,
70
+ encoding: str = "utf-8",
71
+ comment_symbol: str = "#",
72
+ name: str | None = None,
73
+ **metadata: Any,
74
+ ) -> JobShopInstance:
75
+ """Creates a JobShopInstance from a file following Taillard's format.
76
+
77
+ Args:
78
+ file_path:
79
+ A path-like object or string representing the path to the file.
80
+ encoding:
81
+ The encoding of the file.
82
+ comment_symbol:
83
+ A string representing the comment symbol used in the file.
84
+ Lines starting with this symbol are ignored.
85
+ name:
86
+ A string with the name of the instance. If not provided, the
87
+ name of the instance is set to the name of the file.
88
+ **metadata:
89
+ Additional information about the instance.
90
+
91
+ Returns:
92
+ A JobShopInstance object with the operations read from the file,
93
+ and the name and metadata provided.
94
+ """
95
+ with open(file_path, "r", encoding=encoding) as file:
96
+ lines = file.readlines()
97
+
98
+ first_non_comment_line_reached = False
99
+ jobs = []
100
+ for line in lines:
101
+ line = line.strip()
102
+ if line.startswith(comment_symbol):
103
+ continue
104
+ if not first_non_comment_line_reached:
105
+ first_non_comment_line_reached = True
106
+ continue
107
+
108
+ row = list(map(int, line.split()))
109
+ pairs = zip(row[::2], row[1::2])
110
+ operations = [
111
+ Operation(machines=machine_id, duration=duration)
112
+ for machine_id, duration in pairs
113
+ ]
114
+ jobs.append(operations)
115
+
116
+ if name is None:
117
+ name = os.path.basename(str(file_path))
118
+ if "." in name:
119
+ name = name.split(".")[0]
120
+ return cls(jobs=jobs, name=name, **metadata)
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ """Returns a dictionary representation of the instance.
124
+
125
+ This representation is useful for saving the instance to a JSON file,
126
+ which is a more computer-friendly format than more traditional ones
127
+ like Taillard's.
128
+
129
+ Returns:
130
+ The returned dictionary has the following structure:
131
+ {
132
+ "name": self.name,
133
+ "duration_matrix": self.durations_matrix,
134
+ "machines_matrix": self.machines_matrix,
135
+ "metadata": self.metadata,
136
+ }
137
+ """
138
+ return {
139
+ "name": self.name,
140
+ "duration_matrix": self.durations_matrix,
141
+ "machines_matrix": self.machines_matrix,
142
+ "metadata": self.metadata,
143
+ }
144
+
145
+ @classmethod
146
+ def from_matrices(
147
+ cls,
148
+ duration_matrix: list[list[int]],
149
+ machines_matrix: list[list[list[int]]] | list[list[int]],
150
+ name: str = "JobShopInstance",
151
+ metadata: dict[str, Any] | None = None,
152
+ ) -> JobShopInstance:
153
+ """Creates a JobShopInstance from duration and machines matrices.
154
+
155
+ Args:
156
+ duration_matrix:
157
+ A list of lists of integers. The i-th list contains the
158
+ durations of the operations of the job with id i.
159
+ machines_matrix:
160
+ A list of lists of lists of integers if the
161
+ instance is flexible, or a list of lists of integers if the
162
+ instance is not flexible. The i-th list contains the machines
163
+ in which the operations of the job with id i can be processed.
164
+ name:
165
+ A string with the name of the instance.
166
+ metadata:
167
+ A dictionary with additional information about the instance.
168
+
169
+ Returns:
170
+ A JobShopInstance object.
171
+ """
172
+ jobs: list[list[Operation]] = [[] for _ in range(len(duration_matrix))]
173
+
174
+ num_jobs = len(duration_matrix)
175
+ for job_id in range(num_jobs):
176
+ num_operations = len(duration_matrix[job_id])
177
+ for position_in_job in range(num_operations):
178
+ duration = duration_matrix[job_id][position_in_job]
179
+ machines = machines_matrix[job_id][position_in_job]
180
+ jobs[job_id].append(
181
+ Operation(duration=duration, machines=machines)
182
+ )
183
+
184
+ metadata = {} if metadata is None else metadata
185
+ return cls(jobs=jobs, name=name, **metadata)
186
+
187
+ def __repr__(self) -> str:
188
+ return (
189
+ f"JobShopInstance(name={self.name}, "
190
+ f"num_jobs={self.num_jobs}, num_machines={self.num_machines})"
191
+ )
192
+
193
+ def __eq__(self, other: Any) -> bool:
194
+ if not isinstance(other, JobShopInstance):
195
+ return False
196
+ return self.jobs == other.jobs
197
+
198
+ @property
199
+ def num_jobs(self) -> int:
200
+ """Returns the number of jobs in the instance."""
201
+ return len(self.jobs)
202
+
203
+ @functools.cached_property
204
+ def num_machines(self) -> int:
205
+ """Returns the number of machines in the instance.
206
+
207
+ Computed as the maximum machine id present in the instance plus one.
208
+ """
209
+ max_machine_id = -1
210
+ for job in self.jobs:
211
+ for operation in job:
212
+ max_machine_id = max(max_machine_id, *operation.machines)
213
+ return max_machine_id + 1
214
+
215
+ @functools.cached_property
216
+ def num_operations(self) -> int:
217
+ """Returns the number of operations in the instance."""
218
+ return sum(len(job) for job in self.jobs)
219
+
220
+ @functools.cached_property
221
+ def is_flexible(self) -> bool:
222
+ """Returns True if any operation has more than one machine."""
223
+ return any(
224
+ any(len(operation.machines) > 1 for operation in job)
225
+ for job in self.jobs
226
+ )
227
+
228
+ @functools.cached_property
229
+ def durations_matrix(self) -> list[list[int]]:
230
+ """Returns the duration matrix of the instance.
231
+
232
+ The duration of the operation with `job_id` i and `position_in_job` j
233
+ is stored in the i-th position of the j-th list of the returned matrix:
234
+
235
+ ```python
236
+ duration = instance.durations_matrix[i][j]
237
+ ```
238
+ """
239
+ return [[operation.duration for operation in job] for job in self.jobs]
240
+
241
+ @functools.cached_property
242
+ def machines_matrix(self) -> list[list[list[int]]] | list[list[int]]:
243
+ """Returns the machines matrix of the instance.
244
+
245
+ If the instance is flexible (i.e., if any operation has more than one
246
+ machine in which it can be processed), the returned matrix is a list of
247
+ lists of lists of integers.
248
+
249
+ Otherwise, the returned matrix is a list of lists of integers.
250
+
251
+ To access the machines of the operation with position i in the job
252
+ with id j, the following code must be used:
253
+
254
+ ```python
255
+ machines = instance.machines_matrix[j][i]
256
+ ```
257
+
258
+ """
259
+ if self.is_flexible:
260
+ return [
261
+ [operation.machines for operation in job] for job in self.jobs
262
+ ]
263
+ return [
264
+ [operation.machine_id for operation in job] for job in self.jobs
265
+ ]
266
+
267
+ @functools.cached_property
268
+ def operations_by_machine(self) -> list[list[Operation]]:
269
+ """Returns a list of lists of operations.
270
+
271
+ The i-th list contains the operations that can be processed in the
272
+ machine with id i.
273
+ """
274
+ operations_by_machine: list[list[Operation]] = [
275
+ [] for _ in range(self.num_machines)
276
+ ]
277
+ for job in self.jobs:
278
+ for operation in job:
279
+ for machine_id in operation.machines:
280
+ operations_by_machine[machine_id].append(operation)
281
+
282
+ return operations_by_machine
283
+
284
+ @functools.cached_property
285
+ def max_duration(self) -> float:
286
+ """Returns the maximum duration of the instance.
287
+
288
+ Useful for normalizing the durations of the operations."""
289
+ return max(
290
+ max(operation.duration for operation in job) for job in self.jobs
291
+ )
292
+
293
+ @functools.cached_property
294
+ def max_duration_per_job(self) -> list[float]:
295
+ """Returns the maximum duration of each job in the instance.
296
+
297
+ The maximum duration of the job with id i is stored in the i-th
298
+ position of the returned list.
299
+
300
+ Useful for normalizing the durations of the operations.
301
+ """
302
+ return [max(op.duration for op in job) for job in self.jobs]
303
+
304
+ @functools.cached_property
305
+ def max_duration_per_machine(self) -> list[int]:
306
+ """Returns the maximum duration of each machine in the instance.
307
+
308
+ The maximum duration of the machine with id i is stored in the i-th
309
+ position of the returned list.
310
+
311
+ Useful for normalizing the durations of the operations.
312
+ """
313
+ max_duration_per_machine = [0] * self.num_machines
314
+ for job in self.jobs:
315
+ for operation in job:
316
+ for machine_id in operation.machines:
317
+ max_duration_per_machine[machine_id] = max(
318
+ max_duration_per_machine[machine_id],
319
+ operation.duration,
320
+ )
321
+ return max_duration_per_machine
322
+
323
+ @functools.cached_property
324
+ def job_durations(self) -> list[int]:
325
+ """Returns a list with the duration of each job in the instance.
326
+
327
+ The duration of a job is the sum of the durations of its operations.
328
+
329
+ The duration of the job with id i is stored in the i-th position of the
330
+ returned list.
331
+ """
332
+ return [sum(op.duration for op in job) for job in self.jobs]
333
+
334
+ @functools.cached_property
335
+ def machine_loads(self) -> list[int]:
336
+ """Returns the total machine load of each machine in the instance.
337
+
338
+ The total machine load of a machine is the sum of the durations of the
339
+ operations that can be processed in that machine.
340
+
341
+ The total machine load of the machine with id i is stored in the i-th
342
+ position of the returned list.
343
+ """
344
+ machine_times = [0] * self.num_machines
345
+ for job in self.jobs:
346
+ for operation in job:
347
+ for machine_id in operation.machines:
348
+ machine_times[machine_id] += operation.duration
349
+
350
+ return machine_times
351
+
352
+ @functools.cached_property
353
+ def total_duration(self) -> int:
354
+ """Returns the sum of the durations of all operations in all jobs."""
355
+ return sum(self.job_durations)