Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
2276ea7
Add functions to calculate cycle closure
hannahbaumann Nov 6, 2023
32338ff
Add recent changes
hannahbaumann Nov 6, 2023
ec1d008
small fixes
hannahbaumann Nov 6, 2023
e48668a
small fixes 2
hannahbaumann Nov 6, 2023
5edf55e
Larger update of the cycle closure function
hannahbaumann May 19, 2026
2c68605
Merge branch 'main' into cycle_closure_analysis
hannahbaumann May 19, 2026
31477ce
Small fix
hannahbaumann May 19, 2026
6b2dfd0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 19, 2026
0b8329f
Rename function
hannahbaumann May 19, 2026
675184a
Add cycle closure test
hannahbaumann May 19, 2026
bbbca3f
Add test for per edge cc df
hannahbaumann May 19, 2026
7f1d209
Small fix
hannahbaumann May 19, 2026
ddafe13
Add missing import
hannahbaumann May 19, 2026
054b372
Add test for plot_cycle_closure
hannahbaumann May 19, 2026
ec405d8
Small fix
hannahbaumann May 19, 2026
ccd83ee
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 19, 2026
6bbca0a
Merge conflicts
hannahbaumann May 20, 2026
b24329e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 20, 2026
e8099c7
Merge branch 'main' into cycle_closure_analysis
hannahbaumann May 26, 2026
378ed29
Update cinnabar/femap.py
hannahbaumann May 28, 2026
f69667c
Merge branch 'main' into cycle_closure_analysis
hannahbaumann May 28, 2026
790ce3a
Address review comments 1
hannahbaumann May 29, 2026
1813795
Merge branch 'main' into cycle_closure_analysis
hannahbaumann May 29, 2026
5c538e2
more review coments
hannahbaumann May 29, 2026
7a990c6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 29, 2026
5c70d2d
Add missing import
hannahbaumann May 29, 2026
2362af4
fix tests
hannahbaumann May 29, 2026
fc26bbf
fix mypy
hannahbaumann May 29, 2026
6739d54
More mypy fixes
hannahbaumann May 29, 2026
cc96b78
more fixes
hannahbaumann May 29, 2026
7e9f804
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 29, 2026
2d126b7
Apply suggestion from @hannahbaumann
hannahbaumann May 29, 2026
030ac30
Update cc metrics
hannahbaumann Jun 1, 2026
7c73c45
Small fix
hannahbaumann Jun 1, 2026
67d7c42
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 1, 2026
594a498
Small fix2
hannahbaumann Jun 1, 2026
d839374
Add tests for plotting
hannahbaumann Jun 1, 2026
5666bb5
Update cinnabar/plotting.py
hannahbaumann Jun 1, 2026
2318569
Update cinnabar/femap.py
hannahbaumann Jun 1, 2026
3669f55
Update cinnabar/femap.py
hannahbaumann Jun 1, 2026
0d8dbb1
Address review comments
hannahbaumann Jun 1, 2026
082cd6c
Update tests
hannahbaumann Jun 1, 2026
a42ca92
address review coments
hannahbaumann Jun 1, 2026
dade9fd
Fix test
hannahbaumann Jun 1, 2026
3673bca
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 1, 2026
c1ff1e5
Small change
hannahbaumann Jun 1, 2026
a76a12e
Update cinnabar/femap.py
hannahbaumann Jun 2, 2026
fe608b0
Remove missing imports
hannahbaumann Jun 2, 2026
706637b
Apply suggestion from @hannahbaumann
hannahbaumann Jun 3, 2026
f4c6990
Apply suggestion from @hannahbaumann
hannahbaumann Jun 3, 2026
4c8448f
Apply suggestion from @hannahbaumann
hannahbaumann Jun 3, 2026
1838ac4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions cinnabar/femap.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import copy
import math
import itertools
import pathlib
import warnings
Expand Down Expand Up @@ -745,3 +746,117 @@ def draw_graph(self, title: str = "", filename: Union[str, None] = None):
plt.show()
else:
plt.savefig(filename, bbox_inches="tight")

def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float]]:
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
"""
Calculate cycle closure errors for all cycles in the network.

Parameters
----------
max_cycle_length : int, optional
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
Only consider cycles up to this length. Default 5.

Returns
-------
pd.DataFrame
DataFrame with columns 'cycle', 'cc (kcal/mol)', sorted by
cycle closure error descending.
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
"""
network = self.to_legacy_graph()
edge_ddg = {(a, b): d["calc_DDG"] for a, b, d in network.edges(data=True)}
edge_uncertainty = {(a, b): d["calc_dDDG"] for a, b, d in network.edges(data=True)}
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated

# Find all ligand cycles
cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length]

# Loop over cycles, calculate sum of DG along cycle
rows = []
for cycle in cycles:
# Store DDG values along the cycle
sum_ddgs = 0.0
sum_var = 0.0
for inx, ligand in enumerate(cycle):
lig_a = ligand
lig_b = cycle[inx + 1] if inx < len(cycle) - 1 else cycle[0]

# depending on the direction the edge was calculated,
# the sign of the DDG has to change
if (lig_a, lig_b) in edge_ddg:
sum_ddgs += edge_ddg[(lig_a, lig_b)]
sum_var += edge_uncertainty[(lig_a, lig_b)] ** 2
elif (lig_b, lig_a) in edge_ddg:
sum_ddgs -= edge_ddg[(lig_b, lig_a)]
sum_var += edge_uncertainty[(lig_b, lig_a)] ** 2
else:
# Edge missing from network; skip this cycle
break
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated

else:
# Normalize by sqrt(cycle length) to allow comparison across
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have some previously published information of someone using this? This roughly makes sense, but it's not immediately clear to me that it's standard practice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be me in the SepTop paper =)
I think the idea (coming from Eric D.) was that the expected accumulated error scales at sqrt(n) if the errors are considered independent, giving an approximate contribution of a single edge to the cycle closure error.

# different cycle lengths
cc = abs(sum_ddgs / math.sqrt(len(cycle)))
cc_uncertainty_normalized = abs(sum_ddgs) / math.sqrt(sum_var)
rows.append(
{
"cycle": tuple(cycle),
"cc (kcal/mol)": round(cc, 2),
"cc_unc_normalized (kcal/mol)": round(cc_uncertainty_normalized, 2),
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
}
)

df = (
pd.DataFrame(rows, columns=["cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"])
.sort_values("cc (kcal/mol)", ascending=False)
.reset_index(drop=True)
)

return df

def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame:
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
"""
For each edge, report how many cycles it appears in and
the mean and max cycle closure error of those cycles.
Edges with high mean closure error across many cycles are
likely candidates for re-simulation.
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated

Parameters
----------
max_cycle_length : int, optional
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
Only consider cycles up to this length. Defaults to 5.

Returns
-------
pd.DataFrame
DataFrame with columns 'ligandA', 'ligandB', 'n_cycles',
'mean_cc (kcal/mol)', 'max_cc (kcal/mol)', sorted by
mean cycle closure error descending.
"""
from collections import defaultdict

cc_df = self.get_cycle_closure(max_cycle_length=max_cycle_length)
network = self.to_legacy_graph()
edge_ddg = {(a, b): d["calc_DDG"] for a, b, d in network.edges(data=True)}

edge_cycles: dict[tuple, list[float]] = defaultdict(list)
for _, row in cc_df.iterrows():
cycle = list(row["cycle"])
cc = row["cc (kcal/mol)"]
for i, lig in enumerate(cycle):
lig_a = lig
lig_b = cycle[i + 1] if i < len(cycle) - 1 else cycle[0]
edge = (lig_a, lig_b) if (lig_a, lig_b) in edge_ddg else (lig_b, lig_a)
edge_cycles[edge].append(cc)

rows = []
for (a, b), ccs in edge_cycles.items():
rows.append(
{
"ligandA": a,
"ligandB": b,
"n_cycles": len(ccs),
"mean_cc (kcal/mol)": round(sum(ccs) / len(ccs), 3),
"max_cc (kcal/mol)": round(max(ccs), 3),
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
}
)

return pd.DataFrame(rows).sort_values("mean_cc (kcal/mol)", ascending=False).reset_index(drop=True)
43 changes: 43 additions & 0 deletions cinnabar/plotting.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import itertools
import warnings
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
from typing import Any, Literal, Optional, Union

import matplotlib.pylab as plt
Expand Down Expand Up @@ -893,3 +894,45 @@ def ecdf_plot_all_DDGs(
**kwargs,
)
return fig


def plot_cycle_closure(
fe_map: FEMap,
filename: Optional[str] = None,
Comment thread
IAlibay marked this conversation as resolved.
Outdated
max_cycle_length: int = 5,
) -> plt.Figure:
Comment thread
hannahbaumann marked this conversation as resolved.
"""
Plot a histogram of cycle closure errors.

Parameters
----------
fe_map : FEMap
FEMap object containing the calculated edges.
filename : str, optional
If provided, the plot will be saved to this filename.
max_cycle_length : int, optional
Only consider cycles up to this length. Defaults to 5.
The matplotlib Figure object, which can be edited further.
Comment thread
IAlibay marked this conversation as resolved.
Outdated
"""
df = fe_map.get_cycle_closure(max_cycle_length=max_cycle_length)
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated

if df.empty:
warnings.warn("No cycles found; skipping plot.")
return

errors = df["cc (kcal/mol)"]
n_cycles = len(errors)

fig, ax = plt.subplots(figsize=(5, 4))
ax.hist(errors, bins="auto", alpha=0.6, color="steelblue")
Comment thread
hannahbaumann marked this conversation as resolved.
Outdated
ax.set_xlabel(r"Cycle closure (kcal mol$^{-1}$)")
ax.set_ylabel("Count")
ax.set_title(f"Cycle closure distribution (n={n_cycles})")
fig.tight_layout()

if filename is None:
plt.show()
else:
fig.savefig(filename, bbox_inches="tight", dpi=300)

return fig
42 changes: 42 additions & 0 deletions cinnabar/tests/test_femap.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import math

import matplotlib.pyplot as plt
import networkx as nx
Expand Down Expand Up @@ -659,3 +660,44 @@ def test_missing_estimator_metadata(example_map):
with pytest.raises(KeyError, match="No estimator metadata stored for source test."):
example_map.generate_absolute_values()
example_map.get_estimator_metadata("test")


@pytest.fixture()
def perfect_cycle():
"""A perfect cycle with zero cycle closure."""
kcalpm = unit.kilocalorie_per_mole
fe = femap.FEMap()
fe.add_relative_calculation("A", "B", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm)
fe.add_relative_calculation("B", "C", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm)
fe.add_relative_calculation("C", "A", value=-2.0 * kcalpm, uncertainty=0.1 * kcalpm)
return fe


def test_get_cycle_closure_known_value(perfect_cycle):
result = perfect_cycle.get_cycle_closure()
assert isinstance(result, pd.DataFrame)
assert list(result.columns) == ["cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"]
assert len(result) == 1
assert result["cc (kcal/mol)"].iloc[0] == pytest.approx(0.0, abs=1e-6)
assert result["cc_unc_normalized (kcal/mol)"].iloc[0] == pytest.approx(0.0, abs=1e-6)


def test_get_cycle_closure_normalized_known_value():
kcalpm = unit.kilocalorie_per_mole
fe = femap.FEMap()
fe.add_relative_calculation("A", "B", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm)
fe.add_relative_calculation("B", "C", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm)
fe.add_relative_calculation("C", "A", value=-1.5 * kcalpm, uncertainty=0.1 * kcalpm)

result = fe.get_cycle_closure()
expected_cc_normalized = round(abs(0.5) / math.sqrt(3 * 0.1**2), 2)

assert result["cc_unc_normalized (kcal/mol)"].iloc[0] == pytest.approx(expected_cc_normalized, abs=0.01)


def test_get_cc_based_edge_statistics_known_value(perfect_cycle):
result = perfect_cycle.get_cc_based_edge_statistics()
assert len(result) == 3
assert (result["n_cycles"] == 1).all()
assert (result["mean_cc (kcal/mol)"] == 0.0).all()
assert (result["max_cc (kcal/mol)"] == 0.0).all()
31 changes: 31 additions & 0 deletions cinnabar/tests/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,34 @@ def test_plot_ecdf_dgs_no_data(graph):
match="Graph with label test has nodes with missing calculated DG values, which should be stored as `calc_DG`.",
):
plotting.ecdf_plot_DGs([graph], labels=["test"], filename=None)


def test_plot_cycle_closure(fe_map, tmp_path):
Comment thread
jthorton marked this conversation as resolved.
output_file = tmp_path / "cycle_closure.png"
fig = plotting.plot_cycle_closure(fe_map, filename=str(output_file))
assert fig is not None
axes = fig.get_axes()[0]
assert axes.get_xlabel() == r"Cycle closure (kcal mol$^{-1}$)"
assert axes.get_ylabel() == "Count"
assert output_file.exists()


def test_plot_cycle_closure_show(fe_map, show_called):
_ = plotting.plot_cycle_closure(fe_map, filename=None)
assert "show" in show_called


def test_plot_cycle_closure_no_cycles_no_plot(tmp_path):
fe = FEMap()
fe.add_relative_calculation(
"A",
"B",
value=1.0 * unit.kilocalorie_per_mole,
uncertainty=0.1 * unit.kilocalorie_per_mole,
)
assert fe.get_cycle_closure().empty
output_file = tmp_path / "cycle_closure.png"
with pytest.warns(UserWarning, match="No cycles found"):
fig = plotting.plot_cycle_closure(fe, filename=str(output_file))
assert fig is None
assert not output_file.exists()