Skip to content

Commit 3e0cf74

Browse files
committed
fix test
1 parent ab3ff0e commit 3e0cf74

7 files changed

Lines changed: 298 additions & 2 deletions

File tree

.github/workflows/publish-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: PyPI publish
33
on:
44
push:
55
branches:
6-
- mech
6+
- beta
77
release:
88
types:
99
- published

Test/Vis/test_mtg_drawer.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import unittest
2+
3+
import matplotlib
4+
import networkx as nx
5+
6+
matplotlib.use("Agg")
7+
8+
from synkit.Graph.ITS.its_construction import ITSConstruction # noqa: E402
9+
from synkit.Graph.MTG.mtg import MTG # noqa: E402
10+
from synkit.Vis.mtg_drawer import draw_mtg_graph, draw_mtg_steps # noqa: E402
11+
12+
13+
class TestMTGDrawer(unittest.TestCase):
14+
@staticmethod
15+
def _atom(element, *, hcount=0, charge=0, lone_pairs=0, radical=0):
16+
return {
17+
"element": element,
18+
"aromatic": False,
19+
"hcount": hcount,
20+
"charge": charge,
21+
"lone_pairs": lone_pairs,
22+
"radical": radical,
23+
"valence_electrons": {"H": 1, "C": 4, "N": 5, "O": 6, "Cl": 7}[element],
24+
}
25+
26+
@staticmethod
27+
def _bond(graph, u, v, sigma=1.0, pi=0.0):
28+
graph.add_edge(
29+
u,
30+
v,
31+
order=sigma + pi,
32+
kekule_order=sigma + pi,
33+
sigma_order=sigma,
34+
pi_order=pi,
35+
)
36+
37+
def _graph(self, nodes, edges):
38+
graph = nx.Graph()
39+
for node, attrs in nodes.items():
40+
graph.add_node(node, **attrs)
41+
for edge in edges:
42+
self._bond(graph, *edge)
43+
return graph
44+
45+
def _mtg(self):
46+
g0 = self._graph(
47+
{
48+
1: self._atom("C", hcount=3),
49+
2: self._atom("Cl", lone_pairs=3),
50+
},
51+
[(1, 2, 1.0, 0.0)],
52+
)
53+
g1 = self._graph(
54+
{
55+
1: self._atom("C", hcount=3, radical=1),
56+
2: self._atom("Cl", radical=1, lone_pairs=3),
57+
},
58+
[],
59+
)
60+
g2 = self._graph(
61+
{
62+
1: self._atom("C", hcount=3),
63+
2: self._atom("Cl", lone_pairs=3),
64+
},
65+
[(1, 2, 1.0, 0.0)],
66+
)
67+
return MTG(
68+
[ITSConstruction.construct(g0, g1), ITSConstruction.construct(g1, g2)],
69+
mappings=[{1: 1, 2: 2}],
70+
)
71+
72+
def test_draw_mtg_graph_accepts_mtg_object(self):
73+
mtg = self._mtg()
74+
75+
fig, ax = draw_mtg_graph(mtg, title="radical rebound")
76+
77+
self.assertIs(fig, ax.figure)
78+
self.assertEqual(ax.get_title(), "radical rebound")
79+
80+
def test_draw_mtg_graph_accepts_raw_graph_without_mutation(self):
81+
graph = self._mtg().get_mtg()
82+
before_nodes = dict(graph.nodes(data=True))
83+
before_edges = list(graph.edges(data=True))
84+
85+
fig, ax = draw_mtg_graph(graph)
86+
87+
self.assertIs(fig, ax.figure)
88+
self.assertEqual(dict(graph.nodes(data=True)), before_nodes)
89+
self.assertEqual(list(graph.edges(data=True)), before_edges)
90+
91+
def test_draw_mtg_steps_draws_ordered_its_panels_and_composed_panel(self):
92+
mtg = self._mtg()
93+
94+
fig, axes = draw_mtg_steps(mtg, include_composed=True, show_edge_labels=True)
95+
96+
self.assertIs(fig, axes[0].figure)
97+
self.assertEqual(len(axes), 3)
98+
self.assertEqual([ax.get_title() for ax in axes], ["Step 1", "Step 2", "Composed"])
99+
100+
def test_draw_mtg_steps_validates_indices(self):
101+
with self.assertRaises(IndexError):
102+
draw_mtg_steps(self._mtg(), steps=[2])
103+
104+
105+
if __name__ == "__main__":
106+
unittest.main()

doc/api/vis.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ Modern molecule/reaction/ITS renderers
1919
:members:
2020
:show-inheritance:
2121

22+
.. automodule:: synkit.Vis.mtg_drawer
23+
:members:
24+
:show-inheritance:
25+
2226
Diagnostic adapter layer
2327
------------------------
2428

doc/vis.rst

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,38 @@ Use ``projection=True`` when you need to inspect how an ITS decomposes back into
168168
left and right molecular graphs. Use the default ITS-only view for reports and
169169
notebooks.
170170

171+
MTG Timelines
172+
-------------
173+
174+
Compact MTG visualization has two complementary views:
175+
176+
* ``draw_mtg_graph`` shows the fused MTG as a timeline graph;
177+
* ``draw_mtg_steps`` reconstructs ordered ITS steps and draws each step with
178+
the ITS renderer.
179+
180+
.. code-block:: python
181+
182+
from synkit.Graph.MTG.mtg import MTG
183+
from synkit.Vis import draw_mtg_graph, draw_mtg_steps
184+
185+
mtg = MTG([step_1_its, step_2_its])
186+
187+
fig, ax = draw_mtg_graph(
188+
mtg,
189+
title="MTG timeline",
190+
mode="timeline",
191+
)
192+
193+
fig, axes = draw_mtg_steps(
194+
mtg,
195+
include_composed=True,
196+
show_edge_labels=True,
197+
)
198+
199+
Use the timeline graph to see transient bonds and electron-state paths across
200+
the mechanism. Use the step panels when you need to check each reconstructed
201+
ITS independently.
202+
171203
Diagnostic Graph View
172204
---------------------
173205

@@ -198,4 +230,3 @@ The older visualization classes are still exported for compatibility:
198230
New code should use ``draw_molecule_graph``, ``draw_reaction_graph``, and
199231
``draw_its_from_rsmi`` unless a legacy workflow specifically depends on the
200232
class-based API.
201-

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ scikit-learn>=1.4.0
22
seaborn>=0.13.2
33
rdkit>=2025.3.1
44
pandas>=2.2.0
5+
networkx>=3.3
56
requests>=2.32.3
67
numpy>=2.2.0
78
regex>=2024.11.6

synkit/Vis/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
find_reaction_highlights,
2222
)
2323
from .its_drawer import draw_its_from_rsmi, draw_its_graph, draw_its_only
24+
from .mtg_drawer import draw_mtg_graph, draw_mtg_steps
2425

2526
__all__ = [
2627
"GraphVisualizer",
@@ -44,4 +45,6 @@
4445
"draw_its_from_rsmi",
4546
"draw_its_graph",
4647
"draw_its_only",
48+
"draw_mtg_graph",
49+
"draw_mtg_steps",
4750
]

synkit/Vis/mtg_drawer.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
"""MTG visualization helpers.
4+
5+
The compact MTG view is a timeline diagnostic. Step panels reuse the molecule-
6+
like ITS renderer so each reconstructed ITS step is inspected with the same
7+
visual language as normal tuple ITS drawings.
8+
"""
9+
10+
from typing import Any, Iterable, Optional
11+
12+
import matplotlib.pyplot as plt
13+
import networkx as nx
14+
15+
from synkit.Vis.its_drawer import draw_its_only
16+
from synkit.Vis.visual_drawer import draw_graph
17+
18+
19+
def draw_mtg_graph(
20+
mtg: Any,
21+
*,
22+
ax: Optional[plt.Axes] = None,
23+
title: Optional[str] = None,
24+
mode: str = "timeline",
25+
layout: str = "kamada_kawai",
26+
show_atom_map: bool = True,
27+
show_edge_labels: bool = True,
28+
show_node_badges: bool = True,
29+
) -> tuple[plt.Figure, plt.Axes]:
30+
"""Draw a compact MTG timeline graph.
31+
32+
``mtg`` may be a :class:`synkit.Graph.MTG.mtg.MTG` instance or a raw
33+
compact MTG ``networkx.Graph`` from ``MTG.get_mtg()``.
34+
35+
:param mtg: MTG object or compact MTG graph.
36+
:type mtg: Any
37+
:param ax: Optional Matplotlib axes.
38+
:type ax: Optional[plt.Axes]
39+
:param title: Optional title.
40+
:type title: Optional[str]
41+
:param mode: Visual adapter mode. ``"timeline"`` is the recommended MTG
42+
view; ``"sigma_pi"`` gives a shorter electron-bond diagnostic.
43+
:type mode: str
44+
:param layout: NetworkX layout name passed to ``draw_graph``.
45+
:type layout: str
46+
:returns: ``(figure, axes)``.
47+
:rtype: tuple[plt.Figure, plt.Axes]
48+
"""
49+
50+
graph = _as_mtg_graph(mtg)
51+
return draw_graph(
52+
graph,
53+
ax=ax,
54+
mode=mode,
55+
title=title or "MTG timeline",
56+
show_atom_map=show_atom_map,
57+
layout=layout,
58+
show_edge_labels=show_edge_labels,
59+
show_node_badges=show_node_badges,
60+
)
61+
62+
63+
def draw_mtg_steps(
64+
mtg: Any,
65+
*,
66+
steps: Optional[Iterable[int]] = None,
67+
include_composed: bool = False,
68+
title: Optional[str] = None,
69+
max_columns: int = 3,
70+
show_atom_map: bool = True,
71+
label_mode: str = "hetero",
72+
edge_label_mode: str = "kekule",
73+
show_edge_labels: bool = False,
74+
show_electron_labels: bool = False,
75+
electron_label_mode: str = "charge",
76+
) -> tuple[plt.Figure, list[plt.Axes]]:
77+
"""Draw reconstructed MTG ITS steps as ordered panels.
78+
79+
:param mtg: MTG object exposing ``get_its_steps``.
80+
:type mtg: Any
81+
:param steps: Optional zero-based step indices to draw.
82+
:type steps: Optional[Iterable[int]]
83+
:param include_composed: Append the composed outer-state ITS panel.
84+
:type include_composed: bool
85+
:param title: Optional figure title.
86+
:type title: Optional[str]
87+
:param max_columns: Maximum subplot columns.
88+
:type max_columns: int
89+
:returns: ``(figure, axes)``.
90+
:rtype: tuple[plt.Figure, list[plt.Axes]]
91+
"""
92+
93+
if not hasattr(mtg, "get_its_steps"):
94+
raise TypeError("draw_mtg_steps expects an MTG object with get_its_steps().")
95+
96+
all_steps = list(mtg.get_its_steps())
97+
selected = list(range(len(all_steps))) if steps is None else list(steps)
98+
for step in selected:
99+
if step < 0 or step >= len(all_steps):
100+
raise IndexError(f"MTG step index out of range: {step}")
101+
102+
panels = [(f"Step {step + 1}", all_steps[step]) for step in selected]
103+
if include_composed:
104+
if not hasattr(mtg, "get_compose_its"):
105+
raise TypeError("include_composed requires an MTG object with get_compose_its().")
106+
panels.append(("Composed", mtg.get_compose_its()))
107+
108+
if not panels:
109+
raise ValueError("No MTG steps selected for drawing.")
110+
111+
ncols = min(max(1, max_columns), len(panels))
112+
nrows = (len(panels) + ncols - 1) // ncols
113+
fig, axes_grid = plt.subplots(
114+
nrows,
115+
ncols,
116+
figsize=(4.8 * ncols, 4.2 * nrows),
117+
squeeze=False,
118+
facecolor="white",
119+
)
120+
axes = [ax for row in axes_grid for ax in row]
121+
if title:
122+
fig.suptitle(title, fontsize=13, fontweight="bold")
123+
124+
for ax, (panel_title, its) in zip(axes, panels):
125+
draw_its_only(
126+
its,
127+
ax=ax,
128+
title=panel_title,
129+
show_atom_map=show_atom_map,
130+
label_mode=label_mode,
131+
edge_label_mode=edge_label_mode,
132+
show_edge_labels=show_edge_labels,
133+
show_electron_labels=show_electron_labels,
134+
electron_label_mode=electron_label_mode,
135+
)
136+
137+
for ax in axes[len(panels):]:
138+
ax.set_axis_off()
139+
140+
fig.tight_layout()
141+
return fig, axes[: len(panels)]
142+
143+
144+
def _as_mtg_graph(mtg: Any) -> nx.Graph:
145+
if isinstance(mtg, nx.Graph):
146+
return mtg
147+
if hasattr(mtg, "get_mtg"):
148+
graph = mtg.get_mtg()
149+
if isinstance(graph, nx.Graph):
150+
return graph
151+
raise TypeError("Expected an MTG object or a NetworkX compact MTG graph.")

0 commit comments

Comments
 (0)