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.

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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
160
161
162
163
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
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
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
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
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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
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
299
300
301
302
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
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
331
332
333
334
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
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
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
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

make_candidate(source, *, name=None, iupac_name=None, molecular_formula=None, smiles=None, inchi=None, inchikey=None, dtxsid=None, molecular_mass=None, synonyms=None, cas_candidates=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.

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
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

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 and CAS_candidates.

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'
Source code in src/provesid/tools.py
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
def make_candidate(
    source: str,
    *,
    name: Optional[str] = None,
    iupac_name: Optional[str] = None,
    molecular_formula: Optional[str] = None,
    smiles: 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,
) -> 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].

    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.
        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.

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

    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'
    """
    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 [])),
    }

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.

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
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
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.

    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_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:
            score += 4.0

    left_ik = left.get("InChIKey")
    right_ik = right.get("InChIKey")
    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
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
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
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
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.

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
687
688
689
690
691
692
693
694
695
696
697
698
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
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].

    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

    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.get("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 not is_missing(candidate.get("SMILES")):
        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.

Parameters:

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

Source key to candidate record. Entries whose value is None 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 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)
Source code in src/provesid/tools.py
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
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.

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

    Returns:
        A tuple of:

        - the winning source key, or None when no 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)
    """
    valid = {k: v for k, v in candidates.items() if v is not None}
    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 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
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
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 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"),
        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
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
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
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
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
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
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
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
 951
 952
 953
 954
 955
 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
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
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
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
1052
1053
1054
1055
1056
1057
1058
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
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
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
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
1136
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
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
1173
1174
1175
1176
1177
1178
1179
1180
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

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