taskdependencygraph 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.
- _taskdependencygraph_version.py +1 -0
- taskdependencygraph/__init__.py +8 -0
- taskdependencygraph/models/__init__.py +24 -0
- taskdependencygraph/models/ids.py +38 -0
- taskdependencygraph/models/person.py +27 -0
- taskdependencygraph/models/task_dependency_edge.py +45 -0
- taskdependencygraph/models/task_dependency_update.py +61 -0
- taskdependencygraph/models/task_execution_status.py +37 -0
- taskdependencygraph/models/task_node.py +124 -0
- taskdependencygraph/models/task_node_as_artificial_endnode.py +25 -0
- taskdependencygraph/models/task_node_as_artificial_startnode.py +22 -0
- taskdependencygraph/plotting/__init__.py +11 -0
- taskdependencygraph/plotting/kroki.py +148 -0
- taskdependencygraph/plotting/protocols.py +43 -0
- taskdependencygraph/py.typed +2 -0
- taskdependencygraph/task_dependency_graph.py +591 -0
- taskdependencygraph-0.1.0.dist-info/METADATA +233 -0
- taskdependencygraph-0.1.0.dist-info/RECORD +19 -0
- taskdependencygraph-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TaskDependencyGraph
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# pylint:disable=anomalous-backslash-in-string
|
|
6
|
+
# The backslashes are part of an ASCII art embedded into a docstring.
|
|
7
|
+
import copy
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from itertools import pairwise
|
|
11
|
+
from typing import Literal, Mapping
|
|
12
|
+
|
|
13
|
+
import networkx as nx # type: ignore[import-untyped]
|
|
14
|
+
from networkx import DiGraph, dag_longest_path_length
|
|
15
|
+
from pydantic import AwareDatetime
|
|
16
|
+
|
|
17
|
+
from taskdependencygraph.models.ids import TaskDependencyId, TaskId
|
|
18
|
+
from taskdependencygraph.models.task_dependency_edge import TaskDependencyEdge
|
|
19
|
+
from taskdependencygraph.models.task_dependency_update import (
|
|
20
|
+
AddEdgeToGraphPreviewResponse,
|
|
21
|
+
AddNodeToGraphPreviewResponse,
|
|
22
|
+
)
|
|
23
|
+
from taskdependencygraph.models.task_node import TaskNode
|
|
24
|
+
from taskdependencygraph.models.task_node_as_artificial_endnode import task_node_as_artificial_endnode
|
|
25
|
+
from taskdependencygraph.models.task_node_as_artificial_startnode import task_node_as_artificial_startnode
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskDependencyGraph:
|
|
29
|
+
"""
|
|
30
|
+
This class is a wrapper around a directed graph. This means, that in this digraph class instances are instantiated,
|
|
31
|
+
which have the digraph as an attribute.
|
|
32
|
+
|
|
33
|
+
Directed graphs are graphs, in which one node, for example task 2, has a relation to another, for example task 1,
|
|
34
|
+
but not necessarily vice versa (in this example: task 2, which is the successor, depends on task 1 as a predecessor.
|
|
35
|
+
But task 1 doesn't depend on task 2: there is no consequence for task 1 if task 2 is for example not completed).
|
|
36
|
+
|
|
37
|
+
This digraph contains tasks as nodes, which are connected by task dependencies as edges.
|
|
38
|
+
|
|
39
|
+
Note that the actual DiGraph is only an attribute of an instance of TaskDependencyGraph. So, if you want to use the
|
|
40
|
+
TaskDependencyGraph, you need to refer to its attribute ("_graph") first.
|
|
41
|
+
But you shouldn't access it in the first place, except for some tests.
|
|
42
|
+
There is a reason why it's "private"/"protected".
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self, task_list: list[TaskNode], dependency_list: list[TaskDependencyEdge], starting_time_of_run: AwareDatetime
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
With this method task dependency graphs can be initialized.
|
|
50
|
+
"""
|
|
51
|
+
di_graph = nx.DiGraph()
|
|
52
|
+
self._graph = di_graph # The digraph is a protected attribute of the task dependency graph
|
|
53
|
+
for task in task_list:
|
|
54
|
+
di_graph.add_node(task.id, domain_model=task)
|
|
55
|
+
# As the task_instance.id, is the key in the resulting dictionary;
|
|
56
|
+
# the edges need to link the task_instance.ids (and not the task_instances themselves).
|
|
57
|
+
for edge in dependency_list:
|
|
58
|
+
self._graph.add_edge(
|
|
59
|
+
edge.task_predecessor,
|
|
60
|
+
edge.task_successor,
|
|
61
|
+
weight=self._graph.nodes[edge.task_predecessor]["domain_model"].planned_duration.total_seconds() / 60,
|
|
62
|
+
domain_model=edge,
|
|
63
|
+
)
|
|
64
|
+
self._starting_time_of_run = starting_time_of_run
|
|
65
|
+
self._add_artificial_nodes_and_edges()
|
|
66
|
+
self._account_for_earliest_start()
|
|
67
|
+
|
|
68
|
+
def _add_artificial_endnodes_and_edges(self) -> None:
|
|
69
|
+
r"""
|
|
70
|
+
This method adds a task node (with a duration of 0 minutes) as an artificial endnode to the task dependency
|
|
71
|
+
graph. This artificial endnode is then connected to the former final nodes (tasks without successor) by
|
|
72
|
+
artificial edges.
|
|
73
|
+
|
|
74
|
+
Thus this hack converts
|
|
75
|
+
(A)
|
|
76
|
+
| \
|
|
77
|
+
| \
|
|
78
|
+
(B) \
|
|
79
|
+
/ \ \
|
|
80
|
+
(C) (D) \
|
|
81
|
+
| (F)
|
|
82
|
+
(E)
|
|
83
|
+
|
|
84
|
+
to
|
|
85
|
+
(A)
|
|
86
|
+
| \
|
|
87
|
+
| \
|
|
88
|
+
(B) \
|
|
89
|
+
/ \ \
|
|
90
|
+
(C) (D) \
|
|
91
|
+
| | (F)
|
|
92
|
+
| (E) /
|
|
93
|
+
\ | /
|
|
94
|
+
\ | /
|
|
95
|
+
\ |/
|
|
96
|
+
(Z)
|
|
97
|
+
where (Z) is the artificial endnode with duration 0.
|
|
98
|
+
|
|
99
|
+
This artificial construction has practical reasons: it is needed in order to include the duration of the
|
|
100
|
+
former final nodes into the count, when identifying the critical path. The reason why the duration of the
|
|
101
|
+
former final tasks otherwise isn't included into the count is, that only the duration of the predecessor
|
|
102
|
+
tasks is counted - and the former final tasks are no predecessors. By adding the artificial final task the
|
|
103
|
+
former final tasks become predecessors and thus are included into the count.
|
|
104
|
+
"""
|
|
105
|
+
artificial_dependency_list = [
|
|
106
|
+
(task_without_successor, task_node_as_artificial_endnode.id)
|
|
107
|
+
for task_without_successor in self._graph.nodes()
|
|
108
|
+
if not any(DiGraph.successors(self._graph, task_without_successor))
|
|
109
|
+
]
|
|
110
|
+
self._graph.add_node(task_node_as_artificial_endnode.id, domain_model=task_node_as_artificial_endnode)
|
|
111
|
+
for artificial_edge in artificial_dependency_list:
|
|
112
|
+
self._graph.add_edge(
|
|
113
|
+
artificial_edge[0],
|
|
114
|
+
artificial_edge[1],
|
|
115
|
+
weight=self._graph.nodes[artificial_edge[0]]["domain_model"].planned_duration.total_seconds() / 60,
|
|
116
|
+
domain_model=TaskDependencyEdge(
|
|
117
|
+
id=TaskDependencyId(uuid.uuid4()),
|
|
118
|
+
task_predecessor=artificial_edge[0],
|
|
119
|
+
task_successor=artificial_edge[1],
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _add_artificial_startnode_and_edges(self) -> None:
|
|
124
|
+
r"""
|
|
125
|
+
This method adds a task node (with a duration of 0 minutes) as an artificial startnode to the task dependency
|
|
126
|
+
graph. This artificial startnode is then connected to the former final nodes (tasks without successor) by
|
|
127
|
+
artificial edges.
|
|
128
|
+
|
|
129
|
+
Thus this hack converts
|
|
130
|
+
|
|
131
|
+
(B)
|
|
132
|
+
/ \
|
|
133
|
+
(C) (D)
|
|
134
|
+
| | (F)
|
|
135
|
+
| (E) /
|
|
136
|
+
\ | /
|
|
137
|
+
\ | /
|
|
138
|
+
\ |/
|
|
139
|
+
(Z)
|
|
140
|
+
|
|
141
|
+
to
|
|
142
|
+
(A)
|
|
143
|
+
| \
|
|
144
|
+
| \
|
|
145
|
+
(B) \
|
|
146
|
+
/ \ \
|
|
147
|
+
(C) (D) \
|
|
148
|
+
| | (F)
|
|
149
|
+
| (E) /
|
|
150
|
+
\ | /
|
|
151
|
+
\ | /
|
|
152
|
+
\ |/
|
|
153
|
+
(Z)
|
|
154
|
+
where (A) is the artificial startnode with duration 0.
|
|
155
|
+
|
|
156
|
+
This artificial construction has practical reasons: it makes calculations with Networkx easier.
|
|
157
|
+
"""
|
|
158
|
+
artificial_dependency_list = [
|
|
159
|
+
(task_node_as_artificial_startnode.id, task_without_predecessor)
|
|
160
|
+
for task_without_predecessor in self._graph.nodes()
|
|
161
|
+
if not any(DiGraph.predecessors(self._graph, task_without_predecessor))
|
|
162
|
+
]
|
|
163
|
+
self._graph.add_node(task_node_as_artificial_startnode.id, domain_model=task_node_as_artificial_startnode)
|
|
164
|
+
for artificial_edge in artificial_dependency_list:
|
|
165
|
+
self._graph.add_edge(
|
|
166
|
+
artificial_edge[0],
|
|
167
|
+
artificial_edge[1],
|
|
168
|
+
weight=0,
|
|
169
|
+
domain_model=TaskDependencyEdge(
|
|
170
|
+
id=TaskDependencyId(uuid.uuid4()),
|
|
171
|
+
task_predecessor=artificial_edge[0],
|
|
172
|
+
task_successor=artificial_edge[1],
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _add_artificial_nodes_and_edges(self) -> None:
|
|
177
|
+
"""
|
|
178
|
+
This method adds the artificial startnode and endnode and their edges to the task dependency graph.
|
|
179
|
+
"""
|
|
180
|
+
self._add_artificial_endnodes_and_edges()
|
|
181
|
+
self._add_artificial_startnode_and_edges()
|
|
182
|
+
|
|
183
|
+
def _remove_artificial_nodes_and_edges(self) -> None:
|
|
184
|
+
"""
|
|
185
|
+
This method removes the artificial startnode and endnode and their edges from the task dependency graph.
|
|
186
|
+
"""
|
|
187
|
+
# see networkx docs: if we remove a node, all edges connected to it are removed as well
|
|
188
|
+
self._graph.remove_node(task_node_as_artificial_startnode.id)
|
|
189
|
+
self._graph.remove_node(task_node_as_artificial_endnode.id)
|
|
190
|
+
|
|
191
|
+
def _stretch_edges_with_successor_that_has_fixed_start(self) -> None:
|
|
192
|
+
"""
|
|
193
|
+
extends the duration of those tasks, which have a successor with the earliest possible start set.
|
|
194
|
+
Ideally, this should only be called if the edges are reset with self._reset_edges().
|
|
195
|
+
Use self._account_for_earliest_start() to ensure this.
|
|
196
|
+
"""
|
|
197
|
+
# Assume there are tasks A-->B-->C.
|
|
198
|
+
# If B has an earliest possible start, then the weight of the edge between A and B has to be stretched to
|
|
199
|
+
# account for the earliest possible start of B.
|
|
200
|
+
# The stretch amount is defined by the difference between A's actual duration and the earliest start of B.
|
|
201
|
+
# But if A itself is already delayed, then the stretch amount is defined by the difference between the actual
|
|
202
|
+
# start.
|
|
203
|
+
# The stretch amount/buffer length is returned by self._get_duration_or_buffer_length(...)
|
|
204
|
+
for task_id in nx.topological_sort(self._graph):
|
|
205
|
+
task = self._graph.nodes[task_id]["domain_model"]
|
|
206
|
+
if task.earliest_starttime is not None:
|
|
207
|
+
for predecessor_id in self._graph.predecessors(task.id):
|
|
208
|
+
# For this to work, it's important that we always start the iteration at the start node.
|
|
209
|
+
# Otherwise, the results from get_pseudo_duration might not account for the earliest start
|
|
210
|
+
# (of predecessors) yet.
|
|
211
|
+
# In other words: All tasks and respective edges, which are taken into consideration in
|
|
212
|
+
# _get_pseudo_duration have to have their weights adjusted already.
|
|
213
|
+
# This is guaranteed by the topological sort.
|
|
214
|
+
self._graph.edges[predecessor_id, task_id]["weight"] = (
|
|
215
|
+
self._get_duration_or_buffer_length(predecessor_id, task_id).total_seconds() / 60
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _reset_edges(self) -> None:
|
|
219
|
+
"""
|
|
220
|
+
(Re)sets the edge weights to the duration of the predecessor task.
|
|
221
|
+
Does _not_ consider the earliest possible starts.
|
|
222
|
+
This is used to reset the edge weights after nodes or edges have been modified.
|
|
223
|
+
"""
|
|
224
|
+
for edge in self._graph.edges:
|
|
225
|
+
predecessor_id = edge[0]
|
|
226
|
+
self._graph.edges[edge]["weight"] = (
|
|
227
|
+
self._graph.nodes[predecessor_id]["domain_model"].planned_duration.total_seconds() / 60
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _account_for_earliest_start(self) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Adjusts the edge weights to account for the earliest possible start of the successor task.
|
|
233
|
+
"""
|
|
234
|
+
self._reset_edges()
|
|
235
|
+
self._stretch_edges_with_successor_that_has_fixed_start()
|
|
236
|
+
|
|
237
|
+
def _get_label_text(self, task_node: TaskNode) -> str:
|
|
238
|
+
"""
|
|
239
|
+
returns the label text of this TaskNode in the dot representation based on the legacy visualization
|
|
240
|
+
:return: the full label, example:
|
|
241
|
+
EC2210|SAP PI Puffer stoppen (Kommunikation IS-U starten)|Tom Büsche - Dauer 15min|Start 10.10.2023 10:OO:OO"
|
|
242
|
+
"""
|
|
243
|
+
planned_start_str = datetime.strftime(
|
|
244
|
+
self.calculate_planned_starting_time_of_task(task_node.id), "%d.%m.%Y %H:%M:%S%Z"
|
|
245
|
+
)
|
|
246
|
+
assignee_name_or_placeholder: str
|
|
247
|
+
|
|
248
|
+
if task_node.assignee is None:
|
|
249
|
+
assignee_name_or_placeholder = "(nobody)"
|
|
250
|
+
else:
|
|
251
|
+
assignee_name_or_placeholder = task_node.assignee.name
|
|
252
|
+
parts: list[str] = [
|
|
253
|
+
task_node.external_id,
|
|
254
|
+
task_node.name,
|
|
255
|
+
f"{assignee_name_or_placeholder} - duration {task_node.planned_duration}min",
|
|
256
|
+
f"Start {planned_start_str}",
|
|
257
|
+
]
|
|
258
|
+
return "|".join(parts)
|
|
259
|
+
|
|
260
|
+
def labels(self) -> Mapping[TaskId, str]:
|
|
261
|
+
"""
|
|
262
|
+
returns a mapping of the individual task ids to their name
|
|
263
|
+
"""
|
|
264
|
+
result = {
|
|
265
|
+
self._graph.nodes[x]["domain_model"].id: self._get_label_text(self._graph.nodes[x]["domain_model"])
|
|
266
|
+
for x in self._graph.nodes
|
|
267
|
+
}
|
|
268
|
+
result.update({task_node_as_artificial_startnode.id: "START", task_node_as_artificial_endnode.id: "END"})
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
def get_digraph_copy(self) -> DiGraph:
|
|
272
|
+
"""
|
|
273
|
+
Returns a deep copy of the internal networkx DiGraph for external processing.
|
|
274
|
+
The returned graph will be de-coupled from the TaskDependencyGraph instance.
|
|
275
|
+
It may be used, e.g., to plot the graph with networkx directly (without going over the TaskDependencyGraph).
|
|
276
|
+
"""
|
|
277
|
+
return copy.deepcopy(self._graph.copy())
|
|
278
|
+
|
|
279
|
+
def is_on_critical_path(self, task_id: TaskId) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
With this method it can be checked if a task is on the overall critical path, i.e. on the longest path
|
|
282
|
+
between the first task and last task of this run.
|
|
283
|
+
"""
|
|
284
|
+
longest_path = nx.dag_longest_path(self._graph, weight="weight") # The weight of the edge is the duration
|
|
285
|
+
# of the task predecessor.
|
|
286
|
+
# The longest path is the path, the sum of whose task durations is the greatest.
|
|
287
|
+
# The longest_path is a list of their task ids as keys.
|
|
288
|
+
if task_id in longest_path:
|
|
289
|
+
return True
|
|
290
|
+
if (
|
|
291
|
+
task_id not in self._graph.nodes
|
|
292
|
+
and task_id != task_node_as_artificial_startnode.id
|
|
293
|
+
and task_id != task_node_as_artificial_endnode.id
|
|
294
|
+
):
|
|
295
|
+
raise ValueError(f"The task {task_id} is not part of the graph at all")
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
def can_task_be_added(self, task_node: TaskNode) -> AddNodeToGraphPreviewResponse:
|
|
299
|
+
"""
|
|
300
|
+
returns information on whether a task can be added to the graph
|
|
301
|
+
"""
|
|
302
|
+
if task_node.id in self._graph.nodes:
|
|
303
|
+
# probably this is covered by networkx itself, but I didn't check
|
|
304
|
+
return AddNodeToGraphPreviewResponse(
|
|
305
|
+
can_be_added=False, error_message=f"Node with id {task_node.id} already exists in the graph"
|
|
306
|
+
)
|
|
307
|
+
if any(t for t in self._graph.nodes.values() if t["domain_model"].external_id == task_node.external_id):
|
|
308
|
+
return AddNodeToGraphPreviewResponse(
|
|
309
|
+
can_be_added=False,
|
|
310
|
+
error_message=f"Node with external id {task_node.external_id} already exists in the graph",
|
|
311
|
+
)
|
|
312
|
+
return AddNodeToGraphPreviewResponse(can_be_added=True, error_message=None)
|
|
313
|
+
|
|
314
|
+
def add_task(self, task_node: TaskNode) -> None:
|
|
315
|
+
"""
|
|
316
|
+
Adds a node to the graph.
|
|
317
|
+
This is pretty straight forward and only fails if another node with the same (internal or external) id already
|
|
318
|
+
exists.
|
|
319
|
+
"""
|
|
320
|
+
check_result = self.can_task_be_added(task_node)
|
|
321
|
+
if not check_result.can_be_added:
|
|
322
|
+
raise ValueError(check_result.error_message)
|
|
323
|
+
self._remove_artificial_nodes_and_edges()
|
|
324
|
+
self._graph.add_node(task_node.id, domain_model=task_node)
|
|
325
|
+
self._add_artificial_nodes_and_edges()
|
|
326
|
+
self._account_for_earliest_start()
|
|
327
|
+
|
|
328
|
+
# pylint:disable=too-many-return-statements
|
|
329
|
+
def can_edge_be_added(self, task_dependency: TaskDependencyEdge) -> AddEdgeToGraphPreviewResponse:
|
|
330
|
+
"""
|
|
331
|
+
raises an error if the edge can't be added; Does nothing else
|
|
332
|
+
"""
|
|
333
|
+
if task_dependency.task_successor not in self._graph.nodes:
|
|
334
|
+
return AddEdgeToGraphPreviewResponse(
|
|
335
|
+
can_be_added=False,
|
|
336
|
+
error_message=f"Node with id {task_dependency.task_successor} (successor) does not exist in the graph",
|
|
337
|
+
)
|
|
338
|
+
if task_dependency.task_predecessor not in self._graph.nodes:
|
|
339
|
+
return AddEdgeToGraphPreviewResponse(
|
|
340
|
+
can_be_added=False,
|
|
341
|
+
# pylint:disable=line-too-long
|
|
342
|
+
error_message=f"Node with id {task_dependency.task_predecessor} (predecessor) does not exist in the graph",
|
|
343
|
+
)
|
|
344
|
+
if task_dependency.id in {self._graph.edges[x, y]["domain_model"].id for x, y in self._graph.edges}:
|
|
345
|
+
return AddEdgeToGraphPreviewResponse(
|
|
346
|
+
can_be_added=False, error_message=f"Edge with id {task_dependency.id} already exists in the graph"
|
|
347
|
+
)
|
|
348
|
+
if self._graph.has_edge(task_dependency.task_predecessor, task_dependency.task_successor):
|
|
349
|
+
conflict_edge = self._graph.edges[task_dependency.task_predecessor, task_dependency.task_successor][
|
|
350
|
+
"domain_model"
|
|
351
|
+
]
|
|
352
|
+
return AddEdgeToGraphPreviewResponse(
|
|
353
|
+
can_be_added=False,
|
|
354
|
+
# pylint:disable=line-too-long
|
|
355
|
+
error_message=f"Edge between {task_dependency.task_predecessor} and {task_dependency.task_successor} already exists: {conflict_edge}",
|
|
356
|
+
)
|
|
357
|
+
if self._graph.has_edge(task_dependency.task_successor, task_dependency.task_predecessor):
|
|
358
|
+
conflict_edge = self._graph.edges[task_dependency.task_successor, task_dependency.task_predecessor][
|
|
359
|
+
"domain_model"
|
|
360
|
+
]
|
|
361
|
+
return AddEdgeToGraphPreviewResponse(
|
|
362
|
+
can_be_added=False,
|
|
363
|
+
# pylint:disable=line-too-long
|
|
364
|
+
error_message=f"Opposite edge between {task_dependency.task_successor} and {task_dependency.task_predecessor} already exists: {conflict_edge}",
|
|
365
|
+
)
|
|
366
|
+
if nx.has_path(self._graph, task_dependency.task_successor, task_dependency.task_predecessor):
|
|
367
|
+
return AddEdgeToGraphPreviewResponse(
|
|
368
|
+
can_be_added=False,
|
|
369
|
+
# pylint:disable=line-too-long
|
|
370
|
+
error_message=f"Adding this edge would create a cycle between {task_dependency.task_predecessor} and {task_dependency.task_successor}",
|
|
371
|
+
)
|
|
372
|
+
return AddEdgeToGraphPreviewResponse(can_be_added=True, error_message=None)
|
|
373
|
+
|
|
374
|
+
def add_edge(self, task_dependency: TaskDependencyEdge) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Adds an edge to the graph.
|
|
377
|
+
This checks that the graph is still consistent after adding the edge.
|
|
378
|
+
"""
|
|
379
|
+
check_result = self.can_edge_be_added(task_dependency)
|
|
380
|
+
if not check_result.can_be_added:
|
|
381
|
+
raise ValueError(check_result.error_message)
|
|
382
|
+
self._remove_artificial_nodes_and_edges()
|
|
383
|
+
# there is lot's of stuff left todo: what if we want to add an edge without a successor or predecessor?
|
|
384
|
+
self._graph.add_edge(
|
|
385
|
+
task_dependency.task_predecessor,
|
|
386
|
+
task_dependency.task_successor,
|
|
387
|
+
weight=self._graph.nodes[task_dependency.task_predecessor]["domain_model"].planned_duration.total_seconds()
|
|
388
|
+
/ 60,
|
|
389
|
+
domain_model=task_dependency,
|
|
390
|
+
)
|
|
391
|
+
self._add_artificial_nodes_and_edges()
|
|
392
|
+
self._account_for_earliest_start()
|
|
393
|
+
|
|
394
|
+
def _get_duration_or_buffer_length(self, predecessor_id: TaskId, successor_id: TaskId) -> timedelta:
|
|
395
|
+
"""
|
|
396
|
+
Returns either the duration of the task or the difference to a successor with earliest_starttime set.
|
|
397
|
+
This is necessary to calculate start times of tasks where there is any task with the earliest starting time on
|
|
398
|
+
the path.
|
|
399
|
+
In case of the difference to a successor with earliest_starttime being larger than the duration of the task
|
|
400
|
+
itself, the start time of the successor task as well as all following tasks on this path is later than the
|
|
401
|
+
duration of previous tasks suggests (as the earliest starting time of the successor task makes every following
|
|
402
|
+
task on the path start later...).
|
|
403
|
+
To the outside caller, this shall be transparent.
|
|
404
|
+
"""
|
|
405
|
+
if not self._graph.has_edge(predecessor_id, successor_id):
|
|
406
|
+
raise ValueError(f"Edge between {predecessor_id} and {successor_id} does not exist")
|
|
407
|
+
predecessor_duration: timedelta = self._graph.nodes[predecessor_id]["domain_model"].planned_duration
|
|
408
|
+
successor_start: AwareDatetime | None = self._graph.nodes[successor_id]["domain_model"].earliest_starttime
|
|
409
|
+
if successor_start is None:
|
|
410
|
+
return predecessor_duration
|
|
411
|
+
pseudo_duration = successor_start - self.calculate_planned_starting_time_of_task(predecessor_id)
|
|
412
|
+
return max(pseudo_duration, predecessor_duration)
|
|
413
|
+
|
|
414
|
+
def calculate_planned_duration_of_predecessor_tasks_on_critical_path(self, task_id: TaskId) -> timedelta:
|
|
415
|
+
"""
|
|
416
|
+
With this method we can calculate the sum of the durations of those tasks, which are predecessors to the task
|
|
417
|
+
in question and moreover, are on the critical path (only those tasks do not necessarily need to be on the
|
|
418
|
+
critical path, which are on the last path towards the task in question, as the task in question might not be
|
|
419
|
+
on the overall critical path).
|
|
420
|
+
Thus, we can calculate how long it takes to get from the first task(s) to the task in question.
|
|
421
|
+
"""
|
|
422
|
+
# In the following, we will need this dictionary to get from the node id to the node and then to its
|
|
423
|
+
# planned duration.
|
|
424
|
+
# gets the last and thus the longest path in the list
|
|
425
|
+
if task_id not in self._graph.nodes():
|
|
426
|
+
# 1st case: invalid task id
|
|
427
|
+
raise ValueError("This task id is invalid.")
|
|
428
|
+
if task_id == task_node_as_artificial_endnode.id:
|
|
429
|
+
duration_of_tasks_on_overall_longest_path = dag_longest_path_length(self._graph, weight="weight")
|
|
430
|
+
return timedelta(minutes=duration_of_tasks_on_overall_longest_path)
|
|
431
|
+
generator_of_simple_paths_sorted_from_short_to_long = nx.shortest_simple_paths(
|
|
432
|
+
self._graph, task_node_as_artificial_startnode.id, task_id, weight="weight"
|
|
433
|
+
)
|
|
434
|
+
list_of_simple_paths_sorted_from_short_to_long: list[list[TaskId]] = list(
|
|
435
|
+
generator_of_simple_paths_sorted_from_short_to_long
|
|
436
|
+
)
|
|
437
|
+
longest_simple_path: list[TaskId] = list_of_simple_paths_sorted_from_short_to_long.pop()
|
|
438
|
+
result = sum(
|
|
439
|
+
(timedelta(minutes=self._graph.edges[edge]["weight"]) for edge in pairwise(longest_simple_path)),
|
|
440
|
+
timedelta(seconds=0),
|
|
441
|
+
)
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
def calculate_planned_starting_time_of_task(self, task_id: TaskId) -> AwareDatetime:
|
|
445
|
+
"""
|
|
446
|
+
With this method we can calculate the planned starting time of a task.
|
|
447
|
+
"""
|
|
448
|
+
planned_duration_of_predecessor_tasks_on_critical_path = (
|
|
449
|
+
self.calculate_planned_duration_of_predecessor_tasks_on_critical_path(task_id)
|
|
450
|
+
)
|
|
451
|
+
task_domain_model = self._graph.nodes[task_id]["domain_model"]
|
|
452
|
+
own_earliest_start: AwareDatetime | None = task_domain_model.earliest_starttime
|
|
453
|
+
starting_time_of_task = self._starting_time_of_run + planned_duration_of_predecessor_tasks_on_critical_path
|
|
454
|
+
if own_earliest_start is None:
|
|
455
|
+
return starting_time_of_task
|
|
456
|
+
return max(starting_time_of_task, own_earliest_start)
|
|
457
|
+
|
|
458
|
+
def create_list_of_task_node_copies_with_planned_starting_time(self) -> list[TaskNode]:
|
|
459
|
+
"""
|
|
460
|
+
Returns a new task_list, in which tasks are sorted by their planned_starting_time.
|
|
461
|
+
Note that, as in the task_list, artificial_startnode and -endnode are not included in the new_task_list.
|
|
462
|
+
Info: Maybe we don't need this method anymore.
|
|
463
|
+
"""
|
|
464
|
+
new_task_list = []
|
|
465
|
+
|
|
466
|
+
for task_id in self._graph.nodes:
|
|
467
|
+
if task_id in {task_node_as_artificial_startnode.id, task_node_as_artificial_endnode.id}:
|
|
468
|
+
continue
|
|
469
|
+
task = self._graph.nodes[task_id]["domain_model"]
|
|
470
|
+
copied_task = task.model_copy(
|
|
471
|
+
update={"planned_starting_time": self.calculate_planned_starting_time_of_task(task.id)}
|
|
472
|
+
)
|
|
473
|
+
new_task_list.append(copied_task)
|
|
474
|
+
assert all(x.planned_starting_time is not None for x in new_task_list), "The starting time should not be None"
|
|
475
|
+
new_task_list.sort(key=lambda t: t.planned_starting_time)
|
|
476
|
+
return new_task_list
|
|
477
|
+
|
|
478
|
+
def extract_sub_graph(self, sub_start: TaskId, sub_end: TaskId) -> "TaskDependencyGraph":
|
|
479
|
+
"""
|
|
480
|
+
Creates a new TaskDependencyGraph instance that only contains nodes between sub_start and sub_end
|
|
481
|
+
(both inclusive).
|
|
482
|
+
Raises a meaningful error if start node or end node is not a milestone.
|
|
483
|
+
"""
|
|
484
|
+
if sub_start not in self._graph.nodes:
|
|
485
|
+
raise ValueError(f"Node with id {sub_start} (start) does not exist in the graph")
|
|
486
|
+
if sub_end not in self._graph.nodes:
|
|
487
|
+
raise ValueError(f"Node with id {sub_end} (end) does not exist in the graph")
|
|
488
|
+
if not self._graph.nodes[sub_start]["domain_model"].is_milestone:
|
|
489
|
+
raise ValueError(f"Node with id {sub_start} (start) is not a milestone")
|
|
490
|
+
if not self._graph.nodes[sub_end]["domain_model"].is_milestone:
|
|
491
|
+
raise ValueError(f"Node with id {sub_end} end is not a milestone")
|
|
492
|
+
if not nx.has_path(self._graph, sub_start, sub_end):
|
|
493
|
+
raise ValueError(f"There is no path between {sub_start} and {sub_end}")
|
|
494
|
+
|
|
495
|
+
# Collect all paths between sub_start and sub_end
|
|
496
|
+
all_paths = list(nx.all_simple_paths(self._graph, source=sub_start, target=sub_end))
|
|
497
|
+
|
|
498
|
+
# Extract all nodes and edges in these paths
|
|
499
|
+
nodes_in_paths: set[TaskId] = set()
|
|
500
|
+
edges_in_paths: set[tuple[TaskId, TaskId]] = set()
|
|
501
|
+
for path in all_paths:
|
|
502
|
+
nodes_in_paths.update(path)
|
|
503
|
+
edges_in_paths.update((path[i], path[i + 1]) for i in range(len(path) - 1))
|
|
504
|
+
|
|
505
|
+
# Create the subgraph from these nodes and edges
|
|
506
|
+
sub_graph = self._graph.subgraph(nodes_in_paths).edge_subgraph(edges_in_paths).copy()
|
|
507
|
+
|
|
508
|
+
result = TaskDependencyGraph(
|
|
509
|
+
task_list=[sub_graph.nodes[x]["domain_model"] for x in sub_graph.nodes],
|
|
510
|
+
dependency_list=[sub_graph.edges[x, y]["domain_model"] for x, y in sub_graph.edges],
|
|
511
|
+
starting_time_of_run=self.calculate_planned_starting_time_of_task(sub_start),
|
|
512
|
+
)
|
|
513
|
+
# I'm not 100% sure we need this.
|
|
514
|
+
# My intention was to de-couple the new graph as much from the original graph as possible.
|
|
515
|
+
# If they still shared the same (identical, not only equal) nodes, then they might interfere in some scenarios.
|
|
516
|
+
return copy.deepcopy(result)
|
|
517
|
+
|
|
518
|
+
def _get_task_dot(self, task_id: TaskId) -> str:
|
|
519
|
+
"""
|
|
520
|
+
Returns the dot-representation of a single task; This will be basically the dot-representation of the task node
|
|
521
|
+
itself + the properties/attributes that can only be calculated from the TaskDependencyGraph in which the task
|
|
522
|
+
is embedded.
|
|
523
|
+
For details on the dot language see https://graphviz.org/doc/info/lang.html
|
|
524
|
+
"""
|
|
525
|
+
node: TaskNode = self._graph.nodes[task_id]["domain_model"]
|
|
526
|
+
node_attributes: dict[Literal["label", "color"], str] = {
|
|
527
|
+
"label": self._get_label_text(node),
|
|
528
|
+
}
|
|
529
|
+
if self.is_on_critical_path(task_id=task_id):
|
|
530
|
+
node_attributes["color"] = "red"
|
|
531
|
+
result = node.to_dot(node_attributes)
|
|
532
|
+
return result
|
|
533
|
+
|
|
534
|
+
def _get_task_mermaid_gantt(self, task_id: TaskId) -> str:
|
|
535
|
+
"""
|
|
536
|
+
Returns the mermaid-gantt-representation of a single task within the TDG.
|
|
537
|
+
"""
|
|
538
|
+
# In the end we have to obey this syntax: https://mermaid.js.org/syntax/gantt.html#syntax
|
|
539
|
+
node: TaskNode = self._graph.nodes[task_id]["domain_model"]
|
|
540
|
+
attributes: list[str] = []
|
|
541
|
+
# "Tags are optional, but if used, they must be specified first"
|
|
542
|
+
if self.is_on_critical_path(task_id=task_id):
|
|
543
|
+
attributes.append("crit")
|
|
544
|
+
if node.is_milestone or task_id in {task_node_as_artificial_startnode.id, task_node_as_artificial_endnode.id}:
|
|
545
|
+
attributes.append("milestone")
|
|
546
|
+
attributes.append(str(task_id))
|
|
547
|
+
if task_id == task_node_as_artificial_startnode.id:
|
|
548
|
+
# todo: also use this if-branch if node has has frühstmöglicher startzeitpunkt
|
|
549
|
+
# https://github.com/Hochfrequenz/cutover-tool/issues/377
|
|
550
|
+
# <taskID>, <startDate>, <length>
|
|
551
|
+
attributes.append(self._starting_time_of_run.isoformat())
|
|
552
|
+
else:
|
|
553
|
+
# <taskID>, after <otherTaskId>, <length>
|
|
554
|
+
attributes.append("after " + " ".join(str(x) for x in DiGraph.predecessors(self._graph, task_id)))
|
|
555
|
+
attributes.append(f"{int(node.planned_duration.total_seconds() // 60)}m")
|
|
556
|
+
result = f"""
|
|
557
|
+
{node.name} :{', '.join(attributes)}
|
|
558
|
+
"""
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
def to_dot(self) -> str:
|
|
562
|
+
"""
|
|
563
|
+
returns a dot representation of the graph.
|
|
564
|
+
For details on the dot language see https://graphviz.org/doc/info/lang.html
|
|
565
|
+
The style information (font, colors, labels ...) have been adapted from /legacy/legacy_visualization_example.dot
|
|
566
|
+
"""
|
|
567
|
+
result: str = "digraph fahrplan{\nrankdir = LR;\nnode [shape=record fontname=Calibri];\n"
|
|
568
|
+
result += "".join(self._get_task_dot(tid) for tid in self._graph.nodes().keys())
|
|
569
|
+
result += "".join(
|
|
570
|
+
self._graph[successor][predecessor]["domain_model"].to_dot()
|
|
571
|
+
for successor, predecessor in self._graph.edges()
|
|
572
|
+
)
|
|
573
|
+
result += "}"
|
|
574
|
+
# for debugging purposes you might copy the result from your IDE/Debugger and paste it here:
|
|
575
|
+
# https://kroki.io/#try (select 'GraphViz' in the dropdown)
|
|
576
|
+
return result
|
|
577
|
+
|
|
578
|
+
def to_mermaid_gantt(self) -> str:
|
|
579
|
+
"""
|
|
580
|
+
Returns the mermaid-gantt-representation of the entire tdg
|
|
581
|
+
"""
|
|
582
|
+
result: str = """gantt
|
|
583
|
+
title A Gantt Diagram
|
|
584
|
+
dateFormat YYYY-MM-DDTHH:mm:SZ
|
|
585
|
+
axisFormat %d.%m %H:%M
|
|
586
|
+
tickInterval 15minute
|
|
587
|
+
section Example Stream
|
|
588
|
+
"""
|
|
589
|
+
# todo: group by stream https://github.com/Hochfrequenz/cutover-tool/issues/374
|
|
590
|
+
result += "".join(self._get_task_mermaid_gantt(tid) for tid in self._graph.nodes().keys())
|
|
591
|
+
return result
|