/
graph_utils.py
304 lines (251 loc) · 9.65 KB
/
graph_utils.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
"""
Various graph related utilities.
"""
import networkx as nx
from openmdao.utils.file_utils import _load_and_exec
import openmdao.utils.hooks as hooks
try:
import pydot
except ImportError:
pydot = None
def get_sccs_topo(graph):
"""
Return strongly connected subsystems of the given Group in topological order.
Parameters
----------
graph : networkx.DiGraph
Directed graph of Systems.
Returns
-------
list of sets of str
A list of strongly connected components in topological order.
"""
# Tarjan's algorithm returns SCCs in reverse topological order, so
# the list returned here is reversed.
sccs = list(nx.strongly_connected_components(graph))
sccs.reverse()
return sccs
def get_out_of_order_nodes(graph, orders):
"""
Return a list of nodes that are out of order.
Parameters
----------
graph : networkx.DiGraph
Directed graph of Systems.
orders : dict
A dict of order values keyed by node name.
Returns
-------
list of sets of str
A list of strongly connected components in topological order.
list of str
A list of nodes that are out of order.
"""
strongcomps = get_sccs_topo(graph)
out_of_order = []
for strongcomp in strongcomps:
for u, v in graph.edges(strongcomp):
# for any connection between a system in this strongcomp and a system
# outside of it, the target must be ordered after the source.
if u in strongcomp and v not in strongcomp and orders[u] > orders[v]:
out_of_order.append((u, v))
return strongcomps, out_of_order
def write_graph(G, prog='dot', display=True, outfile='graph.svg'):
"""
Write the graph to a file and optionally display it.
Parameters
----------
G : nx.DiGraph or pydot.Dot
The graph to be written.
prog : str
The graphviz program to use for layout.
display : bool
If True, display the graph after writing it.
outfile : str
The name of the file to write.
Returns
-------
pydot.Dot
The graph that was written.
"""
from openmdao.utils.webview import webview
if pydot is None:
raise RuntimeError("graph requires the pydot package. You can install it using "
"'pip install pydot'.")
ext = outfile.rpartition('.')[2]
if not ext:
ext = 'svg'
if isinstance(G, nx.Graph):
pydot_graph = nx.drawing.nx_pydot.to_pydot(G)
else:
pydot_graph = G
try:
pstr = getattr(pydot_graph, f"create_{ext}")(prog=prog)
except AttributeError:
raise AttributeError(f"pydot graph has no 'create_{ext}' method.")
with open(outfile, 'wb') as f:
f.write(pstr)
if display:
webview(outfile)
return pydot_graph
def _graph_setup_parser(parser):
"""
Set up the openmdao subparser for the 'openmdao graph' command.
Parameters
----------
parser : argparse subparser
The parser we're adding options to.
"""
parser.add_argument('file', nargs=1, help='Python file containing the model.')
parser.add_argument('-p', '--problem', action='store', dest='problem', help='Problem name')
parser.add_argument('-o', action='store', dest='outfile', help='file containing graph output.')
parser.add_argument('--group', action='store', dest='group', help='pathname of group to graph.')
parser.add_argument('--type', action='store', dest='type', default='dataflow',
help='type of graph (dataflow, tree). Default is dataflow.')
parser.add_argument('--no-display', action='store_false', dest='show',
help="don't display the graph.")
parser.add_argument('--no-recurse', action='store_false', dest='recurse',
help="don't recurse from the specified group down. This only applies to "
"the dataflow graph type.")
parser.add_argument('--show-vars', action='store_true', dest='show_vars',
help="show variables in the graph. This only applies to the dataflow graph."
" Default is False.")
parser.add_argument('--show-boundary', action='store_true', dest='show_boundary',
help="show connections to variables outside of the graph. This only "
"applies to the dataflow graph. Default is False.")
parser.add_argument('--autoivc', action='store_true', dest='auto_ivc',
help="include the _auto_ivc component in the graph. This applies to "
"graphs of the top level group only. Default is False.")
def _graph_cmd(options, user_args):
"""
Return the post_setup hook function for 'openmdao graph'.
Parameters
----------
options : argparse Namespace
Command line options.
user_args : list of str
Args to be passed to the user script.
"""
def _view_graph(problem):
group = problem.model._get_subsystem(options.group) if options.group else problem.model
if not options.auto_ivc:
exclude = {'_auto_ivc'}
else:
exclude = set()
group.write_graph(gtype=options.type, recurse=options.recurse,
show_vars=options.show_vars, display=options.show, exclude=exclude,
show_boundary=options.show_boundary, outfile=options.outfile)
# register the hooks
hooks._register_hook('final_setup', 'Problem', post=_view_graph, exit=True)
_load_and_exec(options.file[0], user_args)
def _to_pydot_graph(G):
gmeta = G.graph.get('graph', {}).copy()
gmeta['graph_type'] = 'digraph'
pydot_graph = pydot.Dot(**gmeta)
pydot_nodes = {}
for node, meta in G.nodes(data=True):
pdnode = pydot_nodes[node] = pydot.Node(node, **_filter_meta4dot(meta))
pydot_graph.add_node(pdnode)
for u, v, meta in G.edges(data=True):
pydot_graph.add_edge(pydot.Edge(pydot_nodes[u], pydot_nodes[v],
**_filter_meta4dot(meta, arrowsize=0.5)))
# layout graph from left to right
pydot_graph.set_rankdir('LR')
return pydot_graph
def _filter_meta4dot(meta, **kwargs):
"""
Remove unnecessary metadata from the given metadata dict before passing to pydot.
Parameters
----------
meta : dict
Metadata dict.
kwargs : dict
Additional metadata that will be added only if they are not already present.
Returns
-------
dict
Metadata dict with unnecessary items removed.
"""
skip = {'type_', 'local', 'base', 'classname'}
dct = {k: v for k, v in meta.items() if k not in skip}
for k, v in kwargs.items():
if k not in dct:
dct[k] = v
return dct
def _add_boundary_nodes(pathname, G, incoming, outgoing, exclude=()):
"""
Add boundary nodes to the graph.
Parameters
----------
pathname : str
Pathname of the current group.
G : nx.DiGraph
The graph.
incoming : list of (str, str)
List of incoming connections.
outgoing : list of (str, str)
List of outgoing connections.
exclude : list of str
List of pathnames to exclude from the graph.
Returns
-------
nx.DiGraph
The modified graph.
"""
lenpre = len(pathname) + 1 if pathname else 0
for ex in exclude:
expre = ex + '.'
incoming = [(in_abs, out_abs) for in_abs, out_abs in incoming
if in_abs != ex and out_abs != ex and
not in_abs.startswith(expre) and not out_abs.startswith(expre)]
outgoing = [(in_abs, out_abs) for in_abs, out_abs in outgoing
if in_abs != ex and out_abs != ex and
not in_abs.startswith(expre) and not out_abs.startswith(expre)]
if incoming:
tooltip = ['External Connections:']
connstrs = set()
for in_abs, out_abs in incoming:
if in_abs in G:
connstrs.add(f" {out_abs} -> {in_abs[lenpre:]}")
tooltip += sorted(connstrs)
tooltip = '\n'.join(tooltip)
if connstrs:
G.add_node('_Incoming', label='Incoming', shape='rarrow', fillcolor='peachpuff3',
style='filled', tooltip=f'"{tooltip}"', rank='min')
for in_abs, out_abs in incoming:
if in_abs in G:
G.add_edge('_Incoming', in_abs, style='dashed', arrowhead='lnormal',
arrowsize=0.5)
if outgoing:
tooltip = ['External Connections:']
connstrs = set()
for in_abs, out_abs in outgoing:
if out_abs in G:
connstrs.add(f" {out_abs[lenpre:]} -> {in_abs}")
tooltip += sorted(connstrs)
tooltip = '\n'.join(tooltip)
G.add_node('_Outgoing', label='Outgoing', arrowhead='rarrow', fillcolor='peachpuff3',
style='filled', tooltip=f'"{tooltip}"', rank='max')
for in_abs, out_abs in outgoing:
if out_abs in G:
G.add_edge(out_abs, '_Outgoing', style='dashed', shape='lnormal', arrowsize=0.5)
return G
def _cluster_color(path):
"""
Return the color of the cluster that contains the given path.
The idea here is to make nested clusters stand out wrt their parent cluster.
Parameters
----------
path : str
Pathname of a variable.
Returns
-------
int
The color of the cluster that contains the given path.
"""
depth = path.count('.') + 1 if path else 0
ncolors = 10
maxcolor = 98
mincolor = 40
col = maxcolor - (depth % ncolors) * (maxcolor - mincolor) // ncolors
return f"gray{col}"