Skip to content

Utilities

The candidate records and consensus vote behind Search (provesid.tools), and small helpers: CAS check digits and the per-user data and cache directories (provesid.utils).

provesid.tools

Candidate records and the consensus vote behind Search.

A candidate is one source's answer about one compound, normalised into a plain dict so that a ChEBI row, a CompTox row and a ZeroPM row can be compared without caring where each came from. make_candidate builds one; the candidate_from_* adapters build one from a particular source's row shape.

compute_consensus is the vote: it scores every candidate against every other and returns the source whose answer the others corroborate best, together with per-source agreement scores. That is what Search turns into the confidence column and what min_source_support filters on.

The rest are the small predicates and converters those two need — missing-value handling, CAS extraction, RDKit round-trips. They are public because provesid.search imports them across the module boundary, not because callers are expected to reach for them directly.

Attributes

UNRANKED_CAS_SOURCES module-attribute

Sources whose CAS numbers carry no ranking.

Their candidates list the numbers by registry number (see sort_cas_by_number), and pick_casrn asks one that lists several only when no other source has a number.

STRUCTURE_STATUSES module-attribute

The values of a candidate's and a result's structure_status.

valid The SMILES the source gave parses as it is. repaired:<origin> The SMILES the source gave does not parse, or parses to a structure other than the one its InChIKey names, and the structure was rebuilt from the source's own molfile, its InChI, or by putting back the hydrogen an aromatic ring nitrogen lost (nh_repair). A rebuilt structure is kept only when its InChIKey is the one the source states; see repair_structure. In the 2026-05 ChEBI SDF, 12,235 SMILES do not parse and 19,228 more parse to the wrong compound --- guanine's Nc1nc(=O)c2ncnc2n1 lacks two hydrogens --- or without a double bond's configuration; the molfile rebuilds all but 437. invalid The SMILES the source gave does not parse and could not be rebuilt, so the candidate carries no structure. none The source gave no structure.

Classes

Functions:

is_missing(value)

Report whether a value carries no information.

Sources disagree about how to say "nothing": None, float('nan'), an empty string, and the literal string "nan" all turn up in rows read from SQLite and from pandas. This treats all of them the same.

Parameters:

Name Type Description Default
value Any

Any value read from a source row.

required

Returns:

Type Description
bool

True when the value is None, NaN, blank, or the string "nan".

Examples:

>>> is_missing(None), is_missing("nan"), is_missing("  ")
(True, True, True)
>>> is_missing(0)
False
Source code in src/provesid/tools.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def is_missing(value: Any) -> bool:
    """Report whether a value carries no information.

    Sources disagree about how to say "nothing": ``None``, ``float('nan')``,
    an empty string, and the literal string ``"nan"`` all turn up in rows read
    from SQLite and from pandas. This treats all of them the same.

    Args:
        value: Any value read from a source row.

    Returns:
        True when the value is None, NaN, blank, or the string ``"nan"``.

    Examples:
        >>> is_missing(None), is_missing("nan"), is_missing("  ")
        (True, True, True)
        >>> is_missing(0)
        False
    """
    if value is None:
        return True
    if isinstance(value, str):
        return value.strip() == "" or value.strip().lower() == "nan"
    try:
        return bool(pd.isna(value))
    except Exception:
        return False

pick_first(*values)

Return the first argument that carries information.

Used to fill a field from a preferred source, falling back through less preferred ones, without a chain of conditionals.

Parameters:

Name Type Description Default
*values Any

Candidate values, most preferred first.

()

Returns:

Type Description
Any

The first value for which is_missing is False, or None when every argument is missing.

Examples:

>>> pick_first(None, float("nan"), "aspirin", "ASA")
'aspirin'
Source code in src/provesid/tools.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def pick_first(*values: Any) -> Any:
    """Return the first argument that carries information.

    Used to fill a field from a preferred source, falling back through less
    preferred ones, without a chain of conditionals.

    Args:
        *values: Candidate values, most preferred first.

    Returns:
        The first value for which [`is_missing`][provesid.tools.is_missing] is
        False, or None when every argument is missing.

    Examples:
        >>> pick_first(None, float("nan"), "aspirin", "ASA")
        'aspirin'
    """
    for value in values:
        if not is_missing(value):
            return value
    return None

normalize_synonyms(value)

Render synonyms as one semicolon-separated string.

Sources hand back synonyms as a list, a set, or an already-joined string. Candidate records store one string, so every shape collapses to the same representation before comparison.

Parameters:

Name Type Description Default
value Any

A synonym collection or a string of synonyms.

required

Returns:

Type Description
Optional[str]

The synonyms joined by "; ", or None when there are none.

Examples:

>>> normalize_synonyms(["aspirin", "ASA", None])
'aspirin; ASA'
Source code in src/provesid/tools.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def normalize_synonyms(value: Any) -> Optional[str]:
    """Render synonyms as one semicolon-separated string.

    Sources hand back synonyms as a list, a set, or an already-joined string.
    Candidate records store one string, so every shape collapses to the same
    representation before comparison.

    Args:
        value: A synonym collection or a string of synonyms.

    Returns:
        The synonyms joined by ``"; "``, or None when there are none.

    Examples:
        >>> normalize_synonyms(["aspirin", "ASA", None])
        'aspirin; ASA'
    """
    if is_missing(value):
        return None

    if isinstance(value, (list, tuple, set)):
        cleaned = [str(v).strip() for v in value if not is_missing(v)]
        return "; ".join(cleaned) if cleaned else None

    text = str(value).strip()
    return text if text else None

to_float(value)

Convert a value to float, or to None when it will not convert.

Molecular masses arrive as floats, as strings, and as NaN, sometimes in the same column. Comparisons need a float or nothing, never an exception.

Parameters:

Name Type Description Default
value Any

The value to convert.

required

Returns:

Type Description
Optional[float]

The value as a float, or None when it is missing or unparseable.

Examples:

>>> to_float("180.16"), to_float("n/a")
(180.16, None)
Source code in src/provesid/tools.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def to_float(value: Any) -> Optional[float]:
    """Convert a value to float, or to None when it will not convert.

    Molecular masses arrive as floats, as strings, and as NaN, sometimes in the
    same column. Comparisons need a float or nothing, never an exception.

    Args:
        value: The value to convert.

    Returns:
        The value as a float, or None when it is missing or unparseable.

    Examples:
        >>> to_float("180.16"), to_float("n/a")
        (180.16, None)
    """
    if is_missing(value):
        return None
    try:
        return float(value)
    except Exception:
        return None

text_similarity(a, b)

Score how alike two names are, ignoring case and surrounding space.

A cheap difflib ratio, used only as a weak signal in candidate_similarity: names corroborate a match but never decide one, because two sources routinely use different names for the same structure.

Parameters:

Name Type Description Default
a Optional[str]

One name, or None.

required
b Optional[str]

The other name, or None.

required

Returns:

Type Description
float

1.0 for an exact match after normalisation, 0.0 when either side is missing, otherwise the SequenceMatcher ratio in [0, 1].

Examples:

>>> text_similarity("Aspirin", "aspirin ")
1.0
>>> round(text_similarity("aspirin", "asprin"), 2)
0.92
Source code in src/provesid/tools.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def text_similarity(a: Optional[str], b: Optional[str]) -> float:
    """Score how alike two names are, ignoring case and surrounding space.

    A cheap ``difflib`` ratio, used only as a weak signal in
    [`candidate_similarity`][provesid.tools.candidate_similarity]: names
    corroborate a match but never decide one, because two sources routinely use
    different names for the same structure.

    Args:
        a: One name, or None.
        b: The other name, or None.

    Returns:
        1.0 for an exact match after normalisation, 0.0 when either side is
        missing, otherwise the ``SequenceMatcher`` ratio in [0, 1].

    Examples:
        >>> text_similarity("Aspirin", "aspirin ")
        1.0
        >>> round(text_similarity("aspirin", "asprin"), 2)
        0.92
    """
    if is_missing(a) or is_missing(b):
        return 0.0
    a_text = str(a).strip().lower()
    b_text = str(b).strip().lower()
    if a_text == b_text:
        return 1.0
    return SequenceMatcher(None, a_text, b_text).ratio()

extract_cas_values(value)

Find every CAS Registry Number anywhere inside a value.

Walks dicts, lists, tuples and sets recursively and pattern-matches the text of everything else, so an entire source row can be handed over without knowing which of its columns holds a CAS.

A match (\d{2,7}-\d{2}-\d) is kept only when its check digit agrees (see check_CASRN). That drops malformed numbers such as PubChem's 001-02-2 for atrazine and most number-shaped fragments of other text: ChEBI's InChI for XFNLWIPNTYNNJX-UHFFFAOYSA-N contains ...(12)14-10-6-8.... One such fragment in ten still has a valid check digit by chance, so pass the fields that hold CAS numbers, not a whole row, where the source has such fields.

The numbers keep the order they are found in, because a source's order can carry meaning: CompTox's CASRN column holds the current number, and PubChem lists its synonyms most relevant first. A set has no order, so its members are read sorted.

Parameters:

Name Type Description Default
value Any

A row, a collection, or a single value of any type.

required

Returns:

Type Description
List[str]

The distinct CAS-shaped strings found, first occurrence first, or an empty list.

Examples:

>>> extract_cas_values({"CASRN": "50-78-2", "syn": ["ASA", "50-78-2"]})
['50-78-2']
>>> extract_cas_values(["50-78-2", "11126-35-5 | 50-78-2"])
['50-78-2', '11126-35-5']
>>> extract_cas_values("001-02-2; 1912-24-9")
['1912-24-9']
Source code in src/provesid/tools.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def extract_cas_values(value: Any) -> List[str]:
    r"""Find every CAS Registry Number anywhere inside a value.

    Walks dicts, lists, tuples and sets recursively and pattern-matches the
    text of everything else, so an entire source row can be handed over
    without knowing which of its columns holds a CAS.

    A match (``\d{2,7}-\d{2}-\d``) is kept only when its check digit
    agrees (see [`check_CASRN`][provesid.utils.check_CASRN]). That drops
    malformed numbers such as PubChem's ``001-02-2`` for atrazine and most
    number-shaped fragments of other text: ChEBI's InChI for
    ``XFNLWIPNTYNNJX-UHFFFAOYSA-N`` contains ``...(12)14-10-6-8...``. One
    such fragment in ten still has a valid check digit by chance, so pass
    the fields that hold CAS numbers, not a whole row, where the source has
    such fields.

    The numbers keep the order they are found in, because a source's order
    can carry meaning: CompTox's ``CASRN`` column holds the current number,
    and PubChem lists its synonyms most relevant first. A set has no order,
    so its members are read sorted.

    Args:
        value: A row, a collection, or a single value of any type.

    Returns:
        The distinct CAS-shaped strings found, first occurrence first, or an
        empty list.

    Examples:
        >>> extract_cas_values({"CASRN": "50-78-2", "syn": ["ASA", "50-78-2"]})
        ['50-78-2']
        >>> extract_cas_values(["50-78-2", "11126-35-5 | 50-78-2"])
        ['50-78-2', '11126-35-5']
        >>> extract_cas_values("001-02-2; 1912-24-9")
        ['1912-24-9']
    """
    found: List[str] = []

    if value is None:
        return found

    if isinstance(value, dict):
        for dict_value in value.values():
            found.extend(extract_cas_values(dict_value))
    elif isinstance(value, (list, tuple)):
        for item in value:
            found.extend(extract_cas_values(item))
    elif isinstance(value, set):
        for item in sorted(value, key=str):
            found.extend(extract_cas_values(item))
    else:
        text = str(value)
        found.extend(cas for cas in _CAS_PATTERN.findall(text) if check_CASRN(cas))

    return list(dict.fromkeys(found))

sort_cas_by_number(cas_values)

Order CAS numbers by registry number, lowest first.

For sources whose list carries no ranking. The current number is usually the lowest, because the numbers that CAS later retired were mostly registered after it: of the 41,313 CompTox substances with more than one CAS, the CASRN column is the lowest number for 82.6%, and the smallest as a string for 42.5%. Where a source does rank its numbers, keep that order instead.

Parameters:

Name Type Description Default
cas_values List[str]

CAS numbers, as returned by extract_cas_values.

required

Returns:

Type Description
List[str]

A new list, ordered by the number with the hyphens removed.

Examples:

>>> sort_cas_by_number(["11126-35-5", "50-78-2"])
['50-78-2', '11126-35-5']
Source code in src/provesid/tools.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def sort_cas_by_number(cas_values: List[str]) -> List[str]:
    """Order CAS numbers by registry number, lowest first.

    For sources whose list carries no ranking. The current number is
    usually the lowest, because the numbers that CAS later retired were
    mostly registered after it: of the 41,313 CompTox substances with more
    than one CAS, the ``CASRN`` column is the lowest number for 82.6%, and
    the smallest as a string for 42.5%. Where a source does rank its
    numbers, keep that order instead.

    Args:
        cas_values: CAS numbers, as returned by
            [`extract_cas_values`][provesid.tools.extract_cas_values].

    Returns:
        A new list, ordered by the number with the hyphens removed.

    Examples:
        >>> sort_cas_by_number(["11126-35-5", "50-78-2"])
        ['50-78-2', '11126-35-5']
    """
    return sorted(cas_values, key=lambda cas: int(cas.replace("-", "")))

inchi_to_smiles(inchi)

Convert an InChI string to SMILES.

Parameters:

Name Type Description Default
inchi Optional[str]

The InChI string, or None.

required

Returns:

Type Description
Optional[str]

The SMILES string, or None when the input is missing, RDKit is not installed, or RDKit cannot parse the InChI.

Examples:

>>> inchi_to_smiles("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
'CCO'
>>> inchi_to_smiles(None) is None
True
Source code in src/provesid/tools.py
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
def inchi_to_smiles(inchi: Optional[str]) -> Optional[str]:
    """Convert an InChI string to SMILES.

    Args:
        inchi: The InChI string, or None.

    Returns:
        The SMILES string, or None when the input is missing, RDKit is not
        installed, or RDKit cannot parse the InChI.

    Examples:
        >>> inchi_to_smiles("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3")
        'CCO'
        >>> inchi_to_smiles(None) is None
        True
    """
    if is_missing(inchi) or not RDKIT_AVAILABLE or Chem is None:
        return None
    try:
        mol = Chem.MolFromInchi(str(inchi))
        if mol is None:
            return None
        return Chem.MolToSmiles(mol)
    except Exception:
        return None

inchikey_from_smiles(smiles)

Derive an InChIKey from a SMILES string.

Lets a source that publishes a structure but no InChIKey still be matched against one that publishes the key, which is how most cross-source agreement is actually established.

Parameters:

Name Type Description Default
smiles Optional[str]

The SMILES string, or None.

required

Returns:

Type Description
Optional[str]

The InChIKey, or None when the input is missing, RDKit is not installed, or RDKit cannot parse the SMILES.

Examples:

>>> inchikey_from_smiles("OCC")
'LFQSCWFLJHTTHZ-UHFFFAOYSA-N'
Source code in src/provesid/tools.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def inchikey_from_smiles(smiles: Optional[str]) -> Optional[str]:
    """Derive an InChIKey from a SMILES string.

    Lets a source that publishes a structure but no InChIKey still be matched
    against one that publishes the key, which is how most cross-source
    agreement is actually established.

    Args:
        smiles: The SMILES string, or None.

    Returns:
        The InChIKey, or None when the input is missing, RDKit is not
        installed, or RDKit cannot parse the SMILES.

    Examples:
        >>> inchikey_from_smiles("OCC")
        'LFQSCWFLJHTTHZ-UHFFFAOYSA-N'
    """
    if is_missing(smiles) or not RDKIT_AVAILABLE or Chem is None:
        return None
    try:
        mol = Chem.MolFromSmiles(str(smiles))
        if mol is None:
            return None
        inchi = Chem.MolToInchi(mol)
        if is_missing(inchi):
            return None
        return Chem.InchiToInchiKey(inchi)
    except Exception:
        return None

standardize_inchi_and_key(smiles, inchi, inchikey)

Replace a non-standard InChI or InChIKey with the standard one.

CompTox stores a non-standard InChIKey (flag N, as in PGRHXDWITVMQBC-UHFFFAOYNA-N) for about 11% of its substances, and ZeroPM a non-standard InChI (InChI=1/...) and key for about 5%. Such a key never equals the standard key another source publishes for the same structure, so it cannot be clustered with it or used to look the structure up elsewhere. This computes the standard InChI and key from the structure, the SMILES when there is one and the InChI otherwise.

A value that is already standard, or missing, is returned unchanged. A string that is not an InChIKey at all is also left alone.

Parameters:

Name Type Description Default
smiles Optional[str]

The source's structure as SMILES, or None.

required
inchi Optional[str]

The source's InChI, or None.

required
inchikey Optional[str]

The source's InChIKey, or None.

required

Returns:

Type Description
Tuple[Optional[str], Optional[str]]

An (inchi, inchikey) tuple. A non-standard value is replaced by the standard one, or by None when no standard one can be computed (the structure is missing, RDKit cannot read it, or RDKit is not installed).

Examples:

>>> standardize_inchi_and_key(
...     "CC(=O)C1C(=O)OC(C)=CC1=O", None, "PGRHXDWITVMQBC-UHFFFAOYNA-N")
(None, 'PGRHXDWITVMQBC-UHFFFAOYSA-N')
>>> standardize_inchi_and_key(None, "InChI=1/CH2O/c1-2/h1H2", None)
('InChI=1S/CH2O/c1-2/h1H2', None)
>>> standardize_inchi_and_key("C=O", None, "WSFSSNUMVMOOMR-UHFFFAOYSA-N")
(None, 'WSFSSNUMVMOOMR-UHFFFAOYSA-N')
Source code in src/provesid/tools.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
def standardize_inchi_and_key(
    smiles: Optional[str], inchi: Optional[str], inchikey: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
    """Replace a non-standard InChI or InChIKey with the standard one.

    CompTox stores a non-standard InChIKey (flag ``N``, as in
    ``PGRHXDWITVMQBC-UHFFFAOYNA-N``) for about 11% of its substances, and
    ZeroPM a non-standard InChI (``InChI=1/...``) and key for about 5%. Such
    a key never equals the standard key another source publishes for the
    same structure, so it cannot be clustered with it or used to look the
    structure up elsewhere. This computes the standard InChI and key from the
    structure, the SMILES when there is one and the InChI otherwise.

    A value that is already standard, or missing, is returned unchanged. A
    string that is not an InChIKey at all is also left alone.

    Args:
        smiles: The source's structure as SMILES, or None.
        inchi: The source's InChI, or None.
        inchikey: The source's InChIKey, or None.

    Returns:
        An ``(inchi, inchikey)`` tuple. A non-standard value is replaced by
        the standard one, or by None when no standard one can be computed
        (the structure is missing, RDKit cannot read it, or RDKit is not
        installed).

    Examples:
        >>> standardize_inchi_and_key(
        ...     "CC(=O)C1C(=O)OC(C)=CC1=O", None, "PGRHXDWITVMQBC-UHFFFAOYNA-N")
        (None, 'PGRHXDWITVMQBC-UHFFFAOYSA-N')
        >>> standardize_inchi_and_key(None, "InChI=1/CH2O/c1-2/h1H2", None)
        ('InChI=1S/CH2O/c1-2/h1H2', None)
        >>> standardize_inchi_and_key("C=O", None, "WSFSSNUMVMOOMR-UHFFFAOYSA-N")
        (None, 'WSFSSNUMVMOOMR-UHFFFAOYSA-N')
    """
    inchi_is_nonstandard = not is_missing(inchi) and str(inchi).startswith("InChI=1/")
    key_is_nonstandard = (
        not is_missing(inchikey)
        and len(str(inchikey)) == 27
        and str(inchikey)[23] == "N"
    )
    if not (inchi_is_nonstandard or key_is_nonstandard):
        return inchi, inchikey

    standard_inchi = None
    if RDKIT_AVAILABLE and Chem is not None:
        # RDKit's InChI warnings ("Omitted undefined stereo") are expected for
        # these structures and say nothing the caller can act on.
        with rdBase.BlockLogs():
            try:
                mol = Chem.MolFromSmiles(str(smiles)) if not is_missing(smiles) else None
                if mol is None and not is_missing(inchi):
                    mol = Chem.MolFromInchi(str(inchi))
                if mol is not None:
                    standard_inchi = Chem.MolToInchi(mol) or None
            except Exception:
                standard_inchi = None
    standard_key = Chem.InchiToInchiKey(standard_inchi) if standard_inchi else None

    return (
        standard_inchi if inchi_is_nonstandard else inchi,
        standard_key if key_is_nonstandard else inchikey,
    )

first_cas(cas_values)

Pick one CAS number out of a candidate's list.

The list keeps the source's order (see extract_cas_values), so the first number is the one the source puts first. For CompTox that is its CASRN column, the current number, ahead of the retired ones in its identifiers. Sorting the list instead would put aspirin's retired 11126-35-5 ahead of 50-78-2, since it is smaller as a string.

Parameters:

Name Type Description Default
cas_values List[str]

CAS numbers, as returned by extract_cas_values.

required

Returns:

Type Description
Optional[str]

The first CAS number, or None when the list is empty.

Examples:

>>> first_cas(extract_cas_values(["50-78-2", "11126-35-5 | 50-78-2"]))
'50-78-2'
>>> first_cas([]) is None
True
Source code in src/provesid/tools.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
def first_cas(cas_values: List[str]) -> Optional[str]:
    """Pick one CAS number out of a candidate's list.

    The list keeps the source's order (see
    [`extract_cas_values`][provesid.tools.extract_cas_values]), so the first
    number is the one the source puts first. For CompTox that is its
    ``CASRN`` column, the current number, ahead of the retired ones in its
    identifiers. Sorting the list instead would put aspirin's retired
    ``11126-35-5`` ahead of ``50-78-2``, since it is smaller as a string.

    Args:
        cas_values: CAS numbers, as returned by
            [`extract_cas_values`][provesid.tools.extract_cas_values].

    Returns:
        The first CAS number, or None when the list is empty.

    Examples:
        >>> first_cas(extract_cas_values(["50-78-2", "11126-35-5 | 50-78-2"]))
        '50-78-2'
        >>> first_cas([]) is None
        True
    """
    return cas_values[0] if cas_values else None

is_parsable_smiles(smiles)

Report whether RDKit can read and sanitise a SMILES.

ChEBI's SMILES field fails this for about 6% of its records, mostly because an aromatic ring N-H was dropped on export (c2nnnn2 for a tetrazole). Without RDKit installed nothing can be checked, so any non-missing string passes.

Parameters:

Name Type Description Default
smiles Optional[str]

The SMILES string, or None.

required

Returns:

Type Description
bool

True when the SMILES parses, False when it is missing or does not.

Examples:

>>> is_parsable_smiles("c1ccccc1"), is_parsable_smiles("c1nnnn1")
(True, False)
Source code in src/provesid/tools.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
def is_parsable_smiles(smiles: Optional[str]) -> bool:
    """Report whether RDKit can read and sanitise a SMILES.

    ChEBI's ``SMILES`` field fails this for about 6% of its records, mostly
    because an aromatic ring N-H was dropped on export (``c2nnnn2`` for a
    tetrazole). Without RDKit installed nothing can be checked, so any
    non-missing string passes.

    Args:
        smiles: The SMILES string, or None.

    Returns:
        True when the SMILES parses, False when it is missing or does not.

    Examples:
        >>> is_parsable_smiles("c1ccccc1"), is_parsable_smiles("c1nnnn1")
        (True, False)
    """
    if is_missing(smiles):
        return False
    if not RDKIT_AVAILABLE or Chem is None:
        return True
    return _mol_from_smiles_quietly(smiles) is not None

repair_structure(smiles, *, molfile=None, inchi=None, inchikey=None)

Return a usable SMILES for a source's structure, rebuilding it if needed.

A SMILES that parses is returned as it is. One that does not --- ChEBI's export drops the hydrogen of aromatic ring nitrogens, so valsartan's tetrazole reads c2nnnn2 --- is rebuilt from what else the source says about the structure, in this order:

  1. its molfile (ChEBI's curated record, of which the SMILES is an export);
  2. its InChI;
  3. the SMILES itself, with the missing ring N-H put back (nh_repair).

A rebuilt structure is accepted only when its InChIKey is the one the source states (or the key of the InChI it states): the full key, or the skeleton and charge when the source states no stereo. Otherwise the structure is given up as invalid rather than guessed.

A SMILES that parses but is not the compound the source's InChIKey names is also rebuilt, from the molfile only, when one is given. ChEBI exports about 19,000 such SMILES: most lost the configuration of a double bond (CCCCCC/C=CCCC... has one slash, so no stereo), and some 275 lost ring hydrogens and still parse (guanine as Nc1nc(=O)c2ncnc2n1, C5H3N5O). The molfile is the curated record, and when its key is the stated one it wins. Without a molfile that agrees the SMILES is kept, and identity_consistent reports the disagreement.

A missing SMILES is not rebuilt from the other fields: the source gave no structure, and that is reported as "none".

Parameters:

Name Type Description Default
smiles Optional[str]

The source's SMILES, or None.

required
molfile Optional[str]

The source's molfile (V2000 or V3000 block), or None.

None
inchi Optional[str]

The source's InChI, or None.

None
inchikey Optional[str]

The source's InChIKey, or None.

None

Returns:

Type Description
Tuple[Optional[str], Optional[str], str]

A (smiles, origin, status) tuple: the usable SMILES, or None; the field it came from ("smiles", "molfile", "inchi", "nh_repair"), or None; and the STRUCTURE_STATUSES entry. Without RDKit installed nothing is checked, and a SMILES is returned as "valid".

Examples:

>>> repair_structure("CCO")
('CCO', 'smiles', 'valid')
>>> repair_structure("O=c1ncc(F)c(=O)n1",            # 5-fluorouracil, ChEBI
...                  inchikey="GHASVSINZRGABV-UHFFFAOYSA-N")
('O=c1[nH]cc(F)c(=O)[nH]1', 'nh_repair', 'repaired:nh_repair')
>>> repair_structure("O=c1ncc(F)c(=O)n1", inchikey="XLYOFNOQVPJJNP-UHFFFAOYSA-N")
(None, None, 'invalid')
>>> repair_structure(None)
(None, None, 'none')
Source code in src/provesid/tools.py
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
def repair_structure(
    smiles: Optional[str],
    *,
    molfile: Optional[str] = None,
    inchi: Optional[str] = None,
    inchikey: Optional[str] = None,
) -> Tuple[Optional[str], Optional[str], str]:
    """Return a usable SMILES for a source's structure, rebuilding it if needed.

    A SMILES that parses is returned as it is. One that does not --- ChEBI's
    export drops the hydrogen of aromatic ring nitrogens, so valsartan's
    tetrazole reads ``c2nnnn2`` --- is rebuilt from what else the source
    says about the structure, in this order:

    1. its molfile (ChEBI's curated record, of which the SMILES is an export);
    2. its InChI;
    3. the SMILES itself, with the missing ring N-H put back
       (``nh_repair``).

    A rebuilt structure is accepted only when its InChIKey is the one the
    source states (or the key of the InChI it states): the full key, or the
    skeleton and charge when the source states no stereo. Otherwise the
    structure is given up as invalid rather than guessed.

    A SMILES that parses but is not the compound the source's InChIKey names
    is also rebuilt, from the molfile only, when one is given. ChEBI exports
    about 19,000 such SMILES: most lost the configuration of a double bond
    (``CCCCCC/C=CCCC...`` has one slash, so no stereo), and some 275 lost
    ring hydrogens and still parse (guanine as ``Nc1nc(=O)c2ncnc2n1``,
    C5H3N5O). The molfile is the curated record, and when its key is the
    stated one it wins. Without a molfile
    that agrees the SMILES is kept, and
    [`identity_consistent`][provesid.tools.identity_consistent] reports the
    disagreement.

    A missing SMILES is not rebuilt from the other fields: the source gave no
    structure, and that is reported as ``"none"``.

    Args:
        smiles: The source's SMILES, or None.
        molfile: The source's molfile (V2000 or V3000 block), or None.
        inchi: The source's InChI, or None.
        inchikey: The source's InChIKey, or None.

    Returns:
        A ``(smiles, origin, status)`` tuple: the usable SMILES, or None; the
        field it came from (``"smiles"``, ``"molfile"``, ``"inchi"``,
        ``"nh_repair"``), or None; and the
        [`STRUCTURE_STATUSES`][provesid.tools.STRUCTURE_STATUSES] entry.
        Without RDKit installed nothing is checked, and a SMILES is returned
        as ``"valid"``.

    Examples:
        >>> repair_structure("CCO")
        ('CCO', 'smiles', 'valid')
        >>> repair_structure("O=c1ncc(F)c(=O)n1",            # 5-fluorouracil, ChEBI
        ...                  inchikey="GHASVSINZRGABV-UHFFFAOYSA-N")
        ('O=c1[nH]cc(F)c(=O)[nH]1', 'nh_repair', 'repaired:nh_repair')
        >>> repair_structure("O=c1ncc(F)c(=O)n1", inchikey="XLYOFNOQVPJJNP-UHFFFAOYSA-N")
        (None, None, 'invalid')
        >>> repair_structure(None)
        (None, None, 'none')
    """
    if is_missing(smiles):
        return None, None, "none"
    if not RDKIT_AVAILABLE or Chem is None:
        return str(smiles), "smiles", "valid"

    def rebuild(origin: str) -> Optional[str]:
        """The SMILES of the molfile or InChI, when its key is the reference."""
        text = molfile if origin == "molfile" else inchi
        if is_missing(text):
            return None
        with rdBase.BlockLogs():
            try:
                mol = Chem.MolFromMolBlock(str(text)) if origin == "molfile" else Chem.MolFromInchi(str(text))
            except Exception:
                mol = None
        if mol is None or not _keys_agree(_inchikey_of(mol), reference):
            return None
        return Chem.MolToSmiles(mol)

    reference = _reference_inchikey(inchi, inchikey)
    parsed = _mol_from_smiles_quietly(smiles)
    if parsed is not None:
        if reference is None or is_missing(molfile) or _keys_agree(_inchikey_of(parsed), reference):
            return str(smiles), "smiles", "valid"
        rebuilt = rebuild("molfile")
        if rebuilt is not None:
            log.debug("SMILES %r is not %s; taken from the molfile.", smiles, reference)
            return rebuilt, "molfile", "repaired:molfile"
        return str(smiles), "smiles", "valid"

    if reference is None:
        log.debug("Unparsable SMILES %r and no InChIKey to check a repair against.", smiles)
        return None, None, "invalid"

    for origin in ("molfile", "inchi"):
        rebuilt = rebuild(origin)
        if rebuilt is not None:
            log.debug("Unparsable SMILES %r rebuilt from the %s.", smiles, origin)
            return rebuilt, origin, f"repaired:{origin}"

    repaired = _add_aromatic_nh(str(smiles), reference)
    if repaired is not None:
        log.debug("Unparsable SMILES %r repaired to %r.", smiles, repaired)
        return repaired, "nh_repair", "repaired:nh_repair"

    log.debug("Unparsable SMILES %r could not be rebuilt to %s.", smiles, reference)
    return None, None, "invalid"

make_candidate(source, *, name=None, iupac_name=None, molecular_formula=None, smiles=None, molfile=None, inchi=None, inchikey=None, dtxsid=None, molecular_mass=None, synonyms=None, cas_candidates=None, derived_from=None)

Build one source's answer in the shape every comparison expects.

A candidate is a plain dict with a fixed set of keys, so a ChEBI row and a ZeroPM row can be scored against each other without either side knowing where the other came from. The SMILES is canonicalised on the way in, and the molecular mass is taken from the source when it gives one and computed from the structure when it does not — both so that two sources stating the same compound differently still compare equal. For the same reason a non-standard InChI or InChIKey, which CompTox and ZeroPM store for some substances, is replaced by the standard one; see standardize_inchi_and_key.

A SMILES that RDKit cannot read is never passed on. It is rebuilt from the source's molfile or InChI, or repaired, when the result has the InChIKey the source states, and dropped otherwise; see repair_structure. The string the source gave is kept in source_smiles_raw, and structure_status says what happened.

Parameters:

Name Type Description Default
source str

Display name of the source, e.g. "ChEBI". This is what appears in source_details and in the consensus report.

required
name Optional[str]

The source's preferred name for the compound.

None
iupac_name Optional[str]

The IUPAC name, where the source distinguishes it.

None
molecular_formula Optional[str]

The molecular formula as the source states it.

None
smiles Optional[str]

The structure as SMILES.

None
molfile Optional[str]

The structure as a molfile, used only to rebuild a SMILES that does not parse.

None
inchi Optional[str]

The structure as InChI. A non-standard one is replaced.

None
inchikey Optional[str]

The InChIKey. A non-standard one is replaced.

None
dtxsid Optional[str]

The DSSTox identifier, for sources that carry one.

None
molecular_mass Optional[float]

The mass the source states; falls back to the mass RDKit computes from smiles.

None
synonyms Optional[str]

Synonyms, already flattened by normalize_synonyms.

None
cas_candidates Optional[List[str]]

Every CAS the row mentions, in the source's order. Duplicates are dropped and the first occurrence kept, so the first number stays the one first_cas reports.

None
derived_from Optional[str]

For a candidate found by another source's answer rather than by the query --- ChEMBL, which holds no CAS numbers, looked up by the structure ChEBI found for one --- that source's display name. Such a candidate is an echo, not a second opinion, and compute_consensus does not count it.

None

Returns:

Type Description
Dict[str, Any]

The candidate record: a dict with the keys source, name, IUPAC_name, molecular_formula, SMILES, canonical_smiles, InChI, InChIKey, DTXSID, molecular_mass, Synonyms, CAS_candidates, structure_status, structure_origin, source_smiles_raw and derived_from.

Examples:

>>> cand = make_candidate("ChEBI", name="aspirin", smiles="CC(=O)Oc1ccccc1C(=O)O")
>>> cand["canonical_smiles"]
'CC(=O)Oc1ccccc1C(=O)O'
>>> round(cand["molecular_mass"], 2)
180.16
>>> make_candidate("CompTox", smiles="CC(=O)C1C(=O)OC(C)=CC1=O",
...                inchikey="PGRHXDWITVMQBC-UHFFFAOYNA-N")["InChIKey"]
'PGRHXDWITVMQBC-UHFFFAOYSA-N'

ChEBI's SMILES for allopurinol lacks its ring N-H:

>>> cand = make_candidate("ChEBI", smiles="Oc1ncnc2nncc12",
...                       inchikey="OFCNXPDARWKPPY-UHFFFAOYSA-N")
>>> cand["structure_status"], cand["source_smiles_raw"]
('repaired:nh_repair', 'Oc1ncnc2nncc12')
Source code in src/provesid/tools.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
def make_candidate(
    source: str,
    *,
    name: Optional[str] = None,
    iupac_name: Optional[str] = None,
    molecular_formula: Optional[str] = None,
    smiles: Optional[str] = None,
    molfile: Optional[str] = None,
    inchi: Optional[str] = None,
    inchikey: Optional[str] = None,
    dtxsid: Optional[str] = None,
    molecular_mass: Optional[float] = None,
    synonyms: Optional[str] = None,
    cas_candidates: Optional[List[str]] = None,
    derived_from: Optional[str] = None,
) -> Dict[str, Any]:
    """Build one source's answer in the shape every comparison expects.

    A candidate is a plain dict with a fixed set of keys, so a ChEBI row and a
    ZeroPM row can be scored against each other without either side knowing
    where the other came from. The SMILES is canonicalised on the way in, and
    the molecular mass is taken from the source when it gives one and computed
    from the structure when it does not — both so that two sources stating the
    same compound differently still compare equal. For the same reason a
    non-standard InChI or InChIKey, which CompTox and ZeroPM store for some
    substances, is replaced by the standard one; see
    [`standardize_inchi_and_key`][provesid.tools.standardize_inchi_and_key].

    A SMILES that RDKit cannot read is never passed on. It is rebuilt from the
    source's molfile or InChI, or repaired, when the result has the InChIKey
    the source states, and dropped otherwise; see
    [`repair_structure`][provesid.tools.repair_structure]. The string the
    source gave is kept in ``source_smiles_raw``, and ``structure_status``
    says what happened.

    Args:
        source: Display name of the source, e.g. ``"ChEBI"``. This is what
            appears in ``source_details`` and in the consensus report.
        name: The source's preferred name for the compound.
        iupac_name: The IUPAC name, where the source distinguishes it.
        molecular_formula: The molecular formula as the source states it.
        smiles: The structure as SMILES.
        molfile: The structure as a molfile, used only to rebuild a SMILES
            that does not parse.
        inchi: The structure as InChI. A non-standard one is replaced.
        inchikey: The InChIKey. A non-standard one is replaced.
        dtxsid: The DSSTox identifier, for sources that carry one.
        molecular_mass: The mass the source states; falls back to the mass
            RDKit computes from ``smiles``.
        synonyms: Synonyms, already flattened by
            [`normalize_synonyms`][provesid.tools.normalize_synonyms].
        cas_candidates: Every CAS the row mentions, in the source's order.
            Duplicates are dropped and the first occurrence kept, so the
            first number stays the one
            [`first_cas`][provesid.tools.first_cas] reports.
        derived_from: For a candidate found by another source's answer
            rather than by the query --- ChEMBL, which holds no CAS numbers,
            looked up by the structure ChEBI found for one --- that source's
            display name. Such a candidate is an echo, not a second opinion,
            and [`compute_consensus`][provesid.tools.compute_consensus] does
            not count it.

    Returns:
        The candidate record: a dict with the keys ``source``, ``name``,
        ``IUPAC_name``, ``molecular_formula``, ``SMILES``,
        ``canonical_smiles``, ``InChI``, ``InChIKey``, ``DTXSID``,
        ``molecular_mass``, ``Synonyms``, ``CAS_candidates``,
        ``structure_status``, ``structure_origin``, ``source_smiles_raw`` and
        ``derived_from``.

    Examples:
        >>> cand = make_candidate("ChEBI", name="aspirin", smiles="CC(=O)Oc1ccccc1C(=O)O")
        >>> cand["canonical_smiles"]
        'CC(=O)Oc1ccccc1C(=O)O'
        >>> round(cand["molecular_mass"], 2)
        180.16
        >>> make_candidate("CompTox", smiles="CC(=O)C1C(=O)OC(C)=CC1=O",
        ...                inchikey="PGRHXDWITVMQBC-UHFFFAOYNA-N")["InChIKey"]
        'PGRHXDWITVMQBC-UHFFFAOYSA-N'

        ChEBI's SMILES for allopurinol lacks its ring N-H:

        >>> cand = make_candidate("ChEBI", smiles="Oc1ncnc2nncc12",
        ...                       inchikey="OFCNXPDARWKPPY-UHFFFAOYSA-N")
        >>> cand["structure_status"], cand["source_smiles_raw"]
        ('repaired:nh_repair', 'Oc1ncnc2nncc12')
    """
    raw_smiles = None if is_missing(smiles) else str(smiles)
    smiles, structure_origin, structure_status = repair_structure(
        raw_smiles, molfile=molfile, inchi=inchi, inchikey=inchikey
    )
    canonical_smiles, rdkit_mass = smiles_to_canonical_and_mass(smiles)
    inchi, inchikey = standardize_inchi_and_key(smiles, inchi, inchikey)
    return {
        "source": source,
        "name": name,
        "IUPAC_name": iupac_name,
        "molecular_formula": molecular_formula,
        "SMILES": smiles,
        "canonical_smiles": canonical_smiles,
        "InChI": inchi,
        "InChIKey": inchikey,
        "DTXSID": dtxsid,
        "molecular_mass": pick_first(to_float(molecular_mass), rdkit_mass),
        "Synonyms": synonyms,
        "CAS_candidates": list(dict.fromkeys(cas_candidates or [])),
        "structure_status": structure_status,
        "structure_origin": structure_origin,
        "source_smiles_raw": raw_smiles,
        "derived_from": derived_from,
    }

candidate_similarity(left, right)

Score how strongly two candidates agree that they describe one compound.

Each field the two candidates both carry contributes its weight to the denominator and, when the values match, to the numerator. Fields only one side has are ignored entirely, so a sparse source is neither rewarded nor punished for its silence — it simply has less to say.

The weights rank the evidence: canonical SMILES (4) above CAS overlap and InChIKey (3 each), above InChI (2) and mass agreement (2), above formula (1) and name similarity (1). Mass and name score partially — a mass within 0.2 scores full, within 1.0 scores half; a name similarity of 0.9 scores full, 0.7 scores half. Two SMILES whose candidates carry the same InChIKey count as agreeing: they are tautomers (ChEBI writes allopurinol Oc1ncnc2[nH]ncc12, PubChem O=c1[nH]cnc2[nH]ncc12), which the standard InChI deliberately does not tell apart.

Parameters:

Name Type Description Default
left Dict[str, Any]

One candidate record.

required
right Dict[str, Any]

The other candidate record.

required

Returns:

Type Description
float

Weighted agreement in [0, 1]. Returns 0.0 when either side is None or when the two share no comparable field at all — note that "no shared evidence" and "shared evidence that disagrees" both come back as 0.0.

Examples:

>>> a = make_candidate("ChEBI", smiles="CC(=O)Oc1ccccc1C(=O)O")
>>> b = make_candidate("CompTox", smiles="CC(=O)Oc1ccccc1C(=O)O")
>>> candidate_similarity(a, b)
1.0
Source code in src/provesid/tools.py
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
def candidate_similarity(left: Dict[str, Any], right: Dict[str, Any]) -> float:
    """Score how strongly two candidates agree that they describe one compound.

    Each field the two candidates *both* carry contributes its weight to the
    denominator and, when the values match, to the numerator. Fields only one
    side has are ignored entirely, so a sparse source is neither rewarded nor
    punished for its silence — it simply has less to say.

    The weights rank the evidence: canonical SMILES (4) above CAS overlap and
    InChIKey (3 each), above InChI (2) and mass agreement (2), above formula
    (1) and name similarity (1). Mass and name score partially — a mass within
    0.2 scores full, within 1.0 scores half; a name similarity of 0.9 scores
    full, 0.7 scores half. Two SMILES whose candidates carry the same
    InChIKey count as agreeing: they are tautomers (ChEBI writes allopurinol
    ``Oc1ncnc2[nH]ncc12``, PubChem ``O=c1[nH]cnc2[nH]ncc12``), which the
    standard InChI deliberately does not tell apart.

    Args:
        left: One candidate record.
        right: The other candidate record.

    Returns:
        Weighted agreement in [0, 1]. Returns 0.0 when either side is None or
        when the two share no comparable field at all — note that "no shared
        evidence" and "shared evidence that disagrees" both come back as 0.0.

    Examples:
        >>> a = make_candidate("ChEBI", smiles="CC(=O)Oc1ccccc1C(=O)O")
        >>> b = make_candidate("CompTox", smiles="CC(=O)Oc1ccccc1C(=O)O")
        >>> candidate_similarity(a, b)
        1.0
    """
    if left is None or right is None:
        return 0.0

    score = 0.0
    weight = 0.0

    left_cas = set(left.get("CAS_candidates") or [])
    right_cas = set(right.get("CAS_candidates") or [])
    if left_cas and right_cas:
        weight += 3.0
        if left_cas.intersection(right_cas):
            score += 3.0

    left_ik = left.get("InChIKey")
    right_ik = right.get("InChIKey")
    same_key = not is_missing(left_ik) and str(left_ik) == str(right_ik)

    left_smiles = left.get("canonical_smiles")
    right_smiles = right.get("canonical_smiles")
    if not is_missing(left_smiles) and not is_missing(right_smiles):
        weight += 4.0
        if left_smiles == right_smiles or same_key:
            score += 4.0

    if not is_missing(left_ik) and not is_missing(right_ik):
        weight += 3.0
        if str(left_ik) == str(right_ik):
            score += 3.0

    left_inchi = left.get("InChI")
    right_inchi = right.get("InChI")
    if not is_missing(left_inchi) and not is_missing(right_inchi):
        weight += 2.0
        if str(left_inchi) == str(right_inchi):
            score += 2.0

    left_formula = left.get("molecular_formula")
    right_formula = right.get("molecular_formula")
    if not is_missing(left_formula) and not is_missing(right_formula):
        weight += 1.0
        if str(left_formula) == str(right_formula):
            score += 1.0

    left_mass = to_float(left.get("molecular_mass"))
    right_mass = to_float(right.get("molecular_mass"))
    if left_mass is not None and right_mass is not None:
        weight += 2.0
        diff = abs(left_mass - right_mass)
        if diff <= 0.2:
            score += 2.0
        elif diff <= 1.0:
            score += 1.0

    name_sim = text_similarity(left.get("name"), right.get("name"))
    if name_sim > 0.0:
        weight += 1.0
        if name_sim >= 0.9:
            score += 1.0
        elif name_sim >= 0.7:
            score += 0.5

    if weight == 0.0:
        return 0.0
    return score / weight

candidate_compatible_with_consensus(candidate, consensus, threshold=0.35)

Decide whether a candidate may contribute to a result the consensus anchors.

A source that disagrees with the consensus is describing a different compound, and letting it fill empty fields would assemble one record out of two substances. This is the gate that keeps that from happening.

Parameters:

Name Type Description Default
candidate Optional[Dict[str, Any]]

The candidate under consideration, or None.

required
consensus Optional[Dict[str, Any]]

The consensus candidate to measure against, or None when no consensus was reached.

required
threshold float

Minimum candidate_similarity required. The default of 0.35 is permissive by design: it rejects a different compound without rejecting a sparse source that agrees on what little it states.

0.35

Returns:

Type Description
bool

True when the candidate agrees with the consensus closely enough, when it is the consensus source, or when there is no consensus to contradict. False when the candidate is None.

Examples:

>>> aspirin = make_candidate("ChEBI", smiles="CC(=O)Oc1ccccc1C(=O)O")
>>> also_aspirin = make_candidate("CompTox", smiles="CC(=O)OC1=C(C=CC=C1)C(O)=O")
>>> ethanol = make_candidate("ZeroPM", smiles="CCO")
>>> candidate_compatible_with_consensus(also_aspirin, aspirin)
True
>>> candidate_compatible_with_consensus(ethanol, aspirin)
False
Source code in src/provesid/tools.py
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
def candidate_compatible_with_consensus(
    candidate: Optional[Dict[str, Any]],
    consensus: Optional[Dict[str, Any]],
    threshold: float = 0.35,
) -> bool:
    """Decide whether a candidate may contribute to a result the consensus anchors.

    A source that disagrees with the consensus is describing a different
    compound, and letting it fill empty fields would assemble one record out
    of two substances. This is the gate that keeps that from happening.

    Args:
        candidate: The candidate under consideration, or None.
        consensus: The consensus candidate to measure against, or None when
            no consensus was reached.
        threshold: Minimum
            [`candidate_similarity`][provesid.tools.candidate_similarity]
            required. The default of 0.35 is permissive by design: it rejects a
            different compound without rejecting a sparse source that agrees on
            what little it states.

    Returns:
        True when the candidate agrees with the consensus closely enough, when
        it *is* the consensus source, or when there is no consensus to
        contradict. False when the candidate is None.

    Examples:
        >>> aspirin = make_candidate("ChEBI", smiles="CC(=O)Oc1ccccc1C(=O)O")
        >>> also_aspirin = make_candidate("CompTox", smiles="CC(=O)OC1=C(C=CC=C1)C(O)=O")
        >>> ethanol = make_candidate("ZeroPM", smiles="CCO")
        >>> candidate_compatible_with_consensus(also_aspirin, aspirin)
        True
        >>> candidate_compatible_with_consensus(ethanol, aspirin)
        False
    """
    if candidate is None:
        return False
    if consensus is None:
        return True
    if candidate.get("source") == consensus.get("source"):
        return True
    return candidate_similarity(candidate, consensus) >= threshold

pick_casrn(candidates)

Choose one CAS number for a hit from the candidates that make it up.

Candidates are asked in the order given, and the first number of the first one that has any is the answer. A candidate from one of the UNRANKED_CAS_SOURCES that lists more than one number is asked last, because its first number is only the lowest: ChEBI lists (R)-camphor as 76-22-2 and 464-49-3, and CompTox, whose CASRN column is the current number, gives 464-49-3, the number for that stereoisomer. One number needs no ranking, so a source that gives only one keeps its place: for inorganics, PubChem often puts another form first (iron(II) oxide, ChEBI's 1345-25-1, is 17125-56-3 there).

Parameters:

Name Type Description Default
candidates List[Optional[Dict[str, Any]]]

The candidates applied to the hit, most trusted first. None entries are skipped.

required

Returns:

Type Description
Optional[str]

The chosen CAS number, or None when no candidate has one.

Examples:

>>> chebi = make_candidate("ChEBI", cas_candidates=["76-22-2", "464-49-3"])
>>> comptox = make_candidate("CompTox", cas_candidates=["464-49-3"])
>>> pick_casrn([chebi, comptox])
'464-49-3'
>>> pick_casrn([chebi, None])
'76-22-2'
>>> pick_casrn([make_candidate("ChEBI", cas_candidates=["1345-25-1"]),
...             make_candidate("PubChemID", cas_candidates=["17125-56-3", "1345-25-1"])])
'1345-25-1'
>>> pick_casrn([]) is None
True
Source code in src/provesid/tools.py
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
def pick_casrn(candidates: List[Optional[Dict[str, Any]]]) -> Optional[str]:
    """Choose one CAS number for a hit from the candidates that make it up.

    Candidates are asked in the order given, and the first number of the
    first one that has any is the answer. A candidate from one of the
    [`UNRANKED_CAS_SOURCES`][provesid.tools.UNRANKED_CAS_SOURCES] that lists
    more than one number is asked last, because its first number is only the
    lowest: ChEBI lists (R)-camphor as ``76-22-2`` and ``464-49-3``, and
    CompTox, whose ``CASRN`` column is the current number, gives
    ``464-49-3``, the number for that stereoisomer. One number needs no
    ranking, so a source that gives only one keeps its place: for
    inorganics, PubChem often puts another form first (iron(II) oxide,
    ChEBI's ``1345-25-1``, is ``17125-56-3`` there).

    Args:
        candidates: The candidates applied to the hit, most trusted first.
            None entries are skipped.

    Returns:
        The chosen CAS number, or None when no candidate has one.

    Examples:
        >>> chebi = make_candidate("ChEBI", cas_candidates=["76-22-2", "464-49-3"])
        >>> comptox = make_candidate("CompTox", cas_candidates=["464-49-3"])
        >>> pick_casrn([chebi, comptox])
        '464-49-3'
        >>> pick_casrn([chebi, None])
        '76-22-2'
        >>> pick_casrn([make_candidate("ChEBI", cas_candidates=["1345-25-1"]),
        ...             make_candidate("PubChemID", cas_candidates=["17125-56-3", "1345-25-1"])])
        '1345-25-1'
        >>> pick_casrn([]) is None
        True
    """
    def offers_an_unranked_choice(cand: Dict[str, Any]) -> bool:
        return cand.get("source") in UNRANKED_CAS_SOURCES and len(cand.get("CAS_candidates") or []) > 1

    present = [cand for cand in candidates if cand is not None]
    # sorted() is stable, so the order given is kept within each group.
    for cand in sorted(present, key=offers_an_unranked_choice):
        cas = first_cas(cand.get("CAS_candidates") or [])
        if cas is not None:
            return cas
    return None

apply_candidate_to_result(result, candidate)

Fill a result's empty fields from a candidate, in place.

Never overwrites: a field already carrying a value is left alone, so applying candidates in priority order means the most trusted source that had something to say wins each field independently. A result can therefore take its structure from one source and its name from another.

CASRN is not filled here. Which source's CAS is best depends on all the candidates together, so the caller chooses it with pick_casrn.

A SMILES that RDKit cannot read counts as missing: it neither fills the field nor keeps a later, compatible source's structure out of it.

Parameters:

Name Type Description Default
result Dict[str, Any]

The result dict to fill, modified in place.

required
candidate Optional[Dict[str, Any]]

The candidate to read from. None is a no-op.

required

Returns:

Type Description
None

None. The mutation is the point.

Examples:

>>> result = {"name": "aspirin", "SMILES": None}
>>> apply_candidate_to_result(result, make_candidate(
...     "CompTox", name="Aspirin", smiles="CC(=O)OC1=C(C=CC=C1)C(O)=O"))
>>> result["name"], result["SMILES"], result["source"]
('aspirin', 'CC(=O)OC1=C(C=CC=C1)C(O)=O', 'CompTox')
Source code in src/provesid/tools.py
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
def apply_candidate_to_result(result: Dict[str, Any], candidate: Optional[Dict[str, Any]]) -> None:
    """Fill a result's empty fields from a candidate, in place.

    Never overwrites: a field already carrying a value is left alone, so
    applying candidates in priority order means the most trusted source that
    had something to say wins each field independently. A result can therefore
    take its structure from one source and its name from another.

    ``CASRN`` is not filled here. Which source's CAS is best depends on all
    the candidates together, so the caller chooses it with
    [`pick_casrn`][provesid.tools.pick_casrn].

    A SMILES that RDKit cannot read counts as missing: it neither fills the
    field nor keeps a later, compatible source's structure out of it.

    Args:
        result: The result dict to fill, modified in place.
        candidate: The candidate to read from. None is a no-op.

    Returns:
        None. The mutation is the point.

    Examples:
        >>> result = {"name": "aspirin", "SMILES": None}
        >>> apply_candidate_to_result(result, make_candidate(
        ...     "CompTox", name="Aspirin", smiles="CC(=O)OC1=C(C=CC=C1)C(O)=O"))
        >>> result["name"], result["SMILES"], result["source"]
        ('aspirin', 'CC(=O)OC1=C(C=CC=C1)C(O)=O', 'CompTox')
    """
    if candidate is None:
        return

    if not is_parsable_smiles(result.get("SMILES")):
        result["SMILES"] = None
    candidate_smiles = candidate.get("SMILES")
    if not is_parsable_smiles(candidate_smiles):
        candidate_smiles = None

    result["name"] = pick_first(result.get("name"), candidate.get("name"))
    result["IUPAC_name"] = pick_first(result.get("IUPAC_name"), candidate.get("IUPAC_name"))
    result["molecular_formula"] = pick_first(result.get("molecular_formula"), candidate.get("molecular_formula"))
    result["SMILES"] = pick_first(result.get("SMILES"), candidate_smiles)
    result["InChI"] = pick_first(result.get("InChI"), candidate.get("InChI"))
    result["InChIKey"] = pick_first(result.get("InChIKey"), candidate.get("InChIKey"))
    result["DTXSID"] = pick_first(result.get("DTXSID"), candidate.get("DTXSID"))
    result["molecular_mass"] = pick_first(result.get("molecular_mass"), candidate.get("molecular_mass"))
    result["Synonyms"] = pick_first(result.get("Synonyms"), normalize_synonyms(candidate.get("Synonyms")))

    if is_missing(result.get("source")) and candidate_smiles is not None:
        result["source"] = candidate.get("source")

compute_consensus(candidates)

Hold the vote: which source's answer do the others corroborate?

Every candidate is scored against every other with candidate_similarity and given the mean of those scores as its support. The winner is the best-supported source — but among sources within 0.05 of the top score, the more reputable one wins instead. That tie-break matters because support is an average over comparable fields: a source stating almost nothing can agree perfectly on that little and score higher than a richer source that agrees about far more.

Reputation order is ChEBI, CompTox, PubChemID, ZeroPM, ChEMBL; a source not on that list sorts last.

A candidate with derived_from set does not vote. It was found through another source's answer --- ChEMBL by the structure ChEBI found for a CAS number --- so it agrees with that answer by construction, and counting it would hand any wrong first hit a free confirmation.

Parameters:

Name Type Description Default
candidates Dict[str, Optional[Dict[str, Any]]]

Source key to candidate record. Entries whose value is None, or that are derived_from another source, are ignored, so a source that found nothing does not vote.

required

Returns:

Type Description
Tuple[Optional[str], Dict[str, float], float]

A tuple of:

  • the winning source key, or None when no independent source found anything;
  • per-source agreement with the winner, the winner itself scoring 1.0;
  • the mean of those scores, which is what becomes confidence.

Examples:

>>> a = make_candidate("ChEBI", smiles="CC(=O)Oc1ccccc1C(=O)O")
>>> b = make_candidate("CompTox", smiles="CC(=O)Oc1ccccc1C(=O)O")
>>> source, scores, overall = compute_consensus({"chebi": a, "comptox": b})
>>> source, overall
('chebi', 1.0)
>>> echo = make_candidate("ChEMBL", smiles="CC(=O)Oc1ccccc1C(=O)O", derived_from="ChEBI")
>>> compute_consensus({"chebi": a, "chembl": echo})[1]
{'chebi': 1.0}
Source code in src/provesid/tools.py
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
def compute_consensus(candidates: Dict[str, Optional[Dict[str, Any]]]) -> Tuple[Optional[str], Dict[str, float], float]:
    """Hold the vote: which source's answer do the others corroborate?

    Every candidate is scored against every other with
    [`candidate_similarity`][provesid.tools.candidate_similarity] and given the
    mean of those scores as its support. The winner is the best-supported
    source — but among sources within 0.05 of the top score, the more reputable
    one wins instead. That tie-break matters because support is an average over
    *comparable* fields: a source stating almost nothing can agree perfectly on
    that little and score higher than a richer source that agrees about far
    more.

    Reputation order is ChEBI, CompTox, PubChemID, ZeroPM, ChEMBL; a source
    not on that list sorts last.

    A candidate with ``derived_from`` set does not vote. It was found through
    another source's answer --- ChEMBL by the structure ChEBI found for a CAS
    number --- so it agrees with that answer by construction, and counting it
    would hand any wrong first hit a free confirmation.

    Args:
        candidates: Source key to candidate record. Entries whose value is
            None, or that are ``derived_from`` another source, are ignored,
            so a source that found nothing does not vote.

    Returns:
        A tuple of:

        - the winning source key, or None when no independent source found
          anything;
        - per-source agreement with the winner, the winner itself scoring 1.0;
        - the mean of those scores, which is what becomes ``confidence``.

    Examples:
        >>> a = make_candidate("ChEBI", smiles="CC(=O)Oc1ccccc1C(=O)O")
        >>> b = make_candidate("CompTox", smiles="CC(=O)Oc1ccccc1C(=O)O")
        >>> source, scores, overall = compute_consensus({"chebi": a, "comptox": b})
        >>> source, overall
        ('chebi', 1.0)
        >>> echo = make_candidate("ChEMBL", smiles="CC(=O)Oc1ccccc1C(=O)O", derived_from="ChEBI")
        >>> compute_consensus({"chebi": a, "chembl": echo})[1]
        {'chebi': 1.0}
    """
    valid = {
        k: v for k, v in candidates.items()
        if v is not None and not v.get("derived_from")
    }
    if not valid:
        return None, {}, 0.0

    support: Dict[str, float] = {}
    sources = list(valid.keys())
    for source in sources:
        others = [other for other in sources if other != source]
        if not others:
            support[source] = 1.0
            continue
        sims = [candidate_similarity(valid[source], valid[other]) for other in others]
        support[source] = sum(sims) / len(sims)

    priority = ["chebi", "comptox", "pubchem", "zeropm", "chembl"]
    # Among sources within _PRIORITY_TOLERANCE of the top support score, prefer
    # by reputation (priority list).  This prevents a source missing structural
    # fields (e.g. no SMILES) from artificially inflating its support score
    # and winning over a more reputable source with essentially the same agreement.
    _PRIORITY_TOLERANCE = 0.05
    max_support = max(support.values())
    top_sources = [
        src for src in support if max_support - support[src] <= _PRIORITY_TOLERANCE
    ]
    top_sources.sort(
        key=lambda src: priority.index(src) if src in priority else len(priority)
    )
    consensus_source = top_sources[0]

    consensus_candidate = valid[consensus_source]
    source_match_scores = {
        src: (1.0 if src == consensus_source else candidate_similarity(consensus_candidate, valid[src]))
        for src in sources
    }
    overall = sum(source_match_scores.values()) / len(source_match_scores)
    return consensus_source, source_match_scores, overall

candidate_from_chebi_row(row)

Adapt one ChEBI SDF row into a candidate record.

Parameters:

Name Type Description Default
row Dict[str, Any]

A row as ChebiSDF returns it.

required

Returns:

Type Description
Dict[str, Any]

The candidate record. ChEBI states no mass, so the mass comes from RDKit via make_candidate. The row's molfile is passed on, so that a SMILES ChEBI exported without an aromatic N-H (6% of its records) is rebuilt from it. The CAS numbers come from the CAS Registry Numbers field alone: the rest of the row, the InChI in particular, can contain CAS-shaped strings. ChEBI lists them sorted as text, which is no ranking, so they are reordered by sort_cas_by_number.

Examples:

>>> from provesid import ChebiSDF
>>> row = ChebiSDF().get_compound_by_id("CHEBI:15365")
>>> cand = candidate_from_chebi_row(row)
>>> cand["name"], cand["CAS_candidates"], round(cand["molecular_mass"], 2)
('acetylsalicylic acid', ['50-78-2'], 180.16)
Source code in src/provesid/tools.py
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
def candidate_from_chebi_row(row: Dict[str, Any]) -> Dict[str, Any]:
    """Adapt one ChEBI SDF row into a candidate record.

    Args:
        row: A row as [`ChebiSDF`][provesid.chebi_sdf.ChebiSDF] returns it.

    Returns:
        The candidate record. ChEBI states no mass, so the mass comes from
        RDKit via [`make_candidate`][provesid.tools.make_candidate]. The
        row's molfile is passed on, so that a SMILES ChEBI exported without
        an aromatic N-H (6% of its records) is rebuilt from it. The CAS
        numbers come from the ``CAS Registry Numbers`` field alone: the rest
        of the row, the InChI in particular, can contain CAS-shaped strings.
        ChEBI lists them sorted as text, which is no ranking, so they are
        reordered by [`sort_cas_by_number`][provesid.tools.sort_cas_by_number].

    Examples:
        >>> from provesid import ChebiSDF
        >>> row = ChebiSDF().get_compound_by_id("CHEBI:15365")   # doctest: +SKIP
        >>> cand = candidate_from_chebi_row(row)                 # doctest: +SKIP
        >>> cand["name"], cand["CAS_candidates"], round(cand["molecular_mass"], 2)  # doctest: +SKIP
        ('acetylsalicylic acid', ['50-78-2'], 180.16)
    """
    return make_candidate(
        "ChEBI",
        name=row.get("ChEBI NAME"),
        iupac_name=row.get("ChEBI NAME"),
        molecular_formula=row.get("FORMULA"),
        smiles=row.get("SMILES"),
        molfile=row.get("molfile"),
        inchi=row.get("INCHI"),
        inchikey=row.get("INCHIKEY"),
        synonyms=normalize_synonyms(row.get("SYNONYM")),
        cas_candidates=sort_cas_by_number(extract_cas_values(row.get("CAS Registry Numbers"))),
    )

candidate_from_comptox_row(row)

Adapt one CompTox row into a candidate record.

Parameters:

Name Type Description Default
row Dict[str, Any]

A row as CompToxID returns it.

required

Returns:

Type Description
Dict[str, Any]

The candidate record, carrying the DTXSID and preferring the average mass over the monoisotopic one.

Examples:

>>> from provesid import CompToxID
>>> cand = candidate_from_comptox_row(CompToxID().get_by_casrn("50-78-2"))
>>> cand["DTXSID"], cand["molecular_mass"]
('DTXSID5020108', 180.159)
Source code in src/provesid/tools.py
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
def candidate_from_comptox_row(row: Dict[str, Any]) -> Dict[str, Any]:
    """Adapt one CompTox row into a candidate record.

    Args:
        row: A row as [`CompToxID`][provesid.comptox.CompToxID] returns it.

    Returns:
        The candidate record, carrying the DTXSID and preferring the average
        mass over the monoisotopic one.

    Examples:
        >>> from provesid import CompToxID
        >>> cand = candidate_from_comptox_row(CompToxID().get_by_casrn("50-78-2"))  # doctest: +SKIP
        >>> cand["DTXSID"], cand["molecular_mass"]               # doctest: +SKIP
        ('DTXSID5020108', 180.159)
    """
    return make_candidate(
        "CompTox",
        name=row.get("PREFERRED_NAME"),
        iupac_name=row.get("IUPAC_NAME"),
        molecular_formula=row.get("MOLECULAR_FORMULA"),
        smiles=row.get("SMILES"),
        inchi=row.get("INCHI"),
        inchikey=row.get("INCHIKEY"),
        dtxsid=row.get("DTXSID"),
        molecular_mass=pick_first(row.get("AVERAGE_MASS"), row.get("MONOISOTOPIC_MASS")),
        synonyms=normalize_synonyms(row.get("identifiers")),
        cas_candidates=extract_cas_values([row.get("CASRN"), row.get("identifiers")]),
    )

candidate_from_pubchem_row(row)

Adapt one PubChem row into a candidate record.

Parameters:

Name Type Description Default
row Dict[str, Any]

A row as PubChemID returns it.

required

Returns:

Type Description
Dict[str, Any]

The candidate record.

Examples:

>>> from provesid import PubChemID
>>> cand = candidate_from_pubchem_row(PubChemID().get_by_cid(2244))
>>> cand["name"], cand["molecular_mass"], cand["CAS_candidates"]
('Aspirin', 180.16, ['50-78-2'])
Source code in src/provesid/tools.py
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
def candidate_from_pubchem_row(row: Dict[str, Any]) -> Dict[str, Any]:
    """Adapt one PubChem row into a candidate record.

    Args:
        row: A row as [`PubChemID`][provesid.pubchem_id.PubChemID] returns it.

    Returns:
        The candidate record.

    Examples:
        >>> from provesid import PubChemID
        >>> cand = candidate_from_pubchem_row(PubChemID().get_by_cid(2244))  # doctest: +SKIP
        >>> cand["name"], cand["molecular_mass"], cand["CAS_candidates"]     # doctest: +SKIP
        ('Aspirin', 180.16, ['50-78-2'])
    """
    return make_candidate(
        "PubChemID",
        name=row.get("cmpdname"),
        iupac_name=row.get("iupacname"),
        molecular_formula=row.get("mf"),
        smiles=row.get("smiles"),
        inchi=row.get("inchi"),
        inchikey=row.get("inchikey"),
        molecular_mass=row.get("mw"),
        synonyms=normalize_synonyms(row.get("synonyms")),
        cas_candidates=extract_cas_values(row.get("cas_numbers")),
    )

candidate_from_zeropm_name_table(name, table)

Adapt a ZeroPM name-lookup table into a single candidate record.

ZeroPM answers a name with a ranked table rather than a row. The best-ranked entry supplies the structure; every name in the table becomes a synonym and every CAS a candidate CAS, which is what makes ZeroPM a useful corroborator of identifiers even where its structures are thin. ZeroPM publishes InChI but not SMILES, so the SMILES is derived.

Parameters:

Name Type Description Default
name str

The queried name, kept as the candidate's name.

required
table DataFrame

The lookup table, sorted by rank when that column exists.

required

Returns:

Type Description
Optional[Dict[str, Any]]

The candidate record, or None when the table is empty or None.

Examples:

>>> table = pd.DataFrame({"rank": [2, 1],
...                       "inchi": ["InChI=1S/CH4/h1H4", "InChI=1S/CH2O/c1-2/h1H2"],
...                       "inchikey": ["VNWKTOKETHGBQD-UHFFFAOYSA-N", "WSFSSNUMVMOOMR-UHFFFAOYSA-N"],
...                       "cas": ["74-82-8", "50-00-0"]})
>>> cand = candidate_from_zeropm_name_table("Formaldehyde", table)
>>> cand["SMILES"], cand["InChIKey"], cand["CAS_candidates"]
('C=O', 'WSFSSNUMVMOOMR-UHFFFAOYSA-N', ['50-00-0', '74-82-8'])
Source code in src/provesid/tools.py
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
def candidate_from_zeropm_name_table(name: str, table: pd.DataFrame) -> Optional[Dict[str, Any]]:
    """Adapt a ZeroPM name-lookup table into a single candidate record.

    ZeroPM answers a name with a ranked table rather than a row. The
    best-ranked entry supplies the structure; every name in the table becomes
    a synonym and every CAS a candidate CAS, which is what makes ZeroPM a
    useful corroborator of *identifiers* even where its structures are thin.
    ZeroPM publishes InChI but not SMILES, so the SMILES is derived.

    Args:
        name: The queried name, kept as the candidate's name.
        table: The lookup table, sorted by ``rank`` when that column exists.

    Returns:
        The candidate record, or None when the table is empty or None.

    Examples:
        >>> table = pd.DataFrame({"rank": [2, 1],
        ...                       "inchi": ["InChI=1S/CH4/h1H4", "InChI=1S/CH2O/c1-2/h1H2"],
        ...                       "inchikey": ["VNWKTOKETHGBQD-UHFFFAOYSA-N", "WSFSSNUMVMOOMR-UHFFFAOYSA-N"],
        ...                       "cas": ["74-82-8", "50-00-0"]})
        >>> cand = candidate_from_zeropm_name_table("Formaldehyde", table)
        >>> cand["SMILES"], cand["InChIKey"], cand["CAS_candidates"]
        ('C=O', 'WSFSSNUMVMOOMR-UHFFFAOYSA-N', ['50-00-0', '74-82-8'])
    """
    if table is None or table.empty:
        return None

    working = table.copy()
    if "rank" in working.columns:
        working = working.sort_values(by="rank", ascending=True, kind="stable")

    first = working.iloc[0]
    inchi = first.get("inchi")
    smiles = inchi_to_smiles(inchi)

    cas_values = []
    if "cas" in working.columns:
        cas_values = [str(v) for v in working["cas"].dropna().astype(str).tolist()]

    synonyms = None
    if "name" in working.columns and not working["name"].dropna().empty:
        synonyms = normalize_synonyms(working["name"].dropna().astype(str).unique().tolist())

    return make_candidate(
        "ZeroPM",
        name=name,
        iupac_name=name,
        smiles=smiles,
        inchi=inchi,
        inchikey=first.get("inchikey"),
        synonyms=synonyms,
        cas_candidates=extract_cas_values(cas_values),
    )

candidate_from_zeropm_smiles(smiles_query, zeropm)

Adapt a ZeroPM structure lookup into a single candidate record.

ZeroPM cannot be queried by structure directly. The SMILES is resolved to CAS numbers first, and the first five, in ZeroPM's order, are looked up and pooled — a cap, because a structure that matches many registry entries would otherwise cost one query each for no added agreement.

Parameters:

Name Type Description Default
smiles_query str

The structure to look up, as SMILES.

required
zeropm ZeroPM

An initialised ZeroPM client.

required

Returns:

Type Description
Optional[Dict[str, Any]]

The candidate record. When the CAS numbers resolve to no rows, a minimal candidate carrying just the query structure and those CAS numbers is returned instead — they are still evidence. None when the structure resolves to no CAS at all.

The structure is taken from a row whose InChIKey is the query's, when there is one: the pooled CAS numbers include relatives, and for "CCO" the first is 13C-labelled ethanol.

Examples:

>>> from provesid import ZeroPM
>>> cand = candidate_from_zeropm_smiles("CCO", ZeroPM())
>>> cand["InChIKey"], "64-17-5" in cand["CAS_candidates"]
('LFQSCWFLJHTTHZ-UHFFFAOYSA-N', True)
Source code in src/provesid/tools.py
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
def candidate_from_zeropm_smiles(smiles_query: str, zeropm: ZeroPM) -> Optional[Dict[str, Any]]:
    """Adapt a ZeroPM structure lookup into a single candidate record.

    ZeroPM cannot be queried by structure directly. The SMILES is resolved to
    CAS numbers first, and the first five, in ZeroPM's order, are looked up
    and pooled — a cap, because a structure that matches many registry
    entries would otherwise cost one query each for no added agreement.

    Args:
        smiles_query: The structure to look up, as SMILES.
        zeropm: An initialised [`ZeroPM`][provesid.zeropm.ZeroPM] client.

    Returns:
        The candidate record. When the CAS numbers resolve to no rows, a
        minimal candidate carrying just the query structure and those CAS
        numbers is returned instead — they are still evidence. None when the
        structure resolves to no CAS at all.

        The structure is taken from a row whose InChIKey is the query's, when
        there is one: the pooled CAS numbers include relatives, and for
        ``"CCO"`` the first is 13C-labelled ethanol.

    Examples:
        >>> from provesid import ZeroPM
        >>> cand = candidate_from_zeropm_smiles("CCO", ZeroPM())  # doctest: +SKIP
        >>> cand["InChIKey"], "64-17-5" in cand["CAS_candidates"]  # doctest: +SKIP
        ('LFQSCWFLJHTTHZ-UHFFFAOYSA-N', True)
    """
    cas_result = zeropm.get_cas_from_smiles(smiles_query)
    cas_values = extract_cas_values(cas_result)
    if not cas_values:
        return None

    tables = []
    for cas in cas_values[:5]:
        table = zeropm.get_id_table_from_cas(cas)
        if table is not None and not table.empty:
            tables.append(table)

    if not tables:
        return make_candidate("ZeroPM", smiles=smiles_query, cas_candidates=cas_values)

    combined = pd.concat(tables, ignore_index=True)
    # The CAS numbers are not ranked and include relatives of the query: for
    # "CCO" they include 14742-23-5, 13C-labelled ethanol. Take the structure
    # from a row that is the query, when one is.
    query_key = inchikey_from_smiles(smiles_query)
    same_structure = combined[combined["inchikey"] == query_key] if query_key else combined.iloc[0:0]
    first = same_structure.iloc[0] if not same_structure.empty else combined.iloc[0]
    inchi = first.get("inchi")
    smiles = pick_first(smiles_query, inchi_to_smiles(inchi))

    synonyms = None
    if "synonyms" in combined.columns and not combined["synonyms"].dropna().empty:
        synonyms = normalize_synonyms(combined["synonyms"].dropna().astype(str).unique().tolist())

    return make_candidate(
        "ZeroPM",
        smiles=smiles,
        inchi=inchi,
        inchikey=first.get("inchikey"),
        synonyms=synonyms,
        cas_candidates=extract_cas_values(cas_values),
    )

candidate_from_chembl_row(row, chembl=None)

Adapt one ChEMBL row into a candidate record.

Parameters:

Name Type Description Default
row Dict[str, Any]

A row as CheMBL returns it.

required
chembl Optional[CheMBL]

An optional client, used to fetch the molecular mass, which lives in a properties table rather than in the row. Without it the mass falls back to RDKit. A failed fetch is swallowed: the candidate is worth having without its mass.

None

Returns:

Type Description
Dict[str, Any]

The candidate record. ChEMBL states no formula. Its synonyms are sorted alphabetically, so the CAS numbers among them are reordered by sort_cas_by_number.

Examples:

>>> row = {"pref_name": "ASPIRIN", "canonical_smiles": "CC(=O)Oc1ccccc1C(=O)O",
...        "standard_inchi_key": "BSYNRYMUTXBXSQ-UHFFFAOYSA-N",
...        "synonyms": ["Aspirin", "50-78-2"]}
>>> cand = candidate_from_chembl_row(row)
>>> cand["name"], cand["CAS_candidates"], round(cand["molecular_mass"], 2)
('ASPIRIN', ['50-78-2'], 180.16)
Source code in src/provesid/tools.py
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
def candidate_from_chembl_row(row: Dict[str, Any], chembl: Optional[CheMBL] = None) -> Dict[str, Any]:
    """Adapt one ChEMBL row into a candidate record.

    Args:
        row: A row as [`CheMBL`][provesid.chembl.CheMBL] returns it.
        chembl: An optional client, used to fetch the molecular mass, which
            lives in a properties table rather than in the row. Without it the
            mass falls back to RDKit. A failed fetch is swallowed: the
            candidate is worth having without its mass.

    Returns:
        The candidate record. ChEMBL states no formula. Its synonyms are
        sorted alphabetically, so the CAS numbers among them are reordered by
        [`sort_cas_by_number`][provesid.tools.sort_cas_by_number].

    Examples:
        >>> row = {"pref_name": "ASPIRIN", "canonical_smiles": "CC(=O)Oc1ccccc1C(=O)O",
        ...        "standard_inchi_key": "BSYNRYMUTXBXSQ-UHFFFAOYSA-N",
        ...        "synonyms": ["Aspirin", "50-78-2"]}
        >>> cand = candidate_from_chembl_row(row)
        >>> cand["name"], cand["CAS_candidates"], round(cand["molecular_mass"], 2)
        ('ASPIRIN', ['50-78-2'], 180.16)
    """
    props = None
    molregno = row.get("molregno")
    if chembl is not None and not is_missing(molregno):
        try:
            props = chembl.get_properties(int(molregno))
        except Exception:
            props = None

    return make_candidate(
        "ChEMBL",
        name=row.get("pref_name"),
        molecular_formula=None,
        smiles=row.get("canonical_smiles"),
        inchi=row.get("standard_inchi"),
        inchikey=row.get("standard_inchi_key"),
        molecular_mass=(props or {}).get("mw_freebase"),
        synonyms=normalize_synonyms(row.get("synonyms")),
        cas_candidates=sort_cas_by_number(extract_cas_values(row.get("synonyms"))),
    )

candidate_from_pubchem_online(row, synonyms=None)

Adapt one PUG-REST property row into a candidate record.

The online counterpart of candidate_from_pubchem_row. It is kept apart from it, under its own source name, so that a result the network supplied can never be mistaken for one the local database did.

Parameters:

Name Type Description Default
row Dict[str, Any]

One row of get_properties_for_cids, asked for Title, IUPACName, MolecularFormula, SMILES, InChI, InChIKey and MolecularWeight.

required
synonyms Optional[List[str]]

The compound's synonyms from get_compound_synonyms, which is where PubChem keeps its CAS numbers.

None

Returns:

Type Description
Dict[str, Any]

The candidate record, with source "PubChem (online)".

Examples:

>>> cand = candidate_from_pubchem_online(
...     {"CID": 2244, "Title": "Aspirin", "SMILES": "CC(=O)OC1=CC=CC=C1C(=O)O",
...      "MolecularWeight": "180.16"},
...     ["aspirin", "50-78-2"])
>>> cand["source"], cand["CAS_candidates"], cand["molecular_mass"]
('PubChem (online)', ['50-78-2'], 180.16)
Source code in src/provesid/tools.py
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
def candidate_from_pubchem_online(
    row: Dict[str, Any], synonyms: Optional[List[str]] = None
) -> Dict[str, Any]:
    """Adapt one PUG-REST property row into a candidate record.

    The online counterpart of
    [`candidate_from_pubchem_row`][provesid.tools.candidate_from_pubchem_row].
    It is kept apart from it, under its own source name, so that a result the
    network supplied can never be mistaken for one the local database did.

    Args:
        row: One row of
            [`get_properties_for_cids`][provesid.pubchem.PubChemAPI.get_properties_for_cids],
            asked for ``Title``, ``IUPACName``, ``MolecularFormula``,
            ``SMILES``, ``InChI``, ``InChIKey`` and ``MolecularWeight``.
        synonyms: The compound's synonyms from
            [`get_compound_synonyms`][provesid.pubchem.PubChemAPI.get_compound_synonyms],
            which is where PubChem keeps its CAS numbers.

    Returns:
        The candidate record, with source ``"PubChem (online)"``.

    Examples:
        >>> cand = candidate_from_pubchem_online(
        ...     {"CID": 2244, "Title": "Aspirin", "SMILES": "CC(=O)OC1=CC=CC=C1C(=O)O",
        ...      "MolecularWeight": "180.16"},
        ...     ["aspirin", "50-78-2"])
        >>> cand["source"], cand["CAS_candidates"], cand["molecular_mass"]
        ('PubChem (online)', ['50-78-2'], 180.16)
    """
    return make_candidate(
        "PubChem (online)",
        name=row.get("Title"),
        iupac_name=row.get("IUPACName"),
        molecular_formula=row.get("MolecularFormula"),
        smiles=row.get("SMILES"),
        inchi=row.get("InChI"),
        inchikey=row.get("InChIKey"),
        molecular_mass=row.get("MolecularWeight"),
        synonyms=normalize_synonyms(synonyms),
        cas_candidates=extract_cas_values(synonyms),
    )

candidate_from_cactus(smiles, names=None)

Adapt an NCI/CADD Chemical Identifier Resolver answer into a candidate.

CACTUS answers one representation per request, so the caller asks for the two that matter --- the structure and the name list --- and everything else is derived here: the InChIKey by RDKit, the CAS numbers from the names, among which CACTUS lists them. CACTUS's order is no ranking (ethanol's first CAS is 121182-78-3, not 64-17-5), so they are reordered by sort_cas_by_number.

Parameters:

Name Type Description Default
smiles str

The SMILES CACTUS resolved the identifier to.

required
names Optional[List[str]]

The names representation, one name per entry, most preferred first.

None

Returns:

Type Description
Dict[str, Any]

The candidate record, with source "CACTUS".

Examples:

>>> cand = candidate_from_cactus("CC(=O)Oc1ccccc1C(O)=O", ["Aspirin", "50-78-2"])
>>> cand["source"], cand["name"], cand["CAS_candidates"]
('CACTUS', 'Aspirin', ['50-78-2'])
Source code in src/provesid/tools.py
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
def candidate_from_cactus(smiles: str, names: Optional[List[str]] = None) -> Dict[str, Any]:
    """Adapt an NCI/CADD Chemical Identifier Resolver answer into a candidate.

    CACTUS answers one representation per request, so the caller asks for the
    two that matter --- the structure and the name list --- and everything
    else is derived here: the InChIKey by RDKit, the CAS numbers from the
    names, among which CACTUS lists them. CACTUS's order is no ranking
    (ethanol's first CAS is ``121182-78-3``, not ``64-17-5``), so they are
    reordered by [`sort_cas_by_number`][provesid.tools.sort_cas_by_number].

    Args:
        smiles: The SMILES CACTUS resolved the identifier to.
        names: The ``names`` representation, one name per entry, most
            preferred first.

    Returns:
        The candidate record, with source ``"CACTUS"``.

    Examples:
        >>> cand = candidate_from_cactus("CC(=O)Oc1ccccc1C(O)=O", ["Aspirin", "50-78-2"])
        >>> cand["source"], cand["name"], cand["CAS_candidates"]
        ('CACTUS', 'Aspirin', ['50-78-2'])
    """
    names = [name for name in (names or []) if not is_missing(name)]
    return make_candidate(
        "CACTUS",
        name=names[0] if names else None,
        smiles=smiles,
        inchikey=inchikey_from_smiles(smiles),
        synonyms=normalize_synonyms(names),
        cas_candidates=sort_cas_by_number(extract_cas_values(names)),
    )

smiles_to_canonical_and_mass(smiles)

Canonicalise a SMILES string and weigh it in a single RDKit parse.

Both are needed for every candidate, and parsing is the expensive part, so they are produced together.

Parameters:

Name Type Description Default
smiles Optional[str]

The SMILES string, or None.

required

Returns:

Type Description
Tuple[Optional[str], Optional[float]]

A (canonical_smiles, molecular_mass) tuple. Both are None when the input is missing or RDKit cannot parse it. Without RDKit installed, the SMILES is passed through unchanged and the mass is None — an uncanonicalised structure still matches an identical string from another source.

Examples:

>>> smiles, mass = smiles_to_canonical_and_mass("OCC")
>>> smiles, round(mass, 3)
('CCO', 46.069)
>>> smiles_to_canonical_and_mass("not a smiles")
(None, None)
Source code in src/provesid/tools.py
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
def smiles_to_canonical_and_mass(smiles: Optional[str]) -> Tuple[Optional[str], Optional[float]]:
    """Canonicalise a SMILES string and weigh it in a single RDKit parse.

    Both are needed for every candidate, and parsing is the expensive part, so
    they are produced together.

    Args:
        smiles: The SMILES string, or None.

    Returns:
        A ``(canonical_smiles, molecular_mass)`` tuple. Both are None when the
        input is missing or RDKit cannot parse it. Without RDKit installed,
        the SMILES is passed through unchanged and the mass is None — an
        uncanonicalised structure still matches an identical string from
        another source.

    Examples:
        >>> smiles, mass = smiles_to_canonical_and_mass("OCC")
        >>> smiles, round(mass, 3)
        ('CCO', 46.069)
        >>> smiles_to_canonical_and_mass("not a smiles")
        (None, None)
    """
    if is_missing(smiles):
        return None, None

    if not RDKIT_AVAILABLE or Chem is None:
        return str(smiles), None

    try:
        mol = Chem.MolFromSmiles(str(smiles))
        if mol is None:
            return None, None
        canonical = Chem.MolToSmiles(mol, canonical=True)
        mass = float(Descriptors.MolWt(mol)) if Descriptors is not None else None
        return canonical, mass
    except Exception as e:
        logging.warning(f"Failed to parse SMILES '{smiles}': {e}")
        return None, None

formula_element_counts(formula)

Count the atoms of each element in a molecular formula.

Sources write formulas differently --- ChEBI's C17H21NO.HCl, CompTox's C17H22ClNO, C2H3NaO2.3H2O for a hydrate --- so two formulas are compared by their element counts, not their text. Dot-separated parts are summed, each multiplied by a leading count; a trailing charge (-, +2) is dropped; deuterium and tritium count as hydrogen.

Parameters:

Name Type Description Default
formula Optional[str]

The formula, or None.

required

Returns:

Type Description
Optional[Dict[str, int]]

Element symbol to atom count, or None when the formula is missing or is not a plain formula ((C2H4O)n, or ChEBI's C4H6O4R2 for a group with two attachment points).

Examples:

>>> formula_element_counts("C17H21NO.HCl") == formula_element_counts("C17H22ClNO")
True
>>> formula_element_counts("C2H3NaO2.3H2O")
{'C': 2, 'H': 9, 'Na': 1, 'O': 5}
>>> formula_element_counts("C7H5NO5-2") == formula_element_counts("C7H5NO5")
True
>>> formula_element_counts("C5H9O3-")
{'C': 5, 'H': 9, 'O': 3}
>>> formula_element_counts("(C2H4O)n") is None, formula_element_counts("C4H6O4R2") is None
(True, True)
Source code in src/provesid/tools.py
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
def formula_element_counts(formula: Optional[str]) -> Optional[Dict[str, int]]:
    """Count the atoms of each element in a molecular formula.

    Sources write formulas differently --- ChEBI's ``C17H21NO.HCl``, CompTox's
    ``C17H22ClNO``, ``C2H3NaO2.3H2O`` for a hydrate --- so two formulas are
    compared by their element counts, not their text. Dot-separated parts are
    summed, each multiplied by a leading count; a trailing charge (``-``,
    ``+2``) is dropped; deuterium and tritium count as hydrogen.

    Args:
        formula: The formula, or None.

    Returns:
        Element symbol to atom count, or None when the formula is missing or
        is not a plain formula (``(C2H4O)n``, or ChEBI's ``C4H6O4R2`` for a
        group with two attachment points).

    Examples:
        >>> formula_element_counts("C17H21NO.HCl") == formula_element_counts("C17H22ClNO")
        True
        >>> formula_element_counts("C2H3NaO2.3H2O")
        {'C': 2, 'H': 9, 'Na': 1, 'O': 5}
        >>> formula_element_counts("C7H5NO5-2") == formula_element_counts("C7H5NO5")
        True
        >>> formula_element_counts("C5H9O3-")
        {'C': 5, 'H': 9, 'O': 3}
        >>> formula_element_counts("(C2H4O)n") is None, formula_element_counts("C4H6O4R2") is None
        (True, True)
    """
    if is_missing(formula):
        return None
    counts: Counter = Counter()
    for part in str(formula).replace(" ", "").split("."):
        part = _FORMULA_CHARGE.sub("", part)
        multiplier_text = re.match(r"\d*", part).group()
        multiplier = int(multiplier_text) if multiplier_text else 1
        body = part[len(multiplier_text):]
        if not body:
            return None
        tokens = _FORMULA_TOKEN.findall(body)
        if "".join(symbol + count for symbol, count in tokens) != body:
            return None
        for symbol, count in tokens:
            if _ELEMENTS and symbol not in _ELEMENTS:
                return None
            symbol = "H" if symbol in ("D", "T") else symbol
            counts[symbol] += (int(count) if count else 1) * multiplier
    return dict(sorted(counts.items())) if counts else None

structure_formula(smiles)

The molecular formula of a structure, as RDKit computes it.

Parameters:

Name Type Description Default
smiles Optional[str]

The SMILES, or None.

required

Returns:

Type Description
Optional[str]

The Hill-order formula, every fragment merged into one, or None when the SMILES is missing or does not parse.

Examples:

>>> structure_formula("CNCC[C@@H](Oc1ccccc1C)c1ccccc1.Cl")
'C17H22ClNO'
Source code in src/provesid/tools.py
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
def structure_formula(smiles: Optional[str]) -> Optional[str]:
    """The molecular formula of a structure, as RDKit computes it.

    Args:
        smiles: The SMILES, or None.

    Returns:
        The Hill-order formula, every fragment merged into one, or None when
        the SMILES is missing or does not parse.

    Examples:
        >>> structure_formula("CNCC[C@@H](Oc1ccccc1C)c1ccccc1.Cl")
        'C17H22ClNO'
    """
    mol = _mol_from_smiles_quietly(smiles)
    if mol is None:
        return None
    return rdMolDescriptors.CalcMolFormula(mol)

identity_consistent(smiles, inchikey=None, formula=None)

Check a structure against the InChIKey and formula stated for it.

A source states a structure, an InChIKey and a formula, and nothing makes them agree: a result can be assembled whose key belongs to another compound than its SMILES. This compares the key and formula RDKit derives from the structure with the stated ones. The keys must have the same skeleton (first block) and protonation flag (last letter): stereo is not compared, because a SMILES export routinely loses a double bond's or a centre's configuration that the key, made from the molfile, keeps (ChEBI's cefodizime), and that is not a different compound. The formula must have the same element counts (see formula_element_counts).

Parameters:

Name Type Description Default
smiles Optional[str]

The structure, or None.

required
inchikey Optional[str]

The InChIKey stated for it, or None.

None
formula Optional[str]

The formula stated for it, or None.

None

Returns:

Type Description
Optional[bool]

True when every stated value that can be compared agrees with the structure, False when one does not, and None when there is no structure or nothing stated to compare it with.

Examples:

>>> identity_consistent("CCO", "LFQSCWFLJHTTHZ-UHFFFAOYSA-N", "C2H6O")
True
>>> identity_consistent("CCO", formula="C3H8O")
False
>>> identity_consistent("C/C=C/C", "IAQRGUVFOMOMEM-UHFFFAOYSA-N")   # stereo is not compared
True
>>> identity_consistent(None, "LFQSCWFLJHTTHZ-UHFFFAOYSA-N") is None
True
Source code in src/provesid/tools.py
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
def identity_consistent(
    smiles: Optional[str],
    inchikey: Optional[str] = None,
    formula: Optional[str] = None,
) -> Optional[bool]:
    """Check a structure against the InChIKey and formula stated for it.

    A source states a structure, an InChIKey and a formula, and nothing makes
    them agree: a result can be assembled whose key belongs to another
    compound than its SMILES. This compares the key and formula RDKit derives
    from the structure with the stated ones. The keys must have the same
    skeleton (first block) and protonation flag (last letter): stereo is
    not compared, because a SMILES export routinely loses a double bond's
    or a centre's configuration that the key, made from the molfile, keeps
    (ChEBI's cefodizime), and that is not a different compound. The formula
    must have the same element counts (see
    [`formula_element_counts`][provesid.tools.formula_element_counts]).

    Args:
        smiles: The structure, or None.
        inchikey: The InChIKey stated for it, or None.
        formula: The formula stated for it, or None.

    Returns:
        True when every stated value that can be compared agrees with the
        structure, False when one does not, and None when there is no
        structure or nothing stated to compare it with.

    Examples:
        >>> identity_consistent("CCO", "LFQSCWFLJHTTHZ-UHFFFAOYSA-N", "C2H6O")
        True
        >>> identity_consistent("CCO", formula="C3H8O")
        False
        >>> identity_consistent("C/C=C/C", "IAQRGUVFOMOMEM-UHFFFAOYSA-N")   # stereo is not compared
        True
        >>> identity_consistent(None, "LFQSCWFLJHTTHZ-UHFFFAOYSA-N") is None
        True
    """
    mol = _mol_from_smiles_quietly(smiles)
    if mol is None:
        return None

    verdicts: List[bool] = []
    if not is_missing(inchikey):
        structure_key, stated_key = _inchikey_of(mol) or "", str(inchikey)
        verdicts.append(
            structure_key[:14] == stated_key[:14] and structure_key[-1:] == stated_key[-1:]
        )
    stated_counts = formula_element_counts(formula)
    structure_counts = formula_element_counts(structure_formula(smiles))
    if stated_counts is not None and structure_counts is not None:
        verdicts.append(stated_counts == structure_counts)
    return all(verdicts) if verdicts else None

provesid.utils

Small helpers shared across PROVESID: CAS number checking, telling a standard InChIKey from a non-standard one, and the directories where datasets and cached responses live.

Examples:

>>> from provesid.utils import check_CASRN
>>> check_CASRN("50-78-2"), check_CASRN("50-78-3")
(True, False)

Functions:

check_CASRN(cas_rn)

Check if a string is in the CASRN format and then check if it is a valid CASRN.

The format is three hyphen-separated runs of digits; the check digit is the last, and must equal the sum of the other digits, each weighted by its position from the right, modulo 10. The lengths of the runs are not checked.

Parameters:

Name Type Description Default
cas_rn str

The candidate CAS number.

required

Returns:

Type Description
bool

True when the format is right and the check digit agrees.

Examples:

>>> check_CASRN("50-78-2")
True
>>> check_CASRN("001-16-2")     # a malformed number PubChem lists for aspirin
False
>>> check_CASRN("aspirin")
False
Source code in src/provesid/utils.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def check_CASRN(cas_rn: str):
    """
    Check if a string is in the CASRN format and then check if it is a valid CASRN.

    The format is three hyphen-separated runs of digits; the check digit is the
    last, and must equal the sum of the other digits, each weighted by its
    position from the right, modulo 10. The lengths of the runs are not
    checked.

    Args:
        cas_rn: The candidate CAS number.

    Returns:
        (bool): True when the format is right and the check digit agrees.

    Examples:
        >>> check_CASRN("50-78-2")
        True
        >>> check_CASRN("001-16-2")     # a malformed number PubChem lists for aspirin
        False
        >>> check_CASRN("aspirin")
        False
    """
    # Check if the CASRN has the correct format
    if not _has_casrn_format(cas_rn):
        return False

    # Split the CASRN into its parts
    parts = cas_rn.split("-")
    if len(parts) != 3:
        return False

    # Extract the digits and the check digit
    digits = "".join(parts[:-1])
    check_digit = int(parts[-1])

    # Calculate the check digit
    calculated_check_digit = 0
    for i, digit in enumerate(reversed(digits)):
        calculated_check_digit += (i + 1) * int(digit)

    # Validate the check digit
    return calculated_check_digit % 10 == check_digit

is_standard_inchikey(inchikey)

Tell a standard InChIKey from a non-standard one.

The ninth character of the second block is the flag: S for a key computed from a standard InChI, N for one computed with non-standard options. The two never compare equal, even for the same structure, so a non-standard key cannot be matched against the standard keys that PubChem, ChEBI and ChEMBL publish. CompTox stores non-standard keys for about 11% of its substances and ZeroPM for about 5%.

Parameters:

Name Type Description Default
inchikey

The candidate key.

required

Returns:

Type Description
bool

True for a well-formed standard key; False for a non-standard key, a malformed string, or None.

Examples:

>>> is_standard_inchikey("PGRHXDWITVMQBC-UHFFFAOYSA-N")
True
>>> is_standard_inchikey("PGRHXDWITVMQBC-UHFFFAOYNA-N")
False
>>> is_standard_inchikey("InChIKey=PGRHXDWITVMQBC-UHFFFAOYSA-N")
False
Source code in src/provesid/utils.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def is_standard_inchikey(inchikey) -> bool:
    """Tell a standard InChIKey from a non-standard one.

    The ninth character of the second block is the flag: ``S`` for a key
    computed from a standard InChI, ``N`` for one computed with non-standard
    options. The two never compare equal, even for the same structure, so a
    non-standard key cannot be matched against the standard keys that PubChem,
    ChEBI and ChEMBL publish. CompTox stores non-standard keys for about 11%
    of its substances and ZeroPM for about 5%.

    Args:
        inchikey: The candidate key.

    Returns:
        (bool): True for a well-formed standard key; False for a non-standard
            key, a malformed string, or None.

    Examples:
        >>> is_standard_inchikey("PGRHXDWITVMQBC-UHFFFAOYSA-N")
        True
        >>> is_standard_inchikey("PGRHXDWITVMQBC-UHFFFAOYNA-N")
        False
        >>> is_standard_inchikey("InChIKey=PGRHXDWITVMQBC-UHFFFAOYSA-N")
        False
    """
    return (
        isinstance(inchikey, str)
        and bool(_INCHIKEY_PATTERN.match(inchikey))
        and inchikey[23] == "S"
    )

inchikey_flag_variants(inchikey)

Return an InChIKey and the same key with the other standard flag.

For most non-standard keys the hash blocks are the ones the standard key has, and only the flag differs: 98% of CompTox's and 96% of ZeroPM's. Looking up both spellings finds those rows from either spelling. The rest differ in the stereo hash and are not found this way.

Parameters:

Name Type Description Default
inchikey str

A key to look up.

required

Returns:

Type Description
list

[inchikey, variant], the given key first; or [inchikey] when it is not a well-formed key.

Examples:

>>> inchikey_flag_variants("PGRHXDWITVMQBC-UHFFFAOYSA-N")
['PGRHXDWITVMQBC-UHFFFAOYSA-N', 'PGRHXDWITVMQBC-UHFFFAOYNA-N']
>>> inchikey_flag_variants("not a key")
['not a key']
Source code in src/provesid/utils.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def inchikey_flag_variants(inchikey: str) -> list:
    """Return an InChIKey and the same key with the other standard flag.

    For most non-standard keys the hash blocks are the ones the standard key
    has, and only the flag differs: 98% of CompTox's and 96% of ZeroPM's.
    Looking up both spellings finds those rows from either spelling. The rest
    differ in the stereo hash and are not found this way.

    Args:
        inchikey: A key to look up.

    Returns:
        (list): ``[inchikey, variant]``, the given key first; or
            ``[inchikey]`` when it is not a well-formed key.

    Examples:
        >>> inchikey_flag_variants("PGRHXDWITVMQBC-UHFFFAOYSA-N")
        ['PGRHXDWITVMQBC-UHFFFAOYSA-N', 'PGRHXDWITVMQBC-UHFFFAOYNA-N']
        >>> inchikey_flag_variants("not a key")
        ['not a key']
    """
    if not isinstance(inchikey, str) or not _INCHIKEY_PATTERN.match(inchikey):
        return [inchikey]
    other = "N" if inchikey[23] == "S" else "S"
    return [inchikey, inchikey[:23] + other + inchikey[24:]]

data_path()

Get the path to the data directory shipped inside the package.

This holds the small files that ship with PROVESID (the REACH workbook, the CAS Common Chemistry Swagger file). The large offline databases live under user_dataset_path instead.

Returns:

Type Description
str

Absolute path to provesid/data.

Examples:

>>> os.path.basename(data_path())
'data'
Source code in src/provesid/utils.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def data_path():
    """
    Get the path to the data directory shipped inside the package.

    This holds the small files that ship with PROVESID (the REACH workbook,
    the CAS Common Chemistry Swagger file). The large offline databases live
    under [`user_dataset_path`][provesid.utils.user_dataset_path] instead.

    Returns:
        (str): Absolute path to ``provesid/data``.

    Examples:
        >>> os.path.basename(data_path())
        'data'
    """
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")

user_dataset_path(*parts, ensure_exists=True)

Return the OS-specific persistent dataset directory for PROVESID.

The default root comes from platformdirs and resolves to a per-user data directory that is shared across virtual environments on the same machine.

Power users can override the root directory by setting PROVESID_DATA_DIR.

Parameters:

Name Type Description Default
*parts str

Optional subdirectories appended to the root directory.

()
ensure_exists bool

When True (default), create the directory.

True

Returns:

Type Description
str

Absolute path to the requested dataset directory.

Examples:

>>> user_dataset_path("chebifier", ensure_exists=False).endswith("chebifier")
True
Source code in src/provesid/utils.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def user_dataset_path(*parts: str, ensure_exists: bool = True) -> str:
    """Return the OS-specific persistent dataset directory for PROVESID.

    The default root comes from `platformdirs` and resolves to a
    per-user data directory that is shared across virtual environments
    on the same machine.

    Power users can override the root directory by setting
    ``PROVESID_DATA_DIR``.

    Args:
        *parts: Optional subdirectories appended to the root directory.
        ensure_exists: When True (default), create the directory.

    Returns:
        Absolute path to the requested dataset directory.

    Examples:
        >>> user_dataset_path("chebifier", ensure_exists=False).endswith("chebifier")
        True
    """
    override = os.environ.get("PROVESID_DATA_DIR")
    if override:
        root = os.path.abspath(os.path.expanduser(os.path.expandvars(override)))
    else:
        root = user_data_dir(appname="provesid", appauthor="USEtox")

    target = os.path.join(root, *parts) if parts else root
    if ensure_exists:
        os.makedirs(target, exist_ok=True)
    return target

user_cache_path(*parts, ensure_exists=True)

Return the OS-specific persistent cache directory for PROVESID.

This is where provesid.cache keeps API responses. It is deliberately not the system temp directory: most Linux distributions clear /tmp on boot, which silently threw away every cached response between sessions even though the caching layer advertises itself as persistent. The root comes from platformdirs and resolves to a per-user cache directory shared across virtual environments on the same machine.

Cached responses are disposable --- unlike the datasets under user_dataset_path, everything here can be re-fetched --- which is why the two live under different roots and can be cleaned independently.

Power users can override the root directory by setting PROVESID_CACHE_DIR.

Parameters:

Name Type Description Default
*parts str

Optional subdirectories appended to the root directory, e.g. the service name.

()
ensure_exists bool

When True (default), create the directory.

True

Returns:

Type Description
str

Absolute path to the requested cache directory.

Examples:

>>> user_cache_path("pubchem", ensure_exists=False).endswith("pubchem")
True
Source code in src/provesid/utils.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def user_cache_path(*parts: str, ensure_exists: bool = True) -> str:
    """Return the OS-specific persistent cache directory for PROVESID.

    This is where [`provesid.cache`][provesid.cache] keeps API responses. It is
    deliberately *not* the system temp directory: most Linux distributions
    clear ``/tmp`` on boot, which silently threw away every cached response
    between sessions even though the caching layer advertises itself as
    persistent. The root comes from `platformdirs` and resolves to a per-user
    cache directory shared across virtual environments on the same machine.

    Cached responses are disposable --- unlike the datasets under
    [`user_dataset_path`][provesid.utils.user_dataset_path], everything here
    can be re-fetched --- which is why the two live under different roots and
    can be cleaned independently.

    Power users can override the root directory by setting
    ``PROVESID_CACHE_DIR``.

    Args:
        *parts: Optional subdirectories appended to the root directory, e.g.
            the service name.
        ensure_exists: When True (default), create the directory.

    Returns:
        Absolute path to the requested cache directory.

    Examples:
        >>> user_cache_path("pubchem", ensure_exists=False).endswith("pubchem")
        True
    """
    override = os.environ.get("PROVESID_CACHE_DIR")
    if override:
        root = os.path.abspath(os.path.expanduser(os.path.expandvars(override)))
    else:
        root = user_cache_dir(appname="provesid", appauthor="USEtox")

    target = os.path.join(root, *parts) if parts else root
    if ensure_exists:
        os.makedirs(target, exist_ok=True)
    return target