cdxml-toolkit 0.5.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.
- cdxml_toolkit/__init__.py +18 -0
- cdxml_toolkit/_jre/__init__.py +2 -0
- cdxml_toolkit/_jre/temurin-21-jre-win-x64.zip +0 -0
- cdxml_toolkit/analysis/__init__.py +35 -0
- cdxml_toolkit/analysis/deterministic/__init__.py +12 -0
- cdxml_toolkit/analysis/deterministic/discover_experiment_files.py +413 -0
- cdxml_toolkit/analysis/deterministic/lab_book_formatter.py +701 -0
- cdxml_toolkit/analysis/deterministic/lcms_file_categorizer.py +928 -0
- cdxml_toolkit/analysis/deterministic/lcms_identifier.py +598 -0
- cdxml_toolkit/analysis/deterministic/mass_resolver.py +654 -0
- cdxml_toolkit/analysis/deterministic/multi_lcms_analyzer.py +1412 -0
- cdxml_toolkit/analysis/deterministic/procedure_writer.py +446 -0
- cdxml_toolkit/analysis/extract_nmr.py +47 -0
- cdxml_toolkit/analysis/format_procedure_entry.py +479 -0
- cdxml_toolkit/analysis/lcms_analyzer.py +1299 -0
- cdxml_toolkit/analysis/parse_analysis_file.py +134 -0
- cdxml_toolkit/cdxml_builder.py +920 -0
- cdxml_toolkit/cdxml_utils.py +342 -0
- cdxml_toolkit/chemdraw/__init__.py +5 -0
- cdxml_toolkit/chemdraw/_chemscript_server.py +562 -0
- cdxml_toolkit/chemdraw/cdx_converter.py +527 -0
- cdxml_toolkit/chemdraw/cdxml_to_image.py +262 -0
- cdxml_toolkit/chemdraw/cdxml_to_image_rdkit.py +296 -0
- cdxml_toolkit/chemdraw/chemscript_bridge.py +901 -0
- cdxml_toolkit/constants.py +304 -0
- cdxml_toolkit/coord_normalizer.py +438 -0
- cdxml_toolkit/deterministic_pipeline/__init__.py +6 -0
- cdxml_toolkit/deterministic_pipeline/legacy/__init__.py +5 -0
- cdxml_toolkit/deterministic_pipeline/legacy/eln_cdx_cleanup.py +509 -0
- cdxml_toolkit/deterministic_pipeline/legacy/eln_enrichment.py +1394 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_aligner.py +428 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher.py +1337 -0
- cdxml_toolkit/deterministic_pipeline/legacy/scheme_polisher_v2.py +1340 -0
- cdxml_toolkit/deterministic_pipeline/scheme_reader_audit.py +931 -0
- cdxml_toolkit/deterministic_pipeline/scheme_reader_verify.py +1160 -0
- cdxml_toolkit/image/__init__.py +15 -0
- cdxml_toolkit/image/reaction_from_image.py +2103 -0
- cdxml_toolkit/image/structure_from_image.py +1711 -0
- cdxml_toolkit/layout/__init__.py +5 -0
- cdxml_toolkit/layout/alignment.py +1642 -0
- cdxml_toolkit/layout/reaction_cleanup.py +1002 -0
- cdxml_toolkit/layout/scheme_merger.py +2260 -0
- cdxml_toolkit/mcp_server/__init__.py +0 -0
- cdxml_toolkit/mcp_server/__main__.py +5 -0
- cdxml_toolkit/mcp_server/server.py +1567 -0
- cdxml_toolkit/naming/__init__.py +6 -0
- cdxml_toolkit/naming/aligned_namer.py +2342 -0
- cdxml_toolkit/naming/mol_builder.py +3722 -0
- cdxml_toolkit/naming/name_decomposer.py +2843 -0
- cdxml_toolkit/naming/reactions_datamol.json +2414 -0
- cdxml_toolkit/office/__init__.py +5 -0
- cdxml_toolkit/office/doc_from_template.py +722 -0
- cdxml_toolkit/office/ole_embedder.py +808 -0
- cdxml_toolkit/office/ole_extractor.py +272 -0
- cdxml_toolkit/perception/__init__.py +10 -0
- cdxml_toolkit/perception/compound_search.py +229 -0
- cdxml_toolkit/perception/eln_csv_parser.py +240 -0
- cdxml_toolkit/perception/rdf_parser.py +664 -0
- cdxml_toolkit/perception/reactant_heuristic.py +1045 -0
- cdxml_toolkit/perception/reaction_parser.py +2150 -0
- cdxml_toolkit/perception/scheme_reader.py +2948 -0
- cdxml_toolkit/perception/scheme_refine.py +1404 -0
- cdxml_toolkit/perception/scheme_segmenter.py +619 -0
- cdxml_toolkit/perception/spatial_assignment.py +1013 -0
- cdxml_toolkit/rdkit_utils.py +605 -0
- cdxml_toolkit/render/__init__.py +17 -0
- cdxml_toolkit/render/auto_layout.py +229 -0
- cdxml_toolkit/render/compact_parser.py +632 -0
- cdxml_toolkit/render/parser.py +706 -0
- cdxml_toolkit/render/render_scheme.py +267 -0
- cdxml_toolkit/render/renderer.py +2387 -0
- cdxml_toolkit/render/schema.py +90 -0
- cdxml_toolkit/render/scheme_maker.py +1043 -0
- cdxml_toolkit/render/scheme_yaml_writer.py +1487 -0
- cdxml_toolkit/resolve/__init__.py +13 -0
- cdxml_toolkit/resolve/cas_resolver.py +430 -0
- cdxml_toolkit/resolve/chemscanner_abbreviations.json +28813 -0
- cdxml_toolkit/resolve/condensed_formula.py +493 -0
- cdxml_toolkit/resolve/jre_manager.py +195 -0
- cdxml_toolkit/resolve/reagent_abbreviations.json +1046 -0
- cdxml_toolkit/resolve/reagent_db.py +285 -0
- cdxml_toolkit/resolve/superatom_data.json +2856 -0
- cdxml_toolkit/resolve/superatom_table.py +146 -0
- cdxml_toolkit/text_formatting.py +298 -0
- cdxml_toolkit-0.5.0.dist-info/METADATA +318 -0
- cdxml_toolkit-0.5.0.dist-info/RECORD +91 -0
- cdxml_toolkit-0.5.0.dist-info/WHEEL +5 -0
- cdxml_toolkit-0.5.0.dist-info/entry_points.txt +17 -0
- cdxml_toolkit-0.5.0.dist-info/licenses/LICENSE +21 -0
- cdxml_toolkit-0.5.0.dist-info/licenses/NOTICE.md +37 -0
- cdxml_toolkit-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# NOTE: This module is not imported by any other tool as of v0.3.
|
|
2
|
+
# It is a standalone CLI utility.
|
|
3
|
+
#!/usr/bin/env python3
|
|
4
|
+
"""
|
|
5
|
+
coord_normalizer.py — Normalize atom coordinates to ACS Document 1996 standard.
|
|
6
|
+
|
|
7
|
+
Takes atom coordinates from any source (RDKit, PubChem SDF, SciFinder RDF V3000 MOL
|
|
8
|
+
blocks) and normalizes them so they are ready for cdxml_builder.py:
|
|
9
|
+
|
|
10
|
+
- Scale so that the average bond length == 14.40 pt (ACS 1996 target)
|
|
11
|
+
- Flip y-axis (MOL/SDF format is y-up; CDXML is y-down)
|
|
12
|
+
- Center molecule at a caller-supplied (cx, cy) position
|
|
13
|
+
- Strip explicit hydrogens: remove H atoms and update NumHydrogens on the
|
|
14
|
+
heavy atom they were bonded to
|
|
15
|
+
|
|
16
|
+
The module can also be used as a CLI tool to normalise a JSON atom/bond file.
|
|
17
|
+
|
|
18
|
+
Usage (CLI):
|
|
19
|
+
python coord_normalizer.py molecule.json [options]
|
|
20
|
+
python coord_normalizer.py molecule.json --center 200 300 --output normalised.json
|
|
21
|
+
|
|
22
|
+
Input JSON format (same as cdxml_builder.py expects):
|
|
23
|
+
{
|
|
24
|
+
"atoms": [
|
|
25
|
+
{"index": 1, "symbol": "C", "x": 0.0, "y": 0.0},
|
|
26
|
+
...
|
|
27
|
+
],
|
|
28
|
+
"bonds": [
|
|
29
|
+
{"index": 1, "order": 1, "atom1": 1, "atom2": 2},
|
|
30
|
+
...
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Output: same JSON structure with normalised x/y and added "num_hydrogens" fields.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import math
|
|
40
|
+
import sys
|
|
41
|
+
from copy import deepcopy
|
|
42
|
+
from typing import List, Dict, Tuple, Optional
|
|
43
|
+
|
|
44
|
+
from .constants import ACS_BOND_LENGTH
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Constants
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
ACS_BOND_LENGTH_PT = ACS_BOND_LENGTH # target average bond length in points (1 pt = 1/72 in)
|
|
52
|
+
|
|
53
|
+
# Periodic table: element symbol -> (atomic number, default valence)
|
|
54
|
+
# Only the elements we are likely to encounter in medicinal chemistry
|
|
55
|
+
ELEMENT_DATA: Dict[str, Tuple[int, int]] = {
|
|
56
|
+
"H": (1, 1),
|
|
57
|
+
"C": (6, 4),
|
|
58
|
+
"N": (7, 3),
|
|
59
|
+
"O": (8, 2),
|
|
60
|
+
"F": (9, 1),
|
|
61
|
+
"P": (15, 3),
|
|
62
|
+
"S": (16, 2),
|
|
63
|
+
"Cl": (17, 1),
|
|
64
|
+
"Br": (35, 1),
|
|
65
|
+
"I": (53, 1),
|
|
66
|
+
"B": (5, 3),
|
|
67
|
+
"Si": (14, 4),
|
|
68
|
+
"Se": (34, 2),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
ELEMENT_NUMBERS: Dict[str, int] = {sym: data[0] for sym, data in ELEMENT_DATA.items()}
|
|
72
|
+
DEFAULT_VALENCE: Dict[str, int] = {sym: data[1] for sym, data in ELEMENT_DATA.items()}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Core normalisation logic
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _average_bond_length(atoms: List[Dict], bonds: List[Dict]) -> float:
|
|
80
|
+
"""Return the average Euclidean bond length, or 1.0 if no bonds."""
|
|
81
|
+
if not bonds:
|
|
82
|
+
return 1.0
|
|
83
|
+
atom_xy = {a["index"]: (a["x"], a["y"]) for a in atoms}
|
|
84
|
+
lengths = []
|
|
85
|
+
for b in bonds:
|
|
86
|
+
x1, y1 = atom_xy.get(b["atom1"], (0, 0))
|
|
87
|
+
x2, y2 = atom_xy.get(b["atom2"], (0, 0))
|
|
88
|
+
d = math.hypot(x2 - x1, y2 - y1)
|
|
89
|
+
if d > 1e-6:
|
|
90
|
+
lengths.append(d)
|
|
91
|
+
return sum(lengths) / len(lengths) if lengths else 1.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _bounding_box(atoms: List[Dict]) -> Tuple[float, float, float, float]:
|
|
95
|
+
"""Return (min_x, min_y, max_x, max_y) of atom positions."""
|
|
96
|
+
xs = [a["x"] for a in atoms]
|
|
97
|
+
ys = [a["y"] for a in atoms]
|
|
98
|
+
return min(xs), min(ys), max(xs), max(ys)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def strip_explicit_hydrogens(
|
|
102
|
+
atoms: List[Dict],
|
|
103
|
+
bonds: List[Dict],
|
|
104
|
+
) -> Tuple[List[Dict], List[Dict]]:
|
|
105
|
+
"""
|
|
106
|
+
Remove explicit hydrogen atoms from the atom/bond lists.
|
|
107
|
+
|
|
108
|
+
For each heavy atom that had an explicit H neighbour, increment
|
|
109
|
+
num_hydrogens by 1 (or initialise it to 1). Returns new lists;
|
|
110
|
+
input is not modified.
|
|
111
|
+
"""
|
|
112
|
+
atoms = deepcopy(atoms)
|
|
113
|
+
bonds = deepcopy(bonds)
|
|
114
|
+
|
|
115
|
+
# Identify explicit H atom indices
|
|
116
|
+
h_indices = {a["index"] for a in atoms if a.get("symbol", "C") == "H"}
|
|
117
|
+
if not h_indices:
|
|
118
|
+
return atoms, bonds
|
|
119
|
+
|
|
120
|
+
# For each H, find the heavy-atom neighbour and bump its H count
|
|
121
|
+
h_count_delta: Dict[int, int] = {}
|
|
122
|
+
bonds_to_remove = set()
|
|
123
|
+
for b in bonds:
|
|
124
|
+
a1, a2 = b["atom1"], b["atom2"]
|
|
125
|
+
if a1 in h_indices or a2 in h_indices:
|
|
126
|
+
bonds_to_remove.add(b["index"])
|
|
127
|
+
heavy = a2 if a1 in h_indices else a1
|
|
128
|
+
h_count_delta[heavy] = h_count_delta.get(heavy, 0) + 1
|
|
129
|
+
|
|
130
|
+
# Update num_hydrogens on heavy atoms
|
|
131
|
+
for a in atoms:
|
|
132
|
+
if a["index"] in h_count_delta:
|
|
133
|
+
a["num_hydrogens"] = a.get("num_hydrogens", 0) + h_count_delta[a["index"]]
|
|
134
|
+
|
|
135
|
+
# Remove H atoms and their bonds
|
|
136
|
+
atoms = [a for a in atoms if a["index"] not in h_indices]
|
|
137
|
+
bonds = [b for b in bonds if b["index"] not in bonds_to_remove]
|
|
138
|
+
|
|
139
|
+
return atoms, bonds
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_coords(
|
|
143
|
+
atoms: List[Dict],
|
|
144
|
+
bonds: List[Dict],
|
|
145
|
+
center_x: float = 200.0,
|
|
146
|
+
center_y: float = 300.0,
|
|
147
|
+
flip_y: bool = True,
|
|
148
|
+
target_bond_length: float = ACS_BOND_LENGTH_PT,
|
|
149
|
+
strip_hydrogens: bool = True,
|
|
150
|
+
) -> Tuple[List[Dict], List[Dict]]:
|
|
151
|
+
"""
|
|
152
|
+
Normalize atom coordinates and return (atoms, bonds) ready for cdxml_builder.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
atoms : list of dicts with keys: index, symbol, x, y (and optionally z,
|
|
157
|
+
num_hydrogens, cfg, etc.)
|
|
158
|
+
bonds : list of dicts with keys: index, order, atom1, atom2 (and optionally cfg)
|
|
159
|
+
center_x, center_y : target centre in CDXML points
|
|
160
|
+
flip_y : True to negate y (converts MOL y-up → CDXML y-down)
|
|
161
|
+
target_bond_length : desired average bond length in points (default 14.40)
|
|
162
|
+
strip_hydrogens : remove explicit H atoms and count them on heavy atoms
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
(atoms, bonds) — new lists, input unchanged
|
|
167
|
+
"""
|
|
168
|
+
atoms = deepcopy(atoms)
|
|
169
|
+
bonds = deepcopy(bonds)
|
|
170
|
+
|
|
171
|
+
# Step 1 — strip explicit hydrogens before calculating scale
|
|
172
|
+
if strip_hydrogens:
|
|
173
|
+
atoms, bonds = strip_explicit_hydrogens(atoms, bonds)
|
|
174
|
+
|
|
175
|
+
if not atoms:
|
|
176
|
+
return atoms, bonds
|
|
177
|
+
|
|
178
|
+
# Step 2 — flip y-axis (MOL y-up → CDXML y-down)
|
|
179
|
+
if flip_y:
|
|
180
|
+
for a in atoms:
|
|
181
|
+
a["y"] = -a["y"]
|
|
182
|
+
|
|
183
|
+
# Step 3 — scale to ACS bond length
|
|
184
|
+
avg_bl = _average_bond_length(atoms, bonds)
|
|
185
|
+
if avg_bl > 1e-6 and abs(avg_bl - target_bond_length) > 0.01:
|
|
186
|
+
scale = target_bond_length / avg_bl
|
|
187
|
+
for a in atoms:
|
|
188
|
+
a["x"] *= scale
|
|
189
|
+
a["y"] *= scale
|
|
190
|
+
|
|
191
|
+
# Step 4 — translate so centroid lands at (center_x, center_y)
|
|
192
|
+
xmin, ymin, xmax, ymax = _bounding_box(atoms)
|
|
193
|
+
cx = (xmin + xmax) / 2.0
|
|
194
|
+
cy = (ymin + ymax) / 2.0
|
|
195
|
+
dx = center_x - cx
|
|
196
|
+
dy = center_y - cy
|
|
197
|
+
for a in atoms:
|
|
198
|
+
a["x"] += dx
|
|
199
|
+
a["y"] += dy
|
|
200
|
+
|
|
201
|
+
return atoms, bonds
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def normalize_molecule(
|
|
205
|
+
molecule: Dict,
|
|
206
|
+
center_x: float = 200.0,
|
|
207
|
+
center_y: float = 300.0,
|
|
208
|
+
flip_y: bool = True,
|
|
209
|
+
target_bond_length: float = ACS_BOND_LENGTH_PT,
|
|
210
|
+
strip_hydrogens: bool = True,
|
|
211
|
+
) -> Dict:
|
|
212
|
+
"""
|
|
213
|
+
Convenience wrapper: take a molecule dict {"atoms": [...], "bonds": [...]}
|
|
214
|
+
and return a new dict with normalised coordinates.
|
|
215
|
+
|
|
216
|
+
Extra top-level keys (name, role, etc.) are preserved.
|
|
217
|
+
"""
|
|
218
|
+
mol = deepcopy(molecule)
|
|
219
|
+
atoms, bonds = normalize_coords(
|
|
220
|
+
mol.get("atoms", []),
|
|
221
|
+
mol.get("bonds", []),
|
|
222
|
+
center_x=center_x,
|
|
223
|
+
center_y=center_y,
|
|
224
|
+
flip_y=flip_y,
|
|
225
|
+
target_bond_length=target_bond_length,
|
|
226
|
+
strip_hydrogens=strip_hydrogens,
|
|
227
|
+
)
|
|
228
|
+
mol["atoms"] = atoms
|
|
229
|
+
mol["bonds"] = bonds
|
|
230
|
+
return mol
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def normalize_reaction(
|
|
234
|
+
reactants: List[Dict],
|
|
235
|
+
products: List[Dict],
|
|
236
|
+
reactant_y: float = 300.0,
|
|
237
|
+
product_y: float = 300.0,
|
|
238
|
+
reactant_start_x: float = 50.0,
|
|
239
|
+
product_start_x: float = 350.0,
|
|
240
|
+
molecule_gap: float = 80.0,
|
|
241
|
+
flip_y: bool = True,
|
|
242
|
+
target_bond_length: float = ACS_BOND_LENGTH_PT,
|
|
243
|
+
strip_hydrogens: bool = True,
|
|
244
|
+
) -> Tuple[List[Dict], List[Dict]]:
|
|
245
|
+
"""
|
|
246
|
+
Normalize a set of reactant and product molecules for a reaction scheme.
|
|
247
|
+
|
|
248
|
+
Molecules are laid out horizontally with `molecule_gap` points between
|
|
249
|
+
bounding boxes.
|
|
250
|
+
|
|
251
|
+
Returns (reactants_normalised, products_normalised).
|
|
252
|
+
"""
|
|
253
|
+
def layout_molecules(
|
|
254
|
+
mols: List[Dict],
|
|
255
|
+
start_x: float,
|
|
256
|
+
row_y: float,
|
|
257
|
+
) -> List[Dict]:
|
|
258
|
+
result = []
|
|
259
|
+
cursor_x = start_x
|
|
260
|
+
for mol in mols:
|
|
261
|
+
# First normalise at origin to measure the bounding box
|
|
262
|
+
tmp_atoms, tmp_bonds = normalize_coords(
|
|
263
|
+
mol.get("atoms", []),
|
|
264
|
+
mol.get("bonds", []),
|
|
265
|
+
center_x=0.0,
|
|
266
|
+
center_y=0.0,
|
|
267
|
+
flip_y=flip_y,
|
|
268
|
+
target_bond_length=target_bond_length,
|
|
269
|
+
strip_hydrogens=strip_hydrogens,
|
|
270
|
+
)
|
|
271
|
+
if not tmp_atoms:
|
|
272
|
+
result.append(mol)
|
|
273
|
+
continue
|
|
274
|
+
xmin, _, xmax, _ = _bounding_box(tmp_atoms)
|
|
275
|
+
half_w = (xmax - xmin) / 2.0
|
|
276
|
+
cx = cursor_x + half_w
|
|
277
|
+
# Now normalise for real at the correct position
|
|
278
|
+
norm_atoms, norm_bonds = normalize_coords(
|
|
279
|
+
mol.get("atoms", []),
|
|
280
|
+
mol.get("bonds", []),
|
|
281
|
+
center_x=cx,
|
|
282
|
+
center_y=row_y,
|
|
283
|
+
flip_y=flip_y,
|
|
284
|
+
target_bond_length=target_bond_length,
|
|
285
|
+
strip_hydrogens=strip_hydrogens,
|
|
286
|
+
)
|
|
287
|
+
new_mol = deepcopy(mol)
|
|
288
|
+
new_mol["atoms"] = norm_atoms
|
|
289
|
+
new_mol["bonds"] = norm_bonds
|
|
290
|
+
result.append(new_mol)
|
|
291
|
+
# Move cursor past this molecule's bounding box
|
|
292
|
+
cursor_x = cx + half_w + molecule_gap
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
norm_reactants = layout_molecules(reactants, reactant_start_x, reactant_y)
|
|
296
|
+
norm_products = layout_molecules(products, product_start_x, product_y)
|
|
297
|
+
return norm_reactants, norm_products
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Utility: infer missing num_hydrogens from valence
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def infer_hydrogens(atoms: List[Dict], bonds: List[Dict]) -> List[Dict]:
|
|
305
|
+
"""
|
|
306
|
+
For any atom that has no explicit num_hydrogens set, calculate it from
|
|
307
|
+
the default valence minus the sum of bond orders from bonds.
|
|
308
|
+
|
|
309
|
+
This is only called for atoms that don't already have num_hydrogens.
|
|
310
|
+
Returns a new list.
|
|
311
|
+
"""
|
|
312
|
+
atoms = deepcopy(atoms)
|
|
313
|
+
|
|
314
|
+
# Count bond-order sum per atom
|
|
315
|
+
bond_order_sum: Dict[int, int] = {}
|
|
316
|
+
for b in bonds:
|
|
317
|
+
o = b.get("order", 1)
|
|
318
|
+
for idx in (b["atom1"], b["atom2"]):
|
|
319
|
+
bond_order_sum[idx] = bond_order_sum.get(idx, 0) + o
|
|
320
|
+
|
|
321
|
+
for a in atoms:
|
|
322
|
+
if "num_hydrogens" in a:
|
|
323
|
+
continue # already explicit
|
|
324
|
+
sym = a.get("symbol", "C")
|
|
325
|
+
if sym == "C":
|
|
326
|
+
continue # carbons get implicit Hs in ChemDraw automatically
|
|
327
|
+
valence = DEFAULT_VALENCE.get(sym)
|
|
328
|
+
if valence is None:
|
|
329
|
+
continue
|
|
330
|
+
used = bond_order_sum.get(a["index"], 0)
|
|
331
|
+
nh = max(0, valence - used)
|
|
332
|
+
a["num_hydrogens"] = nh
|
|
333
|
+
|
|
334
|
+
return atoms
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# CLI
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
342
|
+
p = argparse.ArgumentParser(
|
|
343
|
+
description="Normalize atom/bond coordinates to ACS Document 1996 style.",
|
|
344
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
345
|
+
)
|
|
346
|
+
p.add_argument("input", help="JSON file with {atoms, bonds} (use - for stdin)")
|
|
347
|
+
p.add_argument(
|
|
348
|
+
"--output", "-o",
|
|
349
|
+
default="-",
|
|
350
|
+
help="Output JSON file (default: stdout)",
|
|
351
|
+
)
|
|
352
|
+
p.add_argument(
|
|
353
|
+
"--center",
|
|
354
|
+
nargs=2,
|
|
355
|
+
type=float,
|
|
356
|
+
metavar=("X", "Y"),
|
|
357
|
+
default=[200.0, 300.0],
|
|
358
|
+
help="Target centre in CDXML points (default: 200 300)",
|
|
359
|
+
)
|
|
360
|
+
p.add_argument(
|
|
361
|
+
"--no-flip-y",
|
|
362
|
+
action="store_true",
|
|
363
|
+
help="Do NOT flip the y-axis (use if coords are already CDXML y-down)",
|
|
364
|
+
)
|
|
365
|
+
p.add_argument(
|
|
366
|
+
"--no-strip-h",
|
|
367
|
+
action="store_true",
|
|
368
|
+
help="Keep explicit hydrogen atoms",
|
|
369
|
+
)
|
|
370
|
+
p.add_argument(
|
|
371
|
+
"--bond-length",
|
|
372
|
+
type=float,
|
|
373
|
+
default=ACS_BOND_LENGTH_PT,
|
|
374
|
+
help=f"Target average bond length in points (default: {ACS_BOND_LENGTH_PT})",
|
|
375
|
+
)
|
|
376
|
+
p.add_argument(
|
|
377
|
+
"--infer-h",
|
|
378
|
+
action="store_true",
|
|
379
|
+
help="Infer missing num_hydrogens from valence after normalisation",
|
|
380
|
+
)
|
|
381
|
+
p.add_argument(
|
|
382
|
+
"--pretty",
|
|
383
|
+
action="store_true",
|
|
384
|
+
help="Pretty-print output JSON",
|
|
385
|
+
)
|
|
386
|
+
return p
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
390
|
+
parser = _build_arg_parser()
|
|
391
|
+
args = parser.parse_args(argv)
|
|
392
|
+
|
|
393
|
+
# Read input
|
|
394
|
+
if args.input == "-":
|
|
395
|
+
data = json.load(sys.stdin)
|
|
396
|
+
else:
|
|
397
|
+
with open(args.input, encoding="utf-8") as fh:
|
|
398
|
+
data = json.load(fh)
|
|
399
|
+
|
|
400
|
+
atoms = data.get("atoms", [])
|
|
401
|
+
bonds = data.get("bonds", [])
|
|
402
|
+
|
|
403
|
+
if not atoms:
|
|
404
|
+
print("WARNING: no atoms found in input", file=sys.stderr)
|
|
405
|
+
|
|
406
|
+
atoms, bonds = normalize_coords(
|
|
407
|
+
atoms,
|
|
408
|
+
bonds,
|
|
409
|
+
center_x=args.center[0],
|
|
410
|
+
center_y=args.center[1],
|
|
411
|
+
flip_y=not args.no_flip_y,
|
|
412
|
+
target_bond_length=args.bond_length,
|
|
413
|
+
strip_hydrogens=not args.no_strip_h,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if args.infer_h:
|
|
417
|
+
atoms = infer_hydrogens(atoms, bonds)
|
|
418
|
+
|
|
419
|
+
# Preserve any extra top-level keys
|
|
420
|
+
out = {k: v for k, v in data.items() if k not in ("atoms", "bonds")}
|
|
421
|
+
out["atoms"] = atoms
|
|
422
|
+
out["bonds"] = bonds
|
|
423
|
+
|
|
424
|
+
indent = 2 if args.pretty else None
|
|
425
|
+
output_text = json.dumps(out, indent=indent)
|
|
426
|
+
|
|
427
|
+
if args.output == "-":
|
|
428
|
+
print(output_text)
|
|
429
|
+
else:
|
|
430
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
431
|
+
fh.write(output_text)
|
|
432
|
+
print(f"Written to {args.output}", file=sys.stderr)
|
|
433
|
+
|
|
434
|
+
return 0
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
if __name__ == "__main__":
|
|
438
|
+
sys.exit(main())
|