molbuilder 1.0.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 (78) hide show
  1. molbuilder/__init__.py +8 -0
  2. molbuilder/__main__.py +6 -0
  3. molbuilder/atomic/__init__.py +4 -0
  4. molbuilder/atomic/bohr.py +235 -0
  5. molbuilder/atomic/quantum_atom.py +334 -0
  6. molbuilder/atomic/quantum_numbers.py +196 -0
  7. molbuilder/atomic/wavefunctions.py +297 -0
  8. molbuilder/bonding/__init__.py +4 -0
  9. molbuilder/bonding/covalent.py +442 -0
  10. molbuilder/bonding/lewis.py +347 -0
  11. molbuilder/bonding/vsepr.py +433 -0
  12. molbuilder/cli/__init__.py +1 -0
  13. molbuilder/cli/demos.py +516 -0
  14. molbuilder/cli/menu.py +127 -0
  15. molbuilder/cli/wizard.py +831 -0
  16. molbuilder/core/__init__.py +6 -0
  17. molbuilder/core/bond_data.py +170 -0
  18. molbuilder/core/constants.py +51 -0
  19. molbuilder/core/element_properties.py +183 -0
  20. molbuilder/core/elements.py +181 -0
  21. molbuilder/core/geometry.py +232 -0
  22. molbuilder/gui/__init__.py +2 -0
  23. molbuilder/gui/app.py +286 -0
  24. molbuilder/gui/canvas3d.py +115 -0
  25. molbuilder/gui/dialogs.py +117 -0
  26. molbuilder/gui/event_handler.py +118 -0
  27. molbuilder/gui/sidebar.py +105 -0
  28. molbuilder/gui/toolbar.py +71 -0
  29. molbuilder/io/__init__.py +1 -0
  30. molbuilder/io/json_io.py +146 -0
  31. molbuilder/io/mol_sdf.py +169 -0
  32. molbuilder/io/pdb.py +184 -0
  33. molbuilder/io/smiles_io.py +47 -0
  34. molbuilder/io/xyz.py +103 -0
  35. molbuilder/molecule/__init__.py +2 -0
  36. molbuilder/molecule/amino_acids.py +919 -0
  37. molbuilder/molecule/builders.py +257 -0
  38. molbuilder/molecule/conformations.py +70 -0
  39. molbuilder/molecule/functional_groups.py +484 -0
  40. molbuilder/molecule/graph.py +712 -0
  41. molbuilder/molecule/peptides.py +13 -0
  42. molbuilder/molecule/stereochemistry.py +6 -0
  43. molbuilder/process/__init__.py +3 -0
  44. molbuilder/process/conditions.py +260 -0
  45. molbuilder/process/costing.py +316 -0
  46. molbuilder/process/purification.py +285 -0
  47. molbuilder/process/reactor.py +297 -0
  48. molbuilder/process/safety.py +476 -0
  49. molbuilder/process/scale_up.py +427 -0
  50. molbuilder/process/solvent_systems.py +204 -0
  51. molbuilder/reactions/__init__.py +3 -0
  52. molbuilder/reactions/functional_group_detect.py +728 -0
  53. molbuilder/reactions/knowledge_base.py +1716 -0
  54. molbuilder/reactions/reaction_types.py +102 -0
  55. molbuilder/reactions/reagent_data.py +1248 -0
  56. molbuilder/reactions/retrosynthesis.py +1430 -0
  57. molbuilder/reactions/synthesis_route.py +377 -0
  58. molbuilder/reports/__init__.py +158 -0
  59. molbuilder/reports/cost_report.py +206 -0
  60. molbuilder/reports/molecule_report.py +279 -0
  61. molbuilder/reports/safety_report.py +296 -0
  62. molbuilder/reports/synthesis_report.py +283 -0
  63. molbuilder/reports/text_formatter.py +170 -0
  64. molbuilder/smiles/__init__.py +4 -0
  65. molbuilder/smiles/parser.py +487 -0
  66. molbuilder/smiles/tokenizer.py +291 -0
  67. molbuilder/smiles/writer.py +375 -0
  68. molbuilder/visualization/__init__.py +1 -0
  69. molbuilder/visualization/bohr_viz.py +166 -0
  70. molbuilder/visualization/molecule_viz.py +368 -0
  71. molbuilder/visualization/quantum_viz.py +434 -0
  72. molbuilder/visualization/theme.py +12 -0
  73. molbuilder-1.0.0.dist-info/METADATA +360 -0
  74. molbuilder-1.0.0.dist-info/RECORD +78 -0
  75. molbuilder-1.0.0.dist-info/WHEEL +5 -0
  76. molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
  77. molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
  78. molbuilder-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,484 @@
1
+ """
2
+ Functional group builder functions.
3
+
4
+ Each function attaches a functional group to an existing atom in a
5
+ Molecule and returns a dict mapping atom labels to their new indices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import numpy as np
13
+
14
+ from molbuilder.molecule.graph import Molecule, Hybridization
15
+ from molbuilder.core.bond_data import bond_length, SP3_ANGLE, SP2_ANGLE, SP_ANGLE
16
+ from molbuilder.core.geometry import normalize, available_tetrahedral_dirs, place_atom_zmatrix
17
+
18
+
19
+ # ===================================================================
20
+ # Constants
21
+ # ===================================================================
22
+
23
+ # Bond lengths not covered by STANDARD_BOND_LENGTHS (Angstroms)
24
+ AMIDE_CN = 1.33 # amide C-N (partial double bond character)
25
+ SS_BOND = 2.05 # disulfide S-S
26
+ CC_AROMATIC = 1.40 # aromatic C-C (average)
27
+ CN_AROMATIC = 1.34 # aromatic C-N (average)
28
+ CO_CARBOXYL = 1.25 # carboxylate C-O (resonance average)
29
+
30
+
31
+ def add_hydroxyl(mol: Molecule, attach_to: int,
32
+ angle_ref: int | None = None,
33
+ dihedral_ref: int | None = None,
34
+ dihedral_deg: float = 180.0) -> dict[str, int]:
35
+ """Attach -OH to an atom. Returns {'O': idx, 'H': idx}."""
36
+ if angle_ref is None:
37
+ nbrs = mol.neighbors(attach_to)
38
+ angle_ref = nbrs[0] if nbrs else 0
39
+
40
+ o_idx = mol.add_atom_bonded(
41
+ "O", attach_to, bond_order=1, angle_ref=angle_ref,
42
+ dihedral_ref=dihedral_ref, dihedral_deg=dihedral_deg,
43
+ hybridization=Hybridization.SP3)
44
+
45
+ h_idx = mol.add_atom_bonded(
46
+ "H", o_idx, bond_order=1, angle_ref=attach_to,
47
+ bond_angle_deg=SP3_ANGLE, dihedral_deg=180.0,
48
+ rotatable=False)
49
+
50
+ return {"O": o_idx, "H": h_idx}
51
+
52
+
53
+ def add_amino(mol: Molecule, attach_to: int,
54
+ angle_ref: int | None = None,
55
+ dihedral_ref: int | None = None,
56
+ dihedral_deg: float = 180.0) -> dict[str, int]:
57
+ """Attach -NH2 to an atom. Returns {'N': idx, 'H1': idx, 'H2': idx}."""
58
+ if angle_ref is None:
59
+ nbrs = mol.neighbors(attach_to)
60
+ angle_ref = nbrs[0] if nbrs else 0
61
+
62
+ n_idx = mol.add_atom_bonded(
63
+ "N", attach_to, bond_order=1, angle_ref=angle_ref,
64
+ dihedral_ref=dihedral_ref, dihedral_deg=dihedral_deg,
65
+ hybridization=Hybridization.SP3)
66
+
67
+ h1 = mol.add_atom_bonded(
68
+ "H", n_idx, bond_order=1, angle_ref=attach_to,
69
+ bond_angle_deg=SP3_ANGLE, dihedral_deg=120.0,
70
+ rotatable=False)
71
+
72
+ h2 = mol.add_atom_bonded(
73
+ "H", n_idx, bond_order=1, angle_ref=attach_to,
74
+ bond_angle_deg=SP3_ANGLE, dihedral_deg=-120.0,
75
+ rotatable=False)
76
+
77
+ return {"N": n_idx, "H1": h1, "H2": h2}
78
+
79
+
80
+ def add_carboxyl(mol: Molecule, attach_to: int,
81
+ angle_ref: int | None = None,
82
+ dihedral_ref: int | None = None,
83
+ dihedral_deg: float = 180.0) -> dict[str, int]:
84
+ """Attach -COOH to an atom. Returns {'C': idx, 'O1': idx, 'O2': idx, 'H': idx}."""
85
+ if angle_ref is None:
86
+ nbrs = mol.neighbors(attach_to)
87
+ angle_ref = nbrs[0] if nbrs else 0
88
+
89
+ c_idx = mol.add_atom_bonded(
90
+ "C", attach_to, bond_order=1, angle_ref=angle_ref,
91
+ dihedral_ref=dihedral_ref, dihedral_deg=dihedral_deg,
92
+ hybridization=Hybridization.SP2)
93
+
94
+ # C=O (double bond)
95
+ o1 = mol.add_atom_bonded(
96
+ "O", c_idx, bond_order=2, angle_ref=attach_to,
97
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=0.0,
98
+ rotatable=False)
99
+
100
+ # C-OH (single bond)
101
+ o2 = mol.add_atom_bonded(
102
+ "O", c_idx, bond_order=1, angle_ref=attach_to,
103
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=180.0,
104
+ hybridization=Hybridization.SP3)
105
+
106
+ h = mol.add_atom_bonded(
107
+ "H", o2, bond_order=1, angle_ref=c_idx,
108
+ bond_angle_deg=SP3_ANGLE, dihedral_deg=0.0,
109
+ rotatable=False)
110
+
111
+ return {"C": c_idx, "O1": o1, "O2": o2, "H": h}
112
+
113
+
114
+ def add_carbonyl(mol: Molecule, attach_to: int,
115
+ angle_ref: int | None = None,
116
+ dihedral_deg: float = 0.0) -> dict[str, int]:
117
+ """Attach C=O to an atom. Returns {'O': idx}."""
118
+ if angle_ref is None:
119
+ nbrs = mol.neighbors(attach_to)
120
+ angle_ref = nbrs[0] if nbrs else 0
121
+
122
+ o_idx = mol.add_atom_bonded(
123
+ "O", attach_to, bond_order=2, angle_ref=angle_ref,
124
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=dihedral_deg,
125
+ rotatable=False)
126
+
127
+ return {"O": o_idx}
128
+
129
+
130
+ def add_amide(mol: Molecule, attach_to: int,
131
+ angle_ref: int | None = None,
132
+ dihedral_ref: int | None = None,
133
+ dihedral_deg: float = 180.0) -> dict[str, int]:
134
+ """Attach -CONH2 to an atom. Returns dict of created indices."""
135
+ if angle_ref is None:
136
+ nbrs = mol.neighbors(attach_to)
137
+ angle_ref = nbrs[0] if nbrs else 0
138
+
139
+ c_idx = mol.add_atom_bonded(
140
+ "C", attach_to, bond_order=1, angle_ref=angle_ref,
141
+ dihedral_ref=dihedral_ref, dihedral_deg=dihedral_deg,
142
+ hybridization=Hybridization.SP2)
143
+
144
+ o_idx = mol.add_atom_bonded(
145
+ "O", c_idx, bond_order=2, angle_ref=attach_to,
146
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=0.0,
147
+ rotatable=False)
148
+
149
+ n_idx = mol.add_atom_bonded(
150
+ "N", c_idx, bond_order=1, angle_ref=attach_to,
151
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=180.0,
152
+ bond_length=AMIDE_CN,
153
+ hybridization=Hybridization.SP2)
154
+
155
+ h1 = mol.add_atom_bonded(
156
+ "H", n_idx, bond_order=1, angle_ref=c_idx,
157
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=0.0,
158
+ rotatable=False)
159
+
160
+ h2 = mol.add_atom_bonded(
161
+ "H", n_idx, bond_order=1, angle_ref=c_idx,
162
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=180.0,
163
+ rotatable=False)
164
+
165
+ return {"C": c_idx, "O": o_idx, "N": n_idx, "H1": h1, "H2": h2}
166
+
167
+
168
+ def add_thiol(mol: Molecule, attach_to: int,
169
+ angle_ref: int | None = None,
170
+ dihedral_ref: int | None = None,
171
+ dihedral_deg: float = 180.0) -> dict[str, int]:
172
+ """Attach -SH to an atom. Returns {'S': idx, 'H': idx}."""
173
+ if angle_ref is None:
174
+ nbrs = mol.neighbors(attach_to)
175
+ angle_ref = nbrs[0] if nbrs else 0
176
+
177
+ s_idx = mol.add_atom_bonded(
178
+ "S", attach_to, bond_order=1, angle_ref=angle_ref,
179
+ dihedral_ref=dihedral_ref, dihedral_deg=dihedral_deg,
180
+ hybridization=Hybridization.SP3)
181
+
182
+ h_idx = mol.add_atom_bonded(
183
+ "H", s_idx, bond_order=1, angle_ref=attach_to,
184
+ bond_angle_deg=96.0, # H-S-C angle ~96 deg
185
+ dihedral_deg=180.0,
186
+ rotatable=False)
187
+
188
+ return {"S": s_idx, "H": h_idx}
189
+
190
+
191
+ def _place_planar_ring(mol: Molecule, attach_to: int,
192
+ symbols: list[str],
193
+ bond_length_val: float = CC_AROMATIC,
194
+ angle_ref: int | None = None,
195
+ dihedral_deg: float = 90.0) -> list[int]:
196
+ """Place a planar ring of atoms bonded to *attach_to*.
197
+
198
+ The ring is built analytically: vertices of a regular polygon with
199
+ the first atom bonded to attach_to. Ring normal is set perpendicular
200
+ to the attach_to bond direction.
201
+
202
+ Returns a list of atom indices for the ring atoms.
203
+ """
204
+ n_ring = len(symbols)
205
+ if angle_ref is None:
206
+ nbrs = mol.neighbors(attach_to)
207
+ angle_ref = nbrs[0] if nbrs else 0
208
+
209
+ center_pos = mol.atoms[attach_to].position
210
+ ref_pos = mol.atoms[angle_ref].position
211
+
212
+ # Bond direction from attach_to toward angle_ref
213
+ bond_dir = normalize(center_pos - ref_pos)
214
+
215
+ # Ring normal: perpendicular to bond direction
216
+ up = np.array([0.0, 0.0, 1.0])
217
+ if abs(np.dot(bond_dir, up)) > 0.9:
218
+ up = np.array([0.0, 1.0, 0.0])
219
+ ring_normal = normalize(np.cross(bond_dir, up))
220
+
221
+ # Rotate ring_normal by dihedral_deg around bond_dir
222
+ from molbuilder.core.geometry import rotation_matrix
223
+ R_dih = rotation_matrix(bond_dir, math.radians(dihedral_deg))
224
+ ring_normal = R_dih @ ring_normal
225
+
226
+ # In-plane direction perpendicular to ring normal and along bond
227
+ in_plane = normalize(np.cross(ring_normal, bond_dir))
228
+
229
+ # Ring radius from bond length
230
+ # For a regular n-gon with edge length L: R = L / (2 * sin(pi/n))
231
+ ring_radius = bond_length_val / (2.0 * math.sin(math.pi / n_ring))
232
+
233
+ # Center of ring: along bond_dir from attach_to
234
+ # The first ring atom bonds to attach_to, so the ring center
235
+ # is offset by ring_radius from the first atom position.
236
+ # First ring atom is at distance bond_length from attach_to
237
+ # in the bond_dir direction.
238
+ first_atom_pos = center_pos + bond_dir * bond_length_val
239
+ # Ring center is at first_atom_pos - ring_radius * bond_dir
240
+ # (ring is oriented so first atom is closest to attach_to)
241
+ # Actually, let the first atom be at the "top" of the ring.
242
+ ring_center = first_atom_pos - ring_radius * bond_dir
243
+
244
+ ring_indices = []
245
+ for i in range(n_ring):
246
+ angle = 2.0 * math.pi * i / n_ring
247
+ pos = (ring_center
248
+ + ring_radius * math.cos(angle) * bond_dir
249
+ + ring_radius * math.sin(angle) * in_plane)
250
+ hyb = Hybridization.SP2
251
+ idx = mol.add_atom(symbols[i], pos, hyb)
252
+ ring_indices.append(idx)
253
+
254
+ # Bond ring atoms to each other
255
+ for i in range(n_ring):
256
+ j = (i + 1) % n_ring
257
+ if i < n_ring - 1:
258
+ mol.add_bond(ring_indices[i], ring_indices[j],
259
+ order=1, rotatable=False)
260
+ else:
261
+ mol.close_ring(ring_indices[i], ring_indices[j])
262
+
263
+ # Bond first ring atom to attach_to
264
+ mol.add_bond(attach_to, ring_indices[0], order=1, rotatable=True)
265
+
266
+ return ring_indices
267
+
268
+
269
+ def add_phenyl_ring(mol: Molecule, attach_to: int,
270
+ angle_ref: int | None = None,
271
+ dihedral_deg: float = 90.0) -> dict[str, int]:
272
+ """Attach a phenyl ring (-C6H5) to an atom.
273
+
274
+ Returns {'ring': [6 carbon indices], 'H': [5 hydrogen indices]}.
275
+ """
276
+ symbols = ["C"] * 6
277
+ ring = _place_planar_ring(mol, attach_to, symbols,
278
+ bond_length_val=CC_AROMATIC,
279
+ angle_ref=angle_ref,
280
+ dihedral_deg=dihedral_deg)
281
+
282
+ # Add H to each ring C except the one bonded to attach_to (ring[0])
283
+ h_indices = []
284
+ for i in range(1, 6):
285
+ c_pos = mol.atoms[ring[i]].position
286
+ # H points outward from ring center
287
+ ring_center = np.mean(
288
+ [mol.atoms[ring[j]].position for j in range(6)], axis=0)
289
+ outward = normalize(c_pos - ring_center)
290
+ h_pos = c_pos + outward * bond_length("C", "H", 1)
291
+ h_idx = mol.add_atom("H", h_pos)
292
+ mol.add_bond(ring[i], h_idx, order=1, rotatable=False)
293
+ h_indices.append(h_idx)
294
+
295
+ return {"ring": ring, "H": h_indices}
296
+
297
+
298
+ def add_imidazole_ring(mol: Molecule, attach_to: int,
299
+ angle_ref: int | None = None,
300
+ dihedral_deg: float = 90.0) -> dict[str, int]:
301
+ """Attach an imidazole ring to an atom (5-membered: C-N=C-NH-C).
302
+
303
+ Numbering: CG-ND1=CE1-NE2(H)-CD2, with CG bonded to attach_to.
304
+ Returns dict with atom name -> index.
305
+ """
306
+ # Imidazole: 5-membered ring C3N2
307
+ # CG(0)-ND1(1)=CE1(2)-NE2(3)-CD2(4)-CG(close)
308
+ symbols = ["C", "N", "C", "N", "C"]
309
+ ring = _place_planar_ring(mol, attach_to, symbols,
310
+ bond_length_val=CN_AROMATIC,
311
+ angle_ref=angle_ref,
312
+ dihedral_deg=dihedral_deg)
313
+
314
+ ring_center = np.mean(
315
+ [mol.atoms[ring[j]].position for j in range(5)], axis=0)
316
+
317
+ # Add H to CE1 (ring[2]) and CD2 (ring[4])
318
+ h_indices = {}
319
+ for label, i in [("HE1", 2), ("HD2", 4)]:
320
+ c_pos = mol.atoms[ring[i]].position
321
+ outward = normalize(c_pos - ring_center)
322
+ h_pos = c_pos + outward * bond_length("C", "H", 1)
323
+ h_idx = mol.add_atom("H", h_pos)
324
+ mol.add_bond(ring[i], h_idx, order=1, rotatable=False)
325
+ h_indices[label] = h_idx
326
+
327
+ # Add H to NE2 (ring[3]) -- the NH of imidazole
328
+ n_pos = mol.atoms[ring[3]].position
329
+ outward = normalize(n_pos - ring_center)
330
+ h_pos = n_pos + outward * bond_length("N", "H", 1)
331
+ he2 = mol.add_atom("H", h_pos)
332
+ mol.add_bond(ring[3], he2, order=1, rotatable=False)
333
+ h_indices["HE2"] = he2
334
+
335
+ return {
336
+ "CG": ring[0], "ND1": ring[1], "CE1": ring[2],
337
+ "NE2": ring[3], "CD2": ring[4], **h_indices,
338
+ }
339
+
340
+
341
+ def add_indole_ring(mol: Molecule, attach_to: int,
342
+ angle_ref: int | None = None,
343
+ dihedral_deg: float = 90.0) -> dict[str, int]:
344
+ """Attach an indole ring system (fused 6+5 ring, as in tryptophan).
345
+
346
+ The 5-membered ring is bonded to attach_to via CG.
347
+ Returns dict with atom name -> index.
348
+ """
349
+ # Build the 6-membered ring (benzene part) first, then the 5-membered
350
+ # ring fused to it sharing two atoms (CD2, CE2).
351
+ # Indole numbering:
352
+ # 5-ring: CG-CD1=NE1-CE2-CD2-CG
353
+ # 6-ring: CE2-CZ2-CH2-CZ3-CE3-CD2
354
+
355
+ # Place 5-membered ring
356
+ symbols_5 = ["C", "C", "N", "C", "C"]
357
+ ring5 = _place_planar_ring(mol, attach_to, symbols_5,
358
+ bond_length_val=CN_AROMATIC,
359
+ angle_ref=angle_ref,
360
+ dihedral_deg=dihedral_deg)
361
+
362
+ # CG=ring5[0], CD1=ring5[1], NE1=ring5[2], CE2=ring5[3], CD2=ring5[4]
363
+ # Now build the 6-membered ring fused at CE2-CD2
364
+ ce2_pos = mol.atoms[ring5[3]].position
365
+ cd2_pos = mol.atoms[ring5[4]].position
366
+
367
+ # Ring center of the 5-ring
368
+ ring5_center = np.mean(
369
+ [mol.atoms[ring5[j]].position for j in range(5)], axis=0)
370
+
371
+ # The 6-ring extends outward from the CE2-CD2 edge
372
+ midpoint = (ce2_pos + cd2_pos) / 2.0
373
+ outward = normalize(midpoint - ring5_center)
374
+
375
+ # Build 4 new atoms for the 6-ring (CZ2, CH2, CZ3, CE3)
376
+ # The edge CE2-CD2 is shared; we need 4 more vertices to close a hexagon.
377
+ edge_vec = normalize(ce2_pos - cd2_pos)
378
+ edge_len = float(np.linalg.norm(ce2_pos - cd2_pos))
379
+
380
+ # Use the ring normal from the 5-ring
381
+ ring_normal = normalize(np.cross(
382
+ mol.atoms[ring5[1]].position - mol.atoms[ring5[0]].position,
383
+ mol.atoms[ring5[4]].position - mol.atoms[ring5[0]].position,
384
+ ))
385
+
386
+ # For a regular hexagon with edge length = CC_AROMATIC:
387
+ h_offset = CC_AROMATIC * math.sin(math.radians(60)) # height
388
+ half_edge = CC_AROMATIC * math.cos(math.radians(60)) # 0.5 * edge
389
+
390
+ # 4 new atoms arranged as a hexagon extension
391
+ cz2_pos = ce2_pos + outward * h_offset + edge_vec * half_edge
392
+ ce3_pos = cd2_pos + outward * h_offset - edge_vec * half_edge
393
+ ch2_pos = cz2_pos + outward * h_offset - edge_vec * half_edge
394
+ cz3_pos = ce3_pos + outward * h_offset + edge_vec * half_edge
395
+
396
+ cz2 = mol.add_atom("C", cz2_pos, Hybridization.SP2)
397
+ ch2 = mol.add_atom("C", ch2_pos, Hybridization.SP2)
398
+ cz3 = mol.add_atom("C", cz3_pos, Hybridization.SP2)
399
+ ce3 = mol.add_atom("C", ce3_pos, Hybridization.SP2)
400
+
401
+ # Bonds for the 6-ring: CE2-CZ2-CH2-CZ3-CE3-CD2
402
+ mol.add_bond(ring5[3], cz2, order=1, rotatable=False) # CE2-CZ2
403
+ mol.add_bond(cz2, ch2, order=1, rotatable=False) # CZ2-CH2
404
+ mol.add_bond(ch2, cz3, order=1, rotatable=False) # CH2-CZ3
405
+ mol.add_bond(cz3, ce3, order=1, rotatable=False) # CZ3-CE3
406
+ mol.close_ring(ce3, ring5[4]) # CE3-CD2
407
+
408
+ # Add H to exposed atoms
409
+ all_ring = ring5 + [cz2, ch2, cz3, ce3]
410
+ overall_center = np.mean(
411
+ [mol.atoms[idx].position for idx in all_ring], axis=0)
412
+
413
+ h_dict = {}
414
+ # CD1 (ring5[1]), NE1 gets H, CZ2, CH2, CZ3, CE3 get H
415
+ for label, idx in [("HD1", ring5[1]), ("HZ2", cz2),
416
+ ("HH2", ch2), ("HZ3", cz3), ("HE3", ce3)]:
417
+ a_pos = mol.atoms[idx].position
418
+ out = normalize(a_pos - overall_center)
419
+ h_pos = a_pos + out * bond_length("C", "H", 1)
420
+ h_i = mol.add_atom("H", h_pos)
421
+ mol.add_bond(idx, h_i, order=1, rotatable=False)
422
+ h_dict[label] = h_i
423
+
424
+ # NE1 hydrogen
425
+ ne1_pos = mol.atoms[ring5[2]].position
426
+ out = normalize(ne1_pos - overall_center)
427
+ h_pos = ne1_pos + out * bond_length("N", "H", 1)
428
+ he1 = mol.add_atom("H", h_pos)
429
+ mol.add_bond(ring5[2], he1, order=1, rotatable=False)
430
+ h_dict["HE1"] = he1
431
+
432
+ return {
433
+ "CG": ring5[0], "CD1": ring5[1], "NE1": ring5[2],
434
+ "CE2": ring5[3], "CD2": ring5[4],
435
+ "CZ2": cz2, "CH2": ch2, "CZ3": cz3, "CE3": ce3,
436
+ **h_dict,
437
+ }
438
+
439
+
440
+ def add_guanidinium(mol: Molecule, attach_to: int,
441
+ angle_ref: int | None = None,
442
+ dihedral_ref: int | None = None,
443
+ dihedral_deg: float = 180.0) -> dict[str, int]:
444
+ """Attach a guanidinium group -C(=NH)(NH2)(NH2) to an atom.
445
+
446
+ Returns dict with atom name -> index.
447
+ """
448
+ if angle_ref is None:
449
+ nbrs = mol.neighbors(attach_to)
450
+ angle_ref = nbrs[0] if nbrs else 0
451
+
452
+ cz = mol.add_atom_bonded(
453
+ "C", attach_to, bond_order=1, angle_ref=angle_ref,
454
+ dihedral_ref=dihedral_ref, dihedral_deg=dihedral_deg,
455
+ hybridization=Hybridization.SP2)
456
+
457
+ # NH1 (=NH)
458
+ nh1 = mol.add_atom_bonded(
459
+ "N", cz, bond_order=2, angle_ref=attach_to,
460
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=0.0,
461
+ bond_length=1.33, hybridization=Hybridization.SP2)
462
+ hh11 = mol.add_atom_bonded(
463
+ "H", nh1, bond_order=1, angle_ref=cz,
464
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=180.0,
465
+ rotatable=False)
466
+
467
+ # NH2 (-NH2)
468
+ nh2 = mol.add_atom_bonded(
469
+ "N", cz, bond_order=1, angle_ref=attach_to,
470
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=180.0,
471
+ bond_length=AMIDE_CN, hybridization=Hybridization.SP2)
472
+ hh21 = mol.add_atom_bonded(
473
+ "H", nh2, bond_order=1, angle_ref=cz,
474
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=0.0,
475
+ rotatable=False)
476
+ hh22 = mol.add_atom_bonded(
477
+ "H", nh2, bond_order=1, angle_ref=cz,
478
+ bond_angle_deg=SP2_ANGLE, dihedral_deg=180.0,
479
+ rotatable=False)
480
+
481
+ return {
482
+ "CZ": cz, "NH1": nh1, "HH11": hh11,
483
+ "NH2": nh2, "HH21": hh21, "HH22": hh22,
484
+ }