"""Command-line utility for plotting saved Neural-LAM graphs."""
# Standard library
import os
from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser
from typing import Any
# Third-party
import numpy as np
import plotly.graph_objects as go
import torch_geometric as pyg
# Local
from . import utils
from .config import load_config_and_datastore
MESH_HEIGHT = 0.1
MESH_LEVEL_DIST = 0.2
GRID_HEIGHT = 0
[docs]
def plot_graph(
grid_pos: np.ndarray,
hierarchical: bool,
graph_ldict: dict[str, Any],
show_axis: bool = False,
save: str | None = None,
) -> go.Figure:
"""Build a 3D plotly figure of the graph structure.
Parameters
----------
grid_pos : np.ndarray
Grid node positions, shape (num_grid_nodes, 2).
hierarchical : bool
Whether the loaded graph is hierarchical.
graph_ldict : dict
Graph dict as returned by ``utils.load_graph``.
show_axis : bool
If True, show the 3D axis.
save : str or None
If given, save the figure as an HTML file at this path.
Returns
-------
go.Figure
The plotly figure object.
"""
(
g2m_edge_index,
m2g_edge_index,
m2m_edge_index,
) = (
graph_ldict["g2m_edge_index"],
graph_ldict["m2g_edge_index"],
graph_ldict["m2m_edge_index"],
)
mesh_up_edge_index, mesh_down_edge_index = (
graph_ldict["mesh_up_edge_index"],
graph_ldict["mesh_down_edge_index"],
)
mesh_static_features = graph_ldict["mesh_static_features"]
# Add in z-dimension
z_grid = GRID_HEIGHT * np.ones((grid_pos.shape[0],))
grid_pos = np.concatenate(
(grid_pos, np.expand_dims(z_grid, axis=1)), axis=1
)
# Normalize mesh_static_features to a list of tensors for zero_index
# functions: hierarchical -> BufferList, non-hierarchical -> single tensor
msf_as_list = (
list(mesh_static_features) if hierarchical else [mesh_static_features]
)
# The plotting requires the edges to be non-zero-indexed
# with grid indices following mesh indices
m2g_edge_index = utils.zero_index_m2g(
m2g_edge_index, msf_as_list, mesh_first=True, restore=True
)
g2m_edge_index = utils.zero_index_g2m(
g2m_edge_index, msf_as_list, mesh_first=True, restore=True
)
# List of edges to plot, (edge_index, color, line_width, label)
edge_plot_list = [
(m2g_edge_index.numpy(), "black", 0.4, "M2G"),
(g2m_edge_index.numpy(), "black", 0.4, "G2M"),
]
# Mesh positioning and edges to plot differ if we have a hierarchical graph
if hierarchical:
mesh_level_pos = [
np.concatenate(
(
level_static_features.numpy(),
MESH_HEIGHT
+ MESH_LEVEL_DIST
* height_level
* np.ones((level_static_features.shape[0], 1)),
),
axis=1,
)
for height_level, level_static_features in enumerate(
mesh_static_features, start=1
)
]
mesh_pos = np.concatenate(mesh_level_pos, axis=0)
# Compute cumulative node offsets per level (in the concatenated
# mesh_pos array, level-l nodes start at level_offsets[l])
# This is needed as the zero-indexing is applied to each level in
# in load_graph()
level_sizes = [msf.shape[0] for msf in mesh_static_features]
level_offsets = np.cumsum([0] + level_sizes[:-1])
# Add intra-level mesh edges (m2m per level)
# Edge indices are zero-indexed per level, so shift by level offset
for level, level_ei in enumerate(m2m_edge_index):
ei_shifted = level_ei.numpy() + level_offsets[level]
edge_plot_list.append((ei_shifted, "blue", 1, f"M2M Level {level}"))
# Add inter-level mesh edges (up/down connect adjacent levels)
for level, level_up_ei in enumerate(mesh_up_edge_index):
ei_up = level_up_ei.numpy().copy()
# Row 0: source in level l, Row 1: target in level l+1
ei_up[0] += level_offsets[level]
ei_up[1] += level_offsets[level + 1]
edge_plot_list.append(
(ei_up, "green", 1, f"Mesh up {level}->{level + 1}")
)
for level, level_down_ei in enumerate(mesh_down_edge_index):
ei_down = level_down_ei.numpy().copy()
# Row 0: source in level l+1, Row 1: target in level l
ei_down[0] += level_offsets[level + 1]
ei_down[1] += level_offsets[level]
edge_plot_list.append(
(ei_down, "green", 1, f"Mesh down {level + 1}->{level}")
)
mesh_node_size = 2.5
else:
mesh_pos = mesh_static_features.numpy()
mesh_degrees = pyg.utils.degree(m2m_edge_index[1]).numpy()
z_mesh = MESH_HEIGHT + 0.01 * mesh_degrees
mesh_node_size = mesh_degrees / 2
mesh_pos = np.concatenate(
(mesh_pos, np.expand_dims(z_mesh, axis=1)), axis=1
)
edge_plot_list.append((m2m_edge_index.numpy(), "blue", 1, "M2M"))
# All node positions in one array
node_pos = np.concatenate((mesh_pos, grid_pos), axis=0)
# Add edges
data_objs = []
for (
ei,
col,
width,
label,
) in edge_plot_list:
edge_start = node_pos[ei[0]] # (M, 3)
edge_end = node_pos[ei[1]] # (M, 3)
n_edges = edge_start.shape[0]
x_edges = np.stack(
(edge_start[:, 0], edge_end[:, 0], np.full(n_edges, None)), axis=1
).flatten()
y_edges = np.stack(
(edge_start[:, 1], edge_end[:, 1], np.full(n_edges, None)), axis=1
).flatten()
z_edges = np.stack(
(edge_start[:, 2], edge_end[:, 2], np.full(n_edges, None)), axis=1
).flatten()
scatter_obj = go.Scatter3d(
x=x_edges,
y=y_edges,
z=z_edges,
mode="lines",
line={"color": col, "width": width},
name=label,
)
data_objs.append(scatter_obj)
# Add node objects
data_objs.append(
go.Scatter3d(
x=grid_pos[:, 0],
y=grid_pos[:, 1],
z=grid_pos[:, 2],
mode="markers",
marker={"color": "black", "size": 1},
name="Grid nodes",
)
)
data_objs.append(
go.Scatter3d(
x=mesh_pos[:, 0],
y=mesh_pos[:, 1],
z=mesh_pos[:, 2],
mode="markers",
marker={"color": "blue", "size": mesh_node_size},
name="Mesh nodes",
)
)
fig = go.Figure(data=data_objs)
fig.update_layout(scene_aspectmode="data")
fig.update_traces(connectgaps=False)
if not show_axis:
fig.update_layout(
scene={
"xaxis": {"visible": False},
"yaxis": {"visible": False},
"zaxis": {"visible": False},
}
)
if save:
fig.write_html(save, include_plotlyjs="cdn")
return fig
[docs]
def main() -> None:
"""Plot graph structure in 3D using plotly."""
parser = ArgumentParser(
description="Plot graph",
formatter_class=ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--datastore_config_path",
type=str,
default="tests/datastore_examples/mdp/config.yaml",
help="Path for the datastore config",
)
parser.add_argument(
"--graph",
type=str,
default="multiscale",
help="Graph to plot",
)
parser.add_argument(
"--save",
type=str,
help="Name of .html file to save interactive plot to",
)
parser.add_argument(
"--show_axis",
action="store_true",
help="If the axis should be displayed",
)
args = parser.parse_args()
_, datastore, _ = load_config_and_datastore(
config_path=args.datastore_config_path
)
xy = datastore.get_xy("state", stacked=True) # (num_grid_nodes, 2)
grid_xy_extent = datastore.get_xy_extent(category="state")
grid_xy_max_span = max(
grid_xy_extent[1] - grid_xy_extent[0],
grid_xy_extent[3] - grid_xy_extent[2],
)
grid_pos = xy / grid_xy_max_span # Divide by maximum horizontal span
# Load graph data
graph_dir_path = os.path.join(datastore.root_path, "graph", args.graph)
hierarchical, graph_ldict = utils.load_graph(
graph_dir_path=graph_dir_path,
mesh_node_features_scaling=grid_xy_max_span,
)
fig = plot_graph(
grid_pos=grid_pos,
hierarchical=hierarchical,
graph_ldict=graph_ldict,
show_axis=args.show_axis,
save=args.save,
)
if not args.save:
fig.show()
if __name__ == "__main__":
main()