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,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)