java/com.sap.sailing.windestimation.lab/python/mst_graph_visualizer_graphviz.py
... ...
@@ -7,10 +7,13 @@ large trees. It uses DOT language to create the graph and renders it with graphv
7 7
8 8
Features:
9 9
- Proper tree layout (top to bottom)
10
-- Each node shows 4 compartments as an HTML-like table
10
+- Each node shows 4 compartments as an HTML-like table with PORT attributes
11
+- Edges connect to specific compartments
11 12
- Best path highlighted in red
13
+- All other edges shown in green (high prob) or gray (lower prob)
12 14
- Edge labels with transition probabilities
13
-- Color-coded confidence levels
15
+- Color-coded compartments by maneuver type
16
+- Legend explaining colors
14 17
15 18
Usage:
16 19
python mst_graph_visualizer_graphviz.py <input_json_file> [output_file]
... ...
@@ -39,13 +42,12 @@ TYPE_ORDER = ['TACK', 'JIBE', 'HEAD_UP', 'BEAR_AWAY']
39 42
TYPE_ABBREV = {'TACK': 'T', 'JIBE': 'J', 'HEAD_UP': 'H', 'BEAR_AWAY': 'B'}
40 43
41 44
42
-def confidence_to_intensity(confidence):
43
- """Convert confidence [0,1] to color intensity for background."""
44
- # Higher confidence = more saturated color
45
- base_intensity = 40 # Minimum intensity (very light)
46
- max_intensity = 200 # Maximum intensity
47
- intensity = int(base_intensity + confidence * (max_intensity - base_intensity))
48
- return intensity
45
+def blend_color_with_white(hex_color, factor):
46
+ """Blend a hex color with white based on factor (0=white, 1=full color)."""
47
+ r = int(int(hex_color[1:3], 16) * factor + 255 * (1 - factor))
48
+ g = int(int(hex_color[3:5], 16) * factor + 255 * (1 - factor))
49
+ b = int(int(hex_color[5:7], 16) * factor + 255 * (1 - factor))
50
+ return f"#{min(255, r):02x}{min(255, g):02x}{min(255, b):02x}"
49 51
50 52
51 53
def format_wind(comp):
... ...
@@ -58,8 +60,11 @@ def format_wind(comp):
58 60
return f"{wind_est:.0f}±{wind_width/2:.0f}°"
59 61
60 62
61
-def create_node_label(node, best_type=None):
62
- """Create HTML-like label for a node with 4 compartments."""
63
+def create_node_label_with_ports(node, best_type=None):
64
+ """
65
+ Create HTML-like label for a node with 4 compartments.
66
+ Each compartment has a PORT attribute for edge connections.
67
+ """
63 68
compartments = {c['type']: c for c in node['compartments']}
64 69
65 70
# Extract time from timestamp
... ...
@@ -69,8 +74,8 @@ def create_node_label(node, best_type=None):
69 74
else:
70 75
time_part = timestamp[:8] if len(timestamp) >= 8 else timestamp
71 76
72
- # Build HTML table
73
- html = '<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="2">'
77
+ # Build HTML table with PORT attributes
78
+ html = '<<TABLE BORDER="1" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">'
74 79
html += '<TR>'
75 80
76 81
for type_name in TYPE_ORDER:
... ...
@@ -81,46 +86,72 @@ def create_node_label(node, best_type=None):
81 86
confidence = comp['confidence']
82 87
is_best = (best_type == type_name)
83 88
84
- # Calculate background color
89
+ # Calculate background color - blend with white based on confidence
85 90
base_color = TYPE_COLORS[type_name]
86 91
if is_best:
87
- # Highlight best with full color and border
92
+ # Best type: full saturation with red border
88 93
bg_color = base_color
89
- border = ' BGCOLOR="{}" COLOR="red"'.format(base_color)
94
+ border_attr = f' BGCOLOR="{bg_color}" BORDER="3" COLOR="red"'
90 95
else:
91
- # Fade color based on confidence
92
- intensity = confidence_to_intensity(confidence)
93
- # Make it lighter by blending with white
94
- r = int(int(base_color[1:3], 16) * confidence + 255 * (1-confidence))
95
- g = int(int(base_color[3:5], 16) * confidence + 255 * (1-confidence))
96
- b = int(int(base_color[5:7], 16) * confidence + 255 * (1-confidence))
97
- bg_color = f"#{min(255,r):02x}{min(255,g):02x}{min(255,b):02x}"
98
- border = f' BGCOLOR="{bg_color}"'
96
+ # Other types: fade based on confidence (min 0.2 to keep some color visible)
97
+ blend_factor = max(0.2, confidence)
98
+ bg_color = blend_color_with_white(base_color, blend_factor)
99
+ border_attr = f' BGCOLOR="{bg_color}"'
99 100
100 101
wind_str = format_wind(comp)
101 102
abbrev = TYPE_ABBREV[type_name]
102 103
103
- # Create cell content
104
- cell_content = f'<B>{abbrev}</B><BR/><FONT POINT-SIZE="8">{confidence:.2f}</FONT><BR/><FONT POINT-SIZE="7">{wind_str}</FONT>'
104
+ # PORT attribute allows edges to connect to this specific cell
105
+ port_name = type_name
106
+
107
+ # Create cell content with PORT
108
+ cell_content = (
109
+ f'<B>{abbrev}</B><BR/>'
110
+ f'<FONT POINT-SIZE="9">{confidence:.2f}</FONT><BR/>'
111
+ f'<FONT POINT-SIZE="8">{wind_str}</FONT>'
112
+ )
105 113
106
- html += f'<TD{border}>{cell_content}</TD>'
114
+ html += f'<TD PORT="{port_name}"{border_attr}>{cell_content}</TD>'
107 115
108 116
html += '</TR>'
109
- html += f'<TR><TD COLSPAN="4"><FONT POINT-SIZE="8">{time_part}</FONT></TD></TR>'
117
+ # Footer row with timestamp
118
+ html += f'<TR><TD COLSPAN="4" BGCOLOR="white"><FONT POINT-SIZE="9">{time_part}</FONT></TD></TR>'
110 119
html += '</TABLE>>'
111 120
112 121
return html
113 122
114 123
115
-def visualize_mst_graph(data, output_file=None, max_nodes=100, show_low_prob_edges=False):
124
+def create_legend():
125
+ """Create a legend explaining the colors."""
126
+ html = '<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">'
127
+ html += '<TR><TD COLSPAN="2" BGCOLOR="white"><B>Legend</B></TD></TR>'
128
+
129
+ # Maneuver type colors
130
+ for type_name in TYPE_ORDER:
131
+ color = TYPE_COLORS[type_name]
132
+ abbrev = TYPE_ABBREV[type_name]
133
+ html += f'<TR><TD BGCOLOR="{color}">{abbrev}</TD><TD BGCOLOR="white">{type_name}</TD></TR>'
134
+
135
+ # Edge colors
136
+ html += '<TR><TD COLSPAN="2" BGCOLOR="white"><B>Edges</B></TD></TR>'
137
+ html += '<TR><TD BGCOLOR="white"><FONT COLOR="red">━━</FONT></TD><TD BGCOLOR="white">Best Path</TD></TR>'
138
+ html += '<TR><TD BGCOLOR="white"><FONT COLOR="darkgreen">━━</FONT></TD><TD BGCOLOR="white">High Prob (&gt;1%)</TD></TR>'
139
+ html += '<TR><TD BGCOLOR="white"><FONT COLOR="gray50">━━</FONT></TD><TD BGCOLOR="white">Medium Prob</TD></TR>'
140
+ html += '<TR><TD BGCOLOR="white"><FONT COLOR="gray80">━━</FONT></TD><TD BGCOLOR="white">Low Prob</TD></TR>'
141
+
142
+ html += '</TABLE>>'
143
+ return html
144
+
145
+
146
+def visualize_mst_graph(data, output_file=None, max_nodes=100, min_edge_prob=0.0):
116 147
"""
117
- Create graphviz visualization of MST graph.
148
+ Create graphviz visualization of MST graph with edges connecting to specific compartments.
118 149
119 150
Args:
120 151
data: Parsed JSON data from MstGraphExporter
121 152
output_file: Output file path (without extension; .pdf/.png will be added)
122
- max_nodes: Maximum nodes to show
123
- show_low_prob_edges: Whether to show edges with very low probability
153
+ max_nodes: Maximum number of nodes to show
154
+ min_edge_prob: Minimum edge probability to display (0 = show all)
124 155
"""
125 156
nodes = {n['id']: n for n in data['nodes']}
126 157
edges = data['edges']
... ...
@@ -130,28 +161,26 @@ def visualize_mst_graph(data, output_file=None, max_nodes=100, show_low_prob_edg
130 161
dot = Digraph(comment='MST Maneuver Graph')
131 162
dot.attr(rankdir='TB') # Top to bottom
132 163
dot.attr('node', shape='plaintext') # Use HTML labels
164
+ dot.attr(splines='polyline') # Use polyline for clearer edge routing
165
+ dot.attr(nodesep='0.5') # Horizontal spacing between nodes
166
+ dot.attr(ranksep='1.0') # Vertical spacing between ranks
133 167
134 168
# Limit nodes
135 169
nodes_to_draw = list(nodes.keys())[:max_nodes]
136 170
nodes_set = set(nodes_to_draw)
137 171
172
+ # Add legend
173
+ dot.node('legend', create_legend())
174
+ dot.node('legend_spacer', '', shape='none', width='0', height='0')
175
+
138 176
# Add nodes
139 177
for node_id in nodes_to_draw:
140 178
node = nodes[node_id]
141 179
best_type = best_paths.get(str(node_id))
142
- label = create_node_label(node, best_type)
180
+ label = create_node_label_with_ports(node, best_type)
143 181
dot.node(str(node_id), label)
144 182
145
- # Collect best path edges for highlighting
146
- best_edge_keys = set()
147
- for edge in edges:
148
- if edge.get('isBestPath', False):
149
- key = (edge['from'], edge['to'], edge['fromType'], edge['toType'])
150
- best_edge_keys.add(key)
151
-
152
- # Group edges by (from_node, to_node) for simplicity
153
- # Only show best path edges and edges between same types
154
- edge_groups = {}
183
+ # Process ALL edges
155 184
for edge in edges:
156 185
from_id = edge['from']
157 186
to_id = edge['to']
... ...
@@ -161,61 +190,55 @@ def visualize_mst_graph(data, output_file=None, max_nodes=100, show_low_prob_edg
161 190
162 191
is_best = edge.get('isBestPath', False)
163 192
trans_prob = edge['transitionProbability']
193
+ from_type = edge['fromType']
194
+ to_type = edge['toType']
164 195
165
- # Filter: show best path edges, same-type edges, or high probability edges
166
- same_type = edge['fromType'] == edge['toType']
167
- high_prob = trans_prob > 0.001
168
-
169
- if is_best or (same_type and high_prob) or show_low_prob_edges:
170
- key = (from_id, to_id)
171
- if key not in edge_groups:
172
- edge_groups[key] = []
173
- edge_groups[key].append(edge)
174
-
175
- # Add edges (simplified - one edge per node pair, prioritizing best path)
176
- for (from_id, to_id), edge_list in edge_groups.items():
177
- # Find best edge in this group
178
- best_edge = None
179
- for e in edge_list:
180
- if e.get('isBestPath', False):
181
- best_edge = e
182
- break
183
-
184
- if best_edge is None:
185
- # Use edge with highest probability
186
- best_edge = max(edge_list, key=lambda x: x['transitionProbability'])
187
-
188
- is_best = best_edge.get('isBestPath', False)
189
- trans_prob = best_edge['transitionProbability']
190
- from_type = best_edge['fromType']
191
- to_type = best_edge['toType']
196
+ # Skip very low probability edges unless they're best path
197
+ if trans_prob < min_edge_prob and not is_best:
198
+ continue
192 199
193
- # Style based on best path
200
+ # Style based on best path and probability
194 201
if is_best:
195 202
color = 'red'
196 203
penwidth = '2.5'
197 204
style = 'bold'
198
- else:
199
- # Color based on probability
200
- if trans_prob > 0.01:
201
- color = 'darkgreen'
202
- elif trans_prob > 0.001:
203
- color = 'gray40'
204
- else:
205
- color = 'gray80'
206
- penwidth = '1.0'
205
+ fontcolor = 'red'
206
+ elif trans_prob > 0.01:
207
+ color = 'darkgreen'
208
+ penwidth = '1.2'
209
+ style = 'solid'
210
+ fontcolor = 'darkgreen'
211
+ elif trans_prob > 0.001:
212
+ color = 'gray50'
213
+ penwidth = '0.8'
207 214
style = 'solid'
215
+ fontcolor = 'gray50'
216
+ else:
217
+ color = 'gray80'
218
+ penwidth = '0.5'
219
+ style = 'dashed'
220
+ fontcolor = 'gray70'
221
+
222
+ # Format probability label
223
+ if trans_prob >= 0.01:
224
+ prob_label = f'{trans_prob:.2f}'
225
+ elif trans_prob >= 0.001:
226
+ prob_label = f'{trans_prob:.3f}'
227
+ else:
228
+ prob_label = f'{trans_prob:.1e}'
208 229
209
- # Label shows type transition and probability
210
- label = f'{TYPE_ABBREV[from_type]}→{TYPE_ABBREV[to_type]}\\n{trans_prob:.1e}'
230
+ # Use PORT to connect to specific compartments
231
+ from_port = f'{from_id}:{from_type}:s' # :s = south (bottom) of cell
232
+ to_port = f'{to_id}:{to_type}:n' # :n = north (top) of cell
211 233
212
- dot.edge(str(from_id), str(to_id),
213
- label=label,
214
- color=color,
234
+ dot.edge(from_port, to_port,
235
+ label=prob_label,
236
+ color=color,
215 237
penwidth=penwidth,
216 238
style=style,
217
- fontsize='8',
218
- fontcolor=color)
239
+ fontsize='7',
240
+ fontcolor=fontcolor,
241
+ arrowsize='0.6')
219 242
220 243
# Render
221 244
if output_file:
... ...
@@ -235,10 +258,10 @@ def visualize_mst_graph(data, output_file=None, max_nodes=100, show_low_prob_edg
235 258
return dot
236 259
237 260
238
-def create_detailed_edge_graph(data, output_file=None, max_depth=10):
261
+def create_detailed_edge_graph(data, output_file=None, max_depth=10, min_edge_prob=0.0):
239 262
"""
240 263
Create a more detailed graph showing all compartment-to-compartment edges.
241
- Each node compartment becomes its own graphviz node.
264
+ Each node compartment becomes its own graphviz node, grouped by cluster.
242 265
243 266
This is useful for small subtrees to see the full inner graph structure.
244 267
"""
... ...
@@ -252,43 +275,62 @@ def create_detailed_edge_graph(data, output_file=None, max_depth=10):
252 275
253 276
dot = Digraph(comment='MST Detailed Inner Graph')
254 277
dot.attr(rankdir='TB')
278
+ dot.attr(nodesep='0.3')
279
+ dot.attr(ranksep='0.8')
280
+
281
+ # Add legend
282
+ dot.node('legend', create_legend())
255 283
256 284
# Create subgraph for each tree node (to keep compartments together)
257 285
for node in nodes_to_draw:
258 286
node_id = node['id']
259 287
best_type = best_paths.get(str(node_id))
260 288
289
+ # Extract time from timestamp for label
290
+ timestamp = node.get('timestamp', '')
291
+ if ' ' in timestamp:
292
+ time_part = timestamp.split(' ')[1][:8]
293
+ else:
294
+ time_part = str(node_id)
295
+
261 296
with dot.subgraph(name=f'cluster_{node_id}') as c:
262
- c.attr(label=f"Node {node_id}")
263
- c.attr(style='rounded')
297
+ c.attr(label=f'{time_part}')
298
+ c.attr(style='rounded,filled')
299
+ c.attr(fillcolor='white')
264 300
265 301
for comp in node['compartments']:
266 302
type_name = comp['type']
267 303
comp_id = f"{node_id}_{type_name}"
268 304
269 305
is_best = (best_type == type_name)
270
- color = TYPE_COLORS[type_name]
306
+ base_color = TYPE_COLORS[type_name]
307
+ confidence = comp['confidence']
271 308
272 309
if is_best:
273
- style = 'filled,bold'
274
- fillcolor = color
310
+ fillcolor = base_color
275 311
fontcolor = 'white'
312
+ penwidth = '3'
313
+ pencolor = 'red'
276 314
else:
277
- style = 'filled'
278
- # Lighter version
279
- fillcolor = f"{color}40" # 40 = 25% opacity in hex
315
+ # Blend based on confidence
316
+ blend_factor = max(0.3, confidence)
317
+ fillcolor = blend_color_with_white(base_color, blend_factor)
280 318
fontcolor = 'black'
319
+ penwidth = '1'
320
+ pencolor = 'black'
281 321
282
- label = f"{TYPE_ABBREV[type_name]}\\n{comp['confidence']:.2f}\\n{format_wind(comp)}"
322
+ label = f"{TYPE_ABBREV[type_name]}\\n{confidence:.2f}\\n{format_wind(comp)}"
283 323
284 324
c.node(comp_id, label,
285 325
shape='box',
286
- style=style,
326
+ style='filled',
287 327
fillcolor=fillcolor,
288 328
fontcolor=fontcolor,
289
- fontsize='10')
329
+ fontsize='9',
330
+ penwidth=penwidth,
331
+ color=pencolor)
290 332
291
- # Add edges between compartments
333
+ # Add ALL edges between compartments
292 334
for edge in edges:
293 335
from_id = edge['from']
294 336
to_id = edge['to']
... ...
@@ -302,31 +344,42 @@ def create_detailed_edge_graph(data, output_file=None, max_depth=10):
302 344
is_best = edge.get('isBestPath', False)
303 345
trans_prob = edge['transitionProbability']
304 346
305
- # Only show significant edges
306
- if trans_prob < 0.0001 and not is_best:
347
+ # Skip very low probability edges unless best path
348
+ if trans_prob < min_edge_prob and not is_best:
307 349
continue
308 350
309 351
if is_best:
310 352
color = 'red'
311
- penwidth = '2.0'
353
+ penwidth = '2.5'
354
+ fontcolor = 'red'
355
+ elif trans_prob > 0.01:
356
+ color = 'darkgreen'
357
+ penwidth = '1.2'
358
+ fontcolor = 'darkgreen'
359
+ elif trans_prob > 0.001:
360
+ color = 'gray50'
361
+ penwidth = '0.8'
362
+ fontcolor = 'gray50'
363
+ else:
364
+ color = 'gray80'
365
+ penwidth = '0.4'
366
+ fontcolor = 'gray70'
367
+
368
+ # Format probability label
369
+ if trans_prob >= 0.01:
370
+ prob_label = f'{trans_prob:.2f}'
371
+ elif trans_prob >= 0.001:
372
+ prob_label = f'{trans_prob:.3f}'
312 373
else:
313
- # Color by probability
314
- if trans_prob > 0.01:
315
- color = 'darkgreen'
316
- penwidth = '1.5'
317
- elif trans_prob > 0.001:
318
- color = 'gray50'
319
- penwidth = '1.0'
320
- else:
321
- color = 'gray80'
322
- penwidth = '0.5'
374
+ prob_label = f'{trans_prob:.1e}'
323 375
324 376
dot.edge(from_comp_id, to_comp_id,
325
- label=f'{trans_prob:.1e}',
377
+ label=prob_label,
326 378
color=color,
327 379
penwidth=penwidth,
328 380
fontsize='7',
329
- fontcolor=color)
381
+ fontcolor=fontcolor,
382
+ arrowsize='0.5')
330 383
331 384
if output_file:
332 385
if '.' in output_file:
... ...
@@ -342,16 +395,40 @@ def create_detailed_edge_graph(data, output_file=None, max_depth=10):
342 395
343 396
def main():
344 397
if len(sys.argv) < 2:
345
- print("Usage: python mst_graph_visualizer_graphviz.py <input_json_file> [output_file] [--detailed]")
398
+ print("Usage: python mst_graph_visualizer_graphviz.py <input_json_file> [output_file] [options]")
346 399
print("\nOptions:")
347
- print(" input_json_file - JSON file exported from MstGraphExporter")
348
- print(" output_file - Output file (extension determines format: .pdf, .png, .svg)")
349
- print(" --detailed - Create detailed compartment-level graph (for small graphs)")
400
+ print(" input_json_file - JSON file exported from MstGraphExporter")
401
+ print(" output_file - Output file (extension determines format: .pdf, .png, .svg)")
402
+ print(" --detailed - Create detailed compartment-level graph (for small graphs)")
403
+ print(" --min-prob=<value> - Minimum edge probability to show (default: 0 = show all)")
404
+ print(" --max-nodes=<n> - Maximum number of nodes to display (default: 100)")
405
+ print("\nCompartment Colors:")
406
+ print(" T (TACK) - Green")
407
+ print(" J (JIBE) - Blue")
408
+ print(" H (HEAD_UP) - Orange")
409
+ print(" B (BEAR_AWAY) - Purple")
410
+ print("\nEdge Colors:")
411
+ print(" Red - Best path (selected by algorithm)")
412
+ print(" Dark Green - High probability (>1%)")
413
+ print(" Gray - Medium/low probability")
350 414
sys.exit(1)
351 415
352 416
input_file = sys.argv[1]
353
- output_file = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith('--') else None
354
- detailed = '--detailed' in sys.argv
417
+ output_file = None
418
+ detailed = False
419
+ min_prob = 0.0
420
+ max_nodes = 100
421
+
422
+ # Parse arguments
423
+ for arg in sys.argv[2:]:
424
+ if arg == '--detailed':
425
+ detailed = True
426
+ elif arg.startswith('--min-prob='):
427
+ min_prob = float(arg.split('=')[1])
428
+ elif arg.startswith('--max-nodes='):
429
+ max_nodes = int(arg.split('=')[1])
430
+ elif not arg.startswith('--'):
431
+ output_file = arg
355 432
356 433
print(f"Loading graph from {input_file}...")
357 434
with open(input_file, 'r') as f:
... ...
@@ -360,13 +437,14 @@ def main():
360 437
num_nodes = len(data['nodes'])
361 438
num_edges = len(data['edges'])
362 439
print(f"Loaded {num_nodes} nodes, {num_edges} edges")
440
+ print(f"Minimum edge probability: {min_prob}")
363 441
364 442
if detailed:
365 443
print("Creating detailed compartment-level visualization...")
366
- create_detailed_edge_graph(data, output_file, max_depth=10)
444
+ create_detailed_edge_graph(data, output_file, max_depth=10, min_edge_prob=min_prob)
367 445
else:
368
- print("Creating tree visualization...")
369
- visualize_mst_graph(data, output_file, max_nodes=100)
446
+ print(f"Creating tree visualization (max {max_nodes} nodes)...")
447
+ visualize_mst_graph(data, output_file, max_nodes=max_nodes, min_edge_prob=min_prob)
370 448
371 449
372 450
if __name__ == '__main__':