From 2276ea7c7dd04b0e5f0fa9491f23c898bd6295a0 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 6 Nov 2023 13:16:50 +0100 Subject: [PATCH 01/48] Add functions to calculate cycle closure --- cinnabar/femap.py | 306 +++++++++++----------------------------------- 1 file changed, 70 insertions(+), 236 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index a403a6f1..ba017a49 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -2,10 +2,9 @@ from typing import Union import openff.units -import pandas as pd from openff.units import unit import warnings -from typing import Optional, Hashable, Union +from typing import Optional import matplotlib.pyplot as plt import networkx as nx @@ -17,20 +16,6 @@ def read_csv(filepath: pathlib.Path, units: Optional[openff.units.Quantity] = None) -> dict: - """Read a legacy arsenic format csv file - - Parameters - ---------- - filepath - path to the csv file - units : openff.units.Quantity, optional - the units to use for values in the file, defaults to kcal/mol - - Returns - ------- - raw_results : dict - a dict with Experimental and Calculated keys - """ if units is None: warnings.warn("Assuming kcal/mol units on measurements") units = _kcalpm @@ -136,192 +121,6 @@ def add_measurement(self, measurement: Measurement): self.graph.add_edge(measurement.labelA, measurement.labelB, **d) self.graph.add_edge(measurement.labelB, measurement.labelA, **d_backwards) - def add_experimental_measurement(self, - label: Union[str, Hashable], - value: openff.units.Quantity, - uncertainty: openff.units.Quantity, - *, - source: str = "", - temperature=298.15 * unit.kelvin, - ): - """Add a single experimental measurement - - Parameters - ---------- - label - the ligand being measured - value : openff.units.Quantity - the measured value, as either Ki, IC50, kcal/mol, or kJ/mol. The type - of input is determined by the units of the input. - uncertainty : openff.units.Quantity - the uncertainty in the measurement - source : str, optional - an identifier for the source of the data - temperature : openff.units.Quantity, optional - the temperature the measurement was taken at, defaults to 298.15 K - """ - if not isinstance(value, openff.units.Quantity): - raise ValueError("Must include units with values, " - "e.g. openff.units.unit.kilocalorie_per_mole") - - if value.is_compatible_with('molar'): - m = Measurement.from_experiment(label, value, uncertainty, - source=source, temperature=temperature) - else: # value.is_compatible_with('kilocalorie_per_mole'): - m = Measurement( - labelA=ReferenceState(), - labelB=label, - DG=value, - uncertainty=uncertainty, - source=source, temperature=temperature, - computational=False, - ) - - self.add_measurement(m) - - def add_relative_calculation(self, - labelA: Union[str, Hashable], - labelB: Union[str, Hashable], - value: openff.units.Quantity, - uncertainty: openff.units.Quantity, - *, - source: str = "", - temperature=298.15 * unit.kelvin, - ): - """Add a single RBFE calculation - - Parameters - ---------- - labelA, labelB - the ligands being measured. The measurement is taken from ligandA - to ligandB, i.e. ligandA is the "old" or lambda=0.0 state, and ligandB - is the "new" or lambda=1.0 state. - value : openff.units.Quantity - the measured DDG value, as kcal/mol, or kJ/mol. - uncertainty : openff.units.Quantity - the uncertainty in the measurement - source : str, optional - an identifier for the source of the data - temperature : openff.units.Quantity, optional - the temperature the measurement was taken at, defaults to 298.15 K - """ - self.add_measurement( - Measurement( - labelA=labelA, - labelB=labelB, - DG=value, - uncertainty=uncertainty, - source=source, - temperature=temperature, - computational=True, - ) - ) - - def add_absolute_calculation(self, - label, - value: openff.units.Quantity, - uncertainty: openff.units.Quantity, - *, - source: str = "", - temperature=298.15 * unit.kelvin, - ): - """Add a single ABFE calculation - - Parameters - ---------- - label - the ligand being measured - value : openff.units.Quantity - the measured value, as kcal/mol, or kJ/mol. - uncertainty : openff.units.Quantity - the uncertainty in the measurement - source : str, optional - an identifier for the source of the data - temperature : openff.units.Quantity, optional - the temperature the measurement was taken at, defaults to 298.15 K - """ - m = Measurement( - labelA=ReferenceState(), - labelB=label, - DG=value, uncertainty=uncertainty, - source=source, temperature=temperature, - computational=True, - ) - self.add_measurement(m) - - def get_relative_dataframe(self) -> pd.DataFrame: - """Gets a dataframe of all relative results - - The pandas DataFrame will have the following columns: - - labelA - - labelB - - DDG - - uncertainty - - source - - computational - """ - kcpm = unit.kilocalorie_per_mole - data = [] - for l1, l2, d in self.graph.edges(data=True): - if d['source'] == 'reverse': - continue - if isinstance(l1, ReferenceState) or isinstance(l2, ReferenceState): - continue - - data.append(( - l1, l2, - d['DG'].to(kcpm).m, d['uncertainty'].to(kcpm).m, - d['source'], d['computational'] - )) - - cols = [ - 'labelA', 'labelB', - 'DDG (kcal/mol)', 'uncertainty (kcal/mol)', - 'source', 'computational' - ] - - return pd.DataFrame( - data=data, - columns=cols, - ) - - def get_absolute_dataframe(self) -> pd.DataFrame: - """Get a dataframe of all absolute results - - The dataframe will have the following columns: - - label - - DG - - uncertainty - - source - - computational - """ - kcpm = unit.kilocalorie_per_mole - data = [] - for l1, l2, d in self.graph.edges(data=True): - if d['source'] == 'reverse': - continue - if not isinstance(l1, ReferenceState): - continue - if isinstance(l2, ReferenceState): - continue - - data.append(( - l2, - d['DG'].to(kcpm).m, d['uncertainty'].to(kcpm).m, - d['source'], d['computational'] - )) - - cols = [ - 'label', - 'DG (kcal/mol)', 'uncertainty (kcal/mol)', - 'source', 'computational' - ] - - return pd.DataFrame( - data=data, - columns=cols, - ) - @property def n_measurements(self) -> int: """Total number of both experimental and computational measurements""" @@ -330,14 +129,8 @@ def n_measurements(self) -> int: @property def n_ligands(self) -> int: """Total number of unique ligands""" - return len(self.ligands) - - @property - def ligands(self) -> list: - """All ligands in the graph""" # must ignore ReferenceState nodes - return [n for n in self.graph.nodes - if not isinstance(n, ReferenceState)] + return sum(1 for n in self.graph.nodes if not isinstance(n, ReferenceState)) @property def degree(self) -> float: @@ -392,33 +185,6 @@ def generate_absolute_values(self): ) ) - # find all computational result labels - comp_ligands = set() - for A, B, d in self.graph.edges(data=True): - if not d['computational']: - continue - comp_ligands.add(A) - comp_ligands.add(B) - - # find corresponding experimental results - - # use mean of experimental results to offset MLE reference point - - # add connection to MLE reference state and true reference state - self.add_measurement( - Measurement( - labelA=ReferenceState(), - labelB=g, - DG=0.1*u, - uncertainty=0.0 * u, - computational=True, - source='MLE', - ) - ) - else: - # TODO: This can eventually be worked around surely? - raise ValueError("Computational results are not fully connected") - def to_legacy_graph(self) -> nx.DiGraph: """Produce single graph version of this FEMap @@ -491,3 +257,71 @@ 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): + """Calculate the sum of DDG along all ligand cycles as a measure of convergence.""" + network = self.to_legacy_graph() + y = [x[2]["calc_DDG"] for x in network.edges(data=True)] + + # Find all ligand cycles + cycles = sorted(nx.simple_cycles(network.to_undirected())) + edges = network.edges + + # Loop over cycles, calculate sum of DG along cycle + dict = {} + for cycle in cycles: + + # Store DDG values along the cycle + sum_ddgs = 0 + for inx, ligand in enumerate(cycle): + if inx < len(cycle) - 1: + ligA = ligand + ligB = cycle[inx + 1] + # Last ligand is connected to first ligand + else: + ligA = ligand + ligB = cycle[0] + # depending on the direction the edge was calculated, + # the sign of the DDG has to change + if (ligA, ligB) in list(edges): + ddg = y[list(edges).index((ligA, ligB))] + elif (ligB, ligA) in edges: + ddg = -y[list(edges).index((ligB, ligA))] + # sum up DDGs along cycle + sum_ddgs += ddg + + # divide by sqrt of number of ligands in the cycle + # to get cycle closure error PER EDGE + cc = abs(sum_ddgs / math.sqrt(len(cycle))) + # Store cycle and cycle closure in dict + dict[','.join(cycle)] = round(cc, 2) + + # Sort cycle closure from high to low + sorted_list = sorted(dict.items(), key=lambda x: x[1], reverse=True) + + return sorted_list + + def store_cycle_closure_to_csv(self, file='cycle_closure.csv'): + """Save cycle closure, sorted from highest cycle closure to lowest + in a csv file.""" + sorted_list = get_cycle_closure(self) + + # CSV file to store results + f = open(file, 'w') + writer = csv.writer(f, lineterminator='\n') + header = '# ligands in cycle, sum(DDGs) / ' \ + 'sqrt(number of ligands in cycle)\n' + f.write(header) + writer.writerows(sorted_list) + f.close() + + return + + def plot_hist_cycle_closure(self, file='cycle_closure_hist.png'): + + sorted_list = get_cycle_closure(self) + + plt.hist([s[1] for s in sorted_list]) + plt.xlabel('Cycle closure in kcal/mol') + plt.savefig(file, bbox_inches="tight") \ No newline at end of file From 32338ff566249185a3440c74e674e0fd586e0d10 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 6 Nov 2023 13:31:42 +0100 Subject: [PATCH 02/48] Add recent changes --- cinnabar/femap.py | 238 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 2 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index ba017a49..cd86aca2 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -2,9 +2,10 @@ from typing import Union import openff.units +import pandas as pd from openff.units import unit import warnings -from typing import Optional +from typing import Optional, Hashable, Union import matplotlib.pyplot as plt import networkx as nx @@ -16,6 +17,20 @@ def read_csv(filepath: pathlib.Path, units: Optional[openff.units.Quantity] = None) -> dict: + """Read a legacy arsenic format csv file + + Parameters + ---------- + filepath + path to the csv file + units : openff.units.Quantity, optional + the units to use for values in the file, defaults to kcal/mol + + Returns + ------- + raw_results : dict + a dict with Experimental and Calculated keys + """ if units is None: warnings.warn("Assuming kcal/mol units on measurements") units = _kcalpm @@ -121,6 +136,192 @@ def add_measurement(self, measurement: Measurement): self.graph.add_edge(measurement.labelA, measurement.labelB, **d) self.graph.add_edge(measurement.labelB, measurement.labelA, **d_backwards) + def add_experimental_measurement(self, + label: Union[str, Hashable], + value: openff.units.Quantity, + uncertainty: openff.units.Quantity, + *, + source: str = "", + temperature=298.15 * unit.kelvin, + ): + """Add a single experimental measurement + + Parameters + ---------- + label + the ligand being measured + value : openff.units.Quantity + the measured value, as either Ki, IC50, kcal/mol, or kJ/mol. The type + of input is determined by the units of the input. + uncertainty : openff.units.Quantity + the uncertainty in the measurement + source : str, optional + an identifier for the source of the data + temperature : openff.units.Quantity, optional + the temperature the measurement was taken at, defaults to 298.15 K + """ + if not isinstance(value, openff.units.Quantity): + raise ValueError("Must include units with values, " + "e.g. openff.units.unit.kilocalorie_per_mole") + + if value.is_compatible_with('molar'): + m = Measurement.from_experiment(label, value, uncertainty, + source=source, temperature=temperature) + else: # value.is_compatible_with('kilocalorie_per_mole'): + m = Measurement( + labelA=ReferenceState(), + labelB=label, + DG=value, + uncertainty=uncertainty, + source=source, temperature=temperature, + computational=False, + ) + + self.add_measurement(m) + + def add_relative_calculation(self, + labelA: Union[str, Hashable], + labelB: Union[str, Hashable], + value: openff.units.Quantity, + uncertainty: openff.units.Quantity, + *, + source: str = "", + temperature=298.15 * unit.kelvin, + ): + """Add a single RBFE calculation + + Parameters + ---------- + labelA, labelB + the ligands being measured. The measurement is taken from ligandA + to ligandB, i.e. ligandA is the "old" or lambda=0.0 state, and ligandB + is the "new" or lambda=1.0 state. + value : openff.units.Quantity + the measured DDG value, as kcal/mol, or kJ/mol. + uncertainty : openff.units.Quantity + the uncertainty in the measurement + source : str, optional + an identifier for the source of the data + temperature : openff.units.Quantity, optional + the temperature the measurement was taken at, defaults to 298.15 K + """ + self.add_measurement( + Measurement( + labelA=labelA, + labelB=labelB, + DG=value, + uncertainty=uncertainty, + source=source, + temperature=temperature, + computational=True, + ) + ) + + def add_absolute_calculation(self, + label, + value: openff.units.Quantity, + uncertainty: openff.units.Quantity, + *, + source: str = "", + temperature=298.15 * unit.kelvin, + ): + """Add a single ABFE calculation + + Parameters + ---------- + label + the ligand being measured + value : openff.units.Quantity + the measured value, as kcal/mol, or kJ/mol. + uncertainty : openff.units.Quantity + the uncertainty in the measurement + source : str, optional + an identifier for the source of the data + temperature : openff.units.Quantity, optional + the temperature the measurement was taken at, defaults to 298.15 K + """ + m = Measurement( + labelA=ReferenceState(), + labelB=label, + DG=value, uncertainty=uncertainty, + source=source, temperature=temperature, + computational=True, + ) + self.add_measurement(m) + + def get_relative_dataframe(self) -> pd.DataFrame: + """Gets a dataframe of all relative results + + The pandas DataFrame will have the following columns: + - labelA + - labelB + - DDG + - uncertainty + - source + - computational + """ + kcpm = unit.kilocalorie_per_mole + data = [] + for l1, l2, d in self.graph.edges(data=True): + if d['source'] == 'reverse': + continue + if isinstance(l1, ReferenceState) or isinstance(l2, ReferenceState): + continue + + data.append(( + l1, l2, + d['DG'].to(kcpm).m, d['uncertainty'].to(kcpm).m, + d['source'], d['computational'] + )) + + cols = [ + 'labelA', 'labelB', + 'DDG (kcal/mol)', 'uncertainty (kcal/mol)', + 'source', 'computational' + ] + + return pd.DataFrame( + data=data, + columns=cols, + ) + + def get_absolute_dataframe(self) -> pd.DataFrame: + """Get a dataframe of all absolute results + + The dataframe will have the following columns: + - label + - DG + - uncertainty + - source + - computational + """ + kcpm = unit.kilocalorie_per_mole + data = [] + for l1, l2, d in self.graph.edges(data=True): + if d['source'] == 'reverse': + continue + if not isinstance(l1, ReferenceState): + continue + if isinstance(l2, ReferenceState): + continue + + data.append(( + l2, + d['DG'].to(kcpm).m, d['uncertainty'].to(kcpm).m, + d['source'], d['computational'] + )) + + cols = [ + 'label', + 'DG (kcal/mol)', 'uncertainty (kcal/mol)', + 'source', 'computational' + ] + + return pd.DataFrame( + data=data, + columns=cols, + ) + @property def n_measurements(self) -> int: """Total number of both experimental and computational measurements""" @@ -129,8 +330,14 @@ def n_measurements(self) -> int: @property def n_ligands(self) -> int: """Total number of unique ligands""" + return len(self.ligands) + + @property + def ligands(self) -> list: + """All ligands in the graph""" # must ignore ReferenceState nodes - return sum(1 for n in self.graph.nodes if not isinstance(n, ReferenceState)) + return [n for n in self.graph.nodes + if not isinstance(n, ReferenceState)] @property def degree(self) -> float: @@ -185,6 +392,33 @@ def generate_absolute_values(self): ) ) + # find all computational result labels + comp_ligands = set() + for A, B, d in self.graph.edges(data=True): + if not d['computational']: + continue + comp_ligands.add(A) + comp_ligands.add(B) + + # find corresponding experimental results + + # use mean of experimental results to offset MLE reference point + + # add connection to MLE reference state and true reference state + self.add_measurement( + Measurement( + labelA=ReferenceState(), + labelB=g, + DG=0.1 * u, + uncertainty=0.0 * u, + computational=True, + source='MLE', + ) + ) + else: + # TODO: This can eventually be worked around surely? + raise ValueError("Computational results are not fully connected") + def to_legacy_graph(self) -> nx.DiGraph: """Produce single graph version of this FEMap From ec1d0083f44cc3788d9732777cb78a34020474df Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 6 Nov 2023 13:33:49 +0100 Subject: [PATCH 03/48] small fixes --- cinnabar/femap.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index cd86aca2..c8513038 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -22,14 +22,14 @@ def read_csv(filepath: pathlib.Path, units: Optional[openff.units.Quantity] = No Parameters ---------- filepath - path to the csv file - units : openff.units.Quantity, optional - the units to use for values in the file, defaults to kcal/mol + path to the csv file + units : openff.units.Quantity, optional + the units to use for values in the file, defaults to kcal/mol Returns ------- raw_results : dict - a dict with Experimental and Calculated keys + a dict with Experimental and Calculated keys """ if units is None: warnings.warn("Assuming kcal/mol units on measurements") @@ -409,7 +409,7 @@ def generate_absolute_values(self): Measurement( labelA=ReferenceState(), labelB=g, - DG=0.1 * u, + DG=0.1*u, uncertainty=0.0 * u, computational=True, source='MLE', From e48668a427ab45d2855f455563e9da6b388280ad Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 6 Nov 2023 13:35:11 +0100 Subject: [PATCH 04/48] small fixes 2 --- cinnabar/femap.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index c8513038..221c147b 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -492,7 +492,6 @@ def draw_graph(self, title: str = "", filename: Union[str, None] = None): else: plt.savefig(filename, bbox_inches="tight") - def get_cycle_closure(self): """Calculate the sum of DDG along all ligand cycles as a measure of convergence.""" network = self.to_legacy_graph() @@ -558,4 +557,4 @@ def plot_hist_cycle_closure(self, file='cycle_closure_hist.png'): plt.hist([s[1] for s in sorted_list]) plt.xlabel('Cycle closure in kcal/mol') - plt.savefig(file, bbox_inches="tight") \ No newline at end of file + plt.savefig(file, bbox_inches="tight") From 5edf55e54c433b57108111f83a040f00e313640e Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 15:22:27 +0200 Subject: [PATCH 05/48] Larger update of the cycle closure function --- cinnabar/femap.py | 195 +++++++++++++++++++++++++++++++------------ cinnabar/plotting.py | 47 ++++++++++- 2 files changed, 186 insertions(+), 56 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 221c147b..51653446 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1,4 +1,6 @@ import pathlib +import csv +import math from typing import Union import openff.units @@ -492,69 +494,152 @@ def draw_graph(self, title: str = "", filename: Union[str, None] = None): else: plt.savefig(filename, bbox_inches="tight") - def get_cycle_closure(self): - """Calculate the sum of DDG along all ligand cycles as a measure of convergence.""" + # def draw_graph(self, title: str = "", filename: Union[str, None] = None, + # highlight_edges=None): + # """ + # Draw the FEMap network graph. + # + # Parameters + # ---------- + # title : str, optional + # Title for the plot. + # filename : str, optional + # Path to save the figure to. If None, displays interactively. + # highlight_edges : list of (str, str) tuples, optional + # Edges to highlight in red, e.g. from get_edge_statistics(). + # """ + # fig, ax = plt.subplots(figsize=(10, 10)) + # graph = self.to_legacy_graph() + # labels = {n: n for n in graph.nodes} + # + # highlight_set = set() + # if highlight_edges: + # for a, b in highlight_edges: + # highlight_set.add((a, b)) + # highlight_set.add((b, a)) + # + # edge_colors = [ + # "red" if (a, b) in highlight_set else "grey" + # for a, b in graph.edges() + # ] + # edge_widths = [ + # 2.5 if (a, b) in highlight_set else 1.0 + # for a, b in graph.edges() + # ] + # + # + # nx.draw_circular(graph, labels=labels, node_color="hotpink", + # node_size=250, edge_color=edge_colors, + # width=edge_widths, ax=ax) + # long_title = f"{title} \n Nedges={self.n_edges} \n Nligands={self.n_ligands} \n Degree={self.degree:.2f}" + # ax.set_title(long_title) + # + # if filename is None: + # plt.show() + # else: + # fig.savefig(filename, bbox_inches="tight", dpi=150) + # plt.close(fig) + + def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float]]: + """ + Calculate cycle closure errors for all cycles in the network. + + Parameters + ---------- + max_cycle_length : int, optional + 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. + """ network = self.to_legacy_graph() - y = [x[2]["calc_DDG"] for x in network.edges(data=True)] + edge_ddg = {(a, b): d["calc_DDG"] for a, b, d in + network.edges(data=True)} # Find all ligand cycles - cycles = sorted(nx.simple_cycles(network.to_undirected())) - edges = network.edges + 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 - dict = {} + rows = [] for cycle in cycles: # Store DDG values along the cycle - sum_ddgs = 0 + sum_ddgs = 0.0 for inx, ligand in enumerate(cycle): - if inx < len(cycle) - 1: - ligA = ligand - ligB = cycle[inx + 1] - # Last ligand is connected to first ligand - else: - ligA = ligand - ligB = cycle[0] + 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 (ligA, ligB) in list(edges): - ddg = y[list(edges).index((ligA, ligB))] - elif (ligB, ligA) in edges: - ddg = -y[list(edges).index((ligB, ligA))] - # sum up DDGs along cycle - sum_ddgs += ddg - - # divide by sqrt of number of ligands in the cycle - # to get cycle closure error PER EDGE - cc = abs(sum_ddgs / math.sqrt(len(cycle))) - # Store cycle and cycle closure in dict - dict[','.join(cycle)] = round(cc, 2) - - # Sort cycle closure from high to low - sorted_list = sorted(dict.items(), key=lambda x: x[1], reverse=True) - - return sorted_list - - def store_cycle_closure_to_csv(self, file='cycle_closure.csv'): - """Save cycle closure, sorted from highest cycle closure to lowest - in a csv file.""" - sorted_list = get_cycle_closure(self) - - # CSV file to store results - f = open(file, 'w') - writer = csv.writer(f, lineterminator='\n') - header = '# ligands in cycle, sum(DDGs) / ' \ - 'sqrt(number of ligands in cycle)\n' - f.write(header) - writer.writerows(sorted_list) - f.close() - - return - - def plot_hist_cycle_closure(self, file='cycle_closure_hist.png'): - - sorted_list = get_cycle_closure(self) - - plt.hist([s[1] for s in sorted_list]) - plt.xlabel('Cycle closure in kcal/mol') - plt.savefig(file, bbox_inches="tight") + if (lig_a, lig_b) in edge_ddg: + sum_ddgs += edge_ddg[(lig_a, lig_b)] + elif (lig_b, lig_a) in edge_ddg: + sum_ddgs -= edge_ddg[(lig_b, lig_a)] + else: + # Edge missing from network; skip this cycle + break + + else: + # divide by sqrt of number of ligands in the cycle + # to get cycle closure error per edge + cc = abs(sum_ddgs / math.sqrt(len(cycle))) + rows.append({"cycle": tuple(cycle), "cc (kcal/mol)": round(cc, 2)}) + + return pd.DataFrame(rows).sort_values("cc (kcal/mol)", ascending=False).reset_index(drop=True) + + def get_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: + """ + 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. + + Parameters + ---------- + max_cycle_length : int, optional + 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), + }) + + return pd.DataFrame(rows).sort_values("mean_cc (kcal/mol)", + ascending=False).reset_index( + drop=True) \ No newline at end of file diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index a782e6d4..62943e85 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -4,7 +4,7 @@ import numpy as np import networkx as nx from adjustText import adjust_text -from . import plotlying, stats +from . import plotlying, stats, FEMap def _master_plot( @@ -572,3 +572,48 @@ def plot_all_DDGs( statistic_type=statistic_type, **kwargs, ) + +def plot_cycle_closure( + fe_map: FEMap, + filename: Optional[str] = None, + max_cycle_length: int = 5, +) -> plt.Figure: + """ + 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. + + Returns + ------- + plt.Figure + The matplotlib Figure object, which can be edited further. + """ + df = fe_map.get_cycle_closure(max_cycle_length=max_cycle_length) + + 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") + 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 From 31477ceeb5cdc0336a22a74c77b2abe11aa90f27 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 15:30:03 +0200 Subject: [PATCH 06/48] Small fix --- cinnabar/femap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index c33911f6..d3a7c049 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1,5 +1,6 @@ """ FEMap +===== The workhorse of cinnabar, a :class:`FEMap` contains many measurements of free energy differences, both relative and absolute, From 6b2dfd05b7ac96eade5ba1c1a80e5cf2dc1c57c5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 13:31:20 +0000 Subject: [PATCH 07/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 39 ++++++++++++++++----------------------- cinnabar/plotting.py | 5 +++-- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index d3a7c049..705e2487 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -8,12 +8,12 @@ """ import copy +import math import pathlib import warnings from dataclasses import asdict from typing import TYPE_CHECKING, Hashable, Optional, Union -import math import matplotlib.pyplot as plt import networkx as nx import numpy as np @@ -719,19 +719,14 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] cycle closure error descending. """ network = self.to_legacy_graph() - edge_ddg = {(a, b): d["calc_DDG"] for a, b, d in - network.edges(data=True)} + edge_ddg = {(a, b): d["calc_DDG"] for a, b, d in network.edges(data=True)} # Find all ligand cycles - cycles = [ - c for c in nx.simple_cycles(network.to_undirected()) - if len(c) <= max_cycle_length - ] + 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 for inx, ligand in enumerate(cycle): @@ -779,8 +774,7 @@ def get_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: 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_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(): @@ -789,20 +783,19 @@ def get_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: 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 = (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), - }) - - return pd.DataFrame(rows).sort_values("mean_cc (kcal/mol)", - ascending=False).reset_index( - drop=True) \ No newline at end of file + 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), + } + ) + + return pd.DataFrame(rows).sort_values("mean_cc (kcal/mol)", ascending=False).reset_index(drop=True) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index bd0a6abb..adea8217 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -893,7 +893,8 @@ def ecdf_plot_all_DDGs( **kwargs, ) return fig - + + def plot_cycle_closure( fe_map: FEMap, filename: Optional[str] = None, @@ -932,5 +933,5 @@ def plot_cycle_closure( plt.show() else: fig.savefig(filename, bbox_inches="tight", dpi=300) - + return fig From 0b8329f319db505f41ab683cb2a5e00c1a5f11e0 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 15:53:02 +0200 Subject: [PATCH 08/48] Rename function --- cinnabar/femap.py | 48 +---------------------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 705e2487..6bcca97d 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -657,52 +657,6 @@ def draw_graph(self, title: str = "", filename: Union[str, None] = None): else: plt.savefig(filename, bbox_inches="tight") - # def draw_graph(self, title: str = "", filename: Union[str, None] = None, - # highlight_edges=None): - # """ - # Draw the FEMap network graph. - # - # Parameters - # ---------- - # title : str, optional - # Title for the plot. - # filename : str, optional - # Path to save the figure to. If None, displays interactively. - # highlight_edges : list of (str, str) tuples, optional - # Edges to highlight in red, e.g. from get_edge_statistics(). - # """ - # fig, ax = plt.subplots(figsize=(10, 10)) - # graph = self.to_legacy_graph() - # labels = {n: n for n in graph.nodes} - # - # highlight_set = set() - # if highlight_edges: - # for a, b in highlight_edges: - # highlight_set.add((a, b)) - # highlight_set.add((b, a)) - # - # edge_colors = [ - # "red" if (a, b) in highlight_set else "grey" - # for a, b in graph.edges() - # ] - # edge_widths = [ - # 2.5 if (a, b) in highlight_set else 1.0 - # for a, b in graph.edges() - # ] - # - # - # nx.draw_circular(graph, labels=labels, node_color="hotpink", - # node_size=250, edge_color=edge_colors, - # width=edge_widths, ax=ax) - # long_title = f"{title} \n Nedges={self.n_edges} \n Nligands={self.n_ligands} \n Degree={self.degree:.2f}" - # ax.set_title(long_title) - # - # if filename is None: - # plt.show() - # else: - # fig.savefig(filename, bbox_inches="tight", dpi=150) - # plt.close(fig) - def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float]]: """ Calculate cycle closure errors for all cycles in the network. @@ -751,7 +705,7 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] return pd.DataFrame(rows).sort_values("cc (kcal/mol)", ascending=False).reset_index(drop=True) - def get_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: + def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: """ For each edge, report how many cycles it appears in and the mean and max cycle closure error of those cycles. From 675184a0c9f784c58c2b90ca881fbfe96688bd7c Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 16:08:15 +0200 Subject: [PATCH 09/48] Add cycle closure test --- cinnabar/tests/test_femap.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index c3e919b1..89ad170f 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -526,3 +526,17 @@ 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") + + +def test_get_cycle_closure_known_value(): + """Perfect cycle should have closure 0.""" + kcalpm = unit.kilocalorie_per_mole + fe = 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) + result = fe.get_cycle_closure() + assert isinstance(result, pd.DataFrame) + assert list(result.columns) == ["cycle", "cc (kcal/mol)"] + assert len(result) == 1 + assert result["cc (kcal/mol)"].iloc[0] == pytest.approx(0.0, abs=1e-6) From bbbca3f54d3e11a049bd7fd6e21503f7cb56ebf3 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 16:13:19 +0200 Subject: [PATCH 10/48] Add test for per edge cc df --- cinnabar/tests/test_femap.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 89ad170f..62092a6a 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -528,15 +528,28 @@ def test_missing_estimator_metadata(example_map): example_map.get_estimator_metadata("test") -def test_get_cycle_closure_known_value(): - """Perfect cycle should have closure 0.""" +@pytest.fixture() +def perfect_cycle(): + """A perfect cycle with zero cycle closure.""" kcalpm = unit.kilocalorie_per_mole fe = 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) - result = fe.get_cycle_closure() + 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)"] assert len(result) == 1 assert result["cc (kcal/mol)"].iloc[0] == pytest.approx(0.0, abs=1e-6) + + +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() From 7f1d209566bf619575e0e6ffcba9b1ac1c1e6b4e Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 16:19:48 +0200 Subject: [PATCH 11/48] Small fix --- cinnabar/tests/test_femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 62092a6a..1cec8df2 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -532,7 +532,7 @@ def test_missing_estimator_metadata(example_map): def perfect_cycle(): """A perfect cycle with zero cycle closure.""" kcalpm = unit.kilocalorie_per_mole - fe = FEMap() + 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) From ddafe13b5b6da9cf1ca871f0bf3ce01996ce3fd3 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 16:31:19 +0200 Subject: [PATCH 12/48] Add missing import --- cinnabar/tests/test_femap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 1cec8df2..4b16a36b 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -3,6 +3,7 @@ import matplotlib.pyplot as plt import networkx as nx import numpy as np +import pandas as pd import pytest from openff.units import unit From 054b3724cda2ff9bfe4c0e25b15a2edca4d47e3f Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 16:38:35 +0200 Subject: [PATCH 13/48] Add test for plot_cycle_closure --- cinnabar/tests/test_plotting.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index 79506582..dea3f71a 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -417,3 +417,18 @@ 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): + 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 From ec405d8e7ecb4e03169c7f8bf545887f8e69aedf Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 19 May 2026 16:53:58 +0200 Subject: [PATCH 14/48] Small fix --- cinnabar/femap.py | 2 +- cinnabar/plotting.py | 1 + cinnabar/tests/test_plotting.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 6bcca97d..06232331 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -703,7 +703,7 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] cc = abs(sum_ddgs / math.sqrt(len(cycle))) rows.append({"cycle": tuple(cycle), "cc (kcal/mol)": round(cc, 2)}) - return pd.DataFrame(rows).sort_values("cc (kcal/mol)", ascending=False).reset_index(drop=True) + return pd.DataFrame(rows, columns=["cycle", "cc (kcal/mol)"]).sort_values("cc (kcal/mol)", ascending=False).reset_index(drop=True) def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: """ diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index adea8217..17232778 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1,4 +1,5 @@ import itertools +import warnings from typing import Any, Literal, Optional, Union import matplotlib.pylab as plt diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index dea3f71a..6f382681 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -432,3 +432,19 @@ def test_plot_cycle_closure(fe_map, tmp_path): 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() From ccd83ee5bc88755eaab45d277d330297c081e583 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 14:55:03 +0000 Subject: [PATCH 15/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 06232331..5cc9fe90 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -703,7 +703,11 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] cc = abs(sum_ddgs / math.sqrt(len(cycle))) rows.append({"cycle": tuple(cycle), "cc (kcal/mol)": round(cc, 2)}) - return pd.DataFrame(rows, columns=["cycle", "cc (kcal/mol)"]).sort_values("cc (kcal/mol)", ascending=False).reset_index(drop=True) + return ( + pd.DataFrame(rows, columns=["cycle", "cc (kcal/mol)"]) + .sort_values("cc (kcal/mol)", ascending=False) + .reset_index(drop=True) + ) def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: """ From 6bbca0a7c5b428cd316855ea5353b725b25bb755 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Wed, 20 May 2026 09:59:20 +0200 Subject: [PATCH 16/48] Merge conflicts --- cinnabar/femap.py | 23 +++++++++++++++-------- cinnabar/tests/test_femap.py | 18 ++++++++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 5cc9fe90..026a9809 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -674,6 +674,7 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] """ 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)} # Find all ligand cycles cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length] @@ -683,6 +684,7 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] 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] @@ -691,23 +693,28 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] # 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 else: - # divide by sqrt of number of ligands in the cycle - # to get cycle closure error per edge + # Normalize by sqrt(cycle length) to allow comparison across + # different cycle lengths cc = abs(sum_ddgs / math.sqrt(len(cycle))) - rows.append({"cycle": tuple(cycle), "cc (kcal/mol)": round(cc, 2)}) + 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), + }) - return ( - pd.DataFrame(rows, columns=["cycle", "cc (kcal/mol)"]) - .sort_values("cc (kcal/mol)", ascending=False) - .reset_index(drop=True) - ) + 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: """ diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 4b16a36b..7d8c1b4e 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -1,5 +1,5 @@ import json - +import math import matplotlib.pyplot as plt import networkx as nx import numpy as np @@ -543,9 +543,23 @@ def perfect_cycle(): 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)"] + 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): From b24329e466b91d11661a11402de14448eb31eb8f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 08:00:43 +0000 Subject: [PATCH 17/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 18 ++++++++++++------ cinnabar/tests/test_femap.py | 1 + 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 026a9809..b1cb995f 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -706,13 +706,19 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] # 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), - }) + rows.append( + { + "cycle": tuple(cycle), + "cc (kcal/mol)": round(cc, 2), + "cc_unc_normalized (kcal/mol)": round(cc_uncertainty_normalized, 2), + } + ) - 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) + 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 diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 7d8c1b4e..1c93a8bb 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -1,5 +1,6 @@ import json import math + import matplotlib.pyplot as plt import networkx as nx import numpy as np From 378ed29c43c20a11eb839915c888aefcf5b207c5 Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Thu, 28 May 2026 14:58:54 +0200 Subject: [PATCH 18/48] Update cinnabar/femap.py Co-authored-by: Josh Horton --- cinnabar/femap.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 60f79174..af5232af 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -814,10 +814,8 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: """ - 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. + For each simulated edge, report how many cycles it appears in and + the mean and max cycle closure error of those cycles per source. Parameters ---------- From 790ce3abc31b3c2ead1ab1d26fc448a370e15e7a Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:16:39 +0200 Subject: [PATCH 19/48] Address review comments 1 --- cinnabar/femap.py | 175 +++++++++++++++++++++-------------- cinnabar/plotting.py | 24 ++++- cinnabar/tests/test_femap.py | 6 +- 3 files changed, 127 insertions(+), 78 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 3c308f3e..088fe472 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -893,7 +893,7 @@ def draw_graph(self, title: str = "", filename: str | None = None): else: plt.savefig(filename, bbox_inches="tight") - def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float]]: + def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> list[tuple[str, float]]: """ Calculate cycle closure errors for all cycles in the network. @@ -904,61 +904,76 @@ def get_cycle_closure(self, max_cycle_length: int = 5) -> list[tuple[str, float] Returns ------- - pd.DataFrame - DataFrame with columns 'cycle', 'cc (kcal/mol)', sorted by - cycle closure error descending. + The pandas DataFrame will have the following columns: + - source + - cycle + - cc (kcal/mol) + - cc_unc_normalized (kcal/mol) + Sorted by source and cycle closure error descending. """ - 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)} - - # Find all ligand cycles - cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length] + df = self.get_relative_dataframe() + comp_df = df[df["computational"]] - # 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 + for source, source_df in comp_df.groupby("source"): + edge_ddg = { + (row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] + for _, row in source_df.iterrows() + } + edge_uncertainty = { + (row["labelA"], row["labelB"]): row["uncertainty (kcal/mol)"] + for _, row in source_df.iterrows() + } + + network = nx.DiGraph() + for a, b in edge_ddg: + network.add_edge(a, b) + + cycles = [ + c for c in nx.simple_cycles(network.to_undirected()) + if len(c) <= max_cycle_length + ] + + for cycle in cycles: + sum_ddgs = 0.0 + sum_var = 0.0 + for i, lig in enumerate(cycle): + lig_a = lig + lig_b = cycle[i + 1] if i < 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 - else: - # Normalize by sqrt(cycle length) to allow comparison across - # different cycle lengths - cc = abs(sum_ddgs / math.sqrt(len(cycle))) - cc_uncertainty_normalized = abs(sum_ddgs) / math.sqrt(sum_var) - rows.append( - { + else: + # Normalize by sqrt(cycle length) to allow comparison across + # different cycle lengths + cc = abs(sum_ddgs / math.sqrt(len(cycle))) + cc_uncertainty_normalized = abs(sum_ddgs) / math.sqrt(sum_var) + rows.append({ + "source": source, "cycle": tuple(cycle), "cc (kcal/mol)": round(cc, 2), - "cc_unc_normalized (kcal/mol)": round(cc_uncertainty_normalized, 2), - } - ) - - df = ( - pd.DataFrame(rows, columns=["cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"]) - .sort_values("cc (kcal/mol)", ascending=False) + "cc_unc_normalized (kcal/mol)": round( + cc_uncertainty_normalized, 2), + }) + + return ( + pd.DataFrame(rows, columns=["source", "cycle", "cc (kcal/mol)", + "cc_unc_normalized (kcal/mol)"]) + .sort_values(["source", "cc (kcal/mol)"], ascending=[True, False]) .reset_index(drop=True) ) - return df - - def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFrame: + def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame: """ For each simulated edge, report how many cycles it appears in and the mean and max cycle closure error of those cycles per source. @@ -970,37 +985,57 @@ def get_cc_based_edge_statistics(self, max_cycle_length: int = 5) -> pd.DataFram 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. + The pandas DataFrame will have the following columns: + - source + - ligandA + - ligandB + - n_cycles + - mean_cc (kcal/mol) + - max_cc (kcal/mol) + + Sorted by source and 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) + cc_df = self.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) + comp_df = self.get_relative_dataframe() + comp_df = comp_df[comp_df["computational"]] + edge_ddg_by_source = { + source: { + (row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] + for _, row in group.iterrows() + } + for source, group in comp_df.groupby("source") + } rows = [] - for (a, b), ccs in edge_cycles.items(): - rows.append( - { + for source, source_cc_df in cc_df.groupby("source"): + edge_ddg = edge_ddg_by_source.get(source, {}) + edge_cycles: dict[tuple, list[float]] = defaultdict(list) + + for _, row in source_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) + + for (a, b), ccs in edge_cycles.items(): + rows.append({ + "source": source, "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), - } - ) + }) + + return ( + pd.DataFrame(rows) + .sort_values(["source", "mean_cc (kcal/mol)"], ascending=[True, False]) + .reset_index(drop=True) + ) - return pd.DataFrame(rows).sort_values("mean_cc (kcal/mol)", ascending=False).reset_index(drop=True) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index 08c25c1c..eace61af 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1010,6 +1010,7 @@ def plot_cycle_closure( fe_map: FEMap, filename: Optional[str] = None, max_cycle_length: int = 5, + sources: Optional[list[str]] = None, ) -> plt.Figure: """ Plot a histogram of cycle closure errors. @@ -1023,21 +1024,34 @@ def plot_cycle_closure( max_cycle_length : int, optional Only consider cycles up to this length. Defaults to 5. The matplotlib Figure object, which can be edited further. + sources : list[str], optional + List of sources to plot. If None, all sources are plotted. """ - df = fe_map.get_cycle_closure(max_cycle_length=max_cycle_length) + df = fe_map.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) if df.empty: warnings.warn("No cycles found; skipping plot.") return - errors = df["cc (kcal/mol)"] - n_cycles = len(errors) + if sources is not None: + df = df[df["source"].isin(sources)] + if df.empty: + warnings.warn( + f"No cycles found for sources {sources}; skipping plot.") + return + + unique_sources = df["source"].unique() fig, ax = plt.subplots(figsize=(5, 4)) - ax.hist(errors, bins="auto", alpha=0.6, color="steelblue") + + for source in unique_sources: + source_df = df[df["source"] == source] + ax.hist(source_df["cc (kcal/mol)"], bins="auto", alpha=0.6, label=source) + ax.set_xlabel(r"Cycle closure (kcal mol$^{-1}$)") ax.set_ylabel("Count") - ax.set_title(f"Cycle closure distribution (n={n_cycles})") + ax.set_title("Cycle closure distribution") + ax.legend() fig.tight_layout() if filename is None: diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index f7156304..ce768155 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -833,7 +833,7 @@ def perfect_cycle(): def test_get_cycle_closure_known_value(perfect_cycle): - result = perfect_cycle.get_cycle_closure() + result = perfect_cycle.get_cycle_closure_dataframe() assert isinstance(result, pd.DataFrame) assert list(result.columns) == ["cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"] assert len(result) == 1 @@ -848,14 +848,14 @@ def test_get_cycle_closure_normalized_known_value(): 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() + result = fe.get_cycle_closure_dataframe() 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() + result = perfect_cycle.get_cycle_closure_edge_statistics_dataframe() assert len(result) == 3 assert (result["n_cycles"] == 1).all() assert (result["mean_cc (kcal/mol)"] == 0.0).all() From 5c538e2f68f8fbd9c5c084e45cc0e950929b14c7 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:25:05 +0200 Subject: [PATCH 20/48] more review coments --- cinnabar/plotting.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index 1390951c..d3218f17 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1065,6 +1065,7 @@ def plot_cycle_closure( filename: Optional[str] = None, max_cycle_length: int = 5, sources: Optional[list[str]] = None, + bin_width: float = 0.5, ) -> plt.Figure: """ Plot a histogram of cycle closure errors. @@ -1080,6 +1081,8 @@ def plot_cycle_closure( The matplotlib Figure object, which can be edited further. sources : list[str], optional List of sources to plot. If None, all sources are plotted. + bin_width : float, optional + Width of histogram bins in kcal/mol. Default: 0.5 """ df = fe_map.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) @@ -1098,9 +1101,12 @@ def plot_cycle_closure( fig, ax = plt.subplots(figsize=(5, 4)) + max_val = df["cc (kcal/mol)"].max() + bins = np.arange(0, max_val + bin_width, bin_width) + for source in unique_sources: source_df = df[df["source"] == source] - ax.hist(source_df["cc (kcal/mol)"], bins="auto", alpha=0.6, label=source) + ax.hist(source_df["cc (kcal/mol)"], bins=bins, alpha=0.6, label=source) ax.set_xlabel(r"Cycle closure (kcal mol$^{-1}$)") ax.set_ylabel("Count") From 7a990c6caec18f368d16200adf9a62f41e8d6cd2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 14:26:28 +0000 Subject: [PATCH 21/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 59 +++++++++++++++++++------------------------- cinnabar/plotting.py | 4 +-- 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 608c59a5..3fb5efa0 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -8,8 +8,8 @@ """ import copy -import math import itertools +import math import pathlib import warnings from dataclasses import asdict @@ -971,23 +971,16 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> list[tuple[s rows = [] for source, source_df in comp_df.groupby("source"): - edge_ddg = { - (row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] - for _, row in source_df.iterrows() - } + edge_ddg = {(row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] for _, row in source_df.iterrows()} edge_uncertainty = { - (row["labelA"], row["labelB"]): row["uncertainty (kcal/mol)"] - for _, row in source_df.iterrows() + (row["labelA"], row["labelB"]): row["uncertainty (kcal/mol)"] for _, row in source_df.iterrows() } network = nx.DiGraph() for a, b in edge_ddg: network.add_edge(a, b) - cycles = [ - c for c in nx.simple_cycles(network.to_undirected()) - if len(c) <= max_cycle_length - ] + cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length] for cycle in cycles: sum_ddgs = 0.0 @@ -1013,17 +1006,17 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> list[tuple[s # different cycle lengths cc = abs(sum_ddgs / math.sqrt(len(cycle))) cc_uncertainty_normalized = abs(sum_ddgs) / math.sqrt(sum_var) - rows.append({ - "source": source, - "cycle": tuple(cycle), - "cc (kcal/mol)": round(cc, 2), - "cc_unc_normalized (kcal/mol)": round( - cc_uncertainty_normalized, 2), - }) + rows.append( + { + "source": source, + "cycle": tuple(cycle), + "cc (kcal/mol)": round(cc, 2), + "cc_unc_normalized (kcal/mol)": round(cc_uncertainty_normalized, 2), + } + ) return ( - pd.DataFrame(rows, columns=["source", "cycle", "cc (kcal/mol)", - "cc_unc_normalized (kcal/mol)"]) + pd.DataFrame(rows, columns=["source", "cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"]) .sort_values(["source", "cc (kcal/mol)"], ascending=[True, False]) .reset_index(drop=True) ) @@ -1056,10 +1049,7 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) comp_df = self.get_relative_dataframe() comp_df = comp_df[comp_df["computational"]] edge_ddg_by_source = { - source: { - (row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] - for _, row in group.iterrows() - } + source: {(row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] for _, row in group.iterrows()} for source, group in comp_df.groupby("source") } @@ -1074,19 +1064,20 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) 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 = (lig_a, lig_b) if (lig_a, lig_b) in edge_ddg else (lig_b, lig_a) edge_cycles[edge].append(cc) for (a, b), ccs in edge_cycles.items(): - rows.append({ - "source": source, - "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), - }) + rows.append( + { + "source": source, + "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), + } + ) return ( pd.DataFrame(rows) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index d3218f17..3186ad7d 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1060,6 +1060,7 @@ def ecdf_plot_all_DDGs( **kwargs, ) + def plot_cycle_closure( fe_map: FEMap, filename: Optional[str] = None, @@ -1093,8 +1094,7 @@ def plot_cycle_closure( if sources is not None: df = df[df["source"].isin(sources)] if df.empty: - warnings.warn( - f"No cycles found for sources {sources}; skipping plot.") + warnings.warn(f"No cycles found for sources {sources}; skipping plot.") return unique_sources = df["source"].unique() From 5c70d2d749018051a6c29cadea795e06625ae215 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:31:36 +0200 Subject: [PATCH 22/48] Add missing import --- cinnabar/tests/test_plotting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index 13404b41..41dc70eb 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -1,4 +1,5 @@ import matplotlib.pylab as plt +import networkx as nx import numpy as np import pytest from openff.units import unit From 2362af4762db5daf4599402405b369a29dd81edc Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:37:24 +0200 Subject: [PATCH 23/48] fix tests --- cinnabar/tests/test_plotting.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index 41dc70eb..74ac1920 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -514,22 +514,6 @@ def test_plot_ecdf_colors(fe_map, tmp_path): assert line.get_color() == "#FF5733" -@pytest.mark.parametrize( - "graph", - [ - nx.MultiDiGraph(), - # graph with nodes but no calculated DDG edges - nx.MultiDiGraph([(0, 1, {"some_other_data": 1.0})]), - ], -) -def test_plot_ecdf_dgs_no_data(graph): - with pytest.raises( - ValueError, - 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): output_file = tmp_path / "cycle_closure.png" fig = plotting.plot_cycle_closure(fe_map, filename=str(output_file)) @@ -553,7 +537,7 @@ def test_plot_cycle_closure_no_cycles_no_plot(tmp_path): value=1.0 * unit.kilocalorie_per_mole, uncertainty=0.1 * unit.kilocalorie_per_mole, ) - assert fe.get_cycle_closure().empty + assert fe.get_cycle_closure_dataframe().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)) From fc26bbf598ffb29ef35c6f67994e7f798ec6145e Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:38:30 +0200 Subject: [PATCH 24/48] fix mypy --- cinnabar/femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 3fb5efa0..53fdc6eb 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -948,7 +948,7 @@ def draw_graph( fig.savefig(filename, bbox_inches="tight", dpi=300) plt.close(fig) - def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> list[tuple[str, float]]: + def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame: """ Calculate cycle closure errors for all cycles in the network. From 6739d5404f5e5f534aa06432f53bca4a3656a638 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:40:55 +0200 Subject: [PATCH 25/48] More mypy fixes --- cinnabar/plotting.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index 3186ad7d..7aeafd0e 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1089,20 +1089,21 @@ def plot_cycle_closure( if df.empty: warnings.warn("No cycles found; skipping plot.") - return + return None if sources is not None: df = df[df["source"].isin(sources)] if df.empty: - warnings.warn(f"No cycles found for sources {sources}; skipping plot.") - return + warnings.warn( + f"No cycles found for sources {sources}; skipping plot.") + return None unique_sources = df["source"].unique() fig, ax = plt.subplots(figsize=(5, 4)) max_val = df["cc (kcal/mol)"].max() - bins = np.arange(0, max_val + bin_width, bin_width) + bins = np.arange(0, max_val + bin_width, bin_width).tolist() for source in unique_sources: source_df = df[df["source"] == source] From cc96b78ce014bb376b9fdc1c7e72280273505a4a Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Fri, 29 May 2026 16:41:56 +0200 Subject: [PATCH 26/48] more fixes --- cinnabar/tests/test_femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index f10866ed..9b6c1bf4 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -855,7 +855,7 @@ def perfect_cycle(): def test_get_cycle_closure_known_value(perfect_cycle): result = perfect_cycle.get_cycle_closure_dataframe() assert isinstance(result, pd.DataFrame) - assert list(result.columns) == ["cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"] + assert list(result.columns) == ["source", "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) From 7e9f8048a22e007d1dfe3f866e596d9f29658588 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 14:43:33 +0000 Subject: [PATCH 27/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/plotting.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index 7aeafd0e..65bf6736 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1094,8 +1094,7 @@ def plot_cycle_closure( if sources is not None: df = df[df["source"].isin(sources)] if df.empty: - warnings.warn( - f"No cycles found for sources {sources}; skipping plot.") + warnings.warn(f"No cycles found for sources {sources}; skipping plot.") return None unique_sources = df["source"].unique() From 2d126b71780997ecc1e0763687f970b26289d1b9 Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Fri, 29 May 2026 16:47:04 +0200 Subject: [PATCH 28/48] Apply suggestion from @hannahbaumann --- cinnabar/plotting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index 65bf6736..d48d05f7 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1067,7 +1067,7 @@ def plot_cycle_closure( max_cycle_length: int = 5, sources: Optional[list[str]] = None, bin_width: float = 0.5, -) -> plt.Figure: +) -> plt.Figure | None: """ Plot a histogram of cycle closure errors. From 030ac302b31a126fafc064c605fabea778d87ed5 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 11:09:28 +0200 Subject: [PATCH 29/48] Update cc metrics --- cinnabar/femap.py | 26 ++++++++++++++++++++------ cinnabar/tests/test_femap.py | 16 ++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 53fdc6eb..3aabe579 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -963,8 +963,20 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame - source - cycle - cc (kcal/mol) - - cc_unc_normalized (kcal/mol) + - cc_unc_normalized Sorted by source and cycle closure error descending. + + Notes + ----- + Three cycle closure metrics are calculated: + + - ``cc_error``: the raw absolute sum of DDGs around the cycle. Units: kcal/mol. + + - ``cc_per_edge``: the cycle closure divided by the square root of the cycle + length, to allow comparison across different cycle lengths. Units: kcal/mol. + + - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, + calculated as ``abs(sum_ddgs) / sqrt(sum_var)``. """ df = self.get_relative_dataframe() comp_df = df[df["computational"]] @@ -1004,19 +1016,21 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame else: # Normalize by sqrt(cycle length) to allow comparison across # different cycle lengths - cc = abs(sum_ddgs / math.sqrt(len(cycle))) - cc_uncertainty_normalized = abs(sum_ddgs) / math.sqrt(sum_var) + cc = abs(sum_ddgs) + cc_per_edge = abs(sum_ddgs) / math.sqrt(len(cycle)) + cc_z_score = abs(sum_ddgs) / math.sqrt(sum_var) rows.append( { "source": source, "cycle": tuple(cycle), "cc (kcal/mol)": round(cc, 2), - "cc_unc_normalized (kcal/mol)": round(cc_uncertainty_normalized, 2), + "cc_per_edge (kcal/mol)": round(cc_per_edge, 2), + "cc_unc_normalized": round(cc_z_score, 2), } ) return ( - pd.DataFrame(rows, columns=["source", "cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"]) + pd.DataFrame(rows, columns=["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized (kcal/mol)"]) .sort_values(["source", "cc (kcal/mol)"], ascending=[True, False]) .reset_index(drop=True) ) @@ -1060,7 +1074,7 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) for _, row in source_cc_df.iterrows(): cycle = list(row["cycle"]) - cc = row["cc (kcal/mol)"] + cc = row["cc_per_edge (kcal/mol)"] for i, lig in enumerate(cycle): lig_a = lig lig_b = cycle[i + 1] if i < len(cycle) - 1 else cycle[0] diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 9b6c1bf4..2c60967b 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -852,16 +852,17 @@ def perfect_cycle(): return fe -def test_get_cycle_closure_known_value(perfect_cycle): +def test_get_cycle_closure_perfect_cycle(perfect_cycle): result = perfect_cycle.get_cycle_closure_dataframe() assert isinstance(result, pd.DataFrame) - assert list(result.columns) == ["source", "cycle", "cc (kcal/mol)", "cc_unc_normalized (kcal/mol)"] + assert list(result.columns) == ["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized"] 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) + assert result["cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx(0.0, abs=1e-6) + assert result["cc_unc_normalized"].iloc[0] == pytest.approx(0.0, abs=1e-6) -def test_get_cycle_closure_normalized_known_value(): +def test_get_cycle_closure_hystereses(): kcalpm = unit.kilocalorie_per_mole fe = femap.FEMap() fe.add_relative_calculation("A", "B", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm) @@ -869,9 +870,12 @@ def test_get_cycle_closure_normalized_known_value(): fe.add_relative_calculation("C", "A", value=-1.5 * kcalpm, uncertainty=0.1 * kcalpm) result = fe.get_cycle_closure_dataframe() + expected_cc = abs(0.5) + expected_cc_per_edge = round(abs(0.5) / math.sqrt(3), 2) 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) + assert result["cc (kcal/mol)"].iloc[0] == pytest.approx(expected_cc, abs=0.01) + assert result["cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx(expected_cc_per_edge, abs=0.01) + assert result["cc_unc_normalized"].iloc[0] == pytest.approx(expected_cc_normalized, abs=0.01) def test_get_cc_based_edge_statistics_known_value(perfect_cycle): From 7c73c45be5885014aa3a70789fb373a7177c1001 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 11:12:09 +0200 Subject: [PATCH 30/48] Small fix --- cinnabar/femap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 3aabe579..8fdbaefb 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -970,9 +970,9 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame ----- Three cycle closure metrics are calculated: - - ``cc_error``: the raw absolute sum of DDGs around the cycle. Units: kcal/mol. + - ``cc (kcal/mol)``: the raw absolute sum of DDGs around the cycle. Units: kcal/mol. - - ``cc_per_edge``: the cycle closure divided by the square root of the cycle + - ``cc_per_edge (kcal/mol)``: the cycle closure divided by the square root of the cycle length, to allow comparison across different cycle lengths. Units: kcal/mol. - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, From 67d7c42a3445debd81ac98d0d8314912dffb5c31 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:12:46 +0000 Subject: [PATCH 31/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 8fdbaefb..9d552f7a 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1030,7 +1030,10 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame ) return ( - pd.DataFrame(rows, columns=["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized (kcal/mol)"]) + pd.DataFrame( + rows, + columns=["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized (kcal/mol)"], + ) .sort_values(["source", "cc (kcal/mol)"], ascending=[True, False]) .reset_index(drop=True) ) From 594a4981309d586ad827091d9be53a4d678ff876 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 11:18:11 +0200 Subject: [PATCH 32/48] Small fix2 --- cinnabar/femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 9d552f7a..63d3f80b 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1032,7 +1032,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame return ( pd.DataFrame( rows, - columns=["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized (kcal/mol)"], + columns=["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized"], ) .sort_values(["source", "cc (kcal/mol)"], ascending=[True, False]) .reset_index(drop=True) From d83937492d59355cbfa05eb78feef9259d44ab23 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 11:30:39 +0200 Subject: [PATCH 33/48] Add tests for plotting --- cinnabar/tests/test_plotting.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index 74ac1920..9be585cb 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -524,6 +524,14 @@ def test_plot_cycle_closure(fe_map, tmp_path): assert output_file.exists() +def test_plot_cycle_closure_source_filter(fe_map): + fig = plotting.plot_cycle_closure(fe_map, sources=[""]) + assert isinstance(fig, plt.Figure) + axes = fig.get_axes()[0] + assert axes.get_xlabel() == r"Cycle closure (kcal mol$^{-1}$)" + assert axes.get_ylabel() == "Count" + + def test_plot_cycle_closure_show(fe_map, show_called): _ = plotting.plot_cycle_closure(fe_map, filename=None) assert "show" in show_called @@ -543,3 +551,11 @@ def test_plot_cycle_closure_no_cycles_no_plot(tmp_path): fig = plotting.plot_cycle_closure(fe, filename=str(output_file)) assert fig is None assert not output_file.exists() + + +def test_plot_cycle_closure_invalid_source(fe_map, tmp_path): + output_file = tmp_path / "cycle_closure.png" + with pytest.warns(UserWarning, match="No cycles found for sources"): + fig = plotting.plot_cycle_closure(fe_map, filename=str(output_file), sources=["nonexistent_source"]) + assert fig is None + assert not output_file.exists() From 5666bb5097f6a6314034a0eafcf4dbddd6dbf4fb Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:34:26 +0200 Subject: [PATCH 34/48] Update cinnabar/plotting.py Co-authored-by: Josh Horton --- cinnabar/plotting.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index d48d05f7..ee97a704 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1075,14 +1075,14 @@ def plot_cycle_closure( ---------- fe_map : FEMap FEMap object containing the calculated edges. - filename : str, optional + filename : str, default None If provided, the plot will be saved to this filename. - max_cycle_length : int, optional + max_cycle_length : int, default 5 Only consider cycles up to this length. Defaults to 5. The matplotlib Figure object, which can be edited further. - sources : list[str], optional + sources : list[str], default None List of sources to plot. If None, all sources are plotted. - bin_width : float, optional + bin_width : float, default 0.5 Width of histogram bins in kcal/mol. Default: 0.5 """ df = fe_map.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) From 2318569dd3ebe87cea04a2174ac6e888c8a2eaf5 Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:34:44 +0200 Subject: [PATCH 35/48] Update cinnabar/femap.py Co-authored-by: Josh Horton --- cinnabar/femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 63d3f80b..84a8d62c 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1045,7 +1045,7 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) Parameters ---------- - max_cycle_length : int, optional + max_cycle_length : int, default 5 Only consider cycles up to this length. Defaults to 5. Returns From 3669f55bc4cb311d08ce82bbb7f40a0635d1ff16 Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:35:35 +0200 Subject: [PATCH 36/48] Update cinnabar/femap.py Co-authored-by: Josh Horton --- cinnabar/femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 84a8d62c..4920f632 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -954,7 +954,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame Parameters ---------- - max_cycle_length : int, optional + max_cycle_length : int, default 5 Only consider cycles up to this length. Default 5. Returns From 0d8dbb17dc5d615728edba5768ea6e95bdb764d8 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 13:07:41 +0200 Subject: [PATCH 37/48] Address review comments --- cinnabar/femap.py | 13 +++++++++---- cinnabar/plotting.py | 15 +++++++-------- cinnabar/tests/conftest.py | 22 ++++++++++++++++++++++ cinnabar/tests/test_femap.py | 31 ++++++++++++------------------- cinnabar/tests/test_plotting.py | 32 ++++++++++++++++++-------------- news/cycle_closure.rst | 26 ++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 45 deletions(-) create mode 100644 news/cycle_closure.rst diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 4920f632..16b8db82 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -973,7 +973,8 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame - ``cc (kcal/mol)``: the raw absolute sum of DDGs around the cycle. Units: kcal/mol. - ``cc_per_edge (kcal/mol)``: the cycle closure divided by the square root of the cycle - length, to allow comparison across different cycle lengths. Units: kcal/mol. + length, to allow comparison across different cycle lengths; + see Baumann et al. (DOI 10.1021/acs.jctc.3c00282). Units: kcal/mol. - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, calculated as ``abs(sum_ddgs) / sqrt(sum_var)``. @@ -1014,11 +1015,15 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame break else: + cc = abs(sum_ddgs) # Normalize by sqrt(cycle length) to allow comparison across # different cycle lengths - cc = abs(sum_ddgs) - cc_per_edge = abs(sum_ddgs) / math.sqrt(len(cycle)) - cc_z_score = abs(sum_ddgs) / math.sqrt(sum_var) + cc_per_edge = cc / math.sqrt(len(cycle)) + cc_z_score = ( + cc / math.sqrt(sum_var) + if sum_var > 0 + else np.nan + ) rows.append( { "source": source, diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index ee97a704..f1f15842 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1069,7 +1069,8 @@ def plot_cycle_closure( bin_width: float = 0.5, ) -> plt.Figure | None: """ - Plot a histogram of cycle closure errors. + Plot a histogram of cycle closure errors, taking the ``cc_per_edge (kcal/mol)`` + which is the cycle closure divided by the square root of the cycle length. Parameters ---------- @@ -1088,27 +1089,25 @@ def plot_cycle_closure( df = fe_map.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) if df.empty: - warnings.warn("No cycles found; skipping plot.") - return None + raise ValueError("The FEMap does not contain cycles.") if sources is not None: df = df[df["source"].isin(sources)] if df.empty: - warnings.warn(f"No cycles found for sources {sources}; skipping plot.") - return None + raise ValueError(f"No cycles found for sources {sources}.") unique_sources = df["source"].unique() fig, ax = plt.subplots(figsize=(5, 4)) - max_val = df["cc (kcal/mol)"].max() + max_val = df["cc_per_edge (kcal/mol)"].max() bins = np.arange(0, max_val + bin_width, bin_width).tolist() for source in unique_sources: source_df = df[df["source"] == source] - ax.hist(source_df["cc (kcal/mol)"], bins=bins, alpha=0.6, label=source) + ax.hist(source_df["cc_per_edge (kcal/mol)"], bins=bins, alpha=0.6, label=source) - ax.set_xlabel(r"Cycle closure (kcal mol$^{-1}$)") + ax.set_xlabel(r"Cycle closure per edge (kcal mol$^{-1}$)") ax.set_ylabel("Count") ax.set_title("Cycle closure distribution") ax.legend() diff --git a/cinnabar/tests/conftest.py b/cinnabar/tests/conftest.py index 168c5774..318fd878 100644 --- a/cinnabar/tests/conftest.py +++ b/cinnabar/tests/conftest.py @@ -524,3 +524,25 @@ def ecdf_femap_missing_exp_data(): label="ligand2", value=-5.0 * unit.kilocalories_per_mole, uncertainty=0.2 * unit.kilocalories_per_mole ) return fe_map + + +@pytest.fixture() +def perfect_cycle(): + """A perfect cycle with zero cycle closure.""" + kcalpm = unit.kilocalorie_per_mole + fe = FEMap() + fe.add_relative_calculation("A", "B", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm, source="method_a") + fe.add_relative_calculation("B", "C", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm, source="method_a") + fe.add_relative_calculation("C", "A", value=-2.0 * kcalpm, uncertainty=0.1 * kcalpm, source="method_a") + return fe + + +@pytest.fixture() +def imperfect_cycle(): + """An imperfect cycle with a known cycle closure error.""" + kcalpm = unit.kilocalorie_per_mole + fe = FEMap() + fe.add_relative_calculation("A", "B", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm, source="method_b") + fe.add_relative_calculation("B", "C", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm, source="method_b") + fe.add_relative_calculation("C", "A", value=-1.5 * kcalpm, uncertainty=0.1 * kcalpm, source="method_b") + return fe diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index 2c60967b..b24f136f 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -841,17 +841,6 @@ def test_missing_estimator_metadata(example_map): 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_perfect_cycle(perfect_cycle): result = perfect_cycle.get_cycle_closure_dataframe() assert isinstance(result, pd.DataFrame) @@ -862,14 +851,8 @@ def test_get_cycle_closure_perfect_cycle(perfect_cycle): assert result["cc_unc_normalized"].iloc[0] == pytest.approx(0.0, abs=1e-6) -def test_get_cycle_closure_hystereses(): - 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_dataframe() +def test_get_cycle_closure_hystereses(imperfect_cycle): + result = imperfect_cycle.get_cycle_closure_dataframe() expected_cc = abs(0.5) expected_cc_per_edge = round(abs(0.5) / math.sqrt(3), 2) expected_cc_normalized = round(abs(0.5) / math.sqrt(3 * 0.1**2), 2) @@ -878,6 +861,16 @@ def test_get_cycle_closure_hystereses(): assert result["cc_unc_normalized"].iloc[0] == pytest.approx(expected_cc_normalized, abs=0.01) +def test_get_cycle_closure_multiple_sources(perfect_cycle, imperfect_cycle): + fe = perfect_cycle + imperfect_cycle + + result = fe.get_cycle_closure_dataframe() + assert len(result) == 2 + assert set(result["source"].unique()) == {"method_a", "method_b"} + assert result[result["source"] == "method_a"]["cc (kcal/mol)"].iloc[0] == pytest.approx(0.0, abs=1e-6) + assert result[result["source"] == "method_b"]["cc (kcal/mol)"].iloc[0] == pytest.approx(0.5, abs=0.01) + + def test_get_cc_based_edge_statistics_known_value(perfect_cycle): result = perfect_cycle.get_cycle_closure_edge_statistics_dataframe() assert len(result) == 3 diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index 9be585cb..3865ca1b 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -524,14 +524,6 @@ def test_plot_cycle_closure(fe_map, tmp_path): assert output_file.exists() -def test_plot_cycle_closure_source_filter(fe_map): - fig = plotting.plot_cycle_closure(fe_map, sources=[""]) - assert isinstance(fig, plt.Figure) - axes = fig.get_axes()[0] - assert axes.get_xlabel() == r"Cycle closure (kcal mol$^{-1}$)" - assert axes.get_ylabel() == "Count" - - def test_plot_cycle_closure_show(fe_map, show_called): _ = plotting.plot_cycle_closure(fe_map, filename=None) assert "show" in show_called @@ -547,15 +539,27 @@ def test_plot_cycle_closure_no_cycles_no_plot(tmp_path): ) assert fe.get_cycle_closure_dataframe().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 + with pytest.raises(ValueError, match="The FEMap does not contain cycles"): + plotting.plot_cycle_closure(fe, filename=str(output_file)) assert not output_file.exists() def test_plot_cycle_closure_invalid_source(fe_map, tmp_path): output_file = tmp_path / "cycle_closure.png" - with pytest.warns(UserWarning, match="No cycles found for sources"): - fig = plotting.plot_cycle_closure(fe_map, filename=str(output_file), sources=["nonexistent_source"]) - assert fig is None + with pytest.raises(ValueError, match="No cycles found for sources"): + plotting.plot_cycle_closure(fe_map, filename=str(output_file), sources=["nonexistent_source"]) assert not output_file.exists() + + +def test_plot_cycle_closure_multiple_sources(perfect_cycle, imperfect_cycle, tmp_path): + fe = perfect_cycle + imperfect_cycle + output_file = tmp_path / "cycle_closure_multiple.png" + fig = plotting.plot_cycle_closure(fe, 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" + legend_texts = [t.get_text() for t in axes.get_legend().get_texts()] + assert "method_a" in legend_texts + assert "method_b" in legend_texts + assert output_file.exists() diff --git a/news/cycle_closure.rst b/news/cycle_closure.rst new file mode 100644 index 00000000..db4f3ad4 --- /dev/null +++ b/news/cycle_closure.rst @@ -0,0 +1,26 @@ +**Added:** + +* Added ``FEMap.get_cycle_closure_dataframe`` to calculate cycle closure errors for all cycles in the network, + reporting raw closure errors, per-edge contributions, and uncertainty-normalized cycle closures. +* Added ``FEMap.get_cycle_closure_edge_statistics_dataframe`` to report per-edge cycle closure statistics. +* Added ``plotting.plot_cycle_closure`` to visualize the cycle closure error distribution as a histogram. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 082cd6ce2e96115404611aed767d7b60c0e1072a Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 13:49:06 +0200 Subject: [PATCH 38/48] Update tests --- cinnabar/femap.py | 41 ++++++++++++++++++++---------------- cinnabar/tests/test_femap.py | 21 ++++++++++++++++-- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 16b8db82..5a9afa53 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1028,9 +1028,9 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame { "source": source, "cycle": tuple(cycle), - "cc (kcal/mol)": round(cc, 2), - "cc_per_edge (kcal/mol)": round(cc_per_edge, 2), - "cc_unc_normalized": round(cc_z_score, 2), + "cc (kcal/mol)": cc, + "cc_per_edge (kcal/mol)": cc_per_edge, + "cc_unc_normalized": cc_z_score, } ) @@ -1048,6 +1048,9 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) For each simulated edge, report how many cycles it appears in and the mean and max cycle closure error of those cycles per source. + The cycle closure values are based on ``cc_per_edge (kcal/mol)``, + defined as the absolute cycle closure divided by the square root of the cycle length. + Parameters ---------- max_cycle_length : int, default 5 @@ -1060,34 +1063,36 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) - ligandA - ligandB - n_cycles - - mean_cc (kcal/mol) - - max_cc (kcal/mol) + - mean_cc_per_edge (kcal/mol) + - max_cc_per_edge (kcal/mol) Sorted by source and mean cycle closure error descending. """ from collections import defaultdict cc_df = self.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) - comp_df = self.get_relative_dataframe() - comp_df = comp_df[comp_df["computational"]] - edge_ddg_by_source = { - source: {(row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] for _, row in group.iterrows()} - for source, group in comp_df.groupby("source") - } + print(cc_df) + # comp_df = self.get_relative_dataframe() + # comp_df = comp_df[comp_df["computational"]] + # edge_ddg_by_source = { + # source: {(row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] for _, row in group.iterrows()} + # for source, group in comp_df.groupby("source") + # } rows = [] for source, source_cc_df in cc_df.groupby("source"): - edge_ddg = edge_ddg_by_source.get(source, {}) + # edge_ddg = edge_ddg_by_source.get(source, {}) edge_cycles: dict[tuple, list[float]] = defaultdict(list) for _, row in source_cc_df.iterrows(): cycle = list(row["cycle"]) - cc = row["cc_per_edge (kcal/mol)"] + cc_per_edge = row["cc_per_edge (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) + # edge = (lig_a, lig_b) if (lig_a, lig_b) in edge_ddg else (lig_b, lig_a) + edge = self._canonical_edge((lig_a, lig_b)) + edge_cycles[edge].append(cc_per_edge) for (a, b), ccs in edge_cycles.items(): rows.append( @@ -1096,13 +1101,13 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) "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), + "mean_cc_per_edge (kcal/mol)": sum(ccs) / len(ccs), + "max_cc_per_edge (kcal/mol)": max(ccs), } ) return ( pd.DataFrame(rows) - .sort_values(["source", "mean_cc (kcal/mol)"], ascending=[True, False]) + .sort_values(["source", "mean_cc_per_edge (kcal/mol)"], ascending=[True, False]) .reset_index(drop=True) ) diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index b24f136f..ab1bbb91 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -875,5 +875,22 @@ def test_get_cc_based_edge_statistics_known_value(perfect_cycle): result = perfect_cycle.get_cycle_closure_edge_statistics_dataframe() 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() + assert (result["mean_cc_per_edge (kcal/mol)"] == 0.0).all() + assert (result["max_cc_per_edge (kcal/mol)"] == 0.0).all() + + +def test_get_cc_based_edge_statistics_reverse_direction(perfect_cycle): + kcalpm = unit.kilocalorie_per_mole + # add edges for more cycles + perfect_cycle.add_relative_calculation("A", "D", value=1.0 * kcalpm, uncertainty=0.1 * kcalpm, source="method_a") + perfect_cycle.add_relative_calculation("D", "B", value=-1.5 * kcalpm, uncertainty=0.1 * kcalpm, source="method_a") + + result = perfect_cycle.get_cycle_closure_edge_statistics_dataframe(max_cycle_length=3) + cc_per_edge_abc = 0.0 / math.sqrt(3) # perfect cycle A -> B -> C -> A + cc_per_edge_bad = 1.5 / math.sqrt(3) # imperfect cycle B -> A -> D -> B + + # A to B in two cycles + ab_row = result[(result["ligandA"] == "A") & (result["ligandB"] == "B")] + assert ab_row["n_cycles"].iloc[0] == 2 + assert ab_row["mean_cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx((cc_per_edge_abc + cc_per_edge_bad) / 2, abs=1e-3) + assert ab_row["max_cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx(cc_per_edge_bad, abs=1e-3) From a42ca92cf9ebdefb4b71dac1b57670c9c554130d Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 13:57:54 +0200 Subject: [PATCH 39/48] address review coments --- cinnabar/femap.py | 9 --------- cinnabar/plotting.py | 24 +++++++++++++++++------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 5a9afa53..c6e83b5f 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1071,17 +1071,9 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) from collections import defaultdict cc_df = self.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) - print(cc_df) - # comp_df = self.get_relative_dataframe() - # comp_df = comp_df[comp_df["computational"]] - # edge_ddg_by_source = { - # source: {(row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] for _, row in group.iterrows()} - # for source, group in comp_df.groupby("source") - # } rows = [] for source, source_cc_df in cc_df.groupby("source"): - # edge_ddg = edge_ddg_by_source.get(source, {}) edge_cycles: dict[tuple, list[float]] = defaultdict(list) for _, row in source_cc_df.iterrows(): @@ -1090,7 +1082,6 @@ def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) 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 = self._canonical_edge((lig_a, lig_b)) edge_cycles[edge].append(cc_per_edge) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index f1f15842..eb597d9e 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1,6 +1,6 @@ import itertools import warnings -from typing import Any, Literal, Optional, Union +from typing import Any, Literal import matplotlib.pylab as plt import networkx as nx @@ -1063,11 +1063,11 @@ def ecdf_plot_all_DDGs( def plot_cycle_closure( fe_map: FEMap, - filename: Optional[str] = None, + filename: str | None, max_cycle_length: int = 5, - sources: Optional[list[str]] = None, + sources: list[str] | None = None, bin_width: float = 0.5, -) -> plt.Figure | None: +) -> plt.Figure: """ Plot a histogram of cycle closure errors, taking the ``cc_per_edge (kcal/mol)`` which is the cycle closure divided by the square root of the cycle length. @@ -1076,15 +1076,25 @@ def plot_cycle_closure( ---------- fe_map : FEMap FEMap object containing the calculated edges. - filename : str, default None + filename : str | None, default None If provided, the plot will be saved to this filename. max_cycle_length : int, default 5 Only consider cycles up to this length. Defaults to 5. - The matplotlib Figure object, which can be edited further. - sources : list[str], default None + sources : list[str] | None, default None List of sources to plot. If None, all sources are plotted. bin_width : float, default 0.5 Width of histogram bins in kcal/mol. Default: 0.5 + + Raises + ------ + ValueError + If the FEMap contains no cycles, or if a requested + source cannot be found. + + Returns + ------- + plt.Figure + The matplotlib Figure object containing the histogram which can be edited further. """ df = fe_map.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) From dade9fd1671a6c110fd2bb690ef14533b9bef2f1 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 14:20:27 +0200 Subject: [PATCH 40/48] Fix test --- cinnabar/tests/test_plotting.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index 3865ca1b..e4b3309a 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -519,7 +519,7 @@ def test_plot_cycle_closure(fe_map, tmp_path): 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_xlabel() == r"Cycle closure per edge (kcal mol$^{-1}$)" assert axes.get_ylabel() == "Count" assert output_file.exists() @@ -557,8 +557,6 @@ def test_plot_cycle_closure_multiple_sources(perfect_cycle, imperfect_cycle, tmp fig = plotting.plot_cycle_closure(fe, 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" legend_texts = [t.get_text() for t in axes.get_legend().get_texts()] assert "method_a" in legend_texts assert "method_b" in legend_texts From 3673bca778da6b8d544be3d709a482d1df1b3870 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:22:50 +0000 Subject: [PATCH 41/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 6 +----- cinnabar/tests/test_femap.py | 8 +++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index c6e83b5f..c12991c5 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -1019,11 +1019,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame # Normalize by sqrt(cycle length) to allow comparison across # different cycle lengths cc_per_edge = cc / math.sqrt(len(cycle)) - cc_z_score = ( - cc / math.sqrt(sum_var) - if sum_var > 0 - else np.nan - ) + cc_z_score = cc / math.sqrt(sum_var) if sum_var > 0 else np.nan rows.append( { "source": source, diff --git a/cinnabar/tests/test_femap.py b/cinnabar/tests/test_femap.py index ab1bbb91..d9fb4fb8 100644 --- a/cinnabar/tests/test_femap.py +++ b/cinnabar/tests/test_femap.py @@ -886,11 +886,13 @@ def test_get_cc_based_edge_statistics_reverse_direction(perfect_cycle): perfect_cycle.add_relative_calculation("D", "B", value=-1.5 * kcalpm, uncertainty=0.1 * kcalpm, source="method_a") result = perfect_cycle.get_cycle_closure_edge_statistics_dataframe(max_cycle_length=3) - cc_per_edge_abc = 0.0 / math.sqrt(3) # perfect cycle A -> B -> C -> A - cc_per_edge_bad = 1.5 / math.sqrt(3) # imperfect cycle B -> A -> D -> B + cc_per_edge_abc = 0.0 / math.sqrt(3) # perfect cycle A -> B -> C -> A + cc_per_edge_bad = 1.5 / math.sqrt(3) # imperfect cycle B -> A -> D -> B # A to B in two cycles ab_row = result[(result["ligandA"] == "A") & (result["ligandB"] == "B")] assert ab_row["n_cycles"].iloc[0] == 2 - assert ab_row["mean_cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx((cc_per_edge_abc + cc_per_edge_bad) / 2, abs=1e-3) + assert ab_row["mean_cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx( + (cc_per_edge_abc + cc_per_edge_bad) / 2, abs=1e-3 + ) assert ab_row["max_cc_per_edge (kcal/mol)"].iloc[0] == pytest.approx(cc_per_edge_bad, abs=1e-3) From c1ff1e5ea28e64a1a71528f314c9ad9839a4b30a Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Mon, 1 Jun 2026 15:05:21 +0200 Subject: [PATCH 42/48] Small change --- cinnabar/plotting.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index eb597d9e..07142917 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1085,16 +1085,16 @@ def plot_cycle_closure( bin_width : float, default 0.5 Width of histogram bins in kcal/mol. Default: 0.5 + Returns + ------- + plt.Figure + The matplotlib Figure object containing the histogram which can be edited further. + Raises ------ ValueError If the FEMap contains no cycles, or if a requested source cannot be found. - - Returns - ------- - plt.Figure - The matplotlib Figure object containing the histogram which can be edited further. """ df = fe_map.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) From a76a12e437dc68c36055b7dfb11ce5055d2dc63a Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:50:11 +0200 Subject: [PATCH 43/48] Update cinnabar/femap.py Co-authored-by: Josh Horton --- cinnabar/femap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index c12991c5..7b570316 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -963,6 +963,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame - source - cycle - cc (kcal/mol) + - cc_per_edge (kcal/mol) - cc_unc_normalized Sorted by source and cycle closure error descending. From fe608b04d59f9bfaf041a03f8cc1736ce81d9db8 Mon Sep 17 00:00:00 2001 From: hannahbaumann Date: Tue, 2 Jun 2026 17:12:10 +0200 Subject: [PATCH 44/48] Remove missing imports --- cinnabar/plotting.py | 1 - cinnabar/tests/test_plotting.py | 1 - 2 files changed, 2 deletions(-) diff --git a/cinnabar/plotting.py b/cinnabar/plotting.py index 07142917..b40e5138 100644 --- a/cinnabar/plotting.py +++ b/cinnabar/plotting.py @@ -1,5 +1,4 @@ import itertools -import warnings from typing import Any, Literal import matplotlib.pylab as plt diff --git a/cinnabar/tests/test_plotting.py b/cinnabar/tests/test_plotting.py index e4b3309a..291f4d30 100644 --- a/cinnabar/tests/test_plotting.py +++ b/cinnabar/tests/test_plotting.py @@ -1,5 +1,4 @@ import matplotlib.pylab as plt -import networkx as nx import numpy as np import pytest from openff.units import unit From 706637b1c59c4f9e60553b362db79d52e8ea5417 Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:38:22 +0200 Subject: [PATCH 45/48] Apply suggestion from @hannahbaumann --- cinnabar/femap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 7b570316..17ed0e70 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -994,6 +994,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame for a, b in edge_ddg: network.add_edge(a, b) + # Using the undirected graph means that self loop edges are not considered. cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length] for cycle in cycles: From f4c69907255bd1ca7f4c7aa596609cdc3d5a29ba Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:38:36 +0200 Subject: [PATCH 46/48] Apply suggestion from @hannahbaumann --- cinnabar/femap.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index 17ed0e70..e5d345a4 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -979,6 +979,8 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, calculated as ``abs(sum_ddgs) / sqrt(sum_var)``. + + The function currently does not consider self loop edges, e.g. A-->B and B-->A edges. """ df = self.get_relative_dataframe() comp_df = df[df["computational"]] From 4c8448fbc05576d398ed76ed5863bfb2bcad3fab Mon Sep 17 00:00:00 2001 From: Hannah Baumann <43765638+hannahbaumann@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:39:23 +0200 Subject: [PATCH 47/48] Apply suggestion from @hannahbaumann --- cinnabar/femap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index e5d345a4..e8f2dde4 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -980,7 +980,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, calculated as ``abs(sum_ddgs) / sqrt(sum_var)``. - The function currently does not consider self loop edges, e.g. A-->B and B-->A edges. + The function currently does not consider self loop edges, e.g. A-->B and B-->A edges. """ df = self.get_relative_dataframe() comp_df = df[df["computational"]] From 1838ac4a4537f5ddc52dfddb59ff11ba5d9495aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:40:25 +0000 Subject: [PATCH 48/48] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- cinnabar/femap.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cinnabar/femap.py b/cinnabar/femap.py index e8f2dde4..a2bfd8ca 100644 --- a/cinnabar/femap.py +++ b/cinnabar/femap.py @@ -979,8 +979,8 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, calculated as ``abs(sum_ddgs) / sqrt(sum_var)``. - - The function currently does not consider self loop edges, e.g. A-->B and B-->A edges. + + The function currently does not consider self loop edges, e.g. A-->B and B-->A edges. """ df = self.get_relative_dataframe() comp_df = df[df["computational"]] @@ -996,7 +996,7 @@ def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame for a, b in edge_ddg: network.add_edge(a, b) - # Using the undirected graph means that self loop edges are not considered. + # Using the undirected graph means that self loop edges are not considered. cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length] for cycle in cycles: