pytorch-frustratometer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. pytorch_frustratometer/__init__.py +50 -0
  2. pytorch_frustratometer/__main__.py +3 -0
  3. pytorch_frustratometer/algorithm/__init__.py +32 -0
  4. pytorch_frustratometer/algorithm/awsem.py +575 -0
  5. pytorch_frustratometer/algorithm/configurational.py +529 -0
  6. pytorch_frustratometer/algorithm/decoy_statistics.py +74 -0
  7. pytorch_frustratometer/algorithm/mj.py +180 -0
  8. pytorch_frustratometer/algorithm/mutational.py +1059 -0
  9. pytorch_frustratometer/algorithm/singleresidue.py +1862 -0
  10. pytorch_frustratometer/analysis/__init__.py +3 -0
  11. pytorch_frustratometer/analysis/dynamic_clusters.py +613 -0
  12. pytorch_frustratometer/base.py +543 -0
  13. pytorch_frustratometer/cli.py +658 -0
  14. pytorch_frustratometer/coefficients.py +203 -0
  15. pytorch_frustratometer/data/__init__.py +2 -0
  16. pytorch_frustratometer/data/burial_gamma.dat +20 -0
  17. pytorch_frustratometer/data/dataset.py +439 -0
  18. pytorch_frustratometer/data/io.py +1761 -0
  19. pytorch_frustratometer/data/shard_formats.py +778 -0
  20. pytorch_frustratometer/data/shard_io.py +48 -0
  21. pytorch_frustratometer/data/shard_writer.py +183 -0
  22. pytorch_frustratometer/data/water_gamma.dat +421 -0
  23. pytorch_frustratometer/frustratometer.py +1432 -0
  24. pytorch_frustratometer/original_code/AWSEM.py +369 -0
  25. pytorch_frustratometer/original_code/GenerateVisualizations.pl +82 -0
  26. pytorch_frustratometer/original_code/Pdb2Gro.py +106 -0
  27. pytorch_frustratometer/original_code/atomisitc_frustration.py +312 -0
  28. pytorch_frustratometer/original_code/draw_links.py +124 -0
  29. pytorch_frustratometer/original_code/fix_backbone.cpp +8232 -0
  30. pytorch_frustratometer/original_code/fix_backbone.h +527 -0
  31. pytorch_frustratometer/original_code/functions.R +1507 -0
  32. pytorch_frustratometer/original_code/smart_matrix_lib.h +652 -0
  33. pytorch_frustratometer/plots/__init__.py +0 -0
  34. pytorch_frustratometer/plots/common.py +355 -0
  35. pytorch_frustratometer/plots/debug/__init__.py +0 -0
  36. pytorch_frustratometer/plots/debug/configurational_outliers.py +186 -0
  37. pytorch_frustratometer/plots/debug/frustration_results.py +279 -0
  38. pytorch_frustratometer/plots/debug/mutational_outliers.py +133 -0
  39. pytorch_frustratometer/plots/debug/offdiagonal_analysis.py +390 -0
  40. pytorch_frustratometer/plots/debug/pairwise_results.py +475 -0
  41. pytorch_frustratometer/plots/debug/singleresidue_outliers.py +128 -0
  42. pytorch_frustratometer/plots/debug/structural_outlier_map.py +291 -0
  43. pytorch_frustratometer/plots/mutations.py +1534 -0
  44. pytorch_frustratometer/plots/mvsj.py +134 -0
  45. pytorch_frustratometer/plots/paper/__init__.py +0 -0
  46. pytorch_frustratometer/plots/paper/fig1_kde.py +136 -0
  47. pytorch_frustratometer/plots/paper/frustaevo_comparison.py +339 -0
  48. pytorch_frustratometer/plots/paper/frustrapy_comparison.py +489 -0
  49. pytorch_frustratometer/plots/paper/frustration_results.py +279 -0
  50. pytorch_frustratometer/plots/paper/msfa.py +321 -0
  51. pytorch_frustratometer/plots/paper/pairwise_results.py +475 -0
  52. pytorch_frustratometer/plots/paper/paper_figures.py +2216 -0
  53. pytorch_frustratometer/plots/paper/plot_funfam_comparison.py +460 -0
  54. pytorch_frustratometer/plots/paper/validation_figures.py +629 -0
  55. pytorch_frustratometer/plots/per_protein.py +2029 -0
  56. pytorch_frustratometer/plots/plot_self_consistency.py +263 -0
  57. pytorch_frustratometer/plots/singleresidue.py +457 -0
  58. pytorch_frustratometer/structure/__init__.py +6 -0
  59. pytorch_frustratometer/structure/geometry.py +467 -0
  60. pytorch_frustratometer/structure/ideal_residues.py +206 -0
  61. pytorch_frustratometer/structure/load_structure.py +205 -0
  62. pytorch_frustratometer/structure/parse.py +507 -0
  63. pytorch_frustratometer/structure/place_ideal_atoms.py +1031 -0
  64. pytorch_frustratometer/structure/protein.py +372 -0
  65. pytorch_frustratometer/structure/residue_constants.py +751 -0
  66. pytorch_frustratometer/utils/__init__.py +1 -0
  67. pytorch_frustratometer/utils/density.py +144 -0
  68. pytorch_frustratometer/utils/utils.py +138 -0
  69. pytorch_frustratometer/utils/weights.py +125 -0
  70. pytorch_frustratometer-0.1.0.data/data/pytorch_frustratometer/data/burial_gamma.dat +20 -0
  71. pytorch_frustratometer-0.1.0.data/data/pytorch_frustratometer/data/water_gamma.dat +421 -0
  72. pytorch_frustratometer-0.1.0.dist-info/METADATA +612 -0
  73. pytorch_frustratometer-0.1.0.dist-info/RECORD +76 -0
  74. pytorch_frustratometer-0.1.0.dist-info/WHEEL +4 -0
  75. pytorch_frustratometer-0.1.0.dist-info/entry_points.txt +2 -0
  76. pytorch_frustratometer-0.1.0.dist-info/licenses/AUTHORS.md +18 -0
@@ -0,0 +1,50 @@
1
+ from .coefficients import Coefficients
2
+ import torch
3
+
4
+
5
+ def __getattr__(name: str):
6
+ if name == "analysis":
7
+ from . import analysis
8
+ return analysis
9
+ if name in ("FrustratometerDataset", "collate_frustratometer"):
10
+ from .data.dataset import FrustratometerDataset, collate_frustratometer
11
+ if name == "FrustratometerDataset":
12
+ return FrustratometerDataset
13
+ return collate_frustratometer
14
+ if name == "Frustratometer3":
15
+ from .frustratometer import Frustratometer3
16
+ return Frustratometer3
17
+ if name == "MJFrustratometer":
18
+ from .algorithm.mj import MJFrustratometer
19
+ return MJFrustratometer
20
+ if name == "SingleResidueFrustratometer":
21
+ from .algorithm.singleresidue import _get_SingleResidueFrustratometer
22
+ return _get_SingleResidueFrustratometer()
23
+ if name == "MutationalFrustratometer":
24
+ from .algorithm.mutational import _get_MutationalFrustratometer
25
+ return _get_MutationalFrustratometer()
26
+ if name == "ConfigurationalFrustratometer":
27
+ from .algorithm.configurational import _get_ConfigurationalFrustratometer
28
+ return _get_ConfigurationalFrustratometer()
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
30
+
31
+
32
+ def Frustratometer(mode, coefficients=None, energy_function="awsem", output_dir=None,
33
+ dtype=torch.float32, **coeff_kwargs):
34
+ """Build the model for ``mode``: one mode, ``"all"`` or a list of modes.
35
+
36
+ Returns ``Frustratometer3``, the model the CLI uses, for the AWSEM energy function and
37
+ ``MJFrustratometer`` for ``energy_function="mj"``. Given ``coefficients`` are copied with ``mode`` set.
38
+ """
39
+ import dataclasses
40
+ if coefficients is None:
41
+ coefficients = Coefficients.from_dict({"mode": mode, **coeff_kwargs})
42
+ else:
43
+ coefficients = dataclasses.replace(coefficients, mode=mode)
44
+
45
+ if energy_function == "mj":
46
+ from .algorithm.mj import MJFrustratometer
47
+ return MJFrustratometer(coefficients, output_dir=output_dir, dtype=dtype)
48
+
49
+ from .frustratometer import Frustratometer3
50
+ return Frustratometer3(coefficients, output_dir=output_dir, dtype=dtype)
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ cli()
@@ -0,0 +1,32 @@
1
+ from .awsem import AWSEM, _register_mode_mixins # noqa: F401
2
+ from .decoy_statistics import ( # noqa: F401
3
+ decoy_aa_frequencies, decoy_aa_weights, decoy_statistics,
4
+ )
5
+
6
+
7
+ def __getattr__(name: str):
8
+ # Lazy-load lightning-dependent classes to avoid paying the import cost
9
+ # at package init time.
10
+ if name in ("FrustratometerBase", "_FromPDBMixin"):
11
+ import importlib
12
+ mod = importlib.import_module("..base", __name__)
13
+ return getattr(mod, name)
14
+ if name in ("Frustratometer3",):
15
+ from ..frustratometer import Frustratometer3
16
+ return Frustratometer3
17
+ if name == "SingleResidueFrustratometer":
18
+ from .singleresidue import _get_SingleResidueFrustratometer
19
+ return _get_SingleResidueFrustratometer()
20
+ if name == "MutationalFrustratometer":
21
+ from .mutational import _get_MutationalFrustratometer
22
+ return _get_MutationalFrustratometer()
23
+ if name == "ConfigurationalFrustratometer":
24
+ from .configurational import _get_ConfigurationalFrustratometer
25
+ return _get_ConfigurationalFrustratometer()
26
+ # Fall back to awsem module for any other names (e.g., compute_* functions)
27
+ try:
28
+ from . import awsem as _awsem
29
+ return getattr(_awsem, name)
30
+ except AttributeError:
31
+ pass
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,575 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Union
3
+
4
+ import click
5
+ import torch
6
+ import torch.nn.functional as F
7
+ import numpy as np
8
+
9
+ from ..structure.residue_constants import CHARGES as charges
10
+ from ..utils.utils import frustration_loss as _frustration_loss
11
+
12
+ # Shared utilities live in _utils.py; re-exported here for backward compatibility.
13
+ from ..utils.utils import (
14
+ index_to_pseudo_logits,
15
+ compute_frustration_metrics,
16
+ )
17
+
18
+
19
+ class AWSEM(torch.nn.Module):
20
+ """
21
+ PyTorch implementation of the Frustratometer energy model.
22
+
23
+ This class computes four types of frustration metrics for protein
24
+ structures using AWSEM-style statistical potentials:
25
+
26
+ - Single-residue frustration
27
+ - Mutational frustration
28
+ - Configurational frustration
29
+ - Smooth frustration
30
+
31
+ The implementation is fully vectorized and supports batch
32
+ decoy evaluation for efficient computation.
33
+
34
+ Parameters
35
+ ----------
36
+ aa_type : torch.IntTensor
37
+ Residue identity indices for the protein sequence.
38
+ Shape: [N_res]. Values must be in [0, 19].
39
+
40
+ edge_index : torch.IntTensor
41
+ Pairwise contact edges used for interaction energies.
42
+ Shape: [2, N_edges].
43
+
44
+ distances : torch.FloatTensor
45
+ Pairwise distances corresponding to edges.
46
+ Shape: [N_edges] or [N_res, N_res].
47
+
48
+ phospho_sites : Optional[torch.IntTensor]
49
+ Indices of phosphorylated residues.
50
+
51
+ water_flag : bool
52
+ If True, use water-mediated interaction potentials.
53
+
54
+ n_decoys : int
55
+ Number of sampled decoys: configurational frustration, single-residue with
56
+ use_c_rand and mutational frustration with use_c_rand.
57
+
58
+ mode : list[str] | tuple[str] | str
59
+ Frustration modes to compute. Options are:
60
+ "singleresidue", "mutational", "configurational", "smooth".
61
+
62
+ run_dmfs : bool
63
+ If True, return decoy amino acid identities together with results.
64
+ """
65
+ def __init__(
66
+ self,
67
+ coefficients,
68
+ dtype: torch.dtype = torch.float32,
69
+ ):
70
+ super().__init__()
71
+ self.coefficients = coefficients
72
+ self._dtype = dtype
73
+
74
+ self.all_modes = ["singleresidue", "mutational", "configurational", "configurational_exact", "smooth"]
75
+
76
+ mode = self.coefficients.mode
77
+ if isinstance(mode, str):
78
+ if mode == "all":
79
+ mode = ["singleresidue", "mutational", "configurational"]
80
+ else:
81
+ mode = [mode]
82
+
83
+ assert isinstance(mode, (list, tuple))
84
+ assert all(m in self.all_modes for m in mode)
85
+
86
+ self.mode = mode
87
+ self.run_dmfs = self.coefficients.run_dmfs
88
+ self.n_decoys = self.coefficients.tert_frust_ndecoys
89
+ self.water_flag = True
90
+ self.phospho_flag = False
91
+ self.use_c_rand = False # set to True to exactly reproduce C++ output (slow); forces float64
92
+
93
+ data_dir = Path(__file__).parent.parent / "data"
94
+
95
+ AWSEM_ORDER = ['A','R','N','D','C','Q','E','G','H','I','L','K','M','F','P','S','T','W','Y','V']
96
+ YOUR_ORDER = ['A','C','D','E','F','G','H','I','K','L','M','N','P','Q','R','S','T','V','W','Y']
97
+
98
+ # --- Mapping from YOUR order → AWSEM index ---
99
+ your_to_awsem = torch.tensor([AWSEM_ORDER.index(aa) for aa in YOUR_ORDER], dtype=torch.long)
100
+
101
+ burial_gamma_raw = np.loadtxt(str(data_dir / "burial_gamma.dat"))
102
+ burial_gamma_raw = torch.from_numpy(burial_gamma_raw).to(dtype)
103
+
104
+ # Remap to YOUR alphabetical order
105
+ burial_gamma = torch.ones((21,3)).to(dtype)
106
+ burial_gamma[:20, :] = burial_gamma_raw[your_to_awsem, :]
107
+ self.register_buffer("burial_gamma", burial_gamma)
108
+
109
+ # Water interaction gamma
110
+ if self.water_flag:
111
+ water_gamma_raw = np.loadtxt(str(data_dir / "water_gamma.dat")) # shape (420, 2)
112
+
113
+ water_gamma_raw = torch.from_numpy(water_gamma_raw).reshape(self.coefficients.n_wells, 210, 2).to(dtype)
114
+ water_gamma_raw *= self.coefficients.k_water
115
+
116
+ # Build upper triangle indices
117
+ i_idx, j_idx = torch.triu_indices(20, 20) # each length 210
118
+
119
+ water_gamma_20 = torch.zeros((self.coefficients.n_wells, 20, 20, 2)).to(dtype)
120
+ water_gamma_20[:, i_idx, j_idx, :] = water_gamma_raw # upper triangle
121
+ water_gamma_20[:, j_idx, i_idx, :] = water_gamma_raw # mirror to lower
122
+
123
+ # Remap rows and columns to YOUR alphabetical order
124
+ water_gamma_20 = water_gamma_20[:, your_to_awsem, :][:, :, your_to_awsem, :]
125
+
126
+ water_gamma = torch.ones((self.coefficients.n_wells, 21, 21, 2)).to(dtype)
127
+ water_gamma[:, :20, :20, :] = water_gamma_20
128
+ else:
129
+ water_gamma = torch.ones((self.coefficients.n_wells, 21, 21, 2)).to(dtype)
130
+
131
+ self.register_buffer("water_gamma", water_gamma)
132
+
133
+ # Register charge lookup as buffer so it moves with .to(device)
134
+ self.register_buffer("charge_lookup", torch.IntTensor(charges))
135
+
136
+ @property
137
+ def dtype(self) -> torch.dtype:
138
+ """float64 when use_c_rand is set (required for C++ reproducibility), else self._dtype."""
139
+ return torch.float64 if self.use_c_rand else self._dtype
140
+
141
+ # ------------------------------------------------------------------
142
+ # Helpers
143
+ # ------------------------------------------------------------------
144
+
145
+ def _b_idx(self, B: int, E: int, device) -> torch.Tensor:
146
+ """[B, E] batch index tensor for advanced indexing."""
147
+ return torch.arange(B, device=device)[:, None].expand(-1, E)
148
+
149
+ def _gather_node(
150
+ self,
151
+ node_features: torch.Tensor, # [B, L] or [B, L, D]
152
+ edge_index: torch.Tensor, # [B, 2, E]
153
+ ):
154
+ """
155
+ Gather node features for src and dst of each edge.
156
+
157
+ Returns:
158
+ src_feats: [B, E] or [B, E, D]
159
+ dst_feats: [B, E] or [B, E, D]
160
+ """
161
+ B, _, E = edge_index.shape
162
+ b_idx = self._b_idx(B, E, edge_index.device)
163
+ src = edge_index[:, 0, :] # [B, E]
164
+ dst = edge_index[:, 1, :] # [B, E]
165
+ return node_features[b_idx, src], node_features[b_idx, dst]
166
+
167
+ def _gather_edge_distances(
168
+ self,
169
+ distance: torch.Tensor, # [B, L, L]
170
+ edge_index: torch.Tensor, # [B, 2, E]
171
+ ) -> torch.Tensor: # [B, E]
172
+ B, _, E = edge_index.shape
173
+ b_idx = self._b_idx(B, E, edge_index.device)
174
+ return distance[b_idx, edge_index[:, 0, :], edge_index[:, 1, :]]
175
+
176
+
177
+ def forward(self, batch):
178
+ """
179
+ Compute requested frustration modes.
180
+
181
+ Returns
182
+ -------
183
+ dict
184
+ Dictionary containing requested frustration metrics.
185
+ Keys may include:
186
+
187
+ - "singleresidue"
188
+ - "mutational"
189
+ - "configurational"
190
+ - "smooth"
191
+ """
192
+
193
+ result = {}
194
+ with torch.no_grad():
195
+ if "singleresidue" in self.mode or "smooth" in self.mode:
196
+ fi, nat_e, stats, sr_metrics, sr_losses, sr_mutations = \
197
+ self.compute_tert_frust_singleresidue(batch)
198
+ # smooth is derived from the single-residue FI, so keep it in the result even when
199
+ # only "smooth" was requested: the structure (.pdb with B-factors) and the .pml
200
+ # files that load it are written by the single-residue writer.
201
+ if "singleresidue" in self.mode or "smooth" in self.mode:
202
+ result["singleresidue"] = fi
203
+ result["_singleresidue_native_energy"] = nat_e
204
+ result["_singleresidue_decoy_stats"] = stats
205
+ if sr_metrics is not None:
206
+ result["_singleresidue_metrics"] = sr_metrics
207
+ if sr_losses is not None:
208
+ result["_singleresidue_losses"] = sr_losses
209
+ if sr_mutations is not None:
210
+ result["_singleresidue_mutations"] = sr_mutations
211
+ if "smooth" in self.mode:
212
+ # F_ij^smooth = -(Fi + Fj) (Guan et al. 2024, PNAS doi:10.1073/pnas.2410662121)
213
+ fi_flat = fi.squeeze(-1) if fi.dim() == 3 else fi # [B, L]
214
+ fi_src, fi_dst = self._gather_node(fi_flat, batch["edge_index"])
215
+ result["smooth"] = -(fi_src + fi_dst)
216
+ if "configurational" in self.mode or "configurational_exact" in self.mode or "mutational" in self.mode:
217
+ result.update(self.compute_tert_frust(batch))
218
+ return result
219
+
220
+ # ------------------------------------------------------------------
221
+ # Mutational / configurational frustration
222
+ # ------------------------------------------------------------------
223
+
224
+ def compute_tert_frust(self, batch):
225
+ """
226
+ Compute pairwise frustration metrics.
227
+
228
+ This function evaluates mutational and configurational frustration
229
+ for all interacting residue pairs.
230
+
231
+ Returns
232
+ -------
233
+ dict
234
+ Dictionary containing frustration indices for each pair.
235
+ """
236
+ native_energy = self.compute_native_ixn(batch)
237
+
238
+
239
+ decoy_results = self.compute_decoy_ixns(batch)
240
+
241
+ # Native aa types at each edge: [B, E]
242
+ aa_src, aa_dst = self._gather_node(batch["aa_type"], batch["edge_index"])
243
+ base_aa = torch.stack([aa_src, aa_dst], dim=0) # [2, B, E]
244
+
245
+ out = {}
246
+ if "mutational" in self.mode:
247
+ fi_mut, nat_mut, mut_stats, mut_metrics, mut_losses, mut_mutations = \
248
+ self.compute_tert_frust_mutational(batch)
249
+ out["_mutational_native_energy"] = nat_mut
250
+ out["_mutational_decoy_stats"] = mut_stats
251
+ out["mutational"] = fi_mut
252
+ if mut_mutations is not None:
253
+ out["_mutational_mutations"] = mut_mutations
254
+ if mut_metrics is not None:
255
+ out["_mutational_metrics"] = mut_metrics
256
+ if mut_losses is not None:
257
+ out["_mutational_losses"] = mut_losses
258
+
259
+ if "configurational_exact" in decoy_results:
260
+ conf_stats = decoy_results["configurational_exact"]["stats"] # [B, 2]
261
+ fi_conf_exact = self.compute_frustration_index(
262
+ native_energy["configurational_exact"], conf_stats.unsqueeze(1)
263
+ )
264
+ out["_configurational_exact_native_energy"] = native_energy["configurational_exact"]
265
+ out["_configurational_exact_decoy_stats"] = conf_stats
266
+ out["configurational_exact"] = fi_conf_exact
267
+
268
+ if "configurational" in decoy_results:
269
+ conf_stats = decoy_results["configurational"]["stats"] # [B, 2]
270
+ # Unsqueeze to [B, 1, 2] so mean/std broadcast over the E edge dimension.
271
+ fi_conf = self.compute_frustration_index(native_energy["configurational"], conf_stats.unsqueeze(1))
272
+
273
+ out["_configurational_native_energy"] = native_energy["configurational"]
274
+ out["_configurational_decoy_stats"] = conf_stats
275
+
276
+ out["configurational"] = fi_conf
277
+ if self.run_dmfs:
278
+ conf_mut = decoy_results["configurational"]
279
+ if "energies" in conf_mut:
280
+ out["_configurational_mutations"] = {
281
+ "energies": conf_mut["energies"],
282
+ "res_type": conf_mut["res_type"],
283
+ }
284
+
285
+ if "configurational_frust_true" in batch and "configurational_frust_mask" in batch:
286
+ true_conf = batch["configurational_frust_true"].to(fi_conf.device)
287
+ mask_conf = batch["configurational_frust_mask"].to(fi_conf.device).bool()
288
+ conf_metrics = compute_frustration_metrics(
289
+ fi_conf, true_conf, mask_conf,
290
+ threshold=self.coefficients.frustration_thresholds[1],
291
+ )
292
+ out["_configurational_metrics"] = conf_metrics
293
+ # FrustrAI-Fold-compatible losses
294
+ pseudo_logits_conf = index_to_pseudo_logits(
295
+ fi_conf, self.coefficients.frustration_thresholds[1]
296
+ ) # [B, E, 3]
297
+ welltype_conf = batch.get("configurational_welltype")
298
+ idx_loss_conf, cls_loss_conf = _frustration_loss(
299
+ mode=2,
300
+ pred_frustration_index=fi_conf.unsqueeze(-1),
301
+ pred_frustration_class=pseudo_logits_conf,
302
+ true_frustration_index=true_conf,
303
+ seq_mask=mask_conf,
304
+ welltype=welltype_conf,
305
+ stage="test",
306
+ )
307
+ out["_configurational_losses"] = (idx_loss_conf.item(), cls_loss_conf.item())
308
+
309
+ return out
310
+
311
+ def compute_native_ixn(self, batch):
312
+ """
313
+ Compute native interaction energies.
314
+
315
+ Returns
316
+ -------
317
+ dict
318
+ Dictionary with native energies for each frustration mode.
319
+ """
320
+
321
+ result = {}
322
+ B, _, E = batch["edge_index"].shape
323
+ device = batch["edge_index"].device
324
+
325
+ # Gather per-edge quantities
326
+ aa_src, aa_dst = self._gather_node(batch["aa_type"], batch["edge_index"]) # [B, E]
327
+ rho_src, rho_dst = self._gather_node(batch["residue_density"], batch["edge_index"]) # [B, E]
328
+ edge_distances = self._gather_edge_distances(batch["distance"], batch["edge_index"]) # [B, E]
329
+
330
+ aa_type = torch.stack([aa_src, aa_dst], dim=0) # [2, B, E]
331
+ density = torch.stack([rho_src, rho_dst], dim=0) # [2, B, E]
332
+
333
+ src = batch["edge_index"][:, 0, :] # [B, E]
334
+ dst = batch["edge_index"][:, 1, :] # [B, E]
335
+
336
+ electrostatics = self.calculate_electrostatics(aa_src, aa_dst, edge_distances) # [B, E]
337
+
338
+ if "configurational" in self.mode or "configurational_exact" in self.mode:
339
+ nat_conf = self.compute_configurational_ixn(
340
+ batch,
341
+ aa_type,
342
+ edge_distances,
343
+ density,
344
+ electrostatics,
345
+ return_decoy_energies=True, # return raw [B, E] energy
346
+ return_stats=False,
347
+ ) # [B, E]
348
+ if "configurational" in self.mode:
349
+ result["configurational"] = nat_conf
350
+ if "configurational_exact" in self.mode:
351
+ result["configurational_exact"] = nat_conf
352
+
353
+ return result
354
+
355
+ def compute_decoy_ixns(self, batch):
356
+ """
357
+ Generate decoy interactions and compute their energies.
358
+
359
+ Returns
360
+ -------
361
+ dict
362
+ Dictionary containing decoy statistics and sampled amino acids.
363
+ """
364
+ from .configurational import sample_configurational_decoys
365
+
366
+ results = {}
367
+
368
+ if "configurational" in self.mode:
369
+ decoy_distances, decoy_density, decoy_aa = sample_configurational_decoys(
370
+ distance_matrix=batch["distance"],
371
+ residue_density=batch["residue_density"],
372
+ aa_type=batch["aa_type"],
373
+ seq_mask=batch["seq_mask"],
374
+ n_decoys=self.n_decoys,
375
+ cutoff=self.coefficients.tert_frust_cutoff,
376
+ use_c_rand=self.use_c_rand,
377
+ )
378
+
379
+ decoy_elec = self.calculate_electrostatics(decoy_aa[0], decoy_aa[1], decoy_distances)
380
+
381
+ conf_stats = self._compute_energy(
382
+ batch, decoy_aa, decoy_distances, decoy_density, decoy_elec,
383
+ return_decoy_energies=self.run_dmfs, return_stats=True
384
+ )
385
+
386
+ if self.run_dmfs:
387
+ results["configurational"] = {
388
+ "stats": conf_stats[1], "energies": conf_stats[0], "res_type": decoy_aa
389
+ }
390
+ else:
391
+ results["configurational"] = {"stats": conf_stats}
392
+
393
+ if "configurational_exact" in self.mode:
394
+ from .configurational_exact import enumerate_configurational_decoys
395
+ exact_stats = enumerate_configurational_decoys(self, batch)
396
+ results["configurational_exact"] = {"stats": exact_stats}
397
+
398
+ return results
399
+
400
+ # ------------------------------------------------------------------
401
+ # Energy terms
402
+ # ------------------------------------------------------------------
403
+
404
+ def compute_frustration_index(self, energy, stats):
405
+ """stats: [..., 2] where [..., 0]=mean, [..., 1]=std."""
406
+ std = stats[..., 1]
407
+ fi = (stats[..., 0] - energy) / std.clamp(min=1e-8)
408
+ # std==0 means all decoys gave identical energy → no information → FI=0 (neutral)
409
+ return torch.where(std < 1e-8, torch.zeros_like(fi), fi)
410
+
411
+ def compute_burial_energy(
412
+ self,
413
+ batch,
414
+ res_type,
415
+ rho
416
+ ):
417
+ """
418
+ Args:
419
+ res_type: [B, L] or [2, B, E] or [B, n_decoys]
420
+ rho: same shape as res_type
421
+ Returns:
422
+ burial_energy: same shape as res_type (float64)
423
+ """
424
+ device = res_type.device
425
+ rho = rho.to(self.dtype)
426
+
427
+ t_min = torch.tanh(self.coefficients.burial_kappa * (rho.unsqueeze(-1) - self.coefficients.burial_ro_min.to(device, dtype=self.dtype))) # [..., L, 3]
428
+ t_max = torch.tanh(self.coefficients.burial_kappa * (self.coefficients.burial_ro_max.to(device, dtype=self.dtype) - rho.unsqueeze(-1))) # [..., L, 3]
429
+
430
+ # C++ uses (t_min + t_max), NOT 0.25*(1+t_min)*(1+t_max)
431
+ theta = t_min + t_max # [..., L, 3]
432
+
433
+ burial_gamma = self.get_burial_gamma(batch, res_type).to(device, dtype=self.dtype) # [..., L, 3]
434
+ burial_energy = -0.5 * self.coefficients.k_burial * (burial_gamma * theta).sum(dim=-1)
435
+
436
+ return burial_energy
437
+
438
+ def compute_water_energy(
439
+ self,
440
+ batch,
441
+ distances,
442
+ density1,
443
+ density2,
444
+ aa_type1,
445
+ aa_type2
446
+ ):
447
+ """
448
+ Fully vectorized water energy computation for all edges at once.
449
+
450
+ edge_index: [2, N_edges] tensor of interacting residue indices
451
+ distances: [N_edges] tensor of distances
452
+ residue_density: [N_res] tensor of per-residue ρ
453
+ aa_type: [N_res] tensor of residue types
454
+ water_gamma: [n_wells, 20, 20, 2] gamma table
455
+ """
456
+ device = distances.device
457
+ distances = distances.to(self.dtype)
458
+ density1 = density1.to(self.dtype)
459
+ density2 = density2.to(self.dtype)
460
+
461
+ # Get leading dimensions from aa_type1
462
+ leading_shape = aa_type1.shape[:-1] # Everything except last dim (edges)
463
+ n_edges = aa_type1.shape[-1]
464
+
465
+ sigma_wat_i = (1.0 - torch.tanh(self.coefficients.water_kappa_sigma * (density1 - self.coefficients.threshold)))
466
+ sigma_wat_j = (1.0 - torch.tanh(self.coefficients.water_kappa_sigma * (density2 - self.coefficients.threshold)))
467
+ sigma_wat = 0.25 * sigma_wat_i * sigma_wat_j
468
+ sigma_prot = 1.0 - sigma_wat
469
+
470
+ # Water gamma for each pair
471
+ water_gamma = self.get_water_gamma(batch) # [n_wells, 20, 20, 2]
472
+
473
+ # Flatten leading dims for indexing, then reshape back
474
+ aa_type1_flat = aa_type1.reshape(-1, n_edges) # [D*B, N_edges]
475
+ aa_type2_flat = aa_type2.reshape(-1, n_edges)
476
+
477
+ # Index and reshape to preserve leading dims
478
+ water_gamma_direct_flat = water_gamma[0, aa_type1_flat, aa_type2_flat, :].mean(dim=-1) # [D*B, N_edges]
479
+ water_gamma_prot_mediated_flat = water_gamma[1, aa_type1_flat, aa_type2_flat, 0] # [D*B, N_edges]
480
+ water_gamma_wat_mediated_flat = water_gamma[1, aa_type1_flat, aa_type2_flat, 1] # [D*B, N_edges]
481
+
482
+ # Reshape back to include leading dims
483
+ water_gamma_direct = water_gamma_direct_flat.reshape(*leading_shape, n_edges)
484
+ water_gamma_prot_mediated = water_gamma_prot_mediated_flat.reshape(*leading_shape, n_edges)
485
+ water_gamma_wat_mediated = water_gamma_wat_mediated_flat.reshape(*leading_shape, n_edges)
486
+
487
+ sigma_gamma_direct = water_gamma_direct
488
+ sigma_gamma_mediated = sigma_prot * water_gamma_prot_mediated + sigma_wat * water_gamma_wat_mediated
489
+
490
+ # Theta functions for direct and mediated wells
491
+ # Direct well
492
+ t_min_direct = torch.tanh(self.coefficients.water_kappa * (distances - self.coefficients.well_rmin[0].to(device, dtype=self.dtype)))
493
+ t_max_direct = torch.tanh(self.coefficients.water_kappa * (self.coefficients.well_rmax[0].to(device, dtype=self.dtype) - distances))
494
+ theta_direct = 0.25 * (1 + t_min_direct) * (1 + t_max_direct)
495
+
496
+ # Mediated well
497
+ t_min_mediated = torch.tanh(self.coefficients.water_kappa * (distances - self.coefficients.well_rmin[1].to(device, dtype=self.dtype)))
498
+ t_max_mediated = torch.tanh(self.coefficients.water_kappa * (self.coefficients.well_rmax[1].to(device, dtype=self.dtype) - distances))
499
+ theta_mediated = 0.25 * (1 + t_min_mediated) * (1 + t_max_mediated)
500
+
501
+ # Total water energy
502
+ water_energy = -(sigma_gamma_direct * theta_direct + sigma_gamma_mediated * theta_mediated)
503
+
504
+ return water_energy
505
+
506
+ def calculate_electrostatics(
507
+ self,
508
+ aa_type1,
509
+ aa_type2,
510
+ distances,
511
+ ):
512
+ """
513
+ Args:
514
+ aa_type1/2: int tensors, any shape
515
+ distances: float tensor, same shape
516
+ Returns:
517
+ V: same shape as distances
518
+ """
519
+ if not self.coefficients.use_electrostatics:
520
+ return torch.zeros_like(distances, dtype=self.dtype)
521
+
522
+ q_i = self.charge_lookup[aa_type1]
523
+ q_j = self.charge_lookup[aa_type2]
524
+
525
+ charged_mask = (q_i != 0) & (q_j != 0)
526
+
527
+ k = self.get_k_ij(q_i, q_j)
528
+ # Clamp to avoid 0/0=NaN for padded entries (distance=0, q=0).
529
+ # charged_mask zeros the result for neutral pairs anyway.
530
+ distances_d = distances.to(self.dtype).clamp(min=1e-6)
531
+
532
+ V = k * q_i.to(self.dtype) * q_j.to(self.dtype) / distances_d * \
533
+ torch.exp(-self.coefficients.k_screening * distances_d / self.coefficients.screening_length)
534
+
535
+ return V * charged_mask.to(self.dtype)
536
+
537
+ def get_k_ij(self, q_i, q_j):
538
+ if (self.coefficients.k_PlusMinus == self.coefficients.k_PlusPlus
539
+ == self.coefficients.k_MinusMinus):
540
+ return self.coefficients.k_PlusMinus
541
+
542
+ k = torch.full_like(q_i, self.coefficients.k_PlusMinus) # default opposite sign
543
+ k = torch.where((q_i > 0) & (q_j > 0),
544
+ torch.full_like(k, self.coefficients.k_PlusPlus), k)
545
+ k = torch.where((q_i < 0) & (q_j < 0),
546
+ torch.full_like(k, self.coefficients.k_MinusMinus), k)
547
+ return k
548
+
549
+ def get_burial_gamma(self, batch, res_type):
550
+ if not self.phospho_flag or "phospho_map" not in batch:
551
+ return self.burial_gamma[res_type.long()]
552
+ else:
553
+ res_type_ = res_type.clone()
554
+ res_type_[batch["phopho_map"]] = 6 # use burial gamma for glutamine instead of serine
555
+ return self.burial_gamma[res_type_]
556
+
557
+ def get_water_gamma(self, batch):
558
+ if not self.phospho_flag or "phospho_map" not in batch:
559
+ return self.water_gamma
560
+ else:
561
+ water_gamma = self.water_gamma.clone()
562
+ water_gamma[batch["phopho_map"]] = self.coefficients.phosph_water_gamma[batch["phopho_map"]]
563
+ return water_gamma
564
+
565
+
566
+ def _register_mode_mixins():
567
+ from .singleresidue import SingleResidueMixin
568
+ from .configurational import ConfigurationalMixin
569
+ from .mutational import MutationalMixin
570
+ import torch.nn as nn
571
+ AWSEM.__bases__ = (SingleResidueMixin, ConfigurationalMixin, MutationalMixin, nn.Module)
572
+
573
+
574
+ _register_mode_mixins()
575
+