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.
@@ -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