torch-flowgraph 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.
- torch_flowgraph/__init__.py +50 -0
- torch_flowgraph/__main__.py +8 -0
- torch_flowgraph/_post_import.py +78 -0
- torch_flowgraph/_version.py +24 -0
- torch_flowgraph/attributes.py +262 -0
- torch_flowgraph/cli.py +44 -0
- torch_flowgraph/clustering.py +282 -0
- torch_flowgraph/clusters.py +209 -0
- torch_flowgraph/dot.py +83 -0
- torch_flowgraph/graph.py +1090 -0
- torch_flowgraph/model/__init__.py +18 -0
- torch_flowgraph/model/graph.py +119 -0
- torch_flowgraph/model/pytorch.py +1058 -0
- torch_flowgraph/nodes.py +664 -0
- torch_flowgraph/parsing.py +1474 -0
- torch_flowgraph/patches.py +539 -0
- torch_flowgraph/plotting.py +368 -0
- torch_flowgraph/validation.py +135 -0
- torch_flowgraph-0.1.0.dist-info/METADATA +105 -0
- torch_flowgraph-0.1.0.dist-info/RECORD +25 -0
- torch_flowgraph-0.1.0.dist-info/WHEEL +5 -0
- torch_flowgraph-0.1.0.dist-info/entry_points.txt +2 -0
- torch_flowgraph-0.1.0.dist-info/licenses/LICENSE +79 -0
- torch_flowgraph-0.1.0.dist-info/licenses/NOTICE +8 -0
- torch_flowgraph-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 European Centre for Medium-Range Weather Forecasts (ECMWF)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Clustering strategies for computation graphs.
|
|
5
|
+
|
|
6
|
+
This module provides:
|
|
7
|
+
|
|
8
|
+
* :class:`Clustering` — abstract base class for all clustering strategies.
|
|
9
|
+
* :class:`ClanClustering` — re-clusters using the McCreary clan algorithm.
|
|
10
|
+
* :class:`LevelClustering` — shows only cluster boxes at a specific depth.
|
|
11
|
+
* :class:`NameClustering` — shows only cluster boxes with specific names.
|
|
12
|
+
|
|
13
|
+
All concrete classes implement :meth:`~Clustering.build_clusters`, which
|
|
14
|
+
receives a :class:`~torch_flowgraph.graph.GraphBase` and returns a new
|
|
15
|
+
:class:`~torch_flowgraph.clusters.Cluster` root. Use
|
|
16
|
+
:meth:`~torch_flowgraph.graph.GraphBase.recluster` to apply a strategy::
|
|
17
|
+
|
|
18
|
+
from torch_flowgraph.clustering import LevelClustering
|
|
19
|
+
shallow = graph.recluster(LevelClustering(level=1))
|
|
20
|
+
|
|
21
|
+
Backward-compatibility shim
|
|
22
|
+
----------------------------
|
|
23
|
+
:func:`apply_clan_clustering` is kept as a convenience wrapper so that
|
|
24
|
+
existing code does not break::
|
|
25
|
+
|
|
26
|
+
from torch_flowgraph import apply_clan_clustering
|
|
27
|
+
clan_graph = apply_clan_clustering(graph)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from abc import ABC
|
|
33
|
+
from abc import abstractmethod
|
|
34
|
+
|
|
35
|
+
from .clusters import Cluster
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Clustering(ABC):
|
|
39
|
+
"""Abstract base class for graph reclustering strategies.
|
|
40
|
+
|
|
41
|
+
Subclasses implement :meth:`build_clusters`, which returns a new
|
|
42
|
+
:class:`~torch_flowgraph.clusters.Cluster` root built from *graph*.
|
|
43
|
+
The graph's nodes and edges are *not* modified; only the cluster
|
|
44
|
+
hierarchy changes.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def build_clusters(self, graph) -> Cluster:
|
|
49
|
+
"""Build and return a new cluster root for *graph*.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
graph:
|
|
54
|
+
Source :class:`~torch_flowgraph.graph.GraphBase`.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
Cluster
|
|
59
|
+
Root of the new cluster hierarchy. The returned tree must
|
|
60
|
+
reference only node-ids that exist in *graph*.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Clan clustering
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ClanClustering(Clustering):
|
|
70
|
+
"""Re-cluster using the McCreary clan-based graph-parsing algorithm.
|
|
71
|
+
|
|
72
|
+
The resulting cluster hierarchy reflects the *dataflow* decomposition
|
|
73
|
+
of the graph (linear chains, independent sub-graphs, primitives) rather
|
|
74
|
+
than the original ``nn.Module`` code hierarchy.
|
|
75
|
+
|
|
76
|
+
Clan kinds assigned to clusters: ``LINEAR``, ``INDEPENDENT``,
|
|
77
|
+
``PRIMITIVE``.
|
|
78
|
+
|
|
79
|
+
Trivial clusters are dissolved after parsing: single-node clusters
|
|
80
|
+
have their node promoted to the parent, and single-child wrapper
|
|
81
|
+
clusters (with no own nodes) are replaced by their only child.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def build_clusters(self, graph) -> Cluster:
|
|
85
|
+
from .parsing import Parser
|
|
86
|
+
|
|
87
|
+
G = graph.to_op_networkx()
|
|
88
|
+
parser = Parser(G)
|
|
89
|
+
parse_tree = parser.parse()
|
|
90
|
+
root = _parse_tree_to_cluster(parse_tree, parent=None)
|
|
91
|
+
root.update_cluster_nodes()
|
|
92
|
+
_dissolve_leaf_clusters(root)
|
|
93
|
+
return root
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_tree_to_cluster(node, parent) -> Cluster:
|
|
97
|
+
"""Convert a :class:`~torch_flowgraph.parsing.ParseTree` node to a Cluster."""
|
|
98
|
+
from .parsing import Kind
|
|
99
|
+
|
|
100
|
+
kind_name = node.clan.kind.name if node.clan.kind != Kind.UNDEF else "UNKNOWN"
|
|
101
|
+
cluster = Cluster(name=kind_name, parent=parent, nodes=list(node.clan.nodes))
|
|
102
|
+
cluster.children = [_parse_tree_to_cluster(child, cluster) for child in node.children]
|
|
103
|
+
return cluster
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _dissolve_leaf_clusters(cluster):
|
|
107
|
+
"""Remove trivial clusters, attaching their contents to the parent.
|
|
108
|
+
|
|
109
|
+
A cluster is trivial when it contains only a single node (no
|
|
110
|
+
sub-clusters) or only a single sub-cluster (no direct nodes).
|
|
111
|
+
Runs repeatedly until no more trivial clusters remain.
|
|
112
|
+
"""
|
|
113
|
+
changed = True
|
|
114
|
+
while changed:
|
|
115
|
+
changed = False
|
|
116
|
+
kept = []
|
|
117
|
+
for child in cluster.children:
|
|
118
|
+
_dissolve_leaf_clusters(child)
|
|
119
|
+
if not child.children and len(child.nodes) <= 1:
|
|
120
|
+
# Single-node (or empty) cluster — promote nodes to parent.
|
|
121
|
+
cluster.nodes.extend(child.nodes)
|
|
122
|
+
changed = True
|
|
123
|
+
elif len(child.children) == 1 and not child.nodes:
|
|
124
|
+
# Single-child wrapper with no own nodes — replace with the child.
|
|
125
|
+
grandchild = child.children[0]
|
|
126
|
+
grandchild.parent = cluster
|
|
127
|
+
kept.append(grandchild)
|
|
128
|
+
changed = True
|
|
129
|
+
else:
|
|
130
|
+
kept.append(child)
|
|
131
|
+
cluster.children = kept
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Selective clustering (level / name filters)
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _SelectiveClustering(Clustering):
|
|
140
|
+
"""Base for strategies that select a subset of existing cluster boxes.
|
|
141
|
+
|
|
142
|
+
Matching clusters are kept as labeled boxes. All other clusters are
|
|
143
|
+
dropped: their nodes are absorbed into the flat root so the complete
|
|
144
|
+
node set is still rendered — just without grouping boxes.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def _find_matching(self, root_cluster: Cluster) -> list[Cluster]:
|
|
149
|
+
"""Return clusters from *root_cluster*'s tree that should be drawn."""
|
|
150
|
+
|
|
151
|
+
def build_clusters(self, graph) -> Cluster:
|
|
152
|
+
matching = self._find_matching(graph.clusters)
|
|
153
|
+
|
|
154
|
+
# Collect all node-ids claimed by matching cluster subtrees.
|
|
155
|
+
matching_nodes: set[int] = set()
|
|
156
|
+
for c in matching:
|
|
157
|
+
matching_nodes.update(c.all_nodes)
|
|
158
|
+
|
|
159
|
+
# Root gets every node *not* in a matching subtree (shown ungrouped).
|
|
160
|
+
root = Cluster(name=None)
|
|
161
|
+
root.nodes = [n for n in graph.nodes if n not in matching_nodes]
|
|
162
|
+
root.children = []
|
|
163
|
+
|
|
164
|
+
for c in matching:
|
|
165
|
+
child = Cluster(name=c.name, nodes=list(c.all_nodes), parent=root)
|
|
166
|
+
root.children.append(child)
|
|
167
|
+
|
|
168
|
+
return root
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LevelClustering(_SelectiveClustering):
|
|
172
|
+
"""Show only cluster boxes at one or more specific depths.
|
|
173
|
+
|
|
174
|
+
All nodes remain in the graph; those not inside a matching cluster
|
|
175
|
+
are rendered without a surrounding box.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
level : int or collection of int
|
|
180
|
+
Target cluster level(s), 0-indexed from the outermost real cluster.
|
|
181
|
+
``0`` means the top-level cluster boxes (the root's direct children),
|
|
182
|
+
``1`` their children, etc. The nameless structural root is not a
|
|
183
|
+
selectable level. Pass a single ``int`` or any iterable of ``int``
|
|
184
|
+
values to select multiple levels simultaneously.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, level) -> None:
|
|
188
|
+
if isinstance(level, int):
|
|
189
|
+
self.levels: frozenset[int] = frozenset([level])
|
|
190
|
+
else:
|
|
191
|
+
self.levels = frozenset(level)
|
|
192
|
+
|
|
193
|
+
def _find_matching(self, root_cluster: Cluster) -> list[Cluster]:
|
|
194
|
+
matching: list[Cluster] = []
|
|
195
|
+
# User-facing level L corresponds to internal cluster depth L + 1,
|
|
196
|
+
# since the root (depth 0) is a nameless structural container.
|
|
197
|
+
target_depths = frozenset(level + 1 for level in self.levels)
|
|
198
|
+
max_depth = max(target_depths) if target_depths else 0
|
|
199
|
+
|
|
200
|
+
def _walk(c: Cluster) -> None:
|
|
201
|
+
if c.depth in target_depths:
|
|
202
|
+
matching.append(c)
|
|
203
|
+
# Don't recurse: the whole subtree is kept in this box.
|
|
204
|
+
elif c.depth < max_depth:
|
|
205
|
+
for child in c.children:
|
|
206
|
+
_walk(child)
|
|
207
|
+
|
|
208
|
+
_walk(root_cluster)
|
|
209
|
+
return matching
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class NameClustering(_SelectiveClustering):
|
|
213
|
+
"""Show only cluster boxes whose name is in *names*.
|
|
214
|
+
|
|
215
|
+
All nodes remain in the graph; nodes not inside a named cluster are
|
|
216
|
+
rendered without a surrounding box.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
names : collection of str
|
|
221
|
+
Cluster names to keep. When a matching cluster is found, its
|
|
222
|
+
children are not inspected (the whole subtree is included in the box).
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self, names) -> None:
|
|
226
|
+
self.names: frozenset[str] = frozenset(names)
|
|
227
|
+
|
|
228
|
+
def _find_matching(self, root_cluster: Cluster) -> list[Cluster]:
|
|
229
|
+
matching: list[Cluster] = []
|
|
230
|
+
|
|
231
|
+
def _walk(c: Cluster) -> None:
|
|
232
|
+
if c.name in self.names:
|
|
233
|
+
matching.append(c)
|
|
234
|
+
else:
|
|
235
|
+
for child in c.children:
|
|
236
|
+
_walk(child)
|
|
237
|
+
|
|
238
|
+
_walk(root_cluster)
|
|
239
|
+
return matching
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# No-op clustering — clears all cluster boxes
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class NoClustering(Clustering):
|
|
248
|
+
"""Remove all cluster boxes, rendering every node at the top level.
|
|
249
|
+
|
|
250
|
+
The returned cluster hierarchy is a single flat root containing all
|
|
251
|
+
nodes and no children. The graph itself is unchanged; only the cluster
|
|
252
|
+
boxes disappear from the rendered output.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def build_clusters(self, graph) -> Cluster:
|
|
256
|
+
root = Cluster(name=None)
|
|
257
|
+
root.nodes = list(graph.nodes)
|
|
258
|
+
return root
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# Backward-compatibility shim
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def apply_clan_clustering(graph):
|
|
267
|
+
"""Re-cluster *graph* using the McCreary clan algorithm.
|
|
268
|
+
|
|
269
|
+
Convenience wrapper around ``graph.recluster(ClanClustering())``.
|
|
270
|
+
Kept for backward compatibility with existing code.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
graph:
|
|
275
|
+
Source :class:`~torch_flowgraph.graph.GraphBase`.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
GraphBase
|
|
280
|
+
New graph with clan-derived cluster hierarchy.
|
|
281
|
+
"""
|
|
282
|
+
return graph.recluster(ClanClustering())
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 European Centre for Medium-Range Weather Forecasts (ECMWF)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Cluster data structure for hierarchical graph grouping.
|
|
5
|
+
|
|
6
|
+
A :class:`Cluster` represents a named group of graph nodes, arranged in a
|
|
7
|
+
tree via parent/children references. Clusters are used to generate Graphviz
|
|
8
|
+
``subgraph cluster_*`` blocks and to control the collapsing/expansion of
|
|
9
|
+
module hierarchies during rendering.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from functools import cached_property
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Cluster:
|
|
16
|
+
"""A named, hierarchical group of graph nodes.
|
|
17
|
+
|
|
18
|
+
Clusters form a tree: each cluster has a *parent* and zero or more
|
|
19
|
+
*children* (sub-clusters). The *nodes* list holds the integer node-ids
|
|
20
|
+
that belong directly to this cluster (not to its children).
|
|
21
|
+
|
|
22
|
+
Attributes
|
|
23
|
+
----------
|
|
24
|
+
name : str or None
|
|
25
|
+
Human-readable cluster name shown in Graphviz DOT output as the
|
|
26
|
+
``label`` attribute.
|
|
27
|
+
nodes : list[int]
|
|
28
|
+
Integer node-ids that are direct members of this cluster.
|
|
29
|
+
children : list[Cluster]
|
|
30
|
+
Sub-clusters nested inside this cluster.
|
|
31
|
+
parent : Cluster or None
|
|
32
|
+
Parent cluster, or ``None`` for the root.
|
|
33
|
+
depth : int
|
|
34
|
+
Depth of this cluster in the tree (set by :class:`ModelGraph`
|
|
35
|
+
during graph finalisation; ``-1`` until then).
|
|
36
|
+
visible : bool or None
|
|
37
|
+
Explicit visibility override. ``None`` means visibility is
|
|
38
|
+
determined automatically by :meth:`show_cluster`.
|
|
39
|
+
kwargs : dict
|
|
40
|
+
Additional Graphviz DOT attributes passed through to
|
|
41
|
+
:attr:`dot_attributes`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, name=None, parent=None, visible=None, nodes=None, children=None, **kwargs):
|
|
45
|
+
"""Initialise a Cluster.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
name:
|
|
50
|
+
Display label for the cluster in DOT output.
|
|
51
|
+
parent:
|
|
52
|
+
Parent :class:`Cluster`, or ``None`` for the root.
|
|
53
|
+
visible:
|
|
54
|
+
Explicit visibility flag. When ``None`` the cluster is always
|
|
55
|
+
shown (see :meth:`show_cluster`).
|
|
56
|
+
nodes:
|
|
57
|
+
Initial list of direct member node-ids. Defaults to ``[]``.
|
|
58
|
+
children:
|
|
59
|
+
Initial list of child :class:`Cluster` instances. Defaults to
|
|
60
|
+
``[]``.
|
|
61
|
+
**kwargs:
|
|
62
|
+
Additional Graphviz DOT attributes (e.g. ``bgcolor``,
|
|
63
|
+
``pencolor``).
|
|
64
|
+
"""
|
|
65
|
+
self.name = name
|
|
66
|
+
self.nodes = [] if nodes is None else nodes
|
|
67
|
+
self.children = [] if children is None else children
|
|
68
|
+
self.parent = parent
|
|
69
|
+
self.depth = -1
|
|
70
|
+
self.visible = visible
|
|
71
|
+
self.is_repeated = False
|
|
72
|
+
self.kwargs = kwargs
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def dot_attributes(self):
|
|
76
|
+
"""Graphviz DOT attribute dictionary for this cluster's subgraph block.
|
|
77
|
+
|
|
78
|
+
Starts from any extra keyword arguments supplied at construction time
|
|
79
|
+
and adds a ``"label"`` key when :attr:`name` is set.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
dict
|
|
84
|
+
Mapping of DOT attribute name to value.
|
|
85
|
+
"""
|
|
86
|
+
attr = self.kwargs.copy()
|
|
87
|
+
if self.name is not None:
|
|
88
|
+
attr["label"] = self.name
|
|
89
|
+
return attr
|
|
90
|
+
|
|
91
|
+
def __len__(self):
|
|
92
|
+
"""Return the number of direct member nodes."""
|
|
93
|
+
return len(self.nodes)
|
|
94
|
+
|
|
95
|
+
def __repr__(self):
|
|
96
|
+
return f"Cluster({self.name})"
|
|
97
|
+
|
|
98
|
+
def as_dict(self):
|
|
99
|
+
"""Serialise the cluster tree to a JSON-compatible dictionary.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
dict
|
|
104
|
+
Dictionary with ``"name"``, ``"nodes"``, and ``"children"``
|
|
105
|
+
keys. Children are recursively serialised.
|
|
106
|
+
"""
|
|
107
|
+
return {
|
|
108
|
+
"name": self.name,
|
|
109
|
+
"nodes": self.nodes,
|
|
110
|
+
"children": [c.as_dict() for c in self.children],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, data, parent):
|
|
115
|
+
"""Deserialise a cluster tree from a dictionary.
|
|
116
|
+
|
|
117
|
+
Handles both the current ``"children"`` key and the legacy
|
|
118
|
+
``"clusters"`` key for backwards compatibility.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
data:
|
|
123
|
+
Dictionary as produced by :meth:`as_dict`.
|
|
124
|
+
parent:
|
|
125
|
+
Parent :class:`Cluster` to assign to the newly created cluster
|
|
126
|
+
(``None`` for the root).
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
Cluster
|
|
131
|
+
Fully reconstructed cluster tree.
|
|
132
|
+
"""
|
|
133
|
+
data = data.copy()
|
|
134
|
+
children = data.pop("children", data.get("clusters", []))
|
|
135
|
+
cluster = cls(parent=parent, **data)
|
|
136
|
+
cluster.children = [cls.from_dict(c, cluster) for c in children]
|
|
137
|
+
return cluster
|
|
138
|
+
|
|
139
|
+
@cached_property
|
|
140
|
+
def all_nodes(self):
|
|
141
|
+
"""Set of all node-ids contained anywhere in this cluster subtree.
|
|
142
|
+
|
|
143
|
+
Computed once and cached. Includes nodes from this cluster and all
|
|
144
|
+
descendant clusters.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
set[int]
|
|
149
|
+
"""
|
|
150
|
+
result = set()
|
|
151
|
+
|
|
152
|
+
def _all_nodes(c):
|
|
153
|
+
for n in c.nodes:
|
|
154
|
+
result.add(n)
|
|
155
|
+
for c in c.children:
|
|
156
|
+
_all_nodes(c)
|
|
157
|
+
|
|
158
|
+
_all_nodes(self)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
def show_cluster(self, depth):
|
|
162
|
+
"""Determine whether this cluster should be rendered as a subgraph.
|
|
163
|
+
|
|
164
|
+
Returns :attr:`visible` when it is explicitly set, otherwise always
|
|
165
|
+
returns ``True``.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
depth:
|
|
170
|
+
Current rendering depth (not used in the default implementation
|
|
171
|
+
but available for subclasses).
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
bool
|
|
176
|
+
"""
|
|
177
|
+
if self.visible is not None:
|
|
178
|
+
return self.visible
|
|
179
|
+
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
def update_cluster_nodes(self):
|
|
183
|
+
"""Redistribute nodes so each node appears in exactly one cluster.
|
|
184
|
+
|
|
185
|
+
Starting from the full set of nodes in this subtree (:attr:`all_nodes`),
|
|
186
|
+
recursively assigns each node to the deepest cluster that contains it,
|
|
187
|
+
removing it from all ancestor clusters' :attr:`nodes` lists.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
Cluster
|
|
192
|
+
*self*, mutated in-place.
|
|
193
|
+
"""
|
|
194
|
+
nodes = set(self.all_nodes)
|
|
195
|
+
|
|
196
|
+
def _update_cluster_nodes(c):
|
|
197
|
+
nonlocal nodes
|
|
198
|
+
|
|
199
|
+
for c in c.children:
|
|
200
|
+
_update_cluster_nodes(c)
|
|
201
|
+
|
|
202
|
+
c.nodes = list(set(c.nodes).intersection(nodes))
|
|
203
|
+
nodes = nodes.difference(c.nodes)
|
|
204
|
+
|
|
205
|
+
return c
|
|
206
|
+
|
|
207
|
+
_update_cluster_nodes(self)
|
|
208
|
+
|
|
209
|
+
return self
|
torch_flowgraph/dot.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 European Centre for Medium-Range Weather Forecasts (ECMWF)
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""High-level DOT export helper for :class:`ModelGraph`.
|
|
5
|
+
|
|
6
|
+
This module provides :func:`save_dot`, a convenience wrapper around
|
|
7
|
+
:func:`~torch_flowgraph.plotting.plot_graph` that accepts a
|
|
8
|
+
:class:`~torch_flowgraph.model.graph.ModelGraph` and writes a Graphviz DOT
|
|
9
|
+
file suitable for rendering with ``dot -Tpdf``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from torch_flowgraph.clusters import Cluster
|
|
13
|
+
from torch_flowgraph.plotting import plot_graph
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def save_dot(
|
|
17
|
+
graph,
|
|
18
|
+
path,
|
|
19
|
+
graph_attributes=None,
|
|
20
|
+
clusters=None,
|
|
21
|
+
depth=None,
|
|
22
|
+
links=False,
|
|
23
|
+
show_clusters=True,
|
|
24
|
+
highlight_fn=None,
|
|
25
|
+
cluster_bg=True,
|
|
26
|
+
):
|
|
27
|
+
"""Write a :class:`ModelGraph` to a Graphviz DOT file.
|
|
28
|
+
|
|
29
|
+
Optionally replaces the graph's cluster hierarchy with *clusters* and/or
|
|
30
|
+
collapses the graph to *depth* levels before rendering.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
graph:
|
|
35
|
+
:class:`~torch_flowgraph.model.graph.ModelGraph` instance to export.
|
|
36
|
+
path:
|
|
37
|
+
Destination file path for the ``.dot`` output.
|
|
38
|
+
graph_attributes:
|
|
39
|
+
Optional dictionary of top-level Graphviz graph attributes (e.g.
|
|
40
|
+
``{"rankdir": "LR"}``).
|
|
41
|
+
clusters:
|
|
42
|
+
Controls subgraph box rendering:
|
|
43
|
+
|
|
44
|
+
* ``None`` (default) — use the graph's own cluster hierarchy.
|
|
45
|
+
* A :class:`~torch_flowgraph.clusters.Cluster` instance — re-cluster
|
|
46
|
+
the graph with this hierarchy before rendering.
|
|
47
|
+
* ``[]`` (empty list) — suppress all subgraph boxes.
|
|
48
|
+
depth:
|
|
49
|
+
Maximum cluster depth to render. When given, the graph is collapsed
|
|
50
|
+
to *depth* levels via
|
|
51
|
+
:meth:`~torch_flowgraph.model.graph.ModelGraph.collapsed`.
|
|
52
|
+
links:
|
|
53
|
+
When ``True``, function nodes include ``URL`` attributes linking to
|
|
54
|
+
the PyTorch documentation. Defaults to ``False``.
|
|
55
|
+
show_clusters:
|
|
56
|
+
When ``False``, all cluster subgraph blocks are rendered as invisible
|
|
57
|
+
so they guide the layout engine without drawing visible boxes.
|
|
58
|
+
Forwarded to :func:`~torch_flowgraph.plotting.plot_graph`.
|
|
59
|
+
Defaults to ``True``.
|
|
60
|
+
cluster_bg:
|
|
61
|
+
When ``False``, cluster boxes are drawn without a background fill.
|
|
62
|
+
Forwarded to :func:`~torch_flowgraph.plotting.plot_graph`.
|
|
63
|
+
Defaults to ``True``.
|
|
64
|
+
"""
|
|
65
|
+
# Re-cluster only when a real Cluster object is provided.
|
|
66
|
+
if isinstance(clusters, Cluster) and clusters is not graph.clusters:
|
|
67
|
+
graph = graph.use_clusters(clusters)
|
|
68
|
+
|
|
69
|
+
graph = graph.collapsed(depth) if depth is not None else graph
|
|
70
|
+
|
|
71
|
+
G = graph.to_op_networkx(links=links)
|
|
72
|
+
# None → show own clusters (skip root wrapper, use children directly);
|
|
73
|
+
# [] → no boxes; Cluster already applied above.
|
|
74
|
+
display_clusters = graph.clusters.children if clusters is None else clusters
|
|
75
|
+
plot_graph(
|
|
76
|
+
path,
|
|
77
|
+
G,
|
|
78
|
+
display_clusters,
|
|
79
|
+
graph_attributes=graph_attributes,
|
|
80
|
+
cluster_bg=cluster_bg,
|
|
81
|
+
show_clusters=show_clusters,
|
|
82
|
+
highlight_fn=highlight_fn,
|
|
83
|
+
)
|