From 8af4911b5923cfafa03bde4c285207b14d485999 Mon Sep 17 00:00:00 2001 From: Luc Patiny Date: Fri, 26 Jun 2026 10:51:16 +0200 Subject: [PATCH 1/2] feat: prochiral labels use superscript and skip enantiotopic by default Render pro-R/pro-S CH2 hydrogen labels as superscripts (']' prefix), matching the skeleton numbering from applyFragmentLabels. By default only diastereotopic hydrogens are labelled; setProchiralHydrogenLabels now takes { includeEnantiotopic } to also label enantiotopic ones (e.g. ethanol CH2). Tests also assert the idCode of the molecule carrying the custom labels. Assisted-By: Claude Opus 4.8 (1M context) --- src/topic/TopicMolecule.ts | 75 ++++++++++++---- ...olecule.setProchiralHydrogenLabels.test.js | 86 ++++++++++++++++--- ...le.setProchiralHydrogenLabels.test.js.snap | 4 +- .../__snapshots__/getEnantioIDs.test.js.snap | 24 +++--- src/topic/__tests__/getEnantioIDs.test.js | 56 +++++++++--- 5 files changed, 190 insertions(+), 55 deletions(-) diff --git a/src/topic/TopicMolecule.ts b/src/topic/TopicMolecule.ts index 4ae715b..1bf336f 100644 --- a/src/topic/TopicMolecule.ts +++ b/src/topic/TopicMolecule.ts @@ -100,6 +100,17 @@ interface GetHoseFragmentOptions { tagAtomFct?: (molecule: Molecule, iAtom: number) => undefined; } +export interface SetProchiralHydrogenLabelsOptions { + /** + * Also label enantiotopic CH2 hydrogens. They carry a genuine pro-R / pro-S + * descriptor but are equivalent in an achiral environment (e.g. the CH2 of + * ethanol), so they are skipped by default; diastereotopic (NMR-inequivalent) + * hydrogens are always labelled. + * @default false + */ + includeEnantiotopic?: boolean; +} + /** * This class deals with topicity information and hose codes * It is optimized to avoid recalculation of the same information @@ -518,27 +529,32 @@ export class TopicMolecule { } /** - * Set a pro-R / pro-S `customLabel` on each diastereotopic CH2 hydrogen, - * appended as lowercase `r` or `s`. Idempotent: calling it again replaces - * any trailing pro-R / pro-S letter rather than stacking. The label is - * appended to whatever `customLabel` the hydrogen already has, falling - * back to the parent carbon's `customLabel` when the hydrogen has none. - * Both `molecule` and `moleculeWithH` are labelled in place, sharing the - * same cached `prochiralityByEnantioID` hash; on `molecule` only hydrogens - * that are explicit (i.e. exist as atoms there) receive a label. - * Repeated calls do no extra CIP work. + * Set a pro-R / pro-S `customLabel` (lowercase `r` / `s`) on each prochiral + * CH2 hydrogen, in both `molecule` and `moleculeWithH`. The label is prefixed + * with `']'` so OCL renders it as a superscript, and appended to the + * hydrogen's existing `customLabel` (or the parent carbon's). By default only + * diastereotopic hydrogens are labelled; pass `includeEnantiotopic` to also + * label enantiotopic ones. Idempotent and does no extra CIP work on repeat. + * @param options - controls which prochiral hydrogens are labelled * @returns The number of hydrogens labelled in `moleculeWithH` */ - setProchiralHydrogenLabels(): number { + setProchiralHydrogenLabels( + options: SetProchiralHydrogenLabelsOptions = {}, + ): number { + const { includeEnantiotopic = false } = options; const moleculeWithH = this.moleculeWithH; const molecule = this.molecule; const moleculeAtoms = molecule.getAllAtoms(); const map = this.prochiralityByEnantioID; const enantioIDs = this.enantioIDs; + const diaIDs = includeEnantiotopic ? null : this.diaIDs; let count = 0; for (let atom = 0; atom < enantioIDs.length; atom++) { const letter = map[enantioIDs[atom]]; if (!letter) continue; + if (diaIDs && !isDiastereotopicHydrogen(moleculeWithH, diaIDs, atom)) { + continue; + } appendProchiralLabel( moleculeWithH, atom, @@ -561,7 +577,8 @@ export class TopicMolecule { /** * Remove a trailing pro-R / pro-S character (`r` or `s`) from the * `customLabel` of every hydrogen that carries one, in both `molecule` - * and `moleculeWithH`. Idempotent. Hydrogens whose `customLabel` does + * and `moleculeWithH`. A label left as just the superscript marker `']'` + * is removed entirely. Idempotent. Hydrogens whose `customLabel` does * not end with `r` or `s` are left untouched. * @returns The number of labels that were stripped from `moleculeWithH` */ @@ -676,6 +693,24 @@ function computeProchiralityByEnantioID( return result; } +// Two geminal CH2 hydrogens are diastereotopic when their (racemic) diaIDs +// differ; an equal diaID means they are enantiotopic (equivalent in an achiral +// environment) and should be skipped unless explicitly requested. +function isDiastereotopicHydrogen( + molecule: Molecule, + diaIDs: string[], + hydrogen: number, +): boolean { + const carbon = molecule.getConnAtom(hydrogen, 0); + for (let j = 0; j < molecule.getAllConnAtoms(carbon); j++) { + const connected = molecule.getConnAtom(carbon, j); + if (connected !== hydrogen && molecule.getAtomicNo(connected) === 1) { + return diaIDs[hydrogen] !== diaIDs[connected]; + } + } + return false; +} + function appendProchiralLabel( molecule: Molecule, hydrogen: number, @@ -686,16 +721,23 @@ function appendProchiralLabel( if (existing) { molecule.setAtomCustomLabel( hydrogen, - existing.replace(/[rs]$/, '') + letter, + toSuperscript(existing.replace(/[rs]$/, '')) + letter, ); return; } const parentLabel = molecule.getAtomCustomLabel(parent); if (parentLabel) { - molecule.setAtomCustomLabel(hydrogen, parentLabel + letter); + molecule.setAtomCustomLabel(hydrogen, toSuperscript(parentLabel) + letter); return; } - molecule.setAtomCustomLabel(hydrogen, letter); + molecule.setAtomCustomLabel(hydrogen, `]${letter}`); +} + +// OCL renders a custom label as a superscript at the top-left of the atom when +// it starts with ']'. Keep the prochiral descriptor (and any inherited prefix) +// superscript, matching the skeleton numbering produced by `applyFragmentLabels`. +function toSuperscript(label: string): string { + return label.startsWith(']') ? label : `]${label}`; } function stripTrailingProchiralLetter(molecule: Molecule): number { @@ -706,7 +748,10 @@ function stripTrailingProchiralLetter(molecule: Molecule): number { if (!customLabel) continue; const stripped = customLabel.replace(/[rs]$/, ''); if (stripped === customLabel) continue; - molecule.setAtomCustomLabel(atom, stripped || null); + molecule.setAtomCustomLabel( + atom, + stripped && stripped !== ']' ? stripped : null, + ); count++; } return count; diff --git a/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js b/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js index 5591b14..b775223 100644 --- a/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js +++ b/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js @@ -52,6 +52,16 @@ function getHydrogenLabels(topicMolecule) { return labels; } +// idCode that encodes the custom labels. The labels sit on hydrogens, which a +// canonizer drops as "simple"; switching to fragment mode keeps every explicit +// hydrogen so the pro-R / pro-S labels survive the round-trip. +function getLabeledIDCode(molecule) { + const { Canonizer } = molecule.getOCL(); + const copy = molecule.getCompactCopy(); + copy.setFragment(true); + return new Canonizer(copy, { encodeAtomCustomLabels: true }).getIDCode(); +} + test('CC[C@H](C)Cl: diaIDs and pro-R/pro-S assignment of the CH2 hydrogens', () => { const molecule = Molecule.fromSmiles('CC[C@H](C)Cl'); const topicMolecule = new TopicMolecule(molecule); @@ -92,7 +102,11 @@ test('CC[C@H](C)Cl: diaIDs and pro-R/pro-S assignment of the CH2 hydrogens', () const label0 = moleculeWithH.getAtomCustomLabel(ch2Hydrogens[0]); const label1 = moleculeWithH.getAtomCustomLabel(ch2Hydrogens[1]); - expect([label0, label1].toSorted()).toStrictEqual(['r', 's']); + expect([label0, label1].toSorted()).toStrictEqual([']r', ']s']); + + expect(getLabeledIDCode(moleculeWithH)).toBe( + 'gNpLADV@\\@dsUURbRGvd[cwzIJw\\{[j\\', + ); // Snapshot captures the diaID → r/s mapping for verification in external software expect({ [diaID0]: label0, [diaID1]: label1 }).toMatchSnapshot(); @@ -138,7 +152,11 @@ test('CC(Cl)CC: the CH2 hydrogens get distinct pro-R / pro-S labels', () => { .map((l) => l.label) .toSorted(); - expect(labels).toStrictEqual(['r', 's']); + expect(labels).toStrictEqual([']r', ']s']); + + expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( + 'gNpLADV@\\@dsUUReBP~tc\\^\u007FQIV{g[]S`', + ); }); test('CC1CCC1 (methylcyclobutane): the four CH2 hydrogens at C2/C4 are labelled, the apex CH2 is not', () => { @@ -152,7 +170,11 @@ test('CC1CCC1 (methylcyclobutane): the four CH2 hydrogens at C2/C4 are labelled, .map((l) => l.label) .toSorted(); - expect(labels).toStrictEqual(['r', 'r', 's', 's']); + expect(labels).toStrictEqual([']r', ']r', ']s', ']s']); + + expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( + 'did@HL`B`N`A`LddTlRjjjdAT`~rHSF{}BSF{gIni~[n\\Vzg@', + ); }); test('CC(Cl)CC: heavy-atom customLabel is inherited by the labelled hydrogens', () => { @@ -178,10 +200,50 @@ test('CC(Cl)CC: heavy-atom customLabel is inherited by the labelled hydrogens', topicMolecule.setProchiralHydrogenLabels(); const labels = getHydrogenLabels(topicMolecule) .map((l) => l.label) - .filter((l) => l.startsWith('3')) + .filter((l) => l.startsWith(']3')) .toSorted(); - expect(labels).toStrictEqual(['3r', '3s']); + expect(labels).toStrictEqual([']3r', ']3s']); + + expect(getLabeledIDCode(moleculeWithH)).toBe( + 'gNpLADV@\\@dsUUReBP~tc\\^\u007FSHkM^{fsonydx', + ); +}); + +test('CCO (ethanol): enantiotopic CH2 hydrogens are not labelled by default', () => { + const molecule = Molecule.fromSmiles('CCO'); + const topicMolecule = new TopicMolecule(molecule); + + expect(topicMolecule.setProchiralHydrogenLabels()).toBe(0); + + const labels = getHydrogenLabels(topicMolecule).map((l) => l.label); + + expect(labels).toStrictEqual([]); +}); + +test('CCO (ethanol): enantiotopic CH2 hydrogens are labelled with includeEnantiotopic', () => { + const molecule = Molecule.fromSmiles('CCO'); + const topicMolecule = new TopicMolecule(molecule); + + expect( + topicMolecule.setProchiralHydrogenLabels({ includeEnantiotopic: true }), + ).toBe(2); + + const labels = getHydrogenLabels(topicMolecule) + .map((l) => l.label) + .toSorted(); + + expect(labels).toStrictEqual([']r', ']s']); +}); + +test('OCc1ccccc1 (benzyl alcohol): enantiotopic CH2 is skipped by default, kept when requested', () => { + const def = new TopicMolecule(Molecule.fromSmiles('OCc1ccccc1')); + + expect(def.setProchiralHydrogenLabels()).toBe(0); + + const all = new TopicMolecule(Molecule.fromSmiles('OCc1ccccc1')); + + expect(all.setProchiralHydrogenLabels({ includeEnantiotopic: true })).toBe(2); }); test('prochiralities is an atom-indexed cached array of r/s/undefined', () => { @@ -254,7 +316,7 @@ test('CC(Cl)CC: implicit-H molecule gets no labels, only moleculeWithH does', () .map((l) => l.label) .toSorted(); - expect(inMoleculeWithH).toStrictEqual(['r', 's']); + expect(inMoleculeWithH).toStrictEqual([']r', ']s']); }); test('explicit-H molecule: both molecule and moleculeWithH get labels', () => { @@ -271,13 +333,13 @@ test('explicit-H molecule: both molecule and moleculeWithH get labels', () => { if (label) inMolecule.push(label); } - expect(inMolecule.toSorted()).toStrictEqual(['r', 's']); + expect(inMolecule.toSorted()).toStrictEqual([']r', ']s']); const inMoleculeWithH = getHydrogenLabels(topicMolecule) .map((l) => l.label) .toSorted(); - expect(inMoleculeWithH).toStrictEqual(['r', 's']); + expect(inMoleculeWithH).toStrictEqual([']r', ']s']); // removeProchiralHydrogenLabels strips both targets. topicMolecule.removeProchiralHydrogenLabels(); @@ -303,7 +365,7 @@ test('setProchiralHydrogenLabels replaces an existing trailing r/s rather than s .map((l) => l.label) .toSorted(); - expect(labels).toStrictEqual(['r', 's']); + expect(labels).toStrictEqual([']r', ']s']); }); test('setProchiralHydrogenLabels preserves a heavy-atom prefix when retagging', () => { @@ -331,10 +393,10 @@ test('setProchiralHydrogenLabels preserves a heavy-atom prefix when retagging', const labels = getHydrogenLabels(topicMolecule) .map((l) => l.label) - .filter((l) => l.startsWith('3')) + .filter((l) => l.startsWith(']3')) .toSorted(); - expect(labels).toStrictEqual(['3r', '3s']); + expect(labels).toStrictEqual([']3r', ']3s']); }); test('removeProchiralHydrogenLabels strips the trailing pro-R / pro-S letter', () => { @@ -375,5 +437,5 @@ test('removeProchiralHydrogenLabels is idempotent and preserves the heavy-atom p .map((l) => l.label) .toSorted(); - expect(labels).toStrictEqual(['3', '3']); + expect(labels).toStrictEqual([']3', ']3']); }); diff --git a/src/topic/__tests__/__snapshots__/TopicMolecule.setProchiralHydrogenLabels.test.js.snap b/src/topic/__tests__/__snapshots__/TopicMolecule.setProchiralHydrogenLabels.test.js.snap index 80c7fc8..8ef7b8e 100644 --- a/src/topic/__tests__/__snapshots__/TopicMolecule.setProchiralHydrogenLabels.test.js.snap +++ b/src/topic/__tests__/__snapshots__/TopicMolecule.setProchiralHydrogenLabels.test.js.snap @@ -2,7 +2,7 @@ exports[`CC[C@H](C)Cl: diaIDs and pro-R/pro-S assignment of the CH2 hydrogens 1`] = ` { - "gGPDALfHRYjjThQ@_iDBIU@": "s", - "gGPDALfHRYjjThU@_iDBIU@": "r", + "gGPDALfHRYjjThQ@_iDBIU@": "]s", + "gGPDALfHRYjjThU@_iDBIU@": "]r", } `; diff --git a/src/topic/__tests__/__snapshots__/getEnantioIDs.test.js.snap b/src/topic/__tests__/__snapshots__/getEnantioIDs.test.js.snap index 36f94f1..3813112 100644 --- a/src/topic/__tests__/__snapshots__/getEnantioIDs.test.js.snap +++ b/src/topic/__tests__/__snapshots__/getEnantioIDs.test.js.snap @@ -3,27 +3,27 @@ exports[`butan-1-ol (CCCCO): all three CH2 groups are enantiotopic and prochiral 1`] = ` { "gGQHBIeIejjQA~dPHeT": { - "proChirality": "s", + "proChirality": "]s", "smiles": "[H]C([H])(C)[C@]([H])([2H])C([H])([H])O", }, "gGQHBIeIejjRA~dPHeT": { - "proChirality": "r", + "proChirality": "]r", "smiles": "[H]C([H])(C)[C@@]([H])([2H])C([H])([H])O", }, "gGQHDIeImjjQA~dPHeT": { - "proChirality": "s", + "proChirality": "]s", "smiles": "[H]C([H])(C)C([H])([H])[C@]([H])([2H])O", }, "gGQHDIeImjjRA~dPHeT": { - "proChirality": "r", + "proChirality": "]r", "smiles": "[H]C([H])(C)C([H])([H])[C@@]([H])([2H])O", }, "gGQHJIeIgjjQA~dPHeT": { - "proChirality": "s", + "proChirality": "]s", "smiles": "[H]C([H])(C([H])([H])O)[C@@]([H])([2H])C", }, "gGQHJIeIgjjRA~dPHeT": { - "proChirality": "r", + "proChirality": "]r", "smiles": "[H]C([H])(C([H])([H])O)[C@]([H])([2H])C", }, } @@ -32,11 +32,11 @@ exports[`butan-1-ol (CCCCO): all three CH2 groups are enantiotopic and prochiral exports[`ethanol (CCO): the CH2 hydrogens are enantiotopic — same diaID, different enantioID 1`] = ` { "gCaHDIeIjiDGzQ@bUP": { - "proChirality": "s", + "proChirality": "]s", "smiles": "[H][C@]([2H])(C)O", }, "gCaHDIeIjiHGzQ@bUP": { - "proChirality": "r", + "proChirality": "]r", "smiles": "[H][C@@]([2H])(C)O", }, } @@ -45,19 +45,19 @@ exports[`ethanol (CCO): the CH2 hydrogens are enantiotopic — same diaID, diffe exports[`propan-1-ol (CCCO): both CH2 groups are enantiotopic and prochiral 1`] = ` { "gJQHBIeIfjdP_iDBIU@": { - "proChirality": "s", + "proChirality": "]s", "smiles": "[H]C([H])([C@@]([H])([2H])C)O", }, "gJQHBIeIfjd\`_iDBIU@": { - "proChirality": "r", + "proChirality": "]r", "smiles": "[H]C([H])([C@]([H])([2H])C)O", }, "gJQHDIeInjdP_iDBIU@": { - "proChirality": "s", + "proChirality": "]s", "smiles": "[H]C([H])(C)[C@]([H])([2H])O", }, "gJQHDIeInjd\`_iDBIU@": { - "proChirality": "r", + "proChirality": "]r", "smiles": "[H]C([H])(C)[C@@]([H])([2H])O", }, } diff --git a/src/topic/__tests__/getEnantioIDs.test.js b/src/topic/__tests__/getEnantioIDs.test.js index b7cca06..212ed3b 100644 --- a/src/topic/__tests__/getEnantioIDs.test.js +++ b/src/topic/__tests__/getEnantioIDs.test.js @@ -51,6 +51,16 @@ function getSortedHydrogenLabels(topicMolecule) { return labels.toSorted(); } +// idCode that encodes the custom labels. The labels sit on hydrogens, which a +// canonizer drops as "simple"; switching to fragment mode keeps every explicit +// hydrogen so the pro-R / pro-S labels survive the round-trip. +function getLabeledIDCode(molecule) { + const { Canonizer } = molecule.getOCL(); + const copy = molecule.getCompactCopy(); + copy.setFragment(true); + return new Canonizer(copy, { encodeAtomCustomLabels: true }).getIDCode(); +} + /** * Returns the SMILES of `molecule` with `hydrogen` turned into a deuterium * (mass 2). Marking a single CH2 hydrogen makes its carbon a stereocenter, so @@ -99,7 +109,9 @@ test('ethanol (CCO): the CH2 hydrogens are enantiotopic — same diaID, differen expect(new Set(topicMolecule.diaIDs).size).toBe(6); expect(new Set(enantioIDs).size).toBe(7); - const count = topicMolecule.setProchiralHydrogenLabels(); + const count = topicMolecule.setProchiralHydrogenLabels({ + includeEnantiotopic: true, + }); expect(count).toBe(2); @@ -110,7 +122,11 @@ test('ethanol (CCO): the CH2 hydrogens are enantiotopic — same diaID, differen ch2.hydrogens[1], ); - expect([label0, label1].toSorted()).toStrictEqual(['r', 's']); + expect([label0, label1].toSorted()).toStrictEqual([']r', ']s']); + + expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( + String.raw`gJQDAh@pBSUUHa|aB{}Df[n\muN`, + ); // The exact enantioID → pro-R / pro-S mapping, for verification in external // software. Snapshotted because idCodes can contain non-printable bytes. The @@ -156,16 +172,22 @@ test('propan-1-ol (CCCO): both CH2 groups are enantiotopic and prochiral', () => expect(new Set(topicMolecule.diaIDs).size).toBe(8); expect(new Set(topicMolecule.enantioIDs).size).toBe(10); - const count = topicMolecule.setProchiralHydrogenLabels(); + const count = topicMolecule.setProchiralHydrogenLabels({ + includeEnantiotopic: true, + }); expect(count).toBe(4); expect(getSortedHydrogenLabels(topicMolecule)).toStrictEqual([ - 'r', - 'r', - 's', - 's', + ']r', + ']r', + ']s', + ']s', ]); + expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( + 'daxHH@r@J@Z@z@RZZjjdaH`~PHkotIL[n\\fzgYny~[j\\', + ); + const mapping = {}; for (const group of groups) { for (let i = 0; i < group.hydrogens.length; i++) { @@ -206,18 +228,24 @@ test('butan-1-ol (CCCCO): all three CH2 groups are enantiotopic and prochiral', expect(new Set(topicMolecule.diaIDs).size).toBe(10); expect(new Set(topicMolecule.enantioIDs).size).toBe(13); - const count = topicMolecule.setProchiralHydrogenLabels(); + const count = topicMolecule.setProchiralHydrogenLabels({ + includeEnantiotopic: true, + }); expect(count).toBe(6); expect(getSortedHydrogenLabels(topicMolecule)).toStrictEqual([ - 'r', - 'r', - 'r', - 's', - 's', - 's', + ']r', + ']r', + ']r', + ']s', + ']s', + ']s', ]); + expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( + 'dmTHX@r@J@Z@z@f@V@rJQRFRjjjrDbiA|`PO_hrXw\\yMuNs]s|wT|mwNk]S`', + ); + const mapping = {}; for (const group of groups) { for (let i = 0; i < group.hydrogens.length; i++) { From 957c681bb9398e16741b2160cb3f72ba6a381fe6 Mon Sep 17 00:00:00 2001 From: Luc Patiny Date: Wed, 1 Jul 2026 16:11:09 +0200 Subject: [PATCH 2/2] test: assert prochiral idCodes with inline getCanonizedIDCode openchemlib 9.24.0 fixes custom labels on hydrogens being dropped from the canonized idCode (Actelion/openchemlib#171), so the compact-copy helper is no longer needed to preserve the pro-R/pro-S labels. Inline moleculeWithH.getCanonizedIDCode(CANONIZER_ENCODE_ATOM_CUSTOM_LABELS) at the assertion sites, drop the getLabeledIDCode helper, and require openchemlib >=9.24.0. The direct call reorders atoms in place, so each idCode assertion now runs after the index-based snapshot in its test. Assisted-By: Claude Opus 4.8 (1M context) --- package-lock.json | 79 ++----------------- package.json | 4 +- ...olecule.setProchiralHydrogenLabels.test.js | 42 +++++----- src/topic/__tests__/getEnantioIDs.test.js | 49 ++++++------ 4 files changed, 54 insertions(+), 120 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0617ba1..e61fec1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,14 +28,14 @@ "fifo-logger": "^2.0.1", "globals": "^17.7.0", "mf-parser": "^3.9.1", - "openchemlib": "^9.23.0", + "openchemlib": "^9.24.0", "prettier": "^3.9.1", "rimraf": "^6.1.3", "typescript": "^6.0.3", "vitest": "^4.1.9" }, "peerDependencies": { - "openchemlib": ">=9.20.1" + "openchemlib": ">=9.24.0" } }, "node_modules/@babel/code-frame": { @@ -2054,9 +2054,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2074,9 +2071,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2094,9 +2088,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2114,9 +2105,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2134,9 +2122,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2154,9 +2139,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2517,9 +2499,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2534,9 +2513,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2551,9 +2527,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2568,9 +2541,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2585,9 +2555,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2602,9 +2569,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2619,9 +2583,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2636,9 +2597,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2653,9 +2611,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2670,9 +2625,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2687,9 +2639,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2704,9 +2653,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2721,9 +2667,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6207,9 +6150,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6231,9 +6171,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6255,9 +6192,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6279,9 +6213,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6728,9 +6659,9 @@ } }, "node_modules/openchemlib": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/openchemlib/-/openchemlib-9.23.0.tgz", - "integrity": "sha512-kRFwKyXNl/ElbAoy41+dfbdZikJ7/0xcKm1bkciRD6dAeiq4Eb2TT04ePctCBBG26Iov+GgxeZqJHFLiG7Qp1Q==", + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/openchemlib/-/openchemlib-9.24.0.tgz", + "integrity": "sha512-aIZc4wxcIq4d1RK32WAkGjjyFUKPcf2zdlklvzkmmo2HuHTojFUubeSxp0CQ6MZDGLiK18iQJwxA4/O9Db2bvA==", "dev": true, "license": "BSD-3-Clause" }, diff --git a/package.json b/package.json index 1e06e5e..8f34e9a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "sdf-parser": "^9.0.0" }, "peerDependencies": { - "openchemlib": ">=9.20.1" + "openchemlib": ">=9.24.0" }, "devDependencies": { "@types/node": "^26.0.1", @@ -56,7 +56,7 @@ "fifo-logger": "^2.0.1", "globals": "^17.7.0", "mf-parser": "^3.9.1", - "openchemlib": "^9.23.0", + "openchemlib": "^9.24.0", "prettier": "^3.9.1", "rimraf": "^6.1.3", "typescript": "^6.0.3", diff --git a/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js b/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js index b775223..236aec0 100644 --- a/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js +++ b/src/topic/__tests__/TopicMolecule.setProchiralHydrogenLabels.test.js @@ -52,16 +52,6 @@ function getHydrogenLabels(topicMolecule) { return labels; } -// idCode that encodes the custom labels. The labels sit on hydrogens, which a -// canonizer drops as "simple"; switching to fragment mode keeps every explicit -// hydrogen so the pro-R / pro-S labels survive the round-trip. -function getLabeledIDCode(molecule) { - const { Canonizer } = molecule.getOCL(); - const copy = molecule.getCompactCopy(); - copy.setFragment(true); - return new Canonizer(copy, { encodeAtomCustomLabels: true }).getIDCode(); -} - test('CC[C@H](C)Cl: diaIDs and pro-R/pro-S assignment of the CH2 hydrogens', () => { const molecule = Molecule.fromSmiles('CC[C@H](C)Cl'); const topicMolecule = new TopicMolecule(molecule); @@ -104,9 +94,11 @@ test('CC[C@H](C)Cl: diaIDs and pro-R/pro-S assignment of the CH2 hydrogens', () expect([label0, label1].toSorted()).toStrictEqual([']r', ']s']); - expect(getLabeledIDCode(moleculeWithH)).toBe( - 'gNpLADV@\\@dsUURbRGvd[cwzIJw\\{[j\\', - ); + expect( + moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe(String.raw`gNpLADV@\@dsUURbRC}De[n]muN`); // Snapshot captures the diaID → r/s mapping for verification in external software expect({ [diaID0]: label0, [diaID1]: label1 }).toMatchSnapshot(); @@ -154,9 +146,11 @@ test('CC(Cl)CC: the CH2 hydrogens get distinct pro-R / pro-S labels', () => { expect(labels).toStrictEqual([']r', ']s']); - expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( - 'gNpLADV@\\@dsUUReBP~tc\\^\u007FQIV{g[]S`', - ); + expect( + topicMolecule.moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe(String.raw`gNpLADV@\@dsUUReBP_hdk]smnip`); }); test('CC1CCC1 (methylcyclobutane): the four CH2 hydrogens at C2/C4 are labelled, the apex CH2 is not', () => { @@ -172,9 +166,11 @@ test('CC1CCC1 (methylcyclobutane): the four CH2 hydrogens at C2/C4 are labelled, expect(labels).toStrictEqual([']r', ']r', ']s', ']s']); - expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( - 'did@HL`B`N`A`LddTlRjjjdAT`~rHSF{}BSF{gIni~[n\\Vzg@', - ); + expect( + topicMolecule.moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe('did@HL`B`N`A`LddTlRjjjdAT`_hRXw\\yMuOs]sbwTx'); }); test('CC(Cl)CC: heavy-atom customLabel is inherited by the labelled hydrogens', () => { @@ -205,9 +201,11 @@ test('CC(Cl)CC: heavy-atom customLabel is inherited by the labelled hydrogens', expect(labels).toStrictEqual([']3r', ']3s']); - expect(getLabeledIDCode(moleculeWithH)).toBe( - 'gNpLADV@\\@dsUUReBP~tc\\^\u007FSHkM^{fsonydx', - ); + expect( + moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe('gNpLADV@\\@dsUUReBP_idUfo]sYww\\r\\'); }); test('CCO (ethanol): enantiotopic CH2 hydrogens are not labelled by default', () => { diff --git a/src/topic/__tests__/getEnantioIDs.test.js b/src/topic/__tests__/getEnantioIDs.test.js index 212ed3b..ef1c210 100644 --- a/src/topic/__tests__/getEnantioIDs.test.js +++ b/src/topic/__tests__/getEnantioIDs.test.js @@ -51,16 +51,6 @@ function getSortedHydrogenLabels(topicMolecule) { return labels.toSorted(); } -// idCode that encodes the custom labels. The labels sit on hydrogens, which a -// canonizer drops as "simple"; switching to fragment mode keeps every explicit -// hydrogen so the pro-R / pro-S labels survive the round-trip. -function getLabeledIDCode(molecule) { - const { Canonizer } = molecule.getOCL(); - const copy = molecule.getCompactCopy(); - copy.setFragment(true); - return new Canonizer(copy, { encodeAtomCustomLabels: true }).getIDCode(); -} - /** * Returns the SMILES of `molecule` with `hydrogen` turned into a deuterium * (mass 2). Marking a single CH2 hydrogen makes its carbon a stereocenter, so @@ -124,10 +114,6 @@ test('ethanol (CCO): the CH2 hydrogens are enantiotopic — same diaID, differen expect([label0, label1].toSorted()).toStrictEqual([']r', ']s']); - expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( - String.raw`gJQDAh@pBSUUHa|aB{}Df[n\muN`, - ); - // The exact enantioID → pro-R / pro-S mapping, for verification in external // software. Snapshotted because idCodes can contain non-printable bytes. The // deuterated SMILES marks the analysed hydrogen so the assignment can be @@ -148,6 +134,15 @@ test('ethanol (CCO): the CH2 hydrogens are enantiotopic — same diaID, differen ), }, }).toMatchSnapshot(); + + // getCanonizedIDCode reorders the atoms in place (labelled hydrogens leave + // the trailing-hydrogen block), so assert it after everything that reads + // moleculeWithH atom indices. + expect( + topicMolecule.moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe('gJQDAh@pBSUUH`\u007FQIf{gK]S`'); }); test('propan-1-ol (CCCO): both CH2 groups are enantiotopic and prochiral', () => { @@ -184,10 +179,6 @@ test('propan-1-ol (CCCO): both CH2 groups are enantiotopic and prochiral', () => ']s', ]); - expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( - 'daxHH@r@J@Z@z@RZZjjdaH`~PHkotIL[n\\fzgYny~[j\\', - ); - const mapping = {}; for (const group of groups) { for (let i = 0; i < group.hydrogens.length; i++) { @@ -204,6 +195,15 @@ test('propan-1-ol (CCCO): both CH2 groups are enantiotopic and prochiral', () => } expect(mapping).toMatchSnapshot(); + + // getCanonizedIDCode reorders the atoms in place (labelled hydrogens leave + // the trailing-hydrogen block), so assert it after everything that reads + // moleculeWithH atom indices. + expect( + topicMolecule.moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe('daxHH@r@J@Z@z@RZZjjdaH`_hRXw\\yMuNs]s|wTx'); }); test('butan-1-ol (CCCCO): all three CH2 groups are enantiotopic and prochiral', () => { @@ -242,10 +242,6 @@ test('butan-1-ol (CCCCO): all three CH2 groups are enantiotopic and prochiral', ']s', ]); - expect(getLabeledIDCode(topicMolecule.moleculeWithH)).toBe( - 'dmTHX@r@J@Z@z@f@V@rJQRFRjjjrDbiA|`PO_hrXw\\yMuNs]s|wT|mwNk]S`', - ); - const mapping = {}; for (const group of groups) { for (let i = 0; i < group.hydrogens.length; i++) { @@ -262,6 +258,15 @@ test('butan-1-ol (CCCCO): all three CH2 groups are enantiotopic and prochiral', } expect(mapping).toMatchSnapshot(); + + // getCanonizedIDCode reorders the atoms in place (labelled hydrogens leave + // the trailing-hydrogen block), so assert it after everything that reads + // moleculeWithH atom indices. + expect( + topicMolecule.moleculeWithH.getCanonizedIDCode( + Molecule.CANONIZER_ENCODE_ATOM_CUSTOM_LABELS, + ), + ).toBe('dmTHX@r@J@Z@z@f@V@rJQRFRjjjrDbi@\u007FQdqnyr[j]f{gyniy[n]Vzg@'); }); test('getEnantioIDs racemic option collapses the enantiotopic CH2 hydrogens', () => {